简单写个聊天室

学习写一个 B/S 架构的聊天室,后端采用 Golang,前端轻度使用 React.js。

0x00 WebSocket

WebSocket 是 HTML5 中新增的协议,基于传统的 HTTP。

由于传统 HTTP 是“请求-响应”协议,无客户端请求则无服务端响应,服务器无法向浏览器主动发送数据。当年 Flash 插件倒是解决了这一问题。其实 HTTP 本身也可以解决,但是思路非常笨重:

  1. 轮询。在浏览器设置 JavaScript 的定时器,按照指定频次向服务端询问是否有新消息,此时就需要严格考量这个“频次”的具体值了,过小则导致服务器不堪重负,过大则导致信息更新不及时。
  2. 轮询的变种——Comet。与普通轮询相似,但在没有消息更新时,服务器会挂起这一方的请求(假设客户端是对应服务端的一个线程,就是挂起一个线程),等有更新了再响应;然而实际上大部分线程在大部分存活时间内都是挂起状态,又是浪费服务器资源。此外,一个 HTTP 长连接长时间没有数据传输的情况下,链路上的任意网关都有权关闭这个连接,所以还要定期发送一些 ICMP 包表示存活……

通过建立套接字连接,比如本项目中使用的 TCP 套接字,就能根本上解决上述问题。客户端只需要维护一个建立好的 Socket,监听上面传递的信息就能获得及时更新;服务端也只需要维护好和所有客户端建立的这些套接字即可,没有过多的握手挥手,也不需要应对海量的 Ping(如果真的有那种设计)。

建立 WebSocket 连接必须由浏览器(客户端)发起,格式和普通 HTTP 相似。

GET ws://localhost:9527/ws/test HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: //localhost:9527
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13

有几点不同:

  • 协议头ws://,而不是某个路径
  • UpgradeConnection告知服务器,这个连接将会“升级”为 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 .

即可。

在容器里写了个脚本,并通过这个脚本运行文件服务和后端服务两个进程。这个分步构建后得到的容器大小还算可以接受。

部署

假设有域名(有证书)://fakedomain.com/。又假设现在的部署场景是:通过 Nginx 提供的反向代理能力,避免使用“IP+端口”或者“域名+端口”的形式访问这个服务,而是映射成为一个路径。比如,前端访问为://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 //[内网 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 竟然是从根路径(//fakedomain.com/)开始的……改为相对路径需要在 package.json 中手动指定homepage

这样导出项目上线后,加载静态资源都会从项目路径开始算起(//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 //[内网 IP]:8080/;
        }
# ...

完事后公网访问 //fakedomain.com/ghat/ 即可。

0x06 小结

以上就是一个用户 ID 都不给设置的屑项目。