Go36-38,39-bytes包

基本操作

bytes包和strings包非常相似,單從它們提供的函數的數量和功能上看,差別微乎其微。
strings包主要是面向Unicode字符和經過UTF-8編碼的字符串,而bytes包主要是面對字節和字節切片。

bytes.Buffer類型

Buffer類型的用途主要是作爲字節序列的緩衝區。
bytes.Buffer是開箱即用的。可以進行拼接、截斷其中的字節序列,以各種形式導出其中的內容,還可以順序的讀取其中的子序列。所以是集讀、寫功能與一身的數據類型,這些也是作爲緩衝區應該擁有的功能。
在內部,bytes.Buffer類型使用字節切片(bootstrap字段)作爲內容容器。還有一個int類型(off字段)作爲已讀字節的計數器,簡稱爲已讀計數。不過這裏的已讀計數是不無獲取也無法計算得到的。bytes.Buffer類型具體如下:

type Buffer struct {
    buf       []byte   // contents are the bytes buf[off : len(buf)]
    off       int      // read at &buf[off], write at &buf[len(buf)]
    bootstrap [64]byte // memory to hold first slice; helps small buffers avoid allocation.
    lastRead  readOp   // last read operation, so that Unread* can work correctly.
}

長度和容量

先看下示例:

package main

import (
    "fmt"
    "bytes"
)

func main() {
    var b1 bytes.Buffer
    contents := "Make the plan."
    b1.WriteString(contents)
    fmt.Println(b1.Len(), b1.Cap())

    p1 := make([]byte, 5)
    n, _ := b1.Read(p1)  // 忽略錯誤
    fmt.Println(n, string(p1))
    fmt.Println(b1.Len(), b1.Cap())
}

/* 執行結果
PS G:\Steed\Documents\Go\src\Go36\article38\example01> go run main.go
Lan: 14 Cap: 64
5 Make
Lan: 9  Cap: 64
PS G:\Steed\Documents\Go\src\Go36\article38\example01>
*/

先聲明瞭一個byte.Buffer類型的變量,並寫入一個字符串。然後打印了這個Buffer值的長度和容量。之後進行了一次讀取,讀取之後,再輸出一個長度和容量。這裏容量沒有變,因爲沒有再寫入任何內容。而長度變小了,這裏的長度是未讀內容的長度,一開始和存放的字節序列的長度一樣,在讀取操作之後,會隨之變小,同樣的,在寫入操作之後,也會增大。

已讀計數

沒有辦法可以直接得到Buffer值的已讀計數,並且也很難估算它。但是爲了用好bytes.Buffer,依然需要去源碼裏瞭解一下已讀計數的作用。
bytes.Buffer中的已讀計數的大致的功用如下:

  1. 讀取內容時,相應方法會依據已讀計數找到未讀部分,並在讀取後更新計數
  2. 寫入內容時,如需擴容,相應方法會根據已讀計數實現擴容策略
  3. 截斷內容時,相應方法截掉的是已讀計數代表的索引之後的未讀部分
  4. 讀回退時,相應方法需要用已讀計數記錄回退點
  5. 重置內容時,相應方法會把已讀計數置爲0
  6. 導出內容時,相應方法會導出已讀計數代表的索引之後的未讀部分
  7. 獲取長度時,相應方法會依據已讀計數和內容容器的長度,計算未讀部分的長度並返回

通過以上功能的介紹,就能夠體會到已讀計數的重要性了。在bytes.Buffer的大多數的方法都用到了已讀計數,而且都是非用不可的。

讀取內容

在讀取內容的時候,相應方法會先根據已讀計數,判斷一下內容容器中是否還有未讀內容。如果有,那就會以已讀計數爲索引開始讀取。讀完之後,還會及時的更新已讀計數。
讀取內容的方法:

  • 所有名稱以Read開頭的方法
  • Next方法
  • WriteTo方法

寫入內容

在寫入內容的時候,絕大多數的響應方法都會先檢查當前的內容容器,看看是否有足夠的容量容納新內容。如果沒有,就會進行擴容。在擴容的時候,方法會在必要時,依據已讀計數找到未讀部分,並把其中的內容拷貝到擴容後的內容容器的頭部位置。然後,方法將會把已讀計數的值置爲0,這樣下一次讀取的時候就會從新的內容容器的第一個字節開始了。
由於擴容後,已讀的內容不會拷貝,所以就真正的丟棄了。不過Buffer本身也不支持對已讀內容的再次操作,只是出於效率和值不可變的考慮,不會進行刪除,而是等到擴容的時候忽略該部分內容不做拷貝,最後等着被回收掉。
寫入內容的方法:

  • 所有名稱以Write開頭的方法
  • ReadFrom方法

