今回はドメイン開発入門について解説していきたいと思います。
まずはドメイン駆動開発とはどういったものかを述べて、そこから一般的なアーキテクチャとどう異なるかについて説明していきたいと思います。
1. ドメイン駆動設計とは
ドメイン駆動設計とは、一言で言うと、ソフトウェアの設計手法のことです。
オブジェクト指向におけるアーキテクチャにおいて、ドメイン層に重点を置いて開発を行い、
仕様が確定したり改修を行っていく度にドメインモデルを反復的に深化させていく手法になります。
ここでのドメイン層とはアプリケーションが対象とする業務領域のことです。
2. 一般のアーキテクチャとどう異なるか?
まずは一般的なアプリケーション(トランザクションスクリプト)のアーキテクチャについておさらいしてみましょう。
・プレゼンテーション層
利用ユーザーに対するインターフェースの提供する。
・ドメイン層(ビジネスレイアとも)
プレゼンテーション層から渡されたデータに対して業務処理を行う。
・データレイア層
データソースとのデータ連携や接続、管理、制御を行う。
という三層アプリケーションが主流となっています。
(アプリケーション層を入れて4層とする場合もありますね。)
具体的な処理の流れとしては、
■トランザクションスクリプト
アクション ⇒ コントロール ⇒ ドメイン層でデータ(Entity)の処理を呼び出しCRUD処理を実装する。
業務ロジックを一つのメソッドにまとめてデータベースを直接(もしくはラッパーを用いて)呼び出し処理を
行わせることです。ほとんどの業務処理がAction周りで完結します。
個別のメソッドに記述してプレゼンテーション層やデータソース層への処理とは異なる部分で実装します。
外部のロジックを呼び出さないことで修正やテストを簡単に行うという設計思想ですね。
※イメージ
対して、
■ドメインモデル
アクション ⇒ コントロール
⇒ ドメイン層で各機能の実装を行い、適宣業務に関係ある処理を共通語の形で呼び出せるようにし、
⇒ リポジトリー層でデータ(Entity)の処理を呼び出しCRUD処理を実装する。
というのが一般的でしょう。
※イメージ
もう少し具体的な例を挙げてみると、
商品の基本情報、在庫情報、出荷情報、入荷情報から帳票出力を実装する際に、
- トランザクションスクリプト ・・・
例えばアクションからコントロールクラスを経て商品の基本情報を全て取得し、
その商品に紐づく入荷情報と出荷情報を取得し、在庫情報を取得して現在のステータスを判定します。
出荷する際は引き算で0になるかそれ以上かで出荷の可否を判定するような処理となります。
- ドメインモデル ・・・
現在の出荷可否情報を一発で取得できる機能を実装します。
機能の名前は見ただけで判別できるような分かり易い名前にします。
さらにドメイン~リポジトリーに別れることでデータの永続性からGCされるまで
必要なデータは存在しているため、
余計な検索処理をせずに必要なデータをリアルタイムで取得することができます。
(というよりもそのように実装します)
ここで注意しておきたい点は、トランザクションスクリプトでもドメインモデルでも実現したい機能は実現ができます。
ですが、機能改修などを想像した際やパフォーマンス改善の際にトランザクションスクリプトは大きな壁に
ぶち当たることでしょう。
反対に、機能改修によりソフトウェアを育てていくという考えがなければドメイン駆動開発を選ぶ必要はないのです。
その理由は、単純に時間と手間がかかるからです。
以上から、言い換えると、
『ドメイン駆動設計とは、ドメイン層を中心に考えた設計手法』と表現することもできます。
上記の例からも分かる通り、ドメイン駆動設計とは
何らかの新しい開発プロセスを提唱するものではないことにご注意ください。
※あくまで、『アプリケーションとして、業務処理ベースでどのように設計するか?』に基づいた設計思想です。
最初にも触れましたが、アジャイル的な開発手法を踏むことで、
ドメインモデルをより優れた設計にしていくものになります。
そのためには、ドメインの専門家(ドメイン層の知識に長けた人)がドメインモデルをより理解しやすい言葉
(ユビキタス言語とも)にすること、その言葉をプログラミングと対比させることが不可欠となります。
RPGの例で言うと、勇者にはどんな特徴があるか、
どんなことができて、どんなことができないかがハッキリするわけです。
ここで、ドメイン駆動開発の核となるドメイン部分のキーワードを上げていきます。
- Entity
ドメインの状態を保持するオブジェクトです。
ドメインオブジェクトとして必要な属性を定義します。
複雑な継承を回避するためにfinal class として定義されるます。
(データのライフサイクルによって中身が変化する。)
状態と振る舞いを持っている。
- valueObject
equalsとhashCodeによってインタフェイスで定義します。
Entityとは逆にライフサイクルにおいて不変のものです。
Entityと同様に状態と振る舞いを持っています。
(ここがトランザクションスクリプトとの大きな違いでしょう)
また、Entityのプロパティに使用されます。
保持しているプロパティの値が同じなら同じオブジェクトと判断します。
(アドレスが異なっていても。。。)
- Repository
オブジェクトの永続化と永続化されたオブジェクトを検索する機能を提供するオブジェクトです。
具体的にはEntityやValueObjectをDBやファイルに保管する際に使用する機能のことです。
(save、delete、updateなど)
ドメインオブジェクトの永続化(DBやファイルなどへの登録)による
問い合わせ処理はドメインの本質ではないため、
それらの処理はRepositoryに委譲します。
後述のサンプルコードのように、Daoのような実装になります。
- Service
振る舞いに特化した機能です。
EntityやValueObjectに分類できない振る舞いを実装します。
これはドメイン駆動設計の単一債務という概念のもと上記のような捉え方をしています。
ただし、何でもServiceに委譲すると、EntityやValueObjectはただの入れ物になってしまいます。
ソフトウェアの仕様上、EntityやValueObjectで行うべきか、
Serviceで行うべきかを切り分けて実装する必要があります。
- Factory
オブジェクトの生成処理をカプセル化するようにしたものです。
オブジェクトを複雑に生成してしまうと、設計上でも分かりにくいものとなってしまいます。
逆にクライアント側へ委譲すると、クライアント側での設計の複雑化につながってしまいます。
それでは、次章ではドメイン部分に着目して上記で挙げた機能を実際にコーディングしてリファクタリングしていく流れを示して参ります。
フレームワークに概要に触れて行きたいと思います。
3.ドメイン駆動の始め方
一般的にドメイン駆動設計でない場合どうしても「データ」と「処理」を分けて考えてしまいがちです。
今回はドメインモデルのもっとも基本的な考え方、データとそれに関係する処理を近くに記述する
といったところにフォーカスし、サンプルをもとにドメイン駆動設計と、そうでない場合を比較しみていきたいと思います。
なお今回はテーマとして、
『ドラゴンクエストⅢのキャラクターがダーマ神殿で賢者になれるか?判別し可能なら賢者に転職する』といったロジックを題材にして考えてみたいと思います。
まずはじめにドメイン設計を意識していないコードの例を示します。
■キャラクターを表すクラス
public class Character {
/** Id*/
private int characterId;
/** 名前*/
private String name;
/** 職業*/
private Syokugyo syokugyo;
/** レベル*/
private int level;
/** 持ち物List*/
List<Item> itemList;
// 〜以下アクセッサ省略
■職業の種類を表現するenum
public enum Syokugyo {
YUSYA("0","勇者"),
KENZYA("1","賢者"),
ASOBININN("2","遊び人");
/** …… 以下職業省略 */
private String code;
private String name;
// コンストラクタ
private Syokugyo(String code, String name) {
this.code = code;
this.name = name;
}
public String getCode(){
return code;
}
public String getName() {
return name;
}
/**
* 遊び人であるか判別する
*/
public boolean isAsobininn() {
if(code.equals("2")) {
return true;
}
return false;
}
/**
* 賢者であるか判別する
*/
public boolean isKenzya() {
if(code.equals("1")) {
return true;
}
return false;
}
/**
* 勇者でも賢者でも無いことを判定する
*/
public boolean isNotYusyaOrKenzya() {
if(!code.equals("0") && !code.equals("1")) {
return true;
}
return false;
}
}
■キャラクターがもつアイテムクラス
public class Item {
/** アイテムID*/
private int itemId;
/** アイテム名*/
private String itemName;
// 以下アクセッサ省略
}
■データ・アクセス部分を担当するDaoのIF
public interface CharacterDao {
// キャラクター情報を取得する
Character searchCharacter(int characterId);
// キャラクターが保持する道具の一覧を取得する
List<Item> getItemList(int characterId);
// キャラクターが保持する道具を削除する
int removeItem(int characterId, int itemId);
}
■DaoImpl
コード省略
実際の処理部分
//********************************
// 賢者になれるか判断するロジックを記述
//********************************
CharacterDao dao = new CharacterDaoImpl();
// キャラクターIDをkeyにキャラクター情報取得
Character characterInfo = dao.searchCharacter(characterId);
// キャラクターIDをkeyにキャラクターの持ちもの一覧を取得
List<Item> items = dao.getItemList(characterId);
// ★=======以下ベタにロジックを記述しているところ=============
// レベル20以上
if(characterInfo.getLevel() >= 20) {
// 勇者 or 賢者でない
if(characterInfo.getSyokugyo().isNotYusyaOrKenzya()) {
for(Item item:items) {
// さとりの書をもつ
if(item.getItemId() == SATORINOSYO_ITEM_NO) {
// 賢者に転職
changeTokenzya(characterInfo, dao);
}
}
}
// 遊び人の場合
if(characterInfo.getSyokugyo().isAsobininn()) {
// 賢者に転職
changeTokenzya(characterInfo, dao);
}
}
}
/** 賢者に転職*/
private void changeTokenzya(Character characterInfo, CharacterDao dao) {
// レベル初期化
characterInfo.setLevel(1);
// 遊び人じゃなければ持ち物からさとりの書はなくなる
if(!characterInfo.getSyokugyo().isAsobininn()) {
dao.removeItem(characterInfo.getCharacterId(), SATORINOSYO_ITEM_NO);
}
// 以下省略
}
上記のコードでは実際に賢者になれるかを判定するロジックと賢者へ転職するロジックがデータと切り離されています。
頻繁にCharacterやItemのアクセッサが呼ばれているところに注目してください。データとそれを使用して判断するロジックが完全に分離されています。またこのロジックをどこに書くかは、プログラマによってまちまちになってしまうことがあります。
(ドメイン層に書かれたり、アプリケーション層に書かれたり)
それではドメイン駆動設計を意識した形にコードをリファクタリングしていきます。
大きく変更されるのはCharacterクラスです。
いままで切り離されデータに関するロジックが追加されているところに注目してみてください。
■変更されたCharacter(ドメインEntity)
/** Id*/
private int characterId;
/** 名前*/
private String name;
/** 職業*/
private Syokugyo syokugyo;
/** レベル*/
private int level;
/** 持ち物Map itemIdをkeyするMap*/
Map<String,Item> motimono;
/** さとりの書のitemNo*/
private static final String SATORINOSYO_ITEM_NO = "99";
// コンストラクタ
public Character(int CharacterId) {
this.characterId = CharacterId;
}
// ……以下アクセサ等省略
/** キャラクター用リポジトリ*/
public static Character getCharacter(int characterId) {
CharacterRepository characterRepository = new CharacterRepositoryImpl();
return characterRepository.searchChracter(characterId);
}
/** 賢者になれるかを判定する*/
public boolean isToChangeKenzya() {
// レベルが20以上である
if(level >= 20) {
// 勇者者ではない かつ さとりの書をもっている
if(!syokugyo.isNotYusyaOrKenzya() && hasSatorinosyo()) {
return true;
}
// 遊び人であるか
if(syokugyo.isAsobininn()) {
return true;
}
}
return false;
}
/** 持ち物の中に『さとりの書』が存在するかを判定する*/
private boolean hasSatorinosyo() {
if(this.motimono.containsKey(SATORINOSYO_ITEM_NO)) {
return true;
} else {
return false;
}
}
/**転職する*/
public void changeToTargetSyokugyo(Syokugyo targetSyokugyo) {
// 賢者への転職の場合
if(targetSyokugyo.isKenzya()) {
this.changeTokenzya();
}
// 魔法使いへの転職
// ……以下省力
}
/**賢者に転職する*/
private void changeTokenzya() {
// レベル1にリセット
this.level = 1;
// 遊び人じゃなければ持ち物から悟りの書がなくなる
if(!syokugyo.isAsobininn()) {
this.motimono.remove(SATORINOSYO_ITEM_NO);
}
// ……以下省略
}
■キャラクタードメインに対応するリポジトリクラス
※ここではDaoクラスの代わりみたいな認識で捉えてください。
public interface CharacterRepository {
Character searchCharacter(int characterId);
}
■CharacterRepositoryImpl
コード省略
実際の処理部分
Character character = Character.getCharacter(characterId);
// 賢者に転職できるか判定
if(character.isToChangeKenzya()) {
// 転職
character.changeToTargetSyokugyo(Syokugyo.KENZYA);
}
}
Character層に大半のロジックを移動したため、呼び出し部分のコードがかなりスッキリしたのがわかると思います。
4.ドメイン駆動をやりますか?
ドメイン駆動開発をやりきるとドメイン層にビジネスロジックが集中し、変更に強いシステムが作成できることは確かです。ただしドメイン駆動開発やはり大変な部分が沢山あります。
valueObjectは不変に作成する、不変クラスを作成するために防御的コピーを多用する、複雑なオブジェクトの作成にBuilderやファクトリーをつくるなどドメイン駆動設計を実現するために手間のかかる作業が沢山あります。
またドメインモデルは一度つくれば終わりというものではありません。とりあえずを組んだ後、リファクタリングを繰り返し、ロジックの移動や集約の考えなおし、大掛かりな修正が発生することがザラにあります。テストコードがあればもちらんそれらのメンテンナンスにも工数をさかれます。プロジェクトのメンバーこれらに耐える強い精神力が必要になります。途中で投げ出すと労多くして益少なしといった最悪のパターンになるリスクもあります。
一度やりはじめたら最後までやり切る強い覚悟これがなにより必要となります。