使用的 Spring Boot 3 的 SpringApplicationRunListener 实现自定义 Banner Starter 启动器

以下是一个完整、标准、可直接使用的 Spring Boot 3 自定义 Banner Starter 实现方案,基于 SpringApplicationRunListener 实现,在应用启动最早期(日志输出前)替换默认 Banner,并支持通过 application.yaml 动态配置 Banner 内容。


✅ 项目目标

开发一个名为 custom-banner-starter 的 Spring Boot Starter,实现:

  • 自动替换 Spring Boot 默认 Banner;
  • Banner 内容可通过 application.yaml 配置;
  • 启动时机与官方一致:在日志输出前第一个打印
  • 无需手动注册,通过 spring.factories 自动加载;
  • 支持 Spring Boot 3.x(兼容 Java 17+);

📁 项目结构

spring-boot-custom-banner-starter/
├── src/
│   └── main/
│       ├── java/
│       │   └── com/example/banner/
│       │       ├── CustomBanner.java                 # 自定义 Banner 实现
│       │       ├── CustomBannerAutoConfiguration.java # 自动配置类(可选,用于配置绑定)
│       │       └── CustomBannerProperties.java       # 配置属性类
│       └── resources/
│           ├── META-INF/
│           │   ├── spring.factories	# ✅ SPI 注册文件
│           │   └── spring/
│           │       └── org.springframework.boot.autoconfigure.AutoConfiguration.imports  # ✅ 自动配置注册(仅用于绑定配置)
│           └── application.yml.example               # 配置示例
└── pom.xml

🔧 1. CustomBannerProperties.java —— 配置属性类

package com.example.custombanner;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * 自定义 Banner 配置属性类
 * 通过 application.yaml 配置,支持动态定制 Banner 内容
 * 前缀:custom.banner
 */
@Setter
@Getter 
@ConfigurationProperties(prefix = CustomBannerProperties.PREFIX)
public class CustomBannerProperties {
	public static final String PREFIX = "custom.banner";

    // 是否启用自定义 Banner(默认 true)
    private boolean enabled = true;

    // 默认标题(可覆盖)
    private String applicationName = "CUSTOM SPRING BOOT";

    // 项目版本号(默认使用 pom.xml 中的版本,也可自定义)
    private String version = "1.0.0";
    
    // 项目作者
    private String author;
    
    // 项目描述,默认取 pom.xml 中的 description
    private String description;
    
    // 是否显示系统信息   
    private boolean showSystemInfo = true;

    // 是否显示版本信息(默认 true)
    private boolean showVersion = true;

    // 输出类型
    private BannerType type = BannerType.TEXT;
    
    @Getter
    @RequiredArgsConstructor
    public enum BannerType {
        TEXT("text", "Text banner"),
        FIGLET("figlet", "Figlet banner"),
        IMAGE("image", "Image banner");

        private final String value;
        private final String description;
    }
}

✅ 说明:使用 @ConfigurationProperties 绑定 custom.banner.* 配置项,支持默认值和动态覆盖。


🔧 2. CustomSpringApplicationRunListener.java —— 核心监听器

package com.example.custombanner;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringApplicationRunListener;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StringUtils;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Properties;

/**
 * 自定义 SpringApplicationRunListener,用于在 Spring Boot 启动最初始阶段(日志输出前)替换 Banner
 * 该监听器会在 SpringApplication.run() 调用时,由 Spring Boot 自动发现并执行
 * 优先级高于默认 Banner,确保在任何日志输出之前打印自定义内容
 */
package com.example.banner.listener;

import com.example.banner.printer.FigletBannerPrinter;
import com.example.banner.printer.ImageBannerPrinter;
import com.example.banner.printer.TextBannerPrinter;
import org.springframework.boot.ConfigurableBootstrapContext;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringApplicationRunListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;

import java.time.Duration;
import java.time.Instant;

/**
 * 自定义 SpringApplicationRunListener,用于在 Spring Boot 启动最初始阶段(日志输出前)替换 Banner
 * 该监听器会在 SpringApplication.run() 调用时,由 Spring Boot 自动发现并执行
 * 优先级高于默认 Banner,确保在任何日志输出之前打印自定义内容
 */
// 可参考 Spring Boot 官方的示例类 EventPublishingRunListener
public class CustomSpringApplicationRunListener implements SpringApplicationRunListener, Ordered {
    private final SpringApplication application;
    private final String[] args;
    private Instant startTime;
    private Instant contextPreparedTime;

