GRPC 概念及应用

266 阅读6分钟

1. GRPC

1.1. 什么是 RPC

r(remote) p(process) c(call), 远程过程调用, 用来在一台机器上调用另一台机器的服务

1.2. 什么是 GRPC

1.2.1. 基本介绍

1.2.1.1. Protocol Buffer

  • 用于序列化结构化数据的成熟机制
  • 一种接口定义语言 (IDL), 通过 .proto 文件定义

借助 protoc 命令行工具, 以及周边的 language plugin, 可以直接生成多种语言的代码

1.2.1.2. GRPC

  • Google 的开源 rpc 框架, 基于 http/2

  • 流推送能力

  • 集成 protocol buffer

grpc 默认以 protocol buffer 作为接口定义语言

1.2.2. 核心概念

RPC 请求类型

  • Unary RPC
  • Server Streaming RPC
  • Client Streaming RPC
  • Bidirection Streaming RPC

取消 RPC

  • 客户端和服务的都可以在任何时间取消一个 RPC

通道

  • 通道提供了对 gRPC server 特定主机号和端口号的连接, 被用于创建客户端 stub (请求对象)

1.3. GRPC vs HTTP + RESTFUL

http + restful

  • 传输文本数据
  • 接口可读性好

grpc (http2 + protoc buffer)

  • 传输 base64 / 二进制数据
  • 可读性差
  • 更高性能, protocol buffer 在压缩大量数据时要优于纯文本, json ...

1.4. GRPC 规范

  • Request → Request-Headers *Length-Prefixed-Message EOS
  • Response → (Response-Headers *Length-Prefixed-Message Trailers) / Trailers-Only

规范以 ABNF 语法编写, 如果有看过一些编程语言的规范应该大概能看懂, 例如上面定义前面加 * 号表示多个

总体来讲基本上就是一个递归定义的语法 ?


2. GRPC-WEB

2.1. 什么是 GRPC-WEB

GRPC-WEB 就是针对 Web 端做的特殊 rpc 框架, 专门用于生成 Web 端的 stub

2.1.1. Web 端的特殊性

既然有了 GRPC, 那么不是可以直接用 .proto 文件加上命令行工具 protoc 加上 js plugin 生成客户端代码, 然后在浏览器上进行请求吗

由于浏览器的各种限制, 不行:

  • 浏览器的请求 api 如 xhr, fetch 并没有暴露出足够的信息来实现 grpc 规范
  • 不支持 response trailer (GRPC 规范的一部分)
  • 难以实现 channel, frame
  • web 客户端更倾向于接受文本数据
  • web 端特有限制 : cors, 安全性 (xsrf ...) ...
  • ...

2.1.2. GRPC-WEB 规范

规范的设计目标是让代理服务器更容易翻译 web 端的请求

2.1.2.1. 设计目标

  • adopt the same framing as “application/grpc” whenever possible

  • decouple from HTTP/2 framing which is not, and will never be, directly exposed by browsers

    将实现与浏览器永远不可能会直接暴露出来的 http/2 帧传输解耦

  • support text streams (e.g. base64) in order to provide cross-browser support (e.g. IE-10)

    支持文本流来提供跨浏览器支持

2.1.2.2. GRPC-WEB 规范 vs GRPC 规范

  • 不限制 http 版本
  • Content-Type: application/grpc-web[-text][+proto]
  • Trailers must be the last message of the response, as enforced by the implementation
  • Response status encoded as part of the response body
  • use EOF (end of body) to close the stream
  • 小写 http header/trailer 名
  • 限制: 只支持 unary call 和 server streaming(且只能用 text 模式)
  • ...

2.2. Envoy

作为 client 到 server 的翻译层, 实现 grpc-web 规范, 同时包含了 cors 处理, 使得后端不需要处理 cors

