LoginSignup
19
18

More than 5 years have passed since last update.

Phoenix で XML をパースして郵便番号検索 API を作る

Last updated at Posted at 2015-09-13

Elixir で XML のパース処理を実装してみます。

今回は郵便番号検索 API に問い合わせて、その返却結果(XML)を JSON に変換して返す API を作ってみます。
比較的軽めの XML パース処理...ということで上記の API を選びましたが、このテクニックを応用させれば Web スクレイピングなども実装可能と思います。

なお、今回は Erlang の xmerl という XML ライブラリを直で使ってみます

Phoenix アプリケーションでの外部 API リクエストについてはコチラを参照してください。

Phoenix アプリケーションをセットアップする

xmlparse_sample というアプリケーションを作り、mix.exs に以下のように追記しましょう。

$ mix phoenix.new xmlparse_sample
mix.exs
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 で郵便番号が検索できるようにします。

router.ex
defmodule XmlparseSample.Router do
  ...
  scope "/api", XmlparseSample do
    pipe_through :api

    get "/postal-code/:code", PageController, :code_search
  end
  ...
end

次にコントローラです。受け取った code を元に API へ問い合わせます。

page_controller.ex
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 を使って目的の値を取得」という感じです。

page_controller.ex
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 の機能を使う際のオマジナイと捉えておきましょう。
今回は :xmlElementxmlAttribute を使いたいので、ソース上部に宣言してあります。

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/2Enum.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 の使い勝手はあまり良いとは言えない
    • xmlElementxmlAttribute といったヘルパーを使って値を取得していくのは、イマイチ直感的ではない...
  • Erlang のインターフェイスを使うのは骨が折れる
    • (Erlang に詳しくないと)そもそもの使い方を調べるのに一苦労する
      • Elixir を突き詰めていくと結局 Erlang になるとはこの事か...
    • スタックトレースも省略されるため、デバッグがしづらい
  • 結論、Elixir 用にラップされたライブラリを使ったほうが無難
    • xpath が使えるものがあるかは未調査
19
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
18