OpenResty OpsLang™ 用户手册

名称

Ops Language (运维语言)用户手册

目录

描述

这本手册是用户角度的 Ops 语言的文档。

本文档中的某些特性可能在当前 Ops 语言版本还没有实现。不过 Ops 语言开发团队正在快速的跟进。 我们会尽量把未实现的特性包含在文档里。如果有疑问,请直接与 OpenResty Inc. 公司联系。

本文档仍然是一份草案。许多细节依旧会面临更新。

习语

为了表达方便,本文中用 opslang 表示 Ops 语言。

有问题的样例代码会在每行开头前缀一个问号,?。比如:

? true =>
?    say($a = 3);

程序布局

在最高级别,一个 Ops 程序由一个主程序文件和一些可选的模块文件集合组成。

每个 .ops 文件都包含下面类型的各种声明语句:

  1. 模块声明语句,
  2. 目标缺省目标 声明,
  3. 顶级 变量 声明,
  4. 例外声明,和
  5. 用户动作声明。

每个 Ops 模块文件的开头都必须出现第一个语句,而非模块语句不能有这个语句。

Ops 主程序文件必须包含至少一个全局声明。

其它东西都是可选的。

为了与其它编程语言的传统一直,Ops 语言的 “hello world” 程序看上去像下面这样:

goal hello {
    run {
        say("Hello, world!");
    }
}

假设这个程序在文件 hello.ops 里,我们可以用下面这样的一条命令运行之:

$ opslang --run hello.ops
Hello, world!

或者用 opslang 当一个编译器,生成名为 hello.lua 的 Lua 模块:

opslang -o hello.lua hello.ops

然后运行用 OpenResty 命令行工具 resty 运行这个生成的 Lua 模块:

$ resty -e 'require "hello".main(arg)'
Hello, world!

回到目录

集腋成裘

标识符

opslang 里面的标识符是一个或多个划线连接的是由字母字符和下划线组成的序列。不过,字的开头不能是下划线。 下面是一些有效的 opslang 标识符的例子:

foo
hisName
uri-prefix
Your_Name1234
a-b_1-c

Ops 语言是一种大小写敏感的语言。所以像 fooFoo 这两个标识符是完全不同的东西。

除了变量名之外,标识符不能是语言的关键字。

回到目录

关键字

Ops 有下列关键字:

for     while      if      while     func      action
ge      gt         le      lt        eq        ne
contains           contains-word     suffix    prefix
my      our        macro   use       INIT      END
as      rx         wc      qw        phase     goal
exception          return  label     for       async
lua

回到目录

全称

用户可以使用全称来引用其它 Ops 模块的符号,或者引用一个目标里的特定动作阶段。

比如,std.say 就是明确引用 std 模块(或名字空间)中的函数 say()。 而 all.run 则是引用目标 all 里面的动作阶段的 run 动作。

用户也可以结合这两个例子。比如 foo.all.run 是模块 foo 里面目标 allrun 动作阶段。

回到目录

变量

变量名包含两个部分:一个前置的特殊字符,叫印记,后面跟着标识符。印记用于标注变量雷习惯。 本语言支持下列印记:

  • $ 用于 标量变量
  • @ 用于 数组变量
  • % 用于 哈希变量

标量变量保存简单数值,比如数字、字串、布尔和数量等。

数组变量是一个包含简单变量的有序列表。

哈希变量是一个无序的键值对列表。

变量通常像下面这样用 my 关键字来声明:

my Int $count;
my Str $name;
my Str @domains;
my Bool %map{Str};

变量声明也可以带一个初始值,例如:

my Int $count = 0;
my Str @domains = qw/ foo.com bar.blah.org /;

在任意范围里定义的每个标量变量,在其生命期里头都只能有一个数据类型。每个变量的数据类型都必须在编译时是可确定的。 标量变量有 4 种数据类型:

  • Str
  • Num
  • Int
  • Bool

不过,Ops 语言在其它环境里,不支持其它数据类型,比如 ExceptionNil

用户必须为用 my 定义的或是定义成用户动作参数的每个标量明确声明一个数据类型。

为了方便,同样数据类型的多个变量可以用一条 my 语句声明,像下面这样:

my Str ($foo, $bar, $baz);

甚至可以混合标量、数组和哈希变量:

my Str ($foo, @bar, %baz{Int});

不过这样的变量声明不能接受任何初始化表达式。

回到目录

数组变量

数组变量保存一个线性简单值的列表,称作数组。

下面是一个例子:

goal all {
    run {
        my Int @ints;

        push(@ints, 32),
        push(@ints, -3),
        push(@ints, 0),
        say("index 0: ", @ints[0]),
        say("index 2: ", @ints[2]),
        say("array: [@ints]");
    }
}

如上所示,数组的元素类型是在 my 关键字和 @ints 变量名之间声明的。

标准函数 push 给数组末尾追加新的元素。多个元素可以用一次 push() 调用同时追加。

之后可以用 @arr[index] 这样的写法访问独立的元素。这个记法也可以用于给特定元素设置新值,如:

@ints[2] = 100

像标量一样,完整的数组也可以插入到字串文本中。

运行这个 int-array.ops 例子程序生成:

$ opslang --run int-array.ops
index 0: 32
index 2: 0
array: [32 -3 0]

还有其它一些标准函数可以操作数组:

  • pop() 函数返回并删除数组的最后一个元素;
  • shift() 函数返回并删除数组的第一个元素;
  • unshift 函数在数组开头前置一个或多个元素;
  • elems 函数返回数组长度。

如果在布尔环境中使用,在数组变量不为空的时候,他们得出真值结果。比如:

goal all {
    run {
        my Num @nums;

        {
            @nums =>
                say("array is NOT empty"),
                done;

            true =>
                say("array is empty");
        };
    }
}

运行这个 array-cond.ops 例子得出:

$ opslang --run array-cond.ops
array is empty

我们可以用 字面数组 初始化一个数组,像下面这样:

goal all {
    run {
        my Num @nums = (3.14, 0);

        {
            @nums =>
                say("array is NOT empty"),
                done;

            true =>
                say("array is empty");
        };
    }
}

现在运行这个例子会给出输出 array is NOT empty,因为数组现在有两个元素。

数组变量可以用 for 语句 遍历。

回到目录

哈希变量

哈西变量保存一个字典数据,这个字典数据是从键字到简单变量的映射。

下面是一个简单例子:

goal all {
    run {
        my Num %ages{Str} = (dog: 1.5, cat: 5.4, bird: 0.3);

        say("bird: ", %ages<bird>),
        say("dog: ", %ages{'dog'}),
        say("cat: ", %ages{"cat"}),
        say("tiger: ", %ages<tiger>);
    }
}

运行这个 ages.ops 例子程序得出:

$ opslang --run ages.ops
bird: 0.3
dog: 1.5
cat: 5.4
tiger: nil

这里我们先定义了一个叫 %ages 的哈希变量(注意 % 印记)。它的键字类型是 Str 而其值类型是 Num。 同时我们用 字面哈希值 给这个哈希变量的 3 种动物 dogcatbird 初始化了年龄。 然后我们输出这个哈希变量各个键字的值。请注意在这个哈希变量里头并不存在 tiger 键字,所以我们在输出它的时候得到了个 nil 值。

如例子所示,如果字串键字是一个字,我们可以用 %hash<word> 语法做为更通用形式%hash{'word'}%hash{"word"} 的缩写。一般而言,要读取一个指定键字的值,我们可以用 %hash{key};如果要覆盖一个指定键字的值,我们可以 %hash{key} = value

我们也可以在键字里头使用标量变量,如:

my $key = 'dog';

say("$key: ", %ages{$key});

还支持其它类型的键字,比如:

goal all {
    run {
        my Str %weekdays{Int} = (1: "Monday", 2: "Tuesday", 3: "Wednesday",
                                 4: "Thursday", 5: "Friday");

        say(%weekdays{3}),
        say(%weekdays{5});
    }
}

运行例程 weekdays.ops 得到:

$ opslang --run weekdays.ops
Wednesday
Friday

如果在布尔环境中使用,那么哈希变量非空时返回真,否则返回假。比如:

goal all {
    run {
        my Int %ages{Str};

        {
            %ages =>
                say("hash NOT empty"),
                done;

            true =>
                say("hash empty");
        };
    }
}

运行例程 hash-empty.ops 得出:

$ opslang --run hash-empty.ops
hash empty

哈希变量无法在 双引号字串单引号字串 中做值替换。在这些环境里, % 自负总是解析成字面意思。

用户可以使用 for 语句遍历哈希变量。

回到目录

捕获变量

本语言提供特殊的捕获变量用于保存前面的正则匹配中抓到的子匹配的内容(如果有的话)。比如,变量 $1 保存捕获组中的第一个子匹配, 而 $2 保存第二个匹配组,$3 对第三个,如此类推。特殊变量 $0 保存完整正则匹配的整个子淄川,不管具体哪个子匹配抓到的分组。

让我们看看下面的例子:

goal all {
    run {
        my Str $s = "hello, world";

        {
            $s contains /(\w+), (\w+)/ =>
                say("0: [$0], 1: [$1], 2: [$2]"),
                done;

            true =>
                say("not matched!");
        };
    }
}

运行此 Ops 程序输出:

0: [hello, world], 1: [hello], 2: [world]

回到目录

函数名与函数调用

本语言广泛使用函数于各种谓语和规则中的动作。 如果需要,用户也可以定义自己的函数(像 用户动作里那样)。

标准函数和用户定义动作可以用完全相同的方式使用。 实际上,许多 opslang 编译器搭载的标准函数实际上是在 std 模块里以 “用户动作” 的方式实现的。

函数名是用标识符直接表示的,不需要印记。

函数调用是用函数名后头跟着一对儿圆括弧,里头放上参数来调用的,像下头这样:

say("hello, ", "world")

不带参数的函数调用可以省略圆括弧。比如:

redo()

可以简化为:

redo

参数可以用位置方式或者名字的方式传递。比如,内置的 say() 动作函数,接受位置参数作为返回消息体的片段,如前面例子演示的那样。 命名参数用参数名和一个冒号字符当前缀传入,像下面这样:

goto(my-label, tries: 3);

这个 goto() 调用接受一个叫 tries 的命名参数,值是 3

内置函数可能需要特定参数以命名参数方式传递,而其它的以位置参数传递。 请参考对应的内置函数的文档获取实际用法的说明。

还有一些语法构造等效于函数调用。 比如 shell 包围字串美元符动作直接用作一个动作。

回到目录

字面文本串

单引号包围字串

单引号包围字串是字面字串值包围在单引号 ('') 里头,像下面这样:

'foo 1234'

字符 $@ 总是他们字面的意思。标量和数组在里头绝不会被代入实际值。

单引号包围字串只支持下面的逃逸序列:

\'
\\

单引号包围字串字面里头出现的任何其它 \ 字符都会被理解为一个字面的 \

回到目录

双引号包围字串

双引号包围字串是一个字面串被包围在双引号("")里头,如下所示:

"hello, world!\n"

双引号包围字串里头支持下面的逃逸序列:

\a
\b
\f
\n
\r
\t
\v
\\
\0
\$
\@
\%
\'
\"

双引号包围字串里头可以对标量和数组变量进行变量代换,如下所示:

"Hello, $name!"

"Names: @names"

如果在变量名后面有容易引发歧义的字符出现,我们可以用花括弧包围变量来消歧,如下所示:

"Hello, ${name}ism"

"Hello, @{names}ya"

为使用方便,本语言还支持另外一种变量代换语法(类似 shell 包围字串):

"Hello, $.name!"

"Hello, @.names!"

或者是花括弧形式:

