I’ve been a long time fan of IoC (or DI) containers ever since I first discovered Castle Windsor back in 2007. I’ve used Windsor in every major project I’ve been involved in since then, and if you’d gone to a developer event in the late naughties, you may well have encountered me speaking about Windsor. Indeed, Seb Lambla had the cheek to call me ‘Windsor man’. I shall label him ‘Rest-a-man’ in revenge.
When I started working on EasyNetQ in 2011, I initially thought it would be a very simple lightweight wrapper around the RabbitMQ.Client library. The initial versions were very procedural, ‘just get it done’, script-ish code burps. But as it turned into a more serious library, I started to factor the different pieces into more SRPish classes using dependency injection. At this point I was doing poor-man’s dependency injection, with an initial piece of wire-up code in the RabbitHutch class.
As EasyNetQ started to gain some traction outside 15below, I began to get questions like, “how do I use a different serializer?” And “I don’t like your error handling strategy, how can I implement my own?” I was also starting to get quite bored of maintaining the ever-growing wire up code. The obvious solution was to introduce a DI container, but I was very reluctant to take a dependency on something like Windsor. Writing a library is a very different proposition than writing an application. Every dependency you introduce is a dependency that your user also has to take. Imagine you are a happily using AutoFac and suddenly Castle.Windsor appears in your code base, or even worse, you are an up-to-date Windsor user, but EasyNetQ insists on installing an old version of Windsor alongside. Nasty. Windsor is an amazing library, some of its capabilities are quite magical, but I didn’t need any of these advanced features in EasyNetQ. In fact I could be highly constrained in my DI container requirements:
- I only needed singleton instances.
- The lifetime of all DI provided components matches the lifetime of the main IBus interface.
- I didn’t need the container to manage component disposal.
- I didn’t need open generic type registration.
- I was happy to explicitly register all my components, so I didn’t need convention based registration.
- I only needed constructor injection.
- I can guarantee that a component implementation will only have a single constructor.
With this highly simplified list of requirements, I realised that I could write a very simple DI container in just few lines of code (currently 113 as it turns out).
My super simple container only provides two registration methods. The first takes a service interface type and a instance creation function:
IServiceRegister Register<TService>(Func<IServiceProvider, TService> serviceCreator) where TService : class;
The second takes an instance type and an implementation type:
IServiceRegister Register<TService, TImplementation>()
where TService : class
where TImplementation : class, TService;
These are both defined in a IServiceRegister interface. There is also a single Resolve method in an IServiceProvider interface:
TService Resolve<TService>() where TService : class;
You can see all 113 lines of the implementation in the DefaultServiceProvider class. As you can see, it’s not at all complicated, just three dictionaries, one holding the list of service factories, another holding a list of registrations, and the last a list of instances. Each registration simply adds a record to the instances dictionary. When Resolve is called, a bit of reflection looks up the implementation type’s constructor and invokes it, recursively calling resolve for each constructor argument. If the service is provided by a service factory, it is invoked instead of the constructor.
I didn’t have any worries about performance. The registration and resolve code is only called once when a new instance of IBus is created. EasyNetQ is designed to be instantiated at application start-up and for that instance to last the lifetime of the application. For 90% of applications, you should only need a single IBus instance.
EasyNetQ’s component are registered in a ComponentRegistration class. This provides an opportunity for the user to register services ahead of the default registration, and since the first to register wins, it provides an easy path for users to replace default services implementations with their own. Here’s an example of replacing EasyNetQ’s default console logger with a custom logger:
var logger = new MyLogger();
var bus = RabbitHutch.CreateBus(connectionString, x => x.Register<IEasyNetQLogger>(_ => logger));
You can replace pretty much any of the internals of EasyNetQ in this way: the logger, the serializer, the error handling strategy, the dispatcher threading model … Check out the ComponentRegistration class to see the whole list.
Of course the magic of DI containers, the first rule of their use, and the thing that some people find extraordinarily hard to grok, is that I only need one call to Resolve in the entire EasyNetQ code base at line 146 in the RabbitHutch class:
return serviceProvider.Resolve<IBus>();
IoC/DI containers are often seen as very heavyweight beasts that only enterprise architecture astronauts could love, but nothing could be more wrong. A simple implementation only needs a few lines of code, but provides your application, or library, with considerable flexibility.
EasyNetQ is open source under the (very flexible) MIT licence. Feel free to cut-n-paste my DefaultServiceProvider class for your own use.
Hy Mike
ReplyDeleteI just ranted on twitter about this. I just want to let you know that :D I'm getting sick of container abstractions. These are so leaky abstractions. Every framework/library these days has one and usually their interfaces are so generic and bloated without giving any meaning to the library. The same happens to your abstraction. To keep the APIs related to the things the library is doing I like to have factories and interfaces which allow on certain extension points to hook into the library. Let me give you a concrete example:
The distributed event broker I did here https://github.com/appccelerate/appccelerate/tree/master/source/Appccelerate.DistributedEventBroker has an interface called IDistributedFactory. The is the main enabling point for changes (call it seam). We have usually standard implementations of these interfaces or an abstract base class which allows you to hook in specifics. With this you control where the framework can be extended.
Let me give you another example for the wild out there were we had to learn by mistake. NServiceBus has a common object builder abstraction which is used everywhere. With that the User can essentially plugin anything. This makes the API breaking at various points. Even NSB will retire from this approach.
Hope this helps
Daniel
Hi Daniel,
ReplyDeleteThanks for that. Don't worry, I like a good rant myself :)
You make a very interesting point. By essentially inviting users to take an interest in every internal interface I am allowing the surface area of the API to extend to the internals of the application. In your example, you only provide a limited set of extension points that you can more closely control.
Interesting to hear that NServiceBus is moving away from this approach. I'm considering moving to a two track version strategy. Something like: The core interfaces of EasyNetQ will be versioned on major numbers, so if you only use IBus or IAdvancedBus you will be guaranteed no breaking changes within a major version number. 'Internal interfaces' are free to change on minor version numbers, so if supply your own dispatcher factory, you should expect to have to change your code on a minor version change.
For the time being EasyNetQ is still very much alpha software, I reserve the right to make across the board breaking changes on minor version numbers up to version 1.0. But I'd like to move to version 1.0 in the near future, so I'm very interested to hear about the experiences of more mature libraries like NServiceBus.
"imagine you are a happily using AutoFac and suddenly Castle.Windsor appears in your code base, or even worse, you are an up-to-date Windsor user, but EasyNetQ insists on installing an old version of Windsor alongside" this, or a more annoying version of it, is the reason we stopped using NSB and moved to EasyNetQ. The sheer weight of dependencies in NSB really starts to grate after a while.
ReplyDeleteAlso, it's strange that you should mention the serializer and error handling as those are the bits that we've changed. We've hacked* a workaround for an issue we hit with changing the serialization and the validation of message types. What we probably need to do is pull out the the error handling as well to fit better with our serialization/validation strategy, but the two things do seem to be pretty tightly coupled.
Anyway, keep up the good work.
*which sounds better than 'bodged'.
Consider a generic interface that lets us wire whatever DI framework we want. Like this https://github.com/ServiceStackV3/ServiceStackV3/wiki/The-IoC-container#use-another-ioc-container
ReplyDeleteAlso, consider your json serializer default could be the servicestack one that is extremely fast compared to the one you currently use...regardless I assume we can switch that with DI?
Wayne, you could switch in your own IoC container, although I could probably make it easier than it is. Currently you'd need to implement your own version of RabbitHutch and ComponentRegistration.
ReplyDeleteYes, switching the serializer is straightforward. See above :)
Mike,
ReplyDeleteYeah, I think a simple interface like the one I pointed to that we implement with DI of our choice would be a good improvement. Thanks for info!