发布于 

Socket.io 踩坑记录

最近由于项目需要接触了 socket.io 这一套工具,它本身是一个 Node.js 的包,使用 long polling 或者 websocket 的方式(在我的项目中我是用的是 websocket)提供持续的网络连接服务。由于简单好用(虽然我在知乎上看到有人说这玩意儿就是给小白玩的,2333),很多开发者为其开发了不同语言的 SDK,比如 Java、C++、Go 等,然后我项目的前端用的 Node.js,后端用 Golang。由于 socket.io 本身的设计以及 Go 版本开源库的残缺,踩了很多坑,我决定在这篇博客中好好总结一下。

跨域请求的问题

由于 go-socket.io 是基于 http 服务器的,在建立 websocket 连接的时候会先发送一个 get 请求,由于请求是跨域的,所以理所应当会被服务器给拦下来,然后返回一个 403,所以首先得处理跨域的问题。

处理这个问题有很多种方式。

第一种方式是直接拿到请求后把 Header 里的 Origin 字段干掉,然后再交给接下来的 http 处理逻辑处理。第二种方式是添加一个中间件来设置 Header 中的允许跨域字段,这两种方式在这个 issue 中都有实现方法: https://github.com/googollee/go-socket.io/issues/242

第三种方式是社区开发者们新提供的方式,就是在启动 socket.io server 的 option 中指定允许的域名,这算是最合适的一种方法了吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
allowOrigin := func(r *http.Request) bool {
return true
}
server, err := socketio.NewServer(&engineio.Options{
Transports: []transport.Transport{
&polling.Transport{
Client: &http.Client{
Timeout: time.Minute,
},
CheckOrigin: allowOrigin,
},
&websocket.Transport{
CheckOrigin: allowOrigin,
},
},
})

更多信息可以查看这个 issue:https://github.com/googollee/go-socket.io/issues/372

go-socket.io 与 socket.io 的兼容性问题

在前端,我一开始使用的是 3.0 版本的 socket.io。结果发现建立连接的握手请求前后端对应不上,后端触发两次 connect 事件,之后也无法进行正常的双工通信,后来发现原来 go 这个库还不支持 2.0 版本及以上的 socket.io。这其中应该是通信协议上不一样。

这个项目其实很早就已经搁置了,owner 在几年前就撒手不管了,这几年一直是靠开源社区的开发者们来维护,所以这个项目的进度迟迟得不到更新。目前这个库只支持 socket.io@2.0 之前的版本,不支持 2.0。

如果实在要与 socket.io 2.0 进行对接,可以选用其他的语言进行开发,或者使用其他的几个 go 开源库,虽然这几个 star 不多,我也没用过。

详情可见这个 issue:https://github.com/googollee/go-socket.io/issues/188

之后我也打算看看这个项目,看看能不能贡献点什么。

分布式场景下的问题

当我把我的后端服务部署到了集群上,我发现有些时候客户端收不到本应收到的消息。经过一系列的分析查找,我发现原来是因为不只有一个实例部署了我的服务,然后所有访问服务的请求都会被 nginx 网关按照一定策略进行负载均衡,然后再分配到某个实例上。又因为我将所有的 socket 连接上下文都存到了内存中,每次都从内存中提取会议中的连接者,再将消息发给他们,由于负载均衡的存在,同一个会议的连接可能被导向不同的实例,这种情况下一个连接生产的信息就无法转发给另一个连接。

为解决这个问题可以从两方面入手。第一种是从负载均衡入手,nginx 的负载均衡策略有很多种,比如对 ip 进行 hash,或者对请求头中的某些信息进行 hash,然后将请求导向不同实例,我们可以更改策略将同一个会议的连接全部导向一个实例;第二种是使用消息中间件,将一个连接产生的消息推入消息队列中,然后另一个连接从中读取。

经过多方面的考量,我最终选用了更改 nginx 负载均衡策略的方式,因为公司的各个消息中间件组件都有很大的局限性,下面我也会说说各种方案我是怎么想的。

负载均衡方案

socket.io 建立连接时会发送一个 GET 请求,然后开始进行握手,而我们可以自定义这个请求。但由于 socket.io 为了遵循 websocket 协议,当使用 websocket 协议传输时,我们没办法更改请求的 Header。但是我们仍然可以为请求添加 query 参数,比如我们可以让同一个会议的连接者请求时都在 query 中带上一个相同的标识,然后再将 nginx 的负载均衡策略改为只按照请求 uri 进行 hash,这样便可以将请求 uri 相同的所有连接都导向同一个实例。

不过这种方案可能会出现热点问题,那就等之后真的出现了再说吧。(不过我应该是等不到那个时候了)

消息中间件方案

Redis 消息队列

其实 Redis 自己也实现了有消息队列功能的接口。消费者可以 Subscribe 一个 Channel,当生产者向 Channel 中推消息的时候,消费者便可以从此 Channel 中拿出消息。不过不幸的是,我们公司的 Redis 并没有支持这一功能,而且公司貌似禁用任何阻塞操作,遂作罢。另外,消费者是不是共同消费 Channel 中的消息,还是一个消费者一个副本,这个我也没有深入研究。

利用 Redis 缓存自行实现

这是一个临时得不能再临时的方案,肯定是无法投入大规模使用的。这个方案简单来说,就是在 Redis 中保存有到某个会议连接的实例,生产者会为每一个实例生产一份消息,然后每一个实例取出自己的一份,在发给连接到自己的连接者。这个方案在逻辑上是很完美的,但是会极其消耗资源。首先,我的一个消息大概会有 8KB,这对于 Redis 来说本身就很离谱了;其次,要从 Redis 中拿出实时的消息,需要随时轮询 Redis,因为公司的 Redis 不提供阻塞操作,而不停的轮询是极其浪费资源的。处于这样的考虑,我还是暂时没选择这样的方案。

RocketMQ 等消息队列

按理来说使用传统的消息队列是最靠谱的方案,生产者将消息推入消息队列,然后每一个订阅了的消费者都可以从中拿到消息。但是公司的消息队列组件看起来并不适合我们这业务场景。公司的 Kafka 主要是为离线业务服务,它会将消息转存到 HDFS,并不太适合我这样的实时在线业务。于是我选择了公司推荐的支持在线业务的 RocketMQ看一看。

其实 RocketMQ 是支持广播消费的(就是生产者产生的每一个消息每一个订阅了的消费者都能消费到,与之对应的是集群消费,就是一个消费者消费了其他消费者就消费不到了),但是公司的 RocketMQ 组建尚未支持。那我就又只能爬了。其实消费组这一概念也可以实现这样的机制,但是公司的消息队列服务都是公用的,每一个 Topic 的 Consume Group 都需要单独申请,我不可能为每一个实例都手动申请一个,因为实例是弹性变化的。遂作罢。

不过还是有一个方案的,就是自建一个消息队列服务,只给自己用,这样上面的问题就都迎刃而解了。不过这得以后有时间再来慢慢研究。。。

写在最后的吐槽与总结之类的话

go-socket.io 这个库其实还有一些其他问题,比如并没有保证连接的线程安全,之后我也想仔细看看它的源码以及 socket.io 的源码与原理,希望能帮这个开源项目做一点优化和增强工作,也好填补一下之后漫长寒假的空虚(到时候可能就每天躺尸了)

对于我接手的这个项目,虽然开发规模不大,主要逻辑也非常简单,比起我们的主要业务来说那就是小玩具,工程量和大作业差不多,但是也让我感受到了开发一个企业级应用需要考虑的各种事情,并且积累的挺多开发经验(虽然大部分是 Node.JS 和 TS 的经验)。

最后不得不说,开源真是个好东西,节约了大量开发成本,再次感谢各位开源大佬。