KISS 与 SOLID 相遇:保持 Solid 简洁

65 阅读19分钟

在穷人队列(使用 Cloudflare Workers 构建穷人队列)的最后一次 SDLC 迭代期间,我在应用 SOLID 原则时遇到了一个微妙的陷阱:误导性的抽象。

虽然 SOLID 原则旨在指导我们编写可维护的代码,但我在自己和他人的工作中发现,我们有时会因为误解了这些原则而违背其初衷。结果如何?要么是过度设计的解决方案增加了复杂性而毫无价值,要么是过度简化的解决方案牺牲了可维护性。

最初的“穷人队列”过于抽象,有三层,但两层就足够了:

队列 → 队列服务 → 持久对象队列 通过消除不必要的中间队列服务层,我创建了一个更清晰、更易于维护的架构,同时仍然忠实地遵守 SOLID 原则。

那么我们如何找到这种平衡呢?答案在于理解 SOLID 的实际要求以及我们认为它需要什么。

什么是 SOLID? SOLID 包含五项原则,可帮助我们编写可维护、灵活的代码:

单一职责:一个类应该只有一个改变的理由 开放/关闭:对扩展开放,对修改关闭 L iskov 替换:子类型必须可以替换其基类型 接口隔离:多个特定接口优于一个通用接口 依赖倒置:依赖于抽象,而不是具体 这些原则非常强大。但它们经常被误解为两个方面:构建我们不需要的抽象,或者完全避免必要的结构。让我们探索如何在遵循 SOLID 的同时保持代码的简洁性。

单一职责:关注变化 原则:每个类都应该有一个改变的理由。

误解:为每个单独的动作创建一个类或提取每个功能。

现实情况:将变化的相关操作分组。

想象一下披萨外送系统。司机负责送披萨。但“一项职责”究竟是什么?未来外送员的职责又会发生怎样的变化?

过度抽象的版本 interface IVehicleOperator { operate(): void; }

interface INavigationStrategy { navigate(destination: Address): Route; }

interface IDeliveryExecutor { execute(delivery: IDelivery): void; }

class PizzaDeliveryVehicleOperatorService implements IVehicleOperator { constructor( private navigationStrategy: INavigationStrategy, private deliveryExecutor: IDeliveryExecutor ) {}

operate(): void { // Orchestrates navigation and delivery } }

这种方法的问题:

不必要的抽象:车辆运行中不存在实际变化 认知开销:送披萨的三个界面 测试复杂性:模拟两个依赖关系只是为了测试交付逻辑 违反 YAGNI:为不存在的场景构建 欠抽象版本 class DeliveryDriver { async deliver(pizza: Pizza, address: Address): Promise { // Navigate const lat = this.geocode(address); const route = this.calculateRoute(lat);

// Drive
await this.startEngine();
await this.followRoute(route);

// Hand off
await this.knockOnDoor();
await this.getPizzaFromBag(pizza);
await this.collectPayment();

}

private geocode(address: Address): LatLng { /* ... / } private calculateRoute(coords: LatLng): Route { / ... / } private startEngine(): Promise { / ... / } private followRoute(route: Route): Promise { / ... / } private knockOnDoor(): Promise { / ... / } private getPizzaFromBag(pizza: Pizza): Promise { / ... / } private collectPayment(): Promise { / ... */ } }

这种方法的问题:

多重职责:导航、驾驶和支付处理将因不同原因而发生变化 难以测试:没有完整的交付流程就无法测试路由 难以扩展:想要添加基于 GPS 的路线规划?必须修改类 违反 SRP:此类至少有三个需要更改的原因 平衡版 interface DeliveryService { deliver(pizza: Pizza, address: Address): Promise; }

class DeliveryDriver implements DeliveryService { constructor( private navigator: (address: Address) => Route ) {}

async deliver(pizza: Pizza, address: Address): Promise { const route = this.navigator(address); await this.drive(route); await this.handOffPizza(pizza); }

private async drive(route: Route): Promise { // Driving logic that changes when vehicle mechanics change }

private async handOffPizza(pizza: Pizza): Promise { // Handoff logic that changes when delivery protocols change } }

// Simple function-based dependencies const simpleNavigator = (address: Address): Route => { return new Route(address); };

// Easy to instantiate, easy to test, easy to extend const driver = new DeliveryDriver(simpleNavigator);

为什么这样更好:

单一职责:该类只有一个改变的原因:如何执行交付 可测试:注入测试导航器,验证驾驶和切换行为 可扩展:需要 GPS 导航?注入不同的功能 简单:没有接口爆炸,只有集中的抽象 关键洞察:单一职责并不意味着“每个类一个功能”,而是“一个变更的理由”。导航算法的变更与交付执行无关,因此我们将它们分开。但驾驶和交接是交付执行职责的一部分。

保持平衡:提取独立变化的依赖项。将相关操作放在一起。

开放/封闭:深思熟虑地扩展 原则:在不改变现有代码的情况下添加新行为。

误解:永远不要使用具体类型,或者相反,永远不要创建抽象。

现实:封装变化的行为;使用适当的结构。

想象一下,一家咖啡店在菜单上添加饮品。他们不需要每次都重建厨房,但他们也不只是写便签。

过度抽象的版本 interface IDrinkFactory { create(): IDrink; }

interface IDrink { prepare(): void; }

abstract class AbstractDrinkCreator { abstract createDrink(): IDrink;

public serveDrink(): IDrink { const drink = this.createDrink(); drink.prepare(); return drink; } }

class CoffeeFactory extends AbstractDrinkCreator implements IDrinkFactory { createDrink(): IDrink { return new Coffee(); }

create(): IDrink { return this.createDrink(); } }

问题:

冗余抽象:工厂接口和抽象创建者做同样的事情 维护负担:添加茶需要工厂类、具体类和样板 欠抽象版本 const coffee = { name: 'Coffee', price: 3, prepare: () => { console.log('Grinding beans...'); console.log('Brewing coffee...'); } };

const tea = { name: 'Tea', price: 2, prepare: () => { console.log('Boiling water...'); console.log('Steeping tea...'); } };

function serve(drink) { drink.prepare(); console.log(Serving ${drink.name}); }

问题:

违反开放/关闭:添加忠诚度折扣意味着修改每个对象文字 无封装:业务逻辑(定价、准备)分散 难以扩展:想要添加尺寸选项?必须修改每个饮料对象 脆弱:属性名称中的拼写错误不会被捕获 平衡版 interface Customer { hasLoyalty: boolean; }

interface PrepareOptions { size?: 'small' | 'medium' | 'large'; }

interface Drink { readonly name: string; getPrice(customer?: Customer): number; prepare(options?: PrepareOptions): Promise; }

class Coffee implements Drink { readonly name = 'Coffee';

getPrice(customer?: Customer): number { const base = 3; return customer?.hasLoyalty ? base * 0.9 : base; }

async prepare(options?: PrepareOptions): Promise { console.log('Grinding beans...'); console.log(Brewing ${options?.size || 'medium'} coffee...); } }

class Tea implements Drink { readonly name = 'Tea';

getPrice(customer?: Customer): number { const base = 2; return customer?.hasLoyalty ? base * 0.9 : base; }

async prepare(options?: PrepareOptions): Promise { console.log('Boiling water...'); console.log(Steeping ${options?.size || 'medium'} tea...); } }

// Open for extension: Add new drinks without modifying existing ones class Espresso implements Drink { readonly name = 'Espresso';

getPrice(customer?: Customer): number { const base = 4; return customer?.hasLoyalty ? base * 0.9 : base; }

async prepare(options?: PrepareOptions): Promise { console.log('Pulling espresso shot...'); } }

async function serve(drink: Drink, customer?: Customer): Promise { const price = drink.getPrice(customer); await drink.prepare(); console.log(${drink.name} ready! Price: $${price}); }

为什么这样更好:

开放/关闭:通过创建新类来添加新饮料,无需更改现有代码 封装:每种饮料都有自己的定价和准备逻辑 类型安全:TypeScript 在编译时捕获错误 可扩展:添加功能(大小、自定义)不会破坏现有代码 对于具有依赖关系的简单初始化:

function createCappuccino(milkFrother: MilkFrother): Drink { return new class implements Drink { readonly name = 'Cappuccino';

getPrice(customer?: Customer): number {
  return customer?.hasLoyalty ? 4.05 : 4.5;
}

async prepare(options?: PrepareOptions): Promise<void> {
  console.log('Brewing espresso...');
  await milkFrother.froth();
  console.log('Combining espresso and foam...');
}

}; }

关键见解:当需要扩展行为时使用类。使用简单对象来配置数据。区别很重要。

保持平衡:针对不同的行为使用适当的结构。不要把所有东西都强行塞进对象里,但如果类能解决实际问题,也不要避免使用类。

里氏替换:行为一致性 原则:父类工作的地方子类也应该工作。

误解:这只是关于继承层次结构。

现实情况:任何接口的实现都必须与其他接口的行为保持一致。

想想数据存储——它们都以一致的语义保存、加载和删除数据。

过度抽象的版本 abstract class AbstractDataStore { abstract save(data: any): void; abstract load(id: string): any; abstract delete(id: string): void;

protected validate(data: any): boolean { return data !== null && data !== undefined; } }

class DatabaseStore extends AbstractDataStore { save(data: any): void { if (!this.validate(data)) throw new Error('Invalid data'); // database logic }

load(id: string): any { // database logic }

delete(id: string): void { // database logic } }

class FileStore extends AbstractDataStore { save(data: any): void { if (!this.validate(data)) throw new Error('Invalid data'); // file logic }

load(id: string): any { // file logic }

delete(id: string): void { // file logic } }

问题:

脆弱的继承:AbstractDataStore 的变更引发广泛波及 隐藏耦合:子类必须了解父类的验证方法 测试复杂性:无法独立测试实现 欠抽象版本 const databaseStore = { save: (data) => { if (!data) throw new Error('Invalid'); // save logic }, load: (id) => { /* load logic / }, delete: (id) => { / delete logic */ } };

const fileStore = { save: (data) => { // Oops! Forgot validation // save logic }, load: (id) => { /* load logic / }, delete: (id) => { / delete logic */ } };

问题:

LSP 违规:商店行为不同(一个验证,一个不验证) 没有类型安全:拼写错误和不一致不会被发现 没有合同:不清楚保证什么行为 平衡版 interface DataStore { save(data: NonNullable): Promise; load(id: string): Promise<T | null>; delete(id: string): Promise; }

class DatabaseStore implements DataStore { private data = new Map<string, T>();

async save(data: NonNullable): Promise { const id = data( as any).id || crypto.randomUUID(); this.data.set(id, data); }

async load(id: string): Promise<T | null> { return this.data.get(id) || null; }

async delete(id: string): Promise { this.data.delete(id); } }

class FileStore implements DataStore { async save(data: NonNullable): Promise { const id = (data as any).id || crypto.randomUUID(); console.log(Saving to file: ${id}); // File system logic }

async load(id: string): Promise<T | null> { console.log(Loading from file: ${id}); return null; // File system logic }

async delete(id: string): Promise { console.log(Deleting file: ${id}); // File system logic } }

// Composition for shared behavior function createCachedStore( store: DataStore, maxSize: number = 100 ): DataStore { const cache = new Map<string, T>();

return { save: async (data) => { await store.save(data); const id = (data as any).id; if (id) cache.set(id, data); }, load: async (id) => { if (cache.has(id)) return cache.get(id)!; const data = await store.load(id); if (data) cache.set(id, data); return data; }, delete: async (id) => { await store.delete(id); cache.delete(id); } }; }

为什么这样更好:

符合 LSP 要求:每个 DataStore 的行为都一致 类型安全:TypeScript 强制执行契约(NonNullable) 可测试:每个实现都可以独立测试 可组合:通过组合而不是继承来添加功能 关键见解:LSP 适用于任何抽象(接口、抽象类、基类)。所有实现都必须行为一致。类型系统有助于实现这一点。

保持平衡:使用具有类型约束的接口。通过组合(而非继承)来共享实现。确保行为一致性。

接口隔离:重点合同 原则:不要强迫实现依赖于它们不使用的方法。

误解:为每种方法创建一个接口,或者相反,为所有方法创建一个巨大的接口。

现实情况:根据客户需求而不是实施细节创建界面。

想象一下设计一个记录器。应用程序的不同部分需要不同的日志记录功能。

过度抽象的版本 interface IBasicLogger { log(msg: string): void; }

interface IErrorLogger { logError(error: Error): void; }

interface IDebugLogger { logDebug(msg: string): void; }

interface IWarningLogger { logWarning(msg: string): void; }

interface IInfoLogger { logInfo(msg: string): void; }

class ProductionLogger implements IBasicLogger, IErrorLogger, IDebugLogger, IWarningLogger, IInfoLogger {

log(msg: string): void { /* / } logError(error: Error): void { / / } logDebug(msg: string): void { / / } logWarning(msg: string): void { / / } logInfo(msg: string): void { / */ } }

问题:

界面爆炸:五个界面对应一个统一的概念 维护噩梦:添加一个级别意味着到处创建一个新的界面 违反凝聚力:日志级别不是单独的职责 欠抽象版本 type LogLevel = 'info' | 'warn' | 'error' | 'debug';

interface Logger { log(level: LogLevel, message: string): void; }

const errorOnlyLogger: Logger = { log: (level, message) => { if (level === 'error') { console.error(message); } // Silently ignores all other levels } };

function reportError(logger: Logger, error: Error): void { logger.log('error', error.message); }

问题:

ISP 违规:errorOnlyLogger声称实现了 Logger,但忽略了其中的大部分内容 隐藏行为:调用者不知道此记录器忽略了非错误 LSP 违规:无法与其他 Logger 实现真正替代 测试混乱:如何测试它是否正确忽略了信息/调试/警告? 平衡版 // Segregated interfaces for different client needs interface ErrorLogger { logError(message: string, error?: Error): void; }

interface InfoLogger { logInfo(message: string): void; }

interface DebugLogger { logDebug(message: string): void; }

// Full logger combines capabilities interface Logger extends ErrorLogger, InfoLogger, DebugLogger { logWarning(message: string): void; }

// Production: Full-featured logger class ConsoleLogger implements Logger { logError(message: string, error?: Error): void { console.error(message, error); }

logInfo(message: string): void { console.info(message); }

logDebug(message: string): void { console.debug(message); }

logWarning(message: string): void { console.warn(message); } }

// Specialized: Error tracking only needs errors class ErrorTrackingService implements ErrorLogger { logError(message: string, error?: Error): void { // Send to Sentry, Rollbar, etc. console.error('Tracking:', message, error); } }

// Clients depend only on what they need function handleCriticalError(logger: ErrorLogger, error: Error): void { logger.logError('Critical error occurred', error); // Only depends on error logging capability }

function logUserAction(logger: InfoLogger, action: string): void { logger.logInfo(User action: ${action}); // Only depends on info logging capability }

function debugPerformance(logger: DebugLogger, timing: number): void { logger.logDebug(Operation took ${timing}ms); // Only depends on debug logging capability }

为什么这样更好:

符合 ISP 要求:客户端仅依赖于他们使用的方法 符合 LSP 要求:每个实现都诚实地履行其合同 灵活:完整记录器或专用记录器均可正常工作 明确的合同:没有隐藏的行为,没有默默的忽视 关键见解:接口隔离关乎客户端需求,而非实现便利性。应用程序的不同部分有不同的需求。handleCriticalError不需要信息/调试日志记录,因此它不应该依赖于这些方法。

保持平衡:根据客户端的使用方式创建接口,而不是实现提供接口的方式。实现可以支持多个接口。

依赖倒置:适当抽象 原则:高级代码不应该依赖于低级细节。

误解:通过复杂的 IoC 容器注入所有内容,或者相反,直接 new 所有内容。

现实:识别真正的依赖关系并注入适当的抽象。

想象一下通知系统。电子邮件提供商可能会改变,但通知逻辑保持不变。

过度抽象的版本 interface IEmailServiceFactory { create(): IEmailService; }

interface IEmailService { send(email: IEmail): Promise; }

interface IEmail { to: string; from: string; subject: string; body: string; }

interface IEmailBuilder { setTo(to: string): IEmailBuilder; setFrom(from: string): IEmailBuilder; setSubject(subject: string): IEmailBuilder; setBody(body: string): IEmailBuilder; build(): IEmail; }

class EmailServiceFactory implements IEmailServiceFactory { create(): IEmailService { return new SendGridEmailService(); } }

class NotificationService { private emailService: IEmailService;

constructor( private emailFactory: IEmailServiceFactory, private emailBuilder: IEmailBuilder ) { this.emailService = emailFactory.create(); }

async notifyUser(email: string, message: string): Promise { const emailMessage = this.emailBuilder .setTo(email) .setFrom('noreply@app.com') .setSubject('Notification') .setBody(message) .build();

await this.emailService.send(emailMessage);

} }

问题:

过度注入:当一个接口足够时,注入工厂和构建器 认知负荷:发送电子邮件的五个界面 测试噩梦:模拟工厂、模拟构建器、验证服务创建 欠抽象版本 class NotificationService { private sendGridClient = new SendGridClient();

async notifyUser(email: string, message: string): Promise { await this.sendGridClient.send({ to: email, from: 'noreply@app.com', subject: 'Notification', body: message }); } }

问题:

硬依赖:与 SendGrid 紧密耦合 无法测试:如果不使用 SendGrid API,则无法进行测试 不灵活:更改提供商需要修改 NotificationService 平衡版 interface EmailSender { send(to: string, subject: string, body: string): Promise; }

interface SmsSender { send(to: string, message: string): Promise; }

class NotificationService { constructor( private emailSender: EmailSender, private smsSender: SmsSender ) {}

async notifyUser( email: string, phone: string, message: string ): Promise { await Promise.all([ this.emailSender.send(email, 'Notification', message), this.smsSender.send(phone, message) ]); }

async notifyByEmail(email: string, message: string): Promise { await this.emailSender.send(email, 'Notification', message); } }

// Production implementation const sendGridSender: EmailSender = { send: async (to, subject, body) => { // SendGrid API call console.log(SendGrid: Sending to ${to}); } };

const twilioSender: SmsSender = { send: async (to, message) => { // Twilio API call console.log(Twilio: SMS to ${to}); } };

// Test implementation const testEmailSender: EmailSender = { send: async (to, subject, body) => { console.log(Test: Would send "${subject}" to ${to}); } };

// Resilient implementation with fallback function createResilientEmailSender( providers: EmailSender[] ): EmailSender { return { send: async (to, subject, body) => { let lastError: Error | undefined;

  for (const provider of providers) {
    try {
      await provider.send(to, subject, body);
      return; // Success!
    } catch (error) {
      lastError = error as Error;
      console.log('Provider failed, trying next...');
    }
  }

  throw new Error(`All providers failed: ${lastError?.message}`);
}

}; }

// Simple instantiation const mailgunSender: EmailSender = { send: async (to, subject, body) => { console.log(Mailgun: Sending to ${to}); } };

const resilientSender = createResilientEmailSender([ sendGridSender, mailgunSender ]);

const service = new NotificationService(resilientSender, twilioSender);

为什么这样更好:

符合 DIP:高级通知服务依赖于抽象 可测试:轻松注入测试实现 灵活:无需更改 NotificationService 即可更换提供商 简单:无需工厂层次结构或 IoC 容器 可组合:通过简单的功能构建复杂的行为(弹性) 关键洞察:依赖倒置是关于依赖抽象,而不是注入一切。识别架构中真正的接缝——实现细节真正变化或需要隔离的点。

保持平衡:注入变化或需要测试隔离的依赖项。对于复杂的构造函数,请使用简单的工厂函数。不要注入不变的配置或实用程序。

为什么平衡很重要 过度抽象和抽象不足都会产生成本。找到适当的平衡至关重要。

过度抽象的代价 认知负荷:每个抽象层都是一次思维跳跃。发送电子邮件的五个接口意味着在更改电子邮件发送方式之前,需要了解五件事。

调试复杂性:当堆栈跟踪充斥着工厂方法、抽象基类和委托层时,它就变得毫无用处。简单的错误隐藏在复杂的架构中。

虚假的灵活性:你构建的那个复杂的工厂模式?你可能永远都不会更换实现。原本旨在支持变更的抽象,却变成了变更的障碍。

开发速度较慢:添加功能需要接触多个接口、更新工厂、修改抽象层。原本只需 10 分钟就能搞定的事情,却需要耗费 1 个小时。

抽象不足的代价 刚性:带有硬编码逻辑的对象字面量如果不进行修改就无法扩展。每次需求变化,都会违反开放/封闭原则。

紧密耦合:依赖项的直接实例化(如new SendGridClient())使得测试变得不可能并且提供者无法改变。

行为不一致:缺乏接口和类型约束,实现会漂移。一个存储验证成功,另一个却没有——到处都是 LSP 违规。

脆弱性:没有编译时安全性就意味着运行时错误。属性名称拼写错误、方法缺失、行为不一致——所有这些都发现得太晚了。

甜蜜点 适当的抽象提供:

清晰度:代码无需不必要的间接性即可揭示意图 灵活性:扩展不需要修改 可测试性:依赖关系可以交换以进行测试 类型安全:编译器在运行时之前捕获错误 可维护性:变化是局部的、可预测的 何时抽象 别猜了。让痛苦指引你。当你感觉到:

1.测试痛苦 症状:如果不触及外部服务或数据库就无法测试业务逻辑。

解决方案:提取依赖项的接口,然后注入它。

// Before: Untestable class OrderService { async processOrder(order: Order): Promise { const payment = new StripePaymentService(); await payment.charge(order.total); } }

// After: Testable interface PaymentService { charge(amount: number): Promise; }

class OrderService { constructor(private payment: PaymentService) {}

async processOrder(order: Order): Promise { await this.payment.charge(order.total); } }

2.伸展疼痛 症状:添加新行为需要在多个地方修改现有代码。

解决方案:提取接口,使实现独立。

// Before: Must modify drink objects to add features const coffee = { name: 'Coffee', price: 3, prepare: () => console.log('Brewing...') };

// After: Extend by adding classes class Coffee implements Drink { readonly name = 'Coffee'; getPrice(customer?: Customer): number { return customer?.hasLoyalty ? 2.7 : 3; } async prepare(): Promise { console.log('Brewing...'); } }

3.重复痛苦 症状:相同的代码重复出现,但略有不同。

解决方案:提取变异点,注入或参数化。

// Before: Duplication async function sendWelcomeEmail(user: User): Promise { await sendGrid.send({ to: user.email, subject: 'Welcome!', body: 'Welcome to our app!' }); }

async function sendPasswordResetEmail(user: User): Promise { await sendGrid.send({ to: user.email, subject: 'Password Reset', body: 'Click here to reset...' }); }

// After: Extract variation interface EmailTemplate { subject: string; body: string; }

async function sendEmail( sender: EmailSender, user: User, template: EmailTemplate ): Promise { await sender.send(user.email, template.subject, template.body); }

const welcomeTemplate = { subject: 'Welcome!', body: 'Welcome to our app!' };

const resetTemplate = { subject: 'Password Reset', body: 'Click here to reset...' };

4.耦合痛 症状:变更意外地产生连锁反应。修改 A 需要修改 B、C 和 D。

解决方案:找出耦合点,引入接口来打破它。

5.现今的多种实现方式 症状:您现在正在运送多个实际的实施方案(而不是假设的未来)。

解决方案:立即抽象。这是抽象提供价值的最清晰信号。

前进的道路:情境驱动设计 SOLID 原则是指南,而非目标。目标是编写易于理解、易于修改和易于维护的代码。抽象是实现这些目标的工具,而非最终目的。

问这些问题: 添加抽象之前:

我现在有两个真实的、不同的实现吗? 这种依赖性是否会导致测试困难? 这里的要求是否会以我今天可以表达的方式发生变化? 在避免抽象之前:

我修改现有扩展代码是否违反了开放/封闭原则? 我的实现是否行为一致(LSP)? 不同的客户端是否被迫依赖他们不使用的接口(ISP)? 对于两者:

新开发人员能在两分钟内理解这一点吗? 我可以轻松测试这个吗? 这会使改变变得更容易还是更难? 决策矩阵 使用此矩阵来指导您的抽象决策:

语境 抽象级别 基本原理 早期原型/MVP 最小 先学习领域,再重构 生产系统 合适的 平衡可维护性与清晰度 独立开发者 最低-中等 减少协调开销 大型团队 中高 合同防止耦合 稳定域 中高 对已证实的模式进行编码 易变的要求 缓和 通过组合实现灵活性 性能至关重要 最小 每一层都耗时纳秒 标准商业应用程序 缓和 微优化的清晰度 通过以下方式关注 SOLID: 单一职责

编写将操作变化分组在一起的重点类 分离独立变化的关注点 使用简单的依赖注入来实现不同的行为 开放/关闭

使用类来扩展行为(不仅仅是数据) 保持抽象的重点和目的性 通过子类化或组合进行扩展,而不是修改 利斯科夫替换

确保所有实施行为一致 使用类型系统来执行合同 明确测试可替代性 接口隔离

根据客户需求创建接口,而不是实现细节 允许实现支持一个或多个接口 确保客户只依赖他们所使用的 依赖倒置

识别实施差异的真正接缝 通过简单的接口注入依赖项 使用工厂函数进行复杂的构造,而不是工厂类 极简主义者的创作过程 开始具体工作:先写出可运行的代码,解决眼前的问题。 感受痛苦:等到你遇到实际问题时: 没有外部依赖就无法测试 添加功能需要修改多个现有文件 相同的代码重复出现,但略有变化 变化出乎意料地产生涟漪 最少提取:仅添加解决特定问题所需的抽象: 测试痛点 → 提取接口的依赖关系 扩展痛苦 → 创建实现接口的类 重复痛苦 → 参数化或提取变异 耦合痛点→在耦合点引入接口 验证 SOLID:检查您的抽象: 具有单一、明确的职责(SRP) 允许不经修改进行扩展(OCP) 支持真正的可替代性(LSP) 满足重点客户需求(ISP) 依赖于抽象,而不是具体(DIP) 重复一遍:让新的痛苦引导进一步的改进。不要预先解决明天的问题。 现实世界的例子:实践中的平衡 示例 1:电子商务订单处理 背景:生产系统,稳定域,8 名开发人员的团队

初始版本(太简单):

class OrderProcessor { async processOrder(order: Order): Promise { // Payment await fetch('stripe.com/api/charge', { method: 'POST', body: JSON.stringify({ amount: order.total }) });

// Inventory
await fetch('https://api.inventory.com/reserve', {
  method: 'POST',
  body: JSON.stringify({ items: order.items })
});

// Email
await fetch('https://sendgrid.com/api/send', {
  method: 'POST',
  body: JSON.stringify({ to: order.email })
});

} }

问题:不可测试、紧密耦合、违反 SRP/OCP/DIP

平衡版本:

interface PaymentService { charge(amount: number, currency: string): Promise; }

interface InventoryService { reserve(items: OrderItem[]): Promise; release(reservationId: string): Promise; }

interface NotificationService { sendOrderConfirmation(order: Order): Promise; }

class OrderProcessor { constructor( private payment: PaymentService, private inventory: InventoryService, private notifications: NotificationService ) {}

async processOrder(order: Order): Promise { // Reserve inventory first const reservation = await this.inventory.reserve(order.items);

try {
  // Charge payment
  const payment = await this.payment.charge(
    order.total,
    order.currency
  );

  // Send confirmation
  await this.notifications.sendOrderConfirmation(order);

  return { success: true, orderId: order.id };
} catch (error) {
  // Release inventory on failure
  await this.inventory.release(reservation.id);
  throw error;
}

} }

为什么有效:

可测试:为每个服务注入测试实现 SRP:每个服务都有一项职责 OCP:可以交换付款/库存/通知提供商 DIP:OrderProcessor 依赖于抽象 依然简单:三个集中接口,没有工厂层次结构 示例 2:实时分析仪表板 背景:原型、不稳定的需求、单独开发者

正确的方法(从简单开始):

interface MetricValue { timestamp: number; value: number; }

class MetricsCollector { private metrics: Map<string, MetricValue[]> = new Map();

record(name: string, value: number): void { const values = this.metrics.get(name) || []; values.push({ timestamp: Date.now(), value }); this.metrics.set(name, values); }

getMetrics(name: string, since?: number): MetricValue[] { const values = this.metrics.get(name) || []; if (since) { return values.filter(v => v.timestamp >= since); } return values; } }

// Simple, concrete, no premature abstraction const collector = new MetricsCollector(); collector.record('api_latency', 150);

何时进行抽象:需求稳定后,您需要:

多种存储后端(内存、Redis、TimescaleDB) 多种收集策略(采样、聚合) 复杂查询(百分位数、聚合) 然后提取:

interface MetricsStore { record(name: string, value: number): Promise; query(name: string, options: QueryOptions): Promise<MetricValue[]>; }

interface AggregationStrategy { aggregate(values: MetricValue[]): AggregatedMetric; }

class MetricsCollector { constructor( private store: MetricsStore, private aggregator: AggregationStrategy ) {}

// Implementation using abstractions }

示例 3:内容管理系统 背景:生产、大型团队、稳定领域

错误的方法(过度抽象):

interface IContentFactory { /* ... / } interface IContentRepository { / ... / } interface IContentValidator { / ... / } interface IContentSerializer { / ... / } interface IContentRenderer { / ... / } abstract class AbstractContentManager { / ... / } class ContentManagerFactory { / ... */ }

正确的方法(适当抽象):

interface Content { id: string; type: 'article' | 'page' | 'post'; title: string; body: string; metadata: Record<string, any>; }

interface ContentStore { save(content: Content): Promise; load(id: string): Promise<Content | null>; delete(id: string): Promise; search(query: SearchQuery): Promise<Content[]>; }

interface ContentRenderer { render(content: Content): Promise; }

class ContentManager { constructor( private store: ContentStore, private renderer: ContentRenderer ) {}

async publish(content: Content): Promise { await this.validate(content); await this.store.save(content); }

async getRenderedContent(id: string): Promise { const content = await this.store.load(id); if (!content) throw new Error('Content not found'); return this.renderer.render(content); }

private validate(content: Content): void { if (!content.title) throw new Error('Title required'); if (!content.body) throw new Error('Body required'); } }

为什么有效:

两个关键抽象:存储和渲染(变化点) 无工厂爆炸:简单的构造函数注入 团队友好:合同清晰,易于理解 可测试:模拟存储和渲染器 可扩展:添加新的内容类型、存储后端、渲染器 应避免的常见反模式

  1. “以防万一”界面 // ❌ Bad: Interface that might be useful someday interface IConfigurationProvider { get(key: string): any; set(key: string, value: any): void; reload(): Promise; watch(key: string, callback: Function): void; }

// ✅ Good: Start with what you need const config = { apiKey: process.env.API_KEY, apiUrl: process.env.API_URL };

// Extract interface only when you need multiple sources

2.抽象基类疾病 // ❌ Bad: Abstract classes for shared implementation abstract class AbstractService { protected abstract logger: Logger; protected abstract config: Config;

protected log(msg: string): void { this.logger.log(msg); } }

// ✅ Good: Composition over inheritance class ServiceBase { constructor( protected logger: Logger, protected config: Config ) {} }

// Or even better: just inject what you need class OrderService { constructor( private logger: Logger, private config: Config ) {} }

3.哥斯拉界面 // ❌ Bad: One interface for everything interface UserService { authenticate(credentials: Credentials): Promise; register(data: RegistrationData): Promise; updateProfile(userId: string, data: ProfileData): Promise; deleteAccount(userId: string): Promise; sendPasswordReset(email: string): Promise; verifyEmail(token: string): Promise; listUsers(filters: Filters): Promise<User[]>; banUser(userId: string, reason: string): Promise; }

// ✅ Good: Segregated by client needs interface AuthenticationService { authenticate(credentials: Credentials): Promise; sendPasswordReset(email: string): Promise; }

interface RegistrationService { register(data: RegistrationData): Promise; verifyEmail(token: string): Promise; }

interface UserManagementService { updateProfile(userId: string, data: ProfileData): Promise; deleteAccount(userId: string): Promise; }

interface AdminUserService { listUsers(filters: Filters): Promise<User[]>; banUser(userId: string, reason: string): Promise; }

4.过早的抽象 // ❌ Bad: Abstracting before you understand the domain interface IPaymentStrategy { execute(context: IPaymentContext): Promise; }

interface IPaymentContext { getAmount(): number; getCurrency(): string; getMetadata(): Map<string, any>; }

// ✅ Good: Start concrete, extract patterns later class PaymentProcessor { async processPayment( amount: number, currency: string, paymentMethod: PaymentMethod ): Promise { // Concrete implementation // Extract abstraction when second payment provider appears } }

衡量成功 如何判断自己是否找到了正确的平衡点?可以使用以下指标:

代码健康指标 好兆头:

✅ 新功能涉及 1-3 个文件,而不是 10 个以上 ✅ 测试不需要复杂的设置 ✅ 新团队成员在 1 小时内理解代码 ✅ Bug 通常出现在一个明显的地方 ✅ 重构感觉安全且本地化 警告标志:

⚠️ “我应该在哪里进行这项更改?”是一个常见的问题 ⚠️ 当不相关的代码发生更改时,测试会中断 ⚠️ 堆栈跟踪深度超过 20 层 ⚠️ 开发人员说“我不想碰那段代码” ⚠️ 简单的功能只需几天而不是几小时 两分钟规则 一个有能力的开发人员可以:

在不到 2 分钟的时间内了解代码的作用吗? 在不到 2 分钟的时间内确定需要更改的位置? 在不到 10 分钟的时间内完成一个简单的改变? 如果没有,那么你的抽象可能太多了。如果更改需要修改多个文件,那么你的抽象可能太少了。

测试试金石 过于抽象:设置测试需要模拟 5 个以上的依赖项 抽象太少:如果不触及外部服务就无法进行测试 恰到好处:测试使用 1-2 个简单的测试替身,运行速度很快 结论:勇于适度 应用 SOLID 最难的部分并非技术层面,而是文化和情境。我们被训练成将抽象等同于专业,将简单等同于天真。但反过来也同样成问题:认为所有抽象都是过度工程。

事实更加微妙:

抽象本身并不邪恶,过早或过度的抽象才是 具体性不是美德;僵化、不可检验的具体性才是美德 SOLID 原则是指南,而不是戒律 背景很重要:团队规模、领域稳定性、项目阶段、性能要求 高级工程师的标志不在于他们能应用多少设计模式,也不在于他们能使用多少抽象。而是知道何时抽象、何时不抽象,并拥有区分两者的判断力。

关键要点 让痛苦指引你:不要抽象,直到你感受到测试痛苦、扩展痛苦、重复痛苦或耦合痛苦 验证 SOLID 合规性:您添加的任何抽象都应该真正满足 SOLID 原则,而不仅仅是添加层 上下文驱动决策:适当的抽象程度取决于您的具体情况——原型与生产、个人与团队、稳定与不稳定 两种极端都会造成伤害:过度抽象会造成认知负荷和虚假灵活性;抽象不足会造成僵化和不可测试性 无畏地重构:简单的代码很容易重构为抽象的代码;过度抽象的代码很难简化 衡量结果:通过清晰度、可测试性和可变性来判断你的抽象,而不是通过遵守模式 平衡信条 开始具体化:编写解决当前问题的工作代码 感受真正的痛苦:等待真正的问题,而不是想象的未来 适当提取:添加真正满足 SOLID 的抽象 验证平衡:检查清晰度、可测试性和可维护性 重复:让新的挑战引导进一步的改进 要平衡。要清晰。要坚定。

最好的代码既不是极简的,也不是极繁的——而是合适的。它言出必行。它能随着需求的变化而轻松修改。它拥有与上下文需求完全匹配的抽象程度——不多不少。

这就是软件工程的艺术:在简单性和结构性之间、今天的需求和明天的灵活性之间、严格的具体性和抽象的复杂性之间找到适当的平衡。

掌握这种平衡,你就会掌握 SOLID。查看更多www.mjsyxx.com