NaN/NaN/NaN

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転送

最初から全てを実装する必要はありません。まずは同期生成 + 基本的なテナント分離から始め、ボリュームが増えるにつれてキュー・キャッシュ・ホワイトラベルを追加していくアプローチが現実的です。

各機能の詳細は以下の記事で深掘りしています。

まずはプレイグラウンドでAPIの動作を確認し、ドキュメントでエンドポイントの仕様を把握してください。ユースケース一覧では請求書・レポート・契約書など実際のSaaS活用事例を紹介しています。

Powered by FUNBREW PDF