argius note

プログラミング関連

ScalaでRSSフィードの処理を書いてみたら思ったより大変でした

ネットワークはJavaのコアAPIを、XMLはScalaのコアAPIを使えば、RSSフィードの処理ってちょー簡単に書けるのでは?
ふとそう思って、試しにScalaの練習も兼ねて書いてみましたが、そんなに簡単じゃないことが分かりました。難しいというわけじゃなく、落とし穴に何度か落ちたような感じです。今回はその辺についてまとめてみました。


先に書いておきますが、Scalaは悪くありません。Javaも悪くありません。たぶん。


環境

  • Java 1.7.0
  • Scala 2.10.0

要件

3年前のエントリで紹介したROMEみたいに汎用的な機能は不要で、フィードからはごく標準的な項目だけを収集できれば良いものとします。どうしても処理が複雑になってしまうところは、省略します。
今回は、フィードXMLの処理よりも、HTTPキャッシュを適切に処理して不要なダウンロードを回避するほうを重視しました。ブロードバンドとはいえ、更新されていないフィードを何度もダウンロードするのは避けたいですよね。


この程度のプログラムだったら、そんなに難しくないはず。作る前はそう思っていました。


JavaAPIによるHTTP処理の実装

機能は限定的に。

  • 圧縮はGZIPのみ対応
  • 更新が無かったら(≒HTTPステータスが304 "Not Modified" なら)取得しない。
    • =>実際は、200以外だったら取得しない。リダイレクトも無視。


HTTPについては、下記サイトが非常に分かりやすいです。


また、下記のコードは独自の実装です。高度なリファレンスコードとしてはApache HttpComponentsのような有名どころのOSSを参考にすると良いかも知れません。



大まかな処理はこのような感じになります。

import java.net.{ URL, HttpURLConnection }
import java.util.zip.GZIPInputStream

val url = new URL("...")
val conn: HttpURLConnection = // インスタンスの取得
  url.openConnection.asInstanceOf[HttpURLConnection]
// TODO リクエストヘッダの処理
conn.connect // 接続
val httpStatus = conn.getResponseCode // HTTPステータス取得
// TODO レスポンスヘッダの処理
if (httpStatus == 200)
  0 // TODO レスポンスデータの保存(RSSフィードのXML)
else
  0 // 何もしない
// TODO レスポンスヘッダを部分的に保存(次回のリクエスト時に使う)


圧縮形式の処理は、まずリクエストの"Accept-Encoding"ヘッダにクライアントがサポートしている圧縮形式を指定します。ここでは、gzipを指定しておけば、ほとんどのサーバに対応できると思います。

// リクエストヘッダの処理
// Accept-Encoding: gzip
conn.setRequestProperty("Accept-Encoding", "gzip")
conn.getHeaderField("Content-Encoding")
// =>gzipで圧縮されていれば以下のヘッダが返される
// Content-Encoding: gzip

レスポンスの"Content-Encoding"に"gzip"がセットされてきたら、レスポンスデータはGZIP圧縮されてきますので、GZIPInputStreamで読み込む必要があります。

import java.io.IOException
import java.util.zip.GZIPInputStream

// Content-Encoding: gzip
// レスポンスデータの保存(RSSフィードのXML)
val is = conn.getInputStream match {
  case null => throw new IOException("InputStream is null")
  case o => 
    conn.getHeaderField("Content-Encoding") match {
      case null => o
      case "gzip" => new GZIPInputStream(o)
      case x => throw new UnsupportedOperationException("Content-Encoding: " + x)
    }
}
try
  0 // TODO isからデータを読み取ってXMLとして保存
finally
  is.close

"Content-Encoding"ヘッダに対するパターンマッチの最後のパターンは、ありえないケースです。しかし、gzipしか指定していないのにgzip以外で返してくるサーバが無いとは限らない*1ので、万が一そのようなレスポンスが来たら、エラーにしてしまいます。



続いて、前回から更新があったかどうかを問い合わせるリクエストヘッダについてです。
HTTPの仕様(RFC 2616)では、大まかには"Last-Modified"と"ETag"があります。


"Last-Modified"は、最終更新日時です。この値で更新有無チェックを行うには、リクエストは"If-Modified-Since"ヘッダを指定します。日時には、RFC 1123で定義されているHTTP日付(HTTP-date)を使用します。
Javaの一般的なフォーマットに従えば、HTTP日付はこう表現できます。

new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz",
                               java.util.Locale.ENGLISH)
// "EEEEEE, dd-MMM-yy HH:mm:ss zzz" // 古い仕様 1999年以前しか無いはず
// "EEE MMM dd HH:mm:ss yyyy"       // ANSI C

HttpURLConnection(のスーパークラスのURLConnection)には、これをセットしてくれるメソッドがあります。

// 前回のレスポンス
// Last-Modified: Fri, 30 Aug 2013 19:45:00 GMT
val lastModifiedOpt: Option[Long] = conn.getLastModified match {
  case x if x > 0 => Some(x)
  case 0 => None // "Last-Modified"ヘッダが無い場合
  case x => None // たぶん有り得ない
}
// 今回のリクエスト
// If-Modified-Since: Fri, 30 Aug 2013 19:45:00 GMT
lastModifiedOpt match {
  case Some(x) => conn.setIfModifiedSince(x)
  case None => // skip
}
conn.connect


"ETag"はエンティティタグと呼ばれるもので、ハッシュ関数によるダイジェスト値の文字列が返されることが多いようです。もしくは、何かしらの一意になるシーケンス番号の場合もあります。フィードが更新される頻度を考えると、Javaのミリ秒でも良さそうです。
この値を使って更新有無をチェックするには、前回取得した"ETag"の値をリクエストの"If-None-Match"ヘッダに指定します。

// 前回のレスポンス
// ETag: "15878e1ccb328feb88cd18096c0a9355"
val eTagOpt: Option[String] = conn.getHeaderField("ETag") match {
  case null => None
  case x => Some(x)
}
// 今回のリクエスト
// If-None-Match: "15878e1ccb328feb88cd18096c0a9355"
eTagOpt match {
  case Some(x) => conn.setRequestProperty("If-None-Match", x)
  case _ => // skip
}
conn.connect


"If-Modified-Since"と"ETag"をサーバが解釈し、更新されていなければHTTPステータス304(Not Modified)が返されることが期待されます。


JavaAPIによるHTTP処理を動かしてみた結果

最初に、HTTP通信のモニタリングについて。

Fiddlerというツールを使うと、HTTPリクエスト&レスポンスを包括的に扱えて便利です。4大ブラウザ(IE,Firefox,Chrome,Opera)はプロキシすら不要で、それ以外もFiddlerをプロキシサーバにしてキャプチャできます。

JavaやScalaからFiddlerを使う場合は、Fiddlerのプロキシサーバを起動させ、JavaとScalaからは下記のシステムプロパティを指定します。

-Dhttp.proxyHost=localhost -Dhttp.proxyPort=8080

今回は、このFiddlerとFirefox(ver.22)を使って通信をモニタしました。一部IEも併用しています。



それでは、HTTP処理を動かしてみます......。


思った以上に、期待はずれのレスポンスが返ってきます。Firefoxでも同じ動きになります。

  • "Last-Modified"も"ETag"も帰ってこない(某動画サイト)
  • "Last-Modified"も"ETag"も正しく処理されているように見えるのにステータスが200
  • サーバがAp**heとか(ダミーかも?)
  • 独自ヘッダ多すぎ

(後半はキャッシュ関係ない)


RSSフィードは、同じページを何度も見るような性質があるのだから、この辺はちゃんとしておいたほうがいいんじゃないのかな、と思うのですが、見事に結果がバラバラです。
今回確認したフィードは平均で60KB程度(非圧縮)でした。もし100フィードのうち全部が、更新されていないのに 304 Not Modified を返さなかったら、無駄な通信が6MBも発生するわけですよね。



ヘッダが返されないものについてはどうしようもないので放っておくとして、更新されていないのにステータス200が返った場合だけは、ダウンロードしないように自前で処理することにします。

val httpStatus = conn.getResponseCode // HTTPステータス取得
if (httpStatus == 200)
  if (isModified())
    0 // レスポンスデータの保存(RSSフィードのXML)
  else
    0 // ステータス200でもダウンロードしない
else
  0 // ダウンロードしない

def isModified(): Boolean = {
  val reqETagOpt: String = ... // リクエストのETag文字列
  val resETagOpt: String = ... // レスポンスのETag文字列
  (reqETagOpt, resETagOpt) match {
    case (Some(v1), Some(v2)) if (isNotBlank(v1) && isNotBlank(v2)) => return v1 != v2
    case _ => // skip
  }
  val reqLastModified: Long = ... // リクエストのLastModified(Long値)
  val resLastModified: Long = ... // レスポンスのLastModified(Long値)
  (reqLastModified, resLastModified) match {
    case (Some(v1), Some(v2)) if (v1 > 0 && v2 > 0) => return v1 < v2
    case _ => // skip
  }
  true
}
def isNotBlank(s: CharSequence) = s != null && s.trim.length > 0

ScalaによるFeedパース処理

異なるフォーマットのフィードを、統一されたFeedデータオブジェクトに変換する処理を作ります。
フィードのフォーマットは、RSS 1.0、RSS 2.0、Atomを対象とします。
例として、RSS 2.0のXMLは大体こんな感じになっています。(DOCTYPEは省略)

<rss version="2.0">
  <channel>
    <title>(サイトのタイトル)</title>
    <item>
      <title>(エントリのタイトル)</title>
      <link>(エントリのページURL)</link>
      <pubDate>(エントリの発行日時)</pubDate>
      <description>(エントリの本文抜粋など)</description>
    </item>
    <item>
(後略)


ところで、Scalaは、とても簡単にXMLを処理できます。

import scala.xml.XML
import scala.xml.Elem
val feedXmlString: String = ... // RSS2.0のXML文字列
val feedElem: Elem = XML.loadString(feedXmlString)
// ファイルなら、XML.loadFile(file)
// URLなら、XML.load(url)

val channel: NodeSeq = feedElem \ "channel"
val title = channel match {
  case x if x.isEmpty => "(unknown title)"
  case x => x.text
}
// title = "(サイトのタイトル)"

但し、Scala 2.10 の時点では、標準APIXMLでは名前空間が使えないようです。名前空間を無視すれば、要素名(tag)は検索できますが、属性(attribute)は名前空間を無視しても検索できません。

// REPL
scala> ( <item><dc:date>Fri Aug 30 04:05:08 GMT</dc:date></item> \ "date" ) text
res0: String = Fri Aug 30 04:05:08 GMT

scala> ( <link url="http://example.com" /> \ "@url" ) text
res1: String = http://example.com

scala> ( <link nm:url="http://example.com" /> \ "@url" ) text
res2: String = ""

scala> ( <link nm:url="http://example.com" /> \ "@nm:url" ) text
res3: String = ""

scala>


以上を踏まえて、RSS 1.0、RSS 2.0、Atomのフィードをパースするコードを書いていきます。

  • データオブジェクト
import java.util.Date
case class FeedEntry(title: String, url: String, published: Date)
class FeedObject() {
  var title: String = ""
  var entries: Seq[FeedEntry] = Nil
  override def toString = s"FeedObject { title=$title, entries=$entries }"
}
  • バージョン判定
def detectVersion(feedElem: Elem): String = {
  feedElem.label.toLowerCase match {
    case "rss" =>
      (feedElem \ "@version").text match {
        case "2.0" => return "RSS 2.0"
        case _ => // next
      }
    case "rdf" => return "RSS 1.0"
    case "feed" => return "Atom"
    case _ => // next
  }
  throw new IllegalArgumentException("version cannot detected")
}

ここは簡単なコードに留めておきます。

  • HTTP日時の処理

発行日時はjava.util.Date型の値を取得するため、HTTP日付のパースを行います。
HTTP日付は、HTTP処理のところで登場したものと同じ書式です。
ここは、パターンが固定のものとしておきます。(何故かは後述)

import java.text.{ SimpleDateFormat, ParseException }
import java.util.Date
import java.util.Locale
def toDate(s: String): Date = {
  val df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz",
                                Locale.ENGLISH)
  try
    return df.parse(s)
  catch {
    case e: ParseException => // 無視
  }
  new Date(0L) // 不明な日時
}

日付タグは、dc:date です。

val o = new FeedObject
val channel = feedElem \ "channel"
o.title = (channel \ "title") text
o.entries = channel \ "item" map { x =>
  val title = (x \ "title").text
  val url =   (x \ "link").text
  val date =  toDate((x \ "date").text)
  FeedEntry(title, url, date)
}

今回見ているところでは、RDFタグがrssタグに、dc:dateタグがpubDateタグにそれぞれ変わっている以外はRSS 1.0と同じです。

val o = new FeedObject
val channel = feedElem \ "channel"
o.title = (channel \ "title") text
o.entries = channel \ "item" map { x =>
  val title = (x \ "title").text
  val url =   (x \ "link").text
  val date =  toDate((x \ "pubDate").text)
  FeedEntry(title, url, date)
}

深さが1レベル減っています。発行日時はpublishedタグですが、古いバージョンではissuedタグの場合もあるようです。

val o = new FeedObject
o.title = (feedElem \ "title") text
o.entries = feedElem \ "entry" map { x =>
  val title = (x \ "title").text
  val url =   (x \ "link").text
  val dateString = (x \ "published").text match {
    case "" => (x \ "issued").text
    case x => x
  }
  val date = toDate(dateString)
  toDate((x \ "pubDate").text)
  FeedEntry(title, url, date)
}

ScalaによるFeedパース処理を動かしてみた結果

日付がRFC 3339になっているものが多いです。Atomの日時はRFC 3339なのでそれは良いんですが、RSS2.0の日付もRFC 3339になっているケースが割とありました。
RFC 3339は、ISO 8601と互換性のあるインターネット標準日時表記の仕様で、例えば"2013-08-30T03:59:00Z"のような表記になります。
RSS 2.0の仕様(LINK)では、pubDateはRFC 822 形式で、年が2桁の場合も有り得るが4桁が優先、となっています。


Atomの場合はRFC 3339、RSSの場合はRFC 822、もしくはRFC 3339とすれば大抵のフィードは処理できそうです。


まともな例として、Wikipediaは、英語版も日本語版も、RSS 2.0の日時表記はちゃんとRFC 822になっています。AtomRFC 3339です。


困ったことに、日本語("2013年08月30日(金)" みたいになってる)で来てしまうものもあったりしました。こういった例外はキリが無いので、想定外の日付パターンが来たら、そこだけ外部スクリプトに投げる、という対応にしておく手もあります。



もうひとつの問題が、JavaのSimpleDateFormatでは、RFC 3339に完全に対応できないことです。"2013-08-30T19:59:00Z"や"2013-08-30T19:59:00 JST"なら処理できますが、RFC 3339のタイムゾーン書式は"+09:00"のようにコロン付きなのです。これだと、SimpleDateFormatは解析できません。
タイムゾーンの部分だけ別処理にする手もありますが、Joda-TimeのようなRFC 3339に対応したライブラリを使う方が賢明かも知れません。


まとめ

Atomは仕切り直しで作られただけあって、RSSよりはシンプルです。それでも結構変更があったり、標準化というのは難しいものですね。それに、RSSだけしか発行していないサイト、RSS 2.0すら無いサイトもそれなりにあるので、どちらにしても今回作った3パターンくらいはサポートしないとなりません。


思ったより苦労しましたが、Scalaだからこの程度で済んだんですよね。それと、Scalaだけじゃないことも勉強になりました。


おしまい。

*1:gzip以外を返すサーバが実際にあったわけではありません。