23

使用 grpc-web, vue 和 Nginx 搭建一个简单 todo 示例

 3 years ago
source link: https://stevenocean.github.io/2020/06/20/nginx-grpc-web-go.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

关于 gRPC

gRPC 是一个高性能、通用的开源 RPC 框架,其由 Google 主要面向移动应用开发并基于 HTTP/2 协议标准而设计,基于 ProtoBuf (Protocol Buffers) 序列化协议开发,且支持众多开发语言()。

gRPC 提供了一种简单的方法来精确地定义服务和为iOS、Android 和 后台支持服务自动生成可靠性很强的客户端功能库。客户端充分利用高级流和链接功能,从而有助于节省带宽、降低的 TCP 链接次数、节省 CPU 使用、和电池寿命。下图为 gRPC 结构图:

r26JbeJ.jpg!web

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 服务的方案:

iIjqUrb.png!web

在客户端和服务端之间的 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 来获取相应信息。如下为示例图:

BJzyA32.jpg!web

References


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK