如何在C#项目中(特别是Unity3D游戏)模仿SpringBoot编写IoC依赖注入容器

322 阅读5分钟

5eb359ef238f46c2d25c49075423d33c30915729.jpg@1320w_740h.png

前言

这两天我用Unity写游戏,特别需要一个IoC(控制反转)容器来简化我的代码逻辑,达到业务之间解耦的效果。

我尝试了许多包括.NET自带的依赖注入(DI)容器,但使用起来都不太符合我的代码习惯。

比如说,如果我有接口类IMyService和实现类MyService,想要通过.NET的IoC依赖注入容器,我就得写一个这样的配置类来绑定服务和接口:

public void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<IMyService, MyService>();
}

这样写是不是很蠢?如果你写过SpringBoot,你应该知道,这种情况下在实现类类名上方打一个@Service注解就可以解决问题了。

说巧不巧,C#也有一个叫特性的东西,跟Java的注解几乎是一码事。因此我决定自行编写IoC容器,对SpringBoot进行拙劣但不失乐趣的模仿。

针对非Unity游戏类对象的依赖注入

我们先针对非Unity游戏进行操作。

我创建了一个解决方案,在解决方案下方建立了一个控制台项目ConsoleApp1和一个类库项目EasyInject,分别用来进行依赖注入测试和编写IoC容器。

image.png

在EasyInject/Attributes下,我建立了两个特性类,分别叫AutowiredAttribute和ComponentAttribute,前者用于打在成员变量字段前进行字段注入,后者用于标记这个类应该被视作类似于Bean的东西记录在IoC容器当中。代码如下:

namespace EasyInject.Attributes;

[AttributeUsage(AttributeTargets.Field)]
public class AutowiredAttribute : Attribute
{
}
namespace EasyInject.Attributes;

[AttributeUsage(AttributeTargets.Class)]
public class ComponentAttribute : Attribute
{
}

image.png

现在我们就可以编写IoC容器了。

先把代码放上来,然后我们一行行讲我的思路:

using System.Reflection;
using EasyInject.Attributes;

namespace EasyInject;

public class MyIoC
{
    private readonly Dictionary<Type, object> _services = new();

    public MyIoC()
    {
        // 扫描所有程序集中打了Component特性的类
        var types = AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(a => a.GetTypes())
            .Where(t => t.GetCustomAttributes(typeof(ComponentAttribute), true).Length > 0).ToList();

        // 先进行实例化(包括构造函数的依赖注入)
        while (types.Count > 0)
        {
            for (var i = 0; i < types.Count; i++)
            {
                var type = types[i];
                // 获取构造函数
                var constructors = type.GetConstructors();
                // 实例
                object? instance = null;

                // 遍历构造函数,找到可以实例化的构造函数
                foreach (var constructor in constructors)
                {
                    // 获取构造函数的参数
                    var parameters = constructor.GetParameters();
                    // 构造函数的参数实例
                    var parameterInstances = new object[parameters.Length];

                    for (var j = 0; j < parameters.Length; j++)
                    {
                        var parameterType = parameters[j].ParameterType;
                        // 如果IoC容器中有这个参数的实例,就注入
                        if (_services.TryGetValue(parameterType, out var parameterInstance))
                        {
                            parameterInstances[j] = parameterInstance;
                        }
                        else
                        {
                            break;
                        }
                    }

                    // 如果有参数没有实例化,就跳过这个构造函数
                    if (parameterInstances.Contains(null)) continue;
                    instance = constructor.Invoke(parameterInstances);
                    break;
                }

                // 如果没有找到可以实例化的构造函数,就找无参构造函数
                if (instance == null && type.GetConstructor(Type.EmptyTypes) != null)
                {
                    instance = Activator.CreateInstance(type);
                }

                if (instance == null) continue;
                // 注册进IoC容器
                _services[type] = instance;

                // 观察这个类是否实现了接口,如果有也要把接口作为key注册进IoC容器
                var interfaces = type.GetInterfaces();
                foreach (var @interface in interfaces)
                {
                    _services[@interface] = instance;
                }

                // 观察这个类是否继承了父类,如果有也要把父类作为key注册进IoC容器
                var baseType = type.BaseType;
                if (baseType != null)
                {
                    _services[baseType] = instance;
                }

                // 从待注册列表中移除
                types.RemoveAt(i);
                i--;
            }
        }

        // 开始进行字段的依赖注入
        foreach (var type in _services.Keys.ToList())
        {
            var instance = _services[type];
            var fields = type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
                .Where(f => f.GetCustomAttributes(typeof(AutowiredAttribute), true).Length > 0);

            foreach (var field in fields)
            {
                // 获取字段的类型
                var serviceType = field.FieldType;
                // 如果IoC容器中有这个类型的实例,就注入
                if (_services.TryGetValue(serviceType, out var value))
                {
                    field.SetValue(instance, value);
                }
                else
                {
                    throw new Exception($"No service of type {serviceType} found for autowiring");
                }
            }
        }
    }

