跳到主要内容

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
需要类型安全的 APIgRPC-Web
公开 API,面向第三方REST/GraphQL
需要浏览器直接访问gRPC-Web
实时双向通信WebSocket 或 gRPC-Web 流

架构概览

gRPC-Web 的核心是代理层,它负责:

  1. 接收浏览器的 gRPC-Web 请求
  2. 转换为标准 gRPC 协议
  3. 转发到后端 gRPC 服务
  4. 将响应转换回 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 配置要点

  1. grpc_web 过滤器:必须放在 router 过滤器之前
  2. CORS 配置:允许跨域请求,开发环境可以设置为 *
  3. HTTP/2 后端:后端集群必须启用 HTTP/2
  4. 超时配置:通过 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 连接复用

优化建议

  1. 将 Envoy 部署在与后端相同的数据中心
  2. 启用 gzip 压缩(在 Envoy 中配置)
  3. 使用 CDN 缓存静态资源
  4. 对于大量数据,考虑分页或流式传输

生产部署

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 的核心概念和实践:

  1. 架构理解:浏览器 → Envoy 代理 → gRPC 服务
  2. 快速开始:从 proto 定义到完整的前端调用
  3. TypeScript 支持:类型安全的前端代码
  4. 框架集成:React 等前端框架的使用方式
  5. 生产部署:Docker 和 Nginx 配置
  6. 功能限制:了解 gRPC-Web 与标准 gRPC 的差异

gRPC-Web 让前端开发者能够以类型安全的方式调用 gRPC 服务,是现代 Web 应用与微服务通信的优秀选择。

> [!TIP] > 如果需要客户端流或双向流功能,可以考虑使用 WebSocket 或 Server-Sent Events (SSE) 作为补充方案。