从零实现一个 Java 微框架 - IoC

Otstar Lin

前言

IoC 容器在之前的文章中就有说明。

之前的文章其实是基于 PHP 的,虽然思想是类似的,不过还是再次说明一下吧。

IoC 是什么?

IoC(Inversion of control,控制反转),它是一种思想而不是一个技术实现(组件),通常也和 DI(Dependency Injection,依赖注入)一同出现,这两者其实可以看成一个东西。不过 DI 更多的是指注入的过程或方式(B 对象注入到了 A),IoC 更多的是指这种反转思想(B 对象交给了外部管理)。

为了更好的描述 IoC,这里我们就引入一个样例吧,就拿我前几天购买的一个阅读器来说吧。既然是阅读器,那么我们肯定是要有个阅读器的类:

其中,我们需要一个 BookStorage 来存储阅读器里存放的书,然后阅读器拥有 read 阅读和 put 存新书的功能。

通常情况下我们会将所依赖的 BookStorage 的对象直接在构造器中 new 出来,这是最简单且直接的使用方式。但是这种简单的方式也导致了一种问题,如果哪天需要开发一个基于网络的阅读器,那么我们的存储不再是 FileBookStorage,而应该是 NetworkBookStorage,此时为了能制作出网络阅读器我们就不得不重新写一个 BookReader 类,然后重新实现内部的逻辑。

这时候肯定有人会提出应该把 BookStorage 从外面通过构造器传入不就可以了?其实当你提出这个疑问的时候你已经可以说了解 IoC 了,这种通过外部传入依赖的方式就称为依赖注入,也就是控制反转的思想

控制反转中的控制指的是对对象管理、创建的权力;反转指的是将这个控制权交给外部环境,至于外部环境可以是几行代码、IoC 容器。使用者只负责使用依赖,至于依赖是如何构造、管理的这就不关使用者的事了。

利用 IoC 思想改造后的构造器如下:

改造后虽然我们丢失了创建 BookStorage 的功能,不过相对的这种方式解决了各部件间强耦合的问题,我们可以通过给 BookReader 传入不同的 BookStorage 来灵活的实现及复用。

IoC 容器

IoC 容器(IoC Container)一般也称为 IoC 服务提供者(IoC Service Provider),简单的说就是用来自动化创建依赖以及管理依赖的工厂。由于经常被简称为 IoC 所以也很多人会认为 IoC 就是 IoC 容器,其实 IoC 容器只是用来方便实现 IoC 思想的一种工具。

最简单的 IoC 容器包括了以下几种功能:

  • 对象的构建管理:当我们需要某个对象的时候无需关心它是如何被创建出来的、需要什么依赖关系,这个过程就是由 IoC 负责的。
  • 对象的依赖绑定:为了对对象进行构建,IoC 容器需要知道对象的依赖关系。
  • 对象的存储管理:既然是容器那就需要有存储的功能,IoC 容器可以依照需求存储单例对象或依赖(需要存储的依赖不单单是 Bean 对象)。

对于现在的 IoC 容器来说注册对象管理信息一般有以下 3 种:

  • 直接编码:即调用注册方法 regsiter 将对象注册到 IoC 容器中。
  • 配置文件:在 Spring IoC 等容器一般都存在这种配置方式,通过配置文件配置 IoC 容器的对象及依赖关系。
  • 元数据:元数据的方式则较为广泛,注解、类型、变量名等等都可以作为元数据来指引 IoC 容器注册和使用对象。

通常情况 IoC 容器有以下几种注入方式:

  • 构造器注入
  • Setter 方式注入
  • 字段注入:字段注入可以归入 Setter 注入,有一定侵入性,是利用反射直接设置字段值的方式。
  • 接口注入:接口注入是比较特殊的,带有侵入性,不一定所有 IoC 容器都支持,是通过实现接口方法来取得注入的依赖。

设计 IoC 容器

既然已经知道了 IoC 容器需要的功能,那么就可以开始设计我们自己的 IoC 容器了。文章可能会结合一些 Spring 的东西。不过本篇文章主要是写我自己的 Java 框架,所以 Spring 就点到为止了,如需深入的话可以看别的源码解析的文章或者书籍。比如这篇大佬写的文章就还不错。

XK-Java IoC 容器的设计较为简单,其设计最初的参考是来自 Laravel,不过经过后续不断的重构已经比较类似 Spring IoC 了。

