SaaSプロダクトを構築していると、かならずと言っていいほどPDF出力の要件が出てきます。「請求書をPDFでダウンロードしたい」「月次レポートを自動送付したい」「契約書に電子署名して保存したい」——これらはいずれも、自前でPDFエンジンを抱えるには割に合わない機能です。
この記事では、SaaSプロダクトにPDF生成APIを組み込む際のアーキテクチャ設計を体系的に解説します。単純なAPI呼び出し方法にとどまらず、マルチテナント対応・テンプレート管理・Webhook連携・レート制限設計・ホワイトラベル・コスト最適化まで、本番運用を見据えた実装パターンを扱います。
FUNBREW PDFのAPIを例に説明しますが、考え方はどのPDF APIにも応用できます。基本的なAPI呼び出し方法は言語別クイックスタートを、本番環境での安定運用についてはPDF API本番運用チェックリストを先に確認してください。
SaaSでPDF機能が求められるシーン
SaaSプロダクトにおけるPDF出力のユースケースは大きく5つに分類できます。
| ユースケース | 例 | 典型的なボリューム | 優先パターン |
|---|---|---|---|
| 請求書・領収書 | 月次課金時の自動発行 | 月数百〜数万件 | 非同期バッチ |
| 月次・週次レポート | ダッシュボードのPDF書き出し | オンデマンド | 同期 |
| 契約書・同意書 | 新規契約時の自動作成 | オンデマンド | 同期 |
| 修了証・認定書 | 研修完了時の自動発行 | イベント駆動 | 非同期 |
| 帳票・伝票 | 発注書・納品書の出力 | 月次バッチ | 非同期バッチ |
それぞれ要求されるレスポンスタイム・ボリューム・テナント分離レベルが異なります。「どのシーンか」を明確にしてからアーキテクチャを選ぶことが重要です。
アーキテクチャパターン:同期・非同期・バッチ
パターン1: 同期処理(ユーザーが今すぐPDFを欲しい)
ユーザーが「ダウンロード」ボタンを押した瞬間にPDFを返す最もシンプルなパターンです。
ブラウザ → アプリサーバー → PDF API → アプリサーバー → ブラウザ(PDF返却)
// SaaSバックエンド: 同期PDF生成エンドポイント
import express from 'express';
const router = express.Router();
router.post('/invoices/:id/download', async (req, res) => {
const invoice = await Invoice.findByTenantAndId(
req.tenant.id, // マルチテナント: テナントIDでスコープ
req.params.id
);
if (!invoice) {
return res.status(404).json({ error: 'Invoice not found' });
}
const html = await renderInvoiceTemplate(invoice, req.tenant);
const pdfResponse = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: {
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
},
}),
signal: AbortSignal.timeout(30_000), // 30秒タイムアウト
});
if (!pdfResponse.ok) {
throw new Error(`PDF generation failed: HTTP ${pdfResponse.status}`);
}
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`);
res.send(pdfBuffer);
});
使い分けの目安: レスポンスタイムが20秒以内に収まる規模(HTMLが軽量、テナントごとの同時リクエストが少ない)なら同期でOKです。それを超える場合は次のパターンへ。
パターン2: 非同期処理(ジョブキュー)
重いPDF(複数ページ・複雑なグラフ)や大量生成にはジョブキューを使います。ユーザーにはすぐ「生成中」を伝え、完了したらWebhookやメールで通知します。
ブラウザ → アプリサーバー(ジョブID返却)
↓(キューにエンキュー)
ワーカー → PDF API → S3保存
↓
Webhook → アプリサーバー → ブラウザに通知
// BullMQ を使ったジョブキュー実装
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const connection = new Redis(process.env.REDIS_URL);
const pdfQueue = new Queue('saas-pdf', { connection });
const s3 = new S3Client({ region: 'ap-northeast-1' });
// エンドポイント: ジョブをエンキューしてジョブIDを即返す
export async function enqueuePdfJob(tenantId, jobType, payload) {
const job = await pdfQueue.add(jobType, {
tenantId,
...payload,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
// テナントごとに優先度を設定(有料プランは高優先度)
priority: payload.priority ?? 0,
});
return { jobId: job.id, status: 'queued' };
}
// ワーカー: バックグラウンドでPDFを生成してS3に保存
const worker = new Worker('saas-pdf', async (job) => {
const { tenantId, invoiceId } = job.data;
const tenant = await Tenant.findById(tenantId);
const invoice = await Invoice.findById(invoiceId);
const html = await renderInvoiceTemplate(invoice, tenant);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options: { format: 'A4' } }),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error(`PDF API error: ${response.status}`);
const pdfBuffer = Buffer.from(await response.arrayBuffer());
// S3にアップロード(テナントIDでパスを分離)
const key = `tenants/${tenantId}/invoices/${invoiceId}.pdf`;
await s3.send(new PutObjectCommand({
Bucket: process.env.PDF_STORAGE_BUCKET,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
ServerSideEncryption: 'AES256',
}));
// ジョブ完了を記録
await PdfJob.update(job.id, {
status: 'completed',
s3Key: key,
completedAt: new Date(),
});
return { s3Key: key };
}, { connection, concurrency: 5 });
worker.on('failed', (job, err) => {
console.error(`PDF job ${job?.id} failed:`, err.message);
// Slackアラートなどを送信
});
パターン3: バッチ処理(月次一括生成)
月末に全テナントの請求書をまとめて生成するパターンです。PDF一括生成ガイドで詳しく解説していますが、SaaSでの実装には追加の考慮が必要です。
# Python: 月次請求書バッチ生成
import asyncio
import aiohttp
import os
from typing import List
async def generate_monthly_invoices(tenants: List[dict], year: int, month: int):
"""全テナントの月次請求書を並行生成する"""
semaphore = asyncio.Semaphore(10) # 同時API呼び出し数を制限
async def generate_for_tenant(session, tenant):
async with semaphore:
invoices = await get_monthly_invoices(tenant['id'], year, month)
if not invoices:
return {'tenantId': tenant['id'], 'count': 0}
items = [
{
'html': await render_invoice_html(invoice, tenant),
'filename': f"invoice-{invoice['number']}.pdf",
'options': {'format': 'A4'},
}
for invoice in invoices
]
async with session.post(
'https://pdf.funbrew.cloud/api/v1/pdf/batch',
headers={'X-API-Key': os.environ['FUNBREW_PDF_API_KEY']},
json={'items': items},
timeout=aiohttp.ClientTimeout(total=300),
) as resp:
resp.raise_for_status()
result = await resp.json()
# S3に保存 + DBに記録
for item_result in result['data']['results']:
await store_pdf_result(tenant['id'], item_result)
return {'tenantId': tenant['id'], 'count': len(items)}
async with aiohttp.ClientSession() as session:
tasks = [generate_for_tenant(session, tenant) for tenant in tenants]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 失敗したテナントを記録
failures = [r for r in results if isinstance(r, Exception)]
if failures:
await notify_batch_failures(failures)
return results
マルチテナント対応:テナントごとのテンプレート管理
SaaSの核心はテナント分離です。PDF機能においても、テナントごとに異なるテンプレート・ブランド・設定を管理する仕組みが必要です。
テンプレートの管理構造
-- テナントごとのPDFテンプレート管理テーブル
CREATE TABLE pdf_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
slug VARCHAR(100) NOT NULL, -- 'invoice', 'report', 'contract'
name VARCHAR(255) NOT NULL,
html TEXT NOT NULL, -- Handlebarsテンプレート
css TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- デフォルトテンプレート(テナント未設定時のフォールバック)
CREATE TABLE default_pdf_templates (
slug VARCHAR(100) PRIMARY KEY,
html TEXT NOT NULL,
css TEXT
);
テンプレートのフォールバック解決
// テナントのテンプレートを取得し、なければデフォルトにフォールバック
async function resolveTemplate(tenantId: string, slug: string): Promise<Template> {
// まずテナント固有テンプレートを検索
const tenantTemplate = await db.query<Template>(
'SELECT * FROM pdf_templates WHERE tenant_id = $1 AND slug = $2 AND is_active = true',
[tenantId, slug]
);
if (tenantTemplate.rows.length > 0) {
return tenantTemplate.rows[0];
}
// フォールバック: デフォルトテンプレート
const defaultTemplate = await db.query<Template>(
'SELECT * FROM default_pdf_templates WHERE slug = $1',
[slug]
);
if (defaultTemplate.rows.length === 0) {
throw new Error(`Template not found: ${slug}`);
}
return defaultTemplate.rows[0];
}
// テンプレートにテナントデータを流し込んでHTMLを生成
async function renderTemplate(
tenantId: string,
slug: string,
variables: Record<string, unknown>
): Promise<string> {
const template = await resolveTemplate(tenantId, slug);
const tenant = await Tenant.findById(tenantId);
// テナントのブランド情報を自動注入
const enrichedVars = {
...variables,
brand: {
name: tenant.companyName,
logo_url: tenant.logoUrl,
primary_color: tenant.brandColor ?? '#2563EB',
address: tenant.address,
},
};
return Handlebars.compile(template.html)(enrichedVars);
}
テナント別テンプレートのAPI
テナントが自分のテンプレートをカスタマイズできるAPIエンドポイントを提供します。
// テンプレート保存エンドポイント
router.put('/templates/:slug', requireTenantAdmin, async (req, res) => {
const { html, css, name } = req.body;
// HTMLをサニタイズ(XSS対策)
const sanitizedHtml = sanitizeTemplateHtml(html);
// テンプレートのプレビューを生成してバリデーション
try {
await generatePreviewPdf(sanitizedHtml, css);
} catch (err) {
return res.status(422).json({
error: 'Template rendering failed',
details: err.message,
});
}
await db.query(
`INSERT INTO pdf_templates (tenant_id, slug, name, html, css)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, slug) DO UPDATE
SET name = EXCLUDED.name,
html = EXCLUDED.html,
css = EXCLUDED.css,
updated_at = NOW()`,
[req.tenant.id, req.params.slug, name, sanitizedHtml, css]
);
res.json({ success: true });
});
テンプレートエンジンとの連携
HTMLテンプレートへのデータ流し込みは、シンプルな文字列置換ではなくテンプレートエンジンを使うことで、ループ・条件分岐・フォーマット処理を実装できます。
Handlebars による請求書テンプレート
<!-- templates/invoice.hbs -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Noto Sans JP', sans-serif; margin: 0; }
.header { display: flex; justify-content: space-between; padding: 40px; }
.logo { height: 48px; }
.invoice-meta { text-align: right; color: #6B7280; font-size: 14px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #E5E7EB; }
th { background: #F9FAFB; font-weight: 600; }
.total-row { font-size: 18px; font-weight: 700; }
{{#if brand.primary_color}}
.accent { color: {{brand.primary_color}}; }
.header-bar { background: {{brand.primary_color}}; height: 4px; }
{{/if}}
</style>
</head>
<body>
<div class="header-bar"></div>
<div class="header">
{{#if brand.logo_url}}
<img src="{{brand.logo_url}}" alt="{{brand.name}}" class="logo">
{{else}}
<h1 class="accent">{{brand.name}}</h1>
{{/if}}
<div class="invoice-meta">
<p><strong>請求書番号:</strong> {{invoice.number}}</p>
<p><strong>発行日:</strong> {{formatDate invoice.issued_at}}</p>
<p><strong>支払期限:</strong> {{formatDate invoice.due_at}}</p>
</div>
</div>
<div style="padding: 0 40px;">
<p><strong>{{customer.name}} 御中</strong></p>
<p style="color: #6B7280; font-size: 14px;">{{customer.address}}</p>
</div>
<div style="padding: 24px 40px;">
<table>
<thead>
<tr>
<th>品目</th>
<th style="text-align: right;">数量</th>
<th style="text-align: right;">単価</th>
<th style="text-align: right;">小計</th>
</tr>
</thead>
<tbody>
{{#each invoice.line_items}}
<tr>
<td>{{this.description}}</td>
<td style="text-align: right;">{{this.quantity}}</td>
<td style="text-align: right;">¥{{formatNumber this.unit_price}}</td>
<td style="text-align: right;">¥{{formatNumber this.amount}}</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr><td colspan="3" style="text-align: right;">小計</td><td style="text-align: right;">¥{{formatNumber invoice.subtotal}}</td></tr>
<tr><td colspan="3" style="text-align: right;">消費税({{invoice.tax_rate}}%)</td><td style="text-align: right;">¥{{formatNumber invoice.tax_amount}}</td></tr>
<tr class="total-row">
<td colspan="3" style="text-align: right;" class="accent">合計</td>
<td style="text-align: right;" class="accent">¥{{formatNumber invoice.total_amount}}</td>
</tr>
</tfoot>
</table>
</div>
{{#if invoice.notes}}
<div style="padding: 24px 40px; color: #6B7280; font-size: 13px;">
<p><strong>備考:</strong> {{invoice.notes}}</p>
</div>
{{/if}}
<div style="padding: 24px 40px; border-top: 1px solid #E5E7EB; font-size: 12px; color: #9CA3AF;">
<p>{{brand.name}} {{brand.address}}</p>
</div>
</body>
</html>
// カスタムHelperの登録
const Handlebars = require('handlebars');
Handlebars.registerHelper('formatDate', (date) => {
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric',
}).format(new Date(date));
});
Handlebars.registerHelper('formatNumber', (num) => {
return new Intl.NumberFormat('ja-JP').format(num);
});
テンプレートエンジンを使った変数・ループ・条件分岐の詳細はPDFテンプレートエンジン入門で解説しています。
Webhook通知の活用
非同期でPDFを生成した後、完了をどのように通知するか。SaaSでは以下の2つのパターンがよく使われます。
パターンA: 内部Webhook(ワーカー→アプリサーバー)
// ワーカーがPDF生成完了後にアプリサーバーに通知
async function notifyPdfCompleted(jobId: string, result: PdfResult) {
await fetch(`${process.env.APP_INTERNAL_URL}/webhooks/pdf-completed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Secret': process.env.INTERNAL_WEBHOOK_SECRET,
},
body: JSON.stringify({
jobId,
tenantId: result.tenantId,
s3Key: result.s3Key,
downloadUrl: result.downloadUrl,
completedAt: new Date().toISOString(),
}),
});
}
// アプリサーバー側でWebhookを受信
router.post('/webhooks/pdf-completed', verifyInternalSecret, async (req, res) => {
const { jobId, tenantId, s3Key } = req.body;
// DBに完了ステータスを記録
await PdfJob.markCompleted(jobId, s3Key);
// テナントのユーザーに通知(メール or プッシュ通知)
const job = await PdfJob.findById(jobId);
await notifyUser(job.userId, {
type: 'pdf_ready',
downloadUrl: generateSignedUrl(s3Key, { expiresIn: '24h' }),
});
res.json({ ok: true });
});
パターンB: テナントへのWebhook転送
テナントが自分のシステムへのWebhookを設定できる機能を提供します。
// テナントのWebhook設定
interface TenantWebhookConfig {
tenantId: string;
url: string;
secret: string; // HMACで署名検証
events: ('pdf.completed' | 'pdf.failed' | 'batch.completed')[];
isActive: boolean;
}
// Webhookを安全に転送する
async function forwardWebhookToTenant(
config: TenantWebhookConfig,
event: string,
payload: object
) {
const body = JSON.stringify({ event, data: payload, timestamp: Date.now() });
const signature = createHmacSignature(body, config.secret);
try {
const response = await fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Event': event,
},
body,
signal: AbortSignal.timeout(10_000), // 10秒タイムアウト
});
await WebhookDelivery.record({
tenantId: config.tenantId,
event,
statusCode: response.status,
success: response.ok,
});
} catch (err) {
await WebhookDelivery.recordFailure(config.tenantId, event, err.message);
// 指数バックオフで再試行
await scheduleWebhookRetry(config, event, payload);
}
}
function createHmacSignature(body: string, secret: string): string {
return require('crypto')
.createHmac('sha256', secret)
.update(body)
.digest('hex');
}
Webhookの詳細な実装パターンはPDF API Webhook連携ガイドで解説しています。
レート制限とキューイング設計
SaaSでは複数テナントが同時にPDFを生成します。外部APIのレート制限に当たらないよう、アプリケーション側でキューイング設計が必要です。
テナントごとの公平なリソース配分
// テナントプランに応じた並行数制限
const PLAN_CONCURRENCY = {
free: 1,
starter: 3,
professional: 10,
enterprise: 30,
} as const;
// テナントごとのBullMQレート制限
async function enqueueWithTenantLimit(
tenantId: string,
plan: keyof typeof PLAN_CONCURRENCY,
jobData: object
) {
const queue = new Queue('pdf-generation', { connection });
await queue.add('generate', jobData, {
// テナントIDをグループとして、同一テナントの並行数を制限
group: {
id: tenantId,
limit: {
max: PLAN_CONCURRENCY[plan],
duration: 60_000, // 1分間のウィンドウ
},
},
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
グローバルレート制限の実装
import redis
import time
class GlobalRateLimiter:
"""Redis を使ったスライディングウィンドウ方式のレート制限"""
def __init__(self, redis_client, max_requests: int, window_seconds: int):
self.redis = redis_client
self.max_requests = max_requests
self.window = window_seconds
def acquire(self, timeout_seconds: float = 5.0) -> bool:
"""リクエスト枠を取得する。取得できない場合はFalseを返す"""
key = 'global:pdf_api:rate_limit'
deadline = time.time() + timeout_seconds
while time.time() < deadline:
now_ms = int(time.time() * 1000)
window_start = now_ms - self.window * 1000
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, '-inf', window_start) # 古いエントリ削除
pipe.zadd(key, {str(now_ms): now_ms}) # 現在のリクエストを追加
pipe.zcard(key) # 現在のカウント
pipe.expire(key, self.window + 1)
_, _, count, _ = pipe.execute()
if count <= self.max_requests:
return True
# 待機して再試行
time.sleep(0.1)
return False # タイムアウト
# 使用例: 1分間に100リクエストまで
limiter = GlobalRateLimiter(
redis_client=redis.Redis.from_url(os.environ['REDIS_URL']),
max_requests=80, # APIの上限100の80%で運用
window_seconds=60,
)
async def generate_pdf_with_rate_limit(html: str, tenant_id: str):
if not limiter.acquire(timeout_seconds=10):
raise TooManyRequestsError('PDF generation rate limit exceeded')
# PDF API呼び出し
return await call_pdf_api(html)
キュー深さの監視
// キューの健全性をモニタリング
async function getPdfQueueHealth() {
const queue = new Queue('pdf-generation', { connection });
const [waiting, active, failed, delayed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
]);
const health = {
waiting,
active,
failed,
delayed,
isHealthy: waiting < 500 && failed < 50,
};
// Datadogなどのメトリクスに送信
await sendMetrics('pdf_queue', health);
return health;
}
ホワイトラベル対応:テナントブランドの動的差し替え
エンタープライズ向けSaaSではPDFのブランドをテナントのものに置き換える「ホワイトラベル」が求められます。
ブランド設定のデータ構造
interface TenantBrandConfig {
tenantId: string;
companyName: string;
logoUrl: string | null;
primaryColor: string; // '#2563EB' 形式
secondaryColor: string;
fontFamily: string; // 'Noto Sans JP', 'Noto Serif JP' など
footerText: string; // 「〒100-0001 東京都...」
watermark: {
enabled: boolean;
text: string; // 'CONFIDENTIAL' など
opacity: number; // 0.1〜0.3
};
}
CSS変数を使ったダイナミックなブランド適用
// テナントのブランド設定をCSSに変換
function generateBrandCss(brand: TenantBrandConfig): string {
return `
:root {
--color-primary: ${brand.primaryColor};
--color-secondary: ${brand.secondaryColor};
--font-family: '${brand.fontFamily}', sans-serif;
}
body {
font-family: var(--font-family);
}
.accent, .total-label {
color: var(--color-primary);
}
.header-bar {
background: var(--color-primary);
height: 4px;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
${brand.watermark.enabled ? `
body::after {
content: '${brand.watermark.text}';
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 72px;
font-weight: 900;
opacity: ${brand.watermark.opacity};
color: var(--color-primary);
pointer-events: none;
z-index: 1000;
}` : ''}
`;
}
// HTML生成時にブランドCSSを注入
async function renderBrandedPdf(
templateSlug: string,
variables: object,
tenantId: string
): Promise<Buffer> {
const tenant = await Tenant.findById(tenantId);
const brand = tenant.brandConfig;
const template = await resolveTemplate(tenantId, templateSlug);
const brandCss = generateBrandCss(brand);
const html = Handlebars.compile(template.html)({
...variables,
brand,
__brandCss: brandCss, // テンプレート内で <style>{{{__brandCss}}}</style> として注入
});
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: { format: 'A4' },
}),
});
return Buffer.from(await response.arrayBuffer());
}
ロゴ画像の動的埋め込み
外部URLのロゴはPDF生成時にアクセスできない場合があります。Base64に変換してHTMLに埋め込む方が安全です。
import fetch from 'node-fetch';
async function embedLogoAsBase64(logoUrl: string): Promise<string> {
if (!logoUrl) return '';
try {
const response = await fetch(logoUrl);
const buffer = await response.buffer();
const contentType = response.headers.get('content-type') ?? 'image/png';
const base64 = buffer.toString('base64');
return `data:${contentType};base64,${base64}`;
} catch {
return ''; // ロゴ取得失敗時は非表示
}
}
// HTMLテンプレートに渡す前に変換
const logoDataUrl = await embedLogoAsBase64(tenant.logoUrl);
const html = template.replace('{{brand.logo_url}}', logoDataUrl);
コスト最適化:キャッシュ・バッチ処理・差分チェック
SaaSでは複数テナントが継続的にPDFを生成するため、コスト管理が重要です。
戦略1: コンテンツハッシュによるキャッシュ
同じHTMLから同じPDFが生成される場合(約款・規約など)はキャッシュで重複生成を排除できます。
import crypto from 'crypto';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
class PdfCacheService {
constructor(
private readonly s3: S3Client,
private readonly bucket: string,
private readonly redis: RedisClient
) {}
private getCacheKey(html: string, options: object): string {
const content = html + JSON.stringify(options);
return crypto.createHash('sha256').update(content).digest('hex');
}
async getOrGenerate(
html: string,
options: object = {},
ttlSeconds = 86400 // デフォルト24時間
): Promise<Buffer> {
const hash = this.getCacheKey(html, options);
const redisKey = `pdf:cache:${hash}`;
const s3Key = `pdf-cache/${hash}.pdf`;
// L1キャッシュ: Redisでメタデータを確認
const cached = await this.redis.get(redisKey);
if (cached) {
// L2キャッシュ: S3からPDFを取得
const s3Response = await this.s3.send(new GetObjectCommand({
Bucket: this.bucket,
Key: s3Key,
}));
return streamToBuffer(s3Response.Body);
}
// キャッシュミス → PDF生成
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options }),
});
const pdfBuffer = Buffer.from(await response.arrayBuffer());
// キャッシュに保存
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}));
await this.redis.setex(redisKey, ttlSeconds, '1');
return pdfBuffer;
}
}
戦略2: 差分チェックで不要な再生成を防ぐ
// データが変更されていなければ既存PDFを再利用
interface PdfRecord {
id: string;
entityType: string; // 'invoice', 'report'
entityId: string;
tenantId: string;
dataHash: string; // 生成時のデータのハッシュ
s3Key: string;
generatedAt: Date;
}
async function generateIfChanged(
tenantId: string,
entityType: string,
entityId: string,
data: object
): Promise<{ pdfBuffer: Buffer; regenerated: boolean }> {
const dataHash = crypto
.createHash('sha256')
.update(JSON.stringify(data))
.digest('hex');
const existing = await PdfRecord.findOne({ tenantId, entityType, entityId });
if (existing && existing.dataHash === dataHash) {
// データ変更なし → キャッシュを返す
const pdfBuffer = await downloadFromS3(existing.s3Key);
return { pdfBuffer, regenerated: false };
}
// データが変更されたので再生成
const html = await renderTemplate(tenantId, entityType, data);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options: { format: 'A4' } }),
});
const pdfBuffer = Buffer.from(await response.arrayBuffer());
const s3Key = `tenants/${tenantId}/${entityType}/${entityId}.pdf`;
await uploadToS3(s3Key, pdfBuffer);
await PdfRecord.upsert({ tenantId, entityType, entityId, dataHash, s3Key });
return { pdfBuffer, regenerated: true };
}
戦略3: テナントごとのコスト追跡
// PDF生成のコストをテナントごとにトラッキング
async function trackPdfGeneration(
tenantId: string,
generationType: 'sync' | 'async' | 'batch',
count = 1
) {
const month = new Date().toISOString().slice(0, 7); // 'YYYY-MM'
await db.query(
`INSERT INTO pdf_usage_metrics (tenant_id, month, generation_type, count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, month, generation_type)
DO UPDATE SET count = pdf_usage_metrics.count + EXCLUDED.count`,
[tenantId, month, generationType, count]
);
// プランの月次上限を超えていないかチェック
const totalThisMonth = await getTotalPdfCountForTenant(tenantId, month);
const planLimit = await getPlanPdfLimit(tenantId);
if (totalThisMonth > planLimit * 0.8) {
await notifyTenantApproachingLimit(tenantId, totalThisMonth, planLimit);
}
}
コスト削減効果の試算
| 最適化施策 | 月間1万件の場合 | 月間10万件の場合 |
|---|---|---|
| キャッシュ(30%ヒット率) | -3,000リクエスト | -30,000リクエスト |
| 差分チェック(20%不変) | -2,000リクエスト | -20,000リクエスト |
| バッチ処理(5件まとめ) | -8,000コール | -80,000コール |
| 合計(重複カウントなし) | 最大-50%削減 | 最大-50%削減 |
実装例:マルチテナントSaaSでの請求書PDF
ここまで解説してきた要素を組み合わせた、SaaS向け請求書PDF生成の全体実装例です。
システム構成
┌─────────────────────────────────────────┐
│ SaaSフロントエンド │
│ 「請求書をダウンロード」ボタン │
└───────────────────┬─────────────────────┘
│ POST /invoices/:id/pdf
┌───────────────────▼─────────────────────┐
│ SaaSバックエンド │
│ - テナント認証・認可 │
│ - テンプレート解決(テナント固有 or デフォルト)│
│ - ブランドCSS生成 │
│ - キャッシュチェック │
└─────┬─────────────────────┬─────────────┘
│ キャッシュミス時 │ キャッシュヒット時
│ │ → S3から直接返却
┌─────▼───────────────────── │ ────────────┐
│ FUNBREW PDF API │ │
│ POST /api/v1/pdf/generate │ │
└─────┬───────────────────── │ ────────────┘
│ │
┌─────▼───────────────────── │ ────────────┐
│ S3 (PDFストレージ) │ │
│ tenants/{id}/invoices/ ◄────────────┘
└─────────────────────────────────────────┘
統合実装コード(Node.js + TypeScript)
// services/invoice-pdf.service.ts
import crypto from 'crypto';
import { Tenant, Invoice } from '../models';
import { PdfCacheService } from './pdf-cache.service';
import { renderTemplate, generateBrandCss } from './template.service';
export class InvoicePdfService {
constructor(
private readonly cache: PdfCacheService
) {}
async generateForTenant(
tenantId: string,
invoiceId: string
): Promise<{ buffer: Buffer; filename: string }> {
// テナントと請求書を取得(権限チェック込み)
const [tenant, invoice] = await Promise.all([
Tenant.findById(tenantId),
Invoice.findByTenantAndId(tenantId, invoiceId),
]);
if (!invoice) throw new NotFoundError(`Invoice ${invoiceId} not found`);
// ブランドCSS生成
const brandCss = generateBrandCss(tenant.brandConfig);
// テンプレート解決とHTMLレンダリング
const html = await renderTemplate(tenantId, 'invoice', {
invoice,
customer: invoice.customer,
brand: tenant.brandConfig,
__brandCss: brandCss,
});
// キャッシュ付きでPDF生成
const pdfBuffer = await this.cache.getOrGenerate(
html,
{ format: 'A4' },
3600 // 1時間キャッシュ(請求書は確定後に変更される可能性あり)
);
// コスト追跡
await trackPdfGeneration(tenantId, 'sync');
return {
buffer: pdfBuffer,
filename: `invoice-${invoice.number}.pdf`,
};
}
async batchGenerateMonthly(
year: number,
month: number
): Promise<BatchResult> {
const tenants = await Tenant.findAllActive();
const results: { tenantId: string; count: number; failed: number }[] = [];
// テナントを並行処理(同時10テナントまで)
const semaphore = new Semaphore(10);
await Promise.allSettled(
tenants.map(async (tenant) => {
await semaphore.acquire();
try {
const invoices = await Invoice.findMonthly(tenant.id, year, month);
if (!invoices.length) return;
// バッチAPIで一括生成
const batchItems = await Promise.all(
invoices.map(async (invoice) => ({
html: await renderTemplate(tenant.id, 'invoice', {
invoice,
customer: invoice.customer,
brand: tenant.brandConfig,
__brandCss: generateBrandCss(tenant.brandConfig),
}),
filename: `invoice-${invoice.number}.pdf`,
options: { format: 'A4' },
}))
);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/batch', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items: batchItems }),
});
const { data } = await response.json();
// 結果をS3に保存
const failed = await this.storeBatchResults(tenant.id, data.results);
await trackPdfGeneration(tenant.id, 'batch', invoices.length);
results.push({
tenantId: tenant.id,
count: invoices.length,
failed,
});
} finally {
semaphore.release();
}
})
);
return { tenants: results };
}
}
APIエンドポイント(Express)
// routes/invoices.ts
router.get('/:id/pdf', requireAuth, async (req, res) => {
try {
const { buffer, filename } = await invoicePdfService.generateForTenant(
req.tenant.id,
req.params.id
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(buffer);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
console.error('PDF generation error:', err);
res.status(500).json({ error: 'PDF generation failed. Please try again.' });
}
});
// バッチ実行エンドポイント(内部・管理者のみ)
router.post('/batch/monthly', requireAdmin, async (req, res) => {
const { year, month } = req.body;
// 非同期で実行してジョブIDをすぐ返す
const jobId = await batchJobQueue.enqueue('monthly-invoices', { year, month });
res.json({ jobId, status: 'queued' });
});
まとめ:SaaS向けPDF統合の設計指針
SaaSにPDF機能を組み込む際の設計指針を整理します。
| 要件 | 推奨パターン |
|---|---|
| ユーザーが今すぐダウンロード | 同期生成(30秒以内) |
| 大量バッチ・月次処理 | 非同期キュー + Webhook通知 |
| テナントごとのデザイン | テンプレートテーブル + フォールバック |
| ブランドのカスタマイズ | CSS変数 + Base64ロゴ埋め込み |
| コスト削減 | コンテンツハッシュキャッシュ + 差分チェック |
| スロットリング | Redis スライディングウィンドウ |
| テナントへの通知 | HMAC署名付きWebhook転送 |
最初から全てを実装する必要はありません。まずは同期生成 + 基本的なテナント分離から始め、ボリュームが増えるにつれてキュー・キャッシュ・ホワイトラベルを追加していくアプローチが現実的です。
各機能の詳細は以下の記事で深掘りしています。
- テンプレート設計: PDFテンプレートエンジン入門
- Webhook連携: PDF API Webhook連携ガイド
- バッチ処理: PDF一括生成ガイド
- 本番運用: PDF API本番運用チェックリスト
- セキュリティ: PDF APIのセキュリティ対策ガイド
まずはプレイグラウンドでAPIの動作を確認し、ドキュメントでエンドポイントの仕様を把握してください。ユースケース一覧では請求書・レポート・契約書など実際のSaaS活用事例を紹介しています。