C# Dapper で DB 接続クラスを作ってみた

# はじめに
OR マッパーを使ってみたかったので Dapper で DB 接続クラスを作ってみました。


# 前提とか欲しい機能とか
* トランザクションスクリプトで使う。
* DI コンテナで注入する。 
  * でも、使うタイミングで接続開始して使い終わったら接続破棄したい。
* SQLite や SQL Server 等、DB を選ばない。 
* 検証 DB や 本番 DB があるので、接続先の追加や切替を楽に行いたい。

# 環境
* Microsoft .NET 9.0
* Dapper 2.1.66
* Microsoft.Data.SqlClient 6.0.1
* Microsoft.Extensions.Configuration 9.0.3
* Microsoft.Extensions.Configuration.Json 9.0.3

# ソース
<table>
  <thead>
    <tr>
      <th>名前</th> <th>説明</th>
    </tr>
  </thead>
  <tr>
    <td>DbService.cs</td><td>DB 接続やトランザクションの管理、<br>SQL の発行を担います。</td>
  </tr>
  <tr>
    <td>DbConnectionSettings.cs</td>
    <td style="vertical-align: top;">同名の json ファイルから、<br>DB 接続情報を取得・保持します。</td>
  </tr>
  <tr>
    <td>DbConnectionSettings.json</td>
    <td>DB 接続情報を設定します。<br>Use で使用する接続情報を切り替えられます。</td>
  </tr>
</table>

<details><summary>ソースは長くなるので、折り畳み</summary>

```DbService.cs
using System.Data;
using Dapper;

namespace DbManager
{
    public interface IDbService : IDisposable
    {
        void BeginTransaction();

        void CommitTransaction();

        void RollbackTransaction();

        IEnumerable<dynamic> Get(string sql, object? param = null);

        IEnumerable<T> Get<T>(string sql, object? param = null);

        int Execute(string sql, object? param = null);
    }

    public class DbService(IDbConnection connection, int commandTimeout) : IDbService
    {
        private readonly IDbConnection _connection = connection;
        private readonly int _commandTimeout = commandTimeout;
        private IDbTransaction? _transaction = null;

        public void BeginTransaction()
        {
            // 接続オープンしておかないと例外が発生する
            // System.InvalidOperationException: '操作が無効です。接続は閉じています。'
            if (_connection.State != ConnectionState.Open)
            {
                _connection.Open();
            }

            // サブトランザクション（ネストされたトランザクション）を防ぐために
            // トランザクションが既に存在する場合は、新しいトランザクションを開始しない
            // -> トランザクションのネストは DB 毎に仕様が異なるのと
            //    トランザクションの管理自体が煩雑になるので基本禁止にする方針で...
            _transaction ??= _connection.BeginTransaction();
        }

        public void CommitTransaction()
        {
            if (_transaction is not null)
            {
                _transaction.Commit();
                _transaction.Dispose();
                _transaction = null;
            }
        }

        public void RollbackTransaction()
        {
            if (_transaction is not null)
            {
                _transaction.Rollback();
                _transaction.Dispose();
                _transaction = null;
            }
        }

        public IEnumerable<dynamic> Get(string sql, object? param = null)
        {
            // Dapper が自動で接続オープンしてくれるっぽいので、接続処理がいらない！
            return _connection.Query(
                sql: sql,
                param: param,
                transaction: _transaction,
                commandTimeout: _commandTimeout);
        }

        public IEnumerable<T> Get<T>(string sql, object? param = null)
        {
            return _connection.Query<T>(
                sql: sql,
                param: param,
                transaction: _transaction,
                commandTimeout: _commandTimeout);
        }

        public int Execute(string sql, object? param = null)
        {
            return _connection.Execute(
                sql: sql,
                param: param,
                transaction: _transaction,
                commandTimeout: _commandTimeout);
        }

        public void Dispose()
        {
            if (_transaction is not null)
            {
                RollbackTransaction();
            }

            if (_connection.State != ConnectionState.Closed)
            {
                _connection.Close();
            }

            _connection.Dispose();

            GC.SuppressFinalize(this);
        }
    }
}
```
↑ 必要に応じて Dapper の GetFirstOrDefault や Async 系のメソッド を追加したり、SqlKata の Query を使えるようにアレンジしても良さそう。

```DbConnectionSettings.cs
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;

namespace DbManager
{
    public class DbConnectionSettings
    {
        public string ProviderName { get; }

        public string ConnectionString { get; }

        public int CommandTimeout { get; }

        public DbConnectionSettings()
        {
            try
            {
                RegistProvider();

                var config = new ConfigurationBuilder()
                    .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
                    .AddJsonFile($"{nameof(DbConnectionSettings)}.json", false, true)
                    .Build();

                var useSectionName = config["Use"]
                    ?? throw new InvalidOperationException(
                        $"The 'Use' section is missing or empty " +
                        $"in the file '{nameof(DbConnectionSettings)}.json'.");

                var section = config.GetSection(useSectionName);
                if (!section.Exists())
                    throw new InvalidOperationException(
                        $"The section '{useSectionName}' could not be found " +
                        $"in the file '{nameof(DbConnectionSettings)}.json'.");

                ProviderName = section["ProviderName"]
                    ?? throw new InvalidOperationException(
                        $"The 'ProviderName' is missing " +
                        $"or empty in the '{useSectionName}' section.");

                ConnectionString = section["ConnectionString"]
                    ?? throw new InvalidOperationException(
                        $"The 'ConnectionString' is missing " +
                        $"or empty in the '{useSectionName}' section.");

                if (!int.TryParse(section["CommandTimeout"], out var commandTimeout))
                    throw new InvalidOperationException(
                        $"The 'CommandTimeout' is missing " +
                        $"or invalid in the '{useSectionName}' section. " +
                        $"It should be an integer.");

                CommandTimeout = commandTimeout;
            }
            catch (Exception ex)
            {
                throw new InvalidOperationException(
                    message: $"An error occurred while reading the file" +
                        $"'{nameof(DbConnectionSettings)}.json'.",
                    innerException: ex);
            }
        }

        public DbConnection CreateDbConnection()
        {
            try
            {
                var factory = DbProviderFactories.GetFactory(ProviderName)
                    ?? throw new InvalidOperationException(
                        $"The provider '{ProviderName}' could not be found.");

                var connection = factory.CreateConnection()
                    ?? throw new InvalidOperationException(
                        $"Unable to create the database connection.");

                connection.ConnectionString = ConnectionString;

                return connection;
            }
            catch (Exception ex)
            {
                throw new InvalidOperationException(
                    message: $"An error occurred " +
                        $"while creating the database connection.",
                    innerException: ex);
            }
        }

        private static void RegistProvider()
        {
            // ※下記の例外が出る場合、ここでプロバイダーを登録しておく
            // System.ArgumentException :
            //     The specified invariant name
            //     'Microsoft.Data.SqlClient' wasn't found
            //     in the list of registered .NET Data Providers.
            //     要求された .Net Framework データ プロバイダーが見つかりません。
            //     これは、インストールされていない可能性があります。

            DbProviderFactories.RegisterFactory(
                providerInvariantName: "Microsoft.Data.SqlClient",
                factory: SqlClientFactory.Instance);
        }
    }
}
```

```DbConnectionSettings.json
{
    "Use": "LocalDB",

    "InMemoryDB": {
        "ProviderName": "Microsoft.Data.Sqlite",
        "ConnectionString": "Data Source=:memory:;",
        "CommandTimeout": 30
    },

    "LocalDB": {
        "ProviderName": "Microsoft.Data.SqlClient",
        "ConnectionString": "Data Source=(localdb)\\MSSQLLocalDB;",
        "CommandTimeout": 30
    },

    "RemoteDB": {
        "ProviderName": "Microsoft.Data.SqlClient",
        "ConnectionString": "Data Source=localhost;Initial Catalog=TestDatabase;User ID=Admin;Password=Admin;",
        "CommandTimeout": 30
    }
}
```

</details>

# 使い方
使うタイミングで生成して使い終わったら破棄したかったので、デリゲート型 (DbServiceFactory) で注入しています。 
これ、かの有名な Service Locator パターンっぽいけど、どうなんだろう...？

<details><summary>ソースは長くなるので、折り畳み</summary>

↓ エントリーポイントで DI コンテナに登録して ...
```Program.cs
using DbManager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace DbManagerTestApp
{
    internal static class Program
    {
        [STAThread]
        internal static void Main()
        {
            // DIコンテナにサービスを登録する
            var host = Host
                .CreateDefaultBuilder()
                .ConfigureServices((context, services) =>
                {
                    // DbConnectionSettings
                    services.AddSingleton<DbConnectionSettings>();

                    // DbService
                    services.AddTransient<IDbService>(provider =>
                    {
                        var settings = provider
                            .GetRequiredService<DbConnectionSettings>();

                        var dbConnection = settings.CreateDbConnection();
                        var commandTimeout = settings.CommandTimeout;

                        return new DbService(dbConnection, commandTimeout);
                    });

                    // DbServiceFactory
                    services.AddTransient<Func<IDbService>>(provider =>
                    {
                        return () => provider.GetRequiredService<IDbService>();
                    });

                    // 画面
                    services.AddTransient<MainForm>();
                })
                .Build();

            ApplicationConfiguration.Initialize();

            var form = host.Services.GetRequiredService<MainForm>();
            Application.Run(form);
        }
    }
}
```

↓ コンストラクタで受け取る ! そして好きなタイミングで生成して使う !
```MainForm.cs
using DbManager;

namespace DbManagerTestApp
{
    // アプリのメイン画面
    public partial class MainForm : Form
    {
        private readonly Func<IDbService> _dbServiceFactory;

        // コンストラクタ
        public MainForm(Func<IDbService> dbServiceFactory)
        {
            InitializeComponent();

            _dbServiceFactory = dbServiceFactory; // 注入
        }

        // ボタンクリック
        private void GetDateTimeButton_Click(object sender, EventArgs e)
        {
            using (var db = _dbServiceFactory()) // ここで DbService が都度 new される
            {
                var data = db.Get("SELECT SYSDATETIME() AS DateTime").ToList();

                LblDate.Text = data[0].DateTime.ToString();

                // using を抜けたら DbService を破棄する
            }
        }
    }
}
```

</details>

# おわりに
勢いで作って勢いで記事にしたので、ちゃんと動かなかったらごめん :pray: