8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-24

はじめに

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

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

# プロジェクトの作成
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

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

index.js

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を作成して終了です。

# コンテナのビルドと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

# 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

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

worker.js

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リクエストを送信することで、各種操作を行うことができます。

# 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を用いたシステム開発のデフォルトになっていくんだろうなと、技術の進歩を感じる経験でもありました。

8
0
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
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?