1. 为什么我们需要SOLID原则?
在TypeScript严格模式下开发时,我经常遇到这样的场景:一个原本简单的功能需求,随着业务迭代逐渐变成难以维护的"意大利面条代码"。上周我就重构了一个3000行的服务类,它最初只有200行,但因为缺乏设计原则的约束,最终变成了一个包含各种条件判断和副作用的大杂烩。
SOLID原则正是为了解决这类问题而生的。这五个字母分别代表:
- S: 单一职责原则(Single Responsibility Principle)
- O: 开闭原则(Open/Closed Principle)
- L: 里氏替换原则(Liskov Substitution Principle)
- I: 接口隔离原则(Interface Segregation Principle)
- D: 依赖倒置原则(Dependency Inversion Principle)
提示:TypeScript严格模式(
strict: true)会强制类型检查,这与SOLID原则追求代码健壮性的目标高度一致。建议始终开启严格模式来实践SOLID。
2. 单一职责原则(SRP)实战
2.1 识别职责混杂的"坏味道"
上周我review的一个用户服务类是这样的:
class UserService { async register(user: User) { // 验证用户数据 if(!user.email.includes('@')) throw new Error('Invalid email'); // 保存到数据库 await db.users.insert(user); // 发送欢迎邮件 await emailService.sendWelcome(user.email); // 生成PDF报告 const pdf = await pdfGenerator.generate(user); await storage.upload(pdf); } }这个类明显违反了SRP,它同时处理了:
- 业务逻辑验证
- 数据持久化
- 邮件通知
- 文件生成与存储
2.2 如何进行职责拆分
重构后的结构:
// 用户验证器 class UserValidator { static validate(user: User) { if(!user.email.includes('@')) throw new Error('Invalid email'); } } // 用户仓库 class UserRepository { async save(user: User) { return db.users.insert(user); } } // 通知服务 class NotificationService { async sendWelcome(email: string) { return emailService.sendWelcome(email); } } // 报表服务 class ReportService { async generateUserReport(user: User) { const pdf = await pdfGenerator.generate(user); return storage.upload(pdf); } } // 重构后的用户服务 class UserService { constructor( private validator: UserValidator, private repository: UserRepository, private notifier: NotificationService, private reporter: ReportService ) {} async register(user: User) { this.validator.validate(user); await this.repository.save(user); await this.notifier.sendWelcome(user.email); await this.reporter.generateUserReport(user); } }注意事项:拆分时要注意类之间的协作成本。过度拆分会导致需要管理大量小类,我建议单个类的代码行数控制在200行以内比较合适。
3. 开闭原则(OCP)的TypeScript实现
3.1 理解"对扩展开放,对修改关闭"
在电商促销系统中,我最初是这样实现折扣计算的:
class DiscountCalculator { getDiscount(type: 'blackFriday' | 'anniversary', price: number) { if(type === 'blackFriday') { return price * 0.7; } else if(type === 'anniversary') { return price * 0.8; } return price; } }这种实现的问题在于:每次新增促销类型都需要修改这个类,违反了OCP。
3.2 使用策略模式重构
interface DiscountStrategy { calculate(price: number): number; } class BlackFridayDiscount implements DiscountStrategy { calculate(price: number) { return price * 0.7; } } class AnniversaryDiscount implements DiscountStrategy { calculate(price: number) { return price * 0.8; } } class DiscountCalculator { private strategies: Record<string, DiscountStrategy> = {}; registerStrategy(name: string, strategy: DiscountStrategy) { this.strategies[name] = strategy; } calculate(name: string, price: number) { const strategy = this.strategies[name]; return strategy ? strategy.calculate(price) : price; } } // 使用方式 const calculator = new DiscountCalculator(); calculator.registerStrategy('blackFriday', new BlackFridayDiscount()); calculator.registerStrategy('anniversary', new AnniversaryDiscount());现在要新增促销类型时,只需新增策略类并注册,无需修改现有代码。
实操心得:在TypeScript中,使用接口定义策略契约可以确保所有策略类实现一致的方法签名。strict模式会强制检查这一点。
4. 里氏替换原则(LSP)的边界控制
4.1 违反LSP的典型案例
考虑几何图形的例子:
class Rectangle { constructor(public width: number, public height: number) {} setWidth(width: number) { this.width = width; } setHeight(height: number) { this.height = height; } area() { return this.width * this.height; } } class Square extends Rectangle { constructor(size: number) { super(size, size); } setWidth(width: number) { super.setWidth(width); super.setHeight(width); // 正方形需要同时修改宽高 } setHeight(height: number) { super.setHeight(height); super.setWidth(height); // 正方形需要同时修改宽高 } } function testArea(shape: Rectangle) { shape.setWidth(4); shape.setHeight(5); console.assert(shape.area() === 20); // 对Square会失败 }虽然数学上正方形是矩形,但在代码中这样的继承关系违反了LSP,因为父类Rectangle的行为不能被Square完全替代。
4.2 正确的抽象方式
interface Shape { area(): number; } class Rectangle implements Shape { constructor(public width: number, public height: number) {} area() { return this.width * this.height; } } class Square implements Shape { constructor(private size: number) {} area() { return this.size * this.size; } }现在Square和Rectangle都实现Shape接口,不再有继承关系,都可以被正确用于计算面积。
常见误区:很多开发者会认为"is-a"关系就应该用继承。实际上LSP要求子类必须完全兼容父类行为,而不仅仅是概念上的"属于"关系。
5. 接口隔离原则(ISP)与TypeScript接口
5.1 "胖接口"问题
在用户系统开发中,我见过这样的接口:
interface UserService { register(user: User): Promise<void>; login(email: string, password: string): Promise<string>; resetPassword(email: string): Promise<void>; updateProfile(user: User): Promise<void>; deleteAccount(userId: string): Promise<void>; generateReport(userId: string): Promise<Report>; // ...还有10多个方法 }这导致任何实现类都必须实现所有方法,即使它只需要其中部分功能。
5.2 接口拆分方案
interface AuthService { register(user: User): Promise<void>; login(email: string, password: string): Promise<string>; resetPassword(email: string): Promise<void>; } interface ProfileService { updateProfile(user: User): Promise<void>; deleteAccount(userId: string): Promise<void>; } interface ReportService { generateReport(userId: string): Promise<Report>; }现在客户端可以只依赖它们真正需要的接口:
class AuthController { constructor(private authService: AuthService) {} async login(req: Request) { const token = await this.authService.login(req.body.email, req.body.password); return { token }; } }TypeScript技巧:可以使用
Pick工具类型从大接口中提取需要的部分:type MinimalAuthService = Pick<AuthService, 'login' | 'register'>;
6. 依赖倒置原则(DIP)与依赖注入
6.1 高层模块不应依赖低层模块
考虑一个订单处理流程:
class MySQLOrderRepository { async save(order: Order) { // 直接依赖MySQL驱动 return mysql.query('INSERT INTO orders...'); } } class OrderService { private repository = new MySQLOrderRepository(); // 直接实例化依赖 async process(order: Order) { await this.repository.save(order); // 处理逻辑 } }这里OrderService直接依赖具体实现MySQLOrderRepository,难以切换数据库。
6.2 依赖抽象与注入
interface OrderRepository { save(order: Order): Promise<void>; } class MySQLOrderRepository implements OrderRepository { async save(order: Order) { return mysql.query('INSERT INTO orders...'); } } class OrderService { constructor(private repository: OrderRepository) {} // 依赖抽象 async process(order: Order) { await this.repository.save(order); // 处理逻辑 } } // 使用时注入具体实现 const service = new OrderService(new MySQLOrderRepository());现在可以轻松切换存储实现,比如测试时使用内存仓库:
class MemoryOrderRepository implements OrderRepository { private orders: Order[] = []; async save(order: Order) { this.orders.push(order); } } const testService = new OrderService(new MemoryOrderRepository());架构建议:结合TypeScript的装饰器和DI容器(如InversifyJS)可以更优雅地管理依赖。在严格模式下,类型系统会确保所有依赖都正确注入。
7. SOLID原则在TypeScript严格模式下的协同效应
当SOLID原则与TypeScript严格模式结合时,会产生强大的协同效应:
- 严格类型检查确保接口契约被正确实现(ISP、DIP)
- readonly修饰符帮助实现不可变性(OCP)
- 抽象类和接口天然支持DIP和ISP
- 类型推断帮助识别违反LSP的情况
- 模块系统促进SRP的实现
一个综合案例:
// 抽象层 interface PaymentProcessor { process(amount: number): Promise<void>; } // 具体实现 class StripeProcessor implements PaymentProcessor { async process(amount: number) { // Stripe支付逻辑 } } // 高层模块 class OrderController { constructor( private processor: PaymentProcessor, private logger: Logger // 另一个抽象依赖 ) {} async checkout(order: Order) { try { await this.processor.process(order.total); this.logger.log(`Order ${order.id} processed`); } catch (error) { this.logger.error('Payment failed', error); throw new PaymentError('Payment processing failed'); } } } // 使用依赖注入容器配置 const container = new Container(); container.bind<PaymentProcessor>(TYPES.PaymentProcessor).to(StripeProcessor); container.bind<Logger>(TYPES.Logger).to(ConsoleLogger); const controller = container.get(OrderController);在这个实现中:
- 每个类都有单一职责(SRP)
- 支付处理器可扩展(OCP)
- 所有实现都遵循接口契约(LSP、ISP)
- 高层模块依赖抽象(DIP)
- TypeScript严格模式确保所有类型正确
8. 实际项目中的平衡艺术
在实践中,我发现完全遵循SOLID有时会导致过度设计。以下是我的经验法则:
- 适度原则:对于预期会频繁变更的部分应用SOLID,稳定部分可以简化
- 演进式设计:初期可以适当放松,随着复杂度增长再重构
- 团队共识:SOLID的严格程度应与团队水平相匹配
- 性能考量:深层次的抽象可能带来性能开销,关键路径需要权衡
一个实用的方法是使用SOLID评分卡定期review代码:
| 原则 | 评分(1-5) | 改进建议 |
|---|---|---|
| SRP | 4 | 拆分订单处理类 |
| OCP | 3 | 引入策略模式处理支付类型 |
| LSP | 5 | 继承关系良好 |
| ISP | 2 | 拆分用户服务接口 |
| DIP | 3 | 引入DI容器 |
9. TypeScript特性强化SOLID实践
9.1 使用类型别名和泛型
// 为领域概念创建类型别名 type UserID = string & { readonly brand: unique symbol }; type Email = string & { readonly brand: unique symbol }; // 泛型仓库接口 interface Repository<T> { save(entity: T): Promise<void>; findById(id: string): Promise<T | null>; } // 具体实现 class UserRepository implements Repository<User> { // 实现方法 }9.2 利用装饰器实现AOP
function logExecution(target: any, key: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = async function(...args: any[]) { console.log(`Calling ${key} with`, args); const result = await original.apply(this, args); console.log(`Completed ${key}`); return result; }; return descriptor; } class ProductService { @logExecution async updateStock(productId: string, quantity: number) { // 库存更新逻辑 } }9.3 条件类型与映射类型
// 从实体类型生成更新DTO类型 type UpdateDTO<T> = { [P in keyof T]?: T[P] extends object ? UpdateDTO<T[P]> : T[P]; }; interface User { id: string; name: string; address: { street: string; city: string; }; } type UserUpdateDTO = UpdateDTO<User>; /* 等价于: { id?: string; name?: string; address?: { street?: string; city?: string; }; } */10. 从理论到实践:重构案例研究
最近我主导了一个电商订单系统的重构,以下是关键步骤:
10.1 原始代码问题分析
class OrderService { async placeOrder(order: OrderData) { // 验证订单数据(200行代码) // 计算折扣(150行代码) // 库存检查(100行代码) // 支付处理(直接调用支付网关SDK) // 创建订单记录(直接操作MySQL) // 发送通知(直接调用邮件和短信服务) // 生成发票(直接操作PDF库) // 更新统计(直接操作Redis) } }这个类的问题:
- 违反SRP(8个不同职责)
- 违反OCP(修改支付方式需要改这个类)
- 违反DIP(直接依赖具体实现)
10.2 重构过程记录
第一步:职责拆分
class OrderValidator { /* 验证逻辑 */ } class DiscountCalculator { /* 折扣计算 */ } class InventoryService { /* 库存管理 */ } class PaymentGateway { /* 支付抽象 */ } class OrderRepository { /* 数据持久化 */ } class NotificationService { /* 通知发送 */ } class InvoiceGenerator { /* 发票生成 */ } class StatsUpdater { /* 统计更新 */ }第二步:引入领域事件
interface DomainEvent { occurredOn: Date; } class OrderPlacedEvent implements DomainEvent { constructor(public readonly order: Order) {} occurredOn = new Date(); } class OrderService { constructor( private eventBus: EventBus, // 其他依赖... ) {} async placeOrder(order: OrderData) { // ...处理逻辑 // 发布事件而不是直接调用其他服务 await this.eventBus.publish(new OrderPlacedEvent(order)); } }第三步:配置依赖注入
// inversify.config.ts container.bind<OrderValidator>(TYPES.OrderValidator).to(OrderValidator); container.bind<EventBus>(TYPES.EventBus).to(RabbitMQEventBus); // ...其他绑定 // 使用装饰器注入 @injectable() class OrderController { constructor( @inject(TYPES.OrderService) private orderService: OrderService ) {} }10.3 重构效果对比
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 类行数 | 1200 | 150-200 |
| 单元测试覆盖率 | 15% | 85% |
| 新增支付方式时间 | 2天 | 2小时 |
| 核心流程修改影响范围 | 整个类 | 单个小类 |
11. 常见陷阱与解决方案
11.1 过度拆分导致碎片化
问题:将每个方法都拆分成单独的类,导致难以理解的碎片化代码。
解决方案:
- 按领域边界拆分,而不是机械地按方法拆分
- 使用领域驱动设计(DDD)的聚合根概念指导拆分
- 保持内聚性,相关操作放在同一个类中
11.2 抽象泄漏
问题:底层实现细节"泄漏"到高层模块。
反例:
interface Database { query(sql: string): Promise<any>; } class UserRepository { constructor(private db: Database) {} async getUsers() { // 高层模块知道SQL细节 return this.db.query('SELECT * FROM users WHERE active = 1'); } }改进:
interface UserRepository { getActiveUsers(): Promise<User[]>; } class SQLUserRepository implements UserRepository { constructor(private db: Database) {} async getActiveUsers() { // SQL细节封装在实现类中 return this.db.query('SELECT * FROM users WHERE active = 1'); } }11.3 循环依赖
问题:类A依赖B,B又依赖A,导致难以测试和维护。
解决方案:
- 引入第三方类或接口打破循环
- 使用依赖注入延迟解析
- 应用中介者模式
11.4 接口膨胀
问题:随着系统演进,接口不断添加方法变得臃肿。
解决方案:
- 定期审查和拆分接口
- 使用接口继承创建细分接口
- 应用命令模式将操作封装为对象
12. 测试策略与SOLID
SOLID原则天然支持可测试性:
12.1 单元测试的便利性
// 测试用户验证器 describe('UserValidator', () => { it('should reject invalid email', () => { const user = { email: 'invalid' }; expect(() => UserValidator.validate(user)).toThrow(); }); }); // 使用mock测试服务层 describe('OrderService', () => { it('should process order', async () => { const mockRepo = { save: jest.fn() }; const service = new OrderService(mockRepo); await service.process(testOrder); expect(mockRepo.save).toHaveBeenCalledWith(testOrder); }); });12.2 测试金字塔实践
- 单元测试:针对每个SOLID类
- 集成测试:验证类之间的协作
- E2E测试:验证整个工作流
测试技巧:使用TS的
Partial<T>创建测试替身:const testUser: Partial<User> = { email: 'test@example.com' };
13. 性能考量与优化
虽然SOLID提高了可维护性,但需要考虑性能影响:
13.1 抽象开销测量
使用装饰器等高级特性会增加调用栈深度。关键路径代码可能需要权衡:
// 生产代码使用完整抽象 class ProductionService { @log @validate @retry async criticalOperation() { /* ... */ } } // 性能关键代码使用简化版本 class HotPathService { async criticalOperation() { // 内联核心逻辑,减少抽象层 } }13.2 依赖注入优化
DI容器在启动时解析依赖关系可能有开销:
- 在应用启动时完成所有绑定
- 对频繁创建的对象使用单例作用域
- 考虑在热路径手动创建实例
14. 代码组织结构建议
保持SOLID代码的可浏览性:
src/ modules/ user/ application/ // 用例服务 domain/ // 实体和接口 infrastructure/ // 具体实现 interfaces/ // 对外暴露的API shared/ kernel/ // 框架和基础设施 utils/ // 通用工具每个模块内部按角色而非技术分层,便于定位代码。
15. 演进式架构与SOLID
随着项目规模增长,SOLID的应用也需要演进:
- 初创阶段:侧重SRP和ISP,保持代码清晰
- 增长阶段:引入DIP和OCP,提高扩展性
- 成熟阶段:全面应用SOLID,注重LSP保证稳定性
定期进行架构评审,评估SOLID原则的应用是否仍然合理。