操操操

第2章:解密Spring IoC:手写Bean容器深入理解依赖注入原理

2021-05-20
14分钟阅读时长

作者:秋小官(小秋哥) 博客:https://zuisishu.com(opens new window) 原文:https://zuisishu.com

无私而能成其私!我乐于分享、热爱学习、拜名师、交贵友,砥砺前行,共同成长!😄

一、前言

小秋哥基本上定下来这个更新风格了。基本上每个章节前面都会做一些铺垫,可能这些铺垫是有关于编程体系内设计模式、算法、架构,也可能是一些生活感悟、一些经历领悟、甚至是一些有关如何完善自我,术术道道层面的东西,当然写这些东西也可以单独开一篇博文,但是如果把它作为可能是一项枯燥知识学习的“前菜”的话,那想必带着一份愉悦的心情再享受知识构成的“饕餮盛宴”,想必心情绝对会是不一样的!我一直坚信,好的氛围在学习知识的时候是尤其重要的!就像你跟别人一块用餐,如果是你最恶心的人,恐怕在五星级餐厅,你的心情也好不到哪里去,但如果是跟你最爱的人,就算是路边摊,心情想必也不会很差,但是如果跟你最喜欢的人,在最高档的五星级餐厅吃饭,我想你们那一刻的幸福感会是前所未有的!所以日常生活中,如果遇到令你烦恼的事情,不妨先换个心情,等心情好了再勇敢面对问题,可能突然会“柳暗花明又一村”了!

二、目标

如我在课程前言中提到的一样,我们要自己来实现Spring,首先我们得知道现有的Spring都解决了软件架构(这里注意与“系统架构”进行区分)中存在的哪些问题,才导致了它如何流行的原因?

Spring作为一个轻量级的开源应用程序框架,通过提供依赖注入、面向切面编程和模板等特性来解决了传统软件架构中的许多问题,从而推动了它的流行。以下是Spring解决的一些主要问题:

  1. 低效的开发与测试:传统软件开发过程中,使用的是紧耦合的架构,导致代码重复、难以维护和测试。而Spring使用依赖注入和面向接口编程的方式,使得代码更加模块化、可扩展和易于测试。

  2. 复杂的配置管理:在传统的Java EE 应用中,需要手动管理JNDI资源、EJB容器等组件,这些组件的配置非常困难并且容易出错。而Spring的IOC容器则可以自动管理bean的生命周期和依赖关系,简化了应用程序的配置和管理。

  3. 缺乏数据访问层:传统的Web应用通常需要手动处理数据库连接和事务管理等相关问题,这些操作非常繁琐和容易出错。Spring提供了一套全面的数据访问层框架,使得开发人员可以更加方便地进行数据库操作。

  4. 难以实现AOP:传统的OOP编程很难实现横切关注点的复用、代码重构和跨类级别的事务管理。而Spring的AOP框架可以很方便地实现这些功能,使得横切关注点的复用和代码重构变得更加简单。

正是由于它对于诸多当时软件架构领域的痛点做了大刀阔斧、与时俱进的改革,才让它如今变得如此流行。但是Spring产生至今已经经历了太多的升级优化、各种扩展兼容,所以短时间内,凭一已之力,你想造就出另一个如此般强大的庞然大物几乎是不可能的,所以我们就要去繁就简,找到Spring中真正核心的那20%的功能,把它实现出来,那我们对于Spring的理解想必与没经历这层思维与实践经验训练过的人就会有起码3年高度上的差异了(我是不是说的有点夸张了😄!!!),所以,我们进入今天的第一个硬核主题Spring Bean 容器是什么?

    1. Spring Bean 容器是什么?

提到容器的概念,想必有过scrum/DevOps参与或部署经验的同学们都不会陌生,一般大公司里,都存在的着一套基本上,全自动化的运维体系!运维体系在容器概念出现以前,是一个特别庞大低效的组织,需要很多人肉的运维工程师高效协作,才可能上演一幕幕完美的上线流程!但多数情况下,它是不顺利的,因为各操作系统的版本不统一,可能不同运维操作相同一台服务器时,由于接洽需求方的不同,可能明里暗里就造成了操作系统安装软件的不同、这不同可能是软件版本的不同、可能是编码不的不同、可能是xxxx,总之,千奇百怪,千差万别!

docker乃至后来k8s的出现扭转了这个局面,它统一程序员本地开发代码运行的环境,让快速部署高扩展性、高可用的集群变得可能,虽然Spring中的IoC概念主导下的bean容器与k8s等虚拟容器编排管理技术在作用、实现方式、生命周期管理和资源使用方式等方面都存在巨大的差异完全不同的两种事物,但我还是总结出来它们有以下的相同点和异同点,依据大脑记忆的特点,更方便大家深刻对其理解。

Docker和Kubernetes(简称k8s)中的容器与Spring bean中的容器概念有一些相同点和异同点,下面是它们之间的比较:

相同点:

  1. 容器都是一种封装应用程序及其依赖项的技术。在Docker和Kubernetes中,容器指的是一个可移植的、自包含的软件单元,其中包含了运行应用程序所需的所有依赖项和配置信息;在Spring中,容器指的是Spring IOC容器,用于管理和组装bean实例。

  2. 容器都有助于实现应用程序的松耦合和可移植性。在Docker和Kubernetes中,容器可以在不同的环境中部署和运行,使得应用程序更加容易移植和扩展;在Spring中,IOC容器可以帮助开发人员将不同的对象解耦,并且可以替换不同的实现方式,以达到更好的可维护性和可测试性。

  3. 容器都实现了一定程度的隔离。在Docker和Kubernetes中,容器提供了基于Linux内核命名空间和控制组(cgroup)的进程隔离和资源限制,保证了应用程序之间的互相独立;在Spring中,IOC容器通过创建实例的方式来避免了不同对象之间的直接依赖,从而实现了松耦合和隔离。

异同点:

  1. 容器的范畴不同。Docker和Kubernetes中的容器指的是一种操作系统级别的虚拟化技术;而Spring中的容器指的是IOC容器,用于管理和组装bean实例。

  2. 容器的作用不同。Docker和Kubernetes中的容器主要用于部署和运行应用程序;而Spring中的IOC容器则主要用于解耦和管理bean实例。

  3. 容器的实现方式有所不同。Docker和Kubernetes中的容器使用了Linux内核的cgroup和namespace机制来实现进程隔离和资源控制;而Spring中的IOC容器则通过Java反射、XML配置或注解等方式来创建bean实例,并管理它们之间的依赖关系。

  4. 容器的生命周期管理不同。Docker和Kubernetes中的容器可以通过镜像创建、启动、停止、重启和销毁等操作来管理容器的生命周期;而Spring中的IOC容器则会在应用程序启动时创建,并随着应用程序的运行一直存在,直到应用程序关闭。

  5. 容器的可移植性不同。在Docker和Kubernetes中,容器可以跨平台运行,从而实现了便捷的应用程序部署和迁移;而在Spring中,IOC容器只是Java应用程序的一部分,不具备跨平台的特性。

  6. 容器的资源使用方式不同。在Docker和Kubernetes中,容器可以使用虚拟化技术来利用宿主机的物理资源;而在Spring中,IOC容器只是一个Java对象集合,不涉及宿主机的物理资源使用。

如果我们看Spring的源码,可能少不了见到如下图这种组织的方式:

           ┌───────────────┐        
           │ BeanDefinition│        
           └───────────────┘        
                   ▲                
                   │                
                implements           
                   │                
           ┌───────────────┐        
           │  BeanFactory  │        
           └───────────────┘        
                   ▲                
                   │                
     extends       │         extends
                   │                
           ┌───────────────┐        
           │AbstractBeanFactory│     
           └───────────────┘        
                   ▲                
                   │                
           implements             
                   │                
           ┌─────────────────┐      
           │DefaultListableBeanFactory│
           └─────────────────┘      
                   ▲                
                   │                
           ┌─────────────────┐      
           │      ApiTest        │    
           └─────────────────┘      

这表示了一个Bean对象的创建流程,BeanDefinition定义了bean的基本属性和配置信息;BeanFactory是一个接口,定义了Spring IoC容器获取bean的方法;AbstractBeanFactory实现了BeanFactory接口,并提供了一些通用的功能;DefaultListableBeanFactory是AbstractBeanFactory的子类,它实现了BeanDefinitionRegistry接口,并提供了完整的自定义Bean注册和管理支持;ApiTest是应用程序中一个使用Spring Ioc容器的类,通过这些类之间的协作,Spring才具备了提供丰富的依赖注入机制和Bean生命周期管理的功能,所以如果把Bean看做是Spring的组成部分,那么,Spring要能够把所有Bean都高效的组织管理,而要达到这种目的,就需要Bean能被解构成可在Spring中可配置的部分,类似于Spring是自动化生产车间的脑机,它具体指挥操控着生成什么样的Bean,以及生产这些Bean需要哪些原料,它要知道从哪里取到原料能产出一个什么样的Bean,并能对该Bean进行精细化的管理,也就是Spring要能够对Bean的全生命周期进行管理。

三、设计

上面我们已经概括了Spring存在的意义,就是对于Bean相关对象构建是时要读取到的参数进行统一管理,才能在系统需要的时候,把这些参数拿到用于构建在程序运行时依赖的对象,因此我们需要找一种数据结构来存储这些数据,哪种数据结构比较合适呢?

业务开发中,可能使用最多的数据结构就是HashMap了,几乎所有的Restful接口对外输出Json结构时,都是HashMap装配完数据然后json化输出的。那为什么HashMap的出镜率如此之高呢,它可是非纯程安全的呀?

这就要根据使用场景的不同来做下说明了。首先来说高效,基本做接口,查询高效是必然要考虑的问题。毕竟针对于写高效,可以有很多架构设计方式来提高应对请求瓶颈的上限,但是查询瓶颈就不一样了,这是实打实你解决不好,基本就无用户体验可言的部分了,而没有好的用户体验的结果,对于终端用户、对于服务提供方、对于服务提供方内开发提供服务的产品、设计、研发几乎都是致命负面的存在,你解决不好,四面八方各种大逼兜就扇来了,就问你怕不怕?

我先举个小例子,来让你感受一下HashMap这种数据结构的低时间复杂度,即查询的高性能:

  1. 创建一个包含有10w个元素的HashMap,并填充随机的键值对
  2. 对于同样的键值对,分别用HashMap和其他数据结构(如数组、链表、队列等)来进行查询操作,并记录各自所需要的时间
  3. 重复上述步骤,取平均值来降低误差

Java中进行上述实验的具体代码实现:

import java.util.HashMap;
import java.util.LinkedList;
import java.util.Random;

public class HashMapPerformanceTest {
    private static final int SIZE = 100000; // 容器大小

