第11章 重构 API(Refactoring APIs)
一、导读
1.1 本章学习目标
通过本章的学习,你将能够:
理解API设计的重要性,认识到API是模块之间、系统之间的契约
掌握查询函数和修改函数分离(Separate Query from Modifier)的原则,避免副作用带来的问题
学会函数参数化(Parameterize Function),用统一的函数替代多个相似函数
掌握标记参数移除(Remove Flag Argument)的技术,提高函数调用的清晰度
学会保持对象完整(Preserve Whole Object),避免传递零散的字段
掌握以参数取代全局变量(Replace Global Variable with Parameter),减少隐式依赖
理解引入参数对象(Introduce Parameter Object)的价值,简化函数签名
能够将多个函数组合成类(Combine Functions into Class)或变换(Combine Functions into Transform)
掌握隐藏委托关系(Hide Delegate)和移除中间人(Remove Middle Man)的权衡
能够在实际项目中设计和重构出清晰、易用的API
1.2 为什么本章如此重要
API(应用程序接口)是软件系统中不同部分之间交互的桥梁。无论是库函数、类方法、模块导出,还是Web服务接口,API的设计质量直接影响代码的可读性、可维护性和可扩展性。
本章的重要性体现在以下几个方面:
首先,好的API是自文档化的。调用者无需阅读实现代码,仅凭函数名和参数就能理解如何使用。这大大降低了学习和使用成本。
其次,API是变化的边界。当内部实现需要改变时,良好的API可以保持不变,从而减少对调用者的影响。这使得重构和演进成为可能。
第三,API设计反映了系统的抽象质量。混乱的API往往意味着底层设计的缺陷;清晰的API则表明良好的抽象和职责划分。
第四,在团队协作中,API是沟通的媒介。前端开发者通过后端API了解可用的功能;库的使用者通过API了解库的能力。好的API可以减少沟通成本,提高协作效率。
第五,随着微服务和分布式架构的流行,API的重要性进一步提升。RESTful API、GraphQL、gRPC等接口设计成为独立的专业领域。
第六,JavaScript/TypeScript生态系统中的npm包数量庞大,API设计的质量直接影响包的受欢迎程度。学习如何设计好的API对于开源贡献和商业开发都至关重要。
最后,API重构与其他重构不同,因为它涉及外部契约。改变公共API需要考虑向后兼容性、版本管理等问题,这需要特殊的技巧和谨慎。
对于任何希望编写高质量代码的开发者来说,本章都是必读的。它不仅告诉你如何设计API,更告诉你如何在变化中保持API的稳定性。
1.3 核心问题引导
在阅读本章之前,请思考以下问题:
你如何判断一个API设计得好还是不好?有什么具体的标准吗?
当一个函数既有返回值又修改外部状态时,会有什么问题?
布尔参数总是坏味道吗?在什么情况下使用布尔参数是合理的?
传递整个对象和传递对象的字段各有什么利弊?
全局变量为什么被认为是有害的?在现代应用中,有没有合理的全局状态使用场景?
什么时候应该将一组函数组织成类?什么时候应该保持为独立函数?
如何处理API的向后兼容性?什么时候可以打破兼容性?
TypeScript的类型系统如何帮助API设计?
带着这些问题阅读本章,你会对API重构有更深刻的理解。
二、核心概念详解
2.1 API设计的原则
在设计或重构API时,以下原则可以作为指导:
1. 单一职责: 每个函数应该只做一件事,并做好这件事。
2. 最小惊讶原则: API的行为应该符合调用者的直觉,不应该有隐藏的副作用。
3. 一致性: 相似的操作用相似的API表达;命名风格保持一致。
4. 完整性: API应该提供完成常见任务所需的全部功能,但不提供多余的功能。
5. 简洁性: 尽可能少的参数、尽可能简单的返回值。
6. 明确的错误处理: 出错时提供清晰的错误信息,而不是静默失败。
7. 文档化: 即使API是自解释的,也应该提供必要的文档说明用途和约束。
2.2 查询函数和修改函数分离(Separate Query from Modifier)
查询函数返回数据但不改变系统状态;修改函数改变状态但不返回数据。将两者分离可以避免副作用带来的问题。
动机:
- 同时查询和修改的函数可能导致意外的副作用
- 调用者可能只想要查询,却意外触发了修改
- 分离后可以独立测试查询和修改逻辑
识别机会:
// Before - 混合查询和修改
function getTotalAndLog(items) {
const total = items.reduce((sum, item) => sum + item.price, 0);
console.log(`Total calculated: ${total}`); // 副作用
return total;
}
// After - 分离
function getTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
function logTotal(total) {
console.log(`Total calculated: ${total}`);
}
// 调用者可以选择是否记录
const total = getTotal(items);
logTotal(total);机制:
识别副作用: 找出函数中除了返回值之外的操作(如日志、数据库写入、状态修改)。
创建独立的修改函数: 将副作用部分提取为独立函数。
保留纯查询函数: 原函数只负责返回数据。
更新调用者: 根据需要调用查询函数、修改函数或两者。
测试: 确保行为不变。
命令-查询分离原则(CQS):
Bertrand Meyer提出的CQS原则:每个函数要么是查询(返回数据),要么是命令(改变状态),但不能同时是两者。
例外情况:
有些场景下混合是合理的:
- 从队列中弹出元素(既返回又修改)
- 单例的懒初始化
- 性能优化(如缓存填充)
在这些情况下,应该在文档中明确说明副作用。
2.3 函数参数化(Parameterize Function)
函数参数化是将多个相似的函数合并为一个带参数的函数。
动机:
- 多个函数结构相同,只有少量值不同
- 合并后可以减少重复代码
- 添加新变体只需添加新参数值
识别机会:
// Before - 多个相似函数
function tenPercentRaise(employee) {
employee.salary *= 1.1;
}
function fivePercentRaise(employee) {
employee.salary *= 1.05;
}
// After - 参数化
function raiseSalary(employee, factor) {
employee.salary *= factor;
}
// 调用
raiseSalary(employee, 1.1); // 10% raise
raiseSalary(employee, 1.05); // 5% raise机制:
识别相似函数: 找出结构相同、只有常量不同的函数。
创建统一函数: 将差异部分作为参数。
替换调用: 将所有调用改为新函数的调用。
删除原函数: 确认所有调用都更新后,删除原函数。
测试: 确保行为不变。
注意事项:
- 参数应该有有意义的默认值
- 如果参数过多,考虑引入参数对象
- 不要过度参数化,保持函数的专注
2.4 标记参数移除(Remove Flag Argument)
标记参数是根据参数值决定执行不同逻辑的参数(通常是布尔值)。移除标记参数可以提高代码的清晰度。
动机:
- 标记参数使函数承担多种责任
- 调用时需要理解标记的含义
- 分离后的函数名可以更清晰地表达意图
识别机会:
// Before - 标记参数
function setDimension(name, value) {
if (name === 'height') {
this._height = value;
return;
}
if (name === 'width') {
this._width = value;
return;
}
}
// After - 分离函数
function setHeight(value) {
this._height = value;
}
function setWidth(value) {
this._width = value;
}机制:
识别标记参数: 找出根据参数值执行不同逻辑的函数。
为每个值创建独立函数: 给每个新函数一个清晰的名称。
替换调用: 将所有调用改为新函数的调用。
删除原函数: 确认所有调用都更新后,删除原函数。
测试: 确保行为不变。
何时保留标记参数:
- 标记值是连续的(如数字范围)而非离散的
- 有大量可能的值,为每个值创建函数不现实
- 标记值是配置选项,未来可能扩展
2.5 保持对象完整(Preserve Whole Object)
保持对象完整是传递整个对象而非其零散字段的做法。
动机:
- 传递多个相关字段使函数签名冗长
- 当对象添加新字段时,需要修改多处函数签名
- 传递对象可以利用对象的内部逻辑
识别机会:
// Before - 传递零散字段
function withinRange(minTemp, maxTemp, low, high) {
return low >= minTemp && high <= maxTemp;
}
// 调用
withinRange(room.minTemp, room.maxTemp, room.low, room.high);
// After - 传递对象
function withinRange(room, range) {
return room.low >= room.minTemp &&
room.high <= room.maxTemp;
}
// 或者更好的方式
class Room {
constructor(minTemp, maxTemp) {
this._minTemp = minTemp;
this._maxTemp = maxTemp;
}
withinRange(range) {
return this.low >= this._minTemp &&
this.high <= this._maxTemp;
}
}机制:
识别来自同一对象的多个参数: 找出函数签名中来自同一对象的多个字段。
改为传递对象: 将多个参数替换为对象参数。
在函数内部访问字段: 修改函数体以访问对象的字段。
更新调用者: 将调用改为传递对象。
测试: 确保行为不变。
注意事项:
- 如果函数只需要对象的少数字段,传递整个对象可能引入不必要的依赖
- 考虑使用接口或类型来明确需要的字段
- 在TypeScript中,可以使用Pick类型指定需要的字段
2.6 以参数取代全局变量(Replace Global Variable with Parameter)
以参数取代全局变量是将函数对全局变量的依赖改为通过参数传入。
动机:
- 全局变量导致隐式依赖,难以追踪
- 测试时需要设置全局状态
- 多线程/并发环境下全局变量不安全
识别机会:
// Before - 依赖全局变量
let serverUrl = 'https://api.example.com';
function fetchUser(userId) {
return fetch(`${serverUrl}/users/${userId}`);
}
// After - 通过参数传入
function fetchUser(userId, baseUrl) {
return fetch(`${baseUrl}/users/${userId}`);
}
// 调用
fetchUser(123, config.serverUrl);机制:
识别全局依赖: 找出函数中使用的全局变量。
添加参数: 将全局变量作为参数添加到函数签名中。
替换引用: 将函数体内的全局变量引用改为参数引用。
更新调用者: 在所有调用点传入相应的值。
删除全局变量: 如果不再需要,删除全局变量。
测试: 确保行为不变。
现代应用中的全局状态:
在现代应用中,完全消除全局状态可能不现实。常见的替代方案:
- 依赖注入容器
- React的Context API
- Redux/Zustand等状态管理库
关键是使依赖显式化,便于管理和测试。
2.7 引入参数对象(Introduce Parameter Object)
引入参数对象是将多个相关参数组合成一个对象。
动机:
- 函数参数过多,难以理解和调用
- 某些参数经常一起出现,形成自然的组合
- 希望通过对象封装参数的验证和行为
识别机会:
// Before - 多个参数
function createOrder(customerId, customerName, customerEmail,
productId, productName, productPrice,
quantity, shippingAddress) {
// ...
}
// After - 参数对象
class OrderRequest {
constructor({ customerId, customerName, customerEmail,
productId, productName, productPrice,
quantity, shippingAddress }) {
this.customerId = customerId;
this.customerName = customerName;
this.customerEmail = customerEmail;
this.productId = productId;
this.productName = productName;
this.productPrice = productPrice;
this.quantity = quantity;
this.shippingAddress = shippingAddress;
}
validate() {
if (!this.customerId) throw new Error('Customer ID required');
if (this.quantity <= 0) throw new Error('Invalid quantity');
// ...
}
get subtotal() {
return this.productPrice * this.quantity;
}
}
function createOrder(request) {
request.validate();
// ...
}机制:
识别相关参数组: 找出经常一起出现的参数。
创建参数类: 为参数组创建一个类或接口。
替换参数: 将多个参数替换为单个参数对象。
迁移验证逻辑: 将参数验证移到参数类中。
更新调用者: 将调用改为创建并传递参数对象。
测试: 确保行为不变。
TypeScript中的实现:
interface OrderRequest {
customerId: string;
customerName: string;
customerEmail: string;
productId: string;
productName: string;
productPrice: number;
quantity: number;
shippingAddress: Address;
}
function createOrder(request: OrderRequest): Order {
// ...
}2.8 将多个函数组合成类(Combine Functions into Class)
将多个函数组合成类是将操作同一数据结构的函数组织到一个类中。
动机:
- 多个函数共享相同的数据上下文
- 函数之间有逻辑关联
- 希望通过封装提供更好的API
识别机会:
// Before - 独立函数
function baseCharge(units) {
if (units < 0) return 0;
if (units <= 100) return units * 0.5;
if (units <= 200) return 50 + (units - 100) * 0.6;
return 110 + (units - 200) * 0.7;
}
function taxCharge(base) {
return base * 0.1;
}
function totalCharge(units) {
const base = baseCharge(units);
return base + taxCharge(base);
}
// After - 组合成类
class ElectricityBill {
constructor(units) {
this._units = units;
}
get baseCharge() {
const units = Math.max(this._units, 0);
if (units <= 100) return units * 0.5;
if (units <= 200) return 50 + (units - 100) * 0.6;
return 110 + (units - 200) * 0.7;
}
get tax() {
return this.baseCharge * 0.1;
}
get total() {
return this.baseCharge + this.tax;
}
}机制:
识别相关函数: 找出操作同一数据或密切相关的函数。
创建类: 为这些函数创建一个类。
迁移数据: 将共享数据作为类的字段。
迁移函数: 将函数迁移为类的方法。
更新调用者: 将调用改为创建实例并调用方法。
测试: 确保行为不变。
2.9 将多个函数组合成变换(Combine Functions into Transform)
将多个函数组合成变换是创建一个统一的变换函数,接收输入数据并返回增强后的数据。
动机:
- 多个函数分别向数据添加不同的字段
- 调用者需要依次调用多个函数
- 希望提供统一的入口点
识别机会:
// Before - 多个独立变换
function calculateBaseCharge(reading) {
return { ...reading, baseCharge: /* calculation */ };
}
function calculateTax(reading) {
return { ...reading, tax: reading.baseCharge * 0.1 };
}
// 调用者需要依次调用
let reading = calculateBaseCharge(rawReading);
reading = calculateTax(reading);
// After - 统一变换
function enrichReading(reading) {
const result = { ...reading };
result.baseCharge = /* calculation */;
result.tax = result.baseCharge * 0.1;
return result;
}
// 调用者只需一次调用
const enriched = enrichReading(rawReading);机制:
识别相关变换: 找出对同一数据进行增强的多个函数。
创建统一变换函数: 创建一个函数执行所有变换。
迁移逻辑: 将各个变换的逻辑移到统一函数中。
更新调用者: 将多次调用改为一次调用。
测试: 确保行为不变。
与类的比较:
- 变换: 适合函数式风格,数据不可变
- 类: 适合面向对象风格,封装行为和状态
选择取决于项目风格和团队偏好。
2.10 隐藏委托关系(Hide Delegate)和移除中间人(Remove Middle Man)
这两个重构在第7章已详细介绍,这里简要回顾:
隐藏委托: 当客户端通过委托链访问对象时(a.b.c.doSomething()),在服务类中创建方法隐藏委托关系。
移除中间人: 当类只是简单委托给另一个对象而没有添加价值时,让客户端直接访问委托对象。
权衡: 根据委托的稳定性和中间人的价值来决定是隐藏还是移除。
三、程序员的视角
3.1 在实际项目中的应用
场景一:API客户端的重构
在一个HTTP客户端库中,原本有多个相似的方法:
// Before
api.getUsers();
api.getAdmins();
api.getModerators();
// After - 参数化
api.getUsers({ role: 'user' });
api.getUsers({ role: 'admin' });
api.getUsers({ role: 'moderator' });场景二:表单验证的参数对象
将多个验证参数组合成对象:
class FormValidator {
validate(form) {
const errors = {};
if (!form.email) errors.email = 'Required';
if (!form.password || form.password.length < 8) {
errors.password = 'Min 8 characters';
}
return errors;
}
}场景三:React组件的API设计
// 好的组件API
<Button
variant="primary"
size="medium"
onClick={handleClick}
disabled={isLoading}
>
Submit
</Button>3.2 常见陷阱与误区
陷阱一:过度参数化
函数参数过多,难以理解和使用。
教训: 超过3-4个参数时考虑引入参数对象。
陷阱二:隐藏必要的复杂性
过度简化API,导致调用者无法控制关键行为。
教训: 在简洁性和灵活性之间找到平衡。
陷阱三:忽视版本管理
改变公共API而不考虑向后兼容。
教训: 使用语义化版本,废弃而非立即删除。
3.3 与其他层级的联系
与设计模式的联系:
- Parameterize Function → Strategy Pattern
- Combine Functions into Class → Facade Pattern
- Hide Delegate → Adapter Pattern
与架构的联系:
- API网关作为服务边界的封装
- RESTful资源设计
- GraphQL schema设计
与TypeScript的联系:
- 接口定义API契约
- 泛型支持参数化
- 类型守卫确保安全
四、代码实战
4.1 示例一:完整的API重构
// Before - 混乱的API
function process(data, type, isAsync, callback) {
if (type === 'json') {
const parsed = JSON.parse(data);
if (isAsync) {
setTimeout(() => callback(null, parsed), 0);
} else {
return parsed;
}
} else if (type === 'csv') {
// CSV解析逻辑
}
}
// After - 清晰的API
class DataProcessor {
constructor(options = {}) {
this._format = options.format || 'json';
this._async = options.async || false;
}
process(data) {
const parsed = this._parse(data);
if (this._async) {
return Promise.resolve(parsed);
}
return parsed;
}
_parse(data) {
switch (this._format) {
case 'json':
return JSON.parse(data);
case 'csv':
return this._parseCsv(data);
default:
throw new Error(`Unsupported format: ${this._format}`);
}
}
_parseCsv(data) {
// CSV解析逻辑
}
}
// 使用
const processor = new DataProcessor({ format: 'json', async: true });
processor.process(data).then(result => {
console.log(result);
});4.2 示例二:TypeScript API设计
interface ApiConfig {
baseUrl: string;
timeout?: number;
retries?: number;
}
class ApiClient {
private config: Required<ApiConfig>;
constructor(config: ApiConfig) {
this.config = {
baseUrl: config.baseUrl,
timeout: config.timeout ?? 5000,
retries: config.retries ?? 3
};
}
async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.config.baseUrl}${path}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new ApiError(response.status, response.statusText);
}
return response.json();
}
async post<T>(path: string, data: unknown): Promise<T> {
// ...
}
}
// 使用
const api = new ApiClient({ baseUrl: 'https://api.example.com' });
const users = await api.get<User[]>('/users');五、深入思考
5.1 历史背景
API设计的理念经历了从过程式到面向对象再到函数式的演变。
早期编程中,API主要是函数库。面向对象引入了类和对象的概念,API变成了消息传递。函数式编程带来了高阶函数和不可变数据的思想。
Web API的发展也经历了类似历程:从SOAP的复杂协议到REST的简洁资源模型,再到GraphQL的灵活查询。
5.2 前沿方向
API优先设计:
- OpenAPI/Swagger规范
- 先定义API再实现
- 自动生成文档和客户端
类型安全的API:
- tRPC等端到端类型安全
- GraphQL Code Generator
- Protocol Buffers
AI辅助API设计:
- 基于使用模式的API建议
- 自动检测API不一致
- 智能文档生成
5.3 延伸阅读
- 《API Design Patterns》by JJ Geewey
- 《Building Microservices》by Sam Newman - API在微服务中的作用
- TypeScript Handbook - 类型系统与API设计
六、实验笔记
实验 1:重构一个现有API
目标: 选择一个项目中的模块,对其API进行重构。
步骤:
分析当前API的问题
设计改进方案
逐步实施重构
更新所有调用者
验证功能正常
记录:
- 当前API的问题:
- 改进方案:
- 遇到的挑战:
- 改善效果:
实验 2:设计一个新API
目标: 为一个新功能设计清晰的API。
步骤:
确定功能需求
设计API接口
征求反馈
实现并测试
记录:
- 功能描述:
- API设计:
- 收到的反馈:
- 最终方案:
七、本章小结
本章介绍了重构API的各种技术和最佳实践。
查询函数和修改函数分离避免了副作用带来的问题,使代码更可预测。函数参数化合并了相似的函数,减少了重复。标记参数移除提高了函数调用的清晰度。
保持对象完整简化了函数签名,使代码更易扩展。以参数取代全局变量消除了隐式依赖,使测试更容易。引入参数对象将相关参数组合在一起,提高了可读性。
将多个函数组合成类或变换提供了统一的入口点,简化了调用者的工作。隐藏委托和移除中间人需要在封装和简洁之间权衡。
好的API设计是一门艺术,需要在简洁性、灵活性、一致性之间找到平衡。通过持续的重构和改进,可以使API越来越优雅。
八、习题
理解题: 解释查询函数和修改函数分离的价值。什么情况下可以违反这个原则?
实践题: 将以下函数参数化:
function sendEmail(to, subject, body) { /* ... */ }
function sendSMS(to, message) { /* ... */ }设计题: 为一个分页API设计参数对象,支持页码、每页数量、排序、过滤等功能。
分析题: 比较将函数组合成类和组合成变换的优缺点。
综合题: 重构以下API,应用本章学到的技术:
let apiUrl = 'https://api.example.com';
function getData(id, useCache) { /* ... */ }
function postData(data, isDraft) { /* ... */ }讨论题: "公共API一旦发布就不能改变",你同意吗?如何管理API的演进?
挑战题: 设计一个类型安全的API客户端,支持自动重试、超时、错误处理等功能。