gRPC-Web:浏览器客户端
gRPC-Web 是 gRPC 的扩展,允许浏览器客户端直接调用 gRPC 服务。由于浏览器环境的限制,gRPC-Web 使用不同的传输协议,需要通过代理(如 Envoy)进行协议转换。
为什么需要 gRPC-Web?
浏览器的限制
标准 gRPC 基于 HTTP/2,但浏览器环境存在以下限制:
- 无法直接访问 HTTP/2 帧:浏览器的 Fetch API 和 XMLHttpRequest 不暴露 HTTP/2 的底层细节
- 无法发送 trailers:gRPC 使用 HTTP trailers 传递状态信息,但浏览器无法读取
- 流式传输受限:浏览器对双向流的支持有限
gRPC-Web 通过以下方式解决这些问题:
- 使用 HTTP/1.1 或 HTTP/2 的文本或二进制编码
- 通过代理(如 Envoy)转换协议
- 提供与标准 gRPC 类似的客户端 API
适用场景
| 场景 | 推荐方案 |
|---|---|
| 内部管理后台 | gRPC-Web |
| 需要类型安全的 API | gRPC-Web |
| 公开 API,面向第三方 | REST/GraphQL |
| 需要浏览器直接访问 | gRPC-Web |
| 实时双向通信 | WebSocket 或 gRPC-Web 流 |
架构概览
gRPC-Web 的核心是代理层,它负责:
- 接收浏览器的 gRPC-Web 请求
- 转换为标准 gRPC 协议
- 转发到后端 gRPC 服务
- 将响应转换回 gRPC-Web 格式
快速开始
项目结构
grpc-web-project/
├── proto/
│ └── echo.proto # 服务定义
├── server/
│ └── main.go # gRPC 服务端
├── client/
│ ├── index.html # 前端页面
│ ├── client.js # JS 客户端代码
│ └── package.json
├── envoy/
│ └── envoy.yaml # Envoy 配置
└── generated/
├── echo_pb.js # 消息定义
└── echo_grpc_web_pb.js # 客户端存根
定义 Proto
// proto/echo.proto
syntax = "proto3";
package echo;
option go_package = "./echo";
// Echo 服务
service EchoService {
// 一元 RPC
rpc Echo(EchoRequest) returns (EchoResponse);
// 服务端流
rpc ServerStream(StreamRequest) returns (stream StreamResponse);
}
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
}
message StreamRequest {
string message = 1;
int32 count = 2;
}
message StreamResponse {
string message = 1;
int32 index = 2;
}
后端服务实现
后端使用标准 gRPC 实现,与普通 gRPC 服务没有区别:
// server/main.go
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
pb "grpc-web-project/proto/echo"
)
type server struct {
pb.UnimplementedEchoServiceServer
}
// Echo 一元 RPC
func (s *server) Echo(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
log.Printf("收到请求: %s", req.Message)
return &pb.EchoResponse{
Message: "Echo: " + req.Message,
}, nil
}
// ServerStream 服务端流
func (s *server) ServerStream(req *pb.StreamRequest, stream pb.EchoService_ServerStreamServer) error {
for i := 0; i < int(req.Count); i++ {
err := stream.Send(&pb.StreamResponse{
Message: fmt.Sprintf("消息 %d: %s", i+1, req.Message),
Index: int32(i + 1),
})
if err != nil {
return err
}
}
return nil
}
func main() {
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalf("监听失败: %v", err)
}
s := grpc.NewServer()
pb.RegisterEchoServiceServer(s, &server{})
log.Println("gRPC 服务启动在 :9090")
s.Serve(lis)
}
Envoy 代理配置
Envoy 是 gRPC-Web 最常用的代理,负责协议转换:
# envoy/envoy.yaml
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 9901
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
max_stream_duration:
grpc_timeout_header_max: 0s
http_filters:
# gRPC-Web 过滤器(必须放在 router 之前)
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.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
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: echo_service
connect_timeout: 0.25s
type: LOGICAL_DNS
# 如果使用 Docker,使用服务名
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: echo_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: server # Docker 服务名,本地开发用 host.docker.internal
port_value: 9090
Envoy 配置要点:
- grpc_web 过滤器:必须放在 router 过滤器之前
- CORS 配置:允许跨域请求,开发环境可以设置为
* - HTTP/2 后端:后端集群必须启用 HTTP/2
- 超时配置:通过
grpc-timeout头传递超时
生成前端代码
安装 protoc 和 gRPC-Web 插件:
# 安装 protoc(参考安装章节)
# 安装 gRPC-Web 插件
# macOS
brew install grpc-web
# 或从源码编译
git clone https://github.com/grpc/grpc-web
cd grpc-web
make install-plugin
# 生成消息代码
protoc -I=./proto \
--js_out=import_style=commonjs,binary:./generated \
proto/echo.proto
# 生成 gRPC-Web 客户端存根
protoc -I=./proto \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:./generated \
proto/echo.proto
输出选项说明:
| 选项 | 说明 |
|---|---|
import_style=commonjs | 使用 CommonJS 模块格式 |
import_style=closure | 使用 Google Closure 模块格式 |
mode=grpcwebtext | 使用 text 编码(默认),支持服务端流 |
mode=grpcweb | 使用二进制编码,不支持流 |
前端客户端代码
// client/client.js
const { EchoRequest, StreamRequest } = require('./generated/echo_pb.js');
const { EchoServiceClient } = require('./generated/echo_grpc_web_pb.js');
// 创建客户端
// 地址是 Envoy 代理地址,不是 gRPC 服务地址
const client = new EchoServiceClient('http://localhost:8080');
// 一元 RPC 调用
function callEcho() {
const request = new EchoRequest();
request.setMessage('Hello gRPC-Web!');
// metadata 是可选的,用于传递元数据
const metadata = {
'custom-header': 'value'
};
// 调用方法
client.echo(request, metadata, (err, response) => {
if (err) {
console.error('错误:', err.message);
return;
}
console.log('响应:', response.getMessage());
});
}
// 服务端流调用
function callServerStream() {
const request = new StreamRequest();
request.setMessage('Stream test');
request.setCount(5);
// 流式调用返回一个流对象
const stream = client.serverStream(request, {});
// 监听数据事件
stream.on('data', (response) => {
console.log(`收到消息 ${response.getIndex()}: ${response.getMessage()}`);
});
// 监听结束事件
stream.on('end', () => {
console.log('流结束');
});
// 监听错误事件
stream.on('error', (err) => {
console.error('流错误:', err.message);
});
// 可以手动取消
// stream.cancel();
}
// 调用
callEcho();
callServerStream();
前端页面
<!-- client/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>gRPC-Web Demo</title>
</head>
<body>
<h1>gRPC-Web Demo</h1>
<div>
<input type="text" id="message" placeholder="输入消息" value="Hello">
<button onclick="sendEcho()">发送</button>
</div>
<div>
<h3>响应:</h3>
<pre id="response"></pre>
</div>
<script src="./bundle.js"></script>
<script>
function sendEcho() {
const message = document.getElementById('message').value;
callEcho(message);
}
</script>
</body>
</html>
使用 Webpack 打包
// client/webpack.config.js
const path = require('path');
module.exports = {
entry: './client.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
mode: 'development',
devServer: {
contentBase: './',
port: 9000,
},
};
// client/package.json
{
"name": "grpc-web-client",
"version": "1.0.0",
"scripts": {
"build": "webpack",
"start": "webpack serve --open"
},
"dependencies": {
"google-protobuf": "^3.25.0",
"grpc-web": "^1.5.0"
},
"devDependencies": {
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.0"
}
}
TypeScript 支持
gRPC-Web 支持 TypeScript,提供更好的类型安全:
安装依赖
npm install google-protobuf grpc-web
npm install -D @types/google-protobuf
生成 TypeScript 代码
# 生成 TypeScript 消息代码
protoc -I=./proto \
--js_out=import_style=commonjs,binary:./generated \
--grpc-web_out=import_style=typescript,mode=grpcwebtext:./generated \
proto/echo.proto
TypeScript 客户端
// client.ts
import { EchoRequest, StreamRequest } from './generated/echo_pb';
import { EchoServiceClient } from './generated/EchoServiceClientPb';
// 创建客户端
const client = new EchoServiceClient('http://localhost:8080');
// 一元 RPC
async function echo(message: string): Promise<string> {
const request = new EchoRequest();
request.setMessage(message);
return new Promise((resolve, reject) => {
client.echo(request, {}, (err, response) => {
if (err) {
reject(err);
} else {
resolve(response.getMessage());
}
});
});}
// 服务端流
function serverStream(message: string, count: number): void {
const request = new StreamRequest();
request.setMessage(message);
request.setCount(count);
const stream = client.serverStream(request, {});
stream.on('data', (response) => {
console.log(`收到: ${response.getMessage()}`);
});
stream.on('error', (err) => {
console.error('错误:', err);
});
stream.on('end', () => {
console.log('完成');
});
}
// 使用
echo('Hello TypeScript').then(console.log);
React 集成
在 React 应用中使用 gRPC-Web:
// hooks/useEchoService.ts
import { useCallback, useState } from 'react';
import { EchoRequest } from '../generated/echo_pb';
import { EchoServiceClient } from '../generated/EchoServiceClientPb';
const client = new EchoServiceClient('http://localhost:8080');
export function useEchoService() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const echo = useCallback(async (message: string) => {
setLoading(true);
setError(null);
const request = new EchoRequest();
request.setMessage(message);
return new Promise((resolve, reject) => {
client.echo(request, {}, (err, response) => {
setLoading(false);
if (err) {
setError(err.message);
reject(err);
} else {
resolve(response.getMessage());
}
});
});
}, []);
return { echo, loading, error };
}
// 使用
function EchoComponent() {
const { echo, loading, error } = useEchoService();
const [response, setResponse] = useState('');
const handleClick = async () => {
try {
const result = await echo('Hello React');
setResponse(result as string);
} catch (e) {
console.error(e);
}
};
return (
<div>
<button onClick={handleClick} disabled={loading}>
{loading ? '发送中...' : '发送'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{response && <p>响应: {response}</p>}
</div>
);
}
高级配置
自定义元数据
// 发送自定义元数据
const metadata = {
'authorization': 'Bearer token123',
'x-request-id': 'req-001',
'custom-header': 'custom-value'
};
client.echo(request, metadata, callback);
超时设置
// 设置超时(毫秒)
const metadata = {
'grpc-timeout': '5000m' // 5秒
};
// 超时格式:<值><单位>
// h - 小时
// m - 分钟
// s - 秒
// ms - 毫秒
// us - 微秒
// n - 纳秒
拦截器
gRPC-Web 支持拦截器,用于添加日志、认证等:
// 自定义拦截器
class LoggingInterceptor {
intercept(request, invoker) {
console.log('请求:', request.getMethodName());
const startTime = Date.now();
return invoker(request).then(response => {
console.log(`响应耗时: ${Date.now() - startTime}ms`);
return response;
});
}
}
// 注册拦截器
const client = new EchoServiceClient('http://localhost:8080', null, {
unaryInterceptors: [new LoggingInterceptor()],
streamInterceptors: [new LoggingInterceptor()]
});
认证集成
// JWT 认证拦截器
class AuthInterceptor {
constructor(tokenProvider) {
this.tokenProvider = tokenProvider;
}
intercept(request, invoker) {
const token = this.tokenProvider.getToken();
const metadata = request.getMetadata();
metadata['authorization'] = `Bearer ${token}`;
return invoker(request);
}
}
// 使用
const authInterceptor = new AuthInterceptor({
getToken: () => localStorage.getItem('token')
});
const client = new EchoServiceClient('http://localhost:8080', null, {
unaryInterceptors: [authInterceptor]
});
与标准 gRPC 的差异
支持的功能
| 功能 | gRPC-Web | 标准 gRPC |
|---|---|---|
| 一元 RPC | ✅ | ✅ |
| 服务端流 | ✅ | ✅ |
| 客户端流 | ❌ | ✅ |
| 双向流 | ❌ | ✅ |
| 压缩 | ❌ | ✅ |
| 拦截器 | ✅ | ✅ |
| 元数据 | ✅ | ✅ |
| Deadline/Timeout | ✅ | ✅ |
性能考虑
- 额外跳转:请求需要经过代理,增加延迟
- 协议开销:gRPC-Web 编码不如原生 HTTP/2 高效
- 连接复用:浏览器自动管理 HTTP 连接复用
优化建议:
- 将 Envoy 部署在与后端相同的数据中心
- 启用 gzip 压缩(在 Envoy 中配置)
- 使用 CDN 缓存静态资源
- 对于大量数据,考虑分页或流式传输
生产部署
Docker Compose 示例
# docker-compose.yml
version: '3'
services:
server:
build: ./server
ports:
- "9090:9090"
envoy:
image: envoyproxy/envoy:v1.28-latest
ports:
- "8080:8080"
- "9901:9901"
volumes:
- ./envoy/envoy.yaml:/etc/envoy/envoy.yaml
depends_on:
- server
client:
build: ./client
ports:
- "9000:9000"
depends_on:
- envoy
Nginx 代理
除了 Envoy,也可以使用 Nginx 作为 gRPC-Web 代理:
# nginx.conf
server {
listen 8080 http2;
location / {
grpc_pass grpc://server:9090;
# CORS 配置
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,x-grpc-web';
if ($request_method = 'OPTIONS') {
return 204;
}
}
}
调试技巧
浏览器开发者工具
gRPC-Web 请求在 Network 面板中显示为 HTTP 请求:
- 请求体:查看发送的 protobuf 消息(可能需要解码)
- 响应体:查看服务器返回的数据
- 请求头:检查
content-type: application/grpc-web-text
启用调试日志
// 在开发环境启用 gRPC-Web 调试日志
const client = new EchoServiceClient('http://localhost:8080', null, {
debug: true
});
常见错误排查
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Http response at 400 or 500 level | 后端或代理错误 | 检查后端日志,确认 Envoy 配置 |
Unknown method | 方法名不匹配 | 检查 proto 定义和生成代码 |
CORS error | 跨域配置问题 | 配置 Envoy CORS 过滤器 |
Net::ERR_INCOMPLETE_CHUNKED_ENCODING | 流传输问题 | 检查代理超时配置 |
小结
本章介绍了 gRPC-Web 的核心概念和实践:
- 架构理解:浏览器 → Envoy 代理 → gRPC 服务
- 快速开始:从 proto 定义到完整的前端调用
- TypeScript 支持:类型安全的前端代码
- 框架集成:React 等前端框架的使用方式
- 生产部署:Docker 和 Nginx 配置
- 功能限制:了解 gRPC-Web 与标准 gRPC 的差异
gRPC-Web 让前端开发者能够以类型安全的方式调用 gRPC 服务,是现代 Web 应用与微服务通信的优秀选择。
> [!TIP] > 如果需要客户端流或双向流功能,可以考虑使用 WebSocket 或 Server-Sent Events (SSE) 作为补充方案。