xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

.NET 5 未満でもモジュール初期化子を利用する

.NET 5 じゃなくても C# 9.0 をできる限り使いたい!そんなあなたのために「.NET 5 未満でも」シリーズ第 2 弾。第 1 弾はこちら

今回はタイトルにある通りモジュール初期化子についてですが、それ自体の用途や詳細挙動は岩永さんのサイトに譲ります。

.NET 5 未満でモジュール初期化子を有効化

モジュール初期化子は ModuleInitializerAttribute を以下の条件を満たすメソッドに付与することで利用できます。

  • 引数なし
  • 戻り値なし
  • static メソッド
  • public / internal のどちらかのアクセシビリティ
  • 非 Generics

逆に言うと ModuleInitializerAttribute をプロジェクト内で用意してあげればコンパイラは解釈できることになります。ということで、準備しましょう。プロジェクト内に閉じるだけであれば internal 型でも問題ありません。

#if !NET5_0_OR_GREATER
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Method, Inherited = false)]
    internal sealed class ModuleInitializerAttribute : Attribute
    { }
}
#endif

あとは C# 9.0 を明示的に有効化すれば OK です。第 1 弾で紹介した init の有効化の方法と全く同じですね。積極的に (?) コンパイラをハックしていきましょう!

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <!-- これで .NET 5 以外のターゲットに対しても init が有効になる -->
        <TargetFrameworks>netstandard2.0;netstandard2.1;net461;net5</TargetFrameworks>
        <LangVersion>9.0</LangVersion>
    </PropertyGroup>
</Project>

.NET 5 未満でも C# 9.0 の init アクセサを利用する

init アクセサは大変良いです。C# 9.0 で追加された初期化のタイミングでのみプロパティに値を設定できる set アクセサです。アクセシビリティは狭ければ狭いほどコードは安全になるので、僕は set を見たらとりあえず init に置き換える勢い!

.NET 5 未満で init を利用する

init アクセサは内部的にはただの set アクセサです。set アクセサに対して IsExternalInit という型と共に modreq という謎の (?) 修飾を行うことでコンパイラが init な挙動と解釈してくれます。逆に言うと IsExternalInit という型があり、それを解釈できるコンパイラがいれば利用できるとも言えます。

ということで .NET 5 以外のプロジェクトに対して IsExternalInit 型を準備します。.NET 5 で用意されている型は public ですが、プロジェクト内だけに閉じる場合は internal な型で大丈夫です。

#if !NET5_0_OR_GREATER
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Runtime.CompilerServices
{
    internal sealed class IsExternalInit
    { }
}
#endif

あとは C# 9.0 を明示的に有効化すれば OK です。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <!-- これで .NET 5 以外のターゲットに対しても init が有効になる -->
        <TargetFrameworks>netstandard2.0;netstandard2.1;net461;net5</TargetFrameworks>
        <LangVersion>9.0</LangVersion>
    </PropertyGroup>
</Project>

readonly struct の制約緩和

個人的にこれの何が嬉しいかと言うと、C# 7.2 から導入された readonly struct の条件緩和があります。init でコンパイラがアクセシビリティをより細かく制御できるようになったおかげで、以下のように書けるようになりました。

// C# 8.0 までは「getter only + コンストラクタ」しか認められなかった
public readonly struct Person
{
    public string Name { get; }
    public Person(string name)
        => this.Name = name;
}
// C# 9.0 からは init でも大丈夫
public readonly struct Person
{
    public string Name { get; init; }
}

C# 9.0 が利用できるコンパイラさえあれば (= 最新の Visual Studio を利用してさえいれば)、Target Framework が .NET 5 でなくても init を利用できるのは大きなメリットです。

_FunctionsSkipCleanOutput を利用しないで Azure Functions プロジェクトのビルド時 DLL 自動削除から DLL を保護する

