SQL anti-patternの3記事目になります。
今回テーブル設計の話ではなく、アプリケーションの実装パターンに近い話です。
正直「SQL Antipatterns: Avoiding the Pitfalls of Database Programming」を読んだ時、「それSQL antipatternじゃなくない?」とも思いましたが、とても役に立つ話でもあります。
全く根拠がない話ですが、多分active record patternというdesign patternは業界で超主流のdesign pattern。たとえその名前が知らなくても、普段の開発には使っているでしょう。
wikipediaは以下のような記述があります。
Active Record(アクティブ・レコード)とは、プログラミングにおいて、企業アプリケーションで頻繁に認められるデザインパターンである。
ついでにwikipediaの説明文も一緒に引用しましょう。
Active Recordはデータベースからデータを読み出すためのアプローチである。データベーステーブルあるいはビューの1行が1つのクラスにラップされ、オブジェクトのインスタンスがそのデータベースの1つの行に結合される。このクラスはデータベースアクセスのカプセル化も行う[1]。オブジェクトの生成後は、保存メソッドで新しい行がデータベースに追加される。 オブジェクトが更新されると、データベースの対応する行もまた更新される。ラッパークラスはテーブルあるいはビューの各カラムに対するアクセサメソッドを実装するが、それ以外の振る舞い(MVCのモデルが担当すべきロジック)も記述することができる[1]。
テーブルとクラスが一対一で結びつくことから非常にシンプルな構造となることが特徴で、後述するRuby on Railsでは、データベース定義からほぼ自動でCRUD操作を備えたクラスを作り出すことを実現している。一方でその性質上、複雑な設計のデータベースとは相性が悪い。
Active Record Pattern
modelを簡素なものにしたいです。
MVCはみんなが熟知している言葉でしょう。そのMVCのMつまりModelがどういうものにすべきかはいくつかの流派があると思います。
Modelをただのデータと解釈する派もあるでしょう。そこからactive record patternが派生猿かもしれません。
アカウント管理機能という簡単な実装例で説明しましょう。
仕様を説明します。
- emailとpasswordでアカウントを作成する。
- 古いpasswordでチェックして、あったら新しいパスワードに変更する
例のため、仕様を究極的に簡単なやつにします。
では、active record patternを用いて、どういう実装になりますか。
まず名前は色々なパターンがあるんですが、entityクラスという呼び方しましょう。用はデータベースのテーブルの1レコードに対応するクラスのことです。
そのアカウトを表すentityクラスは以下のようになります。
@Setter
@Getter
public class Account {
private Long accountId;
private String email;
private String password;
}
ちなみにaccountsのテーブルschemaは以下のようになります。
account_id | password | |
---|---|---|
bigint | varchar(32) | varchar(16) |
次はそのAccountに対してCRUD操作を行うDAOクラスが必要でしょう。
public interface IAccountDao {
void insert(Account account);
void update(Account account);
Account findById(Long accountId);
Account findByEmail(String email);
}
では、実際の業務を実装するLogic/Serviceクラスが登場します。古くから皆様に好かれているクラスですね。
public class AccountService {
@Autowired
private IAccountDao accountDao;
public void changePassword(String email, @NonNull String oldPassword, @NonNull String newPassword){
Account account = accountDao.findByEmail(email);
if(oldPassword.equals("")){
throw new IllegalArgumentException("password cannot be empty");
}
if(!account.getPassword().equals(encryptPassword(oldPassword))){
throw new IllegalArgumentException("old password is wrong");
}
account.setPassword(encryptPassword(newPassword));
accountDao.update(account);
}
public void createNewAccount(String email, @NonNull String password){
Account account = new Account();
account.setPassword(password);
account.setEmail(email);
accountDao.insert(account);
}
private String encryptPassword(String password){
// TODO not implemented
return password;
}
}
このパターンはどこがわるい
直感的にそれはobject指向と言えません
entityクラスはC言語のstructみたいで、DIするsingletonであるService/Logicクラスたちはメソッドを持ってますが、実際にC言語の関数の使い方みたいです。
ドメインモデル貧血症になりやすいです。(この節が非常に長いです)
上に書いた例のように、Accountみたいなentityクラスは、振る舞いを持たず、ただのデータの箱になっています。データに対するCRUDで業務を表現します(本当に手続き指向の感じ)。となると、業務を表現する責務はservice/Logicクラスに持たせることになります。
プロジェクトの規模が大きくなって、特にデータモデルをまたぐ処理が出てきた場合、混沌になります。
これからいくつかの例で説明します。主に凝集度、結合度、表現力という観点から評価します。
例えばAccountServiceは今2つの業務ロジックを表現できています。
changePassword() // パスワード変更
createNewAccount() // 新規
またブログサービスを作るとして、10記事を書いたら、アカウントが昇格するという機能を実現します。
記事を扱うクラスたちは今回説明したいところそんなに関係ないので、実装も最低限にします。
public PostService{
@Autowired
private IPostDao postDao;
@Autowired
public void createNewPost(String text){
Post post = new Post();
post.setText(text);
postDao.save(post);
List<Post> posts = postDao.findAllPosts();
if(posts.size() = 10){
// ?
}
}
}
アカウントの昇格はどうやって実装するかが肝になります。
一番無難なやり方は多分以下のようになります。
public PostService{
@Autowired
private IPostDao postDao;
@Autowired
private AccountService accountService;
public void createNewPost(String text){
Post post = new Post();
post.setText(text);
postDao.save(post);
List<Post> posts = postDao.findAllPosts();
if(posts.size() = 10){
Long accountId = getAccountId(); // 何かしらの仕組みでidを取得。
accountService.upgradeAccount(accountId);
}
}
}
accountServiceのなかにupgradeAccount()というメソッドを追加します。
public class AccountService {
...
public String upgradeAccount(Long accountId){
Account account = accountDao.findById(accountId);
account.setGrade(account.getGrade() + 1);
accountDao.save(account);
}
}
上のような実装は、データ・モデルに対する操作は必ずserviceを通すことを規約として徹底して、もうしかしたら回るかもしれません(もうしかしたら)。
ただ、サービスが増えるに連れて、serviceのなかにほかのserviceに依存したり、service間の相互参照できたりして、密結合になる傾向があります。
もう一例を書きます。
public PostService{
@Autowired
private IPostDao postDao;
@Autowired
private IAccountDao accountDao;
public void createNewPost(String text){
Post post = new Post();
post.setText(text);
postDao.save(post);
List<Post> posts = postDao.findAllPosts();
if(posts.size() = 10){
Long accountId = getAccountId(); // 何かしらの仕組みでidを取得。
Account account = accountDao.findById(accountId);
account.setGrade(account.getGrade() + 1);
accountDao.save(account);
}
}
}
上のような書き方だと、accountに対するデータ操作しかないため、業務の表現が簡潔にできていないため、時間が立ったら、アカウント昇格という業務がみんなの記憶から去っていまうかもしれません。特にデータ操作が多くなる場合はなおさらです。仮にaccountに対して、setterをたくさん呼び出します。結局こんなに値を変更して、何を意味しているかソースを読むことでわかりづらいでしょう。
// 結局こんなに属性を変えて、業務上何を意味しているの???
account.setGrade(account.getGrade() + 1);
account.setLastUpdateTm(now);
account.setLastUpdateByUser(false);
if(account.isPremium()){
account.setContributions(account.getContributions() + 10);
} else {
account.setContributions(account.getContributions() + 1);
}
accountDao.save(account);
PostServiceの中にprivateのupgradeAccount()を追加するという手もありますが、accountのデータモデルに対する操作が複数のクラスに散らばっていることは変わりません。凝集度の低い実装です。
また、accountというモデルがどんな業務に関わっているかを知りたい時、どうすればいいかを想像してみてください。調査しづらくないですか。Accountというクラスが出ているところを全部見るんですね。仕様書を見ればわかるんじゃんと思うかもしれませんが、仕様書がある場合の話ですよね。
一番最悪の実装は多分、AccountServiceの中で、メソッドが増えすぎて、やはりいちいちメソッドを追加するのは面倒くさいから、万能なfindAccount()やupdateAccount()などを作ればいいんじゃんというパターンです。
public PostService{
@Autowired
private IPostDao postDao;
@Autowired
private IAccountService accountService;
public void createNewPost(String text){
Post post = new Post();
post.setText(text);
postDao.save(post);
List<Post> posts = postDao.findAllPosts();
if(posts.size() = 10){
Long accountId = getAccountId(); // 何かしらの仕組みでidを取得
Account account = accountService.findById(accountId);
account.setGrade(account.getGrade() + 1);
accountService.update(account);
}
}
}
public class AccountService {
public void changePassword(String email, @NonNull String oldPassword, @NonNull String newPassword){
...
}
public void createNewAccount(String email, @NonNull String password){
...
}
public void addAdditionalEmail(String email){
...
}
public void changeEmail(String email){
...
}
// 恐怖なメソッド。表現力0で、しかもなんでもできるメソッド
public void updateAccount(String email){
...
}
// 恐怖なメソッド。表現力0で、しかもなんでもできるメソッド
public void saveAccount(String email){
...
}
}
accountServiceの存在する意味は?業務を表現する責務を持っているのに、結局業務のことを何も表していないんじゃないですか。CRUDは業務ではありません!!!
accountServiceにCRUDの口を開放するなら、accountDaoがあればいいでしょう…もっとひどいのはaccountServiceがありながら、他のところでaccountServiceを経由せずに、accountDaoを使ったりするパターン(流石に書きたくないですね)。
ドメインモデル貧血症に関してまとめると、
- 凝集度が低くなりやすい。
- 密結合になりやすい。
- 表現力が低くなりやすい。
業務ロジックがdata schemaに依存しています。
原書に取り上げた問題は、data schemaに何か変更があったあら、applicationも結構の変更が発生するというところです。
でも正直、どんな実装の仕方は根本data schemaに例えばカラム追加がある場合、applicationからdata schemaまでの一連の変更が避けられない気がします。
ただ、entityクラスとデータ・ソースのマッピングが自由にできないことがあるでしょう。
例えばentityクラスの3つのフィールをデーブルのいちカラムにマッピングしたいとか。(Jaywalkingを起こしそうですが…)
あと1つのentityクラスを複数のテーブルにマッピングしたい場合もあるでしょう。
CRUD操作が開放されています
entityはただのデータの箱になって、データ整合性チックはLogic/Serviceクラスに委ねています。例から言うと、メールアドレスとパスワードのからチェック。
ただ、Logic/Serviceを通さずに、意図しない使い方ができてしまいます。またCRUDの制限もないので、データ不整合が起きやすいです。とくにプロジェクトの規模が大きくなって、Logic/Serviceがいっぱいできて、わけわからない状況にいると、なおさらです。上の例でいうと、以下の操作ができてしまいます。
Account account = new Account();
account.setEmail("darling@honey.com");
accountDao.save(account);
本来、passwordは空であってはいけないのに、こういう実装である以上、防ぐのは難しいです。ソースレビューで落とすかみんな規約を守る(規約がある場合)ように祈るしかありません。
また仕様上、accountに対して操作する際、必ずlastUpdateTimeを更新するとい要求があります。
account_id | password | lastUpdateTime | |
---|---|---|---|
bigint | varchar(32) | varchar(16) | DATETIME |
今のような実装でaccountに更新を行う時、lastUpdateTimeを更新するという制御が難しいので、とくにaccountを操作することが散らばっていると、ミスが起きやすいです。
テストが書きにくいです
serviceみたいなDIが必要なクラスのテスト書く時、mockとかが必要になるでしょう。
凝集度が低いことがもたらす問題ですね。
active recordはいつ使っていいですか。
本当に簡単なCRUDで業務ロジックを表せるのなら、active recordを選ぶのは無難でしょう。というか十分でしょう。
また、プロジェクトがスピードを重視する段階にある時、active recordも選択肢としてありだと思います。ただリファクタリングせず、そのまま走りすぎると、いずれ初期のスピードがでなくなる時が訪れます。
##どうしたらいいですか
domainモデルを作ります
実装という観点からすると、データ操作をなるべく特定のところに集約させたいです。
外部にデータの構造、構成も隠蔽したいです。
また業務知識を表現できたら一番良いです。
単純なデータクラスがそういう要件を満たすことができません。自ずと何か別の物が必要になってきます。そういうものをdomainモデルとしましょう。
プログラミングを設計する際、データモデルやデータスキーマなどをどうすべきかは先ずおいておいて、domainモデルという概念を導入します。domainモデルの中で業務ロジックを入れます。domainモデルどうやって業務知識を表現するかを考えます。もちろんそれはモデリングの話になります。
アプリケーションが取り組む問題はそれぞれで、アプリの問題を解決するモデルも一般的もしくは汎用的なものじゃない可能性が高いです。既定の何かのフレームワークを導入することで解決できる問題じゃないと思います。(何かのMVCフレームワークを導入しても、業務ロジックまで全部決めてくれるわけがありません)ですので、domainモデルの設計はアプリケーションの仕様・機能に応じて、やるしかありません。
ドメインモデルってどうやって実装しますか
最近(最近かな)流行っているdonain駆動設計(通称ddd)はdomainモデルに業務ロジックを持たせる手法を提唱しています。
dddの話は機会があれはまた別途でしたいです。とりあえず、その界隈の記事をパクって、参考資料に貼っておきます。
domain駆動設計の1つの実装方法を紹介します。
指針としては、業務ロジックをdomainモデルに対応するクラス(domain object)に入れることです。
まずはaccountクラス、dddの世界でentityと言います。
public class Account {
private AccountId accountId;
private String email;
private EncryptedPassword encryptedPassword;
// パスワードを変更する
public void changePassword(@NonNull String oldPassword, @NonNull
String newPassword){
if(encryptedPassword.verify(oldPassword)){
throw new IllegalArgumentException("old encryptedPassword is not correct");
}
encryptedPassword = new EncryptedPassword(newPassword);
}
// アカウント作成
public static Account createAccount(@NonNull String email, @NonNull String password){
Account account = new Account();
account.accountId = new AccountId(IdGenerator.newId() /* 何かしらの採番仕組みからidをもらう */);
account.email = email;
account.encryptedPassword = new EncryptedPassword(password);
return account;
}
// 外部からコンストラクタを呼べないよう、privateにする
private Account(){
}
}
Accountの識別子になるidクラス。dddのvalue objectで実装します。
@AllArgsConstructor
@Getter
public class AccountId {
@NonNull
private Long id;
}
パスワードのロジックを扱うクラス。value objectです。
public class EncryptedPassword {
private String encryptedPassword;
public EncryptedPassword(@NonNull String password){
if(password.equals("")){
throw new IllegalArgumentException("password cannot be null");
}
// TODO password not encrypted.
this.encryptedPassword = password;
}
public boolean verify(String ps){
return encryptedPassword.equals(ps);
}
}
domain objectたちを永続化するためのクラスです。dddはrepositoryというクラスになります。ある意味、daoクラスと似ています。
public interface IAccountRepository {
Account findById(String email);
void save(Account account);
}
最後、domain objectをつなげるサービスクラス
dddの階層アーキテクチャのなかで、そういうサービスをapplicationServiceといいます。
注意してほしいのは、applicationServiceはdomain objectのメソッドを呼び出すだけで、ドメインロジックはdomain object(例の中でAccount、EncryptedPasswordなどのクラスになります)の中に入っています。
このapplicationServiceはアプリケーションの入り口で、もしspring mvcでrest apiを実装する場合、ControllerクラスがapplicationServiceを呼び出す形になります。
public class AccountApplicationService {
@Autowired
private IAccountRepository accountRepository;
@Transactional
public void changePassword(String email, String oldPassword, String newPasssord){
Account account = accountRepository.findById(email);
account.changePassword(oldPassword, newPassword);
accountRepository.save(account);
}
@Transactional
public Long createAccount(String email, String password){
Account account = Account.createAccount(email, password);
accountRepository.save(account);
}
}
ソース例を理解するのに、dddに関する前提知識が必要で、とてもわかりやすいと思いませんが、active recordと違うところは伝わるでしょう。
まとめ
active record pattern、ドメインモデル貧血症の話をしました。
データ・モデルは全然重要じゃないわけではないです。悪いデータモダルだと他の形でアプリケーションに悪影響を与えます。
ただデータモデルに引きずられすぎると、本末転倒になりそうです。アプリケーションの最優先は仕様を満たすのです。そういう観点に於いてはデータ駆動の開発よりドメイン駆動の開発をすべきではないでしょうか。
参考資料
SQL Antipatterns: Avoiding the Pitfalls of Database Programming (Pragmatic Programmers)
Domain-Driven Design: Tackling Complexity in the Heart of Software
Implementing Domain-Driven Design
[DDD]ドメイン駆動設計で実装を始めるのに一番とっつきやすいアーキテクチャは何か