从 Node.js 到 Go Web(3):请求消息与响应转换
Memos 的创建函数接收 *apipb.CreateMemoRequest,返回 *apipb.Memo。两个类型都来自 Proto,但真正写入 Store 的却是 *store.Memo。这几种类型分别服务于接口和持久化,不能因为名字都叫 Memo 就把它们视为同一个对象。
以下基于提交 b40d8a7b5737。apipb 是导入别名,指向生成的 API 消息包;store 则是项目持久化边界。它们的分离和 Node.js 中输入 DTO、领域对象、公开响应的分工可以对照。
类型检查没有替代业务校验
Go 的静态类型保证代码调用时传入正确的类型,但网络数据必须先解码。ProtoJSON 会处理字段类型、枚举、时间戳等表示;反序列化成功后,内容是否符合业务要求仍要继续判断。
例如 CreateMemo 中显式检查 request.Memo == nil。Proto 字段标注了 REQUIRED,也不能据此假定这套服务注册自动启用了任意一种校验插件。能确认什么检查已经生效,要看本项目实际注册的处理器和函数分支。
Memo 是消息字段,缺失时可以是 nil;普通 proto3 字符串缺省则是空串。对某些需要区分「没提供」和「提供空值」的字段,Proto 使用 optional 等存在性机制。它和 JavaScript 中字段不存在、空字符串、null 的关系不是一一对应,尤其不能用 JSON 中是否出现字段直接推断所有 Go 零值的来源。
输入被转换成服务端控制的写入对象
prepareMemoCreate()先检查可见性,然后构造 Store 对象。下面节选只保留构造部分:
memo := &store.Memo{
UID: uid,
CreatorID: user.ID,
Content: input.Content,
Visibility: visibility,
}
CreatorID 来自已经识别的用户,UID 来自校验与生成步骤,正文才来自客户端。即使 API 的 Memo 消息包含 creator 等响应字段,创建逻辑也没有因此信任客户端宣称的身份。这是转换函数承担的关键职责。
准备阶段还会处理 Space、内容额度、Markdown 派生信息、附件和引用关系。这里的「准备」表示不写数据库或外部状态,不等于纯函数:它仍可能读取 Store 做验证。阅读辅助函数时,这种副作用边界比函数是否只有十行更值得确认。
响应也经过显式转换
convertMemoFromStoreWithCreators()把数据库字段转换成 API 对象,例如:
memoMessage := &apipb.Memo{
Name: name,
State: convertStateFromStore(memo.RowStatus),
Creator: BuildUserName(creator.Username),
CreateTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
Content: memo.Content,
Visibility: convertVisibilityFromStore(memo.Visibility),
Pinned: memo.Pinned,
}
资源名、时间戳和枚举都发生了转换,后面还会补充协作信息、标签、附件等。time.Unix(..., 0) 先把秒数还原为 Go 时间,再变成 Protobuf Timestamp。最终 JSON 的时间表示由 ProtoJSON 决定,不能把生成消息交给普通 encoding/json 后就认定协议完全相同。
这种明确转换不像 FastAPI 的 response_model:这里没有给返回值再挂一个任意 Pydantic 模型。公开字段结构由 Proto 契约和转换逻辑一起决定,资源权限则必须在业务读取与投影过程中保证。序列化知道字段怎么写,不会自行知道当前用户能看哪些 Memo。
JSON 字段和空值也有项目配置
server/api/api.go 的 newGatewayMarshaler() 显式设置 EmitDefaultValues: true、DiscardUnknown: true。前者让许多默认标量和空集合仍出现在输出中,同时不强制把缺失消息字段写成 null;后者让 Gateway 解码时丢弃未知字段。这些是当前 Gateway 的配置,不能直接推广成所有 Go JSON 解析器或所有 Connect 入口的默认行为。Gateway 序列化配置说明了这层可定制的位置。
UpdateMemo 另有 update_mask。它是一组声明要更新哪些字段的路径,避免把每个零值都当成「覆盖成空」。在 Node.js 里经常用 Object.hasOwn() 判断 PATCH 是否传了字段,这里则需要同时阅读消息存在性和 FieldMask 的处理。看到 content == "",还不足以判断服务会保留、清空还是拒绝。
总结
请求消息定义网络输入的形状,Store 对象定义写入和查询需要的数据,响应转换定义公开表达。三者之间的显式赋值说明了哪些字段可信、哪些字段由服务端生成,也让资源名和时间格式不必依赖数据库结构。读 Go Web 项目时,沿这些转换找边界,比只看 struct 标签更接近真实行为。