XK-Java IoC 容器所需要存储的数据:

  • 实例元信息(Binding, BeanDefinition):保存实例的一些信息,如作用域、名称、注解等一系列容器需要使用的信息。
  • 实例(Instance, Singleton):由于有些实例是以单例的状态存储的,所以容器还需要为这些实例提供存储。
  • 别名(Alias):实例可以有别的名称。
  • 类型索引(BeanNameByType):通常情况下我们都不会对每个 Bean 都进行配置,所以 IoC 容器一般是使用类型来进行自动注入的,为了能快速的查询到指定类型的依赖,我们需要对每个绑定到 IoC 容器的依赖都进行类型遍历,然后建立索引。
  • 注入器 & Bean 处理器(Injector & BeanProcessor):为了方便扩展和实现额外的功能就不能把构建的流程封闭到 IoC 容器,所以需要通过注入器或者 Bean 处理器来处理实例或依赖。
  • 作用域(Context, Scope):IoC 容器不可能只存储某个作用域的实例,通常有多种作用域,比如单例域、非单例、请求域等多种作用域来存储实例。
  • 临时依赖(DataBinder):由于部分情况需要临时注入一些依赖,比如 HTTP 的请求参数,这就需要有一个数据容器来临时存储这些参数依赖。

XK-Java IoC 容器所需要的功能:

  • 绑定:用于将依赖绑定到容器中,依赖可以是已经构建完成的实例、立即值、工厂类等等。对应 binddoBind 方法。
  • 构建:当某个依赖被依赖的时候,而且依赖没有构建的时候就需要对依赖进行构建。对应 doBuild 方法。
  • 获取:当我们需要某个实例的时候,就需要从 IoC 容器获取。对应 makedoMake 方法。
  • 销毁:当依赖被从容器删除,或者容器关闭的时候就需要对依赖进行销毁操作,如关闭连接池等等的操作。对应 removedoRemove 方法。

实现

有了大致的设计我们就可以开始动工了。

首先需要准备一些周边的类和接口,以下这些周边类是属于 IoC 的一部分,有些其他的类,如 MergedAnnotation,就不在这里说明了。

有了上面这些周边类,就可以进入下一部分了。

DataBinder

由于 XK-Java 的设计与 Spring 有所不同。在 XK-Java 的设计里,所有需要注入的(如注入单例,注入请求参数) Java 实例都应该由 IoC 容器负责构建。通常情况下我们有可能会临时设置一些依赖,而又不希望这些临时的依赖托管到容器里,这时候就需要 DataBinder 来负责存储。

DataBinder 被设计为简单的依赖存储容器,所以无需非常多的元信息,通过实例的名称、类型、注解即可取得其存储的实例。可以认为是缩小版的 IoC 容器。

DefaultDataBinder 是默认的实现类:

Context

Context 类似于 Spring 的 Scope,用于作用域隔离,比如线程私有实例,请求私有实例等等。具体的可以参考 Spring 的 Scope 的作用。

可以看到 Context 其实就是一个类似 Map 的容器,只不过有些其他的方法:

  • isShared 方法用于确定是否是单例,如果是单例 IoC 容器会在构建实例完后将单例存入 Context
  • isCreated 方法用于确定实例是否启动,避免在未启动的时候错误使用。
  • useProxy 方法用于确定实例是否需要 Cglib 代理来实时获取最新的值,避免发生使用旧对象情况,或者将 Request 作用域对象注入到 Singleton 作用域导致线程不安全。 有了接口自然有实现类,以下是几个不同场景的实现类,这几个实现类由于代码简单就不细说了:
  • SingletonContext:单例作用域,里面就是一个简单的 ConcurrentHashMap
  • PrototypeContext:原型作用域,不存储实例,是非共享的。
  • RequestContext:请求作用域,这个作用域和 SessionContext 一样比较特殊,是存储于对应作用域的对象里,比如 Request 是存储于 HttpServletRequest 的 Attribute 里,Session 则存储于 HttpSession 里。

Binding

Binding 是实例元信息的实现类,与 Spring 中的 BeanDefinition 类似,不过由于不需要太多的功能就只保存了几种元信息:

  • 作用域名称(scope):实例对应的作用域名称。
  • 名称(name):实例名称,全局唯一。
  • 类型及类注解(instanceTypeEntry):实例的类型及标记在该类上的组合注解。
  • 是否是主要的实例(primary):如果是主要注解,当通过类型获取实例,同时该类型下有多个不同的实例,则优先使用 primarytrue 的实例。
  • 额外信息(bindingInfos):带软引用缓存的一些信息,有 init-destory 对应的方法反射和 autowired 需要注入的方法,以及字段和方法的反射以及注解。同类型并且有缓存的情况下就不需要再扫描。

