项目地址 github.com/yzITI/srpc
在web应用当中,前后端的数据交换是必不可少的环节。常用的方案有Restful API接口或者GraphQL接口等,但是在特别小的微应用或者demo中,为了几个数据交换过程去设计和实现一整套接口很不经济划算。
另一种数据交换方式是RPC(Remote Procedure Call),简单来说就是通过网络来调用远程的逻辑片段(函数)。RPC通常被应用在大型的应用,或者基于WebSocket的双向通信上,辅以预设的数据结构和通信schema。然而,这也与我们迅速开发小型应用的需求背道而驰。因此,我们设计并实现了一种基于http请求的极简RPC库srpc,让开发者无感知地完成前后端数据交换。
首先看一看实现的效果。在服务的提供端(后端Nodejs),我们可以导出若干个函数给前端使用:
const srpc = require('./node-srpc.js')
srpc() // listen on port 2333 by default
// the following methods are exported
srpc.test = () => 'Hello, world!'
srpc.add = (x, y) => x + y
// function can be nested!
srpc.calc = {}
srpc.calc.sqrt = x => Math.sqrt(x)
首先引入srpc
,它本身作为函数,在运行时会启动一个http服务端,监听来自前端的调用请求。在srpc
上直接添加成员函数,即可将对应的函数导出。还支持使用嵌套的对象来组织函数。
那么后端函数写好了,前端怎么调用呢?答案是直接调用!
import srpc from './srpc.js'
// initialize with endpoint
srpc('http://localhost:2333/')
// just call the functions!
srpc.test() // Promise -> 'Hello, world!'
srpc.add(1, 2) // Promise -> 3
srpc.calc.sqrt(2) // Promise -> 1.4142135623730951
在初始化(指定后端地址)以后,我们就可以直接调用后端导出的函数了,就好像后端和前端在共用一个srpc
对象一样!当然,因为实际调用是通过网络请求完成的,所以前端调用时任何函数都会返回一个Promise
对象。
使用srpc
时,开发者对网络请求毫无感知,神奇的srpc
对象就好像是自动沟通了前端的函数调用和后端的函数定义一样。
下面,让我们看看这个神奇的srpc
是如何实现的。
调用请求是通过http协议传输的,使用了POST
方法。
简而言之,调用请求包含函数名和参数列表两个信息,被打包成JSON格式,通过http请求发给后端。
后端收到请求以后,根据函数名选择正确的函数,将参数传入函数,并将函数的计算结果(返回值)打包为JSON格式,作为http的相应发给前端。
显而易见,这也带来了srpc
的最大限制:参数和返回值必须可以被编码为JSON格式。
其实代码并不长,这里简单讲解一些主要的实现:
const functions = {}
// ...
module.exports = new Proxy((port = 2333) => {
const server = http.createServer(listener)
return new Promise(r => { server.listen(port, r) })
}, {
get: (target, prop) => functions[prop],
set: (target, prop, value) => functions[prop] = value
})
最重要的部分是Proxy。Proxy是一种JS对象,它可以帮助我们拦截对一个对象的读写操作。
整个模块的导出就是一个Proxy。这个Proxy指向的实际对象是启动服务端的函数,因此当我们调用srpc()
时,它就会启动一个服务端用来监听http请求。
那么,在srpc
上直接设置导出函数是怎么实现的呢?我们看到,这个Proxy拦截了get
和set
方法,将对srpc
对象的读写操作都转化到了另一个对象functions
身上。看似我们在读写srpc
的属性,其实被Proxy暗渡陈仓将读写都转移到了functions
身上。在实际调用的时候,handle
函数会根据请求中的函数名,在functions
中寻找对应的函数并执行。
let f = functions
try { // find function
const ns = body._.split('.')
for (const n of ns) f = f[n]
if (typeof f !== 'function') throw 1
} catch { return ['Function Not Found', 404] }
在handle
函数当中,调用请求中的函数名(body._
)会被首先按照.
分割切断,随后逐级查询functions
对象,并在最后检查获得的值是否为函数。如果成功找到了这个函数,它就会被执行,并将其返回结果作为http相应传回前端。
前端也使用了Proxy,不过要做成链式结构就有些复杂了。
let url = '/'
// ...
export default new Proxy((endpoint = '/') => url = endpoint, { get: proxyGet })
首先,整个模块的导出与后端类似,都是一个封装了初始化函数的Proxy。这样,我们就可以使用srpc(endpoint)
来初始化这个模块,指定后端的地址。
其次,由于我们不需要修改前端的srpc
对象,所以我们只拦截了读取操作。下面我们来仔细研究一下这个proxyGet
函数。
const proxyGet = (target, prop) => {
const key = target.key, newKey = key ? key + '.' + prop : prop, f = getFunction(newKey)
f.key = newKey
return new Proxy(f, { get: proxyGet })
}
让我们用两个例子来说明这个proxyGet
函数的运作原理。
srpc.test()
当我们调用srpc.test()
时,读取srpc.test
的操作被proxyGet
拦截,此时,
key = undefined
newKey = 'test'
f
是通过助手函数getFunction
根据newKey
生成的函数,本质上是一个封装好的发送请求的函数,在调用时会将传入的参数和newKey
一起打包为JSON发给后端,并在后端响应后解析相应的结果。
随后,f
上被附加了一个属性f.key = 'test'
(JS里面函数也是对象,当然可以设置它的属性),并将其封装为一个新的Proxy返回。请注意,这个新的Proxy的拦截函数也是proxyGet
本身!
srpc.test
是一个Proxy,此时我们直接调用了它,就如同调用了f
函数,自然地一个请求就被发送了出去,包含了函数名(根据newKey
生成的)和参数列表。
srpc.calc.sqrt(2)
嵌套的调用就会稍微复杂一些。首先,读取srpc.calc
的操作被proxyGet
拦截,此时,
key = undefined
newKey = 'calc'
与上一个例子一样,proxyGet
返回了一个新的Proxy,封装了一个调用calc
函数的f
,并具有属性f.key = 'calc'
。
这一次,我们并不直接调用这个函数,而是继续读取它的一个属性sqrt
,这个读取操作会再一次被新的Proxy中的proxyGet
拦截。此时,
key = 'calc'
newKey = 'calc.sqrt'
同理,一个新的函数f
会被生成,用于调用函数calc.sqrt
,同时它也会被封装为一个新的Proxy用来支持进一步的链式调用。这一次,它真的被调用了:srpc.calc.sqrt(2)
。所以这个新的f
被实际执行,函数名称calc.sqrt
和参数表被传递到后端…
基本的思想是,在读取一个属性的时候生成一个可以发送请求的调用函数f
,但同时将其封装在Proxy中,以支持嵌套调用。
这个极简的RPC框架目前只适用于JS环境,因为它实在太过灵活,没有任何Schema等约束,也就JS能驾驭。
我们也为阿里云函数计算设计了类似的服务端,非常简单易用!