代码改变世界

对Action方法的参数进行双向转化

2009-10-23 09:47  Jeffrey Zhao  阅读(19210)  评论(24编辑  收藏  举报

昨天有朋友忽然告诉我,在G点中国上搜索URL Routing时,我的《请别埋没了URL Routing》一文排在首位。这不禁让我汗颜,这是因为从现在的角度看起来,这篇文章的内容虽不能算错,但的确也不算是一种非常合适的做法。那篇文章的目的是展示如何利用URL Routing的扩展能力,将URL和Route Values通过Formatter进行双向的转化。这样便可以在Action方法中使用复杂参数的同时,也可以使用复杂参数得到正确的URL。这个目标是好的,可惜当时的思路有些偏差。现在我总结出了更合适的做法,并已经在项目中大量使用,效果不错。

之前提出那种原因,是因为Model Binder是单向的。也就是说,使用Model Binder把URL Routing的结果转化成Action的参数时不会有任何问题。例如,我们可以定义一个DateTimeModelBinder,接受一个字符串作为格式参数,这样便可以把URL Routing过程中得到的字符串转化为Action方法的DateTime类型参数了:

public ActionResult Date([DateTime("yyyy-MM-dd")]DateTime date) { ... }

但是,如果我们想要在视图中使用URL Routing来构造URL:

Html.ActionLink("Tomorrow", "Date", new { date = date.AddDays(1) })

或借助MvcFutures里提供的强类型(表达式树)辅助方法(其实它也是利用了URL Routing):

Html.ActionLink<DemoController>(c => c.Date(date.AddDays(1)), "Tomorrow")

问题就来了,因为它们得到的结果与我们的期望相距甚远:

<a href="/Demo/Date/01/01/2003%2000:00:00">Tomorrow</a>

看这链接中Date后面的那部分,多奇妙,多恶心。出现这个问题的原因在于,我们使用Model Binder可以知道如何将一个字符串正确转化为DateTime对象。但是,在构造URL的时候,如果我们利用了URL Routing,那么直接提供的复杂对象就会通过默认的ToString方法来作为URL的一部分——这显然是有问题的,例如这里的DateTime。因为这一点,在ASP.NET MVC应用程序中使用强类型的表达式树来生成URL几乎是一个不可实现的功能。

当然,我们是程序员,我们可以扩展。因此,我当时扩展了一个FormatRoute,它使用装饰器模式,封装了一个RouteBase对象,并且在GetRouteData时使用Formatter将RouteValueDictionary中的值直接从字符串转化成复杂类型的对象——并且在GetVirtualPath方法中作一个反向的操作。由于Routing功能是双向的,因此我们这么做便可以解决上面这个问题。当然,使用FormatRoute之后,生成Action参数的职责就交到了URL Routing阶段里。在一段时间里,我在项目中定下了这样的“规范”:

  • 如果是从URL中得到的Action参数,那么转化职责交给URL Routing。
  • 如果是从别处(如Post过来的数据)得到的Action参数,转化职责交给Model Binder。

这样,我们在视图中便可以使用强类型的表达式树来生成URL了,同时享受静态检查所得到的便利。

可惜看上去很美,但用起来一般。原因便是——太麻烦了。试想,我们在配置URL Routing的时候,往往会使用同一条Routing规则对应多个不同的URL形式,最终进入不同的Action方法(如默认的{controller}/{action}/{id})。但是,复杂类型参数的转化逻辑是根据Action不同而有所改变的。因此,如果我们要将转化参数的职责交给URL Routing的话,势必需要为每个Action方法指定一个Routing规则。那么好,这样Routing规则是不是会变得泛滥?如果我要修改Action,是不是还要去修改对应的Routing规则?这不是把相关的逻辑分散了吗?于是我又想出了其他一些方式来弥补这个问题,例如由Controller负责Routing规则的配置等等,这些尝试就不多提了。

总之,这个方法的目标正确,但是解决方式有些问题。但是,我们又能如何解决呢?既然事情是出在“URL生成”的方式上,那么我们就来改变一下辅助方法的逻辑吧,反正我们已经为它优化了性能,使它支持指定的Route名称,以及可以忽略某些参数等等。于是我在MvcPatch项目中增加了额外的IRouteBinder接口:

public interface IRouteBinder
{
    RouteValueDictionary BindRoute(RequestContext requestContext, RouteBindingContext bindingContext);
}

public class RouteBindingContext
{
    public object Model { get; set; }

    public Type ModelType { get; set; }

    public string ModelName { get; set; }
}

与Model Binder的功能正好相反,Route Binder的作用是把一个复杂类型的参数转化为一个RouteValueDictionary。在构造URL的辅助方法中会去检查标记在这个参数,或者这个参数类型中的Model Binder(没错,就是ASP.NET MVC本身获取Model Binder的方式),然后如果这个Model Binder还实现了IRouteBinder接口,则会先进行转化再构造URL,否则就和以前一样,直接使用这个参数的值构造URL。

因此,文章之前的DateTime参数的转化,便可以使用这样的一个Binder来处理:

public class DateTimeBinder : IModelBinder, IRouteBinder
{
    public DateTimeBinder(string format)
    {
        this.Format = format;
    }

    public string Format { get; private set; }

    #region IModelBinder Members

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var rawValue = bindingContext.ValueProvider[modelName].RawValue;
        return DateTime.ParseExact(rawValue.ToString(), this.Format, null);
    }

    #endregion

    #region IRouteBinder Members

    public RouteValueDictionary BindRoute(RequestContext requestContext, RouteBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var model = (DateTime)bindingContext.Model;
        var routeValues = new RouteValueDictionary();
        routeValues.Add(modelName, model.ToString(this.Format));
        return routeValues;
    }

    #endregion
}

于是,这个世界和平了。

当然,这个做法和之前相比也有缺陷,那是因为IRouteBinder的功能是在辅助方法内调用的,因此如果您绕开辅助方法使用URL Routing构造URL的话,复杂类型的参数便无法得到转化了。不过权衡之下,现在这个做法使用更加便捷,由于把双向的转化逻辑放在同一处,其维护性也很好。经过我目前项目的使用,效果良好。

最近,我打算在MvcPatch中构建一个合适的示例程序,不会太复杂也但也足以用上MvcPatch里的各种功能(例如一个类似博客形式的应用程序)。如果您有合适的题材,也请及时告诉我。