# 第八章 VimL 异步编程特性 ## 8.3 使用通道控制任务 ### 8.3.1 通道的概念 Vim 手册上的术语 channel 直译为通道,比起任务 job 听来更为抽象。上一节介绍的任 务,直观想起来,即使不是瞬时能完成的“慢”命令,也是一项“短命”的命令,可以期望它 完成,也就完成了任务。 显然,我们可以用 `job_start()` 同时开启几个异步命令,但是如果企图通过这方式开 启一组貌似相关的任务,可能达不到目的。因为开启的不同任务相互之间是独立的,各自 独立在后台运行。比如,连续开启以下两个命令: ```vim : call job_start('cd ~/.vim/') : call job_start('ls') ``` 这两条语句写在一起,并不能(或许有人想当然那样)进入目标目录后列出文件。第一条 语句开启一个后台命令 `cd` 进入目录,但是什么也没干就完成了;第二条语句开启另一 个独立的后台命令 `ls` 仍然是列出当前目录的文件。 不过这个需求在 vim 中是有解决办法的,想想在 vim8.1 中随着异步特性增加的内置终 端的功能,显然是可以通过开启内置终端,在此内置终端中输入 `cd` `ls` 命令列出目 标目录下的所有文件: ```vim : terminal $ cd ~/.vim $ ls ``` 既然可以在 vim 中列出一串内容,可想而知也有他法将列出的内容捕获到 VimL 变量中, 再进行想要的程序逻辑加工。 `:terminal` 命令其实有个默认参数,就是异步开启一个交互 shell 进程(如 bash), 只不过这个任务与上一节介绍的异步任务有所不同,特殊在于它是不会主动结束的,相当 于一个无限死循环等待用户输入,再解释执行(shell 命令)给出回应。那么 vim 与后 台异步开启的这个 shell 进程(任务),肯定是该有个东西连着,以促成相互之前的通 讯,这个东西就叫做“通道”,也就是 channel 。 通道的一端自然是连着 vim ,另一端一般连着的是能长期运行的服务程序。上一节介绍 的异步任务,也是有个通道连着外部命令的,如此 vim 才能知道外部命令有输出,什么 时间结束,才能在适当时机调用回调函数。只不过那外部命令自然结束后,通道也就断了 。所以最好反过来理解,通道才是底层更通用的机制,任务是一种短平快的特殊通道。 Vim 的在线文档 `:help channel` 专门有个文档来描叙通道(及任务)的使用细节,并 且在一开始还有个用 python 写的简单服务程序,用于演示 vim 的通道联连与交互。对 python 有亲切感的读者,可以好好跟一下这个演示示例。从这么简单朴素的服务开始, 通道可以实现复杂如内置终端这样的标志性功能。虽然我们学 VimL ,不求一下子就能写 那么复杂的高级功能,但理解通道的机制,掌握通道的用法,也就能大大扩展 VimL 编程 的效能,满足在旧版本所无法实现的需求。 ### 8.3.2 开启通道与模式选项 要开启一个通道,使用 `ch_open()` 函数,我们将其函数“原型”与前面两节介绍的定时 器、任务的启动函数放在一起对照来看: * 定时: `timer_start({time}, {callback} [, {options}])` * 任务: `job_start({command} [, {options}])` * 通道: `ch_open({address} [, {options}])` 定时器的第一参数是时间,因为它是将在确定的时间内执行工作,同时定时器要有效用, 也必须在第二参数处提供回调函数,以表示到那时执行具体的动作。而任务,是无法提前 得知执行外部命令需要多少(毫秒)时间的。所以启动任务的第一参数,就是外部命令, 有时这就够了,只要让它在后台默默完成即可;之后的选项是可选的,而且对于复杂任务 ,也可能需要几种不同时机的回调,故而全部打包在一个较大的选项字典中,令使用接口 简单清晰。 至于通道,它更抽象在于,它其实不是针对具体命令的,而是针对某个“地址”,就如 socket 编程范畴的“主机:端口”的地址概念。Vim 的通道就是可以联接到这样的地址,与 其另一端的服务进行通讯,至于另一端的服务是由什么命令、由什么语言写的程序,这不 需要关心,也不影响。 在通道的选项集中,除了同样重要的回调函数外,还有个更基础的模式选项须得关注,就 是叫 `mode` 的。模式规定了 Vim 与另一端的程序通讯时的消息格式,粗略地讲,可直 观地理解为传输、读写的字符串格式。共支持四种模式,上一节介绍的由 `job_start()` 启动的任务默认就是使用 NL 模式,意为 `newline` ,换行符分隔每个消息(字符串)。 这里使用 `ch_open()` 开启的通道默认使用 `json` 格式。json 是目前互联网上很流行 的格式,vim 现在也内置了 json 的解析,所以使用方便灵活。 另外两种模式叫做 `js` 与 `raw` 。`js` 模式是与 `json` 类似的、以 javascript 风 格的格式,文档上说效率比 `json` 好些。因为 `js` 编码解码没那么多双引号,以及可 省略空值。 `raw` 是原始格式之意,也就是没任何特殊格式,vim 对此无法作出任何假 设与预处理,全要由用户在回调函数中处理。 至于在具体的 VimL 编程实践中,该使用哪种模式的通道,这取决于要连接的另一端的程 序如何提供服务了。如果能提供 `json` 或 `js` 最好,要不 `NL` 模式简单,如果连换 行符也不一定能保证,那就只能用 `raw` 了。如果另一端的程序也是由自己开发,那掌 握权就更大了,如果简单的可以用 `NL` 模式,复杂的服务就推荐 `json` 了。 模式之所以重要,是因为它深刻影响了回调函数的写法。比如 vim 从通道中每次收到消 息,就会调用 `callback` 选项指定的函数(引用),并向它传递两个参数;故回调函数 一般是形如这样的: ```vim function! Callback_Handler(channel, msg) echo 'Received: ' . a:msg endfunction ``` 其中第一参数 `a:channel` 是通道 ID ,就是 `ch_open()` 的返回值,代表某个特定的 通道(显然可以同时运行多个通道)。第二参数 `a:msg` 所谓的消息,就与通道模式有 关了。如果是 `json` 或 `js` 模式,虽然 vim 收到的消息初始也是字符串,但 vim 自 动给你解码了,于是 `a:msg` 就转换为 VimL 数据类型了,比如可能是富有嵌套的字典 与列表结构。如果是 `NL` 模式,则是去除换行符的字符串;当然如果是 `raw` 模式, 那就是最原始的消息了,可能有的换行符也得用户在回调中注意处理。 ### 8.3.3 通道交互 与任务不同的是,通道仅仅由 `ch_open()` 开启是不够的。那只是建立了连接,告诉你 已经准备好可以与另一端的程序服务协同工作了。但一般它不会自动做具体的工作,需要 让 vim 与彼端的服务互通消息,告诉对方我想干什么,请求对方帮忙完成,并(异步或 同步地)等待回应。虽然有些服务可以主动向 vim 发一些消息,让 vim 自动处理,但毕 竟有限,你也不能放任外部程序不加引导控制地影响 vim 是不。所以,有来有往的消息 传递,才是通道常规操作,也是其功能强大所在。 互通消息的方式,也与通道模式有关。 向 `json` 或 `js` 模式的通道(彼端)发消息,推荐如下三种方式之一: 1. `call ch_sendexpr(channel, {expr})` 2. `call ch_sendexpr(channel, {expr}, {'callback': Handler})` 3. `let response = ch_evalexpr(channel, {expr})` 注意前两种写法,直接用 `:call` 命令调用函数,忽略函数返回值。它单纯地发送消息 ,异步等待回应;当之后某个时刻收到响应后,就调用通道的回调函数。但是如第二种用 法,在发送消息时提供额外选项,单独指定这条消息的回调函数。 于是就要有一种机制来区分哪条消息,vim 在发送消息时实际上发送 `[{number},{expr}]` ,即在消息之前附加一个编号,组成一个二元列表。该编号是 vim 内部处理的,一般是 递增保证唯一,`{expr}` 才是由程序员指定的 VimL 有效数值(或数据结构),并再由 vim 编码成 `json` 字符串,或 `js` 风格的类似字符串。通道彼端接收到这样的消息, 将 `json` 字符串解码,经其内部处理后,再由通道发还给 vim ,并且也是由编号、消 息体组成的二元列表 `[{number},{response}]`。在同一请求——回应中,编号是相同的, vim 据此就能分发到对应的回调函数,传入的第二参数也就是 `{response}` ,不包含编 号的消息主体。 当然,按第一种写法未指定回调地发送消息,收到响应时就会默认分到 在 `ch_open()` 中指定的回调函数中。 至于第三种写法,一般要用 `:let` 命令获取 `ch_evalexpr()` 的返回值。这是同步等 待,就如 `system()` 函数捕获输出一样。同步虽然可能阻塞,但优点是程序逻辑简单, 不必管回调函数那么绕。在通道已经建立的情况下,如果另一端的服务程序也运行在本地 机器, `ch_evalexpr()` 可能比 `system()` 快些。因此,如果预期将要请求执行的操 作并不太复杂时,可尽量用这种同步消息组织编程。另外,通道也有个超时选项,不致于 让 vim 陷入无限等待的恶劣情况。在超时或出错情况下,`ch_evalexpr()` 返回空字符 中,否则返回的也是已解码的 VimL 数据,如同 `ch_sendexpr()` 收到回应时传给回调 函数的消息主体。 对于 `NL` 或 `raw` 模式,无法使用上面这两个函数交互,应该使用另外两个对应的函 数: 1. `call ch_sendraw(channel, {string})` 2. `call ch_sendraw(channel, {string}, {'callback': 'MyHandler'})` 3. `let response = ch_evalraw(channel, {string})` 其中第二参数必须是字符串,而不能是其他复杂的 VimL 数据结构,并且可能需要手动添 加末尾换行符(视通道彼端程序需求而论)。 `json` 与 `js` 模式的通道也能用 `ch_sendraw()` 与 `ch_evalraw()` ,不过需要事 先调用 `json_encode()` 将要发送的 VimL 数据转换(编码)为 `json` 字符串再传给 这俩函数;然后在收到响应时,又要将响应消息用 `json_decode()` 解码以获得方便可 用 VimL 数据。 因此,所谓通道的四种模式,是指通道的 vim 这端如何处理消息的方式,vim 能在多大 程度上自动处理消息的区别上。至于通道另一端如何处理消息,那就不是 vim 所能管的 事了,是那边的程序设计话题。也许那边的程序也有个网络框架自动将 `json` 解码转化 为目标语言的内部数据,或者要需要手动调用 `json` 库的相关函数,再或者是简单粗暴 地自己解析 `json` 字符串……那都与 vim 这边无关了,它们之间只是达到一个协议,需 要传输一个两边都能正确解析的字符串(消息字节)就可以了。 此外还得辨别另一个概念,通道的这四种解析模式,与通道的两种通讯模式又不是同一层 次的东西。后者指的是 socket 或管道(pipe),是与操作系统进程间通讯的更底层的概 念,前者 `json` 或 `NL` 却是 VimL 应用层面的模式。上一节介绍的任务,由 `job_start()` 启动的,使用的是管道,重定向了标准输入输出与错误;这一节介绍的通道 ,由 `ch_open()` 开启的,使用的是 socket ,绑定到了特定的端口地址。然后,在 vim 中,任务的管道,也视为一种特殊通道。 ### 8.4.4 通道示例:自制简易的 vim-终端 本节的最后,打算介绍一个网友写的模拟终端插件: [https://github.com/ZSaberLv0/ZFVimTerminal](https://github.com/ZSaberLv0/ZFVimTerminal) 这应该是在 vim8.1 暂时未推出内置终端,但先提供了 `+job` 与 `+channel` 时写的插件 ,目的在于直接在 vim 中模拟终端,执行 shell 命令。虽然没有后来 vim 内置终端那 么功能强大,但也颇有自己的特色。关键是还比较轻量,代码量不多,可用之学习一下如 何使用 vim 任务与通道的异步功能。借鉴、阅读源码也正是学习任何语言编程的绝好法 门。 首先应该了解,作为发布在 github 上的插件,或多或少都会追求某些通用性,于是在插 件中就不可避免涉及许多配置,比如全局变量的判断与设置。就像这个插件,它想同时用 于 vim 与 nvim ,两者在异步功能上可能提供了略有不同的内置函数接口,然而还想兼 容 vim7 低版本下没异步功能时退回使用 `system()` 代替。 抛开这些“干扰”信息,直击关键代码,看看如何使用 vim 的异步功能吧。从功能说明入 手,它主要是提供了 `:ZFTerminal` 命令,在源码中寻找该命令定义,获知它所调用的 私有函数 `s:zfterminal` : ```vim command! -nargs=* -complete=file ZFTerminal :call s:zfterminal() function! s:zfterminal(...) let arg = get(a:, 1, '') " ... (省略) let needSend=!empty(arg) if exists('b:job') let needSend=1 else call s:updateConfig() let job = s:job_start(s:shell) let handle = s:job_getchannel(job) call s:initialize() let b:job = job let b:handle = handle if exists('g:ZFVimTerminal_onStart') && g:ZFVimTerminal_onStart!='' execute 'g:ZFVimTerminal_onStart(' . b:job . ', ' . b:handle . ')' endif endif if needSend silent! call s:ch_sendraw(b:handle, arg . "\n") endif " ... (省略) endfunction ``` 它这里的思路是将开启的任务保存在 `b:job` 中。这很有必要,因为随后的回调函数都 要用到任务 ID (功通道 ID)。它不能保存在函数中的局部变量中,否则离开函数作用 域就不可引用该 ID 了,也不宜污染全局变量。于是脚本级的 `s:` 变量合适;如果异步 任务始终与某个 buffer 关联,则保存在 `b:` 作用域更清晰,且容易支持多个任务并 行。`ZFTerminal` 正是将一个普通 buffer 当作 shell 前端来用,因而保存为 `b:job` 。 如果在执行命令时,任务不存在,就用 `job_start()` 开始一个任务,否则就向与任务 关联的通道用 `ch_sendraw()` 发送消息。它为这两个函数再作了一个浅层包装(主要为 兼容代码考量及定义一些默认选项)。`job_start()` 它是这样开启的: ```vim function! s:job_start(command) " ... return job_start(a:command, { \ 'exit_cb' : 'ZFVimTerminal#exitcb', \ 'out_cb' : 'ZFVimTerminal#outcb_vim', \ 'err_cb' : 'ZFVimTerminal#outcb_vim', \ 'stoponexit' : 'kill', \ 'mode': 'raw', \ }) endfunction ``` 在这里它指定了几个回调函数,并将通道模式设为 `raw` 。所以在后续 `:ZFTerminal` 命令中就用 `ch_sendraw()` 发送消息了。注意发送消息需要通道 ID 参数,使用 `job_getchannel()` 函数可以获取相任务关联的通道,并且也保存在 `b:` 作用域内。 至于回调函数,请自行结合所实现的功能跟踪,此不再赘述。