2

【ASP.NET Core】自定义的配置源 - 东邪独孤

 1 year ago
source link: https://www.cnblogs.com/tcjiaan/p/16460628.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【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 文件中的配置。

367389-20220709192727581-1703294961.png

 

367389-20220709192812837-307967521.png

 

367389-20220709192904215-1818363607.png

如果我们要明确地读取这些配置,可以这样。

    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}";

运行后得到的结果:

367389-20220709193533516-1463408020.png

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK