しばやん雑記

ASP.NET Core とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure Functions でも appsettings.json と User Secrets を使った設定とシークレットの管理を行う

前に設定周りを全て App Configuration と Key Vault に一元化する方法を紹介しましたが、ローカル環境ではデバッグの度に Access Token と値の取り直しが必要になるので、そこそこオーバーヘッドが大きいです。

ちなみにデプロイすると起動のタイミングで 1 回だけ取りに行くので、オーバーヘッドは無視できます。

ASP.NET Core ではローカル環境では appsettings.json と User Secrets を使い、設定と接続文字列などのシークレットを管理するのが一般的です。それに対して Azure Functions では local.settings.json を使うのと、フォーマットが異なっているので少し扱いが違います。

設定周りはドキュメントにまとまっているので、既に読んでいる人は多いと思います。かなり便利です。

新しい IConfiguration はネストした JSON を扱えますが、local.settings.json は特殊な実装になっていて JSON 内でネスト表記出来ないのも結構不便です。

なので Azure Functions でも ASP.NET Core と同じ形で設定とシークレットを扱えるようにしました。

まずは準備として Azure Functions のプロジェクトを作成し、必要なパッケージのインストールと csproj にいくつかのプロパティを追加します。重要なのは UserSecretsId と appsettings.json 周りの設定です。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AzureFunctionsVersion>v2</AzureFunctionsVersion>
    <UserSecretsId>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="2.2.0" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.29" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="appsettings.*.json" DependentUpon="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>

UserSecretsId は適当に GUID を生成して設定すれば良いです。appsettings.json 周りはビルド・デプロイ時に出力ディレクトリにコピーする設定です。

プロジェクトを修正後、ソリューションエクスプローラーでは以下のように表示されるはずです。

f:id:shiba-yan:20190921143232p:plain

local.settings.json は必須なので消さないように注意してください。特に Consumption / Premium Plan を使う場合には接続文字列などの Scale Controller が必要とする情報を、local.settings.json や App Service 側の App Settings に追加しないと正常に動作しなくなります。

User Secrets に必要な設定を追加すると、Visual Studio から管理できるようになるので便利です。

f:id:shiba-yan:20190921143153p:plain

設定は完了したので後はコードから利用するだけですが、ファイルパスと環境の扱いについては少し注意が必要です。特にカレントディレクトリがローカル環境と Azure 上で異なっているのがはまりポイントです。

この辺りの違いを吸収するために、以下のように簡単なクラスを用意しておきました。

public static class FunctionsEnvironment
{
    private const string Development = "Development";
    private const string Staging = "Staging";
    private const string Production = "Production";

    public static string EnvironmentName =>
        Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT");

    public static string RootDirectory =>
        Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME") != null ? Environment.ExpandEnvironmentVariables("%HOME%/site/wwwroot") : Environment.CurrentDirectory;

    public static bool IsDevelopment => IsEnvironment(Development);

    public static bool IsStaging => IsEnvironment(Staging);

    public static bool IsProduction => IsEnvironment(Production);

    public static bool IsEnvironment(string environmentName) =>
        string.Equals(EnvironmentName, environmentName, StringComparison.OrdinalIgnoreCase);
}

DI を使って IHostingEnvironment などを取得しても良いですが、IServiceProvider を作るのを避けたかったので環境変数を使って解決することにしました。

最後に FunctionsStartup の実装で JSON や User Secrets から設定を読み込んでしまえば完了です。User Secrets に関しては Development の場合だけ読み込むようにします。

public class Startup : FunctionsStartup
{
    public Startup()
    {
        var config = new ConfigurationBuilder();

        config.SetBasePath(FunctionsEnvironment.RootDirectory)
              .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
              .AddJsonFile($"appsettings.{FunctionsEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: true);

        if (FunctionsEnvironment.IsDevelopment)
        {
            config.AddUserSecrets<Startup>();
        }

        config.AddEnvironmentVariables();

        Configuration = config.Build();
    }

    public IConfiguration Configuration { get; }

    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        builder.Services.Configure<SampleOptions>(Configuration);
    }
}

Function の実装は App Configuration / Key Vault の時と同じものを使いました。ローカル環境で実行してみると、正しく appsettings.Development.json と User Secrets から値を取れていることが確認できます。

f:id:shiba-yan:20190921161002p:plain

次は実際に Azure にデプロイして確認してみます。SQL Database の接続文字列は Azure Functions の Connection Strings から直接追加してあります。

f:id:shiba-yan:20190921162002p:plain

実行してみると本番では appsettings.Development.json の値が読み込まれないので、ローカル環境とは値が異なっています。接続文字列は同じものを使っているので、データは同じものが返っています。

f:id:shiba-yan:20190921161835p:plain

個人的にはこれで Azure Functions で設定とシークレットを扱う方法に悩まなくなりました。

いつかは正式に Azure Functions でサポートされるようになると思いますが、実装の方向性は大きく変わらないはずなので移行も簡単でしょう。

ASP.NET Core / Azure Functions で App Configuration と Key Vault を使って設定を一元化する

アプリケーションが 1 つとかの場合は App Service の App Settings や Connection Strings を使って設定すれば良いのですが、数が多くなったり環境が増えてくると大体管理しきれなくなって破綻する傾向にあります。

Infrastructure as a Code の考えで ARM Template や Terraform を使って App Settings を管理するのも良いですが、設定値はインフラというよりアプリ寄りなのでちょっと違うかなという気もします。なので Azure App Configuration と Key Vault を使います。

基本的な方針は App Service 側には必要最低限かつ最初以外ほぼ変更されないものだけを残します。今回であれば App Configuration と Key Vault のエンドポイントや、App Service 自体の設定値などです。

App Service には出来るだけキーなどを設定したくないので、App Configuration と Key Vault には Managed Identity を使ってアクセスします。これで App Service はエンドポイントだけ保持するようになります。

アクセスキーや接続文字列などの重要な情報は Key Vault を使い、それ以外のアプリケーションの動作に関わる設定は App Configuration に保存します。Feature Management と組み合わせるのも便利ですね。

App Configuration は AAD 認証に対応していないので細かなアクセス制御は行えませんが、重要な情報は Key Vault に保存すれば良いので特に問題はないでしょう。

既に何回か書いている気がしますが、以下のライブラリ 2 つをインストールするだけで実現できます。

  • Microsoft.Extensions.Configuration.AzureAppConfiguration
  • Microsoft.Extensions.Configuration.AzureKeyVault

両方とも Microsoft.Azure.Services.AppAuthentication を含んでいますがバージョンが古いので、新しいバージョンをプロジェクトに追加しておくと、使ってくれるようになります。割とバグ修正が多いです。

予め App Configuration と Key Vault は作成とキーの追加を行っておきました。ドキュメントに従って Managed Identity の設定もしておきます。

f:id:shiba-yan:20190916013743p:plain

f:id:shiba-yan:20190916013732p:plain

今回は使っていないですが、App Configuration は既存の App Service から設定のインポートと、App Service へのエクスポートまで出来るようになっていたのでかなり便利です。時間のある時に App Configuration を使った設計パターンをいくつか書いておきたいです。

タイトルの通り ASP.NET Core と Azure Functions のそれぞれで使ってみます。割と普通に出来てしまうので面白みはないですが、Azure Functions はやっと自分の中で答えを見つけられた気がします。

ASP.NET Core

特に新しいことはないですが、これまで通り ConfigureAppConfiguration を使って App Configuration と Key Vault の Configuration Provider を追加します。

これまで通り ConfigurationBuilder は後勝ちなので、追加する順番は重要になってきます。

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
               .ConfigureAppConfiguration(config =>
               {
                   var builtConfig = config.Build();

                   config.AddAzureAppConfiguration(options =>
                       options.ConnectWithManagedIdentity(builtConfig["AppConfig:Endpoint"]));

                   config.AddAzureKeyVault(builtConfig["KeyVault:Endpoint"]);
               })
               .UseStartup<Startup>();
}

