首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >PHP 错误、异常与结果类型!如何设计可靠的失败边界

PHP 错误、异常与结果类型!如何设计可靠的失败边界

作者头像
Tinywan
发布2026-09-15 15:06:58
发布2026-09-15 15:06:58
270
举报
文章被收录于专栏:开源技术小栈开源技术小栈

引言

大多数支付代码在正常路径上看起来都很简单:校验金额、调用提供方、保存支付尝试记录、返回响应。

真正难的是,当这条流程没能走完时,该怎么处理。缺少支付方式、银行卡被拒、提供方不可用、提供方响应格式非法,以及我们自己代码里的一个 TypeError——这些全都是失败,但它们不是同一种失败

如果把它们全都当成异常来处理,我们的接口代码最终会捕获很宽泛的类型,把每个问题都变成一个含糊的错误。如果把它们全都变成 false、null 或者带个 status 键的数组,那重要的含义在调用方做出正确决策之前就已经丢失了。

真正有用的问题不是"我们该用异常还是结果类型?",而是:

应用的哪一层拥有这个失败?它接下来能有意义地做什么?

本文中,我们将只用纯 PHP 构建一个合作方支付工作流。我们会区分非法输入、预期内的业务拒绝、瞬时的提供方故障和编程缺陷。然后,只在拥有其呈现方式的边界处转换每一种失败,测试契约,并在不把支付数据写进日志的前提下让运维路径可见。

一次支付,四种不同的失败

先来看一段看起来很"防御性"、但实际上制造了危险失败边界的代码:

function capturePayment(PaymentGateway $gateway, PaymentAttempt $attempt): array {    try {        $outcome = $gateway->capture($attempt);         return [            'status' => 201,            'body' => $outcome,        ];    } catch (Throwable $exception) {        error_log($exception->getMessage());         return [            'status' => 422,            'body' => ['message' => 'Payment failed.'],        ];    }}

这个函数把好几种截然不同的情况全都坍缩成了一个 422 响应:

1.  输入中缺少支付方式 ID。

2.  客户余额不足。

3.  合作方 API 超时。

4.  我们的代码在该传整数金额的地方传了字符串。

这些情况需要不同的处理方式。第一种应该在支付用例运行之前就被拒绝。第二种是预期内的业务结果,调用方可以解释并据此行动。第三种可能可以重试,应该变成 503 响应或后台恢复。第四种是缺陷,需要告警并返回安全的通用 500 响应,而不是告诉客户"换张卡试试"。

我们的工作流有四个失败边界:

不可信输入          支付应用层           合作方提供方───────────────    ─────────────────    ────────────────  字段非法 ──────▶ 校验结果 ──────────▶ 不进入支付流程      │      ▼  业务决策 ──────▶ 扣款成功或被拒      │      ▼  传输故障 ──────▶ 重试或稍后恢复      │      ▼  编程缺陷 ──────▶ 上报并安全停止

这张图故意画得很简单:失败越往应用中心走,应该变得越具体。在外层边界,输入只是不可信的数据。在支付工作流内部,拒付是一个有意义的决策。在提供方边界,超时是一个传输问题。而类型错误既不是支付决策也不是传输问题——它是我们的代码违反了契约的信号。

失败边界即设计本身

在给异常命名之前,先写下每一种失败意味着什么。对于我们的合作方支付,以下区分是有用的:

缺失或格式错误的输入是预期内的,属于 API 客户端或 UI 的责任。返回带字段错误的校验结果,比如 422 Unprocessable Entity 响应。根本不要进入支付流程。

银行卡或支付方式被拒也是预期内的,但它是一个业务结果。返回结果类型,让客户或调用工作流选择下一步。Web 接口可能返回 422;CLI 命令可能打印拒付原因并返回一个已知的退出码。

商品无法支付是另一条业务规则。当调用方可以用备选方案继续时,用结果类型;当当前操作必须停止时,用领域异常。两种情况都不应该重试。

超时、限流或提供方不可用可能是瞬时的。用可重试异常来表示,这样后台 worker 或恢复工作流可以有意识地重试,最终呈现一个安全的 503,并在尝试耗尽时告警。

提供方凭证无效或提供方契约变更是工程团队的问题,不是客户的问题。它们需要不可重试异常、通用的 500 呈现和运维告警。

TypeError、方法不存在或不变量被打破是编程缺陷。让 Error 传播到进程边界,在那里可以被上报和安全渲染。不要把它变成支付拒付。

