😇

XSLT 1.0でどうしてもJSONを出力したいらしいときに汎用的に書く方法について検討する

2022/06/12に公開約4,800字

10年以上の技術負債を返済してわかったこと 〜 ヤフーのディスプレイ広告におけるレスポンス生成刷新事例を読みました。

記事からの引用部分は「引用」のように強調表記で示しています。

前提として、「XSLT で JSON を生成しているため実装が複雑で保守性が悪い」はそもそもの設計が現代と合っていないのでリプレース待ったなしだと思います。「そもそもサーバで生成した単純なXMLをクライアントサイドでXSLTを使ってJSONを生成する」ことについては現代では非効率と言わざるを得ません。これはXSLT 3.0になろうが変わらないので注意してください。
ただ、「負債と化していたXSLT 1.0の例を見るともうちょっと何とかしようはあるよね」という感じでしたので、「まずはXSLT設定を汎用的な 1種類に集約しました。」について、どんなアプローチがあるかというのを自分なりに検討してみました。もちろん負債やその解消方法については様々な事情があります。本記事はhidaurmaの思考トレーニング程度のことでしかありません。

中間のXML表現を作った方が分かりやすくできるのですが、要件がよく分かっていないので今回はそのままの形をできるだけ保持します。

チラシの裏

(「<xsl:value-of>はXMLの要素をそのまま埋め込むので」なんかも用語の混乱がある。XMLの話をするときは、いわゆるタグ名をelement(要素)、タグで囲まれた中はcontent(内容)として扱うのが良いです。XPath,XSLTではノードごとに文字列出力について決まりがあり、value-ofはそれに従って新規テキストノードを生成します。テキストノードは何らかの処理で出力される際、隣接するテキストノードが結合されます。ので「要素ノードを指定したとき、該当する要素ノードの内容を新規テキストノードとして出力します」などが意図するところだったのでしょうか。)

あと、「[]・{}・,といった要素を <xsl:text> で生成しなければならない」前提や要件が分かっていませんが、XSLT自体の話ではない気がする。

元XMLについて

<?xml version="1.0" encoding="UTF-8"?><!-- https://techblog.yahoo.co.jp/entry/2022060130304125/ -->
<Result>
  <URL>https://yahoo.co.jp</URL>
  <ID>1</ID>
</Result>

設計について言えば、URLに対してもIDに対しても付加すべき属性はなさそうなので、URLとIDが属性で良かったんじゃないかなと思わなくもないですが、まあ文書要素があるだけ良いです多分。

元XSLTについて

<!-- JSON を生成するサンプルの XSLT --><!-- https://techblog.yahoo.co.jp/entry/2022060130304125/ -->
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
  <xsl:output method="html" encoding="UTF-8" />
  <xsl:template match="/">
    <xsl:text>[</xsl:text>
    <xsl:text>{</xsl:text>
    <xsl:text>"url":"</xsl:text>
    <xsl:value-of select="Result/URL" />
    <xsl:text>",</xsl:text>
    <xsl:text>"id":</xsl:text>
    <xsl:value-of select="Result/ID" />
    <xsl:text>}</xsl:text>
    <xsl:text>]</xsl:text>
  </xsl:template>
</xsl:stylesheet>

どういった点が不満かと言えば、「決め打ちの部分が多い」ことでしょうか。「**」

ブラウザのXSLTって出力フォーマットにHTMLしか認めないんですよね確か。ブラウザにこだわらない場合、HTMLまたはXML以外では@methodtextにしましょう。これは今回はしゃーなしですね。それ以外についてはこの先で変更を検討します。

ルートノードのマッチテンプレートと文書要素のマッチテンプレートを分ける

<xsl:template match="/">
    <xsl:text>[</xsl:text>
	<xsl:apply-templates />
    <xsl:text>]</xsl:text>
  </xsl:template>

<xsl:template match="Result">
    <xsl:text>{</xsl:text>
    <xsl:text>"url":"</xsl:text>
    <xsl:value-of select="URL" />
    <xsl:text>",</xsl:text>
    <xsl:text>"id":</xsl:text>
    <xsl:value-of select="ID" />
    <xsl:text>}</xsl:text>
</xsl:template>

ルートノードと文書要素ノードのマッチテンプレートの分離は一見するとコードが増えただけに見えます。今回の場合、配列の開始・終了記号はResult要素とは独立していると判断し分離しました。今後複数のResultが来た場合に対応しやすくなります。今後はないんですが。

「Resultがこない場合」から書くべきだと思うんですが、まあ空の配列があるのは無害とします。

さて、Resultの内部はKey-Valueとなるような要素しか来ないと仮定します。

<xsl:template match="Result">
    <xsl:text>{</xsl:text>
    <xsl:apply-templates mode="kv"/>
    <xsl:text>}</xsl:text>
</xsl:template>

key-valueになるelement-textなXMLの処理

key-valueを形成する部分はmodeを分けるのが自然でしょう。今回はやりませんが、JSON用のエスケープ処理なども別modeを用意することになるかもしれません。

<xsl:template match="element()" mode="kv">
  <xsl:variable name="key" select="lower-case(local-name())" />
  <xsl:variable name="value" select="concat('&quot;', text(), '&quot;')"/>
  <xsl:variable name="keyQuoted" select="concat('&quot;', $key, '&quot;')" />
  <xsl:variable name="hasFolloing" select="boolean(following-sibling::*)"/>
  
  <xsl:value-of select="concat($keyQuoted, ':', $value)" />
    <xsl:if test="$hasFolloing"><xsl:value-of select="','"/>
    </xsl:if>
</xsl:template>

現在の要素ノードの要素名をlocal-name()で取得し、lower-case()で小文字化しています。端的には<URL>からurlが得られます。

$valueは要素が入れ子になった場合などに@selectではなく<xsl:apply-templates>に変更すれば、応用が利きます。

$hasFollowingは後続(兄弟)ノードがある場合に,を挿入しています。ここで挿入するよりもKey-Valueを開始する親ノードで入れた方が良い設計だと思いますが、XSLT 1.0では(xsl:value-of@separatorが使えない)連続するテキストノードを一気に取ろうとすると結合されて返ってしまうのでこのタイミングで挿入しています。

おわり

ここまでを反映し、処理してみます。

[{
"url":"https://yahoo.co.jp",
"id":"1"
}]

配列やマップ部での改行、ネストに沿ったインデントはそれらの文字を先程のものに挿入すれば実現できるため良しとしましょう。

とりあえず、元のXSLTよりは汎用性のある書き方ができそうだという感じが出せたならオッケーです。
元記事の方でも「まずはXSLT設定を汎用的な 1種類に集約しました。」とあったので何らかの最適化が図られたはずですが、説明部で:thinking_face:な箇所があったことを考えると本当に汎用的な1種類になったんだろうか。

劣化テンプレート言語みたいな書き方をするためにXSLTを使うのはあまり幸福ではないという話でした。多分。

おまけ 実体参照よりvariableで頑張ろう

<xsl:variable name="nl" select="'&#x0a;'"/>

最終的に使っていませんが改行文字は$nlとしています。<xsl:variable>を使わず実体参照を用意したりもできますが、実体参照を使う方法は個人的に推奨しません。variableであれば、他から取得した結果の格納などもできますし、XSLT 2.0からはasで型チェックできるようになります。

おまけ XSLT 3.0では

<xsl:output method="json"/>
...
<xsl:template match="/">
<xsl:sequence select="xml-to-json(.)"/>
</xsl:template>

いや、取得したい要素の限定とかはもう少しちゃんと書く必要がありますが。クライアントサイドで実行したい場合、Saxon-JS2を使えば良いんじゃないでしょうか。でもこの程度の処理にXSLTライブラリを使うこと自体がミスマッチなので普通のXML処理ライブラリから力技でいいんだよな。

  • XSLT 2.0では<xsl:value-of>には@separatorを指定して,を挿入したりできる
  • XSLT 3.0ではJSONを出力フォーマットとして指定できる

結論:XSLT 3.0を使おう。願わくば実装してほしい。

Discussion

ログインするとコメントできます