【ASP.NET Core】自定义的配置源

本文的主题是简单说说如何实现 IConfigurationSource、IConfigurationProvider 接口来自定义一个配置信息的来源,后面老周给的示例是实现用 CSV 文件进行应用配置。

在切入主题之前,老周忽然酒兴大发,打算扯一些跟主题有关系的题外话。

关于 ASP.NET Core 的应用程序配置,以下是老周总结出来的无废话内容:

  • 配置信息可以有多种来源。比如,用JSON文件来配置,在内存中直接构建配置,用XML文件来配置,用 .ini 文件来配置等。
  • ASP.NET Core 或 .NET 应用程序会将这些信息来源合并到一起,主要负责人是 IConfigurationBuilder 君。
  • 配置信息是字典格式的,即 Key=Value,如果key相同,不管它来自哪,后添加的会替换先添加的配置。
  • 配置数据可以认为是树形的,它由key/value组成,但可以有小节。
  • IConfiguration 接口表示配置信息中的通用模型,你可以像字典对象那样访问配置,如 config["key"]。这些配置信息都是字符串类型,不管是key还是value。
  • IConfigurationRoot 接口比 IConfiguration 更具体一些。它表示整个应用程序配置树的根。它多了个 Providers  集合,你可以从集合里找出你想单独读取的配置源,比如,你只想要环境变量;它还有个 Reload 方法,用来重新加载配置信息。
  • IConfigurationSource 接口表示配置信息的来源。
  • IConfigurationProvider 接口根据其来源为应用程序提供 Key / Value 形式的配置信息。
  • 上面两位好基友的关系:IConfigurationSource 负责创建 IConfigurationProvider。读取配置信息靠的是 IConfigurationProvider。
  • Microsoft.Extensions.Configuration 并不是只用于 ASP.NET Core 项目,其他 .NET 项目也能用,不过要引用 Nuget 库。
  • 这些家伙的日常运作是这样的:
    • IConfigurationBuilder 管理生产车间(家庭小作坊),它有个 Source 集合,你可以根据需要放各种 IConfigurationSource。这就等于放各种原材料了。
    • 放完材料后,builder 君会检查所有的 source,逐个调用它们的 Build 方法,产生各种 IConfigurationProvider。这样,初步加工完毕,接下来是进一步处理。
    • 逐个调用所有 IConfigurationProvider 的 Load 方法,让它们从各自的 source 中加载配置信息。
    • 把所有的配置信息合并起来统一放到 IConfigurationRoot 中,然后应用程序就可以用各种姿势来访问配置。

 好了,下面看看这些接口的默认实现类。

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();
       OnLoadException ??= 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}";

运行后得到的结果:

 

 至此,咱们这个自定义的配置源总算是实现了。

好了,今天就水到这里了,改天老周和各位继续水文章。

 

posted @ 2022-07-09 19:37  东邪独孤  阅读(825)  评论(0编辑  收藏  举报