开关与审计
两个进程内的设施: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 是
整体替换配置层(不是合并),测试里可以用它隔离取值。
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 | 调用方 |
Result | audit.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}
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)
}
这个包保证什么,不保证什么
Record 会补上 At 和 TraceID,
并把事件交给 sink 一次。不保证的是轨迹不可篡改——顺序、保留期、
不可变性与访问控制都属于 sink 的职责。想让这条轨迹站得住,就把 sink 写得诚实:
同步写入,或者交给一个不会丢事件的队列;写失败要上报,不要吞掉。
其余约定:
- 每个结果都记:成功和失败(4xx 拒绝、409 冲突)都是审计事实。
- 动作完成后立即写,不要攒批,也不要丢进会丢事件的异步队列。
Detail不放秘密:不放 token、密码、完整请求体,只放定位所需的字段。Action用稳定的点分动词,改名会让历史查询断档;Resource用类型/id。
相关示例
- examples/feature:配置选路径、运行期翻转、环境变量覆盖。
- examples/audit:成功与失败都入审计,sink 可插拔,轨迹可读回。
- 认证与安全:翻转开关的管理接口本身要过认证。