异常处理,是现代编程语言的重要特性,也是写出健壮、优雅代码的关键。

在PHP中,异常处理(Exception Handling)允许我们在程序出现错误时,优雅地捕获和处理错误,而不是让程序直接崩溃。通过异常处理,我们可以把错误处理逻辑和业务逻辑分离,让代码更清晰、更易维护;我们可以统一处理错误,记录日志,给用户友好的提示;我们可以在错误发生后,进行清理和恢复,让程序继续运行。

在之前的文章中,我们学习了PHP的各种功能开发,但对异常处理涉及不多。今天,我们来详细学习PHP异常处理,从基础语法到最佳实践,从内置异常到自定义异常,帮你写出更健壮、更优雅的PHP代码。

为什么需要异常处理

在没有异常处理的时代,PHP的错误处理主要依赖错误报告和返回值。

传统错误处理的问题

// 传统方式:通过返回值判断错误
$fp = fopen('file.txt', 'r');
if (!$fp) {
    die('无法打开文件');
}

// 传统方式:通过错误号判断
$result = mysql_query($sql);
if (!$result) {
    die('查询失败:' . mysql_error());
}

这种方式的问题:

  1. 错误处理和业务逻辑混杂:每个函数调用后都要检查返回值,代码冗长,可读性差
  2. 容易忽略错误:如果忘记检查返回值,错误就被忽略了,可能导致更严重的问题
  3. 错误信息不统一:不同的函数,错误返回方式不同(有的返回false,有的返回null,有的返回错误码),处理起来麻烦
  4. 无法传递错误上下文:返回值只能返回简单的错误信息,无法传递详细的错误上下文(错误码、错误消息、出错位置、堆栈信息等)
  5. 无法集中处理错误:错误分散在各个地方,无法统一记录日志、统一提示用户

异常处理的优势

异常处理,解决了上述问题:

  1. 错误处理和业务逻辑分离:业务逻辑写在try块中,错误处理写在catch块中,代码清晰
  2. 错误不会被忽略:异常如果不捕获,会导致程序致命错误,迫使开发者处理
  3. 错误信息统一:所有异常都是Exception类的实例,有统一的接口(getMessage、getCode、getFile、getLine、getTraceAsString等)
  4. 可以传递详细的错误上下文:异常对象可以包含错误消息、错误码、出错文件、出错行号、堆栈跟踪等详细信息
  5. 可以集中处理错误:通过全局异常处理器,可以统一捕获和处理所有未捕获的异常
  6. 可以分层处理:异常可以在适当的层级捕获和处理,也可以向上抛出,由上层处理

PHP异常处理基础

try-catch

最基本的异常处理结构:

try {
    // 业务逻辑,可能抛出异常的代码
    $result = riskyOperation();
    echo "操作成功:$result";
} catch (Exception $e) {
    // 捕获异常,处理错误
    echo "操作失败:" . $e->getMessage();
    // 记录日志、清理资源、给用户提示等
}

执行流程

  1. 执行try块中的代码
  2. 如果try块中没有抛出异常,跳过catch块,继续执行后面的代码
  3. 如果try块中抛出异常,立即跳转到对应的catch块,执行异常处理代码
  4. catch块执行完毕后,继续执行后面的代码

抛出异常:throw

使用throw语句抛出异常:

function divide($a, $b) {
    if ($b == 0) {
        throw new Exception('除数不能为零');
    }
    return $a / $b;
}

try {
    echo divide(10, 0);
} catch (Exception $e) {
    echo '错误:' . $e->getMessage(); // 输出:错误:除数不能为零
}

throw后面跟一个Exception类(或其子类)的实例。抛出异常后,当前函数的执行立即停止,异常向上传播,直到被某个catch块捕获。

Exception类的常用方法

try {
    throw new Exception('错误消息', 100);
} catch (Exception $e) {
    echo $e->getMessage();      // 错误消息
    echo $e->getCode();          // 错误码:100
    echo $e->getFile();          // 出错的文件
    echo $e->getLine();          // 出错的行号
    echo $e->getTrace();         // 堆栈跟踪(数组)
    echo $e->getTraceAsString(); // 堆栈跟踪(字符串)
    echo $e->__toString();       // 异常的字符串表示
}

多个catch块

可以有多个catch块,捕获不同类型的异常:

try {
    // 可能抛出多种异常的代码
    $operation = $_GET['op'];
    if ($operation == 'db') {
        throw new PDOException('数据库连接失败');
    } elseif ($operation == 'file') {
        throw new RuntimeException('文件操作失败');
    } else {
        throw new Exception('未知错误');
    }
} catch (PDOException $e) {
    // 处理数据库异常
    echo '数据库错误:' . $e->getMessage();
} catch (RuntimeException $e) {
    // 处理运行时异常
    echo '运行时错误:' . $e->getMessage();
} catch (Exception $e) {
    // 处理其他所有异常(必须放在最后)
    echo '未知错误:' . $e->getMessage();
}

注意:catch块的顺序很重要,子类异常必须放在父类异常前面。因为异常匹配是按顺序的,如果把Exception放在最前面,所有异常都会被它捕获,后面的catch块永远不会执行。

finally

finally块,无论是否抛出异常,都会执行。通常用于清理资源(关闭文件、数据库连接、释放锁等)。

try {
    $fp = fopen('file.txt', 'r');
    if (!$fp) {
        throw new Exception('无法打开文件');
    }
    // 处理文件
    $content = fread($fp, filesize('file.txt'));
    echo $content;
} catch (Exception $e) {
    echo '错误:' . $e->getMessage();
} finally {
    // 无论是否出错,都会执行,确保文件被关闭
    if (isset($fp) && $fp) {
        fclose($fp);
    }
    echo '执行完毕';
}

finally块的特点:

  • 无论try块是否抛出异常,finally块都会执行
  • 即使try或catch块中有return、break、continue,finally块也会执行
  • finally块通常用于清理资源,确保资源被释放

PHP的异常体系

内置异常类

PHP有一些内置的异常类,形成了一个继承体系:

Exception(所有异常的基类)
├── ErrorException(错误转异常)
├── LogicException(逻辑错误,代码层面的错误)
│   ├── BadFunctionCallException
│   ├── BadMethodCallException
│   ├── DomainException
│   ├── InvalidArgumentException(无效参数)
│   ├── LengthException
│   └── OutOfRangeException
└── RuntimeException(运行时错误,环境层面的错误)
    ├── OutOfBoundsException
    ├── OverflowException
    ├── PDOException(数据库异常)
    ├── RangeException
    ├── UnderflowException
    └── UnexpectedValueException

LogicException vs RuntimeException

  • LogicException:逻辑错误,是代码本身的问题,应该在开发阶段发现和修复。比如传递了无效的参数(InvalidArgumentException)、调用了不存在的方法(BadMethodCallException)。
  • RuntimeException:运行时错误,是环境或外部因素导致的,代码本身没有问题。比如数据库连接失败、文件不存在、网络超时。

合理使用这些内置异常类,可以让错误分类更清晰,处理更精准。

自定义异常

除了内置异常类,我们还可以自定义异常类,继承Exception或其子类:

// 自定义数据库异常
class DatabaseException extends RuntimeException {
    public function __construct($message, $code = 0, Exception $previous = null) {
        parent::__construct($message, $code, $previous);
    }
}

// 自定义验证异常
class ValidationException extends LogicException {
    private $errors;
    
    public function __construct($message, array $errors = [], $code = 0, Exception $previous = null) {
        parent::__construct($message, $code, $previous);
        $this->errors = $errors;
    }
    
    public function getErrors() {
        return $this->errors;
    }
}

// 自定义业务异常
class BusinessException extends Exception {
    // 业务异常,可以包含业务相关的属性和方法
}

// 使用自定义异常
function getUser($id) {
    if (!is_numeric($id) || $id <= 0) {
        throw new InvalidArgumentException('无效的用户ID');
    }
    
    try {
        $user = $db->query("SELECT * FROM users WHERE id = $id")->fetch();
    } catch (PDOException $e) {
        throw new DatabaseException('数据库查询失败', 500, $e);
    }
    
    if (!$user) {
        throw new BusinessException('用户不存在', 404);
    }
    
    return $user;
}

