First, sorry for the big post (I've tried to do some research first) and for the mix of technologies on the same question (ASP.NET MVC 3, Ninject and MvcContrib).
I'm developing a project with ASP.NET MVC 3 to handle some client orders.
In short: I have some objects inherited from and abstract class Order
and I need to parse them when a POST request is made to my controller. How can I resolve the correct type? Do I need to override the DefaultModelBinder
class or there are some other way to do this? Can somebody provide me some code or other links on how to do this? Any help would be great!
If the post is confusing I can do any change to make it clear!
So, I have the following inheritance tree for the orders I need to handle:
public abstract partial class Order {
public Int32 OrderTypeId {get; set; }
/* rest of the implementation ommited */
}
public class OrderBottling : Order { /* implementation ommited */ }
public class OrderFinishing : Order { /* implementation ommited */ }
This classes are all generated by Entity Framework, so I won't modify them because I will need to update the model (I know I can extend them). Also, there will be more orders, but all derived from Order
.
I have a generic view (Create.aspx
) in order to create a order and this view calls a strongly-typed partial view for each of the inherited orders (in this case OrderBottling
and OrderFinishing
). I defined a Create()
method for a GET request and other for a POST request on OrderController
class. The second is like the following:
public class OrderController : Controller
{
/* rest of the implementation ommited */
[HttpPost]
public ActionResult Create(Order order) { /* implementation ommited */ }
}
Now the problem: when I receive the POST request with the data from开发者_开发知识库 the form, MVC's default binder tries to instantiate an Order
object, which is OK since the type of the method is that. But because Order
is abstract, it cannot be instantiated, which is what is supposed to do.
The question: how can I discover which concrete Order
type is sent by the view?
I've already searched here on Stack Overflow and googled a lot about this (I'm working on this problem for about 3 days now!) and found some ways to solve some similar problems, but I couldn't find anything like my real problem. Two options for solving this:
- override ASP.NET MVC
DefaultModelBinder
and use Direct Injection to discover which type is theOrder
; - create a method for each order (not beautiful and would be problematic to maintain).
I haven't tried the second option because I don't think it's the right way to solve the problem. For the first option I've tried Ninject to resolve the type of the order and instantiate it. My Ninject module is like the following:
private class OrdersService : NinjectModule
{
public override void Load()
{
Bind<Order>().To<OrderBottling>();
Bind<Order>().To<OrderFinishing>();
}
}
I've have tried to get one of the types throught Ninject's Get<>()
method, but it tells me that the are more then one way to resolve the type. So, I understand the module is not well implemented. I've also tried to implement like this for both types: Bind<Order>().To<OrderBottling>().WithPropertyInject("OrderTypeId", 2);
, but it has the same problem... What would be the right way to implement this module?
I've also tried use MvcContrib Model Binder. I've done this:
[DerivedTypeBinderAware(typeof(OrderBottling))]
[DerivedTypeBinderAware(typeof(OrderFinishing))]
public abstract partial class Order { }
and on Global.asax.cs
I've done this:
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
RegisterRoutes(RouteTable.Routes);
ModelBinders.Binders.Add(typeof(Order), new DerivedTypeModelBinder());
}
But this throws an exception: System.MissingMethodException: Cannot create an abstract class. So, I presume the binder isn't or can't resolve to the correct type.
Many many thanks in advance!
Edit: first of all, thank you Martin and Jason for your answers and sorry for the delay! I tried both approaches and both worked! I marked Martin's answer as correct because it is more flexible and meets some of the needs for my project. Specifically, the IDs for each request are stored on a database and putting them on the class can break the software if I change the ID only in one place (database or on the class). Martin's approach is very flexible in that point.
@Martin: on my code I changed the line
var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);
to
var concreteType = Assembly.GetAssembly(typeof(Order)).GetType(concreteTypeValue.AttemptedValue);
because my classes where on another project (and so, on a different assembly). I'm sharing this because it's seems a like more flexible than getting only the executing assembly that cannot resolve types on external assemblies. In my case all the order classes are on the same assembly. It's not better nor a magic formula, but I think is interesting to share this ;)
I've tried to do something similar before and I came to the conclusion that there's nothing built in which will handle this.
The option I went with was to create my own model binder (though inherited from the default so its not too much code). It looked for a post back value with the name of the type called xxxConcreteType where xxx was another type it was binding to. This means that a field must be posted back with the value of the type you're trying to bind; in this case OrderConcreteType with a value of either OrderBottling or OrderFinishing.
Your other alternative is to use UpdateModel or TryUpdateModel and ommit the parameter from your method. You will need to determine which kind of model you're updating before calling this (either by a parameter or otherwise) and instantiate the class beforehand, then you can use either method to popuplate it
Edit:
Here is the code..
public class AbstractBindAttribute : CustomModelBinderAttribute
{
public string ConcreteTypeParameter { get; set; }
public override IModelBinder GetBinder()
{
return new AbstractModelBinder(ConcreteTypeParameter);
}
private class AbstractModelBinder : DefaultModelBinder
{
private readonly string concreteTypeParameterName;
public AbstractModelBinder(string concreteTypeParameterName)
{
this.concreteTypeParameterName = concreteTypeParameterName;
}
protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
{
var concreteTypeValue = bindingContext.ValueProvider.GetValue(concreteTypeParameterName);
if (concreteTypeValue == null)
throw new Exception("Concrete type value not specified for abstract class binding");
var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);
if (concreteType == null)
throw new Exception("Cannot create abstract model");
if (!concreteType.IsSubclassOf(modelType))
throw new Exception("Incorrect model type specified");
var concreteInstance = Activator.CreateInstance(concreteType);
bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => concreteInstance, concreteType);
return concreteInstance;
}
}
}
Change your action method to look like this:
public ActionResult Create([AbstractBind(ConcreteTypeParameter = "orderType")] Order order) { /* implementation ommited */ }
You would need to put the following in your view:
@Html.Hidden("orderType, "Namespace.xxx.OrderBottling")
You can create a custome ModelBinder that operates when your action accepts a certain type, and it can create an object of whatever type you want to return. The CreateModel() method takes a ControllerContext and ModelBindingContext that give you access to the parameters passed by route, url querystring and post that you can use to populate your object with values. The default model binder implementation converts values for properties of the same name to put them in the fields of the object.
What I do here is simply check one of the values to determine what type to create, then call the DefaultModelBinder.CreateModel() method switching the type it is to create to the appropriate type.
public class OrderModelBinder : DefaultModelBinder
{
protected override object CreateModel(
ControllerContext controllerContext,
ModelBindingContext bindingContext,
Type modelType)
{
// get the parameter OrderTypeId
ValueProviderResult result;
result = bindingContext.ValueProvider.GetValue("OrderTypeId");
if (result == null)
return null; // OrderTypeId must be specified
// I'm assuming 1 for Bottling, 2 for Finishing
if (result.AttemptedValue.Equals("1"))
return base.CreateModel(controllerContext,
bindingContext,
typeof(OrderBottling));
else if (result.AttemptedValue.Equals("2"))
return base.CreateModel(controllerContext,
bindingContext,
typeof(OrderFinishing));
return null; // unknown OrderTypeId
}
}
Set it to be used when you have an Order parameter on your actions by adding this to Application_Start() in Global.asax.cs:
ModelBinders.Binders.Add(typeof(Order), new OrderModelBinder());
You can also build a generic ModelBinder that works for all of your abstract models. My solution requires you to add a hidden field to your view called 'ModelTypeName' with the value set to the name of the concrete type that you want. However, it should be possible to make this thing smarter and pick a concrete type by matching type properties to fields in the view.
In your Global.asax.cs Application_Start():
ModelBinders.Binders.DefaultBinder = new CustomModelBinder();
CustomModelBinder:
public class CustomModelBinder : DefaultModelBinder
{
protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
{
if (modelType.IsAbstract)
{
var modelTypeValue = controllerContext.Controller.ValueProvider.GetValue("ModelTypeName");
if (modelTypeValue == null)
throw new Exception("View does not contain ModelTypeName");
var modelTypeName = modelTypeValue.AttemptedValue;
var type = modelType.Assembly.GetTypes().SingleOrDefault(x => x.IsSubclassOf(modelType) && x.Name == modelTypeName);
if(type == null)
throw new Exception("Invalid ModelTypeName");
var concreteInstance = Activator.CreateInstance(type);
bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => concreteInstance, type);
return concreteInstance;
}
return base.CreateModel(controllerContext, bindingContext, modelType);
}
}
My solution for that problem support complex models that can contain other abstract class, multiple inheritance, collections or generic classes.
public class EnhancedModelBinder : DefaultModelBinder
{
protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
{
Type type = modelType;
if (modelType.IsGenericType)
{
Type genericTypeDefinition = modelType.GetGenericTypeDefinition();
if (genericTypeDefinition == typeof(IDictionary<,>))
{
type = typeof(Dictionary<,>).MakeGenericType(modelType.GetGenericArguments());
}
else if (((genericTypeDefinition == typeof(IEnumerable<>)) || (genericTypeDefinition == typeof(ICollection<>))) || (genericTypeDefinition == typeof(IList<>)))
{
type = typeof(List<>).MakeGenericType(modelType.GetGenericArguments());
}
return Activator.CreateInstance(type);
}
else if(modelType.IsAbstract)
{
string concreteTypeName = bindingContext.ModelName + ".Type";
var concreteTypeResult = bindingContext.ValueProvider.GetValue(concreteTypeName);
if (concreteTypeResult == null)
throw new Exception("Concrete type for abstract class not specified");
type = Assembly.GetExecutingAssembly().GetTypes().SingleOrDefault(t => t.IsSubclassOf(modelType) && t.Name == concreteTypeResult.AttemptedValue);
if (type == null)
throw new Exception(String.Format("Concrete model type {0} not found", concreteTypeResult.AttemptedValue));
var instance = Activator.CreateInstance(type);
bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => instance, type);
return instance;
}
else
{
return Activator.CreateInstance(modelType);
}
}
}
As you see you have to add field (of name Type) that contains information what concrete class inheriting from abstract class should be created. For example classes: class abstract Content, class TextContent, the Content should have Type set to "TextContent". Remember to switch default model binder in global.asax:
protected void Application_Start()
{
ModelBinders.Binders.DefaultBinder = new EnhancedModelBinder();
[...]
For more information and sample project check following link.
Change the line:
var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);
To this:
Type concreteType = null;
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in loadedAssemblies)
{
concreteType = assembly.GetType(concreteTypeValue.AttemptedValue);
if (null != concreteType)
{
break;
}
}
This is a naive implementation that checks every assembly for the type. I'm sure there's smarter ways to do it, but this works well enough.
精彩评论