【Go】相似csv的数据日志组件设计

原文连接:https://blog.thinkeridea.com/...html

咱们业务天天须要记录大量的日志数据,且这些数据十分重要,它们是公司收入结算的主要依据,也是数据分析部门主要得数据源,针对这么重要的日志,且高频率的日志,咱们须要一个高性能且安全的日志组件,能保证每行日志格式完整性,咱们设计了一个类 csv 的日志拼接组件,它的代码在这里 dataloggit

它是一个能够保证日志各列完整性且高效拼接字段的组件,支持任意列和行分隔符,并且还支持数组字段,但是实现一对多的日志需求,不用记录多个日志,也不用记录多行。它响应一个 []byte 数据,方便结合其它主键写入数据到日志文件或者网络中。github

使用说明

API 列表

  • NewRecord(len int) Record 建立长度固定的日志记录
  • 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 换行符, 使用原地替换会破坏日志字段引用的字符串
  • UnsafeArrayFieldJoin(fieldSep, arraySep string) string 使用 fieldSep 链接 Record,其结果做为一个数组的单元, 使用原地替换会破坏日志字段引用的字符串

底层使用 type Record []string 字符串切片做为一行或者一个数组字段,在使用时它应该是定长的,由于数据日志每每是格式化的,每列都有本身含义,使用 NewRecord(len int) Record 或者 NewRecordPool(len int) *sync.Pool 建立组件,我建议每一个日志使用 NewRecordPool 在程序初始化时建立一个缓存池,程序运行时从缓存次获取 Record 将会更加高效,可是每次放回 Pool 时须要调用 Clean 清空 Record 避免引用字符串没法被回收,而致使内存泄漏。shell

实践

咱们须要保证日志每列数据的含义一至,咱们建立了定长的 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 也提供了一些简单的辅助函数,能够结合下面的实例实现:ide

// 定义 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

本文连接: https://blog.thinkeridea.com/201907/go/csv_like_data_logs.html

版权声明: 本博客全部文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处!

相关文章
相关标签/搜索