《Java 编程入门》2.2 Maven 与标准目录结构

把 library 从单文件 javac 推进到 Maven 工程:解释 groupId、artifactId、version 与标准目录结构,逐项拆开最小 pom.xml 里的 release 与编码配置,再用 mvn compile 验证 target/classes 的产物,并列出目录放错、POM 缺失、编码未设三类常见故障。

本节目标:理解 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 会遇到四个问题:

  1. 文件列表要自己维护。 每加一个类,都要把它拼进命令;漏一个就是编译失败或运行时缺类。
  2. 类路径要自己拼。 依赖的 jar 放在哪、测试依赖和运行依赖怎么区分,javac 不帮你管。
  3. 测试不会自动跑。 javac 只编译,不执行测试,也不生成测试报告。
  4. 产物不可复现。 不同人机器上的命令历史不同,构建出来的东西可能不一样。

Maven 解决的不是「Java 语法」问题,而是「一个多人协作的 Java 工程怎么被稳定地编译、测试、打包」。它用一套约定替代大量命令行参数,再用一个声明式文件描述项目。

Maven 的三个坐标

Maven 用三个值定位一个项目,合称 GAV:

坐标library 的取值作用
groupIdcom.plume组织或域名反写,决定包的归属
artifactIdlibrary项目名,决定产物文件名
version0.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 的工作方式暴露出来了:

  1. 读取 pom.xml,解析出 GAV 和配置。
  2. 执行 resources 插件,把 src/main/resources 复制到 target/classes。目录不存在就跳过。
  3. 执行 compiler 插件,调用 javac,把 src/main/java 下的源码编译到 target/classes。
  4. 打印 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 与命令行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发