代码改变世界

为URL生成设计流畅接口(Fluent Interface)

2009-11-03 09:43 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

昨天我比较了三种URL生成方式的性能,并对结果进行了分析。从结果中我们得知使用Lambda表达式生成URL的性能最差,而且差到了难以接受的地步。经过分析,我们发现其中光“构造表达式树”这个阶段就占了接近30%的开销。虽然表达式树的节点是有些多,但是.NET中创建对象其实非常快,我实在没想到它会占这么高的比例。因此,我们需要这种做法进行方向性的调整,减少对象创建的数目。

但是,既然我们要尽可能地保留“静态检查”的优势,因此这里的关键是如何设计出既美观好用,又高效的做法。例如,我在上一篇文章最后留下的做法是这样的:

// API签名 
public static string Action(this UrlHelper helper, Expression template, params object[] args)
 
// 使用方式 
private readonly static Expression<...> ToPostTemplate = (c, blog, post) => c.Post(blog, post);
public static string ToPost(this UrlHelper helper, Blog blog, Post post)
{
    return helper.Action(ToPostTemplate, blog, post);
}

我要求为每个Action都指定一个表达式“模板”,它是一个单例,既可以作为解析,又可用于缓存。它也不会创建额外对象,因此可以缓解性能问题。但是,它的缺点也很明显:

  • 必须单独创建一个表达式,因此使用比较麻烦(无法直接在视图中调用)。
  • 表达式模板的签名非常麻烦,无法利用C#编译器的类型推导能力。
  • 使用params object[]指定Action的参数,仍有些“弱类型”的意味。

因此,这个方式其实并不理想。不过,在文章的评论中,Ivony...老大给了我一些提示,然后再讨论中,我们总结出了一个似乎不错的API。这个API首先利用了C#编译器的一个特性:可以将一个方法直接转化为相同签名的委托对象,而不需要使用new进行显式创建。例如:

HomeController controller = new HomeController();
Func<Blog, Post, ActionResult> action = controller.Post;

如第2行代码,其完整的写法应该使用new关键字,配合委托的类型,把方法包装为一个委托。但是,从C# 2.0起编译器就允许我们使用省略的写法。于是接下来,我们便要设法利用C# 3.0中那微弱的类型推导能力进行API设计了。从以前的一次讨论中,我们了解了C# 3.0编译器类型推导的一些“盲点”,例如:一个泛型方法只有通过传入的参数,才能确定泛型参数的具体类型。它使得以下代码无法编译通过:

// 定义
static void Do<T1, T2>(Func<T1, T2, ActionResult> action)

// 调用(类型推导失败)
Do(controller.Post);

除非通过其他两个参数来明确T1和T2的类型:

// 方法定义
static void Do<T1, T2>(Func<T1, T2, ActionResult> action, T1 arg1, T2 arg2)

// 调用(编译成功)
Post post = null;
Blog blog = null;
HomeController controller = new HomeController();
Do(controller.Post, blog, post);

可惜委托的返回值还是必须指明ActionResult类型,它无法推导出来。不过,这似乎也基本满足我们的需求。那么,我们又如何为Do方法提供controller的类型信息呢?要知道这样的API是行不通的:

public static string Action<TController, T1, T2>(this UrlHelper helper, ...)

因为C#编译器不允许在方法调用时指定部分参数(如单独指定TController而让T1,T2由类型推导获得),因此我们必须将Controller类型的指定工作,与剩下的泛型参数分开:

public static class UrlHelperExtensions
{
    public static ActionOf<TController> Of<TController>(this UrlHelper helper) where TController : new()
    {
        return new ActionOf<TController>(helper);
    }
}

public class ActionOf<TController>
{
    public ActionOf(UrlHelper urlHelper)
    {
        this.m_urlHelper = urlHelper;
    }

    public UrlHelper m_urlHelper;
}

在ActionOf类中已经明确了TController泛型类型,它只要负责推导后续的参数即可,因此我们定义出这样的方法:

