كيفية "طهي" أنواع مرجعية nullable مع إضافة appsettings.json

في هذه المقالة ، أود مشاركة أفكاري حول ما إذا كان من الممكن كتابة رمز آمن من NullReferenceException في C # الحديث. هذا النوع الخبيث من الاستثناءات لا يخبر المطور بالضبط بمكان وجوده فارغًا. بالطبع ، بدافع اليأس يمكن أن .nachat .pisat .obraschenie .ko .vsem .fields .vot .so .vot ، ولكن هناك حل مناسب - استخدم التعليقات التوضيحية من نوع JetBrains أو Microsoft . بعد ذلك ، سيبدأ المترجم في مطالبتنا (و "المطالبة" بإصرار شديد ، إذا قمنا بتمكين خيار WarningsAsError) ، حيث يجب إضافة الاختيار المناسب بالضبط.



لكن هل كل شيء سلس جدا؟ تحت الخفض ، أريد تفكيك وتقديم حل لمشكلة واحدة محددة.







صياغة المشكلة



ملاحظة: من المفترض أن جميع التعليمات البرمجية في هذه المقالة سيتم تجميعها باستخدام معلمات المشروع:



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


لنفترض أننا نريد كتابة فصل دراسي يأخذ مجموعة محددة من المعلمات التي يحتاجها للعمل:



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


وبالتالي ، نود أن نعلن عن نوع ما من العقود ونخبر كود العميل بأنه لا ينبغي أن يجتاز تسجيل الدخول و CertificatePath بقيم فارغة. لذلك ، يمكن كتابة فئة SomeClientOptions على النحو التالي:



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

        public string CertificatePath { get; set; }

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


الشرط الثاني الواضح تمامًا للتطبيق ككل (هذا ينطبق بشكل خاص على asp.net الأساسية): أن تكون قادرًا على الحصول على SomeClientOptions من ملف json ، والذي يمكن تعديله بسهولة أثناء النشر.



لذلك ، نضيف القسم الذي يحمل نفس الاسم إلى appsettings.json:



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


والسؤال المطروح الآن هو: كيف يمكننا إنشاء كائن SomeClientOptions والتأكد من أن جميع حقول NotNull لن ترجع قيمة خالية تحت أي ظرف من الظروف؟



محاولة ساذجة لاستخدام أدوات مدمجة



أود أن أكتب كتلة من الكود مثل هذا ، ولا أكتب مقالاً عن هبر:



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


لكن هذا الرمز لا يعمل لأن تفرض طريقة Get () عددًا من القيود على النوع الذي تعمل معه:



  • يجب أن يكون النوع T غير مجرّد وأن يحتوي على مُنشئ عام بدون معلمات
  • لا ينبغي أن يرمي منتقدو الممتلكات استثناءات


بالنظر إلى القيود المشار إليها ، فإننا مضطرون إلى إعادة تكوين فئة SomeClientOptions مثل هذا:



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


أعتقد أنك ستوافقني على أن مثل هذا القرار ليس جميلًا ولا صحيحًا. على الأقل لأنه لا يوجد شيء يمنع العميل من إنشاء هذا النوع من خلال المُنشئ وتمريره إلى كائن SomeClient - لن يتم إصدار تحذير واحد في مرحلة التجميع ، وفي وقت التشغيل سنحصل على NRE المرغوب.



ملاحظة: سأستخدم string.IsNullOrEmpty () كاختبار لـ null ، منذ ذلك الحين في معظم الحالات ، يمكن تفسير السلسلة الفارغة على أنها قيمة غير محددة



بدائل أفضل



أولاً ، أقترح تحليل عدة طرق صحيحة لحل المشكلة ، والتي لها عيوب واضحة.



من الممكن تقسيم SomeClientOptions إلى كائنين ، حيث يتم استخدام الأول لإلغاء التسلسل ، ويقوم الثاني بالتحقق من الصحة:



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


أعتقد أن هذا الحل بسيط للغاية وأنيق ، باستثناء أنه سيتعين على المبرمج إنشاء فئة أخرى في كل مرة وتكرار مجموعة من الخصائص.



سيكون من الأصح في البداية استخدام واجهة ISomeClientOptions في SomeClient بدلاً من SomeClientOptions (كما رأينا ، يمكن أن يعتمد التنفيذ بشكل كبير على البيئة).



الطريقة الثانية (الأقل أناقة) هي سحب القيم يدويًا من تكوين الرمز:



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


لا يعجبني هذا الأسلوب بسبب الحاجة إلى تنفيذ عملية التحليل والنوع بشكل مستقل.



علاوة على ذلك ، ألا تعتقد أن هناك الكثير من الصعوبات لمثل هذه المهمة الصغيرة؟



كيف لا تكتب كود إضافي باليد؟



الفكرة الرئيسية هي إنشاء تطبيق لواجهة ISomeClientOptions في وقت التشغيل ، بما في ذلك جميع الفحوصات اللازمة. في المقال أريد أن أعرض فقط مفهوم الحل. إذا كان الموضوع يثير اهتمام المجتمع بدرجة كافية ، فسوف أقوم بإعداد حزمة nuget للاستخدام القتالي (مفتوح المصدر على github).



لسهولة التنفيذ ، قسمت الإجراء بأكمله إلى 3 أجزاء منطقية:



  1. يتم إنشاء تنفيذ وقت التشغيل للواجهة
  2. يتم إلغاء تسلسل الكائن بالوسائل القياسية
  3. يتم التحقق من الخصائص بحثًا عن خالية (فقط تلك الخصائص التي تم وضع علامة 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;
        }
    }




مثال على الاستخدام:

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


خاتمة



وبالتالي ، فإن استخدام أنواع مرجع nullabe ليس تافهاً كما قد يبدو للوهلة الأولى. تتيح لك هذه الأداة فقط تقليل عدد NREs ، وليس التخلص منها تمامًا. العديد من المكتبات لم يتم التعليق عليها بشكل صحيح حتى الآن.



شكرا لك على انتباهك. ارجو ان تكون قد استمتعت بالمقال.



أخبرنا إذا واجهت مشكلة مماثلة وكيف تم التغلب عليها. سأكون ممتنا لتعليقاتكم على الحل المقترح.



All Articles