前言

前些天偶然看到以前写的一份代码,注意有一段尘封的代码,被我遗忘了。这段代码是一个简单的解析器,当时是为了解析日志而做的。最初解析日志时,我只是简单的正则加上分割,写着写着,我想,能不能用一个简单的方案做个解析器,这样可以解析多种日志。于是就有了这段代码,后来日志解析完了,没有解析其它日志就给忘了。再次看到这段代码,用非常简单易读的代码就实现了一个解析器,觉得非常值得分享。

通过本文读者将学到如何实现一门领域特定语言(DSL),并使用这种 DSL 完成文本解析任务,例如 JSON 解析、表达式求值等。

思路

言归正传,这个简单的解析器是怎么构思的呢?那要先从模式匹配开始。文本解析与模式匹配有很多相似之处,比如解析一个整数,跟匹配一个整数就是相似的,都需要根据整数的文法 0|[1-9]\d* 把文本中满足文法的部分找出来。不同的是,当说到解析整数的时候,我们希望得到的结果是一个整数,而不是一段文本。更进一步,如果我们已经知道如何解析整数,那么解析加减表达式又可以看到一些相似性。加减表达式的文法可以表示为 num ('+'|'-' num)*,就像文本模式匹配一样,需要对一些“东西”的到达顺序、重复次数、分支进行匹配。

从上面的描述可以看出,我们要做的是某种模式匹配。模式匹配的明星非正则表达式莫数。观察正则表达式,我们基本的需求都有啥?比如有,匹配一个字符或者按顺序出现的字符。好了,就从这两点出发。我们可以设计两个函数,一个 match 函数用来匹配单个字符,一个 seq 函数表示按顺序匹配。显然,多个 match 就可以形成 seq,所以 seq 是一个高阶函数。但是,seq 是直接组合 match 吗?当然不是,因为 match 需要一个参数,表示需要匹配的字符是哪个,所以 match 也是一个高阶函数,而 seq 需要 match 产生的函数组合成新函数,即 seq([match('a'), match('b'), match('c')])。那 matchseq 生成的函数是什么?是匹配器。允许我们直接对文本进行匹配:

const match_a = match('a');
match_a('abc'); const match_abc = seq([match('a'), match('b'), match('c')]);
match_abc('abc');

由于我们要组合函数,所以匹配器不是只简单返回 bool 值,这样组合过程中,状态会丢失。我们让匹配器返回两个值:一个是匹配后剩余的字符串;一个是匹配是否成功。于是我们就有了:

function match(ch) {
return src => {
if (src.startsWith(ch)) return [true, src.substring(1)];
return [false, src];
};
} // 顺序匹配,其中一个失败则整体失败
function seq(steps) {
return src => {
for (const step of steps) {
const [ok, rest] = step(src);
if (!ok) return [false, src];
src = rest;
}
return [true, src];
};
}

试试看。

const match_a = match('a');
console.log(match_a('abc')); // [true, "bc"]
console.log(match_a('def')); // [false, "def"] const match_abc = seq([match('a'), match('b'), match('c')]);
match_abc('abc');
console.log(match_abc('abc')); // [true, ""]
console.log(match_abc('abd')); // [false, "abd"]

妙极了!

拥抱正则表达式

接下来,我们可以在这个基础上实现正则表达式的其它模式,比如:

  1. alt。候选列表,列表中有一个匹配成功则成功,全部失败则整体失败。对应正则表达式中是 |[]
  2. opt。可选,对应正则表达的 ? 模式。
  3. many。多次重复,对应正则表达式中的 * 模式。

其它不一一列举,如果继续下去,应该能实现一个自己的正则表达式,但那不是我们的目标,我想先回到“解析”上。

观察 match 函数,它只能匹配一个字符,如果要匹配多个则要使用 seq 进行组合,不是很方便。既然正则表达式可以进行文本匹配,我们没有必要重复正则表达式的工作,直接利用它就好。所以,我们可以把正则表达式作为 match 的参数:

function match(pattern) {
const re = new RegExp(pattern, "y"); // 改为 sticky 模式
return src => {
re.lastIndex = 0; // 注意!一定要重置 `lastIndex`
const m = re.exec(src);
if (!m) throw new Error(`unexpected token '${src[0]}'`);
const rest = src.substring(re.lastIndex);
return [m[0], rest];
};
}

解析

前面说过,解析整数的结果是整数而不是长得像整数的字符串,因此,匹配器不能返回 bool,而应该是某个“结果”。所以匹配器的签名应该是 (src: string) => [any, string]。现在匹配器可以直接返回结果了,我们把它改名为“解析器”。要怎么把文本变成那个“结果”呢?可以在 match 后面加一个回调,表示匹配到了文本后,应该如何处理这个文本。显然,这个回调的输入参数是匹配到的文本,输出是“结果”,即 (token: string) => any。我们把这个回调函数取名为 action

如果现在尝试去改 match 函数,就会发现一个问题,seq 函数也返回“解析器”,但这个解析器执行后应该返回什么“结果”呢?为解决这个问题,我们也需要给 seq 函数添加 action,只不过 seq 函数的 action 的输入参数是一个列表。还有,匹配成功后,我也可以不做任何动作,此时把匹配的文本继续往下传即可。现在我们再修改代码:

function noop(tok){ return tok; }

// 用 `pattern` 匹配文本的开头
function match(pattern, action = noop) {
const re = new RegExp(pattern, "y");
return src => {
re.lastIndex = 0;
const m = re.exec(src);
if (!m) throw new Error(`unexpected token '${src[0]}'`);
const rest = src.substring(re.lastIndex);
return [action(m[0]), rest];
};
} // 顺序匹配,其中一个失败则整体失败
function seq(steps, action = noop) {
return src => {
const list = []; for (const step of steps) {
const [val, rest] = step(src); // `step` 函数会 `throw`,因此一个失败整体就会失败
src = rest;
list.push(val);
} return [action(list), src];
};
}

玩玩看:

const match_helloworld = seq([match(/hello/), match(/ /), match(/world/)]);

console.log(match_helloworld('hello world')); // [["hello", " ", "world"], ""]
console.log(match_helloworld('helloworld')); // Error: unexpected token 'w'

还是妙,但目前为止还算常规,跟正则表达式差不多。那么,接下来看下面这个例子:

const int = match(/0|[1-9]\d*/, Number); // 用整数的文法匹配文本,并把匹配到的文本直接转成 js 的 `number`

// 根据文法 `int('+'|'-')int` 进行匹配,并根据中间 token 进行计算
const Exp = seq([int, match(/\+|\-/), int], toks => {
if (toks[1] === '+') return toks[0] + toks[2]; // 因为 `int` 函数返回的是 `number`,所以这里可以直接进行计算!
if (toks[1] === '-') return toks[0] - toks[2];
return toks;
}); console.log(Exp('1+2')); // [3, ""]
console.log(Exp('5-3')); // [2, ""]

现在是不是更妙了。

完全体

光有 seq 可不能满足解析的全部需求,还得要把其它模式加进来,我就不一一说明其它模式的实现方法了,直接给出全部代码,相信看代码也很好理解:

function noop(tok){ return tok; }

// 原来的 `match` 函数,改了个名字
function token(pattern, action = noop) {
if (typeof pattern === 'string') pattern = pattern.replace(/[.*?+(){}|$\[\]\^\\]/g, g => "\\" + g[0]);
const re = new RegExp(pattern, "y");
return src => {
re.lastIndex = 0;
const m = re.exec(src);
if (!m) throw new Error(`unexpected token '${src[0]}'`);
const rest = src.substring(re.lastIndex);
return [action(m[0]), rest];
};
} // 顺序匹配,其中一个失败则整体失败
function seq(steps, action = noop) {
return src => {
const list = []; for (const step of steps) {
const [val, rest] = step(src); // `step` 函数会 `throw`,因此一个失败整体就会失败
src = rest;
list.push(val);
} return [action(list), src];
};
} // 可选匹配
function opt(step, action = noop) {
return (src) => {
try {
const [v, rest] = step(src);
return [action(v), rest];
} catch (err) {
return [undefined, src]; // 也许这里可以改成 `return [action(""), src]`;
}
};
} // 候选列表匹配,列表中其中一个匹配即可
function alt(steps, action = noop) {
return (src) => {
for (const step of steps) {
try {
const [v, rest] = step(src);
return [action(v), rest];
} catch {
// mute
}
} throw new Error(`unknown token ${src[0]}`);
};
} // 重复任意次数
function many(item, action = noop) {
return src => {
const list = [];
let val = undefined; while (true) {
try {
[val, src] = item(src);
list.push(val);
} catch {
break;
}
} return [action(list), src];
};
} // 带分隔符的重复,即满足 `item (sep item)*` 这种文法的
function many_sep(sep, item, action = noop) {
const etc = seq([sep, item], vs => vs[1]);
return seq([item, many(etc)], ([x, xs]) => action([x, ...xs]));
}

好了,直接攒个大活儿,咱们用这些东西解析 JSON 看看:

const ws = token(/\s*/);

const Sep = seq([token(','), ws], v => v[0]); // 也可以简写为 `token(/,\s*/)`

const Str = token(/"([^\\"]|\\[\\\/bfrtn"]|\\u[0-9a-fA-F]{4})*"/, JSON.parse); // 这里使用 `JSON.parse` 算是作弊,不过用它让演示代码缩短了不少

const Num = token(/-?(0|[1-9]\d*)(\.\d+)?([eE][\+\-]?\d+)?/, Number);

// Elems = Val (',' Val)*
const Elems = many_sep(Sep, src => Val(src)); // KVs = KV (',' KV)*
const KVs = many_sep(Sep, src => KV(src)); // Val = Str | Num | 'true' | 'false' | 'null' | '[' ws Elems ws ']' | '{' ws KVs ws '}'
const Val = alt([
Str,
Num,
token(/true|false/, token => token === 'true'),
token(/null/, _ => null),
seq([token('['), ws, Elems, ws, token(']')], v => v[2]), // '[' ws Elems ws ']'
seq([token('{'), ws, KVs, ws, token('}')], v => Object.fromEntries(v[2])), // '{' ws KVs ws '}'
]); // KV = Str ws ':' ws Val
const KV = seq([
Str,
ws,
token(':'),
ws,
Val,
], kv => [kv[0], kv[4]]); // 匹配 KV 列表,并转换成 `[[key, val]...]` 的形式 // 演示
console.log(Val('[1, 2.3e4, true, null, "\\t"]')); // [[1, 23000, true, null, " "], ""]
console.log(Val('{ "k1": 123, "k2": true }')); // [{k1: 123, k2: true}, ""]

怎么样,相当不错吧!!!

接下来再演示下进阶版本的表达式解析与求值,支持加、减、乘、除和括号:

// Factor = '(' ws Exp ws ')' | Num
const Factor = alt([
seq([token('('), ws, src => Exp(src), ws, token(')')], vals => vals[2]),
Num,
]); // Term = Factor (ws '*|/' ws Factor)*
const Term = seq([Factor, many(seq([ws, token(/\*|\//), ws, Factor]))], ([head, tail]) => {
return tail.reduce((acc, x) => {
if (x[1] === '*') return acc * x[3];
if (x[1] === '/') return acc / x[3];
return acc;
}, head);
}); // Exp = Term (ws '+|-' ws Term)*
const Exp = seq([Term, many(seq([ws, token(/\+|-/), ws, Term]))], ([head, tail]) => {
return tail.reduce((acc, x) => {
if (x[1] === '+') return acc + x[3];
if (x[1] === '-') return acc - x[3];
return acc;
}, head);
}); console.log(Exp('( 1 + 2 * 5 ) * 3')); // [33, ""]

总结

本文使用简单易懂的代码,实现了一组可以构造解析器的函数。相信通过本文的演示,你应该对解析器的基本工作原理有了一个浅浅的了解。实际上,我们实现的这一组函数是一种领域特定语言,即 DSL。虽然实现方式简单,但不妨碍这种 DSL 的实用性,在日常的小脚本中,更加体现它的小巧、易懂、功能强大。不过也要注意的是,我是以小巧、简易为目标实现的,所以性能不是首要目标,将本文的实现用于性能挑剔的场合肯定是不合适的。

JS Parser Combinator (解析器组合子)的更多相关文章

  1. 【译】通过 Rust 学习解析器组合器 — Part 1

    原文地址:Learning Parser Combinators With Rust 原文作者:Bodil 译文出自:掘金翻译计划 本文永久链接:https://github.com/xitu/gol ...

  2. [LeetCode] Mini Parser 迷你解析器

    Given a nested list of integers represented as a string, implement a parser to deserialize it. Each ...

  3. 深入浅出 Vue.js 第九章 解析器---学习笔记

    本文结合 Vue 源码进行学习 学习时,根据 github 上 Vue 项目的 package.json 文件,可知版本为 2.6.10 解析器 一.解析器的作用 解析器的作用就是将模版解析成 AST ...

  4. Node.js REPL(交互式解析器)

    Node.js REPL(交互式解释器) Node 自带了交互式解释器,可以执行以下任务: 读取 - 读取用户输入,解析输入了Javascript 数据结构并存储在内存中. 执行 - 执行输入的数据结 ...

  5. 385 Mini Parser 迷你解析器

    Given a nested list of integers represented as a string, implement a parser to deserialize it.Each e ...

  6. 有时间了解一下Spark SQL parser的解析器架构

    1:了解大体架构 2:了解流程以及各个类的职责 3:尝试编写一个

  7. Boost学习之语法解析器--Spirit

    Boost.Spirit能使我们轻松地编写出一个简单脚本的语法解析器,它巧妙利用了元编程并重载了大量的C++操作符使得我们能够在C++里直接使用类似EBNF的语法构造出一个完整的语法解析器(同时也把C ...

  8. 第6章 网页解析器和BeautifulSoup第三方插件

    第一节 网页解析器简介作用:从网页中提取有价值数据的工具python有哪几种网页解析器?其实就是解析HTML页面正则表达式:模糊匹配结构化解析-DOM树:html.parserBeautiful So ...

  9. python 之网页解析器

    一.什么是网页解析器 1.网页解析器名词解释 首先让我们来了解下,什么是网页解析器,简单的说就是用来解析html网页的工具,准确的说:它是一个HTML网页信息提取工具,就是从html网页中解析提取出“ ...

  10. EasyUI基础入门之Parser(解析器)

    前言 JQuery EasyUI提供的组件包含功能强大的DataGrid,TreeGrid.面板.下拉组合等.用户能够组合使用这些组件,也能够单独使用当中一个.(使用的形式是以插件的方式提供的) Ea ...

随机推荐

  1. Correct the classpath of your application so that it contains a single, compatible version of xxx报错解决

    1.背景 有时候引入包有冲突,比如在Maven项目中的不同模块多次重复引入等 这里遇到的问题是重复映入了如下包: <dependency> <groupId>com.baomi ...

  2. CKS考试心得分享

    CKS证书 考试相关 考试报名准备 CKS考试和CKA考试一样,已经开放中国大陆的考试.但区别是CKS目前没有中文题目,考试都是英文题目,唯一区别是CKS中文考试是中文老师监考,仅此而已.因此,建议C ...

  3. 代码随想录Day16

    513.找树左下角的值 给定一个二叉树的 根节点 root,请找出该二叉树的 最底层 最左边 节点的值. 假设二叉树中至少有一个节点. 示例 1: 输入: root = [2,1,3] 输出: 1 示 ...

  4. 什么是FPGA?为什么FPGA会如此重要?

    CPU.GPU.FPGA三者能力相加就是芯片的未来! 很多粉丝问我,嵌入式方向中的FPGA怎么样?收入如何? 前言 讲述FPGA前,我们先讲讲当年中兴被制裁的问题. 美国前总统特朗普曾经发布过一条禁令 ...

  5. 嵌入式工程师到底要不要学习ARM汇编指令?arm学习文章汇总

    嵌入式工程师到底要不要学习ARM汇编指令? 网上搜索这个问题,答案很多,大部分的建议是不要学汇编,只要学C语言. 而一口君作为一个十几年经验的驱动工程师,个人认为,汇编语言还是需要掌握的,想要搞精.搞 ...

  6. celery僵死导致jumpserver提示 连接WebSocket失败

    celery僵死导致jumpserver提示连接WebSocket失败 Celery的任务监控位于堡垒机 "作业中心"下的"任务监控" 中,点击打开新的页面如下 ...

  7. Redis分布式锁防止缓存击穿

    一.Nuget引入 StackExchange.Redis.DistributedLock.Redis依赖 二.使用 StackExchange.Redis 对redis操作做简单封装 public ...

  8. LLM 写标书

    云孚科技 有提到标书写作 https://www.sohu.com/a/726319389_121119682 https://www.aihub.cn/tools/writing/yfwrite/ ...

  9. Openharmony 跑 CV 应用

    最近有个项目,老同学让帮忙验证一个在ARM 板上跑 OpenHarmony,然后再集成一个CV算法上去,写这个文章主要是整理一下思路.如果有思路不对的地方,也烦请指出. 1. 个人做纯软件比较多,所以 ...

  10. C# 泛型对象和DataTable之间的相互转换

    应用场景 实际开发场景下会经常出现DataTable和List对象需要相互转换的时候,通过方法提取避免重复造轮子 List转换成DataTable 基本思路: 向DataTable里面添加新的数据内容 ...