你们中的一些人可能知道,我有一个宠物项目,我喜欢时不时地在其中工作。这个项目是一个名为Shlink的自我托管的URL缩短器。
Shlink是使用expressive作为HTTP调度任务的基础框架而建立的。不久前,一个expressive模块被发布,正式支持用swoole为expressive应用提供服务,我决定将其纳入Shlink。
swoole是如何工作的?
Swoole是一个异步非阻塞I/O框架,其工作方式类似于node.js,但适用于PHP应用。
它的设计方式是,应用程序在两次请求之间保持在内存中,消除了引导步骤,使应用程序的服务速度大大加快。
Swoole可以根据你的要求同时提供多个请求。为此,它使用了工作者。每个工作者一次可以提供一个请求,每个工作者都有一个单独的应用程序实例加载在内存中(这是我最近学到的东西)。
正因为如此,你有时必须改变一下你设计代码的方式。例如,如果一个服务需要是有状态的(它不应该,但狗屎发生了),你将不得不记住,状态将在请求之间持续存在,你将希望以某种方式重置它以避免意外的副作用。
这对EntityManager有什么影响?
在Shlink中保持这种内部状态的主要服务之一是学说EntityManager。
由于它最初被设计为用于经典的Web服务器+CGI上下文,假设它在每次请求时都会被重新创建,因此有一些考虑因素需要考虑。
- 它实现了工作单元的模式。它在内部保持对所有已经创建/改变的实体的跟踪,直到你决定冲洗它。
- 如果出现故障,
EntityManager可能会被关闭,这使得它在那一刻之后无法使用。 - 如果数据库连接过期,
EntityManager将不会优雅地恢复。相反,它将会失败,因为它不知道连接不能使用,使你陷入前述的困境。
如何解决这个问题
我通过尝试和错误了解到这些问题,通过在应用程序上使用它并得到失败的结果。现在,我想分享一下我是如何解决这些问题的。
我面临的第一个问题是,在关闭EntityManager ,使应用程序无法使用。为了解决这个问题,我创建了一个装饰的实例(命名,一个实现EntityManagerInterface 的对象,它反过来又包装了一个实际的EntityManager ),这确保了被包装的实例在某个时候被关闭时能透明地重新创建。
然而,这造成了一些副作用,我最终意识到,最好是让EntityManager 必须从外部重新打开,而且每次请求只能打开一次(这更接近于它在经典设置中的行为方式)。
这就是我想出的Shlink目前使用的ReopeningEntityManager (从v1.20.0开始)。
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\ORM\Decorator\EntityManagerDecorator;
class ReopeningEntityManager extends EntityManagerDecorator
{
/** @var callable */
private $createEm;
public function __construct(callable $createEm)
{
parent::__construct($createEm());
$this->createEm = $createEm;
}
public function open(): void
{
if (! $this->wrapped->isOpen()) {
$this->wrapped = ($this->createEm)();
}
}
}
你可以在shlink-common中找到实际的代码。
它只是期望有一个工厂来创建实际的EntityManager ,并暴露出一个open 的公共方法,如果关闭的话,就会重新创建被包裹的实例。
这个方法是由PSR-15中间件调用的,该中间件需要在中间件流水线的早期注册。它确保该方法在栈上的下一个中间件被调用之前被调用。
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
class CloseDbConnectionMiddleware implements MiddlewareInterface
{
/** @var ReopeningEntityManager */
private $em;
public function __construct(ReopeningEntityManager $em)
{
$this->em = $em;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->em->open();
try {
return $handler->handle($request);
} finally {
$this->em->getConnection()->close();
$this->em->clear();
}
}
}
同样,代码可以在shlink-common中找到。
这个中间件还做了两件事(这次是在调用堆栈上的下一个中间件之后,并确保在抛出异常时也能做到)。
- 它首先关闭了数据库连接。通过这样做,我们确保
EntityManager在下次需要与数据库交互时将重新连接,因此,我们避免了 "连接过期 "问题。 - 然后,它自己清除了
EntityManager。这就避免了内存泄漏,同时也防止了任何孤儿/未刷新的实体 "跨越 "到下一个请求,解决了第一个问题。
进一步的考虑
到目前为止,这种方法一直运行良好,但重要的是要假设EntityManager 并不是真正为这种非阻塞式的环境设计的。
你需要知道,在EntityManager 完成其工作之前,利用它的工作者将不能为任何请求服务。正因为如此,你可能想增加swoole工作者的数量。
另外,在每个请求中关闭DB连接并不是最理想的方法。如果有一个连接池,能够在过期时重新创建连接,并尽可能地保持连接的开放,以加快操作速度,那就更完美了。然而,EntityManager 不是为此而设计的,所以最好是关闭它。
你还需要一个callable 来传递给ReopeningEntityManager 。你解决这个问题的方式取决于你的实现和你使用的依赖关系。
在我的例子中,在Shlink中我使用了ServiceManager ,所以我有一个委托工厂,它接收实际的工厂并将其传递给ReopeningEntityManager 。
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Psr\Container\ContainerInterface;
class ReopeningEntityManagerDelegator
{
public function __invoke(ContainerInterface $container, string $name, callable $createEm): ReopeningEntityManager
{
return new ReopeningEntityManager($createEm);
}
}
你可以在shlink-common中找到实际的代码。
总结
我想把这篇文章也写成对自己的总结,因为我不得不多次询问和调查,而且我找不到一个解释一切的真理来源。
我希望,如果你最终来到这里,这篇文章对你有好处。