Skip to content

Keycloak Sandbox 双沙箱开发环境深度解析

作者: 必码 | bima.cc


前言

Keycloak SPI 开发的现实困境

Keycloak 作为业界领先的开源身份与访问管理(IAM)解决方案,凭借其完善的 OAuth 2.0、OpenID Connect、SAML 2.0 协议支持,以及强大的单点登录(SSO)能力,已经成为企业级应用身份认证的事实标准。然而,当企业的业务需求超越 Keycloak 内置功能时,开发者不可避免地需要通过 SPI(Service Provider Interface)机制进行功能扩展。

SPI 是 Keycloak 架构的核心扩展点,涵盖了用户存储、事件监听、密码策略、协议映射、加密算法等数十个维度。理论上,Keycloak 的 SPI 机制为开发者提供了极大的灵活性;但在实际开发中,SPI 扩展的"最后一公里"——开发环境的搭建与部署验证——却往往成为最令人头疼的环节。

痛点一:环境搭建繁琐。 开发者需要手动下载 Keycloak 发行包、配置 JDK 环境、设置环境变量、修改独立配置文件(standalone.xml 或 standalone-ha.xml),然后将编译好的 SPI JAR 包复制到 providersdeployments 目录,最后启动服务进行验证。这一系列操作不仅步骤繁多,而且极易因环境差异导致各种不可预期的问题。

痛点二:版本切换成本高昂。 Keycloak 的版本迭代非常频繁,从 20.x 到 26.x,几乎每个大版本都会引入 API 变更。当企业需要在不同版本之间进行兼容性测试时,开发者往往需要维护多套独立的 Keycloak 安装环境,或者反复进行下载、解压、配置、清理的重复操作。这种"环境切换"的时间成本,在大型项目中甚至会超过实际的编码时间。

痛点三:部署验证链路长。 传统的 SPI 开发流程是"编码 -> 编译 -> 打包 -> 复制 JAR -> 重启服务 -> 验证"。每次代码修改都需要经历完整的链路,尤其是"复制 JAR 到正确目录"和"重启服务"这两个环节,在不同操作系统和不同 Keycloak 版本中行为可能不一致,增加了调试的难度。

痛点四:团队协作困难。 在团队开发场景中,不同开发者的本地环境可能存在差异(操作系统、JDK 版本、Keycloak 安装路径等),导致"在我机器上能跑"的问题频发。缺乏标准化的开发环境,不仅降低了协作效率,也增加了代码审查和持续集成的复杂度。

现有开发方式的不足

面对上述痛点,社区和开发者们尝试过多种解决方案,但都存在各自的局限性。

方案一:手动管理本地 Keycloak 安装。 这是最传统的方式,开发者直接在本地安装 Keycloak,手动管理 SPI 部署。这种方式虽然直观,但缺乏标准化,版本切换困难,且难以在团队间复用。每次 Keycloak 版本升级都可能需要重新配置整个环境。

方案二:使用 Keycloak Quarkus 的 Dev Mode。 Keycloak 从 Quarkus 版本开始提供了开发模式(kc.sh start-dev),支持热重载。但 Dev Mode 主要面向主题和前端开发,对于 SPI 扩展的调试支持有限,且无法灵活切换 Keycloak 版本。

方案三:基于 Docker 的自定义开发环境。 一些团队会编写自定义的 Dockerfile 和 docker-compose.yml 来搭建开发环境。这种方式虽然解决了环境一致性问题,但需要额外的维护成本,且 Docker 容器内的文件系统访问不如本地环境方便,调试 SPI 时需要额外的端口映射和卷挂载配置。

方案四:使用 Keycloak Test Containers。 Test Containers 是一个优秀的测试框架,可以在集成测试中自动启动 Keycloak 容器。但它主要面向自动化测试场景,不适合日常的交互式开发和调试。

这些方案都解决了一部分问题,但没有一个能够同时满足"环境标准化、版本灵活切换、部署验证便捷、IDE 深度集成"这四个核心需求。这正是 Keycloak Sandbox 诞生的背景。

Keycloak Sandbox 的设计理念

Keycloak Sandbox 的设计理念可以概括为四个关键词:一站式双沙箱一键化零配置

一站式:将 Keycloak SPI 开发所需的全部基础设施——运行环境、构建工具、部署机制、示例代码——整合到一个统一的 Maven 多模块项目中。开发者只需克隆项目、导入 IDE、配置版本,即可开始 SPI 开发,无需任何额外的环境搭建工作。

双沙箱:同时提供 Docker 版沙箱和 Release 版沙箱两种运行环境。Docker 版沙箱提供良好的环境隔离,适合需要同时运行多个 Keycloak 版本或需要干净环境的场景;Release 版沙箱直接在本地文件系统运行,文件访问方便,适合需要频繁修改配置或调试的场景。两种沙箱共享相同的 SPI 扩展,开发者可以根据实际需求灵活选择。

一键化:所有核心操作——启动沙箱、停止沙箱、打包 SPI、发布到沙箱——都封装为带有 main 方法的 Java 类,可以在 IDE 中右键直接运行。开发者无需记忆复杂的命令行参数,也无需在不同终端之间切换,一切操作都在 IDE 中完成。

零配置:全局仅需在父 pom.xml 中配置一个 keycloak.version 属性,即可控制所有模块的 Keycloak 版本。版本号通过 Maven 的属性传播机制自动传递到各个子模块,确保整个项目使用一致的 Keycloak 版本,消除了版本不一致导致的问题。

Keycloak SPI 的技术背景

在深入 Keycloak Sandbox 之前,有必要先了解 Keycloak SPI 的技术背景和架构原理,这将有助于理解 Sandbox 的设计决策。

Keycloak 的 SPI(Service Provider Interface)机制本质上是一种模块化的服务发现和加载框架,类似于 Java 标准的 java.util.ServiceLoader。Keycloak 定义了数十个 SPI 接口,每个接口代表一个可扩展的功能领域。开发者通过实现这些接口并注册服务提供者,就可以扩展或替换 Keycloak 的默认行为。

Keycloak 的核心 SPI 包括但不限于以下类型:

SPI 类型接口说明
用户存储UserStorageProvider自定义用户数据源
事件监听EventListenerProvider监听认证和管理事件
密码策略PasswordPolicyProvider自定义密码强度规则
协议映射ProtocolMapper自定义 Token 声明映射
加密算法HashProvider / SignatureProvider自定义加密和签名算法
认证流程Authenticator自定义认证步骤
客户端策略ClientPolicyProvider客户端访问策略
主题ThemeProvider自定义登录页面主题
脚本映射ScriptMapper通过脚本映射声明
验证码ActionTokenHandler自定义操作令牌处理

每个 SPI 都遵循"提供者-工厂"模式。提供者(Provider)是实际的业务实现类,工厂(Factory)负责创建和配置提供者实例。Keycloak 在启动时通过 META-INF/services/ 目录下的服务注册文件发现所有可用的 SPI 提供者,并根据配置选择激活哪些提供者。

这种架构设计赋予了 Keycloak 极大的灵活性,但也对开发者的环境管理能力提出了更高的要求。开发者需要在不同版本的 Keycloak 上测试自己的 SPI 实现,确保接口兼容性和功能正确性。这正是 Keycloak Sandbox 致力于解决的核心问题。

本文技术定位

本文将从架构设计、核心实现、开发实践三个维度,对 Keycloak Sandbox 进行深度技术解析。文章不仅会讲解"是什么"和"怎么用",更会深入剖析"为什么这样设计"和"内部是如何实现的"。读者将通过本文掌握以下内容:

  • Maven 多模块架构的设计思路与实现细节
  • Docker 版沙箱和 Release 版沙箱的完整技术实现
  • SPI 一键打包发布机制的原理与代码解析
  • 三个内置 SPI 开发示例的实现要点
  • 基于 Keycloak Sandbox 的高效开发工作流

无论你是刚开始接触 Keycloak SPI 开发的新手,还是已经有一定经验希望提升开发效率的资深工程师,本文都将为你提供有价值的参考。对于初学者,建议从第二章和第五章开始阅读,先了解沙箱的使用方法和 SPI 示例,再回过头来理解架构设计。对于有经验的开发者,可以直接从第一章的架构设计开始,深入理解技术实现细节。


第一章 Maven 多模块架构设计

1.1 整体架构设计

Keycloak Sandbox 的整体架构设计遵循"高内聚、低耦合"的软件工程原则,通过清晰的模块划分和职责定义,实现了开发环境管理的标准化和自动化。本节将从模块划分、依赖关系和架构分层三个维度,对整体架构进行深入剖析。

模块划分原则

Keycloak Sandbox 采用 Maven 多模块架构,这是 Java 生态中管理复杂项目的主流方式。在模块划分上,项目遵循以下核心原则:

单一职责原则:每个模块只负责一个明确的功能领域。沙箱环境管理、SPI 打包发布、SPI 扩展实现各自独立成模块,职责边界清晰。

关注点分离原则:运行环境(沙箱)与业务逻辑(SPI 扩展)完全分离。开发者可以专注于 SPI 的业务实现,而无需关心底层的环境管理细节。

可扩展原则:新增 SPI 扩展只需添加新的 Maven 模块,无需修改现有模块的代码。项目结构天然支持横向扩展。

版本一致性原则:通过父 POM 的属性传播机制,确保所有模块使用相同的 Keycloak 版本,消除版本冲突风险。

模块间依赖关系

项目共包含六个核心模块,它们之间的依赖关系如下:

┌─────────────────────────────────────────────────────────────────┐
│                        父 POM (keycloak-sandbox)                │
│                    keycloak.version = 26.6.1                    │
│                   dependencyManagement + pluginManagement        │
├──────────┬──────────┬──────────┬──────────┬────────────────────┤
│  docker  │ release  │extensions│ event    │   sm-crypto        │
│  sandbox │ sandbox  │  module  │ listener │   extension        │
│          │          │          │ extension│                    │
│          │          │          ├──────────┤                    │
│          │          │          │  user    │                    │
│          │          │          │  storage │                    │
│          │          │          │ extension│                    │
└──────────┴──────────┴──────────┴──────────┴────────────────────┘
     独立       独立      依赖SPI     独立SPI      独立SPI
     模块       模块      示例模块     示例模块      示例模块

从依赖关系可以看出:

  • keycloak-server-dockerkeycloak-server-release 是两个独立的沙箱环境模块,它们之间没有直接依赖,可以独立运行。
  • keycloak-server-extensions 是 SPI 打包发布模块,它需要依赖各个 SPI 示例模块(因为要将它们打包为 JAR)。
  • 三个 SPI 示例模块(spi-event-listener-extension、spi-sm-crypto-extension、spi-user-storage-extension)之间相互独立,各自实现不同类型的 SPI 扩展。
  • 所有模块都继承自父 POM,通过 parent 声明获取统一的版本管理和依赖管理。

架构分层

从架构分层的视角来看,Keycloak Sandbox 可以分为以下四层:

┌──────────────────────────────────────────────────────────────┐
│                    第一层:SPI 扩展层                          │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────┐   │
│  │ 事件监听器扩展   │ │ 国密算法扩展    │ │ 用户存储扩展    │   │
│  │ (spi-event-    │ │ (spi-sm-crypto-│ │ (spi-user-     │   │
│  │  listener-ext) │ │  extension)    │ │  storage-ext)  │   │
│  └────────────────┘ └────────────────┘ └────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                    第二层:打包发布层                           │
│  ┌──────────────────────────────────────────────────────┐   │
│  │         keycloak-server-extensions                    │   │
│  │     ExtensionPackagesMain (SPI 一键打包发布)           │   │
│  └──────────────────────────────────────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                    第三层:沙箱运行层                          │
│  ┌────────────────────────┐ ┌──────────────────────────┐   │
│  │  keycloak-server-docker│ │ keycloak-server-release  │   │
│  │  (Docker 版沙箱)       │ │ (Release 版沙箱)         │   │
│  │  Start / Stop 类       │ │ Start / Stop 类          │   │
│  └────────────────────────┘ └──────────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                    第四层:基础配置层                           │
│  ┌──────────────────────────────────────────────────────┐   │
│  │              父 POM (keycloak-sandbox)                 │   │
│  │  keycloak.version + dependencyManagement + plugins    │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘

这种分层架构的设计优势在于:每一层都可以独立演进和替换。例如,如果将来需要支持 Kubernetes 作为第三种沙箱环境,只需在第三层添加一个新的模块即可,无需修改 SPI 扩展层和打包发布层的代码。

1.2 父 POM 与全局版本控制

keycloak.version 属性的传播机制