タイトルが長過ぎてなんのこっちゃワカランと思います。僕も良いタイトルが浮かびません...(

今回は下記の Issue の内容と公式回答についてザックリ解説します。僕の理解が正確かは分からないので、Issue も読んでもらえると助かりますw

Issue の内容

  • System.Interactive.Async v4.1.1 を NuGet 参照してる Azure Functions プロジェクトがある
    • この NuGet パッケージは C# 8.0 以降で言語サポートされた IAsyncEnumerable<T> を利用するときに高頻度で利用する
  • Azure Functions v3 にデプロイしたら「dll がないぜ!」って例外飛んで動かない!
  • どうやら RemoveRuntimeDependencies のせいで System.Interactive.Async.dll が削除されちゃってるっぽい
  • Microsoft.NET.Sdk.Functions を更新したのが問題ぽい
    • v3.0.3 までは動いてた

背景

Azure Functions は起動高速化などを目的として Functions Host が利用している dll と重複している dll をビルド時に削除するようになっています。これは .csproj ビルド時に RemoveRuntimeDependencies タスクが動くことで実現されていて、既定で有効です。削除対象となる dll は runtimeassemblies.json にリストされているのですが、ここに System.Interactive.Async.dll が入っています。Microsoft.NET.Sdk.Functions v3.0.4 以降で System.Interactive.Async.dll が追加されたようで、「Functions SDK を更新 (v3.0.4+) したら突然動かなくなった!」という感じです。実際僕も業務で同じ問題にブチ当たってしばらくハマりました。

ここで問題だったのが RemoveRuntimeDependencies で削除する際に dll のバージョンを見てくれていなかったことです *1。Azure Functions v3 Host は System.Interactive.Async.dll v3.x.x を参照しているのですが、プロジェクト側で NuGet 参照して利用している v4.x.x を問答無用で削除してしまうことで問題が発生していました。C# 8.0 以降を利用するときに大変相性が悪い事案でツラぽよでした。

これまでの回避策

アセンブリ自動削除が行われなければ問題が起こらないので、RemoveRuntimeDependencies タスクを Opt-out します。_FunctionsSkipCleanOutput = true を .csproj に記述すれば OK です。

<PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>

    <!-- ↓↓ コレを追加 ↓↓ -->
    <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
</PropertyGroup>

という workaround を知っていたのでコメントしておきました。それが 2020 年 7 月の話です。

新しい回避策

それから半年以上経過した 2021 年 3 月。つい最近です。オフィシャルな回答として以下が提示されました。簡単に言うと除外する dll を除外リストに入れられるようになったという感じです。

  1. Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator 1.2.1 以降を参照
  2. <FunctionsPreservedDependencies Include="xxx.dll" /> で除外リストに追加
<ItemGroup>
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.11" />

    <!-- ↓↓ コレを追加 ↓↓ -->
    <PackageReference Include="Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator" Version="1.2.1" />
    <FunctionsPreservedDependencies Include="System.Interactive.Async.dll" />
</ItemGroup>

削除されると困る dll を適宜羅列することで、RemoveRuntimeDependencies による出力アセンブリの最適化の恩恵も得られる形になりました。大変ハッピー!

*1:今はバージョンも見て削除しているような気がしなくないですが、まだ未確認なのであまり信じないでください

Basic 認証情報を含む URL を HttpClient で利用する

Basic 認証のあるページにブラウザでアクセスしたことがある方はご存じかと思いますが、以下のような認証情報を入力するダイアログが表示されます。毎度面倒ですよね。

f:id:xin9le:20210308230408p:plain

実は、ブラウザなど一部の Web Client は https://UserName:Password@example.com のような認証情報を URL に書く記法をサポートしています。こうすると認証ダイアログが表示されないので、大変便利です。

Uri クラスで parse する

C# / .NET でも上記のような URL を使いたいなーと考えるわけですが、URL から認証情報 UserName:Password を抜き出すのが大変手間そうです。...しかし!実は Uri クラス が中でも最も面倒な URL の parse 処理をやってくれるので全然難しくありません!

var uri = new Uri("https://UserName:Password@example.com");
Console.WriteLine(uri.UserInfo);  // UserName:Password

これは捗る...!

HttpClient で利用する

こうなってくると「是非 HttpClient でも!」と思うわけですが、当然の如く (?) サポートされていません。残念ながら自前実装するしかないです。と言っても認証情報の取り出しが簡単にできることが分かっているので、あとは Base64 エンコードをしてあげるだけです。例えば以下のようになります。

var uri = new Uri("https://UserName:Password@example.com");
var client = new HttpClient();

// 認証情報の指定がない場合は string.Empty になる
if (!string.IsNullOrWhiteSpace(uri.UserInfo))
{
    // 認証情報が指定されていたらヘッダーを設定
    var bytes = Encoding.UTF8.GetBytes(uri.UserInfo);
    var base64 = Convert.ToBase64String(bytes);
    client.DefaultRequestHeaders.Authorization = new("Basic", base64);
}

var response = await client.GetAsync(uri);  // Basic 認証を潜り抜ける

HttpClientHandler を利用すればリクエスト毎に Authorization ヘッダーを指定したりすることも難しくないでしょう。

まとめ

URL に Basic 認証を含められるようになると、アプリケーション設定の管理も少しばかり簡単になるかもしれませんね。実際僕は実務で役に立ちました :)

C# / .NET 向けの Paidy SDK を公開しました

あの Amazon でも利用できる (特に) 若者に人気の Paidy 決済。その API の C# / .NET 向けラッパーを作成し、公開しました!普段 EC サービスの開発 / 運用を業務で行っているのですが、とあるお客様で Paidy 導入が必要になったので作りました。

本当はすべての API を網羅する必要性なんて全くない *1 ので完全なオーバーワークですが、今後 Paidy を C# / .NET 環境で導入する方が少しでも楽できればいいなと思っています。

https://download.paidy.com/3Banner_Paidy_1200x628_1x_.png

サポートしている機能

Paidy 自体の導入方法は公式ドキュメントをちゃんと読む必要がありますが、決済処理に必要な API はすべて揃っています。

サポート環境

最新環境である .NET 5 はもちろん、.NET Framework まで幅広くサポートします。

  • .NET Framework 4.6.1+
  • .NET Standard 2.0+
  • .NET 5.0+

簡単な使い方紹介

非常にシンプルで薄いラッパーになっているので、公式ドキュメントを見れば迷子になることなく使える作りになっていると思います。

API

API 関連の処理は Microsoft.Extensions.DependencyInjection 経由で提供されます。.NET Core 標準 DI なので「いつもの感じ」でサクッと使えます。

  1. .AddPaidy();
  2. DI から PaymentService / TokenService を取得する
using Microsoft.Extensions.DependencyInjection;
using Paidy;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        var options = new PaidyOptions
        (
            apiEndpoint: "https://api.paidy.com",
            secretKey: "sk_...",
            apiVersion: null
        );
        services.AddPaidy(options);  // これだけで準備完了!
    }
}
using Paidy.Payments;
using Paidy.Tokens;

public class Sample
{
    private PaymentService PaymentService { get; }
    private TokenService TokenService { get; }

    // DI から取得して
    public Sample(PaymentService paymentService, TokenService tokenService)
    {
        this.PaymentService = paymentService;
        this.TokenService = tokenService;
    }

    // API を呼び出す
    public async ValueTask GetPaymentAsync()
    {
        var payment = await this.PaymentService.CaptureAsync("Paidy Payment ID");
        var token = await this.TokenService.RetrieveAsync("Paidy Token ID");
    }
}

Webhook

Webhook で受信した JSON ペイロードを parse する機能も備えます。JSON からインスタンスを作るだけ!

var payload = "{ Paidy payment webhook payload }"; 
var request = PaymentRequest.From(payload);
var payload = "{ Paidy token webhook payload }"; 
var request = TokenRequest.From(payload);

*1:業務使うのはほんの一部