重构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. 函数参数化:消灭重复函数
你想想看,你的代码库里有没有这种函数:
getUserByNamegetUserByEmailgetUserByPhone
三个函数,逻辑几乎一样,只是查询条件不同。这就是典型的「参数化」机会。
我个人习惯是:看到第三个重复函数时,就该考虑参数化了。两个还能忍,三个就是信号。
重构前:三个重复函数
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 类有 setStatus、setAmount、setPaidTime 这些 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. 知识体系图
下面这张图展示了这三个重构手法的关系和应用场景:
5. 避坑指南
这三个手法看起来简单,实际落地时有些坑。我踩过,分享给你:
- 查询与修改分离:别走极端。有些操作本身就是「查询并缓存」,这种不算副作用。关键是「对外部可见的副作用」才需要分离。
- 函数参数化:参数太多怎么办?我建议不超过3个。超过3个,考虑用参数对象封装。否则调用方会疯掉。
- 移除设值函数:别一刀切。有些纯数据对象(DTO、VO)保留 setter 是合理的。关键是业务实体类要谨慎暴露 setter。
我曾经在一个项目中把所有 setter 都删了,结果发现序列化框架(比如 Jackson)需要无参构造器和 setter 才能反序列化。后来我用了建造者模式 + @JsonCreator 来解决。所以,移除 setter 前,先确认你的框架是否支持。
6. 小结
今天这三个手法,说白了就是一件事:让API的意图更明确。
- 查询函数就是查询,别偷偷改东西
- 重复函数用参数化合并,别复制粘贴
- 对象的状态由行为方法管理,别暴露 setter 让外部乱改
做到这三点,你的API会干净很多。调用者看着函数名就知道该不该调用,不用再翻源码确认有没有副作用。这就是好的API设计。
嗯,今天就到这儿。下一章咱们继续聊API重构的其他手法,到时候见。
公众号:蓝海资料掘金营,微信deep3321