简介:

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

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

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

多轮对话机器人框架 CommuneChatbot 使用 swoole 做通信引擎, 同时非常广泛地使用了容器和依赖注入. 在本项目中使用了 “双容器策略” 来解决 “请求隔离问题” .

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

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

解决方案的代码在 https://github.com/thirdgerb/container 创建了一个 composer 包 commune/container

容器的”请求隔离”问题

关于容器, 控制反转与依赖注入

为防止部分读者不了这些概念, 简单说明一下.

所谓容器, 相当于一个全局的工厂. 可以在这里 “注册” 各种服务的工厂方法, 再使用容器统一地获取. 例如

 $container = new Container();

 // 绑定一个单例
$container->singleton(
// 绑定对象的ID, 通常是 interface, 以实现面向接口编程.
UserInterface::class,
// 生成实例的工厂方法.
function() {
return new class implements UserInterface{};
}
); // 从容器中获取实例
$user = $container->get(UserInterfacle::class); $user instanceof UserInterface; // true

当一个类的实例在容器中生成, 或者一个方法被容器调用时, 就可以方便地实现依赖注入.

简单来说, 容器通过反射机制可获取目标方法的依赖 ( laravel 用反射来获取 typehint 类型约束, 而 Swoft项目似乎与spring 相似, 是从注释上获取的).

然后容器查找是否已注册了 依赖 (dependency) 的实现 (resolver), 如果已注册, 就从容器中生成该依赖, 再注入给目标方法.

具有依赖注入能力的容器, 我们称之为 IoC (控制反转) 容器. 关于IoC 容器的好处不是本文重点, 先跳过去了.

IoC 容器的请求隔离问题

容器最典型的应用场景之一, 就是持有单例. 但在 swoole 等引擎上, 一个 worker 进程要响应多个请求, 单例的数据就容易相互污染.

例如我们把 session 的数据放在 一个 SessionInterface 中, 每个逻辑调用时都用容器来取:

$sessionInstance = container()->make(SessionInterface::class);

由于单例在容器内只生成一次, 那第二次请求时, 容器会给出第一次请求的session单例, 从而逻辑就乱套了.

所以容器要运行在 swoole 等引擎上, 必须做到请求与请求相隔离.

常见的解决策略

由于 Laravel 等使用了IoC 容器的项目能带来极好的工程体验, 而Swoole 能带来极大的性能提升, 于是有许多试图结合两者的项目, 都面临了 “请求隔离问题”.

我个人看到过的解决策略有以下三种, 都能一定程度解决问题, 但也有美中不足之处.

  • 克隆策略:
    • 方案: 每次请求, 克隆一个新的 container
    • 问题:
      • 要递归地 clone 属性, 才能避免浅拷贝导致的污染
      • 无法区分进程共享的单例, 和请求隔离的单例.
  • 清洗策略:
    • 方案: 每次请求结束时, 主动清洗掉已注册的单例
    • 问题:
      • 定义类时就要考虑清洗逻辑, 可能要实现interface, 耦合较重
      • swoole 发展到协程后, 同时可能相应多个请求, 清晰策略失效了.
  • 重新注册:
    • 方案: 每个请求到来时, 实例化一个新容器, 重新注册所有服务
    • 问题:
      • 注册服务其实开销很大, 尤其是需要大量读文件的初始化(比如翻译组件)
      • 无法区分进程共享的单例, 和请求隔离的单例.
      • 利用不了 swoole 的优势, 比起多进程模型只少了 composer autoloader 的加载.

CommuneChatbot 遇到的请求隔离问题

多轮对话机器人框架 CommuneChatbot 在启动时需要加载大量多轮对话的逻辑, 消耗时间长 (>100ms), 但实际响应对话的时间不到 10ms. 所以本项目 必须使用 swoole 这类引擎, 不可能用PHP天生的多进程, 那样就只是一个低性能的玩具了.

另一方面, 为了实现

  • 可配置化
  • 组件化
  • 面向接口编程
  • 灵活的闭包

等 feature, CommuneChatbot 严重依赖 IoC 容器. 所以识别要解决请求隔离的问题.

由于原有三种策略的不足之处都是本项目无法绕开的, 因此设计了 “双容器策略”.

CommuneChatbot 的双容器策略

本项目使用的双容器策略是一个通用的策略, 代码在 https://github.com/thirdgerb/container, 是由 Illuminate/Container 项目修改而来.

暂未发布版本, clone 后可以查看实现.

简单来说, 就是在一个 worker 进程中, 存在两种级别的容器:

  • 进程级容器: 一个进程只有一个实例
  • 请求级容器: 每一个请求拥有一个独立的实例

“进程级” 与 “请求级” 容器分开注册服务

CommuneChatbot 中, 类似 laravel 的 serviceProvider 分两处注册.

 // 在worker中注册的服务, 多个请求共享
'processProviders' => [ // 基础组件加载
Studio\Providers\StudioServiceProvider::class,
// 默认的情感单元, 可以把意图或者message 映射成情感
Studio\Providers\FeelingServiceProvider::class, ], // 在conversation开始时才注册服务, 其单例在每个请求之间是隔离的.
'conversationProviders' => [ // 数据读写的组件, 用到了laravel DB 的redis 和 mysql
\Commune\Chatbot\Laravel\Providers\LaravelDBServiceProvider::class,
// 各种权限功能的管理.
Studio\Providers\AbilitiesServiceProvider::class, ],

服务开发者不需要太多考虑是进程级, 还是请求级, 只要避免用到静态属性. 系统搭建者才要考虑

“请求级”容器持有”进程级”容器

CommuneChatbot 使用 trait 改造了 laravel 的 illuminate/container, 以此为基础实现了 递归容器 RecursiveContainer.

 trait RecursiveContainer
{
use ContainerTrait;
/**
* 不用静态属性, 静态属性在子类继承上会有问题.
*
* @var ContainerContract
*/
protected $parentContainer;
/**
* RecursiveContainer constructor.
* @param ContainerContract $parentContainer
*/
public function __construct(ContainerContract $parentContainer)
{
$this->parentContainer = $parentContainer;
} public function getParentContainer() : ContainerContract
{
return $this->parentContainer;
} public function has($abstract)
{
return $this->bound($abstract) || $this->parentContainer->has($abstract);
} /**
* @param string $abstract
* @param array $parameters
* @return mixed
* @throws
*/
public function make(string $abstract, array $parameters = [])
{
// 做个最高效的判断环节. 绝大部分都是单例.
if (isset($this->shared[$abstract])) {
return $this->shared[$abstract];
}
// 优先自己绑定的对象.
// 只有自己没有绑定, 且父容器有绑定的情况下, 才通过父类来做实例化.
if (!$this->bound($abstract) && $this->parentContainer->has($abstract)) {
return $this->parentContainer->make($abstract, $parameters);
}
return $this->resolve($abstract, $parameters);
}

简单来说, 每个递归容器都可以持有一个父容器. 如果某个服务调用 在自己内未注册, 就会到父容器里查找. 父容器也是递归容器的话, 就会递归式查找.

这样, 进程级共享的单例, 就可以注册到 “进程级容器” . 而请求相互隔离的单例, 就注册到 “请求级容器”.

请求内都用 “请求级容器” 来获取实例, 这样就充分灵活了.

“请求级” 容器用静态属性注册服务, 动态属性持有单例

伪代码如下:

 trait ContainerTrait
{ /**
* 请求级容器持有的单例
* @var array
*/
protected $shared = []; /**
* 请求级容器注册的服务
*
* @var callable[]
*/
private static $bindings = [];

这样, 所有服务只需要注册一次, 但服务的单例在每个请求内会重新生成一次.

“请求级容器” 在worker进程初期boot, 每个请求到来时实例化

CommuneChatbot 中的一个代码示例

伪代码如下:

 class SwoolServer
{
/**
* Swoole/Server
*/
protected $server; protected $app; public function run()
{
$this->bootstrap();
} public function bootstrap()
{
// 注册 service provider
// 运行 service provider 的boot 方法
// 进程级和请求级都在这个环节完成初始化
$this->app->bootWorker(); $this->server->on(
'receive',
function($server, $fd, $rid, $data){ // 获取 请求级容器 的 公共容器.
$container = $this->app->getRequestContainer(); // 实例化一个请求隔离的 容器
$requestContainer = $container->new($server, $fd, $rid, $data); // 运行请求内的逻辑
$requestContainer->run();
}
);
} }

使用 Laravel Application 作为 进程级容器

CommuneChatbot 的 framework 是不依赖大型项目的. 但在开发 Studio 时, 发现还是需要一个类似 Laravel 的全栈框架.

所以直接使用了 Laravel 的 Application 做 “进程级容器”, 确保自己请求中用到的核心业务逻辑都不注册到 laravel中, 避免污染.

由于双容器策略基于共同的 interface 开发, 所以只需要为 Laravel Application 定制一个 illuminateAdapter 就可以了

防止内存泄露

使用 swoole, 如果逻辑写得不好导致一些对象相互持有, 无法释放, 则会导致内存泄露. 而且 php 目前排查内存泄露挺有难度.

使用双容器技术, 反而某种意义上方便了排查内存泄露.

因为 CommuneChatbot 是基于依赖注入来启动, 运行的, 请求内生成的绝大多数对象都来自于 IoC 容器, 并为之持有.

一旦 IoC 容器自身在请求结束后无法释放, 就一定发生了请求内的内存泄露.

CommuneChatbot 定义的 请求级容器, 在 __construct 和 __destruct 方法中做了简单的埋点, 伪代码如下:

 class Container {

     protected static $running = [];

     protected $traceId;

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

就可以通过静态属性查看运行中的 container 实例个数.

CommuneChatbot 甚至在 Demo 中提供了一个 #runningSpy -a 的命令. 在公众号中随时输入它, 可以查看当前 worker 进程中几个关键对象的实例数量.

如果实例数随请求线性上升, 那就一定是严重的内存泄露了. 如果只是很少概率的内存泄露, 问题还不大. Swoole 有参数 max_request 定期重启 worker 进程.

就我发现, 最容易导致内存泄露的两种情况:

  • 某个闭包在每次请求时生成一个闭包实例, 被每个容器持有
  • 容器生成的某个服务是匿名类, 导致相互持有

简单来说, 就是定义闭包和匿名类时, 慎重考虑内存泄露的可能性就行.

双容器策略在 CommuneChatbot 项目中的效果

CommuneChatbot 目前使用双容器, Demo 在微信公众号 CommuneChatbot 上运行.

项目默认启动要 80 ms 以上, 而不用读写数据库完成单个请求平均在 3ms 左右.

[2019-07-20 12:20:03] chatbot.INFO: end chat pipe {"gap":2791,"memory":10485760}

Swoole 除了免去了每次请求启动系统的开销之外, 还带来了额外的性能提升:

由于大量使用 PHP 的反射特性来实现复杂的依赖注入, 所以反射本应该是性能开销的大头. 但 PHP 其实有个内部机制, 反射调用一次就会缓存起来, 下次调用的开销是之前的几十分之一.

所以用swoole, 还可能提升了整体依赖注入的性能.

微信公众号上的 CommuneChatbot Demo 目前运行了数千个请求, 查看日志还没有发生一例内存泄露.

进程级容器 Laravel Application 也与 CommuneChatbot 自己的 ConversationContainer 结合得很好. 目前没发现任何问题.

整体结果令人乐观, 对我而言这是目前最合适的解决策略.

PHP 在Swoole中使用双IoC容器实现无污染的依赖注入的更多相关文章

  1. ASP.NET Core Web 应用程序系列(一)- 使用ASP.NET Core内置的IoC容器DI进行批量依赖注入(MVC当中应用)

    在正式进入主题之前我们来看下几个概念: 一.依赖倒置 依赖倒置是编程五大原则之一,即: 1.上层模块不应该依赖于下层模块,它们共同依赖于一个抽象. 2.抽象不能依赖于具体,具体依赖于抽象. 其中上层就 ...

  2. spring源码解析之IOC容器(三)——依赖注入

    上一篇主要是跟踪了IOC容器对bean标签进行解析之后存入Map中的过程,这些bean只是以BeanDefinition为载体单纯的存储起来了,并没有转换成一个个的对象,今天继续进行跟踪,看一看IOC ...

  3. 通过laravel理解IoC(控制反转)容器和DI(依赖注入)

    原文地址: http://www.insp.top/learn-laravel-container ,转载务必保留来源,谢谢了! 容器,字面上理解就是装东西的东西.常见的变量.对象属性等都可以算是容器 ...

  4. Ioc 器管理的应用程序设计,前奏:容器属于哪里? 控制容器的反转和依赖注入模式

    Ioc 器管理的应用程序设计,前奏:容器属于哪里?   我将讨论一些我认为应该应用于“容器管理”应用程序设计的原则. 模式1:服务字典 字典或关联数组是我们在软件工程中学到的第一个构造. 很容易看到使 ...

  5. 谈谈php里的IOC控制反转,DI依赖注入

    理论 发现问题 在深入细节之前,需要确保我们理解"IOC控制反转"和"DI依赖注入"是什么,能够解决什么问题,这些在维基百科中有非常清晰的说明. 控制反转(In ...

  6. IoC控制反转与DI依赖注入

    IoC控制反转与DI依赖注入 IoC: Inversion of Control IoC是一种模式.目的是达到程序的复用.下面的两篇论文是对IoC的权威解释: InversionOfControl h ...

  7. 学习Unity -- 理解依赖注入(IOC)三种方式依赖注入

    IOC:英文全称:Inversion of Control,中文名称:控制反转,它还有个名字叫依赖注入(Dependency Injection).作用:将各层的对象以松耦合的方式组织在一起,解耦,各 ...

  8. Spring-初识Spring框架-IOC控制反转(DI依赖注入)

    ---恢复内容开始--- IOC :控制反转 (DI:依赖注入)使用ioc模式开发 实体类必须有无参构造方法1.搭建Spring环境下载jarhttp://maven.springframework. ...

  9. Ioc(控制反转)、DI(依赖注入)

    一篇非常好的有关控制反转和依赖注入非常不错的文章,简单易通,与大家共同学习,这里只引用了一篇文章,还有很多相关的文章可以通过文章引用地址来看,相信大家看完理解的就比较深刻了 文章摘自:http://j ...

随机推荐

  1. 安装docker 在centos中

    http://www.imooc.com/article/16448 http://blog.csdn.net/jeffleo/article/details/70904368

  2. C语言1博客作业03

    这个作业属于哪个课程 C语言程序设计1 这个作业要求在哪里 (https://edu.cnblogs.com) 我在这个课程的目标是 掌握函数运算 我在这个作业哪个具体方面帮助实现目标 编译一些基本生 ...

  3. 玩转 RTC时钟库 DS3231

    1.前言     接着博主的上一篇 玩转 RTC时钟库 + DS1302,这一篇我们重点讲解DS3231时钟模块.没有看过上一篇的同学,麻烦先去阅读一下,因为很多理论基础已经在上一篇做了详细讲解,这里 ...

  4. springboot security+redis+jwt+验证码 登录验证

    概述 基于jwt的token认证方案 验证码 框架的搭建,可以自己根据网上搭建,或者看我博客springboot相关的博客,这边就不做介绍了.验证码生成可以利用Java第三方组件,引入 <dep ...

  5. vue-class-component使用Mixins

    vue-class-component提供了mixinshelper函数,以类样式的方式使用mixins.通过使用mixins帮助程序,TypeScript可以推断mixin类型并在组件类型上继承它们 ...

  6. C语言文件输入/输出 ACM改进版(用freopen函数方便检验)

    这次用到的文件打开函数不再是fopen,而是stdio.h中包含的另一个函数freopen FILE * freopen ( const char * filename,const char * mo ...

  7. ReoGrid.Mvvm:ReoGrid绑定模型

    ReoGrid 是 C# 编写的.NET 电子表格控件(类似 Excel).支持单元格合并,边框样式,图案背景颜色,数据格式,冻结,公式,宏和脚本执行,表格事件等.支持 Winform\WPF. Re ...

  8. Java基础(40)String、StringBuilder和StringBuffer的区别(TODO)

    一.String String实现了Serializable接口.Comparable<String>接口和CharSequence接口,并且使用final char value[]不可变 ...

  9. SpringCloud配置中心集成Gitlab(十五)

    一 开始配置config服务 config-server pom.xml <dependency> <groupId>org.springframework.cloud< ...

  10. ArcGIS Engine添加地图元素的实现

    在ArcGIS中,我们使用的制图控件除了MapControl之外,还有PageLayoutControl,用于页面布局和制图,生成一幅成品地图. PageLayoutControl 封装了PageLa ...