如何在保险/金融/银行行业减少代码维护





4.00/5 (1投票)
本文将探讨在数据密集的行业中,利用代码生成器、.NET 反射和外部文件来减少代码维护的好处。
引言
在本文中,我将详细阐述如何利用代码生成器和 .NET 反射,为那些处理大量数据和数据测试的行业大幅减少代码维护工作。如果你是在保险、金融或银行公司工作的开发人员,你肯定知道这些公司在代码维护方面存在的问题。很可能你公司里有大量的程序都基于一个单一但庞大的数据格式,而这个格式会时常变更。一旦发生变更,你就知道接下来的几周将痛苦不堪。本文旨在帮助你制定几种策略来应对这一过程。最终,你可能会拥有一个代码生成器,它能生成代码,将其插入到你的项目中,并通过单击按钮重新编译项目。
背景
在我的工作中,大多数同事都在与维护历史遗留代码这项冗长乏味的任务作斗争,甚至没有想过通过自动化来提高效率。在我最近的一个项目中,我接到的任务是将庞大的结构化数据文件与 Excel 工作簿中由 Excel 计算得出的数据进行比较。这些文件大约有 8000 行长,包含超过 1000 个属性,需要与 Excel 工作簿中的数据进行比较。
我认为,思考如何编写尽可能免维护的代码,这个过程可能对许多在处理如此海量数据的行业工作的开发人员都很有趣,因此我决定写这篇文章。
问题
乍一看,这似乎是个简单的任务——但如果仔细研究实际需要达成的目标,就会发现并非如此。
- 高性能——我们需要处理大约 5000 个动态创建的测试用例(存储在 8000 行长的文件中),每个测试用例又包含 20-30 个服务。这些服务本身存储在不同的文件中,每个文件也大约有 8000 行长。
- 最大程度的可编辑性——客户希望自己定义要比较的属性,因为 Excel 工作簿和其中的公式几乎每周都会变。
- 最大程度的可配置性——出于测试目的,他们还希望定义要将哪种类型的文件与哪种类型的 Excel 工作簿进行比较。
大家都知道,“高性能”和“最大程度的某某某”通常是相互矛盾的。如果你给用户更多的控制权,你的代码就必须更动态,也就是说,性能会变差。我们正在尝试寻找一个不会对两者造成太大影响的解决方案。
庞大的数据文件
基本上,我们需要读取这些文件并将其解析为一个包含约 1000 个成员的 C# 结构体。
- 首先,编写这样的代码耗时很长,而且人最终会迷失在这个庞大的数据怪物中。
- 其次,成员的名称可能随时改变(至少在这里是每月一次),并且随时可能引入新的成员。
- 第三,
string
比较的性能不是很好——实际上,在这里它们甚至行不通,因为我们的文件有多个“标题”,每个标题下又包含多个“子成员”,而这些成员的名称在“全局文件范围”内可能会重复。这意味着我们每行至少要进行“标题数量 * 子属性数量”次的string
比较,而且要对 8000 行都这样做。不行。
解决方案:代码生成器
这个问题的解决方案是“代码生成器”。在这个具体案例中,它们读取文件,检测标题和子属性,并生成多个 C# 结构体来代表整个文件。这些生成器的实现完全取决于你——我不会在本文中详细说明如何实现,但如果需要,或许会在另一篇文章中介绍。只需确保除了结构体的 private
成员外,还要为返回值和标题生成 C# 属性!你很快就会发现,这是本文后面所有神奇操作的关键部分。
现在我们有了一个代码生成器,它可以在文件变更时随时生成 C# 代码,维护问题就解决了。但我们那可能高达数百万次的 string
比较怎么办?我为此想出了一个非常聪明的解决方案。在遍历文件中的每个属性时,计算属性字符串的 Hashcode
和该属性所属标题的 Hashcode
。完成此操作后,你可以生成一个 switch
语句,用整数比较替代 string
比较,这能显著提升代码速度。(注意:如果你对 Heading-Hash
进行适当的 abs()
和 mod()
运算(使其不重叠),你可以让标题的哈希值落在 0
到 <num>
的范围内:编译器可能会据此生成一个实际的函数指针表,从而让你获得额外的性能提升。)
这个 switch
语句可能看起来像这样:
switch (headingHash)
{
case {generator_heading_hash}:
{
if (propertyHash == {generator_prop_hash})
myStructure.myHeading.[...].{generator_prop_name} = currentValue;
else if (...)
// Do this for all properties in the current heading
}
// Do this for all headings in the file
}
Excel 工作簿
我们的任务是从工作簿中读取值,并将其与我之前提到的庞大数据文件中的值进行比较。这两种格式之间的接口或连接必须谨慎选择:如果失败,你可能会遇到巨大的维护问题,甚至性能问题。在我被分配这个任务之前,我公司的许多部门都试图寻找一个合适的解决方案。然而,所有的方法都非常难以维护且速度缓慢,因为 Excel 的互操作性有其成本,而他们是逐个单元格处理数据的。我的许多同事最初的方案看起来是这样的:
excelInput.setCell(1, 1, bigFileStructure.someHeading.[...].someProperty);
excelInput.setCell(1, 2, bigFileStructure.someHeading.[...].anotherProperty);
// and a 100 more input parameters
if (excelOutput.cell(1, 1).CastTo<double>() == bigFileStructure.[...].property)
// store result
// and a 1000 more output parameters
维护解决方案:XML 文件和 .NET 反射
为了想出这个解决方案,我着实费了一番脑筋。一方面,我必须为测试提供合理的性能。另一方面,他们希望能够尽可能多地进行操作,以控制测试的内容和方式。最终,我想出了一个能同时满足这两个需求的方案。首先,让我们看看我设计的 XML 文件。该文件的结构如下:
<inputs type="{calculator type}">
<input row="1" column="1" property="{property tree}"/>
</inputs>
Property 特性以及 .NET 反射的闪光点
正如你们中一些人可能知道的,通过 .NET 反射,你可以“按名称”获取函数指针、成员指针以及其他一些神奇的东西。我们如何利用这一点将代码中的实际属性映射到 XML 文件中一个不起眼的 string
上呢?想象一下,我们有一个结构如下的属性树:
<input ... property="Heading1.Property1"/>
还有一个像这样的 C# 结构体:
// Important: We need a static object that remains the same for the entire test run!
// If you want to run the test on multiple cores, adjust the algorithm to your needs ;)
static MyData globalData = new MyData();
struct MyData
{
private Heading1 m_heading1;
public Heading1 Heading1 { get { return m_heading1; } }
}
struct Heading1
{
private double m_property1;
public double Property1 { get { return m_property1; } }
}
然后,你最终可以像下面这样解析属性树:
object current = globalData;
string[] entries = tree.split('.');
for (int i = 0; i < entries.Length-1; i++)
{
var nextEntry = entries[i];
var propInfo = current.GetType().GetProperty(nextEntry);
if (propInfo == null)
// property tree invalid, throw error.
// Gets the reference to e.g. a heading object and sets it current.
current = propInfo.GetValue(current);
}
var finalEntry = entries[entries.Length-1];
var finalProp = current.GetType().GetProperty(finalEntry);
var method = (Func<double>) Delegate.CreateDelegate(typeof(Func<double>), current, finalProp.GetMethod);
listOfMethods.Add(method);
// Note: It is also useful to store the minimum and maximum row/column. This is your task for now ;)
借助 .NET 反射的强大功能,我们现在有了一个函数指针列表,可以在测试运行时直接使用:
public class RunData
{
public class RunEntry
{
public int Row { get; set; }
public int Column { get; set; }
public Func<double> Method { get; }
// We will need those later!
public int MinRow { get; set; }
public int MaxRow { get; set; }
public int MinColumn { get; set; }
public int MaxColumn { get; set; }
}
public List<RunEntry> Entries { get; }
}
public void FillExcel(RunData data)
{
foreach (var entry in data.Entries)
{
excelInput.setCell(entry.Column, entry.Row, entry.Method());
}
}
// For the output, it works pretty much the same, I will not elaborate it in this article.
// if (excelOutput.cell(entry.Column, entry.Row).CastTo<double>() == entry.Method()) { }
这种方法的优势是显而易见的——我们永远不需要重新编译代码(除非我们决定更新代码生成器提供的接口),因此客户在想要实现一个新的属性进行比较时,不必再联系我们。他们只需修改底层的 XML 文件就能实现他们的需求!这是一个双赢的局面。
唯一的缺点是,我们必须使用一个 static
对象,以便在启动时预解析数据并获取委托。虽然这在单线程测试中不是问题,但在多线程测试中会引发严重问题。对此,一个比较“丑陋”的解决方案是,简单地定义数据类的多个 static
实例,并为所有这些实例创建委托。请记住,这会显著降低启动性能。
性能解决方案:基于范围的读写
虽然我们的代码现在已经完全免于维护,但我们仍然是逐个单元格读写,这是一项耗时的任务,每个测试用例的每个服务几乎要消耗掉“约 8 秒的运行时间(!)”。问题在于,我们每读写一个单元格,就必须向 Excel 程序集发出一个互操作调用。互操作的开销远比实际操作单元格要大得多。如果我们改为一次性写入整个范围,互操作的开销就会减少到单个调用,这完全符合我们的需求。但基于范围的读写并不像听起来那么容易——一些输入值是默认值,在写入 Excel 工作簿的过程中不应被覆盖。输入工作表也不要求你逐行连续填写内容——中间会有空白区域。
解决方案:在启动时读取默认值
我们何不在启动时预先获取所有值,并将它们写入一个多维数组中,这样我们就可以在测试运行时修改这个数组,然后将其写回 Excel 工作表?
private object[,] m_inputValues;
public void Initialize()
{
// Remember our "RunEntry" class? We will need min/max row/column now to convert R1C1 to A1!
var a1ref = app.ConvertFormula($"=R{minrow}C{mincol}:R{maxrow}C{maxcol}",
XlReferenceStyle.xlR1C1, XlReferenceStyle.xlA1).ToString().Substring(1);
// Now we have e.g. "AA1:BCE8".
m_inputValues = (object[,]) (worksheet.Range[a1ref].Value2);
}
public void FillExcel(RunData data)
{
foreach (var entry in data.Entries)
{
m_inputValues[entry.Column, entry.Row] = entry.Method();
}
// We again need the A1-reference from Initialize().
worksheet.Range[a1ref].Value2 = m_inputValues;
// Excel will immediately calculate everything, read the output data next.
}
解释:在启动时,我们调用 Initialize
。该函数会一次性将所有默认值读入一个 private
成员变量中。在此过程中,空白区域将变为 null
。我们也不会随意读取一个不确定大小的任意范围——在解析 XML 文件时,我们记住了整个文件中遇到的最小和最大行/列。这些值构成了我们需要的 Excel 范围。现在,当我们在 FillExcel
函数中向 Excel 插入数据时,我们基本上是遍历每个 RunData
条目,同时修改我们在启动时获取的多维数组。(注意:在 C# 中,你可以像调用其他函数一样,使用括号来调用一个委托。)
通过这个解决方案,我将每个测试用例每个服务的运行时间缩短到了“约 150 毫秒”!
结语
编写既无需维护又性能卓越的代码是一项艰巨的任务,尤其是在保险、金融或银行行业工作时,这些行业的数据格式会随着时间的推移不断变化。我希望本文能通过介绍编写代码生成器、结合使用 .NET 反射与委托,以及设计简单高效的 XML 格式(允许客户在需要时自行维护工具参数),帮助你应对这一过程。
如果你有任何问题、建议或批评,请在下面的评论中告诉我。我会尽快回复。
历史
- 2017年9月26日:文章初版