2020年10月

这篇文章转载自好未来学而思技术团队,分享老师:李乐,本文基于Swoole-4.3.2和PHP-7.1.0版本

Swoole协程简介

Swoole4为PHP语言提供了强大的CSP协程编程模式,用户可以通过go函数创建一个协程,以达到并发执行的效果,如下面代码所示:

//Co::sleep() 是 Swoole 提供的 API,并不会阻塞当前进程,只会阻塞协程触发协程切换
go(function() {
    Co::sleep(1);
    echo "a";
});

go(function() {
    Co::sleep(2);
    echo "b";
});

echo "c";

//输出结果:cab
//程序总执行时间 2 秒

其实在Swoole4之前就实现了多协程编程模式,在协程创建、切换以及结束的时候,相应的操作php栈即可(创建、切换以及回收php栈)。

此时的协程实现无法完美的支持php语法,其根本原因在于没有保存c栈信息。(vm内部或者某些扩展提供的API是通过c函数实现的,调用这些函数时如果发生协程切换,c栈该如何处理?)

Swoole4新增了c栈的管理,在协程创建、切换以及结束的同时会伴随着c栈的创建、切换以及回收。

Swoole4协程实现方案如下图所示:

2023-03-25T15:37:46.png

其中:

  • API层是提供给用户使用的协程相关函数,比如go()函数用于创建协程;Co::yield()使得当前协程让出CPU;Co::resume()可恢复某个协程执行;
  • Swoole4协程需要同时管理c栈与php栈,Coroutine用于管理c栈,PHPCoroutine用于管理php栈;其中Coroutine(),yield(),resume()实现了c栈的创建以及换入换出;create_func(),on_yield(),on_resume()实现了php栈的创建以及换入换出;
  • Swoole4在管理c栈时,用到了boost.context库,make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈上下文的创建以及切换;
  • Swoole4对boost.context进行了简单封装,即Context层,Context(),SwapIn()以及SwapOut()对应c栈的创建以及换入换出。

深入理解C栈

函数是对代码的封装,对外暴露的只是一组指定的参数和一个可选的返回值;假设函数P调用函数Q,Q执行后返回函数P,实现该函数调用需要考虑以下三点:

  • 指令跳转:进入函数Q的时候,程序计数器必须被设置为Q的代码的起始地址;在返回时,程序计数器需要设置为P中调用Q后面那条指令的地址;
  • 数据传递:P能够向Q提供一个或多个参数,Q能够向P返回一个值;
  • 内存分配与释放:Q开始执行时,可能需要为局部变量分配内存空间,而在返回前,又需要释放这些内存空间;

大多数语言的函数调用都采用了栈结构实现,函数的调用与返回即对应的是一系列的入栈与出栈操作,我们通常称之为函数栈帧(stack frame)。示意图如下:

2023-03-25T15:38:42.png

上面提到的程序计数器即寄存器%rip,另外还有两个寄存器需要重点关注:%rbp指向栈帧底部,%rsp指向栈帧顶部。

下面将通过具体的代码事例,为读者讲解函数栈帧。c代码与汇编代码如下:

2023-03-25T15:39:14.png

2023-03-25T15:39:25.png

分析汇编代码:

  • main函数与add函数入口,首先将寄存器%rbp压入栈中用于保存其值,其次移动%rbp指向当前栈顶部(此时%rbp,%rsp都指向栈顶,开始新的函数栈帧);
  • main函数"subq $16, %rsp",是为main函数栈帧预留16个字节的内存空间;
  • 调用add函数时,第一个参数和第二个参数分别保存在寄存器%edi和%esi,返回值保存在寄存器%eax;
  • call指令用于函数调用,实现了两个功能:寄存器%rip压入栈中,跳转到新的代码位置;
  • ret指令用于函数返回,弹出栈顶内容到寄存器%rip,依次实现代码跳转;
  • leave指令等同于两条指令:movq %rsp,%rbp和popq %rbp,用于释放main函数栈帧,恢复前一个函数栈帧;
  • 注意add函数栈帧,并没有为其预留空间,寄存器%rsp和%rbp都指向栈帧底部;根本原因是add函数没有调用其他函数。

该程序的栈结构示意图如下:

2023-03-25T15:40:05.png

问题:观察上面的汇编代码,输入参数分别使用的是寄存器%edi和%esi,返回值使用的是寄存器%eax,输入输出参数不应该保存在栈上吗?寄存器比内存访问要快的多,现代处理器寄存器数目也比较多,因此倾向于将参数优先保存在寄存器。比如%rdi, %rsi, %rdx, %rcx, %r8d, %r9d 六个寄存器用于存储函数调用时的前6个参数,那么当输入参数数目超过6个时,如何处理?这些输入参数只能存储在栈上了。
(%rdi等表示64位寄存器,%edi等表示32位寄存器)

2023-03-25T15:40:26.png

Swoole C栈管理

通过学习c栈基本知识,我们知道最主要有三个寄存器:%rip程序计数器指向下一条需要执行的指令,%rbp指向函数栈帧底部,%rsp指向函数栈帧顶部。这三个寄存器可以确定一个c栈执行上下文,c栈的管理其实就是这些寄存器的管理。

第一节我们提到Swoole在管理c栈时,用到了boost.context库,其中make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈执行上下文的创建以及切换;函声明命如下:

fcontext_t make_fcontext(void *sp, size_t size, void (*fn)(intptr_t));
intptr_t jump_fcontext(fcontext_t *ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);

make_fcontext函数用于创建一个执行上下文,其中参数sp指向内存最高地址处(在堆中分配一块内存作为该执行上下文的c栈),参数size为栈大小,参数fn是一个函数指针,指向该执行上下文的入口函数;代码主要逻辑如下:

2023-03-25T15:40:54.png

make_fcontext函数创建的执行上下文示意图如下(可以看到预留了若干字节用于保存上下文信息):

2023-03-25T15:42:12.png

Swoole协程实现的Context层封装了上下文的创建,创建上下文函数实现如下:

2023-03-25T15:42:27.png

可以看到c栈执行上下文是通过sw_malloc函数在堆上分配的一块内存,默认大小为2M字节;参数sp指向的是内存最高地址处;执行上下文的入口函数为Context::context_func()。

