DEV Community

Cover image for ZeroIoC - IoC container powered via Source Generators
Stanislav Silin
Stanislav Silin

Posted on

ZeroIoC - IoC container powered via Source Generators

The main goal of the ZeroIoC is to provide IoC for AOT platforms such as Xamarin, Unity, and Native AOT. It is powered by Roslyn Source Generator as a result executed on build and doesn't require Reflection.Emit to function.

Get Started

  1. Install the NuGet package ZeroIoC to your project.
dotnet add package ZeroIoC
Enter fullscreen mode Exit fullscreen mode
  1. Declare your container that is inherited from ZeroIoCContainer as a partial class

    public interface IUserService
    {
    }

    public class UserService : IUserService
    {
        public Guid Id { get; } = Guid.NewGuid();

        public UserService(Helper helper)
        {
        }
    }

    public class Helper
    {
        public Guid Id { get; } = Guid.NewGuid();
    }

    public partial class Container : ZeroIoCContainer
    {
        protected override void Bootstrap(IZeroIoCContainerBootstrapper bootstrapper)
        {
            bootstrapper.AddSingleton<Helper>();
            bootstrapper.AddTransient<IUserService, UserService>();
        }
    }

Enter fullscreen mode Exit fullscreen mode
  1. Use your container:
  var container = new Container();
  var userService = container.Resolve<IUserService>();
Enter fullscreen mode Exit fullscreen mode

How it works

The NuGet is deployed with the source generator and analyzer. Then it looks for class declarations that are inherited from the ZeroIoCContainer. Inside the generator looks for the ZeroIoCContainer.Bootstrap method. Based on its content, the source generator will generate another part of a partial class. For the case described above, it will look like that(skipping the performance magic):


public partial class Container
{

    public Container()
    {
        Resolvers = Resolvers.AddOrUpdate(typeof(global::Helper), new SingletonResolver(static resolver => new global::Helper()));
        Resolvers = Resolvers.AddOrUpdate(typeof(global::IUserService), new TransientResolver(static resolver => new global::UserService(resolver.Resolve<global::Helper>())));
    }

    protected Container(ImTools.ImHashMap<Type, InstanceResolver> resolvers, ImTools.ImHashMap<Type, InstanceResolver> scopedResolvers, bool scope = false)
        : base(resolvers, scopedResolvers, scope)
    {
    }

    public override IZeroIoCResolver CreateScope()
    {
        var newScope = ScopedResolvers
            .Enumerate()
            .Aggregate(ImHashMap<Type, InstanceResolver>.Empty, (acc, o) => acc.AddOrUpdate(o.Key, o.Value.Duplicate()));

        return new Container(Resolvers, newScope, true);
    }
}

Enter fullscreen mode Exit fullscreen mode

It is pretty simple stuff. The logic is based on a dictionary with Type as a key and instance resolver as a value. Such a class is generated for each separate class declaration, and because there is no static logic, you can safely define as many containers as you like.

Limitations

Let's talk about the ZeroIoCContainer.Bootstrap method. It is not an ordinary method. It is a magic one. It allows you to define the relations between interface and implementation, but the .net runtime will never execute it.

The ZeroIoCContainer.Bootstrap is just a declaration that will be parsed by source generation, and based on it, the mapping will be generated.
It means that there is no point to use statements like that:

 public partial class Container : ZeroIoCContainer
    {
        protected override void Bootstrap(IZeroIoCContainerBootstrapper bootstrapper)
        {
            if(Config.Release)
            {
              bootstrapper.AddSingleton<IHelper, ReleaseHelper>();
            }
            else 
            {
              bootstrapper.AddSingleton<IHelper, DebugHelper>();
            }

            bootstrapper.AddTransient<IUserService, UserService>();
        }
    }
Enter fullscreen mode Exit fullscreen mode

All of them will be just ignored.
To prevent the bunch of WTF situations(and introduce a new one), I added a special analyzer that will warn you about it if you forget.

But If you want to do something at runtime, you can do it like that:

var container = new Container();
if(Config.Release)
{
    container.AddInstance<IHelper>(new ReleaseHelper());
}
else 
{
    container.AddInstance<IHelper>(new DebugHelper());
}

var userService = container.Resolve<IUserService>();
Enter fullscreen mode Exit fullscreen mode

Such an approach doesn't use any reflection underhood and can be safely used inside the AOT environment.

Features

