CMDR05-Tricks-Walks-Hooks

暂时来讲,这是最后一篇关于 cmdr 的系列介绍文章了。 所有这个系列包括: 另一个go命令行参数处理器 - cmdrcmdr 02 - 复刻一个 wgetcmdr 03 - 用流式接口定义命令行参数处理选项cmdr 04 - 简单微服务cmdr 05 - 扫尾 - Tricks/Walks/Hooks这一次的内容算是杂烩乱炖。 Tricks~~debug已经在前文讲述过了。这里不再凑字数了。 --treecmdr 提供了一个内置的选项:--tree。 虽然这是一个选项,但它和 --version 一样是有着命令一样的效果:如果 cmdr 在命令行参数中检测到了 --tree,那么它会忽略已经处理的和将要处理的子命令、选项,直接执行 --tree 的 Action。 要想达到类似的效果并不困难:定义一个选项,重载其 Action 字段到一个响应函数,并且在该响应函数的结尾返回 cmdr.ErrShouldBeStopException,这样就会在该选项被识别时并执行Action后直接退出应用程序了。 --tree 的功能是打印出全部命令和子命令,以树结构方式呈现出来。 一个样例如下图: 这是我在开发阶段执行 examples/demo 小程序所得到的结果。Walk for all commands--tree 实际上是利用了 cmdr 内建的 WalkAllCommands() 所提供的遍历方式。 对所有命令及其选项进行遍历,实际上有两种方式:一是利用 Painter 以及相应的内部机制,二是通过 WalkAllCommands 明确地遍历。 PainterPainter 是一个接口。它被用在输出帮助屏这个方面。尽管输出帮助屏只是一个小小的功能,但你还是可以自定义它的行为。你可以自行实现 Painter 接口并通过 SetCurrentHelpPainter(painter) 来更改帮助屏的显示内容。 如果你真的想这么做,可以查阅 Painter 的定义,也可以 issue 到我,或许说不定我能够有所建议。 ...

June 16, 2019 · 2 min · jiezi

cmdr-04-简单微服务-daemon

cmdr 04 - simple micro-service based on cmdr v0.2.21My ado is too much. 所以这次直入主题,谢绝吐槽。不知道 cmdr 干嘛用的,无妨看看前文 另一个go命令行参数处理器 - cmdrcmdr 02 - 复刻一个 wgetcmdr 03 - 用流式接口定义命令行参数处理选项那么,golang适合做后端开发,无论是 gRPC 还是 RESTful 都是它的强项。 一旦我们想要开发一个微服务时,抛开核心逻辑不谈,也不论 DevOps 方面究竟是 K8s,还是 Docker,还是裸机,总要面对一个启动、调试、测试的日常问题。 cmdr 除了提供命令行参数的解释能力之外,也额外提供了一个daemon插件,它可以帮助你简化日常开发工作,也令你不必关心 pid 文件、日志、退出信号等等问题,也无需重复编排 daemon 相关的命令行指令。 下面介绍怎么使用 daemon 插件,怎么编写实际的业务逻辑。我们以 demo 为例编写一个简单的示例性微服务,并解释具体的做法。 使用 Daemon 插件启用 Daemon 插件启用 Daemon 插件只需一行代码: // Entry is app main entryfunc Entry() { logrus.SetLevel(logrus.DebugLevel) logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true}) daemon.Enable(svr.NewDaemon(), nil, nil, nil) if err := cmdr.Exec(rootCmd); err != nil { logrus.Errorf("Error: %v", err) }}实现 daemon.Daemon 接口启用 daemon 插件,需要你实现 daemon.Daemon 接口,并编写一定的包装代码来连接 cmdr, daemon 以及你的业务逻辑。 ...

June 3, 2019 · 3 min · jiezi

cmdr-03-用流式接口定义命令行参数处理选项

