2022年11月,随着.NET 7的发布,微软发布了新版本的C#语言:C# 11。与之前C#的大版本更新相比,例如,C# 2.0引入了泛型,C# 3.0引入了LINQ和Lambda表达式,C# 4.0引入了协变与逆变、C# 5.0引入了async/await等等,C# 11则更多地是进一步完善C#语言本身的设计,其中,泛型属性(Generic Attribute,也叫“泛型特性”,为了与类的Property“属性”区分,下文直接使用“Attribute”英文单词)就是在Attribute定义上的一种增强,它允许将自定义的Attribute被约束到一个特定的类型上,使得Attribute本身的实现获得强类型的支持,而不需要在实现Attribute时,将object类型的值强行转换到指定的类型上。
这样解释有点难以理解,先举个例子:假设我们需要为Person对象提供一个UI上的编辑器,一种较好的做法就是定义一个Attribute,在Attribute上指定所采用的编辑器的类型,然后,在Person对象的每个属性上应用这个Attribute,于是,在程序生成Person对象编辑界面的时候,就可以根据Person对象每个属性上的Attribute来动态生成界面。这样设计的最大好处就是,界面生成和数据绑定的逻辑可以完全重用,并且可以按需扩展,非常方便;而且重用和扩展不会修改已有代码,大大减少测试工作。
这个例子的实现代码大致如下:
internal abstract class Editor { public abstract void GenerateUI(); } internal sealed class NameEditor : Editor { public NameEditor() { } public override void GenerateUI() { } } internal sealed class DateEditor : Editor { public DateEditor() { } public override void GenerateUI() { } } [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] internal class EditorAttribute : Attribute { public EditorAttribute(Type editorType) => EditorType = editorType; public Type EditorType { get; } public object GetEditor() => Activator.CreateInstance(EditorType); } internal record PersonName(string FirstName, string LastName); internal class Person { [Editor(typeof(NameEditor))] public PersonName Name { get; set; } [Editor(typeof(DateEditor))] public DateTime DayOfBirth { get; set; } }
在上面的代码中,EditorAttribute用于标记Person类型的Name和DayOfBirth属性将使用NameEditor和DateEditor来提供编辑功能,在完成这部分的实现后,运行程序,将看到类似下面的界面效果,这个UI是程序自动生成的:
在C# 11引入“泛型属性”之后,上面的代码可以改成下面的形式:
internal abstract class Editor { public abstract void GenerateUI(); } internal sealed class NameEditor : Editor { public NameEditor() { } public override void GenerateUI() { } } internal sealed class DateEditor : Editor { public DateEditor() { } public override void GenerateUI() { } } [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] internal class EditorAttribute<TEditor> : Attribute where TEditor : Editor, new() { public TEditor GetEditor() => new(); } internal record PersonName(string FirstName, string LastName); internal class Person { [Editor<NameEditor>] public PersonName Name { get; set; } [Editor<DateEditor>] public DateTime DayOfBirth { get; set; } }
请注意上面代码的高亮部分,这些就是应用了“泛型属性”之后的效果,与之前的那个版本相比,使用泛型属性至少有以下几个优势:
- EditorAttribute被约束到TEditor泛型类型上,而TEditor的泛型约束指定了它必须是Editor及其子类,并且要有一个默认的构造函数,这可以充分利用C#强类型语言的特点,代码编程方面更加不容易出错(比如你不会将int类型作为一种数据编辑器类型,而在你的类的Property上使用[Editor(typeof(int)]这样的错误写法)
- GetEditor方法将直接调用指定类型的构造函数以返回对象,不仅提高性能,而且对于调用方来说,它无需再根据EditorType来做类型强制转换,不会打破面向对象设计的原则(原本返回object类型的地方,给它强制转换成Editor相关的类型是不妥的,因为Editor类型是object的一种,但object不一定非要是Editor类型)
- 简化代码(请与前一版本的EditorAttribute类进行比较)
这部分其实是C#11对于Attribute这一伴随着该语言二十几年发展的语言功能的一种增强,结合C#的反射机制,它能实现强大的面向代码的元数据读取能力,使得应用程序在获取读取对象元数据之后,能够根据元数据的内容进行更多操作,甚至改变对象本身的状态。从面向对象设计的角度,它会打破一些常规的设计思路:通过反射和Attribute提供的C#所特有的语言特性(语言惯用法),我们可以总结出更多的代码设计模式,并在实际项目中使用这些模式,以提供更好的功能性扩展并降低开发和测试成本。
回到实际项目中,在一个ASP.NET Core Web API的应用程序中,设计两个POST的API,它们分别用于保存某个城市的温度和风力风向数据。在项目一开始的时候,为了简化编程,我们设计了一个名为“天气数据(WeatherData)”的基类,它有两个属性:City和Data,City用于保存城市名称,而Data则根据不同的API保存不同的数据:如果是温度,则Data保存摄氏度的数值,如果是风力,则Data保存风力的级别(无风、微风、大风等)。当然,对于风力风向数据,除了风力之外,还有风向(Direction),这些可以在WeatherData的子类中实现,于是,就有了如下的类的结构:
于是,在ASP.NET Core Web API中,就可以实现下面的API端点:
[HttpPost("temp")] public IActionResult CreateTemperatureData(TemperatureData td) { return Ok(); } [HttpPost("wind")] public IActionResult CreateWindData(WindData wd) { return Ok(); }
ASP.NET Core Web API有一个非常实用的功能,就是在API被调用之前,会根据需要,对传入参数进行验证,如果验证不通过,则会直接返回400 Bad Request,而不会继续执行API。比如,如果在WeatherData的City属性上指定了[Required] Attribute,那么如果在POST payload中没有指定City的值,API就会直接返回400的错误,而不会继续执行上面的CreateTemperatureData或者是CreateWindData方法:
daxne@daxnet-laptop:~$ curl -X POST -H 'Content-Type: application/json' http://$(hostname).local:5190/WeatherForecast/temp -d '{"data": "23"}' | json_pp % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 255 0 241 100 14 64404 3741 --:--:-- --:--:-- --:--:-- 85000 { "errors" : { "City" : [ "The City field is required." ] }, "status" : 400, "title" : "One or more validation errors occurred.", "traceId" : "00-8334d1b3f6b02482e6f35cb2ddea06a0-3ecb2630b1878d95-00", "type" : "https://tools.ietf.org/html/rfc7231#section-6.5.1" }
ASP.NET Core还支持很多类似[Required]这样的验证特性(Validation Attribute),可以查看System.ComponentModel.DataAnnotations命名空间的相关文档,当然,开发人员还可以根据业务需要,自定义各种不同的验证特性,其基本操作就是实现一个继承于System.ComponentModel.DataAnnotations.ValidationAttribute的Attribute,然后将这个Attribute应用到所需的类型属性上即可。在实际项目中,我们就遇到了这样的需求:在CreateTemperatureData API上,我们希望ASP.NET Core帮助我们验证请求payload中的Data属性,要确保该属性中保存的是一个数值类型的值。假设我们已经实现了这样一个自定义的Validation Attribute,要将这个Attribute应用到WeatherData的Data属性上,就明显不是合理的做法,因为它还有另外一个子类:WindData,直接在基类WeatherData上验证Data的值是否是数值类型,会同时影响到CreateWindData API,此时,ASP.NET Core也会要求WindData的Data属性值也要是数值类型的,而这又不是我们所期望的。
于是,可以借助C# 11的“泛型属性”新功能,来设计我们的Validation Attribute:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] public class RequiresNumericValueOnAttribute<T> : ValidationAttribute where T : WeatherData { protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { if (validationContext.ObjectInstance is T && value is string strValue) { return int.TryParse(strValue, out _) || float.TryParse(strValue, out _) ? ValidationResult.Success : new ValidationResult( "The provided value is not a numeric value", new string[] { validationContext.MemberName ?? string.Empty }); } return ValidationResult.Success; } }
然后,在WeatherData类的Data属性上,使用这个ValidationAttribute:
public class WeatherData { [Required] public string? City { get; set; } [RequiresNumericValueOn<TemperatureData>] public string? Data { get; set; } }
注意这里的高亮部分代码:
- 我们使用了C# 11的泛型Attribute进行自定义的数据验证
- 类的命名上,采用“RequiresNumericValueOnAttribute”,这样,在使用的时候,就是“Requires numeric value on TemperatureData”,这样可读性更强
下面,测试一下,在调用GreateTemperatureData时,如果data还是传入一个字符串的值,则API会直接返回400 Bad Request:
daxne@daxnet-laptop:~$ curl -X POST -H 'Content-Type: application/json' http://$(hostname).local:5190/WeatherForecast/temp -d '{ "city": "string", "data": "string" }' | json_pp % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 297 0 255 100 42 4297 707 --:--:-- --:--:-- --:--:-- 5033 { "errors" : { "Data" : [ "The provided value is not a numeric value" ] }, "status" : 400, "title" : "One or more validation errors occurred.", "traceId" : "00-a3c65a558e632aeab6c1933e063f753b-b05e7cc5fa8a98d4-00", "type" : "https://tools.ietf.org/html/rfc7231#section-6.5.1" }
但如果传入一个数值型的值,则API调用成功(返回200 OK):
daxne@daxnet-laptop:~$ curl -X 'POST' http://$(hostname).local:5190/WeatherForecast/temp \ -H 'accept: */*' \ -H 'Content-Type: application/json' \ -d '{ "city": "string", "data": "23.4" }' -v Note: Unnecessary use of -X or --request, POST is already inferred. * Trying 172.23.16.1:5190... * Connected to daxnet-laptop.local (172.23.16.1) port 5190 (#0) > POST /WeatherForecast/temp HTTP/1.1 > Host: daxnet-laptop.local:5190 > User-Agent: curl/7.81.0 > accept: */* > Content-Type: application/json > Content-Length: 40 > * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < Content-Length: 0 < Date: Wed, 05 Apr 2023 10:40:42 GMT < Server: Kestrel < * Connection #0 to host daxnet-laptop.local left intact
而在调用CreateWindData API时,即使Data指定的是一个字符串类型的值,API仍然会成功返回,也就是在这个API上,ASP.NET Core不会对Data进行值的验证:
daxne@daxnet-laptop:~$ curl -X 'POST' http://$(hostname).local:5190/WeatherForecast/wind \ -H 'accept: */*' \ -H 'Content-Type: application/json' \ -d '{ "city": "string", "data": "string", "direction": "string" }' -v Note: Unnecessary use of -X or --request, POST is already inferred. * Trying 172.23.16.1:5190... * Connected to daxnet-laptop.local (172.23.16.1) port 5190 (#0) > POST /WeatherForecast/wind HTTP/1.1 > Host: daxnet-laptop.local:5190 > User-Agent: curl/7.81.0 > accept: */* > Content-Type: application/json > Content-Length: 67 > * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < Content-Length: 0 < Date: Wed, 05 Apr 2023 10:44:56 GMT < Server: Kestrel < * Connection #0 to host daxnet-laptop.local left intact
简单总结:随着C#语言版本更新,带来了更多的语言特性,在项目中合理使用这些新特性,可以帮助我们做出更优雅的代码设计,无论是技术上还是工程上,都会给我们带来更大的价值。