前面已经见过 HealthController、HealthService 和 HealthStatus。它们负责的事情不同,但都建立在 Java 的类与对象之上。这一章先不讨论 Spring 如何管理对象,集中讲清楚普通 class 的组成、对象的创建和使用方式。
为了避免改动当前心跳接口,下面使用一个独立的 HealthReport 类练习。它表示一次心跳检测结果,保存服务状态和服务名称。
1. 类
类(class)是一种自定义类型,用来描述对象可以保存哪些状态、能够执行哪些操作。对象则是根据这个类型创建出来的具体实例。
一个普通 Java 源文件可以包含包声明、导入声明和类型声明:
1package cn.usehook.spring.basics;23import java.util.Objects;45public class HealthReport {6// 字段、构造方法和普通方法写在类体中7}
类声明可以拆成下面几个部分:
1访问修饰符 其他修饰符 class 类名 {2类成员3}
| 代码 | 含义 |
|---|---|
public | 其他包中的代码也可以访问这个类 |
class | 声明一个类 |
HealthReport | 类名,通常使用大驼峰命名 |
{ ... } | 类体,保存这个类的成员 |
一个类中常见的成员包括:
| 成员 | 作用 |
|---|---|
| 字段 | 保存对象状态或类级共享状态 |
| 构造方法 | 初始化新对象 |
| 实例方法 | 操作某一个对象的状态 |
| 静态方法 | 提供属于类本身的操作 |
| 嵌套类型 | 在类内部声明其他类、接口、枚举或 record |
本章只展开最常用的字段、构造方法和方法。嵌套类型需要先理解更多类型关系,可以留到后面再学习。
HealthReport 是 public 顶级类,因此源文件名必须是 HealthReport.java。一个源文件最多只能声明一个 public 顶级类型,还可以包含其他包级可见的顶级类型,但实际项目通常让一个源文件只承担一个主要类型。
这里只完成了类的定义,程序还没有创建任何 HealthReport 对象。类是类型说明,对象才是运行期间真正保存数据的实例。
2. 字段与封装
字段是声明在类体中的变量。每个 HealthReport 对象都需要保存自己的 status 和 service:
01package cn.usehook.spring.basics;0203public class HealthReport {0405private final String status;06private final String service;0708public HealthReport(String status, String service) {09this.status = status;10this.service = service;11}1213public String getStatus() {14return status;15}1617public String getService() {18return service;19}20}
以 private final String status 为例:
| 部分 | 含义 |
|---|---|
private | 只能在 HealthReport 类内部直接访问 |
final | 每个对象中的这个字段只能完成一次赋值 |
String | 字段的数据类型 |
status | 字段名称,使用小驼峰命名 |
字段通常保持 private,外部代码通过方法读取或修改它们。这种把内部状态和具体实现隐藏在类中、只开放必要操作的方式称为封装。
getStatus() 和 getService() 是读取字段的方法,通常称为 getter。这个类没有提供 setter,构造完成后也不能重新给 final 字段赋值。因为 String 本身不可变,HealthReport 的两个状态不会在创建后发生变化。
需要特别区分字段和局部变量:
| 对比项 | 实例字段 | 局部变量 |
|---|---|---|
| 声明位置 | 类体中、方法外 | 方法或代码块内部 |
| 所属范围 | 每个对象拥有一份 | 只属于本次方法调用 |
| 默认值 | 引用为 null、整数为 0、布尔值为 false | 没有可直接读取的默认值,使用前必须赋值 |
| 生命周期 | 通常随对象存在 | 执行离开代码块后结束 |
空白 final 字段必须在字段声明处或每个构造方法中完成赋值。只声明两个 final 字段而不提供初始化代码,类无法通过编译。
封装并不意味着每个字段都必须机械地生成 getter 和 setter。只读对象可以只有 getter;需要修改状态时,也可以提供表达业务含义的方法,并在方法中检查新值,而不是无条件开放所有字段。
3. 构造方法
构造方法在 new 创建对象时执行,负责让对象从一开始就处于有效状态。它的名称必须与类名相同,并且不声明返回类型,连 void 也不能写。
下面为 HealthReport 提供两个构造方法:
01package cn.usehook.spring.basics;0203public class HealthReport {0405private final String status;06private final String service;0708public HealthReport(String service) {09this("UP", service);10}1112public HealthReport(String status, String service) {13if (status == null || status.isBlank()) {14throw new IllegalArgumentException("status must not be blank");15}16if (service == null || service.isBlank()) {17throw new IllegalArgumentException("service must not be blank");18}1920this.status = status;21this.service = service;22}23}
同一个类可以声明多个参数列表不同的构造方法,这称为构造方法重载:
1HealthReport spring = new HealthReport("spring");2HealthReport database = new HealthReport("DOWN", "postgresql");
第一个构造方法只有 service 参数,通过 this("UP", service) 调用当前类中的第二个构造方法,统一完成检查与字段赋值。构造方法中的 this(...) 必须是第一条语句,避免一个对象被重复或不完整地初始化。
在第二个构造方法中,参数和字段名称相同:
1this.status = status;
左侧的 this.status 表示当前对象的字段,右侧的 status 表示构造方法参数。这里的 this 指向当前正在初始化的对象。
如果一个类没有声明任何构造方法,编译器会提供一个无参数构造方法。一旦自行声明了任意构造方法,编译器就不再自动提供无参数版本。因此,上面的类不能使用 new HealthReport(),除非再显式声明一个无参数构造方法。
无参数构造方法并不是所有 Java 类的硬性要求。对象必须具备哪些数据,应由类自己的有效状态决定。这里的心跳结果必须具有服务名称,所以强制调用方传入 service 比提供一个内容不完整的无参数对象更合理。
4. 方法与静态成员
实例方法描述具体对象能够执行的操作。下面的方法会读取当前对象的字段:
01public boolean isHealthy() {02return "UP".equals(status);03}0405public String describe() {06return describe("health");07}0809public String describe(String prefix) {10return prefix + ": status=" + status + ", service=" + service;11}
以 isHealthy() 为例,一个方法由访问修饰符、返回类型、方法名、参数列表和方法体组成:
| 代码 | 含义 |
|---|---|
public | 允许其他类调用 |
boolean | 返回值类型 |
isHealthy | 方法名 |
() | 参数列表,这里没有参数 |
{ ... } | 方法体 |
return 返回的值必须符合方法声明的返回类型。没有返回值的方法使用 void。参数是调用方交给方法的输入,方法内部还可以声明只在本次调用中使用的局部变量。
两个 describe 方法名称相同,但参数列表不同,这称为方法重载。调用 describe() 时使用无参数版本,调用 describe("database") 时使用带 String 参数的版本。仅修改返回类型不能形成合法重载,因为调用表达式无法只根据返回类型判断应该执行哪个方法。
没有 static 修饰的方法属于对象,需要通过对象引用调用:
1HealthReport report = new HealthReport("spring");23boolean healthy = report.isHealthy();4String description = report.describe();
静态成员属于类本身,不会在每个对象中各保存一份。下面添加一个类常量和一个静态工厂方法:
1public static final String STATUS_UP = "UP";23public static HealthReport up(String service) {4return new HealthReport(STATUS_UP, service);5}
调用静态成员时使用类名:
1HealthReport report = HealthReport.up("spring");2System.out.println(HealthReport.STATUS_UP);
static final 字段常用来声明类级常量,名称通常全部大写并使用下划线分隔。静态方法执行时没有当前对象,也就没有 this,所以不能直接访问 status、service 等实例字段;它只能使用自己的参数、局部变量和其他静态成员,或者先获得一个对象引用。
静态工厂方法并不保证复用同一个对象。上面的 up() 每调用一次都会执行一次 new HealthReport(...),所以每次仍然会创建新的对象。
5. 对象与引用
定义类之后,使用 new 创建对象:
01package cn.usehook.spring.basics;0203public class ClassDemo {0405public static void main(String[] args) {06HealthReport spring = HealthReport.up("spring");07HealthReport database = new HealthReport("DOWN", "postgresql");0809System.out.println(spring.describe());10System.out.println(database.describe("database"));11}12}
下面这行代码包含变量声明和对象创建两个动作:
1HealthReport spring = new HealthReport("UP", "spring");
- 左侧声明一个
HealthReport类型的引用变量spring。 - 右侧创建一个
HealthReport对象,并把对象引用赋给spring。
变量与对象不是同一个概念。多个变量可以引用同一个对象:
1HealthReport first = HealthReport.up("spring");2HealthReport second = first;34System.out.println(first == second); // true
这里只创建了一个对象,然后把同一个引用值赋给两个变量。通过 first 和 second 访问到的是同一个对象。
Java 的参数传递始终是值传递。把对象变量传给方法时,复制的是引用值,而不是复制整个对象:
01private static void replace(HealthReport report) {02report = HealthReport.up("postgresql");03}0405public static void main(String[] args) {06HealthReport report = HealthReport.up("spring");07replace(report);0809System.out.println(report.getService()); // spring10}
replace() 只能让自己的参数变量改为引用另一个对象,不能替换调用方变量 report 中保存的引用。如果对象本身允许修改,方法仍然可以通过复制得到的引用修改同一个对象的内部状态。
引用变量还可能保存 null,表示当前没有引用任何对象。对 null 调用实例方法会抛出 NullPointerException。如果一个对象不再能通过任何引用访问,它会成为垃圾回收器可以回收的对象,但具体回收时间由 JVM 决定,程序不能假设它会立即被清理。
引用变量本身也可以声明为 final:
1final HealthReport report = HealthReport.up("spring");
这里的 final 表示变量 report 不能再指向另一个对象。它和类中 final 字段的含义相似,限制的是再次赋值;它并不会自动让所引用的对象变成不可变对象。
6. 相等性与 Object
所有普通 Java 对象都可以使用 Object 定义的方法,其中最常见的是 equals()、hashCode() 和 toString()。
先创建两个字段值相同但彼此独立的对象:
1HealthReport first = HealthReport.up("spring");2HealthReport second = HealthReport.up("spring");34System.out.println(first == second); // false5System.out.println(first.equals(second)); // 取决于类是否重写 equals
对于引用类型,== 比较两个变量是否引用同一个对象。两个 new 表达式创建了两个对象,所以 first == second 为 false,即使它们保存相同的数据。
如果类没有重写 equals(),从 Object 继承的默认实现同样按照对象身份比较。要让两个状态和服务都相同的报告被视为相等,需要根据字段重写 equals():
01@Override02public boolean equals(Object other) {03if (this == other) {04return true;05}06if (!(other instanceof HealthReport report)) {07return false;08}09return status.equals(report.status) && service.equals(report.service);10}
this == other 先处理两个引用指向同一对象的情况。instanceof 检查另一个对象是否为 HealthReport,并把它赋给局部变量 report,然后逐个比较参与对象身份判断的字段。
重写 equals() 时必须同时重写 hashCode():
1@Override2public int hashCode() {3return Objects.hash(status, service);4}
相等对象必须产生相同的哈希值,否则把对象放入 HashMap、HashSet 等基于哈希的数据结构后会出现不一致的查找结果。反过来,哈希值相同不代表两个对象一定相等。
toString() 用来提供便于阅读的对象文本,打印对象、记录日志或在调试器中查看变量时会被调用:
1@Override2public String toString() {3return "HealthReport{status='%s', service='%s'}".formatted(status, service);4}
toString() 适合调试和日志,不应该把包含密码、令牌等敏感数据的字段直接拼入返回值。
7. 完整类与项目对应
把前面的字段、构造方法、实例方法、静态成员和 Object 方法合在一起,可以得到一个完整的不可变类:
01package cn.usehook.spring.basics;0203import java.util.Objects;0405public final class HealthReport {0607public static final String STATUS_UP = "UP";0809private final String status;10private final String service;1112public HealthReport(String service) {13this(STATUS_UP, service);14}1516public HealthReport(String status, String service) {17this.status = requireText(status, "status");18this.service = requireText(service, "service");19}2021public static HealthReport up(String service) {22return new HealthReport(STATUS_UP, service);23}2425public String getStatus() {26return status;27}2829public String getService() {30return service;31}3233public boolean isHealthy() {34return STATUS_UP.equals(status);35}3637public String describe() {38return describe("health");39}4041public String describe(String prefix) {42return prefix + ": status=" + status + ", service=" + service;43}4445private static String requireText(String value, String fieldName) {46if (value == null || value.isBlank()) {47throw new IllegalArgumentException(fieldName + " must not be blank");48}49return value;50}5152@Override53public boolean equals(Object other) {54if (this == other) {55return true;56}57if (!(other instanceof HealthReport report)) {58return false;59}60return status.equals(report.status) && service.equals(report.service);61}6263@Override64public int hashCode() {65return Objects.hash(status, service);66}6768@Override69public String toString() {70return "HealthReport{status='%s', service='%s'}".formatted(status, service);71}72}
类上的 final 表示不能再声明它的子类。这里把它与私有 final 字段、构造参数检查以及不提供 setter 结合起来,使 HealthReport 创建后保持不变。不可变对象更容易理解和共享,但不是所有类都必须设计成不可变类型;需要累积状态的对象可以提供受控的修改方法。
运行下面的代码可以观察创建、方法调用和相等性:
01package cn.usehook.spring.basics;0203public class ClassDemo {0405public static void main(String[] args) {06HealthReport first = HealthReport.up("spring");07HealthReport second = new HealthReport("UP", "spring");08HealthReport database = new HealthReport("DOWN", "postgresql");0910System.out.println(first.describe());11System.out.println(database.describe("database"));12System.out.println(first == second);13System.out.println(first.equals(second));14System.out.println(first);15}16}
1health: status=UP, service=spring2database: status=DOWN, service=postgresql3false4true5HealthReport{status='UP', service='spring'}
HealthReport 也属于 POJO(Plain Old Java Object)。POJO 不是新的 Java 语法,只表示这个类不需要继承框架父类或实现框架规定的接口。
几个容易混淆的名称可以这样区分:
| 名称 | 核心含义 |
|---|---|
| POJO | 不依赖特定框架约束的普通 Java 对象 |
| JavaBean | 通常遵循无参数构造方法、属性 getter 和 setter 等约定的类 |
| Spring Bean | 由 Spring IoC 容器创建和管理的对象 |
| record | Java 提供的紧凑数据载体类型,自动生成常用成员 |
一个 POJO 可以被 Spring 注册为 Bean,但 POJO 和 Spring Bean 不是同义词。使用构造器注入时,Spring Bean 也不要求必须具有无参数构造方法或为所有字段提供 setter。
当前项目中的 HealthStatus 使用 record:
1package cn.usehook.spring.health;23public record HealthStatus(String status, String service) {}
record 会根据组件列表生成构造方法、访问方法、equals()、hashCode() 和 toString(),适合只承载一组固定数据的对象。它的访问方法是 status() 和 service(),不是普通 JavaBean 风格的 getStatus() 和 getService()。
当一个值只能从有限选项中选择时,可以使用枚举类型,而不是让任意字符串进入业务代码。下面的枚举需要放在独立的 HealthState.java 文件中:
1package cn.usehook.spring.health;23public enum HealthState {4UP,5DOWN6}
enum 声明的每个枚举常量都是 HealthState 类型中预先创建的固定实例。方法参数如果声明为 HealthState,调用方只能传入 HealthState.UP 或 HealthState.DOWN,不能误传 "UNKNOWN" 这样的任意字符串:
01package cn.usehook.spring.basics;0203import cn.usehook.spring.health.HealthState;0405public class EnumDemo {0607public static void main(String[] args) {08HealthState state = HealthState.UP;0910System.out.println(state.name());11System.out.println(state == HealthState.UP);1213for (HealthState value : HealthState.values()) {14System.out.println(value);15}16}17}
同一个枚举常量使用固定实例表示,因此枚举通常可以使用 == 比较。name() 返回声明时的名称,values() 返回全部选项,valueOf("UP") 可以按名称查找枚举;无法匹配时会抛出 IllegalArgumentException,所以不能直接信任未经校验的外部字符串。
枚举不只是常量列表,它也可以声明字段、构造方法和普通方法。下面让每个状态自己保存是否可用以及对外显示文本:
01package cn.usehook.spring.health;0203public enum HealthState {04UP(true, "available"),05DOWN(false, "unavailable");0607private final boolean available;08private final String label;0910HealthState(boolean available, String label) {11this.available = available;12this.label = label;13}1415public boolean isAvailable() {16return available;17}1819public String getLabel() {20return label;21}22}
枚举构造方法不能由业务代码通过 new 调用,它只在声明枚举常量时使用。与 public static final String STATUS_UP = "UP" 相比,枚举同时限制了类型和值域,更适合表达稳定的业务状态。当前心跳项目仍然保留字符串状态,避免在基础章节中同时改动 DTO、数据库映射和测试;后续重构时可以把内部业务模型改为 HealthState,再由 DTO 和持久化边界决定怎样转换成字符串。
HealthService 则更适合使用普通类,因为它表达的是可以执行 check() 的服务行为。下一篇先学习类所在的包、源码目录、访问控制与 import;随后再通过接口与多态理解多个类怎样共享一组行为契约。