マイクロサービス設計におけるよくある落とし穴は、複数のサービス間で直接通信を行うことです。これにより、単一のトップレベルトランザクションを完了するために、複数のサービスにまたがる複数のトランザクションが発生します。本質的に、モノリスが作成され、それが分散モノリスへと進化しました。これは、両方のパラダイムの最悪の部分です。

この記事では、トランザクションはマイクロサービス間の複雑なインタラクションをトリガーするリクエストを表し、明確にするために、一部の図は簡略化されており、特定の実装の詳細が含まれていない場合があります。

image1

この問題を解決するための一般的な戦略は、メッセージバスを利用してサービスを分離することです。このアプローチの利点は次のとおりです。

  • 必要に応じて、または可能な限り、非同期ダウンストリーム処理。
  • サービス停止が発生した場合でも、中断されないトランザクション処理。
  • 停止後、またはバグ修正が本番環境にプッシュされた後の、自動トランザクション再キューイングと再処理。
  • メッセージバスに別のコンシューマーを追加することで、新しい処理ステップを追加できます。

image2

上記の実装で使用できるテクノロジーは、SQS、SNS、RabbitMQ、Kafka、Redis、ActiveMQなど、多岐にわたります。コンシューマーの実装は使用するブローカーに依存しますが、共通の要素が含まれます。

  • サービスへのリクエストのリトライロジック。
  • メッセージの受信とその処理の成功または失敗を、確認または未確認(システムではunack)にする機能。
  • ダウンストリームの依存関係のためにバス内のデータを飽和させること。つまり、新しく作成されたトランザクションのIDまたはインスタンス、トランザクションメタデータなど。
  • ダウンストリーム処理をトリガーするための新しいメッセージの生成。
  • メッセージバス内のキューまたはトピックのサイズに基づく自動スケーリング機能。サービス自体の自動スケーリング機能と同様に、コンシューマーとプロデューサーもスケールできます。

BPMNによるモデリング問題の解決

エンジニアは、状態、バックエンドプロセスなどを表現するために、有限状態マシン(FSM)をよく使用します。有限状態マシンを表現するために使用できる表記標準はいくつか存在しますが、FSMを表現するために基本的なフローチャートをよく使用します。ビジネスプロセスモデル表記法(BPMN)は、エンジニアリングチームとビジネス関係者の両方が理解できる、FSMを作成するための標準を提供します。BPMNの利点は、エンジニアとビジネス関係者の両方がビジネスプロセスについて議論する際に同じ言語を話すため、ドメインをモデリングする際に見られる「翻訳」エラーがなくなることです(ビジネスプロセスモデルと表記法(BPMN)、バージョン2.0)。

たとえば、以下のBPMN図は、注文を作成するプロセスを表しています。手順は明確で、ビジネス関係者とエンジニアの両方が簡単に理解できます。

image3

これらの図は、コミュニケーションに限定されません。BPMNエンジンを使用することで、上記の図をエンジンに公開して、手順を呼び出すことができます。これにより、ビジネスでプロセスをモデル化するために使用したのと同じ図を使用して、作業を調整できます。

BPMNエンジンをシステムに統合してマイクロサービスオーケストレーションを実行する方法はいくつかあります。ただし、使用するエンジンによっては、すべてのエンジンが同じレベルの機能を提供しているわけではなく、利用可能な機能が異なることに注意することが重要です。たとえば、Activitiは、コアエンジン機能の提供に重点を置いており、I/Oなどのタスクを実行するために外部インフラストラクチャに依存しています。その他のエンジンは、Camundaのように、プラットフォームとして提供されており、コアBPMNおよびルールエンジンが含まれていますが、追加のインフラストラクチャと機能が付属しています。

したがって、以下の例は、使用するエンジンによって異なります。[ 注: CamundaはActivitiのフォークであるため、これら2つの特定のエンジン間には重複があります ]これらの例では、BPMNエンジンワーカーはBPMNエンジン自体の内部のスレッドであるか、外部コンポーネントである可能性があります。ベストプラクティスとして、タスクの作業がBPMNエンジンの外部のコンポーネントによって実行される外部タスクパターンを使用することが、I/Oまたは複雑な処理ロジックにスクリプトタスクを使用するよりも推奨されます。

注: 以下の図に示す「ゲートウェイ」へのすべての参照は、コンポーネントの前面にある何らかのインターフェイス(REST API、API GW、gRPC API、マイクロサービスなど)を示します。

BPMNエンジンをメッセージバスとして使用したマイクロサービスオーケストレーション

image4

最も基本的な例では、ゲートウェイのようなサービスがBPMNエンジンの前面にあり、BPMNは本質的にメッセージバスとして機能します。これは、内部メッセージバスを持ち、タスクに対応するトピックへのサブスクリプションを可能にするCamundaで可能です。描かれている「ワークフローゲートウェイ」は、エンジンと通信する何らかの論理コンポーネントです。CamundaにはREST APIが付属しているため、組み込みの機能があり、2つの論理コンポーネントは同じデプロイされたコンポーネントです。

専用メッセージバスを使用したマイクロサービスオーケストレーション

エンジンもメッセージバスとして使用されるパターンと同様に、このパターンでは、メッセージバスをエンジン自体から分離します。この場合、エンジンのワークフローは、ワークフローの呼び出しを担当するメッセージバスコンシューマーによってトリガーされます。

image5

このパターンは、エンジンがメッセージバスと通信し、メッセージバスコンシューマーがマイクロサービスに対して動作するように反転することもできます。

image6

マイクロサービスごとのエンジン/ワークフローインスタンス

このシナリオでは、各マイクロサービスには、マイクロサービスに固有のエンジン/ワークフローインスタンスへのアクセスが付属しています。これは、BPMNエンジンをサービス自体に埋め込み、エンジンを別のプロセスで実行される論理マイクロサービスの一部として出荷することで実行できます(サービスのコンテナを1つではなく2つと考えてください)。または、このCamunda実装の例のように、メインエンジンインスタンスで個々のマイクロサービスに専用のワークフローを使用します。

image7

どれが最適ですか?

すべてです。どれでもありません。これは、システム全体のアーキテクチャ、目標、チーム構成、スキルセットなどに完全に依存します。これらはパターンであり、他のすべての設計パターンと同様の方法で適用する必要があります。つまり、共通の関心事を処理したり、特定の問題を解決したりする方法として。

ワーカーとマイクロサービスの数も考慮する必要があります。強力なDevOpsスキルを持つ規律あるチームは、より多くのコンポーネントとより頻繁なデプロイメントを伴う、より複雑なパターンの運用上のオーバーヘッドを処理するのに適しています。ほとんどの場合、大規模なエンタープライズプロジェクトであっても、単一のプロセスエンジンで十分です。

ワーカーパターンの実装

Camundaを積極的に使用しているため、コード例または実装の詳細をチームに関連付けるために、Camundaの例を使用します。Camunda NodeJSクライアントのソースコードはこちらにあります

image8

このパターンの主なハイライトは次のとおりです。

  • Camundaは水平方向にスケーラブルです
  • 外部タスクワーカーは水平方向にスケーラブルです
  • マイクロサービスは水平方向にスケーラブルです

適切な自動スケーリング構成を使用すると、システムはバックプレッシャーを効果的に処理し、必要に応じてスケールアップおよびスケールダウンできます。必要に応じて、以下のさまざまなパターンを組み合わせて使用できます。

ワーカーDTO

外部タスクパターンを使用する場合のベストプラクティスは、エンジンとワーカー間で動的データを渡すことができる変数としてDTOを定義することです。また、処理中にできるだけ少ない変数を変更し、実際のデータではなくドメインオブジェクトへの参照を保存することをお勧めします。

外部タスクは、プロセス実行中に非同期および並行して呼び出すことができるため、これらの条件を処理するようにプロセスを設計することが重要です。ワーカーは他のワーカーを認識しないようにする必要があります。したがって、プロセスを分岐して一連のアクションを並行して実行できる場合、プロセスはタスクの実行に基づいて変更される可能性のある変数の更新を処理する必要があります。

camunda-external-task-client-jsからのタスクインターフェイスを次に示します。

1. export interface Task {

2. variables: Variables;

3.

4. // これらはパッケージドキュメントによって保証されていませんが、REST APIドキュメントに従って返されます

5. activityId?: string;

6. activityInstanceId?:string;

7. businessKey?:string;

8. errorDetails?:string;

9. errorMessage?:string;

10. executionId?:string;

11. id?:string;

12. lockExpirationTime?:string;

13. priority?:number;

14. processDefinitionId?:string;

15. processDefinitionKey?:string;

16. processInstanceId?:string;

17. retries?:number;

18. tenantId?:string;

19. topicName?:string;

20. workerId?:string;

21. }

プロセスインスタンス、タスクワーカー、およびタスクがオーケストレーションしているサービス間でデータをマーシャリングできるようにするために、DTOを格納する変数が作成されます。

TypeScriptのDTOの例:

1. export interface TaskResult {

2. data: any,

3. startTime: number,

4. endTime: number,

5. taskId: string

6. }

7.

8. export interface ProcessTaskResults {

9. correlationId: string, // これは、メッセージが最初にメッセージバスにドロップされたときに、アップストリームプロデューサーによって作成されます

10. processMetadata: any, // これには、ドメインオブジェクトへのポインタ、およびタスクにとって重要なその他のデータが含まれている必要があります

11. taskResults: Array<TaskResult> // 結果の追加のみの配列。プロセスは結果の処理方法を認識し、タスクから返されたデータに基づいて決定を下すことができます。

12. }

DTOは、プロセスインスタンス変数にシリアル化されます。タスクが実行されると、新しい結果がtaskResults 配列にプッシュされます。次に、プロセスは外部タスク呼び出しからのデータを使用して、プロセスを前進させます。このアプローチの利点は次のとおりです。

  • スクリプトタスクは、プロセス自体で実行する方が適している非常に小さなロジックにのみ必要です。例としては、分岐し、外部タスクの並列呼び出しがあり、並列ステージの最後にマップリデュースを実行してタスクの結果を結合する必要があるプロセスがあります。
  • タスクの結果の値をチェックし、その値に基づいて決定を下すなど、マイナースクリプトで実行しやすい基本的なロジック。
  • マイクロサービスまたはワーカーコードのサービス停止またはバグが原因で障害が発生した場合、プロセス自体を変更して再デプロイすることなく修正できます。サービスが失敗した場合、または特定のエラーコードを返した場合、ワーカーはタスクの失敗またはBPMNエラーを発生させます。これらは、サービスのヘルスが復元されたら、失敗したステップを一括で再トリガーするために使用できるインシデントを発生させます。

汎用外部ワーカー

これは、マイクロサービスオーケストレーションを実行する最も簡単な方法です。

1. import {

2. Handler,

3. HandlerArgs,

4. Variables,

5. Client,

6. logger

7. } fromcamunda-external-task-client-js‘;

8.

9. import * as dotenv fromdotenv‘;

10. import path frompath‘;

11. import { createWorkerConfiguration } from ‘./factories‘;

12.

13. import { makeApiCall, createTaskResult, setTaskResult } from ‘./utils‘;

14.

15. export const callApiHandler: Handler = async ({

16. task,

17. taskService,

18. }: HandlerArgs) => {

19. try {

20. const processMetadata = task.variables.get(‘ProcessTaskResults‘).get(‘processMetadata‘);

21.

22. const apiCallResult = await makeApiCall(processMetadata);

23. const taskResult = createTaskResult(apiCallResult);

24. setTaskResult(task, taskResult);

25.

26. await taskService.complete(task, processVariables);

27. console.log(‘タスクを正常に完了しました!!‘);

28. } catch (e) {

29. await taskService.handleFailure(task, {

30. errorMessage: “タスクワーカーの失敗”,

31. errorDetails: e,

32. retries: 1,

33. retryTimeout: 1000

34. });

35. console.error(`タスクの完了に失敗しました, ${e}`);

36. }

37. };

38.

39. dotenv.config({ path: path.join(__dirname, ‘.env’) });

40.

41. const workerConfig = createWorkerConfiguration(process);

42.

43. // TODO: 実プロジェクト用の設定を定義

44. const camundaClientConfig = {

45. baseUrl: workerConfig.camundaRestUrl,

46. use: logger,

47. };

48.

49. // カスタム設定でクライアントインスタンスを作成

50. const client = new Client(camundaClientConfig);

51.

52. // トピック ‘apiCall’ を購読

53. client.subscribe(“apiCall“, async function({ task, taskService }) {

54. await callApiHandler(task, taskService);

55. });

このワーカーは、Camunda とマイクロサービス間でデータを単純に受け渡します。このパターンを機能させるには、マイクロサービス側で標準化された汎用のDTOインターフェースが必要です。サービスに統一的な汎用DTOがない場合は、別のパターンを使用できます。

このワーカーの利点は、トピックのマップに対する単純な反復処理と汎用のAPIコールハンドラーにより、クライアントの購読を外部から設定でき、新しいトピックやエンドポイントを動的にマッピングできる点です。このワーカーは同質的なオーケストレーションレイヤーを作成し、効果的な実装には事前設計が必要となります。多くの場合、このパターンはグリーンフィールドのプロジェクトで、サービス間でDTO契約を可能にする十分な事前設計が完了している場合に使用されます。

Lambda 外部ワーカー

image9

このパターンはベーシックワーカーパターンと同様に、External Task Worker がトピックをLambdaにマッピングし、Lambda呼び出しとプロセスインスタンス間でデータをマーシャリングする役割を担います。このパターンは抽象化とスケーラビリティの層を追加しますが、その分、複雑さも増します。LambdaがサードパーティシステムやAWSサービスと連携するシナリオでは、スタック内の他の場所でLambdaを再利用できたり、サードパーティサービスとの連携における実装詳細を隠蔽できるため有用です。

ドメインリッチ外部ワーカー

このパターンは構造的にはジェネリックワーカーパターンに類似していますが、当該ワーカーはすべてのきめ細かなオーケストレーションを実行し、さまざまなドメインオブジェクトを理解し、複数のマイクロサービスへの依存関係を持ちます。このパターンでは、ワーカーを分離し、1つのワーカーが扱うトピック数を限定するのが最適です。

このパターンは、ワーカーがインフラやマイクロサービスアーキテクチャについて大幅に多くを理解するため、ジェネリック外部ワーカーと比べてCamundaとマイクロサービス間のインターフェースをより粗粒度にします。ジェネリック外部ワーカーは、マイクロサービスへのAPIコールをラップする手段であり、すべてのマイクロサービスが理解する標準化されたDTOを必要とします。

標準化されたDTOの実装が不可能な場合でも、このモデルではCamundaとワーカー間のインターフェースを汎用のまま維持できます。ただし、ワーカーはAPIコールとの1対1のマッピングにとどまらず、データをどのように扱うかを理解している必要があります。このパターンにより、BPMNを簡素化し、実装が技術的な課題に対処できる可能性があります。例えば、異種のワーカー実装を強いられるレガシーシステムとの相互運用性などです。

SourceFuse の ARCで、事前構築済みのマイクロサービスを見つける準備はできていますか?