ログベースのアラートとSlackを連携して、開発サーバーの問題を迅速に発見しよう

この記事はTech KAYAC Advent Calendar 2023の15日目の記事です。

 

こんにちは、カヤックボンドのサーバーエンジニアの松本です。

今年はカヤックグループ合同のアドベントカレンダーということで、各グループから色んな方が参戦しています。

今回は開発サーバーでの開発体験を高めるためのアラートについて紹介したいと思っています。

なお、今回の記事はGoogle CloudのCloud Loggingにエラーログを送信しているプロジェクトを対象にしています。(50GB/月、保持期間30日まで無料なのでガンガン活用すると良いと思います)

はじめに

Google CloudではError Reportingという自動的にモニタリング/アラートを行えるサービスがあります。

Error Reportingではエラー箇所ごとに発生回数を集計してくれたり、影響ユーザー数も集計してくれたりでGoogle Cloud使うなら絶対活用するべきなのですが、Slack等に送信するメッセージは整形できないので、開発では不便なことがあります。

例えば、こんな情報がSlackで通知されると便利なのではないでしょうか。

  • リクエストURL
  • エラーメッセージ/エラーコード
  • エラー発生箇所(関数名、行数)
  • エラー発生ユーザー

アラートの仕組みがない開発環境ではクライアントさんや企画さんから「エラー出ました」と発信が無いと、サーバー側での事前検知が困難です。

Slackにアラートが発報されて、それを見て直ぐにどの程度深刻か判断できる場合、サーバー側から「エラーっぽいので調査します」だったり、「致命的なのでCloud Runのリビジョンを戻します」等素早い判断が出来、お互い幸せな開発体験を送ることができるでしょう。

最低限の形で動かしてみよう

まずは特定のログが来たらSlackに通知するだけのアラートを作ってみようと思います。

ログベースのアラートは昨年GAになった比較的新しい機能で、公式Docも何故かコンソールからの操作説明を諦めてしまっているのみたいですが、今回はコンソールからの操作で説明します。

 

まずは通知チャンネルの構成を行います。以下のURLより、SlackのAdd newを押すことで、Slackワークスペースとの連携が行います。

https://console.cloud.google.com/monitoring/alerting/notifications

赤い丸より連携を行うことで、登録は完了です。メールやその他アプリへの連携も可能です。

Terraform等やAPI経由で設定を行いたい方は、登録後青い丸から「通知のURL」のコピーが可能です。

 

次に、ログエクスプローラより「アラートを作成」ボタンを選択します。

選択すると、作成画面が出てきます。現時点では日本語対応は部分的のようです。

最低限必要な項目は以下です。

  • Alert Policy Name
  • Choose logs to include in the alert
  • 通知の間隔
  • インシデントの自動クローズ期間
  • 通知チャンネル(複数指定可)

Choose logs to include in the alertにはアラート条件をLoggingのクエリ言語で記載します。PREVIEW LOGSを押下することで、絞り込めているか確認も可能です。

ここまで設定を終えて、slackでログを出してみると以下のようにアラートが発報されます。

このアラートではCloud Runで何かが起きたことは分かるのですが、詳細はURL見ないと分かりません。

そこで、表示されるメッセージのカスタマイズを行ってみようと思います。

アラートメッセージをカスタマイズしよう

ログベースのアラートでは構造化ログの任意のフィールドの値を変数として利用できる機能があります。

今回は、構造化ログからエラー発生箇所を取ってきてSlackに表示させてみようと思います。

アラートの設定から「ADD A LABEL」を押して以下のように指定します。

Documentationには以下のように追記します。

これらの操作で以下のことを実現しています。*1 *2

  • 構造化ログから特定のフィールドを指定して、ラベルを作る
  • 作ったラベルで、アラートのメッセージを作る 

ここまで設定すると、Slackには以下のようなアラートが発報されます。

ここまでくれば、エラーが起きたAPIが何なのか素早く特定できるでしょう。

他にもログに出しているものを出力することで、より良いアラートになると思います。

例えば、アプリケーション側に送信しているエラーコードやメッセージを表示したり、ユーザーIDと管理画面のURLを繋げて管理画面へのリンクを作ったりすることで、サーバー以外の人が見ても役に立つエラーログになると思います。

おわりに

無事にアラートが発報出来たので、めでたしめでたし、と言いたいところですが、もし環境が10環境あって、それら全てに上記の設定を加えてメンテするのは非常に大変です。

社内の勉強会などの各種イベントでは、上記問題などを取り扱った少し広い範囲の紹介を行っているので、ご興味がある方や、一緒に快適な開発体験を行う取り組みを行いたい方、是非↓リンクからご応募いただければと思います。メリークリスマス!

 

カヤックボンドでは一緒に技術力を高め合えるエンジニアを募集しています!

「JS体操」のすゝめ 〜その②〜

このエントリは【カヤック】面白法人グループ Advent Calendar 2023 の25日目の記事です。

本記事は昨日公開の 「JS体操」のすゝめ 〜その①〜 の解説記事です!
まだご覧になっていない方は先にこちらをご覧くださいね!!

techblog.kayac.com

1. はじめに

こんにちは!
デザイナーとして意匠部に配属されたのに、入社早々なぜか小原さん(私の OJT で、昨日の記事の執筆者)に「鬼の技術部研修」に送り込まれた、23新卒の大桐です。

「技術部研修」は読んで字の如くエンジニアのための研修です。入社してすぐの1ヶ月間、かなり広範囲なことを勉強します。レベル高め。新卒デザイナーが参加した前例はないし、もちろん同時期に入った他の新卒デザイナーは参加してませんでした🥲


「あれ?デザイナーとして入社したのにおかしいぞ?
 プログラミング初心者が参加していいようなレベルの研修じゃないな?」


と気づいた時にはもう遅し。。。
クセの強すぎる宇宙人みたいなエンジニアとの会話、右から左に抜けていく横文字だらけのプログラミング用語、慣れない黒い画面での操作、US 配列のキーボードなどに耐えまくり、なんとか1ヶ月終了。
(得られたものはたくさんありました。ありがとうございました。)

聞き慣れない業界用語を覚えるために意匠部ブログにこんな記事も書きました。よろしければ併せてお読みください!

designblog.kayac.com

あ!そういえば先輩デザイナーのベルさんも小原さんに HTML/CSS/JavaScript を書かされてこんなゲームを作ってました。Git もコマンドで打たされたました。鬼過ぎます。

techblog.kayac.com

その後も、鬼の小原さんに「プログラミングはやらないと忘れるし、勉強になるから(ニコッ ♪)」と言われ、毎週ヒィヒィ悲鳴をあげながら「JS体操」に取り組んでいます ♪

「JS体操」は小原さんが主催している JS 脳、プログラミング脳をゆる〜く鍛えるための勉強会です。任意参加です。でも私はよく出題や解説をさせられています。

ところで、その小原さんが書いた昨日の記事「JS体操」のすゝめ 〜その①〜は読みましたか?
最後、「JS体操」の問題が出題されていましたね!

いつものように笑顔で、「解説よろしくね(ニコッ ♪)」と言われてしまったので、小原さん監修の元、気合い入れて154文字の解説しますね ♪

まだ挑戦してない方はこの記事を読むのは問題を解いてからにしてくださいね!問題を解いて、気持ちよく年越ししましょう!

2. 問題(再掲)

以下、昨日の記事からの引用です!

余計なホワイトスペースがたくさん入ったインデントされていない汚い JSON 文字列(本記事では JSON として解析する文字列、JSON の記法で書かれた文字列を便宜上「JSON 文字列」と呼ぶことにします)を綺麗に整形するという問題です。実際の業務では JSON.parse()、JSON.stringify() でやるべき処理ですが、仮に自力でやろうとするとどうなるでしょう?そしてコードゴルフをするとしたら何文字になるでしょう?

汎用的なコードにする必要はありません。つまり問題の JSON 文字列さえ綺麗に整形できるコードであれば OK です。

社内では徐々に JSON が複雑になっていく、という形で何回かにわたり出題しました。
今回ご紹介するのはその最終問題です。

コードは以下に置いてあります!
github.com

3. 考え方

まずは人間が手で整形するように、

1. JSON 文字列中の余計なホワイトスペースを全て無くす
2. 文字列の最初から順番に1文字1文字チェックしていき、特定の文字 {}[],: があったら何らかの処理をする

という操作をやっていくとシンプルなロジックになりそうです。
では解説してきます!

4. ロジックを解く

いきなり複雑な JSON 文字列を整形することを考えるのは大変ですね...まずは余計なホワイトスペースも無いシンプルな JSON 文字列で考えましょう!

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!"}

4.1. 改行が必要なときに改行だけをしてみる

まずは改行だけを入れてみましょう!
改行を挿入する位置は記号によって「前」か「後」かが異なります。
整理すると...

【 改行を入れる位置 】

  • {[, の場合 → 「後」
  • }] の場合 → 「前」


1文字ずつチェックするには色々な方法が考えられますが、まずは jsonString を文字列から配列に変換し、map() メソッドで1文字ずつ見ていきましょう。 jsonString を配列にする方法も色々ありそうですが、一番シンプルそうなスプレッド構文を使い [...jsonString] とします。
※ 以降、改行を入れる位置の違いなどを比較しやすいようあえて縦を揃えます。

export default function beautify(jsonString) {
  const arr = [...jsonString].map((char) => {
    if (char === '{') { return        char + '\n'; }
    if (char === '[') { return        char + '\n'; }
    if (char === ',') { return        char + '\n'; }
    if (char === '}') { return '\n' + char       ; }
    if (char === ']') { return '\n' + char       ; }

    return char;
  });

  return arr.join(''); // join() で配列から文字列に戻すのを忘れずに!
}

実行してみると、

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!"}

{
"hoge":1225,
"fuga":[
2023,
12,
25
],
"piyo":"メリークリスマス!"
}

こうなりました!
ゴールに一歩近づいた〜!

4.2. 固定長の半角スペースを入れてみる

改行 \n やコロン : の後には半角スペースを入れる必要がありますね。 いきなり半角スペースの数を変化させるのは大変なので、まずは固定長(1個)の半角スペースを入れてみましょう!

export default function beautify(jsonString) {
  const arr = [...jsonString].map((char) => {
    if (char === ':') { return              char        + ' '; }
    if (char === '{') { return              char + '\n' + ' '; }
    if (char === '[') { return              char + '\n' + ' '; }
    if (char === ',') { return              char + '\n' + ' '; }
    if (char === '}') { return '\n' + ' ' + char             ; }
    if (char === ']') { return '\n' + ' ' + char             ; }

    return char;
  });

  return arr.join('');
}

すると、、

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!"}

{
 "hoge": 1225,
 "fuga": [
 2023,
 12,
 25
 ],
 "piyo": "メリークリスマス!"
 }

こうなりました!
ゴールにさらに一歩近づいた〜〜!

4.3. 半角スペースの数を可変にしてみる

次はいよいよ半角スペースの数を変えて階層構造を可視化してわかりやすくしましょう。

【 半角スペースの増減 】

  • {[ の場合 → スペースの数を1段階増やす
  • , の場合 → スペースの数は変わらない
  • }] の場合 → スペースの数を1段階減らす
  • : の場合 → スペースの数は常に1個

スペースの数の増減は repeat() メソッドを使うことにします。padEnd() でも良いかもしれませんね!
スペースの数は変数 spaces に保持します。

export default function beautify(jsonString) {
  let spaces = 0;

  const arr = [...jsonString].map((char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });

  return arr.join('');
}

実行すると、

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!"}

{
  "hoge": 1225,
  "fuga": [
    2023,
    12,
    25
  ],
  "piyo": "メリークリスマス!"
}

こうなりました!
ゴールが見えてきた!!!


4.4. map() ではなく replace() にしてみる

インデントはできるようになりましたね! ここで、今後のために少しロジックを変えます。

スプレッド構文で文字列を配列に変換し、それをまた join() で文字列に戻すのが回りくどい!ので replace() と正規表現で文字列のまま処理をするように書き換えちゃいましょう。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/./g, (char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}

最後に join() する必要もなくなりシンプルになった気がします!

4.5. 値の文字列中に {}[],: がある場合を考慮する

さて、「JS体操」の問題の ugly.json を見ると、値の文字列中にも {}[],: が入っていますね…なんて意地悪なんでしょう。

試しに今まで使っていたシンプルな JSON 文字列の "メリークリスマス!"{}[],:を入れてみます。

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!{}[],:"}

これを先程までの beautify() に渡すと、、

{
  "hoge": 1225,
  "fuga": [
    2023,
    12,
    25
  ],
  "piyo": "メリークリスマス!{
    
  }[
    
  ],
  : "
}

あちゃー、インデントしなくてもいいところがインデントされてしまいました! この意地悪なトラップを回避するために文字列の中の記号は無視するようにしてみましょう。

4.5.1. 文字列内の記号は無視する

正規表現を使い、1文字ずつチェックするのではなく「文字列」or「任意の1文字」でチェックしていきます!!つまり... /./g/文字列を表す正規表現がここに入る|./g に書き換えます。

図にするとこんな感じです。

4.5.2. 文字列を表す正規表現

(JSON 中にキーや値として現れる)文字列を表す正規表現を考えます。

ます JSON の文字列はダブルクオーテーションで囲われていますね。そしてダブルクオーテーションで囲われている文字列の中身は、ダブルクオーテーション以外なので "[^"]*" です。

うっかり "[^"]+" にしてしまうと空文字 "" にマッチしなくなるので注意が必要です! ugly.json にもしっかり空文字 "" が仕込んであります…憎い。

コードを書き換えてみましょう。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/"[^"]*"|./g, (char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}

実行してみると、

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリークリスマス!{}[],:"}

{
  "hoge": 1225,
  "fuga": [
    2023,
    12,
    25
  ],
  "piyo": "メリークリスマス!{}[],:"
}

に無事なりました!

4.6. エスケープされたダブルクオーテーションの対応

あ、でもちょっと待ってください。 ugly.json を見ると、エスケープされたダブルクオーテーション \" がたくさん入ってます…本当に意地悪です。

試しに "メリークリスマス!{}[],:"\" を加えてみましょう。

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリー\"クリスマス!{}[],:"}

すると…

{
  "hoge": 1225,
  "fuga": [
    2023,
    12,
    25
  ],
  "piyo": "メリー\"クリスマス!{
    
  }[
    
  ],
  : "
}

おっと、、やっぱり変なところでインデントされちゃいました!文字列を表す正規表現 "[^"]*" をカスタマイズしましょう。

エスケープされたダブルクオーテーションは無視するように、
「ダブルクオーテーション以外」or「エスケープされたダブルクオーテーション」
と考えて
"(?:[^"]|(?<=\\)")*"
ではどうでしょう?

※ 一応無駄にキャプチャしないように ?: をつけておきます

つまりこんな感じ。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/"(?:[^"]|(?<=\\)")*"|./g, (char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}

おそるおそる

{"hoge":1225,"fuga":[2023,12,25],"piyo":"メリー\"クリスマス!{}[],:"}

を渡してみると、、

{
  "hoge": 1225,
  "fuga": [
    2023,
    12,
    25
  ],
  "piyo": "メリー\"クリスマス!{}[],:"
}

やった!

4.7. エスケープされたバックスラッシュの対応

いやいやちょっと待ってください。 ugly.json をよーく見ると、\" だけじゃなく \\" もあります… 一見ダブルクオーテーションがエスケープされているように見えてエスケープされてない! 本当にどこまで意地悪なんでしょう。

文字列の終端を表すダブルクオーテーションの前に \\ を加えてみます。

{"hoge":1225,"fuga\\":[2023,12,25],"piyo":"メリー\"クリスマス!{}[],:"}

これを現状の beautify() に渡すと、、

{
  "hoge": 1225,
  "fuga\\":[2023,12,25],"piyo":"メリー\"クリスマス!{}[],:"
} 

見事に鬼のトラップにハマりました。

なのでまたまた正規表現を直しましょう。
「エスケープされた何か」、つまり \\. をひとかたまりで考えて、
「ダブルクオーテーション以外」or「エスケープされた何か」

"(?:[^"]|\\.)*"
ではどうでしょう?

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/"(?:[^"]|\\.)*"|./g, (char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}

試してみると、

{"hoge":1225,"fuga\\":[2023,12,25],"piyo":"メリー\"クリスマス!{}[],:"}

beautify() に渡すと、、

{
  "hoge": 1225,
  "fuga\\": [
    2023,
    12,
    25
  ],
  "piyo": "メリー\"クリスマス!{
    
  }[
    
  ],
  : "
} 

あれれ、、うまくいきません!

「バックスラッシュ」\ が「ダブルクオーテーション以外」 [^"] にマッチしてしまっていそうですね!
「ダブルクオーテーション以外」or「エスケープされた何か」
ではなく、順番を入れ替えて
「エスケープされた何か」or「ダブルクオーテーション以外」
としましょう。

つまり "(?:\\.|[^"])*" です。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/"(?:\\.|[^"])*"|./g, (char) => {
    if (char === ':') { return                                  char        + ' '                    ; }
    if (char === '{') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '[') { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ',') { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']') { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}
{
  "hoge": 1225,
  "fuga\\": [
    2023,
    12,
    25
  ],
  "piyo": "メリー\"クリスマス!{}[],:"
}

やった! 長い戦いでした。

ところで、昨日の記事に参考文献として引用されていた RFC 4627 で JSON の生みの親らしい Douglas Crockford 氏は文字列を表す正規表現を "(\\.|[^"])*" ではなく "(\\.|[^"\\])*" と書いていますね。後者でないとマッチしない場合もあるのでしょうか??でも今のところは前者で問題なさそうなので2文字短い前者でいきましょう!今回は ugly.json さえ綺麗にできれば良いので!

4.8. 無駄なホワイトスペースを取り除く

さて次は無駄なホワイトスペースです。 まず JSON 文字列に無駄なホワイトスペースをたくさん入れてをこんな感じにしてみます!

  {"hoge":  1225,    "fuga\\"   :[2023, 12, 25], "piyo" 
 : "メリー\" クリスマス!{ }[ ],:"  }  

ホワイトスペースについても、さきほど
「文字列」or 「それ以外の1文字」
という作戦にしたように
「文字列」or「無駄なホワイトスペース(空白)」or「任意の1文字」
としてみましょう。

図にするとこんな感じです。

ここで順番に気をつけてください!
「文字列」or「空白」or「任意の1文字」
ではなく
「空白」or「文字列」or「任意の1文字」
だと文字列の中の(無駄じゃない)ホワイトスペースも「無駄なホワイトスペース」と誤って解釈されるので注意しましょう。

まとめると、正規表現は /"(?:\\.|[^"])*"|./g/"(?:\\.|[^"])*"|\s+|./g になります。

beautify.js はこんな感じ。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(/"(?:\\.|[^"])*"|\s+|./g, (char) => {
    if (!/\S/.test(char)) { return ''                                                                    ; }
    if (char === ':'    ) { return                                  char        + ' '                    ; }
    if (char === '{'    ) { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === '['    ) { return                                  char + '\n' + ' '.repeat(spaces += 2); }
    if (char === ','    ) { return                                  char + '\n' + ' '.repeat(spaces += 0); }
    if (char === '}'    ) { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
    if (char === ']'    ) { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

    return char;
  });
}

なお \s+ の部分にマッチしたときは、それを消し去りたいので、

if (!/\S/.test(char)) { return ''; }

の1行を加えてあります。

実行してみると、、、

  {"hoge":  1225,    "fuga\\"   :[2023, 12, 25], "piyo" 
 : "メリー\" クリスマス!{ }[ ],:"  }  

{
  "hoge": 1225,
  "fuga\\": [
    2023,
    12,
    25
  ],
  "piyo": "メリー\" クリスマス!{ }[ ],:"
}

となりました!やった!!

ここで一度整理のためにコメントを入れておきましょう。

export default function beautify(jsonString) {
  let spaces = 0;

  return jsonString.replace(
    // 「文字列」or「無駄なホワイトスペース(空白)」or「それ以外の任意の1文字」
    /"(?:\\.|[^"])*"|\s+|./g,
    (char) => {
      // 空白以外の文字が含まれていないときは空文字('')を返す
      if (!/\S/.test(char)) { return ''                                                                    ; }

      // コロンの後ろは半角スペースを1つだけ追加する
      if (char === ':'    ) { return                                  char        + ' '                    ; }

      // 開きカッコ('{'と'[')の次では改行し、インデント幅を1段階増やす
      if (char === '{'    ) { return                                  char + '\n' + ' '.repeat(spaces += 2); }
      if (char === '['    ) { return                                  char + '\n' + ' '.repeat(spaces += 2); }

      // コンマ(',')の後ろでは改行し、インデント幅は変えない
      if (char === ','    ) { return                                  char + '\n' + ' '.repeat(spaces += 0); }

      // 閉じカッコ(']'と'}')の前では改行し、インデント幅を1段階減らす
      if (char === '}'    ) { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }
      if (char === ']'    ) { return '\n' + ' '.repeat(spaces -= 2) + char                                 ; }

      // それ以外の文字は何もせず返す
      return char;
    },
  );
}

さて、たぶんロジックはできたので実際の問題の ugly.json で試してみます。 実行してみると、、、

エラーなし!1225文字(最後の改行込みで)!!メリークリスマス!!!

ふう...
これで、今回の要件はクリアです!🙌
いったん、お疲れ様でした!!!
さあここからどんどんコードを短くしていきましょう!

5. コードゴルフ

ここからが長い戦いになります。
1225 文字からどこまで短くできるでしょうか??

5.1. 同じ処理をまとめる①

' '.repeat(spaces += N) の処理が複数登場するので関数にまとめちゃいましょう! 関数名は後述する諸事情によりほんとは "o" で始まると都合がいいのですが…organizeCode()organizeIndentation()、、うーん、長いしイマイチ。素直に indent() でいきます!!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(?:\\.|[^"])*"|\s+|./g,
    (char) => {
      if (!/\S/.test(char)) { return ''                           ; }
      if (char === ':'    ) { return              char + ' '      ; }
      if (char === '{'    ) { return              char + indent(2); }
      if (char === '['    ) { return              char + indent(2); }
      if (char === ','    ) { return              char + indent(0); }
      if (char === '}'    ) { return indent(-2) + char            ; }
      if (char === ']'    ) { return indent(-2) + char            ; }

      return char;
    },
  );
}

すみません、実はコメントは無理やり1225文字にしてクリスマス感を出すために入れただけなので早速取りました。笑

5.2. 同じ処理をまとめる②

  • {[
  • }]

はそれぞれ処理が同じなので if 文をまとめましょう。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(?:\\.|[^"])*"|\s+|./g,
    (char) => {
      if (!/\S/.test(char)            ) { return ''                           ; }
      if (char === ':'                ) { return              char + ' '      ; }
      if (char === ','                ) { return              char + indent(0); }
      if (char === '{' || char === '[') { return              char + indent(2); }
      if (char === '}' || char === ']') { return indent(-2) + char            ; }

      return char;
    },
  );
}

1行1行が長〜くなってきましたが、縦を揃えたほうが比較しやすく見通しが良い!との鬼の教えなのでしばらくこのままでいきますね。

5.3. 正規表現の文字数を減らす

無駄にキャプチャしないようにつけていた ?: を取りましょう。
他言語では?:はエルビス演算子とも呼ばれるそうですが、非キャプチャグループの ?: もエルビスと呼ぶのでしょうか?

そして \s+\s でもよいので \s にします。
(パフォーマンス的にはよくなさそうですが。。)

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|./g,
    (char) => {
      if (!/\S/.test(char)            ) { return ''                           ; }
      if (char === ':'                ) { return              char + ' '      ; }
      if (char === ','                ) { return              char + indent(0); }
      if (char === '{' || char === '[') { return              char + indent(2); }
      if (char === '}' || char === ']') { return indent(-2) + char            ; }

      return char;
    },
  );
}

ここで一旦文字数を確認してみましょう!

635文字!さぁ、この調子でどんどん減らしていきますよ〜〜

5.4. 条件分岐を順不同にする

とある理由で if 文の順番を自由に変えても良いようにしたいです。

ここで、

return char;

に到達するのは

  • char"(\\.|[^"])*" にマッチする文字列のとき
  • char{ or } or [ or ] or , or : 以外のとき

ですね!

これを

  • char"(\\.|[^"])*" にマッチする文字列のとき

のみ、となるように変えておきましょう。

具体的には正規表現 /"(\\.|[^"])*"|\s|./g
「それ以外の任意の一文字」
の部分を
{ or } or [ or ] or , or :
に変えます。

つまり
/"(\\.|[^"])*"|\s|./g

/"(\\.|[^"])*"|\s|[{}[\],:]/g
にします。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (char) => {
      if (!/\S/.test(char)            ) { return ''                           ; }
      if (char === ':'                ) { return              char + ' '      ; }
      if (char === ','                ) { return              char + indent(0); }
      if (char === '{' || char === '[') { return              char + indent(2); }
      if (char === '}' || char === ']') { return indent(-2) + char            ; }
      
      return char;
    },
  );
}

ここで

  • "(\\.|[^"])*" にマッチする文字列は2文字以上
  • \s にマッチする文字列は1文字
  • [{}[\],:] にマッチする文字列も1文字

なので、

return char;

if (char.length > 1) { return char; }
と書き換えます。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (char) => {
      if (!/\S/.test(char)            ) { return ''                           ; }
      if (char === ':'                ) { return              char + ' '      ; }
      if (char === ','                ) { return              char + indent(0); }
      if (char === '{' || char === '[') { return              char + indent(2); }
      if (char === '}' || char === ']') { return indent(-2) + char            ; }
      if (char.length > 1             ) { return              char            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

これで5つの if 文を好きな順番で並び替える事ができるようになりました。
(いずれの if 文にも入らないことが無いことを明示するためあえて throw を入れています)

言い換えると5つの条件式のいずれかを省略できるということです。 省略したいのは条件式が一番長いやつです。 どの条件式を省略すれば良いかを判断するため、それぞれの条件式を短く書き直しましょう。

まず余計なホワイトスペースを削除したうえで、「厳密等価演算子」=== を「(厳密でない)等価演算子」== に書き換えます。 charc にしてしまいましょう!

※ 今回のコードに限っては挙動は変わりませんが、実戦ではなるべく === を使いましょう!(== を使うと、比較時に暗黙の型変換を意図的にして欲しくてあえて == にしているのか、よく分からずに == にしているのか読み取りづらいため)鬼上司に怒られます!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (c) => {
      if (!/\S/.test(c) ) { return ''                        ; }
      if (c==':'        ) { return              c + ' '      ; }
      if (c==','        ) { return              c + indent(0); }
      if (c=='{'||c=='[') { return              c + indent(2); }
      if (c=='}'||c==']') { return indent(-2) + c            ; }
      if (c.length>1    ) { return              c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

順番をちょっと変えます。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (c) => {
      if (!/\S/.test(c) ) { return ''                        ; }
      if (c.length>1    ) { return              c            ; }
      if (c==':'        ) { return              c + ' '      ; }
      if (c==','        ) { return              c + indent(0); }
      if (c=='{'||c=='[') { return              c + indent(2); }
      if (c=='}'||c==']') { return indent(-2) + c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

さらに c.length > 1c[1] にしてしまいます。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (c) => {
      if (!/\S/.test(c) ) { return ''                        ; }
      if (c[1]          ) { return              c            ; }
      if (c==':'        ) { return              c + ' '      ; }
      if (c==','        ) { return              c + indent(0); }
      if (c=='{'||c=='[') { return              c + indent(2); }
      if (c=='}'||c==']') { return indent(-2) + c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

さらに論理和 || はビット論理和 | にしてもこの場合は挙動が変わらないので | にしてしまいます。
true | true1 | 11true
true | false1 | 01true
false | true0 | 11true
false | false0 | 00false

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (c) => {
      if (!/\S/.test(c)) { return ''                        ; }
      if (c[1]         ) { return              c            ; }
      if (c==':'       ) { return              c + ' '      ; }
      if (c==','       ) { return              c + indent(0); }
      if (c=='{'|c=='[') { return              c + indent(2); }
      if (c=='}'|c==']') { return indent(-2) + c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

5.5. 文字列比較の条件式を短くする

ここで、

  • c=='{'|c=='['(13文字)
  • c=='}'|c==']'(13文字)

をさらに短くできないか、検討してみましょう!

5.5.1 indexOf() メソッドを使う方法

  • '{['.indexOf(c)>-1(18文字)
  • '}]'.indexOf(c)>-1(18文字)

ビット否定演算子 ~ でビットを反転させると、-1 が 0 に変わり、他の値は -1 以外の負の整数に変わります。
JavaScriptにおいて負の整数は真とみなされるので、
c'{[' または '}]' に含まれている場合、~'{['.indexOf(c)の結果は負の整数になり結果真となります。
よって以下のように書けます。

  • ~'{['.indexOf(c)(16文字)
  • ~'}]'.indexOf(c)(16文字)

⭐️結果・・・16文字で引き分け

元の13文字より長くなってしまいました。

5.5.2. includes() メソッドを使う方法

  • '{['.includes(c)(16文字)
  • '}]'.includes(c)(16文字)

⭐️結果・・・16文字で引き分け

5.5.3. 正規表現を使う方法

まずは正規表現の test() メソッドで。

  • /{|\[/.test(c)(14文字)
  • /[{[]/.test(c)(14文字)
  • /}|]/.test(c)(13文字)
  • /[}\]]/.test(c)(15文字)

正規表現内のエスケープの必要の有無で文字数に差が出ますね!


文字列の match() メソッドで。

  • c.match(/{|\[/)(15文字)
  • c.match(/[{[]/)(15文字)
  • c.match(/}|]/)(14文字)
  • c.match(/[}\]]/)(16文字)

ここで match() メソッドは、文字列を渡すと正規表現に暗黙的に変換してくれるので、

  • c.match('{|\\[')(16文字)
  • c.match('[{[]')(15文字)
  • c.match('}|]')(14文字)
  • c.match('[}\\]]')(17文字)

文字列ではなく文字列を要素に持つ配列(※ 結局文字列に変換されます)を渡すと、

  • c.match(['{|\\['])(18文字)
  • c.match(['[{[]'])(17文字)
  • c.match(['}|]')(16文字)
  • c.match(['[}\\]]'])(19文字)

「いや、文字数増えてるやないかい!」と思った方は、落ち着いてください。

なぜわざわざ match() メソッドに配列を渡したかというと...
テンプレートリテラルとタグ関数の仕様を利用するとさらに以下のように書けるというのを説明したかったからです!

(テンプレートリテラルとタグ関数の仕様の説明は割愛しますね!)

  • c.match`{|\\[`(14文字)
  • c.match`[{[]`(13文字)
  • c.match`}|]`(12文字)
  • c.match`[}\\]]`(15文字)

⭐️結果・・・12文字で閉じ括弧の条件式 c.match`}|]` の勝利!

c=='}'|c==']' の場合のみ13文字から1文字減らすことが出きました!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /"(\\.|[^"])*"|\s|[{}[\],:]/g,
    (c) => {
      if (!/\S/.test(c)) { return ''                        ; }
      if (c[1]         ) { return              c            ; }
      if (c==':'       ) { return              c + ' '      ; }
      if (c==','       ) { return              c + indent(0); }
      if (c=='{'|c=='[') { return              c + indent(2); }
      if (c.match`}|]` ) { return indent(-2) + c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

developer.mozilla.org

developer.mozilla.org

5.6. 空白のマッチの仕方を変える

さて、!/\S/.test(c) の条件式がちょっと長いですね。 そもそもこのチェックをしなくていいようにはできないでしょうか?

そこで正規表現
/"(\\.|[^"])*"|\s|[{}[\],:]/g/\s*("(\\.|[^"])*"|[{}[\],:])\s*/g とします。

併せて、マッチした部分を引数で受け取る部分: (c) =>(_, c) => としておきます。さらに2文字増えちゃいますが!

図にするとこんな感じです!

これで if (!/\S/.test(c)) { return ''; } の条件分岐が不要になります!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) => {
      if (c[1]         ) { return              c            ; }
      if (c==':'       ) { return              c + ' '      ; }
      if (c==','       ) { return              c + indent(0); }
      if (c=='{'|c=='[') { return              c + indent(2); }
      if (c.match`}|]` ) { return indent(-2) + c            ; }
      
      throw new Error('ありえないはず!');
    },
  );
}

if 文が1行減りました!やったね!!

5.7. 条件式の整理

この似ている2行もまとめてしまいましょう。

if (c==','       ) { return c + indent(0); }
if (c=='{'|c=='[') { return c + indent(2); }

  • true * 21*22
  • false * 20*20

より

if (c=='{'|c=='['|c==',') { return c + indent((c!=',')*2); }

と書けますね!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + ' '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) => {
      if (c[1]                ) { return              c                     ; }
      if (c==':'              ) { return              c + ' '               ; }
      if (c.match`}|]`        ) { return indent(-2) + c                     ; }
      if (c=='{'|c=='['|c==',') { return              c + indent((c!=',')*2); }
      
      throw new Error('ありえないはず!');
    },
  );
}

if 文がまた1行減りました!

*2 が邪魔なので const indent = n => '\n' + ' '.repeat(spaces += n);' ' の半角スペースをそもそも2個にしておきましょう。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + '  '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) => {
      if (c[1]                ) { return              c                 ; }
      if (c==':'              ) { return              c + ' '           ; }
      if (c.match`}|]`        ) { return indent(-1) + c                 ; }
      if (c=='{'|c=='['|c==',') { return              c + indent(c!=','); }
      
      throw new Error('ありえないはず!');
    },
  );
}

だいぶ整理できました!いよいよ一番長い条件式を省略しちゃいます。 4つの条件式の中で一番長いのは c=='{'|c=='['|c==',' ですね!

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + '  '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) => {
      if (c[1]        ) { return              c      ; }
      if (c==':'      ) { return              c + ' '; }
      if (c.match`}|]`) { return indent(-1) + c      ; }
      
      return c + indent(c!=',');
    },
  );
}

そして if (c==':') { return c + ' '; }if (c==':') { return ': '; } ですね。

export default function beautify(jsonString) {
  let spaces = 0;
  const indent = n => '\n' + '  '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) => {
      if (c[1]        ) { return c             ; }
      if (c==':'      ) { return ': '          ; }
      if (c.match`}|]`) { return indent(-1) + c; }
      
      return c + indent(c!=',');
    },
  );
}

またこのへんで文字数を確認してみます!

さっきは635文字だったのでもう約2/3に!
この調子でどんどん減らしましょう〜〜

そろそろ if 文を三項演算子に、 「function 文」を「アロー関数式」に書き換えましょう。 おいおい、export default の後に続いてるから「function 文」ではなく「function 式」じゃないの?と思ってしまいがちですがここは例外的に「function 文」扱いらしいです。仕様的に。

developer.mozilla.org

As a special case, functions and classes are exported as declarations, not expressions, and these declarations can be anonymous. This means functions will be hoisted.

なので今まではセミコロンを ; 最後に付けていなかったのですがアロー関数式にするので一応つけておきます。鬼が見ているので。まぁどうぜ最後には取っちゃうんですが!

export default (jsonString) => {
  let spaces = 0;
  const indent = n => '\n' + '  '.repeat(spaces += n);

  return jsonString.replace(
    /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
    (_, c) =>
      c[1]         ? c            :
      c==':'       ? ': '         :
      c.match`}|]` ? indent(-1)+c :
                     c+indent(c!=','),
  );
};

ここで

let spaces = 0;
const indent = n => '\n' + '  '.repeat(spaces += n);

letconst が長いので、デフォルト引数の挙動を利用して短くしちゃいます。beautify() には引数が1つしか渡ってこないので!

export default (
  jsonString,
  spaces = 0,
  indent = n => '\n' + '  '.repeat(spaces += n),
) => jsonString.replace(
  /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
  (_, c) =>
    c[1]         ? c            :
    c==':'       ? ': '         :
    c.match`}|]` ? indent(-1)+c :
                   c+indent(c!=','),
);

文字数をチェックすると…310文字!

変数名も1文字にしちゃいましょう。

export default (
  j,
  s = 0,
  i = n => '\n' + '  '.repeat(s += n),
) => j.replace(
  /\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
  (_, c) =>
    c[1]         ? c       :
    c==':'       ? ': '    :
    c.match`}|]` ? i(-1)+c :
                   c+i(c!=','),
);

文字数がどんどん減っていくのが楽しい〜!!

余計な半角スペースも取ります。 だんだん解読不能になってきました。

export default(
j,
s=0,
i=n=>'\n'+'  '.repeat(s+=n),
)=>j.replace(
/\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,
(_,c)=>
c[1]?c:
c==':'?': ':
c.match`}|]`?i(-1)+c:
c+i(c!=','),
);

わいわい!!

改行もなくします!

export default(j,s=0,i=n=>'\n'+'  '.repeat(s+=n))=>j.replace(/\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,(_,c)=>c[1]?c:c==':'?': ':c.match`}|]`?i(-1)+c:c+i(c!=','))

さてさてゴールが近そうですが文字数は、、

もうこれ以上は無理でしょ!と思いきや、、

'\n'

は テンプレートリテラルで実際の改行を入れたほうが、(今回の文字数カウントの仕組み上)1文字短くカウントされるので

`
`

にすると、、

やった!!!

最後に引数が json になるとおしゃれな気がするので io に置換して、、

export default(j,s=0,o=n=>`
`+'  '.repeat(s+=n))=>j.replace(/\s*("(\\.|[^"])*"|[{}[\],:])\s*/g,(_,c)=>c[1]?c:c==':'?': ':c.match`}|]`?o(-1)+c:c+o(c!=','))

6. 終わりに

いやぁ。長い道のりでしたね〜〜。
最後まで読んでくださりありがとうございます。笑

JavaScript の仕様への理解を深めるのにはもってこいの体操でした。
でも業務ではなるべく独自実装は避け、JSON.parse()JSON.stringify() でやってくださいね!鬼上司に怒られちゃいますよ!

もし154文字より短くできた方がもしいらっしゃったら、是非教えてください!!!



ふぅ、頭を使ったのでケーキでも食べましょうね。
それでは、メリークリスマス〜〜〜!🎅🎄🎅




面白法人カヤックでは一緒に働く仲間を募集しています。JS 好きな方はぜひぜひご応募ください!
www.kayac.com

www.kayac.com