【Go】相似csv的數據日誌組件設計

原文連接:blog.thinkeridea.com/201907/go/c…html

咱們業務天天須要記錄大量的日誌數據,且這些數據十分重要,它們是公司收入結算的主要依據,也是數據分析部門主要得數據源,針對這麼重要的日誌,且高頻率的日誌,咱們須要一個高性能且安全的日誌組件,能保證每行日誌格式完整性,咱們設計了一個類 csv 的日誌拼接組件,它的代碼在這裏 dataloggit

它是一個能夠保證日誌各列完整性且高效拼接字段的組件,支持任意列和行分隔符,並且還支持數組字段,但是實現一對多的日誌需求,不用記錄多個日誌,也不用記錄多行。它響應一個 []byte 數據,方便結合其它主鍵寫入數據到日誌文件或者網絡中。github

使用說明

API 列表

  • NewRecord(len int) Record 建立長度固定的日誌記錄shell

  • NewRecordPool(len int) *sync.Pool 建立長度固定的日誌記錄緩存池編程

  • ToBytes(sep, newline string) []byte 使用 sep 鏈接 Record,並在末尾添加 newline 換行符數組

  • ArrayJoin(sep string) string 使用 sep 鏈接 Record,其結果做爲數組字段的值緩存

  • ArrayFieldJoin(fieldSep, arraySep string) string 使用 fieldSep 鏈接 Record,其結果做爲一個數組的單元安全

  • Clean() 清空 Record 中的全部元素,若是使用 sync.Pool 在放回 Pool 以前應該清空 Record,避免內存泄漏網絡

  • UnsafeToBytes(sep, newline string) []byte 使用 sep 鏈接 Record,並在末尾添加 newline 換行符, 使用原地替換會破壞日誌字段引用的字符串ide

  • UnsafeArrayFieldJoin(fieldSep, arraySep string) string 使用 fieldSep 鏈接 Record,其結果做爲一個數組的單元, 使用原地替換會破壞日誌字段引用的字符串

底層使用 type Record []string 字符串切片做爲一行或者一個數組字段,在使用時它應該是定長的,由於數據日誌每每是格式化的,每列都有本身含義,使用 NewRecord(len int) Record 或者 NewRecordPool(len int) *sync.Pool 建立組件,我建議每一個日誌使用 NewRecordPool 在程序初始化時建立一個緩存池,程序運行時從緩存次獲取 Record 將會更加高效,可是每次放回 Pool 時須要調用 Clean 清空 Record 避免引用字符串沒法被回收,而致使內存泄漏。

實踐

咱們須要保證日誌每列數據的含義一至,咱們建立了定長的 Record,可是如何保證每列數據一致性,利用go 的常量枚舉能夠很好的保證,例如咱們定義日誌列常量:

const (
	LogVersion = "v1.0.0"
)
const (
	LogVer = iota
	LogTime
	LogUid
	LogUserName
	LogFriends

	LogFieldNumber
)
複製代碼

LogFieldNumber 就是日誌的列數量,也就是 Record 的長度,以後使用 NewRecordPool 建立緩存池,而後使用常量名稱做爲下標記錄日誌,這樣就不用擔憂由於檢查或者疏乎致使日誌列錯亂的問題了。

var w bytes.Buffer // 一個日誌寫組件
var pool = datalog.NewRecordPool(LogFieldNumber) // 建立一個緩存池

func main() {
  r := pool.Get().(datalog.Record)
  r[LogVer] = LogVersion
  r[LogTime] = time.Now().Format("2006-01-02 15:04:05")
  // 檢查用戶數據是否存在
  //if user !=nil{
    r[LogUid] = "Uid"
    r[LogUserName] = "UserNmae"
  //}

  // 拼接一行日誌數據
  data := r.Join(datalog.FieldSep, datalog.NewLine)
  r.Clean() // 清空 Record
  pool.Put(r) // 放回到緩存池

  // 寫入到日誌中
  if _, err := w.Write(data); err != nil {
    panic(err)
  }

  // 打印出日誌數據
  fmt.Println("'" + w.String() + "'")
}
複製代碼

以上程序運行會輸出:

由於分隔符是不可見字符,下面使用,代替字段分隔符,使用;\n代替換行符, 使用/代替數組字段分隔符,是-代替數組分隔符。

'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,;\n'
複製代碼

即便咱們沒有記錄 LogFriends 列的數據,可是在日誌中它仍然有一個佔位符,若是 usernilLogUidLogUserName 不須要特殊處理,也不須要寫入數據,它依然佔據本身的位置,不用擔憂日誌所以而錯亂。

使用 pool 能夠很好的利用內存,不會帶來過多的內存分配,並且 Record 的每一個字段值都是字符串,簡單的賦值並不會帶來太大的開銷,它會指向字符串自己的數據,不會有額外的內存分配,詳細參見string 優化誤區及建議。 使用 Record.Join 能夠高效的鏈接一行日誌記錄,便於咱們快速的寫入的日誌文件中,後面設計講解部分會詳細介紹 Join 的設計。

包含數組的日誌

有時候也並不是都是記錄一些單一的值,好比上面 LogFriends 會記錄當前記錄相關的朋友信息,這多是一組數據,datalog 也提供了一些簡單的輔助函數,能夠結合下面的實例實現:

// 定義 LogFriends 數組各列的數據
const (
	LogFriendUid = iota
	LogFriendUserName

	LogFriendFieldNumber
)

var w bytes.Buffer // 一個日誌寫組件
var pool = datalog.NewRecordPool(LogFieldNumber) // 每行日誌的 pool
var frPool = datalog.NewRecordPool(LogFriendFieldNumber) // LogFriends 數組字段的 pool

func main(){
  // 程序運行時
  r := pool.Get().(datalog.Record)
  r[LogVer] = LogVersion
  r[LogTime] = time.Now().Format("2006-01-02 15:04:05")
  // 檢查用戶數據是否存在
  //if user !=nil{
    r[LogUid] = "Uid"
    r[LogUserName] = "UserNmae"
  //}

  // 拼接一個數組字段,其長度是不固定的
  r[LogFriends] = GetLogFriends(rand.Intn(3))
  // 拼接一行日誌數據
  data := r.Join(datalog.FieldSep, datalog.NewLine)
  r.Clean() // 清空 Record
  pool.Put(r) // 放回到緩存池

  // 寫入到日誌中
  if _, err := w.Write(data); err != nil {
    panic(err)
  }

  // 打印出日誌數據
  fmt.Println("'" + w.String() + "'")
}

// 定義一個函數來拼接 LogFriends 
func GetLogFriends(friendNum int) string {
  // 根據數組長度建立一個 Record,數組的個數每每是不固定的,它總體做爲一行日誌的一個字段,因此並不會破壞數據
	fs := datalog.NewRecord(friendNum) 
 	// 這裏只須要中 pool 中獲取一個實例,它能夠反覆複用
	fr := frPool.Get().(datalog.Record)
	for i := 0; i < friendNum; i++ {
    // fr.Clean() 若是不是每一個字段都賦值,應該在使用前或者使用後清空它們便於後面複用
		fr[LogFriendUid] = "FUid"
		fr[LogFriendUserName] = "FUserName"
    
     // 鏈接一個數組中各個字段,做爲一個數組單元
		fs[i] = fr.ArrayFieldJoin(datalog.ArrayFieldSep, datalog.ArraySep)
	}
	fr.Clean() // 清空 Record
	frPool.Put(fr)  // 放回到緩存池

  // 鏈接數組的各個單元,返回一個字符串做爲一行日誌的一列
	return fs.ArrayJoin(datalog.ArraySep)
}
複製代碼

以上程序運行會輸出:

由於分隔符是不可見字符,下面使用,代替字段分隔符,使用;\n代替換行符, 使用/代替數組字段分隔符,是-代替數組分隔符。

'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,FUid/FUserName-FUid/FUserName;\n'
複製代碼

這樣在解析時能夠把某一字段當作數組解析,這極大的極大的提升了數據日誌的靈活性, 可是並不建議使用過多的層級,數據日誌應當清晰簡潔,可是有些特殊場景可使用一層嵌套。

最佳實踐

使用 ToBytesArrayFieldJoin 時會把數據字段中的鏈接字符串替換一個空字符串,因此在 datalog 裏面定義了4個分隔符,它們都是不可見字符,極少會出如今數據中,可是咱們還須要替換數據中的這些鏈接字符,避免破壞日誌結構。