    public static void main(String[] args) {
        // 创建HashMap并填充随机键值对
        HashMap<Integer, Integer> hashMap = new HashMap<>(SIZE);
        Random random = new Random();
        for (int i = 0; i < SIZE; i++) {
            int key = random.nextInt(SIZE);
            int value = random.nextInt();
            hashMap.put(key, value);
        }

        // 查找同样的键值对并记录各自所需的时间
        int[] keys = new int[SIZE];
        for (int i = 0; i < SIZE; i++) {
            keys[i] = random.nextInt(SIZE);
        }
        long startTime, endTime;
        int count = 50000; // 每个键值对查找次数
        long hashMapTime = 0, linkedListTime = 0;

        // 使用HashMap进行查找
        startTime = System.nanoTime();
        for (int i = 0; i < SIZE; i++) {
            int key = keys[i];
            for (int j = 0; j < count; j++) {
                int value = hashMap.get(key);
            }
        }
        endTime = System.nanoTime();
        hashMapTime = endTime - startTime;

        // 使用LinkedList进行查找
        LinkedList<Integer>[] linkedLists = new LinkedList[SIZE];
        for (int i = 0; i < SIZE; i++) {
            linkedLists[i] = new LinkedList<>();
        }
        for (int key : hashMap.keySet()) {
            int index = key % SIZE;
            linkedLists[index].add(key);
        }
        startTime = System.nanoTime();
        for (int i = 0; i < SIZE; i++) {
            int key = keys[i];
            int index = key % SIZE;
            LinkedList<Integer> list = linkedLists[index];
            for (int j = 0; j < count; j++) {
                Integer value = null;
                for (Integer k : list) {
                    if (k.equals(key)) {
                        value = hashMap.get(k);
                        break;
                    }
                }
            }
        }
        endTime = System.nanoTime();
        linkedListTime = endTime - startTime;

        // 输出结果
        System.out.printf("HashMap average time: %.2f ns\n", (double) hashMapTime / SIZE / count);
        System.out.printf("LinkedList average time: %.2f ns\n", (double) linkedListTime / SIZE / count);
    }
}

过程中可以调整测试数据量,并依赖于多次运行来取平均误差小的平均值。最终,我们可以得出结论,HashMap进行查找的速度比其他数据结构快,并且这还下是最终必杀,实际开发过程中,还可以采用设置初始容量、设置负载因子、调整哈希表大小的方式,以此降低哈希冲突出现的概率,进一步提高效率,这也是我们最终选择HashMap作为我们存储这种键值结构数据的原因。另外多提一嘴,之前版本的Java中由于哈希表数据结构底层实现基于HashTable/HashMap,却有着以下差异:

HashMap和HashTable都是Java中的哈希表数据结构,但它们在JDK1.8之前有一些区别:

  1. 线程安全性:HashTable是线程安全的,而HashMap不是。为了使HashMap线程安全,可以使用ConcurrentHashMap。

  2. 键值对的顺序:HashMap不保证键值对的顺序,而HashTable会把插入的键值对按照插入的顺序存储在哈希表中。

  3. 初始容量和负载因子的默认值:HashTable的初始容量和负载因子的默认值分别为11和0.75,而HashMap的默认值分别为16和0.75。

总的来说,HashMap是一个更先进的哈希表实现,而HashTable是采用了线性冲突法来解决哈希冲突的,但是表现并不好,哈希冲突大量出现,严重降低了查询效率,JDK1.8之后的HashMap则使用了扰动函数寻扯和链式存储来解决哈希冲突,因此相比HashTable更适合在高性能要求的场景下使用,它比HashTable具有更好的性能和灵活性。

而数据结构选好了,模拟Spring提供的行为支持,我们也知道我们要做的事情是什么了:

四、实现

1. 工程结构

├── LICENSE
├── pom.xml
├── src
│   ├── main
│   │   └── java
│   │       └── cn
│   │           └── zuisishu
│   │               └── springframework
│   │                   ├── BeanDefinition.java
│   │                   └── MyBeanContainer.java
│   └── test
│       └── java
│           └── cn
│               └── zuisishu
│                   └── springframework
│                       └── test
│                           ├── ApiTest.java
│                           └── bean
├── target
│   ├── classes
│   │   └── cn
│   │       └── zuisishu
│   │           └── springframework
│   │               ├── BeanDefinition.class
│   │               └── MyBeanContainer.class
│   ├── generated-sources
│   │   └── annotations
│   ├── generated-test-sources
│   │   └── test-annotations
│   └── test-classes
│       └── cn
│           └── zuisishu
│               └── springframework
│                   └── test
│                       ├── ApiTest.class
│                       └── bean
│                           └── UserService.class
└── zuisishu-spring-step-01.iml

工程源码:公众号「秋小官」,回复:Spring专栏,获取完整源码

2. Bean 定义

cn.zuisishu.springframework.BeanDefinition

public class BeanDefinition {

