从 Node.js 到 Go Web(2):从 Proto 找到路由与参数
- 应用入口与 HTTP 处理
- 从 Proto 找到路由与参数(当前)
- 请求消息与响应转换
- 对象装配、Context 与登录用户
- 沿着创建 Memo 读完整请求
- 用测试区分业务与传输边界
在 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 中有测试。
请求体为什么差了一层
请求消息声明如下,去掉了字段说明注释:
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 层级却由各自入口决定。
沿生成代码看参数来源
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."
]
}