.NET 中的正则表达式最佳做法

您所在的位置:网站首页 正则检测api .NET 中的正则表达式最佳做法

.NET 中的正则表达式最佳做法

2024-07-08 18:27| 来源: 网络整理| 查看: 265

.NET 中的正则表达式最佳做法 项目06/20/2024

.NET 中的正则表达式引擎是一种功能强大而齐全的工具,它基于模式匹配(而不是比较和匹配文本)来处理文本。 在大多数情况下,它可以快速、高效地执行模式匹配。 但在某些情况下,正则表达式引擎的速度似乎很慢。 在极端情况下,它甚至看似停止响应,因为它会用若干个小时甚至若干天处理相对小的输入。

本文概述开发人员为了确保其正则表达式实现最佳性能可以采纳的一些最佳做法。

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

考虑输入源

通常,正则表达式可接受两种类型的输入:受约束的输入或不受约束的输入。 受约束的输入是源自已知或可靠的源并遵循预定义格式的文本。 不受约束的输入是源自不可靠的源(如 Web 用户)并且可能不遵循预定义或预期格式的文本。

编写的正则表达式模式的目的通常是匹配有效输入。 也就是说,开发人员检查他们要匹配的文本,然后编写与其匹配的正则表达式模式。 然后,开发人员使用多个有效输入项进行测试,以确定此模式是否需要更正或进一步细化。 当模式可匹配所有假定的有效输入时,则将其声明为生产就绪并且可包括在发布的应用程序中。 此方法使得正则表达式模式适合匹配受约束的输入。 但它不适合匹配不受约束的输入。

若要匹配不受约束的输入,正则表达式必须高效处理以下三种文本:

与正则表达式模式匹配的文本。 与正则表达式模式不匹配的文本。 与正则表达式模式大致匹配的文本。

对于为了处理受约束的输入而编写的正则表达式,最后一种文本类型尤其存在问题。 如果该正则表达式还依赖大量回溯,则正则表达式引擎可能会花费大量时间(在有些情况下,需要许多个小时或许多天)来处理看似无害的文本。

警告

下面的示例使用容易过度回溯并可能拒绝有效电子邮件地址的正则表达式。 不应在电子邮件验证例程中使用。 如需可验证电子邮件地址的正则表达式,请参阅如何:确认字符串是有效的电子邮件格式。

例如,考虑一种常用但有问题的用于验证电子邮件地址别名的正则表达式。 编写正则表达式 ^[0-9A-Z]([-.\w]*[0-9A-Z])*$ 的目的是处理被视为有效的电子邮件地址。 有效的电子邮件地址包含一个字母数字字符,后跟零个或多个可为字母数字、句点或连字符的字符。 该正则表达式必须以字母数字字符结束。 但正如下面的示例所示,尽管此正则表达式可以轻松处理有效输入,但在处理接近有效的输入时性能效率低下:

