Comment "cuisiner" des types de référence Nullable avec ajout de appsettings.json

Dans cet article, je souhaite partager mes réflexions sur la possibilité d'écrire du code à l'abri de NullReferenceException en C # moderne. Ce type d'exception malveillant n'indique pas au développeur exactement où il a null. Bien sûr, par désespoir, vous pouvez ?. Commencer ?. Écrire ?. Adresse ?. À ?. À tous ?. Champs? Ici? .Donc ?. Ici, mais il existe une solution adéquate - utiliser les annotations de type de JetBrains ou Microsoft . Après cela, le compilateur commencera à nous demander (et à "demander" de manière très persistante, si nous activons l'option WarningsAsError), où exactement la vérification appropriée doit être ajoutée.



Mais est-ce que tout est si fluide? Sous la coupe, je souhaite démonter et proposer une solution à un problème spécifique.







Formulation du problème



Remarque: il est supposé que tout le code de cet article sera compilé avec les paramètres du projet:



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


Supposons que nous voulions écrire une classe qui prend un ensemble spécifique de paramètres dont elle a besoin pour fonctionner:



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


Ainsi, nous aimerions déclarer une sorte de contrat et dire au code client qu'il ne doit pas passer Login et CertificatePath avec des valeurs nulles. Par conséquent, la classe SomeClientOptions pourrait être écrite comme ceci:



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

        public string CertificatePath { get; set; }

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


La deuxième exigence assez évidente pour l'application dans son ensemble (cela est particulièrement vrai pour le noyau asp.net): pouvoir obtenir nos SomeClientOptions à partir d'un fichier json, qui peut être commodément modifié pendant le déploiement.



Par conséquent, nous ajoutons la section du même nom à appsettings.json:



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


Maintenant, la question est: comment créer un objet SomeClientOptions et nous assurer que tous les champs NotNull ne retourneront en aucun cas null?



Tentative naïve d'utiliser des outils intégrés



J'aimerais écrire un bloc de code comme celui-ci, et ne pas écrire d'article sur 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);
        }
    }


Mais ce code n'est pas fonctionnel car La méthode Get () impose un certain nombre de restrictions sur le type avec lequel elle fonctionne:



  • Le type T doit être non abstrait et contenir un constructeur public sans paramètre
  • Les heters de propriété ne devraient pas lever d'exceptions


Compte tenu des restrictions indiquées, nous sommes obligés de refaire la classe SomeClientOptions quelque chose comme ceci:



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


Je pense que vous conviendrez avec moi qu'une telle décision n'est ni belle ni correcte. Du moins parce que rien n'empêche le client de simplement créer ce type via le constructeur et de le transmettre à l'objet SomeClient - pas un seul avertissement ne sera émis lors de la compilation, et au moment de l'exécution, nous obtiendrons le NRE tant convoité.



Remarque: j'utiliserai string.IsNullOrEmpty () comme test de null, car dans la plupart des cas, une chaîne vide peut être interprétée comme une valeur non spécifiée



Meilleures alternatives



Tout d'abord, je propose d'analyser plusieurs façons correctes de résoudre le problème, qui présentent des inconvénients évidents.



Il est possible de diviser SomeClientOptions en deux objets, le premier étant utilisé pour la désérialisation et le second effectuant la validation:



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


Je pense que cette solution est assez simple et élégante, sauf que le programmeur devra créer une classe de plus à chaque fois et dupliquer un ensemble de propriétés.



Il serait beaucoup plus correct d'utiliser l'interface ISomeClientOptions dans SomeClient au lieu de SomeClientOptions (comme nous l'avons vu, l'implémentation peut être très dépendante de l'environnement).



La deuxième méthode (moins élégante) consiste à extraire manuellement les valeurs 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!");
        }
    }


Je n'aime pas cette approche en raison de la nécessité d'implémenter indépendamment le processus d'analyse et de conversion de type.



D'ailleurs, ne pensez-vous pas qu'il y a trop de difficultés pour une si petite tâche?



Comment ne pas écrire du code supplémentaire à la main?



L'idée principale est de générer une implémentation pour l'interface ISomeClientOptions au moment de l'exécution, y compris toutes les vérifications nécessaires. Dans l'article, je souhaite proposer uniquement un concept de la solution. Si le sujet intéresse suffisamment la communauté, je préparerai un paquet nuget pour une utilisation au combat (open source sur github).



Pour faciliter la mise en œuvre, j'ai divisé l'ensemble de la procédure en 3 parties logiques:



  1. La mise en œuvre d'exécution de l'interface est créée
  2. L'objet est désérialisé par des moyens standards
  3. Les propriétés sont vérifiées pour null (seules les propriétés marquées comme NotNull sont vérifiées)


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




Exemple d'utilisation:

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


Conclusion



Ainsi, l'utilisation de types de référence nullabe n'est pas aussi simple que cela puisse paraître à première vue. Cet outil vous permet uniquement de réduire le nombre de NRE, pas de vous en débarrasser complètement. De nombreuses bibliothèques n'ont pas encore été correctement annotées.



Merci pour votre attention. J'espère que vous avez apprécié l'article.



Dites-nous si vous avez rencontré un problème similaire et comment vous l'avez contourné. Je vous serais reconnaissant de vos commentaires sur la solution proposée.



All Articles