开关与审计

两个进程内的设施:feature 按名字读一个布尔值来切代码路径, audit 把每个业务动作写成一条"谁、对什么、做了什么、结果如何"的记录。 两者都不持久化自己——开关靠外部驱动,审计靠 sink。

功能开关

feature.Flag{Name, Default} 声明一个开关, Register 把默认值登记进去。用 Flag.Enabled() 读取时, 即便没人注册过,这个 Flag 也能用自己的 Default 回答。

var (
    flagNewCheckout = feature.Flag{Name: "new-checkout", Default: false}
    flagBetaSearch  = feature.Flag{Name: "beta-search", Default: false}
)

func init() { feature.Register(flagNewCheckout, flagBetaSearch) }

// checkout:按每次请求读开关,所以一次翻转对下一次调用就生效
func (s *featureService) checkout(context.Context, *http.Request) (any, error) {
    if flagNewCheckout.Enabled() {
        return map[string]string{"path": "checkout-v2"}, nil
    }
    return map[string]string{"path": "checkout-v1"}, nil
}

取值的优先级链是固定的,从高到低:

flowchart TD
    Q["feature.Enabled(name)
(名字统一转小写)"] --> R{"runtime 里有?"} R -->|是| V1["用它
feature.Set"] R -->|否| E{"环境变量有?
FEATURE_<NAME>"} E -->|"是且能解析成布尔"| V2["用它"] E -->|"否 / 解析失败"| C{"feature: 配置段里有?"} C -->|是| V3["用它"] C -->|否| D{"flags 注册表里有?"} D -->|是| V4["用 Register 的 Default"] D -->|"否(无人注册)"| OFF["false:未注册即关闭"] classDef win fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class V1,V2,V3,V4,OFF win
环境变量在每次调用时读,不是启动时读一次——测试用 t.Setenv, 运维也可以在 exec 之前 export。FEATURE_<NAME> 由名字大写后 把每个非字母数字字符换成 _ 得到,所以 new-checkout 对应 FEATURE_NEW_CHECKOUT。
优先级 来源 写法 适用
1(最高)运行时feature.Set("beta-search", true)管理接口 / 控制面板即时开关
2环境变量FEATURE_BETA_SEARCH=on每个部署环境单独覆盖
3配置段feature: {beta-search: true}随配置一起发布
4注册表feature.Register(feature.Flag{Default: …})没被覆盖时的值

环境变量的值接受 1 / true / on / yes / y / enabled 与 0 / false / off / no / n / disabled(大小写与首尾空白都容忍)。 无法解析的值会被忽略,回落到下一层,而不是当成 false 静默生效。

verticle.Run 在初始化任何组件之前把框架的 feature: 段交给 feature.Configure,服务不用自己接。注意 Configure 是 整体替换配置层(不是合并),测试里可以用它隔离取值。

别把开关缓存在 struct 字段里 包本身只保证"每次读都走一遍优先级链"。若在 Setup 里算一次存下来, Set 就只对重启生效了。同理,开关只作用于本进程: 多副本要一致,就统一发布配置,或由控制面板对每个副本调 Set。

两条设计纪律:开关名用 kebab-case(new-checkout),让它和配置键、 环境变量名三者可互相推导;一个开关只保护一条路径,旧路径清理掉时把开关和旧分支一起删。

审计

audit.Record 把一条 audit.Event 记下来。字段是固定的那七个:

字段 含义 谁填
At发生时间零值时由 Record 补 time.Now()
TraceID请求的 trace id空值时由 Record 从 ctx 取(obs.TraceID)
Actor谁做的:用户 id / 服务名 / "system"调用方
Action做了什么,点分动词形式:order.refund调用方
Resource对什么做的:order/42调用方
Resultaudit.Success 或 audit.Failure调用方
Detail可选的结构化上下文(原因、变更字段),必须可 JSON 化调用方
sequenceDiagram
    autonumber
    participant C as 调用方
    participant M as 中间件 / handler
    participant S as 业务存储
    participant A as audit.Record
    participant K as sink
    C->>M: POST /orders/42/refund
    M->>S: 读当前状态
    S-->>M: 已退过款
    Note over M: 判定之后、返回错误之前单独记一条
    M->>A: Event{Action:"order.refund",
Resource:"order/42", Result:Failure,
Detail:{reason:"already refunded"}} A->>A: 补 At 与 TraceID A->>K: Write(ctx, Event) —— 同步调用 K-->>A: 返回(写失败必须上报,不许吞) M-->>C: 409 {"code":40901}
失败同样入审计:409 是一次真实的业务事实,只记成功会让轨迹在最需要它的时候断掉。 记录发生在业务判定之后、返回错误之前,所以轨迹与调用方看到的结果一致。
if done {
    audit.Record(ctx, audit.Event{
        Actor: actor, Action: "order.refund", Resource: resource, Result: audit.Failure,
        Detail: map[string]string{"reason": "already refunded"},
    })
    return nil, errs.Conflict(40901, "order already refunded")
}
s.store.refunded[id] = true
audit.Record(ctx, audit.Event{
    Actor: actor, Action: "order.refund", Resource: resource, Result: audit.Success,
})
return map[string]any{"refunded": id}, nil

事件落到哪里

sink 方式 适用
默认 logSink 一条结构化日志 obs.Info(ctx, "audit", …),字段 audit_at / audit_actor / audit_action / audit_resource / audit_result / audit_detail,外加 ctx 里的 trace id 已有日志管道(ELK / Loki),零配置
自定义 Sink audit.SetSink(s),s.Write(ctx, Event) 由 Record 同步调用 审计表、只追加的 Kafka topic、WORM 对象存储

audit.SetSink(nil) 恢复默认的日志 sink(测试里常用)。 sink 的选择是全局替换:换成数据库 sink 后,日志里就不再有审计行了。

// orderStore 同时是这个示例的业务存储和它的审计 sink
func (o *orderStore) Write(_ context.Context, e audit.Event) {
    o.mu.Lock()
    defer o.mu.Unlock()
    o.events = append(o.events, e)
}

这个包保证什么,不保证什么

保证:字段完整 + 时间戳 + trace id Record 会补上 At 和 TraceID, 并把事件交给 sink 一次。不保证的是轨迹不可篡改——顺序、保留期、 不可变性与访问控制都属于 sink 的职责。想让这条轨迹站得住,就把 sink 写得诚实: 同步写入,或者交给一个不会丢事件的队列;写失败要上报,不要吞掉。

其余约定:

相关示例