jump_fcontext函数用于切换c栈上下文:

  1. 函数会将当前上下文(寄存器)保存在当前栈顶(push),同时将%rsp寄存器内容保存在ofc地址;
  2. 函数从nfc地址处恢复%rsp寄存器内容,同时从栈顶恢复上下文信息(pop)。

代码主要逻辑如下:

2023-03-25T15:43:50.png

观察jump_fcontext函数的汇编代码,可以看到保存上下文与恢复上下文的代码基本是对称的。恢复上下文时"popq %r8"用于弹出上一次保存的程序计数器%rip的内容,然而并没有看到保存寄存器%rip的代码。这是因为调用jump_fcontext函数时,底层call指令已经将%rip入栈了。

Swoole协程实现的Context层封装了上下文的换入换出,可以在上下文swap_ctx_和ctx_之间随时换入换出,代码实现如下:

2023-03-25T15:44:28.png

上下文示意图如下所示:

2023-03-25T15:45:07.png

Swoole PHP栈管理

php代码在执行时,同样存在函数栈帧的分配与回收。php将此抽象为两个结构,php栈zend_vm_stack,与执行数据(函数栈帧)zend_execute_data。

php栈结构与c栈结构基本类似,定义如下:

struct _zend_vm_stack {
    zval *top;
    zval *end;
    zend_vm_stack_prev;
}

其中top字段指向栈顶位置,end字段指向栈底位置;prev指向上一个栈,形成链表,当栈空间不够时,可以进行扩容。php虚拟机申请栈空间时默认大小为256K,Swoole创建栈空间时默认大小为8K。

执行数据结构体,我们需要重点关注这几个字段:当前函数编译后的指令集(opline指向指令集数组中的某一个元素,虚拟机只需要遍历该数组并执行所有指令即可),函数返回值,以及调用该函数的执行数据;结构定义如下:

2023-03-25T15:46:47.png

php栈初始化函数为zend_vm_stack_init;当执行用户函数调用时,虚拟机通过函数zend_vm_stack_push_call_frame在php栈上分配新的执行数据,并执行该函数代码;函数执行完成后,释放该执行数据。代码逻辑如下:

2023-03-25T15:46:59.png

php栈帧结构示意图如下:

2023-03-25T15:47:25.png

Swoole协程实现,需要自己管理php栈,在发生协程创建以及切换时,对应的创建新的php栈,切换php栈,同时保存和恢复php栈上下文信息。这里涉及到一个很重要的结构体php_coro_task:

2023-03-25T15:47:40.png

这里列出了php_coro_task结构体的若干关键字段,这些字段用于保存和恢复php上下文信息。

协程创建时,底层通过函数PHPCoroutine::create_func实现了php栈的创建:

2023-03-25T15:53:51.png

从代码中可以看到结构php_coro_task是直接存储在php栈的底部。

当通过yield函数让出CPU时,底层会调用函数 PHPCoroutine::on_yield 切换 php 栈:

2023-03-25T15:54:19.png

Swoole协程实现

前面我们简单介绍了Swoole协程的实现方案,以及Swoole对c栈与php栈的管理,接下来将结合前面的知识,系统性的介绍Swoole协程的实现原理。

Swoole协程数据模型

话不多说,先看一张图:

2023-03-25T15:54:43.png

  • 每个协程都需要管理自己的c栈与php栈;
  • Context封装了c栈的管理操作;ctx_字段保存的是寄存器%rsp的内容(指向c栈栈顶位置);swap_ctx_字段保存的是将被换出的协程寄存器%rsp内容(即,将被换出的协程的c栈栈顶位置);SwapIn()对应协程换入操作;SwapOut()对应协程换出操作;
  • 参考jump_fcontext实现,协程在换出时,会将寄存器%rip,%rbp等暂存在c栈栈顶;协程在换入时,相应的会从栈顶恢复这些寄存器的内容;
  • Coroutine管理着协程所有内容;cid字段表示当前协程的ID;task字段指向当前协程的php_coro_task结构,该结构中保存的是当前协程的php栈信息(vm_stack_top,execute_data等);ctx字段指向的是当前协程的Context对象;origin字段指向的是另一个协程Coroutine对象;yield()和resume()对应的是协程的换出换入操作;
  • 注意到php_coro_task结构的co字段指向其对应的协程对象Coroutine;
  • Coroutine还有一些静态属性,静态属性的属于类属性,所有协程共享的;last_cid字段存储的是当前最大的协程ID,创建协程时可用于生成协程ID;current字段指向的是当前正在运行的协程Coroutine对象;on_yield和on_resume是两个函数指针,用于实现php栈的切换操作,实际指向的是方法PHPCoroutine::on_yield和PHPCoroutine::on_resume;

Swoole协程创建与切换

协程创建

Swoole创建协程可以使用go()函数,底层实现对应的是PHP_FUNCTION(swoole_coroutine_create),其函数实现如下:

2023-03-25T15:55:43.png

  • 注意Coroutine::create函数第一个参数伟create_func,该函数后续用于创建php栈,并开始协程代码的执行;
  • 可以看到PHPCoroutine::create在调用Coroutine::create创建创建协程之前,保存了当前php栈信息到php_coro_task结构中。
  • 注意主程序的php栈是虚拟机创建的,结构与上面画的协程php栈不同,主程序的php_coro_task结构并没有存储在php栈上,而是一个静态变量PHPCoroutine::main_task,从get_task方法可以看出,主程序中get_current_task()返回的是null,因此最后获得的php_coro_task结构是PHPCoroutine::main_task。

2023-03-25T15:56:09.png

  • 在Coroutine的构造函数中完成了协程对象Coroutine的创建与初始化,以及Context对象的创建与初始化(创建了c栈);run()函数执行了协程的换入,从而开始协程的运行;

2023-03-25T15:56:28.png

  • 可以看到创建协程对象Coroutine时,通过last_cid来计算当前协程的ID,同时将该协程对象加入到全局map中;代码ctx(stack_size,fn, private_data)创建并初始化了Context对象;
  • run()函数将该协程换入执行时,赋值origin为当前协程(主程序中current为null),同时设置current为当前协程对象Coroutine;调用SwapIn()函数完成协程的换入执行;最后如果协程执行完毕,则关闭并释放该协程对象Coroutine;
  • 初始化Context对象时,可以看到其构造函数Context::Context(size_tstack_size, coroutine_func_t fn, void* private_data),其中参数fn为协程入口函数(PHPCoroutine::create_func),可以看到其赋值给ontext对象的字段fn_,但是在创建c栈上下文时,其传入的入口函数为context_func;

