前置知识
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-alive
,GoFrame
是不是你?
我于是开始了漫长的逮 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
,还是有点麻烦。于是就偷懒在前端加了一个退出的按钮来关闭连接。
关于性能
我也不知道如何测试 chunked
和 websocket
的性能,哪天再必应一下吧。
2022-04-26
追加#1
客户端增加,消息变多的时候响应体会变得非常大,可能会增大处理的负担。实测开三个浏览器(Edge,Firefox,Chrome)分别连续发 6.56 MB 长度的消息,服务端占用内存最高到 150 MB,倒是浏览器先撑不住,各占用了差不多 2 GB 的内存。
解决方案是接收到分段响应后判断响应体长度,如果长度超过阈值则退出登录再重新请求一次。
2022-05-05