本日、次のような状況を想定してみましょう。ログが1件あり、2件目を追加したがまだコミットしていない状態で、1件目を削除しようとすると、何が起こるでしょうか?
何とエラーが発生しました!削除したいPost.Idをバックエンドに送信しただけなのに、なぜこのようなエラーメッセージが出るのでしょうか?

これはC#の特性に関係しています。C#はオブジェクト指向プログラミング言語です。つまり、データやメソッドを含むあらゆるものをオブジェクトにできます。Blog、Postはそれぞれオブジェクトです。このような参照型だけでなく、単純なint、boolなどの基本型もあります。
(注:stringは分類上は参照型ですが、構文上は基本型のように扱われます。これは無数のstringによってメモリが溢れるのを防ぐためです。)
基本型の意味は、2つの基本型間の変更が互いに影響を与えないということです。変数int a = 0;を定義し、次にint b = a;を定義すると、bは0になります。ここでb = 3;と代入しても、aとbは等しくならず、互いに影響しません。下図はLINQPadで示したものです。Dump()はその変数を下のResultsブロックに表示することを意味します。途中でbの値を変更しても、aは影響を受けないことがわかります。

参照型は次のようになります。BオブジェクトがAオブジェクトから派生した場合、どちらのオブジェクトを変更しても、もう一方もそれに応じて変更されます。下図では12行目でBオブジェクトのTitleを"BB"に変更したところ、AオブジェクトのTitleも変わっています。

では、これらとBlogにどんな関係があるのでしょうか?バックエンドのBlogRepository.csのGetBlog()を見てみましょう。ここでblogが返されており、フロントエンドのBlogBase.razor.csでそれを受け取り、Add()がトリガーされるとBlog.Postsに新しいPostModelが追加されます。

フロントエンドでDeleteボタンをクリックすると、バックエンドのPostRepository.csのDeletePost()がSaveChanges()をトリガーします。このときのBlog.Postsには、Blog、Title、Contentを持たないPostModelが含まれています。このデータはまだSubmitボタンをクリックしてバックエンド経由でデータベースに保存されておらず、フロントエンドにしか存在しないデータです。しかし、SaveChanges()がトリガーされると、このデータをデータベースに保存しようとします。TitleとContentはnullを許可していないため、当然エラーが発生します。


また、データベースからPostsだけを取得しても、そのデータは見えません。なぜなら、それはBlogに付随するPostModelだからです。

この問題を解決するにはいくつかの方法があります。1つ目は、BlogとPostを完全に分離し、それぞれに独自のフロントエンドページを持たせることです。ただし、実際のプロジェクトでこのような落とし穴(そうです、これは筆者が自分で掘った落とし穴です…)に遭遇した場合、このようなリファクタリングを行う時間は通常ありません。
2つ目の方法は、バックエンドのPostRepository.csがTitleを持たないPostModelを受け取ったときに、エラーメッセージを返すことです。

フロントエンドのPostBase.razor.csを、deleted.IsSuccessで判定するように変更します。削除が成功した場合はPost!.IdをBlogに渡して、そのPostをページから削除します。失敗した場合は失敗の理由を表示します。


エンジニアの観点から見れば、これでエラーは回避できますが、UX(ユーザーエクスペリエンス)の観点から見ると全く意味がわかりません。なぜログを削除する際に、空のログがあってはいけないという制限があるのでしょうか?そこで、3つ目の方法が必要になります。
3つ目は、ViewModelを作成する方法です。ページのCRUDはViewModelに対して行い、その後でModelにマッピングします。
いわゆるViewModelとは、データベースには存在しないがページに表示したいフィールドのことです。例えば、EmployeeというテーブルにFirstNameとLastNameの2つのフィールドがあるとします。データベースに保存するときは別々に保存しますが、表示するときに加工したい(例えば結合して大文字にするなど)場合、両方のフィールドをフロントエンドに送ってユーザーのブラウザで処理するか、バックエンドで処理してからViewModelで受け取ってフロントエンドに送ります。
別の例として、クレジットカードがあります。CreditCardテーブルには、ユーザーのクレジットカード番号、3桁の認証コード、生年月日が格納されています。オンラインショッピングではよく、カードの下4桁だけを表示します。このような機密データを16桁すべてフロントエンドに送るわけにはいきません。その場合は、バックエンドで処理してからViewModelを介してフロントエンドに送る必要があります。
まず、BlogViewModelとPostViewModelを作成します。これらはViewModelなので、データベース関連の[Key]属性は必要ありません。Modelを使用している箇所はすべてViewModelに変更します。

次に、バックエンドのBlogRepository.csを変更します。ページ表示はViewModelにし、データの読み書きはModelをそのまま使用します。36行目から56行目で手動でマッピングを行っているのがわかります。


PostRepository.csのCreatePost()も同様に変更し、DeletePost()では元のelseブロック内のBlog.Postsに対する判定を削除します。


BlogBase.razor.csとPostBase.razor.csでは、使用していたModelをViewModelに変更します。

ここで新しいデータを作成します。しかし、2件目を作成した直後に2件目を削除しようとすると、Postが見つからないという問題が発生します。なぜでしょうか?

2件目は確かにデータベースに入りましたが、データを再取得していません。そのため、ページ上のBlog.Postsの2件目のPost.Idはまだ0のままです。

Blog.Postsにデータベースを再取得させるためには、PostBase.razor.csにEventCallbackを追加して、BlogBase.razor.csにLoadData()をもう一度実行するよう通知する必要があります。通知するだけなので、<TValue>は渡しません。



そして、2件目を追加した直後に削除すれば、正常に動作します。2件目を追加した後、3件目を追加し、2件目を削除しても正常です。
(注:下図のようなエラーメッセージが表示される場合は、Visual Studioの問題の可能性があります。まずはVisual Studioを再起動してみてください。)

参照:
- .NET Stack and Heap
- In C#, why is String a reference type that behaves like a value type?
- What is ViewModel in MVC?
- Understanding ViewModel in ASP.NET MVC
注:本記事のコードは.NET 6 + Visual Studio 2022でリファクタリングされています。原文のリンクからリファクタリング後のコードと比較して学習できます。お読みいただきありがとうございます。原作者をサポートしてください。