Azureサービスを組み合わせて、HTMLからPDFに変換する仕組みを作ってみた

# はじめに
4種類のAzureサービスを組み合わせて、HTMLからPDFファイルを生成する仕組みを作ってみたので、その時の話をつらつらと書こうと思います。

この記事はTDCソフト株式会社Advent Calendarの24日目の記事です。
https://qiita.com/advent-calendar/2024

# 構成
今回作成したPDF変換は、Node.jsで使用できる「Puppeteer」を使用しています。
https://pptr.dev/

全体の流れは以下の通りで、別々のAzureサービスを利用して構築しています。
* PDF変換処理の受け口となる「API」：Azure Functions
* PDF変換ジョブを管理する「Queue」：Azure Service Bus
* Puppeteerを使用し、PDF変換を行う「Worker」：Azure Container Apps
* 変換対象のHTMLと返還後のPDFファイルを保持する「Storage」：Azure Storage

```mermaid
sequenceDiagram
    participant User as User
    participant API as API
    participant Queue as Queue
    participant Worker as Worker
    participant Storage as Storage

    User->>API:  HTMLを使用してPDF変換API実行（http://localhost:3000/generate）
    API->>Queue:  PDF変換ジョブをキューに追加
    API->>Storage:  変換対象のHTMLを保存
    Queue->>Worker: PDF変換メッセージをキューから取得
    Worker->>Storage: 変換対象のHTMLを取得
    Storage->>Worker: 変換対象のHTMLを返す
    Worker->>Storage: PDF変換を実行し、変換したファイルを保存
    User->>API:  PDFチェックAPI実行（http://localhost:3000/job/:ジョブID）
    API->>Storage:  PDFファイル存在チェック
    Storage->>API:  PDFファイルあり
    API->>User:  PDFファイルあり
    User->>API:  PDFファイル取得API実行 (http://localhost:3000/get/:ジョブID)
    API->>Storage:  PDF取得
    Storage->>API:  PDFファイルをレスポンス
    API->>User:  PDFファイルをレスポンス
```

QueueとStorageに関しては、Azure上で構築したものに接続して使用するだけなので、主に開発を行ったのはAPIとWorkerになります。
以降ではAPIとWorkerの作成について記載します。
なお、Azureサービスの作成はGUIメインで行ったため、詳細な手順は省略します。

# APIの作成
Azure Functionsにデプロイするため、Microsoftが提供している「Azure Functions Core Tools」を使用して、アプリの開発を行いました。

まずは、プロジェクトと関数を作成します。
https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-deploy-container?tabs=acr%2Cbash%2Cazure-cli&pivots=programming-language-javascript

```bash
# プロジェクトの作成
func init --worker-runtime node --language javascript --docker
# 関数の作成
func new --name index --template "HTTP trigger"
```

次に、以下3書類のAPIを作成します。
- PDF変換API（~generate）
- PDFチェックAPI（~job）
- PDF取得API（~get）

APIの処理では、Azure Service Busへのメッセージ送信とAzure StorageへのHTMLファイルのアップロードを行うため、「@azure/service-bus」と「@azure/storage-blob」のライブラリを使用します。そのため、package.jsonに以下を追記します。

package.json
```json
"dependencies": {
    "@azure/functions": "{バージョン}",
    "@azure/storage-blob": "{バージョン}",
    "@azure/service-bus": "{バージョン}"
}
```

index.js
```javascript
const { app } = require('@azure/functions');
const { BlobServiceClient } = require('@azure/storage-blob');
const { ServiceBusClient } = require('@azure/service-bus');
const { PassThrough } = require('stream');

// Blobサービスクライアントを作成
const blobServiceClient = BlobServiceClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING);
const containerClient = blobServiceClient.getContainerClient(process.env.CONTAINER_NAME);
// Service Busクライアントを作成
const sbClient = new ServiceBusClient(process.env.SERVICE_BUS_CONNECTION_STRING);

// PDFファイルの生成API
app.http('generate', {
    methods: ['POST'],
    authLevel: 'anonymous',
    route: 'generate',
    handler: async (request, context) => {
        await generatePDF(context, request);
        return context.res;
    }
});

// PDFファイルの存在チェックAPI
app.http('job', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'job/{id}', 
    handler: async (request, context) => {
        await checkPDF(context, request);
        return context.res;
    }
});

// PDFファイルの取得API
app.http('get', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'get/{id}', 
    handler: async (request, context) => {
        await getPDF(context, request);
        return context.res;
    }
});

async function generatePDF(context, req) {
    const jobId = Date.now();
    // リクエストボディ取得
    const reqBody = await req.json();
    const htmlContent = reqBody.html;

    // HTMLが提供されていない場合はエラーを返す
    if (typeof htmlContent !== 'string' || !htmlContent) {
        context.res = { status: 400, body: 'HTMLコンテンツが提供されていません' };
        return;
    }

    // HTMLをBlobにアップロード
    const htmlBlobUrl = await uploadHtmlToBlob(htmlContent, jobId);

    // Azure Service Busにメッセージを送信
    const sender = sbClient.createSender(process.env.QUEUE_NAME);
    try {
        await sender.sendMessages({ body: JSON.stringify({ jobId, htmlBlobUrl }) });
        console.log(`ジョブID ${jobId} をキューに追加しました`);
        // レスポンスをJson型で返す
        context.res = { status: 200, jsonBody: { message: 'ジョブが受け付けられました', jobId: jobId } };
    } catch (error) {
        context.res = { status: 500, body: `エラーが発生しました: ${error.message}` };
    } finally {
        await sender.close();
    }
}

// HTMLをBlobストレージにアップロードする関数
async function uploadHtmlToBlob(htmlContent, jobId) {
    const blobName = `${jobId}.html`;
    const blockBlobClient = containerClient.getBlockBlobClient(blobName);
    await blockBlobClient.upload(htmlContent, Buffer.byteLength(htmlContent), { blobHTTPHeaders: { blobContentType: 'text/html' } });
    return blockBlobClient.url; // BlobのURLを返す
}

async function checkPDF(context, req) {
    const jobId = req.params.id;
    const pdfPath = `${jobId}.pdf`; // Blobのファイル名
    
    // PDFファイルが存在するかチェック
    try {
        const blobClient = containerClient.getBlobClient(pdfPath);
        const exists = await blobClient.exists();
        if (!exists) {
            context.res = { status: 404, body: false };
            return; 
        }
        context.res = { body: true };
    } catch (err) {
        console.error(err);
        context.res = { status: 500, body: false };
        return;
    }
}

async function getPDF(context, req) {
    const jobId = req.params.id;
    const pdfPath = `${jobId}.pdf`; // Blobのファイル名
    
    // PDFファイルが存在するかチェック
    try {
        const blobClient = containerClient.getBlobClient(pdfPath);
        const exists = await blobClient.exists();
        if (!exists) {
            context.res = { status: 404, body: false }; 
            return;
        }

        // PDFファイルのダウンロード
        const downloadBlockBlobResponse = await blobClient.download(0);
        var resbody = new PassThrough();
        downloadBlockBlobResponse.readableStreamBody.pipe(resbody)
        .on('finish', async () => {
            try {
                // ストリーミングが完了したらBlobを削除
                await blobClient.delete();

                // HTMLファイルも削除
                const htmlPath = `${jobId}.html`;
                await containerClient.getBlockBlobClient(htmlPath).delete();
                
            } catch (deleteError) {
                // 削除エラーは無視し、レスポンスは完了する
                console.error('Blob deletion error:', deleteError);
            }
        });
        context.res = {
            status: 200,
            headers: { 'Content-Type': 'application/pdf' },
            body: resbody
        };
    } catch (err) {
        console.error(err);
        context.res = { status: 500, body: false };
        return;
    }
};

```

最後に、作成したプロジェクトをコンテナ化して、別途用意したAzure Container Registryにアップロードし、アップロードしたコンテナを利用するAzure Functionsを作成して終了です。

```bash
# コンテナのビルドとAzure Container Registryへのアップロード
# registry-nameは作成したAzure Container Registryのリソース名
docker build -t <registry-name>.azurecr.io/pdf-api:latest
docker push <registry-name>.azurecr.io/pdf-api:latest
```

# Workerの作成
Azure Container Appsへデプロイするため、コンテナ環境で実行できるようにアプリ開発を行います。

Dockerfile
```Dockerfile
# Workerコンテナ用のDockerfile
FROM node:20

# Puppeteerを使用するために必要なライブラリをインストール
RUN apt-get update && apt-get install -y \
    wget \
    gnupg \
    curl \
    fonts-liberation \
    libappindicator3-1 \
    libasound2 \
    libatk-bridge2.0-0 \
    libatk1.0-0 \
    libgbm-dev \
    libglib2.0-0 \
    libnss3 \
    libx11-xcb1 \
    libxcomposite1 \
    libxdamage1 \
    libxrandr2 \
    libxss1 \
    libxtst6 \
    x11-utils \
    --no-install-recommends \
    locales \
    fonts-ipafont \
    fonts-ipaexfont \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && echo "ja_JP UTF-8" > /etc/locale.gen && locale-gen

WORKDIR /app
COPY package.json ./
RUN npm install

COPY worker.js ./
CMD ["node", "worker.js"]
```

package.json
```json
"dependencies": {
    "puppeteer": "{バージョン}",
    "@azure/storage-blob": "{バージョン}",
    "@azure/service-bus": "{バージョン}"
}
```

worker.js
```javascript
const { ServiceBusClient } = require('@azure/service-bus');
const { BlobServiceClient } = require('@azure/storage-blob');
const puppeteer = require('puppeteer');

// Service Busの接続文字列とキュー名
const SERVICE_BUS_CONNECTION_STRING = process.env.SERVICE_BUS_CONNECTION_STRING;
const QUEUE_NAME = process.env.QUEUE_NAME; 

// Blob Storageの接続文字列とコンテナ名
const blobServiceClient = BlobServiceClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING);
const CONTAINER_NAME = process.env.CONTAINER_NAME;

// HTMLからPDFを作成する関数
async function createPdfFromHtml(htmlContent) {
    const browser = await puppeteer.launch({
        args: ['--no-sandbox','--disable-setuid-sandbox'],
      });
    const page = await browser.newPage();

    await page.setContent(htmlContent);
    const pdfBuffer = await page.pdf({ 
        format: 'A4', 
        scale: 0.95
    });

    await browser.close();
    return pdfBuffer;
}

// PDFをAzure Blobにアップロードする関数
async function uploadPdfToBlob(containerClient, pdfBuffer, messageId) {
    const blobName = `${messageId}.pdf`;
    const blockBlobClient = containerClient.getBlockBlobClient(blobName);
    await blockBlobClient.upload(pdfBuffer, pdfBuffer.length, {
        blobHTTPHeaders: { blobContentType: 'application/pdf' },
    });
    console.log(`PDFファイルをBlobストレージにアップロードしました: ${blobName}`);
}

// BlobからHTMLコンテンツを取得する関数
async function fetchHtmlFromBlob(htmlBlobUrl) {
    const response = await fetch(htmlBlobUrl);
    if (!response.ok) {
        throw new Error('BlobストレージからのHTMLファイルの取得に失敗しました：' + response.statusText);
    }
    return await response.text();
}

// メッセージを処理する関数
async function processMessage(message) {
    try {
        const jobData = JSON.parse(message.body);
        const jobId = jobData.jobId;
        const htmlBlobUrl = jobData.htmlBlobUrl;
        console.log(`処理中のメッセージ：${message.messageId}, jobId: ${jobId}`);
        
        // HTMLをBlobから取得
        const htmlContent = await fetchHtmlFromBlob(htmlBlobUrl);
        const pdfBuffer = await createPdfFromHtml(htmlContent);

        // Azure Blob StorageにPDFをアップロード
        const containerClient = blobServiceClient.getContainerClient(CONTAINER_NAME);
        await uploadPdfToBlob(containerClient, pdfBuffer, jobId);

        console.log(`処理完了メッセージ：${message.messageId}, jobId: ${jobId}`);
        
    } catch (error) {
        console.error(':', error);
        // エラーハンドリングのロジックを追加することをお勧めします
    }
}

// メイン関数
async function main() {
    const serviceBusClient = new ServiceBusClient(SERVICE_BUS_CONNECTION_STRING);
    const receiver = serviceBusClient.createReceiver(QUEUE_NAME);

    // メッセージ受信の場合のイベントリスナー
    const myMessageHandler = async (messageReceived) => {
        console.log(`メッセージを受信しました： ${messageReceived.body}`);
        await processMessage(messageReceived);
        await receiver.completeMessage(messageReceived);
    };

    // メッセージ受信エラー時のイベント
    const myErrorHandler = async (error) => {
        console.error('メッセージの受信中にエラーが発生しました：', args.error);
    };

    // メッセージの受信待ち
    receiver.subscribe({
        processMessage: myMessageHandler,
        processError: myErrorHandler
    });

    console.log('メッセージ受信を待機中... (Press Ctrl+C to exit)');
}

// プログラムを開始
main().catch((error) => {
    console.error('予期せぬエラーが発生しました：', error);
});
```

APIと同様にコンテナのビルドとAzure Container Registryにアップロードしアップロードしたコンテナを利用するAzure Container Appsを作成して終了です。

なお、今回作成したAzure Container AppsはService Busのメッセージをトリガーに動く仕組みのため、外部との通信は行わない設定にしています。

# 動作確認
作成したAzure Functionsに対してHTTPリクエストを送信することで、各種操作を行うことができます。

```bash
# PDF変換API
curl --location 'https://<Azure functionsのドメイン>/generate' \
--header 'Content-Type: application/json' \
--data '{
    "html": "<h1>こんにちは、世界！</h1><p>PDF変換テスト</p>"
}'

# PDFチェックAPI
curl --location 'https://<Azure functionsのドメイン>/job/<ジョブID>'

# PDF取得API
curl --location 'https://<Azure functionsのドメイン>/get/<ジョブID>'
```


# さいごに

かれこれ数年、Azureを使用したWebアプリの開発・運用を行っていますが、一つの仕組みに複数のサービスを利用した経験はそこまでありませんでした。
今回4種類ものサービスを利用して、一つの仕組みを作ってみましたが、必要な部分のみを作るだけでいいので、クラウドは便利だなと改めて感じました。

また、今回の開発では流行りの生成AIを使用して、開発を行いました。
コードアシスタントは使っていませんが、AIを使うだけで開発の生産性が全然違うなと実感しました。
今後はコードアシスタントのようなAIを用いたシステム開発のデフォルトになっていくんだろうなと、技術の進歩を感じる経験でもありました。