C#で型で状態を表しつつJsonで永続化をしたデータのデシリアライズに失敗する理由と解決方法
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。ここ数ヶ月、型で状態を表すドメインモデリングとそれをイベントソーシングで表現する方法について色々研究しています。
最近では「コンパイル時のユニットテスト」導入するとユニットテストを 書かなくてよくなるのか?
というタイトルで登壇したり、「貴重なデータ、捨ててませんか?~ OSSで始めるイベントソーシングのススメ」
というタイトルで登壇したりしました。
C#で型で状態を表すためには、JsonDerivedType
という属性を親インターフェースにつけることにより、そのインターフェースがどの型で実装されるかを定義することができます。
この方法によって、元の親のインターフェースでデータ型を定義して、その構造ごとJSONに保存して、JSONからデシリアライズも行うことができます。
C#でもこの形式で親の型をベースにしたデータをパターンマッチングを使用して動作を制限したり、各機能を親のインターフェースに対して実装するのではなく、継承型に対して動作を定義することによって、そもそも特定の状態の場合にしか特定の機能を実行できないように定義できます。
この方法が純粋な関数型言語のADTに劣る点としては、インターフェースへの継承が制限されていないため、パターンマッチをするときに完全性の検証ができない、すべてのデータが網羅されていることの確認ができないことです
例えばショッピングカートの例の場合
- 商品追加中ショッピングカート
- 購入確定済み未精算ショッピングカート
- 支払い済みショッピングカート
- 発送中ショッピングカート
- 受け取り済みショッピングカート
という状態ごとに型を変えることができます。
その場合にそれぞれの型ごとに行うことのできる機能を定義します。
- 商品追加中ショッピングカート
- カートに商品を追加
- カートから商品を削除
- 発送先、支払い先を指定
- 購入確定処理
- 購入確定済み未精算ショッピングカート
- クレジット精算処理
- 支払い済みショッピングカート
- 発送処理
- 発送中ショッピングカート
- キャンセル依頼
- 受け取り済みショッピングカート
- 返送用ラベル作成処理
このように各処理が間違えてできないように制限することにより、バグで間違えた処理を実行される可能性を減らしていくことができます。
私たちジェイテックジャパンではSekibanという、C#でイベントソーシングを簡単に行うことのできるオープンソースフレームワークを開発、運用しています。
最近PostgreSQLにも対応しました。PostgreSQLはローカル環境で簡単に実行できるので、開発時のデータベースとして最適です。PostgresにはJSONBカラム型というのがあり、JSONで保存したデータをバイナリ型で保存して検索性をよくする機能があります。
イベントソーシングでは、Eventsというテーブルに全種類のイベントを保存するため、各ペイロードをJSON型で保存することにより、フレキシブルにデータを入れることができます。そのために上記のJSONBカラムを使用していました。実際にはイベントソーシングでは、ペイロード内のデータを検索条件としてイベントを取得することはないのですが、便利で良い機能と思いましたので初期バージョンで使用しました。(これが後々バグを生み出すとはつゆ知らずでした。)
Postgresで発生したデシリアライズ問題。
Postgresでのイベントストアをリリースしたので、弊社で作成しているソリューションのローカル実行をPostgresに変更することによって、AzureのCosmosDBなどに繋ぎに行かなくて良いように変更して、テストをしていたのですが、実際にローカルにデータベースを置くことにより、ローカルの開発スピードも上がってとても満足でした。
たくさんの種類のイベントを追加して開発のテストをしていたら一つのエラーが発生しました。
Deserialization of interface types is not supported. Type 'Project.Domain.ValueObjects.Accumulations.IAccumulationTarget'.
デシリアライズに失敗したというものです。通常インターフェース型でデシリアライズできないのですが、今回に関しては、JsonDerivedType
の設定をしているので、デシリアライズ出来るはずです。
こちらは集計のターゲットをインターフェースとして定義しており、インターフェースとJsonDerivedType
を定義してドメインを定義しています。コードは以下のようになっています。
public record MonthlyAccumulationRegistered(
IAccumulationTarget AccumulationTarget,
YearMonth YearMonth) : IEventPayload<MonthlyAccumulation, MonthlyAccumulationRegistered>
{
...
}
[JsonDerivedType(typeof(BranchAccumulationTarget), nameof(BranchAccumulationTarget))]
[JsonDerivedType(typeof(CompanyAccumulationTarget), nameof(CompanyAccumulationTarget))]
public interface IAccumulationTarget
{
public string GetAccumulationTargetKey();
public string GetAccumulationTargetName();
public IArea GetArea();
}
public record CompanyAccumulationTarget(OtherDomainId OtherDomainId, CompanyId LogiCompanyId, CompanyName Name, IArea TargetArea)
: IAccumulationTarget
{
...
}
[JsonDerivedType(typeof(JapanesePrefecture), nameof(JapanesePrefecture))]
[JsonDerivedType(typeof(JapaneseZipCode), nameof(JapaneseZipCode))]
[JsonDerivedType(typeof(UnspecifiedArea), nameof(UnspecifiedArea))]
public interface IArea : ILocation
{
public string GetName();
public PrefectureValues? GetPrefectureValues();
}
public record JapanesePrefecture(
[property: Range(1, 47)]
[property: Required]
PrefectureValues Value) : IArea
{
...
}
MonthlyAccumulationRegistered
という型がJSONBで保存されているのですが、その中にIAccumulationTarget
型のプロパティがあり、それが、CompanyAccumulationTarget
という実態となっているのですが、その実態の中にさらにIArea
型のプロパティがあり、その実態がJapanesePrefecture
となっているわけです。
このようにモデリングすることによって、集計対象の種類を複数定義したり、またエリアに関しても郵便番号で定義したり、都道府県で定義したりできるわけです。
しかし、このデータがのデシリアライズに失敗したことで多くの疑問が生まれました。
-
JsonDerivedType
のネストがダメなのか - Jsonに日本語が入るなどのケースでデシリアライズできないケースがあるのか
- 型で状態を定義するプログラミングスタイルはやはりC#では実現不可能なのか
型で状態を定義して、インターフェースで型を定義してその実装を複数の実態型で定義して、そのデータをJSONでシリアライズ、デシリアライズすることによって複雑なドメインを型で表現するというこの1年取り組んできたことがうまく行かない可能性がよぎり、とても不安になりました。
ただ落ち着いて考え、CosmosDbでは動いていたはずと考え、色々テストコードを書いて検証して、無事に問題を発見しました。
Postgres のJSONBカラムとJsonDerivedTypeの相性問題
色々検証してわかったのは、 PostgresのJSONBカラムは、JSON保存時にデータを効率化するために、プロパティの並べ替えがなされる場合があるとのことです。
また、C#のSystem.Text.Json
のJsonDerivedType
は、クラス識別子を$type
というプロパティに保存するのですが、デシリアライズの仕様として、クラス識別子$typeはプロパティの最初になければいけないという仕様のようです。(ドキュメント内に出典を見つけたい)
以下のMicrosoft Learnのドキュメントに多相性のオブジェクトをJSONで永続化する方法について書いていますが、型識別子が最初にないといけないということは書いていない気がする。。。
つまり、いかのJSONはデシリアライズに失敗します。
{
"YearMonth": {
"Year": 2024,
"Month": 1
},
"AccumulationTarget": {
"Name": {
"Name": "株式会社 テスト会社名",
"NameKana": "テスト",
"ShortName": "テスト"
},
"$type": "CompanyAccumulationTarget",
"TargetArea": {
"$type": "JapaneseZipCode",
"Value": "2440004"
},
"CompanyId": {
"Value": "f7867c89-774d-3bcb-32c8-be17c173abb9"
},
"OtherDomainId": {
"Value": "f7867c89-774d-3bcb-32c8-be17c173abb9"
}
}
}
しかし、以下のものはデシリアライズに成功します。"$type"項目がクラスの最初に移動したのがわかると思います。
{
"YearMonth": {
"Year": 2024,
"Month": 1
},
"AccumulationTarget": {
"$type": "CompanyAccumulationTarget",
"Name": {
"Name": "株式会社 テスト会社名",
"NameKana": "テスト",
"ShortName": "テスト"
},
"TargetArea": {
"$type": "JapaneseZipCode",
"Value": "2440004"
},
"CompanyId": {
"Value": "f7867c89-774d-3bcb-32c8-be17c173abb9"
},
"OtherDomainId": {
"Value": "f7867c89-774d-3bcb-32c8-be17c173abb9"
}
}
}
おそらく大きなサイズのJSONクラスのデシリアライズ時に最初にクラス識別子がないと、パフォーマンス的に問題があるため、最初においていると思われます。
通常、System.Text.Json
でシリアライズしたオブジェクトをそのままデシリアライズするため、問題にならないと思われますが、今回Postgresに保存する際に、JSONBカラム内でプロパティの並べ替えが行われて、でシリアライズが失敗しました。
解決策
PostgresのJSONカラムは、受け取ったJSONをそのまま文字列として保存します。そのため、JSONBではなく、JSONカラムを使用することで、問題が起きなくなることを確認しました。
この機能により、SekibanでJsonDerivedType
を使用してインターフェースをJSONに永続化、復元を行うことができますので、Cosmos DB, Dynamo DB, PostgreSQLのそれぞれのデータベースで型を使用してドメインを表現して、イベントソーシングを表現することが可能となります。
まとめ
このように、インターフェースにJsonDerivedType
を指定して、型を制限することにより、ドメイン内で複雑なデータをシンプルに表現したり、状態の遷移により動作を切り替えるなどの機能を実装することができます。
今回、JsonDerivedType
を使用したでシリアライズに関して$type
型識別子が最初に置いておかないといけないという知見は、Postgres以外のケース、例えばフロントエンドからtype付きのデータを送るときなども有用な情報ですので覚えておくと良いと思いました。
引き続き効率的なプログラミングをどのように簡単に表現できるかについて色々探究していきたいと思います。
Discussion