雖然組件支持各類鏈接符,可是爲了不數據被破壞,咱們應該選擇一些不可見且少見的單字節字符做爲分隔符。換行符比較特殊,由於大多很多天志讀取組件都是用 \n 做爲行分隔符,若是數據中極少出現 \n 那就可使用 \ndatalog 中定義 \x03\n 做爲換行符,它兼容通常的日誌讀取組件,只須要咱們作少許的工做就能夠正確的解析日誌了。

UnsafeToBytesUnsafeArrayFieldJoin 性能會更好,和它們的名字同樣,他們並不安全,由於它們使用 exbytes.Replace 作原地替換分隔符,這會破壞數據所指向的原始字符串。除非咱們日誌數據中會出現極多的分隔符須要替換,否者並不建議使用它們,由於它們只在替換時提高性能。

我在服務中大量使用 UnsafeToBytesUnsafeArrayFieldJoin ,我老是在一個請求結束時記錄日誌,我確保全部相關的數據不會再使用,因此不用擔憂原地替換致使其它數據被無感知改變的問題,這也許是一個很好的實踐,可是我仍然不推薦使用它們。

設計講解

datalog 並無提供太多的約束很功能,它僅僅包含一種實踐和一組輔助工具,在使用它以前,咱們須要瞭解這些實踐。

它幫咱們建立一個定長的日誌行或者一個sync.Pool,咱們須要結合常量枚舉記錄數據,它幫咱們把各列數據鏈接成記錄日誌須要的數據格式。

它所提供的輔助方法都通過實際項目的考驗,考量諸多細節,以高性能爲核心目標所設計,使用它能夠極大的下降相關組件的開發成本,接下來這節將分析它的各個部分。

我認爲值得說道的是它提供的一個 Join 方法,相對於 strings.Join 能夠節省兩次的內存分配,現從它開始分析。

// Join 使用 sep 鏈接 Record, 並在末尾追加 suffix
// 這個相似 strings.Join 方法,可是避免了鏈接後追加後綴(每每是換行符)致使的內存分配
// 這個方法直接返回須要的 []byte 類型, 能夠減小類型轉換,下降內存分配致使的性能問題
func (l Record) Join(sep, suffix string) []byte {
	if len(l) == 0 {
		return []byte(suffix)
	}

	n := len(sep) * (len(l) - 1)
	for i := 0; i < len(l); i++ {
		n += len(l[i])
	}

	n += len(suffix)
	b := make([]byte, n)
	bp := copy(b, l[0])
	for i := 1; i < len(l); i++ {
		bp += copy(b[bp:], sep)
		bp += copy(b[bp:], l[i])
	}
	copy(b[bp:], suffix)
	return b
}
複製代碼

日誌組件每每輸入的參數是 []byte 類型,因此它直接返回一個 []byte ,而不像 strings.Join 響應一個字符串,在末尾是須要對內部的 buf 進行類型轉換,致使額外的內存開銷。咱們每行日誌不只須要使用分隔符鏈接各列,還須要一個行分隔符做爲結尾,它提供一個後綴 suffix,不用咱們以後在 Join 結果後再次拼接行分隔符,這樣也能減小一個額外的內存分配。

這偏偏是 datalog 設計的精髓,它並無大量使用標準庫的方法,而是設計更符合該場景的方法,以此來得到更高的性能和更好的使用體驗。

// ToBytes 使用 sep 鏈接 Record,並在末尾添加 newline 換行符
// 注意:這個方法會替換 sep 與 newline 爲空字符串
func (l Record) ToBytes(sep, newline string) []byte {
   for i := len(l) - 1; i >= 0; i-- {
      // 提早檢查是否包含特殊字符,以便跳過字符串替換
      if strings.Index(l[i], sep) < 0 && strings.Index(l[i], newline) < 0 {
         continue
      }

      b := []byte(l[i]) // 這會從新分配內存,避免原地替換致使引用字符串被修改
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(sep), []byte{' '}, -1)
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(newline), []byte{' '}, -1)
      l[i] = exbytes.ToString(b)
   }

   return l.Join(sep, newline)
}
複製代碼

ToBytes 做爲很重要的交互函數,也是該組件使用頻率最高的函數,它在鏈接各個字段以前替換每一個字段中的字段和行分隔符,這裏提早作了一個檢查字段中是否包含分隔符,若是包含使用 []byte(l[i]) 拷貝該列的數據,而後使用 exbytes.Replace 提供高性能的原地替換,由於輸入數據是拷貝從新分配的,因此不用擔憂原地替換會影響其它數據。

