Но все ли так гладко? Под катом я хочу разобрать и предложить решение одной конкретной проблемы.
Постановка задачи
Примечание: Подразумевается, что весь код в этой статье будет компилироваться с параметрами проекта:
<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;
}
}
Второе вполне очевидное требование к приложению в целом (особенно это актуально для asp.net core): иметь возможность получать наш SomeClientOptions из какого-нибудь json файла, который можно удобно модифицировать во время деплоя.
Поэтому дописываем одноименную секцию в appsettings.json:
{
"SomeClientOptions": {
"Login": "ferzisdis",
"CertificatePath": ".\full_access.pfx"
}
}
Ну а теперь вопрос: как нам создать объект SomeClientOptions и гарантировать, что все NotNull поля не будут возвращать null не при каких обстоятельствах?
Наивная попытка использовать встроенные инструменты
Мне хотелось бы написать примерно такой блок кода, а не строчить статью на Хабр:
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.
Примечание: В качестве проверки на null я буду использовать string.IsNullOrEmpty(), т.к. в большенстве случаев пустую строку можно интерпретировать как незаданное значение
Альтернативы получше
Предлагаю сначала разобрать несколько правильных способов решить задачу, которые имеют очевидные недостатки.
Можно разбить 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 вместо SomeClientOptions интерфейс ISomeClientOptions (как мы убедились, реализация может очень сильно зависеть от окружения).
Второй (менее элегантный) способ — вытаскивать «вручную» значения из 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 генерировать реализацию в runtime, включающую все необходимые проверки. В статье я хочу предложить лишь концепт решения. Если тема достаточно заинтересует сообщество, подготовлю nuget-пакет для боевого применения (с открытыми исходниками на гитхабе).
Для простоты реализации, я разбил всю процедуру на 3 логические части:
- Создается runtime реализация интерфейса
- Выполняется десериализация объекта стандартными средствами
- Выполняется проверка свойств на 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;
}
}
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);
}
}
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 reference types не так тривиально, как может показаться на первый взгляд. Этот инструмент позволяет лишь снизить количество NRE, а не избавиться от них полностью. Да и многие библиотеки еще не аннатированы должным образом.
Спасибо за уделенное внимание. Надеюсь, вам понравилась статья.
Расскажите, сталкивались ли вы с подобной проблемой и как обходили ее. Буду благодарен за комментарии к предложенному решению.
Sing
или другие, более осмысленные дефолтные значения?
Использовать NullGuard.
kefirr
Да просто
string.Empty
как дефолтное значение. Для коллекций — пустая коллекция, и так далее. По аналогии с value types, у которых есть значение по умолчанию.Чуть больше кода, зато решение очевидное и надёжное.
null!
лучше всё-таки избегать.Sing
Так что использование корректного дефолтного значения, на мой взгляд — лучший вариант.
ferzisdis Автор
На самом деле тут есть два тонких момента:
1. IConfiguration при десериализации все равно запишет NULL в свойства, которых нет в appsettings.json так что в set все равно нужно вставить гард
2. Во всех местах, где нужно выполнять работу со свойствами придется добавлять проверку на дефолтное значение, что само по себе эквивалентно проверки на нулл. Т.к. если мы передадим «NotCertPathProvided» в метод, который работает с путями, то можем получить очень странное поведение. Ну и в добавок, эти дефолтные значения нужно вынести еще куда-то в константы
lair
Вообще-то не должен.
Sing
Вы повторяете мой комментарий, на который отвечаете. Буквально первый абзац. И во втором абзаце я и пишу, что вместо просто не-null значений стоит использовать корректные дефолтные значения — которые не приведут к ошибкам. В таком случае, всё будет работать.
Ну разумеется, я же не весь проект сюда скидываю…