jeremiah-shaulov / php-web-node

PHP-FPM实现,允许在请求之间保留资源(全局变量),因此可以创建数据库连接池

v1.0.1 2020-07-26 01:04 UTC

This package is auto-updated.

Last update: 2024-09-28 16:40:07 UTC


README

PhpWebNode是PHP-FPM实现,使用PHP编写,允许在请求之间保留资源(全局变量),因此可以创建数据库连接池。基于此库的应用程序可以用作PHP-FPM的替代品。PhpWebNode充当FastCGI服务器,Web服务器(如Apache)将发送HTTP请求到它。它将产生子进程来同步处理请求。几个子进程(最多为'pm.max_children')将并行运行,每个子进程将依次处理传入的请求,保留全局(和静态)变量。

php-web-node有多快

由于它是用PHP编写的,因此我预计我的应用程序会比PHP-FPM慢一些。但我惊讶地发现应用程序变得稍微快了一些。实际上,php-web-node消除了重新初始化资源以及重新连接到数据库的需要。

支持的内容

$_SESSION之外,我了解的大部分PHP功能都得到了支持。大多数现有的PHP脚本将像使用PHP-FPM一样工作,除了下面在“第3步。更新PHP脚本”中解释的主要区别。

PhpWebNode实现了完整的FastCGI协议,包括连接多路复用,但据我所知,目前没有流行的Web服务器支持此功能。更多信息

安装

为您的应用程序创建一个目录,进入该目录,并执行以下操作

composer require jeremiah-shaulov/php-web-node

如何使用php-web-node

要使用php-web-node,我们需要进行以下3个步骤

  1. 创建主应用程序
  2. 设置Web服务器以使用我们的应用程序
  3. 更新我们想要用php-web-node服务的PHP脚本

步骤1. 创建主应用程序

我们需要一个作为PHP-FPM服务运行的主应用程序。让我们称它为server.php

<?php

require_once 'vendor/autoload.php';
use PhpWebNode\Server;

$server = new Server
(	[	'listen' => '/run/php-web-node/main.sock', // or: '127.0.0.1:10000', '[::1]:10000'
		'listen.owner' => 'www-data',
		'listen.group' => 'johnny',
		'listen.mode' => 0700,
		'listen.backlog' => 0,
		'user' => 'johnny',
		'group' => null,
		'pm.max_requests' => 1000,
		'pm.max_children' => 2,
		'pm.process_idle_timeout' => 30,
		'request_terminate_timeout' => 10,
	]
);
$server->serve();

构造函数Server接受包含配置参数的数组。它们的含义与PHP-FPM中相同,请参阅这里。只支持上面示例中显示的参数。

johnny更改为您的用户名,或创建一个专门的用户以从它运行应用程序。

现在我们需要从控制台启动此脚本。

sudo php server.php

此脚本需要超级用户权限,因为它将创建'listen'套接字及其父目录。然后它将切换到给定配置中指定的用户。

如果我们想将此服务守护进程化,我们可以在脚本中实现守护进程化,或者我们可以使用外部软件。例如,在Ubuntu中,我们可以

sudo daemon --name=php-web-node --respawn --stdout=/tmp/php-web-node.log --stderr=/tmp/php-web-node-err.log -- php server.php

步骤2. 设置Web服务器

如果您有一个像Apache或Nginx这样的Web服务器,并且已经配置好了与PHP-FPM一起工作,您只需将socket节点名称(或端口)更改为我们的server.php中使用的即可。在上述示例中为/run/php-web-node/main.sock。以下是一个最简单的Apache示例

<VirtualHost *:80>
	ServerName wntest.com
	DocumentRoot /var/www/wntest.com

	<FilesMatch \.php$>
		SetHandler "proxy:unix:/run/php-web-node/main.sock|fcgi://"
		# or IPv4: SetHandler "proxy:fcgi://127.0.0.1:10000"
		# or IPv6: SetHandler "proxy:fcgi://[::1]:10000"
	</FilesMatch>
</VirtualHost>