"Hello, $.{name}!"

"Hello, @.{names}!"

因此在双引号包围字串里头字面的 $@ 字符必须用 \ 逃逸,以避免意外的变量代换 ,如:

"Hello, \$name!"

回到目录

方括弧包围长文本

方括弧包围长文本是 Lua 风格的长方括弧包围的字面字串。 长方括弧的例子有 [[...]][=[...]=][==[...]==] 等等。

长方括弧内部的所有字符都呈现其字面含义,在这里完全没有任何特殊字符。比如,字面串:

[=[\n"hello, 'world'!"\]=]

等效于 单引号包围字串 '\n"hello, \'world\'!"\\'

为了简化使用,如果长方括弧内部的第一个字符是换行符,那么这个换行符会被自动删除,所以下面的长方括弧字串:

[=[
hello world
]=]

等效于 双引号包围字串 "hello world\n"

回到目录

Shell 包围字串

Shell 包围字串提供了一种在 Ops 程序里头嵌入 shell 命令字串的便利的记法。它接受下面各种形式:

sh/.../

sh{...}

sh[...]

sh(...)

sh'...'

sh"..."

sh!...!

sh#...#

类似长方括弧包围字串,所有 shell 包围字串里头的字符都是字面含义。 我们可以用双引号逃逸引号字符,但是逃逸序列自身仍然是最后代换的字串值的一部分。

Ops 编译器总是把已分析过的 shell 包围的字串当作 Bash 命令看到。所以不要在这个语法构造里面使用随意的字串。

shell 包围的字串里头不支持形如 $foo@foo 的变量代换,因为 Bash 语言自己使用这种记法。 所以我们支持在 shell 引号内部使用另外一种“点格式”的变量代换,如下所示:

sh/echo "$.name!"/

或者使用化括弧消歧:

sh/echo "$.{name}ism"/

为了通用,这种“点格式”的变量代换语法也被 双引号包围字串正则字面 所支持。

和其它做变量代换的环境不同的是,shell 引号还会根据变量所使用的 shell 环境,对代换的 Ops 变量做恰当的 shell 字串的引号包围和逃逸。 比如,在 Bash 的双引号内外使用的变量会根据 Bash 语法进行不同的引号包围和逃逸处理。假如我们要代换下面的变量:

$name = "hello\n\"boy\"!";

它接受下面的字面值:

hello
"boy"!

在我们代换他到 shell 的双引号包围里的时候,

sh/echo "$.name"/

我们会拿到下面的 shell 命令串,并最后送到终端模拟器里:

echo "hello
\"boy\"!"

(请注意 ! 并不需要逃逸,因为标准函数 setup-sh() 会禁用 Bash 的历史命令列表功能。)

另一方面,如果在任何 shell 的双引号外部代换 $name 变量,那么它的逃逸是不同的:

sh/echo $.name/

生成最后的 shell 命令串是:

echo hello\
\"boy\"\!

关于在 shell 双引号内外使用代换变量一个需要注意的重要的问题是,后者(shell 双引号外部)不会逃逸空白字符, 因此标量变量很可能最后变成好几个 shell 值,如:

my Str $files = 'a.txt b.txt';

sh{ls $.files},
stream {
    found-prompt => break;
}

这个例子等效于:

sh{ls a.txt b.txt},
stream {
    found-prompt => break;
}

基本上, $files 变量值会被解析成两个独立的参数递给 shell 命令 ls。 因此,如果,如果文件路径可能包含空白,我们一定总是用双引号包围需要代换的 Ops 变量,如下所示:

my Str $file = '/home/agentzh/My Documents/a.txt';

sh{ls "$.file"},
stream {
    found-prompt => break;
}

如果本例中没有双引号,那么 ls 命令会尝试寻找两个独立的文件 /home/agentzh/MyDocuments/a.txt

shell 引号包围也支持其它 shell 变量环境,比如 $(...), 反勾号 (`...`),$((...))$"..."$'...' 等等。

在 shell 的单引号内是不进行变量代换的,这点跟 shell 语言自身一样。如:

sh/echo '$.foo'/

会生成最后的 shell 命令串 echo '$.foo'

用户总可以把最后生成的 shell 引号包围字串丢给 print()say() 函数输出出来查看,像下面这样:

say(sh/echo $.foo/)

用户应该在 shell 引号里面避免使用控制字符,比如 tabs 和哪些不可打印的字符(比如 ESC 字符)。

如果 shell 包围的字串直接在动作里使用,像下面这样:

true =>
    sh/echo hi/,
    done;

那么它会自动被转换成一个 std.send-cmd() 函数调用,把这个 shell 引号包围的字串当作参数传递进去,如下所示:

true =>
    std.send-cmd(sh/echo hi/),
    done;

数组变量也可以在这个环境里代换进去,就像 双引号包围字串一样。比如:

my Str @foo = qw(hello world);

goal all {
    run {
        say(sh/echo @.foo "@.foo"/);
    }
}

运行这个例子生成:

echo hello world "hello world"

回到目录

美元符动作

美元符动作是一种节约 Ops 程序员敲键的便利语法糖。一个美元符动作 $ CMD 实际上是下面 Ops 代码片段的缩写:

std.send-cmd(sh/CMD/),  # 实际引号包围的字符是啥无所谓
stream {
    found-prompt => break;
}

上面的引号字符 / 是随意的。用户可以在美元符动作里随意使用斜杠(除号)。

美元符和空白后面的 CMD 是一个用户 shell 命令字串,如 shell 包围字串 里头的那样。 这个命令行字传一直延伸到行尾,不包括行尾的任何逗号或者分号字符。

感谢隐含的 stream { found-prompt => break; } 块,一个美元符动作在 shell 命令结束运行之前(也就是出现一个新的 shell 提示符之前)不会结束。

如果在一个动作链里头使用美元符动作(像在一个 规则 的后继部分),仍然需要逗号分隔符,就像下面:

true =>
    $ cd /tmp/,
    $ mkdir foo,
    done;

确保自己不要在逗号和换行符之间插入任何空白自负。如果美元符动作处在动作链的末尾, 也要求代码上在换行符之前加一个分号,像下面这样:

true =>
    $ cd /tmp;

true =>
    say("...");

重要提示:我们仍然可以覆盖美元符动作隐含引入的缺省 stream {} 块。方法是我们紧跟在美元符动作后面定义一个明确的 stream {} 块,如下所示:

$ echo hi,
stream {
    out contains-word /hi/ =>
        say("hit!");

    found-prompt =>
        break;
}

一定要记得在自己的stream {} 块结尾添加 found-prompt 规则, 否则你的美元符动作将不会等待 shell 提示符出现,导致超时等等错误。

回到目录

数值常量

数值常量可以用下列形式之一书写:

1527
3.14159
-32
-3.14
78e-3      (带十进制指数)
0xBEFF     (十六进制)
0157       (八进制)

回到目录

正则字面值

正则字面值用于声明一个兼容 Perl 的正则表达式值。 它是通过关键字 rx 和一个引号包围结构来表示的。下面是一些例子:

rx{ hello, \w+}
rx/ [0-9]+ /
rx( [a-zA-Z]* )
rx[ [a-zA-Z]* ]
rx" \d+ - \d+ "
rx' ([^a-z][a-z]+) '
rx! ([^a-z][a-z]+) !
rx# ([^a-z][a-z]+) #

用户可以在正则字面值里头自由使用化括弧、斜杠、圆括弧、双引号或者单引号。 他们都是一样的,区别只是在正则字串里头需要逃逸的引号字符是啥。比如在 rx(...) 形式里头, 正则字串里头的斜杠字符(/)是不需要任何逃逸的。 缺省时,在正则值里头使用空白字符是含义的,只有在字符表构造里才有含义(比如,[a-z])。 这样可以鼓励用户把自己的正则字传结构化得更易读一些。

在正则里头可以声明很多选项,比如:

rx:i/hello/

要求一个大小写无关的模式 hello 的匹配。类似的:

rx:x/ hello, world /

令模式字串里头使用的空白字符无意义,因此这个正则匹配字串 "hello,world",但是无法匹配 "hello, world"

可以同时声明多个选项,把他们叠在一起就行:

rx:i:s/hello, world/

如果不声明选项,我们可以去掉前缀 rx,只用斜杠来表示一个正则字面值,如:

/\w+/

/hello world/

Ops 的正则字面里头,元字符 . 总是匹配任何字符,包括新航字符("\n")和特殊模式 \s 也总是匹配任何空白字符,包括新行。

回到目录

通配符字面值

通配符字面值是指涉谷摩纳哥一个字串,匹配 UNIX 风格的通配符语法的模式。 它是用一个关键字 wc 带着一个引号包围结构表示的,比如:

wc{foo???}
wc/*.foo.com/;
wc(/country/??/);
wc"[a-z].bar.org"
wc'[a-z].*.gov'

跟正则字面一样,通配符字面也可以使用灵活的引号字符。

支持三种通配符元模式: * 用于匹配任何子字串, ? 用于匹配任何单字符,[...] 匹配字符表。

通配符可以声明一个或多个选项,比如:

wc:i/hello/

要求对模式 hello 的大小写无关的匹配。

回到目录

字面数组

字面数组提供了声明常量数组值的一个简便方法。 其通用语法如下:

(elem1, elem2, elem3, ...)

下面是个例子:

action print-int-array(Int @arr) {
    print("[@arr]");
}

goal all {
    run {
        print-int-array((3, 79, -51));
    }
}

运行这个 print-int-array.ops 例子生成:

$ opslang --run print-int-array.ops
[3 79 -51]

字面数组也可以用户初始化一个 数组变量或者给一个接收数组类型的用户动作设置一个缺省值。

出于方便,允许在最后一个元素后面多一个逗号,像下面这样:

(3,14, 5, 0,)

回到目录

引号包围字

引号包围字提供一个简便的、不用连续敲入太多引号的、声明一堆常量字面字串数组的方法。

它用关键字 qw 后面跟着一个灵活的引号包围结构标注。比如:

qw/ foo bar baz /

等效于:

("foo", "bar", "baz")

和正则字面以及通配符字面一样,用户可以选择各种引号包围字符用于引号包围结构,比如:

qw{...}
qw[...]
qw(...)
qw'...'
qw"..."
qw!...!
qw#...#

回到目录

字面哈希值

字面哈希值提供声明常量哈希值的简便方法。一般的语法如下:

(key1: value1, key2: value2, key3: value3, ...)

下面是一个例子:

action print-dog-age (Int %ages{Str}) {
    say("dog age: ", %ages<dog>);
}

goal all {
    run {
        print-dog-age((dog: 3, cat: 4));
    }
}

运行这个 print-dog-age.ops 例子输出为:

$ opslang --run print-dog-age.ops
dog age: 3

字面哈希值也可以用于初始化 哈希变量 或者给一个接收哈希类型参数的用户动作设置缺省参数。

为了简便,允许在最后一个键值对背后多一个逗号。

(dog: 3, cat: 5,)

回到目录

布尔

布尔值呈现为内置函数调用 true()false() 的值。 所有关系运算也计算出布尔值。

下面数值被认为是“条件为假”:

  • 数值 0
  • 字串 “0”
  • false() 的值
  • 空字串
  • 空数组
  • 空哈希表

所有其它值都被认为是“条件为真”。

函数调用 true()false() 经常被简写为 truefalse

回到目录

注释

注释以字符 # 开头,并且延续到当前行尾。比如:

# 这是一个注释

也支持块注释,如:

#`(
This is a block
comment...
)

请注意 # 后面紧跟的反勾号和左缘括弧。块注释内部的圆括弧,只要是成对的,就可以继续使用, 嵌套也行:

#`(
3 * (2 - (5 - 3))
)

回到目录

例外

Ops 支持高效和强大的例外模型,类似用户在其它编程语言,比如 C++ 和 Java 看到的那样。

有两种例外类型,标准例外和用户定义例外。 标准例外如下(每种例外有一句简要描述):

  • timeout

    读超时或者等待终端模拟器(tty)超时。

  • failed

    前面的(shell)命令带着非 0 返回码返回。

  • found-prompt

    前面的(shell)命令结束(不管返回码是多少)。请注意这个例外不会被抛出,因为它不是致命例外。

  • error

    读写终端模拟器(tty)的时候发生了一些奇怪的错误,但并非 timeoutclosed 例外。

  • closed

    终端模拟器(tty)的连接提前关闭。

  • too-much-out

    缓冲了太多从终端模拟器(tty)中读取的数据。这个限制可以通过 opslang 的命令行参数 --max-out-buf-size SIZE 设置。

  • too-many-tries

    gotoredo 或其它重试机制尝试了太多次。

如果要引用一个例外,只要以例外名作为函数名调用这个函数即可。

如果在一个规则条件里头引用例外,那么这就是 Ops 语言的捕获前面动作中生成的例外的方法。 下面是例子:

goal all {
    run {
        $ ( exit 3 ),
        {
            failed =>
                say("cmd failed with exit code: ", exit-code);
        },
        say("done");
    }
}

运行这个 Ops 程序生成下面输出(假设程序文件名是 failed-cmd.ops):

cmd failed with exit code: 3
done

本例中的美元符动作 $ ( exit 3 ) 一开始为当前 Bash 命令返回退出码 3, 因此导致抛出标准的 failed 例外。在随后的动作块理,以 failed 为(唯一)条件的的规则捕获这个 failed 例外, 并且打印出前面 shell 命令对应的退出码。因为 failed 例外被合理捕获和处理,所以执行流继续正常执行到最后的 say("done") 动作然后生成输出。

如果我们把前面例子中不互殴例外的动作快去掉,像下面这样:

goal all {
    run {
        $ ( exit 3 ),
        say("done");
    }
}

那么这个 Ops 程序就会因未捕获的 failed 例外,在执行美元符动作之后立即退出,像下面:

ERROR: failed-cmd-uncaught.ops:3: failed: process returned 3
  in rule/action at failed-cmd-uncaught.ops line 3

failed 例外也回自动捕获错误信息,如果有的话,像下面这样:

goal all {
    run {
        $ ( echo "Bad bad bad!" > /dev/stderr && exit 3 ),
        say("done");
    }
}

生成

ERROR: failed-msg.ops:3: failed: process returned 3: Bad bad bad!
  in rule/action at failed-msg.ops line 3

如果在标准函数 throw 中使用一个例外当参数,那么 throw() 函数调用会向当前 Ops 程序上层抛出一个例外。 如果上层都不捕获这个例外,那么这个 Ops 程序会带着一条错误信息退出。

回到目录

用户例外

Ops 程序员可以重用上面说的任何标准例外,或者定义自己都例外。 要定义一个新例外,只要像下面这样,在任何 .ops 源文件顶级范围内使用 exception 语句声明即可:

exception too-large;

这里我们定义了一个新的名为 too-large 的例外。

多个例外可以在同一个 exception 语句里声明,像下面:

exception foo, bar, baz;

我们可以用高标准函数 throw,在我们 Ops 程序里头随时抛出这样的例外。 用户例外可以像标准例外一样捕获,只要在规则条件里,把对应例外名当作函数调用的方式来饮用即可。比如,

exception too-large;

action foo (Int $n) {
    {
        $n > 100 =>
            throw(too-large);
    };
}

goal all {
    run {
        foo(101),
        {
            too-large =>
                say("caught too large"),
                done;
        },
        say("done");
    }
}

运行此程序生成:

caught too large
done

类似的,未捕获的用户例外导致 Ops 程序退出,跟标准例外一样。 让我们看看下面例子:

exception too-large;

action foo (Int $n) {
    {
        $n > 100 =>
            throw(too-large);
    };
}

goal all {
    run {
        foo(101),
        say("done");
    }
}

它生成输出:

ERROR: too-large.ops:6: too-large
  in action 'foo' at too-large.ops line 6
  in rule/action at too-large.ops line 12
  in rule/action at too-large.ops line 12

throw() 里也可以申明一条文本信息,如:

exception too-large;

action foo (Int $n) {
    {
        $n > 100 =>
            throw(too-large, msg: "$n is larger than 100");
    };
}

goal all {
    run {
        foo(101),
        say("done");
    }
}

生成

ERROR: throw-msg.ops:6: too-large: 101 is larger than 100
  in action 'foo' at throw-msg.ops line 6
  in rule/action at throw-msg.ops line 12
  in rule/action at throw-msg.ops line 12

现在可以看到第一行里头有更详细的错误信息。

模块里定义的例外总是可以用形如 <module-name>.<exception-name> 的全称引用。

回到目录

标签和 Goto

Ops 语言提供 goto 函数,类似于 C 语言里的 “goto” 语句,但是安全很多。

函数 goto() 可以用于直接跳转到当前范围内外的一个指定名称到标签动作。

但是,不能跨当前用户动作或者当前全局动作阶段进行跳跃。

可以跳转过去的动作必须带一个用户定义标签,如下所示:

goal all {
    run {
        say("before"),

    again:

        say("middle"),
        goto(again),
        say("after");
    }
}

在这里我们给动作 say("middle") 赋予里一个叫 “again” 的标签,然后以标签为唯一参数调用 goto 函数。请注意我们把 again 标签当一个函数调用传递。 调用goto(again) 等效于 goto(again())again() 函数调用计算出一个等于前面给 again 标签定义的标签的类型值。

我们可以运行这个例子,看看输出什么(假设这个程序在文件 goto.ops 里):

$ opslang --run goto.ops
before
middle
middle
middle
middle
ERROR: goto.ops:8: too-many-tries: attempted to do goto for 4 times
  in rule/action at goto.ops line 8

这个程序因为抛出例外 而异常退出:

ERROR: goto.ops:8: too-many-tries: attempted to do goto for 4 times

这是因为缺省时 goto() 只允许挑 3 次。这个限制是每个范围的限制。 因此进出当前范围都会重置跳转计数器为 0 。另外,每个动作标签都有独立的跳转计数器。

抛出来的例外的记号是 too-many-tries,可以用下面的 Ops 代码捕获:

goal all {
    run {
        say("before"),

    again:

        say("middle"),
        goto(again),
        {
            too-many-tries =>
                say("caught the too-many-tries exception");
        },
        say("after");
    }
}

这个程序(假设文件名是 goto2.ops)可以成功完成:

$ opslang --run goto2.ops
before
middle
middle
middle
middle
caught the too-many-tries exception
after

回到目录

跳转计数限制

我们已经看到缺省的跳转限制是 3 次,对相同标签的第四次 goto 调用会导致too-many-tries 例外。 我们可以通过给 goto 函数调用传递命名参数 tries: N 来修改这个限制,如下所示:

goto(again, tries: 10);

这时候我们有 10 次的限制而不是 3 次。

除了限制跳转的次数,我们还可以在每次 goto 之前增加可变的延迟。如下所示:

goto(again, init-delay: 0.001, delay-factor: 2, max-delay: 1,
     max-total-delay: 10);

这儿显示了我们在每次 gotoagain 标签之前,都增加了越来越大(睡眠)的延迟: 从 0.001 秒开始,直到 1 秒,每次重试都增大两倍。一旦前面所有尝试的总延迟积累到超过了 10 秒, 将会抛出 too-many-tries 例外。

请注意第一次 goto() 调用是不会有任何延迟或者睡眠的。init-delay 设置只有在第二次尝试(或者是第一次重试)的时候起作用。

请参考 goto 函数的文档 获取更多细节。

回到目录

标签声明

前面的例子都使用 goto() 函数向后跳转(跳转的目标动作在 goto 调用之前)。 用户也可以向前跳转。这种场景下,标签必须用 label 语句声明,否则标签就会在声明之前使用。如下:

goal all {
    run {
        label done;

        say("before"),
        goto(done),
        say("middle"),

    done:

        say("after");
    }
}

在这里,done 标签在目标 allrun 动作阶段一开始就声明了。

运行此程序得出(假设文件名是 goto-fwd.ops):

$ opslang --run goto-fwd.ops
before
after

请注意动作 say("middle") 实际上被前面的 goto() 函数忽略掉了,而标签东走 say("after") 还是会运行的。

回到目录

从检查阶段跳转到动作阶段

我们可以使用 goto() 跳转到定义在其它范围内的标签上,条件是这个跳转没有离开当前的全局动作阶段, 或者没离开当前用户动作的声明体。

所以从一个检查阶段里面跳转到一个周围的动作阶段是很自然的事情,如下所示: following example demonstrated:

goal all {
    run {
        label do-work;

        check {
            ! file-exists("/tmp/a") =>
                goto(do-work);

            true => ok;
        }

    do-work:

        say("hi from all.run");
    }
}

如果文件 /tmp/a 不存在,那么这个程序生成输出:

hi from all.run

否则不会有输出生成。

有时候我们希望不管检查阶段的 ok() 调用结果如何都有条件执行所有动作阶段的目标。 这个功能可以用 opslang 的。-B 命令行选项实现,如下:

opslang -B --run foo.ops

或者是以编译器:

opslang -B -o foo.lua foo.ops

在这个勾子下, -B 选项会让 ok 函数的行为会表现得跟 nok 函数完全一样。 这个功能在开发调试 Ops 程序的时候很有用。

回到目录

规则范围的标签

规则有自己的范围,所以如果你想在单个规则的后面部门使用前向标签声明和 goto 函数的话, 你需要使用标签声明动作语法,如下:

goal all {
    run {
        label done;

        check {
            true =>
                label done,
                say("check pre"),
                goto(done),
                say("NOT THIS"),
            done:
                say("check post");
        }

        say("run pre"),
        goto(done),
        say("NOT THIS"),
    done:
        say("run post");
    }
}

运行这个函数得到如下输出:

check pre
check post
run pre
run post

请注意我们可以在不同范围里有同名标签。

回到目录

操作符

支持下列操作符,按照优先级顺序列出:

优先级               操作符
0                   后包围 [], {}, <>
1                   **
2                   单目 +/-/~, as
3                   * / % x
4                   + - ~
5                   << >>
6                   &
7                   | ^
8                   单目 !, > < <= >= == !=
                    contains contains-word prefix suffix
                    !contains !contains-word !prefix !suffix
                    eq ne lt le gt ge
9                   ..
10                  ?:

用户可以用圆括弧 () 在一个表达式里头明确修改相关优先级或者关联性。

回到目录

算术操作符

此语言支持下列双目操作符:

**      指数
*       乘积
/       除
%       模除
+       加
-       减

比如:

2 ** (3 * 2)        # 得出 64
(7 - 2) * 5         # 得出 25

还支持单目前缀操作符 - 如:

-(3.15 * 2)         # 得出 -6.3

回到目录

字串操作符

本语言支持下列双目字串操作符:

x       重复字串多次并且把他们连接起来
~       字串连接

比如:

"abc" x 3           # 得出 "abcabcabc"
"hello" ~ "world"   # 得出 "helloworld"

回到目录

位操作符

支持下列位操作符:

<<          左移
>>          右移
&           位 AND
|           位 OR
^           位 XOR

单目前缀操作符 ~ 是用于对位的 NOT 操作。不要把他跟字串连接的双目操作符。~ 混了。

回到目录

关系操作符

所有关系操作符的使用都会为当前表达式产生布尔结果。 使用关系操作符的表达式是关系表达式

下列双目操作符在数值上比较两个操作数:

>           大于
<           小于
<=          小于或等于
>=          大于或等于
==          等于
!=          不等于

下列双目操作符在字母顺序上比较两个操作数:

gt          大于
lt          小于
le          小于或等于
ge          大于或等于
eq          等于
ne          不等于

在字串值里头可用 3 个特殊的双目操作符进行模式匹配:

contains            如果操作符右手边的操作符“包含于”左手边的操作符,为真

contains-word       如果右手边的操作符以字的形式“包含于”左手边的操作符,为真

prefix              如果右手边的操作符是左手边操作符的“前缀”,为真

suffix              如果右手边的操作符是左手边操作符的“后缀”,为真

单目前缀操作符 ! 对(布尔)操作数取反

如果在字串比较操作符右手边的操作数长得像一个模式或者一个通配符, 那么会自动在模式中假设匹配锚,比如:

uri eq rx{ /foo } =>
    say("hit");

等效于:

uri contains rx{ \A /foo \z } =>
    say("hit");

不过,如果正则模式 \A 只匹配字串开头,而 \z 只匹配结尾。那么 contains 操作符则不会假设有匹配锚。

类似, contains-word 会假设在用户正则的两边有包围的 \b 正则锚。

回到目录

范围操作符

双目操作符 .. 可以用于生成范围表达式,如:

1 .. 5          # 等效于 1, 2, 3, 4, 5
'a'..'d'        # 等效于 'a', 'b', 'c', 'd'

范围表达式的值是在该范围内所有值平面化的列表。

这个操作符尚未实现!

回到目录

三元操作符

三元关系操作符 ?: 用于根据用户条件,在两个用户表达式之间选择。

比如:

$a < 3 ? $a + 1 : $a

这个表达式,在 $a < 3 为真得时候,计算得出 $a + 1 的值,否则是 $a

回到目录

下标操作符

后包围操作符 [] 用于下标一个数组。比如:

my @names = ('Tom', 'Bob', 'John');

true =>
    say(@names[0]),  # 输出 Tom
    say(@names[1]),  # 输出 Bob
    say(@names[2]);  # 输出 John

负数的下标索引用于从数组末尾访问元素,比如,-1 是最后一个元素, -2 是倒数第二个元素,等等。

类似,后包围操作符 {} 用于索引一个哈希表,如:

my %scores = (Tom: 78, Bob: 100, John: 91);

true =>
    say(%scores{'Bob'});    # output 100

后包围操作符 <> 用于通过字面串来访问一个哈希表, 比如,%scores<John> 等效于 %scores{'John'}

这个操作符尚未实现!

回到目录

规则

规则在 Ops 语言里提供基本的控制流语言结构。 他们替换了传统语言里头的 if/else 语句,让复杂的分支控制流代码更容易读写。

Ops 的下列语言环境中,可以使用规则:

  1. 动作块,和
  2. 流块

回到目录

基本规则布局

Ops 语言的规则由两部分组成,一个条件和一个后果。 条件和后果之间由一个 => 连接,整条规则由一个分号字符结束。基本的规则长得像下面这样:

<condition> => <consequent>;

规则的条件部分可以接受一个活多个关系表达式,类似 resp-status == 200。所有关系表达式都是通过逗号字符连接的 (,),它令所有关系表达式在一起,也就是说,要想整个条件为真,那么所有关系表达式都必须为真。 条件不能有副作用,这个属性是 opslang 编译器强制的,因此,同一个条件里关系表达式中的计算顺序并不改变整个条件的结果。

后果部分通常包含一个或多个动作。每个动作都可以有一些副作用, 比如修改一些请求,执行一些 302 重定向,或者修改当前请求在后台中的路由路径。 我们也可以把一个完整的规则块声明成一个动作(参阅动作块一节)。

下面是一个简单的 opslang 规则:

file-exists("/tmp/a.txt") =>
    $ rm /tmp/a.txt;

在条件部分, file-exists("/tmp/a.txt") 是一个关系表达式。 file-exists() 函数检查其参数声明的文件路径是否存在。

这条规则的意思是如果文件 /tmp/a.txt 存在,则删除它。 后果部分是一个美元符动作,执行 shell 命令 rm /tmp/a.txt

值得一提的是 file-exists() 函数接受一个位置参数而不是命名参数

Ops 语言是一种自由格式语言,所以你可以随意使用空白。 上面例子后果部分中的缩进空白不是必须的,只是为了美观。我们完全可以把整个规则写成一行,比如:

file-exists("/tmp/a.txt") => $ rm /tmp/a.txt;

回到目录

多重关系表达式

用户可以在一个条件里头声明多个关系表达式,比如:

file-exists("/tmp/a.txt"), hostname eq 'tiger' =>
    say("hit!");

这里我们在条件中多了一个关系表达式,也就是: hostname eq 'tiger',它用标准函数 hostname() 测试当前主机名(“hostname”)是否等于 'tiger'。 在这个例子里我们用了一个不同的动作 say("hit!"),执行的时候它会给当前运行 opslang 程序的标准输出流发送一个 “hit!”。

请注意两个关系表达式之间的逗号,它意味着,如果想要整个条件为真,那么逗号两边的关系表达式都必须为真。

用户可以在同一个条件里声明更多的关系表达式,如:

file-exists("/tmp/a.txt"), hostname eq 'tiger', prompt-user eq 'agentzh' =>
    say("hit!");

我们加了第三个关系表达式,它测试最后一次 shell 提示符字串后面的显示值是否当前用户名 (这个通常是用标准函数 setup-sh()自动配置的)。请注意我们也可以用标准函数 prompt-host 替换 hostname 函数。 prompt-host 函数调用通常比 hostname 更快, 因为它无须运行一个新的 shell 命令(hostname()需要执行 shell 命令 hostname)。

回到目录

多重条件

Ops 规则实际上可以接受多个并行的条件,它们之间通过分号操作符连接。 这些条件逻辑上是为当前规则在一起的。

比如:

file-exists("/foo"), prompt-host eq 'glass';
file-exists("/bar"), prompt-host eq 'nuc'
=>
    say("hit!");

如果两个条件之一匹配上了,那么规则就算匹配上。当然,如果两个条件都匹配, 规则也匹配。

回到目录

多重动作

在同一个规则后果里,也可以声明多个动作。 看看下面的例子:

file-exits("/tmp/foo") =>
    chdir("/tmp/"),
    $ rm -f foo,
    say("done!");

这个例子在后果里头有三个动作。第一个动作调用内置函数 chdir() 把当前工作目录修改到 /tmp/。第二个动作是一个 美元符动作, 它以同步的方式执行 shell 命令 rm -f foo (不过从 IO 的角度看,仍然是非阻塞的)。最后, 调用 say() 函数输出 done! 给当前 opslang 程序的 stdout 流。

回到目录

无条件规则

有些规则可以选择无条件运行它们的动作。不过一个 opslang 规则, 总是要求一个条件部分。为了实现无条件规则出发,用户可以使用总是为真的断言 true() 作为条件中单一的关系表达式,如:

true() =>
    say("hello world");

在这个规则里头,动作 say() 将总是运行。

因为 opslang 函数调用在不带参数的时候可以忽略元括弧,我们可以写 true 而不用 true(),如:

true =>
    say("hello world");

回到目录

多重规则

在同一个块里头声明的多个规则,是按顺序执行的。 先写的规则先执行。

看看下面的例子:

file-exists("/tmp/foo") =>
    say("file exists!");

file-not-empty("/tmp/foo") =>
    say("file not empty!");

如果有一个 /tmp/foo,文件,并且里头有一些数据,运行这个 opslang 代码会生成下面的输出:

file exists!
file not empty!

不过,为了能同时匹配,多重规则的条件可能会被 opslang 编译器优化,甚至可能在任何规则世纪执行之前就计算。 这些在 opslang 编译器觉得这么做安全的时候可能发生。

回到目录

动作块

动作块由一对花括弧({})组成,同时也生成一个新的变量范围。 动作块可以在所有可以用动作的地方使用。

在下面的例子里,我们有两个不同的 $a 变量,因为他们属于不同的块(范围):

my Int $a = 3;

{
    my Str $a = "hello";
},
say("a = $a");   # output `3` instead of `hello`

规则和变量一样,从词法上属于所包含的块。 动作块可以用做把相近的规则组合在一起成为一个整体。在这样的设置里, 一些提前执行的规则可以使用特殊动作 done忽略所有同一个块里头的后继东旭哦。 下面例子显示了这个用法:

{
    file-exists("/tmp/a.txt") =>
        print("hello"),
        done;

    true =>
        print("howdy");
},
say(", outside!");

如果文件 /tmp/a.txt 在当前系统中存在,那么这个 Ops 代码片段的输出会是: hello, outside!。 请注意第一个规则里的 done 动作忽略了第二个规则的执行。 如果文件 /tmp/a.txt 不存在,那么它会生成输出 howdy, outside!,因为第一条规则不匹配。

规则块可以任意嵌套,如:

file-exists("a") =>
    say("outer-most"),
    {
        true =>
            say("2nd level");
            {
                uri("b") =>
                    say("3rd level");
            };
    };
};

回到目录

异步块

Ops 语言支持并行运行多个终端会话。这个是通过使用 async 块实现的。 在 async 块里头的动作会通过一个新的轻量化线程在一个新的 终端屏幕里异步执行。

看下面的例子:

goal all {
    run {
        my Int $c = 0;

        $ FOO=32,
        async {
            $ echo in async,
            print(cmd-out);
        },
        $ echo in main,
        print(cmd-out);
    }
}

典型的执行生成:

$ opslang --run async.ops
in main
in async

有时候我们可能会得到互换的输出行:

$ opslang --run async.ops
in async
in main

这个的执行结果是预期中的,因为 async {} 块执行的异步特质。

异步块自动创建一个与当前独立的新的终端窗口。 实际上,它在当前钩子下调用标准函数 new-screen。 因此,可以创建的异步块总数也受 opslang 工具的 --max-screens N 参数的限制。 这个参数显然可以调整。

禁止切换到当前轻量线程不拥有的终端会话。如果尝试这么干,会收到一个类似下面的报错:

test.ops:11: ERROR: screen 'foo' not owned by the current thread

异步块可以嵌套或使用,也可以在用户动作历史用。异步块也可以创建自己的终端窗口。

异步块也可以有一个名字,如:

async foo {
    ...
}

这样的话,对应异步块的终端窗口也可以接受同样的名字。 否则他们获得类似 123等等这样的序列号名称。 具体几号去绝育他们在 ops 源代码文件中出现的顺序。

终端屏幕的日志文件 script 遵循和用 new-screen 函数明确创建终端窗口一样的命名规则。 对于异步终端,他们有特殊的终端名如 1, 2, 3 这样的。

Ops 程序在所有 async {} 块和主线程退出之前不会停止。不过,有几个例外:

  1. 如果执行线程调用了标准函数 exit() 的时候,
  2. 如果执行线程调用了标准函数 die() 的时候,或者
  3. 在一个执行线程有一个未捕获的例外,比如 timeout 的时候。

回到目录

线程之间共享变量

所有 Ops 程序都有一个轻量线程,就是启动的时候创建的主线程。 用户可以通过 async {} 块生成更多清凉线程。所有这些线程共享同样的通过 my 声明的顶层变量。 参考下面的例子:

my Int $c = 0;

goal all {
    run {
        setup-sh,
        $ FOO=32,
        async {
            setup-sh,
            $c++,
            say("c=", $c);
        },
        $c++,
        say("c=", $c);
    }
}

运行这个例子总是给出下面输出:

c=1
c=2

动作阶段或者用户动作里头定义的本地变量,如果在 async {} 块里可见, 那么在 async {} 块派生的时候会立即复制到一个拷贝里头。 在新线程里,进入 async 块到时候,这些本地变量仍然具有他们的初始值,但是给它们赋值的时候将不会再影响其它线程, 其它线程的变量赋值也不会影响这个新线程。比如:

goal all {
    run {
        my Int $c = 0;

        setup-sh,
        $ FOO=32,
        async {
            setup-sh,
            $c++,
            say("async: c=", $c);
        },
        sleep(0.1),
        $c++,
        say("main: c=", $c);
    }
}

执行这个例子总会生成下列输出:

async: c=1
main: c=1

所以实际上这俩线程每个都有自己本地的 $c

回到目录

赋值动作

赋值操作符 = 用于声明一个动作,这个动作可以给一个变量赋值,或则说一个表达式可以是左值。比如:

my $a;

$a = 3;

和所有其它动作一样,赋值表达式自己没有值。 所以不允许把赋值表达式嵌套在其它表达式里头。 比如,下面的例子会生成一个编译时错误:

? my $a;
?
? say($a = 3);

这是因为赋值语句 $a = 3 不返回值,所以它只能当一个独立的动作使用。

下面的赋值:

$a = $a + 3

可以用操作符 += 简化:

$a += 3

类似的还有 *=, /=, %=, x=, ~= 分别用于双目操作符 *, /, %, x, 和 ~

另外,后缀操作符 ++ 可用于简化 += 1。 比如:

$a++

等效于 $a += 1$a = $a + 1。类似的还有后缀操作符 --,等效于 -= 1

类似标准的 = 操作符,所有这些赋值操作符的变种自己都不接受任何数值,只能用于独立的动作中。

回到目录

Lua 块

在 opslang 程序里内联 Lua 代码是希望让复杂的事情有可能实现。

Lua 代码片段包含在Lua 块里,可以用在全局范围,用作一个动作, 或者一个 ops 表达式。

回到目录

全局 Lua 块

全局 lua 块用在当前 .ops 文件(是否 Ops 模块都行)的顶级范围。 这样的 Lua 代码片段将在生成的 Lua 源码文件的顶级范围内一个独立的 Lua 变量范围内扩展。 任何在 Lua 代码块里直接定义的 Lua 本地变量都不会被这个 lua 块以外所见。 如果你想定义整个文件的 lua 代码都可见的 Lua 变量,那么变量名用 _M.NAME 而不要用 NAME

下面是一个简单的例子:

lua {
    print("init!")
}

goal all {
    run {
        say("done");
    }
}

运行这个 global-lua-block.lua 例子给出:

$ opslang --run global-lua-block.lua
init!
done

回到目录

Lua 块动作

Lua 块也可以直接当做动作使用。这种场合下,不会预期有返回值从 Lua 块中返回。下面是一个例子:

goal all {
    run {
        print("hello, "),
        lua {
            print("world!")
        };
    }
}

运行这个 lua-action.ops 例子给出:

$ opslang --run lua-action.ops
hello, world!

回到目录

Lua 块表达式

Lua 块可以用作 Ops 表达式,但在这种场合下,必须声明返回类型,并且内联的 Lua 源代码必须返回一个对应数据类型的 Lua 值,像下面这样:

goal all {
    run {
        say("value: ", lua ret Int { return 3 + 2 })
    }
}

执行这个 lua-expr.ops 例子给出:

$ opslang --run lua-expr.ops
value: 5

回到目录

Lua 块中的变量代换

我们可以在上述所有三种 Lua 块中替换 opslang 的变量。

所有下列形式的 Ops 变量都可以在内联 Lua 代码中支持:

  1. $.NAME
  2. $.{NAME}
  3. $NAME
  4. ${NAME}
  5. @.NAME
  6. @.{NAME}
  7. @{NAME}

如果 Ops 变量在 Lua 的单引号或者双引号包围字串里使用的时候,在最后的 Lua 值里,他们会保持原来的字串值, 即使他们的字串值包含特殊字符,比如 ', ", 和 \ 也如此。 另外一方面,在 Lua 的圆括弧长字串里,Ops 变量不会被代换。

代换出来的 Ops 变量可以用在所有可用 Lua 表达式的 Lua 代码里。

下面是一个在 Lua 的单引号包围字串字面里头代换 Ops 数组变量的例子:

goal all {
    run {
        my Str @foo = ("hello", 'world');

        say("lua: ", lua ret Str {
            return '[@foo]'
        })
    }
}

运行之给出输出:

lua: [hello world]

下面是一个代换 Ops 标量变量的例子:

goal all {
    run {
        my Str $foo = "hello";
        my Int $bar = 32;

        say("lua: ", lua ret Str {
            return $foo .. $.bar + 1
        })
    }
}

运行这个例子给出下面输出:

lua: hello33

回到目录

For 语句

for 语句是一种用于便利数组或者哈希容器的特殊类型的动作。其通用语法如下:

for CONTAINER -> VAR1 [, VAR2] {
    SYMBOL-DECLARATION;
    SYMBOL-DECLARATION;
    ...

    ACTION,
    ACTION,
    ...;
}

下面是遍历一个 Int 数组的例子:

goal all {
    run {
        my Int @arr = (56, -3, 0);

        say("begin"),
        for @arr -> $elem {
            say("elem: $elem");
        },
        say("end");
    }
}

运行这个生成下列输出

begin
elem: 56
elem: -3
elem: 0
end

请注意我们不需要通过 my 关键字给循环变量 $elem 做声明,因为 for 语句的 -> 操作符已经在 for 语句的范围内隐含定义了这个本地变量。

还要注意我们不用声明循环变量 $elem 的类型,因为其类型总是在 -> 操作符生效之前判定为容器表达式生成的数组或哈希的元素类型。

这里离子里我们也可以定义两个循环变量,用于并发循环遍历:

goal all {
    run {
        my Int @arr = (56, -3, 0);

        say("begin"),
        for @arr -> $i, $elem {
            say("elem $i: $elem");
        },
        say("end");
    }
}

得出:

begin
elem 0: 56
elem 1: -3
elem 2: 0
end

同样,我们不用声明 -> 后面变量 $i 的类型, 因为其类型总是 Int

我们可以用类似方法遍历一个哈希表,这时候两个循环变量必须分别声明为哈希表的键和值,比如:

goal all {
    run {
        my Int %a{Str} = (dog: 3, cat: -51);

        say("begin"),
        for %a -> $k, $v {
            say("key: $k, value: ", $v);
        },
        say("end");
    }
}

生成下列输出:

begin
key: dog, value: 3
key: cat, value: -51
end

回到目录

终端模拟器操作

Ops 语言的最强的地方是内置支持任意终端模拟器的自动化。 它对 *NIX 风格的终端的透明操作控制序列支持力度极高。在启动的时候, 所有 Ops 程序都会为后面的操作创建一个伪终端模拟器实例(就是 pty)。 通过这个办法,Ops 程序可以跟人类操作者一样与操作系统的终端界面进行交互。 缺省的时候,这个终端是与交互的 bash 会话绑定的,这个 shell 可以通过命令行工具 opslang--shell SHELL 选项修改。

本文里我们会互换使用 终端终端模拟器 两个属于。 两个术语都表示前述的伪终端模拟器的意思。

从某种角度来说,Ops 语言提供的终端交互能力跟经典的 Tcl/Expect 工具链提供的运行时工具非常类似, 只是在很多方面具有更强功能和更灵活。

完整的终端交互细节都记录在一个命令行工具 script (通常来自 util-linux 项目)生成的日志文件里。 在命令行运行 Ops 程序之后,检查 ./ops.script.log 文件通常可以提供很多有用信息。 这个日志文件路径可以通过 opslang 命令行工具的 --script-log-file PATH 选项修改。

回到目录

向终端写出

Ops 程序可以通过标准函数 send-text 给终端发送文本数据,比如 echo hello。 它还可以通过标准函数 send-key 给终端发送特殊控制字符序列(比如回车键或者 Ctrl-C 组合键)。

回到目录

输入回显

需要注意的是,很多时候我们向终端写出数据的时候,终端会通过回显输入的东西给(人类用户)出相应(虽然并不总是如此)。 更糟糕的是,有时候如果敲击太快,因环境不同,回显的输出甚至会全部或者部分重复。 Ops 程序总是需要准备处理从终端返回的这种“回显”输出。不过,幸运的是, Ops 程序的确提供了一些内置的特性和函数来帮助处理这种无边的细节,所以 Ops 程序员通常不需要关心这些问题, 除非想亲自在非常底层处理每个小细节。

回到目录

从终端读取

从终端读取天生要比向终端写出更难。这是因为终端流输出的数据内容和大小通常都是不可预测的。 Ops 语言提供两种读取终端数据的办法:流的方式和全缓冲的方式。 Ops 语言根据具体的使用场景,可以随意选择任何一种方式读取。

回到目录

流读取

Ops 语言提供流块用于从终端模拟器中读取流数据。

stream 块里,标准函数 out 以一种抽象的方式代表终端的输出流。 这个out 函数调用可以用于双目关系表达式,进行正则和字串模式匹配(相关的 操作符contains, prefix, eq, 和 suffix)。 千万不要把 out 的值当作普通字串值使用;否则会看到下面的编译错误:

ERROR: out(): this function can only be used as the lvalue of relational expression operators at out.ops line 5

out 流自动过滤所有在真实控制台中用于显示或者打扮的很花哨的控制台控制序列。 如果你希望处理这些未加修改的终端控制序列,你应该使用标准函数 raw-out

还有一个 echoed-out 变种,它是用于匹配输出留中的键入回显的。

回到目录

流块

流块的格式如下:

stream {
    rule-1;
    rule-2;
    ...
}

基本上,一个流块以一个关键字 stream 开头,然后跟着一个用一对花括弧包围的块,在这个 块里可以声明一个或多个规则

看看下面的例子:

goal all {
    run {
        stream {
            out suffix /.*?For details type `warranty'\.\s*\z/ =>
                print($0),
                break;
        }
    }
}

如果用这个 Ops 程序跟无所不在的 UNIX bc 一起像下面这样运行 (假设 Ops 程序在文件 bc-banner.ops 里头):

$ opslang --shell bc --run bc-banner.ops
bc 1.06
Copyright 1991-1994, 1997, 1998, 2000 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'.

这个 Ops 程序先读取 bc 的初始旗标输出(由 bc 程序自己在终端打印出来), 然后输出到自己的 stdout (标准输出)设备。 输出中的实际文本可能因你系统的 bc 程序的不同版本(甚至不同来源)而异。

请注意前面例子 stream {} 块里面那唯一一个规则。 我们使用文本For details type `warranty`. 作为旗标语的结尾。 一旦匹配了这个正则,我们就通过打印特殊捕获变量 $0 的值作为输出。 这个值包含所有这个正则匹配上的东西。

请注意上面规则里头的 break 动作。这个函数调用立即终止当前 stream {} 块的读取动作,否则 stream 块会持续循环等待更多规则匹配 (直到 timeout 例外抛出)。

流块可以有多条规则,这些规则和那些规则将以流处理的方式独立与终端输出数据流进行匹配。 让我们看看另外一个处理 bc 程序的例子:

goal all {
    run {
        stream {
            out contains /\w+/ =>
                say("rule 1: $0");

            out contains /\w+/ =>
                say("rule 2: $0");

            out suffix /For details type `warranty'\.\s*/ =>
                say("quitting..."),
                break;
        };
    }
}

运行这个程序产生下面输出:

rule 1: bc
rule 2: bc
quitting...

因此所有 3 个规则都轮流执行。规则执行的顺序很重要:头两个规则都匹配 out 数据流同样位置相同的子字串, 因此它们执行的顺序取决于它们在 Ops 程序源代码中出现的顺序。 结果是第一条规则总是在第二条规则之前执行。而另外一方面,第三条规则匹配 out 数据流中的最后的语句, 所以它总是在头两条规则之后运行,让我们试着把第三条规则挪到头两条规则之前, 看看发生什么:

goal all {
    run {
        stream {
            out suffix /For details type `warranty'\.\s*/ =>
                say("quitting..."),
                break;

            out contains /[a-zA-Z]+/ =>
                say("rule 1: $0");

            out contains /[a-zA-Z]+/ =>
                say("rule 2: $0");
        };
    }
}

运行这个程序得到一样的输出:

rule 1: bc
rule 2: bc
quitting...

第一条规则的匹配文本的位置在后面两条规则匹配文本位置之后, 所以即使第一条规则在 Ops 源代码中先出现,它也在后面两条规则之后运行。 这是流处理的本质。我们不会等着全部输出完毕才进行匹配。我们总是一看到点输出就开始匹配。

还要注意前面的例子里,后两个规则并不包括 break 动作。这么做很重要,因为 break 会立即中断当前 stream{} 的执行流,而我们实际希望的是这个例子能尽可能多匹配和执行规则。 如果我们给 stream 块里所有三个规则都加上 break 动作:

goal all {
    run {
        stream {
            out suffix /For details type `warranty'\.\s*/ =>
                say("quitting..."),
                break;

            out contains /[a-zA-Z]+/ =>
                say("rule 1: $0"),
                break;

            out contains /[a-zA-Z]+/ =>
                say("rule 2: $0"),
                break;
        };
    }
}

那么很自然,在第一个有机会执行的规则上(假设代码存到了文件 all-break.ops 里):

$ opslang --shell bc --run all-break.ops
rule 1: bc

另外一个对前面例子的很重要的观察是,所有 stream 块里头的规则, 都至多执行一次。显然,条件 out contains /[a-zA-Z]+/ 会在 bc 程序的旗标文本中匹配多次, 即使如此,我们也只获得对输出的第一次匹配。这是 Ops 语言设计如此。 如果想让规则匹配多次,我们应该在对应规则的后果部分调用 redo 动作,如:

goal all {
    run {
        stream {
            out suffix /For details type `warranty'\.\s*/ =>
                say("quitting..."),
                break;

            out contains /[a-zA-Z]+/ =>
                say("word: $0"),
                redo;
        };
    }
}

运行这个 redo.ops 程序获得下列输出:

$ opslang --shell bc --run redo.ops
word: bc
word: Copyright
word: Free
word: Software
ERROR: redo.ops:10: too-many-tries
  in stream block rule at redo.ops line 10
  in rule/action at redo.ops line 4

这次,我们通过 redo 动作匹配了更多“单词”:bc, Copyright, Free, 和 Software。 有趣的是,Ops 程序在输出 4 个单词之后,带着一个未捕获例外 too-many-tries 退出了。 抛出 too-many-tries 例外是因为该规则里头的 redo 动作执行了超过 3 次。 标准函数 redo 的缺省重试限制是 3,跟标准函数 goto 一样。要在前述例子里匹配和输出更多单词, 我们可以为 redo() 调用声明 tries 命名参数。如:

goal all {
    run {
        stream {
            out suffix /For details type `warranty'\.\s*/ =>
                say("quitting..."),
                break;

            out contains /[a-zA-Z]+/ =>
                say("word: $0"),
                redo(tries: 20);
        };
    }
}

这次我们可以输出 bc 旗标语中的所有单词,而不会被 too-many-tries 例外抛出:

word: bc
word: Copyright
word: Free
word: Software
word: Foundation
word: Inc
word: This
word: is
word: free
word: software
word: with
word: ABSOLUTELY
word: NO
word: WARRANTY
word: For
word: details
word: type
word: warranty
quitting...

现在让我们用 bc 干点儿真实有用的工作,比如,执行简单的算术计算 3 + 5 * 2

goal calc {
    run {
        my Str $expr;

        stream {
            out suffix /For details type `warranty'\.\s*/ =>
                break;
        },

        # enter the expression:
        $expr = "3 + 5 * 2",
        send-text($expr),
        stream {
            echoed-out suffix $expr =>
                break;
        },

        # submit the entered expression by pressing Enter:
        send-key("\n"),
        stream {
            echoed-out prefix /.*?\n/ =>
                break;
        },

        # collect the result in the new output:
        stream {
            out suffix /^(\d+)\n/ =>
                say("res = $1"),
                break;
        };
    }
}

这个例子有点长,因为它直接处理了裸终端的交互。首先我们用流块等待 bc 输出所有旗标文本。 然后我们输入通过标准函数 send-text 表达式 3 + 5 * 2。 之后我们用另外一个流块等待终端回显我们刚刚“敲入”的表达式文本。 一旦我们确认已经输入了表达式,我们通过调用 send-key 函数模拟一个键盘回车输入(跟人类用户一样)。 然后,我们使用一个新的流块确保回车键按下并且回显给我们。最后,我们使用最后一个流块来接收 bc 输出的计算结果。

请注意我们使用了标准函数 echoed-out 来匹配我们的键入回显。 ehoed-outout 主要的区别是前者会自动过滤掉终端自动折行引入的特殊字符序列 (每个终端都有固定宽度,如果键入的行宽超过宽度限制,就会折行)。

你可能会奇怪,为啥我们要等待输入的回显。这是因为我们所有与 bc 的沟通都要通过终端模拟器, 如果我们键入太快,我们可能就丢失一些键入,就好像打字很快的人类在响应缓慢的终端上偶尔会丢失一些键入一样。

运行这个 calc.ops 程序生成

$ opslang --shell bc --run calc.ops
res = 13

让我们检查裸终端会话历史里头的 ./ops.script.log 文件:

$ cat ops.script.log
Script started on 2019-06-12 17:10:52-07:00
bc 1.07.1
Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006, 2008, 2012-2017 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'.
3 + 5 * 2
13

这就是我们预期的输出。

回到目录

全缓冲读取

全缓冲的终端输出读取是通过定义一个合理的提示符,然后读取标准函数 cmd-out 返回的字串值实现的。 cmd-out 返回的字串值定义为前一个 stream {} 块的 out 流位置到通过当前 stream {} 块匹配上的通配符字串之间的东西。

参考 GDB 提示符 一节获取例子。

回到目录

提示符处理

在前面的 bc 的例子里,我们已经看到如何直接在 Ops 里处理与终端的交互。 对那些更复杂的交互程序,比如 bashgdb,我们需要处理“提示字符串”(或者就是“提示符”), 比如 bash-4.4$(gdb) 这样的。 如果我们在每个 Ops 程序里都手工处理这些提示符,那将会是一个无比枯燥和艰巨的任务。 幸运的是,Ops 通过各种标准函数,比如 push-promptpop-prompt, 还有标准 例外 found-prompt 等,提供内置的提示符处理的支持。

回到目录

GDB 提示符

看看下面的例子,它敲入一些 gdb 命令并输出其结果:

goal all {
    run {
        my Str $cmd;

        push-prompt("(gdb) "),

        # wait for the prompt to show up:
        stream {
            found-prompt => break;
        },

        # send our gdb command:
        $cmd = "p 3 + 2 * 5",
        send-text($cmd),

        # wait for our typing to echo back:
        stream {
            echoed-out suffix $cmd =>
                break;
        },

        # press Enter to submit the command:
        send-key("\n"),
        stream {
            echoed-out prefix /.*?\n/ =>
                break;
        },

        # collect and output the result:
        stream {
            found-prompt =>
                print("res: ", cmd-out),
                break;
        };
    }
}

在这个例子里,我们先用标准函数 push-prompt 给 Ops 运行时注册一个信的提示符字串模式,"(gdb)"。 然后再后面的 stream {} 块里头,我们可以用一个带着条件 found-prompt 的规则等待终端输出流中出现的第一个这样的提示符字串。 一旦我们获得了 gdb 提示符,我们就发送 gdb 命令,p 3 + 2 * 5,等待输入回显,然后敲回车键, 然后获得回车的输入回显,最后等待下一个 gdb 提示符。拿到新的 gdb 提示符之后, 我们用标准函数 cmd-out 为前面执行的命令抽取输出(定义为当前匹配上的提示符字串之前,以及前一个流块输出流(本例是回车键敲入之后的回显)输出的字节之后的数据)。 使用 cmd-out 函数实际上是全缓冲读模式。 很明显它是基于侦测到提示字串和缺省流读取模式的。

我们需要注意我们提示字串模式里头 (gdb) 后面的空白字符。 那个空白是必须的,因为提示符字串匹配总是一个 suffix (后缀)匹配操作,而 GDB 总是在 (gdb) 字串后头立即放一个空白。 它是真实 GDB 提示符的一部分。

我们运行这个 gdb.ops 程序,用 gdb 作为我们的 shell:

$ opslang --shell gdb --run gdb.ops
res: $1 = 13

正是我们要的。

实际上我们可以用标准函数 send-cmd 简化我们上面的例子。 这个函数组合了发送命令字串,等待输出回显,敲入回车键以及等待新行回显的所有操作。 前例简化以后的版本如下:

goal all {
    run {
        push-prompt("(gdb) "),

        # wait for the prompt to show up:
        stream {
            found-prompt => break;
        },

        # send our gdb command:
        send-cmd("p 3 + 2 * 5"),

        # collect and output the result:
        stream {
            found-prompt =>
                print("res: ", cmd-out),
                break;
        };
    }
}

现在的代码短了很多,结果完全一样:

$ opslang --shell gdb --run gdb2.ops
res: $1 = 13

回到目录

Bash 提示符

Bash 的提示符比 GDB 的更复杂,因为它就不是一个固定的字串。 它可以由用户配置(比如通过给 bash 变量设置一个 PS1 模版),因而可能因为提示符模版里头的不同 shell 变量而包含动态的信息。

下面是一个尝试匹配真实 bash 提示符字串的例子:

goal all {
    run {
        stream {
            out suffix rx:x/^ [^\r\n]+ [\$%\#>)\]\}] \s*/
            =>
                push-prompt($0),
                break;
        },
        send-cmd("echo hello"),
        stream {
            found-prompt =>
                break;
        },
        say("out: [", cmd-out, "]");
    }
}

在这个例子里,我们先用一个流块和一个正则模式匹配尝试搜寻一个长得像 shell 提示符的字串。 然后我们把匹配上的字串通过标准函数 push-prompt 推到栈里头。 push-prompt 函数的一个特性是它自动把提示符字串里头的数字规范化为 0, 所以就算 shell 提示符模版包含类似 $? 这样的变量,我们的提示符检测依旧会成功。

让我们用 --shell bash 选项运行这个 sh-prompt.ops 程序:

$ opslang --shell bash --run sh-prompt.ops
out: [hello
]

Ops 语言提供好几个额外的标准函数让我们更容易处理 Bash 会话。比如, 前面例子可以用 shell 包围字串 简化:

goal all {
    run {
        stream {
            out suffix rx:x/^ [^\r\n]+ [\$%\#>)\]\}] \s*/ =>
                push-prompt($0),
                break;
        },
        sh/echo hello/,
        stream {
            found-prompt =>
                break;
        },
        say("out: [", cmd-out, "]");
    }
}

然后我们还可以用美元符动作进一步简化:

goal all {
    run {
        stream {
            out suffix rx:x/^ [^\r\n]+ [\$%\#>)\]\}] \s*/ =>
                push-prompt($0),
                break;
        },
        $ echo hello,
        say("out: [", cmd-out, "]");
    }
}

甚至最初的 shell 提示符检测都可以简化为标准函数 setup-sh 的调用:

goal all {
    run {
        setup-sh,
        $ echo hello,
        say("out: [", cmd-out, "]");
    }
}

当然, setup-sh 函数可以比上面的穷人版 shell 提示符检测干的事儿更多。

实际上,setup-sh 几乎总是任何有 shell 会话交互的 Ops 程序的第一个屌用, 因而,如果用户没有声明 --shell SHELL 命令行参数的话, opslang 命令会自动调用之。 如果 opslang 命令行工具已经自动调用了 setup-sh 函数,那就要确保你不会再你的目标动作阶段之初重复调用此函数。 重复调用会导致读取超时的错误,因为在 shell 会话开始的时候, 不会有任何重复的 shell 提示符字串出现。

因此,如果我们不像前例那样带着 --shell bash 选项运行命令行工具 opslang, 那么我们必须从我们 Ops 程序里删除 setup-sh 调用:

goal all {
    run {
        $ echo hello,
        say("out: [", cmd-out, "]");
    }
}

运行这个 sh-prompt2.ops 输出如下:

$ opslang --run sh-prompt2.ops
out: [hello
]

这个例子终于成为了最精简的样子。

回到目录

嵌套 Bash 会话

Ops 语言的一个强大的地方是处理嵌套 shell 会话时的强有力。下面是一个例子:

goal all {
    run {
        sh/bash/,
        setup-sh,
        $ echo $(( 3 + 1 )),
        say("out: [", cmd-out, "]"),
        exit-sh;  # to quit the nested shell session
    }
}

运行这个 nested-sh.ops 给出下面输出:

$ opslang --run nested-sh.ops
out: [4
]

这里我们必须为嵌套的 shell 会话明确调用 setup-sh 并且调用标准函数 exit-sh 进行合理的退出。 exit-sh 函数实际上在钩子上做下面的两个动作:

pop-prompt,
$ exit 0;

标准函数 pop-prompt 删除当前嵌套 shell 会话的提示符模式并且恢复父 shell 会话的提示符模式设置。 然后它执行 shell 命令 exit 0,然后 Ops 运行程序就可以成功检测到父 bash 会话的提示符字串了。

需要注意的是我们有意在第一个 bash shell 命令里避免使用了美元符动作。 请记住上面的 shell 包围的字串动作 sh/bash/ 只是全称 send-cmd(sh/bash/) 的一个缩写注解。 我们在这里绝不能bash 命令使用美元符动作,因为美元符动作会自动在一个隐含流块里头等待提示符字串。 在这里我们绝对需要一个 setup-sh() 调用为新的 shell 会话自动检查一个新的提示符字串(不是旧会话的提示符)。

参考上面例子的方式,我们可以在 Ops 程序会话里嵌套任意多 shell 会话。 push-promptpop-prompt 函数调用在一个内部的提示符模式栈上运作,可以支持任意深度的增长。

嵌套 bash 会话也可以通过运行其它 shell 命令,比如 su,而不是直接调用 bash 来创建。

回到目录

嵌套 SSH 会话

对付嵌套 SSH 会话跟 嵌套 bash 会话一样简单。下面是一个例子:

goal all {
    run {
        sh/ssh -o StrictHostKeyChecking=no 127.0.0.1/,
        setup-sh,
        $ echo $(( 3 + 1 )),
        say("out: [", cmd-out, "]"),
        exit-sh;  # to quit the nested ssh session
    }
}

运行这个 nested-ssh.ops 程序输出:

$ opslang --run nested-ssh.ops
out: [4
]

当然,现实世界里连接到本地主机没啥用处。 我们可以选择连接远端服务器。

跟嵌套 bash 会话一样,嵌套 SSH 会话也可以嵌套任意深度,比如,

goal all {
    run {
        sh/ssh -o StrictHostKeyChecking=no 127.0.0.1/,
        setup-sh,
        $ echo $(( 3 + 1 )),
        say("1: out: [", cmd-out, "]"),

        sh/ssh -o StrictHostKeyChecking=no 127.0.0.1/,
        setup-sh,

        $ echo $(( 7 + 8 )),
        say("2: out: [", cmd-out, "]"),

        exit-sh,  # to quit the second ssh session
        exit-sh;  # to quit the first ssh session
    }
}

运行这个程序给出输出

1: out: [4
]
2: out: [15
]

这个功能对那些需要先登录中间“跳板机”再登录最终服务器的环境特别有用。

回到目录

多终端屏幕

Ops 语言支持在同一个程序里创建多个终端屏幕,类似流行的命令行工具 GNU screentmux

所有输出和提示符处理都在独立的窗口里进行。 不过 Ops 变量是共享的。

我们可以通过标准函数 new-screen() 创建新的终端窗口,然后通过 switch-screen 函数在多个窗口之间切换。

实际上,Ops 总是创建名为 “main” 的缺省窗口。

窗口名必须是有效的标识符。使用非法的名字串会导致运行时或者编译时错误。

参考下面例子:

goal all {
    run {
        # in the default 'main' screen
        $ Foo=,

        new-screen('foo'),

        # in our new 'foo' screen
        $ FOO=32,

        switch-screen('main'),

        say("main: FOO: [", sh-var('FOO'), "]"),

        switch-screen('foo'),

        say("foo: FOO: [", sh-var('FOO'), "]");
    }
}

在这里我们创建了第二个叫 foo 的屏幕,然后我们在缺省的 main 屏幕里头, 把 shell 变量 FOO 设置为空串,在我们的 foo 屏幕里头设置为 32. 这个例子在这两个不同屏幕里头输出这个 FOO shell 变量的值。 我们使用标准函数 sh-var 检索特定 shell 变量的值。

运行这个 screens.ops 程序输出:

$ opslang --run screens.ops
main: FOO: []
foo: FOO: [32]

自然,不同终端窗口有不一样的 shell 环境。

请注意 new-screen() 函数调用也会自动切换到新创建的屏幕。

switch-screen() 函数调用的一个重要的行为习惯是它只影响到当前用户动作或者当前用户目标动作阶段结束。 switch-screen() 的作用将持续到超越 check {} 阶段,直到周围动作阶段块结束。

单个 Ops 程序能创建的并行窗口数有一个限制。缺省的时候是 20,它可以通过命令行参数 --max-screens 来设置, 如下:

opslang --run --max-screens 100 foo.ops

所生成的脚本日志文件也是每个屏幕独立的。缺省时 main 屏幕的日志文件依旧是 ./ops.script.logfoo 的日志文件是 ./ops.script.log.foo。用户声明的脚本日志文件路径会遵循相同的命名方式 (非 main 屏幕会在 main 文件名后面追加屏幕名)。

回到目录

用户动作

用户可以通过把一些其它动作组合在一起,定义自己的实用动作。 定义客户化动作的常用愈发如下:

action <name> (<arg>...) [ret TYPE] [is export] {
    [<label-declaration> | <variable-declaration>]...

    <action1>,
    <action2>,
    ...
    <actionN>;
}

例子:

action say-hi(Str $who) {
    say("hi, $who!"),
    say("bye");
}

调用这个 say-hi 动作: say-hi("Tom"),会生成输出:

hi, Tom!
bye

可以声明多个动作。

用户定义动作是一个给动作引入自己的词表、并用于规则或者其它环境的好办法。

禁止递归动作。

参数必须带着类型信息声明,这点跟那些使用 my 关键字声明的本地变量一样。 可选的参数可以用缺省值声明,如:

action say-hi(Str $who = "Bob") {
    say("hi, $who");
}

然后 say-hi 动作就可以不带参数调用,如 say-hi(),在此场合下,参数 $who 将获得缺省值 "Bob"

我们可以给这样的可选参数声明缺省值 nil,如:

action foo(Int $a = nil) {
    {
        defined($a) =>
            say("a has a value!"),
            done;

        true =>
            say("a has no value :(");
    }
}

我们也可以使用标准函数 defined 来检查一个变量是否为 nil 。

回到目录

带返回值的用户动作

用户动作可以返回用户声明类型的值。方法是在参数列表后立即声明 ret TYPE 性质,如:

action world() ret Str {
    return "world";
}

请注意我们是如何使用 return 语句把字串值 "world"返回给这个 world 用户动作的调用者的。

如果省略了 ret TYPE 性质,那么就假设不返回值或者返回 nil 值。

回到目录

目标

opslang 的目标有点类似 Makefile 的目标,不过他们从不像 Makefile 的目标那样与任何文件关联。 在某种意义上,他们更类似 GNU Makefile 的“伪目标”。 Ops 的目标是一种命名任务,是 Ops 程序每次运行要实现的任务。 他们是 Ops 程序的入口(类似 C 程序的 main 函数)。

每个 Ops 程序至少需要定义一个目标,当然,也可以定义多个目标。 定义了多个目标的 Ops 程序,他们也会有多个入口。 缺省入口称作缺省目标。通常,缺省目标时在 Ops 程序里定义的第一个目标,这个缺省目标可以通过 default goal 语句覆盖。

Ops 里定义的最简单的目标类似下面:

goal hello {
    run {
        say("hello, world!");
    }
}

这段代码定义了一个新的叫 hello 的目标,在执行的时候给当前 Ops 程序的标准输出流打印出 hello, world! 行。它也是 Ops 语言世界的经典“hello world”程序。如果这个程序在文件 hello.ops 里头,我们可以这样运行:

$ opslang --run hello.ops
hello, world!

正是我们想要的。

这个例子里我们有些东西要注意:

  1. 我们在 hello 目标里定义里一个 run {} 块,它调用了一个 动作阶段.一个目标可以有几个不同类型的动作阶段,比如 run, build, install, prep, 和 packagerun 动作阶段是目标最常用的用户动作阶段。 如果其它类型的动作阶段不合理,那么就使用 run
  2. 本例的 run 动作阶段包含一个动作,就是一个函数调用 say("hello, world!")
  3. 程序文件 hello.ops 只包含这一个目标,hello,所以,如果运行这个 Ops 程序的时候没有通过命令行参数声明其它目标(通过 opslang命令), 那么这个目标也就死活缺省目标。

我们可以在 opslang 命令行上明确声明制定的目标:

opslang --run hello.ops hello

额外的命令行参数 hello 声明要运行的目标。

我们也可以通过更多命令行参数声明多个目标,如:

opslang --run example.ops foo bar baz

在这个例子里,目标 foo, bar, 和 baz 会按顺序执行。

回到目录

缺省目标

每个 Ops 程序都必须有且只能有一个缺省目标, 如果在命令行或者 Lua API 调用里没有声明目标,那么就会运行这个缺省目标。 缺省目标可以用 default goal 语句明确声明,类似下面这样:

default goal foo;

这个语句必须在 Ops 程序的顶层范围里,通常在整个文件的开头附近。 目标 foo 可以在 default goal 语句后面定义。 没有要求 foo 必须定义在这个 default goal 语句之前。

如果 Ops 程序里头没有 default goal 语句,那么第一个定义的目标就会成为缺省目标。

回到目录

动作阶段

一个目标可以一个或多个动作阶段,一个动作阶段是一个命名的动作组合,当运行目标时会执行这个动作组合。 下面是预定义的动作阶段,按通常执行的顺序列出:

  • prep: 做些准备工作。
  • build: 进行软件制作(通常是代码编译和生成)。
  • install: 安装软件。
  • run: 运行软件或其它无法分类的动作。
  • package: 给制作和安装好的软件打包(如 RPM 或者 Deb 包)。

一个目标哦可以定义一个或多个动作阶段,或者也可以不定义。

如果一个目标没有定义动作阶段,那么它就是一个抽象动作阶段, 那么就一定有一些目标依赖

要明确调用一个目标 foo 的动作阶段 xxx,我们只需要用全称引用那个阶段即可:foo.xxx。 比如,要引用 foo 的动作阶段 run,我们写 foo.run()。圆括弧可以想普通 Ops 函数调用那样忽略掉。

如果一个动作定义了多个动作阶段,那么这些动作阶段会形成一个链。 如果一个靠后的董总阶段准备运行,那么任何已定义的在这个动作阶段之前的动作阶段都会按序运行。 比如,如果一个目标 foo 定义了有 prep, build, 和 run 阶段, 然后调用者运行 foo.run 阶段。在 foo.run 阶段运行的时候,目标会自动线运行 foo.prep, 然后 foo.build。而 foo.install 阶段不会运行,因为在这个 foo 的例子里头, 没有定义这个阶段。

也可以不声明动作阶段名调用目标,就把目标当作一个函数就行。比如, 要想屌用目标 foo,我们写 foo()

结果是屌用此目标的 缺省动作阶段

回到目录

动作阶段的本地符号

动作阶段可以定义自己的本地符号,类似 变量标签。这样的符号如果定义在当前动作阶段,那么也可以被[检查阶段](#检查阶段)块可见。

下面是一个例子:

goal hello {
    run {
        my Str $name = "Yichun";

        say("Hi, $name!");
    }
}

运行这个目标生成输出:

Hi, Yichun!

回到目录

缺省动作阶段

如果一个目标被调用,但是没有声明动作阶段, 那么将会调用缺省动作阶段。

缺省动作阶段定义为 1) 除了 packageprep 阶段之外的最后运行阶段, 或者 2) package 阶段,如果它是唯一定义的阶段。

比如,如果一个目标 foo 定义里动作阶段 build, install, run, 和 package,然后缺省动作阶段将会是 run 阶段。另外一个例子,如果目标 bar 只定义了 package 阶段,那么将会使用缺省的动作阶段。

不定义任何动作阶段的抽象目标将智慧执行其依赖的目标。

回到目录

检查阶段

每个动作阶段都可以接受一个可选的检查阶段, 它执行一系列规则来判断当前动作阶段的主要动作是否应该执行。 检查阶段通常用于验证当前目标是否已经制作或满足。

检查阶段环境里可以用两个标准函数来发出成功或者失败的信号。 他们是 oknok 函数。调用 ok() 函数会导致当前检查阶段立即成功, 因此当前动作阶段会被忽略。另外一方面, nok() 函数导致当前检查阶段立即失败, 然后开始执行周围动作阶段的主动作。

如果检查阶段没有调用任何这里啊函数,那么缺省时检查阶段失败(也就是说, 需要运行动作阶段的主动作)。

考虑下面这个简单的例子:

goal hello {
    run {
        check {
            file-exists("/tmp/a.txt") =>
                ok();
        }
        say("hello, world!");
    }
}

在终端运行这个 Ops 程序:

$ rm -f /tmp/a.txt

$ opslang --run hello.ops
hello, world!

$ touch /tmp/a.txt

$ opslang --run hello.ops

$

第一次运行因为不存在 /tmp/a.txt 文件,导致 check {} 阶段失败, 因此 run {} 动作阶段的主动作 say("hello, world!") 就被执行并且一行一行输出。 第二次运行的时候,因为 文件 /tmp/a.txt 已经存在, check 阶段成功,因此主动作这次不再执行。

回到目录

目标依赖

一个目标可以指明一些其它目标为自己的依赖项。 在当前目标执行之前,这些依赖目标的缺省动作阶段会被检查和执行。

比如,

goal foo: bar, baz {
    run { say("hi from foo") }
}

goal bar {
    run { say("hi from bar") }
}

goal baz {
    run { say("hi from baz") }
}

目标 foo 定义了两个依赖, barbaz。 这两个目标会在 foo 运行之前运行。我们在终端上测试这个例子(假设这个例子在文件 deps.ops 里):

$ opslang --run deps.ops
hi from bar
hi from baz
hi from foo

回到目录

目标参数

用户动作 一样,目标可以定义自己的参数。 参数的语法非常类似用户动作的语法。比如:

default goal all;

goal foo(Int $n, Str $s) {
    run {
        say("n = $n, s = $s");
    }
}

goal all {
    run {
        foo(3, "hello"),
        foo(-17, "world");
    }
}

运行这个 Ops 程序生成下面输出:

n = 3, s = hello
n = -17, s = world

我们也可以直接从命令行接口直接调用参数化的目标。看看下面的例子:

goal foo(Int $n, Str $s) {
    run {
        say("n = $n, s = $s");
    }
}

假设这个文件名为 param-goal.ops,我们可以这样运行:

$ opslang --run param-goal.ops 'foo(32, "hello")'
n = 32, s = hello

在目标声明器里头使用的单引号和双引号字串遵循 Ops 语言自己同样的语法规则。 只是在这个环境里变量代换是总被禁止的。看看下面的例子:

$ opslang --run param-goal.ops 'foo(0, "hello\nworld!")'
n = 32, s = hello
world!

$ opslang --run param-goal.ops "foo(0, 'hello\nworld')"
n = 32, s = hello\nworld

这里也支持布尔字面 truefalse

这个环境里,值类型必须准确匹配参数类型,因为不会做自动的类型转换。

在编译器模式下,我们可以做下面的事情:

$ opslang -o param-goal.lua param-goal.ops

$ resty -e 'require "param-goal".main(arg)' /dev/null 'foo(56, "world")'
n = 56, s = world

回到目录

目标记忆

对于任何单个 Ops 程序的运行,每个目标都会最多运行一次。 Ops 的运行时程序将会跟踪目标的成功执行(或检查),这样不会有目标被多次运行。

对于带参数的目标,每个目标的每个实际参数值都会记录一次,因此 foo(32)foo(56) 都会被运行。

这也是目标和用户动作之间的最大的不同。

看下面的例子:

goal foo {
    run {
        say("hi from foo");
    }
}

goal all {
    run {
        foo,
        foo,
        foo;
    }
}

运行这个例子将智慧产生一行输出:

hi from foo

这是因为后面两个 foo 目标因目标结果的记忆而略过了。

回到目录

从目标中返回

我们可以使用 return 语句从目标的主动作链中返回。 只是目标目前还不支持返回任何值。可以使用顶级变量保存目标执行的结果。

回到目录

模块

Ops 语言支持模块化编程,这就意味着它可以很容易构造大型的程序。

每个 Ops 模块都必须在自己的 .ops 文件里。比如, 模块 foo 应该在单独的文件 foo.ops 里,而 模块 foo.bar 应该在文件 foo/bar.ops 里。

每个 Ops 模块文件应该以一个声明模块名都 module 语句开头,比如:

module foo;

或者

module foo.bar;

模块名必须跟文件路径名指定的一样。

要想从其它 Ops 模块或者 Ops 主程序文件中装载 Ops 模块, 只要使用use 语句即可,如下:

use foo;
use foo.bar;

第一个语句在模块搜索路径查找 foo.ops 文件然后装载 foo 模块, 而第二个是找 foo/bar.ops 文件。

每个模块都只能装载一次。对同一个模块的多个 use 语句是 no-op

模块 foo 里头的动作,例外和目标总是可以通过其它 .ops 文件引用,方法是用全称, 比如 foo.bar,这里 foo 是模块名,而 bar 是定义在 foo 模块里的符号名。

回到目录

模块搜索路径

Ops 程序在一个目录列表里搜索 Ops 模块文件。 这个目录列表称作模块搜索路径。 缺省搜索路径包含两个目录:

  1. 当前工作目录, ..
  2. <bin-dir>/../opslib 目录,这里的 <bin-dir> 是保存 opslang 命令行可执行文件自身的忙碌。这个缺省的搜索路径是用于装载标准的 Ops 模块 std,这个模块定义许多标准函数

用户可以通过一次或多次声明 -I PATH 选项,给 opslang 命令行工具添加自己的路径,比如:

opslang -I /foo/bar -I /blah/ ...

通过 -I 选项添加的新路径将被追加到现有搜索路径上。 请注意路径的顺序是有意义的。 Ops 运行时会直接使用第一个匹配上的路径,忽略其它的。

回到目录

Std 模块

Ops 编译器自带一个标准 Ops 模块,叫做 std。此 std 模块定义了许多标准函数,缺省的时候会自动装载到 Ops 程序中,除非通过命令行工具 opslang 的选项 --no-std-lib 关闭之。

回到目录

输出模块符号

模块中定义的类似 目标, 用户动作,和 用户例外这些符号,可以输出给模块的装载器。 方法是给对应的用户动作、用户例外或者目标等的声明添加 is export 性质。如:

module foo;

action do-this () is export {
    say("do this!");
}

然后在住程序文件 main.ops 里,我们可以装载这个模块并且马上开始使用输出的用户动作 do-this()

use foo;

goal all {
    run {
        do-this();
    }
}

运行这个程序生成如下输出:

do this!

不管一个符号是否输出,我们都可以用其全称引用一个装载上来的模块的符号。 比如前面的例子:

use foo;

goal all {
    run {
        foo.do-this();
    }
}

输出目标也类似:

module foo;

goal foo (Str $name): bar, baz is export {
    run {
        say("running foo: $name!");
    }
}

goal bar {
    run {}
}

goal baz {
    run {}
}

请注意一个输出目标的依赖关系并不自动输出。 如果你需要输出这些依赖关系,那么必须给那些目标都加上 is export 性质。

要输出一个用户例外,我们可以写:

exception foo is export;

请注意不要输出太多符号,因为会污染装载者的名字空间。

回到目录

命令行接口

TODO

回到目录

Lua API

TODO

回到目录

标准函数

Ops 语言提供丰富的标准函数集。这些函数有些是通过编译器直接内置的, 其它一些是通过 标准模块实现的。

用户可以用自己的同名用户动作覆盖这些内置函数。 不过通常不建议这么做,因为有可能破坏 Ops 语言的内核。

下面是字母顺序的所有标准函数的文档。

回到目录

append-to-path

assert

assert-host

assert-user

bad-cmd-out

basename

break

ceil

centos-ver

chdir

check-git-uncommitted-changes

chomp

clear-prompt-host

clear-prompt-user

closed

cmd-out

ctrl-c

ctrl-d

ctrl-v

ctrl-z

cwd

deb-codename

default-read-timeout

default-send-timeout

defined

die

dir-exists

do-ctrl-c

done

echoed-out

elems

error

exit

exit-code

exit-sh

failed

false

file-exists

file-modification-time

file-not-empty

file-size

floor

found-prompt

goto

home-dir

hostname

is-regular-file

join

new-screen

nil

nok

nop

now

ok

os-is-amazon

os-is-deb

os-is-debian

os-is-fedora

os-is-redhat

os-is-ubuntu

out

pop

pop-prompt

prepend-to-path

print

prompt

prompt-host

prompt-user

push

push-prompt

raw-out

read-timeout

redhat-major-ver

redo

return

say

send-cmd

send-key

send-text

send-timeout

setup-sh

sh-var

shift

sleep

strlen

substr

switch-screen

throw

timeout

to-int

to-num

too-many-tries

too-much-out

true

unshift

warn

whoami

with-std-prompt

回到目录

作者

章亦春 <yichun@openresty.com>, OpenResty Inc.

回到目录

版权 & 许可证

Copyright (C) 2019 by OpenResty Inc. All rights reserved.

本文档是专属文档,包含商业机密信息。任何时候均禁止未经版权所有者书面许可重新分发本文档。

回到目录