C#でインターフェースのプロパティをJSONにJsonDerivedTypeを使ってシリアライズ、デシリアライズする
TL;DR
C#でpublic record User(string Name, IUserEmail Email);
のようなインタフェースプロパティがあるクラスをJSONシリアライズ/デシリアライズする場合、 JsonDerivedType
属性を使うとできるようになります。(.NET 7以上)
背景:型で制限するプログラミングがしたい
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。最近、ビジネスの要件を効率的にプログラミングする方法について色々考えています。関数型で行うドメインモデリングについて書かれた、 Domain Modeling Made Functionalの本を最近読みました。この本はF#でどのようにドメインモデリングを行うかということが書かれているのですが、F#では判別共用体という、数多くの名前付きケースのうちのいずれかである可能性がある型を定義できます。
type Shape =
| Rectangle of width : float * length : float
| Circle of radius : float
| Prism of width : float * float * height : float
上のような型で、shape
はRectangle
、Circle
かPrism
のどれかの型で表されるというような表現ができます。このように、型を当てはめることにより、業務的に便利なことがあります。
C#を使う場合、そのまま同じ方法で書くことはできませんが、インタフェースを使って似た記述ができます。
例)認証済みメールと未認証メールの型を分ける
例えばメールアドレスを表す場合でも型にすることにより自然な形になります。ユーザーにメールアドレスがあり、認証をしないと使えない場合を考えてみましょう。
public record User(string Name, string Email, bool EmailVelified);
上記のようにメールが認証されたかをチェックできます。ただ、この方法だと、メールアドレスが入っていればメールを送る処理で、EmailVelified
がFalseなのにメールを送ってしまうようなバグを作ってしまう可能性があります。
これを型定義で解決する場合、以下のように、認証済メールと未認証メールで型を分けることによって対応可能です。
public interface IUserEmail;
public record UnverifiedEmail(string Value) : IUserEmail;
public record VerifiedEmail(string Value) : IUserEmail;
public record User(string Name, IUserEmail Email);
少し型は複雑になってしまうのですが、メールを送るためにVerifiedEmail
を必須の処理にするなどの処理を書きやすくなります。
private void SendEmail(VerifiedEmail email)
{
_testOutputHelper.WriteLine("sending email to " + email.Value + "...");
}
var user = new User("test", new UnverifiedEmail("test@example.com"));
var user2 = new User("test", new VerifiedEmail("test@example.com"));
if (user.Email is VerifiedEmail email)
{
SendEmail(email);
}
if (user2.Email is VerifiedEmail email2)
{
SendEmail(email2);
}
上記の内容は、上記のDomain Modeling Made Functionalの本を書いた、Scott Wlaschin氏のYoutubeでの講演の内容からアレンジしたものです。
講演を見るとわかるのですが、型で制限している場合は、間違ったコードを書くことすらできなくなるので、テストを書く必要すら無くなるということをScottさんは何度も強調していました。面白いですね。
そのような理由で、いろいろなところにこのようなインタフェースを使っていこうとしていたところ、JSONとして保存するのが面倒になるという問題があります。
今回のブログではインタフェースのプロパティをどのようにSystem.Text.Jsonを使用してシリアライズ、デシリアライズできたかを紹介します。
発生するエラー
IUserEmail
をプロパティとしたUser
クラスをSystem.Text.Jsonを使用してシリアライズ、デシリアライズしようとすると問題が発生します。
シリアライズ
[Fact]
public void SerializationSucceedsTest()
{
var test = new User("test", new UnverifiedEmail("test@example.com"));
var json = JsonSerializer.Serialize(test);
Assert.NotNull(json);
Assert.Equal("{\"Name\":\"test\",\"Email\":{\"Value\":\"test@example.com\"}}", json);
}
上記のように、シリアライズをトライすると、以下のようなエラーとなります。
Xunit.Sdk.EqualException
Assert.Equal() Failure: Strings differ
↓ (pos 24)
Expected: ···"me":"test","Email":{"Value":"test@example"···
Actual: "{"Name":"test","Email":{}}"
↑ (pos 24)
at Sekiban.Test.CosmosDb.Serializations.UserEmailTest.SerializationSucceedsTest() in
インタフェース自体にはValueプロパティがないため、Email
プロパティの内容が空になってしまいます。
デシリアライズ
[Fact]
public void DeserializationNotThrowsTest()
{
var test = JsonSerializer.Deserialize<User>("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}");
Assert.NotNull(test);
Assert.Equal(new User("test", new UnverifiedEmail("test@example.com")), test);
}
上記のように、デシリアライズをトライすると、以下のようなエラーとなります。
System.NotSupportedException
Deserialization of interface types is not supported. Type 'Sekiban.Test.CosmosDb.Serializations.IUserEmail'. Path: $.Email | LineNumber: 0 | BytePositionInLine: 24.
インタフェースのデシリアライズはサポートしないという訳です。これを見ると、インタフェースのシリアライズ/デシリアライズは大変なのかなと思ってしまいます。
解決方法
調べてみると、比較的簡単に行える解決策を見つけました。Microsoftのドキュメントに以下の記事があります。
System.Text.Json で派生クラスのプロパティをシリアル化する方法
ここでは複数の対処法がありますが、今回の問題の場合、JsonDerivedType
属性を使用することによって解決できます。以下のように定義を調整します。
[JsonDerivedType(typeof(UnverifiedEmail), nameof(UnverifiedEmail))]
[JsonDerivedType(typeof(VerifiedEmail), nameof(VerifiedEmail))]
public interface IUserEmail;
public record UnverifiedEmail(string Value) : IUserEmail;
public record VerifiedEmail(string Value) : IUserEmail;
public record User(string Name, IUserEmail Email);
Derived
が「派生する」という意味があるため、IUserEmail
がUnverifiedEmail
とVerifiedEmail
に派生しますよと指定できます。
2つ目のパラメータは、JSONに記録する型情報をどうするかというものなので、クラスの名称を渡します。そうすることにより、JSONシリアライズするときに、型の中に$type
という付加情報を与え、デシリアライズする際の情報とします。
これによって、以下のテストコードが通るようになります。
[Fact]
public void SerializationSucceedsTest()
{
var test = new User("test", new UnverifiedEmail("test@example.com"));
var json = JsonSerializer.Serialize(test);
Assert.NotNull(json);
Assert.Equal("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}", json);
}
[Fact]
public void DeserializationNotThrowsTest()
{
var test = JsonSerializer.Deserialize<User>("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}");
Assert.NotNull(test);
Assert.Equal(new User("test", new UnverifiedEmail("test@example.com")), test);
}
生成されるJSONは以下のようになります。
{
"Name": "test",
"Email": {
"$type": "UnverifiedEmail",
"Value": "test@example.com"
}
}
{
"Name": "test",
"Email": {
"$type": "VerifiedEmail",
"Value": "test@example.com"
}
}
Email
オブジェクトの$type
に型情報が入るため、デシリアライズした時に正しい型へ戻すことができます。
この方法を使う際には、インタフェースの派生型全てを把握しておく必要があるという条件がありますが、ドメイン内の場合、派生型については把握していることがほとんどで多くのケースで有用に使えます。
このようにすることにより、F#とまではいきませんが、型の情報を指定したプログラミングをC#で行い、ビジネスロジックの安全性を高めたいと考えています。
Discussion