Yes, you understood correctly, this is an article about another bike - about my
Dependency Injection (DI) container. It is already 2015 outside the window, and there are a lot of different containers for every taste and color. Why might need another one?
Firstly, it can simply form by itself! We in
Elba used
this container for quite a long time, and some of the ideas described in the article (Factory Injection, Generics Inferring, Configurators) were initially implemented on top of it through a public API.
Secondly, for a large project, the DI-container is an essential part of the infrastructure, which largely determines the organization of the code. A simple, flexible and easily modified container often allows you to find an elegant solution to a specific problem, to avoid the coherence of individual components, verbose and patterned application code. When solving a specific task, you can derive some pattern, implement it at the container level and then reuse it in other tasks.
')
Thirdly, a DI container is a relatively simple thing. It is very well designed to develop in
TDD mode, due to which it becomes fun and enjoyable to do it.
This article is not an introduction to DI. On this subject there are many other excellent publications, including on Habré. Rather, here is a set of recipes for cooking DI, so that the resulting dish was tasty, but not spicy. If you have a DI-container in production or you have written your own best container, then this is a great place for holivars about whose container is better!
Motivation and Api
The main message of the container is
Convention Over Configuration . What is the point of torturing the user by requiring him to explicitly indicate the interface's compliance with the implementation, if that interface has only one implementation available? Why not just substitute it, saving time to solve more important issues? As it turned out, a similar principle applies in many other situations. What, for example, could a container substitute in a constructor parameter of type IEnumerable or Func to bring the greatest benefit? We will talk about this a little later.
Container code was written exclusively for specific practical tasks. This allowed us to concentrate on a small number of the most useful features and to ignore all the rest. For example, a container supports only one lifestyle - singletone. This means that instances of all classes are created on demand and stored in the internal cache of the container until it is destroyed. The container implements IDisposable, re-invoking Dispose on its supporting cached objects. The order of calling Dispose on different services is determined by the dependencies between them: if service A depends on service B, then Dispose on A will be called before Dispose on B. To create a tree of services for a while and then destroy it, you can use the Clone method on the container. It returns a new container with the same configuration as the original container, but with an empty instance cache.
The main container methods are Resolve and BuildUp. The first returns an instance by type using constructor injection, the second uses property injection to initialize an already created object. The BuildUp method
makes sense to use only if the application Resolve
difficult .
Given that the container makes many decisions on its own, for debugging purposes, it supports the GetConstructionLog method. Using it, you can get a description of the creation process for any service at any time. This description is a tree whose leaves are either services that do not have constructor parameters, or specific primitive values ​​suggested to the container via the configuration API.
Sequence injection
This is the simplest and at the same time quite powerful technique: if the class in the constructor parameter accepts an array or IEnumerable, then the container will substitute in this parameter all the suitable implementations that it can find. In its further work, a class can at any moment select a certain implementation from the list and delegate to it a part of its functions. Or, for example, to inform all realizations about the occurrence of a specific event.
Consider an example. Suppose we need to raise the http-server that serves some fixed set of addresses. A separate block of code that is conveniently presented with this interface is responsible for processing each address:
public interface IHttpHandler { string UrlPrefix { get; } void Handle(HttpContext context); }
Then the dispatching logic of the request-handler can be very simply expressed as follows:
public class HttpDispatcher { private IEnumerable<IHttpHandler> handlers; public HttpDispatcher(IEnumerable<IHttpHandler> handlers) { this.handlers = handlers; } public void Dispatch(HttpContext context) { handlers.Single(h => context.Url.StartsWith(h.Prefix)).Handle(context); } }
The container finds all available IHttpHandler implementations, creates one instance of each of them, and substitutes the resulting list in the handlers parameter. Note that to add a new handler, simply create a new class that implements IHttpHandler — the container will find it itself and pass it to the HttpDispatcher constructor. This is quite easy to achieve
SRP and
OCP compliance.
Another use of Sequence Injection is event notification:
public class UserService { private readonly IDatabase database; private readonly IEnumerable<IUserDeletedHandler> handlers; public UserService(IDatabase database, IEnumerable<IUserDeletedHandler> handlers) { this.database = database; this.handlers = handlers; } public void DeleteUser(Guid userId) { database.DeleteUser(userId); foreach (var handler in handlers) handler.OnUserDeleted(userId); } }
Deleting a user can affect a number of system components. For example, some of them may have entities that refer to a remote user. To handle this situation correctly, it is enough for such a component to simply implement the IUserDeletedHandler interface. At the same time, if a new such component or entity appears, there is no need to edit the UserService code - it is enough, in accordance with
OCP , to simply add the IUserDeletedHandler handler.
Factory Injection
Sometimes you need to create a new instance of the service. There may be various reasons for this. An obvious example is that a service in the constructor takes a parameter whose value becomes known only at the execution stage. Or, perhaps, the service should be recreated for some architectural reasons. For example, the DataContext class from the standard ORM Linq2Sql is recommended to recreate for each http request, since otherwise it starts eating too much memory. In any case, you can act like this:
public class Calculator { private readonly SomeService someService; private readonly int factor; public A(SomeService someService, int factor) { this.someService = someService; this.factor = factor; } public int Calculate() { return someService.SomeComplexCalculation() * factor; } } public class Client { private readonly Func<object, Calculator> createCalculator; public Client(Func<object, Calculator> createCalculator) { this.createCalculator = createCalculator; } public int Calculate(int value) { var instance = createCalculator(new { factor = value }); return instance.Calculate(); } }
The mechanics of creation is implemented through the delegate accepted in the constructor. This delegate is generated by the container in such a way that when it is called, a new Calculator instance will always be created. Through an object-argument, using an anonymous type, you can pass the parameters of the created service. Matching of parameters occurs by name - a member of the anonymous type factor falls into the factor parameter of the Calculator constructor. The constructor parameter someService does not specify a value in the anonymous type, so the container will follow standard rules when it is received.
The main disadvantage here is that checking the name / type of parameters is postponed from the compilation stage to the execution stage. Similar to the dynamic keyword, this requires separate attention when adding / removing / renaming parameters and additional integration tests. However, in practice this does not lead to significant problems. Mainly due to the fact that the use of Factory Injection is not very common. In our projects there are only a few pieces of code in the entire base of thousands of classes of such situations. Secondly, even in these cases, errors with parameter passing are usually very simple and easily detected - when a delegate is invoked, the container does parameter checking in the same way as the compiler does when compiling.
Generics Inferring
Quite often, the container itself can choose not only the interface implementation, but also generic arguments. For example, consider the simple message bus interface:
public interface IBus { void Publish<TMessage>(TMessage message); void Subscribe<TMessage>(Action<TMessage> action); }
Through IBus, you can post messages and subscribe to process them. The mechanics of message delivery are not important here, but usually this or that queue system (RabbitMQ, MSMQ, etc.). A specific message handler is conveniently presented with this interface:
public interface IHandleMessage<in TMessage> { void Handle(TMessage message); }
To handle a new message type, you simply implement IHandleMessage with the corresponding generic argument:
public class UserRegistered { } public class UserRegisteredHandler : IHandleMessage<UserRegistered> { public void Handle(UserRegistered message) {
Now we need to call Subscribe for each implementation of IHandleMessage. Make it easy for a specific IHandleMessage:
public static class MessageHandlerHelper { public static void SubscribeHandler<TMessage>(IBus bus, IHandleMessage<TMessage> handler) { bus.Subscribe<TMessage>(handler.Handle); } }
But with what generic argument should we call the SubscribeHandler method? And where to get all such valid arguments and corresponding implementations of IHandleMessage? Ideally, I would like to reduce the situation to, for example, from Sequence Injection, just injecting an IEnumerable from something, thereby instructing the container to search for all implementations of IHandleMessage.
To do this, let's move the generic argument from the method level to the class level, and hide what we have behind the non-generic interface:
public interface IMessageHandlerWrap { void Subscribe(); } public class MessageHandlerWrap<TMessage> : IMessageHandlerWrap { private readonly IHandleMessage<TMessage> handler; private readonly IBus bus; public MessageHandlerWrap(IHandleMessage<TMessage> handler, IBus bus) { this.handler = handler; this.bus = bus; } public void Subscribe() { bus.Subscribe<TMessage>(handler.Handle); } } public class MessagingHost { private readonly IEnumerable<IMessageHandlerWrap> handlers; public MessagingHost(IEnumerable<IMessageHandlerWrap> handlers) { this.handlers = handlers; } public void Subscribe() { foreach (var handler in handlers) handler.Subscribe(); } }
How it works? To create a MessagingHost container, you need to get all the implementations of IMessageHandlerWrap. There is only one class that implements this interface - MessageHandlerWrap <TMessage>, but to create it, you need to specify the specific value of the generic argument. To do this, the container considers the parameter of the IHandleMessage <TMessage> type constructor - the existence of a suitable IHandleMessage <X> implementation is a necessary condition for creating a MessageHandlerWrap <X>. For IHandleMessage <TMessage>, there is an implementation — this is the UserRegisteredHandler class, which closes IHandleMessage through UserRegistered. Thus, the container will substitute an instance of MessageHandlerWrap <UserRegistered> into the handlers MessagingHost parameter.
This option of closing generics is based on dependency analysis. The above reasoning chain easily extends to the case of an arbitrary number of generic arguments and an arbitrary nesting of some generic services into others. The current container implementation correctly handles these common cases.
Another option for closing generics is based on generic constraints. It can be useful in cases where the generic service has no generic dependencies. In the example from Sequence Injection, let the user-dependent entities implement the following interface:
public interface IUserEntity { Guid UserId { get; } }
Then, to delete all such entities, a single generic handler is enough:
public class DeleteDependenciesWhenUserDeleted<TEntity>: IUserDeletedHandler where TEntity : IUserEntity { private readonly IDatabase database; public DeleteDependenciesWhenUserDeleted(IDatabase database) { this.database = database; } public void OnDeleted(User entity) { foreach (var child in database.Select<TEntity>(x => x.UserId == entity.id)) database.Delete(child); } }
The container will create one instance of DeleteDependenciesWhenUserDeleted for each of the classes that implement IUserEntity.
Configurators
The container provides a configuration API through which you can tell him how to behave in a certain situation:
public interface INumbersProvider { IEnumerable<int> ReadAll(); } public class FileNumbersProvider : INumbersProvider { private readonly string fileName; public FileNumbersProvider(string fileName) { this.fileName = fileName; } public IEnumerable<int> ReadAll() { return File.ReadAllLines(fileName).Select(int.Parse).ToArray(); } } public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder) { builder.Dependencies(new { fileName = "numbers.txt" }); } }
Here, using the Dependencies method, we specify the specific value of the constructor parameter. As in Factory Injection, the binding occurs by the name of the parameter. When the container is created, it scans the assemblies passed to it and calls the Configure method on all found IServiceConfigurator implementations. By convention, the X class configuration must be in the XConfigurator class located in the Configuration folder of the same assembly, although this is not required. In addition to the constructor parameters, using the ServiceConfigurationBuilder methods, you can select a specific interface implementation or, for example, specify the delegate that the container should use to create the class:
public class LogConfigurator : IServiceConfigurator<ILog> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<ILog> builder) { builder.Bind(c => c.target == null ? LogManager.GetLogger("root") : LogManager.GetLogger(c.target)); } }
The parameter for this delegate contains a target property — the type of ILog client class being created. This type will be null if there is no client, i.e. The Resolve () method on the container was called.
Hardcoding the concrete values ​​of the parameters of the constructor in the code may seem to some people a dubious solution. In practice, however, most of the settings (cache size, queue length, timeout values, tcp port numbers) change extremely rarely. They are tightly bound to the code using them. Their change is a crucial step that requires an understanding of the nuances of the operation of this code and therefore is not much different from changing the code itself.
Another atypical solution is to create a separate configurator class for each service. The main profit from this is a very simple configuration code structure. This greatly simplifies life. So, first, to understand exactly how class X is created, it is enough to search for the XConfigurator class with a resharper — an action that takes seconds. Secondly, if you describe the configuration of different services in the same class (modules in Ninject or Autofac, for example), then the likelihood of a landfill is high, because lines of code that configure different classes are often unrelated to each other. In a production project with tens of thousands of classes, hundreds of which need to be configured, such a module can become unreadable. Third, the module abstraction itself is often not obvious - it may not always be possible to simply outline the framework where one module ends and another begins. Especially thinking about it only for organizing the configuration code seems redundant.
PrimaryAssembly
Consider a fairly typical situation: FileNumbersProvider and its configurator from the example above are in some common Class Library Lib.dll and are used in a large number of console applications. In each of them, FileNumbersProvider works with the file “numbers.txt” - and this is exactly what you need. But what if a new console A.exe suddenly appears, in which the file name should be “a.txt”? You can, of course, remove FileNumbersProviderConfigurator from Lib.dll and unzip it in each of the consoles, indicating the correct value of the file name. Or, inside the general configurator, read the file name from another settings file (for this, the container provides the Settings method on ConfigurationContext). But you can do otherwise - just add to A.exe a configurator for FileNumbersProvider with the correct file name. This will work because the container first starts the configurator from Lib.dll, and then the configurator from A.exe, and the latter interrupts the action of the first. This startup order is provided by a simple rule: all non-PrimaryAssembly configurators are run before all configurators from PrimaryAssembly. The specific assembly that should be considered PrimaryAssembly is specified when the container is created.
Profiles
Quite often, the way to create a service depends on the environment. For example, in unit testing mode for INumbersProvider it is natural to use some inmemory implementation, InMemoryNumbersProvider, when running on combat servers, FileNumbersProvider with one file name value, and in manual testing mode with another. The solution to this problem is the concept of profiles. A profile is any class that implements the IProfile marker interface exported by the container. The profile type can be transferred when the container is created, and its current value will be available inside the configurator via the ConfigurationContext. Usually profiles are used like this:
public class InMemoryProfile : IProfile { } public class IntegrationProfile : IProfile { } public class ProductionProfile : IProfile { } public class NumbersProviderConfigurator : IServiceConfigurator<INumbersProvider> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<INumbersProvider> builder) { if (context.ProfileIs<InMemoryProfile>()) builder.Bind<InMemoryNumbersProvider>(); else builder.Bind<FileNumbersProvider>(); } } public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder) { var fileName = context.ProfileIs<ProductionProfile>() ? "productionNumbers.txt" : "integrationNumbers.txt"; builder.Dependencies(new { fileName }); } }
Different applications may define their own profile sets, but usually these three are sufficient.
Contracts
It has already been mentioned above that, in practice, Factory Injection is rather rarely needed in practice. Most of the system can usually be described in the form of a service tree whose elements are cached at the container level. Such a "singleton" model is very convenient for its simplicity. To use some service to a client class, you just need to accept it in the constructor. He does not need to worry about how this service will be created and at what point is destroyed - in all of this he can rely on the container.
However, quite often the service can be conventionally called a singleton "locally", but not "globally." A large service tree that implements complex business logic and has some abstraction over its data source among its leaves does not need to be aware that the container will create it in duplicate, substituting one file for this source and other. Cause and effect here are separated from each other by several levels of abstractions with which this tree operates. The reason is a specific constructor parameter in some service where, according to the application logic, you need to substitute an instance of a tree with a specific file name. The consequence is the use of this file name by the corresponding leaves of the tree.
The above is usually achieved either by dragging a parameter through the entire tree, or by creating factories that substitute this parameter into the correct elements of the tree. The container offers a more natural solution:
public class StatCalculator { private readonly FileNumbersProvider numbers; public StatCalculator(FileNumbersProvider numbers) { this.numbers = numbers; } public double Average() { return numbers.ReadAll().Average(); } } public class StatController { private readonly StatCalculator historyCalculator; private readonly StatCalculator mainCalculator; public StatController([HistoryNumbersContract] StatCalculator historyCalculator, [MainNumbersContract] StatCalculator mainCalculator) { this.historyCalculator = historyCalculator; this.mainCalculator = mainCalculator; } public int HistoryAverage() { return historyCalculator.Average(); } public int MainAverage() { return mainCalculator.Average(); } } public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder) { builder.Contract<HistoryNumbersContract>().Dependencies(new { fileName = “history” }); builder.Contract<MainNumbersContract>().Dependencies(new { fileName = “main” }); } }
The attributes [InMemoryNumbersContract] and [FileNumbersContract] must be inherited from the [RequireContractAttribute] provided by the container. In essence, such an attribute is simply a label with which you can declare some named context. This declaration can be made at once in several places in the tree, either at the level of the constructor parameter or at the class level. Determining the contract on the structure is no different from the usual configuration code - the Contract method on the builder returns a new builder, with which you can supply the contract with a certain meaning. The configuration specified in this way acts on the parameter marked by the atrubite-contract and on all the subtree located under it. For this, the container automatically creates a new instance of the service, if it substantially depends on the configuration of the current contract. The process of re-creating instances rises recursively until it reaches a parameter marked with a contract.
A service tree may contain several contracts from the root to the leaves. In this case, if several of these contracts determine the configuration of the same service, then a simple stack rule applies — the configuration of the contract closest to the point of use of the service is used. If some service from the contract-marked subtree does not use the contract configuration, then it is guaranteed that the instance used for it will be exactly the same as if the labels of the contract did not exist. In other words, if somewhere in another branch of the dependency tree this service meets without a contract, then the same instance of the corresponding class will be used for it.
The configuration can be hung on a sequence of contracts:
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider> { public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder) { builder.Contract<HistoryNumbersContractAttribute>() .Contract<ArchiveContractAttribute>() .Dependencies(new { "archiveHistoryNumbers.txt" }); } }
In this case, “archiveHistoryNumbers.txt” will be used only if the sequence of contracts declared on the way from the root contains HistoryNumbersContractAttribute and ArchiveContractAttribute in the specified order.
You can also define a contract as combining other contracts:
public class AllNumbersConfigurator : IContainerConfigurator { public void Configure(ConfigurationContext context, ContainerConfigurationBuilder builder) { builder.Contract<AllNumbersContractAttribute>() .Union<HistoryNumbersContract>() .Union<MainNumbersContract>() } }
The point of such a union is that sometimes it is necessary to process several contexts defined by contracts at once:
public class StatController { private readonly IEnumerable<StatCalculator> statCalculators; public StatController([AllNumbersContract] IEnumerable<StatCalculator> statCalculators) { this.statCalculators = statCalculators; } public int Sum() { return statCalculators.Sum(c => c.Sum()); } }
The container will select all the contracts from the union, and the resulting StatCalculator instance for each of them will substitute a statCalculators into the sequence.
Contracts allow you to describe service states only for a static, finite set of configurations, when all possible variants of a dependency tree are known at the configuration stage. If the file name for the FileNumbersProvider is entered by the user, then it is much more natural to simply transfer it by parameter via the StatController -> StatCalculator -> FileNumbersProvider chain.
Optional injection
Configurators allow to prohibit the use of some interface implementation or a specific instance of a class:
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider> { public void Configure(ConfigurationContext c, ServiceConfigurationBuilder<FileNumbersProvider> b) { b.WithInstanceFilter(p => p.ReadAll().Any()); } } public class InMemoryNumbersProviderConfigurator : IServiceConfigurator<InMemoryNumbersProvider> { public void Configure(ConfigurationContext c, ServiceConfigurationBuilder<InMemoryNumbersProvider> b) { b.DontUse(); } }
The WithInstanceFilter method imposes a filter on all FileNumbersProvider instances created by the container — clients will receive only those that can return at least one number. The DontUse method completely prohibits the use of InMemoryNumbersProvider. The class constructor can also decide that, in a certain situation, the instance it creates should not be used by container clients. To report this to the container, the constructor must throw a special exception - ServiceCouldNotBeCreatedException. This will be equivalent to using the WithInstanceFilter method in the configurator.If the creation of a service dependency was prohibited by one of the methods described above, the creation of the service itself would also be considered prohibited. Such a process of gradual exclusion of services will climb recursively up the dependency tree until it reaches the Resolve call on the container. At this point, an exception will be generated stating that no implementation has been possible for this service. Another way to stop this process is if you encounter a constructor parameter that has a sequence type (Sequence Injection) on its path. In this case, this element of the sequence will simply be skipped. There is a third option to stop - when the parameter is marked as optional: public class StatController { private readonly StatCalculator statCalculator; public StatController([Optional] StatCalculator statCalculator) { this.statCalculator = statCalculator; } public int InMemorySum() { return statCalculator == null ? 0 : statCalculator.Sum(); } }
The optional attribute provided by the container declares that if it is impossible to create the corresponding service, null should be passed to the parameter. The same effect can be achieved by using the default value of the parameter (= null) or by marking the parameter with the [CanBeNull] attribute from the JetBrains.Annotations library.Suppose now that service A has two non-optional dependencies B and C. Suppose also that the container successfully created B, but the creation of C was prohibited. Then the creation of A will also be prohibited and a copy of B will be unused. This is not a problem, if creating B was a cheap operation, but if B requires complicated initialization (going to the database, opening large files, initializing the cache), then before launching it, I would like to have confidence that it is not useless. For this, the container provides the following interface: public interface IComponent { void Run(); }
All the heavy logic of raising the service should be located in the implementation of the Run method of this interface. The point here is that the container will call Run as a separate stage, after it has completely created the entire dependency tree in the Resolve method. Knowing the composition of the tree, the container simply runs through it and consistently causes Run in order from leaf to root. For each service, the call is made only once - at the first receipt. If the service is used in several subtrees, each of which was created by a separate Resolve call, then Run on this service (if any) will also be called only for the first time.Total
If you are interested in any of the above, source codes are available on github . We haven't gotten to the documentation yet, so for answers to questions about the API it is most convenient to turn to tests. If you feel that you are missing some kind of feature or convention, then Fork and Pull Requests are very welcome.