Wednesday, September 30, 2020

Microsoft.Extensions.DependencyInjection

I've written posts describing dependency injection via constructors, methods, properties, and events but these are all homegrown implementations and I've ignored Unity, etc until now. Microsoft has introduced a simple-ish dependency injection service specifically designed for .Net Core, but usable elsewhere. I'm going to look at how to implement dependency injection using this Microsoft service using my standard application -> configuration -> data provider -> logging dependency hierarchy.

Usually you know at design time which classes will need to be injected. Although the ServiceCollection allows generic services to be added, you normally know which ones you will be adding as you design your application. In my example the logging dependency needs to be injected into the data provider and the data provider needs to be injected into the configuration.

Microsoft allows you to write extension methods for each of the injectable classes. If an injectable class needs a dependency injected itself, the extension method handles it. This hides implementation details and also relaxes dependencies to some extent. It's not perfect, but it's not bad.

Most of the code in this example is the same as previous examples so I'm just going to put the production code in one big file and highlight the new stuff.

Start a new Visual Studio 2019 C#, .Net Core console project and call it CodeDI. Use the package manager to install Microsoft.Extensions.DependencyInjection and System.Data.SqlClient. I'm going to assume you have SQLServer installed with a master database.

Make program.cs look like the code below. The most interesting part is ServiceProvider and the ServiceExtensions class. The ServiceProvider statement simply creates a collection of dependencies using the two extension methods we put in the ServiceExtensions class. That collection gets passed to the Configuration constructor so it can pull out the data provider we want it to use.

The ServiceExtensions class contains an extension method for each of the two classes we have identified as being injectable. The logging provider extension method adds a new singleton instance of the desired logging provider type (because we only need one instance). The data provider extension method builds the ServiceProvider we have so far and adds a new singleton instance of the desired data provider type with the ServiceProvider as a parameter so the data provider's constructor can pull out all the dependencies it needs. Note we don't have to know which dependencies it needs - just that it needs some.

If we switch the order of the registrations we have a problem because the logging provider won't exist when the data provider needs it. Unless we check for this, we will get some null property exceptions later. Using GetRequiredService would help by raising the exceptions at the correct place in the code.

using System;
using System.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;

namespace CoreDI
{
    class Program
    {
        static void Main(string[] args)
        {
            ServiceProvider services = new ServiceCollection()
                .RegisterLoggingProvider<ProductionLoggingProvider>()
                .RegisterDataProvider<ProductionDataProvider>()
                .BuildServiceProvider();

            Configuration configuration = new Configuration(services);
            bool IsProductionMode = configuration.GetSettings().isProductionMode;

            Console.WriteLine($"IsProductionMode is {IsProductionMode}");
            Console.WriteLine($"DataProvider is {configuration.GetDataProviderName()}");
            Console.WriteLine($"LoggingProvider is {configuration.GetLoggingProviderName()}");
            Console.WriteLine($"DataProvider's LoggingProvider is {configuration.GetDataProviderLoggingProviderName()}");
        }
    }

    public static class ServiceExtensions
    {
        public static IServiceCollection RegisterLoggingProvider<T>(this IServiceCollection serviceswhere T:BaseLoggingProvider
        {
            services.AddSingleton(typeof(BaseLoggingProvider), Activator.CreateInstance(typeof(T)));
            return services;
        }

        public static IServiceCollection RegisterDataProvider<T>(this IServiceCollection serviceswhere T:BaseDataProvider
        {
            ServiceProvider serviceProvider = services.BuildServiceProvider();
            services.AddSingleton(typeof(BaseDataProvider), Activator.CreateInstance(typeof(T), serviceProvider));
            return services;
        }
    }

    public abstract class BaseLoggingProvider
    {
        abstract public string GetName();
        abstract public void Log(string s);
    }

    public class ProductionLoggingProvider : BaseLoggingProvider
    {
        public override string GetName()
        {
            return "Production logging provider";
        }
        public override void Log(string s)
        {
            // write to log file
        }
    }

    public class TestLoggingProvider : BaseLoggingProvider
    {
        public override string GetName()
        {
            return "Test logging provider";
        }
        public override void Log(string s)
        {
            Console.WriteLine(s);
        }
    }

    public class Settings
    {
        public bool isProductionMode;
    }

    public abstract class BaseDataProvider
    {
        internal BaseLoggingProvider loggingProvider = null;    // This would normally be protected
        public BaseDataProvider(ServiceProvider services)
        {
            loggingProvider = services.GetService<BaseLoggingProvider>();
        }
        public abstract string GetName();
        public abstract void OpenConnection();
        public abstract Settings GetSettings();
        public abstract void CloseConnection();
        public abstract bool IsConnectionOpen();
    }

    public class ProductionDataProvider : BaseDataProvider
    {
        public ProductionDataProvider(ServiceProvider services) : base(services) { }

        private SqlConnection sqlConnection;

        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            sqlConnection.Close();
            sqlConnection = null;
        }

        public override string GetName()
        {
            return "Production data provider";
        }

        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (IsConnectionOpen())
                return new Settings() { isProductionMode = true };
            else
                throw new Exception("Connection is not open");
        }

