跳到內容

透過 Connect 批次產生 PDF 並追蹤進度

透過 NextPDF Connect(此引擎的獨立 HTTP 服務發行版),你可以從單一用戶端行程推進一份文件清單,直到全部完成。這份 recipe(範例)會把每筆渲染請求提交到非同步工作 endpoint(端點)POST /api/v1/jobs,以 GET /api/v1/jobs/{id} 輪詢每筆工作,直到進入終止狀態,讀取伺服器回報的逐筆工作 statusprogress 欄位,並從 GET /api/v1/jobs/{id}/result 下載每個完成的 PDF。

工作的生命週期固定且簡短。一筆工作先是 pending,接著 running,然後只會進入一個終止狀態:completedfailed,或 cancelled。當伺服器有追蹤進度時,狀態回應會帶一個 0 到 100 的 progress 整數;每次非終止輪詢也都會帶一個 Retry-After 標頭,告訴你下一次請求前該等多久。為每筆提交附上 Idempotency-Key,這樣重試提交會回傳同一筆工作,而不會啟動第二次渲染。

這份 recipe 採用貼近傳輸層、忠實呈現 wire(連線層)的作法。它直接使用 REST 介面,不假設有任何特定語言的 software development kit(SDK,軟體開發套件),因此同一套流程可移植到任何 HTTP 用戶端。

伺服器端使用標準的 Connect 發行版:

Terminal window
composer require nextpdf/server

下方正式環境範例中的 PHP 用戶端使用符合 PSR-18 與 PSR-17 的 Hypertext Transfer Protocol(HTTP,超文字傳輸協定)用戶端與訊息工廠。安裝你的專案已標準採用的任一實作,例如:

Terminal window
composer require psr/http-client psr/http-factory

非同步工作介面把 提交取回 分開。你不需要為每份文件各自維持一條長時間開啟的 HTTP 連線,而是提交一筆工作、取得一組識別碼,再輪詢一個成本低廉的狀態 endpoint,直到工作完成。正是這種形狀讓批次處理變得可控:用戶端可以同時追蹤 N 筆獨立工作,而不必承擔 N 條被阻塞的連線。

整個流程由三個 endpoint 承載:

  • POST /api/v1/jobs 接受的渲染請求主體與同步的 /api/v1/render endpoint 相同:一個 page_size、一個 orientation,以及一個有序的 operations 陣列。對新工作,它會回傳 201 Created;若某個 Idempotency-Key 對應到你已提交過的工作,則回傳 200 OK
  • GET /api/v1/jobs/{id} 會回傳目前的工作記錄。對非終止的工作,它還會設定一個 Retry-After 標頭(伺服器採用 2 秒間隔)以及一個 poll_url 欄位。請遵循這個標頭,而不要在緊密迴圈中持續輪詢。
  • GET /api/v1/jobs/{id}/result 會以 application/pdf 串流回傳已完成的 PDF。只要工作尚未抵達 completed,它就會回傳 409 Conflict;因此只在狀態輪詢確認進入終止狀態後才呼叫它。

每個成功回應都共用同一個信封結構:一個帶有工作欄位的 data 物件,以及一個 meta 物件,其中含有 request_idtimestampduration_msapi_version。你會讀取的工作欄位都位於 data 之下:data.statusdata.progressdata.job_id,以及已完成工作上的 data.result_url

關於目前這個版本,有一點需要誠實說明。伺服器會在回應 POST 之前就先以行內方式處理提交的工作,所以實務上提交回應已經帶有終止的 status,而結果可能在第一次輪詢時就已就緒。此處記載的輪詢與進度契約,就是穩定的 API 形狀。當處理 backend(後端)轉為佇列式 worker 池時,伺服器仍會維持這個契約不變;因此,一個寫成會輪詢的用戶端今天是正確的,在那項變更之後也依然正確。請把輪詢迴圈寫出來。不要假設第一個回應是非終止的,也不要假設它是終止的。

如同伺服器 OpenAPI 文件與 JobHandler 路由所定義的 Connect 非同步工作 REST 介面:

  • POST /api/v1/jobs:提交一筆渲染工作。可選的 Idempotency-Key 請求標頭。主體是一筆渲染請求(operations 為必填,且至少需含一個操作)。回應:201 為新工作、200 為冪等重播、422 為無效主體、409 為冪等衝突、429 為遭速率限制。
  • GET /api/v1/jobs/{id}:輪詢狀態。回應 200 並附上工作記錄;非終止期間會帶有 Retry-After 標頭;若工作不存在或屬於另一個用戶端,則回傳 404
  • GET /api/v1/jobs/{id}/result:下載 PDF。當工作為 completed 時,回傳 200 application/pdf;尚未完成時回傳 409;未知時回傳 404
  • DELETE /api/v1/jobs/{id}:取消一筆 pendingrunning 工作,或刪除一筆 completed 工作(204)。

工作記錄在 data 之下帶有以下欄位,完全依照伺服器序列化它們的方式。

  • job_id:識別碼(一個 job_ 前綴加上 24 個十六進位字元)。
  • status:為 pendingrunningcompletedfailedcancelled 其中之一。前兩者為非終止;後三者為終止。
  • created_at,以及一旦設定後的 started_atcompleted_at:ISO-8601 時間戳記。
  • progress:一個 0 到 100 的整數,僅在伺服器為該工作追蹤進度時才存在;否則不存在(視為未知)。
  • error:一個訊息字串,僅在 failed 工作上才存在。
  • result_url:僅在 completed 工作上才存在;指向結果下載的路徑。
  • poll_url:僅在工作為非終止時才存在。

驗證採用 Authorization 標頭中的 bearer token(持有者權杖):Authorization: Bearer npk_live_{kid}_{secret}

這會在 wire 把單一工作從頭到尾推進一次,讓你看清楚這三次呼叫以及它們回傳的欄位。它會提交、輪詢一次,然後下載。下方的正式環境範例加上了批次迴圈、Retry-After 等待,以及完整的錯誤處理。

Terminal window
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

這是一個自給自足的用戶端。它會提交一批渲染請求,限制同時在飛的工作數量,依照伺服器透過 Retry-After 要求的節奏輪詢每筆工作,回報伺服器提供的 progress,下載每個完成的 PDF,並記錄各項失敗。它使用 PSR-18 HTTP 用戶端與 PSR-17 工廠(Connect recipe 標準採用的傳輸契約),並為每次呼叫可能拋出的例外捕捉最具體的型別:以 Psr\Http\Client\ClientExceptionInterface 對應傳輸失敗,並以一個具型別的 BatchJobException 對應批次無法繼續處理的伺服器回應。沒有任何 catch 區塊是空的。每一個都會記錄並重新拋出,或記下一個明確定義的結果。

請把行內的 $documents 清單換成你自己的輸入。在建構式預期 PSR 介面之處,注入你專案實際使用的 HTTP 用戶端與工廠。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

預期的 STDOUT,每份文件一行;路徑取決於你的輸出目錄:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • 讀取工作欄位時要在 data 之下,而非最上層。 每個成功回應都包在一個 { "data": ..., "meta": ... } 信封結構中。data.statusdata.progress 是你會據以行動的欄位;meta 則帶有用於支援關聯的 request_id
  • progress 可能不存在。 伺服器只在為該工作追蹤進度時,才會納入 progress。請把缺漏的欄位視為「未知」而非零,並以一定會存在的 status 來驅動你的迴圈。
  • 提交可能已經是終止狀態。 在目前的版本中,伺服器會在回應 POST 之前就以行內方式渲染,所以提交回應可能帶有 status: completed,且結果可能在第一次輪詢時就已就緒。你的輪詢迴圈必須能在第零次嘗試就接受終止狀態,而不是堅持要先看到 pending
  • 遵循 Retry-After 非終止的狀態回應會設定 Retry-After(2 秒間隔)。輪詢更快只會浪費請求並招來 429。請把這個值夾限在合理區間,而不要盲目信任它。
  • 在完成前呼叫 /result 會得到 409 只在狀態輪詢顯示 completed 之後才呼叫結果 endpoint。一個 409 Conflict 代表工作尚未完成;它並非傳輸錯誤。
  • Idempotency-Key 可防止重複作業。 用相同金鑰重試提交會回傳原本的工作(回傳 200 而非 201)。請為每份文件使用穩定的金鑰,這樣網路重試永遠不會啟動第二次渲染。用相同金鑰但搭配 不同 主體會構成 409 衝突。
  • 工作以擁有者為範圍。 用某把 API 金鑰提交的工作對另一把金鑰是不可見的;跨擁有者的 GET 會回傳 404 而非 403。請用你提交時所用的同一組憑證來輪詢。
  • failed 工作會帶有一則 error 訊息。 讀取 data.error——在終止的 failed 狀態上——並記錄下來。不要盲目重試。

