Tapi apakah semuanya mulus? Di bawah pemotongan, saya ingin membongkar dan menawarkan solusi untuk satu masalah tertentu.
Rumusan masalah
Catatan: Diasumsikan bahwa semua kode dalam artikel ini akan dikompilasi dengan parameter proyek:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
Misalkan kita ingin menulis kelas yang menggunakan sekumpulan parameter tertentu yang perlu berfungsi:
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() }");
}
}
Karena itu, kami ingin mendeklarasikan beberapa jenis kontrak dan memberi tahu kode klien bahwa ia tidak boleh melewati Login dan CertificatePath dengan nilai null. Oleh karena itu, kelas SomeClientOptions dapat ditulis seperti ini:
public sealed class SomeClientOptions
{
public string Login { get; set; }
public string CertificatePath { get; set; }
public SomeClientOptions(string login, string certificatePath)
{
Login = login;
CertificatePath = certificatePath;
}
}
Persyaratan kedua yang cukup jelas untuk aplikasi secara keseluruhan (ini terutama berlaku untuk inti asp.net): untuk dapat memperoleh SomeClientOptions dari beberapa file json, yang dapat dimodifikasi dengan mudah selama penerapan.
Oleh karena itu, kami menambahkan bagian dengan nama yang sama ke appsettings.json:
{
"SomeClientOptions": {
"Login": "ferzisdis",
"CertificatePath": ".\full_access.pfx"
}
}
Sekarang pertanyaannya adalah: bagaimana kita membuat objek SomeClientOptions dan memastikan bahwa semua bidang NotNull tidak akan mengembalikan null dalam keadaan apa pun?
Upaya naif dalam menggunakan alat bawaan
Saya ingin menulis sekumpulan kode seperti ini, dan tidak menulis artikel di 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);
}
}
Tetapi kode ini tidak berfungsi karena Metode Get () memberlakukan sejumlah batasan pada jenis yang bekerja dengannya:
- Tipe T harus non-abstrak dan berisi konstruktor tanpa parameter publik
- Heter properti tidak boleh mengeluarkan pengecualian
Dengan mempertimbangkan batasan yang ditentukan, kami dipaksa untuk membuat ulang kelas SomeClientOptions seperti ini:
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!");
}
}
}
Saya pikir Anda akan setuju dengan saya bahwa keputusan seperti itu tidak indah dan juga tidak tepat. Setidaknya karena tidak ada yang menghalangi klien untuk hanya membuat tipe ini melalui konstruktor dan meneruskannya ke objek SomeClient - tidak ada peringatan tunggal yang akan dikeluarkan pada tahap kompilasi, dan pada waktu proses kita akan mendapatkan NRE yang didambakan.
Catatan: Saya akan menggunakan string.IsNullOrEmpty () sebagai pengujian untuk null, karena dalam banyak kasus, string kosong dapat diartikan sebagai nilai yang tidak ditentukan
Alternatif yang lebih baik
Pertama, saya mengusulkan untuk menganalisis beberapa cara yang benar untuk menyelesaikan masalah, yang jelas memiliki kerugian.
SomeClientOptions dapat dibagi menjadi dua objek, di mana yang pertama digunakan untuk deserialisasi, dan yang kedua melakukan validasi:
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; }
}
Saya rasa solusi ini cukup sederhana dan elegan, kecuali bahwa programmer harus membuat satu kelas lagi setiap kali dan menggandakan satu set properti.
Akan jauh lebih tepat untuk menggunakan antarmuka ISomeClientOptions di SomeClient daripada SomeClientOptions (seperti yang telah kita lihat, implementasinya bisa sangat bergantung pada lingkungan).
Cara kedua (kurang elegan) adalah menarik nilai secara manual dari 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!");
}
}
Saya tidak suka pendekatan ini karena kebutuhan untuk mengimplementasikan proses konversi penguraian dan jenis secara mandiri.
Selain itu, bukankah menurut Anda ada terlalu banyak kesulitan untuk tugas sekecil itu?
Bagaimana tidak menulis kode ekstra dengan tangan?
Ide utamanya adalah menghasilkan implementasi untuk antarmuka ISomeClientOptions pada waktu proses, termasuk semua pemeriksaan yang diperlukan. Dalam artikel ini saya hanya ingin menawarkan konsep solusi. Jika topiknya cukup menarik bagi komunitas, saya akan menyiapkan paket nuget untuk penggunaan pertempuran (open source di github).
Untuk kemudahan implementasi, saya membagi seluruh prosedur menjadi 3 bagian logis:
- Implementasi runtime dari antarmuka dibuat
- Objek dideserialisasi dengan cara standar
- Properti diperiksa untuk null (hanya properti yang ditandai sebagai NotNull yang diperiksa)
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;
}
}
Contoh penggunaan:
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);
}
}
Kesimpulan
Dengan demikian, menggunakan tipe referensi nullabe tidak semudah yang terlihat pada pandangan pertama. Alat ini hanya memungkinkan Anda untuk mengurangi jumlah EBT, bukan menghilangkannya sepenuhnya. Banyak perpustakaan belum dianotasi dengan benar.
Terima kasih atas perhatian Anda. Semoga Anda menikmati artikelnya.
Beri tahu kami jika Anda mengalami masalah serupa dan cara Anda mengatasinya. Saya akan berterima kasih atas komentar Anda tentang solusi yang diusulkan.