本文的主题是简单说说如何实现 IConfigurationSource、IConfigurationProvider 接口来自定义一个配置信息的来源,后面老周给的示例是实现用 CSV 文件进行应用配置。
在切入主题之前,老周忽然酒兴大发,打算扯一些跟主题有关系的题外话。
关于 ASP.NET Core 的应用程序配置,以下是老周总结出来的无废话内容:
好了,下面看看这些接口的默认实现类。
IConfigurationBuilder ----> ConfigurationBuilder
IConfigurationSource ----> FileConfigurationSource(抽象)、StreamConfigurationSource(抽象)、CommandLineConfigurationSource ……
IConfigurationProvider ----> ConfigurationProvider(抽象)----> MemoryConfigurationProvider ……
IConfigurationRoot、IConfiguration ----> ConfigurationRoot
我没有全部列出来,列一部分,主要是大伙伴能明这些线路就行了。各种实现类,你看名字也能猜到干吗的,比如 CommandLineConfigurationSource,自然是提供命令行参数来做配置源的。
这里不得不提一个有意思的类—— ConfigurationManager,它相当于一个复合体,同时实现 IConfigurationBuilder、IConfigurationRoot 接口。这就相当于它既能用来添加 source,加载配置,又可以直接用来访问配置。所以使用该类,直接 Add 配置源就可以访问了,不需要调用 Build 方法。
ASP.NET Core 应用程序在初始化时默认在服务容器中注册的就是 ConfigurationManager 类,不过,在依赖注入时,你要用 IConfiguration 接口去提取。
---------------------------------------------------------------
好了,以上内容仅仅是知识准备,接下来咱们要动手干大事了。
有大伙伴可能会问:我们直接实现这些个接口吗?不,这显然工作量太大了,完全没必要。咱们要做的是根据实际需要选择抽象类,然后实现这些抽象类就好了。咱们分别来说说 Source 和 Provider。
对于配置的 source,因为它的主要作用是产生 Provider 实例,所以,如果你不需要其他的参数和属性,只想实现 Build 方法返回一个 Provider 实例,那么可以直接实现 IConfigurationSource 接口。另外,有两个抽象类我们是可以考虑的:
1、FileConfigurationSource:如果你要的配置源于文件,就果断实现这个抽象类,它已经包含如 Path(文件路径)、FileProvider 等通用属性。咱们直接重写 Build 方法就完事了,不用去管怎么处理文件路径的事。
在重写 Build 方法时,创建 Configuration Provider 之前最好调用一下 EnsureDefaults 方法。这个方法的作用是当用户没有提供 IFileProvider 时能获得一个默认值。其源代码如下:
public void EnsureDefaults(IConfigurationBuilder builder) { FileProvider ??= builder.GetFileProvider(); onl oadException ??= builder.GetFileLoadExceptionHandler(); }
还一个方法是 ResolveFileProvider,它的作用是当找不到 IFileProvider 时,将根据 Path 属性指定的文件路径创建一个 PhysicalFileProvider 对象。在向 IConfigurationBuilder 添加 source 时可以调用这个方法。
2、StreamConfigurationSource:如果你要的配置源是流对象,不管是内存流还是文件流,或是网络流,可考虑实现此抽象类。这个类公开 Stream 属性,用来设置要读取的流对象。当然,Build 方法一定要重写,因为它是抽象方法,用来返回你自定义的 Provider。
------------------------------------------------------------------------------------------------------
接着看 IConfigurationProvider,它的实现类中有个通用抽象类—— ConfigurationProvider。这个类有个 Data 属性,类型是 IDictionary<string, string>,看到吧,是字典类型。不过这个属性只允许派生类访问。
比较重要的是 Load 方法,这是个虚方法,派生类中我们重写它,然后在方法里面从配置源读取数据,并把处理好的配置数据放进 Data 属性中。这就是加载配置的核心步骤。
为了便于我们自定义,ConfigurationProvider 类又派生出两个抽象类:
1、FileConfigurationProvider :它封装了打开文件、读文件等细节,然后直给你一个抽象的 Load 方法,把已加载的流对象传递进去,然后你实现这个方法,在里面读取配置。意思就是:舞台都帮你搭好了(灯光、音响等都不用你管),请开始你的表演。
public abstract void Load(Stream stream);
2、StreamConfigurationProvider :跟 FileConfigurationProvider 一个鸟样,只不过它针对的源是流对象。该类同样有个抽象方法 Load,用途和签名一样。在这个方法里面实现读取配置。
public abstract void Load(Stream stream);
分析完之后,你会发现个规律:FileConfigurationSource 和 FileConfigurationProvider 是一对的,StreamConfigurationSource 和 StreamConfigurationProvider 是一对。如果配置源于文件,选择实现第一对;若源是流对象就实现第二对。
这些类型的关系不算复杂,为了节约脑细胞,老周就不画它们的关系图,老周相信大伙伴们的理解能力的。
-----------------------------------------------------------------------------------------
现在开始本期节目的最后一环节——写代码。开场白中老周说过,这一次咱们的示例会实现从 CSV 文件中读配置信息。CSV 就是一种简单的数据文件,嗯,文本文件。它的结构是每一行就是一条数据记录,字段用逗号分隔(一般用逗号,也可以用其他符号,主要看你的代码怎么实现了)。这里老周不打算搞太复杂,所以假设字段只用逗号分隔。
规则是这样的:第一行表示配置信息的 Key 列表,第二行是 Key 列表对应的值。比如
appTitle, appID, root 贪食蛇, TS-333, /usr/bin
把上面的内容解析成配置信息就是:
appTitle = 贪食蛇 appID = TS-333 root = /usr/bin
【注】这些配置在读取时是不区分大小写的,即 appTitle 和 apptitle 相同。
不过,老周也考虑有多套配置的情况,假设以下配置用来设置HTML页面的皮肤样式的。
headerColor, tableLine, fontSize black, 2, 15 red, 1.5, 16
按照规则,第一行是 Key 表列,那么二、三行就是 Value。所以这个应用程序就可以用两套 UI 皮肤了。
headerColor = black tableLine = 2 fontSize = 15 ----------------------------- headerColor = red tableLine = 1.5 fontSize = 16
那么,要是把两套配置都加载了,那怎么表示呢。不怕,因为它可以分层(或者说分节点),每个节点之间用冒号隔开。我们假设第一套皮肤配置的索引为 0, 第二套皮肤配置的索引为 1。这样就可以区分它们了。
headerColor:0 = black tableLine:0 = 2 fontSize:0 = 15 -------------------------------- headerColor:1 = red tableLine:1 = 1.5 fontSize:1 = 16
要使用第二套皮肤的字体大小,就访问 config["fontSize:1"]。
现在开工,想一下,咱们这个配置是来自 csv 文件,所以要实现自定义,应当选 FileConfigurationSource 和 FileConfigurationProvider 这两个类来实现。
动手,先写 CSVConfigurationSource 类,很简单,直接实现 Build 方法就完事。
public sealed class CSVConfigurationSource : FileConfigurationSource { public override IConfigurationProvider Build(IConfigurationBuilder builder) { EnsureDefaults(builder); //调用一下这个 return new CSVConfigurationProvider(this); } }
EnsureDefaults 方法记得调用一下,防止代码调用方没提供 FileProvider。重点是直接返回 CSVConfigurationProvider 实例,它接收当前 CSVConfigurationSource 对象作为构造函数参数。
接下来写 CSVConfigurationProvider 类,这个主要是实现 Load 方法。
public sealed class CSVConfigurationProvider : FileConfigurationProvider { public CSVConfigurationProvider(CSVConfigurationSource source) : base(source) { } public override void Load(Stream stream) { using StreamReader reader = new(stream); try { // 先读第一行,确定一下字段名(Key) string? strLine = reader.ReadLine(); if (string.IsNullOrEmpty(strLine)) { throw new FormatException("文件是空的?"); } string[] keys = GetParts(strLine).ToArray(); // 字段数量 // 这个很重要,后面读取值的时候要看看数量是否匹配 int keyLen = keys.Length; // 循环取值 int index = 0; // 临时存放 Dictionary<string, string> tempData = new Dictionary<string, string>(); for(strLine = reader.ReadLine(); !string.IsNullOrEmpty(strLine); strLine = reader.ReadLine()) { // 分割 var valparts = GetParts(strLine).ToArray(); // 分割出来的值个数是否等于字段数 if(valparts.Length != keyLen) { throw new FormatException("值与字段的数量不一致"); } // key - value 按顺序来 // key:<index> = value for(int n = 0; n < keyLen; n++) { string key = keys[n]; // 加上索引 key = ConfigurationPath.Combine(key, index.ToString()); tempData[key] = valparts[n]; } index++; // 索引要++ } // 读完数据后还要整理一下 // 如果 index-1 为0,表示代表配置值的只有一行 // 这种情况下没必要加索引 if(index - 1 == 0) { foreach(string ik in tempData.Keys) { string value = tempData[ik]; // 去掉索引 string key = ConfigurationPath.GetParentPath(ik); // 正式存储 Data[key] = value; } } else { foreach(string key in tempData.Keys) { // 这种情况下直接copy Data[key] = tempData[key]; } } // 临时存放的字典不需要了,清一下 tempData.Clear(); } catch { throw; } } #region 私有成员 private IEnumerable<string> GetParts(string line) { // 拆分并去掉空格 var parts = from seg in line.Split(',') select seg.Trim(); // 提取 foreach(string x in parts) { if(x is null or { Length: 0 } ) { throw new FormatException("咦,怎么有个值是空的?"); } yield return x; //这样返回比较方便 } } #endregion }
GetParts 是私有方法,功能是把一行文本按照逗号分隔出一组值来。
Load 方法的实现线路:
1、先读第一行,确定配置的 Key 列表。
2、从第二行开始读,每读一行就增加一次索引。因为允许一组 Key 对应一组 Value。
3、如果 Value 组只有一行,就不要加索引了,直接 key1、key2、key3就行了;如果有多组 Value,就要用索引,变成 key1:0、key2:0、key3:0;key1:1、key2:1、key3:1;key1:2、key2:2、key3:2。
4、加载的配置都存放到 Data 属性中。
代码中老周用了个临时的 Dictionary。
Dictionary<string, string> tempData = new Dictionary<string, string>();
因为在一行一行地读时,你不能事先知道这文件里面有多少行。如果只有两行,那表明 Value 只有一组,它的索引是0。可实际上,只有一组值的话,索引是多余的,没必要。只有大于一组值的时候才需要。
因为读的时候我们不会去算出文件中有多少行,所以我就假设它有很多行,第二行的索引为 0,第三行为 1,第四行为 2……。不管值是一行还是多行,我都给它加上索引,存放到临时的字典中。
等到整个文件读完了,我再看 index 变量,如果它的值是 1 (每读一行++,如果是 1 ,说明只读了一行),说明只读了第二行,这时候值只有一组,再把索引删去;要是读到的值有N行,那就保留索引。
if(index - 1 == 0) { foreach(string ik in tempData.Keys) { string value = tempData[ik]; // 去掉索引 string key = ConfigurationPath.GetParentPath(ik); // 正式存储 Data[key] = value; } } else { // 保留索引 foreach(string key in tempData.Keys) { // 这种情况下直接copy Data[key] = tempData[key]; } }
ConfigurationPath 有一组静态方法,很好用的,用来合并、剪裁用“:”分隔的路径。我们要充分利用它,可以省很多事,不用自己去合并拆分字符串。这个类还定义了一个只读的字段 KeyDelimiter,它的值就是一个冒号。可见,在.NET 的Configuration API 中,配置树的路径分隔符是在代码中写死的,你只能这样用:root : section1 : key1 = abcdefg。
到了这儿是基本完成,不过不好用,我们得写一组扩展方法,就像运行库默认给我们公开的那样,调用个 AddJsonFile,AddCommandLine,AddEnvironmentVariables 那样,多方便。
public static class CSVConfigurationExtensions { public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, IFileProvider? provider, string path, bool optional, bool reloadOnChange) { return builder.Add<CSVConfigurationSource>(s => { s.FileProvider = provider; s.Path = path; s.Optional = optional; s.ReloadOnChange = reloadOnChange; s.ResolveFileProvider(); //这一行可选 }); } public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path) => builder.AddCsvFile(null, path, false, false); public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path, bool optional) => builder.AddCsvFile(null, path, optional, false); public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange) => builder.AddCsvFile(null, path, optional, reloadOnChange); }
基本上就是模仿 AddJsonFile、AddXmlFile 写的。
接下来是实验阶段。在项目中加一个 csv 文件,可以新建个文本文件,然后改名为 test.csv。
把这个文件的“生成操作”改为“内容”,复制行为是“如果较新则复制”。这样在运行测试时就不用自己手动复制文件。
hashName,keyBits,version MD5,8,1.2.0 SHA1,12,2.0 SHA256,16,0.3.5
这个配置的 Key 有:hashName,keyBits,version。值有三组(二、三、四行)。
打开 Program.cs 文件,在初始化代码中添加 test.csv 文件。
var builder = WebApplication.CreateBuilder(args); // 添加配置 builder.Configuration.AddCsvFile("test.csv", optional: true, reloadOnChange: true); var app = builder.Build();
optional 表示这个文件是可选的,如果找不到就不加载配置了;reloadOnChange 表示监控这个文件,如果它被修改了,就重新加载配置。
在 app.MapGet 方法中,我们用一个调试专用的扩展方法,直接打印所有配置。
app.MapGet("/", () => { IConfigurationRoot rootconfg = (IConfigurationRoot)app.Configuration; return rootconfg.GetDebugView(); });
GetDebugView 扩展方法很好使,运行程序后就能看到所有配置了,包括咱们自定义的 CSV 文件中的配置。
如果我们要明确地读取这些配置,可以这样。
IConfigurationRoot rootconfg = (IConfigurationRoot)app.Configuration; // 第一组配置 string hash1 = rootconfg["hashName:0"]; string bits1 = rootconfg["keyBits:0"]; string version1 = rootconfg["version:0"]; // 第二组 string hash2 = rootconfg["hashName:1"]; string bits2 = rootconfg["keyBits:1"]; string version2 = rootconfg["version:1"]; // 拼接字符串并返回 return $"hashName: {hash1}, keyBits: {bits1}, version: {version1}\n" + $"hashName: {hash2}, keyBits: {bits2}, version: {version2}";
运行后得到的结果:
至此,咱们这个自定义的配置源总算是实现了。
好了,今天就水到这里了,改天老周和各位继续水文章。