PHP API Gateway的构建:统一认证、限流与请求路由的实现指南

PHP API Gateway 的构建:统一认证、限流与请求路由的实现指南

大家好,今天我们来聊聊如何使用 PHP 构建一个 API Gateway,重点关注统一认证、限流和请求路由这三个核心功能。API Gateway 作为微服务架构中的重要组成部分,负责处理所有外部请求,并将它们路由到相应的后端服务。它能够极大地简化客户端的开发,提高系统的安全性、可维护性和可扩展性。

一、API Gateway 的作用与优势

在深入代码之前,我们先来了解一下 API Gateway 的作用和优势:

  • 统一入口: 将多个后端服务暴露为一个统一的入口点,客户端无需关心后端服务的具体地址。
  • 认证与授权: 集中处理身份验证和授权,确保只有经过授权的请求才能访问后端服务。
  • 限流: 防止恶意请求或意外流量峰值导致后端服务崩溃,保障系统的稳定性。
  • 请求路由: 根据请求的 URL、Header 或其他信息,将请求路由到相应的后端服务。
  • 协议转换: 可以在不同的协议之间进行转换,例如将 REST 请求转换为 gRPC 请求。
  • 监控与日志: 集中收集请求的监控数据和日志,方便进行性能分析和故障排查。

二、技术选型

本次实践中,我们将采用以下技术:

  • PHP 8+: 使用最新的 PHP 版本,获得更好的性能和语言特性。
  • Composer: PHP 的依赖管理工具。
  • Slim Framework: 一个轻量级的 PHP 微框架,用于快速构建 API Gateway 的核心逻辑。
  • Redis: 用于存储会话信息和实现限流。
  • Guzzle HTTP Client: 用于向后端服务发送请求。

三、环境准备

首先,确保你已经安装了 PHP 8+、Composer 和 Redis。

  1. 创建项目目录:

    mkdir api-gateway
    cd api-gateway
  2. 初始化 Composer:

    composer init

    根据提示填写项目信息,或者直接按回车键使用默认值。

  3. 安装依赖:

    composer require slim/slim:"^4" slim/psr7 guzzlehttp/guzzle predis/predis

    这个命令会安装 Slim Framework、PSR-7 兼容库、Guzzle HTTP Client 和 Predis (Redis client)。

四、项目结构

推荐的项目结构如下:

api-gateway/
├── src/
│   ├── Middleware/
│   │   ├── AuthenticationMiddleware.php  // 认证中间件
│   │   ├── RateLimitMiddleware.php      // 限流中间件
│   ├── RouteConfig.php                 // 路由配置
│   ├── Config.php                      // 全局配置
├── public/
│   └── index.php                       // 入口文件
├── composer.json
├── .env                                // 环境变量文件
└── .htaccess                           // Apache 配置 (可选)

五、代码实现

  1. .env 文件

首先,创建 .env 文件,用于存储配置信息:

API_KEY_HEADER=X-API-Key
API_KEY=your_secret_api_key
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
RATE_LIMIT_WINDOW=60     # 单位:秒
RATE_LIMIT_MAX_REQUESTS=100
  1. Config.php

创建 src/Config.php 文件,用于读取配置信息:

<?php

namespace App;

use DotenvDotenv;

class Config
{
    private static $instance;
    private $config = [];

    private function __construct()
    {
        $dotenv = Dotenv::createImmutable(__DIR__ . '/../');
        $dotenv->load();

        $this->config = $_ENV;
    }

    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    public function get(string $key, $default = null)
    {
        return $this->config[$key] ?? $default;
    }
}
  1. AuthenticationMiddleware.php

创建 src/Middleware/AuthenticationMiddleware.php 文件,实现认证中间件:

<?php

namespace AppMiddleware;

use PsrHttpMessageServerRequestInterface as Request;
use PsrHttpServerRequestHandlerInterface as Handler;
use SlimPsr7Response;
use AppConfig;

class AuthenticationMiddleware
{
    public function __invoke(Request $request, Handler $handler): Response
    {
        $config = Config::getInstance();
        $apiKeyHeader = $config->get('API_KEY_HEADER');
        $apiKey = $config->get('API_KEY');

        $headerValue = $request->getHeaderLine($apiKeyHeader);

        if (empty($headerValue) || $headerValue !== $apiKey) {
            $response = new Response();
            $response->getBody()->write(json_encode(['error' => 'Unauthorized'], JSON_UNESCAPED_UNICODE));
            return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
        }

        return $handler->handle($request);
    }
}
  1. RateLimitMiddleware.php

创建 src/Middleware/RateLimitMiddleware.php 文件,实现限流中间件:

<?php

namespace AppMiddleware;

use PsrHttpMessageServerRequestInterface as Request;
use PsrHttpServerRequestHandlerInterface as Handler;
use SlimPsr7Response;
use PredisClient;
use AppConfig;

class RateLimitMiddleware
{
    private $redis;

    public function __construct(Client $redis)
    {
        $this->redis = $redis;
    }

    public function __invoke(Request $request, Handler $handler): Response
    {
        $config = Config::getInstance();
        $redisKeyPrefix = 'rate_limit:' . md5($request->getServerParams()['REMOTE_ADDR']); // 使用 IP 地址作为 key
        $rateLimitWindow = (int)$config->get('RATE_LIMIT_WINDOW');
        $rateLimitMaxRequests = (int)$config->get('RATE_LIMIT_MAX_REQUESTS');

        $requestCount = $this->redis->incr($redisKeyPrefix);

        if ($requestCount === 1) {
            $this->redis->expire($redisKeyPrefix, $rateLimitWindow);
        }

        if ($requestCount > $rateLimitMaxRequests) {
            $response = new Response();
            $response->getBody()->write(json_encode(['error' => 'Too Many Requests'], JSON_UNESCAPED_UNICODE));
            return $response->withStatus(429)->withHeader('Content-Type', 'application/json');
        }

        $response = $handler->handle($request);
        return $response->withHeader('X-RateLimit-Limit', $rateLimitMaxRequests)
                        ->withHeader('X-RateLimit-Remaining', $rateLimitMaxRequests - $requestCount)
                        ->withHeader('X-RateLimit-Reset', time() + $this->redis->ttl($redisKeyPrefix));
    }
}
  1. RouteConfig.php

创建 src/RouteConfig.php 文件,实现路由配置:

<?php

namespace App;

use SlimApp;
use SlimRoutingRouteCollectorProxy;
use GuzzleHttpClient;
use PsrHttpMessageResponseInterface as Response;
use PsrHttpMessageServerRequestInterface as Request;
use AppMiddlewareAuthenticationMiddleware;
use AppMiddlewareRateLimitMiddleware;
use PredisClient as RedisClient;

class RouteConfig
{
    public static function configure(App $app)
    {
        $app->group('/api', function (RouteCollectorProxy $group) {
            // 应用 Authentication 中间件
            $group->group('', function (RouteCollectorProxy $groupInner) {
                // 使用GuzzleHttp转发请求
                $groupInner->any('/users/{path:.*}', function (Request $request, Response $response, array $args) {
                    $client = new Client([
                        'base_uri' => 'http://user-service:8081', // 替换为用户服务地址
                        'timeout'  => 5.0,
                    ]);

                    $method = $request->getMethod();
                    $path = '/users/' . $args['path'];
                    $queryParams = $request->getQueryParams();
                    $body = $request->getBody();
                    $headers = $request->getHeaders();

                    try {
                        $res = $client->request($method, $path, [
                            'query' => $queryParams,
                            'body' => $body,
                            'headers' => $headers,
                            'http_errors' => false  // 不要抛出异常,允许处理非200状态码
                        ]);

                        $response->getBody()->write($res->getBody());

                        return $response
                            ->withStatus($res->getStatusCode())
                            ->withHeaders($res->getHeaders());

                    } catch (Exception $e) {
                        $response->getBody()->write(json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
                        return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
                    }
                });

                $groupInner->any('/products/{path:.*}', function (Request $request, Response $response, array $args) {
                    $client = new Client([
                        'base_uri' => 'http://product-service:8082', // 替换为产品服务地址
                        'timeout'  => 5.0,
                    ]);

                    $method = $request->getMethod();
                    $path = '/products/' . $args['path'];
                    $queryParams = $request->getQueryParams();
                    $body = $request->getBody();
                    $headers = $request->getHeaders();

                    try {
                        $res = $client->request($method, $path, [
                            'query' => $queryParams,
                            'body' => $body,
                            'headers' => $headers,
                            'http_errors' => false
                        ]);

                        $response->getBody()->write($res->getBody());

                        return $response
                            ->withStatus($res->getStatusCode())
                            ->withHeaders($res->getHeaders());

                    } catch (Exception $e) {
                        $response->getBody()->write(json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
                        return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
                    }
                });
            })->add(new AuthenticationMiddleware());

            //应用 RateLimit 中间件
            $group->group('', function (RouteCollectorProxy $groupInner) {
                // 任何路由
                $groupInner->get('/hello', function (Request $request, Response $response) {
                   $response->getBody()->write(json_encode(['message' => 'Hello, Rate Limited World!'], JSON_UNESCAPED_UNICODE));
                   return $response->withHeader('Content-Type', 'application/json');
                });
            })->add(new RateLimitMiddleware(new RedisClient([
                'host' => Config::getInstance()->get('REDIS_HOST'),
                'port' => Config::getInstance()->get('REDIS_PORT'),
            ])));

        });
    }
}

注意:

  • 需要替换 http://user-service:8081http://product-service:8082 为实际的后端服务地址。
  • {path:.*} 是 Slim 4 的路由参数语法,表示匹配任意字符,包括斜杠 /,这使得我们可以将请求完整地转发到后端服务。
  • http_errors 设置为 false 允许处理非 200 状态码,并将其转发给客户端。
  • 这里使用group 进行中间件的嵌套,先进行认证,再进行限流。
  • 使用IP地址进行限流,生产环境可能需要更细粒度的限流控制,例如针对用户ID。
  1. index.php

创建 public/index.php 文件,作为入口文件:

<?php

use SlimFactoryAppFactory;
use AppRouteConfig;

require __DIR__ . '/../vendor/autoload.php';

$app = AppFactory::create();

// 添加路由配置
RouteConfig::configure($app);

// 添加中间件(注意顺序)
$app->addBodyParsingMiddleware(); // 解析请求体
$app->addRoutingMiddleware(); // 添加路由中间件
$app->addErrorMiddleware(true, true, true); // 添加错误处理中间件

$app->run();
  1. .htaccess (可选)

如果使用 Apache 服务器,可以创建 .htaccess 文件,将所有请求重定向到 index.php

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [QSA,L]
</IfModule>

六、运行与测试

  1. 启动 Redis:

    确保 Redis 服务器已经启动。

  2. 启动 PHP 内置服务器:

    php -S localhost:8080 -t public
  3. 测试认证:

    发送一个没有 X-API-Key Header 的请求:

    curl -i http://localhost:8080/api/users/123

    应该会收到 401 Unauthorized 错误。

    发送一个带有正确的 X-API-Key Header 的请求:

    curl -i -H "X-API-Key: your_secret_api_key" http://localhost:8080/api/users/123

    如果后端服务正常运行,应该会收到用户服务的响应。

  4. 测试限流:

    在短时间内发送大量请求到 /api/hello

    for i in {1..150}; do curl http://localhost:8080/api/hello; done

    超过 RATE_LIMIT_MAX_REQUESTS 的请求应该会收到 429 Too Many Requests 错误。

七、代码示例

以下是一个完整的 docker-compose.yml 文件,用于启动 API Gateway 和两个模拟的后端服务:

version: "3.8"
services:
  api-gateway:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    depends_on:
      - redis
      - user-service
      - product-service
    environment:
      API_KEY_HEADER: X-API-Key
      API_KEY: your_secret_api_key
      REDIS_HOST: redis
      REDIS_PORT: 6379
      RATE_LIMIT_WINDOW: 60
      RATE_LIMIT_MAX_REQUESTS: 100
    volumes:
      - ./:/var/www/html

  user-service:
    image: nginx:latest
    ports:
      - "8081:80"
    volumes:
      - ./mock/user-service:/usr/share/nginx/html
    networks:
      - mynetwork

  product-service:
    image: nginx:latest
    ports:
      - "8082:80"
    volumes:
      - ./mock/product-service:/usr/share/nginx/html
    networks:
      - mynetwork

  redis:
    image: redis:latest
    ports:
      - "6379:6379"
    networks:
      - mynetwork

networks:
  mynetwork:

需要创建对应的 Dockerfile 文件,例如:

FROM php:8.2-fpm-alpine

RUN apk update && apk add --no-cache $PHPIZE_DEPS

RUN pecl install redis && docker-php-ext-enable redis

RUN docker-php-ext-install pdo pdo_mysql

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

WORKDIR /var/www/html

EXPOSE 8080

CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]

同时创建 mock 文件夹,其中包含 user-serviceproduct-service 两个文件夹,分别用于模拟后端服务。例如,mock/user-service/users/123/index.html 内容可以为:

<html>
  <head>
    <title>User Service</title>
  </head>
  <body>
    <h1>User ID: 123</h1>
  </body>
</html>

mock/product-service/products/456/index.html 内容可以为:

<html>
  <head>
    <title>Product Service</title>
  </head>
  <body>
    <h1>Product ID: 456</h1>
  </body>
</html>

启动 Docker Compose:

docker-compose up -d

八、进阶话题

  • 更灵活的路由规则: 可以使用更复杂的路由规则,例如根据请求方法、Header 或 Cookie 进行路由。
  • 请求转换与聚合: 可以对请求进行转换,例如修改请求头、请求体或 URL。还可以将多个请求聚合为一个请求,减少客户端的请求次数。
  • 服务发现: 可以使用服务发现机制,例如 Consul 或 Eureka,动态地获取后端服务的地址。
  • 熔断与降级: 当后端服务出现故障时,可以进行熔断或降级,防止雪崩效应。
  • 监控与告警: 集成监控系统,例如 Prometheus 和 Grafana,实时监控 API Gateway 的性能指标,并设置告警规则。
  • 使用OpenAPI规范: 可以使用OpenAPI规范(Swagger)定义API接口,并使用工具生成API文档和客户端代码。
  • 安全加固: 除了API Key认证,可以考虑使用OAuth 2.0、JWT等更安全的认证方式。同时,需要对API Gateway进行安全加固,防止SQL注入、XSS等安全漏洞。

九、总结:API Gateway 的关键实现点

通过上述步骤,我们成功构建了一个简单的 PHP API Gateway,实现了统一认证、限流和请求路由的功能。这只是一个基础示例,你可以根据实际需求进行扩展和优化。构建API Gateway的核心在于路由转发和中间件的应用。通过灵活的路由配置,可以将请求转发到不同的后端服务。通过中间件,可以实现各种功能,例如认证、授权、限流、日志记录等。

希望今天的分享对你有所帮助!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注