Introduction

備忘録。
System.Runtime.Serialization.Formatters.Binary.BinaryFormatter を使った実装を Newtonsoft.Json (JSON.NET) に変更したプログラムを評価することがあった。
その際、逆シリアル化したオブジェクトの結果が変更前後で異なる事象に遭遇した。

問題の特定にかなりの時間を要したので残しておく。

What is happened?

実験コードは https://github.com/takuya-takeuchi/Demo/tree/master/Misc/29_JsonAndBinaryDeserialization に公開済み。

シリアル化・逆シリアル化する対象のクラスの実装がかなり特殊なのもあるが、下記のようなクラス。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[DataContract]
[Serializable]
internal sealed class Sample
{

#region Constructors

public Sample():
this(new KeyValuePair<string, string>())
{
}

public Sample(KeyValuePair<string, string> item)
{
this.Title = "";
this.Number = 0;
this.List = new List<KeyValuePair<string, string>> { item };
}

#endregion

#region Properties

[DataMember]
public string Title
{
get; set;
}

[DataMember]
public int Number
{
get; set;
}

[DataMember]
public List<KeyValuePair<string, string>> List
{
get; set;
}

#endregion

}

このクラスを

  • Newtonsoft.Json
  • System.Text.Json
  • System.Runtime.Serialization.Formatters.Binary.BinaryFormatter

でシリアル化し、逆シリアル化で復元すると結果が異なってしまった。
オブジェクトはこのように設定。

1
2
3
4
var obj = new Sample();
obj.Title = "Title";
obj.Number = 1024;
obj.List = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("Key", "Value") };

逆シリアル化すると、 Newtonsoft.Json で逆シリアル化した時のみ、List.Count2 になるのである。

調べると、Newtonsoft.Json は逆シリアル化の際、コンストラクタを使用して復元するのがデフォルトの挙動になっているのだが、コンストラクタが複数ある時、最も引数の多いコンストラクタが優先して使用されるという仕様になっているとのこと。

KeyValuePair<string, string> を引数に持つコンストラクタが選ばれたとして、 List の要素が余計に増えたのかはこれだけではわからない (List 同士が結合された?) が、こうした仕様の違いが、想定外の結果を生み出してしまった。

単純に System.Text.Json を使えば良かった、という話になりそうだが、プログラムが .NET Framework 4.8 で動いており、.NET に移行はできなかった。
何より、System.Runtime.Serialization.Formatters.Binary.BinaryFormatter の実装も共存する必要があったため、.NET への移植も難しい。
(.NET 7 でBinaryFormatter は廃止されてしまった 参考:SerializationFormat.Binary は廃止されました)

というわけで苦労した結果、下記のように Newtonsoft.Json の逆シリアル化の挙動を変更できることが分かった。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
internal sealed class UninitializedObjectResolver : DefaultContractResolver
{
protected override JsonObjectContract CreateObjectContract(Type objectType)
{
var contract = base.CreateObjectContract(objectType);

contract.DefaultCreatorNonPublic = true;
contract.DefaultCreator = () =>
{
return FormatterServices.GetUninitializedObject(objectType);
};

return contract;
}
}

var setting = new JsonSerializerSettings()
{
ContractResolver = new UninitializedObjectResolver()
};

JsonConvert.DeserializeObject<Sample>(jsonString, setting);

System.Runtime.Serialization.FormatterServices.GetUninitializedObject メソッドを使うことで、コンストラクタを呼び出さずにオブジェクトを生成するという回避策。

このおかげで List.Count1 になり、変更前後で挙動が一致するようになった。

Source Code

https://github.com/takuya-takeuchi/Demo/tree/master/Misc/29_JsonAndBinaryDeserialization