実装ビュー
さて、それではいよいよコードを交えて、実装方法について触れていきます。
本節では、ユースケースビューで取り上げた代表的なユースケースと、アーキテクチャ上重要な要素について、具体的な実装方法を解説します。
具体的に取り上げる内容は次のとおりです。
- ユースケース「ログインする」の実装
- ユースケース「従業員を閲覧する」の実装
- ユースケース「従業員を編集する」の実装
ユースケース「ログインする」の実装
本システムではアプリケーション起動時に、統合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で管理されているトランザクション内でデータベース操作が行えることとなります。