[ASP.NET Core 3框架揭秘] 配置[3]:配置模型总体设计
在《读取配置数据》([上篇],[下篇])上面一节中,我们通过实例的方式演示了几种典型的配置读取方式,接下来我们从设计的维度来重写认识配置模型。配置的编程模型涉及到三个核心对象,分别通过三个对应的接口(IConfiguration、IConfigurationSource和IConfigurationBuilder)来表示。如果从设计层面来审视背后的配置模型,还缺少另一个名通过IConfigurationProvider接口表示的核心对象。总的来说,配置模型由这四个核心对象组成,但是要彻底了解这四个核心对象之间的关系,我们先得来聊聊配置的几种数据结构。
一、配置数据结构及其转换
相同的数据具有不同的表现形式和承载方式,同时体现出不同的数据结构。对于配置来说,它在被应用程序消费过程中是以IConfiguration对象的形式来体现的,该对象在逻辑上具有一个树形化层次结构,所以将它称之为配置树,并将这棵树视为配置的“逻辑结构”。配置具有多种原始来源,可以是内存对象、物理文件、数据库或者其他自定义的存储介质。如果采用物理文件来存储配置数据,我们还可以选择不同的文件格式,常见的文件类型包括XML、JSON和INI三种,所以配置的原始数据结构是多种多样的。配置模型的最终目的在于提取原始的配置数据并将其转换成一个IConfiguration对象。话句话说,配置模型的使命就在于按照下图所示的方式将配置数据从原始的结构转换成树形层次结构。
配置从原始结构向逻辑结构的转换不是一蹴而就的,在它们之间具有一种“中间结构”。原始的配置数据被读取出来之后会先统一转换成这种中间结构的数据,那么这种中间结构到底是一种怎样的数据结构呢?一棵配置树通过其叶子结点承载所有的原子配置数据, 这棵树的结构和承载的数据完全可以利用一个简单的数据字典来表达。具体来说,我们只需要将所有叶子节点在配置树中的路径作为Key,将叶子结点承载的配置数据作为Value即可。所谓的“中间结构”指的就是这样的数据字典,我们不妨将其称为“配置字典”。所以配置模型会按照图6-9所示的方式将具有不同原始结构的配置数据统一转换成基于字典的配置字典,最终再完成针对逻辑结构的转换。
对于配置模型的四个核心对象来说,IConfiguration对象是对配置树的体现,其他三个核心对象(IConfigurationSource、IConfigurationBuilder和IConfigurationProvider)在配置的结构转换过程中扮演着不同的角色,至于它们究竟起到怎样的作用,我们将在接下来的内容中对它们作专门的介绍。
二、IConfiguration
配置在应用程序中总是以一个IConfiguration对象的形式供我们使用。一个IConfiguration对象具有树形层次化结构的意思并不是说对应的类型具有对应的数据成员定义,而是说它提供的API在逻辑上体现出树形化层次结构,所以我们才说配置树是一种逻辑结构。如下所示的是IConfiguration接口的完整定义,所谓的层次化逻辑结构就体现在它的成员定义上。
public interface IConfiguration { IEnumerable<IConfigurationSection> GetChildren(); IConfigurationSection GetSection(string key); IChangeToken GetReloadToken(); string this[string key] { get; set; } }
一个IConfiguration对象表示配置树的某个配置节点。对于组成整棵树的所有配置节点来说,表示根节点的IConfiguration对象与表示其它配置节点的IConfiguration对象是不同的,所以配置模型采用不同的接口来表示它们。根节点所在的IConfiguration对象体现为一个IConfigurationRoot对象,除此之外的其他节点对象则被通过一个IConfigurationSection对象表示,IConfigurationRoot和IConfigurationSection接口都是IConfiguration的继承者。下图为我们展示了由一个IConfigurationRoot对象和一组 IConfigurationSection对象构成的配置树。
如下所示的是接口IConfigurationRoot的定义,它具有的唯一方法Reload实现对配置数据的重新加载。IConfigurationRoot对象表示的配置树的根,所以也代表了整棵配置树,如果它被重新加载了,意味着整棵配置树承载的所有配置数据均被重新加载了。
public interface IConfigurationRoot : IConfiguration { void Reload(); }
表示非根配置节点的IConfigurationSection接口具有如下三个属性,只读属性Key用来唯一标识多个具有相同父节点的ConfigurationSection对象,而Path则表示当前配置节点在配置树中的路径,它后组成当前路径的所有IConfigurationSection对象的Key组成,并采用冒号(“:”)作为分隔符。Path和Key的组合体现了当前配置节在整个配置树中的位置。
public interface IConfigurationSection : IConfiguration { string Path { get; } string Key { get; } string Value { get; set; } }
IConfigurationSection的Value属性表示配置节点承载的配置数据。在大部分情况下,只有配置树的叶子结点对应的IConfigurationSection对象才具有值,非叶子节点对应的IConfigurationSection对象实际上仅仅表示存放所有子配置节点的逻辑容器,它们的Value一般返回Null。值得一体的是,这个Value属性并不是只读的,而是可读可写的,但我们写入的值一般不会被持久化,一旦配置树被重新加载,该值将会丢失。
在对IConfigurationRoot和IConfigurationSection具有基本了解情况下我们回过头来看看定义在接口IConfiguration中的成员。它的GetChildren方法返回的IConfigurationSection集合表示它的所有子配置节,另一个方法GetSection则根据指定的Key得到一个具体的子配置节。当GetSection方法执行的时候,指定的参数将会与当前IConfigurationSection的Path进行组合以确定目标配置节点所在的路径,所以如果在调用该方法的时候指定一个相对于当前配置节的路径,我们是可以得到子节点以下的某个配置节。
var source = new Dictionary<string, string> { ["A:B:C"] = "ABC" }; var root = new ConfigurationBuilder() .AddInMemoryCollection(source) .Build(); var section1 = root.GetSection("A:B:C"); //A:B:C var section2 = root.GetSection("A:B").GetSection("C"); //A:C->C var section3 = root.GetSection("A").GetSection("B:C"); //A->B:C Debug.Assert(section1.Value == "ABC"); Debug.Assert(section2.Value == "ABC"); Debug.Assert(section3.Value == "ABC"); Debug.Assert(!ReferenceEquals(section1, section2)); Debug.Assert(!ReferenceEquals(section1, section3)); Debug.Assert(null != root.GetSection("D"));
如上面的代码片段所示,我们以不同的方式调用GetSection方法得到的都是路径为“A:B:C”的IConfigurationSection对象。上面这段代码还体现了另一个有趣的现象,虽然这三个IConfigurationSection对象均指向配置树的同一个节点,但是它们却并非同一个对象。换句话说,当我们调用GetSection方法的时候,不论配置树中是否存在一个与指定路径匹配的配置节,它总是会创建新的IConfigurationSection对象。
IConfiguration还具有一个索引,我们可以指定子配置节的Key或者相对当前配置节点的路径得到对应IConfigurationSection的值。当这个索引执行的时候,它会按照与GetSection方法完全一致的逻辑得到一个IConfigurationSection对象,并返回其Value属性。如果配置树中不具有匹配的配置节,该索引会返回Null而不会抛出异常。
三、IConfigurationProvider
在《读取配置数据[上篇]》介绍IConfigurationSource对象时,我们说它对原始配置源的体现。虽然每种不同类型的配置源都具有一个对应的IConfigurationSource实现,但是针对原始数据的读取并不由它来提供,而是委托一个与之对应的IConfigurationProvider对象来完成。在上面介绍的配置结构转换过程中,针对不同配置源类型的IConfigurationProvider按照如下图所示的方式实现配置从原始结构向物理结构的转换。
由于IConfigurationProvider对象的目的在于将配置从原始结构转换成配置字典,所以我们会发现定义在IConfigurationProvider接口中的方法大都体现为针对字典对象的相关操作。配置数据的加载通过调用IConfigurationProvider的Load方法来完成。我们可以调用TryGet方法获取由指定的Key所标识的配置项的值。从数据持久化的角度来讲,IConfigurationProvider基本上都是只读的,也就是说它只负责从持久化资源中读取配置数据,而不负责持久化更新后的配置数据,所以它提供的Set方法设置的配置数据一般只会保存在内存中,不过通过实现该方法时对提供的值进行持久化也未尝不可。
public interface IConfigurationProvider { void Load(); void Set(string key, string value); bool TryGet(string key, out string value); IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath); IChangeToken GetReloadToken(); }
IConfigurationProvider的GetChildKeys方法用于获取某个指定配置节点(对应于parentPath参数)的所有子节点的Key。当IConfiguration的GetChildren方法被调用时,注册的所有IConfigurationSource对应的IConfigurationProvider的GetChildKeys方法会被调用。这个方法的第一个参数earlierKeys代表的Key来源于其他IConfigurationProvider,当解析出当前IConfigurationProvider提供的Key后,该方法需要对它们合并到earlierKeys集合中,合并后结果将作为方法的返回值。值得一提的是,返回的Key的集合是经过排序的。
每种类型的配置源都具有对应的IConfigurationProvider实现,它们一般不会直接实现接口IConfigurationProvider,而会选择继承另一个名为ConfigurationProvider的抽象类。这个抽象类的定义其实很简单,从如下的代码片段可以看出它仅仅是对一个IDictionary<string, string>对象(Key不区分大小写)的封装,其Set和TryGetValue方法最终操作的都是这个字典对象。
public abstract class ConfigurationProvider : IConfigurationProvider { protected IDictionary<string, string> Data { get; set; } protected ConfigurationProvider()=> Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); public IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath) { var prefix = parentPath == null ? string.Empty : $"{parentPath}:" ; return Data .Where(it => it.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Select(it => Segment(it.Key, prefix.Length)) .Concat(earlierKeys) .OrderBy(it => it); } public virtual void Load() {} public void Set(string key, string value) => Data[key] = value; public bool TryGet(string key, out string value) => Data.TryGetValue(key, out value); private static string Segment(string key, int prefixLength) { var indexOf = key.IndexOf(":", prefixLength, StringComparison.OrdinalIgnoreCase); return indexOf < 0 ? key.Substring(prefixLength) : key.Substring(prefixLength, indexOf - prefixLength); } ... }
抽象类ConfigurationProvider实现了Load方法并将其定义成虚方法,这个方法并没有提供具体的实现,所以它的派生类可以通过重写这个方法从相应的数据源中读取配置数据,并对通过Data属性的设置完成对配置字典的初始化。
四、IConfigurationSource
IConfiurationSource在配置模型中代表配置源,它被注册到IConfigurationBuilder上为后者创建的IConfiguration提供原始的配置数据。由于针对原始配置数据的读取实现在相应的IConfigurationProvider中,所以IConfigurationSource所起的作用在于提供相应的IConfigurationProvider。如下面的代码片段所示,IConfigurationSource接口具有一个唯一的Build方法根据指定的IConfigurationBuilder对象提供对应的IConfigurationProvider。
public interface IConfigurationSource { IConfigurationProvider Build(IConfigurationBuilder builder); }
五、IConfigurationBuilder
IConfigurationBulder在整个配置模型中处于一个核心地位,代表原始配置源的IConfigurationSource也注册到它上面,它的作用就在于利用后者提供的原始数据创建出供应用程序使用的IConfiguration对象。如下面的代码片段所示,IConfigurationBulder接口定义了两个方法,其中Add方法用于注册IConfigurationSource对象,最终的IConfiguration对象则通过Build方法创建,后者返回一个代表整棵配置树的IConfigurationRoot对象。注册的IConfigurationSource被保存在通过Sources属性表示的集合中,而另一个属性Properties则以字典的形式存放任意的自定义属性。
public interface IConfigurationBuilder { IEnumerable<IConfigurationSource> Sources { get; } Dictionary<string, object> Properties { get; } IConfigurationBuilder Add(IConfigurationSource source); IConfigurationRoot Build(); }
配置系统提供了一个名为ConfigurationBulder的类作为IConfigurationBulder接口的默认实现。定义在它上面的Build方法体现了配置系统读取原始配置数据并生成配置树的默认机制。ConfigurationBulder类的Build方法返回一个类型为ConfigurationRoot的对象,对于通过该对象表示配置树来说,每个非根配置节点均是一个类型为ConfigurationSection的对象。
本篇文章从设计和实现原理的角度对配置模型进行了详细的介绍。总的来说,配置模型涉及到四个核心对象,包括承载配置逻辑结构的IConfiguration对象和它的创建者IConfigurationBuilder,以及与配置源相关的IConfigurationSource和IConfigurationProvider。这四个核心对象之间的关系简单而清晰,完全可以通过一句话来概括:IConfigurationBuilder利用注册在它上面的所有IConfigurationSource提供的IConfigurationProvider读取原始配置数据并创建出相应的IConfiguration对象。下图所示的UML展示了配置模型涉及的主要接口/类型以及它们之间的关系。
[ASP.NET Core 3框架揭秘] 配置[1]:读取配置数据[上篇]
[ASP.NET Core 3框架揭秘] 配置[2]:读取配置数据[下篇]
[ASP.NET Core 3框架揭秘] 配置[3]:配置模型总体设计
[ASP.NET Core 3框架揭秘] 配置[4]:将配置绑定为对象
[ASP.NET Core 3框架揭秘] 配置[5]:配置数据与数据源的实时同步
[ASP.NET Core 3框架揭秘] 配置[6]:多样化的配置源[上篇]
[ASP.NET Core 3框架揭秘] 配置[7]:多样化的配置源[中篇]
[ASP.NET Core 3框架揭秘] 配置[8]:多样化的配置源[下篇]
[ASP.NET Core 3框架揭秘] 配置[9]:自定义配置源