8.3 使用通道控制任务

第八章 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 的解析,所以使用方便灵活。

另外两种模式叫做 jsrawjs 模式是与 json 类似的、以 javascript 风 格的格式,文档上说效率比 json 好些。因为 js 编码解码没那么多双引号,以及可 省略空值。 raw 是原始格式之意,也就是没任何特殊格式,vim 对此无法作出任何假 设与预处理,全要由用户在回调函数中处理。

至于在具体的 VimL 编程实践中,该使用哪种模式的通道,这取决于要连接的另一端的程 序如何提供服务了。如果能提供 jsonjs 最好,要不 NL 模式简单,如果边换 行符也不一定能保证,那就只能用 raw 了。如果另一端的程序也是由自己开发,那掌 握权就更大了,如果简单的可以用 NL 模式,复杂的服务就推荐 json 了。

模式之所以重要,是因为它深刻影响了回调函数的写法。比如 vim 从通道中每次收到消 息,就会调用 callback 选项指定的函数(引用),并向它传递两个参数;故回调函数 一般是形如这样的:

function! Callback_Handler(channel, msg)
    echo 'Received: ' . a:msg
endfunction

其中第一参数 a:channel 是通道 ID ,就是 ch_open() 的返回值,代表某个特定的 通道(显然可以同时运行多个通道)。第二参数 a:msg 所谓的消息,就与通道模式有 关了。如果是 jsonjs 模式,虽然 vim 收到的消息初始也是字符串,但 vim 自 动给你解码了,于是 a:msg 就转换为 VimL 数据类型了,比如可能是富有嵌套的字典 与列表结构。如果是 NL 模式,则是去除换行符的字符串;当然如果是 raw 模式, 那就是最原始的消息了,可能有的换行符也得用户在回调中注意处理。

8.3.3 通道交互 #

与任务不同的是,通道仅仅由 ch_open() 开启是不够的。那只是建立了连接,告诉你 已经准备好可以与另一端的程序服务协同工作了。但一般它不会自动做具体的工作,需要 让 vim 与彼端的服务互通消息,告诉对方我想干什么,请求对方帮忙完成,并(异步或 同步地)等待回应。虽然有些服务可以主动向 vim 发一些消息,让 vim 自动处理,但毕 竟有限,你也不能放任外部程序不加引导控制地影响 vim 是不。所以,有来有往的消息 传递,才是通道常规操作,也是其功能强大所在。

互通消息的方式,也与通道模式有关。

jsonjs 模式的通道(彼端)发消息,推荐如下三种方式之一:

  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() 收到回应时传给回调 函数的消息主体。

对于 NLraw 模式,无法使用上面这两个函数交互,应该使用另外两个对应的函 数:

  1. call ch_sendraw(channel, {string})
  2. call ch_sendraw(channel, {string}, {'callback': 'MyHandler'})
  3. let response = ch_evalraw(channel, {string})

其中第二参数必须是字符串,而不能是其他复杂的 VimL 数据结构,并且可能需要手动添 加末尾换行符(视通道彼端程序需求而论)。

jsonjs 模式的通道也能用 ch_sendraw()ch_evalraw() ,不过需要事 先调用 json_encode() 将要发送的 VimL 数据转换(编码)为 json 字符串再传给 这俩函数;然后在收到响应时,又要将响应消息用 json_decode() 解码以获得方便可 用 VimL 数据。

因此,所谓通道的四种模式,是指通道的 vim 这端如何处理消息的方式,vim 能在多大 程度上自动处理消息的区别上。至于通道另一端如何处理消息,那就不是 vim 所能管的 事了,是那边的程序设计话题。也许那边的程序也有个网络框架自动将 json 解码转化 为目标语言的内部数据,或者要需要手动调用 json 库的相关函数,再或者是简单粗暴 地自己解析 json 字符串……那都与 vim 这边无关了,它们之间只是达到一个协议,需 要传输一个两边都能正确解析的字符串(消息字节)就可以了。

此外还得辨别另一个概念,通道的这四种解析模式,与通道的两种通讯模式又不是同一层 次的东西。后者指的是 socke 或管道(pipe),是与操作系统进程间通讯的更底层的概 念,前者 jsonNL 却是 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: 作用域内。 至于回调函数,请自行结合所实现的功能跟踪,此不再赘述。