🗓️

EF CoreにおいてUTCで保存した日時をJSTに変換して表示する方法

2023/10/17に公開

概要

データベース上では日時をUTC:Universal time coordinated(協定世界時)として保存するが、アプリケーションの画面上ではJST(日本標準時)に変換して表示する方法をEntityFramework Coreを使用して説明します。

開発環境

  • .NET 7.0
  • Microsoft Visual Studio 2022 Community (64ビット) Version 17.7.3
  • C# 11.0
  • Windows 10 Pro 64bit 22H2 (OSビルド 19045.3208)
  • PostgreSQL 15.3

対象読者

  • EntityFramework Coreを使った開発をされている方
  • どうしても日時をUTCでデータベースに保存しないといけない方

デモアプリケーションの作成

Blazor ServerでWebアプリケーションを開発します。日時のデータをJSTとして表示/入力し、データベースに保存する時はUTCに変換するような簡単なWebアプリケーションを作成します。

前提条件

予めPostgreSQLをローカルコンピュータ上にインストールしているものとして進めていきます。別の場所にPostgreSQLを用意している場合はPostgreSQLのサーバー名を適宜読み替えてください。

プロジェクトの作成

  1. Blazor Serverのプロジェクトを作成します。
  2. プロジェクト名はEFCoreUTCとしました。
  3. 認証の種類は「なし」、HTTPS用の構成も今回は必要ないのでチェックを外します。
  4. NuGetパッケージの管理から下記のパッケージをインストールします。
  • Microsoft.EntityFrameworkCore.Design 7.0.12
  • Npgsql.EntityFrameworkCore.PostgreSQL 7.0.11

アプリケーションの実装

  1. エンティティ型を作成します。プロジェクト直下にModelsフォルダを作成し、Modelsフォルダ内にTodoクラスを作成します。
Models/Todo.cs
using System.ComponentModel.DataAnnotations;

namespace EFCoreUTC.Models
{
    public class Todo
    {
        public int Id { get; set; }
        [Required]
        [StringLength(20)]
        public string Title { get; set; } = null!;
        [Required]
        [StringLength(100)]
        public string Memo { get; set; } = null!;
        [Required]
        public DateTime ExecutionDate { get; set; }
    }
}
  1. DbContextを作成します。Dataフォルダ内にDbContextを継承したTodoContextクラスを作成します。
Data/TodoContext.cs
using EFCoreUTC.Models;
using Microsoft.EntityFrameworkCore;

namespace EFCoreUTC.Data
{
    public class TodoContext : DbContext
    {
        public DbSet<Todo> Todos { get; set; }

        public TodoContext(DbContextOptions options) : base(options)
        {
        }
    }
}
  1. appsettings.jsonにDB接続文字列を追記します。接続文字列はお使いの環境に合わせて適宜変更してください。
appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
+  "ConnectionStrings": {
+    "DefaultConnectionString": "Host=localhost;Username=user;Password=password;Database=Todo;Timeout=15"
+  },
  "AllowedHosts": "*"
}
  1. Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<TContext> インターフェイスを実装してEF CoreツールにDbContextの作成方法を伝えます。Dataフォルダ内にIDesignTimeDbContextFactory<TContext>インターフェースを実装したTodoContextFactoryクラスを作成します。
Data/TodoContextFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace EFCoreUTC.Data
{
    public class TodoContextFactory : IDesignTimeDbContextFactory<TodoContext>
    {
        public TodoContext CreateDbContext(string[] args)
        {
            //appsettings.jsonからDB接続文字列を取得する
            IConfigurationRoot configuration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json")
                .Build();
            var connectionString = configuration.GetConnectionString("DefaultConnectionString");

            var optionBuilder = new DbContextOptionsBuilder<TodoContext>();
            optionBuilder.UseNpgsql(connectionString);

            return new TodoContext(optionBuilder.Options);
        }
    }
}
  1. サービスを作成します。Dataフォルダ内にTodoServiceクラスを作成します。
Data/TodoService.cs
using EFCoreUTC.Models;
using Microsoft.EntityFrameworkCore;

namespace EFCoreUTC.Data
{
    public class TodoService
    {
        private readonly TodoContext _context;

        public TodoService(TodoContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Todo>> GetTodosAsync() =>
            await _context.Todos.OrderByDescending(x => x.ExecutionDate).ToListAsync();

        public async Task<int> AddAsync(Todo todo)
        {
            _context.Todos.Add(todo);
            return await _context.SaveChangesAsync();
        }
    }
}
  1. Todoの一覧を表示するページを作成します。PagesフォルダにTodosという名前のRazorコンポーネントを作成します。
Pages/Todos.razor
@page "/todos"
@using EFCoreUTC.Data;
@using EFCoreUTC.Models;
@inject TodoService TodoService;

<h1>Todos</h1>

@if (todos == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <div class="mt-4 mb-4"><a href="/todos/add">Add</a></div>

    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Title</th>
                <th>Memo</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var todo in todos)
            {
                <tr>
                    <td>@todo.ExecutionDate.ToString("yyyy/MM/dd HH:mm:ss")</td>
                    <td>@todo.Title</td>
                    <td>@todo.Memo</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private IEnumerable<Todo>? todos;

    protected override async Task OnInitializedAsync()
    {
        todos = await TodoService.GetTodosAsync();
    }
}
  1. Todoを追加するページを作成します。PagesフォルダにAddTodoという名前のRazorコンポーネントを作成します。
Pages/AddTodo.razor
@page "/todos/add"
@using EFCoreUTC.Data;
@using EFCoreUTC.Models;
@inject TodoService TodoService;
@inject NavigationManager NavigationManager;

<h1>Add Todo</h1>

<EditForm Model="@_model" OnValidSubmit="SubmitAsync">
    <DataAnnotationsValidator />
    <div class="container">
        <div class="row mb-2">
            <label for="title" class="col">
                Title:
                <InputText x:id="title" @bind-Value="@_model.Title" />
                <ValidationMessage For="@(()=>_model.Title)" />
            </label>
        </div>
        <div class="row mb-2">
            <label for="memo" class="col">
                Memo:
                <InputText @bind-Value="@_model.Memo" />
                <ValidationMessage For="@(()=>_model.Memo)" />
            </label>
        </div>
        <div class="row mb-2">
            <label for="executionDate" class="col">
                Execution Date:
                <InputDate Type="InputDateType.DateTimeLocal" @bind-Value:format="yyyy/MM/dd HH:mm:ss" @bind-Value="@_model.ExecutionDate" />
                <ValidationMessage For="@(()=>_model.ExecutionDate)" />
            </label>
        </div>
        <div class="row">
            <div class="col">
                <button type="submit">Submit</button>
            </div>
        </div>
    </div>
</EditForm>

@code {
    private Todo _model = new()
        {
            ExecutionDate = DateTime.Now
        };

    private async Task SubmitAsync(EditContext editContext)
    {
        if (!editContext.Validate()) return;

        //Add Todo
        await TodoService.AddAsync(_model);

        //Navigate to todos
        NavigationManager.NavigateTo("/todos");
    }
}
  1. Todoの一覧へのナビゲーションを追加します。Shared/NavMenu.razorにTodo一覧へのナビゲーションを追加します。
Shared/NavMenu.razor
<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">EFCoreUTC</a>
        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</div>

<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
    <nav class="flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </div>
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="todos">
+                <span class="oi oi-list-rich" aria-hidden="true"></span> Todo
+            </NavLink>
+        </div>
    </nav>
</div>

@code {
    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}
  1. EF Coreツールでデータベースを作成します。
dotnet ef migrations Add InitialCreate
dotnet ef database update
  1. Program.csを編集して、汎用ホストにサービスとDbContextを設定します。
Program.cs
using EFCoreUTC.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();
+builder.Services.AddTransient<TodoService>();

+// DbContext
+var constr = builder.Configuration.GetConnectionString("DefaultConnectionString");
+builder.Services.AddDbContext<TodoContext>(options =>
+    options.UseNpgsql(constr));

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
}


app.UseStaticFiles();

app.UseRouting();

app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();
  1. ここで一旦、デバッグ実行してみます。Todoの一覧を表示してAddリンクをクリックします。
  2. 必須項目を入力して、Submitをクリックします。すると例外が発生します。
  3. デバッグ出力を確認するとMicrosoft.EntityFrameworkCore.DbUpdateException例外が発生し、内部例外に
System.InvalidCastException: Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported. Note that it's not possible to mix DateTimes with different Kinds in an array/range. See the Npgsql.EnableLegacyTimestampBehavior AppContext switch to enable legacy behavior.

と出力されているのが確認できます。これはPostgreSQLのtimestamp with timezone型がUTCのみサポートしているために発生します。タイムゾーン情報が付与されたJSTとしてExecutionDateの値を登録しようとして例外が発生しました。

https://www.npgsql.org/doc/types/datetime.html

  1. 例外を回避するためにDateTime型のフィールドはデータベースに保存する時はJSTをUTCに変換し、データベースから取得する時はUTCをJSTに変換するようにコンバーターを作成します。TodoContext.csにてOnModelCreatingをオーバーライドし、Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverterを使ってコンバーターを作成します。ValueConverterのコンストラクタの第1引数には、ストアにデータを書き込むときにオブジェクトを変換する式を記載し、第2引数にはストアからデータを読み取るときにオブジェクトを変換する式を記載します。
Data/TodoContext.cs OnModelCreatingメソッドを抜粋
+        protected override void OnModelCreating(ModelBuilder modelBuilder)
+        {
+            //DateTimeをUTCで保存する為、UTC⇔JST変換をする
+            var jstZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
+            var datetimeConverter = new ValueConverter<DateTime, DateTime>(
+                v => v.Kind == DateTimeKind.Utc ? v : TimeZoneInfo.ConvertTimeToUtc(v, TimeZoneInfo.Local),
+                v => v.Kind == DateTimeKind.Utc ? TimeZoneInfo.ConvertTimeFromUtc(v, TimeZoneInfo.Local) : v);
+        }
  1. TodoエンティティのExecutionDateプロパティに対してHasConversionメソッドを使用して作成したコンバーターを適用します。
Data/TodoContext.cs OnModelCreatingメソッドを抜粋
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            //DateTimeをUTCで保存する為、UTC⇔JST変換をする
            var jstZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
            var datetimeConverter = new ValueConverter<DateTime, DateTime>(
                v => v.Kind == DateTimeKind.Utc ? v : TimeZoneInfo.ConvertTimeToUtc(v, TimeZoneInfo.Local),
                v => v.Kind == DateTimeKind.Utc ? TimeZoneInfo.ConvertTimeFromUtc(v, TimeZoneInfo.Local) : v);

+            modelBuilder.Entity<Todo>()
+                .Property(x => x.ExecutionDate)
+                .HasConversion(datetimeConverter);
        }
  1. これでデータベースにはDateTimeをUTCとして保存し、画面にはJSTで表示する準備ができました。再びデバッグ実行してTodoを追加します。
  2. 今度は無事に登録でき、JSTの日時が表示されました。
  3. psqlコマンドで登録したExecutionDateを確かめます。
  4. GitHubにソースコードを公開しています。

https://github.com/MasayukiHattori/EFCoreUTC

Discussion