Skip to content

编写存储适配器 ​

本页面向要把 wicket 的状态落到某种数据库(PostgreSQL、MySQL、Redis 等)的开发者:适配器该放在哪里、如何证明它符合契约、schema 与日志有哪些要求。接口本身的定义见存储端口。

放在独立的 Go 模块里 ​

适配器应是一个独立仓库(独立 Go module),require github.com/gonewx/wicket,只依赖公开的端口包:

text
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/storagetestRunAuthorizationCodeStoreSuite、RunRefreshTokenStoreSuite、RunReferenceTokenStoreSuite、RunUserConsentStoreSuite、RunPersistedGrantStoreSuite、RunDeviceFlowStoreSuite、RunBackchannelAuthenticationRequestStoreSuite
github.com/gonewx/wicket/session/sessiontestRunSessionStoreSuite
github.com/gonewx/wicket/keymgmt/keymgmttestRunRecordStoreSuite

所有入口的签名形如:

go
func RunAuthorizationCodeStoreSuite(t *testing.T, newStore func() storage.AuthorizationCodeStore, opts ...Option)

newStore 必须每次返回一个全新的空存储:套件在每个用例之前都会调用它,用例之间不能共享状态。工厂里可以建连接、建临时 schema、清表——套件从不直接检查底层介质,只通过接口观察行为。

下面的测试已编译并实际运行通过(演示用适配器内部委托给 wicket 自带的内存实现,你的实现换成真正的数据库访问即可):

go
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,套件会判失败。一种做法是同时包装两个错误,让调用方两种判断都成立:

go
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"),标识它校验的契约版本。约定是:合规的适配器在每个存储类型上实现

go
func (s *AuthorizationCodeStore) ConformsTo() string { return storagetest.SuiteVersion }

返回它通过的套件版本。WithMay(true) 时套件会检查这个值是否与当前 SuiteVersion 一致。

wicket 核心在装配时不会自动检查 ConformsTo(),这一步由宿主决定是否执行。下面是一个可编译的检查函数,宿主可在调用 cfg.Set* 之前使用:

go
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-onlyStore*依赖主键冲突返回 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 发布,适配器选用的许可证需与之兼容。许可方面的更多信息见许可与致谢。

相关阅读 ​

以 Apache License 2.0 发布 · 文档内容 © 2026 Wicket 作者