使用 grpc-web, vue 和 Nginx 搭建一個簡單 todo 示例
關於 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 定義這些方法中請求參數和返回類型。
服務端代碼生成
本示例的服務端採用 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 來獲取相應信息。如下爲示例圖: