Durable Functionsの非同期HTTP APIパターンを使ってZIPファイルダウンロード機能を実装する

2024/02/09に公開

はじめに

複数ファイルを1つのZIPファイルにまとめてダウンロードする機能を実装する機会があり、アーキテクチャ設計から実装までの一通りの開発を実施したのでこの記事で紹介したいと思います。
複数ファイルをZIPにまとめる処理は、ファイル数や容量によっては時間がかかるためAzureのDurble Functions非同期HTTP APIパターンを使って開発しました。
Azure Functionsを使った開発は今までも多数経験がありましたが、Durable Functionsを使うのは初めてで、やや癖があり、まだ完全に理解したわけではありませんが今後の開発にも非常に役にたつパターンをキャッチアップできたと感じております。
今回の機能開発において、Azureを使ったベストプラクティス的な設計・実装ができたので、参考になれば幸いです。

開発背景

複数の画像ファイルを一括でダウンロード機能として、2MB程度の画像データを100ファイル一括ダウンロードする規模想定があり、サーバー負荷やAPIレスポンスの遅延が懸念されました。
そのため、最も処理負荷が高くなる「ZIPファイルの生成」処理を非同期で実現きるアーキテクチャが最適だと考え、Durable Functionsの採用に至りました。

Durable Functionsとは?

公式: Durable Functionsとは

Durable Functions は、サーバーレス コンピューティング環境でステートフル関数を記述できる Azure Functions の拡張機能です。 この拡張機能では、Azure Functions プログラミング モデルを使用して、オーケストレーター関数を記述することでステートフル ワークフローを定義でき、エンティティ関数を記述することでステートフル エンティティを定義できます。 拡張機能によって状態、チェックポイント、再起動がバックグラウンドで管理されるため、ユーザーはビジネス ロジックに専念できます。

Durable Functionsの非同期HTTP APIパターンとは?

公式: パターン #3: 非同期 HTTP API

非同期 HTTP API パターンでは、外部クライアントとの間の実行時間の長い操作の状態を調整するという問題に対処します。 このパターンを実装する一般的な方法は、HTTP エンドポイントによって実行時間の長いアクションをトリガーすることです。 その後、ポーリングによって操作が完了したことを認識できる状態エンドポイントにクライアントをリダイレクトします。

公式では、Durale Functionsを使った複数のアプリケーションパターンが紹介されています。今回はその中の「非同期HTTP APIパターン」を使って開発をしました。
ユースケース的に「ZIPファイル生成」の処理時間が長くなる可能性があり、クライアントによるAPIリクエスト送信からレスポンス取得までの時間が長くなることが最大の懸念事項でした。
公式ドキュメントにも「外部クライアントとの間の実行時間の長い操作の状態を調整するという問題に対処します。」との記載があり、今回のユースケースにおける懸念事項の解消に向けて最適な技術選定だと感じております。

公式: アプリケーション パターンにて紹介されている6つパターン

複数ファイルZIPダウンロード機能の設計

最も処理時間が長くなる「ZIPファイル生成」を非同期処理化し、クライアント側ではその処理完了をポーリングする設計パターンを採用しました。

技術的には、Azure Cosmso DBの拡張機能であるDurable Functionsが提供する非同期HTTP APIパターンを使ってアーキテクチャ設計しましました。
また、ストレージはAzure Blob Storageを使っているため、ZIPファイルダウンロードは署名付きURL(SAS付きURL)を採用しています。
詳しくはアーキテクチャ図を参考にしていただければと思いますが、ざっくりと以下のような処理しています。

【ZIPファイル取得関連】

  1. APIサーバーがクライアントから複数ファイルダウンロードリクエストを受け取る
  2. ダウンロード対象のファイル情報をDBから取得し、「ZIP生成用の非同期HTTP APIサーバー」へZIPファイル生成リクエストを投げる
  3. APIサーバーで「ZIP生成用の非同期HTTP APIサーバー」へのポーリング用URLを取得し、クライアントへ受け渡す
  4. クライアントは、ポーリング用URLを使ってZIPファイル生成の完了をポーリングする
  5. ZIPファイル生成が完了したら、クライアントはZIPファイルダウンロード用URLを取得する
  6. ユーザーは、ZIPファイルをダウンロードする

【ZIPファイル生成関連】

  1. APIサーバーからリクエストを受ける(その際に、ダウンロード対象ファイル情報を受け取る)
  2. リクエストを受け取ったら、APIサーバへZIPファイル生成のポーリング用URLを渡す
  3. ダウンロード対象のファイルデータをストレージから取得する
  4. ダウンロードしたファイルをまとめ、ZIPファイルを生成する
  5. ZIPファイルダウンロード用URLをユーザーに渡し、ポーリング完了とする

※ 処理フロー順に数字を振っています
※ ★がついている数字は、非同期APIサーバー内での処理順です

サンプルコードとコード解説

Durable Functionのクイックスタートに関しては公式を参考にしてください。ポーリングURLの取得方法や完了結果の受け取りについても、クイックスタートを動かしてみると理解できると思うので、今回は説明を割愛いたします。
公式:TypeScript で最初の持続的関数を作成する

サンプルコード(TypeScript)

Node.js のプログラミング モデル v4で実装しています。以下のサンプルコードは関数コード部分になります。
公式: Azure Functions Node.js 開発者ガイド

// Import modules
import {
  app,
  HttpHandler,
  HttpRequest,
  HttpResponse,
  InvocationContext,
} from "@azure/functions";
import {
  BlobSASPermissions,
  BlobServiceClient,
  StorageSharedKeyCredential,
  generateBlobSASQueryParameters,
} from "@azure/storage-blob";
import * as df from "durable-functions";
import {
  ActivityHandler,
  OrchestrationContext,
  OrchestrationHandler,
} from "durable-functions";
import * as FileType from "file-type";
import { v4 as uuidv4 } from "uuid";
import JSZip = require("jszip");

// Declare constant
const zipContainer = "zip-container";
const createZipActivityName = "createZip";

// Setup Azure Blob Storage
const azStAccountName = process.env.AZ_ST_ACCOUNT_NAME;
const azStAccountKey = process.env.AZ_ST_ACCOUNT_KEY;
const azStConnectionString = `DefaultEndpointsProtocol=https;AccountName=${azStAccountName};AccountKey=${azStAccountKey}==;EndpointSuffix=core.windows.net`;
const blobServiceClient =
  BlobServiceClient.fromConnectionString(azStConnectionString);
const storageSharedKeyCredential = new StorageSharedKeyCredential(
  azStAccountName,
  azStAccountKey
);

// Define types of request and response
type RequestBody = {
  downloadBlobs: {
    blob: string;
    container: string;
  }[];
};
type ResponseBody = {
  blobSasUrl: string;
};

// Orchestrator function
const createZipOrchestrator: OrchestrationHandler = function* (
  context: OrchestrationContext
) {
  const input: RequestBody = JSON.parse(context.df.getInput());

  const firstRetryIntervalInMilliseconds = 5000;
  const maxNumberOfAttempts = 3;
  const retryOptions = new df.RetryOptions(
    firstRetryIntervalInMilliseconds,
    maxNumberOfAttempts
  );

  const blobSasUrl = yield context.df.callActivityWithRetry(
    createZipActivityName,
    retryOptions,
    input
  );

  return { blobSasUrl } as ResponseBody;
};
df.app.orchestration("createZipOrchestrator", createZipOrchestrator);

// Activity function
const createZip: ActivityHandler = async (
  body: RequestBody
): Promise<string> => {
  const { downloadBlobs } = body;

  // Download blobs
  const downloadTasks = downloadBlobs.map(async (downloadBlob) => {
    const blockBlobClient = blobServiceClient
      .getContainerClient(downloadBlob.container)
      .getBlockBlobClient(downloadBlob.blob);
    return await blockBlobClient.downloadToBuffer(); // Image buffer data
  });
  const images: Buffer[] = await Promise.all(downloadTasks);

  // Compressed to zip
  const zip = new JSZip();
  let cnt = 1;
  for (const image of images) {
    const fileType = await FileType.fromBuffer(image);
    const name = `${cnt++}.${fileType.ext}`;
    zip.file(name, image);
  }
  const content = await zip.generateAsync({ type: "nodebuffer" });

  const zipBlobName = `${uuidv4()}.zip`;

  // Upload blob and generate blob SAS URL
  const containerClient = blobServiceClient.getContainerClient(zipContainer);
  const blockBlobClient = containerClient.getBlockBlobClient(zipBlobName);
  // Upload blob
  await blockBlobClient.upload(content, content.length, {
    blobHTTPHeaders: { blobContentType: "application/zip" },
  });
  // Generate blob SAS
  const now = Date.now();
  const blobUrl = blockBlobClient.url;
  const blobSASQueryParameters = generateBlobSASQueryParameters(
    {
      containerName: zipContainer,
      blobName: zipBlobName,
      startsOn: new Date(now + 1000 * 60 * -5), // -5min
      expiresOn: new Date(now + 1000 * 60 * 35), // +35min
      permissions: BlobSASPermissions.parse("r"),
    },
    storageSharedKeyCredential
  );

  return `${blobUrl}?${blobSASQueryParameters.toString()}`; // Blob SAS URL
};
df.app.activity(createZipActivityName, {
  handler: createZip,
});

// HTTP handler
const createZipHttpStart: HttpHandler = async (
  request: HttpRequest,
  context: InvocationContext
): Promise<HttpResponse> => {
  const client = df.getClient(context);
  const body: unknown = await request.text();
  const instanceId: string = await client.startNew(
    request.params.orchestratorName,
    { input: body }
  );

  context.log(`Started orchestration with ID = '${instanceId}'.`);

  return client.createCheckStatusResponse(request, instanceId);
};

app.http("createZipHttpStart", {
  route: "orchestrators/{orchestratorName}",
  methods: ["POST"],
  extraInputs: [df.input.durableClient()],
  handler: createZipHttpStart,
});

コード解説

Durable Functionsが持つ3つの関数を理解する

公式: 関数を作成する

最も基本的な Durable Functions アプリには、3 つの関数が含まれています。
・ "オーケストレーター関数" - 他の関数を調整するワークフローを記述します。
・ "アクティビティ関数" - オーケストレーター関数によって呼び出され、作業を実行し、必要に応じて値を返します。
・ クライアント関数 - オーケストレーター関数を開始する通常の Azure Functions。 この例では、HTTP によってトリガーされる関数を使用しています。

公式ドキュメントに記載がある通り、今回の実装を理解するためには3つの関数を把握する必要があります。
サンプルコード内だと、それぞれ以下の関数名で定義してあります。

  • オーケストレーター関数: createZipOrchestrator
  • アクティビティ関数: createZip
  • クライアント関数: createZipHttpStart

それぞれ下記で解説していきます。

オーケストレーター関数: createZipOrchestrator

HTTPリクエストのリクエストボディを受け取り、アクティビティ関数の呼び出しを行います。
アクティブ関数が複数になる場合は、オーケストレーター関数内でアクティビティ関数を適切な順序で呼び出すことができます。
また、処理が完了したらレスポンスボディに必要な値をセットしてを返します。

オーケストレーター関数で最も重要なのがリトライの設定です。

公式: エラー発生時の自動再試行

アクティビティ関数またはサブオーケストレーション関数を呼び出すときに、自動再試行ポリシーを指定できます。

アクティビティ関数ごとにリトライの設定が可能なので、ユースごとにリトライを柔軟に設定することができます。
実装的には、callActivityWithRetryメソッドの第二引数にRetryOptionsを渡すことで設定できます。

アクティビティ関数: createZip

ビジネスロジックは、アクティビティ関数で実装していきます。
今回の実装では、アクティビティ関数にて以下3つの処理を実装しています。

  1. ダウンロード対象のファイルデータをストレージから取得する
  2. ダウンロードしたファイルをまとめ、ZIPファイル生成
  3. ZIPファイルダウンロード用URLを発行

3つの処理をそれぞれ別のアクティビティ関数に分ける案もありましたが、下記の理由で1つのアクティビティ関数内で全て実施する判断に至りました。

  • アクティビティ関数間でダウンロードした画像データの受け渡し処理を行うのは実装難易度が上がること
  • シーケンシャルな処理となるため、処理ごとにリトライを分ける必要がない

クライアント関数: createZipHttpStart
オーケストレーター関数の開始と終了を関しして、ステータスをモニタリングする役割を持ちます。今回のケースだと、HTTPリクエスを受け取ってオーケストレーターを起動させます。

まとめ

Azure Functionsでは、C#JavaScript /TypeScriptPythonPowerShellJavaのプログラミング言語がサポートされており、それぞれSDKが用意されているので実装面のハードルを低くしてくれています。その分、アーキテクチャ設計に集中できることが最大のメリットで、今後もその恩恵を積極的に使っていたいサービスだと感じております。
また、今回は非同期HTTP APIパターンを使用したユースケースを紹介しましたが、Durable Functionsは本当に多機能で、さまざまな機能開発シーンにおいて多様なアーキテクチャパターンを提供してくれるとても有用なサービスだと感じております。

株式会社log build

Discussion