Zergling 是咱们团队自研的埋点管理平台,默认的数据格式以下:javascript
{
"page": "dsong|ufm",
"resource": "song", // 歌曲
"resourceid": 1, // 资源 id
"target": 111, // 不感兴趣
"targetid": "button",
"reason": "",
"reason_type": "fixed"
}
复制代码
一种自定义 json 格式,比较不一样在于:前端
|
分割符,当作数组用在实际过程当中有一些不符合规范的地方:java
应该为git
id: 1111, // 活动 url
复制代码
/
作数组分割符,而不是 |
。 除了上述错误类型以外,还有其余错误类型。因而决定写一个自定义的 json parser 来规范输入问题。总的分为词法分析和语法分析两部分。github
词法分析主要将源码分割成不少小的子字符串变成一系列的 token.json
好比下面的赋值语句。数组
var language = "lox";
复制代码
词法分析后,输出 5 个 token 以下 数据结构
因此词法分析的关键就在于如何分割字符串。函数
咱们先定义 token 的数据结构 (Token.js)ui
class Token {
constructor (type,value){
this.type = type;
this.value = value;
}
}
复制代码
再定义 Token 类型 (TokenType.js), 参考 token type
const TokenType = {
OpenBrace: "{", // 左括号
CloseBrace: "}", // 右括号
StringLiteral: "StringLiteral", // 字符串类型
BitOr: "|",
SingleSlash: "/",
COLON: ":",
QUOTE: '"',
NUMBER: "NUMBER",
COMMA: ",",
NIL: "NIL", // 结束的字符
EOF: "EOF", //end token
};
复制代码
作好上面准备以后,就能够着手处理字符了。
先定义一个类 Lexer (Lexer.js)
class Lexer {
constructor (input) {
this.input = input;// 输入
this.pos = 0;// 指针
this.currentChar = this.input [this.pos];
this.tokens = []; // 返回的全部 token
}
}
复制代码
词法处理是一个个读取字符串,而后分别组装成一个 Token。咱们先从简单的符号好比 {
,=
开始,若是碰到符号,咱们就直接返回对应的 token。对于空白,咱们就忽略。
// 获取全部的 token;
lex () {
while (this.currentChar && this.currentChar != TokenType.NIL) {// 若是当前不是结束的字符
this.skipWhiteSpace ();
let token = "";
switch (this.currentChar) {
case "{":
this.consume ();
token = new Token (TokenType.OpenBrace, TokenType.OpenBrace);
break;
case "}":
this.consume ();
token = new Token (TokenType.CloseBrace, TokenType.CloseBrace);
break;
case ":":
this.consume ();
token = new Token (TokenType.COLON, TokenType.COLON);
break;
case ",":
this.consume ();
token = new Token (TokenType.COMMA, TokenType.COMMA);
break;
}
if (token) this.tokens.push (token);
}
this.tokens.push (new Token (TokenType.EOF, TokenType.EOF));
}
复制代码
this.skipWhiteSpace
主要是处理空白,若是当前字符是空白符,咱们就移动指针 pos++
,去判断下一个字符,直到不是空白符为止。this.consume
这个函数就是用来移动指针.
skipWhiteSpace () {
while (!this.isEnd () && this.isSpace (this.currentChar)) {
this.consume ();
}
}
isSpace (char) {
const re = /\s/gi;
return re.test (char);
}
/** 获取下一个字符 */
consume () {
if (!this.isEnd ()) {
this.pos++;
this.currentChar = this.input [this.pos];
} else {
this.currentChar = TokenType.NIL;
}
}
// 判断是否读完
isEnd () {
return this.pos > this.input.length - 1;
}
复制代码
对于符号的处理直接返回 token 便可,对于字符串稍微麻烦一点。好比 "page"
这个咱们须要读 4 个字符组合在一块儿。所以,当咱们碰到 "
双引号的时候,咱们就进入 getStringToken 函数来处理。
(Lexer.js->lex)
case '"':
token = this.getStringToken ();
break;
复制代码
对于 getStringToken
。咱们这里比较特别,通常的 string 没有 |
这个分隔符,好比 "page"
。而咱们的例子里面如 "dsong|ufm"
, 将返回 dsong
, |
, ufm
, 三个 token。
getStringToken (){
let buffer = "";
while (this.isLetter (this.currentChar) || this.currentChar == TokenType.BitOr)
{
if (this.currentChar == TokenType.BitOr) {
if (buffer)
this.tokens.push (new Token (TokenType.StringLiteral, buffer));
this.tokens.push (new Token (TokenType.BitOr, TokenType.BitOr));
buffer = "";
}
}
}
复制代码
对于 comment 相似,当咱们碰到字符是 /
的时候,咱们就假设他是注释 //xxx
。对于 comment 就自动忽略。
(Lexer.js->lex)
case "/":
token = this.getCommentToken ();
break;
复制代码
getCommentToken () {
// 简单处理两个 /
this.match (TokenType.SingleSlash);
this.match (TokenType.SingleSlash);
while (!this.isNewLine (this.currentChar) && !this.isEnd ()) {
this.consume ();
}
return;
}
isNewLine (char) {
const re = /\r?\n/;
return re.test (char);
}
复制代码
接下来处理数字,相似 string, 好比 111,三个字符,咱们当作一个数字。因此咱们规定当字符是数字的时候,咱们就进入处理 getNumberToken
来处理数字。
(Lexer.js->lex)
default:
if (this.isNumber (this.currentChar)) {
token = this.getNumberToken ();
} else {
throw new Error (`${this.currentChar} is not a valid type`);
}
复制代码
接下来处理 getNumberToken
函数
getNumberToken () {
let buffer = "";
while (this.isNumber (this.currentChar)&&!this.isEnd ()) {
buffer += this.currentChar;
this.consume ();
}
if (buffer) {
return new Token (TokenType.NUMBER, buffer);
}
}
isNumber (char) {
const re = /\d/g;
return re.test (char);
}
复制代码
至此,全部的咱们就得到了全部的 token。
词法分析能够解决用 value 当作注释的问题,好比 {id:"活动 id"}
这种写法,可是没法处理 {id:"page || dsong"}
这种。由于按照咱们的逻词法处理 "page || dsong"
会返回 page,|,|,dsong
4 个 string token。 语法分析主要是对逻辑的验证。
咱们先找到 json 的语法定义。
grammar JSON;
json
: value
;
value
: STRING
| NUMBER
| obj
| 'true'
| 'false'
;
obj
: "{" pair (,pair)* "}"
;
pair
String: value
STRING
: '"' (ESC | SAFECODEPOINT)* '"'
;
NUMBER
: '-'? INT ('.' [0-9] +)? EXP?
;
复制代码
因为咱们须要支持 a|b|c
, 因此修改一下对 string 的处理
value
: STRING
复制代码
改成
value
: STRING (|STRING)*
复制代码
获得上面的语法定义以后,就是考虑如何将其转为代码。 grammar json 这行只是定义,能够忽略。
json
: value
;
value
: STRING
| NUMBER
| obj
| 'true'
| 'false'
;
NUMBER
: '-'? INT ('.' [0-9] +)? EXP?
;
复制代码
这里 json 能够推导出 value, value 又能够推导出 Number 和 'true'。Number 又能够推导出其它,而 'true' 这种是基本数据类型没法再推导其余了。
对于上面这种能够推导出其余的好比 json,value,Number 咱们就叫作非终止符 nonterminal。
'true' 这种就叫作终止符 terminal。
对于 Number 和 String 右边,因为只是字符的范围限定,咱们也当作 terminal 来处理。
由于,将上面的语法定义转为具体代码,规则以下:
nonterminal
,则对应转成函数terminal
。 匹配当前的 token 类型是 terminal 类型,而后指针移到下一个|
。则对应if
或者 switch
*
或者 +
。while
或者 for
循环?
。则转化为 if
因此左边的 value,Number,json
等都是函数,而右边的好比 {
,true
都是先匹配当前 token 类型,而后获取下一个 token。
咱们将 json 的语法转为以下。
先定义 Parser (Parser.js),输入是一个词法分析 lexer。
class Parser {
constructor (lexer) {
this.lexer = lexer;
this.currentToken = lexer.getNextToken ();
}
}
复制代码
而后解析第一条规则,将 json:value
都转为函数。
(Paser.js)
/** json: value */
paseJSON () {
this.parseValue ();
}
复制代码
接下来解析 value 的语法,因为 |
是选择语句,咱们将其转为 switch。根据当前 token 类型是对象仍是 number,string, 走到不一样的分支。
(Parser.js->parseValue)
/** * value : STRING (|STRING)* | NUMBER | obj | 'true' | 'false' ; */
parseValue () {
switch (this.currentToken.type) {
case TokenType.OpenBrace:
this.parseObject ();
break;
case TokenType.StringLiteral:
this.parseString ();
break;
case TokenType.NUMBER:
this.parseNumber ();
break;
case TokenType.TRUE:
break;
case TokenType.FLASE:
break;
}
}
复制代码
根据规则 2,terminal, 匹配当前的 token 类型,而后获取下一个 token. 因此当碰到 true
和 value
的时候,switch 语句改成以下。
case TokenType.TRUE:
this.eat (TokenType.TRUE);
break;
case TokenType.FLASE:
this.eat (TokenType.FALSE);
break;
复制代码
咱们定义一个 eat
函数,匹配当前 token 再获取下一个,若是不符合直接抛出错误信息。
/**match the current token and get the next */
eat (tokenType) {
if (this.currentToken.type == tokenType) {
this.currentToken = this.lexer.getNextToken ();
} else {
throw new Error (
`this.currentToken is ${JSON.stringify ( this.currentToken )} doesn't match the input ${tokenType}`
);
}
}
复制代码
接下来处理 parseObject
,它的语法是 "{" pair (,pair)* "}
。
{
是 terminal,直接 eat
. pair
变量,直接转为函数。
(,pair)*
。根据规则 4,*
转为 while
语句。
*
是正则符号表示零或者更多的状况,因此当碰到这种状况的时候,咱们先判断是否匹配逗号,而后执行 parsePair
函数。
代码以下
/**obj : "{" pair (,pair)* "}" ; */
parseObject () {
this.eat (TokenType.OpenBrace);
this.parsePair ()
while (this.currentToken.type == TokenType.COMMA) {
this.eat (TokenType.COMMA);
this.parsePair ()
}
this.eat (TokenType.CloseBrace);
}
复制代码
解决了上面的语法转换以后,接下来的代码能够根据上面的处理转换。
/** String: value */
parsePair () {
this.eat (TokenType.StringLiteral);
this.eat (TokenType.COLON);
this.parseValue ();
}
//STRING (|STRING)*
parseString () {
this.eat (TokenType.StringLiteral);
while (this.currentToken.type == TokenType.BitOr) {
this.eat (TokenType.BitOr);
this.eat (TokenType.StringLiteral);
}
}
parseNumber () {
this.eat (TokenType.NUMBER);
}
复制代码
至此,咱们的工做已经完成。
对于开头提出的两个问题。
第一个用 value
当作注释,而不用 comment
。这个在词法解析阶段解决。判断字符串用的是 /w/ 的正则。 而这个正则在碰到中文会抛出错误提示。
第二个用 /
作数组分割符,而不是 |
。 这个在语法解析阶段解决。 当解析 value: STRING (|STRING)*
这条规则的时候,若是碰到的字符串后面碰到的不是 | 分隔符,则会报错。
上面的两个 test 已经覆盖,完整代码及 test case 请查看 github
本文发布自 网易云音乐前端团队,欢迎自由转载,转载请保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!