用一个 Go 服务器同时支持 REST、gRPC、gRPC-Web 和 Connect 客户端。本文使用claude code编写


什么是 vanguard-go?

vanguard-go 是 ConnectRPC 团队开发的一个 Go 中间件库,核心功能是协议转码(Transcoding):它能让你只写一套 RPC 服务实现,就同时支持多种客户端协议:

客户端协议 是否支持
Connect Protocol
gRPC
gRPC-Web
REST + JSON(HTTP 转码)

与 gRPC-Gateway 不同,vanguard 直接作为 net/http 中间件运行,无需额外的代理进程,性能更高,集成更简单。


核心概念

理解这三个概念就掌握了 vanguard 的精髓:

Service(服务):对一个 Protobuf RPC 服务的配置包装,包含服务的 schema(用于协议转换)和实际的 HTTP 处理器。

Transcoder(转码器):将一组 Service 包装成 http.Handler,自动处理所有协议转换逻辑,也充当路由器。

HTTP Transcoding Annotations(HTTP 转码注解):在 .proto 文件里用 google.api.http 注解将 RPC 方法映射到 RESTful 路径,这是支持 REST 客户端的关键。


快速开始

1. 安装依赖

go get connectrpc.com/vanguard
go get connectrpc.com/connect

如果你使用 gRPC-Go 服务器,还需要:

go get connectrpc.com/vanguard/vanguardgrpc

2. 定义 Protobuf 服务

这是一个图书馆服务的例子(proto/library/v1/library.proto):

syntax = "proto3";

package library.v1;

import "google/api/annotations.proto";

option go_package = "example/gen/library/v1;libraryv1";

// 书籍消息
message Book {
  string name = 1;    // 格式: shelves/{shelf}/books/{book}
  string title = 2;
  string author = 3;
}

// 获取书籍请求
message GetBookRequest {
  string name = 1;
}

// 创建书籍请求
message CreateBookRequest {
  string parent = 1;  // 格式: shelves/{shelf}
  Book book = 2;
}

// 书籍列表请求
message ListBooksRequest {
  string parent = 1;
  int32 page_size = 2;
}

message ListBooksResponse {
  repeated Book books = 1;
}

// 定义服务,关键是 google.api.http 注解
service LibraryService {
  // 获取书籍 - 映射到 GET /v1/{name=shelves/*/books/*}
  rpc GetBook(GetBookRequest) returns (Book) {
    option (google.api.http) = {
      get: "/v1/{name=shelves/*/books/*}"
    };
  }

  // 创建书籍 - 映射到 POST /v1/{parent=shelves/*}/books
  rpc CreateBook(CreateBookRequest) returns (Book) {
    option (google.api.http) = {
      post: "/v1/{parent=shelves/*}/books"
      body: "book"
    };
  }

  // 列出书籍 - 映射到 GET /v1/{parent=shelves/*}/books
  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
    option (google.api.http) = {
      get: "/v1/{parent=shelves/*}/books"
    };
  }
}

