57、代码坏味道:纯数据类 - 实战封装记录与搬移行为
纯数据类,说白了就是只有 getter/setter 的类。里面塞满了数据,行为却少得可怜。我见过不少项目,这种类能占到一半以上。你想想看,数据和行为分家,这代码能好到哪去?
今天我们就来聊聊怎么收拾这种坏味道。核心思路就两个:封装记录和搬移行为。
什么是纯数据类?
先看个典型例子。我在一个电商项目里见过这样的代码:
public class Order {
private String orderId;
private String customerName;
private List<OrderItem> items;
private double totalAmount;
private String status;
// 一堆 getter/setter
public String getOrderId() { return orderId; }
public void setOrderId(String orderId) { this.orderId = orderId; }
// ... 省略其他 getter/setter
}
这个类除了存数据,啥也不干。计算总价、验证订单状态、生成摘要——这些逻辑全散落在各个 Service 里。嗯,这就是典型的纯数据类。
纯数据类的特征:
- 字段多,方法少(基本都是 getter/setter)
- 业务逻辑散落在外部类中
- 修改数据时,没有统一的校验入口
- 数据和行为分离,违背了封装原则
为什么要重构纯数据类?
我个人习惯,看到纯数据类就手痒。为什么?因为这种代码有三大隐患:
- 数据一致性难保证——谁都能直接 set 字段,校验逻辑分散在各处
- 可读性差——想看一个订单的完整行为,得翻遍所有 Service
- 维护成本高——改一个字段,可能影响几十个地方
我在项目中遇到过最夸张的情况:一个 Order 类,有 30 多个字段,但只有一个 getter/setter 方法。所有业务逻辑散落在 10 多个 Service 里。改个字段名,改了整整两天。
重构手法一:封装记录
封装记录,就是把散落的数据访问逻辑收拢到类内部。说白了,让类自己管好自己的数据。
看个例子。假设我们有个用户类:
// 重构前
public class User {
private String name;
private String email;
private String phone;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
// ... 其他 getter/setter
}
// 外部使用
public class UserService {
public void registerUser(User user) {
if (user.getName() == null || user.getName().isEmpty()) {
throw new IllegalArgumentException("用户名不能为空");
}
if (user.getEmail() == null || !user.getEmail().contains("@")) {
throw new IllegalArgumentException("邮箱格式不正确");
}
// ... 保存用户
}
}
重构后,把校验逻辑搬进 User 类:
// 重构后
public class User {
private String name;
private String email;
private String phone;
public User(String name, String email, String phone) {
validateName(name);
validateEmail(email);
this.name = name;
this.email = email;
this.phone = phone;
}
private void validateName(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("用户名不能为空");
}
}
private void validateEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("邮箱格式不正确");
}
}
// 只保留必要的 getter,去掉 setter
public String getName() { return name; }
public String getEmail() { return email; }
public String getPhone() { return phone; }
}
小技巧:去掉 setter,改用构造方法或工厂方法。这样对象一旦创建,数据就不会被随意修改。我习惯用构造方法做校验,确保对象始终处于有效状态。
重构手法二:搬移行为
搬移行为,就是把散落在各处的业务逻辑,搬回到数据类中。怎么判断哪些行为该搬?有个简单原则:如果一个方法主要操作某个类的数据,那它就应该属于那个类。
看个订单的例子:
// 重构前
public class OrderService {
public double calculateTotal(Order order) {
double total = 0;
for (OrderItem item : order.getItems()) {
total += item.getPrice() * item.getQuantity();
}
return total;
}
public String generateOrderSummary(Order order) {
double total = calculateTotal(order);
return "订单号:" + order.getOrderId() +
",总金额:" + total;
}
}
重构后,把方法搬进 Order 类:
// 重构后
public class Order {
private String orderId;
private List<OrderItem> items;
// ... 其他字段
public double calculateTotal() {
double total = 0;
for (OrderItem item : items) {
total += item.getPrice() * item.getQuantity();
}
return total;
}
public String generateSummary() {
return "订单号:" + orderId +
",总金额:" + calculateTotal();
}
}
注意:搬移行为时,不要一股脑全搬。如果一个方法依赖多个类的数据,或者需要外部资源(如数据库、网络),那它可能更适合留在 Service 层。我曾经犯过这个错,把依赖数据库的方法搬进实体类,结果导致实体类变得臃肿且难以测试。
实战案例:重构用户管理系统
来看一个完整的实战案例。假设我们有这样一个用户管理模块:
// 纯数据类
public class UserProfile {
private String userId;
private String nickname;
private int age;
private String email;
private String phone;
private String address;
// ... 20 多个 getter/setter
}
// Service 类
public class UserProfileService {
public boolean isAdult(UserProfile profile) {
return profile.getAge() >= 18;
}
public String getDisplayName(UserProfile profile) {
if (profile.getNickname() != null) {
return profile.getNickname();
}
return profile.getUserId();
}
public boolean isValidEmail(UserProfile profile) {
return profile.getEmail() != null &&
profile.getEmail().contains("@");
}
}
重构步骤:
- 封装记录——把校验逻辑搬进 UserProfile
- 搬移行为——把 isAdult、getDisplayName 等方法搬进 UserProfile
- 隐藏数据——去掉不必要的 setter,用构造方法初始化
重构后的代码:
public class UserProfile {
private String userId;
private String nickname;
private int age;
private String email;
private String phone;
private String address;
public UserProfile(String userId, String nickname,
int age, String email) {
validateAge(age);
validateEmail(email);
this.userId = userId;
this.nickname = nickname;
this.age = age;
this.email = email;
}
private void validateAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄不合法");
}
}
private void validateEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("邮箱格式不正确");
}
}
public boolean isAdult() {
return age >= 18;
}
public String getDisplayName() {
return nickname != null ? nickname : userId;
}
public boolean isValidEmail() {
return email != null && email.contains("@");
}
// 只保留必要的 getter
public String getUserId() { return userId; }
public String getNickname() { return nickname; }
public int getAge() { return age; }
public String getEmail() { return email; }
}
重构后的效果对比
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 数据封装性 | 差,数据随意修改 | 好,构造时校验,无 setter |
| 行为内聚性 | 差,逻辑散落在 Service | 好,行为集中在类内部 |
| 可测试性 | 需要 mock Service | 直接测试实体类 |
| 维护成本 | 高,改字段影响多处 | 低,改字段只影响本类 |
核心知识体系
下面这张图展示了纯数据类重构的核心逻辑:
避坑指南
我曾经在重构一个老项目时,把所有的纯数据类都改了。结果发现有些类被序列化框架(如 Jackson、Gson)使用,去掉 setter 后反序列化失败了。嗯,这里要注意:
- 序列化兼容——如果类被序列化框架使用,保留 setter 但标记为 @Deprecated
- ORM 框架——JPA、MyBatis 等框架可能需要无参构造和 setter,可以用 @Entity 注解配合
- 不要过度重构——如果一个类只是简单的数据传输对象(DTO),保持现状也没问题
我的建议:重构纯数据类时,先从最核心的领域模型开始。DTO、VO 这类纯粹用于数据传输的类,可以暂时不动。先把业务逻辑密集的实体类改好,效果最明显。
总结一下:纯数据类的重构,核心就是让数据和行为重新在一起。封装记录让数据更安全,搬移行为让逻辑更内聚。改完之后你会发现,代码好读多了,改起来也放心多了。