一次批次的成本,是各筆渲染加上輪詢額外開銷的總和。用戶端這一側有兩個可控制的調節桿。第一,限制並行:maxInFlight 上限固定了同時追蹤多少筆工作,讓用戶端的開啟請求數與記憶體用量不論批次大小都保持平穩。請把它設成與伺服器的 worker 數量相符,而不要更高;在飛工作數多於 worker 只會拉長每筆工作的佇列等待時間。第二,尊重輪詢間隔:每次輪詢都是一次成本低廉的狀態讀取,但緊密迴圈會放大請求量並觸發速率限制器。伺服器的 2 秒 Retry-After 是恰當的預設值,而 runner 會夾限在 1 到 30 秒的區間,這樣一筆緩慢的工作既不會忙碌空轉,也不會卡住整個視窗。

對極大的批次,請分視窗處理(runner 使用 array_chunk),而不要一開始就全部提交出去。這同時限制了用戶端追蹤的狀態與伺服器的佇列深度,因此格式錯誤或過大的批次會在某個視窗內失敗,而不是在數千筆提交之後才失敗。

  • 不要讓 bearer token 出現在日誌與 URL 中。 API 金鑰只會在 Authorization 標頭中傳遞。絕不可把它放進查詢字串、日誌行,或任何寫出的產物中。runner 只會記錄 job_idstatus,絕不記錄憑證。
  • 從伺服器可控的金鑰衍生輸出路徑。 runner 會以你的程式碼所選的文件鍵,接到一個固定的輸出目錄,來建構每條輸出路徑,絕不從伺服器回應中的值來建構。不要把工作欄位內插到檔案系統路徑中,那會打開一條路徑遍歷的破口。
  • 驗證下載的位元組。 runner 會在寫入檔案之前,先檢查 200——亦即來自 /result 的回應——其開頭是否為 %PDF 標頭。一個成功的下載狀態本身並不能證明主體就是 PDF。
  • 在檢查之前,把結果視為不可信任。 工作已完成只代表伺服器渲染出了位元組,不代表那些位元組可以安全地轉送。在把結果交給用戶端或下游系統之前,先讓它通過一道結構檢查步驟。
  • 使用最小權限的金鑰。 非同步工作介面屬於 core 層的渲染。請發給這個批次一把恰好涵蓋它所需操作範圍的金鑰,並依你的密鑰管理政策所定的排程輪替它。
  • 限制輪詢預算。 maxPolls 可阻止卡住的工作永遠占住用戶端。批次會把逾時記錄成一個結果,而不是阻塞,這讓一筆故障的工作不致於拒絕服務其餘工作。

這份 recipe 不做任何規範性的標準主張。它會取用 NextPDF Connect 的非同步工作 REST endpoints(POST /api/v1/jobsGET /api/v1/jobs/{id}GET /api/v1/jobs/{id}/result),並讀取伺服器所定義的工作記錄欄位(statusprogresserrorresult_urlpoll_url)。對已下載結果做的 %PDF 標頭檢查,只確認回應開頭帶有 PDF 標記;它並非有效性或符合性的判定。若要對一組文件做標準檢查,請使用 Enterprise 批次符合性工具。請參閱 透過 Connect 進行批次標準檢查,那是與此處所涵蓋的渲染工作不同的介面。