在上一篇的文章中,我们介绍了正则表达式的基本语法规则以及含义。那么在编写脚本时我们要如何使用它们呢?在不同的语言中,基本都提供了相对应的类库帮助我们实现,本文主要介绍正则在C#中的使用方法。
C#为我们提供了 System.Text.RegularExpressions.Regex 类来实现正则的使用,官方API文档如下:
中文版: https://docs.microsoft.com/zh-cn/dotnet/api/system.text.regularexpressions.regex?view=net-5.0
英文版: https://docs.microsoft.com/en-us/dotnet/api/system.text.regularexpressions.regex?view=net-5.0
若说到字符串的搜索功能,在我们平常的开发中,我们常常会使用System.String类的搜索或比较方法,例如 String.Contains,String.StartsWith,String.IndexOf 等等。然而这些方法都只能通过一个特点的子字符串来查找,即无法使用正则表达式,而我们的Regex类则是使用正则来进行搜索比较的。
例如我们想要知道一个字符串中,是否包含
/
.*/
使用Regex在C#中实现的话,有如下两种方法:
//被查找的字符串
string origin = "abciphone8 iphoneX iphone12123";
//实例化Regex,参数为我们的正则表达式
Regex regex = new Regex(".*");
//生成Match对象
Match match = regex.Match(origin);
if (match.Success)
{
//查找成功,打印结果
Debug.Log("Value:"+match.Value);//Log为:iphone8 iphoneX iphone12
Debug.Log("Index:"+match.Index);//Log为:3
}
string origin = "abciphone8 iphoneX iphone12123";
string pattern = ".*";
//静态方法Regex.Match,参数分别是被查找的字符串和正则表达式
Match match = Regex.Match(origin, pattern);
if (match.Success)
{
//查找成功,打印结果
Debug.Log("Value:"+match.Value);
Debug.Log("Index:"+match.Index);
}
需要注意的是,相比之前的正则,在C#中定义时,我们要忽略正则前后的 / 符号。
例如上面的例子展示的那样,我们可以利用实例化Regex或者使用Regex的静态方法来使用正则表达式:
实例化Regex:我们可以实例化一个Regex对象,其构造函数的参数为正则表达式。需要注意的是,Regex对象是不可变的,即实例化后,就无法改变该对象的正则表达式了。
静态方法:也可通过Regex的静态方法来实现,不需要实例化Regex对象。
我们来看看静态方法内部的实现,可以看出内部依旧是实例化了一个Regex对象。
public static Match Match(string input, string pattern)
{
return Regex.Match(input, pattern, RegexOptions.None, Regex.DefaultMatchTimeout);
}
public static Match Match(string input, string pattern, RegexOptions options, TimeSpan matchTimeout)
{
return new Regex(pattern, options, matchTimeout, true).Match(input);
}
那么两者之间具体有什么区别呢?让我们再来看看实例化Regex的内部实现
public Regex(string pattern)
: this(pattern, RegexOptions.None, Regex.DefaultMatchTimeout, false)
{
}
private Regex(string pattern, RegexOptions options, TimeSpan matchTimeout, bool useCache)
{
...
}
可以看出,他们区别在于,调用私有构造函数时,最后一个布尔值参数useCache不同,静态方法为true,直接实例化为false,它代表的即是我们的正则缓存。(有关构造函数中的RegexOptions和TimeSpan后续介绍)
我们来看下面这个例子
string origin = "abciphone8 iphoneX iphone12123";
float time = Time.realtimeSinceStartup;
for (int i = 0; i < 1000; i++)
{
Match match = new Regex(".*").Match(origin);
}
Debug.Log("use time:" + (Time.realtimeSinceStartup - time));// 0.03s
time = Time.realtimeSinceStartup;
Regex regex = new Regex(".*");
for (int i = 0; i < 1000; i++)
{
Match match = regex.Match(origin);
}
Debug.Log("use time:" + (Time.realtimeSinceStartup - time));// 0.0025s
time = Time.realtimeSinceStartup;
for (int i = 0; i < 1000; i++)
{
Match match = Regex.Match(origin, ".*");
}
Debug.Log("use time:" + (Time.realtimeSinceStartup - time));// 0.0045s
从例子中可以看出,即使当正则表达式相同的情况下,实例化多个Regex对象依旧是很耗性能的。这是因为Regex必须要先将指定的正则表达式进行编译后才可以使用,这个过程是一次性的,发生在我们上面提到的私有构造函数中。因此当有相同正则的Regex对象要被多次使用时,我们应该缓存这个Regex对象再使用,而不是重复的实例化。
然后我们看第三个循环,前面我们知道使用静态方法的本质依旧是实例化一个Regex对象,那么为什么相比直接实例化少了近十倍的时间呢?这里就是我们前面提到的userCache的作用了。因为使用静态方法无法像实例化那样缓存Regex对象,因此Regex会对静态方法中编译过的正则进行缓存,使其在重复调用时可以拥有更好的性能。
即我们的最大缓存数量,默认情况下,会缓存15个最近使用的正则表达式。如果超过这个数量,那么有些较早用到的正则,再次使用时就需要重新编译。我们可以通过修改Regex.CacheSize的值,来修改这个容量。
接下来,我们来了解下上面例子中出现的Match类,它表示单个正则表达式匹配的结果,它有如下几个属性:
属性 | 类别 | 介绍 |
Success | bool | 表示是否匹配成功 |
Index | int | 匹配到的子字符串的第一个字符在原始字符串中的下标 |
Length | int | 匹配到的子字符串的长度 |
Value | string | 匹配到的子字符串的值 |
Groups | GroupCollection | 捕获组的集合 |
其他几个属性理解起来都比较简单,就不过多的介绍了,我们重点看一看Groups属性。
Groups即为Group的集合(GroupCollection),而Group代表的则是我们正则中提到的捕获组的值。当我们在正则中使用子表达式时,且没有 ?: 来使其变为非捕获状态,子表达式的匹配项会被保存起来,这样的子表达式我们称之为捕获组。我们看如下一个例子:
string origin = "abciphone8 iphoneX iphone12 iphone123";
string pattern = "iphone([0-9])";
Regex regex = new Regex(pattern);
Match match = regex.Match(origin);
if (match.Success)
{
Debug.Log("Value:"+match.Value);//Log iphone8
GroupCollection groups = match.Groups;
for (int i = 0; i < groups.Count; i++)
{
Debug.Log($"group:{i} Value:{groups[i].Value}");
}
//Log1 group:0 Value:iphone8
//Log2 group:1 Value:8
}
在例子中,我们会匹配到的子字符串为 iphone8 ,因为用到了捕获组 ([0-9]) ,因此它所匹配到的 8 会被写入到Groups中。同时Group和Match类似,拥有 Success,Index,Length,Value等值供我们读取。(注:准确的说应该是Match和Group类似,因为Match其实是Group的子类)同时还有 Name 属性用于读取捕获组的名称,至于如何命名捕获组,我们会在后续介绍到( (?
从打印的结果来看,我们会发现为什么除了捕获组的捕获到的值之外,Groups中还有一个值(即下标为 0 的那个)?这是因为机制的原因。
Match对象中的Groups是始终至少拥有一个成员,且下标从零开始的集合对象。当我们的正则没有匹配到匹配项时,那么Groups[0]的Success属性为false,Value属性为String.Empty。而当我们的正则匹配到匹配项时,Groups[0] 的值即为正则表达式匹配项的值(这也就是为什么例子中Groups[0]的Value为iphone8)。接着当我们的正则中包含捕获组时,Groups中每个后续的元素(即从下标1开始)代表我们的每个捕获组捕获到的值(因此例子中Groups[1]的Value为8)。
除此之外,我们还会发现Group中还有个属性为Captures,再来详细介绍介绍他的作用。
与Group类似,Captures即为Capture的集合(CaptureCollection),只拥有Index,Length,Value三个属性。那么它的作用又是什么呢?我们先从下面例子来看
...
string pattern = "iphone([0-9]){2}";
...
GroupCollection groups = match.Groups;
for (int i = 1; i < groups.Count; i++)
{
Debug.Log($"group:{i} Value:{groups[i].Value}");//Log 2
}
...
在原本的例子上稍作修改,将我们正则中的捕获组和限定符相搭配。此时我们的正则的匹配项为iphone12,但是我们Group打印出来的的值却只有2。这是因为当我们将限定符应用于捕获组时,只会将最后一个捕获的值写入Group中。如例子中的 ([0-9]){2} ,匹配项对应着字符串12,但是子表达式 ([0-9]) 第一次会捕获到1,第二次为2,因此只将2的值写入Group。那么我们如果想要获取捕获组其他捕获到的值该怎么办?Captures即为我们保存了这些值。
for (int i = 1; i < groups.Count; i++)
{
Debug.Log($"group:{i} Value:{groups[i].Value}");//Log 2
for (int j = 0; j < groups[i].Captures.Count; j++)
{
Debug.Log($"capture:{j} Value:{groups[i].Captures[j].Value}");
}
//Log1 capture:0 Value:1
//Log2 capture:1 Value:2
}
如果我们的捕获组没有结合限定符,那么Captures中只会包含一个Capture对象,其Value值和对应的Group的Value值相同。
简而言之,Captures就是按从里到外、从左到右的顺序获取由捕获组匹配的所有捕获的集合(如果正则表达式用了 RightToLeft 选项,则顺序为按从里到外、从右到左),它可以有零个或更多的项。
补充:三者的关系为Capture->Group->Match,即Match为Group的子类,而Group又是Capture的子类。因此Match中也带有Captures属性,其值和Group[0].Captures相同。
前面我们使用正则只能获取到第一个匹配项,若想获取到下一个匹配项,我们可以使用NextMatch()方法。
string pattern = "iphone[0-9]+";
...
Debug.Log("Value:"+match.Value);//Log iphone8
Match nextMatch = match.NextMatch();
if (nextMatch.Success)
{
Debug.Log("Value:"+nextMatch.Value);//Log iphone12
}
...
比较简单,就不过多介绍了。我们也可以写个循环,直到nextMatch.Success为false时退出循环,这样就可以获取到所有的匹配项了。
刚开始在正则中使用 \ 时,由于不是很懂,感觉快把自己绕进去了。举个简单例子:
首先假设我们有个字符串是 abc\w\\ 这样的,由于我们定义字符串时, \ 也是需要通过 \\ 转义的,因此,字符串的定义如下
string origin = "abc\\w123\\\\";
在正则中的元字符 \w,我们在c#中定义时,同样需要先对 \ 进行转义,如下:
new Regex("\\w");
假如我们想匹配字符串中的 \w,那么正则应该如何定义呢?首先我们要通过正则中的转义符 \ 来对 \ 进行转义,使其变为字符的本意,即 \\w,然后放到c#的string中时,我们又需要将正则中的每个 \ 再次通过 \ 来转义,使其代表的是一个 \ ,因此最终结果为
new Regex("\\\\w");
由此可以推出:string中的 2个\ 代表着正则中的 1个\,而正则中的1个\ 代表的是正则中的转义符,若想要代表 \ 本身,则正则中需要2个 \ 即string中的4个 \ 。
因此如果我们想要匹配字符串中的 \\ 那么,在正则中就需要8个 \
new Regex("\\\\\\\\");
接着我们来看看Regex的具体使用方法(了解了上面Match的具体含义后,这块内容看起来也会非常的好理解了),假设我们有如下字符串
string origin = "abciphone8 iphoneX iphone12123";
如果我们仅仅只需要判断字符串中是否含有我们想要的内容(匹配项),而不关系匹配项的具体位置以及值等,我们就可以使用 IsMatch 方法来实现。
例如我们想要验证字符串中是否包含
string pattern = ".*";
//实例化方法
Regex regex = new Regex(pattern);
bool isMatch = regex.IsMatch(origin);
//静态方法
bool isMatch = Regex.IsMatch(origin, pattern);
调用Match()方法可以获得一个Match对象。
例如我们想找出字符串中包含的 iphone 型号:
string pattern = "iphone([0-9]{1,2}|[X])";
Regex regex = new Regex(pattern);
Match match = regex.Match(origin);
//静态方法
//Match match = Regex.Match(origin, pattern);
if (match.Success)
Debug.Log("Value:"+match.Value);
这样会打印出我们输入串中的第一个iphone型号 iphone8,我们可以通过调用match.NextMatch()方法来获取后续的匹配项。
利用Matches()方法,我们可以获得一个Match的集合(MatchCollection),即所有的匹配项,例如:
MatchCollection matches = regex.Matches(origin);
//静态方法
// MatchCollection matches = Regex.Matches(origin, pattern);
foreach (Match match in matches)
{
Debug.Log("Value:"+match.Value);
}
例如我们想把字符串中所有的iphone都替换为orange,那么可以用如下方法
string pattern = "iphone";
Regex regex = new Regex(pattern);
string result = regex.Replace(origin, "orange");
//静态方法
//string result = Regex.Replace(origin, pattern, "orange");
除此之外,我们还可以将 MatchEvaluator 作为参数使用,它是一个委托,格式如下:
public delegate string MatchEvaluator(Match match);
在每次替换的时候都会调用该委托,并将Match作为参数传递进来,我们可以根据匹配到的值,自定义替换的文本,然后将替换的文本作为返回值传出,实现自定义的替换文本。例如:
MatchEvaluator matchEvaluator = new MatchEvaluator(ReplaceCallback);
string result = Regex.Replace(origin, pattern, matchEvaluator);
string ReplaceCallback(Match match)
{
//if (match.Value == "xxx") return "xxx";
return "orange";
}
使用Split方法会获得一个字符串数组,该数组由被匹配的字符串的各部分组成,即字符串剔除掉匹配项后的结果,例如
string pattern = ".*";
Regex regex = new Regex(pattern);
string[] resultArray = regex.Split(origin);
//静态方法
//string[] resultArray = Regex.Split(origin, pattern);
foreach (var result in resultArray)
{
Debug.Log("result:" + result);
}
我们会得到 abc 和 123 两个子串。
我们知道正则中有很多的元字符,例如 [] ,\w 等等,而使用Regex.Escape()方法,可以快速的帮我们把这些元字符转义为其字符本身的含义。例如:
Regex.Escape("\\w");//Log \\w 即对应string的 "\\\\w"
Regex.Escape("[0-9]+");//Log \[0-9]\+
会被转义为原本字符含义的元字符有如下这些:\、*、+、?、|、{、[、(、)、^、$、.、# 和空白。每个空格都会被转义为 \空格 ,但是空格好像又不是元字符,暂时不是很理解这么做的意义。
与Escape相反,Unescape则会删除正则中的转义字符,例如
Regex.Unescape("\\\\w");//Log \w
Regex.Unescape("\\[a\\ ]+");//Log [a ]+
在前面的例子中,可以看出我们并没有使用到正则的修饰符,例如下面这个带有忽略大小写的修饰符的正则:
/iphone/i
由于在Regex中使用正则的时候,我们是不需要左右两边的 / 符号,因此也无法添加修饰符,修饰符g的全局查找功能我们可以使用NextMatch()或者Matches()来实现,那么当我们需要忽略大小写(修饰符i)或者开启多行模式(修饰符m)时,应该如何实现?
在Regex中,我们可以用内联选项或者RegexOptions来代替它们,我们先来讲讲内联选项。
文档参考:https://docs.microsoft.com/zh-cn/dotnet/standard/base-types/regular-expression-language-quick-reference#regular-expression-options
Regex支持以下几种内联选项:
i | 不区分大小写。 |
m | 使用多行模式。 ^ 和 $ 匹配行的开头和结尾。 |
n | 不捕获未命名的组。 |
s | 使用单行模式。^ 和 $ 匹配字符串的开头和结尾。 |
x | 忽略正则表达式模式中的非转义空白。 |
内联选项的使用方法一共有如下两种:
(?imnsx-imnsx):使用小括号然后问号后面跟我们的内联选项(Miscellaneous Constructs),我们也可用选项或选项组前的减号 (-) 关闭这些选项。例如, (?i-mn) 启用不区分大小写的匹配 (i),关闭多行模式 (m)和未命名的组捕获 (n)。
这样定义的内联选项开始作用于定义的开始处,且持续有效直到正则结束或者后续有新的内联选项的定义与其相反为止。例如下面例子:
string origin = "aab AAb aAb Aab";
string pattern = "\\b(?i)a(?-i)a\\w+\\b";
MatchCollection matches = Regex.Matches(origin, pattern);
foreach (Match match in matches)
{
Debug.Log("match:"+match.Value);
}
//Log1 aab
//Log2 Aab
我们一开始定义了启用忽略大小写,但是在后面又关闭了这个选项。因此在两个定义之间的正则匹配是忽略大小写的,而第二个定义之后又是需要检测大小写的。
(?imnsx-imnsx:子表达式):与前者类似,但是只应用于小括号内的子表达式,例如下面例子:
string origin = "aab AAb aAb Aab";
string pattern = "\\b(?i:a)a\\w+\\b";
MatchCollection matches = Regex.Matches(origin, pattern);
foreach (Match match in matches)
{
Debug.Log("match:"+match.Value);
}
//Log1 aab
//Log2 Aab
只有子表达式a不区分大小写,而子表达式之外的正则依旧区分大小写。
除了前面提到的内联选项可以替代修饰符以外,Regex提供的RegexOptions也可以做到相同的事情。我们可以在实例化Regex时,或者调用静态方法时,将其作为参数传递进去,例如:
Regex regex = new Regex(".*", RegexOptions.IgnoreCase | RegexOptions.Multiline);
Regex.Match("inputString", ".*", RegexOptions.IgnoreCase);
参考链接:https://docs.microsoft.com/zh-cn/dotnet/standard/base-types/regular-expression-options
默认情况下,Regex使用正则时有如下特征:
我们可以通过RegexOptions来改变这些规则,它一共有如下几种枚举:
枚举 | 含义 | 对应的内联选项 |
None | 不设置任何选项 | |
IgnoreCase | 不区分大小写 | i |
Multiline | 使用多行模式 | m |
ExplicitCapture | 不捕获未命名的组,这允许未命名的括号充当非捕获组,而不需要使用 (?:) | n |
Compiled | 指定将正则表达式编译为程序集。 这会产生更快的执行速度,但会增加启动时间 | |
Singleline | 使用单行模式,其中的句号 . 匹配每个字符(而不是除了 \n 以外的每个字符) | s |
IgnorePatternWhitespace | 从模式中排除保留的空白并启用数字符号 (# ) 后的注释 |
x |
RightToLeft | 更改匹配方向为从右向左进行 | |
ECMAScript | 为表达式启用符合 ECMAScript 的行为, 该值只能与IgnoreCase、Multiline和Compiled值一起使用。 该值与其他任何值一起使用均将导致异常。 | |
CultureInvariant | 忽略语言的区域性差异 |
除了我们在正则中提到的元字符外,Regex中还支持更多的一些元字符,例如我们前面提到的用于内联选项的 (?imnsx) 。
name代表的是Unicode的Category或者Block的名称,它们代表着一个字符集合。例如Unicode Category中的Lu代表着所有的大写字母,Unicode Block中的Basic Latin代表着基本的拉丁字母(https://www.fontspace.com/unicode/block)。而\p表示与集合内的字母相匹配,而\P表示与不在集合内的字母相匹配,例如
\p{Lu} 匹配所有的大写字母
\P{IsBasicLatin} 匹配所有的非拉丁字母
需要注意的是,使用Block Name时,需要在Name前加上Is,例如IsBasicLatin,IsCyrillic。
\A:匹配必须出现在字符串的开头
\Z:匹配必须出现在字符串的末尾或出现在字符串末尾的 \n
之前
\z:匹配必须出现在字符串的末尾
\G:匹配必须出现在上一个匹配结束的地方
我们可以利用上述表达式对捕获组进行命名,后续可以通过命名来反向引用捕获组捕获到的值,例如:
(?
\w)\k
可以匹配连续的两个相同字符。
对于命名了的捕获组,我们可以使用下面方法直接从Match中获取
match.Groups[groupName]
(?(expression) yes | no ) :
expression,yes,no分别代表三个正则表达式,如果expression匹配成功则从expression匹配成功的定位点处匹配yes ,否则匹配no。需要注意的是,expression会被当作为预查找,即占用的宽度为0,等价于 (?=expression) ,如果我们的expression不是定位符(例如 (?(^)yes|no)),那么yes中必须以expression开头,否则判断无法成功。例如
string origin = "A10 B22 AA01";
MatchCollection matches = Regex.Matches(origin, "(?(A)A\\d+|\\d+)\\b");
foreach (Match match in matches)
{
Debug.Log("match:"+match.Value);
}
// Log1 A10
// Log2 22
// Log3 A01
若匹配到A,则匹配正则 A\d+\b 否则匹配正则 \d+\b
(?(name) yes | no ):
name指之前的一个已命名或已编号的捕获组,若捕获组拥有匹配项则会匹配yes,例如:
string origin = "A10 B22 33 AA01";
MatchCollection matches = Regex.Matches(origin, "\\b(?A)?(?(CharA)A\\d+|\\d+)\\b");
foreach (Match match in matches)
{
Debug.Log("match:"+match.Value);
}
// Log1 33
// Log2 AA01
这里需要注意的是,捕获组后面的限定符 ? 非常的重要,如果没有这个 ? 那么后续的表达式会永远都是走yes,因为你只有捕获到A才会执行后续的表达式,但是加了限定符?后,可以没有匹配到A执行后续表达式。同样 ?(name) 的占用的宽度为0。
Log2的结果AA01,第一个A即是我们捕获组 (?
此外,类似于我们的 if...else... 可以不需要else一样,我们的替换构造也可以不需要no表达式,例如 (?(A)A\\d+)
在平时的运用中,很多字符都是成对出现的,例如 ()、{}、[] 等,亦或是我们在使用HTML语言时,很多的标签也是需要成对使用,例如
、。假如此时我们有一大串使用到了这些成对字符的字符串,想要知道里面这些字符是否一一对应时,平衡组就可以为我们来解决这样的问题。我们先来看一个例子,如下:
string pattern = "^(((?'Open'<)[^<>]*)+((?'Close-Open'>)[^<>]*)+)*(?(Open)(?!))$";
Debug.Log(Regex.IsMatch("<123>", pattern)); //true
Debug.Log(Regex.IsMatch("<<123>", pattern)); //false
上面的例子就是一个简单的检测 <> 是否符合成对的例子。其中的正则看着十分的复杂,让我们来拆开来分析一下它:
(?'Open'<):这就是我们之前学到的捕获组命名,名为Open的捕获组捕获 < 。每匹配到一个 < 时,都会将其存入内存当中。
[^<>]*:即捕获除了<>两个字符以外的所有字符,零个或多个
((?'Open'<)[^<>]*)+:把以上两者集合起来放入到一个分组中,同时搭配 + ,即表示捕获以 < 开头,后续跟除了 <> 外零个或多个字符的这样的匹配项一次或多次。即可以匹配 (?'Close-Open'>):这就是我们的平衡组了,他的定义和捕获组命名很相似,相比之下就多了name以及 - 号。第一个name(例子中的Close)即当前捕获组的name(是可选项),第二个name(例子中的Open)则是之前已定义了的捕获组名称。后续的表达式即捕获 > 。接下来就是重点了,当我们捕获到 > 时,Regex会做如下操作,首先会检查我们Open组中是否存有值,若没有直接匹配失败。若有值,则会删除Open组中最新存入的一个值,然后将Open和Close之间的值存入到Close中(即<>中间的值,并不是将 > 存入Close中)。因此使用了平衡组后,Open组就像是一个堆栈,当检查到 < 时,将其放入堆栈中,当检查到Close中的 > 时,会把Open中最新的 < 从中移除。因此若当最后Open组为空时,则表示所有的Open,Close对应的匹配项都是成双成对的,也就是平衡。 ((?'Close-Open'>)[^<>]*)+:即表示捕获以 > 开头,后续跟除了 <> 外零个或多个字符的这样的匹配项一次或多次。 (((?'Open'<)[^<>]*)+((?'Close-Open'>)[^<>]*)+)*:简单来说就是匹配零个或多个带有一个或多个<>的匹配项。 (?(Open)(?!)):这个也是一个重点。在平衡组里我们说到,只有Open中的值为空时,才代表成功。如何判断Open组是否为空,我们可以用到前面提到的替换构造,当Open为空,没有对应的no表达式,即匹配成功。而当Open不为空是,yes表达式为(?!),该表达式为正向否定预查,且永远为false,因此匹配失败。 ^...$:整个表达式我们又放在开头和结尾的定位符中,因此会检测整个表达式是否都符合要求。若不加这个定位符,则会匹配出所有符合要求的匹配项。 由于之前的正则有很多的未命名捕获组,我们可以利用 ?: 使其变为非捕获组,这样有利于我们观察Close中捕获到的值,如下: 接来了介绍一些比较实用的使用场景 例如我们在写html的时候,很多div是拥有不同的class属性的,如果我们想要获取这些class属性,可以使用捕获组,例如: string origin = "
示例
利用捕获组获取数据
string origin = "";
string groupName = "classname";
MatchCollection matches = Regex.Matches(origin, "