设计模式 - 面向对象原则:SOLID最佳实践
文章目录
- 深入理解 SOLID:用对原则,别把简单问题搞复杂
- SOLID 原则概览
- 1. 单一职责原则(SRP)
- 2. 开闭原则(OCP)
- 3. 里氏替换原则(LSP)
- 4. 接口隔离原则(ISP)
- 5. 依赖反转原则(DIP)
- 原则之间的联系与平衡
- 案例
- 一、单一职责原则(SRP)—订单处理模块拆分
- 二、开闭原则(OCP)—优惠策略引擎
- 三、里氏替换原则(LSP)—图表渲染组件
- 四、接口隔离原则(ISP)—外部服务集成
- 五、依赖反转原则(DIP)—仓储层设计
- 案例共性与最佳实践
深入理解 SOLID:用对原则,别把简单问题搞复杂
在面向对象编程的世界里,SOLID 原则几乎是每个程序员最熟悉的五个字母组合——但也是最容易被“滥用”或“误用”的设计准则。
很多同学往往将每一条原则孤立地、机械地应用,结果往往制造出十几二十个冗余类,把原本简单的需求复杂化。正如“有了锤子,就到处找钉子”,在并不必要的时候,你硬要用上 SOLID,就很可能把本该一刀搞定的小活儿,变成一场大型重构。
下面,我们就带着“如何正确理解与应用”这个目标,一起来复盘 SOLID 五条原则的来龙去脉。
SOLID 原则概览
2000 年,Robert C. Martin 在论文《设计原理和设计模式》中首次提出 SOLID 概念。过去二十年里,这五条原则帮助我们构建了更易维护、可扩展的系统:
- Single Responsibility Principle (SRP):单一职责原则
- Open–Closed Principle (OCP):开闭原则
- Liskov Substitution Principle (LSP):里氏替换原则
- Interface Segregation Principle (ISP):接口隔离原则
- Dependency Inversion Principle (DIP):依赖反转原则
其核心价值在于——当团队规模扩大、多人协作时,我们需要低耦合、高内聚、可替换的模块。
1. 单一职责原则(SRP)
定义:一个类(或模块)应该只有一个“引起它变化的原因”。
误区:常被简单理解为“一个类只做一件事”、“一个接口只实现一次”“写好不能动”……而忘记“职责=变化的原因”这一核心。
示例:
public class Book {private String title, author, text;public String replaceWord(String word){ /*…*/ }public boolean containsWord(String word){ /*…*/ }public void print(){ /*…*/ } // ← 新增的打印责任public void read(){ /*…*/ } // ← 新增的阅读责任 }
当打印逻辑变化时,你得修改
Book
,当阅读流程变化时,又要修改它——职责不唯一,违反 SRP。
正确做法:抓住“职责”的边界,职责可由多个类共同完成,但要保证各自变化原因单一;例如把打印与阅读逻辑拆到 BookPrinter
、BookReader
。
2. 开闭原则(OCP)
定义:对扩展开放,对修改封闭。
误区:把它当作业务代码里的“金科玉律”,不管成本高低都要“零修改”;结果往往产生一大堆空壳类。
示例:Spring JDBC 的
AbstractDataSource
,通过继承来扩展读写分离策略,而不修改框架源码,即是 OCP 在框架层面的典型应用。
public abstract class Demo extends AbstractDataSource {private int readDsSize;@Overridepublic Connection getConnection() throws SQLException {return this.determineTargetDataSource().getConnection();}@Overridepublic Connection getConnection(String username, String password) throws SQLException {return this.determineTargetDataSource().getConnection(username, password);}protected DataSource determineTargetDataSource() {if (determineCurrentLookupKey() && this.readDsSize > 0){//读库做负载均衡(从库)return this.loadBalance();} else {//写库使用主库return this.getResolvedMasterDataSource();}}protected abstract boolean determineCurrentLookupKey();//其他代码省略}
思考:在业务代码里,需求迭代快,直接修改往往更高效;在框架、类库或架构层面,才更有必要遵循 OCP,以减少对核心组件的侵入式改动。
3. 里氏替换原则(LSP)
定义:子类必须能够替换父类,并保证行为一致性。
意义:保证多态下的可靠性,让调用者无需感知具体子类,就能正确工作。
示例:自定义 Spring 的
PropertyEditorSupport
,遵循基类契约即可插入各种属性编辑器,URL 参数解析也能“无感”替换。
比如,Spring 中提供的自定义属性编辑器,可以解析 HTTP 请求参数中的自定义格式进行绑定并转换为格式输出。只要遵循基类(PropertyEditorSupport)的约束定义,就能为某种数据类型注册一个属性编辑器。我们先定义一个类 DefineFormat,具体代码如下:
public class DefineFormat{private String rawStingFormat;private String uid;private String toAppCode;private String fromAppCode;private Sting timestamp;// 省略构造函数和get, set方法
}
然后,创建一个 Restful API 接口,用于输入自定义的请求 URL。
@GetMapping(value = "/api/{d-format}",
public DefineFormat parseDefineFormat (@PathVariable("d-format") DefineFormat defineFormat) {return defineFormat;
}
接下来,创建 DefineFormatEditor,实现输入自定义字符串,返回自定义格式 json 数据。
public class DefineFormatEditor extends PropertyEditorSupport {//setAsText() 用于将String转换为另一个对象@Overridepublic void setAsText(String text) throws IllegalArgumentException {if (StringUtils.isEmpty(text)) {setValue(null);} else {DefineFormat df = new DefineFormat();df.setRawStingFormat(text);String[] data = text.spilt("-");if (data.length == 4) {df.setUid(data[0]);df.setToAppCode(data[1]);df.setFromAppCode(data[2]);df.setTimestamp(data[3]);setValue(df);} else {setValue(null);}}}//将对象序列化为String时,将调用getAsText()方法@Overridepublic String getAsText() {DefineFormat defineFormat= (DefineFormat) getValue();return null == defineFormat ? "" : defineFormat.getRawStingFormat();}
}
最后,输入 url: /api/dlewgvi8we-toapp-fromapp-zzzzzzz
,返回响应。
{"rawStingFormat:"dlewgvi8we-toapp-fromapp-zzzzzz","uid:"dlewgvi8we","toAppCode":"toapp","fromAppCode":"fromapp","message":"zzzzzzz"
}
使用里氏替换原则(LSP)的本质就是通过继承实现多态行为,这在面向对象编程中是非常重要的一个技巧,对于提高代码的扩展性是很有帮助的。
要点:不仅要继承接口签名,还要遵守合同(前置条件不变、后置条件不减弱、异常行为不变化)。
4. 接口隔离原则(ISP)
定义:多个特定客户端接口胜过一个通用接口。
误区:只看单个接口中的方法数量,不考虑系统整体职责划分。
示例:
interface ICRUD<T> { add(); update(); delete(); query(); sync(); }
当大多数业务并不需要
sync()
时,就被“强迫”实现,违反 ISP。正确的做法是拆分:interface ICRUD<T> { add(); update(); delete(); query(); } interface ISync { sync(); }
建议:整体系统层面思考职责,避免“一个接口—一个实现”式的过度拆分。
5. 依赖反转原则(DIP)
定义:高层模块不依赖底层模块,二者都依赖抽象;抽象不依赖细节,细节依赖抽象。
内涵:关注“好的抽象”——既能覆盖共性,又能灵活替换实现。
示例:JDBC 接口就是对关系型数据库读写操作的正确抽象,底层可插拔各种数据库驱动。
启示:多用接口/抽象类定义契约,把实现细节隐藏在抽象之后;但切忌为了“抽象”而抽象,仍要结合业务场景。
原则之间的联系与平衡
- SRP 是基础:唯有职责清晰,才有更好的扩展(OCP)、替换(LSP)、隔离(ISP)和抽象(DIP)能力。
- OCP 是归宿:真正做到修改关闭,需要在设计时结合 SRP、ISP、DIP 等原则为目标。
- DIP 更高层次:指导我们在架构级别“分离与替换”——借助抽象解耦模块。
- 取舍:过度拆分会违背 KISS 原则。最简单的折中方法是——控制接口数量,抽象通用职责,避免“一接口—一实现”的刻板模式。
专家:软件工程 > 面向对象设计专家
要求:VERBOSITY = V=2(详细),结合真实项目案例,分享 SOLID 原则在业务系统中的落地实践,语言为中文。
计划
- 简要说明选取案例的背景和项目类型
- 按原则分类,每个案例包含:项目背景、问题痛点、SOLID 原则应用、效果与代码片段
- 总结案例共性与最佳实践建议
案例
一、单一职责原则(SRP)—订单处理模块拆分
项目背景
在某电商平台的后台服务中,OrderService
类同时负责订单校验、库存扣减、支付调用、通知发送等多项功能,业务不断膨胀,迭代时常因小改动引发连锁故障。
问题痛点
- 一处变动可能影响多个流程,回归测试成本高
- 类方法过长、维护困难,责任边界不清晰
SRP 应用
-
拆分职责
OrderValidator
:只做订单合法性校验StockManager
:只做库存预扣与回滚PaymentProcessor
:只负责与支付网关交互NotificationSender
:只负责订单状态变更通知
-
组合调用
public class OrderService {private final OrderValidator validator;private final StockManager stockManager;private final PaymentProcessor paymentProcessor;private final NotificationSender notifier;public void placeOrder(Order order) {validator.validate(order);stockManager.reserve(order);paymentProcessor.pay(order);notifier.send(order);} }
-
效果
- 各模块职责清晰,单元测试覆盖率提升至 90%
- 修改通知逻辑时,无需回归库存或支付流程
二、开闭原则(OCP)—优惠策略引擎
项目背景
促销活动层出不穷,初期将 DiscountService
写成多重 if-else
,每次上线新活动都要改这个类,风险极高。
问题痛点
- 修改封闭,新增促销需频繁改动原有代码
- 条件分支难以维护,代码臃肿
OCP 应用
-
抽象策略接口
public interface DiscountStrategy {BigDecimal calculate(Order order); }
-
各活动实现
@Component public class BlackFridayStrategy implements DiscountStrategy { /*…*/ }@Component public class NewUserStrategy implements DiscountStrategy { /*…*/ }
-
策略注册与调用
@Component public class DiscountService {private final List<DiscountStrategy> strategies;public BigDecimal apply(Order order) {return strategies.stream().filter(s -> s.supports(order)).map(s -> s.calculate(order)).reduce(BigDecimal.ZERO, BigDecimal::add);} }
-
效果
- 新增策略只需编写一个类并注入,无需改动
DiscountService
- 代码体量更易扩展,回归风险大幅降低
- 新增策略只需编写一个类并注入,无需改动
三、里氏替换原则(LSP)—图表渲染组件
项目背景
在后台统计系统中,需要渲染不同类型的图表(折线图、柱状图、饼图)。最初用 Chart
抽象类配合 if (type)
逻辑,后来改用继承。
问题痛点
- 部分子类没有实现所有方法,导致运行时抛出
UnsupportedOperationException
- 修改父类抽象方法会破坏部分子类行为
LSP 应用
-
精炼抽象
public interface ChartRenderer {void render(DataSet data); }
-
具体子类全力支持契约
public class LineChartRenderer implements ChartRenderer { /*…*/ } public class PieChartRenderer implements ChartRenderer { /*…*/ }
-
渲染调用无需分支
rendererMap.get(type).render(data);
-
效果
- 所有子类都能安全替换接口
- 后续新增
RadarChartRenderer
无需改动核心逻辑
四、接口隔离原则(ISP)—外部服务集成
项目背景
一套 CRM 系统需要对接多家短信、邮件、推送服务,最初定义一个 MessagingClient
接口,包含 sendSms
、sendEmail
、sendPush
,导致集成方只需邮件时也要实现短信、推送方法。
问题痛点
- 实现类方法桩多,代码臃肿
- 不同服务方复用率低
ISP 应用
-
拆分接口
public interface SmsClient { void sendSms(SmsMessage msg); } public interface EmailClient { void sendEmail(Email msg); } public interface PushClient { void sendPush(PushMessage msg); }
-
各接入实现各自接口
public class TwilioSmsClient implements SmsClient { /*…*/ } public class SendGridEmailClient implements EmailClient { /*…*/ }
-
按需注入
@Service public class NotificationService {private final SmsClient sms;private final EmailClient email;public void notifyOrderCreated(Order o) {sms.sendSms(...);email.sendEmail(...);} }
-
效果
- 避免“被迫”实现无关方法
- 接口职责更聚焦,单元测试更简洁
五、依赖反转原则(DIP)—仓储层设计
项目背景
某金融系统最初直接在业务层 LoanService
中 new JdbcLoanDao()
,测试时需要配合真实数据库,耦合度高。
问题痛点
- 测试难以模拟,业务层依赖底层实现
- 更换存储方式需改动业务层
DIP 应用
-
抽象 DAO 接口
public interface LoanRepository {Loan findById(String id);void save(Loan loan); }
-
业务层依赖接口
public class LoanService {private final LoanRepository repo;public LoanService(LoanRepository repo){ this.repo = repo; }// … 调用 repo 方法 }
-
底层实现注入
@Repository public class JdbcLoanRepository implements LoanRepository { /*…*/ }
-
效果
- 单元测试可注入内存或 Mock 实现
- 切换到 JPA 或其它存储无业务层改动
案例共性与最佳实践
- 先识别“变化点”,再拆分职责或抽象接口。
- 不要为了原则而原则,关注业务痛点与演进成本。
- 测试驱动设计(TDD) 有助于发现违反 SOLID 的耦合点。
- KISS 平衡:遵循 SOLID 的同时,也要兼顾代码简洁与团队可读性。