Laravel 13 提供了强大的异常处理机制,本文深入探讨高级异常处理技术和最佳实践。

异常处理架构

自定义异常类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

abstract class BaseException extends Exception
{
protected string $errorCode;
protected int $httpStatus = 500;
protected array $headers = [];
protected array $context = [];
protected bool $report = true;

public function __construct(
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}

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

public function getHttpStatus(): int
{
return $this->httpStatus;
}

public function getHeaders(): array
{
return $this->headers;
}

public function getContext(): array
{
return $this->context;
}

public function withContext(array $context): self
{
$this->context = array_merge($this->context, $context);
return $this;
}

public function shouldReport(): bool
{
return $this->report;
}

public function render(Request $request): JsonResponse|Response
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'error' => [
'code' => $this->getErrorCode(),
'message' => $this->getMessage(),
'details' => $this->getDetails(),
],
], $this->getHttpStatus(), $this->getHeaders());
}

return response()->view('errors.generic', [
'exception' => $this,
], $this->getHttpStatus(), $this->getHeaders());
}

protected function getDetails(): array
{
return [];
}
}

class ValidationException extends BaseException
{
protected string $errorCode = 'VALIDATION_ERROR';
protected int $httpStatus = 422;
protected array $errors = [];

public function __construct(array $errors, string $message = 'Validation failed')
{
parent::__construct($message);
$this->errors = $errors;
}

protected function getDetails(): array
{
return [
'errors' => $this->errors,
];
}
}

class ResourceNotFoundException extends BaseException
{
protected string $errorCode = 'RESOURCE_NOT_FOUND';
protected int $httpStatus = 404;

public function __construct(string $resource, $id = null)
{
$message = $id
? "{$resource} with ID {$id} not found"
: "{$resource} not found";

parent::__construct($message);

$this->withContext([
'resource' => $resource,
'id' => $id,
]);
}
}

class AuthorizationException extends BaseException
{
protected string $errorCode = 'AUTHORIZATION_DENIED';
protected int $httpStatus = 403;

public function __construct(string $action = null, string $resource = null)
{
$message = $action && $resource
? "You are not authorized to {$action} this {$resource}"
: 'You are not authorized to perform this action';

parent::__construct($message);
}
}

class BusinessLogicException extends BaseException
{
protected string $errorCode = 'BUSINESS_LOGIC_ERROR';
protected int $httpStatus = 400;

public function __construct(string $message, string $errorCode = null)
{
parent::__construct($message);

if ($errorCode) {
$this->errorCode = $errorCode;
}
}
}

class ExternalServiceException extends BaseException
{
protected string $errorCode = 'EXTERNAL_SERVICE_ERROR';
protected int $httpStatus = 502;
protected string $service;

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

parent::__construct(
$message ?? "External service '{$service}' is unavailable",
0,
$previous
);

$this->withContext([
'service' => $service,
]);
}
}

异常处理器

自定义异常处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
<?php

namespace App\Exceptions;

use App\Services\ErrorTracking\ErrorTracker;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException as LaravelValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;

class Handler extends ExceptionHandler
{
protected $dontReport = [
ValidationException::class,
ResourceNotFoundException::class,
AuthorizationException::class,
];

protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];

protected array $exceptionMap = [
ModelNotFoundException::class => ResourceNotFoundException::class,
];

public function __construct(
protected ErrorTracker $errorTracker
) {
parent::__construct(app());
}

public function report(Throwable $e): void
{
if ($this->shouldReport($e)) {
$this->errorTracker->capture($e);
}

parent::report($e);
}

public function render($request, Throwable $e)
{
$e = $this->mapException($e);

if ($request->expectsJson()) {
return $this->renderJson($request, $e);
}

return parent::render($request, $e);
}

protected function mapException(Throwable $e): Throwable
{
foreach ($this->exceptionMap as $from => $to) {
if ($e instanceof $from) {
return new $to(
$this->getResourceName($e),
$this->getResourceId($e)
);
}
}

return $e;
}

protected function renderJson(Request $request, Throwable $e)
{
if ($e instanceof BaseException) {
return $e->render($request);
}

if ($e instanceof LaravelValidationException) {
return response()->json([
'success' => false,
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => $e->getMessage(),
'details' => [
'errors' => $e->errors(),
],
],
], 422);
}

if ($e instanceof AuthenticationException) {
return response()->json([
'success' => false,
'error' => [
'code' => 'UNAUTHENTICATED',
'message' => 'Unauthenticated',
],
], 401);
}

if ($e instanceof HttpException) {
return response()->json([
'success' => false,
'error' => [
'code' => $this->getHttpErrorCode($e),
'message' => $e->getMessage() ?: 'An error occurred',
],
], $e->getStatusCode());
}

$status = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500;

$response = [
'success' => false,
'error' => [
'code' => 'INTERNAL_ERROR',
'message' => app()->environment('production')
? 'An unexpected error occurred'
: $e->getMessage(),
],
];

if (!app()->environment('production')) {
$response['error']['debug'] = [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => collect($e->getTrace())->take(10)->toArray(),
];
}

return response()->json($response, $status);
}

protected function getHttpErrorCode(HttpException $e): string
{
return match ($e->getStatusCode()) {
401 => 'UNAUTHENTICATED',
403 => 'FORBIDDEN',
404 => 'NOT_FOUND',
405 => 'METHOD_NOT_ALLOWED',
429 => 'TOO_MANY_REQUESTS',
500 => 'INTERNAL_ERROR',
502 => 'BAD_GATEWAY',
503 => 'SERVICE_UNAVAILABLE',
default => 'HTTP_ERROR',
};
}

protected function getResourceName(ModelNotFoundException $e): string
{
$model = $e->getModel();
return class_basename($model);
}

protected function getResourceId(ModelNotFoundException $e): mixed
{
return $e->getIds()[0] ?? null;
}
}

异常上下文

上下文提供者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

namespace App\Exceptions\Context;

interface ExceptionContextProvider
{
public function getContext(): array;
}

class UserContextProvider implements ExceptionContextProvider
{
public function getContext(): array
{
if (!auth()->check()) {
return ['user_id' => null, 'guest' => true];
}

$user = auth()->user();

return [
'user_id' => $user->id,
'user_email' => $user->email,
'user_roles' => $user->roles->pluck('name')->toArray(),
];
}
}

class RequestContextProvider implements ExceptionContextProvider
{
public function getContext(): array
{
return [
'url' => request()->fullUrl(),
'method' => request()->method(),
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'route' => request()->route()?->getName(),
'action' => request()->route()?->getActionName(),
];
}
}

class EnvironmentContextProvider implements ExceptionContextProvider
{
public function getContext(): array
{
return [
'environment' => app()->environment(),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'hostname' => gethostname(),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
];
}
}

class ContextAggregator
{
protected array $providers = [];

public function register(string $name, ExceptionContextProvider $provider): self
{
$this->providers[$name] = $provider;
return $this;
}

public function aggregate(): array
{
$context = [];

foreach ($this->providers as $name => $provider) {
try {
$context[$name] = $provider->getContext();
} catch (\Throwable $e) {
$context[$name] = ['error' => $e->getMessage()];
}
}

return $context;
}
}

异常恢复策略

重试机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

namespace App\Exceptions\Retry;

use Closure;
use Throwable;

class RetryStrategy
{
protected int $maxAttempts = 3;
protected int $delayMs = 1000;
protected float $multiplier = 2.0;
protected int $maxDelayMs = 30000;
protected array $retryableExceptions = [];

public function __construct(array $config = [])
{
foreach ($config as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}

public function execute(Closure $callback): mixed
{
$attempt = 0;
$delay = $this->delayMs;

while (true) {
try {
return $callback($attempt);
} catch (Throwable $e) {
$attempt++;

if ($attempt >= $this->maxAttempts || !$this->shouldRetry($e)) {
throw $e;
}

usleep($delay * 1000);

$delay = min((int)($delay * $this->multiplier), $this->maxDelayMs);
}
}
}

public function shouldRetry(Throwable $e): bool
{
if (empty($this->retryableExceptions)) {
return true;
}

foreach ($this->retryableExceptions as $exceptionClass) {
if ($e instanceof $exceptionClass) {
return true;
}
}

return false;
}

public static function forExternalService(string $service): self
{
return new self([
'maxAttempts' => 3,
'delayMs' => 1000,
'multiplier' => 2.0,
'retryableExceptions' => [
\App\Exceptions\ExternalServiceException::class,
\GuzzleHttp\Exception\ConnectException::class,
\GuzzleHttp\Exception\RequestException::class,
],
]);
}
}

断路器模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
<?php

namespace App\Exceptions\CircuitBreaker;

use Closure;
use Illuminate\Support\Facades\Cache;
use Throwable;

enum CircuitState: string
{
case CLOSED = 'closed';
case OPEN = 'open';
case HALF_OPEN = 'half_open';
}

class CircuitBreaker
{
protected string $name;
protected int $failureThreshold = 5;
protected int $successThreshold = 2;
protected int $timeout = 60;
protected array $ignoreExceptions = [];

public function __construct(string $name, array $config = [])
{
$this->name = $name;

foreach ($config as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}

public function execute(Closure $callback): mixed
{
$state = $this->getState();

if ($state === CircuitState::OPEN) {
throw new CircuitOpenException("Circuit '{$this->name}' is open");
}

try {
$result = $callback();
$this->recordSuccess();
return $result;
} catch (Throwable $e) {
if ($this->shouldIgnore($e)) {
throw $e;
}

$this->recordFailure();
throw $e;
}
}

public function getState(): CircuitState
{
$state = Cache::get($this->getStateKey());

if ($state === CircuitState::OPEN->value) {
$lastFailure = Cache::get($this->getLastFailureKey());

if ($lastFailure && now()->diffInSeconds($lastFailure) >= $this->timeout) {
$this->transitionTo(CircuitState::HALF_OPEN);
return CircuitState::HALF_OPEN;
}
}

return CircuitState::tryFrom($state) ?? CircuitState::CLOSED;
}

protected function recordSuccess(): void
{
$state = $this->getState();

if ($state === CircuitState::HALF_OPEN) {
$successes = Cache::increment($this->getSuccessKey());

if ($successes >= $this->successThreshold) {
$this->transitionTo(CircuitState::CLOSED);
}
} else {
Cache::forget($this->getFailureKey());
}
}

protected function recordFailure(): void
{
$state = $this->getState();

Cache::put($this->getLastFailureKey(), now(), $this->timeout * 2);

if ($state === CircuitState::HALF_OPEN) {
$this->transitionTo(CircuitState::OPEN);
return;
}

$failures = Cache::increment($this->getFailureKey());

if ($failures >= $this->failureThreshold) {
$this->transitionTo(CircuitState::OPEN);
}
}

protected function transitionTo(CircuitState $state): void
{
Cache::put($this->getStateKey(), $state->value, $this->timeout * 2);

if ($state === CircuitState::CLOSED) {
Cache::forget($this->getFailureKey());
Cache::forget($this->getSuccessKey());
}
}

protected function shouldIgnore(Throwable $e): bool
{
foreach ($this->ignoreExceptions as $exceptionClass) {
if ($e instanceof $exceptionClass) {
return true;
}
}

return false;
}

protected function getStateKey(): string
{
return "circuit:{$this->name}:state";
}

protected function getFailureKey(): string
{
return "circuit:{$this->name}:failures";
}

protected function getSuccessKey(): string
{
return "circuit:{$this->name}:successes";
}

protected function getLastFailureKey(): string
{
return "circuit:{$this->name}:last_failure";
}
}

class CircuitOpenException extends \RuntimeException
{
}

异常日志

结构化异常日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

namespace App\Exceptions\Logging;

use App\Exceptions\Context\ContextAggregator;
use Illuminate\Support\Facades\Log;
use Throwable;

class ExceptionLogger
{
protected ContextAggregator $contextAggregator;
protected array $levels = [
'critical' => ['emergency', 'alert', 'critical'],
'error' => ['error'],
'warning' => ['warning'],
'info' => ['notice', 'info'],
'debug' => ['debug'],
];

public function __construct(ContextAggregator $contextAggregator)
{
$this->contextAggregator = $contextAggregator;
}

public function log(Throwable $exception, array $extraContext = []): void
{
$level = $this->determineLevel($exception);
$context = $this->buildContext($exception, $extraContext);

Log::channel('exceptions')->{$level}(
$exception->getMessage(),
$context
);
}

protected function determineLevel(Throwable $exception): string
{
if ($exception instanceof \App\Exceptions\BaseException) {
return match ($exception->getHttpStatus()) {
500, 502, 503 => 'critical',
400, 401, 403, 404, 422 => 'warning',
default => 'error',
};
}

$className = get_class($exception);

if (str_contains($className, 'Fatal') || str_contains($className, 'Critical')) {
return 'critical';
}

return 'error';
}

protected function buildContext(Throwable $exception, array $extraContext): array
{
return array_merge(
[
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $this->formatTrace($exception),
],
$this->contextAggregator->aggregate(),
$extraContext
);
}

protected function formatTrace(Throwable $exception): array
{
return collect($exception->getTrace())
->take(10)
->map(fn($frame) => [
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
'class' => $frame['class'] ?? null,
'function' => $frame['function'] ?? null,
])
->toArray();
}
}

总结

Laravel 13 的异常处理系统需要结合自定义异常类、异常处理器、上下文收集和恢复策略来构建。通过完善的异常处理体系,可以提供更好的错误信息、实现优雅降级,并快速定位和解决问题。