こんにちは、エンジニアの杉谷です。
普段はSTARTUP DBチームにて、主にバックエンドエンジニアとしてSTARTUP DBの開発をしています。
今回はSTARTUP DBの開発において、自身が最近学習していたGoFのデザインパターンの1つであるテンプレートメソッドを活用して新機能の実装を行なったので、その取り組みについてお話しさせて頂きます。
テンプレートメソッドとは?
そもそもテンプレートメソッドとは何なのかお話しします。
テンプレートメソッドとは、オブジェクト指向プログラミングにおいて、抽象クラスにアルゴリズムの骨格を定義し、具象クラスで具体的な実装を定義する為のデザインパターンです。
下記の図を用いて、より具体的な説明と使用する際のイメージを説明したいと思います。
図左記にある、AbstractClass
というのが抽象クラスに当たります。
ここではtemplate()
というメソッドが定義されており、さらにその中でprimitive_template1()
とprimitive_template2()
が定義されています。
このtemplate()
がアルゴリズムの骨格を表しています。
続いてConcreteClassA
とConcreteClassB
ですが、これらが具象クラスに当たります。
それぞれAbstractClass
を継承していることが図から見て分かるかと思います。
これらのクラスでは、抽象メソッドprimitive_operation1
とprimitive_operation2
を実装することで、共通のアルゴリズムをそれぞれ具体化しています。
例えばConcreteClassAを使用する際の処理の流れとしてはこのようになります。
ConcreteClassA.new().template
でConcreteClassA
のインスタンスを作成しtemplate
を呼び出す。template
の中で、ConcreteClassA
のprimitive_operation1
を呼び出す。ConcreteClassA
のprimitive_operation1
が実行される。template
の中で、ConcreteClassA
のprimitive_operation2
を呼び出す。ConcreteClassA
のprimitive_operation2
が実行される。ConcreteClassB
を使用する際は、ConcreteClassB.new().template
でConcreteClassB
に書かれたprimitive_operation1
とprimitive_operation2
の定義内容が処理されます。
以上のことからテンプレートメソッドには下記のようなメリットがあることが伺えます。
再利用性が高い:共通のアルゴリズムを抽象クラスに定義することで、各サブクラスで具体的な処理を実装することができます。このため、アルゴリズムの流れは共通化され、再利用性が高くなります。
拡張性が高い:新しいサブクラスを作成することで、アルゴリズムの流れを拡張することができます。具体的な処理が異なるだけで、アルゴリズムの流れは共通しているため、既存のアルゴリズムに新しい処理を追加することも容易です。
カプセル化が実現できる:共通のアルゴリズムを抽象クラスにカプセル化することで、各サブクラスで具体的な処理を実装するだけで済みます。この為、アルゴリズムの詳細は各サブクラス内に内包され、カプセル化が実現できます。
このテンプレートメソッドは、アルゴリズムの流れを共通化する必要がある場合・具体的な処理を異なるサブクラスで実装する必要がある場合において活用されます。
このテンプレートメソッドを活用する場面としては、例えばログイン認証などが挙げられます。ユーザー名とパスワードで認証する場合と、OAuthで認証する場合とでは、認証の具体的な処理が異なります。共通のアルゴリズムであるログイン処理を抽象クラスに定義し、各認証方式で異なる具体的な処理をサブクラスで実装することが考えられます。
何故テンプレートメソッドを用いたのか?
まず初めに、どんな新機能を開発していたのか概要と経緯を簡単にご説明します。
今回実装したのは「PDFダウンロード機能」というもので、STARTUP DBに掲載されいてる企業の詳細情報をPDFで出力することを可能にする機能になります。「スタートアップならびに投資家ページにて、詳細データをエクセルでダウンロードができるものの、多くの情報をエクセルで確認し、社内共有するには適していなかった」といったお声を多く頂き、今回の実装に取り組みました。
下記がSTARTUP DBが提供するPDFのサンプル画像になります。
今回テンプレートメソッドを用いて実装に取り組んだのには大きく2つの理由がありました。
1つ目に、各項目間で大まかなアルゴリズムが共通していたので重複箇所をなるべく減らしたかった事が挙げられます。
下記が具体的な共通するアルゴリズムの内容になります。
- 大見出しを出力する
- 項目名を出力する
- 項目毎にそれぞれ情報を入力する
- 間隔を空ける
サンプルPDFを再度見て頂くと分かるかと思いますが、デザイン自体は各項目ほぼ統一されており、違いとしては出力される情報のみでした。テンプレートメソッドを活用することで、大まかなアルゴリズムの流れが定義されたクラスを作成することが出来、テンプレートメソッドのメリットを十分享受出来ると考えました。
2つ目に、保守性・拡張性が高い機能を実装することが出来ることが挙げられます。
PDFダウンロード機能はSTARTUP DBにおいて、今後も改修される可能性が比較的高い機能ということもあり、実装にあたり保守のし易さや掲載する情報が増えた際の拡張のし易さが設計において重要な点でした。
テンプレートメソッドを活用すれば、抽象クラスを参照することで大まかな処理の内容を理解する事が出来るので、保守がし易くなります。また新しい情報を追加する場合は、その情報に関する処理内容を抽象クラスを継承したサブクラスを新たに作れば済みます。前述の2点からテンプレートメソッドを活用する事で重要な点をカバーすることが出来ると判断しました。
以上の2点から今回の実装でテンプレートメソッドを用いることを決めました。
テンプレートメソッドを用いて実装してみた
下記が実装した抽象クラスと具象クラスのコードになります。
<抽象クラス>
class Download::Company::Pdf::Content … def sequence # 大見出しを出力する output_headline output_total_amount # 項目名を出力する output_item_name # 項目毎にそれぞれ情報を入力する output_contents output_limit_number_notification # 間隔を空ける output_interval ... end def output_headline; end def output_total_amount; end def output_item_name; end def output_contents; end def output_limit_number_notification; end def output_interval; end def output_horizontal_line; end def sequence_for_outline; end def sequence_for_finance_topics; end def sequence_for_funds; end def sequence_for_news; end … # NOTE: タイトル名出力 def headline(text_size, name, down_space) font_size(text_size) @pdf.text name.to_s move_down(down_space) end def total_amount(method) move_down(5) font_size(TEXT_SIZE[:small_headline]) @pdf.text method move_down(10) end # NOTE: 下幅間隔調整 def move_down(down_space) @pdf.move_down(down_space) end # NOTE: リンクのURL作成と活性化 def embed_link_and_activate_link(url_name, url) return '-' if url_name.blank? return url_name if url.blank? @pdf.make_cell(content: to_html(url, url_name), inline_format: true) end # NOTE: 複数の要素を1つの文字列に結合 def compile_to_a_list(multiple_hashes) return '-' if multiple_hashes.blank? hashes_list = multiple_hashes.pluck(:name) hashes_list.join(',') end # NOTE: 境界線 def horizontal_line move_down(30) @pdf.stroke_color 'e3e3e3' @pdf.stroke_horizontal_rule move_down(30) end # NOTE: 備考欄の表示形式の整形 def compile_to_a_list_for_note(notes) return '-' if notes.blank? notes_list = notes.map { |note| note } notes_list.join('/') end def investor_type_classification(lead_investors) return '-' if lead_investors.blank? company_lead_investors = [] officer_lead_investors = [] fund_lead_investors = [] lead_investors.each do |investor| case investor[:investor_type] when 'Company' company_lead_investors << investor when 'Officer' officer_lead_investors << investor else fund_lead_investors << investor end end companies = convert_to_an_array_of_link_tags(company_lead_investors, 'Company') officers = convert_to_an_array_of_link_tags(officer_lead_investors, 'Officer') funds = convert_to_an_array_of_link_tags(fund_lead_investors, 'Fund') content = [companies, officers, funds].flatten.uniq.join(",\n") @pdf.make_cell(content:, inline_format: true) end # NOTE: 日付の出力整形 def convert_date_format(date) return '-' if date.blank? format_date = date.to_s&.gsub(%r{/|\.|-}, '') if format_date.length >= 8 if format_date[4, 2] == '00' format_date[0, 4] elsif format_date[6, 2] == '00' format_date[0, 6].insert(4, '.') else begin I18n.l(format_date&.to_date, format: :hyphon) rescue StandardError => e Rollbar.delay.warning(e.message) end end else format_date end end end
<具象クラス>
# NOTE:サービス情報 class Download::Company::Pdf::Contents::Services < Download::Company::Pdf::Content def initialize(pdf, company) @pdf = pdf @company = company end private def output_headline headline(TEXT_SIZE[:large_headline], PDF_SHEETS[:services][:title], 20) end def output_item_name item_name(PDF_SHEETS[:services][:fields].values, COLUMN_WIDTH[:services]) end def output_contents @company[:services].each do |service| @pdf.table( [ [ embed_link_and_activate_link(service[:name], service[:url]), service[:description], convert_date_format(service[:published_at]), compile_to_a_list(service[:tags]) ] ], column_widths: COLUMN_WIDTH[:services] ) do |table| table.cells.size = TEXT_SIZE[:default] end end end def output_horizontal_line horizontal_line end end
抽象クラスであるDownload::Company::Pdf::Content
に共通するアルゴリズムをsequence
メソッド内で定義しています。Download::Company::Pdf::Contents::Services
ではsequence
内のメソッドをオーバーライドしそれぞれの処理を記載しています。sequence
メソッドでは、一部の具象クラスだけで使用する例外的なメソッドも定義しています。
例えば下記サンプル画像にある資金調達情報ではサービス情報では定義されていなかったoutput_total_amount
が呼び出されています。
<具象クラス>
# NOTE:資金調達情報 class Download::Company::Pdf::Contents::Investments < Download::Company::Pdf::Content def initialize(pdf, company) @pdf = pdf @company = company end private def output_headline headline(TEXT_SIZE[:middle_headline], PDF_SHEETS[:finance][:investments][:title], 5) end def output_total_amount # NOTE: 合計資金調達金額 total_amount(funding_total_amount) end def output_item_name item_name(PDF_SHEETS[:finance][:investments][:fields].values, COLUMN_WIDTH[:investments]) end def output_contents @company[:investments].each do |investment| @pdf.table( [ [ convert_date_format(investment[:funded_at]), output_investment_amount(investment[:funding_amount]), investor_type_classification(investment[:lead_investors]), investor_type_classification(investment[:investors]), compile_to_a_list_for_note(investment[:notes_romaji]) ] ], column_widths: COLUMN_WIDTH[:investments] ) do |table| table.cells.size = TEXT_SIZE[:default] end end end def output_interval interval_for_each_item end def funding_total_amount funding_amount = Download::Company::CellValueCorrector::Detail::Pdf.funding_amount(is_funded: @company[:is_funded], unsigned_million_unit_amount: @company[:funding_amount]) ['百万円未満', '-'].include?(funding_amount) ? "合計資金調達金額:#{funding_amount}" : "合計資金調達金額:#{funding_amount}(百万円)" end def output_investment_amount(million_unit_amount) Download::Company::CellValueCorrector::Detail::Pdf.investment_amount(million_unit_amount) end end
本来は下記のように定義し、具象クラスにオーバーライドを義務付けをすることで処理の統一性を保ちます。もし、抽象クラスに定義されているメソッドを具象クラスが定義していない場合は、実行時エラーが発生します。
def template primitive_template1 primitive_template2 end def primitive_template1 raise "#{__method__} must be implemented" end def primitive_template2 raise "#{__method__} must be implemented" end
しかし今回の実装では、大体の枠組みをテンプレートメソッドで定義し、抽象クラスを手順書のように扱い今後PDFダウンロード機能が改修される際に、理解のし易い構造にしたいという思いがあったので、空メソッドで定義し具象クラス側で必要な処理を定義する方針で実装しました。
実装してみて
「今日の自分のコードは明日の他人のコード」という言葉があるように、自身の書いたコードを振り返った際に読み返せないようでは良い設計とは言えないです。
実装を終え、数ヶ月ぶりに改めてコードを読み返し、抽象クラスに処理内容がまとまっていることで直ぐに処理の概要を掴むことが出来る点はテンプレートメソッドを活用して良かったなと改めて感じました。
また各具象クラスが何をしているのか、クラス単位でまとまっているのでそれぞれの項目でどのような処理が行われているのかも、理解しやすいと感じました。ありがたい事に、レビューをして頂いた方からは処理内容が理解し易かったというお言葉も頂きました。
一方で、自身でコードの再レビューを通して改善したい点が出て来ているので、引き続き自身のコーディングスキルを向上させるべく精進していきたいと思います。