モデル永続化の課題
アプリケーションの実行時・終了時問わず、モデルの状態を保存しない日はありません。
その際、サーバーアプリケーションなどでは最終的にデータベースに保存するのが定石ですが、デスクトップアプリなどでは簡易的にファイルに保存することがポピュラーだと思います。
ファイルに保存する場合、インスタンスをファイル保存可能な形式(バイナリ, XML, JSON, etc...)にシリアライズすることになりますが、これがなかなか曲者です。
単純なプリミティブ型で表せるものであれば適当な既定のシリアライザを使えばいいですが、アプリケーション内のモデル全般となると簡単ではありません。デフォルトコンストラクタのないクラスや、privateのフィールド、public setterのないプロパティといった、既定のシリアライザでは対応しないものも扱います。特にDDDなんかをやっていると、publicのsetterはほとんど見かけなくなりしばしばこの問題に突き当たる気がします。
そんなとき、シリアライズ用のDTOを作るのは悪手です。DTOとモデルの変換は定型コードの大量生産になり、うんざりすることが目に見えています。リファクタリングでモデルが変われば変換コードでバグもでるでしょう。できればモデルのインスタンスをそのままシリアライズしたい。
今回のシリアライズ対象のクラス
最初にテスト用のクラスを示します。これをシリアライズ・デシリアライズして元の状態を復元できることが目標です。
class Serialized
{
private string field;
public string GetFieldProperty => this.field;
public string AutoProperty { get; set; }
public string AutoPropertyWithoutPublicSet { get; private set; }
public Serialized(string field)
{
this.field = field;
}
public void Set(string s)
{
this.AutoPropertyWithoutPublicSet = s.ToUpper();
}
}
テストデータとして下記初期化を行ったインスタンスを使います。
var s = new Serialized("FieldValue")
{
AutoProperty = "AutoPropertyValue"
};
s.Set("WithoutSetValue");
DataContract
なんとなく、不遇な扱いを受けている印象のあるDataContractですが、こういう需要にぴったりだったりします。MSDocsのシリアル化のガイドラインでも
使用する型のインスタンスを Web サービスで永続化させる、または使用する必要がある場合は、データ コントラクトのシリアル化 をサポートすることを検討してください。
とありますが、実際今回の需要にはぴったりです。
まずテストクラスを次のように書き換えます。
[DataContract]
class Serialized
{
[DataMember]
private string field;
public string GetFieldProperty => this.field;
[DataMember]
public string AutoProperty { get; set; }
[DataMember]
public string AutoPropertyWithoutPublicSet { get; private set; }
// 以下略
}
シリアライズしたいデータにのみDataMemberAttributeをつけます。GetFieldPropertyプロパティについては、フィールドのほうを直接シリアライズしているのでスルーします。
続いてDataContractSerializerを使ってシリアライズ
var serializer = new DataContractSerializer(typeof(Serialized));
serializer.WriteObject(anystream, s);
シリアライズ結果は
<Serialized xmlns="http://schemas.datacontract.org/2004/07/" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<AutoProperty>AutoPropertyValue</AutoProperty>
<AutoPropertyWithoutPublicSet>WITHOUTSETVALUE</AutoPropertyWithoutPublicSet>
<field>FieldValue</field>
</Serialized>
となりAttributeで設定した通りです。コードは省略しますが、デシリアライズもきちんと動作します。
- 長所
- .NET組み込み
- モデルのバージョンアップにも対応(データ コントラクトのバージョン管理)
- JSON形式も可(DataContractJsonSerializer)
- デシリアライズ時にコンストラクタが呼び出されない。DataContractではコンストラクタの代わりにFormatterServices.GetUninitializedObjectを使います。オブジェクトのライフサイクルとして、一度生成され、保存され、復元されるものがコンストラクタを再び呼ばないのはとても自然
- 短所
- モデルの変更が必要。とはいえドメインロジックやインタフェースに影響する変更じゃないので許容範囲か
- 特定の技術にモデルが依存する。とはいえドメインロジックやインタフェースに影響する変更じゃないので許容範囲か
- フィールド・プロパティを追加したときにうっかりDataMemberAttributeを付け忘れるとシリアライズされない。当然テストで見つけてください
Json.NET その①
DataContractでできることはJson.NETでもだいたいできます(それがDataContract不遇の理由だったりして…)。まずクラスを書き換えます。
[JsonObject]
class Serialized
{
[JsonProperty]
private string field;
[JsonIgnore]
public string GetFieldProperty => this.field;
[JsonProperty]
public string AutoProperty { get; set; }
[JsonProperty]
public string AutoPropertyWithoutPublicSet { get; private set; }
// 以下略
シリアライズ結果は
{
"field": "FieldValue",
"AutoProperty": "AutoPropertyValue",
"AutoPropertyWithoutPublicSet": "WITHOUTSETVALUE"
}
となります。JsonIgnoreAttributeをつける理由は、Json.NETがデフォルトでopt-outな挙動をするから。つまりpublicなプロパティは全部出力します。その挙動を変えたいときにはJsonObjectAttributeにMemberSerialization.OptInを渡しましょう(参考)。コードは省略しますが、デシリアライズもきちんと動作します。
- 長所
- ライブラリとして非常にポピュラー
- モデルのバージョンアップにも対応(JsonExtensionDataAttribute)
- 短所
- モデルの変更が必要。とはいえドメインロジックやインタフェースに影響する変更じゃないので許容範囲か
- 特定の技術にモデルが依存する。とはいえドメインロジックやインタフェースに影響する変更じゃないので許容範囲か
- フィールドを追加したときにうっかりJsonPropertyAttributeを付け忘れるとシリアライズされない。当然テストで見つけてください
- プロパティを追加したときにうっかりJsonIgnoreAttributeを付け忘れるとシリアライズされてしまう。でもこれはデシリアライズ時に無視されるだけなのであまり悪影響はないかも
- デシリアライズ時にコンストラクタが呼び出される。すべてのコンストラクタ引数をdefault値で呼び出します。なのでコンストラクタ内で引数nullチェックとかしてると例外が出ます。限定的な回避策としては、
- コンストラクタの引数名とプロパティ名を同じにしておく。そうすればデシリアライズ時に、コンストラクタ引数としてそのプロパティの値を渡してくれます。
- JsonConstructorAttributeをつけたprivateコンストラクタを作っておく。これはpublicにするとモデルのインタフェースが変わってしまうのでprivateにしておきます(参考)。
- Json.NETが内部でインスタンス生成する仕組みをoverride。これはあまり詳しく見てないですが、可能なようです
Json.NET その②
要は、インスタンスのデータを保存したいんですから、フィールドを全部シリアライズできればいいんですよ。自動実装プロパティにも、コンパイラが自動生成したフィールドがあるじゃないですか。
ということでテストクラスを書き換えます。
[Serializable]
class Serialized
// 以下略
SerializableAttributeをつけるだけです。あとはSerializerのほうで
var serializer = new Newtonsoft.Json.JsonSerializer
{
ContractResolver = new DefaultContractResolver
{
IgnoreSerializableAttribute = false,
},
};
IgnoreSerializableAttributeをfalseにします。シリアライズ結果がこちら。
{
"field": "FieldValue",
"<AutoProperty>k__BackingField": "AutoPropertyValue",
"<AutoPropertyWithoutPublicSet>k__BackingField": "WITHOUTSETVALUE"
}
コンパイラが自動生成したBackingFieldがきっちり出力されています。Reflectionを使うと実行時にこれらのフィールドが取得できちゃうんですね。それを出力すれば万事解決、フィールドをラップしているプロパティとか余計なことを考えなくて済む!
ちなみにこの方法はJson.NETのソースコードを読んでいて知った方法で、使用方法を説明するドキュメントは見つけられませんでした。一番近いのはMemberSerializationの"Fields"の説明だと思います。
コードは省略しますが、デシリアライズもきちんと動作します。
- 長所
- モデルの修正量が少ない。SerializableAttributeならそれほど不自然じゃないし特定技術に依存しているわけでもない
- モデルのフィールド・プロパティが増えてもAttributeとかの追記漏れがない
- 短所
- フィールド名やプロパティ名が変わると、モデルとシリアライズ結果に互換性がなくなる。前2つの方法では、DataMemberAttributeのName引数やJsonPropertyAttributeのPropertyName引数で、シリアライズ時のプロパティ名を変更し、名前変更の影響を吸収できます。(今回の方法でも、C# 7.3以降、自動実装プロパティのBackingFieldにAttributeをつけることで回避できますが、そこまでするなら前2つの方法でいいかなという気がします)
- 自動実装プロパティのBackingFieldの命名方法が変わるとモデルとシリアライズ結果に互換性がなくなる。これは完全にコンパイラ依存なので何も保証が効きません
- モデルのバージョンアップに対応できない
他のシリアライザ
その他シリアライザについてちょっと見た限りだとあまりいい方法はなさそうです。
- System.Text.Json: privateなフィールドを出力できない?
- System.Xml.Serialization.XmlSerializer: privateなフィールドを出力できない?
なにかわかったら追記します。
その他
- 他クラスのインスタンスをフィールド・プロパティとして持っている場合、今回のやり方は再帰的に適用されます。
- サードパーティのクラスを内部で使っていたらどうすればいいのか。それは諦めます。カプセル化されていて実装が隠蔽されている以上、シリアライズは不可能です。それこそがカプセル化だから。最低限復元可能にするためのデータを自前で保存するとかになると思います
番外編 Entity Framework + SQLite
設定値とかならまだしも、エンティティを保持するならDBしかないでしょ。ということで、デスクトップアプリでも安心して使えるSQLitを使う例。
詳細はEntity Frameworkのドキュメントに譲りますが、OnModelCreatingは次のような感じです。これでデータの保存・取り出しが行えるようになります。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var sBuilder = modelBuilder.Entity<Serialized>();
sBuilder
.Property<int>("shadowId");
sBuilder
.HasKey("shadowId");
sBuilder
.Property("field");
}
今回はIDをもたないモデルが例だったのでシャドウプロパティをPrimary Keyにしていますが、そこは適宜。
- 長所
- モデルを全く修正しなくていい
- トランザクション管理とかもできる
- 保存するファイルを開くとか閉じるとか余計なこと考えなくていい
- 短所
- 出力ファイルが可読な形式ではないので、人間がテキストエディタで開いて編集とかできない
- EFCoreの制限に悩まされるかもしれない
- 今回はたまたまフィールド名とコンストラクタ引数名が同じなのでいけましたが、違ったらだめだった。コンストラクタ指定はEFCore5.0時点で未実装(https://docs.microsoft.com/ja-jp/ef/core/modeling/constructors )
- パフォーマンスは要測定
まとめ
最初の要件を満たせる範囲だと3つ(+番外)方法が見つかりましたがどれも一長一短となりました。個人的にはDataContractがトータル悩むことが少なくなりそうだなと印象です。
DDDのリポジトリにファイルを使う場合、トランザクション管理とかどうすればいいのかな…というのが次の悩み(ファイルを使うこと自体が正しいかはありますが…)。黙ってSQLiteとEFCore使ったほうが楽かもしれない。
参考文献
変更履歴
2021/08/29 初校
2021/09/30 「番外編 EFCore + SQLite」追記