设计模式 - 面向对象原则:SOLID最佳实践

文章目录

深入理解 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)

定义 :一个类(或模块)应该只有一个"引起它变化的原因"。
误区:常被简单理解为"一个类只做一件事"、"一个接口只实现一次""写好不能动"......而忘记"职责=变化的原因"这一核心。

示例

java 复制代码
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。

正确做法 :抓住"职责"的边界,职责可由多个类共同完成,但要保证各自变化原因单一;例如把打印与阅读逻辑拆到 BookPrinterBookReader


2. 开闭原则(OCP)

定义 :对扩展开放,对修改封闭。
误区:把它当作业务代码里的"金科玉律",不管成本高低都要"零修改";结果往往产生一大堆空壳类。

示例 :Spring JDBC 的 AbstractDataSource,通过继承来扩展读写分离策略,而不修改框架源码,即是 OCP 在框架层面的典型应用。

java 复制代码
public abstract class Demo  extends AbstractDataSource {
    private int readDsSize;

    @Override
    public Connection getConnection() throws SQLException {
        return this.determineTargetDataSource().getConnection();
    }
    @Override
    public 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,具体代码如下:

java 复制代码
public class DefineFormat{
    private String rawStingFormat;
    private String uid;
    private String toAppCode;
    private String fromAppCode;
    private Sting timestamp;
    // 省略构造函数和get, set方法
}

然后,创建一个 Restful API 接口,用于输入自定义的请求 URL。

java 复制代码
@GetMapping(value = "/api/{d-format}", 
public DefineFormat parseDefineFormat (
    @PathVariable("d-format") DefineFormat defineFormat) {
    return defineFormat;
}

接下来,创建 DefineFormatEditor,实现输入自定义字符串,返回自定义格式 json 数据。

java 复制代码
public class DefineFormatEditor extends PropertyEditorSupport {

    //setAsText() 用于将String转换为另一个对象
    @Override
    public 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()方法
    @Override
    public String getAsText() {
        DefineFormat defineFormat= (DefineFormat) getValue();

        return null == defineFormat ? "" :    defineFormat.getRawStingFormat();
    }
}

最后,输入 url: /api/dlewgvi8we-toapp-fromapp-zzzzzzz,返回响应。

java 复制代码
{
    "rawStingFormat:"dlewgvi8we-toapp-fromapp-zzzzzz",
    "uid:"dlewgvi8we",
    "toAppCode":"toapp",
    "fromAppCode":"fromapp",
    "message":"zzzzzzz"
}

使用里氏替换原则(LSP)的本质就是通过继承实现多态行为,这在面向对象编程中是非常重要的一个技巧,对于提高代码的扩展性是很有帮助的。

要点:不仅要继承接口签名,还要遵守合同(前置条件不变、后置条件不减弱、异常行为不变化)。


4. 接口隔离原则(ISP)

定义 :多个特定客户端接口胜过一个通用接口。
误区:只看单个接口中的方法数量,不考虑系统整体职责划分。

示例

java 复制代码
interface ICRUD<T> { add(); update(); delete(); query(); sync(); }

当大多数业务并不需要 sync() 时,就被"强迫"实现,违反 ISP。正确的做法是拆分:

java 复制代码
interface ICRUD<T> { add(); update(); delete(); query(); }
interface ISync { sync(); }

建议:整体系统层面思考职责,避免"一个接口---一个实现"式的过度拆分。


5. 依赖反转原则(DIP)

定义 :高层模块不依赖底层模块,二者都依赖抽象;抽象不依赖细节,细节依赖抽象。
内涵:关注"好的抽象"------既能覆盖共性,又能灵活替换实现。

示例:JDBC 接口就是对关系型数据库读写操作的正确抽象,底层可插拔各种数据库驱动。

启示:多用接口/抽象类定义契约,把实现细节隐藏在抽象之后;但切忌为了"抽象"而抽象,仍要结合业务场景。


原则之间的联系与平衡

  1. SRP 是基础:唯有职责清晰,才有更好的扩展(OCP)、替换(LSP)、隔离(ISP)和抽象(DIP)能力。
  2. OCP 是归宿:真正做到修改关闭,需要在设计时结合 SRP、ISP、DIP 等原则为目标。
  3. DIP 更高层次:指导我们在架构级别"分离与替换"------借助抽象解耦模块。
  4. 取舍:过度拆分会违背 KISS 原则。最简单的折中方法是------控制接口数量,抽象通用职责,避免"一接口---一实现"的刻板模式。

专家:软件工程 > 面向对象设计专家

要求:VERBOSITY = V=2(详细),结合真实项目案例,分享 SOLID 原则在业务系统中的落地实践,语言为中文。

计划

  1. 简要说明选取案例的背景和项目类型
  2. 按原则分类,每个案例包含:项目背景、问题痛点、SOLID 原则应用、效果与代码片段
  3. 总结案例共性与最佳实践建议

案例

一、单一职责原则(SRP)---订单处理模块拆分

项目背景

在某电商平台的后台服务中,OrderService 类同时负责订单校验、库存扣减、支付调用、通知发送等多项功能,业务不断膨胀,迭代时常因小改动引发连锁故障。

问题痛点

  • 一处变动可能影响多个流程,回归测试成本高
  • 类方法过长、维护困难,责任边界不清晰

SRP 应用

  1. 拆分职责

    • OrderValidator:只做订单合法性校验
    • StockManager:只做库存预扣与回滚
    • PaymentProcessor:只负责与支付网关交互
    • NotificationSender:只负责订单状态变更通知
  2. 组合调用

    java 复制代码
    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);
        }
    }
  3. 效果

    • 各模块职责清晰,单元测试覆盖率提升至 90%
    • 修改通知逻辑时,无需回归库存或支付流程

