Json解析实战

236 阅读10分钟

最近一直在看编译原理, 把极客时间的 编译原理之美Crafiting Interpreter的前端部分看了一遍, 算是掌握了怎样利用ast语法树来实现一款编程语言, 挺想做笔记把学到的知识记录下来, 但我更希望是从日常开发的角度去写编译原理的文章, 毕竟带着问题去学一门知识, 效果一定比干巴巴去学好。想了又想, 觉得手写Json解析挺好的入门编译原理。

序列化

序列化(Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程, 即把对象转换成字符串。在前端开发中, 通常我们使用 JSON.stringify来实现。

先举一些简单的例子:

JSON.stringify(12) // "12"
JSON.stringify(true) // "true"
JSON.stringify("foo") // "\"foo\""

因此我们要开发的函数像是这样:

export function stringify(obj: any): string {
  const result = parsing(obj)
  return result
}

可以传任何值进去, 返回字符串。

序列化函数逻辑流程如下:

  1. 判断传入参数类型
  2. 根据所得的类型, 调用相应的序列化函数
  3. 返回所得的字符串结果

先实现判断类型参数:

function getTypeStr(v: any) {
  if (Array.isArray(v)) return "array"

  const varType = typeof v

  return varType
}

因为 typeof数组返回的是object, 所以要特别处理数组。

接着我们要开发一系列的解析函数, 定义一个对象来存放解析函数。 先处理简单情况:

const typeParsers: Record<string, (a: any) => string> = {
  number: numParser,
  string: strParser,
  boolean: boolParser
}

/**
 * 根据类型名称, 获取解析函数
 * @param typeName 
 * @returns 
 */
function getTypeParser(typeName: string): (a: any) => string {
  const parser = typeParsers[typeName]

  // undefined 或 null等 的情况
  if (parser === undefined) {
    return defaultParser
  } else {
    return parser
  }
}

function numParser(val: number) {
  return val.toString()
}

function strParser(val: string) {
  return `\"${val}\"`
}

function boolParser(val: boolean) {
  return val ? "true" : "false"
}

function defaultParser(val: any) {
  return ""
}

代码好简单, 没什么好说的, 就是获取解析函数的函数要处理其他特殊情况, 例如 undefined, null等情况, 这里直接处理为返回空值。

最后则是返回结果:

function parsing(val: any) {
  // 获取类型名称
  const typeStr = getTypeStr(val)
  // 获取相应的解析函数
  const parser = getTypeParser(typeStr)
  // 返回字符串结果
  const result = parser(val)

  return result
}

接着要处理比较复杂的类型: 数组和对象

数组要考虑四种情况:

  1. 空数组
  2. 多个元素数组
  3. 一个元素
  4. 最后一个元素

第一种情况直接返回 "[]", 第三和四其实是一样的, 就是元素后面没有逗号的情况, 直接把元素合并到结果就好了, 而第二种情况则要作判断, 如果元素不是最后一个, 合并完后要加逗号, 代码如下:

function arrayParser(val: Array<any>) {
  // 空数组情况
  if (val.length === 0) {
    return "[]"
  }

  let result = "["

  // 遍历去获取对应的解析, 解析成字符串
  let index = 0
  do {
    const itemResult = parsing(val[index])
    result += itemResult

    // 除了最后一个元素外, 每次解析完都要加上逗号
    if (index < val.length - 1) {
      result += ","
    }
    index++
  } while (index < val.length)

  result += "]"

  return result
}

因为元素的类型是不确定的, 所以又要去用parsing去判断和解析每个元素。

最后终于到了对象了, 有了处理数组的经验, 对象也是类似的:

function objectParser<T extends object>(val: T) {
  let result = "{"

  const keys = Object.keys(val)

  // 获取对象的键名称
  type keyType = keyof T

  keys.forEach((key, index) => {
    const keyResult = strParser(key)
    result += keyResult

    result += ":"

    const valueResult = parsing(val[key as keyType])
    result += valueResult

    if (index < keys.length - 1) {
      result += ","
    }
  })

  result += "}"

  return result
}

测试一下:

import { stringify } from "./json";

interface test {
  num: number,
  str: string,
  bol: boolean,
}

// 一般类型测试
const result1 = stringify(1)
const result2 = stringify("abc")
const result3 = stringify(true)
const result4 = stringify(undefined)

console.log(result1) // "1"
console.log(result2) // "\"abc\""
console.log(result3) // "true"
console.log(result4) // ""

// 数组
const result5 = stringify([1, 2, 4, 5, 5, ["foo", "bar"]]) 
console.log(result5) // "[1,2,4,5,5,[\"foo\",\"bar\"]]"

// 对象
const result6 = stringify({foo: 12, bar: "abc", bol: true, bb: [1,2,3]})
console.log(result6) // {"foo":12,"bar":"abc","bol":true,"bb":[1,2,3]}

const result7 = stringify({
  foo: {
    bar: "bar"
  }
})

console.log(result7) // {\"foo\":{\"bar\":\"bar\"}}

序列化还是比较简单, 反序列化则有些难度了

反序列化

反序列化就是字节序列转化成对象的过程, 把对象转成字符串容易, 把字符串转成对象则有些难度。为什么? 因为在序列化时, 类型本身的形式已经由编译器定义好了, 我们要做的只是根据所有类型, 开发相应的解析函数则可, 但现在是字符串, 类型形式编译器是不知道的, 要由我们来定义, 也即是说, 要由我们来定义JSON这门语言到底是什么形式, 这就得要用到编译原理的知识了。

我们应该要怎样实现JSON反序列化函数 (JSON.parse)? 把字符串遍历一遍, 如果解析到 {, 就确定为对象, 解析到 [, 则是数组? 如果是像 "{"foo": "bar{["}"? 很明显bar后的引号也得解析为字符串。这意味着一个符号会根据其位置, 状态可能会有相应转变

因此我们得要先确定字符串中的的词是什么类型, 即我们得要先做 词法分析。词法分析通常我们可以用 有限自动机 来实现。所谓有限自动机, 就是根据当前状态, 给予下一个字符以及一系列状态函数, 以确定下一个状态。定义很复杂, 直接看例子更好。

例如现在传入字符串"true", 先解析t, 根据分析它可能是布尔类型, 但只有字符t, 我们还不能确定, 还要看一下之后的字符有没有rue, 如果有, 而且接着没有其他字符串, 我们可以确定它是布尔类型, 其值为true。过程如下图所示:

booleanToken.drawio.png

我们把数字, 字符串和布尔值的情况都考虑进来, 简单用图概括一下:

simpleToken.drawio.png

首先先是最初状态, 开始遍历字符串, 如果遇到双引号, 则进入字符串状态, 一直遍历, 直到遇到下一个双引号, 如果是数字, 则是一直遍历, 直到遇到不是数字的符号。

用图来表示有限自动机的关係还是挺麻烦的, 可以用正则语法 (就是我们平时写正则表达式的语法) 来概括:

BOOLEAN: true | false
NUMBER: \d+(\.\d+)?
STRING: \"([^"]* | \\")\"
COMMA: ,
LEFT_BRACE: {
RIGHT_BRACE: }
LEFT_BRACKET: [
RIGHT_BRACKET: ]
SEMI_COLON: :

数字得要考虑可能有小数情况, 字符串则要考虑字符串中可能有双引号的情况。基于上述的分析, 我们可以编写词法分析器。先定义一个类叫Scanner, 用来封装要遍历字符串和操控方法:

export class Scanner {

    // 传入要遍历的字符串
    private source: string

    // 当前遍历的字符位置索引
    private index: number

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

    /**
     * 返回当前位置的字符
     * @returns 
     */
    peek() {
        if (this.isAtEnd()) return ""
        return this.source.charAt(this.index)
    }

    /**
     * 当前索引向前一位, 返回当前索引指向的字符
     * @returns 
     */
    advance() {
        this.index++
        return this.source[this.index - 1]
    }

    /**
     * 匹配传入的字符串
     * @param expected 
     * @returns 
     */
    match(expected: string) {
        const subStr = this.source.substring(this.index + 1, this.index + expected.length + 1)
        if (subStr === expected) {
            this.index += expected.length + 1
            return true
        } else {
            return false
        }
    }

    /**
     * 获取当前索引下一位的字符
     * @returns 
     */
    peekNext() {
        if (this.isAtEnd()) return ""
        if (this.index + 1 >= this.source.length) {
            return ""
        }
        return this.source[this.index + 1]
    }

    /**export class Scanner {

    // 传入要遍历的字符串
    private source: string

    // 当前遍历的字符位置索引
    private index: number

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

    /**
     * 返回当前位置的字符
     * @returns 
     */
    peek() {
        if (this.isAtEnd()) return ""
        return this.source.charAt(this.index)
    }

    /**
     * 当前索引向前一位, 返回当前索引指向的字符
     * @returns 
     */
    advance() {
        this.index++
        return this.source[this.index - 1]
    }

    /**
     * 匹配传入的字符串
     * @param expected 
     * @returns 
     */
    match(expected: string) {
        const subStr = this.source.substring(this.index + 1, this.index + expected.length + 1)
        if (subStr === expected) {
            this.index += expected.length + 1
            return true
        } else {
            return false
        }
    }

    /**
     * 获取当前索引下一位的字符
     * @returns 
     */
    peekNext() {
        if (this.isAtEnd()) return ""
        if (this.index + 1 >= this.source.length) {
            return ""
        }
        return this.source[this.index + 1]
    }

    /**
     * 确定是否完成遍历
     * @returns 
     */
    isAtEnd() {
        return this.index >= this.source.length
    }

}
     * 确定是否完成遍历
     * @returns 
     */
    isAtEnd() {
        return this.index >= this.source.length
    }

}

方法都好简单, 没什么需要解释的, 除了match方法, 它接收一个字符串, 获取它的长度, 拿到当前索引下一位开始相同长度的字符串, 如果匹配成功, 则索引需要加上长度。

接着要定义TokenTokenType, Token是遍历字符串后返回的数据结构, 用来存放分析所得出的结果, 而 TokenType则是定义Token的一系列类型。

export enum TokenType {
    STRING,
    NUMBER,
    BOOLEAN,
    COMMA, // ,
    LEFT_BRACE, // {
    RIGHT_BRACE, // }
    LEFT_BRACKET, // [
    RIGHT_BRACKET, // ]
    SEMI_COLON // :
}

export interface Token {
    tokenType: TokenType,
    value: string
}

词法解析器 Tokenizer, 它要做的是实现我们刚才所做的分析:

class Tokenize {
    scanner: Scanner

    private tokens: Array<Token>

    constructor(scanner: Scanner) {
        this.scanner = scanner
        this.tokens = []
    }
}

接着是parse, 它是解析字符串的方法, 完成后返回Token数组:

parse() {
    const rules = this.getTokenGetter()

    while (!this.scanner.isAtEnd()) {
        let isMatch = false

        for (let i = 0; i < rules.length; i++) {
            isMatch = rules[i](this)

            if (isMatch) {
                break
            }
        }

        if (!isMatch) {
            throw new Error('No tokentype matched')
        }
    }
    return this.tokens
}

逻辑好简单, 获取一系列解析规则函数, 遍历它们, 如果其中之一匹配成功, 则进行下一轮遍历, 直到所有字符都解析完成, 如果没有一个匹配成功, 则表示词法错误。有些书或例子会用if来进行匹配, 我则觉得这样写的代码看起来很复杂, 还不如把匹配规则封装成函数, 把它们进行遍历来匹配, 代码看起来逻辑更简单, 见仁见智吧。

getTokenGetter返回一系列解析函数, 它们规定为传入Tokenize, 返回布尔值, 以确定是否匹配成功:

getTokenGetter() {
    const arr:  Array<(tokenize: Tokenize) => boolean> = [
        boolGetter,
        numGetter,
        skipGetter,
        commaGetter,
        leftBraceGetter,
        rightBraceGetter,
        leftBracketGetter,
        rightBracketGetter,
        semicolonGetter,
        stringGetter
    ]

    return arr
}

先看一下单个字符匹配的函数:

function commaGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, ",", TokenType.COMMA)
}

function leftBraceGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, "{", TokenType.LEFT_BRACE)
}

function rightBraceGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, "}", TokenType.RIGHT_BRACE)
}

function leftBracketGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, "[", TokenType.LEFT_BRACKET)
}

function rightBracketGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, "]", TokenType.RIGHT_BRACKET)
}

function semicolonGetter(tokenize: Tokenize) {
    return basicGetter(tokenize, ":", TokenType.SEMI_COLON)
}

function basicGetter(tokenize: Tokenize, c: string, tokenType: TokenType) {
    if (tokenize.scanner.peek() === c) {
        const value = tokenize.scanner.advance()

        tokenize.pushToken({
            tokenType,
            value
        })
        return true
    } else {
        return false
    }
}

因为单个字符匹配的逻辑都是一样的: 获取当前字符, 匹配传入的字符, 如果是相等, 把结果封装成Token, 存入tokens数组中, 最后字符索引+1。

匹配多个字符的情况则比较复杂, 要个别处理了, 先是布尔类型:

function boolGetter(tokenize: Tokenize) {
    const firstChar = tokenize.scanner.peek()

    if (firstChar !== 't' && firstChar !== 'f') {
        return false
    }

    if (firstChar === 't') {
        const isMatch = tokenize.scanner.match("rue")
        if (isMatch) {
            tokenize.pushToken({
                tokenType: TokenType.BOOLEAN,
                value: 'true'
            })
        }

        return isMatch
    }

    if (firstChar === 'f') {
        const isMatch = tokenize.scanner.match("alse")
        if (isMatch) {
            tokenize.pushToken({
                tokenType: TokenType.BOOLEAN,
                value: 'false'
            })
        }

        return isMatch
    }

    return false
}

如果首字符不是tf, 直接返回false, 接着如果是t, 则匹配rue, f则匹配alse。匹配成功则返回true, 而且索引要前进。

数字的情况:

function numGetter(tokenize: Tokenize) {
    let result = ""
    let isMatch = false

    if (!isDigit(tokenize.scanner.peek())) {
        return isMatch
    }

    while (isDigit(tokenize.scanner.peek())) {
        result += tokenize.scanner.advance()
    }

    isMatch = true

    // 有小数点情况
    if (tokenize.scanner.peek() === "." &&
        isDigit(tokenize.scanner.peekNext())
    ) {
        result += tokenize.scanner.advance()
    } else {
        tokenize.pushToken({
            tokenType: TokenType.NUMBER,
            value: result
        })
        return isMatch
    }

    while (isDigit(tokenize.scanner.peek())) {
        result += tokenize.scanner.advance()
    }

    tokenize.pushToken({
        tokenType: TokenType.NUMBER,
        value: result
    })

    return isMatch
}

数字的情况也是挺简单的, 就是匹配数字, 只是要注意有可能是小数的情况。如果是小数, 则要看一下有没有小数点以及小数点后有没有数字, 有的话, 要继续匹配, 直到没有数字出现。isDigit是个工具函数, 就是用正则来进行匹配:

export function isDigit(c: string) {
  return /[0-9]/.test(c)
}

最后是字符串:

function stringGetter(tokenize: Tokenize) {
    // 如果不是双引号开始, 直接结束
    if (tokenize.scanner.peek() !== "\"") {
        return false
    }

    let isMatch = false
    let result = tokenize.scanner.advance()

    while (!tokenize.scanner.isAtEnd() && isString(tokenize.scanner.peek())) {
        const currentChar = tokenize.scanner.peek()

        // 要考虑字符串中有双引号的情况
        if (currentChar === '\\') {
            const quoteMatch = tokenize.scanner.match("\"")
            if (quoteMatch) {
                result += "\\\""
                continue
            }
        }

        result += tokenize.scanner.advance()
    }

    if (tokenize.scanner.isAtEnd()) {
        return isMatch
    }

    if (tokenize.scanner.peek() === "\"") {
        result += tokenize.scanner.advance()
        tokenize.pushToken({
            tokenType: TokenType.STRING,
            value: result
        })
        isMatch = true
    }

    return isMatch
}

字符串匹配则比较麻烦, 因为要考虑如果字符串中有双引号的情况, 如果解析到反斜干, 要看下一个符号是否为双引号, 是的话, 要作为字符处理。isString要匹配的是所有非双引号字符:

export function isString(c: string) {
  return /[^"]/.test(c)
}

事实上, 我这里只是简单处理字符串情况, 字符串要考虑的情况非常多, 感兴趣的读者可参考json字符串词法的定义: github.com/antlr/gramm…

到这里词法分析已经完成了, 最后把它封装成函数, 传入字符串, 返回结果:

export function tokenize(scanner: Scanner) {
    const tokens = new Tokenize(scanner).parse()
    return tokens
}

只要把正则语法写出来, 词法分析没什么难点, 按着正则语法表达的逻辑实现就是了。

句法分析

如果现在向调用tokenize函数, 输入{[ true会返回词法分析结果, 但只有这些分析结果没有任何意义, 因为一看就知道它的句法是不正确的, 没有表达任何意义, 所以我们还得要做句法分析, 即告诉计算机正确的句法是什么, 返回分析结果。

要想告诉计算机正确的句法, 首先我们得要定义句法, 在编译原理中, 句法是用上下文无关语法来定义的。我们可以参考扩展巴科斯范式来表达 (不懂巴科斯范式不影响阅读, 这里就不说明巴科斯范式是什么, 感兴趣的读者可自行搜索):

Json -> Object | Value
Object -> "{" (Pair",")*Pair* "}"
Pair -> String":"Value
Value -> Array | PrimaryValue
Array -> "[" (Json",")*Json* "]"
PrimaryValue -> Boolean | String | Number | Json
Boolean -> BooleanLiteral
String -> StringLiteral
Number -> NumberLiteral

规则的定义是以层层递进的方式定义, 一直到定义至以 Token 表示为止。例如, Json -> Object | Value指的是Json的句法定义为可用 ObjectValue表示, 然后就要去解构它们了。Object则定义为以花括号包围的数据结构, 它里面可能有0或以上的元素, 以逗号分隔。一直解析, 直至以Token形式表示为止, 带有Literal后缀的就是Token了。

我们可以用Ast语法树形式来表示上述规则, 例如"{\"foo\": [{\"bar\":12}]}"可以表示为:

Ast.drawio.png

Ast语法树一直分解, 直到所有叶节点都是 Token为止。

这里要强调一下, 词法和语法分析最重要的不是写代码去实现, 而是定义词法和语法, 因为这两部分的代码实现都不难, 通常情况下根本不需要自己写, 可以直接用工具(如 nearley, antlr)去实现, 反而最难的是定义, 如果定义错误, 有工具也救不了。

最重要的内容已经写完了, 接下来就是代码实现部分, 我们要定义一个函数接收字符串, 返回对象:

export function parse(json: string) {
    const scanner = new Scanner(json)
    const tokens = tokenize(scanner)
    const parser = new Parser(tokens)
    return parser.parse()
}

Scannertokenize已经完成, 要做的是实现Parser:

class Parser {
    private tokens: Array<Token>

    private index: number

    constructor(tokens: Array<Token>) {
        this.tokens = tokens
        this.index = 0
    }

    private peek() {
        return this.tokens[this.index]
    }

    private peekNext() {
        return this.tokens[this.index + 1]
    }

    private advance() {
        this.index++
        return this.tokens[this.index - 1]
    }

    private isAtEnd() {
        return this.index >= this.tokens.length
    }

    private matchToken(tokenType: TokenType) {
        return this.peek().tokenType === tokenType
    }
}

首先先定义数据结构, 以及操控tokens的方法, 和Scanner的很相似, 不多解释了。接下来就把定义好的规则转成代码形式。先是 Json -> Object | Value:

parse() {
    return this.jsonObject()
}

private jsonObject() {
    if (this.peek().tokenType === TokenType.LEFT_BRACE) {
        return this.getObject()
    } else {
        return this.getValue()
    }
}

如果当前Token类型是左花括号, 则肯定是对象, 否则就要继续解析。接着是 Object -> "{" (Pair",")*Pair* "}":

private getObject() {
    if (!this.matchToken(TokenType.LEFT_BRACE)) {
        throw new Error("Object begins with left brace {")
    }
    this.advance()
    const result: Record<string, any> = {}

    // 空对象情况
    if (this.matchToken(TokenType.RIGHT_BRACE)) {
        this.advance()
        return result
    }

    while (!this.isAtEnd() && !this.matchToken(TokenType.RIGHT_BRACE)) {
        const [key, value] = this.getPair()
        result[key] = value

        // 一个或最后一个元素, 接着是右花括号, 跳出循环
        if (this.matchToken(TokenType.RIGHT_BRACE)) {
            break
        }

        if (this.matchToken(TokenType.COMMA) && 
            this.peekNext().tokenType !== TokenType.RIGHT_BRACE) {
                this.advance()
            } else {
                throw new Error("Object Syntax error")
            }
    }

    if (!this.matchToken(TokenType.RIGHT_BRACE)) {
        throw new Error("Object should be closed with right brace")
    } else {
        this.advance()
    }

    return result
}

要分三种情况处理, 空对象, 只有一个元素的对象和多个元素的对象。一个和最后一个元素的特点是相同的, 就是元素接着是右花括号, 没有逗号分隔。

Pair -> String":"Value

private getPair(): [string, any] {
    const key = this.getPrimaryValue() as string

    if (!this.matchToken(TokenType.SEMI_COLON)) {
        throw new Error("Semicolon should follow key")
    } else {
        this.advance()
    }

    const value = this.getPrimaryValue()

    return [key, value] 
}

对象的键一定是字符串, 解析完成了, 值则可能是boolean | number | string | object | array:

private getPrimaryValue() {
    if (this.matchToken(TokenType.BOOLEAN)) {
        const token = this.advance()
        return token.value === 'true' ? true : false
    }

    if (this.matchToken(TokenType.NUMBER)) {
        const token = this.advance()
        return token.value.indexOf(".") === -1 ? parseInt(token.value) : parseFloat(token.value)
    }

    if (this.matchToken(TokenType.STRING)) {
        const token = this.advance()
        const result = token.value.substring(1, token.value.length - 1)
        return result
    }

    if (this.matchToken(TokenType.LEFT_BRACKET)) {
        return this.getArray()
    }

    if (this.matchToken(TokenType.LEFT_BRACE)) {
        return this.getObject()
    }

    throw new Error("Unknown TokenType. Please check the syntax")
}

然后是数组情况, 与对象类似:

private getArray() {
    if (!this.matchToken(TokenType.LEFT_BRACKET)) {
        throw new Error("Array begins with left bracket [")
    }
    this.advance()
    const result: any[] = []

    if (this.matchToken(TokenType.RIGHT_BRACKET)) {
        this.advance()
        return result
    }

    while (!this.isAtEnd() && this.peekNext().tokenType === TokenType.COMMA) {
        result.push(this.getPrimaryValue())
        this.advance()
    }

    if (this.isAtEnd()) {
        throw new Error("Syntax error: array has to be closed with right bracket")
    }

    result.push(this.getPrimaryValue())

    if (!this.matchToken(TokenType.RIGHT_BRACKET)) {
        throw new Error("Syntax error: array has to be closed with right bracket")
    } else {
        this.advance()
    }

    return result
}

总结

通过实现Json解析, 发现其中的难题必须通过编译原理的知识来解决, 利用词法和句法分析解决了反序列化的难题。Json解析的编译原理应用算是比较简单, 没有涉及优先级以及上下文的处理, 甚至我们可以直接利用正则表达式就可以把词法和句法分析都实现了, 不需要上下文无关语法, 这里只是为了示范上下文无关语法的应用, 以及为了让代码看起来更好理解。

源碼地址 : gitee.com/Dominguito/…