LoginSignup
5
5

More than 1 year has passed since last update.

ASP.NET MVC5(.NET Framework)にMicrosoft.Extensions.DependencyInjectionを統合する

Last updated at Posted at 2021-10-03

対象の読者

  • 現在ASP.NET MVC(.NET Framework)で動作するシステムのメンテナンスをしている、又は新規開発する予定の人。
  • そのシステムにDIコンテナを導入したいと考えている人。
  • DIコンテナがASP.NET MVC 5にどのような形で統合されているのか技術的な興味がある人。

目的

ASP.NET MVC5(.NET Framework4.8)で構築されたシステムに、ASP.NET MVC Coreで使用されているDIコンテナ「Microsoft.Extensions.DependencyInjection」を導入し、MVCフレームワークに統合します。

Microsoft.Extensions.DependencyInjection は .NET MVC Core用に作られたDIコンテナだと認識していたのですが、.NET Framework 4.6.1以降でも利用できるという記述を見つけて導入することにしました。

コントローラへのコンストラクタ・インジェクションと、コントローラアクションへのメソッド・インジェクションを、比較的ASP.NET MVC Coreに近い方法で利用できるようにします。

方法

1. AxaFrance Dependency Injection をNuGetでインストールします。

最初、自前でMVC5にDIを統合しようと思って調べていたのですが、やりたいことと同様の事をやってくれるパッケージを見つけたので、これを利用させて頂きます。

2. Glogal.asaxに以下のコードを追加します。

Application_StartにてDIコンテナのサービスプロバイダを生成し、MVC5から使えるよう設定します。

Global.asax.cs
using Microsoft.Extensions.DependencyInjection;
using AxaFrance.Extensions.DependencyInjection;
using AxaFrance.Extensions.DependencyInjection.Mvc;

    private IDisposable _provider;

    protected void Application_Start() {
        // ... 省略 ....

        // DIコンテナの生成とサービスの登録
        IServiceProvider provider = new ServiceCollection()
            .AddMvc()
            .AddScoped<IUserService, UserService>()
            .BuildServiceProvider();

        // DIコンテナを使うDependencyResolverをWeb.Mvcに登録する
        System.Web.Mvc.DependencyResolver.SetResolver(new Mvc.DefaultDependencyResolver(provider));

        // システム終了時に破棄する為、サービスプロバイダへの参照を保存する
        _provider = provider;
    }

    protected void Application_End() {
        // サービスプロバイダの破棄
        _provider?.Dispose();
        Debug.WriteLine("Application_End()");
    }

3. コンストラクタインジェクションでサービスを受け取ります。

コンストラクタ引数に自動的にインジェクションされます。

HomeController.cs
public HomeController(IUserService userService)
{
    // Do something.
    Debug.WriteLine(userService.Name);
}

4. メソッドインジェクションでサービスを受け取ります。

FormServices属性を付けたアクションパラメータに自動的にインジェクションされます。

HomeController.cs
public ActionResult Index([FromServices] IUserService userService)
{
    // Do something.
    Debug.WriteLine(userService.Name);
}

以上です。

AxaFrance.Extensions.DependencyInjection がやっていること

公式に作られたパッケージではないので、動作をしっかり確認する必要があります。
GitHubから、ソースコードをくまなくチェックしていきます。

コントローラのサービス登録

Applicaton_Start()で最初に呼び出しているservices.AddMvc()メソッドはAxaFrance.Extensions.DependencyInjection.Mvcで定義されているServiceCollectionの拡張メソッドでです。現在のアセンブリをスキャンし、以下の条件を満たすクラス、つまりコントローラのみをAddTransientでサービス登録しています。

  • publicなコントローラクラス
  • IControllerに代入可能
  • 抽象クラスやジェネリック定義ではない
  • クラス名の末尾が"Controller"で終わっている
AddMvc()
        public static IServiceCollection AddMvc(this IServiceCollection services)
        {
            Assembly assembly = Assembly.GetCallingAssembly();
            foreach (var @type in assembly.GetExportedTypes()
                .Where(t => !t.IsAbstract && !t.IsGenericTypeDefinition)
                .Where(t => typeof(IController).IsAssignableFrom(t)
                            && t.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)))
            {
                services.AddTransient(@type);
            }

            return services;
        }

コントローラへのコンストラクタインジェクションの実現

以下のコードにより、Web.Mvcがコントローラを生成する際に、Microsoft.Extensions.DependencyInjection を使ってDIするように設定しています。

Web.Mvc.DependencyResolverというシングルトンがあって、ASP.NET MVC内部でこれを使ってコントローラの生成を行っているようですね。なので、このDependencyResolverを独自のものに置き換えてやれば、コントローラの生成をDIコンテナに任せることができる、というわけです。

System.Web.Mvc.DependencyResolver.SetResolver(new Mvc.DefaultDependencyResolver(provider));

上記のMvc.DefaultDependencyResolverはこんな感じの実装です。

DefaultDependencyResolver.cs
    public class DefaultDependencyResolver : IDependencyResolver
    {
        private readonly IServiceProvider serviceProvider;

        public DefaultDependencyResolver(IServiceProvider serviceProvider)
        {
            this.serviceProvider = serviceProvider;
        }

        public object GetService(Type serviceType) => this.GetServiceScope()
            .ServiceProvider.GetService(serviceType);

        public IEnumerable<object> GetServices(Type serviceType) => this.GetServiceScope()
            .ServiceProvider.GetServices(serviceType);

        private IServiceScope GetServiceScope()
        {
            if (HttpContext.Current.Items[ScopedLifetimeHttpModule.HttpContextKey] == null)
            {
                var serviceScope = this.serviceProvider.CreateScope();
                HttpContext.Current.Items[ScopedLifetimeHttpModule.HttpContextKey] = serviceScope;
                return serviceScope;
            }

            return (IServiceScope)HttpContext.Current.Items[ScopedLifetimeHttpModule.HttpContextKey];
        }
    }

サービススコープをリクエスト単位で生成しています。これで、リクエスト単位のスコープということになるようです。
ServiceScopeインスタンスがHttpContextに突っ込まれたままになっているので、このインスタンスの破棄はGC任せになってしまうと思われます。

それが気になる方向けにAxaFrance.Extensions.DependencyInjection.Mvc.ScopedLifetimeHttpModuleというHttpModuleが用意されているようです(公式サイトに特に記載がないので、あくまでもソースコードからの推測です)。ScopedLifetimeHttpModuleの実装はこんな感じです。

    public class ScopedLifetimeHttpModule : IHttpModule
    {
        public static readonly string HttpContextKey = "AxaFrance.Extensions.DependencyInjection.Mvc.ScopedLifetimeHttpModule:ServiceScopeKey";

        public void Dispose()
        {

        }

        public void Init(HttpApplication context)
        {
            (context ?? throw new ArgumentNullException(nameof(context))).EndRequest += OnEndRequest;
        }

        private void OnEndRequest(object sender, EventArgs e)
        {
            var app = (HttpApplication)sender;
            IServiceScope scope = app.Context.Items[HttpContextKey] as IServiceScope;
            scope?.Dispose();
        }
    }

ソースを見る限りでは、HttpApplicationのEndRequestイベントにて、HttpContextに登録したServiceScopeインスタンスをDispose()しているようです。

このHttpModuleを有効にするには、Web.configに次の指定を追加すればよいようです。
参考: https://garafu.blogspot.com/2014/02/aspnet-ihttpmodule.html

Web.config(MVC5の場合)
<configuration>
  <system.webServer>
    <modules runAllManagedModulesForAllRequests="true">
      <add name="ScopedLifetimeHttpModule " type="AxaFrance.Extensions.DependencyInjection.Mvc.ScopedLifetimeHttpModule"/>
    </modules>
  </system.webServer>
</configuration>

この機能ついては、とりあえずWeb.configに指定はしてみましたが、深追いしていません。気になる方はScopedLifetimeHttpModuleと同等のクラスを作って代わりに登録し、きちんと処理が行われている事を確認してみても良いかもしれません。

コントローラアクションへのメソッドインジェクションの実現

CustomModelBinderAttribute属性クラスを継承したFromServiceAttributeクラスを定義して利用しています。
ModelBinderAttributeは、名前の通り、モデルクラスへのバインドを実現する際に利用する属性クラスのようです。

最終的にはFormServiceAttributeの中で生成したIModelBinderの実装クラスであるFormServicesModelBinderクラスのBindeModelメソッド内にて、System.Web.Mvc.DependencyResolver.GetServiceが呼び出され、Microsoft.Extensions.DependencyInjectionとの接続が実現されます。

FromServicesModelBinder.cs
    using System.Web.Mvc;

    internal class FromServicesModelBinder : IModelBinder
    {
        public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            var service = DependencyResolver.Current.GetService(bindingContext.ModelType);
            return service;
        }
    }

実験

念のため、以下のテストサービスを作ってサービスのライフタイムサイクルの確認実験をしてみます。

TestService.cs
class ITestService {
    void AddString( string text );
}

class TestService : ITestService, IDisposable {
    private bool disposed = false;
    private List<string> list = new();


    public TestService() {
        Debug.WriteLine("TestService()");
    }

    public void AddString( string text ) {
        list.Add(text);
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if(!this.disposed)
        {
            if(disposing)
            {
                Debug.WriteLine($"Dispose() for {String.Join(", ", list)}");
            }

            disposed = true;
        }
    }
}

TestServiceは、Disposeされると、それまでにAddStringされたテキストをカンマ区切りでDebugに出力します。

そして、コントローラ側で次のように利用します。
コンストラクタで1回、アクションメソッドでまとめて2回、インジェクションしています。

HomeController.cs

    public void HomeController(ITestService test) {
        test.AddString("constructor");
    }

    public ActionResult Index(ITestService test1, ITestService test2) {
        test1.AddString("method_1");
        test2.AddString("method_2");
    }
}

AddScoped

次のようにAddScopedでサービス登録します。

Global.aspx.cs
    service.AddScoped<ITestService, TestService>();

AddScopedで登録されたサービスは、リクエスト単位で一意です。
HomeController.Indexにアクセスした時のデバッグ出力は次のようになります。

TestService()
Dispose() for constructor, method_1, method_2

つまり、1つのTestServiceインスタンスだけがコンストラクタ、メソッドで使いまわされていることが確認できました。
問題なさそうです。

AddTransient

次のようにAddTransientでサービス登録します。

Global.aspx.cs
    service.AddTransient<ITestService, TestService>();

AddTransientで登録されたサービスは、要求されるたびに新しいインスタンスを生成します。
HomeController.Indexにアクセスした時のデバッグ出力は次のようになります。

TestService()
TestService()
TestService()
Dispose() for constructor
Dispose() for method_1
Dispose() for method_2

TestServiceインスタンスが都度生成されていたことが確認できました。Disposeも問題なくされています。
問題なさそうです。

AddSingleton

次のようにAddSingletonでサービス登録します。

Global.aspx.cs
    service.AddScoped<ITestService, TestService>();

AddSingletonで登録されたサービスは、システムで一意になります。つまり、最初に1回生成された後は、ずっと使いまわされます。TestService.AddStringはマルチスレッド対応されていないので本来これは問題ですが、今はテストであり、私以外にTestServiceを使う人間はいませんので大丈夫でしょう。

HomeController.Indexにアクセスした時のデバッグ出力は次のようになります。

TestService()

Dispose時のメッセージが出力されません。つまり、リクエストが終了しても、まだインスタンスはDisposeされず、生きています。

インスタンス自体は1回しか生成されておらず、その後HomeController.Indexに複数回アクセスしましたが、二度とTestService()は呼ばれないようです。(ウェブサイトを再起動したり、再発行したりすればまた呼ばれるようになります)。

次に、IISサービスマネージャを起動し、ウェブサイトを「停止」します。
すると、次のように出力されます。

Dispose() for constructor, method_1, method_2
Application_End()

正しく、1つのインスタンスが使いまわされていたようです。問題なさそうです。

おまけ

ちなみに最初、Global.asax.csにApplication_End()の記述はありませんでした。AxaFrance.Extensions.DependencyInjectionのUsageにも記載はなく、Sampleでも記述されていなかった為です。

しかし、その状態でIISサービスマネージャーからウェブサイトの停止をしてみても、AddSingletonしたサービスのDisposeのデバッグ出力が確認できませんでした。

そこで、AddSingletonしたサービスがいつ破棄されるかについて調べてみました。

こちらの記事によれば、AddSingletonされたサービスは、「ServiceProviderがアプリのシャットダウン時に破棄されたとき、シングルトンサービスが破棄されます」とのことです。

有効期間がシングルトンのサービス (AddSingleton) は、最初に要求されたときに作成されます (または、Startup.ConfigureServices が実行されて、サービス登録でインスタンスが指定された場合)。 以降の要求は、すべて同じインスタンスを使用します。 アプリをシングルトンで動作させる必要がある場合は、サービス コンテナーによるサービスの有効期間の管理を許可することをお勧めします。 クラス内のオブジェクトの有効期間を管理するために、シングルトンの設計パターンを実装してユーザー コードを提供しないでください。
要求を処理するアプリでは、ServiceProvider がアプリのシャットダウン時に破棄されたとき、シングルトン サービスが破棄されます。

考えてみると、我々はWeb.Mvcに対してMSのDIコンテナであるServiceProviderへの参照を伝えていません。

System.Web.Mvc.DependencyResolver.SetResolver(new Mvc.DefaultDependencyResolver(provider));

providerを知っているのはDefaultDependencyResolverであり、これはWeb.Mvcの預かり知らぬクラスです。よって、Web.Mvcがproviderを自動的に破棄してくれることなどなかったのです。

これはよろしくないので、ちゃんとシステム終了時にproviderを開放してあげなくてはならないことになります。

Global.asax.cs

    private IDisposable _provider;

    protected void Application_End() {

        _provider?.Dispose();
        Debug.WriteLine("Application_End()");
    }

Application_Start()の中で、生成したproviderを_providerに設定します。

Global.asax.cs
using Microsoft.Extensions.DependencyInjection;
using AxaFrance.Extensions.DependencyInjection;
using AxaFrance.Extensions.DependencyInjection.Mvc;


IServiceProvider provider = new ServiceCollection()
    .AddMvc()
    .AddScoped<IUserService, UserService>()
    .BuildServiceProvider();

System.Web.Mvc.DependencyResolver.SetResolver(new Mvc.DefaultDependencyResolver(provider));

_provider = provider; // ここで設定

この状態で再度試してみました。一度ブラウザHomeController.Indexにアクセスし、その後、IISサービスマネージャよりウェブサイトを停止してみます。

すると、次のように出力されました。

Dispose() for constructor, method_1, method_2
Application_End()

これで全て問題なさそうです。
やはり、外部のライブラリを確認せずに使うと、思わぬ落とし穴があります。

今回の結果は開発者に連絡しておこうと思います。

その他、ここへたどり着くまでの流れ(自分用のメモです。興味ない人は読まなくて大丈夫です)

元々はこちらの記事を発見したのが始まりです。
https://stackoverflow.com/questions/68861721/integrating-microsoft-extensions-dependencyinjection-in-asp-net-4-6-1-project

.NET FrameworkのASP.NET MVC5でも、MicrosoftのDIコンテナを使えるんだ!と初めて知りました。

しかし、DIコンテナを導入しても、それをASP.NET MVCに統合する(つまり、コントローラのコンストラクタインジェクションを有効にする)為には、ASP.NET MVCによるコントローラの生成に関与する為の何かしらのコードを書かねばならないはずです。

ASP.NET MVCでコントローラの生成を担っているのはWeb.Mvc.ControllerBuilderというクラスのようで、上記の記事では、DefaultControllerFactoryを継承したコントローラ生成クラスを作成し、以下のように差し替えています。

using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Microsoft.Extensions.DependencyInjection;

public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas();
        FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        BundleConfig.RegisterBundles(BundleTable.Bundles);

        var services = new ServiceCollection();

        // Register all your controllers and other services here:
        services.AddTransient<HomeController>();

        var provider = services.BuildServiceProvider(new ServiceProviderOptions
        {
            // Prefer to keep validation on at all times
            ValidateOnBuild = true,
            ValidateScopes = true
        });

        ControllerBuilder.Current.SetControllerFactory(
            new MsDiControllerFactory(provider));
    }
}
using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using Microsoft.Extensions.DependencyInjection;

public class MsDiControllerFactory : DefaultControllerFactory
{
    private readonly ServiceProvider provider;

    public MsDiControllerFactory(ServiceProvider provider) => this.provider = provider;

    protected override IController GetControllerInstance(
        RequestContext requestContext, Type controllerType)
    {
        IServiceScope scope = this.provider.CreateScope();

        HttpContext.Current.Items[typeof(IServiceScope)] = scope;

        return (IController)scope.ServiceProvider.GetRequiredService(controllerType);
    }

    public override void ReleaseController(IController controller)
    {
        base.ReleaseController(controller);

        var scope = HttpContext.Current.Items[typeof(IServiceScope)] as IServiceScope;

        scope?.Dispose();
    }
}

GetControllerInstanceの中で、DIコンテナからコントローラを取得して返していますね。

ちなみに私が必要なのはVB.NETのコードなので、これを以下のように変換しました。

MsDiControllerFactory.vb
Imports System
Imports System.Web
Imports System.Web.Mvc
Imports System.Web.Routing
Imports Microsoft.Extensions.DependencyInjection

Public Class MsDiControllerFactory
    Inherits DefaultControllerFactory

    Private ReadOnly Property provider As ServiceProvider

    Public Sub New(provider As ServiceProvider)
        Me.provider = provider
    End Sub

    Protected Overrides Function GetControllerInstance(requestContext As RequestContext, controllerType As Type) As IController
        Dim scope As IServiceScope = Me.provider.CreateScope()

        HttpContext.Current.Items(GetType(IServiceScope)) = scope

        Return CType(scope.ServiceProvider.GetRequiredService(controllerType), IController)

    End Function



    Public Overrides Sub ReleaseController(controller As IController)
        MyBase.ReleaseController(controller)

        Dim scope = TryCast(HttpContext.Current.Items(GetType(IServiceScope)), IServiceScope)
        scope?.Dispose()

    End Sub


End Class

そして、Global.asax.vbのApplication_Start()に以下を追記しました。

    Dim services = New ServiceCollection();

    ' Register all your controllers and other services here:
    services.AddTransient(Of HomeController)()

    Dim provider = services.BuildServiceProvider(New ServiceProviderOptions With
    {
        ' Prefer to keep validation on at all times
        .ValidateOnBuild = True,
        .ValidateScopes = True
    })

    ControllerBuilder.Current.SetControllerFactory(
        New MsDiControllerFactory(provider))

これで、コントローラへのコンストラクタインジェクションが実現されました。

その後、「アクションへのメソッドインジェクションはどうしようかな。フィルタを使うのが一般的なのかな」などと考えていたところ、上記のAxaFrance Dependency Injectionを発見し、内容を見て「こっちのがスマートだ」と、切り替えた次第です。

こうして調べた結果はAxaFrance Dependency Injectionの内容を精査するのに役立っているので、決して無駄にはなりませんでした。

結論

利用に関して問題はなさそうです。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5