1. 程式人生 > 其它 >深入 Go 中各個高效能 JSON 解析庫

深入 Go 中各個高效能 JSON 解析庫

轉載請宣告出處哦~,本篇文章釋出於luozhiyun的部落格:https://www.luozhiyun.com/archives/535

其實本來我是沒打算去看 JSON 庫的效能問題的,但是最近我對我的專案做了一次 pprof,從下面的火焰圖中可以發現在業務邏輯處理中,有一半多的效能消耗都是在 JSON 解析過程中,所以就有了這篇文章。

這篇文章深入原始碼分析一下在 Go 中標準庫是如何解析 JSON 的,然後再看看有哪些比較流行的 Json 解析庫,以及這些庫都有什麼特點,在什麼場景下能更好的幫助我們進行開發。

主要介紹分析以下幾個庫:

庫名 Star
標準庫 JSON Unmarshal
valyala/fastjson 1.2 k
tidwall/gjson 8.3 k
buger/jsonparser 4 k

json-iterator 庫也是一個非常有名的庫,但是我測了一下效能和標準庫相差很小,相比之下還是標準庫更值得使用;

Jeffail/gabs 庫與 bitly/go-simplejson 直接用的標準庫的 Unmarshal 來進行解析,所以效能上和標準庫一致,本篇文章也不會提及;

easyjson這個庫需要像 protobuf 一樣為每一個結構體生成序列化的程式碼,具有強入侵性,我個人不是很喜歡,所以也沒提及。

上面的這些庫是我能搜到的 Star 數大於 1k 比較知名,並且仍然在迭代的 JSON 解析庫,如果有遺漏的,可以聯絡我,我會補上。

標準庫 JSON Unmarshal

分析

func Unmarshal(data []byte, v interface{})

官方的 JSON 解析庫需要傳兩個引數,一個是需要被序列化的物件,另一個是表示這個物件的型別。

在真正執行 JSON 解析之前會呼叫 reflect.ValueOf來獲取引數 v 的反射物件。然後會獲取到傳入的 data 物件的開頭非空字元來界定該用哪種方式來進行解析。

func (d *decodeState) value(v reflect.Value) error {
	switch d.opcode {
	default:
		panic(phasePanicMsg)
	// 陣列 
	case scanBeginArray:
		...
	// 結構體或map
	case scanBeginObject:
		...
	// 字面量,包括 int、string、float 等
	case scanBeginLiteral:
		...
	}
	return nil
}