父 POM 是整个项目的"神经中枢",它通过 Maven 的属性传播机制实现 Keycloak 版本的全局控制。以下是父 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>

    <properties>
        <!-- 核心:全局 Keycloak 版本控制 -->
        <keycloak.version>26.6.1</keycloak.version>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <modules>
        <module>keycloak-server-docker</module>
        <module>keycloak-server-release</module>
        <module>keycloak-server-extensions</module>
        <module>spi-event-listener-extension</module>
        <module>spi-sm-crypto-extension</module>
        <module>spi-user-storage-extension</module>
    </modules>

    <!-- 依赖管理 -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-core</artifactId>
                <version>${keycloak.version}</version>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-server-spi</artifactId>
                <version>${keycloak.version}</version>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-server-spi-private</artifactId>
                <version>${keycloak.version}</version>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-services</artifactId>
                <version>${keycloak.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <!-- 插件管理 -->
    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.11.0</version>
                    <configuration>
                        <source>${maven.compiler.source}</source>
                        <target>${maven.compiler.target}</target>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.codehaus.mojo</groupId>
                    <artifactId>exec-maven-plugin</artifactId>
                    <version>3.1.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

这段配置中有几个关键设计点需要深入理解:

第一,<packaging>pom</packaging> 声明。 这表明父 POM 本身不产生任何构建产物,它纯粹作为一个聚合器和配置中心存在。<modules> 部分列出了所有子模块,当在项目根目录执行 mvn clean install 时,Maven 会按照模块间的依赖顺序依次构建所有子模块。

第二,${keycloak.version} 属性引用。<dependencyManagement> 中,所有 Keycloak 相关依赖的版本号都通过 ${keycloak.version} 属性引用。这意味着当需要切换 Keycloak 版本时,只需修改 <properties> 中的一个值,所有子模块的 Keycloak 依赖版本就会自动更新。这是 Maven 属性传播机制的核心优势。

第三,<dependencyManagement> vs <dependencies> 父 POM 使用 dependencyManagement 而非 dependencies 来声明 Keycloak 依赖。这两者的区别至关重要:dependencies 会直接将依赖添加到所有子模块的 classpath 中,而 dependencyManagement 只是"预声明"依赖的版本,子模块需要显式声明 dependency(但不指定 version)才会实际引入。这种设计既保证了版本一致性,又避免了不必要的依赖传递。

依赖管理策略

在子模块中引用 Keycloak 依赖时,无需指定版本号:

xml
<!-- 子模块中的依赖声明 -->
<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-core</artifactId>
        <!-- 版本号从父 POM 继承,无需指定 -->
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
    </dependency>
</dependencies>

这种依赖管理策略的优势在于:

  1. 版本一致性:所有模块使用相同版本的 Keycloak,消除了版本冲突导致的 NoSuchMethodErrorClassNotFoundException 等运行时异常。
  2. 升级便捷性:升级 Keycloak 版本时只需修改一处配置。
  3. 依赖透明性:每个模块显式声明自己需要的依赖,依赖关系一目了然。

插件管理

父 POM 通过 pluginManagement 统一管理构建插件的版本。exec-maven-plugin 是项目中使用频率最高的插件之一,它用于执行 Java 类的 main 方法,是"一键启动/停止沙箱"和"一键打包发布"功能的技术基础。

xml
<!-- 在子模块中使用 exec-maven-plugin -->
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <!-- 版本从父 POM 继承 -->
    <configuration>
        <mainClass>cc.bima.keycloak.server.docker.KeycloakServerStart</mainClass>
    </configuration>
</plugin>

除了 exec-maven-plugin,项目中还可能用到以下插件:

插件用途配置位置
maven-compiler-pluginJava 源代码编译父 POM pluginManagement
maven-shade-pluginSPI JAR 的依赖打包(可选)SPI 模块 POM
maven-jar-pluginJAR 文件打包配置SPI 模块 POM
maven-resources-plugin资源文件处理和过滤Docker 模块 POM
maven-surefire-plugin单元测试执行父 POM pluginManagement

Maven 属性传播的完整链路

为了更清晰地理解版本号如何在项目中传播,以下是完整的属性传播链路:

┌─────────────────────────────────────────────────────────────┐
│  父 POM (keycloak-sandbox/pom.xml)                         │
│  <properties>                                              │
│    <keycloak.version>26.6.1</keycloak.version>              │
│  </properties>                                             │
└──────────────────────┬──────────────────────────────────────┘
                       │ Maven 属性继承
          ┌────────────┼────────────┐
          v            v            v
┌─────────────┐ ┌──────────┐ ┌───────────┐
│ 子模块 POM  │ │ 子模块   │ │ 子模块    │
│ (无version) │ │ (无version)│ │ (无version)│
│ dependency  │ │ dependency│ │ dependency│
│  ↓          │ │  ↓        │ │  ↓        │
│ ${keycloak  │ │ ${keycloak│ │ ${keycloak│
│  .version}  │ │  .version}│ │  .version}│
│  ↓          │ │  ↓        │ │  ↓        │
│ 26.6.1      │ │ 26.6.1    │ │ 26.6.1    │
└─────────────┘ └──────────┘ └───────────┘

                       │ Maven 资源过滤
                       v
┌─────────────────────────────────────────────────────────────┐
│  docker-compose.yml (过滤后)                                │
│  image: quay.io/keycloak/keycloak:26.6.1                   │
└─────────────────────────────────────────────────────────────┘

                       │ exec:java 系统属性传递
                       v
┌─────────────────────────────────────────────────────────────┐
│  KeycloakServerStart.java                                  │
│  System.getProperty("keycloak.version") → "26.6.1"         │
└─────────────────────────────────────────────────────────────┘

这条传播链路确保了从 POM 配置到运行时行为的完整一致性。开发者只需在一个地方修改版本号,整个项目的行为就会自动适配新版本。

1.3 keycloak-server-docker 模块

模块概述

keycloak-server-docker 模块是 Docker 版沙箱的核心实现,它通过 Docker Compose 管理 Keycloak 容器的生命周期。该模块的设计目标是让开发者能够像运行普通 Java 程序一样启动和停止 Keycloak 服务,完全屏蔽 Docker 命令行的复杂性。

模块 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>keycloak-server-docker</artifactId>
    <name>Keycloak Server Docker Sandbox</name>
    <description>Docker 版 Keycloak 沙箱环境</description>

    <dependencies>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-core</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <mainClass>cc.bima.keycloak.server.docker.KeycloakServerStart</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

注意这里 keycloak-core<scope>provided</scope> 声明。这表示该依赖在编译时可用,但不会被打包到最终的构建产物中。这是因为 Docker 版沙箱的启动类只需要引用 Keycloak 的类来获取版本号等信息,实际的 Keycloak 运行时由 Docker 容器提供。

Docker Compose 集成

Docker Compose 配置文件位于 src/main/resources/docker-compose.yml,它是 Docker 版沙箱的核心配置。该文件通过 Maven 的资源过滤机制,在构建时动态注入 Keycloak 版本号。关于 Docker Compose 的详细配置,将在第二章深入解析。

启动/停止类实现原理

Docker 版沙箱的启动类和停止类是整个模块的核心。它们通过 Java 的 ProcessBuilder API 调用 Docker Compose 命令,实现了容器生命周期的 Java化管理。

启动类核心逻辑:

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

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;

/**
 * Keycloak Docker 版沙箱启动类
 *
 * 功能说明:
 * 1. 从父 POM 获取 keycloak.version 属性
 * 2. 通过 Maven 资源过滤将版本号注入 docker-compose.yml
 * 3. 调用 docker-compose up -d 启动 Keycloak 容器
 * 4. 等待服务健康检查通过
 */
public class KeycloakServerStart {

    /** Keycloak 版本号,通过系统属性传入 */
    private static final String KEYCLOAK_VERSION =
            System.getProperty("keycloak.version", "26.6.1");

    /** Docker Compose 文件路径 */
    private static final String COMPOSE_FILE_PATH =
            "src/main/resources/docker-compose.yml";

    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  Keycloak Docker Sandbox - Starting");
        System.out.println("  Keycloak Version: " + KEYCLOAK_VERSION);
        System.out.println("========================================");

        try {
            // 步骤1:检查 Docker 环境
            checkDockerEnvironment();

            // 步骤2:停止已有的容器(避免端口冲突)
            stopExistingContainers();

            // 步骤3:启动 Docker Compose
            startDockerCompose();

            // 步骤4:等待服务就绪
            waitForServiceReady();

            System.out.println("\n[DONE] Keycloak is running at:");
            System.out.println("  http://localhost:8080");
            System.out.println("  Admin: root / root");

        } catch (Exception e) {
            System.err.println("[ERROR] Failed to start Keycloak: "
                    + e.getMessage());
            e.printStackTrace();
            System.exit(1);
        }
    }

    private static void checkDockerEnvironment() throws Exception {
        System.out.println("[1/4] Checking Docker environment...");

        ProcessBuilder pb = new ProcessBuilder("docker", "--version");
        Process process = pb.start();
        int exitCode = process.waitFor();

        if (exitCode != 0) {
            throw new RuntimeException(
                    "Docker is not installed or not running.");
        }

        pb = new ProcessBuilder("docker-compose", "--version");
        process = pb.start();
        exitCode = process.waitFor();

        if (exitCode != 0) {
            throw new RuntimeException(
                    "Docker Compose is not installed.");
        }

        System.out.println("  Docker environment check passed.");
    }

    private static void stopExistingContainers() throws Exception {
        System.out.println("[2/4] Stopping existing containers...");
        ProcessBuilder pb = new ProcessBuilder(
                "docker-compose",
                "-f", COMPOSE_FILE_PATH,
                "down", "--remove-orphans"
        );
        pb.directory(new File(System.getProperty("user.dir")));
        pb.inheritIO();
        Process process = pb.start();
        process.waitFor();
        System.out.println("  Existing containers stopped.");
    }

    private static void startDockerCompose() throws Exception {
        System.out.println("[3/4] Starting Keycloak container...");
        System.out.println("  (First run may take a while to pull image)");

        ProcessBuilder pb = new ProcessBuilder(
                "docker-compose",
                "-f", COMPOSE_FILE_PATH,
                "up", "-d"
        );
        pb.directory(new File(System.getProperty("user.dir")));
        pb.inheritIO();
        Process process = pb.start();
        int exitCode = process.waitFor();

        if (exitCode != 0) {
            throw new RuntimeException(
                    "docker-compose up failed with exit code: " + exitCode);
        }

        System.out.println("  Container started successfully.");
    }

    private static void waitForServiceReady() throws Exception {
        System.out.println("[4/4] Waiting for Keycloak to be ready...");

        int maxRetries = 60;
        int retryInterval = 5000; // 5秒

        for (int i = 0; i < maxRetries; i++) {
            try {
                ProcessBuilder pb = new ProcessBuilder(
                        "curl", "-s", "-o", "/dev/null",
                        "-w", "%{http_code}",
                        "http://localhost:8080"
                );
                Process process = pb.start();
                BufferedReader reader = new BufferedReader(
                        new InputStreamReader(process.getInputStream()));
                String response = reader.readLine();
                process.waitFor();

                if ("200".equals(response) || "302".equals(response)) {
                    System.out.println("  Keycloak is ready!");
                    return;
                }
            } catch (Exception ignored) {
                // 服务尚未就绪,继续等待
            }

            System.out.print(".");
            Thread.sleep(retryInterval);
        }

        throw new RuntimeException(
                "Keycloak did not become ready within the timeout period.");
    }
}

停止类核心逻辑:

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

import java.io.File;

/**
 * Keycloak Docker 版沙箱停止类
 *
 * 功能说明:
 * 1. 调用 docker-compose down 停止并移除容器
 * 2. 清理关联的卷和网络资源
 */
public class KeycloakServerStop {

    private static final String COMPOSE_FILE_PATH =
            "src/main/resources/docker-compose.yml";

    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  Keycloak Docker Sandbox - Stopping");
        System.out.println("========================================");

        try {
            ProcessBuilder pb = new ProcessBuilder(
                    "docker-compose",
                    "-f", COMPOSE_FILE_PATH,
                    "down", "--volumes", "--remove-orphans"
            );
            pb.directory(new File(System.getProperty("user.dir")));
            pb.inheritIO();
            Process process = pb.start();
            int exitCode = process.waitFor();

            if (exitCode == 0) {
                System.out.println("\n[DONE] Keycloak container stopped.");
                System.out.println("  Volumes and networks cleaned up.");
            } else {
                System.err.println(
                        "[WARN] Stop command exited with code: " + exitCode);
            }

        } catch (Exception e) {
            System.err.println("[ERROR] Failed to stop Keycloak: "
                    + e.getMessage());
            e.printStackTrace();
        }
    }
}

版本动态切换机制

Docker 版沙箱的版本切换通过 Maven 的资源过滤(Resource Filtering)机制实现。在模块的 POM 中启用资源过滤后,Maven 在处理 src/main/resources 目录下的文件时,会将 ${keycloak.version} 等占位符替换为实际的属性值。

xml
<build>
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>true</filtering>
        </resource>
    </resources>
</build>

这样,docker-compose.yml 中就可以使用 ${keycloak.version} 占位符:

yaml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:${keycloak.version}

当执行 mvn clean compile 时,Maven 会自动将 ${keycloak.version} 替换为父 POM 中定义的实际版本号(如 26.6.1),生成的目标文件中包含的是具体的版本号。这种机制确保了 Docker 镜像版本与项目配置的 Keycloak 版本始终保持一致。

1.4 keycloak-server-release 模块

模块概述

keycloak-server-release 模块是 Release 版沙箱的核心实现,它直接在本地文件系统上下载、解压和运行 Keycloak 发行包。与 Docker 版相比,Release 版沙箱的最大优势在于可以直接访问 Keycloak 的文件系统,方便查看日志、修改配置、调试 SPI。

Release 包下载与解压

Release 版沙箱的核心挑战在于如何自动化 Keycloak 发行包的下载和解压过程。Keycloak 官方提供了两种发行包格式:

  • ZIP 格式:适用于所有平台
  • TAR.GZ 格式:主要适用于 Linux/macOS

Release 版沙箱的启动类会根据配置的 Keycloak 版本号,自动从 Keycloak 官方下载源获取对应的发行包。下载 URL 的构造规则如下:

https://github.com/keycloak/keycloak/releases/download/{version}/keycloak-{version}.zip

例如,对于版本 26.6.1,下载 URL 为:

https://github.com/keycloak/keycloak/releases/download/26.6.1/keycloak-26.6.1.zip

启动类实现原理

Release 版沙箱的启动类比 Docker 版更为复杂,因为它需要处理下载、解压、进程启动等多个环节:

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

import java.io.*;
import java.net.URL;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
 * Keycloak Release 版沙箱启动类
 *
 * 功能说明:
 * 1. 检查本地是否已存在对应版本的 Keycloak 发行包
 * 2. 如不存在,从官方源下载并解压
 * 3. 配置 Keycloak 环境(设置管理员账号等)
 * 4. 启动 Keycloak 进程
 */
public class KeycloakServerStart {

    private static final String KEYCLOAK_VERSION =
            System.getProperty("keycloak.version", "26.6.1");

    /** Keycloak 安装目录 */
    private static final String SANDBOX_HOME =
            System.getProperty("user.home")
                    + "/.keycloak-sandbox";

    /** Keycloak 下载 URL 模板 */
    private static final String DOWNLOAD_URL_TEMPLATE =
            "https://github.com/keycloak/keycloak/releases/"
            + "download/%s/keycloak-%s.zip";

    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  Keycloak Release Sandbox - Starting");
        System.out.println("  Keycloak Version: " + KEYCLOAK_VERSION);
        System.out.println("========================================");

        try {
            // 步骤1:确保 Keycloak 发行包已就绪
            Path keycloakHome = ensureKeycloakReady();

            // 步骤2:配置 Keycloak
            configureKeycloak(keycloakHome);

            // 步骤3:启动 Keycloak 进程
            startKeycloakProcess(keycloakHome);

            System.out.println("\n[DONE] Keycloak is running at:");
            System.out.println("  http://localhost:8080");
            System.out.println("  Admin: root / root");
            System.out.println("  Install path: " + keycloakHome);

        } catch (Exception e) {
            System.err.println("[ERROR] Failed to start Keycloak: "
                    + e.getMessage());
            e.printStackTrace();
            System.exit(1);
        }
    }

    /**
     * 确保 Keycloak 发行包已下载并解压
     */
    private static Path ensureKeycloakReady() throws Exception {
        Path installDir = Paths.get(SANDBOX_HOME,
                "keycloak-" + KEYCLOAK_VERSION);

        if (Files.exists(installDir)
                && Files.exists(installDir.resolve("bin"))) {
            System.out.println("[1/3] Keycloak " + KEYCLOAK_VERSION
                    + " already installed.");
            return installDir;
        }

        System.out.println("[1/3] Downloading Keycloak " + KEYCLOAK_VERSION
                + "...");
        System.out.println("  (This may take a few minutes)");

        // 创建沙箱目录
        Files.createDirectories(Paths.get(SANDBOX_HOME));

        // 下载发行包
        String downloadUrl = String.format(DOWNLOAD_URL_TEMPLATE,
                KEYCLOAK_VERSION, KEYCLOAK_VERSION);
        Path zipFile = Paths.get(SANDBOX_HOME,
                "keycloak-" + KEYCLOAK_VERSION + ".zip");

        downloadFile(new URL(downloadUrl), zipFile);
        System.out.println("  Download completed.");

        // 解压发行包
        System.out.println("  Extracting...");
        unzipFile(zipFile, Paths.get(SANDBOX_HOME));

        // 删除 ZIP 文件以节省空间
        Files.deleteIfExists(zipFile);

        System.out.println("  Keycloak " + KEYCLOAK_VERSION
                + " installed successfully.");
        return installDir;
    }

    /**
     * 下载文件
     */
    private static void downloadFile(URL url, Path destination)
            throws IOException {
        try (ReadableByteChannel channel = Channels.newChannel(url.openStream());
             FileOutputStream fos = new FileOutputStream(destination.toFile())) {
            fos.getChannel().transferFrom(channel, 0, Long.MAX_VALUE);
        }
    }

    /**
     * 解压 ZIP 文件
     */
    private static void unzipFile(Path zipFile, Path targetDir)
            throws IOException {
        try (ZipInputStream zis = new ZipInputStream(
                new FileInputStream(zipFile.toFile()))) {
            ZipEntry entry;
            byte[] buffer = new byte[8192];
            while ((entry = zis.getNextEntry()) != null) {
                Path newPath = targetDir.resolve(entry.getName());
                if (entry.isDirectory()) {
                    Files.createDirectories(newPath);
                } else {
                    Files.createDirectories(newPath.getParent());
                    try (OutputStream os = Files.newOutputStream(newPath)) {
                        int len;
                        while ((len = zis.read(buffer)) > 0) {
                            os.write(buffer, 0, len);
                        }
                    }
                }
                zis.closeEntry();
            }
        }
    }

    /**
     * 配置 Keycloak(设置管理员账号等)
     */
    private static void configureKeycloak(Path keycloakHome)
            throws Exception {
        System.out.println("[2/3] Configuring Keycloak...");

        // 执行 kc.sh config 命令设置管理员账号
        String osName = System.getProperty("os.name").toLowerCase();
        String scriptExtension = osName.contains("win") ? ".bat" : ".sh";

        Path kcScript = keycloakHome.resolve("bin")
                .resolve("kc" + scriptExtension);

        if (!Files.exists(kcScript)) {
            throw new RuntimeException(
                    "Keycloak start script not found: " + kcScript);
        }

        // 设置脚本可执行权限(Linux/macOS)
        if (!osName.contains("win")) {
            kcScript.toFile().setExecutable(true);
        }

        System.out.println("  Configuration completed.");
    }

    /**
     * 启动 Keycloak 进程
     */
    private static void startKeycloakProcess(Path keycloakHome)
            throws Exception {
        System.out.println("[3/3] Starting Keycloak process...");

        String osName = System.getProperty("os.name").toLowerCase();
        String scriptExtension = osName.contains("win") ? ".bat" : ".sh";

        ProcessBuilder pb = new ProcessBuilder(
                keycloakHome.resolve("bin")
                        .resolve("kc" + scriptExtension).toString(),
                "start-dev"
        );
        pb.directory(keycloakHome.toFile());
        pb.environment().put("KEYCLOAK_ADMIN", "root");
        pb.environment().put("KEYCLOAK_ADMIN_PASSWORD", "root");
        pb.environment().put("KC_HEALTH_ENABLED", "true");
        pb.environment().put("KC_DB", "dev-file");

        // 将 Keycloak 进程的输出重定向到控制台
        pb.inheritIO();

        Process process = pb.start();

        // 注册关闭钩子,确保 JVM 退出时停止 Keycloak
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("\n[INFO] Shutting down Keycloak...");
            process.destroy();
            try {
                process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS);
                if (process.isAlive()) {
                    process.destroyForcibly();
                }
            } catch (InterruptedException e) {
                process.destroyForcibly();
            }
        }));

        System.out.println("  Keycloak process started (PID: "
                + process.pid() + ").");
    }
}

停止类实现原理

Release 版沙箱的停止类需要处理进程查找和优雅关闭的逻辑:

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

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * Keycloak Release 版沙箱停止类
 *
 * 功能说明:
 * 1. 查找 Keycloak 进程
 * 2. 发送终止信号
 * 3. 等待进程优雅退出
 * 4. 必要时强制终止
 */
public class KeycloakServerStop {

    private static final String KEYCLOAK_VERSION =
            System.getProperty("keycloak.version", "26.6.1");
    private static final String SANDBOX_HOME =
            System.getProperty("user.home") + "/.keycloak-sandbox";

    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  Keycloak Release Sandbox - Stopping");
        System.out.println("========================================");

        try {
            stopKeycloakProcess();
            System.out.println("\n[DONE] Keycloak stopped successfully.");
        } catch (Exception e) {
            System.err.println("[ERROR] Failed to stop Keycloak: "
                    + e.getMessage());
            e.printStackTrace();
        }
    }

    private static void stopKeycloakProcess() throws Exception {
        String osName = System.getProperty("os.name").toLowerCase();

        if (osName.contains("win")) {
            stopWindowsProcess();
        } else {
            stopUnixProcess();
        }
    }

    private static void stopUnixProcess() throws Exception {
        // 使用 jps 或 ps 查找 Keycloak 进程
        System.out.println("[INFO] Searching for Keycloak process...");

        ProcessBuilder pb = new ProcessBuilder("jps", "-l");
        Process process = pb.start();
        BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()));

        String line;
        long keycloakPid = -1;
        while ((line = reader.readLine()) != null) {
            if (line.contains("keycloak") || line.contains("quarkus")) {
                String[] parts = line.trim().split("\\s+");
                keycloakPid = Long.parseLong(parts[0]);
                System.out.println("  Found Keycloak process: PID "
                        + keycloakPid);
                break;
            }
        }

        if (keycloakPid == -1) {
            System.out.println("  No Keycloak process found.");
            return;
        }

        // 发送 SIGTERM 信号(优雅停止)
        System.out.println("  Sending SIGTERM to PID " + keycloakPid);
        pb = new ProcessBuilder("kill", String.valueOf(keycloakPid));
        process = pb.start();
        process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS);

        // 检查进程是否已退出
        pb = new ProcessBuilder("kill", "-0", String.valueOf(keycloakPid));
        process = pb.start();
        if (process.waitFor() != 0) {
            System.out.println("  Process stopped gracefully.");
        } else {
            // 强制终止
            System.out.println("  Force killing process...");
            pb = new ProcessBuilder("kill", "-9",
                    String.valueOf(keycloakPid));
            pb.start().waitFor();
            System.out.println("  Process force killed.");
        }
    }

    private static void stopWindowsProcess() throws Exception {
        System.out.println("[INFO] Searching for Keycloak process...");
        ProcessBuilder pb = new ProcessBuilder("tasklist", "/FI",
                "IMAGENAME eq java.exe", "/FO", "CSV", "/NH");
        Process process = pb.start();
        BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()));

        String line;
        while ((line = reader.readLine()) != null) {
            if (line.contains("java")) {
                String[] parts = line.split(",");
                if (parts.length > 1) {
                    String pid = parts[1].replace("\"", "").trim();
                    System.out.println("  Terminating Java process: PID "
                            + pid);
                    ProcessBuilder killPb = new ProcessBuilder(
                            "taskkill", "/PID", pid, "/F");
                    killPb.start().waitFor();
                }
            }
        }
    }
}

1.5 keycloak-server-extensions 模块

模块概述

keycloak-server-extensions 模块是 SPI 一键打包发布的核心,它负责将各个 SPI 示例模块编译后的 JAR 包收集起来,并发布到指定的沙箱环境(Docker 版或 Release 版)。这个模块的设计理念是"一键完成从编译到部署的全流程"。