以後使用以前介紹的 Join 方法鏈接各列數據,若是使用 strings.Join 將會是 []byte(strings.Join([]string(l), sep) + newline) 這其中會增長不少次內存分配,該組件經過巧妙的設計規避這些額外的開銷,以提高性能。

// UnsafeToBytes 使用 sep 鏈接 Record,並在末尾添加 newline 換行符
// 注意:這個方法會替換 sep 與 newline 爲空字符串,替換採用原地替換,這會致使全部引用字符串被修改
// 必須明白其做用,否者將會致使意想不到的結果。可是這會大幅度減小內存分配,提高程序性能
// 我在項目中大量使用,我老是在請求最後記錄日誌,這樣我不會再訪問引用的字符串
func (l Record) UnsafeToBytes(sep, newline string) []byte {
   for i := len(l) - 1; i >= 0; i-- {
      b := exstrings.UnsafeToBytes(l[i])
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(sep), []byte{' '}, -1)
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(newline), []byte{' '}, -1)
      l[i] = exbytes.ToString(b)
   }

   return l.Join(sep, newline)
}
複製代碼

UnsafeToBytesToBytes 類似只是沒有分割符檢查,由於exbytes.Replace 中已經包含了檢查,並且直接使用 exstrings.UnsafeToBytes 把字符串轉成 []byte 這不會發生數據拷貝,很是的高效,可是它不支持字面量字符串,不過我相信日誌中的數據均來自運行時分配,若是不幸包含字面量字符串,也不用太過擔憂,只要使用一個特殊的字符做爲分隔符,每每咱們編程字面量字符串並不會包含這些字符,執行 exbytes.Replace 沒有發生替換也是安全的。

// Clean 清空 Record 中的全部元素,若是使用 sync.Pool 在放回 Pool 以前應該清空 Record,避免內存泄漏
// 該方法沒有太多的開銷,能夠放心的使用,只是爲 Record 中的字段賦值爲空字符串,空字符串會在編譯時處理,沒有額外的內存分配
func (l Record) Clean() {
   for i := len(l) - 1; i >= 0; i-- {
      l[i] = ""
   }
}
複製代碼

Clean 方法更簡單,它只是把各個列的數據替換爲空字符串,空字符串作爲一個特殊的字符,會在編譯時處理,並不會有額外的開銷,它們都指向同一塊內存。

// ArrayJoin 使用 sep 鏈接 Record,其結果做爲數組字段的值
func (l Record) ArrayJoin(sep string) string {
   return exstrings.Join(l, sep)
}

// ArrayFieldJoin 使用 fieldSep 鏈接 Record,其結果做爲一個數組的單元
// 注意:這個方法會替換 fieldSep 與 arraySep 爲空字符串,替換採用原地替換
func (l Record) ArrayFieldJoin(fieldSep, arraySep string) string {
   for i := len(l) - 1; i >= 0; i-- {
      // 提早檢查是否包含特殊字符,以便跳過字符串替換
      if strings.Index(l[i], fieldSep) < 0 && strings.Index(l[i], arraySep) < 0 {
         continue
      }

      b := []byte(l[i]) // 這會從新分配內存,避免原地替換致使引用字符串被修改
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(fieldSep), []byte{' '}, -1)
      b = exbytes.Replace(b, exstrings.UnsafeToBytes(arraySep), []byte{' '}, -1)
      l[i] = exbytes.ToString(b)
   }

   return exstrings.Join(l, fieldSep)
}
複製代碼

ArrayFieldJoin 在鏈接各個字符串時會直接替換數組單元分隔符,以後直接使用 exstrings.Join 進行鏈接字符串,exstrings.Join 相對 strings.Join 的一個改進函數,由於它只有一次內存分配,較 strings.Join 節省一次,有興趣能夠去看它的源碼實現。

總結

datalog 提供了一種實踐以及一些輔助工具,能夠幫助咱們快速的記錄數據日誌,更關心數據自己。具體程序性能能夠交給 datalog 來實現,它保證程序的性能。

後期我會計劃提供一個高效的日誌讀取組件,以便於讀取解析數據日誌,它較與通常文件讀取會更加高效且便捷,有針對性的優化日誌解析效率,敬請關注吧。

轉載:

本文做者: 戚銀(thinkeridea

本文連接: blog.thinkeridea.com/201907/go/c…

版權聲明: 本博客全部文章除特別聲明外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!

相關文章
相關標籤/搜索