簡單寫個聊天室


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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM