动态构造任意复杂的 Linq Where 表达式

2019/04/10 10:10
阅读数 19

前言

       Linq 是 C# 中一个非常好用的集合处理库,用好了能帮我们简化大量又臭又长的嵌套循环,使处理逻辑清晰可见。EF 查询主要也是依赖 Linq。但是 Linq 相对 sql 也存在一些缺点,最主要的就是动态构造查询的难度。sql 只需要简单进行字符串拼接,操作难度很低(当然出错也相当容易),而 Linq 表达式由于对强类型表达式树的依赖,动态构造查询表达式基本相当于手写 AST(抽象语法树),可以说难度暴增。

       AST 已经进入编译原理的领域,对计算机系统的了解程度需求比一般 crud 写业务代码高了几个量级,也导致很多人觉得 EF 不好用,为了写个动态查询要学编译原理这个代价还是挺高的。后来也有一些类似 DynamicLinq 的类库能用表达式字符串写动态查询。

       本着学习精神,研究了一段时间,写了一个在我的想象力范围内,可以动态构造任意复杂的 Where 表达式的辅助类。这个辅助类的过滤条件使用了 JqGrid 的高级查询的数据结构,这是我第一个知道能生成复杂嵌套查询,并且查询数据使用 json 方便解析的 js 表格插件。可以无缝根据 JqGrid 的高级查询生成 Where 表达式。

正文

实现

       JqGrid 高级查询数据结构定义,用来反序列化:

 1     public class JqGridParameter
 2     {
 3         /// <summary>
 4         /// 是否搜索,本来应该是bool,true
 5         /// </summary>
 6         public string _search { get; set; }
 7         /// <summary>
 8         /// 请求发送次数,方便服务器处理重复请求
 9         /// </summary>
10         public long Nd { get; set; }
11         /// <summary>
12         /// 当页数据条数
13         /// </summary>
14         public int Rows { get; set; }
15         /// <summary>
16         /// 页码
17         /// </summary>
18         public int Page { get; set; }
19         /// <summary>
20         /// 排序列,多列排序时为排序列名+空格+排序方式,多个列之间用逗号隔开。例:id asc,name desc
21         /// </summary>
22         public string Sidx { get; set; }
23         /// <summary>
24         /// 分离后的排序列
25         /// </summary>
26         public string[][] SIdx => Sidx.Split(", ").Select(s => s.Split(" ")).ToArray();
27         /// <summary>
28         /// 排序方式:asc、desc
29         /// </summary>
30         public string Sord { get; set; }
31         /// <summary>
32         /// 高级搜索条件json
33         /// </summary>
34         public string Filters { get; set; }
35 
36         /// <summary>
37         /// 序列化的高级搜索对象
38         /// </summary>
39         public JqGridSearchRuleGroup FilterObject => Filters.IsNullOrWhiteSpace()
40             ? new JqGridSearchRuleGroup { Rules = new[] { new JqGridSearchRule { Op = SearchOper, Data = SearchString, Field = SearchField } } }
41             : JsonSerializer.Deserialize<JqGridSearchRuleGroup>(Filters ?? string.Empty);
42 
43         /// <summary>
44         /// 简单搜索字段
45         /// </summary>
46         public string SearchField { get; set; }
47         /// <summary>
48         /// 简单搜索关键字
49         /// </summary>
50         public string SearchString { get; set; }
51         /// <summary>
52         /// 简单搜索操作
53         /// </summary>
54         public string SearchOper { get; set; }
55 
56     }
57 
58     /// <summary>
59     /// 高级搜索条件组
60     /// </summary>
61     public class JqGridSearchRuleGroup
62     {
63         /// <summary>
64         /// 条件组合方式:and、or
65         /// </summary>
66         public string GroupOp { get; set; }
67         /// <summary>
68         /// 搜索条件集合
69         /// </summary>
70         public JqGridSearchRule[] Rules { get; set; }
71         /// <summary>
72         /// 搜索条件组集合
73         /// </summary>
74         public JqGridSearchRuleGroup[] Groups { get; set; }
75     }
76 
77     /// <summary>
78     /// 高级搜索条件
79     /// </summary>
80     public class JqGridSearchRule
81     {
82         /// <summary>
83         /// 搜索字段
84         /// </summary>
85         public string Field { get; set; }
86         /// <summary>
87         /// 搜索字段的大驼峰命名
88         /// </summary>
89         public string PascalField => Field?.Length > 0 ? Field.Substring(0, 1).ToUpper() + Field.Substring(1) : Field;
90         /// <summary>
91         /// 搜索操作
92         /// </summary>
93         public string Op { get; set; }
94         /// <summary>
95         /// 搜索关键字
96         /// </summary>
97         public string Data { get; set; }
98     }

       Where 条件生成器,代码有点多,有点复杂。不过注释也很多,稍微耐心点应该不难看懂:

  1     /// <summary>
  2     /// JqGrid搜索表达式扩展
  3     /// </summary>
  4     public static class JqGridSearchExtensions
  5     {
  6         //前端的(不)属于条件搜索需要传递一个json数组的字符串作为参数
  7         //为了避免在搜索字符串的时候分隔符是搜索内容的一部分导致搜索关键字出错
  8         //无论定义什么分隔符都不能完全避免这种尴尬的情况,所以使用标准的json以绝后患
  9         /// <summary>
 10         /// 根据搜索条件构造where表达式,支持JqGrid高级搜索
 11         /// </summary>
 12         /// <typeparam name="T">搜索的对象类型</typeparam>
 13         /// <param name="ruleGroup">JqGrid搜索条件组</param>
 14         /// <param name="propertyMap">属性映射,把搜索规则的名称映射到属性名称,如果属性是复杂类型,使用点号可以继续访问内部属性</param>
 15         /// <returns>where表达式</returns>
 16         public static Expression<Func<T, bool>> BuildWhere<T>(JqGridSearchRuleGroup ruleGroup, IDictionary<string, string> propertyMap)
 17         {
 18             ParameterExpression parameter = Expression.Parameter(typeof(T), "searchObject");
 19 
 20             return Expression.Lambda<Func<T, bool>>(BuildGroupExpression<T>(ruleGroup, parameter, propertyMap), parameter);
 21         }
 22 
 23         /// <summary>
 24         /// 构造搜索条件组的表达式(一个组中可能包含若干子条件组)
 25         /// </summary>
 26         /// <typeparam name="T">搜索的对象类型</typeparam>
 27         /// <param name="group">条件组</param>
 28         /// <param name="parameter">参数表达式</param>
 29         /// <param name="propertyMap">属性映射</param>
 30         /// <returns>返回bool的条件组的表达式</returns>
 31         private static Expression BuildGroupExpression<T>(JqGridSearchRuleGroup group, ParameterExpression parameter, IDictionary<string, string> propertyMap)
 32         {
 33             List<Expression> expressions = new List<Expression>();
 34             foreach (var rule in group.Rules ?? new JqGridSearchRule[0])
 35             {
 36                 expressions.Add(BuildRuleExpression<T>(rule, parameter, propertyMap));
 37             }
 38 
 39             foreach (var subGroup in group.Groups ?? new JqGridSearchRuleGroup[0])
 40             {
 41                 expressions.Add(BuildGroupExpression<T>(subGroup, parameter, propertyMap));
 42             }
 43 
 44             if (expressions.Count == 0)
 45             {
 46                 throw new InvalidOperationException("构造where子句异常,生成了0个比较条件表达式。");
 47             }
 48 
 49             if (expressions.Count == 1)
 50             {
 51                 return expressions[0];
 52             }
 53 
 54             var expression = expressions[0];
 55             switch (group.GroupOp)
 56             {
 57                 case "AND":
 58                     foreach (var exp in expressions.Skip(1))
 59                     {
 60                         expression = Expression.AndAlso(expression, exp);
 61                     }
 62                     break;
 63                 case "OR":
 64                     foreach (var exp in expressions.Skip(1))
 65                     {
 66                         expression = Expression.OrElse(expression, exp);
 67                     }
 68                     break;
 69                 default:
 70                     throw new InvalidOperationException($"不支持创建{group.GroupOp}类型的逻辑运算表达式");
 71             }
 72 
 73             return expression;
 74         }
 75 
 76         private static readonly string[] SpecialRuleOps = {"in", "ni", "nu", "nn"};
 77 
 78         /// <summary>
 79         /// 构造条件表达式
 80         /// </summary>
 81         /// <typeparam name="T">搜索的对象类型</typeparam>
 82         /// <param name="rule">条件</param>
 83         /// <param name="parameter">参数</param>
 84         /// <param name="propertyMap">属性映射</param>
 85         /// <returns>返回bool的条件表达式</returns>
 86         private static Expression BuildRuleExpression<T>(JqGridSearchRule rule, ParameterExpression parameter,
 87             IDictionary<string, string> propertyMap)
 88         {
 89             Expression l;
 90 
 91             string[] names = null;
 92             //如果实体属性名称和前端名称不一致,或者属性是一个自定义类型,需要继续访问其内部属性,使用点号分隔
 93             if (propertyMap?.ContainsKey(rule.Field) == true)
 94             {
 95                 names = propertyMap[rule.Field].Split('.', StringSplitOptions.RemoveEmptyEntries);
 96                 l = Expression.Property(parameter, names[0]);
 97                 foreach (var name in names.Skip(1))
 98                 {
 99                     l = Expression.Property(l, name);
100                 }
101             }
102             else
103             {
104                 l = Expression.Property(parameter, rule.PascalField);
105             }
106 
107             Expression r = null; //值表达式
108             Expression e; //返回bool的各种比较表达式
109 
110             //属于和不属于比较是多值比较,需要调用Contains方法,而不是调用比较操作符
111             //为空和不为空的右值为常量null,不需要构造
112             var specialRuleOps = SpecialRuleOps;
113 
114             var isNullable = false;
115             var pt = typeof(T);
116             if(names != null)
117             {
118                 foreach(var name in names)
119                 {
120                     pt = pt.GetProperty(name).PropertyType;
121                 }
122             }
123             else
124             {
125                 pt = pt.GetProperty(rule.PascalField).PropertyType;
126             }
127 
128             //如果属性类型是可空值类型,取出内部类型
129             if (pt.IsDerivedFrom(typeof(Nullable<>)))
130             {
131                 isNullable = true;
132                 pt = pt.GenericTypeArguments[0];
133             }
134 
135             //根据属性类型创建要比较的常量值表达式(也就是r)
136             if (!specialRuleOps.Contains(rule.Op))
137             {
138                 switch (pt)
139                 {
140                     case Type ct when ct == typeof(bool):
141                         r = BuildConstantExpression(rule, bool.Parse);
142                         break;
143 
144                     #region 文字
145 
146                     case Type ct when ct == typeof(char):
147                         r = BuildConstantExpression(rule, str => str[0]);
148                         break;
149                     case Type ct when ct == typeof(string):
150                         r = BuildConstantExpression(rule, str => str);
151                         break;
152 
153                     #endregion
154 
155                     #region 有符号整数
156 
157                     case Type ct when ct == typeof(sbyte):
158                         r = BuildConstantExpression(rule, sbyte.Parse);
159                         break;
160                     case Type ct when ct == typeof(short):
161                         r = BuildConstantExpression(rule, short.Parse);
162                         break;
163                     case Type ct when ct == typeof(int):
164                         r = BuildConstantExpression(rule, int.Parse);
165                         break;
166                     case Type ct when ct == typeof(long):
167                         r = BuildConstantExpression(rule, long.Parse);
168                         break;
169 
170                     #endregion
171 
172                     #region 无符号整数
173 
174                     case Type ct when ct == typeof(byte):
175                         r = BuildConstantExpression(rule, byte.Parse);
176                         break;
177                     case Type ct when ct == typeof(ushort):
178                         r = BuildConstantExpression(rule, ushort.Parse);
179                         break;
180                     case Type ct when ct == typeof(uint):
181                         r = BuildConstantExpression(rule, uint.Parse);
182                         break;
183                     case Type ct when ct == typeof(ulong):
184                         r = BuildConstantExpression(rule, ulong.Parse);
185                         break;
186 
187                     #endregion
188 
189                     #region 小数
190 
191                     case Type ct when ct == typeof(float):
192                         r = BuildConstantExpression(rule, float.Parse);
193                         break;
194                     case Type ct when ct == typeof(double):
195                         r = BuildConstantExpression(rule, double.Parse);
196                         break;
197                     case Type ct when ct == typeof(decimal):
198                         r = BuildConstantExpression(rule, decimal.Parse);
199                         break;
200 
201                     #endregion
202 
203                     #region 其它常用类型
204 
205                     case Type ct when ct == typeof(DateTime):
206                         r = BuildConstantExpression(rule, DateTime.Parse);
207                         break;
208                     case Type ct when ct == typeof(DateTimeOffset):
209                         r = BuildConstantExpression(rule, DateTimeOffset.Parse);
210                         break;
211                     case Type ct when ct == typeof(Guid):
212                         r = BuildConstantExpression(rule, Guid.Parse);
213                         break;
214                     case Type ct when ct.IsEnum:
215                         r = Expression.Constant(rule.Data.ToEnumObject(ct));
216                         break;
217 
218                     #endregion
219 
220                     default:
221                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的数据表达式");
222                 }
223             }
224 
225             if (r != null && pt.IsValueType && isNullable)
226             {
227                 var gt = typeof(Nullable<>).MakeGenericType(pt);
228                 r = Expression.Convert(r, gt);
229             }
230 
231             switch (rule.Op)
232             {
233                 case "eq": //等于
234                     e = Expression.Equal(l, r);
235                     break;
236                 case "ne": //不等于
237                     e = Expression.NotEqual(l, r);
238                     break;
239                 case "lt": //小于
240                     e = Expression.LessThan(l, r);
241                     break;
242                 case "le": //小于等于
243                     e = Expression.LessThanOrEqual(l, r);
244                     break;
245                 case "gt": //大于
246                     e = Expression.GreaterThan(l, r);
247                     break;
248                 case "ge": //大于等于
249                     e = Expression.GreaterThanOrEqual(l, r);
250                     break;
251                 case "bw": //开头是(字符串)
252                     if (pt == typeof(string))
253                     {
254                         e = Expression.Call(l, pt.GetMethod(nameof(string.StartsWith), new[] {typeof(string)}), r);
255                     }
256                     else
257                     {
258                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的开始于表达式");
259                     }
260 
261                     break;
262                 case "bn": //开头不是(字符串)
263                     if (pt == typeof(string))
264                     {
265                         e = Expression.Not(Expression.Call(l, pt.GetMethod(nameof(string.StartsWith), new[] {typeof(string)}), r));
266                     }
267                     else
268                     {
269                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的不开始于表达式");
270                     }
271 
272                     break;
273                 case "ew": //结尾是(字符串)
274                     if (pt == typeof(string))
275                     {
276                         e = Expression.Call(l, pt.GetMethod(nameof(string.EndsWith), new[] {typeof(string)}), r);
277                     }
278                     else
279                     {
280                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的结束于表达式");
281                     }
282 
283                     break;
284                 case "en": //结尾不是(字符串)
285                     if (pt == typeof(string))
286                     {
287                         e = Expression.Not(Expression.Call(l, pt.GetMethod(nameof(string.EndsWith), new[] {typeof(string)}), r));
288                     }
289                     else
290                     {
291                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的不结束于表达式");
292                     }
293 
294                     break;
295                 case "cn": //包含(字符串)
296                     if (pt == typeof(string))
297                     {
298                         e = Expression.Call(l, pt.GetMethod(nameof(string.Contains), new[] {typeof(string)}), r);
299                     }
300                     else
301                     {
302                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的包含表达式");
303                     }
304 
305                     break;
306                 case "nc": //不包含(字符串)
307                     if (pt == typeof(string))
308                     {
309                         e = Expression.Not(Expression.Call(l, pt.GetMethod(nameof(string.Contains), new[] {typeof(string)}), r));
310                     }
311                     else
312                     {
313                         throw new InvalidOperationException($"不支持创建{pt.FullName}类型的包含表达式");
314                     }
315 
316                     break;
317                 case "in": //属于(是候选值列表之一)
318                     e = BuildContainsExpression(rule, l, pt);
319                     break;
320                 case "ni": //不属于(不是候选值列表之一)
321                     e = Expression.Not(BuildContainsExpression(rule, l, pt));
322                     break;
323                 case "nu": //为空
324                     r = Expression.Constant(null);
325                     e = Expression.Equal(l, r);
326                     break;
327                 case "nn": //不为空
328                     r = Expression.Constant(null);
329                     e = Expression.Not(Expression.Equal(l, r));
330                     break;
331                 case "bt": //区间
332                     throw new NotImplementedException($"尚未实现创建{rule.Op}类型的比较表达式");
333                 default:
334                     throw new InvalidOperationException($"不支持创建{rule.Op}类型的比较表达式");
335             }
336 
337             return e;
338 
339             static Expression BuildConstantExpression<TValue>(JqGridSearchRule jRule, Func<string, TValue> valueConvertor)
340             {
341                 var rv = valueConvertor(jRule.Data);
342                 return Expression.Constant(rv);
343             }
344         }
345 
346         /// <summary>
347         /// 构造Contains调用表达式
348         /// </summary>
349         /// <param name="rule">条件</param>
350         /// <param name="parameter">参数</param>
351         /// <param name="parameterType">参数类型</param>
352         /// <returns>Contains调用表达式</returns>
353         private static Expression BuildContainsExpression(JqGridSearchRule rule, Expression parameter, Type parameterType)
354         {
355             Expression e = null;
356 
357             var genMethod = typeof(Queryable).GetMethods()
358                 .Single(m => m.Name == nameof(Queryable.Contains) && m.GetParameters().Length == 2);
359 
360             var jsonArray = JsonSerializer.Deserialize<string[]>(rule.Data);
361 
362             switch (parameterType)
363             {
364                 #region 文字
365 
366                 case Type ct when ct == typeof(char):
367                     if (jsonArray.Any(o => o.Length != 1)) {throw new InvalidOperationException("字符型的候选列表中存在错误的候选项");}
368                     e = CallContains(parameter, jsonArray, str => str[0], genMethod, ct);
369                     break;
370                 case Type ct when ct == typeof(string):
371                     e = CallContains(parameter, jsonArray, str => str, genMethod, ct);
372                     break;
373 
374                 #endregion
375 
376                 #region 有符号整数
377 
378                 case Type ct when ct == typeof(sbyte):
379                     e = CallContains(parameter, jsonArray, sbyte.Parse, genMethod, ct);
380                     break;
381                 case Type ct when ct == typeof(short):
382                     e = CallContains(parameter, jsonArray, short.Parse, genMethod, ct);
383                     break;
384                 case Type ct when ct == typeof(int):
385                     e = CallContains(parameter, jsonArray, int.Parse, genMethod, ct);
386                     break;
387                 case Type ct when ct == typeof(long):
388                     e = CallContains(parameter, jsonArray, long.Parse, genMethod, ct);
389                     break;
390 
391                 #endregion
392 
393                 #region 无符号整数
394 
395                 case Type ct when ct == typeof(byte):
396                     e = CallContains(parameter, jsonArray, byte.Parse, genMethod, ct);
397                     break;
398                 case Type ct when ct == typeof(ushort):
399                     e = CallContains(parameter, jsonArray, ushort.Parse, genMethod, ct);
400                     break;
401                 case Type ct when ct == typeof(uint):
402                     e = CallContains(parameter, jsonArray, uint.Parse, genMethod, ct);
403                     break;
404                 case Type ct when ct == typeof(ulong):
405                     e = CallContains(parameter, jsonArray, ulong.Parse, genMethod, ct);
406                     break;
407 
408                 #endregion
409 
410                 #region 小数
411 
412                 case Type ct when ct == typeof(float):
413                     e = CallContains(parameter, jsonArray, float.Parse, genMethod, ct);
414                     break;
415                 case Type ct when ct == typeof(double):
416                     e = CallContains(parameter, jsonArray, double.Parse, genMethod, ct);
417                     break;
418                 case Type ct when ct == typeof(decimal):
419                     e = CallContains(parameter, jsonArray, decimal.Parse, genMethod, ct);
420                     break;
421 
422                 #endregion
423 
424                 #region 其它常用类型
425 
426                 case Type ct when ct == typeof(DateTime):
427                     e = CallContains(parameter, jsonArray, DateTime.Parse, genMethod, ct);
428                     break;
429                 case Type ct when ct == typeof(DateTimeOffset):
430                     e = CallContains(parameter, jsonArray, DateTimeOffset.Parse, genMethod, ct);
431                     break;
432                 case Type ct when ct == typeof(Guid):
433                     e = CallContains(parameter, jsonArray, Guid.Parse, genMethod, ct);
434                     break;
435                 case Type ct when ct.IsEnum:
436                     e = CallContains(Expression.Convert(parameter, typeof(object)), jsonArray, enumString => enumString.ToEnumObject(ct), genMethod, ct);
437                     break;
438 
439                     #endregion
440             }
441 
442             return e;
443 
444             static MethodCallExpression CallContains<T>(Expression pa, string[] jArray, Func<string, T> selector, MethodInfo genericMethod, Type type)
445             {
446                 var data = jArray.Select(selector).ToArray().AsQueryable();
447                 var method = genericMethod.MakeGenericMethod(type);
448 
449                 return Expression.Call(null, method, new[] { Expression.Constant(data), pa });
450             }
451         }
452     }

使用

       此处是在 Razor Page 中使用,内部使用的其他辅助类和前端页面代码就不贴了,有兴趣的可以在我的文章末尾找到 GitHub 项目链接:

 1         public async Task<IActionResult> OnGetUserListAsync([FromQuery]JqGridParameter jqGridParameter)
 2         {
 3             var usersQuery = _userManager.Users.AsNoTracking();
 4             if (jqGridParameter._search == "true")
 5             {
 6                 usersQuery = usersQuery.Where(BuildWhere<ApplicationUser>(jqGridParameter.FilterObject, null));
 7             }
 8 
 9             var users = usersQuery.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).OrderBy(u => u.InsertOrder)
10                 .Skip((jqGridParameter.Page - 1) * jqGridParameter.Rows).Take(jqGridParameter.Rows).ToList();
11             var userCount = usersQuery.Count();
12             var pageCount = Ceiling((double) userCount / jqGridParameter.Rows);
13             return new JsonResult(
14                 new
15                 {
16                     rows //数据集合
17                         = users.Select(u => new
18                         {
19                             u.UserName,
20                             u.Gender,
21                             u.Email,
22                             u.PhoneNumber,
23                             u.EmailConfirmed,
24                             u.PhoneNumberConfirmed,
25                             u.CreationTime,
26                             u.CreatorId,
27                             u.Active,
28                             u.LastModificationTime,
29                             u.LastModifierId,
30                             u.InsertOrder,
31                             u.ConcurrencyStamp,
32                             //以下为JqGrid中必须的字段
33                             u.Id //记录的唯一标识,可在插件中配置为其它字段,但是必须能作为记录的唯一标识用,不能重复
34                         }),
35                     total = pageCount, //总页数
36                     page = jqGridParameter.Page, //当前页码
37                     records = userCount //总记录数
38                 }
39             );
40         }

       启动项目后访问 /Identity/Manage/Users/Index 可以尝试使用。

结语

       通过这次实践,深入了解了很多表达式树的相关知识,表达式树在编译流程中还算是高级结构了,耐点心还是能看懂,IL 才是真的晕,比原生汇编也好不到哪里去。C# 确实很有意思,入门简单,内部却深邃无比,在小白和大神手上完全是两种语言。Java 在 Java 8 时增加了 Stream 和 Lambda 表达式功能,一看就是在对标 Linq,不过那名字取的真是一言难尽,看代码写代码感觉如鲠在喉,相当不爽。由于 Stream 体系缺少表达式树,这种动态构造查询表达式的功能从一开始就不可能支持。再加上 Java 没有匿名类型,没有对象初始化器,每次用 Stream 就难受的一批,中间过程的数据结构也要专门写类,每个中间类还要独占一个文件,简直晕死。抄都抄不及格!

       C# 引入 var 关键字核心是为匿名类型服务,毕竟是编译器自动生成的类型,写代码的时候根本没有名字,不用 var 用什么?简化变量初始化代码只是顺带的。结果 Java 又抄一半,还是最不打紧的一半,简化变量初始化代码。真不知道搞 Java 的那帮人在想些什么。

 

       转载请完整保留以下内容并在显眼位置标注,未经授权删除以下内容进行转载盗用的,保留追究法律责任的权利!

  本文地址:https://www.cnblogs.com/coredx/p/12423929.html

  完整源代码:Github

  里面有各种小东西,这只是其中之一,不嫌弃的话可以Star一下。

原文出处:https://www.cnblogs.com/coredx/p/12423929.html

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部