学习写一个 B/S 架构的聊天室,后端采用 Golang,前端轻度使用 React.js。
0x00 WebSocket
WebSocket 是 HTML5 中新增的协议,基于传统的 HTTP。
由于传统 HTTP 是“请求-响应”协议,无客户端请求则无服务端响应,服务器无法向浏览器主动发送数据。当年 Flash 插件倒是解决了这一问题。其实 HTTP 本身也可以解决,但是思路非常笨重:
- 轮询。在浏览器设置 JavaScript 的定时器,按照指定频次向服务端询问是否有新消息,此时就需要严格考量这个“频次”的具体值了,过小则导致服务器不堪重负,过大则导致信息更新不及时。
- 轮询的变种——Comet。与普通轮询相似,但在没有消息更新时,服务器会挂起这一方的请求(假设客户端是对应服务端的一个线程,就是挂起一个线程),等有更新了再响应;然而实际上大部分线程在大部分存活时间内都是挂起状态,又是浪费服务器资源。此外,一个 HTTP 长连接长时间没有数据传输的情况下,链路上的任意网关都有权关闭这个连接,所以还要定期发送一些 ICMP 包表示存活……
通过建立套接字连接,比如本项目中使用的 TCP 套接字,就能根本上解决上述问题。客户端只需要维护一个建立好的 Socket,监听上面传递的信息就能获得及时更新;服务端也只需要维护好和所有客户端建立的这些套接字即可,没有过多的握手挥手,也不需要应对海量的 Ping(如果真的有那种设计)。
建立 WebSocket 连接必须由浏览器(客户端)发起,格式和普通 HTTP 相似。
GET ws://localhost:9527/ws/test HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: http://localhost:9527
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13
有几点不同:
- 协议头
ws://
,而不是某个路径 -
Upgrade
和Connection
告知服务器,这个连接将会“升级”为 WebSocket 连接 -
Sec-WebSocket-Key
起标识这个连接的作用 -
Sec-WebSocket-Version
写明 WebSocket 版本
服务器如果能够接受,就会响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: server-random-string
建立完成后,双方互相理解能够解析的数据格式,可以传递二进制或者文本数据了。
WebSocket 的全双工继承自 TCP,而 HTTP 是因为协议自身设计限制了全双工。
WebSocket 也可以通过 HTTPS 升级,协议头变成wss://
,底层就是 SSL/TLS。
0x01 雏形
服务端 backend
使用 Golang 自带的net/http
库就能实现最简单的服务器:07bd210(代码均通过 GitHub Commits 给出)。
修改 main.go
WebSocket 采用了第三方库github.com/gorilla/websocket
。增加代码,实现 WebSocket 的服务端:4af4abc。
测试 WebSocket 建立:
客户端 frontend
前端使用 facebook/create-react-app 快速搭建:
$ cd frontend
$ npm install -g create-react-app
$ npx create-react-app .
进入前端项目,不管预置静态文件,先实现前端原始功能:44de094。
新建 src/api/index.js
实现客户端建立 WebSocket 连接的逻辑。定义了两个函数connect()
和sendMsg(msg)
,分别实现建立套接字和向套接字发送数据。
修改 src/App.js
修改代码,实现通过点击按钮发送消息的基本功能。
此时分别运行前后端(前端是开发模式),可以得到点击以发送信息的简单功能页面。
点击后可以在后端看到信息:
浏览器控制台也有相应信息:
至此完成了聊天室的基本结构。
0x02 前端设计
现在流行的前端框架都流行将页面功能划分为各个组件(components),React.js 也不例外。
标题 Header
首先写个最简单的 Header,让页面具有标题,这是页面的基本元素:35d489f。
新建 src/components/Header/Header.jsx
编写渲染页面标题的函数。
新建 src/components/Header/Header.scss
定义标题的样式。React.js 项目似乎不会自动解析 .scss 文件,故需要在项目中加入 node-sass:
npm add node-sass
或者
yarn add node-sass
新建 src/components/Header/index.js
用于导出 Header,便于其它组件在自己的渲染函数render()
中引入。
修改 src/App.js
在render()
中添加<Header />
即可。
聊天记录 ChatHistory
到目前为止,用户还无法从页面获得任何信息,所以下一步是编写关于聊天记录的组件:c7cc871。
新建 src/components/ChatHistory/ChatHistory.jsx
定义了一个ChatHistory
类,其中的render()
函数会返回希望为这个组件渲染的 .jsx 文件。这里会从 App.js 获取数组,然后逐个渲染。这里的this.props.chatHistory
将在 App.js 中新定义。
新建 src/components/ChatHistory/ChatHistory.scss
用于定义历史记录的样式。
新建 src/components/ChatHistory/index.js
用于导出。
在完成这些新建后,继续修改原有代码。
修改 src/api/index.js
增加回调,只要接收到新信息,就会产生回调。
修改 src/App.js
constructor
中新增历史消息的状态,也把connect()
移除。
constructor(props) {
super(props);
this.state = {
chatHistory: [],
};
}
被移出的connect()
现在位于新增的componentDidMount()
中,成为共享组件生命周期的一部分。
然后再在render()
中新增<ChatHistory chatHistory={this.state.chatHistory} />
组件。
现在,运行前后端。用户点击发送的消息会通过 WebSocket 进入后端,再通过后端返回给前端(后端还只是个 echo 服务器)并渲染,完成了历史记录的功能。
0x03 后端多用户处理
现在已经完成了对单个用户实现基于 WebSocket 的 echo 服务器,但和最终效果还存在很大差距。这个项目中,前端一切从简,复杂工作全部交给后端。后端待实现的功能有:
- 实现一个连接池机制,允许管理者跟踪当前有多少个活跃的 WebSocket 连接;
- 对连接池内的所有客户端广播聊天消息;
- 对连接池内的所有客户端广播有用户加入或退出。
调整项目结构
main.go 应当尽可能简单,因此需要先将现有代码按照规范搬入一个包中。Go 有常用的项目规范。
将现有代码搬入 pkg/websocket:72ae7df。
新建 pkg/websocket/websocket.go
实现从传统 HTTP 升级、读、写功能(暂时还是只有 echo 功能)。
修改 main.go
现在只剩下 /ws 路由的函数。
处理多用户
对于每个并发的连接,各开启一个goroutine
,当然还需要关注是否做到了线程安全。
可以使用sync.Mutex
或者channels
来保证数据不会在被修改的同时被访问。本项目中,channels
更适合完成这个任务。
后端终版:74f8812。
新建 pkg/websocket/client.go
每个用户的结构体包含:
-
ID
:用以标明某个具体的连接 -
Conn
:对websocket.Conn
的指针 -
Pool
:对客户端所在连接池的指针
另定义一个Read()
方法持续监听来自 WebSocket 连接的信息。只要有信息,Read()
就会将信息传递到连接池的Broadcast
(是个channel
)。Broadcast
中的信息会对连接池的所有客户端广播。
新建 pkg/websocket/pool.go
我们需要确保 WebSocket 连接中只有一方具有写功能,否则又要处理额外的并发写问题。
定义Start()
监听连接池的所有channels
,并对到来的信息分别处理:
-
Register
:当有新客户端连接后,向所有客户端发送“New User Joined” -
Unregister
:让客户端下线,并告知连接池 -
Client
:另外给予客户端 active/inactive 状态,用以表示客户端浏览器是否获得焦点的状态 -
Broadcast
:用以广播信息,最频繁使用的channel
修改 pkg/websocket/websocket.go
不再需要在此处完成读写。
修改 main.go
相应添加Register
功能,/ws 路由函数添加新建连接池的代码。
0x04 前端完善
输入 ChatInput
开放前端输入:ca7e895。
新建 src/components/ChatInput/ChatInput.jsx
用于存放输入。
新建 src/components/ChatInput/ChatInput.scss
用于定义样式。
新建 src/components/ChatInput/index.js
用于导出。
修改 App.js
添加输入组件,并且修改send()
,变成回车发送。
正确渲染 Message
将 JSON 正确渲染:8a17ae4。
新建 src/components/Message/Message.jsx
用于存放历史记录。
新建 src/components/Message/Message.scss
用于定义样式。
新建 src/components/Message/index.js
用于导出。
修改 src/components/ChatHistory/ChatHistory.jsx
导入 Message 组件。
0x05 容器化
构建
偷个懒,将前后端全部放进一个容器,前端用简单的文件服务器盛放就好了:dfbd2a7。
### build ###
FROM golang:alpine AS build-env
# 1. build backend
RUN mkdir -p /backend
WORKDIR /backend
ADD ./backend/ /backend/
RUN go build -o backend_docker
# 2. build server for frontend
RUN mkdir -p /server
WORKDIR /server
ADD ./server /server/
RUN go build -o server_docker
### run ###
FROM alpine
RUN mkdir -p /build
WORKDIR /
# server
COPY --from=build-env /server/server_docker /
# backend
COPY --from=build-env /backend/backend_docker /
# frontend
ADD ./frontend/build/ /build/
RUN echo -e "#!/bin/sh\n ./server_docker & \n ./backend_docker" > /start.sh
# frontend port
EXPOSE 8080
# backend port
EXPOSE 8081
CMD [ "sh", "start.sh" ]
首先npm run build
得到前端导出项目 build 文件夹,然后运行
docker build -t ghat .
即可。
在容器里写了个脚本,并通过这个脚本运行文件服务和后端服务两个进程。这个分步构建后得到的容器大小还算可以接受。
部署
假设有域名(有证书):https://fakedomain.com/
。又假设现在的部署场景是:通过 Nginx 提供的反向代理能力,避免使用“IP+端口”或者“域名+端口”的形式访问这个服务,而是映射成为一个路径。比如,前端访问为:https://fakedomain.com/ghat/
,配套 WebSocket 访问为:wss://fakedomain.com/ghat-ws/
(这个就是配置在前端 api/index.js 中的公网地址)。
创建服务实例:
docker run -d -p 8080:8080 -p 8081:8081 --restart=always ghat
这里配置踩了两个坑。
wss 而不是 ws
如果服务器配置了证书,就不能使用ws://
访问 WebSocket,会被浏览器直接屏蔽,因此修改 api/index.js 时需要注意。
配套的 Nginx 配置可以是(只给出location
):
# ...
location /ghat-ws/ {
proxy_pass http://[内网 IP]:8081/ws;
# websocket 配置
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 10s;
proxy_read_timeout 7200s;
proxy_send_timeout 15s;
proxy_set_header Host $host; # 保留代理之前的 host
proxy_set_header X-Real-IP $remote_addr; # 保留代理之前的真实客户端 ip
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header HTTP_X_FORWARDED_FOR $remote_addr; # 在多级代理的情况下,记录每次代理之前的客户端真实 ip
proxy_set_header X-Forwarded-Proto $scheme; # 表示客户端真实的协议(http 还是 https)
proxy_redirect default; # 指定修改被代理服务器返回的响应头中的 location 头域跟 refresh 头域数值
}
# ...
相对路径
前端项目导出时疏忽了一点,导致测试时加载静态资源全都 404,一看请求 URL 竟然是从根路径(https://fakedomain.com/
)开始的……改为相对路径需要在 package.json 中手动指定homepage
:
这样导出项目上线后,加载静态资源都会从项目路径开始算起(https://fakedomain.com/ghat/
)。
同时 Nginx 配置可以写成:
# ...
location ^~ /ghat/ {
proxy_set_header HOST $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://[内网 IP]:8080/;
}
# ...
完事后公网访问 https://fakedomain.com/ghat/
即可。
0x06 小结
以上就是一个用户 ID 都不给设置的屑项目。