using System; using System.Diagnostics; using System.Text.RegularExpressions; public class DesignExample { public static void Main() { Stopwatch sw; string[] addresses = { "[email protected]", "[email protected]" }; // The following regular expression should not actually be used to // validate an email address. string pattern = @"^[0-9A-Z]([-.\w]*[0-9A-Z])*$"; string input; foreach (var address in addresses) { string mailBox = address.Substring(0, address.IndexOf("@")); int index = 0; for (int ctr = mailBox.Length - 1; ctr >= 0; ctr--) { index++; input = mailBox.Substring(ctr, index); sw = Stopwatch.StartNew(); Match m = Regex.Match(input, pattern, RegexOptions.IgnoreCase); sw.Stop(); if (m.Success) Console.WriteLine("{0,2}. Matched '{1,25}' in {2}", index, m.Value, sw.Elapsed); else Console.WriteLine("{0,2}. Failed '{1,25}' in {2}", index, input, sw.Elapsed); } Console.WriteLine(); } } } // The example displays output similar to the following: // 1. Matched ' A' in 00:00:00.0007122 // 2. Matched ' AA' in 00:00:00.0000282 // 3. Matched ' AAA' in 00:00:00.0000042 // 4. Matched ' AAAA' in 00:00:00.0000038 // 5. Matched ' AAAAA' in 00:00:00.0000042 // 6. Matched ' AAAAAA' in 00:00:00.0000042 // 7. Matched ' AAAAAAA' in 00:00:00.0000042 // 8. Matched ' AAAAAAAA' in 00:00:00.0000087 // 9. Matched ' AAAAAAAAA' in 00:00:00.0000045 // 10. Matched ' AAAAAAAAAA' in 00:00:00.0000045 // 11. Matched ' AAAAAAAAAAA' in 00:00:00.0000045 // // 1. Failed ' !' in 00:00:00.0000447 // 2. Failed ' a!' in 00:00:00.0000071 // 3. Failed ' aa!' in 00:00:00.0000071 // 4. Failed ' aaa!' in 00:00:00.0000061 // 5. Failed ' aaaa!' in 00:00:00.0000081 // 6. Failed ' aaaaa!' in 00:00:00.0000126 // 7. Failed ' aaaaaa!' in 00:00:00.0000359 // 8. Failed ' aaaaaaa!' in 00:00:00.0000414 // 9. Failed ' aaaaaaaa!' in 00:00:00.0000758 // 10. Failed ' aaaaaaaaa!' in 00:00:00.0001462 // 11. Failed ' aaaaaaaaaa!' in 00:00:00.0002885 // 12. Failed ' Aaaaaaaaaaa!' in 00:00:00.0005780 // 13. Failed ' AAaaaaaaaaaa!' in 00:00:00.0011628 // 14. Failed ' AAAaaaaaaaaaa!' in 00:00:00.0022851 // 15. Failed ' AAAAaaaaaaaaaa!' in 00:00:00.0045864 // 16. Failed ' AAAAAaaaaaaaaaa!' in 00:00:00.0093168 // 17. Failed ' AAAAAAaaaaaaaaaa!' in 00:00:00.0185993 // 18. Failed ' AAAAAAAaaaaaaaaaa!' in 00:00:00.0366723 // 19. Failed ' AAAAAAAAaaaaaaaaaa!' in 00:00:00.1370108 // 20. Failed ' AAAAAAAAAaaaaaaaaaa!' in 00:00:00.1553966 // 21. Failed ' AAAAAAAAAAaaaaaaaaaa!' in 00:00:00.3223372 Imports System.Diagnostics Imports System.Text.RegularExpressions Module Example Public Sub Main() Dim sw As Stopwatch Dim addresses() As String = {"[email protected]", "[email protected]"} ' The following regular expression should not actually be used to ' validate an email address. Dim pattern As String = "^[0-9A-Z]([-.\w]*[0-9A-Z])*$" Dim input As String For Each address In addresses Dim mailBox As String = address.Substring(0, address.IndexOf("@")) Dim index As Integer = 0 For ctr As Integer = mailBox.Length - 1 To 0 Step -1 index += 1 input = mailBox.Substring(ctr, index) sw = Stopwatch.StartNew() Dim m As Match = Regex.Match(input, pattern, RegexOptions.IgnoreCase) sw.Stop() if m.Success Then Console.WriteLine("{0,2}. Matched '{1,25}' in {2}", index, m.Value, sw.Elapsed) Else Console.WriteLine("{0,2}. Failed '{1,25}' in {2}", index, input, sw.Elapsed) End If Next Console.WriteLine() Next End Sub End Module ' The example displays output similar to the following: ' 1. Matched ' A' in 00:00:00.0007122 ' 2. Matched ' AA' in 00:00:00.0000282 ' 3. Matched ' AAA' in 00:00:00.0000042 ' 4. Matched ' AAAA' in 00:00:00.0000038 ' 5. Matched ' AAAAA' in 00:00:00.0000042 ' 6. Matched ' AAAAAA' in 00:00:00.0000042 ' 7. Matched ' AAAAAAA' in 00:00:00.0000042 ' 8. Matched ' AAAAAAAA' in 00:00:00.0000087 ' 9. Matched ' AAAAAAAAA' in 00:00:00.0000045 ' 10. Matched ' AAAAAAAAAA' in 00:00:00.0000045 ' 11. Matched ' AAAAAAAAAAA' in 00:00:00.0000045 ' ' 1. Failed ' !' in 00:00:00.0000447 ' 2. Failed ' a!' in 00:00:00.0000071 ' 3. Failed ' aa!' in 00:00:00.0000071 ' 4. Failed ' aaa!' in 00:00:00.0000061 ' 5. Failed ' aaaa!' in 00:00:00.0000081 ' 6. Failed ' aaaaa!' in 00:00:00.0000126 ' 7. Failed ' aaaaaa!' in 00:00:00.0000359 ' 8. Failed ' aaaaaaa!' in 00:00:00.0000414 ' 9. Failed ' aaaaaaaa!' in 00:00:00.0000758 ' 10. Failed ' aaaaaaaaa!' in 00:00:00.0001462 ' 11. Failed ' aaaaaaaaaa!' in 00:00:00.0002885 ' 12. Failed ' Aaaaaaaaaaa!' in 00:00:00.0005780 ' 13. Failed ' AAaaaaaaaaaa!' in 00:00:00.0011628 ' 14. Failed ' AAAaaaaaaaaaa!' in 00:00:00.0022851 ' 15. Failed ' AAAAaaaaaaaaaa!' in 00:00:00.0045864 ' 16. Failed ' AAAAAaaaaaaaaaa!' in 00:00:00.0093168 ' 17. Failed ' AAAAAAaaaaaaaaaa!' in 00:00:00.0185993 ' 18. Failed ' AAAAAAAaaaaaaaaaa!' in 00:00:00.0366723 ' 19. Failed ' AAAAAAAAaaaaaaaaaa!' in 00:00:00.1370108 ' 20. Failed ' AAAAAAAAAaaaaaaaaaa!' in 00:00:00.1553966 ' 21. Failed ' AAAAAAAAAAaaaaaaaaaa!' in 00:00:00.3223372

如上例输出所示,正则表达式引擎处理有效电子邮件别名的时间间隔大致相同,与其长度无关。 另一方面,当接近有效的电子邮件地址包含五个以上字符时,字符串中每增加一个字符,处理时间会大约增加一倍。 因此,处理接近有效的 28 个字符构成的字符串将需要一个小时,处理接近有效的 33 个字符构成的字符串将需要接近一天的时间。

由于开发此正则表达式时只考虑了要匹配的输入的格式,因此未能考虑与模式不匹配的输入。 这种疏忽反过来会使与正则表达式模式近似匹配的不受约束输入的性能显著降低。

若要解决此问题,可执行下列操作:

开发模式时,应考虑回溯对正则表达式引擎的性能的影响程度,特别是当正则表达式设计用于处理不受约束的输入时。 有关详细信息,请参阅控制回溯部分。

使用无效输入、接近有效的输入以及有效输入对正则表达式进行完全测试。 可以使用 Rex 为特定的正则表达式随机生成输入。 Rex 是 Microsoft Research 开发的一款正则表达式探索工具。

适当处理对象实例化

.NET 正则表达式对象模型的核心是 System.Text.RegularExpressions.Regex 类,表示正则表达式引擎。 通常,影响正则表达式性能的单个最大因素是 Regex 引擎的使用方式。 定义正则表达式需要将正则表达式引擎与正则表达式模式紧密耦合。 无论该耦合过程是需要通过向其构造函数传递正则表达式模式来实例化 Regex 还是通过向其传递正则表达式模式和要分析的字符串来调用静态方法,都会消耗大量资源。

注意

若要详细讨论使用已解释和已编译正则表达式造成的性能影响,请参阅日志博客优化正则表达式性能(第 II 部分):控制回溯。

可将正则表达式引擎与特定正则表达式模式耦合,然后使用该引擎以若干种方式匹配文本:

可以调用静态模式匹配方法,如 Regex.Match(String, String)。 此方法不需要实例化正则表达式对象。

可以实例化一个 Regex 对象并调用已解释的正则表达式的实例模式匹配方法,这是将正则表达式引擎绑定到正则表达式模式的默认方法。 如果实例化 Regex 对象时未使用包括 options 标记的 Compiled 自变量,则会生成此方法。

你可以实例化一个 Regex 对象并调用源代码生成的正则表达式的实例模式匹配方法。 在大多数情况下,建议使用此方法。 为此,请将 GeneratedRegexAttribute 属性置于返回 Regex 的一个分部方法上。

可以实例化一个 Regex 对象并调用已编译的正则表达式的实例模式匹配方法。 当使用包括 Regex 标记的 options 参数实例化 Compiled 对象时,正则表达式对象表示已编译的模式。

这种调用正则表达式匹配方法的特殊方式会对应用程序性能产生影响。 以下各节讨论了何时使用静态方法调用、源代码生成的正则表达式、已解释的正则表达式和已编译的正则表达式,以改进应用程序的性能。

重要

如果方法调用中重复使用同一正则表达式或者应用程序大量使用正则表达式对象,则方法调用的形式(静态、已解释的、源代码生成的、已编译的)会影响性能。

静态正则表达式

建议将静态正则表达式方法用作使用同一正则表达式重复实例化正则表达式对象的替代方法。 与正则表达式对象使用的正则表达式模式不同,静态方法调用所使用的模式中的操作代码 (opcode) 或已编译的公共中间语言 (CIL) 由正则表达式引擎缓存在内部。

例如,事件处理程序会频繁调用其他方法来验证用户输入。 下面的代码中反映了这一示例,其中一个 Button 控件的 Click 事件用于调用名为 IsValidCurrency 的方法,该方法检查用户是否输入了后跟至少一个十进制数的货币符号。

public void OKButton_Click(object sender, EventArgs e) { if (! String.IsNullOrEmpty(sourceCurrency.Text)) if (RegexLib.IsValidCurrency(sourceCurrency.Text)) PerformConversion(); else status.Text = "The source currency value is invalid."; } Public Sub OKButton_Click(sender As Object, e As EventArgs) _ Handles OKButton.Click If Not String.IsNullOrEmpty(sourceCurrency.Text) Then If RegexLib.IsValidCurrency(sourceCurrency.Text) Then PerformConversion() Else status.Text = "The source currency value is invalid." End If End If End Sub

下面的示例显示 IsValidCurrency 方法的一个低效实现:

注意

每个方法调用使用相同模式重新实例化 Regex 对象。 这反过来意味着,每次调用该方法时,都必须重新编译正则表达式模式。

using System; using System.Text.RegularExpressions; public class RegexLib { public static bool IsValidCurrency(string currencyValue) { string pattern = @"\p{Sc}+\s*\d+"; Regex currencyRegex = new Regex(pattern); return currencyRegex.IsMatch(currencyValue); } } Imports System.Text.RegularExpressions Public Module RegexLib Public Function IsValidCurrency(currencyValue As String) As Boolean Dim pattern As String = "\p{Sc}+\s*\d+" Dim currencyRegex As New Regex(pattern) Return currencyRegex.IsMatch(currencyValue) End Function End Module

应将前面的低效代码替换为对静态 Regex.IsMatch(String, String) 方法的调用。 通过这种方法便不必在你每次要调用模式匹配方法时都实例化 Regex 对象,还允许正则表达式引擎从其缓存中检索正则表达式的已编译版本。

using System; using System.Text.RegularExpressions; public class RegexLib2 { public static bool IsValidCurrency(string currencyValue) { string pattern = @"\p{Sc}+\s*\d+"; return Regex.IsMatch(currencyValue, pattern); } } Imports System.Text.RegularExpressions Public Module RegexLib Public Function IsValidCurrency(currencyValue As String) As Boolean Dim pattern As String = "\p{Sc}+\s*\d+" Return Regex.IsMatch(currencyValue, pattern) End Function End Module

默认情况下,将缓存最后 15 个最近使用的静态正则表达式模式。 对于需要大量已缓存的静态正则表达式的应用程序,可通过设置 Regex.CacheSize 属性来调整缓存大小。

此示例中使用的正则表达式 \p{Sc}+\s*\d+ 可验证输入字符串是否包含一个货币符号和至少一个十进制数。 模式的定义如下表所示:

模式 说明 \p{Sc}+ 与 Unicode 符号、货币类别中的一个或多个字符匹配。 \s* 匹配零个或多个空白字符。 \d+ 匹配一个或多个十进制数字。 已解释的、源代码生成的与已编译的正则表达式

未通过指定 Compiled 选项绑定到正则表达式引擎的正则表达式模式是已解释的。 在实例化正则表达式对象时,正则表达式引擎会将正则表达式转换为一组操作代码。 调用实例方法时,操作代码会转换为 CIL 并由 JIT 编译器执行。 同样,当调用一种静态正则表达式方法并且在缓存中找不到该正则表达式时,正则表达式引擎会将该正则表达式转换为一组操作代码并将其存储在缓存中。 然后,它将这些操作代码转换为 CIL,以便于 JIT 编译器执行。 已解释的正则表达式会减少启动时间,但会使执行速度变慢。 由于这个过程,在少数方法调用中使用正则表达式时或调用正则表达式方法的确切数量未知但预期很小时,使用已解释的正则表达式的效果最佳。 随着方法调用数量的增加,执行速度变慢对性能的影响会超过减少启动时间带来的性能改进。

未通过指定 Compiled 选项绑定到正则表达式引擎的正则表达式模式是已编译的。 因此,当实例化正则表达式对象时或当调用一种静态正则表达式方法并且在缓存中找不到该正则表达式时,正则表达式引擎会将该正则表达式转换为一组中间操作代码。 这些代码之后会转换为 CIL。 调用方法时,JIT 编译器将执行该 CIL。 与已解释的正则表达式相比,已编译的正则表达式增加了启动时间,但执行各种模式匹配方法的速度更快。 因此,相对于调用的正则表达式方法的数量,因编译正则表达式而产生的性能产生了改进。

通过使用 GeneratedRegexAttribute 属性装饰 RegEx 返回方法绑定到正则表达式引擎的正则表达式模式是源代码生成的。 源代码生成器(插入到编译器中)以 C# 代码的形式发出自定义 Regex 派生的实现,其逻辑类似于 RegexOptions.Compiled 以 CIL 发出的内容。 可以获得 RegexOptions.Compiled 的所有吞吐量性能优势(实际上更多)和 Regex.CompileToAssembly 的启动优势,但没有 CompileToAssembly 的复杂性。 发出的源是项目的一部分,这意味着它也可以轻松查看和调试。

要进行汇总,我们建议你:

当你使用特定正则表达式调用正则表达式方法相对不频繁时,请使用已解释的正则表达式。 如果你在 C# 中将 Regex 与编译时已知的参数一起使用,并且相对频繁地使用特定的正则表达式,请使用源代码生成的正则表达式。 如果你使用特定正则表达式相对频繁地调用正则表达式方法,并且你使用 .NET 6 或更早的版本,请使用已编译的正则表达式。

很难确定处于哪个确切阈值时,已解释的正则表达式执行速度较慢的代价会超过其启动时间缩短带来的收益。 也很难确定处于哪个阈值时,源代码生成的或已编译的正则表达式启动速度较慢的代价会超过其执行速度较快带来的收益。 这些阈值取决于各种因素,包括正则表达式的复杂程度和它处理的特定数据。 若要确定哪些正则表达式可以为特定应用程序场景提供最佳性能,可以使用 Stopwatch 类来比较其执行时间。

下面的示例比较了已编译的、源代码生成的和已解释的正则表达式在读取 Theodore Dreiser 所著《金融家》中前 10 句文本和所有句文本时的性能。 如示例输出所示,当只对匹配方法的正则表达式进行 10 次调用时,已解释的或源代码生成的正则表达式与已编译的正则表达式相比,可提供更好的性能。 但是,当进行大量调用(在此示例中,超过 13,000 次调用)时,已编译的正则表达式可提供更好的性能。

const string pattern = @"\b(\w+((\r?\n)|,?\s))*\w+[.?:;!]"; [GeneratedRegex(pattern, RegexOptions.Singleline)] private static partial Regex GeneratedRegex(); public static void RunIt() { Stopwatch sw; Match match; int ctr; StreamReader inFile = new(@".\Dreiser_TheFinancier.txt"); string input = inFile.ReadToEnd(); inFile.Close(); // Read first ten sentences with interpreted regex. Console.WriteLine("10 Sentences with Interpreted Regex:"); sw = Stopwatch.StartNew(); Regex int10 = new(pattern, RegexOptions.Singleline); match = int10.Match(input); for (ctr = 0; ctr 零宽度负回顾。 回顾后发当前位置,以确定 subexpression 是否不与输入字符串匹配。 使用超时值

如果正则表达式处理与正则表达式模式大致匹配的输入,则通常依赖于会严重影响其性能的过度回溯。 除认真考虑对回溯的使用以及针对大致匹配输入对正则表达式进行测试之外,还应始终设置一个超时值以确保最大程度地降低过度回溯的影响(如果有)。

正则表达式超时间隔定义了在超时前正则表达式引擎用于查找单个匹配项的时间长度。根据正则表达式模式和输入文本,执行时间可能会超过指定的超时间隔,但回溯所需的时间不会超过指定的超时间隔。 默认超时间隔为 Regex.InfiniteMatchTimeout,这意味着正则表达式将不会超时。可以按如下所示重写此值并定义超时间隔:

在实例化 Regex 对象时调用 Regex(String, RegexOptions, TimeSpan) 构造函数以提供超时值。

调用静态模式匹配方法,如 Regex.Match(String, String, RegexOptions, TimeSpan) 或 Regex.Replace(String, String, String, RegexOptions, TimeSpan),其中包含 matchTimeout 参数。

使用代码设置以进程或应用域作为作用范围的值,例如 AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromMilliseconds(100));。

如果定义了超时间隔并且在此间隔结束时未找到匹配项,则正则表达式方法将引发 RegexMatchTimeoutException 异常。 在异常处理程序中,可以选择使用一个更长的超时间隔来重试匹配、放弃匹配尝试并假定没有匹配项,或者放弃匹配尝试并记录异常信息以供未来分析。

下面的示例定义了一种 GetWordData 方法,此方法实例化了一个正则表达式,使其具有 350 毫秒的超时间隔,用于计算文本文件中的词语数和一个词语中的平均字符数。 如果匹配操作超时,则超时间隔将延长 350 毫秒并重新实例化 Regex 对象。 如果新的超时间隔超过一秒,则此方法将再次向调用方引发异常。

using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; public class TimeoutExample { public static void Main() { RegexUtilities util = new RegexUtilities(); string title = "Doyle - The Hound of the Baskervilles.txt"; try { var info = util.GetWordData(title); Console.WriteLine("Words: {0:N0}", info.Item1); Console.WriteLine("Average Word Length: {0:N2} characters", info.Item2); } catch (IOException e) { Console.WriteLine("IOException reading file '{0}'", title); Console.WriteLine(e.Message); } catch (RegexMatchTimeoutException e) { Console.WriteLine("The operation timed out after {0:N0} milliseconds", e.MatchTimeout.TotalMilliseconds); } } } public class RegexUtilities { public Tuple GetWordData(string filename) { const int MAX_TIMEOUT = 1000; // Maximum timeout interval in milliseconds. const int INCREMENT = 350; // Milliseconds increment of timeout. List exclusions = new List(new string[] { "a", "an", "the" }); int[] wordLengths = new int[29]; // Allocate an array of more than ample size. string input = null; StreamReader sr = null; try { sr = new StreamReader(filename); input = sr.ReadToEnd(); } catch (FileNotFoundException e) { string msg = String.Format("Unable to find the file '{0}'", filename); throw new IOException(msg, e); } catch (IOException e) { throw new IOException(e.Message, e); } finally { if (sr != null) sr.Close(); } int timeoutInterval = INCREMENT; bool init = false; Regex rgx = null; Match m = null; int indexPos = 0; do { try { if (!init) { rgx = new Regex(@"\b\w+\b", RegexOptions.None, TimeSpan.FromMilliseconds(timeoutInterval)); m = rgx.Match(input, indexPos); init = true; } else { m = m.NextMatch(); } if (m.Success) { if (!exclusions.Contains(m.Value.ToLower())) wordLengths[m.Value.Length]++; indexPos += m.Length + 1; } } catch (RegexMatchTimeoutException e) { if (e.MatchTimeout.TotalMilliseconds < MAX_TIMEOUT) { timeoutInterval += INCREMENT; init = false; } else { // Rethrow the exception. throw; } } } while (m.Success); // If regex completed successfully, calculate number of words and average length. int nWords = 0; long totalLength = 0; for (int ctr = wordLengths.GetLowerBound(0); ctr


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3