DateOnly 在 .NET 6 和 ASP.NET Core 6 中
.NET 7 之前(已更新至 .NET 7)的 ASP.NET Core 6 中使用 DateOnly 的解决方案
引言
DateOnly 是 .NET 6 中一个新引入的原生数据类型。显然,它适用于表示、传递和存储仅日期信息,例如 DateOrBirth(出生日期)、RegisterDate(注册日期)和 WhatEverEventDate(任何事件日期)。
过去,.NET(Framework 或 Core)开发者主要使用三种方法:
- 使用
string类型,例如yyyy-MM-dd或yyyyMMdd。然后将对象转换为DateTime进行日期跨度计算。 - 使用
DateTime或DateTimeOffset,并确保TimeOfDay为零。在进行跨时区转换时需要格外小心。 - 使用 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.Json 的 Microsoft.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 代码。您可以尝试 WebApiClientGen 和 OpenApiClientGen,它们都可以生成类似上述的客户端 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.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 来存储数据。 WebApiClientGen 和 OpenApiClientGen 可以为 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=Zero 和 TimeOfDate=Zero。显然,Moment.JS 团队和 Angular Material Components 团队使用了相同的协议。同样,.NET 客户端和 ASP.NET Core Web API 应使用相同的转换器集来确保处理 DateTime 和 DateTimeOffset 的协议,否则,不仅仅日期情况会遇到麻烦,而且 DateTime.Min 和 DateTimeOffset.Min 在跨时区时也会遇到麻烦。
备注
如果您有一个只能存储 DateTime 用于仅日期信息的旧数据库,您需要检查该应用程序是如何存储日期的。
DateOnly 和数据库
显然,并非所有数据库引擎都支持仅日期列。据我所知,MS SQL Server 2016 和 MySql 支持仅日期数据类型。
使用 Entity Framework Code First,相应的特定数据库库应将 DateOnly 映射到 Date 列类型。
集成测试
在开发分布式应用程序时,处理 DateTime 和 DateOnly 时的跨时区问题非常重要。在集成测试期间,您应该将服务和客户端放在不同时区的机器/虚拟机上。我在澳大利亚,通常我会将测试客户端或集成测试套件放在 +10:00 时区,而服务放在 UTC 或 -10:00 时区。
历史
- 2022 年 2 月 22 日:初始版本
