本节目标:理解 Maven 的坐标与标准目录约定,能写出一份只含编译配置的最小
pom.xml,并用mvn compile得到可运行的target/classes。
适用版本:JDK 25 LTS(Temurin 25.0.5);Maven 3.9.12。
javac 的天花板
上一节用两条命令就跑通了 EnvironmentProbe:
javac -d out src/main/java/com/plume/library/EnvironmentProbe.java
java -cp out com.plume.library.EnvironmentProbe
单文件、单包、无依赖时,这样完全够用。但 library 很快会长出 domain、repository、service、cli 几个包,还会出现测试代码、资源文件、配置文件、第三方依赖。到那时,手写 javac 会遇到四个问题:
- 文件列表要自己维护。 每加一个类,都要把它拼进命令;漏一个就是编译失败或运行时缺类。
- 类路径要自己拼。 依赖的 jar 放在哪、测试依赖和运行依赖怎么区分,
javac不帮你管。 - 测试不会自动跑。
javac只编译,不执行测试,也不生成测试报告。 - 产物不可复现。 不同人机器上的命令历史不同,构建出来的东西可能不一样。
Maven 解决的不是「Java 语法」问题,而是「一个多人协作的 Java 工程怎么被稳定地编译、测试、打包」。它用一套约定替代大量命令行参数,再用一个声明式文件描述项目。
Maven 的三个坐标
Maven 用三个值定位一个项目,合称 GAV:
| 坐标 | library 的取值 | 作用 |
|---|---|---|
groupId | com.plume | 组织或域名反写,决定包的归属 |
artifactId | library | 项目名,决定产物文件名 |
version | 0.1.0-SNAPSHOT | 版本号,SNAPSHOT 表示开发中 |
这三个值拼起来是 com.plume:library:0.1.0-SNAPSHOT。当 library 以后被别的项目依赖时,对方就是通过这三个值找到它。卷一的 CLI 项目暂时不会被别人依赖,但坐标仍然要写对,因为打包、安装到本地仓库、发布都依赖它。
SNAPSHOT 不是「随便写写」。Maven 会把带 SNAPSHOT 的版本当作未发布版本,允许覆盖本地仓库里的同名产物;正式版本一旦发布就不应该再改。library 在卷一一直用 0.1.0-SNAPSHOT,到第 17 章讲发布时再处理版本号。
标准目录结构
Maven 的核心是「约定优于配置」。它默认认为源码在 src/main/java,测试在 src/test/java,资源在 src/main/resources,产物写到 target。只要按这个结构放文件,pom.xml 里几乎不用写路径。
library/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/plume/library/
│ │ │ └── LibraryApp.java
│ │ └── resources/
│ └── test/
│ ├── java/
│ │ └── com/plume/library/
│ └── resources/
└── target/
├── classes/
├── test-classes/
└── library-0.1.0-SNAPSHOT.jar
四个目录的职责要分清:
src/main/java:会被编译进产物的源码。包名必须和子目录对应。src/main/resources:会被复制进target/classes的非 Java 文件,比如配置、模板、SQL。src/test/java:只用于测试的源码,不会进入最终 jar。src/test/resources:测试专用资源,不会进入最终 jar。
target 是生成目录,不是源码。它应该被 .gitignore 忽略,也不应该手工修改。很多人把 target 提交进 Git,结果每次构建都产生大量二进制 diff,代码评审完全没法看。
src/main/java 和 src/test/java 的分离还有一个实际好处:测试代码可以引用 JUnit 这类只在测试期存在的依赖,而主代码不会意外引用它们。第 16 章会利用这一点。
最小 pom.xml
pom.xml 是 Maven 的项目对象模型(Project Object Model)。最小可用版本只需要坐标、打包方式和编译配置:
<?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.plume</groupId>
<artifactId>library</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
</project>
逐项解释:
modelVersion固定写4.0.0,这是 POM 模型版本,不是项目版本。packaging写jar,表示最终产物是 jar。默认值也是jar,写出来更清楚。maven.compiler.release告诉编译器用--release 21,产出的 class 文件能在 JDK 21 上运行。开发机是 25,但目标版本降到 21,兼容面更宽。- 两个 encoding 属性都设成 UTF-8。
project.build.sourceEncoding影响源码读取和资源复制,project.reporting.outputEncoding影响报告输出。中文注释和中文资源都依赖它们。
这里没有写依赖,因为卷一第 16 章之前只用 JDK 标准库。也没有指定 compiler plugin 版本,Maven 3.9.12 会绑定一个默认版本(本机是 3.13.0)。真实项目里通常会把插件版本显式写出来,避免不同 Maven 版本解析到不同插件;第 17 章会讲插件版本锁定。
如果想确认「Maven 实际用了哪些配置」,可以执行 mvn help:effective-pom。它会把父 POM、settings.xml 和当前 POM 合并后的结果打印出来,包括插件版本、编码、编译参数。排查「配置写了但不生效」时,先看 effective POM,比反复改 pom.xml 更快。
release 为什么是 21 而不是 25?因为卷一用到的语法在 21 里都有,把目标降到 21 不会损失能力,却能让产物在更多环境运行。如果某个类确实用了 25 才有的 API,--release 21 会在编译期报错,而不是等到运行时才出问题。这是 --release 比 -source/-target 更可靠的地方。
mvn compile 做了什么
在 library 根目录执行:
mvn -B compile
本机输出(节选;路径按示例项目目录调整):
[INFO] Scanning for projects...
[INFO] -------------------------< com.plume:library >--------------------------
[INFO] Building library 0.1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ library ---
[INFO] skip non existing resourceDirectory /Users/bingrong.yan/code/library/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ library ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
这段输出已经把 Maven 的工作方式暴露出来了:
- 读取
pom.xml,解析出 GAV 和配置。 - 执行
resources插件,把src/main/resources复制到target/classes。目录不存在就跳过。 - 执行
compiler插件,调用javac,把src/main/java下的源码编译到target/classes。 - 打印
BUILD SUCCESS。
注意 [debug release 21] 这几个字。它说明 Maven 确实把 maven.compiler.release=21 传给了 javac,不是只在 POM 里摆着。构建参数以日志为准,不要只看配置文件。
编译后的目录:
target/classes/
└── com/
└── plume/
└── library/
└── LibraryApp.class
这个结构和上一节 javac -d out 生成的完全一样。Maven 没有发明新的字节码格式,它只是把「找文件、拼参数、调 javac」这套动作标准化了。
本地仓库与离线构建
Maven 第一次构建时会联网下载插件和依赖,存到本地仓库 ~/.m2/repository。这个目录是跨项目共享的:两个项目都用 compiler plugin 3.13.0,就只会存一份。
本地仓库带来两个实际影响:
- 第一次构建慢,后面快。 下载发生在第一次,之后命中缓存。CI 上通常会把
~/.m2/repository做成缓存目录,避免每次流水线都重新下载。 - 离线环境要提前准备。 内网构建机如果不能访问 Maven Central,需要在
settings.xml里配置镜像仓库,或者提前把依赖导入本地仓库。mvn -o强制离线,缺任何插件都会直接失败。
library 卷一没有第三方依赖,但 Maven 本身仍需要下载核心插件(clean、resources、compiler、surefire、jar)。所以「零依赖」不等于「零下载」。第一次在干净机器上执行 mvn compile,看到大量 Downloading from central 是正常的。
不要在项目里提交 ~/.m2,也不要把本地仓库路径写进 pom.xml。本地仓库是机器级缓存,不是项目的一部分。需要固定仓库地址时,用 settings.xml 或 CI 配置,不要污染项目文件。
在 library 里落地
先建目录:
mkdir -p ~/code/library/src/main/java/com/plume/library
mkdir -p ~/code/library/src/main/resources
mkdir -p ~/code/library/src/test/java/com/plume/library
cd ~/code/library
把上一节的环境探针挪进来,再写一个最小的 LibraryApp,确认 Maven 能编译带包名的源码:
package com.plume.library;
public final class LibraryApp {
public static void main(String[] args) {
System.out.println("library: ready");
}
}
这份代码故意只打印一行。第 3 章会把它改成 Hello, Library 并加上参数解析,现在只验证「目录、包名、POM」三者对齐。
把 pom.xml 放在 library/ 根目录,执行:
mvn -B compile
java -cp target/classes com.plume.library.LibraryApp
-B 是 batch mode,关闭交互式进度条和 ANSI 控制字符,输出更适合粘贴到日志和文档里。CI 默认就会加这个参数,本地排查时也建议加上。
本机输出:
library: ready
再加一个探针,确认运行时确实从 target/classes 加载类:
package com.plume.library;
public final class MavenProbe {
public static void main(String[] args) {
System.out.println("compiled by Maven");
System.out.println("classpath = " + System.getProperty("java.class.path"));
}
}
重新编译并运行:
mvn -B compile
java -cp target/classes com.plume.library.MavenProbe
本机输出:
compiled by Maven
classpath = target/classes
java.class.path 只包含你传给 -cp 的值,不包含 JDK 自己的核心类。Maven 在 compile 阶段把源码编译到 target/classes,运行时再用同一个目录作为类路径根,两边就对齐了。
再加一个 .gitignore,避免把构建产物和 IDE 配置提交进去:
target/
out/
.idea/
*.iml
.vscode/
.DS_Store
.gitignore 的规则要和项目实际使用的工具一致。比如团队统一用 IntelliJ,就可以保留 .idea/ 的忽略;如果项目里提交了共享的代码风格配置,则要单独放行对应文件。原则是:生成物不提交,个人工具配置不提交,团队共享配置才提交。
常见错误与边界
「POM 不在当前目录。」 在 src/ 或 target/ 里执行 mvn,会得到:
[ERROR] The goal you specified requires a project to execute but there is no POM in this directory
Maven 从当前目录开始找 pom.xml,找不到就失败。它不会自动向上搜索父目录(除非用 -f 指定文件)。进入项目根目录再执行,或者用 mvn -f ~/code/library/pom.xml compile。
「源码放在 src/java。」 Maven 只认 src/main/java。目录放错时它不会报错,而是显示:
[INFO] --- compiler:3.13.0:compile (default-compile) @ library ---
[INFO] No sources to compile
[INFO] BUILD SUCCESS
BUILD SUCCESS 加 No sources to compile 是一个危险组合:构建成功了,但什么都没编译。看到这行要立刻检查目录拼写。
「包名和目录不一致。」 package com.plume.library; 的源码如果放在 src/main/java/com/plume/LibraryApp.java,javac 仍可能编译通过,但产物路径会变成 target/classes/com/plume/LibraryApp.class,运行时用 com.plume.library.LibraryApp 就找不到。包名是类全限定名的一部分,目录只是它的物理映射,两者必须一致。
「没设 UTF-8,中文变成问号。」 源码里有中文注释或字符串,而构建机默认编码是 GBK 时,编译可能成功但字符串内容已经损坏。表现是运行时打印乱码,或者测试里的中文断言失败。project.build.sourceEncoding 必须显式设为 UTF-8,不能依赖操作系统默认值。
小结
javac适合单文件验证;多包、多依赖、测试和打包需要构建工具。- Maven 用
groupId:artifactId:version定位项目,SNAPSHOT表示开发中的可覆盖版本。 - 标准目录是
src/main/java、src/main/resources、src/test/java、src/test/resources,产物写到target。 target和 IDE 个人配置不应该提交,团队共享配置才进版本库。- 最小
pom.xml只需要坐标、packaging、maven.compiler.release和 UTF-8 编码。 mvn compile依次执行 resources 和 compiler 插件,把源码编译到target/classes。- 配置是否真的生效,看构建日志里的
[debug release 21],或者用mvn help:effective-pom看合并结果。 - 目录放错不会报错,只会得到
No sources to compile;包名与目录不一致则会在运行时找不到类。 - 第一次构建会把核心插件下载到
~/.m2/repository,之后命中缓存;这个目录不属于项目。
工程骨架有了,接下来要决定「在哪里写代码」。IDE、JShell 和命令行各有适用场景,下一节把它们放进同一套工作流,而不是二选一。
阅读导航:上一节:2.1 安装 JDK 25:Temurin、PATH 与 JAVA_HOME · 下一节:2.3 IDE、JShell 与命令行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。