Bỏ qua để đến nội dung

Lỗi như một tính năng

Spec: ISO 9241-110, §5.6.4 Evidence: Code-backed

NextPDF xem hệ thống phân cấp exception của mình như một bề mặt API, được thiết kế cẩn thận không kém các phương thức ném ra exception đó. Một lỗi luôn cụ thể, có kiểu, có thể được bắt ở đúng mức chi tiết bạn cần, và mang theo ngữ cảnh có cấu trúc cho nhật ký của bạn.

Trang này cho thấy bề mặt đó ngay trong mã nguồn của engine: kiểu cơ sở, các lớp con có kiểu, các named constructor gắn nguyên nhân gốc vào thông điệp, và ngữ cảnh có cấu trúc mà mọi exception của NextPDF đều cung cấp.

Một thông điệp lỗi là lúc engine lên tiếng với bạn vào thời điểm tệ nhất có thể: môi trường production, 2 giờ a.m. sáng, và một tài liệu lẽ ra đã phải xuất xong. Nội dung của thông điệp đó khi ấy quyết định bước tiếp theo là một bản sửa lỗi hay một cuộc điều tra dài.

Một RuntimeException: something went wrong chung chung chẳng cho bạn manh mối nào để đi tiếp. Nó cho bạn biết engine đã thất bại, nhưng không cho biết cái gì thất bại, ở đâu, và chắc chắn không cho biết phải làm gì. Hướng dẫn về yếu tố con người nói rất rõ về điều này. Một lỗi nên tự giải thích đủ rõ để việc sửa lỗi trở thành bước tiếp theo hiển nhiên, chứ không phải một đề tài nghiên cứu ( Spec: ISO 9241-110, §5.6.4.3 ). Một exception nêu rõ nguyên nhân và cách khắc phục không phải là chi tiết trang trí. Nó là sự khác biệt giữa một bản sửa năm phút và một bản sửa năm tiếng.

  • Mọi lỗi của NextPDF đều kế thừa từ một lớp cơ sở trừu tượng, NextPdfException, nên bạn có thể bắt mọi lỗi của thư viện bằng một kiểu duy nhất.
  • Bên dưới nó là các lớp con cụ thể, có kiểu — một phông chữ không tìm thấy, một cấu hình không hợp lệ, một thao tác chữ ký bị lỗi — để bạn có thể bắt đúng lỗi mà mình xử lý được.
  • Mọi exception của NextPDF đều triển khai ContextAwareExceptionInterface và phơi bày getContext(): một map có cấu trúc, an toàn để ghi nhật ký, nên bạn không bao giờ phải phân tích chuỗi thông điệp để lấy lại thông tin chẩn đoán.
  • Các thông điệp đều có thể hành động được: named constructor gắn nguyên nhân gốc thực sự (và thường cả cách sửa) vào thông điệp, thay vì dùng một khuôn mẫu chung chung.
  • Mỗi lớp exception đều ghi rõ ai có thể xử lý nó — lập trình viên, hạ tầng, hay bên gọi thư viện — nên việc phân loại bắt đầu ngay cả trước khi bạn đọc stack trace.

Hệ thống phân cấp này nông và có chủ đích. Nó có một lớp cơ sở, một tầng các kiểu chuyên biệt theo lĩnh vực, và một hợp đồng mà tất cả đều tuân theo.

Một lớp cơ sở, bắt-tất-cả theo thiết kế. NextPdfException là lớp trừu tượng, kế thừa RuntimeException, và triển khai ContextAwareExceptionInterface:

abstract class NextPdfException extends RuntimeException implements ContextAwareExceptionInterface
{
/** @return array<string, mixed> */
public function getContext(): array
{
return [];
}
}

Việc để lớp này là trừu tượng là một quyết định có chủ đích. Bạn không bao giờ vô tình bắt phải lớp cơ sở mơ hồ, vì nó không bao giờ được ném ra trực tiếp. Bạn bắt nó một cách có chủ đích, như một phương án dự phòng, và bắt một lớp con cụ thể khi có thể làm điều gì đó cụ thể.

Các lớp con cụ thể, có kiểu. Một phông chữ bị thiếu không phải là lỗi chung chung; nó là FontNotFoundException, và nó mang theo dữ liệu bạn cần để hành động:

final class FontNotFoundException extends NextPdfException
{
public function __construct(
private readonly string $fontName,
private readonly array $searchPaths,
private readonly bool $fallbackAttempted,
?Throwable $previous = null,
) {
parent::__construct(
\sprintf('Font "%s" not found. Searched: [%s].', $fontName, \implode(', ', $searchPaths)),
0,
$previous,
);
}
// getFontName(), getSearchPaths(), wasFallbackAttempted(), getContext()
}

Thông điệp nêu rõ phông chữ và chính xác các đường dẫn đã được tìm kiếm. Bạn không phải đoán thư mục nào bị thiếu; exception cho bạn biết ngay điều đó.

Ngữ cảnh có cấu trúc, không phải bóc tách chuỗi. Mọi exception đều trả về một map dạng snake_case, chỉ gồm kiểu nguyên thủy, an toàn để chuỗi hóa thẳng vào nhật ký hay payload APM:

public function getContext(): array
{
return [
'config_key' => $this->configKey,
'given_value' => $this->givenValue,
'expected_type' => $this->expectedType,
];
}

Hợp đồng nêu rõ lý do. Một middleware ghi nhật ký có thể gọi $logger->error($e->getMessage(), $e->getContext()) cho bất kỳ exception nào của NextPDF mà không bao giờ phải phân tích thông điệp. Thông điệp dành cho con người. Ngữ cảnh dành cho máy móc. Không bên nào phải đóng vai của bên kia.

Thông điệp có thể hành động được thông qua named constructor. Đây là lúc lỗi không còn là chuyện tình cờ mà trở thành thứ được thiết kế. SignatureException không chỉ nói “signing failed at level B-LT”. Nó cung cấp các named constructor gắn nguyên nhân gốc thực sự, và thường là cả cách khắc phục chính xác, vào thông điệp:

public static function tsaUrlEmpty(string $signatureLevel): self
{
return new self('', $signatureLevel, null,
'TSA endpoint URL is empty: pass a non-empty `tsaUrl` to the TsaClient '
. 'constructor (e.g. "https://timestamp.example.com/tsa") or remove the '
. 'TSA client wiring if no timestamping is required at this signature level');
}

Thông điệp nêu rõ điều gì sai và phải làm gì với nó. Có các constructor cùng nhóm cho gói khả năng bị thiếu, HTTP client vắng mặt, một thuật toán chỉ-tạo-digest bị chọn nhầm, một loại khóa không khớp với thuật toán, và nhiều trường hợp khác nữa. Mỗi constructor biến một nhóm lỗi thành một câu mà lập trình viên có thể hành động dựa vào, không cần đọc mã nguồn của engine.

Những lỗi cố tình gây ồn ào. Một số exception tồn tại chính là để một lỗ hổng âm thầm trở nên ồn ào. NotImplementedException mang theo một nhãn feature có thể grep được bằng máy và một tham chiếu followUp:

final class NotImplementedException extends NextPdfException
{
public function __construct(
public readonly string $feature,
public readonly string $followUp,
?Throwable $previous = null,
) {
parent::__construct(
\sprintf('%s is not implemented in this release. %s', $feature, $followUp),
0, $previous,
);
}
}

Một nhánh đã chạm tới nhưng chưa được nối dây sẽ ném ra exception này thay vì trả về một no-op trông có vẻ hợp lý. Cùng ý tưởng đó dẫn dắt StrictModeViolation; các lớp con của nó mang theo một nhãn ngắn có thể grep được cho cấu trúc đi chệch hướng, cùng với ngữ cảnh vị trí và trích dẫn tùy chọn. Một sai lệch so với spec trở thành một điểm dừng có kiểu, kèm ngữ cảnh, chứ không âm thầm tạo ra kết xuất sai.

