學習寫一個 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 都不給設定的屑專案。