如果被解析的物件是以[開頭,那麼表示這是個陣列物件會進入到 scanBeginArray 分支;如果是以{開頭,表明被解析的物件是一個結構體或 map,那麼進入到 scanBeginObject 分支 等等。

以解析物件為例:

func (d *decodeState) object(v reflect.Value) error {
	...  
	var fields structFields
	// 檢驗這個物件的型別是 map 還是 結構體
	switch v.Kind() {
	case reflect.Map: 
		...
	case reflect.Struct:
		// 快取結構體的欄位到 fields 物件中
		fields = cachedTypeFields(t)
		// ok
	default:
		d.saveError(&UnmarshalTypeError{Value: "object", Type: t, Offset: int64(d.off)})
		d.skip()
		return nil
	}

	var mapElem reflect.Value
	origErrorContext := d.errorContext
	// 迴圈一個個解析JSON字串中的 key value 值
	for {  
		start := d.readIndex()
		d.rescanLiteral()
		item := d.data[start:d.readIndex()]
		// 獲取 key 值
		key, ok := unquoteBytes(item)
		if !ok {
			panic(phasePanicMsg)
		} 
		var subv reflect.Value
		destring := false   
		... 
		// 根據 value 的型別反射設定 value 值 
		if destring {
			// value 值是字面量會進入到這裡
			switch qv := d.valueQuoted().(type) {
			case nil:
				if err := d.literalStore(nullLiteral, subv, false); err != nil {
					return err
				}
			case string:
				if err := d.literalStore([]byte(qv), subv, true); err != nil {
					return err
				}
			default:
				d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal unquoted value into %v", subv.Type()))
			}
		} else {
			// 陣列或物件會遞迴呼叫 value 方法
			if err := d.value(subv); err != nil {
				return err
			}
		}
		...
		// 直到遇到 } 最後退出迴圈
		if d.opcode == scanEndObject {
			break
		}
		if d.opcode != scanObjectValue {
			panic(phasePanicMsg)
		}
	}
	return nil
}
  1. 首先會快取結構體物件;
  2. 迴圈遍歷結構體物件;
  3. 找到結構體中的 key 值之後再找到結構體中同名欄位型別;
  4. 遞迴呼叫 value 方法反射設定結構體對應的值;
  5. 直到遍歷到 JSON 中結尾 }結束迴圈。

小結

通過看 Unmarshal 原始碼中可以看到其中使用了大量的反射來獲取欄位值,如果是多層巢狀的 JSON 的話,那麼還需要遞迴進行反射獲取值,可想而知效能是非常差的了。

但是如果對效能不是那麼看重的話,直接使用它其實是一個非常好的選擇,功能完善的同時並且官方也一直在迭代優化,說不定在以後的版本中效能也會得到質的飛躍。

fastjson

庫地址:https://github.com/valyala/fastjson

這個庫的特點和它的名字一樣就是快,它的介紹頁是這麼說的:

Fast. As usual, up to 15x faster than the standard encoding/json.

它的使用也是非常的簡單,如下:

func main() {
	var p fastjson.Parser
	v, _ := p.Parse(`{
                "str": "bar",
                "int": 123,
                "float": 1.23,
                "bool": true,
                "arr": [1, "foo", {}]
        }`)
	fmt.Printf("foo=%s\n", v.GetStringBytes("str"))
	fmt.Printf("int=%d\n", v.GetInt("int"))
	fmt.Printf("float=%f\n", v.GetFloat64("float"))
	fmt.Printf("bool=%v\n", v.GetBool("bool"))
	fmt.Printf("arr.1=%s\n", v.GetStringBytes("arr", "1"))
}
// Output:
// foo=bar
// int=123
// float=1.230000
// bool=true
// arr.1=foo

使用 fastjson 首先要將被解析的 JSON 串交給 Parser 解析器進行解析,然後通過 Parse 方法返回的物件來獲取。如果是巢狀物件可以直接在 Get 方法傳參的時候傳入相應的父子 key 即可。

分析

fastjson 在設計上和標準庫 Unmarshal 不同的是,它將 JSON 解析劃分為兩部分:Parse、Get。

Parse 負責將 JSON 串解析成為一個結構體並返回,然後通過返回的結構體來獲取資料。在 Parse 解析的過程是無鎖的,所以如果想要在併發地呼叫 Parse 進行解析需要使用 ParserPool

fastjson 是從上往下依次遍歷 JSON ,然後解析好的資料存放在 Value 結構體中:

type Value struct {
	o Object
	a []*Value
	s string
	t Type
}

這個結構體非常簡單:

  • o Object:表示被解析的結構是一個物件;
  • a []*Value:表示表示被解析的結構是個陣列;
  • s string:如果被解析的結構不是物件也不是陣列,那麼其他型別的值會以字串的形式存放在這個欄位中;
  • t Type:表示這個結構的型別,有 TypeObject、TypeArray、TypeString、TypeNumber等。
type Object struct {
	kvs           []kv
	keysUnescaped bool
}

type kv struct {
	k string
	v *Value
}

這個結構存放物件的遞迴結構。如果把上面例子中的 JSON 串解析完畢之後就是這樣一個結構:

程式碼

在程式碼實現上,由於沒有了反射部分的程式碼,所以整個解析過程變得非常的清爽。我們直接看看主幹部分的解析:

func parseValue(s string, c *cache, depth int) (*Value, string, error) {
	if len(s) == 0 {
		return nil, s, fmt.Errorf("cannot parse empty string")
	}
	depth++
	// 最大深度的json串不能超過MaxDepth
	if depth > MaxDepth {
		return nil, s, fmt.Errorf("too big depth for the nested JSON; it exceeds %d", MaxDepth)
	}
	// 解析物件
	if s[0] == '{' {
		v, tail, err := parseObject(s[1:], c, depth)
		if err != nil {
			return nil, tail, fmt.Errorf("cannot parse object: %s", err)
		}
		return v, tail, nil
	}
	// 解析陣列
	if s[0] == '[' {
		...
	}
	// 解析字串
	if s[0] == '"' {
		...
	} 
	...
	return v, tail, nil
}

parseValue 會根據字串的第一個非空字元來判斷要解析的型別。這裡用一個物件型別來做解析:

func parseObject(s string, c *cache, depth int) (*Value, string, error) {
	...
	o := c.getValue()
	o.t = TypeObject
	o.o.reset()
	for {
		var err error
		// 獲取Ojbect結構體中的 kv 物件
		kv := o.o.getKV()
		... 
		// 解析 key 值
		
		kv.k, s, err = parseRawKey(s[1:])
		... 
		// 遞迴解析 value 值
		kv.v, s, err = parseValue(s, c, depth)
		...
		// 遇到 ,號繼續往下解析
		if s[0] == ',' {
			s = s[1:]
			continue
		}
		// 解析完畢
		if s[0] == '}' {
			return o, s[1:], nil
		}
		return nil, s, fmt.Errorf("missing ',' after object value")
	}
}

parseObject 函式也非常簡單,在迴圈體中會獲取 key 值,然後呼叫 parseValue 遞迴解析 value 值,從上往下依次解析 JSON 物件,直到最後遇到 }退出。

