ようこそ Dapper 至上主義の DataAccess へ【#5 WPF MVVM L@bo】
前回は 3 階層アーキテクチャのデータ層から AbstractFactory パターンで DBMS への依存を取り除く方法を紹介したので、今回は Micro-O/RM の Dapper を使用して SQLite からデータを読み書きする方法を紹介します。
2022/10/11 追記
このエントリでも紹介している Dapper の使用方法のみを『Micro-ORM Dapper の使い方』と言う新しいエントリとしてリライトしました!
このエントリでは MVVM パターン内で Dapper を使用するサンプルになっていますが、新しいエントリではシンプルに Dapper の使用方法のみ紹介するようなエントリにしているので、このエントリよりは見やすくなっていると思うので、新しいエントリの方を読んでもらえるとありがたいです!
尚、この記事は Visual Studio 2019 Community Edition で .NET Core 3.1 以上 と C# + Prism 7.2 以降 + ReactiveProperty + Livet + MahApps.Metro + Material Design In XAML Toolkit + SQLite を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
目次
データ層の DataAccess クラス
前回 はプロトタイプアプリを fig. 1 のような 3 階層 MVVM 構造で作成した場合のアプリケーション層から DBMS を隠蔽する方法を紹介しました。
前回 紹介した HalationGhostDbAccessBase はデータ層の裏方的立場のクラスなので、実際にアプリケーション層とやり取りする DataAccess クラスは HalationGhostDbAccessBase から継承して新規に作成します。
今回はその新規で作成した DataAccess クラスから SQLite のデータを読み書きする方法を紹介します。
DataAccess 紹介用サンプルアプリ
DataAccess クラスの紹介用にプロトタイプアプリへ『DapperSample』から始まるサンプル用プロジェクト群を追加しました。(DapperSample ソリューションフォルダにまとめています)
サンプルの実行には DapperSample をスタートアッププロジェクトに設定してください。
併せて DapperSample プロジェクト内に SQLite の DB ファイルも追加しています。
データの中身はこのサイトではお馴染み BLEACH のキャラクターデータを以下のように入力済みです。
(以下は入力データの一部)
ID | キャラクター名 | フリガナ | 誕生日 | 所属 | 斬魄刀 |
---|---|---|---|---|---|
1 | 黒崎 一護 | くろさき いちご | 7月15日 | 15 | 1 |
2 | 朽木 ルキア | くちき ルキア | 1月14日 | 13 | 2 |
3 | 井上 織姫 | いのうえ おりひめ | 9月3日 | 15 | |
4 | 石田 雨竜 | いしだ うりゅう | 11月6日 | 15 | |
5 | 茶渡 泰虎 | さど やすとら | 4月7日 | 15 | |
6 | 阿散井 恋次 | あばらい れんじ | 8月31日 | 6 | 3 |
7 | 黒崎 一心 | くろさき いっしん | 12月10日 | 15 | 4 |
8 | 浦原 喜助 | うらはら きすけ | 12月31日 | 16 | 5 |
9 | 四楓院 夜一 | しほういん よるいち | 1月1日 | 16 | |
10 | 京楽 春水 | きょうらく しゅんすい | 7月11日 | 1 | 6 |
# BLEACH のキャラクターって漢字だし所属もあるしそれなりに認知度もあるんで
# サンプルデータに使用するには管理人的に使いやすいんですw(独り言)
SQLite から データを取得する
追加したサンプルアプリの実行結果は fig. 2 のようになります。
サンプル自体は【Dynamic 型で取得ボタン】Click でデータを取得して簡易コンソールに出力するだけのシンプルなもので、src. 1 は MainWindow の VM です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | using System.Windows; using HalationGhost.WinApps; using Prism.DryIoc; using Prism.Ioc; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace DapperSample { /// <summary> /// MainWindowのVMを表します。 /// </summary> public class MainWindowViewModel : HalationGhostViewModelBase { /// <summary>コンソールの出力内容を取得します。</summary> public ReadOnlyReactivePropertySlim<string> Console { get; } /// <summary>Dynamic型で取得ボタンコマンドを表します。</summary> public ReactiveCommand GetDynamic { get; } /// <summary>Dynamic型で取得ボタンコマンドを実行します。</summary> private void onGetDynamic() { var service = (Application.Current as PrismApplication)?.Container.Resolve<IDapperSampleService>(); service?.ShowTopIdCharacters(this.buf); } /// <summary>コンソールのバッファを表します。</summary> private ConsoleBuffer buf = new ConsoleBuffer(); /// <summary>コンストラクタ。</summary> public MainWindowViewModel() { this.GetDynamic = new ReactiveCommand() .WithSubscribe(() => this.onGetDynamic()) .AddTo(this.disposable); this.Console = this.buf.ConsoleText .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); } } } |
src. 1 の 24行目で DI コンテナから Resolve メソッドでアプリケーション層の Service インタフェースを取得して、データの取得・表示処理を全てアプリケーション層へ委譲しています。
今まで Prism の DI コンテナからオブジェクトを取得する方法はコンストラクタインジェクションしか紹介していませんでしたが、src. 1 の 24 行目のように PrismApplication の Container プロパティから任意のタイミングで取得(生成)することもできます。
アプリケーション層
src. 2 は VM から呼び出されるアプリケーション層の DapperSampleService で、14 行目では DI コンテナからインジェクションされた IRepositoryFactory から DataAccess クラスを生成しています。
RepositoryFactory は DataAccess クラスを new して返すだけの単純なクラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | using System.Collections.Generic; namespace DapperSample { /// <summary>サンプルアプリ用のサービスを表します。</summary> public class DapperSampleService : IDapperSampleService { /// <summary>ID順でトップ10のキャラクターをコンソールに表示します。</summary> /// <param name="console">表示するコンソールを表すConsoleBuffer。</param> public void ShowTopIdCharacters(ConsoleBuffer console) { IEnumerable<dynamic> characters = null; using (var repository = this.factory.CreateCharacterRepository()) { characters = repository.GetTop10Id(); } foreach (var chara in characters) { console.AppendLineToBuffer($"ID: {chara.ID} 名前: {chara.CHARACTER_NAME} かな: {chara.KANA} " + $"誕生日: {chara.BIRTHDAY} 所属: {chara.ORGANIZATION_NAME} 斬魄刀: {chara.ZANPAKUTOU_NAME}"); } } /// <summary>リポジトリのファクトリを表します。</summary> private IRepositoryFactory factory = null; /// <summary>コンストラクタ。</summary> /// <param name="repositoryFactory"></param> public DapperSampleService(IRepositoryFactory repositoryFactory) { this.factory = repositoryFactory; } } } |
DataAccess クラスの継承元である HalationGhostDbAccessBase は内部に DB への Connection を持ち、コンストラクタで接続して、Dispose で切断しています。
このようにオブジェクトの生存期間を外部からコントロールする必要があるクラスを『状態を持つクラス』と言い、基本的に DI コンテナへ登録する対象には向いていません。
とは言っても『状態を持つクラス』を DI したい場合もあるため、状態を持たない Factory クラスをインジェクションして Factory からインスタンスを受け取るようにしています。
参考:『単体テストと副作用 – 第5話 オブジェクト指向への反乱 – Qiita』
そして IRepositoryFactory で取得する DataAccess クラスは Repository パターンで実装します。
DataAccess クラスを Repository パターンで実装する
Repository パターンとはデータの操作に関連する実装を、抽象化したデータ層のクラスに委譲してアプリケーション層から切り離すことで保守や拡張性を高めるパターンで、Microsoft のインフラストラクチャの永続レイヤーの設計 等で解説されています。
上記 Microsoft の記事 で『Repoitory は各集約または集約ルートに 1 つの Repoitory クラスを作成する必要がある』と書かれているように、Repository パターンの紹介記事ではまず src. 3 のような Generic 型パラメータを取る汎用 Repository インタフェースを定義して… と言うような内容を目にすることが多い印象です。
1 2 3 4 5 6 7 8 9 10 11 | /// <summary>汎用リポジトリインタフェースを表します。</summary> public interface IRepository<T> { public void Save(T target); public T GetById(string id); public void Delete(T target); public void Update(T target); } |
src. 3 のような汎用 Repoitory インタフェースを定義したい気持ちは理解できますが、管理人的にはイマイチ納得できません。
納得できない理由として Delete や Update はトランザクション系のデータには不要な場合も多々ありますし、キーを指定してデータを取得する GetById メソッドは文字列のキーにしか対応できない、複合キーにも対応できない等…インタフェースとしてして定義するには不完全だと思うからです。
但し、汎用 Repoitory インタフェースを全否定している訳ではなくマスタ系データのように汎用 Repoitory インタフェースを定義した方が良い場合もあるので、その辺りは臨機応変にすべきだと思っています。
ここでは src. 3 のような汎用 Repoitory インタフェースは作成せず、src. 4 のような BleachCharacter 用の Repository インタフェースを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | using System; using System.Collections.Generic; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public interface ICharacterRepository : IDisposable { /// <summary>フリガナ昇順のトップ10キャラクターを取得します。</summary> /// <returns>取得したキャラクターを表すList<BleachCharacter>。</returns> public List<BleachCharacter> GetTop10Furigana(); /// <summary>ID順のトップ10キャラクターを取得します。</summary> /// <returns>取得したキャラクターを表すIEnumerable<dynamic>。</returns> public IEnumerable<dynamic> GetTop10Id(); // この時点では ↑ これだけですが、後々増えていきます。 } } |
言うまでもありませんが、Repository に定義するメソッドの戻り値は DataTable や DataReader ではなくsrc. 4 のようにエンティティ系モデルで返すように定義します。
データ層のインタフェースに ADO.NET 関連のクラスを使用しないことでアプリケーション層からは DBMS がほぼ完全に隠蔽されるようになりますが、その反面 DB から取得した値をエンティティ系モデルに積み替える処理が必要になります。
O/R マッパー
DB から取得した値をオブジェクトに設定することを O/RM(Object-relational mapping)と呼び、O/RM を実現するためのライブラリ等は O/R マッパーと呼ばれます。
.NET Core に対応した O/R マッパーには Microsoft 製の Entity Framework や Java から移植された NHibernate 等があります。
O/R マッパーは以下のような特徴を持ちます。
- SQL の自動生成
- オブジェクトへの値マッピング
- DB操作のラッピング
- ソースコード自動生成
中でも特に賛否両論が多いのは【SQL の自動生成機能】で、業務系ではよく見かけるホスト由来の DB のように DB 設計がイケてない場合や、そもそもリレーションが張られていないような場合はパフォーマンスが良くない SQL が生成されることも多く、やっぱり SQL は手書きが 1 番!と言う意見も多いようです。
とは言え、オブジェクトへのマッピング処理部分だけは欲しい!と言う意見も同時にあり、 Micro(マイクロ)O/R マッパー(以降 Micro-O/RM)と呼ばれるジャンルのライブラリも出て来ました。
Micro-O/RM は上記 O/R マッパーの特徴の内【オブジェクトへの値のマッピング】に特化して『SQL の自動生成』や『ソースコード自動生成』等の機能を省いた(プロダクトによって異なります)ものが多いため、クエリの実行速度は手書きした SQL に依存します。
実際、オブジェクトと DB の値をマッピングするだけなら Reflection でも可能なのでオレオレ O/R マッパーを作成する事も難しいことではありませんが、DB から取得する値は数千~数万件(もっと)になる場合もあるので、オブジェクトマッピングのパフォーマンスが処理全体のパフォーマンスに影響を及ぼす場合もあります。
次項からは 既に何番煎じなのか分からないくらい紹介されている Micro-O/RM の Dapper を紹介します。
Micro-O/R マッパー Dapper
.NET 用の Micro-O/RM では最も有名と言って差し支えない Dapper は Stack Overflow の中の人が Stack Overflow 自体のパフォーマンス改善を目的に作成した Micro-O/RMで、シンプルな構文と高速なオブジェクトへのマッピングが特徴です。
Dapper は .NET Core・Framework に両対応していて、現時点(2020 年 3 月現在)も超巨大サイトである Stack Overflow で鍛え続けられている信頼度の高い Micro-O/R マッパーと言えます。
既にオレオレ Micro-O/RM を作成していても 1 度試してみる価値はあると思います。
Dapper のパフォーマンスは GitHub の Readme.md で公表されていて、ADO.NET の SqlCommand を直接呼出した場合(ベンチマーク上から 3 番目の Hand Coded)とほとんど変わらない(ベンチマーク上から 6 番目)実行速度を誇ります。
現時点(2020 年 3 月現在)で Dapper が公式にサポートしている DBMS は以下の製品です。
- Oracle
- SQL Server
- MySQL
- PostgreSQL
- SQLite
- SQL Server Compact Edition
- Firebird
上記の通り Dapper は商用・非商用を含めてメジャーな DBMS をサポートしているので、.NET Core(Framework)を採用した大半の業務系開発に使用できると言えます。
Dapper のインストール
Dapper は Nuget から入手できるので、fig. 2 のように『ソリューションの Nuget パッケージの管理ウィンドウ』から【dapper】を検索すると見つかります。
赤枠で囲んだ【Dapper】をデータ層に該当するプロジェクトへインストールすると使用できます。
Dapper で SQLite からデータを Select
Dapper の基本操作は Dapper Tutorial でも書かれていますが、一応このエントリでも紹介します。
以降、Dapper から SQL を発行してクラスへマッピングする例を紹介していきます。
このエントリでは DBMS に SQLite を使用してマッピングしていますが、Dapper がサポートしている DBMS であれば全て同じ方法でマッピングできます。(バインド変数の書き方『:』、『@』が違う程度です)
SQLite しかできない事はほとんど載せていません。(SQL 自体の方言の違いはあります)
Dapper からレコードを dynamic 型で Select する
src. 5 はこのエントリの本題であるキャラクター用の DataAccess(Repository)クラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | using System.Collections.Generic; using System.Text; using Dapper; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public class CharacterRepository : DapperSampleDataAccessBase, ICharacterRepository { /// <summary>ID順でトップ10のキャラクターを取得します。</summary> /// <returns>ID順でトップ10のキャラクターを表すIEnumerable<dynamic>。</returns> public IEnumerable<dynamic> GetTopIdCharacters() { var sql = new StringBuilder(800); sql.AppendLine(" SELECT * FROM "); sql.AppendLine(" ( "); sql.AppendLine(" SELECT CHR.ID, CHR.CHARACTER_NAME "); sql.AppendLine(" , CHR.KANA, CHR.BIRTHDAY, CHR.ORGANIZATION "); sql.AppendLine(" , ORG.ORGANIZATION_NAME, CHR.ZANPAKUTOU, ZBT.ZANPAKUTOU_NAME "); sql.AppendLine(" FROM CHARACTERS CHR "); sql.AppendLine(" LEFT JOIN ORGANIZATIONS ORG ON CHR.ORGANIZATION = ORG.ID "); sql.AppendLine(" LEFT JOIN ZANPAKUTOU ZBT ON CHR.ZANPAKUTOU = ZBT.ID "); sql.AppendLine(" ORDER BY CHR.ID "); sql.AppendLine(" ) CHR LIMIT 10 "); return this.Connection.Query(sql.ToString()); } } } |
このサンプル用に作成した SQLite DB は BLEACH のキャラクターデータをキャラクター Table、組織 Table、斬魄刀 Table の 3 つに分けています。
src. 5 の SQL は分割したテーブルを表示用に Join しているだけの単純な Select 文ですが、先頭 n 件のデータのみ取得するための【LIMIT 句】を 24 行目で指定してます。
Oracle では ROWNUM、SQL Server では Top n を指定した場合と同じく、SQLite ではテーブル名の後ろに【LIMIT n】を追加すると取得件数を制限できます。
Dapper で実際にデータを Select しているのは 26 行目の『this.Connection.Query(sql.ToString() …);』の部分です。
Dapper は DB へアクセスするためのメソッドを全て IDbConnection の拡張メソッドとして定義しているので有効な DbConnection オブジェクトさえあれば 26 行目のように呼び出すことができます。
src. 5 の CharacterRepository が継承する DapperSampleDataAccessBase は前回の #4 で紹介した HalationGhostDataAccessBase を継承していて、このサンプルアプリ内で作成する DataAccess は全てこの DapperSampleDataAccessBase を継承して作成します。
データ層のクラスを fig. 3 のような継承関係で作成すると HalationGhostDataAccessBase から要求される設定は DapperSampleDataAccessBase が一手に引き受けるので、アプリの DataAccess は DBMS を全く意識せずアプリケーションロジックの実装のみに注力できます。
そして、SQL の発行は全て Dapper を通して行うため、HalationGhostDataAccessBase が protected で公開している DbConnection さえあれば良いことになります。
そのため DbCommand や DbDataReader の公開は不要ですが、業務系システム等では SQL のログを取るような要件が含まれる場合があります。
そのような場合に DbConnection を公開してしまうと SQL は全てアプリケーションレベルの DataAccess から直接発行されてしまうため、ログの取得処理の共通化が難しくなります。
SQL ログの取得処理が必要な場合等は DbConnection を公開せず HalationGhostDataAccessBase 等で Dapper をラップしたメソッドを定義する方が良い場合もあると思います。
(Dapper のメソッドはオーバーロードが多いのでラップメソッドの実装も大変だとは思います…)
少し横道に逸れましたが、src. 5 のように Dapper からレコードを Select する Query メソッドは以下ような Query ~ から始まるバリエーションが定義されています。
- Query
- QueryFirst
- QueryFirstOrDefault
- QueryMultiple
- QuerySingle
- QuerySingleOrDefault
上記のメソッドにはそれぞれ型パラメータあり・無し、Async あり・無し版のメソッドも用意されていて、型パラメータを設定しない場合は src. 5 のように dynamic 型又は、IEnumerable<dynamic> 型で取得できます。
DB の値を Dapper から dynamic 型で取得すると src. 2 21 ~ 22 行目のように Select 文で指定したフィールド名がプロパティのように利用できます。(※ src. 2 はかなり上の方で紹介しています)
Dapper からレコードを任意の型で Select する
Dapper は src. 6 のように Query メソッドに型パラメータを指定すると、値をマッピングしたクラスのインスタンスが返されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | using System.Collections.Generic; using System.Linq; using System.Text; using Dapper; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public class CharacterRepository : DapperSampleDataAccessBase, ICharacterRepository { /// <summary>フリガナ昇順のトップ10キャラクターを取得します。</summary> /// <returns>取得したキャラクターを表すList<BleachCharacter>。</returns> public List<BleachCharacter> GetTopFuriganaCharacters() { var sql = new StringBuilder(1000); sql.AppendLine(" SELECT "); sql.AppendLine(" CHR.ID AS Id , CHR.CHARACTER_NAME AS Name "); sql.AppendLine(" , CHR.KANA AS Furigana , CHR.BIRTHDAY AS Birthday "); sql.AppendLine(" , CHR.ORGANIZATION AS OrganizationId , ORG.ORGANIZATION_NAME AS OrganizationName "); sql.AppendLine(" , CHR.ZANPAKUTOU ZanpakutouId , ZBT.ZANPAKUTOU_NAME AS ZanpakutouName "); sql.AppendLine(" FROM CHARACTERS CHR "); sql.AppendLine(" LEFT JOIN ORGANIZATIONS ORG ON CHR.ORGANIZATION = ORG.ID "); sql.AppendLine(" LEFT JOIN ZANPAKUTOU ZBT ON CHR.ZANPAKUTOU = ZBT.ID "); sql.AppendLine(" WHERE CHR.ID <= :Id "); sql.AppendLine(" ORDER BY CHR.KANA "); return this.Connection.Query<BleachCharacter>(sql.ToString(), new BleachCharacter() { Id = 10 }).ToList(); // return this.Connection.Query<BleachCharacter>(sql.ToString(), new { Id = 10 }).ToList(); } } } |
型パラメータに指定したクラスへマッピングするには【フィールド名 == クラスのプロパティ名】になっている必要があるので、src. 6 のようにマッピング先のプロパティ名に合わせて【AS 句】を指定します。
※ 今回作成したサンプル DB のフィールド名は業務系システムでよく見る『全て大文字 + ハイフン区切り』の命名規則で作成しました。
加えて Dapper は src. 6 27 行目のようにバインド変数にマッピングすることもできます。
(SQLite のバインド変数は【:】、【@】のどちらでも指定可能)
Dapper のバインド変数マッピングはクラスのプロパティマッピングの場合と同じく、名前が一致する項目をマッピングするため、バインド変数の『Id』には『10』が設定されます。
※ 28 行目のように匿名型を渡すこともできます。
バインド変数のマッピングに実在・匿名どちらのクラスを指定した場合でも fig. 4 のような実行結果になります。
バインド変数用のパラメータを ADO.NET 標準の方法で設定するのは結構記述が面倒ですが、Dapper では非常に簡単に記述できると思います。
更に Dapper は src. 7 のように【IN 句】に List 型をマッピングすることもできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | using System.Collections.Generic; using System.Linq; using System.Text; using Dapper; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public class CharacterRepository : DapperSampleDataAccessBase, ICharacterRepository { /// <summary>フリガナ昇順のトップ10キャラクターを取得します。</summary> /// <returns>取得したキャラクターを表すList<BleachCharacter>。</returns> public List<BleachCharacter> GetTopFuriganaCharacters() { var sql = new StringBuilder(1000); sql.AppendLine(" SELECT * FROM "); sql.AppendLine(" ( "); sql.AppendLine(" SELECT "); sql.AppendLine(" CHR.ID AS Id , CHR.CHARACTER_NAME AS Name "); sql.AppendLine(" , CHR.KANA AS Furigana , CHR.BIRTHDAY AS Birthday "); sql.AppendLine(" , CHR.ORGANIZATION AS OrganizationId , ORG.ORGANIZATION_NAME AS OrganizationName "); sql.AppendLine(" , CHR.ZANPAKUTOU ZanpakutouId , ZBT.ZANPAKUTOU_NAME AS ZanpakutouName "); sql.AppendLine(" FROM CHARACTERS CHR "); sql.AppendLine(" LEFT JOIN ORGANIZATIONS ORG ON CHR.ORGANIZATION = ORG.ID "); sql.AppendLine(" LEFT JOIN ZANPAKUTOU ZBT ON CHR.ZANPAKUTOU = ZBT.ID "); sql.AppendLine(" WHERE CHR.ORGANIZATION IN :ORGANIZATION "); sql.AppendLine(" ORDER BY CHR.ORGANIZATION "); sql.AppendLine(" ) CHR LIMIT 10 "); return this.Connection.Query<BleachCharacter>(sql.ToString(), new { ORGANIZATION = new List<int>() { 3, 6, 9, 11 } }).ToList(); } } } |
ここではバインド変数のパラメータに Generic の List を渡していますが、単純な配列(おそらく IEnumerable を継承しているクラスであれば OK)でも渡せます。
但し、注意点が 1 つあります! src. 7 の 24 行目をよく見てください。
【IN 句】の右辺に括弧が無い事に気が付きましたか?
おそらく Dapper 内部で括弧を補完しているのだと思いますが、いつものクセで括弧を書いてしまうと SQL 解析エラーになるので気を付けてください。(管理人は 3 分程悩まされました)
src. 7 の実行結果が fig. 5 です。
SQL を書く機会が多い人であれば『Dapper って便利そう』と感じてもらえるかもしれませんが、この連載で紹介しているような WPF アプリで使用する場合に重大な問題が 1 つあります。
Dapper で Value Object にマッピング
WPF アプリで Dapper を使用した場合の問題とは、src. 8 のように ReactiveProperty で定義したプロパティにマッピングできないと言う問題です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | using HalationGhost; using Reactive.Bindings; namespace DapperSample { /// <summary>BLEACHのキャラクターを表します。</summary> public class BleachCharacter : BindableModelBase { /// <summary>キャラクターIDを取得・設定します。</summary> public ReactivePropertySlim<long> Id { get; } /// <summary>キャラクター名を取得・設定します。</summary> public ReactivePropertySlim<string> Name { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> public BleachCharacter() { this.Id = new ReactivePropertySlim<long>(0); this.Name = new ReactivePropertySlim<string>(string.Empty); ~ 略 ~ } } } |
基本的に Dapper はプロパティを値型とみなしてマッピングするため、ReactiveProperty のような参照型のプロパティに直接マッピングすることはできないようです。
このような問題は ReactiveProperty だけでなく DDD(ドメイン駆動設計)等でもお馴染みの Value Object をプロパティの型に指定する場合も同じですが、Dapper に用意されている SqlMapper.TypeHandler を継承して src. 9 のようにマッピング定義を追加するとマッピングできるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | using System.Data; using Reactive.Bindings; using static Dapper.SqlMapper; namespace DapperSample { /// <summary>ReactivePropertySlim<long>型用ハンドラを表します。</summary> public class ReactiveSlimLongTypeHandler : TypeHandler<ReactivePropertySlim<long>> { /// <summary>DBから取得した値からプロパティに設定する値を取得します。</summary> /// <param name="value">DBから取得した値を表すobject。</param> /// <returns>プロパティに設定する値を表すReactivePropertySlim<long>。</returns> public override ReactivePropertySlim<long> Parse(object value) => new ReactivePropertySlim<long>((long)value); /// <summary>バインド変数にマッピングします。</summary> /// <param name="parameter">設定するDBパラメータを表すIDbDataParameter。</param> /// <param name="value">バインド変数にマッピングするプロパティを表すReactivePropertySlim<long>。</param> public override void SetValue(IDbDataParameter parameter, ReactivePropertySlim<long> value) { parameter.DbType = DbType.Int64; parameter.Value = value.Value; } } } |
メソッドコメントの通り Parse メソッドはオブジェクトへのマッピング、SetValue メソッドはバインド変数へのマッピング時に呼び出されます。
サンプルアプリでは src. 9 に加えて ReactivePropertySlim<string> 型の TypeHandler クラスも作成しています。
TypeHandler クラスを作成したら Dapper に TypeHandler を登録する必要があります。
具体的には src. 10 のようにアプリ起動時に TypeHandler クラスを Dapper へ登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | using Dapper; using Prism.Ioc; using Prism.Modularity; using HalationGhost.WinApps.DatabaseAccesses; namespace DapperSample { /// <summary>DapperSampleApplicationLayerモジュールを表します。</summary> public class DapperSampleApplicationLayerModule : IModule { /// <summary>モジュールを初期化します。</summary> /// <param name="containerProvider"></param> public void OnInitialized(IContainerProvider containerProvider) => DapperSampleDataAccessBase.InitializedSqlMapper(); /// <summary>DIコンテナへ型を登録します。</summary> /// <param name="containerRegistry">登録用のDIコンテナを表すIContainerRegistry。</param> public void RegisterTypes(IContainerRegistry containerRegistry) => containerRegistry.Register<IRepositoryFactory, RepositoryFactory>(); } /// <summary>DapperSampleサンプルアプリのDataAccess用親クラスを表します。</summary> public abstract class DapperSampleDataAccessBase : HalationGhostDbAccessBase { /// <summary>Dapperのマッピング設定を初期化します。</summary> public static void InitializedSqlMapper() { SqlMapper.AddTypeHandler(new ReactiveSlimLongTypeHandler()); SqlMapper.AddTypeHandler(new ReactiveSlimStringTypeHandler()); } /// <summary>コンストラクタ。</summary> public DapperSampleDataAccessBase() : base(new HalationGhostDbConnectSettingLoaderBase(string.Empty, "DbConnectSetting.xml")) { } } } |
スペースの都合上 1 箇所にまとめていますが、DapperSampleApplicationLayerModule はアプリケーション層、DapperSampleDataAccessBase はデータ層のクラスです。
DapperSampleApplicationLayer プロジェクトは Prism Module プロジェクトテンプレートから作成したので、アプリ起動時に OnInitialized メソッドが呼ばれます。
OnInitialized メソッドでは DapperSampleDataAccessBase.InitializedSqlMapper メソッドを呼び出してマッピング設定を登録しています。
OnInitialized メソッドで直接マッピング設定を登録する形でも構いませんが、アプリケーション層用の DapperSampleApplicationLayer プロジェクトに Dapper の参照を追加したくなかったので src. 10 のような方法を採っています。
Value Object や ReactiveProperty で定義したプロパティは読み取り専用で定義することが多いですが、TypeHandler クラスは src. 9 15 行目の通りプロパティのインスタンスを返すのでプロパティの定義に setter も追加する必要があるので、src. 11 のように TypeHandler を指定した型のプロパティに setter も追加します。
1 2 3 4 5 6 7 8 9 10 | /// <summary>BLEACHのキャラクターを表します。</summary> public class BleachCharacter : BindableModelBase { /// <summary>キャラクターIDを取得・設定します。</summary> public ReactivePropertySlim<long> Id { get; set; } /// <summary>キャラクター名を取得・設定します。</summary> public ReactivePropertySlim<string> Name { get; set; } ~ 略 ~ } |
一般的な Value Object 等の場合であればここまでマッピングできるはずですが、ReactiveProperty の場合は少し注意が必要です。
ReactiveProperty で定義したプロパティを Subscribe しているような場合は自動実装プロパティではなく src. 12 のような完全記述プロパティに変更する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | using HalationGhost; using Reactive.Bindings; namespace DapperSample { /// <summary>BLEACHのキャラクターを表します。</summary> public class BleachCharacter : BindableModelBase { private ReactivePropertySlim<long> _id; /// <summary>キャラクターIDを取得・設定します。</summary> public ReactivePropertySlim<long> Id { get => this._id; set { this._id?.Dispose(); this._id = value; } } private ReactivePropertySlim<string> _name; /// <summary>キャラクター名を取得・設定します。</summary> public ReactivePropertySlim<string> Name { get => this._name; set { this._name?.Dispose(); this._name = value; } } ~ 略 ~ /// <summary>コンストラクタ。</summary> public BleachCharacter() { this._id = new ReactivePropertySlim<long>(0); this._name = new ReactivePropertySlim<string>(string.Empty); ~ 略 ~ } } } |
重要なのは 17、30 行目の Dispose で、Subscribe しているプロパティでは最悪メモリリークの危険もあるので、元の ReactiveProperty は必ず Dispose しておく方が良いでしょう。
そして、src. 12 には書いていませんが、元々 Subscribe していたプロパティであれば再度 Subscribe する必要もあります。
src. 8 のように ReactiveProperty を単純に new しているだけのプロパティなら src. 11 のように【set;】を追加するだけで問題ないと思いますが、少なくとも Subscribe しているプロパティだけは src. 12 のように変更した方が良いと思います。
但し、管理人も DB から取得した値がマッピングされる所までしか確認していないので、VM に双方向バインドした場合の動作は分かっていません。機会があれば又、確認したいと思います。
Header – Detail モデルへのマッピング
Dapper は単一クラスへのマッピングだけでなく複数のクラスに同時マッピングする事もできます。
src. 13 は業務系システムでもよく見かける『ヘッダ – 明細形式』等と呼ばれる複合(入れ子になった)オブジェクト(SoulSocietyParty)へのマッピング例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | using System.Collections.Generic; using System.Linq; using System.Text; using Dapper; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public class CharacterRepository : DapperSampleDataAccessBase, ICharacterRepository { /// <summary>護廷十三隊別にキャラクターを取得します。</summary> /// <returns>取得した護廷十三隊を表すList<SoulSocietyParty>。</returns> public List<SoulSocietyParty> GetCharactersByParty() { var sql = new StringBuilder(1000); sql.AppendLine(" SELECT ORG.ID AS PartyId, ORG.ORGANIZATION_NAME AS PartyName "); sql.AppendLine(" , CHR.ID AS Id, CHR.CHARACTER_NAME AS Name sql.AppendLine(" , ZPT.ZANPAKUTOU_NAME AS ZanpakutouName, ZPT.BANKAI_NAME AS BankaiName "); sql.AppendLine(" FROM sql.AppendLine(" ( "); sql.AppendLine(" SELECT * FROM ORGANIZATIONS ORG "); sql.AppendLine(" WHERE ORG.ID IN(3, 5, 6, 10, 11) "); sql.AppendLine(" ) ORG "); sql.AppendLine(" LEFT JOIN CHARACTERS CHR ON ORG.ID = CHR.ORGANIZATION "); sql.AppendLine(" LEFT JOIN ZANPAKUTOU ZPT ON CHR.ZANPAKUTOU = ZPT.ID "); sql.AppendLine(" ORDER BY ORG.ID, CHR.ID "); var partyDic = new Dictionary<long, SoulSocietyParty>(); this.Connection.Query<SoulSocietyParty, BleachCharacter, SoulSocietyParty>( sql.ToString(), (party, bleachChara) => { SoulSocietyParty partyEntry = null; if (!partyDic.TryGetValue(party.PartyId, out partyEntry)) { partyEntry = party; partyDic.Add(partyEntry.PartyId, partyEntry); } partyEntry.PartyMembers.Add(bleachChara); return partyEntry; }, splitOn: "Id" ); return partyDic.Values.ToList(); } ~ 略 ~ } /// <summary>サンプルアプリ用のサービスを表します。</summary> public class DapperSampleService : IDapperSampleService { ~ 略 ~ /// <summary>護廷十三隊別にキャラクターをコンソールに表示します。</summary> /// <param name="console">表示するコンソールを表すConsoleBuffer。</param> public void ShowCharactersByParty(ConsoleBuffer console) { console.Clear(); List<SoulSocietyParty> parties = null; using (var repository = this.factory.CreateCharacterRepository()) { parties = repository.GetCharactersByParty(); } foreach (var party in parties) { console.AppendLineToBuffer($"隊ID: {party.PartyId} 隊名: {party.PartyName}"); foreach (var chara in party.PartyMembers) { console.AppendLineToBuffer($"\tID: {chara.Id} 名前: {chara.Name} 斬魄刀: {chara.ZanpakutouName} 卍解: {chara.BankaiName}"); } } } ~ 略 ~ } } |
スペースの都合上、アプリケーション層と DataAccess クラスを一緒に紹介しています。
SQL は所属 Table とキャラクター Table を Join して『護廷十三隊別所属キャラクターリスト』を取得するために Dapper の型パラメータ付き Query メソッドを呼び出しています。
Query メソッドの型パラメータシグネチャは『<TFirst, TSecond, TReturn>』で定義されていて、それぞれ【<1 つ目のマッピング対象クラス, 2 つ目のマッピング対象クラス, マッピング後の戻り値クラス>】を表します。
Query メソッドはマッピング対象クラスを 7 個まで指定できるオーバーロードが用意されています。
複数のオブジェクトに値をマッピングできても生成後のオブジェクトをどこにセットするかは自動で判断できない(判断するのは難しい)ため、親子関係を設定するための匿名メソッドを Query の第 2 パラメータへ指定できるようになっていて src. 13 の 32 ~ 46 行目がその匿名メソッドです。
第 2 パラメータに指定する匿名メソッドは DB から取得した行ごとに呼び出されるので、Query メソッドの外側に宣言した Dictionary に親になるオブジェクト(SoulSocietyParty)を退避しながら親子関係を設定しています。
この匿名メソッドで重要なのは、Query の戻り値は読み捨てている点で、GetCharactersByParty の最終的な戻り値は匿名メソッド内で親オブジェクトを退避していた Dictionary から取得しています。
難しいことをしている訳ではありませんが、他人が書いたコードだと気が付かない場合もあると思うので敢えて書いておきます。(実際、管理人はなかなか気が付きませんでした…)
『ヘッダ – 明細形式』で取得した結果が fig. 6 です。
全キャラクターを出力する必要もないので取得する隊を絞っていますが、『護廷十三隊別所属キャラクターリスト』が出力されています。
又、Dapper の Query メソッドで複数のオブジェクトをマッピングする場合、省略可能パラメータの【splitOn パラメータ】を指定する必要もあります。
splitOn パラメータ は 1 つ目と 2 つ目のオブジェクトをどのフィールド(Select の)で区切るかを指定するパラメータで、src. 13 の場合は BleachCharacter.Id が区切り位置になるため【Id】を指定していて、3 つ以上のクラスにマッピングする場合は【Id,ZanpakutoId】と言うようにカンマで区切って指定します。
ですが、src. 13 の場合、splitOn パラメータをコメントアウトして実行しても正常に動作します。
これは Dapper では【Id】と言うフィールド名は特別扱いなので、フィールドリスト内の【Id】は無条件でクラス間の区切りに使用されると言う仕様があるからだそうです。
Dapper のマッピングについては『Dapper のクエリ – Qiita』等でも紹介されています。
DB からデータを取得する方法については大体紹介し終えたので、続いて登録・追加・削除を一気に紹介します。
Dapper で SQLite へデータを Insert
DB へ Insert する場合も Select する場合と同じで、バインド変数名とクラスのプロパティ名を一致させると src. 14 のようにマッピングされます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Dapper; namespace DapperSample { /// <summary>キャラクター用リポジトリを表します。</summary> public class CharacterRepository : DapperSampleDataAccessBase, ICharacterRepository { ~ 略 ~ /// <summary>キャラクターを登録します。(非同期)</summary> /// <param name="character">登録するキャラクターを表すBleachCharacter。</param> /// <returns>登録件数を表すint。</returns> public async Task<int> RegistCharacterAsync(BleachCharacter character) { var sql = new StringBuilder(300); sql.AppendLine(" INSERT INTO CHARACTERS( CHARACTER_NAME, KANA, ORGANIZATION "); sql.AppendLine(" ) VALUES ( :Name, :Furigana, :OrganizationId ) "); return await this.Connection.ExecuteAsync(sql.ToString(), character); } /// <summary>キャラクターを登録します。(非同期)</summary> /// <param name="characters">登録するキャラクターを表すList<BleachCharacter>。</param> /// <returns>登録件数を表すint。</returns> public async Task<int> RegistCharactersAsync(List<BleachCharacter> characters) { var sql = new StringBuilder(300); sql.AppendLine(" INSERT INTO CHARACTERS( CHARACTER_NAME, KANA, ORGANIZATION "); sql.AppendLine(" ) VALUES ( :Name, :Furigana, :OrganizationId ) "); return await this.Connection.ExecuteAsync(sql.ToString(), characters); } /// <summary>ふりがなを指定してキャラクターを削除します。(非同期)</summary> /// <param name="characters">削除するキャラクターを表すList<BleachCharacter>。</param> /// <returns>削除件数を表すint。</returns> public async Task<int> DeleteCharactersByKanaAsync(List<BleachCharacter> characters) { var sql = new StringBuilder(100); sql.AppendLine(" DELETE FROM CHARACTERS WHERE KANA IN :Furigana "); return await this.Connection.ExecuteAsync(sql.ToString(), new { Furigana = characters.Select(b => b.Furigana).ToList() }); } } } |
src. 14 では非同期版の Query を呼んでいますが、同期版の場合も変わりません。
22 行目のように DB へ登録する値をセットしたクラスのインスタンスを Query メソッドに渡すとバインド変数とプロパティがマッピングされ Insert されます。
※ ID 列はオートナンバー型なので登録対象フィールドからは除外しています
併せて 34 行目も見てください。管理人は今回調べるまでは知りませんでしたが、34 行目のように登録するクラスの List を渡すとループを書かなくても List 内の全メンバを Insert してくれます。
Insert、Update、Delete の場合は Query ではなく Execute を呼ぶ程度の違いなので Select のマッピングが分かっていれば迷う事はほとんど無いと思います。
34 行目のように List を渡せば全メンバが対象になるのも Insert、Update、Delete で共通です。
但し、全メンバが対象になると言っても一括登録・更新・削除される訳ではなく、メンバ数分 SQL が発行されるので、例えば Delete の場合なら 45 行目のようにすると SQL 発行は 1 回で済みます。
src. 14 を呼び出すアプリケーション層が src. 15 です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | using System.Collections.Generic; using System.Threading.Tasks; namespace DapperSample { /// <summary>サンプルアプリ用のサービスを表します。</summary> public class DapperSampleService : IDapperSampleService { ~ 略 ~ /// <summary>Insertしたキャラクターを表示します。</summary> /// <param name="console">表示するコンソールを表すConsoleBuffer。</param> /// <returns>非同期処理の結果を表すTask。</returns> public async Task ShowInsertCharacterAsync(ConsoleBuffer console) { console.Clear(); IEnumerable<BleachCharacter> newCharacters = null; using (var repository = this.factory.CreateCharacterRepository()) { var seq = repository.GetCharacterSeq(); using (var tran = repository.BeginTransaction()) { var characters = this.createSavesCharacters(); try { await repository.DeleteCharactersByKanaAsync(characters) .ContinueWith(c => repository.RegistCharactersAsync(characters)) .ContinueWith(c => tran.CommitAsync()); } catch (System.Exception) { tran.Rollback(); throw; } } newCharacters = await repository.GetCharactersByIdOrverAsync(seq); } foreach (var chara in newCharacters) { console.AppendLineToBuffer($"ID: {chara.Id} 名前: {chara.Name} ふりがな: {chara.Furigana} 所属: {chara.OrganizationName}"); } } /// <summary>登録用のキャラクターリストを生成します。</summary> /// <returns>生成した登録用のキャラクターリストを表すList<BleachCharacter>。</returns> private List<BleachCharacter> createSavesCharacters() { return new List<BleachCharacter>() { new BleachCharacter("麒麟寺 天示郎", "きりんじ てんじろう", 14), new BleachCharacter("曳舟 桐生", "ひきふね きりお", 14), new BleachCharacter("二枚屋 王悦", "にまいや おうえつ", 14), new BleachCharacter("修多羅 千手丸", "しゅたら せんじゅまる", 14), new BleachCharacter("兵主部 一兵衛", "ひょうすべ いちべえ", 14) }; } ~ 略 ~ } } |
src. 15 は単純に Insert しているだけでなく以下の処理を実行しています。
- キャラクターテーブルの最大値 ID を取得
- 登録対象キャラクターとふりがなが一致するレコードを削除
- キャラクターを Insert
- 最初に取得した最大値 ID より大きいキャラクターを Select
- 取得したキャラクターをコンソールへ表示
実行結果が fig. 7 です。
『データをInsert!ボタン』を Click するごとに ID が更新されるので、削除して Insert しているのが分かると思います。
又、ここでは紹介していない ExecuteScalar メソッド等も使用しているので紹介したい所ですが、かなり長いエントリになってしまったので Dapper の紹介はここまでにします。
とりあえず #5 まで続けてきた WPF MVVM L@bo シリーズですが今回で一旦休止します。
アクセスログを見ていてイマイチ反応が良くない(苦笑)のも理由ですが、新規で連載をスタートした WPF UI Gallery にウエイトをかけたいと思っているので手が回らなくなったのが 1 番の理由です。
WPF MVVM L@bo シリーズで紹介してきたプロトタイプアプリを途中で投げ出すつもりは無いので、ボチボチ手は入れていこうと思っているのでその内再開する予定です。
万が一、再開希望のようなコメントでももらえると再スタートが早まるかもしれませんw
とりあえず WPF MVVM L@bo シリーズは今回で一旦休止しますが、今回紹介したサンプルと現時点までのプロトタイプアプリのソースコードはいつもの通り GitHub リポジトリ に上げています。
次回は WPF UI Gallery case: 1-2 が公開予定です。