Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В данной статье хочу поделиться своими размышлениями о том, можно ли на современном C# писать код, безопасный от NullReferenceException. Этот зловредный тип исключения не говорит разработчику, в каком конкретно месте у него null. Конечно, от отчаяния можно?.начать?.писать?.обращение?.ко?.всем?.полям?.вот?.так?.вот, но есть адекватное решение — использовать аннотации типов от JetBrains или Microsoft. После этого компилятор начнет нам подсказывать (и «подсказывать» очень настойчиво, если включить опцию WarningsAsError), в каком конкретно месте нужно добавлять соответствующую проверку.
Но все ли так гладко? Под катом я хочу разобрать и предложить решение одной конкретной проблемы.
Примечание: Подразумевается, что весь код в этой статье будет компилироваться с параметрами проекта:
Предположим, мы хотим написать класс, который принимает определенный набор параметров, необходимый ему для работы:
Таким образом, мы хотели бы объявить некий контракт и сообщить клиентскому коду, что он не должен передавать Login и CertificatePath со значениями null. Поэтому класс SomeClientOptions можно было бы написать как-то так:
Второе вполне очевидное требование к приложению в целом (особенно это актуально для asp.net core): иметь возможность получать наш SomeClientOptions из какого-нибудь json файла, который можно удобно модифицировать во время деплоя.
Поэтому дописываем одноименную секцию в appsettings.json:
Ну а теперь вопрос: как нам создать объект SomeClientOptions и гарантировать, что все NotNull поля не будут возвращать null не при каких обстоятельствах?
Мне хотелось бы написать примерно такой блок кода, а не строчить статью на Хабр:
Но этот код неработоспособен, т.к. метод Get() накладывает ряд ограничений на тип, с которым работает:
Учитывая указанные ограничения, мы вынуждены переделать класс SomeClientOptions примерно таким образом:
Думаю, вы со мной согласитесь, такое решение не является ни красивым, ни правильным. Как минимум потому, что клиенту ничего не мешает просто создать этот тип через конструктор и передать его объекту SomeClient — на этапе компиляции не будет выдано ни единого предупреждения, а в рантайме получим заветный NRE.
Примечание: В качестве проверки на null я буду использовать string.IsNullOrEmpty(), т.к. в большенстве случаев пустую строку можно интерпретировать как незаданное значение
Предлагаю сначала разобрать несколько правильных способов решить задачу, которые имеют очевидные недостатки.
Можно разбить SomeClientOptions на два объекта, где первый используется для десериализации, а второй производит валидацию:
Считаю это решение достаточно простым и изящным, за исключением того, что программисту каждый раз придется создавать на один класс больше и дублировать набор свойств.
Гораздо правильнее изначально было бы использовать в SomeClient вместо SomeClientOptions интерфейс ISomeClientOptions (как мы убедились, реализация может очень сильно зависеть от окружения).
Второй (менее элегантный) способ — вытаскивать «вручную» значения из IConfiguration:
Такой подход мне не нравится из-за необходимости самостоятельной реализации процесса парсинга и конвертации типов.
К тому же, вы не считаете, что слишком уж много сложностей для такой маленькой задачи?
Основная идея заключается в том, чтобы для интерфейса ISomeClientOptions генерировать реализацию в runtime, включающую все необходимые проверки. В статье я хочу предложить лишь концепт решения. Если тема достаточно заинтересует сообщество, подготовлю nuget-пакет для боевого применения (с открытыми исходниками на гитхабе).
Для простоты реализации, я разбил всю процедуру на 3 логические части:
Пример использования:
Таким образом, использование nullabe reference types не так тривиально, как может показаться на первый взгляд. Этот инструмент позволяет лишь снизить количество NRE, а не избавиться от них полностью. Да и многие библиотеки еще не аннатированы должным образом.
Спасибо за уделенное внимание. Надеюсь, вам понравилась статья.
Расскажите, сталкивались ли вы с подобной проблемой и как обходили ее. Буду благодарен за комментарии к предложенному решению.
Но все ли так гладко? Под катом я хочу разобрать и предложить решение одной конкретной проблемы.
Постановка задачи
Примечание: Подразумевается, что весь код в этой статье будет компилироваться с параметрами проекта:
<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;
}
}
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 reference types не так тривиально, как может показаться на первый взгляд. Этот инструмент позволяет лишь снизить количество NRE, а не избавиться от них полностью. Да и многие библиотеки еще не аннатированы должным образом.
Спасибо за уделенное внимание. Надеюсь, вам понравилась статья.
Расскажите, сталкивались ли вы с подобной проблемой и как обходили ее. Буду благодарен за комментарии к предложенному решению.