MOBILUS TECH BLOG

モビルスのプロダクト開発を支えるメンバーが 日々の開発現場の情報を発信します。

CSV作成処理にStrategyパターンを適用した話

Written by Tomotaka Miyauchi

デザインパターンとは

みなさんはデザインパターンをご存知でしょうか?

オブジェクト指向のプログラミング言語において、GoFらの経験則に基づいて提唱されたベストプラクティス集です。(その数、なんと23種類!!)

ただし、現代において、これがベストプラクティスかというと、「?」と思うパターンもそこそこあります。
無闇に使用すると可読性を損なうこともありますので、各パターンのメリット・デメリットを理解した上で利用することが重要です。

今回は、デザインパターンの中で "Strategyパターン" にリファクタリングした話を書きたいと思います。

仕様変更の内容

私が担当しているプロダクト「モビキャスト」はLINE配信を行うSaaSサービスです。
お客様のLINEアカウントにお友だち登録されているエンドユーザー様に対してLINE配信を行います。「モビキャスト」の特徴として、各エンドユーザー様にLINE上のアンケートを回答いただくことで、各ユーザーの情報に基づき、セグメントを絞った配信が可能です。また、REST APIによるLINE配信や、お友だちでないエンドユーザー様に対しても電話番号を指定した配信を行うことができます。

今回、配信結果のエンドユーザー一覧を出力するCSVに仕様変更が入りました。
こちらのCSVは、1件の配信に対して配信したエンドユーザー様の一覧を出力します。

「モビキャスト」では電話番号を指定した配信が可能ですが、
以下の表のように "配信種別(通常配信、電話番号指定)" と "出力種別" によって、CSV項目の編集パターンが異なります。

今回の仕様変更で、電話番号配信の配信結果CSVに項目を追加することになりました。

変更前の編集パターン
変更前の編集パターン

変更後の編集パターン
変更後の編集パターン

リファクタリング前の課題

リファクタリングする前のソースがこちらです。(一部変更しています)

async function getCsvContent(resultType, findQuery, isPhoneNumberPush) {
  const elemsCursor = getCursorFromResultElem(findQuery);

  if (resultType !== 'all') {
    // 出力対象が全部以外の場合
    return await getCsvContentForNotAll(elemsCursor, isPhoneNumberPush);
  } else {
    // 出力対象が全部の場合
    return await getCsvContentForAll(elemsCursor, isPhoneNumberPush);
  }
}

// 出力対象が全部以外の場合のCSV作成
async function getCsvContentForNotAll(elemsCursor, isPhoneNumberPush) {
  const csvContent = [];

  // ヘッダの追加
  csv_content.push(editHeaderPattern1());

  // コンテンツの追加(カーソルの読み込み)
  for (
    let doc = await elemsCursor.next();
    doc !== null;
    doc = await elemsCursor.next()
  ) {
    csvContent.push(editContentPattern1(doc, isPhoneNumberPush));
  }
  return csvContent.join('\n');
}

// 出力対象が全部の場合のCSV作成
async function getCsvContentForAll(elemsCursor, isPhoneNumberPush) {
  const csv_content = [];

  // ヘッダの追加
  csv_content.push(editHeaderPattern2());

  // コンテンツの追加(カーソルの読み込み)
  for (
    let doc = await elemsCursor.next();
    doc !== null;
    doc = await elemsCursor.next()
  ) {
    csv_content.push(editContentPattern2(doc, isPhoneNumberPush));
  }
  return csv_content.join('\n');
}

実装自体はとてもシンプルで可読性も良さそうな気がします。
でも、実は、この実装にはいくつかの問題点があります。

  • 単一責任の原則
    各編集パターンが独立していないため、ソースに変更があった際にどの編集パターンへの変更か、わかりづらいです。

  • 重複コード
    getCsvContentForNotAllgetCsvContentForAllはほぼ同じ処理です。この場合、編集パターンが追加されるたびに重複コードをコピーすることになり、1ファイル内のソース量が増えていきます。

  • バグの発生率
    重複コードに修正が発生すると修正箇所が多くなりバグが発生しやすくなります。

以上のことから、今後の保守性を考え、Strategyパターンでリファクタリングすることにしました。

Strategyパターンで実装

まず、クラス設計を以下のように行いました。

なお、データ取得用のDataStrategyインタフェースも作成すると、CsvStrategyと組み合わせて色々なパターンが作成できます。
今回は不要なので作りませんでした。

クラス図
クラス図

実装は以下のようになりました。

まずはストラテジのコンテキストを実行するソースです。どのストラテジーを適用するか選択し、コンテキストの処理を実行します。
(このソースはクリーンアーキテクチャでいうところのUse Casesにあたります。ビジネスロジックを組み合わせて一連の処理を実現します。)

async function getCsvContent(resultType, findQuery, isPhoneNumberPush) {
  // 出力CSVの選択
  const csvCreatorContext = new CsvCreatorContext();
  if (resultType === 'all' && isPhoneNumberPush) {
    // 「全部」タイプ and 通知メッセージの場合
    csvCreatorContext.setCsvStrategy(new ExCsvStrategyAllTypePnp());
  } else if (resultType === 'all') {
    // 「全部」タイプ and 通知メッセージ以外の場合
    csvCreatorContext.setCsvStrategy(new ExCsvStrategyAllType());
  } else {
    // 「成功」or「失敗」タイプの場合
    csvCreatorContext.setCsvStrategy(new ExCsvStrategyUserKeyOnly());
  }

  // CSV作成
  return await csvCreatorContext.createCsv(elemsCursor, isUnfollwFlagOn);
}

続いて、コンテキストの実装です。
(これ以降のクラスはクリーンアーキテクチャでいうところのEntitiesにあたります。つまり、ビジネスロジックを実装します。)

export default class CsvCreatorContext {
  #CSV_DELIMITER = ',';

  setCsvStrategy(strategy) {
    this.csvStrategy = strategy;
  }

  async createCsv(elemsCursor) {
    const csvContent = [];

    // ヘッダー出力
    const strArrayHeader = this.csvStrategy.getHeader();
    if (strArrayHeader.length > 0) {
      // 空以外の場合
      csvContent.push(strArrayHeader.join(this.#CSV_DELIMITER));
    }

    // データ行出力
    for (
      let doc = await elemsCursor.next();
      doc !== null;
      doc = await elemsCursor.next()
    ) {
      const strArrayContent = this.csvStrategy.editDocument(doc);
      if (strArrayContent.length > 0) {
        // 空以外の場合
        csvContent.push(strArrayContent.join(this.#CSV_DELIMITER));
      }
    }
    return csvContent.join('\n');
  }
}

続いて、CSV作成ストラテジーのインターフェースです。

export default class CsvCreatorCsvStrategy {
  // CSVヘッダ編集
  getHeader() {
    throw new Error("Method 'getHeader()' must be implemented.");
  }

  // CSV編集
  editDocument(doc) {
    throw new Error("Method 'editDocument()' must be implemented.");
  }
}

続いて、CSV作成ストラテジーのインターフェースの実装です。
各種CSVのフォーマットに合わせて複数のストラテジーを作成します。以下はその一例です。

import CsvCreatorCsvStrategy from './CsvCreatorCsvStrategy';

// ALLタイプのCSVストラテジ
export default class ExCsvStrategyAllType extends CsvCreatorCsvStrategy {
  // CSVヘッダ定義
  getHeader(isUnfollowFlag) {
    return [
      'haishin_shirei_id',
      'user_key',
      'result_type',
    ];
  }

  // CSV編集
  editDocument(doc, isUnfollowFlag) {
    return [
      `"${doc.haishin_shirei_id}"`,
      `"${doc.user_key}"`,
      `"${doc.result_type}"`,
    ];
  }
}

Strategyパターンにして嬉しかったこと

Strategyパターンにして1番嬉しいのは、

 「CSVの1つの編集パターンに対する修正が
    他の編集パターンに影響を与えないことが明白である点」
です。

各ストラテジーは独立しているため、修正が他のストラテジーに影響を与えないメリットがあります。また、独立していることにより、各モジュールのユニットテストコードが短くなり、テストコードの修正も容易になります。

今後、編集パターンを増やす場合も「ストラテジーの追加」と「ストラテジー選択の修正」であることが明白であるため、コーディングに迷いがなくなります。

まとめ

いかがでしたか?
デザインパターンを適用することでコードの改善が図れたでしょうか?

とあるカンファレンスで、とある企業のCTOさんが、

 「アーキテクチャ上達のためには、まずはチャレンジし、
    責任持って最後まで運用し、苦しみを糧にスキルアップするしかない」

とおっしゃっていました。

冒頭に書いたように、デザインパターンが必ずしも正解ではないです。ただし、本質的に、なぜ、そうなっているかを考えるのは有意義なことです。
可読性や保守性、テストの容易性などを考えながらいい設計にチャレンジして、より楽しいプログラミングライフを送っていきましょう!!

モビルスでは、一緒に働く仲間を募集中です!
興味のある方は、ぜひ採用情報のページをご覧ください!