NaN/NaN/NaN

C#でPDFを生成するとき、iTextSharp・QuestPDF・FastReport.NETなどのライブラリが候補に挙がります。しかしiTextSharp(iText 5系)は2023年にサポートが終了し、iText 7/8への移行が求められています。iText 7以降はAGPLv3ライセンスのため、商用プロダクトへの組み込みには有償ライセンスが必要です。QuestPDFはMITライセンスで使いやすい一方、HTMLのレイアウトをそのままPDFに反映することが難しく、RazorビューをPDF化するには別途変換処理が必要です。

FUNBREW PDF APIを使えば、HttpClientでHTMLを送るだけで高品質なPDFが返ってきます。ライセンスコストなし、サーバーリソース節約、日本語フォント対応済み――ASP.NET Coreプロジェクトへの統合も数十行で完了します。

この記事では、ASP.NET CoreプロジェクトへのFUNBREW PDF API統合を、DI設計からRazorテンプレート連携、BackgroundServiceによる非同期処理、HttpMessageHandlerモックを使ったテストまで、実践的なコード付きで解説します。他のフレームワーク・言語での統合は言語別クイックスタートを、エラーハンドリングの詳細はエラーハンドリングガイドを参照してください。

なぜAPI方式か

iTextSharp / iText 7のライセンス問題

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

ライブラリ ライセンス 商用利用の制約
iTextSharp (iText 5) AGPL / EOL サポート終了、有償ライセンス必要
iText 7 / 8 AGPLv3 / Commercial 有償ライセンス必要(商用プロダクト)
QuestPDF MIT 制約なし(HTMLレイアウト再現が困難)
FastReport.NET Commercial 有償、DesignerはGUI中心
FUNBREW PDF API SaaS(月額課金) ライセンス問題なし、HTML直接対応

QuestPDFでHTMLは使えない

QuestPDFはFluentなAPIでPDFレイアウトを定義するライブラリです。C#コードでレイアウトを組むのには優れていますが、既存のRazorビューやHTMLテンプレートをそのままPDFにすることはできません。既存のウェブUIをPDFで出力したい場合は、別途変換ロジックが必要になります。

API方式では、RazorでレンダリングしたHTML文字列をそのままAPIに渡せるため、既存のテンプレート資産を再利用できます。

サーバーリソースの節約

iTextやQuestPDFはPDF生成処理をアプリケーションサーバー上で実行するため、複雑なドキュメントほどCPU・メモリを消費します。ASP.NET CoreアプリをDockerコンテナで運用している場合、PDF生成のためにコンテナリソースを増強するのはコスト効率が悪いです。

API方式では、PDF生成の重い処理は外部サービスが担います。アプリケーションサーバーはHTTPリクエストを送るだけなので、インスタンスサイズを抑えられます。Kubernetesではスケールアウトの予測も立てやすくなります。

日本語フォント対応

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

ASP.NET Coreプロジェクトのセットアップ

.NET 8(ASP.NET Core 8)を前提とします。追加NuGetパッケージは不要です。HttpClientは.NET標準ライブラリに含まれています。

appsettings.json に設定を追加

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

// appsettings.json
{
  "FunbrewPdf": {
    "ApiUrl": "https://pdf.funbrew.cloud/api/v1/generate",
    "TimeoutSeconds": 60
  }
}
// appsettings.Development.json(開発環境)
{
  "FunbrewPdf": {
    "ApiKey": "sk-your-dev-api-key"
  }
}

本番環境では環境変数で上書きします。

# 本番環境の環境変数
FunbrewPdf__ApiKey=sk-your-production-api-key

設定クラス

IOptions<T>パターンで設定を型安全に注入します。

// FunbrewPdfOptions.cs
namespace MyApp.Pdf;

public sealed class FunbrewPdfOptions
{
    public const string SectionName = "FunbrewPdf";

    public string ApiKey { get; set; } = string.Empty;
    public string ApiUrl { get; set; } = "https://pdf.funbrew.cloud/api/v1/generate";
    public int TimeoutSeconds { get; set; } = 60;
}

PdfServiceの実装

HttpClientをDIコンテナから注入するサービスクラスを作ります。

// IPdfService.cs
namespace MyApp.Pdf;

public interface IPdfService
{
    Task<byte[]> GenerateFromHtmlAsync(string html, PdfGenerateOptions? options = null, CancellationToken ct = default);
}
// PdfGenerateOptions.cs
namespace MyApp.Pdf;

public sealed class PdfGenerateOptions
{
    public string Format { get; set; } = "A4";
    public string Orientation { get; set; } = "portrait";
    public string Engine { get; set; } = "quality";
    public MarginOptions? Margin { get; set; }
}

public sealed class MarginOptions
{
    public string Top { get; set; } = "20mm";
    public string Right { get; set; } = "15mm";
    public string Bottom { get; set; } = "20mm";
    public string Left { get; set; } = "15mm";
}
// PdfService.cs
using System.Net.Http.Json;
using Microsoft.Extensions.Options;

namespace MyApp.Pdf;

public sealed class PdfService : IPdfService
{
    private readonly HttpClient _httpClient;
    private readonly FunbrewPdfOptions _options;
    private readonly ILogger<PdfService> _logger;

    public PdfService(
        HttpClient httpClient,
        IOptions<FunbrewPdfOptions> options,
        ILogger<PdfService> logger)
    {
        _httpClient = httpClient;
        _options = options.Value;
        _logger = logger;
    }

    public async Task<byte[]> GenerateFromHtmlAsync(
        string html,
        PdfGenerateOptions? options = null,
        CancellationToken ct = default)
    {
        var opts = options ?? new PdfGenerateOptions();

        var payload = new
        {
            html,
            options = new
            {
                format      = opts.Format,
                orientation = opts.Orientation,
                engine      = opts.Engine,
                margin      = opts.Margin is not null ? new
                {
                    top    = opts.Margin.Top,
                    right  = opts.Margin.Right,
                    bottom = opts.Margin.Bottom,
                    left   = opts.Margin.Left,
                } : null,
            },
        };

        var startedAt = DateTime.UtcNow;

        var response = await _httpClient.PostAsJsonAsync(_options.ApiUrl, payload, ct);

        var elapsed = (DateTime.UtcNow - startedAt).TotalMilliseconds;

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "PDF generation failed. Status={Status}, Elapsed={Elapsed}ms, Body={Body}",
                (int)response.StatusCode, elapsed, body);
            throw new PdfGenerationException(
                $"PDF generation failed: HTTP {(int)response.StatusCode} — {body}");
        }

        var pdfBytes = await response.Content.ReadAsByteArrayAsync(ct);

        _logger.LogInformation(
            "PDF generated. Size={Size}bytes, Elapsed={Elapsed}ms",
            pdfBytes.Length, elapsed);

        return pdfBytes;
    }
}
// PdfGenerationException.cs
namespace MyApp.Pdf;

public sealed class PdfGenerationException : Exception
{
    public PdfGenerationException(string message) : base(message) { }
    public PdfGenerationException(string message, Exception inner) : base(message, inner) { }
}

DIコンテナへの登録(Program.cs)

IHttpClientFactoryを使ったHttpClient登録がベストプラクティスです。

// Program.cs
using MyApp.Pdf;

var builder = WebApplication.CreateBuilder(args);

// FunbrewPdf設定のバインド
builder.Services.Configure<FunbrewPdfOptions>(
    builder.Configuration.GetSection(FunbrewPdfOptions.SectionName));

// 名前付きHttpClientの登録
builder.Services.AddHttpClient<IPdfService, PdfService>(
    "FunbrewPdf",
    (sp, client) =>
    {
        var options = sp.GetRequiredService<IOptions<FunbrewPdfOptions>>().Value;
        client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey);
        client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
    });

var app = builder.Build();
// ...
app.Run();

これでIPdfServiceをコンストラクタインジェクションで利用できます。APIキーはHttpClientのデフォルトヘッダーに設定されるため、サービスクラス側で毎回セットする必要はありません。

基本的なPDF生成

ControllerからPDFを返す

// Controllers/PdfController.cs
using Microsoft.AspNetCore.Mvc;
using MyApp.Pdf;

[ApiController]
[Route("api/[controller]")]
public class PdfController : ControllerBase
{
    private readonly IPdfService _pdfService;

    public PdfController(IPdfService pdfService)
    {
        _pdfService = pdfService;
    }

    [HttpPost("generate")]
    public async Task<IActionResult> Generate(
        [FromBody] GeneratePdfRequest request,
        CancellationToken ct)
    {
        try
        {
            var pdfBytes = await _pdfService.GenerateFromHtmlAsync(request.Html, null, ct);
            return File(pdfBytes, "application/pdf", "document.pdf");
        }
        catch (PdfGenerationException ex)
        {
            return StatusCode(502, new { error = ex.Message });
        }
    }
}

public sealed record GeneratePdfRequest(string Html);

HTMLからPDFへの変換の仕組みと最適化のコツはHTML to PDF完全ガイドで詳しく解説しています。

Razorテンプレートとの連携

ASP.NET CoreのRazorエンジンでHTMLをレンダリングしてからAPIに渡すパターンです。既存のRazorビューをそのまま活用できます。

IRazorViewRendererの実装

Razorビューを文字列にレンダリングするヘルパーサービスを実装します。

// IRazorViewRenderer.cs
namespace MyApp.Pdf;

public interface IRazorViewRenderer
{
    Task<string> RenderToStringAsync<TModel>(string viewName, TModel model);
}
// RazorViewRenderer.cs
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;

namespace MyApp.Pdf;

public sealed class RazorViewRenderer : IRazorViewRenderer
{
    private readonly IRazorViewEngine _viewEngine;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IServiceProvider _serviceProvider;

    public RazorViewRenderer(
        IRazorViewEngine viewEngine,
        ITempDataProvider tempDataProvider,
        IServiceProvider serviceProvider)
    {
        _viewEngine = viewEngine;
        _tempDataProvider = tempDataProvider;
        _serviceProvider = serviceProvider;
    }

    public async Task<string> RenderToStringAsync<TModel>(string viewName, TModel model)
    {
        var httpContext = new DefaultHttpContext { RequestServices = _serviceProvider };
        var actionContext = new ActionContext(
            httpContext,
            new RouteData(),
            new ActionDescriptor());

        using var sw = new StringWriter();

        var viewResult = _viewEngine.FindView(actionContext, viewName, false);
        if (!viewResult.Success)
            throw new InvalidOperationException($"View '{viewName}' not found.");

        var viewData = new ViewDataDictionary<TModel>(
            new EmptyModelMetadataProvider(),
            new ModelStateDictionary())
        {
            Model = model,
        };

        var tempData = new TempDataDictionary(httpContext, _tempDataProvider);

        var viewContext = new ViewContext(
            actionContext,
            viewResult.View,
            viewData,
            tempData,
            sw,
            new HtmlHelperOptions());

        await viewResult.View.RenderAsync(viewContext);
        return sw.ToString();
    }
}

Program.csに登録します。

// Program.cs(追加)
builder.Services.AddControllersWithViews(); // Razorビューに必要
builder.Services.AddScoped<IRazorViewRenderer, RazorViewRenderer>();

請求書PDFのViewModelとRazorテンプレート

// Models/InvoiceViewModel.cs
namespace MyApp.Models;

public sealed class InvoiceViewModel
{
    public string InvoiceNumber { get; set; } = "";
    public DateOnly IssuedAt { get; set; }
    public DateOnly DueAt { get; set; }
    public CustomerInfo Customer { get; set; } = new();
    public CompanyInfo Company { get; set; } = new();
    public List<InvoiceItem> Items { get; set; } = [];
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}

public sealed record CustomerInfo(string Name, string Address, string Email);
public sealed record CompanyInfo(string Name, string Address, string Email);
public sealed record InvoiceItem(string Name, int Quantity, decimal UnitPrice);
@* Views/Pdf/Invoice.cshtml *@
@model MyApp.Models.InvoiceViewModel
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: 'Noto Sans JP', sans-serif;
      color: #1a1a1a;
      margin: 0;
      padding: 32px;
    }
    .header { display: flex; justify-content: space-between; margin-bottom: 32px; }
    .invoice-title { font-size: 28px; font-weight: 700; color: #2563eb; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { background: #f1f5f9; padding: 10px 12px; text-align: left; font-size: 13px; }
    td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
    .total-row { text-align: right; font-size: 18px; font-weight: 700; margin-top: 16px; }
    .footer { margin-top: 48px; font-size: 12px; color: #64748b; text-align: center; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="invoice-title">請求書</div>
      <p>No. @Model.InvoiceNumber</p>
      <p>発行日: @Model.IssuedAt.ToString("yyyy年M月d日")</p>
      <p>支払期日: @Model.DueAt.ToString("yyyy年M月d日")</p>
    </div>
    <div>
      <p><strong>@Model.Company.Name</strong></p>
      <p>@Model.Company.Address</p>
      <p>@Model.Company.Email</p>
    </div>
  </div>

  <div>
    <p><strong>請求先: @Model.Customer.Name</strong></p>
    <p>@Model.Customer.Address</p>
  </div>

  <table>
    <thead>
      <tr>
        <th>品目</th>
        <th>数量</th>
        <th>単価</th>
        <th>金額</th>
      </tr>
    </thead>
    <tbody>
      @foreach (var item in Model.Items)
      {
        <tr>
          <td>@item.Name</td>
          <td>@item.Quantity</td>
          <td>¥@item.UnitPrice.ToString("N0")</td>
          <td>¥@((item.Quantity * item.UnitPrice).ToString("N0"))</td>
        </tr>
      }
    </tbody>
  </table>

  <div class="total-row">
    合計(税込): ¥@Model.Total.ToString("N0")
  </div>

  <div class="footer">
    @Model.Company.Name — @Model.Company.Address — @Model.Company.Email
  </div>
</body>
</html>

InvoiceControllerの実装

// Controllers/InvoiceController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MyApp.Models;
using MyApp.Pdf;

[Authorize]
public class InvoiceController : Controller
{
    private readonly IPdfService _pdfService;
    private readonly IRazorViewRenderer _renderer;

    public InvoiceController(IPdfService pdfService, IRazorViewRenderer renderer)
    {
        _pdfService = pdfService;
        _renderer = renderer;
    }

    [HttpGet("/invoices/{invoiceId:int}/pdf")]
    public async Task<IActionResult> Download(int invoiceId, CancellationToken ct)
    {
        // 実際にはDBからデータを取得
        var model = new InvoiceViewModel
        {
            InvoiceNumber = $"INV-{invoiceId:D5}",
            IssuedAt      = DateOnly.FromDateTime(DateTime.Today),
            DueAt         = DateOnly.FromDateTime(DateTime.Today.AddDays(30)),
            Customer      = new CustomerInfo("サンプル株式会社", "東京都千代田区丸の内1-1-1", "billing@example.co.jp"),
            Company       = new CompanyInfo("自社名", "東京都新宿区西新宿2-2-2", "info@mycompany.co.jp"),
            Items =
            [
                new InvoiceItem("FUNBREW PDF Proプラン", 1, 4980m),
                new InvoiceItem("追加API呼び出し 500件", 2, 2000m),
            ],
        };

        var html = await _renderer.RenderToStringAsync("Pdf/Invoice", model);

        try
        {
            var pdfBytes = await _pdfService.GenerateFromHtmlAsync(html, new PdfGenerateOptions
            {
                Format      = "A4",
                Orientation = "portrait",
                Engine      = "quality",
            }, ct);

            return File(pdfBytes, "application/pdf", $"invoice-{invoiceId}.pdf");
        }
        catch (PdfGenerationException ex)
        {
            return StatusCode(502, ex.Message);
        }
    }
}

PDF用CSSのコツはHTML to PDF CSS最適化ガイドを参照してください。請求書PDFの自動化については請求書PDF自動生成ガイドで詳しく解説しています。

非同期処理(BackgroundService / Hosted Service)

大量のPDFやサイズの大きいレポートを生成する場合は、ユーザーをリクエスト中に待たせず、バックグラウンドで生成する設計が適切です。ここではChannel<T>を使ったシンプルなキューパターンを紹介します。

PdfJobQueueの実装

// PdfJobQueue.cs
using System.Threading.Channels;

namespace MyApp.Pdf;

public sealed class PdfJobQueue
{
    private readonly Channel<PdfJob> _channel =
        Channel.CreateBounded<PdfJob>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait,
        });

    public ValueTask EnqueueAsync(PdfJob job, CancellationToken ct = default)
        => _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<PdfJob> ReadAllAsync(CancellationToken ct = default)
        => _channel.Reader.ReadAllAsync(ct);
}

public sealed record PdfJob(
    string JobId,
    string Html,
    string StoragePath,
    PdfGenerateOptions? Options = null);

PdfWorkerサービス

// PdfWorker.cs
using MyApp.Pdf;

public sealed class PdfWorker : BackgroundService
{
    private readonly PdfJobQueue _queue;
    private readonly IPdfService _pdfService;
    private readonly ILogger<PdfWorker> _logger;

    public PdfWorker(PdfJobQueue queue, IPdfService pdfService, ILogger<PdfWorker> logger)
    {
        _queue     = queue;
        _pdfService = pdfService;
        _logger    = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("PdfWorker started.");

        await foreach (var job in _queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                _logger.LogInformation("Processing PDF job {JobId}", job.JobId);

                var pdfBytes = await _pdfService.GenerateFromHtmlAsync(
                    job.Html, job.Options, stoppingToken);

                // 実際にはAzure Blob Storage、S3等に保存する
                await File.WriteAllBytesAsync(job.StoragePath, pdfBytes, stoppingToken);

                _logger.LogInformation("PDF job {JobId} completed. Path={Path}", job.JobId, job.StoragePath);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "PDF job {JobId} failed.", job.JobId);
                // 失敗を記録・通知する(Slack、メール等)
            }
        }
    }
}

Program.csへの登録

// Program.cs(追加)
builder.Services.AddSingleton<PdfJobQueue>();
builder.Services.AddHostedService<PdfWorker>();

Controllerからジョブをエンキュー

// Controllers/ReportController.cs
[ApiController]
[Route("api/[controller]")]
public class ReportController : ControllerBase
{
    private readonly PdfJobQueue _queue;
    private readonly IRazorViewRenderer _renderer;

    public ReportController(PdfJobQueue queue, IRazorViewRenderer renderer)
    {
        _queue    = queue;
        _renderer = renderer;
    }

    [HttpPost("{reportId:int}/generate")]
    public async Task<IActionResult> GenerateAsync(int reportId, CancellationToken ct)
    {
        var html = await _renderer.RenderToStringAsync("Pdf/Report", new { ReportId = reportId });

        var jobId      = Guid.NewGuid().ToString("N");
        var outputPath = $"/tmp/reports/report-{reportId}-{DateTime.UtcNow:yyyyMMdd}.pdf";

        await _queue.EnqueueAsync(new PdfJob(jobId, html, outputPath), ct);

        return Accepted(new
        {
            jobId,
            storagePath = outputPath,
            message     = "PDFの生成を開始しました。完了後にダウンロードできます。",
        });
    }
}

バッチ処理の詳細はPDF一括生成ガイドを参照してください。Webhook連携で非同期PDF生成の完了を通知するパターンはWebhook連携ガイドで解説しています。

テスト(HttpMessageHandler mock)

IHttpClientFactoryを使っているため、テストではHttpMessageHandlerをモックして外部APIを呼び出さずにテストできます。

FakeHttpMessageHandlerの実装

// Tests/FakeHttpMessageHandler.cs
using System.Net;

namespace MyApp.Tests;

public sealed class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;

    public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
    {
        _handler = handler;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
        => Task.FromResult(_handler(request));
}

PdfServiceのユニットテスト

// Tests/PdfServiceTests.cs
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MyApp.Pdf;
using Xunit;

namespace MyApp.Tests;

public sealed class PdfServiceTests
{
    private static IPdfService CreateService(
        Func<HttpRequestMessage, HttpResponseMessage> handler)
    {
        var fakeHandler = new FakeHttpMessageHandler(handler);
        var httpClient  = new HttpClient(fakeHandler);

        var options = Options.Create(new FunbrewPdfOptions
        {
            ApiKey         = "test-api-key",
            ApiUrl         = "https://pdf.funbrew.cloud/api/v1/generate",
            TimeoutSeconds = 30,
        });

        // HttpClientのAuthorizationヘッダーを手動でセット(DI外でテストするため)
        httpClient.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "test-api-key");

        return new PdfService(httpClient, options, NullLogger<PdfService>.Instance);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_ReturnsPdfBytes_WhenSuccessful()
    {
        var fakePdfBytes = "%PDF-1.4 fake content"u8.ToArray();

        var service = CreateService(_ =>
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent(fakePdfBytes)
                {
                    Headers = { ContentType = new("application/pdf") },
                },
            });

        var result = await service.GenerateFromHtmlAsync("<h1>テスト</h1>");

        Assert.Equal(fakePdfBytes, result);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_SendsAuthorizationHeader()
    {
        HttpRequestMessage? capturedRequest = null;

        var service = CreateService(req =>
        {
            capturedRequest = req;
            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent("%PDF"u8.ToArray()),
            };
        });

        await service.GenerateFromHtmlAsync("<h1>テスト</h1>");

        Assert.NotNull(capturedRequest);
        Assert.Equal("Bearer", capturedRequest.Headers.Authorization?.Scheme);
        Assert.Equal("test-api-key", capturedRequest.Headers.Authorization?.Parameter);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_ThrowsPdfGenerationException_OnApiError()
    {
        var service = CreateService(_ =>
            new HttpResponseMessage(HttpStatusCode.InternalServerError)
            {
                Content = new StringContent("Internal Server Error"),
            });

        await Assert.ThrowsAsync<PdfGenerationException>(
            () => service.GenerateFromHtmlAsync("<h1>テスト</h1>"));
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_SendsCorrectJsonPayload()
    {
        string? capturedBody = null;

        var service = CreateService(async req =>
        {
            capturedBody = await req.Content!.ReadAsStringAsync();
            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent("%PDF"u8.ToArray()),
            };
        });

        await service.GenerateFromHtmlAsync("<h1>テスト</h1>", new PdfGenerateOptions
        {
            Format      = "Letter",
            Orientation = "landscape",
        });

        Assert.Contains("\"html\":\"<h1>テスト</h1>\"", capturedBody ?? "");
        Assert.Contains("\"format\":\"Letter\"", capturedBody ?? "");
        Assert.Contains("\"orientation\":\"landscape\"", capturedBody ?? "");
    }
}

WebApplicationFactoryを使ったIntegration Test

// Tests/InvoiceControllerTests.cs
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using MyApp.Pdf;
using Xunit;

namespace MyApp.Tests;

public sealed class InvoiceControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public InvoiceControllerTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    private WebApplicationFactory<Program> CreateFactory(byte[]? fakePdf = null)
    {
        var pdf = fakePdf ?? "%PDF-1.4 fake invoice"u8.ToArray();

        return _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // PdfServiceをモックに差し替え
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(IPdfService));
                if (descriptor is not null)
                    services.Remove(descriptor);

                services.AddScoped<IPdfService>(_ => new FakePdfService(pdf));
            });
        });
    }

    [Fact]
    public async Task Download_ReturnsPdfFile_WithCorrectHeaders()
    {
        var client = CreateFactory().CreateClient();

        var response = await client.GetAsync("/invoices/1/pdf");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
        Assert.Contains("invoice-1.pdf",
            response.Content.Headers.ContentDisposition?.FileName ?? "");
    }

    private sealed class FakePdfService : IPdfService
    {
        private readonly byte[] _pdf;
        public FakePdfService(byte[] pdf) => _pdf = pdf;
        public Task<byte[]> GenerateFromHtmlAsync(
            string html, PdfGenerateOptions? options, CancellationToken ct)
            => Task.FromResult(_pdf);
    }
}

本番運用Tips

Pollyによるリトライ

.NET 8ではMicrosoft.Extensions.Http.Polly(または新しいMicrosoft.Extensions.Http.Resilience)を使ってリトライとサーキットブレーカーを実装できます。

dotnet add package Microsoft.Extensions.Http.Resilience
// Program.cs(リトライ設定)
using Microsoft.Extensions.Http.Resilience;
using Polly;

builder.Services.AddHttpClient<IPdfService, PdfService>(
    "FunbrewPdf",
    (sp, client) =>
    {
        var options = sp.GetRequiredService<IOptions<FunbrewPdfOptions>>().Value;
        client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey);
        client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
    })
    .AddResilienceHandler("pdf-pipeline", builder =>
    {
        // リトライ: 3回、指数バックオフ
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType      = DelayBackoffType.Exponential,
            Delay            = TimeSpan.FromMilliseconds(500),
            ShouldHandle     = args => ValueTask.FromResult(
                args.Outcome.Result?.StatusCode is
                    System.Net.HttpStatusCode.TooManyRequests or
                    System.Net.HttpStatusCode.ServiceUnavailable or
                    System.Net.HttpStatusCode.GatewayTimeout),
        });

        // タイムアウト: 1試行あたり30秒
        builder.AddTimeout(TimeSpan.FromSeconds(30));
    });

エラーハンドリングとリトライ戦略の詳細はエラーハンドリングガイドを参照してください。

PDFのキャッシュ(IMemoryCache)

同じデータから繰り返しPDFを生成する場合は、IMemoryCacheを活用してAPIコールを削減できます。

// CachedPdfService.cs(Decoratorパターン)
using Microsoft.Extensions.Caching.Memory;

namespace MyApp.Pdf;

public sealed class CachedPdfService : IPdfService
{
    private readonly IPdfService _inner;
    private readonly IMemoryCache _cache;

    public CachedPdfService(IPdfService inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<byte[]> GenerateFromHtmlAsync(
        string html,
        PdfGenerateOptions? options = null,
        CancellationToken ct = default)
    {
        // HTMLとオプションからキャッシュキーを生成
        var cacheKey = $"pdf:{ComputeHash(html, options)}";

        if (_cache.TryGetValue(cacheKey, out byte[]? cached))
            return cached!;

        var pdfBytes = await _inner.GenerateFromHtmlAsync(html, options, ct);

        _cache.Set(cacheKey, pdfBytes, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
            Size = pdfBytes.Length,
        });

        return pdfBytes;
    }

    private static string ComputeHash(string html, PdfGenerateOptions? options)
    {
        var input = $"{html}|{options?.Format}|{options?.Orientation}|{options?.Engine}";
        var bytes = System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(bytes)[..16];
    }
}

APIキーのセキュアな管理

本番環境ではappsettings.jsonにAPIキーを直接書かず、Azure Key Vault・AWS Secrets Manager・環境変数を使用します。

// Program.cs(Azure Key Vault使用例)
if (builder.Environment.IsProduction())
{
    var keyVaultUri = builder.Configuration["KeyVaultUri"]!;
    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUri),
        new DefaultAzureCredential());
}

セキュリティのベストプラクティスはセキュリティガイドで詳しく解説しています。

ヘルスチェック

本番環境ではIHealthCheckでPDF APIの疎通確認を実装します。

// PdfApiHealthCheck.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace MyApp.Pdf;

public sealed class PdfApiHealthCheck : IHealthCheck
{
    private readonly IPdfService _pdfService;

    public PdfApiHealthCheck(IPdfService pdfService)
    {
        _pdfService = pdfService;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            await _pdfService.GenerateFromHtmlAsync(
                "<p>health check</p>",
                new PdfGenerateOptions { Engine = "speed" },
                cancellationToken);

            return HealthCheckResult.Healthy("PDF API is reachable.");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("PDF API is not reachable.", ex);
        }
    }
}
// Program.cs(ヘルスチェック登録)
builder.Services.AddHealthChecks()
    .AddCheck<PdfApiHealthCheck>("pdf-api");

app.MapHealthChecks("/health");

本番運用のノウハウはプロダクション運用ガイドにまとめています。

ライブラリ比較

観点 iTextSharp / iText 7 QuestPDF FUNBREW PDF API
ライセンス AGPLv3 / Commercial(有償) MIT SaaS(月額課金)
HTMLサポート 限定的(XML風マークアップ) 非対応(Fluent API) Chromium相当、完全対応
CSSサポート CSS2相当 独自レイアウトAPI 最新CSS完全対応
Razorテンプレート 別途変換必要 非対応 HTML文字列をそのまま送信
日本語 手動フォント設定 手動フォント設定 Noto Sans JP 標準搭載
Dockerイメージ 容易(ただしフォント同梱) 容易 容易(外部API呼び出しのみ)
サーバー負荷 アプリサーバーで処理 アプリサーバーで処理 外部サービスが処理
テスト容易性 モック困難 モック困難 HttpMessageHandlerでモック可能
セットアップ NuGet + フォント設定 NuGet のみ APIキーのみ

使い分けの目安:

  • iText 7: 有償ライセンスを購入済み、PDF仕様への低レベルアクセスが必要な場合
  • QuestPDF: C#コードでレイアウトを定義したい、外部APIを使いたくない場合
  • FUNBREW PDF API: 既存のRazorビューをPDF化したい、Dockerを使っている、日本語PDF、高品質なCSS再現が必要な場合

まとめ

ASP.NET CoreからFUNBREW PDF APIに接続するのは、HttpClientとDIを使えば数十行で完了します。本記事でカバーした設計パターンを適用することで、テストしやすく、本番で安心して使えるPDF生成機能を実装できます。

  1. IHttpClientFactoryでDI設計するIPdfServiceに生成ロジックをカプセル化し、Controllerは薄く保つ
  2. Razorテンプレートを活用するIRazorViewRendererでHTMLを生成し、既存のビューを再利用する
  3. BackgroundServiceで非同期化するChannel<T>PdfWorkerでバックグラウンド処理し、ユーザーを待たせない
  4. FakeHttpMessageHandlerでテストする — 外部API呼び出しをモックして、高速・安定したテストを書く
  5. PollyでリトライするAddResilienceHandlerで一時的な障害に強くする

まずはPlaygroundで自分のHTMLがどんなPDFになるか確認してみてください。準備ができたらドキュメントでAPIの全オプションを確認し、料金プランで最適なプランを選びましょう。

関連記事

Powered by FUNBREW PDF