看一份基本的 envoy 配置文件:

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 8080 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              # @@@@@@@@@@@@@@ 将前端请求代理到后端接口 @@@@@@@@@@@@@
              routes:
              - match: { prefix: "/" }
                route:
                  cluster: echo_service
                  timeout: 0s
                  max_stream_duration:
                    grpc_timeout_header_max: 0s
              # @@@@@@@@@@@@@ cors 设置, 后端无需再进行处理 @@@@@@@@@@@@
              cors:
                allow_origin_string_match:
                - prefix: "*"
                allow_methods: GET, PUT, DELETE, POST, OPTIONS
                allow_headers: keep-alive,user-agent,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,grpc-timeout
                max_age: "1728000"
                expose_headers: custom-header-1,grpc-status,grpc-message
          http_filters:
          # @@@@@@@@@@@@@@ 实现 grpc web 规范 @@@@@@@@@@@@@
          - name: envoy.filters.http.grpc_web
          - name: envoy.filters.http.cors
          - name: envoy.filters.http.router
  clusters:
  - name: echo_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    load_assignment:
      cluster_name: cluster_0
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                  	# @@@@@@@@@@@@@@@@@@@@@@ 后端地址 @@@@@@@@@@@@@@@@@@@@@
                    address: 0.0.0.0
                    port_value: 9090


3. 应用方案

3.1 grpc-web

使用命令行 protocprotoc-gen-grpc-web 插件生成 Web 客户端代码

基本使用方式

  • 创建客户端 stub : new Client(address, credentials, options)

    options 里可以定义拦截器

  • 创建请求 :

    const req = new Req()
    req.setFoo(foo)
    req.setBar(bar)
    
  • 发送 unary 请求 :

    client.someRemoteMethod(req, metaData, (err, response) => {
      if (err) {
          console.log(err.code, err.message)
      } else {
          const foo = response.getFoo()
          const bar = response.getBar()
      }
    })
    
  • 发送 server streaming 请求 :

    const stream = client.someRemoteStreamingMethod(req)
    stream.on('data', response => {
        const foo = response.getFoo()
        const bar = response.getBar()
    })
    stream.on('status', status => {
        console.log(status.code, status.details, status.metaData)
    })
    stream.on('end', () => {
        console.log('stream end')
    })
    

3.2 Server Streaming

对比轮询: 长链接, 避免资源浪费, 何时结束掌控权完全在客户端/服务端

适用场景

  • 大规模数据包
  • 实时场景: 例如页面表格持续刷新以更新状态

基本测试场景: 一个表格, 字段里有 progress, 需要实时更新进度信息

proto 定义

syntax = "proto3";

option go_package = "/proto";

package test;

message Req {
  int64 page = 1;
}

message Res {
  string name = 1;
  string description = 2;
  float progress = 3;
}

service Service {
  // 添加 stream 字段以启用服务端流
  rpc Test(Req) returns (stream Res) {};
}

前端

主要关注的问题有

  • 页面状态变化时取消上一次的流, 例如表格翻页, 需要关闭当前页的数据流, 防止上一页的数据影响当页 ui
  • 重新请求新的流
  • 页面离开, ui 组件销毁时, 需要留意取消流, 防止资源浪费

基本代码实现

import { ServiceClient } from './proto/api_grpc_web_pb'
import { Req, Res } from './proto/api_pb'

import {
  createApp,
  defineComponent,
  h, 
  ref,
  onMounted
} from 'vue'

import {
  NSpace,
  NDataTable,
  NProgress,
} from 'naive-ui'

console.log('ClientClass', ServiceClient)
console.log('ReqClass', Req)
console.log('ResClass', Res)

// @@@@@@@@@@@@@@ 注意地址应该是 envoy 代理的地址 @@@@@@@@@@@@
const address = 'http://localhost:8080'

// @@@@@@@@@@@@@@ 获取 client stub @@@@@@@@@@@@@@@
const client = new ServiceClient(address, null, null)

