xmlstarlet(1) の力を借りて Bash スクリプト内で XML を操作する

前回、jq(1) を用いて JSON フォーマットでやったのの XML 版です。 ∥ jq(1) の力を借りて Bash スクリプト内で JSON を操作する - Qiita

要は、XML はブロック指向にデータをシリアライズするので、それを Bash の変数に格納して、Bash スクリプトでも複雑な構造化データを扱えるようにしよう、という趣旨です。

入手性の高さでは xmllint に分があるのですが、xmllint だと「編集(更新)」「削除」等の機能がないので、かわりに xmlstarlet を使います。パッケージなり Homebrew なりで入れてください。 ∥ XMLStarlet Command Line XML Toolkit: News

XML リテラル

XML リテラルは、スクリプト中で以下のように書こうと思います。一旦 xmlstarlet(1) を通せば、とりあえず XML が “well-formed” であることは保証されます。

members=$(xmlstarlet sel -B -t -c '.' <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<members>
  <member id="123">
    <name>Alice ALICE</name>
    <phone>123-456</phone>
  </member>
  <member id="456">
    <name>Bob BOB</name>
    <phone>456-789</phone>
  </member>
  <member id="789">
    <name>Charlie CHARLIE</name>
    <phone>789-000</phone>
  </member>
</members>
EOF
)

オプションの -t (--template) と -c (--copy-of) はお馴染みですが、ここでのミソは -B (--noblanks) で、出力からスペースを取り去り、minify して変数に格納します。私は、どちらかというとロングな形式のオプション派なのですが、ここではさすがにタイプ量が増えて冗長にすぎるので、ショートの形式を使います。

結果は、<members><member id="123"><name>Alice ALICE</name><phone>123-456</phone></member><member id="456"><name>Bob BOB</name><phone>456-789</phone></member><member id="789"><name>Charlie CHARLIE</name><phone>789-000</phone></member></members> のような一行のデータになります。

value に改行が入っていると一行になりませんが、まあいいです(Bash さんは、改行が入っていても気にとめない)。

ループしつつ参照

こんな感じが、一番よくある使い方かと思われます。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail

members=$(xmlstarlet sel -B -t -c '.' <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<members>
  <member id="123">
    <name>Alice ALICE</name>
    <phone>123-456</phone>
  </member>
  <member id="456">
    <name>Bob BOB</name>
    <phone>456-789</phone>
  </member>
  <member id="789">
    <name>Charlie CHARLIE</name>
    <phone>789-000</phone>
  </member>
</members>
EOF
)

first() { true; }
while read member
do
  ! first && echo "--------"
  echo ID: "$(xmlstarlet sel -t -m 'member' -v '@id' <<<"$member")"
  echo Name: "$(xmlstarlet sel -t -m 'member/name' -v '.' <<<"$member")"
  echo Phone: "$(xmlstarlet sel -t -m 'member/phone' -v '.' <<<"$member")"
  first() { false; }
done < <(xmlstarlet sel -t -m members/member -c . --nl <<<"$members")

実行結果:

$ ./xmlloop
ID: 123
Name: Alice ALICE
Phone: 123-456
--------
ID: 456
Name: Bob BOB
Phone: 456-789
--------
ID: 789
Name: Charlie CHARLIE
Phone: 789-000

検索

次は、検索です。xpath が使えるので、スッキリ書けますね。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail

members=$(xmlstarlet sel -B -t -c '.' <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<members>
  <member id="123">
    <name>Alice ALICE</name>
    <phone>123-456</phone>
  </member>
  <member id="456">
    <name>Bob BOB</name>
    <phone>456-789</phone>
  </member>
  <member id="789">
    <name>Charlie CHARLIE</name>
    <phone>789-000</phone>
  </member>
</members>
EOF
)

searchFirstMatchedAndPut() {
  local id=$1
  local member=$(xmlstarlet sel -t \
   -m "members/member[@id=$id]" -c . -n <<<"$members" | head -n 1 )
  if test -n "$member"
  then
    echo ID: "$(xmlstarlet sel -t -m 'member' -v '@id' <<<"$member")"
    echo Name: "$(xmlstarlet sel -t -m 'member/name' -v '.' <<<"$member")"
    echo Phone: "$(xmlstarlet sel -t -m 'member/phone' -v '.' <<<"$member")"
  else
    echo ID: $id not found.
  fi
}

first() { true; }
for id in 456 999
do
  first || echo "--------"
  searchFirstMatchedAndPut $id
  first() { false; }
done

実行結果:

$ ./xmlsearch
ID: 456
Name: Bob BOB
Phone: 456-789
--------
ID: 999 not found.

更新と追加

更新は、xpath が使えるのでスッキリ書けますね。

追加に関しては、jq(1) で JSON 断片を記述して突っ込んだように、XML 断片をテキストで書いて突っ込む方式が採れなさそうですので、ed コマンドを列挙してチマチマと構築せざるをえませんでした。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail

members=$(xmlstarlet sel -B -t -c '.' <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<members>
  <member id="123">
    <name>Alice ALICE</name>
    <phone>123-456</phone>
  </member>
  <member id="456">
    <name>Bob BOB</name>
    <phone>456-789</phone>
  </member>
  <member id="789">
    <name>Charlie CHARLIE</name>
    <phone>789-000</phone>
  </member>
</members>
EOF
)

members=$(xmlstarlet ed -P -O \
  -s "/members" -t elem -n "member" -v ""\
  -s '/members/member[last()]' -type attr -n id -v "999" \
  -s '/members/member[last()]' -type elem -n name -v "David DAVID" \
  -s '/members/member[last()]' -type elem -n phone -v "999-999" \
  <<<"$members" )

members=$(xmlstarlet ed -P -O \
  -u "members/member[@id=456]/phone/." -v "XXX-XXX" <<<"$members" )
members=$(xmlstarlet ed -P -O \
  -u "members/member[@id=789]/phone/." -v "YYY-YYY" <<<"$members" )

xmlstarlet fo <<<"$members"

実行結果:

$ ./xmlupdate
<?xml version="1.0"?>
<members>
  <member id="123">
    <name>Alice ALICE</name>
    <phone>123-456</phone>
  </member>
  <member id="456">
    <name>Bob BOB</name>
    <phone>XXX-XXX</phone>
  </member>
  <member id="789">
    <name>Charlie CHARLIE</name>
    <phone>YYY-YYY</phone>
  </member>
  <member id="999">
    <name>David DAVID</name>
    <phone>999-999</phone>
  </member>
</members>