从 NPE 到高内聚:Spring 构造器注入的真正价值

将直觉转化为文字,解释一些人们直觉上早已了然于心,却尚未能言说的内容。

前言

这篇文章是从《图与循环依赖、死锁(一) 循环依赖绪论》拆出来的,在写这篇文章的时候,想到了Spring为什么推荐构造器注入,于是我就产生了很多想法。本来想在那篇文章里面展开,感觉这不是那篇文章的主题,就把这部分内容单独独立出来了。那Spring为什么推荐构造器注入呢,一个是构造器注入的Bean在使用的时候是完整的,避免一定程度的空指针风险。一个是构造器注入能够明确依赖,提示开发者当前的类承担了太多职责,需要重构。

接下来我将会通过具体的实例来说明构造器注入是如何避免运行期空指针的,为什么说一个类依赖注入太多是职责不够单一,不够单一的后果是什么。然后演示通过事件监听、AOP、设计模式等方法来消除代码里面的坏味道。

为什么推荐构造器注入

在Spring的官方文档里面是这么说的:

"The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null. Furthermore, constructor-injected components are always returned to the client (calling) code in a fully initialized state. As a side note, a large number of constructor arguments is a bad code smell, implying that the class likely has too many responsibilities and should be refactored to better address proper separation of concerns."

在Spring里面,常见的注入方式有setter注入、字段注入、构造器注入:

typescript 复制代码
// 字段注入
@Component
public class A01Service {  
    
    @Autowired(required = false)
    private  A02Service a02Service;
    
   public void send(){
       a02Service.send();
   }
}
// 构造器注入 方式一
@Component
public class A02Service {
    
    private final A01Service a01Service;
    
    public A02Service(A01Service a01Service) {
        this.a01Service = a01Service;
    }
}
// 构造器注入 方式二
@Component
public class A02Service {
    
    private A01Service a01Service;
    
    public A02Service(A01Service a01Service) {
        this.a01Service = a01Service;
    }
}
​
// set 注入
@Component
public class A02Service {
    
    private  A01Service a01Service;
    
    @Autowired
    public void setA01Service(A01Service a01Service) {
        this.a01Service = a01Service;
    }
}

注意到在第一个字段方式里面我们的声明, 我们是不要求容器里面一定要有这个Bean的。那么意味着,这个Bean在项目启动之后, 可能并没有初始化完成。也就是说现在我们在其他Bean里面再注入A01Service,来调用send方法, 就会出现空指针异常。但是构造器注入就避免了这个问题,因为创造A02Service这个Bean, 需要在容器里面寻找这个Bean,但是容器里面没有,就会导致启动失败。

这就是用构造器注入的好处之一,如果一个Bean依赖了若干个Bean,那么通过构造器注入,这些被依赖的Bean在这个Bean里面都不为空,这一定程度上保证了安全性。所谓的安全性我们可以理解为,方法调用链中不会出现异常打断这个执行链。但是注意到我们在构造器注入的第一种方式中加入了final证明,这个final声明的好处在哪里呢?

final的好处

final在Java里面表达是不可变性, 从语法上来说,当我们声明这个变量是final的时候,我们必须对这个变量初始化:

kotlin 复制代码
public class A02Service {
    private  final  A01Service a01Service;
}

这样的声明, 编译器给出编译报错: java: 可能尚未初始化变量a01Service。但是当你初始化之后,这个变量不能被修改引用:

java 复制代码
public class A02Service {
    
    private  final  A01Service a01Service = new A01Service();   
    
    public void test(){
        a01Service = new A01Service();
    }    
}

java: 无法为最终变量a01Service分配值。这是final关键字的另一个作用,除此之外,将final标记为final会强制编译器在构造函数完成之前, 完成字段的初始化。在JMM中final有一定程度的happens-before语义,会有一个storestore内存屏障,但这并不是本篇讨论的主题,我们会在一篇文章专门讨论。

小小总结一下

我想Spring官方推荐构造器注入的动机应当是保证对象的完整语义,不会出现没有初始化完成的Bean, 导致出现异常情形。另一个动机我想应当是可以看出这个类依赖了多少,职责不够单一。由此就引出了参数过多到职责拆分。

参数过多到职责拆分

Spring官方文档的原文为: " As a side note, a large number of constructor arguments is a bad code smell, implying that the class likely has too many responsibilities and should be refactored to better address proper separation of concerns", 中文译为: 顺便一说,如果一个构造器参数过多,那就是一种代码异味。往往该类承担了过多的职责,应当通过重构来实现更合理的关注点分离。

我当然同意这段话,又觉得这句话很显然,这是一段抽象的话,描述的是一类现象,一个类依赖了太多其他的类。但抽象也要返回到具体上更容易理解一些,比如数是对量的抽象。让我们来看下面一个例子,比如我们以审批流为例,当我们发起一个请假或者加班请求,这个请求对应的Service需要做消息通知,这个消息通知是一个泛化的说法,消息里面包含的有短信、飞书、邮件等等。我们的一个Service如下所示:

ini 复制代码
class ApprovalService {
​
    private final ApplicationRepository    appRepo;
    private final ProcessInstanceRepository piRepo;
    private final ApprovalRecordRepository  recordRepo;
​
    private final SmsSender  sms;
    private final FeishuSender feishu;
    private final EmailSender email;
    private final DingSender  ding;
​
    private final FinanceService finance;
    private final AuditLogger    audit;
    private final IdGenerator    idGen;
​
    public ApprovalService(ApplicationRepository appRepo,
                           ProcessInstanceRepository piRepo,
                           ApprovalRecordRepository recordRepo,
                           SmsSender sms, FeishuSender feishu,
                           EmailSender email, DingSender ding,
                           FinanceService finance, AuditLogger audit,
                           IdGenerator idGen) {
​
        this.appRepo = appRepo;
        this.piRepo  = piRepo;
        this.recordRepo = recordRepo;
​
        this.sms   = sms;
        this.feishu= feishu;
        this.email = email;
        this.ding  = ding;
​
        this.finance = finance;
        this.audit   = audit;
        this.idGen   = idGen;
    }
​
    public String start(String applicant,double amount){
        String appId = idGen.next();
        appRepo.save(appId);
​
        String piId = "PI-"+idGen.next().substring(0,8);
        piRepo.save(piId);
​
        recordRepo.save(piId,"START");
​
        notifyAll(applicant,"流程 "+piId+" 创建成功");
        finance.book(appId,amount);
        audit.log("APP_START",appId);
        return piId;
    }
​
    public void approve(String piId,String node,String approver,boolean pass){
        recordRepo.save(piId,node+(pass?"_PASS":"_REJECT"));
        audit.log("APPROVE_"+node,piId);
​
        String msg = "流程 "+piId+" 在节点 "+node+
                     (pass?" 已通过":" 被驳回")+",处理人:"+approver;
        notifyAll(approver,msg);
    }
​
    private void notifyAll(String who,String msg){
        sms.send(who,msg);
        feishu.send(who,msg);
        email.send(who,msg);
        ding.send(who,msg);
    }
}

上面的代码做了下面几件事:

  1. 创建单数据
  2. 创建待审批流程数据
  3. 创建审批记录
  4. 发送短信,发送钉钉,发送飞书,发送邮件
  5. 添加操作日志
  6. 通知财务系统

味道坏在哪里

结构失控

那上面的代码味道坏在哪里? 从功能上来说也是可以实现的,功能正常运行。我们引入的假设就是变化,我们的假设是每个审批节点都会触发一些消息。我们引入了新的消息通知,比如企业微信,那么我们就需要在每一个节点后面引入微信发送类,然后每一个加一下。可能到时候引入的消息通知越来越多,每次都要将所有流程节点的触发方法都改一下,这样并非是我们想需要的, 那么事实上面的类负担了一部分发送消息的职责,我们可以将消息服务平移到一个专门的消息服务中,然后在审批通过的事务提交之后,将这个事件扩散出去。

注意上面的通知财务系统,这可能是一次rpc调用,我们的审批节点流程事务提交完成之后,将这个消息提交出去就行,也可以通过事件进行扩散,在Spring的语境下,我们只需要写出如下代码即可:

typescript 复制代码
@Autowired
public ApplicationEventPublisher applicationEventPublisher;
​
public  void sendMsg(){
    applicationEventPublisher.publishEvent(new AuditEvent());
}

然后我们将消息相关的服务用策略模式组合一下,然后做分发就可以了:

typescript 复制代码
public enum MessageType {
    FEISHU,     // 飞书
    SMS,        // 短信
    EMAIL,      // 邮件
    DINGTALK   // 钉钉
}
​
public  interface MessageHandler {
      boolean supportType(MessageType messageType);
    
