user_name,新系统要求 name;一张表打平的字段需要按业务模块重新分组……大多数人的第一反应是写一堆 map、reduce、forEach 把数据硬编码转换过去。问题是:规则一变,代码全改。换了个供应商,API 字段名变了,又要重写一遍转换逻辑。这种硬编码方式在项目初期很爽,但维护成本极高。
这篇文章的核心思路是:把转换规则抽成「映射模板」,用配置代替代码。模板描述"从哪来、到哪去、怎么变",引擎负责执行。你需要改规则时,改配置就行,代码零改动。
一、什么是映射模板
映射模板(Mapping Template)本质上是一份声明式配置文件,它告诉转换引擎三件事:
从哪来(Source)——源数据的路径、格式、字段名
到哪去(Target)——目标结构的路径、字段名、层级关系
怎么变(Transform)——类型转换、字符串处理、默认值、条件判断等
一个最简单的映射模板长这样:
{"sourceFormat": "xml","targetFormat": "json","itemPath": "catalog.book","mappings": [ { "from": "@category", "to": "category" }, { "from": "title", "to": "name" }, { "from": "price", "to": "price", "cast": "number" } ]}模板读入引擎后,引擎自动完成解析、取值、映射、转换、组装的全过程。规则即代码,改规则不用重新部署业务代码。
1.1 为什么要用模板
硬编码方式
转换逻辑散落在业务代码里 改一个字段名要改多处代码 不同场景的转换无法复用 新人接手很难看懂转换规则 无法热更新,必须发版
模板驱动方式
转换规则集中管理在配置文件中 改字段名只改模板一处 同一份模板可被多个流程复用 模板即文档,一看就懂 支持配置中心热更新
1.2 模板字段速查
sourceFormat | xml / json | |
targetFormat | xml / json | |
itemPath | "catalog.book" | |
rootTagitemTag | ||
mappings |
每个 mappings 项支持的字段:
from | @ 前缀,如 "@id" | |
to | "contact.email";目标为 XML 属性时用 @ 前缀 | |
cast | "string"、"number"、"boolean"、"date" | |
transform | "split"、"join"、"upper"、"lower"、"trim"、"template" | |
args | {"delimiter": ","} | |
default | ||
itemMappings | ||
inherit | itemMappings 使用,将父级字段注入每个子项 | |
compute | "qty * price" |
◆ ◆ ◆
二、示例一:XML → JSON(图书目录)
最常见的场景:第三方接口返回 XML,系统内部用 JSON。以图书目录为例,包含属性、嵌套和列表。
<catalog><bookcategory="tech"id="b1"><title>数据转换指南</title><author>张三</author><price>59.9</price></book><bookcategory="novel"id="b2"><title>三体</title><author>刘慈欣</author><price>68.0</price></book></catalog>{"items": [ {"category": "tech","bookId": "b1","name": "数据转换指南","author": "张三","price": 59.9,"currency": "CNY" }, {"category": "novel","bookId": "b2","name": "三体","author": "刘慈欣","price": 68.0,"currency": "CNY" } ]}{"sourceFormat": "xml","targetFormat": "json","itemPath": "catalog.book","mappings": [ { "from": "@category", "to": "category" }, { "from": "@id", "to": "bookId" }, { "from": "title", "to": "name" }, { "from": "author", "to": "author" }, { "from": "price", "to": "price", "cast": "number" }, { "from": null, "to": "currency", "default": "CNY" } ]}关键设计点:
@category的 @前缀表示读取 XML 的属性(attribute),不是子元素"from": null, "default": "CNY"表示源数据中没有这个字段,直接填充固定值 "cast": "number"把 XML 中的字符串 "59.9"转为数字59.9itemPath: "catalog.book"表示每个 <book>元素对应目标数组中一个对象
◆ ◆ ◆
三、示例二:JSON → XML(商品库存)
反向场景:内部 JSON 需要输出给外部系统,对方只收 XML。模板的 @ 前缀在目标为 XML 时表示"映射为属性"。
{"products": [ {"sku": "SKU-001","title": "无线耳机","unitPrice": 299,"stock": 150,"category": "数码" }, {"sku": "SKU-002","title": "机械键盘","unitPrice": 459,"stock": 80,"category": "数码" } ]}<inventory><itemsku="SKU-001"category="数码"><title>无线耳机</title><price>299</price><quantity>150</quantity></item><itemsku="SKU-002"category="数码"><title>机械键盘</title><price>459</price><quantity>80</quantity></item></inventory>{"sourceFormat": "json","targetFormat": "xml","rootTag": "inventory","itemTag": "item","itemPath": "products","mappings": [ { "from": "sku", "to": "@sku" }, { "from": "category", "to": "@category" }, { "from": "title", "to": "title" }, { "from": "unitPrice", "to": "price" }, { "from": "stock", "to": "quantity" } ]}JSON → XML 的模板与 XML → JSON 几乎完全对称,区别在于 to 中的 @ 前缀含义随 targetFormat 切换:目标为 XML 时表示"写成属性",目标为 JSON 时被忽略。引擎根据 targetFormat 决定如何序列化。
◆ ◆ ◆
四、示例三:JSON → JSON(用户结构重组)
JSON 到 JSON 的转换在前端开发中极其常见——API 返回的扁平结构和组件需要的嵌套 props 不匹配。
// 旧系统 API 返回的扁平结构{"user_id": 1001,"user_name": "张三","user_email": "zhangsan@example.com","dept_name": "技术部","dept_code": "DEPT-001","role_names": "admin,editor"}// 新系统需要的嵌套结构{"id": 1001,"name": "张三","contact": {"email": "zhangsan@example.com" },"department": {"name": "技术部","code": "DEPT-001" },"roles": ["admin", "editor"],"isAdmin": true}{"sourceFormat": "json","targetFormat": "json","mappings": [ { "from": "user_id", "to": "id" }, { "from": "user_name", "to": "name" }, { "from": "user_email", "to": "contact.email" }, { "from": "dept_name", "to": "department.name" }, { "from": "dept_code", "to": "department.code" }, {"from": "role_names","to": "roles","transform": "split","args": { "delimiter": "," } }, {"from": "role_names","to": "isAdmin","transform": "includes","args": { "value": "admin" } } ]}这个模板展示了几种重要能力:
- 点号嵌套
: "to": "contact.email"自动创建嵌套对象 - 转换函数
: "transform": "split"把逗号字符串变成数组 - 多目标映射
:同一源字段 role_names映射到两个不同目标 - 派生字段
: isAdmin通过includes转换派生得出
◆ ◆ ◆
五、示例四:批量订单(订单头 + 订单明细)
前面三个示例都是单层结构,真实业务中更常见的是头-行(Header-Line)结构:一个订单包含订单头信息和多条订单明细。这个例子展示如何用模板处理多层嵌套、明细数组映射,以及计算字段。
<orders><totalCount>2</totalCount><generatedAt>2024-01-16T08:30:00Z</generatedAt><order><header><orderNo>ORD-2024-001</orderNo><customer>张三</customer><date>2024-01-15</date></header><lines><line><sku>A001</sku><name>机械键盘</name><qty>2</qty><price>199</price></line><line><sku>A002</sku><name>无线鼠标</name><qty>1</qty><price>89</price></line></lines></order><order><header><orderNo>ORD-2024-002</orderNo><customer>李四</customer><date>2024-01-16</date></header><lines><line><sku>B001</sku><name>显示器</name><qty>1</qty><price>1299</price></line></lines></order></orders>{"orders": [ {"orderNumber": "ORD-2024-001","customerName": "张三","orderDate": "2024-01-15","items": [ {"productCode": "A001","productName": "机械键盘","quantity": 2,"unitPrice": 199,"subtotal": 398,"orderNumber": "ORD-2024-001" }, {"productCode": "A002","productName": "无线鼠标","quantity": 1,"unitPrice": 89,"subtotal": 89,"orderNumber": "ORD-2024-001" } ],"totalAmount": 487 }, {"orderNumber": "ORD-2024-002","customerName": "李四","orderDate": "2024-01-16","items": [ {"productCode": "B001","productName": "显示器","quantity": 1,"unitPrice": 1299,"subtotal": 1299,"orderNumber": "ORD-2024-002" } ],"totalAmount": 1299 } ]}{"sourceFormat": "xml","targetFormat": "json","itemPath": "orders.order","mappings": [// —— 订单头字段映射 —— { "from": "header.orderNo", "to": "orderNumber" }, { "from": "header.customer", "to": "customerName" }, { "from": "header.date", "to": "orderDate" },// —— 订单明细:数组元素级映射 —— {"from": "lines.line","to": "items",// 继承父上下文字段:将父级 orderNumber 注入每个子项"inherit": [ { "from": "header.orderNo", "to": "orderNumber" } ],"itemMappings": [ { "from": "sku", "to": "productCode" }, { "from": "name", "to": "productName" }, { "from": "qty", "to": "quantity", "cast": "number" }, { "from": "price", "to": "unitPrice", "cast": "number" },// 计算字段:小计 = 数量 × 单价 { "from": null, "to": "subtotal", "compute": "qty * price" } ] },// —— 汇总字段:明细小计求和 —— { "from": null, "to": "totalAmount", "compute": "sum(items.subtotal)" } ]}这个模板展示了处理头-行结构的核心能力:
- 选择性映射
:源数据中的 totalCount和generatedAt字段在模板中没有任何对应的from规则,引擎自动忽略——源数据并非所有字段都需要转换 - 订单头映射
: header.orderNo → orderNumber,用点号路径读取嵌套字段 - 明细数组映射
: itemMappings对lines.line数组的每个元素独立映射,字段重命名 + 类型转换 - 上下文继承
: "inherit"将父级字段(如header.orderNo)注入每个子项,所以每个明细对象都带有所属的orderNumber - 计算字段
: "compute": "qty * price"在明细行内计算小计,引擎从当前行上下文取值 - 跨层汇总
: "compute": "sum(items.subtotal)"在订单层对明细的小计求和,得到订单总金额
💡 计算字段的工作原理
compute 表达式在 itemMappings 内时,作用域是当前明细行(能访问 qty、price);在外层 mappings 时,作用域是整个订单对象(能访问已生成的 items 数组)。引擎按声明顺序执行,所以 subtotal 必须先于 totalAmount 生成。
◆ ◆ ◆
六、从零写一个模板引擎
理解了模板结构和四个场景后,来看看如何自己实现一个轻量级转换引擎。引擎只需几百行代码,就能覆盖上述所有场景。
6.1 引擎骨架与转换函数注册
using System.Text.Json.Nodes;using System.Text.RegularExpressions;public classTransformEngine{private readonly JsonObject _template;private readonly Dictionary<string, Func<JsonNode?, JsonObject?, JsonNode?>> _transforms;private readonly Dictionary<string, Func<JsonNode?, JsonNode?>> _casts;public TransformEngine(JsonObject template) { _template = template; _transforms = new() { ["split"] = (v, a) => new JsonArray(v!.ToString().Split(a?.TryGetPropertyValue("delimiter", outvar d) == true ? d!.ToString() : ",").Select(s => (JsonNode)s).ToArray()), ["join"] = (v, a) => v is JsonArray arr ? String.Join(a?["delimiter"]?.ToString() ?? ",", arr.Select(x => x!.ToString())) : v, ["upper"] = (v, _) => v!.ToString().ToUpper(), ["lower"] = (v, _) => v!.ToString().ToLower(), ["trim"] = (v, _) => v!.ToString().Trim(), ["includes"] = (v, a) => v!.ToString().Contains(a?["value"]?.ToString() ?? ""), ["template"] = (v, a) => Regex.Replace(a!["template"]!.ToString(), @"\{\{(\w+)\}\}", m => v![m.Groups[1].Value]?.ToString() ?? ""), }; _casts = new() { ["string"] = v => v!.ToString(), ["number"] = v => double.Parse(v!.ToString()), ["boolean"] = v => v!.ToString() == "true" || v!.GetValueKind() == JsonValueKind.True, ["date"] = v => DateTime.Parse(v!.ToString()).ToString("o"), }; } public JsonObject MapOne(JsonNode? item, JsonArray mappings) {var output = new JsonObject();foreach (var rule in mappings.Cast<JsonObject>()) {var result = ApplyRule(item, rule, this, output);if (result != null)SetPath(output, result.Value.Key, result.Value.Value); }return output; }}6.2 路径读写工具
// 按路径读取值,支持 "header.orderNo" 和 "@attribute"private JsonNode? GetPath(JsonNode? obj, string? path){if (path == null) return obj;return path.Split('.').Aggregate(obj, (current, key) => current is JsonObject o && o.TryGetPropertyValue(key, outvar val) ? val : null);}// 按路径设置值,自动创建中间对象public static void SetPath(JsonObject obj, string path, JsonNode? value){var keys = path.Split('.');var cur = obj;for (var i = 0; i < keys.Length - 1; i++) {if (!cur.ContainsKey(keys[i])) cur[keys[i]] = newJsonObject(); cur = (cur[keys[i]] as JsonObject)!; } cur[keys[^1]] = value;}6.3 单条映射执行(含计算字段)
private (string Key, JsonNode? Value)? ApplyRule( JsonNode? item, JsonObject rule, TransformEngine engine, JsonObject context){// 1. 计算字段:用表达式从当前上下文求值if (rule.TryGetPropertyValue("compute", outvar compute)) {return (rule["to"]!.ToString(), EvalExpr(compute!.ToString(), context)); }// 2. 取值var value = rule["from"] is JsonValue f && f.GetValueKind() != JsonValueKind.Null ? GetPath(item, f.ToString()) : null;// 3. 数组元素映射(明细行)if (rule.TryGetPropertyValue("itemMappings", out _) && value is JsonArray arr) {var mapped = arr.Select(sub => {// 先映射子项字段var subObj = engine.MapOne(sub, rule["itemMappings"]!.AsArray());// 再继承父级字段(inherit 中的规则从父 item 取值,写入子项)if (rule.TryGetPropertyValue("inherit", outvar inherit) && inherit is JsonArray inheritArr) {foreach (var inhRule in inheritArr.Cast<JsonObject>()) {var val = GetPath(item, inhRule["from"]?.ToString());if (val != null)SetPath(subObj, inhRule["to"]!.ToString(), val); } }return subObj; }).ToList();return (rule["to"]!.ToString(), newJsonArray(mapped)); }// 4. 默认值if (value == null || value.GetValueKind() == JsonValueKind.Null) {if (rule.TryGetPropertyValue("default", outvar def)) value = def;else return null; }// 5. 类型转换 + 转换函数if (rule.TryGetPropertyValue("cast", outvar cast) && engine._casts.TryGetValue(cast!.ToString(), outvar castFn)) value = castFn(value);if (rule.TryGetPropertyValue("transform", outvar transform) && engine._transforms.TryGetValue(transform!.ToString(), outvar transFn)) value = transFn(value, rule["args"] as JsonObject);return (rule["to"]!.ToString(), value);}6.4 计算表达式求值
// 简易表达式求值:支持四则运算和 sum() 聚合private JsonNode? EvalExpr(string expr, JsonObject ctx){// sum(items.subtotal) → 对 items 数组中每项取 subtotal 求和var sumMatch = Regex.Match(expr, @"^sum\((\w+)\.(\w+)\)$");if (sumMatch.Success) {var arr = ctx[sumMatch.Groups[1].Value] as JsonArray;var field = sumMatch.Groups[2].Value;var sum = arr?.Sum(it => it?[field]?.GetValue<double>() ?? 0) ?? 0;return sum; }// 四则运算:qty * price → 从上下文取字段值计算var tokens = Regex.Split(expr, @"\s*([*+\/-])\s*");if (tokens.Length == 3) {doubleResolve(string t) =>double.TryParse(t, outvar n) ? n : ctx[t]?.GetValue<double>() ?? 0;var lv = Resolve(tokens[0]);var rv = Resolve(tokens[2]);return tokens[1] switch {"*" => lv * rv,"+" => lv + rv,"-" => lv - rv,"/" => lv / rv, _ => 0 }; }return null;}6.5 主流程
public class TransformEngine{// ...构造函数和工具方法...public async Task<JsonNode> RunAsync(string input) {var t = _template; JsonNode? data = input;// 1. 解析源格式if (t["sourceFormat"]?.ToString() == "xml") data = await ParseXmlAsync(input);// 2. 定位数据项数组var items = t.TryGetPropertyValue("itemPath", outvar ip) ? GetPath(data, ip!.ToString()) : data;var arr = items is JsonArray ja ? ja.ToList() : newList<JsonNode?> { items };// 3. 逐条映射var results = arr.Select(item => MapOne(item, t["mappings"]!.AsArray())).ToList();// 4. 序列化目标格式if (t["targetFormat"]?.ToString() == "xml")return ToXml(results, t);return t.ContainsKey("itemPath") ? newJsonObject { ["items"] = newJsonArray(results.ToArray()) } : results[0]!; }}6.6 使用方式
var templateJson = await File.ReadAllTextAsync("./order-template.json");var template = JsonNode.Parse(templateJson)!.AsObject();var engine = new TransformEngine(template);var xml = await File.ReadAllTextAsync("./orders.xml");var result = await engine.RunAsync(xml);Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));// → { "items": [ { "orderNumber": "ORD-2024-001", "items": [...], "totalAmount": 487 }, ... ] }◆ ◆ ◆
七、进阶:模板的高级能力
上面的引擎已能覆盖 80% 的场景,真实业务中还有一些更复杂的需求,这里不方便全部贴出,ParseXmlAsync,ToXml方法因为简单但是太长也没有贴出,这里贴的都是关键代码足够学习了。
八、总结:模板驱动的核心收益
回顾全文四个示例,无论是 XML→JSON、JSON→XML、JSON→JSON,还是订单头+明细的复杂结构,都遵循同一套模板规范:
- 配置即文档
:模板本身就是转换规则的精确描述,不需要额外写文档 - 规则可复用
:同一份模板可被多个接口、多个系统复用 - 修改零代码
:字段改名、结构调整只需改模板,不用重新编译部署 - 测试更简单
:给引擎一组输入+模板,验证输出即可 - 适配成本低
:对接新供应商时,写模板即可,不用改业务代码
无论是格式转换还是结构重组,本质都是"从哪来、到哪去、怎么变"这三个问题的声明式描述。用模板回答这三个问题,代码只负责执行——这就是配置驱动数据转换的核心思想。完整的引擎代码不到 200 行,却足以应对绝大多数业务场景。
夜雨聆风