造輪子系列(一): 一個速度九分快的JSON解析器

前一陣子看到了一個Golang的JSON庫go-simplejson,用來封裝與解析匿名的JSON,說白了就是用map或者slice等來解析JSON,以爲挺好玩,後來有個項目剛好要解析JSON,因而就試了試,不當心看了一眼源代碼,發現居然是用的Golang自帶的encoding/json庫去作的解析,而其自己只是把這個庫封裝了一層,看起來更好看罷了。因而心想能不能徒手寫一個解析器,畢竟寫了這麼多年代碼了,也JSON.parseJSON.stringify了無數次。搗騰了兩天,終於成了,測試了一下,性能比自帶的庫要高不少,速度基本上在1.67倍之間(視JSON串的大小和結構而定),因此決定寫這篇文章分享一下思路。node

先插一個段子,做爲一個已經完完整整寫了將近三年代碼的老碼農,前一段面試,不止一次有面試官問我:如何深拷貝一個對象(JS),我笑笑說寫一個Walk函數遞歸一下就好了啊,若是要考慮到Stackoverflow,那就用棧+迭代就行了。而後他們總是問我,有沒有更好的辦法,而後自言自語的說你能夠用JSON先序列化一遍再反序列化……git

項目取名cheapjson,意思是便宜的,由於你不光不須要定義各個struct,性能還比原生的快,因此很便宜。地址在 https://github.com/acrazing/c...,有興趣的能夠看看~github

JSON value

首先既然是便宜的,便和反射無關了,因此void *是必需的,固然在Golang裏面是interface{},而後須要一個結構來保存必需的信息,進行類型判斷以及邊界檢查。若是是C的話,數組大小,字符串長度,對象Key/Value映射都是必需的工做。不過在Golang裏面就不須要了,編譯器已經搞定了全部的工做。面試

在JSON當中,一個完整的JSON應該包含一個value,這個value的類型多是nulltruefalsenumberstringarray以及 object共6種。而arrayobject還有可能包含子value結構。這些類型的值映射到Golang當中,即是nil, bool, bool, int64/float64, string, []interface{}, map[string]interface{},用一個union結構即可以搞定。注意這裏的number有能夠轉換成整數或者是浮點數,在JavaScript中,所有用64位雙精度浮點數儲存,因此最大的精確整數也就是非規約數是尾數部分2^53 - 1,已經遠遠大於int32了,因此這裏將整數映射成了int64而不是int,由於在部分機器上可能溢出,嚴格的區分一個IEEE-754格式的整數和浮點數並非一件輕鬆的事情,這裏簡化成了若是尾數中的小數部分以及指數部分均不存在,則認爲是一個整數,此外,爲了簡化操做,對於任何不合法的UTF-16字符串,都認爲結構有問題,而終止解析。爲了方便,定義一個結構來保存一個JSON的valuejson

type struct Value {
  value interface{}
}

結構中的value字段保存這個JSONValue的實際值,經過類型斷定來肯定其類型。所以會有不少的斷定,賦值,以及取值函數,好比針對一個string類型的Value須要有斷定是否爲string的操做IsString(),賦值AsString(),以及獲取真實值的操做String()數組

// 斷定是否爲string,若是是,則返回true,不然返回false
func (v *Value) IsString() bool {
  if _, ok := v.value.(string); ok {
    return true
  }
  return false
}

// 將一個Value賦值爲一個string
func (v *Value) AsString(value string) {
  v.value = value
}

// 從一個string類型的Value中取出String值
func (v *Value) String() string {
  if value, ok := v.value.(string); ok {
    return value
  }
  // 若是不是一個string類型,則報錯,因此須要先斷定是否爲string類型
  panic("not a string value")
}

針對這樣的操做還有不少,能夠參考 cheapjson/value.go.函數

JSON parser

對於string, true, false, null, number這樣的值,都屬於字面量,即沒有深層結構,可取直接讀取,而且中間不可能被空白字符切斷,因此能夠直接讀取。而對於一個array或者object,則是一個多層的樹狀結構。最直接的想法確定是用遞歸,可是你們都知道這是不可行的,由於在解析大JSON的時候極可能棧溢出了,因此只能用棧+迭代的辦法。oop

學過編譯原理的人都知道,作AST分析的時候首先要分析Token,而後再分析AST,在解析JSON的時候也應該這樣,雖然Token比較少:只有幾個字面量以及{, [, :, ], }幾個界定符。惋惜我並無學過編譯原理,上來就拿狀態機來迭代了。由於JSON是一棵樹,其解析過程是從樹根一直遍歷到各個葉節點再返回樹根的過程。天然就會涉及到棧的壓入及彈出操做。具體來說,就是在遇到arrayobject的子節點的時候要壓入棧,遇到一個value的結束符的時候要彈出棧。同時還要保存棧結點對應的Value以及其狀態信息。因此我定義了一個棧結點結構:性能

type struct state {
  state int
  value *Value
  parent *state
}

其中state表示當前棧節點的狀態,value表示其所表明的值parent表示其父節點,根節點的父節點爲nil。當要壓入棧時,只須要新建一個節點,將其parent設置爲當前節點便可,要彈出時,將當前結點設置爲當前結點的parent。若是當前節點爲nil,則表示遍歷結束,JSON自身也應該結束,除了空白字符外,不該該還包含任何字符。測試

一個節點可能的狀態有:

const (
    // start of a value
    stateNone = iota
    stateString
    // after [ must be a value or ]
    stateArrayValueOrEnd
    // after a value, must be a , or ]
    stateArrayEndOrComma
    // after a {, must be a key string or }
    stateObjectKeyOrEnd
    // after a key string must be a :
    stateObjectColon
    // after a : must be a value
    // after a value, must be , or }
    stateObjectEndOrComma
    // after a , must be key string
    stateObjectKey
)

狀態的含義和字面意思同樣,好比對於狀態stateArrayValueOrEnd表示當前棧節點遇到了一個array的起始標誌[,在等待一個子Value或者一個array的結束符],而狀態stateArrayEndOrComma表示一個array已經遇到了子Value,在等待結束符]或者Value的分隔符,。所以,在解析一個數組的時候,完整的棧操做過程是:遇到[,將當前結點的狀態設置爲stateArrayValueOrEnd,而後過濾空白字符,斷定第一個字符是]仍是其它字符,若是是],則array結束,彈出棧,若是不是,則將自身狀態修改成stateArrayEndOrComma,並壓入一個新棧結點,將其狀態設置爲stateNone,從新開始解析,此結點解析完成以後,彈出此結點,斷定是,仍是],若是是],則結束彈出,若是是,則不改變自身狀態,並從新一個新棧結點,開始新的循環。完事的狀態機以下:

state.png

其含義以下:

首先初始化一個空節點,狀態設置爲stateNone,而後判斷第一個非空字符,若是是t/f/n/[-0-9],則直接解析字面量,而後彈出,若是是[,則將狀態設置爲stateArrayValueOrEnd,而後斷定第一個字符,若是是],則結束彈出,不然壓入新棧,並將自身狀態設置爲stateArrayEndOrComma,開始新的循環,若是是{,則將狀態設置爲stateObjectKeyOrEnd,若是下一個非空字符爲},則結束彈出,不然解析key,完成以後,壓入新棧,並將自身狀態設置爲stateObjectEndOrComma

比較特殊的是stateString,按道理其也是一個字面量,不須要到一個新的循環裏面去解析。可是由於一個objectkey也是一個string,爲了複用代碼,並避免調用函數產生的性能開銷,將string類型和object的key看成同一類型來處理,具體以下:

root := &state{&Value{nil}, stateNone, nil}
curr := root
for {
  // ignore whitespace
  // check curr is nil or not
  switch curr.state {
    case stateNone:
      switch data[offset] {
        case '"':
          // go to new loop
          curr.state = stateString
          continue
      }
    case stateObjectKey, stateString:
      // parse string
      if curr.state == stateObjectKey {
        // create new stack node
      } else {
        // pop stack
      }
  }
}

此外比較特殊的是在解析完一個object的key以後,當即壓入了一個新棧結點,並將其狀態設置爲stateObjectColon,同時將自身的狀態設置爲stateObjectEndOrComma,在解析完colon以後再這個節點的狀態設置爲stateNone,開始新的循環,具體來講:

if curr.state == stateObjectKey {
  curr.state = stateObjectEndOrComma
  curr = &state{&Value{nil}, stateObjectColon, nil}
  continue
}

這是由於在:以前和以後均可能有空白字符,這裏是爲了複用代碼邏輯:即在每一次迭代開始之時都把全部的空白過濾掉。

for {
  LOOP_WS:
  for ; offset < len(data); offset++ {
    switch data[offset] {
    case '\t', '\r', '\n', ' ':
      continue
    default:
      break LOOP_WS
  }
  // do staff
}

在過濾掉空白後,若是當前棧爲nil,則不該該有字符存在,整個解析結束,不然必定有字符,而且須要進行解析:

for {
  // ignore whitespace
  if curr == nil {
    if offset == len(data) {
      return
    } else {
      // unexpected char data[offset] at offset
    }
  } else if offset == len(data) {
    // unexpected EOF at offset
  }
  // do staff
}

隨後即是根據當前狀態來進行相應的解析了。

後記

從目前的開源項目上來看,性能上應該還有優化的空間,畢竟有人已經作到號稱2-4x的速度,並且如今已經有不少項目在搞將Golang的Struct先編譯一遍,再調用生成的函數針對特定的結構進行解析,速度更快,不過既然就預先編譯了,幹嗎還要用JSON啊,直接PB/MsgPack得了。特別是djson這個庫,解析小JSON的時候速度是原生的3-4倍,可是大的時候只有2倍,而cheapjson則在解析大JSON的時候性能幾乎是原生的7倍,至關搞笑。而從測試結果上來看,總體上性能和內存都還能夠,可是在解析數組的時候比原生的還要差。因此值得改進,尤爲是頻繁的建立和銷燬state節點這一點,還有數組的動態擴容等。

之後有空再慢慢搞吧,我不想白頭髮愈來愈多了。

相關文章
相關標籤/搜索