C#による自家製静的サイトジェネレーターに移行した話

見た目はほとんど変わっていませんが(とはいえ横幅広くしたので印象は結構変わったかも)、このサイト、フルリニューアルしました。内部構造が。完全に。別物に。元々はWordPressだったのですが、今回から自作の静的サイトジェネレーターでhtmlを生成し、GitHub Pagesでホストするようにしました。元になるソース(.md)もGitHub上に置き、GitHub ActionsでビルドしてGitHub Pagesでホスティングされるという、完全GitHub完結ソリューション。また、記事を書くエディタもGitHub web-based editor(リポジトリのトップで.を打つと、VS Codeそのものが起動するやつ)を利用することで、非常に快適で、というかMarkdownエディタとしては最高品質のものが乗っかっていて、たかがブログ書くにしては面倒くさいPush/Pullもなくダイレクトコミットで反映出来てしまうというのがとても良い体験になっています。

.でエディタを起動して、articles配下にYYYY-MM-DD.mdファイルを新規作成。

image

完全にVS Codeそのものでデスクトップアプリのものと全く区別が付かないレベルで、これを超える品質のエディタを普通のサイトに乗せることは不可能でしょう。当然もちろん画像のプレビューもできますし、なんだったら拡張すら入る。

GitHub管理だと画像置き場(アップロード)が面倒くさい問題があるのですが、これはIssueを画像アップローダーとして使うことで回避しています。Issueの入力フォームは、画像をCtrl+Vでそのままアップロードが可能です。そして嬉しいことに、マークダウンに変換してくれているのでコピペするだけでOK。

image

image

上がった先のuser-images.githubusercontentは別にIssueそのものと紐付いているわけではないので、 アップローダ用に使ったIssueはSubmitすることなくポイ、です。そうしてどこにも紐付いていないuser-images.githubusercontentですが、別にだからといって削除されることもなく永続的に上がり続けているので、遠慮なく使わせてもらうことにします。まぁちゃんとGitHub上に上げてるコンテンツ用に使っているので、許されるでしょう、きっと。多分。

そうして出来上がった記事は、そのままエディタ上のgit UIからコミットすると、自身が作業している領域は直接サーバー上のmaster(main)なので、プッシュ不要で反映されます。

image

こうなると、もうWordPressで投稿をポスト、するのと変わらないわけです。ブログ記事程度でcloneしてpullしてstagingしてpushしてというのは地味に重たいので、このぐらい身軽で行きたいですね。(実際、投稿後に編集ラッシュとかよくあるので、ちょっと手数が増えるだけで猛烈に嫌気がさす)

ジェネレートはworkflows/buildy.ymlで、このリポジトリ内に配置されてるC#プロジェクトを直接ビルド/実行することで生成処理をしています。dotnet run便利。

build-blog:
runs-on: ubuntu-latest
steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 6.0.x
    - run: dotnet run --project ./src/Blog2/Blog2.csproj -c Release -- ./articles ./publish
    - uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: ./publish
        keep_files: true

生成されたファイルはpeaceiris/actions-gh-pagesを使ってgh-pagesブランチと同期します。その際、デフォルトでは既に上がってるファイルを全削除してしまうので、今回はstyleやassetを、同期とは別に置いてあるので削除されると困るので、keep_files: trueも指定しています。そうすると記事の削除がしづらくなるんですが、記事の削除はしない or どうしても削除しなかったら二重に(articlesとgh-pages)削除すればいいだけ、という運用で回避。

と、いうわけでシステム的には満足です。

C#でもStatiqなどといった静的サイトジェネレーターは存在するのですが、あえて自作した理由は、サイトのシステムをそっくり移行するという都合上、URLを前のものと完璧に合わせたかったというのがあります。生成結果のファイル一覧が若干変というかクドいというか、といったところがあるのですが、これは前のWordPressでやっていたルーティングをそのまんま再現するためということで。WordPressからのエクスポートも、DB直接見てC#でそのままテーブルダンプから作ったので、まぁ別に大したコードが必要なわけでもないので一気に作っちゃえという気になったというのもあります。

外部ライブラリとしてはMarkdownのHTML化にMarkdigを採用しました。色々高機能ではあるのですが、今回は Markdown.ToHtml(input) しか使っていませんけれど、感触的にはとても良かったです。

シンタックスハイライトにはPrism.jsを用いました。Markdigの出力する```csharpの変換を、特に何も意識せずとも対象にしてくれるのが良かったですね。プラグインはautoloaderとnormalize-whitespaceを合わせて投下しています。

<script src=""https://cdnjs.cloudflare.com/ajax/libs/prism/1.25.0/components/prism-core.min.js""></script>
<script src=""https://cdnjs.cloudflare.com/ajax/libs/prism/1.25.0/plugins/autoloader/prism-autoloader.min.js""></script>
<script src=""https://cdnjs.cloudflare.com/ajax/libs/prism/1.25.0/plugins/normalize-whitespace/prism-normalize-whitespace.min.js""></script>

まとめ

最近ブログ投稿がだいぶ減ってしまっていたのですが、システムも一新したことでやる気が出てきたのでいいことです。まぁ見た目は本当にあんま全然変わってないんですが……!

なお、反映に必要な所要時間は30秒弱。

image

遅いっちゃあ遅いですが、許容できるといえば許容できますね。サイトジェネレートプログラムの実行時間自体は1秒以下で、別に全然時間かかってないんで、CIセットアップとか、それ以外の時間が何かとかかっちゃってます。GitHub Actionsの仕組み的にしょうがないといえばしょうがないんですが、もう少しなんとかなってほしいかなあ。あとGitHub Pages自体が反映が若干遅い。遅い上に進捗が分からないのが地味にストレスフル。とはいえとはいえ、良いんじゃあないでしょうか。良さの殆どはGitHub web-based editorから来てますね、これは本当に革命的に良い。というわけで、このweb-based editorを活かすシステムを作っていくという手段と目的を逆転させた思考が最終的に実際良いんじゃないかと思ってます!

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 で書いて回るのは、悪くない選択になると思います。

Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々々受賞しました

11回目。一年ごとに再審査があって7月に一斉更新されるシステムになっていて、今年も継続しました。

MessagePack for C#はprotobuf-netを抜いて、 .NET で最もGitHubのスター数の多いバイナリシリアライザになりそうな感じです(今はまだちょっと負けてるので、勢い的に8月か9月ぐらいには)。まぁ、たった3000ちょいがMost StarsというC#の狭さみたいなところがなきにしもあらずではありますが(JavaScriptだと桁が違うからなあ)、.NET の存在感というのは決して劣ってはいないと思います。

MessagePack for C#はv3を計画しています。パフォーマンスの大幅な向上(特にUnityで!)や、より良い使い勝手、ゼロアロケーションを超えたゼロコピー、SourceGenerator対応によるAOT対応の強化などなどを、破壊的変更も含めた上で考えてます。改めて、 .NET 6時代の最高のシリアライザを目指しています。

GitHub/Cysharpで公開しているものも、新規には MessagePipeは結構良いと思いますし、引き続き MagicOnionUniTaskは開発進めています。

つまり全体的にとてもC#に貢献している。なるほどえらい。そりゃ更新も当然ですね(

今年は会社として、今ひとつ大きなプロダクトを仕込んでいる最中でして、それで大きなインパクトを Unity と .NET 、双方で引き起こせるはず、です……!乞うご期待。

というわけかで引き続きC#の最前線で戦っていきますので、今年もよろしくおねがいします。

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もいい感じになっていると思うので、ぜひぜひ使っていただければです!

2020年を振り返る

今年は前半が絶好調で、ConsoleAppFrameworkProcessXZStringZLogger、そしてUniTask v2と、凄い勢いでプログラミング的なクリエイティビティを発揮できていました。なので今年トータルとしてみれば良かったと言えます。

が、後半が絶不調で無。とにかく無。なんでこんな無になったのか分からないほどに無。コロナか、コロナが悪いんか。それも実際あるんですが、いや、というかそれが全てかなー。リモートワーク向いてないんすよー、みたいな。リモート前半で魂の貯金を使い切った。無が加速してからヤバいと思って自主リモート返納(自分だけオフィスワーク)に戻したんですが、それでもなんか違うんですよねえ。まぁ、言い訳なんですけどね!新環境に適応できない旧世代民には死あるのみ、って感じなので、来年は脳みそ入れ替えてやってきたいと思います。

この12月は、書く予定だったアドベントカレンダーも書けずにフィニッシュと最悪な感じですからねえ、終わりが全くしまらなかった結果、今年の印象としてはあんま良くない。でも客観的に一年通しで見たら、中々の成果を上げたとは言えます。

OSSがかなり出揃ったことで、Cysharpという会社の輪郭をはっきりさせられた年になりました。対外的には何やってる会社か分からない、まぁ実際そこは今もよくわからないと思うんですが、それでもC#の最先端を突っ走っている会社だというイメージは確固たるものになったのではないでしょうか。去年ではまだまだ足りてないと考えていたのですが、今年追加したOSS群によって、一つポジションを引き上げられたと思っています。

MagicOnionもv4になって .NET 5/Pure C# gRPC 対応を果たしましたし、今年は実際に採用しているアプリケーションがリリースされていったことで、よりCysharpの目指しているヴィジョンの現実感が出てきました。来年はそのヴィジョンをより鮮明にしていくことと、もうプラスαに仕込んでいるものがあるので、その辺の露出がうまくできるといいかなーと思ってます。

私個人の能力の成長という点でも、UniTask v2を始めとしてパワーある実装をやりきったことと、そこから深く学んだこともいっぱいあるので、まだまだ行けるぞという感じです。ちゃんとね、毎年成長してますよ。はい。人間、停滞=衰退ですから。

私は出したもののウケ度に割と拘るところがあるんですが、これは自分の感覚と市場の感覚が乖離していないかを測っているという面もあります。今日が誕生日でもうN回この振り返りも書いてるわけですが、そろそろ油断すると感性が腐る頃合いなんですよね。なんかピンとのズレたことを言い始めてしまうという。端的に言えばそれが老害というわけなんですが、自分も油断するとなりかねない。という危機感がそぞろ出てくるような頃合いでして。しかもね、そういうのは自覚がないわけですよ、本人は自覚がない!本人はイケてると思っているのが余計辛い!自覚がないからこそ老害なのだ。みたいなところがある。

と、いうわけで、客観的な指標が必要で、とりあえず今年はOKじゃないですかね。はい。

その他文化

今年のGame of the YearはDOOM Eternalですよね……!震えるほど面白いゲームって本当に数年単位で久々で、腐った感性を復活させてくれた神の救いですよ。というわけでマストバイ。(しかし超期待したDLCは微妙だった……)

今年のベストアルバムは中村佳穂のAINOUです。中村佳穂『AINOU』はなぜ2018年を代表する名盤なのか?とかって記事出てるように全然今年のアルバムじゃないんですが、聴いたのは今年だからshoganai。名盤。

読み物としては、ちょくちょく月刊専門料理を買ってて、これが面白いんですよね。料理とエンジニアリングは共通するものがあるとCooking for Geeksをはじめとしてよく言われるやつですが、それプラス経営的な話とかも中々身に沁みるものがあって良いわけです。あと、料理業界はまだまだ多分アナログなんですよね、だから紙の雑誌にも相応の密度がある。その点エンジニアの場合はウェブ媒体のほうが紙より良い状態なので、雑誌が面白くないんですよね(Web+DBとかもはやつまらんでしょ)。良くも悪くもですが、まぁもう進んでしまった業界は紙の媒体が面白くなることはないのでしょう。

来年

アドベントカレンダーネタは書いてないしGitHub Issuesもかなり手を付けてないのが残っちゃったしで、あんまりスッキリして来年を迎えられないんですが……!そのへんはなるはやですっきりさせたいとして、今年はCysharpの仕込みフェーズがとてもうまくいった。実際うまくいった。そして仕込みフェーズは終了。つまり来年はどーんといきましょう。というわけで、ぜひぜひ大躍進にご期待くださいな。

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に埋もれてシンドイとか、そういう負の側面も色々あるのですけれど、それでもね、やっていくのはいいことだと思います。そしてやるからには、一つ一つには真剣に取り組むことが、大きなリターンを得るための秘訣かな、と。

Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々受賞しました

しました。多分10回目。Developer Technologiesというのが分かりづらくて嫌なのですが、C#です。一年ごとに再審査があって7月に一斉更新されるのですが、今年も継続です。

最近ちっともブログ書いてない気がしますが、Cygames Engineer's Blogに割とよく書いているので、なんだかんだでつまり結構書いています。Cysharpのほうでも、GitHub/Cysharpでの公開OSS数は15。総☆数も5000近くあるので、まだ設立2年に満たない会社ではありますが、結構存在感を示せていると思っています。もちろん全部C#。めっちゃC#に貢献してるやん。すごいえらい。

直近ではUniTask v2が大きな成果で、同時にC#はまだまだ突き詰められるなというのを実感しました。2年前の自分だとここまで書けなかったので、極めたなんてことはなく、まだまだ日々成長しています。Zシリーズ(ZString, ZLogger)も面白いですね。ConsoleAppFrameworkもばんばん使ってます。

Unity と .NET Coreが主軸というのは変わらず、そして両者を繋ぐ活動ができるのは世界に私だけ(能力的に、ではなくてアクティブに使命持ってやっている人が、ということですよネンノタメ)だと思っていて、OSSによる、C#の価値を広げていく、活用の幅を広げていくというのは引き続きやっていきたいことなのですが、もう一つ、会社としてもアクションを起こしていきたいと考えています。日本で、世界で大きなインパクトを出すためにも、まだまだ足りないことがいっぱいですから。

というわけかで引き続きC#の最前線で戦っていきますので、今年もよろしくおねがいします。

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になってるのですが、これをサーバーループによる駆動に変換するためのブリッジ層を作り込みたいかなあ、と。現状でも自作すればできる状態なんですが、このぐらいは標準で用意してあげたほうがすわりがよさそうだ、と。

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

Prev | | Next

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive