代码改变世界

ASP.NET MVC:Expression Trees 作为参数简化查询 二

2011-09-18 17:25  鹤冲天  阅读(5785)  评论(15编辑  收藏  举报

前文《ASP.NET MVC:Expression Trees 作为参数简化查询》中提出可以将 Expression Trees 用作查询 Action 的参数来简化编码:

1
2
3
4
public ActionResult Index([QueryConditionBinder]Expression<Func<Employee, bool>> predicate) {
    var employees = repository.Query().Where(predicate);
    return View("Index", employees);
}
文中给出的 QueryConditionExpressionModelBinder 类,比较僵化,无法满足实际要求。本文将会从这个类为起点,构建一个灵活的解决方案。

本文的内容稍有枯燥,先给出最终的运行截图,给大家提提神:

演示网站运行截图

在线演示:http://demos.ldp.me/employees

下图显示的 Expression 是根据查询条件动态生成的:

image

调试截图:

image

设计目标

支持以下类型查询:

  • 相等查询
  • 字符串查询:完全匹配、模糊查询、作为开始、作为结束;
  • 日期查询(不考虑时间)、日期范围查询;
  • 比较查询:大于、大于等于、小于、小于等于;
  • 正确处理可空类型

阻止某些查询:

  • ID查询
  • 某些保密属性,如内部价格属性等

扩展性:

  • 系统容易扩展,开放支持加入新的查询类型

易用性:

  • 简单使用

其它:

  • 查询数据验证,配合 MVC 相应机制,对错误输入给出提示。

思考

想法源自 Entity Framework:

EF 中的 Convention

在 EF Code First 中,Entity 与 数据库 Table 之间映射采用 Convention (约定) 的方式:

 System.Data.Entity.ModelConfiguration.Conventions 命名空间中有很多这样的 Convention。这些 Convention 都是被大多人公认的,EF 运行时会加载这些 Convention,因此我们使用 EF 会相当简单,不需要像 NH 那样进行大量繁琐无聊的映射配置工作。

如果你认可其中的某条 Convention 你可以将它移除:

1
2
3
4
5
public class NorthwindDbContext : DbContext
{
    protected override void OnModelCreating(DbModelBuilder modelBuilder) {
        modelBuilder.Conventions.Remove<IdKeyDiscoveryConvention>();
}

不错吧,但 EF 不允许添加新的 Convention,有点遗憾。

分解出 Convention

借鉴 EF 的思路,我们可以分解出以下 Convention:

  • ValueTypeEqualsConvention:值类型相等,年龄 == 18、婚否 = false;
  • StringContainsConvention:字符串包含,即模糊查询;
  • DateEqualsConvention:日期等于,忽略时间;
  • ValueTypeCompareConvention:值类型比较,价格大于 12.00;
  • BetweenDatesConvention:时间界于两个日期之间;
  • IDForbiddenConvention:禁止对 ID 查询。

还有一点,要将各个条件组合起来,如:(年龄 <= 18) 并且 (婚否 = false), 或者 (年龄 <= 18) 或者 (婚否 = false)。因此,还要定义用于连接组合的 Convention:

  • AndCombineConvention:并且,在页面查询中,这个比较常用,我们设成默认的;
  • OrCombinedConvention:或者;
  • XXXComplexCombineConvention:更加复杂的情况,如:(存款 > 100,000,000) Or ((年龄 <= 18) 并且 (婚否 = false))。

可设置的 Order 属性

给每个 Convention 设置一个优先顺序号,大的优先级高:

  • StringContainsConvention、DateEqualsConvention 优先于 ValueTypeEqualsConvention;
  • BetweenDatesConvention 优先于 DateEqualsConvention。

即采用了 StringContainsConvention 就不会再采用 ValueTypeEqualsConvention。

编码时会根据实际应用给每个 Convention 设置一个默认的合理的 Order 值,但为了灵活通用,允许修改,Order 是一个 get-set 属性。

可以添加新的 Convention 以满足更多应用

EF 只能移除不能添加,有时感不方便,不太符合 OCP(Open-Closed principle)。

编码实现

抽象出接口

根据上面的分析,可以提取出下面三个接口:

  • IConvention 接口,代表所有的约定:

    1
    2
    3
    public interface IConvention {
        int Order { get; set; }
    }
  • IPropertyExpressionConvention 接口,将单个查询条件转换为 Expression:

    1
    2
    3
    public interface IPropertyExpressionConvention: IConvention {
        Expression BuildExpression(BuildPropertyExpressionContext context);
    }
  • IExpressionCombineConvention 接口,将多个查询 Expression 进行合并:

    1
    2
    3
    public interface IExpressionCombineConvention : IConvention {
        Expression Combine(IDictionary<string, Expression> expressions);
    }

修改 QueryConditionExpressionModelBinder 类

修改后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class QueryConditionExpressionModelBinder : IModelBinder {
    private ConventionConfiguration _conventionConfiguration;

    public QueryConditionExpressionModelBinder(ConventionConfiguration conventionConfiguration) {
        _conventionConfiguration = conventionConfiguration;
    }

    public QueryConditionExpressionModelBinder(): this(ConventionConfiguration.Default) { }

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
        var modelType = GetModelTypeFromExpressionType(bindingContext.ModelType);
        if (modelType == null) return null;

        var parameter = Expression.Parameter(modelType, modelType.Name[0].ToString().ToLower());

        var dict = new Dictionary<string, Expression>();
        var propertyExpressionConvertions = _conventionConfiguration.GetConventions<IPropertyExpressionConvention>();
        foreach (var property in modelType.GetProperties()){
            foreach (var convention in propertyExpressionConvertions) {
                var context = new BuildPropertyExpressionContext(
                    property,
                    bindingContext.ValueProvider,
                    controllerContext.Controller.ViewData.ModelState,
                    parameter.Property(property.Name)
                    );
                var expression = convention.BuildExpression(context);
                if(expression != null){
                    dict.Add(property.Name, expression);
                    break;
                }
                if (context.IsHandled) break;
            }
        }
        var body = default(Expression);
        foreach (var convention in _conventionConfiguration.GetConventions<IExpressionCombineConvention>())
        {
            body = convention.Combine(dict);
            if (body != null) break;
        }
        //if (body == null) body = Expression.Constant(true);
        return body.ToLambda(parameter);
    }
    /// <summary>
    /// 获取 Expression<Func<TXXX, bool>> 中 TXXX 的类型
    /// </summary>
    private Type GetModelTypeFromExpressionType(Type lambdaExpressionType) {

        if (lambdaExpressionType.GetGenericTypeDefinition() != typeof (Expression<>)) return null;

        var funcType = lambdaExpressionType.GetGenericArguments()[0];
        if (funcType.GetGenericTypeDefinition() != typeof (Func<,>)) return null;

        var funcTypeArgs = funcType.GetGenericArguments();
        if (funcTypeArgs[1] != typeof (bool)) return null;
        return funcTypeArgs[0];
    }
    /// <summary>
    /// 获取属性的查询值并处理 Controller.ModelState 
    /// </summary>
    private object GetValueAndHandleModelState(PropertyInfo property, IValueProvider valueProvider, ControllerBase controller) {
        var result = valueProvider.GetValue(property.Name);
        if (result == null) return null;

        var modelState = new ModelState {Value = result};
        controller.ViewData.ModelState.Add(property.Name, modelState);

        object value = null;
        try{
            value = result.ConvertTo(property.PropertyType);
        }
        catch (Exception ex){
            modelState.Errors.Add(ex);
        }
        return value;
    }
}

高亮代码为修改或新增部分。

QueryConditionExpressionModelBinder 中使用了 ConventionConfiguration 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ConventionConfiguration {

   public static ConventionConfiguration Default = new ConventionConfiguration();

   static ConventionConfiguration() {
       Default.Conventions.Add(new ValueTypeEqualsConvention());
       Default.Conventions.Add(new StringContainsConvention());
       Default.Conventions.Add(new DateEqualsConvention());
       Default.Conventions.Add(new BetweenDatesConvention());
       //
       Default.Conventions.Add(new AwalysTrueCombineConvention());
       Default.Conventions.Add(new OrCombineConvention());
   }

   public ConventionConfiguration() {
       Conventions = new HashSet<IConvention>();
   }
   public HashSet<IConvention> Conventions { get; private set; }

   internal IEnumerable<T> GetConventions<T>() where T: IConvention {
       return Conventions
           .OfType<T>()
           .OrderByDescending(c => c.Order);
   }
}

实现具体 Converntion:

  • ValueTypeEqualsConvention
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class ValueTypeEqualsConvention : PropertyExpressionConventionBase {
    
        public ValueTypeEqualsConvention():base(1) {}
    
        public override Expression BuildExpression(BuildPropertyExpressionContext context) {
            if (!context.Property.PropertyType.IsValueType) return null;
    
            var queryValue = context.ValueProvider.GetQueryValue(context.Property.Name, context.Property.PropertyType);
            context.ModelState.AddIfValueNotNull(context.Property.Name, queryValue.ModelState);
            context.IsHandled = queryValue.ModelState != null;
    
            if(queryValue.Value == null) return null;
            return context.PropertyExpression.Equal(Expression.Constant(queryValue.Value));
        }
    }
  • StringContainsConvention
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class StringContainsConvention : PropertyExpressionConventionBase {
    
        public StringContainsConvention():base(10) { }
    
        public override Expression BuildExpression(BuildPropertyExpressionContext context) {
            if (context.Property.PropertyType != typeof(string)) return null;
    
            var queryValue = context.ValueProvider.GetQueryValue(context.Property.Name, context.Property.PropertyType);
            context.ModelState.AddIfValueNotNull(context.Property.Name, queryValue.ModelState);
            context.IsHandled = queryValue.ModelState != null;
    
            if ((queryValue.Value as string).IsNullOrEmpty()) return null;
            return context.PropertyExpression.Call("Contains", Expression.Constant(queryValue.Value));
        }
    }
  • DateEqualsConvention
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class DateEqualsConvention: PropertyExpressionConventionBase {
        public DateEqualsConvention():base(10) { }
    
        public override System.Linq.Expressions.Expression BuildExpression(BuildPropertyExpressionContext context) {
            if (context.Property.PropertyType.NotIn(typeof(DateTime), typeof(DateTime?))) return null;
            if (!context.Property.Name.EndsWith("day", true, CultureInfo.CurrentCulture) && 
                !context.Property.Name.EndsWith("date", true, CultureInfo.CurrentCulture)) return null;
    
            var queryValue = context.ValueProvider.GetQueryValue(context.Property.Name, typeof(DateTime));
            context.ModelState.AddIfValueNotNull(context.Property.Name, queryValue.ModelState);
            context.IsHandled = queryValue.ModelState != null;
            if (queryValue.Value == null) return null;
    
            var date = ((DateTime)queryValue.Value).Date;
            var expression = context.PropertyExpression;
            if (expression.Type == typeof(DateTime?)) expression = expression.Property("Value");
            return expression.Property("Date").Equal(Expression.Constant(date));
        }
    }
  • AndCombineConvention
    1
    2
    3
    4
    5
    6
    7
    8
    public class AndCombineConvention : IExpressionCombineConvention {
        public int Order { get; set; }
        public System.Linq.Expressions.Expression Combine(IDictionary<string, System.Linq.Expressions.Expression> expressions) {
            if(expressions.Count > 0)
                return expressions.Values.Aggregate((a, e) => a.OrElse(e));
            return null;
        }
    }

特别注意下 DateEqualsConvention,只对名称以 day 或 date 结尾(不区分大小)的 DateTime 或 DateTime?属性进行处理,如 Employee.Birthday、Employee.HireDate。

项目类图

目前实现中主要有以下类和接口:

image    image

image    image

扩展方法类未列出。

QueryConditionExpressionModelBinder 使用

直接使用

1
2
3
4
public ActionResult Index([QueryConditionBinder]Expression<Func<Employee, bool>> predicate) {
    var employees = repository.Query().Where(predicate);
    return View("Index", employees);
}

或配置后使用

若你有新创建的 Convention,可以在 Global.asax 文件中 MvcApplication.Application_Start 方法中进行加入配置:

1
ConventionConfiguration.Default.Conventions.Add(new YourConvention());

如果默认的 Conversions 不满足你的要示,可以移除后重新增加:

1
2
3
4
ConventionConfiguration.Default.Conventions.Clear();
ConventionConfiguration.Default.Conventions.Add(new ValueTypeEqualsConvention());
ConventionConfiguration.Default.Conventions.Add(new DateEqualsConvention { Order = 1000 });
ConventionConfiguration.Default.Conventions.Add(new YourConvention{ Order = 2000});

因为 Order 属性是可修改的,添加时可以重新指定优先级。

或都你可以给某一个查询单独配置 Convention:

1
2
3
var cfg = new ConventionConfiguration();
cfg.Conventions.Add(new StringContainsConvention());
ModelBinders.Binders.Add(typeof(Expression<Func<Order, bool>>), new QueryConditionExpressionModelBinder(cfg));
这时,就不要再使用 QueryConditionBinderAttribute 了:

1
2
3
4
5
6
7
public class OrdersController : Controller{
    private  OrdersRepository repository = new OrdersRepository();
    public ViewResult Index(Expression<Func<Order, bool>> predicate) {
        var orders = repository.Query().Where(predicate);
        return View(orders);
    }
}

后记

根据你的项目,创建适合的 Convention,相信 QueryConditionExpressionModelBinder 一定会帮你省下很多时间。

本文中代码编写仓促,尚未进行严格测试,使用时请注意。如有 bug 请回复给我,谢谢!

后续还有相关文章,实现禁止对某些属性查询的 Convention,以及复杂条件组合 Convention 等等。

 

源码下载:MvcQuery2.rar (1733KB,VS2010 MVC3)

在线演示:http://demos.ldp.me/employees