async decoratorパターンによるUnityWebRequestの拡張とUniTaskによる応用的設計例
- 2020-08-20
UniTask v2も2.0.30まで到達し、いい加減そろそろ安定したと言える頃合いです(ほんと!)。GitHub Star数も1000を超えて、準スタンダードとして安心して使ってもらえるレベルまで到達したと思うので、基盤部分から入れ込んで設計するとこんなことができますよ、という一例を出してみます。
UnityWebRequestはかなりプリミティブな代物で、そのまま使うよりかはある程度はアプリケーションに沿ったラッパーを被せることがほとんどなのではないかと思います。しかし、ライブラリ単体でアプリケーションの要求を全て満たそうとするとヘヴィになりすぎたり、というかそもそもアプリケーション固有の要求には絶対に答えられない。というわけで、理想的なラッパーというのは、それ自身が極力軽量で、拡張性を持たせたプラガブルな仕組みが用意されているものということになります。プラガブルな拡張性がないと、例えば基盤ライブラリ側で用意されたラッパーをアプリケーションで使う場合にうまく要件をあわせられなくて、Forkして直接改造しちゃう、という不毛な自体になったりします。
と、いうものを実現するにあたって、非同期リクエストにつきもののコールバックは非常に相性が悪い。コールバックの連鎖は、コード上でその場でネストしていくだけだったら数階層ネストしてもまぁまぁなんとかなりますが、プラガブルで複雑な組み合わせを実現しようとするとハンドリング不可能になります。
そこでasync/await。async/awaitならコンパイラの力に頼ることでそういうものができます!
async decoratorパターンという名前で紹介しますが、一般にはMiddlewareとして知られているものを実装します。ASP.NET Core、node.js(Express)やReactのMiddleware、PythonのWSGI、MagicOnionではFilterとして実装している、サーバーサイドではよく使われるデザインです。これは非常に強力なデザインパターンで、クライアント処理においても有用だと私は考えています。もし知らなければ絶対に覚えるべき……!
MagicOnionのフィルターの図を持ってくるとこんな感じで
メソッドが外から内側に包まれて呼ばれていきます。
await next(
await next(
await next()
)
);
通常やりたいことってざっくり
- ロギング
- モック
- タイムアウト処理
- リクエスト前のヘッダー処理
- リクエスト後のヘッダー処理
- ステータスコードに応じた例外時処理
- エラー時の処理(ポップアップ/リトライ/画面遷移)
といったことだと思われますが、この仕組みなら、これだけで全て実装できます……!
というわけで、実装例を見ていきましょう。
デコレーター例
まずは共通のインターフェイスとして以下のものを用意します。
public interface IAsyncDecorator
{
UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}
なるほどわからん。RequestContext、ResponseContextがそれぞれリクエスト/レスポンスに必要なデータが詰まっている単純な入れ物ということで特に気にしないこととして、大事なのはFunc nextです。
とりあえず、単純な例としてヘッダーの前後で処理するなにかを。
public class SetupHeaderDecorator : IAsyncDecorator
{
public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
context.RequestHeaders["x-app-timestamp"] = context.Timestamp.ToString();
context.RequestHeaders["x-user-id"] = "132141411"; // どこかから持ってくる
context.RequestHeaders["x-access-token"] = "fafafawfafewaea"; // どこかから持ってくる2
var respsonse = await next(context, cancellationToken); // 次のメソッドが呼ばれる
var nextToken = respsonse.ResponseHeaders["token"];
UserProfile.Token = nextToken; // どこかにセットするということにする
return respsonse;
}
}
await next() によって連鎖しているデコレーターメソッドの内側に進んでいきます。つまり、その前に書けば前処理、後ろに書けば後処理になります。nextの定義がよくわからなくても、デコレーターを量産していくことは簡単です。そこが大事。そんなんでいいんです。
さて、async/awaitと統合されていることによって、try-catch-finallyも自然に書けます。例えばロギングを用意すると
public class LoggingDecorator : IAsyncDecorator
{
public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
var sw = Stopwatch.StartNew();
try
{
UnityEngine.Debug.Log("Start Network Request:" + context.Path);
var response = await next(context, cancellationToken);
UnityEngine.Debug.Log($"Complete Network Request: {context.Path} , Elapsed: {sw.Elapsed}, Size: {response.GetRawData().Length}");
return response;
}
catch (Exception ex)
{
if (ex is OperationCanceledException)
{
UnityEngine.Debug.Log("Request Canceled:" + context.Path);
}
else if (ex is TimeoutException)
{
UnityEngine.Debug.Log("Request Timeout:" + context.Path);
}
else if (ex is UnityWebRequestException webex)
{
if (webex.IsHttpError)
{
UnityEngine.Debug.Log($"Request HttpError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
}
else if (webex.IsNetworkError)
{
UnityEngine.Debug.Log($"Request NetworkError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
}
}
throw;
}
finally
{
/* log other */
}
}
}
また、処理を打ち切ることも簡単に実現できます。nextを呼ばないだけですから。例えばダミーのレスポンスを返す(テストに使ったり、サーバー側の実装が整わない間に進めたりするために)デコレーターが作れます。
public class MockDecorator : IAsyncDecorator
{
Dictionary<string, object> mock;
// Pathと型を1:1にして事前定義したオブジェクトを返す辞書を渡す
public MockDecorator(Dictionary<string, object> mock)
{
this.mock = mock;
}
public UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
// それと if (EditorProfile.EnableMocking) とか用意しておいて、モック使うかの有無をエディタ拡張辺りで切り替えれるようにしとくと楽
if (mock.TryGetValue(context.Path, out var value))
{
// 一致したものがあればそれを返す(実際の通信は行わない)
return new UniTask<ResponseContext>(new ResponseContext(value));
}
else
{
return next(context, cancellationToken);
}
}
}
リトライ的な処理も考えてみましょう。例えば特殊なレスポンスコードを受信したときは、Tokenを取ってから再度処理し直してくれ、みたいな要求があるとします。
public class AppendTokenDecorator : IAsyncDecorator
{
public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
string token = "token"; // どっかから取ってくるということにする
RETRY:
try
{
context.RequestHeaders["x-accesss-token"] = token;
return await next(context, cancellationToken);
}
catch (UnityWebRequestException ex)
{
// 例えば700はTokenを再取得してください的な意味だったとする
if (ex.ResponseCode == 700)
{
// 別口でTokenを取得します的な処理
var newToken = await new NetworkClient(context.BasePath, context.Timeout).PostAsync<string>("/Auth/GetToken", "access_token", cancellationToken);
context.Reset(this); // RequestContextの状態が汚れてる(?)ので、nextを最初からやり直す場合はResetする
token = newToken;
goto RETRY;
}
throw;
}
}
}
シーケンシャルな処理を強制するために、キューを挟む場合はこのように書けます。私は並列リクエストできるなら極力並列にしたい派なので、あまりこういうのを挟むのは好きではないのですけれど、サーバー側の要求によっては必要な場合もあると思います。
public class QueueRequestDecorator : IAsyncDecorator
{
readonly Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)> q = new Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)>();
bool running;
public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
if (q.Count == 0)
{
return await next(context, cancellationToken);
}
else
{
var completionSource = new UniTaskCompletionSource<ResponseContext>();
q.Enqueue((completionSource, context, cancellationToken, next));
if (!running)
{
Run().Forget();
}
return await completionSource.Task;
}
}
async UniTaskVoid Run()
{
running = true;
try
{
while (q.Count != 0)
{
var (tcs, context, cancellationToken, next) = q.Dequeue();
try
{
var response = await next(context, cancellationToken);
tcs.TrySetResult(response);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}
}
finally
{
running = false;
}
}
}
簡単なものから結構複雑そうなものまで、そこそこ単純に書けることがわかったと思います!ただのawait nextという仕組みを用意するだけで!
用意したデコレーターはこんな風に使います。
// デコレーターの詰まったClientを生成(これは一度作ったらフィールドに保存可)
var client = new NetworkClient("http://localhost", TimeSpan.FromSeconds(10),
new QueueRequestDecorator(),
new LoggingDecorator(),
new AppendTokenDecorator(),
new SetupHeaderDecorator());
// 例えばこんな風に呼ぶということにする
var result = await client.PostAsync("/User/Register", new { Id = 100 });
async decoratorを実装する
ちょっと長くなりますが、そんな複雑なわけではありません。
// 基本のインターフェイス
public interface IAsyncDecorator
{
UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}
// リクエスト用の入れ物
public class RequestContext
{
int decoratorIndex;
readonly IAsyncDecorator[] decorators;
Dictionary<string, string> headers;
public string BasePath { get; }
public string Path { get; }
public object Value { get; }
public TimeSpan Timeout { get; }
public DateTimeOffset Timestamp { get; private set; }
public IDictionary<string, string> RequestHeaders
{
get
{
if (headers == null)
{
headers = new Dictionary<string, string>();
}
return headers;
}
}
public RequestContext(string basePath, string path, object value, TimeSpan timeout, IAsyncDecorator[] filters)
{
this.decoratorIndex = -1;
this.decorators = filters;
this.BasePath = basePath;
this.Path = path;
this.Value = value;
this.Timeout = timeout;
this.Timestamp = DateTimeOffset.UtcNow;
}
internal Dictionary<string, string> GetRawHeaders() => headers;
internal IAsyncDecorator GetNextDecorator() => decorators[++decoratorIndex];
public void Reset(IAsyncDecorator currentFilter)
{
decoratorIndex = Array.IndexOf(decorators, currentFilter);
if (headers != null)
{
headers.Clear();
}
Timestamp = DateTimeOffset.UtcNow;
}
}
// レスポンス用の入れ物
public class ResponseContext
{
readonly byte[] bytes;
public long StatusCode { get; }
public Dictionary<string, string> ResponseHeaders { get; }
public ResponseContext(byte[] bytes, long statusCode, Dictionary<string, string> responseHeaders)
{
this.bytes = bytes;
StatusCode = statusCode;
ResponseHeaders = responseHeaders;
}
public byte[] GetRawData() => bytes;
public T GetResponseAs<T>()
{
return JsonUtility.FromJson<T>(Encoding.UTF8.GetString(bytes));
}
}
// 本体
public class NetworkClient : IAsyncDecorator
{
readonly Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next;
readonly IAsyncDecorator[] decorators;
readonly TimeSpan timeout;
readonly IProgress<float> progress;
readonly string basePath;
public NetworkClient(string basePath, TimeSpan timeout, params IAsyncDecorator[] decorators)
: this(basePath, timeout, null, decorators)
{
}
public NetworkClient(string basePath, TimeSpan timeout, IProgress<float> progress, params IAsyncDecorator[] decorators)
{
this.next = InvokeRecursive; // setup delegate
this.basePath = basePath;
this.timeout = timeout;
this.progress = progress;
this.decorators = new IAsyncDecorator[decorators.Length + 1];
Array.Copy(decorators, this.decorators, decorators.Length);
this.decorators[this.decorators.Length - 1] = this;
}
public async UniTask<T> PostAsync<T>(string path, T value, CancellationToken cancellationToken = default)
{
var request = new RequestContext(basePath, path, value, timeout, decorators);
var response = await InvokeRecursive(request, cancellationToken);
return response.GetResponseAs<T>();
}
UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
{
return context.GetNextDecorator().SendAsync(context, cancellationToken, next); // マジカル再帰処理
}
async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
{
// Postしか興味ないからPostにしかしないよ!
// パフォーマンスを最大限にしたい場合はuploadHandler, downloadHandlerをカスタマイズすること
// JSONでbodyに送るというパラメータで送るという雑設定。
var data = JsonUtility.ToJson(context.Value);
var formData = new Dictionary<string, string> { { "body", data } };
using (var req = UnityWebRequest.Post(basePath + context.Path, formData))
{
var header = context.GetRawHeaders();
if (header != null)
{
foreach (var item in header)
{
req.SetRequestHeader(item.Key, item.Value);
}
}
// Timeout処理はCancellationTokenSourceのCancelAfterSlim(UniTask拡張)を使ってサクッと処理
var linkToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkToken.CancelAfterSlim(timeout);
try
{
// 完了待ちや終了処理はUniTaskの拡張自体に丸投げ
await req.SendWebRequest().ToUniTask(progress: progress, cancellationToken: linkToken.Token);
}
catch (OperationCanceledException)
{
// 元キャンセレーションソースがキャンセルしてなければTimeoutによるものと判定
if (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException();
}
}
finally
{
// Timeoutに引っかからなかった場合にてるのでCancelAfterSlimの裏で回ってるループをこれで終わらせとく
if (!linkToken.IsCancellationRequested)
{
linkToken.Cancel();
}
}
// UnityWebRequestを先にDisposeしちゃうので先に必要なものを取得しておく(性能的には無駄なのでパフォーマンスを最大限にしたい場合は更に一工夫を)
return new ResponseContext(req.downloadHandler.data, req.responseCode, req.GetResponseHeaders());
}
}
}
コアの処理はInvokeRecursiveです。もう少し単純化すると
UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
{
context.decoratorIndex++;
return decorators[context.decoratorIndex].SendAsync(context, cancellationToken, InvokeRecursive);
}
というように、IAsyncDecorator[]を少しずつ進めています。nextに入っているのは、配列の次の要素ということで、実際パターンの実装としてはそれだけです。
また、NetworkClient自体がIAsyncDecoratorとなっていて、つまりnextを使わないものが最奥部の、最後の処理となるわけです。
async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
{
// nextは使わず、ここで実際の通信処理を始める
}
今回はasync decoratorの紹介なので本体の処理は雑なんですが(とりあえずJsonシリアライズ/デシリアライズしたものを受け渡しするだけ、的な)、まぁ概ね雰囲気はわかると思うのでそれでいいでしょう。通常Pathとリクエスト/レスポンス型は1:1のはずなので(そうなってなければサーバー実装者を〆て1:1にさせましょう)、その辺のメソッドを自動生成しておくとかはよくあります。また、戻り値を複数めいたこと(ポリモーフィズム的な)のしたいんだよなあ、という場合にはMessagePack for C#のUnionという機能が使えるので、活用するといい感じになります。
面白要素としてはTimeoutの処理を CancellationTokenSource.CancelAfterSlim で行っているところでしょうか。TimeoutはWhenAnyを使って外側から処理するパターンもありますが、対象がCancellationTokenを受け取れる場合は、こっちのほうがより効率的で良いです。
タイトル画面に戻すなどダイアログとシーン遷移を組み合わせる
ネットワークリクエストに失敗した時って、なんかポップアップ出して 「エラーが発生しました タイトルに戻ります 「OK」」 みたいな画面が出てきますよね?それをやりましょうやりましょう。
public enum DialogResult
{
Ok,
Cancel
}
public static class MessageDialog
{
public static async UniTask<DialogResult> ShowAsync(string message)
{
// (例えば)Prefabで作っておいたダイアログを生成する
var view = await Resources.LoadAsync("Prefabs/Dialog");
// Ok, Cancelボタンのどちらかが押されるのを待機
return await (view as GameObject).GetComponent<MessageDialogView>().ClickResult;
}
}
public class MessageDialogView : MonoBehaviour
{
[SerializeField] Button okButton = default;
[SerializeField] Button closeButton = default;
UniTaskCompletionSource<DialogResult> taskCompletion;
// これでどちらかが押されるまで無限に待つを表現
public UniTask<DialogResult> ClickResult => taskCompletion.Task;
private void Start()
{
taskCompletion = new UniTaskCompletionSource<DialogResult>();
okButton.onClick.AddListener(() =>
{
taskCompletion.TrySetResult(DialogResult.Ok);
});
closeButton.onClick.AddListener(() =>
{
taskCompletion.TrySetResult(DialogResult.Cancel);
});
}
// もしボタンが押されずに消滅した場合にネンノタメ。
private void OnDestroy()
{
taskCompletion.TrySetResult(DialogResult.Cancel);
}
}
UniTaskCompletionSourceを活用して、ボタンが押されるまで待機というのを表現できます。こういう使い方、めっちゃするので覚えましょう。UniTaskCompletionSourceめっちゃ大事。
では、これとasync decoratorを組み合わせていきます。
public class ReturnToTitleDecorator : IAsyncDecorator
{
public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
{
try
{
return await next(context, cancellationToken);
}
catch (Exception ex)
{
if (ex is OperationCanceledException)
{
// キャンセルはきっと想定されている処理なのでそのまんまスルー(呼び出し側でOperationCanceledExceptionとして飛んでいく)
throw;
}
if (ex is UnityWebRequestException uwe)
{
// ステータスコードを使って、タイトルに戻す例外です、とかリトライさせる例外です、とかハンドリングさせると便利
// if (uwe.ResponseCode) { }...
}
// サーバー例外のMessageを直接出すなんて乱暴なことはデバッグ時だけですよ勿論。
var result = await MessageDialog.ShowAsync(ex.Message);
// OK か Cancelかで分岐するなら。今回はボタン一個、OKのみの想定なので無視
// if (result == DialogResult.Ok) { }...
// シーン呼び出しはawaitしないこと!awaitして正常終了しちゃうと、この通信の呼び出し元に処理が戻って続行してしまいます
// のでForget。
SceneManager.LoadSceneAsync("TitleScene").ToUniTask().Forget();
// そしてOperationCanceledExceptionを投げて、この通信の呼び出し元の処理はキャンセル扱いにして終了させる
throw new OperationCanceledException();
}
}
}
await使ってサクサク書いていけるので、道具が揃っていれば非同期処理とは思えないほど難なく書けます。
一つ注意なのは、呼び出し元に処理を戻すか戻さないか。普通にreturnすると処理が戻っていってしまいますが、Exceptionを再スローすればそれはそれでエラーとして出てしまってウザい。タイトル画面に戻すということは、その通信処理はキャンセルされたということなので、ここは処理がキャンセルされたとマークするのが正解です。asyncメソッドでキャンセル扱いするにはOperationCanceledExceptionを投げる必要があります。これは初見だと???という感じになると思いますが、そういうものなのでそういうものとして受け入れませう。
まとめ
UniTaskで道具を揃えたんだから、別に普通にばんばん書けるでしょ、便利に使ってね!ぐらいの気持ちでいたのであんまり応用例みたいなのの発信をしてこなかったんですが、よくよく考えると別にそんなことないよね……。ということにやっと気づいたので、色々盛りだくさんで紹介してみましたがどうでしょう。
最初はコールバックに毛が生えたもの程度でもいいとは思いますが、それだけじゃあ勿体ないわけです。せっかく言語機能として用意されているので、コールバックでは実現不可能なもう一段階上の設計が狙えるので、コールバックのことは忘れて使いこなしていって欲しいですね。
キャンセル処理に癖があるのは事実ですが(実際、最後に書いた明示的にOperationCanceledExceptionを投げよう、とかは一から発想していくのは難しいかもしれません)、「引数の最後に渡す」「明示的に投げてもいい」の二点だけなので、これは慣れるしかないし、それを理由にして利用範囲を限定的にするのはよくないかなー、と思ってます。
まぁ、ようするに普通に使ってね!便利ですよ実際!ということで。
ライブラリ作成のすゝめ - 40以上のOSS作成事例から見る個人OSSによる効能とキャリアの開発
- 2020-07-09
去年に専門学校の学生さん向けに講演した資料で、それ以外には未発表のスライドです。デベロッパーのキャリアとしてのエモい話になっているのでデブサミ向けにいいかな、と思って公募したところ落ちた!(←微妙にショックだった)のでずっとお蔵入りで眠っていたのですが、このご時世ですし他で講演できるところもなさそうなので、ここで放出することにしました。
作ることが能力の向上に繋がり、キャリアにも繋がっていく。別にそれだけが唯一解ではないけれど、一つの道筋として力になれたらな、と思っています。
大量に作るというのは、いや、大量ではなくても、メンテナンスが回るわけじゃないから大変だったり、時に無責任に見えてしまう(そういうわけではないけれど大変なのです!ごめんなさい!)とか、Issueに埋もれてシンドイとか、そういう負の側面も色々あるのですけれど、それでもね、やっていくのはいいことだと思います。そしてやるからには、一つ一つには真剣に取り組むことが、大きなリターンを得るための秘訣かな、と。
Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々受賞しました
- 2020-07-02
しました。多分10回目。Developer Technologiesというのが分かりづらくて嫌なのですが、C#です。一年ごとに再審査があって7月に一斉更新されるのですが、今年も継続です。
最近ちっともブログ書いてない気がしますが、Cygames Engineer's Blogに割とよく書いているので、なんだかんだでつまり結構書いています。Cysharpのほうでも、GitHub/Cysharpでの公開OSS数は15。総☆数も5000近くあるので、まだ設立2年に満たない会社ではありますが、結構存在感を示せていると思っています。もちろん全部C#。めっちゃC#に貢献してるやん。すごいえらい。
直近ではUniTask v2が大きな成果で、同時にC#はまだまだ突き詰められるなというのを実感しました。2年前の自分だとここまで書けなかったので、極めたなんてことはなく、まだまだ日々成長しています。Zシリーズ(ZString, ZLogger)も面白いですね。ConsoleAppFrameworkもばんばん使ってます。
Unity と .NET Coreが主軸というのは変わらず、そして両者を繋ぐ活動ができるのは世界に私だけ(能力的に、ではなくてアクティブに使命持ってやっている人が、ということですよネンノタメ)だと思っていて、OSSによる、C#の価値を広げていく、活用の幅を広げていくというのは引き続きやっていきたいことなのですが、もう一つ、会社としてもアクションを起こしていきたいと考えています。日本で、世界で大きなインパクトを出すためにも、まだまだ足りないことがいっぱいですから。
というわけかで引き続きC#の最前線で戦っていきますので、今年もよろしくおねがいします。
GitHub ActionsでUnityでunitypackage生成とビルド&実機(Linux)ユニットテストを実行する
- 2020-04-22
以前にCircleCIでUnityをテスト/ビルドする、或いは.unitypackageを作るまで、それとCIや実機でUnityのユニットテストを実行してSlackに通知するなどするという記事を書いたのですが、時代はGitHub Actionsということで、私も全体的にCircleCIからGitHub Actionsに移行を始めてまして、それに伴ってビルドスクリプトも最新化したので、紹介します。コンフィグ作成にあたっては【Unity】GitHub Actions v2でUnity Test Runnerを走らせて、結果をSlackに報告する【入門】とUnityをGitHub Actionsで動かす際にライセンス認証周りで注意するべき点も参考にしました。
実際のコンフィグは ZLogger/.github/workflows にありますが、Unityの部分だけ取り出して実行可能な形式にすると
name: Build-Debug
on:
push:
branches:
- "**"
tags:
- "!*" # not a tag push
pull_request:
types:
- opened
- synchronize
jobs:
build-unity:
strategy:
matrix:
unity: ['2019.3.9f1', '2020.1.0b5']
include:
- unity: 2019.3.9f1
license: UNITY_2019_3
- unity: 2020.1.0b5
license: UNITY_2020_1
runs-on: ubuntu-latest
container:
# with linux-il2cpp. image from https://hub.docker.com/r/gableroux/unity3d/tags
image: gableroux/unity3d:${{ matrix.unity }}-linux-il2cpp
steps:
- run: apt update && apt install git -y
- uses: actions/checkout@v2
# create unity activation file and store to artifacts.
- run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -logFile -createManualActivationFile || exit 0
- uses: actions/upload-artifact@v1
with:
name: Unity_v${{ matrix.unity }}.alf
path: ./Unity_v${{ matrix.unity }}.alf
# activate Unity from manual license file(ulf)
- run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
env:
UNITY_LICENSE: ${{ secrets[matrix.license] }}
- name: Activate Unity, always returns a success. But if a subsequent run fails, the activation may have failed(if succeeded, shows `Next license update check is after` and not shows other message(like GUID != GUID). If fails not). In that case, upload the artifact's .alf file to https://license.unity3d.com/manual to get the .ulf file and set it to secrets.
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0
# Execute scripts: RuntimeUnitTestToolkit
- name: Build UnitTest(Linux64, mono)
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64
working-directory: src/ZLogger.Unity
- name: Execute UnitTest
run: ./src/ZLogger.Unity/bin/UnitTest/StandaloneLinux64_Mono2x/test
# Execute scripts: Export Package
- name: Export unitypackage
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
working-directory: src/ZLogger.Unity
# Store artifacts.
- uses: actions/upload-artifact@v1
with:
name: ZLogger.Unity.unitypackage
path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage
となっています。微妙に長いね!(ショボいシンタックスハイライターの影響でインデントが腐ってて読みづらいのでworkflows/build-debug.ymlを見ていただいたほうがいいです)
さて、とりあえずUnityにおいての第一関門は認証を通すことなのですが、ここは -createManualActivationFile して -manualLicenFile に投げる、というやり方を採用します(他にも幾つかやり方はある)。Unityのインストール等に関しては、インストール済みのコンテナイメージを使って、コンテナでビルドします。使用できるイメージ一覧は DockerHub - gableroux/unity3d/tagsから選べますが、ここではIL2CPPビルドが実行できると謳ってる、かつ最新の2019.3.9f1-linux-il2cppと2020.1.0b5-linux-il2cppを使うことにしました。マトリックスビルドかけるなら、もっと古いのあたりも入れたほうがいいといえばいいんですが、LinuxでIL2CPPビルド可能なのは2019.3からなのでshoganai。
matrix組むのは、特にアセット作っている人にとっては重要で、というのも新しいUnityサポートしたら古いUnityで使えないAPIを使っちゃってビルドエラーとか、たまによくやるんですよね、うっかり。if-def囲み忘れとか、逆に囲みすぎとか。というわけで、可能なら最低サポートバージョンから、マイナーバージョン毎ぐらいに組むのがいいと思います。そういう意味では、私は今のところ2018.3を最低サポートバージョンにしているので、2018.3, 2018.4もmatrixに組んだほうがいいのですがIL2CPPビルドのために以下略。
さて、認証ですが、この辺で処理しています。
# create unity activation file and store to artifacts.
- run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -logFile -createManualActivationFile || exit 0
- uses: actions/upload-artifact@v1
with:
name: Unity_v${{ matrix.unity }}.alf
path: ./Unity_v${{ matrix.unity }}.alf
# activate Unity from manual license file(ulf)
- run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
env:
UNITY_LICENSE: ${{ secrets[matrix.license] }}
- name: Activate Unity, always returns a success. But if a subsequent run fails, the activation may have failed(if succeeded, shows `Next license update check is after` and not shows other message(like GUID != GUID). If fails not). In that case, upload the artifact's .alf file to https://license.unity3d.com/manual to get the .ulf file and set it to secrets.
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0
基本的に認証の流れは .alf を作る -> .alf を https://license.unity3d.com/manual にアップロードして .ulf ファイルをダウンロード。そのulfファイル(中身はXMLテキスト)をGitHub ActionsのSettings -> Secretsに設定する、ということなのですが、.alfを手元で作るのは面倒なのでCIに作ってもらって、artifactsにあげてます。
初回実行時は.ulfがないので絶対に後続の実行は失敗します(Activate処理だけはエラーにならないので、その先で認証できなかったといってコケます)。ので、ActionsのArtifactsのところからalfをダウンロードして、.ulfを作ります。それをテキストエディタで開いてSecretsのところに適切な名前で保存すればOK。名前との関連付けは
matrix:
unity: ['2019.3.9f1', '2020.1.0b5']
include:
- unity: 2019.3.9f1
license: UNITY_2019_3
- unity: 2020.1.0b5
license: UNITY_2020_1
で設定してますが、ここでは2019.3.9f1用はUNITY_2019_3, 2020.1.0b5用はUNITY_2020_1にしました。2019_3といいつつ、コンテナイメージ毎に新しい認証ファイルがいるので、2019.3.10f1に変えたらSecretは設定しなおしです。面倒くさい。shoganai。
ユニットテストの実行はCysharp/RuntimeUnitTestToolkitを使用します。これはUnity Test Runnerで書いたユニットテストからCUI/GUIでの実行シーンをビルド時に動的に生成するもので、まぁまぁ便利です。特にCUIでのテスト実行はCI用ですね、結果をそのまま出力で見れたり、エラーがあったらそのままエラーにしてくれたりするので非常に楽。それ以外に私はエディタからWindowsでのIL2CPPビルド実行を多用しています(よくIL2CPPで引っかかるライブラリを作っているので)
設定は -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64
といったものを書けばOK。そうすると bin/UnitTest/StandaloneLinux64_Mono2x/test
に成果物ができてるので、すぐに実行すれば、CIでのテスト実行ということになります。
# Execute scripts: RuntimeUnitTestToolkit
- name: Build UnitTest(Linux64, mono)
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod UnitTestBuilder.BuildUnitTest /headless /ScriptBackend mono /BuildTarget StandaloneLinux64
working-directory: src/ZLogger.Unity
- name: Execute UnitTest
run: ./src/ZLogger.Unity/bin/UnitTest/StandaloneLinux64_Mono2x/test
ここで /ScriptBackend mono
を /ScriptBackend IL2CPP
にするとIL2CPPビルドになるので、やったーCIでIL2CPPのテストができるぞー!と思ったんですが、現在のコンテナイメージだとなんか謎エラーでビルドに失敗するので、一旦は諦めました。誰か成功させてください。何かとIL2CPPで引っかかるライブラリを作ってるので、できればここでテストしたいんですけどねえ。
最後に.unitypackageの作成ですが、これはリポジトリに仕込んである生成メソッドをキックして、artifactsにアップロードします。
# Execute scripts: Export Package
- name: Export unitypackage
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
working-directory: src/ZLogger.Unity
# Store artifacts.
- uses: actions/upload-artifact@v1
with:
name: ZLogger.Unity.unitypackage
path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
public static class PackageExporter
{
[MenuItem("Tools/Export Unitypackage")]
public static void Export()
{
var version = Environment.GetEnvironmentVariable("UNITY_PACKAGE_VERSION");
// configure
var root = "Scripts/ZLogger";
var fileName = string.IsNullOrEmpty(version) ? "ZLogger.Unity.unitypackage" : $"ZLogger.Unity.{version}.unitypackage";
var exportPath = "./" + fileName;
var path = Path.Combine(Application.dataPath, root);
var assets = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
.Where(x => Path.GetExtension(x) == ".cs" || Path.GetExtension(x) == ".meta" || Path.GetExtension(x) == ".asmdef")
.Where(x => Path.GetFileNameWithoutExtension(x) != "_InternalVisibleTo")
.Select(x => "Assets" + x.Replace(Application.dataPath, "").Replace(@"\", "/"))
.ToArray();
UnityEngine.Debug.Log("Export below files" + Environment.NewLine + string.Join(Environment.NewLine, assets));
var dir = new FileInfo(exportPath).Directory;
if (!dir.Exists) dir.Create();
AssetDatabase.ExportPackage(
assets,
exportPath,
ExportPackageOptions.Default);
UnityEngine.Debug.Log("Export complete: " + Path.GetFullPath(exportPath));
}
}
と、まぁこれでいい感じに?テストとパッケージ生成ができるようになりました!
なお、リリースビルドは別ワークフローのymlになっているので、マトリックスビルドとalfの処理とテストを省いてます(本当はマトリックスもテストもしたほうがよくて、全部通ったらartifact生成とかにしたほうがいいんですが、まぁ多少ザルでもいいでしょう)。
build-unity:
strategy:
matrix:
unity: ['2019.3.9f1']
include:
- unity: 2019.3.9f1
license: UNITY_2019_3
runs-on: ubuntu-latest
container:
# with linux-il2cpp. image from https://hub.docker.com/r/gableroux/unity3d/tags
image: gableroux/unity3d:${{ matrix.unity }}-linux-il2cpp
steps:
- run: apt update && apt install git -y
- uses: actions/checkout@v2
- run: echo -n "$UNITY_LICENSE" >> .Unity.ulf
env:
UNITY_LICENSE: ${{ secrets[matrix.license] }}
- run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -manualLicenseFile .Unity.ulf || exit 0
# Execute scripts: Export Package
- name: Export unitypackage
run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath . -executeMethod PackageExporter.Export
working-directory: src/ZLogger.Unity
# Store artifacts.
- uses: actions/upload-artifact@v1
with:
name: ZLogger.Unity.unitypackage
path: ./src/ZLogger.Unity/ZLogger.Unity.unitypackage
まとめ
GitHub Actions、最初は抵抗感あったんですが、というかCIの設定というのが、かなり嫌いな種類のエンジニアリングなので、一度覚えてコピペで済ませてるCircleCIから引っ越すのに腰が重かったんですが、まぁやってみればなんとかなる(社内でわちゃくちゃと手伝ってもらったお陰でもありますが)し、やってみれば、割といいんじゃないのかな、と思えるようになりました。CircleCIもいいんですが、最近のUI変更などがイケてない感じだったりで好感度下がっていたので、ぎっはぶActions、いいんじゃないでしょーか。
とりあえずDOOM Eternalが超絶面白いのでやっておくといいです。YouTube - Doom Eternal - Cultist Base Master LevelがDoom Eternalの圧倒的なスピード感と暴力を表現しててとてもいいので、ぜひ見て買ってくださいな。今年のGame of the Yearなので。久しぶりにゲーム超面白い……!と思った。ただたんに(クラシックなFPS的に)スピード速くするだけじゃこうならないんですよねえ(実際、前作のDOOM(2016)をEternal後にやると、あまり面白く感じない)、近年ではバトルロイヤルも発明でしたが、DOOM EternalもFPSを、ゲームを進化させるゲームデザインの発明ですね、とにかく良い。めっちゃ良い。
ProcessX - C#でProcessを C# 8.0非同期ストリームで簡単に扱うライブラリ
- 2020-01-30
C#使う人って全然外部プロセス呼び出して処理ってしないよね。というのは、Windowsがなんかそういうのを避ける雰囲気だから、というのもあるのですが、ともあれ実際、可能な限り避けるどころか絶対避ける、ぐらいの勢いがあります。ライブラリになってないと嫌だ、断固拒否、みたいな。しかし最近はLinuxでもばっちし動くのでそういう傾向もどうかなー、と思いつつ。
避けるというのはOSの違いというのもありそうですが、もう一つはそもそも外部プロセスの呼び出しが死ぬほど面倒くさい。ProcessとProcessStartInfoを使ってどうこうするのですが、異常に面倒くさい。理想的にはシェルで書くように一行でコマンドと引数繋げたstringを投げておしまい、と行きたいのですが、全然そうなってない。呼び出すだけでも面倒くさいうぇに、StdOutのリダイレクトとかをやると更に面倒くさい。非同期でStdOutを読み込むとかすると絶望的に面倒くさい、うえに罠だらけでヤバい。この辺の辛さは非同期外部プロセス起動で標準出力を受け取る際の注意点という記事でしっかり紹介されてますが、実際これを正しくハンドリングするのは難儀です。
そこで「シェルを書くように文字列一行投げるだけで結果を」「C# 8.0の非同期ストリームで、こちらも一行await foreachするだけで受け取れて」「ExitCodeやStdErrorなども適切にハンドリングする」ライブラリを作りました。
using Cysharp.Diagnostics; // using namespace
// async iterate.
await foreach (string item in ProcessX.StartAsync("dotnet --info"))
{
Console.WriteLine(item);
}
というように、 await foreach(... in ProcessX.StartAsync(command))
だけで済みます。普通にProcessで書くと30行ぐらいかかってしまう処理がたった一行で!革命的便利さ!C# 8.0万歳!
実際これ、普通のProcessで書くと中々のコード量になります。
var pi = new ProcessStartInfo
{
// FileNameとArgumentsが別れる(地味にダルい)
FileName = "dotnet",
Arguments = "--info",
// からの怒涛のboolフラグ
UseShellExecute = false,
CreateNoWindow = true,
ErrorDialog = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
using (var process = new Process()
{
StartInfo = pi,
// からのここにもboolフラグ
EnableRaisingEvents = true
})
{
process.OutputDataReceived += (sender, e) =>
{
// nullが終端なのでnullは来ます!のハンドリングは必須
if (e.Data != null)
{
Console.WriteLine(e.Data);
}
};
process.Exited += (sender, e) =>
{
// ExitCode使ってなにかやるなら
// ちなみにExitedが呼ばれてもまだOutputDataReceivedが消化中の場合が多い
// そのため、Exitと一緒にハンドリングするなら適切な待受コードがここに必要になる
};
process.Start();
// 何故かStart後に明示的にこれを呼ぶ必要がある
process.BeginOutputReadLine();
// processがDisposeした後にProcess関連のものを触ると死
// そして↑のようにイベント購読しているので気をつけると触ってしまうので気をつけよう
// ↓でもWaitForExitしちゃったら結局は同期じゃん、ということで真の非同期にするには更にここから工夫が必要
process.WaitForExit();
}
↑のものでも少し簡略化しているぐらいなので、それぞれより正確にハンドリングしようとすると相当、厳しい、です……。
さて、await foreachの良いところは例外処理にtry-catchがそのまま使えること、というわけで、ExitCodeが0以外(或いはStdErrorを受信した場合)には、ProcessErrorExceptionが飛んでくるという仕様になっています。
try
{
await foreach (var item in ProcessX.StartAsync("dotnet --foo --bar")) { }
}
catch (ProcessErrorException ex)
{
// int .ExitCode
// string[] .ErrorOutput
Console.WriteLine(ex.ToString());
}
WaitForExit的に、同期待ちして結果を全部取りたいという場合は、ToTaskが使えます。
// receive buffered result(similar as WaitForExit).
string[] result = await ProcessX.StartAsync("dotnet --info").ToTask();
キャンセルに関しては非同期ストリームのWithCancellationがそのまま使えます。キャンセル時にプロセスが残っている場合はキャンセルと共にKillして確実に殺します。
await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cancellationToken))
{
Console.WriteLine(item);
}
タイムアウトは、CancellationTokenSource自体が時間と共に発火というオプションがあるので、それを使えます。
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cts.Token))
{
Console.WriteLine(item);
}
}
また、ProcessX.StartAsyncのオーバーロードとして、作業ディレクトリや環境変数、エンコードを設定できるものも用意しているので、ほとんどのことは問題なく実装できるはずです。
StartAsync(string command, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(ProcessStartInfo processStartInfo)
Task<string[]> ToTask(CancellationToken cancellationToken = default)
System.Threading.Channels
今回、ProcessのイベントとAsyncEnumeratorとのデータの橋渡しにはSystem.Threading.Channelsを使っています。詳しくはAn Introduction to System.Threading.Channels、或いは日本語だとSystem.Threading.Channelsを使うを読むと良いでしょう。
プロデューサー・コンシューマーパターンのためのライブラリなのですが、めっちゃ便利です。シンプルなインターフェイス(ちょっと弄ってれば使い方を理解できる)かつasync/awaitビリティがめっちゃ高い設計になっていて、今まで書きづらかったものがサクッと書けるようになりました。
これはめちゃくちゃ良いライブラリなのでみんな使いましょう。同作者による System.Threading.Tasks.DataFlow(TPL Dataflow) は全く好きじゃなくて全然使うことはなかったのですが、Channelsは良い。めっちゃ使う。
まとめ
Process自体は、C# 1.0世代(10年前!)に設計されたライブラリなのでしょうがないという側面もありつつも、やはり現代は現代なので、ちゃんと現代流に設計したものを再度提供してあげる価値はあるでしょう。設計技法も言語自体も、遥かに進化しているので、ちゃんとしたアップデートは必要です。
こうした隙間産業だけど、C#に今までなくて面倒で、でもそれが一気に解消して超絶便利に、というのはConsoleAppFrameworkに通じるものがあります。C#の面倒と思えるところを片っ端から潰して超絶便利言語にしていく、ことを目指して引き続きどしどし開発していきます。というわけで是非使ってみてください。
Unityによるリアルタイム通信とMagicOnionによるC#大統一理論の実現 - フォローアップ
- 2020-01-28
先週の土曜日にUnity道場 京都スペシャル4というイベントで登壇してきました。関西にはめったに行かないので、良い機会を頂いて感謝です。参加者応募も231名、場所もかなり大きなホールでいい感じでした。また、主催されたクラウドクリエイティブスタジオさんはサーバー開発もC#でしてる企業さんでもありますね……!すばらすばら。
動画もUnity Learning Material(YouTube)に公開されています。
Unity……?まぁ、Unity、です、ええ。どちらかというと、MagicOnionが何を目指しているのか、みたいなところを説明できたんじゃないかなー、と思ってます。色々、思っているところを入れました。
このスライドを踏まえて、更に今後考えていること、というか「ハードコアを緩和する」というのが当分のテーマなのですが、なにやるか、というと……RPC以外の便利コンポーネントを作る、という意味ではありません。
あまり便利コンポーネントには関心がなくて、というのもどうせ無駄ばっかでパフォーマンスでないから使わん、とかになるんで、それだといらないなー、と。何のかんので私は割と理想主義者なので、良いものを作るための道具を提供したい、という思いがあります。性能の追求とかもその一環ですよね。というわけで、そこに反するものはちょっとねー、と。そこはアプリケーション実装側の責務だと思うので、自分で作り込んで欲しい……!
導入のヘヴィさやインフラ側は緩和していきたいです。特に、現在ネックになっているのがネイティブgRPCなので、それを引っ剥がしたいと思ってます。これを引っ剥がすと、つまり私の方で提供するPure C#なHTTP/2, gRPC実装に置き換えることでクライアント側は完全にプラットフォームフリー!サイズも低減!依存も消滅!そして完全なチューニングが可能になる!サーバー側はMicrosoft実装の ASP.NET Coreによるgrpc-dotnetベースに置き換えます。そうすると、実は通信層が自由に置き換えられるようになるので、TCPだけじゃなくてQUIC(これは実際、MicrosoftがExperimentalな実装をやってる最中なのでそれをすぐ投下できる)や、RUDPとかを入れ込むこともできます。
インフラ周りは、特にKuberenetes + Dedicated Server的に使うと、プラクティスがなさすぎて死にます。これはAgonesというGoogleの開発しているKuberenetes用のミドルウェアで解決すると思ってるんですが、現状だとまだ厳しいんですねー。というわけでAgonesにIssue立てたりもしてるんですが、さてはて。というわけでまだもう少し大変です。
それとアーキテクチャ的に、まずはRPCになってるのですが、これをサーバーループによる駆動に変換するためのブリッジ層を作り込みたいかなあ、と。現状でも自作すればできる状態なんですが、このぐらいは標準で用意してあげたほうがすわりがよさそうだ、と。
理想的な状態までの絵図は描けていますし、かなりいいところまでは来てると思ってます。ので、あともう一歩強化できれば、というところなのでやっていきます、はい。
ConsoleAppFramework - .NET Coreコンソールアプリ作成のためのマイクロフレームワーク(旧MicroBatchFramework)
- 2020-01-09
以前にMicroBatchFramework - クラウドネイティブ時代のC#バッチフレームワークという名前でリリースしていたライブラリですが、リブランディング、ということかでConsoleAppFrameworkに変更しました。それに伴い名前変更による多数の破壊的変更と、全体の挙動の調整を行っています。
当初の想定ではバッチ、特に機能紹介にあるMulti Batchをメイン機能と捉えて作っていたのですが、最終的には汎用的なコンソールアプリケーション用のフレームワークとして出来上がっていたので、より適正な名前にすることで、多くの人に正しく捉えてもらって、届けられるのではないかと思い、今回の変更に至りました。
といったように、 Microsoft.Extensions の仕組みに乗ってLogging, Configuration, DIなどをカバーしつつ、CLI用にパラメーターバインディング、メソッドルーティング、ライフサイクル管理を乗っけているのがConsoleAppFrameworkの意義となります。一度使ってもらえば、もう素のConsoleAppを作ることはなくなります、というぐらいには便利なのではないかと……!
同様のコンセプトとしては、PHPではLaravel Zeroという、Micro-framework for console applicationsがあります。Laravelのロギングやコンフィグレーションを共有しつつも、コマンドラインアプリケーションで使いやすいような処理が施されています。Microsoftによる実装ではdotnet/command-line-apiの System.CommandLine.Hosting + System.CommandLine.DragonFruit が近い機能を持っていますが、ConsoleAppFrameworkのほうがよりプロダクティビティが高いです。というかMSのはダメです。こういうの作るのにMicrosoftはセンスないんですよねー。
あらためてConsoleAppFrameworkの単純な例ですが
using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;
// Entrypoint, create from the .NET Core Console App.
class Program : ConsoleAppBase // inherit ConsoleAppBase
{
static async Task Main(string[] args)
{
// target T as ConsoleAppBase.
await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
}
// allows void/Task return type, parameter is automatically binded from string[] args.
public void Run(string name, int repeat = 3)
{
for (int i = 0; i < repeat; i++)
{
Console.WriteLine($"Hello My ConsoleApp from {name}");
}
}
}
> SampleApp.exe -name "foo" -repeat 5.
といったように、Mainに毛が生えた程度の記述だけで、気の利いたコマンドラインアプリケーションが作れます。今回からヘルプのフォーマットに気合いを入れているので、
public void Run(
[Option("n", "name of send user.")]string name,
[Option("r", "repeat count.")]int repeat = 3)
{
// ...
}
といったようにいい感じのショートカットと説明を属性で追加してあげると、
> SampleApp.exe help
Usage: SampleApp [options...]
Options:
-n, -name <String> name of send user. (Required)
-r, -repeat <Int32> repeat count. (Default: 3)
いい感じのhelpが表示されるようになりました。ちなみにこれは dotnet コマンドのフォーマットに近いものです。
.NET Core 3.0からはランタイム不要での単一ファイルのバイナリ作成がやっとできるようになったので、配布もよりやりやすくなりました。また、パッケージマネージャー経由での .NET Core Global Toolsという仕組み(.NET Core 2.1から)や、プロジェクト単位で設定してバージョン固定などがやりやすい.NET Core Local Tools(.NET Core 3.0から)といった仕組みも整備されているので、かなりいけてます。
また、ConsoleAppFrameworkの持つ複数のバッチ(コマンド/メソッド)を単一アプリケーションで管理する実行可能にする機能は、プロジェクト固有のバッチ(大量にあるはず!)やインフラ管理スクリプトなどを一本化して、CIなどではgit pull後に dotnet run command
で済ませられたりするなどは、実際私自身も有用に使っています。
リブランディングについて
MicroBatchFramework、正直なところもう少しウケてもいいと思ってたんですが、あんま伸びなかったんですよねー。コンセプトは良いはずだし実際機能的にもいいのになんでー?と思ったんですが、ようは"Cloud Native Batch Framework" というのが全然ピンときてないんですよねー。Cloud Nativeとか言っておけば喰い付くだろうとかいう安易なネーミングがダメ。あとBatchってのがやっぱダメだよね。バッチ。バッチって。
というわけで、ずっと気になってたんで、結果、今回の名前変えたのは本質をより表していていいんじゃないかなー、と思いますがどうでしょう?ReadMeも全体的に見直して、ウケる雰囲気になったと思うので、これでもう一発逆転狙いたいです(?)
それと、こういう名前変えるみたいなのも決断の一種なわけですが、名前を変えること自体は誰でもできるし、変えた名前も安易で誰でも決めれるわけですし、実際に変えてみるとピタッとピースがはまったように見える。けれど、じゃあいざ変えましょう、と踏み出すのはとてもむずかしい。というわけで、あ、シャッチョさん仕事したな、みたいな気になりました。まる。
2019年を振り返る
- 2019-12-30
今年はどういう年だったかというと、うーん、まともに会社として動き出した年、ですかね。去年Cysharpという会社を作りましたが、その時点では一人だったので会社感も全く何もなかったのですが、今年から何人か入ってきたので、やっと会社として体をなしてきました。
ブログの本数があからさまに減ってますが、Cygames Engineers' Blogに書いている分もあるのでそこはしょーがない。今年は講演も結構した気がします。CEDECやUniteなど大きなイベントでも話してきましたし、特にUniteのUnderstanding C# Struct All Thingsは好評だったようで、アンケート結果でも実質一位(正しくは4位、1~3位はUnity Technologies Japanの人だったので、それを除いたらという謎基準により)だったので、嬉しみがあります。
ライブラリは、なんか一年中ずっとMessagePack for C# v2に関わってた気がするのですが、なんとか駆け込みでリリースできてよかった。それに合わせてMagicOnionやMasterMemoryといった内部でMessagePackを使っているライブラリのアップデートも間に合い、今年を気持ちよく終えることができそうです。
Cysharpとしてはパブリックなリポジトリ(github/Cysharp)が10個。設立から一年ちょいの会社のポートフォリオとしては、かなり立派なんじゃないでしょうか!会社の社是「C#の可能性を切り開いていく」のとおりに、C#にないものを絶妙な感じに埋めまくっています。
私はC#にとてもこだわっているわけですが、実際のところ、言語の選択がアプリケーションの開発の成功に必須かといったら、別にそうではないとも思っているのです、実のところ。ほとんどのことは、別に何の言語だろうと達成できるだろう、とも。(実際、私はCTOとして在籍していた前の会社、グラニの立ち上げタイトルである「神獄のヴァルハラゲート」をPHPでの開発に同意して、暫くPHP書いていたりもしましたしね←ただし成功したらすぐにC#リプレイスプロジェクトを始動して、半年後に完全移行しましたが)。
それでも、人は何かを選択しなきゃあいけないわけで、そのときにC#を選んでほしいし、選ぶ理由を作っていくことを大事にしています。前提としての他の言語にあってC#にはないものをなくしていき、差別化としてC#にしかないものを用意(超ハイパフォーマンスなライブラリであったり、C#大統一理論なんかがそうです)する。そうすれば自然と選択肢に上がってくるし、その結果、選ばれることが増えていく。実績だって数多くできていく。そういった環境を作っていくために、気を吐いているわけです。
それって会社の経営者としてどうなの!?というと、そうした活動が回り回って自分たちにうまくいくようにうまくしている(いく)ので、まぁまぁうまくいってるんじゃないでしょうか。
私はいっぱいOSSを公開しているのですが、そもそもに私はライブラリを作ることをアートだと捉えているんですよね。比喩でなく文字通りの。強く伝えたいメッセージ、表現したい衝動があり、それは時に、哲学的であり、政治的であり、ポジショントークでもある。私にとってはOSSの形で公開することこそが最も強く表現できる手段で、ライブラリは思想の塊であり、言葉だけよりもずっと流暢に語ってくれると考えています。
なので、つまんない量産型アーキテクチャの話とか聞くと、もっと個性が立っていてもいいのに!いったい何がしたいの?(いや、普通に動くアーキテクチャが一番大事ですが)、とか思ったりはよくします。あと、外資系企業とかシアトルやシリコンバレー勤務とかの話もつまんないですね、彼らが誇っているのって結局は場所が違うだけで自分自身はフツーのことをしてるだけじゃない?魂がないよね!それで他人を腐してるのだから実にダサい。(いやまぁそれは言い過ぎで、どんな仕事にも情熱はこもっているものです!だいじょうぶだいじょうぶ!)
とはいえアートじゃ生きていけない!実際OSSでは喰っていけない!中世ならパトロンが必要なところですが、現代においても食客のような立ち回りが必要です。つまり……。いやまぁ、食客かどうかはともかくとしてともかくとして、私は役に立つべき局面(たまにある、しょっちゅうはない)では驚くべきほど役に立ちます!し、実際、食客じゃないのでちゃんと仕事してますし(してます!)、今仕込んでいるものはきっと来年にはお見せできる(したい)と思うのですが、非常にインパクトのある成果になっていることと思います。Win-Winじゃないですかー。そうありたいね。
というわけで、生きていく間で大事にしたいのは、これからもそうした表現したい衝動を持ち続けられること、です。そのためにも、どこから降ってくるか分からない衝動のために、色々な刺激を受け、脳みそで咀嚼して考えていきたいなあ、と。
C#
今年も色々作ったのですが、MicroBatchFrameworkが地味に大きかったかなあ。めっちゃくちゃ使ってます。作って良かった。もっと評判になればいいのに、ぐぬぬ……。
技術的にはMessagePack for C# v2がめちゃくちゃ大きくて、また一つC#が新しいレベルに到達してしまった……。みたいな感慨があります。v2のお陰で、MagicOnionはどうあるべきなのか、といった道筋がスッと通っていったのが実際感動的です。というわけで、来年はフレームワーク全体を通しての新世代のアーキテクチャの掲示、というのを行えるんじゃないかと思っています。
MasterMemoryも良かったのではないかと、SQLiteの4700倍!なのも当たり前で、これがあると取るべきアーキテクチャの幅が広がるはずです。サーバーサイドでもクライアントサイドでも、活用しがいのあるライブラリになっています。
反省点はUnity側の深堀りがほとんど出来なかったことですね。毎年言い続けているECSやる、とか、去年も言っていたECS+物理エンジンでちゃりんちゃりん、みたいなのも今年も何も実装せずに終わった、とか、いやはやー。来年こそECSイヤーにするぞい!
諸事情で、プログラミングに集中して割ける時間というのがどんどん減っていってはいるのですが(GitHubの草も壊滅的で)、それでも要所要所での爆発力みたいなので成果は出せているし、今年も純粋な能力的成長は果たせたと思っています。今のところの脳みその調子的にはかなり良いので、来年もいい成果を出していけそうです。
漫画/音楽/ゲーム/その他…
GDC 2019に行ってきたのですが、そのアウォードで表彰されてたゲームが、さすがの普通にとても良かった。特にCelesteは本当によく出来ていて暫く遊び続けていました。それとFlorence、これは(リリースは2018年ですが私が今年プレイした中で)Bestで、感情を動かすためのUI/UXの究極系みたいな感じで、がっつりショック受けました。とにかくヤバい。制作秘話がまさにGDCで発表されていました。[GDC 2019]鮮やかな人間ドラマを描く新感覚ゲーム「Florence」は,どのように作られたのか。22か月間の苦闘をデザイナーが振り返る。
音楽は念願の相対性理論の野外ライブに行ってきた!Liveアルバム調べる相対性理論を日比谷野音で全曲実演したのですが、信じられないほど良かった!メンバーチェンジ後の相対性理論はビミョンに思ってたのですが、前のメンバーじゃこのライブには絶対ならなかっただろうし、この到達点のための……!みたいに思ってもう全てが受け入れられた。そんなわけで今年はずっとこのアルバム聴いてましたね。Live映像も出してほしい……。
あとは、魂がふるえる展でふるえて来ました。いやあ、よかったね。実際この展示は最終的に今年の美術展の入場者数でもトップクラスだったそうで、映え、もそうですけれど実物から漂う死生観には圧倒されました。
ベストガジェットはAirPods Proということで。SonyのWF-1000XM3も買っちゃってはいたんですが、どうにも使いづらくてAirPodsをずっと使い続けてたんですが、AirPods Proが出た瞬間に、完全に全て乗り換えました。恐ろしい完成度のデバイスだし、様々な面で既存の延長線上「ではない」革命的な要素が散りばめられていて、モノを作るならこういうのを作りたいよね(喩えで、別にハードウェアを作りたいとかそういうことを言ってるわけではない)、と思わせる一品でした。
来年は
MagicOnion v4と、そのためのパーツ作りが年始に入っています。これは間違いなくC#にとってのキーパーツになるので、ちゃんと仕上げたいですねえ。仕事のほうでも今仕込んでいるものが公開されていく年だと思うので、きっちりやっていけば相乗効果でC#元年(とは)ですよ!にできるんじゃないかと思ってます。そしてUnity度合いが結構下がっちゃってるので、そこも補填していければパーフェクトな姿に……!
といったのを目指してどしどしやっていきます。来年は爆発の年、ということで。
MessagePack for C# v2によるC#における最新のI/Oパイプライン最適化
- 2019-12-17
MessagePack for C#のVersion 2を本日リリースしました。出る出る詐欺で、一年がかりでリリースまで漕ぎ着けました!とにかくめっちゃ時間かかった、死ぬほど私のリソースが取られていた、ので本当にリリースまで持ってこれてよかった……。めでたし。
今回はとてもOSSっぽく開発していて、メインの開発はMicrosoftのVisual StudioチームのPrincipal Software EngineerであるAndrew Arnottさんが書いています。私はそれに対してひたすら、APIデザインが好きじゃないだの、パフォーマンスが私の基準に満たしてないだの、文句つけまくる仕事をしていました。しかしコードのクオリティはさすがに非常に高くて、私だけだったらここには至れなかっただろうことを考えると、いい感じの共同開発ができたんじゃないかなあと思います。その結果として、一年前に掲示されたv2よりも、百億倍良くなってます。この一年間で磨きに磨き抜いたわけです。
最初のリリースから2年半が経ち、MessagePack for C#は今では ASP.NET のリポジトリに含まれていたり、Visual Studio内部で使われていたりと、もはや準標準バイナリシリアライザの地位を得ていたりします。さすがにここまで成長するとは想像してなかった。.NETに貢献し過ぎで偉い。Version 1はシリアライザのパフォーマンスの基準を大きく塗り替えて、新しい世代の水準を作り出したという偉業があったわけですが、今回のv2も大きな成果を出せると思っています。v2はI/Oパイプライン全体の最適化を見据えて、パイプラインの心臓部として正しく機能するためにはどうあるべきか、というのを指し示しました。今後のC#のアプリケーションのアーキテクチャは、ここで指し示した道に進んでいくことでしょう。
v1 -> v2においては破壊的変更多数なので、移行ガイドをmigration.mdにまとめてあるので、適当に読んでおくと良いでしょう、詳しい解説は特にしません。ライブラリ類は一斉にバージョン上げないと詰みます。Cysharpで作っているMagicOnionとMasterMemoryは作業中なので、来週にはドバッと上げておきます多分予定。
パイプラインによるゼロコピー
MessagePack for C#の内部構造について、見るべきメソッドシグネチャは以下の2つです。
public static void Serialize<T>(IBufferWriter<byte> writer, T value, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)
public static T Deserialize<T>(in ReadOnlySequence<byte> byteSequence, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)
シリアライズにおいては IBufferWriter<byte>
, デシリアライズにおいては ReadOnlySequence<byte>
を入出力口に使うというのがポイントです。
どちらもSystem.Buffersに定義されている .NET Standard 2.1
世代のインターフェイスです。反面、v1ではともに byte[]
ベースでした。
I/Oのパイプラインってなんぞや、というと、ようするに入出力へのbyte[]
をどう扱うかということであり、C#的には入り口も出口も最終的にはネイティブがやり取りするので、その手前のもの(SocketAsyncEventArgs, ConslePal+Stream, FileStream, etc...)、を呼び出して処理するフレームワーク、が呼び出すシリアライザ。といった流れになっています。シリアライザは常に中心(Object -> byte[]変換)にいます。上の画像は一般的なやり取りの場合(あるいはv1の場合)で、RedisValue(StackExchange.Redis)がbyte[]を、ByteArrayContent(HttpClient)がbyte[]を、といったように、割と素のbyte[]を求められる局面は多い。その場合、シリアライザはnew byte[]した結果を返すことになり、それはフレームワークの処理を得て、入出力の源流へ再度コピーされ(されないこともある)ます。
ここにおける無駄は、byte[]のアロケーションと、コピーです。
と、いうわけで、byte[]のアロケーションを避けるパターンとして、シリアライザは作業領域として外部のバッファープール( System.Buffers
で定義されたArrayPool<byte>
は .NET Core 3.0ではクラスライブラリ内でも多用されています)を使用し、フレームワークから提供されるStreamに書き込むことで、必要なコストをコピーだけにする手法があります。v1でも一部実装していました。なお、Streamに対して直接Writeすることで自前バッファを使わないという手も理論上可能ですが、Writeによるオーバーヘッドが多いため性能が悪化します。また、どちらにせよStream内部でバッファを持っている場合もあります。さらに、非同期にも対応できませんし、では全てをWriteAsyncで処理すれば、更にオーバーヘッドが多くて全く性能がでません。つまり、性能の良いアプリケーションを作るには、バッファをどう扱うが大事です。v1の設計思想として、シリアライズの一単位をバッファとして取り扱えば良い、だから全てをbyte[]ベースで処理し、Streamへは一気に書き込めば良い。という指針がありました。そして、それは実際正しく機能して、当時存在したあらゆるシリアライザを引き離す性能を叩き出しました。
IBufferWriter<byte>
を活用すると、作業に必要なバッファを元ソースに対して直接要求することができます。それによりバッファ管理を完全に元ソースに任せることができるため、シリアライザ内部の作業バッファからのコピーコストが消滅します。例えばソケット通信で使われるSocketAsyncEventArgsは通常使いまわされますが、それの持つ(byte[] Buffer)に直接書き込む、といったようなことが可能です。
Streamに対してはSystem.IO.Pipleines
の提供するPipeWriterがIBufferWriter<byte>
を実装し、最適なバッファ管理を代替してくれます。
ASP.NET Core 3.0からは従来の(Stream HttpResponse.Body)だけでなく、(PipeWriter HttpResponse.BodyWriter)も提供されるようになりました。MessagePack.AspNetCoreMvcFormatterは、.NETCoreApp 3.0の場合にはBodyWriterに対してシリアライズする実装を用意しています。
現在の.NETのフレームワークは、Streamを要求するものか、あるいはbyte[]を要求するものがほとんどです。しかし、フレームワークレベルでのIBufferWriter対応が進んでいけば、よりMessagePack for C# v2の真価が発揮されていくことでしょう。もちろん、byte[]を返すAPI(byte[] Serialize<T>(T value)
)でも、最適なバッファ管理によってアロケーションやコピーコストを抑えるようになっています。
理論とパフォーマンス
多くある誤解として、async/await
にしたら速くなるわけでもないし、Span<byte>
にしたから速くなるわけでもありません。そして、IBufferWriter<byte>
やReadOnlySequence<byte>
にしても速くなるわけではありません。理屈上コピーが減ったとしても、遅くなりえます。素朴に実装すればコピーしたほうが10倍速い、といった状況はありえます。
例えば [10, 100, 100]
をシリアライズしたいと思ったとして、intが最大5バイト必要だとして、都度writter.GetSpanで取得した場合と、byte[]でどばっと取得した場合を比較すると……
// IBufferWriter<byte>
foreach(var v in values)
{
var buffer = writer.GetSpan(5);
var length = WriteInt(buffer, v); // WriteInt returns write length
writer.Advance(length);
}
// byte[]
var buffer = ArrayPool<byte>.Shared.Rent(64K).AsSpan();
var offset = 0;
foreach(var v in values)
{
var length = WriteInt(buffer.Slice(index), v);
offset += length;
}
// Return buffer...
というようなコードを書いた場合、どう見てもbyte[]ベースで素朴にやったほうが速そうです、というか速いです。ReadOnlySequence<byte>
もそうで、内部は複雑な型のため、そのまま使ってSliceなどを多用すると、かなり遅くなります。よって、IBufferWriter<byte>
によって得られたバッファを適切に管理する中間層、ReadOnlySequence<byte>
によって得られたSegmentのバッファを適切に管理する中間層、の作り込みが必要になってきます。
v2の開発にあたっては、byte[]ベースで極限まで性能を高めたv1があるので、どれだけv1と比較して遅くなっていないか、を基準に随時ベンチマークを取ることによって、中間層の存在による性能低下を検知し、極力性能低下を抑えることに成功しました。
逆に言えば、純粋なシリアライザとしての性能はv1のほうが高速(な場合も多い/バッファ管理が賢くなったのでシリアライズ対象が大きい場合はv2が有利な場合もある)なのですが、パイプラインに組み込めることと、様々な工夫により、トータルでみるとv2のほうが実アプリケーションとしては有利になっています。
配列上のLZ4圧縮
v1 -> v2による性能向上の一つに、v1では64K以上のシリアライズではプールを使わず新規アロケーションをしていましたが、v2ではArrayPoolから取得する32Kのチャンクの連結リストを内部バッファとして使用しています(外部からIBufferWriterを渡さず、v2内部のバッファプールを使用する場合)。
byte[]を作る場合は、最後に連結して一塊に。Streamに書き込む場合は32K毎にWriteAsyncします。これによりバッファが溢れた場合に、List<T>
のように二倍のサイズのバッファを新規に確保して書き込み、などせずに済んでいます。また、常に使用するバッファの大きさが85K以下で済むため、悪名高いLarge Object Heap(LOH)を消費する(ここに溜まるとGCの性能が極度に低下する)ことも避けられています。
そしてv2から新規搭載された新しい圧縮モードである MessagePackCompression.Lz4BlockArray
では、この内部形式を利用して32K単位でLZ4圧縮をかけることにより、圧縮するために、一度、全部が一塊になった大きな配列を確保することを避けています。
実装上の工夫としては、MessagePackの拡張領域であるExtを使用することによって圧縮種別を判定可能にしていることと、Extは長さが必要なため、LZ4で圧縮されてサイズが縮むことを考えると事前に長さを計算することができない!ことを避けるために、Arrayを使用した上で、Arrayの最初の要素をExtにして判定用+LZ4のデシリアライズに必要な圧縮前の長さをここの部分に格納しています。これ、シリアライズもそうですが、デシリアライズ時もブロック単位で伸張できるので、大きなデータでも巨大配列を確保しないで済むという利点があります。
v1までの圧縮モードはMessagePackCompression.Lz4Block
として残していますが、v2ではMessagePackCompression.Lz4BlockArray
を使用することをお薦めしています。既に圧縮済みのバイナリデータに関しては、Lz4BlockArrayでもLz4Blockをデシリアライズすることが可能です(逆も同様)。
ちなみにこの32Kというサイズを選んだのには、ちゃんと意味があります!まず、ArrayPoolの仕様で16K, 32K, 64K, 128Kの大きさで確保されます。20Kを要求した場合は32Kが、65Kを要求した場合は128Kが得られるという図式です。
そしてLZ4圧縮した場合、全く圧縮できなかった場合、ワーストケースでは要求サイズよりもほんの少し「大きく」なります。さて、そこでチャンクのサイズが64Kギリギリまで使用していて、LZ4圧縮をかけようとした場合は、圧縮後のサイズは圧縮完了まで不明のため、事前にワーストケースを想定し64.1K(仮)を要求し、結果として128Kが得られます。つまり、LOH行きです。厳密にはArrayPoolを使用しているため使い回されるので大丈夫ですが、プールサイズには上限を設けているので(全体で共有で32K * 100)、使い切った場合はアロケートされるので、そういうケースでもLOH行きを避けるためのサイズになっています。
真のコードジェネレーター
悪名高いド不安定なコードジェネレーターは大きく改善され、真の安定性と、ディレクトリ単位での指定と、ファイル単位での出力と、CIで使いやすい .NET Core (Local/Global) Toolsでのインストールと、XamarinやUWPで便利なMSBuild Taskの提供と、Unityでは初心者フレンドリーなEditor拡張を追加しました。
とにかくMac/Linuxでも安定して動作する!というのが大きい!やっと大手を振って人にお薦めできるようになりました。
まとめ
と、解説しましたが、実装の8割以上は前述のAArnottさんが行ったものなので、まずは本当にありがとうございます。そもそもv2のキッカケはプロトタイプ実装を掲示されて、Forkしていくパターンか一緒に実装するかのどちらかと言われて、(何ヶ月も返事を放置した末に)、一緒にやっていきましょうとしたのが元でした。その後も、一ヶ月質問を放置するとかメールスルーとか、そもそも開発の遅れは私のコミュニケーションによるものでは……、というようなところを粘り強く乗り越えてもらったお陰です。
いや、そうはいっても私もかなりしっかりやってますよ!?特にAPIのデザインは紆余曲折あってもめにもめた末に一周回って私が最初に掲示したデザインになってるし、性能面では延々と私が地道にベンチマーク取って掲示することで腰を上げてもらったり(その時点で大体どこをどう変えればいいのかは分かってるので、指摘しながら)、Unity周りはそもそも興味ないみたいなので油断するとすぐ壊れるところを直していったりと、はい。
これでパイプラインにおける心臓部分を手に入れることが出来たのですが、改めてまだそもそもパイプラインに血が流れてません。ASP.NET Coreだけでなく他のフレームワークやライブラリ郡(特にRedisと、ADO.NETはいい加減に10年前のレガシーモデルから卒業して新しい抽象層を提供して欲しい)も対応していかなければならないし、私の場合はMagicOnionが、トランスポート層に採用しているGoogleのgRPCがイマイチなせいでめちゃくちゃイマイチです。
というわけで、次回はMagicOnionのパイプライン化を最適化するために、通信層から手を入れる予定です。また、シリアライザはMessagePackだけではなく、JSONも重要なので、改めてUtf8Jsonの改修も行いたいと思っています。.NET Core 3で華々しくデビューしたMicrosoft公式実装のSystem.Text.JsonによるJsonSerializerの性能が極めて悪いので……。残念ながらMicrosoftは柔軟かつ性能の出るシリアライザの作り方を全く分かっていないのでしょう。
また、このパイプラインはサーバーの入口→出口だけで閉じるものではなく、ネットワークを超えてクライアント側(Unity)にまで届くものだと考えています。サーバー/クライアントを大きなパイプラインに見立てて、見えるところ通るところ全てを最適化することが「C#大統一理論」であり、真に強力なのだ。ということを実証していくのが当座の目標で、やっと最初の一歩が踏めました。C#凄いな、と心の底から世界中の人が思ってもらうためにも(そしてあわよくば採用してもらう!)、まだ足りてないものは山のようにあるので、どんどん潰していきましょう。
.NET Core時代のT4によるC#のテキストテンプレート術
- 2019-12-06
C# Advent Calendar 2019用の記事となります。C# Advent Calendar 2019はその2もあって、そちらも埋まってるので大変めでたい。
さて、今回のテーマはT4で、この場合にやりたいのはソースコードジェネレートです。つまるところC#でC#を作る、ということをやりたい!そのためのツールがテンプレートエンジンです。.NETにおいてメジャーなテンプレートエンジンといえばRazorなわけですが、アレはASP.NET MVCのHTML用のViewのためのテンプレートエンジンなため、文法が全くソースコード生成に向いていません、完全にHTML特化なのです。また、利用のためのパイプラインもソースコード生成に全く向いていない(無理やりなんとか使おうとするRazorEngineといったプロジェクトもありますが……)ので、やめておいたほうが無難です。
では何を使えばいいのか、の答えがT4(Text Template Transfomration Toolkit)です。過去にはMicro-ORMとテーブルのクラス定義自動生成についてという記事で、データベースのテーブル定義からマッピング用のC#コードを生成する方法を紹介しました。テーブル定義は、実際にDB通信してもいいし、あるいは何らかの定義ファイルを解析して生成、とかでもいいですね。また、最近の私の作っているライブラリには(UnityのIL2CPP対策で)コードジェネレーターがついていますが(MagicOnion, MasterMemory, MessagePack-CSharpなど)、それらの生成テンプレートは全てT4で作っています。
元々はVisual Studioべったりでしたし、実際べったりなのですが、Mac環境ではVisual Studio for Macで、あるいは(一手間入りますが)VS Codeで使用したり、最近だとRiderが2019.3 EAP 5からばっちしサポートされたようなので、MacやRider派の人も安心です。RiderのT4サポートは曰く
You asked us to support T4, and we’ve answered! T4 support is here, based on our own generator and available as a pre-installed plugin.
The plugin is open-sourced (https://github.com/JetBrains/ForTea) and your contributions are very welcome.
Feature-rich C# support in code blocks includes highlighting, navigation, code completion, typing assistance, refactorings, context actions, inspections, formatting, and more.
T4-specific features include inspections, typing assistance, folding, brace matching, etc.
Extensive support is offered for includes to make the resolve in C# code as correct as possible.
You can execute T4 templates.
You can also debug T4 templates.
All these features work across Windows, macOS, and Linux.
だそうで、なかなかイケてるじゃないですか。Visual Studioも、Riderもそうですが、テンプレートエンジンでデバッガ動かしてステップ実行できたりするのが地味に便利です。VS Codeだと設定に一手間が必要なので(Qiitaにて.NET Core+VS CodeでもT4 テンプレートエンジンでコード生成したい!といった紹介もありますが)、VS2019, VS for Mac, Riderを使ったほうが楽そうです。
T4には2種類の生成パターン、デザイン時コード生成(TextTemplatingFileGenerator)と、実行時テキスト生成(TextTemplatingFilePreprocessor)の2種類がありますが、両方紹介します。
また、詳細なドキュメントがコード生成と T4 テキスト テンプレートにあり、実際かなり複雑な機能も搭載してはいますが、テキストテンプレートで複雑なことはやるべきではない(テキストテンプレートなんてあまり使わないような機能で使い倒した複雑なことやられても解読に困るだけ)ので、シンプルに使いましょう。シンプルに使う分には、全く難しくないです。
デザイン時コード生成
デザイン時コード生成とは、テンプレート単体でテキストを出力するタイプです。出力されたコードが、そのまま自身のプロジェクトのコンパイル対象になるイメージ。使い道としては、手書きだと面倒くさい単純な繰り返しのあるソースコードを一気に量産するパターンがあります。例えばジェネレクスのT1~T16までの似たようなコードを作るのに、一個雛形をテンプレートとして用意して for(1..16) で生成すれば一発、というわけです。他に、プリミティブの型(intとかdoubleとか)に対してのコード生成などもよくやりますね。またマークダウンで書かれた表(別の人がドキュメントとして書いているもの)から、enumを生成する、なんてこともやったりしますね。違うようで違わないようで実際微妙に違う退屈なユニットテストコードの生成、なんかにも使えます。
例としてMessagePackのカスタムシリアライザとして、以下のようなものを作りたいとします(単純な繰り返しの例としてちょうどよかったというだけなので、MessagePackの細かいAPIの部分は見なくてもいいです)。
using System;
using System.Buffers;
namespace MessagePack.Formatters
{
public sealed class ForceInt16BlockFormatter : IMessagePackFormatter<Int16>
{
public static readonly ForceInt16BlockFormatter Instance = new ForceInt16BlockFormatter();
private ForceInt16BlockFormatter()
{
}
public void Serialize(ref MessagePackWriter writer, Int16 value, MessagePackSerializerOptions options)
{
writer.WriteInt16(value);
}
public Int16 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return reader.ReadInt16();
}
}
// 以下、Int16の部分がInt32だったりDoubleだったりするのを用意したい。
}
Int16になっている部分を、Int32やDoubleなど全てのプリミティブにあてはめて作りたい、と。手書きでも気合でなんとかなりますが、そもそも面倒くさいうえに、修正の時は更に面倒くさい。なので、これはT4を使うのが最適な案件といえます。
例はVisual Studio 2019(for Windows)で説明していきますが、T4の中身自体は他のツールを使っても一緒なのと、最後にRiderでの使用方法を解説するので、安心してください。
まずは新しい項目の追加で、「テキスト テンプレート」を選びます。
すると、以下のような空のテンプレートが生成されたんじゃないかと思われます。
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>
csprojには以下のような追加のされ方をしています。
<ItemGroup>
<None Update="ForceSizePrimitiveFormatter.tt">
<Generator>TextTemplatingFileGenerator</Generator>
<LastGenOutput>ForceSizePrimitiveFormatter.txt</LastGenOutput>
</None>
<None Update="ForceSizePrimitiveFormatter.txt">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>ForceSizePrimitiveFormatter.tt</DependentUpon>
</None>
</ItemGroup>
2個のItemが追加されているのは、T4本体と、生成物の2つ分です。<Generator>TextTemplatingFileGenerator</Generator>
というのがT4をこれで処理しますよ、という話で、DependentUponはソリューションエクスプローラーでの見た目上、ネストする親を指定、ということになっています。Visual Studioの不正終了なので、たまに***1.csなどといった末尾にインクリメントされたファイルしか生成されなくなってムカつく!という状況にたまによく陥るのですが、その場合はLastGenOutputあたりを手書きで修正してけば直ります。
さて、まず思うのはシンタックスハイライトが効いていない!ということなので、しょうがないのでVS拡張を入れます。私がよく使うのはtangible T4 Editor 2.5.0 plus UML modeling toolsというやつで、インストール時にmodeling Toolsとかいういらないやつはチェック外してT4 Editorだけ入れておきましょう。なお、Visual Studioは閉じておかないとインストールできません。他のT4用拡張も幾つかあるのですが、コードフォーマッタがキモいとかキモいとか色々な問題があるので、私はこれがお気に入りです(そもそもコードフォーマットがついてない!変な整形されるぐらいなら、ないほうが百億倍マシです)。
さて、<#@ ... #>
が基本的な設定部分で、必要な何かがあればここに足していくことになります。assembly nameは参照アセンブリ、基本的なアセンブリも最小限しか参照されていないので、必要に応じて足しておきましょう。ちなみにRiderだと何も参照されていない空テンプレートが生成されるので、デフォだとLINQすら使えなくてハァァァ?となるので要注意。System.Coreぐらい入れておけよ……。
import namespaceはまんま、名前空間のusingです。output extensionは、フツーは.csを吐きたいと思うので.csにしておきましょう。場合によってはcsvとかjsonを吐きたい場合もあるかもしれないですが。
次に、<# ... #> を使って、テンプレートに使う変数群を用意します。この中ではC#コードが書けて、別に先頭じゃなくてもいいんですが、あまりテンプレート中にC#コードが散らかっても読みづらいので、用意できるものはここで全部用意しておきましょう。
<#
var types = new[]
{
typeof(Int16),
typeof(Int32),
typeof(Int64),
typeof(UInt16),
typeof(UInt32),
typeof(UInt64),
typeof(byte),
typeof(sbyte),
};
Func<Type, string> GetSuffix = t =>
{
return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
};
#>
今回は生成したいプリミティブ型を並べた配列を用意しておきます。また、ここではローカル関数を定義して、くり返し使う処理をまとめることもできるんですが(テンプレート中に式を書くと見にくくなるので、引数を受け取ってstringを返す関数を用意しておくと見やすくなります)、tangible T4 Editorがショボくてローカル関数に対してシンタックスエラー扱いしてくるので(動作はする)、Funcを使うことでお茶を濁します。
ここから先は本体ですが、短いので↑で見せた部分も含めてフルコードが以下になります。Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合に、ちゃんとファイルが生成されていることが確認できるはずです!
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
var types = new[]
{
typeof(Int16),
typeof(Int32),
typeof(Int64),
typeof(UInt16),
typeof(UInt32),
typeof(UInt64),
typeof(byte),
typeof(sbyte),
};
Func<Type, string> GetSuffix = t =>
{
return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
};
#>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY T4. DO NOT CHANGE IT. CHANGE THE .tt FILE INSTEAD.
// </auto-generated>
using System;
namespace MessagePack.Formatters
{
<# foreach(var t in types) { #>
public sealed class Force<#= t.Name #>BlockFormatter : IMessagePackFormatter<<#= t.Name #>>
{
public static readonly Force<#= t.Name #>BlockFormatter Instance = new Force<#= t.Name #>BlockFormatter();
private Force<#= t.Name #>BlockFormatter()
{
}
public void Serialize(ref MessagePackWriter writer, <#= t.Name #> value, MessagePackSerializerOptions options)
{
writer.Write<#= GetSuffix(t) #>(value);
}
public <#= t.Name #> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return reader.Read<#= t.Name #>();
}
}
<# } #>
}
記法の基本的なルールは<# ... #>で括っている部分以外は平文で出力されます。そして、<#= ... #>が式で、そこが文字列として展開されます。
なお、テンプレートを書くにあたって、長年の経験から得たおすすめのお作法があります。
- 計算式はなるべくテンプレート中で書くのを避ける、ために事前に関数として定義しておく
<auto-generated>
を冒頭に書く、これはlintによる解析対象から外れる効果があるのと、このファイルがどこから来ているのかを伝えるために有用- foreachやifなどはなるべく一文で書く、複数行に渡っているとテンプレート中のノイズになって見難くなる。また、あまり
<#
の開始行はインデントつけずに先頭で、というのもインデントつけてると平文のテンプレート生成対象になるため生成コードのインデントがズレやすい
この辺を守ると、割と綺麗に書けると思います。テンプレートはどうしてもコード埋め込みがあって汚くなりやすいので、なるべく綺麗にしておくのは大事です。それでもどうしても避けられないifだらけで、読みにくくなったりはしてしまいますが、そこはしょうがない。
実行時テキスト生成
実行時テキスト生成は、いわゆるふつーに想像するテンプレートエンジンの動作をするもので、プログラム実行時に、変数を渡したらテンプレートにあてはめてstringを返してくれるクラスを生成します。データベースのテーブル定義からマッピング用のC#コードを生成するツール、であったり、私がよくやってるのはRoslyn(C# Compiler)でC#コードを解析して、それをもとにして更にC#コードを生成するツールであったり、というかむしろC#コードを解析してKotlinコードを生成したりなど、やれることはいっぱいあります。リフレクションでアセンブリを舐めて、条件に一致した型、メソッドから何かを作る、みたいなのも全然よくありますね。
これの作り方は、追加→新しい項目から「ランタイム テキスト テンプレート」を選ぶと、悲しいことに別に普通の「テキスト テンプレート」とほぼ同じものが生成されてます(VS2019/.NET Coreプロジェクトの場合)。なぜかというと、csprojを開くとGeneratorがTextTemplatingFileGenerator、つまり普通のテキストテンプレートと同じ指定になっているからです。これは、多分バグですね、でもなんか昔から全然直されてないのでそういうもんだと思って諦めましょう。
しょーがないので手書きで直します。GeneratorのTextTemplatingFileGeneratorをTextTemplatingFilePreprocessorに変えてください。そうすると以下のようなファイルが生成されています(Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合)
#line 1 "C:\Users\neuecc\Source\Repos\ConsoleApp14\ConsoleApp14\MyCodeGenerator.tt"
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "16.0.0.0")]
public partial class MyCodeGenerator : MyCodeGeneratorBase
{
/// <summary>
/// Create the template output
/// </summary>
public virtual string TransformText()
{
return this.GenerationEnvironment.ToString();
}
}
ファイル名で作られたパーシャルクラスと、TransformTextメソッドがあるのが分かると思います。これで何となく使い方は想像つくと思いますが、new MyCodeGenerator().TransformText()
でテンプレート結果が実行時に得られる、という寸法です。
さて、しかしきっとコンパイルエラーが出ているはずです!これはNuGetで「System.CodeDom」を参照に追加することで解決されます。別にCodeDomなんて使ってなくて、CompilerErrorとCompilerErrorCollectionというExceptionを処理する生成コードが吐かれてるから、というだけなので、自分でその辺のクラスを定義しちゃってもいいんですが、まぁ面倒くさいんでCodeDom参照するのが楽ちんです。この辺ねー、昔の名残って感じでめっちゃイケてないんですがしょーがない。
それと行番号がフルパスで書かれててめっちゃ嫌、というかフルパスが書かれたのをバージョン管理に突っ込めねーよ、って感じなので、これも消しましょう。linePragmas="false"をテンプレート冒頭に書いておけば消えます。
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
この対応はほとんど必須ですね、ていうかデフォでfalseにしといてくれよ……。
さて、テンプレートにパラメーターを渡す方法ですが、これは生成されたクラス名と同じpartial classを定義すればOK。
namespace ConsoleApp14
{
// 名前空間/クラス名(ファイル名)を合わせる(親クラスの継承部分は書かなくてOK)
public partial class MyCodeGenerator
{
public GenerationContext Context { get; }
// とりあえずコンストラクタで受け取る
public MyCodeGenerator(GenerationContext context)
{
this.Context = context;
}
}
// 生成に使うパラメーターはクラス一個にまとめておいたほうが取り回しは良い
public class GenerationContext
{
public string NamespaceName { get; set; }
public string TypeSuffix { get; set; }
public int RepeatCount { get; set; }
}
}
これでテンプレート中でContextが変数として使えりょうになります。テンプレートの例として、面白みゼロのサンプルコードを出すと……(Roslynとか使うともっと意味のあるコード例になるのですが、本題と違うところがかさばってしまうので、すみません……)
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY ConsoleApp14.exe. DO NOT CHANGE IT.
// </auto-generated>
namespace <#= Context.NamespaceName #>
{
<# foreach(var i in Enumerable.Range(0, Context.RepeatCount)) { #>
public class Foo<#= Context.TypeSuffix #><#= i #>
{
}
<# } #>
}
RepeatCountの回数で中身空のクラスを作るというしょっぱい例でした。これの注意事項は、普通のテキストテンプレートと特に変わりはないのですが、auto-generatedのところに、生成ツール名を入れておいたほうがいいです。外部ツールでコード生成するという形になるため、ファイルだけ見てもなにで生成されたかわからないんですね。あとから、何かで作られたであろう何で作られたかわからない自動生成ファイルを解析する羽目になると、ツールの特定から始めなくちゃいけなくてイライラするので、この辺をヘッダにちゃんと書いといてあげましょう。ただたんに「これは自動生成されたコードです」と書いてあるだけよりも親切で良い。
記述におけるコツは、やはりテンプレート中に式を書いて文字列を生成するよりかは、この場合だとContext側にメソッドを作って、引数をもらってstringを返すようにすると見通しが良いものが作りやすいでしょう。ちなみにthis.Context
のContextのIntelliSenseは効きません、そっちの解析までしてくれないので。テンプレートファイルにおける入力補完は甘え、おまけみたいなもんなので、基本的には頼らず書きましょう。もちろん、そのせいでばんばんTypoしてエラーもらいます。これが動的型付け言語の世界だ!をC#をやりながら体感できるので、いやあ、やっぱ静的型付け言語はいいですねえ、という気持ちに浸れます。
さて、こうして出来たクラスは、ただのパラメーターを受け取ってstringを返すだけのクラスなので、何に使っても良いのですが、9割はシンプルなコマンドラインツールになるのではないでしょうか。
C#でコマンドラインツールといったら!私の作ってるMicroBatchFrameworkが当然ながらオススメなので、それを使ってツールに仕立ててみましょう。
using MicroBatchFramework;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleApp14
{
class Program : BatchBase
{
static async Task Main(string[] args)
{
await BatchHost.CreateDefaultBuilder().RunBatchEngineAsync<Program>(args);
}
public void Run(
[Option("o", "output file path")]string outputPath,
[Option("n", "namespace name")]string namespaceName,
[Option("t", "type name suffix")]string typeSuffix = "Foo",
[Option("c", "type generate count")]int repeatCount = 10
)
{
// パラメータを作って
var context = new GenerationContext
{
NamespaceName = namespaceName,
TypeSuffix = typeSuffix,
RepeatCount = repeatCount
};
// テキストを生成して
var text = new MyCodeGenerator(context).TransformText();
// UTF8(BOMなし)で出力
File.WriteAllText(outputPath, text, new UTF8Encoding(false));
Console.WriteLine("Success generate:" + outputPath);
}
}
}
これで ConsoleApp14.exe -o "foo.cs" -n "HogeHoge" -t "Bar" -c 99
というしょっぱいコマンドでfoo.csが吐かれるツールが完成しました!
RiderにおけるT4
RiderでのT4は、Visual Studioが使っている生成ツールとは違う、T4の記法として互換性のあるJet Brains独自のツールを実行している気配があります(そのため、場合によっては互換性がないところもあるかもしれません、というか実際linePragms=falseで行番号が消えなかった……)
実行自体は簡単で、ttに対して右クリックしてRunを選べばデザイン時コード生成、Preprocessを選べば実行時テキスト生成の出力結果が得られます。
現時点ではEAP、つまりプレビュー版ですが、まぁ他にもいっぱい良い機能が追加されてるっぽいので、Riderユーザーは積極的にEAPを使っていけばいいんじゃないかしら。
T4にできないこと
T4は、基本的に.ttで書かれたファイルを事前に(VSだと保存時、Riderだと任意に、あるいは設定次第でビルド時に)外部ツールが叩いて、テキスト(.csだったり色々)を生成します。いいところはC#プロジェクトと一体化して埋め込まれるので、パフォーマンスもいいし、そもそもT4生成時に型が合わなければエラーが出てくれる(動的の塊であるテンプレートファイルにとって、書き間違えは日常茶飯事なので、これは結構嬉しい)。実行効率も良いです、ファイル読んでパースして必要があればキャッシュして云々というのがないので。
が、しかし、プログラムが実行時に動的にテンプレートを読み込んでなにかする、みたいなことはできません。ツールのビルド時にテンプレートが出来上がってないといけないので、プラグイン的に足すのは無理です。それは成約になる場合もある、でしょうし、私的には型もわからないようなのを動的に合わせてテンプレート作るとか苦痛なので(←最近なんかそういうのやる機会が多くて、その度にシンドイ思いをしてる)、パラメータ渡しだけでなんとかして済むようにして諦めて作り込んだほうが百億倍マシ、ぐらいには思っていますが、まぁそういうのがしたいというシチュエーション自体は否定できません。その場合は、他のテンプレートエンジンライブラリを選んで組み込めば良いでしょう、T4がやや特殊なだけで、他のテンプレートエンジンライブラリは、むしろそういう挙動だけをサポートしているので。
まとめ
T4は十分使い物になります。微妙にメンテされてるのかされてないのか不安なところもありますが、そもそもMicrosoftもバリバリ使っているので(GitHubに公開されているcorefxのコードとかはT4で生成されているものもかなりあります、最近追加されたようなコードでも)、全く問題ないでしょう。実際、記法も必要十分揃っているし、特に極端に見にくいということもないと思います、ていうかテンプレートエンジンとしてフツーなシンタックスですしね。
仮に複雑なことやりたければ、例えばテンプレートをパーツ化して使い回すために分割して、都度インポートとか、というのもできます(T4 インクルード ディレクティブでは.t4という拡張子が紹介されていますが(拡張子はなんでもいい)、一般的には.ttincludeという拡張子が使われています)。他いろいろな機能がありますが、あらためて、テキストテンプレートごときで複雑なことやられると、追いかける気が失せるので、なるべく単純に保ちましょう。やるとしてもせいぜいincludeまで。それ以上はやらない。
というわけで、どうでしょう。MicroBatchFrameworkとも合わせて、C#のこの辺の環境は割といいほうだと思ってます。コード生成覚えるとやれることも広がるので、ぜひぜひ、コード生成生活を楽しんでください!
また、Unityでも普通に便利に使えると思います。Editor拡張でソースコード生成するものを用意するパターンは多いと思いますが(なにかリソースを読み込んで生成したり、あるいはそのままAssembly.GetExecutingAssembly().GetTypes()して型から生成したり)、その時のテンプレートとして、普通にstringの連結で作ってるケースも少なくなさそうですが、「デザイン時コード生成」も「実行時テキスト生成」も、どっちも全然いけます。「T4にできないこと」セクションに書いたとおり、よくも悪くも外部ツールで実行環境への依存がないので。
Unite Tokyo 2019でC# Structの進化の話をしてきました
- 2019-09-30
Unite Tokyo 2019にて、「Understanding C# Struct All Things」と題して登壇してきました!動画は後日Unity Learning Materialsに公開される予定です。
C#成分強めなので、Unityに馴染みのない人でも読んで楽しめる内容になっていると思います。とはいえ勿論、Uniteでのセッションであるということに納得してもらえるようUnity成分もきちんと取り入れています。というわけで、どちらの属性の人にも楽しんでいただければ!
structに関する機能強化、実際めっちゃ多いんですが、それをカタログ的に延々と紹介してもつまらないので、そうしたカタログ紹介っぽさが出ないように気を配ってます。あと、あんましそういうのでは脳みそに入ってこないというのもあるので。
応用例的なところのものは、ないとつまらないよねーということで色々持ってきたのですが、もう少し説明厚くしても良かったかなー感はありました。セッション内でも雰囲気で流した感じありますしね。とはいえ尺とか尺とか。まぁ雰囲気を分かってもらえれば(structは色々遊べるよ、という)いい、と割り切った面もあるにはあります。詳しくは資料を熟読してください!
Span/NativeArrayの説明も厚くしたくはあったんですが、structそのものの本題からは若干外れるので見送り。あと尺とか尺とか。
今年のUnite、もの凄くいいイベントでした。神運営とはこのことか……。そしてDOTSが熱い。めっちゃやるやる詐欺なので、いい加減本当にそろそろDOTSに手を出して楽しみたいですます!
あと、MessagePack-CSharp v2はいい加減そろそろ出るはず予定です、ちなみにSystem.Memoryとかにめっちゃ依存しているので、MessagePack-CSharpを入れるとSpanとかSystem.Runtime.CompilerServices.Unsafeとかが解禁されます(依存ライブラリとして同梱する予定なので)。いいのかわるいのか。まあ、いいでしょふ。未来未来。
CEDEC 2019にてMagicOnion、或いは他言語とC#の協調について話しました
- 2019-09-09
セッション名はUnity C# × gRPC × サーバーサイドKotlinによる次世代のサーバー/クライアント通信 〜ハイパフォーマンスな通信基盤の開発とMagicOnionによるリアルタイム通信の実現〜(長い!)ということで二部構成になっていて、私は後半部分を担当しました。
Cysharpは他社さんのお仕事も(ボリューム次第で、今はちょっとしたコンサルティングぐらいしか空きがないんですが)受けたりも可能です、ということでアプリボットさんのお手伝いをちょいちょいしています。リアルなMagicOnionの採用の話として、どんな風にやってるんですかねーというところの一環をエモ成分強めで語ってみました。リリースどころかタイトルもまだ未発表なので技術的な部分が弱めなので、次はリアルな実例として色々詰めたいところですね!
前半部、というかがっちゃんこされている資料はこちらで公開されています。前半でgRPCいいぞ!という話をしているのに、こちらは冒頭でprotoは嫌だお!という展開で繋げるアレゲさでしたが、まあジョークの一環です。多分。はい。protoのいいところは中間形式であり言語agnosticなところで、protoのよくないところは中間形式であること、ですね。これが何を言っているかを理解できれば100点満点です!是非の議論は、このことを理解してから進めましょう。
MagicOnionは、ちょうど今日Ver 2.4.0をリリースしまして、やる気満々です。次の展開もいろいろ考えているので、というか積みタスクがんがんがんが。まぁ、順次やってきます。
さて、9月はもう一つ、「Understanding C# Struct All Things」と第してUnite Tokyo 2019でセッションします。 Day 2のRoom A、13:30からでライブ配信もあるので、そちらも見ていただければ!
TaskとValueTaskの使い分け、或いはValueTaskSupplementによる福音
- 2019-08-26
ValueTaskSupplementというライブラリを新しく作って公開しました!
これは、ValueTaskにWhenAny, WhenAll, Lazyを追加するという代物で、それだけだとヘーそーなんだー、としか思えないと思われます。しかし、ValueTaskを使っていくと、めっちゃくちゃ欲しくなる機能になってます。ないと死ぬレベルで。
と、いうわけで、なんでこれが必要なのか、っていうところから説明します。
TaskとValueTask
C# 5.0にasync/awaitが導入された当初はTaskしか存在しませんでした。標準APIのあらゆるメソッドにasyncメソッドを生やすなど、Microsoftの多大な努力により、C#はいち早く非同期時代を迎え、async/awaitは多用(濫用とも言う)されるようになりました。しかし、多用された結果、当初思ってたよりもTaskのオーバーヘッド多くね?同期をラップするだけのシチュエーションも少なくなくね?ということに気付き、C# 7.0から登場したのがValueTaskです。
登場当時のValueTaskは T | Task[T]
という、もし中身が同期の場合はTを、非同期の場合はTaskをラップしたものとして存在しました。なので、TaskとValueTaskの使い分けは、中身が非同期の場合が確定している場合はラップが不要で、かつ(当時)スタンダードな定義に沿うTaskを基本に考えていくのが良いでしょう。と、されていました。
が、しかし、実際にアプリケーションを作っていくと、都度使い分けなんて考えられるものじゃないし、ValueTaskのオーバーヘッドといってもstructでラップするだけの話でそこまで大きいわけじゃない(同期のものをTaskで定義したほうがよほど大きい)ので、普通にアプリケーションで定義する場合のルールは全部ValueTaskでいーんじゃね?と思っていたりは、私の個人的な見解どまりでありました。
そして、更にパフォーマンスを追求する中で、ValueTask->Task変換のオーバーヘッドをなくし、中身をそれぞれに特化したコードを挟み込めるように IValueTaskSource というものが導入されました。これによりValueTaskは T | Task[T] | IValueTaskSource
のどれかの状態を持つという共用体となり、個別に実装されたシナリオでは中身がTask[T]の場合よりもIValueTaskSourceの場合のほうがパフォーマンスが高いということで、名実ともにValueTaskの天下の時代が始まりました。
大々的にパブリックAPIにも露出してくるのは.NET Core 3以降だと思われますが、今でも問題なく使える状態&KestrelやSystem.IO.PipelinesにはValueTaskによるAPIが既に露出しています。MagicOnionのフィルターもValueTaskだったりしたりしましたり。
なお、別の世界線ではUniTaskというものも存在しますが、これは ValueTask + IValueTaskSource に近い代物です。つまり別にTaskなんていらなかったんや……。
ValueTaskの欠点
そんなValueTask最大の欠点は、ユーティリティの欠如。つまり、WhenAllやWhenAnyができない。それらが必要な際はAsTaskでTaskに変換する必要がありました。が、Taskに変換する時点でオーバーヘッドじゃーん。しかもいちいちAsTaskするのはクソ面倒くさい!せっかく IValueTaskSource があるなら、IValueTaskSourceを使ってネイティブなValueTask用のWhenAllやWhenAnyを作ればハイパフォーマンスじゃん!というわけで、それらを提供するのがValueTaskSupplementです。
using ValueTaskSupplement; // namespace
async ValueTask Demo()
{
// `ValueTaskEx`が使う唯一の型です
// こんな風な別々の型のValueTaskがあったとしても
ValueTask<int> task1 = LoadAsyncA();
ValueTask<string> task2 = LoadAsyncB();
ValueTask<bool> task3 = LoadAsyncC();
// awaitできて、タプル記法でサクッと分解できて便利!
var (a, b, c) = await ValueTaskEx.WhenAll(task1, task2, task3);
// WhenAnyでは int winIndexでどれが最初に値を返したか判定できます
var (winIndex, a, b, c) = await ValueTaskEx.WhenAny(task1, task2, task2);
// Timeoutみたいなものの実装はこんな風に
var (hasLeftResult, value) = await ValueTaskEx.WhenAny(task1, Task.Delay(TimeSpan.FromSeconds(1)));
if (!hasLeftResult) throw new TimeoutException();
// Lazyも用意されています!
// awaitを呼ぶまで遅延&値がキャッシュされるAsyncLazyのような代物ですが
// 型がValueTask<T>そのものなので、フィールドに保持したまま、WhenAllなどがそのまま書けて便利
ValueTask<int> asyncLazy = ValueTaskEx.Lazy(async () => 9999);
}
と、いったように、ただのTask.Xxxよりも更に便利になった機能が追加されていて、もう全部ValueTaskで統一でいいっしょ、って気になれます(特に var (a, b, c) = await ....)が便利ですよ!
まとめ
時代はValueTask。Taskのことは忘れて全部ValueTaskで良いのですー、良いのですー。そして、ValueTaskで統一したら、すぐに標準のまんまじゃしんどいのですー、ってことに気づくでしょふ。そこでValueTaskSupplementですよ、っという流れです。絶対そうなります。というわけで諦めて(?)使いましょう。
ところで、最近よくCygames Engineers' Blogに寄稿しているのですが、なんとなくの私の中の使い分けは、Unityに関する成分が含まれる(新規)ライブラリはCygamesのブログのほうに、そうじゃないものはここに、みたいな気持ちではいます。まぁ、どっちも見ていただければればですです。
また、直近イベントでは9月4日にCEDEC 2019で「Unity C# × gRPC × サーバーサイドKotlinによる次世代のサーバー/クライアント通信 〜ハイパフォーマンスな通信基盤の開発とMagicOnionによるリアルタイム通信の実現〜」、9月26日にUnite Tokyo 2019で「Understanding C# Struct All Things」というセッションを行うので、是非是非見に来てください!
Microsoft MVP for Developer Technologies(C#)を再々々々々々々々受賞しました
- 2019-07-02
しました。カテゴリ名が毎回ちょくちょく変わってるんですが、今の状態はDeveloper Technologiesだそうです。つまりC#です。一年ごとに再審査があって、今回も通りました。
去年はCysharpの設立もあり、よりC#に対して直接的に世の中に作用させていくよ!という意思を示しました。まだ活動は始まったばかりですが、ともあれC#を引っ張っていきたいという心持ちがあります。黙ってても別にC#は絶対死にはしないと思ってるんですが、もう少し表に立って光り輝いて欲しいよね、そこが足りないと思うんで、その辺をうまく補完できればなというところです。
去年の宣言は
去年よりも上を、去年よりも上を、とハードルは無限に高くなっていくので、個人にせよ会社にせよ、世界にインパクトを残していける何かをやっていこう、というのが目標ですね。
MagicOnionのリブートは比較的成功していると思いますし、MessagePack for C#は、より強力なライブラリになるよう画策中です。UniTaskは唯一無二でしょう。ワールドスタンダードをここから作っていくんだという気概でやっていますし、ある程度はやれているんじゃあないかしらん。ともあれ、まだ全然足りないので、より気合い入れてやっていこうかと。
引き続き主戦場はC#とUnityです。今はちょうど時代の変わり目で、多くの会社で、旧来のフレームワークから新しいフレームワークに移し替えるという話をよく聞きます。そこで.NET Coreですよ?と差し込める最後のチャンス(テクノロジースタックが固定されたら、また次の5年は入れ替えないですからねえ)、というぐらいに思ってるので(黙ってるとGoになっちゃいますしね!)、C#, .NET Core, それとUnityが価値あるものですよ、というところを多くの人に実感してもらえるように、やっていきます。そして困ったことがあればCysharpのお問い合わせフォームに投げてもらえればチャリンチャリンでウィンウィン。
MVPもメンツがだいぶ入れ替わってる感じで、それは非常に良いと思ってます。新陳代謝大事。そんな中で、私は古い方の人間に入るわけなので、居座ってる勢はただ単なる昔の成果の惰性で受賞続いているとかではなく、常に新しい成果でねじ伏せれるべきなんじゃあないでしょーか。私はしっかりやってると思ってましてよ。
そんなわけで引き続き、今年もよろしくお願いします。
C#のOpenTelemetry事情とCollectorをMagicOnionに実装した話
- 2019-06-28
を、してきました。昨日。OpenCensus/OpenTelemetry meetup vol.2にて。
もともとトレースとかメトリクスの標準化として、OpenCensus(Google)陣営とOpenTracing(CNCF)陣営がいて、正直どっちも流行ってる気配を感じなかったのですが、合流してOpenTelemetryが爆誕!これは本当に今年の頭に発表されたばかりで、仕様策定が完了するのが今年9月(予定)といった感じで、まだ練ってる最中というところです。ただ全体的にはOpenCensusベースでSDKが組まれているので、OpenCensusの時点で完成度がある程度高かったSDKは、割とそのままでも使えそうな雰囲気はあります。
個人的な意義とかはスライドにも書きましたが、まあ、流行って欲しいですね。概念はめっちゃ良いと思うので、きっちり浸透して使われるようになって欲しい。ぎっはぶスターだけが尺度ではないとはいえ、リファレンス実装のJava版の125が最大スター数とか、ワールドワイドで注目度弱すぎないか!?みたいな気は、あります。大丈夫かな。大丈夫ですよね……。一応、参画企業は名だたるところも多いし仕様分裂してるわけでもないので、乗っかる価値はあるんじゃないかと、思います。
.NET SDKの事情
opentelemetry-dotnetで、メインに開発してるのはMicrosoftの人間が一人やってますね。GitHubの草で見ても毎日張り付いてopentelemetry系のなにかでなにかやってるので、仕事として専任で頑張ってるんじゃないでしょうか、多分。クオリティ的には高くもなく低くもなくというところで、過度に期待しなきゃあそんなもんでしょーということで受け入れられそうです。もともとJavaがリファレンス実装になってて、他の言語は、まず基本的なAPIはそれを踏襲すること、というところもあるので、あまりブーブー言ってもしょうがないかもしれません。
Collectorの実装がちょっと面白くて、AspNetCoreのCollectorやHttpClientのCollectorは、 DiagnosticSourceという比較的新しい仕組みから情報を取るようになっています。これによって、プロファイラAPIによるAuto Instrumentによるパフォーマンス低下などもなく、しかし特にユーザーはなにもせずに、メトリクスが取得できるようになっています。
ADO.NETのCollectorがないのでDB系が取れないんですが、多分まだADO.NETがDiagnosticSourceに対応していないので、それが対応するまではやらないみたいなつもりなんだと思います。さすがにADO.NETのCollectorがないと話にならないでしょー。
まとめ
MagicOnionの事情としては実装のPRは用意してますが、まだMergeしてません。ただまぁ、スライドに書いたのですが、結構これらを入れるだけで、撮って出しでもいい感じのダッシュボードが作れるんじゃないかと思います。このへんは前職でリリースしたゲームのダッシュボードを作り込んだ経験が生きてるかな、ってのはありますね。いやほんと。
ともあれというわけで、私はOpenTelemetryにベットするんで、みんなも是非やっていきましょう!ね!流行らせないと未来はない!