ドキュメントでは Key Vault 周りが KeyVaultClient を渡す方法になってますが、エンドポイントだけ渡せば勝手に Managed Identity とデフォルトの設定を使ってくれるのでシンプルです。

それぞれのエンドポイントは appsettings.Development.json や User secret に追加しておけば良いです。

{
  "AppConfig": {
    "Endpoint": "https://***.azconfig.io"
  },
  "KeyVault": {
    "Endpoint": "https://***.vault.azure.net"
  } 
}

これで App Configuration と Key Vault から値を取ってきてくれるので、使う側は特に意識する必要ありません。これまで通り IConfiguration を使って接続文字列を取ったり、オプションを組み立てるだけです。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        services.Configure<SampleOptions>(Configuration);

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

あとは適当にコントローラを用意して、値が正しく取れているのかを確認しました。

たまに IConfiguration をそのまま DI で受け取って使っているケースがありますが、メンテナンス性が下がるので IOptions<T> パターンを使った方が良いです。

public class HomeController : Controller
{
    public HomeController(IOptions<SampleOptions> options, MyDbContext myDbContext)
    {
        _options = options.Value;
        _myDbContext = myDbContext;
    }

    private readonly SampleOptions _options;
    private readonly MyDbContext _myDbContext;

    public async Task<IActionResult> Index()
    {
        ViewBag.Value = _options.TestKey;

        var users = await _myDbContext.Users.ToArrayAsync();

        return View(users);
    }
}

開発環境で実行すると App Configuration に追加した値と、Key Vault に保存した接続文字列を使って DB からデータを取得できているのが確認できます。

f:id:shiba-yan:20190916182843p:plain

Azure サービス認証のおかげでエンドポイントだけでサクッと扱えました。App Service にデプロイする際もエンドポイントと Managed Identity の設定をすると、全く同じコードで動きます。

Azure Functions

ASP.NET Core 向けに Configuration Builder が設計されているので簡単に使えるのは当たり前なのですが、地味に悩むのが Azure Functions です。正式に Configuration Builder を弄る方法が無いので、暫くは ASP.NET Core のようには設定できないでしょう。

他にも appsettings.json を公式にサポートして欲しいという意見がかなり出てますが、簡単にはいかなさそうな雰囲気です。v2 での解決は難しいかも知れませんね。

なので、Azure Functions は App Configuration と Key Vault を使って設定を管理した方が楽なケースが割とありそうです。環境ごとの設定も App Configuration で一元管理できるようになります。

現在 Azure Functions では DI が使えますが、ASP.NET Core とは異なり IConfiguration が提供されないので IServiceProvider が取れるメソッドを使うか、別で ConfigurationBuilder を使う必要があります。

出来るだけ ASP.NET Core の書き方に寄せたかったので、今回は以下のような Startup を用意しました。

public class Startup : FunctionsStartup
{
    public Startup()
    {
        var config = new ConfigurationBuilder()
                     .AddEnvironmentVariables();

        var builtConfig = config.Build();

        config.AddAzureAppConfiguration(options =>
            options.ConnectWithManagedIdentity(builtConfig["AppConfig:Endpoint"]));

        config.AddAzureKeyVault(builtConfig["KeyVault:Endpoint"]);

        Configuration = config.Build();
    }

    public IConfiguration Configuration { get; }

    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        builder.Services.Configure<SampleOptions>(Configuration);
    }
}

Azure Functions Runtime が管理している IConfiguration は使わずに ConfigurationBuilder を作成して、App Configuration と Key Vault を追加するようにしました。Startup クラス内では IConfiguration を触れるので、接続文字列やオプションなども簡単に設定できます。

Azure Functions 独自の挙動ですが local.settings.json の内容は Runtime 起動時に環境変数に設定されるので、以下のような local.settings.json を用意すると問題なくエンドポイントが参照できます。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AppConfig:Endpoint": "https://***.azconfig.io",
    "KeyVault:Endpoint": "https://***.vault.azure.net"
  }
}

Azure 上で動かす場合には App Settings に設定を追加すれば、環境変数に設定されるので同様に動作します。

使う場合は ASP.NET Core とほぼ同じです。IOptions<T> を使ってオプションを受け取れます。

public class Function1
{
    public Function1(IOptions<SampleOptions> options, MyDbContext myDbContext)
    {
        _options = options.Value;
        _myDbContext = myDbContext;
    }

    private readonly SampleOptions _options;
    private readonly MyDbContext _myDbContext;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
        ILogger log)
    {
        var result = await _myDbContext.Users.ToArrayAsync();

        return new OkObjectResult(new { Value = _options.TestKey, Users = result });
    }
}

適当に上の HttpTrigger を実行してみると、ちゃんと App Configuration と Key Vault の設定が使われていることが確認できます。DI サポートのおかげで大分楽になりました。

f:id:shiba-yan:20190916183230p:plain

Azure App Configuration と Key Vault、そして Managed Identity と Azure サービス認証を組み合わせることで設定の一元化が出来るようになりましたが、これで課題が全て解決したわけではないです。

開発中から App Configuration と Key Vault を使うと、アプリケーションの起動に毎回オーバーヘッドが乗って来ますし、設定項目を追加したい時に Azure Portal に行ったりするのは非常に手間です。

この辺りは開発時のフローを決めたり、Git で設定を管理しつつ CI/CD で App Configuration / Key Vault に反映するといった仕組み作りが必要になって来そうです。

Managed Identity と RBAC を使って Azure Storage をアクセスキー無しで扱ってみる

Azure Storage への Managed Identity と RBAC を使ったアクセスが前に GA しましたが、試してなかったので使い方を確認しました。あとは App Service と User assigned managed identity の組み合わせもちゃんと試してなかったので、折角なので同時に使ってみました。

基本的には以下のドキュメントの通りで、良くまとまっています。なのではまった部分を中心に書きます。

まずは開発環境の Visual Studio から Azure Storage に対して AAD を使った操作が行えるか確認しました。組み込まれている Azure サービス認証のおかげで AzureServiceTokenProvider を使っておけば、開発環境と Azure 上の両方で同じコードが利用できます。

Azure SDK は Preview 版も出てますが、今回は以下の NuGet パッケージをインストールしました。

  • Microsoft.Azure.Services.AppAuthentication
  • Microsoft.Azure.Storage.Blob

基本は Access Token を取得して、各ストレージの処理を呼び出すという流れです。アクセストークンを取得する時の Resource ID は https://storage.azure.com/ を使いました。Blob と Queue でさらに細分化されてますが、とりあえず Azure Storage 全体を触れるもので試します。

普段なら Connection String を Parse して CloudStorageAccount を作ってると思いますが、Access Token を使う場合は StorageCredentials を用意してから、CloudStorageAccount を作成します。コンストラクタの引数がちょっと面倒かも知れません。

var accessToken = await new AzureServiceTokenProvider().GetAccessTokenAsync("https://storage.azure.com/");

var credentials = new StorageCredentials(new TokenCredential(accessToken));
var storageAccount = new CloudStorageAccount(credentials, "demoappstorage1", null, true);

var blobClient = storageAccount.CreateCloudBlobClient();

var container = blobClient.GetContainerReference($"{DateTime.Now:yyyyMMdd}");

await container.CreateIfNotExistsAsync();

var blob = container.GetBlockBlobReference($"{DateTime.Now:HHmmss}.txt");

await blob.UploadTextAsync("kazuakix");

ドキュメントでは TokenCredential に用意した Access Token 取り直し用のメソッドを指定してますが、Managed Identity で取得できるトークンは期限が切れる 5 分前に自動で更新されるので、時間のかかる処理以外では都度 Access Token を取得して使えば良いと思います。

この辺りの設計は Azure Management Library と異なっているので統一感がないです。Preview SDK だと改善されていますが、それは後で述べます。

上のコードで AAD を使った Azure Storage の操作が行えるようになりますが、デフォルトでは Blob を操作するためのロールが付いていないので、Storage か Container の IAM からロールの割り当てを追加します。

Azure Portal から Container を選んで接続方法を AAD に変えると、権限の有無が簡単に確認できます。

f:id:shiba-yan:20190915000131p:plain

Azure Storage の AAD 対応に伴って、いくつかロールが追加されています。今回は Storage Account に対して Storage Blob Data Contributor を割り当てました。

f:id:shiba-yan:20190915000142p:plain

User assigned managed identity と自分自身にもロールを割り当てておきます。自分にも割り当てておかないと Visual Studio から確認することが出来ないので、忘れないようにします。

f:id:shiba-yan:20190915000929p:plain

暫く待つと、先ほどエラーになっていた Container の AAD でのアクセスが行えるようになります。

f:id:shiba-yan:20190915000302p:plain

これで先ほどのコードを実行すると、アクセスキー無しで Blob の作成が行えるようになりました。

f:id:shiba-yan:20190915002527p:plain

Azure Storage SDK がアクセストークンを使うように最適化されてないので多少面倒な感じですが、RBAC を使ったアクセス制限も問題なく行えるようになっています。

App Service で動かす

アクセストークンが取れれば問題なく動くことを開発環境で確認出来ているので、後は App Service にデプロイして試します。User assigned managed identity を使うので、App Service に割り当てておきます。

f:id:shiba-yan:20190915002302p:plain

これだけで最初は動くだろうと思っていましたが、実際にデプロイしてみるとアクセストークンが取れずにエラーとなりました。System assigned だと問題なかったので調べると、User assigned の場合は Client ID を指定する必要がありました。

Managed Identity はドキュメントが散らばっていてアレですが、接続文字列で Client ID を渡せます。

接続文字列は環境変数 AzureServicesAuthConnectionString を参照してくれるので、App Service の場合は App Settings にキーと値を追加すると、指定した User assigned managed identity の Access Token が取れるようになります。期待していたのと動作が異なっていました。

運用する場合には、この Client ID の管理がめんどくさくなりそうなので、基本は System assigned を使って行きたい気持ちになりました。App Service の場合はアプリ単位での権限管理になるので、System assigned だからと言って管理が煩雑になることもないでしょう。

おまけ : Azure SDK Preview について

設計がイマイチだった Access Token 周りですが、Azure SDK Preview では Azure.Identity によって AAD 認証と Managed Identity がデフォルトになっていきそうな気配があります。

現在公開されている SDK は、大体が AAD に対応したサービスです。統一感のある API が提供されているので、現在の秩序の無い SDK に比べると大幅に使い勝手が改善されそうです。

Azure CLI を使って Managed Identity に割り当て済みのロールを確認する

Managed Identity が非常に便利なのでいろいろと使ってしまうわけですが、Azure Portal 上だとリソースに対して割り当てたロールは見れるのに、特定の Managed Identity やユーザーに割り当てたロールが見れないので不便です。権限管理という点では望ましくないですね。

何かいい方法がないかと探していると、Azure CLI を使うと簡単に確認できるようだったので試しました。

az role assignment list には特定のリソースに対してのロール以外に、--assignee でのフィルタが仕えるようになってます。ここに Managed Identity の Object ID を指定すれば取れます。

System Assigned Managed Identity の場合は Portal から Object ID を拾ってこれます。

f:id:shiba-yan:20190910020625p:plain

今回は特定のリソースグループなどではなく、サブスクリプション全体で割り当て済みのロールを確認したかったので、オプションとして --all も指定しました。

後は見やすいように JMESPath でロール名とスコープだけフィルタリングしています。

# assignee には Object ID (guid) を指定する
az role assignment list --assignee 00000000-0000-0000-0000-000000000000 --all --query "[].[roleDefinitionName,scope]"

Cloud Shell から実行すると、Managed Identity に割り当てられているロールとスコープが返って来ます。

f:id:shiba-yan:20190910020639p:plain

Azure CLI を使うとロールが取れるので便利です。API レベルでは GUID しか返って来ません。

User Assigned Managed Identity の場合も System Assigned とほぼ同じです。Azure Portal から該当のリソースを開くと、Object ID を拾えるので同じように扱えます。

f:id:shiba-yan:20190910020632p:plain

適当にロールを割り当てた後に Azure CLI を叩くと、ロール情報が返って来ます。

f:id:shiba-yan:20190910021048p:plain

実際のところ IAM が反映されるより先に Azure CLI では情報が取れるようなので、ここでロール割り当てが返ってきたと言っても、アクセスが可能になっているとは限らないです。

反映されるまで 30 分ぐらいかかるケースもあるので、新規に割り当てた場合は注意しましょう。

アプリケーションから使いたい場合は、REST API を叩くか NuGet からクライアントをインストールします。

REST API を直接使う場合は Durable Functions v2 の Durable HTTP を使うと楽です。

ロールの割り当ての監査としては Activity Log を使った方が良いと思いますが、不要な権限が付いてないかのチェックを行うには便利です。Azure CLI でも az role assignment list-changelog を使うと変更ログを取れそうですが、試すとエラーで動かなかったです。

Durable Functions v2 を使った非同期 HTTP API の実装と利用

Durable Functions v2 beta 2 で Durable HTTP という機能が追加されました。クリス氏が Tweet で説明しているように、アクティビティ関数無しでよい感じに 202 Accepted のポーリングを行ってくれる便利機能です。

非同期処理の開始を 202 Accepted で通知して、完了したかどうかを Location ヘッダーで返した URL で確認するパターンを、全く意識させずに扱えます。

しかも完了するまで単純にポーリングするのではなく、Durable Functions の Timer を使っているようなので、待機している間は CPU を使用しないので効率が良いです。とても Consumption 向きです。

非同期 HTTP API の実装

202 Accepted を使った非同期 HTTP API のパターンは一般的なのか気になったので調べてみましたが、仕様として明記されてるわけではないですが使われてはいるようです。ARM API は結構使ってました。

Template Deployment の進捗を見てると、Accepted が返ってくるので分かりやすいです。

f:id:shiba-yan:20190903125642p:plain

時間がかかる処理を行う API は地味に作るのが面倒で、大抵の場合はタイムアウト長くして誤魔化しているのではないかと思いますが、Durable Functions を使えば最初から非同期な API として作られるので簡単です。

ちゃんとドキュメントにも非同期 HTTP API パターンが紹介されています。

テンプレートからオーケストレーターを作成すると CreateCheckStatusResponse でレスポンスを作っているはずなので、非同期に対応した API がこれだけで完成です。

public class Function1
{
    [FunctionName("Function1")]
    public Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        return Task.FromResult("Hello, world");
    }

    [FunctionName("Function1_HttpStart")]
    public async Task<HttpResponseMessage> HttpStart(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]
        HttpRequestMessage req,
        [DurableClient] IDurableClient starter,
        ILogger log)
    {
        // Function input comes from the request content.
        string instanceId = await starter.StartNewAsync("Function1", null);

        log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

        return starter.CreateCheckStatusResponse(req, instanceId);
    }
}

適当にリクエストを投げてみると、ちゃんと 202 Accepted と Location ヘッダーが返って来ます。

Retry-After は何秒後に再実行するかを表していますが、Durable Functions の場合は 10 秒固定です。

f:id:shiba-yan:20190903004748p:plain

Location ヘッダーで返ってきた URL にさらにリクエストを投げると、処理が完了している場合は結果が受け取れます。終わっていない場合は 202 が返ってくるので、同じことの繰り返しです。

f:id:shiba-yan:20190903014415p:plain

実行が完了していると 200 が返ってくるので、後はペイロードの値を使って良しなにするだけです。

非同期 HTTP API の利用

上の例では手動でリクエストを投げて、Durable Functions が提供する非同期 API を実行して結果を取得してみましたが、単純に実装するとループとウェイトを入れるような形になってしまいます。

実際にコンソールアプリケーションで API を実行して、結果を取得するコードを書いてみました。

class Program
{
    static async Task Main(string[] args)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:7071/api/Function1_HttpStart");

        var response = await _httpClient.SendAsync(request);

        while (response.StatusCode == System.Net.HttpStatusCode.Accepted)
        {
            await Task.Delay(response.Headers.RetryAfter.Delta.GetValueOrDefault(TimeSpan.FromSeconds(10)));

            var statusRequest = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location);

            response = await _httpClient.SendAsync(statusRequest);
        }

        Console.WriteLine(await response.Content.ReadAsStringAsync());
    }

    private static readonly HttpClient _httpClient = new HttpClient();
}

見て分かるように、202 Accepted が返ってきた場合には、何らかの方法で処理を待機させる必要があります。この場合は Task.Delay を使っていますが、効率は良いとは言えないです。

代わりに Durable Functions v2 で追加された Durable HTTP を使うと、以下のようにシンプルに書けます。Timer を使っているので効率も良いです。

[FunctionName("Function2")]
public static async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var response = await context.CallHttpAsync(HttpMethod.Get, new Uri("http://localhost:7071/api/Function1_HttpStart"));

    return response.Content;
}

制約としては Durable Functions と同じで、リクエストとレスポンスはシリアライズが可能な必要があるため、Stream 系は扱えないです。基本的には全て string して扱われます。

同じような理由で Multipart なリクエストも扱えないので、ファイルアップロードは難しいですね。実装する場合は Blob にアップロードしてから、URL を渡す形になると思います。

実行する HTTP リクエストは DurableHttpRequest を直接扱うことで、もう少し弄れます。この辺りは HttpClientHttpRequestMessage に近いです。

[FunctionName("Function2")]
public async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = new DurableHttpRequest(
        HttpMethod.Get, 
        new Uri("https://***.azurewebsites.net/api/Function1_HttpStart"), 
        content: JsonConvert.SerializeObject(new { foo = "bar" }));

    request.Headers.Add("X-Function-Key", new StringValues("<functionkey>"));

    var response = await context.CallHttpAsync(request);

    return response.Content;
}

コンストラクタでいろいろ指定しないといけないのが少し面倒な感じです。オブジェクト初期化子で書きたい気持ちが高まるので、暇な時にフィードバックしてみようかと思いました。

ちなみに透過的なリトライは行ってくれないので、その辺りだけは気を付ける必要があります。

元から Long-running なタスクを実行するのに適していた Durable Functions ですが、v2 で更に進化を遂げそうです。利用シーンは結構広いので、今後も積極的に使って行きます。

Azure Pipelines を使った Azure Web Apps / Azure Functions (Linux) へのデプロイを試した

Run From Package に関するドキュメントを眺めていたら、Linux の Premium Plan に対して Run From Package が使えそうな記述があったので試しました。

暫く Linux の App Service を触っていなかったので、知識のアップデートを兼ねてます。

まずは Linux の App Service について簡単にまとめました。ちょっと前に Consumption Plan が GA したり、Premium Plan が Preview で使えるようになったりと、インフラ側は Windows とほぼ同じになっています。

特に Premium V2 が値下げされたので、Windows の Premium V2 よりかなり安く使えます。*1

  • Web Apps (Linux)
    • App Service Plan (F1 / B1-B3 / S1-S3 / P1v2-P3v2)
  • Azure Functions (Linux)
    • Consumtpion Plan
    • App Service Plan (F1 / B1-B3 / S1-S3 / P1v2-P3v2 / EP1-EP3)

この中で EP1-EP3 が Premium Plan を表しています。少し混乱しそうですが、Premium Plan は App Service Plan のインスタンスサイズの一つです。

作成した Web Apps / Azure Functions へのデプロイ方法は以下の 3 種類が選べます。

  • Code
    • git push でビルド & デプロイ
    • Zip を作成してデプロイ
  • Docker
    • Docker Image を用意してデプロイ

今回は Docker を使わない方法を扱うので、Zip を使ったデプロイを行います。ちなみに Consumption と Premium Plan*2 では Docker Image を使った Azure Function のデプロイはサポートされていません。

Azure Pipelines には Web Apps / Azure Functions のデプロイタスクが用意されているので簡単です。

Web App の場合

Linux Web App へのデプロイは Windows Web App とほぼ同じなので難しくないです。デプロイタスクで appTypewebAppLinux に設定すれば、よい感じに ZipDeploy を実行してくれます。

trigger:
- master

variables:
  BuildConfiguration: 'Release'

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: false
    projects: '**/*.csproj'
    arguments: '-c $(BuildConfiguration) -o $(Build.SourcesDirectory)/publish'
  displayName: 'Build Application'

- task: AzureWebApp@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    appType: 'webAppLinux'
    appName: 'linuxwebapp-1'
    package: '$(Build.SourcesDirectory)/publish/**/WebApplication1.zip'
  displayName: 'Deploy to Web App (Linux)'

こんな感じで YAML を書けば終わりなのですが、Premium Plan で Run From Package が使えると書いてあったので、Web Apps でも使える気がしたので設定を追加して試しました。

Configuration からサクッと WEBSITE_RUN_FROM_PACKAGE = 1 を追加します。

f:id:shiba-yan:20190901152221p:plain

設定後に Azure Pipelines から Zip Deploy を実行すると、パッと見は問題なく Run From Package として動作していました。

SSH でコンテナにアクセスして、wwwroot 以下にファイルを作ろうとするとエラーになります。

f:id:shiba-yan:20190901152232p:plain

ちゃんと Zip が Read-only としてマウントされていて動作はしましたが、Web Apps に関しては正式にサポートされている方法ではないので、何かしら問題はありそうです。

Azure Functions (Consumption Plan / App Service Plan) の場合

Azure Functions でも Web Apps と同様にデプロイタスクを使えば、自動的に Zip Deploy が実行されるので簡単なのですが、Consumption Plan のみ Run From Package としてデプロイされるようになっています。

古い情報ですが、Linux の Consumption Plan では Run From Package のみ使えると書いてあります。ただし URL を指定して、外部からパッケージを取得する形の Run From Package です。

ローカルの Zip を使う Run From Package を試しては見ましたが、ランタイムが起動しなくなったので GA した現在も非対応のようです。大人しくデプロイタスクに従いましょう。

App Service Plan の場合はこれまで通り wwwroot 以下に Zip が展開されます。

trigger:
- master

variables:
  BuildConfiguration: 'Release'

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: false
    projects: '**/*.csproj'
    arguments: '-c $(BuildConfiguration) -o $(Build.SourcesDirectory)/publish'
  displayName: 'Build Application'

- task: AzureFunctionApp@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    appType: 'functionAppLinux'
    appName: 'linuxconsumption-1'
    package: '$(Build.SourcesDirectory)/publish/**/FunctionApp1.zip'
  displayName: 'Deploy to Consumption Plan (Linux)'

Consumption Plan の場合はデプロイタスクを使うと、URL を使った Run From Package としてデプロイされていることが確認できます。

SAS を使っていますが、有効期限は 2029 年とかでしたので 10 年間は有効です。

f:id:shiba-yan:20190901153217p:plain

10 年間もほったらかしにすることはないと思いますが、間違って Blob を消さないよう気を付けましょう。

デプロイが頻繁に行われる場合はあっという間にファイルが増えるので、Lifecycle Management を使って古い Blob を消す処理などが必要になりそうです。

Azure Functions (Premium Plan) の場合

Premium Plan では作成時に Run From Package を使ったデプロイが必要だと警告が出ます。Premium Plan は App Service Plan と Consumption Plan を足したような動きをするので理解できます。

f:id:shiba-yan:20190901012321p:plain

Consumption Plan と同じようにデプロイタスクを使えば、勝手に Run From Package としてデプロイしてくれるかと思っていましたが、タスクが Premium Plan に対応していませんでした。

デプロイ方法を変更できないため、仕方なく Azure Functions Core Tools を使ってデプロイします。

Azure Pipelines の Hosted Agent には Azure Functions Core Tools がインストールされていないので、ドキュメントにあるコマンドの通りにインストールしておきます。

デプロイには認証済みの Azure CLI が必要なので、実行は AzureCLI タスクで行います。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: CmdLine@2
  inputs:
    script: |
      wget -q https://packages.microsoft.com/config/ubuntu/16.04/packages-microsoft-prod.deb
      sudo dpkg -i packages-microsoft-prod.deb
      
      sudo apt-get update
      sudo apt-get install azure-functions-core-tools
  displayName: 'Install Azure Functions Core Tools'

- task: AzureCLI@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    scriptLocation: 'inlineScript'
    inlineScript: |
      cd FunctionApp1/FunctionApp1
      func azure functionapp publish linuxpremiumapp2 --csharp
  displayName: 'Deploy to Premium Plan (Linux)'

地味にはまった点として func azure functionapp publish を実行した時に、実行に使うランタイムを判別できないと言われてエラーになったことです。

ローカルだと local.settings.json にランタイム名が含まれているので問題ないですが、自動生成された .gitignore で除外されているのでエラーとなってしまいました。流石にこのファイルを含めたくないので、コマンド実行時に --csharp オプションを付けて対応しました。

これで Premium Plan でも Run From Package を使ったデプロイが行えるようになりました。GA までにはデプロイタスクでも対応されるとは思いますが、それまでは準備が面倒ですが Core Tools を使ってデプロイする必要があります。

*1:元々 S1 ではメモリ少なすぎて辛かったので、その辺りを考慮した可能性あり

*2:Create Azure Functions on Linux using a custom image | Microsoft Docs

ARM Template を使ってリソースへのアクセス権限を付ける

Template Deployment を使って Azure リソースをデプロイする際に、同時に RBAC 周りの設定を行いたいケースがあったので、世界のぶちぞう RD に聞いたら教えてくれました。

結局は後述する制約によって上手くいかなかったのですが、折角なのでメモとして残します。

ドキュメントはイマイチな感じですが、RBAC は Microsoft.Authorization Provider で管理されているので、そこに対してロール割り当てのリソースを新しく作る形になります。

Azure Portal からはデプロイ先のリソースグループと含まれるリソースに対してのみ操作が行えます。サブスクリプションスコープで RBAC を弄る必要がある場合は Azure CLI を使ってデプロイする必要があります。

あまり意識してなかったですが、コマンドは明確に分離されています。

  • az deployment
    • サブスクリプションスコープに対してデプロイ
  • az group deployment
    • リソースグループに対してデプロイ

お分かりの通り Azure Portal の Template Deployment はリソースグループに対してのデプロイなので、リソースグループとその中のリソース以外を対象に出来ません。これが非常に都合が悪かったです。

従って ARM Template で同時にデプロイするリソースに対して割り当てる場合は上手くいきます。

ロール定義の GUID を探す

リソースを作成するためには定義ロールの GUID が必要です。ロール定義もリソースとして ARM 上に用意されているので、Azure CLI を使えば簡単に引っ張ってこれます。

必要なのは GUID と名前ぐらいなので、項目を絞り込むようにすると見やすいです。

az role definition list --query "[].[roleName,name]" --output table

実行すると定義済みのロールが全て表示されます。組み込みのロールが持つ GUID はサブスクリプションなど関係なくグローバルで共通の値です。

f:id:shiba-yan:20190820192421p:plain

ドキュメントにもありましたが、よく使うロールの GUID は以下のような感じです。

  • Owner = 8e3af657-a8ff-443c-a75c-2fe8c4bcb635
  • Contributor = b24988ac-6180-42a0-ab88-20f7382dd24c
  • Reader = acdd72a7-3385-48ef-bd42-f606fba81ae7

ロールの GUID が分かれば、後はロール割り当てのリソースを作成するだけですが、地味に癖があります。とりあえずリソースグループと各リソースで書き方が違うので、それぞれ分けて紹介します。

リソースグループにロール割り当てを追加

サブスクリプションとリソースグループに対してロール割り当てを追加する場合は、シンプルに typeMicrosoft.Authorization/roleAssignments を指定すれば良いです。

割り当てを追加する最小限の ARM Template は以下のようになります。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "principalId": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[parameters('principalId')]"
      }
    }
  ]
}

この例では入力された PrincipalId に対して Reader ロールを割り当てています。実際にテンプレートでは PrincipalId は Managed Identity の System Assigned で作成したものを使うことが多いはずです。

今回は既に作成済みの PrincipalId を使ってデプロイします。謎の roleNameGuid ですが、ロール割り当てのリソース作成時に絶対必要なものです。

f:id:shiba-yan:20190827180433p:plain

ARM Template の仕様で newGuid 関数はパラメータの defaultValue でしか使えないので、こういう不格好な形になっています。冪等性のためには仕方ない部分ですが、不格好です。

デプロイが完了するとリソースグループに対して、ロール割り当てが追加されたのが確認できます。

f:id:shiba-yan:20190827180458p:plain

App Service の Managed Identity で作成された PrincipalId を使ったので、このような表示となります。

各リソースにロール割り当てを追加

リソースグループ内の各リソースに割り当てる場合もほぼ同じですが、typename の値が割り当てたいリソース毎に異なってくるので複雑です。

以下の例のように type にはリソースプロバイダを指定しつつ、name には対象となるリソース名を含める形する必要があります。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "principalId": {
      "type": "string"
    },
    "webAppName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Web/sites/providers/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[concat(parameters('webAppName'), '/Microsoft.Authorization/', parameters('roleNameGuid'))]",
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[parameters('principalId')]"
      }
    }
  ]
}

微妙に手間な書き方ですが、このテンプレートをデプロイすると App Service に対してロール割り当てを追加できます。実行結果はリソースグループの時と同じなので省略します。

Managed Identity (System Assigned) にアクセス権限を付ける

もはやアプリ向けに手動で Service Principal を作る時代は終わっているので、Managed Identity を空気のように利用していきます。

今回は App Service に対するテンプレートを書きます。Managed Identity を自動生成する場合は identity プロパティを追加するだけなので簡単です。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "functionAppName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Web/sites",
      "name": "[parameters('functionAppName')]",
      "apiVersion": "2018-11-01",
      "location": "[resourceGroup().location]",
      "kind": "functionapp",
      "identity": {
        "type": "SystemAssigned"
      },
      "properties": {
        /* 省略 */
      }
    },
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "dependsOn": [
        "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]"
      ],
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions','acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2018-11-01', 'Full').identity.principalId]"
      }
    }
  ]
}

作成した App Service に紐づいた PrincipalIdreference 関数を使えば参照出来るので、後はその値を使ってロール割り当てを追加するだけです。

reference 関数では Full を指定しないと値が取れなかった気がします。とても簡単でした。

User Assigned Managed Identity にアクセス権限を付ける(問題あり)

つい先日 App Service 向けの User Assigned Managed Identity が GA したので、今後は利用することが増えていく気がしています。

System Assigned は 1 つの App Service に必ず 1 つ生成されますが、User Assigned Managed Identity は個別に作成して、自由に App Service へ割り当て出来るので管理しやすいです。

User Assigned Managed Identity も単なるリソースの一つなので、ARM Template を使って生成できます。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "identityName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.ManagedIdentity/userAssignedIdentities",
      "apiVersion": "2018-11-30",
      "name": "[parameters('identityName')]",
      "location": "[resourceGroup().location]"
    },
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "dependsOn": [
        "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName'))]"
      ],
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[reference(parameters('identityName')).principalId]"
      }
    }
  ]
}

なので組み合わせると、ロール周りの設定も同時に出来て非常に便利!と思っていたのですが、どうも Managed Identity の作成完了が AAD への反映より早いらしくロール割り当てを追加時にエラーとなります。

数秒遅延させると上手くいくという残念な状態なので、App Service を作る前に User Assigned Managed Identity を作成しておいて、最後にロール割り当てを追加すると上手くいきます。*1

*1:直してほしいのでフィードバックする予定

ASP.NET Core と Azure Functions でのローカライズについて一通り試した

Twitter で ASP.NET Core / Azure Functions のローカライズに関する話になった時に、そういえば試してなかったなと思ったので一通り触って理解を深めておきました。

考えられる部分、全て触ってみたので非常に長くなりました。概要を先にまとめます。

  • 規約ベースで使用するリソースが解決される
    • ビューはファイルパスを、コントローラなどの型は名前空間を利用する
    • ResourcesPath で設定したパスをベースとして使う
  • リソースにはデフォルトで resx が使われる
    • 拡張可能なので JSON や DB などをソースとしても使える
  • リソースキーとしてデフォルトのカルチャ向けテキストを使うと導入が楽
    • 日本語で書くのは少し抵抗がある…
  • ReSharper を使うとリソースキーの存在チェックをしてくれるので便利

規約ベースでリソースが解決されるので、コントローラやビュー単位でリソースを用意することで保守性が上がります。共通して使うリソースはダミークラスを用意して解決します。

後から度々出てくる Culture と UICulture については、先に正しく理解しておかないとはまります。

  • Culture (例 : CultureInfo.CurrentCulture)
    • 日付、数値のフォーマットなどに使われるカルチャ
  • UICulture (例 : CultureInfo.CurrentUICulture)
    • インターフェースの表示言語に使われるカルチャ

今回は UI 部分だけの対応ですが、日付や数値などのフォーマットも考慮する必要があります。

Web の世界だとあまり見ないかも知れないですが、Azure Portal は別々に設定できます。

f:id:shiba-yan:20190824195624p:plain

Azure Portal の設定で言語とフォーマットが別々で指定出来るように、.NET の世界でも Culture と UICulture は区別されています。

ReSharper を使っていると、日付や数値を文字列に変換する時に InvariantCulture を使えと言われるのは、この辺りが理由になっています。単純に ToString を呼ぶとカルチャによって結果が変わる可能性があります。

実際に今回は UI 周りのローカライズを試していくわけですが、その時にメインとなるのが .NET Core から用意された Microsoft.Extensions.Localization です。

ASP.NET Core に依存しないライブラリなので、DI が使えればコンソールアプリケーションでも使えます。

ASP.NET Core でのローカライズ

基本はドキュメントが充実しているので、まず読んでおくと良いです。色々と書いてありますが、文字列リソースを DI で切り替えて使う仕組みが用意されているという話です。

少し古い情報になりますが、こちらの記事が実際のローカライズ実装にとても参考になります。

ASP.NET Core のローカライズを構成する要素は以下の 4 つです。

  • 使用するカルチャを決定する RequestLocalizationMiddleware / RequestCultureProvider
  • カルチャと名前空間に従って言語リソースを読み込む IStringLocalizer<T>
  • ビューのローカライズを行う IViewLocalizer / IHtmlLocalizer<T>
  • DataAnnotations のローカライズを行う DataAnnotationsLocalizationServices

一般的にはリクエスト単位でカルチャを選択する必要があるので、Core MVC では Middleware を使って使用するカルチャを決定する仕組みが用意されています。

デフォルトの設定では以下の順番でカルチャが決定されますが、カスタマイズは可能です。

  1. Query String (culture / ui-culture)
  2. Cookie (.AspNetCore.Culturec / uic)
  3. Accept-Language

テンプレート的な使い方が出来る Startup クラスを載せておきます。対応するカルチャは en / ja です。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // AddMvc より先に追加する
        services.AddLocalization(options => options.ResourcesPath = "Resources");

        services.AddMvc()
                .AddViewLocalization()
                .AddDataAnnotationsLocalization()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

        services.Configure<RequestLocalizationOptions>(options =>
        {
            var supportedCultures = new[]
            {
                "en",
                "ja"
            };

            options.DefaultRequestCulture = new RequestCulture("en");

            options.AddSupportedCultures(supportedCultures);
            options.AddSupportedUICultures(supportedCultures);
        });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // UseMvc より先に呼び出す
        app.UseRequestLocalization();

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

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

対応するカルチャには ja-jpen-us を設定したくなりますが、Chrome や Edge などのブラウザは Accept-Language にニュートラル言語な ja を含めてくるので、ja-jp を設定すると動きません。

ハイフンが増える度に詳細なカルチャを表していますが、Provider はデフォルトでカルチャのフォールバックが有効となっているので、ja を設定しておけば ja-jp が来ても日本語で表示されます。

DI で各種 Localizer を取得するパターン

Core MVC では以下の 3 つのパターンを使ってローカライズが行えます。規約ベースで解決したリソースファイルから、該当するキーの文字列を引っ張ってくる機能。という感じです。

  • IStringLocalizer / IStringLocalizer<T>
    • ローカライズ結果を LocalizedString として返す
    • タグが含まれている場合は HTML エンコードされる
  • IHtmlLocalizer / IHtmlLocalizer<T>
    • ローカライズ結果を LocalizedHtmlString として返す
    • キー名は HTML エンコードせず、パラメータのみエンコードするのでタグが使える
  • IViewLocalizer
    • 基本は IHtmlLocalizer と同じで、リソース解決時の規約だけが異なる
    • コンパイル後の Razor は _Views_Home_Index のようなクラス名になるので型ベースだと詰む

ビュー以外は名前空間とクラス名ベースでリソースが解決されるので、コントローラなどの役割に依存せずにシンプルにローカライズされた文字列を引っ張ってくることが出来ます。

とりあえず Controller / Tag Helper / View Components / View それぞれで試しました。プロジェクトの構造は以下のようにしています。

f:id:shiba-yan:20190826181043p:plain

それぞれの実装は非常にシンプルに文字列を引っ張ってくるだけです。フォーマットや HTML タグを使ってはいますが、エンコードされるかどうかの違いしかありません。

public class HomeController : Controller
{
    public HomeController(IStringLocalizer<HomeController> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<HomeController> _localizer;

    public IActionResult Index()
    {
        ViewBag.Message = _localizer["Hello, {0}", "かずあき"];

        return View();
    }
}
public class LocalizedTagHelper : TagHelper
{
    public LocalizedTagHelper(IHtmlLocalizer<LocalizedTagHelper> localizer)
    {
        _localizer = localizer;
    }

    private readonly IHtmlLocalizer<LocalizedTagHelper> _localizer;

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var content = await output.GetChildContentAsync();

