コードで学ぶドメイン駆動設計入門 〜エンティティとバリューオブジェクト編〜 - じゅんいち☆かとうの技術日誌
コードで学ぶドメイン駆動設計入門 〜振る舞いとサービス編〜 - じゅんいち☆かとうの技術日誌
コードで学ぶドメイン駆動設計入門 〜ファクトリ編〜 - じゅんいち☆かとうの技術日誌
引き続き連投エントリ。私も来年で39歳になります。そして息子が7歳。いいおやじですが、脳は衰えないと言われています。鍛えれば鍛えたほど進化できると信じます。
ということで、リポジトリ編に入ります。
リポジトリ
リポジトリは、ライフサイクルの途中から最後にフォーカスし、オブジェクトの永続化と永続化されたそのオブジェクトを検索する手段を提供するオブジェクトです。このように説明すると、DAOに近い印象を持つかもしれませんが、DAOはRDBMSやSQLなどのインフラストラクチャ層の関心事を含んでいるので、ここでは一旦忘れます。
ファクトリで生成されたエンティティやバリューオブジェクトは、DBやファイルなどに一時的に保管されることが多いと思います。そのような時にリポジトリを使います。
たとえば、アドレス帳などに記載する住所をAddressのエンティティとして考えた場合に、そのAddressの永続化を担当するAddressRepositoryがあるものとします。そのAddressオブジェクトを一旦ファイルに保存するようなコードは以下です。
ちょっと蛇足ですが、ここで、「あれ、バリューオブジェクトに対応するリポジトリはないのか?」と思ったかもしれませんが、エンティティがバリューオブジェクトの参照を保持するので不要なのです。そもそも、バリューオブジェクトは識別子を持たないため、リポジトリに格納したところで、バリューオブジェクトを識別=見分けることができないのです。属性が完全に一致するかで検索すればいいのでは?と思った場合は要注意です。先のエントリでも述べたように属性は流転していくので、たとえ属性で完全に一致してもそれが本当に目的のオブジェクトかどうか知る術がないのです。この原則はリポジトリだけではなく、コレクションにバリューオブジェクトを格納した場合も検索できないというデメリットはあるので注意したところです。
話を元に戻すと、アドレスをファイルに保存した後はVMからGCされても、ファイルの中にエンティティは存在しています。メモリからファイルに移動した形です。これはドメインモデルとしての、物理的な構造は変化しただけで論理的な構造は変化していません。このようにして、エンティティは、VMのライフサイクルを越える独自のライフサイクルを扱っているわけです。
以下がアドレスをリポジトリに保存しているコードです。
Address address = addressFactory.newAddress(1L, "〇〇会社","東京都中央区銀座1-1-1"); addressRepositoryInFile.store(address); // エンティティを永続化する。すでにファイル上にaddressが存在すれば更新、なければ新規に追加します。 // この後、addressがGCされてもファイル上にaddressは存在する
また、GCされた後にもう一度保存したアドレス(address)を検索したい場合は、以下のようにエンティティの識別子で呼び戻せばよいのです。
Address address = addressRepositoryInFile.findByIdentity(1L); // この後はVM上にGCされるまで存在する。
リポジトリを使ってエンティティの参照を取得した後GCされても、リポジトリ上にエンティティが存在しているならば、まだライフサイクルは終了していません。逆に、エンティティのライフサイクルが終了する、つまり寿命を全うするとは、どういう時でしょうか。
addressRepositoryInFile.delete(address); address = null;
リポジトリから削除され、VM上からもGCされた時です。これでエンティティのライフサイクルは終了です。
しかし、エンティティの識別子については、注意が必要です。上記の例の、識別子1Lのエンティティが寿命を全うしたので、同じ1Lを使って違うエンティティを作ることは可能でしょうか。可能なのですが、それは本当にしてもよいことでしょうか?
先のエントリでも言及した通り、「夏目漱石は亡くなっても、夏目漱石というアイデンティティ」です。1Lは永久欠番にしないと身元(アイデンティティ)が保証できません。永久欠番にしないで再利用してしまうと、時代と共に夏目漱石というアイデンティティは異なるものが存在する(時間軸に対して同一の識別子で違うエンティティの実体が存在した)ということになってしまうのです。
このような事態を回避するならば、識別子の空間を十分に広い空間*1を使い、再利用を禁止するのが一番有効な手立てです。*2
実際の業務システムではDXOのコストが問題に
実際の業務システムでは、RDBMSを扱うリポジトリを実装して同様のことを実現すればよいでしょう。その場合は以下のエントリのようにリポジトリ内部からインフラストラクチャ層のDAOで、ドメイン層のエンティティ、バリューオブジェクトと、インフラストラクチャ層におけるRDBMSのテーブルを表現したエンティティの相互変換がどうしても発生します。
ウェブアプリケーションの構造について - じゅんいち☆かとうの技術日誌
I/Oの流れは、このようなイメージになります。異なるドメイン同士のデータを変換するオブジェクトをDXO(Domain eXchange Object)と呼ばれます。
[Domain Entity, VO] <--DXO--> [Domain Repository] <--DXO--> [RDBMS Dao, Entity]
このような手間の問題があるため、DDDで一番大変なのはリポジトリを実装することではないかと最近思っています。面倒なところなのですが、ここをきちんとやっておかないと、レイヤーが曖昧になりカオスへの道にどんどん入っていく原因を作ってしまいます。今後の改善策としては、DSL(Scalaなども視野に含めた視点でのDSL)で解決したり、コード生成でカバーしたりできるのではないかと考えています。*3
schema-generatorのリポジトリ
それでは、schema-generatorのリポジトリを紹介します。
DataSourceRepositoryInProperties(DataSourceのためのリポジトリ)では、設定ファイル上にあるデータソースを識別子を使って読み込み、DataSourceエンティティで返す役割を持っています。ちょっと長いですがw
/** * {@link DataSource}のためのリポジトリの実装クラス。 * <p> * エンティティである{@link DataSource}の永続化を担うリポジトリ。 * この実装では、プロパティファイルから{@link DataSource}を読み込みます。 * </p> */ public class DataSourceRepositoryInProperties extends AbstractRepositoryInProperties implements DataSourceRepository { private final String fileName; /** * インスタンスを生成する。 * * @param fileName * プロパティファイル名 */ public DataSourceRepositoryInProperties(String fileName) { Validate.notNull(fileName); this.fileName = fileName; } /** * {@inheritDoc} * * @throws FileNotFoundRuntimeException ファイルが見つからない場合 * @throws IORuntimeException * プロパティファイルを読み込み時にエラーが発生した場合、 * もしくはストリームをクローズできなかった場合。 */ @Override public Collection<DataSource> findAll() { Properties properties = loadProperties(fileName); Map<String, DataSource> dataSources = parseProperties(properties); return dataSources.values(); } @Override public DataSource findById(String identity) { Validate.notNull(identity); Properties properties = loadProperties(fileName); Map<String, DataSource> dataSources = parseProperties(properties); DataSource dataSource = dataSources.get(identity); return dataSource; } /** * {@link DataSource}を管理する{@link Map}から指定した識別子の{@link DataSource}を取得する。 * * <p>{@link DataSource}を管理する{@link Map}に指定した識別子の{@link DataSource}がなければ作成して返す。</p> * * @param dataSourceMap {@link DataSource}を管理する{@link Map} * @param identity 識別子 * @return {@link DataSource} */ private DataSource getDataSoruceFromMap( Map<String, DataSource> dataSourceMap, String identity) { DataSource dataSource = null; if (dataSourceMap.containsKey(identity) == false) { dataSource = new DataSource(identity); dataSourceMap.put(identity, dataSource); } else { dataSource = dataSourceMap.get(identity); } return dataSource; } /** * {@link Properties}を解析し、識別子をキーにデータソース情報を値に持つ{@link Map}に格納する。 * * @param properties {@link Properties} * @return 識別子をキーにデータソース情報を値に持つ{@link Map} */ private Map<String, DataSource> parseProperties(Properties properties) { Validate.notNull(properties); Map<String, DataSource> dataSourceMap = Maps.newHashMap(); for (Entry<Object, Object> propertiesEntry : properties.entrySet()) { String key = (String) propertiesEntry.getKey(); if (key.startsWith("dataSources.") == false) { continue; } String value = (String) propertiesEntry.getValue(); String[] split = key.split("\\."); String identity = split[1]; DataSource dataSource = getDataSoruceFromMap(dataSourceMap, identity); String propertyName = split[2]; setProperty(dataSource, propertyName, value); } return dataSourceMap; } /** * {@link DataSource}のプロパティに値を設定する。 * * @param dataSource {@link DataSource} * @param propertyName プロパティ名 * @param value 値 */ private void setProperty(DataSource dataSource, String propertyName, String value) { if (propertyName.equals("driverClassName")) { dataSource.setDriverClassName(value); } else if (propertyName.equals("url")) { dataSource.setUrl(value); } else if (propertyName.equals("userName")) { dataSource.setUserName(value); } else if (propertyName.equals("password")) { dataSource.setPassword(value); } } @Override public void store(DataSource dataSource) { // 読み込みのみなので実装しない throw new UnsupportedOperationException("store"); } }
リポジトリは永続化を担当するので、エンティティを保存する機能とエンティティを検索する機能がメインなのですが、schema-generatorでは、設定ファイルからエンティティを読み込むだけなので、検索する機能のみを実装しています。
ここで注目したいのは、この二つのメソッドです。シグニチャに登場するのはドメイン層のエンティティです。インフラストラクチャ層のエンティティやテーブルクラスではありません。「DAOとは違う」とはこういうことです。仮に、リポジトリのメソッドのシグニチャに、インフラストラクチャ層のエンティティやテーブルクラスを利用した場合(つまりレイヤーを混同した場合)は、DBMSやSQLの言葉でドメイン層が汚染されることを意味します。そうなるとドメイン層にドメイン層以外の概念が存在することになり、コードを検討したり不具合の原因を調査することが困難になるでしょう。DAOはリポジトリの中だけに留めるようにするとよいでしょう。
public Collection<DataSource> findAll() public DataSource findById(String identity)
汎用的なリポジトリのインターフェイス
今度は、schema-generatorの話題から離れて、現在考えているリポジトリのインターフェイスを以下に紹介します。findByIdがresolve、findAllがasEntitiesListに対応します。*4
public interface Repository<T extends Entity<T>> { /** * 識別子に該当するエンティティをリポジトリから取得する。 * * @param identifier 識別子 * @return エンティティ * @throws IllegalArgumentException * @throws EntityNotFoundRuntimeException エンティティが見つからなかった場合 * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ T resolve(EntityIdentifier<T> identifier); /** * このリポジトリに格納されているすべてのエンティティをListで取得する。 * * @return すべてのエンティティのList * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ List<T> asEntitiesList(); /** * このリポジトリに格納されているすべてのエンティティをSetで取得する。 * * @return すべてのエンティティのSet * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ Set<T> asEntitiesSet(); /** * 指定した識別子のエンティティが存在するかを返す。 * * @param identifier 識別子 * @return 存在する場合はtrue * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ boolean contains(EntityIdentifier<T> identifier); /** * 指定したのエンティティが存在するかを返す。 * * @param entity エンティティ * @return 存在する場合はtrue * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ boolean contains(T entity); /** * エンティティを保存する。 * * @param entity 保存する対象のエンティティ * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ void store(T entity); /** * 指定した識別子のエンティティを削除する。 * * @param identifier 識別子 * @throws EntityNotFoundRuntimeException 指定された識別子を持つエンティティが見つからなかった場合 * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ void delete(EntityIdentifier<T> identifier); /** * 指定したエンティティを削除する。 * * @param entity エンティティ * @throws EntityNotFoundRuntimeException 指定された識別子を持つエンティティが見つからなかった場合 * @throws RepositoryRuntimeException リポジトリにアクセスできない場合 */ void delete(T entity); }
このインターフェイスを実装した実装クラスとして、オンメモリリポジトリを紹介します。
エンティティの識別子をキーに、エンティティを値に取るHashMapを内部で持っている単純なリポジトリです。
リポジトリは基本的に同一インスタンスで状態がどんどん変化していく、可変オブジェクトです。そして、エンティティ自体も可変オブジェクトです。先のエントリでも述べたように、可変オブジェクトは共有した際にあずかり知らぬところで意図しない更新が問題となります。その際の解決方法のひとつとしてcloneを実装することも紹介しました。
このオンメモリリポジトリでも、同様に外部から取り入れるエンティティはcloneしてから取り込みます。そして、外部に返すエンティティもcloneしたものを返します。また、オンメモリリポジトリは実体がメモリであるため、cloneすることができます。*5そうすることで、可変オブジェクトも不変条件を維持することができます。
/** * オンメモリ実装のリポジトリ。 * * @param <T> エンティティの型 */ public class OnMemoryRepository<T extends Entity<T>> implements Repository<T> { private final Map<EntityIdentifier<T>, T> entities = new HashMap<EntityIdentifier<T>, T>(); @SuppressWarnings("unchecked") public OnMemoryRepository<T> clone() { try { return (OnMemoryRepository<T>) super.clone(); } catch (CloneNotSupportedException e) { throw new Error("clone not supported"); } } @Override public T resolve(EntityIdentifier<T> identifier) { Validate.notNull(identifier); return entities.get(identifier).clone(); } @Override public List<T> asEntitiesList() { List<T> result = new ArrayList<T>(entities.size()); for (T entity : entities.values()) { result.add(entity.clone()); } return result; } @Override public Set<T> asEntitiesSet() { Set<T> result = new HashSet<T>(entities.size()); for (T entity : entities.values()) { result.add(entity.clone()); } return result; } @Override public boolean contains(EntityIdentifier<T> identifier) { Validate.notNull(identifier); return entities.containsKey(identifier); } @Override public boolean contains(T entity) { Validate.notNull(entity); return contains(entity.getIdentifier()); } @Override public void store(T entity) { Validate.notNull(entity); entities.put(entity.getIdentifier(), entity.clone()); } @Override public void delete(EntityIdentifier<T> identifier) { Validate.notNull(identifier); entities.remove(identifier); } @Override public void delete(T entity) { Validate.notNull(entity); delete(entity.getIdentifier()); } }
次はアグリゲートにいきますが、もしかしたらScalaが入るかもw
追記:
オブジェクト間の依存関係を爆発させないための工夫としては、エンティティの識別子だけを保持するやり方があります。
// 従業員にタスクを割り当てるマネージャ。 public class Mangaer extends AbstractEntity<Manager> { // private Collection<Employee> employees; とせずにエンティティの識別子だけを保持する private final Collection<EntityIdentifier<Employee>> employeeIds; private final Repository<Employee> employeeRepoistory; // コンストラクタにリポジトリとエンティティのIdの集合を渡す public Mangaer(Repository<Employee> employeeRepoistory, Collection<EntityIdentifier<Employee>> employeeIds){ this.employeeRepoistory = employeeRepoistory; this.employeeIds = new ArrayList<EntityIdentifier<Employee>>(employeeIds); } // 全員にタスクを割り当てる public void dispatch(Task task){ for(EntityIdentifier<Employee> employeeId : employeeIds){ final Employee employee = employeeRepoistory.resolve(employeeId); employee.processTask(task); // なんか仕事させる } } }
あわせて読みたい
コードで学ぶドメイン駆動設計入門 〜エンティティとバリューオブジェクト編〜 - じゅんいち☆かとうの技術日誌
コードで学ぶドメイン駆動設計入門 〜振る舞いとサービス編〜 - じゅんいち☆かとうの技術日誌
コードで学ぶドメイン駆動設計入門 〜ファクトリ編〜 - じゅんいち☆かとうの技術日誌
コードで学ぶドメイン駆動設計入門 〜アグリゲート編〜 - じゅんいち☆かとうの技術日誌
*1:128ビットの値表現が可能なUUIDであれば十分だと思います。2の128乗=340澗(かん)個のアドレス表現が可能だからです。澗(かん)と言われてもよくわからないと思うのですが、これはIPv6と同じアドレス空間です。
*2:java.util.UUID#randomUUID()は、論理的にIDの衝突は起こり得るのですが、ほぼゼロに近い確率なので現実的にIDとして利用可能です。DDDの原書にもIDはファクトリで生成するとよいと触れられていますが、AbstractEntityFactory#createメソッドなどで識別子の自動付与を行うとよいでしょう。
*3:Jiemamyプロジェクトには、[http://svn.jiemamy.org/products/leto/object-manipulator/trunk/:title=object-manipulator]というDXO用のライブラリがあります。
*4:こちらもJiemamyプロジェクトのdddbaseから影響を受けています。
*5:ファイルやDBではcloneすることが現実的ではありませんので実装しません