Appearance
Keycloak SPI 服务注册机制深度解析:从 META-INF/services 到 ProviderFactory 的全链路揭秘
作者: 必码 | bima.cc
前言
Keycloak 作为业界领先的开源身份与访问管理(IAM)平台,其最核心的设计哲学之一便是 SPI(Service Provider Interface,服务提供者接口)机制。SPI 机制使得 Keycloak 的几乎每一个核心功能都可以被替换、扩展或增强——从用户存储、密码哈希、令牌签名到事件监听,无一例外。然而,无论你要实现哪种 SPI 扩展,第一步永远是"服务注册":让 Keycloak 知道你的扩展存在,并能够正确地加载和初始化它。
服务注册是 SPI 扩展开发的起点,也是最容易出问题的环节。一个看似简单的 META-INF/services 文件,背后涉及 Java 标准的 SPI 发现机制、Keycloak 的 Provider 加载器、类加载器隔离策略、ProviderFactory 的生命周期管理等多个层面的知识。任何一个环节的疏忽,都可能导致扩展"静默失败"——Keycloak 启动正常,但你的扩展就是没有被加载。
本文基于 keycloak-sandbox 项目中的 4 个 SPI 模块(spi-event-listener-extension、spi-sm-crypto-extension、spi-user-storage-extension、keycloak-server-extensions)共 10+ 个服务注册文件的完整分析,从 Java SPI 的底层原理出发,逐步深入到 Keycloak 的 Provider/Factory/SPI 三层模型,再到 META-INF/services 文件规范、ProviderFactory 生命周期管理、四大 SPI 接口实现模式、ProviderConfigurationBuilder 配置元数据、@AutoService 自动生成机制,以及 SPI 加载流程的调试技巧,力求为读者呈现一幅完整的 Keycloak SPI 服务注册全景图。
读者受众:
- 有一定 Java 基础,希望深入理解 Keycloak SPI 机制的开发者
- 正在进行 Keycloak 扩展开发,遇到服务注册或加载问题的工程师
- 对 Java SPI 机制感兴趣,希望了解其在大型开源项目中实际应用的技术爱好者
- 负责企业级身份认证架构设计,需要评估 Keycloak 扩展能力的架构师
第一章 Keycloak SPI 架构与 Java SPI 机制
1.1 Java SPI 机制回顾
在深入 Keycloak SPI 之前,我们必须先理解 Java 标准库中 SPI(Service Provider Interface)机制的工作原理。Java SPI 是 Java 提供的一种服务发现机制,它允许第三方为某个接口提供实现,而不需要在代码中硬编码依赖关系。这种"面向接口编程、运行时发现实现"的思想,正是 Keycloak SPI 的基石。
1.1.1 SPI 的核心概念
Java SPI 机制由三个核心角色组成:
┌─────────────────────────────────────────────────────────────┐
│ Java SPI 三角关系 │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 服务接口 │ ◄────── │ 服务提供者 │ │
│ │ (Interface) │ 实现 │ (Implementation) │ │
│ └──────┬───────┘ └──────────────────────┘ │
│ │ │
│ │ 注册到 │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ META-INF/services/配置文件 │ │
│ │ (文件名=接口全限定名) │ │
│ │ (文件内容=实现类全限定名) │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ ServiceLoader (发现与加载) │ │
│ │ java.util.ServiceLoader │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘- 服务接口(Service Interface):定义服务契约的 Java 接口或抽象类。例如
java.sql.Driver就是一个典型的服务接口。 - 服务提供者(Service Provider):实现了服务接口的具体类。例如 MySQL 的
com.mysql.cj.jdbc.Driver就是java.sql.Driver的一个服务提供者。 - 服务配置文件:位于
META-INF/services/目录下,文件名为服务接口的全限定名,文件内容为服务提供者的全限定名(每行一个)。
1.1.2 ServiceLoader 工作原理
java.util.ServiceLoader 是 Java SPI 机制的核心工具类,它负责在运行时发现和加载服务提供者。其工作流程如下:
java
// Java SPI 标准使用方式
// 1. 定义服务接口
public interface CryptoService {
String sign(byte[] data);
boolean verify(byte[] data, byte[] signature);
}
// 2. 提供实现类
public class SM2CryptoService implements CryptoService {
@Override
public String sign(byte[] data) { /* SM2 签名逻辑 */ }
@Override
public boolean verify(byte[] data, byte[] signature) { /* SM2 验签逻辑 */ }
}
// 3. 创建 META-INF/services/com.example.CryptoService 文件
// 文件内容: com.example.SM2CryptoService
// 4. 使用 ServiceLoader 加载
ServiceLoader<CryptoService> loader = ServiceLoader.load(CryptoService.class);
for (CryptoService service : loader) {
String result = service.sign(data);
}ServiceLoader 的内部工作流程可以分解为以下步骤:
ServiceLoader.load(ServiceInterface.class)
│
▼
┌─────────────────────────────┐
│ 1. 获取当前线程的类加载器 │
│ Thread.currentThread() │
│ .getContextClassLoader() │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 2. 搜索所有 JAR 中的 │
│ META-INF/services/接口全限定名 文件 │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 3. 逐行读取文件内容 │
│ (忽略空行和 # 注释) │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 4. 通过 Class.forName() 加载实现类 │
│ 并通过反射创建实例 │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 5. 返回实现类实例的迭代器 │
└─────────────────────────────────────────┘1.1.3 SPI 与 API 的区别
很多开发者容易混淆 SPI 和 API 的概念。理解它们的区别对于掌握 Keycloak 的扩展机制至关重要:
┌──────────────────────────────────────────────────────────────┐
│ SPI vs API 对比 │
├──────────────┬───────────────────┬───────────────────────────┤
│ 维度 │ API │ SPI │
├──────────────┼───────────────────┼───────────────────────────┤
│ 调用方向 │ 调用者 → 实现 │ 框架 → 插件实现 │
│ 发现机制 │ 编译时绑定 │ 运行时发现 │
│ 依赖关系 │ 调用者依赖实现 │ 框架定义接口,实现可选 │
│ 典型场景 │ SDK 调用 │ 可插拔扩展 │
│ Java 例子 │ java.sql.Connection│ java.sql.Driver │
│ Keycloak例子 │ Admin REST API │ EventListenerProvider │
└──────────────┴───────────────────┴───────────────────────────┘简单来说:
- API 是"你调用别人"——你作为调用者,使用别人提供的接口。
- SPI 是"别人调用你"——框架定义接口,你提供实现,框架在运行时发现并调用你的实现。
Keycloak SPI 正是这种"框架调用插件"的模式。你编写的 EventListenerProviderFactory、SignatureProviderFactory 等,都是 Keycloak 在运行时通过 Java SPI 机制发现并调用的。
1.1.4 Java SPI 的局限性
虽然 Java SPI 机制简洁优雅,但它也有一些固有的局限性,这些局限性在 Keycloak 中都得到了不同程度的解决:
没有依赖注入:标准 Java SPI 只能通过无参构造函数创建实例。Keycloak 通过引入
ProviderFactory模式解决了这个问题——Factory 负责创建 Provider,可以在创建过程中注入 KeycloakSession 等依赖。没有生命周期管理:标准 Java SPI 不管理实例的生命周期。Keycloak 通过 Factory 的
init()、postInit()、close()方法实现了完整的生命周期管理。没有优先级排序:标准 Java SPI 不支持为多个实现定义优先级。Keycloak 通过
order属性和 SPI 配置解决了这个问题。没有配置元数据:标准 Java SPI 不支持声明式配置。Keycloak 通过
ProviderConfigurationBuilder提供了丰富的配置元数据支持。
1.2 Keycloak SPI 的扩展设计
Keycloak 在 Java 标准 SPI 机制的基础上,构建了一套更加完善的服务提供者框架。这套框架不仅保留了 Java SPI 的"运行时发现"能力,还增加了生命周期管理、依赖注入、配置元数据、优先级排序等企业级特性。
1.2.1 Keycloak SPI 的设计目标
Keycloak SPI 框架的设计目标可以概括为以下几点:
┌──────────────────────────────────────────────────────────────┐
│ Keycloak SPI 设计目标 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 可插拔性 │ │ 可发现性 │ │ 可配置性 │ │
│ │ Pluggable │ │ Discoverable │ │ Configurable │ │
│ │ │ │ │ │ │ │
│ │ 核心功能可 │ │ 自动发现 │ │ 管理控制台 │ │
│ │ 被替换 │ │ 扩展实现 │ │ 动态配置 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 生命周期 │ │ 隔离性 │ │ 一致性 │ │
│ │ Lifecycle │ │ Isolation │ │ Consistency │ │
│ │ │ │ │ │ │ │
│ │ init/create │ │ 类加载器 │ │ 统一的注册 │ │
│ │ /close 管理 │ │ 隔离 │ │ 与加载模式 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────┘1.2.2 Keycloak SPI 的整体架构
Keycloak SPI 的整体架构可以分为以下层次:
┌─────────────────────────────────────────────────────────────────┐
│ Keycloak SPI 整体架构 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 管理控制台 (Admin Console) │ │
│ │ 通过 ProviderConfigurationBuilder 生成配置 UI │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ 配置信息 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KeycloakSessionFactory │ │
│ │ (全局工厂,管理所有 Provider 的生命周期) │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KeycloakSession │ │
│ │ (会话级工厂,按需创建 Provider 实例) │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ProviderFactory│ │ ProviderFactory│ │ ProviderFactory│ │
│ │ (Event) │ │ (Signature) │ │ (UserStorage)│ │
│ │ │ │ │ │ │ │
│ │ 创建和管理 │ │ 创建和管理 │ │ 创建和管理 │ │
│ │ Provider实例 │ │ Provider实例 │ │ Provider实例 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ EventListener│ │ Signature │ │ UserStorage │ │
│ │ Provider │ │ Provider │ │ Provider │ │
│ │ (实际功能) │ │ Provider │ │ Provider │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SPI 发现层 (Java ServiceLoader) │ │
│ │ META-INF/services/ → ServiceLoader → 反射实例化 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘1.2.3 Keycloak 内置 SPI 类型一览
Keycloak 内置了大量的 SPI 类型,覆盖了身份认证与访问管理的方方面面。以下是一些常用的 SPI 类型:
| SPI 类型 | 接口 | 用途 |
|---|---|---|
| EventListener | EventListenerProviderFactory | 事件监听(登录、登出、注册等) |
| UserStorage | UserStorageProviderFactory | 用户存储(外部数据库、LDAP等) |
| Signature | SignatureProviderFactory | 令牌签名算法 |
| Hash | HashProviderFactory | 密码哈希算法 |
| KeyProvider | KeyProviderFactory | 密钥管理 |
| ContentEncryption | ContentEncryptionProviderFactory | 内容加密 |
| Authenticator | AuthenticatorFactory | 认证器 |
| ProtocolMapper | ProtocolMapper | 协议映射器 |
| ClientRegistration | ClientRegistrationProvider | 客户端注册 |
| Exchange | IdentityProviderMapper | 身份提供者映射 |
在 keycloak-sandbox 项目中,我们实现了其中的 6 种 SPI 类型,涵盖了事件监听、密码学、用户存储等核心领域。
1.3 Provider / Factory / SPI 三层模型
Keycloak SPI 的核心设计模式是"三层模型":SPI 定义服务契约,Factory 管理生命周期,Provider 提供实际功能。理解这三层的关系,是掌握 Keycloak SPI 机制的关键。
1.3.1 三层模型详解
┌─────────────────────────────────────────────────────────────────┐
│ Provider / Factory / SPI 三层模型 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第一层: SPI (Service Provider Interface) │ │
│ │ ───────────────────────────────────────────── │ │
│ │ 定义服务类型和基本契约 │ │
│ │ 例如: EventListenerSPI, UserStorageSPI │ │
│ │ 作用: 将服务按类型分类,提供统一的发现入口 │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ 包含多个 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第二层: ProviderFactory (服务工厂) │ │
│ │ ───────────────────────────────────── │ │
│ │ 管理特定 Provider 实现的生命周期 │ │
│ │ 例如: AuditEventListenerProviderFactory │ │
│ │ 作用: init → create → postInit → close │ │
│ │ 一个 SPI 可以有多个 Factory 实现 │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ 创建 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第三层: Provider (服务提供者) │ │
│ │ ───────────────────────────────────── │ │
│ │ 提供实际的功能实现 │ │
│ │ 例如: AuditEventListenerProvider │ │
│ │ 作用: 执行具体的业务逻辑 │ │
│ │ 由 Factory 创建,生命周期由 Session 管理 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 注册层: META-INF/services/ │ │
│ │ ───────────────────────────────── │ │
│ │ 将 Factory 实现类注册到 Java SPI 发现机制 │ │
│ │ 文件名: Factory 接口全限定名 │ │
│ │ 文件内容: Factory 实现类全限定名 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘1.3.2 三层模型的协作流程
让我们通过一个完整的流程来理解三层模型是如何协作的。以事件监听器为例:
时间线 ──────────────────────────────────────────────────────────►
1. Keycloak 启动
│
├─► ServiceLoader 扫描 META-INF/services/
│ 发现: org.keycloak.events.EventListenerProviderFactory
│ 内容: cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory
│
├─► 反射实例化 AuditEventListenerProviderFactory
│
├─► 调用 factory.init(Config.Scope config)
│ → 读取全局配置,初始化资源
│
├─► 调用 factory.postInit(KeycloakSessionFactory factory)
│ → 执行后置初始化,注册全局监听
│
2. 请求到达(用户登录事件)
│
├─► KeycloakSession 获取 EventListenerProvider
│ session.getProvider(EventListenerProvider.class, "bima-audit-event-listener")
│
├─► ProviderManager 查找对应的 Factory
│ 找到: AuditEventListenerProviderFactory (ID 匹配)
│
├─► 调用 factory.create(session, model)
│ → 创建 AuditEventListenerProvider 实例
│
├─► AuditEventListenerProvider.onEvent(event)
│ → 执行审计日志记录
│
├─► Session 关闭时调用 provider.close()
│ → 释放会话级资源
│
3. Keycloak 关闭
│
└─► 调用 factory.close()
→ 释放全局资源(关闭连接池、线程池等)1.3.3 为什么需要 Factory 层?
很多开发者会问:为什么不直接注册 Provider 类,而要引入 Factory 这一层?这是一个非常好的问题。Factory 层的存在有以下几个关键原因:
第一,生命周期分离。 Provider 是会话级的(每个请求可能创建新的 Provider 实例),而 Factory 是全局级的(整个 Keycloak 运行期间只有一个实例)。Factory 负责管理全局资源(如数据库连接池、线程池),Provider 只负责处理单个请求的业务逻辑。
第二,延迟创建。 Factory 在 Keycloak 启动时就被创建和初始化,但 Provider 只在需要时才创建。这种"懒加载"模式可以显著减少启动时间和内存占用。
第三,配置驱动。 Factory 可以通过 getConfigProperties() 声明配置元数据,管理控制台会根据这些元数据自动生成配置界面。管理员在控制台中配置的参数会通过 ComponentModel 传递给 Factory,Factory 根据配置创建不同的 Provider 实例。
第四,多实例支持。 同一个 Factory 可以创建多个不同的 Provider 实例(基于不同的配置)。例如,你可以配置两个不同数据库的用户存储 Provider,它们由同一个 Factory 创建,但连接不同的数据库。
java
// Factory 的多实例能力示例
// 同一个 CustomUserStorageProviderFactory 可以创建多个 Provider:
// - Provider A: 连接 MySQL 数据库 (配置: dbType=mysql, connectionUrl=jdbc:mysql://db1)
// - Provider B: 连接 Oracle 数据库 (配置: dbType=oracle, connectionUrl=jdbc:oracle:thin:@db2)
// - Provider C: 连接达梦数据库 (配置: dbType=dameng, connectionUrl=jdbc:dm://db3)1.3.4 三层模型在 keycloak-sandbox 中的体现
在 keycloak-sandbox 项目中,三层模型得到了充分的体现。以下是项目中所有 SPI 模块的三层映射关系:
┌──────────────────────────────────────────────────────────────────┐
│ keycloak-sandbox 三层模型映射 │
│ │
│ spi-event-listener-extension: │
│ ├── SPI: EventListenerSPI │
│ ├── Factory: AuditEventListenerProviderFactory │
│ │ ID = "bima-audit-event-listener" │
│ ├── Provider: AuditEventListenerProvider │
│ └── 注册: META-INF/services/ │
│ org.keycloak.events.EventListenerProviderFactory │
│ → cc.bima.keycloak.extension.event │
│ .AuditEventListenerProviderFactory │
│ │
│ spi-sm-crypto-extension: │
│ ├── SPI: SignatureSPI │
│ │ Factory: SMSignatureProviderFactory (ID="bima-sm-signature")│
│ ├── SPI: HashSPI │
│ │ Factory: SMHashProviderFactory (ID="bima-sm-hash") │
│ ├── SPI: ContentEncryptionSPI │
│ │ Factory: SMContentEncryptionProviderFactory │
│ │ (ID="bima-sm-content-encryption") │
│ ├── SPI: KeyProviderSPI │
│ │ Factory: SMKeyProviderFactory (ID="sm-generated") │
│ └── 注册: 4 个 META-INF/services/ 文件 │
│ │
│ spi-user-storage-extension: │
│ ├── SPI: UserStorageSPI │
│ ├── Factory: CustomUserStorageProviderFactory │
│ │ ID = "bima-spi-user-storage-extension" │
│ ├── Provider: CustomUserStorageProvider │
│ └── 注册: META-INF/services/ │
│ org.keycloak.storage.UserStorageProviderFactory │
│ → cc.bima.keycloak.extension.storage │
│ .CustomUserStorageProviderFactory │
└──────────────────────────────────────────────────────────────────┘第二章 META-INF/services 服务文件规范
2.1 服务文件格式与位置
META-INF/services 目录是 Java SPI 机制的服务注册中心。每一个 SPI 扩展都必须在这个目录下创建一个服务文件,才能被 Keycloak 发现和加载。
2.1.1 文件位置规范
服务文件必须位于 JAR 包的 META-INF/services/ 目录下。在 Maven 项目中,这意味着文件应该放在 src/main/resources/META-INF/services/ 目录中:
spi-event-listener-extension/
├── pom.xml
└── src/
└── main/
├── java/
│ └── cc/bima/keycloak/extension/event/
│ ├── AuditEventListenerProvider.java
│ └── AuditEventListenerProviderFactory.java
└── resources/
└── META-INF/
└── services/
└── org.keycloak.events.EventListenerProviderFactory2.1.2 文件命名规则
服务文件的文件名必须是 Factory 接口的全限定名(Fully Qualified Name)。这不是随意约定的,而是 ServiceLoader 的硬性要求:
文件名 = Factory 接口的全限定名
示例:
META-INF/services/org.keycloak.events.EventListenerProviderFactory
META-INF/services/org.keycloak.crypto.SignatureProviderFactory
META-INF/services/org.keycloak.crypto.HashProviderFactory
META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactory
META-INF/services/org.keycloak.keys.KeyProviderFactory
META-INF/services/org.keycloak.storage.UserStorageProviderFactory2.1.3 文件内容格式
服务文件的内容是 Factory 实现类的全限定名,每行一个。以下是格式规范:
# 这是注释行,以 # 开头
# ServiceLoader 会忽略注释行和空行
# Factory 实现类的全限定名
cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory
# 可以注册多个实现类(每行一个)
# cc.bima.keycloak.extension.event.AnotherEventListenerProviderFactory格式要点:
- 每行一个实现类的全限定名
- 以
#开头的行是注释,会被忽略 - 空行会被忽略
- 行末的空白字符会被忽略
- 文件编码必须为 UTF-8
- 文件末尾建议保留一个换行符
2.1.4 keycloak-sandbox 中的实际服务文件
以下是 keycloak-sandbox 项目中 spi-user-storage-extension 模块的实际服务文件内容:
文件路径: spi-user-storage-extension/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
文件内容:
cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory这个文件告诉 Keycloak:当需要加载 UserStorageProviderFactory 类型的服务时,请加载 cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory 这个实现类。
2.2 多实现类注册规则
一个服务文件可以注册多个实现类。这在某些场景下非常有用,例如同时注册多个签名算法提供者。
2.2.1 多实现注册示例
# META-INF/services/org.keycloak.crypto.SignatureProviderFactory
# RSA 签名 (Keycloak 内置)
org.keycloak.crypto.ES256SignatureProviderFactory
org.keycloak.crypto.ES384SignatureProviderFactory
org.keycloak.crypto.ES512SignatureProviderFactory
org.keycloak.crypto.RS256SignatureProviderFactory
org.keycloak.crypto.RS384SignatureProviderFactory
org.keycloak.crypto.RS512SignatureProviderFactory
org.keycloak.crypto.PS256SignatureProviderFactory
org.keycloak.crypto.PS384SignatureProviderFactory
org.keycloak.crypto.PS512SignatureProviderFactory
# 国密 SM2 签名 (自定义扩展)
cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory2.2.2 多实现的 ID 冲突处理
当同一个 SPI 类型有多个 Factory 实现时,Keycloak 通过 Factory 的 getId() 方法返回的 ID 来区分它们。每个 Factory 必须返回唯一的 ID:
java
// Keycloak 内置的 RS256 签名工厂
public class RS256SignatureProviderFactory implements SignatureProviderFactory {
@Override
public String getId() {
return "RS256"; // 唯一 ID
}
}
// 自定义的 SM2 签名工厂
public class SMSignatureProviderFactory implements SignatureProviderFactory {
@Override
public String getId() {
return "bima-sm-signature"; // 唯一 ID,不能与内置的冲突
}
}重要规则: 如果两个 Factory 返回了相同的 ID,后加载的会覆盖先加载的。Keycloak 默认按照以下顺序加载:
- Keycloak 内置的 Provider
providers/目录下的 Provider- 部署的 SPI 扩展 JAR 包
2.2.3 多实现的优先级
在 keycloak-sandbox 的 spi-sm-crypto-extension 模块中,一个 JAR 包注册了 4 个不同的 Factory 实现,分别对应 4 个不同的 SPI 类型:
spi-sm-crypto-extension JAR 包中的服务文件:
META-INF/services/org.keycloak.crypto.SignatureProviderFactory
→ cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory
META-INF/services/org.keycloak.crypto.HashProviderFactory
→ cc.bima.keycloak.extension.crypto.hash.SMHashProviderFactory
META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactory
→ cc.bima.keycloak.extension.crypto.encryption.SMContentEncryptionProviderFactory
META-INF/services/org.keycloak.keys.KeyProviderFactory
→ cc.bima.keycloak.extension.crypto.keys.SMKeyProviderFactory这 4 个服务文件虽然位于同一个 JAR 包中,但它们注册的是不同的 SPI 类型,因此不会产生 ID 冲突。
2.3 类加载器与隔离机制
Keycloak 运行在 WildFly/Jakarta EE 容器中,类加载器的层次结构比较复杂。理解类加载器机制对于解决 SPI 加载问题至关重要。
2.3.1 Keycloak 的类加载器层次
┌─────────────────────────────────────────────────────────────────┐
│ Keycloak 类加载器层次结构 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Bootstrap ClassLoader │ │
│ │ (rt.jar, 核心Java类库) │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ Extension ClassLoader │ │
│ │ (Keycloak 扩展模块的类加载器) │ │
│ │ ───────────────────────────── │ │
│ │ 加载 SPI 扩展 JAR 包中的类 │ │
│ │ 包括: Factory 实现类、Provider 实现类、第三方依赖 │ │
│ │ │ │
│ │ ★ SPI 扩展的类由这个类加载器加载 │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ Keycloak Module ClassLoader │ │
│ │ (Keycloak 核心模块的类加载器) │ │
│ │ ───────────────────────────── │ │
│ │ 加载 Keycloak 核心 JAR 包中的类 │ │
│ │ 包括: SPI 接口、Provider 接口、SessionFactory 等 │ │
│ │ │ │
│ │ ★ SPI 接口定义由这个类加载器加载 │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ WildFly Module ClassLoader │ │
│ │ (应用服务器模块类加载器) │ │
│ │ ───────────────────────────── │ │
│ │ 加载 Jakarta EE API、WildFly 核心类 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘2.3.2 双亲委派与 SPI 发现
Java 的类加载器遵循"双亲委派"(Parents Delegation)模型:当一个类加载器收到类加载请求时,它会先把请求委派给父类加载器。只有在父类加载器无法加载时,子类加载器才会尝试加载。
然而,ServiceLoader 在发现服务文件时,使用的是当前线程的上下文类加载器(Thread.currentThread().getContextClassLoader()),而不是调用者的类加载器。这是 Java SPI 机制能够工作的关键:
java
// ServiceLoader 的核心发现逻辑(简化版)
public class ServiceLoader<S> implements Iterable<S> {
private static final String PREFIX = "META-INF/services/";
// 使用线程上下文类加载器
private ClassLoader loader;
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
private ServiceLoader(Class<S> service, ClassLoader loader) {
this.service = service;
this.loader = loader;
reload();
}
private void reload() {
// 使用指定的类加载器查找服务文件
Enumeration<URL> resources = loader.getResources(
PREFIX + service.getName()
);
// 解析并加载实现类...
}
}2.3.3 SPI 扩展的类加载隔离
在 keycloak-sandbox 项目中,SPI 扩展被打包为独立的 JAR 文件,部署到 Keycloak 的 providers/ 目录或通过 Docker 挂载。这些 JAR 文件由 Keycloak 的 Extension ClassLoader 加载,与 Keycloak 核心类库隔离。
这种隔离机制带来了一些重要的注意事项:
┌─────────────────────────────────────────────────────────────────┐
│ SPI 扩展类加载注意事项 │
│ │
│ 1. SPI 接口 vs SPI 实现 │
│ ├── SPI 接口 (如 EventListenerProviderFactory) │
│ │ 由 Keycloak 核心类加载器加载 │
│ └── SPI 实现 (如 AuditEventListenerProviderFactory) │
│ 由 Extension 类加载器加载 │
│ ★ 两者由不同的类加载器加载,但通过接口契约连接 │
│ │
│ 2. 依赖冲突 │
│ ├── SPI 扩展中 scope=provided 的依赖 │
│ │ (如 keycloak-server-spi) 由 Keycloak 提供 │
│ └── SPI 扩展中 scope=compile 的依赖 │
│ (如 HikariCP) 需要打包到 JAR 中 │
│ ★ 依赖版本冲突是 SPI 扩展最常见的部署问题 │
│ │
│ 3. 依赖传递 │
│ ├── SPI 扩展 JAR 中的第三方依赖 │
│ │ 可能与 Keycloak 内置的依赖版本冲突 │
│ └── 解决方案: 使用 shade/relocation 重命名包 │
│ ★ keycloak-sandbox 使用 provided scope 避免冲突 │
└─────────────────────────────────────────────────────────────────┘2.3.4 keycloak-sandbox 的依赖管理策略
keycloak-sandbox 项目采用了"provided scope"策略来管理依赖,这是一种最佳实践:
xml
<!-- keycloak-sandbox 父 POM 中的依赖管理 -->
<dependencyManagement>
<dependencies>
<!-- Keycloak 核心 SPI - provided,运行时由 Keycloak 提供 -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- AutoService - provided,仅编译时使用 -->
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
<scope>provided</scope>
</dependency>
<!-- Lombok - provided,仅编译时使用 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</dependencyManagement>2.4 keycloak-sandbox 中的服务文件清单
keycloak-sandbox 项目包含 3 个 SPI 扩展模块,共注册了 6 个服务文件。以下是完整的清单:
2.4.1 spi-event-listener-extension 模块
| 服务文件路径 | 注册的实现类 | SPI 类型 |
|---|---|---|
META-INF/services/org.keycloak.events.EventListenerProviderFactory | cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory | 事件监听 |
2.4.2 spi-sm-crypto-extension 模块
| 服务文件路径 | 注册的实现类 | SPI 类型 |
|---|---|---|
META-INF/services/org.keycloak.crypto.SignatureProviderFactory | cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory | 签名算法 |
META-INF/services/org.keycloak.crypto.HashProviderFactory | cc.bima.keycloak.extension.crypto.hash.SMHashProviderFactory | 哈希算法 |
META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactory | cc.bima.keycloak.extension.crypto.encryption.SMContentEncryptionProviderFactory | 内容加密 |
META-INF/services/org.keycloak.keys.KeyProviderFactory | cc.bima.keycloak.extension.crypto.keys.SMKeyProviderFactory | 密钥管理 |
2.4.3 spi-user-storage-extension 模块
| 服务文件路径 | 注册的实现类 | SPI 类型 |
|---|---|---|
META-INF/services/org.keycloak.storage.UserStorageProviderFactory | cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory | 用户存储 |
2.4.4 服务文件总览图
keycloak-sandbox 服务文件总览
═══════════════════════════════════════════════════════════════
spi-event-listener-extension (1 个服务文件)
└── META-INF/services/
└── org.keycloak.events.EventListenerProviderFactory
└── cc.bima.keycloak.extension.event
.AuditEventListenerProviderFactory
spi-sm-crypto-extension (4 个服务文件)
└── META-INF/services/
├── org.keycloak.crypto.SignatureProviderFactory
│ └── cc.bima.keycloak.extension.crypto.signature
│ .SMSignatureProviderFactory
├── org.keycloak.crypto.HashProviderFactory
│ └── cc.bima.keycloak.extension.crypto.hash
│ .SMHashProviderFactory
├── org.keycloak.crypto.ContentEncryptionProviderFactory
│ └── cc.bima.keycloak.extension.crypto.encryption
│ .SMContentEncryptionProviderFactory
└── org.keycloak.keys.KeyProviderFactory
└── cc.bima.keycloak.extension.crypto.keys
.SMKeyProviderFactory
spi-user-storage-extension (1 个服务文件)
└── META-INF/services/
└── org.keycloak.storage.UserStorageProviderFactory
└── cc.bima.keycloak.extension.storage
.CustomUserStorageProviderFactory
═══════════════════════════════════════════════════════════════
总计: 3 个模块, 6 个服务文件, 6 个 Factory 实现类第三章 ProviderFactory 生命周期管理
ProviderFactory 是 Keycloak SPI 机制的核心组件。它不仅负责创建 Provider 实例,还管理着从初始化到销毁的完整生命周期。理解 Factory 的生命周期,对于编写健壮的 SPI 扩展至关重要。
3.1 Factory 接口核心方法
Keycloak 中所有的 ProviderFactory 接口都继承自一个通用的基础接口,定义了统一的生命周期方法:
java
// Keycloak ProviderFactory 基础接口(简化版)
public interface ProviderFactory<T extends Provider> {
// 获取 Factory 的唯一标识符
String getId();
// 创建 Provider 实例
T create(KeycloakSession session);
// 初始化(全局级,仅调用一次)
void init(Config.Scope config);
// 后置初始化(全局级,仅调用一次)
void postInit(KeycloakSessionFactory factory);
// 关闭(全局级,仅调用一次)
void close();
// 获取配置元数据(可选)
// List<ProviderConfigProperty> getConfigProperties();
// 获取排序顺序(可选)
// int order();
}以下是 Factory 接口中各方法的调用时序图:
┌─────────────────────────────────────────────────────────────────┐
│ ProviderFactory 生命周期时序图 │
│ │
│ Keycloak 启动 │
│ │ │
│ ├─► 1. ServiceLoader 发现并实例化 Factory │
│ │ (通过 META-INF/services 文件) │
│ │ │
│ ├─► 2. factory.getId() │
│ │ → 获取 Factory 标识符,注册到 ProviderManager │
│ │ │
│ ├─► 3. factory.init(Config.Scope config) │
│ │ → 全局初始化,读取 keycloak-server.properties 配置 │
│ │ ★ 仅调用一次 │
│ │ │
│ ├─► 4. factory.postInit(KeycloakSessionFactory factory) │
│ │ → 后置初始化,可以访问其他已初始化的 Provider │
│ │ ★ 仅调用一次 │
│ │ │
│ │ ─────────── 运行阶段 ─────────── │
│ │ │
│ │ 请求到达 │
│ │ ├─► 5. factory.create(session, model) │
│ │ │ → 创建 Provider 实例 │
│ │ │ ★ 每次请求可能调用多次 │
│ │ │ │
│ │ ├─► 6. provider.onEvent(...) / provider.validate(...) │
│ │ │ → Provider 执行业务逻辑 │
│ │ │ │
│ │ └─► 7. provider.close() │
│ │ → 释放会话级资源 │
│ │ ★ 每次 create 对应一次 close │
│ │ │
│ │ ─────────── 运行阶段 ─────────── │
│ │ │
│ Keycloak 关闭 │
│ │ │
│ └─► 8. factory.close() │
│ → 释放全局资源 │
│ ★ 仅调用一次 │
└─────────────────────────────────────────────────────────────────┘3.2 init 初始化阶段
init(Config.Scope config) 是 Factory 的第一个生命周期方法,在 Keycloak 启动时调用。它接收一个 Config.Scope 参数,可以用来读取 keycloak-server.properties 或 standalone.xml 中的配置。
3.2.1 Config.Scope 的使用
Config.Scope 是一个分层的配置访问接口,支持按前缀获取配置:
java
// 教学示例: Factory 初始化
public class AuditEventListenerProviderFactory
implements EventListenerProviderFactory {
private static final Logger logger =
LoggerFactory.getLogger(AuditEventListenerProviderFactory.class);
// 全局配置参数
private boolean enableAuditLog;
private String auditLogPath;
private int maxLogFileSize;
@Override
public void init(Config.Scope config) {
// 读取配置,提供默认值
this.enableAuditLog = config.getBoolean("enableAuditLog", true);
this.auditLogPath = config.get("auditLogPath", "/var/log/keycloak/audit.log");
this.maxLogFileSize = config.getInt("maxLogFileSize", 10485760); // 10MB
logger.info("AuditEventListenerProviderFactory initialized:");
logger.info(" enableAuditLog = {}", enableAuditLog);
logger.info(" auditLogPath = {}", auditLogPath);
logger.info(" maxLogFileSize = {}", maxLogFileSize);
}
}对应的 keycloak-server.properties 配置:
properties
# keycloak-server.properties
bimaAuditEventListener.enableAuditLog=true
bimaAuditEventListener.auditLogPath=/var/log/keycloak/audit.log
bimaAuditEventListener.maxLogFileSize=104857603.2.2 init 阶段可以做什么
在 init 方法中,你应该执行以下类型的初始化操作:
┌─────────────────────────────────────────────────────────────┐
│ init 阶段适合的操作 │
├─────────────────────────────────────────────────────────────┤
│ ✓ 读取全局配置参数 │
│ ✓ 初始化全局级别的资源(如线程池、缓存) │
│ ✓ 加载配置文件 │
│ ✓ 初始化日志系统 │
│ ✓ 验证运行环境(如检查必需的本地文件是否存在) │
├─────────────────────────────────────────────────────────────┤
│ ✗ 访问 KeycloakSession(此时 Session 尚未创建) │
│ ✗ 访问数据库(建议在 create 中按需建立连接) │
│ ✗ 访问其他 Provider(其他 Provider 可能尚未初始化) │
└─────────────────────────────────────────────────────────────┘3.2.3 keycloak-sandbox 中的 init 实现
在 keycloak-sandbox 项目中,CustomUserStorageProviderFactory 的 init 方法实现非常简洁:
java
// 来自 keycloak-sandbox 项目的实际代码
@Override
public void init(Config.Scope config) {
// 初始化配置
// 用户存储的配置通过 ComponentModel 在 create 时传入
// 全局配置可以在这里读取
}这是因为 CustomUserStorageProviderFactory 的配置是通过管理控制台的 ComponentModel 传入的(而不是通过 keycloak-server.properties),所以 init 方法不需要读取全局配置。数据库连接池的初始化被延迟到了 getDataSource() 方法中,这是一种"懒加载"策略。
3.3 create 实例创建
create(KeycloakSession session) 是 Factory 最核心的方法,它负责创建 Provider 实例。这个方法在每次需要 Provider 时都可能被调用。
3.3.1 create 方法签名差异
不同类型的 SPI,create 方法的签名可能略有不同:
java
// 类型1: 仅接收 session 参数
// 适用于不需要组件级配置的 Provider
T create(KeycloakSession session);
// 类型2: 接收 session 和 model 参数
// 适用于需要组件级配置的 Provider(如 UserStorage)
T create(KeycloakSession session, ComponentModel model);3.3.2 create 方法的实现模式
以下是 keycloak-sandbox 项目中 CustomUserStorageProviderFactory 的 create 方法实现:
java
// 来自 keycloak-sandbox 项目的实际代码
@Override
public CustomUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new CustomUserStorageProvider(session, model, this);
}这个实现遵循了一个重要的设计模式:将 Factory 自身传递给 Provider。这样 Provider 可以通过 Factory 访问共享资源(如数据库连接池):
java
// Provider 通过 Factory 获取共享资源
public class CustomUserStorageProvider implements UserStorageProvider {
private final CustomUserStorageProviderFactory factory;
private final ComponentModel model;
public CustomUserStorageProvider(KeycloakSession session,
ComponentModel model,
CustomUserStorageProviderFactory factory) {
this.factory = factory;
this.model = model;
}
private Connection getConnection() throws SQLException {
// 通过 Factory 获取共享的数据源连接
return factory.getDataSource(model).getConnection();
}
}3.3.3 create 的调用频率
create 方法的调用频率取决于 Provider 的使用场景:
┌─────────────────────────────────────────────────────────────┐
│ create 调用频率分析 │
├──────────────────┬──────────────────────────────────────────┤
│ Provider 类型 │ 调用频率 │
├──────────────────┼──────────────────────────────────────────┤
│ EventListener │ 每次事件触发时可能创建新的 Provider 实例 │
│ UserStorage │ 每次用户查找/认证时创建新的 Provider 实例 │
│ Signature │ 每次令牌签名/验签时创建新的 Provider 实例 │
│ Hash │ 每次密码哈希/验证时创建新的 Provider 实例 │
│ KeyProvider │ 密钥操作时创建新的 Provider 实例 │
└──────────────────┴──────────────────────────────────────────┘重要提示: create 方法应该是轻量级的。不要在 create 中执行耗时的初始化操作(如建立数据库连接)。耗时的资源应该由 Factory 管理,Provider 只需要从 Factory 获取已初始化的资源引用。
3.4 postInit 后置初始化
postInit(KeycloakSessionFactory factory) 在所有 Factory 的 init 方法都执行完毕后调用。它提供了一个机会,让 Factory 可以访问其他已经初始化的 Provider。
3.4.1 postInit 的典型用途
java
// 教学示例: postInit 中访问其他 Provider
@Override
public void postInit(KeycloakSessionFactory factory) {
// 可以在这里注册事件监听器
// 可以访问其他已初始化的 Provider
// 可以执行跨 Provider 的初始化逻辑
// 示例: 注册一个定时任务来清理过期数据
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
// 清理过期审计日志
cleanExpiredAuditLogs();
}, 1, 1, TimeUnit.HOURS);
}3.4.2 keycloak-sandbox 中的 postInit 实现
在 keycloak-sandbox 项目中,大部分 Factory 的 postInit 方法都是空实现,因为它们的初始化逻辑比较简单,不需要跨 Provider 协作:
java
// 来自 keycloak-sandbox 项目的实际代码
@Override
public void postInit(KeycloakSessionFactory factory) {
// 初始化后处理
// 对于简单的 Factory,通常不需要在这里做任何事情
}3.5 close 资源释放
close() 方法在 Keycloak 关闭时调用,用于释放 Factory 持有的全局资源。这是确保资源正确释放的关键方法。
3.5.1 close 的实现要点
java
// 来自 keycloak-sandbox 项目的实际代码
@Override
public void close() {
// 关闭所有数据源
for (HikariDataSource dataSource : dataSourceMap.values()) {
try {
dataSource.close();
logger.info("DataSource closed successfully");
} catch (Exception e) {
logger.error("Error closing DataSource: {}", e.getMessage(), e);
}
}
dataSourceMap.clear();
}这段代码展示了 close 方法的几个最佳实践:
- 遍历所有资源:关闭所有已创建的资源,不留遗漏。
- 异常处理:每个资源的关闭操作都包裹在 try-catch 中,确保一个资源的关闭失败不会影响其他资源的关闭。
- 日志记录:记录关闭操作的结果,便于排查问题。
- 清理集合:关闭所有资源后,清空资源集合,防止内存泄漏。
3.5.2 close 的调用保证
Keycloak 保证 close 方法在以下情况下都会被调用:
- 正常关闭(
CTRL+C或shutdown命令) - 异常关闭(OOM、致命错误等)
- 热部署更新(重新部署 SPI 扩展时)
但需要注意的是,如果 JVM 被强制杀死(kill -9),close 方法可能不会被执行。对于需要保证数据持久化的场景,应该考虑使用更可靠的资源清理机制。
3.6 Factory 实例缓存与单例模式
在 Keycloak 中,每个 Factory 实现类在同一个 Keycloak 实例中只会被创建一次。这意味着 Factory 天然就是"单例"的。
3.6.1 Factory 的单例特性
┌─────────────────────────────────────────────────────────────────┐
│ Factory 单例模式 │
│ │
│ Keycloak 实例 (JVM) │
│ │ │
│ ├── ProviderManager │
│ │ ├── AuditEventListenerProviderFactory (单例) │
│ │ │ └── dataSourceMap: ConcurrentHashMap (共享资源) │
│ │ │ │
│ │ ├── SMSignatureProviderFactory (单例) │
│ │ └── CustomUserStorageProviderFactory (单例) │
│ │ └── dataSourceMap: ConcurrentHashMap (共享资源) │
│ │ │
│ ├── KeycloakSession #1 │
│ │ ├── CustomUserStorageProvider (实例 A) │
│ │ │ └── factory → CustomUserStorageProviderFactory (单例) │
│ │ └── AuditEventListenerProvider (实例 B) │
│ │ │
│ ├── KeycloakSession #2 │
│ │ ├── CustomUserStorageProvider (实例 C) │
│ │ │ └── factory → CustomUserStorageProviderFactory (单例) │
│ │ └── AuditEventListenerProvider (实例 D) │
│ │ │
│ └── KeycloakSession #N │
│ ├── CustomUserStorageProvider (实例 X) │
│ │ └── factory → CustomUserStorageProviderFactory (单例) │
│ └── AuditEventListenerProvider (实例 Y) │
│ │
│ ★ Factory 是全局单例,Provider 是会话级实例 │
│ ★ 多个 Provider 实例共享同一个 Factory 的资源 │
└─────────────────────────────────────────────────────────────────┘3.6.2 共享资源管理
在 keycloak-sandbox 项目中,CustomUserStorageProviderFactory 利用单例特性管理共享的数据库连接池:
java
// 来自 keycloak-sandbox 项目的实际代码
public class CustomUserStorageProviderFactory
implements UserStorageProviderFactory<CustomUserStorageProvider> {
// 共享数据源映射,key为连接URL,value为数据源
// ★ 这个 Map 是 Factory 单例级别的,所有 Provider 实例共享
private final Map<String, HikariDataSource> dataSourceMap =
new ConcurrentHashMap<>();
/**
* 获取或创建数据源
* 使用 computeIfAbsent 保证同一个 URL 只创建一个数据源
*/
public HikariDataSource getDataSource(ComponentModel model) {
String connectionUrl = model.getConfig().getFirst("connectionUrl");
// computeIfAbsent 是线程安全的
// 如果 connectionUrl 对应的数据源已存在,直接返回
// 如果不存在,创建新的数据源并缓存
return dataSourceMap.computeIfAbsent(connectionUrl, url -> {
// 创建并配置 HikariCP 数据源
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(dialect.getDriverClassName());
config.setMaximumPoolSize(10);
config.setMinimumIdle(5);
return new HikariDataSource(config);
});
}
}这种设计模式有几个关键优势:
- 连接池复用:同一个数据库 URL 只创建一个连接池,多个 Provider 实例共享连接池,避免资源浪费。
- 线程安全:使用
ConcurrentHashMap和computeIfAbsent保证线程安全。 - 懒加载:数据源只在第一次需要时创建,减少启动时间。
- 统一管理:所有数据源在
close()方法中统一关闭,避免资源泄漏。
第四章 四大 SPI 接口实现模式
Keycloak 提供了数十种 SPI 类型,每种类型的 Factory 接口都有其特定的方法签名和约定。本章将深入分析 keycloak-sandbox 项目中实现的 6 种 SPI 接口模式,帮助读者理解不同 SPI 类型的实现差异。
4.1 EventListenerProviderFactory 模式
事件监听器是 Keycloak 中最常用的 SPI 类型之一。它允许你在用户登录、登出、注册、修改密码等事件发生时执行自定义逻辑。
4.1.1 接口定义
java
// Keycloak EventListenerProviderFactory 接口(简化版)
public interface EventListenerProviderFactory
extends ProviderFactory<EventListenerProvider> {
// 继承自 ProviderFactory 的方法:
// String getId();
// EventListenerProvider create(KeycloakSession session);
// void init(Config.Scope config);
// void postInit(KeycloakSessionFactory factory);
// void close();
// EventListenerProviderFactory 特有的方法:
List<ProviderConfigProperty> getConfigProperties();
}4.1.2 keycloak-sandbox 实现
java
// 教学示例: 审计事件监听器工厂
// 基于 keycloak-sandbox 的 AuditEventListenerProviderFactory 简化
package cc.bima.keycloak.extension.event;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AuditEventListenerProviderFactory
implements EventListenerProviderFactory {
private static final Logger logger =
LoggerFactory.getLogger(AuditEventListenerProviderFactory.class);
// ★ Factory 的唯一标识符
// 管理控制台中通过这个 ID 来引用此 Provider
public static final String PROVIDER_ID = "bima-audit-event-listener";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public EventListenerProvider create(KeycloakSession session) {
// 创建 Provider 实例
// 注意: EventListenerProviderFactory 的 create 方法
// 只接收 session 参数,不接收 ComponentModel
return new AuditEventListenerProvider(session);
}
@Override
public void init(Config.Scope config) {
logger.info("Initializing AuditEventListenerProviderFactory");
// 读取全局配置
boolean enabled = config.getBoolean("enabled", true);
logger.info("Audit event listener enabled: {}", enabled);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
logger.info("Closing AuditEventListenerProviderFactory");
// 释放全局资源
}
}4.1.3 事件监听器的注册文件
# META-INF/services/org.keycloak.events.EventListenerProviderFactory
cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory4.1.4 EventListenerProvider 的典型事件类型
┌─────────────────────────────────────────────────────────────┐
│ Keycloak 事件类型 │
├──────────────────┬──────────────────────────────────────────┤
│ 事件类型 │ 说明 │
├──────────────────┼──────────────────────────────────────────┤
│ LOGIN │ 用户登录 │
│ LOGIN_ERROR │ 登录失败 │
│ REGISTER │ 用户注册 │
│ REGISTER_ERROR │ 注册失败 │
│ LOGOUT │ 用户登出 │
│ UPDATE_PASSWORD │ 修改密码 │
│ UPDATE_EMAIL │ 修改邮箱 │
│ UPDATE_PROFILE │ 修改个人资料 │
│ CLIENT_LOGIN │ 客户端登录 │
│ CLIENT_REGISTER │ 客户端注册 │
│ TOKEN_ISSUED │ 令牌签发 │
│ TOKEN_REFRESHED │ 令牌刷新 │
│ FEDERATED_IDENTITY_LINK │ 联合身份链接 │
│ REVOKE_GRANT │ 撤销授权 │
│ CODE_TO_TOKEN │ 授权码换令牌 │
│ INTROSPECT_TOKEN │ 令牌内省 │
└──────────────────┴──────────────────────────────────────────┘4.2 SignatureProviderFactory 模式
签名算法 Provider 用于对 JWT 令牌进行签名和验签。keycloak-sandbox 通过 SMSignatureProviderFactory 实现了国密 SM2 签名算法。
4.2.1 接口定义
java
// Keycloak SignatureProviderFactory 接口(简化版)
public interface SignatureProviderFactory
extends ProviderFactory<SignatureProvider> {
String getId();
SignatureProvider create(KeycloakSession session);
boolean isAsymmetric();
}4.2.2 keycloak-sandbox 实现
java
// 教学示例: 国密 SM2 签名算法工厂
// 基于 keycloak-sandbox 的 SMSignatureProviderFactory 简化
package cc.bima.keycloak.extension.crypto.signature;
import org.keycloak.Config;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class SMSignatureProviderFactory implements SignatureProviderFactory {
public static final String PROVIDER_ID = "bima-sm-signature";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public SignatureProvider create(KeycloakSession session) {
return new SMSignatureProvider(session);
}
@Override
public boolean isAsymmetric() {
// SM2 是非对称签名算法
return true;
}
@Override
public void init(Config.Scope config) {
// 初始化 SM2 算法参数
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
// 释放资源
}
}4.2.3 签名算法的注册文件
# META-INF/services/org.keycloak.crypto.SignatureProviderFactory
cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory4.2.4 SignatureProviderFactory 与内置算法的关系
Keycloak 内置了多种签名算法,SM2 签名作为自定义扩展与它们并列:
┌─────────────────────────────────────────────────────────────┐
│ 签名算法 Provider 注册表 │
├──────────────┬──────────────┬───────────────────────────────┤
│ ID │ 类型 │ 来源 │
├──────────────┼──────────────┼───────────────────────────────┤
│ RS256 │ 非对称 RSA │ Keycloak 内置 │
│ RS384 │ 非对称 RSA │ Keycloak 内置 │
│ RS512 │ 非对称 RSA │ Keycloak 内置 │
│ ES256 │ 非对称 EC │ Keycloak 内置 │
│ ES384 │ 非对称 EC │ Keycloak 内置 │
│ ES512 │ 非对称 EC │ Keycloak 内置 │
│ PS256 │ 非对称 RSA │ Keycloak 内置 │
│ PS384 │ 非对称 RSA │ Keycloak 内置 │
│ PS512 │ 非对称 RSA │ Keycloak 内置 │
│ bima-sm-signature │ 非对称 SM2 │ keycloak-sandbox 扩展 │
└──────────────┴──────────────┴───────────────────────────────┘4.3 HashProviderFactory 模式
哈希算法 Provider 用于密码哈希和验证。keycloak-sandbox 通过 SMHashProviderFactory 实现了国密 SM3 哈希算法。
4.3.1 接口定义
java
// Keycloak HashProviderFactory 接口(简化版)
public interface HashProviderFactory
extends ProviderFactory<HashProvider> {
String getId();
HashProvider create(KeycloakSession session);
// HashProvider 接口方法:
// String hash(String input);
// boolean verify(String hash, String input);
}4.3.2 keycloak-sandbox 实现
java
// 教学示例: 国密 SM3 哈希算法工厂
// 基于 keycloak-sandbox 的 SMHashProviderFactory 简化
package cc.bima.keycloak.extension.crypto.hash;
import org.keycloak.Config;
import org.keycloak.crypto.hash.HashProvider;
import org.keycloak.crypto.hash.HashProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class SMHashProviderFactory implements HashProviderFactory {
public static final String PROVIDER_ID = "bima-sm-hash";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public HashProvider create(KeycloakSession session) {
return new SMHashProvider(session);
}
@Override
public void init(Config.Scope config) {
// 初始化 SM3 算法参数
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
// 释放资源
}
}4.3.3 哈希算法的注册文件
# META-INF/services/org.keycloak.crypto.HashProviderFactory
cc.bima.keycloak.extension.crypto.hash.SMHashProviderFactory4.4 UserStorageProviderFactory 模式
用户存储 Provider 是 Keycloak 中最复杂的 SPI 类型之一。它允许 Keycloak 从外部数据源(如关系数据库、LDAP、NoSQL 等)读取和验证用户信息。keycloak-sandbox 的 CustomUserStorageProviderFactory 支持多种国产数据库。
4.4.1 接口定义
java
// Keycloak UserStorageProviderFactory 接口(简化版)
public interface UserStorageProviderFactory<T extends UserStorageProvider>
extends ProviderFactory<T> {
// 继承自 ProviderFactory 的方法
String getId();
T create(KeycloakSession session, ComponentModel model);
void init(Config.Scope config);
void postInit(KeycloakSessionFactory factory);
void close();
// UserStorageProviderFactory 特有的方法
List<ProviderConfigProperty> getConfigProperties();
// 以下方法用于控制用户存储的行为
default List<String> getUsableLocales() { return Collections.emptyList(); }
default void onCache(RealmModel realm,
CachedUserModel user,
UserModel delegate) {}
default void onInvalidation(RealmModel realm,
CachedUserModel user) {}
}4.4.2 keycloak-sandbox 实现
keycloak-sandbox 的 CustomUserStorageProviderFactory 是项目中实现最复杂的 Factory,它支持 7 种数据库方言,管理共享的连接池,并定义了 8 个配置属性。
java
// 教学示例: 自定义用户存储工厂(简化版)
// 基于 keycloak-sandbox 的 CustomUserStorageProviderFactory 简化
package cc.bima.keycloak.extension.storage;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProviderFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CustomUserStorageProviderFactory
implements UserStorageProviderFactory<CustomUserStorageProvider> {
private static final Logger logger =
LoggerFactory.getLogger(CustomUserStorageProviderFactory.class);
public static final String PROVIDER_ID = "bima-spi-user-storage-extension";
// ★ 共享数据源映射 - Factory 单例级别的资源
private final Map<String, HikariDataSource> dataSourceMap =
new ConcurrentHashMap<>();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public CustomUserStorageProvider create(
KeycloakSession session, ComponentModel model) {
return new CustomUserStorageProvider(session, model, this);
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("dbType")
.label("Database Type")
.helpText("Select database type")
.type(ProviderConfigProperty.LIST_TYPE)
.options("mysql", "sqlserver", "oracle",
"dameng", "kingbase", "oceanbase", "gaussdb")
.defaultValue("mysql")
.add()
.property()
.name("connectionUrl")
.label("Connection URL")
.helpText("Database connection URL")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("username")
.label("Username")
.helpText("Database username")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("password")
.label("Password")
.helpText("Database password")
.type(ProviderConfigProperty.PASSWORD)
.add()
.property()
.name("userTable")
.label("User Table")
.helpText("User table name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("users")
.add()
.property()
.name("usernameColumn")
.label("Username Column")
.helpText("Username column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("username")
.add()
.property()
.name("passwordColumn")
.label("Password Column")
.helpText("Password column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("password")
.add()
.property()
.name("emailColumn")
.label("Email Column")
.helpText("Email column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("email")
.add()
.build();
}
@Override
public void init(Config.Scope config) {
// 全局初始化
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
for (HikariDataSource dataSource : dataSourceMap.values()) {
try {
dataSource.close();
logger.info("DataSource closed successfully");
} catch (Exception e) {
logger.error("Error closing DataSource: {}",
e.getMessage(), e);
}
}
dataSourceMap.clear();
}
/**
* 获取或创建数据源(懒加载 + 缓存)
*/
public HikariDataSource getDataSource(ComponentModel model) {
String connectionUrl = model.getConfig().getFirst("connectionUrl");
if (connectionUrl == null) {
throw new IllegalArgumentException(
"Missing connectionUrl configuration");
}
return dataSourceMap.computeIfAbsent(connectionUrl, url -> {
String dbType = model.getConfig().getFirst("dbType");
String username = model.getConfig().getFirst("username");
String password = model.getConfig().getFirst("password");
// 验证必要参数
if (dbType == null || username == null || password == null) {
throw new IllegalArgumentException(
"Missing required configuration parameters");
}
// 根据数据库类型获取方言
DatabaseDialect dialect =
DatabaseDialectFactory.getDialect(dbType);
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(dialect.getDriverClassName());
config.setMaximumPoolSize(10);
config.setMinimumIdle(5);
config.setIdleTimeout(30000);
config.setMaxLifetime(1800000);
config.setConnectionTimeout(30000);
return new HikariDataSource(config);
});
}
}4.4.3 用户存储的注册文件
# META-INF/services/org.keycloak.storage.UserStorageProviderFactory
cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory4.4.4 UserStorageProvider 的接口组合
CustomUserStorageProvider 实现了多个接口,提供了完整的用户管理能力:
java
// 来自 keycloak-sandbox 项目的实际代码
public class CustomUserStorageProvider
implements UserStorageProvider, // 基础接口
UserLookupProvider, // 用户查找
UserQueryProvider, // 用户查询
CredentialInputValidator // 凭证验证
{
// UserLookupProvider 方法:
UserModel getUserById(RealmModel realm, String id);
UserModel getUserByUsername(RealmModel realm, String username);
UserModel getUserByEmail(RealmModel realm, String email);
// UserQueryProvider 方法:
int getUsersCount(RealmModel realm);
Stream<UserModel> searchForUserStream(RealmModel realm,
String search, Integer firstResult, Integer maxResults);
Stream<UserModel> searchForUserStream(RealmModel realm,
Map<String, String> params, Integer firstResult, Integer maxResults);
// CredentialInputValidator 方法:
boolean supportsCredentialType(String credentialType);
boolean isConfiguredFor(RealmModel realm, UserModel user,
String credentialType);
boolean isValid(RealmModel realm, UserModel user,
CredentialInput input);
}┌─────────────────────────────────────────────────────────────────┐
│ UserStorageProvider 接口组合 │
│ │
│ CustomUserStorageProvider │
│ ├── UserStorageProvider (基础接口) │
│ │ └── close() - 释放会话级资源 │
│ │ │
│ ├── UserLookupProvider (用户查找) │
│ │ ├── getUserById() - 按ID查找 │
│ │ ├── getUserByUsername() - 按用户名查找 │
│ │ └── getUserByEmail() - 按邮箱查找 │
│ │ │
│ ├── UserQueryProvider (用户查询) │
│ │ ├── getUsersCount() - 获取用户总数 │
│ │ ├── searchForUserStream() - 按关键字搜索 │
│ │ └── searchForUserByUserAttributeStream() - 按属性搜索 │
│ │ │
│ └── CredentialInputValidator (凭证验证) │
│ ├── supportsCredentialType() - 是否支持凭证类型 │
│ ├── isConfiguredFor() - 是否已配置凭证 │
│ └── isValid() - 验证凭证有效性 │
│ │
│ ★ 可选接口(按需实现): │
│ ├── UserRegistrationProvider - 用户注册 │
│ ├── UserRoleMapperProvider - 角色映射 │
│ ├── GroupLookupProvider - 组查找 │
│ └── ImportedUserValidation - 导入用户验证 │
└─────────────────────────────────────────────────────────────────┘4.5 KeyProviderFactory 模式
密钥 Provider 用于管理加密密钥。keycloak-sandbox 通过 SMKeyProviderFactory 实现了国密密钥的生成和管理。
4.5.1 接口定义
java
// Keycloak KeyProviderFactory 接口(简化版)
public interface KeyProviderFactory
extends ConfiguredProviderFactory<KeyProvider> {
String getId();
KeyProvider create(KeycloakSession session, ComponentModel model);
List<ProviderConfigProperty> getConfigProperties();
void init(Config.Scope config);
void postInit(KeycloakSessionFactory factory);
void close();
}4.5.2 keycloak-sandbox 实现
java
// 教学示例: 国密密钥提供者工厂
// 基于 keycloak-sandbox 的 SMKeyProviderFactory 简化
package cc.bima.keycloak.extension.crypto.keys;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.keys.KeyProvider;
import org.keycloak.keys.KeyProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class SMKeyProviderFactory implements KeyProviderFactory {
public static final String PROVIDER_ID = "sm-generated";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public KeyProvider create(KeycloakSession session,
ComponentModel model) {
return new SMKeyProvider(session, model);
}
@Override
public void init(Config.Scope config) {
// 初始化 SM2/SM4 密钥生成参数
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
// 释放密钥资源
}
}4.5.3 密钥 Provider 的注册文件
# META-INF/services/org.keycloak.keys.KeyProviderFactory
cc.bima.keycloak.extension.crypto.keys.SMKeyProviderFactory4.6 ContentEncryptionProviderFactory 模式
内容加密 Provider 用于加密 JWT 令牌的内容。keycloak-sandbox 通过 SMContentEncryptionProviderFactory 实现了国密 SM4 内容加密。
4.6.1 接口定义
java
// Keycloak ContentEncryptionProviderFactory 接口(简化版)
public interface ContentEncryptionProviderFactory
extends ProviderFactory<ContentEncryptionProvider> {
String getId();
ContentEncryptionProvider create(KeycloakSession session);
boolean isAsymmetric();
}4.6.2 keycloak-sandbox 实现
java
// 教学示例: 国密 SM4 内容加密工厂
// 基于 keycloak-sandbox 的 SMContentEncryptionProviderFactory 简化
package cc.bima.keycloak.extension.crypto.encryption;
import org.keycloak.Config;
import org.keycloak.crypto.ContentEncryptionProvider;
import org.keycloak.crypto.ContentEncryptionProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class SMContentEncryptionProviderFactory
implements ContentEncryptionProviderFactory {
public static final String PROVIDER_ID = "bima-sm-content-encryption";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ContentEncryptionProvider create(KeycloakSession session) {
return new SMContentEncryptionProvider(session);
}
@Override
public boolean isAsymmetric() {
// SM4 是对称加密算法
return false;
}
@Override
public void init(Config.Scope config) {
// 初始化 SM4 算法参数
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后置初始化
}
@Override
public void close() {
// 释放资源
}
}4.6.3 内容加密的注册文件
# META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactory
cc.bima.keycloak.extension.crypto.encryption.SMContentEncryptionProviderFactory4.6.4 六种 SPI 实现模式对比
┌──────────────────────────────────────────────────────────────────┐
│ 六种 SPI 实现模式对比 │
│ │
│ ┌────────────────────┬────────────┬──────────┬───────────────┐ │
│ │ SPI 类型 │ Factory ID │ create签名│ 是否需要Config│ │
│ ├────────────────────┼────────────┼──────────┼───────────────┤ │
│ │ EventListener │ bima-audit │ (session)│ 可选 │ │
│ │ Signature │ bima-sm- │ (session)│ 可选 │ │
│ │ │ signature │ │ │ │
│ │ Hash │ bima-sm- │ (session)│ 可选 │ │
│ │ │ hash │ │ │ │
│ │ UserStorage │ bima-spi- │ (session,│ 必须 │ │
│ │ │ user- │ model) │ │ │
│ │ │ storage │ │ │ │
│ │ KeyProvider │ sm- │ (session,│ 必须 │ │
│ │ │ generated │ model) │ │ │
│ │ ContentEncryption │ bima-sm- │ (session)│ 可选 │ │
│ │ │ content- │ │ │ │
│ │ │ encryption │ │ │ │
│ └────────────────────┴────────────┴──────────┴───────────────┘ │
│ │
│ ★ create(session) - 简单 Provider,不需要组件级配置 │
│ ★ create(session, model) - 需要组件级配置的 Provider │
│ ★ Config - 通过 getConfigProperties() 声明配置元数据 │
└──────────────────────────────────────────────────────────────────┘第五章 ProviderConfigurationBuilder 配置元数据
ProviderConfigurationBuilder 是 Keycloak 提供的声明式配置框架。通过它,Factory 可以定义配置属性的类型、标签、帮助文本、默认值和选项列表,Keycloak 管理控制台会根据这些元数据自动生成配置界面。
5.1 配置属性定义
5.1.1 ProviderConfigurationBuilder 的链式 API
ProviderConfigurationBuilder 采用链式调用风格(Fluent API),让配置定义既简洁又易读:
java
// ProviderConfigurationBuilder 链式调用结构
ProviderConfigurationBuilder.create()
.property() // 开始定义一个属性
.name("propertyName") // 属性名(必填)
.label("Property Label") // 显示标签(必填)
.helpText("Help text") // 帮助文本
.type(ProviderConfigProperty.STRING_TYPE) // 属性类型
.defaultValue("default") // 默认值
.options("opt1", "opt2") // 选项列表(LIST_TYPE)
.add() // 完成当前属性定义
.property() // 开始定义下一个属性
.name("anotherProperty")
// ...
.add()
.build(); // 构建配置列表5.1.2 支持的属性类型
┌─────────────────────────────────────────────────────────────┐
│ ProviderConfigProperty 属性类型 │
├──────────────────────────────┬──────────────────────────────┤
│ 类型常量 │ 说明 │
├──────────────────────────────┼──────────────────────────────┤
│ STRING_TYPE │ 字符串输入框 │
│ BOOLEAN_TYPE │ 布尔开关 │
│ INT_TYPE │ 整数输入框 │
│ LIST_TYPE │ 下拉选择列表 │
│ PASSWORD │ 密码输入框(输入内容隐藏) │
│ TEXT_AREA │ 多行文本输入框 │
│ SCRIPT_TYPE │ 脚本编辑器 │
│ FILE_TYPE │ 文件上传 │
│ GROUP_TYPE │ 配置分组 │
└──────────────────────────────┴──────────────────────────────┘5.1.3 keycloak-sandbox 中的完整配置定义
以下是 keycloak-sandbox 项目中 CustomUserStorageProviderFactory 的完整配置定义,展示了 8 个配置属性的定义方式:
java
// 来自 keycloak-sandbox 项目的实际代码
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
// 属性 1: 数据库类型(下拉列表)
.property()
.name("dbType")
.label("Database Type")
.helpText("Select database type")
.type(ProviderConfigProperty.LIST_TYPE)
.options("mysql", "sqlserver", "oracle",
"dameng", "kingbase", "oceanbase", "gaussdb")
.defaultValue("mysql")
.add()
// 属性 2: 连接 URL(字符串输入框)
.property()
.name("connectionUrl")
.label("Connection URL")
.helpText("Database connection URL")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
// 属性 3: 用户名(字符串输入框)
.property()
.name("username")
.label("Username")
.helpText("Database username")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
// 属性 4: 密码(密码输入框)
.property()
.name("password")
.label("Password")
.helpText("Database password")
.type(ProviderConfigProperty.PASSWORD)
.add()
// 属性 5: 用户表名(字符串输入框,有默认值)
.property()
.name("userTable")
.label("User Table")
.helpText("User table name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("users")
.add()
// 属性 6: 用户名列名(字符串输入框,有默认值)
.property()
.name("usernameColumn")
.label("Username Column")
.helpText("Username column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("username")
.add()
// 属性 7: 密码列名(字符串输入框,有默认值)
.property()
.name("passwordColumn")
.label("Password Column")
.helpText("Password column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("password")
.add()
// 属性 8: 邮箱列名(字符串输入框,有默认值)
.property()
.name("emailColumn")
.label("Email Column")
.helpText("Email column name")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("email")
.add()
.build();
}5.2 管理控制台配置项生成
当 Factory 通过 getConfigProperties() 声明了配置元数据后,Keycloak 管理控制台会自动生成对应的配置界面。
5.2.1 配置界面生成流程
┌─────────────────────────────────────────────────────────────────┐
│ 管理控制台配置界面生成流程 │
│ │
│ 1. 管理员访问 Realm Settings → User Federation │
│ │ │
│ ▼ │
│ 2. Keycloak 列出所有已注册的 UserStorageProviderFactory │
│ │ │
│ ├─► [Select Provider] 下拉列表 │
│ │ ├── LDAP Provider │
│ │ ├── bima-spi-user-storage-extension ← 我们的扩展 │
│ │ └── ... │
│ │ │
│ ▼ │
│ 3. 管理员选择 "bima-spi-user-storage-extension" │
│ │ │
│ ▼ │
│ 4. Keycloak 调用 factory.getConfigProperties() │
│ │ │
│ ▼ │
│ 5. 管理控制台根据配置元数据渲染表单: │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Database Type: [mysql ▼] │ │
│ │ Connection URL: [jdbc:mysql://...] │ │
│ │ Username: [root] │ │
│ │ Password: [********] │ │
│ │ User Table: [users] │ │
│ │ Username Column: [username] │ │
│ │ Password Column: [password] │ │
│ │ Email Column: [email] │ │
│ │ │ │
│ │ [Save] [Cancel] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ★ 每个配置属性的类型决定了表单控件的类型 │
│ ★ LIST_TYPE → 下拉列表,自动填充 options │
│ ★ PASSWORD → 密码输入框,内容自动隐藏 │
│ ★ STRING_TYPE → 普通文本输入框 │
│ ★ defaultValue → 自动填充默认值 │
└─────────────────────────────────────────────────────────────────┘5.2.2 配置属性与表单控件的映射
┌─────────────────────────────────────────────────────────────────┐
│ 配置属性 → 表单控件映射 │
│ │
│ ProviderConfigProperty 类型 → HTML 表单控件 │
│ ───────────────────────────────────────────────── │
│ STRING_TYPE → <input type="text"> │
│ BOOLEAN_TYPE → <input type="checkbox"> │
│ INT_TYPE → <input type="number"> │
│ LIST_TYPE → <select> + <option> │
│ PASSWORD → <input type="password"> │
│ TEXT_AREA → <textarea> │
│ │
│ 元数据字段 → 控件属性 │
│ ───────────────────────────────────────────── │
│ label → <label> 文本 │
│ helpText → tooltip / 帮助图标 │
│ defaultValue → value 属性 │
│ options() → <option> 列表 │
└─────────────────────────────────────────────────────────────────┘5.3 运行时配置读取
当管理员在控制台中保存配置后,这些配置会通过 ComponentModel 对象传递给 Factory 的 create 方法。
5.3.1 ComponentModel 的配置读取
java
// 来自 keycloak-sandbox 项目的实际代码
// 在 Provider 中通过 ComponentModel 读取配置
public class CustomUserStorageProvider implements UserStorageProvider {
private final ComponentModel model;
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
// 从 ComponentModel 中读取配置
String userTable = model.getConfig().getFirst("userTable");
String usernameColumn = model.getConfig().getFirst("usernameColumn");
String emailColumn = model.getConfig().getFirst("emailColumn");
// 使用配置构建 SQL 查询
String sql = "SELECT * FROM " + userTable
+ " WHERE " + usernameColumn + " = ?";
// 执行查询...
}
}5.3.2 配置读取 API
ComponentModel 提供了多种配置读取方法:
java
// ComponentModel 配置读取 API
ComponentModel model = ...;
// 获取单个值
String value = model.getConfig().getFirst("propertyName");
// 获取多个值(多选列表)
List<String> values = model.getConfig().get("propertyName");
// 检查配置是否存在
boolean hasProperty = model.getConfig().containsKey("propertyName");
// 获取所有配置项
Map<String, List<String>> allConfig = model.getConfig().getConfigMap();5.4 配置验证与默认值
5.4.1 默认值处理
在 keycloak-sandbox 项目中,部分配置属性定义了默认值:
┌─────────────────────────────────────────────────────────────┐
│ 配置属性默认值 │
├──────────────────┬──────────────┬───────────────────────────┤
│ 属性名 │ 默认值 │ 说明 │
├──────────────────┼──────────────┼───────────────────────────┤
│ dbType │ mysql │ 默认使用 MySQL │
│ userTable │ users │ 默认表名 │
│ usernameColumn │ username │ 默认用户名列名 │
│ passwordColumn │ password │ 默认密码列名 │
│ emailColumn │ email │ 默认邮箱列名 │
│ connectionUrl │ (无) │ 必须手动配置 │
│ username │ (无) │ 必须手动配置 │
│ password │ (无) │ 必须手动配置 │
└──────────────────┴──────────────┴───────────────────────────┘5.4.2 运行时配置验证
在 getDataSource 方法中,keycloak-sandbox 实现了运行时配置验证:
java
// 来自 keycloak-sandbox 项目的实际代码
public HikariDataSource getDataSource(ComponentModel model) {
String connectionUrl = model.getConfig().getFirst("connectionUrl");
if (connectionUrl == null) {
throw new IllegalArgumentException(
"Missing connectionUrl configuration");
}
return dataSourceMap.computeIfAbsent(connectionUrl, url -> {
String dbType = model.getConfig().getFirst("dbType");
String username = model.getConfig().getFirst("username");
String password = model.getConfig().getFirst("password");
// ★ 验证必要配置参数
if (dbType == null || username == null || password == null) {
String errorMessage = "Missing required configuration parameters: "
+ (dbType == null ? "dbType " : "")
+ (username == null ? "username " : "")
+ (password == null ? "password " : "");
logger.error(errorMessage);
throw new IllegalArgumentException(errorMessage);
}
// 验证通过,创建数据源...
});
}5.4.3 配置验证的最佳实践
┌─────────────────────────────────────────────────────────────┐
│ 配置验证最佳实践 │
├─────────────────────────────────────────────────────────────┤
│ 1. 区分必填和可选配置 │
│ - 必填配置: connectionUrl, username, password │
│ - 可选配置: userTable, usernameColumn, emailColumn │
│ - 有默认值: dbType (mysql), userTable (users) │
│ │
│ 2. 在合适的位置进行验证 │
│ - getConfigProperties() 中声明配置类型和约束 │
│ - create() 或业务方法中进行运行时验证 │
│ - 不要在 init() 中验证(init 时没有 ComponentModel) │
│ │
│ 3. 提供有意义的错误信息 │
│ - 明确指出缺少哪些配置参数 │
│ - 使用日志记录验证失败的原因 │
│ - 抛出 IllegalArgumentException 而非 NullPointerException│
│ │
│ 4. 考虑配置变更的影响 │
│ - 已创建的 Provider 实例不受配置变更影响 │
│ - 新的 Provider 实例会使用最新配置 │
│ - 共享资源(如连接池)需要考虑缓存失效 │
└─────────────────────────────────────────────────────────────┘第六章 @AutoService 自动生成服务文件
手动维护 META-INF/services 文件虽然简单,但容易出错——类名拼写错误、文件名不正确、忘记创建文件等问题时有发生。Google 的 AutoService 库通过注解处理器在编译时自动生成服务文件,从根本上解决了这些问题。
6.1 AutoService 注解原理
6.1.1 AutoService 的工作机制
@AutoService 是一个编译时注解处理器(Annotation Processor),它在 Java 编译阶段自动生成 META-INF/services 文件。
┌─────────────────────────────────────────────────────────────────┐
│ @AutoService 工作流程 │
│ │
│ 1. 编写 Factory 类 │
│ │ │
│ │ @AutoService(EventListenerProviderFactory.class) │
│ │ public class AuditEventListenerProviderFactory │
│ │ implements EventListenerProviderFactory { │
│ │ // ... │
│ │ } │
│ │ │
│ ▼ │
│ 2. javac 编译 │
│ │ │
│ ├─► 词法分析 → 语法分析 → 语义分析 │
│ │ │
│ ├─► 发现 @AutoService 注解 │
│ │ │
│ ├─► 触发 AutoServiceProcessor │
│ │ ├── 读取注解值: EventListenerProviderFactory.class │
│ │ ├── 获取当前类的全限定名: │
│ │ │ cc.bima.keycloak.extension.event │
│ │ │ .AuditEventListenerProviderFactory │
│ │ └── 生成文件内容 │
│ │ │
│ ▼ │
│ 3. 自动生成 META-INF/services 文件 │
│ │ │
│ │ 文件路径: │
│ │ target/classes/META-INF/services/ │
│ │ org.keycloak.events.EventListenerProviderFactory │
│ │ │
│ │ 文件内容: │
│ │ cc.bima.keycloak.extension.event │
│ │ .AuditEventListenerProviderFactory │
│ │ │
│ ▼ │
│ 4. 打包到 JAR │
│ │ │
│ └─► JAR 包中包含自动生成的 META-INF/services 文件 │
│ ServiceLoader 可以正常发现和加载 │
└─────────────────────────────────────────────────────────────────┘6.1.2 AutoService 的 Maven 配置
在 keycloak-sandbox 项目的父 POM 中,AutoService 被声明为 provided 依赖:
xml
<!-- keycloak-sandbox 父 POM -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
</dependencies>
</dependencyManagement>在子模块中引用:
xml
<!-- spi-user-storage-extension POM -->
<dependencies>
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>关键点:
scope=provided:AutoService 只在编译时需要,不会被打包到最终的 JAR 中。optional=true:不会传递依赖给其他模块。
6.2 在 Keycloak SPI 中的应用
6.2.1 使用 @AutoService 注解的 Factory
java
// 使用 @AutoService 自动生成服务文件
package cc.bima.keycloak.extension.event;
import com.google.auto.service.AutoService;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
@AutoService(EventListenerProviderFactory.class)
public class AuditEventListenerProviderFactory
implements EventListenerProviderFactory {
public static final String PROVIDER_ID = "bima-audit-event-listener";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public EventListenerProvider create(KeycloakSession session) {
return new AuditEventListenerProvider(session);
}
// init, postInit, close 方法...
}使用 @AutoService(EventListenerProviderFactory.class) 注解后,编译时会自动生成以下文件:
# 自动生成的文件:
# target/classes/META-INF/services/org.keycloak.events.EventListenerProviderFactory
cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory6.2.2 keycloak-sandbox 中各模块的 @AutoService 使用
keycloak-sandbox 项目的 spi-sm-crypto-extension 模块中,4 个 Factory 类都使用了 @AutoService 注解:
java
// 1. SM2 签名工厂
@AutoService(SignatureProviderFactory.class)
public class SMSignatureProviderFactory implements SignatureProviderFactory {
// 自动生成: META-INF/services/org.keycloak.crypto.SignatureProviderFactory
}
// 2. SM3 哈希工厂
@AutoService(HashProviderFactory.class)
public class SMHashProviderFactory implements HashProviderFactory {
// 自动生成: META-INF/services/org.keycloak.crypto.HashProviderFactory
}
// 3. SM4 内容加密工厂
@AutoService(ContentEncryptionProviderFactory.class)
public class SMContentEncryptionProviderFactory
implements ContentEncryptionProviderFactory {
// 自动生成: META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactory
}
// 4. SM 密钥工厂
@AutoService(KeyProviderFactory.class)
public class SMKeyProviderFactory implements KeyProviderFactory {
// 自动生成: META-INF/services/org.keycloak.keys.KeyProviderFactory
}6.3 编译时生成 vs 手动维护
6.3.1 两种方式对比
┌──────────────────────────────────────────────────────────────────┐
│ @AutoService vs 手动维护对比 │
│ │
│ ┌──────────────────┬──────────────────┬────────────────────────┐ │
│ │ 维度 │ @AutoService │ 手动维护 │ │
│ ├──────────────────┼──────────────────┼────────────────────────┤ │
│ │ 文件创建 │ 编译时自动生成 │ 手动创建 │ │
│ │ 类名变更 │ 自动同步 │ 需要手动更新 │ │
│ │ 拼写错误风险 │ 无 │ 有(常见错误来源) │ │
│ │ 遗漏风险 │ 无 │ 容易忘记创建文件 │ │
│ │ 编译时检查 │ 有 │ 无 │ │
│ │ 额外依赖 │ auto-service JAR │ 无 │ │
│ │ 构建复杂度 │ 略增(注解处理) │ 简单 │ │
│ │ IDE 支持 │ 良好 │ 无特殊支持 │ │
│ │ 调试难度 │ 较高 │ 低(直接看文件) │ │
│ └──────────────────┴──────────────────┴────────────────────────┘ │
│ │
│ 推荐策略: │
│ ★ 新项目: 优先使用 @AutoService │
│ ★ 已有项目: 可以逐步迁移到 @AutoService │
│ ★ 调试阶段: 可以同时保留手动文件作为参考 │
└──────────────────────────────────────────────────────────────────┘6.3.2 混合使用的注意事项
keycloak-sandbox 项目中存在一种情况:spi-user-storage-extension 模块同时存在手动创建的服务文件和 @AutoService 依赖。这种情况下需要注意:
- 不要重复注册:如果使用了
@AutoService,就不要手动创建同名文件,否则会导致同一个 Factory 被注册两次。 - 编译产物检查:编译后检查
target/classes/META-INF/services/目录,确认文件内容正确。 - 版本兼容:确保
auto-service版本与 JDK 版本兼容。keycloak-sandbox 使用的是auto-service 1.0.1,配合 JDK 17 使用。
6.3.3 AutoService 生成的文件格式
AutoService 生成的服务文件格式与手动创建的完全一致:
# AutoService 生成的文件内容示例
# 文件: META-INF/services/org.keycloak.crypto.SignatureProviderFactory
cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory文件末尾会自动添加一个换行符,符合 Java SPI 的规范要求。
第七章 SPI 加载流程与调试技巧
理解 Keycloak 的 SPI 加载流程和掌握调试技巧,是解决 SPI 扩展"静默失败"问题的关键。本章将从 Keycloak 启动时的 SPI 扫描开始,逐步分析 Provider 的加载顺序、优先级机制,以及常见问题的排查方法。
7.1 Keycloak 启动时的 SPI 扫描
7.1.1 SPI 扫描的完整流程
Keycloak 启动时,SPI 扫描是整个初始化流程中的重要环节。以下是完整的扫描流程:
┌─────────────────────────────────────────────────────────────────┐
│ Keycloak 启动时的 SPI 扫描流程 │
│ │
│ 1. WildFly/Jakarta EE 容器启动 │
│ │ │
│ ▼ │
│ 2. KeycloakSubsystem 初始化 │
│ │ │
│ ▼ │
│ 3. 加载 providers/ 目录下的所有 JAR 文件 │
│ │ │
│ ├── spi-event-listener-extension.jar │
│ ├── spi-sm-crypto-extension.jar │
│ ├── spi-user-storage-extension.jar │
│ └── ... │
│ │ │
│ ▼ │
│ 4. 为每个 JAR 创建 Module(JBoss Module) │
│ │ │
│ ▼ │
│ 5. DefaultProviderLoader 初始化 │
│ │ │
│ ▼ │
│ 6. 扫描所有 SPI 类型 │
│ │ │
│ ├── EventListenerSPI │
│ │ └── ServiceLoader.load(EventListenerProviderFactory) │
│ │ ├── 发现内置实现 │
│ │ └── 发现 AuditEventListenerProviderFactory │
│ │ │
│ ├── SignatureSPI │
│ │ └── ServiceLoader.load(SignatureProviderFactory) │
│ │ ├── 发现内置实现 (RS256, ES256, ...) │
│ │ └── 发现 SMSignatureProviderFactory │
│ │ │
│ ├── HashSPI │
│ │ └── 发现 SMHashProviderFactory │
│ │ │
│ ├── ContentEncryptionSPI │
│ │ └── 发现 SMContentEncryptionProviderFactory │
│ │ │
│ ├── KeyProviderSPI │
│ │ └── 发现 SMKeyProviderFactory │
│ │ │
│ └── UserStorageSPI │
│ └── 发现 CustomUserStorageProviderFactory │
│ │ │
│ ▼ │
│ 7. 实例化所有 Factory │
│ │ │
│ ▼ │
│ 8. 调用所有 Factory 的 init(Config.Scope) 方法 │
│ │ │
│ ▼ │
│ 9. 调用所有 Factory 的 postInit(KeycloakSessionFactory) 方法 │
│ │ │
│ ▼ │
│ 10. Keycloak 准备就绪,开始接受请求 │
└─────────────────────────────────────────────────────────────────┘7.1.2 ProviderManager 的内部结构
Keycloak 使用 ProviderManager 来管理每个 SPI 类型的所有 Factory 实现:
┌─────────────────────────────────────────────────────────────────┐
│ ProviderManager 内部结构 │
│ │
│ ProviderManager (per SPI type) │
│ │ │
│ ├── spiClass: Class<SPI> │
│ │ (例如: EventListenerProviderFactory.class) │
│ │ │
│ ├── factories: Map<String, ProviderFactory> │
│ │ ├── "bima-audit-event-listener" │
│ │ │ → AuditEventListenerProviderFactory │
│ │ ├── "jboss-logging" │
│ │ │ → JBossLoggingEventListenerProviderFactory │
│ │ └── ... │
│ │ │
│ ├── defaultFactory: ProviderFactory │
│ │ (默认的 Factory 实现) │
│ │ │
│ └── enabled: boolean │
│ (此 SPI 类型是否启用) │
│ │
│ ★ 通过 factory.getId() 作为 Map 的 key │
│ ★ 通过 session.getProvider(Class, id) 查找 Factory │
└─────────────────────────────────────────────────────────────────┘7.2 Provider 加载顺序与优先级
7.2.1 加载顺序
Keycloak 的 Provider 按照以下顺序加载:
┌─────────────────────────────────────────────────────────────────┐
│ Provider 加载顺序(从高到低) │
│ │
│ 优先级 1: Keycloak 内置 Provider │
│ │ ├── keycloak-core.jar 中的实现 │
│ │ └── keycloak-services.jar 中的实现 │
│ │ │
│ 优先级 2: providers/ 目录下的 Provider JAR │
│ │ ├── spi-event-listener-extension.jar │
│ │ ├── spi-sm-crypto-extension.jar │
│ │ └── spi-user-storage-extension.jar │
│ │ │
│ 优先级 3: 部署描述符中指定的 Provider │
│ │ └── standalone.xml / domain.xml 中的配置 │
│ │ │
│ ★ 后加载的 Provider 不会覆盖先加载的(除非 ID 相同) │
│ ★ 可以通过配置禁用特定的 Provider │
└─────────────────────────────────────────────────────────────────┘7.2.2 通过配置控制 Provider
在 standalone.xml 或 standalone-ha.xml 中,可以配置 Provider 的加载和禁用:
xml
<!-- standalone.xml 中的 SPI 配置示例 -->
<subsystem xmlns="urn:jboss:domain:keycloak-server:1.1">
<providers>
<!-- 加载 providers/ 目录下的所有 JAR -->
<provider>
module="org.keycloak.keycloak-server-spi"
</provider>
</providers>
<spi name="eventsListener">
<provider name="bima-audit-event-listener"
enabled="true"/>
<provider name="jboss-logging"
enabled="false"/>
</spi>
<spi name="userStorage">
<provider name="bima-spi-user-storage-extension"
enabled="true"/>
<default-provider>file</default-provider>
</spi>
</subsystem>7.3 SPI 注册失败的常见原因
SPI 扩展最常见的部署问题就是"静默失败"——Keycloak 启动正常,但扩展没有被加载。以下是常见的失败原因和解决方案:
┌──────────────────────────────────────────────────────────────────┐
│ SPI 注册失败常见原因排查表 │
│ │
│ ┌────────────────────┬──────────────────────────────────────────┐ │
│ │ # 问题 │ 排查方法 │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 1 服务文件路径错误 │ 检查 JAR 包中是否存在 │ │
│ │ │ META-INF/services/接口全限定名 文件 │ │
│ │ │ 命令: jar tf your-extension.jar | │ │
│ │ │ grep META-INF/services │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 2 文件名拼写错误 │ 文件名必须是接口的全限定名 │ │
│ │ │ 正确: org.keycloak.events. │ │
│ │ │ EventListenerProviderFactory │ │
│ │ │ 错误: org.keycloak.events. │ │
│ │ │ EventListenerProvider (缺少 Factory) │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 3 实现类名拼写 │ 文件内容必须是实现类的全限定名 │ │
│ │ 错误 │ 正确: cc.bima.keycloak.extension.event. │ │
│ │ │ AuditEventListenerProviderFactory │ │
│ │ │ 错误: cc.bima.keycloak.extension.event. │ │
│ │ │ AuditEventListenerProvider │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 4 JAR 未部署到 │ 确认 JAR 文件已复制到 │ │
│ │ providers/ 目录 │ Keycloak 的 providers/ 目录 │ │
│ │ │ Docker: 确认 volume 挂载正确 │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 5 依赖冲突 │ 检查 SPI 扩展的依赖是否与 Keycloak │ │
│ │ │ 内置依赖版本冲突 │ │
│ │ │ 解决: 使用 provided scope │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 6 类加载器隔离 │ SPI 扩展中的类无法访问 Keycloak 核心类 │ │
│ │ 问题 │ 检查 jboss-deployment-structure.xml │ │
│ │ │ 或 module.xml 配置 │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 7 Factory 类 │ Factory 类必须有无参构造函数 │ │
│ │ 构造函数问题 │ ServiceLoader 通过反射调用无参构造函数 │ │
│ │ │ 如果定义了有参构造函数,加载会失败 │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 8 getId() 返回 │ 两个 Factory 返回相同的 ID │ │
│ │ null 或重复 ID │ 后加载的会覆盖先加载的 │ │
│ │ │ 确保 ID 唯一且不为 null │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 9 编码问题 │ 服务文件编码必须为 UTF-8 │ │
│ │ │ Windows 下可能使用 GBK 编码 │ │
│ │ │ 导致类名解析失败 │ │
│ ├────────────────────┼──────────────────────────────────────────┤ │
│ │ 10 文件末尾缺少 │ 服务文件末尾应有一个换行符 │ │
│ │ 换行符 │ 某些 ServiceLoader 实现要求末尾有换行 │ │
│ └────────────────────┴──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘7.4 调试 SPI 加载问题
7.4.1 检查 JAR 包内容
部署 SPI 扩展后,首先检查 JAR 包中是否包含正确的服务文件:
bash
# 检查 JAR 包中的 META-INF/services 文件
jar tf spi-user-storage-extension.jar | grep META-INF/services
# 预期输出:
# META-INF/services/org.keycloak.storage.UserStorageProviderFactory
# 查看服务文件内容
jar xf spi-user-storage-extension.jar \
META-INF/services/org.keycloak.storage.UserStorageProviderFactory
cat META-INF/services/org.keycloak.storage.UserStorageProviderFactory
# 预期输出:
# cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory7.4.2 启用 Keycloak 调试日志
在 standalone.xml 中启用 DEBUG 级别的日志,可以看到 SPI 加载的详细过程:
xml
<!-- standalone.xml 日志配置 -->
<subsystem xmlns="urn:jboss:domain:logging:3.0">
<console-handler name="CONSOLE"/>
<logger category="org.keycloak">
<level name="DEBUG"/>
</logger>
<logger category="org.keycloak.services">
<level name="DEBUG"/>
</logger>
<logger category="org.keycloak.provider">
<level name="DEBUG"/>
</logger>
</subsystem>启用 DEBUG 日志后,Keycloak 启动时会输出类似以下内容:
DEBUG [org.keycloak.provider.DefaultProviderLoader] Loading providers from /opt/keycloak/providers/
DEBUG [org.keycloak.provider.DefaultProviderLoader] Scanning SPI: eventsListener
DEBUG [org.keycloak.provider.DefaultProviderLoader] Found provider: bima-audit-event-listener (cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory)
DEBUG [org.keycloak.provider.DefaultProviderLoader] Scanning SPI: signature
DEBUG [org.keycloak.provider.DefaultProviderLoader] Found provider: bima-sm-signature (cc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory)
DEBUG [org.keycloak.provider.DefaultProviderLoader] Scanning SPI: hash
DEBUG [org.keycloak.provider.DefaultProviderLoader] Found provider: bima-sm-hash (cc.bima.keycloak.extension.crypto.hash.SMHashProviderFactory)
DEBUG [org.keycloak.provider.DefaultProviderLoader] Scanning SPI: userStorage
DEBUG [org.keycloak.provider.DefaultProviderLoader] Found provider: bima-spi-user-storage-extension (cc.bima.keycloak.extension.storage.CustomUserStorageProviderFactory)7.4.3 通过管理控制台验证
登录 Keycloak 管理控制台,检查 SPI 扩展是否被正确加载:
┌─────────────────────────────────────────────────────────────────┐
│ 管理控制台验证步骤 │
│ │
│ 1. 验证事件监听器 │
│ Realm Settings → Events → Event Listeners │
│ ★ 应该能看到 "bima-audit-event-listener" │
│ │
│ 2. 验证用户存储 │
│ Realm Settings → User Federation │
│ ★ 下拉列表中应该能看到 │
│ "bima-spi-user-storage-extension" │
│ │
│ 3. 验证签名算法 │
│ Realm Settings → Keys → Algorithms │
│ ★ 签名算法列表中应该能看到 SM2 相关选项 │
│ │
│ 4. 验证密钥提供者 │
│ Realm Settings → Keys → Providers │
│ ★ 密钥提供者列表中应该能看到 "sm-generated" │
└─────────────────────────────────────────────────────────────────┘7.4.4 通过 JMX 或 REST API 验证
Keycloak 提供了 Server Info REST API,可以查看所有已注册的 Provider:
bash
# 查看所有已注册的 Provider
curl -s http://localhost:8080/admin/serverinfo \
-H "Authorization: Bearer $TOKEN" | jq '.'
# 查看特定 SPI 类型的 Provider
curl -s http://localhost:8080/admin/serverinfo/providers \
-H "Authorization: Bearer $TOKEN" | \
jq '.userStorage'7.4.5 常见调试场景
场景 1:扩展 JAR 已部署但未被发现
排查步骤:
1. 检查 JAR 文件是否在 providers/ 目录中
ls -la /opt/keycloak/providers/*.jar
2. 检查 JAR 文件权限
chmod 644 /opt/keycloak/providers/your-extension.jar
3. 检查 Keycloak 启动日志中是否有加载 JAR 的记录
grep "Loading providers" server.log
4. 如果使用 Docker,检查 volume 挂载
docker exec keycloak ls -la /opt/keycloak/providers/场景 2:Factory 类被实例化但 init 方法报错
排查步骤:
1. 检查 Keycloak 启动日志中的异常堆栈
grep -A 20 "Exception" server.log | grep -A 20 "ProviderFactory"
2. 常见原因:
- init 方法中访问了尚未初始化的资源
- 依赖的第三方库版本不兼容
- 配置文件格式错误
3. 解决方案:
- 在 init 方法中添加 try-catch,记录详细错误
- 简化 init 方法,延迟资源初始化场景 3:Provider 创建失败
排查步骤:
1. 检查 create 方法是否抛出异常
- 在 create 方法中添加日志
- 检查传入的 ComponentModel 配置是否完整
2. 检查 Provider 类的构造函数
- 确保所有依赖都可以通过构造函数注入
- 确保没有循环依赖
3. 检查管理控制台中的配置
- 确认所有必填配置项都已填写
- 确认配置值格式正确7.4.6 SPI 加载调试清单
┌─────────────────────────────────────────────────────────────────┐
│ SPI 加载调试清单 │
│ │
│ □ JAR 文件是否在 providers/ 目录中? │
│ □ JAR 文件权限是否正确(644)? │
│ □ JAR 中是否包含 META-INF/services/ 目录? │
│ □ 服务文件名是否正确(接口全限定名)? │
│ □ 服务文件内容是否正确(实现类全限定名)? │
│ □ 服务文件编码是否为 UTF-8? │
│ □ Factory 类是否在 JAR 中? │
│ □ Factory 类是否实现了正确的接口? │
│ □ Factory 类是否有无参构造函数? │
│ □ Factory.getId() 是否返回非空且唯一的 ID? │
│ □ Keycloak 启动日志中是否有加载记录? │
│ □ 管理控制台中是否能看到扩展? │
│ □ 依赖版本是否与 Keycloak 兼容? │
│ □ 是否有异常堆栈信息? │
│ □ 是否启用了 DEBUG 日志? │
└─────────────────────────────────────────────────────────────────┘总结与展望
本文从 Java SPI 机制的基础原理出发,系统性地解析了 Keycloak SPI 服务注册的完整链路。让我们回顾一下核心要点:
第一,META-INF/services 是一切的起点。 无论是手动维护还是通过 @AutoService 自动生成,服务文件都是 Keycloak 发现 SPI 扩展的唯一入口。文件名必须是 Factory 接口的全限定名,文件内容必须是 Factory 实现类的全限定名。这个看似简单的约定,是整个 SPI 机制能够工作的基础。
第二,Provider / Factory / SPI 三层模型是 Keycloak 扩展的核心架构。 SPI 定义服务类型,Factory 管理生命周期和共享资源,Provider 提供实际功能。这种分层设计实现了关注点分离:Factory 负责重量级的全局资源管理(如数据库连接池),Provider 负责轻量级的请求处理。理解这三层的职责边界,是编写高质量 SPI 扩展的关键。
第三,ProviderFactory 的生命周期管理是扩展健壮性的保障。 init() → create() → postInit() → close() 的完整生命周期,为扩展开发者提供了灵活的资源管理能力。keycloak-sandbox 项目中的 CustomUserStorageProviderFactory 展示了如何利用 Factory 的单例特性管理共享的数据库连接池,使用 ConcurrentHashMap 和 computeIfAbsent 实现线程安全的懒加载。
第四,ProviderConfigurationBuilder 是连接代码与管理的桥梁。 通过声明式地定义配置元数据,Factory 可以自动获得管理控制台的配置界面。keycloak-sandbox 项目中定义了 8 个配置属性(包括数据库类型、连接 URL、用户名、密码、表名、列名等),展示了从下拉列表到密码输入框的多种配置类型。
第五,@AutoService 是提升开发效率的最佳实践。 自动生成服务文件消除了手动维护的出错风险,特别是在 Factory 类名变更时能够自动同步。keycloak-sandbox 项目使用 auto-service 1.0.1 配合 JDK 17,为所有 SPI 模块提供了编译时服务文件生成能力。
第六,SPI 加载调试需要系统性的排查方法。 从检查 JAR 包内容到启用 DEBUG 日志,从管理控制台验证到 REST API 查询,本文提供了一套完整的调试工具箱。掌握这些调试技巧,可以快速定位和解决 SPI 扩展的"静默失败"问题。
展望未来,Keycloak SPI 机制仍在不断演进。随着 Keycloak 从 Quarkus 迁移和社区版与商业版的分化,SPI 机制也在适应新的运行时环境。对于国内开发者而言,keycloak-sandbox 项目展示的国密算法 SPI 扩展(SM2 签名、SM3 哈希、SM4 加密)和多数据库方言适配(MySQL、Oracle、达梦、人大金仓、OceanBase、GaussDB 等),为国产化适配提供了宝贵的参考实践。
希望本文能够帮助读者深入理解 Keycloak SPI 的服务注册机制,在实际项目中少走弯路,编写出更加健壮、高效的 SPI 扩展。
版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。
本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。
如需获取完整项目代码或技术支持,请访问 bima.cc。