示例:

func main() {
    var contents string
    b1 := bytes.NewBufferString(contents)
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())

    contents = "一二三四五"
    b1.WriteString(contents)
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())

    contents = "67"
    b1.WriteString(contents)
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
}

截斷內容

截斷內容的方法:Truncate
該方法會接受一個int類型的參數,表示在截斷時需要保留頭部的多個個字節。注意這裏所說的頭部指的是未讀部分的頭部。這個頭部的起始索引正是已讀計數的值。
還是因爲已讀部分邏輯上就是不存在的,所以這裏截斷操作是從未讀部分開始的

讀回退

讀回退有2個方法:

  • UnreadByte : 回退一個一節
  • UnreadRune : 回退一個Unicode字符

調用它們一般是爲了退回到上一次被讀取內容末尾的那個分隔符,或者爲了重新讀取前一個字節或字符做準備。回退是有前提的,在調用之前的哪一個操作必須是讀取內容,並且是成功讀取的。否則這寫方法就會忽略後續操作並返回一個非nil的錯誤值。
UnreadByte方法比較簡單,直接已讀計數減1即可。
而UnreadRune方法需要從已讀計數中減去的,是上一次被讀取的Unicode字符所佔用的字節數。這個字節數存在bytes.Buffer的lastRead字段裏。只有在執行ReadRune方法中才會把這個字段設置爲1至4的值,其他一些讀寫的方法中會在這個字段設置爲0或-1。所以只有緊接在ReadRune方法之後,才能成功調用UnreadRune方法。這個方法明顯比UnreadByte方法的適用面更小。

重置內容

重置內容的方法:Reset
不多解釋了,直接看源碼:

func (b *Buffer) Reset() {
    b.buf = b.buf[:0]
    b.off = 0
    b.lastRead = opInvalid
}

沒有重置內容容器,這樣避免了一次內存分配。

導出內容

導出內容的方法:

  • Bytes方法
  • String方法

訪問未讀部分的中的內容,並返回相應的結果。已讀的部分可以認爲是邏輯丟棄了,如果有過擴容,在垃圾清理後就是真正的物理丟棄了,所以也不應該獲取到。

獲取長度

獲取長度的方法:Lan方法
返回內容容器中未讀部分的長度。而不是其中已存內容的總長度,即:內容長度。

小結

已讀計數器索引之前的那些內容,永遠都是已經被讀過的,幾乎沒有機會再次被讀取到。
不過,這些已讀內容所在的內存空間可能會被存入新的內容。這一般都是由於重置或者擴容內容容器導致的。重置或擴容後,已讀計數一定會被置0,從而再次指向內容容器中的第一個字節。這有時候也是爲了避免內存分配和重用內存空間,這句意思大概是:重用一次內容空間的話,就避免了一次內存分配的操作。直接把之前分配過的但是內容已經不需要的內存再用起來。否則的話,就是一次新的內存分配和一次對已分配內存的清理

擴展知識

主要講兩個問題:

  • 擴容策略
  • 內容泄露

擴容策略

Buffer值既可以被手動擴容,也可以進行自動擴容。並且這種擴容方式的策略是基本一致的。所以,在完全確定後續內容所需的字節數的時候手動擴容,否則讓Buffer值自動擴容就好了。
在擴容的時候,是會先判斷內容容器(bootstrap)的剩餘容量是否夠用,如果可以,會在當前的內容容器上,進行長度擴容。在源碼中就是下面這幾句體現的:

func (b *Buffer) grow(n int) int {
    m := b.Len()
    // 省略中間的若干代碼
    b.buf = b.buf[:m+n]  // 當前內容的長度+需要的長度
    return m
}

若干內容容器的剩餘容量不夠了,那麼擴容就會用新的內容容器去替代原有的內容容器,從而實現擴容。這裏會有一步優化,如果當前內容容器的容量的一半仍然大於或等於現有長度在加上需要的字節數,那麼擴容代碼會複用現有的內容容器,並把容器中未讀內容拷貝到它的頭部位置。這樣就是把已讀內容都覆蓋掉了,整體內容在內存裏往前移。這樣的複用可以省掉一次後續的擴容所帶來的內存分配,以及若干字節的拷貝。
若上面的優化條件不滿足,那麼擴容代碼就要再創建一個新的內容容器,並把原有容器中的未讀內容拷貝進去,最後再用新的容器替換掉原有的容器。這個新容器的容量講會等於原有容量的兩倍再加上需要的字節數。這個策略和之前strings擴容的策略是一樣的
下面是一個擴容的示例代碼:

func main() {
    contents := "Good Year!"
    b1 := bytes.NewBufferString(contents)
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())  // 10, 16
    n := 10
    b1.Grow(n)
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())  // 10, 42
}

如果對處於零值狀態的Buffer值來說,如果第一次擴容時需要的字節數不大於64,那麼該值就會基於一個預先定義好的、長度爲64的數組([64]byte)來作爲內容容器。這樣做的目的是爲了讓Buffer值在剛被真正使用的時候就可以快速的做好準備。
完成上面的步驟,對內容容器的擴容就基本完成了。不過,爲了內部數據的一致性,以及避免原有的已讀內容可能造成的數據混亂,擴容代碼還會把已讀計數置爲0,並再對內容容器做一下切片操作,以掩蓋掉原有的已讀內容。

注意內容泄露

內容泄露:這裏說的內容泄露是指,使用Buffer值的一個方法通過某種非標準的(或者說不正式的)方法得到了不該得到的內容。
比如,通過調用Buffer值的某個用於讀取內容的方法,得到了一部分未讀內容。但是這個Buffer值又有了一些新內容後,卻可以通過當時得到的結果值,直接獲得新的內容,而不需要再次調用相應的讀去內容的方法。這就是典型的非標準讀取方式。這種讀取方式是不應該存在的,即使存在,也不應該使用。因爲它是在無意中(或者說不小心)暴露出來的,其行爲很可能是不穩定的。
在bytes.Buffer中,Bytes方法和Next方法都可能會造成內容的泄露。原因在於,它們都把基於內容容器的切片直接返回給了方法的調用方。通過切片,就可以直接訪問和操作它的底層數組。不論這個切片是基於某個數組得來的,還是通過對另一個切片做切片操作獲得的。這裏的Bytes方法和Next方法返回的字節切片,都是通過對內容容器做切片操作得到的。也就是說,它們與內容容器公用了同一個底層數組,起碼在一段時期之內是這樣的。
以Bytes方法爲例,下面是演示內容泄露的示例:

func main() {
    b1 := bytes.NewBufferString("abc")
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
    s1 := b1.Bytes()
    fmt.Printf("%[1]v, %[1]s\n", s1)
    b1.WriteString("123")
    fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
    fmt.Printf("%[1]v, %[1]s\n", s1)
    // 這裏只要擴充一下切片,就讀到後續內容了
    s1 = s1[:cap(s1)]
    fmt.Printf("%[1]v, %[1]s\n", s1)
    // 只是讀到還不算,還能改
    s1[len(s1)-3] = 'X'
    fmt.Printf("%[1]v, %[1]s\n", s1)
}

這裏要避免擴容,寫入內容後都輸出了一下容量,容量不變就是沒有擴容過。那麼Bytes方法返回的結果值與內容容器在此時還共用着同一個底層數組。之後就簡單的做了再切片,就通過這個結果值把後面的未讀內容都拿到了。這還沒完,如果當時把這個值傳到了外界,那麼外界就可以通過該值修改裏面的內容了。這個後果就很嚴重了,另一個Next方法,也存在相同的問題。
不過,如果經過擴容,Buffer值的內容容器或者它的底層數組就被重新設定了,那麼之前的內容泄露問題就無法再進一步發展了。
這裏是一個很嚴重的數據安全問題。一定要避免這種情況的發生。泄露的包裏的方法本身的特性,無法避免,但是可以小心操作。會造成嚴重後果的途徑是有意或無意的把這些返回的結果值傳到了外界,這個問題可以避免。要在傳出切片這類值之前,做好隔離。不如,先對它們進行深拷貝,然後再把副本傳出去。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章