通過Websocket與gRPC互動 | gRPC雙向資料流的互動控制系列(2)

阿狸不歌發表於2018-10-22

在本系列第一篇文章《gRPC雙向資料流的互動控制系列(1).初步實現》(http://www.ituring.com.cn/article/507208)中,我們完成了通過控制檯進行gRPC雙向資料流互動控制的實驗。但是隻是用控制檯互動大大限制了客戶端的使用範圍,如果我們要在網頁或者移動端與gRPC進行雙向資料流的互動怎麼辦?熟悉前端開發的朋友可能馬上就會想到:用Websocket啦!

Websocket簡介

WebSocket協議誕生於2008年,2011年成為國際標準,現代瀏覽器都已經支援Websocket,移動端不管是安卓還是iOS也沒有問題。Websocket提供一種在單個TCP 連線上進行全雙工通訊的協議,使得客戶端和服務端只需要做一個握手的動作,然後,客戶端和服務端之間就形成了一條快速通道,兩者之間就直接可以進行雙向的資料傳輸。看到這我們可以發現Websocket與gRPC雙向資料流之間簡直是天作之合!


整合Websocket服務端 + gRPC客戶端

主流的各種語言都有庫可供Websocket的服務端使用,為與前文(http://www.ituring.com.cn/article/507208)保持一致,我們仍然用Go語言來時實現。Go語言中常用的Websocket庫是gorrila websocket (github.com/gorilla/websocket),為了使程式碼看起來更簡潔一些,我們本次採用封裝了gorrila websocket的微型框架 melody (github.com/olahol/melody)和 gin框架(github.com/gin-gonic/gin) 來實現。

gin在本程式中的作用比較簡單,就是提供路由,一個是靜態資源(index.html)的路由,一個是websocket的路由。

melody有三個最重要的函式:一個是HandleConnect,用於響應websocket客戶端的連線事件;一個是HandleMessage,用於處理websocket客戶端輸入的訊息;一個是HandleDisconnect,用於處理websocket客戶端斷開連線事件。melody封裝了session,並且可以利用session存取自定義資料。

ws-server-grpc-client.go

package main
import (
    "context"
    "encoding/json"
    "flag"
    "io"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
    "google.golang.org/grpc"
    "gopkg.in/olahol/melody.v1"

    proto "chat" // 自動生成的 proto程式碼
)

var 服務端地址 string

func init() {
    flag.StringVar(&服務端地址, "server", "localhost:3000", "服務端地址")
}

func main() {
    // 解析命令列引數
    flag.Parse()

    // 設定log
    log.SetFlags(log.LstdFlags)

    // 建立gRPC連線
    conn, err := grpc.Dial(服務端地址, grpc.WithInsecure())
    if err != nil {
        log.Printf("連線失敗: [%v]\n", err)
        return
    }
    defer conn.Close()

    // 宣告客戶端
    client := proto.NewChatClient(conn)

    r := gin.Default()
    m := melody.New()

    // 靜態頁面路由
    r.GET("/", func(c *gin.Context) {
        http.ServeFile(c.Writer, c.Request, "html/index.html")
    })

    // websocket路由
    r.GET("/ws", func(c *gin.Context) {
        m.HandleRequest(c.Writer, c.Request)
    })

    // 處理websocket客戶端新連線,併為每一個新連線建立一個 雙向資料流
    m.HandleConnect(func(s *melody.Session) {
        log.Println("有新使用者接入")
        // 給每個連入的新使用者建立一個資料流
        // 宣告 context
        ctx := context.Background()

        // 建立雙向資料流
        stream, err := client.BidStream(ctx)
        if err != nil {
            log.Printf("建立資料流失敗: [%v]\n", err)
            // 如果建立資料流失敗,向客戶端傳送失敗資訊 同時 關閉websocket連線
            s.CloseWithMsg([]byte("建立資料流失敗:" + err.Error()))
            return
        }

        // 如果建立成功,將資料流儲存在 session中
        s.Set("stream", stream)

        // 啟動一個 goroutine 用於接收從服務端返回的訊息
        go func() {
            for {
                // 接收從 服務端返回的資料流
                響應, err := stream.Recv()

                if err == io.EOF {
                    log.Println("⚠️ 收到服務端的結束訊號")
                    s.CloseWithMsg([]byte("⚠️ 收到服務端的結束訊號"))
                    return
                }

                if err != nil {
                    // TODO: 處理接收錯誤
                    log.Println("接收資料出錯:", err)
                    s.CloseWithMsg([]byte("接收資料出錯" + err.Error()))
                    return
                }

                log.Printf("[客戶端收到]: %s", 響應.Output)
                // 如果成功收到從服務端返回的訊息, 將訊息序列化後返回給 websocket 客戶端
                要返回的byte, _ := json.Marshal(響應)
                s.Write(要返回的byte)
            }
        }()
    })

    // 處理使用者發來的訊息
    m.HandleMessage(func(s *melody.Session, msg []byte) {
        log.Println("收到訊息:", msg)
        // 把使用者輸入的資訊原樣返回 websocket 客戶端
        s.Write(msg)

        // 將 []byte 型別的 msg 解析為 proto.Request
        var 輸入資訊 proto.Request
        if err := json.Unmarshal(msg, &輸入資訊); err != nil {
            log.Println("解析輸入資訊失敗:", err)
            s.CloseWithMsg([]byte("輸入資訊解析失敗"))
            return
        }

        // 從 session中取出 stream
        被儲存的資料流, ok := s.Get("stream")
        if !ok {
            s.CloseWithMsg([]byte("沒有找到stream!"))
            return
        }

        // 斷言stream
        stream, ok := 被儲存的資料流.(proto.Chat_BidStreamClient)
        if !ok {
            s.CloseWithMsg([]byte("被儲存的資料流不是Chat_BidStreamClient!"))
            return
        }

        if err := stream.Send(&輸入資訊); err != nil {
            s.CloseWithMsg([]byte("向gRPC服務端傳送訊息失敗:" + err.Error()))
            return
        }
    })

    // 處理 websocket 連線斷開事件,並關閉session 中 stream的連線
    m.HandleDisconnect(func(s *melody.Session) {
        log.Println("websocket客戶端斷開連線")
        // 從 session中取出 stream
        被儲存的資料流, ok := s.Get("stream")
        if !ok {
            log.Println("沒有找到stream!")
            return
        }

        // 斷言stream
        stream, ok := 被儲存的資料流.(proto.Chat_BidStreamClient)
        if !ok {
            log.Println("被儲存的資料流不是Chat_BidStreamClient!")
            return
        }

        if err := stream.CloseSend(); err != nil {
            log.Println("斷開stream連線出錯:", err)
        }
    })

    r.Run(":8080")
}

websocket客戶端

為簡便起見,本例中的客戶端沒有使用任何框架,而是用了最原始的html和javascript,所以介面比較簡陋。我們完全可以用Angular、React、Vue、jQuery……或者基於移動端進行開發。

index.html

<html>
<head>
<meta charset="UTF-8">
<title>WebSocket + gRPC雙向資料流</title>
 <style>
#chat {
  text-align: left;
  background: #f1f1f1;
  width: 500px;
  min-height: 300px;
  padding: 20px;
}
  </style>
</head>
<body>
<center>
<h3>對話</h3>
<h4 id="clientId"></h4>
<div id="output"/>
<pre id="chat"></pre>
<input placeholder="請輸入資訊,回車傳送" id="text" type="text">
</center>
<script>
  var url = "ws://" + window.location.host + "/ws";
  var ws = new WebSocket(url);

  var chat = document.getElementById("chat");
  var text = document.getElementById("text");
  var output = document.getElementById("output");

  var clientId = document.getElementById("clientId");
  var name = "客戶編號:" + Math.floor(Math.random() * 1000);
  clientId.innerHTML = name;

  // 列印連線狀態
  var printStatus = function(狀態) {
    var d       = document.createElement("div");
    d.innerHTML = 狀態;
    output.appendChild(d);
  };

  // 獲取當前時間
  var now = function () {
    var iso = new Date().toISOString();
    return iso.split("T")[1].split(".")[0];
  };

  // 處理websocket訊息
  ws.onmessage = function (msg) {
    var msg = JSON.parse(msg.data);
    if (msg.input){
      var line =  now() + " " + msg.input + "\n";
    }

    if (msg.output){
      var line =  now() + " " + msg.output + "\n";
    }

    if(line){
      chat.innerText += line;
    }
  };

  // 處理連線事件
  ws.onopen = function(evt) {
    printStatus(now() +  ' ' + '<span style="color: green;">成功連線</span>');
  }

  // 處理斷開連線事件
  ws.onclose = function(evt) {
    printStatus(now() +  ' ' + '<span style="color: red;">連線已關閉</span>');
    ws = null;
  }

  // 對話方塊監聽Enter鍵 併傳送訊息
  text.onkeydown = function (e) {
    if (e.keyCode === 13 && text.value !== "") {
      ws.send(JSON.stringify({"input": text.value}))
      text.value = "";
    }
  };
</script>
</body>
</html>

gRPC服務端

與《gRPC雙向資料流的互動控制系列(1).初步實現》(http://www.ituring.com.cn/article/507208)相比,server.go 做了小幅調整,主要是響應指令的條件上做了修改,讀者們可以自行比較。

server.go

package main

import (
"io"
"log"
"net"
"strconv"

"google.golang.org/grpc"

proto "chat" // 自動生成的 proto程式碼
)

// Streamer 服務端
type Streamer struct{}

// BidStream 實現了 ChatServer 介面中定義的 BidStream 方法
func (s *Streamer) BidStream(stream proto.Chat_BidStreamServer) error {
ctx := stream.Context()
for {
    select {
    case <-ctx.Done():
        log.Println("收到客戶端通過context發出的終止訊號")
        return ctx.Err()
    default:
        // 接收從客戶端發來的訊息
        輸入, err := stream.Recv()
        if err == io.EOF {
            log.Println("客戶端傳送的資料流結束")
            return nil
        }
        if err != nil {
            log.Println("接收資料出錯:", err)
            return err
        }

        // 如果接收正常,則根據接收到的 字串 執行相應的指令
        switch 輸入.Input {
        case "結束對話\n", "結束對話":    //⚠️ 此處增加了匹配條件
            log.Println("收到'結束對話'指令")
            if err := stream.Send(&proto.Response{Output: "收到結束指令"}); err != nil {
                return err
            }
            // 收到結束指令時,通過 return nil 終止雙向資料流
            return nil

        case "返回資料流\n", "返回資料流":    //⚠️ 此處增加了匹配條件
            log.Println("收到'返回資料流'指令")
            // 收到 收到'返回資料流'指令, 連續返回 10 條資料
            for i := 0; i < 10; i++ {
                if err := stream.Send(&proto.Response{Output: "資料流 #" + strconv.Itoa(i)}); err != nil {
                    return err
                }
            }

        default:
            // 預設情況下, 返回 '服務端返回: ' + 輸入資訊
            log.Printf("[收到訊息]: %s", 輸入.Input)
            if err := stream.Send(&proto.Response{Output: "服務端返回: " + 輸入.Input}); err != nil {
                return err
            }
        }
    }
}
}

func main() {
    log.Println("啟動服務端...")
    server := grpc.NewServer()

    // 註冊 ChatServer
    proto.RegisterChatServer(server, &Streamer{})

    address, err := net.Listen("tcp", ":3000")
    if err != nil {
        panic(err)
    }

    if err := server.Serve(address); err != nil {
        panic(err)
    }
}

執行效果

先啟動服務端程式 server.go 再啟動客戶端程式 ws-server-grpc-client.go

開啟瀏覽器,進入指定的地址,如 127.0.0.1:8080 輸入訊息,結果類似下圖: 瀏覽器執行效果


小結

本文的例子充分利用了Websocket全雙工通訊的特性,實現了前端程式與gRPC服務端通過雙向資料流進行互動。在下一篇文章中,筆者將介紹如何利用nginx最新特性實現gRPC服務端的負載均衡

相關文章