增强的字符串处理
允许字符串内的构造被程序化地计算,
这是什么
增强字符串处理系统的动机是实现子字符串的程序化处理。例如
输入字符串:今天,{DateTime::dd/MM/yyyy},是你余生的开始。
目标输出字符串:今天,2010/11/21,是你余生的开始。
假设今天的日期是2010/11/21(格式为:dd/MM/yyyy)。
在处理用户输入或需要程序调用之间或独立字符串评估之间保持一致性的输入规范时,我遇到了这种增强字符串处理的需求。这种需求的一个例子是,一个公式需要为数据库查询规范指定一个前一个工作日。另一个同样适用的处理示例是,需要一个配置文件(键,值)来引用本文上一篇文章中处理的https://codeproject.org.cn/KB/files/Enhanced-Configuration3.aspx中的其他(键,值)字符串对。总的来说,增强字符串处理应该能够处理格式为{Identifier::Value}的任何构造,并根据为Identifier设定的规则,转换构造的Value部分。
如果需要,你会去哪里寻求帮助
我假设你对正则表达式处理足够熟悉,能够理解本文。如果你需要弄清楚正则表达式处理的某些方面,那么有很多好的地方可以供你参考和学习。例如,在MSDN网站上搜索:“c# 正则表达式语言元素”。另外,一位好心的萨玛利坦人Jan Goyvaerts写了一篇出色的正则表达式教程:https://regexper.cn/tutorial.html。
在将正则表达式嵌入代码之前,你可以通过Regulator或Tester来测试你的正则表达式。VS2010有一个扩展中的正则表达式求值器。我使用Derek Slager的测试器:http://derekslager.com/blog/posts/2007/09/a-better-dotnet-regular-expression-tester.ashx已经有几年了。
你也可以随时在文章末尾提问。
简单与复杂
需要处理的子字符串,就像前面例子中的{DateTime::dd/MM/yyyy}一样,称为简单表达式。子字符串可以是复杂的,由嵌套的简单表达式组成。简单表达式是指无法分解为更简单表达式的表达式。
要转换的简单子字符串格式
简单表达式由以下部分组成:
开界符:在上面的例子中是开括号(“{”),但你可以指定不同的开界符选择。开界符不限于单个字符;“<delimiter>”也可以作为开界符。
标识符:在上面的例子中是:DateTime。标识符不限于C#标识符;它可以包含空格和其他字符。
分隔符:按惯例是双冒号:“::”,你可以选择不同的惯例。分隔符不能等于界符。
值:在上面的例子中是日期格式:dd/MM/yyyy。Value字符串可能包含多个部分。例如,在构造{ForeignKey::Value}中,Value有两部分:一个FilePath和一个Key,形成完整的构造:{ForeignKey::C:\dir1\dir2\filename.exe::Key1}。我建议你在Value中使用与分隔Value和Identifier的分隔符相同的字符串。另一方面,Value也可以是空的;在这种情况下,你不应忘记分隔符。所以例如:{CurrentDir::}是一个空Value的正确格式,而不是:{CurrentDir}。
闭界符:在上面的例子中是闭括号(“}”),你可以指定任何不等于开界符也不等于分隔符的闭界符。与开界符类似,闭界符不限于单个字符;“</delimiter>”是“<delimiter>”的一个绝佳闭界符。
不符合上述格式的特殊构造
可能需要一种特殊构造,即不包含标识符和分隔符的构造。我们将这种构造保留给ProcessLiteral类。这种构造接收格式为{literal}的字符串,并返回literal本身。ProcessLiteral类不会处理包含分隔符的构造。
引言
从历史上看,这种适应性最初是作为增强配置文件处理开始的,请参见:https://codeproject.org.cn/KB/files/Enhanced-Configuration3.aspx,很快我就发现自己需要更通用的字符串处理。一些不仅适用于配置文件,还适用于驱动其他规范需求,主要是UI用户规范的东西。
我们增强字符串求值目标
- 能够外部指定界符和分隔符。
- 可扩展性:允许开发人员定义任何新的构造。
- 系统必须足够灵活,能够同时处理多个构造。
- 系统应该能够处理嵌套构造。
- 系统必须易于使用和扩展。在这种情况下,我将“易于”限定为使用正则表达式构造。
- 系统应该能够在不抛出异常的情况下,将未处理的构造保留原样。
变换作为示例
- ProcessCounter:返回一个运行计数器值。
- ProcessCurrentDir:返回当前目录。
- ProcessCurrentTime:返回当前日期/时间。
- ProcessDate:返回当前日期(是ProcessCurrentTime的一个子集,但由于ProcessDate先编写,我将其包含在此作为更简单且好的示例)。
- ProcessForeignKey:从包含(键,值)对的文件中返回该值。
- ProcessIf:允许“if”逻辑。
- ProcessKey:从(键,值)对的集合中返回一个值。
- ProcessLiteral:返回不带界符的构造。特殊情况构造。
- ProcessMemory:存储并返回一个值,类似于计算器的内存。
这些构造中的每一个都在EnhancedStringEvaluateTest类(文件:EnhancedStringEvaluateTest.cs)中得到了进一步的示例,该类是随附代码(见图1 - 代码布局)的EvaluateSampleTest程序集的一部分。
在本文中,我将把上述Process类(以及你将构建的Process类)称为ProcessXxx类。这些类位于TestEvaluation程序集下的ProcessEvaluate文件夹中(见图1 - 代码布局)。
图1. 代码布局。
高层架构概览
图2. 架构概览
随附代码中呈现的解决方案,一个两层解决方案,其中一层由EnhancedStringEval类驱动库,另一层是ProcessXxx类。EnhancedStringEval类有两个方法,我们将重点关注它们,一个是EvaluateString(),另一个是EvaluateStrings(),单数和复数方法名。这些方法是公共作用域的。EnhancedStringEval类“知道”ProcessXxx的IProcessEvaluate接口(见图2 - 架构概览)。
IProcessEvaluate接口由每个ProcessXxx类实现,并提供库和每个ProcessXxx类之间的通信传输;遵循策略设计模式。
本文致力于探讨这种交互,即第一层的库,以及更重要的是如何构建一个ProcessXxx类。构建ProcessXxx类将是你的任务,用于创建新的{Identifier::Value}构造。
入门
如果你需要解决当前紧迫的问题,那么
- 在你的解决方案中包含EnhancedStringEvaluate库。
- 构建一个特定的ProcessXxx求值类。EnhancedStringEval库将调用它来求值你特定的字符串构造。
- 从你的客户端代码中访问字符串构造,类似于EvaluateSampleTest程序集中的EnhancedStringEvaluateTest类中的示例。这样就完成了!
一个ProcessXxx类
为了构建一个ProcessXxx求值类,让我们通过一个现有示例,例如TestEvaluation程序集中的ProcessCounter类,它位于ProcessEvaluate文件夹下,该类将处理诸如{Counter::Name}之类的构造。
因此,例如,{Counter::page}在第一次调用时返回0,第二次调用时返回1,第三次调用时返回2,以此类推。而{Counter::Footnote}将独立于{Counter::page}。{Counter::Footnote}将在其第一次、第二次、第三次调用时分别返回0、1、2……依此类推。
任何ProcessXxx类都负责2项任务:
ProcessXxx 任务1:识别它需要转换的模式。通常,你会在构造函数中设置此识别正则表达式模式。
ProcessXxx 任务2:支持IProcessEvaluate接口。此接口只有一个方法:void Evaluate(object src, EnhancedStringEventArgs ea);,顾名思义,它负责评估它支持的子字符串。
Evaluate(object src, EnhancedStringEventArgs ea)方法是一个事件委托回调方法,负责3项任务:
Evaluate 任务1:从通过ea参数传递的字符串构造中读取。字符串,如“Today, {Date::mm/dd/yyyy}, in the market …”被传递到ea.EhancedPairElem.Value。
Evaluate 任务2:如果Evaluate()方法将ea.EhancedPairElem.Value求值为与其原始值不同的值,则将ea.IsHandled设置为true,否则设置为false。
Evaluate 任务3:如果Evaluate()方法将原始文本求值为与其原始ea.EhancedPairElem.Value不同的值,则更新ea.EhancedPairElem.Value指向的值为新值。
ProcessCounter
随附代码中提供的ProcessCounter类可接受以下格式为有效:
- {Counter::Name} 第一次调用时产生0,第二次调用时产生1,通常在初始值为0之后,它将产生前一个值+1。
- {Counter::Name::Init} 始终产生0并重新初始化命名计数器。
- {Counter::Name::Next} 产生前一个值+1。如果作为第一次调用使用,则抛出异常。
- {Counter::Name::Previous} 产生前一个值-1。如果作为第一次调用使用,则抛出异常。
- {Counter::Name::=n} 产生n。将命名计数器初始化为n(n是整数,可以是负整数)。因此{Counter::Name::Init}和{Counter::Name::=0}具有相同的行为。
- {Counter::Name::+n} 将命名计数器增加n(n是整数,可以是负整数)。如果作为第一次调用使用,则抛出异常。因此{Counter::Name::Next}和{Counter::Name::+1}具有相同的行为。
- {Counter::Name::-n} 将命名计数器减去n(n是整数,可以是负整数)。如果作为第一次调用使用,则抛出异常。从某种意义上说,{Counter::Name::-n}是多余的,因为“n”可以是一个负数。所以{Counter::page count::+-5}等同于{Counter::page count::-5}。
任何其他格式将被拒绝,因为它不属于ProcessCount,因此不会被求值。所以,例如,{Counter::Example Count::Init}是一个有效格式,将返回0,但{Counter::Example Count::The best example in the world}将返回自身,并且处理不会抛出异常。
我们将把“Init”、“Previous”、“Next”、“=n”、“+n”和“-n”这些可选的子模式称为“Extras”。
一个捕捉以上可能性的正则表达式
为了同时处理ProcessXxx的两项任务职责,我们将首先:识别模式。
在构建识别正则表达式时,让我们考虑以下几点:
string pattern = @"({)\s*Counter\s*::(?<name>[^{}:]+)" +
@"(::\s*(?<extras>(init)|(next)|(previous)|" +
@"((?<op>[=+-])\s*" +
@"(?<direction>[+-])?\s*(?<val>[0-9]+)))?)?\s*(})";
关于冒号处理的题外话
现在,我们可能想允许名称中有一个冒号,但不能有两个冒号。我们只看模式的名称部分:“(?<name>[^{}:]+)”。所以计数器的名称可以是一个简单的名称,如“page”,如{Counter::page};或者一个更复杂的名称,如“Dr. Seuss: pages of fun”,如{Counter::Dr. Seuss: pages of fun}。对于Counter处理,允许名称中包含冒号并不关键,然而,对于DateTime格式规范,一个冒号更重要,所以让我们讨论一下。
为了允许名称中有一个冒号而不是两个冒号,我们需要编写一个模式,以便在正则表达式语言中编码:“除此字符串外的任何字符串”?在我们的例子中,“除此字符串外的任何字符串”是“除双冒号外的任何字符串”。正则表达式语言可以OOTB(开箱即用)处理“除此字符外的任何字符”,但它不能OOTB处理“除此字符串外的任何字符串”。
一个可能的解决方案是注意到我们可以将“除双冒号外的任何字符串”的问题简化为“不包含前导冒号后跟冒号的任何字符串”。让我们考虑以下构造:
:?[^:]+(:[^:]+)*:?
这个正则表达式字符串模式匹配单个冒号以及任何后跟或前导非冒号字符的冒号。
考虑到名称不应包含花括号分隔符,我们得到名称的模式为:
(?<name>:?[^{}:]+(:[^{}:]+)*:?)
现在将Counter正则表达式构造组合起来,我们得到:
string pattern =
@"({)\s*Counter\s*::(?<name>:?[^{}:]+(:[^{}:]+)*:?)" +
@"(::\s*(?<extras>(init)|(next)|(previous)|" +
@"((?<op>[=+-])\s*" +
@"(?<direction>[+-])?\s*(?<val>[0-9]+)))?)?\s*(})";
允许跨多个字符的字符串分隔符
你正在编写自己的模式,所以如果你的分隔符是开/闭花括号,并且你的分隔符是双冒号(所有默认值),那么你就完成了ProcessCounter识别模式——它已经提供了。
如果你的分隔符是任何其他单字符分隔符,并且你的分隔符是默认的双冒号值,那么在进行简单的分隔符替换后,你就完成了模式。
但是,如果你的分隔符是多字符字符串,那么我们就回到了之前:Name需要处理“除这些字符串外的任何字符串”,其中“这些字符串”现在是开/闭分隔符。
现在我们需要一种新的方法来处理这个问题:如何在正则表达式语言中编码“除此多字符字符串外的任何字符串”,或者简单地说“除此字符串外的任何字符串”。
一个可能的解决方案是将分隔符替换为单个字符的等效字符,现在我们将“除此字符串外的任何字符串”问题简化为“除此字符外的任何字符”,正则表达式语言可以OOTB处理——开箱即用。替换是一个三步过程:
- 将每个多字符的开/闭分隔符替换为单个字符的分隔符;一个极不可能出现在文本中的字符。所以为了我们的讨论,我们假设开界符是“<delimiter>”,闭界符是“</delimiter>”,我们将它们分别替换为Unicode字符1(‘\u0001’)和字符2(‘\u0002’)。
- 调用EnahancedStringEval类库的Evaluate()方法。
- 将任何剩余的Unicode \u0001和\u0002替换回其等效的开闭分隔符。
模式魔法(识别模式)现在看起来如下:
string pattern = string.Format(
@"({0})\s*Counter\s*::(?<name>:?[^{0}{1}:]+(:[^{0}{1}:]+)*:?)" +
@"(::\s*(?<extras>(init)|(next)|(previous)|" +
@"((?<op>[=+-])\s*" +
@"(?<direction>[+-])?\s*(?<val>[0-9]+)))?)?\s*({1})",
_delim.OpenDelimEquivalent, _delim.CloseDelimEquivalent);
其中_delim.OpenDelimEquivalent和_delim.CloseDelimEquivalent,要么是分隔符本身(如果它们是单字符的),要么是单字符的替换。
重要提示: EnhancedStringEval库期望单字符分隔符,无论是原始的单字符分隔符还是替换后的等效单字符分隔符。
选择公理
我们已经讨论了两种方法来构造“除此字符串外的任何字符串”的识别模式。一种方法是我们不允许冒号后面跟着冒号,第二种方法是将字符串替换为单个字符,然后使用正则表达式OOTB语言功能来识别“除此字符外的任何字符”。第二种选择用于分隔符处理;但我们可以将这种替换思想应用于分隔符字符串(双冒号字符串)。选择哪种可能性来处理分隔符将影响ProcessXxx的第二项任务,即支持IProcessEvaluate。
整合ProcessCounter
为了编码ProcessXxx类,我们需要完成的第二项任务是实现IProcessEvaluate接口——ProcessXxx任务2。回调将如下所示:
可能性1
以一种不允许冒号后面跟着冒号的方式处理Name中的冒号;我们需要一个理解要转换的模式的类变量,_reCounter。
// Class instance variables
private readonly Regex _reCounter;
private readonly IDelimitersAndSeparator _delim;
// Constructor
public ProcessCounter(IDelimitersAndSeparator delim)
{
_delim = delim;
// ProcessXxx Task 1:
RegexOptions reo = RegexOptions.Singleline | RegexOptions.IgnoreCase;
string pattern = string.Format(
@"({0})\s*Counter\s*::(?<name>:?[^{0}{1}:]+(:[^{0}{1}:]+)*:?)" +
@"(::\s*(?<extras>(init)|(next)|(previous)|" +
@"((?<op>[=+-])\s*(?<direction>[+-])?\s*(?<val>[0-9]+)))?)?\s*({1})",
_delim.OpenDelimEquivalent, _delim.CloseDelimEquivalent);
_reCounter = new Regex(pattern, reo);
}
为了完成ProcessCounter类的编码,我们需要处理IProcessEvaluate接口——ProcessXxx任务2。回调将如下所示:
public void Evaluate(object src, EnhancedStringEventArgs ea)
{ // 2
ea.IsHandled = false; // 3
string text = ea.EhancedPairElem.Value; // 4
if (string.IsNullOrWhiteSpace(text)) return; // 5
bool rc = _reCounter.IsMatch(text); // 6
if (!rc) return; // 7
string replacement = _reCounter.Replace(text, CounterReplace); // 8
if (replacement == text) return; // 9
ea.IsHandled = true; //10
ea.EhancedPairElem.Value = replacement; //11
}
Evaluate()方法需要遵守三个任务:
Evaluate 任务1:从通过ea参数传递的字符串构造中读取。上面第4行从ea.EhancedPairElem.Value设置text,之后我们处理和转换text。
Evaluate 任务2:如果Evaluate()方法将ea.EhancedPairElem.Value求值为与其原始值不同的值,则将ea.IsHandled设置为true,否则设置为false。第3行将ea.IsHandled初始化为false,如果发生了(到不同值的)转换,则第10行将ea.IsHandled设置为true。
Evaluate 任务3:如果Evaluate()方法将原始文本求值为与其原始ea.EhancedPairElem.Value不同的值,则更新ea.EhancedPairElem.Value指向的值为新值。见第11行。
可能性2
此可能性对双冒号分隔符使用替换方法,就像我们处理随机长度分隔符时一样。在这种情况下,我们的构造函数会稍有不同(两种可能性之间的差异用黄色高亮显示):
// Class instance variables
private readonly Regex _reCounter;
private readonly IDelimitersAndSeparator _delim;
// Constructor
public ProcessCounter(IDelimitersAndSeparator delim)
{
_delim = delim;
// ProcessXxx Task 1:
RegexOptions reo = RegexOptions.Singleline | RegexOptions.IgnoreCase;
string pattern = string.Format(
@"({0})\s*Counter\s*{2}(?<name>[^{0}{1}{2}]+)" +
@"({2}\s*(?<extras>(init)|(next)|(previous)|" +
@"((?<op>[=+-])\s*(?<direction>[+-])?\s*(?<val>[0-9]+)))?)?\s*({1})",
_delim.OpenDelimEquivalent, _delim.CloseDelimEquivalent,
_delim.SeparatorAlternate);
_reCounter = new Regex(pattern, reo);
}
以及IProcessEvaluate
接口的实现——ProcessXxx
任务2,将如下所示:
public void Evaluate(object src, EnhancedStringEventArgs ea)
{ // 2
ea.IsHandled = false; // 3
string text = ea.EhancedPairElem.Value; // 4
if (string.IsNullOrWhiteSpace(text)) return; // 5
string preText = text.Replace(_delim.Separator,
_delim.SeparatorAlternate); // 6
bool rc = _reCounter.IsMatch(preText); // 7
if (!rc) return; // 8
string replacement = _reCounter.Replace(preText, CounterReplace); // 9
if (replacement == preText) return; //10
ea.IsHandled = true; //11
string postText = replacement.Replace(
_delim.SeparatorAlternate, _delim.Separator); //12
ea.EhancedPairElem.Value = postText; //13
return; //14
}
Evaluate()方法的三个任务:
Evaluate 任务1:从通过ea参数传递的字符串构造中读取。上面第4行将text设置为ea.EhancedPairElem.Value,之后我们处理和转换text。
Evaluate 任务2:如果Evaluate()方法将ea.EhancedPairElem.Value求值为与其原始值不同的值,则将ea.IsHandled设置为true,否则设置为false。见第3行,我们将ea.IsHandled初始化为false,并在第11行设置ea.IsHandled为true,如果发生了转换。
Evaluate 任务3:如果Evaluate()方法将原始文本求值为与其原始ea.EhancedPairElem.Value不同的值,则更新ea.EhancedPairElem.Value指向的值为新值。见第13行。
字符串构造的变换
{Counter::Name}到0, 1, 2, …的变换发生在以下代码行中:
string replacement = _reCounter.Replace(preText, CounterReplace);
(见可能性1中的第8行和可能性2中的第9行)。变换发生在_reCounter.Replace方法的第二个参数中。第二个参数CounterReplace是一个委托实例,其签名是MatchEvaluator。MatchEvaluator委托在System.Text.RegularExpression命名空间中定义,如下所示:
[Serializable]
public delegate string MatchEvaluator(Match match);
CounterReplace方法代表了实际的繁重工作。我不深入研究它,因为它很简单,没有巧妙的陷阱。这正是你需要为你自己的{Identifier::Value}构造编写的内容。 其他一切只是样板代码。冒号问题重述——第二意见
在匹配我们的Name子字符串时,我们对于“不包含双冒号分隔符的字符串”的正则表达式模式,我们的第一个选择是匹配一个“冒号不能跟在冒号后面”的字符串;为此我们得出了一个解决方案:“(?<name>:?[^{}:]+(:[^{}:]+)*:?)”。这个解决方案在处理名称中的尾随冒号时没有问题。另一方面,替换方法处理尾随冒号并不那么好。
假设我们将{Counter::Page:::Next}作为我们要计算的构造。当系统将双冒号替换为单个字符(例如Unicode字符3)时,我们的字符串将转换为:{Counter\u003Page\u0003:Next}。转换将第一个双冒号实例转换为\u003,将计数器名称保留为“Page”而不是“Page:”,并将操作保留为“:Next”而不是“Next”。
现在让我们在匆忙寻求解决方案之前考虑一下这个问题。如果我们在名称后面加上一个空格,那么名称就不再以冒号结尾,一切就都好了。你可能会决定制定一个规则:“不允许在名称末尾加冒号”。最后,如果出于任何业务原因你必须有一个以冒号结尾的名称,并且你决心使用替换方式处理名称中的双冒号,那么以下是一个可能的解决方案。
利用EnhancedStringEval库类的PreEvaluate()和PostEvaluate()方法来解决问题。(我将在讨论库时解释PreEvaluate()和PostEvaluate()方法。)目前请记住,库的Evaluate()方法在其周围有Pre/PostEvaluate()方法。绕过问题的方法是检测名称是否以尾随冒号结尾,在PreEvaluate()方法中将尾随冒号只替换为另一个字符——例如\u0004。然后执行求值——EvaluateString()。最后,在PoseEvaluate()方法中,将剩余的\u0004(如果存在)替换为冒号。请参阅EnhancedStringEvaluateTest类中的TestMethod TestCounter2。
选择的自由
另一个需要解决的问题是,你应该在你的ProcessXxx中使用哪种可能性?可能性1使用知道内部如何区分单冒号和双冒号的字符串,如“:?[^:]+(:[^:]+)*:?”;还是可能性2使用单个字符替换字符串,并利用正则表达式语言功能排除该单个字符。
我的看法是,当分隔符字符串像双冒号一样简单时,处理名称中单冒号的额外复杂性是可以管理的,并且仅限于正则表达式模式。这种复杂性仅限于构造函数,而类的其余部分不知道匹配模式的变化。
另一方面,当我们采用替换解决方案时,我们需要警惕这样一个事实:我们需要在Evaluate()方法中来回转换字符串。请参阅可能性2代码中的黄色高亮部分。
总结:我们不应忽视这样一个事实:我们可能不需要处理名称中的单冒号——限制识别模式的名称部分中的冒号可能是一个好选择。如果我们确实需要识别名称部分中的单冒号,并且我们处理的分隔符像双冒号一样简单,那么我将乐于将复杂性限制在正则表达式模式(在构造函数中)。另一方面,如果我们处理的分隔符比双冒号更复杂,那么我将毫不犹豫地采用替换解决方案。
我相信你已经准备好开发自己的ProcessXxx了。如果你觉得需要更多的示例,请参见TestEvaluation程序集下的ProcessEvaluate文件夹中的各种ProcessXxx类。
过渡
通过查看ProcessCounter类,我们完成了ProcessXxx的解释。如果你需要“快速”上手,那么你已经阅读并学习了你需要的所有内容。
此后,我们将审视整个程序;了解库是如何运行的,以及“外部”世界,即客户端,是如何与库交互的。
认识参与者
ProcessXxx:我们刚刚看到它实现了IProcessEvaluate接口。ProcessXxx“知道”如何转换特定的{Identifier::Value}。
EnhancedStringEval库:位于EnhancedStringEvaluate程序集中的类集合,由EnhancedStringEval类驱动。EnhancedStringEval类负责调用各种ProcessXxx回调方法,直到不再发生进一步的转换。这里的连接是通过接口IProcessEvalute,该接口承诺实现Evalute()方法。
客户端:调用库的EnhancedStringEval的EvaluateString()方法的例程。随附程序示例中的客户端位于TestEvaluation程序集中的Program.cs文件内,是Main静态方法的一部分。其他示例可以在EvaluateSampleTest程序集的EnhancedStringEvaluateTest类中找到。为了举例说明客户端的调用:
//
// Client code
//
// First ProcessXxx class (definition)
var currDir = new ProcessCurrentDir();
currDir.CurrentDir = @"C:\Accounting";
// Define a container for ProcessXxx’s
var context = new List<IProcessEvaluate>();
// Add 2 ProcessXxx classes
context.Add(currDir);
context.Add(new ProcessCounter());
// Client instantiates the EnhancedStringEval library and then calls it.
var eval = new EnhancedStringEval(context);
// Evaluate a string using the ProcessXxx’s passed in the constructor
string dir1 = eval.EvaluateString("{Counter::Dir}. {CurrentDir::}");
// dir1 == “0. C:\Accounting”
EnhancedStringEval库类
其目的是为客户端例程提供一个方法签名,以传递包含{Indetifier::Value}的字符串。其签名是: public virtual string EvaluateString(string text)
库会将各种{Identifier::Value}的处理委托给各种ProcessXxx的Evaluate()方法,然后将求值后的字符串返回给客户端。
依赖注入(DI)是我们情况下的自然选择。我们将把各个ProcessXxx类打包,通过“构造函数注入”传递到EnhancedStringEval构造函数中。DI(依赖注入)在文献中也称为IOC(控制反转)。这两个名称都指同一个设计模式。DI允许我们处理这种情况,即我们知道方法的接口,但不知道实现的细节。DI是一种模式,我们将一个未知的类实现“注入”到我们的EnhancedStringEval类库中。其中“注入”是指将一个类实例传递到我们的EnhancedStringEval类库中,而该库不知道其实现,只知道其接口签名。
这是对DI的简略描述。我假设你已经知道DI设计模式,或者可以到别处阅读它。(我最喜欢的关于设计模式的书籍之一是Judith Bishop写的《C# 3.0 Design Patterns》,O’Reilly出版。)总的来说,你无需了解DI设计模式即可理解代码或文章。
OnEvaluateContext
EnhancedStringEval类依赖于IProcessEvaluate承诺的回调方法——Evaluate()。事件回调处理程序是OnEvaluateContext,它位于EnhancedStringEval类中,并在构造函数中进行填充: // Constructor
public EnhancedStringEval(
IEnumerable<IProcessEvaluate> context, IDelimitersAndSeparator delim)
{
_delim = delim;
if (context != null)
foreach (IProcessEvaluate processXxx in context)
if (processXxx != null)
OnEvaluateContext += processXxx.Evaluate;
}
或者,你也可以使用内置的事件添加/删除运算符(“+=”和“-=”)来添加/删除Evaluate回调方法。因此,客户端现在有两个选择。
选择1
var context = new List<IProcessEvaluate>();
context.Add(new ProcessCounter());
var eval = new EnhancedStringEval(context);
string c1 = eval.EvaluateString("Counter1: {Counter::CounterName1}");
变量context是注入到EnhancedStringEval类中的类。
选择2:你也可以一个接一个地添加IProcessEvaluate的Evaluate()方法,如下所示:
var eval = new EnhancedStringEval();
eval.OnEvaluateContext += new ProcessCounter().Evaluate;
string c1 = eval.EvaluateString("Counter1: {Counter::CounterName1}");
我们再次面临选择,我的观点是,如果你能使用选择1(构造函数注入),那么就使用它。但是,如果你出于任何原因,需要在中间添加/删除IProcessEvaluate的Evaluate()的灵活性,那么可以将选择2与选择1结合起来。
客户端将调用EnhancedStringEval类库的两个例程之一:EvaluateString()或EvaluateStrings(),单数或复数例程名称。这两个方法相似但服务于不同的目的,因此名称不同。我们将从讨论EvaluateString()方法开始。
EvaluateString—单数名称
EvaluateString()方法本身如下所示: Public virtual string EvaluateString(string text)
{
string preText = PreEvaluate(text);
string balanceText = BalancePreEvaluate(preText);
string evalText = EvaluateStringPure(balanceText);
string postText = PostEvaluate(evalText);
return postText;
}
PreEvaluate()方法允许客户端在求值发生之前对输入文本执行一些操作。PreEvaluate()方法有一个与之平衡的:PostEvaluate()方法;两者都有一个传递通过的默认实现。这些方法都是虚方法,因此为了使用这些方法,你需要从EnhancedStringEval派生一个类,并重写Pre/PostEvaluate()方法。我们已经看到了这种Pre/PoseEvaluate()方法使用的一个例子,用于处理尾随冒号,请参见EnhancedStringEvaluateTest类中的TestCounter2()方法。我在EvaluateSampleTest程序集中提供了另一个此类重写PreEvaluate(..)方法的示例,请参阅类EnhancedStringEvalNoyyyMMdd在TestSpecialHandlingOfDate()方法中的实现和使用。
BalancePreEvaluate(..)方法旨在保护变换免受不平衡的开/闭界符的影响。BalancePreEvaluate(..)方法的默认实现是抛出异常EnhancedStringException,如果开/闭界符不平衡。BalancePreEvaluate(..)方法不是虚方法。
EvaluateString(..)方法的核心是EvaluateStringPure(..)方法调用,其外观如下:
private string EvaluateStringPure(string text)
{ // 2
if (OnEvaluateContext == null) return text; // 3
var textElem = new EnhancedStrPairElement(TEMPKEY, _delim.PreMatch(text));
var ea = new EnhancedStringEventArgs(textElem); // 5
bool bHandled = false; // 6
Delegate[] context = OnEvaluateContext.GetInvocationList(); // 7
for (int i = 0; i < PassThroughUpperLimit; ++i) // 8
{ // 9
bHandled = false; //10
if (context != null) //11
{ //12
foreach (Delegate processXxx in context) //13
{ //14
try { processXxx.DyncdamicInvoke(new object[] { this, ea }); }
catch { /* Error logging is appropriate here */ } //16
if (ea.IsHandled) //17
{ //18
bHandled = true; //19
string val = _delim.PreMatch(ea.EhancedPairElem.Value); //20
string preText = PreEvaluate(val); //21
string balanceText = BalancePreEvaluate(preText); //22
textElem = new EnhancedStrPairElement(TEMPKEY, balanceText);
ea = new EnhancedStringEventArgs(textElem); //24
} //25
} //26
} //27
if (!bHandled) break; //28
} //29
return _delim.PostMatch(ea.EhancedPairElem.Value); //30
} //31
第15行调用其中一个ProcessXxx的Evaluate()方法。OnEvaluateContext(第7行)包含所有ProcessXxx的Evaluate()方法(在构造函数中传递),并将它们全部分配给变量context,然后在第13行的循环中遍历。
第4-5行设置ea一个EnhancedStringEventArgs变量,该变量将传递给ProcessXxx的Evaluate方法(第15行)。如果Evaluate()委托在第15行求值时更改了原始值,则同样的ea变量将在第24行再次更新。
考虑到变换后的{Identifier::Value}构造可能包含新的{Identifier::Value}构造,该方法通过一个双重for循环进行。外层for循环(第8行)确保我们完成了所有可能的{Identifier::Value}构造的处理。而内层for循环(第13行)遍历ProcessXxx的Evaluate()委托,尝试解析字符串文本中的单个{Identifier::Value}构造,该文本作为参数传递。
EvaluateStringPure例程在以下任一情况停止:内部循环在处理中未处理任何内容,或者外层循环的迭代次数达到最大上限。达到外层循环的最大上限很可能是一个无限循环或无限递归。
每次变换前进行PreEvaluate,每次变换后进行PostEvaluate
有时你需要重复调用一个或两个Pre/PostEvaluate方法。这些是我们调用EvaluateStringPure(..)方法之前的步骤(见第18-25行)。为了举例说明在if (ea.IsHandled)体(第18-25行)中重复调用Pre/PostEvaluate(..)方法的必要性,请考虑EvaluateSampleTest程序集中的EnhancedStringEvaluateTest类中的TestSpcialHandlingOfDate()方法中的场景。该类采用一组嵌套的由(“#{”,“}#”)分隔的构造,并将分隔符转换为(“${”,“}$”)。由于构造是嵌套的,因此需要在每次变换后运行Pre/PostEvaluate方法。PreEvaluate和PostEvaluate只评估一次
有时你需要只运行一次Pre/PostEvaluate方法,而不是每次变换都运行。例如,假设你需要运行一个如下所示的构造:${c}用于日历日期,允许加减天数;那么昨天如下:${c-1}。我们需要将${c}构造转换为{CurrentTime::MM/dd/yyyy},并将${c-1}转换为{CurrentTime::MM/dd/yyyy::-1d},然后再将文本传递给EvaluateString(..)方法。请参阅EnhancedStringEvaluateTest类中的测试方法PrePostEvaluate()部分。
为了实现这样的壮举,我们从EvaluateStringEval类派生一个类,如下所示:
sealed internal class PrePostEvaluteOnce : EnhancedStringEval
{
public PrePostEvaluateOnce(IEnumerable<IProcessEvaluate> context)
: base(context)
{
_reSpeicial = new Regex(
@"\$\{c((?<direction>[-+])(?<amount>\d+))?\}",
RegexOptions.Singleline | RegexOptions.IgnoreCase);
}
public override string EvaluateString(string text)
{
string preText = MyPreEvaluate(text);
return base.EvaluateString(preText);
}
private string MyPreEvaluate(string text)
{
Match m = _reSpeicial.Match(text);
if (!m.Success) return text;
return _reSpeicial.Replace(text, me => CalendarReplace(me));
}
//...
}
我们重写EvaluateString()方法,在调用EvaluateStringEval的EvaluateString():base.EvaluateString(preText)方法之前执行变换。然后像这样使用它,从客户端: var context = new List<IProcessEvaluate>();
context.Add(new ProcessCurrentTime());
EnhancedStringEval proxy = new PrePostEvaluateOnce(context);
string evaluatedString = proxy.EvaluateString(
"Today, ${c}, every man should exceed his grasp");
我们实例化派生的PrePostEvaluateOnce类,并将其分配给基实例EnhancedStringEval,然后“照常”使用它。
EvaluateStrings—复数名称
此例程用于预处理值集合,是一种优化例程。例如,请参阅处理{Key::Value}之类的构造的ProcessKey类。ProcessKey类处理(键,值)元素集合。一个很好的例子是处理配置文件中的值,如app.config。在配置文件中,我们有一系列Key=Value行。所以如果我们有如下条目: <add key="Flat" value="testing" />
<add key="Static flat" value="Static evaluation of flat: {key::flat}" />
那么{key::flat}构造的目的是产生:“testing”。因此,{key::Static flat}的目的是产生:“Static evaluation of flat: testing”。EvaluateStrings用于一次性解析{key::flat}之类的构造,而不是每次调用{key::Static flat}时都解析这些键。
ProcessKey,一个ProcessXxx类,其构造函数如下:
public ProcessKey(
IDictionary<string, string> pairs, IDelimitersAndSeparator delim)
{
_delim = delim;
_pairEntries = new Dictionary<string, EnhancedStrPairElement>();
EnahancedPairs = pairs;
RegexOptions reo = RegexOptions.Singleline | RegexOptions.IgnoreCase;
string pattern = string.Format(
@"({0})\s*Key\s*::(?<Name>([^{0}{1}])*?)({1})",
delim.OpenDelimEquivalent, delim.CloseDelimEquivalent);
_reKey = new Regex(pattern, reo);
ResolveKeys();
}
其中_delim、_pairEntries和_reKey是类实例变量。构造函数中的最后一个方法调用ResolveKeys()如下: private void ResolveKeys()
{
var eval = new EnhancedStringEval(
new List<IProcessEvaluate> { this }, _delim);
eval.EvaluateStrings(_pairEntries);
}
其中最后一个方法调用是:eval.EvaluateStrings(_pairEntries)——复数。EvaluateStrings
Public virtual void EvaluateStrings(
IDictionary<string, EnhancedStrPairElement> enhStrPairs)
{
PreEvaluate(enhStrPairs);
BalancePreEvaluate(enhStrPairs);
EvaluateStringsPure(enhStrPairs);
PostEvaluate(enhStrPairs);
}
注意EvaluateString()方法和EvaluateStrings()方法之间的并行性。这种并行性是不可避免的,两个例程执行非常相似的任务;一个操作单个字符串,另一个操作字典集合。EvaluateStrings()方法的核心是EvaluateStringsPure() private void EvaluateStringsPure(
IDictionary<string, EnhancedStrPairElement> enhStrPairs)
{
if (OnEvaluateContext == null) return;
var links = new LinkedList<EnhancedStrPairElement>();
var pairNodes = from elem in enhStrPairs
where _delim.IsSimpleExpression(elem.Value.Value)
select elem.Value;
foreach (EnhancedStrPairElement pairNode in pairNodes)
links.AddLast(pairNode);
if (links.Count == 0) return;
for (int i = 0; i < PassThroughUpperLimit; ++i)
{
LinkedListNode<EnhancedStrPairElement> linkNode = links.First;
while (linkNode != null)
{
bool bEval = EvalSimpleExpression(linkNode.Value);
if (!bEval)
{
LinkedListNode<EnhancedStrPairElement> p = linkNode.Next;
links.Remove(linkNode);
linkNode = p;
}
else
{
PreEvaluate(enhStrPairs);
BalancePreEvaluate(enhStrPairs);
linkNode = linkNode.Next;
}
}
if (links.Count == 0) break;
}
}
该例程首先循环遍历包含简单表达式的构造,并将这些简单表达式放入一个链表中: var links = new LinkedList<EnhancedStrPairElement>();
var pairNodes = from elem in enhStrPairs
where _delim.IsSimpleExpression(elem.Value.Value) select elem.Value;
foreach (EnhancedStrPairElement pairNode in pairNodes)
links.AddLast(pairNode);
该例程依赖于IDelimitersAndSeparator的IsSimpleExpression()处理,以确定表达式是否包含简单表达式。
然后,当一个链接被转换后,该链接将有一个新值。最终,当我们通过EvalSimpleExpression(linkNode.Value)调用传递该值,并且它不会改变原始值时,该链接将被移除。
EvaluateStringPure(单数名称)在内部循环没有求值任何内容时停止,而EvaluateStringsPure(复数名称)则在链表中跟踪简单表达式,当一个链接无法转换时,该链接将被移除,因此整个例程在没有要继续处理的链接时停止。
其他重要的类/接口
EnhancedStringEventArgs
回到IProcessEvaluate接口,我们看到Evaluate()方法的第二个参数是EnhancedStringEventArgs类型。该类型将EnhancedStrPairElement类型包装到派生自EventArgs的对象中。EnhancedStrPairElement类型是为了以不区分大小写的方式匹配标识符,如“Counter”。这使得{Counter::value}、{counter::value}和{COUNTER::value}成为等效的构造。编写自己的代码
检查随文章提供的许多示例,您会发现这些示例在求值前遵循以下步骤:- 实例化并填充上下文(一个List<IProcessEvaluate>构造)。
- 使用先前构造的上下文实例化一个EnhancedStringEval类。
- 最后,使用EnhancedStringEval的实例来求值字符串。
public sealed class TransformConfiguration
{
private readonly EnhancedStringEval _eval;
public static readonly Inst = new TransformConfiguration();
private TransformConfiguration()
{
var context = new List<IProcessEvaluate>();
context.Add(new ProcessDate());
context.Add(new ProcessKey(config));
context.Add(..);
context...
_eval = new EnhancedStringEval(context);
}
public string EvaluateString(string text)
{
return _eval.EvaluateString(text);
}
}
此后,在评估使用上述“配置”上下文的字符串时,您将像这样评估一个字符串:TransformConfiguration.Inst.EvaluateString(..),无需实例化EnhancedStringEval类或重新构建context。接下来往哪里走
该过程可以通过几种方式得到增强:- 一些ProcessXxx类,如ProcessIf,可以受益于语法处理。一个有前途的语法的例子可以在https://codeproject.org.cn/KB/recipes/grammar_support_1.aspx中找到“C# 3.0 的解析表达式语法支持:第一部分 - PEG Lib 和解析器生成器”,作者:Martin Holzherr。
- 目前的规范不允许在值中使用分隔符。例如:{identifier::value containing an open or close brace}将无法通过花括号匹配检查。这不仅仅是理论上的兴趣。例如,如果我们想编写一个{Decrypt::encrypted value}解密构造——如果我们不能保证加密值既不包含开括号也不包含闭括号,那么我们就无法编写这样的ProcessDecrypt类。这个问题不一定局限于单字符分隔符的情况,因为多字符分隔符被转换为单字符分隔符。
尽情享用!
Avi
类图是使用“Visual Diagram for UML Community Edition”制作的。