// 捕获不同类型的异常
try {
    $user = getUser($_GET['id']);
    echo json_encode($user);
} catch (InvalidArgumentException $e) {
    http_response_code(400);
    echo json_encode(['error' => $e->getMessage()]);
} catch (ValidationException $e) {
    http_response_code(422);
    echo json_encode(['error' => $e->getMessage(), 'errors' => $e->getErrors()]);
} catch (DatabaseException $e) {
    error_log('数据库错误:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo json_encode(['error' => '服务器内部错误']);
} catch (BusinessException $e) {
    http_response_code($e->getCode() ?: 400);
    echo json_encode(['error' => $e->getMessage()]);
} catch (Exception $e) {
    error_log('未知错误:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo json_encode(['error' => '服务器内部错误']);
}

自定义异常的好处:

  1. 错误分类更清晰:不同类型的错误,用不同的异常类表示
  2. 可以携带额外信息:自定义异常可以添加额外的属性和方法(如ValidationException的errors数组)
  3. 可以精准捕获和处理:不同类型的异常,可以有不同的处理方式
  4. 代码更易读:看到抛出的异常类型,就知道是什么类型的错误

异常链(异常嵌套)

PHP 5.3+支持异常链,可以在抛出新异常时,把原始异常作为previous传入,保留完整的错误上下文:

try {
    // 原始操作
    $db->query($sql);
} catch (PDOException $e) {
    // 抛出新异常,把原始异常作为previous
    throw new DatabaseException('查询用户失败', 500, $e);
}

// 捕获后,可以获取原始异常
try {
    getUser(1);
} catch (DatabaseException $e) {
    echo '错误:' . $e->getMessage();
    $previous = $e->getPrevious();
    if ($previous) {
        echo '原始错误:' . $previous->getMessage();
    }
    // 完整的堆栈跟踪
    echo $e->getTraceAsString();
}

异常链的好处:

  1. 保留完整的错误上下文:不会因为包装异常而丢失原始错误信息
  2. 便于调试:可以追溯到最原始的错误原因
  3. 分层错误处理:底层抛出原始异常,上层包装为业务异常,同时保留原始信息

全局异常处理器

对于未被捕获的异常,可以设置全局异常处理器,统一处理:

// 设置全局异常处理器
set_exception_handler(function (Exception $e) {
    // 记录错误日志
    error_log('未捕获的异常:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    
    // 生产环境:给用户友好的提示,不暴露详细错误信息
    if (ENV === 'production') {
        http_response_code(500);
        echo '服务器内部错误,请稍后重试';
    } else {
        // 开发环境:显示详细错误信息
        echo '<h1>未捕获的异常</h1>';
        echo '<p><strong>消息:</strong>' . $e->getMessage() . '</p>';
        echo '<p><strong>文件:</strong>' . $e->getFile() . ' 第 ' . $e->getLine() . ' 行</p>';
        echo '<pre>' . $e->getTraceAsString() . '</pre>';
    }
});

全局异常处理器的作用:

  1. 防止程序崩溃:未捕获的异常会导致致命错误,全局处理器可以优雅地处理
  2. 统一错误处理:所有未捕获的异常,都在这里统一处理(记录日志、提示用户)
  3. 生产环境安全:生产环境不暴露详细错误信息,避免泄露敏感信息

错误转异常:ErrorException

PHP的传统错误(warning、notice等),默认不是异常,不会被try-catch捕获。但可以用seterrorhandler把错误转换为ErrorException:

// 设置错误处理器,把错误转换为异常
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
    // 如果错误被@抑制,不抛出异常
    if (!(error_reporting() & $errno)) {
        return false;
    }
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

// 现在,传统错误也会被try-catch捕获
try {
    $result = 10 / 0; // 会触发warning,现在转为ErrorException
} catch (ErrorException $e) {
    echo '错误:' . $e->getMessage(); // 输出:错误:Division by zero
}

这样,所有的错误(异常和传统错误)都可以用统一的异常处理机制来处理。

PHP 7+的Error类

PHP 7引入了Error类,用于处理一些传统的致命错误(如调用不存在的方法、类型错误等)。Error和Exception都实现了Throwable接口。

// PHP 7+ 可以捕获Error
try {
    $obj = null;
    $obj->method(); // 调用null的方法,PHP 7会抛出Error
} catch (Error $e) {
    echo '错误:' . $e->getMessage();
} catch (Exception $e) {
    echo '异常:' . $e->getMessage();
}

// 也可以用Throwable同时捕获Error和Exception
try {
    // 代码
} catch (Throwable $e) {
    echo '错误或异常:' . $e->getMessage();
}

PHP 7的Error体系:

Throwable(接口)
├── Exception(异常)
│   └── ...(各种异常子类)
└── Error(错误)
    ├── TypeError(类型错误)
    ├── ParseError(解析错误)
    ├── AssertionError(断言错误)
    ├── ArithmeticError(算术错误)
    │   └── DivisionByZeroError(除零错误)
    └── ...

异常处理最佳实践

1. 只在能处理的地方捕获异常

不要滥用try-catch,不要把所有代码都包在try-catch里。只在你能真正处理异常的地方捕获,否则应该让异常向上传播,由更上层处理。

// 不好:捕获了但不处理,等于吞掉了异常
try {
    $db->query($sql);
} catch (PDOException $e) {
    // 什么都不做,异常被吞掉了,错误被忽略
}

// 好:要么处理,要么向上抛
function getUser($id) {
    try {
        return $db->query("SELECT * FROM users WHERE id = $id")->fetch();
    } catch (PDOException $e) {
        // 记录日志,然后重新抛出,让上层处理
        error_log('数据库错误:' . $e->getMessage());
        throw new DatabaseException('获取用户失败', 500, $e);
    }
}

2. 不要捕获过于宽泛的异常

不要一上来就catch (Exception $e),应该先捕获具体的异常类型,最后再用Exception兜底。

// 不好:所有异常都一样处理
try {
    // 代码
} catch (Exception $e) {
    echo '错误';
}

// 好:不同异常不同处理
try {
    // 代码
} catch (PDOException $e) {
    // 数据库异常处理
} catch (InvalidArgumentException $e) {
    // 参数异常处理
} catch (Exception $e) {
    // 其他异常兜底
}

3. 异常消息要清晰有意义

异常消息应该清晰描述错误原因,方便调试和定位问题。

// 不好:消息模糊
throw new Exception('出错了');

// 好:消息清晰,包含上下文
throw new Exception("获取用户失败,用户ID:{$id},数据库错误:" . $e->getMessage());

4. 使用合适的异常类型

根据错误类型,选择合适的异常类(内置或自定义),不要一律用Exception。

// 不好:一律用Exception
if (!is_numeric($id)) {
    throw new Exception('无效的ID');
}

// 好:用合适的异常类型
if (!is_numeric($id)) {
    throw new InvalidArgumentException('无效的用户ID:' . $id);
}

5. 用finally清理资源

对于需要释放的资源(文件句柄、数据库连接、锁等),用finally确保清理,即使发生异常也能释放。

// 不好:如果中间抛出异常,文件不会被关闭
$fp = fopen('file.txt', 'r');
// 处理文件...
fclose($fp); // 如果上面抛出异常,这行不会执行

// 好:用finally确保资源被释放
$fp = fopen('file.txt', 'r');
try {
    // 处理文件...
} finally {
    fclose($fp); // 无论是否抛出异常,都会执行
}

6. 不要在循环中捕获异常

在循环中捕获异常,会影响性能,也可能导致异常处理逻辑混乱。如果需要处理循环中的异常,应该在循环外捕获,或者在循环内记录后继续。

// 不好:循环中频繁try-catch
foreach ($items as $item) {
    try {
        process($item);
    } catch (Exception $e) {
        // 处理
    }
}

// 好:如果单个失败不影响其他,记录后继续
foreach ($items as $item) {
    try {
        process($item);
    } catch (Exception $e) {
        error_log("处理失败:{$item['id']} - " . $e->getMessage());
        continue;
    }
}

7. 生产环境不要暴露详细错误信息

生产环境,异常的详细信息(文件路径、堆栈跟踪、数据库信息等)不应该暴露给用户,避免泄露敏感信息,被攻击者利用。

// 生产环境:友好提示,不暴露细节
try {
    // 代码
} catch (Exception $e) {
    error_log('错误:' . $e->getMessage() . "\n" . $e->getTraceAsString()); // 记录日志
    http_response_code(500);
    echo '服务器内部错误,请稍后重试'; // 给用户友好提示
}

8. 异常处理不能替代输入验证

异常处理是处理意外错误的,不能替代正常的输入验证。对于可以预见的错误(如用户输入无效),应该先验证,而不是靠异常来处理。

// 不好:靠异常来处理无效输入
try {
    $user = getUser($_GET['id']);
} catch (InvalidArgumentException $e) {
    echo '无效的ID';
}

// 好:先验证输入,再处理
if (!isset($_GET['id']) || !is_numeric($_GET['id']) || $_GET['id'] <= 0) {
    http_response_code(400);
    echo '无效的用户ID';
    exit;
}
$user = getUser($_GET['id']);

9. 记录异常日志

所有捕获的异常,都应该记录日志,方便后续排查问题。日志应该包含:异常消息、错误码、出错文件和行号、堆栈跟踪、请求信息(URL、参数、IP等)。

function logException(Exception $e, $context = []) {
    $log = sprintf(
        "[%s] %s in %s:%d\nStack trace:\n%s\nContext: %s\n",
        date('Y-m-d H:i:s'),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString(),
        json_encode($context)
    );
    error_log($log, 3, '/path/to/error.log');
}

10. 不要用异常控制正常流程

异常是用来处理错误和意外情况的,不要用异常来控制正常的业务流程。比如,不要用异常来实现循环跳出、条件判断等。

// 不好:用异常控制流程
try {
    foreach ($items as $item) {
        if ($item['id'] == $targetId) {
            throw new FoundException($item);
        }
    }
} catch (FoundException $e) {
    $found = $e->getItem();
}

// 好:用正常的流程控制
$found = null;
foreach ($items as $item) {
    if ($item['id'] == $targetId) {
        $found = $item;
        break;
    }
}

实战:一个完整的异常处理示例

下面是一个完整的博客系统中,文章详情页的异常处理示例:

// index.php

// 初始化
define('ENV', 'production');
date_default_timezone_set('Asia/Shanghai');

// 全局异常处理器
set_exception_handler(function (Throwable $e) {
    error_log('未捕获异常:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    if (ENV === 'production') {
        http_response_code(500);
        echo '服务器内部错误,请稍后重试';
    } else {
        echo '<pre>' . $e . '</pre>';
    }
    exit;
});

// 错误转异常
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
    if (!(error_reporting() & $errno)) return false;
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

// 路由和业务逻辑
try {
    $id = $_GET['id'] ?? 0;
    
    // 输入验证
    if (!is_numeric($id) || $id <= 0) {
        throw new InvalidArgumentException('无效的文章ID');
    }
    
    // 获取文章
    $article = getArticle($id);
    
    // 渲染页面
    renderArticle($article);
    
} catch (InvalidArgumentException $e) {
    http_response_code(400);
    echo '错误:' . $e->getMessage();
} catch (ArticleNotFoundException $e) {
    http_response_code(404);
    echo '文章不存在';
} catch (DatabaseException $e) {
    error_log('数据库错误:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo '服务器内部错误,请稍后重试';
} catch (Throwable $e) {
    error_log('未知错误:' . $e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo '服务器内部错误,请稍后重试';
}

// 业务函数
function getArticle($id) {
    global $db;
    try {
        $stmt = $db->prepare("SELECT * FROM articles WHERE id = ? AND status = 'published'");
        $stmt->execute([$id]);
        $article = $stmt->fetch();
    } catch (PDOException $e) {
        throw new DatabaseException('查询文章失败', 500, $e);
    }
    
    if (!$article) {
        throw new ArticleNotFoundException("文章不存在:{$id}");
    }
    
    return $article;
}

这个示例展示了:

  1. 全局异常处理器,统一处理未捕获异常
  2. 错误转异常,统一错误处理机制
  3. 输入验证,在业务逻辑前验证输入
  4. 自定义异常(DatabaseException、ArticleNotFoundException),分类错误
  5. 异常链,包装底层异常时保留原始异常
  6. 分层异常处理,不同层级处理不同异常
  7. 生产环境不暴露详细错误信息
  8. 记录异常日志,方便排查

总结

异常处理,是写出健壮、优雅PHP代码的关键。

核心要点:

  1. 为什么需要异常处理:错误处理和业务逻辑分离、错误不会被忽略、统一错误信息、传递详细上下文、集中处理错误
  2. 基础语法:try-catch、throw、Exception类方法(getMessage/getCode/getFile/getLine/getTraceAsString)、多个catch块、finally
  3. 异常体系:内置异常类(LogicException/RuntimeException/InvalidArgumentException/PDOException等)、自定义异常
  4. 异常链:异常嵌套,保留完整错误上下文,getPrevious()
  5. 全局异常处理器:setexceptionhandler,统一处理未捕获异常
  6. 错误转异常:seterrorhandler + ErrorException,统一错误处理
  7. PHP 7+ Error类:Throwable接口,Error和Exception,TypeError/ParseError等
  8. 最佳实践:只在能处理的地方捕获、不捕获过于宽泛的异常、异常消息清晰、使用合适的异常类型、用finally清理资源、不在循环中捕获、生产环境不暴露细节、异常不替代输入验证、记录日志、不用异常控制流程

异常处理,不是为了让代码更复杂,而是为了让代码更健壮、更优雅、更易维护。通过合理的异常处理,我们可以把错误处理逻辑和业务逻辑分离,让代码更清晰;可以统一处理错误,给用户友好的提示;可以在错误发生后,优雅地恢复和清理。

"代码不仅要能运行,还要能优雅地处理错误。"希望这篇文章,能帮你更好地使用PHP异常处理,写出更健壮、更优雅的代码。