在web领域(浏览器端、API端),REST API无疑是目前最流行同时也是非常成熟的架构解决方案,而gRPC作为新兴的RPC框架(Http/2 + Protobuf),由于浏览器对gRPC的支持不足使其很难匹敌 REST 的通用支持能力,现阶段gRPC更适合:
关于gRPC与REST API间的对比,可以参见下表:
特性 | gRPC | REST |
---|---|---|
HTTP协议 | Http 2 客户端-响应通信模型, 支持流通信和双向流, 继承Http 2的优越特性(header压缩、多路复用、双向流…) |
Http 1.1 请求-响应模型(队头阻塞…) |
浏览器支持 | 浏览器原生不支持, 需借助grpc-web + envoy代理来处理Http1.1 和gRPC Http2之间的转换 |
通用的浏览器支持 |
负载数据结构 | 默认使用Protobuf来序列化负载数据, 更高的压缩率及传输效率(更高的性能), 可读性差 |
主要依赖JSON或XML格式来收发数据, 可读性好, 传输效率差 |
代码生成功能 | 带有原生代码(支持多语言)的代码生成功能 | 开发人员必须使用Swagger或Postman这样的第三方工具来为API生成代码 |
可以类比其他RPC协议,例如国内比较流行的Dubbo,也有相同的问题,
例如:
而 手写 或者 配置 协议间转换(例如SpringMVC Controller调用@DubboReference、@GrpcClient服务)的过程是个重复且费时的过程,会浪费开发人员大量的精力,
使用REST转gRPC模式的架构如下图:
即浏览器端(JS环境)通过HTTP 1.1 + REST协议与REST API server通信,
然后由REST API server将HTTP请求(JSON格式)转换为gRPC请求(Protobuf格式),
而REST API server的工作是需要开发人员来实现或者配置的。
gRPC强制采用HTTP/2协议,但是目前在浏览器中通过JS API很难实现gRPC规范,主要原因如下:
鉴于此,gRPC社区推出了gRPC-Web规范(JavaScript实现的gRPC浏览器客户端),相较于原生gRPC其主要不同在于:
Client / Feature | Transport | Unary | Server-side streams | Client-side & bi-directional streaming |
---|---|---|---|---|
Google (grpcweb) | XHR ️ | ✔️ | ❌ | ❌ |
Google (grpcwebtext) | XHR ️ | ✔️ | ✔️ | ❌ |
Improbable | Fetch/XHR ️ | ✔️ | ✔️ | ❌ |
该组件使Web应用(浏览器端JS环境)能够:
使用grpc-web的架构如下图:
gRPC-Web模式相较于之前的REST转gRPC模式:
如上使用grpc-web协议,配套的后端gRPC服务就需要前置一个Envoy代理,这无疑增加了此方案落地的复杂程度。而熟悉Istio的都知道,Envoy是Istio的默认代理,所以在Istio环境下落地grpc-web会很方便,利用Istio的Envoy及EnvoyFilter构件可以无缝连接grpc-web应用。
Istio通过Envoy集成gRPC-Web具体过程可参见:
ServiceMesher - 构建无缝集成的gRPC-Web和Istio的云原生应用教程
Istio通过Envoy集成gRPC-Web的整体架构如下图:
概括起来如下:
示例中的emoji服务的整体创建步骤如下:
其中第1, 2, 3步中定义proto和生成服务端(Go)代码,这个是gRPC编程的基础,不做过多讲解,
其中的proto也可换成任意其他服务定义(仅支持Unary或Server Stream方法),亦可生成其他语言的服务端实现,如Java实现,
关于gRPC Java编程可参见我之前的文章:gRPC Java入门示例。
接下来讲讲需要重点关注的步骤。
第2步中除了生成后端服务端gRPC代码,还需要生成gRPC-Web对应的客户端浏览器端JavaSrcript代码:
生成命令格式:
$ protoc -I=$DIR echo.proto \
--js_out=import_style=commonjs:$OUT_DIR \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:$OUT_DIR
-I 指定proto定义文件输入目录
–js_out 生成JavaScript文件相关配置
–grpc-web_out 生成grpc-web框架相关JavaScript文件配置
$DIR 为proto定义文件的输入目录(使用时需替换为实际目录)
$OUT_DIR 为生成代码的输出路径(使用时需替换为实际目录)import_style 指定js导入方式
- closure 使用goog.require()导入
- commonjs 使用require()导入
- commonjs+dts 实验特性,除了支持commonjs导入,还支持生成*.d.ts类型文件
- typescript 实验特性,使用TypeScript生成Service stub(客户端代码)
- 注: 最后两个commonjs+dts和typescript仅支持在grpc-web_out中使用,不可在js_out中使用
mode 指定payload的格式(即发送数据格式)
- grpcwebtext
Content-type: application/grpc-web-text
- base64编码格式
- 同时支持Unary和Server Stream调用
- grpcweb
Content-type: application/grpc-web+proto
- 二进制protobuf格式
- 仅支持Unary调用
示例中的emoji.proto文件目录如下:
── grpc-web-emoji
└── emoji
└── emoji.proto
进入grpc-web-emoji目录后,执行如下生成命令:
$ protoc -I emoji/ emoji/emoji.proto \
--js_out=import_style=commonjs:emoji \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:emoji
即读取emoji目录下的emoji.proto文件定义,
以commonjs格式生成JS文件到emoji目录下(生成empoji_pb.js文件),
以commonjs格式、数据采用grpcwebtext格式,生成grpc-web框架JS文件到emoji目录下(生成emoji_grpc_web_pb.js文件),
执行命令后目录结构如下所示:
── grpc-web-emoji
└── emoji
├── emoji.proto
├── emoji_grpc_web_pb.js
└── emoji_pb.js
定义前端界面index.html
首先,让我们创建一个名为index.html
的HTML页面。该页面向用户显示一个文本编辑器,并调用一个emojize函数
(我们稍后将定义)将用户输入发送到后端emoji服务。emojize函数
还将消费后端服务返回的gRPC响应,并使用服务端返回的数据更新用户输入框。
DOCTYPE html>
<html>
<body>
<div id="editor" contentEditable="true" hidefocus="true" onkeyup="emojize()">div>
<script src="dist/main.js">script>
body>
html>
创建自定义client.js,导入grpc-web生成文件进行grpc编程
我们将如下所示的JavaScript代码放入名为client.js的
前端文件。
//此处导入之前生成的emoji目录下的相关grpc-web文件
const {EmojizeRequest, EmojizeReply} = require('emoji/emoji_pb.js');
const {EmojiServiceClient} = require('emoji/emoji_grpc_web_pb.js');
//以grpc stub方式编程(同Node.js API)
//定义客户端stub,此处服务端地址为envoy代理入口地址,此例为Istio Gateway HTTP入口
var client = new EmojiServiceClient('http://192.168.99.100:31380');
var editor = document.getElementById('editor');
window.emojize = function() {
//以grpc stub方式编程(构建请求参数)
var request = new EmojizeRequest();
request.setText(editor.innerText);
//以grpc stub方式编程(调用gRPC服务emojize方法)
client.emojize(request, {}, (err, response) => {
//处理gRPC响应信息
editor.innerText = response.getEmojizedText();
});
}
注意:
EmojiServiceClient
与后端emoji服务的连接地址
:
是http://192.168.99.100:31380
,
而非http://localhost:9000(不是后端服务的直接访问地址)
。这是因为gRPC-Web应用程序无法直接与gRPC后端通信,需要借助Enovy进行转换,
本例中使用Istio部署后端emoji服务(借助Istio Sidecar Enovy),所以可以理解为后端服务的地址为:
Istio Ingress Gateway暴露的IP地址及Istio Ingress Gateway HTTP端口
,
示例中Istio在Minikube上运行,其IP地址为192.168.99.100,默认的Istio Ingress HTTP端口为31380
。
使用npm生成main.js文件
最后,需要一些库来生成index.html
中引用的dist/main.js
文件。为此,我们使用如下的npm package.json
配置。
{
"name": "grpc-web-emoji",
"version": "0.1.0",
"description": "gRPC-Web Emoji Sample",
"devDependencies": {
"@grpc/proto-loader": "^0.3.0",
"google-protobuf": "^3.6.1",
"grpc": "^1.15.0",
"grpc-web": "^1.0.0",
"webpack": "^4.16.5",
"webpack-cli": "^3.1.0"
}
}
使用如下命令来安装库并生成dist/main.js
。
$ npm install
$ npx webpack client.js
注:
本人并非专业前端编程,此处仅是引用原文中示例进行讲解,若有问题感谢指正。
可以发现通过gRPC-Web可以:
- 浏览器端使用类似Node.js的gRPC API进行编程
- 浏览器端根据生成的代码进行编程,无需关心方法、数据的定义,可以直接通过API调用,
- 前后端可以使用统一的gRPC proto定义生成各自代码(规范接口定义、减少协作沟通)
相关资源文件汇总如下表:
资源平台 | 资源类型 | 资源名称 | 资源说明 |
---|---|---|---|
K8s | Depoyment | backend.yaml > Delopyment.backend | 定义服务部署,生成实际的工作负载Pod(具体到Docker容器), 需要先将后端gRPC服务构建成Docker镜像后方可在K8s上部署, 示例中将后端服务构建成镜像vnoronha/grpc-web-emoji |
K8s | Service | backend.yaml > Service.backend | 定义集群内服务域名、端口及负载均衡 |
Istio | Gateway | gateway.yaml > Gateway.gateway | 定义Istio网关入口 |
Istio | VirtualService | gateway.yaml > VirtualService.backend | 定义Istio服务路由规则 |
Istio | DestinationRule | gateway.yaml > DestinationRule.backend | 定义Istio服务目标及其版本 |
Istio | EnvoyFilter | filter.yaml > EnvoyFilter.grpc-web-filter | 设置Istio Envoy过滤器规则, 本示例中用于在Envoy中集成grpc-web转换过滤器 |
接下来,我们定义Kubernetes Service
和Deployment
配置,如下所示,并命名为backend.yaml
。
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: backend
spec:
replicas: 1
template:
metadata:
# 定义工作负载labels
labels:
app: backend
version: v1
spec:
containers:
- name: backend
# 后端工作负载镜像
image: vnoronha/grpc-web-emoji
imagePullPolicy: Always
ports:
# 后端工作负载暴露端口
- containerPort: 9000
---
apiVersion: v1
kind: Service
metadata:
# 后端gRPC服务名
name: backend
labels:
app: backend
spec:
ports:
# 此处port name需以grpc-开头
- name: grpc-port
port: 9000
# 关联后端工作负载labels
selector:
app: backend
通过Istio部署此服务,由于Service ports name
中的grpc-前缀
,Istio会将其识别为gRPC服务
。
由于我们希望将gRPC-Web filter
安装在backend sidecar代理上,因此我们需要在部署backend服务之前安装它
。
EnvoyFilter
配置如下所示,我们将其命名为filter.yaml
。
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: grpc-web-filter
spec:
# 选择需要设置此EnvoyFilter的工作负载labels,
# 本例中对应之前定义的Deployment.backend
workloadLabels:
app: backend
filters:
# 插入envoy.grpc_web过滤器,
# 用于转换浏览器端gRPC-Web请求到后端的gRPC服务
- listenerMatch:
listenerType: SIDECAR_INBOUND
listenerProtocol: HTTP
insertPosition:
index: FIRST
filterType: HTTP
filterName: "envoy.grpc_web"
filterConfig: {}
接下来,我们需要定义Istio Gateway -> VirtualServcie - > DestinationRule
来将HTTP流量路由到后端服务,写入名为gateway.yaml
的文件。
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: gateway
spec:
selector:
# 使用默认istio ingressgateway
istio: ingressgateway
servers:
# 监听Http协议入口端口80(实际对应31380 NodePort)
- port:
number: 80
name: http
protocol: HTTP
# 适用于所有请求域名
hosts:
- "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: backend
spec:
# 入口匹配host域名
hosts:
- "*"
# 关联的网关名(上面定义的gateway)
gateways:
- gateway
http:
# 匹配80端口
- match:
- port: 80
route:
# 路由到backend服务的9000端口的v1版本
- destination:
host: backend
port:
number: 9000
subset: v1
# 请求的跨域设置(放开gRPC请求的跨域访问)
corsPolicy:
allowOrigin:
- "*"
allowMethods:
- POST
- GET
- OPTIONS
- PUT
- DELETE
allowHeaders:
- grpc-timeout
- content-type
- 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
maxAge: 1728s
exposeHeaders:
- custom-header-1
- grpc-status
- grpc-message
allowCredentials: true
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: backend
spec:
# 目标服务的host(对应K8s Service Name,即之前的Service.backend)
host: backend
subsets:
# 定义v1版本(对应labels version:v1)
- name: v1
labels:
version: v1
注意,为了能让gRPC-Web正常工作,我们在这里定义了一个复杂的corsPolicy
。
我们现在可以按以下顺序简单地部署上述配置。
# 部署Istio EnvoyFilter,需在Depoyment之前部署
$ kubectl apply -f filter.yaml
# 部署K8s Depoyment、Servie,且Depoyment开启Istio Sidecar注入
$ kubectl apply -f <(istioctl kube-inject -f backend.yaml)
# 部署Istio Gateway、VirtualService、DestinationRule
$ kubectl apply -f gateway.yaml
backend pod
启动之后,我们可以验证gRPC-Web filter在sidecar代理中的配置是否正确
,如下所示:
# 此处backend-7bf6c8f67c-8lbm7即为启动后的backend pod名称
$ istioctl proxy-config listeners backend-7bf6c8f67c-8lbm7 --port 9000 -o json
...
"http_filters": [
{
"config": {},
"name": "envoy.grpc_web"
},
...
到了实验的最后阶段,原示例中通过Python启动一个HTTP服务,来为前端Web应用提供服务。
$ python2 -m SimpleHTTPServer 8080
Serving HTTP on 0.0.0.0 port 8080 ...
注:
通过Python启动前端服务,之前我也没尝试过,不过这个不是重点,
可以选择自己熟悉的、成熟的前端技术栈,如使用Nginx等来部署启动前端服务均可以。
让我们前往emoji web页面http://localhost:8080
.
如果一切顺利,你将拥有一个功能完整的基于gRPC-Web的Web应用,如下所示。
如果你在Chrome等浏览器上打开开发者工具,你将会看到如下所示的gRPC-Web HTTP请求。
gRPC-Web提供了一种浏览器端JS环境兼容(HTTP/1.1, HTTP/2)的模式来进行gRPC服务调用,
使得浏览器端也可以直接使用gRPC stub进行编程,无需编码服务及数据的定义,
前后端使用统一的proto定义,更易于组织间统一规范且减少沟通成本,
但是相较于REST协议(HTTP/1.1 + JSON)其数据可读性差(BASE64或者Protobuf二进制),不易于前端调试,
且gRPC-Web目前需要一个中间代理,即Envoy代理,以便将数据在HTTP和gRPC之间转换,
由于目前Istio的默认Sidecar Proxy即为Enovy,所以在Istio架构下开发人员可以更方便的集成gRPC-Web应用,
即使用Istio中的Envoy来为后端服务端原生gRPC服务添加envoy.grpc_web过滤器转换。
后续…
之前也看到过HTTP+JSON直接转gRPC的相关介绍,
即前端使用转换后的REST API进行调用,后端通过Envoy代理根据proto定义转换为原生gRPC通信,后续会再整理相关介绍。
参考:
CNCF - gRPC-Web is gonig GA - 2018/10/24
CNCF官微 - gRPC-Web迈向GA【翻译版】
https://www.grpc.io/docs/platforms/web/basics/
https://grpc.io/blog/state-of-grpc-web/
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md#protocol-differences-vs-grpc-over-http2
InfoQ - gRPC 与 REST:对比
ServiceMesher - 构建无缝集成的gRPC-Web和Istio的云原生应用教程