2023-03-25T15:57:07.png

  • 函数context_func内部其实调用的就是方法PHPCoroutine::create_func;当协程执行结束时,会标记end字段为true,同时将该协程换出;

2023-03-25T15:57:28.png

问题:参数arg为什么是Context对象呢,是如何传递的呢?这就涉及到jump_fcontext汇编实现,以及jump_fcontext的调用了

2023-03-25T15:57:47.png

调用jump_fcontext函数时,第三个参数传递的是this,即当前Context对象;而函数jump_fcontext汇编实现时,将第三个参数的内容拷贝到%rdi寄存器中,当协程换入执行函数context_func时,寄存器%rdi存储的就是第一个参数,即Context对象。

方法PHPCoroutine::create_func就是创建并初始化php栈,执行协程代码;这里不做过多介绍。

问题:Coroutine的静态属性on_yield和on_resume时什么时候赋值的?

在Swoole模块初始化时,会调用函数swoole_coroutine_util_init(该函数同时声明了"Co"等短名称),该函数进一步的调用PHPCoroutine::init()方法,该方法完成了静态属性的赋值操作。

2023-03-25T15:59:00.png

协程切换

用户可以通过Co::yield()和Co::resume()实现协程的让出和恢复,Co::yield()的底层实现函数为PHP_METHOD(swoole_coroutine_util, yield),Co::resume()的底层实现函数为PHP_METHOD(swoole_coroutine_util, resume)。本节将为读者讲述协程切换的实现原理。

2023-03-25T15:59:18.png

  • 调用Co::resume()恢复某个协程之前,该协程必然已经调用Co::yield()让出CPU;因此在Co::yield()时,会将该协程对象添加到全局map中;Co::resume()时做相应校验,如果校验通过则恢复协程,并从map种删除该协程对象;
  • co->yield()实现了协程的让出操作;

    1. 设置协程状态为SW_CORO_WAITING;
    2. 回调on_yield方法,即PHPCoroutine::on_yield,保存当前协程(task代表协程)的php栈上下文,恢复另一个协程的php栈上下文(origin代表另一个协程对象);
    3. 设置当前协程对象为origin;
    4. 换出该协程;

2023-03-25T15:59:46.png

  • co->resume()实现了协程的恢复操作:

    1. 设置协程状态为SW_CORO_RUNNING;
    2. 回调on_resume方法,即PHPCoroutine::on_resume,保存当前协程(current协程)的php栈上下文,恢复另一个协程(task代表协程)的php栈上下文;
    3. 设置origin为当前协程对象,current为即将要换入的协程对象;
    4. 换入协程;

2023-03-25T16:00:41.png

Swoole协程有四种状态:初始化,运行中,等待运行,运行结束;定义如下:

2023-03-25T16:01:00.png

协程之间可以通过Coroutine对象的origin字段形成一个类似链表的结构;
Co::yield()换出当前协程时,会换入origin协程;
在A协程种调用Co::resume()恢复B协程时,会换出A协程,换入B协程,同时标记A协程为B的origin协程;

协程切换过程比较简单,这里不做过多详述。

Swoole协程调度

Swoole的协程调度是基于事件驱动的,下面将介绍socket读写事件以及sleep定时器事件触发的协程调度。

socket读写事件

Swoole的socket读写使用的成熟的IO多路复用模型:epoll/kqueue/select/poll等,并且将其封装在结构体_swReactor中,其定义如下:

2023-03-25T16:01:37.png

在调用函数PHPCoroutine::create创建协程时,会校验是否已经初始化_swReactor对象,如果没有则会调用php_swoole_reactor_init函数创建并初始化main_reactor对象;

2023-03-25T16:02:46.png

我们以epoll为例,main_reactor各回调函数如下:

2023-03-25T16:03:05.png

注意:这里注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。

类Socket封装了socket读写相关的所有操作以及数据结构,其定义如下:

2023-03-25T16:03:26.png

  • socket字段类型为swConnection,代表传输层连接;
  • reactor字段指向结构体swReactor对象,用于fd事件的注册、修改、删除以及wait;
  • 当调用recv()函数接收数据,阻塞了该协程时,read_co字段指向该协程对象Coroutine;
  • 当调用send()函数接收数据,阻塞了该协程时,write_co字段指向该协程对象Coroutine;
  • 类Socket初始化函数为Socket::init_sock:

2023-03-25T16:03:55.png

当我们调用CoroutineSocket->recv接收数据时,底层实现如下:

2023-03-25T16:04:13.png

类timeout_setter会设置socket的接收数据超时时间read_timeout为timeout。

函数socket->recv_all会循环读取数据,直到读取到指定长度的数据,或者底层返回等待标识阻塞当前协程:

2023-03-25T16:04:29.png

  • 函数首先创建timer_controller对象,设置其超时时间为read_timeout,以及超时回调函数为timer_callback;
  • while(true)死循环读取fd数据,当读取数据量等于__n时,读取操作结束,break该循环;如果读取操作swConnection_recv返回值小于0,并且错误标识为SW_WAIT,说明需要等待数据到来,此时阻塞当前协程等待数据到来(函数wait_event会换出当前协程),阻塞超时时间为read_timeout(函数timer.start()用于设置超时时间)。

2023-03-25T16:04:53.png

函数swTimer_add用于添加一个定时器;Swoole底层定时任务是通过最小堆实现的,堆顶元素的超时时间最近;结构体_swTimer维护着Swoole内部所有的定时任务:

2023-03-25T16:05:30.png

当调用swTimer_add向_swTimer结构中添加定时任务时,需要更新_swTimer中最早的定时任务触发时间_next_msec,同时更新main_reactor对象的超时时间:

2023-03-25T16:06:10.png

函数wait_event负责将当前协程换出,直到注册的事件发生

2023-03-25T16:17:07.png

  • 函数add_event用于添加事件,底层调用reactor->add添加fd的监听事件;
  • read_co = co或者write_co = co,用于记录当前哪个协程阻塞在该socket对象上,当该socket对象的读写事件被触发时,可以恢复该协程执行;
  • 函数yield()将该协程换出;