      void sendMsg(MessageRequest messageRequest);
}
@Component
public class DingTalkMessageHandler implements MessageHandler {
    @Override
    public boolean supportType(MessageType messageType) {
        return MessageType.DINGTALK.equals(messageType);
    }
    @Override
    public void sendMsg(MessageRequest messageRequest) {
​
    }
}
@Component
public class SmsMessageHandler implements MessageHandler{
    @Override
    public boolean supportType(MessageType messageType) {
        return MessageType.SMS.equals(messageType);
    }
    @Override
    public void sendMsg(MessageRequest messageRequest) {
​
    }
}
@Component
public class MessageContext {
​
    @Autowired
    private List<MessageHandler> messageHandlers;
​
    @EventListener(MessageRequest.class)
    public void  sendMessage(MessageRequest messageRequest){
        for (MessageHandler messageHandler : messageHandlers) {
            if (messageHandler.supportType(messageRequest.getMessageType())) {
                messageHandler.sendMsg(messageRequest);
            }
        }
    }
}

这种我们就将ApprovalService依赖的几个消息Service平移了出去,将这些消息服务进行了结构化,所谓结构化就是将这些消息服务通过设计模式联系起来。通过MessageHandler我们就知道当前系统引入了多少个消息服务。所谓结构也就是组成元素之间的联系,一个类依赖太多其他类,太多的依赖,意味着这个类承担了其他类的职能。就像是一个房间是卧室,又是厨房,在这个房间里面加入一点新的变化,不小心就会将酱油打翻。

上面事实上给出了代码治理的第一种方式,就是观察相似性进行聚拢,从语义上分析短信、钉钉都是消息服务,只是类型不同,因此我们可以将其聚拢在一起。后面的财务系统通知我们可以抽象到第三方消息通知中,也可以用事件监听,将财务相关的Service也平移出去。以后如果有新的通知需要引入,我们只需要加监听订阅就行。

同理,操作记录日志相关也可以将其平移出去,如果审批流的每一个操作完成都要记录操作日志,我们事实上也可以用AOP将这些代码都平移到一个类里面进行处理。这样又减少了两个Service。 注意到上面的IdGenerator,用来产生ID,这个事实上基本和所有数据库相关的都需要,那么我们可以将其作为工具类是一种思路。或者我们可以在ORM框架里面做一些处理,做默认填充。

以上就是处理结构性失控的一些基本思路,通过事件监听订阅,将对应的对象动作变为订阅消息。然后通过策略模式,将这些消息服务结构化,方便拓展方便进行维护。如果是一类审批动作的后置操作或者前置操作,我们也考虑用事件监听,也考虑用AOP代理这种方式来给主流程瘦身,

现在我们已经有了处理依赖过多的一些手段,事件发布订阅和AOP将依赖平移,通用能力作为基础设施作为工具类,或者是做成注解的形式来将职责平移。 这些手段的着眼点都在弱化错误的联系,消息服务应当更独立一些,不应该是被注入到所有需要消息能力的类里面。 我们也通过接口强化了消息服务之间的联系,这是强化联系的另一种形式。

我们的审批流,每个节点都会牵扯审批拒绝这些选项,我们可以为每个节点写一条处理逻辑,这些处理逻辑散落在代码里面。如果我想改动审批逻辑,基本上的思路是找注释,或者去问熟悉的人。那么我们就可以通过状态机将这些处理逻辑关联起来,我们就能通过状态机方便的找对应状态的流转逻辑。我们强化内聚和弱化耦合的设计目标始终是为后面更多的变化来做准备,降低维护的成本。

行为失控简介

我们接着回到消息服务里面,通常情况下,短信的供应商也会有许多家,比如阿里、腾讯云、华为云等等。 我们在SmsMessageHandler里面的策略就变成了:

typescript 复制代码
@Component
public class SmsMessageHandler implements MessageHandler{
    @Override
    public boolean supportType(MessageType messageType) {
        return MessageType.SMS.equals(messageType);
    }
    @Override
    public void sendMsg(MessageRequest messageRequest) {
        if("ALIYUN".equals()){
            
        }else if("tengxun".equals()){
            
        }else if("hauweiyun".equals()){
            
        }else if() {
                        
        }else(){
            
            
        }      
    }
}

随着我们短信供应商越来越多,这里的逻辑就会越来越多,越来越复杂。方法是对象的行为,这种情况下我们就可以认为是行为失控,我们也可以将这些if else 转为策略模式,再度进行拆分,让一个类只负责一家短信供应商的行为。从这个角度来说,软件设计具备分形的特征。

另一种软件行为失控就来自于需求和运营的不匹配,电商场景变化多端,有时候会在不同的节日进行不同的折扣计算,还有叠加新人的优惠、会员用户的折扣,满足一定的件数打一定的折扣,我们就可以这么写:

less 复制代码
@Service
public class DiscountService {
​
    public BigDecimal calculatePrice(Order order, User user, LocalDateTime now) {
        BigDecimal origin = order.totalAmount();   // 原价
        BigDecimal price  = origin;                // 待折后价
        
        // 1. 国庆 8 折
        LocalDate holidayStart = LocalDate.of(now.getYear(), 10, 1);
        LocalDate holidayEnd   = LocalDate.of(now.getYear(), 10, 7);
        if (!now.toLocalDate().isBefore(holidayStart) &&
            !now.toLocalDate().isAfter(holidayEnd)) {
            price = price.multiply(new BigDecimal("0.8"));
        }
        // 2. 新人 50 元券
        if (user.isNewcomer() && price.compareTo(new BigDecimal("100")) > 0) {
            price = price.subtract(new BigDecimal("50"));
        }
        // 3. VIP 每满 200-20
        if (user.isVip()) {
            BigDecimal minus = price.divideToIntegralValue(new BigDecimal("200"))
                                    .multiply(new BigDecimal("20"));
            price = price.subtract(minus);
        }
​
        // 4. 满三件再 9 折
        if (order.getItemCount() >= 3) {
            price = price.multiply(new BigDecimal("0.9"));
        }
​
        // 5. 黑五闪促:后台随时开关
        if ("ON".equals(System.getenv("BLACK_FRIDAY_SWITCH"))
            && now.getMonth() == Month.NOVEMBER
            && now.getDayOfMonth() == 24) {
            price = price.multiply(new BigDecimal("0.7"));
        }
​
        // ......继续新增 if/else ......
​
        return price.max(BigDecimal.ZERO);
    }
}

每上线一个活动和下线一个活动,对应的价格计算逻辑就要叠加if else。 我们就可以将这些规则放到规则引擎里面, 下面举一个非常简单的例子:

css 复制代码
[  {    "name": "holiday_20_percent",    "condition": "#date between '2024-10-01' and '2024-10-07'",    "effect":   "#price * 0.8",    "order": 10  },  {    "name": "new_user_minus_50",    "condition": "#isNew && #price > 100",    "effect":   "#price - 50",    "order": 20  }]

运营同学需要调整规则了,即时生效。

写在最后

我们从Spring 为什么推荐构造器注入一路前行,分析了Spring 推荐构造器注入的动机,第一个动机在于选择构造器注入的Bean在启动之后,是初始化完成的,不会有空指针的风险。第二个动机在于警示开发者注入太多,职责不单一,这可能是代码的坏味道。职责不单一的另一种形式的表达是与不该耦合的类耦合在一起,不方便做改动。我们给出了治理代码的几个策略,最基础的策略就是通过事件机制来弱化消息服务和流程服务之间的耦合,通过策略模式来强化消息服务之间的联系。如果是方法里面的逻辑拥挤太多,我们接着可以考虑用策略拆,如果是计算比较逻辑冗长而又复杂,我们可以将其平移到规则引擎里面。

这些内容本来在我的脑海里面游荡,我觉得应该是清晰的,直到我看到了"将直觉转化为文字,解释一些人们直觉上早已了然于心,却尚未能言说的内容", 于是我觉得我有必要写一写。

参考资料

1\] Why field injection is evil [odrotbohm.de/2013/11/why...](https://link.juejin.cn?target=https%3A%2F%2Fodrotbohm.de%2F2013%2F11%2Fwhy-field-injection-is-evil%2F "https://odrotbohm.de/2013/11/why-field-injection-is-evil/")

相关推荐
midsummer_woo39 分钟前
基于spring boot的医院挂号就诊系统(源码+论文)
java·spring boot·后端
Olrookie2 小时前
若依前后端分离版学习笔记(三)——表结构介绍
笔记·后端·mysql
沸腾_罗强2 小时前
Bugs
后端
一条GO2 小时前
ORM中实现SaaS的数据与库的隔离
后端
京茶吉鹿2 小时前
"if else" 堆成山?这招让你的代码优雅起飞!
java·后端
你我约定有三2 小时前
RabbitMQ--消息丢失问题及解决
java·开发语言·分布式·后端·rabbitmq·ruby
程序视点2 小时前
望言OCR 2025终极评测:免费版VS专业版全方位对比(含免费下载)
前端·后端·github
rannn_1113 小时前
Java学习|黑马笔记|Day23】网络编程、反射、动态代理
java·笔记·后端·学习
一杯科技拿铁3 小时前
Go 的时间包:理解单调时间与挂钟时间
开发语言·后端·golang