博客记录-day009-LinkedHashMap、TreeMap、ArrayDeque+MVC到DDD重构

60 阅读26分钟

一、沉默王二-集合框架

1、LinkedHashMap

LinkedHashMap 就是为这个需求应运而生的。LinkedHashMap 继承了 HashMap,所以 HashMap 有的关于键值对的功能,它也有了。

在此基础上,LinkedHashMap 内部追加了双向链表,来维护元素的插入顺序。注意下面代码中的 before 和 after,它俩就是用来维护当前元素的前一个元素和后一个元素的顺序的。

1)插入顺序

要想搞清楚,就需要深入研究一下 LinkedHashMap 的源码。LinkedHashMap 并未重写 HashMap 的 put() 方法,而是重写了 put() 方法需要调用的内部方法 newNode()

这是 HashMap 的。

Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

这是 LinkedHashMap 的。

HashMap.Node<K,V> newNode(int hash, K key, V value, HashMap.Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<>(hash, key, value, e);
    linkNodeLast(p);
    return p;
}

前面曾提到 LinkedHashMap.Entry 继承了 HashMap.Node,并且追加了两个字段 before 和 after,用来维持键值对的关系。

在 LinkedHashMap 中,链表中的节点顺序是按照插入顺序维护的。当使用 put() 方法向 LinkedHashMap 中添加键值对时,会将新节点插入到链表的尾部,并更新 before 和 after 属性,以保证链表的顺序关系——由 linkNodeLast() 方法来完成:

/**
 * 将指定节点插入到链表的尾部
 *
 * @param p 要插入的节点
 */
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail; // 获取链表的尾节点
    tail = p; // 将 p 设为尾节点
    if (last == null)
        head = p; // 如果链表为空,则将 p 设为头节点
    else {
        p.before = last; // 将 p 的前驱节点设为链表的尾节点
        last.after = p; // 将链表的尾节点的后继节点设为 p
    }
}

看到了吧,LinkedHashMap 在添加第一个元素的时候,会把 head 赋值为第一个元素,等到第二个元素添加进来的时候,会把第二个元素的 before 赋值为第一个元素,第一个元素的 afer 赋值为第二个元素。

2)访问顺序

LinkedHashMap 不仅能够维持插入顺序,还能够维持访问顺序。访问包括调用 get() 方法、remove() 方法和 put() 方法。 也就是说,最不经常访问的放在头部

我们可以使用 LinkedHashMap 来实现 LRU 缓存,LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。

/**
 * 自定义的 MyLinkedHashMap 类,继承了 Java 中内置的 LinkedHashMap<K, V> 类。
 * 用于实现一个具有固定大小的缓存,当缓存达到最大容量时,会自动移除最早加入的元素,以腾出空间给新的元素。
 *
 * @param <K> 键的类型
 * @param <V> 值的类型
 */
public class MyLinkedHashMap<K, V> extends LinkedHashMap<K, V> {

    private static final int MAX_ENTRIES = 5; // 表示 MyLinkedHashMap 中最多存储的键值对数量

    /**
     * 构造方法,使用 super() 调用了父类的构造函数,并传递了三个参数:initialCapacity、loadFactor 和 accessOrder。
     *
     * @param initialCapacity 初始容量
     * @param loadFactor      负载因子
     * @param accessOrder     访问顺序
     */
    public MyLinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
        super(initialCapacity, loadFactor, accessOrder);
    }

    /**
     * 重写父类的 removeEldestEntry() 方法,用于指示是否应该移除最早加入的元素。
     * 如果返回 true,那么将删除最早加入的元素。
     *
     * @param eldest 最早加入的元素
     * @return 如果当前 MyLinkedHashMap 中元素的数量大于 MAX_ENTRIES,返回 true,否则返回 false。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

}

MyLinkedHashMap 是一个自定义类,它继承了 LinkedHashMap,并且重写了 removeEldestEntry() 方法——使 Map 最多可容纳 5 个元素,超出后就淘汰。

那同学们可能还想知道,为什么 LinkedHashMap 能实现 LRU 缓存,把最不经常访问的那个元素淘汰?

在插入元素的时候,需要调用 put() 方法,该方法最后会调用 afterNodeInsertion() 方法,这个方法被 LinkedHashMap 重写了。

/**
 * 在插入节点后,如果需要,可能会删除最早加入的元素
 *
 * @param evict 是否需要删除最早加入的元素
 */
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) { // 如果需要删除最早加入的元素
        K key = first.key; // 获取要删除元素的键
        removeNode(hash(key), key, null, false, true); // 调用 removeNode() 方法删除元素
    }
}

removeEldestEntry() 方法会判断第一个元素是否超出了可容纳的最大范围,如果超出,那就会调用 removeNode() 方法对最不经常访问的那个元素进行删除。

2、TreeMap

1)自然排序

TreeMap 是怎么做到的呢?想一探究竟,就得上源码了,来看 TreeMap 的 put() 方法:

public V put(K key, V value) {
    Entry<K,V> t = root; // 将根节点赋值给变量t
    if (t == null) { // 如果根节点为null,说明TreeMap为空
        compare(key, key); // type (and possibly null) check,检查key的类型是否合法
        root = new Entry<>(key, value, null); // 创建一个新节点作为根节点
        size = 1; // size设置为1
        return null; // 返回null,表示插入成功
    }
    int cmp;
    Entry<K,V> parent;
    // split comparator and comparable paths,根据使用的比较方法进行查找
    Comparator<? super K> cpr = comparator; // 获取比较器
    if (cpr != null) { // 如果使用了Comparator
        do {
            parent = t; // 将当前节点赋值给parent
            cmp = cpr.compare(key, t.key); // 使用Comparator比较key和t的键的大小
            if (cmp < 0) // 如果key小于t的键
                t = t.left; // 在t的左子树中查找
            else if (cmp > 0) // 如果key大于t的键
                t = t.right; // 在t的右子树中查找
            else // 如果key等于t的键
                return t.setValue(value); // 直接更新t的值
        } while (t != null);
    }
    else { // 如果没有使用Comparator
        if (key == null) // 如果key为null
            throw new NullPointerException(); // 抛出NullPointerException异常
            Comparable<? super K> k = (Comparable<? super K>) key; // 将key强制转换为Comparable类型
        do {
            parent = t; // 将当前节点赋值给parent
            cmp = k.compareTo(t.key); // 使用Comparable比较key和t的键的大小
            if (cmp < 0) // 如果key小于t的键
                t = t.left; // 在t的左子树中查找
            else if (cmp > 0) // 如果key大于t的键
                t = t.right; // 在t的右子树中查找
            else // 如果key等于t的键
                return t.setValue(value); // 直接更新t的值
        } while (t != null);
    }
    // 如果没有找到相同的键,需要创建一个新节点插入到TreeMap中
    Entry<K,V> e = new Entry<>(key, value, parent); // 创建一个新节点
    if (cmp < 0) // 如果key小于parent的键
        parent.left = e; // 将e作为parent的左子节点
    else
        parent.right = e; // 将e作为parent的右子节点
    fixAfterInsertion(e); // 插入节点后需要进行平衡操作
    size++; // size加1
    return null; // 返回null,表示插入成功
}
  • 首先定义一个Entry类型的变量t,用于表示当前的根节点;
  • 如果t为null,说明TreeMap为空,直接创建一个新的节点作为根节点,并将size设置为1;
  • 如果t不为null,说明需要在TreeMap中查找键所对应的节点。因为TreeMap中的元素是有序的,所以可以使用二分查找的方式来查找节点;
  • 如果TreeMap中使用了Comparator来进行排序,则使用Comparator进行比较,否则使用Comparable进行比较。如果查找到了相同的键,则直接更新键所对应的值;
  • 如果没有查找到相同的键,则创建一个新的节点,并将其插入到TreeMap中。然后使用fixAfterInsertion()方法来修正插入节点后的平衡状态;
  • 最后将TreeMap的size加1,然后返回null。如果更新了键所对应的值,则返回原先的值。

注意 cmp = k.compareTo(t.key) 这行代码,就是用来进行 key 比较的,由于此时 key 是 String,所以就会调用 String 类的 compareTo() 方法进行比较。

2)自定义排序

TreeMap 提供了可以指定排序规则的构造方法。Comparator.reverseOrder() 返回的是 Collections.ReverseComparator 对象,就是用来反转顺序的,非常方便。

既然 TreeMap 的元素是经过排序的,那找出最大的那个,最小的那个,或者找出所有大于或者小于某个值的键来说,就方便多了。

// 获取mapInt中最大的键  
Integer highestKey = mapInt.lastKey();  

// 获取mapInt中最小的键  
Integer lowestKey = mapInt.firstKey();  

// 获取mapInt中小于3的所有键  
Set<Integer> keysLessThan3 = mapInt.headMap(3).keySet();  

// 获取mapInt中大于或等于3的所有键  
Set<Integer> keysGreaterThanEqTo3 = mapInt.tailMap(3).keySet();  

// 输出最大的键  
System.out.println(highestKey);  

// 输出最小的键  
System.out.println(lowestKey);  

// 输出所有小于3的键  
System.out.println(keysLessThan3);  

// 输出所有大于或等于3的键  
System.out.println(keysGreaterThanEqTo3);

TreeMap 考虑得很周全,恰好就提供了 lastKey()firstKey() 这样获取最后一个 key 和第一个 key 的方法。

headMap() 获取的是到指定 key 之前的 key;tailMap() 获取的是指定 key 之后的 key(包括指定 key)。

再来看一下例子:

TreeMap<Integer, String> treeMap = new TreeMap<>();
treeMap.put(1, "value1");
treeMap.put(2, "value2");
treeMap.put(3, "value3");
treeMap.put(4, "value4");
treeMap.put(5, "value5");

// headMap示例,获取小于3的键值对
Map<Integer, String> headMap = treeMap.headMap(3);
System.out.println(headMap); // 输出 {1=value1, 2=value2}

// tailMap示例,获取大于等于4的键值对
Map<Integer, String> tailMap = treeMap.tailMap(4);
System.out.println(tailMap); // 输出 {4=value4, 5=value5}

// subMap示例,获取大于等于2且小于4的键值对
Map<Integer, String> subMap = treeMap.subMap(2, 4);
System.out.println(subMap); // 输出 {2=value2, 3=value3}

headMap、tailMap、subMap方法分别获取了小于3、大于等于4、大于等于2且小于4的键值对。

3.ArrayDeque

要讲栈和队列,首先要讲Deque接口。Deque的含义是“double ended queue”,即双端队列,它既可以当作栈使用,也可以当作队列使用。下表列出了DequeQueue相对应的接口:

Queue MethodEquivalent Deque Method说明
add(e)addLast(e)向队尾插入元素,失败则抛出异常
offer(e)offerLast(e)向队尾插入元素,失败则返回false
remove()removeFirst()获取并删除队首元素,失败则抛出异常
poll()pollFirst()获取并删除队首元素,失败则返回null
element()getFirst()获取但不删除队首元素,失败则抛出异常
peek()peekFirst()获取但不删除队首元素,失败则返回null

下表列出了DequeStack对应的接口:

Stack MethodEquivalent Deque Method说明
push(e)addFirst(e)向栈顶插入元素,失败则抛出异常
offerFirst(e)向栈顶插入元素,失败则返回false
pop()removeFirst()获取并删除栈顶元素,失败则抛出异常
pollFirst()获取并删除栈顶元素,失败则返回null
peek()getFirst()获取但不删除栈顶元素,失败则抛出异常
peekFirst()获取但不删除栈顶元素,失败则返回null

上面两个表共定义了Deque的 12 个接口。

添加,删除,取值都有两套接口,它们功能相同,区别是对失败情况的处理不同。

一套接口遇到失败就会抛出异常,另一套遇到失败会返回特殊值(falsenull 。除非某种实现对容量有限制,大多数情况下,添加操作是不会失败的。

虽然Deque的接口有 12 个之多,但无非就是对容器的两端进行操作,或添加,或删除,或查看。明白了这一点讲解起来就会非常简单。

ArrayDeque和LinkedList是Deque的两个通用实现。

1、addFirst()

addFirst(E e)的作用是在Deque的首端插入元素,也就是在head的前面插入元素,在空间足够且下标没有越界的情况下,只需要将elements[--head] = e即可。

实际需要考虑:

  1. 空间是否够用,以及下标是否越界的问题。

上图中,如果head0之后接着调用addFirst(),虽然空余空间还够用,但head-1,下标越界了。下列代码很好的解决了这两个问题。

//addFirst(E e)
public void addFirst(E e) {
    if (e == null)//不允许放入null
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;//2.下标是否越界
    if (head == tail)//1.空间是否够用
        doubleCapacity();//扩容
}

上述代码我们看到,空间问题是在插入之后解决的,因为tail总是指向下一个可插入的空位,也就意味着elements数组至少有一个空位,所以插入元素的时候不用考虑空间问题。

下标越界的处理解决起来非常简单,head = (head - 1) & (elements.length - 1)就可以了,这段代码相当于取余,同时解决了head为负值的情况。因为elements.length必需是2的指数倍,elements - 1就是二进制低位全1,跟head - 1相与之后就起到了取模的作用,如果head - 1为负数(其实只可能是-1),则相当于对其取相对于elements.length的补码。

下面再说说扩容函数doubleCapacity(),其逻辑是申请一个更大的数组(原数组的两倍),然后将原数组复制过去。过程如下图所示:

图中我们看到,复制分两次进行,第一次复制head右边的元素,第二次复制head左边的元素。

该方法的实现中,首先检查 head 和 tail 是否相等,如果不相等则抛出异常。然后计算出 head 右边的元素个数 r,以及新的容量 newCapacity,如果 newCapacity 太大则抛出异常。

接下来创建一个新的 Object 数组 a,将原有 ArrayDeque 中 head 右边的元素复制到 a 的前面(即图中绿色部分),将 head 左边的元素复制到 a 的后面(即图中灰色部分)。最后将 elements 数组替换为 a,head 设置为 0,tail 设置为 n(即新容量的长度)。

需要注意的是,由于 elements 数组被替换为 a 数组,因此在方法调用结束后,原有的 elements 数组将不再被引用,会被垃圾回收器回收。

2.addLast()

addLast(E e)的作用是在Deque的尾端插入元素,也就是在tail的位置插入元素,由于tail总是指向下一个可以插入的空位,因此只需要elements[tail] = e;即可。插入完成后再检查空间,如果空间已经用光,则调用doubleCapacity()进行扩容。

public void addLast(E e) {
    if (e == null)//不允许放入null
        throw new NullPointerException();
    elements[tail] = e;//赋值
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)//下标越界处理
        doubleCapacity();//扩容
}

下标越界处理方式addFirt()中已经讲过,不再赘述。

3.pollFirst()

pollFirst()的作用是删除并返回Deque首端元素,也即是head位置处的元素。如果容器不空,只需要直接返回elements[head]即可,当然还需要处理下标的问题。由于ArrayDeque中不允许放入null,当elements[head] == null时,意味着容器为空。

public E pollFirst() {
    E result = elements[head];
    if (result == null)//null值意味着deque为空
        return null;
    elements[h] = null;//let GC work
    head = (head + 1) & (elements.length - 1);//下标越界处理
    return result;
}

4.pollLast()

pollLast()的作用是删除并返回Deque尾端元素,也即是tail位置前面的那个元素。

public E pollLast() {
    int t = (tail - 1) & (elements.length - 1);//tail的上一个位置是最后一个元素
    E result = elements[t];
    if (result == null)//null值意味着deque为空
        return null;
    elements[t] = null;//let GC work
    tail = t;
    return result;
}

5.peekFirst()

peekFirst()的作用是返回但不删除Deque首端元素,也即是head位置处的元素,直接返回elements[head]即可。

public E peekFirst() {
    return elements[head]; // elements[head] is null if deque empty
}

6.peekLast()

peekLast()的作用是返回但不删除Deque尾端元素,也即是tail位置前面的那个元素。

public E peekLast() {
    return elements[(tail - 1) & (elements.length - 1)];
}

二、小博哥-编程基础

1.MVC到DDD重构

众所周知,MVC 分层结构是一种贫血模型设计,它将”状态“和”行为“分离到不同的包结构中进行开发使用。domain 里写 po、vo、enum 对象,service 里写功能逻辑实现。也正因为 MVC 结构没有太多的约束,让前期的交付速度非常快。但随着系统工程的长期迭代,贫血对象开始被众多 serivice 交叉使用,而 service 服务也是相互调用。这样缺少一个上下文关系的开发方式,让长期迭代的 MVC 工程逐步腐化到严重腐化。

MVC 工程的腐化根本,就在于对象、服务、组件的交叉混乱使用。时间越长,腐化的越严重。

在 MVC 的分层结构就像家里所有人的衣服放一个大衣柜、所有人的裤子放一个大库柜。衣服裤子(对象),很少的时候很节省空间,因为你的裤子别人可能也拿去穿,复用一下开发速度很快。但时间一长,就越来越乱了。 一条裤子被加肥加大,所有人都穿。

而 DDD 架构的模型分层,则是以人为视角,一个人就是一个领域,一个领域内包括他所需的衣服、裤子、袜子、鞋子。虽然刚开始有点浪费空间,但随着软件的长周期发展,后续的维护成本就会降低。

如下是 DDD 架构所呈现出的一种四层架构分层,可能和一些其他的 DDD 分层略有差异,但核心的重点结构是不变的。尤其是domain 领域、infrastructure 基础,是任何一个 DDD 架构分层都需要有的分层模块。

  • 应用封装 - app:这是应用启动和配置的一层,如一些 aop 切面或者 config 配置,以及打包镜像都是在这一层处理。你可以把它理解为专门为了启动服务而存在的。
  • 接口定义 - api:因为微服务中引用的 RPC 需要对外提供接口的描述信息,也就是调用方在使用的时候,需要引入 Jar 包,让调用方好能依赖接口的定义做代理。
  • 领域封装 - trigger:触发器层,一般也被叫做 adapter 适配器层。用于提供接口实现、消息接收、任务执行等。所以对于这样的操作,这里把它叫做触发器层。
  • 领域编排【可选】 - case:领域编排层,一般对于较大且复杂的的项目,为了更好的防腐和提供通用的服务,一般会添加 case/application 层,用于对 domain 领域的逻辑进行封装组合处理。但对于一些小项目来说,完全可以去掉这一层。少量一层对象转换,代码的维护成本会降低很多。
  • 领域封装 - domain:领域模型服务,是一个非常重要的模块。无论怎么做DDD的分层架构,domain 都是肯定存在的。在一层中会有一个个细分的领域服务,**在每个服务包中会有【模型、仓库、服务】**这样3部分。
  • 仓储服务 - infrastructure:基础层依赖于 domain 领域层,因为在 domain 层定义了仓储接口需要在基础层实现。这是依赖倒置的一种设计方式。所有的仓储、接口、事件消息,都可以通过依赖倒置的方式进行调用。
  • 外部接口 - gateway:对于外部接口的调用,也可以从基础设施层分离一个专门的 gateway 网关层,来封装外部 RPC/HTTP 等类型接口的调用。
  • 类型定义 - types:通用类型定义层,在我们的系统开发中,会有很多类型的定义,包括:基本的 Response、Constants 和枚举。它会被其他的层进行引用使用。(这一层没有画到图中)

经过实践验证,不需要太高成本,MVC 就可以天然的向 DDD 工程分层的模型结构转变。重点是不改变原有的工程模块的依赖关系,将贫血的 domain 对象层,设计为充血的结构。对于 domain 原本在 MVC 分层结构中,就是一个被依赖层,恰好可以与其他层做依赖倒置的设计方案处理。具体如图所示:

左侧是我们常见的 MVC 分层结构,右侧是给大家上文讲解过的 DDD 分层结构。从 MVC 到 DDD 的映射,使用了相同颜色进行标注。之后我来介绍一些细节:

在 MVC 分层结构中,所有的逻辑都集中在 service 层,也是文中提到的腐化最严重的层,要治理的也是这一层。所以首先我们要将 service 里的功能进行拆解。

  1. service 中具备领域特性的服务实现,抽离到原本贫血模型的 domain 中。在 domain 分层中添加 xxx、yyy、zzz 分层领域包,分别实现不同功能。注意每个分层领域包内都具备完整的 DDD 领域服务内所需的模块
  2. service 中的基础功能组件,如:缓存Redis、配置中心等,迁移到 dao 层。这里我们把 dao 层看做为基础设施层。它与 domain 领域层的调用关系,为依赖倒置。也就是 domain 层定义接口,dao 层依赖于 domain 定义的接口,做依赖倒置实现接口。
  3. service 本身最后被当做 application/case 层,来调用 domain 层做服务的编排处理。
  • 因为恰好,MVC 分层结构中,也是 service 和 dao 依赖于 domain,这和 DDD 分层结构是一致的。所以经过这样的映射拆分代码实现调用结构后,并不会让工程结构发生变化。那么只要工程结构不发生变化,我们的改造成本就只剩下代码编写风格和旧代码迁移成本。

  • MVC 分层结构中的 export 层是 RPC 接口定义层,由 web 层实现。web 是对 service 的调用。也就是 DDD 分层结构中调用 application 编排好的服务。这部分无需改动。但如果你原有工程把 domain 也暴露出去了,则需要把对应的包迁移到 export 因为 domain 包有太多的核心对象和属性,还包括数据库持久化对象。这些都不应该被暴露。

  • MVC 分层中,因为有需要对外部 RPC 接口的调用,所以会单独有一层 RPC 来封装其他服务的接口。这一层被 domain 领域层使用,可以定义 adapter 适配器接口,通过依赖倒置,在 rpc 层实现 domain 层定义的调用接口。

  • 此外 dao 层,在 MVC 结构中原本是比较单一的。但经过改造后会需要把基础的 Redis 使用、配置中使用,都迁移到 dao 层。因为原本在 service 层的话,domain 层是调用不到的这些基础服务的,而且也不符合服务功能边界的划分。

image.png

这里我们做一个提额场景的设定。估计大家都用过信用卡,它有一个初始的额度,在后续的使用中会随着信用的积累和消费的增加,进行提高额度。而额度的提高则需要一系列的校验判断并最终做出提额处理。流程如下:

抽象类,是一个非常好用的类。一种是可以定义出流程结构,让代码变得清晰干净。再有一种是定义共用方法,让其他实现类可复用。

那么这里,我们就使用抽象类定义模板 + 策略和工厂实现的规则引擎处理频繁变动的校验类流程,完成代码开发。如图我们先设计下代码的实现结构。

  • 首先,定义一个受理调额的接口。因为额度的调整,包括:提额、降额。所以不要把名字写的太死。
  • 之后,由抽象类实现接口。在抽象类中定义出整个调用链路关系,并把一些公用的数据类支撑逻辑,提到支撑类里。这和 Spring 的设计很像。
  • 之后,因为规则校验这东西是为了支撑核心流程走下去的,而且还是随着业务频繁变动的。那就没必要在主线业务流程中,用 if···else 贴膏药的写代码,而是应该拆解出来。所以这里设计一个策略模式实现的规则校验,并通过工厂对外提供服务。
  • 最后,这些零件类的东西都处理好后。就可以在抽象类的子类实现中进行调用处理了

三、小博哥-基于Cglib实现含构造函数的类实例化策略

在上一章节我们扩充了 Bean 容器的功能,把实例化对象交给容器来统一处理,但在我们实例化对象的代码里并没有考虑对象类是否含构造函数,也就是说如果我们去实例化一个含有构造函数的对象那么就要抛异常了。

发生这一现象的主要原因就是因为 beanDefinition.getBeanClass().newInstance(); 实例化方式并没有考虑构造函数的入参,所以就这个坑就在这等着你了!那么我们的目标就很明显了,来把这个坑填平!

填平这个坑的技术设计主要考虑两部分,一个是串流程从哪合理的把构造函数的入参信息传递到实例化操作里,另外一个是怎么去实例化含有构造函数的对象。

图 4-1

  • 参考 Spring Bean 容器源码的实现方式,在 BeanFactory 中添加 Object getBean(String name, Object... args) 接口,这样就可以在获取 Bean 时把构造函数的入参信息传递进去了。
  • 另外一个核心的内容是使用什么方式来创建含有构造函数的 Bean 对象呢?这里有两种方式可以选择,一个是基于 Java 本身自带的方法 DeclaredConstructor,另外一个是使用 Cglib 来动态创建 Bean 对象。Cglib 是基于字节码框架 ASM 实现,所以你也可以直接通过 ASM 操作指令码来创建对象

工程结构:

small-spring-step-03
└── src
    ├── main
    │   └── java
    │       └── cn.bugstack.springframework.beans
    │           ├── factory
    │           │   ├── config
    │           │   │   ├── BeanDefinition.java
    │           │   │   └── SingletonBeanRegistry.java
    │           │   ├── support
    │           │   │   ├── AbstractAutowireCapableBeanFactory.java
    │           │   │   ├── AbstractBeanFactory.java
    │           │   │   ├── BeanDefinitionRegistry.java
    │           │   │   ├── CglibSubclassingInstantiationStrategy.java
    │           │   │   ├── DefaultListableBeanFactory.java
    │           │   │   ├── DefaultSingletonBeanRegistry.java
    │           │   │   ├── InstantiationStrategy.java
    │           │   │   └── SimpleInstantiationStrategy.java
    │           │   └── BeanFactory.java
    │           └── BeansException.java
    └── test
        └── java
            └── cn.bugstack.springframework.test
                ├── bean
                │   └── UserService.java
                └── ApiTest.java

image.png 本章节“填坑”主要是在现有工程中添加 InstantiationStrategy 实例化策略接口,以及补充相应的 getBean 入参信息,让外部调用时可以传递构造函数的入参并顺利实例化。

2. 新增 getBean 接口

cn.bugstack.springframework.beans.factory.BeanFactory

// 定义一个公共接口 BeanFactory  
public interface BeanFactory {  

    // 根据 bean 的名称获取对应的 bean 对象,可能会抛出 BeansException  
    Object getBean(String name) throws BeansException;  

    // 根据 bean 的名称和可变参数获取对应的 bean 对象,可能会抛出 BeansException  
    Object getBean(String name, Object... args) throws BeansException;  

}
    
  • BeanFactory 中我们重载了一个含有入参信息 args 的 getBean 方法,这样就可以方便的传递入参给构造函数实例化了

3. 定义实例化策略接口

cn.bugstack.springframework.beans.factory.support.InstantiationStrategy

// 定义一个公共接口 InstantiationStrategy  
public interface InstantiationStrategy {  

    // 根据 BeanDefinition、bean 名称、构造函数和参数数组实例化一个对象,可能会抛出 BeansException  
    Object instantiate(BeanDefinition beanDefinition, String beanName, Constructor ctor, Object[] args) throws BeansException;  

}
    
  • 在实例化接口 instantiate 方法中添加必要的入参信息,包括:beanDefinition、 beanName、ctor、args
  • 其中 Constructor 你可能会有一点陌生,它是 java.lang.reflect 包下的 Constructor 类,里面包含了一些必要的类信息,有这个参数的目的就是为了拿到符合入参信息相对应的构造函数。
  • 而 args 就是一个具体的入参信息了,最终实例化时候会用到。

4. JDK 实例化

cn.bugstack.springframework.beans.factory.support.SimpleInstantiationStrategy

// 定义一个类 SimpleInstantiationStrategy 实现 InstantiationStrategy 接口  
public class SimpleInstantiationStrategy implements InstantiationStrategy {  

    // 重写 instantiate 方法,根据给定参数实例化一个对象,可能会抛出 BeansException  
    @Override  
    public Object instantiate(BeanDefinition beanDefinition, String beanName, Constructor ctor, Object[] args) throws BeansException {  
        // 获取 BeanDefinition 中定义的 bean 类  
        Class clazz = beanDefinition.getBeanClass();  
        try {  
            // 如果构造函数不为空,则使用指定的构造函数和参数实例化对象  
            if (null != ctor) {  
                return clazz.getDeclaredConstructor(ctor.getParameterTypes()).newInstance(args);  
            } else {  
                // 如果构造函数为空,则使用无参构造函数实例化对象  
                return clazz.getDeclaredConstructor().newInstance();  
            }  
        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {  
            // 捕获异常,抛出 BeansException,包含失败的信息  
            throw new BeansException("Failed to instantiate [" + clazz.getName() + "]", e);  
        }  
    }  

}
  • 首先通过 beanDefinition 获取 Class 信息,这个 Class 信息是在 Bean 定义的时候传递进去的。
  • 接下来判断 ctor 是否为空,如果为空则是无构造函数实例化,否则就是需要有构造函数的实例化。
  • 这里我们重点关注有构造函数的实例化,实例化方式为 clazz.getDeclaredConstructor(ctor.getParameterTypes()).newInstance(args);,把入参信息传递给 newInstance 进行实例化。

5. Cglib 实例化

cn.bugstack.springframework.beans.factory.support.CglibSubclassingInstantiationStrategy

// 定义一个类 CglibSubclassingInstantiationStrategy 实现 InstantiationStrategy 接口  
public class CglibSubclassingInstantiationStrategy implements InstantiationStrategy {  

