BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル C#のAPI設計のモデルとインターフェース

C#のAPI設計のモデルとインターフェース

原文(投稿日:2018/05/30)へのリンク

従来のMVC、MVP、MVVM、Web MVCの中で共通の要素はモデルです。ビュー、コントローラー、プレゼンターについて解説している記事はたくさんあります。しかし、モデルについてはほとんど取り上げられていません。この記事では、モデルを取り上げ、モデルをが実装する.NETのインターフェースについて解説します。

話を明確にするために、いくつかの言葉を定義します。もっと正確な定義はほかの記事で見られるでしょうが、この記事の目的は以下の定義で十分でしょう。

データモデル

データ(プロパティやコレクションなど)と振る舞いが合わさったオブジェクトやオブジェクトグラフ。この記事で主に扱うのがこのデータモデルです。

データトランスファーモデル(DTO)

DTOはプロパティとコレクションだけを含むオブジェクトまたはオブジェクトグラフ。DTOは振る舞いを持ちません。また、ほとんどの場合、イミュータブルではありません。

また、定義を若干歪めますが、コード生成で作成されたDTOでは、INotifyPropertyChangedのようなシンプルなインターフェースが使われることがあります。

オブジェクトグラフ

オブジェクトグラフは到達可能な子オブジェクトから構成されます。データモデルやDTOの場合、オブジェクトグラフは一方向のツリーに似た構造になります(循環グラフもありうるが、シリアライゼーションで問題になる)。

ドメインモデル

ドメインモデルは関連するデータモデルを表す、より高い次元の概念。

エンティティ

“エンティティ”という言葉には多くの定義があります。本質的には“データモデル”と同じ定義のものもあります。nHibernateとEntity Frameworkが人気になったので、データベースのテーブルと一対一にマッピングされるDTOを意味するようになりました。

この定義の場合、エンティティはカラムと列のマッピングを正確に説明する属性で飾られるようになります。データベースからの遅延読み込みもサポートします。

エンティティを拡張してデータモデルとしての役割を担わせることもできますが、エンティティから別のデータモデルやDTOにマッピングしてからビジネスロジックを適用するのが一般的です。

ビジネスエンティティ

ORMのエンティティと間違わないように。これはデータモデルの別の言い方です。

不変オブジェクト

不変オブジェクトはセッタープロパティやそのオブジェクトを変更するメソッドを持ちません。不変オブジェクトはデータモデルそのものではありません。しかし、静的なルックアップデータを表すために使われるかもしれません。変更できないので、複数のデータモデルで単一の不変オブジェクトを共有しても安全です。

データアクセスレイヤ(DAL)

この記事では、DALはサービスオブジェクト、リポジトリ、データベースの直接呼び出し、ウェブサービスの呼び出しを含みます。データストレージなど外部の依存物とのやりとりで使われます。

データモデルの特徴

真のデータモデルは確定的にテストできます。つまり、データモデルは確定的にテストできるデータ型から成り立っており、最終的にはプリミティブな型に行き着きます。この場合、データモデルは実行時に外部に依存してはならないということです。

最後の点は重要です。あるクラスがデータアクセスレイヤと結合しているなら、それはデータモデルではありません。IRepositoryを使ってコンパイルのときにそのクラスを“疎結合”にするとしても、外部依存に関連する実行時の問題は取りのぞけません。

データモデルが何であるか、何でないかを考えるとき、“生きているエンティティ”に注意する必要があります。遅延読み込みをサポートするため、ORMから取得されるエンティティはデータベースコンテキストに対する参照を持っています。これによって、非確定的な振る舞いの領域に引き戻されてしまいます。変更がコンテキストの状態とオブジェクトの作成され方に依存してしまうということです。

別の言い方をすれば、データモデルのすべてのメソッドは予測可能であり、自身のプロパティの値にだけ依存するべきだということです。

親と子オブジェクトの間のメッセージのやりとり

親オブジェクトと子オブジェクトは互いにやりとりをする必要があります。これが正しく行われないと、結合が強いコードが生まれ、理解しにくくなります。単純にするためには以下の3つのルールに従います。

  1. 親オブジェクトは子オブジェクトのプロパティやメソッドに直接アクセスする。
  2. 子オブジェクトはイベントを発行することによってのみ、親オブジェクトとやりとりする。
  3. 兄弟オブジェクトと直接やりとりするオブジェクトはない。共通の親を経由してやりとりする。

この設計の場合、いつでも親オブジェクトから子を離してテストできます。

データモデルに唯一必須の機能としてのバリデーション

データモデルに実装される可能性のある機能についてたくさん説明したいと思います。その前に、すべてのデータモデルが実装するべき機能について説明します。バリデーションです。

汚いデータに対処しないデータモデルを持つことはほどんど不可能です。モデルが、ファイルや外部アプリケーション、UIから生まれる場合、不正な値、一貫性のない値が入る余地が生まれます。特にUIは問題です。ユーザーはフォームをひとつのフィールドずつ埋めると思われているからです。

このような制約を前提にすると、コンストラクタやプロパティのセッターで例外は使えません。代わりに、バリデーションインターフェースを使います。チェックのタイミングを柔軟に決められます。

.NETはいくつかのバリデーションインターフェースを持っています。しかし、それぞれ、難しい点を抱えています。

IDataErrorInfo

IDataErrorInfoインターフェースは始めからありました。しかし、使いにくいため、非推奨のインターフェースです。次のようなプロパティを考えてみましょう。

string Error { get; }: このプロパティには次の3つの役割があります。

  • オブジェクトレベルのエラーをレポートする。
  • すべてのプロパティレベルのエラーをレポートする。
  • 空文字を返すことでエラーが起きていないことを示す。

string this[string columnName] { get; }: このインデクサプロパティはプロパティ固有のエラーを返します。

この場合、Errorプロパティの役割が大きすぎます。ひとつの文字列にあらゆることを詰め込んでいます。その結果、オブジェクトレベルのバリデーションエラーとプロパティレベルのバリデーションエラーの区別ができなくなります。オブジェクトレベルのエラーだけカバーするように定義しなおしたら、オブジェクト全体としてエラーがあるかどうかがわからなくなってしまうでしょう。

インデクサはどうでしょうか。どうやって呼び出したらいいでしょうか。オブジェクトをIDataErrorInfovariableにキャストすることでしかアクセスできません。次のようなコードを期待する人はほとんどいないでしょう。

var nameError = ((IDataErrorInfo)customer)["Name"];

UIフレームワークがこのインターフェースを要求するとしたら、より賢いバリデーションAPIを持つ基底クラスを作って、このインターフェースを実装しましょう。本当のバリデーション処理に接続できたら、 IDataErrorInfo があるという事実さえ無視できます。

INotifyDataErrorInfo

INotifyDataErrorInfoインターフェースについては2度説明します。まず、INotifyDataErrorInfoはどのように使われることを意図していたか、について説明します。そして、次に、私が考えるINotifyDataErrorInfoの使い方を説明します。

INotifyDataErrorInfoインターフェースはSilverlight 4の非同期バリデーションをサポートするように設計されていました。プロパティの変更がサービス呼び出しのトリガーになる、というのが基本的な考え方です。最終的にサービス呼び出しが完了して、エラーの状態が更新されます。

インターフェースが持つ唯一のプロパティがbool HasErrors { get; }です。このプロパティの実装の仕方についてガイダンスがありません。基本的な選択肢は2つあります。しかし、どちらもうまくいきません。

  • 非同期バリデーションが走るまでブロックする。これだとUIがハングする。
  • すぐにリターンする。これでは、呼び出しが非決定的になってします。非同期バリデーション要求が保留中かどうかわからない。

これを回避するために、EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;イベントが発生するたびにHasErrorsプロパティを更新します。しかし、“保存”ボタンに答えるかたちで、同期的にバリデーションのステータスのチェックをしようとする場合、このやり方はうまくいきません。

さらに、ErrorsChangedは理論的には、プロパティ変更で2回呼ばれます。変更されたときすぐに呼ばれるのと、非同期バリデーションが呼ばれたときです。この場合、HasErrorsが2つの状態を行き来するのでUIがおかしくなります。

そして、IEnumerable GetErrors(string propertyName);メソッドがあります。このメソッドはプロパティの検証に使われます。しかし、nullや空文字を渡して、オブジェクトレベルのバリデーションエラーを起こすことができます。

また、IEnumerable<ValidationResult>ではなくIEnumerableを返します。これは、C#の最初のバージョンのインターフェースを思わせます。

しかし、タイプセーフの欠如だけが問題ではありません。ドキュメントには次のように書いてあります。

このメソッドはIEnumerableを返しますが、これは、非同期バリデーションのルールが処理を完了するたびに変更されます。これによって、エラーが追加、削除、変更されたとき、バリデーションエンジンが自動でユーザーインターフェースのバリデーションのフィードバックを自動で更新できます。

もし、このメソッドがIObservableを返していたら、うまく動くでしょう。しかし、このシナリオでIEnumerableをうまく使うには、非同期バリデーションが完了するのを待つことです。しかし、そうするとUIがハングしてしまいます。

そして、カプセル化の問題があります。先に説明したように、データモデルは外部に依存しません。プロパティの変更はサービスの呼び出しを直接するべきではありません。クラスがテストしにくくなるからです。何かを非同期で検証する必要がある場合、コントローラー、プレゼンター、ビューモデルで行います。

使われるべきものとしてのINotifyDataErrorInfo

欠点はあるものの、INotifyDataErrorInfoは無視できないくらい、多くのUIフレームワークで使われています。幸い、互換性を壊すことなくINotifyDataErrorInfoの規約を再定義することができます。

HasErrorsプロパティはプロパティが変更されたタイミングで同期的に更新されます。もし、クラスがINotifyPropertyChangedを実装し、その値が更新されたら、PropertyChangedイベントが上がります。

ErrorsChangedイベントは、特定のプロパティの検証に成功した場合と失敗した場合に上がります。オブジェクトレベルのバリデーションが変わったら、ErrorsChangedを上げます。この場合、プロパティ名は空文字からnullにします。

この新しいモデルの場合、GetErrorsは、IEnumerable<ValidationResult>をサポートするコレクションクラスを返す必要があります。ValidationResultクラスがどのプロパティでバリデーション警告があったのかなど、有用な情報を提供します。“苗字か名前のどちらかが必要です”というようなえらーメッセージを表示したい場合に使えます。

属性ベースのバリデーション

常に適しているわけではないが、属性ベースのバリデーションでもできることは多いです。プロパティに対してValidationAttributeのサブクラスの属性を設定することで実現できます。次のような属性があります。

独自のバリデーションクラスを作るには、IsValidメソッドをオーバーライドします。普通、これは単一のプロパティのバリデーションで使われますが、 ValidationContext. を使えば、他のプロパティにもアクセスできます。

属性ベースのバリデーションのひとつの利点はASP.NET MVC/WebAPIのようなフレームワークがバリデーションインターフェースを実装するために使っているという点です。そして、宣言的なので、UIでバリデーションのロジックを共有できます

手続き的なバリデーションと属性ベースのバリデーションを混ぜる

理論的にはバリデーション属性を使えば何でもできます。しかし、普通のコードで手続き的にバリデーションした方が簡単な場合もあります。次のような場合はそうでしょう。

  • バリデーションのルールが複数のプロパティに関わっている。
  • バリデーションのルールが子オブジェクトに関わっている。
  • バリデーションルールが他のクラスやプロパティで再利用されない。

手続き的なバリデーションの欠点のひとつはサーバサイドでしか使えないことです。属性ベースのバリデーションのようにバリデーションロジックをUIと共有できません。

また、手続き的なバリデーションは共有されたインターフェースを使う必要があり、そのため、アプリケーションの残りの部分が、このバリデーションを実行するには一貫したやり方で実行する必要があります。

空のフォームの問題

ユーザーがレコードを作成するとき、必須のフィールドが未入力の場合に、空のフォームの問題が起きます。フォームが表示されたときにすベてのフィールドが赤くなっているのは見たくありません。

モデルにはこの問題を解くためのメソッドが必要です。

  • バリデーションを実行するメソッド: "required"のようなルールに基づいて、すべてのフィールドに対してバリデーションを実行する。
  • エラーをクリアするメソッド: オブジェクトからすべてのエラーを除去する。

このモデルの場合、モデルのオブジェクトはきれいな状態から始まります。ユーザーの表示される前に部分的に値が設定されている場合、ユーザーに表示する前にエラーをクリアする必要があります。

それぞれのフィールドがユーザーに触られるたびに、そのフィールドだけにバリデーション処理が走ります。そして、保存の処理の中で、モデル全体のチェックが行われます。ここではユーザーが触らなかったモデルのバリデーションも行われます。

理論的なバリデーションインターフェース

.NETが持つべきバリデーションインターフェースは次のようなものです。

Tortuga Anchorライブラリでこのインターフェースの実装を見ることができます。

IValidatableObject

IValidatableObject インターフェースについて説明しなければ、不注意というものでしょう。このインターフェースはIEnumerable<ValidationResult> Validate(ValidationContext validationContext)メソッドだけを定義します。

このメソッドが好きな理由はいくつかあります。オブジェクト全体のバリデーションを実行できるので、空のフォームの問題を解決できます。また、ValidationResultオブジェクトをリターンします。これは、生の文字列よりも好ましいです。

欠点はValidationContextオブジェクトを受け取る点です。これは誰も使い方を知らないクラスです。このクラスのプロパティは次の通りです。

  • DisplayName: バリデーション対象のメンバーの名前を取得、設定する。
  • Items: コンテキストに関連するkey/valueペアのディクショナリを取得する。
  • MemberName: バリデーション対象のメンバーの名前を取得、設定する。
  • ObjectInstance: バリデーションするオブジェクトを取得する。
  • ObjectType: バリデーションするオブジェクトの型を取得する。
  • ServiceContainer: バリデーションのサービスコンテナを取得する。

これらのプロパティの使い方についてのガイドはありません。例えば、いつMemberNameをセットするべきか、DisplayNameは実際何に使うのか、Itemsには何が保存され、バリデーションのどのタイミングでアクセスできるのか、わかりません。

ドキュメントには“IServiceProviderインターフェースを実装するサービスの中でカスタムのバリデーションを可能にする”と書いてあります。しかし、IServiceProvider.GetService(Type)メソッドがどのような型をサポートするべきか書いていないので、活用できません。

概して、ValidationContextクラスはあらゆることをしたいにも関わらず、API設計の悪さとドキュメント不足によって、何も達成できていないです。UIフレームワークでこのインターフェースを使っているものもないので、IValidatableObjectをサポートする理由はありません。

プロパティ変更の通知

多くのケースでプロパティ変更通知は有用ですが、ほとんどのケースでMVVMパターンを関連しています。INotifyPropertyChangedインターフェース経由で公開されているこれらの通知のしくみを使うことでモデルは連携するUIエレメントに対して、データの変更を通知できます。バックグラウンドプロセスでモデルを更新したり、ひとつのモデルを複数のビューで共有したりすることができます。

何も考えずにプロパティ変更通知を作るのなら、一番簡単なのはプロパティのセッターが実行されるたびにイベントをあげることです。これは動きはしますが、性能上、予期しない結果をもたらします。

上の例では、すべてのプロパティ変更通知でプロパティ名を持つだけの新しいオブジェクトを作成しています。通知を受けるリスナがない場合も、です。通知が頻繁に起きるのなら、不要なガベージコレクションを引き起こすかもしれません。これを避けるにはPropertyChangedEventArgsをキャッチする必要があります。

イベントが必ずしも必要ない場合も問題があります。値が変更されなかった場合も、理由なくスクリーンの再描画が実行されてしまいます。したがって、単純なチェックが必要です。

これを書くのはとてもうんざりします。“MVVMフレームワーク”はこのノイズを無くしてくれます。Getメソッド、Setメソッドが内部のディクショナリとともに状態を維持するのに使われます。この方法でPropertyChangedEventArgsのキャッチと値の変更のチェックができます。具体的な実装は多様ですが、多かれ少なかれ次に示すTortuga Anchorの例のようになります。

ただし、パフォーマンス上のコストがあります。内部のディクショナリにアクセスするのはフィールドにアクセスするよりも遅く、値のボキシングはPropertyChangedEventArgsをキャッチすることから得られるメリットを削いでしまいます。

サーバサイドのコードだけを書いているなら、“UIはないから、これらは必要ない”と考えるかもしれません。こう考えるのは、おそらく正しいでしょう。しかし、INotifyPropertyChangedインターフェースが複雑なコードを単純にする場合もあります。サーバサイドのエンジニアにはこれを使うという選択肢を検討してほしいと思っています。

INotifyPropertyChanging

INotifyPropertyChangedと対になるINotifyPropertyChangingは値が変更される前に上がるイベントです。以前の値を補足するために使われます。LINQ to SQLやEntity FrameworkのようなORMはこの情報を変更追跡の仕組みとして利用します。

ISupportInitialize / ISupportInitializeNotification

ISupportInitializeの目的は一時的にプロパティ/コレクションの変更通知、エラーのバリデーションなどを無効にすることです。これを使うためにはBeginInitbeforeを呼び出します。

EndInitを呼ぶと、単一の“すべてが変更された”というプロパティ変更通知が送信されます。プロパティ名が空またはnullのPropertyChangedEventArgsオブジェクトを使って完了します。

初期化完了の通知を受けたいなら、ISupportInitializeNotificationインターフェースはInitializedイベントとIsInitializedプロパティを追加します。

コレクションの変更通知

それぞれのプロパティの変更を知る必要があるのと同様、コレクションの変更も知る必要があります。これはINotifyCollectionChangedインターフェースによって実現されます。

残念ながら、 INotifyCollectionChangedはそれほど協力ではありません。理論的には、関連するCollectionChangedイベントはコレクションにオブジェクトが追加、削除されたことを通知してくれます。しかし、実際はWPFの設計の欠陥によってうまく動作しません。

INotifyCollectionChangedの最も有名な実装はObservableCollection<T>です。このクラスはそれぞれのアイテムが追加、削除されるたびに、別々のCollectionChangedイベントが発行されるように設計されています。WPFが作成されると、常にObservableCollection<T>を使うことが想定されます。WPFはNotifyCollectionChangedEventArgs.NewItemsがひとつ以上のアイテムを持っていることを想定していません。

この結果、コレクションクラスがWPFの設定で使われないことが完全に保証されない限り、一括アップデートをサポートしたINotifyCollectionChangedを誰も実装しません。

これを念頭に置くと、私の推奨は、カスタムのコレクションクラスを作ろうとしないことです。単に、ObservableCollection<T>ReadOnlyObservableCollection<T>をベースクラスとして使い、その上に追加の機能を乗せていくのがいいでしょう。

タイプセーフなコレクション変更イベント

使われない機能以外にも、INotifyCollectionChangedには問題があります。このインターフェースはタイプセーフではないという点です。型が問題の文脈の場合、アンセーフなキャストを行うか、そのような状況が起きないようなコードを書くかのどちらかです。この問題に対処するため、このインターフェースを次のように実装することを提案したいです。

これで、タイプセーブの問題を解決するだけでなく、NotifyCollectionChangedEventArgs.NewItemsの長さチェックの必要せいを削除することができます。

コレクション内のプロパティ変更通知

.NETの“足りないインターフェース”として、コレクション内のアイテムのプロパティの変更検知をする機能が挙げられます。例えば、OrderCollectionクラスがあったとします。そして、TotalPriceプロパティを画面に表示する必要があります。このプロパティを正確さを保つために、それぞれのアイテムの値段の変更を知る必要があります。

私はいつも、INotifyItemPropertyChangedインターフェースを公開するようにしています。これはPropertyChangedイベントをコレクションの中のオブジェクトから単一のItemPropertyChangedイベントへとリレーします。

これを動作させるには、Collectionはオブジェクトが追加、削除したタイミングで、イベントハンドラの登録、削除をする必要があります。

変更のトラッキングとアンドゥ

それほど使われていないものの、.NETにはオブジェクトの変更のトラッキングとアンドゥ機能を提供するインターフェースがあります。

変更のトラッキング

IChangeTrackingインターフェースは簡単に理解できるように見えます。オブジェクトの変更があったかどうかを示す、というインターフェースのように見えるのです。しかし、実際は少し理解するのが難しいです。

UIの視点から見ると、ユーザーが知りたいのは“このオブジェクト、またはそのいずれかの子オブジェクトが変更されたかどうか”です。

データストレージの視点から見るても、この質問の回答が知りたいです。しかし、子オブジェクトは対象外です。子オブジェクトは別に保存されるからです。

ドキュメントは役に立ちません。子オブジェクトが“オブジェクト’の内容に含まれるかどうかを定義していないからです。私は個人的には、IsChangedは子オブジェクトも含むので、データストレージ向けにIsChangedLocalプロパティを追加する、というやり方を好みます。

復帰可能な変更のトラッキング

IRevertableChangeTrackingインターフェースです。これは、RejectChangesメソッドを追加し、このメソッドで保留中の変更のアンドゥをします。ここでも、アンドゥをこのオブジェクトだけに適用するのか子オブジェクトにも適用するのか、という同じ論点が出てきます。

私は、RejectChangesはオブジェクトグラフを下まで辿り、すべての保留中の変更を拒否すると想定します。これは、コレクション型のプロパティがあると複雑になります。しかし、これをクラス内にカプセル化しておく方が、アドホックな解決策を構築するよりも良いでしょう。

変更可能なオブジェクト

IChangeTrackingとは違い、IEditableObjectはUIでのケースだけで使われます。特に、Ok/キャンセルを提供するダイアログとデータグリッドで使われます。

ダイアログを表示する前、または、データグリッドを変更モードに変える前に、オブジェクトのスナップショットを取るため、BeginEditをコールしなければなりません。そして、EndEditでスナップショットをクリアします。CancelEditはオブジェクトの状態を元に戻します。ほとんどのデータグリッドは自動的にこれらをコールしてくれます。

IEditableObjectIRevertableChangeTrackingがあるなら、IEditableObjectを第2レベルにして、ふたつのレベルのアンドゥを実装するのを推奨します。言い換えれば、RejectChangeは単純にCancelEditを呼び出し、その逆はありません。

プロパティ変更完了インターフェースの不足

“不足しているインターフェース”というテーマを続けていくと、ORM統合について多くの論点があります。IChangeTrackingを使用して、ORMに特定のレコードを保存する必要があるかどうかを伝えることができます。しかし、どのプロパティが変更されたかを示すインターフェイスはありません。つまり、ORMは変更されたフィールドを個別に追跡するか、すべてが変更されたとみなしてオブジェクト全体をデータベースに再送信する必要があります。

等価性、ハッシュコード、IEquatable

私が避けるべきだと思っている機能です。私たちの定義ではデータモデルは変更可能です。そうでなければ、これまで取り上げてきたインターフェースはどれも意味がなくなってしまいます。

問題は、GetHashCodeもEqualsも安全に実装できないということです。ディクショナリはハッシュコードが変わらないことを前提としています。したがって、そのオブジェクトが辞書キーとして機能することが判明した場合は、辞書を破棄します。

さらに、データモデルの等価性とは何を示しているのか、という問題があります。データベースのテーブルの同一の行を表しているということでしょうか。ふたつのオブジェクトのすべてのプロパティが一致するという意味でしょうか。この問いにどのように答えようと、チームのメンバの答えはそれぞれ異なるでしょう。

デフォルト以外の等価性やハッシュコードの実装が必要な場合は、IEqualityComparer<T>を作成することを検討してください。これは、データモデルの外のことなので、非標準的なことをしていることを理解しやすいです。

同様に、特殊なソートのためにはComparer<T>を提供することもできます。

ICloneable

ICloneableインターフェースを実装するべきでないというのはよく知られています。クローンがシャローなのかディープなのかを定義できないからです。

これはCloneメソッドを提供するべきではない、ということではありません。Cloneメソッドを提供したいなら、何をクローンするのかを明確にする必要があります。適切にShallowCloneDeepCloneを実装をするべきかもしれません。

結論

モデルは、アプリケーションのそのほかの部分の基礎になります。一貫性のない命名規約、機能の不足、正しくないインターフェースの実装のような欠陥を避けることに費やす時間は何度も報われます。

著者について

Jonathan Allenは、90年代後半に病院の経営情報システムのプロジェクトでAccessとExcelからエンタープライズソリューションへの移行を行うことからキャリアをスタートしました。その後、5年間を金融セクター向けの自動取引システムの開発に従事した後、ロボット倉庫のUIやガン研究ソフトウェアのミドルレイヤ、大規模な不動産保険企業のビッグデータなど、さまざまなプロジェクトでコンサルタントとして働いています。余暇 は、16世紀のマテリアルアートについて研究したり執筆したりしています。

この記事に星をつける

おすすめ度
スタイル

BT