1
/
5

業務中遭遇したバグでクイズ作ってみた

バックエンドエンジニアの武村です。(弊社ではとても珍しい) 新卒として learningBOX に入社し、強くて偉大な先輩たちの背中を眺めながら日々業務に邁進しています。

弊社 learningBOX は LMS (Learning Management System) 、いわゆる eラーニングシステムを開発する会社ですが、もともとは Web 上でクイズを作成・公開するサービスとして始まりました。それにあやかり、私も何問かクイズを出してみようと思います。題して「業務中に遭遇したバグでクイズ作ってみた」。プログラムを問題として出題するので、それを実行した結果を予測してみてください。

第1問

console.log('ok' ?? 'ng' === 'ok');

?? という演算子をご存じでしょうか。私も詳しく知ったのは最近なのですが、プログラミングで登場した演算子としては新しいものであるという認識です(間違ってたらすいません)。第1問は、そんな ?? を使った問題です。

第2問

const parentObject = {};

console.log(parentObject.child);

console.log(parentObject.child.grandChild);

第2問は、比較的有名な仕様からの問題です。

第3問

console.log(new Date(2023, 1, 31).toLocaleDateString());

第3問も、よく知られている見落としポイントからの出題です。

第4問

次のような JSON を、常にステータスコード 200 で返すバックエンドとの通信を考えます。

interface Response {
  result: boolean;
  error?: Record<string, string>; // result === true のとき error は存在しない
}
axios
  .post('/sampleRequest')
  .then(({ data }: { data: Response }) => {
    console.log('request success');
    for (const [key, value] of Object.entries(data.error)) {
      console.log(`${key}:${value}`);
    }
  })
  .catch(() => {
    console.log('caught unknown error');
  });

axios を使ったこのコードでバックエンドとの通信を行い、error メンバが存在しないようなレスポンスを受け取ったとき、このコードはどのような動きになるでしょう?

色々おかしなコードですが、出題のためのサンプルですのでご容赦いただければ…

自前でバックエンドを用意するのは難しいかもしれないので、問題の範囲でバックエンドと同等の動作をするコードを用意しました。これで paiza.io での検証が可能になります。

const axios = {
  post: (_requestURI: string) => {
    return new Promise((resolve, reject) => {
      resolve({ data: { result: true } });
    });
  },
};

3 つのコードを繋げて記述することで、実行することができます (答えで paiza.io の実装例を掲載します。パッと方法が頭に浮かばない人はどうすればうまくいくか色々試してみよう!) 。

答え

第1問

問題(再掲)

console.log('ok' ?? 'ng' === 'ok');

答え

ok

?? は Null合体演算子と言って、左辺値が null (JavaScriptでは undefined も含む) のときに右辺値が参照される演算子です。null 判定から代替値の代入までを 1 行で行えることから、弊社の業務でも大活躍する演算子です。

しかし、Null合体演算子を他の演算子と組み合わせようとしたとき、悲劇が起こります。Null合体演算子の優先順位は低いため、右辺値のさらに右にある他の演算子との演算が先に行われてしまいます。この問題の例では 'ok' ?? 'ng' よりも 'ng' === 'ok' を先に評価してしまい、その結果真偽値が返ってきそうな式に対してなぜか文字列 ok が返ってきてしまう、ということが起こりました。

この悲劇の根深いところは、null を代入した場合は正しく評価されて false が返ってくるという点です。異常な左辺値である null だと逆に正しく動作してしまうタイプの不具合のため、より気づきにくい厄介なバグとなって立ちはだかりました。

対策は簡単で、Null合体演算子を(代入などごく一部の例外を除く)他の演算子と合わせて使うとき、括弧を使って評価の順番を明示しましょう。

演算子の優先順位 - JavaScript | MDN (mozilla.org)

第2問

問題(再掲)

const parentObject = {};

console.log(parentObject.child);

console.log(parentObject.child.grandChild);

答え

undefined
Uncaught TypeError: Cannot read properties of undefined (reading 'grandChild')

答えは基本的に Google Chrome で実行したものから引っ張ってきているので、ブラウザによっては異なるメッセージが表示されるかもしれません。

JavaScript の仕様によりオブジェクトの存在しないプロパティを参照しようとすると、undefined が返ってきます。これが 1 行目の出力です。

しかし undefined はオブジェクトではないので、undefined からさらにプロパティを参照しようとするとエラーになってしまいます。より具体的には、undefined に . 演算子に対する演算結果が設定されていないためエラーになります。これが 2 行目の出力です。

ただ「オブジェクトの構造がよく分からない状態で、奥まったところにいるメンバを参照したい」ということは、業務ではそこそこあるシチュエーションです。そのようなときには、オプショナルチェーンを使うと安全性と可読性を両立することができます。

オプショナルチェーンは ?. の演算子で定義される操作で、null や undefined からの参照でエラーとなってしまうようなときに、エラーの代わりに undefined を返すというものです。第1問で Null合体演算子について紹介しましたが、オブジェクトにおける Null合体演算子の、さながら兄弟といったところです。

オプショナルチェーン (?.) - JavaScript | MDN (mozilla.org)

オプショナルチェーン演算子を使って問題のプログラムを書き換えると、次のようになります。

const parentObject = {};

console.log(parentObject?.child); // parentObject.child でも可

console.log(parentObject?.child?.grandChild); // parentObject.child?.grandChild でも可

これを実行すると、以下の出力を得ます。

undefined
undefined

第3問

問題(再掲)

console.log(new Date(2023, 1, 31).toLocaleDateString());

答え

2023/3/3

JavaScript の Date オブジェクトは日付計算を行うことができるオブジェクトですが、我々が使っている日付のフォーマットを内部に持っているというわけではありません。Date オブジェクトは UNIX 時間であり、入力された値に対する UNIX 時間を計算することで日付計算を可能にしています。

Date - JavaScript | MDN (mozilla.org)

では、入力する値の形式は我々の直感と一致するかというと、そうでもないというのが Date オブジェクトの罠です。Date オブジェクトは基本的に 0-indexed で計算されるため(日を除く)、1年は0月1日から始まり、11月31日で終わるようになっています。また、曜日は 0 から 6 で表されます。

この情報をもとに問題を読み解くと、月の部分の 1、これは 2 番目の月なので実際には2月であり、日の部分の 31、これは 31 番目の日なので実際には31日となります。2023年2月31日、これを我々が使っている日付に直すと 2023年3月3日となり、答えと一致しました。

第4問

問題(再掲)

interface Response {
  result: boolean;
  error?: Record<string, string>; // result === true のとき error は存在しない
}
axios.post('/sampleRequest')
  .then(({ data }: { data: Response }) => {
    console.log('request success');
    for (const [key, value] of Object.entries(data.error)) {
      console.log(`${key}:${value}`);
    }
  })
  .catch(() => {
    console.log('caught unknown error');
  });

問題の説明文は省略しました。

答え

request success
caught unknown error

axios によるリクエストは Promise オブジェクトを返します。Promise は非同期処理を行うオブジェクトであり、「ある処理を実行したけど、まだ結果が分からないよ~」という状態を保持しながら他の処理をする並列処理のためにあるオブジェクトです。Promise オブジェクト(が待っている処理)が何らかの結果を得たとき、then(成功), catch(失敗), finally(結果に関わらず最後に実行)の内部にある処理を実行するという仕組みになっています。

axios の場合、HTTP リクエストに対して 200 番台のレスポンスが返ってきたときは成功、それ以外のレスポンスが返ってきたときは失敗とみなすので、今回の問題の設定だと常に then の処理を通ります。しかし、then の処理内部で error メンバを参照するとき、undefined を引数にして Object.entries() を実行するので、ここで例外が発生します。

ここからが不思議な部分なのですが、then, catch の内部のオブジェクトも実体は Promise です。そのため、then や catch 内部で例外が発生すると ( then や catch での ) catch の処理が実行されます。もし Promise に catch や then に相当する処理の記述がなかったら、上位の Promise の catch や then を引き継ぎます。今回の場合、then 内部の処理で例外が発生しますが、then には catch にあたる処理は実装されていません。そのため、ひとつ上位の post 関数の Promise にある catch の処理が実行されます。その結果、then と catch の両方のコンソールメッセージが表示されました。

Promise や async, await などの非同期処理について知りたいときは、以下のサイトが参考になります。非常に長く読むのが大変ですが、それだけ非同期処理は難しい処理なのだと思います。

プロミスの使用 - JavaScript | MDN (mozilla.org)

問題で示した、axiosの代わりを使ったコードによる実装を以下のリンクに置いておきます。

https://paiza.io/projects/e/TYc7FzCKDxMBJdfyTNEMJg?theme=twilight

この不具合を防ぐ方策ですが、第2問と同じくオプショナルチェーンが有効です。しかし長期的な視点で見ると、エラーになるような HTTP Response が返ってくるようなケースでは 200 番台のステータスコードを返さないように、リファクタリングする必要があるでしょう。

最後に

丹精込めて作ったクイズ、楽しんでいただけましたか? 少し簡単すぎたかな?

実は、今日お出しした 4 問は全て実際の業務で遭遇したバグを元に作りました。業務ではこんなにも愉快なバグたちと遊ぶことができて楽しいです。それだけバグが多いということでもありますが……。また、バグを元に作っているので、プログラミングするうえで陥りやすい罠にフォーカスしたクイズになったのではないでしょうか。

QC から急に報告されるバグは好きになれませんが、このようにバグを深掘りして原因を探る過程は大好きです。また、僕も今回の記事の内容を踏まえ、問題のような書き方をしないように気をつけます。

今後面白いバグのネタができたら記事にしたいです。それではまた。

learningBOX株式会社では一緒に働く仲間を募集しています
48 いいね!
48 いいね!
同じタグの記事
今週のランキング
learningBOX株式会社からお誘い
この話題に共感したら、メンバーと話してみませんか?