跳到正文

从 Node.js 到 Go Web(2):从 Proto 找到路由与参数

从 Node.js 到 Go Web

  1. 应用入口与 HTTP 处理
  2. 从 Proto 找到路由与参数(当前)
  3. 请求消息与响应转换
  4. 对象装配、Context 与登录用户
  5. 沿着创建 Memo 读完整请求
  6. 用测试区分业务与传输边界

在 Express 中,router.post('/memos', handler) 同时给出路径和处理函数。Memos 的 CreateMemo 没有路由装饰器;路径在 Proto 文件中,处理方法在 Go 文件中,中间由生成代码连接。

本文基于 Memos b40d8a7b5737,使用 Connect Go 1.20.0。Proto 是 Protocol Buffers 的接口描述文件:它声明消息字段和服务方法,工具据此生成 Go 类型、客户端和服务器适配代码。读者不需要先学完整的 RPC 体系,可以先把它当作比 TypeScript 接口多了代码生成能力的契约。

一个方法有两种入口

MemoService 的定义中,创建接口是:

rpc CreateMemo(CreateMemoRequest) returns (Memo) {
    option (google.api.http) = {
      post: "/api/memos"
      body: "memo"
    };
    option (google.api.method_signature) = "memo";
  }

rpc 声明方法名、输入类型和输出类型。google.api.http 则给 gRPC-Gateway 指定 REST 映射:POST /api/memos,请求体绑定到请求消息的 memo 字段。它没有要求把所有 RPC 都设计成 REST 路径。

同一个方法也注册为 Connect 过程 /memos.api.MemoService/CreateMemo。这是协议约定和生成代码决定的过程名;本版的包名是 memos.api,没有 .v1。旧 /api/v1/ 和 /memos.api.v1. 入口会返回 410,迁移行为在 server/api/api_test.go 中有测试。

REST 通过 Gateway,Connect 通过包装方法,最终调用同一个 APIService

请求体为什么差了一层

请求消息声明如下,去掉了字段说明注释:

message CreateMemoRequest {
  Memo memo = 1 [(google.api.field_behavior) = REQUIRED];
  string memo_id = 2 [(google.api.field_behavior) = OPTIONAL];
}

REST 绑定写了 body: "memo",所以 body 本身就是 Memo;没有绑定进 body 的 memo_id 可以从查询参数提供。下面两个是请求形状示例,凭据和地址由实际环境提供:

POST /api/memos?memo_id=lesson-1
Content-Type: application/json
Authorization: Bearer <access-token>

{"content":"读懂路由","visibility":"PRIVATE"}

Connect 则按整个 CreateMemoRequest 解码,使用 ProtoJSON 默认字段名时是:

POST /memos.api.MemoService/CreateMemo
Content-Type: application/json
Connect-Protocol-Version: 1
Authorization: Bearer <access-token>

{"memo":{"content":"读懂路由","visibility":"PRIVATE"},"memoId":"lesson-1"}

两套传输适配器分别构造相同的 Go 请求类型。服务函数看到的类型相同,客户端需要发送的 JSON 层级却由各自入口决定。

HTTP 绑定决定 JSON 从哪一层进入 CreateMemoRequest

沿生成代码看参数来源

Gateway 生成代码中,local_request_MemoService_CreateMemo_0 会创建 CreateMemoRequest,把 req.Body 解码到 protoReq.Memo,解析查询参数,最后执行 server.CreateMemo(ctx, &protoReq)。关键两行可以直接在文件里找到:

marshaler.NewDecoder(req.Body).Decode(&protoReq.Memo)
runtime.PopulateQueryParameters(&protoReq, req.Form, filter_MemoService_CreateMemo_0)

这里第二行出现在带错误处理的 if 初始化语句中;节选只展示调用本身。生成的过滤规则防止已经绑定的 body 字段再被查询参数任意覆盖。路径参数的来源同理,例如 GET /api/{name=memos/*} 会把资源路径填进 GetMemoRequest.Name。

Connect 分支可以看一个完整的手写适配方法:

func (s *ConnectServiceHandler) CreateMemo(ctx context.Context, req *connect.Request[apipb.CreateMemoRequest]) (*connect.Response[apipb.Memo], error) {
	resp, err := s.APIService.CreateMemo(ctx, req.Msg)
	if err != nil {
		return nil, convertGRPCError(err)
	}
	return connect.NewResponse(resp), nil
}

req.Msg 是已经解码的请求消息,ctx 沿着协议处理器和拦截器传入。适配器显式调用 APIService;它没有像 FastAPI 一样根据业务函数的任意参数签名推导每个参数。connect.NewResponse 包装返回值,实际序列化由 Connect Handler 完成。

用请求验证这两种形状

配套实验通过实际 API Handler 发送请求,并检查返回消息的资源名和正文。使用 Go 1.27.2、临时 SQLite 与测试用户,四个场景都通过:

请求 观察结果
REST:Memo body,加 memo_id query 200,得到指定资源名和正文
Connect:包含 memo 与 memoId 的请求消息 200,得到指定资源名和正文
REST:创建请求不带身份凭据 401,拒绝匿名创建
Connect:直接发送 content,缺少 memo 层 400,请求解码被拒绝

实验覆盖进程内 Handler,没有监听真实端口;它验证了这四种输入经过当前路由、鉴权和解码后的行为。源码与结果见请求绑定实验弹窗。

修改接口时应该改哪里

路径或消息结构需要修改时,源头是 proto/api/*.proto,再按仓库的生成命令更新产物。只手改 .pb.go 或 .pb.gw.go 会在下次生成时丢失,也会造成前端类型、OpenAPI 与服务器不一致。手写业务逻辑留在 server/api,生成代码主要用于追踪调用,不在里面补业务条件。

如果请求没有到达 CreateMemo,先看外层鉴权,再看方法、路径、Content-Type、body 层级和解码错误。如果已经到达但字段为空,再看 Proto 的字段映射与默认值。这个顺序比只在业务函数里打印请求更容易找到问题。

总结

Memos 的参数来自明确的适配过程:Proto 声明契约,HTTP annotation 定义 REST 绑定,生成 Handler 解码,手写包装器把消息交给业务。Node.js 中经常手写的 req.params、req.query、req.body 组装工作,在这里有一部分被生成代码承担;服务方法拿到的是组装后的结果。

REST 与 Connect 请求绑定实验

打开原文件 request-binding-results.json

实验固定在 Memos b40d8a7b5737,复用项目现有测试辅助函数创建临时数据库与用户。令牌只在测试进程中生成,结果省略凭据和初始化日志。

下载测试文件,将它放到该提交的 server/api/blog_request_binding_test.go,在仓库根目录运行:

go test ./server/api -run '^TestBlogMemoRequestBinding$' -count=1

测试完成后可以移除复制进去的文件。测试辅助函数默认使用 SQLite,也可通过 DRIVER=sqlite 明确选择;测试需要匹配该提交的 Go 工具链和依赖。它依赖仓库测试包,不是可在任意目录单独执行的 Go 程序。

实验结果记录
{
  "date": "2026-11-03",
  "repositoryCommit": "b40d8a7b573774c52b022c8d6537d6baf2129dc0",
  "go": "1.27.2",
  "driver": "sqlite",
  "entry": "APIService.NewHandler + httptest Request/ResponseRecorder",
  "command": "go test ./server/api -run '^TestBlogMemoRequestBinding$' -count=1 -json",
  "exitCode": 0,
  "sampling": "Pass events only; runtime logs and generated test credentials omitted.",
  "cases": [
    {
      "Test": "TestBlogMemoRequestBinding/REST_body_is_memo",
      "Action": "pass",
      "Elapsed": 0.01
    },
    {
      "Test": "TestBlogMemoRequestBinding/Connect_body_is_request",
      "Action": "pass",
      "Elapsed": 0.01
    },
    {
      "Test": "TestBlogMemoRequestBinding/REST_anonymous_is_rejected",
      "Action": "pass",
      "Elapsed": 0
    },
    {
      "Test": "TestBlogMemoRequestBinding/Connect_flat_body_is_rejected",
      "Action": "pass",
      "Elapsed": 0
    },
    {
      "Test": "TestBlogMemoRequestBinding",
      "Action": "pass",
      "Elapsed": 0.06
    }
  ],
  "expectedHttpStatuses": {
    "REST_body_is_memo": 200,
    "Connect_body_is_request": 200,
    "REST_anonymous_is_rejected": 401,
    "Connect_flat_body_is_rejected": 400
  },
  "limits": [
    "In-process Handler calls, not a real listening network server.",
    "Generated fixture credentials and temporary SQLite; no deployed instance accessed.",
    "Four scenarios only, not complete authentication or transport conformance tests."
  ]
}