第八章 VimL 异步编程特性
8.3 使用通道控制任务
8.3.1 通道的概念
Vim 手册上的术语 channel 直译为通道,比起任务 job 听来更为抽象。上一节介绍的任 务,直观想起来,即使不是瞬时能完成的“慢”命令,也是一项“短命”的命令,可以期望它 完成,也就完成了任务。
显然,我们可以用 job_start()
同时开启几个异步命令,但是如果企图通过这方式开 启一组貌似相关的任务,可能达不到目的。因为开启的不同任务相互之间是独立的,各自 独立在后台运行。比如,连续开启以下两个命令:
: call job_start('cd ~/.vim/')
: call job_start('ls')
这两条语句写在一起,并不能(或许有人想当然那样)进入目标目录后列出文件。第一条 语句开启一个后台命令 cd
进入目录,但是什么也没干就完成了;第二条语句开启另一 个独立的后台命令 ls
仍然是列出当前目录的文件。
不过这个需求在 vim 中是有解决办法的,想想在 vim8.1 中随着异步特性增加的内置终 端的功能,显然是可以通过开启内置终端,在此内置终端中输入 cd
ls
命令列出目 标目录下的所有文件:
: 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
选项指定的函数(引用),并向它传递两个参数;故回调函数 一般是形如这样的:
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
模式的通道(彼端)发消息,推荐如下三种方式之一:
call ch_sendexpr(channel, {expr})
call ch_sendexpr(channel, {expr}, {'callback': Handler})
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
模式,无法使用上面这两个函数交互,应该使用另外两个对应的函 数:
call ch_sendraw(channel, {string})
call ch_sendraw(channel, {string}, {'callback': 'MyHandler'})
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 这边无关了,它们之间只是达到一个协议,需 要传输一个两边都能正确解析的字符串(消息字节)就可以了。
此外还得辨别另一个概念,通道的这四种解析模式,与通道的两种通讯模式又不是同一层 次的东西。后者指的是 socke 或管道(pipe),是与操作系统进程间通讯的更底层的概 念,前者 json
或 NL
却是 VimL 应用层面的模式。上一节介绍的任务,由 job_start()
启动的,使用是管道,重定向了标准输入输出与错误;这一节介绍的通道 ,由 ch_open()
开启的,使用的是 socket ,绑定到了特定的端口地址。然后,在 vim 中,将任务的管道,也视为一种特殊通道。
8.4.4 通道示例:自制简易的 vim-终端
本节的最后,打算介绍一个网友写的拟终端插件: https://github.com/ZSaberLv0/ZFVimTerminal
这应该是在 vim8.1 暂时未推出内置终端,但先提供了 +job
与 +channel
写的插件 ,目的在于直接在 vim 中模拟终端,执行 shell 命令。虽然没有后来 vim 内置终端那 么功能强大,但也颇有自己的特色。关键是还比较轻量,代码量不多,可用之学习一下如 何使用 vim 任务与通道的异步功能。借鉴、阅读源码也正是学习任何语言编程的绝好法 门。
首先应该了解,作为发布在 github 上的插件,或多或少都会追求某些通用性,于是在插 件中就不可避免涉及许多配置,比如全局变量的判断与设置。就像这个插件,它想同时用 于 vim 与 nvim ,两者在异步功能上可能提供了略有不同的内置函数接口,然而还想兼 容 vim7 低版本下没异步功能时退回使用 system()
代替。
抛开这些“干扰”信息,直击关键代码,看看如何使用 vim 的异步功能吧。从功能说明入 手,它主要是提供了 :ZFTerminal
命令,在源码中寻找该命令定义,获知它所调用的 私有函数 s:zfterminal
:
command! -nargs=* -complete=file ZFTerminal :call s:zfterminal(<q-args>)
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()
它是这样开启的:
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:
作用域内。 至于回调函数,请自行结合所实现的功能跟踪,此不再赘述。