PHP RESTful API设计
引言设计良好的RESTful API是现代Web服务的基石。本文从路由匹配、请求处理、响应格式化到版本控制构建一个完整的REST API框架涵盖HATEOAS、内容协商、错误处理等核心概念。HTTP抽象层REST API的核心是HTTP消息的抽象。namespace REST;class Request{private array $attributes [];public function __construct(private string $method,private string $uri,private array $headers [],private array $query [],private array $body [],private array $files [],private ?string $rawBody null,private string $protocol HTTP/1.1,) {$this-parseContentType();}public static function fromGlobals(): self{$rawBody file_get_contents(php://input);$body [];$contentType $_SERVER[CONTENT_TYPE] ?? ;if (str_contains($contentType, application/json)) {$body json_decode($rawBody, true) ?? [];} elseif (str_contains($contentType, application/x-www-form-urlencoded)) {parse_str($rawBody, $body);} elseif (str_contains($contentType, multipart/form-data)) {$body $_POST;}// 解析 URI$uri parse_url($_SERVER[REQUEST_URI] ?? /, PHP_URL_PATH);$query [];if (isset($_SERVER[QUERY_STRING])) {parse_str($_SERVER[QUERY_STRING], $query);}// 解析请求头$headers [];foreach ($_SERVER as $key $value) {if (str_starts_with($key, HTTP_)) {$headerName str_replace(_, -, strtolower(substr($key, 5)));$headers[$headerName] $value;}}return new self($_SERVER[REQUEST_METHOD] ?? GET,$uri,$headers,$query,$body,$_FILES,$rawBody,$_SERVER[SERVER_PROTOCOL] ?? HTTP/1.1,);}private function parseContentType(): void{// 设置 attributes}public function getMethod(): string { return strtoupper($this-method); }public function getUri(): string { return $this-uri; }public function getHeaders(): array { return $this-headers; }public function getHeader(string $name): ?string { return $this-headers[strtolower($name)] ?? null; }public function getQuery(): array { return $this-query; }public function get(string $key, mixed $default null): mixed { return $this-body[$key] ?? $this-query[$key] ?? $default; }public function all(): array { return array_merge($this-query, $this-body); }public function getRawBody(): ?string { return $this-rawBody; }public function setAttribute(string $key, mixed $value): void { $this-attributes[$key] $value; }public function getAttribute(string $key, mixed $default null): mixed { return $this-attributes[$key] ?? $default; }public function expectsJson(): bool{$accept $this-getHeader(Accept) ?? ;return str_contains($accept, /json) || str_contains($accept, */*);}public function isMethod(string $method): bool{return strtoupper($this-method) strtoupper($method);}}class Response{private int $statusCode;private array $headers;private mixed $data;private string $format;public function __construct(mixed $data null,int $statusCode 200,array $headers [],string $format json,) {$this-data $data;$this-statusCode $statusCode;$this-headers $headers;$this-format $format;}public static function json(mixed $data, int $status 200, array $headers []): self{return new self($data, $status, $headers, json);}public static function created(mixed $data null): self{return new self($data, 201, [], json);}public static function noContent(): self{return new self(null, 204);}public static function error(string $message, int $status 400, array $extra []): self{return new self(array_merge([error $message], $extra), $status);}public static function notFound(string $message Resource not found): self{return new self([error $message], 404);}public function withHeader(string $name, string $value): self{$this-headers[$name] $value;return $this;}public function withLink(string $rel, string $href, string $method GET): self{if (!isset($this-headers[Link])) {$this-headers[Link] [];}$this-headers[Link][] sprintf(%s; rel%s; method%s, $href, $rel, $method);return $this;}public function send(): void{http_response_code($this-statusCode);if ($this-statusCode 204) {header(Content-Length: 0);exit;}$contentType match ($this-format) {json application/json; charsetutf-8,xml application/xml; charsetutf-8,text text/plain; charsetutf-8,default application/json; charsetutf-8,};header(Content-Type: $contentType);foreach ($this-headers as $name $values) {if (is_array($values)) {foreach ($values as $v) {header($name: $v, false);}} else {header($name: $values);}}if ($this-format json $this-data ! null) {echo json_encode($this-data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);}exit;}}// 路由系统class Route{public function __construct(public string $method,public string $pattern,public callable $handler,public array $middleware [],) {}public function matches(string $method, string $uri): ?array{if (strtoupper($method) ! strtoupper($this-method)) {return null;}$pattern preg_replace(/\{(\w)\}/, (?P$1[^/]), $this-pattern);$pattern #^ . $pattern . $#;if (preg_match($pattern, $uri, $matches)) {return array_filter($matches, is_string, ARRAY_FILTER_USE_KEY);}return null;}}class Router{private array $routes [];private array $groupStack [];private array $globalMiddleware [];public function addGlobalMiddleware(callable $middleware): void{$this-globalMiddleware[] $middleware;}public function group(array $attributes, callable $callback): void{$this-groupStack[] $attributes;$callback($this);array_pop($this-groupStack);}public function get(string $pattern, callable $handler): self{return $this-addRoute(GET, $pattern, $handler);}public function post(string $pattern, callable $handler): self{return $this-addRoute(POST, $pattern, $handler);}public function put(string $pattern, callable $handler): self{return $this-addRoute(PUT, $pattern, $handler);}public function patch(string $pattern, callable $handler): self{return $this-addRoute(PATCH, $pattern, $handler);}public function delete(string $pattern, callable $handler): self{return $this-addRoute(DELETE, $pattern, $handler);}private function addRoute(string $method, string $pattern, callable $handler): self{$middleware [];// 合并组中间件foreach ($this-groupStack as $group) {if (isset($group[prefix])) {$pattern rtrim($group[prefix], /) . / . ltrim($pattern, /);}if (isset($group[middleware])) {$middleware array_merge($middleware, (array)$group[middleware]);}}$this-routes[] new Route($method, $pattern, $handler, $middleware);return $this;}public function dispatch(Request $request): Response{$uri $request-getUri();$method $request-getMethod();// 检查 OPTIONSif ($method OPTIONS) {return Response::noContent()-withHeader(Allow, GET, POST, PUT, PATCH, DELETE, OPTIONS);}foreach ($this-routes as $route) {$params $route-matches($method, $uri);if ($params ! null) {foreach ($params as $key $value) {$request-setAttribute($key, $value);}$handler $route-handler;$middlewareChain array_merge($this-globalMiddleware,$route-middleware,[$handler]);return $this-executeMiddleware($middlewareChain, $request);}}return Response::notFound(Route not found: $method $uri);}private function executeMiddleware(array $chain, Request $request): Response{$stack $chain;$next function (Request $request) use ($stack) {if (empty($stack)) {return Response::error(No handler defined, 500);}$handler array_shift($stack);return $handler($request, $next);};return $next($request);}}// 内容协商class ContentNegotiator{private array $formatters [];public function registerFormatter(string $format, callable $encoder, callable $decoder): void{$this-formatters[$format] [encode $encoder, decode $decoder];}public function negotiate(Request $request): string{$accept $request-getHeader(Accept) ?? application/json;$preferred $this-parseAcceptHeader($accept);foreach ($preferred as $mime $quality) {foreach ($this-formatters as $format $handler) {$mimeType $handler[encode](__mime_type__);if ($mime $mimeType || $mime */* || $mime application/*) {return $format;}}}return json;}private function parseAcceptHeader(string $header): array{$types [];foreach (explode(,, $header) as $part) {$parts explode(;, trim($part));$mime trim($parts[0]);$quality 1.0;for ($i 1; $i count($parts); $i) {if (str_starts_with(trim($parts[$i]), q)) {$quality (float)substr(trim($parts[$i]), 2);}}$types[$mime] $quality;}arsort($types);return $types;}}// 资源控制器abstract class ResourceController{protected string $modelName resource;protected array $defaultIncludes [];abstract public function index(Request $request): Response;abstract public function show(Request $request): Response;abstract public function store(Request $request): Response;abstract public function update(Request $request): Response;abstract public function destroy(Request $request): Response;protected function paginate(array $items, Request $request, int $total): array{$page (int)($request-get(page, 1));$perPage (int)($request-get(per_page, 15));$perPage min(max($perPage, 1), 100);$totalPages (int)ceil($total / $perPage);$uri $request-getUri();return [data $items,meta [current_page $page,per_page $perPage,total $total,total_pages $totalPages,],links [self $uri?page$pageper_page$perPage,first $uri?page1per_page$perPage,last $uri?page$totalPagesper_page$perPage,prev $page 1 ? $uri?page . ($page - 1) . per_page$perPage : null,next $page $totalPages ? $uri?page . ($page 1) . per_page$perPage : null,],];}protected function resource(mixed $data, Request $request): array{$result [data $data];$includes $request-get(include, );if (!empty($includes)) {$result[included] [];}return $result;}}// HATEOAS 实现class HateoasLink{public function __construct(public string $href,public string $rel,public string $method GET,public ?string $title null,) {}public function toArray(): array{$link [href $this-href, method $this-method, rel $this-rel];if ($this-title) $link[title] $this-title;return $link;}}trait HateoasTrait{private array $links [];public function addLink(string $rel, string $href, string $method GET, ?string $title null): void{$this-links[] new HateoasLink($href, $rel, $method, $title);}public function addSelfLink(string $href): void{$this-addLink(self, $href, GET);}public function getLinks(): array{return array_map(fn(HateoasLink $l) $l-toArray(), $this-links);}}// API 版本管理class VersionManager{private array $versions [];public function register(string $version, callable $bootstrapper): void{$this-versions[$version] $bootstrapper;}public function resolve(Request $request): string{// Accept: application/vnd.apijson; version1.0$accept $request-getHeader(Accept) ?? ;if (preg_match(/version([\d.])/, $accept, $m)) {return $m[1];}// URL 前缀: /v1/users$uri $request-getUri();if (preg_match(#^/v(\d(?:\.\d)?)/#, $uri, $m)) {return $m[1];}// 自定义头$version $request-getHeader(X-API-Version);if ($version) return $version;return 1.0;}public function bootstrap(string $version): void{if (isset($this-versions[$version])) {($this-versions[$version])();}}}// API 异常处理class ApiException extends \RuntimeException{public function __construct(string $message,int $statusCode 400,private array $errors [],?\Throwable $previous null,) {parent::__construct($message, $statusCode, $previous);}public function getStatusCode(): int{return $this-getCode();}public function getErrors(): array{return $this-errors;}public function toResponse(): Response{$body [error [code $this-getCode(),message $this-getMessage(),],];if (!empty($this-errors)) {$body[error][errors] $this-errors;}return Response::json($body, $this-getCode());}}class ValidationException extends ApiException{public function __construct(array $errors){parent::__construct(Validation failed, 422, $errors);}}class AuthenticationException extends ApiException{public function __construct(string $message Unauthenticated){parent::__construct($message, 401);}}class AuthorizationException extends ApiException{public function __construct(string $message Forbidden){parent::__construct($message, 403);}}class NotFoundException extends ApiException{public function __construct(string $message Resource not found){parent::__construct($message, 404);}}// API 应用主类class Application{private Router $router;private array $middleware [];private VersionManager $versionManager;private ContentNegotiator $negotiator;public function __construct(){$this-router new Router();$this-versionManager new VersionManager();$this-negotiator new ContentNegotiator();}public function getRouter(): Router { return $this-router; }public function getVersionManager(): VersionManager { return $this-versionManager; }public function addMiddleware(callable $middleware): void{$this-middleware[] $middleware;}public function handle(Request $request): Response{try {// 版本解析$version $this-versionManager-resolve($request);$this-versionManager-bootstrap($version);$request-setAttribute(api_version, $version);// CORSif ($request-getMethod() OPTIONS) {return Response::noContent()-withHeader(Access-Control-Allow-Origin, *)-withHeader(Access-Control-Allow-Methods, GET, POST, PUT, PATCH, DELETE, OPTIONS)-withHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-API-Version)-withHeader(Access-Control-Max-Age, 86400);}// 运行全局中间件$stack array_merge($this-middleware, [function (Request $req) {return $this-router-dispatch($req);}]);$next function (Request $request) use ($stack) {if (empty($stack)) {return Response::error(Internal error, 500);}$handler array_shift($stack);return $handler($request, $next);};$response $next($request);// CORS headers$response $response-withHeader(Access-Control-Allow-Origin, *)-withHeader(X-API-Version, $version);return $response;} catch (ApiException $e) {return $e-toResponse();} catch (\Throwable $e) {return Response::error(Internal server error, 500);}}public function run(): void{$request Request::fromGlobals();$response $this-handle($request);$response-send();}}// 认证中间件class AuthMiddleware{public function __construct(private array $validTokens []) {}public function __invoke(Request $request, callable $next): Response{$auth $request-getHeader(Authorization);if (!$auth || !str_starts_with($auth, Bearer )) {throw new AuthenticationException(Missing or invalid token);}$token substr($auth, 7);$userId $this-validateToken($token);if ($userId null) {throw new AuthenticationException(Invalid token);}$request-setAttribute(user_id, $userId);return $next($request);}private function validateToken(string $token): ?int{return $this-validTokens[$token] ?? null;}}// 限流中间件class RateLimitMiddleware{private array $store [];public function __construct(private int $maxRequests 100,private int $window 3600,) {}public function __invoke(Request $request, callable $next): Response{$ip $request-getHeader(X-Forwarded-For)?? $request-getAttribute(client_ip)?? 127.0.0.1;$key md5($ip);$now time();$windowStart $now - $this-window;// 清理旧记录if (!isset($this-store[$key])) {$this-store[$key] [];}$this-store[$key] array_filter($this-store[$key],fn($t) $t $windowStart);if (count($this-store[$key]) $this-maxRequests) {$retryAfter $this-store[$key][0] $this-window - $now;return Response::error(Rate limit exceeded, 429)-withHeader(Retry-After, (string)$retryAfter)-withHeader(X-RateLimit-Limit, (string)$this-maxRequests)-withHeader(X-RateLimit-Remaining, 0);}$this-store[$key][] $now;$response $next($request);return $response-withHeader(X-RateLimit-Limit, (string)$this-maxRequests)-withHeader(X-RateLimit-Remaining, (string)($this-maxRequests - count($this-store[$key])));}}// 使用示例$app new Application();$app-addMiddleware(function (Request $request, callable $next) {$start microtime(true);$response $next($request);$duration (microtime(true) - $start) * 1000;return $response-withHeader(X-Response-Time, sprintf(%.2fms, $duration));});$router $app-getRouter();$router-addGlobalMiddleware(function (Request $request, callable $next) {$response $next($request);return $response-withHeader(X-Request-ID, uniqid(req_));});// API v1 路由$router-group([prefix /v1], function (Router $router) {$router-get(/users, function (Request $request) {$users [[id 1, name Alice, email aliceexample.com],[id 2, name Bob, email bobexample.com],];return Response::json([data $users,meta [total count($users)],]);});$router-get(/users/{id}, function (Request $request) {$id $request-getAttribute(id);$user [id (int)$id, name Alice, email aliceexample.com];return Response::json([data $user]);});$router-post(/users, function (Request $request) {$name $request-get(name);$email $request-get(email);if (empty($name)) {throw new ValidationException([name Name is required]);}return Response::created([id 3,name $name,email $email,])-withHeader(Location, /v1/users/3);});$router-put(/users/{id}, function (Request $request) {return Response::json([message User updated]);});$router-delete(/users/{id}, function (Request $request) {return Response::noContent();});});// API v2 路由不同结构$router-group([prefix /v2], function (Router $router) {$router-get(/users, function (Request $request) {return Response::json([users [[id 1, full_name Alice Smith, email aliceexample.com],[id 2, full_name Bob Jones, email bobexample.com],],version 2.0,]);});});// 模拟请求$request new Request(GET, /v1/users?page1per_page10, [Accept application/json]);$response $app-handle($request);printf(Response status: %d\n, (new \ReflectionProperty($response, statusCode)) ?? 200);printf(GET /v1/users - 200\n);$request new Request(POST, /v1/users, [Accept application/json, Content-Type application/json], [], [name Charlie, email charlieexample.com]);$response $app-handle($request);printf(POST /v1/users - 201\n);$request new Request(GET, /v2/users, [Accept application/json]);$response $app-handle($request);printf(GET /v2/users - 200 (v2 API)\n);$request new Request(DELETE, /v1/users/1);$response $app-handle($request);printf(DELETE /v1/users/1 - 204\n);// OpenAPI 规范生成class OpenApiGenerator{private array $routes [];private array $schemas [];public function addRoute(string $method, string $path, array $spec): void{$this-routes[strtolower($method)][$path] $spec;}public function addSchema(string $name, array $schema): void{$this-schemas[$name] $schema;}public function generate(): string{$spec [openapi 3.0.3,info [title API Documentation,version 1.0.0,description Auto-generated API documentation,],paths [],components [schemas $this-schemas,],];foreach ($this-routes as $method $paths) {foreach ($paths as $path $routeSpec) {$spec[paths][$path][$method] $routeSpec;}}return json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);}}$openapi new OpenApiGenerator();$openapi-addSchema(User, [type object,properties [id [type integer],name [type string],email [type string, format email],],]);$openapi-addRoute(GET, /users, [summary List all users,responses [200 [description List of users,content [application/json [schema [type object,properties [data [type array,items [$ref #/components/schemas/User],],],],],],],],]);