JSONにコメントがないのは、意図的にそうしたから
コメントは相互運用性を守るためにJSONから外されました。そのツケを、以降のすべての設定ファイルが払っています。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
タイムアウトが30秒ではなく45秒である理由を設定ファイルに1行書き足したら、デプロイが落ちて、しかもメッセージは何の助けにもなりません。
JSON.parse('{\n // 45s: upstream p99 is 38s\n "timeout": 45\n}')
// Expected property name or '}' in JSON at position 4 (line 2 column 3)
パーサーはキーが来るはずの位置にスラッシュを見つけて、そこで諦めました。Pythonも原因についてはこれ以上明確ではありません。
json.loads('{\n // 45s: upstream p99 is 38s\n "timeout": 45\n}')
# JSONDecodeError: Expecting property name enclosed in double quotes: line 2 column 3 (char 4)
どちらのメッセージもコメントに言及しません。文法の観点では、言及すべきものが存在しないからです。RFC 8259がトークンのあいだに置けると定めている文字はちょうど4つ、スペース、タブ、復帰、改行だけです。それ以外はすべて、値の一部であるか構文エラーであるかのどちらかです。
なぜ削除されたのか
コメントは初期のJSONには含まれていて、Douglas Crockfordがそれを外しました。彼が述べた理由が面白いところです。人々はコメントを散文のためではなく、パーサーへの指示を運ぶために使っていたのです。エンコーディングのヒントや、スキーマへのポインタをコメントに書いておき、特定の消費側がそれを読んで実行する。そうなった時点で、コメントはもうコメントではありません。文書化されていない第二のデータ経路であり、しかもそれが運ばれているのは、どこのどんなパーサーでも同じバイト列から同じ値を読み取れる、という一点を売りにしていた形式の内側なのです。
Crockford自身が示した回避策は、コメント付きのファイルをパーサーに渡す前にミニファイアに通す、というものでした。これは今でも答えの正しい形であり、この記事の残りはおおむね、それを正しくやる方法についての話です。
初期のJSONが何であったかを踏まえれば、この判断は擁護できます。意味についてすでに合意している2つのプログラムのあいだで値を運ぶための転送形式だったのですから。ネットワークパケットにコメントを書く人はいません。
それでもつらい理由
JSONは転送形式のままではいませんでした。ツールチェーン全体の既定の設定言語になったのです。そして設定こそ、値そのものより値の背後にある理由のほうが重要になる場面です。説明のないretries: 0は、次に触る人によって善意で「修正」されます。上に// 意図的。このエンドポイントは冪等ではないと書かれたretries: 0は、そうなりません。
そのため、設定にJSONを採用したエコシステムはどこも独自の上乗せを用意することになり、それらは互いに互換ではありません。
5つの選択肢
_commentキー
{
"_comment": "上流のp99が38秒なので45秒",
"timeout": 45
}
厳密なJSONであり、どこでもパースでき、道具立ても要りません。ただし問題は現実に存在します。スキーマ側でこれを許可しない限りバリデーターに弾かれます。これはデータなので、クライアントに送り出され、ログに残り、差分ではコメントの変更ではなく値の変更として現れます。しかも1つのオブジェクトにつき事実上1つしか置けません。RFC 8259はキーが一意であるべき(SHOULD)と述べるだけで重複時の挙動は未定義であり、JavaScriptもPythonも最後のものを採るため、同じ階層に2つ目の_commentを置くと最初のものが黙って消えます。人々は_comment1、_comment2と回避しますが、そこまで来るとこの手法は割に合いません。
ファイル先頭のヘッダー的な注記には使ってよく、行ごとの注釈には使わないでください。
JSONC
JSONCはJSONに2つを足したものです。//と/* */のコメント、そして末尾カンマ。それだけです。VS Codeが自身のsettings.jsonやkeybindings.jsonに使い、TypeScriptがtsconfig.jsonで受け付けているのがこれです。
{
// 上流のp99は38秒
"timeout": 45,
"retries": 0, // このエンドポイントは冪等ではない
}
位置づけについては率直であるべきです。JSONCには独立した仕様がありません。RFCもバージョン番号も適合性テストスイートもない。エディタという形をした実装が背後にある慣行であり、細部では方言が分かれます(配列の最後の要素のあとの末尾カンマを許すか、コメントが往復で保たれるか)。消費側がすでにそれを解する道具である場合には最も安全な選択肢で、第三者に渡すものには不向きな選択肢です。
JSON5
JSON5はバージョン履歴を持つ本物の仕様で、JSONCよりかなり遠くまで踏み込みます。
- キーが妥当なES5識別子である場合、オブジェクトのキーを引用符なしで書ける
- シングルクォートの文字列
- オブジェクトと配列での末尾カンマ
- 行コメントとブロックコメント
- 16進数
- 先頭および末尾の小数点。つまり
.5や5.が数値になる Infinity、-Infinity、NaN
よく考えるべきなのは最後の項目です。NaNと無限大はJSONに表現を持たないので、それらを使うJSON5文書は、代わりに何を置くかという損失を伴う判断なしにはJSONへ変換できません。ほかの拡張は表記上のもので、変換をきれいに通ります。ファイルの主な書き手が人間で、.json5という拡張子が許容されるならJSON5を使ってよく、API形式としては使わないでください。
JSONをやめる
そのファイルが端から端まで自分で管理している設定で、外部から消費されないのなら、形式は自由に選べますし、JSONが明らかに最良だということもありません。YAMLとTOMLはコメントを一級の機能として持っています。どちらにも固有のコストがあり、決める前に比較記事を読む価値があります。とくにYAMLは、いわゆるノルウェー問題を差し出してきます。PyYAMLやRubyのPsychが実装するYAML 1.1の意味論では、引用符のないnoが真偽値のfalseとして解釈されるのです。
ビルド時に取り除く
注釈付きのファイルを真実の源として保ち、CIでコメントを取り除き、厳密なJSONを配布する。これがCrockfordの示した方法で、すべての要求を満たします。編集者とレビュアーはコメントを見られ、実行時のパーサーはRFC 8259を満たす文書を見ることになり、どの消費側もどちらの形式が存在するかを知る必要がありません。
URLを壊さずにコメントを除去する
真っ先に思いつく実装は正規表現で、真っ先に思いつく正規表現は間違っています。
// これをやってはいけません。
text.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
これに対して走らせて、値が壊れる様子を見てください。
{
"endpoint": "https://api.example.com/v2/orders", // 本番
"note": "変更する前に /* ランブック */ を参照"
}
第1のルールはhttps://の中の//を見つけ、閉じ引用符とカンマを含めてその行の残りを消します。第2のルールは文字列の中のブロックコメントを見つけます。結果として、終端していない文字列と、まったく無関係な場所を指すエラーが残ります。正規表現にこの仕事はできません。スラッシュが文字列の内側にあるかどうかを知る手立てがなく、JSONにおける文字列の内外はエスケープの数え上げに依存するからです。
必要なのは、状態をちょうど1つ持って走査するスキャナです。
function stripJsonComments(text) {
let out = '';
let inString = false;
let inLine = false;
let inBlock = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
const next = text[i + 1];
if (inLine) {
if (c === '\n') { inLine = false; out += c; }
continue;
}
if (inBlock) {
// 行番号がずれないように改行は残す
if (c === '*' && next === '/') { inBlock = false; i++; }
else if (c === '\n') { out += c; }
continue;
}
if (inString) {
out += c;
if (c === '\\') { out += next; i++; continue; } // エスケープ、2文字まとめて消費
if (c === '"') inString = false;
continue;
}
if (c === '"') { inString = true; out += c; continue; }
if (c === '/' && next === '/') { inLine = true; i++; continue; }
if (c === '/' && next === '*') { inBlock = true; i++; continue; }
out += c;
}
return out;
}
人が落としがちなのがエスケープの分岐です。これがないと、"彼は今日 \"https://example.com へ行け\" と言った"のエスケープされた引用符が文字列の終わりと読まれ、続く//がコメントの開始と見なされて、その行の残りが消えます。
これがやらないことにも注意してください。コメントを取り除いても末尾カンマは残り、そちらはそちらで固有のエラーになります。V8は{"a":1,}に対してExpected double-quoted property name in JSON at position 7 (line 1 column 8)を、[1,2,]に対してはまったく異なるUnexpected token ']', "[1,2,]" is not valid JSONを返します。JSONCからJSONへの変換は、その両方を扱う必要があります。
スキャナを抱え込みたくないなら、ファイルをJSON修復ツールに貼り付けてください。コメントと末尾カンマを一度の走査で取り除き、厳密なJSONを返します。そのうえで結果をバリデーターで確かめてください。どちらも完全にブラウザ内で動きます。直そうとしているファイルが認証情報を含む本番設定であるときには、この点が効いてきます。原因がコメントではまったくなかった場合にどうするかは、ほかのエラーページが扱っています。