        output.Content.SetHtmlContent(_localizer["<b>Hello, {0}</b>", content.GetContent()]);
    }
}
public class LocalizedViewComponent : ViewComponent
{
    public LocalizedViewComponent(IStringLocalizer<LocalizedViewComponent> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<LocalizedViewComponent> _localizer;

    public IViewComponentResult Invoke(string name, int age)
    {
        ViewBag.Message = _localizer["{0} is {1} years old.", name, age];

        return View();
    }
}

ビューでは作成した Tag Helper や View Components を呼び出しているのでごちゃっとしています。

日付を出しているのは、カルチャが切り替わったことを分かりやすくするためです。

@{
  ViewData["Title"] = "Home Page";
}

@inject IViewLocalizer Localizer

DateTime.Now = @DateTime.Now.ToString()

<hr />

IStringLocalizer = @ViewBag.Message

<hr />

IViewLocalizer = @Localizer["<b>Mr. {0}</b>", "かずあき"]

<hr />

Tag Helper = <localized>かずあき</localized>

<hr />

View Component = @await Component.InvokeAsync("Localized", new { name = "かずあき", age = 50 })

作成したクラス、ビューに対応するリソースは以下のように準備しました。

今回はカルチャ無しのリソースは用意しませんでしたが、見つからない場合はリソースキーがそのまま使われるので、導入が楽という訳です。デフォルトは en にしているのでリソースキーは英語で書いています。

f:id:shiba-yan:20190824183105p:plain

アプリケーションを実行して、クエリ文字列でカルチャを切り替えてアクセスすると、言語が切り替わっていることが確認できます。もちろん culture=enui-culture=ja というように別々に指定すると、日付のフォーマットと表示言語がそれぞれ別になります。

f:id:shiba-yan:20190824233020p:plain

クエリ文字列を指定しない場合には、ブラウザの言語設定が使われるので日本語で表示されています。

リソースキーをデフォルトのカルチャとして実装するか、それともリソースファイルを用意するかは悩ましいところです。リソースを作成するのが結局のところ一番大変です。

DataAnnotations のエラーメッセージをローカライズ

ASP.NET MVC では DataAnnotations のメッセージは ErrorMessageResourceTypeErrorMessageResourceName を指定してローカライズしていましたが、Core MVC では ErrorMessage をリソースキーとして自動で扱ってくれるようになりました。

なので以下のようなクラスを書いておくと、自動でローカライズ対象になります。

public class FormRequest
{
    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Name))]
    public string Name { get; set; }

    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Age))]
    public int? Age { get; set; }
}

この場合も規約ベースでリソースが解決されるので、以下のような構造にしておきます。

f:id:shiba-yan:20190824231931p:plain

適当にフォームを用意してエラーメッセージを表示されると、ちゃんとローカライズされています。

f:id:shiba-yan:20190824231737p:plain

.NET Core では言語パックが提供されなくなったので、常に英語メッセージになっていますが、元々デフォルトのメッセージは使ったことが無かったので特に困らないかなと思っています。

ビューを個別に用意するパターン

最後に少し特別なパターンとして、ビューをカルチャ単位で分ける方法も提供されています。特に _Layout.cshtml などはリソースファイルに起こすのが大変なので、ビュー毎分けるのも一つの手です。

分ける方法としてはディレクトリとサフィックスが選べます。割と分かりやすい方法です。

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization(options => options.ResourcesPath = "Resources");

    // デフォルトだと Suffix が使われる
    services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.SubFolder)
            //.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

ビューは以下のような形で置いておくと、自動でカルチャに合わせて使われます。

f:id:shiba-yan:20190824203326p:plain

全てのカルチャに対してビューを用意するのは正直凄く面倒なので、この辺りは上手く使い分けが必要です。

ASP.NET Core に関しては大体このくらい理解しておけば、問題なくアプリケーションのローカライズが行えると思います。MVC 5 に比べるとかなり洗練されたなという感想を持ちました。

Azure Functions でのローカライズ

Azure Functions は ASP.NET Core ベースなので同じ方法が使えるはずですが、ランタイムとアプリケーションが分離されている関係上、サテライトアセンブリが読めない問題があるようです。

サテライトアセンブリを正しく読めないなら、自分で予め読み込んでおけば良いのではと思ったので試すと、すんなりと上手くいきました。

Startup を作成して、その中で指定したカルチャのサテライトアセンブリを読み込みます。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

        LoadSatelliteAssembly("ja", "es");
    }

    private void LoadSatelliteAssembly(params string[] uiCultures)
    {
        foreach (var supportedCulture in uiCultures)
        {
            var assemblyDirectory = Path.Combine(Environment.CurrentDirectory, "bin", supportedCulture);

            foreach (var file in Directory.EnumerateFiles(assemblyDirectory, "*.resources.dll"))
            {
                Assembly.LoadFrom(file);
            }
        }
    }
}

後は ASP.NET Core の時と同じように DI を使って IStringLocalizer<T> を受け取って使えますが、カルチャを決定するための Middleware は組み込めないため、自力で決定する必要があります。

今回は適当にクエリ文字列から CultureInfo.CurrentUICulture を設定するようにしました。

public class Function1
{
    public Function1(IStringLocalizer<Function1> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<Function1> _localizer;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        // query string からカルチャを拾って UI 向けに設定
        string culture = req.Query["culture"];

        CultureInfo.CurrentUICulture = new CultureInfo(culture ?? "en");

        return name != null
            ? (ActionResult)new OkObjectResult((string)_localizer["Hello, {0}", name])
            : new BadRequestObjectResult("Please pass a name on the query string or in the request body");
    }
}

コンストラクタで IHttpContextAccessor を取ることも出来るので、良い感じに自動でカルチャを決定する仕組みは Function でも実現できるとは思います。

リソースも ASP.NET Core と同じように規約ベースで配置しておけば良いです。

f:id:shiba-yan:20190824184837p:plain

Function を実行して、適当にカルチャをクエリ文字列で渡すとリソースが切り替わることが確認できます。

f:id:shiba-yan:20190824185140p:plain

HttpTrigger の場合は簡単ですが、それ以外の Queue などでは別の情報を使ってカルチャを決定する必要があります。ユーザー毎に言語設定を持っておいて、それを Function からは読み取るといった形ですね。

結局のところはリソースを個別に用意するのが大変なので、割と悩ましいです。しかし便利になっています。

Task ベースの初期化が必要なクラスを .NET Core の DI で利用する

.NET Core / ASP.NET Core で追加された Dependency Injection は Task ベースの Factory は用意されておらず、ドキュメントではサービスの解決は同期的に行うよう推奨されています。

とはいえ、Task ベースでの初期化が必要な場面がちょいちょいあるので、何とかする必要があります。

async/await and Task based service resolution is not supported. C# does not support asynchronous constructors; therefore, the recommended pattern is to use asynchronous methods after synchronously resolving the service.

Dependency injection in ASP.NET Core | Microsoft Docs

例えば Managed Identity のように Access Token を取得する際にネットワークアクセスが必要な場合や、他にも SqlConnection など準備が必要なクラスを扱う場合に、同期処理にするのは避けたいです。

Azure Functions v2 で書いていたアプリを DI に移行した際に、いくつかパターンを利用しました。

今回は初期化を遅らせて、必要になるタイミング(= await が使えるメソッド)で実行する方法と、Factory を DI に追加して必要な時に Factory 経由でインスタンスを作成する方法の 2 つを使いました。

.NET Core 向けにクライアントライブラリを設計する場合に、今後は DI で使うことを考えていく必要がありそうです。IoC というより、生存期間の管理としての使い方がかなり便利です。

Managed Identity + Azure SDK (AutoRest) の例

既に何回も使っている Managed Identity と Azure SDK の組み合わせでは、SDK 側が ITokenProvider を受け取るようになっているので、そこで認証に関する処理を Task ベースで書けます。

サンプルだと Access Token を取得してから各 Management クライアントを作成するケースが多いですが、以下のように ITokenProvider を実装したクラスを用意しておくと非常に便利です。

internal class AppAuthenticationTokenProvider : ITokenProvider
{
    private readonly AzureServiceTokenProvider _tokenProvider = new AzureServiceTokenProvider();

    public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
    {
        // Managed Identity を使って実行時に Access Token を取得
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

        return new AuthenticationHeaderValue("Bearer", accessToken);
    }
}

上の例で使っている ITokenProvider は AutoRest 向けに用意されているものなので、Azure SDK 以外のシチュエーションでも同様に使えるようになっています。

実際に Azure Function の DI で使う場合には、以下のように Startup クラスで DI に追加します。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // Key Vault 向けに用意されている Callback を指定する
        builder.Services.AddSingleton(provider => new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(new AzureServiceTokenProvider().KeyVaultTokenCallback)));

        // 作成した TokenProvider を渡して初期化する
        builder.Services.AddSingleton(provider => new DnsManagementClient(new TokenCredentials(new AppAuthenticationTokenProvider()))
        {
            SubscriptionId = "<subscription id>"
        });
    }
}

Key Vault は特別扱いされているので、特にクラスを用意する必要無く使えるので便利です。