二、开闭原则(OCP)---优惠策略引擎

项目背景

促销活动层出不穷,初期将 DiscountService 写成多重 if-else,每次上线新活动都要改这个类,风险极高。

问题痛点

  • 修改封闭,新增促销需频繁改动原有代码
  • 条件分支难以维护,代码臃肿

OCP 应用

  1. 抽象策略接口

    java 复制代码
    public interface DiscountStrategy {
        BigDecimal calculate(Order order);
    }
  2. 各活动实现

    java 复制代码
    @Component
    public class BlackFridayStrategy implements DiscountStrategy { /*...*/ }
    
    @Component
    public class NewUserStrategy implements DiscountStrategy { /*...*/ }
  3. 策略注册与调用

    java 复制代码
    @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);
        }
    }
  4. 效果

    • 新增策略只需编写一个类并注入,无需改动 DiscountService
    • 代码体量更易扩展,回归风险大幅降低

三、里氏替换原则(LSP)---图表渲染组件

项目背景

在后台统计系统中,需要渲染不同类型的图表(折线图、柱状图、饼图)。最初用 Chart 抽象类配合 if (type) 逻辑,后来改用继承。

问题痛点

  • 部分子类没有实现所有方法,导致运行时抛出 UnsupportedOperationException
  • 修改父类抽象方法会破坏部分子类行为

LSP 应用

  1. 精炼抽象

    java 复制代码
    public interface ChartRenderer {
        void render(DataSet data);
    }
  2. 具体子类全力支持契约

    java 复制代码
    public class LineChartRenderer implements ChartRenderer { /*...*/ }
    public class PieChartRenderer  implements ChartRenderer { /*...*/ }
  3. 渲染调用无需分支

    java 复制代码
    rendererMap.get(type).render(data);
  4. 效果

    • 所有子类都能安全替换接口
    • 后续新增 RadarChartRenderer 无需改动核心逻辑

四、接口隔离原则(ISP)---外部服务集成

项目背景

一套 CRM 系统需要对接多家短信、邮件、推送服务,最初定义一个 MessagingClient 接口,包含 sendSmssendEmailsendPush,导致集成方只需邮件时也要实现短信、推送方法。

问题痛点

  • 实现类方法桩多,代码臃肿
  • 不同服务方复用率低

ISP 应用

  1. 拆分接口

    java 复制代码
    public interface SmsClient   { void sendSms(SmsMessage msg); }
    public interface EmailClient { void sendEmail(Email msg); }
    public interface PushClient  { void sendPush(PushMessage msg); }
  2. 各接入实现各自接口

    java 复制代码
    public class TwilioSmsClient implements SmsClient { /*...*/ }
    public class SendGridEmailClient implements EmailClient { /*...*/ }
  3. 按需注入

    java 复制代码
    @Service
    public class NotificationService {
        private final SmsClient sms;
        private final EmailClient email;
        public void notifyOrderCreated(Order o) {
            sms.sendSms(...);
            email.sendEmail(...);
        }
    }
  4. 效果

    • 避免"被迫"实现无关方法
    • 接口职责更聚焦,单元测试更简洁

五、依赖反转原则(DIP)---仓储层设计

项目背景

某金融系统最初直接在业务层 LoanServicenew JdbcLoanDao(),测试时需要配合真实数据库,耦合度高。

问题痛点

  • 测试难以模拟,业务层依赖底层实现
  • 更换存储方式需改动业务层

DIP 应用

  1. 抽象 DAO 接口

    java 复制代码
    public interface LoanRepository {
        Loan findById(String id);
        void save(Loan loan);
    }
  2. 业务层依赖接口

    java 复制代码
    public class LoanService {
        private final LoanRepository repo;
        public LoanService(LoanRepository repo){ this.repo = repo; }
        // ... 调用 repo 方法
    }
  3. 底层实现注入

    java 复制代码
    @Repository
    public class JdbcLoanRepository implements LoanRepository { /*...*/ }
  4. 效果

    • 单元测试可注入内存或 Mock 实现
    • 切换到 JPA 或其它存储无业务层改动

案例共性与最佳实践

  1. 先识别"变化点",再拆分职责或抽象接口。
  2. 不要为了原则而原则,关注业务痛点与演进成本。
  3. 测试驱动设计(TDD) 有助于发现违反 SOLID 的耦合点。
  4. KISS 平衡:遵循 SOLID 的同时,也要兼顾代码简洁与团队可读性。