13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

elm-xml-decodeつくった

Last updated at Posted at 2017-12-16

背景

以前Elmでサーバサイドで動くワーカープログラムを作るを書いたときに触れましたが、XML APIを相手にしたElmアプリを書きたくて、どんなパッケージがあるか調べました。すると、

Elmアドカレの9日めにこんな記事:

を書いたことからわかるように、このような目的ではDecoderを単位とするcomposableなAPIが大変便利だと思っていますので、それを実現したかった。

また、XPathのように、標的とするXML要素までのパスを指定して掘り出すようなAPIを導入しているものが見当たらず、複雑な構造のXMLを相手にするのが面倒になりそうな印象を受けていました。

ということでymtszw/elm-xml-decodeを作りました。

elm-xml-decode

READMEにも書いてますが、

  • Provides Decoder-based APIs, sharing the spirit of Json.Decode
  • Also provides DSL-styled decoder compositions, as in Xml.Decode.Pipeline, Xml.Decode.Extra
    • They share the sprits of Json.Decode.Pipeline and Json.Decode.Extra, respectively
  • Handles list of XML node with identical tags, using ListDecoder type
  • Locates targeting XML nodes using "path" of tags, partially mimicking XPath

この辺をテーマとしました。+@で、いい感じのエラーを出力できるような工夫も少ししています。

工夫紹介

Decoder-based APIs

Decoder記事で散々書きまくったので、Decoderの良さ、面白さについてはそちらをご覧ください。
elm-xml-decodeではjinjor/elm-xml-parserをParserとして利用しているので、相手にするデータ構造(XMLノードの型表現)はこちらです。

type Node
    = Element String (List Attribute) (List Node)
    | Text String


type alias Attribute =
    { name : String, value : String }

すると、今回用意したいXml.Decode.Decoder aは以下のような型になります。

type alias Decoder a =
    Node -> Result Error a

あとで紹介しますが、ある程度細かくエラーを分類できるように、独自のError型を設けています。

はっきり言って、このように型定義できた時点で基本的な部分は完成だと思ってます。あとはパズルみたいなもんです。stringの例を挙げると、

{-| Decodes an `XmlParser.Node` into `String`.

  - If the node is `XmlParser.Text`, extracts its value.
  - If the node is `XmlParser.Element` AND contains a single `XmlParser.Text` child,
    extracts its value.
  - Otherwise fails.
  - 
If you want to extract values from node attribute, use [`stringAttr`](#stringAttr) and variants.

[xpn]: http://package.elm-lang.org/packages/jinjor/elm-xml-parser/latest/XmlParser#Node

    run string "<root>string</root>"
    --> Ok "string"
    run string "<root><nested>string</nested></root>"
    --> Err "The node is not a simple text node. At: /, Node: Element \"root\" [] ([Element \"nested\" [] ([Text \"string\"])])"

-}
string : Decoder String
string node =
    case node of
        Text str ->
            Ok str

        Element _ _ [ Text str ] ->
            Ok str

        _ ->
            Err <| DetailedError [] node (Unparsable "The node is not a simple text node.")

こんな感じです。

対象とするノードが、

  • Textノードであるか、
  • 単一のTextノードを子として持つElementノードである、

どちらかの場合であれば成功させているところがちょっと工夫+@です。XPathでは、テキストノードの値(CDATA)を取り出す場合(つまりノードの中の値に興味があるのであって、ノードそのものを必要としているわけではない場合)はpath末尾にtext()と指定することになりますが、今回のようにAPIレスポンスの中のデータを扱うのが主なユースケースであればだいたいはこのケースに該当するはずなので、textのようなDecoderを置くのは冗長だと思ったのが理由です。

(HTMLスクレイピングのような用途ではまた話は別になると思います。が、今回はXMLをターゲットとする都合上、扱えてもXHTMLだけなので、そのような用途であれば別パッケージになるかと思います)

テキストではなくattributeに興味がある場合はstringAttrなどのattribute用のdecoderを用意してあります。

また、attributeとテキスト両方を同時に扱いたいような複雑なノードについては、自由にDecoderを作ってもらう想定です。それができるのがDecoderの良いところです。

Decoder-based APIであるということは、自然にApplicativeスタイルな記述もできることになります。Xml.Decode.PipelineおよびXml.Decode.Extra.(|:)を同梱しました。

List of XML nodes and ListDecoder

これがXMLとJSONの違いが現れるところで、だいぶ悩みました。APIに関するコメント歓迎です。

XMLでは、文書そのものだけを読んだ場合、「あるノードがただひとつしかその階層に存在しないものなのか、あるいは複数ありうるものなのか」がわかりません

singular
<root>
  <tag>value</tag>
</root>
list
<root>
  <tag>value</tag>
  <tag>value</tag>
</root>

上記はいずれもvalidですが、前者では「<tag>は複数ありうるが今回はたまたま1つしかない」のか「そもそも<tag>は1つしかないのが正しい」のかが不明です。一方JSONでは、

singular
{"tag":"value"}

となっていれば"tag"は単一の値を持つフィールドで、

list
{"tag":["value"]}

となっていれば、"tag"は(今回はたまたま1つしか要素がないが)複数の値を配列で持ちうるフィールドであることが明白です。

(リクエストbodyとしては、後者のJSONのようなケースを前者のように略記できるようなAPIもあります。Elasticsearchとかそうですね。でも、レスポンスBodyの方ではあえてそのように非正規化された形式にすることはまずないと思われます。まともな人が作るAPIなら)

ということで、tagやpathを指定できるようなXMLのDecoderを作るにあたっては、使う側が目的の値が単一のものか複数あるものかを指定するAPIになるだろう、という直観がありました。ちなみにこれはelm-xmlなどでも同じです(tagtagsがある。ただし、APIが対称になっておらず、あまりcomposableでないと思う)。

私が選んだのは、Xml.Decode.ListDecoder aというもう一つの型を用意する方式でした。これは後で紹介するpath : List String -> ListDecoder a -> Decoder aと組み合わせて使います。例としては以下のような使い方になります。

someRecordDecoder : Decoder SomeRecord
someRecordDecoder =
    map2 SomeRecord
        (path [ "string", "value" ] (single string))
        (path [ "int", "values" ] (list int))

path関数はpathにマッチするNodeを必ずリストで処理するという一貫性のある関数にしています。singlelistはプリミティブなDecoder aからListDecoder aを生成できる関数で、

  • single : Decoder a -> ListDecoder aはpathにマッチするNodeがただひとつあることを期待し、存在しなかったり、複数あったりしたらエラー
    • decodeした結果としての値もaのまま
  • list : Decoder a -> ListDecoder (List a)はpathにマッチするNodeが0個以上あることを期待する。マッチしたNodeの中にdecodeできないものがあればエラー
    • 結果はList a
  • leakyListlistの亜種で、マッチしたNodeの中にdecodeできないものがあれば読み捨て、失敗はしない

となっています。そこそこ統一的な書き方ができ、読みやすいし誤解が少なくなっていると自分では思います。

なんちゃってXPath

すでに紹介したpath : List String -> List Decoder a -> Decoder aで、階層の深いXMLも簡単に扱えます。

exampleDecoder : Decoder ( String, List Int )
exampleDecoder =
    map2 (,)
        (path [ "string", "value" ] (single string))
        (path [ "int", "values" ] (list int))


run exampleDecoder
    """
    <root>
        <string>
            <value>SomeString</value>
        </string>
        <int>
            <values>1</values>
            <values>2</values>
        </int>
    </root>
    """
--> Ok ( "SomeString", [ 1, 2 ] )
  • 実装はList.filterをナイーブに使ったものなので、あまり速くないと思います。良いアルゴリズムがあったらぜひ教えてください
  • 幅優先探索になっています
  • root階層のtag名は省略できるようになっています
    • と言うか意図的に無視しています。正規のXMLであればただひとつのrootノードがあることは確定しているため
    • 一応、rootノードが想定したものと違うならばエラーとすべきではあるので、run(decodeString)やdecodeXmlの引数として指定できるようにすべきかも
  • 少し弱点があって、指定したpathの末端まで到達しなかった場合(pathの途中で探索対象が尽きた場合)も空リストをListDecoderに渡すことになり、失敗はしません
    • ユースケースが出てきたら、pathの末端以外で探索対象が尽きた場合はエラーにするvariantを用意しようかと思っています。

pathは指定されたpathを毎回最初から探索するので、同じようなpathにある複数の値を取ってきたい場合は重複する探索が複数回発生して非効率です。が、Decoderの特長を活かせばこの問題は容易に解決できます。

deepDecoder : Decoder ( String, List Int )
deepDecoder =
    map2 (,)
        (path [ "long", "way", "to", "string", "value" ] (single string))
        (path [ "long", "way", "to", "int", "values" ] (list int))

上記の例では"long", "way", "to"までダブっていますからもったいないですね。以下のようにできます。

deepDecoder2 : Decoder ( String, List Int )
deepDecoder2 =
    path [ "long", "way", "to"] (single leafDecoder)

leafDecoder : Decoder ( String, List Int )
leafDecoder =
    map2 (,)
        (path [ "string", "value" ] (single string))
        (path [ "int", "values" ] (list int))

ヒャッホウ。Decoderさまさまだぜ。こちらで試せます。

XPathをもう少し真面目に再現しようと思うなら、途中のpathを省略できるようにする(//記法)などを考える必要がありますが、可読性を下げる、曖昧さを導入する(、実装が面倒になる)などマイナス面も結構あると思うのであまり気は進みません。text()@attr記法などはDecoderで指定させるものとしているのでまあなくていいかなと思っています。

エラー表現

pathを使った場合など、可能な場合は指定したpathのどの位置でエラーが起きたか、その時の最近傍のノードはどのようなものだったかを返すようにしています。

type Error
    = SimpleError Problem
    | DetailedError (List String) Node Problem


type Problem
    = NodeNotFound
    | AttributeNotFound String
    | Duplicate
    | Unparsable String

例えば先程のdeepDecoder2を以下のようなノードの不足しているXMLに対して実行すると、

run deepDecoder2
    """
    <root><long><way><to>
        <string></string>
        <int><values>1</values></int>
    </to></way></long></root>
    """
--> Err "Node not found. At: /long/way/to/string/value, Node: Element \"to\" [] ([Text \"\\n \",Element \"string\" [] [],Text \"\\n \",Element \"int\" [] ([Element \"values\" [] ([Text \"1\"])]),Text \"\\n \"])"

このように、エラー発生地点とその近傍のNodeをダンプします。ただ、この例で分かる通りダンプするNodeはもう少しエラー発生地点に近づけられそうなので、あとで修正しておこうと思います。NodeダンプはElmのNodeを単にtoStringしていますが、formatNode的な関数で部分的なXMLとして表示したほうが読みやすそう。ちょっとサボっているところ。

終わり

そんなわけで、まだ色々改善点はありますが、Decoderで快適にXMLを解釈できるという大枠の目的は達成できていると思うので、作ってよかったと思っています。ぜひ使ってみていただけると幸いです。感想や文句バグレポート、Starなどお待ちしています。

また、XML APIを相手にしたい場合に、elm-lang/httpのHttp.getと同じように書けると楽そうです。それもおまけで作っておきました。

import Http.Xml
import Xml.Decode exposing (..)

type alias Example =
--  (略)

exampleDecoder : Decoder Example
exampleDecoder =
--  (略)

Http.Xml.get "http://example.com/xml" exampleDecoder
--> (略) : Http.Request Example

どちらもよろしくお願いします。

13
3
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
13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?