NaN/NaN/NaN

JavaでPDFを生成するとき、iText・Apache PDFBox・JasperReportsなどのライブラリが候補に挙がります。しかしiText 8はAGPLv3ライセンスのため商用プロダクトへの組み込みには有償ライセンス(数十万円〜)が必要です。PDFBoxは自由に使えますが、複雑なHTMLレイアウトの再現が難しく、日本語フォントの埋め込みも手間がかかります。

FUNBREW PDF APIを使えば、HTTPリクエストでHTMLを送るだけで高品質なPDFを生成できます。ライセンスコストゼロ、サーバーリソース節約、日本語フォント対応済み――Spring Bootプロジェクトへの統合も数十行で完了します。

この記事では、Spring Bootアプリケーションへの統合方法を、RestTemplateとWebClient両方のアプローチで解説します。エラーハンドリング、非同期処理、本番運用Tipsまでカバーします。他のフレームワーク・言語での統合は言語別クイックスタートを、エラーハンドリングの詳細はエラーハンドリングガイドを参照してください。

なぜAPI方式か

iText 8のライセンス問題

iText 8はコアライブラリがAGPLv3で公開されています。AGPLv3は「ネットワーク越しにサービスを提供する場合、ソースコードを公開しなければならない」というコピーレフト条件があります。商用プロダクトでソースコードを非公開にしたい場合は、iText社から有償のCommercial Licenseを購入する必要があります。

ライブラリ ライセンス 商用利用の制約
iText 8 AGPLv3 / Commercial 有償ライセンス必要(商用プロダクト)
Apache PDFBox Apache License 2.0 制約なし(HTML再現が困難)
JasperReports LGPL / Commercial テンプレートベース、HTML変換は別途必要
FUNBREW PDF API SaaS(月額課金) ライセンス問題なし、HTML直接対応

サーバーリソースの節約

iTextやPDFBoxを使ったPDF生成はサーバー上で実行されるため、複雑なドキュメントほどCPU・メモリを消費します。特にJasperReportsは大量レポート生成時にヒープが圧迫されやすく、GCチューニングが必要になるケースも多いです。

API方式では、PDF生成処理は外部サービスが担います。アプリケーションサーバーはHTTPリクエストを送るだけなので、インスタンスサイズを抑えられます。Kubernetes環境ではPDFワークロードのスケールアウトも不要です。

日本語フォント対応

iText・PDFBoxで日本語PDFを生成する場合、Noto Sans JPなどのフォントをプロジェクトに同梱し、埋め込みの設定を行う必要があります。FUNBREW PDFはNoto Sans JPがプリインストール済みなので、font-family: 'Noto Sans JP'をCSSに書くだけで日本語が正しくレンダリングされます。

Spring Bootプロジェクトのセットアップ

依存関係(pom.xml)

Spring Boot 3.x(Spring Framework 6.x)を前提とします。WebClientを使う場合はspring-boot-starter-webfluxが必要です。

<dependencies>
  <!-- Web(RestTemplate用) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- WebFlux(WebClient用) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>

  <!-- 設定プロパティ -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
  </dependency>

  <!-- テスト -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>mockwebserver</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Gradleを使う場合は以下の通りです。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'com.squareup.okhttp3:mockwebserver'
}

application.properties(または application.yml)

APIキーは環境変数から読み込みます。ソースコードにハードコーディングしてはいけません。

# application.properties
funbrew.pdf.api-key=${FUNBREW_PDF_API_KEY}
funbrew.pdf.api-url=https://api.pdf.funbrew.cloud/v1/pdf/from-html
funbrew.pdf.timeout-seconds=60
# application.yml(YAMLが好みの場合)
funbrew:
  pdf:
    api-key: ${FUNBREW_PDF_API_KEY}
    api-url: https://api.pdf.funbrew.cloud/v1/pdf/from-html
    timeout-seconds: 60

設定プロパティクラス

@ConfigurationPropertiesでプロパティを型安全に注入します。

// src/main/java/com/example/pdf/config/FunbrewPdfProperties.java
package com.example.pdf.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "funbrew.pdf")
public class FunbrewPdfProperties {

    private String apiKey;
    private String apiUrl = "https://api.pdf.funbrew.cloud/v1/pdf/from-html";
    private int timeoutSeconds = 60;

    // Getters & Setters
    public String getApiKey() { return apiKey; }
    public void setApiKey(String apiKey) { this.apiKey = apiKey; }

    public String getApiUrl() { return apiUrl; }
    public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; }

    public int getTimeoutSeconds() { return timeoutSeconds; }
    public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; }
}

メインクラスに@ConfigurationPropertiesScanを追加するか、@EnableConfigurationPropertiesを設定することでプロパティが自動的にバインドされます。

// src/main/java/com/example/pdf/PdfApplication.java
package com.example.pdf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class PdfApplication {
    public static void main(String[] args) {
        SpringApplication.run(PdfApplication.class, args);
    }
}

RestTemplateでのAPI呼び出し

RestTemplateは同期型のHTTPクライアントです。既存のSpring MVCアプリケーションや、シンプルな同期処理で十分なケースに向いています。Spring Boot 3.xではRestTemplateは引き続き使えますが、新規プロジェクトではWebClientが推奨されています。

PdfRequest / PdfResponse DTO

// src/main/java/com/example/pdf/dto/PdfRequest.java
package com.example.pdf.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public class PdfRequest {

    private String html;
    private String format = "A4";
    private String engine = "quality";

    @JsonProperty("landscape")
    private boolean landscape = false;

    // Constructor
    public PdfRequest(String html) {
        this.html = html;
    }

    public PdfRequest(String html, String format, String engine) {
        this.html = html;
        this.format = format;
        this.engine = engine;
    }

    // Getters
    public String getHtml() { return html; }
    public String getFormat() { return format; }
    public String getEngine() { return engine; }
    public boolean isLandscape() { return landscape; }

    // Setters
    public void setHtml(String html) { this.html = html; }
    public void setFormat(String format) { this.format = format; }
    public void setEngine(String engine) { this.engine = engine; }
    public void setLandscape(boolean landscape) { this.landscape = landscape; }
}

RestTemplate Bean の設定

// src/main/java/com/example/pdf/config/PdfClientConfig.java
package com.example.pdf.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;

@Configuration
public class PdfClientConfig {

    @Bean
    public RestTemplate pdfRestTemplate(
            RestTemplateBuilder builder,
            FunbrewPdfProperties props) {
        return builder
                .connectTimeout(Duration.ofSeconds(10))
                .readTimeout(Duration.ofSeconds(props.getTimeoutSeconds()))
                .build();
    }
}

PdfService(RestTemplate版)

// src/main/java/com/example/pdf/service/PdfService.java
package com.example.pdf.service;

import com.example.pdf.config.FunbrewPdfProperties;
import com.example.pdf.dto.PdfRequest;
import com.example.pdf.exception.PdfGenerationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;

@Service
public class PdfService {

    private static final Logger log = LoggerFactory.getLogger(PdfService.class);

    private final RestTemplate restTemplate;
    private final FunbrewPdfProperties props;

    public PdfService(RestTemplate pdfRestTemplate, FunbrewPdfProperties props) {
        this.restTemplate = pdfRestTemplate;
        this.props = props;
    }

    /**
     * HTMLからPDFを生成してバイナリを返す。
     *
     * @param html 変換するHTML文字列
     * @return PDFバイナリ(byte[])
     * @throws PdfGenerationException API呼び出しが失敗した場合
     */
    public byte[] generateFromHtml(String html) {
        return generateFromHtml(html, "A4", "quality");
    }

    public byte[] generateFromHtml(String html, String format, String engine) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM));
        headers.setBearerAuth(props.getApiKey());

        PdfRequest request = new PdfRequest(html, format, engine);
        HttpEntity<PdfRequest> entity = new HttpEntity<>(request, headers);

        try {
            ResponseEntity<byte[]> response = restTemplate.exchange(
                    props.getApiUrl(),
                    HttpMethod.POST,
                    entity,
                    byte[].class
            );
            log.debug("PDF generated: {} bytes", response.getBody() != null ? response.getBody().length : 0);
            return response.getBody();
        } catch (HttpClientErrorException e) {
            log.error("PDF API client error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
            throw new PdfGenerationException("PDF生成リクエストが拒否されました: " + e.getStatusCode(), e);
        } catch (HttpServerErrorException e) {
            log.error("PDF API server error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
            throw new PdfGenerationException("PDF APIサーバーエラー: " + e.getStatusCode(), e);
        }
    }
}

カスタム例外クラス

// src/main/java/com/example/pdf/exception/PdfGenerationException.java
package com.example.pdf.exception;

public class PdfGenerationException extends RuntimeException {

    public PdfGenerationException(String message) {
        super(message);
    }

    public PdfGenerationException(String message, Throwable cause) {
        super(message, cause);
    }
}

WebClientでのAPI呼び出し

WebClientはSpring WebFluxのリアクティブHTTPクライアントです。ノンブロッキングI/OでPDFを生成し、処理スレッドを解放できます。Spring Boot 3.x以降の新規プロジェクトではWebClientが推奨されています。

WebClient Bean の設定

// src/main/java/com/example/pdf/config/PdfClientConfig.java(WebClient版)
package com.example.pdf.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class PdfClientConfig {

    @Bean
    public WebClient pdfWebClient(FunbrewPdfProperties props) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
                .responseTimeout(Duration.ofSeconds(props.getTimeoutSeconds()))
                .doOnConnected(conn ->
                        conn.addHandlerLast(new ReadTimeoutHandler(props.getTimeoutSeconds(), TimeUnit.SECONDS)));

        return WebClient.builder()
                .baseUrl(props.getApiUrl())
                .defaultHeader("Authorization", "Bearer " + props.getApiKey())
                .defaultHeader("Content-Type", "application/json")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

PdfReactiveService(WebClient版)

// src/main/java/com/example/pdf/service/PdfReactiveService.java
package com.example.pdf.service;

import com.example.pdf.dto.PdfRequest;
import com.example.pdf.exception.PdfGenerationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;

@Service
public class PdfReactiveService {

    private static final Logger log = LoggerFactory.getLogger(PdfReactiveService.class);

    private final WebClient webClient;

    public PdfReactiveService(WebClient pdfWebClient) {
        this.webClient = pdfWebClient;
    }

    /**
     * HTMLからPDFを非同期生成する(リアクティブ版)。
     *
     * @param html 変換するHTML文字列
     * @return Mono<byte[]> PDFバイナリのMono
     */
    public Mono<byte[]> generateFromHtml(String html) {
        return generateFromHtml(html, "A4", "quality");
    }

    public Mono<byte[]> generateFromHtml(String html, String format, String engine) {
        PdfRequest request = new PdfRequest(html, format, engine);

        return webClient.post()
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_OCTET_STREAM)
                .bodyValue(request)
                .retrieve()
                .bodyToMono(byte[].class)
                .doOnSuccess(bytes ->
                        log.debug("PDF generated: {} bytes", bytes != null ? bytes.length : 0))
                .onErrorMap(WebClientResponseException.class, e -> {
                    log.error("PDF API error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
                    return new PdfGenerationException(
                            "PDF生成に失敗しました: HTTP " + e.getStatusCode().value(), e);
                });
    }
}

HTML→PDF変換の実装例

Thymeleafテンプレートを使った請求書PDF

Spring BootではThymeleafが定番のテンプレートエンジンです。Thymeleafでレンダリングしたを HTMLをPDF APIに送る pattern が最もスムーズです。

<!-- pom.xml に追加 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
// src/main/java/com/example/pdf/service/InvoicePdfService.java
package com.example.pdf.service;

import com.example.pdf.model.Invoice;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.util.Locale;

@Service
public class InvoicePdfService {

    private final TemplateEngine templateEngine;
    private final PdfService pdfService;

    public InvoicePdfService(TemplateEngine templateEngine, PdfService pdfService) {
        this.templateEngine = templateEngine;
        this.pdfService = pdfService;
    }

    /**
     * 請求書モデルからPDFを生成する。
     *
     * @param invoice 請求書データ
     * @return PDFバイナリ
     */
    public byte[] generateInvoicePdf(Invoice invoice) {
        // Thymeleafコンテキストにデータをセット
        Context context = new Context(Locale.JAPAN);
        context.setVariable("invoice", invoice);

        // テンプレートをHTMLにレンダリング
        String html = templateEngine.process("pdf/invoice", context);

        // HTMLをPDF APIに送信
        return pdfService.generateFromHtml(html);
    }
}
<!-- src/main/resources/templates/pdf/invoice.html -->
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap');

    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Noto Sans JP', sans-serif;
      font-size: 14px;
      color: #1a1a1a;
      padding: 48px;
      line-height: 1.6;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      border-bottom: 3px solid #1a56db;
      padding-bottom: 20px;
      margin-bottom: 32px;
    }
    .header h1 {
      font-size: 28px;
      font-weight: 700;
      color: #1a56db;
    }
    .meta-box {
      text-align: right;
      font-size: 13px;
      color: #6b7280;
    }
    .meta-box p { margin-top: 4px; }
    .section { margin-bottom: 24px; }
    .section-title {
      font-size: 12px;
      font-weight: 700;
      color: #6b7280;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      margin-bottom: 8px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 24px;
    }
    th {
      background: #f3f4f6;
      padding: 12px 16px;
      text-align: left;
      font-size: 12px;
      font-weight: 700;
      color: #374151;
      border-bottom: 2px solid #e5e7eb;
    }
    td {
      padding: 12px 16px;
      border-bottom: 1px solid #e5e7eb;
      color: #374151;
    }
    .amount { text-align: right; }
    .total-section {
      margin-top: 24px;
      text-align: right;
    }
    .total-row {
      display: flex;
      justify-content: flex-end;
      gap: 48px;
      padding: 8px 0;
      font-size: 14px;
    }
    .total-row.grand-total {
      font-size: 20px;
      font-weight: 700;
      color: #1a56db;
      border-top: 2px solid #1a56db;
      padding-top: 16px;
      margin-top: 8px;
    }
    .footer {
      margin-top: 48px;
      padding-top: 16px;
      border-top: 1px solid #e5e7eb;
      font-size: 12px;
      color: #9ca3af;
      text-align: center;
    }
  </style>
</head>
<body>

  <div class="header">
    <h1>請求書</h1>
    <div class="meta-box">
      <p><strong>請求書番号:</strong> <span th:text="'#' + ${invoice.invoiceNumber}"></span></p>
      <p><strong>発行日:</strong> <span th:text="${invoice.issueDate}"></span></p>
      <p><strong>お支払期限:</strong> <span th:text="${invoice.dueDate}"></span></p>
    </div>
  </div>

  <div class="section">
    <div class="section-title">請求先</div>
    <p th:text="${invoice.customerName} + ' 様'"></p>
    <p th:text="${invoice.customerAddress}" style="color: #6b7280; font-size: 13px;"></p>
  </div>

  <div class="section">
    <div class="section-title">請求元</div>
    <p th:text="${invoice.companyName}"></p>
    <p th:text="${invoice.companyAddress}" style="color: #6b7280; font-size: 13px;"></p>
  </div>

  <table>
    <thead>
      <tr>
        <th>品目</th>
        <th>数量</th>
        <th class="amount">単価</th>
        <th class="amount">小計</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="item : ${invoice.items}">
        <td th:text="${item.name}"></td>
        <td th:text="${item.quantity}"></td>
        <td class="amount" th:text="'¥' + ${#numbers.formatInteger(item.unitPrice, 0, 'COMMA')}"></td>
        <td class="amount" th:text="'¥' + ${#numbers.formatInteger(item.quantity * item.unitPrice, 0, 'COMMA')}"></td>
      </tr>
    </tbody>
  </table>

  <div class="total-section">
    <div class="total-row">
      <span>小計</span>
      <span th:text="'¥' + ${#numbers.formatInteger(invoice.subtotal, 0, 'COMMA')}"></span>
    </div>
    <div class="total-row">
      <span>消費税(10%)</span>
      <span th:text="'¥' + ${#numbers.formatInteger(invoice.tax, 0, 'COMMA')}"></span>
    </div>
    <div class="total-row grand-total">
      <span>合計</span>
      <span th:text="'¥' + ${#numbers.formatInteger(invoice.total, 0, 'COMMA')}"></span>
    </div>
  </div>

  <div class="footer">
    <p>ご不明な点がございましたら、お気軽にお問い合わせください。</p>
  </div>

</body>
</html>

Invoiceモデル

// src/main/java/com/example/pdf/model/Invoice.java
package com.example.pdf.model;

import java.util.List;

public class Invoice {

    private String invoiceNumber;
    private String issueDate;
    private String dueDate;
    private String customerName;
    private String customerAddress;
    private String companyName;
    private String companyAddress;
    private List<InvoiceItem> items;

    // 計算フィールド
    public long getSubtotal() {
        return items.stream()
                .mapToLong(item -> (long) item.getQuantity() * item.getUnitPrice())
                .sum();
    }

    public long getTax() {
        return Math.round(getSubtotal() * 0.1);
    }

    public long getTotal() {
        return getSubtotal() + getTax();
    }

    // Builder パターン
    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private final Invoice invoice = new Invoice();

        public Builder invoiceNumber(String n) { invoice.invoiceNumber = n; return this; }
        public Builder issueDate(String d) { invoice.issueDate = d; return this; }
        public Builder dueDate(String d) { invoice.dueDate = d; return this; }
        public Builder customerName(String n) { invoice.customerName = n; return this; }
        public Builder customerAddress(String a) { invoice.customerAddress = a; return this; }
        public Builder companyName(String n) { invoice.companyName = n; return this; }
        public Builder companyAddress(String a) { invoice.companyAddress = a; return this; }
        public Builder items(List<InvoiceItem> items) { invoice.items = items; return this; }
        public Invoice build() { return invoice; }
    }

    // Getters
    public String getInvoiceNumber() { return invoiceNumber; }
    public String getIssueDate() { return issueDate; }
    public String getDueDate() { return dueDate; }
    public String getCustomerName() { return customerName; }
    public String getCustomerAddress() { return customerAddress; }
    public String getCompanyName() { return companyName; }
    public String getCompanyAddress() { return companyAddress; }
    public List<InvoiceItem> getItems() { return items; }
}
// src/main/java/com/example/pdf/model/InvoiceItem.java
package com.example.pdf.model;

public record InvoiceItem(String name, int quantity, long unitPrice) {
    public String getName() { return name; }
    public int getQuantity() { return quantity; }
    public long getUnitPrice() { return unitPrice; }
}

Controllerでの利用

// src/main/java/com/example/pdf/controller/InvoiceController.java
package com.example.pdf.controller;

import com.example.pdf.exception.PdfGenerationException;
import com.example.pdf.model.Invoice;
import com.example.pdf.model.InvoiceItem;
import com.example.pdf.service.InvoicePdfService;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoicePdfService invoicePdfService;

    public InvoiceController(InvoicePdfService invoicePdfService) {
        this.invoicePdfService = invoicePdfService;
    }

    @PostMapping("/{invoiceId}/pdf")
    public ResponseEntity<byte[]> downloadInvoicePdf(@PathVariable String invoiceId) {
        // 実際にはDBから取得
        Invoice invoice = buildSampleInvoice(invoiceId);

        byte[] pdfBytes = invoicePdfService.generateInvoicePdf(invoice);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_PDF);
        headers.setContentDisposition(
                ContentDisposition.attachment()
                        .filename("invoice-" + invoiceId + ".pdf")
                        .build()
        );
        headers.setContentLength(pdfBytes.length);

        return new ResponseEntity<>(pdfBytes, headers, HttpStatus.OK);
    }

    private Invoice buildSampleInvoice(String invoiceId) {
        return Invoice.builder()
                .invoiceNumber(invoiceId)
                .issueDate("2026-04-05")
                .dueDate("2026-04-30")
                .customerName("株式会社サンプル")
                .customerAddress("東京都渋谷区〇〇 1-2-3")
                .companyName("FUNBREW株式会社")
                .companyAddress("東京都港区△△ 4-5-6")
                .items(List.of(
                        new InvoiceItem("FUNBREW PDF APIプラン", 1, 9_800L),
                        new InvoiceItem("追加API呼び出し(500件)", 1, 2_000L)
                ))
                .build();
    }
}

エラーハンドリング

グローバル例外ハンドラー

@ControllerAdvicePdfGenerationExceptionをキャッチし、適切なHTTPレスポンスを返します。

// src/main/java/com/example/pdf/exception/GlobalExceptionHandler.java
package com.example.pdf.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(PdfGenerationException.class)
    public ProblemDetail handlePdfGenerationException(PdfGenerationException ex) {
        log.error("PDF generation failed", ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_GATEWAY, ex.getMessage());
        problem.setTitle("PDF Generation Failed");
        problem.setType(URI.create("https://pdf.funbrew.cloud/errors/pdf-generation-failed"));
        return problem;
    }
}

Spring Boot 3.x のProblemDetail(RFC 7807)を使うことで、エラーレスポンスが標準化されます。

// エラーレスポンスの例
{
  "type": "https://pdf.funbrew.cloud/errors/pdf-generation-failed",
  "title": "PDF Generation Failed",
  "status": 502,
  "detail": "PDF APIサーバーエラー: HTTP 503"
}

リトライ処理(Spring Retry)

PDF APIが一時的なエラーを返した場合、指数バックオフでリトライできます。spring-retryを使います。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
</dependency>
// メインクラスに追加
@EnableRetry
// PdfService.java にリトライを追加
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;

@Retryable(
    retryFor = { HttpServerErrorException.class, PdfGenerationException.class },
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public byte[] generateFromHtml(String html) {
    // ... 既存の実装
}

maxAttempts = 3delay = 1000msmultiplier = 2.0で、1秒→2秒→4秒の指数バックオフになります。

タイムアウトハンドリング

// PdfService.java(タイムアウト明示版)
import org.springframework.web.client.ResourceAccessException;
import java.net.SocketTimeoutException;

public byte[] generateFromHtml(String html, String format, String engine) {
    try {
        // ... RestTemplate呼び出し
    } catch (ResourceAccessException e) {
        if (e.getCause() instanceof SocketTimeoutException) {
            log.error("PDF API request timed out after {} seconds", props.getTimeoutSeconds());
            throw new PdfGenerationException("PDFの生成がタイムアウトしました。再度お試しください。", e);
        }
        throw new PdfGenerationException("PDF APIへの接続に失敗しました。", e);
    } catch (HttpClientErrorException e) {
        if (e.getStatusCode().value() == 429) {
            log.warn("PDF API rate limit exceeded");
            throw new PdfGenerationException("APIのレート制限に達しました。しばらく時間をおいて再度お試しください。", e);
        }
        throw new PdfGenerationException("PDF生成リクエストが拒否されました: " + e.getStatusCode(), e);
    } catch (HttpServerErrorException e) {
        log.error("PDF API server error: {}", e.getStatusCode());
        throw new PdfGenerationException("PDF APIサーバーエラーが発生しました。", e);
    }
}

詳細なエラーコードとリトライ戦略はエラーハンドリングガイドにまとめています。

非同期処理(Spring Async)

大量のPDF生成やバックグラウンド処理には@Asyncが有効です。Webリクエストをブロックせず、PDF生成をバックグラウンドスレッドで実行できます。

@Async の設定

// src/main/java/com/example/pdf/config/AsyncConfig.java
package com.example.pdf.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "pdfTaskExecutor")
    public Executor pdfTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("pdf-");
        executor.initialize();
        return executor;
    }
}

非同期PDF生成サービス

// src/main/java/com/example/pdf/service/AsyncPdfService.java
package com.example.pdf.service;

import com.example.pdf.model.Invoice;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.CompletableFuture;

@Service
public class AsyncPdfService {

    private static final Logger log = LoggerFactory.getLogger(AsyncPdfService.class);

    private final InvoicePdfService invoicePdfService;

    public AsyncPdfService(InvoicePdfService invoicePdfService) {
        this.invoicePdfService = invoicePdfService;
    }

    /**
     * 非同期でPDFを生成し、指定のパスに保存する。
     *
     * @param invoice 請求書データ
     * @param outputPath 保存先パス
     * @return CompletableFuture<Path> 保存されたファイルパスのFuture
     */
    @Async("pdfTaskExecutor")
    public CompletableFuture<Path> generateAndSave(Invoice invoice, Path outputPath) {
        try {
            log.info("Generating PDF for invoice: {}", invoice.getInvoiceNumber());
            byte[] pdfBytes = invoicePdfService.generateInvoicePdf(invoice);

            Files.createDirectories(outputPath.getParent());
            Files.write(outputPath, pdfBytes);

            log.info("PDF saved: {} ({} bytes)", outputPath, pdfBytes.length);
            return CompletableFuture.completedFuture(outputPath);
        } catch (IOException e) {
            log.error("Failed to save PDF: {}", outputPath, e);
            CompletableFuture<Path> failed = new CompletableFuture<>();
            failed.completeExceptionally(e);
            return failed;
        }
    }

    /**
     * 複数の請求書を並列生成する。
     */
    public CompletableFuture<Void> generateBatch(
            java.util.List<Invoice> invoices,
            Path outputDir) {
        CompletableFuture<?>[] futures = invoices.stream()
                .map(invoice -> {
                    Path outputPath = outputDir.resolve("invoice-" + invoice.getInvoiceNumber() + ".pdf");
                    return generateAndSave(invoice, outputPath);
                })
                .toArray(CompletableFuture[]::new);

        return CompletableFuture.allOf(futures);
    }
}

非同期エンドポイント

// src/main/java/com/example/pdf/controller/InvoiceController.java(非同期エンドポイント追加)
@PostMapping("/{invoiceId}/pdf/async")
public ResponseEntity<Map<String, String>> requestPdfGeneration(@PathVariable String invoiceId) {
    Invoice invoice = buildSampleInvoice(invoiceId);
    Path outputPath = Paths.get("storage/invoices", "invoice-" + invoiceId + ".pdf");

    asyncPdfService.generateAndSave(invoice, outputPath)
            .whenComplete((path, ex) -> {
                if (ex != null) {
                    log.error("Async PDF generation failed for invoice: {}", invoiceId, ex);
                } else {
                    log.info("Async PDF ready: {}", path);
                    // Webhook通知やDB更新など
                }
            });

    return ResponseEntity.accepted().body(Map.of(
            "status", "processing",
            "invoiceId", invoiceId,
            "message", "PDFを生成中です。完了後に通知します。"
    ));
}

バッチ処理の詳細はPDF一括生成ガイドを参照してください。

テスト

RestTemplateのユニットテスト(MockRestServiceServer)

Springのテスト機能MockRestServiceServerを使うと、HTTP呼び出しをモックできます。

// src/test/java/com/example/pdf/service/PdfServiceTest.java
package com.example.pdf.service;

import com.example.pdf.config.FunbrewPdfProperties;
import com.example.pdf.exception.PdfGenerationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;

import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;

@SpringBootTest
class PdfServiceTest {

    @Autowired
    private RestTemplate pdfRestTemplate;

    @Autowired
    private PdfService pdfService;

    @Autowired
    private FunbrewPdfProperties props;

    private MockRestServiceServer mockServer;

    @BeforeEach
    void setUp() {
        mockServer = MockRestServiceServer.createServer(pdfRestTemplate);
    }

    @Test
    void generateFromHtml_Success_ReturnsPdfBytes() {
        // Given
        byte[] fakePdf = "%PDF-1.4 fake content".getBytes();
        mockServer.expect(requestTo(props.getApiUrl()))
                .andExpect(method(HttpMethod.POST))
                .andExpect(header("Authorization", "Bearer " + props.getApiKey()))
                .andRespond(withSuccess(fakePdf, MediaType.APPLICATION_OCTET_STREAM));

        // When
        byte[] result = pdfService.generateFromHtml("<h1>テスト</h1>");

        // Then
        assertThat(result).isEqualTo(fakePdf);
        mockServer.verify();
    }

    @Test
    void generateFromHtml_ServerError_ThrowsPdfGenerationException() {
        // Given
        mockServer.expect(requestTo(props.getApiUrl()))
                .andRespond(withServerError());

        // When / Then
        assertThatThrownBy(() -> pdfService.generateFromHtml("<h1>テスト</h1>"))
                .isInstanceOf(PdfGenerationException.class)
                .hasMessageContaining("500");

        mockServer.verify();
    }

    @Test
    void generateFromHtml_RateLimit_ThrowsPdfGenerationException() {
        // Given
        mockServer.expect(requestTo(props.getApiUrl()))
                .andRespond(withStatus(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS));

        // When / Then
        assertThatThrownBy(() -> pdfService.generateFromHtml("<h1>テスト</h1>"))
                .isInstanceOf(PdfGenerationException.class)
                .hasMessageContaining("レート制限");

        mockServer.verify();
    }
}

WebClientのユニットテスト(MockWebServer)

WebClientのテストにはokhttp3MockWebServerが便利です。

// src/test/java/com/example/pdf/service/PdfReactiveServiceTest.java
package com.example.pdf.service;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.test.StepVerifier;

import java.io.IOException;

class PdfReactiveServiceTest {

    private MockWebServer mockWebServer;
    private PdfReactiveService pdfReactiveService;

    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();

        WebClient webClient = WebClient.builder()
                .baseUrl(mockWebServer.url("/").toString())
                .defaultHeader("Authorization", "Bearer sk-test")
                .build();

        pdfReactiveService = new PdfReactiveService(webClient);
    }

    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }

    @Test
    void generateFromHtml_Success_ReturnsPdfBytes() {
        // Given
        byte[] fakePdf = "%PDF-1.4 fake".getBytes();
        mockWebServer.enqueue(new MockResponse()
                .setBody(new okio.Buffer().write(fakePdf))
                .addHeader("Content-Type", "application/octet-stream")
                .setResponseCode(200));

        // When / Then
        StepVerifier.create(pdfReactiveService.generateFromHtml("<h1>テスト</h1>"))
                .assertNext(result -> assertThat(result).isEqualTo(fakePdf))
                .verifyComplete();
    }

    @Test
    void generateFromHtml_ServerError_ThrowsException() {
        // Given
        mockWebServer.enqueue(new MockResponse().setResponseCode(500));

        // When / Then
        StepVerifier.create(pdfReactiveService.generateFromHtml("<h1>テスト</h1>"))
                .expectErrorMatches(ex ->
                        ex instanceof com.example.pdf.exception.PdfGenerationException
                        && ex.getMessage().contains("500"))
                .verify();
    }
}

本番運用のTips

APIキーのセキュリティ

本番環境では、APIキーを以下の方法で管理します。ソースコードやGitリポジトリに含めてはいけません。

# 環境変数で設定(推奨)
export FUNBREW_PDF_API_KEY=sk-your-production-key

# Kubernetes の場合は Secret を使用
kubectl create secret generic funbrew-pdf-secret \
  --from-literal=api-key=sk-your-production-key
# Kubernetes Deployment での参照例
env:
  - name: FUNBREW_PDF_API_KEY
    valueFrom:
      secretKeyRef:
        name: funbrew-pdf-secret
        key: api-key

Spring Cloud Vaultや AWS Secrets Managerとの連携で、シークレットを動的に取得する方法はセキュリティガイドで解説しています。

PDF生成のキャッシュ

同じ内容のPDFを繰り返し生成する場合、HTMLのハッシュをキャッシュキーとして使えます。

// src/main/java/com/example/pdf/service/CachedPdfService.java
package com.example.pdf.service;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

@Service
public class CachedPdfService {

    private final PdfService pdfService;

    public CachedPdfService(PdfService pdfService) {
        this.pdfService = pdfService;
    }

    /**
     * HTMLのSHA-256ハッシュをキャッシュキーとして、
     * 同一コンテンツのPDFを再生成しない。
     */
    @Cacheable(value = "pdf-cache", key = "#root.target.hashHtml(#html)")
    public byte[] generateWithCache(String html) {
        return pdfService.generateFromHtml(html);
    }

    public String hashHtml(String html) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hashBytes = digest.digest(html.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hashBytes);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}
# application.yml — Caffeineキャッシュの設定
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=200,expireAfterWrite=3600s

アクチュエーターによる監視

Spring Boot Actuatorのカスタムメトリクスで、PDF生成のレイテンシとエラー率を計測します。

// PdfService.java にメトリクスを追加
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;

@Service
public class PdfService {

    private final Timer generateTimer;
    private final Counter errorCounter;

    public PdfService(RestTemplate pdfRestTemplate,
                      FunbrewPdfProperties props,
                      MeterRegistry meterRegistry) {
        this.restTemplate = pdfRestTemplate;
        this.props = props;
        this.generateTimer = Timer.builder("pdf.generation.time")
                .description("PDF generation latency")
                .register(meterRegistry);
        this.errorCounter = Counter.builder("pdf.generation.errors")
                .description("PDF generation error count")
                .register(meterRegistry);
    }

    public byte[] generateFromHtml(String html) {
        return generateTimer.record(() -> {
            try {
                return generateFromHtml(html, "A4", "quality");
            } catch (PdfGenerationException e) {
                errorCounter.increment();
                throw e;
            }
        });
    }
}

コネクションプールの最適化

高トラフィック環境ではHTTPコネクションプールのチューニングが重要です。

// RestTemplate の場合(Apache HttpClient 5)
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;

@Bean
public RestTemplate pdfRestTemplate(FunbrewPdfProperties props) {
    PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
    cm.setMaxTotal(50);           // 最大コネクション数
    cm.setDefaultMaxPerRoute(20); // ホストごとの最大コネクション数

    CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .build();

    HttpComponentsClientHttpRequestFactory factory =
            new HttpComponentsClientHttpRequestFactory(httpClient);
    factory.setConnectTimeout(10_000);
    factory.setReadTimeout((int) (props.getTimeoutSeconds() * 1000L));

    return new RestTemplate(factory);
}

ログ設計

PDF生成のトレースIDをMDCに設定し、ログ集約ツールで追跡できるようにします。

// src/main/java/com/example/pdf/filter/PdfTraceFilter.java
package com.example.pdf.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.UUID;

@Component
public class PdfTraceFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String traceId = ((HttpServletRequest) request).getHeader("X-Trace-Id");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("traceId");
        }
    }
}

本番環境のベストプラクティス全般はプロダクション運用ガイドを参照してください。DockerやKubernetes環境での構成についてはDocker・Kubernetes活用ガイドもあわせてご覧ください。

RestTemplateとWebClientの使い分け

観点 RestTemplate WebClient
処理モデル 同期(スレッドをブロック) 非同期・リアクティブ(ノンブロッキング)
Spring Boot推奨 メンテナンスモード(既存コード向け) 新規プロジェクト推奨
学習コスト 低い(シンプルなAPI) 高い(Reactor/RxJavaの理解が必要)
テスト容易性 MockRestServiceServerで簡単 MockWebServer(okhttp3)が必要
スケーラビリティ スレッド数に依存 イベントループで高スループット
既存MVC統合 自然 block()呼び出しでデッドロックリスク

選び方:

  • 既存のSpring MVCアプリにPDF生成を追加する → RestTemplateで十分
  • 新規のSpring WebFluxアプリ → WebClientを使う
  • 高並列のPDF生成(秒間数十件以上)→ WebClient + リアクティブパイプライン
  • バッチ処理@Async + CompletableFuture(どちらでも可)

まとめ

Spring BootからFUNBREW PDF APIを使ったPDF生成は、最小限のコードで実現できます。iText 8の高額ライセンスやPDFBoxの複雑なHTML再現から解放され、ThymeleafテンプレートとシンプルなHTTP呼び出しでプロ品質のPDFを生成できます。

  1. ライセンスコストゼロ — iTextの商用ライセンスは不要
  2. 日本語フォント対応 — Noto Sans JPがプリインストール済み
  3. サーバーリソース節約 — PDF生成処理を外部に委譲
  4. シンプルな統合 — RestTemplate/WebClient + Thymeleafで完結
  5. 本番対応 — リトライ、キャッシュ、メトリクスまでカバー

まずはPlaygroundでHTMLテンプレートを試し、どんなPDFが生成されるか確認してください。APIの全仕様はドキュメントでご覧いただけます。料金プランで適切なプランを選んで始めましょう。

関連記事

Powered by FUNBREW PDF