Wie man nullfähige Referenztypen mit appsettings.json "kocht", wurde hinzugefügt

In diesem Artikel möchte ich meine Gedanken darüber teilen, ob es möglich ist, Code zu schreiben, der vor NullReferenceException in modernem C # sicher ist. Diese böswillige Art von Ausnahme sagt dem Entwickler nicht genau, wo er null hat. Natürlich können Sie aus Verzweiflung? Beginnen? Schreiben? Adresse? An? An alle? Felder? Hier? Also? Hier, aber es gibt eine adäquate Lösung - um Typanmerkungen von JetBrains oder Microsoft zu verwenden . Danach beginnt der Compiler, uns aufzufordern (und "beharrlich" aufzufordern, wenn wir die Option WarningsAsError aktivieren), wobei genau die entsprechende Prüfung hinzugefügt werden sollte.



Aber ist alles so glatt? Unter dem Schnitt möchte ich zerlegen und eine Lösung für ein bestimmtes Problem anbieten.







Formulierung des Problems



Hinweis: Es wird davon ausgegangen, dass der gesamte Code in diesem Artikel mit den Projektparametern kompiliert wird:



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


Angenommen, wir möchten eine Klasse schreiben, die einen bestimmten Satz von Parametern verwendet, die zum Arbeiten erforderlich sind:



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


Daher möchten wir eine Art Vertrag deklarieren und dem Client-Code mitteilen, dass Login und CertificatePath nicht mit Nullwerten übergeben werden sollen. Daher könnte die SomeClientOptions-Klasse folgendermaßen geschrieben werden:



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

        public string CertificatePath { get; set; }

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


Die zweite ganz offensichtliche Voraussetzung für die gesamte Anwendung (dies gilt insbesondere für den asp.net-Kern): Sie können unsere SomeClientOptions aus einer JSON-Datei abrufen, die während der Bereitstellung bequem geändert werden kann.



Daher fügen wir appsettings.json den gleichnamigen Abschnitt hinzu:



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


Die Frage ist nun: Wie erstellen wir ein SomeClientOptions-Objekt und stellen sicher, dass alle NotNull-Felder unter keinen Umständen null zurückgeben?



Naiver Versuch, eingebaute Werkzeuge zu verwenden



Ich möchte so etwas wie den folgenden Codeblock schreiben und keinen Artikel über Habr schreiben:



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


Aber dieser Code ist nicht funktionsfähig, weil Die Get () -Methode unterwirft dem Typ, mit dem sie arbeitet, eine Reihe von Einschränkungen:



  • Typ T darf nicht abstrakt sein und einen öffentlichen parameterlosen Konstruktor enthalten
  • Property Heter sollten keine Ausnahmen auslösen


Unter Berücksichtigung der angegebenen Einschränkungen sind wir gezwungen, die SomeClientOptions-Klasse wie folgt neu zu erstellen:



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


Ich denke, Sie werden mir zustimmen, dass eine solche Entscheidung weder schön noch richtig ist. Zumindest, weil nichts den Client daran hindert, diesen Typ einfach über den Konstruktor zu erstellen und an das SomeClient-Objekt zu übergeben. In der Kompilierungsphase wird keine einzige Warnung ausgegeben, und zur Laufzeit erhalten wir das begehrte NRE.



Hinweis: Ich werde string.IsNullOrEmpty () als Test für null verwenden, da In den meisten Fällen kann eine leere Zeichenfolge als nicht angegebener Wert interpretiert werden



Bessere Alternativen



Zunächst schlage ich vor, verschiedene korrekte Wege zur Lösung des Problems zu analysieren, die offensichtliche Nachteile haben.



Es ist möglich, SomeClientOptions in zwei Objekte aufzuteilen, wobei das erste zur Deserialisierung verwendet wird und das zweite die Validierung durchführt:



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


Ich denke, diese Lösung ist recht einfach und elegant, außer dass der Programmierer jedes Mal eine weitere Klasse erstellen und eine Reihe von Eigenschaften duplizieren muss.



Es wäre viel korrekter, die ISomeClientOptions-Schnittstelle in SomeClient anstelle von SomeClientOptions zu verwenden (wie wir gesehen haben, kann die Implementierung sehr abhängig von der Umgebung sein).



Die zweite (weniger elegante) Möglichkeit besteht darin, Werte manuell aus der IConfiguration abzurufen:



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


Ich mag diesen Ansatz nicht, weil der Parsing- und Typkonvertierungsprozess unabhängig implementiert werden muss.



Glauben Sie nicht, dass es für eine so kleine Aufgabe zu viele Schwierigkeiten gibt?



Wie schreibe ich keinen zusätzlichen Code von Hand?



Die Hauptidee besteht darin, zur Laufzeit eine Implementierung für die ISomeClientOptions-Schnittstelle zu generieren, einschließlich aller erforderlichen Überprüfungen. In dem Artikel möchte ich nur ein Konzept der Lösung anbieten. Wenn das Thema die Community genug interessiert, werde ich ein Nuget-Paket für den Kampfeinsatz vorbereiten (Open Source auf Github).



Zur Vereinfachung der Implementierung habe ich die gesamte Prozedur in drei logische Teile aufgeteilt:



  1. Die Laufzeitimplementierung der Schnittstelle wird erstellt
  2. Das Objekt wird mit Standardmitteln deserialisiert
  3. Eigenschaften werden auf Null geprüft (nur als NotNull gekennzeichnete Eigenschaften werden geprüft)


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




Anwendungsbeispiel:

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


Fazit



Daher ist die Verwendung von Nullabe-Referenztypen nicht so trivial, wie es auf den ersten Blick erscheinen mag. Mit diesem Tool können Sie nur die Anzahl der NREs reduzieren, nicht jedoch vollständig entfernen. Und viele Bibliotheken wurden noch nicht richtig kommentiert.



Vielen Dank für Ihre Aufmerksamkeit. Ich hoffe, Ihnen hat der Artikel gefallen.



Sagen Sie uns, ob Sie auf ein ähnliches Problem gestoßen sind und wie Sie es umgangen haben. Ich wäre dankbar für Ihre Kommentare zu der vorgeschlagenen Lösung.



All Articles