文档根目录必须存在,因此请创建 /var/www/wntest.com,或者选择不同的目录,并在其中放置一些测试文件,例如 index.php。稍后您可以通过 ServerName URL 访问它。例如上面的是 wntest.com,所以完整的URL将是 http://wntest.com/index.php,或者可能是 http://wntest.com/。另外,请将 wntest.com 添加到您的 /etc/hosts 文件中,如下所示:

127.0.0.1	wntest.com

步骤 3. 更新 PHP 脚本

PHP-FPM 和 php-web-node 之间存在重要的区别。假设我们有一个这样的 index.php 脚本

<?php

if (!isset($n_request)) $n_request = 1;

echo "Request $n_request from ", posix_getpid();
$n_request++;

如果我们用 PHP-FPM 来处理这个脚本,它总是会显示 "请求 1",进程 ID 可以在请求之间改变。从大量请求中,其中一部分会显示相同的进程 ID,因为每个子进程(默认情况下)处理许多传入的请求。

如果我们用 php-web-node 来处理它,我们会看到 $n_request 在增加。你可能会看到相同的进程 ID,因为你的服务器可以处理当您用 1 个子进程刷新页面时到来的所有请求。但是,可以有多达 'pm.max_children'(在上面的例子中为 2)个进程,并且请求计数器会在每个子进程中独立增加。

子进程在每个请求的相同环境中执行 require 'index.php'。这给 index.php 能做什么设置了限制。例如

<?php

function get_n_request()
{	static $n_request = 1;
	return $n_request++;
}

echo "Request ", get_n_request(), " from ", posix_getpid();

要运行这个新脚本,您需要停止 server.php 应用程序,然后再次运行它。这个新脚本做同样的事情,但它在一个全局命名空间中声明了一个函数。第二次执行 require 'index.php' 将会报错

PHP Fatal error:  Cannot redeclare get_n_request()

有 2 种解决这个问题的方法。

  1. 将所有函数和类放入外部文件,并使用 require_once 包含它们。
  2. 使用 PhpWebNode\set_request_handler()
<?php

require_once 'vendor/autoload.php';

function get_n_request()
{	static $n_request = 1;
	return $n_request++;
}

PhpWebNode\set_request_handler
(	__FILE__,
	function()
	{	echo "Request ", get_n_request(), " from ", posix_getpid();
	}
);

如果我们调用 PhpWebNode\set_request_handler() 并给它一个文件名(通常是 __FILE__),则对文件的请求将通过调用指定的回调函数来处理,并且不会对该文件执行 require

如果您从 PHP-FPM 执行上面的脚本,它将表现得相同,因为 php-web-node 确定它不是从 PhpWebNode\Server 类中运行的,并立即执行给定的回调函数。

php-web-node 和 PHP-FPM 之间的另一个重要区别是,在 php-web-node 中,您不能使用像 header()setcookie() 等内置函数。相反,您需要使用来自 PhpWebNode 命名空间的相应函数:如 PhpWebNode\header()PhpWebNode\setcookie() 等。

<?php

require_once 'vendor/autoload.php';
use function PhpWebNode\header;

function get_n_request()
{	static $n_request = 1;
	return $n_request++;
}

PhpWebNode\set_request_handler
(	__FILE__,
	function()
	{	header("Expires: 0"); // this is PhpWebNode\header()
		echo "Request ", get_n_request(), " from ", posix_getpid();
	}
);

理解主应用程序

主应用程序配置并启动 FastCGI 服务器。它还可以执行其他操作,但重要的是要理解,不允许在它中执行阻塞系统调用,因为阻塞调用,如连接到数据库,将暂停处理传入的 HTTP 请求。以下主应用程序是好的,它只执行非阻塞操作。

<?php

require_once 'vendor/autoload.php';
use PhpWebNode\Server;

$n_requests = 0;
$requests_time_took = 0.0;

echo "Started\n";

