ASP.NET Core DIできるDBクラスを作成してクリーンアーキテクチャを理解する
ASP.NET Core
でDI
できる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のトランザクションは意識せずに「一連の処理」という表現でロジックを実装できます。
解説
IDatabase
はAddScoped
でDIコンテナに登録しているため、同一リクエスト内であればUnitOfWorkFactory
やインフラ層のクエリやコマンドのクラスでDIしているIDatabase
はすべて同じインスタンスになります。
そのため、IDatabase
のローカル変数として管理しているDBコンテキストのクラスも同じものになります。
DBコンテキストのトランザクション関連の処理をUnitOfWork
でラップすることにより、DBコンテキストのトランザクション関連の処理をアプリケーション層から制御できるようになります。
参考書籍
クリーンアーキテクチャについて以下の書籍が大変参考になりました。
大変有名な本ですが、ドメイン駆動だけではなくクリーンアーキテクチャについても詳しく説明しています。
サンプルのコードもC#
で書かれています。