    // 重写 instantiate 方法,根据给定参数使用 CGLIB 实例化一个对象,可能会抛出 BeansException  
    @Override  
    public Object instantiate(BeanDefinition beanDefinition, String beanName, Constructor ctor, Object[] args) throws BeansException {  
        // 创建一个 Enhancer 对象用于生成子类的实例  
        Enhancer enhancer = new Enhancer();  
        // 设置要增强的父类为 BeanDefinition 中定义的 bean 类  
        enhancer.setSuperclass(beanDefinition.getBeanClass());  
        // 设置回调,以便在对象方法调用时不做任何操作  
        enhancer.setCallback(new NoOp() {  
            @Override  
            public int hashCode() {  
                return super.hashCode(); // 返回对象的哈希码  
            }  
        });  
        // 如果构造函数为空,则直接创建无参构造函数的实例  
        if (null == ctor) return enhancer.create();  
        // 使用指定的构造函数和参数创建实例  
        return enhancer.create(ctor.getParameterTypes(), args);  
    }  

}
    
  • 其实 Cglib 创建有构造函数的 Bean 也非常方便,在这里我们更加简化的处理了,如果你阅读 Spring 源码还会看到 CallbackFilter 等实现,不过我们目前的方式并不会影响创建。

6. 创建策略调用

cn.bugstack.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory

// 定义一个抽象类 AbstractAutowireCapableBeanFactory 扩展自 AbstractBeanFactory  
public abstract class AbstractAutowireCapableBeanFactory extends AbstractBeanFactory {  

    // 定义一个实例化策略,使用 CglibSubclassingInstantiationStrategy  
    private InstantiationStrategy instantiationStrategy = new CglibSubclassingInstantiationStrategy();  

    // 重写 createBean 方法,根据 bean 名称和 BeanDefinition 创建 bean 实例,可能会抛出 BeansException  
    @Override  
    protected Object createBean(String beanName, BeanDefinition beanDefinition, Object[] args) throws BeansException {  
        Object bean = null; // 声明 bean 变量  
        try {  
            // 创建 bean 实例  
            bean = createBeanInstance(beanDefinition, beanName, args);  
        } catch (Exception e) {  
            // 捕获异常,抛出 BeansException,表示 bean 创建失败  
            throw new BeansException("Instantiation of bean failed", e);  
        }  

        // 将创建的 bean 添加到单例池  
        addSingleton(beanName, bean);  
        return bean; // 返回创建的 bean 实例  
    }  

    // 根据给定的 BeanDefinition 和参数数组创建 bean 实例  
    protected Object createBeanInstance(BeanDefinition beanDefinition, String beanName, Object[] args) {  
        Constructor constructorToUse = null; // 存储将使用的构造函数  
        Class<?> beanClass = beanDefinition.getBeanClass(); // 获取 bean 的类  
        Constructor<?>[] declaredConstructors = beanClass.getDeclaredConstructors(); // 获取声明的构造函数  
        // 遍历所有的构造函数以查找匹配的参数  
        for (Constructor ctor : declaredConstructors) {  
            // 如果参数不为空且构造函数的参数数量与给定参数数量匹配  
            if (null != args && ctor.getParameterTypes().length == args.length) {  
                constructorToUse = ctor; // 设置为当前构造函数  
                break; // 退出循环  
            }  
        }  
        // 使用实例化策略创建 bean 实例并返回  
        return getInstantiationStrategy().instantiate(beanDefinition, beanName, constructorToUse, args);  
    }  

}
  • 首先在 AbstractAutowireCapableBeanFactory 抽象类中定义了一个创建对象的实例化策略属性类 InstantiationStrategy instantiationStrategy,这里我们选择了 Cglib 的实现类。
  • 接下来抽取 createBeanInstance 方法,在这个方法中需要注意 Constructor 代表了你有多少个构造函数,通过 beanClass.getDeclaredConstructors() 方式可以获取到你所有的构造函数,是一个集合。
  • 接下来就需要循环比对出构造函数集合与入参信息 args 的匹配情况,这里我们对比的方式比较简单,只是一个数量对比,而实际 Spring 源码中还需要比对入参类型,否则相同数量不同入参类型的情况,就会抛异常了。

image.png