commune/container

使用双 IOC 容器策略以隔离每个请求的单例;适用于 swoole、roadrunner、reactPHP 等。

v0.3.1 2020-10-06 07:39 UTC

This package is auto-updated.

Last update: 2024-09-06 16:48:02 UTC


README

本项目是从 laravelilluminate/container 基础上修改而来.

目的是实现双容器策略。在 swoole、roadrunner、reactPHP 等项目中,一个 PHP 进程处理多个请求时,为每个请求提供一个进程级别的容器和一个请求级别的容器。后者可以从前者中获取实例。

使用方法

引入包

composer require commune/container

您将得到

  • ContainerTrait : 请求级 ioc 容器的 trait
  • RecursiveContainer : 递归 ioc 容器的 trait
  • IlluminateAdapter : illuminate container 的适配器。

使用 IoC 容器的 trait

定义一个 IoC 容器

/**
 * 标准的 IoC 容器.
 *
 * Class Container
 * @package Commune\Container
 */
class Container implements ContainerContract
{
    use ContainerTrait;

}

使用此容器在进程启动时注册所有工厂方法。这些方法会保存到静态属性里。

然后在请求开始时,直接 new 实例,会共享工厂方法,而生成的单例又隔离开。

class Foo {
    public $count = 0;
}

$container = new Container();
$container->singleton(Foo::class, function(){
    return new Foo;
});

$server->on('request', function($req, $res) {
    $requestContainer = new Container();

    $foo = $requestContainer->get(Foo::class);

    // 请求之间的单例是相互隔离的.
    assert(0, $foo->count);
    $foo->count ++;
});

递归容器

RecursiveContainer 是递归容器的 trait。所谓递归容器,指当前容器持有一个父级容器。如果当前容器无法获取实例,则会到父容器中查找。

因此,在 swoole 中,可以定义一个进程级 (worker) 容器,用于管理各种全局的单例,尤其是和 IO 有关的。例如 mysql、redis、日志等等。

class WorkerContainer  implements ContainerContract
{
    use ContainerTrait;
}

然后定义一个请求级容器,用于处理与请求相关,偏逻辑的信息。

class RequestContainer  implements ContainerContract
{
    use RecursiveContainer;
}

最后将父容器传给子容器,则子容器既可以拿到全局的单例,又能保证请求之间隔离。

// 定义进程级容器. 也可以用adpater去承载一个已有容器, 需要实现 ContainerContract 接口
$workerContainer = new WorkerContainer();

// 定义请求级容器.
$requestContainer = new RequestContainer($workerContainer);

// 分开注册
$workerContainer->singleton(Foo::class, function() {
    return new Foo::class;
});

$requestContainer->singleton(Bar::class, function() {
    return new Bar::class;
});

// 请求级容器可以拿到进程级容器的实例.
$foo = $requestContainer->get(Foo::class);

这样就可以实现双容器策略。至于 laravel 的 register、boot 等机制则需要自己手动去实现。Trait 只提供 container 相关的功能。

常见内存泄露原因

请求级容器每一个请求到来时会重新实例化。所以要避免容器无法释放导致内存泄露。

一些经验是,注册工厂方法时用 use ($container) 的方式从外部获取 container,或是创建实例时使用匿名类,都可能导致闭包无法释放资源,从而造成内存泄露。

推荐用一个静态变量注册已创建的请求级容器,方便查看请求级容器是否正确释放

class RequestContainer extends RecursiveContainer
{
    public static $running = [];

    protected $traceId;

    public function __construct(ContainerContract $workerContainer, string $traceId)
    {
        $this->traceId = $traceId;
        self::$running[$traceId] = true;

        parent::__constrcut($workerContainer);
    }


    public function __destruct()
    {
        unset(self::$running[$this->traceId]);
    }
}

双容器策略介绍

容器(container)技术(可以理解为全局的工厂方法),已经是现代项目的标配。基于容器,可以进一步实现控制反转,依赖注入。Laravel 的巨大成功就是构建在它非常强大的 IoC 容器 illuminate/container 基础上的。而 PSR-11 定义了标准的 container,让更多的 PHP 项目依赖容器实现依赖解耦,面向接口编程。

另一方面,PHP 天生一个进程响应一次请求的模型,已经不能完全适应开发的需要。于是 Swoole、reactPHP、roadrunner 也越来越流行。它们共同的特点是一个 php worker 进程在生命周期内要响应多个请求,甚至同一时间同时运行多个请求(协程)。

在这些引擎上使用传统只考虑单请求的容器技术,就容易发生单例相互污染,内存泄露等问题(姑且称之为 PHP 容器的"请求隔离问题")。于是出现了各种策略以解决之。

多轮对话机器人框架 CommuneChatbot 使用 swoole 做通信引擎,同时非常广泛地使用了容器和依赖注入。在本项目中使用了 "双容器策略" 来解决 "请求隔离问题",解决方案的代码在 https://github.com/thirdgerb/container

所谓"双容器策略",总结如下

  • 同时运行 "进程级容器" 与 "请求级容器"
  • "进程级容器"
    • 传统的 IoC 容器,例如 Illuminate/container
  • "请求级容器"
    • 所有工厂方法注册到容器的静态属性上
    • 在 worker 进程初始化阶段 注册服务
    • 每个请求到来后,实例化一个请求容器。
    • 请求中生成的单例,挂载到容器的动态属性上。
    • 持有"进程级容器",当绑定不存在时,到"进程级容器" 上查找之。
    • 请求结束时进行必要清理,防止内存泄露