    public T GetBean<T>()
    {
        return (T)_services[typeof(T)];
    }
}
  1. 首先,我们在IoC容器类当中声明了一个字典集合(类似于Java的Map键值对),用于存放Bean。字典的键是Bean的类型(Type),值是Bean的实例(object)。
  2. 在构造函数当中,我们首先通过反射,用types变量获取了当前应用程序域中,或者说程序集当中所有打了Component特性的类型。然后写了个while循环,开始遍历所有的类型,并尝试创建每个类型的实例,直到所有类都被实例化。
  3. 得益于反射的强大,我们可以很轻易地对类当中所有的构造函数进行遍历,如果发现有需要传参的构造函数,就尝试从已注册的Bean当中找到对应的Bean并注入进去,否则就跳过这个构造函数。
  4. 如果所有的构造函数都没有办法执行,就跳过这一次循环,看看其他类实例化并注册成Bean之后,可不可以拿来作为构造函数的参数使用。反之,能实现构造函数就会被我们当做Bean,并以他的类型作为Key存入字典当中。
  5. 接下来我们会观察这个类有没有实现接口或继承其他类,因为根据里氏替换原则,声明一个父类或接口类对象去存放子类或实现类是合理可行的。如果有,就把父类和接口类的类型也作为Key,这个Bean作为值存入字典。
  6. 当循环内所有的事情干完之后,从types当中移除这个类,证明它已经被注册成为Bean了。
  7. 然后,我们又遍历所有的Key,查看是否有打了Autowired特性的成员变量,如果有,继续获取类型然后在字典中寻找有没有类型匹配的实例,有则注入。
  8. 在完成构造函数之后,我们编写了一个GetBean方法,用于获取已注册的Bean。

现在我们回到控制台项目中。我创建了一个接口类ITestService,和一个实现类TestService。代码如下:

namespace ConsoleApp1.Service;

public interface ITestService
{
    public void Speak(string str);
}
using EasyInject.Attributes;

namespace ConsoleApp1.Service.impl;

[Component]
public class TestService : ITestService
{
    public void Speak(string str)
    {
        Console.WriteLine(str);
    }
}

image.png

可以看到,我在实现类前打了Component特性。

现在我们回到入口类文件当中,我编写了如下代码进行测试:

using ConsoleApp1.Service;
using EasyInject;
using EasyInject.Attributes;

#pragma warning disable CS0649 // 从未对字段赋值,字段将一直保持其默认值
#pragma warning disable CS8618 // 在退出构造函数时,不可为 null 的字段必须包含非 null 值。请考虑声明为可以为 null。

namespace ConsoleApp1;

internal static class Program
{
    public static void Main()
    {
        var ioc = new MyIoC();
        // 测试依赖注入
        var test = ioc.GetBean<Test>();
        test.Run();
        var test2 = ioc.GetBean<Test2>();
        test2.Run();
    }
}

[Component]
// ReSharper disable once ClassNeverInstantiated.Global
internal class Test
{
    [Autowired]
    // 这里在C#确实会让编译器报错,但实际上是被依赖注入了的
    private ITestService _testService;

    public void Run()
    {
        _testService.Speak("Test1");
    }
}

[Component]
// ReSharper disable once ClassNeverInstantiated.Global
internal class Test2(ITestService testService)
{
    public void Run()
    {
        testService.Speak("Test2");
    }
}

我写了两个类似于SpringBoot当中的控制类Test和Test2。前者通过字段进行注入,后者通过构造器进行注入。然后编写了Run方法输出字符串。

这两个类也要作为Bean存入IoC容器当中,否则不会被自动注入依赖。

不过由于编译器不知道这里打了Autowired就必定会让字段被赋值,所以会报警告,我们写两个#pragma无视就好了。

在Main方法当中,我实例化了IoC容器,这样就会遍历程序集进行依赖注入。这里不要手动new Test和Test2,应该从容器当中获取实例,然后执行Run方法测试效果。

执行代码,输出效果如下:

image.png

这就证明我们的IoC容器实现成功了。

针对Unity游戏类对象的依赖注入

相信各位已经迫不及待地想要在Unity项目里用这个依赖注入容器了。

我们先把刚才的EasyInject项目代码移动到Unity项目当中,特性类放在了Assets/Scripts/Attributes下,IoC容器放在了Assets/Scripts/Utils下。

由于Unity项目没有向我们提供C#启动入口类,所以需要我们自己编写一个脚本。

我们可以在脚本当中保留一个IoC容器的实例,然后利用Unity提供的特性,编写一个方法实例化IoC容器并在场景渲染前执行。代码如下:

using UnityEngine;
using Utils;

namespace Controllers
{
    public class GlobalInitializer : MonoBehaviour
    {
        public static MyIoC Instance;

        private void Awake()
        {
            transform.SetAsFirstSibling();
        }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
        private static void Initialize()
        {
            Instance = new MyIoC();
        }
    }
}

顺带一提,我在Awake当中让这个物件每次都挂在场景物件列表最上方,保证这个类是第一个被实例化的。

然后我们在每个有依赖注入需求的场景当中都创建一个空对象挂载该脚本即可。

image.png

然而这仅仅只是开了个头,在Unity当中想要这样实现依赖注入是很困难的。

对于非游戏物体组件脚本来说我们可以打Component特性,将其作为Bean存入字典中,但组件类是绝对不可以这么做,特别是通过构造器进行依赖注入的方法肯定是不行的。

如果你了解过Unity的底层实现原理,你应该会知道,如果脚本作为组件被挂载在物体上的话,在物体被渲染时,Unity会进行反序列化创建脚本实例。

而且我们的依赖注入容器是在启动时获取Bean的,生成物体后,新生成的脚本实例肯定不会被自动注册到字典里。

这意味着Unity创建的那个脚本实例和我们容器当中的那个实例不是同一个东西,所以我们不可以在继承MonoBehavior的类上使用[Component]特性。

问题倒也不是不能解决,虽然构造器注入不行,但这不代表字段注入不能用。

现在修改MyIoC的代码,我删除了GetBean方法,然后添加了这个方法:

/// <summary>
/// 这个方法一般用于找到所以正在运行的MonoBehaviour,然后进行字段的依赖注入
/// </summary>
/// <param name="instance">MonoBehaviour实例</param>
/// <exception cref="Exception">没有找到对应的实例</exception>
public void Inject(object instance)
{
    var type = instance.GetType();
    var fields = type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
        .Where(f => f.GetCustomAttributes(typeof(AutowiredAttribute), true).Length > 0);

    foreach (var field in fields)
    {
        // 获取字段的类型
        var serviceType = field.FieldType;
        // 如果IoC容器中有这个类型的实例,就注入
        if (_services.TryGetValue(serviceType, out var value))
        {
            field.SetValue(instance, value);
        }
        else
        {
            throw new Exception($"No service of type {serviceType} found for autowiring");
        }
    }
}

如果你观察的比较仔细,会发现这里的代码和之前写的字段注入的内容完全一致。

我的思路很简单,既然我们没办法自己去找新生成的物体组件脚本实例在哪,让他来找我们就行了。

但问题是,什么时候找?怎么自动找?

我又想了个招,我编写了一个抽象类InjectableMonoBehaviour,继承自MonoBehaviour,然后在Start生命周期钩子当中注册自己,有需要依赖注入的类就继承这个抽象类。

然后我添加了一个OnStart方法代替Start,并在Start当中调用这个方法,之后继承这个类的类都得把原本写在Start当中的方法写在这里面。

代码如下:

using Controllers;
using UnityEngine;

namespace Utils
{
    /// <summary>
    /// author: spyn
    /// description: 可注入的MonoBehaviour
    /// </summary>
    public abstract class InjectableMonoBehaviour : MonoBehaviour
    {
        /// <summary>
        /// 在Start方法中注入依赖
        /// </summary>
        private void Start()
        {
            GlobalInitializer.Instance.Inject(this);
            OnStart();
        }

        /// <summary>
        /// 自己的Start方法
        /// </summary>
        protected abstract void OnStart();
    }
}

现在我们来试试看能不能起作用。

我把HTTP请求服务类也做成了接口类和实现类的模式,实现类前打了Component特性。

image.png image.png

在控制类当中,我们继承刚刚写好的InjectableMonoBehavior类,然后添加service接口类的成员变量。

image.png image.png

调用服务也得像这样调用:

image.png

运行游戏,试试看能不能获取到后端数据。

image.png

可以看出来,我们通过依赖注入调用了HTTP服务类,拿到了关卡列表。

总结

然而,既然我们在Unity当中都做出来这个依赖注入容器了,就不要只是在接口类和服务类的里氏替换原则上用用便作罢。

平时我们写游戏的时候,经常会把UI和游戏内一些单例物件拖拽到脚本类的可序列化字段上进行赋值链接。

至少对我来说,这样做无疑是在我的编程精神洁癖区域反复横跳。耦合度如此之高,实在让人心生厌烦。

我们可不可以把这个物件脚本注册成Bean,然后通过[Autowired]注解去进行依赖注入呢?

当然,这里IoC的代码就会变得很复杂了,因为你可能需要给同类型的Bean设置不同的名称去进行依赖注入,你得继续模仿SpringBoot,修改Autowired的代码去进行传参处理。

针对上述的观点,我确实做出了一个Unity3D仅通过特性实现依赖注入的框架,暂定名为UnityEasyInject,大家可以戳进去看看GitHub仓库。

但不管怎样,大家必须得亲手做一做。在软件开发的过程中,探索问题的解决方案应该是一件很有趣好玩的事情,不去动手做真的会错过很多乐趣。

希望大家还是能明白我这句话说的道理,不然不管是我的这篇文章还是读完这篇文章的你,都会显得有亿点相对失败。