提升性 能的利器:深 入解 析SectionReader

xue001發表於2023-10-31

一. 簡介

本文將介紹 Go 語言中的  SectionReader,包括  SectionReader的基本使用方法、實現原理、使用注意事項。從而能夠在合適的場景下,更好得使用 SectionReader型別,提升程式的效能。

二. 問題引入

這裡我們需要實現一個基本的HTTP檔案伺服器功能,可以處理客戶端的HTTP請求來讀取指定檔案,並根據請求的 Range頭部欄位返回檔案的部分資料或整個檔案資料。

這裡一個簡單的思路,可以先把整個檔案的資料載入到記憶體中,然後再根據請求指定的範圍,擷取對應的資料返回回去即可。下面提供一個程式碼示例:

func serveFile(w http.ResponseWriter, r *http.Request, filePath string) {    // 開啟檔案
    file, _ := os.Open(filePath)    defer file.Close()    // 讀取整個檔案資料
    fileData, err := ioutil.ReadAll(file)    if err != nil {        // 錯誤處理
        http.Error(w, err.Error(), http.StatusInternalServerError)        return
    }    // 根據Range頭部欄位解析請求的範圍
    rangeHeader := r.Header.Get("Range")
    ranges, err := parseRangeHeader(rangeHeader)    if err != nil {        // 錯誤處理
        http.Error(w, err.Error(), http.StatusBadRequest)        return
    }    // 處理每個範圍並返回資料
    for _, rng := range ranges {
        start := rng.Start
        end := rng.End        // 從檔案資料中提取範圍的位元組資料
        rangeData := fileData[start : end+1]        // 將範圍資料寫入響應
        w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileInfo.Size()))
        w.Header().Set("Content-Length", strconv.Itoa(len(rangeData)))
        w.WriteHeader(http.StatusPartialContent)
        w.Write(rangeData)
    }
}type Range struct {
    Start int
    End   int}// 解析HTTP Range請求頭func parseRangeHeader(rangeHeader string) ([]Range, error){}

上述的程式碼實現比較簡單,首先,函式開啟 filePath指定的檔案,使用 ioutil.ReadAll函式讀取整個檔案的資料到 fileData中。接下來,從HTTP請求頭中 Range頭部欄位中獲取範圍資訊,獲取每個範圍請求的起始和終止位置。接著,函式遍歷每一個範圍資訊,提取檔案資料 fileData 中對應範圍的位元組資料到 rangeData中,然後將資料返回回去。基於此,簡單實現了一個支援範圍請求的HTTP檔案伺服器。

但是當前實現其實存在一個問題,即在每次請求都會將整個檔案載入到記憶體中,即使使用者只需要讀取其中一小部分資料,這種處理方式會給記憶體帶來非常大的壓力。假如被請求檔案的大小是100M,一個32G記憶體的機器,此時最多隻能支援320個併發請求。但是使用者每次請求可能只是讀取檔案的一小部分資料,比如1M,此時將整個檔案載入到記憶體中,往往是一種資源的浪費,同時從磁碟中讀取全部資料到記憶體中,此時效能也較低。

那能不能在處理請求時,HTTP檔案伺服器只讀取請求的那部分資料,而不是載入整個檔案的內容,go基礎庫有對應型別的支援嗎?

其實還真有,Go語言中其實存在一個 SectionReader的型別,它可以從一個給定的資料來源中讀取資料的特定片段,而不是讀取整個資料來源,這個型別在這個場景下使用非常合適。

下面我們先仔細介紹下 SectionReader的基本使用方式,然後將其作用到上面檔案伺服器的實現當中。

三. 基本使用

3.1 基本定義

SectionReader型別的定義如下:

type SectionReader struct {
   r     ReaderAt
   base  int64
   off   int64
   limit int64}

SectionReader包含了四個欄位:

  • r:一個實現了 ReaderAt介面的物件,它是資料來源。
  • base: 資料來源的起始位置,透過設定 base欄位,可以調整資料來源的起始位置。
  • off:讀取的起始位置,表示從資料來源的哪個偏移量開始讀取資料,初始化時一般與 base保持一致。
  • limit:資料讀取的結束位置,表示讀取到哪裡結束。

同時還提供了一個構造器方法,用於建立一個 SectionReader例項,定義如下:

func NewSectionReader(r ReaderAt, off int64, n int64) *SectionReader {   // ... 忽略一些驗證邏輯
   // remaining 代表資料讀取的結束位置,為 base(偏移量) + n(讀取位元組數)
   remaining = n + off   return &SectionReader{r, off, off, remaining}
}

NewSectionReader接收三個引數, r 代表實現了 ReadAt介面的資料來源, off表示起始位置的偏移量,也就是要從哪裡開始讀取資料, n代表要讀取的位元組數。透過 NewSectionReader函式,可以很方便得建立出 SectionReader物件,然後讀取特定範圍的資料。

3.2 使用方式

SectionReader 能夠像 io.Reader一樣讀取資料,唯 一區別是會被限定在指定範圍內,只會返回特定範圍的資料。

下面透過一個例子來說明 SectionReader的使用,程式碼示例如下:

package mainimport (        "fmt"
        "io"
        "strings")func main() {        // 一個實現了 ReadAt 介面的資料來源
        data := strings.NewReader("Hello,World!")        // 建立 SectionReader,讀取範圍為索引 2 到 9 的位元組
        // off = 2, 代表從第二個位元組開始讀取; n = 7, 代表讀取7個位元組
        section := io.NewSectionReader(data, 2, 7)        // 資料讀取緩衝區長度為5
        buffer := make([]byte, 5)        for {                // 不斷讀取資料,直到返回io.EOF
                n, err := section.Read(buffer)                if err != nil {                        if err == io.EOF {                                // 已經讀取到末尾,退出迴圈
                                break
                        }
                        fmt.Println("Error:", err)                        return
                }
                fmt.Printf("Read %d bytes: %s\n", n, buffer[:n])
        }
}

上述函式使用  io.NewSectionReader 建立了一個  SectionReader,指定了開始讀取偏移量為 2,讀取位元組數為 7。這意味著我們將從第三個位元組(索引 2)開始讀取,讀取 7 個位元組。

然後我們透過一個無限迴圈,不斷呼叫 Read方法讀取資料,直到讀取完所有的資料。函式執行結果如下,確實只讀取了範圍為索引 2 到 9 的位元組的內容:

Read 5 bytes: llo,WRead 2 bytes: or

因此,如果我們只需要讀取資料來源的某一部分資料,此時可以建立一個 SectionReader例項,定義好資料讀取的偏移量和資料量之後,之後可以像普通的 io.Reader那樣讀取資料, SectionReader確保只會讀取到指定範圍的資料。

3.3 使用例子

這裡回到上面HTTP檔案伺服器實現的例子,之前的實現存在一個問題,即每次請求都會讀取整個檔案的內容,這會程式碼記憶體資源的浪費,效能低,響應時間比較長等問題。下面我們使用 SectionReader 對其進行最佳化,實現如下:

func serveFile(w http.ResponseWriter, r *http.Request, filePath string) {        // 開啟檔案
        file, err := os.Open(filePath)        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)                return
        }        defer file.Close()        // 獲取檔案資訊
        fileInfo, err := file.Stat()        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)                return
        }        // 根據Range頭部欄位解析請求的範圍
        rangeHeader := r.Header.Get("Range")
        ranges, err := parseRangeHeader(rangeHeader)        if err != nil {
                http.Error(w, err.Error(), http.StatusBadRequest)                return
        }        // 處理每個範圍並返回資料
        for _, rng := range ranges {
                start := rng.Start
                end := rng.End                // 根據範圍建立SectionReader
                section := io.NewSectionReader(file, int64(start), int64(end-start+1))                // 將範圍資料寫入響應
                w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileInfo.Size()))
                w.WriteHeader(http.StatusPartialContent)
                io.CopyN(w, section, section.Size())
        }
}type Range struct {
        Start int
        End   int}// 解析HTTP Range請求頭func parseRangeHeader(rangeHeader string) ([]Range, error) {}

在上述最佳化後的實現中,我們使用  io.NewSectionReader 建立了  SectionReader,它的範圍是根據請求頭中的範圍資訊計算得出的。然後,我們透過  io.CopyN 將  SectionReader 中的資料直接複製到響應的  http.ResponseWriter 中。

上述兩個HTTP檔案伺服器實現的區別,只在於讀取特定範圍資料方式,前一種方式是將整個檔案載入到記憶體中,再擷取特定範圍的資料;而後者則是透過使用  SectionReader,我們避免了一次性讀取整個檔案資料,並且只讀取請求範圍內的資料。這種最佳化能夠更高效地處理大檔案或處理大量併發請求的場景,節省了記憶體和處理時間。

四. 實現原理

4.1 設計初衷

SectionReader的設計初衷,在於提供一種簡潔,靈活的方式來讀取資料來源的特定部分。

4.2 基本原理

SectionReader 結構體中 offbaselimit欄位是實現只讀取資料來源特定部分資料功能的重要變數。

type SectionReader struct {
   r     ReaderAt
   base  int64
   off   int64
   limit int64}

由於 SectionReader需要保證只讀取特定範圍的資料,故需要儲存開始位置和結束位置的值。這裡是透過 baselimit這兩個欄位來實現的, base記錄了資料讀取的開始位置, limit記錄了資料讀取的結束位置。

透過設定 baselimit兩個欄位的值,限制了能夠被讀取資料的範圍。之後需要開始讀取資料,有可能這部分待讀取的資料不會被一次性讀完,此時便需要一個欄位來說明接下來要從哪一個位元組繼續讀取下去,因此 SectionReader也設定了 off欄位的值,這個代表著下一個帶讀取資料的位置。

在使用 SectionReader讀取資料的過程中,透過 baselimit限制了讀取資料的範圍, off則不斷修改,指向下一個帶讀取的位元組。

4.3 程式碼實現

4.3.1 Read方法說明

func (s *SectionReader) Read(p []byte) (n int, err error) {    // s.off: 將被讀取資料的下標
    // s.limit: 指定讀取範圍的最後一個位元組,這裡應該保證s.base <= s.off
   if s.off >= s.limit {      return 0, EOF
   }   // s.limit - s.off: 還剩下多少資料未被讀取
   if max := s.limit - s.off; int64(len(p)) > max {
      p = p[0:max]
   }   // 呼叫 ReadAt 方法讀取資料
   n, err = s.r.ReadAt(p, s.off)   // 指向下一個待被讀取的位元組
   s.off += int64(n)   return}

SectionReader實現了 Read 方法,透過該方法能夠實現指定範圍資料的讀取,在內部實現中,透過兩個限制來保證只會讀取到指定範圍的資料,具體限制如下:

  • 透過保證  off 不大於  limit 欄位的值,保證不會讀取超過指定範圍的資料
  • 在呼叫 ReadAt方法時,保證傳入切片長度不大於剩餘可讀資料長度

透過這兩個限制,保證了使用者只要設定好了資料開始讀取偏移量  base 和 資料讀取結束偏移量  limit欄位值, Read方法便只會讀取這個範圍的資料。

4.3.2 ReadAt 方法說明

func (s *SectionReader) ReadAt(p []byte, off int64) (n int, err error) {    // off: 引數指定了偏移位元組數,為一個相對數值
    // s.limit - s.base >= off: 保證不會越界
   if off < 0 || off >= s.limit-s.base {      return 0, EOF
   }   // off + base: 獲取絕對的偏移量
   off += s.base   // 確保傳入位元組陣列長度 不超過 剩餘讀取資料範圍
   if max := s.limit - off; int64(len(p)) > max {
      p = p[0:max]      // 呼叫ReadAt 方法讀取資料
      n, err = s.r.ReadAt(p, off)      if err == nil {
         err = EOF
      }      return n, err
   }   return s.r.ReadAt(p, off)
}

