概要
デザインパターンのTemplateMethodパターンの事例です。
デザインパターンは増補改訂版 Java言語で学ぶデザインパターン入門で学ぶことができますし、Qiitaでも取り上げられています。
ここで紹介するのは、私が自分で適用可能な状況を考えて実装した事例です。
本の写経やブログを読んで終わりではなく、どのような状況でどのように実装できるか?
と自分なりに考えることの重要性は、増補改訂版 Java言語で学ぶデザインパターン入門でも述べられています。
本を読んだり、写経したり、Qiitaを見て真似してみたけど、実際にどうやって使うんだろうか?
という疑問を持っている読者の方に、またオブジェクト指向プログラミングを身に着けたい方の参考になればと思います。
TemplateMethodパターンとは?
説明
ロジックが共通しているが、具体的な処理内容はサブクラスで実装するパターンです。
ロジック部分はスーパークラスで実装されており、ロジック内にある個別の処理内容をサブクラス固有の実装にすることで、ロジックの使いまわしと個別処理の実装を分離することができます。
共通のロジックがコピー&ペーストでいろんな場所に分散すると、修正することも、テストすることも大変になります。
そういった手間をかけないようにするためのパターンです。
クラス図
スーパークラスのSalesReportでoutputメソッドが共通ロジックになっており、その中でtitle、detail、summaryメソッドが実行されるようになっています。
title、detail、summaryメソッドをサブクラスであるTextSalasReportとHtmlSalesReportで個別に実装することで、表示形式を分離しています。
実装
SalesReportクラス
売上レポートのスーパークラス。
・title、detail、summaryの表示形式はサブクラスで定義するため抽象メソッドとしている。
・outputメソッドは共通ロジックであり、変更不可のためfinalとしている。
・summaryAmount、yenメソッドも共通ロジックですが、個別の実装を要求するものではなく、サブクラスで利用できるように提供しているだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
package templatemethod; import java.util.Map; abstract class SalesReport { protected String _title; protected Map<String, Integer> _data; protected SalesReport(String title, Map<String, Integer> data) { _title = title; _data = data; } /** * <サブクラスが個別に実装するロジック> * タイトルの表示 * 売上明細表示 * 売上合計表示 * @return */ protected abstract String title(); protected abstract String detail(String key, Integer value); protected abstract String summary(); /** * <共通ロジック> * 売上合計算出機能 * 金額表示機能 * 出力機能 */ final void output() { System.out.println(title()); _data.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .forEach(line -> System.out.println( detail(line.getKey(), line.getValue()) ) ); System.out.println(summary()); } final Integer summaryAmount() { return _data.entrySet().stream() .mapToInt(line -> line.getValue()) .sum(); } final String yen(Integer value) { return value + "円"; } } |
TextSalesReportクラス
テキスト形式で売上レポートを出力するクラスです。
出力形式の仕様は以下のようになります。
< yyyy年M月売上レポート >
A社 : 5000000円
B社 : 2500000円
C社 : 500000円
合計 : 8000000円
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package templatemethod; import java.util.Map; public class TextSalesReport extends SalesReport { public TextSalesReport(String title, Map<String, Integer> data) { super(title, data); } @Override public String title() { return "< " + _title + " >"; } @Override public String detail(String key, Integer value) { return lineFormat(key, yen(value)); } @Override public String summary() { return lineFormat("合計", yen(summaryAmount())); } /** * 明細行のフォーマットを整える * @param left * @param right * @return */ private String lineFormat(String left, String right) { return " " + left + " : " + right; } } |
HtmlSalesReportクラス
HTML形式で売上レポートを出力するクラスです。
出力形式の仕様は以下のようになります。
<h1>yyyy年M月売上レポート</h1>
<table>
<tr><td>A社</td><td>5000000円</td></tr>
<tr><td>B社</td><td>2500000円</td></tr>
<tr><td>C社</td><td>500000円</td></tr>
<tr><td>合計</td><td>8000000円</td></tr>
</table>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
package templatemethod; import java.util.Comparator; import java.util.Map; public class HtmlSalesReport extends SalesReport { public HtmlSalesReport(String title, Map<String, Integer> data) { super(title, data); } @Override public String title() { return element(_title, "h1"); } @Override public String detail(String key, Integer value) { // 最初のDetailに対してのみ、Detailを囲うTableタグを追加しておく。 return firstDetailElement(key) + element( element(key, "td") + element(yen(value), "td"), "tr"); } private String element(String value, String tag) { return "<" + tag + ">" + value + "</" + tag + ">"; } private String firstDetailElement(String key) { String firstKey = _data.keySet().stream() .sorted(Comparator.comparing(String::toString)) .findFirst() .get(); return key.equals(firstKey) ? "<table>\n" : ""; } @Override public String summary() { return element( element("合計", "td") + element(yen(summaryAmount()), "td"),"tr" ) + "\n</table>"; } } |
SalesRepotMainクラス
売上レポートを表示するためのクラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
package templatemethod; import java.util.HashMap; import java.util.Map; /** * 売上レポートを出力します。 * TextSalesReport :テキスト形式で出力します。 * HtmlSalesReport :HTML形式で出力します。 * @author user * */ public class SalesRepotMain { public static void main(String...strings) { String _title = "2021年5月売上レポート"; Map<String, Integer> _companyMap = new HashMap<String, Integer>(){ { put("A社", 5_000_000); put("B社", 2_500_000); put("C社", 500_000); } }; SalesReport tReport = new TextSalesReport(_title, _companyMap); tReport.output(); System.out.println(""); SalesReport hReport = new HtmlSalesReport(_title, _companyMap); hReport.output(); } } |
<出力結果>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
< 2021年5月売上レポート > A社 : 5000000円 B社 : 2500000円 C社 : 500000円 合計 : 8000000円 <h1>2021年5月売上レポート</h1> <table> <tr><td>A社</td><td>5000000円</td></tr> <tr><td>B社</td><td>2500000円</td></tr> <tr><td>C社</td><td>500000円</td></tr> <tr><td>合計</td><td>8000000円</td></tr> </table> |
サブクラスで実装した仕様の違い
TextSalesReportクラス | HtmlSalesReportクラス | |
---|---|---|
titleメソッド | タイトルを<>で囲って表示する。 タイトルの前後に半角スペースを入れること。 | タイトルをh1タグで囲って表示する。 |
detailメソッド | 会社名と金額をコロンで区切って表示する。 会社名の前と、コロンの前後に半角スペースを入れること。 | trタグ内に、tdタグで会社名と金額を表示する。 ただし、最初のtrタグを出力する時だけ、tableタグを追加しておく。 |
summaryメソッド | "合計"と合計金額をコロンで区切って表示する。 "合計"の前と、コロンの前後に半角スペースを入れること。 | trタグ内に、tdタグで"合計"と合計金額を表示する。 また、tableの閉じタグを追加しておく。 |
もし他の出力形式でレポートを出力する場合、ロジックのコピー&ペーストすることなく、別のクラスを追加することで対応できます。
例えば、CSV形式のファイルを出力する必要が出てきたら、CsvSalesReportクラスを作成すればよいということになります。
※サブクラスを追加した結果、スーパークラスに機能追加することが無いという意味では無く、必要に応じてリファクタリングは行われるべきかなとは思っています。
付録(テストコード)
新たなサブクラスの追加でスーパークラスの修正も行った場合、既存のサブクラスに影響を与える可能性があります。
そのためにテストコードを書いておくことはとてもとても重要だと思っています。
リファクタリングでも、既存のコードを修正するのであればテストコードを用意しておくことが望ましいと述べられています。
動いているコードを修正するリスクは高いのです!
テストコードを書けるのであれば、できる限り書くようにしていきましょう。
module-info
Java11の環境であるため、module-infoを利用しています。
1 2 3 4 5 6 |
module designpattern { requires org.junit.jupiter.api; requires org.hamcrest.core; } |
TestTextSalesReport
TextSalesReportクラスのテストコードです。
個別に実装するメソッドのテストを行っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package test; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.*; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import templatemethod.TextSalesReport; class TestTextSalesReport { private String _title = "2021年5月売上レポート"; private Map<String, Integer> _companyMap = new HashMap<String, Integer>(){ { put("A社", 5_000_000); put("B社", 2_500_000); put("C社", 500_000); } }; @Test void タイトルがカッコ付きで取得されること() { TextSalesReport tReport = new TextSalesReport(_title, _companyMap); assertThat(tReport.title(), is("< 2021年5月売上レポート >")); } @Test void 売上情報詳細がコロン区切りで取得されること() { TextSalesReport tReport = new TextSalesReport(_title, _companyMap); assertThat(tReport.detail("C社", 500_000), is(" C社 : 500000円")); } @Test void 売上合計が取得されること() { TextSalesReport tReport = new TextSalesReport(_title, _companyMap); assertThat(tReport.summary(), is(" 合計 : 8000000円")); } } |
TestHtmlSalesReport
TestHtmlSalesReportクラスのテストコードです。
TestTextSalesReportと同様に、個別に実装するメソッドのテストを行っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
package test; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.*; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import templatemethod.HtmlSalesReport; class TestHtmlSalesReport { private String _title = "2021年5月売上レポート"; private Map<String, Integer> _companyMap = new HashMap<String, Integer>(){ { put("A社", 5_000_000); put("B社", 2_500_000); put("C社", 500_000); } }; @Test void タイトルがh1タグで取得されること() { HtmlSalesReport hReport = new HtmlSalesReport(_title, _companyMap); assertThat(hReport.title(), is("<h1>2021年5月売上レポート</h1>")); } @Test void 売上情報詳細がコロン区切りで取得されること_最初に表示される行である場合Tableタグを付与する() { HtmlSalesReport hReport = new HtmlSalesReport(_title, _companyMap); assertThat(hReport.detail("A社", 5_000_000), is("<table>\n<tr><td>A社</td><td>5000000円</td></tr>")); } @Test void 売上情報詳細がコロン区切りで取得されること_最初に表示される行でないため先頭にTableタグは無し() { HtmlSalesReport hReport = new HtmlSalesReport(_title, _companyMap); assertThat(hReport.detail("C社", 500_000), is("<tr><td>C社</td><td>500000円</td></tr>")); } @Test /** * 売上合計の行はTableタグの最終行のため、Tableの閉じタグを付与しておく */ void 売上合計が取得されること() { HtmlSalesReport hReport = new HtmlSalesReport(_title, _companyMap); assertThat(hReport.summary(), is("<tr><td>合計</td><td>8000000円</td></tr>" + "\n</table>")); } } |
テストコードのメリット
実際のところ、この記事を書きあげるまでに何度かコードを書き直しました。
まさしく、リファクタリングを行ったわけです。
その際、テストコードがあったおかげで私は自分の修正に確からしさを感じることができました。
よく「こんな小さな修正でバグが出るはずがない」という思い込みをすることがあります。
(読者のあなたにもあれば、要注意です!)
それでも、ポカはするものなのです。
なので、テストコードがあれば、自分の修正に問題がないことをある程度は保証することはできます。
ですから、何度も言うようですが、テストコードを書けるのであれば、できるだけ書いた方がよいです。
プロジェクト体制やスキルの問題もあると思いますが、私としては自分の仕事に確からしさをもたらし、品質と効率を求めていきたいと思っています。
まとめ
- 共通ロジックをコピー&ペーストすることなく使いまわすことができるパターンである。
- 個別の処理内容はサブクラスで自由に実装可能である。
- 追加のサブクラスが必要になった場合、単純に追加するだけで対応可能である。