WebSerializer - オブジェクトからクエリストリングに変換するHttpClientリクエスト用シリアライザ

T valueから URLエンコードされたクエリストリング、またはx-www-form-urlencodedなHttpContentを生成する、つまりはウェブ(HTTP/1)リクエスト用のシリアライザを作りました。

クエリストリングの生成、意外と面倒くさいな!と。(C#用の)専用のSDKが存在しないWeb APIの場合は、自分でURL組み立てたりFormUrlEncodedContentを組み立てたりしますが、数が多いとまぁ面倒くさい。リクエストのパラメーター数が多いと、null抜いたりも面倒くさい。

レスポンス側はReadFromJsonAsyncなどでダイレクトに変換できるようになって特に問題はないのですが、リクエスト側は、かなりの手作業が要求されます。そのへんを全部やってくれるrefitというライブラリもありますが(Androidのretrofitにインスパイアされたもの)、導入するにはちょっと大仰だな、と思うときも多々あります、というか私は今まで一度も使ってません。

HttpClient用にURLを組み立てるのを簡略化してくれるぐらいでいいな、と思って考えていたら、そういえばそもそもそれってT valueから何かに変換する、つまりシリアライザじゃん、ということに気づきました。T -> msgpack byte[]に変換すればMessagePackシリアライザだし、T -> Json stringに変換すればJSONシリアライザだし、これはT -> UrlEncoded stringに変換するということなのだと。シリアライザ脳なので、そう理解すれば話が早い。

using Cysharp.Web;

var req = new Request(sortBy: "id", direction: SortDirection.Desc, currentPage: 3)

// sortBy=id&direction=Desc&currentPage=3
var q = WebSerializer.ToQueryString(req);

await httpClient.GetAsync("/sort?"+ q);

// data...
public record Request(string? sortBy, SortDirection direction, int currentPage);

public enum SortDirection
{
    Default,
    Asc,
    Desc
}

基本的に使うメソッドは WebSerializer.ToQueryStringWebSerializer.ToHttpContent だけです。URLエンコードされてname=valueで&連結された文字列が取り出せます。メソッドとして叩いたりする場合は、そのまま匿名型で渡してあげればちょうど良い。urlも一緒に渡してあげれば全て同時に組み立ててくれます。値がnullのものは文字列化対象から自動で外されます。

const string UrlBase = "https://foo.com/search";

// null, SortDirection.Asc, 0
async Task SearchAsync(string? sortBy, SortDirection direction, int currentPage)
{
    // "https://foo.com/search?direction=Asc&currentPage=0"
    var url = WebSerializer.ToQueryString(UrlBase, new { sortBy, direction, currentPage });
    await httpClient.GetAsync(url);
}

動的に組み立てる場合は、Dictionary<string, object> も渡せます。(FormUrlEncodedContentDictionary<string, string>で、Value側のToString()が必須なのが地味に面倒くさいので、objectで良いというのは何気に楽だったりします)。

var req = new Dictionary<string, object>
{
    { "sortBy", "id" },
    { "direction", SortDirection.Desc },
    { "currentPage", 10 }
};
var q = WebSerializer.ToQueryString(req);

POST用には、ToHttpContentを使います。

async Task PostMessage(string name, string email, string message)
{
    var content = WebSerializer.ToHttpContent(new { name, email, message });
    await httpClient.PostAsync("/postmsg", content);
}

内部的にはFormUrlEncodedContentは使わずに、専用のHttpContentを通しているため、byte[]変換のオーバーヘッドがありません。

シリアライザ設計

ただたんにクエリストリング組み立てるだけっしょ!というと軽く見られてしまうかもしれないのですが、中身はかなりガチめに作ってあって、構成としてはMessagePack for C#と同様です。パフォーマンスに関しても超ギチギチに詰めているわけではないですが、かなり気を配って作られているので、手で組み立てるよりもむしろ高速になるケースも多いはずです。拡張性もかなり高く作れているはずです。

シリアライザのデザインに関してはMessagePack for C#の次期バージョン(v3)をどうしていこうかなあ、と考えているタイミングでもあるので、そのプロトタイプ的な意識もありますね。なので設計としてはむしろ最新型で、かなり洗練されています。.NET 5/6のみにしているので、レガシーも徹底的に切り捨てていますし。最初は .NET 6のみだったのですが、さすがにそれはやりすぎかと思い .NET 5は足しました。(が、2022-01-21のv1.3.0にて、.NET Standard 2.0/2.1対応もしました)

例えばコンフィグ(WebSerializerOptions)はイミュータブルなのですが、これ自体はrecordで作ってあってwith式でカスタムのコンフィグを作れます。

// CultureInfo: 数値型やDateTimeの文字列化変換に渡すCultureInfo、デフォルトはnull
// CollectionSeparator: 配列などを変換する場合のセパレーター、デフォルトはnullでname=value&name=value...
// Provider: 対象の型をどのように変換するか(`IWebSerialzier<T>`)の変更
var newConfig = WebSerializerOptions.Default with
{
    CultureInfo = CultureInfo.InvariantCulture,
    CollectionSeparator = ",",
    Provider = WebSerializerProvider.Create(
        new[] { new BoolZeroOneSerializer() },
        new[] { WebSerializerProvider.Default })
};

// Bool値を0, 1に変換する(こういうの求めてくるWeb APIあるんですよねー!)
public class BoolZeroOneSerializer : IWebSerializer<bool>
{
    public void Serialize(ref WebSerializerWriter writer, bool value, WebSerializerOptions options)
    {
        // true => 0, false => 1
        writer.AppendPrimitive(value ? 0 : 1);
    }
}

IWebSerializer<T>のインターフェイスについて、ref T valueにしようか検討したのですが、最終的にやめました。

public interface IWebSerializer<T> : IWebSerializer
{
    void Serialize(ref WebSerializerWriter writer, T value, WebSerializerOptions options);
}

ref T valueにすると、プロパティをそのまま渡せなくて、かなり面倒くさくなってね。理屈的にはlarge structに対するコピーコスト削減、ではあるけれど、まぁこのままだと99%効力ないかなあ、という感じがあり。入り口だけinにして一回分コピーを消すぐらいを落とし所にしました、とりあえず今回は。

public static string ToQueryString<T>(in T value, WebSerializerOptions? options = default)

それとSource Generator対応についても考えましたが、まぁ一旦今回は見送って、後でやるかもという感じでしょうか。アイディアは色々ありますが、まずは作ってみないとうまくハマるか見えないところがあるし、MessagePack for C#のような大きなものでドカンとやるよりは、最初は小さなものでテストしていくのが良いものを作る正攻法でもありますね。

Deserializeがない問題

ASP.NET CoreのAddControllerなら、Model Bindingでデシリアライズできるので、不要でしょう。.NET 6時点でのMinimal APIだとなんと自動モデルバインディングがなくて手動でQueryStringから組み立てるという手間が必要になってて、まぁそこでは必要かなあ?と思ったんですが、いや、それしたいならMinimal APIではなくてAddControllerしろや、と思ったので、機能入れるのやめました。実際、そのうちバインディング自体はいれるそうです(さすがに不便なので)。

まとめ

手で組み立てている人は結構多いと思うので、使えるシチュエーションはかなりあると思ってます。ただまあ、こんぐらいなら手でやるよ!と思う人は多いと思うので、その点ではニッチかなあ、というところですね。Web APIの仕様によってはリクエストパラメーターが微妙にデカくてイライラすることがあったり、まぁあとは数を作るときにはやっぱダルいので、ハマるシチュエーションも少なくはないかな、と。

とりあえずは試してみてもらえればと思います。

2021年を振り返る

例年、30日に投稿しているはずなのですが、今年は、どうしても今年中に作りきりたいという思いでConsoleAppFramework v4のリリースをしてしまったので31日で。今年の後半から道具をガラーッと変えて、それがいい感じに作用していったので、なんか満足した気でいます。

Heyをとにかく薦めたい

今年良かったもの第一位はHeyです。Ruby on Rails作者のDHHがやってる会社(Basecamp)のメールサービスなのですが、これが抜群によく出来てる!今までメール一ヶ月放置は当たり前、未読1万件、みたいな状態だったのですが、After Heyでは未読0。すごい。メーラー変えただけでこんな変わるとは。よく出来たツールは人を変えるね。

どうしてもたまりがちな、スパムではないけど自分にとってはスパムに等しいもの(なんだかんだで送られてくる広告メールとかね)を、実質スパム扱いして、一生このメールは見ないという設定をワンポチでできるのが小気味よい。ワンポチどころか、最初のメール受信時に強制的に決めさせることで(決めないとメールが受信ボックスに入らないので、決めるしかない)、最初に使うときの罪悪感というか、とはいえ見るかもしれないしー、役に立つときもあるかもしれないしー、みたいななんとなくある抵抗感みたいなのを、その手で実行させ続けることにより薄れさせていく手腕は見事というほかない。

メールを3分類、読むものとフィード的に見るもの(メルマガとかGitHubのWatchとか)と、領収書系で分けたというのもセンスを感じる。領収書は、例えばKindleで購入するたびに買いましたメールは、捨てるのもアレだけど別に自明すぎて見たくはない、ものが溜まっていくとメールボックスがウザいことになる、を専用の置き場を用意しました、で解決しているのはなるほどなー、と。フィード的なのは全部連結されているので、スクロールさせてバッと流し見で終わらせられるのも良い。

細かいフィルターはできないけど、そもそも細かいフィルターなんて作るの面倒だしメンテ不能になるだけだから作るんじゃねえ、俺達の考えた最高のRailに乗ってりゃあいいんだよ、という押し付けがましさ全開の思想性溢れるのが、いいですね。そういうの、嫌いじゃないです。DHHの語るプログラミング的な思想も好きですしね、私は。Rubyは使いませんが、DHHの思想には納得できるものがめちゃくちゃ多くて割と好きなので。

メールアドレスという、なんだかんだで変えられない、変えにくいものなのに、 hey.com を使え!というのは中々ハードル高いのですが(基本的に汎用メーラーとしては使えず、専用メールアドレスが必要。GMailのクライアントにもPOP3クライアントにもならない)、今回私は10年以上使ってきたプロバイダのメールアドレスが不慮の事故により完全消滅したので、思い切って乗り換えることができました。結果、良かった。怪我の功名ということで。

iPad miniがとにかく良い

タブレットは、というかiPadはなんだかんだで今まで色々なサイズのものを買ってきました。普通のもAirもPro 13インチも。そしてほとんど全く使わなかったのですが……!なんか面倒くさくてねー、重いしー、と。で、iPhoneがPro Maxで大きいから、そこまでサイズ変わらないしねえと思ってminiだけは手を出さなかったのですが、世間でiPad mini 6があまりにも評判がいいので、じゃあまぁ試してみるかと買ったら、なるほど納得!これは超いい!最高……!

やっぱ重さとサイズ感ですかね。これなら手軽に持ち運べる(今の時期コートとかだったらポケットにすら入る)し、片手で持てるというのが読みやすさにもめっちゃ寄与してる。デカいと手も疲れるし、ちゃっと手にとってソファで読もうとかいう気になれなかったわけですが、このサイズ感は絶妙、でした。大きさ的にも全然iPhone Pro Maxよりは明らかに大きくて、雑誌も十分読めるレベルで、漫画は快適。

iPad miniのサイズのままで高級路線(有機EL積んでもらうとか)して欲しいですね。

で、とても気に入ったので、いい感じのスタンドないかなあと思って選んだのがMOFT X。ちょうどiPad mini 6用のサイズのものが出たのでこれがまた快適。どこでもいつでもさっくりスタンドになるのがこんなに良いとは。厚すぎない/重すぎないので、背中に常時貼り付けている状態でも苦にならないし。なんだったらカメラの出っ張り(うざい!)が相殺されて、平らなところにおいてもガタつかなくなったのが最高。

5K2Kは捗る

家の作業環境に割と不満があって、特にモニタ環境が良くなかった(32インチ4K + WQXGAの組合わせ、別に悪くはないんですけどね)ので、5K2K(5120x2160)モニタに変更。

image

現状だとこの解像度はDell U4021QW一択。40インチは大きいかなーと思ったんですが、横に長いから高さ的には今まで使ってた32インチとそう変わらず、ですかね。デカいっちゃあデカですが、こんなもんかな、という感じでもある。この机も横1200の机でそんあ大きい机じゃないんですが、ジャストサイズぐらいで収まりますし。

人によっては100%サイズで使うのは小さい、と思ってしまいそうなところですが、私的には人体改造済み(ICLというレンズを眼球に埋める手術をしたので)なので問題なし、ということで100%サイズで使っています。

このサイズ、いい感じに視野に全部収まるので、デュアルやトリプルよりも快適さあります。今まで、最大で5画面ぐらいまでモニタ増やしてきたのですが、結局メイン以外のものは首をふるのもダルくてそんなに使える感じではないし、音楽プレイヤーのプレイリストを並べるとかだったら、置いてるiPadで聞いて表示しとけばいいじゃん、ということで、全然問題なし。

ツール補助がないとウィンドウがとっちらかってしまうので、Microsoft PowerToysのFancyZonesを使って割り振ってます。これのグリッド吸着がまた使いやすくて(Shift押しながら移動すると、事前定義したグリッドに張り付く)いいですね。

image

中央を広めに取りつつ(主にVisual Studioか、ブラウザがっつし見たりするときはブラウザを置く)、右にブラウザ、左は半分に割って下にGitKraken、上にExplorerみたいなパターンが多めでしょうか。Visual Studio + Unityとか、作業域的に大きく取りたいものを並べる場合は2分割のグリッドパターンも用意して、切り替えるようにしています。

3分割みたいなのは、5K2Kぐらいの解像度がないと出来ないので、この解像度のモニタ増えてくれーって感じですね。選択肢がDELLしかないのは寂しい。液晶自体の画質もそんな良いわけでもないので、もう少し良いのが欲しい。しっかりしたHDR対応のが欲しい。120Hz出るのが欲しい。とか、思うところはそれなりにあります。とはいえ、それでも大満足です。もうこれ以外の解像度のモニタには戻りたくないなあ。

と、いうわけで、モニタ変更のためにゴミ溜めだった机をキレイにした記念で、来年はずっとすっきりした机をキープするぞ、という強い気持ちがあります!単純に机がキレイになってたほうが作業やりやすいですしねー、やっぱゴミ溜めはダメですよ。

キーボードをREALFORCE R3にしたのですが(前はR2でした)、今回からデフォ無線なんですね。キーボードなんて置きっぱなしだから別に有線でいいだろ、と思ってたんですが、これはこれでアリというか、めっちゃいいじゃん?と。サッとキーボードどかしたりがやりやすくなったのがいいですね、デスクで他の作業がやりやすくて。Bluetoothだから、そのままiPadやiPhoneに繋げてもいいし。いやあ、時代は無線。ケーブルがなくなってすっきりするし。

と、いうわけで、無線環境が気に入ったので、デカい有線ヘッドフォン+ヘッドフォンアンプを使っていたのですが(MDR-Z1R + TA-ZH1ES、合計40万もした)、撤去して、写真には写ってませんがAirPods Max買いました。これも満足。いやー、正直あんま有線ヘッドフォン使ってなかったんですよね、面倒くさくて。電源入れるだけ、ではありんですがやっぱ面倒くさくて。ケーブル太くて邪魔くさいし(無駄にケーブル換装して太いケーブルにしてしまったのも良くなかった)。PC専用になっちゃってiPhoneの音も聞けないし。

AirPods MaxだとiPhone/iPad/PCの切り替えが自由なのが想像の100億倍良いなあ、と。音質面でも悪くないし、Apple Musicの空間オーディオとの相性は抜群でこれはめちゃくちゃいいし、さすがのヘッドフォン型なのでAirPods Proよりも音がいい。デジタルクラウンによる音量調整は最高に便利。ゲーム用にDolby Access入れてDolby Atmos for Headphonesを有効化してますが、これもなかなか良い。

スピーカーも置いてたんですが、撤去しました。簡単なものはモニタ内蔵のしょぼいスピーカーで済ませる。ちゃんと聴きたい場合はAirPods Max。それでいいや、と。割り切ったら、全然それでいいじゃん、という気になりました。そもそもあまりデスクトップのスピーカーを稼働させてないしなあ、というのもありますが。

時代は無線

スピーカー撤去の代わりに、じゃないのですが、今年はホームシアターシステムとしてHT-A9を導入したのですが、これも満足度高い。何がいいって、設置が無線で自由度高い。なんか今年は無線化の年ですね、時代は無線。

昔は9.1chにしてたり5.1chだったりでスピーカー並べてたのですが、諸事情あってここ数年はサウンドバーを使ってたんですが、とにかくその音質には不満だったんですね。かといってリアスピーカー並べる気力も起きずというか、そのスペースも確保できない状態なのでどうしたものか、と思っていたところに出たのが、フル無線4.1chシステムのHT-A9。360 Spatial Sound MappingでDolby Atomos時代なサラウンドにするという謳い文句もいいし、設置レイアウトが自由というのがいい。オフィシャルサイトのこの画像を見て購入を決めました。

image

HT-A9の画像、どれもフロントスピーカーの置き方が「わざとらしく」でたらめなんですよね、高さを絶対揃えない。これは、メーカーが別に高さ揃えなくていいんですよ、と推奨してるということなんですが、実際うちでの設置環境もフロントの高さは揃ってないです。揃えられないので。リアも位置も高さもグチャグチャで、適当における棚に置いてるだけって感じです。フロントもリアも無線スピーカーなので、そういう適当配置がめっちゃやりやすい。

それでもちゃんとサウランドするし、360 Spatialな音は心地良い。ただたんに音楽鳴らすだけにも使ってますね、起動も早いのでSpotify Connectでよく流してます。

アンプのサイズが小さい(Apple TVが一回り大きくなった程度)というのも設置が楽になった要因で、いやほんとよく出来てますね。確かに、全部無線なのでスピーカーを駆動するアンプは各スピーカー内蔵状態だから、本体をAVアンプあるあるなクソデカサイズにしなくてもいいんですね。そういうところにも無線の良さが出てますね。

来年に向けてのC#

こうしてダラダラと文章書いたり、そもそもこの12月はやたら記事量産しているなあ、というのは、ブログ書く環境が変わったからです!10年前のWordPressから自家製サイトジェネレーターへの変更によって快適度上がったからですね。いやあ、書きやすいと書く気になります。環境大事。

そんなわけでCysharpはC#はの環境を良くすることに今年一年もちゃんと務められたんじゃないでしょーか。Cysharpからの新規リリース/大型更新は

MessagePipeが大きめかな?

こうした公開していく姿勢、足を少しでも止めてはダメだという思いがあるので、作ろうと思ったらできるだけ勢い持って作りきるようにしています(もちろん途中で止まってしまったものも幾つかありはしますが)。毎年継続的に、Cysharp全体としても既に20個以上公開していて、ヒット作もそれなりの量を出し続けていられる状態は中々のことだと思います。

と、いうわけで、OSSを通じてCysharpをアピールしていくという方向では、良い点をあげてもいいかな、と思うのですが、反面、他の仕事に集中しきれていないのではないかというのが散見していたのは個人的にはマイナスです。今年は「世の中の開発生産性を革命的に改善するプロダクト」の作成に着手し(構想は前からあったのですがようやく始動)、徐々に人も集めだしてCysharpが割とまともな(?)会社っぽく動き出した頃合いでもあるんですが、私がボトルネックになりがち、な状況になりがちなのが、まぁいくないですねえ、と。これはグラニの頃もそうだったので、なんかもうそういうもの感もあるんですが、今回は私が主導してやってるので尻拭いしてくれる人もいないので純粋に良くない!

というわけで、来年の中旬までにその「世の中の開発生産性を革命的に改善するプロダクト」をリリースするために全力でやっていくぞ、というのが目標です。実際出来上がってきて、かなりいいものになりそうな手応えはあるので、早く世の中に出して評価されたいものです。

まぁ、そんなわけで大きな何かがあったわけではないですが、確実に前進した年だと思うので、来年は爆発させる年にしましょう。

ConsoleAppFramework v4 - Minimal API for CommandLine tool

皆さん .NET 6で追加されたMinimal API使ってみました?最初は別にいらんやろ、とか思ってたんですが、いや、これ正直めっちゃ凄い、いい。まぁDelegateベースで書くかどうかは別として(書かないかなー)、謎Startupを葬り去ってBuilder/Runが素直に繋がった形が美しい。Top level statementとの相性も良いので、もうこっちのAPI以外で作る気しないなあ。

さて、ところでConsoleAppFrameworkです。今までクラスが必要だったんですよね、たった一個のメソッドを実装するにも。それがTop level statementとの相性が悪い。Top level statementだけで完結できるとき、クラスって作りたくないんですよね。と、いうわけで、そろそろ大改修が必要かなーと思っていたところにMinimal APIですよ。特にその場でラムダ式でばしばしAPI作っていくスタイルは、むしろコマンドラインツールのほうがマッチするじゃんどう考えても?

と、いうわけで大改修して、Minimal APIベースになったv4、作りました。何が凄いって、一行でコマンドライン引数をパースしてハンドラー定義できちゃうんですね。

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

これは嘘偽りなくNuGetからダウンロードしたら、そのままでこう書けます。C# 10.0のglobal using(をNuGetのライブラリ側に埋め込むというEvilな手法を使ってます)と、ラムダ式の推論の向上によって実現しました。内側では、Minimal APIの実現のために Microsoft.Extensions.* 側にもかなり改修が入っていたので、それをそっくりそのまま利用できました。そういう意味で、 .NET 6になった今だからようやく作れた形になりますね。もちろんv1~v3までの蓄積のお陰というところもあります。集大成……!

さて、Runはちょっとウケ狙いなところもあるんですが、それ以外のAPIもBuilderベースになったので、だいぶ様変わりしています。ただし特徴としてGeneric Hostの上に乗っているというのは変わらないので、DbContext埋めたりappconfig.jsonから取ったりというのは、変わらずスムーズにできます。

// You can use full feature of Generic Host(same as ASP.NET Core).

var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx,services) =>
{
    // Register EntityFramework database context
    services.AddDbContext<MyDbContext>();

    // Register appconfig.json to IOption<MyConfig>
    services.Configure<MyConfig>(ctx.Configuration);

    // Using Cysharp/ZLogger for logging to file
    services.AddLogging(logging =>
    {
        logging.AddZLoggerFile("log.txt");
    });
});

var app = builder.Build();

// setup many command, async, short-name/description option, subcommand, DI
app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y));
app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) =>
{
    await Task.Delay(TimeSpan.FromSeconds(time));
});
app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'"));

// You can insert all public methods as sub command => db select / db insert
// or AddCommand<T>() all public methods as command => select / insert
app.AddSubCommands<DatabaseApp>();

app.Run();

単独のコマンドラインツール用に使ってもいいのですが、ASP.NETのウェブアプリが他にあって、それのバッチを作りたいみたいなときに、こうしたコンフィグの共通化はめっちゃ便利に使えるはずです。ConfigureServicesのコードはまんま一緒にできて、そのままDIできますからね。

また、引き続き AddCommands<T>AddAllCommandType によって、メソッド定義するだけで大量のコマンドを一括追加も可能になっています。

v3 -> v4の破壊的変更

破壊的変更、は沢山あるのですが、基本的に今までの使い方をしている場合は互換オプションで動くようにしたので、アップデートしたから壊れるということはない、はずです。v4からはConsoleApp.Create/CreateBuilder 経由で作るのが基本なのですが、v3は Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<T>() 経由なので、ちょうど互換性オプションを突っ込むのに都合が良かったんですね。なお、RunConsoleAppFrameworkAsyncはエディタから見えないようにしてます。今後は非推奨で、本当に互換のためだけに残してます。

まず変わったところは、デフォルトで長いオプション名が--、短いオプション名が-になりました。v3では-が幾つついていてもいいというゆるふわマッチングだったのですが、(dotonet toolsと同じように)厳格化しています。

また、デフォルトのコマンド/オプション名の変換ルールが単純なlower化から、hoge-hugaというlowerなkebab-caseになりました。これもdotnet tools合わせですね。

また、AddCommands<T>した場合の挙動(v3ではRunConsoleAppFrameworkAsync<T>した場合)が、全てのpublicメソッドをコマンドとして追加するようになりました。デフォルト(ルート)コマンドにしたい場合は[RootCommand]属性を付与してくださいということで。これはAddSubCommand<T>した時と挙動を合わせたかったからです、違うと一貫性がなくて戸惑うので。

と、いうわけで、互換性モードで動かした場合はConsoleAppOptionsは以下のような変更で動くようになっています。よきかなよきかな。(それとargsのコマンド名でHoge.Hugaが来てたらHoge Hugaに分解するのも、この互換性モードだけの挙動です)

options.StrictOption = false;
options.NoAttributeCommandAsImplicitlyDefault = true;
options.NameConverter = x => x.ToLower();
options.ReplaceToUseSimpleConsoleLogger = false;

そうだ、それとCtrl+Cした場合に、正しくCancellationTokenをハンドリングしていない場合でも、タイムアウトをハンドリングしてabortするようになりました。これは、なんか強制終了できなくてウゼーってなりがちというか、私自身よく引っかかってヤバかったので。むしろこれは今までがバグに近くて、正しくHostOptions.ShutdownTimeoutを処理していないせいでした。

ちなみにこのタイムアウト時間はデフォルトは5秒で、ConfigureHostOptions(地味にこれは.NET 6(というかMicrosoft.Extensionsのv6)からの新API)で変更できます。

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureHostOptions(options =>
    {
        // change timeout.
        options.ShutdownTimeout = TimeSpan.FromMinutes(30);
    })
    .Build();

まとめ

無計画にアドホックに作っていったせいで、どうにもクソコードすぎて、改修にめっちゃ手間取ったというか内部的にはほぼ作り直した……。弄るのだるくて嫌だなあと内心実際今まで思ってたんですが、やはりとても嫌なコードであった。v1の時の最初の発想が Class.Method にパラメータ分解してバッチを大量に作りたい(そもそもライブラリ名もMicroBatchFrameworkだったし)というものだけだったのが、徐々に汎用コマンドラインツールに進化していって、都度、適当に追加していった結果ではある。

今回がっつし仕切り直したので、しばらくはメンテが楽になれるかなあ、という感じで、よきかなよきかな。

まぁしかしC# 10.0は地味にヤバいですよ!使えば使うほど味が出てくるというか、最近ようやく手に馴染んで、よくわかってきた感じです。なんというか、とにかく、めっちゃいい。それとC# 10.0 + ConsoleAppFrameworkは全言語見渡しても最強のコマンドラインツール作成ライブラリじゃないです?いや、API自体のできの良さはほとんど ASP .NET CoreのMinimal APIのコピーにすぎないんですが、まぁしかしそれでもやっぱ、これはかなり良い感じじゃないかという手応えがあります。

NativeMemoryArray - .NET 6 APIをフル活用した2GB超えの巨大データを扱うライブラリ

.NET 6 Advent Calendar 2021の12日の代理投稿となります。プレゼント付きですと!?BALMUDA The Brew STARBUCKS RESERVE LIMITED EDITIONが欲しいです!

さて、先程NativeMemoryArrayという新しいライブラリを作成し、公開しました。.NET Standard 2.0でも動作しますが、全体的に .NET 6 の新API群(NativeMemory, Scatter/Gather I/O)を活かすための作りになっていますので、今回のAdvent Calendarにもピッタリ。実用性も、ある……!あります……!もちろんUnity版も用意してあります(NativeArrayと何が違うって?まぁ違うと言えば違います)。

C#には配列、特にbyte[]を扱う上で大きな制約が一つあります。それは、一次元配列の上限値が0x7FFFFFC7(2,147,483,591)ということ。int.MaxValueよりちょっと小さめに設定されていて、ようするにざっくり2GBちょいが限界値になっています。

この限界値は、正確には .NET 6 でひっそり破壊的変更が行われましたので、.NET 6とそれ以外で少し異なります。詳しくは後で述べます。

この2GBという値は、int Lengthの都合上しょうがない(intの限界値に引っ張られている)のですが、昨今は4K/8Kビデオや、ディープラーニングの大容量データセットや、3Dスキャンの巨大点群データなどで、大きな値を扱うことも決して少ないわけではないため、2GB制約は正直厳しいです。そして、この制約はSpan<T>Memory<T>であっても変わりません(Lengthがintのため)。

ちなみにLongLengthは多次元配列における全次元の総数を返すためのAPIのため、一次元配列においては特に意味をなしません。.NET Frameworkの設定であるgcAllowVeryLargeObjectsも、構造体などを入れた場合の大きなサイズを許容するものであり(例えば4バイト構造体の配列ならば、2GB*4のサイズになる)、要素数の限界は超えられないため、byte[]としては2GBが限界であることに変わりはありません。

こうした限界に突き当たった場合は、ストリーミング処理に切り替えるか、またはポインタを使って扱うかになりますが、どちらもあまり処理しやすいとは言えませんし、必ずしもインメモリで行っていた操作が代替できるわけではありません(ポインタなら頑張れば最終的にはなんとでもなりますが)。

そこで、2GB制約を超えつつも、新しいAPI群(Span<T>, IBufferWriter<T>, ReadOnlySequence<T>, RandomAccess.Write/Read, System.IO.Pipelinesなど)と親和性の高いネイティブメモリを裏側に持つ配列(みたいな何か)を作りました。

これによって、例えば巨大データの読み込み/書き込みも、 .NET 6の新Scatter/Gather APIのRandomAccessを用いると、簡単に処理できます。

// for example, load large file.
using var handle = File.OpenHandle("4GBfile.bin", FileMode.Open, FileAccess.Read, options: FileOptions.Asynchronous);
var size = RandomAccess.GetLength(handle);

// via .NET 6 Scatter/Gather API
using var array = new NativeMemoryArray<byte>(size);
await RandomAccess.ReadAsync(handle, array.AsMemoryList(), 0);

// iterate Span<byte> as chunk
foreach (var chunk in array)
{
    Console.WriteLine(chunk.Length);
}

Scatter/Gather APIに馴染みがなくても、IBufferWriter<T>IEnumerable<Memory<T>> を経由してStreamで処理する手法も選べます。

public static async Task ReadFromAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    var writer = buffer.CreateBufferWriter();

    int read;
    while ((read = await stream.ReadAsync(writer.GetMemory(), cancellationToken).ConfigureAwait(false)) != 0)
    {
        writer.Advance(read);
    }
}

public static async Task WriteToAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    foreach (var item in buffer.AsMemorySequence())
    {
        await stream.WriteAsync(item, cancellationToken);
    }
}

あるいはSpan<T>のSliceを取り出して処理してもいいし、ref T this[long index]によるインデクサアクセスやポインタの取り出しもできます。 .NET 6時代に完全にマッチしたAPIを揃えることで、標準の配列と同等、もしくはそれ以上の使い心地に仕上げることによって、C#の限界をまた一つ超える提供できたと思っています。

とはいえもちろん、 .NET Standard 2.0/2.1 にも対応しているので、非 .NET 6なAPIでも大丈夫です、というかScatter/Gather API以外は別に今までもありますし普通に使えますので。

普通の配列的にも使えます。GC避けには、こうした普通のAPIを使っていくのでも便利でしょう、

// call ctor with length, when Dispose free memory.
using var buffer = new NativeMemoryArray<byte>(10);

buffer[0] = 100;
buffer[1] = 100;

// T allows all unmanaged(struct that not includes reference type) type.
using var mesh = new NativeMemoryArray<Vector3>(100);

// AsSpan() can create Span view so you can use all Span APIs(CopyTo/From, Write/Read etc.).
var otherMeshArray = new Vector3[100];
otherMeshArray.CopyTo(mesh.AsSpan());

NativeMemoryArray<T>

NativeMemoryArray<T>はwhere T : unmanagedです。つまり、参照型を含まない構造体にしか使えません。まぁ巨大配列なんて使う場合には参照型含めたものなんて含めてんじゃねーよなので、いいでしょうきっと。巨大配列で使えることを念頭においてはいますが、別に普通のサイズの配列として使っても構いません。ネイティブメモリに確保するので、ヒープを汚さないため、適切な管理が行える箇所では便利に使えるはずです。

Span<T>との違いですが、NativeMemoryArray<T>そのものはクラスなので、フィールドに置けます。Span<T>と違って、ある程度の長寿命の確保が可能ということです。Memory<T>のSliceが作れるため、Async系のメソッドに投げ込むこともできます。また、もちろん、Span<T>の長さの限界はint.MaxValueまで(ざっくり2GB)なので、それ以上の大きさも確保できます。

UnityにおけるNativeArray<T>との違いですが、NativeArray<T>はUnity Engine側との効率的なやりとりのための入れ物なので、あくまでC#側で使うためのNativeMemoryArray<T>とは全然役割が異なります。まぁ、必要に思えない状況ならば、おそらく必要ではありません。

主な長所は、以下になります。

  • ネイティブメモリから確保するためヒープを汚さない
  • 2GBの制限がなく、メモリの許す限り無限大の長さを確保できる
  • IBufferWriter<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonWriter, System.IO.Pipelinesなどから直接読み込み可能
  • ReadOnlySequence<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonReaderなどへ直接データを渡すことが可能
  • IReadOnlyList<Memory<T>>, IReadOnlyList<ReadOnlyMemory<T>> 経由で RandomAccess(Scatter/Gather API)に巨大データを直接渡すことが可能

あまりピンと来ない、かもしれませんが、使ってみてもらえれば分かる、かも。

NativeMemoryArray<T>の全APIは以下のようになっています。

  • NativeMemoryArray(long length, bool skipZeroClear = false, bool addMemoryPressure = false)
  • long Length
  • ref T this[long index]
  • ref T GetPinnableReference()
  • Span<T> AsSpan()
  • Span<T> AsSpan(long start)
  • Span<T> AsSpan(long start, int length)
  • Memory<T> AsMemory()
  • Memory<T> AsMemory(long start)
  • Memory<T> AsMemory(long start, int length)
  • bool TryGetFullSpan(out Span<T> span)
  • IBufferWriter<T> CreateBufferWriter()
  • SpanSequence AsSpanSequence(int chunkSize = int.MaxValue)
  • MemorySequence AsMemorySequence(int chunkSize = int.MaxValue)
  • IReadOnlyList<Memory<T>> AsMemoryList(int chunkSize = int.MaxValue)
  • IReadOnlyList<ReadOnlyMemory<T>> AsReadOnlyMemoryList(int chunkSize = int.MaxValue)
  • ReadOnlySequence<T> AsReadOnlySequence(int chunkSize = int.MaxValue)
  • SpanSequence GetEnumerator()
  • void Dispose()

AsSpan(), AsMemory()はスライスのためのAPIです。取得したSpanやMemoryは書き込みも可能なため、 .NET 5以降に急増したSpan系のAPIに渡せます。SpanやMemoryには最大値(int.MaxValue)の限界があるため、lengthの指定がない場合は、例外が発生する可能性もあります。そこでTryGetFullSpan()を使うと、単一Spanでフル取得が可能かどうか判定できます。また、AsSpanSequence(), AsMemorySequence()でチャンク毎のforeachで全要素を列挙することが可能です。直接foreachした場合は、AsSpanSequence()と同様の結果となります。

long written = 0;
foreach (var chunk in array)
{
    // do anything
    written += chunk.Length;
}

ポインタの取得は、配列とほぼ同様に、そのまま渡せば0から(これはGetPinnableReference()の実装によって実現できます)、インデクサ付きで渡せばそこから取れます。

fixed (byte* p = buffer)
{
}

fixed (byte* p = &buffer[42])
{
}

CreateBufferWriter() によって IBufferWriter<T>を取得できます。これはMessagePackSerializer.Serializeなどに直接渡すこともできるほかに、先の例でも出しましたがStreamからの読み込みのように、先頭からチャンク毎に取得して書き込んでいくようなケースで便利に使えるAPIとなっています。

AsReadOnlySequence() で取得できるReadOnlySequence<T>は、MessagePackSerializer.Deserializeなどに直接渡すこともできるほかに .NET 5から登場した SequenceReaderに通すことで、長大なデータのストリーミング処理をいい具合に行える余地があります。

AsMemoryList(), AsReadOnlySequence()は .NET 6から登場したRandomAccessRead/Writeに渡すのに都合の良いデータ構造です。プリミティブな処理なので使いにくいと思いきや、意外とすっきりと処理できるので、File経由の処理だったらStreamよりもいっそもうこちらのほうがいいかもしれません。

NativeMemory

.NET 6からNativeMemoryというクラスが新たに追加されました。その名の通り、ネイティブメモリを扱いやすくするものです。今までもMarshal.AllocHGlobalといったメソッド経由でネイティブメモリを確保することは可能であったので、何が違うのか、というと、何も違いません。実際NativeMemoryArrayの .NET 6以前版はMarshalを使ってますし。そして .NET 6 では Marshal.AllocHGlobal は NativeMemory.Alloc を呼ぶので、完全に同一です。

ただしもちろん .NET 6 実装時にいい感じに整理された、ということではあるので、NativeMemory、いいですよ。NativeMemory.Allocがmalloc、NativeMemory.AllocZeroedがcalloc、NativeMemory.Freeがfreeと対応。わかりやすいですし。

ちなみにゼロ初期化する NativeMemory.AllocZeroed に相当するものはMarshalにはないので、その点でも良くなったところです。NativeMemoryArray<T>では、コンストラクタのskipZeroClear(public NativeMemoryArray(long length, bool skipZeroClear = false))によってゼロ初期化する/しないを選べます。デフォルトは(危ないので)初期化しています。非.NET 6版では、メモリ確保後にSpan<T>.Clear()経由で初期化処理を入れています。

真のArray.MaxValue

.NET 6以前では、配列の要素数はバイト配列(1バイト構造体の配列)と、それ以外の配列で異なる値がリミットに設定されていました。例えばSystem.Arrayのドキュメントを引いてくると

配列のサイズは、合計で40億の要素に制限され、任意の次元の0X7FEFFFFF の最大インデックス (バイト配列の場合は0X7FFFFFC7、1バイト構造体の配列の場合) に制限されます。

つまり、0X7FFFFFC7の場合と、0X7FEFFFFFの場合がある、と。

と、いうはずだったのですが、.NET 6からArray.MaxLengthというプロパティが新規に追加されて、これは単一の定数を返します。その値は、0X7FFFFFC7です。よって、いつのまにかひっそりと配列の限界値は(ちょびっと大きい方に)大統一されました。

この変更は意外とカジュアルに行われ、まず最大値を取得する、ただし単一じゃないため型によって結果の変わる Array.GetMaxLength<T>() を入れよう、という実装があがってきました。そうしたら、そのPR上での議論で、そもそも当初は最適化を期待したけど別にそんなことなかったし、統一しちゃってよくね?という話になり、そのまま限界値は統一されました。そして新規APIも無事、Array.MaxLengthという定数返しプロパティになりました。

まぁ、シンプルになって良いですけどね。大きい方で統一されたので実害も特にないでしょうし。前述のSystem.Arrayのドキュメントは更新されてないということで、正しくは、.NET 6からは0x7FFFFFC7が限界で、その値はArray.MaxLengthで取れる。ということになります。

Span<T>の限界値はint.MaxValueなので、限界に詰め込んだSpan<T>をそのままToArray()すると死ぬ、という微妙な問題が発生することがあるんですが、まぁそこはしょうがないね。

まとめ

NativeArrayという名前にしたかったのですがUnityと被ってしまうので避けました。しょーがない。

着手当時はマネージド配列のチャンクベースで作っていたのですが(LargeArray.cs)、Sliceが作りづらいし、ネイティブメモリでやったほうが出来ること多くて何もかもが圧倒的にいいじゃん、ということに作業進めている最中に気づいて、破棄しました。参照型の配列が作れるという点で利点はありますが、まぁ参照型で巨大配列なんて作らねーだろ、思うと、わざわざ実装増やして提供するメリットもないかな、とは。

配列はもう昔からあるのでint Lengthなのはしょうがないのですが、Span<T>, Memory<T>のLengthはlongであって欲しかったかなー、とは少し思っています。2016年の段階でのSpanのAPIどうするかドキュメントによると、候補は幾つかあったけど、結果的に配列踏襲のint Lengthになったそうで。2GBでも別に十分だろ、みたいなことも書いてありますが、いや、そうかなー?年にそこそこの回数でたまによく引っかかるんだけどねー?

そして2016年の議論時点ではなかった、C# 9.0でnuint, nuintが追加されたので、nuint Span<T>/Memory<T>.Lengthはありなんじゃないかな、と。

ただNativeMemoryArrayの開発当初はnuint Lengthで作っていたのですが、AsSpan(nuint start, nuint length)みたいなAPIは、カジュアルにintやlongを突っ込めなくて死ぬほど使いづらかったので、最終的にlongで統一することにしました。ので、nuint Lengthは、なしかな。つまり一周回って現状維持。そんなものかー、そんなもんですねー。

.NET 6とAngleSharpによるC#でのスクレイピング技法

C# Advent Calendar 2021の参加記事となっています。去年は2個エントリーしたあげく、1個すっぽかした(!)という有様だったので、今年は反省してちゃんと書きます。

スクレイピングに関しては10年前にC#でスクレイピング:HTMLパース(Linq to Html)のためのSGMLReader利用法という記事でSGMLReaderを使ったやり方を紹介していたのですが、10年前ですよ、10年前!さすがにもう古臭くて、現在ではもっとずっと効率的に簡単にできるようになってます。

今回メインで使うのはAngleSharpというライブラリです。AngleSharp自体は2015年ぐらいからもう既に定番ライブラリとして、日本でも紹介記事が幾つかあります。が、いまいち踏み込んで書かれているものがない気がするので、今回はもう少しがっつりと紹介していきたいと思っています。それと直近Visual StudioのWatchウィンドウの使い方を知らん、みたいな話を聞いたりしたので、デバッグ方法の手順みたいなものを厚めに紹介したいなあ、という気持ちがあります!

AngleSharpの良いところは、まずはHTMLをパースしてCSSセレクターで抽出できるところです。以前はLINQ(to DOM)があればCSSセレクターじゃなくてもいいっす、WhereとSelectManyとDescendantsでやってきますよ、とか言ってましたが、そんなにきちんと構造化されてるわけじゃないHTMLを相手にするのにあたっては、CSSセレクターのほうが100億倍楽!CSSセレクターの文法なんて大したことないので、普通に覚えて使えってやつですね。SQLと正規表現とCSSセレクターは三大言語関係なく覚えておく教養、と。

もう一つは、それ自体でネットワークリクエストが可能なこと。FormへのSubmitなどもサポートして、Cookieも保持し続けるとかが出来るので、ログインして会員ページを弄る、といったようなクローラーが簡単に書けるんですね。この辺非常に良く出来ていて、もう自前クローラーなんて投げ捨てるしかないです。また、JintというPure C#なJavaScriptインタプリタと統合したプラグインも用意されているので、JavaScriptがDOMをガリガリっと弄ってくる今風のサイトにも、すんなり対応できます。

AngleSharpの紹介記事では、よくHttpClientなどで別途HTMLを取ってきたから、それをAngleSharpのHtmlParserに読み込ませる、というやり方が書かれていることが多いのですが、取得も含めて全てAngleSharp上で行ったほうが基本的には良いでしょう。

ここまで来るとPure C#の軽量なヘッドレスブラウザとしても動作する、ということになるので、カジュアルなE2Eテストの実装基盤にもなり得ます。普通のユニットテストと並べて dotnet test だけでその辺もある程度まかなえたら、とても素敵なことですよね?がっつりとしたE2Eテストを書きたい場合はPlaywrightなどを使わなければ、ということになってしまいますが、まずは軽い感じから始めたい、という時にうってつけです。C#で書けるし。いいことです。

BrowingContextとQuerySelectorの基本

まずはシンプルなHTMLのダウンロードと解析を。基本は BrowsingContext を作って、それをひたすら操作していくことになります。

// この辺で色々設定する
var config = Configuration.Default
    .WithDefaultLoader(); // LoaderはデフォではいないのでOpenAsyncする場合につける

// Headless Browser的なものを作る
using var context = BrowsingContext.New(config);

// とりあえずこのサイトの、右のArchivesのリンクを全部取ってみる
var doc = await context.OpenAsync("https://neue.cc");

OpenAsyncで取得できた IDocument をよしなにCSSセレクターで解析していくわけですが、ここで絞り込みクエリー作成に使うのがVisual StudioのWatchウィンドウ。(Chromeのデベロッパーツールなどで機械的に取得したい要素のCSSセレクターを取得できたりしますが、手セレクターのほうがブレなくルールは作りやすいかな、と)。

デバッガーを起動して、とりあえずウォッチウィンドウを開いておもむろに、Nameのところでコードを書きます。

image

ウォッチウィンドウは見たい変数を並べておく、お気に入り的な機能、と思いきや本質的にはそうじゃなくて、式を自由に書いて、結果を保持する、ついでに式自体も保持できるという、実質REPLなのです。代入もラムダ式もLINQも自由に書けるし、入力補完も普通に出てくる。Immediate Windowよりも結果が遥かに見やすいので、Immediate Windowは正直不要です。

デバッガー上で動いているので実データを自由に扱えるというところがいいですね。というわけで、ToHtml()でHTMLを見て、QuerySelectorAllをゆっくり評価しながら書いていきましょう。まずはサイドバーにあるので .side_body を出してみると、あれ、二個あるの?と。

image

中開けてInnerHtml見ると、なるほどProfile部分とArchive部分、と。とりあえず後ろのほうで固定のはずなのでlast-childね、というところで一旦評価して大丈夫なのを確認した後に、あとはa、と。でここまでで期待通りの結果が取れていれば、コピペる。よし。

// 基本、QuerySelectorかQuerySelectorAllでDOMを絞り込む
var anchors = doc.QuerySelectorAll(".side_body:last-child a")
    .Cast<IHtmlAnchorElement>() // AngleSharp.Html.Dom
    .Select(x => x.Href)
    .ToArray();

単一の要素に絞り込んだ場合は、 IHtml*** にキャストしてあげると扱いやすくなります(attributeのhrefのtextを取得、みたいにしなくていい)。頻出パターンなので、QuerySelectorAll<T>でCastもセットになってすっきり。

doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a")

せっかくなので、年に何本記事を書いていたかの集計を出してみたいと思います!URLから正規表現で年と月を取り出すので、とりあえずここでもウォッチウィンドウです。

image

anchrosの[0]を確認して、これをデータソースとしてRegex.Matchを書いて、どのGroupに収まったのかを見ます。この程度だったら特にミスらないでしょー、と思いきや普通に割とミスったりするのが正規表現なので、こういうので確認しながらやっていけるのはいいですね。

後は普通の(?)LINQコード。グルーピングした後に、ひたすら全ページをOpenAsyncしていきます。記事の本数を数えるのはh1の数をチェックするだけなので、特に複雑なCSSセレクターは必要なし。本来はページングの考慮は必要ですが、一月単位だとページングが出てくるほどの記事量がないので、そこも考慮なしで。

var yearGrouped = anchors
    .Select(x =>
    {
        var match = Regex.Match(x, @"(\d+)/(\d+)");
        return new
        {
            Url = x,
            Year = int.Parse(match.Groups[1].Value),
            Month = int.Parse(match.Groups[2].Value)
        };
    })
    .GroupBy(x => x.Year);

foreach (var year in yearGrouped.OrderBy(x => x.Key))
{
    var postCount = 0;
    foreach (var month in year)
    {
        var html = await context.OpenAsync(month.Url);
        postCount += html.QuerySelectorAll("h1").Count(); // h1 == 記事ヘッダー
    }
    Console.WriteLine($"{year.Key}年記事更新数: {postCount}");
}

結果は

2009年記事更新数: 92
2010年記事更新数: 61
2011年記事更新数: 66
2012年記事更新数: 30
2013年記事更新数: 33
2014年記事更新数: 22
2015年記事更新数: 19
2016年記事更新数: 24
2017年記事更新数: 13
2018年記事更新数: 11
2019年記事更新数: 14
2020年記事更新数: 11
2021年記事更新数: 5

ということで右肩下がりでした、メデタシメデタシ。今年は特に書いてないなあ、せめて2ヶ月に1本は書きたいところ……。

なお、C#による自家製静的サイトジェネレーターに移行した話 で紹介しているのですが、このサイトは完全にGitHub上に.mdがフラットに並んで.mdが管理されているので、こういうの出すなら別にスクレイピングは不要です。

UserAgentを変更する

スクレイピングといったらログインしてごにょごにょする。というわけで、そうしたログイン処理をさくっとやってくれるのがAngleSharpの良いところです。ので紹介していきたいのですが、まずはやましいことをするので(?)、UserAgentを偽装しましょう。

AngleSharpが現在何を送っているのかを確認するために、とりあえずダミーのサーバーを立てます。その際には .NET 6 のASP .NET から搭載されたMinimal APIが非常に便利です!そしてそれをLINQPadで動かすと、テスト用サーバーを立てるのにめっちゃ便利です!やってみましょう。

image

たった三行でサーバーが立ちます。便利。

await context.OpenAsync("http://localhost:5000/headers");

でアクセスして、 AngleSharp/1.0.0.0 で送られていることが確認できました。

なお、LINQPadでASP.NETのライブラリを使うには、Referene ASP.NET Core assembliesのチェックを入れておく必要があります。

image

他、よく使うNuGetライブラリや名前空間なども設定したうえで、Set as default for new queriesしておくと非常に捗ります。

さて、で、このUser-Agentのカスタマイズの方法ですが、AngleSharpはServicesに機能が詰まっているようなDI、というかService Locatorパターンの設計になっているので、ロードされてるServicesを(Watch Windowで)一通り見ます。

image

型に限らず全Serviceを取得するメソッドが用意されていない場合でも、<object>で取ってやると全部出てくるような実装は割と多い(ほんと)ので、とりあえずやってみるのはオススメです。今回も無事それで取れました。

で、型名を眺めてそれっぽそうなのを見ると DefaultHttpRequester というのがかなりそれっぽく、その中身を見るとHeadersという輩がいるので、これを書き換えればいいんじゃないだろうかと当たりがつきます。

ここはやましい気持ちがあるので(?)Chromeに偽装しておきましょう。

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36";

再びOpenAsyncしてLINQPadの表示を見て、変更されてること確認できました。

image

ちなみに、DefaultじゃないHttpRequesterをConfigurationに登録しておく、ということも出来ますが、よほどカスタムでやりたいことがなければ、デフォルトのものをちょっと弄るぐらいの方向性でやっていったほうが楽です。

FormにSubmitする

クローラーと言ったらFormにSubmit、つまりログイン!そしてクッキーをいただく!認証!

さて、が、まぁ認証付きの何かを例にするのはアレなので、googleの検索フォームを例にさせていただきたいと思います。先にまずはコード全体像と結果を。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom; // 拡張メソッドとかで有効化されたりするのでusing大事
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies(); // login form的なものの場合これでクッキーを持ち歩く

using var context = BrowsingContext.New(config);

// お行儀悪いので(?)前述のこれやっておく
var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://google.com/");
var form = doc.Forms[0];
var result = await form.SubmitAsync(new { q = "AngleSharp" }); // name = valueは匿名型が使える

// とりあえず結果を表示しておく
var titles = result.QuerySelectorAll<IHtmlHeadingElement>("h3").Select(x => x.TextContent);
var i = 1;
foreach (var item in titles)
{
    Console.WriteLine($"{i++:00}: {item}");
}

image

WithDefaultLoader と、そして認証クッキー持ち歩きのために WithDefaultCookies をコンフィギュレーションに足しておくことが事前準備として必須です。User-Agentの書き換えはご自由に、ただやましいこと、ではなくてUA判定をもとにして処理する、みたいなサイトも少なからずあるので、余計ないこと考えなくて済む対策としてはUAをChromeに偽装しておくのはアリです。

FormへのSubmit自体は3行というか2行です。ページをOpenしてFormに対してSubmitするだけ。超簡単。 .FormsIHtmlElementFormsがすっと取れるので、あとは単純にSubmitするだけです。渡す値は { name = value }の匿名型で投げ込めばOK。

度々出てくるウォッチウィンドウの宣伝ですが、この何の値を投げればいいのか、を調べるのにHTMLとニラメッコではなく、ウォッチウィンドウで調査していきます。

image

まず("input")を拾うのですが、9個ある。多いね、で、まぁこれはほとんどtype = "hidden"なので無視して良い(AngleSharpがSubmitAsync時にちゃんと自動でつけて送信してくれる)。値を入れる必要があるのはhiddden以外のものなので、それをウォッチで普通にLINQで書けば、3件に絞れました。で、中身見ると必要っぽいのはqだけなので、 new { q = "hogemoge" } を投下、と。

認証が必要なサイトでは、これでBrowingContextに認証クッキーがセットされた状態になるので、以降のこのContextでのOpenや画像、動画リクエストは認証付きになります。

画像や動画を拾う

スクレイピングといったら画像集めマンです(?)。AngleSharpでのそうしたリソース取得のやり方には幾つかあるのですが、私が最も良いかな、と思っているのはIDocumentLoader経由でのフェッチです。

// BrowsingContextから引っ張る。Contextが認証クッキー取得済みなら認証が必要なものもダウンロードできる。
var loader = context.GetService<IDocumentLoader>();

// とりあえず適当にこのブログの画像を引っ張る
var response = await loader.FetchAsync(new DocumentRequest(new Url("https://user-images.githubusercontent.com/46207/142736833-55f36246-cb7f-4b62-addf-0e18b3fa6d07.png"))).Task;

using var ms = new MemoryStream();
await response.Content.CopyToAsync(ms);

var bytes = ms.ToArray(); // あとは適当にFile.WriteAllBytesでもなんでもどうぞ

内部用なので少し引数やAPIが冗長なところもありますが、それは後述しますが別になんとでもなるところなので、どちらかというと生のStreamが取れたりといった柔軟性のところがプラスだと思っています。普通にHttpClientで自前で取るのと比べると、認証周りやってくれた状態で始められるのが楽ですね。

並列ダウンロードもいけます、例えば、このブログの全画像を引っ張るコードを、↑に書いた全ページ取得コードを発展させてやってみましょう。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://neue.cc/");
var loader = context.GetService<IDocumentLoader>();

foreach (var arvhives in doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a"))
{
    var page = await context.OpenAsync(arvhives.Href);

    // content(ページ本体)下のimgを全部。
    // 今回はページ単位で5並列ダウンロードすることにする(粒度の考え方は色々ある)
    var imgs = page.QuerySelectorAll<IHtmlImageElement>("#content img");
    await Parallel.ForEachAsync(imgs, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (img, ct) =>
     {
         var url = new Url(img.Source);
         var response = await loader.FetchAsync(new DocumentRequest(url)).Task;

         // とりあえず雑にFile書き出し。
         Console.WriteLine($"Downloading {url.Path}");
         using (var fs = new FileStream(@$"C:\temp\neuecc\{url.Path.Replace('/', '_')}", FileMode.Create))
         {
             await response.Content.CopyToAsync(fs, ct);
         }
     });
}

.NET 6から Parallel.ForEachAsync が追加されたので、asyncコードを並列数(MaxDegreeOfParallelism)で制御した並列実行が容易に書けるようになりました。async/await以降、Parallel系の出番は圧倒的に減ったのは確かなのですが、Task.WhenAllだけだと並列に走りすぎてしまって逆に非効率となってしまって、そこを制御するコードを自前で用意する必要が出てきていたりと面倒なものも残っていました。それが、このParallel.ForEachAsyncで解消されたと思います。

Kurukuru Progress

数GBの動画をダウンロードする時などは、プログレスがないとちゃんと動いているのか確認できなくて不便です。しかし、ただ単にConsole.WriteLineするだけだとログが凄い勢いで流れていってしまって見辛くて困りものです。そこを解決するC#ライブラリがKurukuruで、見ればどんなものかすぐわかるので、まずは実行結果を見てもらいましょう(素の回線だと一瞬でダウンロード終わってしまったので回線の低速シミュレーションしてます)

guruguru

一行だけを随時書き換えていってくれるので、見た目も非常に分かりやすくて良い感じです。これはとても良い。Kurukuru、今すぐ使いましょう。ちなみに今回の記事で一番時間がかかったのは、Kurukuruの並列リクエスト対応だったりして(対応していなかったのでコード書いてPR上げて、今日リリースしてもらいましたできたてほやほやコード)。

AngleSharp側のコードですが、この例はFile Examples のMP4を並列で全部取るというものです。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
using Kurukuru;
using System.Text;

// Kurukuruを使う上で大事なおまじない
// え、デフォルトのEncodingがUTF8じゃないシェルがあるんです!?←Windows
Console.OutputEncoding = Encoding.UTF8;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://file-examples.com/index.php/sample-video-files/sample-mp4-files/");
var loader = context.GetService<IDocumentLoader>();

// ここから本体
var mp4s = doc.QuerySelectorAll<IHtmlAnchorElement>("a").Where(x => x.Href.EndsWith(".mp4"));
Console.WriteLine("Download sample-mp4-files");
await Parallel.ForEachAsync(mp4s, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (mp4, ct) =>
{
    var bin = await loader.FetchBytesAsync(mp4.Href);
    // あとはFile.WriteAllBytesするとか好きにして
});

ポイントは var bin = await loader.FetchBytesAsync(mp4.Href); で、これは拡張メソッドです。loaderにProgress付きでbyte[]返すメソッドを生やしたことで、随分シンプルに書けるようになりました。StreamのままFileStreamに書いたほうがメモリ節約的にはいいんですが、中途半端なところでコケたりした場合のケアが面倒くさいので、ガチガチなパフォーマンスが重視される場合ではないならbyte[]のまま受けちゃってもいいでしょう。1つ4GBの動画を5並列なんですが?という場合でも、たかがメモリ20GB程度なので普通にメモリ積んで処理すればいいっしょ。

FetchBytesAsyncの中身は以下のようなコードになります。

public static class DocumentLoaderExtensions
{
    public static async Task<byte[]> FetchBytesAsync(this IDocumentLoader loader, string address, CancellationToken cancellationToken = default)
    {
        var url = new AngleSharp.Url(address);
        var response = await loader.FetchAsync(new DocumentRequest(url)).Task;
        if (response.StatusCode != System.Net.HttpStatusCode.OK)
        {
            return Array.Empty<byte>(); // return empty instead of throws error(ここをどういう挙動させるかは好みで……。)
        }

        // Content-Lengthが取れない場合は死でいいということにする
        var contentLength = int.Parse(response.Headers["Content-Length"]);

        using var progress = new ProgressSpinner(url.Path.Split('/').Last(), contentLength);
        try
        {
            return await ReadAllDataAsync(response.Content, contentLength, progress, cancellationToken);
        }
        catch
        {
            progress.Cancel();
            throw;
        }
    }

    static async Task<byte[]> ReadAllDataAsync(Stream stream, int contentLength, IProgress<int> progress, CancellationToken cancellationToken)
    {
        var buffer = new byte[contentLength];
        var readBuffer = buffer.AsMemory();
        var len = 0;
        while ((len = await stream.ReadAsync(readBuffer, cancellationToken)) > 0)
        {
            progress.Report(len);
            readBuffer = readBuffer.Slice(len);
        }
        return buffer;
    }
}

public class ProgressSpinner : IProgress<int>, IDisposable
{
    readonly Spinner spinner;
    readonly string fileName;
    readonly int? totalBytes;
    int received = 0;

    public ProgressSpinner(string fileName, int? totalBytes)
    {
        this.totalBytes = totalBytes;
        this.fileName = fileName;
        this.spinner = new Spinner($"Downloading {fileName}");
        this.spinner.Start();
    }

    public void Report(int value)
    {
        received += value;
        if (totalBytes != null)
        {
            var percent = (received / (double)totalBytes) * 100;
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)} / {ToHumanReadableBytes(totalBytes.Value)} ( {Math.Floor(percent)}% )";
        }
        else
        {
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)}";
        }
    }

    public void Cancel()
    {
        spinner.Fail($"Canceled {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    public void Dispose()
    {
        spinner.Succeed($"Downloaded {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    static string ToHumanReadableBytes(int bytes)
    {
        var b = (double)bytes;
        if (b < 1024) return $"{b:0.00} B";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} KB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} MB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} GB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} TB";
        b /= 1024;

        return $"{0:0.00} PB";
    }
}

KurukuruのSpinnerを内包した IProgress<T> を作ってあげて、その中でよしなにやってあげるということにしました。まぁちょっと長いですが、一回用意すれば後はコピペするだけなので全然いいでしょう。みなさんもこのProgressSpinner、使ってやってください。

コマンド引数やロギング処理やオプション取得

クローラーとしてガッツシやりたいなら、モードの切り替えとかロギングとか入れたいです、というか入れます。そこで私が定形として使っているのはConsoleAppFrameworkZLogger。Cysharpの提供です。ワシが作った。それと今回のようなケースだとKokubanも便利なので入れます。やはりCysharpの提供です。

<ItemGroup>
    <PackageReference Include="AngleSharp" Version="1.0.0-alpha-844" />
    <PackageReference Include="Kurukuru" Version="1.4.0" />
    <PackageReference Include="ConsoleAppFramework" Version="3.3.2" />
    <PackageReference Include="ZLogger" Version="1.6.1" />
    <PackageReference Include="Kokuban" Version="0.2.0" />
</ItemGroup>

この場合Program.csは以下のような感じになります。割と短いですよ!

using ConsoleAppFramework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text;
using ZLogger;

Console.OutputEncoding = Encoding.UTF8;

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.AddZLoggerConsole();
        x.AddZLoggerFile($"logs/{args[0]}-{DateTime.Now.ToString("yyyMMddHHmmss")}.log");
    })
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<NanikaOptions>(hostContext.Configuration.GetSection("Nanika"));
    })
    .RunConsoleAppFrameworkAsync(args);

public class NanikaOptions
{
    public string UserId { get; set; } = default!;
    public string Password { get; set; } = default!;
    public string SaveDirectory { get; set; } = default!;
}

コンソールログだけだとウィンドウ閉じちゃったときにチッとかなったりするので(?)、ファイルログあると安心します。ZLoggerは秘伝のxmlコンフィグなどを用意する必要なく、これだけで有効化されるのが楽でいいところです。それでいてパフォーマンスも抜群に良いので。

ConsoleAppFrameworkはGenericHostと統合されているので、コンフィグの読み込みもOptionsで行います。appsettings.jsonを用意して

{
  "Nanika": {
    "UserId": "hugahuga",
    "Password": "takotako",
    "SaveDirectory": "C:\\temp\\dir",
  }
}

.csprojのほうに

<ItemGroup>
    <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
</ItemGroup>

と書いてあげれば、自動で読み込まれるようになるという仕様です。そして本体のコードは

public class NanikaDownloader : ConsoleAppBase
{
    readonly ILogger<NanikaDownloader> logger;
    readonly NanikaOptions options;

    // コンストラクタインジェクションでOptionsを受け取る
    public NanikaDownloader(ILogger<NanikaDownloader> logger, IOptions<NanikaOptions> options)
    {
        this.logger = logger;
        this.options = options.Value;
    }

    public async Task DownloadAre()
    {
        // Context.CancellationTokenを渡すのを忘れないように!(Ctrl+Cのキャンセル対応に必須)
        await loader.FecthAsyncBytes("...", Context.CancellationToken)
    }

    public async Task DownloadSore(int initialPage)
    {
        // Kokubanを使うとConsoleに出す文字列の色分けが簡単にできる!( `Chalk.Color +` だけで色が付く)
        logger.LogInformation(Chalk.Green + $"Download sore {initialPage} start");
    }
}

のように書きます。これの場合は、引数で NanikaDownloader.DownloadAre, NanikaDownloader.DownloadSore -initialPage * の実行切り替えができるようになるわけですね……!

また、文字色が一色だけだとコンソール上のログはかなり見づらいわけですが、Kokubanを使うことで色の出し分けが可能になります。これは、地味にめちゃくちゃ便利なのでおすすめ。別にバッチ系に限らず、コンソールログの色を調整するのってめっちゃ大事だと、最近実感しているところです。

ASP .NET Core(とかMagicOnionとか)で、ZLoggerでエラーを赤くしたい!とか、フレームワークが吐いてくる重要でない情報はグレーにして目立たなくしたい!とかの場合は、ZLoggerのPrefix/SuffixFormatterを使うのをオススメしてます(Kokubanのようにさっくり書けはしないのですが、まぁConfigurationのところで一回やるだけなので)

logging.AddZLoggerConsole(options =>
{
#if DEBUG
    // \u001b[31m => Red(ANSI Escape Code)
    // \u001b[0m => Reset
    // \u001b[38;5;***m => 256 Colors(08 is Gray)
    options.PrefixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error)
        {
            ZString.Utf8Format(writer, "\u001b[31m[{0}]", info.LogLevel);
        }
        else
        {
            if (!info.CategoryName.StartsWith("MyApp")) // your application namespace.
            {
                ZString.Utf8Format(writer, "\u001b[38;5;08m[{0}]", info.LogLevel);
            }
            else
            {
                ZString.Utf8Format(writer, "[{0}]", info.LogLevel);
            }
        }
    };
    options.SuffixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error || !info.CategoryName.StartsWith("MyApp"))
        {
            ZString.Utf8Format(writer, "\u001b[0m", "");
        }
    };
#endif

}, configureEnableAnsiEscapeCode: true); // configureEnableAnsiEscapeCode

こういうの、地味に開発効率に響くので超大事です。やっていきましょう。

まとめ

AngleSharpにかこつけてウォッチウィンドウをとにかく紹介したかったのです!ウォッチウィンドウ最強!値の変化があると赤くなってくれたりするのも便利ですね、使いこなしていきましょう。別にUnityとかでもクソ便利ですからね?

あ、で、AngleSharpはめっちゃいいと思います。他の言語のスクレピングライブラリ(Beautiful Soupとか)と比べても、全然張り合えるんじゃないかな。冒頭に書きましたがE2Eテストへの応用なども考えられるので、使いこなし覚えるのとてもいいんじゃないかと思います。ドキュメントが色々書いてあるようで実は別にほとんど大したこと書いてなくて役に立たないというのは若干問題アリなんですが、まぁ触って覚えるでもなんとかなるので、大丈夫大丈夫。

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を投げよう、とかは一から発想していくのは難しいかもしれません)、「引数の最後に渡す」「明示的に投げてもいい」の二点だけなので、これは慣れるしかないし、それを理由にして利用範囲を限定的にするのはよくないかなー、と思ってます。

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

Prev | | Next

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive