複数執筆者による共有Feedの解析と更新管理 Part2

これは元々2009/10/26にAmebloに投稿した記事です。

Feed(フィード)を共有するブログ

前回の記事までで、複数の執筆者が共有するWebFeed(フィード: RSS1.0/2.0/Atom1.0)から、
個々の執筆者による最新更新記事のデータを取得するまでは完了しましたので、
今回は、それらをどのように管理するかについてを記述します。


Blogaraでのユーザーやブログの管理

ユーザー/ブログテーブル

ブログ情報サイトBlogara:ブロガラでは、1個人や1テーマ公式サイトをユーザーと呼び、
それらのユーザーがBlogaraでの基本管理単位となります。
従ってミュージシャンのグループやお笑いのコンビなども、
個々人の名前が表に出ているようなら、グループ単位では無く基本的には個人単位での登録となります。

それぞれのユーザーは、ユーザーテーブルに1レコードを持ち、
各種記事更新サイト(blog/twitter/ニュース/コラムなど これらを総称してブログと呼ぶ)
を管理するブログテーブルに複数レコードを所有出来ます。

ユーザーテーブルはユーザーID/氏名/性別/生年月日/肩書/公式サイトURLなど各ユーザー固有の情報を管理し、
ブログテーブルは、ブログID/ブログURL/FeedURL/更新頻度などのブログ管理に必要なカラムを持ちます。
1ユーザーは複数のブログを所有し、1つのブログが複数のユーザーに関連付けられる場合もありますので、
ユーザーとブログの関係は、ユーザーIDとブログIDをカラムに持つブログユーザーテーブルに登録されます。


ブログの更新情報テーブル

ブログテーブルには記事題名や本文のような記事内容に関連するカラムは存在していません。
現在のBlogaraは、各ブログの最新更新1件分のデータだけを管理/保存していますので、
ブログとブログ更新情報(記事内容)は1対1の関係となり、ブログテーブルに各カラムを作成し、
ブログテーブルで更新情報を管理するという選択肢も有りました。

が、今後複数の更新情報を保持するような仕様変更を行うケースを考慮し、
さらにはブログテーブルが巨大化するのも好ましく有りませんので、
ブログID/記事題名/記事本文/更新日時などのカラムを持つブログ更新情報テーブルを利用しています。


ブログ更新記事の取得

Pull型でのFeed取得

BlogaraではcrontabにFeed取得/更新スケジュールを設定し、
定期的に更新プロセスを走らせ各Feed配信サイトへのリクエストを行い、
レスポンスとして返されたFeedデータから更新情報を取得しています。

2009年10月時点においてBlogaraには未だ数百件のブログが登録されているだけですが、
いずれ数千件以上登録されているであろうブログの更新情報取得を、
何も考えずに全てのブログを対象とし1つのプロセス上で行う事は賢い実装とは言えません。

これが自らが管理するサーバーを対象とした内部ネットワーク内での話なら多少強引な手法としてアリでしょうが、
外部ネットワークを経由した外部サーバーへの問い合わせについては、
ディレイやタイムアウトが発生する可能性を考慮する必要がある為、
大量のFeed取得リクエストを1つの待ち行列に詰め込む事はリスクが高すぎます。
これは大量のメールを配信するようなサービスと同様の課題になります。


ブログ更新頻度設定

その対策の一つとしてBlogaraでは、
更新処理対象のブログを選別し、毎回全てのブログの更新チェックをする事の無いようにしています。

これには、ブログ登録時に選択するブログ更新頻度設定を利用します。
これは数値1から9までで設定される値となり、執筆者がどのくらいの頻度でブログを更新するかによって決定されます。
# ここ最近約1ヶ月分の更新を目視で確認し手動で設定していますが、
# かなり手間の掛かる作業になっていますので、これも自動化すべき優先項目の一つになっています。

更新頻度1が設定されたブログは更新チェックプロセスで毎回Feedのリクエストを行い、
最も低い更新頻度9のブログは、約1%の確率で更新チェックを行います。
が、1%ともなると数日間更新が反映されない恐れもありますので、
サーバー/ネットワーク双方の負荷が軽くなるであろう毎日午前5時に行われる更新チェックでは、
Blogaraに登録されている全てのブログについてのチェックが行われます。


複数プロセスでの並列分散処理

結局一日一度は全ブログのチェックを実施するので、
やはりオーソドックスに並列分散処理という定番の実装を行います。

個々の処理間での依存性が高いケースでは、並列分散処理にもそれなりの設計が必要ですが、
今回のようなブログのFeedデータの取得と更新情報抽出という処理では、
個々のブログ間での依存性は皆無となりますので、単純に振り分けるだけで問題無い容易なケースとなります。
要するに、行列を1列に並ばせるのでは無く5列に分け、個々の行列の長さを短くするようなイメージです。


