跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

当前 spring 项目的心跳接口全部写在 HealthController.java 中。项目继续增长后,控制器还要处理用户、订单和数据库逻辑,把所有类放在同一个目录会越来越难以维护。

Java 使用包(package)组织类型。包不只是文件夹分类,它会成为类完整名称的一部分,也会参与访问控制。理解包以后,才能准确理解 import、public,以及项目中的类为什么要放在对应目录。

1. 包

一个 Java 包是一组相关类型的命名空间。类、接口、枚举、注解和 record 都可以属于某个包。

第一章中的控制器声明如下:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
package cn.usehook.spring.controller;
2
3
public class HealthController {
4
}

第一行把 HealthController 放入 cn.usehook.spring.controller 包,因此这个类的完整名称是:

type-name.txt
1
cn.usehook.spring.controller.HealthController

完整名称由「包名 + 类型名」组成。不同包可以声明同名类型,例如 cn.usehook.spring.user.Status 和 cn.usehook.spring.order.Status,编译器可以通过完整名称区分它们。

包名通常采用反向域名并全部使用小写字母:

package-name.txt
1
cn.usehook.spring
2
cn.usehook.spring.controller
3
cn.usehook.spring.health

这些名称看起来有层级,但 Java 不会把子包当作父包的内部成员。cn.usehook.spring、cn.usehook.spring.controller 和 cn.usehook.spring.health 是三个彼此独立的包。父包中的包级可见成员,不会自动对所谓的子包开放。

没有 package 声明的类型属于未命名包,也称默认包。简单练习可以这样编译,但 Spring Boot 项目不应该使用默认包,否则组件扫描范围和类型组织都会变得不清晰。

2. 包与目录

Maven 把 src/main/java 作为主源码根目录。源码根目录不属于包名,从它之后的目录通常与 package 声明逐段对应:

Application.java应用入口类
HealthController.javaHTTP 控制器
HealthService.java心跳业务逻辑
HealthStatus.java心跳数据类型
package-info.java包级文档

以 HealthService.java 为例:

source-path.txt
1
源码根目录:src/main/java
2
包名:cn.usehook.spring.health
3
包目录:cn/usehook/spring/health
4
源文件:HealthService.java
5
完整路径:src/main/java/cn/usehook/spring/health/HealthService.java

真正决定包归属的是源码中的 package cn.usehook.spring.health;。目录保持一致是 Maven、编译器和 IDE 共同遵循的项目约定。如果只移动文件而不修改 package,类型的包名不会随目录自动改变,项目结构也会变得不一致。

一个源文件通常声明一个与文件同名的 public 顶级类型。HealthService.java 声明 HealthService,编译后会得到 cn/usehook/spring/health/HealthService.class。

package-info.java 用来保存包级 Javadoc 或包注解,不声明普通业务类:

src/main/java/cn/usehook/spring/health/package-info.java
1
/**
2
* 提供应用心跳状态的查询能力。
3
*/
4
package cn.usehook.spring.health;

它不是每个包的必需文件。只有包需要统一文档或注解时才创建。

3. 跨包调用

现在把 /api/v1/health 的心跳数据和业务逻辑从控制器中提取到 cn.usehook.spring.health 包。

先创建心跳结果类型:

src/main/java/cn/usehook/spring/health/HealthStatus.java
1
package cn.usehook.spring.health;
2
3
public record HealthStatus(String status, String service) {}

再创建心跳服务:

src/main/java/cn/usehook/spring/health/HealthService.java
01
package cn.usehook.spring.health;
02
03
import org.springframework.stereotype.Service;
04
05
@Service
06
public class HealthService {
07
08
public HealthStatus check() {
09
return new HealthStatus("UP", "spring");
10
}
11
}

HealthService 与 HealthStatus 属于同一个包,因此服务可以直接使用 HealthStatus,不需要 import。

控制器属于另一个包,需要通过 import 引入这两个类型:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.health.HealthService;
04
import cn.usehook.spring.health.HealthStatus;
05
import org.springframework.web.bind.annotation.CrossOrigin;
06
import org.springframework.web.bind.annotation.GetMapping;
07
import org.springframework.web.bind.annotation.RequestMapping;
08
import org.springframework.web.bind.annotation.RestController;
09
10
@RestController
11
@RequestMapping("/api/v1")
12
@CrossOrigin(origins = "*")
13
public class HealthController {
14
15
private final HealthService healthService;
16
17
public HealthController(HealthService healthService) {
18
this.healthService = healthService;
19
}
20
21
@GetMapping("/health")
22
public HealthResponse health() {
23
HealthStatus data = healthService.check();
24
return new HealthResponse(0, "success", data);
25
}
26
27
public record HealthResponse(int code, String message, HealthStatus data) {}
28
}

这里有两步不能混淆:

  1. import cn.usehook.spring.health.HealthService; 让当前源文件可以使用简单名称 HealthService。
  2. healthService.check() 才是真正调用 HealthService 对象的 check() 方法。

普通 import 引入的是类型名称,不是方法,也不会创建对象。HealthService 上的 @Service 让 Spring 创建服务对象,控制器的构造方法再接收这个对象。

不写 import 时,也可以直接使用类型的完整名称:

HealthController.java
1
cn.usehook.spring.health.HealthStatus data = healthService.check();

两种写法指向同一个类型。重复使用一个外部类型时,通常使用 import;只出现一次或存在同名类型冲突时,可以使用完整名称。

import 也不会下载外部库。如果要引入 Spring 或第三方库中的类型,必须先在 pom.xml 中声明依赖,让 Maven 把对应类放入编译 classpath,然后 Java 源码中的 import 才能解析成功。

4. 访问控制

把类放进另一个包后,仅仅写 import 还不够。被调用的类型和成员必须具有合适的访问权限。

Java 顶级类型最常见的两种访问级别是:

声明可访问范围
public class HealthService在包边界允许的前提下,其他包也可以使用
class HealthService仅同一个包中的代码可以使用,也称包级可见

类中的字段、构造方法和方法还可以使用下面四种访问级别:

修饰符同一个类同一个包其他包中的子类其他包中的普通类
public可以可以可以可以
protected可以可以按继承访问规则使用不可以
不写修饰符可以可以不可以不可以
private可以不可以不可以不可以

从 HealthController 调用 healthService.check(),至少需要满足:

HealthService.java
1
public class HealthService {
2
3
public HealthStatus check() {
4
return new HealthStatus("UP", "spring");
5
}
6
}

外部类 HealthService 必须是 public,check() 方法也必须是 public。方法返回的 HealthStatus 出现在另一个包的代码中,所以它同样必须是 public。

如果删掉 check() 前的 public,它就变成包级可见方法。HealthService 所在的 cn.usehook.spring.health 可以调用它,但 cn.usehook.spring.controller 中的控制器不能调用。

public 表示允许跨包访问,但它不会把代码自动注册给 Spring,也不会为当前源文件生成 import。Spring Bean 注册、Java 名称解析和访问控制是三个不同环节。

5. import

Java 提供了几种导入形式,它们都必须写在 package 声明之后、类型声明之前。

单类型导入

HealthController.java
1
package cn.usehook.spring.controller;
2
3
import cn.usehook.spring.health.HealthService;
4
import cn.usehook.spring.health.HealthStatus;

这是最清晰的写法。每一行引入一个可访问的顶级类型,阅读代码时可以直接看到类型来源。

通配符导入

HealthController.java
1
import cn.usehook.spring.health.*;

* 表示当前包中所有可访问的顶级类型,但不会导入 cn.usehook.spring.health.internal 等子包,也不会导入类型中的方法。通配符不会在编译结果中加入更多代码,但显式导入通常更容易阅读和处理同名类型。

隐式与同包类型

同一个包中的类型可以直接使用。java.lang 中的 String、Object、System 等类型也会被每个普通编译单元隐式导入。其他 JDK 类型仍然需要显式导入,例如:

HealthService.java
1
import java.time.Instant;

静态导入

只有可访问的静态成员才能通过 import static 导入。导入后可以省略类型名:

HealthService.java
01
package cn.usehook.spring.health;
02
03
import static java.util.Objects.requireNonNull;
04
05
public class HealthService {
06
07
public HealthStatus check(String service) {
08
requireNonNull(service);
09
return new HealthStatus("UP", service);
10
}
11
}

requireNonNull 是 Objects 的 public static 方法,所以可以静态导入。普通实例方法不能通过 import static 引入,仍然要先取得对象,再使用 对象.方法() 调用。

6. 包与模块

普通 Java 包没有独立的 export 文件。当前 Spring Boot 项目通过 classpath 运行,没有 module-info.java;要让 controller 包使用 health 包中的代码,只需要同时满足:目标类型是 public、目标方法或构造方法允许外部访问,并在使用方通过 import 引入类型。

包和 Java 平台模块系统(JPMS)都能建立代码边界,但位于不同层次:

对比项Java 包Java 模块
声明文件普通源文件中的 packagemodule-info.java 中的 module
包含内容类、接口、枚举、注解、record一个或多个包
主要标识类型命名空间依赖和公开边界
使用外部代码import 类型,调用可访问成员声明对其他模块的读取关系
对外提供通过 public 提供类型和成员选择哪些包作为模块 API
反射边界主要由类型和成员访问权限决定可以单独控制深层反射开放
是否必须命名包强烈推荐命名模块按项目需要使用

当前项目的关系是:

current-project.txt
1
spring Maven 项目
2
└── 未命名模块 当前 classpath 中的应用代码
3
├── cn.usehook.spring
4
│ └── Application
5
├── cn.usehook.spring.controller
6
│ └── HealthController
7
└── cn.usehook.spring.health
8
├── HealthService
9
└── HealthStatus

pom.xml 中的 Maven 依赖也不等于 Java 包或 Java 模块。Maven 负责取得构建所需的 JAR,包负责确定类型名称与包级访问,JPMS 则为多个命名模块建立更高层的依赖和公开边界。

当前项目不需要增加模块描述符。侧栏末尾的「Java 模块」属于可选进阶内容,会在独立纯 Java 实验中讲解 requires、exports 和 opens,不会修改心跳项目。下一篇继续学习接口与多态,理解为什么跨包调用通常依赖公开契约,而不是绑死在某个具体实现上。