.NETの依存性注入を自動で登録する

近年の .NET 開発はコンソールしかり Web しかり、 Microsoft.Extensions.* 名前空間で提供されているホスト、ロギング、依存性注入などの機能を利用することが普通になってきたと感じています。今回はその中の依存性注入の機能に関して、独自の属性( Attribute )とリフレクションを駆使して必要なサービスを自動的に登録する手法について紹介します。


はじめに

筆者は .NET に触れる以前は Java の開発者でして、 Spring や JavaEE といった Java のフレームワークを触った経験があります。これらのフレームワークには依存性注入の機能が標準搭載されており、基本的にはアノテーション( .NET の属性)をクラスやメソッドに付与するだけで依存関係が自動的に定義されていました。しかし .NET の依存性注入機能にはこのような自動機能は存在せず、標準では一つ一つ真心をこめて登録する必要があります。大変面倒くさいです。
本記事はそんな Java での使用感を忘れられない筆者による、専用の属性とリフレクションを使用して .NET の依存性注入機能に対してサービスを自動登録している方法の紹介です。なお依存性注入の概念や .NET の依存性注入機能自体に関する解説はしませんことを予めお断りしておきます。

試した環境

要素 バージョン
Visual Studio 2022 (17.3.2)
.NET SDK 6.0.400

本記事に記載のコードは全て GitHub 上に置いてあります。

github.com

サービスを自動登録する

はじめに依存性注入機能にサービスクラスを登録する方法の紹介です。最終的にこのようなコードで使用できるようになります。

// 登録するサービス

using NX.DependencyInjection;

namespace Hoge;

[Component(Scope = ComponentScope.Singleton, Target = typeof(IHogeService))]
public class HogeService : IHogeService
{
}
// 自動登録の設定

var services = new ServiceCollection();
// "Target.Assembly.Name" アセンブリから対象クラスを自動登録
services.RegisterComponents("Target.Assembly.Name");

属性の作成

まずは自動登録する対象をマーキングする属性を作成します。独自の属性を実装するには System.Attribute を継承したクラスを作成し、 System.AttributeUsage 属性を付与します。 .NET の依存性注入機能に登録するためには登録対象の型とライフサイクルが必要になりますので、プロパティに持たせてあります。

using System;

namespace NX.DependencyInjection
{
    /// <summary>
    /// <see cref="Microsoft.Extensions.DependencyInjection.IServiceCollection" />にコンポーネントを自動登録するための属性
    /// </summary>
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
    public class ComponentAttribute : Attribute
    {
        /// <summary>
        /// コンポーネントのライフサイクル。未設定の場合は<see cref="ComponentScope.Scoped"/>
        /// </summary>
        public ComponentScope Scope { get; set; } = ComponentScope.Scoped;

        /// <summary>
        /// コンポーネントの登録対象の型。未設定の場合はコンポーネント自身の型。
        /// </summary>
        public Type? TargetType { get; set; }

        public ComponentAttribute()
        {
        }

        public ComponentAttribute(ComponentScope scope, Type? targetType = null)
        {
            Scope = scope;
            TargetType = targetType;
        }
    }
}

この属性だけでは味気ないため、ライフサイクルの名前を付けた SingletonAttributeTransientAttribute 、機能の名前を付けた RepositoryAttributeServiceAttribute などをこの ComponentAttribute を継承して作成するのも良いと思います( GitHub 上のサンプルコードにあります)。

リフレクションの実装

続いて先ほどの ComponentAttribute をリフレクションを使用して取得しましょう。今回の実装ではアセンブリの名前を指定してそのアセンブリの中に登録されている全てのクラスから ComponentAttribute が付与されているものを探して ComponentInfo という内部クラスにマッピングしています。属性側に AllowMultiple = true を指定しているため同時に複数の属性が付与されている可能性があり、その点だけ注意が必要です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace NX.DependencyInjection
{
    public static class ComponentLoader
    {
        public static IEnumerable<ComponentInfo> Load(string assemblyName)
        {
            try
            {
                return Load(Assembly.Load(assemblyName));
            }
            catch (Exception e)
            {
                throw new AssemblyLoadException($"Failed to load assembly \"{assemblyName}\"", e);
            }
        }

        public static IEnumerable<ComponentInfo> Load(Assembly assembly)
        {
            // アセンブリ内のクラスから指定の属性を持つもののみを抽出
            return assembly.GetTypes()
                .SelectMany(type =>
                    type.GetCustomAttributes<ComponentAttribute>().Select(attr => (Type: type, Attr: attr)))
                .Select(x => new ComponentInfo(x.Attr.Scope, x.Attr.TargetType ?? x.Type, x.Type));
        }

        // ComponentAttributeの情報を保持しておくための箱
        public class ComponentInfo
        {
            public ComponentScope Scope { get; }
            public Type TargetType { get; }
            public Type ImplementType { get; }

            public ComponentInfo(ComponentScope scope, Type targetType, Type implementType)
            {
                Scope = scope;
                TargetType = targetType;
                ImplementType = implementType;

                if (!TargetType.IsAssignableFrom(ImplementType))
                {
                    throw new ArgumentException($"Type \"{TargetType.FullName}\" is not assignable from \"{ImplementType.FullName}\"");
                }
            }
        }
    }
}

依存性注入機能への登録

最後に IServiceCollection に対してリフレクションで取得された ComponentInfo から ServiceDescriptor を登録します。登録したいコンポーネントが存在するアセンブリの名前を RegisterComponents 拡張関数の引数に渡すことで、 ComponentAttribute を付与されたクラスが全て IServiceCollection に登録されます。たったこれだけのコードで、長々と使用するサービス群の一覧を手書きする必要はもうありません!

using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace NX.DependencyInjection
{
    public static class ServiceCollectionExtensions
    {
        /// <summary>
        /// 指定のアセンブリからコンポーネントを検索して自動登録します。
        /// </summary>
        /// <param name="services">コンポーネントを登録するServiceCollection</param>
        /// <param name="assemblyNames">コンポーネントを検索するアセンブリの名前一覧</param>
        public static IServiceCollection RegisterComponents(this IServiceCollection services, params string[] assemblyNames)
        {
            foreach (var ci in assemblyNames.SelectMany(ComponentLoader.Load))
            {
                var lifetime = ci.Scope switch
                {
                    ComponentScope.Singleton => ServiceLifetime.Singleton,
                    ComponentScope.Scoped => ServiceLifetime.Scoped,
                    ComponentScope.Transient => ServiceLifetime.Transient,
                    _ => throw new InvalidOperationException("Unknown ComponentScope value")
                };

                services.Add(new ServiceDescriptor(ci.TargetType, ci.ImplementType, lifetime));
            }

            return services;
        }
    }
}

設定を自動登録する

続いて Options (.NET の設定に簡易にアクセスする機能)を自動的に登録する方法を紹介します。 AppSettings.json などの設定ファイルからセクション(設定ファイルの階層)を指定して単純にマッピングするケースのみがサポートされます。最終的にこのようなコードで使用できるようになります。

// 設定クラス

using NX.DependencyInjection;

namespace Hoge;

[Configuration(SectionKey = "Hoge:Fuga")]
public class HogeConfiguration
{
}
// 自動登録の設定

var services = new ServiceCollection();
var configuration = ...;
services.RegisterConfigurations(configuration, "Target.Assembly.Name");

依存性注入機能への登録

基本的な実装方法は先の「サービスを自動登録する」と同じですので、同じようなコードの掲載は省略して、最後の ServiceCollection に対して自動登録する箇所だけコードを提示しておきます。省略された部分は GitHub 上のコード をご覧下さい。
サービスの自動登録時は ServiceDescriptor を生成して登録するだけでしたが、 Options の場合は少し複雑になります。通常ですと services.Configure<Hoge>() などと登録処理を呼び出しているかと思います。この Configure の中では複数の処理が行われているため、単純に ServiceDescriptor を生成して登録するだけとはなりません。 Configure のメソッド情報を取得し、 MakeGenericMethod で Generics の型を指定したメソッド情報を作成し、呼び出しを行う、と数段階のリフレクションが必要です。

using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace NX.DependencyInjection
{
    public static class ServiceCollectionExtensions
    {
        /// <summary>
        /// 指定のアセンブリから設定クラスを検索して自動登録します。
        /// </summary>
        /// <param name="services">コンポーネントを登録するServiceCollection</param>
        /// <param name="configuration">設定値</param>
        /// <param name="assemblyNames">コンポーネントを検索するアセンブリの名前一覧</param>
        public static IServiceCollection RegisterConfigurations(this IServiceCollection services, IConfiguration configuration,
            params string[] assemblyNames)
        {
            services.AddOptions();

            // Configureメソッドの情報を取得
            var configureMethod = typeof(OptionsConfigurationServiceCollectionExtensions)
                                      .GetMethod(nameof(OptionsConfigurationServiceCollectionExtensions.Configure),
                                          new[] { typeof(IServiceCollection), typeof(IConfiguration) })
                                  ?? throw new InvalidOperationException(
                                      "Unable to reflect Configure(IServiceCollection, IConfiguration).");
            var namedConfigureMethod = typeof(OptionsConfigurationServiceCollectionExtensions)
                                           .GetMethod(nameof(OptionsConfigurationServiceCollectionExtensions.Configure),
                                               new[] { typeof(IServiceCollection), typeof(string), typeof(IConfiguration) })
                                       ?? throw new InvalidOperationException(
                                           "Unable to reflect Configure(IServiceCollection, string, IConfiguration).");

            foreach (var ci in assemblyNames.SelectMany(ConfigurationLoader.Load))
            {
                var configurationSection = string.IsNullOrEmpty(ci.Section) ? configuration : configuration.GetSection(ci.Section);

                // Nameのありなしでリフレクション対象のメソッドシグネチャが変わる
                // Configureメソッド情報にGenericsの情報を加えて実行
                if (string.IsNullOrEmpty(ci.Name))
                {
                    configureMethod.MakeGenericMethod(ci.Type)
                        .Invoke(null, new object[] { services, configurationSection });
                }
                else
                {
                    namedConfigureMethod.MakeGenericMethod(ci.Type)
                        .Invoke(null, new object[] { services, ci.Name, configurationSection });
                }
            }

            return services;
        }
    }
}

おわりに

.NET の依存性注入機能に対してサービスと設定のクラスを自動で登録する方法の紹介でした。

手動で登録処理を書くのももちろんアリなのですが、冒頭にも書きました通り、筆者にはとても面倒くさく感じてしまいます。筆者と同じように丹精込めて依存を定義していくのが面倒だと思う方がいらっしゃいましたら、ぜひ本記事の内容を参考にして頂ければと思います。快適な開発ライフを送りましょう!

執筆担当者プロフィール
中谷 大造

中谷 大造(日本ビジネスシステムズ株式会社)

情報システム部の中谷です。社内用スクラッチアプリの開発をしています。

担当記事一覧