这些不是通用的状态码。Web 接口、CLI 命令和定时 worker 不会以同样的方式呈现同一个结果。重要的决策是归属权

例如,银行卡拒付在支付边界通常是预期内的。调用网络 API 询问一张卡能否扣款,仍然是业务流程的一部分。超时则不同:我们不能声称支付失败了,因为超时只说明我们没有收到响应

当涉及到钱时,这个区别非常重要。超时不是提供方什么都没做的证据。提供方可能已经扣款了,只是响应在返回给我们的路上丢了。盲目地用新标识符重试,可能会向客户扣两次款。

PHP 不止有 Exception

PHP 通过 Throwable 接口表示每一个可抛出的值。两个主要分支是 Exception 和 Error:

Throwable├── Exception│   ├── RuntimeException│   ├── InvalidArgumentException│   └── 应用和库异常└── Error    ├── TypeError    ├── ValueError    ├── AssertionError    └── 引擎和编程错误

Exception 是我们通常用来描述被中断操作的分支。提供方不可用、导入文件无法读取、领域规则被违反,在以下情况下可以用异常表示。

Error 表示更低层级的失败,通常由非法代码或运行时契约被打破引起。给严格类型的方法传错类型会导致 TypeError。调用未定义的方法会导致 Error。这些失败实现了 Throwable,但不应该被转换成普通的业务行为。

这就是为什么这两个 catch 含义截然不同:

try {    $gateway->capture($attempt);} catch (Exception $exception) {    // 处理 Exception 及其子类,但不包括 Error。}

try {    $gateway->capture($attempt);} catch (Throwable $throwable) {    // 同时处理 Exception 和 Error。}

catch (Throwable) 在真正的进程边界偶尔有用。命令运行器可以用它把 throwable 交给一个集中的错误渲染器。PHP 还提供了 set_exception_handler() 来处理到达进程顶部的未捕获 throwable。

但在接口、应用服务、领域对象或提供方适配器内部,这几乎从来不是正确的选择。这些层应该只捕获它们理解的异常类型。在支付尝试周围捕获 Throwable 然后返回 false,会让一个 TypeError 看起来和一次本应的拒付一模一样。

也不要把异常的消息当作错误码来用。消息是写给人看的,可能被翻译,也会在日常维护中变化。类名、稳定的领域原因或专用属性才应该承载分支判断的信息。

结果类型用于正常的备选结果

PHP 没有像 Rust 的 Result 或 Swift 的 Result 那样的原生代数结果类型。但我们仍然可以用一个接口和几个聚焦的值对象,来建模一个小而明确的正常结果集合。

对于扣款操作,成功和提供方拒付都是正常的备选结果。两种情况下调用方都需要决定下一步,所以返回一个值比为拒付抛出异常更清晰:

interface PaymentOutcome { } final readonly class PaymentCaptured implements PaymentOutcome {    public function __construct(        public string $providerPaymentId,    ) {}} final readonly class PaymentDeclined implements PaymentOutcome {    public function __construct(        public string $reason,    ) {}} final readonly class PaymentDetails {    public function __construct(        public int $amountInCents,        public string $currency,        public string $paymentMethodId,    ) {}} final readonly class PaymentAttempt {    public function __construct(        public string $id,        public PaymentDetails $payment,    ) {}} interface PaymentGateway {    public function capture(PaymentAttempt $attempt): PaymentOutcome;}

类型签名告诉每一个调用方:一次成功的方法调用有两种可能的业务结果。我们不可能意外地忽略一次拒付——因为没有 null 值可以忘记检查,也没有可能和实现失败混淆的魔法 false。

应用服务可以把两条路径写得很明确:

interface PaymentAttempts {    public function start(PaymentDetails $payment): PaymentAttempt;     public function markCaptured(string $attemptId, string $providerPaymentId): void;     public function markDeclined(string $attemptId, string $reason): void;     public function markFailed(string $attemptId, string $reason): void;} final readonly class CapturePayment {    public function __construct(        private PaymentGateway $gateway,        private PaymentAttempts $attempts,    ) {}     public function handle(PaymentDetails $payment): PaymentOutcome    {        $attempt = $this->attempts->start($payment);        $outcome = $this->gateway->capture($attempt);         if ($outcome instanceof PaymentCaptured) {            $this->attempts->markCaptured($attempt->id, $outcome->providerPaymentId);             return $outcome;        }         $this->attempts->markDeclined($attempt->id, $outcome->reason);         return $outcome;    }}

这段代码假设 PaymentOutcome 只有这两个实现。PHP 无法强制接口是封闭的,所以不要让这个集合随意增长。当第三种结果变得有用时,有意识地添加它,更新每一个呈现层,并判断它到底是正常的备选结果,还是应该成为异常的中断。

结果类型也有权衡:

   它们让预期内的备选结果在方法签名和测试中可见。

   它们把普通控制流留在 try/catch 块之外。

   当每个小助手函数都返回另一个包装器时,代码会变得嘈杂。

   它们不能替代异常来处理 I/O 故障、配置缺失,或调用方在同一流程中无法合理处理的情况。

当调用方有有意义的下一步动作时,用结果。当正常工作无法继续、控制流必须离开当前路径时,用异常。

在边界处转换提供方失败

提供方集成说的是它自己的语言:连接失败、响应码、提供方特定的载荷。我们应用的其他部分不应该需要知道这些细节。

提供方适配器是把这种语言翻译成支付语言的正确位置。首先,定义跨越这个边界的异常:

final class PaymentProviderUnavailable extends RuntimeException {    public function __construct(        public readonly string $provider,        Throwable $previous,    ) {        parent::__construct(            message: "Payment provider [{$provider}] is unavailable.",            previous: $previous,        );    }     /** @return array */    public function logContext(): array    {        return ['provider' => $this->provider];    }} final class PaymentProviderRequestFailed extends RuntimeException {    public function __construct(        public readonly string $provider,        Throwable $previous,    ) {        parent::__construct(            message: "Payment provider [{$provider}] rejected the integration request.",            previous: $previous,        );    }}

两个异常都把原始 throwable 保留为 previous。这是异常转换,不是异常擦除。我们的日志和错误追踪器仍然能看到传输失败,而应用代码可以捕获 PaymentProviderUnavailable 而不需要导入提供方的库。

添加的上下文故意很小。提供方名称和尝试 ID 能帮助运维人员诊断集成问题。原始请求头、授权令牌、完整请求体、银行卡数据,以及复制进异常消息的提供方响应,都不应该出现在应用日志里。

把具体的传输实现藏在一个小的提供方面向契约后面,让支付边界保持纯 PHP:

enum PartnerCaptureStatus {    case Captured;    case Declined;    case RetryableFailure;    case RequestFailure;} final readonly class PartnerCaptureResponse {    public function __construct(        public PartnerCaptureStatus $status,        public ?string $providerPaymentId = null,        public ?string $declineReason = null,    ) {}} final class PartnerConnectionFailed extends RuntimeException { } interface PartnerPaymentApi {    public function capture(PaymentAttempt $attempt): PartnerCaptureResponse;}

这个接口故意是提供方特定的。执行 HTTP 请求的代码可以把提供方的响应映射成 PartnerCaptureResponse。我们应用的其他部分只看到稳定的支付契约:

final readonly class PartnerPaymentGateway implements PaymentGateway {    public function __construct(        private PartnerPaymentApi $api,    ) {}     public function capture(PaymentAttempt $attempt): PaymentOutcome    {        try {            $response = $this->api->capture($attempt);        } catch (PartnerConnectionFailed $exception) {            throw new PaymentProviderUnavailable('partner-pay', $exception);        }         return match ($response->status) {            PartnerCaptureStatus::Captured => new PaymentCaptured(                $response->providerPaymentId                    ?? throw new PaymentProviderRequestFailed(                        'partner-pay',                        new RuntimeException('Partner Pay omitted the payment ID.'),                    ),            ),            PartnerCaptureStatus::Declined => new PaymentDeclined(                $response->declineReason ?? 'payment_declined',            ),            PartnerCaptureStatus::RetryableFailure => throw new PaymentProviderUnavailable(                'partner-pay',                new RuntimeException('Partner Pay reported a temporary failure.'),            ),            PartnerCaptureStatus::RequestFailure => throw new PaymentProviderRequestFailed(                'partner-pay',                new RuntimeException('Partner Pay rejected the integration request.'),            ),        };    }}

具体的映射属于提供方适配器。另一个提供方可能用状态码、成功响应里的某个字段,或提供方特定的异常来表示拒付。把那些词汇留在边缘。向应用的其他部分返回我们稳定的 PaymentDeclined 值。

有一个细节容易被忽略:每一次调用都应该用尝试记录的稳定 ID 作为提供方的幂等键。传输代码负责怎么发送这个值,但应用层负责这个值本身。只有当提供方文档说明用同一个键重复调用会返回或收敛到同一笔扣款时,我们才重试支付。

重试需要幂等契约

重试本身不会让支付变安全。幂等契约才让重试变安全。

在调用提供方之前,创建一条持久化的支付尝试记录,带一个类似 payment_attempt_01J... 的标识符。用这个标识符作为提供方的幂等键。当最终的提供方支付 ID 已知时,持久化它。

然后定义每一种不确定状态下该做什么:

支付尝试已持久化      │      ▼用稳定幂等键发送提供方请求      │      ├── 收到拒付 ───────────────▶ 标记尝试为已拒付      ├── 收到扣款 ───────────────▶ 标记尝试为已扣款      └── 超时或连接丢失 ─────────▶ 用同一个键重试或查询提供方

如果超时发生在提供方已经扣款之后,下一次用同一个键的调用绝不能创建第二笔扣款。有些提供方提供了按幂等键或支付 ID 查询的接口。当提供方文档要求对账而不是重复扣款请求时,就用它。

后台 worker 可以只重试表示临时性提供方故障的异常。这个小例子用 sleep() 让策略可见。在生产环境中,应该由进程管理器或任务系统来调度下一次尝试,而不是让一个 worker 空等:

final readonly class RetryPaymentAttempt {    private const array BACKOFF_SECONDS = [0, 30, 120, 600];     public function __construct(        private PaymentGateway $gateway,        private PaymentAttempts $attempts,    ) {}     public function handle(PaymentAttempt $attempt): PaymentOutcome    {        $lastException = null;         foreach (self::BACKOFF_SECONDS as $delay) {            if ($delay > 0) {                sleep($delay);            }             try {                return $this->capture($attempt);            } catch (PaymentProviderRequestFailed $exception) {                $this->attempts->markFailed($attempt->id, 'provider_request_failed');                 throw $exception;            } catch (PaymentProviderUnavailable $exception) {                $lastException = $exception;            }        }         $this->attempts->markFailed($attempt->id, 'provider_unavailable');         throw $lastException ?? new LogicException('A payment retry must fail with an exception.');    }     private function capture(PaymentAttempt $attempt): PaymentOutcome    {        $outcome = $this->gateway->capture($attempt);         if ($outcome instanceof PaymentCaptured) {            $this->attempts->markCaptured($attempt->id, $outcome->providerPaymentId);             return $outcome;        }         $this->attempts->markDeclined($attempt->id, $outcome->reason);         return $outcome;    }}

PaymentProviderUnavailable 用有界退避重试。PaymentProviderRequestFailed 立即失败,因为错误的凭证或变更的提供方契约不会在几次快速重试后自行好转。正常的 PaymentDeclined 作为值返回,因为 worker 已经完成了它的业务职责。

这个例子故意很小。真实的 worker 必须原子化地记录尝试、防止两个 worker 并发处理同一笔支付尝试,并使用持久化调度器。但失败模型保持不变:只重试明确定义的瞬时条件,用同一个幂等键,并让耗尽状态可见。

在外层边界呈现失败

输入校验属于支付应用服务之前。一个纯 PHP 校验器可以把不可信输入变成有效值,或一个明确的错误列表:

interface PaymentInputResult { } final readonly class ValidPaymentInput implements PaymentInputResult {    public function __construct(        public PaymentDetails $payment,    ) {}} final readonly class InvalidPaymentInput implements PaymentInputResult {    /** @param array $errors */    public function __construct(        public array $errors,    ) {}} final class PaymentInputValidator {    /** @param array $input */    public function validate(array $input): PaymentInputResult    {        $errors = [];        $amount = filter_var($input['amount_in_cents'] ?? null, FILTER_VALIDATE_INT);        $currency = $input['currency'] ?? null;        $paymentMethodId = $input['payment_method_id'] ?? null;         if (! is_int($amount) || $amount < 1) {            $errors['amount_in_cents'] = 'The amount must be a positive integer.';        }         if (! is_string($currency) || preg_match('/^[A-Z]{3}$/', $currency) !== 1) {            $errors['currency'] = 'The currency must be a three-letter uppercase code.';        }         if (! is_string($paymentMethodId) || $paymentMethodId === '') {            $errors['payment_method_id'] = 'A payment method is required.';        }         if ($errors !== []) {            return new InvalidPaymentInput($errors);        }         return new ValidPaymentInput(new PaymentDetails(            amountInCents: $amount,            currency: $currency,            paymentMethodId: $paymentMethodId,        ));    }}

边界现在可以把校验结果和支付结果转换成 HTTP 形式的数组,而不让传输细节泄漏到支付逻辑中:

function capturePaymentEndpoint(    array $input,    PaymentInputValidator $validator,    CapturePayment $capturePayment,): array {    $validation = $validator->validate($input);     if ($validation instanceof InvalidPaymentInput) {        return [            'status' => 422,            'body' => ['errors' => $validation->errors],        ];    }     try {        $outcome = $capturePayment->handle($validation->payment);    } catch (PaymentProviderUnavailable $exception) {        reportPaymentFailure($exception, 'unknown');         return [            'status' => 503,            'body' => ['message' => 'Payments are temporarily unavailable.'],        ];    }     if ($outcome instanceof PaymentCaptured) {        return [            'status' => 201,            'body' => [                'status' => 'captured',                'payment_id' => $outcome->providerPaymentId,            ],        ];    }     return [        'status' => 422,        'body' => [            'status' => 'declined',            'reason' => $outcome->reason,        ],    ];}

同一个 CapturePayment 服务可以从 CLI 命令调用,而不需要假装它是 HTTP。命令可以打印 Payment declined: insufficient_funds 并返回一个已知的退出码。它可以让 PaymentProviderUnavailable 异常到达命令边界,在那里命令记录尝试 ID 并以失败退出,让运维人员或调度器决定下一步。

注意我们没有加什么:没有在每个方法周围都包一个 try/catch。接口拥有其正常 PaymentOutcome 的呈现。进程边界拥有未处理提供方异常的安全呈现。这让两条路径都很明确,而不需要重复响应代码。

只在边界改变含义时才包装

包装每一个异常没有用。下面这段没有增加任何信息,还把原始类型对已经理解它的调用方藏了起来:

try {    return $gateway->capture($attempt);} catch (PaymentProviderUnavailable $exception) {    throw new PaymentProviderUnavailable('partner-pay', $exception);}

网关已经表达了支付层面的含义。应用服务应该让它直接通过。

当异常跨越到不同的词汇表时,包装才有用。PartnerConnectionFailed 是传输层面的关注点;PaymentProviderUnavailable 是支付层面的关注点。存储层异常是持久化关注点;如果支付边界需要向调用方传达持久化记录失败,PaymentAttemptCouldNotBeRecorded 异常可能是有用的。

转换时,保留前一个异常,只添加新层拥有的上下文:

throw new PaymentProviderUnavailable(    provider: 'partner-pay',    previous: $exception,);

避免以下模式:

   在普通应用代码中捕获 Throwable

   把每个异常都变成 false、null 或空集合

   为每个没有业务含义的提供方状态都新建一个异常类

   根据异常消息做分支判断

   捕获异常只为了记日志然后再重新抛出,这可能导致重复上报

   把请求体、令牌、银行卡数据或提供方错误载荷放进异常消息

   在没有幂等契约的情况下,因为失败"可能是临时的"就重试支付

好的异常层级是小的。它描述一个边界需要区分的失败模式,而不是每一行可能抛出的代码。

测试失败契约

失败处理是行为。在边界处测试契约,而不是断言某个内部 catch 块执行了。

对于提供方适配器,用一个小的内存 fake。它返回提供方响应,并记录收到的稳定尝试 ID。Pest 让预期行为一目了然:

final class FakePartnerPaymentApi implements PartnerPaymentApi {    /** @var list */    public array $receivedAttemptIds = [];     public function __construct(        private PartnerCaptureResponse $response,    ) {}     public function capture(PaymentAttempt $attempt): PartnerCaptureResponse    {        $this->receivedAttemptIds[] = $attempt->id;         return $this->response;    }} it('returns a decline for an expected provider rejection', function (): void {    $api = new FakePartnerPaymentApi(new PartnerCaptureResponse(        status: PartnerCaptureStatus::Declined,        declineReason: 'insufficient_funds',    ));    $gateway = new PartnerPaymentGateway($api);     $outcome = $gateway->capture(new PaymentAttempt(        id: 'payment-attempt-123',        payment: new PaymentDetails(2_900, 'USD', 'payment-method-123'),    ));     expect($outcome)        ->toBeInstanceOf(PaymentDeclined::class)        ->and($outcome->reason)->toBe('insufficient_funds')        ->and($api->receivedAttemptIds)->toBe(['payment-attempt-123']);});

重要的断言是:提供方声明的拒付变成了一个 PaymentDeclined 值。它不是可重试异常,也不会变成通用服务器错误。

用一个抛出 PartnerConnectionFailed 的 fake 单独测试传输边界。断言应该证明网关把它转换成了 PaymentProviderUnavailable,并把原始 throwable 保留为 getPrevious()。

然后添加对外部世界重要的测试:

1.  非法输入产生字段错误,且不调用网关。

2.  拒付返回文档化的结果,且不上报提供方故障。

3.  提供方超时返回安全的 503 响应,记录尝试 ID 和提供方名称,但不记录敏感信息。

4.  后台重试在每一次提供方调用中都使用同一个支付尝试 ID。

5.  不可重试的集成失败立即停止,不重复调用。

6.  一次模糊的提供方调用后的超时,在尝试新的扣款之前,用同一个幂等键进行对账。

无敏感信息的可观测性

异常给了我们类型、消息、前一个 throwable、堆栈跟踪,以及我们附加的任何上下文。只有当上下文能帮助运维人员行动而不暴露客户数据时,这才有价值。

在进程边界,记录一个小的结构化事件:

function reportPaymentFailure(PaymentProviderUnavailable $exception, string $attemptId): void {    error_log(json_encode([        'event' => 'payment.capture.failed',        'attempt_id' => $attemptId,        'exception' => $exception::class,        ...$exception->logContext(),    ], JSON_THROW_ON_ERROR));}

记录包含事件名、支付尝试 ID、异常类和提供方。在安全的情况下,让完整的前一个 throwable 对你的错误追踪器可用,但不要把可观测性策略建立在把敏感字符串复制进每一行日志上。

监控 payment.capture.failed 事件数量、重试次数、待处理尝试的时长,以及最老的未对账尝试。提供方故障应该变成一个可观测的事件,而不是一堆缓慢增长的卡住的行。

实用运维清单

在上线一个支付集成之前,检查这些问题:

1.  每一笔支付尝试都能用一个持久化、稳定的 ID 标识吗?

2.  在提供方支持的情况下,每一次提供方扣款都用那个 ID 作为幂等键吗?

3.  超时路径会对账已有的提供方支付,而不是创建新的扣款吗?

4.  校验失败在调用提供方之前就被拒绝了吗?

5.  正常的拒付是作为明确的值返回,而不是宽泛的异常吗?

6.  只有瞬时的提供方故障被重试,且有有界退避和最大尝试次数吗?

7.  无效凭证和提供方契约故障被阻止了无限重试吗?

8.  worker 防止了同一笔尝试的并发处理吗?

9.  异常报告包含尝试 ID、提供方和安全的关联数据,但不包含请求体、请求头、令牌或支付详情吗?

10.  运维人员能看到失败尝试、重试次数、提供方错误率和最老的未对账支付尝试吗?

11.  有没有文档化的修复命令或运行手册来对账模糊的尝试?

12.  测试覆盖了客户、运维人员和后台 worker 各自看到的失败行为吗?

如果其中任何一个答案是"否",再加一个 catch 块不会让系统更安全。缺失的通常是一个边界、一次状态转换,或一个运维决策。

结论

PHP 错误、异常和结果类型不是互相竞争的工具。它们描述的是不同种类的信息。

   在边缘用校验结果处理格式错误的输入。

   用小的结果类型处理调用方可以继续的预期内业务备选结果。

   用转换后的异常处理被中断的操作,比如提供方不可用。

   让编程错误保持可见,而不是把它们伪装成正常的支付失败。

合作方支付适配器拥有提供方细节,并把它们翻译成支付语言。应用服务拥有支付尝试状态。接口或命令拥有呈现。后台 worker 拥有延迟重试和恢复。进程边界拥有未处理异常的安全渲染和上报。

从一个当前捕获了宽泛异常或返回含糊布尔值的工作流开始。命名它的失败模式,判断哪些是正常的备选结果,在集成边界保留上下文,为每个调用方接下来能做什么写测试。这一小步设计,会把失败从事后补救变成应用其余部分可以信任的契约。

希望你喜欢这篇文章,如果喜欢,别忘了分享给朋友!回见!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-09-06,如有侵权请联系 cloudcommunity@tencent.com 删除
目录
  • 引言
  • 一次支付,四种不同的失败
  • 失败边界即设计本身
  • PHP 不止有 Exception
  • 结果类型用于正常的备选结果
  • 在边界处转换提供方失败
  • 重试需要幂等契约
  • 在外层边界呈现失败
  • 只在边界改变含义时才包装
  • 测试失败契约
  • 无敏感信息的可观测性
  • 实用运维清单
  • 结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档