appsettings.jsonを追加してnull可能な参照タイプを「調理」する方法

この記事では、最新のC#でNullReferenceExceptionから安全なコードを記述できるかどうかについての私の考えを共有したいと思います。この悪意のあるタイプの例外は、開発者にnullがどこにあるかを正確に伝えません。?もちろん、絶望のうち、あなたがスタート?。書き込み?。ここでアドレス?。ここへ?。?。するためにすべてのフィールド。だから?。?。をすることができますが、適切な解決策がある-から型注釈を使用するジェットブレーンズまたはMicrosoftがその後、コンパイラはプロンプトを表示し始め(WarningsAsErrorオプションを有効にすると、非常に永続的に「プロンプト」を表示します)、適切なチェックを追加する必要があります。



しかし、すべてがとてもスムーズですか?カットの下で、私は分解して、1つの特定の問題の解決策を提供したいと思います。







問題の定式化



注:この記事のすべてのコードは、プロジェクトパラメーターを使用してコンパイルされることを前提としています。



<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をnull値で渡してはならないことを伝えたいと思います。したがって、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;
        }
    }


アプリケーション全体の2番目の非常に明白な要件(これは特に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オブジェクトに渡すことを妨げるものは何もないため、コンパイル段階で警告が1つも発行されず、実行時に切望されたNREが取得されます。



注:nullのテストとしてstring.IsNullOrEmpty()を使用します。ほとんどの場合、空の文字列は不特定の値として解釈される可能性があります



より良い選択肢



まず、明らかな欠点がある問題を解決するためのいくつかの正しい方法を分析することを提案します。



SomeClientOptionsを2つのオブジェクトに分割することができます。最初のオブジェクトは逆シリアル化に使用され、2番目のオブジェクトは検証を実行します。



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


このソリューションは、プログラマーが毎回もう1つのクラスを作成し、プロパティのセットを複製する必要があることを除いて、非常にシンプルでエレガントだと思います。



SomeClientOptionsの代わりにSomeClientでISomeClientOptionsインターフェイスを使用する方がはるかに正しいでしょう(これまで見てきたように、実装は環境に大きく依存する可能性があります)。



2番目の(あまりエレガントではない)方法は、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. プロパティのnullがチェックされます(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の数を減らすことしかできず、NREを完全に取り除くことはできません。そして、多くのライブラリはまだ適切に注釈が付けられていません。



ご清聴ありがとうございました。あなたが記事を楽しんだことを望みます。



同様の問題が発生したかどうか、およびどのように回避したかを教えてください。提案された解決策についてのコメントをいただければ幸いです。



All Articles