如何通过添加appsettings.json来“烹饪”可为空的引用类型

在本文中,我想分享一下我的想法,即是否可以编写现代C#中的NullReferenceException安全的代码。这种恶意的异常类型无法告知开发人员确切的空值。当然,绝望的可以.nachat .pisat .obraschenie .ko .vsem .fields .vot .so .vot,但是有一个适当的解决方案-?????????使用JetBrainsMicrosoft的类型注释此后,编译器将开始提示我们(如果我们启用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() }");
        }
    }


因此,我们想声明某种合同,并告诉客户端代码它不应传递具有空值的Login和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核心尤其如此):能够从某个json文件获取我们的SomeClientOptions,可以在部署期间方便地对其进行修改。



因此,我们将相同名称的部分添加到appsettings.json中:



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


现在的问题是:我们如何创建SomeClientOptions对象并确保所有NotNull字段在任何情况下都不会返回null?



天真的尝试使用内置工具



我想编写类似以下代码块的内容,而不是在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);
        }
    }


但是此代码不起作用,因为 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对象-在编译阶段不会发出单个警告,因此在运行时我们将获得梦co以求的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; }
    }


我认为该解决方案非常简单而优雅,只是程序员每次必须创建一个以上的类并复制一组属性。



在SomeClient中使用ISomeClientOptions接口代替SomeClientOptions会更正确(如我们所见,实现可能非常依赖于环境)。



第二种(不太优雅)的方法是从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!");
        }
    }


我不喜欢这种方法,因为需要独立实现解析和类型转换过程。



此外,您不觉得这么小的任务有太多困难吗?



如何不手工编写额外的代码?



主要思想是在运行时为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引用类型并不像乍看起来那样简单。该工具仅允许您减少NRE的数量,而不能完全摆脱它们。而且许多库尚未得到正确注释。



感谢您的关注。希望您喜欢这篇文章。



告诉我们您是否遇到类似的问题以及如何解决。感谢您对提议的解决方案发表意见。



All Articles