Help us understand the problem. What is going on with this article?

実例で分かるデザインパターン ~Webスクレイピングツールを例にして~

More than 1 year has passed since last update.

概要

 オブジェクト指向プログラミングでよく話題になる単語の一つとして、「デザインパターン」と呼ばれるものがあります。
 これは有用な設計パターンに名前を付けて分類したもので、一般的にはGoFのデザインパターン23種を指します。
 今となっては古い……というより言語仕様に吸収されてしまったパターンも多いですが、それでもこれについて学ぶことはソフトウェア設計を学ぶ上で重要だと思われます。
 今回は、その中でもよく使われる一つである「Abstract Factoryパターン」について、適用前と適用後でどうコードが変わったのかについて具体例を交えつつ解説します。

Webスクレイピングについて軽く説明

 Webスクレイピングとは、「Webページ・Webサイトを分析してデータを取り出す」手法のことです。具体的には、次のようなステップが踏まれることが多いです。

  • Webページ(大抵はHTML)をダウンロードする
  • ダウンロードしてきたデータをパース(構文解析)し、含まれる情報を取り出しやすくする -データを取り出し、必要に応じてフィルタ・整形・保存する

 ここで、ダウンロードする機能やパースする機能は、言語標準機能やライブラリなどで簡単に手に入ることが多いです。例えばRubyの場合、パース部分にはNokogiriという有名なライブラリやその派生系をインストールして使うことになるでしょう。
 単なるNokogiriでも、CSSセレクタXPathセレクタによって対象を指定し、当てはまるタグの要素やその内側のタグなどを分析することができます。派生系のMechanizeCapybaraになりますと、BASIC認証やSSLが簡単に扱えたり、JavaScriptで動的に生成されたページも扱えたりするようになります。ただ、話が本筋から逸れますので、それらの細かな話は別のサイトに当たられることをお勧めします。
  Rails スクレイピング手法 Mechanizeの使い方 - Qiita
  Rubyでスクレイピング - Qiita ※Capybaraの解説記事

# Webスクレイピングのサンプル(るりまサーチを例にして)
require 'open-uri' # ダウンロード用のライブラリ
require 'nokogiri' # パース用のライブラリ

Encoding.default_external = "UTF-8" # 内部のエンコーディングをUTF-8にしておく
keyword = "include" # 検索キーワード

# 検索用URLを作成
url = "https://docs.ruby-lang.org/ja/search/query:#{keyword}/"
# ダウンロード処理(charsetに対象サイトのエンコーディングが入る)
charset = nil
html = open(url){|f|charset = f.charset; f.read}
# パース処理
doc = Nokogiri::HTML.parse(html, nil, charset)
# 分析処理(ここでは各メソッド名と返り値の文字列を取り出している)
method_name_list = []
doc.css('dt.entry-name > h3 > span.signature').each{|node|
  method_name_list.push(node.inner_text.gsub(/\n +/, ''))
}
# 出力処理
method_name_list.each{|method_name|
  puts "・#{method_name}"
}

デザインパターンが無いことで起きた問題

 当時作っていたツールは、「複数のWebサイトを横断検索し、結果をデータベースに保存する」といった内容のものでした。
 大まかなロジックをRubyで書くと、おおよそこんな感じのコードになるでしょう。これだけだと、特に難しい点は無いと思います。

require 'open-uri'
require 'nokogiri'
require 'parallel' # 複数のWebサイトを同時にDLするために使用

# サイトデータを事前に構築しておく
site_list = make_site_list()
# スレッド数はお好みで
data_hash = {} #データを蓄えるハッシュ
Parallel.each(site_list, in_threads: 10) do |site|
  # ダウンロード・リトライ機能などを内包したダウンローダーのクラスを初期化
  downloader = Downloader.new
  # 検索結果をダウンロード・パースする
  # (download_htmlメソッドには、リトライ機能や時間待ち機能を内包させておく)
  doc = downloader.download_html(site.search_url)
  # 検索結果の各項目を分析し、それぞれダウンロードしていく
  doc.css('hoge > fuga').each{|node|
    # 項目名と詳細URLを分析で読み取る
    name = node.inner_text
    detail_url = node.css('a').attribute('href').value
    # 各URLをダウンロード・分析し、項目名と合わせる
    # (RubyにはGILがあるので書き込みにロック処理が必要ない)
    doc2 = downloader.download_html(detail_url)
    info = {:piyo => doc2.css('piyo').inner_text, :poyo => doc2.css('poyo').inner_text}
    data_hash[name] = info
  }
