Go基於WebSocket聊天程式

語言: CN / TW / HK

後端程式碼: 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視訊通話

截圖

  • 語音,文字,圖片,視訊訊息

go-chat-panel.jpeg * 視訊通話

screenshot-20211127-092057.png

  • 螢幕共享

screenshot-20211127-092410.png

訊息協議

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 = []
}

```