C# INI 文件解析器





5.00/5 (24投票s)
一个基于正则表达式、无集合的 C# INI 文件解析器,在编辑条目时保留原始文件格式。
引言
解析 INI 文件是在处理配置时编程中相当常见的任务。INI 文件简单易懂,对人类和机器都友好。实现此功能有几种主要方法:
- 使用字符串操作函数手动解析。这种方法在处理各种 INI 文件格式时具有最大的灵活性,但实现起来需要更多的工作。
- 使用各种 API 的模块。它们提供现成的函数来读取、写入和处理 INI 格式的数据。这是一种更简单快捷的方法,但受限于库本身的功能,并且还会使项目变得平台依赖。
- 使用常见的配置文件处理库进行解析,例如 Python 中的 configparser 或 .NET 中的 ConfigurationManager。这种方法是通用的,但可能不如专用解决方案灵活。
- 使用正则表达式进行处理。
在本文中,我将介绍如何使用 C# 中的正则表达式来解析 INI 文件。这种方法提供了更大的灵活性和对处理逻辑的定制。
正则表达式需要对语法有更深入的了解,但通过它们,您无需集合即可修改现有条目并添加新条目。这种方法提供了高性能、灵活性和保留原始格式,使其成为使用单一工具处理各种格式的 INI 文件的有效解决方案。 但是,在深入文章之前,我想回答您可能有的一个问题。
在 21 世纪,为什么还需要解析 INI 文件?
有一种观点认为 INI 文件已经过时,不适合存储参数。我不会争论这个说法,但我会给出几个使用 INI 文件合理的例子,并且是最佳解决方案:
- 如果您的软件使用命令行,通过 INI 文件分组和批量传递大量参数会更方便。
- 如果您的软件使用接受 INI 参数的第三方实用程序。
- 如果您的软件参数很少,并且没有“设置”窗口或任何图形界面。
- 如果您需要能够以用户可理解的格式导出和导入参数到文件。
- 只是作为备份选项,或者向过去致敬,那时草地更绿,我们更年轻,Doom 从软盘安装,参数通过 INI 传递。
INI 文件格式
这种格式相当简单,并且对大多数开发人员来说已经很熟悉了。总的来说,它是由等号分隔的键值对列表,称为参数。为了方便起见,参数被分组到节中,节用方括号括起来。然而,尽管如此,仍然存在一些细微差别和细微差异,因为没有严格定义单一标准。如果我创建一个新的解析器,我的目标是使其通用,以便它尽可能高效地提取信息,因此在编写用于处理 INI 文件的通用解析器时,必须考虑到这些特性。
例如,可以使用不同的符号来表示注释,最常见的选项是井号(hash)或分号,以及键和值之间的各种分隔符。除了普通的等号,在这种情况下有时也会使用冒号。也有一些文件没有节,只有键值对。不同的系统可能使用不同的字符来终止行。对于“Key”和“key”是否应被视为不同或不区分大小写地视为相同,并没有明确定义。文件可能包含语法错误或任何未定义的数据,但这不应妨碍正确解析内容的有效部分。
在存储字符串数组方面也没有达成共识。一些标准允许多个同名键,另一些则允许使用转义字符来分隔参数值中的字符串。尽管大多数时候解析器会提取找到的第一个单个值。我们的解析器可以同样好地处理所有这些任务。
下面是使用流行的文本编辑器进行的语法高亮示例。如您所见,其格式不为节名或条目值后面的注释提供支持。为什么不呢?
正则表达式
经过大量研究,我提出了以下正则表达式,它允许我确定 INI 文件中每个字符的含义。整体如下:
(?=\S)(?<text>(?<comment>(?<open>[#;]+)(?:[^\S\r\n]*)(?<value>.+))|(?<section>(?<open>\[)(?:\s*)(?<value>[^\]]*\S+)(?:[^\S\r\n]*)(?<close>\]))|(?<entry>(?<key>[^=\r\n\ [\]]*\S)(?:[^\S\r\n]*)(?<delimiter>:|=)(?:[^\S\r\n]*)(?<value>[^#;\r\n]*))|(?<undefined>.+))(?<=\S)|(?<linebreaker>\r\n|\n)|(?<whitespace>[^\S\r\n]+)
在我们开始编写代码之前,我想分解一下解析正则表达式本身,并解释每个部分的作用。
-
(?=\S)
是一个正向先行断言,它检查下一个字符不是空白字符。这是为了跳过行开头的空白字符。 -
(?<text>....)
是一个命名组,用于捕获文件的文本块。这将使我们能够获得文件的全部内容以供进一步分析。 -
(?<comment>(?<open>[#;]+)(?:[^\S\r\n]*)(?<value>.+))
是一个命名组,用于捕获文件中的注释。它由以下部分组成:(?<open>[#;]+)
是一个捕获一个或多个“#”或“;”字符的组,表示注释的开始。(?:[^\S\r\n]*)
- 是一个捕获所有空白字符(不包括换行符)的字符组。这是处理行内缩进的方式。(?<value>.+)
- 一个捕获行尾所有字符的组,即整个注释文本。
-
(?<section>(?<open>\[)(?:\s*)(?<value>[^\]]*\S+)(?:[^\S\r\n]*)(?<close>\]))
- 是一个捕获节的命名组。它由以下部分组成:(?<open>\[)
- 一个捕获“ [ ”字符的组,表示节的开始。(?:\s*)
- 一个捕获零个或多个空白字符的组。(?<value>[^\]]*\S+)
- 一个捕获一个或多个非空白字符(不包括“]”字符)的组。(?:[^\S\r\n]*)
- 再次,一个捕获缩进的组。(?<close>\])
是一个捕获“]”字符的组,它标记节的结束。
-
(?<entry>(?<key>[^=\r\n\[\]]*\S)(?:[^\S\r\n]*)(?<delimiter>:|=)(?:[^\S\r\n]*)(?<value>[^#;\r\n]*))
是一个捕获条目(键值对)的命名组。它由以下部分组成:(?<key>[^=\r\n\[\]]*\S)
是一个捕获一个或多个非空白字符(不包括“=”、“换行符”和“[”字符)的组。(?:[^\S\r\n]*)
- 缩进,见上文。(?<delimiter>:|=)
是一个捕获分隔键和值的“:”或“=”字符的组。(?:[^\S\r\n]*)
- 缩进。(?<value>[^#;\r\n]*)
是一个捕获零个或多个字符(不包括“#”、“;”、换行符)的组。
-
(?<undefined>.+)
是一个命名组,用于捕获未匹配前面组的任何未定义文本部分。 -
(?<=\S)
是一个正向后行断言,它检查前面的字符不是空白字符。这是为了跳过行尾的空白字符。 -
(?<linebreaker>\r\n|\n)
是一个命名组,用于捕获换行符(“\r\n”或“\n”)。 -
(?<whitespace>[^\S\r\n]+)
是一个命名组,用于捕获行开头和结尾的缩进。
这是一个非常详细且精心设计的正则表达式,旨在准确解析 INI 文件的结构并从中提取所有必要组件(节、键、值、注释等)。它可以处理 INI 文件的各种格式变体,并提供一种健壮且灵活的解析方式。
看看这个正则表达式如何解析上面的配置文件。
您可以使用此 链接 尝试使用此正则表达式。
C# 编码
为了解决使用正则表达式解析 INI 文件的问题,我创建了 IniFile 类。该类将负责使用正则表达式读取和解析 INI 文件内容,以提取键、值和节。该类具有加载文件、获取节列表、按键获取值以及将更改写回文件的功能。通过使用正则表达式,IniFile 可以处理各种配置文件格式,包括带有注释、缩进、空格、语法错误和其他功能的。这将使解析器更加灵活和通用。要使用该类,您需要向其传递一个包含 INI 文件数据和解析设置的字符串或流。
看看如何使用 IniFile 类读取与上一个示例相同的配置文件。
var iniFile = IniFile.Load("config.ini");
// "Empty" section
string keyAboveSection = iniFile.ReadString(null, "key");
// Section1 contains integer values.
int num1 = iniFile.ReadInt32("Section1", "number1"); // Ignoring case available.
int num2 = iniFile.Read<int>("Section1", "Number2"); // The generic methods have been implemented.
// Section2 contains multiline content.
double pi = iniFile.ReadDouble("Section2", "NumberPI");
string singleString = iniFile["Section2", "SingleString"]; // The indexer is ready to use.
string multiString = iniFile.ReadString("Section2", "MultiString");
string[] arrayString = iniFile.ReadStrings("Section2", "ArrayString");
// Section3 contains more various types.
Encoding encoding = iniFile.Read<Encoding>("Section3", "encoding",
Encoding.UTF8, new CustomEncodingConverter);
CultureInfo culture = iniFile.Read<CultureInfo>("Section3", "culture",
CultureInfo.InvariantCulture);
Uri uri = iniFile.Read<Uri>("Section3", "url");
很神奇,对吧?让我们看看这是如何实现的,以及该类还提供哪些其他功能。
类的主要功能
- 支持各种加载和保存方法:该类提供了从字符串、流或文件加载 INI 文件的方法,以及将它们保存到流或文件的方法。
- 使用正则表达式:使用正则表达式可以灵活高效地处理各种 INI 文件格式,包括支持注释、节和键值对。与使用手动字符串处理相比,这使得代码更紧凑且易于扩展。
- 不依赖集合:该类不使用集合来存储 INI 文件数据,这使其内存效率更高,并且简化了处理大文件的操作。
- 保留原始格式:在修改现有条目或添加新条目时,该类会保留原始 INI 文件格式,包括注释、空格和换行符的位置。这有助于保持文件的可读性和结构。
- 支持转义字符:该类提供了处理键值中转义字符的功能。这允许正确处理制表符、换行符等特殊字符。
- 自动检测换行符:该类自动检测 INI 文件中的换行符类型(CRLF、LF 或 CR)并在保存更改时使用它们。如果没有找到新行,将使用当前操作系统的默认选择。
- 自动检测编码: 该类根据文件的前 4 个字节(也称为字节顺序标记 (BOM))自动检测文件中使用的编码。这个功能可能不是最强大的,但总比没有好。
- 灵活自定义字符串比较:该类允许您根据应用程序的要求自定义字符串比较规则(区分大小写、区域性)。
- 支持各种加载和保存方法:该类提供了从字符串、流或文件加载 INI 文件的方法,以及将它们保存到流或文件的方法。
- 用于处理 INI 文件的便捷 API:该类提供了简单直观的 API,用于向 INI 文件读写值,包括支持各种数据类型。
因此,使用 IniFile 类可以高效灵活地处理 INI 文件,保留其结构和格式,并为自定义和功能扩展提供了充足的机会。
类的结构
该类存储允许您更精确地自定义文件分析过程的字段。
- 存储 INI 文件内容。 该类有一个私有字段
_content
来存储 INI 文件内容。 - 用于解析的正则表达式。 该类使用存储在
_regex
字段中的正则表达式来解析 INI 文件。 - 支持转义字符。
_allowEscapeChars
标志决定了 INI 文件中是否允许转义字符。 - 定义换行符的类型。 不同的操作系统使用不同的方法来标记一行的结束。在我们可以处理文件之前,我们必须确定文件中使用了哪种方法。
_lineBreaker
字段包含用于表示 INI 文件中换行的字符串。 - 区域性信息。
_culture
字段包含有关用于解析的区域性的信息。 - 字符串比较规则。
_comparison
字段决定了如何在 INI 文件中执行字符串比较。
类方法
IniFile 类提供了方便的方法,用于将各种类型的值读写到 INI 文件中。
- 读取值
ReadString
、ReadStrings
- 用于读取字符串值,包括字符串数组。ReadObject
、Read<T>
- 使用TypeConverter
读取任意类型的值。ReadArray
- 用于读取任意类型的值数组。- 读取基本数据类型的方法:
ReadBoolean
、ReadChar
、ReadSByte
、ReadByte
、ReadInt16
、ReadUInt16
、ReadInt32
、ReadUInt32
、ReadInt64
、ReadUInt64
、ReadSingle
、ReadDouble
、ReadDecimal
、ReadDateTime
。
- 写入值
WriteString
、WriteStrings
- 用于写入字符串值,包括字符串数组。WriteObject
、Write<T>
- 使用 TypeConverter 写入任意类型的值。WriteArray
- 用于写入任意类型的值数组。- 写入基本数据类型的方法:
WriteBoolean
、WriteChar
、WriteSByte
、WriteByte
、WriteInt16
、WriteUInt16
、WriteInt32
、WriteUInt32
、WriteInt64
、WriteUInt64
、WriteSingle
、WriteDouble
、WriteDecimal
、WriteDateTime
。这些方法允许您轻松地读写 INI 文件中的值,自动执行类型转换(使用TypeConverter
)。这简化了使用 INI 文件的工作,并使代码更具可读性和可靠性。
- 此外,IniFile 类还提供了便捷的方法,可以根据 INI 文件中存储的数据自动初始化对象属性。这大大简化和加快了读写 INI 文件设置的过程。
ReadSettings
和WriteSettings
方法允许您自动读取和写入给定类型的 (包括嵌套类型) 的所有静态属性。当应用程序有许多分布在不同类别的设置时,这非常有用。ReadProperty
和WriteProperty
方法允许您读取和写入对象的单个属性值。这样做,它们会自动根据属性的名称和类型确定属性的节和键,从而消除了开发人员手动指定此信息的需要。因此,使用这些方法可以大大简化使用 INI 文件的工作,与手动管理设置的读写相比,它更高效且不易出错。它们还支持各种数据类型,包括数组,并提供了使用自定义类型转换器的能力。
这些方法使用 _regex 字段中存储的正则表达式来处理 INI 文件内容。该类还提供了一些用于处理正则表达式、字符串和文件系统的辅助方法。
该类提供了静态 Load
方法,用于从各种来源(字符串、流、文件)加载 INI 文件,以及 Save
方法,用于将 INI 文件内容保存到各种输出流。
IniFile 类包含一些额外的辅助方法,用于处理解析器设置、正则表达式、字符串和文件系统。
GetCultureInfo
:返回一个CultureInfo
对象,该对象定义了指定StringComparison
的字符串比较规则。GetRegexOptions
:根据指定的StringComparison
设置或清除RegexOptions
标志,并返回修改后的值。GetComparer
:返回一个基于指定 StringComparison 的StringComparer
对象。ToEscape
:使用反斜杠转义输入字符串中的特殊字符。UnHex
:将十六进制数转换为 Unicode 字符。UnEscape
:转换输入字符串中的所有转义字符。MoveIndexToEndOfLinePosition
:在StringBuilder
中将索引移动到当前行的末尾。InsertLine
:在StringBuilder
的指定索引处插入指定字符串,后跟指定的换行符,并更新索引。AutoDetectLineBreaker
:确定指定字符串中的换行符类型("\r\n"
、"\n"
或"\r"
)。AutoDetectLineEncoding
:尝试根据文件内容的前四个字节(BOM)来确定文本编码。MayBeToLower
:根据指定的StringComparison
在必要时将字符串转换为小写。IsInvalidPath
:检查文件名字符串是否包含路径的无效字符。ValidateFileName
:检查文件名是否有效,以及(可选)文件是否存在。GetFullPath
:通过检查其有效性,返回给定文件名的完整路径。GetDeclaringPath
:使用指定的目录分隔符返回指定类型的声明路径。
这些辅助方法在 IniFile
类内部使用,以确保与正则表达式、字符串、文件系统和解析器设置的正确交互。
工作原理
此类不使用集合来存储数据。相反,它使用正则表达式来解析 INI 文件内容。这使得您可以省略集合,并在编辑时保留原始文件格式。
GetSections
、GetKeys
、GetValue
、GetValues
、SetValue
和 SetValues
方法中使用的遍历 INI 文件内容的通用算法如下:
- 初始化一个正则表达式,该正则表达式将文件内容分割成节、键和值。
- 遍历文件内容中正则表达式的所有匹配项。
- 对于每个匹配项,检查它是节、键还是值。
- 根据匹配项的类型,保存其信息并用于相应的方法。
- 对于
GetValue
、GetValues
、SetValue
和SetValues
方法,还会另外监视当前匹配项所在的节,以便在正确的节中返回或设置值。 - 处理所有匹配项的结果将被返回或用于修改文件内容。这种方法允许您在无需使用集合的情况下高效地处理 INI 文件内容,同时保留原始文件格式。
所有这些方法的核心看起来是这样的:
Regex regex; // A regular expression object.
string content; // A string containing INI file data.
// ...
// Iterate over the content to find the section and key
for (Match match = regex.Match(content); match.Success; match = match.NextMatch())
{
if (match.Groups["section"].Success)
{
// Handling actions for sections.
}
if (match.Groups["entry"].Success)
{
// Handling actions for entries.
}
// Updating content if necessary.
}
如果我们只需要获取文件中所有节的列表,这非常简单:
string section; // String contains a section name.
string key; // String contains a key name.
HashSet<string> sections = new HashSet<string>();
for (Match match = _regex.Match(Content); match.Success; match = match.NextMatch())
{
if (match.Groups["section"].Success)
{
sections.Add(match.Groups["value"].Value);
}
}
在其他需要处理键的情况下,我们首先使用额外的标志 inSection
检查我们是否在一个节中。
string section; // String contains a section name.
string key; // String contains a key name.
HashSet<string> keys = new HashSet<string>(); // A collection of unique names.
bool inSection = false; // Indicates that we are in a section.
for (Match match = _regex.Match(Content); match.Success; match = match.NextMatch())
{
if (match.Groups["section"].Success)
{
inSection = match.Groups["value"].Value.Equals(section);
continue;
}
if (inSection && match.Groups["entry"].Success)
{
string key = match.Groups["key"].Value;
keys.Add(key);
}
}
SetValue
和 SetValues
方法写入数据的通用算法如下:
- 创建一个 StringBuilder 实例,用于修改 INI 文件内容。
- 使用正则表达式遍历文件内容。
- 如果找到一个匹配搜索键的节或条目,则:
- 获取代表值的组。
- 计算代表值的组的索引和长度。
- 从 StringBuilder 中移除旧值。
- 如果新值不为空,则将其插入 StringBuilder 中。
- 如果在迭代后,表示值未设置的标志仍然设置,则:
- 计算新条目应插入的索引。
- 如果这不是全局节,并且该节尚未遇到,则将新节插入 StringBuilder 中。
- 将具有键和值的新条目插入 StringBuilder 中。
- 将 StringBuilder 的内容写回
IniFile
实例的内容。
通过用其索引和长度替换找到的值来更新现有键值。新值插入在相同的位置,替换之前的,保留缩进。实现相当简单:
string section; // String contains a section name.
string key; // String contains a key name.
string value; // String contains a new value.
// Create a content editor.
StringBuilder sb = new StringBuilder(content);
for (Match match = _regex.Match(Content); match.Success; match = match.NextMatch())
{
if (match.Groups["section"].Success)
{
inSection = match.Groups["value"].Value.Equals(section);
continue;
}
if (inSection && match.Groups["entry"].Success)
{
// The match was found on the current iteration.
if (!match.Groups["key"].Value.Equals(key))
continue;
// Value and it's start and stop position to replace.
Group group = match.Groups["value"];
int index = group.Index;
int length = group.Length;
// Remove the old value.
sb.Remove(index, length);
// Insert the new value in its place.
sb.Insert(index, value);
}
}
// Updating the content.
content = sb.ToString();
使用代码
基本操作
以下是一些使用 IniFile
类的示例。
打开文件
// Here is an example of loading a file with parsing options
// that were explicitly specified:
IniFile ini = IniFile.Load("config.ini", Encoding.UTF8,
StringComparison.InvariantCultureIgnoreCase, true);
// All the above parameter values after the file name
// are passed in the form they are implied by default,
// So you can write the same in a shorter way:
ini = IniFile.Load("config.ini");
将参数读取到字符串变量中
string value = ini.ReadString("Section1", "Key1", "default value");
// If you want to receive a key without a section (or above all sections),
// pass in a null or empty string as the section name:
value = ini.ReadString("", "Key0", "default value");
// - or -
value = ini.ReadString(null, "Key0", "default value");
将参数读取到整数变量中
int intValue = ini.ReadInt32("Section1", "IntKey", 42);
将字符串数组读取到新变量中
string[] values = ini.ReadStrings("Section1", "ArrayKey", "default1", "default2");
使用索引器读取
string value = ini["Section1", "Key1", "default"];
读取各种类型的对象
// In this example, we use the ReadObject method of the IniFile class
// to read the value of the CultureInfo parameter from the Settings section.
// We pass the CultureInfo type as the desired type,
// the default value CultureInfo.InvariantCulture,
// and an instance of CultureInfoTypeConverter as the type converter.
CultureInfo culture1 = (CultureInfo)ini.ReadObject("Settings", "Culture",
typeof(CultureInfo),
CultureInfo.InvariantCulture,
new CultureInfoTypeConverter());
// In this example, we use a generic method Read without using an optional parameter.
Uri uri = ini.Read<Uri>("Settings", "Culture");
使用索引器写入
ini["Section1", "Key1"] = "new value";
写入字符串
ini.WriteString("Section1", "Key1", "new value");
写入字符串数组
ini.WriteStrings("Section1", "ArrayKey", "value1", "value2", "value3");
使用索引器写入
ini["Section1", "Key1"] = "new value";
保存文件
ini.Save("config.ini");
初始化自定义类
首先,我们创建 Person
类:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public DateTime Birthday { get; set; }
}
现在,让我们来看一个使用我们创建的类的 ReadSettings
方法的示例。
// Create an instance of IniFile.
IniFile ini = new IniFile("person.ini");
// Reading the settings for the Person class.
Person person = new Person();
ini.ReadSettings(person);
// We display data about a person.
Console.WriteLine($"Name: {person.Name}");
Console.WriteLine($"Age: {person.Age}");
Console.WriteLine($"Birthday: {person.Birthday.ToString("yyyy-MM-dd")}");
此代码使用的 person.ini 文件内容:
[Person] Name=John Age=35 Birthdyay=1989-04-25
同时,要从 INI 文件参数读取 Person
对象,您可以使用以下方法,即使用类型转换器。有必要为 Person
类创建 PersonTypeConverter
。
public class PersonTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context,
CultureInfo culture, object value)
{
if (value is string str)
{
string[] parts = str.Split(',');
if (parts.Length == 3)
{
return new Person
{
Name = parts[0].Trim(),
Age = int.Parse(parts[1].Trim()),
Birthday = DateTime.Parse(parts[2].Trim())
};
}
}
return base.ConvertFrom(context, culture, value);
}
public override object ConvertTo(ITypeDescriptorContext context,
CultureInfo culture,
object value,
Type destinationType)
{
if (value is Person person)
{
return $"{person.Name},
{person.Age},
{person.Birthday.ToString("yyyy-MM-dd")}";
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
这样,我们就可以使用自定义 TypeConverter
在 INI 文件中存储和读取 Person
对象。
var iniFile = new IniFile();
DateTime birthDay = DateTime.ParseExact(
"25-04-1989",
"dd-MM-yyyy",
CultureInfo.InvariantCulture
);
Person person = new Person { Name = "John", Age = 35, Birthday = birthDay };
iniFile.WriteObject("Section1", "Person", person, new PersonTypeConverter());
var person = iniFile.Read<Person>("Section1", "Person", null, new PersonTypeConverter());
iniFile.Save("persons.ini:);
此代码生成的 persons.ini 文件内容:
[Section1] Person=John,35,1989-04-25
从示例中可以看出,IniFile
在处理各种数据类型时具有许多优点。
通过使用正则表达式且不使用集合,IniFile 可以快速读写 INI 文件数据,并保留格式。这使得在不降低性能的情况下处理大量数据成为可能。
IniFile 支持根据 INI 文件中的数据自动初始化对象属性。这简化了应用程序的设置,并消除了手动提取和分配值给属性的需要。
IniFile 提供了方便的方法来读取和写入不同类型的数据,包括标准的 .NET 类型。它还允许使用自定义类型(通过 TypeConverter),这减少了类型转换时出错的可能性。因此,IniFile
是一个强大而灵活的 INI 文件处理工具,在处理任意数据类型时提供了高效率、易用性和可扩展性。
结论
在 C# 中使用正则表达式解析 INI 文件提供了一种高效灵活的处理配置数据的方法。这种方法不仅可以正确解析内容,还可以保留原始格式,这在某些应用程序中可能至关重要。
虽然该类不使用集合,但如果您需要添加数据缓存,可以轻松修改它以使用 MatchCollection
。
希望本文能帮助您更好地理解和使用正则表达式来处理 INI 文件!