Skip to main content
Innovation|Innovation

Java 中的 SOLID 原则:让代码易于修改的五条规则

Java 中 SOLID 五条原则的实操、代码优先讲解——单一职责、开闭、里氏替换、接口隔离、依赖倒置——通过"糟糕版到干净版"的对比示例,以及关于何时真正该应用它们的诚实指引。

2026年5月6日5 min read

大多数代码不会因为错而崩溃,而会因为难以修改而崩溃。SOLID 就是为后一个问题准备的五条小规则。

为什么 SOLID 仍然重要

我还记得第一次给别人写好的 codebase 添加功能的那天。我在一个文件里改了三行,结果项目里某处的四个测试就红了。代码在技术上没错,只是没有为下一个人考虑。

SOLID 就是对那段经历的回应。这是 Robert C. Martin 在 2000 年代初命名的五条原则,帮助面向对象的代码在成长中保持灵活。它们不是法律,第一天也不一定是正确选择。但当你在 Java 里维护任何稍微复杂的项目大约三年之后,你会开始理解它们存在的理由。

这个缩写代表:

  • S — Single Responsibility Principle(单一职责原则)
  • O — Open/Closed Principle(开闭原则)
  • L — Liskov Substitution Principle(里氏替换原则)
  • I — Interface Segregation Principle(接口隔离原则)
  • D — Dependency Inversion Principle(依赖倒置原则)

本指南通过一个具体的 Java 例子讲解每条原则:糟糕版本、SOLID 版本,以及一句话回答"我什么时候真的会用到?"。例子刻意做得很小——pattern 在草图大小看清后,就更容易认出。

S — Single Responsibility Principle(单一职责原则)

一个类只应该有一个变化的理由。

问题

看一个做了太多事的类。在编辑器里看起来还好,但每一次需求变化都会动它。

public class Invoice {
    private List<LineItem> items;
    private Customer customer;

    public double calculateTotal() {
        double total = 0;
        for (LineItem item : items) {
            total += item.getPrice() * item.getQuantity();
        }
        return total;
    }

    public String generatePdf() {
        // builds a PDF using iText
        return "...pdf bytes...";
    }

    public void sendEmail(String address) {
        // opens an SMTP connection, attaches the PDF, sends
    }

    public void saveToDatabase() {
        // opens a JDBC connection, runs INSERT
    }
}

这个类可能有四个变化原因:定价规则改了、PDF 库换了、市场部要不同的邮件模板、数据库从 MySQL 迁到 Postgres。四个团队可能同时在编辑同一个文件。

解决

按职责拆分。每个类负责一件事。

public class Invoice {
    private final List<LineItem> items;
    private final Customer customer;

    public Invoice(List<LineItem> items, Customer customer) {
        this.items = items;
        this.customer = customer;
    }

    public double calculateTotal() {
        return items.stream()
            .mapToDouble(i -> i.getPrice() * i.getQuantity())
            .sum();
    }

    public List<LineItem> getItems() { return items; }
    public Customer getCustomer() { return customer; }
}

public class InvoicePdfRenderer {
    public byte[] render(Invoice invoice) { /* ... */ }
}

public class InvoiceEmailer {
    public void send(Invoice invoice, String address, byte[] pdf) { /* ... */ }
}

public class InvoiceRepository {
    public void save(Invoice invoice) { /* ... */ }
}

现在每个变化都有自己的家。SMTP 服务器换了,只有 InvoiceEmailer 会被打开。

什么时候用

当一个类的职责描述里开始堆"并且"——"计算总额并且发邮件并且写数据库"——就拆开它。"并且"句子的形状是 SRP 给你最清晰的信号。

O — Open/Closed Principle(开闭原则)

代码应当对扩展开放、对修改封闭。

问题

你写了一个折扣计算器。每加一个新的客户等级,就要在同一个方法里加一个 if 分支。

public class DiscountCalculator {
    public double apply(Customer customer, double total) {
        if (customer.getTier().equals("REGULAR")) {
            return total;
        } else if (customer.getTier().equals("PREMIUM")) {
            return total * 0.90;
        } else if (customer.getTier().equals("VIP")) {
            return total * 0.80;
        } else if (customer.getTier().equals("EMPLOYEE")) {
            return total * 0.70;
        }
        return total;
    }
}

加一个 "STUDENT" 等级意味着编辑这个类。编辑意味着重新测试每一个旧等级。这个类对变化没有关闭。

解决

把每条规则推到接口后面。新等级以新类的形式出现。

public interface DiscountPolicy {
    boolean appliesTo(Customer customer);
    double apply(double total);
}

public class RegularDiscount implements DiscountPolicy {
    public boolean appliesTo(Customer c) { return c.getTier().equals("REGULAR"); }
    public double apply(double total) { return total; }
}

public class PremiumDiscount implements DiscountPolicy {
    public boolean appliesTo(Customer c) { return c.getTier().equals("PREMIUM"); }
    public double apply(double total) { return total * 0.90; }
}

public class DiscountCalculator {
    private final List<DiscountPolicy> policies;

    public DiscountCalculator(List<DiscountPolicy> policies) {
        this.policies = policies;
    }

    public double apply(Customer customer, double total) {
        return policies.stream()
            .filter(p -> p.appliesTo(customer))
            .findFirst()
            .map(p -> p.apply(total))
            .orElse(total);
    }
}

添加学生等级现在就是一个新文件:StudentDiscount implements DiscountPolicy。原有的类完全没动。在 Spring 中,你只需把它注册为 @Component,框架自然把列表注入进来。

什么时候用

当你看到一连串 else if,每来一个"同一类事物"的新变体就增长,那种增长就是味道。每个分支都想成为一个类。

L — Liskov Substitution Principle(里氏替换原则)

子类必须能替换掉父类,而调用方不会出现意外。

问题

经典例子是 RectangleSquare。在几何里正方形矩形,那么代码里为什么不能继承?

public class Rectangle {
    protected int width, height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int area() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w;
    }
    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h;
    }
}

现在写一个使用矩形的方法:

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20;  // FAILS for Square — area is 16
}

正方形满足了 extends Rectangle,却破坏了所有假定宽高相互独立的调用方。这种继承是个谎言。

解决

不要硬撑这种关系。两种形状都实现一个共同的接口;谁也不继承谁。

public interface Shape {
    int area();
}

public class Rectangle implements Shape {
    private final int width, height;
    public Rectangle(int w, int h) { this.width = w; this.height = h; }
    public int area() { return width * height; }
}

public class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    public int area() { return side * side; }
}

现在 Shape 只承诺真正成立的事:每个 shape 都有面积。没有调用方被误导。

什么时候用

每当你想用 extends 时,问一句:我能在父类工作的任何地方用子类替代而不修改调用代码吗?如果你必须 override 方法去抛出 UnsupportedOperationException,那不是继承——那是道歉。改用接口或组合。

I — Interface Segregation Principle(接口隔离原则)

不要强迫客户端依赖它们用不到的方法。

问题

一个庞大的接口,每个实现者都得满足。

public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void writeCode();
    void writeReport();
}

public class Robot implements Worker {
    public void work() { /* OK */ }
    public void writeCode() { /* OK */ }

    public void eat() { throw new UnsupportedOperationException(); }
    public void sleep() { throw new UnsupportedOperationException(); }
    public void attendMeeting() { throw new UnsupportedOperationException(); }
    public void writeReport() { throw new UnsupportedOperationException(); }
}

机器人被迫对自己一半方法撒谎。更糟的是,每次接口扩展,所有实现者都坏掉,直到他们再加一个空方法。

解决

多个小接口,只被真正需要的类拿起。

public interface Workable { void work(); }
public interface Eatable   { void eat(); }
public interface Sleepable { void sleep(); }
public interface Coder     { void writeCode(); }

public class Robot implements Workable, Coder {
    public void work() { /* ... */ }
    public void writeCode() { /* ... */ }
}

public class Human implements Workable, Eatable, Sleepable, Coder {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
    public void writeCode() { /* ... */ }
}

每个类只实现与它本性相符的契约。不需要为了满足要求过多的父契约而抛异常。

什么时候用

味道是空方法、UnsupportedOperationException,或者注释写着"对该类型不适用"。每一个都是接口在告诉你:早该拆开了。

D — Dependency Inversion Principle(依赖倒置原则)

依赖抽象,而不是依赖具体类。

问题

一个高层类直接钻进低层类。

public class MySqlOrderRepository {
    public void save(Order order) {
        // JDBC calls to MySQL
    }
}

public class OrderService {
    private final MySqlOrderRepository repository = new MySqlOrderRepository();

    public void placeOrder(Order order) {
        // business rules
        repository.save(order);
    }
}

service 被焊死在 MySQL 上。换数据库意味着改 OrderService。写单元测试意味着启一个真的 MySQL。高层策略(下单)受低层细节(哪个数据库)摆布。

解决

service 依赖一个接口;选择哪个数据库是从外部注入的细节。

public interface OrderRepository {
    void save(Order order);
}

public class MySqlOrderRepository implements OrderRepository {
    public void save(Order order) { /* ... */ }
}

public class PostgresOrderRepository implements OrderRepository {
    public void save(Order order) { /* ... */ }
}

public class InMemoryOrderRepository implements OrderRepository {
    private final List<Order> saved = new ArrayList<>();
    public void save(Order order) { saved.add(order); }
    public List<Order> all() { return saved; }
}

public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void placeOrder(Order order) {
        // business rules
        repository.save(order);
    }
}

生产环境里你接 PostgresOrderRepository。单元测试里你接 InMemoryOrderRepository 并断言 all()。业务规则毫无察觉。

什么时候用

任何一处 new 关键字旁边出现的是一个该类不该关心的东西——数据库驱动、HTTP 客户端、时钟、随机数生成器——那个 new 就是隐藏的依赖。把它换成构造器参数和接口,可测试性和可替换性就免费来了。

五条规则如何协同

五条原则不是五个独立练习。它们是同一思想的五个视角:能让你插入变化的代码

  • SRP 定义单元。一个变化的理由。
  • OCP 定义接缝。新行为放进新类。
  • LSP 让接缝诚实。可信赖的可替换性。
  • ISP 让接缝狭窄。别让我依赖我用不到的东西。
  • DIP 让接缝向外。策略依赖抽象;细节从外部插入。

合起来,它们描绘一个小巧、有教养的类——专注的职责、清晰的契约、可读的接线图。Spring Boot、Quarkus 以及 Java 里大家日常用的多数框架都是 SOLID 工厂——把"做对的事"变成最省力的路径。

常见错误

第一天就过度应用。一个开发者、三个类的周末项目不需要给每个协作者来一个接口。SOLID 在 codebase 活过最初的作者之后才赚回它的成本。过早抽象是一种自己的债。

把 SRP 误读成"每个类一个方法"。一个类可以有很多方法,只要它们都为同一个职责服务。SRP 说的是变化的理由,不是方法的数量。

把 LSP 当成继承谜题。更深的规则是"尊重父类承诺的契约"。如果子类削弱了某个保证——收窄输入、抛出新异常、改动父类不该改动的状态——调用方就崩溃。

误以为 DI 容器替你完成了 DIP。在具体类上写 @Autowired 不是 DIP。原则讲的是依赖抽象,框架只是让接线没那么啰嗦。

把接口拆得过头。ISP 不是"每个接口一个方法"。如果三个方法在真实调用者那里始终一起出现,它们就该在一起。味道是调用处用不到的方法,不是接口里多于一个方法。

最后一个想法

SOLID 在 2000 年的一篇论文里得名,那篇论文主要描述了人们已经摸索了十年的实践。这是每条好工程原则的诚实起源:有人看够了代码腐烂,给腐烂得最慢的形状起了名字,写了下来。要点从来不是背口诀。要点是认出形状——四件事的类、长长的 if 阶梯、说谎的子类、臃肿的接口、焊死的依赖——快到修起来便宜。

软件不必衰老成脆弱的样子。它可以变成那种未来的你或下一个人,在某个周二上午拿起来、毫不犹豫就能修改的样子。这五条规则给你的就是这个。

常见问题

我需要把 SOLID 应用到每一个类吗?

不需要。SOLID 在那种会被很多人、很多次、很长时间编辑的代码里才挣回它的分量。一次性脚本或 50 行的工具类不需要构造器注入的抽象层。当复杂度或变化压力出现时再启用原则——以后重构的成本是真实的,但现在过早抽象的成本也一样。

SOLID 和 Strategy、Factory 这类设计模式有什么不同?

SOLID 是为什么。设计模式是满足 SOLID 的常见形状。Strategy 模式是遵守 OCP 的一种方式。依赖注入是遵守 DIP 的一种方式。Adapter 模式常常是修复 LSP 违例的办法。没有原则的模式变成 cargo cult;没有模式的原则让你不停重新发明轮子。

Spring Boot 会让我的代码自动 SOLID 吗?

它去掉了一些摩擦,但不强制原则。@Autowired 在具体类上仍然把你绑死在那个类上。@Service 注解的 1000 行 god class 仍然违反 SRP。Spring 是个工具——原则告诉你你是否在用得好。

SOLID 和 clean architecture 的关系是什么?

Clean architecture(以及相关的"端口与适配器"/六边形架构)就是 SOLID 在系统层面的样子。SRP 变成"领域逻辑放一层、基础设施放另一层"。DIP 变成"领域依赖接口;适配器在边缘插入"。如果 SOLID 是语法,clean architecture 就是段落。

我怎么知道自己在抽象上走过头了?

三个迹象:要看三个文件才能跟住一个方法在做什么;你的接口只有一个实现,且看不出第二个有可能存在;新同事入职后过了很久才能动任何东西。抽象赚回成本是因为它隐藏了一个真正的第二种变化。如果没有,删掉一层往往读起来像松了一口气。

More from Innovation