小結

通過上面的分析可以知道 fastjson 在實現上比標準庫簡單不少,效能也高上不少。使用 Parse 解析好 JSON 樹之後可以多次反覆使用,避免了需要反覆解析進而提升效能。

但是它的功能是非常的簡陋的,沒有常用的如 JSON 轉 Struct 或 JSON 轉 map 的操作。如果只是想簡單的獲取 JSON 中的值,那麼使用這個庫是非常方便的,但是如果想要把 JSON 值轉化成一個結構體就需要自己動手一個個設值了。

GJSON

庫地址:https://github.com/tidwall/gjson

GJSON 在我的測試中,雖然效能是沒有 fastjson 這麼極致,但是功能是非常完善,效能也是相當 OK 的,下面先簡單介紹一下 GJSON 的功能。

GJSON 的使用是和 fastjson 差不多的,也是非常的簡單,只要在引數中傳入 json 串以及需要獲取的值即可:

json := `{"name":{"first":"li","last":"dj"},"age":18}`
lastName := gjson.Get(json, "name.last")

除了這個功能以外還可以進行簡單的模糊匹配,支援在鍵中包含萬用字元*?*匹配任意多個字元,?匹配單個字元,如下:

json := `{
	"name":{"first":"Tom", "last": "Anderson"},
	"age": 37,
	"children": ["Sara", "Alex", "Jack"]
}`
fmt.Println("third child*:", gjson.Get(json, "child*.2"))
fmt.Println("first c?ild:", gjson.Get(json, "c?ildren.0"))
  • child*.2:首先child*匹配children.2讀取第 3 個元素;
  • c?ildren.0c?ildren匹配到children.0讀取第一個元素;

除了模糊匹配以外還支援修飾符操作:

json := `{
	"name":{"first":"Tom", "last": "Anderson"},
	"age": 37,
	"children": ["Sara", "Alex", "Jack"]
}`
fmt.Println("third child*:", gjson.Get(json, "children|@reverse"))

children|@reverse 先讀取陣列children,然後使用修飾符@reverse翻轉之後返回,輸出。

nestedJSON := `{"nested": ["one", "two", ["three", "four"]]}`
fmt.Println(gjson.Get(nestedJSON, "nested|@flatten"))

@flatten將陣列nested的內層陣列平攤到外層後返回:

["one","two","three", "four"]

等等還有一些其他有意思的功能,大家可以去查閱一下官方文件。

分析

GJSON 的 Get 方法引數是由兩部分組成,一個是 JSON 串,另一個叫做 Path 表示需要獲取的 JSON 值的匹配路徑。

在 GJSON 中因為要滿足很多的定義的解析場景,所以解析是分為兩部分的,需要先解析好 Path 之後才去遍歷解析 JSON 串。

在解析過程中如果遇到可以匹配上的值,那麼會直接返回,不需要繼續往下遍歷,如果是匹配多個值,那麼會一直遍歷完整個 JSON 串。如果遇到某個 Path 在 JSON 串中匹配不到,那麼也是需要遍歷完整個 JSON 串。

在解析的過程中也不會像 fastjson 一樣將解析的內容儲存在一個結構體中,可以反覆的利用。所以當呼叫 GetMany 想要返回多個值的時候,其實也是需要遍歷 JSON 串多次,因此效率會比較低。

除此之外,在解析 JSON 的時候並不會對它進行校驗,即使這個放入的字串不是個 JSON 也會照樣解析,所以需要使用者自己去確保放入的是 JSON 。

程式碼

func Get(json, path string) Result {
	// 解析 path 
	if len(path) > 1 {
		...
	}
	var i int
	var c = &parseContext{json: json}
	if len(path) >= 2 && path[0] == '.' && path[1] == '.' {
		c.lines = true
		parseArray(c, 0, path[2:])
	} else {
		// 根據不同的物件進行解析,這裡會一直迴圈,直到找到 '{' 或 '['
		for ; i < len(c.json); i++ {
			if c.json[i] == '{' {
				i++
				 
				parseObject(c, i, path)
				break
			}
			if c.json[i] == '[' {
				i++
				parseArray(c, i, path)
				break
			}
		}
	}
	if c.piped {
		res := c.value.Get(c.pipe)
		res.Index = 0
		return res
	}
	fillIndex(json, c)
	return c.value
}