除了元信息外 Binding 还保存了一些其他的数据:

  • Context:作用域,为了 Binding 内部方法方便操作,避免要使用的时候从 IoC 容器中获取。
  • FactoryBean:创建实例的工厂方法,如果未设置 IoC 容器会默认使用 doBuild 方法构建实例。
  • MutexBinding 的互斥量(锁)用于避免多次初始化单例使用。将互斥量分散到不同的 Binding 中,避免使用唯一互斥量导致大量阻塞的发生。同时单例也采用双重检查模式,避免每次获取的时候都锁住互斥量导致阻塞。

数据部分说完了就开始看方法吧,首先是初始化,初始化就是对 Binding 内的字段进行赋值,扫描对应类型的方法、注解等等的信息,对于消耗性能的扫描部分则使用软引用,空间换时间,避免重复扫描,同时在内存不足的时候可以时间换空间,一定程度避免 OOM 发生。

然后 Binding 里剩下的重要操作就是对实例的操作了:

Injector

与 Spring 不同的是,XK-Java 为了更好的扩展性添加了注入器(Injector)的设计,其作用仅作为对实例或参数进行注入。目前共有两种类型的 Injector

添加注入器的方式非常简单,只需要给注入器的类添加 @Injector 注解,注解扫描器会自动识别注入器并添加到 IoC 容器中。同时可以使用 @Order 注解来对注入器进行排序。

DefaultParameterInjector 是默认的参数注入器,其负责通过参数的元信息构建或获取参数,然后让 IoC 容器可以构建实例或者调用方法:

注入规则:

  • 标记了 @DataBind 方法。利用 @DataBind 的一些限制查找依赖注入。
  • 标记了 @Value 方法。使用 @Value 的表达式进行查找注入。
  • 未标记任何注解。使用 DataBinder 进行注入。

DefaultPropertyInjector 是默认的字段(成员)注入器,与 PropertiesValueInjector 一同作用,用于对实例字段进行注入:

注入规则:

  • 字段存在 Setter 方法。使用 Setter 方法注入。
  • 字段标记了 @Autowired 注解,但是没有 Setter 方法。直接反射设置。
  • 字段标记了 @Autowired 注解,同时有 Setter 方法。使用 Setter 方法注入。

PropertiesValueInjector 是用于注入配置文件的注入器:

注入规则:

  • 字段标记了 @Value 注解。使用 @Value 注解注入,需要注意注入的 Properties 来源是 @ConfigurationProperties@PropertySource 的设置,如果没这两个注入则是 Environment,也就是根配置。
  • 类上有 @ConfigurationProperties 注解,此时就按字段上的注解或者字段名称来注入。

然后就是 DefaultMethodInjector 了,这个是用于注入标记了 @Autowired 注解的方法:

BeanProcessor

BeanProcessor 是在实例在构建前、构建后、销毁时进行一些额外操作的处理器,有以下 3 种处理器:

  • BeforeInjectProcessor:在实例进行构建和注入前进行一些操作,比如 PropertiesProcessor 提前进行一些数据收集。
  • BeanAfterCreateProcessor:在实例构建后进行一些操作,如创建 Aop 代理,调用 PostConstruct 方法等。
  • BeanDestroyProcessor:在实例销毁时进行一些操作,如调用 PreDestroy 方法。

这部分因为功能需求不高就几个实现类,这里就拿 AopBeanProcessor 来举个例子吧:

Container

接下来就到了最重要的 IoC 容器部分了,有了上面的准备,容器就可以很方便的进行编写了:

Container 里较为重要的方法都已经通过注解说清楚了。不过有些不重要的部分这里就不贴了,主要是代码太长了 2333。

结语

到这里 XK-Java 的 IoC 容器部分就写完了。

文章十几天了终于肝完了,主要也是内容比较多,也相对复杂,和上次的 XK-PHP 相比简直是小巫见大巫。所以文中可能会有错误,同时毕竟我也不是什么业界大佬,没有丰富的经验,代码里可能有挺多错误,如果你发现了错误可以在底下评论。溜了溜了 🤣。