public string Action<T1, T2>(Func<TController, Func<T1, T2, ActionResult>> action, T1 arg1, T2 arg2)

请注意现在Action方法第一个参数的类型:这是一个委托对象,接受TController类型作为参数,返回另一个委托对象——它不需要使用new进行显式创建。这个委托对象返回ActionResult类型,并接受T1,T2两个参数——这两个参数的类型又可以由Action方法的后续参数确定下来。因此,最后的调用方法大概是这样的:

Url.Of<HomeController>().Action(c => c.Post, blog, post)

这行代码可读性也不错,我们可以这样理解:“URL of HomeController’s action ‘Post’ with parameter ‘blog’ & ‘post’...”,因此它也可以算是一个流畅接口(Fluent Interface)。这个调用方式在我看来还是挺美观的,而且有明确的静态类型检查,属于比较理想的API。至于在Action方法内部,我们可以通过传入一个TController类型的对象来得到一个委托,这个委托对象的Method属性便是那个Action的MethodInfo——至于它的参数,便是blog和post了:

public class ActionOf<TController> where TController : new()
{    
    private static TController prototype = new TController();

    public string Action<T1, T2>(Func<TController, Func<T1, T2, ActionResult>> action, T1 arg1, T2 arg2)
    {
        return Action(action(prototype).Method, arg1, arg2);
    }

    public static string Action(MethodInfo methodInfo, params object[] args) { ... }
}

在这段代码中,我要求TController类型有个默认的构造函数,这样便于我们构造一个TController来“获取委托”,并得到它的Method信息。在实际使用过程中,我们不能强求每个Controller类型都满足这个条件,因此我打算使用Emit来动态生成TController的子类。使用Emit的好处在于,我们生成的动态类型可以绕开C#编译器的限制,不调用任何基类的构造函数,这样可以创建一个无用的对象而不会触及基类的任何逻辑,恰好满足我们的需求。

当然,这种流畅接口和Lambda表达式相比还是有些缺点,如:

  • 获得IDE的智能提示效果不佳。
  • 需要为参数数目不同的Action方法准备不同的重载。
  • 对类型要求严格,而构造Lambda表达式可以如普通C#代码那样的“宽松”

关于最后一点可能值得用代码示例来说明。如这样的Action方法:

public ActionResult Detail(long id) { ... }

而构造URL时:

int id = 0;
helper.Action<HomeController>(c => c.Detail(id));  // Lambda Expression
helper.Of<HomeController>().Action(c => c.Detail, (long)id); // Fluent Interface

在流畅接口中将id强制转型为long的操作是不可以省略的,因为ActionOf<HomeController>.Action方法需要根据泛型类型来推断出c.Detail的签名。如果传入一个int类型的id,则编译器便会告知我们“c.Detail不符合Func<int, ActionResult>类型”,进而编译失败。而使用Lambda表达式时,C#编译器会自动对id进行“提升(Lift)”操作,把int转化为long。不过这点在实际使用过程中应该不会成为问题。

使用流畅接口生成URL时还是会创建一些对象,例如ActionOf对象,action委托对象、调用action后得到的另一个委托对象等等,可能还需要包括公用的Action(MethodInfo, params object[])方法所需要的对象数组。不过对象数量还是要比Lambda表达式少一些,而且不像Expression类型的那些工厂方法包含一些逻辑,因此在性能是有所提高的。这里我进行了一个简单的性能测试,得到的结果为:

Lambda Expression
        Time Elapsed:   9,353ms
        CPU Cycles:     20,339,339,141
        Gen 0:          373
        Gen 1:          0
        Gen 2:          0

Fluent Interface
        Time Elapsed:   3,041ms
        CPU Cycles:     6,614,088,228
        Gen 0:          97
        Gen 1:          0
        Gen 2:          0

可见,使用流畅接口的方式,在构造URL的第1阶段(构造对象)可以得到超过2/3的性能提升,而如果参数数量多的话差距应该会更为明显。

您觉得这个方式如何?可以给出您的看法吗?

相关文章