上面提到,创建协程时,注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。函数swoole_event_wait底层就是调用main_reactor->wait等待fd读写事件的产生;我们以epoll为例讲述事件循环的逻辑:

2023-03-25T16:17:44.png

swReactorEpoll_wait是对函数epoll_wait的封装;当有读写事件发生时,执行相应的handle,根据上面的讲解我们知道读写事件的handle分别为readable_event_callback和writable_event_callback;

2023-03-25T16:17:57.png

可以看到函数readable_event_callback只是简单的恢复read_co协程即可;

当epoll_wait发生超时,最终调用的是函数swReactor_onTimeout,该函数会从Swoole维护的一系列定时任务swTimer中查找已经超时的定时任务,同时执行其callback回调;

2023-03-25T15:49:11.png

该callback回调函数即为上面设置的timer_callback:

2023-03-25T15:48:46.png

同样的,timer_callback函数只是简单的恢复read_co或者write_co协程即可

sleep定时器事件

Co::sleep()的实现函数为PHP_METHOD(swoole_coroutine_util, sleep),该函数通过调用Coroutine::sleep实现了协程休眠的功能:

2023-03-25T15:48:23.png

可以看到,与socket读写事件超时处理相同,sleep内部实现时通过swTimer_add添加定时任务,同时换出当前协程实现的。该定时任务会导致main_reactor对象的超时时间的改变,即修改了epoll_wait的超时时间。

sleep的超时处理函数为sleep_timeout,只需要换入该阻塞协程对象即可,实现如下:

2023-03-25T15:48:00.png

本文转载于学而思网校技术团队,2020.10月我加入了好未来励步事业部,我们事业部开始大面积应用 swoole,所以对 swoole 进行了深入学习。本文作者:陈曹奇昊

初次接触Swoole的PHP开发者多少都会有点雾里看花的感觉,看不清本质。一部分PHP开发者并不清楚Swoole是什么,只是觉得很牛掰就想用了,这种行为无异于写作文的时候总想堆砌一些华丽的辞藻或是引经据典来提升文章逼格,却背离了文章的主题,本末倒置,每一种技术的诞生都有它的原因,异步或是协程不是万能的银弹,你需要它的时候再去用它,而不是想用它而用它,毕竟编程世界的惯性是巨大的,这天下还是同步阻塞的天下。还有一部分开发者是对Swoole有了一些自己的见解,但对错参半,写出来的程序能跑,甚至也能上生产,但不是最优的,其中大部分问题都源于开发者无法将惯有的思维方式灵活转变。

协程

首先协程的最简定义是用户态线程,它不由操作系统而是由用户创建,跑在单个线程(核心)上,比进程或是线程都更加轻量化,通常创建它只有内存消耗:假如你的配置允许你开几千个进程或线程,那么开几万个几十万个协程也是很轻松的事情,只要内存足够大,你可以几乎无止境地创建新的协程。在Swoole下,协程的切换实现是依靠双栈切换,即C栈和PHP栈同时切换,由于有栈协程的上下文总是足够的小,且在用户态便能完成切换,它的切换速度也总是远快于进程、线程,一般只需要纳秒级的CPU时间,对于实际运行的逻辑代码来说这点开销总是可以忽略不计(尤其是在一个重IO的程序中,通过调用分析可以发现协程切换所占的CPU时间非常之低)。

对于Swoole这样的有栈协程,你完全可以简单地将其看做是一个栈切换器,你可以在运行的子程序中随意切换到另一个子程序,底层会保存好被切走的协程的执行位置,回来时可以从原先的位置继续往下运行。

2023-03-25T15:29:53.png

Swoole多进程模型下的进程、线程、协程关系图
但这篇文章我们要谈的并不只是单单「协程」这一个概念,还隐含了关于异步网络IO一系列的东西,光有协程是什么也做不了的,因为Swoole的协程永远运行在一个线程中,想用它做并行计算是不可能的,运行速度只会因为创建开销而更慢,没有异步网络IO支持,你只能在不同协程间切来切去玩。

实际上PHP早就实现了协程,yield关键字就是允许你从一个函数中让出执行权,需要的时候能重新回到让出的位置继续往下执行,但它没有流行起来也有多种原因,一个是它的传染性,每一层调用都需要加关键字,另一个就是PHP没有高效可靠的异步IO支持,让其食之无味。

异步

注:本文中提到的异步IO并非全为严格定义上的异步IO,更多的是日常化的表达
简单了解了协程,再让我们来理解一下什么是异步IO吧。严格来说,在Unix下我们常说的异步并不是真异步,而是同步非阻塞,但是其效果和异步非常相近,所以我们日常中还是以异步相称。同步非阻塞和真异步区别在于:真异步是你提交读写请求后直接检查读写是否已完成即可,所以在Win下这样的技术被叫做「完成端口」,而同步非阻塞仅是操作不会长时间地陷入内核,但你需要在检查到可读或可写后,调用API同步地去拷贝数据,这会不可避免地陷入内核态,但read/write通常并不会阻塞太多的时间,从宏观上整个程序仍可以看作是全异步的。

阻塞非阻塞
同步read, write(read, write) + poll/ select / epoll / kqueue
异步-aio_read, aio_write, IOCP(windows)

在实际使用中,「伪异步」的Reactor模型并不比Windows下IOCP的Proactor逊色,并且我更喜欢Reactor的可控性,当然为了追求极致的性能和解决网络和文件异步IO统一的问题,未来Linux的io_uring可能会成为新的趋势。
2023-03-25T15:30:12.png

Reactor运行流程简图
我们可以通过上面的图片简单理解Reactor模型的运行流程,所谓的「异步」不过是多路复用带来的观感效果,你的程序不会阻塞在一个IO上,而是在无事可干的时候再阻塞在一堆IO上,即IO操作不在你需要CPU的时候阻塞你,你就不会感受到IO阻塞的存在。

结合现实情景来说,以前你要买饭(IO操作),你得下楼去买,还得排队等饭店大厨做完才能取回家吃(IO阻塞),到了下一餐,你又得重复之前的操作,很是麻烦,而且越是繁忙的时候等的时间越长(慢速IO),你觉得一天到晚净排队了,极大地浪费了你写代码的时间(CPU时间)。现在有了外卖,你直接下单(异步请求)就可以继续专心写代码(非阻塞),你还可以一次定三份饭(多路IO),饭到了骑手打电话让你下楼取(事件触发),前后只花了不到几分钟(同步读写,如果是Proactor连取餐都省了,直接给你送上楼),周六晚上的九点,你终于合上电脑,觉得充实极了,因为你几乎一整周都在写代码(CPU利用率高)。

