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
使用命令行 protoc 和 protoc-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 的长链接, 符合预期