Go基於WebSocket聊天程式
後端程式碼: https://github.com/kone-net/go-chat 前端程式碼: https://github.com/kone-net/go-chat-web
go-chat
使用Go基於WebSocket的通訊聊天軟體。
功能列表:
- 登入註冊
- 修改頭像
- 群聊天
- 群好友列表
- 單人聊天
- 新增好友
- 新增群組
- 文字訊息
- 剪下板圖片
- 圖片訊息
- 檔案傳送
- 語音訊息
- 視訊訊息
- 螢幕共享(基於圖片)
- 視訊通話(基於WebRTC的p2p視訊通話)
後端
程式碼倉庫 go中協程是非常輕量級的。在每個client接入的時候,為每一個client開啟一個協程,能夠在單機實現更大的併發。同時go的channel,可以非常完美的解耦client接入和訊息的轉發等操作。
通過go-chat,可以掌握channel的和Select的配合使用,ORM框架的使用,web框架Gin的使用,配置管理,日誌操作,還包括proto buffer協議的使用,等一些列專案中常用的技術。
後端技術和框架
- web框架Gin
- 長連線WebSocket
- 日誌框架Uber的zap
- 配置管理viper
- ORM框架gorm
- 通訊協議Google的proto buffer
- makefile 的編寫
- 資料庫MySQL
- 圖片檔案二進位制操作
前端
基於react,UI和基本元件是使用ant design。可以很方便搭建前端介面。
介面選擇單頁框架可以更加方便寫聊天介面,比如像訊息提醒,可以在一個介面接受到訊息進行提醒,不會因為換頁面或者檢視其他內容影響訊息接受。 前端程式碼倉庫: https://github.com/kone-net/go-chat-web
前端技術和框架
- React
- Redux狀態管理
- AntDesign
- proto buffer的使用
- WebSocket
- 剪下板的檔案讀取和操作
- 聊天框傳送文字顯示底部
- FileReader對檔案操作
- ArrayBuffer,Blob,Uint8Array之間的轉換
- 獲取攝像頭視訊(mediaDevices)
- 獲取麥克風音訊(Recorder)
- 獲取螢幕共享(mediaDevices)
- WebRTC的p2p視訊通話
截圖
- 語音,文字,圖片,視訊訊息
* 視訊通話
- 螢幕共享
訊息協議
protocol buffer協議
```go syntax = "proto3"; package protocol;
message Message { string avatar = 1; //頭像 string fromUsername = 2; // 傳送訊息使用者的使用者名稱 string from = 3; // 傳送訊息使用者uuid string to = 4; // 傳送給對端使用者的uuid string content = 5; // 文字訊息內容 int32 contentType = 6; // 訊息內容型別:1.文字 2.普通檔案 3.圖片 4.音訊 5.視訊 6.語音聊天 7.視訊聊天 string type = 7; // 如果是心跳訊息,該內容為heatbeat int32 messageType = 8; // 訊息型別,1.單聊 2.群聊 string url = 9; // 圖片,視訊,語音的路徑 string fileSuffix = 10; // 檔案字尾,如果通過二進位制頭不能解析檔案字尾,使用該字尾 bytes file = 11; // 如果是圖片,檔案,視訊等的二進位制 } ```
選擇協議原因
通過訊息體能看出,訊息大部分都是字串或者整型型別。通過json就可以進行傳輸。那為什麼要選擇google的protocol buffer進行傳輸呢? * 一方面傳輸快 是因為protobuf序列化後的大小是json的10分之一,是xml格式的20分之一,但是效能卻是它們的5~100倍. * 另一方面支援二進位制 當我們看到訊息體最後一個欄位,是定義的bytes,二進位制型別。 我們在傳輸圖片,檔案,視訊等內容的時候,可以將檔案直接通過socket訊息進行傳輸。 當然我們也可以將檔案先通過http介面上傳後,然後返回路徑,再通過socket訊息進行傳輸。但是這樣只能實現固定大小檔案的傳輸,如果我們是語音電話,或者視訊電話的時候,就不能傳輸流。
快速執行
執行go程式
go環境的基本配置 ...
拉取後端程式碼
shell
git clone https://github.com/kone-net/go-chat
進入目錄
shell
cd go-chat
拉取程式所需依賴
shell
go mod download
MySQL建立資料庫
mysql
CREATE DATABASE chat;
修改資料庫配置檔案 ```shell vim config.toml
[mysql] host = "127.0.0.1" name = "chat" password = "root1234" port = 3306 table_prefix = "" user = "root"
修改使用者名稱user,密碼password等資訊。 ```
建立表
shell
將chat.sql裡面的sql語句複製到控制檯建立對應的表。
在user表裡面新增初始化使用者
shell
手動新增使用者。
執行程式
shell
go run cmd/main.go
執行前端程式碼
配置React基本環境,比如nodejs ...
拉取程式碼
shell
git clone https://github.com/kone-net/go-chat-web
安裝前端基本依賴
shell
npm install
如果後端地址或者埠號需要修改
shell
修改src/common/param/Params.jsx裡面的IP_PORT
執行前端程式碼預設啟動埠是3000
shell
npm start
訪問前端入口
http://127.0.0.1:3000/login
程式碼結構
├── Makefile 程式碼編譯,打包,結構化等操作
├── README.md
├── api
│ └── v1 controller類,對外的介面,如新增好友,查詢好友等。所有http請求的入口
├── bin
│ └── chat 打包的二進位制檔案
├── chat.sql 整個專案的SQL
├── cmd main函式入口,程式啟動
├── common
│ ├── constant 常量
│ └── util 工具類
├── config 配置初始化類
├── config.toml 配置檔案
├── dao
│ └── pool 資料庫連線池
├── errors 封裝的異常類
├── global
│ └── log 封裝的日誌類,使用時不會出現第三方的包依賴
├── go.mod
├── go.sum
├── logs 日誌檔案
├── model 資料庫模型,和表一一對應
│ ├── request 請求的實體類
│ ├── response 響應的實體類
├── protocol 訊息協議
│ ├── message.pb.go protoc buffer自動生成的檔案
│ └── message.proto 定義的protoc buffer欄位
├── response 全域性響應,通過http請求的,都包含code,msg,data三個欄位
├── router gin和controller類進行繫結
├── server WebSocket中訊息的接受和轉發的主要邏輯
├── service controller呼叫的服務類
├── static 靜態檔案,圖片等
│ ├── img
│ └── screenshot markdown用到的截圖檔案
└── test 測試檔案
Makefile
程式打包
在根目錄下執行make命令 mac ```bash make build-darwin
實際執行命令是Makefile下的 CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/chat cmd/main.go ```
linux ```bash make build
實際執行命令是Makefile下的 CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/chat cmd/main.go ```
後端proto檔案生成
如果修改了message.proto,就需要重新編譯生成對應的go檔案。 在根目錄下執行 ```bash make proto
實際執行命令是Makefile下的 protoc --gogo_out=. protocol/*.proto ```
如果本地沒有安裝proto檔案,需要先進行安裝,不然找不到protoc命令。 使用gogoprotobuf
安裝protobuf庫檔案
bash
go get github.com/golang/protobuf/proto
安裝protoc-gen-gogo
bash
go get github.com/gogo/protobuf/protoc-gen-gogo
安裝gogoprotobuf庫檔案
bash
go get github.com/gogo/protobuf/proto
在根目錄測試:
bash
protoc --gogo_out=. protocol/*.proto
前端proto檔案生成
前端需要安裝protoc buffer庫
bash
npm install protobufjs
生成protoc的js檔案到目錄 ```bash npx pbjs -t json-module -w commonjs -o src/chat/proto/proto.js src/chat/proto/*.proto
src/chat/proto/proto.js 是生成的檔案的目錄路徑及其檔名稱 src/chat/proto/*.proto 是自己寫的欄位等 ```
程式碼說明
WebSocket
該檔案是gin的路由對映,將普通的get請求,Upgrader為socket連線 ```go // router/router.go func NewRouter() *gin.Engine { gin.SetMode(gin.ReleaseMode)
server := gin.Default()
server.Use(Cors())
server.Use(Recovery)
socket := RunSocekt
group := server.Group("")
{
...
group.GET("/socket.io", socket)
}
return server
} ```
這部分對請求進行升級為WebSocket。 * c.Query("user")使用者登入後,會獲取使用者的uuid,在連線到socket時會攜帶使用者的uuid。 * 通過該uuid和connection進行關聯。 * server.MyServer.Register <- client將每個client例項,通過channel進行傳達,Server例項的Select會對該例項進行儲存。 * client.Read(),client.Write()通過協程讓每個client對自己獨有的channel進行訊息的讀取和傳送 ```go // router/socket.go var upGrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }
func RunSocekt(c *gin.Context) { user := c.Query("user") if user == "" { return } log.Info("newUser", zap.String("newUser", user)) ws, err := upGrader.Upgrade(c.Writer, c.Request, nil) //升級協議為WebSocket if err != nil { return }
client := &server.Client{
Name: user,
Conn: ws,
Send: make(chan []byte),
}
server.MyServer.Register <- client
go client.Read()
go client.Write()
} ```
這是Server的三個channel, * 使用者登入後,將使用者和connection繫結存放在map中 * 使用者離線後,將使用者從map中剔除 * 所有訊息,每個client將訊息獲取後放入該channel中,統一在這裡進行訊息的分發 * 分發訊息: * 如果是單聊,直接根據前端傳送的uuid找到對應的client進行傳送。 * 如果是群聊,需要在資料庫查詢該群所有的成員,在根據uuid找到對應的client進行傳送。 * 如果訊息為普通文字訊息,可以直接轉發到對應的客戶端。 * 如果訊息為視訊檔案,普通檔案,照片之類的,需要先將檔案進行儲存,然後返回檔名稱,前端根據名稱呼叫介面獲取檔案。 ```go // server/server.go func (s *Server) Start() { log.Info("start server", log.Any("start server", "start server...")) for { select { case conn := <-s.Register: log.Info("login", log.Any("login", "new user login in"+conn.Name)) s.Clients[conn.Name] = conn msg := &protocol.Message{ From: "System", To: conn.Name, Content: "welcome!", } protoMsg, _ := proto.Marshal(msg) conn.Send <- protoMsg
case conn := <-s.Ungister:
log.Info("loginout", log.Any("loginout", conn.Name))
if _, ok := s.Clients[conn.Name]; ok {
close(conn.Send)
delete(s.Clients, conn.Name)
}
case message := <-s.Broadcast:
msg := &protocol.Message{}
proto.Unmarshal(message, msg)
...
...
}
}
}
```
剪下板圖片上傳
上傳剪下板的檔案,首先我們需要獲取剪下板檔案。 如以下程式碼: * 通過在聊天輸入框,繫結貼上命令,獲取貼上板的內容。 * 我們只獲取檔案資訊,其他文字資訊過濾掉。 * 先獲取檔案的blob格式。 * 通過FileReader,將blob轉換為ArrayBuffer格式。 * 將ArrayBuffer內容轉換為Uint8Array二進位制,放在訊息體。 * 通過protobuf將訊息轉換成對應協議。 * 通過socket進行傳輸。 * 最後,將本地的圖片追加到聊天框裡面。 ```javascript bindParse = () => { document.getElementById("messageArea").addEventListener("paste", (e) => { var data = e.clipboardData if (!data.items) { return; } var items = data.items
if (null == items || items.length <= 0) {
return;
}
let item = items[0]
if (item.kind !== 'file') {
return;
}
let blob = item.getAsFile()
let reader = new FileReader()
reader.readAsArrayBuffer(blob)
reader.onload = ((e) => {
let imgData = e.target.result
// 上傳檔案必須將ArrayBuffer轉換為Uint8Array
let data = {
fromUsername: localStorage.username,
from: this.state.fromUser,
to: this.state.toUser,
messageType: this.state.messageType,
content: this.state.value,
contentType: 3,
file: new Uint8Array(imgData)
}
let message = protobuf.lookup("protocol.Message")
const messagePB = message.create(data)
socket.send(message.encode(messagePB).finish())
this.appendImgToPanel(imgData)
})
}, false)
}
```
上傳錄製的視訊
上傳語音同原理 * 獲取視訊呼叫許可權。 * 通過mediaDevices獲取視訊流,或者音訊流,或者螢幕分享的視訊流。 * this.recorder.start(1000)設定每秒返回一段流。 * 通過MediaRecorder將流轉換為二進位制,存入dataChunks陣列中。 * 鬆開按鈕後,將dataChunks中的資料合成一段二進位制。 * 通過FileReader,將blob轉換為ArrayBuffer格式。 * 將ArrayBuffer內容轉換為Uint8Array二進位制,放在訊息體。 * 通過protobuf將訊息轉換成對應協議。 * 通過socket進行傳輸。 * 最後,將本地的視訊,音訊追加到聊天框裡面。
特別注意: 獲取視訊,音訊,螢幕分享呼叫許可權,必須是https協議或者是localhost,127.0.0.1 本地IP地址,所有本地測試可以開啟幾個瀏覽器,或者分別用這兩個本地IP進行2tab測試 ```javascript /* * 當按下按鈕時錄製視訊 / dataChunks = []; recorder = null; startVideoRecord = (e) => { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; //獲取媒體物件(這裡指攝像頭)
let preview = document.getElementById("preview");
this.setState({
isRecord: true
})
navigator.mediaDevices
.getUserMedia({
audio: true,
video: true,
}).then((stream) => {
preview.srcObject = stream;
this.recorder = new MediaRecorder(stream);
this.recorder.ondataavailable = (event) => {
let data = event.data;
this.dataChunks.push(data);
};
this.recorder.start(1000);
});
}
/**
* 鬆開按鈕傳送視訊到伺服器
* @param {事件} e
*/
stopVideoRecord = (e) => {
this.setState({
isRecord: false
})
let recordedBlob = new Blob(this.dataChunks, { type: "video/webm" });
let reader = new FileReader()
reader.readAsArrayBuffer(recordedBlob)
reader.onload = ((e) => {
let fileData = e.target.result
// 上傳檔案必須將ArrayBuffer轉換為Uint8Array
let data = {
fromUsername: localStorage.username,
from: this.state.fromUser,
to: this.state.toUser,
messageType: this.state.messageType,
content: this.state.value,
contentType: 3,
file: new Uint8Array(fileData)
}
let message = protobuf.lookup("protocol.Message")
const messagePB = message.create(data)
socket.send(message.encode(messagePB).finish())
})
this.setState({
comments: [
...this.state.comments,
{
author: localStorage.username,
avatar: this.state.user.avatar,
content: <p><video src={URL.createObjectURL(recordedBlob)} controls autoPlay={false} preload="auto" width='200px' /></p>,
datetime: moment().fromNow(),
},
],
}, () => {
this.scrollToBottom()
})
if (this.recorder) {
this.recorder.stop()
this.recorder = null
}
let preview = document.getElementById("preview");
preview.srcObject.getTracks().forEach((track) => track.stop());
this.dataChunks = []
}
```