zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core DIできるDBクラスを作成してクリーンアーキテクチャを理解する

ASP.NET CoreDIできるDBクラスを作成して、クリーンアーキテクチャで実装します。

トランザクションはUnitOfWorkとしてアプリケーション層から制御します。

ASP.NET Core appsettings.jsonを別プロジェクトで読み込むでDBの接続情報のみ、インフラ層で読み込めるようにしましたが、DB関連の処理はすべてインフラ層に実装するように、ASP.NET Core appsettings.jsonを別プロジェクトで読み込むのソースに対して修正していきます。

プロジェクトの作成

現在はWeb層(プレゼン層)とインフラ層しかありませんが、クリーンアーキテクチャで実装するため、アプリケーション層とドメイン層を作成して、ソリューションに追加します。

$ dotnet new classlib -o App.Application
$ dotnet sln add App.Application
$ dotnet new classlib -o App.Domain
$ dotnet sln add App.Domain

また、各プロジェクトの参照設定を行います。

Web層
$ dotnet add reference ../App.Infrastructure
$ dotnet add reference ../App.Application
$ dotnet add reference ../App.Domain
アプリケーション層
$ dotnet add reference ../App.Domain
インフラ層
$ dotnet add reference ../App.Application
$ dotnet add reference ../App.Domain

ドメイン層が最下層のレイヤーになるため、ドメイン層の参照設定は不要です。

よくある3層アーキテクチャでは、インフラ層(データアクセス層)が最下層のレイヤーになりますが、インターフェースやDIを使って依存関係を逆転させるため、ドメイン層が最下層となります。

パッケージのインストール

PostgreSQLに接続するためにNpgsqlというパッケージをWeb層にインストールしましたが、Web層からは削除して、インフラ層にインストールします。

cd App.Infrastructure # インフラ層に移動
dotnet add package Npgsql
cd App.Web # Web層に移動
dotnet remove package Npgsql

DBコンテキストクラスの作成

インフラ層にDBコンテキストのクラスを作成します。

このクラスはDIコンテナには登録しません。

DBコネクションオブジェクトをコンストラクタで受け取り、コネクションオブジェクトとトランザクションを管理します。

また、IDisposableを実装して、dispose時にトランザクションのロールバックやコネクションオブジェクトのクローズ、破棄を行うようにします。

DatabaseContext.cs
using System.Data;
using System.Data.Common;

namespace App.Infrastructure.Core;

public sealed class DatabaseContext : IDisposable
{
    private readonly DbConnection _connection;
    private DbTransaction? _transaction;

    public DatabaseContext(DbConnection connection)
    {
        _connection = connection;
    }

    public DbConnection GetConnection()
    {
        if (_connection.State == ConnectionState.Open)
        {
            return _connection;
        }
        _connection.Open();
        return _connection;
    }

    public void BeginTransaction()
    {
        _transaction = GetConnection().BeginTransaction();
    }

    public void Commit()
    {
        if (_transaction != null)
        {
            _transaction.Commit();
            _transaction.Dispose();
        }
    }

    public void Rollback()
    {
        if (_transaction != null)
        {
            _transaction.Rollback();
            _transaction.Dispose();
        }
    }

    private bool disposedValue;
    private void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                try
                {
                    _transaction?.Rollback();
                }
                catch
                {

                }
                _transaction?.Dispose();

                if (_connection?.State == ConnectionState.Open)
                {
                    _connection.Close();
                }
                _connection?.Dispose();
            }

            disposedValue = true;
        }
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

DBクラスの作成

インフラ層にDBクラスを作成します。

インターフェースを作成して、そのインターフェイスを実装する形で作成します。

DBの接続文字列などの設定情報をコンストラクタで受け取ります。

DBコンテキストのクラスを取得するファンクションGetContextを実行します。

DBコンテキストはローカル変数として保持しておき、複数回GetContextが呼ばれた場合は、保持してあるDBコンテキストを返すようにします。

これにより、複数のクラスにまたがってトランザクションを管理することができるようになります。

また、IDisposableを実装して、dispose時にDBコンテキストの破棄(DBコンテキストで実装したコネクションクローズなど)を行うようにします。

