轻量级递归文本模板数据格式化程序






4.92/5 (11投票s)
从数据源填充文本模板。
引言
本文提供了一个代码,该代码可以接收基于文本的模板,并用从数据源提取的数据值替换模板中标识的变量。目前支持的数据源是.NET类对象实例,以及IDataRecord
和IDataReader
对象(例如SqlDataReader
)。
背景
我为公司写的许多程序都涉及因各种原因向不同的人发送电子邮件。这些电子邮件通常是基于数据的,也就是说,我从数据库中提取一些数据,然后在发送电子邮件之前将其格式化。过去,我通过从DLL资源读取文本模板,然后使用string.Replace
来替换**预定义**的变量名来实现这一点。以下是我**过去的做法**。
string SalesOrderItems = GetSalesOrderItemsText();
EmailData info = GetData();
string template = GetTemplate();
string emailBody = template
.Replace("{AccountManagerName}", info.AccountManagerName)
.Replace("{SalesOrderNumber}", info.SalesOrderNumber)
.Replace("{SalesOrderItems}", SalesOrderItems);
GetTemplate()
可能会返回一些类似HTML的内容,如下所示:
{AccountManagerName}:<br />
Sales Order <b>#{SalesOrderNumber}</b> has been fulfilled. It contains the following items:
<table>
<tr>
<th>Item #</th>
<th>Part #</th>
<th>Description</th>
<th>Price</th>
</tr>
{SalesOrderItems}
</table>
而SalesOrderItems
的构建方式基本相同,来自不同的模板,我将生成许多行项目,然后使用string.Join(string.Empty, SalesOrderLinesList)
将它们连接起来。SalesOrderItems
的模板可能看起来像这样:
<tr>
<td>{ItemNumber}</td>
<td>{PartNumber}</td>
<td>{Description}</td>
<td>{Price}</td>
</tr>
在用多个不同的模板构建完此电子邮件后,我会使用.NET库将邮件发送给正确的人。尽管这种方法多年来一直对我很有帮助,但这种方法存在**一些问题**:
- 如果我想在模板中添加新字段,我必须在
.Replace
方法调用系列中添加代码。 - 我必须有单独的代码来构建将在主电子邮件中替换的SalesOrderItems。
- 模板必须拆分成多个部分(一个用于主电子邮件,一个用于重复的项目)。
因此,我最近决定重写我处理此需求的编码方式,而下面提供的代码是该努力的结果。此代码仍有改进的空间,但它处理了我所需的前几个场景。
新代码的优点
我的新版本模板填充代码与旧方法相比,具有以下优点和好处:
- **更简单的变量名** - 与上述使用我发现难以输入和阅读的
{Property}
语法不同,我现在使用@Property语法,类似于SQL变量。 - **非数据结构特定** - 它完全独立,不需要任何特定于数据结构的编码。也就是说,您可以将类型为
A
或类型为B
的对象传递给它,其中A
和B
具有完全不同的字段/属性。代码会自动定位模板中的变量名并进行相应的替换(它使用.NET反射来获取数据值)。 - **多级数据** - 代码能够使用
@Property.SubProperty
语法遍历数据结构。因此,如果A
有一个名为Data
的属性,该属性是一个类型为B
的类,并且类B
包含一个名为Name
的属性,您可以使用@Data.Name
。它可以深入到您需要的任何程度。 - **数据格式化** - 它支持使用
@Variable{Width}{Format}
进行数据格式化。宽度和格式字符串与.NETstring.Format
中使用的相同,并且完全支持所有内置类型格式。例如,如果对象A
包含一个名为DateProcessed
的属性,该属性是DateTime
类型,您可以使用@DateProcessed{12}{yyyy-MMM-dd}
来按您喜欢的方式格式化输出。宽度和格式说明符都是可选的 - 您可以使用两者、其中一个或都不使用,但宽度必须在前。 - **内联列表处理** - 它支持任意实际级别的内联重复替换。因此,不再需要为从列表中构建的模板的每个部分拥有单独的模板文件。唯一的要求是,引用的对象属性(或对象,如果使用
@$
)需要实现IEnumerable
或IDataReader
才能进行重复。 - **内联条件** - 支持简单的数字和字符串条件,以输出“这个”或“那个”。
使用代码
对于下面的每个代码示例,我将假设以下对象结构:
public class CustomerInfo
{
public string Name;
}
public class PartInfo
{
public string PartNumber;
public string Description;
public bool IsRestricted;
public List<PartInfo> SimilarParts = null;
}
public class ItemInfo
{
public PartInfo Part;
public string Description;
public decimal Quantity;
public decimal UnitPrice;
public decimal TotalPrice
{
get
{
return Quantity * UnitPrice;
}
}
}
public class OrderInfo
{
public CustomerInfo Customer = null;
public int OrderNumber;
public List<ItemInfo> Items = null;
public decimal TotalPrice
{
get
{
return (Items != null) ? Items.Sum(i => i.TotalPrice) : 0M;
}
}
}
static void Main(string[] args)
{
PartInfo widget1 = new PartInfo()
{
PartNumber = "ABC-001",
Description = "Widget #1",
IsRestricted = true
};
PartInfo widget2 = new PartInfo()
{
PartNumber = "ABC-002",
Description = "Widget #2",
SimilarParts = new List<PartInfo>() { widget1 }
};
PartInfo widget3 = new PartInfo()
{
PartNumber = "ABC-003",
Description = "Widget #3",
SimilarParts = new List<PartInfo>() { widget1, widget2 }
};
OrderInfo order = new OrderInfo()
{
Customer = new CustomerInfo() { CustomerName = "Michael Bray" },
OrderNumber = 173123,
Items = new List<ItemInfo>() {
new ItemInfo()
{
Part = widget1,
Quantity = 2,
UnitPrice = 30
},
new ItemInfo()
{
Part = widget2,
Quantity = 4.5M,
UnitPrice = 10
},
new ItemInfo()
{
Part = widget3,
Quantity = 60,
UnitPrice = 4.25M
}
}
};
// Run examples
}
示例1:简单数据值替换
本示例演示了如何执行基本变量替换。这是“Hello World”示例。
string template = "Hello, your order #@OrderNumber has been fulfilled. "
+ "The total price is @TotalPrice.";
string filled = FillTemplate(template, order);
输出: Hello, your order #173123 has been fulfilled. The total price is 360.
示例2:多级数据和数据格式化
本示例演示了深入对象结构的能力。请注意代码如何引用Customer.Name
变量。它还演示了数字格式化 - 在这种情况下,TotalPrice
- 使用格式说明符“C”以使小数部分显示为货币。
string template = "Hello @Customer.Name, your order #@OrderNumber has been fulfilled. "
+ "The total price is @TotalPrice{C}.";
string filled = FillTemplate(template, order);
输出: Hello Michael Bray, your order #173123 has been fulfilled. The total price is $360.00.
示例3:列表处理和重复数据
为了处理数据列表,您必须在模板中使用特殊的构造。让我先给一个例子,然后描述这个构造。
string template = "Hello @Customer.Name, your order #@OrderNumber has been fulfilled. "
+ "The items are:\r\n\r\n"
+ "Part Number Description Quantity Unit Price Total Price\r\n"
+ "----------- ------------------- -------- ---------- -----------"
+ "@Items[[#\r\n"
+ "@Part.PartNumber{-12} @Part.Description{-20} @Quantity{8}{F2} "
+ "@UnitPrice{11}{C} @TotalPrice{12}{C}#]]\r\n"
+ " -----------\r\n"
+ " GRAND TOTAL: @TotalPrice{12}{C}\r\n";
string filled = FillTemplate(template, order);
输出
Hello Michael Bray, your order #173123 has been fulfilled. The items are:
Part Number Description Quantity Unit Price Total Price
----------- ------------------- -------- ---------- -----------
ABC-001 Widget #1 2.00 $30.00 $60.00
ABC-002 Widget #2 4.50 $10.00 $45.00
ABC-003 Widget #3 60.00 $4.25 $255.00
-----------
GRAND TOTAL: $360.00
正如上面的代码可能显而易见的,为了处理带有“重复模板”的数据列表,您应该使用语法:
@PropertyName{Width}[[C ....repeated template... C]]
其中 C 是任何字符(在上面的示例中,我使用了#字符)。选择的字符 C 作为结束标签的一部分,该标签标识模板的重复部分已结束。通过选择不同的字符 C,您甚至可以在其他重复模板**内部**拥有重复模板!例如,您可以使用类似以下的内容:
template = "Hello @Customer.Name, your order #@OrderNumber has been fulfilled. "
+ "The items are:\r\n\r\n"
+ "Part Number Description Quantity Unit Price Total Price Similar Parts\r\n"
+ "----------- ------------------- -------- ---------- ----------- -------------"
+ "@Items[[#\r\n"
+ "@Part.PartNumber{-12} @Part.Description{-20} @Quantity{8}{F2} @UnitPrice{11}{C} "
+ "@TotalPrice{12}{C} @Part.SimilarParts{40}[[%@PartNumber,%]]#]]\r\n"
+ " -----------\r\n"
+ " GRAND TOTAL: @TotalPrice{12}{C}\r\n";
filled = FillTemplate(template, order);
输出
Hello Michael Bray, your order #173123 has been fulfilled. The items are:
Part Number Description Quantity Unit Price Total Price Similar Parts
----------- ------------------- -------- ---------- ----------- -------------
ABC-001 Widget #1 2.00 $30.00 $60.00
ABC-002 Widget #2 4.50 $10.00 $45.00 ABC-001,
ABC-003 Widget #3 60.00 $4.25 $255.00 ABC-001,ABC-002,
-----------
GRAND TOTAL: $360.00
请注意SimilarParts
是如何通过在使用[[# ... #]]
的Items
重复模板内部使用带有[[% ... %]]
的重复模板来生成的。只要您不在使用相同分隔符字符的另一个重复模板内部使用模板分隔符字符序列,您就可以将这些重复模板嵌套到所需的深度。(为了清楚起见,字符本身可以在模板中使用 - 它只在紧邻]]
模板终止字符时才有意义。)
与标准变量一样,在这种情况下,{Width}说明符是可选的,如果使用,它将用于格式化由重复模板子表达式返回的字符串。仅当您在一行上格式化数据时才应使用它 - 尝试为多行构造(如项目列表)使用{Width}可能价值不大。
示例4:条件
条件使用与重复数据类似的语法实现:
@?[[C <Conditional> C]][[C <TRUE expression> C]][[C <OPTIONAL FALSE Expression> C]]
请注意,条件必须使用@?作为前缀,并且至少有两个后续子句,一个用于实际条件,一个用于条件为真时要输出的表达式。它还可以选择性地后跟一个可选子句,用于条件为假时输出。
目前,只实现了非常简单的条件。它必须是数字(十进制)或字符串比较,使用运算符>
、>=
、<
、<=
、==
或!=
。条件首先被评估为数字比较,如果失败(也就是说,如果运算符两侧的表达式无法转换为十进制数字),则比较继续作为区分大小写的字符串比较。目前不允许使用复合表达式(例如,使用&&
(AND)或||
(OR))。条件中的表达式周围的空白将被忽略。
template = "Parts List:\r\n"
+ "Part Number Description Is Restricted\r\n"
+ "------------- --------------------- -------------\r\n"
+ "@$[[% @PartNumber{-12} @Description{-20} "
+ "@?[[# @IsRestricted == True #]][[# YES #]][[# NO #]] \r\n%]]";
filled = FillTemplate(template, new List<partinfo>() { widget1, widget2, widget3 });
输出:
Parts List:
Part Number Description Is Restricted
------------- --------------------- -------------
ABC-001 Widget #1 YES
ABC-002 Widget #2 NO
ABC-003 Widget #3 NO
细微之处
还有几点值得注意:
- 代码将尝试查找属性和字段。变量名可能区分大小写。
- 有一个特殊的变量称为
@$
,它将返回对象本身,而不是尝试查找字段或属性。这在某些情况下可能很有用,例如,如果您对.ToString()
进行了重写,而您希望使用该重写作为输出,或者如果您想使用List
作为数据源(因为列表本身没有访问项目的字段或属性)。 然后,要访问列表,可以使用类似以下的构造:Users: \r\n@$[[#@FirstName @LastName -- @Email\r\n#]]
- 代码的设计可以同时查看实例属性和静态属性,但尚未与静态属性或字段进行测试。同样,它的编码方式可以访问公共和非公共属性和字段,但尚未测试非公共属性或字段。我建议坚持使用公共实例字段或公共实例属性。
- 代码能够处理基于对象的(本文主要讨论的)数据源,但它也可以处理
IDataRecord
对象和IDataReader
对象,如SqlDataReader
。(请注意,SqlDataReader实际上同时是IDataRecord
和IDataReader
!) - 如果传递的对象仅是
IDataRecord
,它将被视为一个类实例。但是,在使用IDataRecord
时,多级数据、重复模板(单人和嵌套)不可用,因为IDataRecord
提供的数据结构根本不实现这些概念。 - 如果传递的是
IDataReader
,它将遍历所有传递的记录,因此重复模板可用。嵌套的重复模板不可用,因为每次迭代都被视为一个IDataRecord
。如果您使用重复模板,则必须使用@$
作为主变量名,类似于第2点中描述的语法。 - 实现会递归地先评估所有检测到的中继器元素,然后评估条件。通过这样做,递归地应该不会出现“中继器内的条件”或“条件内的中继器”之类的冲突,只要您遵守嵌套字符问题(即,不要在使用相同分隔符C字符的另一个内部使用[[C C]],即使一个是一个中继器而另一个是条件)。 演示项目有一个更复杂的示例,其中使用了多个嵌套级别。(具体来说,它演示了一个包含中继器的条件,该中继器又包含一个条件。)
关注点
代码使用一个.NET正则表达式来查找模板中需要评估和替换的变量,第二个正则表达式用于查找条件。求值正则表达式有点复杂,但也不难理解。
(?<VarName>@(\w+(\.\w+)*|\$))({(?<Width>-?\d+)})?({(?<Format>.+?)}|
((?<Open>\[{2}(?<CloseC>.))).+?(?<SubExpr-Open>\k<CloseC>\]{2}))?
我将把它分解成单独的部分:
(?<VarName>@(\w+(\.\w+)*|\$))
这部分定位变量名,该变量名可以是$
特殊变量,也可以是一系列重复的Property.SubProperty.SubProperty
...请特别注意,'.'字符必须包含在(被字母数字字符包围)中 - 变量名末尾的点不会被匹配(也不应该)。这允许您将变量名放在句子的末尾,如上面的示例#1所示。
({(?<Width>-?\d+)})?
这部分查找一个可选的.NET样式宽度说明符。它必须是一个整数(尽管0没有有效含义,并且.NET格式化机制甚至可能拒绝它)。
对于最后一部分,我将稍微拆分这部分,以便更容易看到发生了什么...(换句话说,忽略换行符和其他空白!)
(
{(?<Format>.+?)}
| ((?<Open>\[{2}(?<CloseC>.))).+?(?<SubExpr-Open>\k<CloseC>\]{2})
)?
这部分查找包含在大括号内的格式字符串,**或者**它查找匹配的分隔符子表达式,由“使用代码”示例#3中讨论的[[C ... C]]
语法指示。请注意,这意味着同时拥有格式说明符和子表达式是非法的。这个限制是故意设计的,因为.NET中没有有效的字符串格式说明符(尽管您可以指定宽度)。
条件正则表达式类似,我将不再详细讨论。
已知限制和可能的未来增强
- 如果找到的成员是属性(而不是字段),并且该属性是索引属性,则只会使用默认属性。
- 如示例#3第2部分(嵌套重复模板)所示,目前无法修剪我们希望严格作为中间字符的内容。这就是为什么“类似零件”的输出列表不仅在零件号之间包含逗号,还在列表末尾也包含逗号。
- 目前没有代码来处理您希望在模板文件中将@符号紧跟在字符/数字旁边而不执行替换的情况。如果您需要@符号,只需在其后加上一个空格即可。
代码注释
我提供了示例代码,其中包含FillTemplate
方法的源代码,并演示了上述大多数功能。唯一没有完全涵盖的是传递SqlDataReader
,尽管我提供了一个模板供使用。(这是因为我不知道您可能拥有哪些SQL数据源,并且您需要填写一些SQL代码才能看到此功能的实际效果。有关详细信息,请参阅代码。)
历史
- 2012年12月7日 - 首次发布。
- 2012年12月18日 - 添加了条件功能。