
Todo programador se imaginou - bem, ou pode querer se imaginar - como um piloto de avião, quando você tem um grande projeto, um enorme painel de sensores, métricas e interruptores para ele, com os quais você pode configurar facilmente tudo como deveria. Bem, pelo menos não correndo para levantar manualmente o chassi. Ambas as métricas e os gráficos são bons, mas hoje eu quero falar sobre esses mesmos copos e botões que podem alterar os parâmetros de comportamento da aeronave, configure-os.
A importância das configurações é difícil de subestimar. Todo mundo usa uma ou outra abordagem para configurar seus aplicativos e, em princípio, não há nada de complicado nisso, mas é realmente tão simples? Proponho olhar o "antes" e o "depois" da configuração e entender os detalhes: como funciona, quais são os novos recursos que temos e como usá-los ao máximo. Aqueles que não estão familiarizados com a configuração no .NET Core aprenderão o básico, e aqueles que estão familiarizados terão comida para pensar e usar novas abordagens em seu trabalho diário.
Configuração Pré- .NET Core
Em 2002, o .NET Framework foi introduzido, e como era a época do entusiasmo do XML, os desenvolvedores da Microsoft decidiram “vamos tê-lo em todos os lugares” e, como resultado, temos configurações XML que ainda estão vivas. No início da tabela, temos uma classe ConfigurationManager estática, por meio da qual obtemos representações de strings de valores de parâmetros. A configuração em si era mais ou menos assim:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="Title" value=".NET Configuration evo" />
<add key="MaxPage" value="10" />
</appSettings>
</configuration>
O problema foi resolvido, os desenvolvedores ganharam uma opção de customização, que é melhor que os arquivos INI, mas com peculiaridades próprias. Assim, por exemplo, o suporte para diferentes valores de configurações para diferentes tipos de ambientes de aplicativos é implementado usando transformações XSLT do arquivo de configuração. Podemos definir nossos próprios esquemas XML para elementos e atributos se quisermos algo mais complexo em termos de agrupamento de dados. Os pares de valores-chave têm um tipo estritamente de string e, se precisarmos de um número ou data, "vamos fazer você mesmo de alguma forma":
string title = ConfigurationManager.AppSettings["Title"];
int maxPage = int.Parse(ConfigurationManager.AppSettings["MaxPage"]);
Em 2005 adicionamos seções de configuração , que permitiam agrupar parâmetros, construindo seus próprios esquemas, evitando conflitos de nomenclatura. Também apresentamos arquivos * .settings e um designer especial para eles.

Agora você pode obter uma classe gerada e fortemente tipada que representa os dados de configuração. O designer permite que você edite convenientemente os valores, a classificação por colunas do editor está disponível. Os dados são recuperados usando a propriedade Default da classe gerada, que fornece o objeto de configuração Singleton.
DateTime date = Properties.Settings.Default.CustomDate;
int displayItems = Properties.Settings.Default.MaxDisplayItems;
string name = Properties.Settings.Default.ApplicationName;
Também adicionamos escopos de valores de parâmetro de configuração. A área do usuário é responsável pelos dados do usuário, que podem ser alterados por ele e salvos durante a execução do programa. O salvamento ocorre em um arquivo separado ao longo do caminho% AppData% \ * Nome do aplicativo *. O escopo do aplicativo permite que você recupere valores de parâmetro sem ser redefinido pelo usuário.
Apesar das boas intenções, tudo ficou mais complicado.
- Na verdade, esses são os mesmos arquivos XML que começaram a aumentar de tamanho mais rapidamente e, como resultado, tornaram-se inconvenientes para ler.
- A configuração é lida do arquivo XML uma vez e precisamos recarregar o aplicativo para aplicar as alterações aos dados de configuração.
- As classes geradas a partir de arquivos * .settings foram marcadas com o modificador selado, portanto, essa classe não pôde ser herdada. Além disso, esse arquivo pode ser alterado, mas se ocorrer uma regeneração, perdemos tudo o que escrevemos.
- Trabalhar com dados apenas de acordo com o esquema de valor-chave. Para obter uma abordagem estruturada para trabalhar com configurações, precisamos implementar isso nós mesmos.
- A fonte de dados pode ser apenas um arquivo; provedores externos não são suportados.
- Além disso, temos um fator humano - parâmetros privados entram no sistema de controle de versão e ficam expostos.
Todos esses problemas permanecem no .NET Framework até hoje.
Configuração do .NET Core
No .NET Core, eles reinventaram a configuração e criaram tudo do zero, removeram a classe estática do ConfigurationManager e resolveram muitos dos problemas que existiam "antes". O que nós ganhamos de novo? Como antes - a fase de geração dos dados de configuração e a fase de consumo desses dados, mas com um ciclo de vida mais flexível e estendido.
Definir e preencher com dados de configuração
Assim, para a etapa de geração de dados, podemos utilizar várias fontes, não nos limitando apenas aos arquivos. A configuração é feita através do IConfgurationBuilder - a base para a qual podemos adicionar fontes de dados. Os pacotes NuGet estão disponíveis para vários tipos de fontes:
Formato | Método de extensão para adicionar fonte ao IConfigurationBuilder | Pacote NuGet |
Json | AddJsonFile | Microsoft.Extensions.Configuration.Json |
XML | AddXmlFile | Microsoft.Extensions.Configuration.Xml |
INI | AddIniFile | Microsoft.Extensions.Configuration.Ini |
Argumentos de linha de comando | AddCommandLine | Microsoft.Extensions.Configuration.CommandLine |
Variáveis ambientais | AddEnvironmentVariables | Microsoft.Extensions.Configuration.EnvironmentVariables |
Segredos do usuário | AddUserSecrets | Microsoft.Extensions.Configuration.UserSecrets |
KeyPerFile | AddKeyPerFile | Microsoft.Extensions.Configuration.KeyPerFile |
Azure KeyVault | AddAzureKeyVault | Microsoft.Extensions.Configuration.AzureKeyVault |
Cada fonte é adicionada como uma nova camada e substitui os parâmetros com chaves correspondentes. Aqui está o exemplo Program.cs que vem por padrão no modelo de aplicativo ASP.NET Core (versão 3.1).
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder =>
{ webBuilder.UseStartup<Startup>(); });
Eu quero me concentrar em CreateDefaultBuilder . Dentro do método, veremos como ocorre a configuração inicial das fontes.
public static IWebHostBuilder CreateDefaultBuilder(string[] args)
{
var builder = new WebHostBuilder();
...
builder.ConfigureAppConfiguration((hostingContext, config) =>
{
IHostingEnvironment env = hostingContext.HostingEnvironment;
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
if (env.IsDevelopment())
{
Assembly appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
if (appAssembly != null)
{
config.AddUserSecrets(appAssembly, optional: true);
}
}
config.AddEnvironmentVariables();
if (args != null)
{
config.AddCommandLine(args);
}
})
...
return builder;
}
Portanto, concluímos que a base para toda a configuração será o arquivo appsettings.json; além disso, se houver um arquivo para um ambiente específico, ele terá uma prioridade mais alta e, portanto, substituirá os valores correspondentes da base. E o mesmo acontece com cada fonte subsequente. A ordem de adição afeta o valor final. Visualmente, tudo se parece com isto:

Se quiser usar seu pedido, você pode simplesmente cancelá-lo e definir como precisa dele.
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); })
.ConfigureAppConfiguration((context,
builder) =>
{
builder.Sources.Clear();
//
});
Cada fonte de configuração tem duas partes:
- Implementação de IConfigurationSource. Fornece uma fonte de valores de configuração.
- Implementação de IConfigurationProvider. Converte os dados originais no valor-chave resultante.
Implementando esses componentes, podemos obter nossa própria fonte de dados para configuração. Aqui está um exemplo de como você pode implementar a obtenção de parâmetros de um banco de dados por meio do Entity Framework.
Como usar e recuperar dados
Agora que tudo está claro com a definição e o preenchimento dos dados de configuração, proponho dar uma olhada em como podemos usar esses dados e como obtê-los de forma mais conveniente. A nova abordagem para configurar projetos apresenta uma grande tendência para o popular formato JSON, e isso não é surpreendente, porque com sua ajuda podemos construir qualquer estrutura de dados, agrupar dados e ter um arquivo legível ao mesmo tempo. Vamos pegar o seguinte arquivo de configuração, por exemplo:
{
"Features" : {
"Dashboard" : {
"Title" : "Default dashboard",
"EnableCurrencyRates" : true
},
"Monitoring" : {
"EnableRPSLog" : false,
"EnableStorageStatistic" : true,
"StartTime": "09:00"
}
}
}
Todos os dados formam um dicionário de valor-chave simples, a chave de configuração é formada a partir de toda a hierarquia de chaves de arquivo para cada valor. Uma estrutura semelhante teria o seguinte conjunto de dados:
Recursos: Painel: Título | Painel padrão |
Recursos: Painel: EnableCurrencyRates | verdade |
Características: Monitoramento: EnableRPSLog | falso |
Recursos: Monitoramento: EnableStorageStatistic | verdade |
Características: Monitoramento: StartTime | 09:00 |
Podemos obter o valor usando o objeto IConfiguration . Por exemplo, veja como podemos obter os parâmetros:
string title = Configuration["Features:Dashboard:Title"];
string title1 = Configuration.GetValue<string>("Features:Dashboard:Title");
bool currencyRates = Configuration.GetValue<bool>("Features:Dashboard:EnableCurrencyRates");
bool enableRPSLog = Configuration.GetValue<bool>("Features:Monitoring:EnableRPSLog");
bool enableStorageStatistic = Configuration.GetValue<bool>("Features:Monitoring:EnableStorageStatistic");
TimeSpan startTime = Configuration.GetValue<TimeSpan>("Features:Monitoring:StartTime");
E isso já não é ruim, temos uma boa maneira de obter dados que são convertidos para o tipo de dados necessário, mas de alguma forma não tão legal quanto gostaríamos. Se recebermos os dados fornecidos acima, acabaremos com um código duplicado e cometeremos erros nos nomes das chaves. Em vez de valores individuais, você pode montar um objeto de configuração completo. Vincular dados a um objeto por meio do método Bind nos ajudará com isso. Exemplo de classe e recuperação de dados:
public class MonitoringConfig
{
public bool EnableRPSLog { get; set; }
public bool EnableStorageStatistic { get; set; }
public TimeSpan StartTime { get; set; }
}
var monitorConfiguration = new MonitoringConfig();
Configuration.Bind("Features:Monitoring", monitorConfiguration);
var monitorConfiguration1 = new MonitoringConfig();
IConfigurationSection configurationSection = Configuration.GetSection("Features:Monitoring");
configurationSection.Bind(monitorConfiguration1);
No primeiro caso, vinculamos pelo nome da seção e, no segundo, obtemos uma seção e vinculamos a partir dela. A seção permite que você trabalhe com uma visão parcial da configuração - desta forma, você pode controlar o conjunto de dados com o qual trabalhamos. As seções também são usadas em métodos de extensão padrão - por exemplo, obter uma string de conexão usa a seção "ConnectionStrings".
string connectionString = Configuration.GetConnectionString("Default");
public static string GetConnectionString(this IConfiguration configuration, string name)
{
return configuration?.GetSection("ConnectionStrings")?[name];
}
Opções - visualização de configuração digitada
Criar um objeto de configuração manualmente e vincular aos dados não é prático, mas há uma solução na forma de usar Opções . As opções são usadas para obter uma visão fortemente tipada de uma configuração. A classe de visualização deve ser pública com um construtor sem parâmetros e propriedades públicas para atribuição de um valor, o objeto é preenchido por meio de reflexão. Mais detalhes podem ser encontrados na fonte .
Para começar a usar Opções, precisamos registrar o tipo de configuração usando o método Configure extension para IServiceCollection indicando a seção que iremos projetar em nossa classe.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
}
Depois disso, podemos receber configurações injetando uma dependência nas interfaces IOptions, IOptionsMonitor, IOptionsSnapshot. Podemos obter o objeto MonitoringConfig da interface IOptions por meio da propriedade Value.
public class ExampleService
{
private IOptions<MonitoringConfig> _configuration;
public ExampleService(IOptions<MonitoringConfig> configuration)
{
_configuration = configuration;
}
public void Run()
{
TimeSpan timeSpan = _configuration.Value.StartTime; // 09:00
}
}
Um recurso da interface IOptions é que, no contêiner de injeção de dependência, a configuração é registrada como um objeto com o ciclo de vida do Singleton. Na primeira vez que um valor é solicitado pela propriedade Value, um objeto é inicializado com os dados que existem enquanto este objeto existe. IOptions não suporta atualização de dados. Existem interfaces IOptionsSnapshot e IOptionsMonitor para oferecer suporte a atualizações.
IOptionsSnapshot no container DI é registrado com o ciclo de vida Scoped, o que torna possível obter um novo objeto de configuração a pedido com um novo escopo de container. Por exemplo, durante uma solicitação da web receberemos o mesmo objeto, mas para uma nova solicitação receberemos um novo objeto com dados atualizados.
IOptionsMonitor é registrado como um Singleton, com a única diferença de que cada configuração é recebida com os dados reais no momento da solicitação. Além disso, IOptionsMonitor permite que você registre um manipulador de eventos de alteração de configuração se você precisar responder ao próprio evento de alteração de dados.
public class ExampleService
{
private IOptionsMonitor<MonitoringConfig> _configuration;
public ExampleService(IOptionsMonitor<MonitoringConfig> configuration)
{
_configuration = configuration;
configuration.OnChange(config =>
{
Console.WriteLine(" ");
});
}
public void Run()
{
TimeSpan timeSpan = _configuration.CurrentValue.StartTime; // 09:00
}
}
Também é possível obter IOptionsSnapshot e IOptionsMontitor pelo nome - isso é necessário se você tiver várias seções de configuração correspondentes a uma classe e quiser obter uma específica. Por exemplo, temos os seguintes dados:
{
"Cache": {
"Main": {
"Type": "global",
"Interval": "10:00"
},
"Partial": {
"Type": "personal",
"Interval": "01:30"
}
}
}
O tipo a ser usado para a projeção:
public class CachePolicy
{
public string Type { get; set; }
public TimeSpan Interval { get; set; }
}
Registramos configurações com um nome específico:
services.Configure<CachePolicy>("Main", Configuration.GetSection("Cache:Main"));
services.Configure<CachePolicy>("Partial", Configuration.GetSection("Cache:Partial"));
Podemos receber valores da seguinte forma:
public class ExampleService
{
public ExampleService(IOptionsSnapshot<CachePolicy> configuration)
{
CachePolicy main = configuration.Get("Main");
TimeSpan mainInterval = main.Interval; // 10:00
CachePolicy partial = configuration.Get("Partial");
TimeSpan partialInterval = partial.Interval; // 01:30
}
}
Se você olhar o código-fonte do método de extensão com o qual registramos o tipo de configuração, você pode ver que o nome padrão é Options.Default, que é uma string vazia. Então, implicitamente, sempre passamos o nome para as configurações.
public static IServiceCollection Configure<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
=> services.Configure<TOptions>(Options.Options.DefaultName, config);
Como a configuração pode ser representada por uma classe, também podemos adicionar validação de valor de parâmetro marcando as propriedades usando atributos de validação do namespace System.ComponentModel.DataAnnotations. Por exemplo, especificamos que o valor da propriedade Type deve ser obrigatório. Mas também precisamos indicar ao registrar a configuração que a validação deve ocorrer em princípio. Há um método de extensão ValidateDataAnnotations para isso.
public class CachePolicy
{
[Required]
public string Type { get; set; }
public TimeSpan Interval { get; set; }
}
services.AddOptions<CachePolicy>()
.Bind(Configuration.GetSection("Cache:Main"))
.ValidateDataAnnotations();
A peculiaridade de tal validação é que acontecerá apenas no momento do recebimento do objeto de configuração. Isso torna difícil entender que a configuração não é válida quando o aplicativo é iniciado. Há um problema no GitHub para esse problema . Uma solução para esse problema pode ser a abordagem apresentada no artigo Adicionando validação a objetos de configuração fortemente tipados no ASP.NET Core.
Desvantagens das opções e como contorná-las
Configurar por meio de Opções também tem suas desvantagens. Para uso, precisamos adicionar uma dependência e, a cada vez, precisamos acessar a propriedade Value / CurrentValue para obter um objeto de valor. Você pode obter um código mais limpo obtendo um objeto de configuração limpo sem o invólucro Opções. A solução mais simples para o problema pode ser o registro adicional no contêiner de uma dependência de tipo de configuração pura.
services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
services.AddScoped<MonitoringConfig>(provider => provider.GetRequiredService<IOptionsSnapshot<MonitoringConfig>>().Value);
A solução é direta, não forçamos o código final a saber sobre IOptions, mas perdemos a flexibilidade para ações de configuração adicionais se precisarmos delas. Para resolver este problema, podemos usar o padrão "Bridge", que nos permitirá obter uma camada adicional na qual podemos realizar ações adicionais antes de receber o objeto.
Para atingir esse objetivo, precisamos refatorar o código de exemplo atual. Como a classe de configuração possui uma restrição na forma de um construtor sem parâmetros, não podemos passar o objeto IOptions / IOptionsSnapshot / IOptionsMontitor para o construtor; para isso, separaremos a leitura da configuração da visualização final.
Por exemplo, digamos que desejamos especificar a propriedade StartTime da classe MonitoringConfig com uma representação de string de minutos com um valor de "09", que não se ajusta ao formato padrão.
public class MonitoringConfigReader
{
public bool EnableRPSLog { get; set; }
public bool EnableStorageStatistic { get; set; }
public string StartTime { get; set; }
}
public interface IMonitoringConfig
{
bool EnableRPSLog { get; }
bool EnableStorageStatistic { get; }
TimeSpan StartTime { get; }
}
public class MonitoringConfig : IMonitoringConfig
{
public MonitoringConfig(IOptionsMonitor<MonitoringConfigReader> option)
{
MonitoringConfigReader reader = option.Value;
EnableRPSLog = reader.EnableRPSLog;
EnableStorageStatistic = reader.EnableStorageStatistic;
StartTime = GetTimeSpanValue(reader.StartTime);
}
public bool EnableRPSLog { get; }
public bool EnableStorageStatistic { get; }
public TimeSpan StartTime { get; }
private static TimeSpan GetTimeSpanValue(string value) => TimeSpan.ParseExact(value, "mm", CultureInfo.InvariantCulture);
}
Para conseguir uma configuração limpa, precisamos registrá-la no contêiner de injeção de dependência.
services.Configure<MonitoringConfigReader>(Configuration.GetSection("Features:Monitoring"));
services.AddTransient<IMonitoringConfig, MonitoringConfig>();
Essa abordagem permite que você crie um ciclo de vida completamente separado para a formação de um objeto de configuração. É possível adicionar sua própria validação de dados ou, adicionalmente, implementar o estágio de descriptografia de dados se você os receber em formato criptografado.
Garantindo a segurança dos dados
Uma importante tarefa de configuração é a segurança dos dados. As configurações de arquivo são inseguras porque os dados são armazenados em texto não criptografado, de fácil leitura; frequentemente, os arquivos estão no mesmo diretório do aplicativo. Por engano, você pode enviar valores para o sistema de controle de versão, que pode desclassificar os dados, mas imagine se este for um código público! A situação é tão comum que existe até uma ferramenta pronta para encontrar tais vazamentos - Gitleaks . Há um artigo separado que fornece estatísticas e a variedade de dados divulgados.
Freqüentemente, um projeto deve ter parâmetros separados para ambientes diferentes (Release / Debug, etc.). Por exemplo, como uma das soluções, você pode usar a substituição de valores finais usando as ferramentas de integração e entrega contínua, mas esta opção não protege os dados durante o desenvolvimento. A ferramenta User Secrets foi projetada para proteger o desenvolvedor . Ele está incluído no .NET Core SDK (3.0.100 e superior). Qual é o princípio básico desta ferramenta? Primeiro, devemos inicializar nosso projeto para trabalhar com o comando init.
dotnet user-secrets init
O comando adiciona um elemento UserSecretsId ao arquivo de projeto .csproj. Com este parâmetro, obtemos um armazenamento privado que armazenará um arquivo JSON regular. A diferença é que ele não está localizado no diretório do seu projeto, portanto, estará disponível apenas no computador atual. O caminho para Windows é% APPDATA% \ Microsoft \ UserSecrets \ <user_secrets_id> \ secrets.json, e para Linux e MacOS ~ / .microsoft / usersecrets / <user_secrets_id> /secrets.json. Podemos adicionar o valor do exemplo acima com o comando set:
dotnet user-secrets set "Features:Monitoring:StartTime" "09:00"
Uma lista completa dos comandos disponíveis pode ser encontrada na documentação.
A segurança de dados na produção é melhor garantida usando armazenamento especializado, como: AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, Consul, ZooKeeper. Para conectar alguns, já existem pacotes NuGet prontos, e para alguns é fácil implementá-los você mesmo, uma vez que há acesso à API REST.
Conclusão
Problemas modernos requerem soluções modernas. Junto com a mudança de monólitos para infraestruturas dinâmicas, as abordagens de configuração também sofreram mudanças. Havia uma necessidade, independentemente da localização e do tipo de fontes de dados de configuração, a necessidade de uma resposta imediata às mudanças de dados. Junto com o .NET Core, temos uma boa ferramenta para implementar todos os tipos de cenários de configuração de aplicativos.