Get 方法裡面可以看到有很長一串的程式碼是用來解析各種 Path,然後一個 for 迴圈一直遍歷 JSON 直到找到 '{' 或 '[',然後才進行相應的邏輯進行處理。

func parseObject(c *parseContext, i int, path string) (int, bool) {
	var pmatch, kesc, vesc, ok, hit bool
	var key, val string
	rp := parseObjectPath(path)
	if !rp.more && rp.piped {
		c.pipe = rp.pipe
		c.piped = true
	}
	// 巢狀兩個 for 迴圈 尋找 key 值
	for i < len(c.json) {
		for ; i < len(c.json); i++ {
			if c.json[i] == '"' { 
				i++
				var s = i
				for ; i < len(c.json); i++ {
					if c.json[i] > '\\' {
						continue
					}
					// 找到 key 值跳轉到 parse_key_string_done
					if c.json[i] == '"' {
						i, key, kesc, ok = i+1, c.json[s:i], false, true
						goto parse_key_string_done
					}
					...
				}
				key, kesc, ok = c.json[s:], false, false
			// 直接break
			parse_key_string_done:
				break
			}
			if c.json[i] == '}' {
				return i + 1, false
			}
		}
		if !ok {
			return i, false
		}
		// 校驗是否是模糊匹配
		if rp.wild {
			if kesc {
				pmatch = match.Match(unescape(key), rp.part)
			} else {
				pmatch = match.Match(key, rp.part)
			}
		} else {
			if kesc {
				pmatch = rp.part == unescape(key)
			} else {
				pmatch = rp.part == key
			}
		}
		// 解析 value
		hit = pmatch && !rp.more
		for ; i < len(c.json); i++ {
			switch c.json[i] {
			default:
				continue
			case '"':
				i++
				i, val, vesc, ok = parseString(c.json, i)
				if !ok {
					return i, false
				}
				if hit {
					if vesc {
						c.value.Str = unescape(val[1 : len(val)-1])
					} else {
						c.value.Str = val[1 : len(val)-1]
					}
					c.value.Raw = val
					c.value.Type = String
					return i, true
				}
			case '{':
				if pmatch && !hit {
					i, hit = parseObject(c, i+1, rp.path)
					if hit {
						return i, true
					}
				} else {
					i, val = parseSquash(c.json, i)
					if hit {
						c.value.Raw = val
						c.value.Type = JSON
						return i, true
					}
				}
			...
			break
		}
	}
	return i, false
}

在上面看 parseObject 這段程式碼的時候其實不是想讓大家學習如何解析 JSON,以及遍歷字串,而是想要讓大家看看一個 bad case 是怎樣的。for 迴圈一層套一層,if 一個接一個看得我 San 值狂掉,這段程式碼大家是不是看起來很眼熟?是不是有點像工作中遇到的某個同事寫的程式碼?

小結

優點:

  1. 效能相對標準庫來說還算不錯;
  2. 可玩性高,可以各種檢索、自定義返回值,這點非常方便;

缺點:

  1. 不會校驗 JSON 的正確性;
  2. 程式碼的 Code smell 很重。

需要注意的是,如果需要解析返回 JSON 的值的話,GetMany 函式會根據指定的 key 值來一次次遍歷 JSON 字串,解析為 map 可以減少遍歷次數。

jsonparser

庫地址:https://github.com/buger/jsonparser

這也是一個比較熱門,並且號稱高效能,能比標準庫快十倍的解析速度。

分析

jsonparser 也是傳入一個 JSON 的 byte 切片,以及可以通過傳入多個 key 值來快速定位到相應的值,並返回。

和 GJSON 一樣,在解析過程中是不會像 fastjson 一樣有個資料結構快取已解析過的 JSON字串,但是遇到需要解析多個值的情況可以使用 EachKey 函式來解析多個值,只需要遍歷一次 JSON字串即可實現獲取多個值的操作。

如果遇到可以匹配上的值,那麼會直接返回,不需要繼續往下遍歷,如果是匹配多個值,那麼會一直遍歷完整個 JSON 串。如果遇到某個 Path 在 JSON 串中匹配不到,那麼也是需要遍歷完整個 JSON 串。

並且在遍歷 JSON 串的時候通過迴圈的方式來減少遞迴的使用,減少了呼叫棧的深度,一定程度上也是可以提升效能。

在功能性上 ArrayEach、ObjectEach、EachKey 等三個函式都可以傳入一個自定義的函式,通過函式來實現個性化的需求,使得實用性大大增強。

對於 jsonparser 來說,程式碼沒什麼可分析的,非常的清晰,感興趣的可以自己去看看。

小結

對於 jsonparser 來說相對標準庫比較而言效能如此高的原因可以總結為:

  1. 使用 for 迴圈來減少遞迴的使用;
  2. 相比標準庫而言沒有使用反射;
  3. 在查詢相應的 key 值找到了便直接退出,可以不用繼續往下遞迴;
  4. 所操作的 JSON 串都是已被傳入的,不會去重新再去申請新的空間,減少了記憶體分配;

除此之外在 api 的設計上也是非常的實用,ArrayEach、ObjectEach、EachKey 等三個函式都可以傳入一個自定義的函式在實際的業務開發中解決了不少問題。

缺點也是非常的明顯,不能對 JSON 進行校驗,即使這個 傳入的不是 JSON。

效能對比

解析小 JSON 字串

解析一個結構簡單,大小約 190 bytes 的字串

庫名 操作 每次迭代耗時 佔用記憶體數 分配記憶體次數 效能
標準庫 解析為map 724 ns/op 976 B/op 51 allocs/op
解析為struct 297 ns/op 256 B/op 5 allocs/op 一般
fastjson get 68.2 ns/op 0 B/op 0 allocs/op 最快
parse 35.1 ns/op 0 B/op 0 allocs/op 最快
GJSON 轉map 255 ns/op 1009 B/op 11 allocs/op 一般
get 232 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 106 ns/op 232 B/op 3 allocs/op

解析中等大小 JSON 字串

解析一個具有一定複雜度,大小約 2.3KB 的字串

庫名 操作 每次迭代耗時 佔用記憶體數 分配記憶體次數 效能
標準庫 解析為map 4263 ns/op 10212 B/op 208 allocs/op
解析為struct 4789 ns/op 9206 B/op 259 allocs/op
fastjson get 285 ns/op 0 B/op 0 allocs/op 最快
parse 302 ns/op 0 B/op 0 allocs/op 最快
GJSON 轉map 2571 ns/op 8539 B/op 83 allocs/op 一般
get 1489 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 878 ns/op 2728 B/op 5 allocs/op

解析大 JSON 字串

解析複雜度比較高,大小約 2.2MB 的字串

庫名 操作 每次迭代耗時 佔用記憶體數 分配記憶體次數 效能
標準庫 解析為map 2292959 ns/op 5214009 B/op 95402 allocs/op
解析為struct 1165490 ns/op 2023 B/op 76 allocs/op 一般
fastjson get 368056 ns/op 0 B/op 0 allocs/op
parse 371397 ns/op 0 B/op 0 allocs/op
GJSON 轉map 1901727 ns/op 4788894 B/op 54372 allocs/op 一般
get 1322167 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 233090 ns/op 1788865 B/op 376 allocs/op 最快

總結

在這次的分享過程中,我找了很多 JSON 的解析庫分別進行對比分析,可以發現這些高效能的解析庫基本上都有一些共同的特點:

  • 不使用反射;
  • 通過遍歷 JSON 字串的位元組來挨個解析;
  • 儘量使用傳入的 JSON 字串來進行解析遍歷,減少記憶體分配;
  • 犧牲了一定的相容性;

儘管如此,但是功能上,每個都有一定的特色 fastjson 的 api 操作最簡單;GJSON 提供了模糊查詢的功能,自定義程度最高;jsonparser 在實現高效能的解析過程中,還可以插入回撥函式執行,提供了一定程度上的便利。

綜上,回到文章的開頭中,對於我自己的業務來說,業務也只是簡單的解析 http 請求返回的 JSON 串的部分欄位,並且欄位都是確定的,無需搜尋功能,但是有時候需要做一些自定義的操作,所以對我來說 jsonparser 是最合適的。

所以如果各位對效能有一定要求,不妨結合自己的業務情況來挑選一款 JSON 解析器。

Reference

https://github.com/buger/jsonparser

https://github.com/tidwall/gjson

https://github.com/valyala/fastjson

https://github.com/json-iterator/go

https://github.com/mailru/easyjson

https://github.com/Jeffail/gabs

https://github.com/bitly/go-simplejson