从pom到yml:Maven资源过滤实现版本号与构建时间自动注入
前两天搭新服务时遇到一个需求版本号、构建时间要直接出现在接口里方便运维确认线上跑的是不是最新包。群里很快有人提议在application.yml里写死一个版本号改版本的时候同步改一次。我当场反对版本号在pom里已经有一份配置里再来一份等于埋了个漏改的雷。于是我们把方案定成了“从pom读取参数变量到yml配置使用”简单说就是把pom里的属性值在打包时替换进application.yml让配置随构建自动生成。这条路径在Maven加Spring Boot的项目里很常用看起来只有三步实际坑不少。今天把这套做法掰开揉碎讲一遍给同样在配置版本、环境、构建时间这些信息踩坑的同学做个参考。1. 为什么要把参数放进pom而不是直接写进yml1.1 版本号这类信息必须只有一个源头很多项目里版本号散落在pom、前端、接口文档、监控系统里。发布时一看pom是1.2.3接口返回的却是1.2.2马上就会怀疑“包是不是打错了”。与其靠人工同步不如让配置文件在构建时自动读取pom中version的值。Maven内置了${project.version}属性配合资源过滤版本号这个字段在项目里就只有一个存储位置。多模块项目也一样父pom用dependencyManagement加version统一管理子模块继承最终打进各自配置里的版本号都来自同一个源头不会因为某个子模块没更新而出现偏差。版本号一旦有了唯一的源后续发版、打镜像、生成审计信息都会省心很多。1.2 除了版本号构建时间和环境标识也适合这样注入版本号只是最典型的例子。${maven.build.timestamp}是Maven的构建开始时间每次打包自动变化能够帮助定位“这是什么时候产生的包”。环境标识也能这样处理比如用Maven Profile给不同的发布环境注入dev、test、prod等标签。有了这些信息日志、监控、异常上报里都能带上排查问题的时候扫一眼就知道这条日志来自哪个包、哪个环境、哪个构建时间定位速度快很多。这个价值在微服务场景尤其明显几十个服务实例同时跑如果每个实例都能在启动时上报自己的版本和构建时间发布和回滚操作都会安全很多。1.3 构建期配置和运行期配置本来就是两回事有人说既然有配置中心和环境变量为什么非要在yml里写这些因为两类东西的时效不一样。版本号、构建时间在构建那一刻就确定了属于产物的“体检报告”数据库地址、访问密钥则要按运行环境动态变化属于“运行指令”。前者应该固化进产物后者才适合在运行时覆盖。拿现实里的东西打比方邮包上印了“今天发出”的邮戳这是构建期信息收件地址却可以临时改派那是运行期信息。把构建期信息交给Maven把运行期信息交给配置中心和启动参数这个边界清楚了很多设计争论自然就没了。2. 先搞明白pom变量是怎么跑到yml里的2.1 Maven资源过滤就是一次模板替换Maven打包时会把src/main/resources下的文件复制到target/classes默认是原样复制。如果你在pom的resources配置里打开了filtering复制过程就会对文件内容里的占位符做替换。用大白话说Maven这时候扮演了一个模板引擎把project.version这种占位符替换成pom里的实际属性值再把替换后的文件写到target/classes。Spring Boot启动时加载的是target/classes/application.yml自然就拿到了替换后的值。这个过程发生在构建期和运行环境无关。如果你没有继承Spring Boot的parent一个最朴素的资源过滤配置长这样build resources resource directorysrc/main/resources/directory filteringtrue/filtering /resource /resources /build2.2 Spring Boot为什么默认用而不是$这是最容易忽略的一个点。Spring Boot的配置里到处都是${server.port}这种占位符它们是等应用启动后由Spring解析的。如果Maven过滤也使用${}作为分隔符两者就撞车了资源过滤阶段会把${server.port}当成Maven属性尝试替换轻则替换失败重则把配置坑成空值。Spring Boot的spring-boot-starter-parent已经内置了处理方案把Maven过滤的分隔符从默认的${}换成了。所以你在yml里看到project.version就是Maven要替换的${xxx}则留给Spring解析。这个约定强烈建议保留不要图省事去改成${}分隔符。2.3 没继承Spring Boot parent时怎么手动配有些项目并没有继承spring-boot-starter-parent那就要自己在maven-resources-plugin里把分隔符改成同时关闭默认分隔符plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-resources-plugin/artifactId configuration delimiters delimiter/delimiter /delimiters useDefaultDelimitersfalse/useDefaultDelimiters /configuration /pluginuseDefaultDelimiters设为false很关键它确保Maven只认不碰${}。这个配置建议放在buildplugins里位置别放错否则过滤行为不会改变。3. 实操从pom读取参数变量到yml配置使用3.1 第一步在pom里定义好属性和格式打开pom.xml在properties里准备你需要注入的变量。比如要获取项目版本号和构建时间可以这样定义properties maven.build.timestamp.formatyyyy-MM-dd HH:mm:ss/maven.build.timestamp.format app.version${project.version}/app.version app.build.time${maven.build.timestamp}/app.build.time /properties这里maven.build.timestamp.format控制构建时间的显示格式。设置成yyyy-MM-dd HH:mm:ss过滤出来的时间就是正常人能看懂的风格而不是默认的UTC大长串。app.version和app.build.time是自定义属性后面在yml里用app.version和app.build.time引用。当然也可以不自定义属性直接在yml里写project.version和maven.build.timestamp。Maven允许内置属性直接参与资源过滤。不过自定义一层的好处是统一命名以后如果要调整取值规则只改pom里的属性定义就行。3.2 第二步在application.yml里放置占位符在src/main/resources/application.yml里写app: version: app.version build-time: app.build.time占位符的写法必须是属性名不要写${app.version}那样会被Spring当成自己的占位符去解析启动时会异常。还有一点就是区分大小写APP.VERSION和app.version不是一回事建议统一用小写加短横线命名。3.3 第三步构建并验证替换结果在项目根目录执行mvn clean package构建完成后打开target/classes/application.yml你会看到占位符已经被替换成实际值app: version: 1.2.3 build-time: 2025-01-12 10:23:45如果构建后看到的还是app.version那说明过滤没生效参考后面“常见问题”一节排查。验证这一步强烈建议做一次不是每次构建都会自动打开过滤尤其在自定义构建流程里少了插件配置就会出现原样输出。3.4 第四步把配置读到Java代码里只是写进yml还不够很多时候要在接口里返回这些信息。用Value最直接RestController public class VersionController { Value(${app.version}) private String version; Value(${app.build-time}) private String buildTime; GetMapping(/version) public MapString, String version() { return Map.of(version, version, buildTime, buildTime); } }启动应用访问接口你会看到版本号和构建时间。要注意的是如果yml里的占位符没有替换Spring会拿到app.version这个字符串本身然后当作普通值注入接口返回就会是占位符原样而不是真正的版本号。所以先验证第3步再写代码。3.5 一个特别提醒IDEA直接运行不触发过滤这是新手最容易踩的坑。在IDEA里直接右键运行main方法Spring Boot应用通常不会经过Maven的资源过滤阶段yml里的app.version不会被替换接口返回的就成了占位符原样。原因是IDEA直接运行使用的是Java编译输出目录并没有走Maven的生命周期。解决办法有两个一是用mvn spring-boot:run在命令行启动这个命令会触发Maven生命周期资源过滤会正常生效。二是给IDEA的启动配置增加“Before launch”步骤执行mvn resources:resources虽然多一步但能在本地调试时也保证配置正确。我个人的习惯是本地调试时直接用命令行跑避免环境差异。4. 进阶玩法结合Maven Profile做环境切换4.1 用Profile给不同环境注入不同变量构建期最常见的诉求是“同一份yml模板打出来的包按环境不同配置也不同”。Maven Profile可以做到。比如在pom里定义两个profileprofiles profile iddev/id properties env.namedev/env.name log.levelDEBUG/log.level /properties activation activeByDefaulttrue/activeByDefault /activation /profile profile idprod/id properties env.nameprod/env.name log.levelWARN/log.level /properties /profile /profiles然后在yml里app: env: env.name logging: level: com.example: log.level打包时使用-Pprod比如mvn clean package -Pprod构建后target/classes/application.yml里的env会变成prod日志级别变成WARN。这样就实现了构建期模板化。4.2 小心Maven Profile和Spring Profile不是一回事很多人会把Maven的-P和Spring Boot的spring.profiles.active搞混。-Pprod影响的是Maven构建行为能改变资源过滤时的属性值spring.profiles.active影响的是Spring Boot应用运行时会加载哪些application-{profile}.yml。两者不冲突但不要以为执行了mvn package -PprodSpring Boot就会自动激活prod环境。常见做法是让构建期的属性再往下传一层spring: profiles: active: spring.profile.active然后在每个Maven Profile里定义spring.profile.active属性。例如prod profile里加spring.profile.activeprod/spring.profile.active这样执行mvn package -Pprod时yml里的spring.profile.active被替换成prod运行时就自动激活prod的Spring配置组。但要注意运行参数优先级更高如果启动时显式传--spring.profiles.activetest那还是以运行参数为准。4.3 什么情况不该塞进pom资源过滤虽然是好工具但别把什么环境差异都往里塞。数据库地址、账号密码、第三方的密钥这些敏感信息一旦经过pom和yml打进包就相当于把秘密跟着产物跑了。构建期注入适合非敏感的元数据比如环境名、版本号、构建时间、微服务名。真正敏感的配置应该放到配置中心或环境变量里在运行期加载。我自己见过一些项目把十几个环境的数据库地址写在pom profile里最后仓库里全是库连接信息权限一放错就是安全事故这个教训希望你们不用再踩。5. 实战做一个版本信息接口5.1 需求拆分和配置绑定我拿项目里真实做过的事情举例运维希望线上接口直接能返回当前部署包的应用名、版本号、构建时间、运行环境。四类信息里应用名、版本号、构建时间都来自pom运行环境由构建时的profile决定。实现方式有两种一种是直接用Value但字段多了以后类里全是Value注解看着乱。更推荐用ConfigurationProperties做一个绑定类。5.2 写一个配置属性类yml里保持app: name: project.artifactId version: project.version build-time: app.build.time env: env.nameJava类Component ConfigurationProperties(prefix app) public class AppInfoProperties { private String name; private String version; private String buildTime; private String env; // getter和setter方法省略IDE会自动生成 }注意ConfigurationProperties需要类里有setter方法Spring Boot才能完成绑定。如果你用的是Java 14以上的record也可以用构造器绑定但需要额外注解支持这里不多展开。接着在控制器里注入这个类RestController public class AppInfoController { private final AppInfoProperties appInfo; public AppInfoController(AppInfoProperties appInfo) { this.appInfo appInfo; } GetMapping(/app/info) public AppInfoProperties info() { return appInfo; } }启动后请求接口得到类似结果{ name: demo-service, version: 1.2.3, buildTime: 2025-01-12 10:23:45, env: prod }就这样一个给运维用的版本信息接口就完成了。只要下次重新打包版本号和构建时间会自动更新不用再去手改任何地方。5.3 和官方build-info方案的取舍其实Spring Boot官方有一个更轻的获取构建信息方式就是spring-boot-maven-plugin的build-info目标。配置后会在META-INF/build-info.properties里生成构建信息然后用自动配置的BuildProperties读取。这个方案优点是不需要往yml里放占位符构建信息由插件统一生成。缺点是只能拿到版本、时间和一个自定义的额外属性如果想约束更多字段或者想在yml层面控制输出格式从pom到yml的这套方案会灵活得多。我的建议是如果只是要版本号和时间官方build-info就够如果还要环境标识、服务名、团队自定义的发布标签那就用pom到yml的方式掌握以后一点也不比build-info复杂。6. 常见问题与排查技巧6.1 占位符还是原样没有被替换这是遇到最多的问题。先看构建后的target/classes/application.yml如果里面还是app.version说明根本没经过过滤。按照这个顺序查确认pom有没有继承spring-boot-starter-parent如果没有检查maven-resources-plugin的分隔符配置。确认打包命令是不是真的执行了Maven生命周期如果从IDE直接运行大概率不过滤。检查src/main/resources是不是被其他resources配置覆盖了。曾经有个项目buildresources里只写了自定义的src/main/docker目录导致默认的src/main/resources不参与任何处理过滤自然不生效。所以写resources配置时要留意别把默认资源目录覆盖掉。6.2 Spring的${}被Maven替换导致启动失败如果你看到启动时报错提示无法解析${...}同时错误信息里出现了被替换过的奇怪值那基本是过滤分隔符冲突了。最常见的场景是项目没有用Spring Boot parent却把filtering打开然后占位符用了${}导致Spring的${server.port}被Maven动了手脚。解决办法是统一改为分隔符并按前面2.3小节的配置关闭默认分隔符。这个问题一旦出现最好顺手检查所有yml文件因为被替换的文件可能不止一个。6.3 中文配置变成乱码资源过滤默认按平台编码读取文件Windows和Linux默认编码可能不一样于是出现中文乱码。解决办法是在pom里固定编码properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties同时给资源插件指定编码configuration encodingUTF-8/encoding /configuration加上之后无论什么平台构建yml里的中文描述都会保持一致。这个细节在多人协作、跨平台打包的项目里尤其重要。6.4 构建时间不是预期的时间maven.build.timestamp默认使用UTC时区可能导致你在中国时区看到的构建时间比本地慢8小时。调整方式是在pom里指定时区比如properties maven.build.timestamp.localezh_CN/maven.build.timestamp.locale maven.build.timestamp.formatyyyy-MM-dd HH:mm:ss/maven.build.timestamp.format /properties不过maven.build.timestamp.locale主要影响本地化输出如果格式固定加上format就够了。如果依然不对检查一下CI机器的系统时区必要时在构建命令层面设置统一时区。构建时间反映的是“打包那一刻”不是应用启动时间接口里要展示应用启动时间的话应该在应用启动时用线程安全的Instant记录不要在yml里蹭这个构建时间。6.5 多模块项目中的属性继承问题在多模块Maven工程里父pom里定义的properties子模块可以继承但${project.version}在子模块里解析出来的是子模块自己的版本值。如果想让所有子模块的配置里都注入同一个版本最好的做法是在父pom里定义一个统一属性比如properties release.version2.0.0/release.version /properties然后子模块pom的version统一用${release.version}yml里也引用release.version。这样版本号在父pom只维护一处子模块和配置里都跟着变。不要用${project.parent.version}这种绕的写法在继承链比较复杂的时候容易出现意外空值。6.6 过滤进来的值里带特殊字符YAML解析被坑pom属性值如果包含#、冒号、等特殊符号替换进yml后可能被YAML解析成注释、键值对等。比如想在pom里定义一条描述this is a #prod build过滤到yml后#后面的内容会被YAML当作注释导致配置被截断。解决办法不要在这种跨层过滤的属性里放特殊字符或者用引号包起来比如yml里写成app: desc: app.descMaven替换后值变成this is a #prod buildYAML中的引号能避免注释解析。这个坑我踩过不止一次尤其在配置描述、通知文案这类字段里最容易发生。做了这么多年项目关于这套配置注入我最后总结几个自己一直在用的习惯。第一版本号就用project.version不要另外定义一个version属性再赋值一遍。Maven本身就有版本号多一层属性就多一层失同步的机率。但构建时间这种内置变量没有语义化名字我会自定义一个app.build.time来包装方便后续格式化。第二过滤后的target/classes/application.yml要当成正式产物检查。每次构建完至少抽查一次替换结果确保不是原样占位符。我在CI里都会加一条“构建后检查配置文件是否包含”的小任务一旦发现异常就中断流程这比等项目启动后接口返回占位符再发现要快得多。第三敏感配置坚决不走pom过滤。pom和yml都是能进仓库的一旦把密钥写在pom属性里再过滤进yml打包就等于把秘密直接暴露在源码仓库和历史提交记录里影响面远比想象中大。构建期只放平台无关的元数据密钥交给配置中心或K8s Secret那层去管。最后还有一个算不上技巧的习惯碰到同事问“能不能在yml里用pom变量”我的第一反应都不是回答能不能而是先问这个变量属于构建期还是运行期。版本号、构建时间是构建期数据库地址是运行期。想清楚这一层方案基本就出来了。希望大家在自己的项目里也能把这套机制用得顺手少踩几个坑。