【.NET Core框架】配置(Configuration)

简介

.netcore中的配置模块可以将你的配置文件自动读取成一个树状结构(逻辑上是树状,实际上是扁平化的),这样你就可以方便快捷的获取配置数据了。
可使用多种类型数据源(json、内存、xml、ini、command、env...),还可以自定义配置源;
支持多环境版本、如果多次添加相同的配置,后添加的会覆盖之前添加的;
热加载,修改配置文件后可不重启项目,重新将文件加载到内存;

github: https://github.com/aspnet/Configuration/

树状结构(逻辑上)

当配置模块完成配置构建后会返回一个树状结构,这个树状结构我们称之为IConfiguration(IConfigurationRoot和IConfigurationSection都继承自IConfiguration),它的根节点是IConfigurationRoot、子节点是IConfigurationSection。子节点里封装了三个属性(Key、Path、Value),这种树状结构和注册表的组织形式很像。

涉及到的nuget包

Microsoft.Extensions.Configuration.Abstractions: 抽象定义;
Microsoft.Extensions.Configuration:配置模块顶层实现(包含从内存中提取配置);
Microsoft.Extensions.Configuration.Json:从json中提取配置
Microsoft.Extensions.Configuration.Xml:从xml中提取配置
Microsoft.Extensions.Configuration.EnvironmentVariables:从环境变量中提取配置;
Microsoft.Extensions.Configuration.CommandLine:从命令行中提取配置;
Microsoft.Extensions.Configuration.Ini:从ini文件中提取配置;
Microsoft.Extensions.Configuration.Binder:将配置数据绑定到对象;

添加数据源

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            var env = context.HostingEnvironment;
            config.AddJsonFile("connectionstring.json", optional: true, reloadOnChange: true)
                  .AddJsonFile($"connectionstring.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
        });

optional:bool类型,指示该文件是否是可选的。如果该参数为false,但是指定的文件又不存在,则会报错。
reloadOnChange:bool类型,指示该文件发生更改时,是否要重新加载配置。
除了json配置源还可以添加如下类型的配置源:

  • xml
config.AddXmlFile("appsettings.xml", optional: true, reloadOnChange: true);
  • ini
config.AddIniFile("appsettings.ini", optional: true, reloadOnChange: true);
  • 命令行
config.AddCommandLine(args);

有三种设置命令行参数的方式:
使用=:

dotnet run Book:Name="Command line book name"

使用/:

dotnet run /Book:Name "Command line book name" 

使用--:

dotnet WebApplication5.dll --Book:Name "Command line book name"

交换映射
该功能是针对命令行配置参数进行key映射的,如你可以将n映射为Name,要求:
交换映射key必须以-或--开头。当使用-开头时,命令行参数书写时也要以-开头,当使用--开头时,命令行参数书写时可以以--或/开头。
交换映射字典中的key不区分大小写,不能包含重复key。如不能同时出现-n和-N,但可以同时出现-n和--n
接下来我们来映射一下:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            var switchMappings = new Dictionary<string, string>
            {
                ["--bn"] = "Book:Name",
                ["-ba0"] = "Book:Authors:0",
                ["--ba1"] = "Book:Authors:1",
                ["--bmr"] = "Book:Bookmark:Remarks"
            };
            config.AddCommandLine(args, switchMappings);
        });

然后以命令行命令启动:

dotnet run --bn "Command line book name" -ba0 "Command line book author A" /ba1 "Command line book author B" --bmr="Command line bookmark remarks"
  • 环境变量
// 添加前缀为 My_ 的环境变量
config.AddEnvironmentVariables(prefix: "My_");

在添加环境变量时,通过指定参数prefix,只读取限定前缀的环境变量。不过在读取环境变量时,会将前缀删除。如果不指定参数prefix,那么会读取所有环境变量。
当创建默认通用主机(Host)时,默认就已经添加了前缀为DOTNET_的环境变量,加载应用配置时,也添加了未限定前缀的环境变量。另外,在 ASP.NET Core 中,配置 Web主机时,默认添加了前缀为ASPNETCORE_的环境变量。
需要注意的是,由于环境变量的分层键:并不受所有平台支持,而双下划线()是全平台支持的,所以要使用双下划线()来代替冒号(:),如下:

//Book__Name == Book.Name
set Book__Name "book name"
  • 内存
config.AddInMemoryCollection(new Dictionary<string, string>
            {
                ["Book:Name"] = "Memmory book name",
                ["Book:Authors:0"] = "Memory book author A",
                ["Book:Authors:1"] = "Memory book author B",
                ["Book:Bookmark:Remarks"] = "Memory bookmark remarks"
            });

.net core框架已经为我们添加了配置文件appsettings.json、appsettings.{EnvironmentName}.json,所以不需要重新添加

读取配置

弱类型读取

Configuration["Logging:Default:t1"]        //按层级取值,返回值是字符串类型

Configuration.GetSection("Ips").Value    //返回值是字符串类型

强类型读取(绑定)

虽然我们可以从IConfiguration中轻松的取出配置值,但是我们更倾向于将其转换成一个POCO对象,以面向对象的方式来使用配置,我们将这个转换过程称为配置绑定。
绑定相关的部分扩展方法定义在NuGet包“Microsoft.Extensions.Configuration.Binder”中

Settings settings = Configuration.Get<Settings>();//将Configuration转为Settings对象

string[] ips = Configuration.GetSection("Ips").Get<string[]>();//将Ips节点转为数组

LogLevel logLevel = Configuration.GetSection("Logging:LogLevel").Get<LogLevel>();//节点转为对象

int CACHE_TIME = configuration.GetValue<int>("CACHE_TIME", 20);//20是默认值,需要安装 Microsoft.Extensions.Configuration.Binder包

Bind:将Configuration绑定现有对象

Logging logging = new Logging();
Configuration.GetSection("Logging").Bind(logging);

自定义绑定转换器

[TypeConverter(typeof(PointConvertor))]
public class Point
{
    public double X { set; get; }
    public double Y { set; get; }
}
public class PointConvertor : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType == typeof(string);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        try
        {
            string str = value.ToString();
            var arr = str.Split(',');
            var x = double.Parse(arr[0]);
            var y = double.Parse(arr[1]);
            return new Point()
            {
                X = x,
                Y = y
            };
        }
        catch (Exception ex)
        {
            Console.WriteLine($"转换出错:{ex?.Message}");
            return null;
        }
    }
}
public class Person
{
    public Point pos { set; get; }
}

class Program
{
    public static void Main(string[] args)
    {
        var conf = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string>()
        {
            ["root:person:pos"] = "113.456784,27.234562"
        }).Build();
        var person = conf.GetSection("root:person").Get<Person>();
    }
}

绑定到字典

            var conf = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string>()
            {
                ["root:appname"] = "测试应用",
                ["root:appversion"] = "v1.0.0",
                ["root:person:小明:name"] = "小明",
                ["root:person:小明:age"] = "28",
                ["root:person:小明:id"] = "2",
                ["root:person:小红:name"] = "小红",
                ["root:person:小红:age"] = "24",
                ["root:person:小红:id"] = "3",
                ["root:student:0:id"] = "4",
                ["root:student:0:name"] = "张三",
                ["root:student:0:age"] = "10",
                ["root:student:1:id"] = "5",
                ["root:student:1:name"] = "李四",
                ["root:student:1:age"] = "11"
            }).Build();
            var dics = conf.GetSection("root:student").Get<IDictionary<string, Person>>();
            string json = JsonConvert.SerializeObject(dics);
            //{
            // "0":{"name":"张三","id":4,"age":10},
            // "1":{"name":"李四","id":5,"age":11}
            //}

            var dics2 = conf.GetSection("root:person").Get<IDictionary<string, Person>>();
            string json2 = JsonConvert.SerializeObject(dics2);
            //{
            // "小明":{"name":"小明","id":2,"age":28},
            // "小红":{"name":"小红","id":3,"age":24}
            //}

核心对象

  • IConfiguration:配置信息最终会转换成一个IConfiguration对象供应用程序使用
  • IConfigurationBuilder:IConfigurationBuilder是IConfiguration对象的构建者
  • IConfigurationSource:IConfigurationSource代表配置数据的来源,它会注册到IConfigurationBuilder对象上
  • IConfigurationProvider:向IConfiguration提供数据,IConfiguration内部包含IConfigurationProvider对象

以上对象的关系如图:

以上接口以及其他一些基础类型均定义在NuGet包“Microsoft.Extensions.Configuration.Abstractions”中。对这些接口的默认实现,则大多定义在“Microsoft.Extensions.Configuration”这个NuGet包中

IConfiguration

一个IConfiguration对象表示配置树的某个配置节点。表示根节点的IConfiguration对象与表示其它配置节点是不同的,配置模型采用不同的接口来表示它们。根节点使用IConfigurationRoot接口表示,其他节点使用IConfigurationSection接口表示,IConfigurationRoot和IConfigurationSection接口都是IConfiguration的继承者。

public interface IConfiguration
{
    IEnumerable<IConfigurationSection>     GetChildren();    
    IConfigurationSection GetSection(string key);//key:相对于当前配置节的路径  
    IChangeToken GetReloadToken();
   
    string this[string key] { get; set; }
}

IConfigurationRoot

Reload方法实现对配置数据的重新加载。IConfigurationRoot对象表示的配置树的根,所以也代表了整棵配置树,如果它被重新加载了,意味着整棵配置树承载的所有配置数据均被重新加载了。
IConfigurationRoot:

public interface IConfigurationRoot : IConfiguration
{
    void Reload();
}

ConfigurationRoot:

 public class ConfigurationRoot : IConfigurationRoot, IDisposable
    {
        private readonly IList<IConfigurationProvider> _providers;
        private readonly IList<IDisposable> _changeTokenRegistrations;
        private ConfigurationReloadToken _changeToken = new ConfigurationReloadToken();

        /// <param name="providers">The <see cref="IConfigurationProvider"/>s for this configuration.</param>
        public ConfigurationRoot(IList<IConfigurationProvider> providers)
        {
            if (providers == null)
            {
                throw new ArgumentNullException(nameof(providers));
            }

            _providers = providers;
            _changeTokenRegistrations = new List<IDisposable>(providers.Count);
            foreach (IConfigurationProvider p in providers)
            {
                p.Load();
                _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
            }
        }

        public IEnumerable<IConfigurationProvider> Providers => _providers;

        public string this[string key]
        {
            get
            {
                for (int i = _providers.Count - 1; i >= 0; i--)
                {
                    IConfigurationProvider provider = _providers[i];

                    if (provider.TryGet(key, out string value))
                    {
                        return value;
                    }
                }

                return null;
            }
            set
            {
                if (!_providers.Any())
                {
                    throw new InvalidOperationException(SR.Error_NoSources);
                }

                foreach (IConfigurationProvider provider in _providers)
                {
                    provider.Set(key, value);
                }
            }
        }

        public IEnumerable<IConfigurationSection> GetChildren() => this.GetChildrenImplementation(null);

        public IChangeToken GetReloadToken() => _changeToken;

        public IConfigurationSection GetSection(string key)
            => new ConfigurationSection(this, key);

        public void Reload()
        {
            foreach (IConfigurationProvider provider in _providers)
            {
                provider.Load();
            }
            RaiseChanged();
        }

        private void RaiseChanged()
        {
            ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _changeToken, new ConfigurationReloadToken());
            previousToken.OnReload();
        }

        public void Dispose()
        {
            // dispose change token registrations
            foreach (IDisposable registration in _changeTokenRegistrations)
            {
                registration.Dispose();
            }

            // dispose providers
            foreach (IConfigurationProvider provider in _providers)
            {
                (provider as IDisposable)?.Dispose();
            }
        }
    }

IConfigurationSection

Key用来唯一标识多个具有相同父节点的ConfigurationSection对象
Path表示当前配置节点在配置树中的路径
Value表示配置节点承载的配置数据,只有配置树的叶子结点对应的IConfigurationSection对象才具有值
IConfigurationSection:

public interface IConfigurationSection : IConfiguration
{    
    string Path { get; }
    string Key { get; }
    string Value { get; set; }
}

ConfigurationSection:

public class ConfigurationSection : IConfigurationSection
    {
        private readonly IConfigurationRoot _root;
        private readonly string _path;
        private string _key;

        public ConfigurationSection(IConfigurationRoot root, string path)
        {
            if (root == null)
            {
                throw new ArgumentNullException(nameof(root));
            }

            if (path == null)
            {
                throw new ArgumentNullException(nameof(path));
            }

            _root = root;
            _path = path;
        }

        /// <summary>
        /// 获取到此Section的完整路径
        /// </summary>
        public string Path => _path;

        /// <summary>
        /// 获取此Section在其父节点中占用的键。
        /// </summary>
        public string Key
        {
            get
            {
                if (_key == null)
                {
                    _key = ConfigurationPath.GetSectionKey(_path);
                }
                return _key;
            }
        }

        /// <summary>
        /// Gets or sets the section value.
        /// </summary>
        public string Value
        {
            get
            {
                return _root[Path];
            }
            set
            {
                _root[Path] = value;
            }
        }

        public string this[string key]
        {
            get
            {
                return _root[ConfigurationPath.Combine(Path, key)];
            }

            set
            {
                _root[ConfigurationPath.Combine(Path, key)] = value;
            }
        }

        public IConfigurationSection GetSection(string key) => _root.GetSection(ConfigurationPath.Combine(Path, key));

        public IEnumerable<IConfigurationSection> GetChildren() => _root.GetChildrenImplementation(Path);

        public IChangeToken GetReloadToken() => _root.GetReloadToken();
    }

IConfigurationProvider

IConfigurationProvider对象的目的在于将配置从原始结构转换成配置字典,配置数据的加载通过调用IConfigurationProvider的Load方法来完成。
IConfigurationProvider的GetChildKeys方法用于获取某个指定配置节点(对应于parentPath参数)的所有子节点的Key。当IConfiguration的GetChildren方法被调用时,注册的所有IConfigurationSource对应的IConfigurationProvider的GetChildKeys方法会被调用。这个方法的第一个参数earlierKeys代表的Key来源于其他IConfigurationProvider,当解析出当前IConfigurationProvider提供的Key后,该方法需要对它们合并到earlierKeys集合中,合并后结果将作为方法的返回值。值得一提的是,返回的Key的集合是经过排序的。

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();
}

ConfigurationProvider:
每种类型的配置源都具有对应的IConfigurationProvider实现,它们一般不会直接实现接口IConfigurationProvider,而会选择继承另一个名为ConfigurationProvider的抽象类。
这个基类把配置源里面的数据都放在了一个字典(IDictionary<string, string> Data)中,这样凡是需要获取配置数据的时候就会遍历这个字典,找到后就返回。

public abstract class ConfigurationProvider : IConfigurationProvider
    {
        private ConfigurationReloadToken _reloadToken = new ConfigurationReloadToken();

        protected ConfigurationProvider()
        {
            Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }

        protected IDictionary<string, string> Data { get; set; }

        public virtual bool TryGet(string key, out string value)
            => Data.TryGetValue(key, out value);

        public virtual void Set(string key, string value)
            => Data[key] = value;

        /// <summary>
        /// Loads (or reloads) the data for this provider.
        /// </summary>
        public virtual void Load()
        { }

        //返回此提供程序拥有的键的列表。
        public virtual IEnumerable<string> GetChildKeys(
            IEnumerable<string> earlierKeys,
            string parentPath)
        {
            string prefix = parentPath == null ? string.Empty : parentPath + ConfigurationPath.KeyDelimiter;

            return Data
                .Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
                .Select(kv => Segment(kv.Key, prefix.Length))
                .Concat(earlierKeys)
                .OrderBy(k => k, ConfigurationKeyComparer.Instance);
        }

        private static string Segment(string key, int prefixLength)
        {
            int indexOf = key.IndexOf(ConfigurationPath.KeyDelimiter, prefixLength, StringComparison.OrdinalIgnoreCase);
            return indexOf < 0 ? key.Substring(prefixLength) : key.Substring(prefixLength, indexOf - prefixLength);
        }

        public IChangeToken GetReloadToken()
        {
            return _reloadToken;
        }

        protected void OnReload()
        {
            ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _reloadToken, new ConfigurationReloadToken());
            previousToken.OnReload();
        }

        public override string ToString() => $"{GetType().Name}";
    }

IConfigurationSource

IConfiurationSource代表配置源,它为IConfigurationBuilder提供原始的配置数据,每种不同类型的配置源都具有一个对应的IConfigurationSource实现。由于针对原始配置数据的读取实现在相应的IConfigurationProvider中,所以IConfigurationSource所起的作用在于提供相应的IConfigurationProvider。

public interface IConfigurationSource
{
    IConfigurationProvider Build(IConfigurationBuilder builder);
}

MemoryConfigurationSource:

//内存中的配置源
public class MemoryConfigurationSource : IConfigurationSource
{
	public IEnumerable<KeyValuePair<string, string>> InitialData
	{
		get;
		set;
	}
	public IConfigurationProvider Build(IConfigurationBuilder builder)
	{
		return new MemoryConfigurationProvider(this);
	}
}

IConfigurationBuilder

它通过IConfigurationSource创建IConfiguration对象
IConfigurationBuilder:

public interface IConfigurationBuilder
{
    IEnumerable<IConfigurationSource> Sources { get; }
    Dictionary<string, object> Properties { get; }

    IConfigurationBuilder Add(IConfigurationSource source);
    IConfigurationRoot Build();
}

ConfigurationBuilder:
这个类负责搜集配置源,将配置源Build后获取到provider,最后将这些provider放进新创建的IConfigurationRoot中

public class ConfigurationBuilder : IConfigurationBuilder
{
	public IList<IConfigurationSource> Sources
	{
		get;
	} = new List<IConfigurationSource>();
	public IDictionary<string, object> Properties
	{
		get;
	} = new Dictionary<string, object>();
	public IConfigurationBuilder Add(IConfigurationSource source)
	{
		if (source == null)
		{
			throw new ArgumentNullException("source");
		}
		Sources.Add(source);
		return this;
	}
	public IConfigurationRoot Build()
	{
		List<IConfigurationProvider> list = new List<IConfigurationProvider>();
		foreach (IConfigurationSource source in Sources)
		{
			IConfigurationProvider item = source.Build(this);
			list.Add(item);
		}
		return new ConfigurationRoot(list);
	}
}

案例

1、遍历数组节点

{"Configs": [
      {
        "Name": "n1"
      }
    ]
}
private void LoadConfigs(IConfiguration configuration)
        {
            var configs = configuration.GetSection("Configs");
            var configCount = configs.GetChildren().Count();
            for (int i = 0; i < configCount; i++)
            {
                var config = configs.GetSection(i.ToString());
                string name = config.GetValue<string>("Name");
            }
        }
    }

2、自定义数据源

数据源是一个加密的文件

ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddCryptoFile(Path.Combine(AppContext.BaseDirectory, "appsettings.json.crypto"), true);
});

ConfigurationBuilderExtension

    public static class ConfigurationBuilderExtension
    {
        public static IConfigurationBuilder AddCryptoFile(this IConfigurationBuilder configurationBuilder, string path, bool reloadOnChange)
        {
            configurationBuilder.Add(new CryptoFileConfigurationSource(path, reloadOnChange));
            return configurationBuilder;
        }
    }

CryptoFileConfigurationSource

    public class CryptoFileConfigurationSource : IConfigurationSource
    {
        /// <summary>
        /// 路径
        /// </summary>
        public string Path { get; set; }
        /// <summary>
        /// 是否启用热加载
        /// </summary>
        public bool ReloadOnChange { get; set; }
        public CryptoFileConfigurationSource(string path, bool reloadOnChange)
        {
            Path = path;
            ReloadOnChange = reloadOnChange;
        }
        public IConfigurationProvider Build(IConfigurationBuilder builder)
        {
            return new CryptoFileConfigurationProvider(this);
        }
    }

CryptoFileConfigurationProvider

    public class CryptoFileConfigurationProvider : ConfigurationProvider
    {
        private static ConcurrentDictionary<string, string> CONFIG_CACHE = new ConcurrentDictionary<string, string>();
        private CryptoFileConfigurationSource _source = null;
        private string _random = string.Empty;
        public CryptoFileConfigurationProvider(CryptoFileConfigurationSource source)
        {
            this._source = source;
            string fileName = IOHelper.GetFileNameNoPath(_source.Path);
            string filePath = _source.Path.Replace(fileName, string.Empty);
            IFileProvider fileProvider = new PhysicalFileProvider(filePath);
            if (_source.ReloadOnChange)
            {
                ChangeToken.OnChange(
                      () => fileProvider.Watch(fileName),
                      () =>
                      {
                          Load();
                      });
            }
            else
            {
                Load();
            }
        }

        public override void Load()
        {
            if (!IOHelper.IsExistFilePath(_source.Path))
            {
                throw new ArgumentException($"{_source.Path}路径不存在");
            }
            string cryptoContent = IOHelper.GetFileContent(_source.Path);
            //解密cryptoContent,获取json
            string json = AESCryptoHelper.Decrypt(cryptoContent);
            byte[] bytes = Encoding.UTF8.GetBytes(json);
            MemoryStream ms = new MemoryStream(bytes);
            this.Data = JsonConfigurationFileParser.Parse(ms);
        }
    }

JsonConfigurationFileParser

    internal class JsonConfigurationFileParser
    {
        private JsonConfigurationFileParser() { }

        private readonly IDictionary<string, string> _data = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        private readonly Stack<string> _context = new Stack<string>();
        private string _currentPath;

        private JsonTextReader _reader;

        public static IDictionary<string, string> Parse(Stream input)
            => new JsonConfigurationFileParser().ParseStream(input);

        private IDictionary<string, string> ParseStream(Stream input)
        {
            _data.Clear();
            _reader = new JsonTextReader(new StreamReader(input));
            _reader.DateParseHandling = DateParseHandling.None;

            var jsonConfig = JObject.Load(_reader);

            VisitJObject(jsonConfig);

            return _data;
        }

        private void VisitJObject(JObject jObject)
        {
            foreach (var property in jObject.Properties())
            {
                EnterContext(property.Name);
                VisitProperty(property);
                ExitContext();
            }
        }

        private void VisitProperty(JProperty property)
        {
            VisitToken(property.Value);
        }

        private void VisitToken(JToken token)
        {
            switch (token.Type)
            {
                case JTokenType.Object:
                    VisitJObject(token.Value<JObject>());
                    break;

                case JTokenType.Array:
                    VisitArray(token.Value<JArray>());
                    break;

                case JTokenType.Integer:
                case JTokenType.Float:
                case JTokenType.String:
                case JTokenType.Boolean:
                case JTokenType.Bytes:
                case JTokenType.Raw:
                case JTokenType.Null:
                    VisitPrimitive(token.Value<JValue>());
                    break;

                default:
                    throw new Exception("format error");
            }
        }

        private void VisitArray(JArray array)
        {
            for (int index = 0; index < array.Count; index++)
            {
                EnterContext(index.ToString());
                VisitToken(array[index]);
                ExitContext();
            }
        }

        private void VisitPrimitive(JValue data)
        {
            var key = _currentPath;

            if (_data.ContainsKey(key))
            {
                throw new Exception("format error");
            }
            _data[key] = data.ToString(CultureInfo.InvariantCulture);
        }

        private void EnterContext(string context)
        {
            _context.Push(context);
            _currentPath = ConfigurationPath.Combine(_context.Reverse());
        }

        private void ExitContext()
        {
            _context.Pop();
            _currentPath = ConfigurationPath.Combine(_context.Reverse());
        }
    }

参考:
https://www.cnblogs.com/artech/p/inside-asp-net-core-05-09.html
https://blog.csdn.net/u010476739/article/details/105856032

posted @ 2020-12-09 21:45  .Neterr  阅读(2256)  评论(0编辑  收藏  举报