end
# ダウンロード結果を保存する
db = Database.new #架空のデータベース
data_hash.each{|name, info|
  db.execute("INSERT INTO data(name, piyo, poyo) VALUES ('#{name}', '#{info[:piyo]}', '#{info[:poyo]}')")
}

 ただ、このロジックを複数サイトに対応させようとすると、次のような問題が発生しました。

場合分けが面倒

 上の擬似コードでは、「検索結果から項目名・詳細URLを読み取る処理」や「詳細URLから詳細情報を読み取る処理」などが決め打ちされていました。ところが、実際のWebサイトはサイト毎に仕様がバラバラです。
 サイトAでは「'hoge > fuga'」だったものがサイトBでは「'piyo > poyo'」だったり、上手く絞り込むために「doc.css(~).css(~)」とどんどん積み上がっていくこともあります。また、サイトによってはAPIが提供されており、APIを叩くだけで検索結果を取り出せるといったこともありました。
 こうした一癖も二癖もあるサイト達を単純な場合分けで対処しようとすると、当然ながらコードがゴチャゴチャしていきます。コメントをいくら振ったとしても、対応サイトの追加・修正が面倒くさいといった問題は解決しませんでした。

# だいたいこんな風になってしまう例
detail_url_hash = {}
case site.name
# サイトAの場合、JSONを解析してURLを取り出す
when "siteA" then
  json = downloader.download_json(site.special_url)
  json.each{|key, value|
    detail_url_hash[key] = "#{value.url}/detail"
  }
# サイトBの場合、記述が特殊なので場合分けを行う
when "siteB" then
  doc.css().css().each{|node|
    detail_url_hash[node.css('span').css('name').inner_text] = node.css('h1.name').css('a').inner_text
  }
# その他の場合
else
  # pattern1~pattern3は、CSSセレクタの文字列をサイト毎に格納したハッシュ
  doc.css(pattern1[site.name]).each{|node|
    detail_url_hash[node.css(pattern2[site.name]).inner_text] = node.css(pattern3[site.name]).inner_text
  }
end

コードをコピペするのは辛い

 当然、上記のようなコードでは保守性が極めて悪くなってしまいます。
 そこで次に思いついたのが、「サイト毎にコードを分ける」といったものでした。
 これにより保守性は改善されましたが、「似たようなコードを別々のサイトで書いてしまう」といった問題が出てきてしまいました。共通部分をメソッドとして無理やり括りだすこともできますが、それはそれで内部構造が分かりにくくなってしまうデメリットがあります。

# 一見美しいが、要するに個別ファイルに分割しただけ
download_data = {}
case site.name
when "siteA" then download_data_a()
when "siteB" then download_data_b()
else download_data_other(site.name)
end
# 結果をマージする
data_hash.merge!(download_data)

デザインパターンによるコードの改善

 設計について頭を悩ませていた時に知ったのが、「Abstract Factory」というデザインパターンです。ポイントとしては次の3点。

  • ロジックを纏めたクラス達(A1,A2,...)について、その共通部分となる基底クラスBと、B型のインスタンスを生成するためのファクトリクラスFを用意する
  • Fのメソッド(例えばF.create)において、引数などでA1,A2,...のどのクラスを生成するか指定し、返り値を基底クラスBの型として受ける。要するに「B hoge = F.create("A1");」といった風に書けるようにする
  • また、基底クラスBには基本的なメソッドを用意しておき、非共通部分は仮想メソッドにして派生クラスA1,A2,...でオーバーライドさせる

  • これにより、「B hoge = F.create("A1");」とインスタンスを生成したメソッドからは派生クラスの中身を考えずともhoge.execute();」などと使用でき、また派生クラス側からはコードの共通化とポリモーフィズムといったメリットが得られる

 これらをRubyで表現すると、まず基底クラスBはこういった定義になり、