$server = new Server
(	[	'listen' => '/run/php-web-node/main.sock',
		'listen.owner' => 'www-data',
		'listen.group' => 'johnny',
		'listen.mode' => 0700,
		'listen.backlog' => 0,
		'user' => 'johnny',
		'group' => null,
		'pm.max_requests' => 1000,
		'pm.max_children' => 10,
		'pm.process_idle_timeout' => 30,
		'request_terminate_timeout' => 10,
	]
);
$server->onerror
(	function($msg)
	{	echo $msg, "\n";
	}
);
$server->onrequestcomplete
(	function($pool_id, $messages, $time_took) use(&$n_requests, &$requests_time_took)
	{	$n_requests++;
		$requests_time_took += $time_took;
	}
);
$server->set_interval
(	function() use(&$n_requests, &$requests_time_took)
	{	echo "$n_requests requests", ($n_requests==0 ? "" : " (".round($requests_time_took/$n_requests, 3)." sec avg)"), "\n";
		$n_requests = 0;
		$requests_time_took = 0.0;
	},
	6
);
$server->serve();

$server->serve() 函数启动服务器主循环,该循环将永远运行,因此此函数不会返回或抛出异常。

该应用程序每 6 秒(每分钟 10 次)打印出完成的请求数和平均请求时间。这个时间是测量从子进程接收请求任务到从子进程收到完整响应的时间。实际的请求时间更长。

进程池

正如我们之前提到的,php-web-node 管理子进程就像 PHP-FPM 一样,每个子进程都有自己的持久资源。资源的例子是数据库连接。因此,存储在全局变量中的 PDO 对象不会重新初始化。如果我们设置 'pm.max_children' 为 10,我们将得到一个最多有 10 个槽位的数据库连接池。

如果我们想使一半的 HTTP 请求连接到一个数据库,而另一半连接到另一个数据库怎么办?默认情况下,HTTP 请求将随机分配到子进程,因此每个子进程有时会处理连接到数据库 A 的请求,有时会处理连接到数据库 B 的请求。因此,我们将从 10 个子进程中获得 20 个持久连接到 2 个数据库服务器。

PhpWebNode 允许我们在主应用程序中将每个传入的 HTTP 请求检查之后,再将其定向到子进程,主应用程序可以选择将其定向到哪个子进程组。每个子进程组被称为进程池。我们可以根据需要拥有任意多的进程池,以及每个池中任意多的子进程。

默认情况下只有一个名为 ''(空字符串)的池。'pm.max_children' 设置是每个池中进程的最大数量。

为了捕获并检查传入的 HTTP 请求,我们可以设置 $server->onrequest() 回调。

public function onrequest(callable $onrequest_func=null, int $catch_input_limit=0)

$onrequest_func 回调接收一个 Request 对象,它具有以下字段

  • $request->server - 请求的 $_SERVER
  • $request->get - 请求的 $_GET
  • $request->post - 请求的 $_POST。PhpWebNode 将在调用 $onrequest_func 之前读取并缓冲至少 $catch_input_limit 字节的请求 POST 主体。如果主体更长,则 $request->post 将仅包含迄今为止读取的完整参数。如果主体的 Content-Type 不是 application/x-www-form-urlencodedmultipart/form-data,则 $request->post 将为空数组。
  • $request->input - 请求的 file_get_contents('php://input')。如果 POST 主体长于 $catch_input_limit,它将是不完整的。如果 Content-Type 是 multipart/form-data,则此值为空字符串。
  • $request->input_complete - 如果 $request->input 是完整的 POST 主体,则为 true。
  • $request->content_type - 请求 $_SERVER['CONTENT_TYPE'] 在第一个分号之前的小写子串。

$onrequest_func 允许您通过返回池 ID 或名称(字符串)来决定将传入的 HTTP 请求转发到哪个池。

<?php

require_once 'vendor/autoload.php';
use PhpWebNode\{Server, Request};

