Elixir で XML のパース処理を実装してみます。
今回は郵便番号検索 API に問い合わせて、その返却結果(XML)を JSON に変換して返す API を作ってみます。
比較的軽めの XML パース処理...ということで上記の API を選びましたが、このテクニックを応用させれば Web スクレイピングなども実装可能と思います。
なお、今回は Erlang の xmerl
という XML ライブラリを直で使ってみます。
Phoenix アプリケーションでの外部 API リクエストについてはコチラを参照してください。
Phoenix アプリケーションをセットアップする
xmlparse_sample
というアプリケーションを作り、mix.exs に以下のように追記しましょう。
$ mix phoenix.new xmlparse_sample
defmodule XmlparseSample.Mixfile do
...
defp deps do
[{:phoenix, "~> 0.17"},
{:phoenix_ecto, "~> 1.1"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.1"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:cowboy, "~> 1.0"},
# HTTPoison を追記
{:httpoison, "~> 0.7.2"}]
end
end
忘れずにライブラリのダウンロードを行います。
$ mix deps.get
郵便番号検索 API に問い合わせる
一気に実装してしまいましょう。
まずはルーティングの設定からです。/api/postal-code/xxxxxxx
で郵便番号が検索できるようにします。
defmodule XmlparseSample.Router do
...
scope "/api", XmlparseSample do
pipe_through :api
get "/postal-code/:code", PageController, :code_search
end
...
end
次にコントローラです。受け取った code
を元に API へ問い合わせます。
defmodule XmlparseSample.PageController do
...
def code_search(conn, %{"code" => code}) do
HTTPoison.start
case HTTPoison.get! "http://zip.cgis.biz/xml/zip.php?zn=#{code}" do
%{status_code: 200, body: body} -> json conn, %{succeed: 200}
%{status_code: code} -> json conn, %{error: code}
end
end
...
end
特に変わったところはありませんね。
パースする XML の構造を確認する
実装の前に、今回パースする XML(郵便番号検索 API の返却)の構造を確認しておきましょう。
$ curl -s "http://zip.cgis.biz/xml/zip.php?zn=1060032" | xmllint --format -
<?xml version="1.0" encoding="utf-8"?>
<ZIP_result>
<result name="ZipSearchXML"/>
<result version="1.01"/>
<result request_url="http%3A%2F%2Fzip.cgis.biz%2Fxml%2Fzip.php%3Fzn%3D1060032"/>
<result request_zip_num="1060032"/>
<result request_zip_version="none"/>
<result result_code="1"/>
<result result_zip_num="1060032"/>
<result result_zip_version="0"/>
<result result_values_count="1"/>
<ADDRESS_value>
<value state_kana="トウキョウト"/>
<value city_kana="ミナトク"/>
<value address_kana="ロッポンギ(ツギノビルヲノゾク)"/>
<value company_kana="none"/>
<value state="東京都"/>
<value city="港区"/>
<value address="六本木(次のビルを除く)"/>
<value company="none"/>
</ADDRESS_value>
</ZIP_result>
郵便番号を投げると、対応する住所が返ってくる仕組みです。
今回は欲しいのは ADDRESS_value
にある state
, city
, address
とそれぞれに対応するルビです。
ちなみに、住所のような歴史の古いデータ構造には例外がツキモノです。
郵便番号周りでいうと、住所との紐づきが 1対1 ではなく 1対n だったりします。
$ curl -s "http://zip.cgis.biz/xml/zip.php?zn=6048072" | xmllint --format -
<?xml version="1.0" encoding="utf-8"?>
<ZIP_result>
<result name="ZipSearchXML"/>
<result version="1.01"/>
<result request_url="http%3A%2F%2Fzip.cgis.biz%2Fxml%2Fzip.php%3Fzn%3D6048072"/>
<result request_zip_num="6048072"/>
<result request_zip_version="none"/>
<result result_code="1"/>
<result result_zip_num="6048072"/>
<result result_zip_version="0"/>
<result result_values_count="2"/>
<ADDRESS_value>
<value state_kana="キョウトフ"/>
<value city_kana="キョウトシナカギョウク"/>
<value address_kana="ヤオヤチョウ"/>
<value company_kana="none"/>
<value state="京都府"/>
<value city="京都市中京区"/>
<value address="八百屋町(六角通寺町西入、六角通御幸町東入、六角通御幸町西入、六角通麩"/>
<value company="none"/>
</ADDRESS_value>
<ADDRESS_value>
<value state_kana="キョウトフ"/>
<value city_kana="キョウトシナカギョウク"/>
<value address_kana="ヤオヤチョウ"/>
<value company_kana="none"/>
<value state="京都府"/>
<value city="京都市中京区"/>
<value address="屋町東入、御幸町通麩屋町下る、麩屋町通六角下る)"/>
<value company="none"/>
</ADDRESS_value>
</ZIP_result>
つまり、返却する ADDRESS_value は Array にする必要があります。
XML をパースする
いよいよ本題です。
Elixir では Erlang の XML ライブラリ xmerl
が標準で利用できます。
流れとしては、「xmerl で XML をパース」 -> 「xpath を使って目的の値を取得」という感じです。
defmodule XmlparseSample.PageController do
require Record
use XmlparseSample.Web, :controller
Record.defrecord :xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")
Record.defrecord :xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")
...
@targets [:state, :city, :address, :state_kana, :city_kana, :address_kana]
def is_target?(attribute) do
xmlAttribute(attribute, :name) in @targets
end
def parse_address(address_node) do
attributes = :xmerl_xpath.string('//value', address_node)
|> Enum.map(&(xmlElement(&1, :attributes)))
|> Enum.map(fn elems ->
elems
|> Enum.filter(&is_target?/1)
end)
|> List.flatten
keys = attributes
|> Enum.map(&(xmlAttribute(&1, :name)))
values = attributes
|> Enum.map(&(to_string xmlAttribute(&1, :value)))
Enum.into(List.zip([keys, values]), %{})
end
def parse_xml(body) do
{document, _} = body
|> :binary.bin_to_list
|> :xmerl_scan.string
parsed = :xmerl_xpath.string('//ADDRESS_value', document)
|> Enum.map(&parse_address/1)
end
def code_search(conn, %{"code" => code}) do
HTTPoison.start
case HTTPoison.get! "http://zip.cgis.biz/xml/zip.php?zn=#{code}" do
%{status_code: 200, body: body} -> json conn, parse_xml(body)
%{status_code: code} -> json conn, %{error: code}
end
end
end
思いの外長くなってしまいました。
まず、code_search/2
の HTTP リクエスト成功時(status_code=200)に、返却された XML 文字列を parse_xml/1
に渡すようにします。
pasrse_xml/1
では、渡された文字列を文字リストへと変換して :xmerl_scan/1
へと渡し、解析のベースとなる XML エレメント(中身は構造化されたタプル)を取得します。
ちなみに、この XML エレメントに対して xmerl の関数を使って操作していくわけですが、これらは Erlang のインターフェイスなので Record を使って事前にマクロを定義しておきます。
あまり深く考えず、Erlang の機能を使う際のオマジナイと捉えておきましょう。
今回は :xmlElement
と xmlAttribute
を使いたいので、ソース上部に宣言してあります。
require Record
Record.defrecord :xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")
Record.defrecord :xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")
XML エレメントに対して xpath を使った検索をかけるのが :xmerl_xpath.string/2
です。
今回走査したい ADDRESS_value はルート直下にあるため、//ADDRESS_value
と書くだけで OK です。
parsed = :xmerl_xpath.string('//ADDRESS_value', document)
※ xpath は文字リストで表現する点に注意してください。ダブルクオーテーションではありません。
:xmerl_xpath
はリストを返すので、パイプライン演算子で各要素を parse_address/1
へと渡します。
parse_address/1
では、最初にまたしても :xmerl_xpath
を使って要素の抽出を行っています。今回は ADDRESS_value の中にある value を抽出します。
この XML は「値を attribute で表現する」という構造をしているため、一旦 attribute を全て取得する必要があります。
xmlElement/2
の第2引数に :attributes
を指定することで、要素の attribute を取得できます。
|> Enum.map(&(xmlElement(&1, :attributes)))
取得された attribute には不要な項目や予期せぬ項目が含まれる可能性があるため、必要なもののみを抽出します。
これが Enum.filter/2
のところなのですが、ちょっとデータ構造が複雑になってしまっている都合上、パイプライン演算子をネストしています(あまり良くない書き方かもしれません)
フィルタの条件は「定義済みの Key かどうか」で、Key の定義はモジュールのアトリビュートで表現しています。
必要な attribute が抽出できたら、最後に乱暴にList をフラット化します。
この段階で生成される attributes
の内容は以下のようなイメージです。
[
#xmlAttribute, #xmlAttribute, #xmlAttribute, ...
]
XML アトリビュートも、中身は構造化されたタプルで、:xmlAttribute/2
を使うことで key, value を取得できます。key を取る場合は第2引数に :name
を、value を取る場合は :value
を指定します。
あとは、これらの XML アトリビュートから key, value を抜き出してマップに詰めればいいわけです。
方法はいろいろあると思いますが、今回は key と value をそれぞれ別個で抜き出して、最後に List.zip/2
と Enum.into/2
の組み合わせでマップに変換しています。
keys = attributes
|> Enum.map(&(xmlAttribute(&1, :name)))
values = attributes
|> Enum.map(&(to_string xmlAttribute(&1, :value)))
Enum.into(List.zip([keys, values]), %{})
この段階で生成されるマップは以下のような感じです。
%{
"state" => "...",
"state_ruby" => "...",
"city" => "...",
...
}
parse_xml/1
の返却は、これが ADDRESS_value の数だけ生成されて詰め込まれたリストです。
最後にそれを JSON に変換して完了です。
json conn, parse_xml(body)
API を叩いてみる
まずは 1対1 のひも付きを持つ郵便番号を検索してみましょう。
$ curl -s "http://localhost:4000/api/postal-code/1060032" | jq .
[
{
"address": "六本木(次のビルを除く)",
"address_kana": "ロッポンギ(ツギノビルヲノゾク)",
"city": "港区",
"city_kana": "ミナトク",
"state": "東京都",
"state_kana": "トウキョウト"
}
]
いい感じですね。
次に 1対n のひも付きを持つ郵便番号を検索してみます。
$ curl -s "http://localhost:4000/api/postal-code/6048072" | jq .
[
{
"address": "八百屋町(六角通寺町西入、六角通御幸町東入、六角通御幸町西入、六角通麩",
"address_kana": "ヤオヤチョウ",
"city": "京都市中京区",
"city_kana": "キョウトシナカギョウク",
"state": "京都府",
"state_kana": "キョウトフ"
},
{
"address": "屋町東入、御幸町通麩屋町下る、麩屋町通六角下る)",
"address_kana": "ヤオヤチョウ",
"city": "京都市中京区",
"city_kana": "キョウトシナカギョウク",
"state": "京都府",
"state_kana": "キョウトフ"
}
]
これも意図通りです。
感想
-
xmerl
の使い勝手はあまり良いとは言えない-
xmlElement
やxmlAttribute
といったヘルパーを使って値を取得していくのは、イマイチ直感的ではない...
-
- Erlang のインターフェイスを使うのは骨が折れる
- (Erlang に詳しくないと)そもそもの使い方を調べるのに一苦労する
- Elixir を突き詰めていくと結局 Erlang になるとはこの事か...
- スタックトレースも省略されるため、デバッグがしづらい
- (Erlang に詳しくないと)そもそもの使い方を調べるのに一苦労する
- 結論、Elixir 用にラップされたライブラリを使ったほうが無難
- xpath が使えるものがあるかは未調査