Go語言核心36講(Go語言實戰與應用十七)--學習筆記

MingsonZheng發表於2021-11-29

39 | bytes包與位元組串操作(下)

在上一篇文章中,我們分享了bytes.Buffer中已讀計數的大致功用,並圍繞著這個問題做了解析,下面我們來進行相關的知識擴充套件。

知識擴充套件

問題 1:bytes.Buffer的擴容策略是怎樣的?

Buffer值既可以被手動擴容,也可以進行自動擴容。並且,這兩種擴容方式的策略是基本一致的。所以,除非我們完全確定後續內容所需的位元組數,否則讓Buffer值自動去擴容就好了。

在擴容的時候,Buffer值中相應的程式碼(以下簡稱擴容程式碼)會先判斷內容容器的剩餘容量,是否可以滿足呼叫方的要求,或者是否足夠容納新的內容。

如果可以,那麼擴容程式碼會在當前的內容容器之上,進行長度擴充。

更具體地說,如果內容容器的容量與其長度的差,大於或等於另需的位元組數,那麼擴容程式碼就會通過切片操作對原有的內容容器的長度進行擴充,就像下面這樣:

b.buf = b.buf[:length+need]

反之,如果內容容器的剩餘容量不夠了,那麼擴容程式碼可能就會用新的內容容器去替代原有的內容容器,從而實現擴容。

不過,這裡還有一步優化。

如果當前內容容器的容量的一半,仍然大於或等於其現有長度(即未讀位元組數)再加上另需的位元組數的和,即:

cap(b.buf)/2 >= b.Len() + need

那麼,擴容程式碼就會複用現有的內容容器,並把容器中的未讀內容拷貝到它的頭部位置。

這也意味著其中的已讀內容,將會全部被未讀內容和之後的新內容覆蓋掉。

這樣的複用預計可以至少節省掉一次後續的擴容所帶來的記憶體分配,以及若干位元組的拷貝。

若這一步優化未能達成,也就是說,當前內容容器的容量小於新長度的二倍。

那麼,擴容程式碼就只能再建立一個新的內容容器,並把原有容器中的未讀內容拷貝進去,最後再用新的容器替換掉原有的容器。這個新容器的容量將會等於原有容量的二倍再加上另需位元組數的和。

新容器的容量 =2* 原有容量 + 所需位元組數

通過上面這些步驟,對內容容器的擴充基本上就完成了。不過,為了內部資料的一致性,以及避免原有的已讀內容可能造成的資料混亂,擴容程式碼還會把已讀計數置為0,並再對內容容器做一下切片操作,以掩蓋掉原有的已讀內容。

順便說一下,對於處在零值狀態的Buffer值來說,如果第一次擴容時的另需位元組數不大於64,那麼該值就會基於一個預先定義好的、長度為64的位元組陣列來建立內容容器。

在這種情況下,這個內容容器的容量就是64。這樣做的目的是為了讓Buffer值在剛被真正使用的時候就可以快速地做好準備。

package main

import (
	"bytes"
	"fmt"
)

func main() {
	// 示例1。
	var contents string
	buffer1 := bytes.NewBufferString(contents)
	fmt.Printf("The length of new buffer with contents %q: %d\n",
		contents, buffer1.Len())
	fmt.Printf("The capacity of new buffer with contents %q: %d\n",
		contents, buffer1.Cap())
	fmt.Println()

	contents = "12345"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer1.WriteString(contents)
	fmt.Printf("The length of buffer: %d\n", buffer1.Len())
	fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
	fmt.Println()

	contents = "67"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer1.WriteString(contents)
	fmt.Printf("The length of buffer: %d\n", buffer1.Len())
	fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
	fmt.Println()

	contents = "89"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer1.WriteString(contents)
	fmt.Printf("The length of buffer: %d\n", buffer1.Len())
	fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
	fmt.Print("\n\n")

	// 示例2。
	contents = "abcdefghijk"
	buffer2 := bytes.NewBufferString(contents)
	fmt.Printf("The length of new buffer with contents %q: %d\n",
		contents, buffer2.Len())
	fmt.Printf("The capacity of new buffer with contents %q: %d\n",
		contents, buffer2.Cap())
	fmt.Println()

	n := 10
	fmt.Printf("Grow the buffer with %d ...\n", n)
	buffer2.Grow(n)
	fmt.Printf("The length of buffer: %d\n", buffer2.Len())
	fmt.Printf("The capacity of buffer: %d\n", buffer2.Cap())
	fmt.Print("\n\n")

	// 示例3。
	var buffer3 bytes.Buffer
	fmt.Printf("The length of new buffer: %d\n", buffer3.Len())
	fmt.Printf("The capacity of new buffer: %d\n", buffer3.Cap())
	fmt.Println()

	contents = "xyz"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer3.WriteString(contents)
	fmt.Printf("The length of buffer: %d\n", buffer3.Len())
	fmt.Printf("The capacity of buffer: %d\n", buffer3.Cap())
}

問題 2:bytes.Buffer中的哪些方法可能會造成內容的洩露?

首先明確一點,什麼叫內容洩露?這裡所說的內容洩露是指,使用Buffer值的一方通過某種非標準的(或者說不正式的)方式,得到了本不該得到的內容。

比如說,我通過呼叫Buffer值的某個用於讀取內容的方法,得到了一部分未讀內容。我應該,也只應該通過這個方法的結果值,拿到在那一時刻Buffer值中的未讀內容。

但是,在這個Buffer值又有了一些新內容之後,我卻可以通過當時得到的結果值,直接獲得新的內容,而不需要再次呼叫相應的方法。

這就是典型的非標準讀取方式。這種讀取方式是不應該存在的,即使存在,我們也不應該使用。因為它是在無意中(或者說一不小心)暴露出來的,其行為很可能是不穩定的。

在bytes.Buffer中,Bytes方法和Next方法都可能會造成內容的洩露。原因在於,它們都把基於內容容器的切片直接返回給了方法的呼叫方。

我們都知道,通過切片,我們可以直接訪問和操縱它的底層陣列。不論這個切片是基於某個陣列得來的,還是通過對另一個切片做切片操作獲得的,都是如此。

在這裡,Bytes方法和Next方法返回的位元組切片,都是通過對內容容器做切片操作得到的。也就是說,它們與內容容器共用了同一個底層陣列,起碼在一段時期之內是這樣的。

以Bytes方法為例。它會返回在呼叫那一刻其所屬值中的所有未讀內容。示例程式碼如下:

contents := "ab"
buffer1 := bytes.NewBufferString(contents)
fmt.Printf("The capacity of new buffer with contents %q: %d\n",
 contents, buffer1.Cap()) // 內容容器的容量為:8。
unreadBytes := buffer1.Bytes()
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未讀內容為:[97 98]。

我用字串值"ab"初始化了一個Buffer值,由變數buffer1代表,並列印了當時該值的一些狀態。

你可能會有疑惑,我只在這個Buffer值中放入了一個長度為2的字串值,但為什麼該值的容量卻變為了8。

雖然這與我們當前的主題無關,但是我可以提示你一下:你可以去閱讀runtime包中一個名叫stringtoslicebyte的函式,答案就在其中。

接著說buffer1。我又向該值寫入了字串值"cdefg",此時,其容量仍然是8。我在前面通過呼叫buffer1的Bytes方法得到的結果值unreadBytes,包含了在那時其中的所有未讀內容。

但是,由於這個結果值與buffer1的內容容器在此時還共用著同一個底層陣列,所以,我只需通過簡單的再切片操作,就可以利用這個結果值拿到buffer1在此時的所有未讀內容。如此一來,buffer1的新內容就被洩露出來了。

buffer1.WriteString("cdefg")
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 內容容器的容量仍為:8。
unreadBytes = unreadBytes[:cap(unreadBytes)]
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基於前面獲取到的結果值可得,未讀內容為:[97 98 99 100 101 102 103 0]。

如果我當時把unreadBytes的值傳到了外界,那麼外界就可以通過該值操縱buffer1的內容了,就像下面這樣:

unreadBytes[len(unreadBytes)-2] = byte('X') // 'X'的ASCII編碼為88。
fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes()) // 未讀內容變為了:[97 98 99 100 101 102 88]。

現在,你應該能夠體會到,這裡的內容洩露可能造成的嚴重後果了吧?

對於Buffer值的Next方法,也存在相同的問題。不過,如果經過擴容,Buffer值的內容容器或者它的底層陣列被重新設定了,那麼之前的內容洩露問題就無法再進一步發展了。我在 demo80.go 檔案中寫了一個比較完整的示例,你可以去看一看,並揣摩一下。

package main

import (
	"bytes"
	"fmt"
)

