IValidatableObjectのValidateメソッドへDIを行う

このエントリーは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()とかしないと言うことです。

次に手順です。わかりづらいですが、とりあえず試してみると理解できると思います。

  1. IServiceProviderとIDependencyResolverを実装したServiceProviderを実装する。
  2. ValidatableObjectAdapterを実装したValidatableObjectAdapterを作成して、1で作成したServiceProviderをコンストラクタで設定する。
  3. UnityなどDIコンテナでDataAnnotationsModelValidatorProviderに2で作成したValidatableObjectAdapterを登録します。

IServiceProviderとIDependencyResolverを実装したServiceProviderを実装する

IServiceProviderIServiceProvider インターフェイス (System).aspx)で以下の通り説明されています。

サービス オブジェクト、つまり、他のオブジェクトにカスタム サポートを提供するオブジェクトを取得するための機構を定義します。

いまいちわかりづらいですが、他のオブジェクトに何かしらのオブジェクトを提供するために利用するものです。なぜ唐突にIServiceProviderが登場するかというと、IValidatableObjectに関連します。

IValidatableObjectValidateでは引数としてValidationContextを受け取ります。このValidationContextには GetServiceが定義されており、ここから今回取得したいServiceやRepositoryなどを取得するのですが、このGetServiceで取得できる値はValidationContextのコンストラクタで指定したIServiceProviderとなります。

このため、ValidationContextIServiceProviderを実装したクラスを渡したいわけです。

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

参考にした書籍

プログラミングASP.NET MVC 第3版 ASP.NET MVC 5対応版

プログラミングASP.NET MVC 第3版 ASP.NET MVC 5対応版