const App = defineComponent({
  name: 'App',
  setup (props, ctx) {
    const dataRef = ref([])
    const pageRef = ref(1)
    const streamRef = ref(null)

    const request = (page) => {
      const req = new Req()
      req.setPage(page - 1)

      console.log('Client', client)
      console.log('Req', req)

      const stream = client.test(req)
      streamRef.value = stream

      console.log('stream', stream)
      console.log('can stream cancel ?', 'cancel' in stream)

      stream.on('data', response => {
        console.log('response', response.array)

        dataRef.value = [
          {
            name: response.getName(),
            description: response.getDescription(),
            progress: response.getProgress(),
          },
        ]
      })

      stream.on('status', status => {
        console.log('status', status)
      })

      stream.on('end', end => {
        console.log('end', end)
      })
    }
    const cancelRequest = () => {
      if (streamRef.value && 'cancel' in streamRef.value) {
        streamRef.value.cancel()
      }
    }

    onMounted(() => {
      request(pageRef.value)
    })

    return {
      columns: [
        {
          title: 'name',
          key: 'name',
        },
        {
          title: 'description',
          key: 'description',
        },
        {
          title: 'progress',
          key: 'progress',
          render (rowData) {
            return h(
              NProgress,
              {
                type: 'line',
                percentage: rowData.progress.toFixed(4) * 100
              },
            )
          },
        },
      ],
      data: dataRef,
      page: pageRef,
      stream: streamRef,
      request,
      cancelRequest,
    }
  },
  render () {
    const self = this
    const { columns, } = self

    const title = h(
      'h1',
      { style: 'text-align: center' },
      'Grpc-web server streaming test',
    )
    const content = h(
      NSpace,
      { justify: 'center' },
      [
        h(
          NDataTable,
          {
            style: 'width: 400px',
            columns,
            data: this.data,
            remote: true,
            pagination: {
              page: this.page,
              // @@@@@@@@@@@@@@@@@ 基本 demo, 配合后端先限制为 2, 以展示翻页需要取消上一次的流这个点 @@@@@@@@@@@@
              pageCount: 2,
            },
            onUpdatePage (page) {
              self.page = page

              // cancel previous page streaming
              self.cancelRequest()

              // start new page streaming
              self.request(page)

            }
          },
        )
      ]
    )

    return h(
      'div',
      null,
      [
        title,
        content
      ]
    )
  },
})

const app = createApp(App)
app.mount('#app')

后端

package main

import (
	"log"
	"net"
	"time"

	pb "example.com/streaming/proto"
	"google.golang.org/grpc"
)

// @@@@@@@@@@@@@@@ 后端地址 @@@@@@@@@@@@@@@@@
const (
	port = ":9090"
)

type server struct {
	pb.ServiceServer
}

// @@@@@@@@@@@@@@@@@@@@@@@@@@@ 模拟表格的两页数据 @@@@@@@@@@@@@@@@@@@@@@@@@
var response_array [][]pb.Res = [][]pb.Res{
	// @@@@@@@@@@@@@@@@@ 模拟表格第一页第一条数据的进度过程 @@@@@@@@@@@@@@@@@@
	{
		pb.Res{Name: "a", Description: "description a", Progress: 0.3},
		pb.Res{Name: "a", Description: "description a", Progress: 0.5},
		pb.Res{Name: "a", Description: "description a", Progress: 0.7},
		pb.Res{Name: "a", Description: "description a", Progress: 0.9},
		pb.Res{Name: "a", Description: "description a", Progress: 1.0},
	},
	// @@@@@@@@@@@@@@@@@ 模拟表格第二页第一条数据的进度过程 @@@@@@@@@@@@@@@@@@
	{
		pb.Res{Name: "b", Description: "description b", Progress: 0.2},
		pb.Res{Name: "b", Description: "description b", Progress: 0.3},
		pb.Res{Name: "b", Description: "description b", Progress: 0.35},
		pb.Res{Name: "b", Description: "description b", Progress: 0.49},
		pb.Res{Name: "b", Description: "description b", Progress: 0.65},
		pb.Res{Name: "b", Description: "description b", Progress: 0.79},
		pb.Res{Name: "b", Description: "description b", Progress: 0.85},
		pb.Res{Name: "b", Description: "description b", Progress: 1.0},
	},
}

// @@@@@@@@@@@@@@ 服务端推流方法 @@@@@@@@@@@@@@@
func (s *server) Test(req *pb.Req, stream pb.Service_TestServer) error {
	page := req.Page
	responses := response_array[page]
	for i := 0; i < len(responses); i++ {
        // @@@@@@@@@@@@@ 手动模拟 2s delay @@@@@@@@@@@@@
		time.Sleep(2 * time.Second)
		if err := stream.Send(&responses[i]); err != nil {
			return err
		}
	}
	return nil
}

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

	s := grpc.NewServer()
	pb.RegisterServiceServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())

	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

可以看到, 客户端与服务端维持了一个 10s 的长链接, 符合预期

Demo 源码

References

grpc 介绍

grpc 核心概念

grpc 规范

grpc-web 规范

Interceptors in gRPC-Web