DevKitLab Logo DevKitLab
YAML / パース / デバッグ / 設定ファイル

YAML はなぜ壊れるのか——大声のエラーと、静かなエラー

同じ朝に二つのチケットが来て、どちらも「YAML が壊れた」と言う。一つは 12 行目の赤い解析エラーで、一分で直る。もう一つはサービスが正常に起動したのに挙動がおかしい——引用符なしの値が、そっと別のものに変わっていた。これは正反対の問題で、危険なのは静かなほうだ。

同じ朝に二つのチケットが来て、どちらも同じことを言う——YAML が壊れた。

一つ目は赤い解析エラーだ。CI ログが 12 行目を指し、「タブはインデントに使えない」とあって、一分もかからず直る。二つ目のほうが奇妙だ。サービスはきれいに起動し、どこにもエラーはないのに、間違ったリージョンを提供している。一時間にらんでようやく見つかる——誰かがノルウェーに region: NO と書き、設定がそれを真偽値の false として読み込んでいたのだ。同じ訴えで、まったく別の問題。そして二つ目こそが本番に届くほうだ——止められるほど大声で壊れたものが、何一つなかったからこそ。

YAML について押さえるべきはここだ——「壊れた」は、一つの文をまとった二つの正反対の失敗だ。 一方は大声だ。パーサーがファイルを拒み、どこかを正確に教えてくれる。もう一方は静かだ。ファイルはちゃんと解析され、あなたが一度も書いていない値をプログラムに手渡す。大声のほうは十分を無駄にする。静かなほうは本番に出る。目の前がどちらかを見分けられるようになると、YAML はもう化け物じみて見えなくなる。二つは正反対のデバッグ手順を要するからだ。これは設定フォーマットの支柱記事の姉妹編だ——あちらは YAML が推測する癖はその便利さの代償だと論じた。ここでは、その推測がどう噛みつくのか、そして普通の構文エラーとどう見分けるのかを、具体的に片づける。

大声の失敗:YAML が解析を拒む(そしてそれは朗報だ)

YAML がファイルを拒んだら、幸運だと思っていい。パーサーが問題をあなたの代わりに捕まえ、行と列を渡してくれた。うっとうしいが、危険ではない——指し示された場所へ行き、構文を直すだけだ。実際に出くわすのは次のあたりだ。

タブをインデントに使う。 最も多い。YAML はインデントにタブ文字を禁じる——構造はスペースのみだ。

server:
	host: localhost

host の前のそのタブは、一字一句このとおりに出す。

YAMLParseError: Tabs are not allowed as indentation at line 2, column 1

直し方はメッセージの言うとおり——タブをスペースに置き換える。これが一番厄介なのは、エディタがこっそりタブを入れたり、コピペが引きずり込んだりしたときだ。ファイルは見た目には正しくインデントされていて、そのタブが見えるのはパーサーだけなのだ。

ちぐはぐなインデント。 YAML は各行のインデントの深さから構造を推し量る。だからスペースが一つ多い・少ないだけで、何が何の下に入るかが変わり——そしてしばしば、パーサーが辻褄を合わせられないエラーになる。

database:
  host: localhost
   port: 5432

porthost より一スペース深く、パーサーはこのありえない入れ子を拒む。直し方は、同じ階層のキーを寸分たがわず同じ列に揃えることだ。

閉じ忘れた引用符や括弧。 フローコレクション([...]{...})と引用符付き文字列は閉じなければならない。

ports: [80, 443

Flow sequence … must be sufficiently indented(あるいは後続によっては括弧欠落のエラー)を出す——そして name: "prodMissing closing "quote を出す。どちらも行を名指す。どちらも、開いたものを閉じれば直る。

重複したキー。 一つのマッピングに同名のキーが二つあるのはあいまいで、厳格なパーサーは端から拒む。

region: us-east-1
region: eu-west-1

Map keys must be unique at line 2, column 1。(どのパーサーもここまで厳格ではない——最後の値をこっそり残すものもあり、それはこれを静かにもう一方の分類へ滑り込ませる。あなたのパーサーが本当に拒むなら、それは味方をしてくれている。)

この節のどの失敗も、動きは同じだ——エラーを読み、それが名指す行と列へ行き、そこで構文を直す。 パーサーはあなたの側にいる。次は、味方でないほうの半分だ。

静かな失敗:YAML はちゃんと解析し、間違った値を手渡す

ここには赤いエラーも、行番号も、たどれる手がかりもない。ファイルは「正常に」解析された。問題は、その「正常」があなたの書いたものとは別物を意味したことだ——文字列が真偽値に、空のキーが null に、バージョンが数値に変わる。プログラムは壊れたデータで走りだし、真の原因からはるか遠いどこかで倒れる。これが高くつくほうで、いくつかの顔を持つ。

コロンのあとのスペース抜け。 キーにはコロンのあとにスペース(または改行)が要る。そのスペースを抜くと、エラーは出ない——ただの文字列になる。

timeout:30

は、キー timeout に値 30 ではなく、文字列 "timeout:30" として解析される。あなたの config.timeoutundefined で、そのことをどこも一言も告げない。

暗黙の null。 あとに何もないキーは、空文字列ではない——null だ。~nullNullNULL も同じだ。

retries:
fallback: ~
cache: null

三つの値はすべて null だ。retries を空文字列や 0、あるいは「未設定なら既定を使う」だと思っていたら、代わりに正真正銘の null を、null チェックのないコードへ渡しているかもしれない。値は不在に見えて、実は在って、null なのだ。

ノルウェー問題と、その一族。 これは支柱記事のあれで、静かな失敗の筆頭だ。YAML 1.1 のもとでは——PyYAML のようなパーサーが今も既定でこれに従う——一群の裸の語が真偽値になる。

region: NO      # 真偽値 false、国コードの "NO" ではない
enabled: yes    # 真偽値 true
debug: off      # 真偽値 false

yesnoonoffyn とその大文字小文字は、すべて真偽値へ変換される。YAML 1.2 の core schema はこれを廃した——そこでは NO は文字列のまま——が、多くのランタイムは今も既定で 1.1 なので、安全だと決めてかからないほうがいい。(全容は支柱記事に。

あなたの文字列を食う数値。 数値の形をしたものは何であれ数値になり、それをテキストとして意味あるものにしていた部分を捨てる。

version: 1.10   # 数値 1.1——末尾のゼロが消える
build:   1e5    # 数値 100000——科学表記と読まれる
zip:     01234  # 1234(1.2 core)または 668(8 進、1.1)——先頭ゼロが消える
port:    0700   # 700(1.2 core)または 448(8 進、1.1)
time:    22:22  # 1.1 では 1342(60 進法:22×60 + 22)。1.2 core では文字列

これらが変換されるか——そして何に——は、使うパーサーとそれが適用する schema 次第だ。これらの暗黙ルールを使うパーサーのもとでは、エラーひとつなく型が変わる(たとえば 22:22 は YAML 1.2 の core schema では文字列のままだが、1.1 では 1342 になる)。"1.10" を期待していたバージョン照合は 1.1 と突き合わせて違うと判定し、ゼロ埋めの ID はそっとゼロを失う。

これらはたいていエラーにならず、だからたどれる行もない——パーサーから見れば、何も起きていないのだ。デバッグの動きは「エラーをたどる」ではありえない——エラーがないのだから。代わりに、一つの習慣であるほかない。

それぞれのデバッグの仕方

すべては二つを見分けることに尽き、その判定はたった一問だ——解析エラーが出たか、出なかったか。

  • エラーが出た。 大声の失敗だ。メッセージが行と列を運んでくる——そこへ行き、構文を直す。タブをスペースに、同階層を揃え、引用符と括弧を閉じ、重複キーの名を変える。せいぜい十分。
  • 解析は通ったのに、下流の何かがおかしい。 静かな失敗で、たどれる行はない。タイプミス探しにファイル全体を読み直すのではなく——プログラムが実際に受け取った値を見て、逆にたどる。テキストのはずの場所に false、キーのはずの場所に null、バージョン文字列のはずの場所に数値——どれも、変換された引用符なしのスカラーを一直線に指している。直し方はほぼいつも同じ一つの習慣だ——人がテキストと読むものには、すべて引用符を付ける——国コード、バージョン番号、先頭ゼロの ID、時刻、そして yes/no/on/off の一族。
region: "NO"
version: "1.10"
zip: "01234"

引用符はほとんどコストなしで、その値の「推測」をオフにする。

本番に出す前に静かなものを目で見る、もっと速い方法がある。ファイルを YAML コンバーターに貼り、チェック欄を読む。既定では YAML 1.2 として解析するが、上の罠のいくつか——YAML 1.1 の真偽値トラップ(あの引用符なしの no)、いまにも浮動小数点数へ崩れそうなバージョン番号、2^53 を超える大きな整数、インデントに使われたタブ——を、変わる値とともに名指しで指摘する。残りは同じファイルを JSON に変換すれば拾える。JSON 出力で見える型が、プログラムが実際に受け取る型だ——暗黙の null も、文字列になった timeout:30 も、正体を現す。

チェックリスト

次に YAML が「壊れた」ら、まずどちらかを問う。

  1. 解析エラーはあるか。 あれば、大声で安全だ——名指された行と列へ。タブをスペースに、インデントを直し、引用符や括弧を閉じ、重複キーを整理する。
  2. 解析は通ったのに挙動が変か。 静かなほうだ。たどれる行はない——プログラムが受け取った値を調べ、変換されたスカラーへ遡る。
  3. あいまいなスカラーに引用符を付ける。 国コード、バージョン番号、先頭ゼロの ID、時刻、そして yes/no/on/off の一族。引用符はその値の YAML の「推測」をオフにする——静かな失敗に対して最も効く一つの習慣だ。
  4. 本番に出す前にチェック欄を通す。 YAML コンバーターは静かな型変換を前もって並べ、JSON 変換をのぞけば本当の型が見える。

覚えておく一行——ビルドを止める YAML エラーは、もらえて幸運なほうだ。 それは数分で済み、自分で自分を指し示す。あなたに午後を丸ごと払わせるのは、一言も発さず解析を通り抜け、プログラムにそっと falsenull や末尾を丸めた数値を手渡す、あの YAML だ。大声の失敗だけでなく、あの静かな「成功」を疑うことを覚えれば、YAML の不気味さは大半が消える。