C#11新特性之“泛型属性”在实际项目中的运用

By | 2023年4月5日

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; }
}

注意这里的高亮部分代码:

  1. 我们使用了C# 11的泛型Attribute进行自定义的数据验证
  2. 类的命名上,采用“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#语言版本更新,带来了更多的语言特性,在项目中合理使用这些新特性,可以帮助我们做出更优雅的代码设计,无论是技术上还是工程上,都会给我们带来更大的价值。

(总访问量:799;当日访问量:1)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据