由于其运行环境的特殊性,Javascript大量使用异步的通信机制,凡是涉及到网络调用和事件机制的代码都会涉及。在异步通信的环境下编码经常会用到 回调函数。Javascript由于有函数式语言的一些特点使得它在Javascript里面实现回调函数非常的优雅和自然,包括函数作为一级的对象、匿 名函数、闭包机制等。但是要体会到个中的优雅,需要先融汇贯通这些机制。如果是初学者学习这些东西可能比有编程经验的人少很多障碍,认为事情本来就该是这 个样子。但是,对于长期使用过程式语言编码(比如传统的C/C++程序员),又没有接触过函数式语言的程序员来说,可能需要阅读一道思维的小坎。这件事情 有时候会造成一定的困扰,因为“老手”程序员会想:毕竟我已经懂得一套能写程序的方法,大家都说语言之间差别不重要,毕竟C++里面也有使用异步调用的时 候,主要注意一下语法的区别就好了。所以最终就变成了使用Javascript来模仿别的过程式语言,这样的结果最终很有可能是写出很别扭的程序给自己添 堵。本文尝试用几个例子说明异步通信的环境用Javascript写回调函数很使用类似C语言写回调函数的区别,以及为什么Javascript原生要更 适合做这件事情。(简单起见,下面例子中的代码均为伪代码,并不一定严格符合C/C++或者Javascript的语法,但是笔者尽量写得与语法要求接 近。)
我们首先从C/C++的同步调用开始,假设我们要写一个函数,向远方的服务器发送一个字符串形式得命令,并且从服务器得到一个字符串作为响应。例1就展示了使用C语言在同步同步通信的机制下代码的样子。
例1 使用C语言的编码方式实现调用访问远程的接口
view plaincopy to clipboardprint?
01.//{{{get_data_v1
02.int get_data_v1()
03.{
04. // 准备数据
05. char bufCmd[]="cmd=1001&uin=123456?m=abc";
06. char bufRcv[4096];
07. // 建立连接
08. socket s = new Socket();
09. connnect(s, ip, port);
10. // 发送数据
11. send(s, bufCmd);
12. // 接收数据
13. recv(s, bufRcv);
14. // 处理结果
15. use(bufRcv);
16. return 0;
17.}
18.//}}}
在 例1中,get_data_v1执行了准备数据、创建了socket、建立连接、发送请求、接收响应并最终使用use函数处理接收到的数据,一切都显得很 自然。为了方便说明问题,我们将这个通信的过程封装一下,将整个建立连接并收发包的过程封装成一个叫send_and_recv的函数。
例2 将通信过程封装成独立的函数,简化业务流程代码
view plaincopy to clipboardprint?
01.//{{{get_data_v2
02.// 发包收包的过程
03.int send_and_recv(struct addr, char* bufCmd, char* bufRcv)
04.{
05. socket s = new Socket();
06. connnect(s, addr.ip, addr.port);
07. send(s, bufCmd);
08. recv(s, bufRcv);
09.}
10.// 原来的业务流程
11.int get_data_v2()
12.{
13. // 准备数据
14. char bufCmd[]="cmd=1001&uin=123456?m=abc";
15. char bufRecv[4096];
16. // 通信,收发数据
17. // addr={ip, port}
18. send_and_recv(addr, bufCmd, bufRcv);
19. // 处理结果
20. use(bufRcv);
21. return 0;
22.}
23.//}}}
例 2和例1很类似,不过是对通信过程进行封装了,并且ip-port对也变成了一个叫addr的地址结构体。改动以后处理过程变得更简单,剩下准备数据、通 信和处理结果三步。现在,我们开始进入正题,现在我们假设这个通信过程变成异步的,它接收一个回调函数用于处理取得的数据。如例3所示。
例3 将通信过程变成异步调用
view plaincopy to clipboardprint?
01.//{{{get_data_v3
02.// 变成异步调用以后,原来的调用过程分成了两段
03.// 前半段组装参数调用发包过程
04.// 后半段处理返
05.// 这里假设send_and_recv是一个异步的网络通信函数
06.void get_data_v3()
07.{
08. char bufCmd[]="cmd=1001&uin=123456?m=abc";
09. char bufRcv[4096];
10. send_and_recv_async(addr, bufCmd, bufRcv, callback);
11.} // end of get_data_v3
12.// 回调函数的定义
13.int callback(char* bufRcv) {
14. // 处理接收都的数据
15. use(bufRcv);
16. return 0;
17.}
18.//}}}
在 例3中,假设使用了一个异步的通信过程send_and_recv_async,最后一个参数callback是一个回调函数指针。然后,当接收到响应以 后,send_and_recv_async会调用callback并传入接收到的数据。相比例2,这个get_data的过程被异步通信过程一分为二: 前半段为准备请求,后半段是处理结果。事实上,对将同步通信方式变成异步以后,都会涉及到将原来完整处理过程一分为二的问题。在两段程序没有什么相互依赖 的情况下,这样的分解不会造成什么问题。但是,如果处理结果的过程依赖于一些外部参数,那么情况就会变得很复杂。我们先来看看在同步通信的情况下,程序的 样子,见例4。
例4 假设处理结果的时候依赖外部参数
view plaincopy to clipboardprint?
01.//{{{get_data_v4
02.// 这里原来的业务流程需要外部传进来的两个参数(a,b)来决定如何处理结果
03.int get_data_v4(int a, int b)
04.{
05. char bufCmd[]="cmd=1001&uin=123456?m=abc";
06. char bufRcv[4096];
07. send_and_recv(addr, bufCmd, bufRcv);
08. // 处理过程依赖于外部传进来的参数a和b
09. use(bufRcv, a, b);
10. return 0;
11.}
12.//}}}
在例4中,我们的结果处理过程use依赖于传入的两个参数a和b。现在我们来看看例4的程序如果使用异步通信会怎样,见例5。
例5 加上参数依赖后再变成异步调用
view plaincopy to clipboardprint?
01.// 版本a
02.//{{{get_data_v5
03.// 需要参数的异步调用需要将参数透传到后半段的回调函数中
04.void get_data_v5a(int a, int b)
05.{
06. char bufCmd[]="cmd=1001&uin=123456?m=abc";
07. char bufRcv[4096];
08. send_and_recv_async(addr, bufCmd, bufRcv, callbacka, a, b);
09.} // end of get_data_v5a
10.// 回调函数的定义
11.int callbacka(char* bufRcv, int a, int b) {
12. use(bufRcv, a, b);
13. return 0;
14.}
15.// 版本b
16.int g_a;
17.int g_b;
18.void get_data_v5b(int a, int b)
19.{
20. g_a = a;
21. g_b = b;
22. char bufCmd[]="cmd=1001&uin=123456?m=abc";
23. char bufRcv[4096];
24. send_and_recv_async(addr, bufCmd, bufRcv, callbackb);
25.} // end of get_data_v5b
26.// 回调函数的定义
27.int callbacka(char* bufRcv, int a, int b) {
28.int callbackb(char* bufRcv) {
29. use(bufRcv, g_a, g_b);
30. return 0;
31.}
32.//}}}
例 5中有两个版本,get_data_v5a假设了通信机制可以透传a和b两个参数给回调函数,get_data_v5b则使用了两个全局变量来传递处理结 果所需的参数。两个都不见得是很好的方法,get_data_v5a的问题是,异步通信的机制不见得能提供这种透传机制,除非程序员自己封装;即使程序员 自己封装,那也意味着如果要实现多个处理数据的过程(像get_data)那就要实现多个异步调用的过程(send_and_recv_async),代 码复杂且复用性差不好维护。而全局变量的版本也好不到哪里去,使用这种全局的机制,意味着不必要的信息暴露,也就有被别的地方错修改的问题,同时这个函数 还变成不可重入的。即使将全局机制封装在一个类里面,每次初始化一个对象,可以改善依然不能解决信息暴露的问题,同时还带来了管理这多个对象的复杂性。
两种方法相比而言,貌似透传的机制要稍好一些。我们对get_data_v5a略做修改,使得它通信过程能够有更广泛的复用。
例6 使用一个closure对象打包过程中的参数
view plaincopy to clipboardprint?
01.//{{{get_data_v6
02.// 为了统一回调函数的形式并且缩短回调的参数列表,将这种需要透传的参数只有一个
03.// 统一的数据结构打包
04.void get_data_v6(int a, int b)
05.{
06. // 准备数据
07. char bufCmd[]="cmd=1001&uin=123456?m=abc";
08. char bufRcv[4096];
09. // 打包处理结果所需要的参数
10. closure.a = a;
11. closure.b = b;
12. // 通信
13. send_and_recv_async(addr, bufCmd, bufRcv, callback, closure);
14.} // end of get_data_v6
15.// 回调函数的定义
16.int callback(char* bufRcv, struct closure) {
17. // 处理结果
18. use(bufRcv, closure.a, closure.b);
19. return 0;
20.}
21.//}}}
例 6里面使用了一个叫closure的结构,假设这个结构是个通用的数据容器,可以容纳我们使用的个中类型的任意数量的参数。增加了这一个万能的数据容器参 数以后,异步通信过程只要能透传这么一个数据容器就能够很好支持个中各样的参数透传的需求。这个数据容器由于是在get_data函数内部产生的局部变 量,不会污染全局数据或者比get_data更大的作用域。这种受限的可见性不仅提高了代码的可维护性,还恢复了函数的可重入性。
至此我 们关于回调机制的实现的假想代码可以说已经达到比较优雅的程度了,仅仅还有一朵小乌云。那就是我们忽略了C/C++语言里面并没有原生实现这个超级结构, 同样我们依然还有一点点麻烦就是还需要指定要透传的参数。考虑到原本从准备数据到通信再到处理结果是一个完整统一的过程,原本不需要区分什么数据是前半端 使用的什么数据是后半段使用的,只要脚气怎么治疗让前半端和后半段共享一个上下文在大部分情况下就能满足需求了。所以现实情况下我们只能做一些妥协,使用个中折衷方案 来使得程序能运行起来。同样,考虑到回调函数和启动函数的关系,给回调函数命名也不是那么优雅的事情,因为毕竟它们只是同一个过程的两半,却要使用两个名 字,合理一点就应该叫get_data_first和get_data_second,或者get_data_trigger和 get_data_result_handler。如果接口多的话,就会有很多这种某过程first和某过程second,或者某过程trigger和某 过程result_handler。能不能某过程就象同步那样使用一个名字呢?我们的设想真的就没有办法达到吗?答案是否定的,在Javascript能 够帮助我们实现我们所有的设想。见例7。
例7 Javascript的异步调用
view plaincopy to clipboardprint?
01.//{{{get_data_js
02.//
03.// 写成Javascript代码就变成现在这个样子
04.// url对应之前的addr
05.// 使用匿名函数代替原来命名的callback定义
06.// 原生支持闭包closure
07.//
08.function get_data_js(a, b)
09.{
10. var bufCmd = "cmd=1001&uin=123456?m=abc";
11. var bufRcv;
12. send_and_recv_with_xhr(/*addr*/url, bufCmd, bufRcv, /*callback*/
13. function(bufRcv/*, closure*/) {
14. use(bufRcv, /*closure.*/a, /*closure.*/b);
15. return 0;
16. }
17. );
18.}
19.//}}}
例 7是使用Javascript实现类似例6的功能,仅仅存在一些细微的差别。例6的场景下可能更多使用TCP或者UDP作为通信协议,而在例7使用的则是 浏览器提供的XHR对象实现的HTTP协议。这点差别并不会影响我们对于异步通信下回调函数实现机制的讨论,只要他们的通信机制都是异步的就可以了。例7 中使用注释的形式标注了例6里面使用的一些参数的名字以暗示它们的对应关系,方便比较这两个例子。我们看到了,在Javascript里面我们所有的设想 都变成了现实。(1)首先关于能够透传一切的超级结构,Javascript中实现了闭包的机制,保证了在这种内部的函数对象可以访问到定义它的环境能访 问到的所有数据,也就是在例7中的匿名回调函数可以访问到get_data_js中能访问到的所有数据。当然,这里重要的是局部数据,如a和b。如果是全 局数据的话左旋肉碱真的有用吗并不需要通过闭包也能访问到。而且这个过程是Javascript的运行环境提供的,对于程序员是透明的,程序员并不需要指定哪些参数需要透 传。(2)不需要再为回调函数命名,因为Javascript支持匿名函数的定义,可以像定义变量一样定义函数。而这个最终导致了我们在使用异步通信机制 的时候和使用同步的通信机制及其接近,没有多余的名字,没有不必要的可见性。