跳到正文

从 Node.js 到 Go Web(3):请求消息与响应转换

从 Node.js 到 Go Web

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

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 标签更接近真实行为。