Skip to content
xiehuc edited this page Apr 23, 2014 · 4 revisions

理解事件机制

lwqq 使用两种事件,都是基于 LwqqCommand 这一强大的机制实现的。

Http通讯事件

通常很多函数都会返回一个 LwqqAsyncEvent 指针。通过使用这个指针能够添加回调 函数,从而当Http过程结束后,能够得到通知进行相应的处理。这个事件是一次性的,也 就是当所有的回调都结束后,这个事件会自动的释放,不能够再次使用。

设计缘由

事实上 LwqqCommand 已经具有所有的回调函数的能力,然而在这里,将 LwqqCommand 升 华到了一个更高的层次 --- LwqqAsyncEvent。加入了许多新的特性:

  1. 和 LwqqHttpRequest 绑定,一个事件对应一个 http 请求。
  2. 和 LwqqClient 绑定,一个事件属于一个 qq 。
  3. 和 LwqqAsyncEvset 配套,从而组成更加复杂的异步任务。
  4. 结果号(result)和错误号(failcode),两者的区别是结果号对应的是 webqq 服务器返 回的 json 的 retcode(正数) 或者是内部错误(负数), 而错误号是更广泛的诸如取消 ,同步,重试。通常有一个结果号的时候就是没有错误,相反,有一个错误号的时候 结果号不可靠的。
  5. 自动释放内存

LwqqAsyncEvset 的语义是将许多事件加入一个组,同时监控,当所有的事件都完成的时候 执行回调。特别适合批量网络任务(比如获取所有用户的头像,所有用户的QQ号)等等的。 它提供的特性包括:

  1. 所有事件完成后触发事件
  2. 提供错误的事件的数量(为了简化设计,只能提供错误的数量而不能提供哪些错误了) 为了找出哪些事件错误了,需要对所有事件添加回调判断结果号。
  3. 半自动释放内存

设计原则

LwqqAsyncEvent 的设计并不复杂,大部分特性都仅仅只需增加一个新的变量即可。唯一和 evset配套的地方稍微复杂一些。

为了简化实现,当一个event加入evset后event的一个指针指向evset,而evset仅仅保持一 个引用计数增加1。当一个event完成后,减小对应的evset的引用计数。当引用计数减小到 0时则执行evset的回调。

为此需要使用互斥锁,因为引用计数是共享的。

LwqqAsyncEvent的自动释放内存则非常简单,在 lwqq_async_event_finish 回调完成 之后立即释放内存即可。说是自动的是因为凡是不是自己创建的事件都必然会调用 finish ,无论网络是否错误。当网络错误的时候,会设置错误号,需要在回调函数中判断,并且 正确的释放其它内存。

LwqqAsyncEvset的半自动释放内存是因为所有的evset基本上都是自己创建的。如果一个 evset没有加入事件,那么它就不会释放内存,如果是加入了事件,则会自动释放内存。但 是问题在于:

lwqq_async_evset_add_event(set,lwqq_info_get_friend_list(...))

第二个参数不一定会返回一个event,有可能会出现某些错误而返回NULL,这样set相当于 就没有加入事件从而就不能够自动释放内存,所以是半自动的。

触发事件

设计缘由

过去,我们使用函数指针来完成这样的需求,比如 login_complete(LwqqClient* lc,int login_ec) ,通过提供这些指针,我们可以接管 登录完成 这样的事件。

然后在适当的地方,使用 login_complete(lc,1) 表示现在登录完成了。这里,需要 传给回调函数一些重要的参数。这是必不可少的。

但是,回调函数也有一些麻烦,比如说只能进行一次回调,而无法重叠。这样会在实现 Plugin 机制的时候带来困难。

例如我们设计了一个数据库插件 lwdb 它需要在原来的执行流上插入自己实现的任务 ,比如在添加删除好友时候,需要同时吧数据库中相应的条目给更新了。实际上,这里只 需要 添加好友 删除好友 这样的原语,并且加入对应的函数。仅此而已。如果是 回调函数能够重叠,那么则可以实现这样的机制:

---+----------+-------------------
   |new friend|  lwdb callback
---+----------+-------------------
   |new friend|  plugin2 callback
---+----------+-------------------
   |new friend|  app callback
---+----------+-------------------

能够在不同的空间中独立各个插件。并且需要能够传入变量,需要能够重复调用。

设计原则

触发事件是 LwqqEvents 结构体的成员,其实就是一个个的 LwqqCommand 对象, 通过 LwqqClient::events 来使用,使用 lwqq_add_event 来添加事件。

和通讯事件不同的是,触发事件是永久的。这是通过调用 vp_do_repeat 而不是 vp_do 来实现的。所以不要多次给触发事件添加同一个回调函数。一个触发事件代表 了一个时间,比如 login_complete 会在登录结束后的某个时机触发。 need_verify 会在需要输入验证码的时候触发。

触发事件另一个特征是可以接收来自lwqq的变量,而Http通讯事件的回调函数的参数都是 由自己提供了。比如 login_complete 事件的注释上会标注,会更改 login_ec 变量,而该变量位于 LwqqClient::args 中,是一个 LwqqArguments 类型。

因为LwqqCommand的实现使得不能够像回调函数一样直接传入参数,而是在创建的时候就指 定了所有的参数,不能够中途更改(即时能更改,也会使得api十分复杂,难于使用)。为 了实现对 LwqqCommand 的兼容,避免大的更改,这里巧妙的使用了 指针 来实现 参数传入。具体说来,需要创建 LwqqCommand 的时候使用一个指向LwqqArguments的 成员的指针。例如:

lwqq_add_event(lc->events->login_complete,_C_(2p,login_complete,lc,&lc->args->login_ec))

这样,login_ec 是一个传入参数,而 lc 则不是。当需要获取 login_ec 的 时候只需要简单的解引用就行了。因为一个事件可能会更改许多变量,所以只需要注册自 己感兴趣的变量就可以了。这样不会使得参数列表过于冗长。

也许你会想,这样的话,那不直接加入lc,然后在函数内部使用 lc->args->login_ec 来访问不就完了。哪还有什么传入参数固定参数的区别?事实上的确是的,在没有想到之 前会觉得有很多条路不知道走哪条好,但是想明白了之后会发现原来如此的简单。但是使 用上面的方法有另外一个优势是能够兼容原来的回调函数用法,只需要改成指针方式即可。 我们可以用以下的方法直接使用回调函数:

int ec = LWQQ_EC_OK;
login_complete(lc,&ec);

而如果是在内部使用 lc->args->login_ec 的方式则不能够方便的直接调用。

不定长回调函数

即vp_list,在 lib/vp_list.h 中定义,它的命名很大程度上类似 va_list , 全称是 variable params list。

在lib/type.h中重定义为LwqqCommand。为了和整个项目的命名风格匹配。

值得一提的是 vp_list.h 被设计为可以脱离项目单独使用,也就是说你可以直接复制 vplist.c/h 到其它的项目中。从而方便的使用它。

设计缘由

回调函数的一大不便是参数数目固定,也就是说需要首先定义函数的类型,然后才能提供 函数指针。但是在使用中必须严格的按照参数的类型来传入变量,所以早先的 lwqq 代码 有如下的回调代码:

void** data = s_malloc0(sizeof(void*)*3);
data[0] = lc;
data[1] = req;
data[2] = buddy;
lwqq_async_add_event_listener(ev,callback,data);

void callback(LwqqAsyncEvent* ev,void* userdata)
{
    void **d = (void**)userdata;
    LwqqClient* lc = d[0];
    LwqqHttpRequest* req = d[1];
    LwqqBuddy* = d[2];
    s_free(d);

    ......
}

可以看到,非常的丑陋,难于使用。因此,受益于 libpurple/signal.h 的设计,如果是 能够提供一种变参的回调函数的话,就可以方便的传入参数了,而不用这么恶心的在每个 回调的时候加入这段wrapper代码。

需要说明的是,libpurple/signal.h 的实现非常复杂,所以我做了一些简化,抽出了核心 的部分,从而设计成了 vp_list.h

设计原则

那么 vp_list 是怎么设计的呢?首先我们需要使用单行的代码设计,实际上有多行和单行的两种区别,如果是多行的话,就是:

VPList* vp = make_callable(callback)
set_param(vp,1,'i',1);
set_param(vp,2,'s',"hello");

如果是单行的话,则是如同:

make_callable(callback,'i',1,'s',"hello");
make_callable('is',callback,1,"hello");

我们最常接触的变参函数定义可以提供一个很好的思路, 例如printf就有这样的能力。当然,printf的设计核心是使用了va_list。所以我们设计的 起点是变参函数。

然后,一开始我想的是使用va_copy将va_list复制出来。但是实际上是不行的,最主要的 问题是va_list是基于栈的,也就是说,不能在不同的线程中传递,只能在线程中的不同的 栈中传递。那怎么办呢?我们需要把va_list结构赋值到堆中,然后提供一个指针,这样就 可以跨线程的使用了。至于命名嘛,就是 vp_list 了。和va_list非常接近的,非常合适 的名字。设计好不好,第一步就是看命名好不好,为了这个我可是想了很久的说。

然后就是抽出变参函数的共性,c语言不像c++那样动态,变参函数的共性是都有一个模板, 例如printf函数的第一个参数,就指定了接下来的参数列表各自具有什么样子的类型和数量。 另外一种模板的使用是在每个参数的前面用一种特殊的表示,例如上面的例子中的 'i',1,'s',"hello" 所以我们这里也需要一个模板,但是不同于printf,这个模板需 要指定函数的参数。类型千千万,怎么用模板呢?好在大部分的时候我们都是使用的指针 ,而任何类型的指针长度都是一样的,固定的。就是long型的长度。所以这样就大量的简 化了工作了。另外一个问题是模板用什么?字符串? 'ppp' 就表示3个指针,而实际 上会发现在实现的时候会遇到严重的问题,函数怎么写?函数的调用是固定的,就算我们 使用宏,也会固化在代码中,所以最后这里模板选择了函数类型,提高了权限,如果是用 函数作为模板的话,我们能够做的就更多了。我们可以将实际的调用分散在模板函数中。 就像 visit 设计模式(大概)。

在下面我们称模板函数为分发函数 DISPATCH FUNCTION 另外实际的回调函数称为 CALLBACK FUNCTION 剩下的函数的参数就使用变参列表 va_list,最后我们可以得到封 装函数:

typedef void (*CALLBACK_FUNC)(void)
vp_command vp_make_command(DISPATCH_FUNC,CALLBACK_FUNC,...);

CALLBACK_FUNC 可以是任何的函数类型,我们最后都会强制转换为 void (*)(void)的类型 ,当然,这是常用手段。

DISPATCH_FUNC 的设计则需要一些技巧了。首先变参列表可以从 va_list 获取,然 后需要将它复制到堆中, 也就是 vp_list 中,然后,如上文所说,模板函数还需要负责 实际的回调调用。所以 分发函数 有两种使用途径,并且,可以确定出分发函数的类型应 该是 typedef void (*DISPATCH_FUNC)(CALLBACK_FUNC,vp_list* vp,va_list* q) 不过实际代码中是把 va_list * 看作 void * 当然,这无关紧要。

当CALLBACK_FUNC 为NULL的时候,分发函数将va_list 复制到 vp_list 中,至于复制方法 ,首先malloc一个固定长度的buffer,为参数类型的长度的和,因为我们事先知道参数的类 型和数量,所以可以使用sizeof算出来。其次,依次获取一个变量,并使用memcpy赋值到 buffer中。为了提高效率,使用宏定义的方式来实现而不是函数。

当va_list为NULL的时候,分发函数将各个参数从vp_list中取出,然后将函数指针还原成 正确的类型。并执行调用。最后,我们可以看到一个分发函数的大概形式:

void vp_func_2p(VP_CALLBACK func,vp_list* vp,void* q)
{
    typedef void (*f)(void*,void*);
    if( !func ){
        va_list va;
        va_copy(va,*(va_list*)q);
        vp_init(*vp,sizeof(void*)*2);
        vp_dump(*vp,va,void*);
        vp_dump(*vp,va,void*);
        va_end(va);
        return ;
    }
    void* p1 = vp_arg(*vp,void*);
    void* p2 = vp_arg(*vp,void*);
    ((f)func)(p1,p2);
}

有了分发函数,创建vp_command和调用的实现就非常地明显了。下一步,为了能够重叠更 多的回调函数,加入了一个next指针,从而组成了一个链表的形式,更具体的,第一个节 点在栈区,剩下的next部分在堆区:

|stack|---->|heap|---->|heap|---->......

至于为什么不直接将所有节点都制作为堆区,并没有更多特殊的原因。既然已经设计成这 样了就不如这样吧。没有太大的性能差距。而重叠使用的函数是 vp_link 这个函数将 第二个参数(栈区)复制到堆区,并使得第一个参数的next链表的最后一节指向它,最后 为了获得所有权,将第二个参数清理了。以免使用者误调用。

之后,创建两个执行函数 vp_dovp_do_repeat 和一个清理函数 vp_cancel 他们都是先执行第一个栈区的回调,然后在一个循环中执行剩下的堆区的 回调。区别在于 vp_do 一边执行一边清理内存。所以 vp_do 只能执行一次。而 vp_do_repeat 专门为那些需要重复调用的回调函数所设计。执行的时候不清理内存, 在最后退出程序的时候使用 vp_cancel 来执行清理。

最后,为了使用方便,创建一个 _C_ 宏,将vp_command封装一下,使得 _C_(dsph,callable,...) 最后会解析为 vp_make_command(vp_func_##dsph,callable,...) 同时也保留了很好的扩展性,如果 需要调用一个特殊的回调函数,只需要写一个新的分发函数,并且在命名上保持 vp_func_<...> 的风格即可。

永久事件机制

即LwqqEvents, 虽然和LwqqAsyncEvents的命名非常的接近.但是其实是两个层面的东西 .lwqq库实现的核心是LwqqCommand,在此之上, 扩展出来了LwqqAsyncEvents, 其特点是使 用 vp_do 函数, 只能够回调一次, 随即就会被释放内存. 这样的设计在那些网络通信 而言是十分自然的. 因为不同的网络通信的回调都不尽相同, 所以不需要重复.

设计缘由

然而, 另外一种情况是需要重复调用的场合, 也就是真正的事件机制了. 事件机制的本质 就是对一个有名称的事件, 添加一定的处理. 并且其语义相符! 比如说 添加好友 这样 一个事件, 需要对所有的好友都进行相同的处理, 没有歧义. 然而问题出来了: 这样的事 件需要lwqq库主动的传入一些参数, 比如好友指针之类的. 这在 不定长回调 中是没有相 关的.

每个网络通信都会直接返回一个LwqqAsyncEvent用于监控本次通讯.但是有时候一些网络通 讯十分复杂, 需要好几个阶段才能够完成(比如说 登录 , 添加好友 等) , 就需要对 LwqqAsyncEvent做一些比较绕的实现(on_chain是最简单的一种, 另外一种是先返回一个空 的AsyncEvent, 然后必要的时候再手动调用finish来触发). 但是这样的实现不太自然, 可 读性比较差. 而 事件 在这时就负责处理这些隐性的交互.

总结下, 事件 机制语义所需要的一些需求:

  1. 可重复调用
  2. 从库传给回调函数的参数
  3. 从库的深处发出的回调, 不能直接使用LwqqAsyncEvent

设计原则

事件机制的实现还是基于LwqqCommand来完成的. 因为Command实在是提供了太多诱人的特 性了. 如回调重叠, 不定长参数. 都是非常好的特性.

为了解决第一个问题. 使用新的 vp_do_repeat 来具体地执行回调. 这个原语的特点 是, 回调之后不会自动释放内存, 因此就可以重复回调了. 在LwqqClient释放的时候再对 每个事件使用 vp_cancel 来释放内存.

至于第二个问题. 我苦想了许久之后, 最后终于使用 二重指针 来十分优雅的解决了这 个问题. 不添加任何新的代码, 直接再原基础上就能够实现!! 首先,所有的Events都放在 了LwqqClient::events 指针中. 然后, _C_ 宏的参数都是常量固定的. 在中途重新解 构 Command 的变参空间并修改参数实在是过于复杂无法实现. 最后, 我们需要从库向回调 传入参数. 也就是不是在使用 _C_ 时候能够绑定的参数. 那么, 把思想逆转过来 . 不是想着如何修改参数, 而是传入参数的地址(由于大部分参数都是指针类型, 所以这里 就是二重指针了). 参数我们可以做成全局变量. 因此地址也就成了固定的值了. 在实际调 用参数的时候,先向全局变量赋值,然后在调用回调. 这样, 回调函数中绑定了的是全局变 量的地址. 取值之后就获得了库需要传入的参数了.

文字说起来感觉特别绕, 这里用代码简单解释一下实现原理, 首先LwqqClient::args 是一 个结构体, 里面有所有使用各种类型的参数, 一个事件只会使用其中的某些参数. 这个会 在注释中写清楚.

添加回调的代码示例如下:

vp_link(&lc->events->event_name,_C_(3p,callback,arg1,&lc->args->arg_name,arg3));
lwqq_add_event(lc->events->new_friend, _C_(2p,callback,lc,&lc->args->buddy));

上面一行是原形,下面一行是实际的代码. 首先arg1是本地参数, 是调用者可以控制的参数 . 比如下面的lc, 全局范围都不会变动的. 而 &lc->args->arg_name 则是库传入的参 数, 由于这个时候还没有产生, 所以使用地址来绑定. 比如 &lc->args->buddy . new_friend 表示是有新的好友到来的时候, 这个是隐性的, 比如来陌生人消息了, 在 好友列表中是找不到的, 这个时候库就会调用 new_friend 来通知, 快点创建一个好 友显示出来.

而产生回调的代码会如下:

LwqqBuddy* b = lwqq_buddy_new();
...
lc->args->buddy = b;
vp_do_repeat(lc->events->new_friend);

这样, 在回调函数中:

void callback(LwqqClient* lc, LwqqBuddy** p_buddy)
{
   LwqqBuddy* buddy = *p_buddy;
   ......(use buddy)......
   return;
}

是不是挺简单的. 看到这段实现, 至少会提出两个疑问:

  1. 使用全局变量(对一个LwqqClient而言)是否安全. 是否会产生冲突
  2. 既然这样设计了, 那为什么不直接用 LwqqBuddy* buddy = lc->args->buddy 这 样直接就省略了一个参数了.

对于第一个问题, 回答是由于lwqq推荐使用双eventloop的实现, 所以实际上这类回调函数 都会集中在其中一条eventloop上来回调. 当然, 这占用了一个线程, 在线程内一个时间只 能有一个回调执行. 所以不会产生冲突.

对于第二个问题, 回答是为了兼容性. 因为使用指针的实现可以方便的用于其它场合, 而 另外一种由于和LwqqClient结构体绑定死了, 所以使用范围要小得多.例如, 调用者可以直 接使用callback:

void login_complete(LwqqClient* lc)
{
   LwqqBuddy* b;
   LIST_FOREACH(b,&lc->friends,entries)
      callback(lc,&b);
}

而另外一种实现则需要先赋值 args->buddy 才能使用回调, 十分的不直观.

最后, 将所有的事件都放入LwqqClient::events, 将所有需要使用的参数都放在 LwqqClient::args, 起到提示作用, 看到了这两个变量, 就知道了这里是使用了永久事件 了.

扩展机制(Extension)

扩展机制是充分利用了永久事件来实现了. 其设计出发点是因为很多情况下为了实现某些 功能, 都会写大量相似的代码. 那为何不将这些代码都统一起来, 提供一个方便的接口来 启用和禁用呢? 于是Extension就这样提出了.

现在Extension主要被用于lwdb, 可以方便的添加数据库的支持.

首先Extension的接口有两个函数指针, init 和 remove, 分别在启用和禁用的时候调用. 在init中有大量的添加永久事件的代码, 在不同的事件中插入处理代码, 比如数据库要求 在新添加好友和群组的时候, 和讨论组的信息改变的时候, 要及时写入数据库.

而 remove 中则是移除这些添加的事件. 由于Command的底层机制是使用链表. 所以不会造 成太大的问题.

扩展还被设计为自动回收内存的. 也就是说, 不必手工调用 remove, 扩展结构体的内存也 会在释放LwqqClient的时候自动释放掉. 其实现就在于 ext_clean 事件, 会在 LwqqClient释放之前调用. 然后所有的扩展都添加remove函数到该事件中. 就能够自动释 放掉内存了.

但是需要额外注意的是, 不能够完全依赖扩展, 由于Lwqq的设计思想是Light Weight, 所 以事实上扩展和该设计思想有些背离. 一个轻量级的库不应该进行过多的干涉. 因此扩展 所添加的事件比较保守, 其原则是如果是能够获得LwqqAsyncEvent*的, 就不会添加事件了 . 换种说法是如果看得到的事件, 应该用户手工去处理, 看不到的事件则会交给扩展去处 理.

就lwdb的实现而言, 扩展还是能够很好的完成其工作的.