簡單寫個聊天室

4thrun發表於2021-10-27

學習寫一個 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: http://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 .

即可。

在容器裡寫了個指令碼,並通過這個指令碼執行檔案服務和後端服務兩個程式。這個分步構建後得到的容器大小還算可以接受。

部署

假設有域名(有證照):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 都不給設定的屑專案。

相關文章