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)
  • 业务逻辑散落在外部类中
  • 修改数据时,没有统一的校验入口
  • 数据和行为分离,违背了封装原则

为什么要重构纯数据类?

我个人习惯,看到纯数据类就手痒。为什么?因为这种代码有三大隐患:

  1. 数据一致性难保证——谁都能直接 set 字段,校验逻辑分散在各处
  2. 可读性差——想看一个订单的完整行为,得翻遍所有 Service
  3. 维护成本高——改一个字段,可能影响几十个地方

我在项目中遇到过最夸张的情况:一个 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("@");
    }
}

重构步骤:

  1. 封装记录——把校验逻辑搬进 UserProfile
  2. 搬移行为——把 isAdult、getDisplayName 等方法搬进 UserProfile
  3. 隐藏数据——去掉不必要的 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 直接测试实体类
维护成本 高,改字段影响多处 低,改字段只影响本类

核心知识体系

下面这张图展示了纯数据类重构的核心逻辑:

纯数据类重构核心逻辑 纯数据类 数据与行为分离 重构手法 封装记录 + 搬移行为 封装良好的类 数据与行为统一 步骤一:封装记录 • 构造方法校验 • 去掉不必要的 setter 步骤二:搬移行为 • 识别操作本类数据的方法 • 搬移到类内部 注意事项 不要搬移依赖外部资源的方法 | 保持类职责单一

避坑指南

我曾经在重构一个老项目时,把所有的纯数据类都改了。结果发现有些类被序列化框架(如 Jackson、Gson)使用,去掉 setter 后反序列化失败了。嗯,这里要注意:

  • 序列化兼容——如果类被序列化框架使用,保留 setter 但标记为 @Deprecated
  • ORM 框架——JPA、MyBatis 等框架可能需要无参构造和 setter,可以用 @Entity 注解配合
  • 不要过度重构——如果一个类只是简单的数据传输对象(DTO),保持现状也没问题
我的建议:重构纯数据类时,先从最核心的领域模型开始。DTO、VO 这类纯粹用于数据传输的类,可以暂时不动。先把业务逻辑密集的实体类改好,效果最明显。

总结一下:纯数据类的重构,核心就是让数据和行为重新在一起。封装记录让数据更安全,搬移行为让逻辑更内聚。改完之后你会发现,代码好读多了,改起来也放心多了。

公众号:蓝海资料掘金营,微信deep3321