说明google.api.http 注解告诉 vanguard 如何将 REST 请求路由到对应的 RPC 方法。路径模板中的 {name=shelves/*/books/*} 会自动将 URL 路径段提取到 Protobuf 字段中。


3. 生成代码

使用 buf 工具(推荐)或 protoc 生成 Go 代码:

buf.gen.yaml 配置:

version: v2
plugins:
  - local: [go, tool, protoc-gen-go]
    out: gen
    opt: paths=source_relative
  - local: [go, tool, protoc-gen-connect-go]
    out: gen
    opt:
      - paths=source_relative
      - simple
buf generate

生成后你会得到:

  • gen/library/v1/library.pb.go — Protobuf 消息类型
  • gen/library/v1/libraryv1connect/library.connect.go — Connect 服务接口和工厂函数

4. 实现服务逻辑

// server/library_service.go
package server

import (
    "context"
    "fmt"

    "connectrpc.com/connect"
    libraryv1 "example/gen/library/v1"
)

// LibraryServiceImpl 实现了 LibraryService 接口
type LibraryServiceImpl struct {
    // 实际应用中这里放数据库连接等
    books map[string]*libraryv1.Book
}

func NewLibraryServiceImpl() *LibraryServiceImpl {
    return &LibraryServiceImpl{
        books: map[string]*libraryv1.Book{
            "shelves/top/books/1": {
                Name:   "shelves/top/books/1",
                Title:  "三体",
                Author: "刘慈欣",
            },
        },
    }
}

// GetBook 获取单本书
func (s *LibraryServiceImpl) GetBook(
    ctx context.Context,
    req *connect.Request[libraryv1.GetBookRequest],
) (*connect.Response[libraryv1.Book], error) {
    book, ok := s.books[req.Msg.Name]
    if !ok {
        return nil, connect.NewError(connect.CodeNotFound,
            fmt.Errorf("book %q not found", req.Msg.Name))
    }
    return connect.NewResponse(book), nil
}

// CreateBook 创建新书
func (s *LibraryServiceImpl) CreateBook(
    ctx context.Context,
    req *connect.Request[libraryv1.CreateBookRequest],
) (*connect.Response[libraryv1.Book], error) {
    book := req.Msg.Book
    name := fmt.Sprintf("%s/books/%d", req.Msg.Parent, len(s.books)+1)
    book.Name = name
    s.books[name] = book
    return connect.NewResponse(book), nil
}

// ListBooks 列出书籍
func (s *LibraryServiceImpl) ListBooks(
    ctx context.Context,
    req *connect.Request[libraryv1.ListBooksRequest],
) (*connect.Response[libraryv1.ListBooksResponse], error) {
    var books []*libraryv1.Book
    for _, b := range s.books {
        books = append(books, b)
    }
    return connect.NewResponse(&libraryv1.ListBooksResponse{Books: books}), nil
}

5. 用 vanguard 包装服务(核心步骤)

// main.go
package main

import (
    "log"
    "net/http"

    "connectrpc.com/vanguard"
    "example/gen/library/v1/libraryv1connect"
    "example/server"
)

func main() {
    impl := server.NewLibraryServiceImpl()

    // 步骤 1:用 Connect 创建处理器,返回 (路径, http.Handler)
    path, handler := libraryv1connect.NewLibraryServiceHandler(impl)

    // 步骤 2:创建 vanguard.Service,这是关键的包装步骤
    // vanguard 会自动从已注册的 Protobuf schema 中读取 HTTP 注解
    svc := vanguard.NewService(path, handler)

    // 步骤 3:创建 Transcoder,它实现了 http.Handler
    transcoder, err := vanguard.NewTranscoder([]*vanguard.Service{svc})
    if err != nil {
        log.Fatalf("创建 transcoder 失败: %v", err)
    }

    // 步骤 4:启动服务器
    // 现在这个服务器同时支持 Connect、gRPC、gRPC-Web 和 REST 请求!
    log.Println("服务器启动在 :8080")
    if err := http.ListenAndServe(":8080", transcoder); err != nil {
        log.Fatalf("服务器错误: %v", err)
    }
}

就这么简单!三步完成多协议支持。


使用场景详解

场景一:Connect 服务器 + REST 支持(最常见)

上面的快速开始已经覆盖了这个场景。启动后,你可以用 REST 方式访问:

# 创建书籍(REST POST)
curl -X POST http://localhost:8080/v1/shelves/top/books \
  -H "Content-Type: application/json" \
  -d '{"title": "流浪地球", "author": "刘慈欣"}'

# 获取书籍(REST GET)
curl http://localhost:8080/v1/shelves/top/books/1

# 列出书籍(REST GET)
curl http://localhost:8080/v1/shelves/top/books

同时,gRPC 和 Connect 客户端也能正常工作,无需任何额外配置。


场景二:为现有 gRPC-Go 服务添加多协议支持

如果你已有使用 google.golang.org/grpc 构建的服务,使用 vanguardgrpc 子包:

package main

import (
    "log"
    "net/http"

    "connectrpc.com/vanguard"
    "connectrpc.com/vanguard/vanguardgrpc"
    "google.golang.org/grpc"
    "google.golang.org/grpc/encoding"
    "google.golang.org/protobuf/encoding/protojson"

    libraryv1 "example/gen/library/v1"
    "example/server"
)

func main() {
    // 可选:为 gRPC 服务器注册 JSON 编解码器
    encoding.RegisterCodec(vanguardgrpc.NewCodec(&vanguard.JSONCodec{
        MarshalOptions:   protojson.MarshalOptions{EmitUnpopulated: true},
        UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
    }))

    // 创建标准 gRPC 服务器
    grpcServer := grpc.NewServer()
    impl := server.NewLibraryServiceImpl()
    libraryv1.RegisterLibraryServiceServer(grpcServer, impl)

    // 用 vanguard 包装 gRPC 服务器
    // 这会自动发现 grpcServer 中所有已注册的服务
    transcoder, err := vanguardgrpc.NewTranscoder(grpcServer)
    if err != nil {
        log.Fatalf("创建 transcoder 失败: %v", err)
    }

    // 启动 HTTP/2 服务器(gRPC 需要 HTTP/2)
    log.Println("服务器启动在 :8080")
    if err := http.ListenAndServeTLS(":8080", "cert.pem", "key.pem", transcoder); err != nil {
        log.Fatalf("服务器错误: %v", err)
    }
}

场景三:反向代理模式(代理旧版 REST 服务)

vanguard 还能将 RPC 请求代理转发到后端 REST 服务,适合渐进式迁移:

package main

import (
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"

    "connectrpc.com/vanguard"
    "example/gen/library/v1/libraryv1connect"
)

func main() {
    // 后端旧版 REST 服务地址
    backendURL, _ := url.Parse("http://legacy-backend:9090")
    proxy := httputil.NewSingleHostReverseProxy(backendURL)

    // 将 Connect/gRPC 请求转码后代理到 REST 后端
    svc := vanguard.NewService(
        libraryv1connect.LibraryServiceName,
        proxy, // 使用反向代理作为 handler
    )

    transcoder, err := vanguard.NewTranscoder([]*vanguard.Service{svc})
    if err != nil {
        log.Fatalf("创建 transcoder 失败: %v", err)
    }

    log.Println("代理服务器启动在 :8080")
    http.ListenAndServe(":8080", transcoder)
}

在这个场景下,客户端发来的 gRPC 或 Connect 请求,会被 vanguard 转码成 REST 请求,再转发到旧版后端。


高级配置

限制某个服务支持的协议

默认情况下,vanguard 会为每个服务启用所有协议。你可以通过 ServiceOption 限制:

import "connectrpc.com/vanguard"

svc := vanguard.NewService(
    path, handler,
    // 只允许 Connect 和 REST,拒绝原生 gRPC 请求
    vanguard.WithTargetProtocols(
        vanguard.ProtocolConnect,
        vanguard.ProtocolGRPCWeb,
    ),
    // 只允许 JSON 格式,不允许 Protobuf 二进制
    vanguard.WithTargetCodecs("json"),
)

使用动态 Schema(不依赖代码生成)

当 Protobuf schema 在运行时才能确定时,用 NewServiceWithSchema

import (
    "google.golang.org/protobuf/reflect/protoreflect"
    "connectrpc.com/vanguard"
)

// schema 是通过反射 API 或服务发现动态获取的
var schema protoreflect.ServiceDescriptor = getSchemaFromSomewhere()

svc := vanguard.NewServiceWithSchema(schema, handler)

添加全局 Transcoder 选项

transcoder, err := vanguard.NewTranscoder(
    services,
    // 统一设置所有服务的未知字段处理策略
    vanguard.WithUnknownRequestFields(vanguard.UnknownFieldHandlingDiscard),
)

与其他 HTTP 中间件组合

vanguard 的 Transcoder 实现了 http.Handler,可以直接套用任何标准中间件:

// 例如添加日志、CORS、鉴权等中间件
handler := loggingMiddleware(
    corsMiddleware(
        authMiddleware(transcoder),
    ),
)
http.ListenAndServe(":8080", handler)

HTTP 转码注解速查

场景 注解写法
GET 请求,路径参数 get: "/v1/{name=shelves/*}"
POST 请求,Body 为某字段 post: "/v1/items" + body: "item"
POST 请求,Body 为整个请求 post: "/v1/items" + body: "*"
PUT 请求,更新资源 put: "/v1/{book.name=shelves/*/books/*}" + body: "book"
DELETE 请求 delete: "/v1/{name=shelves/*/books/*}"
额外路径绑定 在注解中加 additional_bindings { get: "/v2/..." }

路径参数提取示例:

// URL: /v1/shelves/science/books/42
// 会将 name 字段设为 "shelves/science/books/42"
rpc GetBook(GetBookRequest) returns (Book) {
  option (google.api.http) = {
    get: "/v1/{name=shelves/*/books/*}"
  };
}

// Query 参数会自动映射到其他未被路径占用的字段
// URL: /v1/shelves/top/books?page_size=10&page_token=abc
// page_size 和 page_token 会自动从 query string 中解析
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
  option (google.api.http) = {
    get: "/v1/{parent=shelves/*}/books"
  };
}

完整项目结构示例

my-service/
├── proto/
│   └── library/v1/
│       └── library.proto        # 定义服务和 HTTP 注解
├── gen/
│   └── library/v1/
│       ├── library.pb.go        # 生成的消息类型
│       └── libraryv1connect/
│           └── library.connect.go  # 生成的 Connect 接口
├── server/
│   └── library_service.go       # 业务逻辑实现
├── main.go                      # 服务入口,配置 vanguard
├── buf.gen.yaml                 # buf 代码生成配置
└── go.mod

常见问题

Q: 为什么 vanguard.NewService 返回错误说找不到服务 schema?

A: vanguard 需要服务的 Protobuf 描述符已注册到 Go 的 Protobuf 全局注册表中。使用 protoc-gen-go 生成的代码会在 init() 函数中自动注册。确保你 import 了生成的 *.pb.go 文件所在的包,即使不直接使用它。

Q: 不写 HTTP 注解,REST 客户端还能用吗?

A: 不能。vanguard 的 REST 路由依赖 google.api.http 注解。不过 Connect 和 gRPC 客户端不需要注解就能工作。

Q: vanguard 和 gRPC-Gateway 有什么区别?

A: gRPC-Gateway 是一个独立的代理进程,需要在 gRPC 服务前面额外部署。vanguard 是一个 Go 库,作为中间件直接嵌入你的服务进程,无需额外的网络跳转,延迟更低,部署更简单。

Q: 支持流式 RPC 吗?

A: Connect 协议的流式 RPC 完全支持。对于 REST 场景,单次 RPC(Unary)映射良好,流式需要 SSE(Server-Sent Events)支持,具体取决于注解配置。


参考资料