跳到正文

从 Node.js 到 Go Web(4):对象装配、Context 与登录用户

从 Node.js 到 Go Web

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

在 Koa 中,登录中间件经常把用户写进 ctx.state.user。Memos 的服务方法没有 Koa Context,却能调用 fetchCurrentUser(ctx)。要理解用户从哪里来,需要把「应用共享的依赖」和「本次请求的身份」分开追踪。

本文基于 Memos b40d8a7b5737。Go 的 context.Context 用于传递取消、截止时间和请求范围内的值,它不包含响应写入器,也不是 Koa ctx 的直接替代品。

Store 是启动时传进来的

cmd/memos/main.go 先创建 Driver 和 Store,再调用 server.NewServer(ctx, profile, storeInstance)。Server 调用 api.NewAPIService(secret, profile, store),后者构造并保存共享依赖。下面节选只保留字段赋值的形状:

service := &APIService{
    Secret:  secret,
    Profile: profile,
    Store:   store,
    // 其他共享服务在构造函数中一起创建。
}

这是显式对象装配。可以对照 Node.js 的 createService({ store, config }):由启动代码确定依赖,然后让请求处理器复用服务对象。这里没有容器根据任意参数名自动寻找 Store,也不是每个请求都重新创建 Store。

启动时装配共享依赖,请求时传入独立的 Context 和消息

服务方法前面的 (s *APIService) 叫方法接收者,用来访问服务自身的字段;它不是 HTTP 额外注入的参数。可以把 s.Store 和类方法里的 this.store 对照,但 Go 的方法、接口和结构体组合有自己的类型规则,不需要套完整的 JavaScript 原型模型。

身份由传输入口放进 Context

Connect 拦截器是一个包住下游函数的对象。其鉴权路径如下:

func (in *AuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
		header := req.Header()
		authHeader := header.Get("Authorization")

		result := in.authorizer.Authenticate(ctx, authHeader)
		if err := in.authorizer.CheckAccess(ctx, req.Spec().Procedure, result); err != nil {
			return nil, newAuthorizationConnectError(err)
		}
		if err := in.authorizer.Throttle(ctx, req.Spec().Procedure, result); err != nil {
			return nil, convertGRPCError(err)
		}

		ctx = auth.ApplyToContext(ctx, result)

		return next(ctx, req)
	}
}

它读取 Authorization,调用 Authorizer 识别凭据,检查该过程的访问策略,执行限流,再通过 auth.ApplyToContext() 写入身份,最后调用下游。这里 next(ctx, req) 是一个真实函数调用;它接收的是新的 Context,不会神奇地修改所有已经存在的请求对象。

REST 经过 Gateway 的 middleware,走同一个 Authorizer,但不是穿过这条 Connect 拦截器。Gateway 需要自己匹配过程名、设置请求 Context,并调用下一层。因此「两条入口共享策略」和「两条入口执行同一段中间件」是不同的判断。

两种入口识别身份后,把请求范围的值放进 Context

服务方法再从 Context 解析当前用户

GetUserID()用自定义键类型取值,缺失时返回 0。fetchCurrentUser() 再用这个 ID 查询 Store,并确认用户仍然处于有效状态:

func (s *APIService) fetchCurrentUser(ctx context.Context) (*store.User, error) {
	userID := auth.GetUserID(ctx)
	if userID == 0 {
		return nil, nil
	}
	user, err := s.Store.GetUser(ctx, &store.FindUser{
		ID: &userID,
	})
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, errors.Errorf("user %d not found", userID)
	}
	if !isActiveCurrentUser(user) {
		return nil, nil
	}
	return user, nil
}

Context 里保存身份线索,不代表之后完全不需要查库。这个项目通过读取用户当前状态,让注销、禁用等业务状态有再次参与判断的机会。完整凭据种类和失效策略还需要继续读 server/auth,不能把这里概括成「任意 JWT 解码成功就能访问」。

测试辅助函数可以直接构造带 UserID 的 Context,这有利于验证业务,但它绕过了 HTTP 凭据验证。测试中的 context.WithValue() 不是登录接口,生产请求也不能由客户端直接指定任意 Context 值。

Context 的取消和身份是不同用途

HTTP Request 的 Context 会随着请求生命周期取消;下游调用如果支持 Context,就可以停止不再需要的工作。Memos 的健康检查用 context.WithTimeout(r.Context(), ...) 给数据库 Ping 加上更短的期限,并 defer cancel() 释放计时资源。标准库 Context 文档解释了取消的传递关系。

传 Context 不会强制打断任意 CPU 运算,也不会自动给所有数据库操作加超时。被调用方需要观察取消,或使用支持 Context 的接口。服务中把 ctx 一路传下去,是保留这种协作能力;用全新的 Background 替换它,则可能把请求取消信号切断。

总结

Memos 主要通过构造函数装配共享对象,通过 Context 传递本次调用的身份和取消信号。前者接近 Node.js 的服务工厂,后者可以借助 ctx.state 建立直觉,但没有响应操作能力。遇到一个陌生参数时,先判断它属于对象生命周期还是请求生命周期,再沿显式调用寻找来源。