Roslyn Analyzerでコンフィグを読み込ませて挙動を変更する

方法。が、欲すぃ。例えば採番する時に0ベースなのか1ベースなのかプロジェクトによって変えたい。例えばCodeFix時の名前変更のルールを先頭アンスコ付けるのか付けないのかを自由に変えさせたい。NotifyPropertyChangedGenerator - RoslynによるVS2015時代の変更通知プロパティの書き方の時は、専用のAttributeを使うので、その中のインターフェイスを書き換えてコンフィグ代わりにしてね、という方法を取ったのですが、専用の属性が使えなきゃ適用できない手法で、全然汎用的っぽくないし、当然ながら全然イケてない。

ではどうするか。実は、Additional Filesという仕組みが用意されているので、それを用いることでコンフィグを読みこませることができます!詳細はroslyn/Using Additional Files.mdに書かれていますが、任意のテキストファイルを色々な手法(コマンドラインの引数やcsprojの定義としてなど)でプロジェクトに設定し、Analyzer側では AnalyzerOptions.AdditionalFiles で読めます。

では、やってみましょう。

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AdditionalFileAnalyzer : DiagnosticAnalyzer
{
    static DiagnosticDescriptor Rule = new DiagnosticDescriptor("AdditionalFileAnalyzer", "AdditionalFileテスト", "AdditionalFiles:{0}", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true);
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

    public override void Initialize(AnalysisContext context)
    {
        context.RegisterSymbolAction(Analyze, SymbolKind.NamedType);
    }

    private static void Analyze(SymbolAnalysisContext context)
    {
        // AnalyzerOptionsにAdditionalFilesがある。
        // CodeFixContextの場合はProjectに生えてるので、 context.Document.Project.AnalyzerOptions で取得可能。
        var additionalFiles = context.Options.AdditionalFiles;

        // Pathから引っ掛けて取る
        var config = additionalFiles.FirstOrDefault(x => System.IO.Path.GetFileName(x.Path) == "config.json");
        if (config != null)
        {
            // GetText().ToString()で文字列が取れるので、あとはJsonConvertでデシリアライズするなりなんなりどうぞご自由に……。
            var text = config.GetText().ToString();
            context.ReportDiagnostic(Diagnostic.Create(Rule, context.Symbol.Locations[0], text));
        }
        else
        {
            context.ReportDiagnostic(Diagnostic.Create(Rule, context.Symbol.Locations[0], "JSONが見つかってないぞ"));
        }
    }
}

これでJSONが見つかってないぞ、と怒られまくります。ファイルを追加するには、まずプロジェクトの適当なところにconfig.jsonを足して、Build Actionを AdditionalFiles に変更します。が、変更しようとするとやっぱり怒られるので、しょうがないからcsprojを手動で書き換えます。

<ItemGroup>
    <AdditionalFiles Include="config.json" />
</ItemGroup>

これで、以下の様な結果が得られます。

image

うん、ちゃんと読めてる!

コンフィグとして使うには、まぁイマドキのJSONでコンフィグだったらJSON.NETでJsonConvert.DeserializeObjectなんかでサクッと復元してやるのが楽でしょう。外部DLL読み込むのが嫌だという場合は、XMLにしてLINQ to XMLやXmlSerializer使うのも全然お手軽でいいとは思います。

コンフィグなんてほぼほぼ固定になるのに毎回デシリアライズ走らせるのは嫌だなあ、って場合はstaticオブジェクトにキャッシュしちゃうのも悪くない手だと思います。その場合は、書き換えた場合はプロジェクト再読み込み(or VS再起動)になってしまいますが、実用的には全然問題ないはず。といいたいのですが、複数のプロジェクト跨ぎで共有されちゃうと厄介だったりするので注意が必要なので、まぁちょっとDeserializeするぐらい大したことねーよということで、毎度Deserializeするのも全然構わないかな、とは。

このコンフィグを読み込む手法はStyleCopAnalyzer/Configuration.mdでも採用されているし、これがスタンダードのやり方だと思って良さそうです。

SerializableDictionary - Unityで高速に辞書復元するためのライブラリ

という、ScriptableObjectとかJsonUtilityとか、そもそもSerializeFieldとかでシリアライズできるDictionaryを作りました。

もともとDictionaryはシリアライズできないのですが、ISerializationCallbackReceiverを用いてシリアライズ/デシリアライズのタイミングでKeyの配列、Valueの配列に戻してやるなどで保存すること自体は全然可能でした。のですが、速度的には問題あるな、というのに直面しました。

その前に、JSONから復元するのがまず遅かった。じゃあMsgPackやProtobufに変更したら速いかといえば、別にそこまでそうではなかった。これはつまり、C#のレイヤーで大量の何かを舐めて何かを作るという行為そのものが遅い。ではScriptableObject化すればどうだろう、確かにデシリアライズのプロセスがUnityネイティブ(実体は不明)と化して、確かに速い。が、そこからDictionaryに変換してやるのをC#で書いたらやっぱりそこが遅い。

遅い、というとアレで、量次第ですけどね。今回、量がやたら多かったので結構かなり相当引っ張られてた。初期化のタイミングなどで大量のDictionaryを捌くような場合に、無視できない程度に結構引っかかる遅さを醸し出してる。結局、配列からであっても、C#のレイヤーで大量の何かを舐めて何かを作るという行為そのものが遅い。という悲しい現実をつきつけられるのであった。

というわけで、SerializableDictionaryはDictionaryの内部構造をシリアライズすることで、ネイティブプロセスのみで完結して爆速で復元します。

SerializableDictionaryではSerializableDictionary, SerializableLookup(MultiDictinary), SerializableTupleの3つを提供します。今のとこアセットストアに公開するつもりはそんなないので、使いたい場合はソースコードをZipでダウンロードするなりGitで落とすなりしてプロジェクトに投げ込んでください。

SerializableDictionary

例えばキーがint、値がstringの辞書を保存したい場合は、まず、継承したクラスを作ります。

[Serializable]
public class IntStringSerializableDictionary : SerializableDictionary<int, string>
{

}

わざわざ継承しなきゃいけない理由は、ジェネリックな型はシリアライズできないからです!しょうがないね。別にゆうてそんなに大量の型があるわけでもないでしょうし、素直にそれぐらいは作りましょう。あとは、普通に使えば普通にシリアライズ可能になってます。メデタシメデタシ。

インスペクタに表示するためのPropertyDrawerも用意してあります(こちらも定義しないとインスペクタに何も表示されなくて不安になる)。使う場合は、SerializableDictionaryPropertyDrawerを継承した型を一つ作って、そこに属性でひたすらぶら下げます。

#if UNITY_EDITOR

[UnityEditor.CustomPropertyDrawer(typeof(IntStringSerializableDictionary))]
[UnityEditor.CustomPropertyDrawer(typeof(IntDoubleSerializableDictionary))]
[UnityEditor.CustomPropertyDrawer(typeof(IntIntStringSerializableDictionary))]
public class ExtendedSerializableDictionaryPropertyDrawer : SerializableDictionaryPropertyDrawer
{

}

#endif

これを定義すれば、インスペクタ上では

image

なんとKeyとValueの確認しか用意されてなくて、エディット不能!ただのDump!うーん、気が向いたらエディット可能にします。そのうち(多分やらない)。

複数キーの辞書

Int + Intの組み合わせでキーにしたいとか、辞書にはよくあるケースです。そして、そういったよくあるケースではKeyにTupleを使うことが多いです。が、UnityにはTupleはありません。UniRxにTupleがあります、が、それはシリアライズ可能ではありません(Genericだからねー、structなので継承もできない)。と、いうわけで、辞書のキーにしたいよね専用にSerializableTupleを用意しておきました。使う場合はもちろんまずは継承してジェネリックを消すとこからはじめます。

[Serializable]
public class IntIntTuple : SerializableTuple<int, int>
{
    public IntIntTuple()
    {

    }

    public IntIntTuple(int item1, int item2)
        : base(item1, item2)
    {

    }
}

[Serializable]
public class IntIntStringSerializableDictionary : SerializableDictionary<IntIntTuple, string>
{

}

あとは普通にキーに使ってもらえれば、普通に使えます。ちょっと手間ですが、そこまで多いわけでもないでしょうし我慢できる範囲内。だといいかな。

SerializableLookup

ILookupは、Keyに対してValue側が複数になっている辞書です。Dictionary[Key, Value[]] みたいなイメージ。通常のILookupはLINQのToLookup経由でしか作成できない、Readonlyなシロモノです。これ、非常に便利な型でして、よく使います。ToLookupしらない人は覚えましょう。ただ、勿論シリアライズできないのでAddを加えたSerializableLookupを用意しました(Removeはありません!つまりBuilderのほうがイメージ近いかもしれません。Removeがない理由は実装しててバグッたからとりあえず消してるだけなのでそのうち入れるかもしれないかもしれない)

使い方はDictionaryと同様。

[Serializable]
public class IntIntSerializableLookup : SerializableLookup<int, int>
{
}

#if UNITY_EDITOR

[UnityEditor.CustomPropertyDrawer(typeof(IntIntSerializableLookup))]
public class ExtendedSerializableLookupPropertyDrawer : SerializableLookupPropertyDrawer
{

}

#endif

ちなみに中身は面倒くさいんでSerializableDictionaryの一部を改変して辻褄合わせてるだけなので、実効速度的な意味ではToLookupで生成したものに比べるとやや劣るかなー、といったところ。まぁハッシュキーの衝突具合とかにもよるので、いうほどそこまでではないと思います。実装の雑さは気にしてるのでそのうち直したい(絶対やらない)

TrimExcess

ListにせよDictionaryにせよ、任意個数をAdd可能なものは、内部である程度余分なバッファを持っています。しかし、ScriptableObjectなどにしてAssetBundleに載せたい場合は、その後の追加なども特になく個数は固定である可能性も少なくないはずです。と、いうわけで、TrimExcessメソッドを呼ぶことで余分なバッファを切り落とすことができます。もし個数が固定であることが見えているなら、事前にSerialize前に呼んであげておくことで、メモリ節約につながります。

Unityでシリアライズ可能なもの

内部構造の話なのですが、その前にUnityでシリアライズ可能なものの制限についておさらい。

  • [Serializable]のついた非ジェネリックな具象型
  • UnityEngine.Objectを継承した型
  • public、または[SerializeField]のついたインスタンスフィールド
  • int, float, double, bool, stringなどのプリミティブなデータ型
  • 配列、もしくはList[T]

Dictionaryに非対応なのは勿論ですが、Nullable[T]に非対応が割と痛かったりするかな!また、トップレベル以外でnullをサポートしていなかったりして、ちょっと複雑な型を作った場合、nullを入れたと思ったら全部0が入った謎データに置き換わっていた、とかが生じます。それらの制限の回避策としては、SerializableDictionaryと同じようにSerializableNullableのようなそれっぽい似非な型を自前で作ってあげればなんとかなります。nullのほうも同様にNullableClass(なんじゃそりゃ)を作ってあげることにより、nullかそうでないかの区別を可能にできます。面倒くさくはあるんですが、どうしても必要な場合はそうして回避できなくもないよ、ということで。

SerializableDictionaryは、Dictionaryの内部構造を、Unityでシリアライズ可能な範囲(ようするにひたすら単純な配列まみれにする)に修正することで実現しています。オリジナルのDictionary自体はdotonet/corefxのものです。

ハッシュコードを永続化することの安全性

ハッシュコードを永続化することは、推奨されないことが明言されています。というのも、そのオブジェクトに対するハッシュコードが一意であるかが、どのスコープまで保たれるかというのは、全くもって不明瞭だからです。参照型などはアプリケーション起動毎に異なってなにの役にも立たなくなる、では数字は?文字列は?保証はないんですねー。

実用的な意味では、問題ないと判断しても構わないと思っています。しかし、まず、monoと.NET Frameworkのような環境が違うもので生成したもの同士の互換性はないと考えたほうがいいでしょう(実際ない)。また、.NET Framework内だけでも、バージョンが異なれば、違うハッシュアルゴリズムが使われることにより異なるハッシュコードが使われる可能性は全然あります。今後も、Unityのバージョンアップ+(もしあるのなら)monoのバージョンアップが発生した際などは、互換性が崩れる可能性があります。最悪そうなった場合は、任意のComparerを挟み込めるようになっているので、そこで互換性を保ったハッシュコードを返してやることにより、一応大丈夫とはいえます。一応。

とはいえまあ、実用的な意味では大丈夫でしょう。タブンネ。まぁしかし、この辺グレーゾーンなきらいもあるから、Unity公式でサポートってのは難しいんじゃないかなあ、というのはしょうがないかなー。

まとめ

Unityって結局どこで動いているものなのよ、ってのを改めて突きつけられた感じがしました。C++のエンジンがあくまでも主だな、と。また、シリアライズを通して考えると、一見不思議なMonoBehaviourやpublic fieldなども納得がいくように見えてきて、ようするにネイティブとの境界線を接続している場所なんですね。COMとdynamicでやり取りするように、ネイティブレイヤーとフィールドでやり取りする。そう思えば、何もかも腑に落ちてきた気がします(悟り!)。

C#のレイヤーでいかに仕事をさせないかがキモで、そのためにC#を書く。ってのも、まぁ悪くない話だし、Unityアプリケーションとしての整合感やパフォーマンスが最も求めるべきことなのだ。というのは認識しておきたいな、なんて改めて思わさせられました。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

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

Twitter:@neuecc GitHub:neuecc

Archive