Pull to refresh

Binary serialization in Unity3d

Reading time12 min
Views22K
Столкнулся с довольно-таки тривиальной проблемой. Сериализовать и десерилизовать данные.

Задача

Есть приложение, клиент-сервер. Клиент — Unity3d сервер PhotonServer. Есть модель, которая и на клиенте и на сервере должна быть эквивалентной. Требуется синхронизировать состояние модели и, возможно, дополнительные классы.

Решение

Protobuf

Самое логичное решение — это использовать бинарный протокол. В этом явный фаворит — ptotobuf (использовал proto-net 668). Он не поддерживает веб-сборку, но это допустимая жертва. Разметил требуемые классы. Проверяю. Все работает, небольшой размер и быстрый в работе. Шикарно. Но!

В один прекрасный момент Protobuf выплюнул екзепшен, мол, такой класс не найден. Как это?
Баг подробно с примером кода.

Начал различными способами решать эту проблему. Есть вариант скармливать Protobuf типы. Что уже не хорошо. Можно допустить достаточно много ошибок или забыть указать тот или иной тип. Более того, Protobuf не поддерживает многомерные массивы.

Как ни прискорбно, но Protobuf придется в сторону. К слову, однажды пытался использовать Protobuf в связке php и Unity. Со стороны php реализация Protobuf оказалась достаточно баганутой. В итоге в php и Unity использовал json. Это сработало, потому что между php и Unity ходили довольно-таки простые структуры данных.

Message pack

Сещуствует еще один примечательный сериализатор. Есть реализации на огромное количество языков. Замечательно. Решил попробовать. Примитивный тип сериализовал нормально. Размер 18 байт против моего 41 байта, против 19 байтов protobuf и против 44 байтов json. Отличный результат. В чем же хитрость? На официальном сайте есть пример, как он на самом деле все пакует. Вот ссылка.

Пример
[Serializable, ProtoContract()]
    public class TTT
    {
        [TDataMember, ProtoMember(1)]
        public string s = "compact";
        [TDataMember, ProtoMember(2)]
        public bool f = true;
        [TDataMember, ProtoMember(3)]
        public string s2 = "schema";
        [TDataMember, ProtoMember(4)]
        public short i = 0;

    }


Но сложный пример, который будет, далее не осилил message pack и protobuf.

Примеры ошибок.

Message pack
PlatformNotSupportedException: On-the-fly enum serializer generation is not supported in Unity iOS. Use pre-generated serializer instead.
MsgPack.Serialization.ReflectionSerializers.ReflectionSerializerHelper.CreateReflectionEnuMessagePackSerializer[State] (MsgPack.Serialization.SerializationContext context)
MsgPack.Serialization.MessagePackSerializer.CreateReflectionInternal[State] (MsgPack.Serialization.SerializationContext context)
MsgPack.Serialization.MessagePackSerializer.CreateReflectionInternal (MsgPack.Serialization.SerializationContext context, System.Type targetType)
MsgPack.Serialization.SerializationContext.GetSerializer (System.Type targetType, System.Object providerParameter)
MsgPack.Serialization.ReflectionSerializers.ReflectionSerializerHelper.GetMetadata (MsgPack.Serialization.SerializationContext context, System.Type targetType, System.Func`2[]& getters, System.Action`2[]& setters, System.Reflection.MemberInfo[]& memberInfos, MsgPack.Serialization.DataMemberContract[]& contracts, MsgPack.Serialization.IMessagePackSerializer[]& serializers)
MsgPack.Serialization.ReflectionSerializers.ReflectionObjectMessagePackSerializer`1[TestS+TestC]..ctor (MsgPack.Serialization.SerializationContext context)
MsgPack.Serialization.MessagePackSerializer.CreateReflectionInternal[TestC] (MsgPack.Serialization.SerializationContext context)
MsgPack.Serialization.SerializationContext.GetSerializer[TestC] (System.Object providerParameter)
MsgPack.Serialization.SerializationContext.GetSerializer[TestC] ()

/// [,] string
ArgumentException: 'System.String[,]' is not compatible for 'System.String[]'.
Parameter name: objectTree
MsgPack.Serialization.MessagePackSerializer`1[System.String[]].MsgPack.Serialization.IMessagePackSerializer.PackTo (MsgPack.Packer packer, System.Object objectTree)
MsgPack.Serialization.ReflectionSerializers.ReflectionObjectMessagePackSerializer`1[TestS+TestC].PackMemberValue (MsgPack.Packer packer, .TestC objectTree, Int32 index)
MsgPack.Serialization.ReflectionSerializers.ReflectionObjectMessagePackSerializer`1[TestS+TestC].PackToCore (MsgPack.Packer packer, .TestC objectTree)
MsgPack.Serialization.MessagePackSerializer`1[TestS+TestC].PackTo (MsgPack.Packer packer, .TestC objectTree)
MsgPack.Serialization.MessagePackSerializer`1[TestS+TestC].Pack (System.IO.Stream stream, .TestC objectTree)
/// etc
SerializationException: Non generic collection may contain only MessagePackObject type.
MsgPack.Serialization.DefaultSerializers.NonGenericEnumerableSerializerBase`1[T].PackToCore (MsgPack.Packer packer, .T objectTree)
MsgPack.Serialization.MessagePackSerializer`1[T].MsgPack.Serialization.IMessagePackSerializer.PackTo (MsgPack.Packer packer, System.Object objectTree)
MsgPack.Serialization.ReflectionSerializers.ReflectionCollectionSerializer`1[System.Collections.ArrayList].PackToCore (MsgPack.Packer packer, System.Collections.ArrayList objectTree)
MsgPack.Serialization.MessagePackSerializer`1[System.Collections.ArrayList].MsgPack.Serialization.IMessagePackSerializer.PackTo (MsgPack.Packer packer, System.Object objectTree)
MsgPack.Serialization.ReflectionSerializers.ReflectionObjectMessagePackSerializer`1[TestS+TestC].PackMemberValue (MsgPack.Packer packer, .TestC objectTree, Int32 index)
MsgPack.Serialization.ReflectionSerializers.ReflectionObjectMessagePackSerializer`1[TestS+TestC].PackToCore (MsgPack.Packer packer, .TestC objectTree)
MsgPack.Serialization.MessagePackSerializer`1[TestS+TestC].PackTo (MsgPack.Packer packer, .TestC objectTree)
MsgPack.Serialization.MessagePackSerializer`1[TestS+TestC].Pack (System.IO.Stream stream, .TestC objectTree)


Protobuf-net
NotSupportedException: Multi-dimension arrays are supported
ProtoBuf.Meta.MetaType.ResolveListTypes (ProtoBuf.Meta.TypeModel model, System.Type type, System.Type& itemType, System.Type& defaultType)
ProtoBuf.Meta.MetaType.ApplyDefaultBehaviour (Boolean isEnum, ProtoBuf.ProtoMemberAttribute normalizedAttribute)
ProtoBuf.Meta.MetaType.ApplyDefaultBehaviour ()
ProtoBuf.Meta.RuntimeTypeModel.FindOrAddAuto (System.Type type, Boolean demand, Boolean addWithContractOnly, Boolean addEvenIfAutoDisabled)
ProtoBuf.Meta.RuntimeTypeModel.GetKey (System.Type type, Boolean demand, Boolean getBaseKey)


Json

Итак, Protobuf не подходит. Что использовать? Json? Почему бы и нет. Тут вторая проблема: джейсон не умеет сериализовать поля типа интерфейс и абстрактные классы. Не беда, воспользовавшись гуглом нашел, как «научить» его делать это. В итоговом файле появились данные о типе поля и его данные (с указанием сборки, это важно; почему — написано далее). Но при десериализации это поле почему-то нул. Снова гуглю. Ведь если научил сериализовать, значит можно и десериализовать. Получается тот же костыль, что и с Protobuf. Такой вариант не подходит. Использовал сборку JSON .NET For Unity, который есть в ассетмаркете.

Итог: Json хорош для не сложных структур. Но когда есть поля типа абстрактный класс или интерфейс, с ним возникают проблемы.

XML

В любой вариации xml — достаточно громоздкий. Поэтому решил не рассматривать. Хоть и часть проекта на xml. Например, система локализации.

BinaryFormatter

Решил обратится к стандартным средствам. Разметил код, сериализуем. Success! Большой объем файла, правда, не есть хорошо. Не беда, пройдемся еще и компрессией. Использовал LZMA. Выиграл немного в размере, но проиграл по скорости работы. Допустимая жертва. Теперь сборки. Барабанная дробь. Веб не поддерживается, беда…

Теперь устроим обмен между клиентом и сервером. И… Очередной FAIL. Дело в том, что сборки у классов разные, хоть классы одни и те же. В юнити своя сборка на фотоне своя. Можно решить через костыльный способ. Забиндить сборки и вручную их переименовать, но сборка попадает в бинарный файл. Зачем она там нужна?

Решил что к этому способу вернусь, просмотрел еще парочку сериализаторов. Один из них Шарп сериализатор. Смог сериализовать поля типа интерфейс, но тоже прописывает сборку и не поддерживается в вебе. Тогда я решил сначала сформировать требования к сериализатору.

Тест примитивного типа
            TTT c = new TTT();
            TSerizalization serizalization = new TSerizalization();
            bytes = serizalization.Serizalize(c, true);
            System.IO.File.WriteAllBytes("d:\\s.dat", bytes);
            Debug.LogError("T complete " + bytes.Length );

            json = JsonConvert.SerializeObject(c);
            System.IO.File.WriteAllText("d:\\s.json", json);
            Debug.LogError("J complete " + json.Length);

            System.Runtime.Serialization.Formatters.Binary.BinaryFormatter formater = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
            formater.AssemblyFormat = System.Runtime.Serialization.Formatters.FormatterAssemblyStyle.Full;
            System.IO.MemoryStream mstream = new System.IO.MemoryStream();
            formater.Serialize(mstream, c);
            Debug.LogError("B complete " +  mstream.ToArray().Length);

            System.IO.File.WriteAllBytes("d:\\s2.dat", mstream.ToArray());

            mstream = new System.IO.MemoryStream();

            var serializer = MsgPack.Serialization.SerializationContext.Default.GetSerializer<TTT>();
            serializer.Pack(mstream, c);
            System.IO.File.WriteAllBytes("d:\\s3.dat", mstream.ToArray());
            Debug.LogError("M complete " +  mstream.ToArray().Length);

            mstream = new System.IO.MemoryStream();
            ProtoBuf.Serializer.Serialize<TTT>(mstream, c);
            
            System.IO.File.WriteAllBytes("d:\\s4.dat", mstream.ToArray());
            Debug.LogError("P complete " + mstream.ToArray().Length);


Требования к сериализатору


Требуемый сериализатор должен уметь:
  • Сериализовать в бинарный формат;
  • Сериализовать в небольшой размер;
  • Сериализовать классы используя конкретные сборки (при этом информация о сборке необязательно нужна в файле);
  • Сериализовать пользовательские классы;
  • Поддерживать массивы;
  • Работать в веб версией юнити;
  • Работать с фотоном.

Из тех сериализаторов, что я попробовал. Этим требованиям более менее соответствовал только Json и protobuf ранней версии.

Собственный сериализатор


Начал гуглить. Но безрезультатно. Что же делать?
Использовать стандартное решение — не очень хороший вариант. Большой размер. Проблемы с платформами.
Тогда я решил, под выше написанные требования написать собственный сериализатор. Почему бы и нет. Это оказалось гораздо эффективней, нежели в пустую разбирать и тестировать тот или иной сериализатор.

С чего лучше начать?

Как сохранить объекты и как их загрузить. В этом плане мне понравился подход protobuf-net 668. А именно — маркировать требуемые поля и свойства. Также маркировать и методы, которые будут вызваны до сериализации и после десериализации.

Карта

Для начала нужно сохранить карту. А именно — ключ и тип. Чтобы по этой карте можно было потом восстановить объект. Для стандартных типов значение < 0 и для пользовательских соответственно > 0. Размер ключа int16.

Map
public class TMap
 {
        public Dictionary<Type, short> StandartTypes { get; protected set; }
        public Dictionary<Type, short> DataBase { get; protected set; }
        public Dictionary<short, Type> DataBaseTags { get; protected set; }
...
}


Коллекции вынес в отдельные теги что бы логически разделить.

TData множество объектов

Теперь нужно достать все поля и свойства. Запаковать их в свою структуру, чтобы присвоить им теги.

Множество объектов
public class TData : TContainerBase
    {
        public object value;
        public List<TData> childrens = new List<TData>();
...
}


Поскольку могут быть массивы, в контейнер добавляю информацию о мере массива.

Описание базового контейнера
 public  class TContainerBase
    {
        public short Tag { get; protected set; }
        public int ArrayRank { get; protected set; }
        public List<int> ArrayDimension { get; protected set; }
...


Я специально не делил на контейнер типа коллекция и контейнер типа объект. Все данные представлены в виде множества. Что и есть массив, если объект значит ранг и размерность в нулях.

Теперь нужен контейнер, в который будет восстановлен сам объект.

Контейнер
public class TContainer : TContainerBase
    {
        public int Size { get; protected set; }
        public List<object> List { get; protected set; }
...


Далее. Со структурами данных завершено. Теперь нужно их заполнить.

Сначала карту типов и карту объекта. Для этого нужен класс, который все это объединит. Создаю дополнительно абстрактные классы для записи и считывания. Это на случай, если, например, понадобится добавить еще один формат. Тот же json или xml.

Чтение запись
public abstract class TReaderBase 
    {
        public abstract T Read<T>(byte[] bytes, Assembly assembly);
    }
public abstract class TWriterBase 
    {
        public abstract byte[] Write(TMap map, TData data);
    }


Возможно, потом у них появятся дополнительные методы писать в поток и читать из потока. Но пока это не нужно. Теперь все это склеим в один класс.

T Serialization
 public class TSerizalization
    {
        protected TMap map;

        protected TWriterBase writer;
        protected TReaderBase reader;
        
        public TSerizalization()
        {
            writer = new TBinaryWriter();
            reader = new TBinaryReader();
        }
         public virtual byte[] Serialize(object target, bool callBeforeSerializationMethods = false);
         public virtual T Deserialize<T>(byte[] bytes, Assembly assembly, bool callAfterDeserializationMethods = false);
         protected virtual TData Read(object obj)
}


Тест


Готово. Теперь перейдем к тестам. Осторожно много наборов данных.

Тест
public interface IClass
{
}
[System.Serializable ]
public class TestC : IClass
{
[To2dnd.TDataMember]
public int a = 10;
[To2dnd.TDataMember]
public int b = 12;
[To2dnd.TDataMember]
public string s= "Hello World";

[To2dnd.TDataMember]
public State state = State.Close;

[To2dnd.TDataMember]
public DateTime dt = new DateTime();

[To2dnd.TDataMember]
public Type type = typeof(IClass);

[To2dnd.TDataMember]
public string[,] arr = new string[,]
{
{"1111", "2222", "3333", "4444" },
{"aaaa", "bbbb", "cccc", "dddd" },
{"321", "32", "2qfs", "12f" }
};


[To2dnd.TDataMember]
public object classD = new TestC2();

[To2dnd.TDataMember]
public TestC1[] array1 = new TestC1[] { new TestC1(), new TestC2(), new TestC2() };

[To2dnd.TDataMember]
public ArrayList arr2 = new ArrayList( new string[]{ "list1", "list2" });


[To2dnd.TDataMember]
public List<string> list = new List<string>() { "list Item 1", "List Item 2" };

[To2dnd.TDataMember]
public Dictionary<string, int> dic = new Dictionary<string, int>()
{
{"one", 1},
{"two", 2},
{"three", 3},
{"four", 4}
};

[To2dnd.TDataMember]
public Hashtable ht = new Hashtable()
{
{"H one", 1},
{"H two", 2},
{"H three", 3},
{"H four", 4}
};

[To2dnd.TDataMember]
public SortedList<string, int> sl = new SortedList<string, int>()
{
{"S one", 1},
{"S two", 2},
{"S three", 3},
{"S four", 4}
};

[To2dnd.TDataMember]
public Dictionary<string, List<string>> dic3 = new Dictionary<string,List<string>>()
{
{">> 1", new List<string>(){"a1", "a2", "a3"} },
{">> 2", new List<string>(){"b1", "b2", "b3"} },
{">> 3", new List<string>(){"c1", "c2", "c3"} }
};
[To2dnd.TDataMember]
public List<List<string>> l = new List<List<string>>()
{
new List<string>(){"a1", "a2", "a3"},
new List<string>(){"b1", "b2", "b3"},
};



[ProtoMember(16)]
public Dictionary<string, Dictionary<string, string>> dic4 = new Dictionary<string, Dictionary<string, string>>()
{
{">> 1", new Dictionary<string, string>()
{
{ "a1", "a2"},
{ "a2", "a3"}
}
},
{">> 2", new Dictionary<string, string>()
{
{ "a1", "a2"},
{ "a2", "a3"}
}
}
};

[ProtoMember(17)]
public Dictionary<string, Dictionary<string, string>> Dic4 {get; protected set;}


[ProtoMember(18)]
public Dictionary<string, object> dic333 = new Dictionary<string, object>()
{
{":@", new List<string>(){"1", "2", "3"}},
{":@2", new TestC2()},
{":@222", "sff"}
};



public TestC()
{
Dic4 = new Dictionary<string,Dictionary<string,string>>()
{
{">> 1", new Dictionary<string, string>()
{
{ "a1", "a2"},
{ "a2", "a3"}
}
},
{">> 2", new Dictionary<string, string>()
{
{ "a1", "a2"},
{ "a2", "a3"}
}
}
};
}

}
[Serializable]
public class TestC1 : IClass
{
[To2dnd.TDataMember]
public float value1 = 10;
[To2dnd.TDataMember]
public float value2 = 12;
}


[Serializable ]
public class TestC2 : TestC1
{
[To2dnd.TDataMember]
public float a1 = 10;
[To2dnd.TDataMember]
public float b2 = 12;
[To2dnd.TDataMember]
public string str = "Class 1";

[To2dnd.TDataMember]
public State state = State.Close;

public TestC2()
{
}

[TAfterDeserialization]
public void After()
{
}

[TBeforeSerialization]
public void Before()
{
}
}

public class TestC33
{
[To2dnd.TDataMember]
public float b2 = 12;

[To2dnd.TDataMember]
public TestC2 tt = new TestC2();

[ To2dnd.TDataMember]
public TestC1[] array1 = new TestC1[] { new TestC1(), new TestC2(), new TestC2() };

[To2dnd.TDataMember]
public object classD = new TestC2();

[To2dnd.TDataMember]
public Type type = typeof(IClass);
}



Видео теста:



Итог


По объему файла, конечно, проигрывает Protobuf и messagepack. Ведь я сохраняю карту типов и не использую хитрые махинации с смещением битов или конвертации строк «byte[] bytes = Encoding.UTF7.GetBytes((string)data.value)». Это дополнительная нагрузка, возможно, потом расширю в виде вариативности. Протестировал обмен данными между фотоном и юнити. Работает как и ожидалось. Ведь я создаю тип относительно сборки, которая является параметром в методе Deserialize.

Готовые решения, которых так много в интернете, не подошли по требованиям. Поэтому пришлось изобрести велосипед. Который оправдал затраченное на него время. Его можно расширять улучшать.

Заключение


Если вы используете примитивные типы, то вам подойдет любой из рассмотренных сериализаторов. Для примитивов я все же предпочел бы Protobuf. Но для сложных типов данных готовые решения подойдут не всегда.

Ссылки


Protobuf
Unity3d Json
MSDN binary formatter
Message pack
Бечмарки
Tags:
Hubs:
Total votes 26: ↑17 and ↓9+8
Comments30

Articles