Siêu dữ liệu phân loại nằm ngay trong lớp. Mỗi lớp exception nêu rõ ai có thể xử lý nó ngay trong docblock. Ví dụ, FontNotFoundException là “Developer (verify font path) or Infrastructure (fix file permissions)”. InvalidConfigException là “Developer (fix configuration before calling NextPDF)”. NotImplementedException là “Library callers — either remove the call or pin to a future release”. Việc phân loại bắt đầu trước cả stack trace, vì câu hỏi “đây là việc của tôi hay của vận hành?” đã có sẵn câu trả lời.

Bảng dưới đây tóm tắt thiết kế và lợi ích mà mỗi đặc tính mang lại cho bạn.

Đặc tính thiết kếTrong mã nguồnLợi ích mang lại cho bạn
Một lớp cơ sở trừu tượngNextPdfException (trừu tượng, triển khai interface ngữ cảnh)Bắt mọi lỗi của thư viện bằng một kiểu duy nhất, không vô tình bắt phải lớp cơ sở mơ hồ
Các lớp con cụ thể, có kiểuFontNotFoundException, InvalidConfigException, SignatureException, …Bắt đúng lỗi mà bạn xử lý được
Ngữ cảnh có cấu trúcgetContext() — chỉ kiểu nguyên thủy dạng snake_caseGhi nhật ký hoặc gửi tới APM mà không cần phân tích chuỗi thông điệp
Thông điệp có thể hành động đượcNamed constructor gắn nguyên nhân gốc + cách khắc phụcMột câu bạn có thể hành động dựa vào, không phải một khuôn mẫu
Cố tình gây ồn àoNotImplementedException, StrictModeViolationMột lỗ hổng âm thầm trở thành một điểm dừng có kiểu, có thể grep được
Siêu dữ liệu phân loại”Actionable by:” trong docblock của mỗi lớpBiết đó là vấn đề của ai trước khi đọc trace

Trang này là Evidence: Code-backed : mọi lớp, chữ ký phương thức, và hình dạng thông điệp đều được trích dẫn từ namespace exception của engine, không phải được diễn giải lại.

  • Lớp cơ sở trừu tượng và hợp đồng ContextAwareExceptionInterface của nó, các lớp con có kiểu, hình dạng của getContext(), và các named constructor của SignatureException đều được trích dẫn nguyên văn từ mã nguồn.
  • Các dòng phân loại “Actionable by:” chính là các hợp đồng nằm trong docblock của lớp, ở chính những tệp đó.
  • Nền tảng về yếu tố con người là Spec: ISO 9241-110 — §5.6.4.3, về những lỗi tự giải thích đủ rõ để có thể sửa, và nguyên tắc tính bền vững trước lỗi sử dụng ở §6. Engine xem lập trình viên là người dùng và exception là giao diện phải thỏa mãn những điều khoản đó.

Bắt rộng để làm phương án dự phòng, bắt cụ thể ở nơi bạn có thể hành động, và đưa ngữ cảnh có cấu trúc thẳng vào logger của bạn — không cần phân tích thông điệp.

<?php
declare(strict_types=1);
use NextPDF\Core\Document;
use NextPDF\Exception\FontNotFoundException;
use NextPDF\Exception\NextPdfException;
use Psr\Log\LoggerInterface;
function renderInvoice(LoggerInterface $logger): ?string
{
try {
$document = Document::createStandalone();
$document->setTitle('Invoice 2026-0042');
$document->addPage();
$document->setFont('BrandSans', '', 12);
$document->cell(0, 10, 'Thank you for your business.', newLine: true);
return $document->getPdfData();
} catch (FontNotFoundException $e) {
// Specific: we can recover — fall back to a built-in font.
// getContext() is log-safe structured data, not a parsed string.
$logger->warning($e->getMessage(), $e->getContext());
return null; // caller re-renders with 'helvetica'
} catch (NextPdfException $e) {
// Backstop: any other NextPDF failure, still with structured context.
$logger->error($e->getMessage(), $e->getContext());
return null;
}
}

