9.1.2 asp.net core 自动生成组合查询
在做系统的时候,经常遇到前台录入一大堆的查询条件,然后点击查询提交后台,在Controller里面生成对应的查询SQL或者表达式,数据库执行再将结果返回客户端。
例如如下页面,输入三个条件,日志类型、开始和结束日期,查询后台系统操作日志,并显示。
这种类似页面在系统中还是比较多的,通常情况下,我们会在cshtml中放上日志类型、开始、结束日期这三个控件,controller的action有对应的三个参数,然后在action、逻辑层或者仓储层实现将这三个参数转换为linq,例如转成c=>c.BeginDate>=beginDate && c.EndDate < endDate.AddDay(1) && c.OperType == operType。
这里有个小技巧,就是结束日期小于录入的结束日期+1天。一般大家页面中录入结束日期的时候都是只到日期,不带时分秒,例如结束日期为2016年1月31日,endDate 就是2016-01-31。其实这时候,大家的想法是到2016年1月31日23:59:59秒止。如果数据库中存储的是带时分秒的时间,例如2016-01-31 10:00:00.000,而程序中写的是c.EndDate < endDate的话,那么这个2016年1月31日零点之后的全不满足条件。所以,这里应该是小于录入的结束日期+1。
如果我们有更多的条件怎么办?如果有的条件允许为空怎么办?如果有几十个这样的页面的?难道要一个个的去写么?
基于以上的考虑,我们为了简化操作,编写了自动生成组合查询条件的通用框架。做法主要有如下几步:
- 前端页面采用一定的格式设置html控件的Id和Name。
- 编写ModelBinder,接收前台传来的参数,生成查询条件类
- 将查询条件类转换为SQL语句(petapoco等框架)或者表达式(EF框架),我们.netcore下用的是ef,因此只说明表达式的做法。peta的我们在.net framework下也实现了,在此不做叙述。
- 提交数据库执行
下面详细介绍下具体的过程。
1、前端页面采用一定的格式设置Html控件的Id和Name,这里我们约定的写法是{Op}__{PropertyName},就是操作符、两个下划线、属性名。
1 <form asp-action="List" method="post" class="form-inline"> 2 <div class="form-group"> 3 <label class="col-md-4 col-xs-4 col-sm-4 control-label">日志类型:</label> 4 <div class="col-md-8 col-xs-8 col-sm-8"> 5 <select id="Eq__LogOperType" name="Eq__LogOperType" class="form-control" asp-items="@operateTypes"></select> 6 </div> 7 </div> 8 <div class="form-group"> 9 <label class="col-md-4 col-xs-4 col-sm-4 control-label">日期:</label> 10 <div class="col-md-8 col-xs-8 col-sm-8"> 11 <input type="date" id="Gte__CreateDate" name="Gte__CreateDate" class="form-control" value="@queryCreateDateStart.ToDateString()" /> 12 </div> 13 </div> 14 <div class="form-group"> 15 <label class="col-md-4 col-xs-4 col-sm-4 control-label"> - </label> 16 <div class="col-md-8 col-xs-8 col-sm-8"> 17 <input type="date" id="Lt__CreateDate" name="Lt__CreateDate" class="form-control" value="@queryCreateDateEnd.ToDateString()" /> 18 </div> 19 </div> 20 <button class="btn btn-primary" type="submit">查询</button> 21 </form>
例如,日志类型查询条件要求日志类型等于所选择的类型。日志类的日志类型属性是LogOperType,等于的操作符是Eq,这样Id就是Eq__LogOperType。同样的操作日期在开始和结束日期范围内,开始和结束日期的Id分别为Gte__CreateDate和Lt__CreateDate。
2、编写ModelBinder,接收前端传来的参数,生成查询条件类。
这里,我们定义一个查询条件类,QueryConditionCollection,注释写的还是比较明确的:
1 /// <summary> 2 /// 操作条件集合 3 /// </summary> 4 public class QueryConditionCollection : KeyedCollection<string, QueryConditionItem> 5 { 6 /// <summary> 7 /// 初始化 8 /// </summary> 9 public QueryConditionCollection() 10 : base() 11 { 12 } 13 14 /// <summary> 15 /// 从指定元素提取键 16 /// </summary> 17 /// <param name="item">从中提取键的元素</param> 18 /// <returns>指定元素的键</returns> 19 protected override string GetKeyForItem(QueryConditionItem item) 20 { 21 return item.Key; 22 } 23 } 24 25 /// <summary> 26 /// 操作条件 27 /// </summary> 28 public class QueryConditionItem 29 { 30 /// <summary> 31 /// 主键 32 /// </summary> 33 public string Key { get; set; } 34 /// <summary> 35 /// 名称 36 /// </summary> 37 public string Name { get; set; } 38 39 /// <summary> 40 /// 条件操作类型 41 /// </summary> 42 public QueryConditionType Op { get; set; } 43 44 ///// <summary> 45 ///// DataValue是否包含单引号,如'DataValue' 46 ///// </summary> 47 //public bool IsIncludeQuot { get; set; } 48 49 /// <summary> 50 /// 数据的值 51 /// </summary> 52 public object DataValue { get; set; } 53 }
按照我们的设计,上面日志查询例子应该产生一个QueryConditionCollection,包含三个QueryConditionItem,分别是日志类型、开始和结束日期条件项。可是,如何通过前端页面传来的请求数据生成QueryConditionCollection呢?这里就用到了ModelBinder。ModelBinder是MVC的数据绑定的核心,主要作用就是从当前请求提取相应的数据绑定到目标Action方法的参数上。
1 public class QueryConditionModelBinder : IModelBinder 2 { 3 private readonly IModelMetadataProvider _metadataProvider; 4 private const string SplitString = "__"; 5 6 public QueryConditionModelBinder(IModelMetadataProvider metadataProvider) 7 { 8 _metadataProvider = metadataProvider; 9 } 10 11 public async Task BindModelAsync(ModelBindingContext bindingContext) 12 { 13 QueryConditionCollection model = (QueryConditionCollection)(bindingContext.Model ?? new QueryConditionCollection()); 14 15 IEnumerable<KeyValuePair<string, StringValues>> collection = GetRequestParameter(bindingContext); 16 17 List<string> prefixList = Enum.GetNames(typeof(QueryConditionType)).Select(s => s + SplitString).ToList(); 18 19 foreach (KeyValuePair<string, StringValues> kvp in collection) 20 { 21 string key = kvp.Key; 22 if (key != null && key.Contains(SplitString) && prefixList.Any(s => key.StartsWith(s, StringComparison.CurrentCultureIgnoreCase))) 23 { 24 string value = kvp.Value.ToString(); 25 if (!string.IsNullOrWhiteSpace(value)) 26 { 27 AddQueryItem(model, key, value); 28 } 29 } 30 } 31 32 bindingContext.Result = ModelBindingResult.Success(model); 33 34 //todo: 是否需要加上这一句? 35 await Task.FromResult(0); 36 } 37 38 private void AddQueryItem(QueryConditionCollection model, string key, string value) 39 { 40 int pos = key.IndexOf(SplitString); 41 string opStr = key.Substring(0, pos); 42 string dataField = key.Substring(pos + 2); 43 44 QueryConditionType operatorEnum = QueryConditionType.Eq; 45 if (Enum.TryParse<QueryConditionType>(opStr, true, out operatorEnum)) 46 model.Add(new QueryConditionItem 47 { 48 Key = key, 49 Name = dataField, 50 Op = operatorEnum, 51 DataValue = value 52 }); 53 } 54 }
主要流程是,从当前上下文中获取请求参数(Querystring、Form等),对于每个符合格式要求的请求参数生成QueryConditionItem并加入到QueryConditionCollection中。
为了将ModelBinder应用到系统中,我们还得增加相关的IModelBinderProvider。这个接口的主要作用是提供相应的ModelBinder对象。为了能够应用QueryConditionModelBinder,我们必须还要再写一个QueryConditionModelBinderProvider,继承IModelBinderProvider接口。
1 public class QueryConditionModelBinderPrivdier : IModelBinderProvider 2 { 3 public IModelBinder GetBinder(ModelBinderProviderContext context) 4 { 5 if (context == null) 6 { 7 throw new ArgumentNullException(nameof(context)); 8 } 9 10 if (context.Metadata.ModelType != typeof(QueryConditionCollection)) 11 { 12 return null; 13 } 14 15 return new QueryConditionModelBinder(context.MetadataProvider); 16 } 17 }
下面就是是在Startup中注册ModelBinder。
services.AddMvc(options =>
{
options.ModelBinderProviders.Insert(0, new QueryConditionModelBinderPrivdier());
});
3、将查询类转换为EF的查询Linq表达式。
我们的做法是在QueryConditionCollection类中编写方法GetExpression。这个只能贴代码了,里面有相关的注释,大家可以仔细分析下程序。
1 public Expression<Func<T, bool>> GetExpression<T>() 2 { 3 if (this.Count() == 0) 4 { 5 return c => true; 6 } 7 8 //构建 c=>Body中的c 9 ParameterExpression param = Expression.Parameter(typeof(T), "c"); 10 11 //获取最小的判断表达式 12 var list = Items.Select(item => GetExpression<T>(param, item)); 13 //再以逻辑运算符相连 14 var body = list.Aggregate(Expression.AndAlso); 15 16 //将二者拼为c=>Body 17 return Expression.Lambda<Func<T, bool>>(body, param); 18 } 19 20 private Expression GetExpression<T>(ParameterExpression param, QueryConditionItem item) 21 { 22 //属性表达式 23 LambdaExpression exp = GetPropertyLambdaExpression<T>(item, param); 24 25 //常量表达式 26 var constant = ChangeTypeToExpression(item, exp.Body.Type); 27 28 //以判断符或方法连接 29 return ExpressionDict[item.Op](exp.Body, constant); 30 } 31 32 private LambdaExpression GetPropertyLambdaExpression<T>(QueryConditionItem item, ParameterExpression param) 33 { 34 //获取每级属性如c.Users.Proiles.UserId 35 var props = item.Name.Split('.'); 36 37 Expression propertyAccess = param; 38 39 Type typeOfProp = typeof(T); 40 41 int i = 0; 42 do 43 { 44 PropertyInfo property = typeOfProp.GetProperty(props[i]); 45 if (property == null) return null; 46 typeOfProp = property.PropertyType; 47 propertyAccess = Expression.MakeMemberAccess(propertyAccess, property); 48 i++; 49 } while (i < props.Length); 50 51 return Expression.Lambda(propertyAccess, param); 52 } 53 54 #region ChangeType 55 /// <summary> 56 /// 转换SearchItem中的Value的类型,为表达式树 57 /// </summary> 58 /// <param name="item"></param> 59 /// <param name="conversionType">目标类型</param> 60 private Expression ChangeTypeToExpression(QueryConditionItem item, Type conversionType) 61 { 62 if (item.DataValue == null) 63 return Expression.Constant(item.DataValue, conversionType); 64 65 #region 数组 66 if (item.Op == QueryConditionType.In) 67 { 68 var arr = (item.DataValue as Array); 69 var expList = new List<Expression>(); 70 //确保可用 71 if (arr != null) 72 for (var i = 0; i < arr.Length; i++) 73 { 74 //构造数组的单元Constant 75 var newValue = arr.GetValue(i); 76 expList.Add(Expression.Constant(newValue, conversionType)); 77 } 78 79 //构造inType类型的数组表达式树,并为数组赋初值 80 return Expression.NewArrayInit(conversionType, expList); 81 } 82 #endregion 83 84 var value = conversionType.GetTypeInfo().IsEnum ? Enum.Parse(conversionType, (string)item.DataValue) 85 : Convert.ChangeType(item.DataValue, conversionType); 86 87 return Expression.Convert(((Expression<Func<object>>)(() => value)).Body, conversionType); 88 } 89 #endregion 90 91 #region SearchMethod 操作方法 92 private readonly Dictionary<QueryConditionType, Func<Expression, Expression, Expression>> ExpressionDict = 93 new Dictionary<QueryConditionType, Func<Expression, Expression, Expression>> 94 { 95 { 96 QueryConditionType.Eq, 97 (left, right) => { return Expression.Equal(left, right); } 98 }, 99 { 100 QueryConditionType.Gt, 101 (left, right) => { return Expression.GreaterThan(left, right); } 102 }, 103 { 104 QueryConditionType.Gte, 105 (left, right) => { return Expression.GreaterThanOrEqual(left, right); } 106 }, 107 { 108 QueryConditionType.Lt, 109 (left, right) => { return Expression.LessThan(left, right); } 110 }, 111 { 112 QueryConditionType.Lte, 113 (left, right) => { return Expression.LessThanOrEqual(left, right); } 114 }, 115 { 116 QueryConditionType.Contains, 117 (left, right) => 118 { 119 if (left.Type != typeof (string)) return null; 120 return Expression.Call(left, typeof (string).GetMethod("Contains"), right); 121 } 122 }, 123 { 124 QueryConditionType.In, 125 (left, right) => 126 { 127 if (!right.Type.IsArray) return null; 128 //调用Enumerable.Contains扩展方法 129 MethodCallExpression resultExp = 130 Expression.Call( 131 typeof (Enumerable), 132 "Contains", 133 new[] {left.Type}, 134 right, 135 left); 136 137 return resultExp; 138 } 139 }, 140 { 141 QueryConditionType.Neq, 142 (left, right) => { return Expression.NotEqual(left, right); } 143 }, 144 { 145 QueryConditionType.StartWith, 146 (left, right) => 147 { 148 if (left.Type != typeof (string)) return null; 149 return Expression.Call(left, typeof (string).GetMethod("StartsWith", new[] {typeof (string)}), right); 150 151 } 152 }, 153 { 154 QueryConditionType.EndWith, 155 (left, right) => 156 { 157 if (left.Type != typeof (string)) return null; 158 return Expression.Call(left, typeof (string).GetMethod("EndsWith", new[] {typeof (string)}), right); 159 } 160 } 161 }; 162 #endregion
4、提交数据库执行并反馈结果
在生成了表达式后,剩下的就比较简单了。仓储层直接写如下的语句即可:
var query = this.dbContext.OperLogs.AsNoTracking().Where(predicate).OrderByDescending(o => o.CreateDate).ThenBy(o => o.OperLogId);
predicate就是从QueryConditionCollection.GetExpression方法中生成的,类似
Expression<Func<OperLogInfo, bool>> predicate = conditionCollection.GetExpression<OperLogInfo>();
QueryConditionCollection从哪里来呢?因为有了ModelBinder,Controller的Action上直接加上参数,类似
public async Task<IActionResult> List(QueryConditionCollection queryCondition) { ... }
至此,自动生成的组合查询就基本完成了。之后我们程序的写法,只需要在前端页面定义查询条件的控件,Controller的Action中加上QueryConditionCollection参数,然后调用数据库前将QueryConditionCollection转换为表达式就OK了。不再像以往一样在cshtml、Controller中写一大堆的程序代码了,在条件多、甚至有可选条件时,优势更为明显。