DB が見えるのは嫌なので 3 階層 に AbstractFactory したいと思います。【#4 WPF MVVM L@bo】
前回はプロトタイプアプリで使用する DB としてファイルベース DB の SQLite とその周辺ツール類を紹介したので、今回は DB にアクセスするための 3 階層アーキテクチャと使用する DB をアプリケーション層から隠蔽するための AbstractFactory パターンを紹介します。
尚、この記事は 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# での基本的なコーディング知識を持っている人が対象です。
DB への I/O
WPF Prism episode シリーズからこの新シリーズまでを通して画面動作関連のサンプル紹介が主な内容で、データの I/O 等の内部処理についてはほとんど取り上げてきませんでしたが、今回の新連載ではどんどん扱っていこうと思っています。
WPF から DBへの I/O と言っても特別なことが必要な訳ではなく Windows Form や Web の場合と変わりません。
.NET から DB へアクセスするには System.Data 名前空間に用意されている ADO.NET を使用すれば特に困ることは無いと思いますが、ADO.NET 標準の方法だと SQL 文を発行する度に『DB へ接続 → Command を生成 → 実行』のような処理を何度も繰り返し書く必要があり面倒なので、DB アクセスライブラリを作成してコード量を減らしたり、抽象化していることも多いと思います。
管理人も今回プロトタイプアプリを作るついでに DB へのアクセスが多少楽になる(はずの)汎用の DB アクセスライブラリを作成したので、その内容を紹介します。
尚、ADO.NET の標準的な使用法は検索すればいくらでも見つかるはずなのでそちらを見てもらうとして、次章以降は ADO.NET から SQL を発行するような基本知識は持っている前提の内容です。
多階層アーキテクチャ
今回管理人が作成した DB へのアクセスライブラリは fig. 1 のような 3 階層アーキテクチャのデータ層に位置するクラス群として設計しました。
今時なので、多階層アーキテクチャで設計されていないアプリなんて無いんじゃないの?と思えるくらい当たり前のアーキテクチャだと思っていますが、業務系の開発現場ではちゃんと多階層で設計されたライブラリに出会うことは稀(管理人個人の感想です)なので一応紹介することにします。
fig. 1 は多階層アーキテクチャの中で最も基本と言える 3 階層アーキテクチャ(三層構造)で、他の 5 階層や 7 階層等はこの 3 階層からの派生です。
3 階層の各レイヤーは以下のような役割を持っています。
- ▼ プレゼンテーション層
- UI 層とも呼ばれ、ユーザーへの情報表示や、ユーザーからの入力を受け付ける機能を担当するレイヤーで、Windows Form では Form、WPF では Window、UserControl 等の UI 部品、Web ではブラウザに描画される View がこのレイヤーに位置します。
- ▼ アプリケーション層
- ビジネスロジック層、ファンクション層等とも呼ばれるレイヤーで、下で紹介するデータ層とプレゼンテーション層を仲介する役割を持ちます。
5 階層、7 階層等のアーキテクチャはこの層を『責務』、『関心』、『再利用性』に注目して分割したアーキテクチャです。
- ▼ データ層
- パーシステンス層とも呼ばれるデータ永続化層。業務系開発の大部分はこのレイヤーに RDBMS が位置し、データの読込・保存機能を担うレイヤーです。
多階層アーキテクチャについてはかなり古い記事ですが【階層アーキテクチャの利点は、複雑さの減少 – ITmedia エンタープライズ】が詳しく、主に 5、7 階層アーキテクチャについて紹介されています。
(全て読むには無料の会員登録が必要かもしれません)
MVVM と 多層アーキテクチャ
3 階層アーキテクチャの各レイヤーは MVVM の各要素の役割と被る所も多く、2 つの概念図を対比すると fig. 2 のようになります。
WPF Prism episode シリーズでも何度か書いた『Model は通常、何層かに分ける』とは fig. 2 のように Model は最低でも 2 階層には分けるべきと考えているからです。
加えて、MVVM は UI 層に特化したパターンであり、View、ViewModel 以外は全て Model に分類されると言う意味も fig. 2 から分かってもらえると思います。
fig. 1 のようなアーキテクチャと MVVM のようなデザインパターンは混同されがちですが、振る舞い等を定義するデザインパターンと違って、多階層アーキテクチャは【責務】、【関心】、【再利用性】に注目してクラスをグループ化し、モジュールを配置するレイヤーの位置とデータの受け渡し順を明確にするための指標のようなもの考えておけば良いと思います。
そのため、デザインパターンのように役割が厳密に定義されているわけではありませんが、多階層アーキテクチャはデータの流れやクラスの関心、システム全体の構造等をチーム内で共通に認識するような用途に適していると思います。
多階層アーキテクチャとデザインパターンでは適用分野が異なるので、多階層アーキテクチャと MVVM のどちらを採用するという話ではなく、『システム全体としては 3 層で、プレゼンテーション層は MVVM にする』が正しい表現です。
この辺りの話は『MVC、3 層アーキテクチャから設計を学び始めるための基礎知識 – Qiita』等で詳しく解説されています。(リンク先の内容は MVVM ではなく MVC ですが基本的な考え方は変わりません)
3 階層アーキテクチャの例には Web がよく引き合いに出される(出し易い)事が多いですが、Web だから 3 階層にする、デスクトップアプリだから 2 階層で良いと言うものではなくいくつかの例外を除いてどんなアプリケーションでも最低 3 階層アーキテクチャで作成すべきだと管理人は考えていて、特に データの I/O を機能として持つアプリであれば 3 階層アーキテクチャは必須だと思っています。
次章からはコードを交えながら実装内容を紹介していきます。
DataAccess ライブラリ
作成中のプロトタイプアプリは全体を fig. 3 のような 3 階層アーキテクチャで設計しています。
fig. 3 のような構成にすることで VM からデータ層を直接呼び出すことができなくなり、必ずアプリケーション層を通してデータ層へアクセスすることを強制できます。
ですが、単純に fig. 3 のまま作成してしまうとデータ層が SQLite に依存してしまうため、バックエンド DB を変更したいような場合等の影響が大きいことが予想できますし、作成した DataAcces ライブラリを他プロジェクトに再利用したい場合でも SQLite しか使用できないのはライブラリとしてはお粗末なので、データ層から DBMS への依存を取り除くことにします。
尚、今回のエントリではプレゼンテーション層やアプリケーション層はとりあえず置いておいて、データ層についてのみ紹介します。
データ層から DBMS への依存を取り除く
DBMS への依存をデータ層から取り除くための AbstractFactory(fig. 4 パープルの IDbAccessHelper)を fig. 20 のように配置して HalationGhostDbAccessBase 内部で使用する設計にしました。
fig. 4 のクラス群は他プロジェクトへも使い回せるようにプロトタイプアプリとは別プロジェクトに分割して作成しています。
DataAccess クラスのベースクラス
アプリケーション層と実際にやり取りする DataAccess クラスは src. 3 の HalationGhostDbAccessBase を継承する前提で設計しています。
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 | using System; using System.Data.Common; namespace HalationGhost.WinApps.DatabaseAccesses { /// <summary>DBアクセスのベースクラスを表します。</summary> public abstract class HalationGhostDbAccessBase : IDisposable { /// <summary>データベースのコネクションを表します。</summary> protected DbConnection Connection { get; private set; } = null; /// <summary>トランザクションを開始します。</summary> /// <returns>DBのトランザクションを表すDbTransaction。</returns> public DbTransaction BeginTransaction() => HalationGhostDbAccessBase.helper.GetTransaction(this.Connection); ~ 略 ~ /// <summary>このクラスを破棄します。</summary> /// <param name="disposing">破棄中かを表すbool。</param> protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { this.Connection?.Dispose(); } disposedValue = true; } } ~ 略 ~ /// <summary>デフォルトコンストラクタ。</summary> public HalationGhostDbAccessBase() : base() => this.initializeConnection(); /// <summary>キャッシュした接続文字列を表します。</summary> private static DbConnectionSetting connectionSetting = null; /// <summary>AbstractFactoryを表すIDbAccessHelper。</summary> private static IDbAccessHelper helper = null; /// <summary> /// 接続を初期化します。 /// </summary> private void initializeConnection() { if (HalationGhostDbAccessBase.connectionSetting == null) { // キャッシュされていない場合は接続設定ファイルを読み込む HalationGhostDbAccessBase.connectionSetting = new DbConnectionSettingLoader().Load(); if (HalationGhostDbAccessBase.connectionSetting == null) throw new Exception("DBの接続設定ファイルがLoadできません。"); } // 接続する設定ファイル番号を取得 var num = this.getConnectionNumber(); if ((HalationGhostDbAccessBase.helper == null) || (!num.HasValue)) HalationGhostDbAccessBase.helper = DbAccessHelperFactory.CreateHelper(HalationGhostDbAccessBase.connectionSetting, num); this.Connection = HalationGhostDbAccessBase.helper.GetConnection(); } /// <summary>接続対象の設定ファイル番号を取得します。</summary> /// <returns>接続対象の設定ファイル番号を表すint?。</returns> protected virtual int? getConnectionNumber() => null; } } |
HalationGhostDbAccessBase は DB との Connection を保持することが主な役割のクラスなので、IDisposable を継承してクラスの生存期間と DbConnection の生存期間は一致する設計にしています。
そのため、接続設定は HalationGhostDbAccessBase のコンストラクタでシリアライズしたファイルを読み込むようにしています。
一般的に DB の接続文字列は App.config や Web.config に保存した値を使用することが多いと思いますが、ここで紹介したように階層を分けているとスタートアッププロジェクトで取得した接続文字列を順に渡していく必要があり又、再利用する場合の考慮も面倒なのでライブラリ自身で読み込むようにしました。
そのため、ライブラリを使用するアプリが接続設定ファイルの場所を変更したい場合はこの DbConnectionSettingLoader のみ変更すれば対応できるようにしています。
(DbConnectionSettingLoader も別プロジェクトに分けた方が更に変更の影響を受けにくくなると思いますが、今回はプロトタイプなのでとりあえず同一プロジェクトに置いています)
又、virtual で定義した HalationGhostDbAccessBase.getConnectionNumber メソッドを継承先でオーバーライドすることで接続先 DB を変更できるようにしているので、例えば Oracle と SQLite の両方に接続する必要があるシステムでも継承先の DataAccess クラスから接続先 DB を切り替えることができます。
AbstractFactory パターン
そして AbstractFactory として動作する IDbAccessHelper(名前がパターン名と一致していません…)は src. 4 のように定義しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | using System.Data.Common; namespace HalationGhost.WinApps.DatabaseAccesses { /// <summary>AbstractFactoryを表します。</summary> public interface IDbAccessHelper { /// <summary>DBのConnectionを取得します。</summary> /// <returns>DBのConnectionを表すDbConnection。</returns> public DbConnection GetConnection(); /// <summary>DBのトランザクションを取得します。</summary> /// <param name="connection">トランザクションを取得するDbConnection。</param> /// <returns>DBのトランザクションを表すDbTransaction。</returns> public DbTransaction GetTransaction(DbConnection connection); } } |
ADO.NET には元々 AbstractFactory の使用を想定(?)した DB 操作関連のクラスが System.Data.Common に含まれているので、src. 4 ではその中から DbConnection と DbTransaction のみ公開しています。
DB 操作関連のクラスは他にも DbCommand や DbDataReader 等がありますが、公開していない理由は次回エントリで紹介する予定です。
又、src. 4 でインタフェースのメソッドに【public】が付いているのは Visual Studio 2019(Ver.16.3)以降で対応した『インターフェイスのデフォルト実装』と言う C# 8.0 の新機能のおかげです。ここではメソッドにアクセシビリティを付けただけで特に新機能には関係ありませんが、興味があれば岩永さん主宰の『インターフェイスのデフォルト実装 – ++C++; // 未確認飛行 C』等を見ると良いと思います。
そして src. 4 の IDbAccessHelper を継承した src. 5 の SqliteAccessHelper は SQLite 固有の処理なので別プロジェクトに分けて作成しています。
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 | using System.Data.Common; using System.Data.SQLite; using System.IO; using System.Text.RegularExpressions; using HalationGhost.WinApps.Utilities; namespace HalationGhost.WinApps.DatabaseAccesses.Sqlite { /// <summary>SQLiteデータベースアクセスヘルパを表します。</summary> public class SqliteAccessHelper : IDbAccessHelper { /// <summary>DBのConnectionを取得します。</summary> /// <returns>DBのConnectionを表すDbConnection。</returns> public DbConnection GetConnection() { var con = new SQLiteConnection(SqliteAccessHelper.builder.ToString()); return con.OpenAndReturn(); } /// <summary>DBのトランザクションを取得します。</summary> /// <param name="connection">トランザクションを取得するDbConnection。</param> /// <returns>DBのトランザクションを表すDbTransaction。</returns> public DbTransaction GetTransaction(DbConnection connection) { if (connection == null) return null; var con = connection as SQLiteConnection; if (con == null) return null; if (con.State != System.Data.ConnectionState.Open) return null; return con.BeginTransaction(); } /// <summary>DBへの接続情報を表します。</summary> private static DbConnectInformation connectInfo = null; /// <summary>DBへの接続文字列を表します。</summary> private static SQLiteConnectionStringBuilder builder = null; /// <summary>コンストラクタ。</summary> /// <param name="connectInformation">DBへの接続情報を表すDbConnectInformation。</param> public SqliteAccessHelper(DbConnectInformation connectInformation) { if (SqliteAccessHelper.connectInfo != null) return; SqliteAccessHelper.connectInfo = connectInformation; var sqLitePath = this.createDbFilePath(); if (!File.Exists(sqLitePath)) throw new FileNotFoundException("データベースファイルが存在しません。", sqLitePath); SqliteAccessHelper.builder = new SQLiteConnectionStringBuilder() { DataSource = sqLitePath }; } /// <summary>データベースファイルへのパスを生成します。</summary> /// <returns>データベースファイルへのパスを表す文字列。</returns> private string createDbFilePath() { if (Regex.IsMatch(SqliteAccessHelper.connectInfo.DataSource, "^{exePath}.")) { var execPath = AssemblyUtility.GetExecutingPath(); return Regex.Replace(SqliteAccessHelper.connectInfo.DataSource, "{exePath}", execPath); } else { return SqliteAccessHelper.connectInfo.DataSource; } } } } |
src. 5 のように各 DB 固有クラス(SQLiteConnection、SQLiteTransaction 等)の生成や固有設定等を DB 固有のクラスに閉じ込めることで Oracle への接続が必要になれば OracleAccessHelper、Microsoft Access への接続が必要になれば MsAccessAccessHelper を作成 & 追加するだけで対応できるようになります。
このような構成にしておけば DBMS を変更する必要が出てきた場合でも、実際に SQL を記述した DataAccess はほとんど変更の必要は無く DBMS を変更できます。
(RDBMS 固有の SQL が書かれている場合は、アプリの DataAccess も変更が必要になります)
補足として接続する DBMS が追加・変更になった場合は、IDbAccessHelper を継承したクラスの追加に加えて src. 6 の DbAccessHelperFactory も変更する必要があります。
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 | using HalationGhost.WinApps.DatabaseAccesses.Sqlite; namespace HalationGhost.WinApps.DatabaseAccesses { /// <summary>IDbAccessHelperのファクトリを表します。</summary> public static class DbAccessHelperFactory { /// <summary>IDbAccessHelperを生成します。</summary> /// <param name="setting">DBの接続設定を表すDbConnectionSetting。</param> /// <param name="targetNumber">接続先DBの番号を表すint?。</param> /// <returns>DBへアクセスするためのIDbAccessHelper。</returns> public static IDbAccessHelper CreateHelper(DbConnectionSetting setting, int? targetNumber) { var num = targetNumber; if (!num.HasValue) num = setting.TargetNumber; var info = setting.ConnectInformations.Find(c => c.Number == num); if (info == null) return null; switch (info.DbType) { case DatabaseType.SQLite: return new SqliteAccessHelper(info); } return null; } } } |
まあ、変更が必要と言っても src. 6 のハイライト部分のみ追記するだけで済むので影響範囲は限定できると思います。
この連載で作成しているプロトタイプアプリから DB に接続するだけで考えるとかなり大袈裟な構成になってしまいましたが、ここで作成しておけば別のアプリにも流用できるので多少労力がかかったとしても無駄にはならないはずと考えています。
これでアプリから DB へ接続する準備は整ったので、実際のプロトタイプアプリからデータを読み書きする方法を紹介したいと思いますが、長くなりそうなので次回のエントリに続きます。
今回のソースコードもいつもの通り GitHub リポジトリ に上げています。
次回記事「ようこそ Dapper 至上主義の DataAccess へ【#5 WPF MVVM L@bo】」