TypeScriptでサードパーティー性ライブラリに依存しないように実装を考えてみた

はじめに

エンジニアの松原です。最近業務でAWSのLambda(Node.js)を利用した機能を開発しています。素のJavascriptを書いていくのは躊躇われたので、TypeScriptで書いています。

今回はTypeScriptを使ってコードを書いていくうちに、継承やインターフェースについて気になったため、Node.jsを使う上でほぼ必須となるサードパーティー製のライブラリを、利用する側のコードでそれらのライブラリになるべく依存しないよう、インターフェースを使って依存関係を分離してみた例を取り上げてみます。

今回サンプルで利用するサードパーティー製ライブラリとして、ログ出力でよく利用される winston を使って、標準入出力にログとして出力する際に呼び出し側がこのライブラリに依存しないように分離する実装を考えてみました。
以下にコードサンプルを用意しました。

github.com

実装部分について

以下にサンプルコードの実装について解説していきます。

基本となるインターフェース

利用側ではロガー(ログ出力機能)の本体部分で以下の LoggerContext のみ依存するようにします。初期化のための処理などは隠蔽したいため、 initialize() などのメソッドはインターフェースには持たせていません。
次に LoggerSettings ですが、ここはロガーを利用する際の設定として利用しつつ、後で拡張できるよう、こちらもインターフェースを利用しています。こちらも利用側で使用します。
最後の CreateLoggerDelegate インターフェースは実装側でロガーを作成・初期化するとして置いたもので、こちらは実装側のみが依存するようにデリゲートとして作ったものになります

// ./src/logger/logger.ts
export interface LoggerContext {
  debug(message: string): void,
  error(message: string): void,
  info(message: string): void,
  warn(message: string): void
}

export interface LoggerSettings {
  label: string
}

export interface CreateLoggerDelegate<S extends LoggerSettings> {
  (loggerSettings: S): LoggerContext;
}

ロガーの実装クラス

ロガーの実装クラスでは、 winston を利用しています。実装では以下のいくつかポイントがあり、

  • 環境変数 LOG_LEVEL の設定によって表示されるログレベルを変えられるようにする
  • winston はログレベルの変更に対応しているので、それを利用する
  • ロガーを利用する側はログレベルについては知らなくてよい

これらのポイントを押さえるように LoggerContext に対して実装を行っています。

createConsoleLogger は ConsoleLogger の存在を隠蔽するため、デリゲートとして定義しています。

// ./src/logger/ConsoleLogger.ts
import { LoggerContext, LoggerSettings, CreateLoggerDelegate } from './logger';
import { Logger, createLogger, format, transports }  from 'winston';

const logLevel = process.env.LOG_LEVEL ?? 'info';

const customFormat = format.printf(info => {
  return `[${info.label}] ${info.message}`;
});

class ConsoleLogger implements LoggerContext {
  private logger: Logger;

  constructor(label: string) {
    this.logger = createLogger({
      level: logLevel,
      format: format.combine(
        format.label({ label: label }),
        customFormat
      ),
      transports: new transports.Console()
    });
  }

  // <<後略>>
}

export const createConsoleLogger: CreateLoggerDelegate<LoggerSettings> =
  (loggerSettings: LoggerSettings): LoggerContext => new ConsoleLogger(loggerSettings.label);

ロガーを選択する関数

今後他のロガーも増やせるよう、引数設定によって実装が異なる LoggerContext のインターフェースを返却する関数を作成し、利用する側でこの関数を実行するようにしています。
もうちょっと丁寧にするのであれば、この関数自体もインターフェース化して渡してあげるのが良いかもしれません。

// ./src/logger/index.ts
import type { LoggerSettings, LoggerContext } from './logger';

import { createConsoleLogger } from './ConsoleLogger';
import { createDummyLogger } from './DummyLogger';

const LoggerType = {
  CONSOLE: 'console',
  DUMMY: 'dummy'
} as const;
type LoggerType = typeof LoggerType[keyof typeof LoggerType];

const createLogger = (loggerSettings: LoggerSettings, type: LoggerType = LoggerType.CONSOLE): LoggerContext => {
  if (type === LoggerType.CONSOLE) {
    return createConsoleLogger(loggerSettings);
  } else if (type === LoggerType.DUMMY) {
    return createDummyLogger(loggerSettings);
  } else {
    return createDummyLogger(loggerSettings);
  }
}

export { LoggerSettings, LoggerContext, LoggerType, createLogger };

利用する側の実装について

ロガーを利用するためにロガーの設定やタイプを指定している以外は、特に特別なコードを書くようにはしていません。createLoggerのメソッドを呼び出して、LoggerContext のインターフェースをもらい、そのインターフェースにある関数を呼び出すだけで良いようにしています。

// ./src/index.ts
import { createLogger, LoggerSettings, LoggerContext, LoggerType } from './logger';

const loggerSetting: LoggerSettings = {
  label: 'LoggerLabel'
};

const logger: LoggerContext = createLogger(loggerSetting, LoggerType.CONSOLE);

function main() {
  logger.debug('Hello World Debug!');
  logger.info('Hello World Info!');
  logger.warn('Hello World Warn!');
  logger.error('Hello World Error!');
}

main();

実行例

以下実行例です。環境変数に LOG_LEVEL にそれぞれ debuginfowarnerror を設定することで、ログに表示される範囲を変更することができます。

$ export LOG_LEVEL=debug && yarn run dev
yarn run v1.22.19
$ ts-node src/index.ts
[LoggerLabel] Hello World Debug!
[LoggerLabel] Hello World Info!
[LoggerLabel] Hello World Warn!
[LoggerLabel] Hello World Error!
Done in 1.08s.

おわりに

今回TypeScriptでなるべく利用する側でサードパーティ製ライブラリに依存しないようにケアしてコードを書いてみました。実際にはもっと複雑なケースがあるため、今回のようにシンプルに書き続けられるかはわかりませんが、今後もなるべく依存関係を考えつつコードを書けるように努めていきたいと思います。