$server = new Server
(	[	'listen' => '/run/php-web-node/main.sock',
		'listen.owner' => 'www-data',
		'listen.group' => 'johnny',
		'listen.mode' => 0700,
		'listen.backlog' => 0,
		'user' => 'johnny',
		'group' => null,
		'pm.max_requests' => 1000,
		'pm.max_children' => 5,
		'pm.process_idle_timeout' => 30,
		'request_terminate_timeout' => 10,
	]
);
$server->onrequest
(	function(Request $request)
	{	$db_id = $request->get['db-id'] ?? null;
		return $db_id=='a' ? 'A' : 'B';
	},
	8*1024
);
$server->serve();

返回固定数量的值选项很重要。在上述示例中,我们返回了 2 个选项:'A' 和 'B',因此我们将得到 2 个池,每个池中有 5 个 ('pm.max_children') 进程。如果您停止从 $onrequest_func 回调中返回某个值,相应的池最终将被释放。

子进程可以通过调用 PhpWebNode\get_pool_id() 来检查其池 ID。

$onrequest_func 回调可以做的另一件事是抛出异常来取消请求,而无需将其转发到子进程。

$server->onrequest
(	function(Request $request)
	{	if (empty($request->get['page-id']))
		{	http_response_code(404);
			PhpWebNode\header('Expires: '.gmdate('r', time()+60*60));
			throw new Exception("Page doesn't exist"); // cancel the request
		}
	}
);

PHP MySQL 连接池实现

如我们所见,进程池可以像数据库连接池一样工作。

请注意,在 PHP 中没有重置 MySQL 连接的方法,至少我并不知道有这种方法。因此,在每个请求的开始,我们需要清理我们可以清理的内容,并且如果事务未由前一个请求提交,我们可以回滚正在进行的事务。

此外,您还需要知道,根据您执行的查询,MySQL 端的内存消耗可能会随着每个查询而下降。最终,这可能会使 MySQL 服务器无响应。因此,我们对可以重用连接的次数有限制,并且无论如何都需要定期重新连接。即使每个连接重用 10 次,也会大大释放系统中的网络压力。

在我的实验中,使用 PHP-FPM,我始终看到 2500 个打开的套接字,其中几乎所有都处于 TIME_WAIT 状态。

sudo netstat -putnw | wc -l

使用 php-web-node 将每个连接重用 10 次,这个数字减少到 700。

实现数据库连接池的客户端脚本示例

<?php

const DB_DSN = 'mysql:host=localhost;dbname=information_schema';
const DB_USER = 'root';
const DB_PASSWORD = 'root';
const RECONNECT_EACH_N_REQUESTS = 10;

function get_pdo()
{	static $pdo = null;
	static $n_request = 0;

	if ($n_request++ % RECONNECT_EACH_N_REQUESTS == 0)
	{	$pdo = new PDO(DB_DSN, DB_USER, DB_PASSWORD);
		$n_request = 1;
	}
	else
	{	// Reset the connection
		$pdo->exec("ROLLBACK");
	}

	return $pdo;
}

PhpWebNode\set_request_handler
(	__FILE__,
	function()
	{	$pdo = get_pdo();
		$cid = $pdo->query("SELECT Connection_id()")->fetchColumn();
		echo "Connection ID = $cid";
	}
);

像往常一样,在主应用程序中我们指定池参数

<?php

require_once 'vendor/autoload.php';
use PhpWebNode\Server;

$server = new Server
(	[	'listen' => '/run/php-web-node/main.sock',
		'listen.owner' => 'www-data',
		'listen.group' => 'johnny',
		'listen.mode' => 0700,
		'listen.backlog' => 0,
		'user' => 'johnny',
		'group' => null,
		'pm.max_requests' => 1000,
		'pm.max_children' => 5,
		'pm.process_idle_timeout' => 30,
		'request_terminate_timeout' => 10,
	]
);
$server->serve();

连接池将支持最多 pm.max_children 个并发数据库连接。如果其中任何一个连接在 pm.process_idle_timeout 秒内保持空闲,它将被关闭。每个 pm.max_requests 请求的子进程将被淘汰,因此我们可以将 pm.max_requests 设置为 10,这样数据库连接会在每 10 个请求后重新连接,但最好将 pm.max_requests 设置为一个较大的值,因为这将节省因停止子进程和再次创建子进程而耗费的 CPU。