Cómo "cocinar" tipos de referencia que aceptan valores NULL con appsettings.json agregado

En este artículo, quiero compartir mis pensamientos sobre si es posible escribir código que esté a salvo de NullReferenceException en C # moderno. Este tipo de excepción maliciosa no le dice al desarrollador exactamente dónde tiene null. Por supuesto, de la desesperación, puede ?. inicio ?. escritura ?. Dirección ?. Para ?. A todos ?. campos ?. Aquí Así ?. Aquí, pero no es una solución adecuada - a anotaciones de tipos de uso de JetBrains o Microsoft . Después de eso, el compilador comenzará a avisarnos (y "avisar" de manera muy persistente, si habilitamos la opción WarningsAsError), donde exactamente se debe agregar la verificación apropiada.



¿Pero todo es tan sencillo? Debajo del corte, quiero desmontar y ofrecer una solución a un problema específico.







Formulación del problema



Nota: Se supone que todo el código de este artículo se compilará con los parámetros del proyecto:



<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>


Supongamos que queremos escribir una clase que toma un conjunto específico de parámetros que necesita 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() }");
        }
    }


Por lo tanto, nos gustaría declarar algún tipo de contrato y decirle al código del cliente que no debe pasar Login y CertificatePath con valores nulos. Por lo tanto, la clase SomeClientOptions podría escribirse de esta manera:



    public sealed class SomeClientOptions
    {
        public string Login { get; set; }

        public string CertificatePath { get; set; }

        public SomeClientOptions(string login, string certificatePath)
        {
            Login = login;
            CertificatePath = certificatePath;
        }
    }


El segundo requisito bastante obvio para la aplicación en su conjunto (esto es especialmente cierto para el núcleo de asp.net): poder obtener nuestras SomeClientOptions de algún archivo json, que se puede modificar convenientemente durante la implementación.



Por lo tanto, agregamos la sección del mismo nombre a appsettings.json:



{
  "SomeClientOptions": {
    "Login": "ferzisdis",
    "CertificatePath":  ".\full_access.pfx"
  }
}


Ahora la pregunta es: ¿cómo creamos un objeto SomeClientOptions y nos aseguramos de que todos los campos NotNull no devuelvan nulos bajo ninguna circunstancia?



Intento ingenuo de usar herramientas integradas



Me gustaría escribir algo como el siguiente bloque de código y no escribir un artículo 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);
        }
    }


Pero este código no es funcional porque El método Get () impone una serie de restricciones sobre el tipo con el que trabaja:



  • El tipo T debe ser no abstracto y contener un constructor público sin parámetros
  • Los heters de la propiedad no deben lanzar excepciones


Teniendo en cuenta las restricciones especificadas, nos vemos obligados a rehacer la clase SomeClientOptions algo como esto:



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!");
            }
        }
    }


Creo que estará de acuerdo conmigo en que esa decisión no es ni hermosa ni correcta. Al menos porque nada impide que el cliente simplemente cree este tipo a través del constructor y lo pase al objeto SomeClient; no se emitirá ni una sola advertencia en la etapa de compilación, y en tiempo de ejecución obtendremos el codiciado NRE.



Nota: Usaré string.IsNullOrEmpty () como prueba para nulo, ya que en la mayoría de los casos, una cadena vacía se puede interpretar como un valor no especificado



Mejores alternativas



Primero, propongo analizar varias formas correctas de resolver el problema, que tienen obvias desventajas.



Es posible dividir SomeClientOptions en dos objetos, donde el primero se usa para la deserialización y el segundo realiza la validación:



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


Creo que esta solución es bastante simple y elegante, excepto que el programador tendrá que crear una clase más cada vez y duplicar un conjunto de propiedades.



Sería mucho más correcto utilizar la interfaz ISomeClientOptions en SomeClient en lugar de SomeClientOptions (como hemos visto, la implementación puede depender mucho del entorno).



La segunda forma (menos elegante) es extraer manualmente valores de 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!");
        }
    }


No me gusta este enfoque debido a la necesidad de implementar de forma independiente el proceso de conversión de tipos y análisis.



Además, ¿no crees que hay demasiadas dificultades para una tarea tan pequeña?



¿Cómo no escribir código adicional a mano?



La idea principal es generar una implementación para la interfaz ISomeClientOptions en tiempo de ejecución, incluidas todas las comprobaciones necesarias. En el artículo quiero ofrecer solo un concepto de la solución. Si el tema interesa lo suficiente a la comunidad, prepararé un paquete nuget para uso en combate (código abierto en github).



Para facilitar la implementación, dividí todo el procedimiento en 3 partes lógicas:



  1. Se crea la implementación en tiempo de ejecución de la interfaz
  2. El objeto se deserializa por medios estándar
  3. Las propiedades se verifican en busca de nulos (solo se verifican las propiedades que están marcadas como NotNull)


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




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


Conclusión



Por lo tanto, el uso de tipos de referencia nulabe no es tan trivial como podría parecer a primera vista. Esta herramienta solo le permite reducir la cantidad de NRE, no deshacerse de ellas por completo. Muchas bibliotecas aún no se han anotado correctamente.



Gracias por su atención. Espero que hayas disfrutado del artículo.



Díganos si encontró un problema similar y cómo lo solucionó. Agradecería sus comentarios sobre la solución propuesta.



All Articles