GoでPDF生成API|goroutine並列処理・リトライ・本番運用パターン
Goの並行性モデルはPDFの大量生成に最適です。goroutineは軽量で、channelを使えばサードパーティ不要の有界ワーカープールを簡単に構築できます。このガイドでは、goroutineベースの並列処理・リトライロジック・コンテキスト伝播・Gin/Echoとの統合など、本番環境で必要なGoパターンに集中して解説します。
RubyとGoの基本的なコード例についてはRuby・Goクイックスタートガイドを参照してください。Node.js・Python・PHPの例は言語別クイックスタートにまとめています。
前提条件
# APIキーを環境変数に設定
export FUNBREW_PDF_API_KEY="sk-your-api-key"
無料アカウントを作成してAPIキーを取得してください。全オプションの詳細はAPIドキュメントを参照してください。
基本的なPDF生成
まず、APIコールをきれいにラップする本番対応の構造体を定義します。
package pdfapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// Client はHTTPクライアントをラップする。リクエスト間で共有して再利用する。
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
}
// NewClient は本番向けのデフォルト設定でクライアントを生成する。
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second,
// コネクションプールを共有してTLSハンドシェイクを削減する。
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
apiKey: os.Getenv("FUNBREW_PDF_API_KEY"),
baseURL: "https://pdf.funbrew.cloud/api/v1",
}
}
type GenerateRequest struct {
HTML string `json:"html"`
Options map[string]any `json:"options,omitempty"`
}
// Generate はHTMLを送信してPDFバイト列を返す。
func (c *Client) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/pdf/generate", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("X-API-Key", c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return nil, &APIError{Status: resp.StatusCode, Body: string(errBody)}
}
return io.ReadAll(resp.Body)
}
// APIError はHTTPステータスとレスポンスボディを保持する。
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("PDF API %d: %s", e.Status, e.Body)
}
// IsRetryable はリトライ対象のエラーかを返す。
func (e *APIError) IsRetryable() bool {
return e.Status == 429 || e.Status >= 500
}
func main() {
client := pdfapi.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pdf, err := client.Generate(ctx, pdfapi.GenerateRequest{
HTML: `<!DOCTYPE html>
<html lang="ja">
<head><style>
body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; color: #1a202c; }
h1 { font-size: 24pt; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
.meta { color: #718096; font-size: 10pt; margin-top: 4px; }
</style></head>
<body>
<h1>月次レポート</h1>
<p class="meta">FUNBREW PDF APIで生成</p>
<p>レポート本文がここに入ります。</p>
</body>
</html>`,
Options: map[string]any{
"format": "A4",
"engine": "quality",
"margin": map[string]string{
"top": "20mm", "bottom": "20mm",
"left": "15mm", "right": "15mm",
},
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.WriteFile("report.pdf", pdf, 0644)
fmt.Printf("generated report.pdf (%d bytes)\n", len(pdf))
}
指数バックオフによるリトライ
レートリミット(HTTP 429)や一時的なサーバーエラー(5xx)は本番環境で必ず起こります。指数バックオフのリトライループで Generate をラップします。
package pdfapi
import (
"context"
"errors"
"math"
"time"
)
// RetryConfig はリトライ動作を制御する。
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
// DefaultRetryConfig は本番環境向けの標準設定。
var DefaultRetryConfig = RetryConfig{
MaxAttempts: 4,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 30 * time.Second,
}
// GenerateWithRetry は指数バックオフでGenerateをラップする。
func (c *Client) GenerateWithRetry(ctx context.Context, req GenerateRequest, cfg RetryConfig) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
pdf, err := c.Generate(ctx, req)
if err == nil {
return pdf, nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || !apiErr.IsRetryable() {
// 400 Bad Requestなどリトライ不可のエラーは即時失敗。
return nil, err
}
lastErr = err
// delay = baseDelay * 2^attempt (MaxDelayで上限)
delay := time.Duration(float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)))
if delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("%d回試行しましたが失敗しました: %w", cfg.MaxAttempts, lastErr)
}
使用例:
pdf, err := client.GenerateWithRetry(ctx, req, pdfapi.DefaultRetryConfig)
goroutineワーカープールによるバッチ生成
クイックスタートガイドでは sync.WaitGroup とセマフォチャネルを使ったシンプルな例を示しています。本番バッチジョブでは、専用のワーカープールがより保守しやすく可観測性も高くなります。
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
)
// Job は1件のPDF生成タスクを表す。
type Job struct {
ID string
HTML string
}
// Result は1件の生成結果を保持する。
type Result struct {
ID string
PDF []byte
Bytes int
Err error
}
// BatchGenerate は最大 concurrency 個のgoroutineを並列実行する。
// 入力スライスと同じ順序で結果を返す。
func BatchGenerate(ctx context.Context, client *pdfapi.Client, jobs []Job, concurrency int) []Result {
results := make([]Result, len(jobs))
type indexedJob struct {
index int
job Job
}
jobCh := make(chan indexedJob, len(jobs))
var wg sync.WaitGroup
// ワーカーgoroutineを起動。
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for ij := range jobCh {
pdf, err := client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: ij.job.HTML,
Options: map[string]any{
"format": "A4",
"engine": "quality",
},
}, pdfapi.DefaultRetryConfig)
// 元のインデックスに書き込むのでmutexが不要。
results[ij.index] = Result{
ID: ij.job.ID,
PDF: pdf,
Bytes: len(pdf),
Err: err,
}
}
}()
}
// 全ジョブをキューに投入。
for i, j := range jobs {
jobCh <- indexedJob{index: i, job: j}
}
close(jobCh)
wg.Wait()
return results
}
func main() {
client := pdfapi.NewClient()
jobs := []Job{
{ID: "INV-001", HTML: "<h1>請求書 001</h1><p>株式会社サンプル — ¥100,000</p>"},
{ID: "INV-002", HTML: "<h1>請求書 002</h1><p>グローベックス — ¥250,000</p>"},
{ID: "INV-003", HTML: "<h1>請求書 003</h1><p>イニテック — ¥180,000</p>"},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// 5並列 — APIプランのレートリミットに合わせて調整する。
results := BatchGenerate(ctx, client, jobs, 5)
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(os.Stderr, "%s: 失敗 — %v\n", r.ID, r.Err)
continue
}
path := r.ID + ".pdf"
os.WriteFile(path, r.PDF, 0644)
fmt.Printf("%s: OK — %d bytes → %s\n", r.ID, r.Bytes, path)
}
}
クイックスタートガイドのシンプルなgoroutine例との主な違い:
- 結果を事前割り当てスライスの元のインデックスに書き込むため、mutexなしで順序を保持できる
- ワーカーはバッファ付きchannelをブロックして読む。goroutine数はちょうど
concurrency個に維持される GenerateWithRetryをワーカー内部で呼ぶため、リトライはプール内に閉じている
Gin統合
package main
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// PDFHandler はGinハンドラー。起動時にクライアントを注入してリクエスト間で再利用する。
type PDFHandler struct {
client *pdfapi.Client
}
func (h *PDFHandler) Download(c *gin.Context) {
invoiceID := c.Param("id")
// 本番ではここでDBから請求書データを取得する。
html := buildInvoiceHTML(invoiceID)
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
pdf, err := h.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: html,
Options: map[string]any{"format": "A4", "engine": "quality"},
}, pdfapi.DefaultRetryConfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", `attachment; filename="invoice-`+invoiceID+`.pdf"`)
c.Data(http.StatusOK, "application/pdf", pdf)
}
func buildInvoiceHTML(id string) string {
return `<!DOCTYPE html><html lang="ja"><head><style>
body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; }
</style></head><body>
<h1>請求書 ` + id + `</h1>
<p>FUNBREW PDF APIで生成</p>
</body></html>`
}
func main() {
client := pdfapi.NewClient()
handler := &PDFHandler{client: client}
r := gin.Default()
r.GET("/invoices/:id/pdf", handler.Download)
r.Run(":8080")
}
Echo統合
package main
import (
"context"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type PDFService struct {
client *pdfapi.Client
}
func (s *PDFService) DownloadReport(c echo.Context) error {
reportID := c.Param("id")
ctx, cancel := context.WithTimeout(c.Request().Context(), 45*time.Second)
defer cancel()
pdf, err := s.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: `<h1>レポート ` + reportID + `</h1><p>FUNBREW PDF</p>`,
Options: map[string]any{"format": "A4", "engine": "quality"},
}, pdfapi.DefaultRetryConfig)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
c.Response().Header().Set("Content-Disposition",
`attachment; filename="report-`+reportID+`.pdf"`)
return c.Blob(http.StatusOK, "application/pdf", pdf)
}
func main() {
svc := &PDFService{client: pdfapi.NewClient()}
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/reports/:id/pdf", svc.DownloadReport)
e.Logger.Fatal(e.Start(":8080"))
}
本番運用パターン
環境変数による設定管理
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type PDFConfig struct {
APIKey string
BaseURL string
Timeout time.Duration
Concurrency int
MaxRetries int
}
func LoadPDFConfig() (*PDFConfig, error) {
apiKey := os.Getenv("FUNBREW_PDF_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("FUNBREW_PDF_API_KEY が設定されていません")
}
concurrency, _ := strconv.Atoi(os.Getenv("PDF_CONCURRENCY"))
if concurrency <= 0 {
concurrency = 5
}
timeout, _ := strconv.Atoi(os.Getenv("PDF_TIMEOUT_SECONDS"))
if timeout <= 0 {
timeout = 60
}
maxRetries, _ := strconv.Atoi(os.Getenv("PDF_MAX_RETRIES"))
if maxRetries <= 0 {
maxRetries = 4
}
return &PDFConfig{
APIKey: apiKey,
BaseURL: "https://pdf.funbrew.cloud/api/v1",
Timeout: time.Duration(timeout) * time.Second,
Concurrency: concurrency,
MaxRetries: maxRetries,
}, nil
}
可観測性: ログとメトリクス
構造化ログとレイテンシ計測をクライアントに追加します。
package pdfapi
import (
"context"
"log/slog"
"time"
)
// InstrumentedClient はロギングとメトリクスを追加するラッパー。
type InstrumentedClient struct {
*Client
logger *slog.Logger
}
func NewInstrumentedClient(logger *slog.Logger) *InstrumentedClient {
return &InstrumentedClient{
Client: NewClient(),
logger: logger,
}
}
func (ic *InstrumentedClient) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
start := time.Now()
pdf, err := ic.Client.Generate(ctx, req)
duration := time.Since(start)
if err != nil {
ic.logger.Error("PDF生成失敗",
"duration_ms", duration.Milliseconds(),
"error", err,
)
// ここでPrometheusカウンターなどをインクリメントする。
return nil, err
}
ic.logger.Info("PDF生成完了",
"duration_ms", duration.Milliseconds(),
"size_bytes", len(pdf),
)
return pdf, nil
}
バッチワーカーのグレースフルシャットダウン
長期稼働バッチワーカーでは SIGTERM を受け取ったとき、処理中のジョブを完了させてから終了させます。
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
client := pdfapi.NewClient()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
jobs := loadJobsFromQueue() // キュー/DBからジョブを取得。
// シグナル受信後、最大2分間は処理中のジョブを待つ。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
results := BatchGenerate(shutdownCtx, client, jobs, 5)
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(os.Stderr, "%s: 失敗 — %v\n", r.ID, r.Err)
} else {
fmt.Printf("%s: OK — %d bytes\n", r.ID, r.Bytes)
}
}
}
コネクションプールのチューニング
デフォルトの http.Transport は中程度のスループットまで問題ありません。高並列バッチでは以下のようにチューニングします。
transport := &http.Transport{
// MaxIdleConnsPerHost をワーカープールのサイズに合わせる。
MaxIdleConnsPerHost: 20,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
// ネットワーク障害時のフェイルファストを速くする。
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
まとめ
| パターン | 使いどころ |
|---|---|
単純な Generate 呼び出し |
HTTPハンドラーでオンデマンドPDF生成 |
GenerateWithRetry |
本番のあらゆるパス(レートリミットは必ず発生する) |
ワーカープール(BatchGenerate) |
請求書一括生成・夜間レポート・データエクスポート |
InstrumentedClient |
レイテンシメトリクスが必要なサービス |
signal.NotifyContext |
Kubernetes/systemdで動くバッチワーカー |
Goの context パッケージはPDF APIと自然に統合されます。Gin/Echoのリクエストコンテキストを Generate に渡すだけでキャンセレーションが自動的に伝播します。共有の http.Transport によってコネクションが再利用されるため、連続したリクエストでTLSハンドシェイクのオーバーヘッドを払う必要がありません。
関連リンク
- Ruby・Goクイックスタートガイド — FaradayやRestyを使った基本コード例
- 言語別クイックスタート — Node.js・Python・PHPの実装例
- PDFバッチ生成ガイド — 大規模生成向けキューパターン
- PDF APIエラーハンドリングガイド — エラー種別・リトライ戦略・サーキットブレーカー
- APIドキュメント — FUNBREW PDF全オプションリファレンス
- Playground — コードを書く前にブラウザでHTML→PDFをテスト
- ユースケース一覧 — 請求書・レポート・証明書の活用例