Go 每日一庫(kù)之 gotalk

簡(jiǎn)介

gotalk專(zhuān)注于進(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)編寫(xiě)一個(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ù)為消息名,字符串類(lèi)型,保證唯一且可辨識(shí)即可。第二個(gè)參數(shù)為處理函數(shù),收到對(duì)應(yīng)名稱(chēng)的消息,調(diào)用該函數(shù)處理。處理函數(shù)接受一個(gè)參數(shù),返回兩個(gè)值。正常處理完成通過(guò)第一個(gè)返回值傳遞處理結(jié)果,出錯(cuò)時(shí)通過(guò)第二個(gè)返回值表示錯(cuò)誤類(lèi)型。

這里的處理器函數(shù)比較簡(jiǎn)單,接受一個(gè)字符串參數(shù),直接原樣返回。

然后,調(diào)用gotalk.Serve()啟動(dòng)服務(wù)器,監(jiān)聽(tīng)端口。它接受 3 個(gè)參數(shù),協(xié)議類(lèi)型、監(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編寫(xiě) 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 重寫(xiě)上面的服務(wù)端程序,然后編寫(xiě)一個(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 程序的編寫(xiě),將 WebSocket 通信細(xì)節(jié)封裝在一個(gè) JavaScript 文件gotalk.js中??梢灾苯訌膫}(cāng)庫(kù)中的 js 目錄下獲取使用。接著我們編寫(xiě)頁(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),為了編寫(xiě)方便和易于理解使用async-await同步的寫(xiě)法。響應(yīng)的內(nèi)容直接顯示在頁(yè)面上:

[圖片上傳失敗...(image-bb0d81-1623109177730)]

注意,gotalk.js文件需要放在服務(wù)器運(yùn)行目錄的gotalk目錄下。

協(xié)議格式

gotalk采用基于 ASCII 的協(xié)議格式,設(shè)計(jì)為方便人類(lèi)閱讀且靈活的。每條傳輸?shù)南⒍挤譃閹讉€(gè)部分:類(lèi)型標(biāo)識(shí)、請(qǐng)求ID、操作、消息內(nèi)容。

  • 類(lèi)型標(biāo)識(shí):只用一個(gè)字節(jié),用來(lái)表示消息的類(lèi)型,是請(qǐng)求消息還是響應(yīng)消息,流式消息還是非流式的,錯(cuò)誤、心跳和通知也都有其特定的類(lèi)型標(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",顯示如下:

[圖片上傳失敗...(image-36fbe0-1623109177730)]

接下來(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??

參考

  1. gotalk GitHub:https://github.com/rsms/gotalk
  2. Go 每日一庫(kù) GitHub:https://github.com/darjun/go-daily-lib

我的博客:https://darjun.github.io

歡迎關(guān)注我的微信公眾號(hào)【GoUpUp】,共同學(xué)習(xí),一起進(jìn)步~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Mix Go 是一個(gè)基于 Go 進(jìn)行快速開(kāi)發(fā)的完整系統(tǒng),類(lèi)似前端的 Vue CLI,提供: 通過(guò) mix-go/m...
    擼代碼的鄉(xiāng)下人閱讀 806評(píng)論 0 4
  • 表情是什么,我認(rèn)為表情就是表現(xiàn)出來(lái)的情緒。表情可以傳達(dá)很多信息。高興了當(dāng)然就笑了,難過(guò)就哭了。兩者是相互影響密不可...
    Persistenc_6aea閱讀 129,519評(píng)論 2 7
  • 16宿命:用概率思維提高你的勝算 以前的我是風(fēng)險(xiǎn)厭惡者,不喜歡去冒險(xiǎn),但是人生放棄了冒險(xiǎn),也就放棄了無(wú)數(shù)的可能。 ...
    yichen大刀閱讀 7,671評(píng)論 0 4

友情鏈接更多精彩內(nèi)容