模块 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>keycloak-server-extensions</artifactId>
    <name>Keycloak Server Extensions</name>
    <description>SPI 一键打包发布模块</description>

    <dependencies>
        <!-- 依赖所有 SPI 示例模块 -->
        <dependency>
            <groupId>cc.bima.keycloak</groupId>
            <artifactId>spi-event-listener-extension</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>cc.bima.keycloak</groupId>
            <artifactId>spi-sm-crypto-extension</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>cc.bima.keycloak</groupId>
            <artifactId>spi-user-storage-extension</artifactId>
            <version>${project.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <mainClass>
                        cc.bima.keycloak.extension.packages
                            .ExtensionPackagesMain
                    </mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

ExtensionPackagesMain 实现

ExtensionPackagesMain 是打包发布的核心类,它的完整实现将在第四章详细解析。这里先给出其基本框架:

java
package cc.bima.keycloak.extension.packages;

import java.io.*;
import java.nio.file.*;
import java.util.*;

/**
 * SPI 一键打包发布主类
 *
 * 支持两种发布目标:
 * - docker:将 JAR 发布到 Docker 版沙箱的卷目录
 * - release:将 JAR 发布到 Release 版沙箱的 providers 目录
 */
public class ExtensionPackagesMain {

    /** SPI 示例模块列表 */
    private static final String[] SPI_MODULES = {
            "spi-event-listener-extension",
            "spi-sm-crypto-extension",
            "spi-user-storage-extension"
    };

    /** 发布目标:docker 或 release */
    private static String target = "release";

    public static void main(String[] args) {
        // 解析参数,确定发布目标
        if (args.length > 0) {
            target = args[0].toLowerCase();
        }

        System.out.println("========================================");
        System.out.println("  SPI Extension Packages");
        System.out.println("  Target: " + target + " sandbox");
        System.out.println("========================================");

        try {
            // 执行打包和发布
            packageAndDeploy();
            System.out.println("\n[DONE] SPI extensions deployed.");
        } catch (Exception e) {
            System.err.println("[ERROR] Deployment failed: "
                    + e.getMessage());
            e.printStackTrace();
            System.exit(1);
        }
    }

    private static void packageAndDeploy() throws Exception {
        // 1. 确保所有 SPI 模块已编译
        // 2. 收集 JAR 文件
        // 3. 复制到目标沙箱
        // 4. 触发重载(如果需要)
        // 详细实现见第四章
    }
}

第二章 Docker 版沙箱深度解析

2.1 Docker Compose 配置详解

Docker Compose 是 Docker 版沙箱的核心配置文件,它定义了 Keycloak 服务的容器化运行方式。以下是完整的配置文件及其逐行解析:

yaml
version: '3.8'

services:
  keycloak:
    image: quay.io/keycloak/keycloak:${keycloak.version}
    container_name: keycloak-sandbox
    ports:
      - "8080:8080"
      - "8443:8443"
      - "1099:1099"
    environment:
      # 管理员账号配置
      KEYCLOAK_ADMIN: root
      KEYCLOAK_ADMIN_PASSWORD: root

      # 数据库配置(使用开发模式内置的 H2 数据库)
      KC_DB: dev-file

      # 健康检查配置
      KC_HEALTH_ENABLED: "true"

      # 日志级别配置
      QUARKUS_LOG_LEVEL: INFO
      KC_LOG_LEVEL: INFO

      # SPI 提供者目录
      KC_SPI_PROVIDERS: /opt/keycloak/providers

      # 调试配置(可选,开发时启用)
      # DEBUG: "true"
      # DEBUG_PORT: "*:8787"

      # JVM 参数
      JAVA_OPTS_APPEND: >
        -Xms256m
        -Xmx1024m
        -Dkeycloak.profile.feature.upload_scripts=enabled

    volumes:
      # SPI 扩展 JAR 挂载目录
      - ${SANDBOX_HOME:-~/.keycloak-sandbox}/providers:/opt/keycloak/providers
      # Keycloak 数据持久化
      - ${SANDBOX_HOME:-~/.keycloak-sandbox}/data:/opt/keycloak/data
      # 日志目录
      - ${SANDBOX_HOME:-~/.keycloak-sandbox}/logs:/opt/keycloak/logs

    networks:
      - keycloak-network

    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 60s

    restart: unless-stopped

networks:
  keycloak-network:
    driver: bridge

Keycloak 服务配置

镜像选择:使用 quay.io/keycloak/keycloak:${keycloak.version} 作为基础镜像。${keycloak.version} 通过 Maven 资源过滤在构建时替换为实际版本号。Quay.io 是 Red Hat 运营的容器镜像仓库,是 Keycloak 官方推荐的镜像源。

容器命名container_name: keycloak-sandbox 为容器指定了固定名称,便于管理和引用。在停止类中,可以通过容器名称精确地停止目标容器。

端口映射

yaml
ports:
  - "8080:8080"   # HTTP 端口,Keycloak 管理控制台和 API
  - "8443:8443"   # HTTPS 端口,安全通信
  - "1099:1099"   # JMX 远程调试端口(可选)

端口映射采用 主机端口:容器端口 的格式。8080 端口是 Keycloak 的默认 HTTP 端口,用于访问管理控制台和 REST API。8443 端口用于 HTTPS 通信。1099 端口用于 JMX 远程监控和调试。

环境变量

环境变量是配置 Keycloak 容器行为的主要方式:

环境变量说明
KEYCLOAK_ADMINroot管理员用户名
KEYCLOAK_ADMIN_PASSWORDroot管理员密码
KC_DBdev-file使用内置 H2 文件数据库
KC_HEALTH_ENABLEDtrue启用健康检查端点
KC_SPI_PROVIDERS/opt/keycloak/providersSPI 提供者目录
JAVA_OPTS_APPENDJVM 参数附加 JVM 参数

KC_DB: dev-file 是一个关键配置。Keycloak Quarkus 版本支持多种数据库后端(PostgreSQL、MySQL、MariaDB 等),但在开发环境中,使用内置的 H2 文件数据库最为便捷,无需额外配置外部数据库服务。

JAVA_OPTS_APPEND 用于向 Keycloak 的 JVM 添加自定义参数。这里配置了初始堆内存(256MB)、最大堆内存(1024MB)以及启用脚本上传功能。

卷挂载

yaml
volumes:
  - ${SANDBOX_HOME:-~/.keycloak-sandbox}/providers:/opt/keycloak/providers
  - ${SANDBOX_HOME:-~/.keycloak-sandbox}/data:/opt/keycloak/data
  - ${SANDBOX_HOME:-~/.keycloak-sandbox}/logs:/opt/keycloak/logs

卷挂载是 Docker 版沙箱与宿主机交互的核心机制。三个挂载点各有用途:

  1. providers 目录:这是 SPI 扩展 JAR 的部署目录。打包发布模块会将编译好的 JAR 文件复制到宿主机的 ~/.keycloak-sandbox/providers 目录,容器内的 Keycloak 会自动加载该目录下的 JAR 文件。

  2. data 目录:持久化 Keycloak 的运行时数据,包括 H2 数据库文件、缓存数据等。即使容器被销毁重建,数据也不会丢失。

  3. logs 目录:将 Keycloak 的日志文件映射到宿主机,方便开发者直接在宿主机上查看和分析日志。

${SANDBOX_HOME:-~/.keycloak-sandbox} 使用了 Shell 变量替换语法,默认值为 ~/.keycloak-sandbox。如果环境变量 SANDBOX_HOME 已设置,则使用其值。这种设计允许开发者自定义沙箱数据目录的位置。

网络配置

yaml
networks:
  keycloak-network:
    driver: bridge

使用 bridge 驱动创建自定义网络。虽然在单容器场景下使用默认网络也可以工作,但显式声明自定义网络是一个良好的实践,为将来添加数据库、消息队列等依赖服务预留了网络命名空间。

健康检查

yaml
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
  interval: 10s
  timeout: 5s
  retries: 10
  start_period: 60s

健康检查通过 Keycloak 的 /health/ready 端点检测服务是否就绪。各参数含义:

  • interval: 10s:每 10 秒检查一次
  • timeout: 5s:每次检查的超时时间为 5 秒
  • retries: 10:连续失败 10 次后标记为不健康
  • start_period: 60s:容器启动后的 60 秒内不计入失败次数(因为 Keycloak 启动较慢)

2.2 启动类实现原理

Docker 版沙箱的启动类在第一章已经给出了基本实现,这里进一步深入解析其关键技术细节。

Docker Compose 生命周期管理

启动类通过 ProcessBuilder 调用 docker-compose 命令,实现了容器生命周期的 Java 化管理。这种方式的优势在于:

  1. 跨平台兼容ProcessBuilder 是 Java 标准库的一部分,在所有平台上行为一致。
  2. 输出透传:通过 pb.inheritIO() 将子进程的标准输出和错误输出透传到当前控制台,开发者可以实时看到 Docker Compose 的执行日志。
  3. 错误处理:通过检查进程退出码可以判断命令是否执行成功。

版本号动态注入

版本号的动态注入是 Docker 版沙箱的关键技术点。整个流程如下:

┌──────────────────┐     ┌───────────────────┐
│   父 POM         │     │  docker-compose   │
│  keycloak.version│────>│    .yml 模板       │
│    = 26.6.1      │     │  ${keycloak.      │
└──────────────────┘     │   version}        │
                         └────────┬──────────┘
                                  │ Maven 资源过滤
                                  v
                         ┌───────────────────┐
                         │  docker-compose   │
                         │    .yml (输出)     │
                         │  image: quay.io/  │
                         │  keycloak/        │
                         │  keycloak:26.6.1  │
                         └───────────────────┘

具体步骤:

  1. 开发者在父 POM 中设置 <keycloak.version>26.6.1</keycloak.version>
  2. docker-compose.yml 中使用 ${keycloak.version} 占位符
  3. Maven 编译时,资源过滤插件扫描 src/main/resources 目录
  4. ${keycloak.version} 替换为 26.6.1
  5. 输出的 target/classes/docker-compose.yml 包含实际版本号
  6. 启动类使用过滤后的配置文件启动 Docker Compose

2.3 停止类实现原理

停止类的实现需要考虑几个关键问题:

优雅停止 vs 强制停止:Keycloak 作为 Java 应用,直接发送 SIGKILL 信号可能导致数据不一致(例如正在写入数据库的事务被中断)。正确的做法是先发送 SIGTERM 信号,等待进程自行完成清理工作后再退出。

java
// 优雅停止流程
ProcessBuilder pb = new ProcessBuilder(
        "docker-compose",
        "-f", COMPOSE_FILE_PATH,
        "stop"       // 使用 stop 而非 kill,发送 SIGTERM
);
pb.inheritIO();
Process process = pb.start();

// 等待容器停止(最多 30 秒)
boolean stopped = process.waitFor(30, TimeUnit.SECONDS);

if (!stopped) {
    // 超时后强制停止
    System.out.println("  Graceful stop timed out, forcing...");
    pb = new ProcessBuilder(
            "docker-compose",
            "-f", COMPOSE_FILE_PATH,
            "kill"   // 强制终止
    );
    pb.inheritIO();
    pb.start().waitFor();
}

资源清理:停止容器后,还需要清理关联的资源:

java
// 清理容器、卷、网络
ProcessBuilder pb = new ProcessBuilder(
        "docker-compose",
        "-f", COMPOSE_FILE_PATH,
        "down",
        "--volumes",      // 删除关联的卷
        "--remove-orphans", // 删除孤立容器
        "--rmi", "local"  // 删除本地镜像(可选)
);

--remove-orphans 参数确保清理那些不在当前 docker-compose.yml 中定义但由之前版本创建的容器,避免资源泄漏。

2.4 Docker 版沙箱的扩展定制

自定义 Docker Compose 配置

Docker 版沙箱支持通过环境变量和配置覆盖进行定制。例如,如果需要使用 PostgreSQL 作为数据库后端,可以扩展 docker-compose.yml:

yaml
version: '3.8'

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

  keycloak:
    image: quay.io/keycloak/keycloak:${keycloak.version}
    container_name: keycloak-sandbox
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "8080:8080"
    environment:
      KEYCLOAK_ADMIN: root
      KEYCLOAK_ADMIN_PASSWORD: root
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
    volumes:
      - ${SANDBOX_HOME:-~/.keycloak-sandbox}/providers:/opt/keycloak/providers
    networks:
      - keycloak-network

volumes:
  postgres-data:

networks:
  keycloak-network:
    driver: bridge

添加依赖服务

在实际开发中,Keycloak SPI 扩展可能需要依赖外部服务(如消息队列、缓存服务等)。以下是一个添加 Redis 缓存的示例:

yaml
services:
  redis:
    image: redis:7-alpine
    container_name: keycloak-redis
    ports:
      - "6379:6379"
    networks:
      - keycloak-network

  keycloak:
    # ... 其他配置 ...
    environment:
      # 通过环境变量传递 Redis 连接信息
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      - redis

调试模式

在开发调试 SPI 扩展时,启用远程调试是非常有用的。可以通过以下方式配置:

yaml
services:
  keycloak:
    # ... 其他配置 ...
    environment:
      DEBUG: "true"
      DEBUG_PORT: "*:8787"
    ports:
      - "8787:8787"  # 调试端口映射

然后在 IDE 中配置远程调试连接:

  1. IntelliJ IDEA: Run -> Edit Configurations -> Add New -> Remote JVM Debug
  2. Host: localhost, Port: 8787
  3. 设置断点后启动调试,即可在 IDE 中调试 Keycloak 容器内的 SPI 代码

2.5 Docker 版沙箱的网络与安全考量

网络隔离策略

在生产环境中,Keycloak 通常需要与数据库、消息队列等后端服务通信。在 Docker 版沙箱中,通过自定义网络实现服务间的隔离和通信:

yaml
version: '3.8'

services:
  # 前端网络 - Keycloak 对外暴露
  keycloak:
    networks:
      - frontend
      - backend

  # 后端网络 - 数据库等内部服务
  postgres:
    networks:
      - backend
    # 不暴露端口到宿主机,仅内部通信

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true  # 内部网络,不允许外部访问

这种双层网络架构确保了数据库等敏感服务不会暴露到宿主机网络,同时 Keycloak 可以同时访问前端网络(接收外部请求)和后端网络(访问数据库)。

安全配置建议

在开发环境中,虽然不需要生产级别的安全配置,但养成良好的安全习惯仍然很重要:

yaml
services:
  keycloak:
    # ... 其他配置 ...
    security_opt:
      - no-new-privileges:true    # 防止容器内进程提升权限
      - seccomp:default           # 使用默认的系统调用过滤
    read_only: false               # 开发环境需要写入,生产环境建议 true
    tmpfs:
      - /tmp                       # 使用内存文件系统存储临时文件
    environment:
      # 生产环境应使用密钥管理服务
      KC_TRUSTSTORE_PATHS: /opt/keycloak/conf/truststore.jks
      KC_TRUSTSTORE_PASSWORD: change_me
      # 禁用不必要的功能
      KC_SPI_THEME_STATIC_MAX_AGE: -1
      KC_SPI_THEME_CACHE_THEMES: false
      KC_SPI_THEME_CACHE_TEMPLATES: false

2.6 Docker 版沙箱的故障排查

常见问题与解决方案

问题一:端口冲突

当宿主机的 8080 端口已被占用时,Docker 容器无法启动。解决方案是修改端口映射:

yaml
ports:
  - "18080:8080"   # 将宿主机端口改为 18080
  - "18443:8443"

问题二:容器启动失败

查看容器日志是排查启动失败的首选方法:

bash
# 查看容器日志
docker logs keycloak-sandbox

# 实时跟踪日志
docker logs -f keycloak-sandbox

# 查看最近的 100 行日志
docker logs --tail 100 keycloak-sandbox

问题三:SPI JAR 未被加载

确认 JAR 文件已正确放置在挂载目录中:

bash
# 检查宿主机目录
ls -la ~/.keycloak-sandbox/providers/

# 检查容器内目录
docker exec keycloak-sandbox ls -la /opt/keycloak/providers/

# 检查 Keycloak 是否识别了 SPI
docker exec keycloak-sandbox cat /opt/keycloak/conf/keycloak.conf | grep spi

问题四:容器内存不足

如果 Keycloak 容器因内存不足而崩溃,可以调整 JVM 内存参数:

yaml
environment:
  JAVA_OPTS_APPEND: >
    -Xms512m
    -Xmx2048m
    -XX:MaxMetaspaceSize=512m

同时确保 Docker Desktop 分配了足够的内存(推荐至少 4GB)。


第三章 Release 版沙箱深度解析

3.1 Release 包下载机制

Keycloak 官方下载源

Keycloak 的发行包托管在 GitHub Releases 上。Release 版沙箱通过构造正确的下载 URL 来获取对应版本的发行包:

https://github.com/keycloak/keycloak/releases/download/{version}/keycloak-{version}.zip

这个 URL 的构造规则是固定的,只要知道版本号就可以直接下载。Keycloak 的版本号遵循语义化版本规范(Semantic Versioning),格式为 MAJOR.MINOR.PATCH,例如 26.6.1

版本号解析

版本号从父 POM 的 keycloak.version 属性通过 Maven 资源过滤传递到启动类。启动类通过系统属性获取版本号:

java
private static final String KEYCLOAK_VERSION =
        System.getProperty("keycloak.version", "26.6.1");

这里使用 System.getProperty 而非硬编码版本号,是因为在 IDE 中直接运行 main 方法时,Maven 会通过 exec-maven-plugin 将 POM 中的属性设置为 JVM 系统属性。

下载进度管理

对于大型发行包(Keycloak 的 ZIP 包通常在 200MB 以上),下载进度管理是用户体验的重要环节。以下是带进度显示的下载实现:

java
private static void downloadFileWithProgress(URL url, Path destination)
        throws IOException {
    System.out.println("  Downloading from: " + url);

    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    int fileSize = connection.getContentLength();

    try (InputStream is = connection.getInputStream();
         FileOutputStream fos = new FileOutputStream(destination.toFile())) {

        byte[] buffer = new byte[8192];
        long totalRead = 0;
        int bytesRead;

        while ((bytesRead = is.read(buffer)) != -1) {
            fos.write(buffer, 0, bytesRead);
            totalRead += bytesRead;

            // 显示下载进度
            if (fileSize > 0) {
                int percent = (int) ((totalRead * 100) / fileSize);
                System.out.print("\r  Progress: "
                        + percent + "% ("
                        + (totalRead / 1024 / 1024) + "MB / "
                        + (fileSize / 1024 / 1024) + "MB)");
            }
        }
    }

    System.out.println("\n  Download completed.");
}

本地缓存机制

为了避免每次启动都重新下载,Release 版沙箱实现了本地缓存机制。已下载的 Keycloak 发行包会被解压到 ~/.keycloak-sandbox/keycloak-{version}/ 目录,后续启动时直接使用本地缓存:

java
private static Path ensureKeycloakReady() throws Exception {
    Path installDir = Paths.get(SANDBOX_HOME,
            "keycloak-" + KEYCLOAK_VERSION);

    // 检查本地缓存
    if (Files.exists(installDir)
            && Files.exists(installDir.resolve("bin"))
            && Files.exists(installDir.resolve("lib"))) {
        System.out.println("[1/3] Using cached Keycloak "
                + KEYCLOAK_VERSION);
        return installDir;
    }

    // 缓存不存在,执行下载和解压
    System.out.println("[1/3] Keycloak " + KEYCLOAK_VERSION
            + " not found locally.");
    downloadAndExtract();
    return installDir;
}

当需要切换 Keycloak 版本时,只需修改父 POM 中的 keycloak.version 属性,Release 版沙箱会自动检测到版本变更并下载新版本的发行包。不同版本的发行包存储在不同的子目录中,可以同时保留多个版本以便随时切换。

3.2 启动类实现原理

进程启动

Release 版沙箱通过 ProcessBuilder 启动 Keycloak 进程。Keycloak Quarkus 版本的启动脚本是 bin/kc.sh(Linux/macOS)或 bin/kc.bat(Windows),启动命令为:

bash
./kc.sh start-dev

start-dev 模式是 Keycloak 提供的开发模式,它具有以下特性:

  • 自动创建管理员账号(通过环境变量 KEYCLOAK_ADMINKEYCLOAK_ADMIN_PASSWORD
  • 使用内置 H2 文件数据库
  • 启用热重载(对 providers 目录的变更自动检测)
  • 输出详细的开发日志

类路径配置

在 Release 版沙箱中,Keycloak 的类路径由启动脚本自动配置。启动脚本会扫描以下目录并将其添加到类路径:

  • lib/:Keycloak 核心库
  • providers/:SPI 扩展 JAR(这是打包发布模块的目标目录)
  • conf/:配置文件目录

当开发者通过打包发布模块将 SPI JAR 复制到 providers/ 目录后,Keycloak 的热重载机制会自动检测到新文件并重新加载类路径。

JVM 参数

通过环境变量 JAVA_OPTS_APPEND 可以向 Keycloak 进程传递自定义 JVM 参数:

java
pb.environment().put("JAVA_OPTS_APPEND",
        "-Xms256m -Xmx1024m "
        + "-agentlib:jdwp=transport=dt_socket,"
        + "server=y,suspend=n,address=*:5005");

常用的 JVM 参数包括:

参数说明
-Xms256m初始堆内存大小
-Xmx1024m最大堆内存大小
-agentlib:jdwp=...启用远程调试
-Dkeycloak.profile.feature.xxx=enabled启用特定功能特性
-verbose:class输出类加载信息(调试 SPI 加载问题时有用)

3.3 停止类实现原理

进程查找与终止

Release 版沙箱的停止类需要解决的核心问题是"如何准确地找到并终止 Keycloak 进程"。在 Unix 系统上,可以通过以下几种方式查找进程:

方式一:通过 jps 命令

java
ProcessBuilder pb = new ProcessBuilder("jps", "-v");
Process process = pb.start();
BufferedReader reader = new BufferedReader(
        new InputStreamReader(process.getInputStream()));

String line;
while ((line = reader.readLine()) != null) {
    // Keycloak 进程通常包含 "quarkus" 或 "keycloak" 关键字
    if (line.contains("keycloak") || line.contains("quarkus")) {
        String[] parts = line.trim().split("\\s+");
        long pid = Long.parseLong(parts[0]);
        // 终止进程...
    }
}

方式二:通过 PID 文件

更可靠的方式是在启动时将 Keycloak 进程的 PID 写入文件:

java
// 启动类中写入 PID 文件
Path pidFile = Paths.get(SANDBOX_HOME,
        "keycloak-" + KEYCLOAK_VERSION + ".pid");
Files.write(pidFile,
        String.valueOf(process.pid()).getBytes());

// 停止类中读取 PID 文件
String pidStr = new String(Files.readAllBytes(pidFile)).trim();
long pid = Long.parseLong(pidStr);

优雅关闭

优雅关闭的流程如下:

┌─────────────┐    SIGTERM    ┌──────────────┐
│   停止类     │─────────────>│ Keycloak进程  │
│             │              │              │
│             │              │ 1. 停止接收   │
│             │              │    新请求     │
│             │              │ 2. 完成进行中  │
│             │              │    的请求     │
│             │              │ 3. 关闭数据库  │
│             │              │    连接       │
│             │              │ 4. 释放资源   │
│             │<─────────────│ 5. 退出      │
└─────────────┘   进程退出    └──────────────┘
java
private static void gracefulStop(long pid) throws Exception {
    // 发送 SIGTERM
    System.out.println("  Sending SIGTERM to PID " + pid);
    ProcessBuilder pb = new ProcessBuilder("kill", "-TERM",
            String.valueOf(pid));
    pb.start().waitFor();

    // 等待进程退出(最多 30 秒)
    long startTime = System.currentTimeMillis();
    long timeout = 30_000;

    while (System.currentTimeMillis() - startTime < timeout) {
        // 检查进程是否还存在
        pb = new ProcessBuilder("kill", "-0", String.valueOf(pid));
        Process check = pb.start();
        int exitCode = check.waitFor();

        if (exitCode != 0) {
            // 进程已退出
            System.out.println("  Process exited gracefully.");
            return;
        }

        Thread.sleep(1000);
    }

    // 超时,强制终止
    System.out.println("  Timeout, force killing PID " + pid);
    pb = new ProcessBuilder("kill", "-9", String.valueOf(pid));
    pb.start().waitFor();
    System.out.println("  Process force killed.");
}

3.4 Release 版沙箱的优势场景

文件系统直接访问

Release 版沙箱最大的优势在于可以直接访问 Keycloak 的文件系统。这对于以下场景特别有用:

查看日志文件

bash
# 直接查看 Keycloak 日志
tail -f ~/.keycloak-sandbox/keycloak-26.6.1/data/log/keycloak.log

# 搜索特定错误
grep "ERROR" ~/.keycloak-sandbox/keycloak-26.6.1/data/log/keycloak.log

修改配置文件

bash
# 编辑 Keycloak 配置
vim ~/.keycloak-sandbox/keycloak-26.6.1/conf/keycloak.conf

# 编辑主题文件
vim ~/.keycloak-sandbox/keycloak-26.6.1/themes/base/login/theme.properties

检查 SPI 部署

bash
# 查看 providers 目录中的 JAR 文件
ls -la ~/.keycloak-sandbox/keycloak-26.6.1/providers/

# 检查 JAR 内容
jar tf ~/.keycloak-sandbox/keycloak-26.6.1/providers/my-spi-extension.jar

配置热修改

Keycloak Quarkus 版本支持配置热修改。开发者可以直接修改 conf/keycloak.conf 文件,然后重启服务使配置生效。对于某些配置项(如日志级别),甚至可以在运行时通过管理控制台修改。

properties
# keycloak.conf 示例配置
# 数据库配置
db=postgres
db-url=jdbc:postgresql://localhost:5432/keycloak
db-username=keycloak
db-password=keycloak

# HTTP 配置
http-enabled=true
http-port=8080
http-relative-path=/

# SPI 配置
spi-events-listener-bima-audit-enabled=true
spi-events-listener-bima-audit-exclude-events=[LOGIN_ERROR]

# 日志配置
log-level=INFO
log-console-output=default
log-file-output=default
log-file-path=data/log/keycloak.log

调试便利性

Release 版沙箱的调试便利性体现在多个方面:

1. 本地远程调试

bash
# 启动时附加调试参数
export JAVA_OPTS_APPEND="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev

然后在 IDE 中连接到 localhost:5005 进行远程调试。

2. 快速重启

由于 Keycloak 安装在本地文件系统,重启速度比 Docker 容器更快。开发者可以在修改 SPI 代码后,快速执行"编译 -> 打包 -> 复制 JAR -> 重启"的完整流程。

3. 环境变量直接设置

bash
# 直接设置环境变量启动
KEYCLOAK_ADMIN=admin \
KEYCLOAK_ADMIN_PASSWORD=admin123 \
KC_LOG_LEVEL=DEBUG \
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev

3.5 Release 版沙箱的配置文件详解

Keycloak Quarkus 版本使用 conf/keycloak.conf 作为主配置文件。以下是 Release 版沙箱中常用的配置项及其说明:

properties
# ============================================================
# Keycloak Release 版沙箱配置文件详解
# 文件位置:~/.keycloak-sandbox/keycloak-{version}/conf/keycloak.conf
# ============================================================

# ----- 数据库配置 -----
# 开发环境使用内置 H2 文件数据库
db=dev-file
# 生产环境切换为 PostgreSQL 示例
# db=postgres
# db-url=jdbc:postgresql://localhost:5432/keycloak
# db-username=keycloak
# db-password=keycloak

# ----- HTTP/HTTPS 配置 -----
http-enabled=true
http-port=8080
http-relative-path=/
# https-port=8443
# https-certificate-file=/opt/keycloak/conf/server.crt.pem
# https-certificate-key-file=/opt/keycloak/conf/server.key.pem

# ----- 管理员配置 -----
# 首次启动时创建的管理员账号(仅在 dev-file 数据库模式下有效)
# 生产环境应通过命令行创建:kc.sh create-user
# KEYCLOAK_ADMIN 和 KEYCLOAK_ADMIN_PASSWORD 环境变量优先级更高

# ----- SPI 提供者目录 -----
# SPI 扩展 JAR 的存放目录
spi-provider=providers

# ----- 事件监听器 SPI 配置 -----
# 启用自定义事件监听器
spi-events-listener-bima-audit-enabled=true
# 排除特定事件类型
spi-events-listener-bima-audit-exclude-events=LOGIN_ERROR,CODE_TO_TOKEN_ERROR
# 是否异步处理事件
spi-events-listener-bima-audit-async=true

# ----- 密码策略配置 -----
spi-password-policy-bima-sm3-enabled=true

# ----- 用户存储 SPI 配置 -----
spi-user-storage-bima-custom-enabled=true
# 外部数据源连接配置
# spi-user-storage-bima-custom-db-url=jdbc:mysql://localhost:3306/users
# spi-user-storage-bima-custom-db-username=user_service
# spi-user-storage-bima-custom-db-password=secret

# ----- 日志配置 -----
log-level=INFO
log-console-output=default
log-console-format=%d{HH:mm:ss} %-5p [%c] (%t) %s%e%n
log-file-output=default
log-file-path=data/log/keycloak.log
log-file-format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n
log-file-rotation=file-size
log-file-rotation-file-size=50M
log-file-rotation-max-files=5

# ----- 缓存配置 -----
spi-connections-infinispan-default-locks-owner=true
cache-realms-enabled=true
cache-users-enabled=true
cache-authorization-enabled=true

# ----- 代理配置 -----
# proxy-headers=xforwarded
# http-max-pending-connections=5000

# ----- 功能特性开关 -----
# 启用脚本引擎(用于自定义认证脚本)
feature-upload_scripts=enabled
# 启用 Token 交换
feature-token-exchange=enabled
# 启用 Admin Fine-Grained AuthZ
feature-admin-fine-grained-authz=enabled
# 启用 PAR(Pushed Authorization Requests)
feature-par=enabled

3.6 Release 版沙箱的故障排查

日志分析

Keycloak 的日志文件位于 data/log/keycloak.log,是排查问题的第一手资料。以下是一些常用的日志分析技巧:

bash
# 实时查看日志
tail -f ~/.keycloak-sandbox/keycloak-26.6.1/data/log/keycloak.log

# 搜索错误日志
grep -i "error\|exception\|failed" \
    ~/.keycloak-sandbox/keycloak-26.6.1/data/log/keycloak.log

# 查看 SPI 加载相关日志
grep -i "spi\|provider\|service" \
    ~/.keycloak-sandbox/keycloak-26.6.1/data/log/keycloak.log

# 查看最近 10 分钟的日志
find ~/.keycloak-sandbox/keycloak-26.6.1/data/log/ \
    -name "keycloak.log" -mmin -10 -exec cat {} \;

常见启动问题

问题一:端口被占用

bash
# 检查 8080 端口占用情况
lsof -i :8080
# 或
netstat -tlnp | grep 8080

# 解决方案:停止占用端口的进程,或修改 Keycloak 端口
export KC_HTTP_PORT=18080
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev

问题二:JDK 版本不兼容

bash
# 检查 JDK 版本
java -version

# Keycloak 26.x 需要 JDK 17 或 21
# 如果使用 JDK 11,需要升级或切换到兼容的 Keycloak 版本

问题三:SPI 加载失败

SPI 加载失败通常会在启动日志中显示为 WARN 或 ERROR 级别的日志。常见原因包括:

  1. JAR 文件格式不正确(如 MANIFEST.MF 缺失)
  2. 服务注册文件路径错误
  3. SPI 实现类缺少必要的注解或接口实现
  4. 依赖冲突(如 SPI JAR 中打包了与 Keycloak 冲突的第三方库)

排查方法:

bash
# 检查 JAR 文件内容
jar tf ~/.keycloak-sandbox/keycloak-26.6.1/providers/my-spi.jar

# 确认服务注册文件存在
jar tf ~/.keycloak-sandbox/keycloak-26.6.1/providers/my-spi.jar \
    | grep META-INF/services

# 使用 -verbose:class 查看 Keycloak 的类加载过程
export JAVA_OPTS_APPEND="-verbose:class"
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev 2>&1 \
    | grep -i "your-spi-package"

第四章 SPI 一键打包发布机制

4.1 打包流程设计

SPI 一键打包发布是 Keycloak Sandbox 最具实用价值的功能之一。它将原本需要多步手动操作才能完成的"编译 -> 打包 -> 复制 -> 重载"流程,简化为一个 IDE 中的右键操作。

Maven 构建流程

完整的打包发布流程如下:

┌──────────────────────────────────────────────────────────────────┐
│                    ExtensionPackagesMain                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Step 1: 执行 Maven 构建                                          │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  mvn clean package -pl spi-event-listener-extension,      │  │
│  │      spi-sm-crypto-extension,spi-user-storage-extension    │  │
│  │      -am                                                  │  │
│  └────────────────────────────────────────────────────────────┘  │
│                              │                                   │
│                              v                                   │
│  Step 2: 定位编译产物                                            │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  spi-event-listener-extension/target/*.jar                 │  │
│  │  spi-sm-crypto-extension/target/*.jar                      │  │
│  │  spi-user-storage-extension/target/*.jar                   │  │
│  └────────────────────────────────────────────────────────────┘  │
│                              │                                   │
│                              v                                   │
│  Step 3: 复制到目标沙箱                                          │
│  ┌─────────────────────┬──────────────────────────────────────┐  │
│  │   Docker 沙箱        │         Release 沙箱                 │  │
│  │  ~/.keycloak-       │  ~/.keycloak-sandbox/               │  │
│  │  sandbox/providers/ │  keycloak-{version}/providers/      │  │
│  └─────────────────────┴──────────────────────────────────────┘  │
│                              │                                   │
│                              v                                   │
│  Step 4: 触发重载(Release 沙箱自动检测,Docker 需重启容器)        │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

JAR 文件定位

打包发布模块需要准确地定位各 SPI 模块的编译产物。在 Maven 标准项目结构中,编译后的 JAR 文件位于 {module}/target/ 目录下。定位逻辑如下:

java
/**
 * 查找指定模块的编译产物 JAR 文件
 */
private static Path findModuleJar(String moduleName, Path projectRoot)
        throws IOException {
    Path targetDir = projectRoot.resolve(moduleName).resolve("target");

    if (!Files.exists(targetDir)) {
        throw new RuntimeException(
                "Target directory not found for module: " + moduleName
                + ". Please run 'mvn clean package' first.");
    }

    // 查找 target 目录下的 JAR 文件(排除 original 和 sources)
    try (var stream = Files.list(targetDir)) {
        return stream
                .filter(path -> path.toString().endsWith(".jar"))
                .filter(path -> !path.toString().contains("original"))
                .filter(path -> !path.toString().contains("sources"))
                .filter(path -> !path.toString().contains("javadoc"))
                .findFirst()
                .orElseThrow(() -> new RuntimeException(
                        "No JAR file found in " + targetDir));
    }
}

目标目录选择

根据发布目标的不同,JAR 文件会被复制到不同的目录:

java
/**
 * 获取目标沙箱的 providers 目录
 */
private static Path getTargetProvidersDir(String target)
        throws Exception {
    String keycloakVersion = System.getProperty(
            "keycloak.version", "26.6.1");
    String sandboxHome = System.getProperty(
            "user.home") + "/.keycloak-sandbox";

    switch (target) {
        case "docker":
            // Docker 版沙箱的 providers 挂载目录
            return Paths.get(sandboxHome, "providers");

        case "release":
            // Release 版沙箱的 providers 目录
            return Paths.get(sandboxHome,
                    "keycloak-" + keycloakVersion, "providers");

        default:
            throw new IllegalArgumentException(
                    "Unknown target: " + target
                    + ". Use 'docker' or 'release'.");
    }
}

4.2 ExtensionPackagesMain 完整实现

以下是 ExtensionPackagesMain 的完整实现代码:

java
package cc.bima.keycloak.extension.packages;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

/**
 * SPI 一键打包发布主类
 *
 * 功能说明:
 * 1. 执行 Maven 构建所有 SPI 模块
 * 2. 收集编译产物 JAR 文件
 * 3. 复制到指定沙箱的 providers 目录
 * 4. 清理旧版本 JAR(可选)
 *
 * 使用方式:
 * - IDE 中右键运行 main 方法
 * - Maven 命令:mvn exec:java -Dexec.mainClass="..."
 * - 参数:docker 或 release(默认 release)
 */
public class ExtensionPackagesMain {

    /** SPI 示例模块列表 */
    private static final String[] SPI_MODULES = {
            "spi-event-listener-extension",
            "spi-sm-crypto-extension",
            "spi-user-storage-extension"
    };

    /** 项目根目录 */
    private static final Path PROJECT_ROOT = Paths.get(
            System.getProperty("user.dir")).getParent();

    /** 发布目标 */
    private static String target = "release";

    /** Keycloak 版本 */
    private static final String KEYCLOAK_VERSION =
            System.getProperty("keycloak.version", "26.6.1");

    /** 沙箱主目录 */
    private static final String SANDBOX_HOME =
            System.getProperty("user.home") + "/.keycloak-sandbox";

    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  SPI Extension Packages");
        System.out.println("  Target: " + target + " sandbox");
        System.out.println("  Keycloak Version: " + KEYCLOAK_VERSION);
        System.out.println("========================================");

        try {
            // 解析命令行参数
            parseArguments(args);

            // 步骤1:执行 Maven 构建
            buildSpiModules();

            // 步骤2:收集 JAR 文件
            List<Path> jarFiles = collectJarFiles();

            // 步骤3:获取目标目录
            Path targetDir = getTargetProvidersDir();

            // 步骤4:清理旧版本 JAR
            cleanOldJars(targetDir);

            // 步骤5:复制 JAR 到目标目录
            deployJars(jarFiles, targetDir);

            System.out.println("\n[DONE] SPI extensions deployed to: "
                    + targetDir);
            System.out.println("  Deployed " + jarFiles.size() + " JARs.");

            if ("docker".equals(target)) {
                System.out.println("\n  NOTE: Please restart the Docker "
                        + "container to load new extensions.");
            } else {
                System.out.println("\n  NOTE: Keycloak will auto-detect "
                        + "new JARs in the providers directory.");
            }

        } catch (Exception e) {
            System.err.println("[ERROR] Deployment failed: "
                    + e.getMessage());
            e.printStackTrace();
            System.exit(1);
        }
    }

    /**
     * 解析命令行参数
     */
    private static void parseArguments(String[] args) {
        if (args.length > 0) {
            target = args[0].toLowerCase();
            if (!"docker".equals(target) && !"release".equals(target)) {
                System.err.println("Invalid target: " + target);
                System.err.println("Usage: ExtensionPackagesMain "
                        + "[docker|release]");
                System.exit(1);
            }
        }
    }

    /**
     * 执行 Maven 构建所有 SPI 模块
     */
    private static void buildSpiModules() throws Exception {
        System.out.println("\n[Step 1] Building SPI modules...");

        // 构建模块列表参数
        String modules = String.join(",", SPI_MODULES);

        ProcessBuilder pb = new ProcessBuilder(
                "mvn",
                "clean", "package",
                "-pl", modules,
                "-am",
                "-DskipTests"
        );
        pb.directory(PROJECT_ROOT.toFile());
        pb.inheritIO();

        Process process = pb.start();
        int exitCode = process.waitFor();

        if (exitCode != 0) {
            throw new RuntimeException(
                    "Maven build failed with exit code: " + exitCode);
        }

        System.out.println("  Build completed successfully.");
    }

    /**
     * 收集所有 SPI 模块的 JAR 文件
     */
    private static List<Path> collectJarFiles() throws IOException {
        System.out.println("\n[Step 2] Collecting JAR files...");

        List<Path> jarFiles = new ArrayList<>();

        for (String module : SPI_MODULES) {
            Path jarFile = findModuleJar(module);
            jarFiles.add(jarFile);
            System.out.println("  Found: " + jarFile.getFileName()
                    + " (" + Files.size(jarFile) / 1024 + " KB)");
        }

        return jarFiles;
    }

    /**
     * 查找指定模块的编译产物 JAR 文件
     */
    private static Path findModuleJar(String moduleName)
            throws IOException {
        Path targetDir = PROJECT_ROOT.resolve(moduleName)
                .resolve("target");

        if (!Files.exists(targetDir)) {
            throw new RuntimeException(
                    "Target directory not found for module: "
                    + moduleName);
        }

        try (Stream<Path> stream = Files.list(targetDir)) {
            return stream
                    .filter(p -> p.toString().endsWith(".jar"))
                    .filter(p -> !p.getFileName().toString()
                            .contains("original"))
                    .filter(p -> !p.getFileName().toString()
                            .contains("sources"))
                    .filter(p -> !p.getFileName().toString()
                            .contains("javadoc"))
                    .findFirst()
                    .orElseThrow(() -> new RuntimeException(
                            "No JAR found in " + targetDir));
        }
    }

    /**
     * 获取目标沙箱的 providers 目录
     */
    private static Path getTargetProvidersDir() throws Exception {
        Path dir;

        if ("docker".equals(target)) {
            dir = Paths.get(SANDBOX_HOME, "providers");
        } else {
            dir = Paths.get(SANDBOX_HOME,
                    "keycloak-" + KEYCLOAK_VERSION, "providers");
        }

        // 确保目录存在
        Files.createDirectories(dir);
        return dir;
    }

    /**
     * 清理目标目录中的旧版本 JAR
     */
    private static void cleanOldJars(Path targetDir) throws IOException {
        System.out.println("\n[Step 3] Cleaning old JARs in "
                + targetDir + "...");

        try (Stream<Path> stream = Files.list(targetDir)) {
            stream.filter(p -> p.toString().endsWith(".jar"))
                    .forEach(p -> {
                        try {
                            Files.delete(p);
                            System.out.println("  Deleted: "
                                    + p.getFileName());
                        } catch (IOException e) {
                            System.err.println("  Failed to delete: "
                                    + p.getFileName());
                        }
                    });
        }
    }

    /**
     * 复制 JAR 文件到目标目录
     */
    private static void deployJars(List<Path> jarFiles, Path targetDir)
            throws IOException {
        System.out.println("\n[Step 4] Deploying JARs...");

        for (Path jarFile : jarFiles) {
            Path target = targetDir.resolve(jarFile.getFileName());
            Files.copy(jarFile, target,
                    StandardCopyOption.REPLACE_EXISTING);
            System.out.println("  Deployed: " + jarFile.getFileName()
                    + " -> " + target);
        }
    }
}

4.3 打包发布到 Docker 沙箱

发布到 Docker 沙箱的流程相对简单,因为 Docker Compose 已经将宿主机的 ~/.keycloak-sandbox/providers 目录挂载到了容器内的 /opt/keycloak/providers 目录。打包发布模块只需要将 JAR 文件复制到宿主机的挂载目录即可:

┌─────────────────────┐     复制 JAR     ┌──────────────────────┐
│  ExtensionPackages  │────────────────>│  宿主机               │
│  Main               │                  │  ~/.keycloak-sandbox/ │
│                     │                  │  providers/           │
└─────────────────────┘                  │  ├── event-listener.jar│
                                         │  ├── sm-crypto.jar     │
                                         │  └── user-storage.jar  │
                                         └──────────┬───────────┘
                                                    │ Docker 卷挂载
                                                    v
                                         ┌──────────────────────┐
                                         │  容器                │
                                         │  /opt/keycloak/      │
                                         │  providers/          │
                                         │  ├── event-listener.jar│
                                         │  ├── sm-crypto.jar     │
                                         │  └── user-storage.jar  │
                                         └──────────────────────┘

注意事项:Docker 版沙箱在发布 JAR 后需要重启容器才能加载新的 SPI 扩展。这是因为 Keycloak 在启动时扫描 providers 目录,运行时不会自动检测新文件。重启容器的命令为:

bash
docker-compose -f src/main/resources/docker-compose.yml restart keycloak

4.4 打包发布到 Release 沙箱

发布到 Release 沙箱的流程与 Docker 版类似,但有一个重要的区别:Keycloak Quarkus 版本支持对 providers 目录的热重载。

┌─────────────────────┐     复制 JAR     ┌──────────────────────────┐
│  ExtensionPackages  │────────────────>│  ~/.keycloak-sandbox/    │
│  Main               │                  │  keycloak-26.6.1/        │
│                     │                  │  providers/              │
└─────────────────────┘                  │  ├── event-listener.jar   │
                                         │  ├── sm-crypto.jar        │
                                         │  └── user-storage.jar     │
                                         └──────────┬───────────────┘
                                                    │ Keycloak 自动检测
                                                    v
                                         ┌──────────────────────────┐
                                         │  Keycloak 进程            │
                                         │  检测到 providers/ 变更   │
                                         │  自动重新加载 SPI         │
                                         └──────────────────────────┘

Keycloak Quarkus 的热重载机制基于文件系统监视(File System Watch)。当 providers 目录中的文件发生变化时,Keycloak 会自动触发 SPI 的重新扫描和加载。这意味着在大多数情况下,开发者不需要手动重启 Keycloak 服务。

然而,热重载有一些限制:

  1. 新增 SPI 类型:如果新增了一个全新的 SPI 类型(例如从只有事件监听器扩展变为同时有用户存储扩展),可能需要重启才能正确加载。
  2. 删除 SPI:删除 JAR 文件后,对应的 SPI 提供者不会被自动卸载,需要重启。
  3. 配置变更:SPI 的配置变更(如 keycloak.conf 中的 SPI 参数)需要重启才能生效。

4.5 打包优化

增量构建

ExtensionPackagesMain 在执行 Maven 构建时使用了 -pl-am 参数,实现增量构建:

java
ProcessBuilder pb = new ProcessBuilder(
        "mvn",
        "clean", "package",
        "-pl", modules,   // 只构建指定的模块
        "-am",            // 同时构建依赖的模块
        "-DskipTests"     // 跳过测试(开发阶段加速构建)
);

-pl(--also-make)参数指定只构建列出的模块,-am(--also-make-dependents)参数确保被依赖的上游模块也被构建。这种配置避免了每次都构建整个项目,大幅缩短了构建时间。

依赖分析

在打包发布前,可以进行依赖分析以确保 JAR 包的完整性:

java
/**
 * 分析 JAR 文件的依赖关系
 */
private static void analyzeDependencies(Path jarFile) throws IOException {
    System.out.println("  Analyzing dependencies for: "
            + jarFile.getFileName());

    try (var jarFs = FileSystems.newFileSystem(jarFile,
            (ClassLoader) null)) {
        // 检查 META-INF/services 目录
        Path servicesDir = jarFs.getPath("META-INF", "services");
        if (Files.exists(servicesDir)) {
            try (var stream = Files.list(servicesDir)) {
                stream.forEach(serviceFile -> {
                    System.out.println("    SPI: "
                            + serviceFile.getFileName());
                });
            }
        }

        // 检查 META-INF/keycloak-*.json 文件
        Path metaInfDir = jarFs.getPath("META-INF");
        if (Files.exists(metaInfDir)) {
            try (var stream = Files.list(metaInfDir)) {
                stream.filter(p -> p.getFileName().toString()
                        .startsWith("keycloak-"))
                        .forEach(descriptor -> {
                            System.out.println("    Descriptor: "
                                    + descriptor.getFileName());
                        });
            }
        }
    }
}

构建缓存

Maven 本身提供了构建缓存机制。在父 POM 中可以配置构建扩展以启用更细粒度的缓存:

xml
<build>
    <extensions>
        <extension>
            <groupId>org.apache.maven.extensions</groupId>
            <artifactId>maven-build-cache-extension</artifactId>
            <version>1.1.0</version>
        </extension>
    </extensions>
</build>

构建缓存会记录每个模块的输入(源代码、依赖、配置)和输出(编译产物),当输入没有变化时直接使用缓存的输出,跳过实际的编译步骤。对于大型项目,这可以将重复构建的时间缩短 50% 以上。


第五章 内置 SPI 开发示例解析

5.1 示例项目概览

Keycloak Sandbox 内置了三个 SPI 开发示例,覆盖了 Keycloak 扩展开发中最常见的场景。这些示例不仅是学习 Keycloak SPI 开发的参考实现,也是验证沙箱环境功能是否正常的测试用例。

┌──────────────────────────────────────────────────────────────────┐
│                    SPI 示例项目概览                                │
├──────────────────┬───────────────────────────────────────────────┤
│ 示例名称          │ 说明                                          │
├──────────────────┼───────────────────────────────────────────────┤
│ spi-event-       │ 事件监听器扩展:监听 Keycloak 事件(登录、     │
│ listener-ext     │ 登出、注册等)并推送到外部消息队列              │
├──────────────────┼───────────────────────────────────────────────┤
│ spi-sm-crypto-   │ 国密算法扩展:实现 SM2/SM3/SM4 国密算法,     │
│ extension        │ 为 Keycloak 提供符合国密标准的加密支持          │
├──────────────────┼───────────────────────────────────────────────┤
│ spi-user-        │ 用户存储扩展:自定义用户存储提供者,支持从     │
│ storage-ext      │ 外部数据源加载和管理用户信息                    │
└──────────────────┴───────────────────────────────────────────────┘

每个示例项目都遵循标准的 Maven 项目结构,并包含完整的服务提供者注册文件(META-INF/services/)。

5.2 事件监听器 SPI 示例

项目结构

spi-event-listener-extension/
├── src/main/java/cc/bima/keycloak/extension/event/
│   ├── AuditEventListenerProvider.java          # 事件监听器实现
│   ├── AuditEventListenerProviderFactory.java    # 事件监听器工厂
│   └── EventMessage.java                        # 事件消息模型
├── src/main/resources/
│   └── META-INF/services/
│       └── org.keycloak.events.EventListenerProviderFactory
└── pom.xml

核心实现

AuditEventListenerProvider:事件监听器的核心实现类,负责处理 Keycloak 产生的各类事件。

java
package cc.bima.keycloak.extension.event;

import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventType;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 审计事件监听器提供者
 *
 * 功能说明:
 * 1. 监听 Keycloak 管理事件和客户端事件
 * 2. 将事件转换为标准化的消息格式
 * 3. 通过异步方式发送到外部消息队列
 * 4. 支持事件过滤和自定义处理
 */
public class AuditEventListenerProvider implements EventListenerProvider {

    private final KeycloakSession session;
    private final ExecutorService executorService;
    private final Set<EventType> enabledEventTypes;
    private final boolean asyncEnabled;

    public AuditEventListenerProvider(KeycloakSession session,
            Set<EventType> enabledEventTypes,
            boolean asyncEnabled) {
        this.session = session;
        this.enabledEventTypes = enabledEventTypes;
        this.asyncEnabled = asyncEnabled;
        this.executorService = asyncEnabled
                ? Executors.newSingleThreadExecutor() : null;
    }

    @Override
    public void onEvent(Event event) {
        // 检查事件是否在启用列表中
        if (enabledEventTypes != null
                && !enabledEventTypes.contains(event.getType())) {
            return;
        }

        // 构建事件消息
        EventMessage message = buildEventMessage(event);

        // 发送事件
        if (asyncEnabled) {
            executorService.submit(() -> sendToMessageQueue(message));
        } else {
            sendToMessageQueue(message);
        }
    }

    @Override
    public void onEvent(AdminEvent event, boolean includeRepresentation) {
        // 处理管理事件
        EventMessage message = buildAdminEventMessage(event,
                includeRepresentation);
        if (asyncEnabled) {
            executorService.submit(() -> sendToMessageQueue(message));
        } else {
            sendToMessageQueue(message);
        }
    }

    /**
     * 构建客户端事件消息
     */
    private EventMessage buildEventMessage(Event event) {
        EventMessage message = new EventMessage();
        message.setEventType(event.getType().toString());
        message.setRealmId(event.getRealmId());
        message.setClientId(event.getClientId());
        message.setUserId(event.getUserId());
        message.setSessionId(event.getSessionId());
        message.setIpAddress(event.getIpAddress());
        message.setTime(event.getTime());
        message.setDetails(event.getDetails());

        // 获取用户信息
        if (event.getUserId() != null) {
            RealmModel realm = session.realms().getRealm(event.getRealmId());
            if (realm != null) {
                UserModel user = session.users()
                        .getUserById(realm, event.getUserId());
                if (user != null) {
                    message.setUsername(user.getUsername());
                    message.setEmail(user.getEmail());
                }
            }
        }

        return message;
    }

    /**
     * 构建管理事件消息
     */
    private EventMessage buildAdminEventMessage(AdminEvent event,
            boolean includeRepresentation) {
        EventMessage message = new EventMessage();
        message.setEventType("ADMIN_" + event.getOperationType()
                .toString());
        message.setRealmId(event.getRealmId());
        message.setResourceType(event.getResourceType()
                .toString());
        message.setResourceId(event.getResourcePath());
        message.setTime(System.currentTimeMillis());

        if (includeRepresentation && event.getRepresentation() != null) {
            message.setDetails(Map.of("representation",
                    event.getRepresentation()));
        }

        if (event.getError() != null) {
            message.setDetails(Map.of("error", event.getError()));
        }

        return message;
    }

    /**
     * 发送事件到消息队列
     */
    private void sendToMessageQueue(EventMessage message) {
        try {
            // 这里实现消息队列的发送逻辑
            // 支持多种消息通道:Kafka、RabbitMQ、RocketMQ
            System.out.println("[AuditEvent] " + message.getEventType()
                    + " - User: " + message.getUsername()
                    + " - IP: " + message.getIpAddress()
                    + " - Realm: " + message.getRealmId());
        } catch (Exception e) {
            System.err.println("[AuditEvent] Failed to send event: "
                    + e.getMessage());
        }
    }

    @Override
    public void close() {
        if (executorService != null) {
            executorService.shutdown();
        }
    }
}

AuditEventListenerProviderFactory:事件监听器的工厂类,负责创建和配置监听器实例。

java
package cc.bima.keycloak.extension.event;

import org.keycloak.Config.Scope;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

import java.util.HashSet;
import java.util.Set;
import java.util.Arrays;

/**
 * 审计事件监听器工厂
 *
 * SPI 配置示例(在 keycloak.conf 中):
 * spi-events-listener-bima-audit-enabled=true
 * spi-events-listener-bima-audit-exclude-events=LOGIN_ERROR
 * spi-events-listener-bima-audit-async=true
 */
public class AuditEventListenerProviderFactory
        implements EventListenerProviderFactory {

    private static final String PROVIDER_ID = "bima-audit";

    private Set<EventType> excludeEvents = new HashSet<>();
    private boolean asyncEnabled = true;

    @Override
    public EventListenerProvider create(KeycloakSession session) {
        // 计算启用的事件类型(全部减去排除的)
        Set<EventType> enabledTypes = null; // null 表示全部启用
        if (!excludeEvents.isEmpty()) {
            enabledTypes = new HashSet<>(Arrays.asList(EventType.values()));
            enabledTypes.removeAll(excludeEvents);
        }

        return new AuditEventListenerProvider(
                session, enabledTypes, asyncEnabled);
    }

    @Override
    public void init(Scope config) {
        // 从配置中读取参数
        String excludeStr = config.get("exclude-events");
        if (excludeStr != null && !excludeStr.isEmpty()) {
            for (String eventType : excludeStr.split(",")) {
                try {
                    excludeEvents.add(
                            EventType.valueOf(eventType.trim()));
                } catch (IllegalArgumentException e) {
                    System.err.println("Unknown event type: "
                            + eventType);
                }
            }
        }

        asyncEnabled = config.getBoolean("async", true);
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
        // 初始化后处理(可选)
    }

    @Override
    public void close() {
        // 清理资源
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }
}

服务注册文件

# src/main/resources/META-INF/services/
# org.keycloak.events.EventListenerProviderFactory
cc.bima.keycloak.extension.event.AuditEventListenerProviderFactory

在 Sandbox 中测试

  1. 启动 Release 版沙箱(推荐,方便查看日志输出)
  2. 运行 ExtensionPackagesMain 将 SPI 发布到沙箱
  3. 访问 http://localhost:8080,使用 root/root 登录
  4. 在管理控制台中创建一个测试用户并登录
  5. 查看控制台输出,应该能看到 [AuditEvent] LOGIN 相关的日志

5.3 国密算法 SPI 示例

项目结构

spi-sm-crypto-extension/
├── src/main/java/cc/bima/keycloak/extension/sm/
│   ├── SMContentEncryptionProvider.java       # 国密内容加密
│   ├── SMContentEncryptionProviderFactory.java
│   ├── SMHashProvider.java                    # 国密哈希算法
│   ├── SMHashProviderFactory.java
│   ├── SMKeyProvider.java                     # 国密密钥提供者
│   ├── SMKeyProviderFactory.java
│   ├── SMSignatureProvider.java               # 国密签名
│   └── SMSignatureProviderFactory.java
├── src/main/resources/
│   └── META-INF/services/
│       ├── org.keycloak.crypto.ContentEncryptionProviderFactory
│       ├── org.keycloak.crypto.HashProviderFactory
│       ├── org.keycloak.keys.KeyProviderFactory
│       └── org.keycloak.crypto.SignatureProviderFactory
└── pom.xml

核心实现

SMHashProvider:国密 SM3 哈希算法的实现,可用于密码哈希和数据完整性校验。

java
package cc.bima.keycloak.extension.sm;

import org.keycloak.crypto.HashException;
import org.keycloak.crypto.HashProvider;
import org.keycloak.models.KeycloakSession;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

/**
 * 国密 SM3 哈希算法提供者
 *
 * SM3 是中国国家密码管理局发布的密码散列函数标准,
 * 输出为 256 位哈希值,安全性优于 SHA-256。
 *
 * 适用场景:
 * - 密码哈希存储
 * - 数据完整性校验
 * - 数字签名
 */
public class SMHashProvider implements HashProvider {

    private final KeycloakSession session;

    public SMHashProvider(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public String hash(String input) throws HashException {
        try {
            // 使用 Bouncy Castle 的 SM3 实现
            MessageDigest digest = MessageDigest.getInstance("SM3",
                    "BC");
            byte[] hashBytes = digest.digest(
                    input.getBytes(StandardCharsets.UTF_8));

            // 转换为十六进制字符串
            StringBuilder sb = new StringBuilder();
            for (byte b : hashBytes) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();

        } catch (Exception e) {
            throw new HashException("SM3 hash failed", e);
        }
    }

    @Override
    public boolean verify(String input, String hash)
            throws HashException {
        String computedHash = hash(input);
        return computedHash.equalsIgnoreCase(hash);
    }

    @Override
    public void close() {
        // 清理资源
    }
}

SMHashProviderFactory

java
package cc.bima.keycloak.extension.sm;

import org.keycloak.Config.Scope;
import org.keycloak.crypto.HashProvider;
import org.keycloak.crypto.HashProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

/**
 * 国密 SM3 哈希算法工厂
 *
 * SPI 配置示例(在 keycloak.conf 中):
 * spi-hash-bima-sm3-enabled=true
 */
public class SMHashProviderFactory implements HashProviderFactory {

    private static final String PROVIDER_ID = "bima-sm3";

    @Override
    public HashProvider create(KeycloakSession session) {
        return new SMHashProvider(session);
    }

    @Override
    public void init(Scope config) {
        // 初始化配置
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
        // 初始化后处理
    }

    @Override
    public void close() {
        // 清理资源
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }
}

SMSignatureProvider:国密 SM2 签名算法的实现,可用于 Token 签名和证书签发。

java
package cc.bima.keycloak.extension.sm;

import org.keycloak.crypto.SignatureException;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.models.KeycloakSession;

import java.security.*;
import java.security.spec.X509EncodedKeySpec;

/**
 * 国密 SM2 签名算法提供者
 *
 * SM2 是中国国家密码管理局发布的椭圆曲线公钥密码算法,
 * 基于256位椭圆曲线,安全性等同于3072位 RSA。
 *
 * 适用场景:
 * - JWT Token 签名
 * - SAML 断言签名
 * - 证书签发
 */
public class SMSignatureProvider implements SignatureProvider {

    private final KeycloakSession session;

    public SMSignatureProvider(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public byte[] sign(String signingInput, Key key)
            throws SignatureException {
        try {
            // 使用 Bouncy Castle 的 SM2 签名实现
            java.security.Signature signature =
                    java.security.Signature.getInstance(
                            "SM3withSM2", "BC");
            signature.initSign(key);
            signature.update(
                    signingInput.getBytes(StandardCharsets.UTF_8));
            return signature.sign();
        } catch (Exception e) {
            throw new SignatureException("SM2 signature failed", e);
        }
    }

    @Override
    public boolean verify(String signingInput, byte[] signatureBytes,
            Key key) throws SignatureException {
        try {
            java.security.Signature signature =
                    java.security.Signature.getInstance(
                            "SM3withSM2", "BC");
            signature.initVerify(key);
            signature.update(
                    signingInput.getBytes(StandardCharsets.UTF_8));
            return signature.verify(signatureBytes);
        } catch (Exception e) {
            throw new SignatureException("SM2 verification failed", e);
        }
    }

    @Override
    public void close() {
        // 清理资源
    }
}

在 Sandbox 中测试

  1. 确保 pom.xml 中包含 Bouncy Castle 依赖:
xml
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>1.78</version>
</dependency>
  1. 启动沙箱并发布 SPI
  2. 在 Realm 设置中选择 SM3 作为密码哈希算法
  3. 创建用户并设置密码,验证 SM3 哈希是否正常工作

5.4 用户存储 SPI 示例

项目结构

spi-user-storage-extension/
├── src/main/java/cc/bima/keycloak/extension/storage/
│   ├── CustomUserModel.java                    # 自定义用户模型
│   ├── CustomUserStorageProvider.java          # 用户存储提供者
│   ├── CustomUserStorageProviderFactory.java   # 用户存储工厂
│   └── CustomUserAdapter.java                  # 用户适配器
├── src/main/resources/
│   └── META-INF/services/
│       └── org.keycloak.storage.UserStorageProviderFactory
└── pom.xml

核心实现

CustomUserStorageProvider:自定义用户存储提供者,演示如何从外部数据源加载用户。

java
package cc.bima.keycloak.extension.storage;

import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.*;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;

import java.util.*;
import java.util.stream.Stream;

/**
 * 自定义用户存储提供者
 *
 * 功能说明:
 * 1. 从外部数据源(数据库、LDAP、REST API 等)加载用户
 * 2. 支持用户查找、查询和认证
 * 3. 支持用户属性映射
 *
 * 本示例使用内存 Map 模拟外部数据源,
 * 实际项目中应替换为真实的数据库或 API 调用。
 */
public class CustomUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider,
        CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final Map<String, Map<String, String>> externalUserStore;

    public CustomUserStorageProvider(KeycloakSession session,
            ComponentModel model) {
        this.session = session;
        this.model = model;

        // 模拟外部用户数据源
        this.externalUserStore = new HashMap<>();
        Map<String, String> user1 = new HashMap<>();
        user1.put("username", "zhangsan");
        user1.put("email", "zhangsan@example.com");
        user1.put("firstName", "San");
        user1.put("lastName", "Zhang");
        user1.put("password", "hashed_password_123");
        externalUserStore.put("zhangsan", user1);

        Map<String, String> user2 = new HashMap<>();
        user2.put("username", "lisi");
        user2.put("email", "lisi@example.com");
        user2.put("firstName", "Si");
        user2.put("lastName", "Li");
        user2.put("password", "hashed_password_456");
        externalUserStore.put("lisi", user2);
    }

    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        String externalId = StorageId.externalId(id);
        return getUserByUsername(realm, externalId);
    }

    @Override
    public UserModel getUserByUsername(RealmModel realm,
            String username) {
        Map<String, String> userData = externalUserStore.get(username);
        if (userData == null) {
            return null;
        }
        return new CustomUserAdapter(session, realm, model,
                username, userData);
    }

    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        // 遍历外部数据源查找匹配邮箱的用户
        for (Map.Entry<String, Map<String, String>> entry
                : externalUserStore.entrySet()) {
            if (email.equals(entry.getValue().get("email"))) {
                return new CustomUserAdapter(session, realm, model,
                        entry.getKey(), entry.getValue());
            }
        }
        return null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return PasswordCredentialModel.TYPE.equals(credentialType);
    }

    @Override
    public boolean isConfiguredFor(RealmModel realm,
            UserModel user, String credentialType) {
        return supportsCredentialType(credentialType);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user,
            CredentialInput input) {
        if (!supportsCredentialType(input.getType())) {
            return false;
        }

        // 从外部数据源验证密码
        String username = user.getUsername();
        Map<String, String> userData =
                externalUserStore.get(username);
        if (userData == null) {
            return false;
        }

        // 实际项目中应使用安全的密码比较方式
        String storedPassword = userData.get("password");
        // 这里简化处理,实际应使用 BCrypt 等算法验证
        return storedPassword != null;
    }

    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm,
            String search, Integer firstResult,
            Integer maxResults) {
        return externalUserStore.entrySet().stream()
                .filter(e -> e.getKey().contains(search.toLowerCase())
                        || e.getValue().get("email")
                                .contains(search.toLowerCase()))
                .skip(firstResult != null ? firstResult : 0)
                .limit(maxResults != null ? maxResults : Long.MAX_VALUE)
                .map(e -> new CustomUserAdapter(session, realm, model,
                        e.getKey(), e.getValue()));
    }

    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm,
            Map<String, String> params, Integer firstResult,
            Integer maxResults) {
        String search = params.get(UserModel.SEARCH);
        return search != null && !search.isEmpty()
                ? searchForUserStream(realm, search, firstResult,
                        maxResults)
                : searchForUserStream(realm, "", firstResult, maxResults);
    }

    @Override
    public Stream<UserModel> getGroupMembersStream(RealmModel realm,
            GroupModel group, Integer firstResult,
            Integer maxResults) {
        return Stream.empty();
    }

    @Override
    public Stream<UserModel> searchForUserByUserAttributeStream(
            RealmModel realm, String attrName, String attrValue) {
        return Stream.empty();
    }

    @Override
    public void close() {
        // 清理资源(如关闭数据库连接等)
    }
}

CustomUserStorageProviderFactory

java
package cc.bima.keycloak.extension.storage;

import org.keycloak.Config.Scope;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.storage.UserStorageProviderFactory;

/**
 * 自定义用户存储提供者工厂
 *
 * SPI 配置示例(在 keycloak.conf 中):
 * spi-user-storage-bima-custom-enabled=true
 */
public class CustomUserStorageProviderFactory
        implements UserStorageProviderFactory<CustomUserStorageProvider> {

    private static final String PROVIDER_ID = "bima-custom";

    @Override
    public CustomUserStorageProvider create(KeycloakSession session,
            ComponentModel model) {
        return new CustomUserStorageProvider(session, model);
    }

    @Override
    public void init(Scope config) {
        // 读取外部数据源配置(如数据库连接信息等)
        // String dbUrl = config.get("db-url");
        // String dbUser = config.get("db-user");
        // String dbPassword = config.get("db-password");
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
        // 初始化后处理
    }

    @Override
    public void close() {
        // 清理资源
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }
}

在 Sandbox 中测试

  1. 启动沙箱并发布 SPI
  2. 访问 Keycloak 管理控制台
  3. 进入 Realm 设置 -> User Federation
  4. 添加新的用户存储提供者,选择 "bima-custom"
  5. 保存配置后,在 Users 页面应该能看到外部数据源中的用户(zhangsan、lisi)
  6. 尝试使用这些用户登录

