JS极简RPC实现

项目地址 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拦截了getset方法,将对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能驾驭。

我们也为阿里云函数计算设计了类似的服务端,非常简单易用!

你可能感兴趣的:(rpc,javascript,前端)