C#レコード型とJSON: データ構造の自由度と整合性
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。この記事は、私たちの作っているイベントソーシング、CQRSフレームワークSekibanの運用しながらわかった知見の1つについてまとめてみました。
JSONでイベントを保存すること利点
イベントソーシングでイベントを保存する時、多くの場合イベントオブジェクトをJSON化してデータベースに保存します。多くの場合はAzure Cosmos DB や AWS Dynamo DBにJSON型で保存します。そのため、JSONで保存できる形であればある程度自由なデータ構造のままイベントデータのモデリングを行うことができます。
最近、Postgres SQL をイベントストアとする機能も追加中です。Postgres SQLをイベントストアとするフレームワークも多いので、期待していたのですが、実際jsonbでJSONを保存できます。
PostgreSQLのJSONBデータタイプは、JSONデータをバイナリ形式で保存し、高速な検索と操作を可能にします。重複キーを排除し、キーの順序を保持しないため、データの整合性を保つことができます。また、JSONBは特定のキーの値を直接操作でき、GINインデックスをサポートしているため、高速なJSONデータの検索が可能です。
このように、JSONでのデータの永続化を使っていて、それによりイベントテーブルに関しては全ての種類のイベントを保存することができるようになり、それぞれのテーブル定義を別々に作成する必要がなくなります。
今回はそのようなJSONを使用した永続化をした時に問題となる、シリアライズとでシリアライズの整合性について扱います。
C# レコード型の特徴
C#のrecordは、C# 9.0から導入された新しい参照型で、不変性と値による等価性を持つ特性を持っています。
不変性とは、一度作成されたインスタンスの状態が変更できないという特性です。これは、データの安全性を保つために重要な特性で、一度設定された値が意図せずに変更されることを防ぎます。
値による等価性とは、同じ値を持つ異なるインスタンスが等しいとみなされるという特性です。これは、オブジェクトの内容が同じであるかどうかを比較する際に便利です。
また、recordはコンストラクタ、プロパティ、Equals、GetHashCode、ToStringなどのメソッドが自動的に生成されます。これにより、これらのメソッドを自分で実装する手間が省けます。
recordの特徴的な機能として、プライマリコンストラクタがあります。プライマリコンストラクタは、recordの定義部分で直接引数を取ることができるコンストラクタです。これにより、コンストラクタの引数とプロパティの定義を一度に行うことができます。
以下に、recordとプライマリコンストラクタの使用例を示します。
public record Person(string FirstName, string LastName, int Age);
このC#のコードは、新しいPersonレコード型を定義しています。Personレコード型は3つのプロパティを持っています:FirstName、LastName、そしてAge。これらのプロパティはすべてレコード型のプライマリコンストラクタの一部として定義されています。
C#レコード型のコンストラクタとJSONの相性
C#のrecordは、JSONとの相性が非常に良いです。recordは不変性を持つため、一度作成されたインスタンスの状態が変更されることはありません。これは、JSONとしてシリアライズされたデータをデシリアライズして再利用する際に、データの整合性を保つのに非常に役立ちます。
しかし、C#のrecordとSystem.Text.Jsonを組み合わせて使用する際には、いくつか注意点があります。
- コンストラクタとプロパティの名称のマッチが必要
こちらに関しては、プライマリコンストラクターで定義したRecordのフィールドに関しては自動的にプロパティの名前とリンクされますので、問題ありません。
- プライマリコンストラクタとプロパティの併用が可能
public record Person(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
}
上記のように、住所の情報だけがプロパティとして定義することも可能です。この場合でも
- Name
- Age
- Address
全ての項目をJSONにシリアライズ、デシリアライズ可能です。
通常にシリアライズ、デシリアライズする場合、recordはデータ型であるため、System.Text.JSONでは標準で対応しています。
実はrecord には2種類あります
- record struct で定義する値型
- record もしくは record class で定義する参照型レコード型
record structでは内部的に無名のコンストラクタが作成されますが、JSONの挙動としては違いはありません。
C#レコード型に追加項目がある場合 - 数値項目
JSONでデータを保存して再取得することにより、データを復元できますが、問題はJSONを保存した後にデータに追加項目がある場合です
public record Person(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
}
public record PersonV2(string Name, int Age, int Height)
{
public string Address { get; init; } = string.Empty;
}
上記の例ではクラス名を変えていますが、実際には保存後にHeight項目を追加する場合のことを想定しています。
項目を変更した時の挙動を確認するのに、以下のテストコードを記述します。
Person
レコード型で保存したjsonをPersonV2
でデシリアライズします。
この場合、追加したHeightにはデフォルトの値が入るので、その値が0でよければ大丈夫です。
[Fact]
public void Test3()
{
var person = new Person("John", 30) { Address = "123 Main St" };
var json = JsonSerializer.Serialize(person);
var person2 = JsonSerializer.Deserialize<PersonV2>(json);
Assert.NotNull(person2);
Assert.Equal("John", person2.Name);
Assert.Equal(30, person2.Age);
Assert.Equal("123 Main St", person2.Address);
Assert.Equal(0, person2.Height);
}
また、デフォルト値も入れることができます。
public record Person(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
}
public record PersonV2(string Name, int Age, int Height = 2)
{
public string Address { get; init; } = string.Empty;
}
public class UnitTest1
{
public void Test3()
{
var person = new Person("John", 30) { Address = "123 Main St" };
var json = JsonSerializer.Serialize(person);
var person2 = JsonSerializer.Deserialize<PersonV2>(json);
Assert.NotNull(person2);
Assert.Equal("John", person2.Name);
Assert.Equal(30, person2.Age);
Assert.Equal("123 Main St", person2.Address);
Assert.Equal(2, person2.Height);
}
}
PersonV2
の新しい項目にはデフォルト値を設定することも可能です。今回ではHeight=2を指定することにより、デシリアライズして取得したデータのHeight=2となっています。
ここに関しては、指定した通りにデータが作成されて良い感じです。
C#レコード型に追加項目がある場合 - 値オブジェクトなどのクラスが追加される場合
では追加機能が値オブジェクトのようなrecordやclassだった場合はいかがでしょうか?
public record Picture(string Url);
public record Person(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
}
public record PersonV3(string Name, int Age, Picture Picture)
{
public string Address { get; init; } = string.Empty;
}
public class UnitTest1
{
[Fact]
public void Test4()
{
var person = new Person("John", 30) { Address = "123 Main St" };
var json = JsonSerializer.Serialize(person);
var person2 = JsonSerializer.Deserialize<PersonV3>(json);
Assert.NotNull(person2);
Assert.Equal("John", person2.Name);
Assert.Equal(30, person2.Age);
Assert.Equal("123 Main St", person2.Address);
Assert.NotNull(person2.Picture);
// このオブジェクトがnullになってしまう!!!
}
}
上記の場合、Pictureは通常のrecordの定義ではnullにならない記述をしているのに、jsonから復元することでnullになってしまいます。
型の定義では想定していないnullが入ってくるということはこのデータを使うプログラムで非常に困ってしまいます。
型のパラメーターでデフォルトオブジェクトが定義できるでしょうか?
C# Recordのプライマリコンストラクタにデフォルト値を入れる場合、クラスコンストラクタなどのコード項目を入れられないという問題があります。
ではこれをどのように解決できるでしょうか?
対応策 - 追加項目に関してはプロパティを使用する
public record Picture(string Url);
public record Person(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
}
public record PersonV4(string Name, int Age)
{
public string Address { get; init; } = string.Empty;
public Picture Picture { get; init; } = new Picture("");
}
public class UnitTest1
{
[Fact]
public void Test5()
{
var person = new Person("John", 30) { Address = "123 Main St" };
var json = JsonSerializer.Serialize(person);
var person2 = JsonSerializer.Deserialize<PersonV4>(json);
Assert.NotNull(person2);
Assert.Equal("John", person2.Name);
Assert.Equal(30, person2.Age);
Assert.Equal("123 Main St", person2.Address);
Assert.NotNull(person2.Picture);
// 今回はnullにならずに、オブジェクトがjsonにない場合、デフォルトのオブジェクトが使われる
}
}
今回はPictureをrecordのコンストラクタではなく、プロパティとして定義することにより、オブジェクトがnullになることを防ぐことができました。
言語仕様的にプライマリコンストラクタで new Picture がつかえないのはいただけない気がするのですが、将来のアップデートに期待しつつ、今のところは、プロパティを使っていきたいと思います。
まとめ
このように、オブジェクトJSONで保存した時には型の変更には十分注意をして行なっていきたいと思います。型を更新せずに、別の型を使うというのも一案で、それによってバージョンアップに対応していくことができます。その方法もまた機会があればご紹介したいと思います。
Discussion