6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

フレームワーク時代にも生き続ける God パターン

Last updated at Posted at 2020-10-18

クソ長い記事なので嫌いな方は読み飛ばしてください。私が昔よく書いていた仕様変更に脆いコードと、そのリファクタ方法をまとめた記事です。

サンプルコードは Angular 10 を前提としたものです。

1)JavaScript 時代のコード

例としてスポーツのアクティビティを記録するアプリを題材にしてみます。このアプリでは、ヨガ、ランニング、バレーボールなどのスポーツとその活動を記録し、日別・月別のサマリを表示します。

下記は太古の昔によく私が書いていた典型的なパターンの擬似コードです。昔なつかし jQuery です。ここはナナメ読みパートなので、コメントだけざーっと見ていただければ構いません。

<div id="contents" hidden>
  <tab-group>
    <tab id="daily">
      <chart id="dailyActivity"></chart>
    </tab>
  <tab-group>

  <form id="form">
    <select id="activity">
      <option value="yoga">ヨガ</option>
      <option value="running">ランニング</option>
      <option value="volleyball">バレーボール</option>
    </select>
    
    <input type="number" id="minutes"><button type="submit" id="entry">登録</button>
  </form>
</div>

<div id="loading">しばらくお待ちください</div>
<div id="modal"></div>
$(document).ready(function () {
  // 初期データの取得
  $.ajax({ type: "GET", url: "/activities", success: function (resopnse) {
    // グラフのレンダリングのためのデータ整形
    var dailyChartData = new Chart($("daily"), {
      type: "barChart",
      data: response.dailyData.map(...)
    });
    
    // 処理が完了したらコンテンツ表示
    $("#loading").hide();
    $("#contents").show();
  }});
  
  // アクティビティの選択
  var activity;
  
  $("#activity option").on("click", function () {
    activity = $("#activity option:selected").value;
  });
  
  // フォームの送信
  $("#form").submit(function () {
    if (activity === null || activity === undefined) {
      $("#modal").text("アクティビティを選択してください");
      $("#activity").focus();
      $("#modal").show();
      return;
    }
    
    $.ajax({ type: "POST", url: "/activities", {
      activity: activity,
      minutes: $("#minites").val() || 0,
    }})
  });
});

太古の人間は クロスブラウザ対応 とも戦っていました。
jQuery はそんな問題から開発者を救ってくれた偉大なるライブラリでした。

2)時代はフレームワークへ

時代は一気に 2020 年へ。フレームワークの登場によって $() はデータバインドへと移行しました。コードの量が減って便利になりましたね。下記は Angular で擬似コードを書き直したものです。

ここもナナメ読みパートです。コメントだけざっと見てください。

<div *ngIf="loading else loading">
  <tab-group>
    <tab>
      <chart ng-chart="dailyActivity"></chart>
    </tab>
  <tab-group>

  <form [formGroup]="form" (ngSubmit)="submit()">
    <select formControlName="activity">
      <option *ngFor="let activity of activities">
        {{activity.label}}
      </option>
    </select>
    
    <input type="number" formControlName="minutes"><button type="submit" [disabled]="form.invalid">登録</button>
  </form>
</div>

<ng-template #loading>しばらくお待ちください</ng-template>
class ActivitySummaryComponent implements OnInit {
  public loading = true;
  public dailyChartData = {};
  public activities = [];
  public form;
  
  // DI が使えるようになった
  contsuctor(
    private http: HttpClient,
    private modal: ModalService,
  ) {}
  
  ngOnInit() {
    this.activities = [
      { label: "ヨガ" },
      { label: "ランニング" },
      { label: "バレーボール" },
    ];
    
    this.http.get("/activities").subscribe(response => {
      this.dailyChartData = response.map(...),
    });
    
    // フォームが簡単に扱えるようになった
    this.form = new FormGroup({
      activity: new FormControl(response.activity),
      minutes: new FormControl(response.minites),
    }, { validators: ... });
    
    // フラグを切り替えるだけで HTML のレンダリングが操作できるようになった
    this.loading = false;
  }
  
  submit() {
    // フォームがバリデーションしてくれるのでロジックでチェックする必要がなくなった
    this.http.post("/activities", {
      activity: this.form.get("activity"),
      minutes: this.form.get("minutes"),
    });
  }
}

$() の嵐から救われるようになったのは Angular に限らず他のフレームワークでも同じです。さらにコンポーネント志向によって「入力フォームコンポーネント」「ローディング中表示コンポーネント」などカスタムコンポーネントを作る事ができ <div> は中身のコンテンツごと <loading> など分かりやすい名前を付けられるようになりました。

<!-- activity-page.component.html -->
<loading></loading>
<!-- loading.component.html -->
<div>
  <mat-icon>loading</mat-icon>
  しばらくお待ちください
</div>

コンポーネント分割は、ファイルの分割を伴います。数千行にも及ぶ index.js document.$ready() から解放され、適度に分割されたファイルを扱えるようになりましたね。

3)God パターン

フレームワークの登場によって、JavaScript/TypeScript のロジックから HTML をせっせと操作する必要はなくなりました。ルーティングやフォームなど便利な機能もたくさん提供してくれます。ただし「どうロジックを書くか?」という部分は相変わらず開発者に委ねられています。

私が仕事で開発しているシステムは B to B の商材です。個人で趣味開発したアプリとは比べ物にならないくらい複雑です。ひとつのページにコンポーネントが数十個並ぶ事もザラですし、それぞれのコンポーネント同士が連携し、非同期処理だったり動的に選択肢が変わるなど複雑な動きをしています。いくら整理整頓して書いても、コンポーネント分割しても、システムとしてやるべき仕事・書くべきロジックは減らす事はできないのです。

そもそも、フレームワーク登場前のコードは何が問題だったのでしょうか。なぜ index.js は数千行に膨れ上がっていたのでしょうか?

God パターンと呼ぼう

index.js が膨れ上がる原因は「やること多すぎ」が原因のひとつです。

  • ページに必要な情報を全部持っている
  • プレゼンテーション全般(フィルタ・フォーマット)
  • ユーザーロールによる処理の変更(Admin だったら押せるボタン)
  • DOM操作
  • ビジネスロジック
  • バリデーション
  • ajax通信もやっちゃう

とにかくコンテキストがでかい。「神は全てを知っている」状態ですよね。よくいう神クラス God パターン のいっちょあがりです。

God パターン is

jQuery は開発者に広く受け入れられました。理由は「DOM 操作から ajax 通信までなんでもできる簡単さ」があったからです。 $() を書けばフロントエンドに必要な大抵の操作は事足ります。そして DOM と直結しているので、ブラウザを見れば書いたコードの結果は <div><button> として現れます。とても分かりやすいです。

反対に、かゆいところに手が届かない問題がありました。

  • テストが書きにくい
  • 処理が汎用化しづらく仕様変更に弱い
  • 密結合のため一箇所修正するとあちこち影響を受ける

そして JavaScript 特有の問題もありました。

  • オブジェクトの key/value を変更してもどこに影響するのか検知できない
    • ひたすら画面で動作確認
  • function の引数がオブジェクトだと、どんな形で何が入ってくるのか分からない
    • ドキュメント代わりに JSDoc のアノテーションを書きまくった
  • null/undefined との戦い
    • 突然画面に現れる TypeError: can't access property "XXX" of undefined
    • どのタイミングでどこで null/undefined になったのか分からない

The Death of God パターン?

コンポーネント志向によって巨大な index.js は滅亡し、現代は <loading> <header> <chart> など、フレームワークの手助けによってコンポーネント分割できる時代となりました。これで God パターンから解放されたのでしょうか?

私の答えは No です。

さきほどのフレームワークで書き直したコードを抜粋します。

class ActivitySummaryComponent {
  // 画面の <select> に表示するための選択肢
  this.activities = [
    { label: "ヨガ" },
    ...
  ];
  
  // ajax 通信、グラフ用のデータの整形
  this.http.get("/activities").subscribe(response => {
    this.dailyChartData = ...
  });
  
  // フォーム生成
  this.form = new FormGroup({
    activity: new FormControl(response.activity),
    ...
  }, { validators: ... });
  
  // ローディング表示の切り替え
  this.loading = false;

  // ajax 通信
  this.http.post("/activities", {
    activity: this.form.get("activity"),
    ...
  });
}

コンテキスト、でかいですよね。ローディング中表示から ajax 通信からグラフ表示まで、何から何まで知っています。

ここに「Admin だったら押せるボタン」「ボタンが押されたら選択肢を変更」「選択されたら ajax 通信して次の <select> の選択肢を得る」など、ロジックはどんどん追加されます。コンポーネント分割してロジックを分散したとしても、相変わらず巨大なコンテキストを扱うなら、それは 単なるファイル移動 です。全てを知っている一神教の神様 index.js は分散して多神教になっただけで、神の存在には変わりがないのです。

God パターンの問題

「全てを知っている」事の代償は「全ての責任を負う」事です。

ajax 通信のレスポンスに変更が入った時、<select> の選択肢が増減した時、またはユーザーロールによって表示を切り替える時、コンポーネント分割された小さな神様たちは「全てを知っている」ので影響を受けます。ajax 通信のレスポンスをデータバインドでいくつかのコンポーネントに渡している場合など、レスポンスの変更はあちこちに影響します。

仮に activity と名付けたオブジェクトに変更が入った時、まずはテキスト検索で修正箇所を探すはずです。あちこちのファイルで activitySummaryconst data = activity; のようなコードが検索結果にヒットしたら、それらは修正するべきコードなのか、修正したら画面のどこに影響するのか、すぐに分かるでしょうか?

4)愚直なコンテキスト

神が扱うコンテキストは 実装するその時に必要だったプリミティブ値の集合体 である事が多く、それが仕様変更を入れづらい構造を作り出しています。

安直な分岐

アクティビティには「ヨガ」「ランニング」「バレーボール」などスポーツの種類があり、それぞれのスポーツごとに「ペースタイム」「チームの人数」など特有の指標が存在します。

God パターンで扱うインターフェースは、このようになりがちです。

interface Summary {
  label: string;
  minute: number;
  perKm?: number; // ランニング特有の指標、1kmを何分で走るか
  numbers?: number; // バレーボール特有の6人制・9人制
}

神は全てを知っているので、コンテキストの扱い方を熟知しています。Summary インターフェースを返す場合はランニングのアクティビティだけに perKm が存在する事も知っています。

function generateSummary(data: object): Summary {
  // ヨガには perKm は存在しない
  if (data.type === "yoga") {
    return {
      label: "ヨガ",
      minute: data.minute,
    };
  }
  // ランニングには perKm が存在する
  if (data.type === "running") {
    return {
      label: "ランニング",
      minute: data.minute,
      perKm: data.perKm,
      
    };
  }
  // バレーボールには numbers が存在する
  if (data.type === "volleyball") {
    return {
      label: "バレーボール",
      minute: data.minute,
      numbers: data.numbers,
    };
  }
}

このインターフェースには問題があり、不整合なデータが簡単に入り込みます。単なるミスや、自分以外の開発者がコンテキストの使い方を間違えるなどした時に、インターフェースがそれを許容してしまうのです。

const summary = {
  label: "テニス",
  minute: 100,
  perKm: 5, // 謎の値を許容
  numbers: 2, // ロジック側ではバレーボールにしか対応していないのに値が入る
};

God パターンで不整合の対策としてよく使われるのは if 文です。分岐はあちこちの処理で登場します。

// ------------------------------
// グラフデータを作るための関数
// ------------------------------
function generateChartData(summary: Summary) {
  // ヨガに perKm が存在しても、使わないから問題ないよね?
  if (activity.type === "yoga") {
    return {
      title: "ヨガのアクティビティ",
      minute: summary.minute,
    };
  }
  if (activity.type === "running") { ... }
  if (activity.type === "volleyball") { ... }
}

// ------------------------------
// ajax で POST するための関数
// ------------------------------
function generatePostData(summary: Summary) {
  // ヨガに perKm が存在しても、使わないから問題ないよね?
  if (activity.type === "yoga") {
    return {
      minute: summary.minute,
    };
  }
  if (activity.type === "running") { ... }
  if (activity.type === "volleyball") { ... }
}

問題ありありです。このようなロジックを書いてしまうと仕様変更に弱くなります。

仮に type:baseball(野球) というアクティビティが追加された時、上記の例の generateChartData と generatePostData は間違いなく影響を受けます。それとは別に、他にもサービスやコンポーネントが if 分岐しているロジックがある事でしょう。記憶だけを頼りに片っ端から修正したとして、修正対象から漏れてしまった箇所は、type:baseball が渡った時にどんな挙動になるのか想像できますか?

安直なインターフェース

God パターンでありがちなこのインターフェースを掘り下げてみます。

interface Summary {
  label: string;
  minute: number;
  perKm?: number;
  numbers?: number;
}

上記インターフェースはいくつかのコンポーネントですでに利用されているものとします。そこに「アクティビティをリストアップしてコメントとお気に入りマークをつける」という新しいページを追加する事になりました。

インターフェースには、新しいページに必要な key/value が追加されます。また、追加の key/value は既存のコンポーネントに影響を与えないためにオプショナルとして宣言します。

interface Summary {
  label: string;
  minute: number;
  perKm?: number;
  numbers?: number;
  
  // 追加の key/value
  type?: "monthly" | "daily" // 日別 or 月別アクティビティ
  entryDate?: Date; // アクティビティを登録した日
  activityDate?: Date; // 活動した日
  isFavorite?: boolean; // お気に入りマーク
  comment?: string; // コメント
}

そして新しく追加されたページのために、新しいコンポーネントが作られました。神は全てを知っているので、オプショナルとして宣言した key/value に値が入る事を知っています。

<section>
  <h1>{{ summary.label }}</h1>
  <span *ngIf="summary.isFavorite"></span>
  <acide>{{ summary.minutes }}分活動しました</acide>
  <acide>{{ summary.activityDate | date: "yyyy-MM-dd" }}</acide>
  <div>{{ summary.comment }}</div>
</section>
export class ActivitySummaryComponent {
  @Input() summary: Summary;
}

ただし、開発者は人間です。「新しく追加されたページ」の開発に関わっていない人はどんな事情で key/value が追加されたのか知りません。オプショナルが列挙されたインターフェースを見て、どのページで何をセットするべきなのか、伝わるでしょうか?

interface Summary {
  label: string;
  minute: number;
  perKm?: number;
  numbers?: number;
  type?: "monthly" | "daily"
  entryDate?: Date;
  activityDate?: Date;
  isFavorite?: boolean;
  comment?: string;
}

例えばコンポーネント A B C がデータバインドでこのインターフェースを受け取るとします。

  • Atype entryDate に値が入る事を期待している
  • Btype に値が入る事を期待している
  • C は期待しているものがない

このようなコンポーネントごとの事情は、コードを読まないと分かりません。また、コンポーネント A で「やっぱり comment も表示したい」となった時、インターフェースがそれを担保しないため、データを渡している部分を探して修正する必要があります。どちらも人間の記憶に頼る作業です。せっかく型システムを持った TypeScript を使っているのに、非効率です。

共通化の問題

下記はアクティビティを選択するための選択肢です。

const activities = [
  { type: "yoga", label: "ヨガ" },
  { type: "running", label: "ランニング" },
  { type: "volleyball", label: "バレーボール" },
  { type: "baseball", label: "野球" },
];

「バレーボール」を選択した時は 6 9 のどちらか、「野球」を選択した時は 9 固定で「人数」を入力できるものとします。このような場合、神のために必要なコンテキストがその場で生成されます。

// <select> の選択肢を動的に生成するための関数
function generateNumbersSelection(activity: object) {
  if (activity.type === "volleyball") {
    return [6, 9];
  }
  if (activity.type === "baseball") {
    return [9];
  }
}

そして「XXX のページと YYY のページ両方で人数を入力できるようにしたい」という要望が出てきたとします。上記のロジックはどのように共通化したら良いでしょうか?

  1. コピペして XXX と YYY で同じロジックを使う
  2. utils.ts などユーティリティ関数を集めたファイルを作る
  3. サービスクラスを作って共通化する

コピペが良くないのは「DRY 原則」として知られています。

プログラマが知るべき97のこと DRY原則
https://xn--97-273ae6a4irb6e2hsoiozc2g4b8082p.com/%E3%82%A8%E3%83%83%E3%82%BB%E3%82%A4/DRY%E5%8E%9F%E5%89%87/

ユーティリティファイルもあまり良くない解決策です。このくらいのコードでユーティリティを生やしてしまうと爆発的にコードが増殖し「探しづらいファイル」が誕生します。サービスクラスも同じで、メソッドを膨大に持ったサービスクラスが誕生するだけです。

とにかくこのロジックは「共通化しづらい」のです。

関数の弱さ

システムは開発途上で、新しいアクティビティ「ロードバイク」はユーザーロール Admin でログインした時だけ使えるものとします。

function generateActivities(isAdmin: boolean) {
  const activities = [
    { type: "yoga", label: "ヨガ" },
    { type: "running", label: "ランニング" },
    ...
  ];
  
  // Admin だったらロードバイクが画面上で選べる
  if (isAdmin) {
    activities.push({ type: "roadBike", label: "ロードバイク" });
  }
  
  return activities;
}

さらにシステムの有料利用ユーザー向けに、人気のアクティビティがいくつか追加されるものとします。

function generateActivities(isAdmin: boolean, isFreePlan: boolean) {
  const activities = [
    { type: "yoga", label: "ヨガ" },
    { type: "running", label: "ランニング" },
    ...
  ];
  
  if (isAdmin) {
    activities.push({ type: "roadBike", label: "ロードバイク" });
  }
  
  // 有料利用ユーザー向けのアクティビティ
  if (!isFreePlan) {
    activities.push({ type: "swimming", label: "水泳" });
    activities.push({ type: "climbing", label: "クライミング" });
  }
  
  return activities;
}

さらにさらに、システムのお試し利用をスタートするとベータ機能も含め全アクティビティが使えるようになりました。

function generateActivities(isAdmin: boolean, isFreePlan: boolean, isTrial: boolean) {
  const activities = [
    { type: "yoga", label: "ヨガ" },
    { type: "running", label: "ランニング" },
    ...
  ];
  
  // Admin またはお試し利用
  if (isAdmin || isTrial) {
    activities.push({ type: "roadBike", label: "ロードバイク" });
  }
  
  // 有料ユーザーまたはお試し利用
  if (!isFreePlan || isTrial) {
    activities.push({ type: "swimming", label: "水泳" });
    activities.push({ type: "climbing", label: "クライミング" });
  }
  
  return activities;
}

引数と条件分岐がどんどん増えていくのが分かるでしょうか。もし if の中身が push() などの一行の処理でなく、グラフのデータ生成のような長いロジックの場合、この関数はとんでもなく巨大なコードに育っていきます。

そして神はビジネスロジックを知っているので、全く別の場所にあるコンポーネントでバリデーションロジックも簡単に書いてしまいます。

FreePlanComponent {
  onSelectActivitiy(activity) {
    if (!isTrial && ["roadBike", "swimming", "climging"].includes(activity.type)) {
      this.error = "ご利用のプランではこのアクティビティは選択できません";
    }
  }
}

「ロードバイク」は開発途中のため Admin だけに利用を許可しているアクティビティです。開発が完了したら全ユーザーに公開されます。その時、記憶を頼りにしてどこを修正すべきか探しますか?もしくはテキスト検索で探しますか?もし "roadBike""loadBike" とタイポしている箇所があったらテキスト検索だけで解決しますか?

テストしづらい

全てを知っている神をテストするには、全てを知っている神を知り尽くした神が必要です(訳わかんないですね)。つまりは、何でもかんでもテスト対象になるという事です。

describe("神コンポーネント", () => {
  let component: GodComponent;
  
  beforeEach(() => {
    TestBed.configureTestingModule({}).compileComponents();
    sut = TestBed.createComponent(GodComponent);
  });
  
  it("HTTP レスポンスをパースする事のテスト", () => { ... });
  it("日別グラフデータを生成する事のテスト", () => { ... });
  it("月別グラフデータを生成する事のテスト", () => { ... });
  it("アクティビティの入力のバリデーションのテスト", () => { ... });
  it("ユーザー名がヘッダーに表示される事のテスト", () => { ... });
  it("初期表示のローディングが表示される事のテスト", () => { ... });
  it("ローディング中は登録ボタンが押せない事のテスト", () => { ... });
  it("登録のリクエストボディが正しく整形する事のテスト", () => { ... });
  it("ユーザーロールAdminで登録した時のテスト", () => { ... });
  it("ユーザーロールAdminでXXXが表示される事のテスト", () => { ... });
  it("ユーザーロールAdminはYYYが登録できる事のテスト", () => { ... });
  ...
});

神コンポーネントと化した God パターンのテスト、きちんと書けていますか?面倒だからとちょろちょろっと正常パターンだけ書いて、細かいケースのテストを避けていませんか?

愚直なコンテキストとは

愚直なコンテキストとは、開発者がその場しのぎで追加を重ねたプリミティブの集合体です。

今自分が書いているロジックとそれに必要なコンテキストに目が向いていて、仕様変更に対する修正の簡単さや、自分以外の開発者が仕様を取り違えないようにするという配慮がないのです。

「仕様変更」だとか「自分以外の開発者」とか説明してもなかなか納得が得られないのですが、ひとつだけ誰もが納得してくれる指標があります。それはユニットテストです。ユニットテストの書きやすさは健全性の指標です。適度に分割されたコンポーネントやサービスはユニットテストが書きやすいのです。

今書いているそのロジック、ユニットテストはしやすいですか?God パターンに陥ってませんか?

5)リファクタリング

私は素晴らしく優れたエンジニアではないので、鉄壁の解決策は知りません。ここからは God パターンから脱出するために個人的に心がけているリファクタリング方法のパートです。

レイヤーを分割して考える

God パターンのコンポーネントでは、ひとつのコンポーネントがたくさんの仕事をしています。これらを整理してみましょう。

  • データソース HTTP リクエストを実行しレスポンスを HTML レンダリング用に整形
  • プレゼンテーション データバインドを利用して HTML にデータをレンダリングする
  • DOM フォーカスを与えたり CSS クラスを動的に決定する
  • ビジネスロジック ユーザーロールによる分岐
  • バリデーション 不整合の検知、エラーの表示
  • ビジネスロジック A の時に B が決定するなど、オブジェクトに変化をもたせる

どうしてもコンポーネントから切り離せないものは「DOM」「プレゼンテーション」の2つだけです。「今書いているこの処理は、どこのレイヤーに属するのか?コンポーネントがやるべきなのか?」を常に考え、分離する事が God パターン脱出の近道です。

上記に上げた データソース などは単にカテゴリを分類しただけのものですが、レイヤーの考え方は下記の DDD に関する書籍がとても参考になります。DDD 初心者にもやさしい本です。

ドメイン駆動設計 モデリング/実装ガイド | BOOTH
https://booth.pm/ja/items/1835632

また、現在位置を意識しつつディレクトリを適切に切るという考え方は FRONTEND CONFERENCE 2019 に参加した時の okunokentaro さんのセッションが非常に参考になりました。

ディレクトリ構成ベストプラクティス | speakerdeck
https://speakerdeck.com/okunokentaro/frontend-conference-2019

データソース層の導入

HTTP リクエストはコンポーネントから HttpClient を直接操作せず、リポジトリオブジェクトを作成します。

import * as Api from "./interface.ts";

@Injectable()
export class ActivityRepository {
  constructor(private http: HttpClient) {}
  
  get(): Rx.Observable<Api.Activity[]> {
    return this.http.get("/activities");
  }
}

API レスポンスの型は必ずインターフェースとして宣言します。もしバックエンドの返す API レスポンスが間違っていた場合、まず求めるインターフェースがどうなっているか確認しますよね。そういう時にコードを読まずに型定義を見ればフロントエンドの求める構造と型が一目瞭然になるからです。

// interface.ts
export type Activity = {
  id: string;
  name: string;
  minutes: number;
  numbers?: number;
};

そしてリポジトリのエンドポイントごとにパーサーを作成します。このサービスは API レスポンスのファサードです。もし API レスポンスに変更が入ったとしても、このレイヤーで吸収する事ができます。また、フロントエンドに都合の良い初期値をセットする事もできます。

@Injectable()
export class ActivityParser {
  parse(response: Api.Activity) {
    // フロントエンドに都合の良い初期値をセットする
    // フロントエンドに都合の良い構造に変換する
    return {
      type: response.id,
      label: response.name,
      minites: response.minites,
      numbers: response.numbers || 0,
    };
  }
}

HTTP リクエストの利用者は、リポジトリとパーサーをセットで使います。同じエンドポイントでも、グラフのレンダリングデータ、サマリの表示データというように使い分ける事ができます。このようにすると、だらだらと長いレスポンスの整形処理をコンポーネントに書く必要がなくなります。

@Component({})
export class ActivityComponent {
  constructor(
    private repository: ActivityRepository,
    private parser: ActivityParser,
  ) {
    const activiries = this.repository
      .get()
      .map(response => this.parser.parse(response));
  }
}

パーサーとは逆に POST などリクエストボディを整形するものはフォーマッターという名前で宣言しています。リクエストの内容はインターフェースとして宣言し、それに沿ったオブジェクトを返却するようにします。

@Injectable()
export class ActivityFormatter {
  format(data): Api.Activity {
    // フロントエンドに都合の良い構造をリクエストボディに変換する
    return {
      type: data.id,
      minites: data.minites,
      numbers: data.numbers,
    };
  }
}

ユースケース層の導入

データソース層を導入しただけでは不十分な事があります。コンポーネントが依存だらけになる問題です。

@Component({})
export class ActivityComponent {
  constructor(
    private repository: ActivityRepository,
    private parser: ActivityParser,
    private dailyChartParser: ActivityDailyChartParser,
    private monthlyChartParser: ActivityMonthlyChartParser,
    ...
  ) {}
}

このような場合はユースケースを導入して、データソースに対する操作とコンテキストをコンポーネントから分離します。

// ユースケース
@Injectable({})
export class ActivityUseCase {
  constructor(
    private repository: ActivityRepository,
    private dailyChartParser: ActivityDailyChartParser,
    private monthlyChartParser: ActivityMonthlyChartParser,
  ) {}
  
  get() {
    const response = this.repository.get();
    
    return {
      daily: this.dailyChartParser.parse(response),
      monthly: this.monthlyChartParser.parse(response),
    };
  }
}

依存をユースケースに集約する事で、コンポーネントは操作ごとにユースケースのメソッドを呼び出すだけのシンプルな仕事になります。

// コンポーネント
@Component({})
export class ActivityComponent {
  constructor(private useCase: ActivityUseCase) {}
  
  ngOnInit() {
    // データを取得する
    this.activities = this.useCase.get();
  }
  
  reload() {
    // データを再読み込みする
    this.useCase.reload();
  }
  
  save() {
    // データを保存する
    this.activities.save();
  }
}

ユースケースをどの粒度で作成するのか(ページ単位、コンテキスト単位など)は扱うシステムによって異なると思っています。あくまで個人的なものですが、私は API リクエストとそれに関する処理がゴチャゴチャしてきたらユースケースに移すという使い方をしています。

ドメインモデルの導入

何度も登場した「アクティビティ」はビジネスロジックでありドメインの知識です。type ごとの挙動は if 文をあちこちにばら撒くのではなく、モデルに集約します。

// ------------------------------
// ヨガに関する知識を持つモデル
// ------------------------------
class ActivityYoga implements Activity {
  public readonly type = "yoga";
  public readonly label = "ヨガ";

  constructor(
    public minutes: number,
  ) {}

  // ランニングの指標は持たない
  // うっかりセットされないように開発者のための例外を上げる
  set perKm(value: any) {
    throw new Error("not supported.");
  }
}

// ------------------------------
// ランニングに関する知識を持つモデル
// ------------------------------
class ActivityRunning implements Activity {
  public readonly type = "running";
  public readonly label = "ランニング";

  constructor(
    public minutes: number,
    private _perKm: number,
  ) {}
}

モデルに集約した事で「ヨガは perKm を持たない」事は保証されます。こうすることで、あちこちの if 文で type ごとに分岐してデータを収集する事はなくなり、整合性の取れたデータが保証されます。

モデルを生成する箇所はどうしても分岐が発生するのでその部分はファクトリに任せます。

@Injectable({})
export class ActivityFactory {
  get(response: Api.Activity): Activity {
    switch (response.type) {
      case "yoga": return new ActivityRunning(response);
      case "running": return new ActivityYoga(response);
      default: throw new Error("not supported.");
    }
  }
}

順番が前後しますが、実際の運用コードでは、先に上げたパーサーはこれらのモデルを返却するようにしています。そしてコンポーネントもインターフェースではなく整合性の取れたモデルを扱うようにしています。

モデル導入のメリットは他にもあります。このクラスはピュアな TypeScript クラスのため、フレームワークへの依存がありません。フレームワークを変更する事になった場合や、同じコンテキストを扱う別システムを立ち上げる場合など、簡単に持ち運ぶ事ができます。

いつか捨てる事を前提とする事で技術的負債に強いシステムを作る、という考え方は FRONTEND CONFERENCE 2019 に参加した時の小島さんのセッションが非常に参考になりました。2020/10 現在、残念ながらスライドのリンクが切れていますがいつか復活する事を願ってリンクを記載します。

レガシーなフロントエンドをリプレイスする
https://2019.kfug.jp/session/kojimadaiki

ビジネスロジックの分離

アクティビティのようにモノとして表現しやすいモデルの他に「まとめて登録/削除する」「ページネーション」など、ドメイン特有の処理が入る事があります。私はこれらも TypeScript のクラスやサービスとして作成し、複数のページで使い回せるようにしています。ページネーションは便利なライブラリもたくさんありますが、独特のフィルタや、アルファベット順でない並べ替えなどドメイン知識が入り込みやすい部分でもあります。

例として「まとめて登録/削除する」を State パターンで実装したものを Qiita で投稿していますので、よろしければ参考にどうぞ。

デザインパターンをTypeScriptで学ぶ(State)
https://qiita.com/ringtail003/items/d146760a59eb4cd8f4ca

TypeScript の型システムに頼る

よく string 型で宣言した変数をコンポーネントのビューで if 分岐するというコードを見かけます。

@Component({
  template: `
    <div *ngIf="type === 'running'">
  `
})
export class ActivityComponent {
  public type: string;
}

これは避けるべきコードです。"runing" など不整合な値が紛れ込んだ場合に TypeScript のコンパイルエラーを得る事ができません。せっかく TypeScript を使っているなら、型安全を最大限活用するべきだと思うのです。

返り値の型がないコードも同じです。Angular のライフサイクルイベントなどは別ですが、自分で書いた関数やメソッドは型を返却するべきです。型安全の恩恵を受けられないばかりか、自分以外の開発者に「何をする関数なのか」を伝える手段が絶たれてしまいます。

function foo() {
  return {
    idd: 1, // 何かタイポしているのに気づけない
    name: "",
    // 本来は label:"" を返さなければいけないが気づけない
  };
}

どの場面でどのような返り値を型として宣言するべきなのか、という考え方は Twitter で見かけた takepepe さんのスライドが非常に参考になりました。

TypeScript の流儀 | speakerdeck
https://speakerdeck.com/takefumiyoshii/typescript-falseliu-yi

そして TypeScript の型安全については私なんかがちょろっと説明できるシロモノではありません...。takepepe さんの書籍を読んで「型安全てこういう事なのか!」を学んだので、お勧め書籍としてリンクを掲載します。

実践 TypeScript | amazon
https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5TypeScript-BFF%E3%81%A8Next-js-Nuxt-js%E3%81%AE%E5%9E%8B%E5%AE%9A%E7%BE%A9-%E5%90%89%E4%BA%95-%E5%81%A5%E6%96%87/dp/483996937X

コンポーネントのレイヤーを分ける

だいぶ前のほうで どうしてもコンポーネントから切り離せないものは「DOM」「プレゼンテーション」の2つだけ と書きましたが、実際にはモーダル表示であったり、データ保存後の画面遷移であったり、コンポーネントにはいろいろな仕事が入り込んで来ます。

これらは「DOM を触るコンポーネント」「プレゼンテーションに徹するコンポーネント」「ユースケースとやり取りするコンポーネント」のようにコンポーネントを分類し、ディレクトリを分ける事で区別がつくようにしています。

// pages/layouts/foo.component.ts
//   "/foo" というページを構成するためのコンポーネント
//   このページに関する全てのコンポーネントの「親」となる
//   モーダル表示、画面遷移、ユースケースとのやり取りはこのコンポーネントの責務
//   DOMは触らない
@Component({
  template: `<user-search (onSearch)="search()"></user-search>`
})
FooPageComponent {
  constructor(
    private fooUseCase: FooUseCase,
    private router: Router,
  ) {}
  
  search() {
    this.fooUseCase.save().subscribe(
      () => this.router.navigateByUrl("...");
    );
  }
}
// pages/foo/components/user-search.component.ts
//   プレゼンテーションに徹するコンポーネント
//   ユーザー入力を受け付けて親コンポーネントに通知するのが責務
//   モーダル表示や画面遷移はしない(必要なら親にイベントを上げる)
//   DOMは触らない
@Component({
  selector: "user-search",
  template: `
    <search onSearch="search()"></search>
    <table></table>
  `
})
UserSearchComponent {
  @Output() onSearch = new EventEmitter();
  
  search() {
    this.onSearch.emit();
  }
}
// pages/shared/components/search.component.ts
//   DOM を触るコンポーネント
//   DOM のイベントを親コンポーネントに通知する
//   それ以外はしない
@Component({
  selector: "search"
  template: `
    <input type="text" (keypress)="keypress" kana-only>
    <button (click)="search()">検索</button>
  `
})
SearchComponent {
  @Output() onSearch = new EventEmitter();
  
  // DOM の要素へのアクセス
  @ViewChild(KanaOnlyDirective) directive: KanaOnlyDirective;
  
  // Tab/Enter キーの制御など
  keypress() {}
  
  search() {
    this.onSearch.emit();
  }
}

実行コンテキストを分けて考える

例えば「ステージング環境では検証したいけど、本番環境では見せたくないコンポーネント」のような、実行コンテキストに依存する処理があったとします。

これについては分離するべき妥当なレイヤーが思いつきません。このような場合、私はフレームワークを頼ります。フレームワークにその機能がないのであれば次に取り外しが簡単なサービスクラスの導入を考え、どうしてもうまく解決できない場合のみ、コンポーネントに if 文を書きます。

「本番環境では見せたくないコンポーネント」のケースでは Angular の機能を利用して実装する事ができたので、参考までにリンクを記載します。

[Angular] ステージング/プロダクション環境でローカル開発中の機能を見せないための仕組み
https://tech.quartetcom.co.jp/2020/10/05/angular-tips/

サンプルを書いたり実際ステージングで試したりと1日がかりの作業でしたが、仕組みさえできてしまえば何度でも使い回す事ができます。「本番環境では見せたくないコンポーネント」が出てくるたびにコンポーネントに if 文を書くよりも、よっぽど安全で効率的だと思うのです。

おわりに

繰り返しますが、私は素晴らしく優れたエンジニアではありません。DDD も全く詳しくないですし、レイヤーだとかユースケースだとかもしかしたら使い方が間違っているかもしれません。リファクタのプロでもないので「みんなにやさしいコード」が書ける自身もありません。ただ、だからこそ、物事を整理し、今現在知っている知識をフル活用して God パターンを作り出さないようにという努力が必要なのです。

この記事は、みんなでこのアーキテクチャ使おうぜ!と啓蒙するものではありません。私自身が自分のために、汚いコードを書かないようにと改めて戒めを書き起こしたものです。

同じく God パターンに陥って苦しんでいるどなたかの一助になれば幸いです。

6
4
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?