Dependency Injection re-designed: simpler, yet more powerful new principles
If you are familiar with common DI concepts, you can skip this part and go directly to DI internals or examples.
public class Key<T> {
final Type type;
final Name name;
}
public final class Binding<T> {
final Set<Dependency> dependencies;
final BindingCompiler<T> compiler;
}
public class Injector {
private final Injector parentScope;
private final Map<Key<?>, Binding<?>> bindings;
private final Map<Key<?>, Object> instances = new HashMap<>();
private final Trie<Scope, Map<Key<?>, Binding<?>>> nestedScopes;
public Injector(Injector parentScope, Trie<Scope, Map<Key<?>, Binding<?>>> nestedScopes) {
this.parentScope = parentScope;
this.bindings = nestedScopes.get();
this.nestedScopes = nestedScopes;
}
public <T> T createInstance(Key<T> key) {
T instance = (T) instances.get(key);
if (instance == null) {
Binding<?> binding = bindings.get(key);
if (binding == null) {
instance = parentScope.createInstance(key);
} else {
Key<?>[] dependencies = binding.dependencies;
Object[] dependencyInstances = new Object[dependencies.length];
for (int i = 0; i < dependencies.length; i++) {
dependencyInstances[i] = createInstance(dependencies[i]);
}
instance = (T) binding.factory.apply(dependencyInstances);
}
instances.put(key, instance);
}
return instance;
}
public Injector enterScope(Scope scope) {
return new Injector(this, nestedScopes.get(scope));
}
}
In short - scopes give us “local singletons” which live as long as the scope itself. In DataKernel DI scopes are a bit different from other DI frameworks:
enter the scope
. This means you create an Injector and its scope will be set to the one that it’s entering. public class Injector {
...
private final Trie<Scope, DependencyGraph> scopeTree;
...
public Injector enterScope(Scope scope) {
return new Injector(this, scopeTree.get(scope));
}
...
}
This example can show you how scopes works.
Dependency graph is hard to create directly, so we provide automatic graph transformation, generation and validation mechanisms with a simple yet powerful DSL.
All of these preprocessing steps are performed in start-up time by compiling modules:
public interface Module {
Trie<Scope, Map<Key<?>, Set<Binding<?>>>> getBindings();
Map<Integer, Set<BindingTransformer<?>>> getBindingTransformers();
Map<Class<?>, Set<BindingGenerator<?>>> getBindingGenerators();
Map<Key<?>, Multibinder<?>> getMultibinders();
}
It’s trivial to manually implement the Module interface, but it’s even easier to extend AbstractModule, which
supports @Provides
method scanning and the DSL for creating/transforming/generating bindings.
We’ve compared DataKernel DI to Guice and Spring in the same scenario, using JMH as the benchmark tool. We ran benchmarks in AverageTime mode and made 20 measurements. All measurement results are represented in nanoseconds.
DkDiScopesBenchmark.measure
Score: 138.475; Error: ± 6.156; Units: ns/op;
GuiceDiScopesBenchmark.measure
Score: 780.017; Error: ± 23.994: Units: ns/op;
SpringDiBenchmark.measure
Score: 77191; Error: ± 322.6; Units: ns/op;
Benchmarks were launched on a machine with the following parameters: Ubuntu 18.04 bionic, Kernel: x86_64 Linux 4.15.0-55-generic, CPU: Intel Core i5-8400 @ 6x 4GHz [27.8°C].
You can find benchmark sources on GitHub.
To represent the main concepts and features of DataKernel DI, we’ve created an example, which starts with low-level DI concepts and gradually covers more specific advanced features.
In this example we have a kitchen, where you can automatically create tasty cookies with our wonderful DI. Before we get to cooking, there are several POJOs with default constructors marked with @Inject annotation: Kitchen, Sugar, Butter, Flour, Pastry and Cookie.
Let’s bake a Cookie using DI in a hardcore way. First of all, we need to provide all the needed ingredients for the cookie which are Sugar, Butter and Flour. Next, there is a recipe for Pastry, which includes ingredients we already know how to get. Finally, we can store a recipe of how to bake a Cookie.
It’s baking time now! Just create the injector with all these recipes and ask it for your Cookie instance.
public void manualBindSnippet() {
Map<Key<?>, Binding<?>> bindings = new LinkedHashMap<>();
bindings.put(Key.of(Sugar.class), Binding.to(() -> new Sugar("WhiteSugar", 10.0f)));
bindings.put(Key.of(Butter.class), Binding.to(() -> new Butter("PerfectButter", 20.0f)));
bindings.put(Key.of(Flour.class), Binding.to(() -> new Flour("GoodFlour", 100.0f)));
bindings.put(Key.of(Pastry.class), Binding.to(Pastry::new, Sugar.class, Butter.class, Flour.class));
bindings.put(Key.of(Cookie.class), Binding.to(Cookie::new, Pastry.class));
Injector injector = Injector.of(Trie.leaf(bindings));
Cookie instance = injector.getInstance(Cookie.class);
assertEquals(10.f, instance.getPastry().getSugar().getWeight());
}
This time we will bake a Cookie with a simple DSL. We will bundle our recipes for Sugar, Butter and Flour in a ‘cookbook’ module.
Instead of creating bindings explicitly and storing them directly in a map, we just bind the recipes in our module and then give it to the injector.
public void moduleBindSnippet() {
Module module = Module.create()
.bind(Sugar.class).to(() -> new Sugar("WhiteSugar", 10.0f))
.bind(Butter.class).to(() -> new Butter("PerfectButter", 20.0f))
.bind(Flour.class).to(() -> new Flour("GoodFlour", 100.0f))
.bind(Pastry.class).to(Pastry::new, Sugar.class, Butter.class, Flour.class)
.bind(Cookie.class).to(Cookie::new, Pastry.class);
Injector injector = Injector.of(module);
assertEquals("PerfectButter", injector.getInstance(Cookie.class).getPastry().getButter().getName());
}
@Provides
It’s time for real Cookie business. Instead of making bindings explicitly, we will use the declarative DSL.
Like in the previous example, we create a cookbook module, but this time all bindings for the ingredients will be created
automatically from the provider methods. These methods are marked with the @Provides
annotation:
public void provideAnnotationSnippet() {
Module cookbook = new AbstractModule() {
@Provides
Sugar sugar() { return new Sugar("WhiteSugar", 10.f); }
@Provides
Butter butter() { return new Butter("PerfectButter", 20.0f); }
@Provides
Flour flour() { return new Flour("GoodFlour", 100.0f); }
@Provides
Pastry pastry(Sugar sugar, Butter butter, Flour flour) {
return new Pastry(sugar, butter, flour);
}
@Provides
Cookie cookie(Pastry pastry) {
return new Cookie(pastry);
}
};
Injector injector = Injector.of(cookbook);
assertEquals("PerfectButter", injector.getInstance(Cookie.class).getPastry().getButter().getName());
}
Sometimes it happens that you prepare an injection scheme, but this scheme is not a module. But there is a scan() method which can help you to make a connection between DI entities and your scheme.
public void scanObjectSnippet() {
Module cookbook = Module.create().scan(new Object() {
@Provides
Sugar sugar() { return new Sugar("WhiteSugar", 10.f); }
@Provides
Butter butter() { return new Butter("PerfectButter", 20.0f); }
@Provides
Flour flour() { return new Flour("GoodFlour", 100.0f); }
@Provides
Pastry pastry(Sugar sugar, Butter butter, Flour flour) {
return new Pastry(sugar, butter, flour);
}
@Provides
Cookie cookie(Pastry pastry) {
return new Cookie(pastry);
}
});
Injector injector = Injector.of(cookbook);
assertEquals("PerfectButter", injector.getInstance(Cookie.class).getPastry().getButter().getName());
}
If your class provides a scheme, you can use it easily:
public void scanClassSnippet() {
Module cookbook = Module.create().scan(InjectsDefinition.class);
Injector injector = Injector.of(cookbook);
assertEquals("PerfectButter", injector.getInstance(Cookie.class).getPastry().getButter().getName());
}
@Inject
When we created our POJOs, we’ve marked their constructors with @Inject
annotation:
static class Sugar {
private final String name;
private final float weight;
@Inject
public Sugar() {
this.name = "WhiteSugar";
this.weight = 10.f;
}
...
}
If a binding depends on a class that has no known binding, injector will try to automatically generate binding for it.
It will search for @Inject
annotation on its constructors, static factory methods or the class itself (in this case
the default constructor is used) and use them as a factory in generated binding.
Since nothing depends on the Cookie binding, by default no bindings will be generated at all. Here we use a plain bind to tell the injector that we want this binding to be present. Thus the whole tree of bindings it depends on will be generated:
public void injectAnnotationSnippet() {
Module cookbook = Module.create().bind(Cookie.class);
Injector injector = Injector.of(cookbook);
assertEquals("WhiteSugar", injector.getInstance(Cookie.class).getPastry().getSugar().getName());
}
@Named
Let’s be trendy and bake a sugar-free cookie. In order to do so, along with @Provides
annotation, we will
also use @Named
annotation and provide two different Sugar, Pastry and Cookie factory functions. This
approach allows to use different instances of the same class. Now we can tell our injector, which of the
cookies we need - a normal one or sugar-free.
public void namedAnnotationSnippet() {
Module cookbook = new AbstractModule() {
@Provides
@Named("zerosugar")
Sugar sugar1() { return new Sugar("SugarFree", 0.f); }
@Provides
@Named("normal")
Sugar sugar2() { return new Sugar("WhiteSugar", 10.f); }
@Provides
Butter butter() { return new Butter("PerfectButter", 20.f); }
@Provides
Flour flour() { return new Flour("GoodFlour", 100.f); }
@Provides
@Named("normal")
Pastry pastry1(@Named("normal") Sugar sugar, Butter butter, Flour flour) {
return new Pastry(sugar, butter, flour);
}
@Provides
@Named("zerosugar")
Pastry pastry2(@Named("zerosugar") Sugar sugar, Butter butter, Flour flour) {
return new Pastry(sugar, butter, flour);
}
@Provides
@Named("normal")
Cookie cookie1(@Named("normal") Pastry pastry) {
return new Cookie(pastry);
}
@Provides
@Named("zerosugar")
Cookie cookie2(@Named("zerosugar") Pastry pastry) { return new Cookie(pastry); }
};
Injector injector = Injector.of(cookbook);
float normalWeight = injector.getInstance(Key.of(Cookie.class, "normal"))
.getPastry().getSugar().getWeight();
float zerosugarWeight = injector.getInstance(Key.of(Cookie.class, "zerosugar"))
.getPastry().getSugar().getWeight();
assertEquals(10.f, normalWeight);
assertEquals(0.f, zerosugarWeight);
}
Also ModuleBuilder usage is also possible, but we want you to use Module.create().scan()
method.
public void moduleBuilderWithNamedBindsSnippet() {
Module cookbook = Module.create()
.bind(Key.of(Sugar.class, "zerosugar")).to(() -> new Sugar("SugarFree", 0.f))
.bind(Key.of(Sugar.class, "normal")).to(() -> new Sugar("WhiteSugar", 10.f))
.bind(Key.of(Pastry.class, "zerosugar")).to(Pastry::new, Key.of(Sugar.class).named("zerosugar"), Key.of(Butter.class), Key.of(Flour.class))
.bind(Key.of(Pastry.class, "normal")).to(Pastry::new, Key.of(Sugar.class).named("normal"), Key.of(Butter.class), Key.of(Flour.class))
.bind(Key.of(Cookie.class, "zerosugar")).to(Cookie::new, Key.of(Pastry.class).named("zerosugar"))
.bind(Key.of(Cookie.class, "normal")).to(Cookie::new, Key.of(Pastry.class).named("normal"));
Injector injector = Injector.of(cookbook);
float normalWeight = injector.getInstance(Key.of(Cookie.class, "normal"))
.getPastry().getSugar().getWeight();
float zerosugarWeight = injector.getInstance(Key.of(Cookie.class, "zerosugar"))
.getPastry().getSugar().getWeight();
assertEquals(10.f, normalWeight);
assertEquals(0.f, zerosugarWeight);
}
Our cookies turned out to be so amazingly tasty, that now there are a lot of people who want to try them. But there is a small problem, DataKernel DI makes instances singleton by default. And we can’t sell the same cookie to all our customers.
Luckily, there is a solution: we can use a custom ScopeAnnotation @OrderScope
to create ORDER_SCOPE
scope:
@ScopeAnnotation(threadsafe = false)
@Target({ElementType.METHOD})
@Retention(RUNTIME)
public @interface OrderScope {
}
public static final Scope ORDER_SCOPE = Scope.of(OrderScope.class);
So our cookbook will look as follows:
Module cookbook = Module.create()
.bind(Kitchen.class).to(Kitchen::new)
.bind(Sugar.class).to(Sugar::new).in(OrderScope.class)
.bind(Butter.class).to(Butter::new).in(OrderScope.class)
.bind(Flour.class).to(Flour::new).in(OrderScope.class)
.bind(Pastry.class).to(Pastry::new, Sugar.class, Butter.class, Flour.class).in(OrderScope.class)
.bind(Cookie.class).to(Cookie::new, Pastry.class).in(OrderScope.class);
In this way, only kitchen will remain singleton:
We received 10 orders from our customers, so now we need 10 instances of cookies:
ORDER_SCOPE
.Injector injector = Injector.of(cookbook);
Kitchen kitchen = injector.getInstance(Kitchen.class);
Set<Cookie> cookies = new HashSet<>();
for (int i = 0; i < 10; ++i) {
Injector subinjector = injector.enterScope(ORDER_SCOPE);
assertSame(subinjector.getInstance(Kitchen.class), kitchen);
if (i > 0) assertFalse(cookies.contains(subinjector.getInstance(Cookie.class)));
cookies.add(subinjector.getInstance(Cookie.class));
}
assertEquals(10, cookies.size());
You can configure the process of how your injector gets instances and transform this process. For example, you can simply add some logging by overriding configure method:
public void transformBindingSnippet() {
Module cookbook = Module.create()
.bind(Sugar.class).to(Sugar::new)
.bind(Butter.class).to(Butter::new)
.bind(Flour.class).to(() -> new Flour("GoodFlour", 100.0f))
.bind(Pastry.class).to(Pastry::new, Sugar.class, Butter.class, Flour.class)
.bind(Cookie.class).to(Cookie::new, Pastry.class)
.transform(0, (bindings, scope, key, binding) ->
binding.onInstance(x -> System.out.println(Instant.now() + " -> " + key)));
Injector injector = Injector.of(cookbook);
assertEquals("GoodFlour", injector.getInstance(Cookie.class).getPastry().getFlour().getName());
}
Now you will receive an output which will represent the time when an instance was created and the instance itself.