Khối catch cụ thể khôi phục được vì kiểu exception đã cho nó biết rằng việc khôi phục là khả thi. Phương án dự phòng ghi nhật ký ngữ cảnh có cấu trúc cho mọi trường hợp còn lại. Không lúc nào ứng dụng phải đọc thông điệp để biết chuyện gì đã xảy ra.

Cách hiểu sai thường gặp là một cây exception sâu là kỹ thuật hóa quá mức, và một kiểu lỗi duy nhất sẽ đơn giản hơn. Nó đơn giản hơn cho engine và tệ hơn cho bạn. Một kiểu duy nhất nghĩa là mọi lỗi đều là một stack trace chung chung, còn logic khôi phục trở thành phép so khớp chuỗi. Phép so khớp đó rất mong manh; lần diễn đạt lại thông điệp tiếp theo sẽ phá vỡ nó. Một hệ thống phân cấp nhỏ, cụ thể đưa kiến thức đó vào hệ thống kiểu, nơi trình biên dịch và các khối catch của bạn có thể tận dụng.

Một hiểu lầm thứ hai là thông điệp và ngữ cảnh bị dư thừa. Không phải vậy. Thông điệp là văn xuôi dành cho con người đọc trong một dòng nhật ký. Ngữ cảnh là một map có kiểu dành cho việc định tuyến trong mã, cảnh báo, hay bảng điều khiển. Trộn lẫn hai thứ này chính là cái bẫy phân tích chuỗi mà hợp đồng getContext() tồn tại để loại bỏ.

Hệ thống phân cấp này nông một cách có chủ đích. NextPDF không tạo một lớp exception riêng cho mọi lỗi có thể hình dung ra. Nó chỉ tạo một lớp khi việc bắt riêng chính lỗi đó là điều mà một bên gọi hợp lý sẽ làm. Chia tách quá mức chỉ đổi vấn đề phân tích chuỗi lấy vấn đề một danh sách catch phình to.

getContext() được cấu trúc cho nhật ký và APM, nên theo hợp đồng nó chỉ trả về các kiểu nguyên thủy và danh sách kiểu nguyên thủy, không có đối tượng lồng nhau. Đó là ngữ cảnh chẩn đoán, không phải ảnh chụp đã chuỗi hóa của nội bộ engine. Nó cũng không phải là một định dạng truyền tải ổn định để xây dựng các schema bên ngoài dựa vào.

Trang này mô tả bề mặt thiết kế của exception. Tập hợp chính xác các exception và các trường của chúng tiến hóa theo engine. Các lớp và hình dạng được trích dẫn ở đây là hiện hành tính đến lần xem xét này và mang tính minh họa cho hợp đồng, không phải một danh mục cố định. Hợp đồng — một lớp cơ sở, các lớp con có kiểu, ngữ cảnh có cấu trúc, thông điệp có thể hành động được — mới là phần ổn định.

  • Code-backed (mức độ bằng chứng) — một trang mà các tuyên bố của nó được đối chiếu với chính mã nguồn của engine, được trích dẫn thay vì diễn giải lại.
  • Context-aware exception — một exception của NextPDF triển khai ContextAwareExceptionInterface và phơi bày getContext(). Phương thức đó trả về một map dạng snake_case gồm các trường chẩn đoán nguyên thủy, an toàn để chuỗi hóa vào nhật ký hay payload APM mà không cần phân tích chuỗi thông điệp.
  • Named constructor — một phương thức factory tĩnh (ví dụ SignatureException::tsaUrlEmpty()) tạo ra một exception với thông điệp gắn vào một nguyên nhân gốc cụ thể và, thường là, cách khắc phục của nó.
  • PAdES — PDF Advanced Electronic Signatures, họ profile của ETSI dành cho việc ký PDF. Được viết đầy đủ ở lần dùng đầu tiên; trình bày sâu trên các trang về ký.
  • TSA — Time-Stamping Authority, dịch vụ đáng tin cậy cấp các dấu thời gian RFC 3161 được dùng bởi các profile PAdES cấp cao hơn.