cmdr 03 - 用流式接口定义命令行参数处理选项基于 v0.2.17 转眼已经来到了 cmdr v0.2.17 了,为了解决此前版本中关于子命令和选项定义语句的太多嵌套的问题,我们实现了流式调用接口(Fluent APIs)。 cmdr 是我发布的一个开源的 golang 命令行参数处理器。它是 golang flags 的替代品。之所以发布它,是因为已有的 command line UI 三方包无法满足我的日常要求,迫不得己自己造一个。如果尚未有了解 cmdr 怎么使用的,不妨抽空浏览我的早前文章,以求获得一些基本概念: 另一个go命令行参数处理器 - cmdrcmdr 02 - 复刻一个 wget稍后我会继续针对 cmdr 的用法做介绍文章。 至于本文呢 ,只是简单讲述一下如何使用 cmdr 的流式接口(Fluent API)来完成定义。 定义 RootCommandroot := cmdr.Root("aa", "1.0.1").Header("aa - test for cmdr - no version")rootCmd = root.RootCommand()第二句是拿到一个 *cmdr.Command 指针,稍后可以做一下相关的其它操作。 此外,rootCmd 作为函数返回值,也便于被用到向 cmdr.Exec() 做传递参数。 func buildCmds() *cmdr.Command { root := ... rootCmdr = root.RootCommand() ... return rootCmdr}func main() { if err := cmdr.Exec(buildCmds()); err != nil { logrus.Fatal(err) }}定义命令 Command顶级的命令其实就是 RootCommand 的 子命令,所以: ...

June 1, 2019 · 2 min · jiezi

cmdr-02-复刻一个-wget

cmdr 02 - Covered for wget 基于 cmdr v0.2.11继 Getting Start 之后,我们来介绍如何用 cmdr 复刻一个 wget 的命令行界面,并具体介绍 Command 和 Flag 的各个细节以及 cmdr 能够做到哪些别人做不到的事。 此外,我们也声明一下,Getting Start ('另一个go命令行参数处理器 - cmdr') 的内容有了一些轻微的变化,因为这两周来,我们已经不停地增加了很多特性来完善 cmdr 的能力,期间有一些不恰当的策略、衍生的命名、采用的算法都有所调整,虽然尽力避免变化,但它是不可免的。我们是期望给你的编程界面越来越完美,让整个编写的流程流畅化,自然化。 wget 的参数wget 本身是一个 GNU 应用程序。它的命令行参数有长有短,短参数可能有两个字符,此外参数被分为若干个分组。请看一部分截取: 这将是我们复刻的基准。 cmdr 都能做到些什么 - First我们曾经做过多个应用,不同的开发语言,不同的目标,有的是练练手,有的是眼前有个事情有点烦、不好处理、一怒之下就干,有的是有特定的目的例如一个RESTful服务,等等。 所以,要想满足那么多的情况下命令行参数的组织和设定都能被很好地表示,不夸张地说,迄今数十年来,我们没有找到一个命令参数解释器能够完成这个任务。把时间限定在最近几年,把开发语言限定在 Golang,C++,Python 等几种之内,依然没有谁真的能这么称呼自己。现有的命令行参数解释器都有这样那样的不如意: 短参数不能重复,哪怕是在多级命令结构下也必须全局唯一;不能分组;分组后顺序随机或者字母序,开发者无法干预,无法按照自己的意愿提供最好的顺序;短参数需要两个字母、或者三个字母的缩略语,更能表达参数原意时,基本上大多数现有的命令行参数解释器都废了;想要长参数显示为“--progress=TYPE”的式样,其中的 TYPE 还可以被复用;想要 git -m 的效果,结果费尽了力,终于实现了一个,然而受制于既有命令行解释器的结构,实现的坑坑洼洼的,自己都难以满意;想要和配置文件挂钩,没错挂钩了,然而需要写很多代码来安排;想要 /etc/program 加载配置文件,结果累了;想要 /etc/nginx/sites.avaliable 那样的效果,自己 watch 了,却合并不了新的配置到已经加载和构建好的配置中,也无法有效地通知应用的业务层按需取用新的配置条目;还有很多遇到这些情况时,多数时候只能忍了,毕竟没有太多精力专门去搞参数问题,还有大把的业务需要去完成的对吧。 cmdr 选择和实现 wget-demo 也是为了展示自己大体上能够解决命令行参数处理的多数问题。不过和其它命令行参数的策略不同地在于:别人通常会对参数值的类型做很多文章,例如支持 string/int/slice/map 的多种式样,或者提供 validator,或者采用 Golang 结构 Tag 方式来挂钩参数类型处理器等等。但是 cmdr 在参数类型方面只能说有且够,整体的重心并不在这些方面。 ...

May 30, 2019 · 5 min · jiezi

另一个go命令行参数处理器-cmdr

cmdr 是另一个命令行参数处理器(Golang)。 Golang 自己带有 flags 进行命令行参数处理,算是便利的,然而和 Google 一贯的做法相同,非常独,非常反人类。 在计算机人机交互界面的历史上,命令行的交互方式只有一种是贯穿始终,得到传承和延续的,那就是 getopt 以及 getopt_long。说起 getopt 来也可以讲述一个怪长的故事,然而本文不做此打算。无论如何,你需要知道的就是,getopt及其交互界面已经是POSIX的一部分,一个卓有成效的程序员、开发者、科学家,或者计算机从业者,对于这个界面都已经是训练有素,无需成本了。你可能在用着它,但你或许只是没有意识到它的存在而已。GNU的大部分命令行小刀都采用了这样的界面,所以,例如tar, gwk, gzip, ls, rm, …,以及无法列举的那些工具都是这样的界面。 所以,自行其是,自己搞一套,并非不可以。但我可以不买账。 那么,这并非我独自一人的自赏。我们只需要知道,在 Golang 的开源圈子里,已经有了数十种 getopt-like 的复刻本,用以为 Golang 开发的应用程序提供更好的命令行界面。这里面不乏 viper/cobra, cli 那样的巨作,也有一些小巧精干的实现。 cmdr 也是这么一个 getopt-like 的实现。和已有的其它实现不同之处在于,cmdr基本上原样复制了 getopt 的表现。也就是说,一个典型的 Unix/Linux 应用程序,例如 cp,mv 等等,是怎么做的,那么基于 cmdr 的应用程序也就是怎么做的。这里讲的当然是关于命令行参数怎么被解释的问题,而非应用程序的具体逻辑。 让我们来看看都有哪些具体方面。 POSIX 约定POSIX 表示可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是 IEEE(电气和电子工程师协会,Institute of Electrical and Electronics Engineers)为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称,其正式称呼为IEEE Std 1003,而国际标准名称为ISO/IEC 9945。此标准源于一个大约开始于1985年的项目。POSIX这个名称是由 理查德·斯托曼(RMS)应IEEE的要求而提议的一个易于记忆的名称。它基本上是Portable Operating System Interface(可移植操作系统接口)的缩写,而X则表明其对Unix API的传承。 电气和电子工程师协会(Institute of Electrical and Electronics Engineers,IEEE)最初开发 POSIX 标准,是为了提高 UNIX 环境下应用程序的可移植性。然而,POSIX 并不局限于 UNIX。许多其它的操作系统,例如 DEC OpenVMS 和 Microsoft Windows NT,都支持 POSIX 标准。 ...

May 15, 2019 · 4 min · jiezi

python模块之getopt脚本参数解析

getopt模块用于解析脚本参数。 getopt.getopt(args, shortopts, longopts=[])解析命令行选项及参数列表。 args:要解析的参数列表,但不包括当前执行的python脚本名称,一般等同于sys.argv[1:]。 shortopts:要识别的短选项字符串,如果后接:表示需要给定参数。如ab:c:,表示识别-a, -b和-c的短选项,其中-b和-c需要后接参数。如果不需要短选项,可以设置为空字符串。 longopts:要识别的长选项列表(不包括--前缀),长选项如果后接=表示需要给定参数,不支持可选参数。如["help", "user=", "password="],表示识别--help, --user=root, --password=123456的长选项。 函数返回值由两个元素组成。第一个是(option, value)元组的列表,第二个是args剥离短选项及其参数和长选项及其参数之后剩余的参数列表。(option, value)元组中的option表示包含-或--前缀的选项,value表示该option对应的参数,可以为空字符串表示无参数。 import getopt# 模拟向MySQL的test库导入tb_country.sql的命令行args = "-uroot -p --host=127.0.0.1 --port=3306 --verbose -Dtest < tb_country.sql".split()options, arg = getopt.getopt(args, "u:pD:", ["host=", "port=", "verbose"])print(arg) # ['<', 'tb_country.sql']for option in options: print(option)# ('-u', 'root')# ('-p', '')# ('--host', '127.0.0.1')# ('--port', '3306')# ('--verbose', '')# ('-D', 'test')一旦遇到非选项参数,将停止选项解析,从该非选项参数开始的所有参数全部视为返回值中的第二个元素 import getoptargs = "-a root 123456 -b --opt1 --opt2".split()options, arg = getopt.getopt(args, "ab", ["opt1", "opt2"])print(arg) # ['root', '123456', '-b', '--opt1', '--opt2']print(options) # [('-a', '')]getopt.gnu_getopt(args, shortopts, longopts=[])和getopt()类似,但默认使用GNU风格的选项解析模式,这意味着选项参数和非选项参数可以混合,而getopt()会在遇到第一个非选项参数时停止解析。 ...

May 8, 2019 · 1 min · jiezi

如何编写简单的parser(实践篇)

上一篇(《如何编写简单的parser(基础篇)》)中介绍了编写一个parser所需具备的基础知识,接下来,我们要动手实践一个简单的parser,既然是“简单”的parser,那么,我们就要为这个parser划定范围,否则,完整的JavaScript语言parser的复杂度就不是那么简单的了。划定范围基于能够编写简单实用的JavaScript程序和具备基础语法的解释能力这两点考虑,我们将parser的规则范围划分如下:声明:变量声明 & 函数声明赋值:赋值操作 (& 左表达式)加减乘除:加减操作 & 乘除操作条件判断:if语句如果用一句话来划分的话,即一个能解析包括声明、赋值、加减乘除、条件判断的解析器。功能划分基于上一篇中介绍的JavaScript语言由词组(token)组成表达式(expression),由表达式组成语句(statement)的模式,我们将parser划分为——负责解析词法的TokenSteam模块,负责解析表达式和语句的Parser,另外,负责记录读取代码位置的InputSteam模块。这里,有两点需要进行说明:由于我们这里包含的expression解析类型和statement的解析类型都不多,所以,我们使用一个parser模块来统一解析,但是在如babel-parser这类完整的parser中,是将expression和statement拆开进行解析的,这里的逻辑仅供参考;另外,这里对词法的解析是逐字进行解析,并没有使用正则表达式进行匹配解析,因为在完整度高的parser中,使用正则匹配词法会提高整体的复杂度。InputSteamInputSteam负责读取和记录当前代码的位置,并把读取到的代码交给TokenSteam处理,其意义在于,当传递给TokenSteam的代码需要进行判读猜测时,能够记录当前读取的位置,并在接下来的操作汇总回滚到之前的读取位置,也能在发生语法错误时,准确指出错误发生在代码段的第几行第几个字符。该模块是功能最简洁的模块,我们只需创建一个类似“流”的对象即可,其中主要包含以下几个方法:peek() —— 阅读下一个代码,但是不会将当前读取位置迁移,主要用于存在不确定性情况下的判读;next() —— 阅读下一个代码,并移动读取位置到下一个代码,主要用于确定性的语法读取;eof() —— 判断是否到当前代码的结束部分;croak(msg) —— 抛出读取代码的错误。接下来,我们看一下这几个方法的实现:function InputStream(input) { var pos = 0, line = 1, col = 0; return { next : next, peek : peek, eof : eof, croak : croak, }; function next() { var ch = input.charAt(pos++); if (ch == “\n”) line++, col = 0; else col++; return ch; } function peek() { return input.charAt(pos); } function eof() { return peek() == “”; } function croak(msg) { throw new Error(msg + " (" + line + “:” + col + “)”); }}TokenSteam我们依据一开始划定的规则范围 —— 一个能解析包括声明、赋值、加减乘除、条件判断的解析器,来给TokenSteam划定词法解析的范围:变量声明 & 函数声明:包含了变量、“var”关键字、“function”关键字、“{}”符号、“()”符号、“,”符号的识别;赋值操作:包含了“=”操作符的识别;加减操作 & 乘除操作:包含了“+”、“-”、“”、“/”操作符的识别;if语句:包含了“if”关键字的识别;字面量(毕竟没有字面量也没办法赋值):包括了数字字面量和字符串字面量。接下来,TokenSteam主要使用InputSteam读取并判读代码,将代码段解析为符合ECMAScript标准的词组流,返回的词组流大致如下:{ type: “punc”, value: “(” } // 符号,包含了()、{}、,{ type: “num”, value: 5 } // 数字字面量{ type: “str”, value: “Hello World!” } // 字符串字面量{ type: “kw”, value: “function” } // 关键字,包含了function、var、if{ type: “var”, value: “a” } // 标识符/变量{ type: “op”, value: “!=” } // 操作符,包含+、-、、/、=其中,不包含空白符和注释,空白符用于分隔词组,对于已经解析了的词组流来说并无意义,至于注释,在我们简单的parser中,就不需要解析注释来提高复杂度了。有了需要判读的词组,我们只需根据ECMAScript标准的定义,进行适当的简化,便能抽取出对应词组需要的判读规则,大致逻辑如下:首先,跳过空白符;如果input.eof()返回true,则结束判读;如果input.peek()返回是一个“"”,接下来,读取一个字符串字面量;如果input.peek()返回是一个数字,接下来,读取一个数字字面量;如果input.peek()返回是一个字母,接下来,读取的可能是一个标识符,也可能是一个关键字;如果input.peek()返回是标点符号中的一个,接下来,读取一个标点符号;如果input.peek()返回是操作符中的一个,接下来,读取一个操作符;如果没有匹配以上的条件,则使用input.croak()抛出一个语法错误。以上的,即是TokenSteam工作的主要逻辑了,我们只需不断重复以上的判断,即能成功将一段代码,解析成为词组流了,将该逻辑整理为代码如下:function read_next() { read_while(is_whitespace); if (input.eof()) return null; var ch = input.peek(); if (ch == ‘"’) return read_string(); if (is_digit(ch)) return read_number(); if (is_id_start(ch)) return read_ident(); if (is_punc(ch)) return { type : “punc”, value : input.next() }; if (is_op_char(ch)) return { type : “op”, value : read_while(is_op_char) }; input.croak(“Can’t handle character: " + ch);}主逻辑类似于一个分发器(dispatcher),识别了接下来可能的工作之后,便将工作分发给对应的处理函数如read_string、read_number等,处理完成后,便将返回结果吐出。需要注意的是,我们并不需要一次将所有代码全部解析完成,每次我们只需将一个词组吐给parser模块进行处理即可,以避免还没有解析完词组,就出现了parser的错误。为了使大家更清晰的明确词法解析器的工作,我们列出数字字面量的解析逻辑如下:// 使用正则来判读数字function is_digit(ch) { return /[0-9]/i.test(ch);}// 读取数字字面量function read_number() { var has_dot = false; var number = read_while(function(ch){ if (ch == “.”) { if (has_dot) return false; has_dot = true; return true; } return is_digit(ch); }); return { type: “num”, value: parseFloat(number) };}其中read_while函数在主逻辑和数字字面量中都出现了,该函数主要负责读取符合格则的一系列代码,该函数的代码如下:function read_while(predicate) { var str = “”; while (!input.eof() && predicate(input.peek())) str += input.next(); return str;}最后,TokenSteam需要将解析的词组吐给Parser模块进行处理,我们通过next()方法,将读取下一个词组的功能暴露给parser模块,另外,类似TokenSteam需要判读下一个代码的功能,parser模块在解析表达式和语句的时候,也需要通过下一个词组的类型来判读解析表达式和语句的类型,我们将该方法也命名为peek()。function TokenStream(input) { var current = null; function peek() { return current || (current = read_next()); } function next() { var tok = current; current = null; return tok || read_next(); } function eof() { return peek() == null; } // 主代码逻辑 function read_next() { //…. } // … return { next : next, peek : peek, eof : eof, croak : input.croak }; }在next()函数中,需要注意的是,因为有可能在之前的peek()判读中,已经调用read_next()来进行判读了,所以,需要用一个current变量来保存当前正在读的词组,以便在调用next()的时候,将其吐出。Parser最后,在Parser模块中,我们对TokenSteam模块读取的词组进行解析,这里,我们先讲一下最后Parser模块输出的内容,也就是上一篇当中讲到的抽象语法树(AST),这里,我们依然参考babel-parser的AST语法标准,在该标准中,代码段都是被包裹在Program节点中的(其实也是大部分AST标准的模式),这也为我们Parser模块的工作指明了方向,即自顶向下的解析模式:function parse_toplevel() { var prog = []; while (!input.eof()) { prog.push(parse_statement()); } return { type: “prog”, prog: prog };}该parse_toplevel函数,即是Parser模块的主逻辑了,逻辑也很简单,代码段既然是有语句(statements)组成的,那么我们就不停地将词组流解析为语句即可。parse_statement和TokenSteam类似的是,parse_statement也是一个类似于分发器(dispatcher)的函数,我们根据一个词组来判读接下来的工作:function parse_statement() { if(is_punc(”;")) skip_punc(";"); else if (is_punc("{")) return parse_block(); else if (is_kw(“var”)) return parse_var_statement(); else if (is_kw(“if”)) return parse_if_statement(); else if (is_kw(“function”)) return parse_func_statement(); else if (is_kw(“return”)) return parse_ret_statement(); else return parse_expression();}当然,这样的分发模式,也是只限定于我们在最开始划定的规则范围,得益于规则范围小的优势,parse_statement函数的逻辑得以简化,另外,虽然语句(statements)是由表达式(expressions)组成的,但是,表达式(expression)依然能单独存在于代码块中,所以,在parse_statement的最后,不符合所有语句条件的情况,我们还是以表达式进行解析。parse_function在语句的解析中,我们拿函数的的解析来作一个例子,依据AST标准的定义以及ECMAScript标准的定义,函数的解析规则变得很简单:function parse_function(isExpression) { skip_kw(“function”); return { type: isExpression?“FunctionExpression”:“FunctionDeclaration”, id: is_punc("(")?null:parse_identifier(), params: delimited("(", “)”, “,”, parse_identifier), body: parse_block() };}对于函数的定义:首先一定是以关键字“function”开头;其后,若是匿名函数,则没有函数名标识符,否则,则解析一个标识符;接下来,则是函数的参数,包含在一对“()”中,以“,”间隔;最后,即是函数的函数体。在代码中,解析参数的函数delimited是依据传入规则,在起始符与结束符之间,以间隔符隔断的代码段来进行解析的函数,其代码如下:function delimited(start, stop, separator, parser) { var res = [], first = true; skip_punc(start); while (!input.eof()) { if (is_punc(stop)) break; if (first) first = false; else skip_punc(separator); if (is_punc(stop)) break; res.push(parser()); } skip_punc(stop); return res;}至于函数体的解析,就比较简单了,因为函数体即是多段语句,和程序体的解析是一致的,ECMAScript标准的定义也很清晰:function parse_block() { var body = []; skip_punc("{"); while (!is_punc("}")) { var sts = parse_statement() sts && body.push(sts); } skip_punc("}"); return { type: “BlockStatement”, body: body }}parse_atom & parse_expression接下来,语句的解析能力具备了,该轮到解析表达式了,这部分,也是整个Parser比较难理解的一部分,这也是为什么将这部分放到最后的原因。因为在解析表达式的时候,会遇到一些不确定的过程,比如以下的代码:(function(a){return a;})(a)当我们解析完成第一对“()”中的函数表达式后,如果此时直接返回一个函数表达式,那么后面的一对括号,则会被解析为单独的标识符。显然这样的解析模式是不符合JavaScript语言的解析模式的,这时,往往我们需要在解析完一个表达式后,继续往后进行尝试性的解析。这一点,在parse_atom和parse_expression中都有所体现。回到正题,parse_atom也是一个分发器(dispatcher),主要负责表达式层面上的解析分发,主要逻辑如下:function parse_atom() { return maybe_call(function(){ if (is_punc("(")) { input.next(); var exp = parse_expression(); skip_punc(")"); return exp; } if (is_kw(“function”)) return parse_function(true) var tok = input.next(); if (tok.type == “var” || tok.type == “num” || tok.type == “str”) return tok; unexpected(); });}该函数一开头便是以一个猜测性的maybe_call函数开头,正如上我们解释的原因,maybe_call主要是对于调用表达式的一个猜测,一会我们在来看这个maybe_call的实现。parse_atom识别了位于“()”符号中的表达式、函数表达式、标识符、数字和字符串字面量,若都不符合以上要求,则会抛出一个语法错误。parse_expression的实现,主要处理了我们在最开始规则中定义的加减乘除操作的规则,具体实现如下:function parse_expression() { return maybe_call(function(){ return maybe_binary(parse_atom(), 0); });}这里又出现了一个maybe_binary的函数,该函数主要处理了加减乘除的操作,这里看到maybe开头,便能知道,这里也有不确定的判断因素,所以,接下来,我们统一讲一下这些maybe开头的函数。maybe_这些以maybe开头的函数,如我们以上讲的,为了处理表达式的不确定性,需要向表达式后续的语法进行试探性的解析。maybe_call函数的处理非常简单,它接收一个用于解析当前表达式的函数,并对该表达式后续词组进行判读,如果后续词组是一个“(”符号词组,那么该表达式一定是一个调用表达式(CallExpression),那么,我们就将其交给parse_call函数来进行处理,这里,我们又用到之前分隔解析的函数delimited。// 推测表达式是否为调用表达式function maybe_call(expr) { expr = expr(); return is_punc("(") ? parse_call(expr) : expr;}// 解析调用表达式function parse_call(func) { return { type: “call”, func: func, args: delimited("(", “)”, “,”, parse_expression), };}由于解析加、减、乘、除操作时,涉及到不同操作符的优先级,不能使用正常的从左至右进行解析,使用了一种二元表达式的模式进行解析,一个二元表达式包含了一个左值,一个右值,一个操作符,其中,左右值可以为其他的表达式,在后续的解析中,我们就能根据操作符的优先级,来决定二元的树状结构,而二元的树状结构,就决定了操作的优先级,具体的优先级和maybe_binary的代码如下:// 操作符的优先级,值越大,优先级越高var PRECEDENCE = { “=”: 1, “||”: 2, “&&”: 3, “<”: 7, “>”: 7, “<=”: 7, “>=”: 7, “==”: 7, “!=”: 7, “+”: 10, “-”: 10, “”: 20, “/”: 20, “%”: 20,};// 推测是否是二元表达式,即看该左值接下来是否是操作符function maybe_binary(left, my_prec) { var tok = is_op(); if (tok) { var his_prec = PRECEDENCE[tok.value]; if (his_prec > my_prec) { input.next(); return maybe_binary({ type : tok.value == “=” ? “assign” : “binary”, operator : tok.value, left : left, right : maybe_binary(parse_atom(), his_prec) }, my_prec); } } return left;}需要注意的是,maybe_binary是一个递归处理的函数,在返回之前,需要将当前的表达式以当前操作符的优先级进行二元表达式的解析,以便包含在另一个优先级较高的二元表达式中。为了让大家更方便理解二元的树状结构如何决定优先级,这里举两个例子:// 表达式一1+23// 表达式二12+3这两段加法乘法表达式使用上面的方法解析后,分别得到如下的AST:// 表达式一{ type : “binary”, operator : “+”, left : 1, right : { type: “binary”, operator: “”, left: 2, // 这里简化了左右值的结构 right: 3 }}// 表达式二{ type : “binary”, operator : “+”, left : { type : “binary”, operator : “”, left : 1, right : 2 }, right : 3}可以看到,经过优先级的处理后,优先级较为低的操作都被处理到了外层,而优先级高的部分,则被处理到了内部,如果你还感到迷惑的话,可以试着自己拿几个表达式进行处理,然后一步一步的追踪代码的执行过程,便能明白了。总结其实,说到底,简单的parser复杂度远比完整版的parser低很多,如果想要更进一步的话,可以尝试去阅读babel-parser的源码,相信,有了这两篇文章的铺垫,babel的源码阅读起来也会轻松不少。另外,在文章的最后,附上该篇文章的demo。参考几篇可以参考的原文,推荐大伙看看:《How to implement a programming language in JavaScript》(http://lisperator.net/pltut/)《Parsing in JavaScript: Tools and Libraries》(https://tomassetti.me/parsing…)标准以及文献:《ECMAScript® 2016 Language Specification》(http://www.ecma-international…)the core @babel/parser (babylon) AST node types(https://github.com/babel/babe…) ...

January 30, 2019 · 4 min · jiezi

如何编写简单的parser(基础篇)

什么是parser?简单的说,parser的工作即是将代码片段转换成计算机可读的数据结构的过程。这个“计算机可读的数据结构”更专业的说法是“抽象语法树(abstract syntax tree)”,简称AST。AST是代码片段具体语义的抽象表达,它不包含该段代码的所有细节,比如缩进、换行这些细节,所以,我们可以使用parser转换出AST,却不能使用AST还原出“原”代码,当然,可以还原出语义一致的代码,就如同将ES6语法的js代码转换成ES5的代码。parser的结构一般来说,一个parser会由两部分组成:词法解析器(lexer/scanner/tokenizer)对应语法的解释器(parser)在解释某段代码的时候,先由词法解释器将代码段转化成一个一个的词组流(token),再交由解释器对词组流进行语法解释,转化为对应语法的抽象解释,即是AST了。为了让大家更清楚的理解parser两部分的工作顺序,我们通过一个例子来进行说明:437 + 734在parser解析如上的计算表达式时,词法解析器首先依次扫描到“4”、“3”、“7”直到一个空白符,这时,词法解析器便将之前扫描到的数字组成一个类型为“NUM”的词组(token);接下来,词法解析器继续向下扫描,扫描到了一个“+”,对应输出一个类型为“PLUS”的词组(token);最后,扫描“7”、“3”、“4”输出另一个类型为“NUM”的词组(token)。语法解释器在拿到词法解析器输出的词组流后,根据词组流的“NUM”,“PLUS”,“NUM”的排列顺序,解析成为加法表达式。由上的例子我们可以看出,词法解析器根据一定的规则对字符串进行解析并输出为词组(token),具体表现为连续不断的数字组合(“4”、“3”、“7”和“7”、“3”、“4”)即代表了数字类型的词组;语法解释器同样根据一定的规则对词组的组合进行解析,并输出对应的表达式或语句。在这里,词法解析器应用的规则即为词汇语法(Lexical Grammar)的定义,语法解释器应用的规则即为表达式(Expressions)、语句(Statements)、声明(Declarations)和函数(Functions)等的定义。ECMAScript标准看到这里大家可能会感觉到奇怪,为什么讲parser讲的好好的,又跑到ECMAScript标准上来了呢?因为以上提到的词汇语法(Lexical Grammar)、表达式(Expressions)、语句(Statements)、声明(Declarations)和函数(Functions)等都是ECMAScript标准中的所定义的,这其实也是ECMAScript标准的作用之一,即定义JavaScript的标准语法。词汇词法(Lexical Grammar)ECMAScript的词汇词法规定了JavaScript中的基础语法,比如哪些字符代表了空白(White Space),哪些字符代表了一行终止(Line Terminators),哪些字符的组合代表了注释(Comments)等。具体的规定说明,可以在ECMAScript标准11章中找到。这里我们不仔细研读每个语法的定义,只需知道词法解析器(lexer)判读词组(token)的依据来源于此即可,为了让大家有一定的了解,这里,我们拿上面例子中的数字字面量(Numeric Literals)来进行说明:ECMAScript标准中,对数字字面量的定义如下:该定义需要自上向下解读:首先,规则定义了数字字面量(Numeric Literal)可以是十进制字面量(Decimal Literal)、二进制整数字面量(Binary Integer Literal)、八进制整数字面量(Octal Integer Literal)、十六进制整数字面量(Hex Integer Literal);在我们的例子中,我们只关心十进制的字面量,所以,接下来,规则定义十进制字面量(Decimal Literal)可以是包含小数点与不包含小数点的组合,这里我们只需关注不包含小数点的定义,即十进制整数字面量(Decimal Integer Literal) + 可选的指数部分(Exponent Part);最后,规则定义十进制整数字母量由非零数字(Non Zero Digit)+ 十进制数字(Decimal Digit)或十进制数字组(Decimal Digits)组成,非零数字是由19的数字组成,十进制数字是由09组成。将上面的定义重新整合后,就能得到我们需要的数字字面量的定义规则:非零数字(19)+十进制数字组(09)需要注意的是,这是简化版的数字字面量定义,完整版的需要加上以上规则中的所有分支条件。表达式(Expressions)、语句(Statements)ECMAScript标准12~13章包含了表达式和语句的相关定义,之前由词法解析器(lexer)处理后生成的词组流(token)交由语法解释器(parser)处理的主要内容,即是处理词组流构成的表达式与语句。在这里,我们需要稍加明确一下表达式与语句之间的不同与关系:首先,语句包含表达式,大部分语句是由关键字+表达式或语句组成,而表达式则是由字面量(Literal)、标识符(Identifier)、符号(Punctuators)等低一级的词组组成;其次,表达式一般来讲会产生一个值,而语句不总有值。理解第一点对于我们写语法解释器很重要,由于语句是由表达式组成的,而表达式是有词组组成的,词组是有词法解析器进行解析生成的,所以,在语法解释器中,将以表达式为切入点,由表达式解析再深入到语句解析中。抽象语法树(AST)了解一个parser的结构,以及parser解析语法所依赖的规则后,接下来,我们需要了解一下一个parser所生产出来的结果——抽象语法树。在文章的开头,我有简单的解释抽象语法树即是具体代码片段的抽象表达,那它具体是长什么样的呢?function sum (a , b) { return a+b;}以上的代码片段,AST树的描述如下(使用babylon7-7.0.0-beta.44,结果进行了简化):{ “type”: “Program”, “body”: [ { “type”: “FunctionDeclaration”, “id”: { “type”: “Identifier”, “name”: “sum” }, “params”: [ { “type”: “Identifier”, “name”: “a” }, { “type”: “Identifier”, “name”: “b” } ], “body”: { “type”: “BlockStatement”, “body”: [ { “type”: “ReturnStatement”, “argument”: { “type”: “BinaryExpression”, “left”: { “type”: “Identifier”, “name”: “a” }, “operator”: “+”, “right”: { “type”: “Identifier”, “name”: “b” } } } ] } } ]}对该AST仔细观察一番,便会明白,AST其实即是我们在已经ECMAScript标准对代码进行解析后,将标识符(identifier)、声明(declaration)、表达式(expression)、语句(statement)等按代码表述的逻辑整理成为树状结构。就拿上面的例子来说,当语法解析器识别了一个二元表达式(Binary Expression),便将这个二元表达式所携带的信息——左值,右值,操作符按照固定的计算机可读的数据格式保存下来,即是我们看到的AST树了。当然,AST也需要具备固定的格式,这样计算机才能依照该格式阅读AST并进行接下来的编译工作,当然,有一些AST也被用来转义(如babel)。关于AST定义的规则,我们可以参考babel的定义,这也是后面我们实现parser时,所参考的标准。接下来理解完以上相关的知识,我们便具备编写一个parser的先决条件了,那在下一章,我们将实际操作一番,编写一个简易版本的JavaScript语言parser。 ...

January 22, 2019 · 1 min · jiezi