使う側はこれまで通りで、コンストラクタインジェクションでインスタンスを受け取るだけです。

public class Function1
{
    public Function1(DnsManagementClient dnsManagementClient)
    {
        _dnsManagementClient = dnsManagementClient;
    }

    private readonly DnsManagementClient _dnsManagementClient;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // REST API の呼び出し直前に TokenProvider が実行される (async / await が使える)
        var zones = await _dnsManagementClient.Zones.ListAsync();

        return new OkResult();
    }
}

必要になるまで Access Token の取得は行われないのと、Access Token は AzureServiceTokenProvider が適切にキャッシュしてくれるのでシンプルに利用できます。

同じような処理を HttpClient で実現する場合は DelegatingHandler を使うと手っ取り早いです。

public class DemoHttpHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        await ProcessRequestAsync(request, cancellationToken);
        
        return await base.SendAsync(request, cancellationToken);
    }

    private readonly AzureServiceTokenProvider _tokenProvider = new AzureServiceTokenProvider();

    private async Task ProcessRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    }
}

この例では Authorization ヘッダーを弄るだけですが、SendAsync では HttpRequest を自由に触れるのでリクエストのカスタマイズが簡単です。

実装した Handler を HttpClientFactory と組み合わせて使っても良いですね。

Factory を作成して DI に追加する例

Azure SDK (AutoRest) ではコンストラクタで初期化用のインターフェースが用意されていたので簡単に対応出来ましたが、用意されていない場合は Factory を作成するのが簡単です。

SqlConnection の場合は利用前に OpenAsync を実行して接続を開いておく必要がありますが、そういったケースにも Factory で対応出来ます。

public interface IAsyncDemoFactory
{
    Task<DemoClient> CreateClientAsync();
}

// 呼び出しの度に新しいインスタンスが必要な場合
public class AsyncDemoFactory : IAsyncDemoFactory
{
    public async Task<DemoClient> CreateClientAsync()
    {
        var demoClient = new DemoClient();

        // 初期化を async / await で行う必要があるクラス
        await demoClient.InitializeAsync();

        return demoClient;
    }
}

// 全体でインスタンスは 1 つだけ必要な場合
public class SingletonAsyncDemoFactory : IAsyncDemoFactory
{
    // AsyncLazy<T> (https://devblogs.microsoft.com/pfxteam/asynclazyt/)
    private readonly AsyncLazy<DemoClient> _initializer = new AsyncLazy<DemoClient>(async () =>
    {
        var demoClient = new DemoClient();

        await demoClient.InitializeAsync();

        return demoClient;
    });

    public async Task<DemoClient> CreateClientAsync()
    {
        return await _initializer;
    }
}

Factory は実際にインスタンスを作成するメソッドを提供していて、内部で初期化するメソッドを呼び出します。Factory 自体は DI で解決されるので、IOptions<T> や他のインスタンスを参照出来ます。

まだ CoreFX で提供されていないですが、ここで AsyncLazy<T> を使えば Singleton にも出来ます。

Factory は Singleton として DI に追加すれば良いので、特に難しいことはありません。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // DI に Factory を追加
        builder.Services.AddSingleton<IAsyncDemoFactory, AsyncDemoFactory>();
    }
}

使う側ではコンストラクタで Factory を受け取り、実際に使う直前に CreateClientAsync を呼び出して新しくインスタンスを作成、もしくは既に作成されたものを取得します。

この辺りは HttpClienFactory と同じ考え方なので、既に使っている場合は理解しやすいはずです。

public class Function1
{
    public Function1(IAsyncDemoFactory asyncDemoFactory)
    {
        _asyncDemoFactory = asyncDemoFactory;
    }

    private readonly IAsyncDemoFactory _asyncDemoFactory;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // await を使ってクライアントを作成する
        var demoClient = await _asyncDemoFactory.CreateClientAsync();

        var message = await demoClient.HelloAsync();

        return new OkObjectResult(message);
    }
}

インスタンスの作成を利用直前まで遅延させることで、例外が投げられた時のハンドリングが簡単になるというメリットもあります。DI に用意された Factory を使った場合は、コンストラクタへのインジェクション時に例外が投げられるので、どうしようもなくなります。

Factory を用意せずに Lazy<T> を DI に入れる方法もありますが、保守性が下がるので止めた方が良いです。

.NET Core / ASP.NET Core の DI が async / await に対応することは直近ではまず無いはずなので、クライアントの設計と Factory で対応していくことになるかと思います。

Azure Functions から送信される Application Insights テレメトリにバージョンを付ける

勝手に付いてるだろうと思っていましたが、Azure Functions の場合は Application Insights の各テレメトリにバージョンが付いていなかったので、ITelemetryInitializer を使ってカスタマイズすることにしました。

ASP.NET Core の場合はデフォルトで付いているので特に気にする必要はないです。Azure Pipelines と組み合わせて自動でバージョンを付ける場合は以下のエントリを参照してください。

バージョンをテレメトリに付けることで、自動的に Application Insights がグルーピングを行ってくれたり、Smart Detection が賢くなったりとメリットが多いです。

実際にログから Groupted results を開くと、アプリケーションのバージョンでグルーピングされています。

f:id:shiba-yan:20190820004911p:plain

バージョン管理を行うのはモニタリング面でも非常に重要なので、ちゃんと送っておきたいところです。

ASP.NET Core では簡単に設定できましたが、Azure Functions の場合はカスタム ITelemetryInitializer を使って解決しました。

ITelemetryInitializer を実装する

Azure Functions SDK には Application Insights SDK は含まれていないので、別途インストールする必要があります。バージョンは最新を使っておけば問題なさそうです。

最新のランタイムに含まれているバージョンを調べましたが v2.10 でした。

既に Application Insights のテレメトリに用意されているプロパティへバージョンをセットするだけなので、非常に簡単なコードになりました。

public class ApplicationVersionInitializer : ITelemetryInitializer
{
    public string ApplicationVersion { get; set; }

    public void Initialize(ITelemetry telemetry)
    {
        telemetry.Context.Component.Version = ApplicationVersion;
    }
}

分かりにくいですが、アプリケーションのバージョンは Component.Version の値が使われます。

実装した Telemetry Initializer を DI に追加

Telemetry Initializer を実装すれば、後は DI に追加すれば Application Insights のパイプラインで使われます。

DI を使うためには、もはやお馴染みとなったパッケージをインストールすれば良いです。

DI には Singleton として追加します。インターフェースを指定しないと動かないので注意。

アセンブリからバージョンを取得する処理を Telemetry Initializer に入れても良かったのですが、型名からアセンブリを特定した方が分かりやすいのでプロパティで初期化するようにしました。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddSingleton<ITelemetryInitializer>(new ApplicationVersionInitializer
        {
            ApplicationVersion = typeof(Startup).Assembly
                                                .GetCustomAttribute<AssemblyInformationalVersionAttribute>()
                                                ?.InformationalVersion
        });
    }
}

特に説明することのない単純なコードでした。これでアセンブリのバージョンが Application Insights のテレメトリと一緒に送信されるようになります。

適当な Function App にデプロイして、Application Insights 側でテレメトリを確認しておきます。

f:id:shiba-yan:20190820002043p:plain

全てのテレメトリに Application Version が確認できますね。バージョン番号自体は分かりやすいように付けたので、特に意味のない値です。本来なら CI 側でバージョンを付けます。

Kusto を使ってアプリケーションのバージョン毎での集計も簡単に行えます。

f:id:shiba-yan:20190820002054p:plain

エラーレートやパフォーマンスに関係するメトリクスをアプリケーションのバージョン毎に集計して、グラフとして表示などすればトレンドが簡単に読み取れるので便利です。

特にパフォーマンス改善では、そのバージョンで本当に効果があったのかを検証できないと全く意味がないです。Azure Pipelines の Deployment job と組み合わせることで、デプロイされたコミットを一目で確認できるので更に便利になります。