這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前一陣子看到了一個Golang的JSON庫go-simplejson
,用來封裝與解析匿名的JSON,說白了就是用map
或者slice
等來解析JSON,覺得挺好玩,後來有個項目恰好要解析JSON,於是就試了試,不小心看了一眼原始碼,發現竟然是用的Golang內建的encoding/json
庫去做的解析,而其本身只是把這個庫封裝了一層,看起來更好看罷了。於是心想能不能徒手寫一個解析器,畢竟寫了這麼多年代碼了,也JSON.parse
,JSON.stringify
了無數次。搗騰了兩天,終於成了,測試了一下,效能比內建的庫要高很多,速度基本上在1.6
到7
倍之間(視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
的類型可能是null
,true
,false
,number
,string
, array
以及 object
共6種。而array
和object
還有可能包含子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是一棵樹,其解析過程是從樹根一直遍曆到各個分葉節點再返回樹根的過程。自然就會涉及到棧的壓入及彈出操作。具體來講,就是在遇到array
和object
的子節點的時候要壓入棧,遇到一個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
,按道理其也是一個字面量,不需要到一個新的迴圈裡面去解析。但是因為一個object
的key
也是一個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
節點這一點,還有數組的動態擴容等。
以後有空再慢慢搞吧,我不想白頭髮越來越多了。