代码改变世界

使用DryIocZero进行低开销依赖注入

2020-09-05 15:47  Dorisoy  阅读(258)  评论(0编辑  收藏  举报

转载:https://ryandavis.io/adventures-in-low-overhead-dependency-injection-using-dryioczero/

Dependency Injection (DI) might be one of the more polarising topics in the Xamarin community. In general, you're either on board with it and use it religiously - or - you're wrong ⁽ʲᵘˢᵗ ᵏᶦᵈᵈᶦⁿᵍ⁾. The price for the privilege of a DI container is some overhead - both at startup, when service registrations are made, and during runtime, as dependencies are resolved. In this post, I'll look a little bit at using a library called DryIocZero - a lesser known sibling of the popular DryIoC framework - to help minimise the overhead associated with the use of DI. I'll look a bit at what it's like using it in a cross-platform Xamarin app, and also demonstrate a PoC Prism integration, which - whilst a little rough to work with - does appear to show potential for nontrivial performance improvements.

(This is a pretty long and dense post, so make sure you're comfy!)

Does DI container choice even matter?

It does, because of the overhead that's involved. How much, and when it is incurred (registration/resolve), does vary from container to container, as demonstrated in these benchmarks. Note that these are times for 500,000 resolutions, so we should take care when drawing conclusions for our purposes.

Working in mobile - a comparatively performance-sensitive environment - Xamarin developers hopefully gravitate towards containers with lower overheads. I like DryIoc, and admire the commitment the author @dadhi places on keeping it light and fast, without compromising on features.

Inspired by Clinton Rocksmith's recent post on using Lazy<T> to defer subgraph resolution, I decided to cross off something that's been on the list for a couple of years now - investigating DryIocZero, a sibling to DryIoC that allows you to create a container that incurs zero registration overhead at startup.

Zero calories ʳᵉᵍᶦˢᵗʳᵃᵗᶦᵒⁿ ᵒᵛᵉʳʰᵉᵃᵈ? ɴᴏ ᴡᴀʏ ᴛʜᴀᴛ's ᴘᴏssɪʙʟᴇ

I mean, that's what they said about Coke Zero too.
(mum tells me there's a catch)

It does sound too good to be true. So what's the deal? From the DryIoc extensions page, DryIocZero is "[a] slim IoC Container based on service factory delegates generated at compile-time by DryIoc".

In short, you use the DryIoc library at compile time to generate code that describes a complete, configured and validated implementation of your container. The result is a class that you can new up that is immediately ready to resolve the roots you specified - there's no need to burn time during startup adding registrations, and no need for DryIoc to process types and understand the dependency graph or resolution method for these requests. A simple example should make the idea clearer:

On the left is a basic dependency graph with two roots A and B (viewmodels, maybe) and a small number of interface dependencies. Below that, is some straightforward DryIocZero registration code - the same code you'd write when working with DryIoc normally. On the right is the relevant code that DryIocZero generates for that configuration, which I have tided a little for readability.

Effectively, each resolution root gets a dedicated and self-contained implementation that is scope aware, generated based on the container configuration. When a root needs to be resolved, ResolveGenerated calls the appropriate method and you get your requested object. As you can see, resolution implementations involve no interface indirection, and no reflection - both of these are desirable properties in a Xamarin world. The Container class (which includes more code in a static partial counterpart) can be instantiated directly with no need for further configuration. Sounds pretty good!

Proof of Concept - Prism + DryIocZero

To put this approach through its paces with a more thorough test, I decided to try to configure a Prism app using DryIocZero. Though I haven't yet used Prism in a real app, I have the impression that it is both opinionated and configurable enough that integrating DryIocZero should need thought, but be possible.

In the end, the Prism source, plus a good post from @dansiegel (Using unsupported DI containers with Prism) were enough to set me on the right track. It seems to have worked out, and you can check out the final result here. I started out with the Prism template and each commit covers a specific aspect of the conversion, so you can use those to follow the process if interested. I'll call out a few bits here.

Creating Registrations

A large amount of the setup lands in the Registrations.ttinclude file. This is the T4 template file that needs to be modified to include the container configuration. In addition to convention-based registrations for services, viewmodels and pages, my implementation moves the registration of Prism core types out of startup to be performed at compile time.

Parts of the set up are moved to seperate methods, so you can get a better idea of the high level flow:The first call worth noting is the LoadAssemblyWithDependencies call. It is a crude method I pulled together which attempts to read the deps.json of the app assembly in order to load other required assemblies into memory. Since DryIocZero creates a real DryIoC container from which to generate the compile-time version, any types you are registering need to be loaded into the running environment. You can reference assemblies manually in the T4 template, but I found the approach of dynamic and reflection-based type resolution to be more manageable, particularly since there's no C# intellisense anyway. This implementation makes use of the Microsoft.Extensions.DependencyModel package to load referenced nuget package assemblies into memory.

The next point of note is the RegisterPrismTypes method. This takes the registrations found in the core PrismApplicationBase class, and instead wires them up as part of the compile-time container. This approach is somewhat brittle (would need to be kept in sync with any changes the Prism team makes), but is good to take off the startup path.

 

  public void RegisterPrismTypes(Assembly asm, IContainer container)
  {
  var types = new Dictionary<Type, Type>
  {
  [FindType("Prism.Navigation.INavigationService")] = FindType("Prism.Navigation.PageNavigationService"),
  [FindType("Prism.Behaviors.IPageBehaviorFactory")] = FindType("Prism.Behaviors.PageBehaviorFactory"),
  [FindType("Prism.Common.IApplicationProvider")] = FindType("Prism.Common.ApplicationProvider"),
  [FindType("Prism.Logging.ILoggerFacade")] = FindType("Prism.Logging.EmptyLogger"),
  [FindType("Prism.AppModel.IApplicationStore")] = FindType("Prism.AppModel.ApplicationStore"),
  [FindType("Prism.Events.IEventAggregator")] = FindType("Prism.Events.EventAggregator"),
  [FindType("Prism.Services.IPageDialogService")] = FindType("Prism.Services.PageDialogService"),
  [FindType("Prism.Services.Dialogs.IDialogService")] = FindType("Prism.Services.Dialogs.DialogService"),
  [FindType("Prism.Services.IDeviceService")] = FindType("Prism.Services.DeviceService"),
  [FindType("Prism.Modularity.IModuleCatalog")] = FindType("Prism.Modularity.ModuleCatalog"),
  [FindType("Prism.Modularity.IModuleManager")] = FindType("Prism.Modularity.ModuleManager"),
  [FindType("Prism.Modularity.IModuleInitializer")] = FindType("Prism.Modularity.ModuleInitializer")
  };
   
  foreach (var t in types)
  container.Register(t.Key, t.Value);
   
  // nav service needs to be registered with a service key too
  container.Register(
  FindType("Prism.Navigation.INavigationService"),
  FindType("Prism.Navigation.PageNavigationService"),
  serviceKey: "PageNavigationService");
  }

In addition to registering the types here, I intercept RegisterRequiredTypes in the app subclass, to prevent Prism from also trying to register the types.

 

The last interesting point in the registrations is probably the convention based page/vm registration. This scans for pages and viewmodels and registers them in the container according to Prism requirements.

  public void RegisterViews(Assembly asm, IContainer container)
  {
  var pageTypes = GetTypesFromAssembly(asm, t => t.IsSubclassOf(typeof(BasePage))).ToArray();
   
  // register nav page
  container.Register(typeof(object), FindType("Xamarin.Forms.NavigationPage"), serviceKey: "NavigationPage");
   
  // register all our pages and matching vm
  // assumes matching vm exists, 1->1 relationship, ignores namespaces, etc.
  // this might not be valid for you, in which case this will blow up
  foreach(var pageType in pageTypes)
  {
  var vmType = GetTypesFromAssembly(asm, t => t.Name == pageType.Name + "ViewModel").First();
   
  // register page type
  container.Register(pageType);
   
  // prism wants it as a named registration against object, so maybe the above is unneccessary
  container.Register(typeof(object), pageType, serviceKey: pageType.Name);
   
  // register the vm
  container.Register(vmType);
   
  // keep track of the pair of types so we can generate our "register pages" method later
  PageTypes.Add(($"{pageType.Namespace}.{pageType.Name}", $"{vmType.Namespace}.{vmType.Name}"));
  }
  }
view rawRegisterViews.cs hosted with ❤ by GitHub

It's mostly straightforward, but the last line is worth a mention. Each page/viewmodel combination is added to a list and an additional modification I made to the generation template results in the creation of a RegisterPageTypes() method on the container. This method can be be called at runtime to configure the Prism page registry and viewmodel locator based on registrations, again without requiring assembly scanning.

 

Integrating with Prism

The other major modification to fit DryIoCZero is the creation of an IContainerExtension, an abstraction that Prism operates against to allow its use with different DI frameworks. Again, Dan's post here provides a great overview. Since the container methods are very generic, most methods end up being simple argument pass-throughs to the Container implementation; to the point that we could probably just adopt IContainerExtension directly. In this case, I created a dedicated class.

In setting up the extension, I opted to completely remove the runtime DryIoC dependency, and disallow support for adding type to type registrations at runtime (delegates and instances are still supported). To my limited understanding, mutation of the container in Prism is primarily required to support the use of Prism modules - if you are not dynamically loading code or doing hot patching, you can live without supporting these kinds of registrations at runtime.

For the purposes of investigation, I did create another implementation of IContainerExtension that chained together a runtime DryIoc container and compile time DryIoCZero container, which you can see here. Using this implementation, you can combine both compile time registrations with dynamic runtime registrations if neccessary. By adding an UnknownServiceResolver rule to the runtime container, this even supports split resolutions. For example, if you dynamically load an admin module with types that depend on an existing UserService that was registered at compile time, DryIoC will resolve any new dependencies from the dynamic container and check the compile-time container for missing ones. I'm not sure of the overhead on this, but the technique is at least possible.

What's the impact?

I'm optimistic about the performance improvement that DryIoCZero can provide, especially on older devices. That said, I'm cautious about relying on the results of my benchmarks, because I'm not a pro benchmarker. I will say that these results seem consistent and are in line with the expectations that we have (i.e. that DryIocZero will be faster). These tests were run on a version of the PrismZero app with a few more services, pages, viewmodels and dependencies defined; not quite representative of a production-complexity app, but more complex than the template.

In order to appease my superstition of the possiblity of second-order effects, rather than measure only the container creation time, I opted to measure something closer to Prism app startup time (excluding Xamarin.Forms initialisation). This means starting the timer right before the built-in template calls LoadApplication, which includes instantiation of the App subclass. The timer is stopped in OnInitialisedAsync, after Prism startup and container registrations, and prior to any navigation. This means that the times do not represent the exact figures for compile time vs run time container instantiation, but - as the only thing changed between runs - give a good feel for the difference when using each.If the results are reliable, the performance impact can be substantial on older devices. My expectation is that a DryIocZero container will scale more favourably with increased number and complexity of registrations, but I have not tested this. It would be awesome to see if someone else could reproduce a similiar result independently, to give me confidence that this doesn't include anything that makes it incorrect.

No free lunches

Amazing! We should all switch to DryIocZero immediately, right? Maybe, maybe not. In reality, there are caveats, considerations and compromises that using DryIocZero involves. Before negatives, I want to run over the good bits.

Pros:

Low overhead:
Of course, this is the primary benefit. Given the results we've seen, compile time generation of the container can take a bunch of the startup impact of DI out of the picture.

Resolution performance appears to depend on whether your code is JIT'ed or AOT'ed. When JIT-ing, DryIoC resolves slightly more quickly, whilst when AOT'd, DryIoCZero comes out on top in most cases. I reproduced this behaviour both on device and on my workstation using the IoC benchmarking project, so I am confident in this assessment.

That said, given these times are in milliseconds and are measuring 500,000 resolutions, the difference between resolution times for either is probably marginal. On the slowest device when JIT'ing I saw resolves take 2-3 ms longer using DryIocZero. For saving 500ms+ at startup, I consider that a fair trade.

No penalty for convention-based registrations:
This is kind of part of the previous point, but worth an additional callout. For convenience, it's common to prefer to create registrations based on some kind of convention, or based on the presence of marker types. You can see this technique in use in the Prism demo app.

Writing code to register types based on conventions like this is nice, because we don't have to go back to the container and add new registrations every time we create new services etc. However, doing so requires reflecting over assemblies to find the matching types, which adds to startup time. With DryIocZero, this work gets done at compile time, so we get the convenience of convention based registration without addition cost.

Less 'magic':
Because DryIocZero generates real code into your project, there's less mystery involved in injection. As the developer, you can set breakpoints inside resolution methods if you want to step through the process. The compiler gets to work on the resolution code as well, so it is eligible for AOT and optimisation. The linker is also better informed because of the lack of indirection. For example, it can see exactly which constructors are being invoked on types, and won't link them out. In the case of convention based registrations, where eligible types would normally only be detected at runtime, the linker again gets the benefit of seeing the types referenced in the container at compile time.

Extensible:
Probably both a pro and a con of DryIocZero is its delivery method - a set of classes you add to your project, including a T4 template. Since the template itself can be modified you can add your own code generation functionality. An example of this is the automated generation of RegisterPageTypes() in the demo app.

Cons:

Unfortunately. it's not all sunshine and rainbows 😭. There's no denying that there's more effort involved in setting up and maintaining injection via DryIocZero, for a variety of reasons. Installation is more involved than your standard NuGet (but the instructions are easy to follow). Some other considerations have reasonable solutions or workarounds that I worked out quickly at the start, others could pose ongoing development time overhead.

As I look to try using DryIocZero on a new project, I'll find out whether it's truly viable. I'm think that I probably have a higher 'inconvenience' threshold than many (but hey because of that I've been hot reloading code forever thanks Frank). For now, here are some pain points that I found.

No access to runtime state in registrations:
A well documented limitation of Zero is that you can't preconfigure implementations that depend on runtime state. Since the container is effectively created ahead of time, this makes sense, and rules out the use of methods like RegisterInstance(...) and RegisterDelegate(c => ...).

So what if you have a dependency with an implementation that can't be known until runtime? In situations like that, you can use the RegisterPlaceholder method to tell DryIocZero that you will provide an implementation at runtime. That allows DryIocZero to construct resolution implementations with (appropriately) placeholders for the missing types, allowing the container to compile. As an example, here is the code generated in a resolution if we specify that the IamNested dependency from the beginning example will be provided at runtime:

It looks a bit scary, but it's mostly a Resolve call with all the optional arguments specified.
Can't say I understand the 'preResolveParent' argument though 🤔

 

If we don't register a placeholder at all, DryIocZero will not generate any roots that involve graphs with that dependency - how could they compile? In the case of our example, this would remove B from the generated code - and write an error at the top of the container like the below:

Although it's good the the container is validated, I actually tend to think that a missing dependency should cause the entire container to fail generation. In the above case, since the container code still compiles, you could end up hitting the missing dependency at run time.

Semi-mutable implications:
I'm coining the term 'semi-mutable' to describe a DryIocZero container's mutability. A container generated by Zero is actually independent and self-contained - although the generation process uses DryIoc, the output is lighter-weight and has no such dependency. There are various implications to this, but one to keep in mind is that it is not an IContainer so can't be used in place of one, and does not support everything an IContainer supports. Looking at this issue, this is something that might change in the future, but it is this way for now.

We've seen that a DryIoCZero container supports some mutation - specifically, the ability to replace placeholders, or add new registrations with instance or delegate implementations. However, it doesn't support the 'standard' registration syntax:

// this overload does not exist on a zero-generated container
container.Register<IThing, Thing>();  

If you think about it, this is reasonable. We're telling the container that it needs to be able to later resolve IThing to a Thing, which may have arbitrary dependencies and its own resolution strategy - but this isn't a DryIoc.Container that knows how to work out things like that. This is lightweight, purpose-built container class designed to resolve exactly what we told it to be able to resolve at build time.

For most purposes, this probably isn't a big deal. However, in an interpreter powered, hot-patch-friendly world, you might need a fall back. One option is to provide your own delegate that reflects over Thing and attempts to resolve the dependencies from the container. It won't be blazing fast, but given that you've already accepted the perf hit of your new dynamic code being interpreted (on iOS, at least), this might be acceptable. Another option is to chain the DryIocZero container to a fully fledged DryIoC container, as I mentioned when talking about the Prism demo.

Configuring the configuring of the container:
Again, a strength and weakness of DryIocZero is its implementation via T4 templating. It's a clever and workable solution, but T4 templates are a bit awkward to write code in. For that reason, I'm tending towards favouring convention-based registrations - the idea being that if you get the conventions set up properly at the start, you'll rarely need to revisit it.

The obvious challenge is the lack of C# autocompletion. Since we're typing into a T4 template that's to be expected, but it means working out overloads and namespaces can be harder.

The bigger challenge is making sure that the environment that's executing the template has access to all the required types to be registered. You can add references to the T4 template to have assemblies loaded, and the first one you'll need to add is a reference to your own app. That's easy enough thanks to a few available substitutions. I don't love it but I can live with it:

With that reference added (and your project built), you can now refer to your types. However, if you need to register something from another assembly, or a type of yours that derives from a type defined in another assembly, like a Xamarin.Forms ContentPage subclass, you'll need to reference that assembly too. One way to handle this is to keep adding references to the T4 template as you find that you're missing them, but that isn't much fun. An alternative approach is to dynamically load the dependencies of your app before configuring the container, and use reflection-based techniques to reference types. It sounds bad, but since we already aren't getting autocomplete and we're favouring convention-based registration I think it's the better approach. This is the technique I used in the Prism proof of concept.

🐔 Which came first 🥚:
As shown earlier, the T4 template used by DryIocZero needs to use the types from your project to know how to generate the container implementation. That means that your project already needs to be built to generate the container. Of course, once the container is generated, your project needs to be rebuilt to make use of the generated container implementation. Aaahhh.

In reality, the way DryIocZero is implemented means that it avoids a true chicken-and-egg problem, because the generated code goes into a partial class. Since the other part of the partial class is always in the project, there's no problem in having your project reference the container even when the resolution implementations haven't been generated yet.

However, changes to your project can cause the container to fall out of sync. For example, adding a new dependency to an existing registered type will break the generated container, as it will be missing the new constructor argument. To fix this, we just need to regenerate the container. Unfortunately, to regenerate the container we need to build the project with the updated type dependencies, and we can't build the project because the existing generated container is missing the construc-- Aaaaaaaaaaaahhh.

The answer is to just delete the generated container before trying to build, but this can be a bit arduous and awkward. I feel like with some clever project structuring this could be improved, which might work in with the next point.

Platform dependencies:
This is probably the major outstanding question I have around putting together a reasonable implementation of DryIocZero in a Xamarin app. Typically, cross-platform apps define their DI container in the shared project, with a mechanism that allows the platform heads to perform any platform-specific registrations for implementations that exist in the heads only. This can be as simple as providing an Action<Container> to the shared project during initialisation, or using something like Prism's PlatformInitializer pattern. How to handle this using DryIocZero?

The easiest option is to just define those dependencies as placeholders and register them at runtime. This again needs to work in with the constraints around runtime registration - constructed instances or delegates only, which implies the need to hand-resolve any dependencies. Looking over existing projects, this is probably an acceptable approach - between Xamarin.Essentials, other plugins and netstandard, many platform dependencies are already abstracted.

The better option might be to move generation of the container (now, containers) to act on the platform heads. A shared project could host the DryIocZero classes, and the template could <@ include @> a file that exists in each head with the implementation for RegisterPlatformDependencies(). It might be worth investigation.

Do the Pros outweigh the Cons?

Whew, that was a lot of words.

I do see several challenges to using DryIoC in anger in its current form, but I think the answer comes down to the project, team and tolerance for straying from the beaten path. There are other aspects I can think of but haven't investigated yet, such as working this into a build pipeline and how it might work in a team and with PRs. My spidey sense says that the container should probably not be checked in, and should be generated at build time.

Personally, I'm game to give a try, and will be using in on my next project. If it's not working out, falling back to DryIoc proper involves little more than copying the configuration code out of the T4 template and into the app, making it a relatively low-risk opportunity. If people are interested, I'll keep them up to date with how it goes!