SectionReader還提供了 ReadAt方法,能夠指定偏移量處實現資料讀取。它根據傳入的偏移量 off欄位的值,計算出實際的偏移量,並呼叫底層源的 ReadAt方法進行讀取操作,在這個過程中,也保證了讀取資料範圍不會超過 baselimit欄位指定的資料範圍。

這個方法提供了一種靈活的方式,能夠在限定的資料範圍內,隨意指定偏移量來讀取資料,不過需要注意的是,該方法並不會影響例項中 off欄位的值。

4.3.3 Seek 方法說明

func (s *SectionReader) Seek(offset int64, whence int) (int64, error) {   switch whence {   default:      return 0, errWhence   case SeekStart:      // s.off = s.base + offset
      offset += s.base   case SeekCurrent:      // s.off = s.off + offset
      offset += s.off   case SeekEnd:      // s.off = s.limit + offset
      offset += s.limit
   }   // 檢查
   if offset < s.base {      return 0, errOffset
   }
   s.off = offset   return offset - s.base, nil}

SectionReader也提供了 Seek方法,給其提供了隨機訪問和靈活讀取資料的能力。舉個例子,假如已經呼叫 Read方法讀取了一部分資料,但是想要重新讀取該資料,此時便可以使 Seek方法將 off欄位設定回之前的位置,然後再次呼叫Read方法進行讀取。

五. 使用注意事項

5.1 注意off值在base和limit之間

當使用  SectionReader 建立例項時,確保  off 值在  base 和  limit 之間是至關重要的。保證  off 值在  base 和  limit 之間的好處是確保讀取操作在有效的資料範圍內進行,避免讀取錯誤或超出範圍的訪問。如果  off 值小於  base 或大於等於  limit,讀取操作可能會導致錯誤或返回 EOF。

一個良好的實踐方式是使用  NewSectionReader 函式來建立  SectionReader 例項。 NewSectionReader 函式會檢查 off 值是否在有效範圍內,並自動調整  off 值,以確保它在  base 和  limit 之間。

5.2 及時關閉底層資料來源

當使用 SectionReader時,如果沒有及時關閉底層資料來源可能會導致資源洩露,這些資源在程式執行期間將一直保持開啟狀態,直到程式終止。在處理大量請求或長時間執行的情況下,可能會耗盡系統的資源。

下面是一個示例,展示了沒有關閉 SectionReader底層資料來源可能引發的問題:

func main() {
    file, err := os.Open("data.txt")    if err != nil {
        log.Fatal(err)
    }    defer file.Close()
    section := io.NewSectionReader(file, 10, 20)
    buffer := make([]byte, 10)
    _, err = section.Read(buffer)    if err != nil {
        log.Fatal(err)
    }    // 沒有關閉底層資料來源,可能導致資源洩露或其他問題}

在上述示例中,底層資料來源是一個檔案。在程式結束時,沒有顯式呼叫 file.Close()來關閉檔案控制程式碼,這將導致檔案資源一直保持開啟狀態,直到程式終止。這可能導致其他程式無法訪問該檔案或其他與檔案相關的問題。

因此,在使用 SectionReader時,要注意及時關閉底層資料來源,以確保資源的正確管理和避免潛在的問題。

六. 總結

本文主要對 SectionReader進行了介紹。文章首先從一個基本HTTP檔案伺服器的功能實現出發,解釋了該實現存在記憶體資源浪費,併發效能低等問題,從而引出了 SectionReader

接下來介紹了 SectionReader的基本定義,以及其基本使用方法,最後使用 SectionReader對上述HTTP檔案伺服器進行最佳化。接著還詳細講述了 SectionReader的實現原理,從而能夠更好得理解和使用 SectionReader

最後,講解了 SectionReader的使用注意事項,如需要及時關閉底層資料來源等。基於此完成了 SectionReader的介紹。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70034822/viewspace-2992119/,如需轉載,請註明出處,否則將追究法律責任。

相關文章