見出し画像

Patterns of Enterprise Application Architecture から学ぶ - Chapter 7

はじめに

こんにちは。ソフトウェアエンジニアのttokutakeと申します。

これはPatterns of Enterprise Application Architecureという本を読んでみて、自分が理解した内容を要約して書き起こしていくシリーズの7回目の記事です。

全8回のシリーズとなる予定です。

間違っている部分などがありましたら、ご指摘いただけますと幸いです。

注意事項

  • 対象読者は主にWebアプリケーションのエンジニアです。

  • 本の内容をそのまま記載しているわけではありません。

    • 内容のすべてを記載すると情報量が多いように感じたので、省略している部分がそれなりにあります。

    • 自分自身の見解を述べている箇所もあります。

Chapter 7. Distribution Strategies

オブジェクトを複数のノード間で分散させて共有したいと思う場面は多々あると思います。しかしこれはとても落とし穴が多いです。この章ではオブジェクトの分散についての教訓を示します。

分散オブジェクトの誘惑

ある注文システムを考えてみます。そのシステムは `Order`, `Invoice`, `Customer`, `Delivery`というオブジェクトから構成されています。各オブジェクトをそれぞれ別のノードに配置する設計を考えてみます。

オブジェクトを分散させている様子

筆者はこのような設計についてクライアントからレビューされることがよくあったようですが、オススメできないと答えていたようです。

このような設計を実現するためのツールを提供するベンダーは、処理のボトルネックとなるオブジェクトのノードだけを増やせるのでコスト効率が良くなるという利点を挙げていたようです。そのツールは、リモート呼び出しかローカル呼び出しかを意識しないで透過的に扱えるので今までと変わらずコーディングできるということもよくうたっていたようです。

透過的に扱えることは利点と思えるかもしれませんが、分散環境においてはむしろパフォーマンス面で欠点となりえます。また、オブジェクトを分散させることはアプリケーションのビルドやデプロイも難しくします。

ただし各オブジェクトをサービスとして考えた場合、マイクロサービスアーキテクチャーとしてこのような設計を採用することは可能です。透過的に扱うことは現在でもまったく推奨されないと思いますが、 Docker のようなコンテナ技術や Kubernetes のようなオーケストレーションツールがビルドやデプロイの問題を解決しようと試みています。

リモート呼び出しとローカル呼び出しのインターフェース

オブジェクトを分散させるのがよろしくない理由は、コンピューターの基本的な事実に由来します。プロセス内でのプロシージャーコールはとてもとても速いです。分離された2つのプロセス間でのプロシージャーコールはとても遅いです。別のノード上のプロセスともなればとんでもない遅さになります。これはネットワークを経由する必要があるからです。つまりこの違いを意識するためには見分けがつくようにインターフェースを分けておく必要があります。

ローカル呼び出しであれば、インターフェースは細かい粒度で分けても問題ありません。たとえば `Customer` オブジェクトの `name` フィールドや `email` フィールドに対するゲッターやセッターメソッドといった粒度です。

リモート呼び出しのインターフェースではそうはいきません。 `name` フィールドや `email` フィールドはそれぞれ取得するのではなく、一度に取得するようにします。呼び出し回数をどれだけ少なくするかが重要です。つまりコードの読みやすさを重視すると分けたほうがよい処理を、パフォーマンスの理由からまとめる必要があるような場面もありえます。

ここから導かれる筆者の主張は、分散オブジェクト設計の第一法則は「分散させるな!」です。

だとするとマルチコアプロセッサーを効率的に使うにはどうしたらよいのでしょうか?答えは簡単です。すべてのオブジェクトをコピーして各プロセス上に配置して、ローカル呼び出しだけにすればよいのです。

各プロセスにすべてのオブジェクトをコピーしている様子

分散すべきところ

分散させずに済むならその方がよいのですが、それには限界があります。

  • PCやスマホといったクライアントからサーバーにアクセスするアプリケーションの場合は、当然クライアントとサーバーは別のプロセス上で動いています。

  • WebアプリケーションサーバーとDBも別のノード上で動いているケースがほとんどです。ストアドプロシージャーを使うこともできますが、それですべてのアプリケーションの動作をまかなうのは現実的ではありません。

  • 外部のSaaSを使う場合にWeb APIにリクエストするような場面もプロセス間通信をしていると言えます。

  • そしてアプリケーション内でもオブジェクトを分散させないといけないような場面は出てきます。

分散の境界

オブジェクトを分散させる境界は極力限定させるべきだと筆者は言います。

Remote Facadeを使うとリモート呼び出しの境界を定めることができ、それ以外の箇所では取得したオブジェクトを利用するだけで済みます。

非常に単純な実装例を紹介します。 `CrewFacade` クラスはWeb APIにリクエストをする4つの CRUD 用メソッドを実装しています。ここではちゃんと実装しているのは `fetch()` だけという手抜き具合ですがお許しください。

// remote_facade.ts

interface CrewData {
  id: number;
  name: string;
  bounty: number;
  specialMoves: SpecialMove[];
}

interface SpecialMove {
  id: number;
  name: string;
}

export class CrewFacade {
  static async fetch(_id: number): Promise<CrewData> {
    const response = await fetch("http://api:8080");
    const crewData = await response.json();
    return crewData;
  }

  static create(crewData: CrewData) {
    const _requestBody = JSON.stringify(crewData);
    // TODO: Send crew data to a server for creation
  }

  static update(crewData: CrewData) {
    const _requestBody = JSON.stringify(crewData);
    // TODO: Send crew data to a server for update
  }

  static delete(_id: string) {
    // TODO: Send a request to a server for deletion
  }
}

重要なのは個別のフィールドを操作するようなメソッドを実装していないということです。 `updateName()` ではなく `update()` をインターフェースとして表出させておくことで、利用側には更新したいフィールドはまとめて更新するように促します。Remote FacadeはCRUDしか実装してはいけないということはないので、状況に応じていろいろなメソッドを実装すると良いです。

あくまで境界を分離することとパフォーマンスを意識したパターンですので、 GoF の Facadeパターン とは目的が異なることに注意してください。個人的にはData Mapperの方が近しいパターンであると思います。Data Mapperについては こちら で解説していますので、よければご参照ください。

Web API側ではドメインモデルをそのままクライアントに渡すことはできないので、Data Transfer Objectによるデータ変換をしてクライアントに適切なデータを渡します。

以下のようなドメインモデル(Active Record)があるとします。実際にはDBなどからデータを取得しますが、ここでは手を抜いて固定のデータを返すようにしています。

// domain.ts

export class Crew {
  constructor(
    public id: number,
    public name: string,
    public bounty: number,
    public specialMoves: SpecialMove[],
  ) {}

  static fetch(_id: number) {
    // TODO: Fetch data from a data source like DB
    const specialMoves = [
      new SpecialMove(1, "Gum-Gum Pistol"),
      new SpecialMove(2, "Gum-Gum Bazooka"),
    ];
    return new Crew(
      1,
      "Luffy",
      1_500_000_000,
      specialMoves,
    );
  }
}

export class SpecialMove {
  constructor(
    public id: number,
    public name: string,
  ) {}
}

Data Transfer Objectはドメインモデルをクライアントに渡すデータに変換します。

// data_transfer_object.ts

import { Crew, SpecialMove } from "./domain.ts";

export class CrewDto {
  private constructor(
    public id: number,
    public name: string,
    public bounty: number,
    public specialMoves: SpecialMoveDto[],
  ) {}

  static fromDomainModel(crew: Crew) {
    const specialMoveDtos = crew.specialMoves.map((s) =>
      SpecialMoveDto.fromDomainModel(s)
    );
    return new CrewDto(
      crew.id,
      crew.name,
      crew.bounty,
      specialMoveDtos,
    );
  }
}

export class SpecialMoveDto {
  private constructor(
    public id: number,
    public name: string,
  ) {}

  static fromDomainModel(specialMove: SpecialMove) {
    return new SpecialMoveDto(specialMove.id, specialMove.name);
  }
}

JSONを返すAPIはData Transfer Objectを用いてドメインモデルを変換しています。

// presentation.ts

import { Crew } from "./domain.ts";
import { CrewDto } from "./data_transfer_object.ts";

export class Presentation {
  static handler(_request: Request): Response {
    const crew = Crew.fetch(1);
    const crewDto = CrewDto.fromDomainModel(crew);

    return new Response(JSON.stringify(crewDto), {
      status: 200,
      headers: { "content-type": "application/json; charset=utf-8" },
    });
  }
}

実は今回の例だとわざわざ `crewDto` に変換しなくても `JSON.stringify(crew)` と `crew` を直接使っても同じJSONになります。Data Transfer Objectは本来はクライアントに見せたくないデータをフィルタリングしたり、複雑な計算をした値をフィールドとして追加したりもできます。

Remote FacadeとData Transfer Objectの実装例のすべてのコードは こちら にありますので、気になる方はご参照ください。

Data Transfer Objectはオブジェクトを提供する側と提供される側どちらでも利用されるので、プリミティブ型のデータと別のData Transfer Object以外は含まないのが普通ですと本には記載されています。

ただData Transfer Objectを共有するということはそもそも利用する言語ランタイムが同じである必要があるため、そういったData Transafer Objectの使い方をする場面は現在ではあまり見ないかもしれません。もしデータ型を共有したいなら Open API や Protocol Buffers といった異なるプラットフォーム間でデータのスキーマ情報を共有できる手段を用いることが多いように思います。

分散のためのインターフェース

この本が執筆された当時はHTTP上のXMLインターフェースが台頭してきていたようです。今ではそんなに見かけることはなくなったと思いますが、 SOAP が人気になりつつあったようです。

XMLであればどんなプラットフォームでもパースできて、HTTPという一般的によく利用されるプロトコルを利用することでファイヤーウォールなどを気にしなくて済むという利点が主な理由です。

現在では JSON を用いた RESTful APIGraphQL 、さらにはProtocol Buffersを用いた GRPC のようなプロトコルが一般的には使われるようになっています。

共通のプラットフォームでやり取りするのであれば、プラットフォームに用意されたリモートコールを用いることもできます。これはパースの必要がないので、パフォーマンスの改善が期待できます。自分が知っているものだと ErlangのMessage Passing や Java RMI などが該当します。

ただし、現代ではサーバーリソースの追加などが容易なので、このレベルでパフォーマンスの改善を求められるようなシステムやアプリケーションはそんなには多くないかもしれません。

ここまで紹介した同期的な呼び出し以外にメッセージベースの非同期の呼び出しをするやり方もあります。これは簡単な紹介に留めますが、 Amazon SQSCloud Pub/Sub などのサービスや RabbitMQ のようなソフトウェアがよく利用されています。

さいごに

今回はChapter 7. Distribution Strategiesについての紹介をしました。

説明が不足していたり、わかりにくいようなところがありましたら、お気軽にご連絡いただければと思います。

次回はChapter 8. Putting It All Togetherを紹介します。最終回です。どうぞよろしくお願いします。

この記事が気に入ったらサポートをしてみませんか?