0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptで学ぶDecorator:元の契約を保って責務を重ねる

0
Posted at

ログ、計測、再試行、権限確認、cache。これらは複数の処理へ横断的に加えたい一方、本来の業務処理へ直接書くと中心的な意図を埋もれさせます。共通helperへ切り出しても、呼び出す順序や例外処理を各所で再現すれば重複は残ります。

Decorator(デコレーター)は、元のobjectと同じinterfaceを保ちながら、外側から責務を追加する構造パターンです。JavaScriptでは高階関数を使うと小さく表現できます。本記事では記事保存処理へログ、再試行、timeoutを段階的に重ね、合成順序、キャンセル、テスト、失敗しやすい設計まで扱います。

なお、GoFのDecoratorパターンと、@decorator というECMAScript/TypeScriptの言語構文は関連する用途を持ちますが同義ではありません。この違いも後半で整理します。

横断処理が本体へ混ざった例

記事を保存する処理へログと再試行を直接追加すると、何を保存するかより、周辺処理の方が長くなります。

type Post = Readonly<{ id: string; title: string }>;

async function savePostBefore(post: Post): Promise<void> {
  console.info("save:start", { postId: post.id });

  let lastError: unknown;
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    try {
      await postRepository.save(post);
      console.info("save:success", { postId: post.id, attempt });
      return;
    } catch (error) {
      lastError = error;
      console.error("save:failed", { postId: post.id, attempt, error });
    }
  }

  throw lastError;
}

この関数を変更する理由は、保存仕様、ログ形式、再試行回数、再試行可能なエラーの判定と複数あります。別の publishPost に同じ機能を付けるとcopyが始まります。逆に共通関数へ無理にまとめると、処理ごとに異なる引数を受ける巨大helperになりがちです。

責務を分けるには、まず中心となる処理の契約を明確にします。キャンセルを伝播できるよう AbortSignal も引数に含めます。

type SavePost = (
  post: Post,
  options?: { signal?: AbortSignal },
) => Promise<void>;

const savePost: SavePost = async (post, options) => {
  await postRepository.save(post, options?.signal);
};

Decoratorを正確に定義する

GoFのDecoratorは、Componentと同じinterfaceを実装するDecoratorが、内側のComponentを保持し、呼び出しの前後へ振る舞いを追加するパターンです。利用側から見ると、元のComponentと装飾後のobjectを同じように扱えます。DecoratorをさらにDecoratorで包めるため、継承で全組み合わせのsubclassを作らず、必要な責務を合成できます。

JavaScriptでは関数も値なので、SavePost を受け取り SavePost を返す高階関数が同じ構造を表せます。入力、戻り値、例外、キャンセルという契約を保つことが重要です。単に前後で何かを実行するwrapperが、元と異なる型や意味を返すなら交換可能ではありません。

ログDecoratorを作る

loggerを外から受け取り、保存処理の開始、成功、失敗を記録します。元のerrorを握りつぶさず再throwし、利用側の失敗契約を維持します。

type Logger = Pick<Console, "info" | "error">;

function withLogging(inner: SavePost, logger: Logger): SavePost {
  return async (post, options) => {
    logger.info("save:start", { postId: post.id });
    try {
      await inner(post, options);
      logger.info("save:success", { postId: post.id });
    } catch (error) {
      logger.error("save:failed", { postId: post.id, error });
      throw error;
    }
  };
}

装飾後も SavePost なので、呼び出し方法は変わりません。

const loggedSavePost = withLogging(savePost, console);

await loggedSavePost(
  { id: "post-1", title: "Decoratorを学ぶ" },
  { signal: new AbortController().signal },
);

loggerをmodule globalから直接読むのではなく引数にしたため、テストでは記録先を差し替えられます。また、記事本文やtokenのような機密情報を無条件にlogへ含めないこともDecoratorの責務です。汎用loggerだから安全、とは限りません。

再試行Decoratorを加える

再試行では、回数だけでなく「何を再試行してよいか」が重要です。認証失敗やvalidation errorまで繰り返しても成功しません。判定関数と待機関数を依存として受け取ると、方針を明示して決定的にテストできます。

type RetryOptions = Readonly<{
  maxAttempts: number;
  isRetryable: (error: unknown) => boolean;
  wait: (attempt: number, signal?: AbortSignal) => Promise<void>;
}>;

function withRetry(inner: SavePost, options: RetryOptions): SavePost {
  if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
    throw new RangeError("maxAttempts must be at least 1");
  }

  return async (post, callOptions) => {
    for (let attempt = 1; ; attempt += 1) {
      try {
        await inner(post, callOptions);
        return;
      } catch (error) {
        if (
          attempt >= options.maxAttempts ||
          !options.isRetryable(error) ||
          callOptions?.signal?.aborted
        ) {
          throw error;
        }
        await options.wait(attempt, callOptions?.signal);
      }
    }
  };
}

待機は AbortSignal を受け取り、利用者がキャンセルしたら直ちに止めます。次はbrowserとNode.jsで使える最小実装です。

function wait(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);

    const onAbort = () => {
      clearTimeout(timer);
      reject(signal?.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

const retriedSavePost = withRetry(savePost, {
  maxAttempts: 3,
  isRetryable: (error) => error instanceof TypeError,
  wait: (attempt, signal) => wait(100 * 2 ** (attempt - 1), signal),
});

再試行は副作用を重複させます。HTTP requestならidempotency key、DB操作ならtransactionや一意制約など、同じ処理が複数回実行されても壊れない設計が必要です。「通信エラーが返ったが、サーバー側では保存済み」という状況を無視してはいけません。

timeoutは処理を止められるか

Promiseを Promise.race へ入れるだけでは、負けた処理自体は止まりません。裏で通信や書き込みが続く可能性があります。内側へ AbortSignal を渡せる契約にして、timeout時にabortします。

function withTimeout(inner: SavePost, timeoutMs: number): SavePost {
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
    throw new RangeError("timeoutMs must be positive");
  }

  return async (post, options) => {
    const timeoutController = new AbortController();
    const timer = setTimeout(
      () => timeoutController.abort(new Error("save timed out")),
      timeoutMs,
    );

    const signal = options?.signal
      ? AbortSignal.any([options.signal, timeoutController.signal])
      : timeoutController.signal;

    try {
      await inner(post, { signal });
    } finally {
      clearTimeout(timer);
    }
  };
}

AbortSignal.any を利用できない実行環境では、複数signalをまとめるhelperやpolyfillが必要です。また、内側のrepositoryがsignalを無視するなら、呼び出し側へtimeout errorを返せても、処理は中断されません。Decoratorが保証できる範囲を文書化します。

合成順序は振る舞いである

Decoratorは外側から内側へ呼び出し、結果は内側から外側へ戻ります。したがって、同じ3つを重ねても順序で意味が変わります。

const enhancedSavePost = withLogging(
  withRetry(withTimeout(savePost, 2_000), {
    maxAttempts: 3,
    isRetryable: (error) => error instanceof TypeError,
    wait: async () => {},
  }),
  console,
);

この順序ではログは全体で開始・成功・失敗を1回記録し、各attemptには2秒のtimeoutが掛かります。withLogging をretryの内側へ置けばattemptごとにlogが出ます。withTimeout を最外周へ置けば、待機を含む全attemptの合計時間を制限します。

withLogging
  └─ withRetry
       └─ withTimeout
            └─ savePost

どれが正解かは監視要件です。「全体のlatency」と「attemptごとのlatency」のどちらを測るのかを決めます。順序が重要なのに、あちこちで手作業で合成すると不整合が生まれます。composition rootで意味のある名前を付けた完成形を作ると追跡しやすくなります。

テストで委譲と失敗を確認する

Decoratorのテストでは、内側が適切な回数呼ばれること、引数とerrorが維持されること、追加責務が意図した範囲で働くことを確認します。

import assert from "node:assert/strict";
import test from "node:test";

test("retry delegates until the operation succeeds", async () => {
  let calls = 0;
  const unstable: SavePost = async () => {
    calls += 1;
    if (calls < 3) throw new TypeError("temporary network failure");
  };

  const decorated = withRetry(unstable, {
    maxAttempts: 3,
    isRetryable: (error) => error instanceof TypeError,
    wait: async () => {},
  });

  await decorated({ id: "post-1", title: "test" });
  assert.equal(calls, 3);
});

再試行してはいけないerrorがそのまま外へ出ることも重要です。

test("retry preserves a non-retryable error", async () => {
  const original = new Error("validation failed");
  let calls = 0;
  const invalid: SavePost = async () => {
    calls += 1;
    throw original;
  };

  const decorated = withRetry(invalid, {
    maxAttempts: 3,
    isRetryable: (error) => error instanceof TypeError,
    wait: async () => {},
  });

  await assert.rejects(
    decorated({ id: "post-1", title: "" }),
    (error) => error === original,
  );
  assert.equal(calls, 1);
});

logger testでは、error object全体のsnapshotよりevent名とIDを確認すると、内部形式の変更に強くなります。timeout testでは実時間を待つよりfake timerを使うと高速で安定します。

objectを包むDecorator

複数メソッドを持つinterfaceや、instanceとして依存注入したい場合はobject wrapperが適します。GoFの典型形に近い実装です。

interface Notifier {
  send(message: string): Promise<void>;
}

class LoggingNotifier implements Notifier {
  constructor(
    private readonly inner: Notifier,
    private readonly logger: Logger,
  ) {}

  async send(message: string): Promise<void> {
    this.logger.info("notify:start");
    try {
      await this.inner.send(message);
      this.logger.info("notify:success");
    } catch (error) {
      this.logger.error("notify:failed", error);
      throw error;
    }
  }
}

Decoratorは Notifier の全契約を保つ必要があります。interfaceへ新メソッドが追加されたとき、wrapper側の実装漏れはTypeScriptが検出できます。JavaScriptの Proxy で汎用的に横取りする方法もありますが、private field、this binding、property descriptor、デバッグ時の追跡に注意が必要です。明示的なwrapperの方が安全なことは少なくありません。

言語構文のdecoratorとは別物

ECMAScript decoratorsは、class、method、fieldなどの定義時に処理を適用する言語機能です。TypeScriptにも現行のdecorator対応と、過去の experimentalDecorators があります。次の構文を使ったからGoF Decoratorになる、使わないからパターンを実装できない、という関係ではありません。

function loggedMethod<This, Args extends unknown[], Return>(
  original: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This>,
) {
  return function (this: This, ...args: Args): Return {
    console.log(`calling ${String(context.name)}`);
    return original.call(this, ...args);
  };
}

class Calculator {
  @loggedMethod
  add(a: number, b: number): number {
    return a + b;
  }
}

言語decoratorは定義へ処理を適用する仕組み、GoF Decoratorは同じinterfaceを持つobjectを包んで責務を合成する設計です。method decoratorが結果としてGoFに似たwrapperを作ることはありますが、評価時期、適用対象、交換方法が異なります。

RxJS operatorをDecorator的に読む

RxJS公式はpipeable operatorを「Observableを入力に取り、別のObservableを返す関数」と説明しています。元のObservable instanceは変更されません。operatorを pipe で重ね、変換、絞り込み、副作用などを宣言的に合成できます。

import { filter, map, of, tap } from "rxjs";

const source$ = of(1, 6, 10);

const result$ = source$.pipe(
  map((value) => value * 2),
  filter((value) => value > 10),
  tap((value) => console.log(value)),
);

同じObservableという抽象を保ちながら振る舞いを重ねる点はDecorator的です。ただし、RxJS公式はpipeable operatorをGoF Decoratorとして分類していません。operatorにはlazyなsubscription、stream変換、error/completionといったRxJS固有の意味があります。本記事での対応付けは、合成可能なwrapperという考え方を学ぶ教育的な類推です。

NgRx meta-reducerをDecorator的に読む

NgRxのMetaReducerは、reducerを受け取りreducerを返します。公式資料でも、actionの記録、stateの前処理・後処理、元reducerへの委譲などに使えると説明されています。

import type { Action } from "@ngrx/store";

function loggerMetaReducer<State>(
  reducer: (state: State | undefined, action: Action) => State,
) {
  return (state: State | undefined, action: Action): State => {
    console.log("before", state, action);
    const nextState = reducer(state, action);
    console.log("after", nextState);
    return nextState;
  };
}

元と同じreducer契約を返し、前後へ責務を加えるため、関数Decoratorに非常に近い構造です。それでも、NgRxの公式分類はMetaReducerであり、GoFパターンとしての宣言ではありません。またreducerは同期的で純粋な状態遷移という制約を持つため、非同期retryのようなDecoratorを同じ感覚で入れてはいけません。

より単純な代替案とトレードオフ

1つの処理だけへ1行のlogを足すなら、関数内へ直接書く方が理解しやすいでしょう。多数のHTTP handlerへ同じ関心事を適用するなら、frameworkのmiddlewareやinterceptorの方が適用範囲を管理しやすい場合があります。固定した振る舞いをclass階層全体で共有するなら継承も候補ですが、責務の組み合わせごとにsubclassが増えます。

Decoratorは組み合わせやすい反面、実際の実行順序が生成場所を見なければ分かりません。stack traceがwrapperで長くなり、関数名やmetadataが失われることもあります。同じDecoratorを二重適用するとlogや課金が重複します。適用済みを検出する仕組みを足すより、composition rootで一度だけ組み立てる方が単純なことも多いです。

失敗しやすい設計

  • 元のerrorを別errorへ置き換え、原因やstackを失う。包むなら cause を保持します。
  • 引数や AbortSignal を内側へ渡し忘れ、契約を壊す。
  • Promise.race だけで処理をキャンセルできたと誤解する。
  • retry可能性とidempotencyを確認せず、副作用を重複させる。
  • 合成順序をテストせず、計測範囲やtimeout範囲を変えてしまう。
  • 可変objectをDecorator間で共有し、同時実行の情報を混ぜる。
  • arrow functionでmethodを包む際に必要な this を失う。
  • 業務の中心処理まで多数のwrapperへ分散し、全体を追えなくする。

cache Decoratorではcache key、期限、errorをcacheするか、同時requestをまとめるかまで必要です。認可Decoratorでは「実行前に必ず拒否できるか」、transaction Decoratorでは「内側の全I/Oが同じtransactionを使うか」を確認します。責務名が簡単でも、保証する意味は具体的に定義してください。

使うかどうかの判断基準

Decoratorが向くのは、複数の対象へ同じ横断的責務を選択的に付けたい、元の契約を保てる、責務の組み合わせが複数ある、継承より実行時の合成が適している場合です。ログ、計測、再試行、timeout、cache、認可などが代表例ですが、対象処理の意味に合うことが前提です。

導入前に「元の入力・出力・error・キャンセルを保てるか」「適用順序は何を意味するか」「副作用を複数回実行してよいか」「どこで一度だけ組み立てるか」「middlewareなど既存の仕組みの方が適切ではないか」を確認します。wrapperが1つで再利用もないなら、直接実装する方が読みやすい可能性があります。

まとめ

Decoratorは、元と同じ契約を維持したwrapperを重ね、中心処理から横断的な責務を分離します。JavaScriptでは高階関数で小さく始め、複数methodを持つ場合はobject wrapperを使えます。価値は行数削減ではなく、責務ごとの変更とテストを局所化し、必要な組み合わせを明示できることです。

実装では合成順序、errorの保持、キャンセル、idempotencyが品質を左右します。また、RxJS operatorやNgRx MetaReducerに構造上の類似はありますが、公式のGoF分類ではありません。言語構文のdecoratorとも区別し、何を包み、どの契約を守るのかを基準に設計してください。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?