Blogaraでの更新チェックでは、
crontabに登録したスケジュールで直接呼ばれるコマンド(PHPスクリプト これを便宜上マスタープロセスと呼ぶ)で、
まず実行する更新チェックプロセス(これもPHPスクリプト 同様に実行プロセスと呼ぶ)数を決定し、
ブログ更新管理テーブルにマスター+実行プロセス数分のレコードを作成します。
マスタープロセスでは、
総実行プロセス数とその実行プロセスに割り当てられたシリアル番号(0から総実行プロセス数-1まで)を引数として、
各実行プロセスをバックグラウンドジョブとして起動します。

呼び出された実行プロセスでは、引数で与えられた総実行プロセス数($nProc)と、
この実行プロセスに割り当てられたシリアル番号($index)を使い、処理対象となるブログを決定します。
BlogaraでのブログID(idBlogカラム)はAUTO_INCREMENTな数値なので、
個々の実行プロセスで処理対象となるブログの選択は、
クエリのWHERE句で、WHERE idBlog%$nProc=$indexのように指定し、
DBからのSELECTでのレコード抽出時に行われます。

全ての実行プロセスが終了した時点で、
今回記事が更新されたブログを所有するユーザーが所属するテーマ関連のファイル(JSONとAtomFeed)を更新し、
一連のブログ更新チェック処理が終了します。


共有Feedでの更新情報管理

今回の本題

と、1ユーザー専用Feedの場合には相変わらず上記のようなシンプルな処理で問題無いのですが、
複数ユーザー共有Feedでは考慮すべき点が有ります。

共有Feedから個々のユーザーについての更新情報の取得は前回の記事で行いましたので、
超絶手抜き実装としては、Feedを共有するブログそれぞれに同一のFeedを設定し、
専用Feedブログと同様にそれらのFeedを持つブログ毎に、
何度も同一Feedのリクエストを行というプランも可能ですが、あまりにも無駄過ぎてお話になりません。

前回の記事の指針にも書いたように、複数ブログで共有するFeedについては、
更新チェック処理中に一度だけリクエストを行い、
そのFeedデータから共有する全てのブログの更新情報を取得するようにする必要が有ります。


マスター/スレーブ ブログ

そこでBlogaraでは、Feedを共有するブログを1つのマスターブログと複数のスレーブブログに分け、
Feedの情報を管理するのはマスターブログだけにし、
スレーブブログのFeedカラムはNULL値のままとします。

元々、ブログとしては存在するがFeedを配信しない残念なサイト用にFeedカラムにNULL値を許可し、
更新チェック処理での対象ブログ選択のクエリには、
WHERE句でfeed IS NOT NULLという指定を行う事で更新チェック対象から除外していますので、
NULL値が設定されているスレーブブログも自動的に更新対象外ブログとして扱われます。


このマスター/スレーブを導入する為に、ブログテーブルにはidMasterというカラムを追加し、
マスターブログであるならば0、スレーブブログならマスターブログのブログIDが格納されます。
また専用Feedの通常ブログはdefaultのNULL値のままとなります。

更新チェック処理では、対象ブログ選択時にidMasterの値も取得し、
それが0のブログはマスターブログである可能性があると判断し、
Feedを共有するスレーブのブログ(idMasterに今回のマスターのブログIDが格納されている)の
ブログIDとセレクタ(XPath式)を取得し、
マスターブログのFeed取得と解析の"ついで"に、それら全てのスレーブの更新情報の取得も行います。


マスターブログの選択

Feedを共有するブログの中でどのブログをマスターに設定するかですが、
マスターブログの削除や更新停止設定はスレーブにも影響しますので、
なるべく変更が無いブログを選択する事が望ましくなります。

共有Feedを配信するブログページでは大抵、全てのブログの更新情報を表示する一覧ページが有り、
また、個々の執筆者専用のページも用意されているケースが多くなりますので、
マスターブログはFeedを共有するそれらのブログの一覧ページとなり、
スレーブブログは個々の執筆者専用ページになるのが典型的な構成と言えます。

前回に取り上げた、 テーマ: アイドリング!!!に登録されているアイドリング!!!ブログ「煮詰まります!!!」や、
テーマ: NFLNFL JAPAN コラムでも、
それぞれの一覧ページがマスターページに設定され、
個々のメンバー/コラム執筆者の個別ページへのリンクと
Feedで執筆者を特定するセレクタ(XPath式)が設定されたブログがスレーブブログとなります。
また、マスターブログのセレクタには何も設定されていませんので、
誰の記事かは関係無くFeedを共有するブログの中での最新の更新記事(要するに専用Feedと同様に先頭の記事)
マスターブログの更新情報レコードとしてDBに登録されます。


