-
Notifications
You must be signed in to change notification settings - Fork 25
Design Events
lwqq 使用两种事件,都是基于 LwqqCommand
这一强大的机制实现的。
通常很多函数都会返回一个 LwqqAsyncEvent
指针。通过使用这个指针能够添加回调
函数,从而当Http过程结束后,能够得到通知进行相应的处理。这个事件是一次性的,也
就是当所有的回调都结束后,这个事件会自动的释放,不能够再次使用。
事实上 LwqqCommand 已经具有所有的回调函数的能力,然而在这里,将 LwqqCommand 升 华到了一个更高的层次 --- LwqqAsyncEvent。加入了许多新的特性:
- 和 LwqqHttpRequest 绑定,一个事件对应一个 http 请求。
- 和 LwqqClient 绑定,一个事件属于一个 qq 。
- 和 LwqqAsyncEvset 配套,从而组成更加复杂的异步任务。
- 结果号(result)和错误号(failcode),两者的区别是结果号对应的是 webqq 服务器返 回的 json 的 retcode(正数) 或者是内部错误(负数), 而错误号是更广泛的诸如取消 ,同步,重试。通常有一个结果号的时候就是没有错误,相反,有一个错误号的时候 结果号不可靠的。
- 自动释放内存
LwqqAsyncEvset 的语义是将许多事件加入一个组,同时监控,当所有的事件都完成的时候 执行回调。特别适合批量网络任务(比如获取所有用户的头像,所有用户的QQ号)等等的。 它提供的特性包括:
- 所有事件完成后触发事件
- 提供错误的事件的数量(为了简化设计,只能提供错误的数量而不能提供哪些错误了) 为了找出哪些事件错误了,需要对所有事件添加回调判断结果号。
- 半自动释放内存
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_do
和 vp_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来触发). 但是这样的实现不太自然, 可 读性比较差. 而 事件 在这时就负责处理这些隐性的交互.
总结下, 事件 机制语义所需要的一些需求:
- 可重复调用
- 从库传给回调函数的参数
- 从库的深处发出的回调, 不能直接使用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; }
是不是挺简单的. 看到这段实现, 至少会提出两个疑问:
- 使用全局变量(对一个LwqqClient而言)是否安全. 是否会产生冲突
- 既然这样设计了, 那为什么不直接用
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主要被用于lwdb, 可以方便的添加数据库的支持.
首先Extension的接口有两个函数指针, init 和 remove, 分别在启用和禁用的时候调用. 在init中有大量的添加永久事件的代码, 在不同的事件中插入处理代码, 比如数据库要求 在新添加好友和群组的时候, 和讨论组的信息改变的时候, 要及时写入数据库.
而 remove 中则是移除这些添加的事件. 由于Command的底层机制是使用链表. 所以不会造 成太大的问题.
扩展还被设计为自动回收内存的. 也就是说, 不必手工调用 remove, 扩展结构体的内存也
会在释放LwqqClient的时候自动释放掉. 其实现就在于 ext_clean
事件, 会在
LwqqClient释放之前调用. 然后所有的扩展都添加remove函数到该事件中. 就能够自动释
放掉内存了.
但是需要额外注意的是, 不能够完全依赖扩展, 由于Lwqq的设计思想是Light Weight, 所 以事实上扩展和该设计思想有些背离. 一个轻量级的库不应该进行过多的干涉. 因此扩展 所添加的事件比较保守, 其原则是如果是能够获得LwqqAsyncEvent*的, 就不会添加事件了 . 换种说法是如果看得到的事件, 应该用户手工去处理, 看不到的事件则会交给扩展去处 理.
就lwdb的实现而言, 扩展还是能够很好的完成其工作的.