徒手寫一個JSON解析器(Golang)

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

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

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

項目取名cheapjson,意思是便宜的,因為你不光不需要定義各個struct,效能還比原生的快,所以很便宜。地址在 https://github.com/acrazing/cheapjson,有興趣的可以看看~

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的value

type struct Value {  value interface{}}

結構中的value欄位儲存這個JSONValue的實際值,通過類型判定來確定其類型。因此會有很多的判定,賦值,以及存取子,比如針對一個string類型的Value需要有判定是否為string的操作IsString(),賦值AsString(),以及擷取真實值的操作String()

// 判定是否為string,如果是,則返回true,否則返回falsefunc (v *Value) IsString() bool {  if _, ok := v.value.(string); ok {    return true  }  return false}// 將一個Value賦值為一個stringfunc (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的時候很可能棧溢出了,所以只能用棧+迭代的辦法。

學過編譯原理的人都知道,做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,然後判斷第一個非Null 字元,如果是t/f/n/[-0-9],則直接解析字面量,然後彈出,如果是[,則將狀態設定為stateArrayValueOrEnd,然後判定第一個字元,如果是],則結束彈出,否則壓入新棧,並將自身狀態設定為stateArrayEndOrComma,開始新的迴圈,如果是{,則將狀態設定為stateObjectKeyOrEnd,如果下一個非Null 字元為},則結束彈出,否則解析key,完成之後,壓入新棧,並將自身狀態設定為stateObjectEndOrComma

比較特殊的是stateString,按道理其也是一個字面量,不需要到一個新的迴圈裡面去解析。但是因為一個objectkey也是一個string,為了複用代碼,並避免調用函數產生的效能開銷,將string類型和object的key當作同一類型來處理,具體如下:

root := &state{&Value{nil}, stateNone, nil}curr := rootfor {  // 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節點這一點,還有數組的動態擴容等。

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

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.