關於 gRPC

gRPC 是一個高性能、通用的開源 RPC 框架,其由 Google 主要面向移動應用開發並基於 HTTP/2 協議標準而設計,基於 ProtoBuf (Protocol Buffers) 序列化協議開發,且支持衆多開發語言()。

gRPC 提供了一種簡單的方法來精確地定義服務和爲iOS、Android 和 後臺支持服務自動生成可靠性很強的客戶端功能庫。客戶端充分利用高級流和鏈接功能,從而有助於節省帶寬、降低的 TCP 鏈接次數、節省 CPU 使用、和電池壽命。下圖爲 gRPC 結構圖:

gRPC 具有如下一些重要的特性:

  • gRPC 默認通過 Protocol Buffers 來定義接口,可以制定更加嚴格規範的接口約束;
  • 而基於 ProtoBuf 可以將數據序列化爲二進制格式,從而大幅度減少數據量,進而大幅度的提升性能;
  • 支持流式通信(Streaming),基於 HTTP/2 協議傳輸可以實現 Streaming 功能模式,可提供更快的響應和更高的性能;
  • 支持多種語言,包括:Android Java、C++、C#/.NET、Dart、Go、Python、Web 等等;

關於 grpc-web

gRPC-web 是針對瀏覽器端的 gRPC 的 Javascript 實現,是需要配合代理來一起使用的。

在官網上的示例都是使用 Envoy 作爲代理,本文主要採用 Nginx 作爲代理,從 nginx-1.13.10 引入了對 grpc 的支持(grpc_pass),可以代理 gRPC TCP 連接,還可以終止、檢查和跟蹤 gRPC 的方法調用,可以如下:

  • 發佈 gRPC 服務,然後使用 NGINX 應用 HTTP/2 TLS 加密、限速、基於 IP 的訪問控制列表和日誌記錄;也可以使用未加密的 HTTP/2(h2c cleartext)或者在服務之上封裝 TLS 加密和認證信息;
  • 通過單個端點發布多個 gRPC 服務,使用 NGINX 檢查並跟蹤每個內部服務的調用;
  • 使用 Round Robin, Least Connections 或其他方法在集羣分配調用,對 gRPC 服務集羣進行負載均衡;

下圖爲一種簡單的使用 Nginx 暴露 gRPC 服務的方案:

在客戶端和服務端之間的 Nginx 可以爲服務端的應用提供一個穩定可靠的網關。

下面通過一個簡單完整的 todo 示例來演示 gRPC-web + Nginx + Go 服務端的應用。

創建 todo.proto 協議文件

定義接口和數據類型:

syntax = "proto3";
package todo;

message getTodoParams{}

message addTodoParams {
  string task = 1;
}

message deleteTodoParams {
  string id = 1;
}

message todoObject {
  string id = 1;
  string task = 2;
}

message todoResponse {
  repeated todoObject todos = 1;
}

message deleteResponse {
  string message = 1;
}  

service todoService {
  rpc addTodo(addTodoParams) returns (todoObject) {}
  rpc deleteTodo(deleteTodoParams) returns (deleteResponse) {}
  rpc getTodos(getTodoParams) returns (todoResponse) {}
}

這裏使用的是 protobuf 的 v3 版本規範。通過 service 定義了一個 todoService,其中包含了 addTodo、deleteTodo、getTodos 等是三個 RPC 方法,以及通過 message 定義這些方法中請求參數和返回類型。

詳細的 protobuf 規範參考

服務端代碼生成

本示例的服務端採用 Go,下面使用 protoc 編譯生成工具來生成服務端代碼,執行如下命令行:

➜  todo protoc -I ./todo/ --go_out=plugins=grpc:./todo/ ./todo/todo.proto
➜  todo tree
.
├── todo
│   ├── todo.pb.go
│   └── todo.proto
└── todo-client

在上面的 protoc 命令中,指定 proto 文件的路徑在 ./todo/ 下面,且指定了生成的源碼文件 todo.pb.go 輸出路徑也爲 ./todo/。

下面我們創建一個 handler.go 來處理這些 RPC 方法:

package todo

import (
  "log"
  "golang.org/x/net/context"
  "github.com/satori/go.uuid"
)

type Server struct {
  Todos []*TodoObject
}

func (s *Server) AddTodo(ctx context.Context, newTodo *AddTodoParams) (*TodoObject, error) {
  log.Printf("Received new task %s", newTodo.Task)
  todoObject := &TodoObject{
    Id: uuid.NewV1().String(),
    Task: newTodo.Task,
  }
  s.Todos = append(s.Todos, todoObject)
  return todoObject, nil
}

func (s *Server) GetTodos(ctx context.Context, _ *GetTodoParams) (*TodoResponse, error) {
  log.Printf("get tasks")
  return &TodoResponse{Todos: s.Todos}, nil
}

func (s *Server) DeleteTodo(ctx context.Context, delTodo *DeleteTodoParams) (*DeleteResponse, error) {
  var updatedTodos []*TodoObject
  for index, todo := range s.Todos {
    if(todo.Id == delTodo.Id) {
      updatedTodos = append(s.Todos[:index], s.Todos[index + 1:]...)
        break;
    }
  }
  s.Todos = updatedTodos
  return &DeleteResponse{Message: "success"}, nil
}

如上示例,在服務運行時的 Todos 數組中存儲所有 todo 記錄,所有的 RPC 方法都 “綁定” 在 Server 結構體下,其中:

  • AddTodo :其中使用了 uuid 包來爲每一條 todo 記錄生成唯一的標識,並被 append 到 Todos 數組後;
  • DeleteTodo :根據 todo.id 找到對應的 todo 記錄並從 Todos 列表中移除和更新;
  • GetTodo :簡單的返回整個 Todo 列表;

Go support for Protocol Buffers

服務端入口程序

我們還需要啓動 RPC 服務器的入口程序 server.go,位於 todo 根目錄下,此時目錄結構如下:

➜  todo tree
.
├── server.go
├── todo
│   ├── handler.go
│   ├── todo.pb.go
│   └── todo.proto
└── todo-client

server.go 的源碼如下:

package main

import (
    "fmt"
    "log"
    "net"

    "github.com/thearavind/grpc-todo/todo"
    "google.golang.org/grpc"
)

func main() {
    lis, err: = net.Listen("tcp", fmt.Sprintf(":%d", 50096))
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    s: = todo.Server {}
    grpcServer: = grpc.NewServer()
    // attach the todo service to the server
    todo.RegisterTodoServiceServer(grpcServer, & s)

    if err: = grpcServer.Serve(lis);
    err != nil {
        log.Fatalf("failed to serve: %s", err)
    } else {
        log.Printf("Server started successfully")
    }
}

在 main() 主函數入口中,首先啓動 TCP 監聽,端口爲 50096,之後創建 Server 和 grpcServer 實例,並通過 RegisterTodoServiceServer 將我們的 todo 服務註冊到這個新建的 grpcServer 上,最後啓動 grpc server。

我們可以通過 go run server.go 來啓動服務端:

Nginx 代理配置

Nginx 配置如下:

server {
    listen       9080;
    server_name  localhost;

    location / {
        grpc_set_header     Content-Type application/grpc;
        grpc_pass           grpc://localhost:50098;

        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' "*";
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE';
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Transfer-Encoding,Custom-Header-1,X-Accept-Content-Transfer-Encoding,X-Accept-Response-Streaming,X-User-Agent,X-Grpc-Web';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }

        if ($request_method = 'POST') {
            add_header 'Access-Control-Allow-Origin' '*';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Transfer-Encoding,Custom-Header-1,X-Accept-Content-Transfer-Encoding,X-Accept-Response-Streaming,X-User-Agent,X-Grpc-Web';
            add_header 'Access-Control-Expose-Headers' 'Content-Transfer-Encoding,Grpc-Message,Grpc-Status';
        }
    }
}

通過 grpc_pass 來實現 grpc 協議的代理,重定向至服務端 50098 端口。

注意:這裏需要通過 grpc_set_header 來設置 Content-Type 爲 application/grpc,默認爲 application/grpc-web。

使用 vuejs 實現的前端應用

我們通過 vue 來創建一個簡單的 todo-client:

➜  todo vue create todo-client
➜  todo tree -L 2 
.
├── go.mod
├── go.sum
├── server.go
├── todo
│   ├── handler.go
│   ├── todo.pb.go
│   └── todo.proto
└── todo-client
    ├── babel.config.js
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── public
    ├── README.md
    └── src

下面通過 grpc-web 插件生成 Javascript 的接口代碼:

➜  todo protoc -I=./todo todo.proto --js_out=import_style=commonjs:./todo-client/src --grpc-web_out=import_style=commonjs,mode=grpcweb:./todo-client/src
➜  todo tree -L 2
.
├── go.mod
├── go.sum
├── server.go
├── todo
│   ├── handler.go
│   ├── todo.pb.go
│   └── todo.proto
└── todo-client
    ├── babel.config.js
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── public
    ├── README.md
    └── src
        ├── App.vue
        ├── assets
        ├── components
        ├── main.js
        ├── todo_grpc_web_pb.js
        └── todo_pb.js

在 todo-client/src 目錄下生成 todo_grpc_web_pb.js 和 todo_pb.js 文件,下面我們在 App.vue 中引入:

<script>
import { addTodoParams, getTodoParams, deleteTodoParams } from "./todo_pb";
import { todoServiceClient } from "./todo_grpc_web_pb";

export default {
  name: 'App',
  components: {
  },
  data: function() {
    return {
      inputField: "",
      todos: []
    };
  },
  created: function() {
    this.client = new todoServiceClient("http://192.168.1.188:9080", null, null);
    this.getTodos();
  },
  methods: {
    getTodos: function() {
      let getRequest = new getTodoParams();
      this.client.getTodos(getRequest, {}, (err, response) => {
        this.todos = response.toObject().todosList;
        console.log(this.todos);
      });
    },
    addTodo: function() {
      let request = new addTodoParams();
      request.setTask(this.inputField);
      this.client.addTodo(request, {}, () => {
        this.inputField = "";
        this.getTodos();
      });
    },
    deleteTodo: function(todo) {
      let deleteRequest = new deleteTodoParams();
      deleteRequest.setId(todo.id);
      this.client.deleteTodo(deleteRequest, {}, (err, response) => {
        if (response.getMessage() === "success") {
          this.getTodos();
        }
      });
      console.log("todo -> ", todo.id);
    }
  }
}
</script>

在 created 階段創建 todoServiceClient 實例,其中第一個參數爲代理的主機地址端口,這裏爲 Nginx 的(HTTP)服務地址和端口。在 getTodos, addTodo, deleteTodo 等方法中可以創建 RPC 訪問的請求參數實例,並且在異步調用後,通過 response 來獲取相應信息。如下爲示例圖:

References

相關文章