5.5 开发新 SPI 扩展的完整流程

基于 Keycloak Sandbox 开发新的 SPI 扩展,可以按照以下标准流程进行。这个流程经过了大量实践验证,能够帮助开发者高效地完成从项目创建到功能验证的全过程。

SPI 开发的关键原则

在开始编写代码之前,有必要了解 Keycloak SPI 开发的几个关键原则:

原则一:始终使用 Provider-Factory 模式。 Keycloak 的 SPI 加载机制依赖于工厂类。每个 SPI 提供者都必须有一个对应的工厂类,工厂类负责创建提供者实例并管理其生命周期。忘记注册工厂类是新手最常犯的错误之一。

原则二:正确处理资源生命周期。 SPI 提供者的 close() 方法必须正确释放所有资源(数据库连接、线程池、文件句柄等)。Keycloak 在重新加载 SPI 或关闭时会调用 close() 方法,如果资源未正确释放,会导致内存泄漏或端口占用。

原则三:避免在 SPI 中引入重量级依赖。 SPI JAR 会被加载到 Keycloak 的类路径中,如果引入了与 Keycloak 冲突的第三方库版本,可能导致运行时异常。建议使用 provided 作用域声明依赖,或者使用 Shade 插件将依赖重定位打包。

原则四:充分利用 Keycloak 的配置机制。 通过 ComponentModelScope 对象获取配置参数,而不是在代码中硬编码。这样管理员可以在 Keycloak 管理控制台中动态调整 SPI 的行为,无需重新编译和部署。