协程+异步=同步非阻塞编程

现在我们有了协程和异步,我们可以做什么呢?那就是异步的同步化。这时候有的开发者就会说了,诶呀好不容易习惯异步了,怎么又退回到同步了呢。这就是为什么有些开发者始终写不出最优的协程代码的原因,异步由于操作的完成不是立即的,所以我们需要回调,而回调总是反人类的,嵌套的回调更是如此。

而结合协程,消灭回调我们只需要两步:在发出异步请求之后挂起协程,在异步回调触发时恢复协程。

Swoole\Coroutine\run(function(){
    // 1. 创建定时器并挂起协程#1
    Swoole\Coroutine::sleep(1);
    // 3. 协程恢复,继续向下运行退出,再次让出
});
// 2. 协程#1让出,进入事件循环,等待1s后定时器回调触发,恢复协程#1
// 4. 协程#1退出并让出,没有更多事件,事件循环退出,进程结束
短短的一行协程sleep,使用时几乎与同步阻塞的sleep无异,却是异步的。
for ($n = 10; $n--;) {
    Swoole\Coroutine::create(function(){
        Swoole\Coroutine::sleep(1);
    });
}

我们循环创建十个协程并各sleep一秒,但实际运行可以发现整个进程只阻塞了一秒,这就表明在Swoole提供的API下,阻塞操作都由进程级别的阻塞变为了协程级别的阻塞,这样我们可以以很小的开销在进程内通过创建大量协程来处理大量的IO任务。

协程代码编写思路

定时任务

当我们说到定时任务时,很多人第一时间都想到定时器,这没错,但是在协程世界,它不是最佳选择。

$stopTimer = false;
$timerContext = [];
$timerId = Swoole\Timer::tick(1, function () {
    // do something
    global $timerContext;
    global $timerId;
    global $stopTimer;
    $timerContext[] = 'data';
    if ($stopTimer) {
        var_dump($timerContext);
        Swoole\Timer::clear($timerId);
    }
});
// if we want to stop it:
$stopTimer = true;

在异步回调下,我们需要以这样的方式来掌控定时器,每一次定时器回调都会创建一个新的协程,并且我们不得不通过全局变量来维护它的上下文。

如果是协程呢?

Swoole\Coroutine\run(function() {
    $channel = new Swoole\Coroutine\Channel;
    Swoole\Coroutine::create(function () use ($channel) {
        $context = [];
        while (!$channel->pop(0.001)) {
            $context[] = 'data';
        }
        var_dump($context);
    });
    // if we want to stop it, just call:
    $channel->push(true);
});

完全同步的写法,从始至终只在一个协程里,不会丢失上下文,channel->pop在这里的效果相当于毫秒级sleep,并且我们可以通过push数据去停止这个定时器,非常的简单清晰。

Task

由于开发者的强烈要求,Swoole官方曾经做了一个错误的决定,就是在Task进程中支持协程和异步IO。

图片
正如图中所示,Task进程最初被设计为用来处理无法异步化的任务,充当类似于PHP-FPM的角色(半异步半同步模型),这样各司其职,能够将执行效率最大化。

最早期的Swoole开发者,甚至直接将Swoole的Worker进程用于执行同步阻塞任务,这种做法并非没有可取之处,它比PHP-FPM下的效率更高,因为程序是持续运行,常驻内存的,少了一些VM启动和销毁的开销,只是需要自己处理资源的生命周期等问题。

此外就是使用异步API的开发者,他们会开一堆Task进程,将一些暂时无法异步化的同步阻塞任务丢过去处理。

而以上两种都是历史条件下正确并合适的Swoole打开方式。

但是还有一小撮开发者,一股脑地把所有任务都投递给Task进程,以为这样就实现了任务异步化,Worker进程除了接收响应和投递任务什么也不干,殊不知这就相当于每一个任务的处理多了两次数据序列化开销 + 两次数据反序列开销 + 两次IPC开销 + 进程切换开销。

而当协程逐渐成为新的趋势后,又有越来越多的社区呼声要求Task进程也能支持协程和异步IO,这样他们就可以将协程方式编写的任务投递到Task中执行。但异步任务可以很轻量地在本进程被快速处理掉,对Worker整体性能并不会有太大影响,他们这样的行为,也是典型的舍近求远。

Task方式处理协程任务

$server->on('Receive', function(Swoole\Server $server) {
    # 投递任务,序列化任务数据,通过IPC发送给Task进程
    $task_id = $server->task('foo'); 
});
# 切换到Task进程
# 接收并反序列化Worker通过IPC发送来的任务数据
$server->on('Task', function (Swoole\Server $server, $task_id, $from_id, $data) {
    # 使用协程DNS查询
    $result = \Swoole\Coroutine::gethostbyname($data);
    # 序列化数据,通过IPC发送回Worker进程
    $server->finish($result);
});
# 回到Worker进程
# 接收并反序列化Task通过IPC发送来的结果数据
$server->on('Finish', function (Swoole\Server $server, int $task_id, $result) {
    # 需要通过任务id才能确认是哪个任务的结果
    echo "Task#{$task_id} finished";
    # 打印结果
    var_dump($result);
});

协程方式写Task

注:batch方法由swoole/library提供,内置支持需要Swoole-v4.5.2及以上版本,低版本可以自己使用Channel来调度

use Swoole\Coroutine;

Coroutine\run(function () {
    # 并发三个DNS查询任务
    $result = Coroutine\batch([
        '100tal' => function () {
            return Coroutine::gethostbyname('www.100tal.com');
        },
        'xueersi' => function () {
            return Coroutine::gethostbyname('www.xueersi.com');
        },
        'zhiyinlou' => function () {
            return Coroutine::gethostbyname('www.zhiyinlou.com');
        }
    ]);
    var_dump($result);
});

输出(API保证返回值顺序与输入顺序一致,不会因为异步而乱序)

array(3) {
  ["100tal"]=>
  string(14) "203.107.33.189"
  ["xueersi"]=>
  string(12) "60.28.226.27"
  ["zhiyinlou"]=>
  string(14) "101.36.129.150"
}

非常的简单易懂,不存在任何序列化或者IPC开销,并且由于程序是完全非阻塞的,大量的Task任务也不会对整体性能造成影响,所以说Task进程中使用协程或异步完全就是个错误,作为一个程序员,思维的僵化是很可怕的。

读到这里大家应该也能明白,我们所谈论的协程化技术实际上可以看做传统同步阻塞和非阻塞技术的超集,非阻塞的技术让程序可以同时处理大量IO,协程技术则是实现了可调度的异步单元,它让异步程序的行为变得更加可控。如果你的程序只有一个协程,那么程序整体就是同步阻塞的;如果你的程序在创建某个协程以后不关心它的内部返回值,它就是异步的。

希望通过本文,大家能够加深对协程和异步IO的理解,写出高质量可维护性强的协程程序。

0x00 什么是 IoC

IoC (控制反转)并不是一个新鲜的概念,如果之前有过 Java 经验,那么肯定不会对 IoC 陌生,而 PHP 近几年的框架其实也开始出现了大量的容器应用,在这里需要注意 IoC 和 IoC 容器是两个概念,不要混淆

IoC 是编程思想,而 IoC 容器是帮助我们进行 IoC 的工具,我们通过控制反转把对象之间的调用过程交给 IOC 容器来完成,而最终实现业务代码的解耦。

在 Golang 中 IOC 容器的使用时候,我们要根据我们的具体情况还进行分析是否要使用 IOC

  • 如果你的项目对于性能要求比较高,最好就不要使用 IOC 容器了,因为在 Golang 中实现 IoC 要使用到反射,而反射使用的 空间,如果大量使用反射就会对 Golang 的 GC 造成一定的压力。
  • 要是你的项目对于性能要求不高,追求的是业务的封装性和可维护性,那么就可以考虑使用 IoC 容器。

如果你不了解什么是 IoC 也没有关系,下面会带大家简单的了解一下什么是 IOC 容器。

下面来简单解释一下什么是 IoC。

img

假设现在有两个 Service:UserService 和 OrderService。

  • UserService: 用户服务, GetUserInfo 获取用户信息
  • OrderService: 订单服务, GetOrderInfo 获取订单信息。

这两个 Service 中的方法传递的参数也都是用户 id,现在以代码的形式来模拟一下这个场景。

初始目录如下:

.
├── go.mod
├── main.go
└── svc           #service 目录
    ├── OrderService.go
    └── UserService.go

UserService代码如下:

package svc

import "fmt"

type UserService struct {
}

func NewUserService() *UserService {
    return &UserService{}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

OrderService

package svc

import "fmt"

type OrderService struct {
}

func NewOrderService() *OrderService {
    return &OrderService{}
}

func (this *OrderService) GetOrderInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的订单信息")
}

基础的 Service 已经封装好了,接下来进行调用。

package main

import "study-go/svc"

func main() {
    uid := 123
    
    orderSvc := svc.NewOrderService()
    userSvc := svc.NewUserService()

    //获取用户详细
    userSvc.GetUserInfo(uid)
    orderSvc.GetOrderInfo(uid)
}

如果代码写到这个程度是否就可以结束了,答案是可以的,并不是很 Low,在 Golang 中并不是要写成和 Java 一样,有大量的设计模式和类与类之间的嵌套。

如果这个时候时候你的领导跟你说要将获取订单信息的方法放到 UserService 里面,你应该怎么做?

package svc

import "fmt"

type UserService struct {
}

func NewUserService() *UserService {
    return &UserService{}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    NewOrderService().GetOrderInfo(uid)
}

你完全可以这样写,因为获取用户订单信息是和用户相关的,没有必要放在两个 Service 里面,这个时候我们可以视作 UserService 依赖于 OrderService ,而我们处理的方式是主动处理依赖(在方法内初始化了 NewOrderService),也就是将依赖硬编码到我们的代码中,也叫做控制正转

调用的时候就变成了这个样子:

package main

import "study-go/svc"

func main() {
    uid := 123
    userSvc := svc.NewUserService()

    //获取用户详细
    userSvc.GetUserInfo(uid)
    userSvc.GetOrderInfo(uid)
}

而我们要讲的是依赖反转是被动的,也就是说在我们的 Service 代码里面不会有任何主动初始化 OrderService 的代码,我们需要将我们代码更改一下:

package svc

import "fmt"

type UserService struct {
    orderSvc *OrderService
}

func NewUserService(order *OrderService) *UserService {
    return &UserService{orderSvc: order}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    this.orderSvc.GetOrderInfo(uid)
}

观察上方代码的变化:

  1. 我们首先在 UserService 结构体定时以的时候定义了一个新的属性 orderSvc;
  2. 改造了构造方法,在初始化 UserService 的时候,需要给 UserService 传递一个 OrderService 进来;
  3. 在 GetOrderInfo 方法中,因为在构造 UserService 的时候已经传递来了一个 OrderService ,所以可以通过属性的方法进行调用 orderSvc.GetOrderInfo;

那么在调用的时候就变成了这个样子:

package main

import "study-go/svc"

func main() {
    uid := 123
    
    userSvc := svc.NewUserService(svc.NewOrderService())

    //获取用户详细
    userSvc.GetUserInfo(uid)
    userSvc.GetOrderInfo(uid)
}

这个时候,UserService 就被动的 接收了依赖,这就叫做控制反转,也就是 IOC。

这个时候其实是有一些问题的,如果 UserService 有十七八个依赖,我们要怎么样进行处理?这个时候就需要一个工具来帮助我们统一进行管理那就是 IOC 容器,这一部分内容将在下一小节进行讲解。

0x01 利用 Map 存储 Service

在上一小节讲过 IoC 容器是用来帮助我们处理 IoC 的工具,所以我们需要保存两个 Service 之间的依赖关系。

在编写容器代码前,我们现将 OrderService 改造一下,在其中加上 version。

type OrderService struct {
    version string
}

func NewOrderService() *OrderService {
    return &OrderService{version: "1"}
}

我们通过 version 来判断后面我们放到容器中的 OrderService 和取出来的是否一致。

我们先来实现一个基础版本的容器,使用 Map 来存储依赖关系:

package injector

import "reflect"

type BeanMapper map[reflect.Type]reflect.Value

func (this BeanMapper) add(bean interface{}) {
    t := reflect.TypeOf(bean)
    if t.Kind() != reflect.Ptr {
        panic("require ptr object")
    }
    this[t] = reflect.ValueOf(bean)
}

在 Map 中 key 是 reflect.Type, 而 value 是 reflect.Value。

我们来看 add 方法,其实就是将依赖关系存储到 map 中,在使用的时候再从 map 中取出来,在这里为了简化操作,我们目前值允许指针被加入到 map 中。

package injector

var BeanFactory *BeanFactoryImpl

func init() {
    BeanFactory = NewBeanFactory()
}

type BeanFactoryImpl struct {
    BeanMapper BeanMapper
}

func (this *BeanFactoryImpl) Set(vList ...interface{}) {
    if vList == nil || len(vList) == 0 {
        return
    }
    for _, v := range vList {
        this.BeanMapper.add(v)
    }
}

func NewBeanFactory() *BeanFactoryImpl {
    return &BeanFactoryImpl{BeanMapper: make(BeanMapper)}
}

现在创建和添加已经写好了,在这里,我们使用工厂模式来创建容器,init 函数是初始化函数,他在 main 函数执行前执行,用来初始化资源或者提前创建对象。

现在我们再给容器添加获取代码,首先给 mapper 添加 get 方法,很简单,其实就是依靠 map 的获取操作。

func (this BeanMapper) get(bean interface{}) reflect.Value {
    t := reflect.TypeOf(bean)

    if v, ok := this[t]; ok {
        return v
    }
    return reflect.Value{}
}

然后再添加 Factory 的获取方法。

func (this *BeanFactoryImpl) Get(v interface{}) interface{} {
    if v == nil {
        return nil
    }
    bean := this.BeanMapper.get(v)
    if bean.IsValid() {
        return bean.Interface()
    }
    return nil
}

这样一来我们就完成了容器的添加依赖和获取依赖,我们来实践一下。

package main

import (
    "fmt"
    "study-go/injector"
    "study-go/svc"
)

func main() {
    injector.BeanFactory.Set(svc.NewOrderService())
    orderSvc := injector.BeanFactory.Get((*svc.OrderService)(nil))
    fmt.Println(orderSvc)
}

执行一下:

&{2}

输出的时候将 OrderService 中的 version 给打印了出来,这证明我们可以从容器中取到放进去的值。

0x02 处理依赖注入

我们的 UserService 依赖了 OrderService,我们之前使用手动编码来处理依赖关系,现在我们有了容器,现在我们来使用容器来自动注入。

做法其实有很多方案,在Java 可以使用注解, PHP 中使用注释来模拟注解,而在 Golang 中的处理方式稍有不同,因为在 Golang 中并没有对注解的原生支持,所以我们需要使用 Tag 来实现,Tag 是用于标识结构体字段的额外信息,标准库 reflect 包提供了操作 Tag 的方法[1]

package svc

import "fmt"

type UserService struct {
    OrderSvc *OrderService `inject:"-"`
}

func NewUserService(order *OrderService) *UserService {
    return &UserService{OrderSvc: order}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    this.OrderSvc.GetOrderInfo(uid)
}

首先我们将 Uservice 中的 orderSvc 改为公开的属性,然后在其后面增加 inject tag,其中 "-" 其实写不写都无所谓,但是因为我们在业务开发的时候,可能还会有 json tag ,所以我们在这里约定 inject:"-" 就等于我要使用依赖注入。

接下来,我们在 BeanFactory 中实现 Apply 方法,用来处理依赖注入。

func (this *BeanFactoryImpl) Apply(bean interface{}) {

    if bean == nil {
        return
    }

    v := reflect.ValueOf(bean)

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }

    if v.Kind() != reflect.Struct {
        return
    }

    for i := 0; i < v.NumField(); i++ {
        field := v.Type().Field(i)
        if v.Field(i).CanSet() && field.Tag.Get("inject") != "" {
            if getV := this.Get(field.Type); getV != nil {
                v.Field(i).Set(reflect.ValueOf(getV))
            }
        }
    }
}

首先在代码中判断了是否为结构体,并且从 map 中获取已经实例化好的 Service,在这里我们还需要改造一下 Map 的 get 方法,因为这个时候传递的 field.Type 在 get 方法中会报错,我们需要兼容一下这个类型,在原有代码中增加一下断言。

func (this BeanMapper) get(bean interface{}) reflect.Value {
    var t reflect.Type
    //断言一下 bean
    if bt, ok := bean.(reflect.Type); ok {
        t = bt
    } else {
        t = reflect.TypeOf(bean)
    }

    if v, ok := this[t]; ok {
        return v
    }
    return reflect.Value{}
}

现在让我们来试一下新的成果。

func main() {
    injector.BeanFactory.Set(svc.NewOrderService())

    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)
    
    fmt.Println(userService.OrderSvc)
}

现在我们的依赖管理采用 Apply 方法进行管理,Apply 方法自动在容器中寻找其依赖项,然后注入到 UserService 中,运行程序显示结果如下:

&{2}

0x03 动态注入 & Tag 支持表达式

在这个阶段,我们要是实现一个能够基于 Tag 表达式能够动态注入的功能,因为如果项目不复杂,只有几个 Service,那么我们还可以进行手工的将 Service 添加到容器中,那么如果现在随着业务的增加,Service 的数量增加到 20 个,甚至 100 个,我们就需要在 BeanFactory.Set() 方法的参数中写大量的代码来完成容器的初始化。

这么做很显然是不够好的,以为一个完整的容器是需要能够动态注入的,我们只需要基于简单的配置,就可以完成动态注入。

首先我们先创建一个新的文件 ServiceConfig.go 我们将在 ServiceConfig Struct 中来管理我们依赖的创建。

package config

import "study-go/svc"

type ServiceConfig struct {
}

func NewServiceConfig() *ServiceConfig {
    return &ServiceConfig{}
}

func (this *ServiceConfig) OrderService() *svc.OrderService {
    return svc.NewOrderService()
}

以后业务中所有依赖实例化我们都存放在这个文件中,这种做法其实是类似于 Java Spring 的做法。

上面刚才说了,我们要实现一个能够基于 Tag 动态注入的功能,目前字段 Tag 中给的约定是 "-" ,用来直接去到容器中被实例化好的 Service,我们可以更近一步,将约定在增加一份,也就是所谓的表达式,例如 inject:"ServiceConfig.OrderService()

根据这个表达式可以清晰的看出,该字段依赖于 ServiceConfig.OrderService,那么只需要 BeanFactory 代替我们去把这个值拿到就可以了。

但是目前 BeanFactory 目前只有一个 BeanMap 来存储被实例化好的 struct ,并不支持表达式,所以我们需要先增加一个字段。

type BeanFactoryImpl struct {
    BeanMapper BeanMapper             // 存储依赖
    ExprMap    map[string]interface{} // 新增的字段,利用表达式的方式存储依赖 
}

新增的 ExprMap 中 key 存储的就是我们需要支持的表达式,而他的 value 其实就是我们的实例化好的 struct。

接下来,我们先引入一个新的库 github.com/shenyisyn/goft-expr ,这个库的作用是使用表达式来执行函数或者 struct,我们来修改 BeanFactory 中 的 Apply 方法,以支持新的表达式。

func (this *BeanFactoryImpl) Apply(bean interface{}) {
    if bean == nil {
        return
    }

    v := reflect.ValueOf(bean)

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }

    if v.Kind() != reflect.Struct {
        return
    }

    for i := 0; i < v.NumField(); i++ {
        field := v.Type().Field(i)
        if v.Field(i).CanSet() && field.Tag.Get("inject") != "" {

            // 兼容老的方式 注入
            if field.Tag.Get("inject") == "-" {
                if getV := this.Get(field.Type); getV != nil {
                    v.Field(i).Set(reflect.ValueOf(getV))
                }
            } else {
                fmt.Println("使用了表达式")
                ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
                if ret != nil && !ret.IsEmpty() {
                    v.Field(i).Set(reflect.ValueOf(ret[0]))
                }
            }

        }
    }
}

我们来看这一段代码:

// 兼容老的方式 注入
if field.Tag.Get("inject") == "-" {
    ...
} else {
        ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
        if ret != nil && !ret.IsEmpty() {
        v.Field(i).Set(reflect.ValueOf(ret[0]))
}

首先是判断了 inject 是否等于 - 符号,这样是为了兼容老的表达式,也就是默认去读取依赖的 Service,然后在下方,我们去根据表达式取出了已经在容器中准备好的 Service。

这一切都已经准备好了,我们来先试验一下根据表达式来进行注入,先修改 UserService:

type UserService struct {
    OrderSvc *OrderService `inject:"ServiceConfig.OrderService()"`
}

然后是 main.go

func main() {
  srvConfig := config.NewServiceConfig()
  
    injector.BeanFactory.ExprMap = map[string]interface{}{
        "ServiceConfig": srvConfig,
    }
  
    injector.BeanFactory.Set(srvConfig)

    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)

    fmt.Println(userService.OrderSvc)
}

接下来执行结果如下:

➜  study-go go run main.go 
&{2}

接下来,我们继续优化我们的代码,能够让代码真正的动态注入,在上面,我们需要手动配置 ExprMap ,但是其实我们同样可以使用反射,来自动完成这一配置,这一次我们先来看 main.go

func main() {
  srvConfig := config.NewServiceConfig()
  
    injector.BeanFactory.Set(srvConfig)
  
    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)

    fmt.Println(userService.OrderSvc)
}

BeanFactory 新增 Config 方法:

func (this *BeanFactoryImpl) Config(cfgs ...interface{}) {
    for _, cfg := range cfgs {
        t := reflect.TypeOf(cfg)
        if t.Kind() != reflect.Pointer {
            panic("required ptr object!")
        }
        this.Set(cfg) //把 cfg 本身也加入到 bean 中
        t = t.Elem()
        this.ExprMap[t.Name()] = cfg  // 自动设置 ExprMap 
        v := reflect.ValueOf(cfg).Elem()
        for i := 0; i < t.NumMethod(); i++ {
            method := v.Method(i)
            callRet := method.Call(nil)
            if callRet != nil && len(callRet) == 1 {
                this.Set(callRet[0].Interface())
            }
        }
    }
}

这段代码其实很好理解:

  1. 判断是否为指针,因为这里我们需要的是被实例化好的 Strcut
  2. 然后将 ServiceConfig 也放入到 BeanMap 当中
  3. 然后设置 ExprMap
  4. 最后序列化 ServiceConfig 中的所有 NewXXX 函数

这样一来动态注入就已经完成了

0x04 支持接口注入

如果要让我们的容器支持接口,其实也非常简单,只需要修改 BeanMap 中的 Get 方法即可:

func (this BeanMapper) get(bean interface{}) reflect.Value {
  ...
    if v, ok := this[t]; ok {
        return v
    }
    //如果没有找到那就去看看接口
    for k, v := range this {
        if k.Implements(t) {
            return v
        }
    }
    return reflect.Value{}
}

0x05 处理循环依赖

当我们的 Service 存在多级嵌套的时候,按照现在的代码逻辑是无法去自动完成下一级的自动注入的,我们仅需要在apply 完成的时候,在对其依赖进行递归调用就可以

// 兼容老的方式 注入
if field.Tag.Get("inject") == "-" {
    if getV := this.Get(field.Type); getV != nil {
          v.Field(i).Set(reflect.ValueOf(getV))
          this.Apply(getV)
    }
} else {
        ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
        if ret != nil && !ret.IsEmpty() {
             retV := ret[0]
             v.Field(i).Set(reflect.ValueOf(retV))
             this.Apply(retV)
        }
}

0x06 IOC 容器还需要做的事情

到目前为止,我们的 基础版的 IOC 就已经完成了,但是目前还有两个问题没有解决:

  1. 单例和多例,目前这个 IOC 容器,每一次在执行的时候其实都是会调用 NewXXX 实例化出来一个依赖,所以需要能够支持单例或者多例
  2. ServiceConfig 目前还不支持参数实例化

后面如果有时间的话,我会将这块补上。

0x07 结尾

在这里首先要感谢程序员在囧途的沈逸的课程,这篇文章其实就是以 Golang 手撸 IOC 容器为基础编写的,大部分都是他讲课的内容,我在其基础上进行了一些编辑,并且引用了其他的一些文章内容来充实整篇文章,目前与课程不同的是没有去实现单例和多例。

Ref