本节目标:能用包把
library分层,能准确说出四种访问修饰符的边界,并理解module-info.java在 classpath 之上又加了什么。
适用版本:JDK 25 LTS(Temurin 25.0.5)。
包解决三个问题
单文件程序里,类名是全局的。两个人都写了一个 Book,就没法放进同一个程序。包(package)解决的第一个问题就是命名冲突:用 com.plume.library.domain.Book 和 com.example.catalog.Book 区分同名类。
第二个问题是组织。library 会长出领域对象、仓储、服务、命令行四层,如果所有类都堆在一个目录里,读代码的人要靠文件名猜关系。包把「同一职责的类」放在一起,目录结构直接反映模块边界。
第三个问题是访问控制。Java 的包私有(package-private)可见性以包为单位:同一个包里的类可以互相访问,包外的类不行。这给了你一种比 public 更细的边界,既不是「完全公开」,也不是「完全私有」。
包声明必须写在文件第一行(注释除外):
package com.plume.library.domain;
一个文件只能有一个包声明。没有包声明的类属于「无名包」,只能用于临时验证,不能用于正式项目。无名包里的类不能被有包名的类导入,这是 JShell 片段和真实工程之间的一道硬边界。
命名与目录
包名用反写域名:com.plume.library。全小写,不用下划线,不用连字符。每个点对应一层目录:
src/main/java/
└── com/
└── plume/
└── library/
├── LibraryApp.java
├── domain/
│ └── Book.java
├── repository/
│ └── InMemoryLibraryRepository.java
├── service/
│ └── LibraryService.java
└── cli/
├── CommandNames.java
└── Usage.java
目录和包名必须一致。package com.plume.library.cli; 的源码放在 src/main/java/com/plume/library/CLI/ 里,编译器可能仍接受,但类路径解析会出问题:JVM 用包名拼路径,大小写不匹配时找不到 class。macOS 默认文件系统不区分大小写,这类错误在本机能跑,到 Linux 上就暴露。
包名不是越深越好。com.plume.library.service.internal.impl 这种五层结构,通常说明分层过度。卷一的规模用两层足够:com.plume.library 放入口,com.plume.library.domain、.repository、.service、.cli 放四类职责。第 8 章引入接口时再考虑是否拆子包。
import 的三种写法
跨包使用类时,有三种写法:
// 1. 单类型导入:只导入 Usage
import com.plume.library.cli.Usage;
// 2. 按需导入:导入 cli 包下所有类型
import com.plume.library.cli.*;
// 3. 全限定名:不写 import,直接写完整类名
com.plume.library.cli.Usage.print();
单类型导入最推荐。它明确告诉读者「这个文件用了哪个类」,也不会因为包内新增同名类而突然冲突。按需导入写起来省事,但在大文件里很难看出依赖来源,两个包有同名类时还会编译失败。全限定名适合只用一次、或者故意要和另一个同名类区分的场景。
同一个包里的类不需要 import;java.lang 包(String、System、Object、Integer 等)也不需要。import 只影响编译期的名字解析,不改变运行时行为,也不会把类「复制」进来。删掉一个没用的 import 不会影响功能,但会让代码更干净。
静态导入是另一种形式:
import static com.plume.library.cli.CommandNames.ADD;
之后可以直接写 ADD,不用写 CommandNames.ADD。静态导入适合常量或工具方法,但过度使用会让读者不知道名字从哪来。卷一的规模不建议大面积使用。
四种可见性
Java 有四个访问级别,作用在类、字段、方法、构造器上:
| 修饰符 | 同一个类 | 同一个包 | 子类(不同包) | 任意位置 |
|---|---|---|---|---|
private | 可见 | 不可见 | 不可见 | 不可见 |
| 无修饰符(包私有) | 可见 | 可见 | 不可见 | 不可见 |
protected | 可见 | 可见 | 可见 | 不可见 |
public | 可见 | 可见 | 可见 | 可见 |
几个容易记错的点:
- 包私有不是「默认公开」。 它是包级可见,包外一律不可见,哪怕类是
public。 protected的前提是继承。 不同包的子类可以通过this或子类实例访问父类的protected成员,但不能通过父类实例访问。第 8 章会展开。- 顶层类只能用
public或包私有。 不能写private class Book,也不能写protected class Book。内部类不受这个限制。 public类必须放在同名文件里。 包私有类可以放在任意文件名里,但通常还是一个类一个文件。
访问控制不是「越严越好」。字段用 private,对外暴露方法;包内协作的辅助类用包私有;真正给外部用的 API 才用 public。这样改内部实现时,不会牵动包外代码。
在 library 里拆包
把 library 拆成两个包:com.plume.library 放入口,com.plume.library.cli 放命令相关的类。先写一个包私有的命令名常量类:
package com.plume.library.cli;
final class CommandNames {
static final String ADD = "add";
static final String LIST = "list";
static final String BORROW = "borrow";
static final String RETURN = "return";
static final String SEARCH = "search";
static final String QUIT = "quit";
private CommandNames() {
}
}
类本身没有 public,所以只有 com.plume.library.cli 包里的代码能访问它。构造器是 private,防止被实例化;所有成员都是 static,这是一个纯常量容器。
再写一个公开的用法提示类:
package com.plume.library.cli;
public final class Usage {
private Usage() {
}
public static void print() {
System.out.println("usage: library <"
+ String.join("|", CommandNames.ADD, CommandNames.LIST,
CommandNames.BORROW, CommandNames.RETURN,
CommandNames.SEARCH, CommandNames.QUIT)
+ ">");
}
}
Usage 是 public,因为入口类要从另一个包调用它。CommandNames 是包私有,因为它只是 cli 包的内部实现,外部不需要知道命令名常量放在哪。这就是「公开的 API 窄,内部的实现可以自由改」。
入口类导入 Usage:
package com.plume.library;
import com.plume.library.cli.Usage;
public final class LibraryApp {
public static void main(String[] args) {
System.out.println("Hello, Library");
if (args.length == 0) {
Usage.print();
return;
}
System.out.println("command = " + args[0]);
}
}
编译并运行:
javac -d out $(find src/main/java -name '*.java')
java -cp out com.plume.library.LibraryApp
本机输出:
Hello, Library
usage: library <add|list|borrow|return|search|quit>
现在验证包私有边界。在 com.plume.library 包里写一个类,尝试直接引用 CommandNames:
package com.plume.library;
import com.plume.library.cli.CommandNames;
public final class AccessProbe {
public static void main(String[] args) {
System.out.println(CommandNames.ADD);
}
}
编译失败,报错原文:
AccessProbe.java:3: 错误: CommandNames在com.plume.library.cli中不是公共的; 无法从外部程序包中对其进行访问
import com.plume.library.cli.CommandNames;
^
AccessProbe.java:7: 错误: CommandNames在com.plume.library.cli中不是公共的; 无法从外部程序包中对其进行访问
System.out.println(CommandNames.ADD);
^
2 个错误
注意错误发生在 import 和引用两处。包私有类连「被导入」都不允许,不是等到调用方法才检查。要跨包使用,只有两条路:把 CommandNames 改成 public,或者让 cli 包提供一个公开方法(比如 Usage.print)把内部细节包起来。后者更好,因为它把变化留在包内。
模块:在包之上再加一层
包是编译期和运行期的命名空间,但它有一个历史遗留问题:只要一个类在类路径上,反射就能访问它的所有成员,不管包有没有导出。 模块(module)是 JDK 9 引入的更强边界,用 module-info.java 声明:
module com.plume.library {
exports com.plume.library;
}
exports 表示「这个包对其他模块可见」。没有导出的包,即使类是 public,其他模块也不能访问。模块还可以用 requires 声明依赖:
module com.plume.library {
exports com.plume.library;
requires java.logging;
}
模块源码的目录结构和 classpath 项目不同:每个模块一个顶层目录,module-info.java 放在模块目录根部。比如 src/com.plume.library/module-info.java 和 src/com.plume.library/com/plume/library/LibraryApp.java。
编译和运行改用模块路径:
javac -d mods --module-source-path src $(find src -name '*.java')
java --module-path mods --module com.plume.library/com.plume.library.LibraryApp
本机 JDK 25 输出:
Hello, Library
usage: library <add|list|borrow|return|search|quit>
--module 后面是「模块名/主类全限定名」。只写模块名会报:
模块 com.plume.library 不具有 ModuleMainClass 属性,请使用 -m <模块>/<主类>
因为模块本身不知道哪个类是入口。要在 module-info.java 里写 mainClass,或者运行时显式指定主类。
模块和类路径的区别可以这样理解:
| 维度 | classpath | module path |
|---|---|---|
| 边界单位 | 包 | 模块 |
| 可见性 | public 即可见 | 必须 exports |
| 依赖声明 | 靠 classpath 里的 jar | requires 显式声明 |
| 反射 | 默认可以深入 | 默认受限,需要 opens |
| 上手成本 | 低 | 高,需要处理拆分包和自动模块 |
卷一的 library 不启用模块。原因很实际:模块要求所有依赖都模块化,第三方库如果没有 module-info.class,会变成「自动模块」,名字从 jar 文件名推导,容易出现冲突;卷一后面要用的 JUnit 5 在测试路径上也有自己的模块配置。第 17 章讲模块化 jar 时会再处理这些边界。现在只需要知道:包是必须的,模块是可选的;模块提供更强的封装,也带来更高的配置成本。
常见错误与边界
「包声明和目录不一致。」 package com.plume.library.cli; 的源码放在 src/main/java/com/plume/library/ 下,javac 可能仍能编译,但产物路径会按包名生成到 out/com/plume/library/cli/,而不是源码所在目录。运行时用全限定名找类,通常还能找到;但 IDE 的导航和 Maven 的增量编译会混乱。让目录和包名严格对应,是成本最低的做法。
「public 类文件名不对。」 和第 3.1 节一样:public class Usage 必须放在 Usage.java。包私有类没有这个限制,但一个文件放多个类会让包边界更难看清。
「模块里忘记 exports。」 模块编译通过,但另一个模块引用时报「包不可见」。public 只在模块内部有效,跨模块必须 exports。如果还要允许反射,得加 opens,两者不是一回事。
「模块路径和类路径混用。」 --module-path 上的模块不会自动进入 -cp,-cp 上的类也不会自动成为模块。混用时会出现「同一个包在两个地方」「自动模块名字冲突」等问题。卷一只用 classpath,就是为了避开这类复杂度。
小结
- 包解决命名冲突、代码组织和访问控制三件事;包名反写域名,全小写,目录必须对应。
- 四种可见性从窄到宽是
private、包私有、protected、public;顶层类只能public或包私有。 - 包私有类不能被包外
import,要跨包使用必须改成public或提供公开方法包住实现。 - 模块用
module-info.java的exports/requires在包之上加边界,public不再等于跨模块可见。 - classpath 上手简单,module path 封装更强但配置成本高;卷一使用 classpath。
library的入口留在com.plume.library,命令相关实现放进com.plume.library.cli,只暴露Usage。
包和类都放好了,接下来要让 Maven 把「编译、测试、打包」串成一条可重复的流水线。下一节拆开 compile、test、package 三个阶段,看看每个阶段在 target 下留下什么。
阅读导航:上一节:3.1 Hello, Library:javac 与 java 的完整流程 · 下一节:3.3 Maven 生命周期:compile、test、package 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。