好的,使用 Swoole 结合 PHP-FPM 实现基于不同域名指向不同目录的多租户模式是一个非常强大且灵活的方案。这种架构下,Swoole 扮演了传统架构中 Nginx 的角色:一个高性能的 HTTP 网关。
其核心思想是:
Swoole 作为前端 HTTP 服务器:接收所有客户端请求。
动态路由逻辑:在 Swoole 的 onRequest 事件中,检查请求头中的 Host 字段(即访问的域名)。
确定文档根目录:根据 Host 域名,从配置(如数组、配置文件、数据库)中查找对应的网站根目录(Document Root)。
代理到 PHP-FPM:Swoole 充当 FastCGI 客户端,将请求(包括动态计算出的 SCRIPT_FILENAME 参数)转发给后端的 PHP-FPM 服务进行处理。
返回响应:Swoole 接收 PHP-FPM 的执行结果,并将其返回给客户端。
这种方式可以让你在纯 PHP 环境中实现一个类似 Nginx server 块的功能,并且拥有 Swoole 带来的协程并发优势。
实现步骤
1. 环境准备安装 Swoole 扩展:确保你的 PHP 环境已经安装了 Swoole 扩展。
安装并运行 PHP-FPM:确保 PHP-FPM 服务正在运行,并记下其监听地址(通常是 Unix Socket 或 TCP Socket)。
例如,在 /etc/php/8.1/fpm/pool.d/www.conf 中找到 listen 配置,可能是 /run/php/php8.1-fpm.sock 或 127.0.0.1:9000。
创建租户目录和文件:
# 创建网站根目录 sudo mkdir -p /var/www/tenant-a sudo mkdir -p /var/www/tenant-b # 为租户A创建入口文件 echo "<?php echo '<h1>Welcome to Tenant A!</h1>'; phpinfo(); ?>" | sudo tee /var/www/tenant-a/index.php # 为租户B创建入口文件 echo "<?php echo '<h1>This is Tenant B speaking.</h1>'; phpinfo(); ?>" | sudo tee /var/www/tenant-b/index.php # 设置权限,确保php-fpm进程有权限读取 sudo chown -R www-data:www-data /var/www/tenant-a sudo chown -R www-data:www-data /var/www/tenant-b2. 编写 Swoole HTTP 网关服务器
我们将使用 Swoole 内置的协程 FastCGI 客户端 Swoole\Coroutine\FastCGI\Client 来与 PHP-FPM 通信。
server.php:
<?php
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Coroutine\Http\Server;
use Swoole\Coroutine\FastCGI\Client;
use Swoole\Coroutine\FastCGI\Client\Exception as FcgiException;
Co\run(function () {
// 创建 Swoole HTTP 服务器
$server = new Server('0.0.0.0', 9501, false);
// 租户配置:域名 => 网站根目录
// 在生产环境中,这应该从配置文件、数据库或服务发现中加载
$tenantConfig = [
'tenant-a.com' => '/var/www/tenant-a',
'tenant-b.com' => '/var/www/tenant-b',
];
// PHP-FPM 的监听地址 (请根据你的配置修改)
$fpmHost = 'unix:/run/php/php8.1-fpm.sock'; // Unix Socket 方式
// $fpmHost = '127.0.0.1:9000'; // TCP 方式
// 注册请求处理函数
$server->handle('/', function (Request $request, Response $response) use ($tenantConfig, $fpmHost) {
$host = $request->header['host'] ?? '';
// 1. 根据 Host 查找租户的文档根目录
$documentRoot = $tenantConfig[$host] ?? null;
if (!$documentRoot) {
$response->status(404);
$response->end("<h1>404 Not Found</h1><p>Tenant for host '{$host}' is not configured.</p>");
return;
}
// 2. 静态文件处理 (重要优化)
$filePath = $documentRoot . $request->server['request_uri'];
if (is_file($filePath) && !str_ends_with($filePath, '.php')) {
$response->sendfile($filePath);
return;
}
// 3. 准备 FastCGI 客户端和参数
try {
// 如果是 Unix Socket
if (str_starts_with($fpmHost, 'unix:')) {
$client = new Client(substr($fpmHost, 5));
} else { // 如果是 TCP Socket
list($ip, $port) = explode(':', $fpmHost);
$client = new Client($ip, (int)$port);
}
// 构造要执行的 PHP 脚本路径
// 简单的实现:总是执行 index.php
// 更完善的实现需要解析 request_uri
$scriptFilename = $documentRoot . '/index.php';
// 构建 FastCGI 参数
$params = [
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => $request->server['request_method'],
'SCRIPT_FILENAME' => $scriptFilename,
'SCRIPT_NAME' => '/index.php', // 或者基于 $request->server['request_uri']
'QUERY_STRING' => $request->server['query_string'] ?? '',
'REQUEST_URI' => $request->server['request_uri'],
'DOCUMENT_ROOT' => $documentRoot,
'SERVER_SOFTWARE' => 'swoole-http-server',
'REMOTE_ADDR' => $request->server['remote_addr'],
'REMOTE_PORT' => $request->server['remote_port'],
'SERVER_ADDR' => $request->server['server_addr'] ?? '127.0.0.1',
'SERVER_PORT' => $request->server['server_port'],
'SERVER_NAME' => $host,
'SERVER_PROTOCOL' => $request->server['server_protocol'],
'CONTENT_TYPE' => $request->header['content-type'] ?? '',
'CONTENT_LENGTH' => $request->header['content-length'] ?? '0',
];
// 添加所有 HTTP 头
foreach ($request->header as $key => $value) {
$params['HTTP_' . strtoupper(str_replace('-', '_', $key))] = $value;
}
// 4. 执行 FastCGI 请求
$fpmResponse = $client->execute($params, $request->rawContent() ?: '');
// 5. 解析并返回 FPM 的响应
// FPM 响应格式: Headers\r\n\r\nBody
list($headerStr, $body) = explode("\r\n\r\n", $fpmResponse, 2);
$headers = explode("\r\n", $headerStr);
// 设置响应头
foreach ($headers as $header) {
if (str_starts_with(strtolower($header), 'status:')) {
$response->status((int)substr($header, 8));
} else {
list($key, $value) = explode(': ', $header, 2);
$response->header($key, $value);
}
}
// 发送响应体
$response->end($body);
} catch (FcgiException $e) {
$response->status(502);
$response->end("<h1>502 Bad Gateway</h1><p>Cannot connect to PHP-FPM: {$e->getMessage()}</p>");
}
});
echo "Swoole HTTP server is started at http://0.0.0.0:9501\n";
echo "Tenant-A mapped to: " . $tenantConfig['tenant-a.com'] . "\n";
echo "Tenant-B mapped to: " . $tenantConfig['tenant-b.com'] . "\n";
$server->start();
}); 3. 运行和测试启动 Swoole 服务器:由于 PHP-FPM 的 socket 文件通常需要 root 权限才能访问,你可能需要使用 sudo 来运行。
sudo php server.php
测试:你需要模拟来自不同域名的请求。curl 的 --resolve 选项或者修改 /etc/hosts 文件是最好的方式。
测试租户A:
# 告诉 curl 将 tenant-a.com 的请求发送到 127.0.0.1:9501 curl --resolve tenant-a.com:9501:127.0.0.1 http://tenant-a.com:9501/index.php
你应该会看到 "Welcome to Tenant A!" 以及 phpinfo 的输出,其中 _SERVER['DOCUMENT_ROOT'] 会是 /var/www/tenant-a。
测试租户B:
curl --resolve tenant-b.com:9501:127.0.0.1 http://tenant-b.com:9501/index.php
你应该会看到 "This is Tenant B speaking." 以及 phpinfo 的输出,其中 _SERVER['DOCUMENT_ROOT'] 会是 /var/www/tenant-b。
测试未配置的域名:
curl --resolve other-domain.com:9501:127.0.0.1 http://other-domain.com:9501/
你会收到 "404 Not Found" 的错误信息。
生产环境的考量与增强
动态配置加载:不要将租户配置硬编码在代码里。应该从 .env 文件、YAML/JSON 配置文件,或者一个专用的数据库表中加载,以便于动态添加或修改租户。
完善的路由:上面的例子总是执行 index.php。一个真正的应用需要解析 REQUEST_URI 来确定要执行哪个 PHP 文件,这类似于 Nginx 的 try_files 指令。你可以实现一个简单的路由解析逻辑。
静态文件处理:代码中已经包含了一个基础的静态文件处理逻辑。对于生产环境,这是至关重要的性能优化,避免了所有图片、CSS、JS 请求都经过 PHP-FPM,大大减轻了后端压力。
独立的 PHP-FPM 池 (Pools):为了实现更好的资源隔离和安全性,可以为每个租户(或一组租户)配置一个独立的 PHP-FPM 池。每个池可以有自己的用户/组、进程数限制和 PHP 配置。你只需要在 Swoole 的配置中,将域名映射到对应的 FPM 池地址即可。
tenant-a.com -> unix:/run/php/fpm-pool-a.sock
tenant-b.com -> unix:/run/php/fpm-pool-b.sock
错误处理和日志:增强 try-catch 块,记录连接 FPM 失败的日志。同时,捕获 PHP-FPM 返回的错误并以适当的方式记录或展示。
通过这种方式,你用 Swoole 构建了一个功能强大、高度可定制的、纯 PHP 技术栈的多租户 Web 应用网关,完全摆脱了对 Nginx 或 Apache 的依赖。
网友回复


