Hello World
Spiga

趣味编程:C#中Specification模式的实现(参考答案 - 下)

2009-09-29 10:35 by 老赵, 13307 visits

上一篇文章中我们利用C#语言的特性实现了一种轻量级的Specification模式,它的关键在于抛弃了具体的Specification类型,而是使用一个委托对象代替唯一关键的IsSatisfiedBy方法逻辑。据我们分析,其优势之一在于使用简单,其劣势之一在于无法静态表示。但是它们还都是在处理“业务逻辑”,如果涉及到一个用于LINQ查询或其他地方的表达式树,则问题就不那么简单了——但也没有我们想象的那么复杂。

好,那么我们就把场景假想至LINQ上。LINQ与普通业务逻辑不同的地方在于,它不是用一个IsSatisfiedBy方法或一个委托对象用来表示判断逻辑,而是需要构造一个表达式树,一种数据结构。如果您还不清楚表达式树是什么,那么可以看一下脑袋的写的上手指南。这是.NET 3.5带来的重要概念,在4.0中又得到了重要发展,如果您要在.NET方面前进,这是一条必经之路。

And、Or和Not之间,最容易处理的便是Not方法,于是我们从这个地方下手,直接来看它的实现:

public static class SpecExprExtensions
{
    public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> one)
    {
        var candidateExpr = one.Parameters[0];
        var body = Expression.Not(one.Body);

        return Expression.Lambda<Func<T, bool>>(body, candidateExpr);
    }
}

一个Expression<TDelegate>对象中主要有两部分内容,一是参数,二是表达式体(Body)。对于Not方法来说,我们只要获取它的参数表达式,再将它的Body外包一个Not表达式,便可以此构造一个新的表达式了。这部分逻辑非常简单,看了脑袋的文章,了解了表达式树的基本结构就能理解这里的含义。那么试验一下:

Expression<Func<int, bool>> f = i => i % 2 == 0;
f = f.Not();

foreach (var i in new int[] { 1, 2, 3, 4, 5, 6 }.AsQueryable().Where(f))
{
    Console.WriteLine(i);
}

打印出来的自然是所有的奇数,即1、3、5。

而And和Or的处理上会有所麻烦,我们不能这样像Not一样简单处理:

public static Expression<Func<T, bool>> And<T>(
    this Expression<Func<T, bool>> one, Expression<Func<T, bool>> another)
{
    var candidateExpr = one.Parameters[0];
    var body = Expression.And(one.Body, another.Body);
    return Expression.Lambda<Func<T, bool>>(body, candidateExpr);
}

这么做虽然能够编译通过,但是在执行时便会出错。原因在于one和another两个表达式虽然都是同样的形式(Expression<Func<T, bool>>),但是它们的“参数”不是同一个对象。也就是说,one.Body和another.Body并没有公用一个ParameterExpression实例,于是我们无论采用哪个表达式的参数,在Expression.Lambda方法调用的时候,都会告诉您新的body中的某个参数对象并没有出现在参数列表中。

于是,我们如果要实现And和Or,做的第一件事情便是统一两个表达式树的参数,于是我们准备一个ExpressionVisitor:

internal class ParameterReplacer : ExpressionVisitor
{
    public ParameterReplacer(ParameterExpression paramExpr)
    {
        this.ParameterExpression = paramExpr;
    }

    public ParameterExpression ParameterExpression { get; private set; }

    public Expression Replace(Expression expr)
    {
        return this.Visit(expr);
    }

    protected override Expression VisitParameter(ParameterExpression p)
    {
        return this.ParameterExpression;
    }
}

ExpressionVisitor几乎是处理表达式树这种数据结构的不二法门,它可以用于求值,变形(其实是生成新的结构,因为表达式树是immutable的数据结构)等各种操作。例如,解决表达式树的缓存时用它来求树的散列值或读写前缀树,快速计算表达式时用它来提取表达式树的参数,并将不同的表达式树“标准化”为相同的结构。

ExpressionVisitor基类的关键,就在于提供了遍历表达式树的标准方式,如果您直接继承这个类并调用Visit方法,那么最终返回的结果便是传入的Expression参数本身。但是,如果您覆盖的任意一个方法,返回了与传入时不同的对象,那么最终的结果就会是一个新的Expression对象。ExpressionVisitor类中的每个方法都负责一类表达式,也都都遵循了类似的原则:它们会递归地调用Visit方法,如果Visit返回新对象,那么它们也会构造新对象并返回。

ParameterReplacer类的作用是将一个表达式树里的所有ParameterExpression替换成我们指定的新对象,因此只需覆盖VisitParameter方法就可以了。有了它之后,And和Or方法的实现轻而易举:

public static Expression<Func<T, bool>> And<T>(
    this Expression<Func<T, bool>> one, Expression<Func<T, bool>> another)
{
    var candidateExpr = Expression.Parameter(typeof(T), "candidate");
    var parameterReplacer = new ParameterReplacer(candidateExpr);

    var left = parameterReplacer.Replace(one.Body);
    var right = parameterReplacer.Replace(another.Body);
    var body = Expression.And(left, right);

    return Expression.Lambda<Func<T, bool>>(body, candidateExpr);
}

public static Expression<Func<T, bool>> Or<T>(
    this Expression<Func<T, bool>> one, Expression<Func<T, bool>> another)
{
    var candidateExpr = Expression.Parameter(typeof(T), "candidate");
    var parameterReplacer = new ParameterReplacer(candidateExpr);

    var left = parameterReplacer.Replace(one.Body);
    var right = parameterReplacer.Replace(another.Body);
    var body = Expression.Or(left, right);

    return Expression.Lambda<Func<T, bool>>(body, candidateExpr);
}

于是,我们最终构造得到的Expression<Func<T, bool>>对象便可以传入一个LINQ Provider,最终得到查询结果:

Expression<Func<int, bool>> f = i => i % 2 == 0;
f = f.Not().And(i => i % 3 == 0).Or(i => i % 4 == 0);

foreach (var i in new int[] { 1, 2, 3, 4, 5, 6 }.AsQueryable().Where(f))
{
    Console.WriteLine(i);
}

输出的结果是3和4。

这种做法是非常有实用价值的。因为有了LINQ,因此许多朋友都会选择在数据访问层暴露一个这样的方法给上层调用:

class ProductDao
{
    public Product GetProduct(Expression<Func<Product, bool>> predicate)
    {
        ...
    }
}

但是您有没有想过这么做的缺点是什么呢?这么做的缺点便是“过于自由”。由于GetProduct方法只将参数限制为一个Expression<Func<Product, bool>>对象,因此在调用的时候,我们可以使用任意的形式进行传递。因此,外层完全有可能传入一个目前LINQ Provider不支持的表达式树形式,也完全有可能传入一个虽然支持,但会导致查询速度慢,影响项目整体性能的表达式树。前者要在运行时才抛出异常,而后者则引发的性能问题则更难发现。因此我认为,数据访问层不应该如此自由,它要做出限制。而限制的方式,便是使用Query Object模式,让GetProduct方法接受一个受限的Criteria对象:

public abstract class ProductCriteria
{
    internal ProductCriteria(Expression<Func<Product, bool>> query)
    {
        this.Query = query;
    }

    public Expression<Func<Product, bool>> Query { get; private set; }
}

class ProductDao
{
    public Product GetProduct(ProductCriteria predicate)
    {
        ...
    }
}

而在使用时,我们只提供有限的几种条件,如:

public class ProductIdEqCriteria : ProductCriteria
{
    public ProductIdEqCriteria(int id)
        : base(p => p.ProductID == id)
    { }
}

public class ProductViewRangeCriteria : ProductCriteria
{
    public ProductViewRangeCriteria(int min, int max)
        : base(p => p.ViewCount > min && p.ViewCount < max)
    { }
}

再加上配套的扩展方法用于And,Or,Not,于是一切尽在掌握。现在再去瞅瞅原Query Object模式中复杂的实现,您是否会有一种满足感?

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

