Skip to content

自定义异常类

概述

PHP 允许开发者通过继承 Exception(或其子类)来创建自定义异常类。自定义异常可以携带特定的业务信息,使异常处理更加精确和语义化。

良好的自定义异常设计是应用程序错误处理策略的核心。通过定义有意义的异常类型,可以让调用方精确捕获特定异常,避免笼统的 try/catch。

版本说明

自定义异常类在所有 PHP 版本中可用。PHP 8.0+ 建议为自定义异常添加类型声明、使用 readonly 属性(PHP 8.1+)、以及构造器属性提升。

基础概念

为什么需要自定义异常

php
<?php

declare(strict_types=1);

// 使用通用异常的问题:
try {
    // 不同业务场景都抛出 RuntimeException
    throw new RuntimeException('用户不存在');
    throw new RuntimeException('余额不足');
} catch (RuntimeException $e) {
    // 无法区分是哪种业务异常
}

// 使用自定义异常的优势:
try {
    throw new UserNotFoundException('u_123');
} catch (UserNotFoundException $e) {
    // 精确处理用户不存在
} catch (InsufficientBalanceException $e) {
    // 精确处理余额不足
}

自定义异常的要素

  1. 类名:清晰表达异常含义(后缀为 Exception
  2. 继承关系:继承合适的基类(Exception、RuntimeException 等)
  3. 属性:携带业务上下文信息
  4. 构造函数:接收必要的上下文参数
  5. 异常码:用于 API 响应和日志分析

语法与代码

基本自定义异常

php
<?php

declare(strict_types=1);

class ValidationException extends RuntimeException
{
    /** @var array<string, string> */
    private array $errors;

    /**
     * @param array<string, string> $errors  字段 => 错误消息
     */
    public function __construct(array $errors, ?Throwable $previous = null)
    {
        $this->errors = $errors;
        parent::__construct('数据验证失败', 422, $previous);
    }

    /**
     * @return array<string, string>
     */
    public function errors(): array
    {
        return $this->errors;
    }

    public function firstError(): string
    {
        return reset($this->errors);
    }
}

// 使用
try {
    $errors = [
        'email' => '邮箱格式不正确',
        'age'   => '年龄必须大于0',
    ];
    throw new ValidationException($errors);
} catch (ValidationException $e) {
    echo $e->getMessage();           // 数据验证失败
    echo $e->getCode();              // 422
    print_r($e->errors());          // ['email' => '...', 'age' => '...']
    echo $e->firstError();          // 邮箱格式不正确
}

带业务上下文的异常

php
<?php

declare(strict_types=1);

class EntityNotFoundException extends RuntimeException
{
    private string $entityType;
    private string $entityId;

    public function __construct(
        string $entityType,
        string $entityId,
        ?Throwable $previous = null
    ) {
        $this->entityType = $entityType;
        $this->entityId = $entityId;

        $message = "{$entityType}(ID: {$entityId}) 未找到";
        parent::__construct($message, 404, $previous);
    }

    public function entityType(): string
    {
        return $this->entityType;
    }

    public function entityId(): string
    {
        return $this->entityId;
    }
}

// 使用
try {
    throw new EntityNotFoundException('Order', 'ORD-001');
} catch (EntityNotFoundException $e) {
    echo $e->getMessage();         // Order(ID: ORD-001) 未找到
    echo $e->entityType();        // Order
    echo $e->entityId();          // ORD-001
    echo $e->getCode();           // 404
}

PHP 8.1+ 构造器属性提升

php
<?php

declare(strict_types=1);

// PHP 8.1+ 使用 readonly 和构造器属性提升
class ApiException extends RuntimeException
{
    public function __construct(
        private readonly string $publicMessage,
        private readonly string $internalCode,
        int $httpStatus = 500,
        ?Throwable $previous = null
    ) {
        parent::__construct($this->publicMessage, $httpStatus, $previous);
    }

    public function publicMessage(): string
    {
        return $this->publicMessage;
    }

    public function internalCode(): string
    {
        return $this->internalCode;
    }
}

// 使用
throw new ApiException(
    publicMessage: '请求频率过高',
    internalCode: 'RATE_LIMIT_EXCEEDED',
    httpStatus: 429
);

详细说明

异常消息格式

php
<?php

declare(strict_types=1);

// 异常消息应包含足够上下文
// 但不应包含敏感信息(密码、密钥等)

// 好的消息
throw new RuntimeException("文件 /tmp/data.csv 读取失败: Permission denied");
throw new EntityNotFoundException('User', '123');
throw new InvalidArgumentException("参数 \$amount 必须大于 0,收到 -1");

// 差的消息(缺乏上下文)
throw new RuntimeException('Error');
throw new RuntimeException('Failed');
throw new RuntimeException('操作失败');

// 好的消息 + 异常属性
class PaymentException extends RuntimeException
{
    public function __construct(
        private readonly string $orderId,
        private readonly string $reason,
        ?Throwable $previous = null
    ) {
        $message = "订单 {$orderId} 支付失败: {$reason}";
        parent::__construct($message, 0, $previous);
    }

    public function orderId(): string { return $this->orderId; }
    public function reason(): string { return $this->reason; }
}

自定义异常码

php
<?php

declare(strict_types=1);

// 使用枚举定义异常码(PHP 8.1+)
enum AppErrorCode: int
{
    case NotFound = 404;
    case ValidationFailed = 422;
    case Unauthorized = 401;
    case Forbidden = 403;
    case Conflict = 409;
    case InternalError = 500;
}

class AppException extends RuntimeException
{
    public function __construct(
        private readonly AppErrorCode $errorCode,
        string $detail = '',
        ?Throwable $previous = null
    ) {
        $message = $detail !== '' ? $detail : $errorCode->name;
        parent::__construct($message, $errorCode->value, $previous);
    }

    public function errorCode(): AppErrorCode
    {
        return $this->errorCode;
    }
}

// 使用
throw new AppException(
    AppErrorCode::NotFound,
    "用户不存在"
);

异常链(Previous Exception)

php
<?php

declare(strict_types=1);

class OrderService
{
    public function createOrder(array $data): int
    {
        try {
            $this->validateData($data);
            return $this->insertOrder($data);
        } catch (ValidationException $e) {
            // 包装异常,保留原始异常
            throw new OrderCreationException(
                '订单创建失败: ' . $e->getMessage(),
                previous: $e
            );
        }
    }

    private function validateData(array $data): void
    {
        // ...
    }

    private function insertOrder(array $data): int
    {
        return 1;
    }
}

class OrderCreationException extends RuntimeException {}

class ValidationException extends RuntimeException {}

实战示例

完整的业务异常体系

php
<?php

declare(strict_types=1);

// 基础异常
abstract class BaseException extends Exception
{
    public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}

// 用户相关异常
class UserException extends BaseException {}

class UserNotFoundException extends UserException
{
    public function __construct(string $userId, ?Throwable $previous = null)
    {
        parent::__construct("用户 {$userId} 不存在", 404, $previous);
    }
}

class UserAlreadyExistsException extends UserException
{
    public function __construct(string $email, ?Throwable $previous = null)
    {
        parent::__construct("邮箱 {$email} 已被注册", 409, $previous);
    }
}

class InvalidCredentialsException extends UserException
{
    public function __construct(?Throwable $previous = null)
    {
        parent::__construct('用户名或密码错误', 401, $previous);
    }
}

// 支付相关异常
class PaymentException extends BaseException {}

class PaymentFailedException extends PaymentException
{
    public function __construct(
        private readonly string $transactionId,
        private readonly string $reason,
        ?Throwable $previous = null
    ) {
        $message = "交易 {$transactionId} 失败: {$reason}";
        parent::__construct($message, 400, $previous);
    }
}

// 使用示例
class UserService
{
    public function register(string $email, string $password): int
    {
        if ($this->emailExists($email)) {
            throw new UserAlreadyExistsException($email);
        }

        return $this->createUser($email, $password);
    }

    public function login(string $email, string $password): array
    {
        $user = $this->findByEmail($email);
        if ($user === null) {
            throw new InvalidCredentialsException();
        }

        if (!$this->verifyPassword($password, $user['password'])) {
            throw new InvalidCredentialsException();
        }

        return $user;
    }

    private function emailExists(string $email): bool { return false; }
    private function createUser(string $email, string $password): int { return 1; }
    private function findByEmail(string $email): ?array { return null; }
    private function verifyPassword(string $raw, string $hash): bool { return true; }
}

// 调用方可以精确捕获
try {
    $userService = new UserService();
    $userService->register('existing@mail.com', 'password');
} catch (UserAlreadyExistsException $e) {
    echo "邮箱已注册: " . $e->getMessage();
} catch (UserException $e) {
    echo "用户相关错误: " . $e->getMessage();
} catch (Throwable $e) {
    echo "系统错误: " . $e->getMessage();
}

注意事项

不要创建过多的异常类

php
<?php

declare(strict_types=1);

// 反面示例:过度细分
class EmailTooShortException extends ValidationException {}
class EmailTooLongException extends ValidationException {}
class EmailInvalidFormatException extends ValidationException {}
class EmailDomainNotAllowedException extends ValidationException {}

// 正面示例:使用属性区分
class ValidationException extends RuntimeException
{
    public function __construct(
        private readonly string $field,
        private readonly string $rule,
        private readonly mixed $value,
        ?Throwable $previous = null
    ) {
        $message = "字段 {$field} 验证失败: {$rule}";
        parent::__construct($message, 422, $previous);
    }

    public function field(): string { return $this->field; }
    public function rule(): string { return $this->rule; }
    public function value(): mixed { return $this->value; }
}

异常应该是不可变的

php
<?php

declare(strict_types=1);

// PHP 8.1+ 使用 readonly 确保异常不可变
class DomainException extends RuntimeException
{
    public function __construct(
        private readonly string $domain,
        private readonly string $reason,
        ?Throwable $previous = null
    ) {
        parent::__construct("{$domain} 错误: {$reason}", 0, $previous);
    }

    public function domain(): string { return $this->domain; }
    public function reason(): string { return $this->reason; }
}

最佳实践

  1. 异常名使用 PascalCase + Exception 后缀:如 UserNotFoundException
  2. 继承合适的基类LogicExceptionRuntimeException
  3. 包含业务上下文:通过属性携带业务相关信息
  4. 设置有意义的异常码:如 HTTP 状态码或自定义错误码
  5. 使用异常链:通过 previous 参数保留原始异常
php
<?php

declare(strict_types=1);

// 推荐的自定义异常模板(PHP 8.1+)
class BusinessException extends RuntimeException
{
    public function __construct(
        string $message,
        int $code = 0,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}

class NotFoundException extends BusinessException
{
    public function __construct(
        string $entity,
        string|int $id,
        ?Throwable $previous = null
    ) {
        parent::__construct(
            "{$entity}({$id}) 未找到",
            404,
            $previous
        );
    }
}

参考链接