重构API(一):将查询函数和修改函数分离、函数参数化、移除设值函数

好,咱们今天聊API重构。说实话,API这块儿是重构的重灾区。我见过太多代码,一个函数又查数据又改状态,调用者根本搞不清调用后会有什么副作用。今天这三个手法——查询与修改分离、函数参数化、移除设值函数——就是专门整治这种混乱的。

1. 为什么要把查询和修改分开?

先问个问题:你调用一个函数,它返回了结果,你敢不敢直接拿这个结果去用?

不敢?为什么?因为你怕它有副作用。它可能改了某个全局变量,可能写了数据库,可能发了网络请求。这种「查询+修改」混在一起的函数,我称之为「危险函数」。

我在项目中遇到过这样一个案例:一个叫 getUserAndUpdateLoginTime 的函数,名字就暴露了问题。调用者只是想拿用户信息展示,结果每次调用都更新了登录时间。后来排查线上问题,发现用户登录时间全乱了——就是因为某个定时任务调了这个函数。

所以,原则很简单:一个函数要么是查询,要么是修改,不要两者兼得

核心原则:查询函数不应有可见的副作用。修改函数不应返回数据(除非是简单的成功/失败标识)。

代码示例:分离前

// 这个函数既查询了用户,又修改了状态
public User getUserAndMarkActive(int userId) {
    User user = userRepository.findById(userId);
    user.setLastActiveTime(LocalDateTime.now());
    userRepository.save(user);
    return user;
}

代码示例:分离后

// 纯查询
public User getUser(int userId) {
    return userRepository.findById(userId);
}

// 纯修改
public void markUserActive(int userId) {
    User user = userRepository.findById(userId);
    user.setLastActiveTime(LocalDateTime.now());
    userRepository.save(user);
}

你看,调用者现在很明确:getUser 就是拿数据,不会改东西;markUserActive 就是改状态,不返回数据。职责清晰,谁都不会误用。

小技巧:如果你发现一个函数名里同时有「get」和「set/update」字样,基本就是需要拆分的信号。

2. 函数参数化:消灭重复函数

你想想看,你的代码库里有没有这种函数:

  • getUserByName
  • getUserByEmail
  • getUserByPhone

三个函数,逻辑几乎一样,只是查询条件不同。这就是典型的「参数化」机会。

我个人习惯是:看到第三个重复函数时,就该考虑参数化了。两个还能忍,三个就是信号。

重构前:三个重复函数

public User getUserByName(String name) {
    return userRepository.findByField("name", name);
}

public User getUserByEmail(String email) {
    return userRepository.findByField("email", email);
}

public User getUserByPhone(String phone) {
    return userRepository.findByField("phone", phone);
}

重构后:一个参数化函数

public User getUserByField(String fieldName, String value) {
    return userRepository.findByField(fieldName, value);
}

调用方也很清晰:getUserByField("email", "test@example.com")。当然,如果你担心字符串拼写错误,可以用枚举或者常量类来约束字段名。

注意:参数化不是万能的。如果不同查询条件导致完全不同的查询逻辑(比如一个走缓存,一个走数据库),那就别硬合并。参数化适用于「逻辑相同,仅参数不同」的场景。

3. 移除设值函数:别暴露内部状态

这个手法,说白了就是:别让外部直接修改对象的内部字段

我见过太多代码,一个对象暴露了十几个 setter,外部代码想怎么改就怎么改。结果呢?对象的状态一致性全靠调用者自觉。你想想看,这靠谱吗?

我曾经接手过一个订单系统,Order 类有 setStatussetAmountsetPaidTime 这些 setter。结果业务代码里到处是:

order.setStatus("PAID");
order.setPaidTime(LocalDateTime.now());
// 忘了设置 amount,导致对账出错

正确的做法是:提供有业务含义的行为方法,而不是暴露字段的设值函数

重构前:暴露 setter

public class Order {
    private String status;
    private BigDecimal amount;
    private LocalDateTime paidTime;

    public void setStatus(String status) { this.status = status; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public void setPaidTime(LocalDateTime paidTime) { this.paidTime = paidTime; }
}

重构后:提供行为方法

public class Order {
    private String status;
    private BigDecimal amount;
    private LocalDateTime paidTime;

    public void pay(BigDecimal amount) {
        if (this.status.equals("PAID")) {
            throw new IllegalStateException("订单已支付");
        }
        this.status = "PAID";
        this.amount = amount;
        this.paidTime = LocalDateTime.now();
    }
}

你看,pay 方法封装了支付的全部逻辑:校验状态、设置金额、记录时间。调用者只需要说「我要支付这个订单」,剩下的由对象自己搞定。

经验之谈:移除设值函数后,你会发现单元测试也变简单了。因为对象的有效状态组合变少了,你不需要测试「status=PAID但amount=null」这种非法状态——因为代码根本不允许这种状态出现。

4. 知识体系图

下面这张图展示了这三个重构手法的关系和应用场景:

API重构三大手法 API重构 查询与修改分离 函数参数化 移除设值函数 查询函数无副作用 修改函数不返回数据 消灭重复函数 逻辑相同仅参数不同 用行为方法替代setter 保证对象状态一致性 目标:API清晰、安全、易维护

5. 避坑指南

这三个手法看起来简单,实际落地时有些坑。我踩过,分享给你:

  • 查询与修改分离:别走极端。有些操作本身就是「查询并缓存」,这种不算副作用。关键是「对外部可见的副作用」才需要分离。
  • 函数参数化:参数太多怎么办?我建议不超过3个。超过3个,考虑用参数对象封装。否则调用方会疯掉。
  • 移除设值函数:别一刀切。有些纯数据对象(DTO、VO)保留 setter 是合理的。关键是业务实体类要谨慎暴露 setter。

我曾经在一个项目中把所有 setter 都删了,结果发现序列化框架(比如 Jackson)需要无参构造器和 setter 才能反序列化。后来我用了建造者模式 + @JsonCreator 来解决。所以,移除 setter 前,先确认你的框架是否支持。

6. 小结

今天这三个手法,说白了就是一件事:让API的意图更明确

  • 查询函数就是查询,别偷偷改东西
  • 重复函数用参数化合并,别复制粘贴
  • 对象的状态由行为方法管理,别暴露 setter 让外部乱改

做到这三点,你的API会干净很多。调用者看着函数名就知道该不该调用,不用再翻源码确认有没有副作用。这就是好的API设计。

嗯,今天就到这儿。下一章咱们继续聊API重构的其他手法,到时候见。


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