Documentation
¶
Overview ¶
backend.go —— 票据状态存储接缝(issue #145 多副本部署)。
为什么要把状态抽出来 ¶
原实现把判定与状态焊在一起(mu + map):签发/消费的判定语义正确, 但状态只活在本进程里——多副本宿主在副本 A 签发的票据,副本 B 消费时 找不到,一次性短时授权的防重放语义跨副本失效。抽成 Backend 之后: **判定逻辑(TTL、单次消费、容量策略的调用时机)留在 Store,跨进程状态 由宿主接 DB / 协调后端**——与 sideeffect.Acquire「判定归包内、原子性归 存储」同一条分工。
跨进程实现的硬要求(原子性契约) ¶
- **Take 必须原子**:多副本并发 Take 同一 id,恰好一个返回 ok=true。 DB 形态 = `DELETE ... RETURNING`(或等价事务);Redis 形态 = Lua。 判定「过期则失败」也在同一次取出里完成——先查后删的两步形态会把 「两个副本同时消费」的窗口重新打开,正是本包要防的那件事。
- **Put 写入即可见**,同 id 覆盖(与进程内语义一致;id 是 192 bit 随机,碰撞不在现实里)。
- GC / Len 是清理与观测面,一致性要求弱于 Take。
进程内参考实现 ¶
NewMemoryBackend:单进程成立,**不得上生产**(跨进程原子性它给不了)。 它的职责是把判定逻辑垫在契约之下,让契约可测——与 coord 的内存后端、 examples/pgstore 对 taskboard 的分工同口径(内核证契约,宿主接生产)。
Package ticket 一次性短时授权票据(机制面)。
生命周期: Stable(v0.33.0 定级)——只允许 additive 变更(新增导出符号 / 新增方法且给零值默认实现 / 新增错误值);改签名、改语义、改默认值、 删导出符号都算 breaking,判定规则与变更流程见 docs/api-compat.md, 逐包理由见 api-lifecycle.yaml。
这件事为什么要有个原语 ¶
宿主要把控制台、IM、长连接这类「先给个凭据、稍后凭凭据进来」的面暴露出去时, 绕不开三件事:**凭据短命**、**只能用一次**、**只能用于某一个用途**。 各宿主各写一套,于是每次都重犯同一种错:把**长期令牌塞进 query**—— 令牌会落进访问日志、代理日志、浏览器历史、Referer 头,于是「短时凭据」 名义上短时,实际是长期有效且到处留痕。
本包把机制面收成一处:**签发(Issue)/ 消费(Consume)/ 过期(TTL)/ 用途绑定**。
三条纪律 ¶
**单次消费**:票据消费即作废(用过即焚)。同一 id 第二次消费**必然失败**—— 不是「尽量不重复」,是结构上不可能重复。
**过期即失效**:消费时刻晚于过期时刻 ⇒ 失败。过期判定与消费判定**同一处** (不要两处各判一次,两处判就会有一处漂)。
**机制与策略分离**:本包**不决定**谁能签、票据能干什么、过期多久—— 那是宿主的治理决策。本包只提供机制:宿主拿到 id 与过期时刻,自己决定 拿它做什么。绑定载荷(Binding)里的字段语义也归宿主解释。
边界 ¶
状态在 Backend 接缝之后(issue #145):缺省是进程内存储(重启即失效)—— 对「只活几十秒的票据」这是**正确**的默认,宁可让用户重取一次,也不要把 凭据持久化到磁盘(落盘即多一份要擦的痕迹)。多副本宿主实现 Backend 经 NewWithBackend 注入:判定逻辑(TTL / 单次消费 / 容量调用时机)留在本包, 跨进程原子性归存储——原子性契约见 backend.go 文件头。本包**不提供** 分布式实现,也不做任何集群语义(复制、选主、租约一概不认识)。
Index ¶
Constants ¶
const DefaultMaxEntries = 1024
DefaultMaxEntries 默认容量上限:票据空间有界,超出即**拒发**(报错)。
⭐ 为什么要有上限:票据是「发了就占一份内存」的凭据,若宿主无限制签发, 一次误用的批量签发就能把进程内存吃干。本包的立场是**满了就说满**, 而不是默默淘汰(默默淘汰会让宿主以为票据还在,实际已失效 ⇒ 表现为 「偶发地连不上」,极难查)。
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Backend ¶ added in v0.35.0
type Backend interface {
// Put 写入一张未消费票据。容量策略由实现自判(内存参考实现满了报错,
// 与旧实现「票据空间已满」同一拒发口径)。
Put(id string, binding Binding, expiresAt time.Time) error
// Take 原子取出并删除:不存在或已过期 ⇒ ok=false。
//
// ⚠️ 过期票据**也已被删除**——与进程内旧实现「消费尝试顺手清过期」
// 逐字一致(先删后判的顺序是语义的一部分,不是实现巧合)。
Take(id string, now time.Time) (Binding, bool, error)
// GC 删除已过期票据,返回清理张数。
GC(now time.Time) (int, error)
// Len 返回现存票据数(含过期未清理的——它们只是不可消费,仍占空间)。
Len() (int, error)
}
Backend 票据状态存储(并发安全;原子性契约见文件头)。
实现可以是进程内的(MemoryBackend)、SQL 的、Redis 的——本包只依赖 这四个操作的语义,不依赖它们怎么达成。
func NewMemoryBackend ¶ added in v0.35.0
NewMemoryBackend 构造进程内参考实现(maxSize <= 0 ⇒ DefaultMaxEntries)。
这是 Store 缺省构造(New / NewWithClock)背后的实现;宿主的跨进程 Backend 由宿主自己实现并经 NewWithBackend 注入。
type Binding ¶
type Binding struct {
// Subject 主体标识(宿主自解释,例如某个身份)。
Subject string
// Purpose 用途标识(宿主自解释,例如某个端点或某个能力)。
Purpose string
// Target 目标标识(宿主自解释,例如某个会话或某个对象的引用)。
Target string
}
Binding 票据携带的**用途绑定**(字段语义由宿主解释)。
本包只负责**原样携带与原样取回**,不解释、不校验、不推断。
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store 票据机制面:判定逻辑 + 状态接缝(Backend;进程内参考实现见 backend.go)。
零值不可用,用 New 构造。
func NewWithBackend ¶ added in v0.35.0
NewWithBackend 构造票据存储:判定逻辑在本包,状态在注入的 Backend。
多副本宿主用它接自己的跨进程存储(DB / 协调后端);Backend 的原子性契约 见 backend.go 文件头(Take 必须原子——多副本并发消费同一张票恰好一个赢)。 b 为 nil ⇒ 退化为进程内参考实现(DefaultMaxEntries)。
func NewWithClock ¶
NewWithClock 构造**进程内**票据存储并**注入时钟**。
⭐ 为什么要注入时钟:过期判定是本包的核心语义之一,若测试只能靠 sleep 等过期, 一则慢、二则把「等到了」当成了「判对了」——真把判定写反了,测试照样绿 (只是慢一点)。注入时钟 ⇒ 过期语义可以被**确定性**地测。
maxSize <= 0 ⇒ 取 DefaultMaxEntries。
func (*Store) Consume ¶
Consume 消费一张票据:取出绑定载荷并**立即作废**。
⭐ 语义:未知 id ⇒ 失败;已过期 ⇒ 失败;已被消费过 ⇒ 失败(同一路径, 因为消费即删除,第二次根本找不到)。成功 ⇒ 返回绑定载荷。
过期与未知**不区分**(都返回 false):区分开等于给「枚举 id」开了一扇 能区分「存在但过期」的窗,而票据 id 是 192 bit 随机,枚举本来就不可能—— 但没必要的差别就不留。
Backend 故障 ⇒ 按消费失败处理(fail-closed:票没被取走,宿主侧表现为 「这次没通过」,重试即可;绝不放行一张没验成的票)。本包入口没有 ctx 可传,跨进程实现的超时/截止由实现内部管理。
func (*Store) GC ¶
GC 清掉过期票据,返回清理张数。
过期票据**已不可消费**(Consume 判过期即失败),但仍占着空间 ⇒ 需要有人 定期收。宿主可挂定时器调它,也可只在签发前顺手调。 Backend 故障 ⇒ 返回 0(本轮没清成,下轮再试;清理是尽力面不是判定面)。
func (*Store) Issue ¶
Issue 签发一张票据:返回**票据 id** 与**过期时刻**。
⭐ 过期时刻由**签发方给的 TTL** 决定(机制面不替宿主定策略)。 随机源失败 ⇒ 显式报错(不降级成「可预测的 id」——票据 id 一旦可预测, 单次消费语义就没有意义了)。
容量已满 ⇒ 显式报错(见 DefaultMaxEntries 的说明)。
⚠️ 绑定载荷**零校验**(含零值 Binding{}——机制面不解释字段语义)。 零绑定票据消费成功后返回全空 Binding,消费方拿到「授权成立」却答不出 「授权给谁、干什么、对什么对象」,审计面同样无从追溯。多数宿主应改用 IssueStrict(签发前校验绑定完整性);确有「字段语义宿主自定义、允许留空」 的场景再用本函数。
func (*Store) IssueStrict ¶
IssueStrict 签发前校验**绑定完整性**:Binding 三字段(Subject/Purpose/ Target)任一为空 ⇒ 显式报错,不签发。
为什么默认建议走这里:空绑定不是策略,是**绑定不完整**——宿主策略无论 如何都没法在一张「无主体/无用途/无目标」的票据上做判定,而消费侧拿到的 是「授权成立 + 全空载荷」的假安全感(转用/误签发无从追溯)。与包内既有 防线同哲学:随机源失败显式报错、容量满显式拒发,语义层面的非法值同样 不静默放行。字段语义仍归宿主解释,本函数只校验「非空」这一完整性底线。