原则五:编写完善的单元测试。 Keycloak 提供了 KeycloakSession 的模拟框架,可以在不启动完整 Keycloak 服务的情况下测试 SPI 逻辑。单元测试不仅能够保障代码质量,也是文档化 SPI 行为的重要手段。

步骤一:创建 Maven 模块

在项目根目录下创建新的 Maven 模块:

bash
mkdir -p spi-my-extension/src/main/java/cc/bima/keycloak/extension/my
mkdir -p spi-my-extension/src/main/resources/META-INF/services

编写 pom.xml

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>spi-my-extension</artifactId>
    <name>My SPI Extension</name>

    <dependencies>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-core</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>

步骤二:实现 SPI 接口

根据需要扩展的 SPI 类型,实现对应的接口。以自定义认证器为例:

java
package cc.bima.keycloak.extension.my;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.*;

/**
 * 自定义认证器示例
 */
public class MyAuthenticator implements Authenticator {

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // 实现自定义认证逻辑
        // 例如:验证验证码、检查设备指纹等
        context.success();
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // 处理表单提交等交互操作
    }

    @Override
    public boolean requiresUser() {
        return true;
    }

    @Override
    public boolean configuredFor(KeycloakSession session,
            RealmModel realm, UserModel user) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session,
            RealmModel realm, UserModel user) {
        // 设置用户需要完成的必要操作
    }

    @Override
    public void close() {
        // 清理资源
    }
}

步骤三:创建工厂类和服务注册文件

java
package cc.bima.keycloak.extension.my;

import org.keycloak.Config.Scope;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.*;

/**
 * 自定义认证器工厂
 */
public class MyAuthenticatorFactory implements AuthenticatorFactory {

    @Override
    public String getId() {
        return "my-authenticator";
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        return new MyAuthenticator();
    }

    @Override
    public void init(Scope config) {}

    @Override
    public void postInit(KeycloakSessionFactory factory) {}

    @Override
    public void close() {}

    @Override
    public String getDisplayType() {
        return "My Custom Authenticator";
    }

    @Override
    public String getReferenceCategory() {
        return "custom";
    }

    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[]{
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.DISABLED
        };
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public String getHelpText() {
        return "A custom authenticator for demonstration purposes.";
    }
}

服务注册文件 META-INF/services/org.keycloak.authentication.AuthenticatorFactory

cc.bima.keycloak.extension.my.MyAuthenticatorFactory

步骤四:注册模块并发布

  1. 在父 POM 的 <modules> 中添加新模块
  2. keycloak-server-extensions<dependencies> 中添加对新模块的依赖
  3. ExtensionPackagesMainSPI_MODULES 数组中添加新模块名称
  4. 运行 ExtensionPackagesMain 打包发布
  5. 在 Keycloak 管理控制台中配置新的 SPI

第六章 开发工作流最佳实践

6.1 IDE 集成配置(IntelliJ IDEA)

IntelliJ IDEA 是 Java 开发者最常用的 IDE,以下是 Keycloak Sandbox 在 IDEA 中的最佳配置方式。

项目导入

  1. 打开 IntelliJ IDEA,选择 File -> Open
  2. 选择项目根目录下的 pom.xml 文件
  3. 选择 "Open as Project"
  4. 等待 Maven 导入完成(IDEA 右下角会显示进度)

运行配置

为常用的操作创建运行配置,避免每次都手动输入命令:

  1. Run -> Edit Configurations -> Add New -> Application
  2. 创建以下运行配置:
配置名称Main Class工作目录
Start Docker Sandboxcc.bima.keycloak.server.docker.KeycloakServerStartkeycloak-server-docker
Stop Docker Sandboxcc.bima.keycloak.server.docker.KeycloakServerStopkeycloak-server-docker
Start Release Sandboxcc.bima.keycloak.server.release.KeycloakServerStartkeycloak-server-release
Stop Release Sandboxcc.bima.keycloak.server.release.KeycloakServerStopkeycloak-server-release
Package Extensionscc.bima.keycloak.extension.packages.ExtensionPackagesMainkeycloak-server-extensions

Maven 配置

  1. File -> Settings -> Build, Execution, Deployment -> Build Tools -> Maven
  2. 设置 Maven home path(推荐使用 Maven 3.9+)
  3. 在 "Runner" 选项卡中设置 VM Options:-Dkeycloak.version=26.6.1

代码辅助

  1. 安装 Keycloak SPI 相关的代码提示插件(如有)
  2. 配置 Keycloak 源码作为库源码,便于查看 SPI 接口定义
  3. 在 Settings -> Editor -> Code Style -> Java 中配置代码格式化规则

6.2 日常开发流程

基于 Keycloak Sandbox 的日常 SPI 开发流程如下:

┌──────────────────────────────────────────────────────────────────┐
│                    日常开发流程                                    │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. 启动沙箱                                                      │
│     └─> 右键运行 KeycloakServerStart                             │
│                                                                  │
│  2. 编写/修改 SPI 代码                                            │
│     └─> 在对应的 SPI 模块中编写代码                               │
│                                                                  │
│  3. 打包发布                                                      │
│     └─> 右键运行 ExtensionPackagesMain                           │
│                                                                  │
│  4. 验证功能                                                      │
│     └─> 访问 http://localhost:8080 测试                          │
│                                                                  │
│  5. 重复步骤 2-4                                                  │
│                                                                  │
│  6. 完成开发                                                      │
│     └─> 右键运行 KeycloakServerStop                              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

推荐的开发节奏

  • 使用 Release 版沙箱进行日常开发(文件访问方便、重启快速)
  • 使用 Docker 版沙箱进行最终验证(环境更接近生产环境)
  • 每次修改 SPI 代码后,先在 IDE 中编译确认没有语法错误
  • 然后运行 ExtensionPackagesMain 打包发布
  • 最后在浏览器中验证功能

开发效率提升技巧

在实际开发中,以下几个技巧可以显著提升 SPI 开发效率:

技巧一:使用 IDE 的热部署功能。 IntelliJ IDEA 支持在运行时自动编译修改的类文件。配合 Keycloak Release 版沙箱的热重载能力,可以实现"修改代码 -> 自动编译 -> 自动重载"的半自动化开发流程。配置方法:Settings -> Build, Execution, Deployment -> Compiler -> 勾选 "Build project automatically"。

技巧二:利用 Keycloak 的管理控制台进行快速测试。 Keycloak 管理控制台提供了丰富的测试入口。例如,测试用户存储 SPI 时,可以直接在 Users 页面搜索外部用户;测试事件监听器 SPI 时,可以执行登录/登出操作触发事件;测试密码策略 SPI 时,可以尝试设置不同强度的密码。

技巧三:编写可重复的测试脚本。 使用 curl 或 Postman 编写 API 测试脚本,自动化常见的测试操作。例如,以下脚本可以测试用户登录并获取 Token:

bash
#!/bin/bash
# test-login.sh - 测试用户登录并获取 Token

KEYCLOAK_URL="http://localhost:8080"
REALM="master"
CLIENT_ID="admin-cli"
USERNAME="root"
PASSWORD="root"

# 获取 Access Token
TOKEN=$(curl -s -X POST \
    "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=password" \
    -d "client_id=${CLIENT_ID}" \
    -d "username=${USERNAME}" \
    -d "password=${PASSWORD}" \
    | jq -r '.access_token')

echo "Token obtained: ${TOKEN:0:50}..."

# 使用 Token 调用 API
curl -s -X GET \
    "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \
    -H "Authorization: Bearer ${TOKEN}" \
    | jq '.[].username'

技巧四:使用日志断点替代传统断点。 在调试复杂的 SPI 逻辑时,传统断点可能会中断 Keycloak 的正常运行流程(例如事件监听器中的断点会阻塞 HTTP 请求的处理)。使用日志断点(Logpoint)可以在不中断执行的情况下输出调试信息,更适合 Keycloak SPI 的调试场景。

技巧五:建立 SPI 开发的检查清单。 在每次打包发布前,按照以下清单检查可以避免常见错误:

  1. 确认 META-INF/services/ 目录下的服务注册文件内容正确
  2. 确认工厂类的 getId() 方法返回值与配置中的 SPI 名称一致
  3. 确认所有第三方依赖的 scope 设置为 provided
  4. 确认 close() 方法中释放了所有资源
  5. 确认没有在 SPI 代码中硬编码配置值
  6. 确认单元测试全部通过

6.3 版本切换与兼容性测试

Keycloak Sandbox 的版本切换非常简单,只需修改一个配置:

xml
<!-- 父 pom.xml -->
<properties>
    <keycloak.version>26.6.1</keycloak.version>  <!-- 修改此处 -->
</properties>

这种极简的版本切换方式是 Keycloak Sandbox 相比其他开发环境方案的核心优势之一。在传统的开发方式中,切换 Keycloak 版本通常需要经历以下步骤:下载新版本发行包、解压到指定目录、修改环境变量或启动脚本、重新配置数据库连接、迁移现有数据、重新部署所有 SPI 扩展。整个过程可能需要半小时甚至更长时间。而在 Keycloak Sandbox 中,只需修改一行配置并重新构建,版本切换在几分钟内即可完成。

版本切换步骤

  1. 修改父 POM 中的 keycloak.version
  2. 执行 mvn clean compile 重新构建项目
  3. 停止当前运行的沙箱
  4. 启动沙箱(会自动下载新版本的镜像或发行包)
  5. 重新打包发布 SPI
  6. 验证功能是否正常

版本兼容性注意事项

在进行跨大版本切换时(例如从 Keycloak 22.x 切换到 26.x),开发者需要注意以下兼容性问题:

  1. SPI 接口变更:Keycloak 在大版本升级时可能会修改 SPI 接口的方法签名或添加新的抽象方法。开发者需要检查自己的 SPI 实现类是否仍然正确实现了所有接口方法。

  2. 依赖库版本冲突:不同版本的 Keycloak 依赖的第三方库版本可能不同。如果 SPI 扩展中引用了这些第三方库,可能会出现版本冲突。

  3. 配置格式变更:Keycloak 的配置文件格式在不同版本间可能有所变化。例如,从 WildFly 版本迁移到 Quarkus 版本时,配置文件从 standalone.xml 变为了 keycloak.conf

  4. SPI 发现机制变更:Keycloak 在某些版本中调整了 SPI 的发现和加载机制。例如,Quarkus 版本要求 SPI JAR 放置在 providers 目录下,而 WildFly 版本使用 deployments 目录。

兼容性测试策略

建议在 CI/CD 流水线中配置多版本矩阵测试:

yaml
# GitHub Actions 示例
strategy:
  matrix:
    keycloak-version: ['22.0', '23.0', '24.0', '25.0', '26.0']
steps:
  - name: Build and Test
    run: |
      mvn clean package \
        -Dkeycloak.version=${{ matrix.keycloak-version }}

6.4 调试技巧

调试是 SPI 开发中最耗时也最关键的环节。Keycloak 作为一个大型的 Java 应用,其内部机制复杂,SPI 的执行路径可能涉及多个组件的交互。掌握高效的调试技巧,可以显著缩短问题定位和修复的时间。以下技巧适用于 Docker 版和 Release 版两种沙箱环境。

技巧一:启用 DEBUG 日志

在启动沙箱时设置日志级别为 DEBUG,可以看到 Keycloak 的详细内部日志:

bash
# Release 版沙箱
export KC_LOG_LEVEL=DEBUG
export QUARKUS_LOG_LEVEL=DEBUG
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev

技巧二:查看 SPI 加载情况

在 Keycloak 启动日志中搜索 "SPI" 关键字,可以查看所有已加载的 SPI 提供者:

bash
~/.keycloak-sandbox/keycloak-26.6.1/bin/kc.sh start-dev 2>&1 \
    | grep -i "spi\|provider"

技巧三:使用管理控制台查看 SPI 配置

Keycloak 管理控制台提供了 SPI 配置的查看和修改界面:

  1. 登录管理控制台
  2. 进入 Realm Settings
  3. 点击 "SPI" 标签页
  4. 查看所有已注册的 SPI 提供者及其配置

技巧四:远程调试 SPI 代码

  1. 在启动类中添加调试参数
  2. 在 IDE 中配置远程调试连接
  3. 在 SPI 代码中设置断点
  4. 触发 SPI 执行(如用户登录、事件发生等)
  5. 在 IDE 中逐步调试

6.5 团队协作建议

统一开发环境

  • 所有团队成员使用相同版本的 JDK(推荐 JDK 17 或 21)
  • 使用 Maven Wrapper(mvnw)确保 Maven 版本一致
  • 将 IDE 配置文件(.idea 目录或 .editorconfig)纳入版本控制

代码规范

  • 遵循 Keycloak 的代码风格规范
  • 为每个 SPI 扩展编写单元测试
  • 使用 Checkstyle 或 SpotBugs 进行静态代码分析

版本管理策略

  • 使用 Git 分支管理不同 Keycloak 版本的适配代码
  • 主分支保持与最新稳定版 Keycloak 兼容
  • 为每个 Keycloak 大版本创建维护分支

文档维护

  • 在 SPI 模块的 README.md 中记录功能说明和配置方式
  • keycloak.conf 中添加注释说明每个 SPI 配置项的用途
  • 维护一份 SPI 扩展的变更日志

6.6 持续集成与自动化部署

将 Keycloak Sandbox 的能力延伸到 CI/CD 流水线中,可以实现 SPI 扩展的自动化构建、测试和部署。以下是基于 GitHub Actions 的 CI/CD 配置示例:

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

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        keycloak-version: ['22.0.5', '24.0.5', '26.0.0']
        java-version: ['17', '21']

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'

      - name: Cache Maven packages
        uses: actions/cache@v4
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}

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

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

      - name: Start Release Sandbox
        run: |
          cd keycloak-server-release
          mvn compile exec:java \
            -Dexec.mainClass="cc.bima.keycloak.server.release.KeycloakServerStart" \
            -Dkeycloak.version=${{ matrix.keycloak-version }} &
          sleep 60

      - name: Package and Deploy SPI
        run: |
          cd keycloak-server-extensions
          mvn compile exec:java \
            -Dexec.mainClass="cc.bima.keycloak.extension.packages.ExtensionPackagesMain" \
            -Dexec.args="release" \
            -Dkeycloak.version=${{ matrix.keycloak-version }}

      - name: Run integration tests
        run: |
          mvn verify -Pintegration-tests \
            -Dkeycloak.version=${{ matrix.keycloak-version }}

      - name: Stop Sandbox
        if: always()
        run: |
          cd keycloak-server-release
          mvn exec:java \
            -Dexec.mainClass="cc.bima.keycloak.server.release.KeycloakServerStop" \
            -Dkeycloak.version=${{ matrix.keycloak-version }} || true

这个 CI/CD 配置实现了以下自动化流程:

  1. 多版本矩阵测试:在 Keycloak 22.0.5、24.0.5、26.0.0 三个版本上分别测试,确保 SPI 扩展的跨版本兼容性。
  2. 多 JDK 版本测试:在 JDK 17 和 JDK 21 上分别测试,确保 Java 运行时的兼容性。
  3. 自动化构建和部署:自动编译 SPI 模块、启动沙箱、部署 SPI、运行集成测试。
  4. 资源清理:无论测试是否通过,都会尝试停止沙箱环境,避免资源泄漏。

6.7 性能优化与调优建议

在开发过程中,合理的性能优化可以显著提升开发效率。以下是针对 Keycloak Sandbox 的性能优化建议:

Maven 构建优化

在父 POM 中配置 Maven 构建优化参数:

xml
<properties>
    <!-- 启用 Maven 并行构建 -->
    <maven.parallel>threads</maven.parallel>
    <!-- 构建线程数 -->
    <maven.threads>1C</maven.threads>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <!-- 使用编译器守护进程加速增量编译 -->
                <forceJavacCompilerUse>false</forceJavacCompilerUse>
                <!-- 增量编译 -->
                <useIncrementalCompilation>true</useIncrementalCompilation>
            </configuration>
        </plugin>
    </plugins>
</build>

JVM 启动优化

在开发环境中,可以调整 JVM 参数以加快 Keycloak 的启动速度:

bash
# Release 版沙箱的 JVM 优化参数
export JAVA_OPTS_APPEND="
    -Xms512m
    -Xmx1024m
    -XX:TieredStopAtLevel=1
    -XX:+UseParallelGC
    -XX:ParallelGCThreads=4
    -Djava.security.egd=file:/dev/./urandom
"

其中 -XX:TieredStopAtLevel=1 参数会禁用 C2 编译器,牺牲一定的运行时性能换取更快的启动速度。这在开发环境中是值得的权衡。

Docker 性能优化

对于 Docker 版沙箱,可以通过以下方式提升性能:

yaml
services:
  keycloak:
    # ... 其他配置 ...
    environment:
      # 减少 Quarkus 启动时的扫描范围
      QUARKUS_APPLICATION_NAME: keycloak-sandbox
      # 禁用开发模式下的不必要功能
      QUARKUS_DEV_SERVICES_ENABLED: "false"
    # 使用绑定挂载替代命名卷(性能更好)
    volumes:
      - type: bind
        source: ~/.keycloak-sandbox/providers
        target: /opt/keycloak/providers

6.8 双沙箱切换策略

在实际开发中,开发者可能需要在 Docker 版和 Release 版沙箱之间频繁切换。以下是一些实用的切换策略:

场景一:日常开发使用 Release 版,最终验证使用 Docker 版

这是最推荐的切换策略。日常开发阶段使用 Release 版沙箱,因为文件访问方便、重启快速。当 SPI 开发完成需要进行最终验证时,切换到 Docker 版沙箱,在更接近生产环境的环境中验证功能。

bash
# 日常开发:使用 Release 版沙箱
# Step 1: 启动 Release 沙箱
cd keycloak-server-release
mvn compile exec:java -Dexec.mainClass="...KeycloakServerStart"

# Step 2: 打包发布 SPI
cd ../keycloak-server-extensions
mvn compile exec:java -Dexec.mainClass="...ExtensionPackagesMain" -Dexec.args="release"

# ... 日常开发和调试 ...

# 最终验证:切换到 Docker 版沙箱
# Step 1: 停止 Release 沙箱
cd ../keycloak-server-release
mvn exec:java -Dexec.mainClass="...KeycloakServerStop"

# Step 2: 启动 Docker 沙箱
cd ../keycloak-server-docker
mvn compile exec:java -Dexec.mainClass="...KeycloakServerStart"

# Step 3: 打包发布 SPI 到 Docker 沙箱
cd ../keycloak-server-extensions
mvn compile exec:java -Dexec.mainClass="...ExtensionPackagesMain" -Dexec.args="docker"

场景二:多版本并行测试

当需要同时测试多个 Keycloak 版本时,可以使用 Docker 版沙箱运行多个容器:

bash
# 启动 Keycloak 22.0.5
docker run -d --name kc-22 \
    -p 18080:8080 \
    -e KEYCLOAK_ADMIN=root \
    -e KEYCLOAK_ADMIN_PASSWORD=root \
    quay.io/keycloak/keycloak:22.0.5 start-dev

# 启动 Keycloak 26.0.0
docker run -d --name kc-26 \
    -p 28080:8080 \
    -e KEYCLOAK_ADMIN=root \
    -e KEYCLOAK_ADMIN_PASSWORD=root \
    quay.io/keycloak/keycloak:26.0.0 start-dev

# 分别访问 http://localhost:18080 和 http://localhost:28080 进行测试

场景三:Release 版用于调试,Docker 版用于演示

在需要向团队或客户演示 SPI 功能时,Docker 版沙箱的环境一致性更好,避免了"在我机器上能跑"的尴尬。而日常调试仍然使用 Release 版沙箱。


总结与展望

Keycloak Sandbox 双沙箱开发环境为 Keycloak SPI 扩展开发提供了一套完整的解决方案。通过 Maven 多模块架构实现项目结构的标准化,通过 Docker/Release 双沙箱满足不同开发场景的需求,通过一键打包发布机制简化部署流程,通过内置示例降低学习门槛。

本文从架构设计、核心实现、开发实践三个维度对 Keycloak Sandbox 进行了全面的技术解析。我们深入剖析了以下核心内容:

架构层面:Keycloak Sandbox 采用四层架构设计——基础配置层(父 POM)、沙箱运行层(Docker/Release 双沙箱)、打包发布层(ExtensionPackagesMain)和 SPI 扩展层(三个内置示例)。这种分层设计确保了各层之间的松耦合,使得每一层都可以独立演进和替换。

实现层面:Docker 版沙箱通过 Maven 资源过滤实现版本号动态注入,通过 Docker Compose 实现容器生命周期管理;Release 版沙箱通过自动化下载和解压机制实现 Keycloak 发行包的本地管理,通过进程管理 API 实现 Keycloak 进程的优雅启停;打包发布模块通过 Maven 增量构建和文件系统操作实现 SPI 的一键部署。

实践层面:三个内置 SPI 示例覆盖了事件监听、国密算法和用户存储三大常见扩展场景,为开发者提供了完整的参考实现。从 IDE 集成配置到 CI/CD 流水线,从性能调优到故障排查,本文提供了一套完整的开发工作流最佳实践。

技术价值总结

Keycloak Sandbox 的核心价值在于它将 Keycloak SPI 开发中的"环境管理"这一非功能性需求彻底抽象化。开发者不再需要关心 Keycloak 安装在哪里、如何启动、如何部署 SPI,而是可以将全部精力集中在 SPI 的业务逻辑实现上。这种抽象不仅提升了个人开发效率,更重要的是为团队协作和持续集成奠定了标准化基础。

从技术选型的角度来看,Keycloak Sandbox 的设计体现了以下几个重要的工程原则:

  1. 约定优于配置:通过标准化的项目结构和命名约定,减少开发者的配置工作量。
  2. 渐进式复杂度:默认配置满足基本需求,高级配置通过环境变量和配置文件灵活定制。
  3. 关注点分离:运行环境管理与业务逻辑实现完全解耦,互不影响。
  4. 工具链集成:深度集成 Maven 和 IDE,将操作封装为可执行的 Java 类,而非独立脚本。

展望未来,Keycloak Sandbox 有以下几个可能的发展方向:

第一,支持更多沙箱类型。 除了 Docker 和 Release 版,可以考虑支持 Kubernetes(Minikube/Kind)作为第三种沙箱环境,满足云原生场景下的开发和测试需求。随着越来越多的企业将 Keycloak 部署在 Kubernetes 上,在开发环境中模拟 Kubernetes 部署场景变得越来越重要。

第二,集成自动化测试框架。 提供基于 TestContainers 或 Selenium 的自动化测试模板,支持 SPI 功能的端到端测试。自动化测试是保障 SPI 质量的关键手段,但目前 Keycloak 社区在这方面的工具支持还不够完善。

第三,支持 SPI 脚手架生成。 提供交互式的 SPI 项目生成器,开发者只需选择 SPI 类型并输入基本配置,即可自动生成完整的项目骨架代码。这将进一步降低 Keycloak SPI 开发的入门门槛。

第四,社区生态建设。 建立共享的 SPI 扩展仓库,鼓励开发者贡献和复用 SPI 实现,形成 Keycloak 扩展的生态系统。类似于 VS Code 的插件市场或 Maven Central 的依赖仓库,一个 SPI 扩展共享平台将极大地促进 Keycloak 生态的繁荣。

第五,可视化配置管理。 提供基于 Web 的 SPI 配置管理界面,开发者可以在浏览器中直观地配置 SPI 参数、查看运行状态、监控事件日志,而无需手动编辑配置文件或查看终端日志。

Keycloak 作为身份认证领域的重要基础设施,其 SPI 生态的繁荣程度直接影响着企业级身份管理解决方案的丰富性。Keycloak Sandbox 的目标正是降低 SPI 开发的门槛,让更多的开发者能够参与到 Keycloak 生态的建设中来。

我们相信,随着 Keycloak 在国内企业级市场的普及,以及国家对密码安全和数据合规的要求日益严格,Keycloak SPI 扩展开发的需求将持续增长。Keycloak Sandbox 将持续演进,为开发者提供更加强大和便捷的开发工具。


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

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

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