Chapter 09

gRPC-Web 与浏览器

打破浏览器与 gRPC 的壁垒——从 Envoy 代理到 ConnectRPC 的现代方案演进

为什么浏览器不能直接调用 gRPC

浏览器中的 Fetch API 和 XMLHttpRequest 无法控制 HTTP/2 帧(Frame)级别的传输,存在以下根本限制:

方案一: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 开始,告别复杂的代理配置。