But is everything so smooth? Under the cut, I want to disassemble and offer a solution to one specific problem.
Formulation of the problem
Note: It is assumed that all code in this article will compile with the project parameters:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
Suppose we want to write a class that takes a specific set of parameters that it needs to work:
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() }");
}
}
Thus, we would like to declare some kind of contract and tell the client code that it should not pass Login and CertificatePath with null values. Therefore, the SomeClientOptions class could be written something like this:
public sealed class SomeClientOptions
{
public string Login { get; set; }
public string CertificatePath { get; set; }
public SomeClientOptions(string login, string certificatePath)
{
Login = login;
CertificatePath = certificatePath;
}
}
The second quite obvious requirement for the application as a whole (this is especially true for the asp.net core): to be able to get our SomeClientOptions from some json file, which can be conveniently modified during deployment.
Therefore, we add the section of the same name to appsettings.json:
{
"SomeClientOptions": {
"Login": "ferzisdis",
"CertificatePath": ".\full_access.pfx"
}
}
Now the question is: how do we create a SomeClientOptions object and ensure that all NotNull fields will not return null under any circumstances?
Naive attempt at using built-in tools
I would like to write something like the following block of code, and not write an article on 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);
}
}
But this code is not functional because The Get () method imposes a number of restrictions on the type it works with:
- Type T must be non-abstract and contain a public parameterless constructor
- Property heters should not throw exceptions
Taking into account the specified restrictions, we are forced to remake the SomeClientOptions class something like this:
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!");
}
}
}
I think you will agree with me that such a decision is neither beautiful nor correct. At least because nothing prevents the client from simply creating this type through the constructor and passing it to the SomeClient object - not a single warning will be issued at the compilation stage, and at runtime we will get the coveted NRE.
Note: I will use string.IsNullOrEmpty () as a test for null, since in most cases, an empty string can be interpreted as an unspecified value
Better alternatives
First, I propose to analyze several correct ways to solve the problem, which have obvious disadvantages.
It is possible to split SomeClientOptions into two objects, where the first is used for deserialization, and the second performs validation:
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; }
}
I think this solution is quite simple and elegant, except that the programmer will have to create one more class each time and duplicate a set of properties.
It would be much more correct to use the ISomeClientOptions interface in SomeClient instead of SomeClientOptions (as we have seen, the implementation can be very dependent on the environment).
The second (less elegant) way is to manually pull values ββfrom 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!");
}
}
I don't like this approach because of the need to independently implement the parsing and type conversion process.
Besides, don't you think that there are too many difficulties for such a small task?
How not to write extra code by hand?
The main idea is to generate an implementation for the ISomeClientOptions interface at runtime, including all the necessary checks. In the article I want to offer only a concept of the solution. If the topic interests the community enough, I will prepare a nuget package for combat use (open source on github).
For ease of implementation, I split the entire procedure into 3 logical parts:
- Runtime implementation of the interface is created
- The object is deserialized by standard means
- The properties are checked for null (only those properties that are marked as NotNull are checked)
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;
}
}
Usage example:
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);
}
}
Conclusion
Thus, using nullabe reference types is not as trivial as it might seem at first glance. This tool only allows you to reduce the number of NREs, not get rid of them completely. Many libraries have not been properly annotated yet.
Thank you for your attention. Hope you enjoyed the article.
Tell us if you encountered a similar problem and how you got around it. I would be grateful for your comments on the proposed solution.