neuecc/MessagePack-CSharp の Union

neuecc/MessagePack-CSharp の Union (おそらく日本語では直和型)という機能は、抽象的な型のフィールド・プロパティのシリアライズ・デシリアライズにおいて、具体的な型情報を保持するための仕組みです。(別に MessagePack そのものにそういう機能があるわけではないのでご注意ください。)

具体的な型情報を保持するとはどういうことでしょうか。Unity の SerializeField を考えてみましょう。Unity やその他の多くのシリアライザーは、フィールドに代入されたオブジェクトの型は無視し、そのフィールドの型でシリアライズ・デシリアライズを行おうとします。つまり、型情報が保持されません。以下のコードを見てください。

SomeComponent.cs
using System;
using UnityEngine;
[Serializable]
public class BaseClass { }
[Serializable]
public class SubClass1 : BaseClass { }
[Serializable]
public class SubClass2 : BaseClass { }
public class SomeComponent : MonoBehaviour
{
[SerializeField]
BaseClass field;
void Start()
{
if (field != null)
{
print("Restored: " + field.GetType()); // => BaseClass
}
field = new SubClass1();
print("Assigned: " + field.GetType()); // => SubClass1
}
}

このコードを適当な GameObject に貼り付けて再生すると、何度再生しても Restored: BaseClass と出力されます。SubClass1 しか代入していないのにおかしいと思いませんか。

このように、Unity のシリアライザーは型情報を正しくシリアライズ・デシリアライズすることができません。neuecc/MessagePack-CSharp では Union という仕組みを使うことにより、シリアライズされた時の型に正しく戻すことができます。

Union をベースにすると何がうれしいのか

Union をベースに設計するとコードの冗長性が減り、メンテのしやすいコードになります。

Union を使わない場合1

とあるスマホゲームを作るチームにいたときに、リアルタイムバトルを行うためのコードを書いていたことがあります。リアルタイムでのバトルなのでいろんなメッセージが飛び交っていました。シリアライザにそのままメッセージを食べさせて楽をするために以下のようなオブジェクトを投げあっていました。

フィールドはどれか一つしか使われない前提で見てください。

public class BattleMessage
{
public Attack attack;
public Recover recover;
public Move move;
public UseSkill useSkill;
}
public class Attack { }
public class Recover { }
public class Move { }
public class UseSkill { }

使うときにはこうします。

BattleMessage msg = DeserializeMessage(receivedBytes);
if (msg.attack != null)
{
ProcessAttackMessage(msg.attack);
}
else if (msg.recover != null)
{
ProcessRecoverMessage(msg.recover);
}
...

受け取る部分や定義部分は自動生成すればいいとはいえ、ちょっとかっこ悪い感じがしますね。手書きだとミスが発生しそうです。

Union を使わない場合2

フィールドにどのタイプであるかを記すものを書いておき、それをもとにメッセージをハンドリングします。

public enum BattleMessageType
{
Attack,
Recover,
Move,
UseSkill,
}
public class BattleMessageContainer
{
public BattleMessageType type;
public BattleMessage message;
}
public class Attack { }
public class Recover { }
public class Move { }
public class UseSkill { }
BattleMessage msg = DeserializeMessage(receivedBytes);
switch (msg.type)
{
case Attack:
ProcessAttackMessage((Attack)msg.message);
break;
case Recover:
ProcessRecoverMessage((Recover)msg.message);
break;
...
}

こちらも自動生成を使わないとミスが発生しそうです。(ちなみに、Union は内部的にこれと似たようなことをしてくれています。)

Union を使う場合

では、Union を使うとどうなるでしょうか。

using MessagePack;
[Union(0, typeof(Attack))]
[Union(1, typeof(Recover))]
[Union(2, typeof(Move))]
[Union(3, typeof(UseSkill))]
[MessagePackObject]
public abstract class BattleMessage { }
[MessagePackObject]
public class Attack : BattleMessage { }
[MessagePackObject]
public class Recover : BattleMessage { }
[MessagePackObject]
public class Move : BattleMessage { }
[MessagePackObject]
public class UseSkill : BattleMessage { }
BattleMessage msg = MessagePackSerializer.Deserialize<BattleMessage>(receivedBytes);
if (msg is Attack)
{
ProcessAttackMessage((Attack)msg);
}
else if (msg is Recover)
{
ProcessRecoverMessage((Recover)msg);
}
...

あら……あまり変わらない……ですね……。

ちなみに型スイッチの使える C# だともう少し短くなります。

BattleMessage msg = MessagePackSerializer.Deserialize<BattleMessage>(receivedBytes);
switch (msg)
{
case Attack m:
ProcessAttackMessage(m);
break;
case Recover m:
ProcessRecoverMessage(m);
break;
...
}

ちなみに、以下のクラスとの対応付けの部分は、私は自動生成しています。

[Union(0, typeof(Attack))]
[Union(1, typeof(Recover))]
...

型安全なメッセージハンドリング

ここまでだと、Union を使っても、書き方が変わるだけであまりうれしくないと思うかもしれません。でもちょっと待ってください。正しい型でやってくるということは、GetType()で型を取ることができます。ということは、Dictionary か何かに Type とハンドラを突っ込んであげれば楽ができそうです。

