自己動手寫json解析器0x01-分詞

wls1036發表於2022-12-22

背景

作為一個程式設計師,心裡一直有一個手擼編譯器的夢,奈何技術不夠一直沒能付諸實踐,JSON雖然不是一門語言,但很適合用來作為編譯器的練手,原因在於

  • 關鍵字較少,結構簡單
  • 語法簡單,沒有判斷,迴圈等高階語言語法
  • 文字格式,測試比較方便

雖然寫程式碼硬解析也能做到,但總歸不科學,對於複雜的語法,硬解析根本無法解決。從阮一峰的部落格瞭解到the-super-tiny-compiler這個專案,該專案是一個迷你編譯器,將lisp表示式轉化為c語言表達時,程式碼去掉註釋不到200行,很適合用來學習,這個專案給了我很多啟發,開始對寫一個json解析器有了一點思路,該系列博文將記錄一個完成json解析器的實現過程,當然我自己也是小白,不是什麼編譯器專家,只是希望給同樣是小白的你一點參考,大神可以繞道。

說明

如何把一個json字串解析成一個java物件,大概要分為一下步驟

  • 分詞(tokenizer)將json字串分解成一個個獨立的單元,比如下面這個簡單的json字串
{
  "name": "asan",
  "age": 32
}

經過分詞後會分解成下面這種格式

[
{"type":"object","value":"{","valueType":"object"},{"type":"key","value":"name","valueType":"string"},{"type":"value","value":"asan","valueType":"string"},{"type":"key","value":"age","valueType":"string"},{"type":"value","value":32,"valueType":"number"},{"type":"object","value":"}","valueType":"object"}
]

這期間會將無用字元過濾,分解為一個個token

  • 解析抽象語法樹(AST):tokenizer只是將字串分解平鋪,AST負責將平鋪的各個token按照語義變成一棵樹,帶有層級結構,比如上面的token解析抽象語法樹如下
{
    "items": [
        {
            "name": "name",
            "type": "value",
            "value": "asan"
        },
        {
            "name": "age",
            "type": "value",
            "value": 32
        }
    ],
    "type": "object"
}
  • 物件生成,根據抽象語法樹生成物件

無論是tokenizer還是ast,格式都不是固定,上面只是一個參考,但作用都是類似的,基本上解析器都要經過tokenizer和ast兩個步驟。

分詞(tokenizer)

示例json

如無特殊說明,後續程式都是基於以下這個json進行測試

{
  "name": "asan",
  "age": 32,
  "mail": null,
  "married": true,
  "birthday": "1992-02-08",
  "salary": 1234.56,
  "deposit": -342.34,
  "description": "a \"hundsome\" man",
  "tags": [
    "coder",
    "mid-age"
  ],
  "location": {
    "province": "福建省",
    "city": "泉州市",
    "area": "晉江市"
  },
  "family": [
    {
      "relation": "couple",
      "name": "Helen"
    },
    {
      "relation": "daughter",
      "name": "XiaoMan"
    }
  ]
}

該示例基本包含了json所有常用的元素,可以滿足基本的測試

  • 基本資料型別:字串,整型,浮點型,日期,null,布林值
  • 物件(location)
  • 陣列(family)
  • 基本型別陣列(tags)

分詞

我們首先定義一個儲存分詞結果的結構,該結構至少需要包含以下兩個欄位

  • type:token型別,包含object(物件),array(陣列),key(欄位名),value(欄位值),kvSymbol(key-value之間的冒號:)
  • value:token值

但一個type可能不足以描述json,比如json的value有字串,整型,浮點型等,但type都是value,你可能會說為什麼不每個定義一個型別呢,如果每個定義一個型別,那麼到時候判斷該token是不是value型別的時候就比較麻煩,需要依次判斷是不是字串,整型,浮點型等,因此我們增加了一個欄位valueType用來儲存值型別

  • type:token型別
  • value:token值
  • valueType:值型別(string,bool,null,number)

我們暫時不去定義列舉,先把解析器實現再去重構程式碼,暫時不考慮程式碼的合理性。

以下是第一版本的解析器

import java.util.ArrayList;
import java.util.List;

/**
 * @Description:
 * @author: jianfeng.zheng
 * @since: 2022/12/20 11:56
 * @history: 1.2022/12/20 created by jianfeng.zheng
 */
public class JSONParser {

    //currentIndex儲存當前字串掃描的位置,字串是逐字元進行掃描
    private int currentIndex = 0;

    /**
     * 對json字串進行分詞
     *
     * @param json json字串
     * @return token列表
     */
    public List<Token> tokenizer(String json) {
        // 儲存分詞結果
        List<Token> tokens = new ArrayList<>();

        while (currentIndex < json.length()) {
            char c = json.charAt(currentIndex);

            //對於空白符可以直接跳過,如果有更多的空白符只需新增新的判斷即可
            if (c == ' ' || c == '\r' || c == '\n' || c == ',') {
                //字元只要處理過了必須要將當前位置移動到下一個
                ++currentIndex;
                continue;
            } else if (c == '{' || c == '}') {
                //物件
                tokens.add(new Token("object", c));
            } else if (c == '[' || c == ']') {
                //陣列
                tokens.add(new Token("array", c));
            } else if (c == '"') {
                //字串
                StringBuffer value = new StringBuffer();
                char cc = json.charAt(++currentIndex);

                // 這裡以"作為字串結束符的標誌
                // 當然這個不嚴謹因為沒考慮到轉義,但這個問題留著後面解決,我們暫時忽略
                while (cc != '"') {
                    value.append(cc);
                    cc = json.charAt(++currentIndex);
                }
                tokens.add(new Token("string", value.toString()));
            } else if (c == ':') {
                // key-value中間的分隔符
                tokens.add(new Token("kvSymbol", "kvSymbol", c));
            } else if (c >= '0' && c <= '9') {
                //數字
                StringBuffer value = new StringBuffer();
                value.append(c);
                char cc = json.charAt(++currentIndex);
                //這裡考慮到帶有小數點的浮點數
                while (cc == '.' || (cc >= '0' && cc <= '9')) {
                    value.append(cc);
                    cc = json.charAt(++currentIndex);
                }
                //數字暫時統一用浮點數進行表示
                tokens.add(new Token("value", "number", Float.parseFloat(value.toString())));
            }
            ++currentIndex;
        }
        return tokens;
    }
}

程式碼流程如下

  • 迴圈遍歷json字串
  • 檢測關鍵字,並識別出關鍵字以token儲存
  • 對於字串的處理目前不論是key還是value統一儲存的是string型別
  • 對於數字型別的處理目前都是以Float浮點數進行儲存

測試

我們寫一個程式程式進行測試

public class Main {

    public static void main(String[] args) {
        String json = "{\"name\": \"asan\", \"age\": 32}";
        JSONParser parser = new JSONParser();
        List<Token> tokens = parser.tokenizer(json);
        System.out.println(String.format("|%-12s|%-12s|%-15s|", "type", "valueType", "value"));
        System.out.println("-------------------------------------------");
        for (Token t : tokens) {
            System.out.println(String.format("|%-12s|%-12s|%-15s|",
                    t.getType(),
                    t.getValueType(),
                    t.getValue()));
        }
        System.out.println("-------------------------------------------");
    }
}

我們先拿一個比較簡單的json{"name": "asan", "age": 32}進行測試,測試結果如下

|type        |valueType   |value          |
-------------------------------------------
|object      |object      |{              |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |asan           |
|string      |string      |age            |
|kvSymbol    |kvSymbol    |:              |
|value       |number      |32.0           |
-------------------------------------------

目前這個結果符合我們的預期。

最佳化

其他基本型別

目前程式value型別程式只處理了字串和數字,bool和null型別未處理,由於程式是一個字元一個字元對字串進行掃描,但要判斷bool和null必須往後進行掃描。

  • 判斷null
if ((c == 'n') &&
    json.startsWith("null", currentIndex)) {
  tokens.add(new Token("value", "null", null));
  //如果讀取到null值需要將當前指標往前移動3個字元(null佔4個字元,除去已經讀取到的1個字串還需要移動3個字元)
  currentIndex += 3;
}
  • 判斷bool值

bool值的判斷就是判斷true和false兩個字串,和判斷空值類似

if ((c == 't') &&
    json.startsWith("true", currentIndex)) {
  tokens.add(new Token("value", "bool", true));
  currentIndex += 3;
}
if ((c == 'f') &&
    json.startsWith("false", currentIndex)) {
  tokens.add(new Token("value", "bool", false));
  //false是5個字元因此需要移動4位
  currentIndex += 4;
}

我們將測試的json字串修改為{"name": "asan", "age": 32,"mail": null,"married": true}再進行測試,結果如下

|type        |valueType   |value          |
-------------------------------------------
|object      |object      |{              |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |asan           |
|string      |string      |age            |
|kvSymbol    |kvSymbol    |:              |
|value       |number      |32.0           |
|string      |string      |mail           |
|kvSymbol    |kvSymbol    |:              |
|value       |null        |null           |
|string      |string      |married        |
|kvSymbol    |kvSymbol    |:              |
|value       |bool        |true           |
|object      |object      |}              |
-------------------------------------------

結果符合預期。

字串處理

字串目前的處理方式是檢測到"就當作是字串,直到下一個"出現,但這種處理方式是不嚴謹的,有可能字串本身就包含了",因此需要對跳脫字元進行處理,我們修改字串的處理函式

if (c == '"') {
  //字串
  StringBuffer value = new StringBuffer();
  char cc = json.charAt(++currentIndex);
  // 這裡以"作為字串結束符的標誌
  while (cc != '"') {
      if (cc == '\\') {
          cc = json.charAt(++currentIndex);
      }
      value.append(cc);
      cc = json.charAt(++currentIndex);
  }
  tokens.add(new Token("string", value.toString()));
 }

我們將測試字串改為{"name": "asan", "age": 32,"description": "a \"hudsom\" man","married": true}再測試,結果如下

|type        |valueType   |value          |
-------------------------------------------
|object      |object      |{              |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |asan           |
|string      |string      |age            |
|kvSymbol    |kvSymbol    |:              |
|value       |number      |32.0           |
|string      |string      |description    |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |a "hudsom" man |
|string      |string      |married        |
|kvSymbol    |kvSymbol    |:              |
|value       |bool        |true           |
|object      |object      |}              |
-------------------------------------------

成功識別到了跳脫字元串

數字處理

數字處理目前也有一些問題

  • 沒有處理負數情況
  • 沒有處理科學技術法
  • 沒有區分浮點和整型統一都是浮點數

程式修改如下

if ((c >= '0' && c <= '9') || c == '-') {
  // 數字
  StringBuffer value = new StringBuffer();
  value.append(c);
  // 判斷是不是浮點數
  boolean isFloat = false;
  //如果json是一位整型比如:1,那麼這裡不判斷就會報錯
  if (currentIndex + 1 < json.length()) {
      char cc = json.charAt(++currentIndex);
      // 判斷包含浮點型,整型,科學技術法
      while (cc == '.' || (cc >= '0' && cc <= '9') || cc == 'e' || cc == 'E' || cc == '+' || cc == '-') {
          value.append(cc);
          if (cc == '.') {
              isFloat = true;
          }
          cc = json.charAt(++currentIndex);
      }
  }
  if (isFloat) {
      //浮點數
      tokens.add(new Token("value", "float", Float.parseFloat(value.toString())));
  } else {
      //整型
      tokens.add(new Token("value", "long", Long.parseLong(value.toString())));
  }
}

我們用字串{"age":32,"deposit": -342.34}進行測試,測試結果如下

|type        |valueType   |value          |
-------------------------------------------
|object      |object      |{              |
|string      |string      |age            |
|kvSymbol    |kvSymbol    |:              |
|value       |long        |32             |
|string      |string      |deposit        |
|kvSymbol    |kvSymbol    |:              |
|value       |float       |-342.34        |
-------------------------------------------

完整測試

我們用完整的字串進行測試,結果如下

|type        |valueType   |value          |
-------------------------------------------
|object      |object      |{              |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |asan           |
|string      |string      |age            |
|kvSymbol    |kvSymbol    |:              |
|value       |long        |32             |
|string      |string      |married        |
|kvSymbol    |kvSymbol    |:              |
|value       |bool        |true           |
|string      |string      |birthday       |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |1992-02-08     |
|string      |string      |salary         |
|kvSymbol    |kvSymbol    |:              |
|value       |float       |1234.56        |
|string      |string      |description    |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |a "hudsom" man |
|string      |string      |tags           |
|kvSymbol    |kvSymbol    |:              |
|array       |array       |[              |
|string      |string      |coder          |
|string      |string      |mid-age        |
|array       |array       |]              |
|string      |string      |location       |
|kvSymbol    |kvSymbol    |:              |
|object      |object      |{              |
|string      |string      |province       |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |福建省            |
|string      |string      |city           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |泉州市            |
|string      |string      |area           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |晉江市            |
|object      |object      |}              |
|string      |string      |family         |
|kvSymbol    |kvSymbol    |:              |
|array       |array       |[              |
|object      |object      |{              |
|string      |string      |relation       |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |couple         |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |Helen          |
|object      |object      |}              |
|object      |object      |{              |
|string      |string      |relation       |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |daughter       |
|string      |string      |name           |
|kvSymbol    |kvSymbol    |:              |
|string      |string      |XiaoMan        |
|object      |object      |}              |
|array       |array       |]              |
|object      |object      |}              |
-------------------------------------------

完整程式碼

public class JSONParser {

    //currentIndex儲存當前字串掃描的位置,字串是逐字元進行掃描
    private int currentIndex = 0;

    /**
     * 對json字串進行分詞
     *
     * @param json json字串
     * @return token列表
     */
    public List<Token> tokenizer(String json) {
        // 儲存分詞結果
        List<Token> tokens = new ArrayList<>();
        while (currentIndex < json.length()) {
            char c = json.charAt(currentIndex);
            //對於空白符可以直接跳過,如果有更多的空白符只需新增新的判斷即可
            if (c == ' ' || c == '\r' || c == '\n' || c == ',') {
                //字元只要處理過了必須要將當前位置移動到下一個
                ++currentIndex;
                continue;
            } else if (c == '{' || c == '}') {
                //物件
                tokens.add(new Token("object", c));
            } else if (c == '[' || c == ']') {
                //陣列
                tokens.add(new Token("array", c));
            } else if (c == '"') {
                //字串
                StringBuffer value = new StringBuffer();
                char cc = json.charAt(++currentIndex);
                // 這裡以"作為字串結束符的標誌
                while (cc != '"') {
                    if (cc == '\\') {
                        cc = json.charAt(++currentIndex);
                    }
                    value.append(cc);
                    cc = json.charAt(++currentIndex);
                }
                tokens.add(new Token("string", value.toString()));
            } else if (c == ':') {
                // key-value中間的分隔符
                tokens.add(new Token("kvSymbol", "kvSymbol", c));
            } else if ((c >= '0' && c <= '9') || c == '-') {
                // 數字
                StringBuffer value = new StringBuffer();
                value.append(c);
                // 判斷是不是浮點數
                boolean isFloat = false;
                //如果json是一位整型比如:1,那麼這裡不判斷就會報錯
                if (currentIndex + 1 < json.length()) {
                    char cc = json.charAt(++currentIndex);
                    // 判斷包含浮點型,整型,科學技術法
                    while (cc == '.' || (cc >= '0' && cc <= '9') || cc == 'e' || cc == 'E' || cc == '+' || cc == '-') {
                        value.append(cc);
                        if (cc == '.') {
                            isFloat = true;
                        }
                        cc = json.charAt(++currentIndex);
                    }
                }
                if (isFloat) {
                    //浮點數
                    tokens.add(new Token("value", "float", Float.parseFloat(value.toString())));
                } else {
                    //整型
                    tokens.add(new Token("value", "long", Long.parseLong(value.toString())));
                }
            } else if ((c == 'n') && json.startsWith("null", currentIndex)) {
                tokens.add(new Token("value", "null", null));
                currentIndex += 3;
            } else if ((c == 't') &&
                    json.startsWith("true", currentIndex)) {
                tokens.add(new Token("value", "bool", true));
                currentIndex += 3;
            } else if ((c == 'f') &&
                    json.startsWith("false", currentIndex)) {
                tokens.add(new Token("value", "bool", false));
                //false是5個字元因此需要移動4位
                currentIndex += 4;
            }
            ++currentIndex;
        }
        //將當前位置重置
        currentIndex = 0;
        return tokens;
    }
}

程式碼

完整程式碼請參考專案https://github.com/wls1036/tiny-json-parser的0x01分支

相關文章