Asp.Net MVC3: Set custom IServiceProvider in Valid

2019-01-07 16:38发布

问题:

Update 18th December 2012

Since this question seems to be getting quite a few views, I should point out that the accepted answer is not the solution I used, but it does provide the links and resources to build a solution, but, to my mind, not the ideal solution. My answer contains replacements for standard parts of the MVC framework; and you should only use those if you are comfortable checking that they still work for future versions (some private code was ripped out of the official sources, because there wasn't enough extensibility in the base classes).

I can confirm, however, that these two classes also work for Asp.Net MVC 4 as well as 3.

It is also possible to repeat a similar implementation for the Asp.Net Web API framework as well, which I have done recently.

End update

I have a type that has a lot of 'standard' validation (required etc) but also a bit of custom validation as well.

Some of this validation requires grabbing hold of a service object and looking up some lower-level (i.e. 'beneath' the Model layer) meta data using one of the other properties as a key. The meta data then controls whether one or more properties are required as well as valid formats for those properties.

To be more concrete - the type is a Card Payment object, simplified to two of the properties in question as follows:

public class CardDetails
{
  public string CardTypeID { get; set; }
  public string CardNumber { get; set; }
}

I then have a service:

public interface ICardTypeService
{
  ICardType GetCardType(string cardTypeID);
}

ICardType then contains different bits of information - the two here that are crucial being:

public interface ICardType
{
  //different cards support one or more card lengths
  IEnumerable<int> CardNumberLengths { get; set; }
  //e.g. - implementation of the Luhn algorithm
  Func<string, bool> CardNumberVerifier { get; set; }
}

My controllers all have the ability to resolve an ICardTypeService using a standard pattern i.e.

 var service = Resolve<ICardTypeService>();

(Although I should mention that the framework behind this call is proprietary)

Which they gain via the use of a common interface

public interface IDependant
{
  IDependencyResolver Resolver { get; set; }
}

My framework then takes care of assigning the most-specific dependency resolver available for the controller instance when it is constructed (either by another resolver, or by the MVC standard controller factory). That Resolve method in the last-but one code block is a simple wrapper around this Resolver member.

So - if I can grab the selected ICardType for the payment that is received from the browser, I can then perform initial checks on card number length etc. The issue is, how to resolve the service from within my override of IsValid(object, ValidationContext) override of ValidationAttribute?

I need to pass through the current controller's dependency resolver to the validation context. I see that ValidationContext both implements IServiceProvider and has an instance of IServiceContainer - so clearly I should be able to create a wrapper for my service resolver that also implements one of those (probably IServiceProvider).

I've already noted that in all places where a ValidationContext is produced by the MVC framework, the service provider is always passed null.

So at what point in the MVC pipeline should I be looking to override the core behaviour and inject my service provider?

I should add that this will not be the only scenario in which I need to do something like this - so ideally I'd like something which I can apply to the pipeline so that all ValidationContexts are configured with the current service provider for the current controller.

回答1:

Have you thought about creating a model validator, using a modelValidatorProvider, instead of using validation attributes? This way you're not dependant on ValidationAttribute but can create your own validation implementation (this will work in addition the existing DataAnnotations validation).

http://msdn.microsoft.com/en-us/library/system.web.mvc.modelvalidatorprovider.aspx

http://dotnetslackers.com/articles/aspnet/Experience-ASP-NET-MVC-3-Beta-the-New-Dependency-Injection-Support-Part2.aspx#s10-new-support-for-validator-provider

http://dotnetslackers.com/articles/aspnet/Customizing-ASP-NET-MVC-2-Metadata-and-Validation.aspx#s2-validation



回答2:

On MVC 5.2, you can leveragesteal @Andras's answer and the MVC source and:

1. Derive a DataAnnotationsModelValidatorEx from DataAnnotationsModelValidator

namespace System.Web.Mvc
{
    // From https://aspnetwebstack.codeplex.com/SourceControl/latest#src/System.Web.Mvc/DataAnnotationsModelValidator.cs
    // commit 5fa60ca38b58, Apr 02, 2015
    // Only diff is adding of secton guarded by THERE_IS_A_BETTER_EXTENSION_POINT
    public class DataAnnotationsModelValidatorEx : DataAnnotationsModelValidator
    {
        readonly bool _shouldHotwireValidationContextServiceProviderToDependencyResolver;

        public DataAnnotationsModelValidatorEx(
            ModelMetadata metadata, ControllerContext context, ValidationAttribute attribute,
            bool shouldHotwireValidationContextServiceProviderToDependencyResolver=false)
            : base(metadata, context, attribute)
        {
           _shouldHotwireValidationContextServiceProviderToDependencyResolver =
                shouldHotwireValidationContextServiceProviderToDependencyResolver;
        }
    }
}

2. Clone the base impl of public override IEnumerable<ModelValidationResult> Validate(object container)

3. Apply the hack Render the elegant incision after Validate creates the context:-

public override IEnumerable Validate(object container) { // Per the WCF RIA Services team, instance can never be null (if you have // no parent, you pass yourself for the "instance" parameter). string memberName = Metadata.PropertyName ?? Metadata.ModelType.Name; ValidationContext context = new ValidationContext(container ?? Metadata.Model) { DisplayName = Metadata.GetDisplayName(), MemberName = memberName };

#if !THERE_IS_A_BETTER_EXTENSION_POINT
   if(_shouldHotwireValidationContextServiceProviderToDependencyResolver 
       && Attribute.RequiresValidationContext)
       context.InitializeServiceProvider(DependencyResolver.Current.GetService);
#endif
   ValidationResult result = Attribute.GetValidationResult(Metadata.Model, context);
    if (result != ValidationResult.Success)
    {
        // ModelValidationResult.MemberName is used by invoking validators (such as ModelValidator) to
        // construct the ModelKey for ModelStateDictionary. When validating at type level we want to append the
        // returned MemberNames if specified (e.g. person.Address.FirstName). For property validation, the
        // ModelKey can be constructed using the ModelMetadata and we should ignore MemberName (we don't want
        // (person.Name.Name). However the invoking validator does not have a way to distinguish between these two
        // cases. Consequently we'll only set MemberName if this validation returns a MemberName that is different
        // from the property being validated.

       string errorMemberName = result.MemberNames.FirstOrDefault();
        if (String.Equals(errorMemberName, memberName, StringComparison.Ordinal))
        {
            errorMemberName = null;
        }

       var validationResult = new ModelValidationResult
        {
            Message = result.ErrorMessage,
            MemberName = errorMemberName
        };

       return new ModelValidationResult[] { validationResult };
    }

   return Enumerable.Empty<ModelValidationResult>();
}

4. Tell MVC about the new DataAnnotationsModelValidatorProvider in town

after your Global.asax does DependencyResolver.SetResolver(new AutofacDependencyResolver(container)) :-

DataAnnotationsModelValidatorProvider.RegisterAdapterFactory(
    typeof(ValidatorServiceAttribute),
    (metadata, context, attribute) => new DataAnnotationsModelValidatorEx(metadata, context, attribute, true));

5. Use your imagination to abuse your new Service Locator consume using ctor injection via GetService in your ValidationAttribute, for example:

public class ValidatorServiceAttribute : ValidationAttribute
{
    readonly Type _serviceType;

    public ValidatorServiceAttribute(Type serviceType)
    {
        _serviceType = serviceType;
    }

    protected override ValidationResult IsValid(
        object value, 
        ValidationContext validationContext)
    {
        var validator = CreateValidatorService(validationContext);
        var instance = validationContext.ObjectInstance;
        var resultOrValidationResultEmpty = validator.Validate(instance, value);
        if (resultOrValidationResultEmpty == ValidationResult.Success)
            return resultOrValidationResultEmpty;
        if (resultOrValidationResultEmpty.ErrorMessage == string.Empty)
            return new ValidationResult(ErrorMessage);
        return resultOrValidationResultEmpty;
    }

    IModelValidator CreateValidatorService(ValidationContext validationContext)
    {
        return (IModelValidator)validationContext.GetService(_serviceType);
    }
}

Allows you to slap it on your model:-

class MyModel 
{
    ...
    [Required, StringLength(42)]
    [ValidatorService(typeof(MyDiDependentValidator), 
        ErrorMessage = "It's simply unacceptable")]
    public string MyProperty { get; set; }
    ....
}

which wires it to a:

public class MyDiDependentValidator : Validator<MyModel>
{
    readonly IUnitOfWork _iLoveWrappingStuff;

    public MyDiDependentValidator(IUnitOfWork iLoveWrappingStuff)
    {
        _iLoveWrappingStuff = iLoveWrappingStuff;
    }

    protected override bool IsValid(MyModel instance, object value)
    {
        var attempted = (string)value;
        return _iLoveWrappingStuff.SaysCanHazCheez(instance, attempted);
    }
}

The preceding two are connected by:

interface IModelValidator
{
    ValidationResult Validate(object instance, object value);
}

public abstract class Validator<T> : IModelValidator
{
    protected virtual bool IsValid(T instance, object value)
    {
        throw new NotImplementedException(
            "TODO: implement bool IsValid(T instance, object value)" +
            " or ValidationResult Validate(T instance, object value)");
    }

    protected virtual ValidationResult Validate(T instance, object value)
    {
        return IsValid(instance, value) 
            ? ValidationResult.Success 
            : new ValidationResult("");
    }

    ValidationResult IModelValidator.Validate(object instance, object value)
    {
        return Validate((T)instance, value);
    }
}

I'm open to corrections, but most of all, ASP.NET team, would you be open to a PR to add a constructor with this facility to DataAnnotationsModelValidator?



回答3:

Update

In addition to the class shown below, I've done a similar thing for IValidatableObject implementations as well (short notes towards the end of the answer instead of a full code sample because then the answer just gets too long) - I've added the code for that class as well in response to a comment - it does make the answer very long, but at least you'll have all the code you need.

Original

Since I'm targeting ValidationAttribute-based validation at the moment I researched where MVC creates the ValidationContext that gets fed to the GetValidationResult method of that class.

Turns out it's in the DataAnnotationsModelValidator's Validate method:

public override IEnumerable<ModelValidationResult> Validate(object container) {
  // Per the WCF RIA Services team, instance can never be null (if you have
  // no parent, you pass yourself for the "instance" parameter).
  ValidationContext context = new ValidationContext(
    container ?? Metadata.Model, null, null);
  context.DisplayName = Metadata.GetDisplayName();

  ValidationResult result = 
    Attribute.GetValidationResult(Metadata.Model, context);

  if (result != ValidationResult.Success) {
    yield return new ModelValidationResult {
      Message = result.ErrorMessage
    };
  }
}

(Copied and reformatted from MVC3 RTM Source)

So I figured some extensibility here would be in order:

public class DataAnnotationsModelValidatorEx : DataAnnotationsModelValidator
{
  public DataAnnotationsModelValidatorEx(
    ModelMetadata metadata, 
    ControllerContext context, 
    ValidationAttribute attribute)
    : base(metadata, context, attribute)
  {
  }

  public override IEnumerable<ModelValidationResult> Validate(object container)
  {
    ValidationContext context = CreateValidationContext(container);

    ValidationResult result = 
      Attribute.GetValidationResult(Metadata.Model, context);

    if (result != ValidationResult.Success)
    {
      yield return new ModelValidationResult
      {
        Message = result.ErrorMessage
      };
    }
  }

  // begin Extensibility

  protected virtual ValidationContext CreateValidationContext(object container)
  {
    IServiceProvider serviceProvider = CreateServiceProvider(container);
    //TODO: add virtual method perhaps for the third parameter?
    ValidationContext context = new ValidationContext(
      container ?? Metadata.Model, 
      serviceProvider, 
      null);
    context.DisplayName = Metadata.GetDisplayName();
    return context;
  }

  protected virtual IServiceProvider CreateServiceProvider(object container)
  {
    IServiceProvider serviceProvider = null;

    IDependant dependantController = 
      ControllerContext.Controller as IDependant;

    if (dependantController != null && dependantController.Resolver != null)
      serviceProvider = new ResolverServiceProviderWrapper
                        (dependantController.Resolver);
    else
      serviceProvider = ControllerContext.Controller as IServiceProvider;
    return serviceProvider;
  }
}

So I check first for my IDependant interface from the controller, in which case I create an instance of a wrapper class that acts as an adapter between my IDependencyResolver interface and System.IServiceProvider.

I thought I'd also handle cases where a controller itself is an IServiceProvider too (not that that applies in my case - but it's a more general solution).

Then I make the DataAnnotationsModelValidatorProvider use this validator by default, instead of the original:

//register the new factory over the top of the standard one.
DataAnnotationsModelValidatorProvider.RegisterDefaultAdapterFactory(
  (metadata, context, attribute) => 
    new DataAnnotationsModelValidatorEx(metadata, context, attribute));

Now 'normal' ValidationAttribute-based validators, can resolve services:

public class ExampleAttribute : ValidationAttribute
{
  protected override ValidationResult 
    IsValid(object value, ValidationContext validationContext)
  {
    ICardTypeService service = 
      (ICardTypeService)validationContext.GetService(typeof(ICardTypeService));
  }
}

This still leaves direct ModelValidator-derived needing to be reimplemented to support the same technique - although they already have access to the ControllerContext, so it's less of an issue.

Update

A similar thing has to be done if you want IValidatableObject-implementing types to be able to resolve services during the implementation of Validate without having to keep deriving your own adapters for each type.

  • Derive a new class from ValidatableObjectAdapter, I called it ValidatableObjectAdapterEx
  • from MVCs v3 RTM source, copy the Validate and ConvertResults private method of that class.
  • Adjust the first method to remove references to internal MVC resources, and
  • change how the ValidationContext is constructed

Update (in response to comment below)

Here's the code for the ValidatableObjectAdapterEx - and I'll point out hopefully more clearly that IDependant and ResolverServiceProviderWrapper used here and before are types that only apply to my environment - if you're using a global, statically-accessible DI container, however, then it should be trivial to re-implement these two classes' CreateServiceProvider methods appropriately.

public class ValidatableObjectAdapterEx : ValidatableObjectAdapter
{
  public ValidatableObjectAdapterEx(ModelMetadata metadata, 
                                    ControllerContext context)
   : base(metadata, context) { }

  public override IEnumerable<ModelValidationResult> Validate(object container)
  {
    object model = base.Metadata.Model;
    if (model != null)
    {
      IValidatableObject instance = model as IValidatableObject;
      if (instance == null)
      {
        //the base implementation will throw an exception after 
        //doing the same check - so let's retain that behaviour
        return base.Validate(container);
      }
      /* replacement for the core functionality */
      ValidationContext validationContext = CreateValidationContext(instance);
      return this.ConvertResults(instance.Validate(validationContext));
    }
    else
      return base.Validate(container);  /*base returns an empty set 
                                          of values for null. */
  }

  /// <summary>
  /// Called by the Validate method to create the ValidationContext
  /// </summary>
  /// <param name="instance"></param>
  /// <returns></returns>
  protected virtual ValidationContext CreateValidationContext(object instance)
  {
    IServiceProvider serviceProvider = CreateServiceProvider(instance);
    //TODO: add virtual method perhaps for the third parameter?
    ValidationContext context = new ValidationContext(
      instance ?? Metadata.Model,
      serviceProvider,
      null);
    return context;
  }

  /// <summary>
  /// Called by the CreateValidationContext method to create an IServiceProvider
  /// instance to be passed to the ValidationContext.
  /// </summary>
  /// <param name="container"></param>
  /// <returns></returns>
  protected virtual IServiceProvider CreateServiceProvider(object container)
  {
    IServiceProvider serviceProvider = null;

    IDependant dependantController = ControllerContext.Controller as IDependant;

    if (dependantController != null && dependantController.Resolver != null)
    {
      serviceProvider = 
        new ResolverServiceProviderWrapper(dependantController.Resolver);
    }
    else
      serviceProvider = ControllerContext.Controller as IServiceProvider;

    return serviceProvider;
  }

  //ripped from v3 RTM source
  private IEnumerable<ModelValidationResult> ConvertResults(
    IEnumerable<ValidationResult> results)
  {
    foreach (ValidationResult result in results)
    {
      if (result != ValidationResult.Success)
      {
        if (result.MemberNames == null || !result.MemberNames.Any())
        {
          yield return new ModelValidationResult { Message = result.ErrorMessage };
        }
        else
        {
          foreach (string memberName in result.MemberNames)
          {
            yield return new ModelValidationResult 
             { Message = result.ErrorMessage, MemberName = memberName };
          }
        }
      }
    }
  }
}

End Code

With that class in place, you can register this as the default adapter for IValidatableObject instances with the line:

DataAnnotationsModelValidatorProvider.
  RegisterDefaultValidatableObjectAdapterFactory(
    (metadata, context) => new ValidatableObjectAdapterEx(metadata, context)
  );