xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

.NET 6 で Microsoft.Extensions.Configuration から DateOnly / TimeOnly 型に直接マッピングする

.NET 6 で DateOnly および TimeOnly 型が追加されました。日付や時間のみを扱う (若干残念な名前を除けば) 待望の子ですね。

ところで、アプリケーション構成として日付や時間 (特に時間) を扱うことはちょくちょくあるのではないかと思います。現代の .NET (Core 系) でアプリケーションの構成情報を利用すると言えば Microsoft.Extensions.ConfigurationIConfiguration ですが、実はここから DateOnlyTimeOnly に直接マッピングできません、残念ながら。DateTimeDateTimeOffset などはできるのに!

Microsoft.Extensions.Configuration の実装を追いかけてみると TypeConverter を通してマッピングを図るようになっているのですが、.NET 6 時点では DateOnlyTimeOnly に対応する TypeConverter が登録されていないために動作しないんですね。この問題は .NET Runtime Team も認識していて、.NET 7 Preview 6 で既に修正されています。

でも我々は (というか僕は) .NET 6 でも使いたい!ということで .NET 6 でも DateOnlyConverterTimeOnlyConverter を利用できるようにしてしまいましょう。

TypeConverter を実装

まず .NET 7 で実装された DateOnlyConverterTimeOnlyConverter を持ってきます。一部コンパイルが通らない箇所があるのでそこだけ修正します。若干ずるいけどこれがベストなのだ...(ゴゴゴ

修正箇所

  • .NET 7 以降ではこの実装は不要なので #if NET6_0 を追加
  • String Resources を直接展開
  • .NET 7 で新規に追加された TimeOnly.Microsecond プロパティに関する箇所を削除

実装の全貌

そこそこ実装が長いので折り畳み状態にしました。ご了承ください。

DateOnlyConverter.cs

#if NET6_0

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.Design.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace System.ComponentModel;

/// <summary>
/// Provides a type converter to convert <see cref='System.DateOnly'/> objects to and from various other representations.
/// </summary>
public class DateOnlyConverter : TypeConverter
{
    /// <summary>
    /// Gets a value indicating whether this converter can convert an object in the given source type to a <see cref='System.DateOnly'/>
    /// object using the specified context.
    /// </summary>
    /// <inheritdoc />
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
    }

    /// <inheritdoc />
    public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType)
    {
        return destinationType == typeof(InstanceDescriptor) || base.CanConvertTo(context, destinationType);
    }

    /// <summary>
    /// Converts the given value object to a <see cref='System.DateOnly'/> object.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (value is string text)
        {
            text = text.Trim();
            if (text.Length == 0)
            {
                return DateOnly.MinValue;
            }

            try
            {
                // See if we have a culture info to parse with. If so, then use it.
                DateTimeFormatInfo? formatInfo = null;

                if (culture != null)
                {
                    formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));
                }

                if (formatInfo != null)
                {
                    return DateOnly.Parse(text, formatInfo);
                }
                else
                {
                    return DateOnly.Parse(text, culture);
                }
            }
            catch (FormatException e)
            {
                var message = $"{text} is not a valid value for {nameof(DateOnly)}.";
                throw new FormatException(message, e);
            }
        }

        return base.ConvertFrom(context, culture, value);
    }

    /// <summary>
    /// Converts the given value object from a <see cref='System.DateOnly'/> object using the arguments.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
    {
        if (destinationType == typeof(string) && value is DateOnly dateOnly)
        {
            if (dateOnly == DateOnly.MinValue)
            {
                return string.Empty;
            }

            culture ??= CultureInfo.CurrentCulture;

            DateTimeFormatInfo? formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));

            if (culture == CultureInfo.InvariantCulture)
            {
                return dateOnly.ToString("yyyy-MM-dd", culture);
            }

            string format = formatInfo!.ShortDatePattern;

            return dateOnly.ToString(format, CultureInfo.CurrentCulture);
        }

        if (destinationType == typeof(InstanceDescriptor) && value is DateOnly date)
        {
            return new InstanceDescriptor(typeof(DateOnly).GetConstructor(new Type[] { typeof(int), typeof(int), typeof(int) }), new object[] { date.Year, date.Month, date.Day });
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

#endif

TimeOnlyConverter.cs

#if NET6_0

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.Design.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace System.ComponentModel;

/// <summary>
/// Provides a type converter to convert <see cref='System.TimeOnly'/> objects to and from various other representations.
/// </summary>
public class TimeOnlyConverter : TypeConverter
{
    /// <summary>
    /// Gets a value indicating whether this converter can convert an object in the given source type to a <see cref='System.TimeOnly'/>
    /// object using the specified context.
    /// </summary>
    /// <inheritdoc />
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
    }

    /// <inheritdoc />
    public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType)
    {
        return destinationType == typeof(InstanceDescriptor) || base.CanConvertTo(context, destinationType);
    }

    /// <summary>
    /// Converts the given value object to a <see cref='System.TimeOnly'/> object.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (value is string text)
        {
            text = text.Trim();
            if (text.Length == 0)
            {
                return TimeOnly.MinValue;
            }

            try
            {
                // See if we have a culture info to parse with. If so, then use it.
                DateTimeFormatInfo? formatInfo = null;

                if (culture != null)
                {
                    formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));
                }

                if (formatInfo != null)
                {
                    return TimeOnly.Parse(text, formatInfo);
                }
                else
                {
                    return TimeOnly.Parse(text, culture);
                }
            }
            catch (FormatException e)
            {
                var message = $"{text} is not a valid value for {nameof(TimeOnly)}.";
                throw new FormatException(message, e);
            }
        }

        return base.ConvertFrom(context, culture, value);
    }

    /// <summary>
    /// Converts the given value object from a <see cref='System.TimeOnly'/> object using the arguments.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
    {
        if (destinationType == typeof(string) && value is TimeOnly timeOnly)
        {
            if (timeOnly == TimeOnly.MinValue)
            {
                return string.Empty;
            }

            culture ??= CultureInfo.CurrentCulture;

            DateTimeFormatInfo formatInfo = (DateTimeFormatInfo)culture.GetFormat(typeof(DateTimeFormatInfo))!;

            return timeOnly.ToString(formatInfo.ShortTimePattern, CultureInfo.CurrentCulture);
        }

        if (destinationType == typeof(InstanceDescriptor) && value is TimeOnly time)
        {
            if (time.Ticks == 0)
            {
                return new InstanceDescriptor(typeof(TimeOnly).GetConstructor(new Type[] { typeof(long) }), new object[] { time.Ticks });
            }

            return new InstanceDescriptor(typeof(TimeOnly).GetConstructor(new Type[] { typeof(int), typeof(int), typeof(int), typeof(int) }),
                                            new object[] { time.Hour, time.Minute, time.Second, time.Millisecond });
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

#endif

参照元

TypeConverter を登録

TypeConverter の porting ができたら、.NET Runtime が認識できるよう登録していきましょう。ザックリ以下のようにします。

#if NET6_0

public static class TypeConverterShims
{
    public static void Register()
    {
        register<DateOnly, DateOnlyConverter>();
        register<TimeOnly, TimeOnlyConverter>();

        static void register<TObject, TConverter>()
        {
            var attribute = new TypeConverterAttribute(typeof(TConverter));
            TypeDescriptor.AddAttributes(typeof(TObject), attribute);
        }
    }
}

#endif

最後にこれをアプリケーション起動時に一度呼び出せば OK です。IConfiguration.Get<T>(); などの前に呼び出すことを忘れずに!

TypeConverterShims.Register();