上記2例の場合には、マスターブログとなるブログ一覧ページは、
それぞれアイドリング公式サイトとNFL Japan公式サイトが所有するブログという扱いとなるので、
独立したブログとして扱いやすいマスターブログとなります。
が、複数執筆者での共有Feedを利用し、ブログ一覧ページと個々人専用ページが存在しているとしても、
本来ならマスターブログとなる一覧ページをどのユーザーが所有するのか分類が難しいケースも有ります。

例を挙げると、テーマ: AKB48のグループ内ユニットノースリーブスのブログでは、
ブログを執筆する個々のメンバーは当然個人として登録されていますが、ユニット自体は登録されていません。
その為、通常はマスターブログとなるメンバー一覧ページを所有するユーザーが存在しませんので、
このケースでは五十音順での先頭となる小嶋陽菜氏のブログをマスターブログとして設定し、
//rss1:item[dc:subject='小嶋 陽菜'][1]/*のようなXPath式をセレクタとして指定する事で、
マスターブログでありながら個々人専用のブログの更新情報を管理するブログとなります。

このように少人数の共有Feedブログの場合には、一覧ページがマスターブログとなるのでは無く、
あるメンバーがマスターブログとなり、その他のメンバーがスレーブブログとなる構成の場合も有ります。


実装

Feedデータの取得と解析処理は、ネット経由での情報取得クラスのサブクラスとして実装していますが、
下記のコードはその一部分となります。

Feedデータからの更新情報取得では、まずFeedURLをリクエストし、
レスポンスとして返されたFeedデータをDOMDocumentクラスのloadXMLで読み込みます。
そのデータに対しそれぞれのブログで設定されたセレクタ(XPath式)を使い(未設定の場合は先頭のitem/entryノード)、
更新記事情報として必要なデータ(題名/本文/更新日)を取り出すretrieveFeedDataメソッドを呼び出します。

  protected function initDocument( &$head, &$dataStr ) {
    if ( !$dataStr ) return NETCHK_EMPTYDATA;

// headのContent-Typeにはtext/htmlとあるが、bodyはxmlデータという面倒なサイトに対応
    if ( stripos($head['Content-Type'],'xml') !== FALSE || (preg_match('/<?xml /i',$dataStr) && !preg_match('/<html\W/i',$dataStr)) ) 
    {
$head['_meta']['type'] = 'xml'; if ( !@$this->doc->loadXML($dataStr) ) return NETCHK_INVALIDXML; } else if ( !$head['Content-Type'] || stripos($head['Content-Type'],'html') !== FALSE ) { $head['_meta']['type'] = 'html'; if ( !@$this->doc->loadHTML($dataStr) ) return NETCHK_INVALIDHTML; } else return NETCHK_UNKNOWNFORMAT; $this->xpath = new DOMXPath( $this->doc ); return TRUE; } public function checkUpdate( $feed, $lastMod=NULL, $ETag=NULL, &$slaves=NULL ) { // // ここでFeedURLからのFeedデータ取得処理やレスポンスヘッダの値による判定処理が有りますが省略。 // $ret = $this->initDocument( $head, $body ); if ( $ret !== TRUE ) return $ret; $ret = $this->parseFeedData( $slaves ); if ( !is_array($ret) ) return $ret; // // レスポンスヘッダのLast-ModifiedやETag関係の処理が有りますが省略。 } public function parseFeedData( &$slaves=NULL ) { $nodeList = $this->xpath->query( '/*[1]' ); $root = $nodeList->item(0); if ( !$root ) return BLOGCHK_INVALIDFEED; $ns = $this->doc->documentElement->lookupNamespaceURI( NULL ); $prefix = ''; if ( $ns ) { if ( isset($this->defPrefix[$root->nodeName]) ) { $prefix = $this->defPrefix[$root->nodeName]; $this->xpath->registerNamespace( $prefix, $ns ); $prefix .= ':'; } } if ( $slaves ) { $valids = array(); foreach ( $slaves as $idBlog=>&$data ) { if ( !$idBlog ) continue; $ret = $this->retrieveFeedData( $root->nodeName, $prefix, $data['selector'] ); if ( !is_array($ret) ) { $data['error'] = $ret; continue; } $data = array_merge( $data, $ret ); $valids[$idBlog] = $idBlog; } } else $ret = $this->retrieveFeedData( $root->nodeName, $prefix ); return ($slaves ? $valids : $ret); }

最後に

このように、複数執筆者(ユーザー)共有Feedにより更新情報が配信されるブログであっても、
1個人専用Feedと同様に個々のユーザー毎の記事更新情報の取得が問題無く行えますので、
対象の人物に関するより詳細なブログ更新情報を発信する事が出来ます。

あとは、各ブログサイト運営者の方々には是非、複数人で執筆するブログでは、
実際に記事を書いた人物を特定する情報をFeedに設定して頂きたいと願うばかりとなります。;)