Mas está tudo tão tranquilo? Sob o corte, quero desmontar e oferecer uma solução para um problema específico.

Formulação do problema
Observação: presume-se que todo o código neste artigo será compilado com os parâmetros do projeto:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
Suponha que queremos escrever uma classe que tenha um conjunto específico de parâmetros de que precisa para funcionar:
public sealed class SomeClient
{
private readonly SomeClientOptions options;
public SomeClient(SomeClientOptions options)
{
this.options = options;
}
public void SendSomeRequest()
{
Console.WriteLine($"Do work with { this.options.Login.ToLower() }" +
$" and { this.options.CertificatePath.ToLower() }");
}
}
Assim, gostaríamos de declarar algum tipo de contrato e dizer ao código do cliente que ele não deve passar Login e CertificatePath com valores nulos. Portanto, a classe SomeClientOptions poderia ser escrita da seguinte forma:
public sealed class SomeClientOptions
{
public string Login { get; set; }
public string CertificatePath { get; set; }
public SomeClientOptions(string login, string certificatePath)
{
Login = login;
CertificatePath = certificatePath;
}
}
O segundo requisito bastante óbvio para o aplicativo como um todo (isso é especialmente verdadeiro para o núcleo do asp.net): poder obter nossas SomeClientOptions de algum arquivo json, que pode ser convenientemente modificado durante a implantação.
Portanto, adicionamos a seção de mesmo nome a appsettings.json:
{
"SomeClientOptions": {
"Login": "ferzisdis",
"CertificatePath": ".\full_access.pfx"
}
}
Agora a questão é: como criamos um objeto SomeClientOptions e garantimos que todos os campos NotNull não retornem null em nenhuma circunstância?
Tentativa ingênua de usar ferramentas integradas
Eu gostaria de escrever algo como este bloco de código, e não escrever um artigo sobre Habr:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
var options = Configuration.GetSection(nameof(SomeClientOptions)).Get<SomeClientOptions>();
services.AddSingleton(options);
}
}
Mas este código não é funcional porque O método Get () impõe uma série de restrições ao tipo com o qual trabalha:
- O tipo T deve ser não abstrato e conter um construtor público sem parâmetros
- Heters de propriedade não devem lançar exceções
Levando em consideração as restrições especificadas, somos forçados a refazer a classe SomeClientOptions mais ou menos assim:
public sealed class SomeClientOptions
{
private string login = null!;
private string certificatePath = null!;
public string Login
{
get
{
return login;
}
set
{
login = !string.IsNullOrEmpty(value) ? value : throw new InvalidOperationException($"{nameof(Login)} cannot be null!");
}
}
public string CertificatePath
{
get
{
return certificatePath;
}
set
{
certificatePath = !string.IsNullOrEmpty(value) ? value : throw new InvalidOperationException($"{nameof(CertificatePath)} cannot be null!");
}
}
}
Acho que você vai concordar comigo que tal decisão não é bonita nem correta. Pelo menos porque nada impede o cliente de simplesmente criar este tipo por meio do construtor e passá-lo para o objeto SomeClient - nenhum aviso será emitido na fase de compilação, e em tempo de execução obteremos o cobiçado NRE.
Nota: Vou usar string.IsNullOrEmpty () como um teste para nulo, uma vez que na maioria dos casos, uma string vazia pode ser interpretada como um valor não especificado
Melhores alternativas
Em primeiro lugar, proponho analisar várias maneiras corretas de resolver o problema, que têm desvantagens óbvias.
É possível dividir SomeClientOptions em dois objetos, onde o primeiro é usado para desserialização e o segundo executa a validação:
public sealed class SomeClientOptionsRaw
{
public string? Login { get; set; }
public string? CertificatePath { get; set; }
}
public sealed class SomeClientOptions : ISomeClientOptions
{
private readonly SomeClientOptionsRaw raw;
public SomeClientOptions(SomeClientOptionsRaw raw)
{
this.raw = raw;
}
public string Login
=> !string.IsNullOrEmpty(this.raw.Login) ? this.raw.Login : throw new InvalidOperationException($"{nameof(Login)} cannot be null!");
public string CertificatePath
=> !string.IsNullOrEmpty(this.raw.CertificatePath) ? this.raw.CertificatePath : throw new InvalidOperationException($"{nameof(CertificatePath)} cannot be null!");
}
public interface ISomeClientOptions
{
public string Login { get; }
public string CertificatePath { get; }
}
Acho que esta solução é bastante simples e elegante, exceto que o programador terá que criar mais uma classe de cada vez e duplicar um conjunto de propriedades.
Seria muito mais correto usar a interface ISomeClientOptions em SomeClient em vez de SomeClientOptions (como vimos, a implementação pode ser muito dependente do ambiente).
A segunda maneira (menos elegante) é extrair manualmente os valores do IConfiguration:
public sealed class SomeClientOptions : ISomeClientOptions
{
private readonly IConfiguration configuration;
public SomeClientOptions(IConfiguration configuration)
{
this.configuration = configuration;
}
public string Login => GetNotNullValue(nameof(Login));
public string CertificatePath => GetNotNullValue(nameof(CertificatePath));
private string GetNotNullValue(string propertyName)
{
var value = configuration[$"{nameof(SomeClientOptions)}:{propertyName}"];
return !string.IsNullOrEmpty(value) ? value : throw new InvalidOperationException($"{propertyName} cannot be null!");
}
}
Não gosto dessa abordagem por causa da necessidade de implementar independentemente o processo de análise e conversão de tipo.
Além disso, você não acha que as dificuldades são demais para uma tarefa tão pequena?
Como não escrever código extra manualmente?
A ideia principal é gerar uma implementação para a interface ISomeClientOptions em tempo de execução, incluindo todas as verificações necessárias. No artigo, quero oferecer apenas um conceito da solução. Se o tópico interessar o suficiente à comunidade, prepararei um pacote nuget para uso em combate (código aberto no github).
Para facilitar a implementação, divido todo o procedimento em 3 partes lógicas:
- A implementação da interface em tempo de execução é criada
- O objeto é desserializado por meios padrão
- As propriedades são verificadas quanto a nulas (apenas as propriedades marcadas como NotNull são verificadas)
public static class ConfigurationExtensions
{
private static readonly InterfaceImplementationBuilder InterfaceImplementationBuilder = new InterfaceImplementationBuilder();
private static readonly NullReferenceValidator NullReferenceValidator = new NullReferenceValidator();
public static T GetOptions<T>(this IConfiguration configuration, string sectionName)
{
var implementationOfInterface = InterfaceImplementationBuilder.BuildClass<T>();
var options = configuration.GetSection(sectionName).Get(implementationOfInterface);
NullReferenceValidator.CheckNotNullProperties<T>(options);
return (T) options;
}
}
InterfaceImplementationBuilder
public sealed class InterfaceImplementationBuilder
{
private readonly Lazy<ModuleBuilder> _module;
public InterfaceImplementationBuilder()
{
_module = new Lazy<ModuleBuilder>(() => AssemblyBuilder
.DefineDynamicAssembly(new AssemblyName(Guid.NewGuid().ToString()), AssemblyBuilderAccess.Run)
.DefineDynamicModule("MainModule"));
}
public Type BuildClass<TInterface>()
{
return BuildClass(typeof(TInterface));
}
public Type BuildClass(Type implementingInterface)
{
if (!implementingInterface.IsInterface)
{
throw new InvalidOperationException("Only interface is supported");
}
var typeBuilder = DefineNewType(implementingInterface.Name);
ImplementInterface(typeBuilder, implementingInterface);
return typeBuilder.CreateType() ?? throw new InvalidOperationException("Cannot build type!");
}
private void ImplementInterface(TypeBuilder typeBuilder, Type implementingInterface)
{
foreach (var propertyInfo in implementingInterface.GetProperties())
{
DefineNewProperty(typeBuilder, propertyInfo.Name, propertyInfo.PropertyType);
}
typeBuilder.AddInterfaceImplementation(implementingInterface);
}
private TypeBuilder DefineNewType(string baseName)
{
return _module.Value.DefineType($"{baseName}_{Guid.NewGuid():N}");
}
private static void DefineNewProperty(TypeBuilder typeBuilder, string propertyName, Type propertyType)
{
FieldBuilder fieldBuilder = typeBuilder.DefineField("_" + propertyName, propertyType, FieldAttributes.Private);
PropertyBuilder propertyBuilder = typeBuilder.DefineProperty(propertyName, PropertyAttributes.HasDefault, propertyType, null);
MethodBuilder getPropMthdBldr = typeBuilder.DefineMethod("get_" + propertyName, MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig | MethodAttributes.Virtual, propertyType, Type.EmptyTypes);
ILGenerator getIl = getPropMthdBldr.GetILGenerator();
getIl.Emit(OpCodes.Ldarg_0);
getIl.Emit(OpCodes.Ldfld, fieldBuilder);
getIl.Emit(OpCodes.Ret);
MethodBuilder setPropMthdBldr =
typeBuilder.DefineMethod("set_" + propertyName,
MethodAttributes.Public
| MethodAttributes.SpecialName
| MethodAttributes.HideBySig
| MethodAttributes.Virtual,
null, new[] { propertyType });
ILGenerator setIl = setPropMthdBldr.GetILGenerator();
Label modifyProperty = setIl.DefineLabel();
Label exitSet = setIl.DefineLabel();
setIl.MarkLabel(modifyProperty);
setIl.Emit(OpCodes.Ldarg_0);
setIl.Emit(OpCodes.Ldarg_1);
setIl.Emit(OpCodes.Stfld, fieldBuilder);
setIl.Emit(OpCodes.Nop);
setIl.MarkLabel(exitSet);
setIl.Emit(OpCodes.Ret);
propertyBuilder.SetGetMethod(getPropMthdBldr);
propertyBuilder.SetSetMethod(setPropMthdBldr);
}
}
NullReferenceValidator
public sealed class NullReferenceValidator
{
public void CheckNotNullProperties<TInterface>(object options)
{
var propertyInfos = typeof(TInterface).GetProperties();
foreach (var propertyInfo in propertyInfos)
{
if (propertyInfo.PropertyType.IsValueType)
{
continue;
}
if (!IsNullable(propertyInfo) && IsNull(propertyInfo, options))
{
throw new InvalidOperationException($"Property {propertyInfo.Name} cannot be null!");
}
}
}
private bool IsNull(PropertyInfo propertyInfo, object obj)
{
var value = propertyInfo.GetValue(obj);
switch (value)
{
case string s: return string.IsNullOrEmpty(s);
default: return value == null;
}
}
// https://stackoverflow.com/questions/58453972/how-to-use-net-reflection-to-check-for-nullable-reference-type
private bool IsNullable(PropertyInfo property)
{
if (property.PropertyType.IsValueType)
{
throw new ArgumentException("Property must be a reference type", nameof(property));
}
var nullable = property.CustomAttributes
.FirstOrDefault(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute");
if (nullable != null && nullable.ConstructorArguments.Count == 1)
{
var attributeArgument = nullable.ConstructorArguments[0];
if (attributeArgument.ArgumentType == typeof(byte[]) && attributeArgument.Value != null)
{
var args = (ReadOnlyCollection<CustomAttributeTypedArgument>)attributeArgument.Value;
if (args.Count > 0 && args[0].ArgumentType == typeof(byte))
{
return (byte)args[0].Value == 2;
}
}
else if (attributeArgument.ArgumentType == typeof(byte))
{
return (byte)attributeArgument.Value == 2;
}
}
var context = property.DeclaringType.CustomAttributes
.FirstOrDefault(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute");
if (context != null &&
context.ConstructorArguments.Count == 1 &&
context.ConstructorArguments[0].ArgumentType == typeof(byte) &&
context.ConstructorArguments[0].Value != null)
{
return (byte)context.ConstructorArguments[0].Value == 2;
}
// Couldn't find a suitable attribute
return false;
}
}
Exemplo de uso:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
var options = Configuration.GetOptions<ISomeClientOptions>("SomeClientOptions");
services.AddSingleton(options);
}
}
Conclusão
Portanto, usar tipos de referência nullabe não é tão trivial quanto pode parecer à primeira vista. Essa ferramenta permite apenas reduzir o número de NREs, não se livrar deles completamente. Muitas bibliotecas ainda não foram devidamente anotadas.
Obrigado pela sua atenção. Espero que você tenha gostado do artigo.
Conte-nos se você encontrou um problema semelhante e como o solucionou. Eu ficaria muito grato por seus comentários sobre a solução proposta.