编写一个利用代码生成器的依赖注入框架
从依赖注入说起
尽管依赖注入的作用,DIP,IoC 这些概念不是本文讨论的重点,它们仅仅是你使用依赖注入的理由,但是要编写一个依赖注入框架,首先需要了解依赖注入的概念。
什么是依赖注入
简单地说,依赖注入是一个将依赖对象注入到类中的过程。再具体一点,就是将类的依赖的对象赋值的过程。然而不同的是,这个过程是手动完成的?还是自动完成的?是通过构造函数?还是通过外部直接赋值?还是通过其他方式?显然,为了实现高效的依赖注入,我们需要一个框架来帮助我们完成这些工作。
依赖注入框架的工作原理
由于.NET 的传统是通过构造函数来实现依赖注入,我们在此以这种方法为例,来说明依赖注入框架的工作原理(尽管我们要实现的框架不使用这种方式)。让我们先来看一段使用依赖注入的代码:
假设我们有这些类:
1 | class HighLevelService(Module module) |
在这个样例中,我们通过HighLevelService来实现某项功能,但是HighLevelService需要某个低级模块LowLevelModule来完成这项功能。
让我们来观察一下使用依赖注入框架的代码,这里以Microsoft.Extensions.DependencyInjection为例:
1 | using Microsoft.Extensions.DependencyInjection; |
不管是什么框架,我们的工作流程都是以下几步:
- 创建一个服务集合,用于记录所有服务的注册信息。
- 注册服务,将服务的类型和生命周期注册到服务集合中。
- 构建服务提供者,将服务集合构建成一个服务提供者。
- 获取服务,通过服务提供者获取服务。
- 使用服务。
但还没完,我们还有许多问题待解决:
- Transient, Scoped, Singleton 这些生命周期是如何实现的?
- ServiceProvider 的内部是如何实现的?
- 如何在框架中检测一个类的依赖有哪些?
- 如何创建出这些依赖?
依赖反射的依赖注入实现
对于问题 4,显然我们熟悉的new不能够支持我们的需求,我们需要一个更加灵活的方式来创建对象。在 C#中,我们可以通过反射来创建对象。
1 | Activator.CreateInstance(typeof(Module), new object[] { /*params of the ctor*/}); |
依赖反射,只要有一个类的类型,以及参数,我们就可以创建出这个类的实例。
对于问题 3: 我们通过反射来获取一个类的构造函数,然后获取这个构造函数的参数列表,这些参数的类型就是这个类的依赖。对于每个依赖,我们都像上文一样递归地获取它的构造函数,直到没有依赖为止。
不过,这种方式有一个问题,就是性能问题。反射是一个非常慢的操作,而且我们每次都要通过反射来获取构造函数,这是一个非常耗时的操作。所以我们需要一个更加高效的方式来实现依赖注入。
.NET 提供了源生成器,我们可以通过源生成器来生成代码(包括Source Generator和IIncrementalGenerator,前者由于性能问题正逐渐被淘汰),这样我们就可以在编译时生成代码,而不是在运行时生成代码。这样我们就可以避免反射的性能问题。
依赖注入框架的实现
利用源生成器的依赖注入
但是源生成器不是万能的。由于源生成器只能生成新的代码,而不能修改现有的代码。我们不得不考虑一下如何实现对字段的赋值,或者将依赖传入构造函数。
通过字段赋值的依赖注入
对于使用字段赋值的依赖注入,我们只能在这个类的内部对依赖字段进行赋值。所以我们需要将这个类标记为partial,然后为这个类添加方法,利用这个方法对类的字段的访问性来实现依赖注入。
现在让我们回到上文中使用依赖注入的例子:
1 | class HighLevelService |
要让框架知道哪些是我们要注入的字段,我们可以定义一个InjectAttribute,然后在这个字段上添加这个属性。
1 | class HighLevelService |
这样,在源生成器分析代码时,我们就知道了哪些是我们要注入的字段。现在,假设我们已经实现了这个框架的其他所有部分,我们可以通过Provider.GetRequiered<LowLevelModule>()来获取依赖。我们要如何把获取的依赖赋值给_module呢?前文提到,我们需要依赖HighLevelService自身的方法来获取对依赖字段的访问性。所以我们可以用源生成器生成一个这样的方法:
1 | // 我们编写的代码 |
这样在原理上似乎已经可行了,但是我们还有一个问题:我们如何调用这个方法呢?或者说我们应该让谁来调用这个方法呢?显然不能是HighLevelService,这样的依赖注入丧失了一部分的自动化。所以这个重任就落到了我们的框架上。我们需要在ServiceProvider中调用这个方法。但是框架如何知道这个方法的存在呢?或者说,我们要求所有能够依赖注入的类都有这个方法,那么这样答案就很显然了。我们可以定义一个接口ICandicate:
1 | interface ICandicate |
然后,在使用Provider.GetService<HighLevelService>()时,我们的框架便可以调用这个方法。
现在看起来我们已经有了一个相当可行的方案。但我们还有一个问题没有解决:如何创建这个HighLevelService对象,以及其他依赖对象。在下文中我们会讨论这个问题。
通过构造函数的依赖注入
对于使用构造函数的依赖注入,我们不得不考虑一个问题:我们如何不依赖反射来创建对象。由于我们不使用反射,因此new是我们唯一的选择。那么问题就变成了在哪里使用new。
其实解决方案不止一种。在介绍我们的方法前,我打算为大家介绍一个颠覆我们对于依赖注入框架的理解的依赖注入库Pure.DI 。事实上,他们自称,这根本就不是一个库或者框架。这个库通过分析依赖路径,创建对象图,不依赖任何容器和框架。也就是说,对于我们上文中的例子:
1 | class HighLevelService(Module module) |
这个框架生成一个这样的代码:
1 | partial class Comsumption |
核心就在于
1 | return new Service( |
这是通过算法分析依赖路径生成的代码。这样只需要获取Comsumption.Root就可以获取到所需的对象,而依赖都通过构造函数注入了。而且,更重要的是,我们真的不需要容器了,要获取我们的对象,我们直接new Comsumption().Root,这等价于我们的Provider.GetRequiredService<HighLevelService>()。完全地摆脱了容器。
关于这个库的更多信息,请访问Pure.DI 。
不过这种方法还是太高级了一些,不适合我们为了了解依赖注入框架工作原理并尝试编写一个使用源生成器的依赖注入框架的目的。所以现在让我们回到我们的问题:如何创建对象。
现在我需要剧透一下下一节的内容,假设我们的对象已经创建了,我们调用的GetService<T>()函数实质上是通过一个System.Type的对象映射到object对象来获取组件。
那如果对象没有被创建呢?我们可以通过System.Type到一个工厂方法的映射来创建对象。
事实上,许多依赖注入框架允许你传入自己的工厂方法用于注册服务,就像下面一样。
1 | services.AddTransient<HighLevelService>(() => new HighLevelService(new Module())); |
但是为了实现自动化,我们计划让源生成器来生成这个工厂方法。与上文中的Pure.DI不同的是,我们不会直接把依赖new出来传给HighLevelService的构造函数。而是通过我们的框架来GetRequiredService<Module>()。
所以事实上我们的源生成器应该生成下面的工厂方法:
1 | Func<Provider, object> _factory = provider => |
这样的话,我们的框架就能够控制依赖的生命周期了。这也是Pure.DI目前还没有实现的功能。因为他们将生命周期管理交给了用户,对于Singleton类型,你需要自己将对象持久地保存在一个静态字段中。对于Transient类型,你可以每次都new一个Comsumption对象。但是对于Scoped类型,他们目前还没有解决方案。当然,对于任何框架来说,Scoped类型都是一个比较棘手的问题。
现在,我们需要想办法将每个类型的工厂方法的委托保存起来。显然我们可以用哈希表来储存这些委托。但是我个人认为RBtree也是一个不错的选择。
如果我们自定义自己的委托类型,并且重写GetHashCode方法(事实上我们的委托可以仅仅通过对象类型Type来获取哈希),我们仅仅只需要多花几次计算哈希值的时间,就可以获取到需要的对象。而这几次计算哈希的时间对于构造函数来说是微不足道的。并且你还可以将哈希持久化地储存到字段中(因为我们的哈希仅仅需要和类型 Type 相关,而类型是不会变的)。取而代之的是,我们减少了大量的内存开销。当然,这只是我个人的想法。而且我仅认为你应该在ServiceAccessor的映射中使用RBtree,而在System.Type到object的映射中应该使用哈希表。
现在我们知道了如何创建对象,应该用什么数据结构来储存工厂方法。但是还有些实际问题要思考。我们生成的工厂方法应该定义在哪里?假如我们定义在实际要创建的类型的类里面,我们可以为类型生成下面的代码:
1 | partial class HighLevelService |
但这仅对于我们自己写的类型有效。假如我们想要将一个System.Stopwatch作为一个服务,或者我们的服务依赖System.Stopwatch,那怎么办呢?我们不能修改System.Stopwatch的源码。因为partial仅仅对于Roslyn有意义,在CLR层面和IL层面都不存在partial,分部类都被Roslyn整合到了一起,因此我们无法生成System.Stopwatch的工厂方法。
所以我们生成的工厂方法只能在实际使用框架的项目中,而不能是我们的依赖注入框架。
让我们看看现有的使用源生成器的依赖注入框架是如何解决这个问题的。osu.Framework的框架不支持自动类型创建,因此也不使用工厂方法,但是对于有无参构造函数的依赖(可以直接new()),会在Inject委托中直接构造对象(这也意味着依赖注入框架不能实现生命周期管理)。但是osu.Framework的依赖注入框架和框架内的其他代码紧密结合,因此不单独解决这个问题。
让我们再来看看pakrym/jab,在这个项目的README.md中,我们可以看到这个库的使用方法:
1 | [] |
看到这里,你应该有一个思路了,既然我们的工厂方法只能在实际使用框架的项目中,那我们就在自己的项目中定义一个partial类,并标注为ServiceProvider。这样源生成器就可以将这个类补充成一个完整的ServiceProvider。并且,我们的工厂方法也可以定义在这个类中。为了提高工厂方法的复用性,我们可以用一个静态的容器类来储存工厂方法。然后在Type initializer中将工厂方法添加到容器类中。这样我们不管定义多少个ServiceProvider,我们的工厂方法都可以被复用。
这里要说明的一点是,我并没有检查pakrym/jab的代码或使用这个库生成的代码,这意味着我并不知道这个库是如何实现的。这只是我根据README.md中样例代码的推测。不过,我们确实有了一个切实可行的方案。
如果你希望了解其他的使用源生成器的依赖注入框架,可以自行查看一下仓库:
容器的实现
现在我们来考虑问题 2。与其从问题本身出发,不如从我们的目标出发。假设我们就是GetService<T>()方法,对于需要创建的服务,我们直接按照上文的方法创建对象,而对于已经创建的对象,我们需要从某个地方拿到这个对象。而我们是如何获取这个对象的呢?是对象的类型(或者接口)。这之间是一个Type到object的映射。要具有这样的映射关系,同时还要保证速度。我们很容易想到使用哈希表。事实上,也确实是这样。我们可以使用Dictionary<Type, object>来实现这样的映射关系。这样我们就可以在O(1)的时间复杂度内获取到我们需要的对象。当然,这只是简单的实现。不过,这就是我们的容器的核心。或者说,这就是一个简单的 DI 容器。
这 是我在我自己的项目中实现的一个简单的 DI 容器。事实上就是一个哈希表的包装。也正是因为简单,促使了我计划重新实现一个更加完善的 DI 框架并在本文中探讨 DI 框架的设计。
了解了容器的实现原理,我们就可以开始扩展我们的思维,架构整个框架的设计了。
依赖注入框架的设计
首先我们需要考虑我们要实现哪些功能。既然我们以经知道了我们的框架的核心是一个简单的 DI 容器和一些具体的实现细节,那么我们的框架的功能就是对这个容器的操作。但是要实现哪些操作?我们不妨再来看看使用依赖注入框架的代码的工作流程。
生命周期管理
对于Transient,我们只需要在GetService时创建对象并返回即可。
对于Singleton,如果对象不存在,我们需要创建对象并返回,如果对象存在,我们直接返回对象。
然而Scoped就稍显复杂一些了。表面上和Singleton一样,如果对象不存在,我们需要创建对象并返回,如果对象存在,我们直接返回对象。但是Scoped的对象的生命周期是和Scoped相关的。但是Scoped对象的Scope到底是什么呢?或者说Scoped对象的 Lifetime 到底是依据谁的生命周期呢?
在Microsoft.Extensions.DependencyInjection中,当我们创建了一个ServiceProvider,我们会发现这个 provider 有一个CreateScope()的方法,这意味着Scoped对象的生命周期是和 Provider 相关的。同时,当我们查看ServiceCollection的代码,我们会发现ServiceCollection只是一个登记了ServiceDescriptor的集合,这也暗示着一切的生命周期的管理和实现都是在ServiceProvider中的。
那让我们写一些片段代码来研究一下Scope到底是什么。
首先我们来定义一个服务对象,这个对象只需要能够区分出不同的实例即可。
1 | class Service |
然后测试以下代码:
1 | ServiceCollection services = []; |
运行代码,我们会发现每一次得到的都是同样的输出。
1 | caiyi@archlinux ~/r/di> dotnet run |
让我们做一些修改:
1 | ServiceCollection services = []; |
再次运行代码,我们会发现每一次得到的都是不同的输出。
1 | caiyi@archlinux ~/r/di> dotnet run |
再试试我们提到的CreateScope()方法:
1 | ServiceCollection services = []; |
运行代码,我们会发现每一次得到的都是不同的输出。
1 | caiyi@archlinux ~/r/di> dotnet run |
现在你应该能理解了,Scoped对象的生命周期是和一个IServiceScope相关的,而IServiceScope是和ServiceProvider相关的。所以Scoped对象的生命周期是和ServiceProvider的生命周期相关的。也就是说,Scoped对象的生命周期是和ServiceProvider的生命周期一样的。
事实上,IServiceScope里有一个ServiceProvider,默认情况下,我们使用BuildServiceProvider创建的ServiceProvider只是省略了包含它的IServiceScope。而ServiceProvider的CreateScope方法依然是通过创建它的ServiceCollection的CreateScope方法。如果你直接查看CreateScope方法的源码,你会发现只有一行代码:
1 | public static IServiceScope CreateScope(this IServiceProvider provider) |
上面的样例代码中rootProvider的命名可能会让你误解,但是rootProvider并不是ServiceProvider的根,而是ServiceProvider的一个实例。事实上,通过CreateScope方法创建的IServiceScope的ServiceProvider的对象们之间是平行关系,和rootProvider之间也是平行关系。
考虑到IServiceScope和ServiceProvider极为紧密的关系,我们可以理解为Scoped对象的生命周期是和ServiceProvider的生命周期一样的。
但现在又引出了一个问题,既然Singleton服务在任何时候都返回同一个实例,那么不同的ServiceProvider也会返回同一个实例。这意味着Singleton不是储存在ServiceProvider里的。那么不同的ServiceProvider是如何返回同一个实例的呢?
查看ServiceProvider的源码,我们可以发现ServiceProvider内部有一个这样的字段:
1 | internal ServiceProviderEngineScope Root { get; } |
那么关于生命周期管理的一切疑问都已经解决了。
注入依赖
对于注入依赖,我打算支持构造函数注入和字段注入。
构造函数注入
对于构造函数注入,前文我们已经讨论过了,我们使用源生成器,扫描Candicate的构造函数的参数列表,在生成的工厂方法中,将每个参数都通过传入的provider获取到依赖,然后构造出new语句并返回。它应该是类似下面的形式:
1 | // 对于其他服务的工厂方法... |
递归地,最终一定会找到具有无参构造函数的依赖或者已经传入了工厂方法的依赖。这样我们就能构造并注入所有的依赖了。
字段注入
在C#中,我们不需要单独的setter,因为你可以定义成属性,而属性和字段都可以直接赋值。因此我们事实上支持所有的注入方式。
前文提到,为了支持私有字段和私有属性的注入,我们需要在Candicate的内部生成方法,利用内部方法对私有字段的可访问性提供对字段的赋值能力。现在,考虑下面的一个类:
1 | partial class HighLevelService |
我们可以生成下面的代码:
1 | partial class HighLevelService : IDICandicate |
注意我这里将ActivateCandicate方法标记成了virtual,因为我们不止希望注入基类,还希望能够注入子类。这样的话,子类生成的ActivateCandicate只需要调用一下base.ActivateCandicate()就可以将基类的字段注入依赖。
然后,当我们通过ServiceProvider调用GetRequired<HighLevelService>时,ServiceProvider先通过构造函数注入的方式,使用工厂模式构造实例,然后在将实例强转为IDICandicate并调用ActivateCandicate。
容器API的设计
现在我们对一个依赖注入框架的实现的每个部分都非常清楚了,现在我们可以开始设计我们的 API 提案了。
这里以Microsoft.Extensions.DependencyInjection的语法为例,先考虑我们使用DI的基本工作流:
1 | ServiceCollection services = []; |
我们需要一个Services登记表,可以添加,删除Service记录信息。现在我们假设这就是一个
List<ServiceDescriptior>这个登记表可以构造出我们的ServiceProvider. 基本上,对于我们这种使用源生成器的实现来说,我们只需要将
List<ServiceDescriptior>拿过来就能够完成构造。当然我们会出于性能原因把这个List转换成Dictionary。ServiceProvider本质上也就一个功能:获取对象。我们直接用伪代码来表示吧:
1 | class ServiceProvider |
这样就实现了整个依赖注入框架和容器。当然,我们还需要编写源生成器和Analyzer来提供其他行为。不过由于本文只是从理论层面解决编写利用源生成器的依赖注入框架所面临的问题,实际的实现在此不会讨论。并且使用源生成器对类进行分析的过程和是否反射对类分析的过程相当相似。
- 标题: 编写一个利用代码生成器的依赖注入框架
- 作者: Caiyi Shyu
- 创建于 : 2024-03-13 21:23:30
- 更新于 : 2024-09-30 18:43:49
- 链接: https://blog.caiyi1.me/2024/03/13/Build-a-Dependency-Injection-Framework/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。