ひでぼ~blog

C#ときどきゲーム

EFCoreのTimestampで楽観的排他制御を行う

EFCoreのTimestampを使って楽観的排他制御をやってみます。

実行環境

Entityを用意する

次のようなEntityを用意しました。

public record Todo
{
    public int Id { get; set; }

    public string? Content { get; set; }

    public DateTime Created { get; set; }

    public DateTime? Updated { get; set; }

    public DateTime? Deleted { get; set; }

    [Timestamp]
    public byte[] Version { get; set; } = null!;
}

楽観的排他制御のためにVersionというプロパティを用意し、Timestamp属性をつけておきます。 テスト用のデータを入れておきました。

適当なカラムを更新する

コマンド引数で指定した時間だけ実行を遅らせるようにして、プログラムを同時に実行するようにします。

var dbContext = new AppDbContext();

if (!int.TryParse(args.FirstOrDefault(), out var delay))
{
    delay = default;
}

var todo = await dbContext.Todos.FirstAsync();

todo.Content = "ガチアサリ";

await Task.Delay(delay);

await dbContext.SaveChangesAsync();

実行してみる

複数のターミナルから'dotnet run'をババっと実行してみます。ドキュメントによるとDbUpdateConcurrencyExceptionが発生するそうですがどうでしょう。

Unhandled exception. Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

無事?DbUpdateConcurrencyExceptionが発生しました。

参考

learn.microsoft.com

EFCoreのSaveChangesAsyncをoverrideして共通処理を差し込む

DBなどの永続化層のデータを更新する際に作成日や更新日などの日付を更新したいことがよくあります。そのあたりの処理を共通化してみたいと思います。

実行環境

  • VS 2022 17.6.5
  • .NET 7
  • EF Core 7.0.4

Entityを用意する

ITimeStampというインタフェースを持つEntityを定義します。

public class Todo : ITimeStamp
{
    public int Id { get; set; }

    public string? Content { get; set; }

    public DateTime Created { get; set; }

    public DateTime? Updated { get; set; }

    public DateTime? Deleted { get; set; }
}

public interface ITimeStamp
{
    DateTime Created { get; set; }

    DateTime? Updated { get; set; }

    DateTime? Deleted { get; set; }
}

SaveChangesをoverrideする

DbContextのSaveChanges(Async)をoverrideします。ChangeTrackerでEntityの状態(Added, Modified, Deleted)をチェックし、任意のカラムを更新するようにします。

public class AppDbContext : DbContext
{
    // ...

    public override int SaveChanges()
    {
        UpdateTimeStamp();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        UpdateTimeStamp();
        return base.SaveChangesAsync(cancellationToken);
    }

    private void UpdateTimeStamp()
    {
        foreach (var entry in ChangeTracker.Entries())
        {
            if (entry.Entity is not ITimeStamp)
            {
                return;
            }

            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Property(nameof(ITimeStamp.Created)).CurrentValue = DateTime.Now;
                    break;

                case EntityState.Modified:
                    entry.Property(nameof(ITimeStamp.Updated)).CurrentValue = DateTime.Now;
                    break;

                case EntityState.Deleted:
                    // 論理削除にするためModifiedにする
                    entry.State = EntityState.Modified;
                    entry.Property(nameof(ITimeStamp.Deleted)).CurrentValue = DateTime.Now;
                    break;

            }
        }
    }
}

データを投入してみる

Todoを3つ追加してみます。

var dbContext = new AppDbContext();

var todos = new List<Todo>
{
    new Todo { Id = 1, Content = "サーモンラン" },
    new Todo { Id = 2, Content = "ガチホコ" },
    new Todo { Id = 3, Content = "ガチヤグラ" },
};

await dbContext.Todos.AddRangeAsync(todos);
await dbContext.SaveChangesAsync();

Createdに現在時刻がセットされました。

データを更新してみる

Todoを1件更新してみます。

var todo = await dbContext.Todos.FirstAsync();
todo.Content = "ビッグラン";

await dbContext.SaveChangesAsync();

Updatedが更新されました。

データを削除してみる

Todoを論理削除してみます。

var todo = await dbContext.Todos.FirstAsync();
dbContext.Todos.Remove(todo);

await dbContext.SaveChangesAsync();

Deletedが更新されました。

参考

qiita.com

EFCoreでChangeTrackerの内容を確認する

EFCoreのChangeTrackerについて今まで雰囲気で分かった気になっていたので、理解を深めるためにDbContextのデータをいろいろ操作しながらChangeTrackerの内容を確認してみます。

実行環境

Entityを用意する

次のようなEntityを作成します。

public record ToDo
{
    public required int Id { get; set; }

    public required string Description { get; set; }

    public required DateTime DueDate { get; set; }

    public required bool IsCompleted { get; set; }
}

ChangeTrackerの取得

ChangeTrackerは以下で確認できます。 ShortViewとLongViewの2つが用意されていて、ShortViewはシンプル、LongViewはより詳細な内容が確認できます。

dbContext.ChangeTracker.DebugView.ShortView
// or
dbContext.ChangeTracker.DebugView.LongView

LongViewはこのような感じになっています。

ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

CRUDしながらChangeTrackerの内容を出力する

何もしていない状態

dbContextに対してまだ何も操作を行っていない状態です。

var dbContext = new AppDbContext();
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

まだ何も追跡されていないので何も出力されません。




追加

var toDo = new ToDo { Id = 1, Description = "コードレビュー", DueDate = DateTime.Today, IsCompleted = true };

await dbContext.ToDos.AddAsync(toDo);
Console.WriteLine("AddAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

await dbContext.SaveChangesAsync();
Console.WriteLine("SaveChangesAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

ToDoの変更が追跡され、Addedとしてマークされました。また、SaveChangesAsyncすることでAddedからUnchangedに変わりました。

AddAsync後
ToDo {Id: 1} Added
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

SaveChangesAsync後
ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

読み取り

var toDo = await dbContext.ToDos.FirstAsync();
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

取得しただけの場合はUnchangedとしてマークされていました。

ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

更新

var toDo = await dbContext.ToDos.FirstAsync();
Console.WriteLine("FirstAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

toDo.IsCompleted = false;
dbContext.ChangeTracker.DetectChanges();
Console.WriteLine("プロパティ変更後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

await dbContext.SaveChangesAsync();
Console.WriteLine("SaveChangesAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

ToDoを取得し、IsCompletedをfalseにして更新してみました。 プロパティ変更後にModifiedとしてマークされています。また、IsCompletedというカラムがModifiedとなっているためカラムレベルで変更が追跡されていることが分かります。 こちらも追加のときと同様にSaveChangesAsyncするとUnchangedになりました。

FirstAsync後
ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

プロパティ変更後
ToDo {Id: 1} Modified
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'False' Modified Originally 'True'

SaveChangesAsync後
ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'False'

Modifiedに関してはLongViewを取得する前に

dbContext.ChangeTracker.DetectChanges();

を呼んでおかないとModifiedというマークがつかないというハマりポイントがありました。

削除

var toDo = await dbContext.ToDos.FirstAsync();
Console.WriteLine("FirstAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

dbContext.ToDos.Remove(toDo);
Console.WriteLine("Remove後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

await dbContext.SaveChangesAsync();
Console.WriteLine("SaveChangesAsync後");
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);

削除の場合はDELETEDというマークがつきました。 SaveChangesAsync後、ChangeTrackerの内容は空になりました。

FirstAsync後
ToDo {Id: 1} Unchanged
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

Remove後
ToDo {Id: 1} Deleted
  Id: 1 PK
  Description: 'コードレビュー'
  DueDate: '2023/06/29 0:00:00'
  IsCompleted: 'True'

SaveChangesAsync後

まとめ

ChangeTrackerはEntityに対してCRUDを行ったときに変更を追跡し、カラムレベルで追跡を行っていることが分かりました。 また、SaveChangesAsyncしたタイミングでそれまでに追跡していた操作をデータベースに対して実行することが分かりました。

参考

learn.microsoft.com

tsuna-can.hateblo.jp

Azure Communication Servicesのメール送信を試してみる

Azureでメールが送れるサービスが使えるようになったとのことで試してみました。

Azureでリソースを作成する

Azure Portalから以下3つのリソースを作成します。

  • Communication Services
  • Email Communication Services
  • ドメイン

Communication Servicesの作成

Azure Portalからポチポチとリソース作成をしていきます。 Communication Servicesのデータの場所はAsia Pacificを選択しました。この後に作成するEmail Communication Servicesでも同様の場所を選択する必要があります。

Email Communication Servicesの作成

こちらも同様にデータの場所でAsia Pacificを選択し、作成していきます。

ドメイン

Email Communication Servicesのデプロイ完了後にリソースに移動すると、ドメインを追加しましょうとなっているので無料のAzureサブドメインを追加しました。

ドメインの接続

作成したCommunication Servicesのリソースに移動し、ドメインの接続を行います。 電子メールサービスは作成したEmail Communication Servicesのリソースを選択し、確認済みのドメインは1つ前に作成したドメインを選択します。

接続文字列の確認

Communication Servicesのリソースに移動し、キーから接続文字列を確認します。 この後にプログラムからメールを送信するときに使うのでコピーしておきます。

プログラムからメールを送信する

コンソールアプリでメールを送信してみます。 Azure.Communication.EmailというNugutパッケージを追加します。

dotnet add package Azure.Communication.Email

メールを送信するプログラムはこのようになりました。

using Azure;
using Azure.Communication.Email;

// Azure Communication Servicesから取得した接続文字列を使用
var connectionString = "************";
var emailClient = new EmailClient(connectionString);

var subject = "Welcome to Azure Communication Service Email APIs.";
var htmlContent = "<html><body><h1>Quick send email test</h1><br/><h4>This email message is sent from Azure Communication Service Email.</h4><p>This mail was sent using .NET SDK!!</p></body></html>";
// 取得したドメイン名を使用
var sender = "donotreply@********";
// 送信先のメールアドレスを設定
var recipient = "hogehoge@gmail.com";

try
{
    var emailSendOperation = await emailClient.SendAsync(
        WaitUntil.Completed,
        sender,
        recipient,
        subject,
        htmlContent);
    var statusMonitor = emailSendOperation.Value;

    Console.WriteLine($"Email Sent. Status = {emailSendOperation.Value.Status}");

    var operationId = emailSendOperation.Id;
    Console.WriteLine($"Email operation id = {operationId}");
}
catch (RequestFailedException ex)
{
    Console.WriteLine($"Email send operation failed with error code: {ex.ErrorCode}, message: {ex.Message}");
}

実行後にメールを確認するとちゃんと届いていました。

料金感

azure.microsoft.com

この記事を書いた時点ではメール1通あたり¥0.03513が発生するようです。

参考

learn.microsoft.com

C#でChatGPTとのお喋りを試してみる

ChatGPTが非常に流行っているので試してみることにしました。 ここではC#のコンソールアプリケーションでChatGPTと会話ができるように実装し、その過程と結果を紹介します。

実行環境

OpenAIのAPIはこちらのライブラリを使って呼び出しました。

www.nuget.org

Azure OpenAI Service用のライブラリですがChatGPTを直で使う場合でも使えるようだったのでこちらを利用しました。 ベータ版なのでVisual StudioのNugetパッケージマネージャーからインストールする際は、プレビュー版も含めて検索しないとヒットしないのでご注意ください。

実装してみる

コンソールアプリケーションでWeb版のChatGPTのようにAIとお喋りできるようなものを作ってみました。

using Azure.AI.OpenAI;

var apiKey = "your api key";
var modelName = "gpt-3.5-turbo";

var client = new OpenAIClient(apiKey);

while (true)
{
    Console.WriteLine("YOU:");
    var input = Console.ReadLine();
    if (input == "quit")
    {
        return;
    }

    Console.WriteLine();

    var response = await client.GetChatCompletionsAsync(modelName, new ChatCompletionsOptions
    {
        Messages =
        {
            new ChatMessage(ChatRole.User, input)
        }
    });

    Console.WriteLine("ChatGPT:");
    Console.WriteLine(response.Value.Choices.First().Message.Content);
    Console.WriteLine();
}

実行結果

YOU:
こんばんはー!

ChatGPT:
こんにちは、私はAIアシスタントです。どうかしましたか?

YOU:
いや特に何かはありません!

ChatGPT:
では、なにか私ができることはありますか?

YOU:
大丈夫です!

ChatGPT:
ありがとうございます!

YOU:
私が1個前に質問したこと覚えてますか?

ChatGPT:
申し訳ありません、私は覚えていません。私はAIであり、前回の会話の履歴を参照することはできません。お手数ですが、もう一度質問内容をお知らせください。

なんか会話が嚙み合ってない…と思ったら、この実装だと会話の内容が保持されていませんでした。

会話の内容を保持する

前段の実装だと、会話の履歴が残っておらず、質問のたびに新しい会話をしていることになっています。 なので、こちらが質問した内容とChatGPTが回答した内容を毎回APIに投げるようにします。

using Azure.AI.OpenAI;

var apiKey = "your api key";
var modelName = "gpt-3.5-turbo";

var client = new OpenAIClient(apiKey);
var options = new ChatCompletionsOptions();

while (true)
{
    Console.WriteLine("YOU:");
    var input = Console.ReadLine();
    if (input == "quit")
    {
        return;
    }

    Console.WriteLine();

    // ユーザーの入力はChatRole.UserとしてMessagesに追加
    options.Messages.Add(new ChatMessage(ChatRole.User, input));

    var response = await client.GetChatCompletionsAsync(modelName, options);
    var output = response.Value.Choices.First().Message.Content;

    Console.WriteLine("ChatGPT:");
    Console.WriteLine(output);
    Console.WriteLine();

    // ChatGPTの出力はChatRole.AssistantとしてMessagesに追加
    options.Messages.Add(new ChatMessage(ChatRole.Assistant, output));
}

ユーザーの入力、ChatGPTの出力を都度都度MessagesというListに入れておき、APIをコールする際に今までのやり取りをすべて投げます。 ユーザーの入力はChatRole.Userとし、ChatGPTの出力はChatRole.Assistantとすることで、どのメッセージが誰の発言なのかを特定させます。

実行結果

YOU:
こんばんはー!

ChatGPT:
こんばんは、お元気ですか?

YOU:
元気ですー!

ChatGPT:
よかったですね!何か楽しいことがありましたか?

YOU:
いいプログラムが書けました!

ChatGPT:
素晴らしいですね! どのようなプログラムを作られたのでしょうか?

YOU:
ChatGPTさんとお話するプログラムです!

ChatGPT:
それは面白そうですね!ChatGPTさんをモデルにして、どのようにお話しできるか、是非教えてください!

スムーズな会話になりました🎉

参考

platform.openai.com

zenn.dev

EFCore 7.0の一括更新・一括削除を試す

久しぶりにEFCoreを触ってみたら 一括更新・一括削除( ExecuteUpdate / ExecuteDelete ) という良さげな機能が追加されていたので試してみました。 今までは一度SELECTした後にUPDATEなりDELETEしていたのがSELECTしなくても良くなりました。

実行環境

モデルの準備

次のようなTodoをモデルとします。

internal record Todo
{
    public int Id { get; set; }

    public string? Content { get; set; }

    public DateTime DateTime { get; set; }
}

更新に先んじて初期データを投入しておきます。

var dbContext = new AppDbContext();

var todos = new List<Todo>
{
    new Todo { Id = 1, Content = "シャケ", DateTime = DateTime.Today },
    new Todo { Id = 2, Content = "ガチホコ", DateTime = DateTime.Today },
    new Todo { Id = 3, Content = "ガチヤグラ", DateTime = DateTime.Today },
};

await dbContext.Todos.AddRangeAsync(todos);
await dbContext.SaveChangesAsync();

一括更新

ExecuteUpdateというメソッドを使います。SetPropertyで更新するプロパティ(カラム)を指定します。 すべてのTodoの日付を更新してみます。

var dbContext = new AppDbContext();
dbContext.Todos.ExecuteUpdateAsync(s => s.SetProperty(b => b.DateTime, b => b.DateTime.AddDays(1)));

すべてのTodoの日付が更新されました。

一括削除

ExecuteDeleteというメソッドを使います。 ガチ~から始まる内容のTodoを削除してみます。

var dbContext = new AppDbContext();
await dbContext.Todos.Where(todo => todo.Content.Contains("ガチ")).ExecuteDeleteAsync();

ガチホコガチヤグラのTodoが削除されました。

参考

learn.microsoft.com

Visual Studio 2022のIntegrated Http Clientを試す

Visual Studio 2022のバージョン17.5から、VSCode拡張機能REST Clientと同じようなHTTPリクエストを実行できる機能が追加されたそうなので試してみました。 marketplace.visualstudio.com

実行環境

WebAPIを準備する

MinimalAPIで適当なエンドポイントを作成しました。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
    return forecast;
});

app.MapPost("/weatherforecast", (WeatherForecast forecast) =>
{
    return forecast;
});

app.Run();

internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

httpファイルを追加する

ソリューションエクスプローラーから新しい項目を追加でhttpファイルを追加します。

リクエストを書いてみる

追加したhttpファイルの中身を書いてみます。

GET https://localhost:7039/weatherforecast

GETだけだと味気ないのでJSONを投げるPOSTも書いてみます。

POST https://localhost:7039/weatherforecast
Content-Type: application/json

{
    "date":"2023-03-01",
    "temperatureC":53,
    "summary":"Cool",
    "temperatureF":127
}

実行する

httpファイルの右側に出てくるボタンを押すとリクエストを投げます。

POSTもやってみます。 ちゃんとレスポンスが返ってきました。

APIのテストコードとして使ったり、APIをちょっと叩くのに便利そうな感じがしました。 今までほぼ同じことをVSCode拡張機能であるRestClientを使ってやっていましたが、本家Visual Studioで同じことができるようになったのは嬉しいですね。

参考

devblogs.microsoft.com