実装方針に関する最近の考えのメモです。もしかしたら来年あたりには全く変わってしまっているかもしれないので、いまのうちに書き残しておきます。
本稿が他の人の参考になるかどうかは知りません。もし他の人に1つだけ推すとしたら、それは「クエリごとに .sql ファイルとクラスを作る」の部分です。
文脈
主に業務系アプリを対象としています。
一部の用語を DDD やクリーンアーキテクチャ (CA) から借りている部分がありますが、アーキテクチャだけが DDD ではないし、CA としては不完全だと思うので、そういうのは名乗らないことにします。
ソースコードの配置
何をどう書くか考える前に、まず どこに 書くかを考えます。だいたいこういう構成になるというものが自分の中で固まってきたので、ここに書きます。
なお、大文字・小文字や用語などは言語・フレームワークによって異なるので、雰囲気だけ書きます。例えば「ディレクトリを分ける」と書いている部分は、言語によってはモジュールやパッケージかもしれません。
さて、たいてい src 直下がトップレベルです。トップレベルには「外部システムとの連携」のためのディレクトリをシステムの種類ごとに置きます。ここでいう外部システムとは、データベース、メールサーバ、Web などです。アプリの特定の機能とは関連しない、例えば「データベースと接続する」「ウェブサーバーを起動する」といったものをおいておきます。特定の機能と密接に関連のあるものは、後述のドメインに置きます。
- data
- データベース関連の具体的なコード
- web
- Web 関連の具体的なコード
- etc.
これらと同じ階層に、作ろうとしているアプリのためのディレクトリ (名前は app やアプリの名前) を置き、内側はドメインごとにディレクトリを分けます。ドメインはざっくり言って機能のグループのようなもので、例えば「ユーザ(認証)」とか「注文」とかです。(いわゆる横割りというやつ。なぜ横なのかは知りません。)
- app
- ドメイン1
- ドメイン2, ...
- ドメインN
ドメインの中は逆にいわゆる縦割りで、書くものの意味ではなく形式で分けます。
- (ドメイン i)
- actions/
- アクションごとのクラス
- data/
- クエリやコマンドごとのクラスと .sql ファイル
- entities/
- エンティティごとのクラス
- views/
- UI の実装
- controller ファイル
- actions/
それぞれ詳細に見ていきます。
app/xxx_domain/controller
コントローラーは1個のクラスであり、このドメインの外側に公開される唯一のものです。
ドメイン内の依存関係の最上位に位置し、内部にあるすべてのものを触ることができます。MVC の C とだいたい同じですね。ファットコントローラにならないように注意して書きます。
ディレクトリ内の依存関係は controller → actions → data/views → entities という一方向になります。data/views が互いを参照できないあたりは presentation domain separation (PDS) です。
app/xxx_domain/entities
entities は機能固有の実装を書く場所です。外部のサービスやフレームワークに依存しないクラスや関数からなります。データの運搬用の型とか、データ検証の判定関数、数値計算、データの形状の変形などが代表例です。
app/xxx_domain/views
UI の実装です。UI の実装に使うライブラリやフレームワークに依存します。
- React なら renderXxx (関数コンポーネント) の詰め合わせになるでしょう。
- WinForms なら Form などが入ります。
- WPF なら Xaml と ViewModel クラスが入ります。
app/xxx_domain/data
data には、このドメインで使用する SQL のクエリやコマンドをまとめて配置します。冒頭に書いた通り、ここが一番書きたかったことで、ポイントは以下の2つです。
- SQL は .sql ファイルに書く
- SQL 文ごとにクラスを作る
ご存じの通り SQL を文字列連結で作るのはよくないです。動的な値はプリペアドステートメントの変数 (@name
とか :name
みたいなやつ) を使って埋め込みます。
動的な条件はなるべく SQL 側で解決します。例えば名前が入力されたら完全一致で絞り込み、入力されていなければ全件検索、という条件は以下のように書けます。
select users.user_id
from users
where @name is null or users.name = @name
ただ、どうしても動的 SQL に頼る場面があります。もっともよくあるのは IN 句に動的なリストを列挙するケースです。以下のように件数が固定の SQL になるケースは少ないでしょう。
where user_id in (@id1, @id2, @id3)
これは SQL の動的な書き換えによって対処します。具体的にどうやるかは場合によります。例えば C# なら StackOverflow/Dapper がこの機能を持つのでおすすめです。もし良いライブラリがなければ正規表現による置換 (IN \(@[a-z_]+\)
→ IN (?, ?, ...)
) などで頑張ることになるかもしれませんね……
そして、そういうダーティーな部分を隠すために SQL 文の利用を1個のクラスで覆っておきます。C# + Dapper ならこんな感じ。
using Dapper;
public sealed class FindUserQuery
{
// SQL ファイルをリソースファイルに登録しておく。
public string CommandText => Resources.FindUserQuery;
public sealed class Param
{
public string name { get; set; }
}
public sealed class Result
{
public long user_id { get; set; }
}
public IEnumerable<Result> Find(Param param, IDbConnection connection, IDbTransaction transaction)
{
return connection.Query<Result>(CommandText, param, transaction);
}
}
使う側は new FindUserQuery().Find(...)
とやるだけです。Dapper を使っていることや、内部で SQL の動的な書き換えが行われていることなどは、クエリの実装詳細として隠蔽します。
app/xxx_domain/actions
アクションは先日「「アクション」の概念とエラー処理や通知の場所」に書いた通り、短時間で起こる一連の処理の最上位 です。それをクラスや関数として定義したものを1つ1つファイルとして配置します。
例えばボタンを押したときにデータを更新する機能があるなら、きっと Save アクションがここに配置されるでしょう。イメージ:
internal sealed class SaveAction
{
// 状態へのアクセス
private MyState State { get; }
// UI へのアクセス
private MyView View { get; }
// DI されるサービス
private IDatabase Database { get; }
private ILogger Logger { get; }
public SaveAction(MyState state, MyView view, IDatabase database, ILogger logger)
{
State = state;
View = view;
Database = database;
Logger = logger;
}
public Execute()
{
try
{
Database.BeginTransaction((connection, transaction) =>
{
new Data.SaveCommand(State, connection, transaction).Execute();
});
View.NotifySuccess("保存しました。");
}
catch (Exception ex)
{
Logger.LogError(ex);
View.NotifyError("保存に失敗しました。");
}
}
}
トランザクションを張ったり、例外をキャッチして異常系 (エラー処理) に遷移させるといった、入れ子になるべきでないものはだいたいここに置くことになります。
内容的には entities や controller に含めてもよさそうなものですが、わざわざ別のディレクトリに分けているのは 目立たせるため です。コードを読むときのとっかかり、と言い換えてもいいかもしれません。ソースコードを調査したり変更するために「どのコードを読めばいいか」を考えるとき、単独のファイルに SaveAction.cs というファイルがあれば、本稿のアーキテクチャを知らなくても「きっとここに保存処理が書かれてるに違いない」と分かるはずです。(たぶん)
ソースコードの性質
WHERE の次に WHAT を考えます。最終的なソースコードが満たすべき条件や性質の方向性のことです。
- 動くこと
アプリがちゃんと動く、というのは当然ながら大前提です。課す条件がこれだけなら、特に設計は考えずに書きなぐればいいです。しかし実際には、以下のような性質もほしくなります。
- 解読しやすさ
コードに関する調査を短時間で行える設計が好ましいです。ある機能に該当するコードがどのあたりにあるか、あるデータがどこから来てどこへ行くのか、といった調査をしやすい設計が好ましいです。
人によってはこれを「変更しやすさ」に含めるかもしれませんが、解読しやすさは「変更」には限らない場面で重要となります。例えば「この挙動はバグでは?」「この機能をこういう使い方できる?」みたいな質問を受けたり、別の箇所を実装・修正していて「既存のコードはどう作ってたっけ?」と疑問が生じたりしたときです。
言い換えると、リポジトリをデータベースに見立てたとき、CRUD のうち CUD (Create/Update/Delete) だけでなく R (Read) も重要です。もっとも、R はたいてい CUD に先立つものなので、含意されてると考えることもできますが。
- 変更しやすさ
コードを変更するときにアプリが壊れにくい設計が好ましいです。
例えば本質的に同じコードが複数存在すると、その一部だけ変更してしまい、微妙なバージョン違いが発生するリスクがあります。再びデータベースに見立てると、これは正規化されていないデータベースへの更新異常のようなものです。
有効な対抗策は don't repeat yourself (DRY)、すなわち関数やクラスとして抽象化してそれを参照するコードで置き換えることですが、それだけではありません。
同じコードが複数存在するといっても、同じファイルの同じ場所に2つ連続で並んでいて、しかも「このコードは2つ連続で並んでいます」とコメントがついていたら、更新異常のリスクは現実的に十分少なくなります。一般化していうと、 同様のコードに印をつけておく 方法です。これは「DRY した方がいい気がするけど、本当に本質的に同じなのか、たまたま同じなだけなのか、まだ判断がつかない」とか「抽象化の方向性が思いつかない」といった躊躇の場面で有効な妥協案だと思います。
ビュー・ビューモデル実装についての細かいこと
views ディレクトリで少しだけ触れた WinForms/WPF に関しての考えも書いておきます。(React は何も考えず書けて最高!)
WinForms の MVP
以前に WinForms で MVVM をやろうと試みましたが、データバインディングの仕組みが微妙だったのでやめました。model-view-presenter (MVP) が無難な気がします。
WinForms で MVP をやるとき、おすすめなのは、Form に配置するコントロールのアクセス指定子 (modifier) を internal にしておいて、別のクラスに Form のインスタンスを持たせる構成です。言い換えると、Form の派生クラスの実装を別のクラスに書くということです。
Form には Windows を表示するという責務に専念してもらって、その上に配置しているテキストボックスとかの操作は取り除いた方が見通しが良い気がします。それに、Form はメンバが多いので、その中で作業すると入力補完がだるいです。
internal sealed class MyView
{
private MyForm Form { get; }
public string SetText(string text)
{
// MyForm に配置してるテキストボックスのテキストを変更する。
// MyTextBox フィールドのアクセス指定子を internal にしておけば、このように別のクラスから触れる。
Form.MyTextBox.Text = text;
}
}
WPF の MVVM
WPF のビューモデルの実装は本当に悩ましい のですが、私が試した中で一番良かったのは、状態をイミュータブルなデータ構造として定義して、その差分を Rx.NET + ReactiveProperty に注ぎ込む手法です。
TODO リストを例にとります。次のようにモデルはイミュータブルにします。(IReadOnlyList
は厳密にはイミュータブルではありませんが、普通に使えばイミュータブルなのでよしとします。)
// entities/TodoList.cs
internal sealed class TodoItem
{
public long ItemId { get; }
public string Text { get; }
public bool IsDone { get; }
}
internal sealed class TodoList
{
public IReadOnlyList<TodoItem> Items { get; }
}
モデルの差分は直和で定義してもいいですし、めんどくさければ関数でもいいと思います。
internal delegate TodoList TodoListDelta(TodoList model);
MVVM なので似たようなビューモデルを書きます。ReactiveProperty や ObservableCollection などを使った、WPF 用の実装です。
internal sealed class TodoItemViewModel
{
public long ItemId { get; }
public ReactivePropertySlim<string> Text { get; }
public ReactivePropertySlim<bool> IsDone { get; }
}
internal sealed class TodoListViewModel
{
public ObservableCollection<TodoItemViewModel> Items { get; }
}
状態を1個の ReactiveProperty に入れておき、これを購読するだけでモデルの変更をすべて検出できるようにします。
internal sealed class MainPageViewModel
{
public ReactivePropertySlim<TodoListModel> Store { get; }
}
モデルの変更を購読して、ViewModel を更新します。Rx を使うと Store.Select().ToReadOnlyProperty()
とシンプルに書けて嬉しいです。
注意点はコレクションである Items の更新が特殊なことです。Rx そのままだとできない気がするので、2つのリストを比較して差分アルゴリズムにより挿入・削除のリストを構築し、実行する、という実装にします。すべての要素にユニークなキーがついているリストの差分をとるのはそれほど難しくないです。(差分を関数ではなく直和で表現しておけばこれは回避できます。)
注意点として、更新操作を実装する際に、変更点の生じない操作に対して同一のモデルを返すようにすることです。そうしないと状態の変更時に起こしたイベントがさらに状態を更新したとき、ループがいつまで経っても終わりません。
最後に
このような胡乱な内容の記事を書くことに異論ある人もいるかもしれません。どんなに間違ったことでも頭だけで考えていると世紀の大発明のような気がしてしまうので、ときどき文字に起こして人に見られる状況下におくのは重要だと考えています。本稿の記事も、書くにつれて自信が減っていくのですが、まあしばらくこのアーキテクチャをやっていて問題は起こってないので大丈夫でしょう。