65.9K
CodeProject 正在变化。 阅读更多。
Home

DateOnly 在 .NET 6 和 ASP.NET Core 6 中

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2022年2月22日

CPOL

7分钟阅读

viewsIcon

36807

.NET 7 之前(已更新至 .NET 7)的 ASP.NET Core 6 中使用 DateOnly 的解决方案

引言

DateOnly 是 .NET 6 中一个新引入的原生数据类型。显然,它适用于表示、传递和存储仅日期信息,例如 DateOrBirth(出生日期)、RegisterDate(注册日期)和 WhatEverEventDate(任何事件日期)。

过去,.NET(Framework 或 Core)开发者主要使用三种方法:

  1. 使用 string 类型,例如 yyyy-MM-ddyyyyMMdd。然后将对象转换为 DateTime 进行日期跨度计算。
  2. 使用 DateTimeDateTimeOffset,并确保 TimeOfDay 为零。在进行跨时区转换时需要格外小心。
  3. 使用 Noda Time 库或类似库。然而,使用额外的库可能会根据具体情况带来一些负面影响。

因此,拥有一个专用的仅日期类型确实是一件好事。但是,我发现 DateOnly 在 ASP.NET Core 或 System.Text.Json 中尚未得到妥善支持。如果您在 Web API 中使用 DateOnly,很快就会在绑定和序列化方面遇到麻烦。

本文提供了在 .NET 7 推出之前,在 ASP.NET Core 6 中利用 DateOnly 的解决方案。

.NET 7 的重要更新

截至 2022 年 11 月 9 日发布的 .NET 7,微软的 .NET 开发团队已经修复了本文中提到的大部分问题。如果您使用的是 ASP.NET 7 并且不使用 JavaScript 客户端,可以跳过本文。如果您已经使用了本文提供的库,迁移到 ASP.NET 7 的应用程序不会出现中断,因为微软的 .NET 团队在 .NET 7 中显然使用了一些类似的解决方案。 

如果您有使用 JavaScript 客户端并且在请求载荷中序列化仅日期信息,那么您可能对进一步的提示 "带有 JavaScript 客户端的 ASP.NET 7 中的 DateOnly" 感兴趣。

背景

过去,我使用 Newtonsoft.Json.Converters.IsoDateTimeConverter 的派生类来处理仅日期信息。

    public class DateAndTimeConverter : IsoDateTimeConverter
    {
        static readonly Type typeOfDateTime = typeof(DateTime);
        static readonly Type typeOfNullableDateTime = typeof(DateTime?);
        static readonly Type typeOfDateTimeOffset = typeof(DateTimeOffset);
        static readonly Type typeOfNullDateTimeOffset = typeof(DateTimeOffset?);

        public override void WriteJson
        (JsonWriter writer, object value, JsonSerializer serializer)
        {
            var type = value.GetType();
            if (type == typeOfDateTimeOffset)
            {
                var dto = (DateTimeOffset)value;
                if (dto == DateTimeOffset.MinValue)
                {
                    writer.WriteNull();
                    return;
                }
                else if (dto.TimeOfDay == TimeSpan.Zero)
                {
                    writer.WriteValue(dto.ToString("yyyy-MM-dd"));
                    return;
                }
            }
            else if (type == typeOfNullDateTimeOffset)
            {
                var dto = (DateTimeOffset?)value;
                if (!dto.HasValue || dto.Value == DateTimeOffset.MinValue)
                {
                    writer.WriteNull();
                    return;
                }
                else if (dto.Value.TimeOfDay == TimeSpan.Zero)
                {
                    writer.WriteValue(dto.Value.ToString("yyyy-MM-dd"));
                    return;
                }
            }
            else if (type == typeOfDateTime)
            {
                var dt = (DateTime)value;
                if (dt.TimeOfDay == TimeSpan.Zero)
                {
                    writer.WriteValue(dt.ToString("yyyy-MM-dd"));
                    return;
                }
            }
            else if (type == typeOfNullableDateTime)
            {
                var dto = (DateTime?)value;
                if (!dto.HasValue || dto.Value == DateTime.MinValue)
                {
                    writer.WriteNull();
                    return;
                }
                else if (dto.Value.TimeOfDay == TimeSpan.Zero)
                {
                    writer.WriteValue(dto.Value.ToString("yyyy-MM-dd"));
                    return;
                }
            }

            base.WriteJson(writer, value, serializer);
        }
    }

如今,我更倾向于使用 JsonConverter<T>。这种方法看起来更简洁,而且更灵活。而 System.Text.Json 有一个具有类似接口的类

Using the Code

DateOnlyJsonConverter 是 nuget 包 Fonlow.DateOnlyExtensions 中的一个转换器。您应该在 ASP.NET Core 控制器和 .NET 客户端中都使用 DateOnlyJsonConverter

public sealed class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
    public override void WriteJson
    (JsonWriter writer, DateOnly value, JsonSerializer serializer)
    {
        writer.WriteValue(value.ToString("O"));
    }

    public override DateOnly ReadJson
    (JsonReader reader, Type objectType, DateOnly existingValue, 
     bool hasExistingValue, JsonSerializer serializer)
    {
        var v = reader.Value;
        if (v == null)
        {
            return DateOnly.MinValue;
        }

        var vType = v.GetType();
        if (vType == typeof(DateTimeOffset)) //when the object is from a property 
                                             //in POST body. When used in service, 
                                             //better to have 
                                    //options.SerializerSettings.DateParseHandling = 
                                    //Newtonsoft.Json.DateParseHandling.DateTimeOffset;
        {
            return DateOnly.FromDateTime(((DateTimeOffset)v).DateTime);
        }

        if (vType == typeof(string))
        {
            return DateOnly.Parse((string)v); //DateOnly can parse 00001-01-01
        }

        if (vType == typeof(DateTime)) //when the object is from a property 
                                       //in POST body from a TS client
        {
            return DateOnly.FromDateTime((DateTime)v);
        }

        throw new NotSupportedException
              ($"Not yet support {vType} in {this.GetType()}.");
    }
}

在您的 ASP.NET Core Startup 代码中,将转换器注入到控制器中。

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers()
            .AddNewtonsoftJson(
                options =>
                {
                    options.SerializerSettings.DateParseHandling = 
                            Newtonsoft.Json.DateParseHandling.DateTimeOffset;
                    options.SerializerSettings.Converters.Add
                                               (new DateOnlyJsonConverter());
                    options.SerializerSettings.Converters.Add
                                               (new DateOnlyNullableJsonConverter());
                }
            );

在您使用 HttpClient 的 .NET 客户端代码中,将转换器添加到 JsonSerializerSettings

var jsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings()
{
    NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore,
};

jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter());
jsonSerializerSettings.Converters.Add(new DateOnlyNullableJsonConverter());
Api = new DemoWebApi.Controllers.Client.SuperDemo(httpClient, jsonSerializerSettings);

public partial class SuperDemo
{
    private System.Net.Http.HttpClient client;

    private JsonSerializerSettings jsonSerializerSettings;

    public SuperDemo(System.Net.Http.HttpClient client, 
                     JsonSerializerSettings jsonSerializerSettings = null)
    {
        if (client == null)
            throw new ArgumentNullException("Null HttpClient.", "client");

        if (client.BaseAddress == null)
            throw new ArgumentNullException("HttpClient has no BaseAddress", "client");

        this.client = client;
        this.jsonSerializerSettings = jsonSerializerSettings;
    }

    public System.DateOnly PostDateOnly(System.DateOnly d, 
           Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/SuperDemo/DateOnly";
        using (var httpRequestMessage = 
               new HttpRequestMessage(HttpMethod.Post, requestUri))
        {
            using (var requestWriter = new System.IO.StringWriter())
            {
                var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
                requestSerializer.Serialize(requestWriter, d);
                var content = new StringContent(requestWriter.ToString(), 
                              System.Text.Encoding.UTF8, "application/json");
                httpRequestMessage.Content = content;
                if (handleHeaders != null)
                {
                    handleHeaders(httpRequestMessage.Headers);
                }

                var responseMessage = client.SendAsync(httpRequestMessage).Result;
                try
                {
                    responseMessage.EnsureSuccessStatusCodeEx();
                    var stream = responseMessage.Content.ReadAsStreamAsync().Result;
                    using (JsonReader jsonReader = new JsonTextReader
                                      (new System.IO.StreamReader(stream)))
                    {
                        var serializer = JsonSerializer.Create(jsonSerializerSettings);
                        return serializer.Deserialize<System.DateOnly>(jsonReader);
                    }
                }
                finally
                {
                    responseMessage.Dispose();
                }
            }
        }
    }

现在,DateOnly 几乎可以在所有情况下用于 ASP.NET Core 6。

更多示例可以在测试套件中找到。

关注点

URL 中的 DateOnly?

到目前为止,通过自定义 JsonConverters,您可以在 HTTP POST 请求体和返回结果中使用 DateOnly 对象,作为独立对象或复杂对象的属性值。但是,将 DateOnly 对象用作 URL 的一部分目前还不可行,因为自定义 JsonConverter 不起作用,而是由“Microsoft.AspNetCore.Routing.EndpointMiddleware”以及可能使用 System.Text.JsonMicrosoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexObjectModelBinder 处理。

显然,微软的 ASP.NET Core 团队需要做些什么,才能像对待 DateTimeOffset 一样对待 DateOnly。目前,DateOnly 没有被列入 .NET Core 6 ASP.NET Core 模型绑定中的简单类型。

尽管如此,这对于应用程序开发者来说并不是一个大问题,他们可以简单地使用 string 类型而不是 DateOnly 类型作为 URL 参数,并传递 ISO 8601 日期字符串。例如:

[HttpGet]
[Route("DateOnlyStringQuery")]
public DateOnly QueryDateOnlyAsString([FromQuery] string d)
{
    return DateOnly.Parse(d);
}

[Fact]
public async void TestQueryDateOnlyString()
{
    DateOnly d = new DateOnly(2008, 12, 18);
    var r = await api.QueryDateOnlyAsStringAsync(d.ToString("O"));
    Assert.Equal(d, r);
}

或者,直接使用 POST

代码生成器怎么样?

编写与 Web API 通信的客户端代码听起来重复且乏味。如今,许多开发者更喜欢使用代码生成器来生成客户端 API 代码。您可以尝试 WebApiClientGenOpenApiClientGen,它们都可以生成类似上述的客户端 API 代码。

Newtonsoft.Json 还是 System.Text.Json?

截至 .NET 6,仍然有一些 System.Text.Json 无法正确处理而 Newtonsoft.Json 可以处理的情况。有关更多详细信息,请阅读"ASP.NET Core 6 中 Newtonsoft.Json 与 System.Text.Json 的对比"。

.NET Framework 客户端怎么办?

显然,微软没有计划将 DateOnly 反向移植到 .NET Framework。那么,如果您有需要维护的 .NET Framework 客户端应用程序,并希望与使用 DateOnly 的 ASP.NET Core Web API 服务进行通信,您能怎么做?

您可以使用 nuget 包 Fonlow.DateOnlyExtensionsNF 中的 DateTimeOffsetJsonConverterDateTimeJsonConverter。有关与使用 DateOnly 的 ASP.NET Core Web API 进行通信的示例,可以在此测试套件中找到。

WebApiClientGen 生成的 C# 客户端 API 代码始终将 DateOnly 映射到 DateOnly。而 OpenApiClientGen 有一个名为“DateToDateOnly”的设置,默认为 True。如果您希望生成的代码同时被 .NET Framework 客户端和 .NET 客户端使用,可以将“DateToDateOnly”设置为 true。复制生成的代码,并将所有“DateOnly”标识符替换为“DateTimeOffset”。通过 PowerShell 脚本自动化这种变体的生成代码并不难。

.NET Framework 客户端应用程序代码

var jsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings()
{
    NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore,
};

jsonSerializerSettings.DateParseHandling = 
    Newtonsoft.Json.DateParseHandling.DateTimeOffset; //needed to make sure 
                    //JSON serializers assume DateTimeOffset rather than DateTime.
jsonSerializerSettings.Converters.Add
    (new DateTimeOffsetJsonConverter()); //needed to handle DateOnly.MinValue
jsonSerializerSettings.Converters.Add
    (new DateTimeOffsetNullableJsonConverter()); //needed to handle DateOnly.MinValue
Api = new DemoWebApi.Controllers.Client.DateTypes(httpClient, jsonSerializerSettings);

[Fact]
public void TestPostDateOnly()
{
    var dateOnly = new DateTimeOffset(1988, 12, 23, 0, 0, 0, TimeSpan.Zero);
    var r = api.PostDateOnly(dateOnly);
    Assert.Equal(dateOnly.Date, r.Date);
    Assert.Equal(DateTimeOffset.Now.Offset, r.Offset); //Local date start, 
    //because the return  object is "1988-12-23". 
    //No matter the client sends "2022-03-12" or "2022-03-12T00:00:00+00:00" 
    //or "2022-03-12T00:00:00Z"
    Assert.Equal(TimeSpan.Zero, r.TimeOfDay);
}

您可能会注意到需要一个额外的设置 DateParseHandling。这可以确保跨时区通信保留正确时区信息,而 NewtonSoft.Json JsonConverte.ReadJson() 默认将 DateTimeOffset 读取为 DateTime,从而丢失时区信息。相比之下,在 .NET 6 中,我们不需要在客户端使用 DateTimeOffset 来处理来自服务器的 DateOnly,因此不需要转换器。

JavaScript 或 TypeScript 客户端怎么办?

您的 JavaScript 客户端只能使用 Date 对象与 Web API 通信,而 Web API 总是为仅日期数据返回 yyyy-MM-dd 字符串。幸运的是,JavaScript 可以很好地处理这个问题,可能是因为 Date 对象在内部始终使用 UTC 来存储数据。 WebApiClientGenOpenApiClientGen 可以为 jQuery、Angular 2+、AXIOS、Aurelia 和 Fetch API 生成客户端 API。在“**DateTypes API**”类别下的此测试套件中,您可以看到 TypeScript 应用程序如何使用生成的客户端 API 代码来处理 DateOnly

日期选择器

您的客户端程序可能使用一些日期选择器组件。在 .NET 中,您需要确保日期选择器组件与 DateOnly 兼容,否则,您可能需要坚持当前的数据绑定实践。

如果您正在使用日期选择器组件开发 Web UI,您需要确保所选的日期,例如“1980-01-01”,以“1980-01-01T00:00:00.000Z”的形式存储在 Date 对象中,而不是“1979-12-31T14:00:00.000Z”(我在澳大利亚 +10 时区)。

例如,在开发 Angular SPA 时,我使用 Angular Material Components 的 DatePicker 组件。为了确保获得的 Date 对象是“1980-01-01T00:00:00.000Z”,可以有两种方法。

@NgModule/providers 中,提供以下内容:

{ provide: DateAdapter, useClass: MomentDateAdapter, 
  deps: [MAT_DATE_LOCALE, MAT_MOMENT_DATE_ADAPTER_OPTIONS] },
{ provide: MAT_DATE_FORMATS, useValue: MAT_MOMENT_DATE_FORMATS },
{ provide: MAT_MOMENT_DATE_ADAPTER_OPTIONS, useValue: { useUtc: true, strict: true } },

在包含许多懒加载模块的复杂业务应用程序中,您可能需要在每个懒加载模块或组件中声明这些提供者,而第三方组件可能偏好 DatePicker 的其他设置。

备注

使用上述设置的日期选择器可能会用一个圆圈高亮显示今天的日期,该圆圈与 UTC 的今天匹配。例如,如果您在 +10 时区,并在上午 10 点之前使用日期选择器,您可能会看到昨天日期的圆圈高亮。这不太理想,但是,如果您将此设置用于出生日期,大多数情况下不会有问题,因为数据库中的大多数出生日期应该是几个月前的。

将 DateTime 对象映射到仅日期信息的约定

DateTime 对象映射到仅日期信息的信号是设置 TimeZone=ZeroTimeOfDate=Zero。显然,Moment.JS 团队和 Angular Material Components 团队使用了相同的协议。同样,.NET 客户端和 ASP.NET Core Web API 应使用相同的转换器集来确保处理 DateTimeDateTimeOffset 的协议,否则,不仅仅日期情况会遇到麻烦,而且 DateTime.MinDateTimeOffset.Min 在跨时区时也会遇到麻烦。

备注

如果您有一个只能存储 DateTime 用于仅日期信息的旧数据库,您需要检查该应用程序是如何存储日期的。

DateOnly 和数据库

显然,并非所有数据库引擎都支持仅日期列。据我所知,MS SQL Server 2016MySql 支持仅日期数据类型。

使用 Entity Framework Code First,相应的特定数据库库应将 DateOnly 映射到 Date 列类型。

集成测试

在开发分布式应用程序时,处理 DateTimeDateOnly 时的跨时区问题非常重要。在集成测试期间,您应该将服务和客户端放在不同时区的机器/虚拟机上。我在澳大利亚,通常我会将测试客户端或集成测试套件放在 +10:00 时区,而服务放在 UTC 或 -10:00 时区。

历史

  • 2022 年 2 月 22 日:初始版本
© . All rights reserved.