はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】YetAnotherHttpHandlerを用いてHTTP/2を扱えるHttpClientを作成する

はじめに

今回はYetAnotherHttpHandlerを用いてUnityでHTTP/2を扱う方法を紹介したいと思います。

背景

まず.NETが提供するHttpClientですが、.NET Core3.0以降であればHTTP/2に対応しています。
HttpClient クラス (System.Net.Http) | Microsoft Learn

var client = new HttpClient
{
    BaseAddress = new Uri("https://localhost:5001"),
    DefaultRequestVersion = new Version(2, 0)
};

using var response = await client.GetAsync("/");
Console.WriteLine(response.Content);

.NET Core 3.0 の新機能 - .NET | Microsoft Learn


現在対応しているUnityのランタイムの最新は.NET Standard2.1ですが、まだHttpClientがHTTP/2に対応していません。(全てはMonoのせい)
learn.microsoft.com

UnityでHttp/2を扱いたい場合はどうすればよいのかという話になるわけですが、そこでYetAnotherHttpHandlerが登場します。

概要

YetAnotherHttpHandlerによりUnity(.NET Standard2.1)でHTTP/2を利用することができるようになります。ライブラリが提供するYetAnotherHttpHandlerHttpMessageHandlerを継承しており、HttpClientのコンストラクタに渡すだけでHTTP/2を利用するようになります。

// .NETの内部実装
public HttpClient(HttpMessageHandler handler)  : this(handler, true){ }
// YetAnotherHttpHandlerの内部実装
public class YetAnotherHttpHandler : HttpMessageHandler

https://github.com/dotnet/runtime/blob/0154a2f3403d94ea6d6f93f5a774b6e366969e0a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L142-L151
github.com

つまりユーザーはいつも通りHttpClientを利用するので、使い心地をほとんど変える必要がないという素晴らしさです。

YetAnotherHttpHandler brings the power of HTTP/2 to Unity and .NET Standard.

This library enables the use of HTTP/2, which Unity does not support. It allows you to use grpc-dotnet instead of the deprecated C-core gRPC library. It can also be used for asset downloading via HTTP/2, providing more functionality to your projects.

The library is implemented as a HttpHandler for HttpClient, so you can use the same API by just replacing the handler. (drop-in replacement)

YetAnotherHttpHandlerはHTTP/2のパワーをUnityと.NET Standardにもたらします。 このライブラリはUnityがサポートしていないHTTP/2の使用を可能にします。 非推奨のCコアgRPCライブラリの代わりにgrpc-dotnetを使用することができます。 また、HTTP/2経由のアセットダウンロードにも使用でき、プロジェクトにさらなる機能を提供します。 このライブラリはHttpClientのHttpHandlerとして実装されているので、ハンドラを置き換えるだけで同じAPIを使用できます。 (ドロップイン置き換え)

https://github.com/Cysharp/YetAnotherHttpHandler

環境

  • Unity 2021.3 (LTS) or later

Architecture/Platformの対応について詳細はreadmeをご覧ください。
Builder in hyper_util::client::legacy - Rust

インストール方法

最近CySharpさんはNuGetForUnityやUnityNuGetを採用してきていますが、私は考え方が古いのかUPMの方が良いのかなとまだ思ってます... (何故かライブラリを信用できない)

というわけでUPMでインストールするには以下のURLをUnity Package ManagerのAdd package from git URL...に入力してください。

https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler
UPMによるインストール

追加で依存先のライブラリをインポートする必要があります。

Releasesに依存先のdllを含めた.unitypackageを上げておいてくれてるみたいです。Cysharp.Net.Http.YetAnotherHttpHandler.Dependencies.unitypackageをインストールしてUnityへインポートすればOKです。
github.com


それかnuget.orgから直接取ってくる手法も一応あります。Download packageを押して以下の手順で操作をします。

  • .nupkgをダウンロード
  • 拡張子を.zipに変更して展開
  • system.io.pipelines.8.0.0/lib/netstandard2.0/System.IO.Pipelines.dllをUnityのPluginsフォルダに配置
  • system.runtime.compilerservices.unsafe.6.0.0/lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dllを同様にPluginsフォルダに配置

使い方

YetAnotherHttpHandlerを生成して、HttpClientのコンストラクタに渡してあげます。

using System.Net.Http;
using Cysharp.Net.Http;
using UnityEngine;

public class Sample : MonoBehaviour
{
    private async void Start()
    {
        using var handler = new YetAnotherHttpHandler();
        using var httpClient = new HttpClient(handler);

        // 適当にブログのページを取得します。HTTP/2に対応していることは確認済です。
        var result = await httpClient.GetAsync("https://www.hanachiru-blog.com/");

        // HTTP message version : 2.0
        var version = result.Version;
        Debug.Log(version);

        var content = await result.Content.ReadAsStringAsync();
        Debug.Log(content);
    }
}

github.com

HTTP/2で通信に成功している

正しく通信できることを確認できました。

さいごに

readmeに書かれている通り、Grpc.Netに移行でHTTP/2が必要になり利用されるケースが多いようです。

そこら辺も詳しくreadmeにかかれているので、気になる方はチェックしてみてください。
github.com

【Unity】UI ToolkitのTwoPaneSplitViewをコードから動的に生成せずに利用する方法

はじめに

UI ToolkitのTwoPaneSplitViewというVisualElementがあるのですが、それがめちゃくちゃ便利です。2つの要素をリサイズできるようにしつつ分割して表示してくれるものになります。
docs.unity3d.com

TwoPaneSplitView

よくコードからTwoPaneSplitViewを生成する方法が紹介されていますが、実用するうえではUXMLに直書きしたい場合の方が多いのではないのかなと思います。しかしUnityのデフォルトではLibrary/Standardにはありません。(Unity2022.3.22f1時点で確認)

Library/StandardにTwoPaneSplitViewはない

今回はコードから生成せずにTwoPaneSplitViewを利用する方法を紹介したいと思います。

概要

改めて紹介するとTwoPaneSplitViewとは水平または垂直に2つのペインに子要素を配置するコンテナです。

TwoPaneSplitView

The TwoPaneSplitView element is a container that arranges its children in two panes, either horizontally or vertically. The user can resize the panes by dragging the divider between them. A TwoPaneSplitView must have exactly two children.

// DeepL翻訳
TwoPaneSplitView要素は、水平または垂直に2つのペインに子要素を配置するコンテナです。 ユーザーは、ペイン間の仕切りをドラッグすることによって、ペインのサイズを変更することができます。 TwoPaneSplitViewは、正確に2つの子を持つ必要があります。

https://docs.unity.cn/Manual/UIE-uxml-element-TwoPaneSplitView.html

コードからの生成方法

これはよく紹介されているので書かなくてもいいかもしれませんが、例えば以下のようなコードで生成できます。

public void CreateGUI()
{
    // 最初の子要素を固定ペインとする場合は 0、2 番目の子要素を固定ペインとする場合は 1。
    var fixedPaneIndex = 0;

    // 固定ペインの初期の幅または初期の高さ。
    var fixedPaneInitialWidth = 200f;

    // 分割ビューの方向。(Horizontal or Vertical)
    var orientation = TwoPaneSplitViewOrientation.Horizontal;

    var splitView = new TwoPaneSplitView(fixedPaneIndex, fixedPaneInitialWidth, orientation);
    rootVisualElement.Add(splitView);

    // 左ペイン
    var leftPane = new VisualElement();
    splitView.Add(leftPane);

    // 右ペイン
    var rightPane = new VisualElement();
    splitView.Add(rightPane);
}

uxmlに書き込む方法

TwoPaneSplitViewを配置したい場所に以下の要素を入れ込んでください。

<ui:TwoPaneSplitView class="unity-two-pane-split-view">
    <ui:VisualElement />
    <ui:VisualElement />
</ui:TwoPaneSplitView>

一度これを入れ込むことにより、あとはUI Builder上からでもイジれるようになります。

UI Builder上でTwoPaneSplitViewをいじっている様子

【Unity】.asmrefはCore CLRがくると動作しなくなるよという話

はじめに

UnityはMonoからCore CLRへの移行を進めていますが、Core CLRがくると.asmrefが使えなくなるみたいです。そのあたりを軽く調べてみたので、そのまとめを書き残しておきたいと思います。

また現時点ではまだ開発中なので、今後変わるかも知れないのでご注意ください。

話題の議論

Unity DiscussionsでUnity Future .NET Development StatusというタイトルでユーザーとUnity中の人が議論している様子を見ることができます。
https://discussions.unity.com/t/unity-future-net-development-status/

そこでとあるユーザーがCoreCLRがくると.asmrefがなくなる話はどうなったの?と質問していました。

As a long time has passed since this was last talked about. How’s the latest status for .asmref and usage of [InternalsVisibleTo] attribute?
There were rumors that .asmref will be gone with CoreCLR and I’m not clear if that’s true or if it has changed by now.

Any kind of update would be appreciated. Thanks!

// DeepL翻訳
この話題からずいぶん時間が経ってしまったが、.asmrefと[InternalsVisibleTo]属性の最新状況はどうなっているのだろうか。 .asmrefと[InternalsVisibleTo]属性の使い方の最新状況はどうなっているのでしょうか? CoreCLRで.asmrefがなくなるという噂がありましたが、それが本当なのか、それとも今頃になって変わったのかよくわかりません。 何か最新情報があれば教えてください。 ありがとうございます!

Unityの中の人の回答としては、.asmrefは廃止するということみたいです。

Yes, asmref won’t be supported anymore. For now, as we can’t undisclosed more about some of the changes that we are making, It will be difficult to give more details about this, hope you understand.

// DeepL翻訳
はい、asmrefはもうサポートされません。 今のところ、私たちが行っているいくつかの変更についてこれ以上公表することができないため、これ以上の詳細をお伝えすることは難しいのですが、ご理解いただければ幸いです。

https://discussions.unity.com/t/unity-future-net-development-status/836646/2491

代替案

また中の人が代替案を提案してくれていました。

  • System.Reflection
  • IL Post Processing
  • UnsafeAccessorAttribute

We won’t have a strict substitute, but in this kind of situations (and that applies to any .NET code, not only for Unity), folks have been always able to workaround with e.g System.Reflection, IL post processing assemblies and there are also new unsafe workaround toolbox coming like the more recent [UnsafeAccessorAttribute]. But the best substitute imo would be to have closer collaboration with users, good feedback loop, and ways to add missing plugin entry points whenever possible. It might sound impossible but it is still a north star for us.

厳密な代用品はありませんが、このような状況では(Unityだけでなく、すべての.NETコードに当てはまります)、System.Reflection、ILポスト処理アセンブリなどで回避することができますし、最近の[UnsafeAccessorAttribute]のような新しい安全でない回避ツールボックスもあります。 しかし、最良の代替案は、ユーザーとの緊密なコラボレーション、良いフィードバックループ、そして可能な限り不足しているプラグインのエントリーポイントを追加する方法を持つことだと思います。 不可能に聞こえるかもしれませんが、それでも私たちにとっては北極星です。

.NET8が利用できるようになればわりとUnsafeAccessorAttributeは候補に上がってくるのかなと思ってます。

まあ.asmref程はユーザーにとって使いやすく安全なものではないですが、早くMono脱却してほしい気持ちの方がはるかに大きいです。

【Unity】RiderのBreakpointにてEvaluate and logが出力に表示されないときの対処法(Breakpoint Trace message outputを設定する必要あり)

はじめに

RiderにはBreakpointにてEvaluate and log(評価して記録)というブレークポイントに到達したときに値の評価しログ出力できる機能があります。

Evaluate and log

JetBrains Rider では、任意の行、メソッド、例外ブレークポイントをトレースポイントに変換できます。トレースポイントメッセージは、デバッグ出力(デバッグウィンドウのデバッグ出力タブ)に記録されます。

pleiades.io

ただ最近のRider(例. Rider 2024.1.5)だと何故かこれがログに表示されないという現象が起きるみたいです。その対処法を書いていきたいと思います。

前提

Riderにはデバッグウィンドウとは別にUnityウィンドウが存在します。

デバッグウィンドウとUnityウィンドウ

pleiades.io
pleiades.io

Unityのログ(例えばDebug.Logの出力)はこちらに表示されるみたいですね。

対処法

環境設定のBreakpoint Trace message outputUnity log or Debug Consoleのどっちかを選ぶことができ、選んだらそこにログが出力されるようになります。

Breakpoint Trace message outputを設定する
それぞれにログが出力されている様子

またデフォルトがDebbuger console and Unity logだったのですがその状態だとログ出力されず、一度別の設定にしてから戻すと両方ともログ出力される謎挙動してました。多分バグな気がします...。

【Unity】UI ToolkitでWindow画面いっぱいに表示ができないときの対処法(Unityが自動生成するTemplateContainerの影響)

はじめに

今回はUI Toolkitで画面いっぱいに何かを表示したいときの方法を紹介したいと思います。

画面いっぱいに表示している様子

UI Builderと実際の見た目が異なる

BackgroudColorを赤色に設定したVisual Elementを配置し、flex-grow1に設定してあげるとUIBuilderのWindowいっぱいに表示されます。

UI Builder上の表示

ただしUnityエディタ上で実際にWindowを立ち上げても、何故かWindowいっぱいに表示されません。

実際の見た目が異なる

原因

Unityが自動生成するTempalteContainerが原因です。TemplateContainerは子のサイズに応じて自動でリサイズします。

UI Toolkit Debuggerで調べた様子

UI Toolkit Debuggerで見てみると、Visual ElementTempateContainerの大きさまでしか広げることができず、TemplateContainerがWindowいっぱいには広がっていません。

またこれは覚えてほしい機能一位なのですが、UI Toolkit Debuggerstyleの変更ができます。TemplateContainerflex-grow1に変更すると画面いっぱいに赤色になります。

TemplateContainerのflex-growを1に変更

対処法

コード上でflex-growの設定をしたい場合は、rootVisualElementから設定してあげると良いでしょう。

public void CreateGUI()
{
    // TemplateContainerのflexGrowを1に設定する
    rootVisualElement.Children().First().style.flexGrow = 1;
    
    // ...
}

【C#】Incremental SourceGeneratorを利用した爆速Enumユーティリティライブラリを作成した話

はじめに

先日、SourceGeneratorを利用したパフォーマンスの良いEnumユーティリティを提供するライブラリを公開しました。
github.com

記事の後半で詳しく紹介しますが、パフォーマンスが良いと知られていたFastEnumよりも高速に動作しゼロアロケーションを達成しています。
GitHub - xin9le/FastEnum: The world fastest enum utilities for C#/.NET

パフォーマンス比較

今回はこのライブラリの実装についてツラツラと書いていきたいと思います。

背景

まず前提として、.NETに用意されているAPIは遅いです。

public enum Weather
{
    Sun,
    Cloud,
    Rain,
    Snow
}
// Sun,Cloud,Rain,Snow
Weather[] values = Enum.GetValues<Weather>();

// Sun,Cloud,Rain,Snow
string[] names = Enum.GetNames<Weather>();

// Rain
string? name = Enum.GetName(Weather.Rain);

// Cloud
string toString = Weather.Cloud.ToString();

// True
bool defined = Enum.IsDefined(Weather.Sun);

// Sun
Weather parse = Enum.Parse<Weather>("Sun");

// True
// Sun
bool tryParse = Enum.TryParse<Weather>("Sun", out Weather value);

対してxin9leさんのFastEnumでは、Static Type Cachingという手法を用いてキャッシュを行うことでパフォーマンス向上を狙っています。

// Sun,Cloud,Rain,Snow
IReadOnlyList<Weather> values = FastEnum.GetValues<Weather>();

// Sun,Cloud,Rain,Snow
IReadOnlyList<string> names = FastEnum.GetNames<Weather>();

// Rain
string? name = FastEnum.GetName(Weather.Rain);

// Cloud
string toString = Weather.Cloud.FastToString();

// True
bool defined = FastEnum.IsDefined(Weather.Sun);

// Sun
Weather parse = FastEnum.Parse<Weather>("Sun");

// True
// Sun
bool tryParse = FastEnum.TryParse<Weather>("Sun", out Weather value);

github.com
www.hanachiru-blog.com

以下はFastEnumのReadmeに記載されているパフォーマンス比較で、かなり処理速度が向上していることが伺えます。またゼロアロケーションです。

FastEnumのReadmeより引用

一応補足しておきますが、APIの返り値の型が完全に一致しているわけではないですし、Static Type Cachingの性質上一度メモリ上に確保されてしまうとアプリケーションを終了するまで占有しつづけてしまうといったデメリットも存在することはご留意ください。

ただ実用する上ではFastEnumを使ったとしてもそこまで問題にならないケースが殆どだと思いますし、積極的にFastEnumを利用していくと良いと思います。

RapidEnum

私が今回作成したRapidEnumIncremental SourceGeneratorを用いてコンパイルのタイミングでenumの解析を行い、コード生成を行います。

// [RapidEnum]という属性をpublic or internalなenumに付与してあげると...
[RapidEnum]
public enum Weather
{
    Sun,
    Cloud,
    Rain,
    Snow
}

// ↓のコードがコンパイルのタイミングで自動生成される
public static partial class WeatherEnumExtensions
{
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static string ToStringFast(this global::Sample.Weather value)
  {
      return value switch
      {
          Sample.Weather.Sun => nameof(Sample.Weather.Sun),
          Sample.Weather.Cloud => nameof(Sample.Weather.Cloud),
          Sample.Weather.Rain => nameof(Sample.Weather.Rain),
          Sample.Weather.Snow => nameof(Sample.Weather.Snow),
          _ => value.ToString()
      };
  }
  
  private static readonly ReadOnlyCollection<global::Sample.Weather> CacheValues = new ReadOnlyCollection<global::Sample.Weather>(new[]
  {
      Sample.Weather.Sun,
      Sample.Weather.Cloud,
      Sample.Weather.Rain,
      Sample.Weather.Snow,
  });
  
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static IReadOnlyList<global::Sample.Weather> GetValues() => CacheValues;
  
 ...
}
// 利用する時は生成されたコードを利用する
// Sun,Cloud,Rain,Snow
IReadOnlyList<Weather> values = WeatherEnumExtensions.GetValues();

生成されたコードを見ると高速な理由が分かると思いますが、ただのswitchがあるだけです。あとはReadOnlyCollectionをキャッシュしておいて、それを返すだけ。めっちゃシンプルです。

これは今までランタイムでenumの解析を行っていたのを、コンパイルのタイミングに解析のタイミングをずらしたことによって得られた恩恵です。

利用する側に定義をする

enum[RapidEnum]を利用すると定義側にユーティリティを作成するのに対し、[RapidEnumWithType]を用いることで利用側にユーティリティを定義することができます。これによって、サードパーティ製のライブラリに定義されているenumなどの任意のenumに対して利用可能です。

// System.DateTimeKind has Unspecified, Utc, Local
// public static partial class  or  internal static partial classのどちらかに[RapidEnumWithType(対象enumの型)]を付与する
[RapidEnumWithType(typeof(DateTimeKind))]
public static partial class DateTimeKindEnumExtensions
{
}
// Unspecified,Utc,Local
IReadOnlyList<DateTimeKind> values = DateTimeKindEnumExtensions.GetValues();

[RapidEnum][RapidEnumWithType]の違いは、定義側・利用者側のどちらにユーティリティを定義するかの違いです。パフォーマンスに差があるわけではありません。

結果

.NET APIよりも約十倍 ~ 数万倍パフォーマンスが良いという結果が得られました。またFastEnumより高速です。

パフォーマンス比較

考察

パフォーマンスが良いと書きましたが、RapidEnumには色々と制約があるので一概に比較できるものではないことは補足させてください。

まずFastEnumと異なり、publicinternalenumにしか適応できないです。というのもあくまでISGによりユーティリティクラスが生えてくるので、そのクラスから利用できない型にはアクセスできないのが理由です。

また属性をつけないといけないめんどくささがあるのと、生成されるクラス名([RapidEnumWithType]は自分で設定したクラス名が適応)が分かりづらいという点もつらいです。

// クラス名は一応自由に設定できる
[RapidEnumWithType(typeof(DateTimeKind))]
public static partial class HogeHoge
{
}
// Unspecified,Utc,Local
IReadOnlyList<DateTimeKind> values = HogeHoge.GetValues();

今後の展望

実際に使うとやはり属性を付与するのがめんどくさいですよね...。

SourceGeneratorなので一定仕方がない面があるのですが、CySharpさんのConsoleAppFrameworkはメソッドのシグネチャを見てコード生成を行う大胆な手法を取り入れています。
neue.cc

つまりメソッドが定義されているからそれを呼ぶのではなく、メソッドを利用しようとするとそのメソッドが生えてくるという世界観です。

// Genericを見てコード生成を行う案
public enum Hoge 
{
    A,
    B
}
_ = RapidEnum<Hoge>.Hoge.GetNames();

// ↓が生成される
public static partial class RapidEnum<T>
{
    public static partial class Hoge
    {
        public static IReadOnlyList<string> GetNames()
        {
            // 処理を行う
        }
    }
} 

genericを利用するのでバイナリサイズに気をつけないといけないのは勿論ですが、Nested Classesを利用することでパフォーマンスを保ちます。

一応static type cachingを利用すれば一つのclassに集約できそうな気もしているのですが、うまくいけるかは自信がないです...(チャレンジしてみたいですが)

構想自体はあるのですが、結局はインテリセンスが快適に動作するのかあたりが焦点だと思います。また勝手にコード生成されるのは如何なものかという意見もあるのは重々承知してます。(それは絶対やめておけみたいな意見もちょこちょこ聞いています)

ひとまずR&D的な意味合いで、experimental機能として実装してみたいという温度感ですね。

【C#】MessagePack-CSharpでCustom Formatterを定義して、独自の型やサードパーティー製ライブラリに含まれる型に対応する方法

概要

Custom Formatterを作成することで、独自に定義した型・サードパーティ製ライブラリで定義された型などのMessagePack-CSharpが対応していない型に対してシリアライズ・デシリアライズ処理を記述することで対応させることができます。

環境

MessagePack v2.5.172

やり方

今回は分かりやすいようにサンプルとして、Unity.Mathematicsに定義されているint3を真似した型で実験しようと思います。

// nullチェック周りの解説も入れたいのでstructではなくclassにしてます
public class Int3
{
    public int X { get; }
    public int Y { get; }
    public int Z { get; }

    public Int3(int x, int y, int z)
    {
        X = x;
        Y = y;
        Z = z;
    }
}

Unity.Mathematics/src/Unity.Mathematics/int3.gen.cs at master · Unity-Technologies/Unity.Mathematics · GitHub

Custom Formatterの作成

まずは対象の型をジェネリクスで指定したIMessagePackFormatter<T>を実装したFormatterを定義します。

public class Int3Formatter : IMessagePackFormatter<Int3>
{
    public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options)
    {
        // どのようにシリアライズするか記述する
    }

    public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
    {
        // どのようにデシリアライズするか記述する
    }
}

シリアライズ・デシリアライズ処理を記述していきます。今回の場合は[x, y, z]のように配列で表現していきます。

public class Int3Formatter : IMessagePackFormatter<Int3>
{
    public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options)
    {
        // valueがnullのときはnilを書き込む
        if (value == null)
        {
            writer.WriteNil();
            return;
        }
        
        // 3つの要素を持つ配列を定義する
        writer.WriteArrayHeader(3);
        writer.WriteInt32(value.X);
        writer.WriteInt32(value.Y);
        writer.WriteInt32(value.Z);
        
        // System.Text.Jsonなんかでは「writer.WriteEndArray()」のような終了を表す記述が必要ですが、MessagePackでは必要ありません
        // MessagePackでは最初に要素数を指定する仕様になってます
    }

    public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
    {
        // Nilで読み取ることができたらnullを返す
        if (reader.TryReadNil())
        {
            return null;
        }

        // 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です
        // 内部的に「reader.Depth++」される
        // MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる
        options.Security.DepthStep(ref reader);
        Int3 int3;
        try
        {
            var count = reader.ReadArrayHeader();
            if (count != 3) throw new ArgumentException(count.ToString());
            var x = reader.ReadInt32();
            var y = reader.ReadInt32();
            var z = reader.ReadInt32();
            int3 = new Int3(x, y, z);
        }
        finally
        {
            // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す
            reader.Depth--;
        }

        return int3;
    }
}

まず注目してほしいのは配列の長さと要素を書き込んでいる箇所です。

// シリアライズ
writer.WriteArrayHeader(3);
writer.WriteInt32(value.X);
writer.WriteInt32(value.Y);
writer.WriteInt32(value.Z);
// デシリアライズ
var count = reader.ReadArrayHeader();
if (count != 3) throw new ArgumentException(count.ToString());
var x = reader.ReadInt32();
var y = reader.ReadInt32();
var z = reader.ReadInt32();

MessagePackのフォーマット仕様として、配列は最初に種類を記述 => 要素数を記述 => それぞれの要素を記述という流れになってます。それに従って、最初にArrayHeaderを記述して各Int32を書き込むという流れになってます。

fixarray stores an array whose length is upto 15 elements:
+--------+~~~~~~~~~~~~~~~~~+
|1001XXXX|    N objects    |
+--------+~~~~~~~~~~~~~~~~~+

array 16 stores an array whose length is upto (2^16)-1 elements:
+--------+--------+--------+~~~~~~~~~~~~~~~~~+
|  0xdc  |YYYYYYYY|YYYYYYYY|    N objects    |
+--------+--------+--------+~~~~~~~~~~~~~~~~~+

array 32 stores an array whose length is upto (2^32)-1 elements:
+--------+--------+--------+--------+--------+~~~~~~~~~~~~~~~~~+
|  0xdd  |ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ|    N objects    |
+--------+--------+--------+--------+--------+~~~~~~~~~~~~~~~~~+

where
* XXXX is a 4-bit unsigned integer which represents N
* YYYYYYYY_YYYYYYYY is a 16-bit big-endian unsigned integer which represents N
* ZZZZZZZZ_ZZZZZZZZ_ZZZZZZZZ_ZZZZZZZZ is a 32-bit big-endian unsigned integer which represents N
* N is the size of an array

msgpack/spec.md at master · msgpack/msgpack · GitHub


あとは一応null対策のために、nullのときはnilを書き込んでいます。

// シリアライズ
if (value == null)
{
    writer.WriteNil(); 
    return;
}
// デシリアライズ
if (reader.TryReadNil())
{
   return null;
}



おそらく一番謎に感じるのはoptions.Security.DepthStepの箇所だと思います。

// 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です
// 内部的に「reader.Depth++」される
// MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる
options.Security.DepthStep(ref reader);
Int3 int3;
try
{
    var count = reader.ReadArrayHeader();
    if (count != 3) throw new ArgumentException(count.ToString());
    var x = reader.ReadInt32();
    var y = reader.ReadInt32();
    var z = reader.ReadInt32();
    int3 = new Int3(x, y, z);
}
finally
{
    // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す
    reader.Depth--;
}

これはセキュリティのために記述されていて、内部実装を見れば意外とシンプルです。

デシリアライズを開始するタイミングでreader.Depthをインクリメントし、終わった時にデクリメントするように実装してあげます。そうすることで、悪意を持ったデータをデシリアライズしようとしてデシリアライズ処理が際限ないレベルで行われることを防ぎます。

まあ防ぐというよりは、StackOverflowとエラーを区別するようにするという意図もあるとは思いますが。

Resolverの作成

Int3Formatterを直接利用することもあるかとは思いますが、MessagePackSerializerを通して利用することが大半でしょう。

それを実現するためにはまずResolverを作成する必要があります。作り方としてはIFormatterResolverを実装した型を定義します。

public class Int3Resolver : IFormatterResolver
{
    public IMessagePackFormatter<T>? GetFormatter<T>()
    {
        // Tから判断して適切なFormatterを返す
        // 該当するFormatterがなければnullを返す
    }
}

パフォーマンスのためにResolverFormatterを使い回すようにしながら該当Formatterを返すようにしてあげます。

public class Int3Resolver : IFormatterResolver
{
    // Resolverを使い回すため用意
    public static Int3Resolver Instance = new();
    
    // Formatterを使い回すため用意
    private readonly Int3Formatter _int3Formatter = new();
    
    public IMessagePackFormatter<T>? GetFormatter<T>()
    {
        if (typeof(T) == typeof(Int3))
        {
            return (IMessagePackFormatter<T>)_int3Formatter;
        }

        return null;
    }
}
よりパフォーマンスを上げるために

GetFormatterのたびにtypeofが走るのはパフォーマンス的には微妙です。さらに改善するためにはStatic Type Cachingという手法を使います。
www.hanachiru-blog.com

public class Int3Resolver : IFormatterResolver
{
    // Resolverを使い回すため用意
    public static Int3Resolver Instance = new();
    
    public IMessagePackFormatter<T>? GetFormatter<T>()
    {
        return FormatterCache<T>.Formatter;
    }
    
    // Static Type Cachingによる高速化
    private static class FormatterCache<T>
    {
        // T型に対して必ず一度だけFormatterの設定が行われるようになる
        internal static readonly IMessagePackFormatter<T>? Formatter;

        static FormatterCache()
        {
            Formatter = (IMessagePackFormatter<T>?)Int3FormatterCacheHelper.CreateFormatter(typeof(T));
        }
    }
}

internal static class Int3FormatterCacheHelper
{
    public static object? CreateFormatter(Type t)
    {
        if (t == typeof(Int3))
        {
            return new Int3Formatter();
        }
        
        return null;
    }
}

MessagePackSerializerOptionsへの設定

MessagePackSerializer.SerializeMessagePackSerializer.Deserializeは引数に設定したMessagePackSerializerOptionsに指定したResolverが適応されます。また明示的に指定しない場合はMessagePackSerializer.DefaultOptionsが利用されています。

その前提でMessagePackSerializer.DefaultOptionsを書き換えてみましょう。

基本的にMessagePack-CSharpがデフォルトで用意してくれているResolverに自身が定義したResolverを追加したCompositeResolverをまず定義してあげます。

// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要
IFormatterResolver resolver = CompositeResolver.Create(
    Int3Resolver.Instance, 
    StandardResolver.Instance
    );

StandardResolver.Instanceがデフォルトで用意してくれているFormatterが入っているResolverです。その前に先ほど定義してあげたInt3Resolver.Instanceを挟んであげることで、独自の型かどうかチェックしてFormatterを返してくれるようになります。

それをもとにMessagePackSerializer.DefaultOptionsに適応してみます。

// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要
IFormatterResolver resolver = CompositeResolver.Create(
    Int3Resolver.Instance, 
    StandardResolver.Instance
    );

// グローバルな設定を書き換える
MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver(
      resolver
  );

これで設定はすべて完成です。

実験

今までの作業が正しく動作するのか確認してみましょう。

// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要
IFormatterResolver resolver = CompositeResolver.Create(
    Int3Resolver.Instance, 
        StandardResolver.Instance
    );

// グローバルな設定を書き換える
MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver(
      resolver
  );

var int3 = new Int3(1, 2, 3);
var serializedInt3 = MessagePackSerializer.Serialize(int3);


// 93, d2, 0, 0, 0, 1, d2, 0, 0, 0, 2, d2, 0, 0, 0, 3
Console.WriteLine(string.Join(", ", serializedInt3.Select(x => x.ToString("x"))));

var deserializedInt3 = MessagePackSerializer.Deserialize<Int3>(serializedInt3);

// 1, 2, 3
Console.WriteLine(deserializedInt3.X);
Console.WriteLine(deserializedInt3.Y);
Console.WriteLine(deserializedInt3.Z);

正しく標準出力されていれば成功です。

コード全文

using MessagePack;
using MessagePack.Formatters;
using MessagePack.Resolvers;

namespace Sample;

public class Program
{
    public static void Main(string[] args)
    {
        // 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要
        IFormatterResolver resolver = CompositeResolver.Create(
            Int3Resolver.Instance, 
                StandardResolver.Instance
            );
        
        // グローバルな設定を書き換える
        MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver(
              resolver
          );
        
        var int3 = new Int3(1, 2, 3);
        var serializedInt3 = MessagePackSerializer.Serialize(int3);
        
        
        // 93, d2, 0, 0, 0, 1, d2, 0, 0, 0, 2, d2, 0, 0, 0, 3
        Console.WriteLine(string.Join(", ", serializedInt3.Select(x => x.ToString("x"))));

        var deserializedInt3 = MessagePackSerializer.Deserialize<Int3>(serializedInt3);
        
        // 1, 2, 3
        Console.WriteLine(deserializedInt3.X);
        Console.WriteLine(deserializedInt3.Y);
        Console.WriteLine(deserializedInt3.Z);
    }
}

public class Int3Formatter : IMessagePackFormatter<Int3>
{
    public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options)
    {
        // valueがnullのときはnilを書き込む
        if (value == null)
        {
            writer.WriteNil();
            return;
        }
        
        // 3つの要素を持つ配列を定義する
        writer.WriteArrayHeader(3);
        writer.WriteInt32(value.X);
        writer.WriteInt32(value.Y);
        writer.WriteInt32(value.Z);
        
        // System.Text.Jsonなんかでは「writer.WriteEndArray()」のような終了を表す記述が必要ですが、MessagePackでは必要ありません
        // MessagePackでは最初に要素数を指定する仕様になってます
    }

    public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
    {
        // Nilで読み取ることができたらnullを返す
        if (reader.TryReadNil())
        {
            return null;
        }

        // 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です
        // 内部的に「reader.Depth++」される
        // MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる
        options.Security.DepthStep(ref reader);
        Int3 int3;
        try
        {
            var count = reader.ReadArrayHeader();
            if (count != 3) throw new ArgumentException(count.ToString());
            var x = reader.ReadInt32();
            var y = reader.ReadInt32();
            var z = reader.ReadInt32();
            int3 = new Int3(x, y, z);
        }
        finally
        {
            // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す
            reader.Depth--;
        }

        return int3;
    }
}

public class Int3Resolver : IFormatterResolver
{
    // Resolverを使い回すため用意
    public static Int3Resolver Instance = new();
    
    public IMessagePackFormatter<T>? GetFormatter<T>()
    {
        return FormatterCache<T>.Formatter;
    }
    
    // Static Type Cachingによる高速化
    private static class FormatterCache<T>
    {
        // T型に対して必ず一度だけFormatterの設定が行われるようになる
        internal static readonly IMessagePackFormatter<T>? Formatter;

        static FormatterCache()
        {
            Formatter = (IMessagePackFormatter<T>?)Int3FormatterCacheHelper.CreateFormatter(typeof(T));
        }
    }
}

internal static class Int3FormatterCacheHelper
{
    public static object? CreateFormatter(Type t)
    {
        if (t == typeof(Int3))
        {
            return new Int3Formatter();
        }
        
        return null;
    }
}