前言

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

通过本文读者将学到如何实现一门领域特定语言(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. CF208E 题解

    Blood Cousins 前置知识:线段树合并. 我们先把题目转化一下.这里先设 \(v\) 的 \(p\) 级祖先为 \(u\),事实上要求的东西就是 \(u\) 的 \(p\) 级后代的个数减 ...

  2. 需要多久才能看完linux内核源码?

    代码中自由颜如玉!代码中自有黄金屋! 一.内核行数 Linux内核分为CPU调度.内存管理.网络和存储四大子系统,针对硬件的驱动成百上千.代码的数量更是大的惊人. 先说说最早的内核linux 0.11 ...

  3. 使用FModel提取黑神话悟空的资产

    目录 前言 设置 效果展示 闲聊 可能遇到的问题 没有相应的UE引擎版本选项 前言 黑神话悟空昨天上线了,解个包looklook. 本文内容比较简洁,仅介绍解包黑神话所需的专项配置,关于FModel的 ...

  4. [VS Code扩展]写一个代码片段管理插件(二):功能实现

    @ 目录 创建和插入代码片段 代码片段列表 代码片段预览 代码片段编辑 自定义映射 默认映射 自动完成 项目地址 创建和插入代码片段 VS Code扩展提供了数据存储,其中globalState是使用 ...

  5. iPhone 打不开 Apple News 解决方法

    想看 Apple News,但是在主屏幕找不到,在 App Store 搜索 Apple News 后打开时显示访问控制已启用,然而在设置中检查发现访问控制并没有启用. 经过一番摸索,发现这个访问控制 ...

  6. Cannot find loader com.jme3.scene.plugins.ogre.MeshLoader

    五月 20, 2022 2:46:07 下午 com.jme3.asset.AssetConfig loadText 警告: Cannot find loader com.jme3.scene.plu ...

  7. electron修改vue项目打包后的exe图标

    vue用electron打包点击这里 安装electron-icon-builder 添加图标生成器:npm i electron-icon-builder 生成图标 1.在package.json的 ...

  8. SSH 安全机制 及常见问题

    常见问题: ssh_dispatch_run_fatal: Connection to {your_ip} port 22: invalid argument ssh -oKexAlgorithms= ...

  9. sicp每日一题[1.41]

    Exercise 1.41 Define a procedure double that takes a procedure of one argument as argument and retur ...

  10. 小tips:怎样实现简单的前端hash与history路由方式?

    前端路由实现方式,主要有两种,分别是history和hash模式. hash模式 不同路由对应的hash是不一样的,如何能够监听到URL中关于hash部分发生的变化?浏览器已经暴露给我们一个现成的方法 ...