なぜエラーの抽象化が必要なのか
例として、あるアプリケーションにローカルファイルへの操作を抽象化した次のような Storage
クラスがあったとします。
import fs from "fs/promises";
export class Storage {
async get(key) {
const buf = await fs.readFile(key, {
encoding: "utf-8",
});
return buf.toString();
}
}
get()
メソッドにファイルパスを与えると、ファイルの内容を文字列として返します。
この Storage
クラスを利用するコードの一例として、次のような getConfig()
関数があったとします。
async function getConfig(storage) {
try {
const config = await storage.get("config.json");
return JSON.parse(config);
} catch (e) {
if (e.code === "ENOENT") {
return {};
}
throw e;
}
}
getConfig()
はローカルの config.json
を取得し、オブジェクトとして返す関数です。
もし config.json
が存在しなかった場合、空のオブジェクトを返す仕様となっています。
さて、ある時このアプリケーションの要件が変わり、ローカルのファイルシステムをストレージとして利用するのをやめて S3 を使わなければならなくなったとします。
ストレージを抽象化している Storage
クラスを変更すれば対応できそうです。
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
export class Storage {
#bucket;
#s3Client;
constructor(bucket, config) {
this.#bucket = bucket;
this.#s3Client = new S3Client(config);
}
async get(key) {
const command = new GetObjectCommand({
Bucket: this.#bucket,
Key: key,
});
const res = await this.#s3Client.send(command);
let out = "";
for await (const chunk of res.Body) {
out += chunk.toString();
}
return out;
}
}
get()
メソッドのインタフェースを変更せずにストレージを S3 に差し替えることができました。
コンストラクタは変更されているのでアプリケーションの初期化部分のコードは修正が必要ですが、大部分のコードは S3 対応の影響を受けずに動作するはずです。
このように抽象化をうまく用いることで変更に強いコードを書くことができます。
しかし前述の getConfig()
の場合はそうはいきません。
config.json
の取得元が変わったことにより、ファイルが存在しなかった場合に発生するエラーも変わってしまっているためです。
仕様通りに動作させるためには次のような変更が必要です。
async function getConfig(storage) {
try {
const config = await storage.get("config.json");
return JSON.parse(config);
} catch (e) {
if (e.name === "NoSuchKey") {
return {};
}
throw e;
}
}
結局、get()
メソッドの呼び出し元で ENOENT
のような SystemError をハンドリングしているコードはすべて変更する必要が出てしまいました。
何がまずかったのでしょうか。
それは Storage
クラスがエラーを抽象化せず、下位レイヤーのエラーをそのまま上位レイヤーに投げてしまう設計になっていた点です。
より変更に強いコードにするには、呼び出し元でハンドリングされ得るエラーを適切に抽象化してあげる必要があります。
今回のケースでは「ファイルが存在しない場合のエラー」を抽象化するように Storage
クラスを設計すべきでした。
import fs from "fs/promises";
export class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = "NotFoundError";
}
}
export class Storage {
async get(key) {
try {
const buf = await fs.readFile(key, {
encoding: "utf-8",
});
return buf.toString();
} catch (e) {
if (e.code === "ENOENT") {
throw new NotFoundError();
}
throw e;
}
}
}
このような実装になっていれば getConfig()
では抽象化した NotFoundError
のみを扱えばよくなるので下位レイヤーのエラーを直接ハンドリングする必要がなくなります。
async function getConfig(storage) {
try {
const config = await storage.get("config.json");
return JSON.parse(config);
} catch (e) {
if (e instanceof NotFoundError) {
return {};
}
throw e;
}
}
ストレージを S3 に差し替えても NotFoundError
を throw するように実装すれば getConfig()
に影響は出ません。
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
export class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = "NotFoundError";
}
}
export class Storage {
#bucket;
#s3Client;
constructor(bucket, config) {
this.#bucket = bucket;
this.#s3Client = new S3Client(config);
}
async get(key) {
const command = new GetObjectCommand({
Bucket: this.#bucket,
Key: key,
});
try {
const res = await this.#s3Client.send(command);
let out = "";
for await (const chunk of res.Body) {
out += chunk.toString();
}
return out;
} catch (e) {
if (e.name === "NoSuchKey") {
throw new NotFoundError();
}
throw e;
}
}
}
元のエラー情報を失わずに抽象化する
上記の NotFoundError
を用いたコードにも問題があります。
それはエラーを抽象化する際に元のエラーの情報が失われてしまっているという点です。
async get(key) {
try {
const buf = await fs.readFile(key, {
encoding: "utf-8",
});
return buf.toString();
} catch (e) {
if (e.code === "ENOENT") {
// 全く新しい Error を生成しているので元の e が失われている。
throw new NotFoundError();
}
throw e;
}
}
予期せぬエラーが発生した際のログ出力やデバッグのことを考えると、エラーを抽象化する際にその原因となったエラーの情報は保持したまま throw したいところです。
つまり、エラーを抽象化する際に元のエラーを wrap して繋いでいくような仕組み (エラーチェイン) が必要です。
Error Cause を使おう
Error Cause はエラーチェインを標準化する ECMAScript の仕様で、2021 年 10 月 26 日に Stage 4 に移行 (ES2022 で標準化されることが決定) しました。
この仕様では
- Error のコンストラクタの第二引数にオプションとして原因となったエラー
cause
を与えられること - Error インスタンスの
cause
プロパティで原因となったエラーを取得できること
を規定しています。
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });
console.error(err3);
console.error(err3.cause); // err2 が出力される。
console.error(err3.cause.cause); // err3 が出力される。
仕様としてはこれだけです。
これだけの仕様なら Error を継承したカスタムエラーで再現することは容易です。
また、verror のようにエラーチェインを実現するためのライブラリもいくつか存在しています。
ではなぜあえて Error Cause を標準化する必要があったのでしょうか。
それは cause
プロパティを標準仕様として規定することにより、デバッグツールなどでエラーチェインを扱えるようにするためです。
例として jest では Error Cause の標準化を受けてテスト中に発生したエラーのエラーチェインを辿ってすべての stack trace を出力する機能が提案され、前向きに検討されています。
今後は Error Cause を使っていく方が様々なツールの恩恵を受けやすくなっていくものと思われます。
そんな Error Cause ですが、Node.js では v16.9.0 から使用可能になっています。
また、モダンブラウザの一部でも利用可能です。
Node.js v12, v14 や非対応のブラウザでは error-cause や Pony Cause といった polyfill を使用すると良いでしょう。
Error Cause において重要なのは cause
プロパティなので、あるいは cause
プロパティを持ったカスタムエラーを作っても同様の恩恵が得られるはずです。
チェインしたエラーも含めた stack trace を出力する
Error Cause でチェインしたエラーをコンソール出力しても cause
の情報は何も出力されません(stack
プロパティも同様)。
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });
console.error(err3); // err2, err1 の情報は出力されない。
console.error(err3.stack); // err2, err1 の stack は出力されない。
実際の利用を考えると、エラーチェインを辿って stack trace を生成するような関数を用意してあげると良いでしょう1。
export function stackWithCauses(err: unknown): string {
return stackWithCausesRecursively(err, new Set());
}
function stackWithCausesRecursively(err: unknown, seen: Set<Error>): string {
if (err instanceof Error) {
const stack = err.stack ? err.stack : err.toString();
if (seen.has(err)) {
return `${stack}\n... (truncated because the causes have become circular)`;
}
if (isErrorWithCause(err)) {
const cause = err.cause;
if (cause) {
seen.add(err);
return `${stack}\nCaused by ${stackWithCausesRecursively(cause, seen)}`;
}
}
return stack;
}
return "";
}
interface ErrorWithCause extends Error {
cause: unknown;
}
function isErrorWithCause(err: unknown): err is ErrorWithCause {
return err instanceof Error && "cause" in err;
}
コンソール出力するときは次のようになります。
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });
console.log(stackWithCauses(err3));
Node.js での小技
何も考えずに console.error(e)
でいい感じに cause
の情報も出力したい!という場合、Node.js ならカスタムエラーに util.inspect.custom シンボルのメソッドを定義してあげれば実現可能です。
import util, { InspectOptions } from "util";
interface InspectOptionsWithSeen extends InspectOptions {
// あまり良い方法とは思えませんが、
// cause の循環をチェックするため InspectOptions に
// 参照済みの Error を記録します。
_seen?: Set<Error>;
}
export class CustomError extends Error {
constructor(message?: string, options?: { cause?: unknown }) {
super(message, options);
}
[util.inspect.custom](_depth: number, options: InspectOptionsWithSeen) {
const noCustomInspect: InspectOptions = Object.assign({}, options, {
customInspect: false,
});
const original = util.inspect(this, noCustomInspect);
if (options._seen?.has(this) ?? false) {
return `${original}\n... (truncated because the causes have become circular)`;
}
if (!this.cause) {
return original;
}
if (!options._seen) {
options._seen = new Set();
}
options._seen.add(this);
return `${original}\nCaused by ${util.inspect(this.cause, options)}`;
}
}
-
Pony Cause には定義されています
TypeScript で実装すると次のような感じでしょうか。 ↩