はじめに
クラス設計に悩む人向けの内容です。
どのように抽象化して依存性を排除してゆくかを解説していきます。
デザインパターンを駆使したクラス設計とリファクタリングをエンジニア歴9年目にしてほぼ初めて行って、しかもいい感じの結果だったのでまとめていこうと思います。
Facade, Abstract Factory, Builderの3パターンを扱います。
ソースコードの全体はGithubのリポジトリを参照してください。
また、各デザインパターンのクラス図はこちらのサイトが詳しいので、クラス図の解説はそちらにお任せします。
では、我々の仕事の楽しみの1つであるランチタイムをこれらのデザインパターンで表現していきます。
ランチへ行こう
さて、今日のお昼はラーメンを食べに行くことにしましょうか。
ラーメンを食べに行く様子を普通に実装するとこうなります。
public class Engineer {
public void goLunch() {
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
}
}
これだとラーメンに依存し過ぎています。
ランチが毎日同じラーメンならこれでいいのですが、別のラーメン屋へ行きたい、たまには定食も食べたい、帰りにはコンビニ寄りたいってなると、リスト2のように分岐でどんどん処理が膨れ上がりますよね。
public void goLunch(LunchEnum lunch) {
switch (lunch) {
case とんこつラーメン:
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
break;
case 塩ラーメン:
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 支払いをする
// 店を出る
break;
case 焼き魚定食:
// 定食屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
break;
// ・・・・etc
}
Calendar today = Calendar.getInstance();
if (Calendar.WEDNESDAY == today.get(Calendar.DAY_OF_WEEK)) {
// マガジン発売日の場合コンビニに立ち寄る
// マガジンを読む
}
可読性も悪くメンテナンス性も悪くなります。
そうなる前に細かい処理は外に出してgoLunch()メソッドではランチタイムを過ごしたことだけわかるようにしてしまいしょう。
Facadeパターン
ランチタイムにFacade(ファサード)パターンを当てはめます。
リスト3のようにLunchTimeクラスを作ります。
public class LunchTime {
public void goLunch(LunchEnum lunch) {
switch (lunch) {
case とんこつラーメン:
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
break;
case 塩ラーメン:
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 支払いをする
// 店を出る
break;
case 焼き魚定食:
// 定食屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
break;
// ・・・・etc
}
Calendar today = Calendar.getInstance();
if (Calendar.WEDNESDAY == today.get(Calendar.DAY_OF_WEEK)) {
// マガジン発売日の場合コンビニに立ち寄る
// マガジンを読む
}
}
}
リスト4のようにLunchTimeクラスをnewしてgoLunch()メソッドを呼び出すだけにしてしまいましょう。
public class Engineer {
public void goLunch(LunchEnum lunch) {
LunchTime lunchTime = new LunchTime();
lunchTime.goLunch(lunch);
}
}
Facadeパターンのおかげでエンジニアは毎日毎日goLunch()を呼び出すだけでランチタイムを過ごせるようになりました。
さらに、ランチの分岐が増えても、ランチ前に銀行へ寄ってお金を下ろす事になってもエンジニアクラスには全く影響はなくなります。
依存性は限りなく低くなりました。
メインのロジックからビジネスロジックを別のクラスに切り出して実装するこのFacadeパターンは実際の開発の現場でもよく見かけます。
Serviceクラスとして作られてActionクラスから呼び出されることが多いのではないかと思います。
このままではswitch文がイケません。
ラーメンのこの部分や
// ラーメン屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
定食屋のこの部分は、
// 定食屋に入る
// 食券を買う
// 席に座る
// 食べる
// 店を出る
処理順の違いこそあれ、共通点が見受けられます。
お店に入ってからやることはだいたい決まっているようです。
ラーメン屋、定食屋ということを隠蔽してお店に入ってからやることだけを抽象化できそうです。
Abstract Factoryパターン
そんな時はAbstaract Factoryパターンを使ってみましょう。
Factoryクラスを用意します。
public abstract class LunchFactory {
/** 店に入る */
abstract public void in();
/** 支払いをする */
abstract public void bill();
/** 注文をする */
abstract public void order();
/** 食べる */
abstract public void eat();
/** 席に座る */
abstract public void sit();
/** 店を出る */
abstract public void exit();
}
お店に入ってからの実体はこのように定義するとします。
Factoryクラスを継承したラーメンファクトリや定食ファクトリを作成します。
// とんこつラーメンのファクトリクラス
public class TonkotsuRamen extends LunchFactory {
@Override
public void in() {
System.out.println("とんこつラーメン屋に入ったで");
}
@Override
public void bill() {
System.out.println("券売機に700円入れたで");
}
@Override
public void order() {
System.out.println("券売機でとんこつラーメン押したで");
}
@Override
public void eat() {
System.out.println("ラーメン食べたで");
}
@Override
public void sit() {
System.out.println("テーブル席に座ったで");
}
@Override
public void exit() {
System.out.println("大将!ごちそうさん!");
}
}
// 焼き魚定食のファクトリクラス
public class SammaTeisyoku extends LunchFactory {
@Override
public void in() {
System.out.println("魚定食屋に来ました");
}
@Override
public void bill() {
System.out.println("レジで850円支払いました");
}
@Override
public void order() {
System.out.println("さんまの焼き魚定食注文しました");
}
@Override
public void eat() {
System.out.println("さんま食べました");
}
@Override
public void sit() {
System.out.println("二人席に通されました");
}
@Override
public void exit() {
System.out.println("よいお味でした");
}
}
LunchTimeクラスは、
ランチメニューに応じたランチの実体を受け取り、抽象クラスに対してアクションするだけでよくなります。
public void goLunch(LunchEnum lunchMenu) {
LunchFactory lunch = null;
switch (lunchMenu) {
case とんこつラーメン:
lunch = new TonkotsuRamen();
break;
case 塩ラーメン:
lunch = new SioRamen();
break;
case 焼き魚定食:
lunch = new SammaTeisyoku();
break;
// ・・・・etc
}
lunch.in();
lunch.sit();
lunch.order();
lunch.eat();
lunch.bill();
lunch.exit();
ランチの実体をほぼ追い出すことに成功しました。
これで例えとんこつラーメンの値段が値上がりしても、大盛りのラーメンを注文しても、さんまじゃなく鯖の焼き魚定食を選択してもこのLunchTimeクラスには全く影響はなくなったことがわかります。
だが!しかし!But!
けどけれどYet!
ラーメン屋さんや定食屋さんには若干の手順の違いがあります。
食券制のラーメン屋だとこの順番になりますね。
lunch.in();
lunch.bill(); // 券売機にお金を入れる
lunch.order(); // 食べたいラーメンのボタンを押す
lunch.sit();
lunch.eat();
lunch.exit();
この手順すら隠蔽してしまいましょう。
Builderパターン
そんな時のBuilderパターンです。
手順を指示するDirectorと、成果物を作るBuilderに分けるというデザインパターンになります。
DirectorとBuilderのインターフェースを用意します(抽象クラスでもよいです)。
Builderクラスのメソッド達はさっきまでFactoryクラスにいたメソッド達です。
public interface LunchDirector {
public void lunch();
}
public interface LunchBuilder {
/** 店に入る */
public void in();
/** 支払いをする */
public void bill();
/** 注文をする */
public void order();
/** 食べる */
public void eat();
/** 席に座る */
public void sit();
/** 店を出る */
public void exit();
}
リスト9のようにRamenDirectorクラスとTeisyokuDirectorクラスを作ります。
ラーメン屋さんと定食屋さんでは手順に若干の違いがありますね。
ディレクターはビルダーの実体を意識する必要はありません。
ディレクターそれぞれが指示する手順を守ればよいだけです。それがディレクターの役割です。
public class RamenDirector implements LunchDirector {
private LunchBuilder builder;
public RamenDirector(LunchBuilder builder) {
this.builder = builder;
}
@Override
public void lunch() {
builder.in();
builder.bill(); // 券売機にお金を入れる
builder.order(); // ラーメンを選ぶ
builder.sit();
builder.eat();
builder.exit();
}
}
public class TeisyokuDirector implements LunchDirector {
private LunchBuilder builder;
public TeisyokuDirector(LunchBuilder builder) {
this.builder = builder;
}
@Override
public void lunch() {
builder.in();
builder.sit();
builder.order(); // 注文する
builder.eat();
builder.bill(); // レジで支払いをする
builder.exit();
}
}
Factoryクラスの実体だったクラスはFactoryの継承をやめ、Builderインターフェースを実装させました。
ビルダークラスは手順を全く意識する必要がありません。
ディレクターが指示する通りに動けばよいのです。
public class TonkotsuRamen implements LunchBuilder {
@Override
public void in() {
System.out.println("とんこつラーメン屋に入ったで");
}
@Override
public void bill() {
System.out.println("券売機に700円入れたで");
}
@Override
public void order() {
System.out.println("券売機でとんこつラーメン押したで");
}
@Override
public void eat() {
System.out.println("ラーメン食べたで");
}
@Override
public void sit() {
System.out.println("テーブル席に座ったで");
}
@Override
public void exit() {
System.out.println("大将!ごちそうさん!");
}
}
public class SammaTeisyoku implements LunchBuilder {
@Override
public void in() {
System.out.println("魚定食屋に来ました");
}
@Override
public void bill() {
System.out.println("レジで850円支払いました");
}
@Override
public void order() {
System.out.println("さんまの焼き魚定食注文しました");
}
@Override
public void eat() {
System.out.println("さんま食べました");
}
@Override
public void sit() {
System.out.println("二人席に通されました");
}
@Override
public void exit() {
System.out.println("よいお味でした");
}
}
Factoryクラスもリスト11のように変更します。
public abstract class LunchFactory {
public static LunchFactory getFactory() {
return new ConcreteLunchFactory();
}
abstract public void goLunch(LunchEnum lunchMenu);
}
public class ConcreteLunchFactory extends LunchFactory {
@Override
public void goLunch(LunchEnum lunchMenu) {
// ディレクターを作る
LunchDirector director = null;
switch (lunchMenu) {
case とんこつラーメン:
director = new RamenDirector(new TonkotsuRamen());
break;
case 塩ラーメン:
director = new RamenDirector(new SioRamen());
break;
case 焼き魚定食:
director = new TeisyokuDirector(new SammaTeisyoku());
break;
// ・・・etc
}
// ランチ実施
director.lunch();
}
}
するとLunchTimeクラスは実にシンプルなクラスになります。
Factoryであり、Facadeも兼ね備えていますね。
さらに実体を完全に追い出したので、依存性を限りなく低くすることができました。
public class LunchTime {
public void goLunch(LunchEnum lunchMenu) {
LunchFactory factory = LunchFactory.getFactory();
factory.goLunch(lunchMenu);
Calendar today = Calendar.getInstance();
if (Calendar.WEDNESDAY == today.get(Calendar.DAY_OF_WEEK)) {
// マガジン発売日の場合コンビニに立ち寄る
// マガジンを読む
}
}
}
これでどんなメニューにも対応できるランチタイムの過ごし方が完成しました。
ランチメニューにうどんが加わっても、丼ものが加わっても影響範囲はConcreteFactoryクラスとDirectorクラスとBuilderクラスのみとなります。
同様に、コンビニに立ち寄る処理も上手く追い出せそうです。
こちらの場合は月曜ならジャンプ、木曜ならチャンピオンとヤンジャンを読むという形で呼び分けたい気もするのでStrategyパターンを使ってもいいかもしれません。
実際の開発に当てはめると?
では、ここまで抽象化したクラス設計は実際にどのようなシーンで有効なのでしょうか?
ズバリ、いくつかの機能が似たような処理を行う場合に有効でしょう。
処理は抽象化しておいて、機能毎の差分だけ実体として横展開させるという使い方ができるでしょう。
私が実際に仕事で設計したのはバッチで、たまたまこの3パターンがハマる内容でした。
オンラインの機能でも実現はできると思います。
まとめ
- ちゃんとクラス設計して依存性を排除すると実体部分の横展開が最小工数で実現できる。今回の例では、今後の横展開はディレクタークラスとビルダークラスを増やすのみでよいことになります。
- 影響範囲が限定されるのでメンテナンス性が高い。
- レビューの生産性UPが見込める。可読性向上、抽象化により本当にレビューが必要な範囲が限定される。
- 単体テストの工数削減が見込める。プロジェクトの方針次第なところはありますが、ほぼ抽象化されているので本当にテストが必要な範囲はビルダークラスの各メソッドのみでよいはずです。
歴9年目ですがエンジニアとして1レベルアップした気分になりました。
参考
あとがき
作り終わってから気付いたけど、支払いはbillじゃなくpayですね。
雰囲気で通じそうなので直さないでおきます。
英語も勉強しよう。。。