このエントリーはASP.NET MVC5に対応しています。ASP.NET MVC Core2に対応した記事はこちらになります。
blog.beaglesoft.net
こんにちは。beaglesoftの真鍋です。
ASP.NET MVC5でモデルの検証を行うときにIValidatableObject
を実装してValidate
で検証処理を行うことがあると思います。たとえば、対象のモデルの複数項目について検証を行う場合などが多いのではないでしょうか。
プログラミングMicrosoft ASP.NET MVC 第3版ASP.NET MVC 5 対応版 (マイクロソフト公式解説書)でも、IValidatableObjectインターフェースということで説明があります。
今回はこの検証でRepositoryやServiceオブジェクトなどを利用する方法を試してみました。
IValidatableObjectをそのまま利用する
IValidatableObjectを実装したクラスでは、Validateメソッドでの処理を行います。この検証処理でたとえばRepositoryやRepositoryを利用するServiceを利用したい場合、DIを利用しない場合には以下のようなコードとなります。
public class CompanyViewModel : IValidatableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public long Id { get; set; }
/// <summary>
/// 会社名
/// </summary>
[StringLength(50)]
[Display(Name = "会社名")]
public string Name { get; set; }
...
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
using (var dataContext = new DataContext())
{
var fooRepository = new FooRepository(dataContext);
var fooService = new FooService(fooRepository);
...
}
}
}
これはこれで正しく動作しますが、テストがとても難しくなります。Validateメソッドの内容にも寄るのでしょうが、検証ロジックは基本的にテストを行いたい対象です。そのため、テスタビリティが低いことはできるだけさけたいですよね。
一般的にテストを行うときにはMoqを利用してInterfaceに定義されたメソッドを置き換えていきます。そうすることで、テストの関心事項である処理フローに集中できるからですね。
対応した方法
それではどのように対応したかです。目的はIValidatableObject.ValidateでRepositoryやServiceのインスタンスを受け取ることです。簡単に言うと、Validateではnew FooRepository()
とかしないと言うことです。
次に手順です。わかりづらいですが、とりあえず試してみると理解できると思います。
- IServiceProviderとIDependencyResolverを実装したServiceProviderを実装する。
- ValidatableObjectAdapterを実装したValidatableObjectAdapterを作成して、1で作成したServiceProviderをコンストラクタで設定する。
- UnityなどDIコンテナで
DataAnnotationsModelValidatorProvider
に2で作成したValidatableObjectAdapterを登録します。
IServiceProviderとIDependencyResolverを実装したServiceProviderを実装する
IServiceProvider
はIServiceProvider インターフェイス (System).aspx)で以下の通り説明されています。
サービス オブジェクト、つまり、他のオブジェクトにカスタム サポートを提供するオブジェクトを取得するための機構を定義します。
いまいちわかりづらいですが、他のオブジェクトに何かしらのオブジェクトを提供するために利用するものです。なぜ唐突にIServiceProvider
が登場するかというと、IValidatableObject
に関連します。
IValidatableObject
のValidate
では引数としてValidationContext
を受け取ります。このValidationContext
には
GetService
が定義されており、ここから今回取得したいServiceやRepositoryなどを取得するのですが、このGetService
で取得できる値はValidationContext
のコンストラクタで指定したIServiceProvider
となります。
このため、ValidationContext
へIServiceProvider
を実装したクラスを渡したいわけです。
IServiceProvider
を実装したCustomServiceProvider
クラスは次の通りとなります。
public class CustomServiceProvider: IDependencyResolver, IServiceProvider
{
private readonly IFooService _fooService;
public CustomServiceProvider(IFooService fooService)
{
_fooService = fooService;
}
public object GetService(Type serviceType)
{
return _fooService;
}
public IEnumerable<object> GetServices(Type serviceType)
{
return Enumerable.Empty<object>();
}
}
ValidatableObjectAdapterを実装したValidatableObjectAdapterを作成して、1で作成したServiceProviderをコンストラクタで設定する
次に先ほど定義したCustomServiceProvider
を受け取りValidationContext
を生成するValidatableObjectAdapter
を作成します。これはValidationContextを生成するための手順と考えればいいと思います。(ちょっと自信がないですが…)
public class CustomValidatableObjectAdapter : ValidatableObjectAdapter
{
private readonly IServiceProvider _serviceProvider;
// note:コンストラクタでserviceProviderを設定してインスタンス変数に保持する
public CustomValidatableObjectAdapter(IServiceProvider serviceProvider, ModelMetadata metadata, ControllerContext context)
: base(metadata, context)
{
this._serviceProvider = serviceProvider;
}
public override IEnumerable<ModelValidationResult> Validate(object container)
{
var model = Metadata.Model;
if (model == null)
{
return Enumerable.Empty<ModelValidationResult>();
}
var validatable = model as IValidatableObject;
if (validatable == null)
{
throw new InvalidOperationException();
}
// note:ここでValidationContextを生成してコンストラクタで受け取った_serviceProviderを設定する。
var validationContext = new ValidationContext(validatable, _serviceProvider, null);
return ConvertResults(validatable.Validate(validationContext));
}
private IEnumerable<ModelValidationResult> ConvertResults(IEnumerable<ValidationResult> results)
{
foreach (var result in results)
{
if (result != ValidationResult.Success)
{
if (result.MemberNames == null || !result.MemberNames.Any())
{
yield return new ModelValidationResult { Message = result.ErrorMessage };
}
else
{
foreach (var memberName in result.MemberNames)
{
yield return new ModelValidationResult { Message = result.ErrorMessage, MemberName = memberName };
}
}
}
}
}
}
UnityなどDIコンテナでDataAnnotationsModelValidatorProvider
に2で作成したValidatableObjectAdapterを登録します
最後にここまで作成したクラスを利用してインスタンスを生成します。今回はDIコンテナとしてUnityを利用します。
以下修正しました
当初はUnityConfig.csに設定を行っていましたが、Microsoft.Practices.Unity.ResolutionFailedException
が発生するためGlobal.asax.cs
に下記の通り設定するように修正しました。
public class MvcApplication : HttpApplication
{
protected void Application_Start()
{
Utilities.LoadNativeAssemblies(Server.MapPath("~/bin"));
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
var container = UnityConfig.GetConfiguredContainer();
var fooService = container.Resolve<IFooService>();
// note:RegisterViewModelのIsValidで使用するCustomValidatorを設定
var serviceProvider = new CustomServiceProvider(fooService);
// note:ここにはIValidatableObjectを実装したクラスを指定する
DataAnnotationsModelValidatorProvider.RegisterValidatableObjectAdapterFactory(typeof(CompanyViewModel),
(metadata, context) => new CustomValidatableObjectAdapter(serviceProvider, metadata, context));
}
}
修正前の内容
修正前の内容はこちらの通りとなります。この場合、
Microsoft.Practices.Unity.ResolutionFailedException はユーザー コードによってハンドルされませんでした。
がスローされるようになりました。原因はちょっとわかっていません。
/// <summary>
/// Specifies the Unity configuration for the main container.
/// </summary>
public class UnityConfig
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// Registers the type mappings with the Unity container.
/// </summary>
/// <remarks>
/// There is no need to register concrete types such as controllers or
/// API controllers (unless you want to change the defaults), as Unity
/// allows resolving a concrete type even if it was not previously
/// registered.
/// </remarks>
/// <param name="container">The unity container to configure.</param>
public static void RegisterTypes(IUnityContainer container)
{
// NOTE: To load from web.config uncomment the line below. Make sure to add a Microsoft.Practices.Unity.Configuration to the using statements.
// container.LoadConfiguration();
container.RegisterType<DataContext>(new PerRequestLifetimeManager(),
new InjectionFactory(_ =>
{
var basicContext = new DataContext();
basicContext.Database.Log = s =>
{
if (Logger.IsDebugEnabled)
{
Logger.Debug("SQL:{0}", s);
}
};
return basicContext;
}));
...
container.RegisterType<IFooRepository, FooRepository>();
container.RegisterType<IFooService, FooService>();
// note:RegisterViewModelのIsValidで使用するCustomValidatorを設定
var serviceProvider = new AddressServiceProvider(container.Resolve<IFooService>());
// note:ここにはIValidatableObjectを実装したクラスを指定する
DataAnnotationsModelValidatorProvider.RegisterValidatableObjectAdapterFactory(typeof(CompanyViewModel),
(metadata, context) => new CustomValidatableObjectAdapter(serviceProvider, metadata, context));
}
...
}
IValidatableObjectを実装したValidateクラスでDIされたServiceを利用する
ここまでで準備は整いましたので、あとはDIされたServiceを利用します。
public class CompanyViewModel : IValidatableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public long Id { get; set; }
/// <summary>
/// 会社名
/// </summary>
[StringLength(50)]
[Display(Name = "会社名")]
public string Name { get; set; }
...
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// using (var dataContext = new DataContext())
// {
// var fooRepository = new FooRepository(dataContext);
// var fooService = new FooService(fooRepository);
// ...
// }
var fooServices = validationContext.GetService(typeof(IFooService)) as IFooService;
...
}
}
コードもだいぶんすっきりしますし、テストも通常のメソッドと同様に引数のValidationContext
経由でMoqを定義することで容易に行うことができます。
それにしても、この辺の情報へ皆さんどうやってアクセスされているのでしょうか?以下のように参考にさせていただいたサイトはいくつかありましたが、切り口を見つけること自体が大変だと感じます。
参考にしたサイト
今回の処理とは少し違うのですが、IServiceProvider経由でInjectしたいクラスを渡すこと、それにその周辺の技術的な説明がとても助かりました。ありがとうございます。
ValidationAttribute の検証メソッド内で、外部コンポーネントを利用する - miso_soup3 Blog
RegisterDefaultValidatableObjectAdapterFactory
を利用するところまではこのgistを参考にさせていただきました。
Adding IServiceProvider to ValidationContext in ASP.NET MVC
RegisterValidatableObjectAdapterFactory
の利用方法についてここを見ればわかりました。ずーっとIValidatableObject
を実装したinterfaceを作成して上手くいかないと悩んでいました。
Validate
参考にした書籍