        public override bool IsConnectionOpen()
        {
            return (sqlConnection != null && sqlConnection.State == System.Data.ConnectionState.Open);
        }

        public override void OpenConnection()
        {
            loggingProvider.Log("Opening connection");
            sqlConnection = new SqlConnection("server=(local);database=master;trusted_connection=true");
            sqlConnection.Open();
        }
    }

    public class TestDataProvider : BaseDataProvider
    {
        public TestDataProvider(ServiceProvider services) : base(services) { }

        private bool isConnectionOpen = false;

        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            isConnectionOpen = false;
        }

        public override string GetName()
        {
            return "Test data provider";
        }

        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (IsConnectionOpen())
                return new Settings() { isProductionMode = false };
            else
                throw new Exception("Connection is not open");
        }

        public override bool IsConnectionOpen()
        {
            return (isConnectionOpen);
        }

        public override void OpenConnection()
        {
            loggingProvider.Log("Opening connection");
            isConnectionOpen = true;
        }
    }

    public class Configuration
    {
        private BaseDataProvider dataProvider;
        private BaseLoggingProvider loggingProvider;

        public Configuration(ServiceProvider services)
        {
            dataProvider = services.GetService<BaseDataProvider>();
            loggingProvider = services.GetService<BaseLoggingProvider>();
        }

        public string GetDataProviderName()
        {
            return dataProvider.GetName();
        }

        public string GetLoggingProviderName()
        {
            return loggingProvider.GetName();
        }

        public string GetDataProviderLoggingProviderName()
        {
            return dataProvider.loggingProvider.GetName();
        }

        public Settings GetSettings()
        {
            Settings settings;
            dataProvider.OpenConnection();
            settings = dataProvider.GetSettings();
            dataProvider.CloseConnection();
            return settings;
        }
    }
}

If you run the program you will see the expected results.


Now let's see how we test this. Add an NUnit .Net Core, C# project to the solution and call it CoreDITest. Add references to Microsoft.Extensions.DependencyInjection and CoreDI. Rename Test.cs to ConfigurationTest.cs and make the contents look like the code below. It looks very similar to the previous tests we have done. I highlighted the interesting part.

As you can see, the only thing that changed between the production and the test code is the classes we are registering. This is how it should be.

using NUnit.Framework;
using Microsoft.Extensions.DependencyInjection;
using CoreDI;
 
namespace CoreDITest
{
    public class Tests
    {
        [Test]
        public void TestProvidersTest()
        {
            ServiceProvider services = new ServiceCollection()
               .RegisterLoggingProvider<TestLoggingProvider>()
               .RegisterDataProvider<TestDataProvider>()
               .BuildServiceProvider
();
 
            Configuration configuration = new Configuration(services);
            bool IsProductionMode = configuration.GetSettings().isProductionMode;
 
            Assert.IsFalse(IsProductionMode);
            Assert.AreEqual("Test data provider"configuration.GetDataProviderName());
            Assert.AreEqual("Test logging provider"configuration.GetLoggingProviderName());
            Assert.AreEqual("Test logging provider"configuration.GetDataProviderLoggingProviderName());
        }
    }
}

Build the project. When you run the tests, they all pass. This demonstrates that the test versions of the dependencies have been injected.



No comments:

Post a Comment