ASP.NET Core的配置(2):配置模型详解
在上面一章我们以实例演示的方式介绍了几种读取配置的几种方式,其中涉及到三个重要的对象,它们分别是承载结构化配置信息的Configuration,提供原始配置源数据的ConfigurationProvider,以及作为“中间人”的ConfigurationBuilder。接下来我们将会对由这三个核心对象组成的配置模型进行详细介绍,不过在此之前我们有必要来认识配置信息在不同载体中所体现出来的三种结构。
目录
一、配置的三种结构
逻辑结构
原始结构
物理结构
结构转换
二、Configuration
三、ConfigurationProvider
四、ConfigurationBuilder
一、配置的三种结构
相同的数据具有不同的表现和承载方式,同时体现出不同的数据结构。对于配置来说,它在消费过程中是以Configuration对象的形式来体现的,该对象逻辑上具有一个树形化的层次结构。配置具有多种来源,可以是内存对象、物理文件或者数据库,不同类型的数据源决定了不同的配置结构。我们将这两种结构称为逻辑结构和原始结构。在这两种结构之间,配置还存在一种中间结构,我们姑且称之为物理结构。
逻辑结构
配置的逻辑结构就是Configuration对象所体现的结构,说得更加准确一点应该是针对Configuration对象的API所体现的结构(因为不是所有的Configuration对象内部都封装一组配置数据)。配置在逻辑上呈现为一种树形结构,我们称之为配置树,组成这棵树的某个节点就体现为一个Configuration对象。表现为键值对的原子配置项存储于叶子节点中,而非叶子节点仅仅体现为一个配置节点的逻辑容器,自身不包含具体的配置数据。对于我们在第一节定义的FormatSettings来说,它对应的配置具有如右图所示的逻辑结构。
原始结构
配置采用怎样的原始结构取决于我们采用何种方式定义它。最常见的配置源体现为采用某个格式的文本文件,那么配置的原始结构则由文件的格式来决定。对于我们在第一节定义的FormatSettings类型,我们可以按照如下的形式以XML和JSON的格式来定义其配置。
XML:
1: <Format>
2: <DateTime>
3: <LongDatePattern>dddd, MMMM d, yyyy</LongDatePattern>
4: <LongTimePattern>h:mm:ss tt</LongTimePattern>
5: <ShortDatePattern>M/d/yyyy</ShortDatePattern>
6: <ShortTimePattern>h:mm tt</ShortTimePattern>
7: </DateTime>
8: <CurrencyDecimal>
9: <Digits>2</Digits>
10: <Symbol>$</Symbol>
11: </CurrencyDecimal>
12: </Format>
JSON:
1: {
2: "format": {
3: "dateTime": {
4: "longDatePattern" : "dddd, MMMM d, yyyy",
5: "longTimePattern" : "h:mm:ss tt",
6: "shortDatePattern" : "M/d/yyyy",
7: "shortTimePattern" : "h:mm tt"
8: },
9: "currencyDecimal": {
10: "digits": "2",
11: "symbol": "$"
12: }
13: }
14: }
物理结构
配置模型的终极目的就是将配置从原始结构转换成逻辑结构。不过在进行结构转化的时候,它并不会直接将原始的配置数据转换成一个Configuration对象,它们之间由一种被我称为物理结构的中间结构作为过度。配置的物理结构体现为一个简单的数据字典。同样是针对FormatSettings这个类型,对应的配置将具有如下表所示的物理结构。
结构转换
配置模型的终极目的在于将具有不同来源的配置转换成Configuration对象,配置源和Configuration对象本身分别体现了配置的原始结构和逻辑结构,所以配置模型旨在实现配置数据从原始结构向逻辑结构的转换。在具体转换过程中,配置模型先利用与配置源相对应的ConfigurationProvider将配置数据从原始结构转换成体现为数据字典的物理结构。当我们利用ConfigurationBuilder生成Configuration的时候,实际上将配置数据从物理结构转换成逻辑结构。
二、Configuration
我们在上面以数据结构转换的角度分析了Configuratin、ConfigurationProvider和ConfigurationBuilder这三个核心对象在配置模型中所起的作用,接下来让我们来更加深入地认识它们。我们首先来介绍Configuration对象,本章不断提及的Configuration泛指类型实现了IConfiguration接口的对象,该接口定义在“Microsoft.Extensions.Configuration”命名空间下,如果未作特别说明,本章涉及到的与配置相关的类型均定义在此命名空间下。
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对象通过接口IConfigurationRoot表示,另一个接口IConfigurationSection则表示针对非空节点的Configuration对象,两个接口都继承IConfiguration。如右图所示,一棵完整的配置树由一个ConfigurationRoot对象和若干ConfigurationSection构成。
ConfigurationRoot
我们将所有实现了IConfigurationRoot接口的类型和其对象统称为ConfigurationRoot。如下面的代码片段所示,IConfigurationRoot仅仅包含一个唯一的方法Reload实现对配置数据的重新加载。一个ConfigurationRoot对象表示配置数的根节点,如果它被重新加载了,那么这颗配置树承载的所有配置数据均被重新加载了。
1: public interface IConfigurationRoot : IConfiguration
2: {
3: void Reload();
4: }
ConfigurationSection
我们将所有实现了IConfigurationSection接口的类型及其对象统称为ConfigurationSection,一个ConfigurationSection对应着配置树中某个非根配置节。IConfigurationSection具有如下三个属性,只读属性Key用来唯一标识多个“同父”配置节,而另一个只读属性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返回对应的ConfigurationSection对象。当GetSection方法执行的时候,指定的参数将会与当前ConfigurationSection的Path进行组合以确定目标ConfigurationSection所在的路径,所以如果在调用该方法的时候指定一个相对于当前配置节的路径,我们是可以得到子节点以下的某个配置节。
1: Dictionary<string, string> source = new Dictionary<string, string>
2: {
3: ["A:B:C"] = "ABC"
4: };
5: IConfiguration root = new ConfigurationBuilder()
6: .Add(new MemoryConfigurationProvider(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 == section2.Value && section2.Value == section3.Value);
14: Debug.Assert(!ReferenceEquals(section1, section2) && !ReferenceEquals(section2, section3));
15: Debug.Assert(null == root.GetSection(Guid.NewGuid().ToString()));
如上面的代码片段所示,我们以不同的方式调用GetSection方法得到的都是路径为“Format:DateTime:LongDatePattern”的ConfigurationSection。上面这段代码还体现了另一个有趣的现象,虽然这三个ConfigurationSection对象均指向配置树的同一个节点,但是它们却并非同一个对象。换句话说,当我们调用GetSection方法的时候,不论配置树种是否存在一个与指定路径匹配的配置节,它总是会创建一个全新的ConfigurationSection对象。
IConfiguration还具有一个索引,我们可以指定子配置节的Key或者相对当前配置节的路径得到对应配置节的值。当这个索引执行的时候,它会按照与GetSection方法完全一致的逻辑得到一个ConfigurationSection对象,并返回其Value属性。如果配置树中不具有匹配的配置节,该索引会返回Null而不会抛出异常。
三、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, string delimiter)
8: }
配置数据的加载通过调用ConfigurationProvider的Load方法来完成。我们可以调用TryGet方法获取有指定的Key所标识的配置项的值。从数据持久化的角度来讲,ConfigurationProvider基本上都是只读的,也就是说ConfigurationProvider只负责从持久化资源中读取配置数据,而不负责更新保存在持久化资源的配置数据,所以它提供的Set方法设置的配置数据一般只会保存在内存中。ConfigurationProvider的GetChildKeys方法用于获取指定路径对应配置节的所有子节点的Key。
每种不同类型的配置源都具有对应的ConfigurationProvider,它们对应的类型大都不会直接实现IConfigurationProvider接口,而是继承另一个名为ConfigurationProvider的抽象类。这个抽象类的定义其实很简单,从如下的代码片段可以看出它仅仅是对一个IDictionary<string, string>对象的封装,其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, string delimiter)
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: }
接下来我们简单介绍一下定义在这个抽象类中GetChildKeys方法的逻辑。采用基于路径的Key让数据字典在逻辑上具有了树形化层次结构,而这个方法用于获取将指定配置节作为父节点的所有配置节的Key。指定的父配置节通过参数parentPath表示的路径来体现,另一个参数delimiter则表示路径采用的分隔符。除此之外,这个方法还具有一个字符串集合类型的参数earlierKeys,它表示预先解析出来的Key,这个列表会包含在返回的结果中。
1: class Program
2: {
3: static void Main(string[] args)
4: {
5: Dictionary<string, string> source = new Dictionary<string, string>
6: {
7: ["A:B:C"] = "",
8: ["A:B:D"] = "",
9: ["A:E"] = "",
10: };
11:
12: MemoryConfigurationProvider provider = new MemoryConfigurationProvider(source);
13: Console.WriteLine("{0, -20}{1}", "Parent Path", "Child Keys");
14: Console.WriteLine("------------------------------------------");
15:
16: Print("Null", provider.GetChildKeys(new string[] { "X", "Y", "Z" }, null, ":"));
17: Print("A", provider.GetChildKeys(new string[] { "x", "y", "z" }, "A", ":"));
18: Print("A:B", provider.GetChildKeys(new string[] { "X", "Y", "Z }, "A:B", ":"));
19: Print("A:B:C",provider.GetChildKeys(new string[] { "X", "Y", "Z }, "A:B:C", ":"));
20: }
21:
22: static void Print(string parentPath, IEnumerable<string> keys)
23: {
24: Console.WriteLine("{0, -20}{1}", parentPath, string.Join(", ", keys.ToArray()));
25: }
26: }
为了让读者朋友们能够更加直观地理解GetChildKeys方法的逻辑,我们编写了如上一段实例程序。我们创建了一个MemoryConfigurationProvider对象,由塔封装的配置数据字段包含三个元素,它们对应的Key分别是“A:B:C”、“A:B:D”和“A:E”。我们调用它的GetChildKeys方法并将表示父节点的路径分别指定为“A”、“A:B和“A:B:C”以获取相应子节点的Key。除此之外,我们采用冒号(“:”)作为分隔符,并将earlierKeys指定为包含“X”、“Y”和“Z”三个元素的数组。这段程序执行之后会在控制台上产生如下的输出结果,我们从中可以看出一个细节,返回的结构并没有将重复的Key剔除。
1: Parent Path Child Keys
2: ------------------------------------------
3: Null X, Y, Z
4: A B, B, E, X, Y, Z:
5: A:B C, D, X, Y, Z
6: A:B:C X, Y, Z
四、ConfigurationBuilder
ConfigurationBuilder泛指所有实现了IConfigurationBuilder接口的类型及其对象,它在配置模型中的作用就是利用注册的ConfigurationProvider提取转换成数据字典的配置数据并创建对应的Configuration对象,具体来说创建的是一个体现配置树的ConfigurationRoot对象。注册到ConfigurationBuilder上的ConfigurationProvider体现为IConfigurationBuilder接口的Providers属性,我们可以调用Add方法将ConfigurationProvider添加到这个集合中。
1: public interface IConfigurationBuilder
2: {
3: IEnumerable<IConfigurationProvider> Providers { get; }
4: Dictionary<string, object> Properties { get; }
5:
6: IConfigurationBuilder Add(IConfigurationProvider provider);
7: IConfigurationRoot Build();
8: }
除此之外,IConfigurationBuilder还具有一个字典类型的只读属性Properties,我们可以将任意自定义的属性附加当一个ConfigurationBuilder对象上,并通过对应的Key得到这些属性值。ConfigurationRoot的创建最终通过Build方法完成。
原生的配置模型中提供了一个实现IConfigurationBuilder接口的类型,那就是在我们之前演示的实例中多次使用的ConfigurationBuilder类,配置模型默认的配置生成机制体现在它实现的Build方法中。具体来说,实现在ConfigurationBuilder类中的Build方法返回对象的真实类型为ConfigurationRoot,该对象通过一个类型为ConfigurationSection对象表示非根配置节。右图所示的UML展示了配置模型中以Configuration、ConfigurationProvider和ConfigurationBuilder为核心的相关接口/类型以及它们之前的关系。
ConfigurationRoot和ConfigurationSection这个两个类型的定义体现配置模型默认采用怎样的机制读取配置数据,这是我们本节论述的重点内容。虽然配置模型最终提供的配置数据通过Configuration对象来体现,但是不论ConfigurationRoot还是ConfigurationSection对象,它们自身本没有封装任何的形式的配置数据,所有针对它们的数据读写操作最终都会转移到相应的ConfigurationProvider上。由于Configuration对象仅仅体现为ConfigurationProvider的代理,所以由同一个ConfigurationBuilder创建的所有ConfigurationRoot对象都是等效的,下面的代码片段体现了这样的等效性。
1: IConfigurationBuilder builder = new ConfigurationBuilder().Add(new MemoryConfigurationProvider());
2:
3: IConfiguration config1 = builder.Build();
4: IConfiguration config2 = builder.Build();
5:
6: config1["Foobar"] = "ABC";
7: Debug.Assert(config2["Foobar"] == "ABC");
组成配置树的ConfigurationRoot和ConfigurationSection不但自身封装和配置数据,配置节父子关系的维护也并不直接通过对象之间的引用关系来维系。如右图所示,对于一个表示配置树中某个非根配置节的ConfigurationSection对象来说,它仅仅保留着对根节点的引用,后者是一个类型为ConfigurationRoot的对象。当我们调用ConfigurationSection方法获取或者设置配置数据的时候,它会直接将调用请求转发给表示配置树根的ConfigurationRoot对象。
具体来说,当我们试图通过某个ConfigurationSection对象得到对应配置节点的值时,该对象会将配置数据的读取请求转发给它所引用的表示数值树根的ConfigurationRoot对象,同时将自身的路径一并传递给后者。ConfigurationRoot最终利用ConfigurationProvider根据指定的路径得到对应配置项的值,左图揭示了这样的流程。
在对实现在ConfigurationRoot和ConfigurationSection这两个类中针对配置的读写机制有了大概的了解之后,我们从代码实现的角度来进一步地来认识这两个类型,在这之前我们需要先来认识一个名为ConfigurationPath的工具类。顾名思义,ConfigurationPath帮助我们实现针对配置树路径相关的计算,其中Combine方法将多个片段合并成一个完整的路径,GetSectionKey方法会根据指定的路径得到对应的Key,而GetParentPath则根据指定的路径得到上一级的路径。
1: public static class ConfigurationPath
2: {
3: public static string Combine(params string[] pathSegements) ;
4: public static string Combine(IEnumerable<string> pathSegements) ;
5: public static string GetSectionKey(string path) ;
6: public static string GetParentPath(string path) ;
7: }
ConfigurationRoot
ConfigurationRoot真实的实现逻辑基本上体现在如下所示的代码片段中。一个ConfigurationRoot对象维护着一组提供原始配置数据的ConfigurationProvider对象,每一次对配置数据的读写操作最终都会转移到它们头上。当调用它的索引指定相应的Key获取对应配置项的值时,我们会将这组ConfigurationProvider对象进行逆向排序,并将指定的Key作为参数依次调用每个ConfigurationProvider的TryGet方法直到该方法返回True,并将这个方法返回的值最为索引最终的返回值。当我们利用索引对指定配置项的值进行设置的时候,实际上会调用每个ConfigurationProvider的Set方法。
1: public class ConfigurationRoot: IConfigurationRoot
2: {
3: private IList<IConfigurationProvider> providers;
4:
5: public ConfigurationRoot(IList<IConfigurationProvider> providers)
6: {
7: this.providers = providers;
8: providers.ForEach(provider => provider.Load());
9: }
10:
11: public string this[string key]
12: {
13: get
14: {
15: string value = null;
16: return providers.Reverse().Any(p => p.TryGet(key, out value))
17: ? value: null;
18: }
19: set
20: {
21: providers.ForEach(provider => provider.Set(key, value));
22: }
23: }
24:
25: public IEnumerable<IConfigurationSection> GetChildren()=> this.GetChildren(null);
26:
27: public IConfigurationSection GetSection(string key) => new ConfigurationSection(this, key);
28:
29: public void Reload() => providers.ForEach(provider => provider.Load());
30:
31: internal IEnumerable<IConfigurationSection> GetChildrenCore(string path)
32: {
33: return providers.Aggregate(Enumerable.Empty<string>(),
34: (seed, source) => source.GetChildKeys(seed, path, ":"))
35: .Distinct()
36: .Select(key => GetSection(ConfigurationPath.Combine(path, key)));
37: }
38: //其它成员
39: }
ConfigurationRoot实现在索引中读取配置的逻辑体现了配置模型一个重要的特性,那就是如果某个配置项的数据具有多个来源,那么最后添加到ConfigurationBuilder中的ConfigurationProvider具有更高的优先级,我们姑且将这个特性称为ConfigurationProvider“后来居上”的原则。如果希望覆盖应用现有的某个配置,我们只需要将提供新配置的ConfigurationProvider添加到ConfigurationBuilder之上即可。
我们定义了一个辅助方法GetChildrenCore来获取某个配置节的所有子配置节,这个指定的配置节通过作为参数的路径来表示。当这个方法执行之后,所有ConfigurationProvider的GetChildKeys方法会被调用以获取所有子配置节的Key,我们利用它们生成表示配置节的ConfigurationSection对象。在实现的GetChildren方法中,我们会调用这个方法来获取隶属于自己的所有子配置节。而另一个GetSection方法中,我们直接返回根据指定路径(对于表示根配置节来说,参数key表示配置节的路径)创建的ConfigurationSection对象。
ConfigurationSection
在上面关于用于模拟ConfigurationRoot类型定义的代码中我们知道最终表示非根配置节的ConfigurationSection对象是根据它的路径和作为根配置节的ConfigurationRoot对象创建的。ConfigurationRoot将配置的读写操作递交给相应的ConfigurationProvider来完成,而ConfigurationSection则将委托自己的根配置节来完成读写配置的操作,这样的策略体现在如下所示的代码中。
1: public class ConfigurationSection: IConfigurationSection
2: {
3: private ConfigurationRoot root;
4: private string key;
5:
6: public string Key
7: {
8: get { return key ?? (key = ConfigurationPath.GetSectionKey(this.Path)); }
9: }
10: public string Path { get; private set; }
11: public string Value { get; set; }
12: public string this[string key]
13: {
14: get
15: {
16: return root[this.Path];
17: }
18:
19: set
20: {
21: root[this.Path] = Value;
22: }
23: }
24:
25: public ConfigurationSection(ConfigurationRoot root, string path)
26: {
27: this.root = root;
28: this.Path = path;
29: }
30:
31: public IConfigurationSection GetSection(string key) => root.GetSection(ConfigurationPath.Combine(this.Path, key));
32: public IEnumerable<IConfigurationSection> GetChildren() => root.GetChildren(this.Path);
33: //其它成员
34: }
[1] ForEach是为了让代码尽量精练而为类型IEnumerable<T>定义的扩展方法,在后续章节中我们会经常使用到它。
ASP.NET Core的配置(1):读取配置信息
ASP.NET Core的配置(2):配置模型详解
ASP.NET Core的配置(3): 将配置绑定为对象[上篇]
ASP.NET Core的配置(3): 将配置绑定为对象[下篇]
ASP.NET Core的配置(4):多样性的配置源[上篇]
ASP.NET Core的配置(4):多样性的配置源[下篇]
ASP.NET Core的配置(5):配置的同步