WaterCloud开发实战:动态数据转静态表格与返回后处理全指南
发布时间:2026/9/28 5:48:20来源:尧图网络
1. WaterCloud 里动态数据和静态表格为什么会同时存在做 .NET Core 开发的朋友对 WaterCloud 这种快速开发框架应该不陌生代码生成器一跑单表增删改查、前端表格、表单就全出来了。这种框架爽是真爽但凡是正经项目早晚会遇到一个坎默认生成的表格强类型绑死了可业务上偏偏需要从多张表拼数据、做统计、出报表结果后端查出来的是dynamic、DataTable这类动态对象前端却还是拿写死的列名去绑定。标题这句话说白了就两层意思怎么把动态数据变成静态表格能用的实体以及表格数据返回给前端前还需要做哪些后处理。这两件事看着不难真正上手后会踩到不少坑。我最近在一个基于 WaterCloud 的水务监测项目里做二开就同时撞上这俩问题。报表模块要按日、按站点动态生成列数据库里是几十张表关联出来的结果而设备管理列表又要求返回统一的code/count/data结构给前端 LayUI table日期字段不能带T枚举要显示成中文密码字段绝对不能原样吐出去。折腾完一轮之后我觉得有必要把整个思路和踩坑记录整理出来给同样在用 .NET Core 和 WaterCloud 做二开的朋友一份可以直接抄作业的参考。先厘清概念动态数据在 WaterCloud 项目里一般有三个来源。第一种最常见多表 join 查询用 SqlSugar 或者原生 SQL 查出来的匿名对象Service 层返回IEnumerabledynamic。第二种是直接读 DataTable比如调存储过程、复杂报表、跨库查询结果集本身就是动态的。第三种是反射组装列头典型场景是透视表原始数据是站点 时间 指标值三列前端要的却是每一行一个站点、每一列一个时间点的交叉表列名是运行时才能确定的。这三种来源有个共同点它们的列结构和某个固定的实体类不是完全对上的要么多列、要么少列、要么列名不同。与之相对的静态表格指的是前端模板里写死列名的表格数据结构每列的名字、类型、含义在编译期就确定了。WaterCloud 默认的数据列表走的就是这条路代码生成器先生成一个数据库实体类Controller 把它查出来包一层分页对象返回前端table.render里用field: xxx去对应。一旦动态数据不转成静态表格直接用前端模板没法写后端也没法做统一的后处理整个链路就断了。所以要解决标题里的问题本质上要打通一条链路动态数据要么在 Service 层转成强类型 DTO要么在返回前套一层字典/JObject 让前端列名固定下来转完之后还要在序列化层面做时间格式、枚举描述、脱敏、Null 处理这些后处理。接下来我把两条线分别展开讲。2. 动态数据转静态表格三条路线和各自的代价动态数据转静态表格目标只有一个让运行时才知道的列变成编译期就确定的属性。做法不止一种我实际试过三条路线效果差别挺大关键是看数据量和可维护性要求。2.1 JSON 中转最省事但别让它成为性能瓶颈最简单粗暴的方式是把动态对象序列化成 JSON 字符串再反序列化成强类型 List。第一次写的时候觉得这办法有点土但架不住它真的省事using Newtonsoft.Json; public static class DynamicConvertHelper { public static ListT ConvertByJsonT(IEnumerabledynamic source) where T : class { if (source null) { return new ListT(); } string json JsonConvert.SerializeObject(source); return JsonConvert.DeserializeObjectListT(json); } }这段代码的优点是解决了“动态对象没法直接给泛型方法用”的尴尬Json.NET 在序列化时会自动把IDictionarystring, object、ExpandoObject、匿名对象统统按属性名匹配到目标类型上。属性名大小写不敏感基本不会出幺蛾子。如果动态数据的属性名和目标 DTO 的属性名有出入还可以加JsonProperty特性做映射比写一遍手拉赋值干净得多。缺点也特别明显大数据量场景下两次序列化很费 CPU。我在本地测试过把 10 万行、每行约 20 列的数据用这种方式转换耗时稳定在 1.2 秒到 1.5 秒左右而同样数据量用手写反射赋值只需要 300 毫秒上下。所以我的建议是1000 行以下、公司内网接口、管理后台这种并发不高的场景用 JSON 中转完全没问题但如果是首页大屏、需要给几百个终端拉数据的报表接口这条路尽量别走。踩过一个更隐蔽的坑如果你的动态对象里某个属性值是Newtonsoft.Json.Linq.JToken类型比如JValue、JArray直接反序列化会经常报无法将 JValue 转换为目标类型。原因很简单JToken虽然表示一个 JSON 节点但它不是真正的int、DateTimeJson.NET 严格模式下不会自动拆包装。解决办法是在反序列化前先把动态对象过一遍JObject.FromObject()或者在 DTO 的对应属性上做自定义转换器。这个坑后文还有更具体的处理办法。2.2 DataTable 反射转实体最稳可空类型的坑必须处理如果数据是DataTable来的比如调了存储过程或者用了 SqlSugar 的DataReader直接填充表格那更适合用反射按行转实体。这个方法本质上是把DataTable的列名和实体属性名一一对应然后逐行赋值using System.Data; using System.Reflection; public static class DataTableConvertHelper { public static ListT DataTableToListT(DataTable dt) where T : class, new() { ListT result new ListT(); if (dt null || dt.Rows.Count 0) { return result; } PropertyInfo[] props typeof(T).GetProperties() .Where(p p.CanWrite) .ToArray(); foreach (DataRow row in dt.Rows) { T item new T(); foreach (PropertyInfo prop in props) { if (!dt.Columns.Contains(prop.Name)) { continue; } object value row[prop.Name]; if (value null || value DBNull.Value) { continue; } Type targetType Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; try { if (targetType typeof(Guid)) { prop.SetValue(item, Guid.Parse(value.ToString())); } else { prop.SetValue(item, Convert.ChangeType(value, targetType)); } } catch (Exception ex) { // 建议这里打日志不要静默吞掉否则线上数据一旦变了形态很难排查 Console.WriteLine($列 {prop.Name} 转换失败: {ex.Message}); } } result.Add(item); } return result; } }这段代码最值得注意的地方在Nullable.GetUnderlyingType因为int?、DateTime?这类可空类型的PropertyType是NullableInt32直接Convert.ChangeType会抛InvalidCastException。先取底层的非空类型再用Convert.ChangeType转这是标准的处理姿势。另外Guid类型Convert.ChangeType也转不了要单独Guid.Parse。这个方法还有个天然优势如果数据库列比实体属性多多的列不影响如果实体属性比数据库列多没在表里找到对应列名的属性就跳过。非常适合报表场景里从宽表里挑一部分字段填充 DTO。但注意DataTableToList的匹配是大小写敏感的dt.Columns.Contains(prop.Name)判断时如果数据库列名是USERNAME实体属性是UserName就会匹配不上。推荐先统一列名再转换或者用StringComparer.OrdinalIgnoreCase做一次字典映射别指望数据库那边不知道哪一天就把列名改了。2.3 ExpandoObject/字典匹配最灵活JToken 的隐蔽坑第三种路线适用于数据不是DataTable而是ExpandoObject、IEnumerabledynamic或匿名对象集合的情况。做法是把动态对象当成IDictionarystring, object来遍历按属性名匹配写入目标实体可以做成一个通用转换器using System.Dynamic; using Newtonsoft.Json.Linq; public static class DynamicConvertHelper { public static ListT ConvertByDictionaryT(IEnumerabledynamic source) where T : class, new() { ListT result new ListT(); if (source null) { return result; } PropertyInfo[] props typeof(T).GetProperties() .Where(p p.CanWrite) .ToArray(); foreach (var item in source) { IDictionarystring, object dict item as IDictionarystring, object; if (dict null item is ExpandoObject exp) { dict (IDictionarystring, object)exp; } if (dict null) { // 处理匿名对象的情况转一次字典 dict item?.GetType() .GetProperties() .ToDictionary(p p.Name, p p.GetValue(item)); } if (dict null) { continue; } T target new T(); foreach (PropertyInfo prop in props) { if (!dict.ContainsKey(prop.Name)) { continue; } object value dict[prop.Name]; if (value null) { continue; } Type targetType Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; try { if (value is JToken token) { prop.SetValue(target, token.ToObject(targetType)); } else if (targetType.IsInstanceOfType(value)) { prop.SetValue(target, value); } else { prop.SetValue(target, Convert.ChangeType(value, targetType)); } } catch (Exception ex) { Console.WriteLine($属性 {prop.Name} 转换失败: {ex.Message}); } } result.Add(target); } return result; } }这里的JToken判断是关键。为什么会有JToken因为在某些框架层动态对象已经被序列化过一次比如经过 API 网关、Redis 缓存反序列化或者某个地方用了JObject.Parse之后再往外传时属性值就变成了JValue类型。如果不去处理它Convert.ChangeType一上来就抛异常。加了token.ToObject(targetType)之后Json.NET 内部会做类型转换JValue(2025-01-15)就能正确变成DateTime。字典做匹配路线的最大优势是列名不要求完全相等可以在循环里加一个别名映射表比如数据库返回stnm实体属性是stationName在字典匹配时把这个别名加进去就行比改 SQL 的AS stationName更可控。2.4 三个方案怎么选给个实际的选型参考三条路线不是互相排斥的实际项目中经常混用。我自己定的选型规则是这样的数据来源推荐方案原因匿名对象 / ExpandoObject行数小于 2000JSON 中转代码最少后期维护成本最低DataTable / 存储过程结果集行数不限反射逐行赋值避免 JSON 中转的序列化开销稳定可控列名不固定、需要动态映射别名字典匹配灵活度最高容易扩展别名映射行数超过几万且属性固定手写强类型赋值性能最好反射和 JSON 的损耗都不要手写赋值听起来太古板但对于高频热点接口比如每秒钟被大屏轮询的统计接口确实该这么做。反正 DTO 字段就十几个写一个new DeviceStatDto { Name row[name], Count Convert.ToInt32(row[cnt]) }也就几十行换来的是确定性的性能表现。3. 表格数据返回后的后处理真正的坑都在序列化这一层动态数据转完静态表格不等于万事大吉。因为后端返回给前端的是 JSON而前端 LayUI table 对 JSON 的字段类型、日期格式、Null 处理有一些约定俗成的期望。如果直接在 Controller 里return Json(list)很容易出现三种情况日期显示成2025-01-15T10:30:00、枚举显示成数字、密码字段明文摆出来。这些都属于表格数据返回需要后处理的范畴。3.1 时间格式前端表格里那一串 T 和 UTC 问题的来源.NET Core 默认序列化DateTime用的是 ISO 8601 格式也就是2025-01-15T10:30:00。如果数据库里存的是 UTC 时间甚至会出现2025-01-15T02:30:00Z这种带时区尾巴的字符串。LayUI 表格拿到这个值直接往单元格里塞用户看到的是一坨没有任何格式的可读性很差的文本。处理办法有两个层级。全局配置优先在注册 MVC 或 Controllers 服务时指定默认格式services.AddControllersWithViews() .AddNewtonsoftJson(options { options.SerializerSettings.DateFormatString yyyy-MM-dd HH:mm:ss; options.SerializerSettings.NullValueHandling NullValueHandling.Ignore; });如果是用原生的System.Text.Json则写法不同services.AddControllersWithViews() .AddJsonOptions(options { options.JsonSerializerOptions.Converters.Add(new DateTimeJsonConverter(yyyy-MM-dd HH:mm:ss)); });全局配置能解决 90% 的场景但有个问题日期类型是DateTime?、DateTimeOffset、或者属性上需要特殊格式比如生日只要yyyy-MM-dd时全局配置就会误伤。所以更稳妥的做法是只对个别字段加JsonProperty或自定义转换器public class DateOnlyJsonConverter : JsonConverterDateTime { private readonly string _format yyyy-MM-dd; public override void WriteJson(JsonWriter writer, DateTime value, JsonSerializer serializer) { writer.WriteValue(value.ToString(_format)); } public override DateTime ReadJson(JsonReader reader, Type objectType, DateTime existingValue, bool hasExistingValue, JsonSerializer serializer) { if (reader.Value null) { return default; } return DateTime.Parse(reader.Value.ToString()); } }用的时候在 DTO 属性上标一下public class ReportRowDto { [JsonConverter(typeof(DateOnlyJsonConverter))] public DateTime StatDate { get; set; } }要注意自定义转换器是加在属性上的优先级高于全局配置。如果你全局已经配置了yyyy-MM-dd HH:mm:ss某个字段不想跟随全局格式就用转换器覆盖。3.2 枚举字段存的是数字显示要文字WaterCloud 的实体字段里枚举类型很常见比如状态、类型、性别。Json.NET 默认把枚举序列化成数字前端拿到1就得自己写 JS 映射文本。这种逻辑散落在前端可维护性极差所以后端应该统一转成文本。推荐做法是给枚举加Description特性再写一个通用转换器public enum DeviceStatus { [Description(离线)] Offline 0, [Description(在线)] Online 1, [Description(告警)] Alarm 2 } public class EnumDescriptionConverter : JsonConverterEnum { public override void WriteJson(JsonWriter writer, Enum value, JsonSerializer serializer) { if (value null) { writer.WriteNull(); return; } FieldInfo field value.GetType().GetField(value.ToString()); string text field?.GetCustomAttributeDescriptionAttribute()?.Description ?? value.ToString(); writer.WriteValue(text); } public override Enum ReadJson(JsonReader reader, Type objectType, Enum existingValue, bool hasExistingValue, JsonSerializer serializer) { string input reader.Value?.ToString(); if (string.IsNullOrEmpty(input)) { return existingValue; } Type enumType Nullable.GetUnderlyingType(objectType) ?? objectType; if (!enumType.IsEnum) { return existingValue; } foreach (string name in Enum.GetNames(enumType)) { FieldInfo field enumType.GetField(name); string desc field?.GetCustomAttributeDescriptionAttribute()?.Description; if (desc input || name input) { return (Enum)Enum.Parse(enumType, name); } } return existingValue; } }然后在序列化配置里加入这个转换器options.SerializerSettings.Converters.Add(new EnumDescriptionConverter());这样前端就永远不感知数字了显示什么、后台存什么彻底解耦。需要注意一个细节当枚举属性是DeviceStatus?可空时objectType是NullableDeviceStatusEnum.GetNames(objectType)会抛异常所以要先用Nullable.GetUnderlyingType取底层枚举类型这个在上面代码里已经处理了。3.3 敏感字段、Null 和金额精度放在后处理里一起管表格数据返回时不只是格式问题还有安全问题。最典型的场景是用户列表接口实体里带着密码 Hash、盐值、手机号、身份证号。这类字段即便前端模板没绑定只要 JSON 里有浏览器开发者工具一开就全暴露了。所以后处理的第一原则是坚决不要原样输出内部实体Controller 层返回 DTO 或者直接用[JsonIgnore]标掉敏感字段。前者是根治后者是兜底。用[JsonIgnore]时有个坑如果实体同时要被其他接口使用比如内部管理系统和开放 API 用的是同一个实体一个字段在某个接口要输出、另一个接口不能输出全局JsonIgnore就一刀切了。这时候就得用 DTO 或者JObject手动构造输出。我的习惯是敏感类数据一律建 DTO哪怕 DTO 有一半属性和实体重合也值得多写几行安全上的收益远远大于代码量。Null 处理也是个容易忽略的点。表格里某个数字字段是null前端会显示空白如果希望显示成0或者-可以在序列化配置里统一处理。NullValueHandling.Ignore是把 null 字段直接丢掉前端模板比如field: total就取不到值反而变成 undefined。个人经验是数字字段建议在构造 DTO 时就用0兜底字符串字段保留 null 让前端按需处理不要一刀切忽略。金额精度属于另一个高频问题。decimal类型如果数据库存的是12.5序列化输出是12.50本没有错但如果发生了精度丢失比如12.5000001前端表格会显示出一长串小数非常难看。解决办法是在 DTO 层就把金额字段格式化好直接用字符串返回也是常见做法public class ReportRowDto { public decimal Amount { get; set; } [JsonIgnore] public string AmountText Amount.ToString(0.00); }前端绑定AmountText显示就成了12.50。注意别把格式化逻辑漏到前端去表格列一多前端根本管不过来。3.4 统一响应外壳LayUI table 对 JSON 结构的硬性约定做后处理还有一个容易被忽略但特别致命的点返回值结构。WaterCloud 前端用的是 LayUI 的table.render它对接口返回的 JSON 结构有约定典型的成功响应必须是这种格式{ code: 0, msg: , count: 100, data: [ { id: 1, name: 站点A }, { id: 2, name: 站点B } ] }code必须是 0 才算成功count是总条数data是当前页的数据。如果后端返回{ success: true, total: 100, rows: [...] }前端表格就会解析不到数据。很多从其他框架转到 WaterCloud 的人第一步就挂在结构上。所以我的建议是写一个统一响应模型而不是每个 Controller 都现拼匿名对象public class TableResultT { public int code { get; set; } public string msg { get; set; } public long count { get; set; } public ListT data { get; set; } }Controller 里统一返回public IActionResult GetDevicePageList(int page, int limit) { var items _deviceService.GetPageList(page, limit, out long total); return Json(new TableResultDeviceDto { code 0, msg success, count total, data items }); }这样后处理逻辑就可以收敛在这个模型里比如全局给msg赋默认值、code统一从常量读取。以后要调整接口结构只需要改一处。4. 把转换 后处理沉淀成通用工具工程化落地的具体做法讲完了方案和坑最后聊怎么把这些散点沉淀成框架级能力。WaterCloud 本身是个快速开发框架业务系统里可能有几十张表、几十个列表页如果每个接口都复制粘贴转换逻辑代码会越来越难维护。我在这次项目里做了一个小工具集用起来顺手分享给大家。4.1 先定义后处理的处理管道后处理的本质是对返回的每一行数据做若干次加工。为了不让加工逻辑散乱定义了一个简单的管道接口public interface ITableDataPostProcessor { void ProcessT(ListT items); }常见的实现有DateTimeFormatterProcessor处理需要特殊日期格式的字段EnumDescriptionProcessor处理枚举字段通常已经在序列化层完成这里更多是做 DTO 构建时的兜底SensitiveFieldMaskProcessor对手机号、邮箱做脱敏替换比如138****1234NullValueFillProcessor对指定字段填充默认值调用时机放在 Service 返回数据之后、Controller 返回 Json 之前。如果多个处理器都要执行就做一个调度入口public class TableDataPostProcessor { private readonly ListITableDataPostProcessor _processors new(); public TableDataPostProcessor Add(ITableDataPostProcessor processor) { _processors.Add(processor); return this; } public TableDataPostProcessor AddRange(IEnumerableITableDataPostProcessor processors) { _processors.AddRange(processors); return this; } public void ProcessT(ListT items) { foreach (var processor in _processors) { processor.Process(items); } } }在依赖注入里注册一份单例配合扩展方法使用public static class TableDataExtensions { public static ListT ProcessTableDataT(this ListT items, ActionTableDataPostProcessor configure) { var pipeline new TableDataPostProcessor(); configure?.Invoke(pipeline); pipeline.Process(items); return items; } }调用起来长这样var dtos _reportService.GetDailyReport(dto); dtos.ProcessTableData(pipeline { pipeline.Add(new SensitiveFieldMaskProcessor()); pipeline.Add(new NullValueFillProcessor()); });这样整个后处理的链路就变成一个可插拔的配置新页面要加规则只需要新增一个处理器不碰原有代码。4.2 在 Service 层转换在 Controller 层统一加工我踩过一次很大的坑是让 Controller 直接接收dynamic再往下传结果后处理逻辑不知道数据是什么类型只能靠反射去猜。后来规范成了两条铁律第一动态转换发生在 Service 层。Service 拿到DataTable或ExpandoObject之后立刻转成强类型 DTO转完的 DTO 基本不再变形。这样做的好处是单元测试好写坏数据在 Service 层就能暴露。第二Controller 只做统一外壳和管道调度。Controller 不关心 DTO 内部字段怎么格式化不关心枚举怎么转文本只负责把加工后的列表塞进TableResultT。如果有一天要接别的协议比如导出 Excel直接从 Service 拿ListT把表格后处理器换一套专门针对导出的实现就行Controller 不动。这个分层总结成一句话动态数据进静态数据出后处理在管道里做管道挂在 Service 和 Controller 之间。4.3 Swagger 前缀等周边配置的连带影响做完整套改造之后还有一个环境配置容易踩坑和标题的返回后处理相关但又不是同一层的问题如果你的 WaterCloud 应用部署在二级路径下比如 Nginx 里站点的路由是https://your.domain/watercloud那 Swagger 页面和接口的访问路径都要带上前缀否则后端内部的接口地址是能访问的前端代理转发却匹配不上。最简单的处理是在 Program.cs 或 Startup 里设置 PathBaseapp.UsePathBase(/watercloud); app.UseSwagger(c { c.RouteTemplate swagger/{documentName}/swagger.json; c.PreSerializeFilters.Add((swaggerDoc, httpReq) { swaggerDoc.Servers new ListOpenApiServer { new OpenApiServer { Url ${httpReq.Scheme}://{httpReq.Host.Value}{httpReq.PathBase} } }; }); });如果不设置UsePathBaseSwagger UI 里的请求地址就会缺前缀联调时前端控制台密密麻麻全是 404。这个属于返回路径后处理的配角但实际项目里非常影响效率一并提一下。还有配套的 CORS 配置。如果前端站点和后端 API 域名不同后处理完的接口会被浏览器跨域策略拦截在服务里加上services.AddCors(options { options.AddPolicy(AllowFrontend, policy policy.WithOrigins(https://your.front.domain) .AllowAnyHeader() .AllowAnyMethod()); });然后在app.UseRouting()之后调用app.UseCors(AllowFrontend)。顺序错了CORS 配置不会生效接口还是会报跨域错误。4.4 性能实测与常见坑一次十万行数据的返工教训这次项目里我最初图省事所有报表接口都用 JSON 中转方式。报表页一次查询出来大概 8 万行、18 个字段压测时发现接口 P95 延迟冲到了 1.8 秒虽然不至于崩但大屏轮询时明显有卡顿。后来逐步替换成 DataTable 反射转实体延迟降到 400-500 毫秒再对最核心的一个接口手写赋值延迟稳定在 200 毫秒以内。这个优化过程让我重新评估了代码维护性和性能之间的权重如果数据量百行以内JSON 中转和反射几乎没差别怎么简单怎么来。如果数据量上万一定优先用反射逐行赋值性能稳定且代码量可控。如果数据量十万以上且接口热点考虑手写赋值或者干脆做分页、做聚合别一股脑全量返回。还有一个容易忽略的性能点动态数据转静态表格时如果 DTO 里有很多子对象的嵌套结构比如列表里每行又带一个ListDeviceDetail反射转换的深度会成倍增加。这种时候最好在 SQL 层就做聚合或者用一个JObject组装避免在内存里一层层反射。4.5 最后几个实战建议项目收尾后我把这次二开踩过的坑总结成几条经验给后来者提个醒第一能强类型就别动态。动态数据转静态表格之所以麻烦根源是动态对象破坏了类型信息。如果能在 SQL 层用AS把列名固定让查询结果和 DTO 完全对齐后面的转换成本会低很多。我见过太多人为了省一个 DTO让整个 Service 层飘满dynamic后处理逻辑根本没法写。第二后处理不是只做一次。表格返回给前端是一套后处理导出 Excel 是另一套发送告警短信可能还要再处理一次。所以后处理逻辑最好是可配置的管道而不是散落在每个接口里的foreach。用ITableDataPostProcessor接口把规则提炼出来每个新场景只是新增一个处理器的事。第三安全不能指望前端模板。只要 JSON 里有敏感字段哪怕前端没有展示它也是暴露了。所有集合接口都过一遍 DTO 构建流程敏感属性剔干净了再交给表格。第四日期、枚举、金额这三类字段后处理时一次到位。我见过一个项目前端为了把日期字符串转换成yyyy-MM-dd每个页面写一遍 JS 截取函数后来接口加了个 UTC 时区字段前端直接乱套。后端统一格式化前端的活就少一半。最后再分享一个小技巧如果你接手了一个别人留下的 WaterCloud 二开项目第一件事不是改代码而是把所有dynamic出现的 Service 方法列出来看它们的下游是谁。凡是要返回给表格组件的按照本文的流程转成 DTO 并打上统一后处理凡是只管内部计算、不进表格的暂时不动。否则一边改一边漏容易在数据边界上出莫名其妙的格式问题。
网站建设高端定制企业官网