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対応版

Azure App Serviceへカスタムドメインを設定する

こんにちは。beaglesoftの真鍋です。

ここ1ヶ月ほどMicrosoft Azureを利用しているのですが、Microsoftらしからぬドキュメントの充実ぶりに驚いています。ただ、過去の情報と現在の情報が混在していたりしてちょっと戸惑うあたりは昔のmsdnに似ていますが…。

さて、今回初めてAzure App Serviceへカスタムドメインを設定しました。本当はAzureでドメインを購入してSSLまで設定しようと思っていたのですが、ドメインの購入に失敗してドメインが取得できなくなるという状態になってしまったので仕方なくAzureで外部で購入したドメインをカスタムドメインとして設定しました。^1

以下はそのときの手順をまとめた内容となります。

※ ここの手順はValueDomainの設定以外Azure App Service のカスタム ドメイン名の構成の手順を実施しました。

ドメインの取得

ドメインの取得は特に制限がないのですが、今回はValueDomainを利用しました。本当はAzureで購入して早めに終わらせたいと思っていたのですが、なぜかエラーになりドメインが取得できなかったためあきらめました。

ドメインの設定

取得したドメインをAzure App Serviceで利用するために以下のことを行います。

  1. cnameの確認
  2. cnameの設定(ValueDomain)
  3. cnameの設定(Azure)
  4. Aレコードの設定

なお、今回は設定するカスタムドメインをsample.com、Azure App Serviceのホスト名をsampleとします。

cnameを確認する

ドメインのcnameの設定は、Azure App Service のインスタンスを名前で関連づける設定になります。まずはAzure App Serviceのホスト名を確認します。ホスト名の確認は次の手順で行います。

  1. 対象の App Service 設定を表示し、ルーティングカスタムドメイン及びSSLを表示します。 1.外部ドメインの使用をクリックし、ドメイン名に表示された内容を確認します。

ドメイン名の説明には以下の記述がありますので、その内容をValueDomainに設定します。

このレコードが www.yourdomain.com から sample.azurewebsites.net を指すか、awverify.www.yourdomain.com から awverify.sample.azurewebsites.net を指すように設定します。

※このとき、「A レコードの構成時に使用する IP アドレス」にはIPアドレスは表示されていません。cnameが正しく設定できたら表示されます。

cnameの設定(ValueDomain)

ValueDomainではDNSの設定を行います。先ほど確認した内容から以下の設定を追加します。

cname www sample.azurewebsites.net.
cname awverify sample.azurewebsites.net.

DNS設定画面はコントロールパネルのドメイン - ドメインの設定操作(登録済みドメイン一覧)を表示し、対象となるドメイン名のDNS/URLをクリックすることで表示されます。

cnameの設定(Azure)

ドメインのcnameの設定(ValueDomain)で設定を行い、15分から30分程度してからAzure App Serviceに先ほど設定したcnameの内容を設定します。設定を行うため、cnameの確認で表示したカスタムドメイン及びSSLから外部ドメインの使用を表示します。

ドメイン名に先ほど設定したカスタムドメインを入力します。入力する内容はwww.yourdomain.comとなります。

azure-custom-domain1.png (4.9 kB)

このとき、エラーとなる場合には以下の内容を確認してください。

  1. cnameの設定(ValueDomain)の設定から時間が経っておらずDNSの伝播が行われていない。
    1. Dig web interfaceでドメイン名がAzureAppServiceと関連しているか確認してください。
  2. cnameの設定(ValueDomain)の設定が正しく設定されているか確認してください。
    1. 良くある設定間違いとして、ドメイン名の最後に.(ピリオド)を忘れることがあります。

設定が正しく行われたことを確認したら、カスタムドメイン名でAzure App Serviceにアクセスできるか確認してください。

Aレコードの設定

Aレコードの設定はcnameの設定が完了していることが前提となります。cnameの設定(Azure)が完了した状態から作業を開始します。 Aレコードはドメイン名とIPアドレスを関連づけるための設定です。この設定を行うことによりホスト名のないルートドメイン(sample.com)を使用することができます。

まずは設定するIPアドレスを確認します。

  1. 対象の App Service 設定を表示し、ルーティングカスタムドメイン及びSSLを表示します。 1.外部ドメインの使用をクリックし、A レコードの構成時に使用する IP アドレスに表示された内容を確認します。

次にValueDomainで設定を行います。 cnameの設定(ValueDomain)の時に表示したDNS設定に以下の設定を追記します。

a * 123.45.67.89

設定が完了したら、ホスト名のないルートドメイン(sample.com)でAzure App Serviceにアクセスできるか確認します。

注意事項

IPアドレスは以下のときに変更されることがあるそうなので注意が必要です。

この値は Web アプリを削除または再作成する場合や App Service のプランを Free モードに戻す場合に変更されることがあります。

ここまででカスタムドメインをAzure App Serviceへ設定する方法は完了です。お疲れ様でした。

AppDomain "master.sys[runtime].6" を作成できませんでした

こんにちは。beaglesoftの真鍋です。

ASP.NET MVCでSQL Serverを利用しているときに以下のような事象に発生しました。SQL ServerへSQLの問い合わせを実行するときに発生した例外です。

AppDomain "master.sys[runtime].6" を作成できませんでした。 ファイルまたはアセンブリ 'System.Data, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089'、またはその依存関係の 1 つが読み込めませんでした。このコマンドを実行するための十分な記憶域がありません。 (HRESULT からの例外:0x80070008)

この処理を実行しているときに、ちょうど SQL Serverではバッチ処理を実行しており、SQL Server Management Studioから接続してSQLを実行しても同様の事象が発生しました。

バッチ処理が終了後も事象については変わらなかったためSQL Serverのサービスを再起動することで事象は解決しました。

この件について少し調べてみたところ、select * from sys.dm_os_memory_clerks where type = 'MEMORYCLERK_SQLCLR'に事象の説明がありました。

ポイントとしては、

  1. SQLServerではSQL OSの中でSQL CLR を利用して動作している。
  2. このSQL CLRはアプリケーションドメインごとにメモリを確保している。
  3. そのメモリがどのようになっているか確認する。

確認するためには以下のSQLを実行します。

select * from sys.dm_os_memory_clerks where type = 'MEMORYCLERK_SQLCLR'

memory_clerk_address type                   name            memory_node_id pages_kb             virtual_memory_reserved_kb virtual_memory_committed_kb awe_allocated_kb     shared_memory_reserved_kb shared_memory_committed_kb page_size_in_bytes   page_allocator_address host_address
-------------------- ---------------------- --------------- -------------- -------------------- -------------------------- --------------------------- -------------------- ------------------------- -------------------------- -------------------- ---------------------- ------------------
0x043786B0           MEMORYCLERK_SQLCLR     Default         0              1560                 36160                      13608                       0                    0                         0                          8192                 0x043786F0             0x00000000
0x046786B0           MEMORYCLERK_SQLCLR     Default         32             0                    0                          0                           0                    0                         0                          8192                 0x046786F0             0x00000000

(2 行処理されました)

とりあえず、機会があれば再現してみようと思います。