IDatabase.cs
namespace App.Infrastructure.Core;

public interface IDatabase
{
    public DatabaseContext GetContext();
}
Database.cs
using Npgsql;

namespace App.Infrastructure.Core;

public sealed class Database : IDatabase, IDisposable
{
    private readonly IDatabaseConfig _config;
    private DatabaseContext? _context;

    public Database(IDatabaseConfig config)
    {
        _config = config;
    }

    public DatabaseContext GetContext()
    {
        var connection = new NpgsqlConnection(_config.GetConnectionString());
        return _context ??= new DatabaseContext(connection);
    }

    private bool disposedValue;
    private void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                _context?.Dispose();
            }

            disposedValue = true;
        }
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

DIコンテナに登録

DBクラスをDIコンテナに登録します。

ライフサイクルはAddTransientではなくAddScopedとしてください。

AddTransientだと各クラスでDIされるたびにインスタンスを作成するため、クラス間をまたいだトランザクション処理が行えなくなります。

DependencyInjection.cs
using App.Infrastructure.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace App.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddScoped<IDatavaseConfig>(_ => new DatabaseConfig(configuration.GetConnectionString("DefaultConnection") ?? ""));
        services.AddScoped<IDatabase, Database>();

        return services;
    }
}

使い方

以下のように使用します。

インターフェースと実装クラスを作成します。

インターフェースはドメイン層、実装クラスはインフラ層に実装します。

アプリケーション層では、ドメイン層のインターフェースを参照することにより、インフラ層を参照せずに、インフラ層に実装されたDB関連の処理を実行することができるようになります。

IQueryService.cs
namespace App.Domain.Core;

public interface IQueryService
{
    Task<string> FindAsync();
}

実装クラスではDBクラスのインターフェースをDI(コンストラクタで受け取る)します。

QueryService.cs
namespace App.Infrastructure.Core;

public class QueryService : IQueryService
{
    public readonly IDatabase _database;
    public QueryService(IDatabase database)
    {
        _database = database;
    }

    public async Task<string> FindAsync()
    {
        // コンテキストの取得
        var context = _database.GetContext();

        // コネクションオブジェクトを取得
        var connection = await context.GetConnectionAsync();

        // コマンドオブジェクトを作成
        using var command = connection.CreateCommand();
        command.CommandText = "SELECT current_date";

        // 実行結果を返す
        return command.ExecuteScalar()?.ToString() ?? "";
    }
}

DIコンテナに登録します。

DependencyInjection.cs
        services.AddScoped<IDatavaseConfig>(_ => new DatabaseConfig(configuration.GetConnectionString("DefaultConnection") ?? ""));
        services.AddScoped<IDatabase, Database>();

        services.AddTransient<IQueryService, QueryService>();
        return services;
    }
}

Web層やアプリケーション層ではIQueryServiceをDIして、使用します。

これにより、DB関連の処理はドメイン層のインターフェースを参照して実行できるようになります。

using App.Domain.Core;

    private readonly ILogger<WeatherForecastController> _logger;
    private readonly IQueryService _queryService;

    public WeatherForecastController(ILogger<WeatherForecastController> logger, IQueryService queryService)
    {
        _logger = logger;
        _queryService = queryService;
    }
    
    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        var result = await _queryService.FindAsync();

アプリケーション層の実装

実際はDBから取得した値に対して、いろいろと処理を行うと思います。

Web層の各APIに対応するロジックはWeb層にそのまま実装するのではなく、アプリケーション層に実装します。

アプリケーション層の各処理をすべてDIコンテナに登録するのは手間なので、MediatRというライブラリを使用して、Web層からアプリケーション層への処理を効率的に実装します。

MediatRの導入方法についてはASP.NET CoreにMediatRを導入するで紹介しています。

UnitOfWorkの実装

例えばDBからデータを取得、データを変更してDBに保存といったような一連の処理をアプリケーション層でトランザクションを管理しながら実行するために、アプリケーション層からトランザクションの制御を行えるようにします。

トランザクションの開始から終了までの一連の処理を「作業単位」unit of workと表現し、アプリケーション層にインターフェース、インフラ層に実装クラスを作成します。

IUnitOfWork.cs
namespace App.Application.Core;

public interface IUnitOfWork : IDisposable
{
    void Commit();
    void Rollback();
}
UnitOfWork.cs
using App.Application.Core;

namespace App.Infrastructure.Core;

public sealed class UnitOfWork : IUnitOfWork
{
    private readonly DatabaseContext _context;

    public UnitOfWork(DatabaseContext context)
    {
        _context = context;
        _context.BeginTransaction();
    }

    public void Commit()
    {
        _context.Commit();
    }

    public void Rollback()
    {
        _context.Rollback();
    }

    private bool disposedValue;
    private void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                try
                {
                    _context.Rollback();
                }
                catch
                {

                }
            }
            disposedValue = true;
        }
    }
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

UnitOfWorkはDBコンテキストクラスをパラメータに作成します。

DIでインスタンス化するのではなく、アプリケーション層の処理で明示的にUnitOfWorkをインスタンス化します。

インスタンス化するための共通ロジックをUnitOfWorkFactoryとして実装します。

アプリケーション層にインターフェース、インフラ層に実装クラスを作成します。

IUnitOfWorkFactory.cs
namespace App.Application.Core;

public interface IUnitOfWorkFactory
{
    IUnitOfWork CreateUnitOfWork();
}
UnitOfWorkFactory.cs
using App.Application.Core;

namespace App.Infrastructure.Core;

public class UnitOfWorkFactory : IUnitOfWorkFactory
{
    private readonly IDatabase _database;

    public UnitOfWorkFactory(IDatabase database)
    {
        _database = database;
    }

    public IUnitOfWork CreateUnitOfWork()
    {
        return new UnitOfWork(_database.GetContext());
    }
}

IDatabaseをDIするため、UnitOfWorkFactoryはDIコンテナに登録します。

DependencyInjection.cs
        services.AddScoped<IDatavaseConfig>(_ => new DatabaseConfig(configuration.GetConnectionString("DefaultConnection") ?? ""));
        services.AddScoped<IDatabase, Database>();
        services.AddTransient<IUnitOfWorkFactory, UnitOfWorkFactory>();

アプリケーション層の処理は以下のようになります。

using App.Application.Core;

namespace App.Application.Sample;

public class SampleUpdateCommandHandler : CommandHandler<SampleUpdateCommand, CreatedCommandResult>
{
    private readonly ISampleRepository _repository;
    private readonly IUnitOfWorkFactory _uowFactory;

    public SampleUpdateCommandHandler(
        ISampleRepository repository,
        IUnitOfWorkFactory uowFactory)
    {
        _repository = repository;
        _uowFactory = uowFactory;
    }

    public override async Task<CreatedCommandResult> Handle(SampleUpdateCommand command, CancellationToken cancellationToken)
    {
        // トランザクション開始
        using var uow = _uowFactory.CreateUnitOfWork();

        // DBからデータを取得
        var entity = await _repository.FindByIdAsync(command.Data.SampleId);

        if (entity == null)
        {
            throw new Exception("データなし");
        }

        // データの変更
        entity.ChangeData(command.Data);

        // データをDBに反映
        await _repository.SaveAsync(entity);

        // コミット
        uow.Commit();

    }
}

これで、一連の処理をトランザクションで管理することができます。

アプリケーション層から見ればDBのトランザクションは意識せずに「一連の処理」という表現でロジックを実装できます。

解説

IDatabaseAddScopedでDIコンテナに登録しているため、同一リクエスト内であればUnitOfWorkFactoryやインフラ層のクエリやコマンドのクラスでDIしているIDatabaseはすべて同じインスタンスになります。

そのため、IDatabaseのローカル変数として管理しているDBコンテキストのクラスも同じものになります。

DBコンテキストのトランザクション関連の処理をUnitOfWorkでラップすることにより、DBコンテキストのトランザクション関連の処理をアプリケーション層から制御できるようになります。

参考書籍

クリーンアーキテクチャについて以下の書籍が大変参考になりました。

大変有名な本ですが、ドメイン駆動だけではなくクリーンアーキテクチャについても詳しく説明しています。

サンプルのコードもC#で書かれています。

ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本


関連記事