编写存储适配器
本页面向要把 wicket 的状态落到某种数据库(PostgreSQL、MySQL、Redis 等)的开发者:适配器该放在哪里、如何证明它符合契约、schema 与日志有哪些要求。接口本身的定义见存储端口。
放在独立的 Go 模块里
适配器应是一个独立仓库(独立 Go module),require github.com/gonewx/wicket,只依赖公开的端口包:
github.com/you/wicket-mydb
├── go.mod // require github.com/gonewx/wicket v0.1.4
├── LICENSE
├── code_store.go // storage.AuthorizationCodeStore
├── refresh_store.go // storage.RefreshTokenStore
├── session_store.go // session.Store
├── key_store.go // keymgmt.RecordStore
├── migrations/ // 本仓自有的建表脚本
└── *_test.go // 契约套件这样做的好处:wicket 本身不引入任何数据库驱动;适配器可以按自己的节奏发版;表结构的所有权清晰地归适配器仓。
生产装配需要注入 ServerConfig 列出的全部 11 个存储注入缝(否则 Freeze() 会 panic)。适配器不必一次覆盖所有接口——一个仓可以只实现授权码与刷新令牌,其余由另一个适配器(例如 Redis)提供。
跑通契约套件
wicket 为三族端口各提供一套可执行的契约套件:
| 套件包 | 入口函数 |
|---|---|
github.com/gonewx/wicket/storage/storagetest | RunAuthorizationCodeStoreSuite、RunRefreshTokenStoreSuite、RunReferenceTokenStoreSuite、RunUserConsentStoreSuite、RunPersistedGrantStoreSuite、RunDeviceFlowStoreSuite、RunBackchannelAuthenticationRequestStoreSuite |
github.com/gonewx/wicket/session/sessiontest | RunSessionStoreSuite |
github.com/gonewx/wicket/keymgmt/keymgmttest | RunRecordStoreSuite |
所有入口的签名形如:
func RunAuthorizationCodeStoreSuite(t *testing.T, newStore func() storage.AuthorizationCodeStore, opts ...Option)newStore 必须每次返回一个全新的空存储:套件在每个用例之前都会调用它,用例之间不能共享状态。工厂里可以建连接、建临时 schema、清表——套件从不直接检查底层介质,只通过接口观察行为。
下面的测试已编译并实际运行通过(演示用适配器内部委托给 wicket 自带的内存实现,你的实现换成真正的数据库访问即可):
package mydb
import (
"testing"
"time"
"github.com/gonewx/wicket/keymgmt"
"github.com/gonewx/wicket/keymgmt/keymgmttest"
"github.com/gonewx/wicket/session"
"github.com/gonewx/wicket/session/sessiontest"
"github.com/gonewx/wicket/storage"
"github.com/gonewx/wicket/storage/storagetest"
)
func TestAuthorizationCodeStoreConformance(t *testing.T) {
storagetest.RunAuthorizationCodeStoreSuite(t, func() storage.AuthorizationCodeStore {
return NewAuthorizationCodeStore() // 每次一个全新的空 store
}, storagetest.WithMay(true))
}
func TestRecordStoreConformance(t *testing.T) {
keymgmttest.RunRecordStoreSuite(t, func() keymgmt.RecordStore {
return NewRecordStore()
}, keymgmttest.WithMay(true))
}
type fixedClock struct{}
func (fixedClock) Now() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }
func TestSessionStoreConformance(t *testing.T) {
sessiontest.RunSessionStoreSuite(t, func() session.Store {
return NewSessionStore(fixedClock{})
})
}MUST 与 MAY
用例按 RFC 2119 分级,子测试名以 MUST/ 或 MAY/ 开头:
- MUST 用例总是运行。跳过或失败任何一个 MUST 用例,都不能宣称合规。覆盖的内容包括:未命中返回哨兵、insert-only 冲突、版本号冲突与回写、过期记录仍可读、
RemoveExpired恰好回收早于 cutoff 的记录、已取消 ctx 下的读写行为、不产生后台 goroutine 等。 - MAY 用例默认跳过,传
WithMay(true)才运行,覆盖增强行为(并发压力、契约版本声明检查等)。
建议在 CI 中始终带上 WithMay(true),并用 go test -race 运行。
已取消的 ctx
套件会用一个已经取消的 ctx 调用读写方法,三套的要求不完全相同:
- 写入(三套一致):要么返回
context.Canceled,要么确实完成且之后可读到——“返回成功但什么也没写”判失败。 - 读取未命中:
storagetest只要求返回非 nil 错误(哨兵或context.Canceled均可);sessiontest与keymgmttest则要求errors.Is(err, session.ErrSessionNotFound)/errors.Is(err, keymgmt.ErrRecordNotFound)成立。
后一条对数据库适配器是个陷阱:把已取消的 ctx 传给驱动,得到的是 context.Canceled,套件会判失败。一种做法是同时包装两个错误,让调用方两种判断都成立:
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("%w: %w", session.ErrSessionNotFound, err)
}(示意:是否这样处理请结合你的驱动行为评估;它会让“取消”在上层也表现为“未找到”。)
声明契约版本:ConformsTo
每个套件包导出一个常量 SuiteVersion(v0.1.4 中三者均为 "1.0.0"),标识它校验的契约版本。约定是:合规的适配器在每个存储类型上实现
func (s *AuthorizationCodeStore) ConformsTo() string { return storagetest.SuiteVersion }返回它通过的套件版本。WithMay(true) 时套件会检查这个值是否与当前 SuiteVersion 一致。
wicket 核心在装配时不会自动检查 ConformsTo(),这一步由宿主决定是否执行。下面是一个可编译的检查函数,宿主可在调用 cfg.Set* 之前使用:
func requireConformance(name string, store any) error {
c, ok := store.(interface{ ConformsTo() string })
if !ok {
return fmt.Errorf("%s: 适配器未声明 ConformsTo()", name)
}
if got := c.ConformsTo(); got != storagetest.SuiteVersion {
return fmt.Errorf("%s: 契约版本 %s,当前 wicket 要求 %s", name, got, storagetest.SuiteVersion)
}
return nil
}会话与密钥记录存储分别与 sessiontest.SuiteVersion、keymgmttest.SuiteVersion 比较。升级 wicket 后若 SuiteVersion 变化,应先在适配器仓用新套件重跑并发版,再升级宿主。
自备迁移
- 建表脚本放在适配器仓里,随适配器版本一起发布,表结构的所有权归适配器。
- 迁移由宿主在启动序列中显式调用(例如导出
migrations.Up(ctx, db)),不要在构造函数或首次查询时偷偷执行,也不要起后台任务。 - 迁移要幂等,可以重复执行;提供回滚入口更好。
- wicket 不负责任何迁移,也不感知你的表名。
从访问模式推导 schema
不要把 storage/models 里的 Go 结构体逐字段映射成列。这些模型会随 wicket 演进而增删字段,一一映射会让每次升级都变成一次表结构变更。更稳妥的做法是从端口方法的读写方式倒推:
| 访问模式 | 来自哪些方法 | schema 上的体现 |
|---|---|---|
| 按句柄精确读取、删除 | Get* / Remove*(handle、key、device code、request id) | 句柄作主键或唯一索引 |
| insert-only | Store* | 依赖主键冲突返回 ErrDuplicateHandle,不要用 ON CONFLICT DO UPDATE |
| 按 subject / client 批量查询或吊销 | RemoveReferenceTokens、GetAll/RemoveAll、GetSessionsBySubjectID | 在 subject、client 上建索引 |
| 按过期时间回收 | RemoveExpired / DeleteExpired | 单独存一列过期时间并建索引,仅用于回收,读取时不过滤 |
| 版本号并发 | UpdateRefreshToken、RecordStore.Update | 一列整数版本号,UPDATE ... WHERE handle = ? AND version = ? 后检查影响行数 |
需要被查询、索引的少数字段单独成列,其余内容可以序列化成一列(JSON 或二进制)整体存取。读取时必须还原出完整对象,并返回独立副本。
在适配器仓里留一份简短的设计说明,写清每张表、每个索引服务于哪个端口方法,方便后来者评审与调优。
日志与凭据
- 只使用宿主注入的
*slog.Logger(通过构造参数传入),不要自建 logger,也不要直接写 stdout/stderr。 - 任何级别的日志都不得包含令牌值、授权码、刷新令牌句柄、私钥材料、client secret 或数据库连接串中的密码。需要排查时只记录“存在与否”以及标识符的短前缀。
- 错误信息同理:包装驱动错误时,确认它不会把带凭据的 SQL 参数或 DSN 带出来。
许可证
适配器是独立的模块,需要在仓库根目录放置自己的 LICENSE 文件,并在 go.mod 声明自己的模块路径。wicket 本身以 Apache License 2.0 发布,适配器选用的许可证需与之兼容。许可方面的更多信息见许可与致谢。