跳转到内容

Contract Primer

当你已经通过 稳定根 确认改动仍属于默认服务路径,而且问题进一步收窄到服务如何塑造其 HTTP 表面时,就打开这一页:写回什么内容、如何表示错误、在 request context 里携带什么信息。

contract 拥有结构化错误模型、规范的响应助手,以及请求级别的 context 访问器。它是允许停留在传输边界而不引入 app construction、routing 或持久化的模块唯一可用的导入。

func Greet(w http.ResponseWriter, r *http.Request) {
rc := contract.RequestContextFromContext(r.Context())
name := rc.Params["name"]
_ = contract.WriteResponse(w, r, http.StatusOK,
map[string]string{"message": "hello, " + name}, nil)
}
func Fail(w http.ResponseWriter, r *http.Request) {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeNotFound).
Message("user not found").
Build())
}
  • 你正在编写或修改 WriteErrorWriteResponse 的输出形状
  • 你正在添加或修改 error category 或 error type
  • 你正在读写请求级别的元数据,例如 request ID、route params 或 trace headers
  • 改动关心的是 route matching 或 URL parameter 提取策略 — 那属于 router
  • 改动引入了 session lifecycle、token 签发或 auth identity — 那属于 security
  • 工作关于 middleware 如何观察或转换请求 — 从 middleware 开始
  • 改动添加了 protocol-gateway 行为、multipart upload 便捷函数或特定功能的响应信封
  1. contract/module.yaml
  2. contract/response.go
  3. contract/errors.go
  4. contract/context_core.go
  5. reference/standard-service/internal/handler/health.go
这些工作适合留在 contract 一旦变成这些问题就应移出
WriteError / WriteResponse — 唯一规范的写入路径 带有各功能构造函数的特定功能响应信封
ErrorCategoryErrorType 常量与 APIError 形状 各功能错误注册表或临时 error 构造函数族
RequestContext,含 ParamsRoutePatternRouteName — 仅传输元数据 可变请求袋(mutable request bag)、abort 状态或 context service-locator 助手
request ID 与 trace 元数据 carrier middleware 策略、request-id 生成策略或字段脱敏
import "github.com/spcent/plumego/contract"
func (h APIHandler) GetUser(w http.ResponseWriter, r *http.Request) {
user := fetchUser(r.Context())
contract.WriteResponse(w, r, http.StatusOK, user, nil)
}

WriteResponse 把 data 包装成 {"data": ..., "request_id": "..."} 并设置 Content-Type: application/json。当 requestid 中间件已运行时,request_id 字段自动填充。

带元数据的分页响应:

contract.WriteResponse(w, r, http.StatusOK, items, map[string]any{
"total": 120,
"page": 2,
})
func (h APIHandler) Greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeRequired).
Detail("field", "name").
Message("name is required").
Build())
return
}
contract.WriteResponse(w, r, http.StatusOK, map[string]string{"message": "hello, " + name}, nil)
}

调用 Type 会自动设置 HTTP 状态码、错误代码和分类。Message 覆盖默认的人类可读文本。Detaildetails 映射添加字段。

常量 HTTP 状态 使用场景
TypeRequired 400 必要参数或请求体字段缺失
TypeValidation 400 字段存在但验证失败
TypeInvalidFormat 400 格式错误(UUID、邮箱等)
TypeOutOfRange 400 值超出允许范围
TypeDuplicate 400 值已存在
TypeUnauthorized 401 请求未经认证
TypeInvalidToken 401 Token 格式错误或无效
TypeExpiredToken 401 Token 已过期
TypeForbidden 403 已认证但权限不足
TypeNotFound 404 资源不存在
TypeConflict 409 状态冲突(如并发更新)
TypeAlreadyExists 409 资源已存在
TypeRateLimited 429 触发限流
TypeInternal 500 服务端内部故障
TypeUnavailable 503 下游依赖不可用
TypeTimeout 504 上游调用超时

每个错误响应都使用以下信封:

{
"error": {
"type": "required_field_missing",
"code": "REQUIRED",
"message": "name is required",
"category": "validation_error",
"details": { "field": "name" }
},
"request_id": "abc-123"
}
// 由 middleware/requestid 注入的请求 ID
rid := contract.RequestIDFromContext(r.Context())
// 路由器设置的路由元数据
rc := contract.RequestContextFromContext(r.Context())
id := rc.Params["id"]
// 带默认值的查询参数
page := r.URL.Query().Get("page")
if page == "" {
page = "1"
}
错误用法 正确模式
对错误使用 WriteResponse 使用 WriteError——它会自动设置正确的 HTTP 状态码
WriteError 后忘记 return 必须立即 return——响应已写入,继续执行会产生未定义行为
用结构体字面量构造 APIError 使用 NewErrorBuilder()——结构体布局不属于稳定 API
在 message 中暴露完整错误字符串 服务端记录内部错误;WriteError 中只返回固定的对用户友好的消息
所有错误都用 TypeInternal 选择具体类型:TypeRequiredTypeNotFound
响应缺少 request_id 确保 middleware/requestidPrepare 之前注册

contract 是稳定根中允许使用的最低层导入:只有 stdlib。这一约束使它成为系统中每个 handler 共同使用的语言。破坏错误形状或响应信封会造成跨仓库的回归。此处的改动会波及每一个消费者。把 contract 当作已发布的 API 表面,而不是实现细节。