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