作为关于变化之问的SOLID原则:重构Spring Boot服务
通过思考哪些部分可能会发生变化,以及如何避免SOLID原则常常导致的过度设计问题,学会在Spring Boot后端应用各项SOLID原则。
来源: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me
大多数开发者都能说出SOLID这五个字母代表的含义,但很少有人能解释如何运用这些原则来构建包含数据存储、支付接口和通知渠道的真正Spring Boot服务。当产品经理在周五下午要求通过WhatsApp发送通知时,仅有定义是远远不够的。本指南将通过典型的后端代码逐一讲解这些原则,展示每项原则所建议的代码重构方式,更重要的是,还会指出机械地应用这些原则会如何使代码库变得更糟。
这五项原则的共同点并非“更多接口”或“更小的类”,而是让代码易于修改,这样修改某一行为就不会影响到其他相关部分。下面的示例使用的是 Java 语言,但其原理同样适用于 Node.js 中的 TypeScript 服务;如果您更喜欢那种环境,还有一篇相关文章:通过实用的 JavaScript 示例理解 SOLID 原则。
SOLID 原则试图解决的问题
先从一个订单服务开始,它在同一个方法中完成结账所需的所有操作:验证、持久化存储、发送邮件、生成发票、发出通知以及记录分析数据。
public class OrderService {
public void createOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendEmail(order);
generateInvoice(order);
sendNotification(order);
updateAnalytics(order);
}
}
这里没有任何问题。代码的逻辑是从上到下依次执行的,可以快速通过代码审查,也能在生产环境中稳定运行数月。问题出在后续的需求上:有的利益相关者希望通知通过WhatsApp而非电子邮件发送;还有人要求将发票生成功能移到后台任务中。数据团队又宣布要进行数据库迁移,分析结果则需发布到Kafka上。
所有这些需求最终都落到了同一个类中。虽然代码依然能正常运行,但问题在于每一次小的改动都迫使开发者打开、修改并重新测试一个与其它五个功能相互关联的单元。这种一处改动就会牵连到许多其他部分的耦合现象,正是SOLID原则旨在控制的。
单一职责原则:减少变更的理由
单一职责原则指出一个类应当只有一个职责,实际上也就是只应有一个修改的理由。这一原则常被误理解为“每个类只有一个方法”。这种理解是错误的:只要一个类的所有方法都服务于同一目标,即便它有二十个方法,依然可以保持单一职责。
重要的问题不在于类的规模大小,而在于什么因素会迫使你修改它。如果你能列出几个彼此无关的修改原因,那么这个类很可能承担了多个职责。
识别混合职责
以下是一个在许多代码库中常见的用户注册服务示例。
public class UserService {
public void registerUser(User user) {
validate(user);
saveToDatabase(user);
sendEmail(user);
generateReport(user);
}
}
它承担着四项不同的功能:验证用户、持久化存储数据、发送邮件以及生成报告。这也就意味着有四个独立的理由需要对其进行修改。更换电子邮件服务提供商就需要编辑UserService;切换到不同的数据库同样需要编辑UserService;报告格式的改变则又需要再次编辑UserService。当不同的团队不断修改同一个文件时,就出现了设计上的问题。
将任务委托给协作人员
通过重构,协调逻辑仍保留在UserService中,而那些专业性较强的工作则交由通过构造函数注入的专用协作人员来处理。
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public void registerUser(User user) {
validate(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
由此产生的依赖结构很容易理解:
UserService
|
+---- UserRepository
|
+---- EmailService
UserService 负责决定注册过程中的操作,而 UserRepository 负责数据持久化,EmailService 则负责邮件发送。验证功能仍保留在服务层,因为它本身就是注册规则的一部分;报告功能则完全不在注册流程之内。
在设计时应当避免单纯统计方法数量,而应思考有多少不同的因素会促使你修改某个类。这样的问题能帮助你发现真正的耦合关系,而非仅仅关注表面的代码规模。
开闭原则:在不重新修改稳定代码的情况下添加新功能
所谓软件实体应当对扩展开放而对修改封闭的正式表述听起来很抽象。用通俗的话来说,就是你应该能够在不反复编辑那些已经正常运行且经过测试的代码的情况下引入新功能。
不断增多的条件判断
支付处理就是典型的例子。最初的版本通常是通过字符串来决定处理方式的。
public class PaymentService {
public void pay(String type) {
if (type.equals("CARD")) {
// card payment
}
if (type.equals("UPI")) {
// UPI payment
}
if (type.equals("NET_BANKING")) {
// net banking
}
}
}
对于三种支付方式来说这还行。但后来业务方又增加了钱包、PayPal、Apple Pay以及银行转账等支付方式。每增加一种新的支付方式,就意味着要重新打开PaymentService类,扩展条件判断逻辑,并对现有的所有分支进行重新测试。这样一来,这个类就会逐渐变成一条冗长的if/else链。
用多态性替代分支判断
开闭原则引入了一种抽象概念,用于描述每种支付方式必须完成的任务。
public interface PaymentProcessor {
void process(Payment payment);
}
这样,每种支付方式都可以有自己独立的实现。信用卡处理模块:
public class CardPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// card payment
}
}
而UPI支付处理模块则是:
public class UpiPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// UPI payment
}
}
现在,要添加新的提供者意味着需要编写新类而非修改现有类。已经在生产环境中使用的处理器不会被改动,从而降低了每次变更的影响范围。在 Spring 应用中,通常会将每个处理器注册为 Bean,并通过以支付类型为键的映射来选择合适的处理器,因此甚至无需为选择逻辑新增分支。
当接口毫无用处时
以上内容并不意味着每个类都必须有接口。在 Spring 项目中,一种非常常见的模式就是让接口与其唯一的实现类一一对应:
public interface UserService {
void createUser();
}
背后只有一个类:
public class UserServiceImpl implements UserService {
}
如果不存在实现第二次改动的实际可能,且替换现有实现也无法带来任何好处,那么这个接口就只是多出一个需要处理的文件而已。现代的模拟库已经能够对具体类进行模拟,因此仅从可测试性角度出发也很难成为采用的充分理由。SOLID原则强调的是为预期的变化进行设计,而非机械地让每个类都包裹在抽象层中。
里斯科夫替换原则:遵守契约,而不仅是签名
里斯科夫替换原则指出,在需要使用父类型的任何地方,都应能使用其子类型,且不会破坏程序的正确性。在这五项原则中,它往往显得最为理论化,因此可以借助支付系统的例子来说明。
抽象层与之前相同:
public interface PaymentProcessor {
void process(Payment payment);
}
卡片实现满足了接口所承诺的功能:
public class CardPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
// process payment
}
}
目前一切正常。但后来有人添加了一个用于现金支付的处理器,实际上它根本无法处理任何在线交易:
public class CashPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
throw new UnsupportedOperationException();
}
}
编译器认为没有问题,因为方法签名匹配。然而实际行为却违背了最初的承诺。任何持有 PaymentProcessor 对象的调用者都会合理地认为调用 process 方法就能完成支付处理。但在这种实现下,看似无害的遍历所有处理器的循环却可能在运行时突然抛出异常,于是调用者不得不添加 instanceof 检查或 try/catch 块来保护自己,这显然表明抽象层已经出现了漏洞。
在编写实现代码时,需要思考的问题是它是否真能如抽象层所承诺的那样运行。如果诚实的答案是否定的,通常的解决办法是修改抽象层,例如将离线支付单独建模,或把相关功能拆分到更具体的接口中。里斯科夫原则关注的是行为正确性,而非implements关键字是否能通过编译。
接口隔离:避免使用者依赖他们不需要的功能
接口隔离原则指出,不应强迫任何客户端依赖那些它并不使用的方法。大型、包罗万象的接口往往是这一原则被违反的典型表现。
一个面向所有人的接口
想象这样一个整合了员工管理、薪资发放、休假审批和报表功能的接口:
public interface EmployeeOperations {
void createEmployee();
void updateEmployee();
void deleteEmployee();
void generateSalary();
void approveLeave();
void generateReport();
}
报表生成组件仅需执行一项操作:
generateReport();
但由于它依赖于EmployeeOperations,因此会与创建、删除、薪资及休假处理等它根本不会用到的逻辑绑定在一起。
按使用方需求拆分
更好的设计方式是提供独立的、专注的接口。一个用于管理员工记录:
public interface EmployeeManagement {
void createEmployee();
void updateEmployee();
void deleteEmployee();
}
另一个则用于报表生成:
public interface EmployeeReporting {
void generateReport();
}
这样每个使用方就只依赖其实际需要的功能。虽然一个类仍可同时实现这两项功能,但分离设计能确保客户端只能看到所需的部分。
随着系统发展为何这点很重要
在小型应用中,过大的接口只是些小麻烦。而在大型应用中,它会导致各种意外的耦合现象,一个使用者的变更会影响到许多无关的使用者。较小的接口能让依赖边界更加清晰,也更容易理解。
其注意事项与开闭原则相同:不要仅仅为了满足要求就将接口拆分成碎片。只有当不同的客户端确实需要不同行为子集时,才应该对接口进行拆分。
依赖倒置:让业务逻辑依赖于抽象
依赖倒置原则要求业务层代码和基础设施层代码都依赖于共享的抽象,而不是让策略层直接引用具体的低级类。在Spring Boot应用中,这一原则可以说是五项原则中最具影响力的。
硬编码的基础设施
在这里,订单服务会创建自己的数据存储对象:
public class OrderService {
private final MySqlOrderRepository repository;
public OrderService() {
this.repository = new MySqlOrderRepository();
}
}
业务逻辑现在能够明确知道自己的数据存储在哪种数据库技术中。若要更换 MySQL,或是在单元测试中使用模拟的数据存储对象,就必须修改 OrderService 本身。高层策略与底层细节相互关联。
反转依赖关系
首先定义业务逻辑实际所需的功能,且这些功能不应依赖于具体的存储技术:
public interface OrderRepository {
void save(Order order);
}
接着让针对 MySQL 的类成为该接口的一种实现:
@Repository
public class MySqlOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
// save order
}
}
最后,让服务通过构造函数来获取该抽象接口:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService 已不再关心订单最终存储在何处。其实现可以是 MySQL、PostgreSQL、MongoDB,也可以是调用其他服务,或是测试中使用的内存模拟对象。知识流向发生了转变:现在是基础设施遵循业务层定义的接口,而非业务层去适应基础设施。
Spring 的依赖注入如何支持这些原则
由于 Spring 的容器负责自动配置依赖关系,因此它让依赖倒置原则显得十分自然。当某个服务在构造函数中声明所需的协作对象时,Spring 会找到类型匹配 OrderRepository 的 bean 并将其传入。
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
该类从不自行创建其依赖对象。这与在业务代码中直接实例化依赖的对象形成对比:
OrderRepository repository =
new MySqlOrderRepository();
那一行代码将调用者与特定类绑定在一起,若不修改它则无法进行替换。构造函数注入将耦合度控制在抽象层面,并使依赖关系在类签名中清晰可见,这也是它通常比字段注入更受青睐的原因。需记住,依赖注入是一种机制,而依赖倒置则是一种设计原则:容器帮助你连接各种抽象,但你仍需自行决定要定义哪些抽象。
SOLID并不意味着更多的类
那些刚刚接触 SOLID 的团队有时会做得过火。原本一个用户功能最终会演变成十几种类型:
UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor
如果在整个代码库中都采用这种方式,最终会生成四十个类,而其中八个类就能更清晰地表达相同的设计。这属于过度设计,而非良好的架构。设计原则的存在就是为了降低复杂性;如果应用某项原则反而让代码更难理解,那很可能是机械地照做,而非针对实际的变更需求。
从可能发生变化的部分入手
有经验的后端工程师不会先思考何处可以应用SOLID原则,而是会从另一个问题开始:该组件中哪些部分可能发生变化?答案与这些设计原则有着直接的对应关系:
- 数据库可能会变化:引入数据访问层抽象。
- 支付服务提供商可能会变化:将各提供商封装在支付抽象层之后。
以这种方式思考能让这些原则更加扎实。在变化频繁的地方添加抽象层,让稳定的代码保持简洁。
将相同思路应用于服务边界
这种思维方式可扩展到单个类之外。想象有一个可部署的服务负责处理所有这些领域:
User
Payment
Notification
Reporting
Inventory
Shipping
它或许能完美运行,但用户管理、支付处理、通知发送、报表生成、库存控制以及物流配送等功能会因不同原因发生变化,且往往由不同团队在不同的时间节点进行处理。单一职责原则背后的逻辑有助于你找出合理的功能边界。
这并不意味着每个业务概念都值得单独成为一个微服务。SOLID原则并非要求增加更多服务,而是指导如何合理划分职责与依赖关系,无论这些边界最终以模块、包还是服务的形式呈现。
后端组件的审查清单
在认定某个重要的后端组件已完成开发之前,需针对每一条原则逐一检查:
- 单一职责:该类是否存在多个互不相关的修改原因?
对这些问题给出诚实的回答,比能够背诵定义更有价值。
核心要点
- 这五项原则的目标都是相同的:让后续的变更成本低且影响范围小,从而使更换数据库、添加支付提供商或更改通知渠道变得像常规操作而非高风险行为。
new SomeImplementation()则会引发“这个类真的需要知道这些吗?”的疑问。