1. 现实问题:公开字段会让库存失去可信度
如果 Product.stock 是 public,任何调用方都可以直接写成 -99;订单、管理后台和测试各自修改字段,最终很难知道谁破坏了规则。类的意义是把数据和能维护数据合法性的行为放在一起。封装不是“所有字段都写 private”这么简单,而是让对象负责守住自己的不变量。
我们做一个最小 StockItem:名称不能空,库存不能为负,出库不能超过当前数量。这个对象还不涉及数据库和 Spring,只用来练习构造时建立状态、方法调用时改变状态、查询方法只读状态。
2. 最小可运行示例:让对象自己拒绝非法变化
public class StockItemDemo {
public static void main(String[] args) {
StockItem item = new StockItem("机械键盘", 5);
System.out.println(item.reserve(2));
System.out.println("剩余=" + item.available());
System.out.println(item.reserve(4));
}
static final class StockItem {
private final String name;
private int quantity;
StockItem(String name, int quantity) {
if (name == null || name.isBlank() || quantity < 0) {
throw new IllegalArgumentException("商品状态不合法");
}
this.name = name;
this.quantity = quantity;
}
boolean reserve(int amount) {
if (amount <= 0 || amount > quantity) return false;
quantity -= amount;
return true;
}
int available() { return quantity; }
String name() { return name; }
}
}
构造器只允许对象以合法初始状态出生。reserve 的检查和扣减在同一个对象方法里,调用方不用复制规则。这里返回 boolean 足够演示;真实订单会返回带原因的结果,避免调用方只知道“失败”却不知道是数量不足还是请求无效。
3. 调用链与对象变化
new StockItem 先在堆中申请对象空间,再执行构造器,this 指向正在初始化的对象。name 引用指向传入的字符串,quantity 保存基本数值。构造器正常返回后,item 变量才拿到该对象的引用;若校验抛异常,对象不会以可用引用交给调用方。
调用 item.reserve(2) 时,方法读取当前字段 5,检查通过后把同一对象的 quantity 改成 3,并返回 true。第二次调用读取的是已经改变的 3,而不是构造器时的快照,因此返回 false。name() 只读,quantity 没有公开 setter,外部无法绕过规则。
4. 为什么这样设计
private 是编译器层面的访问边界,final 表示引用初始化后不再改指向;它们都不是线程安全的保证。封装的核心是减少状态可写入口,把检查和写入放在同一处。若把 getter/setter 全量生成,实际上只是把字段公开成了方法,仍需问 setter 是否允许存在。
对象方法表达“对这个对象做什么”,静态工具方法表达“给定输入计算什么”。前者可以维护不变量,后者更适合无状态转换。构造器参数也不是越多越好,字段多时可以用工厂方法或构建器,但必须继续保证最终对象合法。
5. 项目落点:领域对象、DTO 和持久化对象分开
博客系统中的 Article 可以守住标题、状态和发布时间规则,ArticleCreateRequest 只表达 HTTP 输入,ArticleRecord 负责和数据库列对应。不要让 Controller 直接改数据库对象的所有字段;Service 应明确哪些动作会改变状态,例如 publish()、archive(),而不是 setStatus(1) 到处散落。
练习:给 StockItem 增加 restock(int amount) 和 release(int amount),写出库存变化表。再问自己:名称是否允许修改?如果允许,规则放在哪个方法?用例先写清楚,类的字段和方法自然会收敛。
6. 易错排查
- 构造器参数校验遗漏:对象可能一创建就违反业务规则,先在入口拒绝而不是等数据库报错。
- 暴露可变集合:
getTags()直接返回内部 List 会让外部绕过规则,考虑不可变副本或只读视图。 - 用
final误认为对象不可变:final 引用指向的对象仍可能被修改;不可变需要状态本身不可变。 - 复制对象后状态不同步:明确是共享同一引用、浅拷贝还是深拷贝,不能靠变量名猜。
7. 一页复习
类把状态和维护状态的行为绑在一起;构造器建立初始不变量,公开方法定义合法变化,private 关闭绕过规则的入口。看一个对象时,先问“谁拥有这条规则、谁有权改变它”,再决定字段、方法和返回值。这个问题会直接连接到 Service、Entity 和缓存对象的设计。
可以把 StockItem 的状态写成一张小表:构造输入 ("机械键盘",5) 得到合法对象;reserve(2) 前是 5,检查通过后变成 3 并返回 true;reserve(4) 前是 3,检查失败后仍是 3 并返回 false;restock(0) 应拒绝且不产生变化。状态不变是失败分支的重要输出,不能只断言返回值。
封装还包括返回集合和时间的处理。如果对象内部有标签列表,getTags() 直接返回内部 ArrayList,外部可以绕过 addTag 的去重和长度限制;更安全的做法是返回 List.copyOf(tags)。如果对象保存 LocalDateTime,要说明它是创建时间快照还是可被外部时钟改变的引用。字段 private 只是起点,真正的边界是所有合法变化都从命名方法进入。
在博客项目中,Article 领域类可以放在 domain/article,ArticleCreateRequest 放在 web dto,数据库记录放在 persistence record。Controller 不能拿请求中的 status 直接调用 setter,发布应该调用 article.publish(actor, now),由对象或 Service 检查当前状态。这样的文件落点让审查者能迅速看到规则拥有者。
排查对象问题时先打印 id、状态和引用身份,再看字段内容;如果复制对象后修改一边另一边也变,查的是浅拷贝还是共享可变集合;如果构造器异常,确认调用方没有把半初始化对象保存到缓存;如果并发下数量越界,记住封装不等于原子性,还要把更新交给事务或锁。练习可以为每个变化写“前状态—输入—后状态—返回”四列测试。
对象的生命周期可以用四个节点描述:构造前只有输入参数,构造器校验后才得到可用引用,业务方法改变受保护状态,离开使用范围后由垃圾回收处理。垃圾回收不会修复非法状态,也不会替你关闭数据库或文件资源,所以对象不变量与外部资源生命周期必须分开设计。
如果库存对象要支持并发,reserve 的检查和扣减必须在同一事务或锁内;如果需要审计,方法可以返回包含 before/after 的结果给 Service,由 Service 写日志。不要在领域对象里直接 println 或调用 Redis,否则封装从“保护规则”退化成“把依赖藏在类里”。
排查时可先构造最小对象并调用一条方法,确认错误在构造、状态迁移还是序列化;若 JSON 显示了不该有的字段,查 DTO 是否误用了 Entity;若 getter 返回的数据被外部改变,查可变集合和浅拷贝;若不同请求互相影响,查单例 Bean/静态字段是否持有实例状态。练习是把 StockItem 变成不可变快照加显式命令结果。
对象落地后再检查序列化和并发,不要把“构造器通过”当成全部正确。文章、库存、订单这类对象都应能用一组前状态和后状态测试说明每次命令的影响,字段越少被外部直接写,规则越集中。
验证清单:构造 StockItem("键盘", 5) 后记录库存,分别执行成功扣减、超量扣减、补货和非法数量,确认失败命令不改变状态;再从 getTags() 取得集合并尝试修改,确认对象仍能守住自己的约束。若 JSON 需要展示字段,使用 DTO 做一次显式转换并检查 Entity 的内部字段没有被直接暴露。复盘时为每个公开方法写“允许谁调用、改变哪个状态、失败后状态是否相同”,这张表就是封装边界。
进阶附录:记录类与不可变快照
JDK 16 以后可以用 record 表达主要用于携带数据的不可变对象,例如 record PageRequest(int page, int size) {},但记录组件仍可能指向可变 List。记录适合 DTO 和结果值,不会自动替代需要复杂行为和生命周期的领域类。使用前仍要在紧凑构造器中校验边界。
本课按「Java 21 面向对象基础、封装与对象生命周期」的学习范围组织,正文与示例均为本站原创整理。