Archive - Programming

C#でgoogle/zx風にシェルスクリプトを書く

あまりシェルスクリプトを書かない私なのですが(小物でもなんでも書き捨てC#で書くスタイル)、CI だの .NET Core だのなんなので、全く書かないというわけにもいかない昨今です。まぁしかしcmdは嫌だし今更(?)PowerShellもなぁという感じもあり、bashねぇ、とかブツブツ言いながらしょっぱいスクリプトを書く羽目になるわけです。

そこに颯爽と現れたのが google/zx。素敵そうだなーと思いつつJavaScriptを日常的に書くわけでもないのでスルーしてたのですが、こないだもちょっと複雑なシェルスクリプトをJavaScriptで書くで紹介されていて、なるほど色物じゃなくて便利なのか、そうだよね便利だよね!と思い、私は日常的にC#を書くので、C#だったら便利だな、同じ感じで書けるなら、と、思い至ったのでした。

というかまぁzx見て思ったのが、これぐらいの内部DSL、C#でもいけるよ、ということであり……。そして以下のようなものが誕生しました。

image

もともとProcessX - C#でProcessを C# 8.0非同期ストリームで簡単に扱うライブラリというものを公開していたので、更にそれをDSL風味に、zxっぽくシンタックスを弄りました。C# 5.0 async/awaitの拡張性、C# 6.0 using static、C# 6.0 String Interpolation、そしてC# 9.0のTop level statementsと、C#も内部DSLを容易にする構文がどんどん足されています。現在previewのC# 10.0でも、Improvement Interpolated Stringsとして、InterpolatedStringHandlerによって$”"の生成時の挙動そのものを生で弄ることが可能になり、よりますます表現のハックが可能になり、色々と期待が持てます。

さて、で、これが使いやすいかというと、見た通りで、使いやすい、です……!stringをawaitしていることに一瞬違和感はめちゃくちゃあるでしょうが、DSLだと思って慣れれば全然自然です(そうか?)。なんか言われてもgoogle/zxなもんです、で逃げれば説得力マシマシになった(そうか?)のが最高ですね。cmd/PowerShell/bashに対する利点は、google/zxの利点と同じように

  • 型が効いてる(C#なので)
  • async/awaitが便利(C#なので)
  • フォーマッタもある(C#なので)
  • エディタ支援が最高(C#なので)

ということで、ぜひぜひお試しください。

csx vs new csproj vs ConsoleAppFramework

C#には.csxという失われしC#スクリプティングな構文が用意されていて、まさに1ファイルでC#の実行が完結するのでこうしたシェルスクリプト風味に最適、と思いきや、実行もエディッティング環境も貧弱で、まさに失われしテクノロジーになっているので、見なかったことにしておきましょう。実際、より良いC#スクリプティング的なシンプルC#の提案が Add Simple C# Programs として出ています(つまりcsxは完全に産廃、NO FUTURE……)。提案(proposed/simple-csharp-pgorams.md)読むと面白いですが、ちょっと少し時間かかりそうですね。

というわけで、csprojとProgram.csの2ファイル構成が良いんじゃないかと思います。ちょっと冗長ではあるけれど、しょーがないね。実行に関しては dotnet run でビルドと実行がその場でできるので、ビルドなしの直接スクリプト実行みたいな雰囲気にはできます。これは普通に便利で、CIとかでもgit pullしている状態のリポジトリ内のスクリプトに対して一行でdotnet run書くだけで動かせるので、非常に良い。こうした .NET Core以降のシンプルになったcsprojとdotnetコマンドの充実から、csxの価値がどんどん消えていったんですねえ。

さて、実際のプロジェクトなどでは、そもそもシェルスクリプト(に限らずバッチなんかも)は一つどころか大量にあったりすることもあるでしょう。そこでCysharpの提供しているCysharp/ConsoleAppFrameworkを使うと、クラスを定義するだけで簡単に実行対象を増やしていけるので、大量のスクリプトの管理を1csprojでまかなうことが可能になります。実行は dotnet run — foo/bar のようにすればいいだけです。非常におすすめ。シェルスクリプト的なものは、ConsoleAppFramework + ProcessX/zx で書いて回るのは、悪くない選択になると思います。

2021年のC# Roslyn Analyzerの開発手法、或いはUnityでの利用法

C#のAnalyzer、.NET 5時代の現在では標準でも幾つか入ってきたり、dotnet/roslyn-analyzersとして準標準なものも整備されてきたり(非同期関連だと他にmicrosoft/vs-threadingのAnalyzerも便利)、Unity 2020.2からはUnityもAnalyzer対応したり、MicrosoftもUnity向けのmicrosoft/Microsoft.Unity.Analyzersという便利Analyzerが登場してきたりと、特に意識せずとも自然に使い始めている感じになってきました。

Analyzerって何?というと、まぁlintです。lintなのですが、Roslyn(C#で書かれたC# Compiler)から抽象構文木を取り出せるので、それによってユーザーが自由にルールを作って、警告にしたりエラーにしたりできる、というのがミソです。更に高度な機能として、CodeFix(コードを任意に修正)もついているのですが、それはそれとして。

このサイトでも幾つか書いてきましたが、初出の2014年-2015年辺りに固まってますね。もう6年前!

実用的という点では、MessagePack for C#に同梱しているMessagePackAnalyzerは今も現役でしっかり便利に使える代物になっています。

と、いうわけで使う分にはいい感じになってきた、のですが、作る側はそうでもありません。初出の2015年辺りからテンプレートは変わってなくて、NuGetからすんなり入れれる時代になっても、VSIXがついてくるようなヘヴィなテンプレート。このクロスプラットフォームの時代に.NET Frameworkべったり、Visual Studioベッタリって……。Analyzerと似たようなシステムを使うSource Generator(UnitGenerator - C# 9.0 SourceGeneratorによるValueObjectパターンの自動実装とSourceGenerator実装Tips )は、まぁまぁ今風のそこそこ作りやすい環境になってきたのに、Analyzerは取り残されている雰囲気があります。

AnalyzerはCodeFixまで作ると非常に面倒なのですが、Analyzer単体でも非常に有益なんですよね。そしてプロジェクト固有の柔軟なエラー処理というのは、あって然りであり、もっとカジュアルに作れるべきなのです。が、もはや私でも腰が重くなってしまうぐらいに、2021年に作りたくないVisual Studio 2019のAnalyzerテンプレート……。

どうしたものかなー、と思っていたのですが、非常に良い記事を見つけました、2つ!

前者の記事ではVS2019 16.10 preview2で ソースジェネレーターのデバッガーサポートが追加された、 <IsRoslynComponent>true</IsRoslynComponent> とすればいい。という話。なるほどめっちゃ便利そう、でもソースジェネレーターばっか便利になってくのはいいんですがAnalyzer置いてきぼりですかぁ?と思ったんですが、 IsRoslynComponent だし、なんか挙動的にも別にAnalyzerで動いても良さそうな雰囲気を醸し出してる。と、いうわけで試してみたら無事動いた!最高!VS2019 16.10はまだpreviewですが(現時点では16.9が安定版の最新)、これはもうこれだけでpreview入れる価値ありますよ(あと少し待てば普通に正式版になると思うので待っても別にいいですが)

後者の記事は .NET 5 時代のすっきりしたAnalyzerのcsprojの書き方を解説されています。つまり、この2つを合体させればシンプルにAnalyzerを開発できますね……?

というわけでやっていきましょう。中身は本当に上記2つの記事そのものなので、そちらのほうも参照してください。

SuperSimpleAnalyzerをシンプル構成で作る

まずは Visual Studio 2019 16.10 をインストールします。16.10はついこないだ正式版になったばかりなので、バージョンを確認して16.10未満の場合はアップデートしておきましょう。

Analyzerはnetstarndard2.0、Analyzerを参照するテスト用のConsoleAppプロジェクトをnet5.0で作成します。最終的には以下のようなソリューション構造にします。

image

さて、ではSuperSimpleAnalyzerのほうのcsprojをコピペ的に以下のものにしましょう。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>library</OutputType>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>latest</LangVersion>
        <Nullable>enable</Nullable>
        <IsRoslynComponent>true</IsRoslynComponent>
        <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <IncludeSymbols>false</IncludeSymbols>
        <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
        <DevelopmentDependency>true</DevelopmentDependency>
    </PropertyGroup>
 
    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" />
    </ItemGroup>
 
    <Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
        <ItemGroup>
            <TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
            <TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
        </ItemGroup>
    </Target>
</Project>

基本的に【C#】アナライザー・ソースジェネレーター開発のポイントから丸コピペさせてもらっちゃっているので、それぞれの詳しい説明は参照先記事に譲ります……!幾つか重要な点を出すと、Microsoft.CodeAnalysis.CSharpのバージョンは新しすぎると詰みます。現在の最新は3.9.0ですが、3.9.0だと、今の正式版VS2019(16.9)だと動かない(動かなかったです、私の環境では、どうなんですかね?)ので、ちょっと古めの3.8.0にしておきます。

もう一つは、件の <IsRoslynComponent>true</IsRoslynComponent> の追加です。

では、次にConsoleApp.csprojのほうに行きましょう。

<Project Sdk="Microsoft.NET.Sdk">
 
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>
 
    <ItemGroup>
        <ProjectReference Include="..\AnalyzerDemo\SuperSimpleAnalyzer.csproj">
            <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
            <OutputItemType>Analyzer</OutputItemType>
        </ProjectReference>
    </ItemGroup>
 
</Project>

こちらは別に特段変わったことなく、Analyzerのcsprojを参照するだけです。その際に <OutputItemType>Analyzer</OutputItemType>を欠かさずに。

では再び SuperSimpleAnalyzer に戻って、プロパティ→デバッグから、「起動」をRoslyn Componentに変更すると以下のような形にできます。

image

(この時、Target Projectが真っ白で何も選択できなかったら、ConsoleAppのほうでAnalyzer参照をしてるか確認の後、とりあえずVisual Studioを再起動しましょう)

これで、SuperSimpleAnalyzerをF5するとAnalyzerがConsoleAppで動いてる状態でデバッガがアタッチされます!

のですが、最後にじゃあそのAnalyzerの実体をコピペできるように置いておきます。

#pragma warning disable RS2008
 
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Linq;
 
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SuperSimpleAnalyzer : DiagnosticAnalyzer
{
    // どうせローカライズなんてしないのでString直書きしてやりましょう
    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        id: "SuperSimpleAnalyzer",
        title: "SuperSimpleAnalyzer",
        messageFormat: "MyMessageFormat",
        category: "Naming",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Nanika suru.");
 
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
 
    public override void Initialize(AnalysisContext context)
    {
        // お約束。
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
 
        // 解析起動させたい部分を選ぶ。あとRegisterなんとかかんとかの種類は他にもいっぱいある。
        context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }
 
    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
        // ここを適当に書き換える(これはサンプル通りの全部Lowerじゃないクラス名があった場合に警告を出す)
        var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
 
        if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
        {
            // Diagnosticを作ってReportDiagnosticに詰める。
            var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);
            context.ReportDiagnostic(diagnostic);
        }
    }
}

Resourcesとか別に使う必要ないと思うので、ハイパーベタ書きの.csファイル一個に収めてあります。これでF5をすると……

image

もちろんConsoleAppのほうでは、実際に動いて警告出している様が確認できます。

image

昔のVSIXの時は、別のVisual Studioを起動させていたりしたので重たくて面倒くさかったのですが、今回の IsRoslynComponent では、普通のデバッグの感覚で実行できるので、めちゃくちゃ楽です。最高に書きやすい、これが2021年……!

ユニットテストもする

ユニットテストのいいところは、テストをデバッグ実行すればコードの中身をダイレクトにステップ実行できるところにもあります。ある程度、上のように実コードでデバッグ実行して雰囲気を作れた後は、ユニットテスト上で再現コードを作っていくと、より捗るでしょう。

基本的にはxUnitのテンプレートでプロジェクトを作って、 Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit を参照に追加するだけ。ではあるのですが、net5でシンプルに作ったら連なってる依存関係のせいなのか .NET Frameworkのものの参照が入って警告されたりで鬱陶しいことになったので、とりあえず以下のが警告の出ないパターン(?)で作ったものになります。netcoreapp3.1で。

<Project Sdk="Microsoft.NET.Sdk">
 
    <PropertyGroup>
        <TargetFramework>netcoreapp3.1</TargetFramework>
        <IsPackable>false</IsPackable>
    </PropertyGroup>
 
    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" />
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" />
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.0" />
 
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
        <PackageReference Include="xunit" Version="2.4.1" />
        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
    </ItemGroup>
</Project>

このプロジェクトに作ったAnalyzerの参照を足して、以下のようなテストコードを書きます。

        [Fact]
        public async Task SimpleTest2()
        {
            var testCode = @"
class Program
{
    static void Main()
    {
    }
}";
 
            await Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<SuperSimpleAnalyzer>
                .VerifyAnalyzerAsync(testCode, new DiagnosticResult("SuperSimpleAnalyzer", DiagnosticSeverity.Warning).WithSpan(0, 0, 0, 0));
        }

やることはVerifyAnalyzerAsyncに、それによって発生するエラー部分をDianogsticResultで指定する、という感じです。

シンプルなケースはそれでいいのですが、テストコードにNuGetで外部ライブラリ参照があったり、プロジェクト参照があったりすると、これだけだとテストできません。そこで、そうしたケースが必要な場合は CSharpAnalyzerTest に追加の参照関係を指定してあげる必要があります( XUnit.AnalyzerVerifier は CSharpAnalyzerTest をxUnitのシンプルなケースに特化してラップしただけのものです)。

例えばMessagePipeでは以下のようなユーティリティを用意してテストしました。

static async Task VerifyAsync(string testCode, int startLine, int startColumn, int endLine, int endColumn)
{
 
    await new CSharpAnalyzerTest<MessagePipeAnalyzer, XUnitVerifier>
    {
        ReferenceAssemblies = ReferenceAssemblies.Default.WithPackages(ImmutableArray.Create(new PackageIdentity("MessagePipe", "1.4.0"))),
        ExpectedDiagnostics = { new DiagnosticResult("MPA001", DiagnosticSeverity.Error).WithSpan(startLine, startColumn, endLine, endColumn) },
        TestCode = testCode
    }.RunAsync();
}
 
static async Task VerifyNoErrorAsync(string testCode)
{
 
    await new CSharpAnalyzerTest<MessagePipeAnalyzer, XUnitVerifier>
    {
        ReferenceAssemblies = ReferenceAssemblies.Default.WithPackages(ImmutableArray.Create(new PackageIdentity("MessagePipe", "1.4.0"))),
        ExpectedDiagnostics = { },
        TestCode = testCode
    }.RunAsync();
}

これで

        [Fact]
        public async Task SimpleTest()
        {
            var testCode = @"using MessagePipe;
 
class C
{
    public void M(ISubscriber<int> subscriber)
    {
        subscriber.Subscribe(x => { });
    }
}";
 
            await VerifyAsync(testCode, 7, 9, 7, 39);
        }
 
        [Fact]
        public async Task NoErrorReport()
        {
            var testCode = @"using MessagePipe;
 
class C
{
    public void M(ISubscriber<int> subscriber)
    {
        var d = subscriber.Subscribe(x => { });
    }
}";
 
            await VerifyNoErrorAsync(testCode);
        }

のようにテストが書けました。

まとめ

というわけでAnalyzer書いていきましょう。今現在は結局Visual Studioだけかよ!みたいな気もしなくもないですが、そのうちVS CodeとかRiderでも出来るようになるんじゃないでしょうか、どうだろうね、そのへんはわかりません。

ところでUnity 2020.2からAnalyzerが使えると言いましたが、そのサポート状況はなんだかヘンテコで、ぶっちゃけあんま使えないんじゃ疑惑があります。特に問題は、Unity Editor側では有効になっているけどIDE側で有効にならない場合が割とあります。これはUnityの生成したcsprojに、カスタムで追加したAnalyzerの参照が適切に入ってなかったりするせいなのですが、それだと使いづらいですよね、というかAnalyzerってコード書いてる最中にリアルタイムに警告あるのがイケてるポイントなので。

そこでCysharpでCsprojModifierというUnity用の拡張をオープンソースで公開しました。ついさっき。6時間ぐらい前に。

これがあるとUnityでも正しくAnalyzerの参照の入ったcsprojを使える他に、例えばBannedApiAnalyzersという、任意のクラスやメソッド、プロパティの呼び出しを禁止するという、かなり使えるAnalyzerがあるんですが(例えばUnityだとGameObject.Find絶対禁止マンとかが作れます)、これはどのメソッドの呼び出しを禁止するかをBannedSymbols.txtというファイルに書く必要があり、Unityのcsproj生成まんまだとこのBannedSymbols.txtへの参照が作れないんですね。で、CsprojModifierなら、参照を入れたcsprojが作れるので、問題なくUnityでBannedApiAnalyzersが使えるようになるというわけです。

というわけで改めて、Analyzer、書いていきましょう……!

実際こないだリリースしたMessagePipe用に、Subscribe放置を絶対に許さない(エラー化する)Analyzerを公開しました。

こういうの、必要だし、そしてちゃんと導入するととても強力なんですよね。せっかくのC#の強力な機能なので、やっていきましょう。

C#のasync/await再考, タイムアウト処理のベストプラクティス, UniTask v2.2.0

お題を3つ並べましたが、記事は逆順で書いていきます!というわけで、UniTask v2.2.0を出しました。改めてUniTask v2とはUnityのためのゼロアロケーションasync/awaitと非同期LINQを実現するライブラリで、とv2リリース時の解説記事を貼っつけましたが、ちょいちょい細かい改善を続けてまして、今回v2.2.0になります。

PlayerLoopへのループ挿入のカスタマイズ対応

現状のUnityはPlayerLoop上で動いていて、Unity 2020.1のリストをここに置いておきましたが、デフォルトでは120個ぐらいのループがエンジンから駆動されています。UpdateループだけでもScriptRunBehaviourUpdate, ScriptRunDelayedDynamicFrameRate, ScriptRunDelayedTasks, DirectorUpdateと色々あります。UniTaskも基本的にはPlayerLoop上で動かしているのですが、自由に任意の実行箇所を選べるように、28個のループを挿入しています。これにより UniTask.Yield(PlayerLoopTiming.PreLateUpdate) などといったような指定を可能にしているわけですが、28個ってちょっと多いんじゃないか?という。デフォで120個あるうちのプラス28個、多いっちゃあ多いけど、ループの中身も空っぽに近いし、空UpdateのMonoBehaviourを10000個並べるみたいなのとは比較にならないほど小さい話だから許容範囲内ぢゃん、と思ってはいるんですが、例えばAndroidでDeep Profilingなんかすると、ちょとプロファイラのデータに出てきちゃったりなんかは指摘されたことがあります(Deep Profilingの影響があるので、実際のビルドではそうでもないんですが)。

何れにせよ、99.99%はUpdateしか使わねえよ、みたいなのはあると思います。というわけで、UniTaskのPlayerLoopの挿入量を任意に調整できるようにしました。

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
public static void InitUniTaskLoop()
{
    var loop = PlayerLoop.GetCurrentPlayerLoop();
    PlayerLoopHelper.Initialize(ref loop, InjectPlayerLoopTimings.Minimum);
}

これで、Update | FixedUpdate | LastPostLateUpdate の3つしか挿入されなくなります。InjectPlayerLoopTimingsは任意のLoopTimingの選択、例えば InjectPlayerLoopTimings.Update | InjectPlayerLoopTimings.FixedUpdate | InjectPlayerLoopTimings.PreLateUpdate のような指定と、3つのプリセット、 All(デフォルトです), Standard(Lastを抜いたもの、挿入量が半分になる(ただし一番最後のLastPostLateUpdateは挿入する))、Minimum(Update, FixedUpate, LastPostLateUpdate)が選べます。正直なところ9割の人はMinimumで十分だと思ってますが、まぁ状況に応じて任意に足したり引いたりしてもらえればいいんじゃないかと。

ところで、そうすると、挿入していないループタイミングを指定するとどうなるんですか?というと、実行時例外です。えー、それじゃー困るよーと思うので、そこで使えるのがMicrosoft.CodeAnalysis.BannedApiAnalyzersというやつで、(Unity 2020.2からAnalyzerが何のハックもなくそのまま使えるようになったのでAnalyzerは普通に使えますよ!)、例えばInjectPlayerLoopTimings.Minimum用に、このBannedApiAnalyzersの設定、BannedSymbols.txtを書くとこうなります。

F:Cysharp.Threading.Tasks.PlayerLoopTiming.Initialization; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastInitialization; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.EarlyUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastEarlyUpdate; Isn't injected this PlayerLoop in this project.d
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastFixedUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.PreUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastPreUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.PreLateUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastPreLateUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.PostLateUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.TimeUpdate; Isn't injected this PlayerLoop in this project.
F:Cysharp.Threading.Tasks.PlayerLoopTiming.LastTimeUpdate; Isn't injected this PlayerLoop in this project.

こうすると、例えば PlayerLoopTiming.LastUpdate をコード上に書くと RS0030 のWarningとなります。WarningじゃなくてErrorでいいので、そこはUnityのドキュメントの通りにwarn->errorに設定を入れてやれば、以下の画像のようになります。

このぐらい出来ていれば、十分でしょう。ところでBannedApiAnalyzersはめっちゃ使えるやつなので、これの対応以外にも普通に入れておくと捗ります。どうしてもこのメソッドはプロジェクトでは使用禁止!といったようなものはあると思います、それを規約じゃなくてコンパイルエラー(警告)に変換できるわけです。例えばGameObject.Find(”name”) 絶対殺すマンとかがさくっと設定できるわけです。

(と思ったのですが、現状のUnity 2020.2のAnalyzer標準対応はかなりヘッポコのようで、そのままだとBannedApiAnalyzersはうまく使えなさそうです(BannedSymbols.txtの適用ができないとか、その他色々。csproj生成をフックして差し込むことはできるので、それによって差し込んでIDE側で利用する、ぐらいが妥協点になりそう)

タイムアウト処理について

タイムアウトはキャンセルのバリエーションと見なせます。つまり、CancellationTokenを渡すところに、時限発火のCancellationTokenを渡せばいいのです。そうすれば、タイムアウトの時間が来るとキャンセルが発動する。それがタイムアウト処理です。UniTaskでは CancellationTokenSouce.CancelAfterSlim(TimeSpan) というのがあるので、それを使います。

var cts = new CancellationTokenSource();
cts.CancelAfterSlim(TimeSpan.FromSeconds(5)); // 5sec timeout.
 
try
{
    await UnityWebRequest.Get("http://foo").SendWebRequest().WithCancellation(cts.Token);
}
catch (OperationCanceledException ex)
{
    if (ex.CancellationToken == cts.Token) // Tokenの比較をすることで厳密に発火元を調べることができますが、この場合100%タイムアウトなので比較しなくてもそれはそれでいい
    {
        UnityEngine.Debug.Log("Timeout");
    }
}

CancellationTokenSource は.NET標準のクラスであり、CancelAfterというメソッドが標準にありますが、これは(例によって)使わないでください。標準で備え付けられているものは当然のようにスレッドタイマーを用いますが、これはUnityにおいては不都合な場合が多いでしょう。CancelAfterSlimはUniTaskが用意している拡張メソッドで、PlayerLoopベースでタイマー処理を行います。パフォーマンス上でも軽量です。

タイムアウトによるキャンセル処理と、別のキャンセル処理を組み合わせたい場合も少なくないでしょう。その場合は CancellationTokenSource.CreateLinkedTokenSource を使ってCancellationTokenを合成します。

var cancelToken = new CancellationTokenSource();
cancelButton.onClick.AddListener(()=>
{
    cancelToken.Cancel(); // cancel from button click.
});
 
var timeoutToken = new CancellationTokenSource();
timeoutToken.CancelAfterSlim(TimeSpan.FromSeconds(5)); // 5sec timeout.
 
try
{
    // combine token
    var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, timeoutToken.Token);
 
    await UnityWebRequest.Get("http://foo").SendWebRequest().WithCancellation(linkedTokenSource.Token);
}
catch (OperationCanceledException ex)
{
    if (timeoutToken.IsCancellationRequested)
    {
        UnityEngine.Debug.Log("Timeout.");
    }
    else if (cancelToken.IsCancellationRequested)
    {
        UnityEngine.Debug.Log("Cancel clicked.");
    }
}

これによってキャンセルボタンのクリックによるキャンセル発火と、タイムアウトによるキャンセル発火を合成することが出来ました。

TimeoutController

ここまでが王道パターンのキャンセル処理だったのですが、今回UniTask v2.2.0では新しくTimeoutControllerというクラスを追加しました。これはタイムアウトが発火しない場合はアロケーションがなく再利用可能なCancellationTokenSourceです。タイムアウトは例外的状況なはずなので、これによってほとんどの状況で、タイムアウト処理のためのアロケーションをゼロにすることができます。

TimeoutController timeoutController = new TimeoutController(); // setup to field for reuse.
 
async UniTask FooAsync()
{
    try
    {
        // you can pass timeoutController.Timeout(TimeSpan) to cancellationToken.
        await UnityWebRequest.Get("http://foo").SendWebRequest()
            .WithCancellation(timeoutController.Timeout(TimeSpan.FromSeconds(5)));
        timeoutController.Reset(); // call Reset(Stop timeout timer and ready for reuse) when succeed.
    }
    catch (OperationCanceledException ex)
    {
        if (timeoutController.IsTimeout())
        {
            UnityEngine.Debug.Log("timeout");
        }
    }
}

再利用(と、内部のタイマーの停止)のために、awaitが正常終了したらResetを手動で呼んでください、という一点だけ気をつけてください。

CreateLinkedTokenSource的な使い方をする場合は、コンストラクタの引数に別のCancellationTokenを渡せます。これによってTimeout(TimeSpan)で得られるCancellationTokenがリンクされたものとなります。

TimeoutController timeoutController;
CancellationTokenSource clickCancelSource;
 
void Start()
{
    this.clickCancelSource = new CancellationTokenSource();
    this.timeoutController = new TimeoutController(clickCancelSource);
}

ところでここで告知が幾つか有りまして、まず、UniTaskには標準で .Timeout, .TimeoutWithoutException というメソッドが生えているのですが、これらは可能であれば使わないでください。というのも、 .Timeoutは外部からタイムアウト処理を行うもので、その場合に動いているタスク本体を停止することができないのです。タイムアウトが発火してもTask自体は動いていて、やってることは結果を無視するということです(世の中、AbortできないAPIも少なくなくて、そういうもののキャンセル処理ってこういうことなので、別にこれ自体は悪いわけではない)。かたやCancellationTokenをメソッドに渡す場合は、内部からのタイムアウト処理となるので、その場合TaskがAbort可能なものであれば、正しく処理がAbortされます。まとめると、CancellationTokenを渡すことができないメソッドに対して外付けでタイムアウト処理を行いたいときだけ、.Timeoutを使いましょう、ということになります。正直名前ももう少し、あんま使わないで感を醸し出す名前に変更したいぐらいなのですが、まぁとりあえずは、ということで……。

もう一つ、UniTaskには AsyncOperation.WithCancellation とは別に UniTask.WithCancellation というメソッドが生えていたのですが、UniTask.WithCancellationのほうの名前をAttachExternalCancellationに変更しました。これもTimeoutの話と同じで、 AsyncOperation.WithCancellation が内部からのキャンセル処理で、 UniTask.WithCancellation は外部からのキャンセル処理となっていて、挙動は似ていても内部動作が全く違うからです。内部キャンセルのほうが望ましいんですが、コードを見ただけだと内部キャンセルなのか外部キャンセルなのか分からないのは非常に良くない。つーかマズい。ダメ。ので変えました。名前的にも、使いたくない雰囲気を漂わせてる名前であるとおり、あんま使わないでねという意図が込められています。

最後に微妙に細かいところなのですが、AsyncOperation.WithCancellationの挙動を.ToUniTask(cancellationToken)のただのショートカットにしました。Timeout処理で使うのに微妙に都合が悪かったからです。挙動はあんま変わらないんですが、細かく厳密なことを言うと少し違うんですが、まぁ、そういうことということで。

この手の初期のデザインミスの修正は、あんま破壊的変更祭り死ね、とはならない程度に、ちょいちょいやらなきゃなあとは思ってるので、すみませんが宜しくおねがいします。

async/awaitは何故無限に分からないのか

async/await自体は非同期処理を容易にするための仕組みであり、雰囲気としては誰でも同期処理と同じように書けることをゴールにしています。そして、実際のところそれは、達成できてます。同期と同じことしかしなければ。asyncと宣言してawaitと書けば、同期処理と同じです。それは全く嘘偽りなく正しい。別にラムダ式も出てこないし特殊なコールバックも実行順序もない。ちゃんとループも書けるしtry-catchもできる。そういうように作られてる。

じゃあなぜ難しいのかというと、同期処理よりも出来ることが増えているからです。

  • 直列にすべきか並行にすべきか
  • キャンセルにどう対応すべきか
  • 伝搬の終点をどう扱うべきか
  • Task(UniTask)が伝搬するのをよしとすべきか
  • 投げっぱなし処理にすべきか

で、これらってそもそも同期処理だと出来ないことなんですよね、キャンセルって同期だと原則できないわけで。だからキャンセルなんて考えず黙ってawait、以上。とすればいいのです。別に並行(WhenAll)なんてしなくても直列で回してもいいのです、だって同期だったら黙って直列でやってた話じゃないですか。以上。

が、まぁ人間出来るとなると欲が出るし、そもそも実際そういうわけにはいかないので、同期処理と比べて、よりベターな処理にするために、考えることが増える。やるべきことが増える。そこが難しさのポイントです。でも出来ることが多いってのは良いアプリケーション作りのためには悪いことではない。ブロッキング処理がなくなればUIの体験は非常に良くなるし、並行処理で高速に読み込まれれば嬉しいし、きちんとキャンセル処理されたほうがいいに決まってる。だから、非同期は重要なのです。

というわけで、とりあえず一個一個考えていきましょうか。

直列にすべきか並行にすべきか

これ、JavaScriptの記事とかで、 Promise.all 使わないのは素人、バーカバーカ。みたいな記事がめちゃくちゃ良くありますが、んなこたーなくて使うかどうかはものによる。もちろん簡単に並行に束ねられるのは素晴らしいことなので、それはいいです。大いにやるべきだ。じゃあ直列処理は間違ってるかというと、別に間違っちゃあいないし、そうすべき局面だってそれなりにある。あと、allを使う必要があるからasync/awaitよりPromiseだ、みたいなのは意味不明なので無視していい。そもそも、そういう人たちってロクにコード書いたことないからなのか、thenとallぐらいしか用例を知らない説すらある。awaitはただのthenの糖衣構文「ではない」し、thenだけだと無理があるみたいなパターンもいっぱいあります。例えば非同期のミドルウェアパターンをasync decoratorパターンによるUnityWebRequestの拡張とUniTaskによる応用的設計例で紹介しましたが、これなんかはasync/awaitだからこそ成立させられる、そして非常に強力な用例です。

と、脱線しましたが、とはいえこうした並行処理を簡単に書けるようになったのがasync/await(つまりはPromise/Future/Task/UniTask)のいいところです。同期処理の場合では書けないのは勿論、コールバックベースでも難しくて無理がある、のでやらないものだったのが、async/awaitの登場によって頻繁に出てくるパターン、そして誰でも比較的安全に処理できるパターンとなりました。ちなみにこれ、Promiseだけでも誰でも使えるパターンとはなり得なくて、async/awaitがあるからこそ、Promiseのコード上での出現頻度が上がり、それによって適用可能になるシチュエーションが増えるという側面があると思っています。

Task(UniTask)が伝搬するのをよしとすべきか

前の話から続けると、asyncのための型(Promise/Task/UniTask)が頻出するのは、いいことだと思ってます。そのお陰で、効果的に適用できるシチュエーションが増えるんですから。とはいえ面倒くせーしグチャグチャするし嫌だ、という気持ちは大いにわかる。はい。

と、ここで最新型のasync/await実装であるSwift 6から幾つか例を見てみましょう。日本語でわかりやすくまとまってる Swift 6で来たる並行処理の大型アップデート近況先取り! Swift 6 の async/await から引かせてもらいますが、まずメソッドの宣言。

func download(from url: URL) async -> Data

Dataが戻り値なわけですが UniTask[Data] みたいになっていない、Promiseが出てこないやったー、かというと、別にそんなこたぁないかなあ、と思います。Swiftの場合、asyncで宣言したメソッドにはawaitが必須であり、awaitを使うにはasyncである必要がある、と、伝搬していっているわけなので、 async -> Data の一塊で見れば、制約や機能は UniTask[Data] のようなものと大きな違いはありません(型として明示されない分だけ、より強い制約がかかってるのですが、そのへんは後述)。

そういうわけでasyncが伝搬している(悪いような言い方をすればコードを汚染している)わけですが、それに関してはどうでしょう。Swiftがいい対称性を持っているのはSwiftの検査例外と似たような雰囲気で捉えられるところで、エラーの発生しうるメソッド(throws)の呼び出しにはawaitのようにtryが必要で、tryにはthrowかcatchが必要、と。

なので、最下層でエラーなしメソッドからエラーありメソッドに変えたら、呼び出し側はどんどんさかのぼってエラー処理を書く必要がある。別にこれはGoも一緒ですよね、戻り値が(value)から(value, error)に変わり、対応していく必要がある。そういう対応が面倒くさいので、そうしたエラーに関しては検査しない勢もいる(C#や非検査例外のJavaなんかはそうですよね、どちらかというとむしろそのほうが多数派)わけで、良し悪し、とは言いませんが、現代的にエラー処理を強制的に伝搬させることは絶対に忌避するもの、というほどの価値観ではなくなってるのではないかと思います。

で、async/awaitの話しに戻りますが、非同期もまた同様に最下層で同期から非同期に処理を変更したら伝搬していく。で、エラー処理をやったほうがいいのと同じように、同期から非同期へと性質が異なるものになったので、そしてそのことが型で明示されるのは当然いいことなので、伝搬していくのは当たり前じゃないですか?性質が変化したことを型(UniTask)なり宣言(async)なりで示し、上層側に性質が変化したことにより増えた出来ることの選択(並行処理/キャンセル/etc…)を与える。悪いことじゃないので受け入れるべきだし、async汚染とか言って喜んでるのはやめるべきですね。

全部非同期というか、そういうことを全く意識させないような言語としてデザインする、というアイディアも当然あって、Goは実際それに近くて、しかも圧倒的に少数派で独特なデザインなのに大成功を収めているのが凄い。まぁじゃあそれが理想的で全ての言語がそうなっていくべきかというとそうではないとは思います(例えばキャンセルやタイムアウト処理などは結局意識させなきゃいけないので、Contextを伝搬させる必要があるため、完全に透過的にできているかというとそうではない。また全体のシンプル化の結果WaitGroupのような他ではあまり出てこないプリミティブな処理や、Channelが頻出する、もちろんそれはトレードオフなのでデザインとしてナシではないですが)。みんな違ってみんないい、とは思いませんが、目の前のプロダクトのために現在の現実の時間で何を選ぶべきか、という話ですね。

伝搬はしょーがないとしても、書き味を良くするやり方はありますよね。Swiftの場合は、非同期で宣言している関数に同期関数を突っ込める。雑多なところでいうと、Task.FromResult()書いて回らなくていい、的な良さがありますね。ただまあ呼び出し側のawait, asyncの伝搬のほうが面倒くさ度というか、書くことはずっと多いので、あったほうがいいけど、なくても許容できるぐらいの感じかしら。

それと async -> Data には UniTask[Data] のようなTask型が出てこない。これも一々ジェネリクスで書くの面倒くさいので、asyncって言ってるんだからイチイチ、そっちの型でまで書きたくない、と。めっちゃいいですね。はい、いいです。また、文法とタイトにくっついてるのでUniTaskのawait二度漬け禁止とか、フィールドには持たないで欲しいなぁみたいなのが文法レベルで制限かけられる。これもいいところです。

じゃあそれと比べたC#の良いところというか現状こうなってるという点では、asyncで宣言した戻り値の型によって実行する非同期ランタイム(AsyncMethodBuilder)が切り替えられます。asyncで宣言したメソッドを非同期ステートマシンに変換するのはコンパイラの仕事ですが、そのステートマシンの各ポイントでどう処理するかの実行機は型に紐付いています。Taskで宣言しているメソッドはTaskの非同期ランタイム、ValueTaskで宣言してるメソッドはValueTaskの非同期ランタイム、そしてUniTaskで宣言してるメソッドはUniTaskの非同期ランタイムで動きます。UniTaskがやっているように、この非同期ランタイムはユーザーがC#で実装できます。

世の中の99%は別に既定の非同期ランタイムで不自由しない、と思いきや、そうではなくて、完全にデフォルトの実装を無視して100%実行環境(Unity)に特化して最適化することの効果、意味みたいなことを実証したのがUniTaskで、ちゃんと成功しています。非同期実行ランタイムを切り替えられる言語は他にもありますが(Rustもそうですね)、C#のそれは私が自分で書いてそこそこうまく普及させたというのもありますが、現状よくできた仕組みになっているんじゃないかとは思います。

伝搬の終点

asyncは伝搬していきますが、一番根っこで何か処理しなきゃいけないのはC#もそうですし、別にSwiftも同様です。Swift 6の仕様を見る限り@asyncHandlerでマークされたメソッドは伝搬を打ち切った根っこのメソッドになるようですが、つまりようするにこれってC#でいうところの async void です。

伝搬をどういう風に打ち切ればいいのかというのは、実際初心者殺しなところがありますが、フレームワークがasync/await前提で作られている場合は意識させないことが可能です。例えばMVCウェブフレームワークのControllerで言ったら

public class FooController : Controller
{
    // Foo/Helloでアクセスできる
    public async Task Hello()
    {
        // Usercode...
    }
}

というようにすると、ユーザーのコード記述のエントリポイントは async Task Hello であり、非同期伝搬の最上位の処理(async void)はMVCフレームワークの中で隠蔽されています。

コンソールアプリケーションのMainもそうです

static async Task Main()
{
    // Usercode...
}

最上位がMainなので、伝搬の終点なんて考えなくていい。

じゃあUnityは、とかWinFormsやWPFは?というと、async/awaitなんて存在しない時代からのフレームワークであり、別にそれを前提としていないので、最上位を自分で作る必要があります。これが悩ましさを増させてしまうんですね。まぁ大抵はユーザーの入力が起点なので、Buttonのイベントハンドラーに対して UniTaskVoid(async void) を突っ込む、みたいな運用になってきますが……。あとはStartCoroutineと同じような雰囲気で、MonoBehaviourのどこかでFireAndForgetですね。何れにせよ、自分で最上位となるポイントを判断しなきゃいけないというのが、ひと手間感じるところで、難しいと言われてもしょうがない話です。async voidは使うんじゃねえ(正しくはある)、みたいな話もあるから余計分からなくなるという。使っても良いんですよ、最上位では……。

UniTaskの場合はUniTaskVoidという存在がまた面倒くささを増量しているのですが、上の方でC#は戻り値の型で非同期ランタイムを切り替えられると書きましたが、つまりvoidに対するC#既定のランタイムがあり、voidで宣言する以上、それは変えられないのです。そのためasync UniTaskVoid と書かせるのですが、voidは特殊な存在でありUniTaskVoidは普通の戻り値の型なので、C#コンパイラの都合上、最上位として使うためにはなんらかのハンドリング(空の警告を抑制するためだけの.Forget()呼び出し)が強いられるという……。

C# 10.0 だから C# 11.0 だかに向けての提案にAsyncMethodBuilder overrideという仕様があって、メソッド単位で非同期ランタイムを選択できるようになる、可能性があります。そうしたら

[AsyncMethodBuilderOverride(typeof(UniTaskVoidMethodBuilder))]
async void FooAsync() { }

みたいに書けるようになるかもしれません。うーん、でも別にこれ全然書き味悪いですねぇ。

[UniTaskVoid]
async void FooAsync() { }

ぐらいまで縮められるようになって欲しい、まぁまだProposalなので今後に期待、あとどっか適当なタイミングで提案しておこう(そもそも C# で現実的に稼働してる 非同期ランタイム を実装してるのはMicrosoftのTask/ValueTask実装者(Stephen Toub)と私ぐらいしかいないのだ)

キャンセルにどう対応すべきか

C#において、asyncメソッドは引数の最後にCancellationTokenを受け入れるべきだというふんわりした規約があります。これが、ダセーしウゼーし面倒くせーと大不評で。なるほどね、そうだね!私もそう思う!

なんでこうなってるかというと、asyncに使うTask型って別にasyncで宣言したメソッドからしか作れないわけじゃなくて、手動で作れるんですよね。new Taskみたいな。Task.FromResultみたいな。それどころか別にawaitできる型もGetAwaiterという決め打ちな名前のメソッドを後付けで(拡張メソッドで)実装すればawaitできるようになりますからね。ゆるふわー。

それはそれで非常に拡張性があって、そもそもasync/awaitに全然対応していないもの(Unity)に対してもユーザー側(UniTask)が対応させることが出来たりして、とても良かったのです、が、awaitする型全体を通してコンパイラがChildTask的な、便利Contextを裏側で自動で伝搬してあげるみたいな仕組みを作りづらいわけです。

Swiftの場合は言語とタイトにくっついたasyncが用意されているので、let handle = Task.runDetached { await ...} handle.cancel() みたいに書ける、つまりObservableをSubscribeしたのをDisposeすればCancelでこれがUniRxで良かったのにUniTaskは面倒くせえなおい、みたいなことが出来てハッピーっぽそうです。独立したCancellationTokenを持っているのは、それはそれで柔軟な取り回しができて悪くない場合もあるんですが、まぁ99.99%の状況で上位から伝搬するCancellationTokenだけで済むのは間違いないでしょう。

ともあれ現状のC#的にはどうにもなんないししょーがないかなぁ、と思ってます。(GoだってContext手動で取り回すわけだし、ね)。はい。実際にはExecutionContextというスロットをawaitの伝搬で共有していて、SynchronizationContext.Currentはそれ経由で格納されてるので、そこにCancellationToken.Currentみたいなものを仕込むこと自体はランタイム的には出来るんですけどね。でも、ExecutionContextのスロットを使うというオーバーヘッドも避けれるなら避けたほうがいいというのもあります(などもあって、Taskで自動的に行われているExecutionContextの伝搬をUniTaskでは切っています)。

一応、文化として「引数の最後にCancellationTokenを受け入れる」というルールが普及していること自体は良かったと思います。JavaScriptだとAbortControllerがCancellationTokenのような機能を果たしますが、これを使っていくのが一般的という雰囲気でもないので、キャンセルに対する統一的なやり方が作れてない感じがあるので。

CPU資源の有効活用とスケジューラー

まず、非同期とCPU使って並列処理だー、みたいなのは被るけど被らないんですね。そして、CPUをぶん回さない非同期に価値はないかというと、んなわきゃぁないんですね。まずI/Oの非同期について考えるのが大事で、JavaScriptがシングルスレッドだから全然使えないかと言ったらんなわきゃあねえだろ、であり(Node.jsで見事実証されてます)、Redisがアーキテクチャとしてシングルスレッドを選択しても価値ある性能を出せることを証明してます。

その上で使える資源は色々使えたほうがいいよーということであり、C#のasync/awaitの場合はTaskが、というかawaitからawaitの間が実行単位になってきます。Unityの場合はawaitの最中にゲームエンジン(C++)に処理を渡して、エンジンが処理結果をメインスレッドに戻してきたのをC#がawaitで受け取る流れになってます。エンジン側に処理をぶん投げまくってC#側のメインスレッドを空けるのが現状のUnityにおける非同期というかasync/awaitというわけですね(この辺はJavaScriptに非常に似ています)。

.NET の場合はasyncメソッドは最終的にどこかの非同期I/Oに叩き込まれて、awaitで戻ってくるときにスレッドプールを使います。async/awaitが言語に実装されて以降、C#はスレッドプールをめちゃくちゃ使うようになりました、というかawaitするとスレッドプールに行くので、本質的にもはやプログラムは全てスレッドプール上で動いているといっても過言ではない。のです。全てがGoルーチンみたいな世界観と同じです(言い過ぎ)。というわけで、スレッドプールのスケジューラーへの改善の投資は続いて、もちろんワークスティーリングもしますし、ただのスレッドのプール、ではない賢い動作をする、.NETの非同期処理の心臓部となっています。

.NET 6ではこのスレッドプールはPure C#実装になります。というのもC#が動くランタイムも複数あって(.NET Coreであったりmonoであったり)、それぞれが個別のネイティブ実装だと、一つのランタイムがアルゴリズム改善しても、他のランタイムに反映されなくなってしまうなどなど。.NET Core以降、C#上で低レベルなコードが書けるようになったこととランタイムの実行速度の改善が続いていることもあり、.NET 6においてはネイティブ実装→Pure C#実装への切り替えはパフォーマンス的な向上にも繋がったそうです。

まとめ

C#のasync/awaitが登場したのは2012年、preview辺りの頃から考えるともう10年前!実用言語での大規模投入は間違いなく初めてで、最初の実装(C# 5.0)が現在から見て良かったかというと、まずかった部分も少なからずあります。しかしまぁ、6.0, 7.0, 8.0と改良を進めて来た現在のC#のasync/awaitは別に他と比べて劣っているとは思えません。8.0 のasync streamsやAsync LINQはSwiftのasync seqeunceのproposal(つまりまだ先)みたいなところもありますし。

Unity上でUniTaskみたいな独自非同期ランタイムを作るのも、別にC#で無理してやってるというわけでもなく、自分の中では自然なことです。現実にモバイルゲームを開発していこうというところで、まず動かせない要素を決める、つまりUnityというのは不動な要素。そしてそこに乗ってるC#も外れない言語。その中で、現在可能な技術(C# 8.0)の範囲で、最高の結果を引き出すための手法を選んで、手を動かす。

こないだ私の会社で出してるOSSの紹介をしたのですが、非現実的な理想ではなくて、今表現できる最高のものを生み出していく。というのをモットーにしてます。エンジニアなら評論家にならず手を動かして結果で示せ、ということですね。

A quick tour of the Cysharp OSS from Yoshifumi Kawai

というわけでまぁUniTask v2.2.0もいい感じになっていると思うので、ぜひぜひ使っていただければです!

UnitGenerator - C# 9.0 SourceGeneratorによるValueObjectパターンの自動実装とSourceGenerator実装Tips

ValueObjectは好きですか?私は大嫌いです。いじょ。

ざっくり言えばプリミティブ型に専用の型を付ける教義です。例えばUserIdをintとして扱っているとTeamIdと取り違えるかもしれないし、Hpに突っ込んでしまうかもしれない。StrengthとIntelligenceとAgilityとSpeedは別物なのだから全部intじゃなくて区別して欲しい、そうじゃないと間違った演算しちゃうぞ、と。まぁそういう自体を避けるために、それぞれラップした個別型を作るのです。int strengthじゃなくてStrength strengthだぞ、と。

これは一見正しく実際正しいのですが、問題もあります。一つに面倒くさい。ラップしたctorを作るのだけでも定形でウザ、と思いますが、更に等値とか実装するのは面倒くさい。また、そのままだと計算できなくなるので、算術演算のために生の値を.Valueで取り出す、が頻出すると安全度も下がるし見た目もめっちゃ汚くなる、当然ながらものすごく書きづらい。そしてシリアライゼーションの問題。Serialize(userId)としたときに「{ “Value” = 100 }」なんて形にシリアライズされたら最低で、全く許容できない。また、データベースで扱うときにもORMはそのままだとプリミティブしか扱えないので、マッピングできなくて不便なことになります。

といった問題があるため、基本的には大嫌いなのでそういうのやらない、プリミティブで何が悪いんだボケ。ぐらいの勢いでした。実際、社内でそうしたい、という話があった場合にはトップダウン権限で却下してたぐらいです(横暴!)。のですが、上記の問題が解決するのならば、全然許せます。むしろ良い。むしろすべき。かもしれません。

そこで C# 9.0 から新搭載されたSourceGeneratorの出番です。SourceGeneratorを活用したUnitGeneratorというライブラリを新しく作りました。今回はその内容の解説と、SourceGeneratorを実装する上でのTipsを紹介します。また、この記事は C# その2 Advent Calendar 2020 15日用です。19日にもC# Advent Calendar 2020でSourceGeneratorネタを書く予定なので、まずはPart 1ということで合わせてお楽しみください。

ちなみにC# Advent Calendar 2020の初日の記事 C# 9.0で加わったC# Source Generatorと、それで作ったValueObjectGeneratorの紹介 と内容的には非常に似通ってるんですが、そこはshoganai。またC#9.0 SourceGeneratorでReadonly構造体を生成するGeneratorを作ってみました。とも被ってますね、しょーがしょーがない。

SourceGeneratorの特性

GitHubとNuGetにUnitGeneratorとして公開しました(この記事でも後で触れますが、ReadMe末尾にはUnityでの使い方も載せてあります)。

使い方は、public readonly partial structに対して、[UnitOf(typeof(T))]を書くだけです。

using UnitGenerator;
 
[UnitOf(typeof(int))]
public readonly partial struct UserId { }

これを書くと、SourceGeneratorが裏側で以下のpartial classをコンパイル時(ビルド前)に生成します。

[System.ComponentModel.TypeConverter(typeof(UserIdTypeConverter))]
public readonly partial struct UserId : IEquatable<UserId> 
{
    readonly int value;
 
    public UserId(int value)
    {
        this.value = value;
    }
 
    public readonly int AsPrimitive() => value;
    public static explicit operator int(UserId value) => value.value;
    public static explicit operator UserId(int value) => new UserId(value);
    public bool Equals(UserId other) => value.Equals(other.value);
    public override bool Equals(object? obj) => // snip...
    public override int GetHashCode() => value.GetHashCode();
    public override string ToString() => "UserId(" + value + ")";
    public static bool operator ==(in UserId x, in UserId y) => x.value.Equals(y.value);
    public static bool operator !=(in UserId x, in UserId y) => !x.value.Equals(y.value);
 
    private class UserIdTypeConverter : System.ComponentModel.TypeConverter
    {
        // snip...
    }
}

SourceGeneratorのいいところは、生成コードがC#コンパイラのメモリ内で完結していることです。つまり、ファイルが出てきません。ファイルが出てこないのは非常にいいことで、自動生成ファイルが減った時の管理をしなくてすみます。ディレクトリごと毎回Cleanするのもイマイチですし、かといって古いファイルが残り続けるのはマズいので、そこの管理をどうするか問題は毎度面倒くさいことです。

欠点はメモリ内で完結していることです。ソースが見えないとデバッガビリティも下がりますし、コンパイルしないと追加されたコードが使えないというのもコード書いてる最中の手触り的に面倒。というのが一般的な話なのですが、そこを言語組み込みの機能として用意したことでカバーしているのがSourceGeneratorの良いところです。まず、デバッガビリティに関してはIDE(Visual Studioなど)でコードジャンプできるようになっているし、デバッガのステップ実行もフルサポート。また、IDEのインクリメンタルコンパイルとフルに連動しているため、属性を書いた瞬間から、裏ではそこの部分だけコンパイルが走ってコードが生成されて、生成コードが利用可能になっています。これは今までのビルド時プリプロセッサー/ポストプロセッサーではできなかった体験で、中々小気味良いものです。

唯一の欠点は既存コードをEditできないので、partialであることが必須になることと、編集を要求する内容は作れないことでしょうか。まぁ、それは従来あったAnalyzer(CodeFixProvider)でやればいいということで、それなりに棲み分けもできてますし、ソースコードの追加しかできないという仕様のお陰で、作成に関してはかなりシンプルになったこともいいことです。

UnitGenerateOptions

値の等値性だけを実装するのはままあるのですが、それだけだと不便なんですよね。例えばHpは + 100 とかそのまま演算したいじゃん、と。その辺のサポートがないとすぐに.Valueで生の値を取り出すことになって よくないし、MinやMaxなんかもそのまんま使いたい、例えばHpを現在値の2倍で回復する、みたいなのは target.Hp = Hp.Min(target.Hp * 2, target.MaxHp) と書けたるとかなり自然でいいよね、と。

その辺の生成をサポートするのが UnitGenerateOptions で、これを組み合わせることによって、算術演算子など好きなメソッドが追加されます。UserIdのようなものは算術演算子が生成されては困るので抑制したいし、Hpはフルで生成したい、みたいな使い分けができます。

[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Hp { }
 
// -- generates
 
[System.ComponentModel.TypeConverter(typeof(HpTypeConverter))]
public readonly partial struct Hp : IEquatable<Hp> , IComparable<Hp>
{
    readonly int value;
 
    public Hp(int value)
    {
        this.value = value;
    }
 
    public readonly int AsPrimitive() => value;
    public static explicit operator int(Hp value) => value.value;
    public static explicit operator Hp(int value) => new Hp(value);
    public bool Equals(Hp other) => value.Equals(other.value);
    public override bool Equals(object? obj) => // snip...
    public override int GetHashCode() => value.GetHashCode();
    public override string ToString() => "Hp(" + value + ")";
    public static bool operator ==(in Hp x, in Hp y) => x.value.Equals(y.value);
    public static bool operator !=(in Hp x, in Hp y) => !x.value.Equals(y.value);
    private class HpTypeConverter : System.ComponentModel.TypeConverter { /* snip... */ }
 
    // UnitGenerateOptions.ArithmeticOperator
    public static Hp operator +(in Hp x, in Hp y) => new Hp(checked((int)(x.value + y.value)));
    public static Hp operator -(in Hp x, in Hp y) => new Hp(checked((int)(x.value - y.value)));
    public static Hp operator *(in Hp x, in Hp y) => new Hp(checked((int)(x.value * y.value)));
    public static Hp operator /(in Hp x, in Hp y) => new Hp(checked((int)(x.value / y.value)));
 
    // UnitGenerateOptions.ValueArithmeticOperator
    public static Hp operator ++(in Hp x) => new Hp(checked((int)(x.value + 1)));
    public static Hp operator --(in Hp x) => new Hp(checked((int)(x.value - 1)));
    public static Hp operator +(in Hp x, in int y) => new Hp(checked((int)(x.value + y)));
    public static Hp operator -(in Hp x, in int y) => new Hp(checked((int)(x.value - y)));
    public static Hp operator *(in Hp x, in int y) => new Hp(checked((int)(x.value * y)));
    public static Hp operator /(in Hp x, in int y) => new Hp(checked((int)(x.value / y)));
 
    // UnitGenerateOptions.Comparable
    public int CompareTo(Hp other) => value.CompareTo(other);
    public static bool operator >(in Hp x, in Hp y) => x.value > y.value;
    public static bool operator <(in Hp x, in Hp y) => x.value < y.value;
    public static bool operator >=(in Hp x, in Hp y) => x.value >= y.value;
    public static bool operator <=(in Hp x, in Hp y) => x.value <= y.value;
 
    // UnitGenerateOptions.MinMaxMethod
    public static Hp Min(Hp x, Hp y) => new Hp(Math.Min(x.value, y.value));
    public static Hp Max(Hp x, Hp y) => new Hp(Math.Max(x.value, y.value));
}

この辺のメソッドがしっかり生成されることによって、プリミティブ型をそのまま使うのと遜色のない使用感が担保できるわけです。

if (character.Hp <= 0) // Hp.GetType == typeof(Hp)
{
    // is dead.
}

みたいに書けるようになってとても嬉しい。

また、演算子のオーバーロードはしっかり考慮して作るのが地味に大変な代物なので、そこをちゃんとやってくれるのも助かりです。例えばboolの場合はtrue演算子を自動実装します。

public static bool operator true(Foo x) => x.value;
public static bool operator false(Foo x) => !x.value;
public static bool operator !(Foo x) => !x.value;

こんなの自分で実装する機会なんてほとんどないと思いますが、これによってifに直接突っ込めるようになります。

if (foo) // foo.GetType() == typeof(Foo)
{
}

UnitGenerateOptionsは現在のところ以下のオプションを提供しています。

[Flags]
internal enum UnitGenerateOptions
{
    None = 0,
    ImplicitOperator = 1,
    ParseMethod = 2,
    MinMaxMethod = 4,
    ArithmeticOperator = 8,
    ValueArithmeticOperator = 16,
    Comparable = 32,
    Validate = 64,
    JsonConverter = 128,
    MessagePackFormatter = 256,
    DapperTypeHandler = 512,
    EntityFrameworkValueConverter = 1024,
}

例えば以下のように指定できます。

[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Strength { }
 
[UnitOf(typeof(DateTime), UnitGenerateOptions.ParseMethod | UnitGenerateOptions.Comparable)]
public readonly partial struct EndDate { }
 
[UnitOf(typeof(string), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct Message { }
 
[UnitOf(typeof(byte[]))]
public readonly partial struct Image { }
 
[UnitOf(typeof((string street, string city)), UnitGenerateOptions.Validate)]
public readonly partial struct StreetAddress
{
    private partial void Validate()
    {
        if (!DataMaster.Contains(value.street)) throw new Exception("Invalid Street: " + value.street);
        if (!DataMaster.Contains(value.city)) throw new Exception("Invalid City: " + value.city);
    }   
}

Validateだけ少し特殊で、自動生成側のコードがpartial void Validate()メソッドを生成して、自動生成されるコンストラクタでそれを呼ぶようになっています。Validateの実体をユーザー側が書けばOKということですね。プリミティブ型と違って、値が検証済みであることが保証されている、というのも一般的なプラクティスとしては重要な話です。(ただしstructのため、default(T)は防げないので、そういう意味では完全なValidationではありません)

シリアライザの自動実装

繰り返しますが 「{ “Value” = 100 }」みたいにシリアライズされるのは最低です。「100」とシリアライズされなければならない。と、いうわけで、そういう場合は専用のシリアライザを実装すれば回避できます。現状はSystem.Text.JsonのJsonConverterとMessagePack用のMessagePackFormatterを自動実装するオプションが用意されています。こういうのをちまちま用意するのは、私がシリアライザについて人一倍拘りがあるからで、普通はあんまないでしょうね。でもシリアライザはシステムにおいて本当に大事なことだから!

例えば UnitGenerateOptions.MessagePackFormatter は以下のようなコードを自動実装します。

[UnitOf(typeof(int), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct UserId { }
 
// -- generates
 
[MessagePackFormatter(typeof(UserIdMessagePackFormatter))]
public readonly partial struct UserId 
{
    class UserIdMessagePackFormatter : IMessagePackFormatter<UserId>
    {
        public void Serialize(ref MessagePackWriter writer, UserId value, MessagePackSerializerOptions options)
        {
            options.Resolver.GetFormatterWithVerify<int>().Serialize(ref writer, value.value, options);
        }
 
        public UserId Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
        {
            return new UserId(options.Resolver.GetFormatterWithVerify<int>().Deserialize(ref reader, options));
        }
    }
}

private classでFormatterが実装されるのがポイントで、Attributeからそのフォーマッターを取り出すことで、外部のResolverへの登録をせずに専用の対応をしています。Serialize/DeserializeはResolver経由じゃなくて直接Writer/Readerのプリミティブ型を呼ぶことで高速化できますが、まぁそれは次の機会に。このコードを発展化させた、MessagePack for C#におけるSourceGenerator対応については12/19の記事で詳しく触れる予定です。

データベースに関しても UnitGenerateOptions.DapperTypeHandler, UnitGenerateOptions.EntityFrameworkValueConverter でDapperとEF Coreの対応コードを生成します。ただしこちらは自動利用のシステムがないので、手動で取り出して登録する必要があります。

.Value is dead

UnitGeneratorはpublicプロパティを一つも生成しません。つまり、.Valueはありません。私は.Valueによる値の取り出しが悪いプラクティスだと思っていて、カジュアルに使おうという気持ちを起こさないようにしています。演算子の生成なども用意してあるし、あとは専用のメソッドを自前で書いたりしていくなどで解決できるといいよね、と。

とはいえさすがに取り出せないのは不便というか実用的ではないので、.AsPrimitive() で取れます。プロパティではなくメソッドというだけで、心理的に少し抵抗感出るんじゃないでしょうか?制約なんてそのぐらいでいいでしょう。あんまりキツくやるのも好きではないので。

Unityで使う

Source Generatorは C# 9.0 の機能です。というわけで、2020年現在のUnityはどのバージョンもそれをサポートしていません。じゃあ使えないじゃんって話なのですが、幸いファイルとして生成する機能も用意されているので、外部コマンドを実行したら自動生成する、ぐらいの雰囲気でならUnityでも使うことができます。

まずはコンフィグとなるcsprojを用意します。例えばUnitSourceGen.csprojとして、以下のような内容のものを作ります。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net5.0</TargetFramework>
 
        <!-- add this two lines and configure output path -->
        <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
        <CompilerGeneratedFilesOutputPath>$(ProjectDir)..\Generated</CompilerGeneratedFilesOutputPath>
    </PropertyGroup>
 
    <ItemGroup>
        <!-- reference UnitGenerator -->
        <PackageReference Include="UnitGenerator" Version="1.0.0" />
 
        <!-- add target sources path from Unity -->
        <Compile Include="..\MyUnity\Assets\Scripts\Models\**\*.cs" />
    </ItemGroup>
</Project>

あとは .NET SDKを入れて、コマンドを叩きましょう。

dotnet build UnitSourceGen.csproj

これで UnitGenerator\UnitGenerator.SourceGenerator*.Generated.cs がOutputPathに指定したところに生成されています。UnitGeneratorは、UnitOfAttributeやUnitGenerateOptionsも自動生成コードの中に含まれる仕様(ランタイムレス)なので、一回空の状態で実行すれば、それらのコードが生成されて利用可能になります。

SourceGenerator実装の方法

netstandard2.0のライブラリプロジェクトとして(いまのところnet5.0だとうまくいかない、これはVisual Studioが .NET Frameworkで動いているせいだから、らしい)Microsoft.CodeAnalysis.CSharpを参照します。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>preview</LangVersion>
        <Nullable>enable</Nullable>
    </PropertyGroup>
 
    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
    </ItemGroup>
</Project>

また、合わせてテスト用のプロジェクトを用意して、ライブラリプロジェクトを参照するようにしておくといいでしょう。プロジェクト参照を、OutputItemType=”Analyzer”にしておきます。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
        <Nullable>enable</Nullable>
        <LangVersion>preview</LangVersion>
    </PropertyGroup>
 
    <ItemGroup>
        <ProjectReference Include="..\..\src\UnitGenerator\UnitGenerator.csproj"
                          OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
    </ItemGroup>
</Project>

あとはISourceGeneratorを実装するだけ。

[Generator]
public class SourceGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
#if DEBUG
        if (!System.Diagnostics.Debugger.IsAttached)
        {
            // System.Diagnostics.Debugger.Launch();
        }
#endif 
 
        context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
    }
 
    public void Execute(GeneratorExecutionContext context)
    {
    }
 
    // 実装しなくてもいいけど、この段階で対象になるファイルを引っ掛けておくとワンパスで処理できる
    class SyntaxReceiver : ISyntaxReceiver
    {
        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
        }
    }
}

System.Diagnostics.Debugger.Launch() を入れておくと、デバッガでアタッチできて実装が捗ります。ただしVisual Studioがインクリメンタルコンパイル的にかなりの頻度でキックしてくるので、不要なときはコメントアウトしておくのが吉。また、SourceGeneratorの実装コードの変更にたいしてVisual Studioのキャッシュがうまく追随してくれなくて、実装中は挙動が腐ることがよくあるので、困ったときの再起動でやり過ごしましょう。

RegisterForSyntaxNotificationsは使っても使わなくてもどちらでもいいのですが(ExecuteのところでSyntaxTreeの全てが手に入るので探索し放題)、ここで大雑把でも引っ掛けておいたほうが、その後の処理が軽量になるので、使ったほうが基本的にはヨシ。

SourceGeneratorでユーザーが使う属性は、参照DLL内に含めておいてそれを使う場合と、参照DLLは完全に空にして、ソースジェネレーター自身が生成するパターンがあります。後者のパターンを使うと、ソースジェネレーターのためだけに参照DLLが増えることを避けれるので、今回のUnitGeneratorのような、生成コードが全ての処理を行うタイプのものは、そちらのパターンを使ったほうが良いでしょう。

やりかたは単純に最初に必要な属性を突っ込んでしまうという、ただそれだけなのですが一点注意なのは、この生成は絶対死守しましょう。Execute内で例外が発生したりすると、ここでAddSourceした属性の追加はキャンセルされます。

public void Execute(GeneratorExecutionContext context)
{
    context.AddSource("UnitOfAttribute.cs", "internal class UnitOfAttribute...);
 
    try
    {
        // manipulate syntax...
    }
    catch (Exception ex)
    {
        System.Diagnostics.Trace.WriteLine(ex.ToString());
    }
}

特にIDEのインクリメンタルコンパイルが稼働している状態だと、入力途中の「不完全なコード」が頻繁に飛んできます。こうした不完全なコードによる不正な構文木を正しくハンドリングするのはかなり難しく、例外を飛ばしてしまうのは正直避けられません。しかし、何があっても最初に生成する属性のAddSourceだけは維持しないと、「入力途中の不完全コード→例外発生で属性が吹っ飛ぶ→属性が吹っ飛ぶので入力補完が効かないどころか書いてるものが全てエラーになる」という負のループが発生します。なので、これに関してはtry-catchで握り潰しOKです。

コード生成のためのテンプレートですが、サンプルだとみんなstring interpolationでさっくり処理してますが、やめときましょう。複雑なコードを生成しようとすると破綻するので、よほど単純な生成じゃないならちゃんとテンプレートエンジン使いましょう。

じゃあ何を使えばいいのか、というとT4 Templateです。以前に.NET Core時代のT4によるC#のテキストテンプレート術という記事を書いたので、それを読んでくださいな。これの「実行時テキスト生成(TextTemplatingFilePreprocessor)」を使います。具体的なUnitGeneratorのテンプレートはUnitGenerator/CodeTemplate.ttにあるので参考にどうぞ。ただたんにOptionによってifがちょろちょろある程度ですが、それでもこれをstring interpolationとStringBuilderで処理するのは無理があります。

ユニットテストに関しては CSharpGeneratorDriver というものが用意されているので、それで小さいCompilationを作って渡せばOK。ってどういうこっちゃという感じですが、chsienki/GeneratorTests.csのコードをまんま使えばOKですね。中身は単純です。

var comp = CreateCompilation(/* ソースジェネレーターの対象コード */);
var newComp = RunGenerators(comp, out var generatorDiags, new SimpleGenerator());
 
// あとはnewCompから生成コードを引っ張ってきて、それが意図通りの正しさかどうか見たり
Assert.Empty(newComp.GetDiagnostics()); // エラーなくちゃんと生成できてるかどうか

ただし、参照DLLを増やすと面倒くさい挙動したり、そもそも生成されたコードの挙動が正しいかどうかを見たい(UnitGeneratorでいうと算術演算子が正しいかとか、シリアライザの実装が正しいかとか)ほうが多いんじゃないかなーと思うので、普通にユニットテストプロジェクトにSourceGenerator参照して、それが生成されたコードを動かして普通にAssert書く、みたいなのでいいかな。私は実際そんなわけで、CSharpGeneratorDriver経由のテストはやめました。(というかそもそも普通のユニットテストもたいして書いてない説はある)

最後にNuGetへのパブリッシュについて。SourceGeneratorはAnalyerとして登録したいので、ひと手間いります。具体的には以下のように処理します。

<PropertyGroup>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
 
<ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

IncludeBuildOutputで、自身のDLLを参照用として含めないようにします、これは前述の「参照DLLは完全に空にして、ソースジェネレーター自身が必要な属性を生成するパターン」を使う場合には自身の参照は不要だからですね。SuppressDependenciesWhenPackingは、これ設定しとかないとpack時に空なんだけど、という警告が出てくるので黙らせます。空なのは知っとるがな。

Analyzerとしてpackするにはanalyzers/dotnet/cs以下に配置すればいいだけ、ということで、そういう設定をしておきます。

一手間と言ってもこれだけです。昔はAnalyzerはPowerShell動かして小細工しなきゃいけないとか色々あって超絶面倒くさかったんですが、.NET 5時代の今は、だいぶ簡単になりました。

まとめ

ずっとF#のUnits of Measureのようなものが欲しいと思っていたのですよね。プリミティブなのだけど型がついてる。コンパイル時には型が消えてプリミティブそのものになるのでオーバーヘッドがない。そのままでも色々な演算ができる。

UnitGeneratorはお洒落なsuffixで生成とかはできないし組み込みの単位の変換関係(グラムとキログラムとかインチとフィーととか)があるわけじゃないので、同じものかといったら全然別物ではありますが、しかしValue Objectパターンの実装としては必要十分で、雰囲気も近づけられたのではないかと思います。

C#において、1要素のstructはメモリレイアウト的にはプリミティブ型と同一なので、完全に消せるわけではないですが、オーバーヘッドも減らしていける余地があります(演算のたびにnewで包み直していたりするのも、Unsafe.Asを活用していけばなくせるので、だいぶ近づけはするかな、と)。

実際ちゃんと型がついているのは良い状態で最終的に捗るのは間違いないので、このUnitGeneratorのアプローチが役に立てば何よりですね、是非試してみてください。あと、SourceGeneratorも是非作っていきましょう!

MagicOnion v4 - .NET 5 と ASP .NET Core gRPC対応への進化

CysharpからMagicOnion v4を先週リリースしました。今回のリリースの実装はほぼ全て@mayukiさんにやってもらったので、詳細はそちらに丸投げドンとして(ReadMeもかなり書き換えてあるので、詳しいところはそちらも読んでください)、改めて .NET 5とgRPC、そしてMagicOnionの位置付けとロードマップなどを説明したいかな、と思います。

MagicOnion v4ではサーバーサイド側は完全に ASP.NET Core KestrelベースのPure C#実装になりました。今まではGoogleの提供していたgRPC C Coreを利用していたのですが、今回よりMicrosoft実装に切り替えています。これによりASP.NET Core MVCなどと基盤が共通化されたので、gRPCを提供しつつHTTP/1 REST APIの口やHTML出力を行うような同居がとてもやりやすくなりました。

そして何より、パフォーマンスが向上しています。

.NET 5における性能向上に関してはgRPC performance improvements in .NET 5にて紹介されていますが、まず、gRPCと一口で言っても性能は言語によって千差万別です。HTTP/2だから速いとか、gRPCだから速いとか、そういうことはありません。大事なのは実装です。

gRPCの場合は、各言語での独自実装組(Java, Go, Rust)と、Cで作られているCoreのバインディング組(Ruby, Python, Node.js, etc…)に分かれます。バインディングだから低速だということはないのですが(そもそも高速なC++実装はC Coreの上に作られている)、どうしてもマーシャリング部分の実装の甘さや、各言語の部分に乗っかった箇所の実装の弱さに引っ張られて、性能が落ちやすい傾向にあります。

実際のところ、どれだけパフォーマンスに本気になって実装しているか、というところが性能に現れるので、わざわざ各言語で独自実装しているものは本気度が高く、バインディングで済ませているのは本気度が低い(動けば御の字)といったような見方でも良いでしょう。

C#も今まではC Coreのバインディングでしたが、.NET Core 3.1からPure C#実装が提供され、そして今回の.NET 5よりHTTP/2の性能向上に注力したことで、C++, Rust, Goと並ぶTier1の位置までパフォーマンス向上を果たしました。

もともとHTTP/1においても執念深く延々と性能改善施策を続けていて、ついにTechEmpower Web Framework Benchmarksでは1位(Plaintextのみですが)を奪取しています。

それ以外にも .NET 5ではPerformance Improvements in .NET 5として細かい対応を延々としてきたのがついに実ったという感じですね。Announcing .NET 5.0で表明されていますが、.NET 5 は Unified Platformsを標榜してランタイムコンポーネント・コンパイラ・言語を統一するという話があります。そうした大きなバージョンアップに相応しい一歩なのではないでしょうか。

ちなみに、.NET 5、実質的に機能しだすのは.NET 6からで、5に関しては基礎固めと.NET Core 3のリブランディング的な感じなので、実際のインパクトは今の所あんまありません。

gRPCとトランスポート中立、或いはQUIC

色々な構成要素の塊がgRPCなのですが、それぞれの要素はプラガブルで分解可能だったりします。シリアライザはProtocol Buffersでなくてもいいし(C++実装はFlatBuffersに置き換えられるものも用意されていたりするし、MagicOnionはMessagePackを使っています)、トランスポート層もHTTP/2的な決まりごとにさえ従えるのなら、ある程度は自由に変更できます、実際TCPではなくUNIX domain socketへの置き換えはプロセス間通信としてgRPCを使う場合にはままあります。

C Coreにべったりの場合は、換装の自由度が低かったりしたのですが、Pure C#に置き換わり、その辺の仕組みが全て ASP.NET Core の上に乗っかったことにより、比較的自由に弄れるようになっています。

既にASP.NET CoreにはQuic実装がMicrosoft.AspNetCore.Server.Kestrel.Transport.Experimental.Quicとして(Experimentalですが)提供されています。MsQuicを基盤として利用するため、Quicの実装準拠度としても比較的信用が置けるでしょう。MsQuicはWindows Serverで使うため、当然ながら相当固い実装である必要があるからです。なお、MsQuic自体はクロスプラットフォームのためLinuxで動きます。

サーバーはそうして自由に対応できるとして、現状クライアント側がイマイチなのですが、そこは追々という感じでしょうか。

特にゲームでのリアルタイム通信での利用時に、TCPであることがボトルネックとなることは避けたいので、RUDPや、中国のネットワークゲームでよく使われるKCPによる通信の口は用意しておきたいと思っています。QUICが大安定して全てそれで解決、みたいな時代が来ればいいんですけれどね。そう遠くはなさそうな感じがあるので、期待しています。

gRPCとMagicOnion、StreamingHubとBidirectional Streaming

gRPCの色々な構成要素の中でも最重要なのがprotoによる言語中立のスキーマとコードジェネレートにあるでしょう。MagicOnionは、そのprotoを投げ捨ててC# to C#に限定されるため、デメリットを超えるだけのメリットが必要です。

一つはprotoが言語中立であることによる表現力の乏しさを、C#そのものスキーマとすることで解決しています。protoの場合は少しのプリミティブとEnum、コレクションとマップのみですが(また、nullもない)、MagicOnionの場合は、C#そのものスキーマとして見立て、メッセージ形式にMessagePack for C#を利用することで、(ほぼ)全てのC#型が転送可能になっています。

例えば.NET 5ではWCFの置き換えにgRPCが推奨されていますが Migrate a WCF request-reply service to a gRPC unary RPC 、protoへの書き換えが必要なことと、表現力のギャップに苦しむことがあります。MagicOnionならDataContractで表現された型は全てMessagePack for C#でシリアライズ可能ですし、OperationContractのメソッドの複数引数のような表現も可能なため、移行におけるギャップはほとんどありません。

また、gRPCは双方向のリアルタイム通信用にBidirectional streaming RPCが利用できますが、これは双方が投げっぱなしのAPIしか存在しないため、戻り値の取得や処理の完了の待機などが実装できません。更にエンドポイントとなる型も一つしか使えないため、大量のoneofで呼び出しの切り分け処理をするしかありません。

MagicOnion StreamingHubはBidirectional streaming RPCの上に、双方向にC#としての型やメソッドのルーティング処理をつけ、client -> server -> client の呼び出しでは戻り値の取得やエラー送信、完了待機のシステムを入れました。この基盤処理の実装によって、初めてgRPCで実用的なリアルタイム通信が可能になっています。なお、APIは ASP.NET Core SignalRに寄せたため、そちらの経験があれば比較的スムーズに移行できるはずです。

UnityとgRPC

MagicOnion v4ではサーバーサイド側は完全に ASP.NET Core KestrelベースのPure C#実装になりました。クライアント側も.NET Coreの場合はHttpClientベースのPure C#実装になりました。Unityは……?というと、引き続きC Coreベースの提供になります。Unity側の改善はMagicOnionにおいてはv5でなんとかする予定ですので少々お待ち下さい。

Unity側の実装がC Coreで提供されている状態は、初期セットアップが相当面倒くさくなっています。というのもGoogleがAndroid, iOS向けのビルドを雑にとりあえずといった感じで提供しているだけなので、そのまんまだと動かないという……。MagicOnionのReadMeのSupport for Unity Clientセクションで、その辺は手厚めに解説してはいます。例えばそのまんまだとiOS用のgRPC libが100MBを超えていてGitHubで扱えないという問題が発生するのですが、ReadMeに説明してあるストリッピングの手順に従ってもらえればlibのサイズを縮めることができます。

ほか、C Coreの持つネイティブコネクションのライフサイクルと、Unity上での頻繁なPlay/StopによるC#側のライフサイクルが自動では一致しないため、ネイティブコネクションがリークするとエディタごと巻き込んでフリーズする(この場合、コネクション管理を徹底してライフサイクルを一致させれば大丈夫)、といった面倒くさい問題が発生したりします。

また、Taskベースで作られているためアロケーションが多めという問題もあったり。

これらネイティブライブラリであることの問題は、Pure C#実装を提供することで解決すると考えています。Task部分に関してはUniTaskを活用するように書き換えれば、アロケーションも減らせるでしょう。実際、Unity用のメジャーなOSSネットワークフレームワークであるMirror、の作者陣が内部分裂してForkされたMirrorNGはUniTaskベースで構築されています。

Pure C#実装の場合は、UnityのC#ランタイムであるmonoがあまり性能が良いとは言えないため、問題になる可能性があるのですが、サーバーとして使わなければ大丈夫なのではないかと踏んでいます。フルUnity実装でサーバーを提供する場合は気になるところなのでネイティブ実装を混ぜるなどの方向性もあるとは思いますが、現状のMagicOnionの構成はUnityはクライアントにしかならないので。

gRPCであること

gRPCを推しているのは、HTTP/2に乗っかっていることでインフラ側のミドルウェアが豊富なことがあります。Nginx, Envoyなど、今ではほとんどのソフトウェアがHTTP/2対応していますし、AWS ALBに至ってはgRPC専用のサポートを追加してきました。これらを活用することで、独自の通信形式などに比べると、サーバー構築の柔軟性が飛躍的に向上しています。独自っぽい雰囲気の漂っているMagicOnionも、変更しているのはメッセージの中身だけなので、gRPCのミドルウェアのエコシステムにはフルに乗っかれています(というか、ちゃんと乗れるように作っているのです)

また、自社で何もかもをすべて作らない、というのもあります。ASP.NET Coreに乗っかることで、Microsoftによる通信ライブラリの性能改善にタダ乗りしています。

gRPCそのもののメリットとしては、API通信とリアルタイム通信の二系統を一つのフレームワークに一本化できること、これは私が4年前にgRPCを採用した(当時は1.0が出たばかりでUnityの利用事例はゼロだし、一般の事例はマイクロサービスのサーバー間通信用としてが多くクライアント-サーバー通信を置き換えようとする例もあまりなかった)理由でもあります。

ロードマップ

v5におけるUnityクライアントの作成、ユーティリティとして負荷テストツールの提供、などがとりあえず並んでいます。特に負荷テストツールはあと少しなので、近日中にお届けできるかと。

MagicOnion自体のプロダクト採用の実績は増えてきていて、直近ではD4DJ Groovy MixがAPI(Unary)もリアルタイムマルチ(StreamingHub)も活用しています。また、バーチャルキャスト 2.0では、ルームというVR SNSの実装に活用されているそうです。

ゲームーサーバーとしてC#が活用できるのか?といったことは、クラスメソッドさん向けにお話したゲームサーバー用の発表でも話しましたので、レポ記事 - Cysharpの河合様をゲスト講師にお招きしてゲームサーバーに関する社内勉強会を開催しました!と、発表資料もどうぞ。

Building the Game Server both API and Realtime via c# from Yoshifumi Kawai

世の中、適材適所なのは間違いありません。だからこそ、「C#がその適材である」と言えるだけの環境を提供していく、というのがCysharpのミッションでもあります。Microsoftも.NET Coreにおいて、当初はWindowsべったりなC#が今更Linuxとか言ったって、みたいな白い目で見られていました。しかし、最初のバージョンから4年が経ち、文句を言わせないだけのパフォーマンスでもって証明してきました。

.NET 5はスタート地点だと考えています。C#も大変面白い環境になってきたと思うので、是非みんなと追求していけたら嬉しいですね。

ConsoleAppFramework v3 - より強化されたC#のためのコマンドラインツール用フレームワーク

.NET 5も控えていることだし、というのは関係ないのですが、CLIアプリケーションや大量のバッチをC#で簡単に作れるフレームワークであるところのConsoleAppFrameworkを思い立って更新しました。

基本的な構成である、Generic Hostの上に乗っかるCLIフレームワークというコンセプトには変更ありません。

メソッド定義がそのままコマンドライン引数になって、ヘルプなども自動生成してくれます。Host(ASP.NET Coreなどでも使う)の設定によってロガーやDIの設定、オプションの読み込みとバインディングも可能なので、細かいコンフィグレーションもそれで行えますし、基盤が一緒なためASP.NET Coreなどとの共通化なども可能になります。

一番単純な例を出すとこんな感じになります。

public class Program : ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }
 
    public void Hello([Option("m", "Message to display.")]string message)
    {
        Console.WriteLine("Hello " + message);
    }
}
> SampleApp.exe help
 
Usage: SampleApp [options...]
 
Options:
  -m, -message <String>    Message to display. (Required)
 
Commands:
  help          Display help.
  version       Display version.
 
> SampleApp.exe -m World
Hello World

今回の変更内容は

  • 厳密っぽいオプション引数指定
  • version, helpコマンドをデフォルトでヘルプ表示
  • class/methodによる自動コマンド定義を class method コマンド引数で実行可能に(以前はClass.Methodだった)
  • Interceptorを廃止してFilterによる拡張

Interceptorの廃止だけが破壊的変更で、それ以外は互換性取れています。

厳密っぽいオプション引数指定

厳密っぽいというか、 -i, --input のようにショート版の名前を-、ロング版の名前を–で一致を見るスタイルを適用可能にしました。デフォルトは-の数を無視します、つまり-inputでも–inputでも—–inputでも同じ扱いにしています。これ区別するの面倒くさいなーと思っていて、例えばgoのコマンドは全て-o, -outputみたいな-だけで済ませていて、私もそれでいいじゃん、むしろそれがいいじゃん、と思ってはいるのですが(なのでデフォルトはそう)、区別したい人も世の中には大勢いるとは思うので、そーいうオプションを足しました。

public class Program : ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args, new ConsoleAppOptions
        {
            StrictOption = true, // default is false.
            ShowDefaultCommand = false, // default is true
        });
    }
 
    public void Hello([Option("m", "Message to display.")]string message)
    {
        Console.WriteLine(message);
    }
}
> SampleApp.exe help
 
Usage: SampleApp [options...]
 
Options:
  -m, --message <String>    Message to display. (Required)

デフォルトが -m, --message <String>だったhelpが、 -m, --message になっています。-messageという指定をすると、名前が合わないというエラーが出るようになります。

また、version, helpコマンドがデフォルトでヘルプ表示されるように今回からなりました。これもオプションで ShowDefaultCommand = false にすれば表示されなくなります(表示されなくなるだけで、コマンドとして存在はしています)。

class/methodによる自動コマンド定義

プロジェクトに沿ったバッチを作成する場合に、数十、時に数百個のバッチを作る必要があります。そうなると一々コマンド定義をしてる場合じゃねえ、という感じなので、自動でルーティングしてくれる機能がConsoleAppFrameworkにはあります。 MVCフレームワークがclass/methodでURLルーティングするのと同様に、class methodというサブコマンド階層を自動で生成してくれます。

class Program
{
    static async Task Main(string[] args)
    {
        // <T>を指定しないとアセンブリ全体から実行コマンドとなるクラスを検索して登録する
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args);
    }
}
 
public class Foo : ConsoleAppBase
{
    public void Echo(string msg)
    {
        Console.WriteLine(msg);
    }
 
    public void Sum([Option(0)]int x, [Option(1)]int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}
 
public class Bar : ConsoleAppBase
{
    public void Hello2()
    {
        Console.WriteLine("H E L L O");
    }
}
> SampleApp.exe help
Usage: SampleApp <Command>
 
Commands:
  foo echo
  foo sum
  bar hello2
  help          Display help.
  version       Display version.
 
> SampleApp.exe foo sum 10 30
40

前のバージョンでは “Foo.Sum” というコマンド名での呼び出しだったのですが、それはコマンドラインツールとして不自然だろう、ということで、小文字の “class method” で実行されるようになりました。互換性のために “Foo.Sum”といった指定でも実行可能です。

Filterによる拡張

ASP.NET Core や MagicOnion のように、フィルターによって実行前後を拡張できるようになりました。実装はConsoleAppFilterを継承して、await nextを実行するという非同期スタイルです。

public class MyFilter : ConsoleAppFilter
{
    // Filter is instantiated by DI so you can get parameter by constructor injection.
 
    public async override ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        try
        {
            /* on before */
            await next(context); // next
        }
        catch
        {
            /* on after */
            throw;
        }
        finally
        {
            /* on finally */
        }
    }
}
 
// ConsoleAppContext
public class ConsoleAppContext
{
    public string?[] Arguments { get; }
    public DateTime Timestamp { get; }
    public CancellationToken CancellationToken { get; }
    public ILogger<ConsoleAppEngine> Logger { get; }
    public MethodInfo MethodInfo { get; }
    public IServiceProvider ServiceProvider { get; }
    public IDictionary<string, object> Items { get; }
}

フィルターはグローバル(全てのメソッドで呼ばれる)、クラス、メソッド単位で付与することが可能です。

// フィルターの呼び出し順序はOrderで設定可能
await Host.CreateDefaultBuilder()
    .RunConsoleAppFrameworkAsync(args, options: new ConsoleAppOptions
    {
        GlobalFilters = new ConsoleAppFilter[] { new MyFilter2 { Order = -1 }, new MyFilter() }
    });
 
[ConsoleAppFilter(typeof(MyFilter3))]
public class MyBatch : ConsoleAppBase
{
    [ConsoleAppFilter(typeof(MyFilter4), Order = -9999)]
    [ConsoleAppFilter(typeof(MyFilter5), Order = 9999)]
    public void Do()
    {
    }
}

まとめ

シンプルさと機能性のバランスがうまくとれてるんじゃないでしょうか。すごく細かい調整ができるわけではないので、そこはどうしても割り切りという感じになってしまうのですが、それでもほとんどのユースケースは満たせているんじゃないかと思います。

自動コマンド定義は大量にバッチを量産する場合に便利、でもあるのですが、それと同時にC#のプロジェクト一つで大量のバッチを管理できるようになる、というのも利点です。ファイル単位で管理するとわけわからん、ということになりがちですが、これなら綺麗に整理されますし、ロジックのメソッド化などで共通化もできます。また、フィルターを活用することによっても前処理や後処理などの共通化をより推し進められるでしょう。

大きなプロジェクトの一部としてのバッチアプリの場合、ASP.NET Coreなどのコンフィグに定義されているDBのパスなどが、同じジェネリックホストなのでそのまま読み込めるのも楽になれるポイントです。ロガーのパフォーマンスが必要な場合は、 Cysharp/ZLoggerを使うと良いでしょう、ZLoggerも Microsoft.Extensions.Logging の上に構築されているので、ジェネリックホストが基盤になっているConsoleAppFrameworkではスムーズに使えます。

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.SetMinimumLevel(LogLevel.Trace);
        x.AddZLoggerConsole();
        x.AddZLoggerFile("fileName.log");
    })
    .RunConsoleAppFrameworkAsync(args);

と、いうわけでより強力になったConsoleAppFramework、是非使ってみてください。

async decoratorパターンによるUnityWebRequestの拡張とUniTaskによる応用的設計例

UniTask v2も2.0.30まで到達し、いい加減そろそろ安定したと言える頃合いです(ほんと!)。GitHub Star数も1000を超えて、準スタンダードとして安心して使ってもらえるレベルまで到達したと思うので、基盤部分から入れ込んで設計するとこんなことができますよ、という一例を出してみます。

UnityWebRequestはかなりプリミティブな代物で、そのまま使うよりかはある程度はアプリケーションに沿ったラッパーを被せることがほとんどなのではないかと思います。しかし、ライブラリ単体でアプリケーションの要求を全て満たそうとするとヘヴィになりすぎたり、というかそもそもアプリケーション固有の要求には絶対に答えられない。というわけで、理想的なラッパーというのは、それ自身が極力軽量で、拡張性を持たせたプラガブルな仕組みが用意されているものということになります。プラガブルな拡張性がないと、例えば基盤ライブラリ側で用意されたラッパーをアプリケーションで使う場合にうまく要件をあわせられなくて、Forkして直接改造しちゃう、という不毛な自体になったりします。

と、いうものを実現するにあたって、非同期リクエストにつきもののコールバックは非常に相性が悪い。コールバックの連鎖は、コード上でその場でネストしていくだけだったら数階層ネストしてもまぁまぁなんとかなりますが、プラガブルで複雑な組み合わせを実現しようとするとハンドリング不可能になります。

そこでasync/await。async/awaitならコンパイラの力に頼ることでそういうものができます!

async decoratorパターンという名前で紹介しますが、一般にはMiddlewareとして知られているものを実装します。ASP.NET Core、node.js(Express)やReactのMiddleware、PythonのWSGI、MagicOnionではFilterとして実装している、サーバーサイドではよく使われるデザインです。これは非常に強力なデザインパターンで、クライアント処理においても有用だと私は考えています。もし知らなければ絶対に覚えるべき……!

MagicOnionのフィルターの図を持ってくるとこんな感じで

メソッドが外から内側に包まれて呼ばれていきます。

await next(
    await next(
        await next()
    )
);

通常やりたいことってざっくり

  • ロギング
  • モック
  • タイムアウト処理
  • リクエスト前のヘッダー処理
  • リクエスト後のヘッダー処理
  • ステータスコードに応じた例外時処理
  • エラー時の処理(ポップアップ/リトライ/画面遷移)

といったことだと思われますが、この仕組みなら、これだけで全て実装できます……!

というわけで、実装例を見ていきましょう。

デコレーター例

まずは共通のインターフェイスとして以下のものを用意します。

public interface IAsyncDecorator
{
    UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}

なるほどわからん。RequestContext、ResponseContextがそれぞれリクエスト/レスポンスに必要なデータが詰まっている単純な入れ物ということで特に気にしないこととして、大事なのはFunc nextです。

とりあえず、単純な例としてヘッダーの前後で処理するなにかを。

public class SetupHeaderDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        context.RequestHeaders["x-app-timestamp"] = context.Timestamp.ToString();
        context.RequestHeaders["x-user-id"] = "132141411"; // どこかから持ってくる
        context.RequestHeaders["x-access-token"] = "fafafawfafewaea"; // どこかから持ってくる2
 
        var respsonse = await next(context, cancellationToken); // 次のメソッドが呼ばれる
 
        var nextToken = respsonse.ResponseHeaders["token"];
        UserProfile.Token = nextToken; // どこかにセットするということにする
 
        return respsonse;
    }
}

await next() によって連鎖しているデコレーターメソッドの内側に進んでいきます。つまり、その前に書けば前処理、後ろに書けば後処理になります。nextの定義がよくわからなくても、デコレーターを量産していくことは簡単です。そこが大事。そんなんでいいんです。

さて、async/awaitと統合されていることによって、try-catch-finallyも自然に書けます。例えばロギングを用意すると

public class LoggingDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            UnityEngine.Debug.Log("Start Network Request:" + context.Path);
 
            var response = await next(context, cancellationToken);
 
            UnityEngine.Debug.Log($"Complete Network Request: {context.Path} , Elapsed: {sw.Elapsed}, Size: {response.GetRawData().Length}");
 
            return response;
        }
        catch (Exception ex)
        {
            if (ex is OperationCanceledException)
            {
                UnityEngine.Debug.Log("Request Canceled:" + context.Path);
            }
            else if (ex is TimeoutException)
            {
                UnityEngine.Debug.Log("Request Timeout:" + context.Path);
            }
            else if (ex is UnityWebRequestException webex)
            {
                if (webex.IsHttpError)
                {
                    UnityEngine.Debug.Log($"Request HttpError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
                }
                else if (webex.IsNetworkError)
                {
                    UnityEngine.Debug.Log($"Request NetworkError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
                }
            }
            throw;
        }
        finally
        {
            /* log other */
        }
    }
}

また、処理を打ち切ることも簡単に実現できます。nextを呼ばないだけですから。例えばダミーのレスポンスを返す(テストに使ったり、サーバー側の実装が整わない間に進めたりするために)デコレーターが作れます。

public class MockDecorator : IAsyncDecorator
{
    Dictionary<string, object> mock;
 
    // Pathと型を1:1にして事前定義したオブジェクトを返す辞書を渡す
    public MockDecorator(Dictionary<string, object> mock)
    {
        this.mock = mock;
    }
 
    public UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        // それと if (EditorProfile.EnableMocking) とか用意しておいて、モック使うかの有無をエディタ拡張辺りで切り替えれるようにしとくと楽
        if (mock.TryGetValue(context.Path, out var value))
        {
            // 一致したものがあればそれを返す(実際の通信は行わない)
            return new UniTask<ResponseContext>(new ResponseContext(value));
        }
        else
        {
            return next(context, cancellationToken);
        }
    }
}

リトライ的な処理も考えてみましょう。例えば特殊なレスポンスコードを受信したときは、Tokenを取ってから再度処理し直してくれ、みたいな要求があるとします。

public class AppendTokenDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        string token = "token"; // どっかから取ってくるということにする
        RETRY:
        try
        {
            context.RequestHeaders["x-accesss-token"] = token;
            return await next(context, cancellationToken);
        }
        catch (UnityWebRequestException ex)
        {
            // 例えば700はTokenを再取得してください的な意味だったとする
            if (ex.ResponseCode == 700)
            {
                // 別口でTokenを取得します的な処理
                var newToken = await new NetworkClient(context.BasePath, context.Timeout).PostAsync<string>("/Auth/GetToken", "access_token", cancellationToken);
                context.Reset(this); // RequestContextの状態が汚れてる(?)ので、nextを最初からやり直す場合はResetする
                token = newToken;
                goto RETRY;
            }
 
            throw;
        }
    }
}

シーケンシャルな処理を強制するために、キューを挟む場合はこのように書けます。私は並列リクエストできるなら極力並列にしたい派なので、あまりこういうのを挟むのは好きではないのですけれど、サーバー側の要求によっては必要な場合もあると思います。

public class QueueRequestDecorator : IAsyncDecorator
{
    readonly Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)> q = new Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)>();
    bool running;
 
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        if (q.Count == 0)
        {
            return await next(context, cancellationToken);
        }
        else
        {
            var completionSource = new UniTaskCompletionSource<ResponseContext>();
            q.Enqueue((completionSource, context, cancellationToken, next));
            if (!running)
            {
                Run().Forget();
            }
            return await completionSource.Task;
        }
    }
 
    async UniTaskVoid Run()
    {
        running = true;
        try
        {
            while (q.Count != 0)
            {
                var (tcs, context, cancellationToken, next) = q.Dequeue();
                try
                {
                    var response = await next(context, cancellationToken);
                    tcs.TrySetResult(response);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }
        }
        finally
        {
            running = false;
        }
    }
}

簡単なものから結構複雑そうなものまで、そこそこ単純に書けることがわかったと思います!ただのawait nextという仕組みを用意するだけで!

用意したデコレーターはこんな風に使います。

// デコレーターの詰まったClientを生成(これは一度作ったらフィールドに保存可)
var client = new NetworkClient("http://localhost", TimeSpan.FromSeconds(10),
    new QueueRequestDecorator(),
    new LoggingDecorator(),
    new AppendTokenDecorator(),
    new SetupHeaderDecorator());
 
// 例えばこんな風に呼ぶということにする
var result = await client.PostAsync("/User/Register", new { Id = 100 });

async decoratorを実装する

ちょっと長くなりますが、そんな複雑なわけではありません。

// 基本のインターフェイス
public interface IAsyncDecorator
{
    UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}
 
// リクエスト用の入れ物
public class RequestContext
{
    int decoratorIndex;
    readonly IAsyncDecorator[] decorators;
    Dictionary<string, string> headers;
 
    public string BasePath { get; }
    public string Path { get; }
    public object Value { get; }
    public TimeSpan Timeout { get; }
    public DateTimeOffset Timestamp { get; private set; }
 
    public IDictionary<string, string> RequestHeaders
    {
        get
        {
            if (headers == null)
            {
                headers = new Dictionary<string, string>();
            }
            return headers;
        }
    }
 
    public RequestContext(string basePath, string path, object value, TimeSpan timeout, IAsyncDecorator[] filters)
    {
        this.decoratorIndex = -1;
        this.decorators = filters;
        this.BasePath = basePath;
        this.Path = path;
        this.Value = value;
        this.Timeout = timeout;
        this.Timestamp = DateTimeOffset.UtcNow;
    }
 
    internal Dictionary<string, string> GetRawHeaders() => headers;
    internal IAsyncDecorator GetNextDecorator() => decorators[++decoratorIndex];
 
    public void Reset(IAsyncDecorator currentFilter)
    {
        decoratorIndex = Array.IndexOf(decorators, currentFilter);
        if (headers != null)
        {
            headers.Clear();
        }
        Timestamp = DateTimeOffset.UtcNow;
    }
}
 
// レスポンス用の入れ物
public class ResponseContext
{
    readonly byte[] bytes;
 
    public long StatusCode { get; }
    public Dictionary<string, string> ResponseHeaders { get; }
 
    public ResponseContext(byte[] bytes, long statusCode, Dictionary<string, string> responseHeaders)
    {
        this.bytes = bytes;
        StatusCode = statusCode;
        ResponseHeaders = responseHeaders;
    }
 
    public byte[] GetRawData() => bytes;
 
    public T GetResponseAs<T>()
    {
        return JsonUtility.FromJson<T>(Encoding.UTF8.GetString(bytes));
    }
}
 
// 本体
public class NetworkClient : IAsyncDecorator
{
    readonly Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next;
    readonly IAsyncDecorator[] decorators;
    readonly TimeSpan timeout;
    readonly IProgress<float> progress;
    readonly string basePath;
 
    public NetworkClient(string basePath, TimeSpan timeout, params IAsyncDecorator[] decorators)
        : this(basePath, timeout, null, decorators)
    {
    }
 
    public NetworkClient(string basePath, TimeSpan timeout, IProgress<float> progress, params IAsyncDecorator[] decorators)
    {
        this.next = InvokeRecursive; // setup delegate
 
        this.basePath = basePath;
        this.timeout = timeout;
        this.progress = progress;
        this.decorators = new IAsyncDecorator[decorators.Length + 1];
        Array.Copy(decorators, this.decorators, decorators.Length);
        this.decorators[this.decorators.Length - 1] = this;
    }
 
    public async UniTask<T> PostAsync<T>(string path, T value, CancellationToken cancellationToken = default)
    {
        var request = new RequestContext(basePath, path, value, timeout, decorators);
        var response = await InvokeRecursive(request, cancellationToken);
        return response.GetResponseAs<T>();
    }
 
    UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
    {
        return context.GetNextDecorator().SendAsync(context, cancellationToken, next); // マジカル再帰処理
    }
 
    async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
    {
        // Postしか興味ないからPostにしかしないよ!
        // パフォーマンスを最大限にしたい場合はuploadHandler, downloadHandlerをカスタマイズすること
 
        // JSONでbodyに送るというパラメータで送るという雑設定。
        var data = JsonUtility.ToJson(context.Value);
        var formData = new Dictionary<string, string> { { "body", data } };
 
        using (var req = UnityWebRequest.Post(basePath + context.Path, formData))
        {
            var header = context.GetRawHeaders();
            if (header != null)
            {
                foreach (var item in header)
                {
                    req.SetRequestHeader(item.Key, item.Value);
                }
            }
 
            // Timeout処理はCancellationTokenSourceのCancelAfterSlim(UniTask拡張)を使ってサクッと処理
            var linkToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            linkToken.CancelAfterSlim(timeout);
            try
            {
                // 完了待ちや終了処理はUniTaskの拡張自体に丸投げ
                await req.SendWebRequest().ToUniTask(progress: progress, cancellationToken: linkToken.Token);
            }
            catch (OperationCanceledException)
            {
                // 元キャンセレーションソースがキャンセルしてなければTimeoutによるものと判定
                if (!cancellationToken.IsCancellationRequested)
                {
                    throw new TimeoutException();
                }
            }
            finally
            {
                // Timeoutに引っかからなかった場合にてるのでCancelAfterSlimの裏で回ってるループをこれで終わらせとく
                if (!linkToken.IsCancellationRequested)
                {
                    linkToken.Cancel();
                }
            }
 
            // UnityWebRequestを先にDisposeしちゃうので先に必要なものを取得しておく(性能的には無駄なのでパフォーマンスを最大限にしたい場合は更に一工夫を)
            return new ResponseContext(req.downloadHandler.data, req.responseCode, req.GetResponseHeaders());
        }
    }
}

コアの処理はInvokeRecursiveです。もう少し単純化すると

UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
{
    context.decoratorIndex++;
    return decorators[context.decoratorIndex].SendAsync(context, cancellationToken, InvokeRecursive);
}

というように、IAsyncDecorator[]を少しずつ進めています。nextに入っているのは、配列の次の要素ということで、実際パターンの実装としてはそれだけです。

また、NetworkClient自体がIAsyncDecoratorとなっていて、つまりnextを使わないものが最奥部の、最後の処理となるわけです。

async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
{
    // nextは使わず、ここで実際の通信処理を始める
}

今回はasync decoratorの紹介なので本体の処理は雑なんですが(とりあえずJsonシリアライズ/デシリアライズしたものを受け渡しするだけ、的な)、まぁ概ね雰囲気はわかると思うのでそれでいいでしょう。通常Pathとリクエスト/レスポンス型は1:1のはずなので(そうなってなければサーバー実装者を〆て1:1にさせましょう)、その辺のメソッドを自動生成しておくとかはよくあります。また、戻り値を複数めいたこと(ポリモーフィズム的な)のしたいんだよなあ、という場合にはMessagePack for C#のUnionという機能が使えるので、活用するといい感じになります。

面白要素としてはTimeoutの処理を CancellationTokenSource.CancelAfterSlim で行っているところでしょうか。TimeoutはWhenAnyを使って外側から処理するパターンもありますが、対象がCancellationTokenを受け取れる場合は、こっちのほうがより効率的で良いです。

タイトル画面に戻すなどダイアログとシーン遷移を組み合わせる

ネットワークリクエストに失敗した時って、なんかポップアップ出して 「エラーが発生しました タイトルに戻ります 「OK」」 みたいな画面が出てきますよね?それをやりましょうやりましょう。

public enum DialogResult
{
    Ok,
    Cancel
}
 
public static class MessageDialog
{
    public static async UniTask<DialogResult> ShowAsync(string message)
    {
        // (例えば)Prefabで作っておいたダイアログを生成する
        var view = await Resources.LoadAsync("Prefabs/Dialog");
 
        // Ok, Cancelボタンのどちらかが押されるのを待機
        return await (view as GameObject).GetComponent<MessageDialogView>().ClickResult;
    }
}
 
public class MessageDialogView : MonoBehaviour
{
    [SerializeField] Button okButton = default;
    [SerializeField] Button closeButton = default;
 
    UniTaskCompletionSource<DialogResult> taskCompletion;
 
    // これでどちらかが押されるまで無限に待つを表現
    public UniTask<DialogResult> ClickResult => taskCompletion.Task;
 
    private void Start()
    {
        taskCompletion = new UniTaskCompletionSource<DialogResult>();
 
        okButton.onClick.AddListener(() =>
        {
            taskCompletion.TrySetResult(DialogResult.Ok);
        });
 
        closeButton.onClick.AddListener(() =>
        {
            taskCompletion.TrySetResult(DialogResult.Cancel);
        });
    }
 
    // もしボタンが押されずに消滅した場合にネンノタメ。
    private void OnDestroy()
    {
        taskCompletion.TrySetResult(DialogResult.Cancel);
    }
}

UniTaskCompletionSourceを活用して、ボタンが押されるまで待機というのを表現できます。こういう使い方、めっちゃするので覚えましょう。UniTaskCompletionSourceめっちゃ大事。

では、これとasync decoratorを組み合わせていきます。

public class ReturnToTitleDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        try
        {
            return await next(context, cancellationToken);
        }
        catch (Exception ex)
        {
            if (ex is OperationCanceledException)
            {
                // キャンセルはきっと想定されている処理なのでそのまんまスルー(呼び出し側でOperationCanceledExceptionとして飛んでいく)
                throw;
            }
 
            if (ex is UnityWebRequestException uwe)
            {
                // ステータスコードを使って、タイトルに戻す例外です、とかリトライさせる例外です、とかハンドリングさせると便利
                // if (uwe.ResponseCode) { }...
            }
 
            // サーバー例外のMessageを直接出すなんて乱暴なことはデバッグ時だけですよ勿論。
            var result = await MessageDialog.ShowAsync(ex.Message);
 
            // OK か Cancelかで分岐するなら。今回はボタン一個、OKのみの想定なので無視
            // if (result == DialogResult.Ok) { }...
 
            // シーン呼び出しはawaitしないこと!awaitして正常終了しちゃうと、この通信の呼び出し元に処理が戻って続行してしまいます
            // のでForget。
            SceneManager.LoadSceneAsync("TitleScene").ToUniTask().Forget();
 
 
            // そしてOperationCanceledExceptionを投げて、この通信の呼び出し元の処理はキャンセル扱いにして終了させる
            throw new OperationCanceledException();
        }
    }
}

await使ってサクサク書いていけるので、道具が揃っていれば非同期処理とは思えないほど難なく書けます。

一つ注意なのは、呼び出し元に処理を戻すか戻さないか。普通にreturnすると処理が戻っていってしまいますが、Exceptionを再スローすればそれはそれでエラーとして出てしまってウザい。タイトル画面に戻すということは、その通信処理はキャンセルされたということなので、ここは処理がキャンセルされたとマークするのが正解です。asyncメソッドでキャンセル扱いするにはOperationCanceledExceptionを投げる必要があります。これは初見だと???という感じになると思いますが、そういうものなのでそういうものとして受け入れませう。

まとめ

UniTaskで道具を揃えたんだから、別に普通にばんばん書けるでしょ、便利に使ってね!ぐらいの気持ちでいたのであんまり応用例みたいなのの発信をしてこなかったんですが、よくよく考えると別にそんなことないよね……。ということにやっと気づいたので、色々盛りだくさんで紹介してみましたがどうでしょう。

最初はコールバックに毛が生えたもの程度でもいいとは思いますが、それだけじゃあ勿体ないわけです。せっかく言語機能として用意されているので、コールバックでは実現不可能なもう一段階上の設計が狙えるので、コールバックのことは忘れて使いこなしていって欲しいですね。

キャンセル処理に癖があるのは事実ですが(実際、最後に書いた明示的にOperationCanceledExceptionを投げよう、とかは一から発想していくのは難しいかもしれません)、「引数の最後に渡す」「明示的に投げてもいい」の二点だけなので、これは慣れるしかないし、それを理由にして利用範囲を限定的にするのはよくないかなー、と思ってます。

まぁ、ようするに普通に使ってね!便利ですよ実際!ということで。

ライブラリ作成のすゝめ - 40以上のOSS作成事例から見る個人OSSによる効能とキャリアの開発

ライブラリ作成のすゝめ - 事例から見る個人OSS開発の効能 from Yoshifumi Kawai

去年に専門学校の学生さん向けに講演した資料で、それ以外には未発表のスライドです。デベロッパーのキャリアとしてのエモい話になっているのでデブサミ向けにいいかな、と思って公募したところ落ちた!(←微妙にショックだった)のでずっとお蔵入りで眠っていたのですが、このご時世ですし他で講演できるところもなさそうなので、ここで放出することにしました。

作ることが能力の向上に繋がり、キャリアにも繋がっていく。別にそれだけが唯一解ではないけれど、一つの道筋として力になれたらな、と思っています。

大量に作るというのは、いや、大量ではなくても、メンテナンスが回るわけじゃないから大変だったり、時に無責任に見えてしまう(そういうわけではないけれど大変なのです!ごめんなさい!)とか、Issueに埋もれてシンドイとか、そういう負の側面も色々あるのですけれど、それでもね、やっていくのはいいことだと思います。そしてやるからには、一つ一つには真剣に取り組むことが、大きなリターンを得るための秘訣かな、と。

GitHub ActionsでUnityでunitypackage生成とビルド&実機(Linux)ユニットテストを実行する

以前にCircleCIでUnityをテスト/ビルドする、或いは.unitypackageを作るまで、それとCIや実機でUnityのユニットテストを実行してSlackに通知するなどするという記事を書いたのですが、時代はGitHub Actionsということで、私も全体的にCircleCIからGitHub Actionsに移行を始めてまして、それに伴ってビルドスクリプトも最新化したので、紹介します。コンフィグ作成にあたっては【Unity】GitHub Actions v2でUnity Test Runnerを走らせて、結果をSlackに報告する【入門】UnityをGitHub Actionsで動かす際にライセンス認証周りで注意するべき点も参考にしました。

実際のコンフィグは ZLogger/.github/workflows にありますが、Unityの部分だけ取り出して実行可能な形式にすると

name: Build-Debug

on:
  push:
    branches:
      - "**"
    tags:
      - "!*" # not a tag push
  pull_request:
    types:
      - opened
      - synchronize

jobs:
  build-unity:
    strategy:
      matrix:
        unity: ['2019.3.9f1', '2020.1.0b5']
        include:
          - unity: 2019.3.9f1
            license: UNITY_2019_3
          - unity: 2020.1.0b5
            license: UNITY_2020_1
    runs-on: ubuntu-latest
    container:
      # with linux-il2cpp. image from https://hub.docker.com/r/gableroux/unity3d/tags
      image: gableroux/unity3d:${{ matrix.unity }}-linux-il2cpp
    steps:
      - run: apt update && apt install git -y
      - uses: actions/checkout@v2
      # create unity activation file and store to artifacts.
      - run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -logFile -createManualActivationFile || exit 0
      - uses: actions/upload-artifact@v1
        with:
          name: Unity_v${{ matrix.unity }}.alf
          path: ./Unity_v${{ matrix.unity }}.alf
      # activate Unity from manual license file(ulf)
      - run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
        env:
          UNITY_LICENSE: ${{ secrets[matrix.license] }}
      - name: Activate Unity, always returns a success. But if a subsequent run fails, the activation may have failed(if succeeded, shows `Next license update check is after` and not shows other message(like GUID != GUID). If fails not). In that case, upload the artifact's .alf file to https://license.unity3d.com/manual to get the .ulf file and set it to secrets.
        run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0
 
      # Execute scripts: RuntimeUnitTestToolkit
      - name: Build UnitTest(Linux64, mono)
        run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64
        working-directory: src/ZLogger.Unity
      - name: Execute UnitTest
        run: ./src/ZLogger.Unity/bin/UnitTest/StandaloneLinux64_Mono2x/test
 
      # Execute scripts: Export Package
      - name: Export unitypackage
        run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
        working-directory: src/ZLogger.Unity
 
      # Store artifacts.      
      - uses: actions/upload-artifact@v1
        with:
          name: ZLogger.Unity.unitypackage
          path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage

となっています。微妙に長いね!(ショボいシンタックスハイライターの影響でインデントが腐ってて読みづらいのでworkflows/build-debug.ymlを見ていただいたほうがいいです)

さて、とりあえずUnityにおいての第一関門は認証を通すことなのですが、ここは -createManualActivationFile して -manualLicenFile に投げる、というやり方を採用します(他にも幾つかやり方はある)。Unityのインストール等に関しては、インストール済みのコンテナイメージを使って、コンテナでビルドします。使用できるイメージ一覧は DockerHub - gableroux/unity3d/tagsから選べますが、ここではIL2CPPビルドが実行できると謳ってる、かつ最新の2019.3.9f1-linux-il2cppと2020.1.0b5-linux-il2cppを使うことにしました。マトリックスビルドかけるなら、もっと古いのあたりも入れたほうがいいといえばいいんですが、LinuxでIL2CPPビルド可能なのは2019.3からなのでshoganai。

matrix組むのは、特にアセット作っている人にとっては重要で、というのも新しいUnityサポートしたら古いUnityで使えないAPIを使っちゃってビルドエラーとか、たまによくやるんですよね、うっかり。if-def囲み忘れとか、逆に囲みすぎとか。というわけで、可能なら最低サポートバージョンから、マイナーバージョン毎ぐらいに組むのがいいと思います。そういう意味では、私は今のところ2018.3を最低サポートバージョンにしているので、2018.3, 2018.4もmatrixに組んだほうがいいのですがIL2CPPビルドのために以下略。

さて、認証ですが、この辺で処理しています。

# create unity activation file and store to artifacts.
- run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -logFile -createManualActivationFile || exit 0
- uses: actions/upload-artifact@v1
    with:
      name: Unity_v${{ matrix.unity }}.alf
      path: ./Unity_v${{ matrix.unity }}.alf
# activate Unity from manual license file(ulf)
- run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
    env:
      UNITY_LICENSE: ${{ secrets[matrix.license] }}
- name: Activate Unity, always returns a success. But if a subsequent run fails, the activation may have failed(if succeeded, shows `Next license update check is after` and not shows other message(like GUID != GUID). If fails not). In that case, upload the artifact's .alf file to https://license.unity3d.com/manual to get the .ulf file and set it to secrets.
  run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0

基本的に認証の流れは .alf を作る -> .alf を https://license.unity3d.com/manual にアップロードして .ulf ファイルをダウンロード。そのulfファイル(中身はXMLテキスト)をGitHub ActionsのSettings -> Secretsに設定する、ということなのですが、.alfを手元で作るのは面倒なのでCIに作ってもらって、artifactsにあげてます。

初回実行時は.ulfがないので絶対に後続の実行は失敗します(Activate処理だけはエラーにならないので、その先で認証できなかったといってコケます)。ので、ActionsのArtifactsのところからalfをダウンロードして、.ulfを作ります。それをテキストエディタで開いてSecretsのところに適切な名前で保存すればOK。名前との関連付けは

matrix:
unity: ['2019.3.9f1', '2020.1.0b5']
include:
    - unity: 2019.3.9f1
      license: UNITY_2019_3
    - unity: 2020.1.0b5
      license: UNITY_2020_1

で設定してますが、ここでは2019.3.9f1用はUNITY_2019_3, 2020.1.0b5用はUNITY_2020_1にしました。2019_3といいつつ、コンテナイメージ毎に新しい認証ファイルがいるので、2019.3.10f1に変えたらSecretは設定しなおしです。面倒くさい。shoganai。

ユニットテストの実行はCysharp/RuntimeUnitTestToolkitを使用します。これはUnity Test Runnerで書いたユニットテストからCUI/GUIでの実行シーンをビルド時に動的に生成するもので、まぁまぁ便利です。特にCUIでのテスト実行はCI用ですね、結果をそのまま出力で見れたり、エラーがあったらそのままエラーにしてくれたりするので非常に楽。それ以外に私はエディタからWindowsでのIL2CPPビルド実行を多用しています(よくIL2CPPで引っかかるライブラリを作っているので)

設定は -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64 といったものを書けばOK。そうすると bin/UnitTest/StandaloneLinux64_Mono2x/test に成果物ができてるので、すぐに実行すれば、CIでのテスト実行ということになります。

# Execute scripts: RuntimeUnitTestToolkit
- name: Build UnitTest(Linux64, mono)
    run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64
    working-directory: src/ZLogger.Unity
- name: Execute UnitTest
    run: ./src/ZLogger.Unity/bin/UnitTest/StandaloneLinux64_Mono2x/test

ここで /ScriptBackend mono/ScriptBackend IL2CPP にするとIL2CPPビルドになるので、やったーCIでIL2CPPのテストができるぞー!と思ったんですが、現在のコンテナイメージだとなんか謎エラーでビルドに失敗するので、一旦は諦めました。誰か成功させてください。何かとIL2CPPで引っかかるライブラリを作ってるので、できればここでテストしたいんですけどねえ。

最後に.unitypackageの作成ですが、これはリポジトリに仕込んである生成メソッドをキックして、artifactsにアップロードします。

# Execute scripts: Export Package
- name: Export unitypackage
    run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
    working-directory: src/ZLogger.Unity
 
# Store artifacts.      
- uses: actions/upload-artifact@v1
    with:
    name: ZLogger.Unity.unitypackage
    path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
 
public static class PackageExporter
{
    [MenuItem("Tools/Export Unitypackage")]
    public static void Export()
    {
        var version = Environment.GetEnvironmentVariable("UNITY_PACKAGE_VERSION");
 
        // configure
        var root = "Scripts/ZLogger";
        var fileName = string.IsNullOrEmpty(version) ? "ZLogger.Unity.unitypackage" : $"ZLogger.Unity.{version}.unitypackage";
        var exportPath = "./" + fileName;
 
        var path = Path.Combine(Application.dataPath, root);
        var assets = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
            .Where(x => Path.GetExtension(x) == ".cs" || Path.GetExtension(x) == ".meta" || Path.GetExtension(x) == ".asmdef")
            .Where(x => Path.GetFileNameWithoutExtension(x) != "_InternalVisibleTo")
            .Select(x => "Assets" + x.Replace(Application.dataPath, "").Replace(@"\", "/"))
            .ToArray();
 
        UnityEngine.Debug.Log("Export below files" + Environment.NewLine + string.Join(Environment.NewLine, assets));
 
        var dir = new FileInfo(exportPath).Directory;
        if (!dir.Exists) dir.Create();
        AssetDatabase.ExportPackage(
            assets,
            exportPath,
            ExportPackageOptions.Default);
 
        UnityEngine.Debug.Log("Export complete: " + Path.GetFullPath(exportPath));
    }
}

と、まぁこれでいい感じに?テストとパッケージ生成ができるようになりました!

なお、リリースビルドは別ワークフローのymlになっているので、マトリックスビルドとalfの処理とテストを省いてます(本当はマトリックスもテストもしたほうがよくて、全部通ったらartifact生成とかにしたほうがいいんですが、まぁ多少ザルでもいいでしょう)。

  build-unity:
    strategy:
      matrix:
        unity: ['2019.3.9f1']
        include:
          - unity: 2019.3.9f1
            license: UNITY_2019_3
    runs-on: ubuntu-latest
    container:
      # with linux-il2cpp. image from https://hub.docker.com/r/gableroux/unity3d/tags
      image: gableroux/unity3d:${{ matrix.unity }}-linux-il2cpp
    steps:
    - run: apt update && apt install git -y
    - uses: actions/checkout@v2
    - run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
      env:
        UNITY_LICENSE: ${{ secrets[matrix.license] }}
    - run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0
 
    # Execute scripts: Export Package
    - name: Export unitypackage
      run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
      working-directory: src/ZLogger.Unity
 
    # Store artifacts.
    - uses: actions/upload-artifact@v1
      with:
        name: ZLogger.Unity.unitypackage
        path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage

まとめ

GitHub Actions、最初は抵抗感あったんですが、というかCIの設定というのが、かなり嫌いな種類のエンジニアリングなので、一度覚えてコピペで済ませてるCircleCIから引っ越すのに腰が重かったんですが、まぁやってみればなんとかなる(社内でわちゃくちゃと手伝ってもらったお陰でもありますが)し、やってみれば、割といいんじゃないのかな、と思えるようになりました。CircleCIもいいんですが、最近のUI変更などがイケてない感じだったりで好感度下がっていたので、ぎっはぶActions、いいんじゃないでしょーか。

とりあえずDOOM Eternalが超絶面白いのでやっておくといいです。YouTube - Doom Eternal - Cultist Base Master LevelがDoom Eternalの圧倒的なスピード感と暴力を表現しててとてもいいので、ぜひ見て買ってくださいな。今年のGame of the Yearなので。久しぶりにゲーム超面白い……!と思った。ただたんに(クラシックなFPS的に)スピード速くするだけじゃこうならないんですよねえ(実際、前作のDOOM(2016)をEternal後にやると、あまり面白く感じない)、近年ではバトルロイヤルも発明でしたが、DOOM EternalもFPSを、ゲームを進化させるゲームデザインの発明ですね、とにかく良い。めっちゃ良い。

ProcessX - C#でProcessを C# 8.0非同期ストリームで簡単に扱うライブラリ

C#使う人って全然外部プロセス呼び出して処理ってしないよね。というのは、Windowsがなんかそういうのを避ける雰囲気だから、というのもあるのですが、ともあれ実際、可能な限り避けるどころか絶対避ける、ぐらいの勢いがあります。ライブラリになってないと嫌だ、断固拒否、みたいな。しかし最近はLinuxでもばっちし動くのでそういう傾向もどうかなー、と思いつつ。

避けるというのはOSの違いというのもありそうですが、もう一つはそもそも外部プロセスの呼び出しが死ぬほど面倒くさい。ProcessとProcessStartInfoを使ってどうこうするのですが、異常に面倒くさい。理想的にはシェルで書くように一行でコマンドと引数繋げたstringを投げておしまい、と行きたいのですが、全然そうなってない。呼び出すだけでも面倒くさいうぇに、StdOutのリダイレクトとかをやると更に面倒くさい。非同期でStdOutを読み込むとかすると絶望的に面倒くさい、うえに罠だらけでヤバい。この辺の辛さは非同期外部プロセス起動で標準出力を受け取る際の注意点という記事でしっかり紹介されてますが、実際これを正しくハンドリングするのは難儀です。

そこで「シェルを書くように文字列一行投げるだけで結果を」「C# 8.0の非同期ストリームで、こちらも一行await foreachするだけで受け取れて」「ExitCodeやStdErrorなども適切にハンドリングする」ライブラリを作りました。

using Cysharp.Diagnostics; // using namespace
 
// async iterate.
await foreach (string item in ProcessX.StartAsync("dotnet --info"))
{
    Console.WriteLine(item);
}

というように、 await foreach(... in ProcessX.StartAsync(command)) だけで済みます。普通にProcessで書くと30行ぐらいかかってしまう処理がたった一行で!革命的便利さ!C# 8.0万歳!

実際これ、普通のProcessで書くと中々のコード量になります。

var pi = new ProcessStartInfo
{
    // FileNameとArgumentsが別れる(地味にダルい)
    FileName = "dotnet",
    Arguments = "--info",
    // からの怒涛のboolフラグ
    UseShellExecute = false,
    CreateNoWindow = true,
    ErrorDialog = false,
    RedirectStandardError = true,
    RedirectStandardOutput = true,
};
 
using (var process = new Process()
{
    StartInfo = pi,
    // からのここにもboolフラグ
    EnableRaisingEvents = true
})
{
    process.OutputDataReceived += (sender, e) =>
    {
        // nullが終端なのでnullは来ます!のハンドリングは必須
        if (e.Data != null)
        {
            Console.WriteLine(e.Data);
        }
    };
 
    process.Exited += (sender, e) =>
    {
        // ExitCode使ってなにかやるなら
        // ちなみにExitedが呼ばれてもまだOutputDataReceivedが消化中の場合が多い
        // そのため、Exitと一緒にハンドリングするなら適切な待受コードがここに必要になる
    };
 
    process.Start();
 
    // 何故かStart後に明示的にこれを呼ぶ必要がある
    process.BeginOutputReadLine();
 
    // processがDisposeした後にProcess関連のものを触ると死
    // そして↑のようにイベント購読しているので気をつけると触ってしまうので気をつけよう
    // ↓でもWaitForExitしちゃったら結局は同期じゃん、ということで真の非同期にするには更にここから工夫が必要
    process.WaitForExit();
}

↑のものでも少し簡略化しているぐらいなので、それぞれより正確にハンドリングしようとすると相当、厳しい、です……。

さて、await foreachの良いところは例外処理にtry-catchがそのまま使えること、というわけで、ExitCodeが0以外(或いはStdErrorを受信した場合)には、ProcessErrorExceptionが飛んでくるという仕様になっています。

try
{
    await foreach (var item in ProcessX.StartAsync("dotnet --foo --bar")) { }
}
catch (ProcessErrorException ex)
{
    // int .ExitCode
    // string[] .ErrorOutput
    Console.WriteLine(ex.ToString());
}

WaitForExit的に、同期待ちして結果を全部取りたいという場合は、ToTaskが使えます。

// receive buffered result(similar as WaitForExit).
string[] result = await ProcessX.StartAsync("dotnet --info").ToTask();

キャンセルに関しては非同期ストリームのWithCancellationがそのまま使えます。キャンセル時にプロセスが残っている場合はキャンセルと共にKillして確実に殺します。

await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cancellationToken))
{
    Console.WriteLine(item);
}

タイムアウトは、CancellationTokenSource自体が時間と共に発火というオプションがあるので、それを使えます。

using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
    await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cts.Token))
    {
        Console.WriteLine(item);
    }
}

また、ProcessX.StartAsyncのオーバーロードとして、作業ディレクトリや環境変数、エンコードを設定できるものも用意しているので、ほとんどのことは問題なく実装できるはずです。

StartAsync(string command, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(ProcessStartInfo processStartInfo)
 
Task<string[]> ToTask(CancellationToken cancellationToken = default)

System.Threading.Channels

今回、ProcessのイベントとAsyncEnumeratorとのデータの橋渡しにはSystem.Threading.Channelsを使っています。詳しくはAn Introduction to System.Threading.Channels、或いは日本語だとSystem.Threading.Channelsを使うを読むと良いでしょう。

プロデューサー・コンシューマーパターンのためのライブラリなのですが、めっちゃ便利です。シンプルなインターフェイス(ちょっと弄ってれば使い方を理解できる)かつasync/awaitビリティがめっちゃ高い設計になっていて、今まで書きづらかったものがサクッと書けるようになりました。

これはめちゃくちゃ良いライブラリなのでみんな使いましょう。同作者による System.Threading.Tasks.DataFlow(TPL Dataflow) は全く好きじゃなくて全然使うことはなかったのですが、Channelsは良い。めっちゃ使う。

まとめ

Process自体は、C# 1.0世代(10年前!)に設計されたライブラリなのでしょうがないという側面もありつつも、やはり現代は現代なので、ちゃんと現代流に設計したものを再度提供してあげる価値はあるでしょう。設計技法も言語自体も、遥かに進化しているので、ちゃんとしたアップデートは必要です。

こうした隙間産業だけど、C#に今までなくて面倒で、でもそれが一気に解消して超絶便利に、というのはConsoleAppFrameworkに通じるものがあります。C#の面倒と思えるところを片っ端から潰して超絶便利言語にしていく、ことを目指して引き続きどしどし開発していきます。というわけで是非使ってみてください。

Unityによるリアルタイム通信とMagicOnionによるC#大統一理論の実現 - フォローアップ

先週の土曜日にUnity道場 京都スペシャル4というイベントで登壇してきました。関西にはめったに行かないので、良い機会を頂いて感謝です。参加者応募も231名、場所もかなり大きなホールでいい感じでした。また、主催されたクラウドクリエイティブスタジオさんはサーバー開発もC#でしてる企業さんでもありますね……!すばらすばら。

【Unity道場京都スペシャル4】Unityによるリアルタイム通信とMagicOnionによるC#大統一理論の実現 from UnityTechnologiesJapan002

動画もUnity Learning Material(YouTube)に公開されています。

Unity……?まぁ、Unity、です、ええ。どちらかというと、MagicOnionが何を目指しているのか、みたいなところを説明できたんじゃないかなー、と思ってます。色々、思っているところを入れました。

このスライドを踏まえて、更に今後考えていること、というか「ハードコアを緩和する」というのが当分のテーマなのですが、なにやるか、というと……RPC以外の便利コンポーネントを作る、という意味ではありません。

あまり便利コンポーネントには関心がなくて、というのもどうせ無駄ばっかでパフォーマンスでないから使わん、とかになるんで、それだといらないなー、と。何のかんので私は割と理想主義者なので、良いものを作るための道具を提供したい、という思いがあります。性能の追求とかもその一環ですよね。というわけで、そこに反するものはちょっとねー、と。そこはアプリケーション実装側の責務だと思うので、自分で作り込んで欲しい……!

導入のヘヴィさやインフラ側は緩和していきたいです。特に、現在ネックになっているのがネイティブgRPCなので、それを引っ剥がしたいと思ってます。これを引っ剥がすと、つまり私の方で提供するPure C#なHTTP/2, gRPC実装に置き換えることでクライアント側は完全にプラットフォームフリー!サイズも低減!依存も消滅!そして完全なチューニングが可能になる!サーバー側はMicrosoft実装の ASP.NET Coreによるgrpc-dotnetベースに置き換えます。そうすると、実は通信層が自由に置き換えられるようになるので、TCPだけじゃなくてQUIC(これは実際、MicrosoftがExperimentalな実装をやってる最中なのでそれをすぐ投下できる)や、RUDPとかを入れ込むこともできます。

インフラ周りは、特にKuberenetes + Dedicated Server的に使うと、プラクティスがなさすぎて死にます。これはAgonesというGoogleの開発しているKuberenetes用のミドルウェアで解決すると思ってるんですが、現状だとまだ厳しいんですねー。というわけでAgonesにIssue立てたりもしてるんですが、さてはて。というわけでまだもう少し大変です。

それとアーキテクチャ的に、まずはRPCになってるのですが、これをサーバーループによる駆動に変換するためのブリッジ層を作り込みたいかなあ、と。現状でも自作すればできる状態なんですが、このぐらいは標準で用意してあげたほうがすわりがよさそうだ、と。

理想的な状態までの絵図は描けていますし、かなりいいところまでは来てると思ってます。ので、あともう一歩強化できれば、というところなのでやっていきます、はい。

ConsoleAppFramework - .NET Coreコンソールアプリ作成のためのマイクロフレームワーク(旧MicroBatchFramework)

以前にMicroBatchFramework - クラウドネイティブ時代のC#バッチフレームワークという名前でリリースしていたライブラリですが、リブランディング、ということかでConsoleAppFrameworkに変更しました。それに伴い名前変更による多数の破壊的変更と、全体の挙動の調整を行っています。

当初の想定ではバッチ、特に機能紹介にあるMulti Batchをメイン機能と捉えて作っていたのですが、最終的には汎用的なコンソールアプリケーション用のフレームワークとして出来上がっていたので、より適正な名前にすることで、多くの人に正しく捉えてもらって、届けられるのではないかと思い、今回の変更に至りました。

といったように、 Microsoft.Extensions の仕組みに乗ってLogging, Configuration, DIなどをカバーしつつ、CLI用にパラメーターバインディング、メソッドルーティング、ライフサイクル管理を乗っけているのがConsoleAppFrameworkの意義となります。一度使ってもらえば、もう素のConsoleAppを作ることはなくなります、というぐらいには便利なのではないかと……!

同様のコンセプトとしては、PHPではLaravel Zeroという、Micro-framework for console applicationsがあります。Laravelのロギングやコンフィグレーションを共有しつつも、コマンドラインアプリケーションで使いやすいような処理が施されています。Microsoftによる実装ではdotnet/command-line-apiの System.CommandLine.Hosting + System.CommandLine.DragonFruit が近い機能を持っていますが、ConsoleAppFrameworkのほうがよりプロダクティビティが高いです。というかMSのはダメです。こういうの作るのにMicrosoftはセンスないんですよねー。

あらためてConsoleAppFrameworkの単純な例ですが

using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;
 
// Entrypoint, create from the .NET Core Console App.
class Program : ConsoleAppBase // inherit ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        // target T as ConsoleAppBase.
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }
 
    // allows void/Task return type, parameter is automatically binded from string[] args.
    public void Run(string name, int repeat = 3)
    {
        for (int i = 0; i < repeat; i++)
        {
            Console.WriteLine($"Hello My ConsoleApp from {name}");
        }
    }
}
 
> SampleApp.exe -name "foo" -repeat 5.

といったように、Mainに毛が生えた程度の記述だけで、気の利いたコマンドラインアプリケーションが作れます。今回からヘルプのフォーマットに気合いを入れているので、

public void Run(
    [Option("n", "name of send user.")]string name, 
    [Option("r", "repeat count.")]int repeat = 3)
{
    // ...
}

といったようにいい感じのショートカットと説明を属性で追加してあげると、

> SampleApp.exe help
Usage: SampleApp [options...]
 
Options:
  -n, -name <String>     name of send user. (Required)
  -r, -repeat <Int32>    repeat count. (Default: 3)

いい感じのhelpが表示されるようになりました。ちなみにこれは dotnet コマンドのフォーマットに近いものです。

.NET Core 3.0からはランタイム不要での単一ファイルのバイナリ作成がやっとできるようになったので、配布もよりやりやすくなりました。また、パッケージマネージャー経由での .NET Core Global Toolsという仕組み(.NET Core 2.1から)や、プロジェクト単位で設定してバージョン固定などがやりやすい.NET Core Local Tools(.NET Core 3.0から)といった仕組みも整備されているので、かなりいけてます。

また、ConsoleAppFrameworkの持つ複数のバッチ(コマンド/メソッド)を単一アプリケーションで管理する実行可能にする機能は、プロジェクト固有のバッチ(大量にあるはず!)やインフラ管理スクリプトなどを一本化して、CIなどではgit pull後に dotnet run command で済ませられたりするなどは、実際私自身も有用に使っています。

リブランディングについて

MicroBatchFramework、正直なところもう少しウケてもいいと思ってたんですが、あんま伸びなかったんですよねー。コンセプトは良いはずだし実際機能的にもいいのになんでー?と思ったんですが、ようは”Cloud Native Batch Framework” というのが全然ピンときてないんですよねー。Cloud Nativeとか言っておけば喰い付くだろうとかいう安易なネーミングがダメ。あとBatchってのがやっぱダメだよね。バッチ。バッチって。

というわけで、ずっと気になってたんで、結果、今回の名前変えたのは本質をより表していていいんじゃないかなー、と思いますがどうでしょう?ReadMeも全体的に見直して、ウケる雰囲気になったと思うので、これでもう一発逆転狙いたいです(?)

それと、こういう名前変えるみたいなのも決断の一種なわけですが、名前を変えること自体は誰でもできるし、変えた名前も安易で誰でも決めれるわけですし、実際に変えてみるとピタッとピースがはまったように見える。けれど、じゃあいざ変えましょう、と踏み出すのはとてもむずかしい。というわけで、あ、シャッチョさん仕事したな、みたいな気になりました。まる。

MessagePack for C# v2によるC#における最新のI/Oパイプライン最適化

MessagePack for C#のVersion 2を本日リリースしました。出る出る詐欺で、一年がかりでリリースまで漕ぎ着けました!とにかくめっちゃ時間かかった、死ぬほど私のリソースが取られていた、ので本当にリリースまで持ってこれてよかった……。めでたし。

今回はとてもOSSっぽく開発していて、メインの開発はMicrosoftのVisual StudioチームのPrincipal Software EngineerであるAndrew Arnottさんが書いています。私はそれに対してひたすら、APIデザインが好きじゃないだの、パフォーマンスが私の基準に満たしてないだの、文句つけまくる仕事をしていました。しかしコードのクオリティはさすがに非常に高くて、私だけだったらここには至れなかっただろうことを考えると、いい感じの共同開発ができたんじゃないかなあと思います。その結果として、一年前に掲示されたv2よりも、百億倍良くなってます。この一年間で磨きに磨き抜いたわけです。

最初のリリースから2年半が経ち、MessagePack for C#は今では ASP.NET のリポジトリに含まれていたり、Visual Studio内部で使われていたりと、もはや準標準バイナリシリアライザの地位を得ていたりします。さすがにここまで成長するとは想像してなかった。.NETに貢献し過ぎで偉い。Version 1はシリアライザのパフォーマンスの基準を大きく塗り替えて、新しい世代の水準を作り出したという偉業があったわけですが、今回のv2も大きな成果を出せると思っています。v2はI/Oパイプライン全体の最適化を見据えて、パイプラインの心臓部として正しく機能するためにはどうあるべきか、というのを指し示しました。今後のC#のアプリケーションのアーキテクチャは、ここで指し示した道に進んでいくことでしょう。

v1 -> v2においては破壊的変更多数なので、移行ガイドをmigration.mdにまとめてあるので、適当に読んでおくと良いでしょう、詳しい解説は特にしません。ライブラリ類は一斉にバージョン上げないと詰みます。Cysharpで作っているMagicOnionMasterMemoryは作業中なので、来週にはドバッと上げておきます多分予定。

パイプラインによるゼロコピー

MessagePack for C#の内部構造について、見るべきメソッドシグネチャは以下の2つです。

public static void Serialize<T>(IBufferWriter<byte> writer, T value, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)
public static T Deserialize<T>(in ReadOnlySequence<byte> byteSequence, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)

シリアライズにおいては IBufferWriter<byte>, デシリアライズにおいては ReadOnlySequence<byte> を入出力口に使うというのがポイントです。 どちらもSystem.Buffersに定義されている .NET Standard 2.1 世代のインターフェイスです。反面、v1ではともに byte[] ベースでした。

I/Oのパイプラインってなんぞや、というと、ようするに入出力へのbyte[]をどう扱うかということであり、C#的には入り口も出口も最終的にはネイティブがやり取りするので、その手前のもの(SocketAsyncEventArgs, ConslePal+Stream, FileStream, etc…)、を呼び出して処理するフレームワーク、が呼び出すシリアライザ。といった流れになっています。シリアライザは常に中心(Object -> byte[]変換)にいます。上の画像は一般的なやり取りの場合(あるいはv1の場合)で、RedisValue(StackExchange.Redis)がbyte[]を、ByteArrayContent(HttpClient)がbyte[]を、といったように、割と素のbyte[]を求められる局面は多い。その場合、シリアライザはnew byte[]した結果を返すことになり、それはフレームワークの処理を得て、入出力の源流へ再度コピーされ(されないこともある)ます。

ここにおける無駄は、byte[]のアロケーションと、コピーです。

と、いうわけで、byte[]のアロケーションを避けるパターンとして、シリアライザは作業領域として外部のバッファープール( System.Buffers で定義されたArrayPool<byte>は .NET Core 3.0ではクラスライブラリ内でも多用されています)を使用し、フレームワークから提供されるStreamに書き込むことで、必要なコストをコピーだけにする手法があります。v1でも一部実装していました。なお、Streamに対して直接Writeすることで自前バッファを使わないという手も理論上可能ですが、Writeによるオーバーヘッドが多いため性能が悪化します。また、どちらにせよStream内部でバッファを持っている場合もあります。さらに、非同期にも対応できませんし、では全てをWriteAsyncで処理すれば、更にオーバーヘッドが多くて全く性能がでません。つまり、性能の良いアプリケーションを作るには、バッファをどう扱うが大事です。v1の設計思想として、シリアライズの一単位をバッファとして取り扱えば良い、だから全てをbyte[]ベースで処理し、Streamへは一気に書き込めば良い。という指針がありました。そして、それは実際正しく機能して、当時存在したあらゆるシリアライザを引き離す性能を叩き出しました。

IBufferWriter<byte>を活用すると、作業に必要なバッファを元ソースに対して直接要求することができます。それによりバッファ管理を完全に元ソースに任せることができるため、シリアライザ内部の作業バッファからのコピーコストが消滅します。例えばソケット通信で使われるSocketAsyncEventArgsは通常使いまわされますが、それの持つ(byte[] Buffer)に直接書き込む、といったようなことが可能です。

Streamに対してはSystem.IO.Pipleinesの提供するPipeWriterIBufferWriter<byte>を実装し、最適なバッファ管理を代替してくれます。

ASP.NET Core 3.0からは従来の(Stream HttpResponse.Body)だけでなく、(PipeWriter HttpResponse.BodyWriter)も提供されるようになりました。MessagePack.AspNetCoreMvcFormatterは、.NETCoreApp 3.0の場合にはBodyWriterに対してシリアライズする実装を用意しています。

現在の.NETのフレームワークは、Streamを要求するものか、あるいはbyte[]を要求するものがほとんどです。しかし、フレームワークレベルでのIBufferWriter対応が進んでいけば、よりMessagePack for C# v2の真価が発揮されていくことでしょう。もちろん、byte[]を返すAPI(byte[] Serialize<T>(T value))でも、最適なバッファ管理によってアロケーションやコピーコストを抑えるようになっています。

理論とパフォーマンス

多くある誤解として、async/awaitにしたら速くなるわけでもないし、Span<byte>にしたから速くなるわけでもありません。そして、IBufferWriter<byte>ReadOnlySequence<byte>にしても速くなるわけではありません。理屈上コピーが減ったとしても、遅くなりえます。素朴に実装すればコピーしたほうが10倍速い、といった状況はありえます。

例えば [10, 100, 100] をシリアライズしたいと思ったとして、intが最大5バイト必要だとして、都度writter.GetSpanで取得した場合と、byte[]でどばっと取得した場合を比較すると……

// IBufferWriter<byte>
foreach(var v in values)
{
    var buffer = writer.GetSpan(5);
    var length = WriteInt(buffer, v); // WriteInt returns write length
    writer.Advance(length); 
}
 
// byte[]
var buffer = ArrayPool<byte>.Shared.Rent(64K).AsSpan();
var offset = 0;
foreach(var v in values)
{
    var length = WriteInt(buffer.Slice(index), v);
    offset += length;
}
// Return buffer...

というようなコードを書いた場合、どう見てもbyte[]ベースで素朴にやったほうが速そうです、というか速いです。ReadOnlySequence<byte>もそうで、内部は複雑な型のため、そのまま使ってSliceなどを多用すると、かなり遅くなります。よって、IBufferWriter<byte>によって得られたバッファを適切に管理する中間層、ReadOnlySequence<byte>によって得られたSegmentのバッファを適切に管理する中間層、の作り込みが必要になってきます。

v2の開発にあたっては、byte[]ベースで極限まで性能を高めたv1があるので、どれだけv1と比較して遅くなっていないか、を基準に随時ベンチマークを取ることによって、中間層の存在による性能低下を検知し、極力性能低下を抑えることに成功しました。

逆に言えば、純粋なシリアライザとしての性能はv1のほうが高速(な場合も多い/バッファ管理が賢くなったのでシリアライズ対象が大きい場合はv2が有利な場合もある)なのですが、パイプラインに組み込めることと、様々な工夫により、トータルでみるとv2のほうが実アプリケーションとしては有利になっています。

配列上のLZ4圧縮

v1 -> v2による性能向上の一つに、v1では64K以上のシリアライズではプールを使わず新規アロケーションをしていましたが、v2ではArrayPoolから取得する32Kのチャンクの連結リストを内部バッファとして使用しています(外部からIBufferWriterを渡さず、v2内部のバッファプールを使用する場合)。

image

byte[]を作る場合は、最後に連結して一塊に。Streamに書き込む場合は32K毎にWriteAsyncします。これによりバッファが溢れた場合に、List<T>のように二倍のサイズのバッファを新規に確保して書き込み、などせずに済んでいます。また、常に使用するバッファの大きさが85K以下で済むため、悪名高いLarge Object Heap(LOH)を消費する(ここに溜まるとGCの性能が極度に低下する)ことも避けられています。

そしてv2から新規搭載された新しい圧縮モードである MessagePackCompression.Lz4BlockArray では、この内部形式を利用して32K単位でLZ4圧縮をかけることにより、圧縮するために、一度、全部が一塊になった大きな配列を確保することを避けています。

image

実装上の工夫としては、MessagePackの拡張領域であるExtを使用することによって圧縮種別を判定可能にしていることと、Extは長さが必要なため、LZ4で圧縮されてサイズが縮むことを考えると事前に長さを計算することができない!ことを避けるために、Arrayを使用した上で、Arrayの最初の要素をExtにして判定用+LZ4のデシリアライズに必要な圧縮前の長さをここの部分に格納しています。これ、シリアライズもそうですが、デシリアライズ時もブロック単位で伸張できるので、大きなデータでも巨大配列を確保しないで済むという利点があります。

v1までの圧縮モードはMessagePackCompression.Lz4Blockとして残していますが、v2ではMessagePackCompression.Lz4BlockArrayを使用することをお薦めしています。既に圧縮済みのバイナリデータに関しては、Lz4BlockArrayでもLz4Blockをデシリアライズすることが可能です(逆も同様)。

ちなみにこの32Kというサイズを選んだのには、ちゃんと意味があります!まず、ArrayPoolの仕様で16K, 32K, 64K, 128Kの大きさで確保されます。20Kを要求した場合は32Kが、65Kを要求した場合は128Kが得られるという図式です。

そしてLZ4圧縮した場合、全く圧縮できなかった場合、ワーストケースでは要求サイズよりもほんの少し「大きく」なります。さて、そこでチャンクのサイズが64Kギリギリまで使用していて、LZ4圧縮をかけようとした場合は、圧縮後のサイズは圧縮完了まで不明のため、事前にワーストケースを想定し64.1K(仮)を要求し、結果として128Kが得られます。つまり、LOH行きです。厳密にはArrayPoolを使用しているため使い回されるので大丈夫ですが、プールサイズには上限を設けているので(全体で共有で32K * 100)、使い切った場合はアロケートされるので、そういうケースでもLOH行きを避けるためのサイズになっています。

真のコードジェネレーター

悪名高いド不安定なコードジェネレーターは大きく改善され、真の安定性と、ディレクトリ単位での指定と、ファイル単位での出力と、CIで使いやすい .NET Core (Local/Global) Toolsでのインストールと、XamarinやUWPで便利なMSBuild Taskの提供と、Unityでは初心者フレンドリーなEditor拡張を追加しました。

とにかくMac/Linuxでも安定して動作する!というのが大きい!やっと大手を振って人にお薦めできるようになりました。

まとめ

と、解説しましたが、実装の8割以上は前述のAArnottさんが行ったものなので、まずは本当にありがとうございます。そもそもv2のキッカケはプロトタイプ実装を掲示されて、Forkしていくパターンか一緒に実装するかのどちらかと言われて、(何ヶ月も返事を放置した末に)、一緒にやっていきましょうとしたのが元でした。その後も、一ヶ月質問を放置するとかメールスルーとか、そもそも開発の遅れは私のコミュニケーションによるものでは……、というようなところを粘り強く乗り越えてもらったお陰です。

いや、そうはいっても私もかなりしっかりやってますよ!?特にAPIのデザインは紆余曲折あってもめにもめた末に一周回って私が最初に掲示したデザインになってるし、性能面では延々と私が地道にベンチマーク取って掲示することで腰を上げてもらったり(その時点で大体どこをどう変えればいいのかは分かってるので、指摘しながら)、Unity周りはそもそも興味ないみたいなので油断するとすぐ壊れるところを直していったりと、はい。

これでパイプラインにおける心臓部分を手に入れることが出来たのですが、改めてまだそもそもパイプラインに血が流れてません。ASP.NET Coreだけでなく他のフレームワークやライブラリ郡(特にRedisと、ADO.NETはいい加減に10年前のレガシーモデルから卒業して新しい抽象層を提供して欲しい)も対応していかなければならないし、私の場合はMagicOnionが、トランスポート層に採用しているGoogleのgRPCがイマイチなせいでめちゃくちゃイマイチです。

というわけで、次回はMagicOnionのパイプライン化を最適化するために、通信層から手を入れる予定です。また、シリアライザはMessagePackだけではなく、JSONも重要なので、改めてUtf8Jsonの改修も行いたいと思っています。.NET Core 3で華々しくデビューしたMicrosoft公式実装のSystem.Text.JsonによるJsonSerializerの性能が極めて悪いので……。残念ながらMicrosoftは柔軟かつ性能の出るシリアライザの作り方を全く分かっていないのでしょう。

また、このパイプラインはサーバーの入口→出口だけで閉じるものではなく、ネットワークを超えてクライアント側(Unity)にまで届くものだと考えています。サーバー/クライアントを大きなパイプラインに見立てて、見えるところ通るところ全てを最適化することが「C#大統一理論」であり、真に強力なのだ。ということを実証していくのが当座の目標で、やっと最初の一歩が踏めました。C#凄いな、と心の底から世界中の人が思ってもらうためにも(そしてあわよくば採用してもらう!)、まだ足りてないものは山のようにあるので、どんどん潰していきましょう。

.NET Core時代のT4によるC#のテキストテンプレート術

C# Advent Calendar 2019用の記事となります。C# Advent Calendar 2019はその2もあって、そちらも埋まってるので大変めでたい。

さて、今回のテーマはT4で、この場合にやりたいのはソースコードジェネレートです。つまるところC#でC#を作る、ということをやりたい!そのためのツールがテンプレートエンジンです。.NETにおいてメジャーなテンプレートエンジンといえばRazorなわけですが、アレはASP.NET MVCのHTML用のViewのためのテンプレートエンジンなため、文法が全くソースコード生成に向いていません、完全にHTML特化なのです。また、利用のためのパイプラインもソースコード生成に全く向いていない(無理やりなんとか使おうとするRazorEngineといったプロジェクトもありますが……)ので、やめておいたほうが無難です。

では何を使えばいいのか、の答えがT4(Text Template Transfomration Toolkit)です。過去にはMicro-ORMとテーブルのクラス定義自動生成についてという記事で、データベースのテーブル定義からマッピング用のC#コードを生成する方法を紹介しました。テーブル定義は、実際にDB通信してもいいし、あるいは何らかの定義ファイルを解析して生成、とかでもいいですね。また、最近の私の作っているライブラリには(UnityのIL2CPP対策で)コードジェネレーターがついていますが(MagicOnion, MasterMemory, MessagePack-CSharpなど)、それらの生成テンプレートは全てT4で作っています。

元々はVisual Studioべったりでしたし、実際べったりなのですが、Mac環境ではVisual Studio for Macで、あるいは(一手間入りますが)VS Codeで使用したり、最近だとRiderが2019.3 EAP 5からばっちしサポートされたようなので、MacやRider派の人も安心です。RiderのT4サポートは曰く

You asked us to support T4, and we’ve answered! T4 support is here, based on our own generator and available as a pre-installed plugin.
The plugin is open-sourced (https://github.com/JetBrains/ForTea) and your contributions are very welcome.
Feature-rich C# support in code blocks includes highlighting, navigation, code completion, typing assistance, refactorings, context actions, inspections, formatting, and more.
T4-specific features include inspections, typing assistance, folding, brace matching, etc.
Extensive support is offered for includes to make the resolve in C# code as correct as possible.
You can execute T4 templates.
You can also debug T4 templates.
All these features work across Windows, macOS, and Linux.

だそうで、なかなかイケてるじゃないですか。Visual Studioも、Riderもそうですが、テンプレートエンジンでデバッガ動かしてステップ実行できたりするのが地味に便利です。VS Codeだと設定に一手間が必要なので(Qiitaにて.NET Core+VS CodeでもT4 テンプレートエンジンでコード生成したい!といった紹介もありますが)、VS2019, VS for Mac, Riderを使ったほうが楽そうです。

T4には2種類の生成パターン、デザイン時コード生成(TextTemplatingFileGenerator)と、実行時テキスト生成(TextTemplatingFilePreprocessor)の2種類がありますが、両方紹介します。

また、詳細なドキュメントがコード生成と T4 テキスト テンプレートにあり、実際かなり複雑な機能も搭載してはいますが、テキストテンプレートで複雑なことはやるべきではない(テキストテンプレートなんてあまり使わないような機能で使い倒した複雑なことやられても解読に困るだけ)ので、シンプルに使いましょう。シンプルに使う分には、全く難しくないです。

デザイン時コード生成

デザイン時コード生成とは、テンプレート単体でテキストを出力するタイプです。出力されたコードが、そのまま自身のプロジェクトのコンパイル対象になるイメージ。使い道としては、手書きだと面倒くさい単純な繰り返しのあるソースコードを一気に量産するパターンがあります。例えばジェネレクスのT1~T16までの似たようなコードを作るのに、一個雛形をテンプレートとして用意して for(1..16) で生成すれば一発、というわけです。他に、プリミティブの型(intとかdoubleとか)に対してのコード生成などもよくやりますね。またマークダウンで書かれた表(別の人がドキュメントとして書いているもの)から、enumを生成する、なんてこともやったりしますね。違うようで違わないようで実際微妙に違う退屈なユニットテストコードの生成、なんかにも使えます。

例としてMessagePackのカスタムシリアライザとして、以下のようなものを作りたいとします(単純な繰り返しの例としてちょうどよかったというだけなので、MessagePackの細かいAPIの部分は見なくてもいいです)。

using System;
using System.Buffers;
 
namespace MessagePack.Formatters
{
    public sealed class ForceInt16BlockFormatter : IMessagePackFormatter<Int16>
    {
        public static readonly ForceInt16BlockFormatter Instance = new ForceInt16BlockFormatter();
 
        private ForceInt16BlockFormatter()
        {
        }
 
        public void Serialize(ref MessagePackWriter writer, Int16 value, MessagePackSerializerOptions options)
        {
            writer.WriteInt16(value);
        }
 
        public Int16 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
        {
            return reader.ReadInt16();
        }
    }
 
    // 以下、Int16の部分がInt32だったりDoubleだったりするのを用意したい。
}

Int16になっている部分を、Int32やDoubleなど全てのプリミティブにあてはめて作りたい、と。手書きでも気合でなんとかなりますが、そもそも面倒くさいうえに、修正の時は更に面倒くさい。なので、これはT4を使うのが最適な案件といえます。

例はVisual Studio 2019(for Windows)で説明していきますが、T4の中身自体は他のツールを使っても一緒なのと、最後にRiderでの使用方法を解説するので、安心してください。

まずは新しい項目の追加で、「テキスト テンプレート」を選びます。

すると、以下のような空のテンプレートが生成されたんじゃないかと思われます。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

csprojには以下のような追加のされ方をしています。

<ItemGroup>
    <None Update="ForceSizePrimitiveFormatter.tt">
        <Generator>TextTemplatingFileGenerator</Generator>
        <LastGenOutput>ForceSizePrimitiveFormatter.txt</LastGenOutput>
    </None>
    <None Update="ForceSizePrimitiveFormatter.txt">
        <DesignTime>True</DesignTime>
        <AutoGen>True</AutoGen>
        <DependentUpon>ForceSizePrimitiveFormatter.tt</DependentUpon>
    </None>
</ItemGroup>

2個のItemが追加されているのは、T4本体と、生成物の2つ分です。<Generator>TextTemplatingFileGenerator</Generator>というのがT4をこれで処理しますよ、という話で、DependentUponはソリューションエクスプローラーでの見た目上、ネストする親を指定、ということになっています。Visual Studioの不正終了なので、たまに***1.csなどといった末尾にインクリメントされたファイルしか生成されなくなってムカつく!という状況にたまによく陥るのですが、その場合はLastGenOutputあたりを手書きで修正してけば直ります。

さて、まず思うのはシンタックスハイライトが効いていない!ということなので、しょうがないのでVS拡張を入れます。私がよく使うのはtangible T4 Editor 2.5.0 plus UML modeling toolsというやつで、インストール時にmodeling Toolsとかいういらないやつはチェック外してT4 Editorだけ入れておきましょう。なお、Visual Studioは閉じておかないとインストールできません。他のT4用拡張も幾つかあるのですが、コードフォーマッタがキモいとかキモいとか色々な問題があるので、私はこれがお気に入りです(そもそもコードフォーマットがついてない!変な整形されるぐらいなら、ないほうが百億倍マシです)。

さて、<#@ ... #>が基本的な設定部分で、必要な何かがあればここに足していくことになります。assembly nameは参照アセンブリ、基本的なアセンブリも最小限しか参照されていないので、必要に応じて足しておきましょう。ちなみにRiderだと何も参照されていない空テンプレートが生成されるので、デフォだとLINQすら使えなくてハァァァ?となるので要注意。System.Coreぐらい入れておけよ……。

import namespaceはまんま、名前空間のusingです。output extensionは、フツーは.csを吐きたいと思うので.csにしておきましょう。場合によってはcsvとかjsonを吐きたい場合もあるかもしれないですが。

次に、<# … #> を使って、テンプレートに使う変数群を用意します。この中ではC#コードが書けて、別に先頭じゃなくてもいいんですが、あまりテンプレート中にC#コードが散らかっても読みづらいので、用意できるものはここで全部用意しておきましょう。

<#
    var types = new[]
    {
        typeof(Int16),
        typeof(Int32),
        typeof(Int64),
        typeof(UInt16),
        typeof(UInt32),
        typeof(UInt64),
        typeof(byte), 
        typeof(sbyte),
    };
 
    Func<Type, string> GetSuffix = t =>
    {
        return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
    };
#>

今回は生成したいプリミティブ型を並べた配列を用意しておきます。また、ここではローカル関数を定義して、くり返し使う処理をまとめることもできるんですが(テンプレート中に式を書くと見にくくなるので、引数を受け取ってstringを返す関数を用意しておくと見やすくなります)、tangible T4 Editorがショボくてローカル関数に対してシンタックスエラー扱いしてくるので(動作はする)、Funcを使うことでお茶を濁します。

ここから先は本体ですが、短いので↑で見せた部分も含めてフルコードが以下になります。Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合に、ちゃんとファイルが生成されていることが確認できるはずです!

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
    var types = new[]
    {
        typeof(Int16),
        typeof(Int32),
        typeof(Int64),
        typeof(UInt16),
        typeof(UInt32),
        typeof(UInt64),
        typeof(byte), 
        typeof(sbyte),
    };
 
    Func<Type, string> GetSuffix = t =>
    {
        return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
    };
#>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY T4. DO NOT CHANGE IT. CHANGE THE .tt FILE INSTEAD.
// </auto-generated>
 
using System;
 
namespace MessagePack.Formatters
{
<# foreach(var t in types) { #>
    public sealed class Force<#= t.Name #>BlockFormatter : IMessagePackFormatter<<#= t.Name #>>
    {
        public static readonly Force<#= t.Name #>BlockFormatter Instance = new Force<#= t.Name #>BlockFormatter();
 
        private Force<#= t.Name #>BlockFormatter()
        {
        }
 
        public void Serialize(ref MessagePackWriter writer, <#= t.Name #> value, MessagePackSerializerOptions options)
        {
            writer.Write<#= GetSuffix(t) #>(value);
        }
 
        public <#= t.Name #> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
        {
            return reader.Read<#= t.Name #>();
        }
    }
 
<# } #>
}

記法の基本的なルールは<# … #>で括っている部分以外は平文で出力されます。そして、<#= … #>が式で、そこが文字列として展開されます。

なお、テンプレートを書くにあたって、長年の経験から得たおすすめのお作法があります。

  • 計算式はなるべくテンプレート中で書くのを避ける、ために事前に関数として定義しておく
  • <auto-generated>を冒頭に書く、これはlintによる解析対象から外れる効果があるのと、このファイルがどこから来ているのかを伝えるために有用
  • foreachやifなどはなるべく一文で書く、複数行に渡っているとテンプレート中のノイズになって見難くなる。また、あまり
  • <#の開始行はインデントつけずに先頭で、というのもインデントつけてると平文のテンプレート生成対象になるため生成コードのインデントがズレやすい

この辺を守ると、割と綺麗に書けると思います。テンプレートはどうしてもコード埋め込みがあって汚くなりやすいので、なるべく綺麗にしておくのは大事です。それでもどうしても避けられないifだらけで、読みにくくなったりはしてしまいますが、そこはしょうがない。

実行時テキスト生成

実行時テキスト生成は、いわゆるふつーに想像するテンプレートエンジンの動作をするもので、プログラム実行時に、変数を渡したらテンプレートにあてはめてstringを返してくれるクラスを生成します。データベースのテーブル定義からマッピング用のC#コードを生成するツール、であったり、私がよくやってるのはRoslyn(C# Compiler)でC#コードを解析して、それをもとにして更にC#コードを生成するツールであったり、というかむしろC#コードを解析してKotlinコードを生成したりなど、やれることはいっぱいあります。リフレクションでアセンブリを舐めて、条件に一致した型、メソッドから何かを作る、みたいなのも全然よくありますね。

これの作り方は、追加→新しい項目から「ランタイム テキスト テンプレート」を選ぶと、悲しいことに別に普通の「テキスト テンプレート」とほぼ同じものが生成されてます(VS2019/.NET Coreプロジェクトの場合)。なぜかというと、csprojを開くとGeneratorがTextTemplatingFileGenerator、つまり普通のテキストテンプレートと同じ指定になっているからです。これは、多分バグですね、でもなんか昔から全然直されてないのでそういうもんだと思って諦めましょう。

しょーがないので手書きで直します。GeneratorのTextTemplatingFileGeneratorをTextTemplatingFilePreprocessorに変えてください。そうすると以下のようなファイルが生成されています(Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合)

    #line 1 "C:\Users\neuecc\Source\Repos\ConsoleApp14\ConsoleApp14\MyCodeGenerator.tt"
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "16.0.0.0")]
    public partial class MyCodeGenerator : MyCodeGeneratorBase
    {
        /// <summary>
        /// Create the template output
        /// </summary>
        public virtual string TransformText()
        {
            return this.GenerationEnvironment.ToString();
        }
    }

ファイル名で作られたパーシャルクラスと、TransformTextメソッドがあるのが分かると思います。これで何となく使い方は想像つくと思いますが、new MyCodeGenerator().TransformText()でテンプレート結果が実行時に得られる、という寸法です。

さて、しかしきっとコンパイルエラーが出ているはずです!これはNuGetで「System.CodeDom」を参照に追加することで解決されます。別にCodeDomなんて使ってなくて、CompilerErrorとCompilerErrorCollectionというExceptionを処理する生成コードが吐かれてるから、というだけなので、自分でその辺のクラスを定義しちゃってもいいんですが、まぁ面倒くさいんでCodeDom参照するのが楽ちんです。この辺ねー、昔の名残って感じでめっちゃイケてないんですがしょーがない。

それと行番号がフルパスで書かれててめっちゃ嫌、というかフルパスが書かれたのをバージョン管理に突っ込めねーよ、って感じなので、これも消しましょう。linePragmas=”false”をテンプレート冒頭に書いておけば消えます。

<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>

この対応はほとんど必須ですね、ていうかデフォでfalseにしといてくれよ……。

さて、テンプレートにパラメーターを渡す方法ですが、これは生成されたクラス名と同じpartial classを定義すればOK。

namespace ConsoleApp14
{
    // 名前空間/クラス名(ファイル名)を合わせる(親クラスの継承部分は書かなくてOK)
    public partial class MyCodeGenerator
    {
        public GenerationContext Context { get; }
 
        // とりあえずコンストラクタで受け取る
        public MyCodeGenerator(GenerationContext context)
        {
            this.Context = context;
        }
    }
 
    // 生成に使うパラメーターはクラス一個にまとめておいたほうが取り回しは良い
    public class GenerationContext
    {
        public string NamespaceName { get; set; }
        public string TypeSuffix { get; set; }
        public int RepeatCount { get; set; }
    }
}

これでテンプレート中でContextが変数として使えりょうになります。テンプレートの例として、面白みゼロのサンプルコードを出すと……(Roslynとか使うともっと意味のあるコード例になるのですが、本題と違うところがかさばってしまうので、すみません……)

<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY ConsoleApp14.exe. DO NOT CHANGE IT.
// </auto-generated>
namespace <#= Context.NamespaceName #>
{
<# foreach(var i in Enumerable.Range(0, Context.RepeatCount)) { #>
    public class Foo<#= Context.TypeSuffix #><#= i #>
    {
    }
 
<# } #>
}

RepeatCountの回数で中身空のクラスを作るというしょっぱい例でした。これの注意事項は、普通のテキストテンプレートと特に変わりはないのですが、auto-generatedのところに、生成ツール名を入れておいたほうがいいです。外部ツールでコード生成するという形になるため、ファイルだけ見てもなにで生成されたかわからないんですね。あとから、何かで作られたであろう何で作られたかわからない自動生成ファイルを解析する羽目になると、ツールの特定から始めなくちゃいけなくてイライラするので、この辺をヘッダにちゃんと書いといてあげましょう。ただたんに「これは自動生成されたコードです」と書いてあるだけよりも親切で良い。

記述におけるコツは、やはりテンプレート中に式を書いて文字列を生成するよりかは、この場合だとContext側にメソッドを作って、引数をもらってstringを返すようにすると見通しが良いものが作りやすいでしょう。ちなみにthis.ContextのContextのIntelliSenseは効きません、そっちの解析までしてくれないので。テンプレートファイルにおける入力補完は甘え、おまけみたいなもんなので、基本的には頼らず書きましょう。もちろん、そのせいでばんばんTypoしてエラーもらいます。これが動的型付け言語の世界だ!をC#をやりながら体感できるので、いやあ、やっぱ静的型付け言語はいいですねえ、という気持ちに浸れます。

さて、こうして出来たクラスは、ただのパラメーターを受け取ってstringを返すだけのクラスなので、何に使っても良いのですが、9割はシンプルなコマンドラインツールになるのではないでしょうか。

C#でコマンドラインツールといったら!私の作ってるMicroBatchFrameworkが当然ながらオススメなので、それを使ってツールに仕立ててみましょう。

using MicroBatchFramework;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
 
namespace ConsoleApp14
{
    class Program : BatchBase
    {
        static async Task Main(string[] args)
        {
            await BatchHost.CreateDefaultBuilder().RunBatchEngineAsync<Program>(args);
        }
 
        public void Run(
            [Option("o", "output file path")]string outputPath,
            [Option("n", "namespace name")]string namespaceName,
            [Option("t", "type name suffix")]string typeSuffix = "Foo",
            [Option("c", "type generate count")]int repeatCount = 10
            )
        {
            // パラメータを作って
            var context = new GenerationContext
            {
                NamespaceName = namespaceName,
                TypeSuffix = typeSuffix,
                RepeatCount = repeatCount
            };
 
            // テキストを生成して
            var text = new MyCodeGenerator(context).TransformText();
 
            // UTF8(BOMなし)で出力
            File.WriteAllText(outputPath, text, new UTF8Encoding(false));
 
            Console.WriteLine("Success generate:" + outputPath);
        }
    }
}

これで ConsoleApp14.exe -o "foo.cs" -n "HogeHoge" -t "Bar" -c 99 というしょっぱいコマンドでfoo.csが吐かれるツールが完成しました!

RiderにおけるT4

RiderでのT4は、Visual Studioが使っている生成ツールとは違う、T4の記法として互換性のあるJet Brains独自のツールを実行している気配があります(そのため、場合によっては互換性がないところもあるかもしれません、というか実際linePragms=falseで行番号が消えなかった……)

実行自体は簡単で、ttに対して右クリックしてRunを選べばデザイン時コード生成、Preprocessを選べば実行時テキスト生成の出力結果が得られます。

image

現時点ではEAP、つまりプレビュー版ですが、まぁ他にもいっぱい良い機能が追加されてるっぽいので、Riderユーザーは積極的にEAPを使っていけばいいんじゃないかしら。

T4にできないこと

T4は、基本的に.ttで書かれたファイルを事前に(VSだと保存時、Riderだと任意に、あるいは設定次第でビルド時に)外部ツールが叩いて、テキスト(.csだったり色々)を生成します。いいところはC#プロジェクトと一体化して埋め込まれるので、パフォーマンスもいいし、そもそもT4生成時に型が合わなければエラーが出てくれる(動的の塊であるテンプレートファイルにとって、書き間違えは日常茶飯事なので、これは結構嬉しい)。実行効率も良いです、ファイル読んでパースして必要があればキャッシュして云々というのがないので。

が、しかし、プログラムが実行時に動的にテンプレートを読み込んでなにかする、みたいなことはできません。ツールのビルド時にテンプレートが出来上がってないといけないので、プラグイン的に足すのは無理です。それは成約になる場合もある、でしょうし、私的には型もわからないようなのを動的に合わせてテンプレート作るとか苦痛なので(←最近なんかそういうのやる機会が多くて、その度にシンドイ思いをしてる)、パラメータ渡しだけでなんとかして済むようにして諦めて作り込んだほうが百億倍マシ、ぐらいには思っていますが、まぁそういうのがしたいというシチュエーション自体は否定できません。その場合は、他のテンプレートエンジンライブラリを選んで組み込めば良いでしょう、T4がやや特殊なだけで、他のテンプレートエンジンライブラリは、むしろそういう挙動だけをサポートしているので。

まとめ

T4は十分使い物になります。微妙にメンテされてるのかされてないのか不安なところもありますが、そもそもMicrosoftもバリバリ使っているので(GitHubに公開されているcorefxのコードとかはT4で生成されているものもかなりあります、最近追加されたようなコードでも)、全く問題ないでしょう。実際、記法も必要十分揃っているし、特に極端に見にくいということもないと思います、ていうかテンプレートエンジンとしてフツーなシンタックスですしね。

仮に複雑なことやりたければ、例えばテンプレートをパーツ化して使い回すために分割して、都度インポートとか、というのもできます(T4 インクルード ディレクティブでは.t4という拡張子が紹介されていますが(拡張子はなんでもいい)、一般的には.ttincludeという拡張子が使われています)。他いろいろな機能がありますが、あらためて、テキストテンプレートごときで複雑なことやられると、追いかける気が失せるので、なるべく単純に保ちましょう。やるとしてもせいぜいincludeまで。それ以上はやらない。

というわけで、どうでしょう。MicroBatchFrameworkとも合わせて、C#のこの辺の環境は割といいほうだと思ってます。コード生成覚えるとやれることも広がるので、ぜひぜひ、コード生成生活を楽しんでください!

また、Unityでも普通に便利に使えると思います。Editor拡張でソースコード生成するものを用意するパターンは多いと思いますが(なにかリソースを読み込んで生成したり、あるいはそのままAssembly.GetExecutingAssembly().GetTypes()して型から生成したり)、その時のテンプレートとして、普通にstringの連結で作ってるケースも少なくなさそうですが、「デザイン時コード生成」も「実行時テキスト生成」も、どっちも全然いけます。「T4にできないこと」セクションに書いたとおり、よくも悪くも外部ツールで実行環境への依存がないので。

Unite Tokyo 2019でC# Structの進化の話をしてきました

Unite Tokyo 2019にて、「Understanding C# Struct All Things」と題して登壇してきました!動画は後日Unity Learning Materialsに公開される予定です。

【Unite Tokyo 2019】Understanding C# Struct All Things from UnityTechnologiesJapan002

C#成分強めなので、Unityに馴染みのない人でも読んで楽しめる内容になっていると思います。とはいえ勿論、Uniteでのセッションであるということに納得してもらえるようUnity成分もきちんと取り入れています。というわけで、どちらの属性の人にも楽しんでいただければ!

structに関する機能強化、実際めっちゃ多いんですが、それをカタログ的に延々と紹介してもつまらないので、そうしたカタログ紹介っぽさが出ないように気を配ってます。あと、あんましそういうのでは脳みそに入ってこないというのもあるので。

応用例的なところのものは、ないとつまらないよねーということで色々持ってきたのですが、もう少し説明厚くしても良かったかなー感はありました。セッション内でも雰囲気で流した感じありますしね。とはいえ尺とか尺とか。まぁ雰囲気を分かってもらえれば(structは色々遊べるよ、という)いい、と割り切った面もあるにはあります。詳しくは資料を熟読してください!

Span/NativeArrayの説明も厚くしたくはあったんですが、structそのものの本題からは若干外れるので見送り。あと尺とか尺とか。

今年のUnite、もの凄くいいイベントでした。神運営とはこのことか……。そしてDOTSが熱い。めっちゃやるやる詐欺なので、いい加減本当にそろそろDOTSに手を出して楽しみたいですます!

あと、MessagePack-CSharp v2はいい加減そろそろ出るはず予定です、ちなみにSystem.Memoryとかにめっちゃ依存しているので、MessagePack-CSharpを入れるとSpanとかSystem.Runtime.CompilerServices.Unsafeとかが解禁されます(依存ライブラリとして同梱する予定なので)。いいのかわるいのか。まあ、いいでしょふ。未来未来。

Prev |

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for Developer Technologies(C#)

April 2011
|
July 2021

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc