前置知识

http 响应格式

第一行是状态行,比如说响应 200 就是

HTTP/1.1 200 OK

不空行,后面是响应头

Connection: keep-alive
Transfer-Encoding: chunked
......

空一行,后面是响应体。

什么是 chunked

分块传输 Transfer-Encoding: chunked 表示这个请求返回的数据较大,服务端会边生成数据边传输,这就可以用来实现服务端主动给客户端推送消息。

编码格式为

响应行
响应头
(空行)
响应体

其中响应体的编码格式为

(假设响应数据为字符串s)
fmt.Sprintf("%x\r\n",len(s))
fmt.Sprintf("%s\r\n",s)

每段响应体的第一行是响应数据比特流长度的 16 进制表示,第二行是响应数据本体。

开整

vite 关闭连接 Connection: closed

项目地址在这里

废话少说,操着 IntelliJ IDEA 就开始撸码。

后端使用的是 GoFrame,先给请求头加上 Transfer-Encoding: chunked,然后把 Response 对象保存起来以便服务端主动给客户端发消息。

前端使用 Vue3+TypeScript 开发,用 vite 做开发服务器并配好跨域。由于 chunked 连接在接受消息的时候 XMLHttpRequest( 后简称为 xhr) 的 readyState 属性并没有跳到 4,而是一直触发 3,所以并不能用 axios 来请求,只能自己封装一个。

意想不到的事情发生了,连接被关掉了。客户端和服务端谁关的连接?没看到响应头有 chunked 嘛?在浏览器查看响应头的 Connection 值为 closed,而请求头是 Connection: keep-aliveGoFrame 是不是你?

我于是开始了漫长的逮 bug 之旅。经过无数次在源码打断点、log 信息之后得出结论:Connection 在进入后端的时候就已经为 closed。麻了,关连接的既不是服务端也不是客户端,而是 vite

烦内,先睡觉了。

确定是 vite 在代理的时候会把连接关闭,于是不用 vite 做跨域了,设置 xhr.withCredentials=true,话说我这是简单请求啊,前端不需要加跨域。

好,这次响应头有 Connection: keep-alive 了。不过断连接的问题还是没有解决。于是又开始漫长的翻 GoFrame 源码,试图找到 connection.Close() 之类的蛛丝马迹。

然后没找到。

于是开始必应。

Connection: keep-alive,但是服务端关闭连接

我的好必应,让我找到了用 gin 实现 chunked 通信的示例代码,我又充满了决心。

gin 实践了一下确实可行,正当我想换框架的时候我一个突发奇想,开一个协程继续往 Response 里写数据,看前端会不会响应。

然后连接又又被关了。在 handler 函数返回之后,连接就关了。那应该不是框架的问题。于是又开始必应。

终于,在 github goframe issue 里面找到了 chunked 通信的示例代码,原来要拿到底层的 ResponseWriter。蹂躏了一番键盘,出现了和 gin 一样的问题。所以我的方向错了,问题应该出在比框架还要底层的东西。

遇到困难睡大觉。

使用 Hijack 解决

新一天,上课的时候无聊看 go 语言标准库文档,看到 net/http 库里有一个 type Hijacker,我又充满了决心。

这个 Hijack() 函数好几把难用,也真踏马刺激。直接让操作者接管连接,调用的这个方法后框架就不会对连接进行操作,就连调用框架封装好的 Writer 也直接报错。

行吧,现在知道开头为啥要讲 http 响应的编码格式了吧,我还得手搓 http 响应。

由于接管了连接,跨域也得手动搓:

access-control-allow-credentials: true
access-control-allow-headers: ......
access-control-allow-methods: GET,PUT,POST,DELETE,OPTIONS
access-control-allow-origin: request.Header.Get("Origin")

然后保存,这次连接不会断了,除非调用 connection.Close() 方法。

不足之处

关于何时断开连接

只用 GoFrame 也不是不能做,就是有点麻烦,首先得开一个计时的协程,time 库有封装好的,然后监听前端的心跳,如果预定的时间过后没收到心跳就断开连接。问题是有多少个连接就要开多少个协程,虽然协程的消耗非常少,但还是不想这么做。

于是就想到了 Redis,可以监听 Redis 的键过期事件来断开对应的连接。缺点是还要额外开一个 Redis,还是有点麻烦。于是就偷懒在前端加了一个退出的按钮来关闭连接。

关于性能

我也不知道如何测试 chunkedwebsocket 的性能,哪天再必应一下吧。

2022-04-26

追加#1

客户端增加,消息变多的时候响应体会变得非常大,可能会增大处理的负担。实测开三个浏览器(Edge,Firefox,Chrome)分别连续发 6.56 MB 长度的消息,服务端占用内存最高到 150 MB,倒是浏览器先撑不住,各占用了差不多 2 GB 的内存。

解决方案是接收到分段响应后判断响应体长度,如果长度超过阈值则退出登录再重新请求一次。

2022-05-05