for Startups Tech blog

このブログのデザインを刷新しました。(2023/12/26)

Rubyでテンプレートメソッド活用してみた

こんにちは、エンジニアの杉谷です。
普段はSTARTUP DBチームにて、主にバックエンドエンジニアとしてSTARTUP DBの開発をしています。
今回はSTARTUP DBの開発において、自身が最近学習していたGoFデザインパターンの1つであるテンプレートメソッドを活用して新機能の実装を行なったので、その取り組みについてお話しさせて頂きます。

テンプレートメソッドとは?

そもそもテンプレートメソッドとは何なのかお話しします。

テンプレートメソッドとは、オブジェクト指向プログラミングにおいて、抽象クラスにアルゴリズムの骨格を定義し、具象クラスで具体的な実装を定義する為のデザインパターンです。

下記の図を用いて、より具体的な説明と使用する際のイメージを説明したいと思います。

template_method概略図

図左記にある、AbstractClassというのが抽象クラスに当たります。

ここではtemplate()というメソッドが定義されており、さらにその中でprimitive_template1()primitive_template2()が定義されています。

このtemplate()アルゴリズムの骨格を表しています。

続いてConcreteClassAConcreteClassBですが、これらが具象クラスに当たります。 それぞれAbstractClassを継承していることが図から見て分かるかと思います。

これらのクラスでは、抽象メソッドprimitive_operation1primitive_operation2を実装することで、共通のアルゴリズムをそれぞれ具体化しています。

例えばConcreteClassAを使用する際の処理の流れとしてはこのようになります。

  • ConcreteClassA.new().templateConcreteClassAインスタンスを作成しtemplateを呼び出す。
  • templateの中で、ConcreteClassAprimitive_operation1を呼び出す。
  • ConcreteClassAprimitive_operation1が実行される。
  • templateの中で、ConcreteClassAprimitive_operation2を呼び出す。
  • ConcreteClassAprimitive_operation2が実行される。
  • ConcreteClassBを使用する際は、ConcreteClassB.new().templateConcreteClassBに書かれたprimitive_operation1primitive_operation2の定義内容が処理されます。

以上のことからテンプレートメソッドには下記のようなメリットがあることが伺えます。

再利用性が高い:共通のアルゴリズムを抽象クラスに定義することで、各サブクラスで具体的な処理を実装することができます。このため、アルゴリズムの流れは共通化され、再利用性が高くなります。

拡張性が高い:新しいサブクラスを作成することで、アルゴリズムの流れを拡張することができます。具体的な処理が異なるだけで、アルゴリズムの流れは共通しているため、既存のアルゴリズムに新しい処理を追加することも容易です。

カプセル化が実現できる:共通のアルゴリズムを抽象クラスにカプセル化することで、各サブクラスで具体的な処理を実装するだけで済みます。この為、アルゴリズムの詳細は各サブクラス内に内包され、カプセル化が実現できます。

このテンプレートメソッドは、アルゴリズムの流れを共通化する必要がある場合・具体的な処理を異なるサブクラスで実装する必要がある場合において活用されます。

このテンプレートメソッドを活用する場面としては、例えばログイン認証などが挙げられます。ユーザー名とパスワードで認証する場合と、OAuthで認証する場合とでは、認証の具体的な処理が異なります。共通のアルゴリズムであるログイン処理を抽象クラスに定義し、各認証方式で異なる具体的な処理をサブクラスで実装することが考えられます。

何故テンプレートメソッドを用いたのか?

まず初めに、どんな新機能を開発していたのか概要と経緯を簡単にご説明します。

今回実装したのは「PDFダウンロード機能」というもので、STARTUP DBに掲載されいてる企業の詳細情報をPDFで出力することを可能にする機能になります。「スタートアップならびに投資家ページにて、詳細データをエクセルでダウンロードができるものの、多くの情報をエクセルで確認し、社内共有するには適していなかった」といったお声を多く頂き、今回の実装に取り組みました。

下記がSTARTUP DBが提供するPDFのサンプル画像になります。

pdf_sample

今回テンプレートメソッドを用いて実装に取り組んだのには大きく2つの理由がありました。

1つ目に、各項目間で大まかなアルゴリズムが共通していたので重複箇所をなるべく減らしたかった事が挙げられます。

下記が具体的な共通するアルゴリズムの内容になります。

  • 大見出しを出力する
  • 項目名を出力する
  • 項目毎にそれぞれ情報を入力する
  • 間隔を空ける

サンプルPDFを再度見て頂くと分かるかと思いますが、デザイン自体は各項目ほぼ統一されており、違いとしては出力される情報のみでした。テンプレートメソッドを活用することで、大まかなアルゴリズムの流れが定義されたクラスを作成することが出来、テンプレートメソッドのメリットを十分享受出来ると考えました。

2つ目に、保守性・拡張性が高い機能を実装することが出来ることが挙げられます。

PDFダウンロード機能はSTARTUP DBにおいて、今後も改修される可能性が比較的高い機能ということもあり、実装にあたり保守のし易さや掲載する情報が増えた際の拡張のし易さが設計において重要な点でした。

テンプレートメソッドを活用すれば、抽象クラスを参照することで大まかな処理の内容を理解する事が出来るので、保守がし易くなります。また新しい情報を追加する場合は、その情報に関する処理内容を抽象クラスを継承したサブクラスを新たに作れば済みます。前述の2点からテンプレートメソッドを活用する事で重要な点をカバーすることが出来ると判断しました。

以上の2点から今回の実装でテンプレートメソッドを用いることを決めました。

テンプレートメソッドを用いて実装してみた

下記が実装した抽象クラスと具象クラスのコードになります。

<抽象クラス>

class Download::Company::Pdf::Contentdef 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ダウンロード機能が改修される際に、理解のし易い構造にしたいという思いがあったので、空メソッドで定義し具象クラス側で必要な処理を定義する方針で実装しました。

実装してみて

「今日の自分のコードは明日の他人のコード」という言葉があるように、自身の書いたコードを振り返った際に読み返せないようでは良い設計とは言えないです。

実装を終え、数ヶ月ぶりに改めてコードを読み返し、抽象クラスに処理内容がまとまっていることで直ぐに処理の概要を掴むことが出来る点はテンプレートメソッドを活用して良かったなと改めて感じました。

また各具象クラスが何をしているのか、クラス単位でまとまっているのでそれぞれの項目でどのような処理が行われているのかも、理解しやすいと感じました。ありがたい事に、レビューをして頂いた方からは処理内容が理解し易かったというお言葉も頂きました。

一方で、自身でコードの再レビューを通して改善したい点が出て来ているので、引き続き自身のコーディングスキルを向上させるべく精進していきたいと思います。