背景
以前Elmでサーバサイドで動くワーカープログラムを作るを書いたときに触れましたが、XML APIを相手にしたElmアプリを書きたくて、どんなパッケージがあるか調べました。すると、
-
eeue56/elm-xml
- XMLのParseはできる。が、けっこう重要なバグがあり、しばらく放置されている。
- Elmの型の値へのdecodeもできるようになっているが、あまりcomposableでないAPIになってる。
-
- @jinjor さんのやつ。Parseはできるが、decoderはない。
-
- あとから存在に気づいたやつ。elm-xmlのAPIでXML =>
Json.Value
と変換し、coreのJson.Decode
を使うというアプローチ。 - 結果として
Decoder
によるcomposableなAPIを使えるが、ちょっと迂遠に思える。
- あとから存在に気づいたやつ。elm-xmlのAPIでXML =>
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では、文書そのものだけを読んだ場合、「あるノードがただひとつしかその階層に存在しないものなのか、あるいは複数ありうるものなのか」がわかりません。
<root>
<tag>value</tag>
</root>
<root>
<tag>value</tag>
<tag>value</tag>
</root>
上記はいずれもvalidですが、前者では「<tag>
は複数ありうるが今回はたまたま1つしかない」のか「そもそも<tag>
は1つしかないのが正しい」のかが不明です。一方JSONでは、
{"tag":"value"}
となっていれば"tag"
は単一の値を持つフィールドで、
{"tag":["value"]}
となっていれば、"tag"
は(今回はたまたま1つしか要素がないが)複数の値を配列で持ちうるフィールドであることが明白です。
(リクエストbodyとしては、後者のJSONのようなケースを前者のように略記できるようなAPIもあります。Elasticsearchとかそうですね。でも、レスポンスBodyの方ではあえてそのように非正規化された形式にすることはまずないと思われます。まともな人が作るAPIなら)
ということで、tagやpathを指定できるようなXMLのDecoder
を作るにあたっては、使う側が目的の値が単一のものか複数あるものかを指定するAPIになるだろう、という直観がありました。ちなみにこれはelm-xmlなどでも同じです(tag
とtags
がある。ただし、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を必ずリストで処理するという一貫性のある関数にしています。single
とlist
はプリミティブなDecoder a
からListDecoder a
を生成できる関数で、
-
single : Decoder a -> ListDecoder a
はpathにマッチするNodeがただひとつあることを期待し、存在しなかったり、複数あったりしたらエラー- decodeした結果としての値も
a
のまま
- decodeした結果としての値も
-
list : Decoder a -> ListDecoder (List a)
はpathにマッチするNodeが0個以上あることを期待する。マッチしたNodeの中にdecodeできないものがあればエラー- 結果は
List a
- 結果は
-
leakyList
はlist
の亜種で、マッチした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などお待ちしています。
- Elm package: ymtszw/elm-xml-decode
- GitHub: ymtszw/elm-xml-decode
また、XML APIを相手にしたい場合に、elm-lang/httpのHttp.get
と同じように書けると楽そうです。それもおまけで作っておきました。
- Elm package: ymtszw/elm-http-xml
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
どちらもよろしくお願いします。