async/awaitのキャンセル処理やタイムアウトを効率的に扱うためのパターン&プラクティス
- 2022-07-13
async/awaitの鬼門の一つとして、適切なキャンセル処理が挙げられます。別に基本的にはそんな難しいことではなく、CancellationTokenSourceを作る、CanellationTokenを渡す、OperationCanceledExceptionをハンドリングする。というだけの話です。けれど、Tokenに手動でコールバックをRegisterしたときとか、渡す口が空いてないものに無理やりなんとかするときとか、タイムアウトに使った場合の始末とか、ちょっと気の利いた処理をしたいような場面もあり、そうした時にどうすれば良いのか悩むこともあります。
こういうのはパターンと対応さえ覚えてしまえばいい話でもあるので、今回はAlterNatsの実装時に直面したパターンから、「外部キャンセル・タイムアウト・大元のDispose」が複合された状況での処理の記述方法と、適切な例外処理、そして最後にObjectPoolなども交えた効率的なゼロアロケーションでのCancellationTokenSourceのハンドリング手法を紹介します。
CreateLinkedTokenSourceを使ったパターン
何かのClientを実装してみる、ということにしましょう。キャンセル処理の最も単純なパターンは引数の末尾にCancellationTokenを用意して、内部のメソッドにひたすら伝搬させていくことです。きちんと伝搬させていけば、最奥の処理が適切にCancellationTokenをハンドリングしてキャンセル検知時にOperationCanceledExceptionを投げてくれます。CancellationTokenをデフォルト引数にするか、必ず渡す必要があるよう強制するかは、アプリケーションの性質次第です。アプリケーションに近いコードでは強制させるようにしておくと、渡し忘れを避けれるので良いでしょう。
class Client
{
public async Task SendAsync(CancellationToken cancellationToken = default)
{
await SendCoreAsync(cancellationToken);
}
async Task SendCoreAsync(CancellationToken cancellationToken)
{
// nanika...
}
}
非同期メソッドのキャンセルはCancellationTokenで処理するのが基本で、別途Cancelメソッドを用意する、といったことはやめておきましょう。実装が余計に複雑化するだけです。CancellationTokenを伝搬させるのが基本であり全てです。
任意のキャンセルの他に、タイムアウト処理を入れたい、というのは特に通信系ではよくあります。async/awaitでのタイムアウトの基本は、タイムアウトもキャンセル処理の一つである、ということです。CancellationTokenSourceにはCancelAfterという一定時間後にCancelを発火させるというメソッドが用意されているので、これを使ってCancellationTokenを渡せば、すなわちタイムアウトになります。
// Disposeすると内部タイマーがストップされるのでリークしない
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMinutes(1));
await client.SendAsync(cts.Token);
UniTaskではCancelAfterSlimというメソッドが用意されているため、そちらを使うことをお薦めします。Cancelはスレッドプールを使いますが、CancelAfterSlimはPlayerLoop上で動くため、Unityフレンドリーな実装になっています。ただし内部タイマーのストップ手法がCancelAfterSlimの戻り値をDisposeする必要があるというように、実装に若干差異があります。
タイムアウト時間は大抵固定のため、ユーザーに都度CancelAfterを叩かせるというのは、だいぶ使いにくい設計です。そこで、CancelAfterの実行はSendAsyncメソッドの内部で行うことにしましょう。そうした内部のタイムアウト用CancellationTokenと、外部からくるCancellationTokenを合成して一つのCancellationTokenに変換するには、CancellationTokenSource.CreateLinkedTokenSourceが使えます。
class Client
{
public TimeSpan Timeout { get; }
public Client(TimeSpan timeout)
{
this.Timeout = timeout;
}
public async Task SendAsync(CancellationToken cancellationToken = default)
{
// 連結された新しいCancellationTokenSourceを作る
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(Timeout);
await SendCoreAsync(cts.Token);
}
// snip...
}
CreateLinkedTokenSourceで生成されたCancellationTourceは連結されたいずれかがCancelされると、生成されたCancellationTokenSource自体もCancelされます。また、それ自体からもCancelが発火できます。
これで完成!なのですが、このままだと例外処理に問題があります。
OperationCanceledExceptionは CancellationToken
というプロパティを持っていて、これを元に呼び出し側はキャンセルの原因を判別することができます。一つ例を出しますが、以下のようにOperationCanceledExceptionをcatchしたうえで、更に判定を入れてコード分岐をかけることがあります。
try
{
await client.SendAsync(token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == token)
{
// Cancelの原因をTokenによって判定できる
}
例外を何も処理せずに全部おまかせでやると、投げられる OperationCanceledException.CancellationToken は CreateLinkedTokenSource で連結したTokenになってしまい、何の意味もない情報ですし、原因の判別に使うこともできません。
また、タイムアウトをOperationCanceledExceptionとして扱ってしまうことも問題です。OperationCanceledExceptionは特殊な例外で、既知の例外であるとしてロギングから抜いたりすることもままあります(例えばウェブサーバーでクライアントの強制切断(リクエスト中にブラウザ閉じたりとか)でキャンセルされることはよくあるけれど、それをいちいちエラーで記録していたらエラー祭りになってしまう)。タイムアウトは明らかな異常であり、そうしたキャンセルとは確実に区別して欲しいし、OperationCanceledExceptionではない例外になって欲しい。
これは .NET のHttpClientでも HttpClient throws TaskCanceledException on timeout #21965 としてIssueがあがり(TaskCanceledExceptionはOperationCanceledExceptionとほぼ同義です)、大激論(121コメントもある!)を巻き起こしました。HttpClientはタイムアウトだろうが手動キャンセルだろうが区別なくTaskCanceledExceptionを投げるのですが、原因は、実装が上の例の通りCreateLinkedTokenSourceで繋げたもので処理していて、そして、特に何のハンドリングもしていなかったからです。
結論としてこれはHttpClientの設計ミスなのですが、一度世の中に出したクラスの例外の型を変更することは .NET の互換性維持のポリシーに反するということで(実際、これを変更してしまうと影響は相当大きくなるでしょう)、お茶を濁した対応(InnerExceptionにTimeoutExceptionを仕込んで、判定はそちら経由で一応できなくもないようにした)となってしまったのですが、今から実装する我々は同じ轍を踏んではいけない。ということで、ちゃんと正しく処理するようにしましょう。
public async Task SendAsync(CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(Timeout);
try
{
await SendCoreAsync(cts.Token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{
if (cancellationToken.IsCancellationRequested)
{
// 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
throw new OperationCanceledException(ex.Message, ex, cancellationToken);
}
else
{
// タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
}
}
}
やることは別に難しくはなく、OperationCanceledExceptionをcatchしたうえで、外から渡されたcancellationTokenがキャンセルされているならそれが原因、そうでないならタイムアウトが原因であるという判定をして、それに応じた例外を投げ直します。
最後に、Client自体がDisposeできるとして、それに反応するようなコードにしましょう。
class Client : IDisposable
{
// IDisposableと引っ掛けて、Client自体がDisposeされたら実行中のリクエストも終了させるようにする
readonly CancellationTokenSource clientLifetimeTokenSource;
public TimeSpan Timeout { get; }
public Client(TimeSpan timeout)
{
this.Timeout = timeout;
this.clientLifetimeTokenSource = new CancellationTokenSource();
}
public async Task SendAsync(CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientLifetimeTokenSource.Token, cancellationToken);
cts.CancelAfter(Timeout);
try
{
await SendCoreAsync(cts.Token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{
if (cancellationToken.IsCancellationRequested)
{
// 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
throw new OperationCanceledException(ex.Message, ex, cancellationToken);
}
else if (clientLifetimeTokenSource.IsCancellationRequested)
{
// クライアント自体がDisposeされたのでOperationCanceledException、或いは独自の例外を投げる
throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
}
else
{
// タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
}
}
}
async Task SendCoreAsync(CancellationToken cancellationToken)
{
// nanika...
}
public void Dispose()
{
clientLifetimeTokenSource.Cancel();
clientLifetimeTokenSource.Dispose();
}
}
差分はCreateLinkedTokenSourceで連結するトークンを増やすのと、例外処理時の分岐を増やすことだけです。
ゼロアロケーション化する
殆どの場合は上記のパターンで全く問題ないのですが、都度CreateLinkedTokenSourceで新しいCancellationTokenSourceを作るのが気になる、かもしれません。どちらにせよasyncメソッドが非同期で実行される場合には、非同期ステートマシン自体のアロケーションが発生するので実際のところ別に気にするほどのことではない。のですが、IValueTaskSourceやPoolingAsyncValueTaskMethodBuilderを使ったアロケーションを避ける非同期実装を行っていた場合には、相当気になる問題になってきます。また、HTTP/1のREST呼び出しのような頻度では大したことないですが、これが例えばサーバーで大量の並列実行をさばく、クライアントではリアルタイム通信で毎フレーム通信する、といった用途だと、この辺も気を配りたくなってくるかもしれません。
なお、ここでは説明の簡略化のために、SendAsyncメソッド自体はasync Taskのままにします。
まずは外部キャンセルのない、タイムアウトだけのケースを見ていきます。タイムアウトは正常系の場合は発火しない、つまり殆どの場合は発火しないため、非発火時にはCancellationTokenSourceを使い回すようにしましょう。
class Client
{
// SqlConnectionのようなメソッドを多重に呼ぶことを禁止しているクラスの場合はフィールドにCancellationTokenSourceを一つ
// HttpClientのようにあちこちから多重に呼ばれる場合があるものはObjectPoolで保持する
readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;
public TimeSpan Timeout { get; }
public Client(TimeSpan timeout)
{
this.Timeout = timeout;
this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
}
public async Task SendAsync()
{
var timeoutTokenSource = timeoutTokenSourcePool.Get();
timeoutTokenSource.CancelAfter(Timeout);
try
{
await SendCoreAsync(timeoutTokenSource.Token);
}
finally
{
// Timeout処理が発火していない場合はリセットして再利用できる
if (timeoutTokenSource.TryReset())
{
timeoutTokenSourcePool.Return(timeoutTokenSource);
}
}
}
}
ObjectPoolの実装は色々ありますが、今回は説明の簡略化のためにMicrosoft.Extensions.ObjectPoolを使いました(NuGetからMicrosoft.Extensions.ObjectPoolを参照する必要あり)。タイムアウトが発動した場合は再利用不能なので、プールに戻してはいけません。なお、 CancellationTokenSource.TryResetは .NET 6 からのメソッドになります。それ以前の場合は CancelAfter(Timeout.InfiniteTimeSpan)
を呼んでタイマー時間を無限大に引き伸ばす変更を入れる(内部的にはTimerがChangeされる)というハックがあります。
外部キャンセルが入る場合には、LinkedTokenを作らず、CancellationToken.UnsafeRegisterでタイマー用のCancellationTokenSourceをキャンセルするようにします。
public async Task SendAsync(CancellationToken cancellationToken = default)
{
var timeoutTokenSource = timeoutTokenSourcePool.Get();
CancellationTokenRegistration externalCancellation = default;
if (cancellationToken.CanBeCanceled)
{
// 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
externalCancellation = cancellationToken.UnsafeRegister(static state =>
{
((CancellationTokenSource)state!).Cancel();
}, timeoutTokenSource);
}
timeoutTokenSource.CancelAfter(Timeout);
try
{
await SendCoreAsync(timeoutTokenSource.Token);
}
finally
{
// Registerの解除(TryResetの前に「必ず」先に解除すること)
// CancellationTokenRegistration.Disposeは解除完了(コールバック実行中の場合は実行終了)までブロックして確実に待ちます
externalCancellation.Dispose();
if (timeoutTokenSource.TryReset())
{
timeoutTokenSourcePool.Return(timeoutTokenSource);
}
}
}
CancellationToken.UnsafeRegisterは .NET 6 からのメソッドでExecutionContextをCaptureしないため、より高効率です。それ以前の場合はRegisterを使うか、呼び出しの前後でExecutionContext.SuppressFlow/RestoreFlowするというハックが使えます(UniTaskのRegisterWithoutCaptureExecutionContextはこの実装を採用しています)。
CancellationTokenにコールバックを仕込む場合、レースコンディションが発生する可能性が出てきます。この場合だとTimeout用のCancellationTokenSourceをプールに戻した後にCancelが発生すると、最悪なことになります。それを防ぐために、CancellationTokenRegistration.DisposeをTryResetの前に必ず呼びましょう。CancellationTokenRegistration.Disposeの優れているところは、コールバックが実行中の場合は実行終了までブロックして確実に待ってくれます。これによりマルチスレッドのタイミング問題ですり抜けてしまうといったことを防いでくれます。
ブロックといいますが、コールバックに登録されたメソッドがすぐに完了する性質のものならば、lockみたいなものなので神経質になる必要はないでしょう。CancellationTokenRegistrationにはDisposeAsyncも用意されていますが、むしろそちらを呼ぶほうがオーバーヘッドであるため、無理にDisposeAsyncのほうを優先する必要はないと考えています。CancellationTokenRegistrationには他にUnregisterメソッドもあり、これはfire-and-forget的に解除処理したい場合に有効です。使い分けですね。
なお、CancellationTokenへのコールバックのRegister(UnsafeRegister)は、初回はコールバック登録用のスロットを生成するといったアロケーションがありますが、Dispose/Registerを繰り返す二回目以降はスロットを再利用してくれます。このへんも新規に(Linked)CancellationTokenSourceを作るより有利な点となりますね。
引き続き、Client自体の寿命に引っ掛けるCancellationTokenを追加した実装を見ていきましょう。といっても、単純にRegisterを足すだけです。
class Client : IDisposable
{
readonly TimeSpan timeout;
readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;
readonly CancellationTokenSource clientLifetimeTokenSource;
public TimeSpan Timeout { get; }
public Client(TimeSpan timeout)
{
this.Timeout = timeout;
this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
this.clientLifetimeTokenSource = new CancellationTokenSource();
}
public async Task SendAsync(CancellationToken cancellationToken = default)
{
var timeoutTokenSource = timeoutTokenSourcePool.Get();
CancellationTokenRegistration externalCancellation = default;
if (cancellationToken.CanBeCanceled)
{
// 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
externalCancellation = cancellationToken.UnsafeRegister(static state =>
{
((CancellationTokenSource)state!).Cancel();
}, timeoutTokenSource);
}
// Clientの寿命に合わせたものも同じように追加しておく
var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
{
((CancellationTokenSource)state!).Cancel();
}, timeoutTokenSource);
timeoutTokenSource.CancelAfter(Timeout);
try
{
await SendCoreAsync(timeoutTokenSource.Token);
}
finally
{
// Registerの解除増量
externalCancellation.Dispose();
clientLifetimeCancellation.Dispose();
if (timeoutTokenSource.TryReset())
{
timeoutTokenSourcePool.Return(timeoutTokenSource);
}
}
}
async Task SendCoreAsync(CancellationToken cancellationToken)
{
// snip...
}
public void Dispose()
{
clientLifetimeTokenSource.Cancel();
clientLifetimeTokenSource.Dispose();
}
}
例外処理も当然必要です!が、ここは最初の例のLinkedTokenで作ったときと同じです。
public async Task SendAsync(CancellationToken cancellationToken = default)
{
var timeoutTokenSource = timeoutTokenSourcePool.Get();
CancellationTokenRegistration externalCancellation = default;
if (cancellationToken.CanBeCanceled)
{
externalCancellation = cancellationToken.UnsafeRegister(static state =>
{
((CancellationTokenSource)state!).Cancel();
}, timeoutTokenSource);
}
var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
{
((CancellationTokenSource)state!).Cancel();
}, timeoutTokenSource);
timeoutTokenSource.CancelAfter(Timeout);
try
{
await SendCoreAsync(timeoutTokenSource.Token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == timeoutTokenSource.Token)
{
// 例外発生時の対応はLinkedTokenで作ったときと特に別に変わらず
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException(ex.Message, ex, cancellationToken);
}
else if (clientLifetimeTokenSource.IsCancellationRequested)
{
throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
}
else
{
throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
}
}
finally
{
externalCancellation.Dispose();
clientLifetimeCancellation.Dispose();
if (timeoutTokenSource.TryReset())
{
timeoutTokenSourcePool.Return(timeoutTokenSource);
}
}
}
ということで、↑のものが最終形となりました。
async/awaitで実装されている場合、Tokenのコールバックも一メソッド内で収まっているために挙動の見通しがだいぶ良くなります。async/awaitを封印してIValueTaskSourceを使った実装をする場合は、複数のコールバックを手で処理する必要があり、また登録、発火する箇所も複数箇所にちらばってしまうため、遥かに複雑性が増します。
AlterNatsではハイパフォーマンスSocketプログラミングとして実装を解説した記事で、IValueTaskSourceをChannel(キュー)に詰め込むとしていますが、キャンセル時にはManualResetValueTaskSourceCoreのSetExceptionを叩き、正常完了時にはSetResultの前でTryResetからのReturnするという、まぁ基本的な流れは一緒です。少し異なるのは、キャンセルで発火するのはawaitに紐付けられた継続処理だけで、実体はキューに残り続けていて、取り出し時にキャンセル状況をチェックして、何もしないようにする。といったことでしょうか。状況が複雑化する分、レースコンディション起因のバグが入り込みやすくなってしまうので、CancellationTokenRegistrationの挙動をしっかり把握して実装する必要があります。
まとめ
簡単かどうかでいうと、言われればなるほどそうですねーって感じですが、都度考えてやれって言われると結構難しいと思います。なので、こういうパターンなんですね、というのを頭に叩き込んでおくというのは重要だと思いますし、まぁとりあえず覚えてください。覚えれば、別にコード的に複雑というわけでもないので、易易と対処できるようになるはずです。
StackExchange.Redisも非同期メソッド、CancellationTokenを受け取ってなかったりしますし、パフォーマンスを追求しつつCancellationToken対応を入れるのは、かなり難しい問題だったりします。しかしこの .NET 6世代ではかなりメソッドも増えていて、やろうと思えばやりきれるだけの手札が揃っています。なので、パターン化して真正面から立ち向かいましょう……!
Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々々々受賞しました
- 2022-07-06
12回目。一年ごとに再審査があって7月に一斉更新されるシステムになっていて、今年も継続しました。
元々、私の活動はオンライン一人自己完結型なので、C#布教活動(?)的にコロナの影響は一切受けていないのですが、勉強会開催などコミュニティ構築型の人だと影響を受けやすいここ数年だったのではないかと思います。ただ、やはりアフターコロナで人との繋がりは極度に減ったし、人の入れ替わり、新しい台頭みたいなのも少なくなってきたなあ、というのが肌間ありますね。改めて、コミュニティを維持してくれている人のありがたさを知りました。というわけで、C#コミュニティ盛り上がっていって欲しい!のですが、私自身のスタンスは変わらず、OSSで世の中に存在感を出していくことだとは思っています。
好不調の並が割と激しくて、ここ数ヶ月何もやってないわーみたいなときもよくあるのですが、年を通すと毎年3, 4個は新規にOSSをリリースしているし、既存ライブラリのメンテナンスやテコ入れ大型リニューアルも数個やっていたりするので、年間通して見ればかなりハイパフォーマンスで、それを10年以上継続してるんだから中々なんじゃないですか?と自画自賛したくなったり。
そんなこんなの活動を続けてきた結果、CEDECという国内最大のゲーム業界のカンファレンスでもCEDEC AWARDS 2022のエンジニアリング部門で、「.NET/Unity開発の可能性を広げるオープンソースソフトウェアの提供」として優秀賞を受賞しました。C#は元々裏方で便利に使っていたというのはありましたが、表でもガンガン使っていけるよ、といったC#の存在感は、高めていけてるんじゃないかと思います。CysharpとしてOSSを通じてC#の可能性を広げるということがしっかり伝わってるというのがとても嬉しいですね!参加者投票で部門別最優秀賞が決まるらしいので、是非投票を……!
また、今年はプリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しましたという記事で書きましたが、開発に関わっていた「プリコネ!グランドマスターズ」のリリースがありました。の事例発表をCEDEC 2022でC#によるクライアント/サーバーの開発言語統一がもたらす高効率な開発体制 ~プリコネ!グランドマスターズ開発事例~としてCygamesさんより発表があります。
- クライアント/サーバーの開発言語統一によるメリット
- MagicOnion(gRPC)を利用したリアルタイムサーバー実装手法と具体例
- Blazorを使用したツールの開発例、開発プロジェクトおよびソリューション統合管理の手法
ということで、かなり面白い感じの内容になるのではないでしょうか、期待……!
C#がエンタープライズ業界(とは)で強いというのは重々承知だしAzureもシェア高くめっちゃ使われてるんだよ、という話は分かりはするのですが、to Cやスタートアップ企業で積極的に使われる言語になって欲しい、というのがずーっとの願いで、私自身もそうした業界に身をおいて、実績で示し続けて来たと思いますし、これからも引き続き道を示せるようにしていきたいです。
もちろん、ハイパフォーマンスなC#によって最前線での実力を見せていく、ということも変わらずに……!
AlterNats - ハイパフォーマンスな.NET PubSubクライアントと、その実装に見る.NET 6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについて
- 2022-05-11
タイトルはここぞとばかりに全盛りにしてみました!今回NATSの.NETクライアント実装としてAlterNatsというライブラリを新しく作成し、公開しました。
公式の既存クライアントの3倍以上、StackExchange.RedisのPubSubと比較して5倍以上高速であり、通常のPubSubメソッドは全てゼロアロケーションです。
そもそもNATSとはなんぞやか、というと、クラウドネイティブなPubSubのミドルウェアです。Cloud Native Computing Foundationのincubating projectなので、それなりの知名度と実績はあります。
PubSubというと、特にC#だとRedisのPubSub機能で行うのが、StackExchange.Redisという実績あるライブラリもあるし、AWSやAzure、GCPがマネージドサービスも用意しているしで、お手軽でいいのですが、盲目的にそれを使うのが良いのか少し疑問に思っていました。
RedisはKVS的な使い方がメインであり、PubSubはどちらかというとオマケ機能であるため
- PubSub専用のモニタリングの欠如
- PubSub用のクラスタリング対応
- マネージドサービスでの価格体系のバランスの悪さ(PubSub特化ならメモリはあまりいらない)
- そもそものパフォーマンス
といった点が具体的な懸念です。そして、NATSはPubSub専用に特化されているため、そのためのシステムが豊富に組まれているし、性能も申し分なさそうに思えました。しいて欠点を言えばマネージドサービスが存在しないのがネックですが、純粋なPubSubとしての利用ならば永続化処理について考える必要がないので、ミドルウェアとしては運用しやすい部類にはいるのではないかと思っています。(NATS自体はNATS JetStreamという機能によってAt-least / exactly onceの保証のあるメッセージングの対応も可能ですが、そこに対応させるにはストレージが必要になる場合もあります)
しかし調べていくうちに懸念となったのが公式クライアントであるnats.netで、あまり使いやすくないのですね。async/awaitにも対応していないし、古くさく、それどころかそもそも.NET的に奇妙に見えるAPIであり、そうなるとパフォーマンスに関しても疑問に思えてくる。
何故そうなっているかの理由はReadMeにも明記されていて、メンテナンス性のためにGoクライアント(ちなみにNATS Server自体はGoで書かれている)と同じようなコードベースになっている、と。そのためC#的ではない部分が多々あるし、GoとC#ではパフォーマンスを出すための書き方が全く異なるので、あまり良い状況ではなさそう。
それならば完全にC#に特化して独自に作ってしまうほうがいいだろうということで、作りました。公式クライアントと比べると全ての機能をサポートしているわけではない(JetStreamにも対応していないしLeaf Nodes運用で必須になるであろうTLSにも対応していません)のですが、PubSubのNATS Coreに特化して、まずは最高速を叩き出せるようにしました。PubSub利用する分には機能面での不足はないはずです。
AlterNatsは公式じゃないAlternativeなNATSクライアントという意味です。まんまですね。割と語感が良いので命名的には結構気に入ってます。
Getting Started
APIは、nats.net
があまりにもC#っぽくなくややこしい、ということを踏まえて、シンプルに、簡単に、C#っぽく書けるように調整しました。
// create connection(default, connect to nats://localhost:4222)
await using var conn = new NatsConnection();
// for subscriber. await register to NATS server(not means await complete)
var subscription = await conn.SubscribeAsync<Person>("foo", x =>
{
Console.WriteLine($"Received {x}");
});
// for publisher.
await conn.PublishAsync("foo", new Person(30, "bar"));
// unsubscribe
subscription.Dipose();
// ---
public record Person(int Age, string Name);
Subscribeでhandlerを登録し、Publishでメッセージを飛ばす。データは全て自動でシリアライズされます(デフォルトではSystem.Text.Json、MessagePack for C#を用いたハイパフォーマンスなシリアライズも可能な拡張オプションも標準で用意してあります)
別のURLへの接続や、認証のための設定などを行うNatsOptions/ConnectOptionsはイミュータブルです。そのため、with式で構築するやり方を取っています。
// Options can configure `with` operator
var options = NatsOptions.Default with
{
Url = "nats://127.0.0.1:9999",
LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Information),
Serializer = new MessagePackNatsSerializer(),
ConnectOptions = ConnectOptions.Default with
{
Echo = true,
Username = "foo",
Password = "bar",
}
};
await using var conn = new NatsConnection(options);
NATSには標準で結果を受け取るプロトコルも用意されています。サーバー間の簡易的なRPCとして使うと便利なところもあるのではないかと思います。これもSubscribeRequestAsync
/RequestAsync
という形で簡単に直感的に書けるようにしました(Request側は戻り値の型を指定する必要があるため、型指定が少しだけ冗長になります)
// Server
await conn.SubscribeRequestAsync("foobar", (int x) => $"Hello {x}");
// Client(response: "Hello 100")
var response = await conn.RequestAsync<int, string>("foobar", 100);
例では await using
ですぐに破棄してしまっていますが、基本的にはConnectionはシングルトンによる保持を推奨しています。staticな変数に詰めてもいいし、DIでシングルトンとして登録してしまってもいいでしょう。接続は明示的にConnectAsyncすることもできますが、接続されていない場合は自動で接続を開くようにもなっています。
コネクションはスレッドセーフで、物理的にも一つのコネクションには一つの接続として繋がり、全てのコマンドは自動的に多重化されます。これにより裏側で自動的にバッチ化された高効率な通信を実現していますが、負荷状況に応じて複数のコネクションを貼った場合が良いケースもあります。AlterNatsではNatsConnectionPoolという複数コネクションを内包したコネクションも用意しています。また、クライアント側で水平シャーディングを行うためのNatsShardingConnectionもあるため、必要に応じて使い分けることが可能です。
内部のロギングはMicrosoft.Extensions.Loggingで管理されています。AlterNats.Hosting
パッケージを使うと、Generic Hostと統合された形で適切なILoggerFactoryの設定と、シングルトンのサービス登録を行ってくれます。
DIでの取り出しは直接NatsConnectionを使わずに、INatsCommandを渡すことで余計な操作(コネクションの切断など)が出来ないようになります。
using AlterNats;
var builder = WebApplication.CreateBuilder(args);
// Register NatsConnectionPool, NatsConnection, INatsCommand to ServiceCollection
builder.Services.AddNats();
var app = builder.Build();
app.MapGet("/subscribe", (INatsCommand command) => command.SubscribeAsync("foo", (int x) => Console.WriteLine($"received {x}")));
app.MapGet("/publish", (INatsCommand command) => command.PublishAsync("foo", 99));
app.Run();
メタバースアーキテクチャ
CysharpではMagicOnionという .NET/Unity で使えるネットワークフレームワークを作っているわけですが、AlterNatsはこれと絡めることで、構成の幅を広げることができると考えています、というかむしろそのために作りました。
クライアントにUnity、サーバーにMagicOnionがいるとして、サーバーが一台構成なら、平和です、繋げるだけですもの。開発の最初とかローカルでは楽なのでこの状態でもいいですね。
しかし現実的にはサーバーは複数台になるので、そうなると色々なパターンが出てきます。よくあるのが、ロードバランサーを立ててそれぞれが別々のサーバーに繋がっているものを、更に後ろのPubSubサーバーを通して全サーバーに分配するパターン。
これはNode.jsのリアルタイムフレームワークであるSocket.IOのRedisアダプター、それの.NET版であるSignalRのRedisバックプレーン、もちろんMagicOnionにもあるのですが、このパターンはフレームワークでサポートされている場合も多いです。RedisのPubSubでできることはNATSでもできる、ということで、NATSでもできます。
これは各サーバーをステートレスにできるのと、スケールしやすいので、Chatなどの実装にはやりやすい。欠点はステートを持ちにくいので、クライアントにステートがあり、データのやり取りをするタイプしか実装できません。サーバー側にステートを持ったゲームロジックは持たせずらいでしょう(ステートそのものは各サーバーで共有できないため)。また、PubSubを通すことによるオーバーヘッドも気になるところかもしれません。
ロードバランサーを立てる場合、ロードバランサーのスティッキーセッションを活用して一台のサーバーに集約させるというパターンもあります(あるいは独自プロトコルでもリバースプロキシーを全面に立てて、カスタムなロジックで後ろの台を決定することもほぼ同様の話です)。ただし、色々なユーザーを同一サーバーに集約させたいようなケースでは、そのクッキーの発行誰がやるの、みたいなところは変わらずありますね。そこまで決めれるならIPアドレスを返して直繋ぎさせてしまってもいいんじゃないの?というのも真です。
そうした外側に対象のIPアドレスを教えてくれるサービスがいて、先にそれに問い合わせてから、対象のサーバーへ繋ぎに行くパターンは、古典的ですが安定です。
この場合は同一サーバーに繋ぎにいくためにサーバー内にインメモリでフルにステートを持たせることが出来ますし、いわゆるゲームループを中で動かして処理するようなこともできます。また、画面のないヘッドレスUnityなどをホストして、クライアントそのものをサーバー上で動かすこともできますね。
しかし、このパターンは素直なようでいて、実際VMだとやりやすいのですが、Kubernetesでやるのは難しかったりします。というのも、Kubernetesの場合は外部にIPが露出していないため、クラスター内の一台の特定サーバーに繋ぎにいくというのが難しい……!
このような場合に最近よく活用されているのがAgonesというGoogleが主導して作っているKubernetesの拡張で、まさにゲーム向きにKubernetesを使えるようにするためのシステムです。
ただし、これはこれで難点があって、Agonesが想定しているゲームサーバーは1プロセス1ゲームセッション(まさにヘッドレスUnityのような)のホスティングであるため、1つのプロセスに多数のゲームセッションをホストさせるような使い方はそのままだと出来ません。コンテナなので、仮想的なプロセスを複数立ち上げればいいでしょ、というのが思想なのはわからなくもないのですが、現実的には軽量なゲームサーバー(それこそMagicOnionで組んだりする場合)なら、1プロセスに多数のゲームセッションを詰め込めれるし、これをコンテナで分けて立ち上げてしまうとコスト面では大きな差が出てしまいます。
さて、Cysharpではステートフルな、特にゲームに向いたC#サーバーを構築するための補助ライブラリとしてLogicLooperというゲームループを公開しています。このライブラリはこないだリリースしたプリコネ!グランドマスターズでも使用していますが、従来MagicOnionと同居して使っていたLogicLooperを、剥がしたアーキテクチャはどうだろうか、という提案があります。(実際のプリコネ!グランドマスターズのアーキテクチャはMagicOnionと同居し、リバースプロキシーを使った方式を採用しているので(↑の画像のものに近い)、この案とは異なります)
パーツが増えて複雑になったように見えて、この構成には大きな利点があります。まず、同居しているものがなくなったので複雑になったようで実はシンプルになっています。それぞれがそれぞれの役割にフルに集中できるようになるため、パフォーマンスも良くなり、かつ、性能予測もしやすくなります。特にロジックをフルに回転させるLogicLooperがクライアントや接続数の影響を受けずに独立できているのは大きな利点です。
ゲーム全体のステートはLogicLooper自体が管理するため、クライアントとの接続を直接受けているMagicOnion自体はステートレスな状態です。そのため、インフラ的にもロードバランサーの下にMagicOnionを並べるだけで済みますし、サーバー間の接続に伴う面倒事は全てNATSに押し付けられるため、インフラ管理自体はかなりシンプルな構成が取れます。
また、MagicOnion自体はステートを持てるシステムであり、各ユーザーそれぞれのステートを持つのは容易です(サーバーを越えなければいい)。そこで、LogicLooperから届いたデータのうち、繋がってるユーザーに届ける必要がないデータは、MagicOnionの持つユーザーのステートを使ってカリング処理をして、そもそも転送しなかったり間引いたりして通信量を削減することで、ユーザーの体験が良くなります。
各ユーザーから届くデータを使ったステート更新/データ送信に関しては、LogicLooperがゲームループ状になっているので、ループの間に溜まったデータをもとにしてバッチ処理を行えばいいでしょう。バッチ化というと、通信「回数」の削減のためのコマンドを単純にまとめあげて一斉送信するものと、内容を見て処理内容を縮小するパターンが考えられますが、LogicLooperを使ったアプローチでは後者を効率的に行なえます。前者のコマンドの一斉送信に関しては、AlterNatsが裏側で自動パイプライニング化としてまとめているので(後で詳しく説明します)、そこに関しても効率化されています。
このアーキテクチャで気になるのがPubSub通信のオーバーヘッドですが、それに関しての解決策がAlterNatsで、究極的に高速なクライアントがあれば(さすがにインメモリには到底及ばないとはいえ)、そもそものクライアントとサーバーの間にもネットワークがいるわけで、経路のトータルで見れば実用的な範囲に収められる。という想定で作りました。
ところで、そして究極的な利点は、全てC#で組めるということです。どういうことかというと、MagicOnionもLogicLooperも汎用的なC#フレームワークです。特別なプラグインを差し込んで処理するというわけではなくて、ふつーのC#コードをふつーに書くことで、それぞれの箇所に、アプリケーション固有のコードを仕込んでいくことができる。これが、本当の大きな利点です。専用のC++ミドルウェアを作って挟んで最適化できるぞ!などといったシステムは、素晴らしいことですが、専門性が高く再現性が低い。MagicOnionとLogicLooper、そしてAlterNatsを活用したこの構成なら、C#エンジニアなら誰でも(容易に)できる構成です。Cysharpのメッセージは「C#の可能性を切り開いていく」ですが、誰もが実現できる世界を作っていくというのが目標でもあります。
なお、ワーカーとしてのLogicLooperを作るにWorker Serviceという.NET 6からのプロジェクトタイプが適切です。
ハイパフォーマンスSocketプログラミング
- Socket API
C#で最も低レベルにネットワーク処理を扱えるクラスはSocketです。そして、非同期でハイパフォーマンスな処理を求めるならSocketAsyncEventArgsをうまく再利用しながらコールバックを仕込む必要があります。
これは非常に厄介で些か難易度も高いのですが、現在はasync/awaitの時代、ちゃんとawaitできる***Asyncメソッド郡が用意されています。しかし、使ってはいけないAPI、使ってはいけないオーバーロードも並んでいるので、その選別が必要です。SocketのAPIは歴史的事情もあり混沌としてしまっているのです……。
使うべきAPIを分かりやすく見分ける手段があります。それは戻り値が ValueTask
のものを選ぶことです。
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken)
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)
public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken))
オーバーロードにはTask返しのものもあるので、気をつけてください。
// これらのAPIは使ってはいけない
public Task ConnectAsync(string host, int port)
public Task<int> ReceiveAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)
public Task<int> SendAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)
ValueTask返しのAPIは内部的には AwaitableSocketAsyncEventArgs
というものがValueTaskの中身になるようになっていて、これがいい感じに使いまわされる(awaitされると内部に戻るようになっている)ことで、Taskのアロケーションもなく効率的な非同期処理を実現しています。SocketAsyncEventArgs
の使いにくさとは雲泥の差なので、これは非常にお薦めできます。
また、同期APIはSpanを受け取れるのですが、非同期APIは(ステートをヒープに置く都合上)Memoryしか受け取れないことには注意してください。これはSocketプログラミングに限らず非同期系APIにおける一般的な話で、全体的に上手く組んでおかないと、Spanが使えないことが障壁になることがあります。必ず、Memoryで取り回せるようにしておきましょう。
- テキストプロトコルのバイナリコード判定
NATSのプロトコルはテキストプロトコルになっていて、文字列処理で簡単に切り出すことができます。実際これはStreamReaderを使うことで簡単にプロトコルの実装ができます。ReadLineするだけですから。しかし、ネットワークに流れるのは(UTF8)バイナリデータであり、文字列化は無駄なオーバーヘッドとなるため、パフォーマンスを求めるなら、バイナリデータのまま処理する必要があります。
NATSでは先頭の文字列(INFO
, MSG
, PING
, +OK
, -ERR
など)によって流れてくるメッセージの種類が判定できます。文字列処理で空白でSplitして if (msg == "INFO") などとすればめちゃくちゃ簡単ですが、先にも言った通り文字列変換は意地でも通しません。INFOは[73, 78, 70, 79]なので、Slice(0, 4).SequenceEqual で判定するのは悪くないでしょう。ReadOnlySpan<byte>
のSequenceEqualはめちゃくちゃ最適化されていて、長いものであれば必要であればSIMDとかも使って高速に同値判定します。LINQのSequenceEqualとは別物です!
しかし、もっと欲張って見てみましょう、プロトコルの識別子はサーバーから送られてくるものは全て4文字以内に収まっています。つまり、これはIntに変換しやすい状態です!というわけで、AlterNatsのメッセージ種判定コードはこうなっています。
// msg = ReadOnlySpan<byte>
if(Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference<byte>(msg)) == 1330007625) // INFO
{
}
これ以上速い判定はできないと思うので、理論上最速ということでいいでしょう。3文字の命令も、直後に必ずスペースや改行が来るので、それを含めた以下のような定数を使って判定に回しています。
internal static class ServerOpCodes
{
public const int Info = 1330007625; // Encoding.ASCII.GetBytes("INFO") |> MemoryMarshal.Read<int>
public const int Msg = 541545293; // Encoding.ASCII.GetBytes("MSG ") |> MemoryMarshal.Read<int>
public const int Ping = 1196312912; // Encoding.ASCII.GetBytes("PING") |> MemoryMarshal.Read<int>
public const int Pong = 1196314448; // Encoding.ASCII.GetBytes("PONG") |> MemoryMarshal.Read<int>
public const int Ok = 223039275; // Encoding.ASCII.GetBytes("+OK\r") |> MemoryMarshal.Read<int>
public const int Error = 1381123373; // Encoding.ASCII.GetBytes("-ERR") |> MemoryMarshal.Read<int>
}
バイナリプロトコルなら特に何のひねりも必要なく実装できるので、バイナリプロトコルのほうが実装者に優しくて好きです……。
- 自動パイプライニング
NATSプロトコルの書き込み、読み込みは全てパイプライン(バッチ)化されています。これはRedisのPipeliningの解説が分かりやすいですが、例えばメッセージを3つ送るのに、一つずつ送って、都度応答を待っていると、送受信における多数の往復がボトルネックになります。
メッセージの送信において、AlterNatsは自動でパイプライン化しています。System.Threading.Channelsを用いてメッセージは一度キューに詰め込まれ、書き込み用のループが一斉に取り出してバッチ化します。ネットワーク送信が完了したら、再び送信処理待ち中に溜め込まれたメッセージを一括処理していく、という書き込みループのアプローチを取ることで、最高速の書き込み処理を実現しました。
ラウンドトリップタイムの話だけではなく(そもそもNATSの場合はPublish側とSubscribe側が独立しているので応答待ちというのもないのですが)、システムコールの連続した呼び出し回数を削減できるという点でも効果が高いです。
なお、.NET最高速ロガーであるZLoggerでも同じアプローチを取っています。
- 一つのオブジェクトに機能を盛る
Channelに詰め込む都合上、データを書き込みメッセージオブジェクトに入れてヒープに保持しておく必要があります。また、書き込み完了まで待つ非同期メソッドのためのPromiseも必要です。
await connection.PublishAsync(value);
こうしたAPIを効率よく実装するために、どうしても確保する必要のある一つのメッセージオブジェクト(内部的にはCommandと命名されている)に、あらゆる機能を同居して詰め込みましょう。
class AsyncPublishCommand<T> : ICommand, IValueTaskSource, IThreadPoolWorkItem, IObjectPoolNode<AsyncPublishCommand<T>>
internal interface ICommand
{
void Write(ProtocolWriter writer);
}
internal interface IObjectPoolNode<T>
{
ref T? NextNode { get; }
}
このオブジェクト(AsyncPublishCommand<T>
)自体は、T dataを保持して、Socketにバイナリデータとして書き込むための役割(ICommand
)をまずは持っています。
それに加えてIValueTaskSourceであることにより、このオブジェクト自身がValueTaskになります。
そしてawait時のコールバックとして、書き込みループを阻害しないためにThreadPoolに流す必要があります。そこで従来のThreadPool.QueueUserWorkItem(callback)
を使うと、内部的には ThreadPoolWorkItem
を生成してキューに詰め込むため、余計なアロケーションがあります。 .NET Core 3.0からIThreadPoolWorkItemを実装することで、内部のThreadPoolWorkItem
の生成をなくすことができます。
最後に、同居させることで必要なオブジェクトが1つになりましたが、その1つをプーリングしてゼロアロケーション化します。オブジェクトプールはConcurrentQueue<T>
などを使うと簡単に実装できますが、自分自身をStackのNodeにすることで、配列を確保しないで済むようにしています。また、Nodeの出し入れに関しては、今回のキャッシュの実装では正確に取り出せる必要性はないため、lockは使わず、マルチスレッドで競合が発生した場合はキャッシュミス扱いにして新規生成するようにしています。これはオブジェクトプーリングにおける性能バランスとしては、良いチョイスだと考えています。
internal sealed class ObjectPool<T>
where T : class, IObjectPoolNode<T>
{
int gate;
int size;
T? root;
readonly int limit;
public ObjectPool(int limit)
{
this.limit = limit;
}
public int Size => size;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryPop([NotNullWhen(true)] out T? result)
{
// Instead of lock, use CompareExchange gate.
// In a worst case, missed cached object(create new one) but it's not a big deal.
if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
{
var v = root;
if (!(v is null))
{
ref var nextNode = ref v.NextNode;
root = nextNode;
nextNode = null;
size--;
result = v;
Volatile.Write(ref gate, 0);
return true;
}
Volatile.Write(ref gate, 0);
}
result = default;
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryPush(T item)
{
if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
{
if (size < limit)
{
item.NextNode = root;
root = item;
size++;
Volatile.Write(ref gate, 0);
return true;
}
else
{
Volatile.Write(ref gate, 0);
}
}
return false;
}
}
- Zero-copy Architecture
Publish/Subscribeするデータは通常、C#の型をJSONやMessagePackなどにシリアライズしたものを流します。この場合、どうしてもbyte[]でやり取りすることが多くなります、例えばStackExchange.RedisのRedisValue
の中身は実質byte[]で、送信にせよ受信にせよ、byte[]を生成して保持することになります。
これを避けるために、ArrayPoolから出し入れしてごまかしてゼロアロケーションにする、みたいなのはありがちではありますが、それでもコピーのコストが発生していることには代わりありません。ゼロアロケーションは当然目指すところですが、ゼロコピーに向けても頑張りましょう!
AlterNatsのシリアライザーはWriteにIBufferWriter<byte>
, ReadにReadOnlySequence<byte>
を要求します。
public interface INatsSerializer
{
int Serialize<T>(ICountableBufferWriter bufferWriter, T? value);
T? Deserialize<T>(in ReadOnlySequence<byte> buffer);
}
public interface ICountableBufferWriter : IBufferWriter<byte>
{
int WrittenCount { get; }
}
// 例えばMessagePack for C#を使う場合の実装
public class MessagePackNatsSerializer : INatsSerializer
{
public int Serialize<T>(ICountableBufferWriter bufferWriter, T? value)
{
var before = bufferWriter.WrittenCount;
MessagePackSerializer.Serialize(bufferWriter, value);
return bufferWriter.WrittenCount - before;
}
public T? Deserialize<T>(in ReadOnlySequence<byte> buffer)
{
return MessagePackSerializer.Deserialize<T>(buffer);
}
}
System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>
を受け取るオーバーロードが用意されています。IBufferWriter<byte>
経由でSocketに書き込むために用意しているバッファーにシリアライザが直接アクセスし、書き込みすることで、Socketとシリアライザ間でのbyte[]のコピーをなくします。
Read側では、ReadOnlySequence<byte>
を要求します。Socketからのデータの受信は断片的な場合も多く、それをバッファのコピーと拡大ではなく、連続した複数のバッファを一塊として扱うことでゼロコピーで処理するために用意されたクラスがReadOnlySequence<T>
です。
「ハイパフォーマンスの I/O をより簡単に行えるように設計されたライブラリ」であるSystem.IO.PipelinesのPipeReader
で読み取ったものを扱うのが、よくあるパターンとなります。ただし、AlterNatsではPipelinesは使わずに独自の読み取り機構とReadOnlySequence<byte>
を使用しました。
System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>
を受け取るオーバーロードが用意されているため、それを直接渡すことができます。つまり、現代的なシリアライザはIBufferWriter<byte>
とReadOnlySequence<byte>
のサポートは必須です。これらをサポートしていないシリアライザはそれだけで失格です。
まとめ
プロトコルが単純で少ないのでちゃちゃっと作れると思いきや、まあ確かに雑にTcpClientとStreamReader/Writerでやれば秒殺だったのですが、プロトコルって量産部分でしかないので、そこがどんだけ量少なかろうと、基盤の作り込みは相応に必要で、普通に割と時間かかってしまった、のですが結構良い感じに作れたと思います。コード的にも例によって色々な工夫が盛り込まれていますので、是非ソースコードも読んでみてください。
クライアント側の実装によってパフォーマンスが大きく違うというのはシリアライザでもよくあり経験したことですが、NATSのパフォーマンスを論じるにあたって、その言語のクライアントは大丈夫ですか?というところがあり、そして、C#は大丈夫ですよ、と言えるものになっていると思います。
NATSの活用に関してはこれからやっていくので実例あるんですか?とか言われると知らんがな、というところですが(ところでMagicOnionはこないだのプリコネ!グランドマスターズだけではなく最近特によくあるので、実例めっちゃあります)、これから色々使っていこうかなと思っているので、まぁ是非AlterNatsと共に試してみてください。
プリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しました
- 2022-04-08
Cygamesから4/1にリリースされたプリコネ!グランドマスターズのサーバーサイドとインフラ開発をCysharpが開発協力しました。リアルタイム通信を含むオートバトラー系のゲームです。
Cysharpはサーバー側のアーキテクチャ設計と基盤実装、クラウドインフラ構築、一部サーバーロジック実装を担いました。リアルタイム通信部分だけではなくてAPIサーバーからマッチメイキング、インフラまで、構成されるあらゆる要素がC#で作られています!
- クライアント (Unity)
- API サーバー(MagicOnion)
- バトルエンジンサーバー (リアルタイム通信; MagicOnion, LogicLooper)
- マッチメイキングサーバー (リアルタイム通信; MagicOnion)
- バッチ(ConsoleAppFramework)
- デバッグ機能サーバー (Web; Blazor)
- 管理画面サーバー (Web; Blazor)
- インフラ (Infrastructure as Code; Pulumi + C#)
サーバー側アプリケーションは.NET 6をKubernetes上で動かしています。Unityクライアント側でもCysharpのOSSは7つクレジットされていますが、表記のないサーバー側専用のものを合わせたら10個以上使用しています。ここまで徹頭徹尾C#でやっているプロジェクトは世界的にも珍しいんじゃないでしょうか。中心的に活躍しているのはMagicOnionですが、サーバーサイドゲームループのためのLogicLooper、負荷テストのためのDFrameなども実戦投入されて、成果を出しました。サーバートラブルも特になく、しっかり安定稼働しました。という事後報告です。そして今日、もとより期間限定公開ということで一週間の配信期間が終了しました。
アーキテクチャ含めの詳しい話は後日どこかでできるといいですね……!今回、私は実装者としては裏方というか監督というかという感じなので、発表する際は別の人にお任せします……!
C#でのサーバー構成をまた一つ実証できて、参考になって欲しいのですが(そしてC#採用事例増えて欲しい!)、こういった構成を、Cysharpだから出来る、のではなくて、誰もが実現できる環境にしていきたいとも思っています。重要なパーツは積極的にOSS化していますし、実績も着実に積み重ねられています。が、しかしまだまだ難しい面も数多くあるということは認識しています。かといってmBaaSの方向でやっていくべき、とは思わないんですね。ロジックはゲームの差別化のための重要な要素であり、サーバーサイドでも書くべきで。だから注力しているのは書きやすくするための環境で、そのために足りないものを提供していっています。
ところでサーバーとクライアントの繋ぎ、あるいはサーバーとサーバーの繋ぎが、MagicOnionだけだと複雑で難しくなってしまうところがあるな、と思っていまして、ちょうど先月-今月はメッセージングライブラリの開発に注力しています。AlterNatsという名前でPreview公開していますが、これを挟むと色々改善されるんじゃないかなあ、と思っているので、少々お待ち下さい。そんな感じに、常により最善のC#アーキテクチャの探究と、OSSを通じた共有をまだまだ続けていきます。
DFrame - C#でテストシナリオを書く分散負荷テストフレームワーク
- 2022-02-28
と、いうものをリリースしました。Web UIとなるDFrame.Controllerと、負荷テストシナリオをC#で書くDFrame.Workerの組み合わせで成り立っていて、DFrame.Workerをウェブ上のクラスターに配置することで(Controllerと接続するただの常駐アプリなので、配置先はオンプレでもVMでもコンテナでもKuberenetesでもなんでもいい)、1から数千のワーカーが連動して、大量のリクエストを発生させます。また、テストシナリオをプレーンなC#で記述できるということは、HTTP/1だけではなく、あらゆる種類の通信をカバーできます。WebSocket、HTTP/2、gRPC、MagicOnion、あるいはPhotonや自作のTCPトランスポート、更にはRedisやデータベースなどが対象になります。
DFrame.Workerは通常の.NETの他に、Unityにも対応しています!つまり、大量のHeadless Unity、あるいはデバイスファームに配置することで、Unityでしか動かないような独自通信フレームワークであっても負荷テストをかけることが可能です。
また、あまり注目されていませんが負荷テストツールにもパフォーマンスの違いは「かなり」あり、性能の良さは重要で、そこのところにもかなりチューニングしました。
Web UI(DFrame.Controller)はBlazor Serverで作られていて、分散ワーカーとの通信はMagicOnionで行っています。自動化のためのWeb APIの口もあるため、Blazor Server, ASP.NET Minimum API, MagicOnionのキメラ同居なアーキテクチャでC#でフル活用なのが設計的にも面白いポイントです。
C#で負荷テストシナリオを書く意義
負荷テストフレームワークは世の中に山のようにあります。代表的なものでもab, jMeter, k6, Artillery, Gatling, wrk, bombardier, Locust、k6やArtillery、GatlingなどはSaaSとしても提供していますし、クラウドサービス側も、Azure Load Testing(Managed jMeter)のようなマネージドサービスを出していますし、.NETでもdotnet/crankというものが存在していたりします。
DFrameはこの中でいうとアーキテクチャ含めLocustに近い(Controller-Worker構成やWebUIなど)のですが、その特徴の中で重要な点として挙げられているのが、シナリオをコードで書けること、です。よくわからんUIで設定させたり、複雑怪奇なXMLやYAMLやJSON書かせたりせず、プレーンなコードで書ける。これが大事。LocustはPythonですが、他にk6はJavaScriptで書けるようになっています。
じゃあLocustでいいじゃん、k6でいいじゃん、という話になるのですが、C#で書きたいんですね、シナリオを。これは別にただ単に自分の好きな言語で書きたいからというわけではなくて、サーバーあるいはクライアント言語と負荷試験シナリオ作成言語は同一のものであるべきだからです。例えばUnityのゲームを開発している場合(サーバーサイドの言語は何でもいい)、UnityのゲームはC#で記述されていますが、その場合C#でテストシナリオが書けるのなら
- 最初からクライアントSDK(エンドポイントと型付きのRequest/Response)に相当するものがある
- クライアントの実装と完全に等しいのでゲームのドメインロジックが最初からある
となります。それによりテストシナリオの記述の手間を大幅に削減できます。もちろん、Unity依存の部分を引き剥がすなどの追加の作業は必要ですが、完全に書き起こすなどといった無駄は発生しません。もしPythonでもJavaScriptでもLuaでも、とにかく異なる言語である場合は、比較にならないほどに作業量が膨大になってきます。
そして実際のクライアントコードとある程度共通になることで、サーバー/クライアント側の変化への追随が用意になります。それにより一回のリリースのための負荷テストではなく、継続的な負荷テスト環境を作っていけます。
また、プレーンなC#で記述できることで、冒頭にも書きましたがあらゆる通信の種類をカバーできるのは、通信プロトコルが多様化している昨今、大きな利点となります。
DFrameApp.Run
NuGetからDFrameをパッケージ参照したうえで、一行で起動します。テストシナリオ(Workload)の記述の行数もありますが、それでもこれだけで。
using DFrame;
DFrameApp.Run(7312, 7313); // WebUI:7312, WorkerListen:7313
public class SampleWorkload : Workload
{
public override async Task ExecuteAsync(WorkloadContext context)
{
Console.WriteLine($"Hello {context.WorkloadId}");
}
}
これで http://localhost:7312
をブラウザで開けば、SampleWorkload
がいます。
と、いうわけで、WorkloadのExecuteAsyncにコードを書くのが基本です。ExecuteAsync前の準備用としてSetupAsync、後始末としてTeardownAsyncもあります。単純なgRPCのテストを書くとこなります。
public class GrpcTest : Workload
{
GrpcChannel? channel;
Greeter.GreeterClient? client;
public override async Task SetupAsync(WorkloadContext context)
{
channel = GrpcChannel.ForAddress("http://localhost:5027");
client = new Greeter.GreeterClient(channel);
}
public override async Task ExecuteAsync(WorkloadContext context)
{
await client!.SayHelloAsync(new HelloRequest(), cancellationToken: context.CancellationToken);
}
public override async Task TeardownAsync(WorkloadContext context)
{
if (channel != null)
{
await channel.ShutdownAsync();
channel.Dispose();
}
}
}
Concurrencyの数だけWorkloadが生成されて、Total Request / Workers / Concurrencyの数だけExecuteAsyncが実行されます。コードで書くと言っても別にそう複雑なこともなく、よくわからんDSLで書くわけでもないので、むしろ(C#が書けるなら)とても書きやすいでしょう。中身も見てのとおり単純なので、gRPCでもMagicOnionでも何でも実行できます。
引数を受け取ることも可能なので、任意のURLを渡すようなものも作れます。コンストラクタでは、パラメーター、あるいはDIでインジェクトしたインスタンスを受け取れます。
using DFrame;
using Microsoft.Extensions.DependencyInjection;
// use builder can configure services, logging, configuration, etc.
var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureServices(services =>
{
services.AddSingleton<HttpClient>();
});
await builder.RunAsync();
public class HttpGetString : Workload
{
readonly HttpClient httpClient;
readonly string url;
// HttpClient is from DI, URL is passed from Web UI
public HttpGetString(HttpClient httpClient, string url)
{
this.httpClient = httpClient;
this.url = url;
}
public override async Task ExecuteAsync(WorkloadContext context)
{
await httpClient.GetStringAsync(url, context.CancellationToken);
}
}
WebUI画面にString urlの入力箇所が現れて、好きなURLを叩き込むことができるようになりました。
なお、単純なHTTPのGET/POST/PUT/DELETEをテストしたいという場合は、IncludesDefaultHttpWorkloadを有効にしてもらうと、内蔵のパラメーターを受け取るWorkloadが追加されます。
using DFrame;
var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureWorker(x =>
{
x.IncludesDefaultHttpWorkload = true;
});
builder.Run();
分散テスト
Workerは起動時に指定したControllerのアドレスにHTTP/2(MagicOnion/gRPC)で繋ぎに行って、常駐します。という普通の(?)アプリケーションなので、ウェブサーバーを分散させるのと同様に複数のWorkerを立ち上げてもらえれば、自動的に繋がります。
構成としては、以下の画像のようにControllerとWorkerのプロジェクトを分けるのが正当派(?)ですが
同居させてしまって、起動時のコマンドライン引数でどちらかのモード(あるいは両方)が起動するようにすることも、ローカルでの開発がしやすくなるのでお薦めです。 DFrameApp.CreateBuilder
にはそのための補助的な機構が用意されています。
using DFrame;
var builder = DFrameApp.CreateBuilder(5555, 5556); // portWeb, portListenWorker
if (args.Length == 0)
{
// local, run both(host WebUI on http://localhost:portWeb)
await builder.RunAsync();
}
else if (args[0] == "controller")
{
// listen http://*:portWeb as WebUI and http://*:portListenWorker as Worker listen gRPC
await builder.RunControllerAsync();
}
else if (args[0] == "worker")
{
// worker connect to (controller) address.
// You can also configure from appsettings.json via builder.ConfigureWorker((ctx, options) => { options.ControllerAddress = "" });
await builder.RunWorkerAsync("http://foobar:5556");
}
ローカルでWorkerの.exeを複数実行する、とかでも手元でとりあえずのWorker connectionsが増える様は確認できます。
Workerを増やすと表がにぎやかになって楽しい。実行するWorkerの数はスライダーで調整できるので、各種パラメーターを台数1で調整したあとに、徐々に実行Workerを増やしていく、といった使い方も可能です。また、その辺を自動でやってくれるRepeatモード(TotalRequestとWorkerを完了後に指定数増やして繰り返す)も用意しました。jMeterでいうところのRamp-Upの代わりに使えればいいかな、という想定でもあります。
アーキテクチャ的に最初から分散前提で作られているというのもあり、増やしても性能が劣化しない、リニアに性能が向上していくように作りました。Controllerは単一なのでスケールしないのですが、なるべく多くのWorkerをぶら下げられるように工夫しています。Controller <-> WorkerはMagicOnionで通信しているので、DFrame自身がMagicOnionの負荷テストになっているのです。
パフォーマンス
多数ある負荷テストフレームワークですが、パフォーマンスはそれぞれかなり異なります。詳しくはk6のブログOpen source load testing tool review 2020に非常に詳細に書かれていますが、例えばとにかくwrkがぶっちぎって他の数十倍~数百倍速かったりする、と。パフォーマンスは当然ながらとても重要で、ワーガーの非力さでターゲットに負荷をかけきれなかったりします。それに対応するためクラスターを組んでいくにしても、多くの台数やより高いスペックのマシンが必要になって、色々と辛い。
というわけでパフォーマンスは高ければ高いほうがいいのですが、先のブログに書かれている通り、拡張性の口やレポート取り出しの口などは必要です。その点でWrkは機能を満たさないということで、ブログではなんか結果から取り除かれてますね(その対応がいいのかどうかはなんとも言えませんが、まぁk6自身のアピールのためでもあるのでしょうがないね)。ちなみにフレームワークのパフォーマンスの指標として使われているTechEmpower Web Framework Benchmarksの負荷クライアントはwrkのようです。
さて、で、DFrameはどうかというと、かなり良好です。というのも、DFrameはライブラリとして提供されて、実行時は全てがC#の実行ファイルとしてコンパイル済みの状態になるのですね。スクリプトを動的に読んで実行するから遅くなってしまう、みたいなことがない。比較的高速な言語であるC#をそのまま利用するので、その時点である程度はいける。理論上。理屈上。
と、いう甘い見込みのもと実際作っていくと、さすがにそこまでさっくりとはいかず、相応にチューニングが必要だったのですが、最終的にはかなりの数字が出るようになりました。比較としてabとk6で測ってみると
本来はターゲットとワーカーは別マシンにしないといけないのですが(ワーカーの負荷でCPUが跳ね上がる影響をサーバー側がモロに影響受けてしまうので)、それでもそれなりに数字は変動しますし動きはするしマシンパワーも結構強め(Ryzen 9 5950x)なので、ちょっと手抜きでlocalhost上の無を返すHTTP/1サーバーをターゲットに、32並列(-c 32, -32VUs, Concurrency=32)で実行。
abが、6287 req/sec、k6が125619 req/sec、DFrameが207634 req/secです。abは、厳しい、厳しい……。もっと出るはずと思っているんですが、私の環境(Windows)だと昔からこんな感じなので、性能的には信用できないかなぁ。Windowsだとダメだったりするのかもしないのかもしれませんね。DFrameの場合Concurrencyにまだ余裕があって、増やすとまだまだ伸びたのですが、k6は割と頭打ちでした。
また、画像は出してませんがLocustは残念ながらかなり遅い上にCPUを食いまくるという感じで(Pythonだしね……)、いくらクラスタ化が容易とはいえ、ここまで1ワーカーあたりの性能が低いと、ないかなあ、という感想です。JMeterはそこまで悪くはないですが、パフォーマンスに影響を与える地雷コンフィグを必死にかいくぐってなおそこそこ程度なのはしんどみ。
ちなみになんで圧倒的性能番長であるwrkと比較しないのかというと、Windowsで動かすのが大変だからです。すみません……。
自動化のためのREST API
最初はいいけど、毎回GUIでポチポチやるの面倒で、それはそれで嫌だよね。CIで定期的に回したりもできないし。というわけで、バッチ起動モード、はついていないのですが、代わりにREST APIが自動で有効になっています。例えば /api/connections
で現在接続中のワーカーコネクション数が取れます。実行パラメーターなどはPostでJSONを投げる形になっています。
REST APIでJSONをやり取りするだけなので、どの言語から叩くことも可能ですが、C#の場合は DFrame.RestSdk
パッケージにて型付けされたクライアントが用意されているので、手間なくはじめられます。
using DFrame.RestSdk;
var client = new DFrameClient("http://localhost:7312/");
// start request
await client.ExecuteRequestAsync(new()
{
Workload = "SampleWorkload",
Concurrency = 10,
TotalRequest = 100000
});
// loadtest is running, wait complete.
await client.WaitUntilCanExecute();
// get summary and results[]
var result = await client.GetLatestResultAsync();
実行状況は全て連動しているので、REST APIから実行した進捗もWeb UI側でリアルタイムに状況確認できます。
Unityでも動く
Unityで動かしやすいかといったら全然そんなことないので、動かせるようにするのはもはや執念という感じではあるのですが、Unity対応しました。冒頭で書いたようにヘッドレスUnityを並べてコントロールする、みたいな用途は考えられます。まぁ、あと普通の負荷テストでも、通信部分のC#を普通の .NET に切り出すのが面倒だという場合に、ヘッドレスUnityでとりあえずビルドすることで何もしなくてもOK(そうか?)という策もあります。
Unityで動かす場合は、依存の解決(MagicOnion、gRPC、MessagePack for C#)が大変です!まぁ、それは置いておいて。それが出来ているなら、以下のようなMonoBehaviourに寿命をくっつけたインスタンスで起動させると良い感じです(MagicOnionというかネイティブgRPCは適切にコネクションをCloseしないとUnity Editorがフリーズするという酷い問題があるのですが、このコードは問題なくちゃんとクリーンアップしてくれるようになっています)。
public class DFrameWorker : MonoBehaviour
{
DFrameWorkerApp app;
[RuntimeInitializeOnLoadMethod]
static void Init()
{
new GameObject("DFrame Worker", typeof(SampleOne));
}
private void Awake()
{
DontDestroyOnLoad(gameObject);
}
async void Start()
{
// setup your controller address
app = new DFrameWorkerApp("localhost:7313");
await app.RunAsync();
}
private void OnDestroy()
{
app.Dispose();
}
}
[Preserve]
public class SampleWorkload : Workload
{
public override Task ExecuteAsync(WorkloadContext context)
{
Debug.Log("Exec");
return Task.CompletedTask;
}
public override Task TeardownAsync(WorkloadContext context)
{
Debug.Log("Teardown");
return Task.CompletedTask;
}
}
// Preserve for Unity IL2CPP
internal class PreserveAttribute : System.Attribute
{
}
Editor上の確認だとこんな具合です。
ライブラリかツールか
DFrame.Controller、他の設定を入れなければただのウェブアプリなので、ビルド済みのexeとしての提供も可能です。Locustなど他のツールも入れたら、とりあえず実行できる、のに比べると、必ず自分で組み込んでビルドしなきゃいけない。のは欠点に見える。
なのでビルド済みコンテナをDocker Hubかなんかで提供するという案もあったのですが、Workerはどうしても自分で組み込んでビルドする必要があるので、そこだけ省けても利点あるのかな?と考えて、最終的に却下しました。かわりに DFrameApp.Run
の一行だけでController+Workerの同居が起動できるようにして、最初の一歩の面倒臭さをライブラリデザインの工夫で乗り切ることにしました。Controller自体も、Microsoft.NET.Sdk.Web
ではなく、コンソールアプリケーションのテンプレートのMicrosoft.NET.Sdk
から起動できるようにしました。
DFrame.Controllerがライブラリとして提供されていることのメリットは、コンフィグが通常のコードやASP.NETの仕組みに乗っかったほうが圧倒的にシンプルになります。DIで好きなロガーを設定して、URLの指定やSSLなどもappsettings.jsonで行うのは、大量の複雑怪奇なコマンドラインオプションよりもずっと良いでしょう。
ログの永続化処理も、プラグイン的に用意するのではなく、普通にDIでインジェクトしてもらう(IExecutionResultHistoryProvider
というものが用意されていて、これを実装したものをDIに登録してもらえば、結果をデータベースに入れたり時系列DBに入れたりして統計的な参照ができるようになります)ほうが、使いやすいはずです。
Blazor Server + MagicOnion
DFrame.ControllerはBlazor ServerとMagicOnion(grpc-dotnet)が同居した構成になっています。これは中々面白い構成で、Web UIとMagicOnion(Server側)が同じメモリを共有しているので、末端のMagicOnion(Client側)の変更をダイレクトにC#だけを通してブラウザにまで届けているんですね。逆もしかりで、APIからのアクセス含めて、全てがリアルタイムに伝搬して画面も同期しているのですが、普通にやるとかなり複雑怪奇になるはずが、かなりシンプルに実装できています。
と、いうわけで、Cysharpではこの組み合わせに可能性を感じていて、別のサービスも同種のアーキテクチャで絶賛制作中なので興味ありましたら以下略。
紆余曲折
最初のバージョンは2年ぐらい前に作っていました。コンセプトは「自己分裂する分散バッチフレームワーク」ということで、自分自身のコピーを動的に作って無限大に分散して実行していくというもので。分散のための基盤としてKubernetesを使って。クラウドネイティブ!かっこいい!そして、一応動くものはできていました。あとは仕上げていくだけ、といったところで、放置していました。完成させなきゃ、と思いつつ、内心薄々あんまいい感じではないな、と思っていたため手が進まず無限放置モードへ。そして時が流れ、社内でもがっつり使うことになり引っ張り出されてきたそれは、やはりあまりいい感じではなく。で、最終的に言われたんですね、そもそも分裂機能いらなくね?と。
それでようやく気づくわけです、コンセプトから完全に間違っているからうまくいくわけがない!
反省として良くなかった理由としては、まず、現代のクラウドコンピューターを過大に評価していた。「自己分裂する」のは、一瞬で無限大にスケールして即起動、そして終わったら即終了、ならば、まぁそれでいいんですが、現実のスケールする時間はそんなに立派じゃない。サーバーレスといいつつ、別に1リクエスト毎にコンテナが起動して処理するわけはなく、常駐してリクエストを待つ。そりゃそうだ、と。自己分裂のコンセプトだと、分裂コストが重たいのは否めない。
もう一つは分裂するためのコードがDFrame内に記述されている。Kuberentesをコントロールするコードがたっぷり入ってしまって。そのせいでコードサイズが膨らんでしまったし、使う方も複雑なコンフィグをDFrame側に埋めなきゃいけなくなってしまった。これは二重にイケてない。作るのも複雑で、使うのも複雑ですからね、いいところがない……。
と、いうわけで、最初のかっこいいコンセプトを否定して、自己分裂しない。単純に繋ぎに行くだけ。としたことで、頭を抱えてうまくいかないと感じていた行き詰まりは解消したのでした。
まとめ
もう少し早くに作って提供したかった、という後悔がめっちゃあるのですが、同時に .NET 6だから出来たという要素もめっちゃあるので(パラメーター渡しの仕組みなどはConsoleAppFramework v4の設計の経験からスムーズに実装できた)、しょーがない。という気もする。Blazor Serverなどの進化も必要だったし。
しかし↑で書いたとおり最初に立てたコンセプトが間違っていて、長いこと軌道修正できず放置してしまっていたというのは個人的には割と手痛い経験です……。まぁ、間違ったコンセプトのまま進行してしまうというのは別によくあるので、それはしょーがないものとして別にいいんですが、自力で気づいてパーッと作り上げられてたらなあ、みたいな、みたいな。。。
ともあれ、完成したものとしてはかなり良い感じで(私の出すものとしては珍しくUIもちゃんとついているし!←UI作業は他の人に助力を請うてます)、ちょっとニッチ感もありますがC#アプリケーション開発の必需品として成り得る出来だと思っていますので、ぜひぜひお試しください。
WebSerializer - オブジェクトからクエリストリングに変換するHttpClientリクエスト用シリアライザ
- 2022-01-09
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¤tPage=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.ToQueryString
か WebSerializer.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¤tPage=0"
var url = WebSerializer.ToQueryString(UrlBase, new { sortBy, direction, currentPage });
await httpClient.GetAsync(url);
}
動的に組み立てる場合は、Dictionary<string, object>
も渡せます。(FormUrlEncodedContent
はDictionary<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年を振り返る
- 2021-12-31
例年、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)モニタに変更。
現状だとこの解像度はDell U4021QW一択。40インチは大きいかなーと思ったんですが、横に長いから高さ的には今まで使ってた32インチとそう変わらず、ですかね。デカいっちゃあデカですが、こんなもんかな、という感じでもある。この机も横1200の机でそんあ大きい机じゃないんですが、ジャストサイズぐらいで収まりますし。
人によっては100%サイズで使うのは小さい、と思ってしまいそうなところですが、私的には人体改造済み(ICLというレンズを眼球に埋める手術をしたので)なので問題なし、ということで100%サイズで使っています。
このサイズ、いい感じに視野に全部収まるので、デュアルやトリプルよりも快適さあります。今まで、最大で5画面ぐらいまでモニタ増やしてきたのですが、結局メイン以外のものは首をふるのもダルくてそんなに使える感じではないし、音楽プレイヤーのプレイリストを並べるとかだったら、置いてるiPadで聞いて表示しとけばいいじゃん、ということで、全然問題なし。
ツール補助がないとウィンドウがとっちらかってしまうので、Microsoft PowerToysのFancyZonesを使って割り振ってます。これのグリッド吸着がまた使いやすくて(Shift押しながら移動すると、事前定義したグリッドに張り付く)いいですね。
中央を広めに取りつつ(主に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時代なサラウンドにするという謳い文句もいいし、設置レイアウトが自由というのがいい。オフィシャルサイトのこの画像を見て購入を決めました。
HT-A9の画像、どれもフロントスピーカーの置き方が「わざとらしく」でたらめなんですよね、高さを絶対揃えない。これは、メーカーが別に高さ揃えなくていいんですよ、と推奨してるということなんですが、実際うちでの設置環境もフロントの高さは揃ってないです。揃えられないので。リアも位置も高さもグチャグチャで、適当における棚に置いてるだけって感じです。フロントもリアも無線スピーカーなので、そういう適当配置がめっちゃやりやすい。
それでもちゃんとサウランドするし、360 Spatialな音は心地良い。ただたんに音楽鳴らすだけにも使ってますね、起動も早いのでSpotify Connectでよく流してます。
アンプのサイズが小さい(Apple TVが一回り大きくなった程度)というのも設置が楽になった要因で、いやほんとよく出来てますね。確かに、全部無線なのでスピーカーを駆動するアンプは各スピーカー内蔵状態だから、本体をAVアンプあるあるなクソデカサイズにしなくてもいいんですね。そういうところにも無線の良さが出てますね。
来年に向けてのC#
こうしてダラダラと文章書いたり、そもそもこの12月はやたら記事量産しているなあ、というのは、ブログ書く環境が変わったからです!10年前のWordPressから自家製サイトジェネレーターへの変更によって快適度上がったからですね。いやあ、書きやすいと書く気になります。環境大事。
そんなわけでCysharpはC#はの環境を良くすることに今年一年もちゃんと務められたんじゃないでしょーか。Cysharpからの新規リリース/大型更新は
- ProcessX Zx
- MessagePipe
- Kokuban
- CsprojModifier
- NativeMemoryArray
- ConsoleAppFramework v4
- ObservableCollections ←ReadMe/解説がまだな仕掛品
MessagePipeが大きめかな?
こうした公開していく姿勢、足を少しでも止めてはダメだという思いがあるので、作ろうと思ったらできるだけ勢い持って作りきるようにしています(もちろん途中で止まってしまったものも幾つかありはしますが)。毎年継続的に、Cysharp全体としても既に20個以上公開していて、ヒット作もそれなりの量を出し続けていられる状態は中々のことだと思います。
と、いうわけで、OSSを通じてCysharpをアピールしていくという方向では、良い点をあげてもいいかな、と思うのですが、反面、他の仕事に集中しきれていないのではないかというのが散見していたのは個人的にはマイナスです。今年は「世の中の開発生産性を革命的に改善するプロダクト」の作成に着手し(構想は前からあったのですがようやく始動)、徐々に人も集めだしてCysharpが割とまともな(?)会社っぽく動き出した頃合いでもあるんですが、私がボトルネックになりがち、な状況になりがちなのが、まぁいくないですねえ、と。これはグラニの頃もそうだったので、なんかもうそういうもの感もあるんですが、今回は私が主導してやってるので尻拭いしてくれる人もいないので純粋に良くない!
というわけで、来年の中旬までにその「世の中の開発生産性を革命的に改善するプロダクト」をリリースするために全力でやっていくぞ、というのが目標です。実際出来上がってきて、かなりいいものになりそうな手応えはあるので、早く世の中に出して評価されたいものです。
まぁ、そんなわけで大きな何かがあったわけではないですが、確実に前進した年だと思うので、来年は爆発させる年にしましょう。
ConsoleAppFramework v4 - Minimal API for CommandLine tool
- 2021-12-30
皆さん .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超えの巨大データを扱うライブラリ
- 2021-12-22
.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から登場したRandomAccessのRead/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#でのスクレイピング技法
- 2021-12-04
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のところでコードを書きます。
ウォッチウィンドウは見たい変数を並べておく、お気に入り的な機能、と思いきや本質的にはそうじゃなくて、式を自由に書いて、結果を保持する、ついでに式自体も保持できるという、実質REPLなのです。代入もラムダ式もLINQも自由に書けるし、入力補完も普通に出てくる。Immediate Windowよりも結果が遥かに見やすいので、Immediate Windowは正直不要です。
デバッガー上で動いているので実データを自由に扱えるというところがいいですね。というわけで、ToHtml()でHTMLを見て、QuerySelectorAllをゆっくり評価しながら書いていきましょう。まずはサイドバーにあるので .side_body
を出してみると、あれ、二個あるの?と。
中開けて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から正規表現で年と月を取り出すので、とりあえずここでもウォッチウィンドウです。
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で動かすと、テスト用サーバーを立てるのにめっちゃ便利です!やってみましょう。
たった三行でサーバーが立ちます。便利。
await context.OpenAsync("http://localhost:5000/headers");
でアクセスして、 AngleSharp/1.0.0.0
で送られていることが確認できました。
なお、LINQPadでASP.NETのライブラリを使うには、Referene ASP.NET Core assembliesのチェックを入れておく必要があります。
他、よく使うNuGetライブラリや名前空間なども設定したうえで、Set as default for new queries
しておくと非常に捗ります。
さて、で、このUser-Agentのカスタマイズの方法ですが、AngleSharpはServicesに機能が詰まっているようなDI、というかService Locatorパターンの設計になっているので、ロードされてるServicesを(Watch Windowで)一通り見ます。
型に限らず全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の表示を見て、変更されてること確認できました。
ちなみに、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}");
}
WithDefaultLoader
と、そして認証クッキー持ち歩きのために WithDefaultCookies
をコンフィギュレーションに足しておくことが事前準備として必須です。User-Agentの書き換えはご自由に、ただやましいこと、ではなくてUA判定をもとにして処理する、みたいなサイトも少なからずあるので、余計ないこと考えなくて済む対策としてはUAをChromeに偽装しておくのはアリです。
FormへのSubmit自体は3行というか2行です。ページをOpenしてFormに対してSubmitするだけ。超簡単。 .Forms
で IHtmlElementForms
がすっと取れるので、あとは単純にSubmitするだけです。渡す値は { name = value }
の匿名型で投げ込めばOK。
度々出てくるウォッチウィンドウの宣伝ですが、この何の値を投げればいいのか、を調べるのにHTMLとニラメッコではなく、ウォッチウィンドウで調査していきます。
まず("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で、見ればどんなものかすぐわかるので、まずは実行結果を見てもらいましょう(素の回線だと一瞬でダウンロード終わってしまったので回線の低速シミュレーションしてます)
一行だけを随時書き換えていってくれるので、見た目も非常に分かりやすくて良い感じです。これはとても良い。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、使ってやってください。
コマンド引数やロギング処理やオプション取得
クローラーとしてガッツシやりたいなら、モードの切り替えとかロギングとか入れたいです、というか入れます。そこで私が定形として使っているのはConsoleAppFrameworkとZLogger。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#による自家製静的サイトジェネレーターに移行した話
- 2021-11-21
見た目はほとんど変わっていませんが(とはいえ横幅広くしたので印象は結構変わったかも)、このサイト、フルリニューアルしました。内部構造が。完全に。別物に。元々は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
ファイルを新規作成。
完全にVS Codeそのものでデスクトップアプリのものと全く区別が付かないレベルで、これを超える品質のエディタを普通のサイトに乗せることは不可能でしょう。当然もちろん画像のプレビューもできますし、なんだったら拡張すら入る。
GitHub管理だと画像置き場(アップロード)が面倒くさい問題があるのですが、これはIssueを画像アップローダーとして使うことで回避しています。Issueの入力フォームは、画像をCtrl+Vでそのままアップロードが可能です。そして嬉しいことに、マークダウンに変換してくれているのでコピペするだけでOK。
上がった先のuser-images.githubusercontent
は別にIssueそのものと紐付いているわけではないので、 アップローダ用に使ったIssueはSubmitすることなくポイ、です。そうしてどこにも紐付いていないuser-images.githubusercontent
ですが、別にだからといって削除されることもなく永続的に上がり続けているので、遠慮なく使わせてもらうことにします。まぁちゃんとGitHub上に上げてるコンテンツ用に使っているので、許されるでしょう、きっと。多分。
そうして出来上がった記事は、そのままエディタ上のgit UIからコミットすると、自身が作業している領域は直接サーバー上のmaster(main)なので、プッシュ不要で反映されます。
こうなると、もう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秒弱。
遅いっちゃあ遅いですが、許容できるといえば許容できますね。サイトジェネレートプログラムの実行時間自体は1秒以下で、別に全然時間かかってないんで、CIセットアップとか、それ以外の時間が何かとかかっちゃってます。GitHub Actionsの仕組み的にしょうがないといえばしょうがないんですが、もう少しなんとかなってほしいかなあ。あとGitHub Pages自体が反映が若干遅い。遅い上に進捗が分からないのが地味にストレスフル。とはいえとはいえ、良いんじゃあないでしょうか。良さの殆どはGitHub web-based editorから来てますね、これは本当に革命的に良い。というわけで、このweb-based editorを活かすシステムを作っていくという手段と目的を逆転させた思考が最終的に実際良いんじゃないかと思ってます!
C#でgoogle/zx風にシェルスクリプトを書く
- 2021-08-23
あまりシェルスクリプトを書かない私なのですが(小物でもなんでも書き捨てC#で書くスタイル)、CI だの .NET Core だのなんなので、全く書かないというわけにもいかない昨今です。まぁしかしcmdは嫌だし今更(?)PowerShellもなぁという感じもあり、bashねぇ、とかブツブツ言いながらしょっぱいスクリプトを書く羽目になるわけです。
そこに颯爽と現れたのが google/zx。素敵そうだなーと思いつつJavaScriptを日常的に書くわけでもないのでスルーしてたのですが、こないだもちょっと複雑なシェルスクリプトをJavaScriptで書くで紹介されていて、なるほど色物じゃなくて便利なのか、そうだよね便利だよね!と思い、私は日常的にC#を書くので、C#だったら便利だな、同じ感じで書けるなら、と、思い至ったのでした。
というかまぁzx見て思ったのが、これぐらいの内部DSL、C#でもいけるよ、ということであり……。そして以下のようなものが誕生しました。
もともと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#なので)
ということで、ぜひぜひお試しください。
- https://github.com/Cysharp/ProcessX
- PM> Install-Package ProcessX
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#)を再々々々々々々々々々受賞しました
- 2021-07-02
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は結構良いと思いますし、引き続き MagicOnionやUniTaskは開発進めています。
つまり全体的にとてもC#に貢献している。なるほどえらい。そりゃ更新も当然ですね(
今年は会社として、今ひとつ大きなプロダクトを仕込んでいる最中でして、それで大きなインパクトを Unity と .NET 、双方で引き起こせるはず、です……!乞うご期待。
というわけかで引き続きC#の最前線で戦っていきますので、今年もよろしくおねがいします。
2021年のC# Roslyn Analyzerの開発手法、或いはUnityでの利用法
- 2021-05-08
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年前!
- VS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する)
- VS2015+RoslynによるCodeRefactoringProviderの作り方と活用法
- UniRxでの空呼び出し検出、或いはRoslynによるCode Aware Libraries時代の到来について
- NotifyPropertyChangedGenerator - RoslynによるVS2015時代の変更通知プロパティの書き方
実用的という点では、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で作成します。最終的には以下のようなソリューション構造にします。
さて、では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に変更すると以下のような形にできます。
(この時、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をすると……
もちろんConsoleAppのほうでは、実際に動いて警告出している様が確認できます。
昔の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
- 2021-02-26
お題を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の紹介をしたのですが、非現実的な理想ではなくて、今表現できる最高のものを生み出していく。というのをモットーにしてます。エンジニアなら評論家にならず手を動かして結果で示せ、ということですね。
というわけでまぁUniTask v2.2.0もいい感じになっていると思うので、ぜひぜひ使っていただければです!