🗓️
EF CoreにおいてUTCで保存した日時をJSTに変換して表示する方法
概要
データベース上では日時を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のサーバー名を適宜読み替えてください。
プロジェクトの作成
- Blazor Serverのプロジェクトを作成します。
- プロジェクト名はEFCoreUTCとしました。
- 認証の種類は「なし」、HTTPS用の構成も今回は必要ないのでチェックを外します。
- NuGetパッケージの管理から下記のパッケージをインストールします。
- Microsoft.EntityFrameworkCore.Design 7.0.12
- Npgsql.EntityFrameworkCore.PostgreSQL 7.0.11
アプリケーションの実装
- エンティティ型を作成します。プロジェクト直下に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; }
}
}
- 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)
{
}
}
}
- 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": "*"
}
- 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);
}
}
}
- サービスを作成します。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();
}
}
}
- 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();
}
}
- 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");
}
}
- 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;
}
}
- EF Coreツールでデータベースを作成します。
dotnet ef migrations Add InitialCreate
dotnet ef database update
- 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();
- ここで一旦、デバッグ実行してみます。Todoの一覧を表示してAddリンクをクリックします。
- 必須項目を入力して、Submitをクリックします。すると例外が発生します。
- デバッグ出力を確認すると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の値を登録しようとして例外が発生しました。
- 例外を回避するために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);
+ }
- 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);
}
- これでデータベースにはDateTimeをUTCとして保存し、画面にはJSTで表示する準備ができました。再びデバッグ実行してTodoを追加します。
- 今度は無事に登録でき、JSTの日時が表示されました。
- psqlコマンドで登録したExecutionDateを確かめます。
- GitHubにソースコードを公開しています。
Discussion