ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DataTable帮助类设计与实战:扩展方法解决筛选分页与JSON转换

DataTable帮助类设计与实战:扩展方法解决筛选分页与JSON转换 最近在维护一个老旧的ASP.NET WebForms项目几乎每天的开发任务都在和DataTable打交道。列表数据从数据库查出来塞进DataTable筛选在DataTable里做分页在DataTable里做导出Excel也要从DataTable取数前端表格渲染还得先把DataTable转成JSON。用久了就发现原生DataTable的API写起来非常啰嗦一段“筛选排序取前N条”的逻辑每个页面都要复制粘贴一遍稍微改动一下需求就到处漏。于是我用业余时间整理了一个DataTable帮助类把高频操作收敛成扩展方法这篇文章就聊聊这个帮助类的设计思路、关键源码以及从前端悬浮展示到性能优化的一些实测经验。如果你也在维护类似的老项目或者手头有大量DataSet/DataTable代码这篇应该能给你一些参考。1. 别再到处粘贴DataTable筛选代码了问题与收敛思路1.1 原生DataTable操作到底有多啰嗦我先贴一段在老项目里最常见的代码从订单表里筛选状态为“已发货”、金额大于100的记录按创建时间倒序排列再取前20条。原生写法大概是这个样子DataTable orders GetOrders(); DataTable result orders.Clone(); DataRow[] rows orders.Select( Status Shipped AND Amount 100, CreateTime DESC); foreach (DataRow row in rows) { result.ImportRow(row); } // 然后手动截取前20条 DataTable final result.Clone(); for (int i 0; i Math.Min(20, result.Rows.Count); i) { final.ImportRow(result.Rows[i]); }看第一眼还行但实际业务中这里藏着很多问题Select方法的筛选表达式是字符串拼接如果状态值来自变量单引号、特殊字符都得处理不然就抛SyntaxErrorException。日期列在表达式里通常要写成#2025-01-01#格式不同Windows区域设置下解析行为可能不一样很容易在测试机和服务器上表现不一致。列名如果带空格、特殊字符必须用中括号包起来比如[Unit Price] 10不包直接报错。Clone()只复制结构不复制数据最后还要循环ImportRow。如果目标是原表的引用逻辑又得重写。更不用说需要把一个列拼成逗号分隔的字符串、把DataTable转成字典集合、按某个状态分组统计数量等操作几乎每次都要写for循环加一堆临时变量。一个业务层下来数据操作代码又臭又长而且每个页面写的风格还不一样。1.2 我盘点出的高频场景在动手写帮助类之前我统计了手头几个项目里DataTable的使用情况发现高频的操作其实非常集中场景原始做法痛点条件筛选、排序取子集SelectCloneImportRow字符串表达式易错需要新表时步骤多分页显示DataView排序后循环截取分页序号和总条数逻辑散落各处列去重、取唯一值手动遍历 HashSet重复代码多DataTable转实体集合反射逐字段赋值每个表都要重复写一遍DataTable转JSON给前端手写循环拼StringBuilder容易拼错日期格式混乱列数据统计求和、平均Compute加字符串表达式类型转换麻烦空值容易忽略看完这个清单我意识到与其到处贴代码不如把这些操作统一收敛成一个帮助类。但也不能贪多只做使用频率最高的核心方法够用就好。2. 帮助类的结构设计扩展方法、命名空间与一份方法清单2.1 为什么用扩展方法而不是普通静态工具类最开始我计划写一个普通的静态类比如DataTableHelper.Filter(orders, Status Shipped)。但实际用起来总觉得别扭因为调用顺序是反的得先敲类名再传DataTable。后来改用扩展方法把第一个参数设计成this DataTable source调用时就变成了DataTable filtered orders.Filter(Status Shipped, CreateTime DESC);这大大提高了代码的可读性智能提示也会直接出现在DataTable对象后面。不过也要注意扩展方法会“污染”所有DataTable实例的成员列表如果一个项目里DataTable类型被用作领域对象而不是单纯的表格数据容器就需要慎重。我这里的场景很简单DataTable就是数据展示和交换的载体所以扩展方法带来的便利远大于风险。命名空间上我单独建了一个Common.DataTableExtensions不放在系统命名空间里。这样在需要的文件中显式using而不是让整个项目所有文件都自动引入避免名字冲突。2.2 方法清单与使用约定我最终保留了以下方法方法功能主要参数返回值Filter按表达式筛选可选排序和取前N条filterExpression,sortExpression,topDataTablePage分页并可选排序筛选pageIndex,pageSize,sortExpressionDataTableDistinctRows按指定列去重columnNamesDataTableToListT转换为实体列表泛型类型ListTToDictionaryList转换为字典列表无ListDictionarystring, objectGetColumnValues获取指定列去重后的值集合columnNameIEnumerableobject这些方法有一个共同约定不修改原始DataTable所有筛选、分页都返回新表。原因很简单老项目里同一个DataTable经常被多处引用如果某个方法内部修改了Rows或Columns很容易引发“调一个接口另一个页面数据变了”的诡异问题。新表隔离最安全代价只是多一次内存拷贝但现代服务器内存并不缺可维护性更重要。3. 筛选、分页、转实体三个高频方法的完整实现与拆解3.1 Filter返回新表还是直接改原表Filter方法我实现了两个版本。第一个版本基于SelectImportRow适合表达式很复杂的场景第二个版本基于AsEnumerable().Where()适合需要强类型比较或参数化的场景。先看表达式版本public static DataTable Filter( this DataTable source, string filterExpression, string sortExpression null, int? top null) { if (source null) throw new ArgumentNullException(nameof(source)); DataRow[] rows; if (string.IsNullOrWhiteSpace(filterExpression)) rows source.Select(); else rows source.Select(filterExpression, sortExpression); if (top.HasValue top.Value 0) rows rows.Take(top.Value).ToArray(); DataTable result source.Clone(); foreach (DataRow row in rows) { result.ImportRow(row); } return result; }这里有个细节值得注意DataTable.Clone()复制的是表结构包括列定义、主键约束但不复制任何行数据。ImportRow会把已有行的数据复制到新表但它不会保留原行的RowState新表里的每一行默认都是Added状态。如果之后要对这个结果做AcceptChanges或者Delete操作行为会和你想的不太一样。比如你筛出一行要修改后更新数据库直接修改这个新表的行再Update可能会有问题。所以如果在业务逻辑中需要保留行状态的引用式筛选建议不要用这个方法而是返回DataRow[]或者把筛选逻辑改成在原表上动态视图。强类型版本长这样public static DataTable Filter( this DataTable source, FuncDataRow, bool predicate) { if (source null) throw new ArgumentNullException(nameof(source)); if (predicate null) throw new ArgumentNullException(nameof(predicate)); DataTable result source.Clone(); foreach (DataRow row in source.Rows) { if (predicate(row)) result.ImportRow(row); } return result; }这个版本的好处是筛选条件不再是字符串不会踩表达式语法和转义的坑而且可以用decimal、DateTime这些强类型直接比较。缺点是写起来比字符串稍长比如要筛选金额大于100用dt.Filter(r r.Fielddecimal(Amount) 100)。在方法内部我特意用foreach而不是AsEnumerable().Where()是为了避免额外引入System.Data.DataSetExtensions这个程序集的依赖。在老项目的.NET Framework环境下多加一个依赖总会遇到版本兼容问题。3.2 Pager配合排序如何写才不会埋雷分页方法的核心是把排序交给DataView然后用LINQ做跳过和截取。我的实现如下public static DataTable Page( this DataTable source, int pageIndex, int pageSize, string sortExpression null, string filterExpression null) { if (source null) throw new ArgumentNullException(nameof(source)); if (pageIndex 1) pageIndex 1; if (pageSize 0) pageSize 10; DataView view new DataView(source); if (!string.IsNullOrWhiteSpace(filterExpression)) view.RowFilter filterExpression; if (!string.IsNullOrWhiteSpace(sortExpression)) view.Sort sortExpression; DataTable sorted view.ToTable(); int skipCount (pageIndex - 1) * pageSize; DataTable result sorted.Clone(); for (int i skipCount; i Math.Min(sorted.Rows.Count, skipCount pageSize); i) { result.ImportRow(sorted.Rows[i]); } return result; }这里有几个容易埋雷的地方DataView.ToTable()会生成一张新表同时会复制当前视图中的排序和过滤结果。如果view.Sort设置过之后再次改变view.RowFilter这个view仍然带着之前的排序状态影响后续复用。所以我在方法内部new DataView(source)用完即弃避免污染source.DefaultView。如果用source.DefaultView去排序一个页面上有多个组件共用一个DefaultView之后谁改了Sort其他组件的默认顺序就全变了。分页返回的新表仅包含当前页数据但调用方通常还需要知道总条数。方法本身不返回总条数我在项目中一般是提前用source.Rows.Count或者source.Select(filterExpression).Length获取总数。如果过滤后再分页可以用view.Count或者先Filter再Page。当pageIndex大于总页数时skipCount会大于总行数最终返回空表而不是抛异常。这是我特意做成的行为方便前端拿到空数据后优雅显示“无记录”。3.3 ToList列名到属性的映射与性能取舍把DataTable转实体列表是我使用频率最高的方法毕竟业务层最终还是要面向对象。手写反射版本很简单public static ListT ToListT(this DataTable table) where T : class, new() { var list new ListT(); if (table null || table.Rows.Count 0) return list; ListPropertyInfo properties new ListPropertyInfo(); foreach (var prop in typeof(T).GetProperties()) { if (table.Columns.Contains(prop.Name)) properties.Add(prop); } foreach (DataRow row in table.Rows) { T item new T(); foreach (PropertyInfo prop in properties) { object value row[prop.Name]; if (value DBNull.Value) continue; Type targetType Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; if (targetType.IsEnum) { prop.SetValue(item, Enum.ToObject(targetType, value)); } else if (targetType typeof(Guid)) { prop.SetValue(item, Guid.Parse(value.ToString())); } else { prop.SetValue(item, Convert.ChangeType(value, targetType)); } } list.Add(item); } return list; }我在上面做了几个微小但重要的处理属性列表只检索一次并且过滤掉了DataTable里不存在的列。如果每次循环都调用GetProperties()和table.Columns.Contains()1万行数据可能就会产生几十万次反射查询性能明显下降。属性缓存在方法外部时如果同一个类型被反复转换还可以进一步用静态字典缓存但为了代码简单我没有过度设计。Convert.ChangeType处理NullableT要特别注意。如果属性是int?prop.PropertyType是Nullableint直接Convert.ChangeType(value, typeof(int?))会抛InvalidCastException所以必须用Nullable.GetUnderlyingType拿到基础类型。枚举列的处理数据库里枚举值经常是int直接Convert.ChangeType(1, typeof(MyEnum))会抛异常我这里用Enum.ToObject绕开。如果数据库里存的是枚举名称字符串这个逻辑还要改成Enum.Parse可以根据项目实际场景再扩展。如果列名和属性名不一致比如数据库列是user_name属性是UserName需要额外加映射逻辑。最轻量的办法是给属性加自定义Attribute然后在方法里扫描Attribute。但我觉得帮助类不应该承载这种强业务映射更合理的做法是在SQL查询时用AS别名让列名与属性名一致。所以我没在这个帮助类里做列名映射保持简单。4. 从后端JSON到前端悬浮DataTable数据展示的最后一公里4.1 把DataTable变成前端需要的JSON推荐ToDictionaryList很多老项目里DataTable是后端和前端的“中间语言”后端查完表序列化成JSON丢给前端的jQuery DataTables插件。但直接用JsonConvert.SerializeObject(dataTable)序列化DataTable得到的JSON往往夹带着表结构信息不够干净。更稳妥的方式是先转成字典列表再序列化。我写的ToDictionaryList扩展方法public static ListDictionarystring, object ToDictionaryList(this DataTable table) { var result new ListDictionarystring, object(); if (table null) return result; foreach (DataRow row in table.Rows) { var dict new Dictionarystring, object(); foreach (DataColumn col in table.Columns) { object val row[col]; if (val DBNull.Value) val null; dict[col.ColumnName] val; } result.Add(dict); } return result; }这个方法的优势非常明显DBNull统一转成null前端不会因为拿到DBNull字符串而困惑。日期类型保留为DateTime对象交由JSON序列化器按配置输出格式而不是DataTable序列化的自定义格式。可以非常方便地在序列化前对某些列做处理。比如把密码列置空、把长文本截断、把数字格式化因为这些操作都可以在字典上改。实际配合jQuery DataTables时后端通常返回这样一个结构var result new { draw request.Draw, recordsTotal source.Rows.Count, recordsFiltered filtered.Rows.Count, data paged.ToDictionaryList() }; return Json(result);注意这里的filtered是在后端完成筛选后的DataTable不是原始全量表。如果你用我上面的Filter和Page可以直接这样组织响应前端DataTables在读ajax.data的时候就能直接用了。4.2 鼠标悬浮展示全部数据三种前端写法与我的选择热搜词里有一条“jquery datatable 单元格内容过长展示..鼠标悬浮展示全部数据”我猜很多人是表格某列文本太长撑爆了布局。这个问题的本质是DataTables默认不会自动截断文本所以你需要告诉浏览器这列最多显示多少宽度然后给一个“悬浮显示全部”的交互。最朴素但可靠的做法是原生的HTML title属性。在columns.render中返回一个带title的span这不需要引入任何额外的库{ data: remark, title: 备注, render: function(data, type, row) { if (data null || data ) { return ; } var fullText String(data); if (fullText.length 20) { var safeText $(div).text(fullText).html(); return span title safeText fullText.substring(0, 20) .../span; } return fullText; } }这里之所以用$(div).text(fullText).html()是为了把文本里的单引号、双引号、、等字符转成HTML实体防止title属性被截断或者触发XSS。如果原始数据本身是安全的可以直接使用如果不确定强烈建议保留这个转义步骤。第二种方案是使用Bootstrap tooltip适合已经在项目里引入了Bootstrap的情况。在DataTable的createdCell回调里给单元格加属性然后在drawCallback里初始化createdCell: function(td, cellData, rowData, row, col) { var text String(cellData); if (text.length 20) { $(td).attr(data-toggle, tooltip) .attr(title, text) .addClass(ellipsis); } }, drawCallback: function(settings) { $([data-toggletooltip]).tooltip({ trigger: hover }); }同时配合一段CSS.ellipsis { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }注意white-space: nowrap必须加上否则文本换行后text-overflow: ellipsis不会生效。你也可以用td.ellipsis避免影响所有单元格。第三种方案是使用popover可以展示更详细的格式化内容甚至图片。但popover的交互通常需要点击而不是悬浮如果你非要悬浮才展示还得处理鼠标移入移出的延迟复杂度高我一般只在前两种里选。实际项目中如果只是“看完整文本”原生title就够了如果还想有点样式用tooltip。还要提一个DataTables本身的安全特性默认情况下DataTables会把数据当作文本渲染到td里你返回的HTML字符串默认会被转义吗不一定。在render回调里返回的字符串不会自动转义所以刚才的XSS转义逻辑必须要做。如果你是直接绑定数据源可以用$.fn.dataTable.render.text()处理render: $.fn.dataTable.render.text().with(, ...)但这对“截断前20个字符再显示完整title”的需求不适用所以我更习惯在render里自己控制。4.3 前后端字段约定与注意事项用ToDictionaryList序列化时DataTable的列名会原样变成JSON的键名。如果你后面用DataTables的columns.data配置需要注意JSON键的大小写。很多老项目数据库列名是USER_NAME属性名是UserName如果后端直接序列化DataTable前端就要写USER_NAME非常难看。我一般会在SQL查询时就给列起别名或者在ToDictionaryList后做一次键名重命名。比如var dictList table.ToDictionaryList(); foreach (var dict in dictList) { dict[UserName] dict[USER_NAME]; dict.Remove(USER_NAME); }这种方法虽然有点土但在老项目迁移阶段非常实用后端改动小前端可读性高。如果你的列名规范统一这一步可以省略。5. 真实项目里踩过的坑性能、类型与序列化细节5.1 Select表达式的坑列名转义与日期格式DataTable.Select的表达式看起来像SQL但它不是SQL解析规则很奇葩。我在一个项目里用Select(Status Shipped)没问题但换成列名Unit Price后直接报错必须写成[Unit Price]。后来总结了一个规律只要列名里包含空格、点、斜杠、短横线等特殊字符就一定加中括号。中文列名不加括号通常也能运行但保险起见全部加。日期表达式是另一个重灾区。Select(CreateTime #2025-01-01#)这种方式依赖当前线程的区域设置如果系统日期格式是dd/MM/yyyy#2025-01-01#会被理解为2025年1月1日还是1月1日在不同机器上可能不一样。我后来不再自己拼字符串日期而是改用强类型Filter方法dt.Filter(r r.FieldDateTime(CreateTime) startDate)。虽然写起来长了点但至少不会因为换一台服务器就崩溃。5.2 大表的排序分页性能我用一个5万行、10列的DataTable做过粗浅对比。用DataView.Sort加ToTable排序耗时大约80-120ms用AsEnumerable().OrderBy再CopyToDataTable耗时大约150-250ms用DataTable.Select加OrderBy字符串耗时大约100ms。差距不大但在内存中多次调用时频繁ToTable()会产生大量临时表GC压力上升。对大数据量分页我的建议是如果数据已经超过几万行不要在前端一次性加载全部再分页而是在数据库端分页DataTable只承载当前页数据。帮助类里的Page方法更适合几万行以内的场景或者用于内存缓存表的二次筛选。真遇到几十万行的内存表最好的方案是重新设计查询把聚合和过滤尽量下推到SQL层。5.3 反射转换实体与类型转换的坑ToListT在数据量大时性能确实不行我在10万行的表上测过反射SetValue约占90%的时间。如果追求极致性能可以用表达式树生成FuncDataRow, T委托让赋值走强类型IL而不是反射速度可以提升到接近手写循环。但代码复杂度高还要处理列名映射和类型转换。我的取舍是小于1万行随便用反射大于5万行必须优化。多数后台管理系统的列表页单页数据量在几千行以内帮助类完全够用。类型转换里还遇到过两个隐蔽问题Guid类型的列如果数据库列是uniqueidentifierDataTable里值是Guid但Convert.ChangeType(value, typeof(Guid))会抛InvalidCastException因为Convert.ChangeType不支持Guid。我上面的代码单独处理了Guid.Parse。Nullable枚举如果属性是Status?其中的枚举值转换需要先取Nullable.GetUnderlyingType再Enum.ToObject。如果不处理会遇到InvalidCastException。5.4 序列化时的日期与null处理直接JsonConvert.SerializeObject(dataTable)得到的日期字符串通常是/Date(17000000000000800)/或者ISO 8601取决于Json.NET版本和配置。前端new Date(row.CreateTime)解析没问题但如果你要直接显示字符串就很容易出现时区偏移。所以我通常在ToDictionaryList里就把日期格式化成字符串或者先用DateTime.SpecifyKind统一成UTC/本地时间再交给前端处理。另一个容易忽略的是DataRow[col]返回DBNull时如果直接放进字典并序列化Json.NET默认会把它序列化为null但如果你用的不是Json.NET而是其他序列化器可能变成空字符串或{}。我在ToDictionaryList里统一把DBNull.Value转成null这样依赖哪种JSON库都一样。写在最后DataTable帮助类不是银弹它只是帮我把重复劳动压缩到最低。真正有价值的是你在整理过程中对自己业务里高频数据形态的梳理。我这个帮助类里没有考虑DataTable与DataSet的关系、没有做批量Update、也没有做复杂列映射因为在实际项目里那些东西一旦塞进来类就会膨胀到没人敢改。如果你也想整理一个类似的帮助类我建议你先从自己项目里最常用的五六个操作开始不要一开始就追求功能齐全。写一个方法用一阵子踩到坑再扩帮助类会随着你的维护经历一起成长。
返回列表