跳到正文

从 Node.js 到 Go Web(5):沿着创建 Memo 读完整请求

从 Node.js 到 Go Web

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

创建一条 Memo 需要的不只是把正文写入数据库。以 Memos b40d8a7b5737 为例,服务还要确认调用者、限制写入、准备附件和关系,再转换出对外消息。本文沿着 APIService.CreateMemo() 阅读一条完整请求,只展开会影响框架边界和失败行为的步骤。

REST 示例使用 POST /api/memos,body 是 {"content":"第一条笔记","visibility":"PRIVATE"}。Gateway 把它组装为 CreateMemoRequest;Connect 的对应过程则接收包含 memo 字段的完整请求消息。两条路径都调用下面的服务函数。

进入业务前,外层已经做了什么

HTTP Server 接入请求,API Handler 选择传输入口,外层鉴权和限流根据入口策略执行,适配器解析消息,再调用 CreateMemo。解码失败、访问策略拒绝或超出请求体限制时,业务函数可能根本不会执行。

这并不意味着服务函数可以不检查用户。它也可能被测试或其他内部入口直接调用,而且业务层还需要有效用户对象及具体资源权限。CreateMemo 的前半段是:

func (s *APIService) CreateMemo(ctx context.Context, request *apipb.CreateMemoRequest) (*apipb.Memo, error) {
	user, err := s.fetchCurrentUser(ctx)
	if err != nil {
		return nil, status.Errorf(codes.Internal, "failed to get user")
	}
	if user == nil {
		return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
	}
	if err := s.throttleAndCharge(ratelimit.ScopeWriteUser, userKey(user.ID), 1); err != nil {
		return nil, err
	}
	if request.Memo == nil {
		return nil, status.Errorf(codes.InvalidArgument, "memo is required")
	}

	memoUID, err := ValidateAndGenerateUID(request.MemoId)
	if err != nil {
		return nil, err
	}

	prepared, err := s.prepareMemoCreate(ctx, user, request.Memo, memoUID)
	if err != nil {
		return nil, err
	}
	if err := s.checkMemoQuota(ctx, user.ID); err != nil {
		return nil, err
	}

这里的 := 同时声明并赋值;常见的 if err != nil { return ... } 是显式传播失败。(*apipb.Memo, error) 表示函数同时返回结果和错误,调用方必须检查两者;它没有把错误自动抛成 JavaScript 异常。

准备和写入分开

prepareMemoCreate() 校验可见性和 Space,生成 Store 对象,检查内容额度,构造 Markdown 派生信息,并准备附件和引用关系。它不写数据库或外部状态,但会读数据以验证输入。CreateMemo 随后再检查用户 Memo 数量额度。

真正的写入进入 createMemoWithMutation(),它把 Memo、附件绑定和引用关系统一交给 Store.ApplyMemoMutation()。这比「先插入 Memo,再逐条补附件」更能集中表达一次业务变更需要保持的关系。具体事务和冲突检查要继续看 Store 与数据库 Driver;不能仅凭函数名推断所有外部动作也在同一个事务里。

创建先校验与准备,再执行变更,写入成功后组装响应并触发通知

准备函数从当前用户写入 CreatorID,写入辅助函数又确认 create.CreatorID == user.ID。这说明对象可以经过多层检查:接口参数合法、用户有效和写入对象归属正确,分别防止不同的问题。

写入后还有可能失败

Store 成功后,CreateMemo 重新取得附件和关系,调用转换函数构造 API Memo。这些步骤仍可能返回错误。随后才尝试派发 webhook、发布 SSE 变化通知,并发送提及通知。源码中 webhook 失败只记录 warning,不把这次创建改成失败。

这一点直接影响客户端重试:如果数据库已经完成写入,但后面的查询、转换或网络响应失败,客户端看到的错误不等于「肯定没创建」。提供固定 memo_id 可以让同一资源 ID 的重复创建被检测,但不能据此宣称接口实现了完整的幂等重放协议,也不能把相同 ID 冲突等同于返回第一次成功的响应。

阅读业务函数时,可以在纸上标出首次持久化写入的位置。它前后的失败,对调用者和重试策略的含义不同。这是跨语言都适用的读码方法。

错误由哪一层变成 HTTP 响应

业务里常见 status.Errorf(codes.InvalidArgument, ...) 或 codes.Unauthenticated,表示 gRPC 风格的状态。Gateway 负责把状态映射成 REST 错误,Connect 包装器调用 convertGRPCError() 转为 *connect.Error。它们共享业务含义,但错误体形状并不完全一样。

如果在 APIService 里直接写 http.Error(),这个函数既没有 ResponseWriter,也会和协议无关的服务职责冲突。更自然的改法是返回合适的业务错误,再在传输边界确定 HTTP 表达。反过来,普通错误被转换为内部错误时,也不能把内部堆栈当成可公开的客户端信息。

总结

CreateMemo 展示了框架与业务的接缝:外层把网络请求变成消息和 Context,服务检查身份并准备写入,Store 执行持久化变更,转换和协议层再形成响应。首次写入之后仍可能失败,因此成功范围、通知策略和重试语义必须沿真实代码判断,不能只看状态码名称。