    private Object bean;

    public BeanDefinition(Object bean) {
        this.bean = bean;
    }

    public Object getBean() {
        return bean;
    }

}

上面我们把BeanDefinition单独拆成了数据存储类型,但是底层用的依然是Object,Object在Java中就是万类之源,所有类都默认继承了了Object,你在自己实现类BeanFactory工厂的类中其实你也可以用Object来直接用于声明,但这就违背了我们这里分拆组成部分,让Spring更高效管理的初衷,也不符合软件开发模式中先进的思想,因为我们后面还要不断的扩充这个BeanDefinition的组成,所以目前来看它是相对多余简单的,但是万丈高楼平地起!

3. Bean 工厂

cn.zuisishu.springframework.BeanFactory

public class MyBeanContainer {

    private Map<String, BeanDefinition> beans = new HashMap<>();

    public Object getBean(String name) {
        return beans.get(name).getBean();
    }

    public void register(String name, BeanDefinition bean) {
        beans.put(name, bean);
    }

}

我们在自定义实现的这个名称为MyBeanContainer的BeanFactory中,定义了getBean方法和register方法,如果你对于开发语言不默生,对于写一些组件扩展不默生的话,基本就是你开发一个自定义类,使它支持基础功能的思路,比如我们常用的Mybatis的JDBC相关,其实它也是底层帮你实现了基础的增删改查方法,但是你要复杂的查询语句的时候,就要依照于最基础的结构,来构建出更复杂的查询结构,而我们就属于在提供最基础的类组成部分。这里非常重要的就是getBean部分,如果像常规的实现类,可能你就返回了查询结果就别行,但是Spring的Bean它是支撑整个系统运行的必要组成部分,它肯定不可以只返回单纯的查询结果,它一定是不断的实例系统运行依赖的各类对象,所以注意该处的处理!

五、测试

1. 事先准备

cn.zuisishu.springframework.test.bean.UserService

public class UserService {

    public void queryUserInfo(){
        System.out.println("查询用户信息");
    }

}

这里简单定义了一个 UserService 对象,方便我们后续对Spring容器测试。

2. 测试用例

cn.zuisishu.springframework.test.ApiTest

@Test
public void test_BeanFactory(){
    // 1.初始化 BeanFactory
    MyBeanContainer beanFactory = new MyBeanContainer();

    // 2.注入bean
    BeanDefinition beanDefinition = new BeanDefinition(new UserService());
    beanFactory.register("userService", beanDefinition);

    // 3.获取bean
    UserService userService = (UserService) beanFactory.getBean("userService");
    System.out.print(userService);
    userService.queryUserInfo();
}

在单测中主要包括初始化 Bean 工厂、注册 Bean、获取 Bean,三个步骤,使用效果上贴近与 Spring,但显得会更简化。 在 Bean 的注册中,这里是直接把 UserService 实例化后作为入参传递给 BeanDefinition 的,在后续的陆续实现中,我们会把这部分内容放入 Bean 工厂中实现。

3. 测试结果

查询用户信息1
Process finished with exit code 0

通过测试结果可以看到,我们已经初步达成预期目的了。

六、总结

我们已经搭建出了Spring Bean的最简化版本,让你明白它的运行原理,虽然比较简单,但是一切都是建立在简单、理解基础上的。这样一步一步探索下去,相信你只要跟住步伐,功能提升3年不是大问题!像我在开篇,在这个章节开始中描述的步骤一样,留意就会发现,知识不是孤岛,如果你的知识量储备足够,那么,面对问题时,你会有更多的思路,而能力强的内在含义就在于:你可以有更多选择!所以,努力提升自己的知识储备,面对问题积极思考,相信一切问题都不是问题!我们也可以看到,写代码只是最后一步,甚至用不用Java来写都不重要,同样的设计思路,在其它语言中热门框架设计上,也依旧在断使用,所以,语言不应该限制你的想象力,只有明白我们扣出的这些最底层的内核原理,才真正有意义!加油!