为什么浏览器不能直接调用 gRPC
浏览器中的 Fetch API 和 XMLHttpRequest 无法控制 HTTP/2 帧(Frame)级别的传输,存在以下根本限制:
- 浏览器不允许应用程序控制 HTTP/2 流的生命周期
- gRPC 使用 HTTP/2 的 Trailer 帧传递状态码,但 Fetch API 不支持读取 Trailer
- CORS 预检(OPTIONS)机制与 gRPC 的 HTTP/2 帧格式不兼容
方案一:grpc-web + Envoy 代理
grpc-web 协议通过在 HTTP/1.1 或 HTTP/2 上模拟 gRPC 语义来解决浏览器兼容问题。需要 Envoy 代理将 grpc-web 请求转换为标准 gRPC。
架构:Browser --[grpc-web]--> Envoy --[grpc]--> gRPC Server
Envoy 配置(docker-compose.yml)
# docker-compose.yml
version: '3'
services:
envoy:
image: envoyproxy/envoy:v1.30-latest
ports:
- "8080:8080"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
grpc-server:
build: .
ports:
- "50051:50051"
# envoy.yaml
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
http_filters:
- name: envoy.filters.http.grpc_web # grpc-web 转换
- name: envoy.filters.http.cors # CORS 支持
- name: envoy.filters.http.router
clusters:
- name: grpc_service
type: LOGICAL_DNS
http2_protocol_options: {} # 后端用 HTTP/2
load_assignment:
cluster_name: grpc_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: grpc-server, port_value: 50051 }
TypeScript 客户端(grpc-web)
# 安装依赖
npm install grpc-web google-protobuf
npm install -D protoc-gen-grpc-web
# 生成 TypeScript 客户端代码
protoc \
--proto_path=proto \
--js_out=import_style=commonjs:gen_web \
--grpc-web_out=import_style=typescript,mode=grpcwebtext:gen_web \
product/v1/product.proto
// React 组件中使用 grpc-web 客户端
import { ProductServiceClient } from '../gen_web/product/v1/ProductServiceClientPb';
import { GetProductRequest } from '../gen_web/product/v1/product_pb';
const client = new ProductServiceClient('http://localhost:8080');
export function ProductDetail({ productId }: { productId: string }) {
const [product, setProduct] = useState<Product.AsObject | null>(null);
useEffect(() => {
const req = new GetProductRequest();
req.setId(productId);
client.getProduct(req, {}, (err, response) => {
if (err) {
console.error('gRPC error:', err.code, err.message);
return;
}
setProduct(response.getProduct()!.toObject());
});
}, [productId]);
if (!product) return <div>Loading...</div>;
return <div>{product.name} — ¥{product.price}</div>;
}
方案二:ConnectRPC(推荐)
ConnectRPC 是 Buf 团队开发的现代解决方案,无需 Envoy 代理,服务端直接支持 Connect 协议(兼容 grpc、grpc-web、REST JSON)。
Connect 协议:Connect 是一个轻量级协议,运行在 HTTP/1.1 或 HTTP/2 上,兼容 gRPC 和 grpc-web。浏览器可以直接调用,无需任何代理。同一服务同时兼容 gRPC、grpc-web 和 REST JSON 三种调用方式。
服务端:Go + ConnectRPC
# 安装 connect-go
go get connectrpc.com/connect
// main.go — 使用 ConnectRPC 而非原生 gRPC
package main
import (
"net/http"
"connectrpc.com/connect"
productv1 "github.com/myapp/gen/product/v1"
"github.com/myapp/gen/product/v1/productv1connect"
)
type productHandler struct{}
func (h *productHandler) GetProduct(
ctx context.Context,
req *connect.Request[productv1.GetProductRequest],
) (*connect.Response[productv1.ProductResponse], error) {
// 通过 req.Header() 读取 HTTP 头
token := req.Header().Get("Authorization")
_ = token
resp := connect.NewResponse(&productv1.ProductResponse{
Product: &productv1.Product{
Id: req.Msg.Id,
Name: "Sample Product",
},
})
return resp, nil
}
func main() {
handler := &productHandler{}
mux := http.NewServeMux()
// 注册 ConnectRPC handler(自动支持 gRPC + Connect + gRPC-Web)
path, h := productv1connect.NewProductServiceHandler(handler)
mux.Handle(path, h)
// 使用 h2c(明文 HTTP/2,开发用)
http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))
}
前端 TypeScript 客户端(ConnectRPC)
# 安装
npm install @connectrpc/connect @connectrpc/connect-web
npm install -D @bufbuild/protoc-gen-es @connectrpc/protoc-gen-connect-es
# buf.gen.yaml 生成 TypeScript 代码
plugins:
- plugin: buf.build/bufbuild/es
out: src/gen
opt: target=ts
- plugin: buf.build/connectrpc/es
out: src/gen
opt: target=ts
// src/grpcClient.ts — ConnectRPC 客户端配置
import { createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { ProductService } from './gen/product/v1/product_connect';
const transport = createConnectTransport({
baseUrl: 'http://localhost:8080',
});
export const productClient = createClient(ProductService, transport);
// src/ProductDetail.tsx
import { productClient } from './grpcClient';
export function ProductDetail({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
async function load() {
try {
// 完全类型安全!像调用本地函数一样
const { product } = await productClient.getProduct({ id });
setProduct(product ?? null);
} catch (err) {
if (err instanceof ConnectError) {
console.error(`Error ${err.code}: ${err.message}`);
}
}
}
load();
}, [id]);
return product ? (
<div>{product.name} — ¥{product.price}</div>
) : <div>Loading...</div>;
}
方案对比
| 方案 | 需要代理 | 类型安全 | 流式支持 | 推荐场景 |
|---|---|---|---|---|
| grpc-web + Envoy | 需要 Envoy | 基本 | 仅服务端流 | 遗留系统改造 |
| ConnectRPC | 不需要 | 完整 | 全部支持 | 新项目首选 |
| grpc-gateway | 不需要 | 部分 | 不支持 | 需要 REST 兼容 |
本章小结:ConnectRPC 是 2024 年浏览器调用 gRPC 服务的最佳方案——无需 Envoy 代理,完整类型安全,支持所有流式模式,同时向下兼容标准 gRPC 客户端。新项目推荐直接从 ConnectRPC 开始,告别复杂的代理配置。