Go 每日一庫(kù)之 gotalk
簡(jiǎn)介
gotalk專注于進(jìn)程間的通信,致力于簡(jiǎn)化通信協(xié)議和流程。同時(shí)它:
提供簡(jiǎn)潔、清晰的 API; 支持 TCP,WebSocket 等協(xié)議; 采用非常簡(jiǎn)單而又高效的傳輸協(xié)議格式,便于抓包調(diào)試; 內(nèi)置了 JavaScript 文件 gotalk.js,方便開(kāi)發(fā)基于 Web 網(wǎng)頁(yè)的客戶端程序;內(nèi)含豐富的示例可供學(xué)習(xí)參考。
那么,讓我們來(lái)玩一下吧~
快速使用
本文代碼使用 Go Modules。
創(chuàng)建目錄并初始化:
$ mkdir gotalk && cd gotalk
$ go mod init github.com/darjun/go-daily-lib/gotalk
安裝gotalk庫(kù):
$ go get -u github.com/rsms/gotalk
接下來(lái)讓我們來(lái)編寫一個(gè)簡(jiǎn)單的 echo 程序,服務(wù)端直接返回收到的客戶端信息,不做任何處理。首先是服務(wù)端:
// get-started/server/server.go
package main
import (
"log"
"github.com/rsms/gotalk"
)
func main() {
gotalk.Handle("echo", func(in string) (string, error) {
return in, nil
})
if err := gotalk.Serve("tcp", ":8080", nil); err != nil {
log.Fatal(err)
}
}
通過(guò)gotalk.Handle()注冊(cè)消息處理,它接受兩個(gè)參數(shù)。第一個(gè)參數(shù)為消息名,字符串類型,保證唯一且可辨識(shí)即可。第二個(gè)參數(shù)為處理函數(shù),收到對(duì)應(yīng)名稱的消息,調(diào)用該函數(shù)處理。處理函數(shù)接受一個(gè)參數(shù),返回兩個(gè)值。正常處理完成通過(guò)第一個(gè)返回值傳遞處理結(jié)果,出錯(cuò)時(shí)通過(guò)第二個(gè)返回值表示錯(cuò)誤類型。
這里的處理器函數(shù)比較簡(jiǎn)單,接受一個(gè)字符串參數(shù),直接原樣返回。
然后,調(diào)用gotalk.Serve()啟動(dòng)服務(wù)器,監(jiān)聽(tīng)端口。它接受 3 個(gè)參數(shù),協(xié)議類型、監(jiān)聽(tīng)地址、處理器對(duì)象。此處我們使用 TCP 協(xié)議,監(jiān)聽(tīng)本地8080端口,使用默認(rèn)處理器對(duì)象,傳入nil即可。
服務(wù)器內(nèi)部一直循環(huán)處理請(qǐng)求。
然后是客戶端:
func main() {
s, err := gotalk.Connect("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
for i := 0; i < 5; i++ {
var echo string
if err := s.Request("echo", "hello", &echo); err != nil {
log.Fatal(err)
}
fmt.Println(echo)
}
s.Close()
}
客戶端首先調(diào)用gotalk.Connect()連接服務(wù)器,它接受兩個(gè)參數(shù):協(xié)議和地址(IP + 端口)。我們使用與服務(wù)器一致的協(xié)議和地址即可。連接成功會(huì)返回一個(gè)連接對(duì)象。調(diào)用連接對(duì)象的Request()方法,即可向服務(wù)器發(fā)送消息。Request()方法接受 3 個(gè)參數(shù)。第一個(gè)參數(shù)為消息名,這對(duì)應(yīng)于服務(wù)器注冊(cè)的消息名,請(qǐng)求一個(gè)不存在的消息名會(huì)返回錯(cuò)誤。第二個(gè)參數(shù)是傳給服務(wù)器的參數(shù),有且只能有一個(gè)參數(shù),對(duì)應(yīng)處理器函數(shù)的入?yún)ⅰ5谌齻€(gè)參數(shù)為返回值的指針,用于接受服務(wù)器返回的結(jié)果。
如果請(qǐng)求失敗,返回錯(cuò)誤err。使用完成之后不要忘記關(guān)閉連接對(duì)象。
先運(yùn)行服務(wù)器:
$ go run server.go
在開(kāi)啟一個(gè)命令行,運(yùn)行客戶端:
$ go run client.go
hello
hello
hello
hello
hello
實(shí)際上如果了解標(biāo)準(zhǔn)庫(kù)net/http,你應(yīng)該就會(huì)發(fā)現(xiàn),使用gotalk的服務(wù)端代碼與使用net/http編寫 Web 服務(wù)器非常相似。都非常簡(jiǎn)單,清晰:
// get-started/http/main.go
package main
import (
"fmt"
"log"
"net/http"
)
func index(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello world")
}
func main() {
http.HandleFunc("/", index)
if err := http.ListenAndServe(":8888", nil); err != nil {
log.Fatal(err)
}
}
運(yùn)行:
$ go run main.go
使用 curl 驗(yàn)證:
$ curl localhost:8888
hello world
WebSocket
除了 TCP,gotalk還支持基于 WebSocket 協(xié)議的通信。下面我們使用 WebSocket 重寫上面的服務(wù)端程序,然后編寫一個(gè)簡(jiǎn)單 Web 頁(yè)面與之通信。
服務(wù)端:
func main() {
gotalk.Handle("echo", func(in string) (string, error) {
return in, nil
})
http.Handle("/gotalk/", gotalk.WebSocketHandler())
http.Handle("/", http.FileServer(http.Dir(".")))
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
gotalk消息處理函數(shù)的注冊(cè)還是與前面的一樣。不同的是這里將 HTTP 路徑/gotalk/的請(qǐng)求交由gotalk.WebSocketHandler()處理,這個(gè)處理器負(fù)責(zé) WebSocket 請(qǐng)求。同時(shí),在當(dāng)前工作目錄開(kāi)啟一個(gè)文件服務(wù)器,掛載到 HTTP 路徑/上。文件服務(wù)器是為了客戶端方便地請(qǐng)求index.html頁(yè)面。最后調(diào)用http.ListenAndServe()開(kāi)啟 Web 服務(wù)器,監(jiān)聽(tīng)端口 8080。
然后是客戶端,gotalk為了方便 Web 程序的編寫,將 WebSocket 通信細(xì)節(jié)封裝在一個(gè) JavaScript 文件gotalk.js中。可以直接從倉(cāng)庫(kù)中的 js 目錄下獲取使用。接著我們編寫頁(yè)面index.html,引入gotalk.js:
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="text/javascript" src="gotalk/gotalk.js"></script>
</head>
<body>
<input id="txt">
<button id="snd">send</button><br>
<script>
let c = gotalk.connection()
.on('open', () => log(`connection opened`))
.on('close', reason => log(`connection closed (reason: ${reason})`))
let btn = document.querySelector("#snd")
let txt = document.querySelector("#txt")
btn.onclick = async () => {
let content = txt.value
if (content.length === 0) {
alert("no message")
return
}
let res = await c.requestp('echo', content)
log(`reply: ${JSON.stringify(res, null, 2)}`)
return false
}
function log(message) {
document.body.appendChild(document.createTextNode(message))
document.body.appendChild(document.createElement("br"))
}
</script>
</body>
</html>
首先調(diào)用gotalk.connection()連接服務(wù)端,返回一個(gè)連接對(duì)象。調(diào)用此對(duì)象的on()方法,分別注冊(cè)連接建立和斷開(kāi)的回調(diào)。然后給按鈕添加回調(diào),每次點(diǎn)擊將輸入框中的內(nèi)容發(fā)送給服務(wù)端。調(diào)用連接對(duì)象的requestp()方法發(fā)送請(qǐng)求,第一個(gè)參數(shù)為消息名,對(duì)應(yīng)在服務(wù)端使用gotalk.Handle()注冊(cè)的名字。第二個(gè)即為處理參數(shù),會(huì)一并發(fā)送給服務(wù)端。這里使用 Promise 處理異步請(qǐng)求和響應(yīng),為了編寫方便和易于理解使用async-await同步的寫法。響應(yīng)的內(nèi)容直接顯示在頁(yè)面上:

注意,gotalk.js文件需要放在服務(wù)器運(yùn)行目錄的gotalk目錄下。
協(xié)議格式
gotalk采用基于 ASCII 的協(xié)議格式,設(shè)計(jì)為方便人類閱讀且靈活的。每條傳輸?shù)南⒍挤譃閹讉€(gè)部分:類型標(biāo)識(shí)、請(qǐng)求ID、操作、消息內(nèi)容。
類型標(biāo)識(shí):只用一個(gè)字節(jié),用來(lái)表示消息的類型,是請(qǐng)求消息還是響應(yīng)消息,流式消息還是非流式的,錯(cuò)誤、心跳和通知也都有其特定的類型標(biāo)識(shí)。 請(qǐng)求 ID:用 4 個(gè)字節(jié)表示,方便匹配響應(yīng)。由于 gotalk可以同時(shí)發(fā)送任意個(gè)請(qǐng)求并接收之前請(qǐng)求的響應(yīng)。所以需要有一個(gè) ID 來(lái)標(biāo)識(shí)接收到的響應(yīng)對(duì)應(yīng)之前發(fā)送的哪條請(qǐng)求。操作:即為我們上面定義的消息名,例如"echo"。 消息內(nèi)容:使用長(zhǎng)度 + 實(shí)際內(nèi)容格式。
看一個(gè)官方請(qǐng)求的示例:
+------------------ SingleRequest
| +---------------- requestID "0001"
| | +--------- operation "echo" (text3Size 4, text3Value "echo")
| | | +- payloadSize 25
| | | |
r0001004echo00000019{"message":"Hello World"}
r:表示這是一個(gè)單條請(qǐng)求。0001:請(qǐng)求 ID 為 1,這里采用十六進(jìn)制編碼。004echo:這部分表示操作為"echo",在實(shí)際字符串內(nèi)容前需要指定長(zhǎng)度,否則接收方不知道內(nèi)容在哪里結(jié)束。004指示"echo"長(zhǎng)度為 4,同樣采用十六進(jìn)制編碼。00000019{"message":"Hello World"}:這部分是消息的內(nèi)容。同樣需要指定長(zhǎng)度,十六進(jìn)制00000019表示長(zhǎng)度為 25。
詳細(xì)格式可以查看官方文檔。
使用這種可閱讀的格式給問(wèn)題排查帶來(lái)了極大的便利。但是在實(shí)際使用中,可能需要考慮安全和隱私的問(wèn)題。
聊天室
examples內(nèi)置一個(gè)基于 WebSocket 的聊天室示例程序。特性如下:
可以創(chuàng)建房間,默認(rèn)創(chuàng)建 3 個(gè)房間 animals/jokes/golang;在房間聊天(基本功能); 一個(gè)簡(jiǎn)單的 Web 頁(yè)面。
運(yùn)行:
$ go run server.go
打開(kāi)瀏覽器,輸入"localhost:1235",顯示如下:

接下來(lái)就可以創(chuàng)建房間,在房間聊天了。
整個(gè)實(shí)現(xiàn)的有幾個(gè)要點(diǎn):
其一,gotalk.WebSocketHandler()創(chuàng)建的 WebSocket 處理器可以設(shè)置連接回調(diào):
gh := gotalk.WebSocketHandler()
gh.OnConnect = onConnect
在回調(diào)中設(shè)置隨機(jī)用戶名,并將當(dāng)前連接的gotalk.Sock存儲(chǔ)下來(lái),方便消息廣播:
func onConnect(s *gotalk.WebSocket) {
socksmu.Lock()
defer socksmu.Unlock()
socks[s] = 1
username := randomName()
s.UserData = username
}
其二,gotalk設(shè)置處理器函數(shù)可以有兩個(gè)參數(shù),第一個(gè)表示當(dāng)前連接,第二個(gè)才是實(shí)際接收到的消息參數(shù)。
其三,enableGracefulShutdown()函數(shù)實(shí)現(xiàn)了 Web 服務(wù)器的優(yōu)雅關(guān)閉,非常值得學(xué)習(xí)。接收到SIGINT信號(hào),先關(guān)閉所有的連接,再退出程序。注意監(jiān)聽(tīng)信號(hào)和運(yùn)行 HTTP 服務(wù)器并不是同一個(gè) goroutine,看它們是如何協(xié)作的:
func enableGracefulShutdown(server *http.Server, timeout time.Duration) chan struct{} {
server.RegisterOnShutdown(func() {
// close all connected sockets
fmt.Printf("graceful shutdown: closing sockets\n")
socksmu.RLock()
defer socksmu.RUnlock()
for s := range socks {
s.CloseHandler = nil // avoid deadlock on socksmu (also not needed)
s.Close()
}
})
done := make(chan struct{})
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
go func() {
<-quit // wait for signal
fmt.Printf("graceful shutdown initiated\n")
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
fmt.Printf("server.Shutdown error: %s\n", err)
}
fmt.Printf("graceful shutdown complete\n")
close(done)
}()
return done
}
接收到SIGINT信號(hào)后done通道關(guān)閉,server.ListenAndServe()返回http.ErrServerClosed錯(cuò)誤,退出循環(huán):
done := enableGracefulShutdown(server, 5*time.Second)
// Start server
fmt.Printf("Listening on http://%s/\n", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
<- done
整個(gè)聊天室功能比較簡(jiǎn)單,代碼也比較短,建議深入理解。在此基礎(chǔ)之上做擴(kuò)展也比較簡(jiǎn)單。
總結(jié)
gotalk實(shí)現(xiàn)了一個(gè)簡(jiǎn)單、易用的通信庫(kù)。并且提供了 JavaScript 文件gotalk.js,方便 Web 程序的開(kāi)發(fā)。協(xié)議格式清晰,易調(diào)試。內(nèi)置豐富的示例。整個(gè)庫(kù)的代碼也不長(zhǎng),建議深入了解。
大家如果發(fā)現(xiàn)好玩、好用的 Go 語(yǔ)言庫(kù),歡迎到 Go 每日一庫(kù) GitHub 上提交 issue??
參考
gotalk GitHub:https://github.com/rsms/gotalk Go 每日一庫(kù) GitHub:https://github.com/darjun/go-daily-lib
推薦閱讀
