Try .NET Core

.NET Coreを動かした、試した記録を書き残します。

音楽サーバ"Mopidy"のフロントエンドを作る:05 AspCoreのDIの仕組み

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第5回です。
さて今回は、ASP.Net CoreのDI(Dependency Injection)の仕組みを追っていきます。

DI(Dependency Injection)ってなに?

日本語では「依存性の注入」などと表現されます。
...えっとナニソレ?字面だけ見てもさっぱり分からないシロモノですよね。

何がどう便利なの?という点については、下記のQiita記事がとても詳細に例を挙げて解説して頂いています。
「なぜDI(依存性注入)が必要なのか?」についてGoogleが解説しているページを翻訳した - Qiita

クラスベースのオブジェクト指向言語で、

  • コード間の結合を薄めることで、並行作業をスムーズにする
  • ユニットテストを書きやすくする
  • インスタンス生成から破棄までの、クラスのライフサイクルを確実なものにする

といった目的を追い求めて出来上がった、デザインパターンの一種です。

特に真価を発揮するのは、大規模チームで沢山の機能を並行して書き進めるときです。
クラス設計者が機能のインタフェースを書いてしまえば、

  • 実装者はインタフェースを元に機能実装を進める
  • テスターはインタフェースを元にユニットテストを書き進める
  • その機能を利用して別の機能を実装する人は、インタフェースを使って書き進める

と、それぞれの作業を止めない形で並行してタスクをこなすことが出来ます。

AspCoreのDI

主にJava界隈で発達したDIの考え方は、その後ASP.Netを.Net Core対応する際に取り込まれました。
Microsoft公式ドキュメントでも、一章を割いて詳しく書かれています。

AspCoreで書いた自作のクラスは、基本的にこのDIを経由して使うことになります。

AspCoreでは、DIに登録するクラスを"サービス"と見立て、"サービス"を保持するServiceCollection、"サービス"の生成/破棄を受け持つServiceProviderという形で実装されています。

...うん。頭痛い。

実は既に使ってたりして

見慣れない人にとっては、概念を把握するのはとっても面倒なDIなんです、が。
実はAspCoreでは、プロジェクトを作った最初の時点で、既にそれを使っているんです。

一番最初のコミットの、Startup.csがこちら。
これは何も手を入れていない、テンプレートそのままの状態です。

注目すべきは、ConfigureServicesメソッドです。

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.Configure<CookiePolicyOptions>(options =>
    {
        // This lambda determines whether user consent for non-essential cookies is needed for a given request.
        options.CheckConsentNeeded = context => true;
        options.MinimumSameSitePolicy = SameSiteMode.None;
    });

    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

引数として受け取ったservicesで、AddMvcメソッドを実行しています。
これは、「MvcっていうサービスをServiceCollectionに追加しますよ」という意味合いです。

メソッドの名前がConfigureService、そして先頭のコメント:

Use this method to add services to the container.

とあるように、サービスを登録するときはここに書くわけですね。

なおコメント中のthe containerは、ServiceCollection(=services)を示します。

DIのお仕事を受け持つオブジェクトは「DIコンテナ」と表現されます。
コメント上の記載ではcontainerと書いておいて、servicesはDIコンテナなんだよ、と注釈しているんですね。

サービスのライフサイクル

さて、DIのハードルを下げた(?)ところで。

ひとくちにサービスと言っても、いろんなお役目があると思います。
データベースの値を取ってきて加工したり、バックエンドで延々とバッチジョブを処理したりと、目的に応じて機能を書いていきます。

DIではコード上でクラスをインスタンシエイトしません。
DI上のサービスは、DIを通してインスタンスを受け取り、DIを通して破棄されます。

そこで、この機能がいつ生成され、いつ破棄するべきなのか、予めDIに教えておきます。
その機能の生成から破棄までの流れを、「サービスのライフサイクル」と表現します。

AspCoreのサービスライフサイクル

AspCoreでは、ライフサイクルの種類として3つが用意されています。
公式ドキュメントのここですね。

  1. Transient - 使い終わったら即破棄
  2. Scoped - 1回のhttpリクエリトが終わったら破棄
  3. Singleton - 一度生成されたら、破棄しないで保持し続ける

このライフサイクルに合わせて、実際の機能を作っていくことになります。
データベースの操作なんかは1.のTransientで使い捨て、常駐タスクは3.のSingletonで走らせる感じですかね。

2.のScopedは...うーん。あんまり良い使い方が思いつきませんでした...。

サービスを登録する

Mopidy.Finderでサービス関係を整理し終えたのは、だいぶ後半のコミットでした。

使い捨て系のDB操作用Storeクラスはすぐに整備したのですが、バックエンドの常駐タスクをよろしくない形で書いてしまったため、このコミットで書き直しています。

サービスを登録しているのは、前述のとおり、Startup.csです。
src/aspCore/Startup.cs:

public class Startup
{
    // -- 中略 --

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<CookiePolicyOptions>(options =>
        {
            // This lambda determines whether user consent for non-essential cookies is needed for a given request.
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.None;
        });

        services.AddDbContext<Dbc>(options =>
        {
            // フルパス指定が出来ない?要検証。
            //options.UseSqlite($"Data Source=\"{Program.DbPath}\"");
            options.UseSqlite($"Data Source=database.db");

            // MySQL接続のとき
            //options.UseMySQL(this.Configuration.GetConnectionString("DbConnectionMySql"));
        },
            ServiceLifetime.Transient, // 呼び出し都度インスタンシエイトする。
            ServiceLifetime.Transient
        );

        services
            .AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
            .AddJsonOptions(options =>
            {
                // JSON生成時、キャメル先頭を大文字で返す。
                options.SerializerSettings.ContractResolver
                    // アッパーキャメルの場合
                    // = new DefaultContractResolver();
                    // ロウアーキャメルの場合
                    //= new CamelCasePropertyNamesContractResolver();
                    = new DefaultContractResolver();

                // 無限ループ検出時の動作。
                // シリアライズエラー時、デフォルトでは途中状態の文字列を返してしまう。
                options.SerializerSettings.ReferenceLoopHandling
                    = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
            });
        services
            .AddTransient<AlbumStore, AlbumStore>()
            .AddTransient<GenreStore, GenreStore>()
            .AddTransient<ArtistStore, ArtistStore>()
            .AddTransient<ArtistAlbumStore, ArtistAlbumStore>()
            .AddTransient<GenreAlbumStore, GenreAlbumStore>()
            .AddTransient<GenreArtistStore, GenreArtistStore>()
            .AddTransient<AlbumTracksStore, AlbumTracksStore>()
            .AddTransient<TrackStore, TrackStore>()
            .AddTransient<SettingsStore, SettingsStore>()
            .AddTransient<JobStore, JobStore>()
            .AddSingleton<Query, Query>()
            .AddSingleton<Playback, Playback>()
            .AddSingleton<Library, Library>()
            .AddSingleton<Tracklist, Tracklist>()
            .AddSingleton<DbMaintainer, DbMaintainer>();
    }
    // -- 中略 --
}

ConfigureServicesメソッドの末尾に、大量のAddTransientAddSingletonがありますね。

AddTransientで追加しているのは、データベースのテーブルを操作するクラスです。
対して、staticで構わないもの、またstaticでいて欲しいものをAddSingletonで追加しています。

前々回の記事で上げた際の記述:

そして、忘れがちな最後のひと手間、AlbumStoreクラスをDIコンテナに登録します。
aspCore/Startup.csのConfigureServicesメソッド末尾に、下記を追記します。

ずいぶんさらっと流してますが、これが今回記事のキモです。

今回記事の導入では、インタフェースを定義した上で実装を作る、と書きました。
本来のDI機能を使う上では、そうすべきです。

例えば、AlbumStoreを登録する際は予めIAlbumStoreインタフェースを定義した上で

services
    .AddTransient<IAlbumStore, AlbumStore>();

のように、インタフェースに対する実装クラスを割り当てるのが正しいDIの使い方です。

今回は、個人的なプロジェクトなので、サボっています!

都度生成サービスを受け取る

DI上のサービスを使う際は、メソッド引数に[FromServices]という属性を書きます。
「DIが持ってるサービスなんだよ」ということを教えてあげるんですね。

例えば、コントローラではこのように。
src/aspCore/Controllers/ArtistController.cs

[Produces("application/json")]
[Route("Artist")]
public class ArtistController : Controller
{
    [HttpGet("GetPagenatedList")]
    public XhrResponse GetPagenatedList(
        [FromQuery] int[] GenreIds,
        [FromQuery] string FilterText,
        [FromQuery] int? Page,
        [FromServices] ArtistStore store  // <- ここでインスタンスを受け取る
    )
    {
        var args = new ArtistStore.PagenagedQueryArgs()
        {
            GenreIds = GenreIds,
            FilterText = FilterText,
            Page = Page
        };
        var result = store.GetPagenatedList(args);

        return XhrResponseFactory.CreateSucceeded(result);
    }
}

GetPagenatedListメソッドの引数で、ArtistStoreインスタンスを貰っています。

そのArtistStoreの実装の中でも、別のサービスを受け取っています。
src/aspCore/Models/Artists/ArtistStore.cs:

public class ArtistStore : PagenagedStoreBase<Artist>, IMopidyScannable
{
    // -- 中略 --

    private Library _library;

    public ArtistStore(
        [FromServices] Dbc dbc,          // <- Dbcインスタンスを受け取る
        [FromServices] Library library   // <- Libraryインスタンスを受け取る
    ) : base(dbc)
    {
        this._library = library;
    }

    public PagenatedResult GetPagenatedList(PagenagedQueryArgs args)
    {
        var query = this.Dbc.GetArtistQuery();

        if (args.GenreIds != null && 0 < args.GenreIds.Length)
            query = query
                .Where(e => e.GenreArtists.Any(e2 => args.GenreIds.Contains(e2.GenreId)));

        if (!string.IsNullOrEmpty(args.FilterText))
            query = query
                .Where(e => e.LowerName.Contains(args.FilterText.ToLower()));

        var totalLength = query.Count();

        query = query.OrderBy(e => e.LowerName);

        if (args.Page != null)
        {
            query = query
                .Skip(((int)args.Page - 1) * this.PageLength)
                .Take(this.PageLength);
        }

        var array = query.ToArray();

        var result = new PagenatedResult()
        {
            TotalLength = totalLength,
            ResultLength = array.Length,
            ResultPage = args.Page,
            ResultList = array
        };

        return result;
    }
    // -- 中略 --
}

コンストラクタで、DbcLibraryインスタンスを貰ってますね。

このように、連鎖的にDI上のサービスを貰い受けながら実装を書いていきます。
これで、インスタンスが破棄されず宙ぶらりんになることを防ぎながら、実装を進めることが出来ます。

常駐サービスを受け取る

バックエンドで常駐しておいて欲しいサービスは、Startup.csで書きます。
リリース時点のStartup.csでの例がこちらです。

src/aspCore/Startup.cs:

public class Startup
{
    // -- 中略 --
    public void ConfigureServices(IServiceCollection services)
    {
        // -- 中略 --
        services
            .AddTransient<AlbumStore, AlbumStore>()
            .AddTransient<GenreStore, GenreStore>()
            .AddTransient<ArtistStore, ArtistStore>()
            .AddTransient<ArtistAlbumStore, ArtistAlbumStore>()
            .AddTransient<GenreAlbumStore, GenreAlbumStore>()
            .AddTransient<GenreArtistStore, GenreArtistStore>()
            .AddTransient<AlbumTracksStore, AlbumTracksStore>()
            .AddTransient<TrackStore, TrackStore>()
            .AddTransient<SettingsStore, SettingsStore>()
            .AddTransient<JobStore, JobStore>()
            .AddTransient<DbEnsurer, DbEnsurer>()
            .AddSingleton<Query, Query>()
            .AddSingleton<Playback, Playback>()
            .AddSingleton<Library, Library>()
            .AddSingleton<Tracklist, Tracklist>()
            .AddSingleton<DbMaintainer, DbMaintainer>();
    }

    private DbMaintainer _dbMaintainer;

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(
        IApplicationBuilder app,
        IApplicationLifetime applicationLifetime,
        IHostingEnvironment env
    )
    {
        using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
        using (var dbEnsurer = serviceScope.ServiceProvider.GetService<DbEnsurer>())
        {
            dbEnsurer.Ensure();
            this._dbMaintainer = serviceScope.ServiceProvider.GetService<DbMaintainer>();
        }

        // アプリケーション起動/終了をハンドルする。
        // https://stackoverflow.com/questions/41675577/where-can-i-log-an-asp-net-core-apps-start-stop-error-events
        applicationLifetime.ApplicationStarted.Register(this.OnStarted);
        applicationLifetime.ApplicationStopping.Register(this.OnShutdown);

        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }

        app.UseStaticFiles();
        app.UseCookiePolicy();

        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    }

    private void OnStarted()
    {
        if (!this._dbMaintainer.IsAlbumScannerRunning)
            this._dbMaintainer.RunAlbumScanner();
    }

    private void OnShutdown()
    {
        if (this._dbMaintainer.IsAlbumScannerRunning
            || this._dbMaintainer.IsDbUpdateRunning)
        {
            this._dbMaintainer.StopAllTasks()
                .GetAwaiter()
                .GetResult();
        }
    }
}

Configureメソッドの中で、IApplicationBuilderを通してDbMaintainerインスタンスを取得、保持しておきます。

using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
    this._dbMaintainer = serviceScope.ServiceProvider.GetService<DbMaintainer>();
}

DbMaintainerはSingletonで登録したので、一度生成してしまえば破棄されません。

そしてIApplicationLifetimeで、Asp.NetCoreの起動/終了をハンドルします。

applicationLifetime.ApplicationStarted.Register(this.OnStarted);
applicationLifetime.ApplicationStopping.Register(this.OnShutdown);

それぞれハンドルしたメソッドで、DbMaintainerの開始/終了を呼び出しています。
起動時に常駐し、終了時には常駐タスクを終わらせるような仕組みが出来ました。

以上、AspCoreのDIのおはなし、でした!