面试题|手写JSON解析器

这周的 Cassidoo 的每周简讯有这么一个面试题::

写一个函数,这个函数接收一个正确的 JSON 字符串并将其转化为一个对象(或字典,映射等,这取决于你选择的语言)。示例输入:

fakeParseJSON('{ "data": { "fish": "cake", "array": [1,2,3], "children": [ { "something": "else" }, { "candy": "cane" }, { "sponge": "bob" } ] } } ')

当时,我想这么写:

const fakeParseJSON = JSON.parse;

但是,我想起之前写了一些关于AST的文章,

其中涵盖了编译器管道的概述以及如何操作AST,但是我没有过多介绍如何实现解析器。因为实现JavaScript编译器对我来说是一项艰巨的任务。

那就没必要担心。 JSON也是一种语言,有自己的语法,可以参考规范。 根据编写JSON解析器所需的知识和技术转移到编写JS解析器中。

好了,那就开始编写一个JSON解析器吧。

语法

查看规范文档页面,可以看到以下两个图。

json
  element

value
  object
  array
  string
  number
  "true"
  "false"
  "null"

object
  '{' ws '}'
  '{' members '}'

两个图其实是等价的。

一个基于视觉,一个基于文本。基于文本语法的语法 —— 巴科斯-诺尔范式,通常被提供给另一个解析这种语法并为其生成解析器的解析器,终于说到解析器了!??

在这篇文章中,我们重点关注铁路图上,因为它是可视化的,看起来更友好。

先来看下第一张的铁路图:

所以这就是JSON中“object”的语法。

从左侧开始,沿着箭头的方向走,一直到右侧为止。

圆圈里面是一个字符,例如 {,:},矩形里面是其它语法的占位符,例如 whitespace(空格)stringvalue。因此要解析"whitespace",我们需要查阅"whitepsace"语法。

因此,对于一个对象而言,从左边开始,第一个字符必须是一个左花括号 {,然后往下走会有两种情况:

  • whitespace} → 结束
  • whitespacestringwhitespace:value} → 结束

当然当抵达value的时候,你可以选择继续下去:

  • } → 结束,或者
  • ,whitespace → … → value
    你可以继续循环,直到你决定去:
  • } → 结束。
    现在已经熟悉了铁路图,我们继续到下一节。

实现解析器

下面我们开始编写代码,代码结构如下:

function fakeParseJSON(str) {
  let i = 0;
  // TODO
}

初始化 i 将其作为当前字符的索引值,只要 i 值到达 str 的长度,我们就会结束函数。

后面我们来实现“object”语法:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      // 如果不是 '}',
      // 我们接收 string -> whitespace -> ':' -> value -> ... 这样的路径字符串
      while (str[i] !== '}') {
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
      }
    }
  }
}

我们可以调用 parseObject 来解析类似string和whitespace之类的语法,只要我们实现这些功能,一切都解决了??。

还有就是我我忘记加逗号了。逗号只会出现在开始第二次whitespacestringwhitespace: → … 循环之前。

在这个基础上,我们加上了一下几行:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      let initial = true;
      // 如果不是 '}',
      // 就按照这样的路径执行: string -> whitespace -> ':' -> value -> ...
      while (str[i] !== '}') {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
        initial = false;
      }
      // move to the next character of '}'
      i++;
    }
  }
}

一些命名上的约定:

  • 当我们根据语法解析代码并使用返回值时,命名为parseSomething
  • 当我们期望字符在那里,但是我们没有使用字符时,命名为eatSomething
  • 当字符不存在,我们也可以接受。命名skipSomething

下面来实现eatCommaeatColon

function fakeParseJSON(str) {
  // ...
  function eatComma() {
    if (str[i] !== ',') {
      throw new Error('Expected ",".');
    }
    i++;
  }

  function eatColon() {
    if (str[i] !== ':') {
      throw new Error('Expected ":".');
    }
    i++;
  }
}

到目前为止,我们实现了parseObject的语法,但是这个解析函数的返回值是什么呢?

不错,我们需要返回一个JavaScript对象:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      const result = {};

      let initial = true;
      // 如果不是 '}',
      // 就按照这样的路径执行: string -> whitespace -> ':' -> value -> ...
      while (str[i] !== '}') {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
        result[key] = value;
        initial = false;
      }
      // 移动到下一个字符 '}'
      i++;

      return result;
    }
  }
}

现在你已经看到我怎么去实现“object“语法,现在是时候让你尝试一下”array“语法了:

function fakeParseJSON(str) {
  // ...
  function parseArray() {
    if (str[i] === '[') {
      i++;
      skipWhitespace();

      const result = [];
      let initial = true;
      while (str[i] !== ']') {
        if (!initial) {
          eatComma();
        }
        const value = parseValue();
        result.push(value);
        initial = false;
      }
      // 移动到下一个字符 ']'
      i++;
      return result;
    }
  }
}

现在,我们来看一个更有趣的语法,“value”:

如上图的路径:
一个值是以“whitespace”开始,然后接着是以下类型的一种:“string”,“number”,“object”,“array”,“true”,“false” 或者null,最后以一个“whitespace”结束。

function fakeParseJSON(str) {
  // ...
  function parseValue() {
    skipWhitespace();
    const value =
      parseString() ??
      parseNumber() ??
      parseObject() ??
      parseArray() ??
      parseKeyword('true', true) ??
      parseKeyword('false', false) ??
      parseKeyword('null', null);
    skipWhitespace();
    return value;
  }
}

这个??叫做空值合并运算符,它类似我们用来设置默认值 foo || default 中的 ||,只要foo是假值,|| 就会返回 default, 而空值合并运算符只会在 foo 为 null 或 undefined 时返回 default。可以看个例子:

const foo = null ?? 'default string';
console.log(foo);
// 输出: "default string"

parseKeyword 将检查当前 str.slice(i) 是否与关键字字符串匹配,如果匹配,将返回关键字值:

function fakeParseJSON(str) {
  // ...
  function parseKeyword(name, value) {
    if (str.slice(i, i + name.length) === name) {
      i += name.length;
      return value;
    }
  }
}

这个就是parseKeyword的实现。

我们还有 3 个以上的语法要实现,但我为了控制文章篇幅,在下面的 CodeSandbox 中实现这些语法。
CodeSandbox

完成所有语法实现之后,然后返回由parseValue返回的json值:

function fakeParseJSON(str) {
  let i = 0;
  return parseValue();

  // ...
}

就是这样!

好了,还没有那么快完成朋友,我们只是完成的理想的部分,那么非理想的部分呢?

处理异常输入

作为一个优秀的开发人员,我们也需要优雅地处理非理想情况。对于解析器,这意味着使用适当的错误消息大声警告开发人员。

让我们来处理两个最常见的错误情况:

  • Unexpected token
  • Unexpected end of string

在所有的 while 循环中,例如 parseObject 中的 while 循环:

function fakeParseJSON(str) {
  // ...
  function parseObject() {
    // ...
    while(str[i] !== '}') {

我们需要确保访问的字符不会超过字符串的长度。这发生在字符串意外结束时,而我们仍然在等待一个结束字符 —— }。比如说下面的例子:

function fakeParseJSON(str) {
  // ...
  function parseObject() {
    // ...
    while (i < str.length && str[i] !== '}') {
      // ...
    }
    checkUnexpectedEndOfInput();

    // move to the next character of '}'
    i++;

    return result;
  }
}

更好的优化

你还记得当你还是一个初级开发者的时候,每次遇到一些不清晰的语法错误的时候,你完全不知道哪里出问题了?

现在你有经验了,是时候停止这种恶性循环和吐槽了。

Unexpected token "a"

例如以上的错误,只会让用户很困惑地盯着屏幕,而不知道错误在哪里。

相比去吐槽,其实有很多更好的方式去改善这些错误提示,下面有几点建议可以考虑加到解析器里面:

错误码和标准错误信息

标准关键字对用户谷歌寻求帮助很有用

// 不好的提示
Unexpected token "a"
Unexpected end of input

// 好的提示
JSON_ERROR_001 Unexpected token "a"
JSON_ERROR_002 Unexpected end of input

更好地查看哪里出问题

像 Babel 这样的解析器,会向你显示一个代码框架,它是一个带有下划线、箭头或突出显示错误的代码片段

// 不好的提示
Unexpected token "a" at position 5

// 好的提示
{ "b"a
      ^
JSON_ERROR_001 Unexpected token "a"

一个如何输出代码片段的例子:

function fakeParseJSON(str) {
  // ...
  function printCodeSnippet() {
    const from = Math.max(0, i - 10);
    const trimmed = from > 0;
    const padding = (trimmed ? 3 : 0) + (i - from);
    const snippet = [
      (trimmed ? '...' : '') + str.slice(from, i + 1),
      ' '.repeat(padding) + '^',
      ' '.repeat(padding) + message,
    ].join('\n');
    console.log(snippet);
  }
}

修正错误建议

可以的话,可以说明是哪里出问题以及给出修复建议。

// 不好的提示
Unexpected token "a" at position 5

// 好的提示
{ "b"a
      ^
JSON_ERROR_001 Unexpected token "a".
Expecting a ":" over here, eg:
{ "b": "bar" }
      ^
You can learn more about valid JSON string in http://goo.gl/xxxxx

如果可能,根据解析器目前收集的上下文提供建议

fakeParseJSON('"Lorem ipsum');

// instead of
Expecting a `"` over here, eg:
"Foo Bar"
        ^

// show
Expecting a `"` over here, eg:
"Lorem ipsum"
            ^

基于上下文的建议会让人感觉更有关联性和可操作性。 记住所有的建议,用以下几点检查已经更新的CodeSandbox

  • 有意义的错误消息
  • 带有错误指向失败点的代码段
  • 为错误恢复提供建议

推荐阅读Evan Czaplicki的关于如何提高编译器用户体验的一篇文章“编译器错误建议

完整代码:

function fakeParseJSON(str) {
  let i = 0;

  const value = parseValue();
  expectEndOfInput();
  return value;

  function parseObject() {
    if (str[i] === "{") {
      i++;
      skipWhitespace();

      const result = {};

      let initial = true;
      // if it is not '}',
      // we take the path of string -> whitespace -> ':' -> value -> ...
      while (i < str.length && str[i] !== "}") {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        if (key === undefined) {
          expectObjectKey();
        }
        skipWhitespace();
        eatColon();
        const value = parseValue();
        result[key] = value;
        initial = false;
      }
      expectNotEndOfInput("}");
      // move to the next character of '}'
      i++;

      return result;
    }
  }

  function parseArray() {
    if (str[i] === "[") {
      i++;
      skipWhitespace();

      const result = [];
      let initial = true;
      while (i < str.length && str[i] !== "]") {
        if (!initial) {
          eatComma();
        }
        const value = parseValue();
        result.push(value);
        initial = false;
      }
      expectNotEndOfInput("]");
      // move to the next character of ']'
      i++;
      return result;
    }
  }

  function parseValue() {
    skipWhitespace();
    const value =
      parseString() ??
      parseNumber() ??
      parseObject() ??
      parseArray() ??
      parseKeyword("true", true) ??
      parseKeyword("false", false) ??
      parseKeyword("null", null);
    skipWhitespace();
    return value;
  }

  function parseKeyword(name, value) {
    if (str.slice(i, i + name.length) === name) {
      i += name.length;
      return value;
    }
  }

  function skipWhitespace() {
    while (
      str[i] === " " ||
      str[i] === "\n" ||
      str[i] === "\t" ||
      str[i] === "\r"
    ) {
      i++;
    }
  }

  function parseString() {
    if (str[i] === '"') {
      i++;
      let result = "";
      while (i < str.length && str[i] !== '"') {
        if (str[i] === "\\") {
          const char = str[i + 1];
          if (
            char === '"' ||
            char === "\\" ||
            char === "/" ||
            char === "b" ||
            char === "f" ||
            char === "n" ||
            char === "r" ||
            char === "t"
          ) {
            result += char;
            i++;
          } else if (char === "u") {
            if (
              isHexadecimal(str[i + 2]) &&
              isHexadecimal(str[i + 3]) &&
              isHexadecimal(str[i + 4]) &&
              isHexadecimal(str[i + 5])
            ) {
              result += String.fromCharCode(
                parseInt(str.slice(i + 2, i + 6), 16)
              );
              i += 5;
            } else {
              i += 2;
              expectEscapeUnicode(result);
            }
          } else {
            expectEscapeCharacter(result);
          }
        } else {
          result += str[i];
        }
        i++;
      }
      expectNotEndOfInput('"');
      i++;
      return result;
    }
  }

  function isHexadecimal(char) {
    return (
      (char >= "0" && char <= "9") ||
      (char.toLowerCase() >= "a" && char.toLowerCase() <= "f")
    );
  }

  function parseNumber() {
    let start = i;
    if (str[i] === "-") {
      i++;
      expectDigit(str.slice(start, i));
    }
    if (str[i] === "0") {
      i++;
    } else if (str[i] >= "1" && str[i] <= "9") {
      i++;
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }

    if (str[i] === ".") {
      i++;
      expectDigit(str.slice(start, i));
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }
    if (str[i] === "e" || str[i] === "E") {
      i++;
      if (str[i] === "-" || str[i] === "+") {
        i++;
      }
      expectDigit(str.slice(start, i));
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }
    if (i > start) {
      return Number(str.slice(start, i));
    }
  }

  function eatComma() {
    expectCharacter(",");
    i++;
  }

  function eatColon() {
    expectCharacter(":");
    i++;
  }

  // error handling
  function expectNotEndOfInput(expected) {
    if (i === str.length) {
      printCodeSnippet(`Expecting a \`${expected}\` here`);
      throw new Error("JSON_ERROR_0001 Unexpected End of Input");
    }
  }

  function expectEndOfInput() {
    if (i < str.length) {
      printCodeSnippet("Expecting to end here");
      throw new Error("JSON_ERROR_0002 Expected End of Input");
    }
  }

  function expectObjectKey() {
    printCodeSnippet(`Expecting object key here

For example:
{ "foo": "bar" }
  ^^^^^`);
    throw new Error("JSON_ERROR_0003 Expecting JSON Key");
  }

  function expectCharacter(expected) {
    if (str[i] !== expected) {
      printCodeSnippet(`Expecting a \`${expected}\` here`);
      throw new Error("JSON_ERROR_0004 Unexpected token");
    }
  }

  function expectDigit(numSoFar) {
    if (!(str[i] >= "0" && str[i] <= "9")) {
      printCodeSnippet(`JSON_ERROR_0005 Expecting a digit here

For example:
${numSoFar}5
${" ".repeat(numSoFar.length)}^`);
      throw new Error("JSON_ERROR_0006 Expecting a digit");
    }
  }

  function expectEscapeCharacter(strSoFar) {
    printCodeSnippet(`JSON_ERROR_0007 Expecting escape character

For example:
"${strSoFar}\\n"
${" ".repeat(strSoFar.length + 1)}^^
List of escape characters are: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t, \\u`);
    throw new Error("JSON_ERROR_0008 Expecting an escape character");
  }

  function expectEscapeUnicode(strSoFar) {
    printCodeSnippet(`Expect escape unicode

For example:
"${strSoFar}\\u0123
${" ".repeat(strSoFar.length + 1)}^^^^^^`);
    throw new Error("JSON_ERROR_0009 Expecting an escape unicode");
  }

  function printCodeSnippet(message) {
    const from = Math.max(0, i - 10);
    const trimmed = from > 0;
    const padding = (trimmed ? 4 : 0) + (i - from);
    const snippet = [
      (trimmed ? "... " : "") + str.slice(from, i + 1),
      " ".repeat(padding) + "^",
      " ".repeat(padding) + message
    ].join("\n");
    console.log(snippet);
  }
}

// console.log("Try uncommenting the fail cases and see their error message");
// console.log("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");

// Fail cases:
printFailCase("-");
printFailCase("-1.");
printFailCase("1e");
printFailCase("-1e-2.2");
printFailCase("{");
printFailCase("{}{");
printFailCase('{"a"');
printFailCase('{"a": "b",');
printFailCase('{"a":"b""c"');
printFailCase('{"a":"foo\\}');
printFailCase('{"a":"foo\\u"}');
printFailCase("[");
printFailCase("[][");
printFailCase("[[]");
printFailCase('["]');

function printFailCase(json) {
  try {
    console.log(`fakeParseJSON('${json}')`);
    fakeParseJSON(json);
  } catch (error) {
    console.error(error);
  }
}

总结

要实现解析器,你需要从语法开始。
你可以用铁路图或巴科斯-诺尔范式来使语法正式化。设计语法是最困难的一步。

一旦你解决了语法问题,就可以开始基于语法实现解析器。

错误处理很重要,更重要的是要有有意义的错误消息,以便用户知道如何修复它。

现在,你已经了解了如何实现简单的解析器,现在应该关注更复杂的解析器了:

最后,请关注 @cassidoo,她的每周简讯棒极了。
(完)

以上译文仅用于学习交流,水平有限,难免有错误之处,敬请指正。如果觉得文章对你有帮助,请点个赞吧。

参考

原文地址:https://www.cnblogs.com/GeniusLyzh/p/12350647.html

时间: 2024-10-19 09:53:41

面试题|手写JSON解析器的相关文章

Atiit 如何手写词法解析器

1.1. 通过编程直接从正则->nfa->dfa->表驱动词法解析一条龙自动生成.那是用程序自动生成是需要这样的,自己手写完全不必要这么复杂1 1.2. 状态转移表.使用状态表比较简单,dfa比较麻烦.Dfa其实就是比较高级的状态表..1 1.3. 然后给了你代码框架(这里以nested case statement 为例):2 1.4. 源码实现2 1.1. 通过编程直接从正则->nfa->dfa->表驱动词法解析一条龙自动生成.那是用程序自动生成是需要这样的,自己手

一起写一个JSON解析器

[本篇博文会介绍JSON解析的原理与实现,并一步一步写出来一个简单但实用的JSON解析器,项目地址:SimpleJSON.希望通过这篇博文,能让我们以后与JSON打交道时更加得心应手.由于个人水平有限,叙述中难免存在不准确或是不清晰的地方,希望大家可以指正:)] 一.JSON解析器介绍 相信大家在平时的开发中没少与JSON打交道,那么我们平常使用的一些JSON解析库都为我们做了哪些工作呢?这里我们以知乎日报API返回的JSON数据来介绍一下两个主流JSON解析库的用法.我们对地址 http://

这个东西,写C++插件的可以用到。 RapidJSON —— C++ 快速 JSON 解析器和生成器

原文: RapidJSON —— C++ 快速 JSON 解析器和生成器 时间 2015-04-05 07:33:33  开源中国新闻原文  http://www.oschina.net/p/rapidjson 4月18日 武汉 源创会开始报名,送华为开发板 Rapidjson 是一个 C++ 的快速 JSON 解析器和生成器,使用 SAX/DOM 风格的 API 设计. 示例代码: // rapidjson/example/simpledom/simpledom.cpp` #include "

自己动手实现一个简单的JSON解析器

1. 背景 JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式.相对于另一种数据交换格式 XML,JSON 有着诸多优点.比如易读性更好,占用空间更少等.在 web 应用开发领域内,得益于 JavaScript 对 JSON 提供的良好支持,JSON 要比 XML 更受开发人员青睐.所以作为开发人员,如果有兴趣的话,还是应该深入了解一下 JSON 相关的知识.本着探究 JSON 原理的目的,我将会在这篇文章中详细向大家介绍一个简单的JSON解析器的解析流

使用HttpClient+Json解析器爬取数据并存入数据库

一.题目要求 说明:这里我只展示爬取数据的代码,将可视化结果与统计数据结合,实时显示当前最新数据只需将这篇博客代码和我那篇使用Echarts可视化数据库数据结合即可 二.思路 同学思路:我的大部分同学思路是使用HttpClient+Jsoup获取并解析目标页面的数据,然后调用Dao层中的方法将数据筛选并存入数据库中 我的思路:我直接使用了一个网上的一个最新疫情数据Json的目标网页,然后使用HttpClient获取该Json格式数据,然后使用Json解析器将 数据分离,然后调用Dao层中的方法将

大家都懂的 JSON 解析器原理(一)简介 & 低配版入门

没学过编译原理,做一个 JSON 解析器难吗?--难!是不是就不能"迎难而上"呢?--不是!越是难的越是一个挑战!--笔者这里尝试通过通俗易懂的行文为大家介绍一下 JSON 解析器,--那一串串长长的 JSON 文本到底是如何被解析成为 Java 里面"可以理解的"对象的.前面的铺垫可能比较长,但请尽量不要跳过,因为那都是基础,尤其对于我们非科班来说,应要恶补.当然,为照顾大家的理解程度(包括我自己,我也会以后回看自己的代码,以此反复理解.反复消化),我会把代码写多

ASP.NET万能JSON解析器

ASP.NET万能JSON解析器 概念介绍还是先简单说说Json的一些例子吧.注意,以下概念是我自己定义的,可以参考.net里面的TYPE的模型设计如果有争议,欢迎提出来探讨!1.最简单:{"total":0} total就是值,值是数值,等于02. 复杂点{"total":0,"data":{"377149574" : 1}}total是值,data是对象,这个对象包含了"377149574"这个值,等于

Spring MVC4设置使用fastjson作为json解析器,替代jackson

不论是性能.易用性.特性支持,fastjson都要远好于默认的jackson,所以如果应用程序经常使用ajax进行数据交互,建议用fastjson作为默认解析器,只需要简单配置: <mvc:annotation-driven>   <mvc:message-converters register-defaults="true">     <bean class="com.alibaba.fastjson.support.spring.FastJs

如何编写一个JSON解析器

编写一个JSON解析器实际上就是一个函数,它的输入是一个表示JSON的字符串,输出是结构化的对应到语言本身的数据结构. 和XML相比,JSON本身结构非常简单,并且仅有几种数据类型,以Java为例,对应的数据结构是: "string":Java的String: number:Java的Long或Double: true/false:Java的Boolean: null:Java的null: [array]:Java的List<Object>或Object[]: {"