背景#

一个团队维护多个 Spring Boot 服务时,常见的痛点是:

  • 每个新服务都要从头抄一遍 pom.xmlapplication.yamllogback-spring.xmlDockerfileMakefile……
  • 抄着抄着就出现"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

两条关键规则:

  1. archetype-resources/ 下的目录结构就是将来生成项目的结构——一比一复制
  2. 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 版本,甚至是"是否启用某个模块"。

fileSetfilteredpackaged#

属性 含义
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},问自己两个问题:

  1. 这个变量是用户在 archetype:generate 时要回答的(比如 ${groupId}${artifactId}${servicePort})? → 原样写 ${xxx},让 Velocity 替换成用户输入的值。

  2. 这个变量要保留到生成项目里,由 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>

写完之后,最好还是跑一遍生成流程,用 catdiff 核对生成后的 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 文件没有包声明,或包路径不对 fileSetpackaged="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 个文件(mvnwmvnw.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 的映射逻辑容易对不上。

filteredpackaged 组合速查#

一个完整的组合矩阵:

filtered packaged 使用场景
true true Java 源码(需要替换变量 + 放进包目录)
true false 资源文件(YAML/配置,需要替换变量,按原路径摆放)
false false 原样拷贝文件(logback、docker-compose、Dockerfile)
false true 很少用(原样拷贝但放进包目录)

弄错的话,生成出来的项目要么 Java 找不到类,要么 YAML 里全是空值。

小结#

从现有项目抽象 archetype 的收益:

  • 一致性:新服务天生就带着团队约定的 Actuator、日志、Dockerfile、Makefile 配置。
  • 上手成本:新同事一条命令初始化项目,不用翻三个仓库抄文件。
  • 升级路径:archetype 自身迭代版本,新项目拉新版本就能自然享受到改进。

制作过程里最容易翻车的地方:

  1. Velocity 转义。我这里不太建议依赖 \$,更倾向用 #[[${...}]]# 包住要透传的占位符。看着丑,但通常更省事。
  2. .gitignore 会被偷偷过滤掉。改名成 gitignore + post-generate Groovy 脚本重命名。
  3. git-commit-id-maven-plugin 在新生成的项目里会报错。加 <failOnNoGitDirectory>false</failOnNoGitDirectory>
  4. fileSet 的 filteredpackaged 组合。Java 用 true/true,YAML 用 true/false,logback/Dockerfile 用 false/false

做完一轮之后,建议把整个流程自动化到 CI:每次 archetype 仓库推送主干,跑一遍「install → 在临时目录 generate → 在生成项目里 mvn package」的冒烟测试。这几步足够抓到绝大部分模板变更引入的回归。

参考#