# 基底クラス
class CrawlerBase
  # コンストラクタ(共通部分)
  def initialize
    # ダウンローダー部分を初期化
    @downloader = Downloader.new
  end
  # スクレイピングを実行する(共通部分)
  def execute
    # {項目名, 詳細URL}の一覧を取得する
    data_hash = get_data_hash()
    # {項目名, 詳細情報}の一覧を取得する
    detail_data_hash = {}
    data_hash.each{|name, url|
      # 詳細URLから詳細情報を得る
      data_info = get_data_info(url)
      # 一覧に追加
      detail_data_hash[name] = data_info
    }
    # 結果を返す
    return detail_data_hash
  end

  # Webページをダウンロード(共通部分)
  def download_page(url)
    # ページを取得
    retry_count = 0
    begin
      doc = @downloader.download_html(url)
      sleep(1000)
      return doc
    rescue
      retry_count += 1
      puts "retry...[#{retry_count}/3] #{url}"
      sleep(1000)
      retry if retry_count < 3
      raise # 3回以上リトライ=諦める
    end
  end

  # {項目名, 詳細URL}の一覧を取得する(抽象メソッド)
  def get_data_hash
    return {}
  end

  # 詳細URLから詳細情報を得る(抽象メソッド)
  def get_data_info(url)
    return {}
  end
end

 次にファクトリクラスFはこんな定義になります。

# ファクトリクラス
class CrawlerFactory
  # インスタンスを生成する
  def self.create(site_name)
    case site_name
    when "SiteA"
      return CrawlerA.new
    when "SiteB"
      return CrawlerB.new
    when "SiteC"
      return CrawlerC.new
    end
  end
end

 最後に、基底クラスBを継承して利用する各クラスA1,A2,...はこういった定義になります。

# 一番スタンダードなパターン
class CrawlerA < CrawlerBase
  # コンストラクタ
  def initialize
    super
    @site_name = "SiteA"
  end
  # {項目名, 詳細URL}の一覧を取得する
  def get_data_hash
    doc = download_page("site_a.com/result")
    data_hash = {}
    doc.css('hoge').each{|node|
       data_hash[node.css('h1').inner_text] = node.css(`a`).inner_text
    }
    return data_hash
  end
  # 詳細URLから詳細情報を得る
  def get_data_info(url)
    doc = download_page(url)
    data_info = {}
    data_info[:piyo] = doc.css(`span > x`).inner_text
    data_info[:poyo] = doc.css(`span > y`).inner_text
    return data_info
  end
end

# executeを書き換えてしまうパターン
class CrawlerB < CrawlerBase
  # コンストラクタ
  def initialize
    super
    @site_name = "SiteB"
  end
  # スクレイピングを実行する
  def execute
    # {項目名, キーワード}の一覧を取得する
    json = @downloader.download_json("site_b.com/result.json")
    json.each{|key, value|
      data_hash[key] = "#{value}"
    }
    # {項目名, 詳細情報}の一覧を取得する
    detail_data_hash = {}
    data_hash.each{|name, keyword|
      # APIを叩いて詳細情報を得る
      json = @downloader.download_json("site_b.com/#{keyword}/detail.json")
      # 一覧に追加
      detail_data_hash[name] = json
    }
    # 結果を返す
    return detail_data_hash
  end
end

 上記のクラスを使用する際は、ファクトリクラスでインスタンスを生成し、そこからメソッドを叩くだけで結果が返ってくることになります。各サイト毎に固有の部分と共通部分が分かれているので修正・追加が容易になりました。

require 'open-uri'
require 'nokogiri'
require 'parallel' # 複数のWebサイトを同時にDLするために使用

# サイトデータを事前に構築しておく
site_list = make_site_list()
# スレッド数はお好みで
data_hash = {} #データを蓄えるハッシュ
Parallel.each(site_list, in_threads: 10) do |site|
  # ダウンロード・リトライ機能などを内包したダウンローダーのクラスを初期化
  crawler = CrawlerFactory.create(site.name)
  # 検索結果をダウンロードし、ハッシュに追加する
  data_hash.merge!(crawler.execute)
end
# ダウンロード結果を保存する
db = Database.new #架空のデータベース
data_hash.each{|name, info|
  db.execute("INSERT INTO data(name, piyo, poyo) VALUES ('#{name}', '#{info[:piyo]}', '#{info[:poyo]}')")
}

まとめ

 デザインパターン(今回はAbstract Factory)を使用することにより、複雑だったコードが、相当見通しよくなりました。
(上記擬似コードでは「例外処理」など細かな要素を省きましたが、追加することはさほど難しくありません)

YSRKEN
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした