类型体操做不动了?来用 TS 实现一个 JSON Parser 吧。

736 阅读4分钟

用 TS 实现一个 JSON Parser 吧

昨天在逛知乎时发现一个大佬说的一句话比较有道理:

想要入门一门语言最简单的方法就是用这门语言写一个 json 解释器。

最近正好又在学习 TS,于是就尝试用 TS 实现一个简易的 JSON 解析器。整个代码不到 300 行,非常的简单易懂。你可以在这里 simpleJsonParse 找到本文的所有代码。

JSON 数据结构简介

json 是我们在开发种经常会使用到的一种用于配置或者数据传输的文本格式,其格式非常简单,也非常便于解析。在 json 官网中json对 json 的语法有详细的描述。在 json 中有以下几种数据类型:

  • object
  • array
  • string
  • number
  • boolean(true|false)
  • null

想必这几种类型大家都非常熟悉,这里只挑一些平时可能注意不到的地方说一下。

number 类型

number 在 json 中需要满足以下条件:

  1. 不能以 0 开头的数字,除非等于 0。例如 0 是合法的 number,但是 01 不是。
  2. 可以用 e 或 E 表示科学计数法,例如,3.1e2 表示 312。

string 类型

string 类型在 json 中需要满足以下条件:

  1. 包裹在双引号中。
  2. 可以用 \ 进行转义,例如 \" 表示 "
  3. 可以用 \u 表示 uniconde 字符,例如 \u4e2d 表示

基本思想

假设我们现在要开始解析 object 类型,object 类型由 { 开始,} 结束,中间由多个 key: value 组成。因此写成伪代码可以是:

peekObject(){
    checkAndSkipLiterate("{");
    res = {}
    while(true){
        skipEmpty();
        key = peekString();
        skipEmpty();
        checkAndSkipLiterate(":");
        skipEmpty();
        value = peekValue();
        res[key] = value;
        if(getFirstChar() === '}'){
            break;
        }
        checkAndSkipLiterate(",");
    }
    return value;
}

这里涉及到一个 checkAndSkipLiterate 函数,我们只关系它的功能:判断当前正在解析的字符串是否以该字符串开头,如果是则跳过该字符串,如果不是则抛出异常。我们知道 object 类型都是以 { 开头,因此在第一行代码需要检测是否满足要求,否则直接抛出异常。随后我们进入循环。现在看到另一个函数 skipEmpty,它的作用是跳过所有的空白字符。然后我们调用 peekString 方法获得从当前位置开始的第一个字符,并返回它的值。这就是 key,然后同样跳过空白字符。检查接下来的字符是否为 :,最后再调用 peekValue 方法解析当前位置开始的 value 并返回它。最后判断如果已经解析到了 },则说明这个 object 解析结束,返回整个结果。否则检测是否为 ,,如果是则继续解析下一个 key-value pair。

这里有个非常重要的函数 peekValue,其作用是解析接下来的 value。但是在 json 中所有的类型都可以为 value。因此我们可以根据开头的字符是什么来判断解析来要解析的类型。例如字符是 [ 那么接下来要解析的则为 array 类型,同理如果是 " 则解析来要解析的是字符串类型。因此整个 peekValue 的实现如下:

peekValue(){
        if(this.getFirstChar() === '"'){ // 以 " 开头,则表示接下来要解析 string
            return this.peekString();
        }else if(this.getFirstChar() === "{"){ // 以 [ 开头表示要解析 array
            return this.peekObject()
        }else if(this.getFirstChar() === '['){
            return this.peekArray();
        }else if(this.getFirstChar() >= '0' && this.getFirstChar() <= '9'){
            return this.peekNumber();
        }else if(this.getFirstChar() === 't'){
            this.checkAndSkipLiterate('true');
            return true;
        }else if(this.getFirstChar() === 'f'){
            this.checkAndSkipLiterate('false');
            return false;
        }else if(this.getFirstChar() === "n"){
            this.checkAndSkipLiterate("null");
            return null;
        }
        // 这里应当要抛出格式错误的异常
    }

可以说到这里我们已经了解了整个解释器的基本逻辑,接下来就是实现解析各种数据类型的函数了。

正式开始

定义类型

既然是用 ts 写,那么首先需要定义出需要用到的类型:

type SingleValue = string|number|boolean|null // 非嵌套的类型
type JsonObject = {[key: string]:SingleValue|JsonObject|(JsonObject|SingleValue)[]} // object 类型
type JsonValue = JsonObject|SingleValue|(JsonObject|SingleValue)[] // value 类型

interface Parser{
    parse(): JsonObject;
}

我们知道在 json 中类型存在递归,例如 object 可以表示为 {[key: string]: value},但同时,value 类型也可以包含 object 类型。因此存在用自身表示自身的方法。此外,为了更友好的错误提示,我们还需要定义一个异常类用来表示解析异常:

class JsonParseException{
    position: number;
    exceptionChar: string;
    shouldBe: boolean;
    constructor(position: number, exceptionChar: string, shouldBe: boolean=true){
        this.position = position;
        this.exceptionChar = exceptionChar;
        this.shouldBe = shouldBe;
    }

    getExcepted(): string{
        if(this.shouldBe){
            return `should be '${this.exceptionChar}' here`
        }else{
            return `should not be '${this.exceptionChar}' here`
        }
    }
}

其中 position 表示解析错误的位置,exceptionChar 表示出错的字符,shouldBe 表示是否应该是这个字符。例如当我们解析到位置 3 时,应当时一个 ],当时此时却不是。我们就可以抛出 JsonParseException(3, ']', true)。或者当我们解析到字符串的末尾却发现还没找到闭合的结构,我们可以抛出JsonParseException(20, 'EOF', false),表示位置 20 不应该是 EOF。

解释器

class SimpleParser implements Parser{
    
    index: number;
    json: string;

    constructor(json: string){
        this.index = 0;
        this.json = json;
    }

解释器有两个成员属性分别是:

  • json: 需要解析的字符串
  • index: 存储当前解析到的位置在字符串中的下标,index 就如一个指针,始终指向我们接下来要解析的字符串的开始

然后有了这些属性后,我们先定义一些工具方法:

    // 将 index + 1,指针指向下一个字符
     private goToNextChar(){
        this.index += 1;
    }
    
    // 得到当前正在解析的第一个字符
    private getFirstChar(): string{
        return this.json[this.index];
    }
    
    // 得到剩余还未解析的字符
    private getRestChars(): string{
        return this.json.slice(this.index);
    }
    
    // 是否已经解析完所有字符串
    private reachEOF(): boolean{
        return this.index === this.json.length;
    }
    
    // 抛出对应的异常
    private raiseException(exceptionChar: string, shouldBe: boolean=true){
        throw new JsonParseException(this.index, exceptionChar, shouldBe);
    }
    
    // 判断当前字符是否等于特定字符,如果相等则将指针往后移
    // 否则抛出异常
    private equalOrRise(chr: string){
        if(this.getFirstChar() === chr){
            this.goToNextChar();
        }else{
            this.raiseException(chr);
        }
    }
    
    // 判断还未检测的字符串是否以 ${literal} 开头,如果是则跳过 ${literal}
    // 否则抛出异常
    private checkAndSkipLiterate(literal: string){
        for(const s of literal){
            this.equalOrRise(s)
        }
    }
    
    // 跳过所有空白字符
    private skipEmpty(){
        while(SimpleParser.EMPTY_STRING.has(this.json[this.index])){
            this.goToNextChar();
        }
    }

逻辑都很简单,唯一需要注意的是 skipEmpty 的实现方式,json 规定以下字符为空白字符: image.png 也就是:

  • 空格(space)
  • \n(linefeed)
  • \r(carriage return)
  • \t(horizontal tab)

因此 EMPTY_STRING 定义为:

static EMPTY_STRING = new Set([" ", "\n", "\r", "\t"])

有了这些工具函数之后我们就可以实现解析各种类型的方法。

peekObject

peekObject 解析从当前位置开始的 object,刚才已经介绍过,代码如下:

    private peekObject(): JsonObject{
        this.checkAndSkipLiterate("{");
        const jsonObject = {}
        while(true){
            this.skipEmpty();
            const key = this.peekString();
            this.checkAndSkipColon();
            const value = this.peekValue();
            jsonObject[key] = value;
            this.skipEmpty();
            if(this.getFirstChar() === "}"){
                this.goToNextChar();
                break;
            }
            this.chekAndSkipComma();
        }
        return jsonObject;
    }

peekArray

peekArray 的逻辑与 peekObject 非常相似,只是缺少了解析 key 的过程:

    private peekArray(): JsonValue{
        this.checkAndSkipLiterate("["); // 检查是否以 [ 开头
        let values = []
        while(true){
            this.skipEmpty(); // 跳过空白字符
            values.push(this.peekValue()) // 解析 value
            this.skipEmpty(); // 跳过空白字符
            if(this.getFirstChar() === ']'){ // 如果解析到了 ],则表明 array 解析完成
                this.goToNextChar();
                break;
            }
            this.chekAndSkipComma(); // 否则检查是否为, 并继续下一个 value 的解析
        }
        return values;
    }

peekNumber

这里我直接用正则来匹配数字,看接下来的字符是否满足 number 的语法:

private peekNumber(): number{
        let symbol = 1;
        if(this.json[this.index] === '-'){ // 解析符号位
            this.index += 1;
            symbol = -1;
        }
        const [allMatch] = SimpleParser.NUMBER_RE.exec(this.getRestChars());
        // 如果解析不到 number,则抛出异常
        if(!allMatch){
            this.raiseException('0-9');
        }
        // 将指针移动到 number 之后的位置
        this.index += allMatch.length
        // 返回解析到的 number
        return symbol * Number.parseFloat(allMatch);
    }    

其中 NUMBER_RE 定义:

static NUMBER_RE = new RegExp("^(0|([1-9][0-9]*(\.[0-9][0-9]*)?))([eE](0|[1-9][0-9]*))?");

peekString

因为涉及到转义字符解析 string 的逻辑要稍微复杂一点:

private peekString(): string{
        this.checkAndSkipLiterate('"'); // 解析是否以 " 开头
        const chars: string[] = []; 
        while(true){
            if (this.reachEOF()){ // 如果还没有遇到结尾的 " 就以及解析完了整个字符串,则怕抛出异常
                this.raiseException('EOF', false);
            }
            let chr: string = this.getFirstChar(); // 获取当前解析的字符
            if(chr === '"'){ // 如果解析到了结尾的 " 则退出
                this.goToNextChar();
                break;
            }
            if(chr === '\\'){ // 如果遇到了 \ 表示要处理转义字符
                this.goToNextChar();
                const nextChar = this.getFirstChar(); // 获取 \ 的下一个字符,决定转义字符的值
                switch(nextChar){
                    case '"': chr = '"'; break;
                    case '\\': chr = '\\'; break;
                    case 'b': chr = '\b'; break;
                    case 'f': chr = '\f'; break;
                    case 'n': chr = '\n'; break;
                    case 'r': chr = '\r'; break;
                    case 't': chr = '\t'; break;
                    case 'u': // 如果是 \u,则需要处理 unicode
                        let unicodeControlChars = "";
                        let validUnicodeChars = true;
                        for(let i = 0; i < 4; i++){
                            this.goToNextChar();
                            let maybeNumber = this.getFirstChar();
                            unicodeControlChars += maybeNumber;
                            if(!this.isHex(maybeNumber)){
                                validUnicodeChars = false;
                            }
                        }
                        if(!validUnicodeChars){
                            throw Error(`invaliad uniconde char \\u${unicodeControlChars}`)
                        }
                        // 先将解析到的 16 进制字符转串换为数字,再调用
                        // String.fromCharCode 得到最终的 unicode 字符
                        chr = String.fromCharCode(Number.parseInt(`0x${unicodeControlChars}`));
                        break;
                    default:
                        throw Error(`invaliad control char \\${nextChar}`)
                } 
            }
            this.goToNextChar(); // 往前移动指针
            chars.push(chr); // 保存当前的 char
        }
        return chars.join("");
    }

peekValue

在这些函数的基础上可以直接完成 peekValue:

 private peekValue(): JsonValue{
        if(this.getFirstChar() === '"'){
            return this.peekString();
        }else if(this.getFirstChar() === "{"){
            return this.peekObject()
        }else if(this.getFirstChar() === '['){
            return this.peekArray();
        }else if(this.getFirstChar() >= '0' && this.getFirstChar() <= '9'){
            return this.peekNumber();
        }else if(this.getFirstChar() === 't'){
            this.checkAndSkipLiterate('true');
            return true;
        }else if(this.getFirstChar() === 'f'){
            this.checkAndSkipLiterate('false');
            return false;
        }else if(this.getFirstChar() === "n"){
            this.checkAndSkipLiterate("null");
            return null;
        }
        this.raiseException('json value')
    }

其中 true,false,null 的处理比较简单,因此没有单独封装一个函数而是在 peekValue 中直接处理。

parse

至次我们的所有方法已经完成,由于一个完整的 json 最外层必定是个 object,所以 parse 实际上就直接返回 peekObject 即可:

    parse(): JsonObject {
        try{
            const jsonObj = this.peekObject();
            this.skipEmpty(); 
            if(!this.reachEOF()){ // 如果解析到最后仍然有字符串则需要抛出异常
                this.raiseException('EOF');
            }
            return jsonObj;
        }catch(e){
            if(e instanceof JsonParseException){
                throw Error(`\njson syntax error\n${this.formatException(e)}`)
            }else{
                throw e;
            }
        }
    }

再定义一个方法方便调用:

function parse(json: string): JsonObject{
    return new SimpleParser(json).parse();
}

试一下最终的成果吧:

const json = `{
    "name": "lily",
    "age": 123, 
    "sex": null,
    "country": "\\u4e2d国",
    "arg1": true,
    "arg2": false  ,
    "arg3": [1, "2", true, {"a": 1}   ],
    "address": {
        "email": "testtest",
        "phone": [123456, "aaa", [1, "sadfaf", {"1": 123.0e3}], {"a": "b"}]
    }
}`
console.log(parse(json))
/*
{
  name: 'lily',
  age: 123,
  sex: null,
  country: '中国',
  arg1: true,
  arg2: false,
  arg3: [ 1, '2', true, { a: 1 } ],
  address: { email: 'testtest', phone: [ 123456, 'aaa', [Array], [Object] ] }
}
*/

错误信息

当解析到格式错误时最好能有一个比较友好的错误提示来指出错误的位置和原因,例如:

console.log(parse(`{"a": "b", "c"}`))
/*
json syntax error
{"a": "b", "c"}
--------------^should be ':' here
*/

因此需要有一个函数来将抛出的异常进行格式化:

    formatException(e: JsonParseException, contextLength: number=100): string{
        const contextStartPosition = Math.max(e.position - contextLength, 0);
        const parseContext = this.json.slice(contextStartPosition, e.position + 1)
        const lastLine = parseContext.split('\n').pop();
        const lastLineStartPosition = contextStartPosition + (parseContext.length - lastLine.length)
        const prompt = Array.from(Array(e.position - lastLineStartPosition + 1).keys())
                                    .map((_, index, array) => (index < (array.length - 1) ? '-': '^'))
                                    .join('')
                                    .concat(' ' + e.getExcepted());
        return `${parseContext}\n${prompt}`
    }

逻辑也比较简单,即从报错的位置往前选取一段字符打印在第一行,然后根据选取的字符串长度以及出错的位置打印第二行的提示部分。一个需要注意的地方是当选取的字符串存在多行时,打印的部分只需要计算最后一行的长度。

为解释器增加注释的功能

原生的 json 语法是不支持注释的。但是在某些场景下注释功能也非常重要,例如将 json 作为配置文件使用。既然这里实现了一个解释器,我们也可以稍微改动一下,为其增加注释的功能。由于注释只能在空白处出现,因此我们只需要修改一下 skipEmpty 的逻辑:

     // 将之前的 skipEmpty 改为 skipInvisiableChars
     private skipInvisiableChars(){
        while(SimpleParser.EMPTY_STRING.has(this.json[this.index])){
            this.goToNextChar();
        }
    }
    
    // 在 skipEmpty 中处理注释
    private skipEmpty(){
        while(true){
            this.skipInvisiableChars(); // 跳过不可见字符
            // 如果以 // 开头则为注释,跳过直到下一行
            if(this.getRestChars().startsWith("//")){ 
                do{
                    this.goToNextChar();
                }while(this.getFirstChar() !== '\n' && !this.reachEOF())
            }else{
                break;
            }
        }
    }

测试一下:

const json = `{
    "name": "lily",
    "age": 123, 
    "sex": null, // this is a comment

    // this is another comment
    "country": "\\u4e2d国",
    "arg1": true,
    "arg2": false  ,
    "arg3": [1, "2", true, {"a": 1}   ],
    "address": {
        "email": "testtest",
        "phone": [123456, "aaa", [1, "sadfaf", {"1": 123.0e3}], {"a": "b"}]
    }
}
`
console.log(parse(json))
/*
{
  name: 'lily',
  age: 123,
  sex: null,
  country: '中国',
  arg1: true,
  arg2: false,
  arg3: [ 1, '2', true, { a: 1 } ],
  address: { email: 'testtest', phone: [ 123456, 'aaa', [Array], [Object] ] }
}*/

这里增加的仅仅是单行注释,当然要增加多行注释也非常简单,有兴趣的同学可以自行测试。

总结

虽然实现的解释器非常简单,基本没有考虑性能的优化。但是也了解到了实现一个 JSON Parser 的基本逻辑。用来属性一门语言也是非常好的方法。