Azure Functionsで設定ファイルのバリデーションをしつつDIする

# はじめに
## 動機

たとえば、Azure FunctionsでRedisを使うとき、DI(DependencyInjection)を使いたい。
DIを使うには設定を読み込んでインスタンス作ってDIサービスに登録する必要がある。
そこまではいいんだけど、設定が欠落していても起動時にわからず、実際にAPI叩くまでわからん。
すごく困る。

ということで、アプリを起動した時点である程度設定エラーが検出できて、DIする方法を見つけてきて実装してみました。



## やりたいこと

* AzureFunctionsでDependencyInjectionを使う
* DependencyInjectionで注入するオブジェクトの構築に設定情報を使う
* 設定パラメタをappsettings.jsonからPOCOへ読み込む
* 設定パラメタを保持するPOCOにアノテーションをつけてバリデーションを行う
* 設定パラメタを保持するPOCOのバリデーションは再帰的に行いたい


https://github.com/dotnet/runtime/issues/36391

ここで上がってるIssueとほぼ同じ事がやりたかった。
IssueにリンクしてるPullRequestがマージされたっぽいので、そのうち公式サポートされるのかも。


## 環境

* Azure Functions v3
* .NET Core 3.1

# 実際の手順

1. プロジェクトにNuGetパッケージを入れる
  1. [Microsoft.Azure.Functions.Extensions 1.1.0](https://www.nuget.org/packages/Microsoft.Azure.Functions.Extensions/1.1.0)
  1. [Microsoft.Extensions.Options.DataAnnotations 3.1.18](https://www.nuget.org/packages/Microsoft.Extensions.Options.DataAnnotations/3.1.18)
  1. [ReHackt.Extensions.Options.Validation 3.1.9](https://www.nuget.org/packages/ReHackt.Extensions.Options.Validation/3.1.9)
1. 設定パラメタを保持するPOCOクラス「MyConfig」を作る
  1. 必須項目にバリデーション用のアノテーションをつける
1. appsettings.jsonファイルを作る
  1. 出力フォルダにコピーされるように設定する。
1. FunctionsStartupを継承したStartupクラスを作る
  1. appsettings.jsonからパラメタを読み込むように設定する
  1. 読み込んだ設定パラメタを「MyConfig」にバインドしてバリデーションする
1. 「MyConfig」をAzureFunctionsで利用する

## プロジェクトにNuGetパッケージを入れる

| パッケージ名 | バージョン | 用途 |
|:-----------|:------------|:------------:|
| Microsoft.Azure.Functions.Extensions| [1.1.0](https://www.nuget.org/packages/Microsoft.Azure.Functions.Extensions/1.1.0)   | DependencyInjectionに必要         |
| Microsoft.Extensions.Options.DataAnnotations| [3.1.18](https://www.nuget.org/packages/Microsoft.Extensions.Options.DataAnnotations/3.1.18) | 設定パラメタPOCOのアノテーションとバリデーションに必要         |
| ReHackt.Extensions.Options.Validation| [3.1.9](https://www.nuget.org/packages/ReHackt.Extensions.Options.Validation/3.1.9)        | 設定パラメタPOCOの再帰的なバリデーションに必要         |

## 設定情報を保持するPOCOクラス「MyConfig」を作る


```C#:MyConfig.cs
using System.ComponentModel.DataAnnotations;

namespace DISample1.FunctionApp.Config
{
    public class MyConfig
    {
        [Required]
        public string Message { get; set; }
        [Required]
        public MyRedisOptions MyRedisOptions { get; set; }
    }
    public class MyRedisOptions
    {
        [Required]
        public string Host { get; set; }
        
        [Range(1024,60000)]
        public int Port { get; set; }
        
        [Required]
        public string AuthPassword { get; set; }
    }
}
```

## appsettings.jsonを作る

```json:appsettings.json
{
  "MyConfig":{
    "Message": "This is appsetting.json Message",
    "MyRedisOptions": {
      "Host":"localhost",
      "Port": "6379",
      "AuthPassword": "password"
    }
  }
}
```

### appsettings.jsonを出力フォルダへコピーされるようにする

.csprojファイルを編集して、以下のようにする。

```XML:csprojファイル
<None Update="appsettings.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>      
</None>
<None Update="appsettings.Development.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
```
[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-dotnet-dependency-injection#customizing-configuration-sources)





## FunctionsStartupを継承したStartupクラスを作る

```C#:Startup.cs
using System.IO;
using DISample1.FunctionApp;
using DISample1.FunctionApp.Config;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

[assembly: FunctionsStartup(typeof(Startup))]
namespace DISample1.FunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
        {
            FunctionsHostBuilderContext context = builder.GetContext();

            builder.ConfigurationBuilder
                .AddJsonFile(Path.Combine(context.ApplicationRootPath, "appsettings.json"), optional: true, reloadOnChange: false)
                .AddJsonFile(Path.Combine(context.ApplicationRootPath, $"appsettings.{context.EnvironmentName}.json"), optional: true, reloadOnChange: false)
                .AddEnvironmentVariables();
        }

        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddOptions<MyConfig>()
                .Bind(builder.GetContext().Configuration.GetSection("MyConfig"))
                .ValidateDataAnnotationsRecursively()
                .ValidateEagerly();
            
            // ここでバリデーションを発生させる
            var options  = builder.Services.BuildServiceProvider().GetRequiredService<IOptions<MyConfig>>();
            var aaa = options.Value;
        }
    }
}
```

### appsettings.jsonからパラメタを読み込むように設定する
これはStartup.csのConfigureAppConfigurationメソッドでやっている。
[公式ドキュメント：構成ソースのカスタマイズ](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-dotnet-dependency-injection#customizing-configuration-sources)


```C#:Startup.csのConfigureAppConfigurationメソッド
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
    FunctionsHostBuilderContext context = builder.GetContext();

    builder.ConfigurationBuilder
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, "appsettings.json"), optional: true, reloadOnChange: false)
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, $"appsettings.{context.EnvironmentName}.json"), optional: true, reloadOnChange: false)
        .AddEnvironmentVariables();
}
```

### 読み込んだ設定パラメタを「MyConfig」にバインドしてバリデーションする
これはStartup.csのConfigureメソッドでやっている。

```C#:Startup.csのConfigureメソッド
public override void Configure(IFunctionsHostBuilder builder)
{
    builder.Services.AddOptions<MyConfig>()
        .Bind(builder.GetContext().Configuration.GetSection("MyConfig"))
        .ValidateDataAnnotationsRecursively()
        .ValidateEagerly();
    
    // ここでバリデーションを発生させる
    var options  = builder.Services.BuildServiceProvider().GetRequiredService<IOptions<MyConfig>>();
    var aaa = options.Value;
}
```

## 「MyConfig」をAzureFunctionsで利用する

コンストラクタでDependencyInjectionしている。

```C#:ShowConfig.cs
using System.Threading.Tasks;
using DISample1.FunctionApp.Config;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace DISample1.FunctionApp
{
    public class ShowConfig
    {
        private readonly MyConfig _myConfig;

        public ShowConfig(IOptions<MyConfig> options)
        {
            _myConfig = options.Value;
        }

        [FunctionName("ShowConfig")]
        public async Task<IActionResult> RunAsync(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req, ILogger log)
        {
            return (ActionResult)new OkObjectResult($"Hello, {_myConfig.Message}\nRedis:{_myConfig.MyRedisOptions.Host}:{_myConfig.MyRedisOptions.Port}");

        }
    }
}
```

## バリデーションに失敗したときのコンソール出力

プロセス自体は止まらないけど、
とりあえずローカル環境で動かす分にはエラーがわかるので、これでOKとする

```console:バリデーション失敗時のコンソール出力
[2021-08-28T14:16:29.058Z] A host error has occurred during startup operation '29971fab-979d-4125-8c12-2c314b89a35a'.
[2021-08-28T14:16:29.058Z] Microsoft.Extensions.Options: DataAnnotation validation failed for members Message with the error 'The Message field is required.'.
[2021-08-28T14:16:29.058Z] DataAnnotation validation failed for members MyRedisOptions.Host with the error 'The Host field is required.'.
[2021-08-28T14:16:29.058Z] DataAnnotation validation failed for members MyRedisOptions.Port with the error 'The field Port must be between 1024 and 60000.'.
Value cannot be null. (Parameter 'provider')
Press any to continue....[2021-08-28T14:16:30.100Z] A host error has occurred during startup operation '3e379b87-8928-4662-9b52-9f6922766a2f'.
[2021-08-28T14:16:30.100Z] Microsoft.Extensions.Options: DataAnnotation validation failed for members Message with the error 'The Message field is required.'.
[2021-08-28T14:16:30.100Z] DataAnnotation validation failed for members MyRedisOptions.Host with the error 'The Host field is required.'.
[2021-08-28T14:16:30.100Z] DataAnnotation validation failed for members MyRedisOptions.Port with the error 'The field Port must be between 1024 and 60000.'.
[2021-08-28T14:16:32.129Z] A host error has occurred during startup operation '4ef938b9-5434-4373-8d92-deb93067ce42'.
[2021-08-28T14:16:32.129Z] Microsoft.Extensions.Options: DataAnnotation validation failed for members Message with the error 'The Message field is required.'.
[2021-08-28T14:16:32.129Z] DataAnnotation validation failed for members MyRedisOptions.Host with the error 'The Host field is required.'.
[2021-08-28T14:16:32.129Z] DataAnnotation validation failed for members MyRedisOptions.Port with the error 'The field Port must be between 1024 and 60000.'.
```


# 参考資料

* DependencyInjection関係
  * [公式ドキュメント:.NET Azure Functions で依存関係の挿入を使用する](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-dotnet-dependency-injection)
* appsettins.jsonを読み込む
  * [公式ドキュメント:構成ソースのカスタマイズ](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-dotnet-dependency-injection#customizing-configuration-sources)
  * [BEACHSIDE BLOG:Azure Functions V2 の Startup.cs で appsettings.json を読み込む（2019年5月バージョン）](https://blog.beachside.dev/entry/2019/05/20/173000)
  * [yotiky Tech Blog:appsettings.json を読み込む](https://yotiky.hatenablog.com/entry/azurefunctions_dependencyinjection#appsettingsjson-%E3%82%92%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80)
* アノテーションとバリデーション関係
    * [公式ドキュメント：オプションの検証](https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/configuration/options?view=aspnetcore-3.1#options-validation)
    * [しばやん雑記：Azure Functions に Options のバリデーションを追加する](https://blog.shibayan.jp/entry/20200620/1592584941)
    * [Qiita:ASP.NET Core の IStartupFilter でアプリの起動時に設定を検証する
](https://qiita.com/kaneko-h/items/3994896702d279e4041c)
    * [Andrew Lock | .NET Escapades:Adding validation to strongly typed configuration objects in ASP.NET Core](https://andrewlock.net/adding-validation-to-strongly-typed-configuration-objects-in-asp-net-core/)
    * [KeesTalksTech:Validate strongly typed options when using config sections](https://keestalkstech.com/2019/09/validate-strongly-typed-options-when-using-config-sections/)
    * [stackoverflow:Validation of ASP.NET Core options during startup](https://stackoverflow.com/questions/51692665/validation-of-asp-net-core-options-during-startup)