[date: 2018-12-04 21:26] [visits: 3]

实简单现websocket信道服务

开端

之前有为一个项目做顾问工作,帮助解决了几个问题。该项目完全按照wafer解决方案做的,我在解决问题过程中,发现wafer信道容易出现消息丢失情况,当时由于金主预算有限,没有从根本上解决这个问题,只是做了些曲线救国的优化,然后继续将就着用。与此同时腾讯官方表态wafer整套解决方案都不再提倡,同时不再维护,希望开发者往小程序“云开发”的方向靠拢。

直到有一天,wafer信道停止了服务,导致整个项目瘫住,金主没辙了,又找到了我...

可选方案

为了生计,来活了,自己当然是考虑的,所以为金主想了两个方案:重写信道服务与重写项目。

重写信道服务

wafer不再维护,但原有的功能可以继续使用,只是不再提供信道服务,如果要让整个项目“复活”,可以选择再实现一个信道服务用来替代曾经的免费信道服务。

这个方案的优点是只需要专心解决技术问题,对项目原有业务的影响几乎为0,对金主而言成本一般。

但从开发的角度来看,缺点也比较明显:“需要严格适配已有的SDK”,wafer信道的参与方是server、tunnel-server与client,这其中与tunnel-server通信的双方都依赖wafer提供的SDK。要做到不影响业务代码,需严格适配这两个SDK,也就是要去仔细阅读SDK源码,然后定义tunnel-server的相关接口和参数,这对我而言并不是一件愉快的事。

重写项目

只是重新实现信道服务,金主担心不稳定,所以就咨询了一下重写的成本。我的评估是相差不大,因为重写,信道服务由自己定接口相较于适配SDK会快很多,而且整个项目的业务并不复杂,从零实现也比较快。

这里我个人也偏向于重写,因为老的代码质量我个人觉得很不靠谱,而且设计也不合理。比如对战匹配队列、用户对战信息等,竟然全放在内存中,这意味着无法部署多个实例,要知道金主的单机可是8核16G。

这里怕有一些新手不太理解,再详细解释一下,假设我们把匹配队列放在内存里面:

匹配队列const queueList = [];,第一个用户A来排队,往queueList中push这个用户的ID,又一个用户B来排队,发现queueList中已经有一个用户了,取出来然后通知他们(A-B)匹配成功

咋一看没什么问题,其实不然:

内存是的scope是进程,而Node.js是单线程,scope变相成了线程,一个线程只能运行在CPU的一个核上,像queueList这种数据限制了业务同时启动多个进程实例(启多个进程实例,用户请求可能落在不同进程上,上面的A-B就不一定能匹配成功),所以最后只能启一个进程,最多能利用1/8的CPU资源

资源浪费不是最致命的,最致命的是无法横向扩展,应用承压的时候就只能干瞪眼。这个基础设计层面的坑以及代码本身的质量让我时刻惦记着重写,所以最终也说服金主选择了重写这个项目。

设计

在决定重写之后,对websocket信道通信这块有想过两个方案:

信道通信功能与业务服务作为一个整体

最初是倾向这个方案,因为效率更高,业务模块可以直接通过socket给client推送消息,但有一个致命问题没找到好的解决办法:“用于通信的socket只能在内存中持有,这让应用服务是有状态的”。针对这个问题有想过在request的url中携带用户ID然后通过nginx路由,但觉得会复杂度上升太多,故放弃。

这里同样解释一下上述提到应用是有状态的含义:

在Node.js中,借助websocket库实现websocket通信,其原理是server与client建立连接后,server持有一个该连接对应的socket引用,然后通过该socket与客户端进行通信。这个socket引用只能在内存中使用,与前面的queueList例子类似,如果A用户的某些消息要通知给B,如何能找到B用户对应的socket?如果只有一个进程,提前在内存中记录好映射关系还可以找到,但如果是多个进程,就不好办了,这时我们称服务是有状态的,因为用户跟进程有绑定关系

模仿wafer这种设计,将信道通信功能剥离成独立的服务

因为信道通信功能与业务服务作为一个整体,自己没找到很好的解决应用服务有状态问题的方案,所以考虑将信道通信功能作为独立的服务剥离。

剥离成独立的服务之后,应用服务继续保持无状态,信道服务来处理状态问题,我的做法是:

信道服务设计一个节点(node)的概念,每一个节点只启动一个实例,信道服务的使用者从任意节点获取连接URL('wss://xxx/{node}?id={tunnelId}')后,自己维护tunnelId与node的对应关系,向信道推送消息时携带节点信息,比如在http请求的URL中包含节点信息('http://xxx/{node}/send-message?id={tunnelId}'),这个请求通过nginx路由转发到与之匹配的信道节点,从而保证该节点持有与client通信的socket

这样设计之后,信道服务器可以通过启动多个节点实现水平扩展,暂时不会成为单点瓶颈。目前设计中节点信息是单独体现,应用服务需要维护tunnelId与node的映射,而如果将node信息融入tunnelId中的话,可以节省这一步,不过应用服务本身需要维护用户与tunnelId的对应关系,顺便一起维护node的信息非常容易,也就无所谓了。

实现

实现并不复杂,信道服务对外提供四个接口:

监听host:port,启动websocket服务器,等待连接

获取一个信道的连接URL,同时提供回调信息用于信道收到客户端消息后通知下游,用RPC框架或者用HTTP通知均可,目前自己是用框架内的HTTP-RPC

这个接口用于通过信道发送消息给客户端,是业务服务器调用,调用方式采用HTTP

同sendMessageById,只不过是关闭连接时使用罢了

上述4个接口,除了init其他三个对外表现为HTTP请求的方式,也就是业务服务器通过HTTP与信道服务器交互,我认为这里降低了效率,但目前影响不大,等到需要降低通信延迟,提高效率的时候再单独重构交互方式即可。

这4个接口,自己在框架下实现起来并不费劲,共300行代码左右,其中核心代码放在Gist上,有兴趣的朋友可以看看。

后记

最近有段时间没更新博客,主要因为太忙,且不知道应该写什么样的内容,觉得只是记录自己遇到的问题与如何解决的话就过于流水,况且有深度的问题本身就少。有时侯想分享自己做项目的经验,又感觉内容宽泛,不知道从何写起。还是慢慢平衡,尽量写写,坚持才是胜利,毕竟写博客这事是对自己的大利好。