🦝

【EFCore × CosmosDB】に Repository パターンを導入した

2024/06/21に公開

はじめに

DB 周りは Springbootでよく書いてた Repository がいいなぁ、でも EFCore を入れたかと言って、Repository ができるわけではないのか・・・と思い、作ってみました。
ほぼほぼ GitHub Copilot Chat の言いなりではありますが、如何せん .NET はド素人なんで、使えるものは使うんじゃー

環境

.NET 8
Microsoft.EntityFrameworkCore 8.0

ソースコード全体

EFCore 周り

CosmosDbContext.cs
using api.Models.CosmosDbModels;
using Microsoft.EntityFrameworkCore;

namespace api.Data.Context.CosmosDb;

public partial class CosmosDbContext : DbContext
{
    public DbSet<Todos> Todos { get; set; }

    public CosmosDbContext(DbContextOptions<CosmosDbContext> options) : base(options)
    {
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        OnModelCreatingTodos(modelBuilder);
    }

    partial void OnModelCreatingTodos(ModelBuilder modelBuilder);
}

public partial class CosmosDbContext
{
    partial void OnModelCreatingTodos(ModelBuilder modelBuilder)
    {
        // Todos コンテナの設定
        modelBuilder.Entity<Todos>().ToContainer("Todos");
        modelBuilder.Entity<Todos>().HasNoDiscriminator();
        modelBuilder.Entity<Todos>().HasKey(p => p.id);
        modelBuilder.Entity<Todos>().Property(p => p.id).ToJsonProperty("id");
        // 細かい項目は省略

        modelBuilder.Entity<Todos>().Property(p => p.date).ToJsonProperty("date");

        // partition key
        modelBuilder.Entity<Todos>().HasPartitionKey(p => p.date);
    }
}

partial クラスを初めて作りましたけど、こんな感じで使うんですねぇ。
partial クラスでわざわざ作ったのは、コンテナ増えそうだなーと思ったからです。
SQLServer側はそうしたのを踏襲しただけです。

っていうか、Springbootだと良しなに書いてたやつをここで書くのか、、と勉強になりました。

IIdentifiable.cs
namespace api.Models.CosmosDbModels;

public interface IIdentifiable
{
    string id { get; }
}
Todos.cs
namespace api.Models.CosmosDbModels;

public record Todos(string id, <!-- 項目は略 ---> ,string date) : IIdentifiable;

IIdentifiable というインターフェースを作りましたが、後述する Repository の パーティションキーがid以外の場合に更新できない問題に対処するために作りました。

Repository

お待ちかねの Repository です。
インターフェースは DI するし、テストもするし、ということで作ってます。

IRepository.cs
namespace api.Data.Context.Repositories;

public interface IRepository<T>
{
    Task<IEnumerable<T>> GetAllAsync();
    Task<T> GetByIdAsync(int id);
    Task<T> AddAsync(T entity);
    Task UpdateAsync(T entity);

    Task UpdateAsync(T entity, string partitionKeyProperty, string partitionKeyValue);

    Task<IEnumerable<T>> FindByPartitionKeyAsync(string partitionKeyProperty, string partitionKeyValue);
}
CosmosDbRepository.cs
using api.Data.Context.CosmosDb;
using api.Models.CosmosDbModels;
using Microsoft.EntityFrameworkCore;

namespace api.Data.Context.Repositories;

public class CosmosDbRepository<T> : IRepository<T> where T : class, IIdentifiable
{
    protected readonly CosmosDbContext _context;
    private DbSet<T> _entities;

    public CosmosDbRepository(CosmosDbContext context)
    {
        _context = context;
        _entities = context.Set<T>();
    }

    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _entities.ToListAsync();
    }

    public async Task<T> GetByIdAsync(int id)
    {
        return await _entities.FindAsync(id);
    }

    public async Task<T> AddAsync(T entity)
    {
        var result = await _entities.AddAsync(entity);
        await _context.SaveChangesAsync();
        return result.Entity;
    }

    public async Task UpdateAsync(T entity)
    {
        _entities.Update(entity);
        await _context.SaveChangesAsync();
    }

    /// <summary>
    /// パーティションキーがid以外の場合の更新処理
    /// </summary>
    /// <param name="entity"></param>
    /// <param name="partitionKeyProperty"></param>
    /// <param name="partitionKeyValue"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException"></exception>
    public async Task UpdateAsync(T entity, string partitionKeyProperty, string partitionKeyValue)
    {
        // パーティションキーに基づいてデータベースからエンティティを取得
        var trackedEntity = await _entities
            .SingleOrDefaultAsync(e => EF.Property<string>(e, partitionKeyProperty) == partitionKeyValue && e.id == entity.id);

        if (trackedEntity != null)
        {
            // 取得したエンティティのプロパティを更新する
            _context.Entry(trackedEntity).CurrentValues.SetValues(entity);

            // 変更を保存
            await _context.SaveChangesAsync();
        }
        else
        {
            // エンティティが見つからない場合の処理
            throw new InvalidOperationException("Entity not found.");
        }
    }

    // パーティションキーに基づいてエンティティを検索するメソッド
    public async Task<IEnumerable<T>> FindByPartitionKeyAsync(string partitionKeyProperty, string partitionKeyValue)
    {
        return await _entities.Where(e => EF.Property<string>(e, partitionKeyProperty) == partitionKeyValue).ToListAsync();
    }
}

少し解説。
GetAllAsync, GetByIdAsync, AddAsync, UpdateAsync(entity) は Copilot が自動生成してくれたメソッドです。楽々。まあ見ての通りです。
CosmosDB の SDK をそのまま使ってたときもそうなんですが、パーティションキーがid以外が少し曲者で、それはこちらの EFCore になっても同じです。

特に更新は、UpdateAsync(entity) をそのまま使うと以下のようなエラーがでます。

Exception: System.AggregateException: One or more errors occurred. (The instance of entity type 'Todos' cannot be tracked because another instance with the same key value for {'id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.)

Copilot の解説によると

このエラーは、Entity Framework Coreが同じキー値を持つTodosエンティティの2つのインスタンスを追跡しようとしているために発生しています。これは、UpdateAsyncメソッドを使用してエンティティを更新しようとした際に、そのエンティティが既にコンテキストによって追跡されている(または別のインスタンスが追跡されている)場合に起こります。
UpdateAsyncメソッドにおいて、パーティションキーがidではない場合でも、問題の根本原因はエンティティの追跡方法にあります。更新処理を行う前に、既に追跡されているエンティティを適切に処理する必要があります。

だそうです。追跡すると楽だけど、、、ってところですね。

Copilot になんとかしてーとお願いしたところ、UpdateAsyc(entity, partitionKeyProperty, partitionKeyValue) が生まれました。
アプローチとしては以下のような流れだそうです。

Cosmos DB では、パーティションキーを使用してデータを分割し、効率的なクエリとスケーラビリティを実現します。しかし、Entity Framework Core を使用して Cosmos DB にアクセスする場合、直接的にパーティションキーを指定して特定のエンティティを更新する機能は提供されていません。Entity Framework Core では、エンティティの状態を変更して変更をデータベースに反映させるプロセスを抽象化しています。
ただし、パーティションキーを考慮してエンティティを更新するには、まず該当するエンティティを検索し、そのエンティティのプロパティを更新してから、変更をデータベースに保存するという手順を踏む必要があります。以下に、パーティションキーを考慮してエンティティを更新する一般的な手順を示します。

  1. パーティションキーと一致する条件でエンティティを検索します。
  2. 検索したエンティティのプロパティを更新します。
  3. 変更をデータベースに保存します。

コード的には、確かに、特定して見つけたentityを更新する、という形なんですが、わざわざ書くほどのものか?とも思ったりします。
まあ、これで更新できるようになったのでそのままにしますが。

あとがき

ちなみに、今回はパーティションキーが string にしましたが、他の型の場合は修正が必要です。
いろいろ試しましたけど、 string 以外の型をパーティションキーに充てると、結構めんどくさい。たぶんやらない。
C#は色んな書き方があって、日々発見があり、結構楽しいです。Copilotいなかったら無理だー。

Discussion