來自公衆號:新世界雜貨鋪
這是HTTP2.0系列的最後一篇,筆者推薦閱讀順序以下:html
在前篇(*http2ClientConn).roundTrip
方法中提到了寫入請求header,而在寫入請求header以前須要先編碼(源碼見https://github.com/golang/go/...)。git
在中篇(*http2ClientConn).readLoop
方法中提到了ReadFrame()
方法,該方法會讀取數據幀,若是是http2FrameHeaders
數據幀,會調用(*http2Framer).readMetaFrame
對讀取到的數據幀解碼(源碼見https://github.com/golang/go/...)。github
由於標頭壓縮具備較高的獨立性,因此筆者基於上面提到的編/解碼部分的源碼本身實現了一個能夠獨立運行的小例子。本篇將基於本身實現的例子進行標頭壓縮分析(完整例子見https://github.com/Isites/go-...)。golang
HTTP2使用 HPACK 壓縮格式壓縮請求和響應標頭元數據,這種格式採用下面兩種技術壓縮:web
本篇不對哈夫曼編碼作過多的闡述,主要對雙端共同維護的索引列表進行分析。算法
HPACK 壓縮上下文包含一個靜態表和一個動態表:靜態表在規範中定義,並提供了一個包含全部鏈接均可能使用的經常使用 HTTP 標頭字段的列表;動態表最初爲空,將根據在特定鏈接內交換的值進行更新。segmentfault
認識靜/動態表須要先認識headerFieldTable
結構體,動態表和靜態表都是基於它實現的。閉包
type headerFieldTable struct { // As in hpack, unique ids are 1-based. The unique id for ents[k] is k + evictCount + 1. ents []HeaderField evictCount uint64 // byName maps a HeaderField name to the unique id of the newest entry with the same name. byName map[string]uint64 // byNameValue maps a HeaderField name/value pair to the unique id of the newest byNameValue map[pairNameValue]uint64 }
下面將對上述的字段分別進行描述:app
ents
:entries的縮寫,表明着當前已經索引的Header數據。在headerFieldTable中,每個Header都有一個惟一的Id,以ents[k]
爲例,該惟一id的計算方式是k + evictCount + 1
。函數
evictCount
:已經從ents中刪除的條目數。
byName
:存儲具備相同Name的Header的惟一Id,最新Header的Name會覆蓋老的惟一Id。
byNameValue
:以Header的Name和Value爲key存儲對應的惟一Id。
對字段的含義有所瞭解後,接下來對headerFieldTable幾個比較重要的行爲進行描述。
(*headerFieldTable).addEntry:添加Header實體到表中
func (t *headerFieldTable) addEntry(f HeaderField) { id := uint64(t.len()) + t.evictCount + 1 t.byName[f.Name] = id t.byNameValue[pairNameValue{f.Name, f.Value}] = id t.ents = append(t.ents, f) }
首先,計算出Header在headerFieldTable中的惟一Id,並將其分別存入byName
和byNameValue
中。最後,將Header存入ents
。
由於使用了append函數,這意味着ents[0]
存儲的是存活最久的Header。
(*headerFieldTable).evictOldest:從表中刪除指定個數的Header實體
func (t *headerFieldTable) evictOldest(n int) { if n > t.len() { panic(fmt.Sprintf("evictOldest(%v) on table with %v entries", n, t.len())) } for k := 0; k < n; k++ { f := t.ents[k] id := t.evictCount + uint64(k) + 1 if t.byName[f.Name] == id { delete(t.byName, f.Name) } if p := (pairNameValue{f.Name, f.Value}); t.byNameValue[p] == id { delete(t.byNameValue, p) } } copy(t.ents, t.ents[n:]) for k := t.len() - n; k < t.len(); k++ { t.ents[k] = HeaderField{} // so strings can be garbage collected } t.ents = t.ents[:t.len()-n] if t.evictCount+uint64(n) < t.evictCount { panic("evictCount overflow") } t.evictCount += uint64(n) }
第一個for循環的下標是從0開始的,也就是說刪除Header時遵循先進先出的原則。刪除Header的步驟以下:
byName
和byNameValue
的映射。evictCount
的數量。(*headerFieldTable).search:從當前表中搜索指定Header並返回在當前表中的Index(此處的Index
和切片中的下標含義是不同的)
func (t *headerFieldTable) search(f HeaderField) (i uint64, nameValueMatch bool) { if !f.Sensitive { if id := t.byNameValue[pairNameValue{f.Name, f.Value}]; id != 0 { return t.idToIndex(id), true } } if id := t.byName[f.Name]; id != 0 { return t.idToIndex(id), false } return 0, false }
若是Header的Name和Value均匹配,則返回當前表中的Index且nameValueMatch
爲true。
若是僅有Header的Name匹配,則返回當前表中的Index且nameValueMatch
爲false。
若是Header的Name和Value均不匹配,則返回0且nameValueMatch
爲false。
(*headerFieldTable).idToIndex:經過當前表中的惟一Id計算出當前表對應的Index
func (t *headerFieldTable) idToIndex(id uint64) uint64 { if id <= t.evictCount { panic(fmt.Sprintf("id (%v) <= evictCount (%v)", id, t.evictCount)) } k := id - t.evictCount - 1 // convert id to an index t.ents[k] if t != staticTable { return uint64(t.len()) - k // dynamic table } return k + 1 }
靜態表:Index
從1開始,且Index爲1時對應的元素爲t.ents[0]
。
動態表: Index
也從1開始,可是Index爲1時對應的元素爲t.ents[t.len()-1]
。
靜態表中包含了一些每一個鏈接均可能使用到的Header。其實現以下:
var staticTable = newStaticTable() func newStaticTable() *headerFieldTable { t := &headerFieldTable{} t.init() for _, e := range staticTableEntries[:] { t.addEntry(e) } return t } var staticTableEntries = [...]HeaderField{ {Name: ":authority"}, {Name: ":method", Value: "GET"}, {Name: ":method", Value: "POST"}, // 此處省略代碼 {Name: "www-authenticate"}, }
上面的t.init
函數僅作初始化t.byName
和t.byNameValue
用。筆者在這裏僅展現了部分預約義的Header,完整預約義Header參見https://github.com/golang/go/...。
動態表結構體以下:
type dynamicTable struct { // http://http2.github.io/http2-spec/compression.html#rfc.section.2.3.2 table headerFieldTable size uint32 // in bytes maxSize uint32 // current maxSize allowedMaxSize uint32 // maxSize may go up to this, inclusive }
動態表的實現是基於headerFieldTable
,相比原先的基礎功能增長了表的大小限制,其餘功能保持不變。
前面介紹了動/靜態表中內部的Index和內部的惟一Id,而在一次鏈接中HPACK索引列表是由靜態表和動態表一塊兒構成,那此時在鏈接中的HPACK索引是怎麼樣的呢?
帶着這樣的疑問咱們看看下面的結構:
上圖中藍色部分表示靜態表,黃色部分表示動態表。
H1...Hn
和H1...Hm
分別表示存儲在靜態表和動態表中的Header元素。
在HPACK索引中靜態表部分的索引和靜態表的內部索引保持一致,動態表部分的索引爲動態表內部索引加上靜態表索引的最大值。在一次鏈接中Client和Server經過HPACK索引標識惟一的Header元素。
衆所周知HTTP2的標頭壓縮可以減小不少數據的傳輸,接下來咱們經過下面的例子,對比一下編碼先後的數據大小:
var ( buf bytes.Buffer oriSize int ) henc := hpack.NewEncoder(&buf) headers := []hpack.HeaderField{ {Name: ":authority", Value: "dss0.bdstatic.com"}, {Name: ":method", Value: "GET"}, {Name: ":path", Value: "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png"}, {Name: ":scheme", Value: "https"}, {Name: "accept-encoding", Value: "gzip"}, {Name: "user-agent", Value: "Go-http-client/2.0"}, {Name: "custom-header", Value: "custom-value"}, } for _, header := range headers { oriSize += len(header.Name) + len(header.Value) henc.WriteField(header) } fmt.Printf("ori size: %v, encoded size: %v\n", oriSize, buf.Len()) //輸出爲:ori size: 197, encoded size: 111
注:在 HTTP2 中,請求和響應標頭字段的定義保持不變,僅有一些微小的差別:全部標頭字段名稱均爲小寫,請求行如今拆分紅各個 :method
、:scheme
、:authority
和 :path
僞標頭字段。
在上面的例子中,咱們看到原來爲197字節的標頭數據如今只有111字節,減小了近一半的數據量!
帶着一種 「臥槽,牛逼!」的心情開始對henc.WriteField
方法調試。
func (e *Encoder) WriteField(f HeaderField) error { e.buf = e.buf[:0] if e.tableSizeUpdate { e.tableSizeUpdate = false if e.minSize < e.dynTab.maxSize { e.buf = appendTableSize(e.buf, e.minSize) } e.minSize = uint32Max e.buf = appendTableSize(e.buf, e.dynTab.maxSize) } idx, nameValueMatch := e.searchTable(f) if nameValueMatch { e.buf = appendIndexed(e.buf, idx) } else { indexing := e.shouldIndex(f) if indexing { e.dynTab.add(f) // 加入動態表中 } if idx == 0 { e.buf = appendNewName(e.buf, f, indexing) } else { e.buf = appendIndexedName(e.buf, f, idx, indexing) } } n, err := e.w.Write(e.buf) if err == nil && n != len(e.buf) { err = io.ErrShortWrite } return err }
經調試發現,本例中:authority
,:path
,accept-encoding
和user-agent
走了appendIndexedName
分支;:method
和:scheme
走了appendIndexed
分支;custom-header
走了appendNewName
分支。這三種分支總共表明了兩種不一樣的編碼方法。
因爲本例中f.Sensitive
默認值爲false且Encoder給動態表的默認大小爲4096,按照e.shouldIndex
的邏輯本例中indexing
一直爲true(在筆者所使用的go1.14.2源碼中,client端還沒有發現有使f.Sensitive
爲true的代碼)。
筆者對上面e.tableSizeUpdate
相關的邏輯不提的緣由是控制e.tableSizeUpdate
的方法爲e.SetMaxDynamicTableSizeLimit
和e.SetMaxDynamicTableSize
,而筆者在(*http2Transport).newClientConn
(此方法相關邏輯參見前篇)相關的源碼中發現了這樣的註釋:
// TODO: SetMaxDynamicTableSize, SetMaxDynamicTableSizeLimit on // henc in response to SETTINGS frames?
筆者看到這裏的時候心裏激動不已呀,產生了一種強烈的想貢獻代碼的慾望,奈何本身能力有限只能看着機會卻抓不住呀,只好含恨埋頭苦學(開個玩笑~,畢竟某位智者說過,寫的越少BUG越少😄)。
(*Encoder).searchTable:從HPACK索引列表中搜索Header,並返回對應的索引。
func (e *Encoder) searchTable(f HeaderField) (i uint64, nameValueMatch bool) { i, nameValueMatch = staticTable.search(f) if nameValueMatch { return i, true } j, nameValueMatch := e.dynTab.table.search(f) if nameValueMatch || (i == 0 && j != 0) { return j + uint64(staticTable.len()), nameValueMatch } return i, false }
搜索順序爲,先搜索靜態表,若是靜態表不匹配,則搜索動態表,最後返回。
此表示法對應的函數爲appendIndexed,且該Header已經在索引列表中。
該函數將Header在HPACK索引列表中的索引編碼,原先的Header最後僅用少許的幾個字節就能夠表示。
func appendIndexed(dst []byte, i uint64) []byte { first := len(dst) dst = appendVarInt(dst, 7, i) dst[first] |= 0x80 return dst } func appendVarInt(dst []byte, n byte, i uint64) []byte { k := uint64((1 << n) - 1) if i < k { return append(dst, byte(i)) } dst = append(dst, byte(k)) i -= k for ; i >= 128; i >>= 7 { dst = append(dst, byte(0x80|(i&0x7f))) } return append(dst, byte(i)) }
由appendIndexed
知,用索引頭字段表示法時,第一個字節的格式必須是0b1xxxxxxx
,即第0位必須爲1
,低7位用來表示值。
若是索引大於uint64((1 << n) - 1)
時,須要使用多個字節來存儲索引的值,步驟以下:
0b10000000
, 而後i右移7位並和128進行比較,判斷是否進入下一次循環。用這種方法表示Header時,僅須要少許字節就能夠表示一個完整的Header頭字段,最好的狀況是一個字節就能夠表示一個Header字段。
此種表示法對應兩種狀況:一,Header的Name有匹配索引;二,Header的Name和Value均無匹配索引。這兩種狀況分別對應的處理函數爲appendIndexedName
和appendNewName
。這兩種狀況均會將Header添加到動態表中。
appendIndexedName: 編碼有Name匹配的Header字段。
func appendIndexedName(dst []byte, f HeaderField, i uint64, indexing bool) []byte { first := len(dst) var n byte if indexing { n = 6 } else { n = 4 } dst = appendVarInt(dst, n, i) dst[first] |= encodeTypeByte(indexing, f.Sensitive) return appendHpackString(dst, f.Value) }
在這裏咱們先看看encodeTypeByte
函數:
func encodeTypeByte(indexing, sensitive bool) byte { if sensitive { return 0x10 } if indexing { return 0x40 } return 0 }
前面提到本例中indexing一直爲true,sensitive爲false,因此encodeTypeByte的返回值一直爲0x40
。
此時回到appendIndexedName函數,咱們知道增長動態表Header表示法的第一個字節格式必須是0xb01xxxxxx
,即最高兩位必須是01
,低6位用於表示Header中Name的索引。
經過appendVarInt
對索引編碼後,下面咱們看看appendHpackString
函數如何對Header的Value進行編碼:
func appendHpackString(dst []byte, s string) []byte { huffmanLength := HuffmanEncodeLength(s) if huffmanLength < uint64(len(s)) { first := len(dst) dst = appendVarInt(dst, 7, huffmanLength) dst = AppendHuffmanString(dst, s) dst[first] |= 0x80 } else { dst = appendVarInt(dst, 7, uint64(len(s))) dst = append(dst, s...) } return dst }
appendHpackString
編碼時分爲兩種狀況:
哈夫曼編碼後的長度小於原Value的長度時,先用appendVarInt
將哈夫曼編碼後的最終長度存入buf,而後再將真實的哈夫曼編碼存入buf。
哈夫曼編碼後的長度大於等於原Value的長度時,先用appendVarInt
將原Value的長度存入buf,而後再將原Value存入buf。
在這裏須要注意的是存儲Value長度時僅用了字節的低7位,最高位爲1表示存儲的內容爲哈夫曼編碼,最高位爲0表示存儲的內容爲原Value。
appendNewName: 編碼Name和Value均無匹配的Header字段。
func appendNewName(dst []byte, f HeaderField, indexing bool) []byte { dst = append(dst, encodeTypeByte(indexing, f.Sensitive)) dst = appendHpackString(dst, f.Name) return appendHpackString(dst, f.Value) }
前面提到encodeTypeByte
的返回值爲0x40
,因此咱們此時編碼的第一個字節爲0b01000000
。
第一個字節編碼結束後經過appendHpackString
前後對Header的Name和Value進行編碼。
前面理了一遍HPACK的編碼過程,下面咱們經過一個解碼的例子來理一遍解碼的過程。
// 此處省略HPACK編碼中的編碼例子 var ( invalid error sawRegular bool // 16 << 20 from fr.maxHeaderListSize() from remainSize uint32 = 16 << 20 ) hdec := hpack.NewDecoder(4096, nil) // 16 << 20 from fr.maxHeaderStringLen() from fr.maxHeaderListSize() hdec.SetMaxStringLength(int(remainSize)) hdec.SetEmitFunc(func(hf hpack.HeaderField) { if !httpguts.ValidHeaderFieldValue(hf.Value) { invalid = fmt.Errorf("invalid header field value %q", hf.Value) } isPseudo := strings.HasPrefix(hf.Name, ":") if isPseudo { if sawRegular { invalid = errors.New("pseudo header field after regular") } } else { sawRegular = true // if !http2validWireHeaderFieldName(hf.Name) { // invliad = fmt.Sprintf("invalid header field name %q", hf.Name) // } } if invalid != nil { fmt.Println(invalid) hdec.SetEmitEnabled(false) return } size := hf.Size() if size > remainSize { hdec.SetEmitEnabled(false) // mh.Truncated = true return } remainSize -= size fmt.Printf("%+v\n", hf) // mh.Fields = append(mh.Fields, hf) }) defer hdec.SetEmitFunc(func(hf hpack.HeaderField) {}) fmt.Println(hdec.Write(buf.Bytes())) // 輸出以下: // ori size: 197, encoded size: 111 // header field ":authority" = "dss0.bdstatic.com" // header field ":method" = "GET" // header field ":path" = "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png" // header field ":scheme" = "https" // header field "accept-encoding" = "gzip" // header field "user-agent" = "Go-http-client/2.0" // header field "custom-header" = "custom-value" // 111 <nil>
經過最後一行的輸出能夠知道確確實實從111個字節中解碼出了197個字節的原Header數據。
而這解碼的過程筆者將從hdec.Write
方法開始分析,逐步揭開它的神祕面紗。
func (d *Decoder) Write(p []byte) (n int, err error) { // 此處省略代碼 if d.saveBuf.Len() == 0 { d.buf = p } else { d.saveBuf.Write(p) d.buf = d.saveBuf.Bytes() d.saveBuf.Reset() } for len(d.buf) > 0 { err = d.parseHeaderFieldRepr() if err == errNeedMore { // 此處省略代碼 d.saveBuf.Write(d.buf) return len(p), nil } // 此處省略代碼 } return len(p), err }
在筆者debug的過程當中發現解碼的核心邏輯主要在d.parseHeaderFieldRepr
方法裏。
func (d *Decoder) parseHeaderFieldRepr() error { b := d.buf[0] switch { case b&128 != 0: return d.parseFieldIndexed() case b&192 == 64: return d.parseFieldLiteral(6, indexedTrue) // 此處省略代碼 } return DecodingError{errors.New("invalid encoding")} }
第一個字節與上128不爲0只有一種狀況,那就是b爲0b1xxxxxxx
格式的數據,綜合前面的編碼邏輯能夠知道索引Header表示法對應的解碼方法爲d.parseFieldIndexed
。
第一個字節與上192爲64也只有一種狀況,那就是b爲0b01xxxxxx
格式的數據,綜合前面的編碼邏輯能夠知道增長動態表Header表示法對應的解碼方法爲d.parseFieldLiteral
。
經過(*Decoder).parseFieldIndexed
解碼時,真實的Header數據已經在靜態表或者動態表中了,只要經過HPACK索引找到對應的Header就解碼成功了。
func (d *Decoder) parseFieldIndexed() error { buf := d.buf idx, buf, err := readVarInt(7, buf) if err != nil { return err } hf, ok := d.at(idx) if !ok { return DecodingError{InvalidIndexError(idx)} } d.buf = buf return d.callEmit(HeaderField{Name: hf.Name, Value: hf.Value}) }
上述方法主要有三個步驟:
readVarInt
函數讀取HPACK索引。d.at
方法找到索引列表中真實的Header數據。d.CallEmit
最終會調用hdec.SetEmitFunc
設置的閉包,從而將Header傳遞給最上層。readVarInt:讀取HPACK索引
func readVarInt(n byte, p []byte) (i uint64, remain []byte, err error) { if n < 1 || n > 8 { panic("bad n") } if len(p) == 0 { return 0, p, errNeedMore } i = uint64(p[0]) if n < 8 { i &= (1 << uint64(n)) - 1 } if i < (1<<uint64(n))-1 { return i, p[1:], nil } origP := p p = p[1:] var m uint64 for len(p) > 0 { b := p[0] p = p[1:] i += uint64(b&127) << m if b&128 == 0 { return i, p, nil } m += 7 if m >= 63 { // TODO: proper overflow check. making this up. return 0, origP, errVarintOverflow } } return 0, origP, errNeedMore }
由上述的readVarInt函數知,當第一個字節的低n爲不全爲1時,則低n爲表明真實的HPACK索引,能夠直接返回。
當第一個字節的低n爲全爲1時,須要讀取更多的字節數來計算真正的HPACK索引。
(1<<uint64(n))-1
並賦值給ireadVarInt
函數邏輯和前面appendVarInt
函數邏輯相對應。
(*Decoder).at:根據HPACK的索引獲取真實的Header數據。
func (d *Decoder) at(i uint64) (hf HeaderField, ok bool) { if i == 0 { return } if i <= uint64(staticTable.len()) { return staticTable.ents[i-1], true } if i > uint64(d.maxTableIndex()) { return } dt := d.dynTab.table return dt.ents[dt.len()-(int(i)-staticTable.len())], true }
索引小於靜態表長度時,直接從靜態表中獲取Header數據。
索引長度大於靜態表時,根據前面介紹的HPACK索引列表,能夠經過dt.len()-(int(i)-staticTable.len())
計算出i在動態表ents
的真實下標,從而獲取Header數據。
經過(*Decoder).parseFieldLiteral
解碼時,須要考慮兩種狀況。1、Header的Name有索引。2、Header的Name和Value均無索引。這兩種狀況下,該Header都不存在於動態表中。
下面分步驟分析(*Decoder).parseFieldLiteral
方法。
一、讀取buf中的HPACK索引。
nameIdx, buf, err := readVarInt(n, buf)
二、 若是索引不爲0,則從HPACK索引列表中獲取Header的Name。
ihf, ok := d.at(nameIdx) if !ok { return DecodingError{InvalidIndexError(nameIdx)} } hf.Name = ihf.Name
三、若是索引爲0,則從buf中讀取Header的Name。
hf.Name, buf, err = d.readString(buf, wantStr)
四、從buf中讀取Header的Value,並將完整的Header添加到動態表中。
hf.Value, buf, err = d.readString(buf, wantStr) if err != nil { return err } d.buf = buf if it.indexed() { d.dynTab.add(hf) }
(*Decoder).readString: 從編碼的字節數據中讀取真實的Header數據。
func (d *Decoder) readString(p []byte, wantStr bool) (s string, remain []byte, err error) { if len(p) == 0 { return "", p, errNeedMore } isHuff := p[0]&128 != 0 strLen, p, err := readVarInt(7, p) // 省略校驗邏輯 if !isHuff { if wantStr { s = string(p[:strLen]) } return s, p[strLen:], nil } if wantStr { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() // don't trust others defer bufPool.Put(buf) if err := huffmanDecode(buf, d.maxStrLen, p[:strLen]); err != nil { buf.Reset() return "", nil, err } s = buf.String() buf.Reset() // be nice to GC } return s, p[strLen:], nil }
首先判斷字節數據是不是哈夫曼編碼(和前面的appendHpackString
函數對應),而後經過readVarInt
讀取數據的長度並賦值給strLen
。
若是不是哈夫曼編碼,則直接返回strLen
長度的數據。若是是哈夫曼編碼,讀取strLen
長度的數據,並用哈夫曼算法解碼後再返回。
在前面咱們已經瞭解了HPACK索引列表,以及基於HPACK索引列表的編/解碼流程。
下面筆者最後驗證一下已經編解碼事後的Header,再次編解碼時的大小。
// 此處省略前面HAPACK編碼和HPACK解碼的demo // try again fmt.Println("try again: ") buf.Reset() henc.WriteField(hpack.HeaderField{Name: "custom-header", Value: "custom-value"}) // 編碼已經編碼事後的Header fmt.Println(hdec.Write(buf.Bytes())) // 解碼 // 輸出: // ori size: 197, encoded size: 111 // header field ":authority" = "dss0.bdstatic.com" // header field ":method" = "GET" // header field ":path" = "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png" // header field ":scheme" = "https" // header field "accept-encoding" = "gzip" // header field "user-agent" = "Go-http-client/2.0" // header field "custom-header" = "custom-value" // 111 <nil> // try again: // header field "custom-header" = "custom-value" // 1 <nil>
由上面最後一行的輸出可知,解碼僅用了一個字節,即本例中編碼一個已經編碼過的Header也僅需一個字節。
綜上:在一個鏈接上,client和server維護一個相同的HPACK索引列表,多個請求在發送和接收Header數據時能夠分爲兩種狀況。
最後,由衷的感謝將HTTP2.0系列讀完的讀者,真誠的但願各位讀者可以有所收穫。
若是你們有什麼疑問能夠在評論區和諧地討論,筆者看到了也會及時回覆,願你們一塊兒進步。
注:
- 寫本文時, 筆者所用go版本爲: go1.14.2
- 索引Header表示法和增長動態表Header表示法均爲筆者自主命名,主要便於讀者理解。
參考: