noxi雑記

.NET、Angularまわりの小ネタブログ

Xamarinで .NET Extensionsを使ってみる

最近全くと言って良いほどXamarinを触っていなかったnoxiです。

この記事は Xamarin Advent Calendar 2018 の16日目です。15日目は tan-y さんのXamarin.Forms for Windows Forms で特殊な PageRenderer の実装をしようとしてはまった話でした。明日は moonmile さんです。よろしくお願いします。

この記事ではXamarin自体のあれこれではなく、Xamarinで開発するときに利用できるライブラリを紹介します。この記事の対象者は、日頃からASP.NET Coreを利用されている方でXamarinをやられている方、またはXamarinを利用していてASP.NET Coreを触ったことが無い方です。掲載されているコードは全てXamarin.FormsをPrismテンプレート(Unity)で作成し、Xamarin.Formsのバージョンを最新に更新したものをベースとしています。

前提条件

この記事は以下のバージョンで検証しています。

注意点として、Androidは依存ライブラリの参照方式をPackageReferenceから古のpackages.configに変更しないと Could not load assembly 'System.Memory' during startup registration とエラーになって起動しません [参考]。

.NET Extensionsとは

まずはこの記事のタイトルにもなっている「 .NET Extensions」について紹介します。紹介と言ってもGithubのリンクとREADMEの序文を貼り付けるだけなのですが、、、

github.com

.NET Extensions is an open-source, cross-platform set of APIs for commonly used programming patterns and utilities, such as dependency injection, logging, and app configuration. Most of the API in this project is meant to work on many .NET platforms, such as .NET Core, .NET Framework, Xamarin, and others. While commonly used in ASP.NET Core applications, these APIs are not coupled to the ASP.NET Core application model. They can be used in console apps, WinForms and WPF, and others.

(Google翻訳) .NET Extensionsは、依存性注入、ロギング、アプリケーション設定など、よく使用されるプログラミングパターンとユーティリティ用のオープンソースクロスプラットフォームAPIセットです。 このプロジェクトのほとんどのAPIは、.NET Core、.NET Framework、Xamarinなどの多くの.NETプラットフォームで動作するように設計されています。 ASP.NETコアアプリケーションでは一般的に使用されますが、これらのAPIASP.NETコアアプリケーションモデルと結合されていません。 コンソールアプリケーション、WinForms、WPFなどで使用できます。

Google翻訳凄い。。。
加えて説明すると、ASP.NET Coreの開発にあたりDIやロギングなどの一般的に利用される機能を、サードパーティに依存せず実装したものが .NET Extensionsです。 .NET Standardで実装されているため、 .NET Standardのバージョンがサポートされているランタイムであれば何にでも利用することができます。

この記事ではクライアントアプリでも使用しそうな依存性注入(DI)、ロギング、設定、メモリキャッシュ、HTTPクライアントを紹介します。

依存性注入(DI)

NuGetパッケージ 説明
Microsoft.Extensions.DependencyInjection.Abstractions 依存性注入機能のインターフェースのみを含むパッケージです。
Microsoft.Extensions.DependencyInjection 依存性注入機能が実装されているパッケージです。

依存性注入(DI)の使い方

.NET ExtensionsのDI機能はコンポーネントを登録する IServiceCollectionコンポーネントを取得する IServiceProvider の2つに別れています。 IServiceProvider にはコンポーネントを登録する機能はありませんので、必要なものは事前に全て IServiceCollection に登録する必要があります。

.NET ExtensionsのDI機能が標準で用意しているスコープは以下の3つです。

名前 説明
Singleton 名前そのまま、シングルトンです。 IServiceProvider から取得するインスタンスは全て共通のインスタンスになります。
ASP.NET CoreではHTTPリクエストやデータベースに依存しないものに使用されます。
Scoped IServiceProvider から取得するインスタンスはスコープ単位で共通のインスタンスになります。スコープが異なれば違うインスタンスが取得されます。
ASP.NET CoreではHTTPリクエスト単位でスコープが切られるため、HTTPリクエストに依存するものに使用されます。
Transient IServiceProvider から取得するインスタンスは全て別のインスタンスになります。

DI機能を簡単に使ってみたのが次のコードです。 IServiceCollectionAddSingletonAddScopedなどのメソッドを使用してコンポーネントを登録し、 BuildServiceProvider メソッドで IServiceProvider を生成します。 IServiceProvider からは GetRequiredService メソッドを使用してコンポーネントを取得します。また CreateScope メソッドを使用するとスコープを切ることができます。

// コンポーネントの登録
IServiceCollection services = new ServiceCollection();
services.AddSingleton<ISingletonService, SingletonService>();
services.AddScoped<IScopedService, ScopedService>();
services.AddTransient<ITransientService, TransientService>();

// コンポーネントの取得
IServiceProvider provider = services.BuildServiceProvider();
ISingletonService singletonService = provider.GetRequiredService<ISingletonService>();

// スコープの作成とスコープ共有コンポーネントの取得
using (IServiceScope serviceScope = provider.CreateScope())
{
    IScopedService scopedService = serviceScope.ServiceProvider.GetRequiredService<IScopedService>();
}

依存性注入(DI)の注意

.NET ExtensionsのDI機能は、先に書いたように後から動的にDIで管理する対象を変更できません。そのためPrismのDI実装としては使用できないらしいです(issue)。この記事は Xamarin.Forms + Prism を対象に書こうとしていたため、まずおおきな躓きです。解散。
Android/iOS共に上記コードは動作しますので、Prismと組み合わせないのであれば用途は十分にあると思います。

ロギング

NuGetパッケージ 説明
Microsoft.Extensions.Logging.Abstractions ロギング機能のインターフェースのみを含むパッケージです。
Microsoft.Extensions.Logging ロギング機能が実装されているパッケージです。
Microsoft.Extensions.Logging.Configuration アプリケーション設定からロギング設定を読み込む機能が実装されているパッケージです。
Microsoft.Extensions.Logging.Console コンソールに対してログを出力するプロバイダーが実装されているパッケージです。
Microsoft.Extensions.Logging.Debug デバッグコンソールに対してログを出力するプロバイダーが実装されているパッケージです。
Microsoft.Extensions.Logging.Configuration デバッグコンソールに対してログを出力するプロバイダーが実装されているパッケージです。

ロギングの使い方

.NET Extensionsのロギング機能はログプロバイダーを追加することでログ出力先を追加することができます。標準ではコンソールやデバッグコンソール、Windowsのイベントログなどに出力するログプロバイダーの実装があります。またNLogなどの別のロガーに対して出力するログプロバイダーも存在します。
.NET Extensionsのロギング機能からログ出力を行うには、まず ILoggerFactory を作成しログプロバイダーやログ出力オプションを設定します。そして ILoggerFactory から ILogger を取得します。ログのレベルは TraceDebugInformationWarningErrorCritical です。

例としてDIコンテナにUnityを使用しつつデバッグコンソールにログを出力してみます。NuGetから Microsoft.Extensions.LoggingMicrosoft.Extensions.Logging.Debug 、そして Unity.Microsoft.Logging の3つを追加し、 App.xaml.cs に以下のコードを追加すると、Prismのログを .NET Extensions経由でデバッグコンソールに出力します。

(App.xaml.cs)

protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
{
    base.RegisterRequiredTypes(containerRegistry);
    RegisterLogger(containerRegistry);
}

private void RegisterLogger(IContainerRegistry containerRegistry)
{
    // ログの設定
    ILoggerFactory loggerFactory = new LoggerFactory();
    loggerFactory.AddProvider(new DebugLoggerProvider()); // デバッグログ出力

    // DI登録
    containerRegistry.GetContainer().AddExtension(new LoggingExtension(loggerFactory));
    containerRegistry.RegisterSingleton<ILoggerFacade, PrismLogger>();
}

/// <summary>
/// .NET Extensionsのロガー経由でログを出力するPrismのLoggerFacade。
/// </summary>
public class PrismLogger : ILoggerFacade
{
    private readonly ILogger _logger;

    public PrismLogger(ILogger<PrismLogger> logger)
    {
        // コンストラクタでDIからロガーを受け取る
        this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public void Log(string message, Category category, Priority priority)
    {
        switch (category)
        {
            case Category.Debug:
                this._logger.LogDebug(message);
                break;

            case Category.Info:
                this._logger.LogInformation(message);
                break;

            case Category.Warn:
                this._logger.LogWarning(message);
                break;

            case Category.Exception:
                this._logger.LogError(message);
                break;
        }
    }
}

ViewModelでも同様に、 ILogger<MainViewViewModel> をコンストラクタで受け取りログを出力します。

(ViewModels/MainPageViewModel.cs)

public class MainPageViewModel : ViewModelBase
{
    private readonly ILogger _logger;

    public MainPageViewModel(INavigationService navigationService, ILogger<MainPageViewModel> logger)
        : base(navigationService)
    {
        // コンストラクタでDIからロガーを受け取る
        this._logger = logger;

        this.Title = "Main Page";
    }

    public override void OnNavigatingTo(INavigationParameters parameters)
    {
        this._logger.LogDebug($"NavigatingTo {nameof(MainPageViewModel)}");

        base.OnNavigatingTo(parameters);
    }
}

アプリケーション設定

NuGetパッケージ 説明
Microsoft.Extensions.Configuration.Abstractions アプリケーション設定機能のインターフェースのみを含むパッケージです。
Microsoft.Extensions.Configuration アプリケーション機能機能が実装されているパッケージです。
Microsoft.Extensions.Configuration.Json アプリケーション設定をJSONファイルから読み込む機能が実装されているパッケージです。
Microsoft.Extensions.Configuration.EnvironmentVariables アプリケーション設定を環境変数から読み込む機能が実装されているパッケージです。
Microsoft.Extensions.Configuration.Binder アプリケーション設定をオブジェクトにバインドする機能が実装されているパッケージです。

アプリケーション設定を使用する

ASP.NET Coreではアプリケーション設定をJSONファイルと( appsettings.json )して保持できるようになりました。その機能を提供しているのがこのアプリケーション設定機能です。ちなみにJSONファイルから以外にもINIファイル、環境変数コマンドライン引数など、複数のものを組み合わせて1つのアプリケーション設定として利用出来ます。

例えば次のJSONファイルに対して、値を取得してみます。

{
  "Sample": {
    "Name": "This is name",
    "Age": 100
  }
}

.NET Extensionsの設定機能を利用するには ConfigurationBuilder に対してJSONファイルや環境変数など、何から設定を読み出すかを指定します。そして生成された IConfiguration から設定値を取得したり、クラスにバインドかけたりを行います。個別の設定値を取得するには GetValue 、クラスに値をバインドするには Bind を使用します。取得またはバインドする際にキーを指定しますが、対象がネストした場所にある場合は対象までのキーをコロンで繋げた値を指定します。

// アプリケーション設定の設定
IConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
configurationBuilder.SetBasePath(Environment.CurrentDirectory);

// ベースとなるJSONファイルと環境別JSONファイルの読み込み設定
configurationBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
configurationBuilder.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false);

// アプリケーション設定の取得
IConfiguration configuration = configurationBuilder.Build();

// 個別の設定を取得
string name = configuration.GetValue<string>("Sample:Name");
// "This is name"

// クラスに値をバインド
SampleConfig config = new SampleConfig();
configuration.Bind("Sample", config);
// { Name = "This is name", Age = 100 }

Xamarin.Forms (iOS) でアプリケーション設定を使用する

Xamarin.Formsでこのアプリケーション設定を使用するには、Android/iOS各プラットフォームプロジェクトにJSONファイルとJSONファイルを読み込む処理を配置し、共通プロジェクトにアプリケーション設定をバインドするクラスを実装するのが良いでしょう。

さっそく試してみます。まずは全てのプロジェクトに Microsoft.Extensions.Configuration.Json NuGetパッケージを追加します。そしてiOSプラットフォームプロジェクトにアプリケーション設定とするJSONファイルを追加します。JSONファイルのプロパティで Copy to Output DirectoryCopy always または Copy if newer に設定します。

(appsettings.json)
{
  "Sample": {
    "EventName": "Special Raid Weekend",
    "Description": "Lugia and Ho-Oh will be joining Raid Battles"
  }
}

次にiOSプラットフォームプロジェクトにアプリケーション設定を使用してJSONファイルを読み込む処理を追加します。 AppDelegateiOSInitializer に次のコードのように変更します。

(AppDelegate.cs)

public class iOSInitializer : IPlatformInitializer
{
    public void RegisterTypes(IContainerRegistry containerRegistry)
    {
        // アプリ設定の追加
        RegisterConfiguration(containerRegistry);
    }

    private void RegisterConfiguration(IContainerRegistry containerRegistry)
    {
        IConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
        configurationBuilder.SetBasePath(Environment.CurrentDirectory);
        configurationBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
        configurationBuilder.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false);

        IConfigurationRoot configuration = configurationBuilder.Build();
        containerRegistry.RegisterInstance(typeof(IConfiguration), configuration);
    }
}

そして最後に共通プロジェクトの App.RegisterTypes メソッドで、ここで登録した IConfiguration から任意の設定クラスにバインドします。細かな話ですが、PrismのDI登録系メソッドの呼び出しは App.RegisterRequiredTypesPlatformInitizlier.RegisterTypesApp.RegisterTypes の順番で行われます。

(App.xaml.cs)

public class SampleConfig
{
    public string EventName { get; set; }
    public string Description { get; set; }
}

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    IConfiguration configuration = this.Container.Resolve<IConfiguration>();
    RegisterConfigurations(containerRegistry, configuration);
}

private void RegisterConfigurations(IContainerRegistry containerRegistry, IConfiguration configuration)
{
    var config = new SampleConfig();
    configuration.Bind("Sample", config);

    containerRegistry.RegisterInstance(typeof(SampleConfig), config);
}

余談ですがiOSで半年くらい前に試した時は、ランタイム上に未実装の機能が存在したためこの設定機能は利用できませんでした。ランタイムに該当機能が実装されてたのか、ライブラリ側の実装が変わったのかは分かりませんが、使えるようになっていて良かったです。

Xamarin.Forms (Android) でアプリケーション設定を使用する

AndroidiOSでアプリケーション設定を利用する場合と比較すると、大変に複雑です。iOSは何も気にせずプロジェクトのルートディレクトリーに配置したJSONファイルを読み込みましたが、Androidではこのようなファイルを読み込むことはできません。

一般的にAndroidアプリケーションではファイルをそのまま保持したい場合assetsを使用します。今回のJSON設定ファイルもassetsの下に配置しますが、アプリケーション設定の標準実装にassetsから読み込む機能は存在しません。そこでだいぶ適当な感は否めないのですがアプリケーション設定からassetsを使用するファイルプロバイダーを実装してみました(下のGist参照)。このファイルプロバイダーを利用してアプリケーション設定をAndroidから利用します。

まずはJSON設定ファイルを作成します。Assetsディレクトリの下に作成し、また、JSONファイルのプロパティでビルドアクションを AndroidAsset に変更してください。

(Assets\appsettings.json)
{
  "Sample": {
    "EventName": "Special Raid Weekend",
    "Description": "Lugia and Ho-Oh will be joining Raid Battles"
  }
}

次にAndroidプラットフォームプロジェクトの MainActivity を次のコードのように変更します。使用するFileProviderを指定している点とBasePathを指定していない点がiOSの時との差分です。なお Xamarin.Formsプロジェクトですので、共通プロジェクト側の処理はiOSと同じです。

(MainActivity.cs)

[Activity(Label = "XamarinNetExtensions", Icon = "@mipmap/ic_launcher", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
    protected override void OnCreate(Bundle bundle)
    {
        TabLayoutResource = Resource.Layout.Tabbar;
        ToolbarResource = Resource.Layout.Toolbar;

        base.OnCreate(bundle);

        global::Xamarin.Forms.Forms.Init(this, bundle);
        LoadApplication(new App(new AndroidInitializer(ApplicationContext))); // Context追加
    }
}

public class AndroidInitializer : IPlatformInitializer
{
    private readonly Context _context;

    public AndroidInitializer(Context context)
    {
        // ApplicationContextをコンストラクタで受け取り保持しておく
        _context = context ?? throw new ArgumentNullException(nameof(context));
    }

    public void RegisterTypes(IContainerRegistry containerRegistry)
    {
        // 色々便利なのでApplicationContextをDI登録
        containerRegistry.RegisterInstance(typeof(Context), _context);

        // アプリ設定の追加
        RegisterConfiguration(containerRegistry);
    }

    private void RegisterConfiguration(IContainerRegistry containerRegistry)
    {
        IFileProvider fileProvider = new AndroidAssetFileProvider(_context);

        IConfigurationBuilder configurationBuilder = new ConfigurationBuilder();

        // JSONファイルを読み込む際のFileProviderを指定
        configurationBuilder.AddJsonFile(fileProvider, "appsettings.json", optional: false, reloadOnChange: false);
        configurationBuilder.AddJsonFile(fileProvider, "appsettings.Development.json", optional: true, reloadOnChange: false);

        IConfigurationRoot configuration = configurationBuilder.Build();
        containerRegistry.RegisterInstance(typeof(IConfiguration), configuration);
    }
}

メモリキャッシュ

NuGetパッケージ 説明
Microsoft.Extensions.Caching.Abstractions キャッシュ機能のインターフェースのみを含むパッケージです。
Microsoft.Extensions.Caching.Memory メモリ上にキャッシュ機能を実装したパッケージです。

メモリキャッシュを使用する

.NET Extensionsのキャッシュ機能は大きく分けてオブジェクトをメモリ上に一時保管するメモリキャッシュ、データベースやKVSなどを利用して他のサーバーやストレージにデータを保管する分散キャッシュの2種類があります。ここでは前者のメモリキャッシュを紹介します。クライアントアプリで分散キャッシュは意味が無いと思いますので。。。

特に難しいこともないので、メモリキャッシュをさっそく利用してみます。 .NET Extensionsのメモリキャッシュは IMemoryCache インターフェースです。DI登録時は MemoryCacheOptions と共に登録します。

(App.xaml.cs)

protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
{
    base.RegisterRequiredTypes(containerRegistry);
    RegisterMemoryCache(containerRegistry);
}

protected void RegisterMemoryCache(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterSingleton<IMemoryCache, MemoryCache>();
    containerRegistry.RegisterInstance(typeof(IOptions<MemoryCacheOptions>), Options.Create(new MemoryCacheOptions()));
}

IMemoryCache にオブジェクトを保管または取得するには GetOrCreateAsync メソッドを使用します。このメソッドはメモリキャッシュ上にオブジェクトが存在する場合はそのオブジェクトを返します。また存在しない場合はオブジェクトを生成するFuncを実行します。メモリキャッシュにはオブジェクトのバイトサイズによるキャッシュサイズ上限の機能や時間を指定して保管期限を指定する機能がありますので、これらの制限機能を使用するのであれば、オブジェクトを生成するFuncの中で一緒に指定します。

SampleConfig c = await cache.GetOrCreateAsync("EntryKey", async entry =>
{
    // オブジェクトサイズを指定(メモリキャッシュ全体でのサイズ上限を指定しない場合は不要)
    entry.Size = 100;

    // オブジェクト保管期限の絶対指定
    entry.AbsoluteExpiration = DateTimeOffset.Now.AddHours(1);

    // オブジェクト保管期限の相対指定
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);

    // メモリキャッシュで保管するオブジェクトを指定
    var config = new SampleConfig();
    entry.Value = config;

    return config;
});

HTTPクライアント(HttpClientFactory)

NuGetパッケージ 説明
Microsoft.Extensions.Http HTTPクライアントファクトリーの機能を実装したパッケージです。

HTTPクライアント(HttpClientFactory)を使用する

.NET ExtensionsのHTTPクライアント機能は、HTTPクライアントそのものは見慣れた .NETのHTTPクライアント( System.Net.Http.HttpClient )が使用されます。このHttpClientは使い回すことを前提に設計されているためシングルトンで管理されている方が多いと思いますが、ずっと同じHttpClientインスタンスを使い続けると今度はDNSの変更が反映されないといった問題が発生します。このHttpClientFactoryを使用することで、適切なHttpClientの管理が行えます。

まずはとりあえず使っ。。。と思ったら、非常に残念なお知らせです。ソースコードを眺めてみた限りこのライブラリは必要なコンポーネントがinternalに指定されており、 .NET ExtensionsのDIコンテナー以外にHttpClientFactoryを追加するのは困難な様です。解散二回目。

DI機能に .NET Extensionsを使用するのであればとてもお勧めしたい機能なので、使い方が知りたい方は こちら をご覧下さい。

おわりに

近年のC#界隈は .NET Core、Xamarin、Unity(ゲームエンジンの方)など色々なランタイムがあり、各方面で様々なライブラリが使用されています。今回はそのうち主に .NET Core(ASP.NET Core)で標準的に利用されているライブラリをXamarinでもちょっとは使えるんだよということを紹介しました。

冒頭にも書いたとおりライブラリが .NET Standardで実装されていれば、 .NET Standardのバージョンがサポートされているランタイムで利用することができます。普段ASP.NET Coreで開発をしている身としては、全く違うランタイムでも同じライブラリを使用して開発できるのがとても嬉しいです。