もちろん、1番のやり方でもリフレクションを使えば同じようなことはできますがパフォーマンスが悪いでしょう。2番のやり方では BattleMessageTypeType の対応表を持っておけば同じことができるはずですが、Union の再実装と変わらない感じになってしまいます。

では、書いてみましょう。

var handlerDict = new Dictionary<Type, Action<????>>();
void ProcessMessage(BattleMessage msg)
{
handlerDict[msg.GetType()].Invoke(msg); // こんな感じ?でも型安全じゃないね……。
}

さて、型引数の第二項には何を書けばいいんでしょう。Action<BattleMessage>だと、ハンドラ側で具体的な型にキャストしなければならず、型安全ではなくなってしまいます。つまりこういうのを自分で書かないといけないということです。

void HandleAttackMessage(BattleMessage msg)
{
var attackMsg = (Attack)msg;
}

型安全性はできるかぎり確保したいところです。

誰が型を知っているのか

こういうときは、誰がコンパイル時に正しい型を知っているかを考えます。BattleMessage は実行時には中身を知っていますが、コンパイル時には知りません。でも、ハンドラ側は自分が扱うべき正しい型を知っています。つまり、ハンドラにどの型用のハンドラなのかを聞けばいいんです。

ここでは、ハンドラの集合を持っておき、それぞれのハンドラに対して、あなたはこの型扱えますか?というのを聞いて、扱えますよといった場合にはそのハンドラに対してメッセージを渡します。

まず、特定のメッセージの型を扱えるか教えてくれるハンドラです。

public interface IHandler
{
bool Accepts(BattleMessage msg);
void Handle(BattleMessage msg);
}
public class Handler<T> : IHandler where T: BattleMessage
{
readonly Action<T> internalHandler;
public Handler(Action<T> internalHandler)
{
this.internalHandler = internalHandler;
}
public bool Accepts(BattleMessage msg)
{
return mes is T;
}
public void Handle(BattleMessage msg)
{
internalHandler((T)mes);
}
}

使う側はこんな感じですね。

List<IHandler> handlers = new List<IHandler>();
void RegisterHandler(IHandler handler)
{
handlers.Add(handler);
}
void RegisterHandler<T>(Action<T> handler) where T : BattleMessage
{
handlers.Add(new Handler<T>(handler));
}
void ProcessMessage(BattleMessage mes)
{
foreach (var handler in handlers)
{
if (handler.Accepts(mes))
{
handler.Handle(mes);
}
}
}

これでジェネリクスを用いてボイラープレートを少なく、型安全に受け取ったメッセージを処理できるようになりました。

実際のコード

私はこれを発展させ、問い合わせを Dictionary にした MessageDispatcher というのを使っています。上記のもののほうが、基底型・派生型も正しく扱えますが、パフォーマンスではおそらく以下のもののほうが良いでしょう。(計ってないので推測です。そのうち計ります。)

MessageDispatcher.cs
using System;
using System.Collections.Generic;
public class MessageDispatcher<MessageBaseT>
{
interface IHandlerWrapper
{
void Dispatch(MessageBaseT message);
Delegate WrappedHandler { get; }
}
readonly Dictionary<Type, List<IHandlerWrapper>> handlerDict = new Dictionary<Type, List<IHandlerWrapper>>();
public void Dispatch(MessageBaseT message)
{
List<IHandlerWrapper> handlers;
if (handlerDict.TryGetValue(message.GetType(), out handlers))
{
handlers.ForEach(h => h.Dispatch(message));
}
}
public void AddListener<MessageT>(Action<MessageT> handler) where MessageT : MessageBaseT
{
var wrappedHandler = new HandlerWrapper<MessageT>(handler);
List<IHandlerWrapper> handlers;
if (handlerDict.TryGetValue(typeof(MessageT), out handlers))
{
handlerDict[typeof(MessageT)].Add(wrappedHandler);
}
else
{
handlerDict[typeof(MessageT)] = new List<IHandlerWrapper>() { wrappedHandler };
}
}
public void RemoveListener<MessageT>(Action<MessageT> handler) where MessageT : MessageBaseT
{
List<IHandlerWrapper> handlers;
if (handlerDict.TryGetValue(typeof(MessageT), out handlers))
{
handlerDict[typeof(MessageT)].RemoveAll(h => ReferenceEquals(h.WrappedHandler, handler));
}
}
class HandlerWrapper<MessageT> : IHandlerWrapper where MessageT : MessageBaseT
{
public readonly Action<MessageT> handler;
public Delegate WrappedHandler { get { return handler; } }
public HandlerWrapper(Action<MessageT> handler)
{
this.handler = handler;
}
public void Dispatch(MessageBaseT message)
{
handler((MessageT)message);
}
}
}

そういえば、もともとは neuecc/MessagePack-CSharp の Union の話でしたが、ずいぶん話がそれてしまいました……。Union によって型情報があるからこのようなことが可能になっています。ぜひ、素晴らしい Union ライフを!