2020年を振り返る
- 2020-12-30
今年は前半が絶好調で、ConsoleAppFramework、ProcessX、ZString、ZLogger、そしてUniTask v2と、凄い勢いでプログラミング的なクリエイティビティを発揮できていました。なので今年トータルとしてみれば良かったと言えます。
が、後半が絶不調で無。とにかく無。なんでこんな無になったのか分からないほどに無。コロナか、コロナが悪いんか。それも実際あるんですが、いや、というかそれが全てかなー。リモートワーク向いてないんすよー、みたいな。リモート前半で魂の貯金を使い切った。無が加速してからヤバいと思って自主リモート返納(自分だけオフィスワーク)に戻したんですが、それでもなんか違うんですよねえ。まぁ、言い訳なんですけどね!新環境に適応できない旧世代民には死あるのみ、って感じなので、来年は脳みそ入れ替えてやってきたいと思います。
この12月は、書く予定だったアドベントカレンダーも書けずにフィニッシュと最悪な感じですからねえ、終わりが全くしまらなかった結果、今年の印象としてはあんま良くない。でも客観的に一年通しで見たら、中々の成果を上げたとは言えます。
OSSがかなり出揃ったことで、Cysharpという会社の輪郭をはっきりさせられた年になりました。対外的には何やってる会社か分からない、まぁ実際そこは今もよくわからないと思うんですが、それでもC#の最先端を突っ走っている会社だというイメージは確固たるものになったのではないでしょうか。去年ではまだまだ足りてないと考えていたのですが、今年追加したOSS群によって、一つポジションを引き上げられたと思っています。
MagicOnionもv4になって .NET 5/Pure C# gRPC 対応を果たしましたし、今年は実際に採用しているアプリケーションがリリースされていったことで、よりCysharpの目指しているヴィジョンの現実感が出てきました。来年はそのヴィジョンをより鮮明にしていくことと、もうプラスαに仕込んでいるものがあるので、その辺の露出がうまくできるといいかなーと思ってます。
私個人の能力の成長という点でも、UniTask v2を始めとしてパワーある実装をやりきったことと、そこから深く学んだこともいっぱいあるので、まだまだ行けるぞという感じです。ちゃんとね、毎年成長してますよ。はい。人間、停滞=衰退ですから。
私は出したもののウケ度に割と拘るところがあるんですが、これは自分の感覚と市場の感覚が乖離していないかを測っているという面もあります。今日が誕生日でもうN回この振り返りも書いてるわけですが、そろそろ油断すると感性が腐る頃合いなんですよね。なんかピンとのズレたことを言い始めてしまうという。端的に言えばそれが老害というわけなんですが、自分も油断するとなりかねない。という危機感がそぞろ出てくるような頃合いでして。しかもね、そういうのは自覚がないわけですよ、本人は自覚がない!本人はイケてると思っているのが余計辛い!自覚がないからこそ老害なのだ。みたいなところがある。
と、いうわけで、客観的な指標が必要で、とりあえず今年はOKじゃないですかね。はい。
その他文化
今年のGame of the YearはDOOM Eternalですよね……!震えるほど面白いゲームって本当に数年単位で久々で、腐った感性を復活させてくれた神の救いですよ。というわけでマストバイ。(しかし超期待したDLCは微妙だった……)
今年のベストアルバムは中村佳穂のAINOUです。中村佳穂『AINOU』はなぜ2018年を代表する名盤なのか?とかって記事出てるように全然今年のアルバムじゃないんですが、聴いたのは今年だからshoganai。名盤。
読み物としては、ちょくちょく月刊専門料理を買ってて、これが面白いんですよね。料理とエンジニアリングは共通するものがあるとCooking for Geeksをはじめとしてよく言われるやつですが、それプラス経営的な話とかも中々身に沁みるものがあって良いわけです。あと、料理業界はまだまだ多分アナログなんですよね、だから紙の雑誌にも相応の密度がある。その点エンジニアの場合はウェブ媒体のほうが紙より良い状態なので、雑誌が面白くないんですよね(Web+DBとかもはやつまらんでしょ)。良くも悪くもですが、まぁもう進んでしまった業界は紙の媒体が面白くなることはないのでしょう。
来年
アドベントカレンダーネタは書いてないしGitHub Issuesもかなり手を付けてないのが残っちゃったしで、あんまりスッキリして来年を迎えられないんですが……!そのへんはなるはやですっきりさせたいとして、今年はCysharpの仕込みフェーズがとてもうまくいった。実際うまくいった。そして仕込みフェーズは終了。つまり来年はどーんといきましょう。というわけで、ぜひぜひ大躍進にご期待くださいな。
UnitGenerator - C# 9.0 SourceGeneratorによるValueObjectパターンの自動実装とSourceGenerator実装Tips
- 2020-12-15
ValueObjectは好きですか?私は大嫌いです。いじょ。
ざっくり言えばプリミティブ型に専用の型を付ける教義です。例えばUserIdをintとして扱っているとTeamIdと取り違えるかもしれないし、Hpに突っ込んでしまうかもしれない。StrengthとIntelligenceとAgilityとSpeedは別物なのだから全部intじゃなくて区別して欲しい、そうじゃないと間違った演算しちゃうぞ、と。まぁそういう自体を避けるために、それぞれラップした個別型を作るのです。int strengthじゃなくてStrength strengthだぞ、と。
これは一見正しく実際正しいのですが、問題もあります。一つに面倒くさい。ラップしたctorを作るのだけでも定形でウザ、と思いますが、更に等値とか実装するのは面倒くさい。また、そのままだと計算できなくなるので、算術演算のために生の値を.Value
で取り出す、が頻出すると安全度も下がるし見た目もめっちゃ汚くなる、当然ながらものすごく書きづらい。そしてシリアライゼーションの問題。Serialize(userId)としたときに「{ "Value" = 100 }」なんて形にシリアライズされたら最低で、全く許容できない。また、データベースで扱うときにもORMはそのままだとプリミティブしか扱えないので、マッピングできなくて不便なことになります。
といった問題があるため、基本的には大嫌いなのでそういうのやらない、プリミティブで何が悪いんだボケ。ぐらいの勢いでした。実際、社内でそうしたい、という話があった場合にはトップダウン権限で却下してたぐらいです(横暴!)。のですが、上記の問題が解決するのならば、全然許せます。むしろ良い。むしろすべき。かもしれません。
そこで C# 9.0 から新搭載されたSourceGeneratorの出番です。SourceGeneratorを活用したUnitGeneratorというライブラリを新しく作りました。今回はその内容の解説と、SourceGeneratorを実装する上でのTipsを紹介します。また、この記事は C# その2 Advent Calendar 2020 15日用です。19日にもC# Advent Calendar 2020でSourceGeneratorネタを書く予定なので、まずはPart 1ということで合わせてお楽しみください。
ちなみにC# Advent Calendar 2020の初日の記事 C# 9.0で加わったC# Source Generatorと、それで作ったValueObjectGeneratorの紹介 と内容的には非常に似通ってるんですが、そこはshoganai。またC#9.0 SourceGeneratorでReadonly構造体を生成するGeneratorを作ってみました。とも被ってますね、しょーがしょーがない。
SourceGeneratorの特性
GitHubとNuGetにUnitGenerator
として公開しました(この記事でも後で触れますが、ReadMe末尾にはUnityでの使い方も載せてあります)。
使い方は、public readonly partial struct
に対して、[UnitOf(typeof(T))]
を書くだけです。
using UnitGenerator;
[UnitOf(typeof(int))]
public readonly partial struct UserId { }
これを書くと、SourceGeneratorが裏側で以下のpartial classをコンパイル時(ビルド前)に生成します。
[System.ComponentModel.TypeConverter(typeof(UserIdTypeConverter))]
public readonly partial struct UserId : IEquatable<UserId>
{
readonly int value;
public UserId(int value)
{
this.value = value;
}
public readonly int AsPrimitive() => value;
public static explicit operator int(UserId value) => value.value;
public static explicit operator UserId(int value) => new UserId(value);
public bool Equals(UserId other) => value.Equals(other.value);
public override bool Equals(object? obj) => // snip...
public override int GetHashCode() => value.GetHashCode();
public override string ToString() => "UserId(" + value + ")";
public static bool operator ==(in UserId x, in UserId y) => x.value.Equals(y.value);
public static bool operator !=(in UserId x, in UserId y) => !x.value.Equals(y.value);
private class UserIdTypeConverter : System.ComponentModel.TypeConverter
{
// snip...
}
}
SourceGeneratorのいいところは、生成コードがC#コンパイラのメモリ内で完結していることです。つまり、ファイルが出てきません。ファイルが出てこないのは非常にいいことで、自動生成ファイルが減った時の管理をしなくてすみます。ディレクトリごと毎回Cleanするのもイマイチですし、かといって古いファイルが残り続けるのはマズいので、そこの管理をどうするか問題は毎度面倒くさいことです。
欠点はメモリ内で完結していることです。ソースが見えないとデバッガビリティも下がりますし、コンパイルしないと追加されたコードが使えないというのもコード書いてる最中の手触り的に面倒。というのが一般的な話なのですが、そこを言語組み込みの機能として用意したことでカバーしているのがSourceGeneratorの良いところです。まず、デバッガビリティに関してはIDE(Visual Studioなど)でコードジャンプできるようになっているし、デバッガのステップ実行もフルサポート。また、IDEのインクリメンタルコンパイルとフルに連動しているため、属性を書いた瞬間から、裏ではそこの部分だけコンパイルが走ってコードが生成されて、生成コードが利用可能になっています。これは今までのビルド時プリプロセッサー/ポストプロセッサーではできなかった体験で、中々小気味良いものです。
唯一の欠点は既存コードをEditできないので、partialであることが必須になることと、編集を要求する内容は作れないことでしょうか。まぁ、それは従来あったAnalyzer(CodeFixProvider)でやればいいということで、それなりに棲み分けもできてますし、ソースコードの追加しかできないという仕様のお陰で、作成に関してはかなりシンプルになったこともいいことです。
UnitGenerateOptions
値の等値性だけを実装するのはままあるのですが、それだけだと不便なんですよね。例えばHpは + 100 とかそのまま演算したいじゃん、と。その辺のサポートがないとすぐに.Valueで生の値を取り出すことになって
よくないし、MinやMaxなんかもそのまんま使いたい、例えばHpを現在値の2倍で回復する、みたいなのは target.Hp = Hp.Min(target.Hp * 2, target.MaxHp)
と書けたるとかなり自然でいいよね、と。
その辺の生成をサポートするのが UnitGenerateOptions で、これを組み合わせることによって、算術演算子など好きなメソッドが追加されます。UserIdのようなものは算術演算子が生成されては困るので抑制したいし、Hpはフルで生成したい、みたいな使い分けができます。
[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Hp { }
// -- generates
[System.ComponentModel.TypeConverter(typeof(HpTypeConverter))]
public readonly partial struct Hp : IEquatable<Hp> , IComparable<Hp>
{
readonly int value;
public Hp(int value)
{
this.value = value;
}
public readonly int AsPrimitive() => value;
public static explicit operator int(Hp value) => value.value;
public static explicit operator Hp(int value) => new Hp(value);
public bool Equals(Hp other) => value.Equals(other.value);
public override bool Equals(object? obj) => // snip...
public override int GetHashCode() => value.GetHashCode();
public override string ToString() => "Hp(" + value + ")";
public static bool operator ==(in Hp x, in Hp y) => x.value.Equals(y.value);
public static bool operator !=(in Hp x, in Hp y) => !x.value.Equals(y.value);
private class HpTypeConverter : System.ComponentModel.TypeConverter { /* snip... */ }
// UnitGenerateOptions.ArithmeticOperator
public static Hp operator +(in Hp x, in Hp y) => new Hp(checked((int)(x.value + y.value)));
public static Hp operator -(in Hp x, in Hp y) => new Hp(checked((int)(x.value - y.value)));
public static Hp operator *(in Hp x, in Hp y) => new Hp(checked((int)(x.value * y.value)));
public static Hp operator /(in Hp x, in Hp y) => new Hp(checked((int)(x.value / y.value)));
// UnitGenerateOptions.ValueArithmeticOperator
public static Hp operator ++(in Hp x) => new Hp(checked((int)(x.value + 1)));
public static Hp operator --(in Hp x) => new Hp(checked((int)(x.value - 1)));
public static Hp operator +(in Hp x, in int y) => new Hp(checked((int)(x.value + y)));
public static Hp operator -(in Hp x, in int y) => new Hp(checked((int)(x.value - y)));
public static Hp operator *(in Hp x, in int y) => new Hp(checked((int)(x.value * y)));
public static Hp operator /(in Hp x, in int y) => new Hp(checked((int)(x.value / y)));
// UnitGenerateOptions.Comparable
public int CompareTo(Hp other) => value.CompareTo(other);
public static bool operator >(in Hp x, in Hp y) => x.value > y.value;
public static bool operator <(in Hp x, in Hp y) => x.value < y.value;
public static bool operator >=(in Hp x, in Hp y) => x.value >= y.value;
public static bool operator <=(in Hp x, in Hp y) => x.value <= y.value;
// UnitGenerateOptions.MinMaxMethod
public static Hp Min(Hp x, Hp y) => new Hp(Math.Min(x.value, y.value));
public static Hp Max(Hp x, Hp y) => new Hp(Math.Max(x.value, y.value));
}
この辺のメソッドがしっかり生成されることによって、プリミティブ型をそのまま使うのと遜色のない使用感が担保できるわけです。
if (character.Hp <= 0) // Hp.GetType == typeof(Hp)
{
// is dead.
}
みたいに書けるようになってとても嬉しい。
また、演算子のオーバーロードはしっかり考慮して作るのが地味に大変な代物なので、そこをちゃんとやってくれるのも助かりです。例えばboolの場合はtrue演算子を自動実装します。
public static bool operator true(Foo x) => x.value;
public static bool operator false(Foo x) => !x.value;
public static bool operator !(Foo x) => !x.value;
こんなの自分で実装する機会なんてほとんどないと思いますが、これによってifに直接突っ込めるようになります。
if (foo) // foo.GetType() == typeof(Foo)
{
}
UnitGenerateOptionsは現在のところ以下のオプションを提供しています。
[Flags]
internal enum UnitGenerateOptions
{
None = 0,
ImplicitOperator = 1,
ParseMethod = 2,
MinMaxMethod = 4,
ArithmeticOperator = 8,
ValueArithmeticOperator = 16,
Comparable = 32,
Validate = 64,
JsonConverter = 128,
MessagePackFormatter = 256,
DapperTypeHandler = 512,
EntityFrameworkValueConverter = 1024,
}
例えば以下のように指定できます。
[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Strength { }
[UnitOf(typeof(DateTime), UnitGenerateOptions.ParseMethod | UnitGenerateOptions.Comparable)]
public readonly partial struct EndDate { }
[UnitOf(typeof(string), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct Message { }
[UnitOf(typeof(byte[]))]
public readonly partial struct Image { }
[UnitOf(typeof((string street, string city)), UnitGenerateOptions.Validate)]
public readonly partial struct StreetAddress
{
private partial void Validate()
{
if (!DataMaster.Contains(value.street)) throw new Exception("Invalid Street: " + value.street);
if (!DataMaster.Contains(value.city)) throw new Exception("Invalid City: " + value.city);
}
}
Validateだけ少し特殊で、自動生成側のコードがpartial void Validate()
メソッドを生成して、自動生成されるコンストラクタでそれを呼ぶようになっています。Validateの実体をユーザー側が書けばOKということですね。プリミティブ型と違って、値が検証済みであることが保証されている、というのも一般的なプラクティスとしては重要な話です。(ただしstructのため、default(T)は防げないので、そういう意味では完全なValidationではありません)
シリアライザの自動実装
繰り返しますが 「{ "Value" = 100 }」みたいにシリアライズされるのは最低です。「100」とシリアライズされなければならない。と、いうわけで、そういう場合は専用のシリアライザを実装すれば回避できます。現状はSystem.Text.JsonのJsonConverterとMessagePack用のMessagePackFormatterを自動実装するオプションが用意されています。こういうのをちまちま用意するのは、私がシリアライザについて人一倍拘りがあるからで、普通はあんまないでしょうね。でもシリアライザはシステムにおいて本当に大事なことだから!
例えば UnitGenerateOptions.MessagePackFormatter
は以下のようなコードを自動実装します。
[UnitOf(typeof(int), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct UserId { }
// -- generates
[MessagePackFormatter(typeof(UserIdMessagePackFormatter))]
public readonly partial struct UserId
{
class UserIdMessagePackFormatter : IMessagePackFormatter<UserId>
{
public void Serialize(ref MessagePackWriter writer, UserId value, MessagePackSerializerOptions options)
{
options.Resolver.GetFormatterWithVerify<int>().Serialize(ref writer, value.value, options);
}
public UserId Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return new UserId(options.Resolver.GetFormatterWithVerify<int>().Deserialize(ref reader, options));
}
}
}
private classでFormatterが実装されるのがポイントで、Attributeからそのフォーマッターを取り出すことで、外部のResolverへの登録をせずに専用の対応をしています。Serialize/DeserializeはResolver経由じゃなくて直接Writer/Readerのプリミティブ型を呼ぶことで高速化できますが、まぁそれは次の機会に。このコードを発展化させた、MessagePack for C#におけるSourceGenerator対応については12/19の記事で詳しく触れる予定です。
データベースに関しても UnitGenerateOptions.DapperTypeHandler, UnitGenerateOptions.EntityFrameworkValueConverter でDapperとEF Coreの対応コードを生成します。ただしこちらは自動利用のシステムがないので、手動で取り出して登録する必要があります。
.Value is dead
UnitGeneratorはpublicプロパティを一つも生成しません。つまり、.Valueはありません。私は.Valueによる値の取り出しが悪いプラクティスだと思っていて、カジュアルに使おうという気持ちを起こさないようにしています。演算子の生成なども用意してあるし、あとは専用のメソッドを自前で書いたりしていくなどで解決できるといいよね、と。
とはいえさすがに取り出せないのは不便というか実用的ではないので、.AsPrimitive()
で取れます。プロパティではなくメソッドというだけで、心理的に少し抵抗感出るんじゃないでしょうか?制約なんてそのぐらいでいいでしょう。あんまりキツくやるのも好きではないので。
Unityで使う
Source Generatorは C# 9.0 の機能です。というわけで、2020年現在のUnityはどのバージョンもそれをサポートしていません。じゃあ使えないじゃんって話なのですが、幸いファイルとして生成する機能も用意されているので、外部コマンドを実行したら自動生成する、ぐらいの雰囲気でならUnityでも使うことができます。
まずはコンフィグとなるcsprojを用意します。例えばUnitSourceGen.csprojとして、以下のような内容のものを作ります。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<!-- add this two lines and configure output path -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(ProjectDir)..\Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- reference UnitGenerator -->
<PackageReference Include="UnitGenerator" Version="1.0.0" />
<!-- add target sources path from Unity -->
<Compile Include="..\MyUnity\Assets\Scripts\Models\**\*.cs" />
</ItemGroup>
</Project>
あとは .NET SDKを入れて、コマンドを叩きましょう。
dotnet build UnitSourceGen.csproj
これで UnitGenerator\UnitGenerator.SourceGenerator*.Generated.cs がOutputPathに指定したところに生成されています。UnitGeneratorは、UnitOfAttributeやUnitGenerateOptionsも自動生成コードの中に含まれる仕様(ランタイムレス)なので、一回空の状態で実行すれば、それらのコードが生成されて利用可能になります。
SourceGenerator実装の方法
netstandard2.0のライブラリプロジェクトとして(いまのところnet5.0だとうまくいかない、これはVisual Studioが .NET Frameworkで動いているせいだから、らしい)Microsoft.CodeAnalysis.CSharpを参照します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
また、合わせてテスト用のプロジェクトを用意して、ライブラリプロジェクトを参照するようにしておくといいでしょう。プロジェクト参照を、OutputItemType="Analyzer"にしておきます。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\UnitGenerator\UnitGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
あとはISourceGeneratorを実装するだけ。
[Generator]
public class SourceGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
// System.Diagnostics.Debugger.Launch();
}
#endif
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
}
// 実装しなくてもいいけど、この段階で対象になるファイルを引っ掛けておくとワンパスで処理できる
class SyntaxReceiver : ISyntaxReceiver
{
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
}
}
}
System.Diagnostics.Debugger.Launch() を入れておくと、デバッガでアタッチできて実装が捗ります。ただしVisual Studioがインクリメンタルコンパイル的にかなりの頻度でキックしてくるので、不要なときはコメントアウトしておくのが吉。また、SourceGeneratorの実装コードの変更にたいしてVisual Studioのキャッシュがうまく追随してくれなくて、実装中は挙動が腐ることがよくあるので、困ったときの再起動でやり過ごしましょう。
RegisterForSyntaxNotificationsは使っても使わなくてもどちらでもいいのですが(ExecuteのところでSyntaxTreeの全てが手に入るので探索し放題)、ここで大雑把でも引っ掛けておいたほうが、その後の処理が軽量になるので、使ったほうが基本的にはヨシ。
SourceGeneratorでユーザーが使う属性は、参照DLL内に含めておいてそれを使う場合と、参照DLLは完全に空にして、ソースジェネレーター自身が生成するパターンがあります。後者のパターンを使うと、ソースジェネレーターのためだけに参照DLLが増えることを避けれるので、今回のUnitGeneratorのような、生成コードが全ての処理を行うタイプのものは、そちらのパターンを使ったほうが良いでしょう。
やりかたは単純に最初に必要な属性を突っ込んでしまうという、ただそれだけなのですが一点注意なのは、この生成は絶対死守しましょう。Execute内で例外が発生したりすると、ここでAddSourceした属性の追加はキャンセルされます。
public void Execute(GeneratorExecutionContext context)
{
context.AddSource("UnitOfAttribute.cs", "internal class UnitOfAttribute...);
try
{
// manipulate syntax...
}
catch (Exception ex)
{
System.Diagnostics.Trace.WriteLine(ex.ToString());
}
}
特にIDEのインクリメンタルコンパイルが稼働している状態だと、入力途中の「不完全なコード」が頻繁に飛んできます。こうした不完全なコードによる不正な構文木を正しくハンドリングするのはかなり難しく、例外を飛ばしてしまうのは正直避けられません。しかし、何があっても最初に生成する属性のAddSourceだけは維持しないと、「入力途中の不完全コード→例外発生で属性が吹っ飛ぶ→属性が吹っ飛ぶので入力補完が効かないどころか書いてるものが全てエラーになる」という負のループが発生します。なので、これに関してはtry-catchで握り潰しOKです。
コード生成のためのテンプレートですが、サンプルだとみんなstring interpolationでさっくり処理してますが、やめときましょう。複雑なコードを生成しようとすると破綻するので、よほど単純な生成じゃないならちゃんとテンプレートエンジン使いましょう。
じゃあ何を使えばいいのか、というとT4 Templateです。以前に.NET Core時代のT4によるC#のテキストテンプレート術という記事を書いたので、それを読んでくださいな。これの「実行時テキスト生成(TextTemplatingFilePreprocessor)」を使います。具体的なUnitGeneratorのテンプレートはUnitGenerator/CodeTemplate.ttにあるので参考にどうぞ。ただたんにOptionによってifがちょろちょろある程度ですが、それでもこれをstring interpolationとStringBuilderで処理するのは無理があります。
ユニットテストに関しては CSharpGeneratorDriver
というものが用意されているので、それで小さいCompilationを作って渡せばOK。ってどういうこっちゃという感じですが、chsienki/GeneratorTests.csのコードをまんま使えばOKですね。中身は単純です。
var comp = CreateCompilation(/* ソースジェネレーターの対象コード */);
var newComp = RunGenerators(comp, out var generatorDiags, new SimpleGenerator());
// あとはnewCompから生成コードを引っ張ってきて、それが意図通りの正しさかどうか見たり
Assert.Empty(newComp.GetDiagnostics()); // エラーなくちゃんと生成できてるかどうか
ただし、参照DLLを増やすと面倒くさい挙動したり、そもそも生成されたコードの挙動が正しいかどうかを見たい(UnitGeneratorでいうと算術演算子が正しいかとか、シリアライザの実装が正しいかとか)ほうが多いんじゃないかなーと思うので、普通にユニットテストプロジェクトにSourceGenerator参照して、それが生成されたコードを動かして普通にAssert書く、みたいなのでいいかな。私は実際そんなわけで、CSharpGeneratorDriver経由のテストはやめました。(というかそもそも普通のユニットテストもたいして書いてない説はある)
最後にNuGetへのパブリッシュについて。SourceGeneratorはAnalyerとして登録したいので、ひと手間いります。具体的には以下のように処理します。
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
IncludeBuildOutputで、自身のDLLを参照用として含めないようにします、これは前述の「参照DLLは完全に空にして、ソースジェネレーター自身が必要な属性を生成するパターン」を使う場合には自身の参照は不要だからですね。SuppressDependenciesWhenPackingは、これ設定しとかないとpack時に空なんだけど、という警告が出てくるので黙らせます。空なのは知っとるがな。
Analyzerとしてpackするにはanalyzers/dotnet/cs以下に配置すればいいだけ、ということで、そういう設定をしておきます。
一手間と言ってもこれだけです。昔はAnalyzerはPowerShell動かして小細工しなきゃいけないとか色々あって超絶面倒くさかったんですが、.NET 5時代の今は、だいぶ簡単になりました。
まとめ
ずっとF#のUnits of Measureのようなものが欲しいと思っていたのですよね。プリミティブなのだけど型がついてる。コンパイル時には型が消えてプリミティブそのものになるのでオーバーヘッドがない。そのままでも色々な演算ができる。
UnitGeneratorはお洒落なsuffixで生成とかはできないし組み込みの単位の変換関係(グラムとキログラムとかインチとフィーととか)があるわけじゃないので、同じものかといったら全然別物ではありますが、しかしValue Objectパターンの実装としては必要十分で、雰囲気も近づけられたのではないかと思います。
C#において、1要素のstructはメモリレイアウト的にはプリミティブ型と同一なので、完全に消せるわけではないですが、オーバーヘッドも減らしていける余地があります(演算のたびにnewで包み直していたりするのも、Unsafe.Asを活用していけばなくせるので、だいぶ近づけはするかな、と)。
実際ちゃんと型がついているのは良い状態で最終的に捗るのは間違いないので、このUnitGeneratorのアプローチが役に立てば何よりですね、是非試してみてください。あと、SourceGeneratorも是非作っていきましょう!
MagicOnion v4 - .NET 5 と ASP .NET Core gRPC対応への進化
- 2020-11-16
CysharpからMagicOnion v4を先週リリースしました。今回のリリースの実装はほぼ全て@mayukiさんにやってもらったので、詳細はそちらに丸投げドンとして(ReadMeもかなり書き換えてあるので、詳しいところはそちらも読んでください)、改めて .NET 5とgRPC、そしてMagicOnionの位置付けとロードマップなどを説明したいかな、と思います。
MagicOnion v4ではサーバーサイド側は完全に ASP.NET Core KestrelベースのPure C#実装になりました。今まではGoogleの提供していたgRPC C Coreを利用していたのですが、今回よりMicrosoft実装に切り替えています。これによりASP.NET Core MVCなどと基盤が共通化されたので、gRPCを提供しつつHTTP/1 REST APIの口やHTML出力を行うような同居がとてもやりやすくなりました。
そして何より、パフォーマンスが向上しています。
.NET 5における性能向上に関してはgRPC performance improvements in .NET 5にて紹介されていますが、まず、gRPCと一口で言っても性能は言語によって千差万別です。HTTP/2だから速いとか、gRPCだから速いとか、そういうことはありません。大事なのは実装です。
gRPCの場合は、各言語での独自実装組(Java, Go, Rust)と、Cで作られているCoreのバインディング組(Ruby, Python, Node.js, etc...)に分かれます。バインディングだから低速だということはないのですが(そもそも高速なC++実装はC Coreの上に作られている)、どうしてもマーシャリング部分の実装の甘さや、各言語の部分に乗っかった箇所の実装の弱さに引っ張られて、性能が落ちやすい傾向にあります。
実際のところ、どれだけパフォーマンスに本気になって実装しているか、というところが性能に現れるので、わざわざ各言語で独自実装しているものは本気度が高く、バインディングで済ませているのは本気度が低い(動けば御の字)といったような見方でも良いでしょう。
C#も今まではC Coreのバインディングでしたが、.NET Core 3.1からPure C#実装が提供され、そして今回の.NET 5よりHTTP/2の性能向上に注力したことで、C++, Rust, Goと並ぶTier1の位置までパフォーマンス向上を果たしました。
もともとHTTP/1においても執念深く延々と性能改善施策を続けていて、ついにTechEmpower Web Framework Benchmarksでは1位(Plaintextのみですが)を奪取しています。
それ以外にも .NET 5ではPerformance Improvements in .NET 5として細かい対応を延々としてきたのがついに実ったという感じですね。Announcing .NET 5.0で表明されていますが、.NET 5 は Unified Platformsを標榜してランタイムコンポーネント・コンパイラ・言語を統一するという話があります。そうした大きなバージョンアップに相応しい一歩なのではないでしょうか。
ちなみに、.NET 5、実質的に機能しだすのは.NET 6からで、5に関しては基礎固めと.NET Core 3のリブランディング的な感じなので、実際のインパクトは今の所あんまありません。
gRPCとトランスポート中立、或いはQUIC
色々な構成要素の塊がgRPCなのですが、それぞれの要素はプラガブルで分解可能だったりします。シリアライザはProtocol Buffersでなくてもいいし(C++実装はFlatBuffersに置き換えられるものも用意されていたりするし、MagicOnionはMessagePackを使っています)、トランスポート層もHTTP/2的な決まりごとにさえ従えるのなら、ある程度は自由に変更できます、実際TCPではなくUNIX domain socketへの置き換えはプロセス間通信としてgRPCを使う場合にはままあります。
C Coreにべったりの場合は、換装の自由度が低かったりしたのですが、Pure C#に置き換わり、その辺の仕組みが全て ASP.NET Core の上に乗っかったことにより、比較的自由に弄れるようになっています。
既にASP.NET CoreにはQuic実装がMicrosoft.AspNetCore.Server.Kestrel.Transport.Experimental.Quicとして(Experimentalですが)提供されています。MsQuicを基盤として利用するため、Quicの実装準拠度としても比較的信用が置けるでしょう。MsQuicはWindows Serverで使うため、当然ながら相当固い実装である必要があるからです。なお、MsQuic自体はクロスプラットフォームのためLinuxで動きます。
サーバーはそうして自由に対応できるとして、現状クライアント側がイマイチなのですが、そこは追々という感じでしょうか。
特にゲームでのリアルタイム通信での利用時に、TCPであることがボトルネックとなることは避けたいので、RUDPや、中国のネットワークゲームでよく使われるKCPによる通信の口は用意しておきたいと思っています。QUICが大安定して全てそれで解決、みたいな時代が来ればいいんですけれどね。そう遠くはなさそうな感じがあるので、期待しています。
gRPCとMagicOnion、StreamingHubとBidirectional Streaming
gRPCの色々な構成要素の中でも最重要なのがprotoによる言語中立のスキーマとコードジェネレートにあるでしょう。MagicOnionは、そのprotoを投げ捨ててC# to C#に限定されるため、デメリットを超えるだけのメリットが必要です。
一つはprotoが言語中立であることによる表現力の乏しさを、C#そのものスキーマとすることで解決しています。protoの場合は少しのプリミティブとEnum、コレクションとマップのみですが(また、nullもない)、MagicOnionの場合は、C#そのものスキーマとして見立て、メッセージ形式にMessagePack for C#を利用することで、(ほぼ)全てのC#型が転送可能になっています。
例えば.NET 5ではWCFの置き換えにgRPCが推奨されていますが Migrate a WCF request-reply service to a gRPC unary RPC 、protoへの書き換えが必要なことと、表現力のギャップに苦しむことがあります。MagicOnionならDataContractで表現された型は全てMessagePack for C#でシリアライズ可能ですし、OperationContractのメソッドの複数引数のような表現も可能なため、移行におけるギャップはほとんどありません。
また、gRPCは双方向のリアルタイム通信用にBidirectional streaming RPCが利用できますが、これは双方が投げっぱなしのAPIしか存在しないため、戻り値の取得や処理の完了の待機などが実装できません。更にエンドポイントとなる型も一つしか使えないため、大量のoneofで呼び出しの切り分け処理をするしかありません。
MagicOnion StreamingHubはBidirectional streaming RPCの上に、双方向にC#としての型やメソッドのルーティング処理をつけ、client -> server -> client の呼び出しでは戻り値の取得やエラー送信、完了待機のシステムを入れました。この基盤処理の実装によって、初めてgRPCで実用的なリアルタイム通信が可能になっています。なお、APIは ASP.NET Core SignalRに寄せたため、そちらの経験があれば比較的スムーズに移行できるはずです。
UnityとgRPC
MagicOnion v4ではサーバーサイド側は完全に ASP.NET Core KestrelベースのPure C#実装になりました。クライアント側も.NET Coreの場合はHttpClientベースのPure C#実装になりました。Unityは……?というと、引き続きC Coreベースの提供になります。Unity側の改善はMagicOnionにおいてはv5でなんとかする予定ですので少々お待ち下さい。
Unity側の実装がC Coreで提供されている状態は、初期セットアップが相当面倒くさくなっています。というのもGoogleがAndroid, iOS向けのビルドを雑にとりあえずといった感じで提供しているだけなので、そのまんまだと動かないという……。MagicOnionのReadMeのSupport for Unity Clientセクションで、その辺は手厚めに解説してはいます。例えばそのまんまだとiOS用のgRPC libが100MBを超えていてGitHubで扱えないという問題が発生するのですが、ReadMeに説明してあるストリッピングの手順に従ってもらえればlibのサイズを縮めることができます。
ほか、C Coreの持つネイティブコネクションのライフサイクルと、Unity上での頻繁なPlay/StopによるC#側のライフサイクルが自動では一致しないため、ネイティブコネクションがリークするとエディタごと巻き込んでフリーズする(この場合、コネクション管理を徹底してライフサイクルを一致させれば大丈夫)、といった面倒くさい問題が発生したりします。
また、Taskベースで作られているためアロケーションが多めという問題もあったり。
これらネイティブライブラリであることの問題は、Pure C#実装を提供することで解決すると考えています。Task部分に関してはUniTaskを活用するように書き換えれば、アロケーションも減らせるでしょう。実際、Unity用のメジャーなOSSネットワークフレームワークであるMirror、の作者陣が内部分裂してForkされたMirrorNGはUniTaskベースで構築されています。
Pure C#実装の場合は、UnityのC#ランタイムであるmonoがあまり性能が良いとは言えないため、問題になる可能性があるのですが、サーバーとして使わなければ大丈夫なのではないかと踏んでいます。フルUnity実装でサーバーを提供する場合は気になるところなのでネイティブ実装を混ぜるなどの方向性もあるとは思いますが、現状のMagicOnionの構成はUnityはクライアントにしかならないので。
gRPCであること
gRPCを推しているのは、HTTP/2に乗っかっていることでインフラ側のミドルウェアが豊富なことがあります。Nginx, Envoyなど、今ではほとんどのソフトウェアがHTTP/2対応していますし、AWS ALBに至ってはgRPC専用のサポートを追加してきました。これらを活用することで、独自の通信形式などに比べると、サーバー構築の柔軟性が飛躍的に向上しています。独自っぽい雰囲気の漂っているMagicOnionも、変更しているのはメッセージの中身だけなので、gRPCのミドルウェアのエコシステムにはフルに乗っかれています(というか、ちゃんと乗れるように作っているのです)
また、自社で何もかもをすべて作らない、というのもあります。ASP.NET Coreに乗っかることで、Microsoftによる通信ライブラリの性能改善にタダ乗りしています。
gRPCそのもののメリットとしては、API通信とリアルタイム通信の二系統を一つのフレームワークに一本化できること、これは私が4年前にgRPCを採用した(当時は1.0が出たばかりでUnityの利用事例はゼロだし、一般の事例はマイクロサービスのサーバー間通信用としてが多くクライアント-サーバー通信を置き換えようとする例もあまりなかった)理由でもあります。
ロードマップ
v5におけるUnityクライアントの作成、ユーティリティとして負荷テストツールの提供、などがとりあえず並んでいます。特に負荷テストツールはあと少しなので、近日中にお届けできるかと。
MagicOnion自体のプロダクト採用の実績は増えてきていて、直近ではD4DJ Groovy MixがAPI(Unary)もリアルタイムマルチ(StreamingHub)も活用しています。また、バーチャルキャスト 2.0では、ルームというVR SNSの実装に活用されているそうです。
ゲームーサーバーとしてC#が活用できるのか?といったことは、クラスメソッドさん向けにお話したゲームサーバー用の発表でも話しましたので、レポ記事 - Cysharpの河合様をゲスト講師にお招きしてゲームサーバーに関する社内勉強会を開催しました!と、発表資料もどうぞ。
世の中、適材適所なのは間違いありません。だからこそ、「C#がその適材である」と言えるだけの環境を提供していく、というのがCysharpのミッションでもあります。Microsoftも.NET Coreにおいて、当初はWindowsべったりなC#が今更Linuxとか言ったって、みたいな白い目で見られていました。しかし、最初のバージョンから4年が経ち、文句を言わせないだけのパフォーマンスでもって証明してきました。
.NET 5はスタート地点だと考えています。C#も大変面白い環境になってきたと思うので、是非みんなと追求していけたら嬉しいですね。
ConsoleAppFramework v3 - より強化されたC#のためのコマンドラインツール用フレームワーク
- 2020-10-01
.NET 5も控えていることだし、というのは関係ないのですが、CLIアプリケーションや大量のバッチをC#で簡単に作れるフレームワークであるところのConsoleAppFrameworkを思い立って更新しました。
- github - Cysharp/ConsoleAppFramework
基本的な構成である、Generic Hostの上に乗っかるCLIフレームワークというコンセプトには変更ありません。
メソッド定義がそのままコマンドライン引数になって、ヘルプなども自動生成してくれます。Host(ASP.NET Coreなどでも使う)の設定によってロガーやDIの設定、オプションの読み込みとバインディングも可能なので、細かいコンフィグレーションもそれで行えますし、基盤が一緒なためASP.NET Coreなどとの共通化なども可能になります。
一番単純な例を出すとこんな感じになります。
public class Program : ConsoleAppBase
{
static async Task Main(string[] args)
{
await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
}
public void Hello([Option("m", "Message to display.")]string message)
{
Console.WriteLine("Hello " + message);
}
}
> SampleApp.exe help
Usage: SampleApp [options...]
Options:
-m, -message <String> Message to display. (Required)
Commands:
help Display help.
version Display version.
> SampleApp.exe -m World
Hello World
今回の変更内容は
- 厳密っぽいオプション引数指定
- version, helpコマンドをデフォルトでヘルプ表示
- class/methodによる自動コマンド定義を class method コマンド引数で実行可能に(以前はClass.Methodだった)
- Interceptorを廃止してFilterによる拡張
Interceptorの廃止だけが破壊的変更で、それ以外は互換性取れています。
厳密っぽいオプション引数指定
厳密っぽいというか、 -i, --input
のようにショート版の名前を-、ロング版の名前を--で一致を見るスタイルを適用可能にしました。デフォルトは-の数を無視します、つまり-inputでも--inputでも-----inputでも同じ扱いにしています。これ区別するの面倒くさいなーと思っていて、例えばgoのコマンドは全て-o, -outputみたいな-だけで済ませていて、私もそれでいいじゃん、むしろそれがいいじゃん、と思ってはいるのですが(なのでデフォルトはそう)、区別したい人も世の中には大勢いるとは思うので、そーいうオプションを足しました。
public class Program : ConsoleAppBase
{
static async Task Main(string[] args)
{
await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args, new ConsoleAppOptions
{
StrictOption = true, // default is false.
ShowDefaultCommand = false, // default is true
});
}
public void Hello([Option("m", "Message to display.")]string message)
{
Console.WriteLine(message);
}
}
> SampleApp.exe help
Usage: SampleApp [options...]
Options:
-m, --message <String> Message to display. (Required)
デフォルトが -m, --message <String>
だったhelpが、 -m, --message
になっています。-messageという指定をすると、名前が合わないというエラーが出るようになります。
また、version, helpコマンドがデフォルトでヘルプ表示されるように今回からなりました。これもオプションで ShowDefaultCommand = false にすれば表示されなくなります(表示されなくなるだけで、コマンドとして存在はしています)。
class/methodによる自動コマンド定義
プロジェクトに沿ったバッチを作成する場合に、数十、時に数百個のバッチを作る必要があります。そうなると一々コマンド定義をしてる場合じゃねえ、という感じなので、自動でルーティングしてくれる機能がConsoleAppFrameworkにはあります。 MVCフレームワークがclass/methodでURLルーティングするのと同様に、class methodというサブコマンド階層を自動で生成してくれます。
class Program
{
static async Task Main(string[] args)
{
// <T>を指定しないとアセンブリ全体から実行コマンドとなるクラスを検索して登録する
await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args);
}
}
public class Foo : ConsoleAppBase
{
public void Echo(string msg)
{
Console.WriteLine(msg);
}
public void Sum([Option(0)]int x, [Option(1)]int y)
{
Console.WriteLine((x + y).ToString());
}
}
public class Bar : ConsoleAppBase
{
public void Hello2()
{
Console.WriteLine("H E L L O");
}
}
> SampleApp.exe help
Usage: SampleApp <Command>
Commands:
foo echo
foo sum
bar hello2
help Display help.
version Display version.
> SampleApp.exe foo sum 10 30
40
前のバージョンでは "Foo.Sum" というコマンド名での呼び出しだったのですが、それはコマンドラインツールとして不自然だろう、ということで、小文字の "class method" で実行されるようになりました。互換性のために "Foo.Sum"といった指定でも実行可能です。
Filterによる拡張
ASP.NET Core や MagicOnion のように、フィルターによって実行前後を拡張できるようになりました。実装はConsoleAppFilterを継承して、await nextを実行するという非同期スタイルです。
public class MyFilter : ConsoleAppFilter
{
// Filter is instantiated by DI so you can get parameter by constructor injection.
public async override ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
{
try
{
/* on before */
await next(context); // next
}
catch
{
/* on after */
throw;
}
finally
{
/* on finally */
}
}
}
// ConsoleAppContext
public class ConsoleAppContext
{
public string?[] Arguments { get; }
public DateTime Timestamp { get; }
public CancellationToken CancellationToken { get; }
public ILogger<ConsoleAppEngine> Logger { get; }
public MethodInfo MethodInfo { get; }
public IServiceProvider ServiceProvider { get; }
public IDictionary<string, object> Items { get; }
}
フィルターはグローバル(全てのメソッドで呼ばれる)、クラス、メソッド単位で付与することが可能です。
// フィルターの呼び出し順序はOrderで設定可能
await Host.CreateDefaultBuilder()
.RunConsoleAppFrameworkAsync(args, options: new ConsoleAppOptions
{
GlobalFilters = new ConsoleAppFilter[] { new MyFilter2 { Order = -1 }, new MyFilter() }
});
[ConsoleAppFilter(typeof(MyFilter3))]
public class MyBatch : ConsoleAppBase
{
[ConsoleAppFilter(typeof(MyFilter4), Order = -9999)]
[ConsoleAppFilter(typeof(MyFilter5), Order = 9999)]
public void Do()
{
}
}
まとめ
シンプルさと機能性のバランスがうまくとれてるんじゃないでしょうか。すごく細かい調整ができるわけではないので、そこはどうしても割り切りという感じになってしまうのですが、それでもほとんどのユースケースは満たせているんじゃないかと思います。
自動コマンド定義は大量にバッチを量産する場合に便利、でもあるのですが、それと同時にC#のプロジェクト一つで大量のバッチを管理できるようになる、というのも利点です。ファイル単位で管理するとわけわからん、ということになりがちですが、これなら綺麗に整理されますし、ロジックのメソッド化などで共通化もできます。また、フィルターを活用することによっても前処理や後処理などの共通化をより推し進められるでしょう。
大きなプロジェクトの一部としてのバッチアプリの場合、ASP.NET Coreなどのコンフィグに定義されているDBのパスなどが、同じジェネリックホストなのでそのまま読み込めるのも楽になれるポイントです。ロガーのパフォーマンスが必要な場合は、 Cysharp/ZLoggerを使うと良いでしょう、ZLoggerも Microsoft.Extensions.Logging の上に構築されているので、ジェネリックホストが基盤になっているConsoleAppFrameworkではスムーズに使えます。
await Host.CreateDefaultBuilder()
.ConfigureLogging(x =>
{
x.ClearProviders();
x.SetMinimumLevel(LogLevel.Trace);
x.AddZLoggerConsole();
x.AddZLoggerFile("fileName.log");
})
.RunConsoleAppFrameworkAsync(args);
と、いうわけでより強力になったConsoleAppFramework、是非使ってみてください。
async decoratorパターンによるUnityWebRequestの拡張とUniTaskによる応用的設計例
- 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とかが解禁されます(依存ライブラリとして同梱する予定なので)。いいのかわるいのか。まあ、いいでしょふ。未来未来。