Java 中的 SOLID 原则:让代码易于修改的五条规则
Java 中 SOLID 五条原则的实操、代码优先讲解——单一职责、开闭、里氏替换、接口隔离、依赖倒置——通过"糟糕版到干净版"的对比示例,以及关于何时真正该应用它们的诚实指引。
大多数代码不会因为错而崩溃,而会因为难以修改而崩溃。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(里氏替换原则)
子类必须能替换掉父类,而调用方不会出现意外。
问题
经典例子是 Rectangle 和 Square。在几何里正方形是矩形,那么代码里为什么不能继承?
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 就是段落。
我怎么知道自己在抽象上走过头了?
三个迹象:要看三个文件才能跟住一个方法在做什么;你的接口只有一个实现,且看不出第二个有可能存在;新同事入职后过了很久才能动任何东西。抽象赚回成本是因为它隐藏了一个真正的第二种变化。如果没有,删掉一层往往读起来像松了一口气。