実装ビュー
さて、それではいよいよコードを交えて、実装方法について触れていきます。
本節では、ユースケースビューで取り上げた代表的なユースケースと、アーキテクチャ上重要な要素について、具体的な実装方法を解説します。
具体的に取り上げる内容は次のとおりです。
- ユースケース「ログインする」の実装
- ユースケース「従業員を閲覧する」の実装
- ユースケース「従業員を編集する」の実装
ユースケース「ログインする」の実装
本システムではアプリケーション起動時に、統合Windows認証を利用してシングルサインオンでログインします。本節ではアプリケーションを起動しユーザーを認証しする流れを解説し、個別の箇所をコードを見ながら説明したいと思います。
大まかな流れは次の通りです。
あくまで全体の流れを把握していただくための図ですので、省略していたり不正確な箇所がありますがご了承ください。
画面左側の赤く囲った部分がクライアント端末上で動作するWPFアプリケーションの領域、右側の赤く囲った部分がアプリケーションサーバー上で動作するWCFサービスの領域です。
WPFアプリケーションの初期画面のロード時に、ViewModelのAuthenticateメソッドを経由してIAuthenticationServiceのAuthenticateを呼び出します。そこからネットワークを経由して、アプリケーションサーバー上のAuthenticationServiceのAuthenticateメソッドを呼び、EmployeeDaoでリモート(WPFアプリケーション)のドメインアカウントから従業員情報を取得し、取得できたら認証OKとします。
そして認証結果がViewModelまで帰ってきて、認証OKであったらRequestNavigateメソッドを呼び出してメニュー画面を表示します。認証NGだった場合は、エラー画面へ遷移します。
では具体的なコードを見ていきましょう。
先の図ではOnLoadedメソッドからMainWindowViewModelのAuthorityメソッドを呼び出していました。しかし実際の実装は異なります。MainWindowからMainWindowViewModelを呼び出している箇所を見てみましょう。
実際にViewModelを呼び出している個所はMainWindow.xamlにあります。次のコードです。
<Window x:Class="AdventureWorks.EmployeeManager.Presentation.Views.MainWindow" ...> <i:Interaction.Triggers> <i:EventTrigger EventName="Loaded"> <i:InvokeCommandAction Command="{Binding AuthenticateCommand}"/> </i:EventTrigger> </i:Interaction.Triggers> ...
WindowでLoadedイベントが発生したタイミングでMainWindowViewModelのAuthenticateCommandを実行しています。
MainWindowViewModelのAuthenticateCommandは、次のように実装されています。
public ICommand AuthenticateCommand => new DelegateCommand(Authenticate); private void Authenticate() { var isAuthenticated = _authenticationService.Authenticate(); ...
DelegateCommandはPrismで提供されているICommandクラスの実装クラスで、コンストラクタで渡されたActionをコマンド実行時に呼び出すように作られています。
つまり、WindowでLoadedイベントが発生すると、ViewModelのAuthenticateCommandが実行され、AuthenticateCommandはAuthenticateメソッドを呼び出す。という流れになっています。
MVVMパターンを採用した実装では一般的なコードではありますが、それでもコードビハインドにイベントハンドラを定義して記述した方が簡単じゃないか?と思う人も多いのではないでしょうか。
MVVMパターンの実装で、イベントハンドラとコードビハインドをあまり利用せず、XAMLへICommandをバインドして実装することには明確な理由があります。
それはコードビハインドに記述したコードはテストが困難で再利用性も低いからです。
コードビハインドに記述した場合、そこに書かれたコードはViewをインスタンス化しないと実行できません。しかしViewはUnit Testコードからインスタンス化しにくいという問題があります。そのため、コードビハインドに記述せず、TriggerやBehaviorといった技術要素を利用してXAMLに記述します。
TriggerやBehavior自体はViewと直接依存関係はありませんので、テスト容易性が確保できる、ということになります。
またコードビハインドに書いたコードは、類似のロジックを別のViewで使いまわそうとした場合、コードをコピーすることになります。しかしTriggerやBehaviorなどで実装されていれば、すでに品質が確保されているコードを容易に再利用できます。
XAMLを採用したMVVMパターンで、イベントハンドラとコードビハインドあまり利用しないのは、まとめると次の二つの理由によると私は考えています。
- コードのテスト容易性を確保するため
- コードの再利用性を高めるため
ところで振り返って、先のコードを見てみると、再利用やテスト容易性を考慮する必要があると言えるでしょうか? このケースでは殆どないと思います。しかし、ある箇所ではコードビハインドに、ある箇所ではXAMLにと、記述が分散すると可読性が低下してしまいます。
テスト容易性・再利用性・可読性、これらを担保するために「無理のない範囲で」XAMLに記述することを優先したら良いと考えています。
続いてMainWindowViewModelでRequestNavigateを呼び出している個所を見てみましょう。
if (_login.Authenticate()) { RequestNavigate<MenuViewModel>(_regionManager); } else { RequestNavigate<ErrorViewModel>(_regionManager); }
RequestNavigateの型パラメーターで遷移先のViewに対応するViewModelの型を渡して遷移を指示しています。RequestNavigate<TViewModel>の実装も見てみましょう。
RequestNavigate<TViewModel>メソッドはMainWindowViewModelの親クラスである、ViewModelBaseに定義されています。
protected void RequestNavigate<TViewModel>(IRegionManager regionManager, string regionName = RegionNames.ContentRegion) where TViewModel : ViewModelBase { var typeName = typeof(TViewModel).Name; var viewName = typeName.Substring(0, typeName.Length - "ViewModel".Length); regionManager.RequestNavigate(regionName, viewName); }
ViewModelのクラス名から遷移先のView名を決定して画面遷移を行なっています。またRegionの名称はデフォルトはRegionNamesクラスの定数を利用しています。ちなみにMainWindow.xamlでRegionを定義している箇所も次のように同じ定数を参照しています。
<ContentControl prism:RegionManager.RegionName="{x:Static viewModels:RegionNames.ContentRegion}" /> ...
このようなコードを用意せず、ベタに書くと次のようなコードになります。
regionManager.RequestNavigate("ContentRegion", "Menu");
"Menu"に関しては、ViewModelから類推するのではなく、nameof(Menu)でも良いかも知れません。
regionManager.RequestNavigate("ContentRegion", nameof(Menu));
ViewとViewModelが微妙に双方向依存になってしまいますが妥協できる範囲でしょう。
しかしRegionの名称に関しては何れかで定数定義する必要があります。業務アプリケーションの場合は画面遷移が単純で、ほとんど同じRegionしか利用しないということもよくあります。そのため本システムではこの共通メソッドを用意しています。
また画面遷移で、前の画面に戻る遷移ケースで、ViewやViewModelのインスタンスを破棄したいような場合にも、ベースクラスで共通メソッドを用意しておくと実装が楽になります。そのため戻る処理と粒度を合わせる意味で、RequestNavigate<TViewModel>もViewModelの基底クラスに共通メソッドを用意しています。
画面遷移の戻る処理時に、View・ViewModelのインスタンスを破棄する件の詳細は以下の記事をご覧ください。
さて、クライアント側の実装は以上です。続いてサーバーサイドの実装も見てみましょう。
ログイン処理に利用するWCFサービスは、IAuthenticationServiceクラスのAuthenticateメソッドです。
[ServiceContract] public interface IAuthenticationService { [OperationContract] bool Authenticate(); }
呼び出し元のユーザーに本システムの利用権限の有無を返却します。認証には統合Windows認証を用いてユーザーを識別し、Employeeテーブルに対象のアカウントが登録されているかどうか判定します。
先に記載した図では、トランザクションの管理が隠蔽されてしまっていますので、もう少し詳しい図を次に用意しました。
先にも説明した通り、トランザクション管理などはAspect Oriented Programing(AOP)を利用します。
赤で括った部分がAOPで実装されている個所です。実際にはWCFホストがAntiDeadlockInterceptorを呼び出すわけではありません。WCFホストはAuthenticateServiceのAuthenticateメソッドを呼び出します。その呼び出しをCastle.CoreのDynamicProxyの仕組みを利用してインターセプトして、デッドロック対応処理やトランザクション処理を「織り込んで」います。
順番にコードを見ていきましょう。まずはAntiDeadlockInterceptorのコードです。
public class AntiDeadlockInterceptor : IInterceptor { public const int DefaultMaxRetryCount = 5; public static int MaxRetryCount { get; set; } = DefaultMaxRetryCount; public void Intercept(IInvocation invocation) { for (var i = 1; ; i++) { try { invocation.Proceed(); break; } catch (System.Data.SqlClient.SqlException ex) { // デッドロックで、最大リトライ回数を超えていない場合は再実行する // それ以外は例外をスローする if (ex.Number != 1205 || i == DefaultMaxRetryCount) throw; } } } }
AntiDeadlockInterceptorは、Catsle.CoreのIInterceptorを実装しています。WCFサービスのAPI呼び出しを、IInterceptorのInterceptメソッドでフックします。
AntiDeadlockInterceptorではデッドロック時のリトライ処理が実装されています。どれだけ丁寧に気を配ってデータベース処理を実装しても、一般的な使用方法の範疇であってもデッドロックを完璧に回避することはできません。そのため後続処理でデッドロック例外がスローされてきた場合、ここでリトライ処理を実施しています。
AntiDeadlockInterceptorはIInvocationのProceedメソッドを呼び出すことで、後続のTransactionInterceptorを呼び出します。
続いてTransactionInterceptorを見てみましょう。
public class TransactionInterceptor : IInterceptor { public const int DefaultMaxRetryCount = 5; public static int MaxRetryCount { get; set; } = DefaultMaxRetryCount; private readonly ITransactionContext _transactionContext; public TransactionInterceptor(ITransactionContext transactionContext) { _transactionContext = transactionContext; } public void Intercept(IInvocation invocation) { using (var transaction = _transactionContext.Open()) { invocation.Proceed(); transaction.Complete(); } } }
TransactionInterceptorではトランザクションを開始し、InterceptのProceedメソッドを呼び出すことで後続、つまりWCFサービスの実装を呼び出します。
WCFサービスの実装が正常に実行完了したら、Completeメソッドを呼び出しトランザクションをコミットします。WCFサービスの実装が例外を発生した場合は、Completeメソッドが呼び出されずトランザクションはロールバックされます。
ITransactionContextはTransactionInterceptorで開始されたデータベーストランザクションを、WCFサービスのリクエスト~レスポンスの間、保持するクラスです。詳細な実装については、あまり重要なポイントはないため説明は割愛します。興味のある方はコードをご覧ください。
続いてAuthenticationServiceの実装を見てみましょう。
public class AuthenticationService : IAuthenticationService { private readonly EmployeeDao _employeeDao; public AuthenticationService(EmployeeDao employeeDao) { _employeeDao = employeeDao; } public virtual bool Authenticate() { var name = ServiceSecurityContext.Current.WindowsIdentity.Name; return _employeeDao.FindByLoginID(name) != null; } }
コンストラクタでEmployeeDaoがインジェクションされ、Authenticateメソッドの中で利用しています。
Authenticateメソッドの中では、ServiceSecurityContextから統合Windows認証で認証されたユーザー名を取得しています。「ドメイン名/アカウント名」のような形で取得されます。取得したユーザー名からEmployeeテーブルを検索し、Employeeが存在した場合は認証OKとしてtrueを返します。
WCFで統合Windows認証を利用するための詳細な設定は、以下の記事をご覧ください。
ところでAuthenticationServiceのAuthenticateメソッドを、AOPを利用せずに実装したらどうなるでしょうか? その場合、次のような実装に変更する必要があるでしょう。
public virtual bool Authenticate() { for (var i = 1; ; i++) { try { bool isAuthenticated; using (var scope = new TransactionScope()) using (var connection = OpenConnection()) { var name = ServiceSecurityContext.Current.WindowsIdentity.Name; isAuthenticated = _employeeDao.FindByLoginID(name) != null; scope.Complete(); } return isAuthenticated; } catch (System.Data.SqlClient.SqlException ex) { // デッドロックで、最大リトライ回数を超えていない場合は再実行する // それ以外は例外をスローする if (ex.Number != 1205 || i == DefaultMaxRetryCount) throw; } } }
WFCサービスのメソッドすべてで、同じ実装が重複するかと思うと、眩暈がしてきます。
AOPを利用しない場合と比較すると、次のような問題があることが分かります。
- WCFサービスのすべてのメソッドに同じようなトランザクション制御コードを記述する必要がある
- トランザクション制御を個別に実装しているので、WCFの全メソッドでトランザクションの成功時と失敗時のテストを記述する必要がある
- データベースをコミットしてしまうため、テスト前後でデータベースの事前・事後処理が必要になりテスト工数が膨らむ
- 同様の理由から、テストの平行実行が難しい
これらの理由からトランザクション制御とビジネスロジックの実装は分離することが好ましく、そのための手法としてAOPが最も適切であると私は考えています。
AOPの実現手段は色々ありますが、今回は先にも記載したようにCatsle.CoreのDynamicProxyを、SimpleInjectorがインスタンス生成する際に適用するようにしています。実際にその指定をしている個所を見てみましょう。実際のコードはWCFサービス実装のBootstrapperクラスにあります。
BootstrapperクラスのBuildContainerメソッドを見てみましょう。
private static void BuildContainer() { var container = new Container(); container.Options.DefaultScopedLifestyle = new WcfOperationLifestyle(); container.Intercept<IAuthenticationService>( typeof(AntiDeadlockInterceptor), typeof(TransactionInterceptor)); ... // Services TransactionContext.SetOpenConnection(OpenConnection); container.Register<ITransactionContext, TransactionContext>(Lifestyle.Scoped); container.Register<AuthContext>(Lifestyle.Scoped); container.Register<IAuthenticationService, AuthenticationService>(Lifestyle.Scoped); container.Register<IHumanResourcesService, HumanResourcesService>(Lifestyle.Scoped); // DatabaseAccesses ... }
container.Interceptと呼び出している個所が該当のコードになります。
IAuthenticationServiceに対して、AntiDeadlockInterceptorとTransactionInterceptorを適用するように指示しています。Interceptは拡張メソッドとして定義されており、私がNuGetに公開しているSimpleInjector.Extras.DynamicProxyパッケージに実装されています。興味のある方は次のリンク先をご覧ください。
さて、最後にEmployeeDaoを見てみます。
public class EmployeeDao { private readonly ITransactionContext _transactionContext; public EmployeeDao(ITransactionContext transactionContext) { _transactionContext = transactionContext; } public virtual Employee FindByLoginID(string loginID) { return _transactionContext.Connection.Find<Employee>(statement => statement .Where($"{nameof(Employee.LoginID)} = '{loginID}'") ).SingleOrDefault(); } }
コンストラクタでITransactionContextが注入されています。この時注入されるインスタンスは、TransactionInterceptorに注入されていたインスタンスと同じものが取得されます。
FindByLoginIDメソッドでITransactionContextのConnectionプロパティから、データベースの接続オブジェクトを取得していますが、これはITransactionInterceptorのInterceptメソッド内でOpenした時に開かれたものと同じものとなり、ITransactionInterceptorで管理されているトランザクション内でデータベース操作が行えることとなります。
ユースケース「従業員を閲覧する」の実装
本ユースケースでは、従業員テーブルを利用します。関連テーブルを抜粋したER図を次に示します。
実際には図に表記していない多数の関連テーブルが存在します。しかし本稿ではこちらの図の中でもさらに、次の5つのテーブルのみを扱うものとします。
- BusinessEntity
- Person
- Employee
- Gender
- MaritalStatus
EmployeeテーブルにGenderとMaritalStatusというカラムがありますが、この中身にはコードが設定されています。
表示する値はそれぞれ、GenderテーブルとMaritalStatusテーブルに格納されているため、表示する際にはそちらの値に置き換える必要があります。
本ユースケースでは、BusinessEntity、Person、Employeeの三つのテーブルを次のように結合した、ManagedEmployeeというビューから値を取得します。
create view HumanResources.ManagedEmployee as select Employee.BusinessEntityID, Person.FirstName, Person.LastName, Person.EmailPromotion, Employee.NationalIDNumber, Employee.LoginID, Employee.JobTitle, Employee.BirthDate, Employee.MaritalStatus, Employee.Gender, Employee.HireDate, Employee.SalariedFlag, Employee.VacationHours, Employee.SickLeaveHours, Employee.CurrentFlag from HumanResources.Employee inner join Person.BusinessEntity on Employee.BusinessEntityID = BusinessEntity.BusinessEntityID inner join Person.Person on Employee.BusinessEntityID = Person.BusinessEntityID
データ取得処理時にテーブルを結合するのではなく、結合済みのビューに対してデータ取得処理行うことで、次のような利点を得られます。
- 複数個所で同様の結合を必要とした場合に、結合ルールの誤りによる不具合を予防できる
- 単一のビューにすることで、OR Mapperから扱いやすくなる
- インデックス付きビューなどを利用できる可能性がある
ManagedEmployeeにも、Employeeと同様にGenderとMaritalStatusのコードのみを持ちます。このため画面で表示する際にそれらのマスターテーブルから値を取得して表示することとします。
一覧画面で編集しない場合は、データベースのビューに表示名を入れると実装がより容易になります。しかし今回はグリッド上で編集も行います。その際、GenderやMaritalStatusはコンボボックスで取り扱うため、表示名はデータベースのビューには入れず、それぞれのマスターから取得して表示時に置き換えることとします。
さて次のシーケンス図が、「従業員を閲覧する」ユースケースのクライアントサイドの処理概要になります。
左から3レーン目のManagedEmployeeListViewModelからIHumanResourcesServiceを3回呼び出し、次の値を取得していることが見て取れます。
- Genderマスター
- MaritalStatusマスター
- 全従業員一覧
これらを取得したのちに、一覧画面を表示します。
先にも説明した通り、従業員を表すManagedEmployeeクラスでは、GenderとMaritalStatusはコードしか保持していないため、1.と2.で表示値を取得しています。その際に、編集機能でプルダウンで編集できるよう、選択可能な値をすべてここで取得しています。
注目していただきたいポイントの一つが、WCFサービスから取得されてきたManagedEmployeeをManagedEmployeeViewModelに詰め替えている箇所です。具体的なコードは次のとおりです。
private void LoadManagedEmployees() { var managedEmployees = _humanResourcesService.GetManagedEmployees(); foreach (var managedEmployee in managedEmployees) { ManagedEmployees.Add(new ManagedEmployeeViewModel(managedEmployee)); } }
ManagedEmployeeをコンストラクタの引数として渡して、ManagedEmployeeViewModelを生成していることが見て取れます。
ManagedEmployeeViewModelのコンストラクタの実装が次の通りです。
public ManagedEmployee ManagedEmployee { get; } public ManagedEmployeeViewModel(ManagedEmployee managedEmployee) { ManagedEmployee = managedEmployee; Mapper.Map(ManagedEmployee, this); ... }
ManagedEmployeeクラスとManagedEmployeeViewModelクラスはほとんど同じプロパティで構成されており、Mapperクラスを利用して、ViewModelへ値を詰め替えています。
わざわざ類似のクラスを用意して値を詰め替えているのには理由があります。本システムではグリッド上で従業員の追加・編集処理を行います。このため、グリッドにバインドする値には、新規・更新・未編集の何れかを表す状態プロパティが必要です。サービスから取得したクラスには当然、ユーザーインターフェースの実現のみに必要なプロパティは存在しないため、専用のViewModelを作成して値を詰め替えて表示します。
このような理由以外にも、次のようなケースでもViewModelへの詰め替えが必要になることがあります。
- 従業員の編集時にUndo機能を実装するためには、編集前の値を保持しておきたい
- ユーザーインターフェースの制御のための特殊な振る舞い(コマンドやメソッド)が必要
対して、GenderとMaritalStatusは、編集やユーザーインターフェース上の特殊な処理を想定しておらず、ViewModelへの詰め替え処理は行わないで、そのまま利用します。
さて必要な情報を取得してきたら、あとはSPREAD for WPFのGcSpreadGridコンポーネントのItemsSourceプロパティにManagedEmployeeViewModelのコレクションをバインドします。
次のコードが、実際のバインドしているコードの抜粋です。
<sg:GcSpreadGrid ItemsSource="{Binding ManagedEmployees}" AutoGenerateColumns="False" Locked="{Binding IsReadOnly}" Protected="{Binding IsReadOnly}" CanUserSortColumns="True" CanUserFilterColumns="True" ColumnDragMode="SelectThenDrag"> <i:Interaction.Behaviors> <behaviors:SetItemsSourceBehavior ComboBoxCell="{Binding ElementName=Gender}" ItemsSource="{Binding Genders}"/> ... </i:Interaction.Behaviors> <sg:GcSpreadGrid.Columns> <sg:Column Header="名" DataField="FirstName"> <sg:Column.CellType> <sg:TextCellType/> </sg:Column.CellType> </sg:Column> ... <sg:Column Header="性別" DataField="Gender"> <sg:Column.CellType> <sg:ComboBoxCellType x:Name="Gender" ContentPath="Name" SelectedValuePath="Code"/> </sg:Column.CellType> </sg:Column> ...
今回はSPREADの設定にデザイナーを使わず、XAMLで記述する方式を取りました。これは別にXAMLが特に優れているからという訳ではなく、リファレンスとしてお見せするのにXAMLのほうが都合が良いというのが理由です。実際にはXAMLかデザイナー(もしくは、お勧めしませんがコードビハインドでC#)の何れで実装するかは、各々のプロジェクトで判断する必要があるでしょう。
さて、前述のXAMLについて、四つほど見ていただきたいポイントがあります。
まず一つは、GcSpreadGridのプロパティのLockedとProtectedにViewModelのIsReadOnlyプロパティをバインドしている個所です。ViewModel側でデフォルトではtrueが設定されています。こうすることで、読み取り専用となるように制御しています。
続いてProtectedのすぐ下のCanUserSortColumnsとCanUserFilterColumns、ColumnDragModeの三つのプロパティです。これは全ての列でソートとフィルタリングを有効にし、選択した列をドラッグで移動可能にしています。実際の振る舞いは後程見ていただきましょう。
三つ目がGcSpreadGrid.Columns以降です。列は動的生成することも可能ですが、列の順序を制御したり、列ヘッダーの名称を日本語名で表示するためには、個別に列を定義する必要があります。実際には多数の列が定義されていますが、ここではFirstNameとGenderの列のみ記載しています。FirstNameはTextCellTypeを、GenderにはComboBoxCellTypeを指定しています。GenderのComboBoxCellTypeでは、表示値をGenderオブジェクトのNameプロパティを採用し、選択値にはCodeプロパティを採用するように定義しています。
そして四つ目がSetItemsSourceBehaviorです。これは本システムで作成したもので、ComboBoxCellTypeのItemsSourceへデータベースから取得したGenderのリストをバインディングしています。残念なことにSPREAD for WPFのComboBoxCellTypeはBindableObjectを継承していない関係で、ItemsSourceへ直接値をバインドすることができません。コードビハインドから設定することも可能ではありますが、XAMLからバインドするためにSetItemsSourceBehaviorを作成しました。良かったらコードを参考にしてみてください。
クライアントサイドについては以上です。続いてサーバーサイドの実装も見てみましょう。次のシーケンスがサーバーサイドの流れになります。
とくに見ていただきたいのが、AOPで「織り込まれる」InterceptorがTransactionInterceptorに加えて、AuthenticationInterceptorが増えていることです。
コードを見てみましょう。
public class AuthenticationInterceptor : IInterceptor { private readonly EmployeeDao _employeeDao; public AuthenticationInterceptor(EmployeeDao employeeDao) { _employeeDao = employeeDao; } public void Intercept(IInvocation invocation) { var name = ServiceSecurityContext.Current.WindowsIdentity.Name; if (_employeeDao.FindByLoginID(name) == null) { throw new SecurityAccessDeniedException(); } else { invocation.Proceed(); } } }
先に開設したAuthenticationServiceとほぼ同じロジックになっています。
AuthenticationServiceのAuthenticateメソッド以外は、実行時に利用者の認証処理を行います。これは不明なユーザーによってWCFサービスが実行されることを排除するためです。
このようにAOPを利用することによって、抜け漏れなく、かつ重複したコードもない、堅牢で生産性の高いサービスを構築することが可能となります。
DataAccess層の実装は、認証ユースケースと差がないため説明は割愛します。
では実際の動作を見てみましょう。
列クリックでのソートやフィルタリング、列を選択して列順の入れ替えが正しく動作しています。またGenderがコードではなく、名称が表示されていることが見て取れるでしょう。
ユースケース「従業員を編集する」の実装
先にも記載した通り、本ユースケースのUIからデータベースまでの相互作用は、基本的に「従業員を閲覧する」ユースケースと違いがありません。本ユースケースをアーキテクチャ設計として記載する目的は、ユーザーインターフェースの詳細な実装アーキテクチャを決定・解説することにあります。そのため、実際のユーザーインターフェースの操作を追いながら、詳細を記述していきたいと思います。
プロパティの変更通知の実装
さて本ユースケースは、「従業員を閲覧する」ユースケースを拡張して実現します。具体的には、「従業員を閲覧する」ユースケースを実行後、次の画面の「編集」ボタンを押下することでユースケースを開始します。
元々、SPREAD for WPFは編集に対応しているところを、「従業員を閲覧する」ユースケースで読み取り専用で表示しているため、編集ボタンを押下することで編集可能に状態を変更することで、ユースケースを開始します。
「従業員を閲覧する」ユースケースの解説でもお見せしましたが、XAML上で編集可否を制御している箇所をもう一度見てみましょう。
<sg:GcSpreadGrid Grid.Row="1" ItemsSource="{Binding ManagedEmployees}" AutoGenerateColumns="False" Locked="{Binding IsReadOnly}" Protected="{Binding IsReadOnly}"
初期はLockedとProtectedの二つのプロパティにtrueが設定されているため、編集ボタンを押下してIsReadOnlyをfalseに変更します。
このとき変更を通知するため、通常はINotifyPropertyChangedインターフェースを実装した、次のようなコードを記述する必要があります。
public class ManagedEmployeeListViewModel : INotifyPropertyChanged { private bool _isReadOnly = true; public bool IsReadOnly { get => _isReadOnly; set => SetProperty(value, ref _isReadOnly); } public event PropertyChangedEventHandler PropertyChanged; protected bool SetProperty<T>( T value, ref T field, [CallerMemberName] string propertyName = null) { if (Equals(field, value)) return false; field = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); return true; } }
正直、通知が必要なプロパティに全てこの記述を行うのは苦痛です。苦痛な上に、不具合の温床でもあります。
そこで本システムではPropertyChanged.Fodyを利用することとします。PropertyChanged.Fodyを利用することで、先のコードと同等のコードが次のように記述するのみで実現できます。
[AddINotifyPropertyChangedInterface] public class ManagedEmployeeListViewModel : ViewModelBase { public bool IsReadOnly { get; set; } = true; }
PropertyChanged.FodyはFodyという静的コード生成フレームワークを利用しています。
前述のコードではクラスの属性としてAddINotifyPropertyChangedInterfaceが宣言されています。PropertyChanged.Fodyでは、AddINotifyPropertyChangedInterface属性が宣言されたクラスに対して、コンパイル時にコンパイルされたバイトコードを修正して、INotifyPropertyChangedを利用した変更通知に必要な処理を自動的に組み込んでくれます。
詳細は英語になりますが、次のサイトが参考になります。
編集状態の変更に伴うボタンの状態管理
従業員一覧画面には、編集ボタンと保存ボタンがあります。これらのボタンはそれぞれ、閲覧中は編集ボタンのみが押下でき、編集中は保存ボタンのみが押下できるように、状態を管理する必要があります。
ボタンには次のように、ViewModelのEditCommandとSaveCommandをバインドします。
<Button Style="{DynamicResource AccentedSquareButtonStyle}" Width="100" Margin="10, 0" Content="編集" Command="{Binding EditCommand}"/> <Button Style="{DynamicResource AccentedSquareButtonStyle}" Width="100" Margin="10, 0" Content="保存" Command="{Binding SaveCommand}"/>
ボタンはバインドされているCommandのCanExecuteがtrueの場合に押下可能となります。
ICommandの詳細は次の記事も参考にしてください。
ここでは先にも記載した、IsReadOnlyプロパティの状態によって制御すればよいのですが、具体的な実装方法は多数あります。本システムではいずれかのプロパティの状態の変更を受けて、別のプロパティの状態を変更したり、何らかの処理をする場合、ReactivePropertyを利用することとします。
ReactivePropertyはMVVMアーキテクチャでリアクティブプログラミングを容易に利用するためのライブラリです。詳細は次の記事をお勧めします。
実際のコードを見てみましょう。
[AddINotifyPropertyChangedInterface] public class ManagedEmployeeListViewModel : ViewModelBase { public bool IsReadOnly { get; set; } = true; public ReactiveCommand EditCommand { get; } public ReactiveCommand SaveCommand { get; } public ManagedEmployeeListViewModel( IHumanResourcesService humanResourcesService, IRegionManager regionManager) { ... EditCommand = this.ObserveProperty(x => x.IsReadOnly).ToReactiveCommand(); EditCommand.Subscribe(() => IsReadOnly = false); SaveCommand = this.ObserveProperty(x => x.IsReadOnly).Select(x => !x).ToReactiveCommand(); SaveCommand.Subscribe(OnSave); } ...
EditCommandもSaveCommandも、いずれもReactiveCommandとして宣言しており、インスタンスはコンストラクタで生成しています。
EditCommandはIsReadOnlyプロパティがtrueの時のみ押下でき、押下するとIsReadOnlyをfalseに変更するよう実装しています。
SaveCommandは逆にIsReadOnlyがfalseの時のみ押下でき、押下するとOnSaveメソッドを呼び出すように実装しています。
リアクティブプログラミングのメリットは、さまざまな解説がされていますが、私は上記コードのように条件と振る舞いを宣言的に記述できることに大きなメリットを感じています。仕様をコードでそのまま表現できる感覚が気に入っています。
リアクティブプログラミングは.NET Frameworkから始まって、現在は多種多様なプログラミング言語に輸出されています。取っ掛かりに困難を感じることもあるかもしれませんが、その困難を乗り越える努力を払う価値が十分にあるでしょう。ReactivePropertyはリアクティブプログラミングへの入門としても非常に優れたライブラリです。まだ経験のない方は、ぜひ次の記事も読んでみてはいかがでしょうか?
グリッドの各行の編集状態の管理
グリッドの各行は次の三つの状態を取ります。
- UnModified
- Modified
- New
この状態を1行を表すManagedEmployeeViewModelクラスで管理します。実際のコードを見てみましょう。
[AddINotifyPropertyChangedInterface] public class ManagedEmployeeViewModel : INotifyPropertyChanged { private readonly ManagedEmployee _managedEmployee; public EditStatus EditStatus { get; private set; } public ManagedEmployeeViewModel(ManagedEmployee managedEmployee) { _managedEmployee = managedEmployee; EditStatus = EditStatus.UnUpdated; Mapper.Map(managedEmployee, this); PropertyChanged += ChangedProperty; } private void ChangedProperty(object sender, PropertyChangedEventArgs e) { if (EditStatus == EditStatus.UnModified && e.PropertyName != nameof(EditStatus)) { EditStatus = EditStatus.Modified; } } public event PropertyChangedEventHandler PropertyChanged; ... }
先に記載した、「従業員を閲覧する」ユースケースのコードに大きく二つの修正を加えています。
一つはEditStatusプロパティを追加したこと。先に記載した三つの状態の何れかを表します。
もう一つは、自分自身のPropertyChangedイベントを監視し、EditStatusがUnModifiedで更新されたプロパティがEditStatus以外だった場合に、EditStatusをModifiedに変更する処理を追加しています。
こうすることでグリッド上で既存の行を更新されたときに、ViewModel側で変更された行を検知できるようにしています。
新たに行が追加された場合には、ManagedEmployeeViewModelのデフォルトコンストラクタが呼び出されます。
public ManagedEmployeeViewModel() { _managedEmployee = new ManagedEmployee(); EditStatus = EditStatus.New; }
入力値のバリデーション
SPREAD for WPFでは、バリデーションの実現方法を、データソースに実装する検証やイベントによる継承、DataAnnotationによる検証など、複数用意しています。
本システムでは、DataAnnotationを利用しています。DataAnnotationによるバリデーションは、ViewModelのプロパティに属性を指定することで実現します。
例えば次のコードは、姓が必須であることを表しています。
[Required] public virtual string LastName { get; set; }
実際にバリデーション チェックでエラーになった場合、次のように表示されます。
エラーのある項目にマーカー(ここでは赤い三角)が表示され、エラーのある項目にフォーカスが当たると任意のエラーメッセージが表示されます。
DataAnnotation自体は.NET Frameworkが提供している仕組みで、次のページで標準でサポートされているDataAnnotationを確認することが可能です。
グリッドの編集状態をサービスへ通知し、データベースを更新する
更新のトリガー処理は既に先に記載済みです。
XAML上でButtonのCommandにViewModelのSaveCommandがバインドされており、SaveCommandが実行されると、ViewModelのOnSaveメソッドが呼び出されます。
OnSaveメソッドの実装は次の通りです。
private void OnSave() { IsReadOnly = true; var updatedEmployees = ManagedEmployees .Where(x => x.EditStatus == EditStatus.Modified) .Select(x => x.Commit()) .ToList(); var newEmployees = ManagedEmployees .Where(x => x.EditStatus == EditStatus.New) .Select(x => x.Commit()) .ToList(); _humanResourcesService.ModifyManagedEmployees(updatedEmployees, newEmployees); ManagedEmployees.Clear(); LoadManagedEmployees(); }
次の手順で更新処理を実施しています。
- グリッドを読み取り専用に変更
- 更新行のViewModelを取得し、Commitを呼び出した後、ManagedEmployeeのListの作成
- 新規追加行のViewModelを取得し、Commitを呼び出した後、ManagedEmployeeのListの作成
- IHumanResourcesServiceを呼び出して、データベースへの追加・更新を実行
- グリッドを一旦クリア
- 従業員情報を再ロード
Commitの実装は次のようになっています。
public ManagedEmployee Commit() { Mapper.Map(this, _managedEmployee); return _managedEmployee; }
ViewModelの編集状態をManagedEmployeeの値に上書き更新し、更新後のManagedEmployeeを返却しています。
従業員情報を再ロードしているのは、再ロードすることでEmployeeテーブルなどのModifiedDateを取得し直し、更新の衝突処理を実装できるようにする意図があります。ただし今回のサンプルではそこまで実装されていません。
さあ、それでは最後に実際に実装したものの動作の様子を見てみましょう。
以上で実装ビューの解説は全てとなります。
さいごに
さて、ここまで特定のビジネス・システム化の背景を想定して、求められるシステムを構築するためのアーキテクチャを検討・記載してきました。
ビジネス要件、システム要件、非機能要件が明確になりきっているとは言えないため、アーキテクチャも不鮮明な部分が残ってはいます。それでもアプリケーションを構築する際のヒントには十分になりえるのではないかと思います。
本稿が、アプリケーション構築にお悩みの方の一助になれれば幸いです。