Skip to content

Keycloak Sandbox 全局版本控制机制:Maven 属性传播与运行时版本获取的深度实践

作者: 必码 | bima.cc


前言

Keycloak 作为业界领先的开源身份与访问管理(IAM)解决方案,其版本迭代频率之快令许多开发者措手不及。从早期的 WildFly 嵌入式部署到如今的 Quarkus 原生容器化架构,Keycloak 的每一次版本升级都伴随着 SPI(Service Provider Interface)接口的变更、内部 API 的重构以及运行时行为的调整。对于基于 Keycloak SPI 进行二次开发的团队而言,精确匹配 Keycloak 版本不仅是技术要求,更是项目能否稳定运行的生命线。

在多模块 Maven 项目中,版本管理始终是一个核心挑战。一个典型的 Keycloak SPI 扩展项目通常包含多个子模块:SPI 接口实现模块、事件监听器模块、自定义认证流程模块、协议映射器模块等。每个模块都需要依赖特定版本的 Keycloak 核心库,任何一个模块的版本偏差都可能导致运行时的 ClassNotFoundException、NoSuchMethodError 或是更为隐蔽的行为不一致问题。

本文以 keycloak-sandbox 项目为蓝本,深入剖析其全局版本控制机制的完整实现。keycloak-sandbox 是一个基于 Keycloak SPI 构建的沙箱环境,它通过 Maven 父 POM 集中管理版本、Resource Filtering 动态注入版本号、运行时 API 获取版本信息等手段,构建了一套从编译时到运行时的完整版本控制链路。这套机制不仅解决了多模块版本一致性问题,还支持一键版本切换和多版本沙箱并行运行,为 Keycloak SPI 开发者提供了一套可复用的版本管理最佳实践。

读者受众:

  • 使用 Keycloak SPI 进行二次开发的 Java 工程师
  • 负责多模块 Maven 项目架构设计的技术负责人
  • 需要管理多个 Keycloak 版本环境的运维工程师
  • 对构建工具版本管理机制感兴趣的技术爱好者
  • 正在构建 Keycloak 相关开源项目或商业产品的架构师

阅读本文需要具备以下前置知识:Maven 基础配置与多模块项目管理、Java SPI 机制的基本概念、Keycloak 的基本使用经验、Docker 容器化部署的基本了解。如果你已经在实际项目中遇到过 Keycloak 版本升级导致的兼容性问题,那么本文将为你提供系统性的解决方案。


第一章 多模块项目版本管理挑战

1.1 Keycloak 版本迭代频率

Keycloak 项目自 2014 年由 Red Hat(现为 IBM 的一部分)开源以来,已经经历了数百个版本的迭代。从版本号的演进来看,Keycloak 的版本管理策略经历了几个重要的阶段:

早期阶段(1.0 - 4.x): 这一阶段 Keycloak 作为 WildFly 应用服务器的子系统运行,版本迭代相对缓慢,每个大版本之间的间隔通常在 3-6 个月。SPI 接口相对稳定,向后兼容性较好。

中期阶段(5.0 - 16.x): 随着 Kubernetes 和微服务架构的兴起,Keycloak 开始加速迭代。这一阶段引入了大量的新特性,包括但不限于:多租户支持增强、Identity Provider 联合认证改进、用户存储联邦机制优化等。每个小版本都可能包含 SPI 接口的调整。

现代化阶段(17.x - 26.x): 这是 Keycloak 发展史上最具变革性的阶段。从 17.0 开始,Keycloak 正式从 WildFly 迁移到 Quarkus 运行时,这一架构变革带来了显著的性能提升,同时也意味着大量的内部 API 发生了根本性变化。从 22.0 开始,Keycloak 放弃了对传统 WildFly 发行版的支持,全面拥抱 Quarkus 原生部署模式。

以下是 Keycloak 近年来的版本发布节奏示意:

Keycloak 版本发布时间线(2023-2025)

2023-Q1  ─── 21.0 ─── 21.1 ─── 22.0(弃用 WildFly 发行版)
2023-Q2  ─── 22.1 ─── 23.0(Quarkus 3 升级)
2023-Q3  ─── 23.1 ─── 24.0
2023-Q4  ─── 24.1 ─── 25.0
2024-Q1  ─── 25.1 ─── 26.0
2024-Q2  ─── 26.1 ─── 26.2 ─── 26.3
2024-Q3  ─── 26.4 ─── 26.5
2024-Q4  ─── 26.6 ─── 26.7
2025-Q1  ─── 27.0 ─── 27.1 ─── 27.2

从时间线可以看出,Keycloak 大约每 3-4 周就会发布一个新版本。对于 SPI 开发者来说,这意味着:

第一,API 稳定性窗口极短。 一个 Keycloak 大版本的生命周期通常只有 6-9 个月。在这么短的时间内,开发者需要完成 SPI 开发、测试、部署和验证。一旦错过了某个版本的兼容性窗口,升级到下一个版本可能需要重新适配。

第二,补丁版本也可能包含破坏性变更。 与许多遵循严格语义化版本控制的项目不同,Keycloak 的补丁版本(如 26.6.0 到 26.6.1)有时也会包含 SPI 接口的微调。这是因为 Keycloak 团队将安全性修复和关键缺陷修复的优先级置于 API 稳定性之上。

第三,社区版本与商业版本存在差异。 Red Hat SSO(Single Sign-On)作为 Keycloak 的商业版本,其版本号与社区版 Keycloak 并非一一对应。开发者在参考 Red Hat 文档时需要特别注意版本映射关系。

1.2 版本不匹配的后果

版本不匹配是 Keycloak SPI 开发中最常见也最危险的问题之一。它的表现形式多种多样,从编译期的明显错误到运行时的隐蔽故障,每一种都可能给生产环境带来严重的风险。

编译期错误是最容易识别的。 当 SPI 实现模块依赖的 Keycloak 版本与运行环境不一致时,最常见的编译期错误包括:

java
// 编译期错误示例 1:接口方法签名变更
// Keycloak 25.x 中的 Authenticator 接口
public interface Authenticator {
    void authenticate(AuthenticationFlowContext context);
    void action(AuthenticationFlowContext context);
    boolean requiresUser();
    // Keycloak 26.x 新增了此方法
    default boolean isConfigurable() {
        return true;
    }
}

// 编译期错误示例 2:类被移除或重命名
// Keycloak 22.x 中存在,26.x 中被移除
import org.keycloak.models.utils.KeycloakModelUtils;
// 在 26.x 中需要使用
import org.keycloak.models.utils.ModelUtils;

运行时错误则更加隐蔽和危险。 由于 Java 的类型擦除和二进制兼容性机制,某些版本不匹配问题在编译期不会暴露,只有在运行时才会触发:

java
// 运行时错误示例 1:NoSuchMethodError
// 编译时使用 Keycloak 26.6.1 的 jar,运行时部署在 Keycloak 25.0.0 上
// 方法 org.keycloak.services.resources.LoginActionsService$ProtocolEndpoint
// 在 25.0.0 中不存在
java.lang.NoSuchMethodError: org.keycloak.services.resources
    .LoginActionsService$ProtocolEndpoint.<init>(
        Lorg/keycloak/models/RealmModel;
        Lorg/keycloak/models/ClientModel;
        Ljava/lang/String;
    )

// 运行时错误示例 2:AbstractMethodError
// SPI 接口新增了抽象方法,但部署的 provider jar 是基于旧版本编译的
java.lang.AbstractMethodError:
    Receiver 'com.example.CustomAuthenticator' does not define
    or inherit an implementation of the resolved method
    'abstract isConfigurable()Z' in
    'org.keycloak.authentication.Authenticator'

行为不一致是最难排查的版本问题。 有时候版本不匹配不会抛出异常,但会导致 SPI 的行为与预期不符:

java
// 行为不一致示例:事件监听器的触发时机变化
// Keycloak 25.x 中,LOGIN_ERROR 事件在认证失败时触发
// Keycloak 26.x 中,LOGIN_ERROR 事件的触发时机被调整到
// 认证流程完全结束之后,且事件 payload 中的字段发生了变化
public class CustomEventListenerProvider implements EventListenerProvider {
    @Override
    public void onEvent(Event event) {
        // 在 26.x 中,event.getDetails() 可能不再包含某些字段
        String clientId = event.getDetails().get("client_id");
        // 在 25.x 中 clientId 始终存在
        // 在 26.x 中 clientId 可能为 null(取决于事件类型)
    }
}

版本不匹配还可能导致安全漏洞。 当 SPI 实现依赖的 Keycloak 版本低于运行环境时,SPI 可能无法利用新版本中的安全增强特性。例如,Keycloak 26.x 引入了改进的 CSRF 保护机制和更严格的 OIDC 参数验证,如果 SPI 是基于 24.x 编译的,可能绕过了这些安全检查。

1.3 传统版本管理方案的痛点

在 keycloak-sandbox 项目引入全局版本控制机制之前,Keycloak SPI 开发社区中普遍采用的版本管理方案存在诸多痛点。让我们逐一分析这些传统方案的局限性。

痛点一:硬编码版本号

这是最原始也最常见的版本管理方式。开发者直接在每个模块的 pom.xml 中硬编码 Keycloak 的版本号:

xml
<!-- 模块 A 的 pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
        <version>26.6.1</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<!-- 模块 B 的 pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
        <version>26.6.1</version> <!-- 容易忘记更新 -->
        <scope>provided</scope>
    </dependency>

<!-- 模块 C 的 pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-services</artifactId>
        <version>26.5.0</version> <!-- 版本不一致! -->
        <scope>provided</scope>
    </dependency>
</dependencies>

这种方式的致命缺陷在于:当需要升级 Keycloak 版本时,开发者必须逐个修改每个模块的 pom.xml 文件。在一个包含 10+ 子模块的项目中,遗漏任何一个模块都可能导致版本不一致。更糟糕的是,这种不一致在编译期可能不会报错(因为 Maven 会使用最近距离优先原则解析依赖),但在运行时却会引发各种奇怪的问题。

痛点二:属性定义分散

稍微进阶一些的团队会使用 Maven 属性来管理版本号,但属性定义分散在各个模块中:

xml
<!-- 模块 A 的 pom.xml -->
<properties>
    <keycloak.version>26.6.1</keycloak.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
        <version>${keycloak.version}</version>
    </dependency>
</dependencies>

<!-- 模块 B 的 pom.xml -->
<properties>
    <keycloak.version>26.6.1</keycloak.version> <!-- 重复定义 -->
</properties>

这种方式虽然避免了硬编码,但属性定义的重复性导致维护成本依然很高。每个模块各自定义属性,本质上只是把硬编码变成了"属性的硬编码"。

痛点三:缺少运行时版本感知

许多项目只关注编译时的版本管理,却忽略了运行时的版本感知。当 SPI 部署到 Keycloak 服务器后,如何确认当前运行的 Keycloak 版本与 SPI 编译时的版本一致?传统方案通常缺乏这种运行时的版本校验机制。

java
// 传统方案中常见的做法:在日志中打印版本信息
// 但这个版本信息是编译时硬编码的,无法反映运行时的实际版本
public class MyProvider implements Authenticator {
    private static final String KEYCLOAK_VERSION = "26.6.1"; // 硬编码

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        logger.info("Running on Keycloak version: " + KEYCLOAK_VERSION);
        // 这里的版本信息可能是错误的!
    }
}

痛点四:Docker 镜像版本与代码版本脱节

在容器化部署场景中,Docker 镜像的版本需要与 SPI 代码的版本保持一致。传统方案通常需要手动维护 docker-compose.yml 或 Dockerfile 中的版本号,这很容易与 Maven 项目中的版本号产生偏差:

yaml
# docker-compose.yml - 版本号需要手动维护
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.6.1  # 容易忘记更新
    # 而此时 pom.xml 中的版本可能已经升级到 26.7.0

痛点五:多版本测试困难

Keycloak SPI 开发者经常需要在不同版本上测试自己的扩展。传统方案中,切换版本通常意味着:修改多个 pom.xml 文件中的版本号、重新编译所有模块、更新 Docker 配置、重新构建容器镜像。这个过程繁琐且容易出错,严重影响了开发效率。

传统版本切换流程(痛点示意):

┌─────────────────────────────────────────────────────────┐
│  1. 打开每个子模块的 pom.xml                              │
│  2. 逐一修改 keycloak.version 属性值                       │
│  3. 修改 docker-compose.yml 中的镜像版本                   │
│  4. 修改启动脚本中的下载 URL                               │
│  5. 执行 mvn clean install                                │
│  6. docker-compose down && docker-compose up              │
│  7. 手动验证版本是否一致                                   │
│  8. 如果发现问题,回滚所有修改并重试                         │
│                                                          │
│  预计耗时:15-30 分钟                                      │
│  出错概率:高                                              │
└─────────────────────────────────────────────────────────┘

正是这些痛点催生了 keycloak-sandbox 项目的全局版本控制机制。接下来,我们将深入剖析这套机制的设计与实现。


第二章 父 POM 全局版本控制

2.1 dependencyManagement 集中管理

Maven 的 dependencyManagement 是实现全局版本控制的核心机制。在 keycloak-sandbox 项目中,父 POM 通过 dependencyManagement 节点集中声明所有 Keycloak 相关依赖的版本,确保所有子模块使用完全一致的依赖版本。

