从现有项目抽象出 Maven Archetype
目录
背景#
一个团队维护多个 Spring Boot 服务时,常见的痛点是:
- 每个新服务都要从头抄一遍
pom.xml、application.yaml、logback-spring.xml、Dockerfile、Makefile…… - 抄着抄着就出现"A 服务用了新的 actuator 配置,B 服务还是旧的"这种偏差。
- Onboarding 新成员时,“我要建个新服务"变成一个需要问老人、翻三个仓库的事情。
一个比较务实的解决方式:从现有的成熟项目里,抽象出 Maven Archetype。以后建新服务直接:
mvn archetype:generate \
-DarchetypeGroupId=com.example \
-DarchetypeArtifactId=my-service-archetype \
-DarchetypeVersion=1.0.0 \
-DgroupId=com.example \
-DartifactId=order-service \
-Dversion=0.0.1-SNAPSHOT
一条命令生出一个带完整脚手架的新项目。
本文记录从零做一个 Archetype 的完整流程,包括最容易踩坑的 Velocity 转义。
两种制作方式#
方式一:archetype:create-from-project#
Maven 官方提供了自动生成脚本:
cd my-reference-project
mvn archetype:create-from-project
它会在 target/generated-sources/archetype/ 下生成一个 archetype 项目骨架。
适合场景:现有项目结构很干净、大部分文件都能直接复用。
缺点:
- 会把业务代码一并塞进
archetype-resources/,还得手动挑。 - 所有
${...}都会被机械地替换,不区分是"用户可配置的变量"还是"Spring 运行时占位符”。 - 生成后还是要大改一轮,不如一开始就手写。
方式二:手工制作#
自己按约定的目录结构搭一个 archetype 项目,把模板文件逐个放进去。
适合场景:需要抽象多个现有项目的共同部分,或者想要一次做多个不同形态的 archetype(比如 gateway、BFF、domain service 各一个)。
下文以手工制作为主。
Archetype 项目结构#
一个最小可用的 archetype 项目长这样:
my-service-archetype/
├── pom.xml # archetype 本身的 POM
└── src/main/resources/
├── META-INF/maven/
│ └── archetype-metadata.xml # 描述符:声明属性、文件集
└── archetype-resources/ # 生成项目的模板目录
├── pom.xml # 生成项目的 pom
├── Dockerfile
├── Makefile
└── src/
├── main/
│ ├── java/
│ │ └── Application.java
│ └── resources/
│ ├── application.yaml
│ └── logback-spring.xml
└── test/
└── java/
└── ApplicationTests.java
两条关键规则:
archetype-resources/下的目录结构就是将来生成项目的结构——一比一复制。- Java 文件直接放在
src/main/java/下(通常不要再手写包路径子目录),Maven 会根据用户给定的package参数自动补上。
1. Archetype 自身的 POM#
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-service-archetype</artifactId>
<version>1.0.0</version>
<packaging>maven-archetype</packaging>
<build>
<extensions>
<extension>
<groupId>org.apache.maven.archetype</groupId>
<artifactId>archetype-packaging</artifactId>
<version>3.2.1</version>
</extension>
</extensions>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-archetype-plugin</artifactId>
<version>3.2.1</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
关键点:
<packaging>maven-archetype</packaging>激活 archetype 打包生命周期。archetype-packaging这个 extension 通常需要带上,否则mvn install时不会生成 archetype 的 catalog 信息。
核心文件:archetype-metadata.xml#
这个文件告诉 Maven: 用户要回答哪些问题、哪些文件需要模板渲染、哪些文件要按包路径摆放。
<?xml version="1.0" encoding="UTF-8"?>
<archetype-descriptor
xmlns="https://maven.apache.org/plugins/maven-archetype-plugin/archetype-descriptor/1.0.0"
name="my-service-archetype">
<requiredProperties>
<requiredProperty key="package">
<defaultValue>com.example.${artifactId}</defaultValue>
</requiredProperty>
<requiredProperty key="servicePort">
<defaultValue>8080</defaultValue>
</requiredProperty>
<requiredProperty key="springBootVersion">
<defaultValue>3.5.10</defaultValue>
</requiredProperty>
<requiredProperty key="javaVersion">
<defaultValue>21</defaultValue>
</requiredProperty>
</requiredProperties>
<fileSets>
<!-- Java 源码:走 Velocity 渲染 + 按包路径摆放 -->
<fileSet filtered="true" packaged="true" encoding="UTF-8">
<directory>src/main/java</directory>
<includes>
<include>**/*.java</include>
</includes>
</fileSet>
<!-- 配置文件:走 Velocity 渲染,但不按包路径摆放 -->
<fileSet filtered="true" packaged="false" encoding="UTF-8">
<directory>src/main/resources</directory>
<includes>
<include>application.yaml</include>
</includes>
</fileSet>
<!-- logback:不走 Velocity,因为它自己用了 ${} 占位符 -->
<fileSet filtered="false" packaged="false" encoding="UTF-8">
<directory>src/main/resources</directory>
<includes>
<include>logback-spring.xml</include>
</includes>
</fileSet>
<fileSet filtered="true" packaged="true" encoding="UTF-8">
<directory>src/test/java</directory>
<includes>
<include>**/*.java</include>
</includes>
</fileSet>
<fileSet filtered="false" packaged="false" encoding="UTF-8">
<directory></directory>
<includes>
<include>Dockerfile</include>
<include>Makefile</include>
</includes>
</fileSet>
</fileSets>
</archetype-descriptor>
requiredProperties 的作用#
用户执行 archetype:generate 时,除了 Maven 标准的 groupId / artifactId / version / package 之外,会被依次追问这里声明的属性,每一项都带默认值。
可以用来参数化任何希望用户在初始化时选择的东西:端口号、JDK 版本、Spring Boot 版本,甚至是"是否启用某个模块"。
fileSet 的 filtered 与 packaged#
| 属性 | 含义 |
|---|---|
filtered="true" |
走 Velocity 模板渲染,${...} 会被替换 |
filtered="false" |
原样拷贝,所有 ${...} 保留 |
packaged="true" |
文件会被放到 <package> 对应的目录下(Java 源码通常都要用) |
packaged="false" |
文件路径不受 package 影响(资源文件、根目录文件用) |
一个常见错误是把 logback-spring.xml 也设为 filtered="true"。Logback 本身用 ${appName} 这种语法做变量替换,结果会被 Velocity 先截胡渲染掉。配置文件里自带 ${} 语法的,都要考虑是不是要设成 filtered="false",或者下文所说的转义。
最容易踩的坑:Velocity 转义#
Archetype 的模板引擎是 Velocity。一切 filtered="true" 的文件里,${someVar} 都会被尝试替换成 Velocity 上下文里的变量。
这就带来一个问题:生成的项目里,本来就要有很多 ${...}——
- Maven POM 里引用 property:
${spring-cloud.version} - Spring 配置里引用环境变量:
${DB_CONNECTION_STRING:localhost:3306/app} - Spring
@Value("${app.timeout}")注解 - Logback 的
${appName}
这些都希望原样保留到生成后的文件里,不能被 Velocity 吃掉。
网上常见的答案:反斜杠转义(但并不总是有效)#
很多教程会告诉你:"\$ 转义就行了":
<version>\${spring-cloud.version}</version>
理论上 Velocity 看到 \$ 就知道要输出字面量 $。但这条规则有两个很容易踩的坑:
陷阱 1:只对 Velocity 上下文里"存在"的变量才生效
Velocity 的转义处理是:先尝试把 ${name} 解析成引用,如果解析成功,\ 前缀让它输出字面量字符串;如果解析失败(name 在上下文里根本不存在),\ 被原样保留。
结果就是模板里的 \${spring-cloud.version},Velocity 把 spring-cloud.version 当成上下文变量查了一下,查不到——于是输出变成:
<!-- 预期 -->
<version>${spring-cloud.version}</version>
<!-- 实际 -->
<version>\${spring-cloud.version}</version>
Maven 解析这份 POM 时,会看到版本号字符串里有反斜杠,直接报错:
'dependencies.dependency.version' must not contain any of these characters \/:"<>|?* but found \
陷阱 2:默认值带冒号时,Velocity 解析器直接崩溃
Spring 的占位符常见写法:
url: jdbc:mysql://\${DB_CONNECTION_STRING:localhost:3306/app}
Velocity 看到 ${VAR:...} 会尝试解析成它自己的备选值语法,但遇到 : 和 / 就语法错误、直接中止:
Encountered ":localhost:3306/app}\n..."
Was expecting one of: "[" ... "|" ... "}" ...
整个生成流程失败。
一个更稳妥的方案:#[[ ... ]]# 原样输出块#
Velocity 的原样输出语法 #[[ 任意内容 ]]# 不管里面是什么字符,都当成字面量输出。至少在我的实践里,这是更稳妥的解法:
<!-- 模板里 -->
<version>#[[${spring-cloud.version}]]#</version>
# 模板里
url: jdbc:mysql://#[[${DB_CONNECTION_STRING:localhost:3306/app}]]#
// 模板里
@Value("#[[${app.timeout}]]#")
private Duration timeout;
生成后统统变成干净的 ${...}。
这种写法看起来丑,但优点是不容易再被 Velocity 错误解析——不管变量名里有没有 :、/、.,不管它是不是 Velocity 上下文里定义过的名字,都能原样透传。
什么时候还可以用 \${}?#
\$ 转义仍然有它的位置:当你确定变量名纯字母数字、且这个名字在 Velocity 上下文里被定义过时。比如 archetype 里自己定义过的 property:
<!-- springCloudVersion 在 archetype-metadata 里声明过 -->
<!-- 但我想在生成的 POM 里引用 Maven property,而不是直接取值 -->
<version>\${spring-cloud.version}</version> <!-- 不推荐,容易翻车 -->
如果你不想反复验证转义细节,我更倾向直接用 #[[...]]#,而不是纠结什么时候 \$ 能省字。 看着丑一点,但后面通常更省心。
一个简单的判断规则#
面对一个 ${xxx},问自己两个问题:
-
这个变量是用户在
archetype:generate时要回答的(比如${groupId}、${artifactId}、${servicePort})? → 原样写${xxx},让 Velocity 替换成用户输入的值。 -
这个变量要保留到生成项目里,由 Spring/Maven/Logback 运行时解释(比如
${DB_URL}、${spring-cloud.version})? → 用#[[${xxx}]]#包起来,原样透传。
archetype-resources/pom.xml 是最乱的地方#
因为 POM 里同时混着两种 ${...}:
<properties>
<!-- 这两行的 ${...} 是 archetype 变量,原样写 -->
<java.version>${javaVersion}</java.version>
<spring-cloud.version>${springCloudVersion}</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<!-- 这里要保留到生成后的 POM 里,用 #[[...]]# -->
<version>#[[${spring-cloud.version}]]#</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
写完之后,最好还是跑一遍生成流程,用 cat 或 diff 核对生成后的 POM 是否如预期——Velocity 转义错误最容易在这里出问题。
archetype-resources/ 下的模板文件#
生成项目的 POM#
用 archetype 变量注入 parent 版本、Java 版本等:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>${springBootVersion}</version>
<relativePath/>
</parent>
<groupId>${groupId}</groupId>
<artifactId>${artifactId}</artifactId>
<version>${version}</version>
<properties>
<java.version>${javaVersion}</java.version>
</properties>
...
</project>
Java 入口类#
直接用 ${package} 做包名:
package ${package};
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
文件位置是 archetype-resources/src/main/java/Application.java,不要写 src/main/java/com/example/Application.java——packaged="true" 会自动在生成时把包路径补上。
application.yaml#
server:
port: ${servicePort}
spring:
application:
name: ${artifactId}
datasource:
url: jdbc:mysql://#[[${DB_HOST:localhost}]]#:#[[${DB_PORT:3306}]]#/app
username: #[[${DB_USERNAME:root}]]#
password: #[[${DB_PASSWORD:}]]#
${servicePort} 和 ${artifactId} 是 archetype 变量、会被 Velocity 替换成用户输入的值;#[[${DB_HOST:localhost}]]# 等是 Spring 运行时占位符、要原样透传到生成文件里。
本地测试#
手写完后,先本地装一下:
cd my-service-archetype
mvn clean install
然后在任何空目录下试着生成:
mvn archetype:generate \
-DarchetypeGroupId=com.example \
-DarchetypeArtifactId=my-service-archetype \
-DarchetypeVersion=1.0.0 \
-DgroupId=com.example \
-DartifactId=demo-service \
-Dversion=0.0.1-SNAPSHOT \
-DinteractiveMode=false \
-DservicePort=8081
生成完进去跑一下:
cd demo-service
mvn clean package
mvn spring-boot:run
能起来,说明 archetype 基本可用。
常见错误排查#
| 症状 | 原因 | 解法 |
|---|---|---|
生成时报 Was expecting one of: "[" ... "|" ... "}" ... |
YAML/POM 里用了 \${VAR:默认值带冒号},Velocity 解析失败 |
改成 #[[${VAR:默认值}]]# |
生成的 POM 里 ${spring-cloud.version} 前面多了个 \ |
\${...} 的变量在 Velocity 上下文没定义过,反斜杠没被消费 |
改成 #[[${...}]]# |
生成的 POM 报 version must not contain \/:... |
同上,Maven 读到了 \2025.0.1 |
改成 #[[${...}]]# |
| 生成的 Java 文件没有包声明,或包路径不对 | fileSet 缺 packaged="true" |
补上属性 |
logback-spring.xml 启动报变量未找到 |
没有改成 filtered="false" |
改 metadata |
生成时报 property 'xxx' not found |
archetype-metadata.xml 里漏了 requiredProperty |
补上声明 |
生成的项目里没有 .gitignore |
Maven 资源插件默认排除 .gitignore(见下节) |
用 post-generate 钩子重命名 |
坑:.gitignore 不会跟着生成#
明明在 archetype-resources/ 下放了 .gitignore,也在 metadata 里 include 了,生成出来的项目里就是没有——别怀疑自己,Maven 的资源插件有一套默认排除规则,**/.gitignore、**/.gitattributes、**/.git/** 等一律被过滤掉。
原因:这些文件在一般 Maven 项目里是项目 SCM 元数据,不该被打包进 artifact。但对 archetype 来说,我们就是要把它们打进去再原样吐出来。
尝试 1:关掉默认排除(不够用)#
第一反应是在 archetype 自己的 pom.xml 里配:
<build>
<plugins>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<addDefaultExcludes>false</addDefaultExcludes>
</configuration>
</plugin>
</plugins>
</build>
对一些场景管用,但在 maven-archetype 打包的流程里,打进 archetype jar 的资源不完全走 maven-resources-plugin,这条配置也可能被忽略。生成后的项目里依然没有 .gitignore。
尝试 2:换名 + post-generate 钩子#
更可靠的做法:把 .gitignore 改名成不会被排除的 gitignore(去掉点),然后在 archetype 里加一个 post-generate Groovy 脚本,生成结束后把它改回来。
步骤:
第一步:把 archetype-resources/.gitignore 重命名为 archetype-resources/gitignore。
第二步:metadata 里的 <include> 跟着改:
<fileSet filtered="false" packaged="false" encoding="UTF-8">
<directory></directory>
<includes>
<include>gitignore</include>
</includes>
</fileSet>
第三步:新建 src/main/resources/META-INF/archetype-post-generate.groovy:
import java.nio.file.Files
import java.nio.file.Paths
def outputDir = Paths.get(request.getOutputDirectory(), request.getArtifactId())
def src = outputDir.resolve("gitignore")
def dst = outputDir.resolve(".gitignore")
if (Files.exists(src)) {
Files.move(src, dst)
}
Maven Archetype 会在生成完成后自动执行这个脚本,把 gitignore 重命名为 .gitignore。用户看不到任何中间状态。
同样的套路也可以用于:.env 模板(被 Maven 排除)、.github/ 下的 workflow(路径里有 .)、.dockerignore 等。
Post-generate 钩子的其他用处#
这个 Groovy 脚本位置不只是做重命名,还能:
- 生成完后自动
git init - 根据
artifactId生成 Kubernetes Helm chart 目录 - 检查用户输入的属性是否合法,不合法就
fail()掉
拿到的 request 对象有 getArtifactId()、getGroupId()、getPackage()、getProperties() 等方法,能精确控制生成后的动作。
坑:生成的项目里 git-commit-id-maven-plugin 报错#
原始模板里常见这样一段,用来把当前 git commit 信息编进 actuator 的 /info:
<plugin>
<groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId>
<executions>
<execution>
<goals><goal>revision</goal></goals>
<phase>initialize</phase>
</execution>
</executions>
</plugin>
问题:archetype 生成出来的新项目没有 .git 目录,plugin 一跑就炸:
.git directory is not found! Please specify a valid [dotGitDirectory]
用户第一次 mvn package 就失败,体验会比较差。这里可以加一个配置,让它容忍缺失:
<plugin>
<groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId>
<configuration>
<failOnNoGitDirectory>false</failOnNoGitDirectory>
</configuration>
<executions>
<execution>
<goals><goal>revision</goal></goals>
<phase>initialize</phase>
</execution>
</executions>
</plugin>
或者结合上一节,在 post-generate 钩子里顺手 git init 一下,也是一种办法。
坑:别让模板依赖 Maven wrapper#
很多 Spring Boot 项目 Dockerfile 里会写:
RUN ./mvnw clean package -DskipTests
Archetype 如果照抄这段但没把 .mvn/wrapper/ 跟 mvnw 脚本一起放进 archetype-resources,生成的项目一做 Docker 构建就失败——mvnw 不存在。
两个选择:
- 方案 A:把 Maven wrapper 也塞进 archetype-resources。需要至少 3 个文件(
mvnw、mvnw.cmd、.mvn/wrapper/maven-wrapper.properties),其中mvnw要带执行权限——Maven archetype 处理文件权限不太可靠,容易出问题。 - 方案 B:Dockerfile/Makefile 里统一用系统的
mvn,在 Docker 构建镜像里预装 Maven。更简单。
如果是我来选,我会更倾向方案 B:生成出来的项目更干净,没有 wrapper 相关文件。代价是 CI 环境需要有 Maven,但很多内网 Jenkins/GitLab 执行器本来就会预装。
多形态 Archetype:按服务类型拆分#
实际团队往往不只有一种服务形态。比如:
- 网关类:只做流量路由,依赖 Spring Cloud Gateway。
- 聚合层:Controller + 多个下游调用,依赖 Spring MVC + Redis + Kafka。
- 领域服务:Controller → Service → Repository,依赖 JPA + Liquibase。
与其做一个"大而全"的 archetype 然后让每个新项目再去删东西,不如按形态拆分:
service-archetypes/
├── pom.xml # 聚合 POM
├── gateway-archetype/
├── bff-archetype/
└── ms-archetype/
聚合 POM:
<packaging>pom</packaging>
<modules>
<module>gateway-archetype</module>
<module>bff-archetype</module>
<module>ms-archetype</module>
</modules>
一次 mvn install 把三个都装到本地仓库,用户初始化时按场景选择。
发布到私有仓库#
本地验证通过之后,就可以发布到内网的 Nexus / Artifactory。
在 archetype 的 pom.xml 里配置 distributionManagement:
<distributionManagement>
<repository>
<id>internal-releases</id>
<url>https://nexus.example.com/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>internal-snapshots</id>
<url>https://nexus.example.com/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
然后:
mvn clean deploy
其他同事的 ~/.m2/settings.xml 里加上这个仓库后,就能直接用 archetype:generate 拉到。
注册到 catalog#
想让 archetype 出现在 archetype:generate 的交互式列表里,还可以把它注册到 catalog:
mvn archetype:update-local-catalog
或者维护一个 archetype-catalog.xml 放到 Nexus。
其他经验#
把 JDK 版本做成可配置参数#
如果项目用了比较新的 JDK(如 Spring Boot 3.5 + JDK 25),生成的 Dockerfile 里 eclipse-temurin:25-jdk-noble 要确认镜像已经发布;archetype 模板里写死版本的话,用户升级 JDK 时还得去改 archetype。
我通常会把 JDK 版本做成 requiredProperty,让用户按需选择。
Lombok 和 MapStruct 的注解处理器顺序#
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>#[[${mapstruct.version}]]#</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
这里通常需要把 Lombok 放在最前面,lombok-mapstruct-binding 放最后,否则 @Data 生成的 getter/setter 和 MapStruct 的映射逻辑容易对不上。
filtered 和 packaged 组合速查#
一个完整的组合矩阵:
filtered |
packaged |
使用场景 |
|---|---|---|
true |
true |
Java 源码(需要替换变量 + 放进包目录) |
true |
false |
资源文件(YAML/配置,需要替换变量,按原路径摆放) |
false |
false |
原样拷贝文件(logback、docker-compose、Dockerfile) |
false |
true |
很少用(原样拷贝但放进包目录) |
弄错的话,生成出来的项目要么 Java 找不到类,要么 YAML 里全是空值。
小结#
从现有项目抽象 archetype 的收益:
- 一致性:新服务天生就带着团队约定的 Actuator、日志、Dockerfile、Makefile 配置。
- 上手成本:新同事一条命令初始化项目,不用翻三个仓库抄文件。
- 升级路径:archetype 自身迭代版本,新项目拉新版本就能自然享受到改进。
制作过程里最容易翻车的地方:
- Velocity 转义。我这里不太建议依赖
\$,更倾向用#[[${...}]]#包住要透传的占位符。看着丑,但通常更省事。 .gitignore会被偷偷过滤掉。改名成gitignore+ post-generate Groovy 脚本重命名。git-commit-id-maven-plugin在新生成的项目里会报错。加<failOnNoGitDirectory>false</failOnNoGitDirectory>。- fileSet 的
filtered和packaged组合。Java 用true/true,YAML 用true/false,logback/Dockerfile 用false/false。
做完一轮之后,建议把整个流程自动化到 CI:每次 archetype 仓库推送主干,跑一遍「install → 在临时目录 generate → 在生成项目里 mvn package」的冒烟测试。这几步足够抓到绝大部分模板变更引入的回归。