func main() {
	// 示例1。
	contents := "ab"
	buffer1 := bytes.NewBufferString(contents)
	fmt.Printf("The capacity of new buffer with contents %q: %d\n",
		contents, buffer1.Cap())
	fmt.Println()

	unreadBytes := buffer1.Bytes()
	fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes)
	fmt.Println()

	contents = "cdefg"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer1.WriteString(contents)
	fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
	fmt.Println()

	// 只要擴充一下之前拿到的未讀位元組切片unreadBytes,
	// 就可以用它來讀取甚至修改buffer中的後續內容。
	unreadBytes = unreadBytes[:cap(unreadBytes)]
	fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes)
	fmt.Println()

	value := byte('X')
	fmt.Printf("Set a byte in the unread bytes to %v ...\n", value)
	unreadBytes[len(unreadBytes)-2] = value
	fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes())
	fmt.Println()

	// 不過,在buffer的內容容器真正擴容之後就無法這麼做了。
	contents = "hijklmn"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer1.WriteString(contents)
	fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
	fmt.Println()

	unreadBytes = unreadBytes[:cap(unreadBytes)]
	fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes)
	fmt.Print("\n\n")

	// 示例2。
	// Next方法返回的後續位元組切片也存在相同的問題。
	contents = "12"
	buffer2 := bytes.NewBufferString(contents)
	fmt.Printf("The capacity of new buffer with contents %q: %d\n",
		contents, buffer2.Cap())
	fmt.Println()

	nextBytes := buffer2.Next(2)
	fmt.Printf("The next bytes of the buffer: %v\n", nextBytes)
	fmt.Println()

	contents = "34567"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer2.WriteString(contents)
	fmt.Printf("The capacity of buffer: %d\n", buffer2.Cap())
	fmt.Println()

	// 只要擴充一下之前拿到的後續位元組切片nextBytes,
	// 就可以用它來讀取甚至修改buffer中的後續內容。
	nextBytes = nextBytes[:cap(nextBytes)]
	fmt.Printf("The next bytes of the buffer: %v\n", nextBytes)
	fmt.Println()

	value = byte('X')
	fmt.Printf("Set a byte in the next bytes to %v ...\n", value)
	nextBytes[len(nextBytes)-2] = value
	fmt.Printf("The unread bytes of the buffer: %v\n", buffer2.Bytes())
	fmt.Println()

	// 不過,在buffer的內容容器真正擴容之後就無法這麼做了。
	contents = "89101112"
	fmt.Printf("Write contents %q ...\n", contents)
	buffer2.WriteString(contents)
	fmt.Printf("The capacity of buffer: %d\n", buffer2.Cap())
	fmt.Println()

	nextBytes = nextBytes[:cap(nextBytes)]
	fmt.Printf("The next bytes of the buffer: %v\n", nextBytes)
}

總結

我們結合兩篇內容總結一下。與strings.Builder型別不同,bytes.Buffer不但可以拼接、截斷其中的位元組序列,以各種形式匯出其中的內容,還可以順序地讀取其中的子序列。

bytes.Buffer型別使用位元組切片作為其內容容器,並且會用一個欄位實時地記錄已讀位元組的計數。

雖然我們無法直接計算出這個已讀計數,但是由於它在Buffer值中起到的作用非常關鍵,所以我們很有必要去理解它。

無論是讀取、寫入、截斷、匯出還是重置,已讀計數都是功能實現中的重要一環。

與strings.Builder型別的值一樣,Buffer值既可以被手動擴容,也可以進行自動的擴容。除非我們完全確定後續內容所需的位元組數,否則讓Buffer值自動去擴容就好了。

Buffer值的擴容方法並不一定會為了獲得更大的容量,替換掉現有的內容容器,而是先會本著儘量減少記憶體分配和內容拷貝的原則,對當前的內容容器進行重用。並且,只有在容量實在無法滿足要求的時候,它才會去建立新的內容容器。

此外,你可能並沒有想到,Buffer值的某些方法可能會造成內容的洩露。這主要是由於這些方法返回的結果值,在一段時期內會與其所屬值的內容容器共用同一個底層陣列。

如果我們有意或無意地把這些結果值傳到了外界,那麼外界就有可能通過它們操縱相關聯Buffer值的內容。

這屬於很嚴重的資料安全問題。我們一定要避免這種情況的發生。最徹底的做法是,在傳出切片這類值之前要做好隔離。比如,先對它們進行深度拷貝,然後再把副本傳出去。

思考題

今天的思考題是:對比strings.Builder和bytes.Buffer的String方法,並判斷哪一個更高效?原因是什麼?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章