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