I would say it is in the MVP stage. Under MVP, I mean that the set of features is big enough to be helpful in real projects.
This set contains:

  • Multiple IoC containers can be active at the same time.
  • Support for the singleton, scoped, and transient lifetimes => basic things that cover 99% of all needs.
  • Powered by source generation to avoid reflection and Reflection.Emit => you can use it inside the AOT Xamarin/Unity app.
  • Fast enough, with minimal overhead => the end-user of the Xamarin app will not notice a difference.

About the performance. There are two kinds of it. Performance in "hot runtime" and startup performance - «cold start.» As contestants, I selected the next IoCs:

  • Microsoft.Extensions.DependencyInjection — default IoC in the Asp.Net Core
  • Grace — IoC that takes first places in different benchmarks in the internet

I will not show the complete source code for benchmarks. You can find it here: github.com/byme8/ZeroIoC

In the beginning, let's look at performance in «hot runtime» — the benchmark measures time required to resolve an instance. All benchmarks are recorded via BenchmarkDotNet and have a following look:

[Benchmark]
public IUserService ZeroTransient()
{
    return (IUserService)_zeroioc.Resolve(typeof(IUserService));
}
Enter fullscreen mode Exit fullscreen mode

And we have the next results:


// * Summary *

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1165 (21H1/May2021Update)
11th Gen Intel Core i7-11700KF 3.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100-preview.7.21379.14
  [Host]     : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
  DefaultJob : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT


|             Method |      Mean |     Error |    StdDev |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------- |----------:|----------:|----------:|-------:|------:|------:|----------:|
|     GraceSingleton |  3.027 ns | 0.0026 ns | 0.0025 ns |      - |     - |     - |         - |
|      ZeroSingleton |  6.862 ns | 0.0056 ns | 0.0050 ns |      - |     - |     - |         - |
| MicrosoftSingleton | 12.575 ns | 0.0649 ns | 0.0542 ns |      - |     - |     - |         - |
|     GraceTransient | 42.975 ns | 0.1765 ns | 0.1474 ns | 0.0038 |     - |     - |      32 B |
|      ZeroTransient | 47.505 ns | 0.1450 ns | 0.1285 ns | 0.0038 |     - |     - |      32 B |
| MicrosoftTransient | 53.098 ns | 1.0928 ns | 1.2585 ns | 0.0038 |     - |     - |      32 B |
Enter fullscreen mode Exit fullscreen mode

As we can see, Grace won in every case, but if we have check the Microsoft.Extensions.DependencyInjection our ZeroIoc is twice as fast. I was tinkering with the Grace to understand why it is so fast. It looks like the main advantage lies in Grace's having its custom-written dictionary. It allows to avoid a bunch of redundant method calls and that all. So, at the moment, I have decided to leave it as is. Maybe in the future, I will come back to it.

Now let's have a look at startup performance. This benchmark will include instantiation of the container and resolving the instance of a class.

Here are the results:

// * Summary *

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1165 (21H1/May2021Update)
11th Gen Intel Core i7-11700KF 3.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100-preview.7.21379.14
  [Host]     : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
  DefaultJob : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT


|           Method |         Mean |       Error |      StdDev |  Gen 0 |  Gen 1 | Gen 2 | Allocated |
|----------------- |-------------:|------------:|------------:|-------:|-------:|------:|----------:|
|      ZeroStartup |     168.5 ns |     0.76 ns |     0.63 ns | 0.0908 |      - |     - |     760 B |
| MicrosoftStartup |   2,442.5 ns |     7.26 ns |     6.79 ns | 0.8392 | 0.0153 |     - |   7,040 B |
|     GraceStartup | 294,460.6 ns | 1,884.45 ns | 1,762.72 ns | 3.9063 | 1.9531 |     - |  33,551 B |
Enter fullscreen mode Exit fullscreen mode

In this case, the ZeroIoc is much faster than other candidates. 10x times faster than Microsoft.Extensions.DependencyInjection and more than 1000x times faster than the Grace. It happens because ZeroIoC doesn't do any work at runtime to start working correctly. Everything was done at compile time.

I would say that such IoC container is not very useful for the asp.net core application right now. But Microsoft is working on the NativeAOT that will allow us to compile C# code to native binaries directly. The ZeroIoC can be handy in this scenario because it can work in the AOT environment without any limitations. Also, it is nice for Xamarin apps. If you want to improve the startup performance of your app, it is essential to use the AOT for android and iOS(you don't have another choice here) platforms, and the ZeroIoC will work as smoothly as possible there.

Thanks for your time!

Links: GitHub, NuGet

Discussion (0)