21 条回复

  1. James.Ying
    *.*.*.*
    链接

    James.Ying 2009-09-29 10:55:00

    抢个沙发 不容易啊

  2. shenzhen
    *.*.*.*
    链接

    shenzhen 2009-09-29 10:57:00

    赵老师我来了。。每次都拿了你的文章就走真不好意识啊。。

  3. Yin.Pu@CQUSoft
    *.*.*.*
    链接

    Yin.Pu@CQUSoft 2009-09-29 11:27:00

    这篇文章的后半部分是我长期以来最想了解的内容。

  4. Jeffrey Chan
    *.*.*.*
    链接

    Jeffrey Chan 2009-09-29 11:33:00

    Query Object模式有点像Strategy模式?两者之间有什么区别?

  5. 老赵
    admin
    链接

    老赵 2009-09-29 11:34:00

    @Jeffrey Chan
    没看出有啥相似的。

  6. 老赵
    admin
    链接

    老赵 2009-09-29 11:39:00

    @Yin.Pu@CQUSoft
    那以后早点告诉我吧,呵呵。

  7. Jeffrey Chan
    *.*.*.*
    链接

    Jeffrey Chan 2009-09-29 11:45:00

    那应该是我理解错了,Strategy模式是定义一系列的算法,把这些算法一个个封装成单独的类,所以感觉有点像Query Object模式。

  8. 老赵
    admin
    链接

    老赵 2009-09-29 11:48:00

    @Jeffrey Chan
    Query Object不是封装算法,只是构建查询条件。

  9. Yin.Pu@CQUSoft
    *.*.*.*
    链接

    Yin.Pu@CQUSoft 2009-09-29 11:51:00

    Jeffrey Zhao:
    @Yin.Pu@CQUSoft
    那以后早点告诉我吧,呵呵。



    我上次是看到你提出这个做法是在《谈表达式树的缓存:引言》中。

  10. 路过者[未注册用户]
    *.*.*.*
    链接

    路过者[未注册用户] 2009-09-29 12:26:00

    好像也可以使用Invoke方法来统一参数,譬如

    public static Expression<Func<T, bool>> And<T>(
    this Expression<Func<T, bool>> one, Expression<Func<T, bool>> other)
    {
    InvocationExpression result = Expression.Invoke(other, one.Parameters.Cast<Expression>());

    return Expression.Lambda<Func<T, bool>>(
    Expression.And(one.Body,
    result),
    one.Parameters);
    }

  11. liyou
    *.*.*.*
    链接

    liyou 2009-09-29 12:39:00

    vs2008有什么工具可以提高你的编写代码的速度,像Linux 的vim?

  12. 老赵
    admin
    链接

    老赵 2009-09-29 13:02:00

    @liyou
    VS 2008速度已经蛮快了,如果记下快捷键。

  13. 老赵
    admin
    链接

    老赵 2009-09-29 13:03:00

    @路过者
    虽然Complie后的结果相同,但是这样的表达式不能用来解析了。
    所以,我们要把它们变成最纯朴的predicate方式,呵呵。

  14. 路过者[未注册用户]
    *.*.*.*
    链接

    路过者[未注册用户] 2009-09-29 13:32:00

    那确实是个问题

    请问,你这ExpressionVisitor是自己写的么?还是用的 .NET 4.0里的?

  15. 老赵
    admin
    链接

    老赵 2009-09-29 13:36:00

    @路过者
    MSDN里有完整代码。

  16. Yin.Pu@CQUSoft
    *.*.*.*
    链接

    Yin.Pu@CQUSoft 2009-09-29 18:06:00

    如果给ProductDao的GetProduct方法提供一系列的Criteria对象,那这个过程不是和写LoadByXXX类似了吗?

  17. 老赵
    admin
    链接

    老赵 2009-09-29 18:10:00

    @Yin.Pu@CQUSoft
    GetProduct方法只有一个实现,是基于ProductCriteria这个抽象类的,说白了就是一个受限的Expression<Func<Product, bool>>。

  18. Ulysess[未注册用户]
    *.*.*.*
    链接

    Ulysess[未注册用户] 2009-09-30 10:53:00

    http://www.albahari.com/nutshell/linqkit.aspx

  19. 老赵
    admin
    链接

    老赵 2009-09-30 11:31:00

    @Ulysess
    不错,有意思。

  20. 链接

    W 2012-08-09 10:57:38

    赵老师,当and时,调用visit方法可以将返回新的对象,即修改后的表达式,为什么还要重写visitParamerter的方法呢 而且该方法定义了一个参数 好像没有用,这个方法不了解,可以给我解释下吗?

  21. mushishi
    116.226.37.*
    链接

    mushishi 2015-10-23 17:21:19

    这个表达式扩展是不是可以用于规约模式?

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我