keycloak-sandbox 的父 POM 基本结构如下:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cc.bima.keycloak</groupId>
    <artifactId>keycloak-sandbox</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <name>Keycloak Sandbox</name>
    <description>
        Keycloak SPI 沙箱环境 - 全局版本控制的多模块项目
    </description>

    <modules>
        <module>sandbox-common</module>
        <module>sandbox-spi</module>
        <module>sandbox-provider</module>
        <module>sandbox-dist</module>
    </modules>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <keycloak.version>26.6.1</keycloak.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- Keycloak 核心 SPI 依赖 -->
            <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>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-model-jpa</artifactId>
                <version>${keycloak.version}</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-common</artifactId>
                <version>${keycloak.version}</version>
                <scope>compile</scope>
            </dependency>

            <!-- 工具库依赖 -->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>1.18.22</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>com.google.auto.service</groupId>
                <artifactId>auto-service</artifactId>
                <version>1.0.1</version>
                <scope>provided</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

dependencyManagement 与直接在 dependencies 中声明依赖有着本质的区别。理解这种区别对于正确使用全局版本控制至关重要:

dependencyManagement vs dependencies 的区别:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  dependencyManagement:                                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 只声明版本信息,不实际引入依赖                       │      │
│  │ • 子模块需要显式声明依赖(但不指定版本)                │      │
│  │ • 子模块可以通过 <version> 覆盖父 POM 的版本          │      │
│  │ • 适用于:统一管理版本号                               │      │
│  └────────────────────────────────────────────────────┘      │
│                                                              │
│  dependencies:                                                │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 实际引入依赖,所有子模块自动继承                     │      │
│  │ • 子模块无法排除(只能通过 exclusion 排除传递依赖)    │      │
│  │ • 适用于:所有子模块都必需的依赖                      │      │
│  └────────────────────────────────────────────────────┘      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

在 keycloak-sandbox 项目中,选择使用 dependencyManagement 而非 dependencies 是一个经过深思熟虑的架构决策。原因如下:

第一,Keycloak SPI 依赖的 scope 为 provided。 这意味着这些依赖在编译时可用,但在运行时由 Keycloak 服务器本身提供。如果使用 dependencies 直接声明,所有子模块都会无条件继承这些依赖,而实际上并非所有子模块都需要全部的 Keycloak SPI 依赖。例如,sandbox-common 模块可能只需要 keycloak-common,而 sandbox-provider 模块才需要 keycloak-server-spikeycloak-services

第二,不同子模块可能需要不同的 Keycloak 依赖子集。 通过 dependencyManagement,每个子模块可以按需声明自己需要的依赖,同时版本号由父 POM 统一管控:

xml
<!-- sandbox-common/pom.xml - 只需要 keycloak-common -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-common</artifactId>
        <!-- 版本由父 POM 的 dependencyManagement 管理 -->
    </dependency>
</dependencies>

<!-- sandbox-provider/pom.xml - 需要完整的 SPI 依赖 -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi-private</artifactId>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-services</artifactId>
    </dependency>
    <dependency>
        <groupId>cc.bima.keycloak</groupId>
        <artifactId>sandbox-common</artifactId>
        <version>${project.version}</version>
    </dependency>
</dependencies>

第三,dependencyManagement 支持导入 BOM(Bill of Materials)。 如果将来 keycloak-sandbox 需要引入其他使用 BOM 管理版本的库,可以很方便地通过 scope=import 的方式导入:

xml
<dependencyManagement>
    <dependencies>
        <!-- 导入 Keycloak BOM(如果官方提供) -->
        <dependency>
            <groupId>org.keycloak.bom</groupId>
            <artifactId>keycloak-spi-bom</artifactId>
            <version>${keycloak.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

        <!-- 项目自身的依赖版本管理 -->
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <version>${keycloak.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

2.2 keycloak.version 属性定义

在父 POM 的 <properties> 节点中,keycloak.version 属性是整个版本控制体系的基石。这个看似简单的属性定义,实际上承载了版本控制架构的核心设计思想。

xml
<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <keycloak.version>26.6.1</keycloak.version>
</properties>

属性命名规范的设计考量。 选择 keycloak.version 作为属性名遵循了 Maven 社区的命名惯例。在 Maven 项目中,属性名通常采用 {组件名}.{属性类别} 的格式。使用 keycloak 作为前缀可以避免与其他属性的命名冲突,使用 version 作为后缀则清晰地表达了属性的用途。

这种命名规范在大型多模块项目中尤为重要。考虑以下场景:一个项目同时依赖 Keycloak、Spring Boot 和 PostgreSQL,如果属性命名不规范,很容易产生混淆:

xml
<!-- 不规范的属性命名 -->
<properties>
    <version>26.6.1</version>          <!-- 太模糊,谁的版本? -->
    <keycloak>26.6.1</keycloak>        <!-- 缺少 .version 后缀 -->
    <kcVersion>26.6.1</kcVersion>      <!-- 驼峰命名不符合 Maven 惯例 -->
</properties>

<!-- 规范的属性命名 -->
<properties>
    <keycloak.version>26.6.1</keycloak.version>
    <spring-boot.version>3.2.0</spring-boot.version>
    <postgresql.version>42.7.0</postgresql.version>
</properties>

单一事实源原则(Single Source of Truth)。 keycloak.version 属性体现了软件工程中的单一事实源原则。在整个项目中,Keycloak 的版本号只在一个地方定义。无论是编译依赖、Docker 镜像版本、下载 URL 还是运行时版本校验,都引用这同一个属性值。

单一事实源原则示意:

                    ┌─────────────────────┐
                    │   父 POM            │
                    │   <properties>      │
                    │   keycloak.version  │
                    │   = 26.6.1         │
                    └─────────┬───────────┘

              ┌───────────────┼───────────────┐
              │               │               │
    ┌─────────▼──────┐ ┌─────▼──────┐ ┌──────▼─────────┐
    │  编译依赖       │ │  资源过滤   │ │  运行时获取     │
    │                │ │            │ │               │
    │ keycloak-spi   │ │ docker-    │ │ Version.      │
    │ keycloak-svc   │ │ compose    │ │ VERSION       │
    │ keycloak-model │ │ .yml       │ │               │
    │ keycloak-common│ │            │ │               │
    └────────────────┘ └────────────┘ └───────────────┘
         全部引用          全部引用        运行时校验
       ${keycloak.version} ${keycloak_version}  版本一致性

版本属性的继承机制。 Maven 的属性继承遵循"子模块覆盖父 POM"的原则。默认情况下,子模块会继承父 POM 中定义的所有属性。如果子模块需要使用不同的 Keycloak 版本(例如,某个子模块需要兼容旧版本的 Keycloak),可以在子模块的 <properties> 中覆盖该属性:

xml
<!-- 特殊子模块需要使用不同的 Keycloak 版本 -->
<properties>
    <keycloak.version>25.0.0</keycloak.version> <!-- 覆盖父 POM 的值 -->
</properties>

但在 keycloak-sandbox 项目中,我们强烈不建议这种做法。版本不一致是多模块项目的天敌,如果确实需要支持多个 Keycloak 版本,应该通过 Maven Profile 或独立的分支来管理,而不是在同一个构建中混用不同版本。

2.3 属性传播到子模块

Maven 的属性传播机制是全局版本控制能够生效的关键。当一个子模块继承父 POM 时,它会自动获得父 POM 中定义的所有属性。这种传播机制确保了版本号在整个项目树中的一致性。

Maven 属性解析顺序。 理解 Maven 的属性解析顺序有助于排查版本不一致的问题。Maven 按照以下优先级从高到低解析属性:

Maven 属性解析优先级(从高到低):

1. 系统属性(-Dkeycloak.version=26.7.0)
   └── 命令行传入的属性具有最高优先级

2. 用户属性(settings.xml 中的属性)
   └── ~/.m2/settings.xml 中定义的属性

3. 当前 POM 的 properties 节点
   └── 子模块自身定义的属性

4. 父 POM 的 properties 节点
   └── 父 POM 中定义的属性

5. Super POM 的 properties 节点
   └── Maven 内置的 Super POM 属性

6. 环境变量
   └── 操作系统环境变量(通过 ${env.KEYCLOAK_VERSION} 访问)

这个解析顺序意味着,开发者可以通过命令行参数临时覆盖版本号,而无需修改任何 POM 文件:

bash
# 使用命令行参数临时切换 Keycloak 版本
mvn clean install -Dkeycloak.version=26.7.0

# 这在 CI/CD 环境中特别有用
mvn clean deploy -Dkeycloak.version=${CI_KEYCLOAK_VERSION}

子模块的 POM 配置。 在 keycloak-sandbox 项目中,子模块的 POM 配置非常简洁,因为版本信息已经由父 POM 管理:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>cc.bima.keycloak</groupId>
        <artifactId>keycloak-sandbox</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>sandbox-provider</artifactId>
    <name>Sandbox Provider</name>

    <dependencies>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <!-- 版本由父 POM 的 dependencyManagement 提供 -->
            <!-- scope 由父 POM 的 dependencyManagement 提供 -->
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-services</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.auto.service</groupId>
            <artifactId>auto-service</artifactId>
        </dependency>
    </dependencies>
</project>

注意子模块的 <dependency> 声明中既没有 <version> 也没有 <scope>。这两个信息都从父 POM 的 dependencyManagement 中继承。这种设计带来了几个重要的好处:

好处一:减少重复配置。 每个子模块的依赖声明减少了约 40% 的代码量,POM 文件更加简洁易读。

好处二:消除版本遗漏风险。 由于版本号由父 POM 集中管理,子模块不可能意外地使用错误的版本。

好处三:scope 统一管控。 Keycloak SPI 依赖的 scope 必须是 provided(因为运行时由 Keycloak 服务器提供),通过父 POM 集中管理 scope 可以避免子模块错误地将其设为 compile

好处四:升级操作原子化。 升级 Keycloak 版本时,只需修改父 POM 中的一个属性值,所有子模块在下次构建时自动使用新版本。

2.4 版本一致性保障

虽然 Maven 的属性传播机制和 dependencyManagement 提供了版本管理的基础设施,但在实际项目中,还需要额外的保障措施来确保版本一致性。keycloak-sandbox 项目采用了多层防护策略。

第一层:Maven Enforcer 插件。 通过 Maven Enforcer 插件,可以在构建阶段强制执行版本一致性规则:

xml
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-enforcer-plugin</artifactId>
            <version>3.4.0</version>
            <executions>
                <execution>
                    <id>enforce-versions</id>
                    <goals>
                        <goal>enforce</goal>
                    </goals>
                    <configuration>
                        <rules>
                            <!-- 禁止依赖中存在版本为空的情况 -->
                            <requireProperty>
                                <property>keycloak.version</property>
                                <message>
                                    keycloak.version 属性必须定义!
                                </message>
                            </requireProperty>

                            <!-- 要求所有 Keycloak 依赖使用统一版本 -->
                            <dependencyConvergence/>
                        </rules>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

第二层:依赖分析插件。 使用 maven-dependency-plugin 定期检查依赖树,确保没有版本冲突:

bash
# 检查依赖树中是否存在版本冲突
mvn dependency:tree -Dverbose | grep "omitted for conflict"

# 检查所有 Keycloak 相关依赖是否使用统一版本
mvn dependency:tree | grep "org.keycloak"

第三层:版本一致性校验脚本。 在 CI/CD 流水线中加入版本一致性校验脚本:

bash
#!/bin/bash
# verify-version-consistency.sh
# 校验项目中所有 Keycloak 依赖是否使用统一版本

EXPECTED_VERSION=$(mvn help:evaluate \
    -Dexpression=keycloak.version -q -DforceStdout)

echo "期望的 Keycloak 版本: $EXPECTED_VERSION"

# 检查依赖树中的所有 Keycloak 依赖
ACTUAL_VERSIONS=$(mvn dependency:tree -pl '!node_modules' \
    | grep "org.keycloak" \
    | grep -oP ':\K[0-9]+\.[0-9]+\.[0-9]+' \
    | sort -u)

echo "实际的 Keycloak 版本:"
echo "$ACTUAL_VERSIONS"

# 检查是否只有一个版本
VERSION_COUNT=$(echo "$ACTUAL_VERSIONS" | wc -l)
if [ "$VERSION_COUNT" -ne 1 ]; then
    echo "错误:发现多个 Keycloak 版本!"
    echo "$ACTUAL_VERSIONS"
    exit 1
fi

if [ "$ACTUAL_VERSIONS" != "$EXPECTED_VERSION" ]; then
    echo "错误:Keycloak 版本不一致!"
    echo "期望: $EXPECTED_VERSION"
    echo "实际: $ACTUAL_VERSIONS"
    exit 1
fi

echo "版本一致性校验通过"

第四层:版本锁定文件。 在 CI/CD 环境中,可以使用 Maven 的 flatten-maven-plugin 生成版本锁定文件,确保构建的可重复性:

xml
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>flatten-maven-plugin</artifactId>
    <version>1.5.0</version>
    <configuration>
        <flattenMode>resolveCiFriendliesOnly</flattenMode>
        <updatePomFile>true</updatePomFile>
    </configuration>
    <executions>
        <execution>
            <id>flatten</id>
            <phase>process-resources</phase>
            <goals>
                <goal>flatten</goal>
            </goals>
        </execution>
        <execution>
            <id>flatten.clean</id>
            <phase>clean</phase>
            <goals>
                <goal>clean</goal>
            </goals>
        </execution>
    </executions>
</plugin>

通过这四层防护,keycloak-sandbox 项目实现了从编译时到 CI/CD 的全链路版本一致性保障。任何版本不一致的问题都会在最早的阶段被发现和拦截,避免其流入运行环境。


第三章 Maven Resource Filtering 版本注入

3.1 Resource Filtering 原理

Maven Resource Filtering 是一种在构建过程中动态替换资源文件中占位符的机制。它是连接编译时版本信息与运行时配置文件的桥梁。在 keycloak-sandbox 项目中,Resource Filtering 负责将父 POM 中定义的 keycloak.version 属性注入到 Docker Compose 配置文件和其他资源文件中。

Resource Filtering 的工作原理。 当 Maven 执行 process-resources 阶段时,maven-resources-plugin 会扫描项目 src/main/resources 目录下的资源文件。如果启用了 filtering,插件会将文件中所有 ${property.name} 格式的占位符替换为对应的 Maven 属性值。

Resource Filtering 工作流程:

┌──────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  src/main/   │     │  Maven           │     │  target/classes/ │
│  resources/  │────▶│  Resources       │────▶│                  │
│              │     │  Plugin          │     │  (过滤后的资源)  │
│  docker-     │     │                  │     │                  │
│  compose.yml │     │  ${keycloak_     │     │  docker-         │
│              │     │   version}       │     │  compose.yml     │
│  application │     │       │          │     │                  │
│  .properties │     │       ▼          │     │  application     │
│              │     │  26.6.1          │     │  .properties     │
└──────────────┘     └──────────────────┘     └──────────────────┘

                    ┌─────▼──────┐
                    │  父 POM    │
                    │  属性源    │
                    │            │
                    │ keycloak.  │
                    │ version    │
                    │ = 26.6.1   │
                    └────────────┘

启用 Resource Filtering 的配置。 在 keycloak-sandbox 项目的父 POM 中,Resource Filtering 通过以下配置启用:

xml
<build>
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>true</filtering>
            <!-- 包含需要过滤的文件 -->
            <includes>
                <include>**/docker-compose*.yml</include>
                <include>**/*.properties</include>
                <include>**/*.yaml</include>
            </includes>
        </resource>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>false</filtering>
            <!-- 排除不需要过滤的文件(如二进制文件) -->
            <excludes>
                <exclude>**/docker-compose*.yml</exclude>
                <exclude>**/*.properties</exclude>
                <exclude>**/*.yaml</exclude>
            </excludes>
        </resource>
    </resources>
</build>

这里使用了两个 <resource> 块的配置模式:第一个启用了 filtering 并指定需要过滤的文件类型,第二个禁用了 filtering 并排除已被第一个块处理的文件。这种"白名单"模式确保只有需要替换占位符的文件才会被处理,避免了二进制文件(如图片、证书文件)被意外修改。

属性名映射规则。 Maven 属性名中的点号(.)在资源文件中通常被映射为下划线(_)。这是因为许多配置文件格式(如 YAML、properties)对点号有特殊含义。因此,父 POM 中的 keycloak.version 属性在资源文件中使用 ${keycloak_version} 来引用:

xml
<!-- 父 POM 中的属性定义 -->
<properties>
    <keycloak.version>26.6.1</keycloak.version>
</properties>
yaml
# 资源文件中的引用(使用下划线)
image: quay.io/keycloak/keycloak:${keycloak_version}

Maven 实际上支持两种属性引用方式:${keycloak.version}${keycloak_version}。两种方式在大多数情况下都能正确解析,但使用下划线形式更加安全,因为某些资源文件格式可能将点号解析为嵌套结构的分隔符。

3.2 docker-compose.yml 版本占位符

在 keycloak-sandbox 项目中,Docker Compose 配置文件是版本注入最重要的目标文件。通过 Resource Filtering,docker-compose.yml 中的 Keycloak 版本号可以在构建时动态设置,无需手动维护。

模板化的 docker-compose.yml。src/main/resources 目录下,docker-compose.yml 使用占位符来引用 Keycloak 版本:

yaml
# src/main/resources/docker-compose.yml(模板文件)
version: '3.8'

services:
  keycloak:
    image: quay.io/keycloak/keycloak:${keycloak_version}
    container_name: keycloak-sandbox-${keycloak_version}
    ports:
      - "8080:8080"
      - "8443:8443"
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_HEALTH_ENABLED: "true"
      KC_METRICS_ENABLED: "true"
      KC_LOG_LEVEL: INFO
      # 版本特定的配置
      KC_SPI_TRUSTSTORE_FILE: /opt/keycloak/conf/truststore.jks
    command:
      - start-dev
    volumes:
      - keycloak-data:/opt/keycloak/data
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    networks:
      - keycloak-network

  postgres:
    image: postgres:16
    container_name: keycloak-postgres
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - keycloak-network

volumes:
  keycloak-data:
  postgres-data:

networks:
  keycloak-network:
    driver: bridge

注意模板中使用了两个 ${keycloak_version} 占位符:一个用于 Docker 镜像的 tag,另一个用于容器名称。这种设计确保了当 Keycloak 版本升级时,Docker 镜像和容器名称会同步更新,避免了旧版本容器与新版本镜像的混淆。

构建后的 docker-compose.yml。 经过 Maven Resource Filtering 处理后,生成的文件中所有占位符都被替换为实际值:

yaml
# target/classes/docker-compose.yml(构建后的文件)
version: '3.8'

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.6.1
    container_name: keycloak-sandbox-26.6.1
    ports:
      - "8080:8080"
      - "8443:8443"
    # ... 其余配置不变

3.3 ${keycloak_version} 动态替换

${keycloak_version} 占位符的动态替换过程涉及 Maven 构建生命周期的多个阶段。理解这个过程有助于排查替换失败的问题。

替换过程的详细步骤:

${keycloak_version} 动态替换的完整过程:

Step 1: Maven 初始化
┌─────────────────────────────────────────────┐
│ mvn clean install                           │
│                                             │
│ Maven 解析命令行参数和 settings.xml           │
│ 加载父 POM 和所有子模块的 POM                │
│ 合并属性定义,确定最终属性值                  │
│                                             │
│ keycloak.version = 26.6.1                   │
│ (如果命令行传入了 -Dkeycloak.version=X.X.X │
│  则使用命令行的值)                          │
└──────────────────┬──────────────────────────┘

Step 2: process-resources 阶段

┌──────────────────▼──────────────────────────┐
│ maven-resources-plugin 执行                  │
│                                             │
│ 1. 扫描 src/main/resources/ 目录            │
│ 2. 读取每个文件的内容                        │
│ 3. 查找 ${...} 格式的占位符                  │
│ 4. 在属性上下文中查找匹配的属性名             │
│    ${keycloak_version} → keycloak.version    │
│    → 26.6.1                                 │
│ 5. 替换占位符为属性值                        │
│ 6. 将替换后的文件写入 target/classes/        │
└──────────────────┬──────────────────────────┘

Step 3: 输出验证

┌──────────────────▼──────────────────────────┐
│ target/classes/docker-compose.yml            │
│                                             │
│ image: quay.io/keycloak/keycloak:26.6.1     │
│ container_name: keycloak-sandbox-26.6.1     │
│                                             │
│ 替换完成,文件可用于部署                      │
└─────────────────────────────────────────────┘

占位符未替换的常见原因。 在实际开发中,占位符未被替换是一个常见问题。以下是排查清单:

bash
# 1. 检查 filtering 是否启用
# 在父 POM 或子模块 POM 中确认:
# <filtering>true</filtering>

# 2. 检查文件是否在正确的目录
# Resource Filtering 只处理 src/main/resources/ 下的文件
# src/main/java/ 下的文件不会被处理

# 3. 检查属性名是否正确
# Maven 属性名中的点号和下划线可以互换
# 但建议统一使用下划线形式

# 4. 检查 Maven 转义字符
# 如果需要在文件中保留 ${...} 字面量,使用 \${...} 转义
# 或者使用 $${...}(双美元符号)

# 5. 检查文件编码
# 确保资源文件的编码与 project.build.sourceEncoding 一致
# 通常使用 UTF-8

# 6. 查看详细构建日志
mvn process-resources -X | grep -A5 "filtering"

多占位符场景。 keycloak-sandbox 项目的资源文件中可能包含多个占位符,每个占位符都引用不同的 Maven 属性:

yaml
# 多占位符示例
services:
  keycloak:
    image: quay.io/keycloak/keycloak:${keycloak_version}
    container_name: keycloak-sandbox-${keycloak_version}
    environment:
      KC_DB: ${db.type}                    # 数据库类型
      KC_DB_URL: ${db.url}                 # 数据库连接 URL
      KC_DB_USERNAME: ${db.username}       # 数据库用户名
      KC_DB_PASSWORD: ${db.password}       # 数据库密码
      JAVA_OPTS_APPEND: >
        -Dkeycloak.version=${keycloak_version}
        -Dsandbox.build=${project.version}
        -Dsandbox.timestamp=${maven.build.timestamp}

对应的父 POM 属性定义:

xml
<properties>
    <keycloak.version>26.6.1</keycloak.version>
    <db.type>postgres</db.type>
    <db.url>jdbc:postgresql://postgres:5432/keycloak</db.url>
    <db.username>keycloak</db.username>
    <db.password>keycloak</db.password>
    <project.version>1.0.0-SNAPSHOT</project.version>
    <maven.build.timestamp>${maven.build.timestamp}</maven.build.timestamp>
    <maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
</properties>

3.4 过滤范围控制

在实际项目中,并非所有资源文件都需要进行占位符替换。不恰当的过滤范围可能导致二进制文件损坏、配置文件意外修改等问题。keycloak-sandbox 项目通过精细的过滤范围控制来避免这些问题。

需要过滤的文件类型。 以下类型的文件通常需要启用 Resource Filtering:

需要 Resource Filtering 的文件:

┌────────────────────────────────────────────────────┐
│ 文件类型          │ 示例文件                        │
├───────────────────┼────────────────────────────────┤
│ YAML 配置文件     │ docker-compose.yml             │
│                   │ application.yml                │
│                   │ kubernetes/*.yaml              │
├───────────────────┼────────────────────────────────┤
│ Properties 文件   │ application.properties         │
│                   │ version.properties             │
│                   │ spi-config.properties          │
├───────────────────┼────────────────────────────────┤
│ Shell 脚本        │ start.sh                       │
│                   │ deploy.sh                      │
│                   │ health-check.sh                │
├───────────────────┼────────────────────────────────┤
│ 文本模板文件      │ README.md.template             │
│                   │ config.xml.template            │
│                   │ notification-email.txt         │
├───────────────────┼────────────────────────────────┤
│ JSON 配置文件     │ realm-export.json              │
│                   │ theme-config.json              │
└───────────────────┴────────────────────────────────┘

不能过滤的文件类型。 以下类型的文件绝对不能启用 Resource Filtering,否则会导致文件损坏:

禁止 Resource Filtering 的文件:

┌────────────────────────────────────────────────────┐
│ 文件类型          │ 损坏原因                        │
├───────────────────┼────────────────────────────────┤
│ 二进制文件        │ JAR、ZIP、TAR 等压缩包会被      │
│                   │ 当作文本处理,导致结构损坏        │
├───────────────────┼────────────────────────────────┤
│ 图片文件          │ PNG、JPG、SVG 等图片文件中的     │
│                   │ ${...} 序列会被错误替换          │
├───────────────────┼────────────────────────────────┤
│ 证书文件          │ JKS、P12、PEM 等证书文件是      │
│                   │ 二进制格式,过滤会导致损坏        │
├───────────────────┼────────────────────────────────┤
│ 编译后的类文件    │ .class 文件是二进制格式          │
├───────────────────┼────────────────────────────────┤
│ 加密文件          │ 加密数据中可能包含 ${...} 序列   │
│                   │ 过滤会导致解密失败               │
└───────────────────┴────────────────────────────────┘

精细的过滤范围配置。 keycloak-sandbox 项目使用以下配置来实现精细的过滤范围控制:

xml
<build>
    <resources>
        <!-- 需要过滤的资源 -->
        <resource>
            <directory>src/main/resources</directory>
            <filtering>true</filtering>
            <includes>
                <include>**/*.yml</include>
                <include>**/*.yaml</include>
                <include>**/*.properties</include>
                <include>**/*.sh</include>
                <include>**/*.txt</include>
                <include>**/*.xml</include>
                <include>**/*.json</include>
            </includes>
            <!-- 排除二进制文件(即使扩展名匹配) -->
            <excludes>
                <exclude>**/certs/**</exclude>
                <exclude>**/keystore/**</exclude>
                <exclude>**/themes/**/*.png</exclude>
                <exclude>**/themes/**/*.jpg</exclude>
            </excludes>
        </resource>

        <!-- 不需要过滤的资源 -->
        <resource>
            <directory>src/main/resources</directory>
            <filtering>false</filtering>
            <excludes>
                <exclude>**/*.yml</exclude>
                <exclude>**/*.yaml</exclude>
                <exclude>**/*.properties</exclude>
                <exclude>**/*.sh</exclude>
                <exclude>**/*.txt</exclude>
                <exclude>**/*.xml</exclude>
                <exclude>**/*.json</exclude>
            </excludes>
        </resource>
    </resources>
</build>

使用过滤标记目录。 一种更优雅的过滤范围控制方式是使用专门的目录来存放需要过滤的资源文件:

src/main/resources/
├── filtered/                    # 需要过滤的资源
│   ├── docker-compose.yml       # ${keycloak_version} 会被替换
│   ├── application.properties   # ${keycloak_version} 会被替换
│   └── deploy.sh                # ${keycloak_version} 会被替换
├── static/                      # 不需要过滤的资源
│   ├── certs/                   # 证书文件
│   ├── themes/                  # 主题资源
│   └── images/                  # 图片文件
└── META-INF/
    └── services/                # SPI 服务发现文件

对应的 POM 配置:

xml
<resources>
    <resource>
        <directory>src/main/resources/filtered</directory>
        <filtering>true</filtering>
        <targetPath>${project.build.outputDirectory}</targetPath>
    </resource>
    <resource>
        <directory>src/main/resources/static</directory>
        <filtering>false</filtering>
        <targetPath>${project.build.outputDirectory}</targetPath>
    </resource>
    <resource>
        <directory>src/main/resources</directory>
        <filtering>false</filtering>
        <excludes>
            <exclude>filtered/**</exclude>
            <exclude>static/**</exclude>
        </excludes>
    </resource>
</resources>

这种目录结构的好处是:开发者一眼就能看出哪些文件会被过滤,哪些不会。不需要记忆复杂的 include/exclude 规则,降低了配置错误的风险。


第四章 运行时版本获取

4.1 org.keycloak.common.Version.VERSION

Keycloak 提供了一个内置的版本信息类 org.keycloak.common.Version,它包含了当前运行的 Keycloak 实例的版本信息。在 keycloak-sandbox 项目中,这个类是运行时版本获取的核心工具。

Version 类的核心 API。 org.keycloak.common.Version 类提供了以下关键信息:

java
package org.keycloak.common;

/**
 * Keycloak 版本信息类。
 * 该类在 Keycloak 启动时由构建系统自动生成,
 * 包含当前 Keycloak 发行版的版本信息。
 */
public final class Version {

    // 当前 Keycloak 的完整版本号
    // 例如: "26.6.1"
    public static final String VERSION;

    // Keycloak 的资源版本号(用于 Quarkus 资源定位)
    // 例如: "26.6"
    public static final String RESOURCE_VERSION;

    // Keycloak 的构建时间戳
    // 例如: "2024-12-15 10:30:00 UTC"
    public static final String BUILD_TIME;

    // Keycloak 的 Git commit hash
    // 例如: "abc1234def5678"
    public static final String GIT_REVISION;

    // 服务器信息字符串(用于 HTTP 响应头)
    // 例如: "Keycloak/26.6.1"
    public static final String SERVER_INFO;
}

在 SPI 中使用 Version 类。 keycloak-sandbox 项目中的 SPI 实现通过 Version 类获取运行时的 Keycloak 版本信息,用于版本校验和日志记录:

java
package cc.bima.keycloak.sandbox.provider;

import org.keycloak.common.Version;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * 沙箱环境提供者工厂
 * 负责创建沙箱环境提供者实例
 */
public class SandboxProviderFactory implements ProviderFactory<SandboxProvider> {

    private static final Logger logger =
        LoggerFactory.getLogger(SandboxProviderFactory.class);

    // 编译时的 Keycloak 版本(由 Maven Resource Filtering 注入)
    // 这个值在编译时确定,用于与运行时版本进行比对
    private static final String COMPILE_TIME_VERSION = "${project.version}";

    @Override
    public SandboxProvider create() {
        // 获取运行时的 Keycloak 版本
        String runtimeVersion = Version.VERSION;

        logger.info("=============================================");
        logger.info("Keycloak Sandbox Provider 初始化");
        logger.info("运行时 Keycloak 版本: {}", runtimeVersion);
        logger.info("Keycloak 资源版本: {}", Version.RESOURCE_VERSION);
        logger.info("Keycloak 构建时间: {}", Version.BUILD_TIME);
        logger.info("Keycloak Git 修订: {}", Version.GIT_REVISION);
        logger.info("=============================================");

        // 版本一致性校验
        validateVersionCompatibility(runtimeVersion);

        return new SandboxProvider(runtimeVersion);
    }

    /**
     * 校验运行时版本与编译时版本的兼容性
     */
    private void validateVersionCompatibility(String runtimeVersion) {
        // 提取主版本号和次版本号(忽略补丁版本)
        String runtimeMajorMinor = extractMajorMinor(runtimeVersion);
        String compileMajorMinor = extractMajorMinor(COMPILE_TIME_VERSION);

        if (!runtimeMajorMinor.equals(compileMajorMinor)) {
            logger.warn("版本兼容性警告:");
            logger.warn("  编译时版本: {}", COMPILE_TIME_VERSION);
            logger.warn("  运行时版本: {}", runtimeVersion);
            logger.warn("  主版本号不匹配,可能出现兼容性问题!");
        } else {
            logger.info("版本兼容性校验通过 ({} ~ {})",
                COMPILE_TIME_VERSION, runtimeVersion);
        }
    }

    /**
     * 提取版本号的主版本号和次版本号
     * 例如: "26.6.1" -> "26.6"
     */
    private String extractMajorMinor(String version) {
        if (version == null || version.isEmpty()) {
            return "unknown";
        }
        int secondDot = version.indexOf('.', version.indexOf('.') + 1);
        return secondDot > 0 ? version.substring(0, secondDot) : version;
    }

    @Override
    public void init(Spi config) {
        logger.info("SandboxProviderFactory 初始化完成");
    }

    @Override
    public void postInit(Spi config) {
        logger.info("SandboxProviderFactory 后置初始化完成");
    }

    @Override
    public void close() {
        logger.info("SandboxProviderFactory 关闭");
    }

    @Override
    public String getId() {
        return "sandbox";
    }
}

4.2 编译时 vs 运行时版本

理解编译时版本和运行时版本的区别是 Keycloak SPI 开发中的关键知识点。这两个概念虽然都涉及"版本",但它们的来源、用途和可靠性完全不同。

编译时版本(Compile-time Version)。 编译时版本是指 SPI 代码在编译时所依赖的 Keycloak 库的版本。它决定了 SPI 可以使用哪些 API、哪些类和方法。

编译时版本的决定因素:

┌──────────────────────────────────────────────────────┐
│                                                      │
│  父 POM                                              │
│  ┌────────────────────────────────┐                  │
│  │ <keycloak.version>26.6.1</     │                  │
│  │ keycloak.version>              │                  │
│  └──────────────┬─────────────────┘                  │
│                 │                                    │
│                 ▼                                    │
│  Maven 依赖解析                                      │
│  ┌────────────────────────────────┐                  │
│  │ 下载 keycloak-server-spi-     │                  │
│  │ 26.6.1.jar 到本地仓库          │                  │
│  │ 下载 keycloak-services-       │                  │
│  │ 26.6.1.jar 到本地仓库          │                  │
│  └──────────────┬─────────────────┘                  │
│                 │                                    │
│                 ▼                                    │
│  Java 编译器                                          │
│  ┌────────────────────────────────┐                  │
│  │ 使用 26.6.1 版本的 jar 进行    │                  │
│  │ 编译                          │                  │
│  │ - 检查方法签名是否匹配         │                  │
│  │ - 检查类是否存在               │                  │
│  │ - 生成字节码                   │                  │
│  └────────────────────────────────┘                  │
│                                                      │
│  编译时版本 = 26.6.1                                 │
│                                                      │
└──────────────────────────────────────────────────────┘

运行时版本(Runtime Version)。 运行时版本是指 SPI 实际部署和运行的 Keycloak 服务器的版本。它通过 org.keycloak.common.Version.VERSION 获取。

运行时版本的获取路径:

┌──────────────────────────────────────────────────────┐
│                                                      │
│  Keycloak 服务器启动                                  │
│  ┌────────────────────────────────┐                  │
│  │ 1. 加载 keycloak-common.jar    │                  │
│  │ 2. 读取 Version.VERSION 常量   │                  │
│  │ 3. 该常量在 Keycloak 构建时    │                  │
│  │    由 Maven 注入               │                  │
│  └──────────────┬─────────────────┘                  │
│                 │                                    │
│                 ▼                                    │
│  SPI Provider 加载                                   │
│  ┌────────────────────────────────┐                  │
│  │ 1. Keycloak 发现 SPI Provider  │                  │
│  │ 2. 调用 ProviderFactory.create()│                  │
│  │ 3. Provider 中调用             │                  │
│  │    Version.VERSION 获取版本    │                  │
│  └────────────────────────────────┘                  │
│                                                      │
│  运行时版本 = Keycloak 服务器的实际版本                │
│  (可能等于、高于或低于编译时版本)                    │
│                                                      │
└──────────────────────────────────────────────────────┘

版本匹配的几种情况。 编译时版本与运行时版本的关系决定了 SPI 的行为:

版本匹配情况分析:

情况 1:版本完全一致(理想情况)
┌─────────────────────────────────────────┐
│ 编译时版本: 26.6.1                      │
│ 运行时版本: 26.6.1                      │
│                                         │
│ 结果: 完美兼容                           │
│ 风险: 无                                │
│ 建议: 这是推荐的生产环境配置              │
└─────────────────────────────────────────┘

情况 2:运行时版本高于编译时版本(向前兼容)
┌─────────────────────────────────────────┐
│ 编译时版本: 26.6.1                      │
│ 运行时版本: 26.7.0                      │
│                                         │
│ 结果: 通常兼容(Keycloak 较好的向后兼容) │
│ 风险: 低                                │
│ 注意: 新版本可能废弃了某些 API           │
│ 建议: 测试所有 SPI 功能后升级编译版本     │
└─────────────────────────────────────────┘

情况 3:运行时版本低于编译时版本(向后不兼容)
┌─────────────────────────────────────────┐
│ 编译时版本: 26.6.1                      │
│ 运行时版本: 26.5.0                      │
│                                         │
│ 结果: 可能不兼容                         │
│ 风险: 高                                │
│ 注意: 编译时使用的新 API 在运行时不存在   │
│ 建议: 降级编译版本或升级运行时版本        │
└─────────────────────────────────────────┘

情况 4:主版本号不同(严重不兼容)
┌─────────────────────────────────────────┐
│ 编译时版本: 26.6.1                      │
│ 运行时版本: 25.0.0                      │
│                                         │
│ 结果: 几乎必定不兼容                     │
│ 风险: 极高                              │
│ 注意: WildFly → Quarkus 架构变更         │
│ 建议: 必须重新编译 SPI                   │
└─────────────────────────────────────────┘

4.3 版本号在沙箱启动中的使用

keycloak-sandbox 项目的核心功能之一是自动化地启动 Keycloak 沙箱环境。在这个过程中,版本号扮演着关键角色:它决定了使用哪个版本的 Keycloak Docker 镜像,以及如何配置沙箱环境。

Docker 沙箱的版本使用流程。 在 Docker 模式下,keycloak-sandbox 通过以下步骤使用版本号:

java
package cc.bima.keycloak.sandbox.server;

import org.keycloak.common.Version;

import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * Keycloak 沙箱服务器启动器
 * 负责动态生成配置文件并启动 Keycloak 沙箱环境
 */
public class KeycloakServerStart {

    private static final String DOCKER_IMAGE_TEMPLATE =
        "quay.io/keycloak/keycloak:%s";

    private final String version;
    private final Path workDir;

    public KeycloakServerStart(Path workDir) {
        // 从 Keycloak 运行时 API 获取版本号
        this.version = Version.VERSION;
        this.workDir = workDir;
    }

    /**
     * 启动 Docker 模式的 Keycloak 沙箱
     */
    public void startDockerSandbox() throws IOException {
        System.out.println("========================================");
        System.out.println("  Keycloak Sandbox 启动中...");
        System.out.println("  Keycloak 版本: " + version);
        System.out.println("========================================");

        // 1. 准备工作目录
        prepareWorkDirectory();

        // 2. 动态生成 docker-compose.yml
        generateDockerComposeFile();

        // 3. 拉取 Docker 镜像
        pullDockerImage();

        // 4. 启动容器
        startContainers();

        // 5. 等待健康检查通过
        waitForHealthCheck();

        System.out.println("Keycloak 沙箱启动完成!");
        System.out.println("管理控制台: http://localhost:8080/admin");
        System.out.println("账号: admin / admin");
    }

    /**
     * 动态生成 docker-compose.yml
     * 使用运行时版本号替换镜像 tag
     */
    private void generateDockerComposeFile() throws IOException {
        String dockerImage = String.format(DOCKER_IMAGE_TEMPLATE, version);
        String containerName = "keycloak-sandbox-" + version;

        // 读取模板文件(由 Maven Resource Filtering 预处理)
        String template = loadTemplate("docker-compose.yml");

        // 用运行时版本号再次确认/替换
        String content = template
            .replace("${keycloak_version}", version)
            .replace("${KEYCLOAK_IMAGE}", dockerImage)
            .replace("${CONTAINER_NAME}", containerName);

        // 写入工作目录
        Path composeFile = workDir.resolve("docker-compose.yml");
        Files.writeString(composeFile, content, StandardCharsets.UTF_8);

        System.out.println("已生成 docker-compose.yml");
        System.out.println("  Docker 镜像: " + dockerImage);
        System.out.println("  容器名称: " + containerName);
    }

    /**
     * 拉取指定版本的 Docker 镜像
     */
    private void pullDockerImage() throws IOException {
        String dockerImage = String.format(DOCKER_IMAGE_TEMPLATE, version);

        System.out.println("正在拉取 Docker 镜像: " + dockerImage);
        ProcessBuilder pb = new ProcessBuilder(
            "docker", "pull", dockerImage
        );
        pb.inheritIO();
        Process process = pb.start();
        try {
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new RuntimeException(
                    "Docker 镜像拉取失败: " + dockerImage
                );
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("镜像拉取被中断", e);
        }
        System.out.println("Docker 镜像拉取完成");
    }

    /**
     * 启动 Docker Compose 容器
     */
    private void startContainers() throws IOException {
        System.out.println("正在启动容器...");
        ProcessBuilder pb = new ProcessBuilder(
            "docker-compose", "-f",
            workDir.resolve("docker-compose.yml").toString(),
            "up", "-d"
        );
        pb.inheritIO();
        Process process = pb.start();
        try {
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new RuntimeException("容器启动失败");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("容器启动被中断", e);
        }
    }

    /**
     * 等待 Keycloak 健康检查通过
     */
    private void waitForHealthCheck() throws IOException {
        String healthUrl = "http://localhost:8080/health/ready";
        int maxRetries = 30;
        int retryInterval = 5000; // 5 秒

        System.out.println("等待 Keycloak 健康检查通过...");
        for (int i = 0; i < maxRetries; i++) {
            try {
                ProcessBuilder pb = new ProcessBuilder(
                    "curl", "-sf", healthUrl
                );
                Process process = pb.start();
                int exitCode = process.waitFor();
                if (exitCode == 0) {
                    System.out.println("Keycloak 已就绪!");
                    return;
                }
            } catch (Exception e) {
                // 忽略异常,继续重试
            }
            try {
                Thread.sleep(retryInterval);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        throw new RuntimeException("Keycloak 健康检查超时");
    }

    private void prepareWorkDirectory() throws IOException {
        Files.createDirectories(workDir);
    }

    private String loadTemplate(String name) throws IOException {
        InputStream is = getClass().getClassLoader()
            .getResourceAsStream(name);
        if (is == null) {
            throw new IOException("模板文件不存在: " + name);
        }
        return new String(is.readAllBytes(), StandardCharsets.UTF_8);
    }
}

Release 沙箱的版本使用流程。 除了 Docker 模式,keycloak-sandbox 还支持直接下载 Keycloak 发行版的方式启动沙箱:

java
package cc.bima.keycloak.sandbox.server;

import org.keycloak.common.Version;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

/**
 * Keycloak 发行版沙箱启动器
 * 通过下载官方发行版 ZIP 包启动沙箱
 */
public class KeycloakReleaseStart {

    private static final String DOWNLOAD_URL_TEMPLATE =
        "https://github.com/keycloak/keycloak/releases/download/%s/keycloak-%s.zip";

    private final String version;
    private final Path workDir;

    public KeycloakReleaseStart(Path workDir) {
        this.version = Version.VERSION;
        this.workDir = workDir;
    }

    /**
     * 启动 Release 模式的 Keycloak 沙箱
     */
    public void startReleaseSandbox() throws IOException, InterruptedException {
        System.out.println("========================================");
        System.out.println("  Keycloak Release Sandbox 启动中...");
        System.out.println("  Keycloak 版本: " + version);
        System.out.println("========================================");

        // 1. 下载 Keycloak 发行版
        Path zipFile = downloadRelease();

        // 2. 解压发行版
        Path keycloakHome = extractRelease(zipFile);

        // 3. 配置 Keycloak
        configureKeycloak(keycloakHome);

        // 4. 启动 Keycloak(开发模式)
        startKeycloak(keycloakHome);

        System.out.println("Keycloak Release 沙箱启动完成!");
    }

    /**
     * 下载指定版本的 Keycloak 发行版
     */
    private Path downloadRelease() throws IOException, InterruptedException {
        String downloadUrl = String.format(DOWNLOAD_URL_TEMPLATE,
            version, version);

        Path zipFile = workDir.resolve("keycloak-" + version + ".zip");

        if (Files.exists(zipFile)) {
            System.out.println("发行版已存在,跳过下载: " + zipFile);
            return zipFile;
        }

        System.out.println("正在下载 Keycloak " + version + " 发行版...");
        System.out.println("下载地址: " + downloadUrl);

        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofMinutes(10))
            .build();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(downloadUrl))
            .timeout(Duration.ofMinutes(30))
            .GET()
            .build();

        HttpResponse<Path> response = client.send(request,
            HttpResponse.BodyHandlers.ofFile(zipFile));

        if (response.statusCode() != 200) {
            throw new RuntimeException(
                "下载失败,HTTP 状态码: " + response.statusCode()
            );
        }

        System.out.println("下载完成: " + zipFile);
        return zipFile;
    }

    /**
     * 解压发行版 ZIP 包
     */
    private Path extractRelease(Path zipFile) throws IOException, InterruptedException {
        Path extractDir = workDir.resolve("keycloak-" + version);

        if (Files.exists(extractDir)) {
            System.out.println("发行版已解压,跳过解压: " + extractDir);
            return extractDir;
        }

        System.out.println("正在解压发行版...");
        ProcessBuilder pb = new ProcessBuilder(
            "unzip", "-q", zipFile.toString(),
            "-d", workDir.toString()
        );
        pb.inheritIO();
        Process process = pb.start();
        int exitCode = process.waitFor();
        if (exitCode != 0) {
            throw new RuntimeException("解压失败");
        }

        System.out.println("解压完成: " + extractDir);
        return extractDir;
    }

    /**
     * 启动 Keycloak(开发模式)
     */
    private void startKeycloak(Path keycloakHome) throws IOException {
        String os = System.getProperty("os.name").toLowerCase();
        String scriptExtension = os.contains("win") ? ".bat" : ".sh";
        String startScript = keycloakHome.resolve("bin")
            .resolve("kc" + scriptExtension).toString();

        System.out.println("正在启动 Keycloak (开发模式)...");
        System.out.println("启动脚本: " + startScript);

        ProcessBuilder pb = new ProcessBuilder(
            startScript, "start-dev"
        );
        pb.directory(keycloakHome.toFile());
        pb.inheritIO();

        // 设置环境变量
        pb.environment().put("KC_DB", "h2");
        pb.environment().put("KEYCLOAK_ADMIN", "admin");
        pb.environment().put("KEYCLOAK_ADMIN_PASSWORD", "admin");

        pb.start();
    }

    private void configureKeycloak(Path keycloakHome) {
        // 可以在这里添加自定义配置
        System.out.println("配置 Keycloak: " + keycloakHome);
    }
}

4.4 版本不匹配检测

版本不匹配检测是 keycloak-sandbox 项目中一个重要的安全机制。它在 SPI 加载阶段主动检测编译时版本与运行时版本是否一致,在问题造成实际损害之前发出警告。

版本不匹配检测的实现。 keycloak-sandbox 提供了一个专门的版本检测工具类:

java
package cc.bima.keycloak.sandbox.util;

import org.keycloak.common.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 版本兼容性检测工具
 * 用于检测编译时版本与运行时版本是否兼容
 */
public final class VersionCompatibilityChecker {

    private static final Logger logger =
        LoggerFactory.getLogger(VersionCompatibilityChecker.class);

    private static final Pattern VERSION_PATTERN =
        Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(-.*)?$");

    private VersionCompatibilityChecker() {
        // 工具类,禁止实例化
    }

    /**
     * 版本兼容性检测结果
     */
    public enum Compatibility {
        /** 版本完全一致 */
        EXACT_MATCH,
        /** 主版本号和次版本号一致,补丁版本不同 */
        PATCH_DIFFERENT,
        /** 次版本号不同,主版本号一致 */
        MINOR_DIFFERENT,
        /** 主版本号不同 */
        MAJOR_DIFFERENT,
        /** 版本号格式无法解析 */
        UNPARSEABLE
    }

    /**
     * 检测编译时版本与运行时版本的兼容性
     *
     * @param compileTimeVersion 编译时的 Keycloak 版本
     * @return 兼容性检测结果
     */
    public static Compatibility check(String compileTimeVersion) {
        String runtimeVersion = Version.VERSION;

        logger.debug("版本兼容性检测:");
        logger.debug("  编译时版本: {}", compileTimeVersion);
        logger.debug("  运行时版本: {}", runtimeVersion);

        if (compileTimeVersion == null || compileTimeVersion.isEmpty()) {
            logger.warn("编译时版本未定义,无法进行兼容性检测");
            return Compatibility.UNPARSEABLE;
        }

        if (runtimeVersion == null || runtimeVersion.isEmpty()) {
            logger.warn("运行时版本未定义,无法进行兼容性检测");
            return Compatibility.UNPARSEABLE;
        }

        // 完全一致
        if (compileTimeVersion.equals(runtimeVersion)) {
            logger.info("版本完全一致: {}", runtimeVersion);
            return Compatibility.EXACT_MATCH;
        }

        // 解析版本号
        int[] compileParts = parseVersion(compileTimeVersion);
        int[] runtimeParts = parseVersion(runtimeVersion);

        if (compileParts == null || runtimeParts == null) {
            logger.warn("版本号格式无法解析");
            return Compatibility.UNPARSEABLE;
        }

        // 主版本号不同
        if (compileParts[0] != runtimeParts[0]) {
            logger.error("主版本号不匹配!");
            logger.error("  编译时: {}.x.x", compileParts[0]);
            logger.error("  运行时: {}.x.x", runtimeParts[0]);
            logger.error("  这可能导致严重的兼容性问题!");
            return Compatibility.MAJOR_DIFFERENT;
        }

        // 次版本号不同
        if (compileParts[1] != runtimeParts[1]) {
            logger.warn("次版本号不匹配!");
            logger.warn("  编译时: {}.{}.x", compileParts[0], compileParts[1]);
            logger.warn("  运行时: {}.{}.x", runtimeParts[0], runtimeParts[1]);
            logger.warn("  部分功能可能不兼容");
            return Compatibility.MINOR_DIFFERENT;
        }

        // 补丁版本不同
        logger.info("补丁版本不同(通常兼容):");
        logger.info("  编译时: {}.{}.{}", compileParts[0], compileParts[1], compileParts[2]);
        logger.info("  运行时: {}.{}.{}", runtimeParts[0], runtimeParts[1], runtimeParts[2]);
        return Compatibility.PATCH_DIFFERENT;
    }

    /**
     * 解析版本号为数字数组 [major, minor, patch]
     */
    private static int[] parseVersion(String version) {
        Matcher matcher = VERSION_PATTERN.matcher(version);
        if (!matcher.matches()) {
            return null;
        }
        try {
            return new int[]{
                Integer.parseInt(matcher.group(1)),
                Integer.parseInt(matcher.group(2)),
                Integer.parseInt(matcher.group(3))
            };
        } catch (NumberFormatException e) {
            return null;
        }
    }

    /**
     * 获取版本兼容性报告
     */
    public static String getCompatibilityReport(String compileTimeVersion) {
        Compatibility compatibility = check(compileTimeVersion);

        StringBuilder report = new StringBuilder();
        report.append("\n");
        report.append("╔══════════════════════════════════════════╗\n");
        report.append("║     Keycloak 版本兼容性检测报告          ║\n");
        report.append("╠══════════════════════════════════════════╣\n");
        report.append(String.format("║  编译时版本: %-28s ║\n", compileTimeVersion));
        report.append(String.format("║  运行时版本: %-28s ║\n", Version.VERSION));
        report.append("╠══════════════════════════════════════════╣\n");
        report.append(String.format("║  检测结果:   %-28s ║\n", compatibility));
        report.append("╚══════════════════════════════════════════╝\n");

        return report.toString();
    }
}

在 SPI 生命周期中集成版本检测。 版本检测应该在 SPI Provider 的初始化阶段执行,确保在任何业务逻辑执行之前发现版本问题:

java
/**
 * 在 ProviderFactory 的 init 方法中执行版本检测
 */
@Override
public void init(Spi config) {
    // 获取编译时版本(由 Maven Resource Filtering 注入)
    String compileTimeVersion = "${keycloak_version}";

    // 执行版本兼容性检测
    VersionCompatibilityChecker.Compatibility compatibility =
        VersionCompatibilityChecker.check(compileTimeVersion);

    // 根据检测结果采取不同的策略
    switch (compatibility) {
        case EXACT_MATCH:
        case PATCH_DIFFERENT:
            // 版本兼容,正常启动
            logger.info("版本兼容性检测通过");
            break;

        case MINOR_DIFFERENT:
            // 次版本号不同,发出警告但继续运行
            logger.warn("版本兼容性检测警告:次版本号不匹配");
            logger.warn("建议升级编译时版本以匹配运行时版本");
            break;

        case MAJOR_DIFFERENT:
            // 主版本号不同,拒绝启动
            throw new IllegalStateException(
                "版本兼容性检测失败:主版本号不匹配!\n" +
                "编译时版本: " + compileTimeVersion + "\n" +
                "运行时版本: " + Version.VERSION + "\n" +
                "请确保 SPI 编译版本与 Keycloak 服务器版本一致。"
            );

        case UNPARSEABLE:
            // 版本号无法解析,发出警告
            logger.warn("版本号格式无法解析,跳过兼容性检测");
            break;
    }
}

版本检测的架构位置。 版本检测应该放在 SPI 生命周期的最早阶段,确保在任何业务逻辑执行之前发现问题:

SPI 生命周期中的版本检测位置:

ProviderFactory.init()

    ├──▶ 版本兼容性检测(最早执行)
    │    ├── 读取编译时版本
    │    ├── 获取运行时版本
    │    ├── 比对版本号
    │    └── 根据结果决定是否继续

    ├──▶ 加载配置
    │    └── 读取 SPI 配置参数

    ├──▶ 初始化资源
    │    └── 建立数据库连接等

    └──▶ 准备就绪
         └── 等待 create() 调用

ProviderFactory.create()

    └──▶ 创建 Provider 实例
         └── 此时版本已确认兼容

ProviderFactory.postInit()

    └──▶ 后置初始化
         └── 注册事件监听器等

第五章 版本切换与多版本共存

5.1 一键版本切换流程

keycloak-sandbox 项目的全局版本控制机制最直接的价值在于:只需修改一个属性值,即可完成整个项目的版本切换。这种"一键切换"的能力极大地简化了版本管理工作。

一键版本切换的完整流程。 以下是 keycloak-sandbox 项目中切换 Keycloak 版本的完整步骤:

一键版本切换流程:

Step 1: 修改父 POM 中的版本属性
┌─────────────────────────────────────────────────────┐
│ <!-- 父 POM pom.xml -->                              │
│ <properties>                                        │
│     <!-- 修改这一行即可 -->                           │
│-    <keycloak.version>26.6.1</keycloak.version>     │
│+    <keycloak.version>26.7.0</keycloak.version>     │
│ </properties>                                       │
└─────────────────────────────────────────────────────┘

Step 2: Maven 自动传播版本到所有子模块
┌─────────────────────────────────────────────────────┐
│ mvn clean install                                   │
│                                                     │
│ [INFO] keycloak-sandbox ............................ │
│ [INFO] keycloak-sandbox-common .................... │
│ [INFO]   keycloak-server-spi ......... 26.7.0      │
│ [INFO]   keycloak-services ........... 26.7.0      │
│ [INFO] keycloak-sandbox-spi ...................... │
│ [INFO]   keycloak-server-spi ......... 26.7.0      │
│ [INFO] keycloak-sandbox-provider ................. │
│ [INFO]   keycloak-server-spi ......... 26.7.0      │
│ [INFO]   keycloak-services ........... 26.7.0      │
│ [INFO] keycloak-sandbox-dist ..................... │
│ [INFO]   所有模块使用统一版本 26.7.0               │
└─────────────────────────────────────────────────────┘

Step 3: Resource Filtering 自动更新资源文件
┌─────────────────────────────────────────────────────┐
│ target/classes/docker-compose.yml                   │
│                                                     │
│ image: quay.io/keycloak/keycloak:26.7.0            │
│ container_name: keycloak-sandbox-26.7.0            │
│                                                     │
│ (版本号已自动替换)                                 │
└─────────────────────────────────────────────────────┘

Step 4: 运行时使用新版本
┌─────────────────────────────────────────────────────┐
│ Version.VERSION → "26.7.0"                         │
│ Docker 镜像 → quay.io/keycloak/keycloak:26.7.0    │
│ 下载 URL → .../keycloak-26.7.0.zip                 │
└─────────────────────────────────────────────────────┘

使用 Maven Profile 实现版本切换。 对于需要频繁在不同版本之间切换的开发场景,可以使用 Maven Profile 来预定义常用版本:

xml
<!-- 父 POM 中的 Profile 定义 -->
<profiles>
    <!-- Keycloak 26.6.x 系列 -->
    <profile>
        <id>kc-26.6</id>
        <properties>
            <keycloak.version>26.6.1</keycloak.version>
        </properties>
    </profile>

    <!-- Keycloak 26.7.x 系列 -->
    <profile>
        <id>kc-26.7</id>
        <properties>
            <keycloak.version>26.7.0</keycloak.version>
        </properties>
    </profile>

    <!-- Keycloak 27.0.x 系列 -->
    <profile>
        <id>kc-27.0</id>
        <properties>
            <keycloak.version>27.0.0</keycloak.version>
        </properties>
    </profile>

    <!-- 最新稳定版 -->
    <profile>
        <id>kc-latest</id>
        <properties>
            <keycloak.version>27.2.0</keycloak.version>
        </properties>
    </profile>
</profiles>

使用 Profile 切换版本:

bash
# 使用 Keycloak 26.6.1
mvn clean install -Pkc-26.6

# 使用 Keycloak 27.0.0
mvn clean install -Pkc-27.0

# 使用最新稳定版
mvn clean install -Pkc-latest

# 查看可用的 Profile
mvn help:all-profiles

使用命令行参数临时切换版本。 如果只是临时测试某个版本,无需修改 POM 文件,直接通过命令行参数覆盖:

bash
# 临时使用 Keycloak 27.1.0(不修改 POM 文件)
mvn clean install -Dkeycloak.version=27.1.0

# 在 CI/CD 中使用环境变量
mvn clean deploy -Dkeycloak.version=${KEYCLOAK_VERSION}

5.2 多版本沙箱并行

在某些场景下,开发者需要同时运行多个不同版本的 Keycloak 沙箱。例如,在验证 SPI 在不同版本上的兼容性时,或者在迁移过程中需要同时对比新旧版本的行为差异。keycloak-sandbox 的版本控制机制支持多版本沙箱并行运行。

多版本并行的架构设计。 多版本沙箱并行的核心挑战在于资源隔离:每个版本的 Keycloak 实例需要独立的端口、数据存储和容器名称。

多版本沙箱并行架构:

┌──────────────────────────────────────────────────────────────┐
│                    keycloak-sandbox                           │
│                                                              │
│  ┌─────────────────┐  ┌─────────────────┐  ┌──────────────┐ │
│  │  KC 26.6.1      │  │  KC 26.7.0      │  │  KC 27.0.0   │ │
│  │                 │  │                 │  │              │ │
│  │  端口: 8081     │  │  端口: 8082     │  │  端口: 8083  │ │
│  │  DB: kc_2661    │  │  DB: kc_2670    │  │  DB: kc_2700 │ │
│  │  容器:          │  │  容器:          │  │  容器:        │ │
│  │  kc-sandbox-    │  │  kc-sandbox-    │  │  kc-sandbox- │ │
│  │  26.6.1         │  │  26.7.0         │  │  27.0.0      │ │
│  └─────────────────┘  └─────────────────┘  └──────────────┘ │
│         │                    │                    │          │
│         └────────────────────┼────────────────────┘          │
│                              │                               │
│                    ┌─────────▼──────────┐                    │
│                    │  共享 SPI Provider │                    │
│                    │  (按版本编译)     │                    │
│                    └────────────────────┘                    │
└──────────────────────────────────────────────────────────────┘

多版本并行的 Docker Compose 配置。 通过为每个版本生成独立的 Docker Compose 文件来实现并行运行:

java
/**
 * 多版本沙箱管理器
 */
public class MultiVersionSandboxManager {

    private final Map<String, Path> sandboxInstances = new ConcurrentHashMap<>();

    /**
     * 启动指定版本的沙箱
     */
    public void startSandbox(String version, int portOffset) throws IOException {
        Path sandboxDir = Path.of("sandboxes", "kc-" + version);
        Files.createDirectories(sandboxDir);

        // 生成该版本专用的 docker-compose.yml
        generateVersionedComposeFile(sandboxDir, version, portOffset);

        // 启动容器
        ProcessBuilder pb = new ProcessBuilder(
            "docker-compose", "-f",
            sandboxDir.resolve("docker-compose.yml").toString(),
            "up", "-d"
        );
        pb.inheritIO();
        pb.start();

        sandboxInstances.put(version, sandboxDir);
        System.out.println("已启动 Keycloak " + version + " 沙箱");
        System.out.println("  管理控制台: http://localhost:" +
            (8080 + portOffset) + "/admin");
    }

    /**
     * 生成版本专用的 docker-compose.yml
     */
    private void generateVersionedComposeFile(
            Path dir, String version, int portOffset) throws IOException {

        int httpPort = 8080 + portOffset;
        int httpsPort = 8443 + portOffset;
        int dbPort = 5432 + portOffset;
        String dbName = "kc_" + version.replace(".", "");

        String compose = String.format("""
            version: '3.8'

            services:
              keycloak-%s:
                image: quay.io/keycloak/keycloak:%s
                container_name: kc-sandbox-%s
                ports:
                  - "%d:8080"
                  - "%d:8443"
                environment:
                  KC_DB: postgres
                  KC_DB_URL: jdbc:postgresql://postgres-%s:%d/%s
                  KC_DB_USERNAME: keycloak
                  KC_DB_PASSWORD: keycloak
                  KEYCLOAK_ADMIN: admin
                  KEYCLOAK_ADMIN_PASSWORD: admin
                command: start-dev
                depends_on:
                  - postgres-%s

              postgres-%s:
                image: postgres:16
                container_name: kc-postgres-%s
                environment:
                  POSTGRES_DB: %s
                  POSTGRES_USER: keycloak
                  POSTGRES_PASSWORD: keycloak
                ports:
                  - "%d:5432"

            networks:
              default:
                name: kc-network-%s
            """,
            version, version, version,
            httpPort, httpsPort,
            version, dbPort, dbName,
            version, version, version, dbName,
            dbPort, version
        );

        Files.writeString(
            dir.resolve("docker-compose.yml"),
            compose, StandardCharsets.UTF_8
        );
    }

    /**
     * 停止指定版本的沙箱
     */
    public void stopSandbox(String version) throws IOException {
        Path sandboxDir = sandboxInstances.get(version);
        if (sandboxDir == null) {
            throw new IllegalArgumentException("未找到版本: " + version);
        }

        ProcessBuilder pb = new ProcessBuilder(
            "docker-compose", "-f",
            sandboxDir.resolve("docker-compose.yml").toString(),
            "down", "-v"
        );
        pb.inheritIO();
        pb.start();

        sandboxInstances.remove(version);
        System.out.println("已停止 Keycloak " + version + " 沙箱");
    }

    /**
     * 列出所有运行中的沙箱
     */
    public void listSandboxes() {
        System.out.println("运行中的沙箱:");
        sandboxInstances.forEach((version, dir) -> {
            System.out.println("  Keycloak " + version + " - " + dir);
        });
    }
}

5.3 版本兼容性矩阵

在管理多个 Keycloak 版本时,维护一份版本兼容性矩阵是至关重要的。这份矩阵记录了不同 Keycloak 版本之间的 API 变更、SPI 接口变化以及已知的兼容性问题。

Keycloak SPI 兼容性矩阵示例。 以下是 keycloak-sandbox 项目维护的兼容性矩阵:

Keycloak SPI 兼容性矩阵(keycloak-sandbox 项目)

┌────────────┬────────────┬────────────┬────────────┬────────────┐
│ SPI 接口    │  KC 25.x   │  KC 26.x   │  KC 27.x   │  变更说明   │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│Authenticator│ 兼容       │ 兼容       │ 兼容       │ 无重大变更  │
│            │            │            │            │            │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│EventListen │ 兼容       │ 事件payload│ 兼容       │ 26.x 事件   │
│erProvider  │            │ 字段变更    │            │ 细节调整    │
│            │            │            │            │            │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│ProtocolMap │ 兼容       │ 兼容       │ 方法签名   │ 27.x 新增   │
│per          │            │            │ 变更       │ 参数        │
│            │            │            │            │            │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│UserStorage │ 兼容       │ 兼容       │ 兼容       │ 无重大变更  │
│Provider    │            │            │            │            │
│            │            │            │            │            │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│ClientIniti │ 兼容       │ 25→26 不   │ 兼容       │ 26.x 修复   │
│atedNodeProv│            │ 兼容       │            │ 初始化顺序  │
│ider        │            │            │            │            │
└────────────┴────────────┴────────────┴────────────┴────────────┘

自动化兼容性测试。 keycloak-sandbox 项目可以通过自动化测试来验证 SPI 在不同 Keycloak 版本上的兼容性:

java
/**
 * SPI 跨版本兼容性测试基类
 */
public abstract class AbstractCrossVersionTest {

    protected static final String[] TEST_VERSIONS = {
        "25.0.0", "26.0.0", "26.6.1", "27.0.0"
    };

    /**
     * 在所有目标版本上运行兼容性测试
     */
    @ParameterizedTest
    @ValueSource(strings = {"25.0.0", "26.0.0", "26.6.1", "27.0.0"})
    void testCompatibility(String keycloakVersion) {
        // 1. 启动指定版本的 Keycloak 容器
        String containerId = startKeycloakContainer(keycloakVersion);

        try {
            // 2. 部署 SPI Provider
            deployProvider(containerId);

            // 3. 执行功能测试
            runFunctionalTests(keycloakVersion);

            // 4. 验证版本兼容性
            assertVersionCompatibility(keycloakVersion);

        } finally {
            // 5. 清理容器
            stopKeycloakContainer(containerId);
        }
    }

    protected abstract void runFunctionalTests(String version);

    private String startKeycloakContainer(String version) {
        // 使用 Testcontainers 启动 Keycloak 容器
        return "container-" + version;
    }

    private void deployProvider(String containerId) {
        // 将编译好的 SPI JAR 复制到容器的 providers 目录
    }

    private void stopKeycloakContainer(String containerId) {
        // 停止并删除容器
    }

    private void assertVersionCompatibility(String version) {
        // 验证 SPI 在该版本上的行为是否符合预期
    }
}

5.4 SPI 跨版本适配策略

当需要在多个 Keycloak 版本上运行同一个 SPI 时,跨版本适配策略是必不可少的。keycloak-sandbox 项目采用了几种常见的适配模式。

策略一:反射适配。 当不同版本的 API 存在差异时,可以使用 Java 反射来动态调用方法:

java
/**
 * 反射适配器 - 处理不同版本的方法签名差异
 */
public class ReflectionAdapter {

    private static final Logger logger =
        LoggerFactory.getLogger(ReflectionAdapter.class);

    /**
     * 安全调用方法,处理不同版本的方法签名差异
     */
    public static <T> T safeInvoke(Object target, String methodName,
            Class<?>[] paramTypes, Object[] args, T defaultValue) {
        try {
            Method method = target.getClass().getMethod(methodName, paramTypes);
            @SuppressWarnings("unchecked")
            T result = (T) method.invoke(target, args);
            return result;
        } catch (NoSuchMethodException e) {
            logger.debug("方法不存在(可能是版本差异): {}", methodName);
            return defaultValue;
        } catch (Exception e) {
            logger.warn("方法调用失败: {}", methodName, e);
            return defaultValue;
        }
    }

    /**
     * 检查类是否存在(用于判断 Keycloak 版本)
     */
    public static boolean classExists(String className) {
        try {
            Class.forName(className);
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }

    /**
     * 获取 Keycloak 版本特性
     */
    public static KeycloakFeatures detectFeatures() {
        KeycloakFeatures features = new KeycloakFeatures();

        // 通过类的存在性判断版本特性
        features.setHasQuarkusRuntime(
            classExists("io.quarkus.runtime.Quarkus")
        );
        features.setHasNewEventModel(
            classExists("org.keycloak.events.EventType$Login")
        );
        features.setHasUpdatedAdminConsole(
            classExists("org.keycloak.admin.ui.KeycloakAdminUI")
        );

        return features;
    }
}

/**
 * Keycloak 版本特性描述
 */
public class KeycloakFeatures {
    private boolean hasQuarkusRuntime;
    private boolean hasNewEventModel;
    private boolean hasUpdatedAdminConsole;

    // getters and setters
}

策略二:版本分支。 根据运行时版本号选择不同的代码路径:

java
/**
 * 版本分支适配器
 */
public class VersionBranchAdapter {

    /**
     * 根据版本选择不同的实现策略
     */
    public EventListenerProvider createEventListener(
            KeycloakSession session) {

        String version = Version.VERSION;
        int majorVersion = extractMajorVersion(version);

        return switch (majorVersion) {
            case 25 -> new LegacyEventListenerProvider(session);
            case 26 -> new ModernEventListenerProvider(session);
            case 27 -> new LatestEventListenerProvider(session);
            default -> throw new UnsupportedOperationException(
                "不支持的 Keycloak 版本: " + version
            );
        };
    }

    private int extractMajorVersion(String version) {
        try {
            return Integer.parseInt(version.split("\\.")[0]);
        } catch (Exception e) {
            throw new IllegalArgumentException(
                "无法解析版本号: " + version, e
            );
        }
    }
}

策略三:适配器模式。 使用经典的适配器模式来统一不同版本的 API 接口:

java
/**
 * 统一的事件数据访问接口
 */
public interface EventDataAccessor {
    String getClientId(Event event);
    String getIpAddress(Event event);
    Map<String, String> getDetails(Event event);
}

/**
 * Keycloak 25.x 的事件数据适配器
 */
public class LegacyEventDataAccessor implements EventDataAccessor {
    @Override
    public String getClientId(Event event) {
        return event.getDetails() != null
            ? event.getDetails().get("client_id") : null;
    }

    @Override
    public String getIpAddress(Event event) {
        return event.getIpAddress();
    }

    @Override
    public Map<String, String> getDetails(Event event) {
        return event.getDetails();
    }
}

/**
 * Keycloak 26.x+ 的事件数据适配器
 */
public class ModernEventDataAccessor implements EventDataAccessor {
    @Override
    public String getClientId(Event event) {
        // 26.x 中 clientId 直接作为事件属性
        return (String) event.getDetail("client_id");
    }

    @Override
    public String getIpAddress(Event event) {
        // 26.x 中 IP 地址的获取方式变更
        return (String) event.getDetail("address");
    }

    @Override
    public Map<String, String> getDetails(Event event) {
        // 26.x 中事件详情的结构发生变化
        Map<String, String> details = new HashMap<>();
        if (event.getDetails() != null) {
            details.putAll(event.getDetails());
        }
        return details;
    }
}

/**
 * 适配器工厂 - 根据版本选择合适的适配器
 */
public class EventDataAccessorFactory {
    public static EventDataAccessor create() {
        int majorVersion = extractMajorVersion(Version.VERSION);

        if (majorVersion >= 26) {
            return new ModernEventDataAccessor();
        } else {
            return new LegacyEventDataAccessor();
        }
    }
}

第六章 版本控制最佳实践

6.1 语义化版本管理

语义化版本控制(Semantic Versioning,简称 SemVer)是软件行业广泛采用的版本号规范。理解 SemVer 对于正确管理 Keycloak 版本至关重要。

语义化版本号的结构。 一个语义化版本号由三部分组成:MAJOR.MINOR.PATCH

语义化版本号结构:

    MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]

    ┌──────┬──────┬──────┬───────────┬────────┐
    │ 26   │  .   │  6   │  .        │  1     │
    └──┬───┘      └──┬───┘           └───┬────┘
       │             │                   │
       ▼             ▼                   ▼
    主版本号      次版本号            补丁版本号
    (Major)      (Minor)            (Patch)

    含义:
    MAJOR - 不兼容的 API 变更
    MINOR - 向后兼容的功能新增
    PATCH - 向后兼容的缺陷修复

    示例:
    26.0.0  - 新的主版本,可能包含不兼容变更
    26.6.0  - 新功能,向后兼容
    26.6.1  - 缺陷修复,向后兼容
    27.0.0-beta1 - 27.0.0 的预发布版本

Keycloak 的版本策略与 SemVer 的差异。 需要注意的是,Keycloak 并非严格遵循 SemVer 规范。以下是主要的差异点:

Keycloak 版本策略 vs 标准 SemVer:

┌──────────────────┬──────────────────┬──────────────────────┐
│ 方面             │ 标准 SemVer      │ Keycloak 实际策略     │
├──────────────────┼──────────────────┼──────────────────────┤
│ 补丁版本         │ 只修复缺陷       │ 可能包含小功能新增    │
│                  │ 不改变 API       │ 可能微调 API          │
├──────────────────┼──────────────────┼──────────────────────┤
│ 次版本           │ 新增向后兼容功能 │ 可能包含不兼容变更    │
│                  │                  │ (内部 API)          │
├──────────────────┼──────────────────┼──────────────────────┤
│ 主版本           │ 不兼容的 API 变更│ 架构级变更            │
│                  │                  │ (如 WildFly→Quarkus)│
├──────────────────┼──────────────────┼──────────────────────┤
│ 预发布版本       │ -alpha, -beta    │ 使用较少              │
│                  │ -rc 后缀         │ 主要通过里程碑发布    │
├──────────────────┼──────────────────┼──────────────────────┤
│ 弃用策略         │ 至少一个主版本   │ 弃用周期较短          │
│                  │ 的过渡期         │ 通常 1-2 个次版本     │
└──────────────────┴──────────────────┴──────────────────────┘

在 keycloak-sandbox 中应用语义化版本管理。 虽然 Keycloak 本身并非严格遵循 SemVer,但 keycloak-sandbox 项目自身应该严格遵循:

xml
<!-- keycloak-sandbox 项目的版本号管理 -->
<properties>
    <!-- 项目自身版本 -->
    <project.version>1.0.0-SNAPSHOT</project.version>

    <!-- 依赖版本 -->
    <keycloak.version>26.6.1</keycloak.version>
</properties>

<!-- 版本号演进规则:
     1.0.0-SNAPSHOT → 1.0.0(首次正式发布)
     1.0.0 → 1.0.1(缺陷修复)
     1.0.1 → 1.1.0(新增功能,如支持新的 SPI 类型)
     1.1.0 → 2.0.0(不兼容变更,如最低 Keycloak 版本要求提升)
-->

6.2 CI/CD 中的版本控制

在持续集成和持续部署(CI/CD)流水线中,版本控制需要特别注意自动化和可重复性。keycloak-sandbox 项目的 CI/CD 配置展示了如何在流水线中管理版本。

GitHub Actions 中的版本控制。 以下是 keycloak-sandbox 项目的 CI/CD 配置示例:

yaml
# .github/workflows/ci.yml
name: Keycloak Sandbox CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  MAVEN_OPTS: -Xmx2g

jobs:
  # 版本一致性检测
  version-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Check version consistency
        run: |
          # 验证所有子模块使用统一的 Keycloak 版本
          EXPECTED_VERSION=$(mvn help:evaluate \
            -Dexpression=keycloak.version -q -DforceStdout)

          echo "期望的 Keycloak 版本: $EXPECTED_VERSION"

          # 检查依赖树
          mvn dependency:tree -DoutputType=text > /tmp/deps.txt

          # 验证所有 Keycloak 依赖使用统一版本
          ACTUAL_VERSIONS=$(grep "org.keycloak" /tmp/deps.txt \
            | grep -oP '\d+\.\d+\.\d+' | sort -u)

          VERSION_COUNT=$(echo "$ACTUAL_VERSIONS" | wc -l)

          if [ "$VERSION_COUNT" -ne 1 ]; then
            echo "::error::发现多个 Keycloak 版本!"
            echo "$ACTUAL_VERSIONS"
            exit 1
          fi

          echo "版本一致性校验通过: $EXPECTED_VERSION"

  # 多版本兼容性测试
  compatibility-test:
    needs: version-check
    runs-on: ubuntu-latest
    strategy:
      matrix:
        keycloak-version: ['25.0.0', '26.0.0', '26.6.1', '27.0.0']
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build with Keycloak ${{ matrix.keycloak-version }}
        run: |
          mvn clean install \
            -Dkeycloak.version=${{ matrix.keycloak-version }} \
            -DskipTests

      - name: Run compatibility tests
        run: |
          mvn test \
            -Dkeycloak.version=${{ matrix.keycloak-version }} \
            -Dtest=CrossVersionCompatibilityTest

  # Docker 镜像构建
  docker-build:
    needs: compatibility-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Get Keycloak version
        id: version
        run: |
          KC_VERSION=$(mvn help:evaluate \
            -Dexpression=keycloak.version -q -DforceStdout)
          echo "kc_version=$KC_VERSION" >> $GITHUB_OUTPUT

      - name: Build and push Docker image
        run: |
          mvn clean install
          docker build \
            -t bima/keycloak-sandbox:${{ steps.version.outputs.kc_version }} \
            -t bima/keycloak-sandbox:latest \
            -f Dockerfile .

版本控制的自动化检查清单。 在 CI/CD 流水线中,以下检查应该自动化执行:

CI/CD 版本控制自动化检查清单:

┌────┬───────────────────────────┬──────────────────────────┐
│ #  │ 检查项                    │ 检查方式                  │
├────┼───────────────────────────┼──────────────────────────┤
│ 1  │ keycloak.version 已定义   │ mvn help:evaluate        │
│    │                           │ -Dexpression=keycloak.   │
│    │                           │ version                  │
├────┼───────────────────────────┼──────────────────────────┤
│ 2  │ 所有子模块版本一致         │ mvn dependency:tree      │
│    │                           │ + grep 声明式检查         │
├────┼───────────────────────────┼──────────────────────────┤
│ 3  │ 无版本冲突                │ mvn enforcer:enforce      │
│    │                           │ dependencyConvergence    │
├────┼───────────────────────────┼──────────────────────────┤
│ 4  │ Resource Filtering 生效   │ 检查 target/classes 中   │
│    │                           │ 的文件内容               │
├────┼───────────────────────────┼──────────────────────────┤
│ 5  │ Docker 镜像 tag 与        │ 对比 pom.xml 和          │
│    │ keycloak.version 一致     │ docker-compose.yml       │
├────┼───────────────────────────┼──────────────────────────┤
│ 6  │ 无 SNAPSHOT 依赖          │ mvn enforcer:enforce      │
│    │ (生产构建)               │ requireReleaseDeps       │
├────┼───────────────────────────┼──────────────────────────┤
│ 7  │ 编译通过                  │ mvn compile              │
├────┼───────────────────────────┼──────────────────────────┤
│ 8  │ 单元测试通过              │ mvn test                 │
├────┼───────────────────────────┼──────────────────────────┤
│ 9  │ 集成测试通过              │ mvn verify               │
└────┴───────────────────────────┴──────────────────────────┘

6.3 版本号命名规范

在 keycloak-sandbox 项目中,版本号命名规范不仅适用于 Keycloak 本身,也适用于项目自身的版本管理。统一的命名规范有助于团队协作和自动化工具的使用。

版本号命名规范总览。

版本号命名规范:

1. 项目版本号格式
   {major}.{minor}.{patch}-{qualifier}

   示例:
   1.0.0-SNAPSHOT    # 开发版本
   1.0.0              # 正式发布
   1.0.1              # 补丁修复
   1.1.0              # 功能新增
   2.0.0              # 不兼容变更

2. Keycloak 版本号格式
   {major}.{minor}.{patch}

   示例:
   26.6.1             # 当前使用版本
   27.0.0             # 目标升级版本

3. Docker 镜像 tag 格式
   {project-version}-kc{keycloak-version}

   示例:
   1.0.0-kc26.6.1    # 项目 1.0.0 + Keycloak 26.6.1
   1.1.0-kc27.0.0    # 项目 1.1.0 + Keycloak 27.0.0

4. 分支命名格式
   {type}/{keycloak-version}-{description}

   示例:
   feature/kc27-migration     # 功能分支
   fix/kc26-event-listener    # 修复分支
   release/1.0.0-kc26.6.1     # 发布分支

版本号在 POM 中的管理。 以下是一个完整的版本号管理配置示例:

xml
<project>
    <!-- 项目基本信息 -->
    <groupId>cc.bima.keycloak</groupId>
    <artifactId>keycloak-sandbox</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <!-- Java 版本 -->
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>

        <!-- 编码 -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <!-- Keycloak 版本(核心版本属性) -->
        <keycloak.version>26.6.1</keycloak.version>

        <!-- 构建工具版本 -->
        <maven.version>3.9.6</maven.version>
        <maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
        <maven-surefire-plugin.version>3.2.2</maven-surefire-plugin.version>
        <maven-failsafe-plugin.version>3.2.2</maven-failsafe-plugin.version>

        <!-- 测试框架版本 -->
        <junit.version>5.10.1</junit.version>
        <testcontainers.version>1.19.3</testcontainers.version>
        <mockito.version>5.8.0</mockito.version>

        <!-- 代码质量工具版本 -->
        <spotbugs.version>4.8.3</spotbugs.version>
        <checkstyle.version>10.12.7</checkstyle.version>

        <!-- 构建时间戳格式 -->
        <maven.build.timestamp.format>
            yyyy-MM-dd HH:mm:ss
        </maven.build.timestamp.format>
    </properties>

    <!-- 版本信息文件(用于运行时查看) -->
    <build>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>version.properties</include>
                    <include>**/docker-compose*.yml</include>
                </includes>
            </resource>
        </resources>
    </build>
</project>

version.properties 文件。 为了方便运行时查看版本信息,可以创建一个专门的版本属性文件:

properties
# src/main/resources/version.properties
# 此文件由 Maven Resource Filtering 处理

# 项目信息
sandbox.version=${project.version}
sandbox.name=${project.name}
sandbox.groupId=${project.groupId}
sandbox.artifactId=${project.artifactId}

# Keycloak 信息
keycloak.version=${keycloak.version}

# 构建信息
build.timestamp=${maven.build.timestamp}
build.java.version=${java.version}
build.maven.version=${maven.version}

# 运行时信息(由 Java 代码填充)
runtime.java.version=${java.runtime.version}
runtime.os.name=${os.name}
runtime.os.version=${os.version}

6.4 版本变更影响评估

在决定升级 Keycloak 版本之前,进行系统性的影响评估是降低风险的关键步骤。keycloak-sandbox 项目提供了一套版本变更影响评估的框架。

版本变更影响评估流程。

版本变更影响评估流程:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  Step 1: 变更范围识别                                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 阅读 Keycloak Release Notes                      │      │
│  │ • 检查 CHANGES.md 文件                             │      │
│  │ • 关注 Breaking Changes 部分                       │      │
│  │ • 查阅 JIRA 上的变更列表                           │      │
│  └────────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│  Step 2: SPI 接口影响分析                                    │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 对比新旧版本的 SPI 接口定义                       │      │
│  │ • 检查方法签名变更                                 │      │
│  │ • 检查新增/删除的接口方法                          │      │
│  │ • 检查注解变更                                     │      │
│  └────────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│  Step 3: 依赖传递影响分析                                    │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 检查 Keycloak 传递依赖的版本变更                 │      │
│  │ • 评估第三方库版本冲突的可能性                     │      │
│  │ • 检查 Jakarta EE / Quarkus 版本变更               │      │
│  └────────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│  Step 4: 行为变更评估                                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 检查默认配置变更                                 │      │
│  │ • 检查 SPI 加载机制变更                            │      │
│  │ • 检查事件模型变更                                 │      │
│  │ • 检查认证流程变更                                 │      │
│  └────────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│  Step 5: 测试计划制定                                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 编写版本升级测试用例                             │      │
│  │ • 执行回归测试                                     │      │
│  │ • 执行性能基准测试                                 │      │
│  │ • 执行安全扫描                                     │      │
│  └────────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│  Step 6: 升级执行与验证                                      │
│  ┌────────────────────────────────────────────────────┐      │
│  │ • 修改 keycloak.version 属性                        │      │
│  │ • 执行 mvn clean install                           │      │
│  │ • 运行全量测试                                     │      │
│  │ • 在沙箱环境中验证                                 │      │
│  │ • 部署到预发布环境                                 │      │
│  └────────────────────────────────────────────────────┘      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

版本变更影响评估工具。 keycloak-sandbox 项目提供了一个自动化评估工具:

java
/**
 * 版本变更影响评估器
 */
public class VersionChangeImpactAssessor {

    private final String currentVersion;
    private final String targetVersion;

    public VersionChangeImpactAssessor(
            String currentVersion, String targetVersion) {
        this.currentVersion = currentVersion;
        this.targetVersion = targetVersion;
    }

    /**
     * 执行完整的影响评估
     */
    public ImpactReport assess() {
        ImpactReport report = new ImpactReport();
        report.setCurrentVersion(currentVersion);
        report.setTargetVersion(targetVersion);

        // 1. 评估版本跨度
        report.setVersionSpan(calculateVersionSpan());

        // 2. 评估风险等级
        report.setRiskLevel(assessRiskLevel());

        // 3. 生成建议
        report.setRecommendations(generateRecommendations());

        return report;
    }

    /**
     * 计算版本跨度
     */
    private VersionSpan calculateVersionSpan() {
        int[] current = parseVersion(currentVersion);
        int[] target = parseVersion(targetVersion);

        int majorDiff = target[0] - current[0];
        int minorDiff = target[1] - current[1];
        int patchDiff = target[2] - current[2];

        return new VersionSpan(majorDiff, minorDiff, patchDiff);
    }

    /**
     * 评估风险等级
     */
    private RiskLevel assessRiskLevel() {
        int[] current = parseVersion(currentVersion);
        int[] target = parseVersion(targetVersion);

        // 主版本号变更 = 高风险
        if (current[0] != target[0]) {
            return RiskLevel.HIGH;
        }

        // 次版本号变更超过 2 个 = 中高风险
        int minorDiff = Math.abs(target[1] - current[1]);
        if (minorDiff > 2) {
            return RiskLevel.MEDIUM_HIGH;
        }

        // 次版本号变更 = 中风险
        if (current[1] != target[1]) {
            return RiskLevel.MEDIUM;
        }

        // 仅补丁版本变更 = 低风险
        return RiskLevel.LOW;
    }

    /**
     * 生成升级建议
     */
    private List<String> generateRecommendations() {
        List<String> recommendations = new ArrayList<>();
        RiskLevel risk = assessRiskLevel();

        recommendations.add("建议在升级前创建功能分支");
        recommendations.add("建议在沙箱环境中充分测试");

        if (risk == RiskLevel.HIGH) {
            recommendations.add("高风险升级!建议分阶段升级:");
            recommendations.add("  1. 先升级到中间版本");
            recommendations.add("  2. 验证所有 SPI 功能");
            recommendations.add("  3. 再升级到目标版本");
            recommendations.add("建议进行全面的回归测试");
            recommendations.add("建议通知所有相关团队成员");
        }

        if (risk == RiskLevel.MEDIUM || risk == RiskLevel.MEDIUM_HIGH) {
            recommendations.add("建议检查 Keycloak Release Notes");
            recommendations.add("建议运行兼容性测试套件");
        }

        recommendations.add("建议在升级后运行版本兼容性检测");
        return recommendations;
    }

    private int[] parseVersion(String version) {
        String[] parts = version.split("\\.");
        return new int[]{
            Integer.parseInt(parts[0]),
            Integer.parseInt(parts[1]),
            Integer.parseInt(parts[2])
        };
    }
}

/**
 * 影响评估报告
 */
public class ImpactReport {
    private String currentVersion;
    private String targetVersion;
    private VersionSpan versionSpan;
    private RiskLevel riskLevel;
    private List<String> recommendations;

    // getters and setters

    public void printReport() {
        System.out.println("╔══════════════════════════════════════════╗");
        System.out.println("║     Keycloak 版本变更影响评估报告        ║");
        System.out.println("╠══════════════════════════════════════════╣");
        System.out.printf("║  当前版本: %-30s ║%n", currentVersion);
        System.out.printf("║  目标版本: %-30s ║%n", targetVersion);
        System.out.println("╠══════════════════════════════════════════╣");
        System.out.printf("║  版本跨度: %-30s ║%n", versionSpan);
        System.out.printf("║  风险等级: %-30s ║%n", riskLevel);
        System.out.println("╠══════════════════════════════════════════╣");
        System.out.println("║  升级建议:                               ║");
        for (String rec : recommendations) {
            System.out.printf("║  - %-37s ║%n", rec);
        }
        System.out.println("╚══════════════════════════════════════════╝");
    }
}

/**
 * 风险等级枚举
 */
public enum RiskLevel {
    LOW("低风险"),
    MEDIUM("中风险"),
    MEDIUM_HIGH("中高风险"),
    HIGH("高风险");

    private final String description;
    // constructor
}

总结与展望

本文深入剖析了 keycloak-sandbox 项目的全局版本控制机制,从 Maven 父 POM 的集中版本管理到 Resource Filtering 的动态注入,从运行时版本获取到多版本沙箱并行运行,构建了一套完整的版本控制体系。

核心机制回顾。 keycloak-sandbox 的版本控制机制可以概括为以下版本传递链路:

keycloak-sandbox 版本传递链路全景图:

┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  父 POM (pom.xml)                                    │      │
│  │                                                      │      │
│  │  <properties>                                        │      │
│  │    <keycloak.version>26.6.1</keycloak.version>       │      │
│  │  </properties>                                       │      │
│  │                                                      │      │
│  │  <dependencyManagement>                              │      │
│  │    keycloak-server-spi: ${keycloak.version}          │      │
│  │    keycloak-services:   ${keycloak.version}          │      │
│  │    keycloak-model-jpa:  ${keycloak.version}          │      │
│  │    keycloak-common:     ${keycloak.version}          │      │
│  │  </dependencyManagement>                             │      │
│  └──────────────┬──────────────────────┬───────────────┘      │
│                 │                      │                        │
│        ┌────────▼────────┐    ┌───────▼──────────┐            │
│        │  编译时传播      │    │  资源文件传播     │            │
│        │                 │    │                   │            │
│        │  子模块 POM     │    │  Resource         │            │
│        │  继承版本号     │    │  Filtering        │            │
│        │                 │    │                   │            │
│        │  sandbox-common │    │  docker-compose   │            │
│        │  sandbox-spi    │    │  .yml             │            │
│        │  sandbox-       │    │                   │            │
│        │  provider       │    │  ${keycloak_      │            │
│        │  sandbox-dist   │    │   version}        │            │
│        │                 │    │  → 26.6.1         │            │
│        └────────┬────────┘    └───────┬───────────┘            │
│                 │                      │                        │
│                 └──────────┬───────────┘                        │
│                            │                                    │
│                   ┌────────▼────────┐                           │
│                   │  运行时获取      │                           │
│                   │                 │                           │
│                   │  Version.       │                           │
│                   │  VERSION        │                           │
│                   │  → "26.6.1"     │                           │
│                   │                 │                           │
│                   │  Docker 镜像:   │                           │
│                   │  quay.io/       │                           │
│                   │  keycloak/      │                           │
│                   │  keycloak:      │                           │
│                   │  26.6.1         │                           │
│                   │                 │                           │
│                   │  下载 URL:      │                           │
│                   │  github.com/    │                           │
│                   │  keycloak/      │                           │
│                   │  releases/      │                           │
│                   │  download/      │                           │
│                   │  26.6.1/        │                           │
│                   │  keycloak-      │                           │
│                   │  26.6.1.zip     │                           │
│                   └─────────────────┘                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

关键设计原则总结。 keycloak-sandbox 的版本控制机制遵循了以下核心设计原则:

  1. 单一事实源原则(Single Source of Truth): Keycloak 版本号只在父 POM 的 <properties> 中定义一次,所有其他地方都引用这个属性。

  2. 集中管理原则(Centralized Management): 通过 dependencyManagement 集中管理所有 Keycloak 相关依赖的版本,子模块无需关心版本号。

  3. 自动传播原则(Automatic Propagation): Maven 的属性继承机制确保版本号自动传播到所有子模块,无需手动同步。

  4. 运行时感知原则(Runtime Awareness): 通过 org.keycloak.common.Version.VERSION 在运行时获取实际的 Keycloak 版本,实现编译时与运行时版本的交叉验证。

  5. 防御性编程原则(Defensive Programming): 通过版本兼容性检测、Maven Enforcer 插件、CI/CD 自动化检查等多层防护,确保版本一致性。

  6. 可操作性原则(Actionability): 提供一键版本切换、多版本并行、版本兼容性矩阵等实用功能,降低版本管理的操作成本。

未来展望。 随着 Keycloak 项目的持续演进,版本控制机制也需要不断优化。以下是几个值得关注的方向:

第一,Keycloak BOM 支持。 如果 Keycloak 官方提供标准的 BOM(Bill of Materials),keycloak-sandbox 可以进一步简化依赖管理,直接导入 BOM 而非逐个声明依赖。

第二,版本自动检测与推荐。 可以开发一个 Maven 插件,自动检测 Keycloak 的最新稳定版本,并评估当前项目与最新版本的兼容性,给出升级建议。

第三,版本兼容性数据库。 建立一个社区驱动的版本兼容性数据库,记录不同 Keycloak 版本之间的 API 变更和已知问题,为版本升级决策提供数据支持。

第四,AI 辅助版本迁移。 利用大语言模型分析 Keycloak 的 Release Notes 和源码变更,自动生成 SPI 迁移指南和代码修改建议。

第五,版本沙箱云服务。 将 keycloak-sandbox 的多版本并行能力扩展为云服务,开发者可以通过 Web 界面快速创建任意版本的 Keycloak 沙箱环境,无需本地安装 Docker。

Keycloak 的版本管理是一个持续演进的课题。keycloak-sandbox 项目的全局版本控制机制为这个课题提供了一个实用的参考实现。无论你是个人开发者还是企业团队,都可以基于这套机制构建适合自己的 Keycloak SPI 开发工作流。


版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

如需获取完整项目代码或技术支持,请访问 bima.cc