You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
15 KiB
253 lines
15 KiB
2 years ago
|
# 第八章 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(<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()` 它是这样开启的:
|
||
|
|
||
|
```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:` 作用域内。
|
||
|
至于回调函数,请自行结合所实现的功能跟踪,此不再赘述。
|