    /**
     * 必须的构造函数
     * Spring Boot 会通过反射调用这个构造函数
     */
    public CustomSpringApplicationRunListener(SpringApplication application, String[] args) {
        this.application = application;
        this.args = args;
        System.out.println("CustomSpringApplicationRunListener 构造函数被调用");
    }
    
    @Override
    public void starting(ConfigurableBootstrapContext bootstrapContext) {
        startTime = Instant.now();
        System.out.println("🚀 [阶段1 - starting] 应用启动开始");
        System.out.println("   启动时间: " + startTime);
        System.out.println("   主应用类: " + application.getMainApplicationClass());
        System.out.println("   命令行参数: " + String.join(", ", args));
    }

    @Override
    public void environmentPrepared(ConfigurableBootstrapContext bootstrapContext, ConfigurableEnvironment environment) {
        System.out.println("🌍 [阶段2 - environmentPrepared] 环境准备完成");
        System.out.println("   激活的配置文件: " + String.join(", ", environment.getActiveProfiles()));
        System.out.println("   默认配置文件: " + String.join(", ", environment.getDefaultProfiles()));
        System.out.println("   应用名称: " + environment.getProperty("spring.application.name", "未设置"));
        switch (environment.getProperty("custom.banner.type", "figlet")) {
            case "text":
                new TextBannerPrinter().printBanner(environment, application.getMainApplicationClass(), System.out);
                break;
            case "figlet":
                new FigletBannerPrinter().printBanner(environment, application.getMainApplicationClass(), System.out);
                break;
            case "image":
                new ImageBannerPrinter().printBanner(environment, application.getMainApplicationClass(), System.out);
                break;
        }

    }

    @Override
    public void contextPrepared(ConfigurableApplicationContext context) {
        contextPreparedTime = Instant.now();
        System.out.println("🔧 [阶段3 - contextPrepared] 应用上下文创建完成");
        System.out.println("   上下文ID: " + context.getId());
        System.out.println("   上下文显示名称: " + context.getDisplayName());
        System.out.println("   启动阶段耗时: " +
                Duration.between(startTime, contextPreparedTime).toMillis() + "ms");
    }

    @Override
    public void contextLoaded(ConfigurableApplicationContext context) {
        Instant contextLoadedTime = Instant.now();
        System.out.println("📦 [阶段4 - contextLoaded] Bean定义加载完成");
        System.out.println("   Bean定义数量: " + context.getBeanDefinitionCount());
        System.out.println("   上下文准备耗时: " +
                Duration.between(contextPreparedTime, contextLoadedTime).toMillis() + "ms");
    }

    @Override
    public void started(ConfigurableApplicationContext context, Duration timeTaken) {
        System.out.println("✅ [阶段5 - started] 应用上下文刷新完成");
        System.out.println("   启动总耗时: " + timeTaken.toMillis() + "ms");
        System.out.println("   所有Bean定义: " + String.join(", ", context.getBeanDefinitionNames()));
    }

    @Override
    public void ready(ConfigurableApplicationContext context, Duration timeTaken) {
        System.out.println("🎉 [阶段6 - ready] 应用完全启动完成");
        System.out.println("   就绪阶段耗时: " + timeTaken.toMillis() + "ms");
        System.out.println("   总启动时间: " +
                Duration.between(startTime, Instant.now()).toMillis() + "ms");
    }

    @Override
    public void failed(ConfigurableApplicationContext context, Throwable exception) {
        SpringApplicationRunListener.super.failed(context, exception);
        System.out.println("❌ [阶段7 - failed] 应用启动失败");
        System.out.println("   失败原因: " + exception.getMessage());
        System.out.println("   失败时间: " + Instant.now());
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

✅ 关键点说明:

  • starting() 方法是唯一能在日志输出前打印 Banner 的时机;
  • environmentPrepared() 用于提前读取配置,因为此时 ApplicationContext 还未创建,不能使用 @Autowired
  • 使用 System.out.println() 直接输出,确保在日志框架(Logback)初始化前完成;
  • 配置绑定通过 Properties 手动解析,兼容 Spring Boot 3 的启动顺序。

🔧 3. CustomBannerAutoConfiguration.java —— 自动配置类

package com.example.custombanner;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * 自动配置类:启用自定义 Banner Starter
 * 该类仅用于注册 @ConfigurationProperties 和确保监听器被加载
 * 实际的监听器注册通过 META-INF/spring.factories 完成
 */
@Configuration
@EnableConfigurationProperties(CustomBannerProperties.class)
@ConditionalOnProperty(
    prefix = "custom.banner",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true // 默认启用
)
public class CustomBannerAutoConfiguration {
    // 此类无需任何 Bean 定义,仅用于激活配置绑定
    // 真正的监听器注册在 spring.factories 中
}

✅ 说明:此配置类确保 @ConfigurationProperties 生效,并通过 @ConditionalOnProperty 控制是否启用(默认开启)。


📦 4. META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports —— Spring Boot 3 注册 IOC 组件的新方式

这是 Spring Boot 3 的新的 IOC 组件注册机制,替代旧版 spring.factories 但是 SpringApplicationRunListener 监听器的注册依旧依赖于 spring.factories 来完成,这一点需要特别注意

src/main/resources/META-INF/ 目录下创建 spring.factories 文件:

# src/main/resources/META-INF/spring.factories
org.springframework.boot.SpringApplicationRunListener=\
    com.example.banner.listener.CustomSpringApplicationRunListener

src/main/resources/META-INF/spring/ 目录下创建 org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件:

# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.banner.CustomBannerAutoConfiguration

艺术字输出工具类:

package com.example.banner.utils;

import lombok.Getter;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.StringTokenizer;

/**
 * Figlet 艺术字体工具类
 * 用于将普通文本转换为ASCII艺术字体
 * <p>
 * Figlet 是一种将普通文本转换为大型字母的程序,
 * 字母由文本字符组成,类似于早期电脑和游戏机上的字符艺术。</p>
 *
 * @author urbane
 * @since 1.0.0
 */
@Getter
public class FigletUtil {

    /**
     * 支持的最大字符数
     */
    private static final int MAX_CHARS = 1024;
    /**
     * 硬空格字符,用于替换普通空格
     */
    private final char hardBlank;
    /**
     * 字体高度(行数)
     */
    private final int height;
    /**
     * 不包含下降部分的字体高度
     */
    private final int heightWithoutDescenders;
    /**
     * 最大行长度
     */
    private final int maxLine;
    /**
     * 字符压缩模式
     */
    private final int smushMode;
    /**
     * 字体数据数组
     * 三维数组:[字符编码][行数][列数]
     */
    private final char[][][] font; // [charCode][row][col]
    /**
     * 字体名称
     */
    private final String fontName;

    /**
     * 构造函数,从输入流加载字体文件
     *
     * @param fontStream 字体文件输入流
     * @throws IOException 当读取字体文件出错时抛出
     */
    public FigletUtil(InputStream fontStream) throws IOException {
        if (fontStream == null) {
            throw new IllegalArgumentException("Font stream cannot be null");
        }

        this.font = new char[MAX_CHARS][][];

        try (BufferedReader bufferedReader = new BufferedReader(
                new InputStreamReader(new BufferedInputStream(fontStream), StandardCharsets.UTF_8))) {

            // 读取字体文件头部信息
            String headerLine = bufferedReader.readLine();
            if (headerLine == null) {
                throw new IOException("Invalid font file: empty header");
            }
            StringTokenizer tokenizer = new StringTokenizer(headerLine, " ");
            if (tokenizer.countTokens() < 6) {
                throw new IOException("Invalid font header format");
            }

            // 解析字体基本信息
            String signature = tokenizer.nextToken();
            this.hardBlank = signature.charAt(signature.length() - 1);
            this.height = Integer.parseInt(tokenizer.nextToken());
            this.heightWithoutDescenders = Integer.parseInt(tokenizer.nextToken());
            this.maxLine = Integer.parseInt(tokenizer.nextToken());
            this.smushMode = Integer.parseInt(tokenizer.nextToken());

            // 读取注释行数
            int commentLines = Integer.parseInt(tokenizer.nextToken());

            // 读取字体名称
            tokenizer = new StringTokenizer(bufferedReader.readLine(), " ");
            if (tokenizer.hasMoreElements()) {
                this.fontName = tokenizer.nextToken();
            } else {
                this.fontName = "";
            }

            // 跳过注释行
            for (int i = 0; i < commentLines - 1; i++) {
                bufferedReader.readLine();
            }

            // 读取字符数据
            int charCode = 31;
            while (headerLine != null) {
                ++charCode;

                for (int lineIndex = 0; lineIndex < this.height; ++lineIndex) {
                    headerLine = bufferedReader.readLine();
                    if (headerLine != null) {
                        boolean isAbnormalFormat = true;
                        if (lineIndex == 0) {
                            try {
                                String codeTag = headerLine.concat(" ").split(" ")[0];
                                if (codeTag.length() > 2 && "x".equals(codeTag.substring(1, 2))) {
                                    // 十六进制编码
                                    charCode = Integer.parseInt(codeTag.substring(2), 16);
                                } else {
                                    // 十进制编码
                                    charCode = Integer.parseInt(codeTag);
                                }
                            } catch (NumberFormatException var18) {
                                isAbnormalFormat = false;
                            }
                            // 如果是异常格式,读取下一行并恢复字符编码
                            if (isAbnormalFormat) {
                                headerLine = bufferedReader.readLine();
                            }
                        }

                        // 初始化字符数据数组
                        if (lineIndex == 0) {
                            this.font[charCode] = new char[this.height][];
                        }

                        // 计算当前行的有效长度
                        int effectiveLength = headerLine.length() - 1 - (lineIndex == this.height - 1 ? 1 : 0);
                        if (this.height == 1) {
                            ++effectiveLength;
                        }

                        // 分配数组空间
                        this.font[charCode][lineIndex] = new char[effectiveLength];

                        // 处理字符数据,将硬空格替换为空格
                        // 处理字符数据,将硬空格替换为空格
                        for (int charIndex = 0; charIndex < effectiveLength; charIndex++) {
                            char currentChar = headerLine.charAt(charIndex);
                            this.font[charCode][lineIndex][charIndex] =
                                    (currentChar == this.hardBlank) ? ' ' : currentChar;
                        }
                    }
                }
            }
        }
    }

    /**
     * 将普通文本转换为 Figlet 艺术字体
     *
     * @param message 要转换的文本
     * @return 转换后的艺术字体字符串
     */
    public String convert(String message) {
        StringBuilder result = new StringBuilder();

        // 按行处理,逐行构建艺术字体
        for (int lineIndex = 0; lineIndex < this.height; lineIndex++) {
            // 处理每个字符
            for (int charIndex = 0; charIndex < message.length(); charIndex++) {
                String charLine = this.getCharLineString(message.charAt(charIndex), lineIndex);
                if (charLine != null) {
                    result.append(charLine);
                }
            }
            // 每行结束后添加换行符
            result.append('\n');
        }

        return result.toString();
    }

    /**
     * 获取指定字符某一行的字符串表示
     *
     * @param characterCode 字符的ASCII码
     * @param lineIndex     行索引
     * @return 指定行的字符串,如果不存在则返回null
     */
    public String getCharLineString(int characterCode, int lineIndex) {
        return this.font[characterCode][lineIndex] == null ? null : new String(this.font[characterCode][lineIndex]);
    }

    /**
     * 使用默认字体(Standard.flf)转换单行文本
     *
     * @param message 要转换的文本
     * @return 转换后的艺术字体字符串
     * @throws IOException 当读取字体文件或转换过程中出错时抛出
     */
    public static String convertOneLine(String message) throws IOException {
        return convertOneLine(FigletUtil.class.getClassLoader().getResourceAsStream("/fonts/Standard.flf"), message);
    }

    /**
     * 使用指定字体流转换单行文本
     *
     * @param fontFileStream 字体文件输入流
     * @param message        要转换的文本
     * @return 转换后的艺术字体字符串
     * @throws IOException 当读取字体文件或转换过程中出错时抛出
     */
    public static String convertOneLine(InputStream fontFileStream, String message) throws IOException {
        return (new FigletUtil(fontFileStream)).convert(message);
    }

    /**
     * 使用指定字体文件转换单行文本
     *
     * @param fontFile 字体文件
     * @param message  要转换的文本
     * @return 转换后的艺术字体字符串
     * @throws IOException 当读取字体文件或转换过程中出错时抛出
     */
    public static String convertOneLine(File fontFile, String message) throws IOException {
        return convertOneLine(new FileInputStream(fontFile), message);
    }
}

package com.example.banner.printer;

import com.example.banner.utils.FigletUtil;
import org.springframework.boot.Banner;
import org.springframework.boot.SpringBootVersion;
import org.springframework.core.env.Environment;

import java.io.InputStream;
import java.io.PrintStream;
import java.net.InetAddress;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class FigletBannerPrinter implements Banner {

    private static final String RESET = "\u001B[0m";
    private static final String GREEN = "\u001B[32m";
    private static final String CYAN = "\u001B[36m";
    private static final String YELLOW = "\u001B[33m";

    @Override
    public void printBanner(Environment environment, Class<?> sourceClass, PrintStream out) {
        try {
            // 获取服务名(动态!)
            String appName = environment.getProperty("spring.application.name", "Unknown-Service").toUpperCase();

            // ✅ 动态生成 ASCII Art 标题
            String asciiArtTitle = generateAsciiArt(appName);

            // 其他信息
            String serverPort = environment.getProperty("server.port", "8080");
            String profiles = String.join(",", environment.getActiveProfiles());
            String version = environment.getProperty("info.app.version", "1.0.0");
            String springBootVersion = SpringBootVersion.getVersion();
            String startTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            String author = environment.getProperty("custom.banner.author", "unknown");
            String hostAddress = getHostAddress();
            String baseUrl = "https://" + hostAddress + ":" + serverPort;
            String healthUrl = baseUrl + "/actuator/health";
            String swaggerUrl = baseUrl + (isSwaggerV3(environment) ? "/swagger-ui/index.html" : "/swagger-ui.html");

            // 打印横幅
            out.println(GREEN + "===============================================" + RESET);
            out.println(CYAN + asciiArtTitle + RESET); // ✅ 动态 ASCII Art 标题
            out.println(GREEN + "===============================================" + RESET);
            out.println("  服务端口    : " + YELLOW + "🌐 " + serverPort + RESET);
            out.println("  服务IP      : " + CYAN + "📍 " + hostAddress + RESET);
            out.println("  激活环境    : " + GREEN + "🧪 " + (profiles.isEmpty() ? "default" : profiles) + RESET);
            out.println("  服务版本    : " + YELLOW + "🏷️  " + version + RESET);
            out.println("  Spring Boot : " + GREEN + "🌱 " + springBootVersion + RESET);
            out.println("  启动时间    : " + YELLOW + "🕙 " + startTime + RESET);
            out.println("  项目作者    : " + CYAN + "📍 " + author + RESET);
            out.println("  健康检查    : " + CYAN + "🩺 " + healthUrl + RESET);
            out.println("  Swagger文档 : " + GREEN + "📚 " + swaggerUrl + RESET);
            out.println(GREEN + "===============================================" + RESET);
        } catch (Exception e) {
            out.println("⚠️  横幅打印异常: " + e.getMessage());
        }
    }

    // ✅ 核心方法:动态生成 ASCII Art
    private String generateAsciiArt(String text) {
        try {
            // 可选的字体文件下载,参考官网 https://github.com/xero/figlet-fonts/tree/master
            // 或者直接去 ASCII 艺术字生成工具推荐:https://patorjk.com/software/taag/#p=testall 去在线挑选

            // figlet 内置字体 "slant"(推荐),也可换 "standard",
            // return FigletFont.convertOneLine(text); // 默认 slant
            // return FigletFont.convertOneLine(text, "standard");

            // 自定义字体,figlet 工具包默认只提供了两个字体,我不喜欢。可以到 https://github.com/xero/figlet-fonts/tree/master 选择自己喜欢的字体
            InputStream fontStream = getClass().getResourceAsStream("/fonts/ANSI Shadow.flf");
//             InputStream fontStream = getClass().getResourceAsStream("/fonts/ANSI Regular.flf");

            // 使用内置字体 "slant", "standard"(默认) 两种可选字体,也可以下载并使用自定义字体
            return FigletUtil.convertOneLine(fontStream, text);
        } catch (Exception e) {
            // 降级:如果字体转换失败,返回原始文本
            return "🚀 " + text;
        }
    }

    // 判断是否 Swagger V3
    private boolean isSwaggerV3(Environment environment) {
        return environment.getProperty("springdoc.api-docs.enabled", Boolean.class, false) ||
                environment.containsProperty("springdoc.swagger-ui.path");
    }

    // 获取主机 IP
    private String getHostAddress() {
        try {
            InetAddress address = InetAddress.getLocalHost();
            if (address.isLoopbackAddress()) {
                return String.valueOf(InetAddress.getByName(java.net.NetworkInterface
                        .getNetworkInterfaces().nextElement().getInetAddresses().nextElement().getHostAddress()));
            }
            return address.getHostAddress();
        } catch (Exception e) {
            return "127.0.0.1";
        }
    }
}

📄 5. 可选:提供示例配置文件 application.yml.example

src/main/resources/ 下创建:

# custom-banner-starter 示例配置
# 请复制到你的 application.yaml 中并按需修改

custom:
  banner:
    enabled: true                  # 是否启用自定义 Banner(默认 true)
    title: "MY INSURANCE SYSTEM"   # 自定义标题(默认:CUSTOM SPRING BOOT)
    version: "v1.0.0-INSURANCE"    # 自定义版本(默认:自动获取 Spring Boot 版本)
    border-char: "="               # 边框字符(默认:=)
    show-spring-logo: true         # 是否显示 Spring 徽标(默认 true)
    show-version: true             # 是否显示版本(默认 true)

✅ 用户只需将此内容复制到自己的 application.yaml 中即可定制。


📦 6. pom.xml(Maven 依赖)

<?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>com.example</groupId>
    <artifactId>custom-banner-starter</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <name>custom-banner-starter</name>
    <description>自定义 Spring Boot Banner 启动器</description>

    <properties>
        <java.version>17</java.version>
        <spring-boot.version>3.5.4</spring-boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <skip>true</skip> <!-- 此 starter 不需打包为可执行 jar -->
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

✅ 7. 使用示例(在目标 Spring Boot 项目中)

① 引入依赖(Maven)

<dependency>
    <groupId>com.example</groupId>
    <artifactId>custom-banner-starter</artifactId>
    <version>1.0.0</version>
</dependency>

② 在 application.yaml 中配置(可选)

custom:
  banner:
    title: "人寿保险核心系统"
    version: "v2.3.1-PROD"
    border-char: "#"
    show-spring-logo: false
    show-version: true

③ 启动应用,效果如下:

============================================================
 :: 人寿保险核心系统 ::
v2.3.1-PROD
============================================================

在任何日志输出之前打印,完全替代默认 Banner。


🔍 8. 与 ApplicationListener / CommandLineRunner 的区别

特性SpringApplicationRunListenerApplicationListener<ContextRefreshedEvent>CommandLineRunner / ApplicationRunner
执行时机最早(日志前)上下文刷新后(应用已启动)应用启动完成后
能否改 Banner✅ 唯一正确方式❌ 太晚,日志已输出❌ 太晚
是否需要手动注册❌ 自动发现(通过 imports)✅ 需要 @Component✅ 需要 @Component
是否影响启动流程✅ 影响启动早期阶段❌ 仅在启动后触发❌ 启动后触发

结论:只有 SpringApplicationRunListener 能在日志输出前修改 Banner,是唯一合法方案。


⚠️ 9. 注意事项与最佳实践

项目说明
文件命名必须精确META-INF/spring/org.springframework.boot.SpringApplicationRunListener 必须完全匹配,大小写敏感
不要使用 @ComponentCustomSpringApplicationRunListener 不要加 @Component,否则会被重复加载
避免使用 Spring Beanstarting() 中不能注入 @Autowired Bean,因为上下文尚未初始化
使用 System.out.println必须使用原生输出,避免 Logger,否则可能被日志框架拦截或延迟
配置优先级custom.banner.enabled=false 可关闭此 Starter,不影响其他功能
兼容性本方案兼容 Spring Boot 3.0+,不支持 2.x(因注册机制不同)
测试建议在独立 Spring Boot 项目中测试,避免与已有 Banner 冲突

✅ 10. 总结:为什么这个方案是“标准”的?

优势说明
时机精准使用 SpringApplicationRunListener.starting(),确保 Banner 在日志前输出
配置灵活支持通过 application.yaml 动态定制,无需重新打包
自动注册使用 Spring Boot 3 推荐的 AutoConfiguration.imports,无 spring.factories 兼容问题
无侵入性不修改用户代码,引入即生效
企业级可用适合银行、保险等对启动信息有品牌要求的场景
符合规范严格遵循 Spring Boot 官方扩展机制

📎 附:如何发布到私有仓库?

  1. 执行 mvn clean install → 本地仓库;
  2. 或部署到公司 Nexus/Artifactory;
  3. 项目中引入依赖即可自动生效。

最终效果:你的保险系统启动时,不再显示“Spring Boot :: (v3.5.4)”,而是优雅地展示 “人寿保险核心系统 v2.3.1-PROD”,体现专业性和品牌一致性。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值