Skip to content

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 正是这种"框架调用插件"的模式。你编写的 EventListenerProviderFactorySignatureProviderFactory 等,都是 Keycloak 在运行时通过 Java SPI 机制发现并调用的。

1.1.4 Java SPI 的局限性

虽然 Java SPI 机制简洁优雅,但它也有一些固有的局限性,这些局限性在 Keycloak 中都得到了不同程度的解决:

  1. 没有依赖注入:标准 Java SPI 只能通过无参构造函数创建实例。Keycloak 通过引入 ProviderFactory 模式解决了这个问题——Factory 负责创建 Provider,可以在创建过程中注入 KeycloakSession 等依赖。

  2. 没有生命周期管理:标准 Java SPI 不管理实例的生命周期。Keycloak 通过 Factory 的 init()postInit()close() 方法实现了完整的生命周期管理。

  3. 没有优先级排序:标准 Java SPI 不支持为多个实现定义优先级。Keycloak 通过 order 属性和 SPI 配置解决了这个问题。

  4. 没有配置元数据:标准 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 类型接口用途
EventListenerEventListenerProviderFactory事件监听(登录、登出、注册等)
UserStorageUserStorageProviderFactory用户存储(外部数据库、LDAP等)
SignatureSignatureProviderFactory令牌签名算法
HashHashProviderFactory密码哈希算法
KeyProviderKeyProviderFactory密钥管理
ContentEncryptionContentEncryptionProviderFactory内容加密
AuthenticatorAuthenticatorFactory认证器
ProtocolMapperProtocolMapper协议映射器
ClientRegistrationClientRegistrationProvider客户端注册
ExchangeIdentityProviderMapper身份提供者映射

在 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.EventListenerProviderFactory

2.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.UserStorageProviderFactory

2.1.3 文件内容格式

服务文件的内容是 Factory 实现类的全限定名,每行一个。以下是格式规范:

# 这是注释行,以 # 开头
# ServiceLoader 会忽略注释行和空行

# Factory 实现类的全限定名
cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory

# 可以注册多个实现类(每行一个)
# cc.bima.keycloak.extension.event.AnotherEventListenerProviderFactory

格式要点:

  1. 每行一个实现类的全限定名
  2. # 开头的行是注释,会被忽略
  3. 空行会被忽略
  4. 行末的空白字符会被忽略
  5. 文件编码必须为 UTF-8
  6. 文件末尾建议保留一个换行符

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.SMSignatureProviderFactory

2.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 默认按照以下顺序加载:

  1. Keycloak 内置的 Provider
  2. providers/ 目录下的 Provider
  3. 部署的 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.EventListenerProviderFactorycc.bima.keycloak.extension.event.AuditEventListenerProviderFactory事件监听

2.4.2 spi-sm-crypto-extension 模块

服务文件路径注册的实现类SPI 类型
META-INF/services/org.keycloak.crypto.SignatureProviderFactorycc.bima.keycloak.extension.crypto.signature.SMSignatureProviderFactory签名算法
META-INF/services/org.keycloak.crypto.HashProviderFactorycc.bima.keycloak.extension.crypto.hash.SMHashProviderFactory哈希算法
META-INF/services/org.keycloak.crypto.ContentEncryptionProviderFactorycc.bima.keycloak.extension.crypto.encryption.SMContentEncryptionProviderFactory内容加密
META-INF/services/org.keycloak.keys.KeyProviderFactorycc.bima.keycloak.extension.crypto.keys.SMKeyProviderFactory密钥管理

2.4.3 spi-user-storage-extension 模块

服务文件路径注册的实现类SPI 类型
META-INF/services/org.keycloak.storage.UserStorageProviderFactorycc.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.propertiesstandalone.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=10485760

3.2.2 init 阶段可以做什么

init 方法中,你应该执行以下类型的初始化操作:

┌─────────────────────────────────────────────────────────────┐
│              init 阶段适合的操作                              │
├─────────────────────────────────────────────────────────────┤
│ ✓ 读取全局配置参数                                           │
│ ✓ 初始化全局级别的资源(如线程池、缓存)                      │
│ ✓ 加载配置文件                                               │
│ ✓ 初始化日志系统                                             │
│ ✓ 验证运行环境(如检查必需的本地文件是否存在)                 │
├─────────────────────────────────────────────────────────────┤
│ ✗ 访问 KeycloakSession(此时 Session 尚未创建)              │
│ ✗ 访问数据库(建议在 create 中按需建立连接)                  │
│ ✗ 访问其他 Provider(其他 Provider 可能尚未初始化)           │
└─────────────────────────────────────────────────────────────┘

3.2.3 keycloak-sandbox 中的 init 实现

在 keycloak-sandbox 项目中,CustomUserStorageProviderFactoryinit 方法实现非常简洁:

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 项目中 CustomUserStorageProviderFactorycreate 方法实现:

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 方法的几个最佳实践:

  1. 遍历所有资源:关闭所有已创建的资源,不留遗漏。
  2. 异常处理:每个资源的关闭操作都包裹在 try-catch 中,确保一个资源的关闭失败不会影响其他资源的关闭。
  3. 日志记录:记录关闭操作的结果,便于排查问题。
  4. 清理集合:关闭所有资源后,清空资源集合,防止内存泄漏。

3.5.2 close 的调用保证

Keycloak 保证 close 方法在以下情况下都会被调用:

  • 正常关闭(CTRL+Cshutdown 命令)
  • 异常关闭(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);
        });
    }
}

这种设计模式有几个关键优势:

  1. 连接池复用:同一个数据库 URL 只创建一个连接池,多个 Provider 实例共享连接池,避免资源浪费。
  2. 线程安全:使用 ConcurrentHashMapcomputeIfAbsent 保证线程安全。
  3. 懒加载:数据源只在第一次需要时创建,减少启动时间。
  4. 统一管理:所有数据源在 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.AuditEventListenerProviderFactory

4.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.SMSignatureProviderFactory

4.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.SMHashProviderFactory

4.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.CustomUserStorageProviderFactory

4.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.SMKeyProviderFactory

4.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.SMContentEncryptionProviderFactory

4.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.AuditEventListenerProviderFactory

6.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 依赖。这种情况下需要注意:

  1. 不要重复注册:如果使用了 @AutoService,就不要手动创建同名文件,否则会导致同一个 Factory 被注册两次。
  2. 编译产物检查:编译后检查 target/classes/META-INF/services/ 目录,确认文件内容正确。
  3. 版本兼容:确保 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.xmlstandalone-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.CustomUserStorageProviderFactory

7.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 的单例特性管理共享的数据库连接池,使用 ConcurrentHashMapcomputeIfAbsent 实现线程安全的懒加载。

第四,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