簡單寫個聊天室

學習寫一個 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 都不給設置的屑項目。