原文链接:https://blog.thinkeridea.com/201907/go/csv_like_data_logs.html

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

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

使用说明

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 避免引用字符串无法被回收,而导致内存泄漏。

实践

我们需要保证日志每列数据的含义一至,我们创建了定长的 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

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

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

【Go】类似csv的数据日志组件设计的更多相关文章

  1. Unity应用架构设计(13)——日志组件的实施

    对于应用程序而言,日志是非常重要的功能,通过日志,我们可以跟踪应用程序的数据状态,记录Crash的日志可以帮助我们分析应用程序崩溃的原因,我们甚至可以通过日志来进行性能的监控.总之,日志的好处很多,特 ...

  2. 大数据之ETL设计详解

    ETL是BI项目最重要的一个环节,通常情况下ETL会花掉整个项目的1/3的时间,ETL设计的好坏直接关接到BI项目的成败.ETL也是一个长期的过程,只有不断的发现问题并解决问题,才能使ETL运行效率更 ...

  3. 日志组件logback的介绍及配置使用方法

    一.logback的介绍 Logback是由log4j创始人设计的又一个开源日志组件.logback当前分成三个模块:logback-core,logback- classic和logback-acc ...

  4. D3js初探及数据可视化案例设计实战

    摘要:本文以本人目前所做项目为基础,从设计的角度探讨数据可视化的设计的方法.过程和结果,起抛砖引玉之效.在技术方案上,我们采用通用web架构和d3js作为主要技术手段:考虑到项目需求,这里所做的可视化 ...

  5. 日志处理(二) 日志组件logback的介绍及配置使用方法(转)

    本文转自:http://www.cnblogs.com/yuanermen/archive/2012/02/13/2348942.html http://www.cnblogs.com/yuanerm ...

  6. 转:日志组件logback的介绍及配置使用方法

    转自:http://blog.csdn.net/zgmzyr/article/details/8267072 一.logback的介绍 Logback是由log4j创始人设计的又一个开源日志组件.lo ...

  7. 日志组件logback的介绍及配置使用方法(一)

    一.logback的介绍 Logback是由log4j创始人设计的又一个开源日志组件.logback当前分成三个模块:logback-core,logback- classic和logback-acc ...

  8. Logback 日志组件的使用

    Logback 是由 log4j 创始人设计的又一个开源日志组件. 一. logback 的介绍 ​ logback 当前分成三个模块:logback-core,logback- classic 和 ...

  9. 【开源】OSharp3.0框架解说系列(6.2):操作日志与数据日志

    OSharp是什么? OSharp是个快速开发框架,但不是一个大而全的包罗万象的框架,严格的说,OSharp中什么都没有实现.与其他大而全的框架最大的不同点,就是OSharp只做抽象封装,不做实现.依 ...

随机推荐

  1. 把#define宏转换成指定格式

    之前在弄一个东西的,有一大堆的宏,需要把它转换成其它的形式.遇到这种大批量的东西,我特别没有耐心去一个一个的弄,于是写了一段代码. 估计大家平常比较难用得上,不过可以平常相似的情况用来参考. Sort ...

  2. 简单的Windows Webcam应用:Barcode Reader

    原文:简单的Windows Webcam应用:Barcode Reader 在Windows上用WinForm创建一个Webcam应用需要用到DirectShow.DirectShow没有提供C#的接 ...

  3. 自定义QT窗口部件外观之QStyle

    自定义QT窗口部件外观 重新定义Qt内置窗口部件的外观常用的方法有两种:一是通过子类化QStyle 类或者预定义的一个样式,例如QWindowStyle,来定制应用程序的观感:二是使用Qt样式表. Q ...

  4. Java实现Qt的SIGNAL-SLOT机制(保存到Map中,从而将它们关联起来,收到信号进行解析,最后反射调用)

    SIGNAL-SLOT是Qt的一大特色,使用起来十分方便.在传统的AWT和Swing编程中,我们都是为要在 监听的对象上添加Listener监听器.被监听对象中保存有Listener的列表,当相关事件 ...

  5. Java代码消除switch/case,if/else语句的几种实现方式

    转自:https://my.oschina.net/stefanzhlg/blog/372413 我们在平时的编码中,我们经常会遇到这样的情况: 使用过多的switch/case 或者 if else ...

  6. Matlab与.Net混合编程-多维数组赋值出错的问题

    问题描述:Matlab可编译供.net调用的dll.两种不同环境对数据类型的定义相差较大,因此在C#中调用Matlab编译的函数时,首先要将C#中的变量类型转换成与Matlab对应的中转类型.Matl ...

  7. shell多线程(2)之基于管道实现并发

    在shell脚本里批量执行程序是比较常见的方式,如果程序很多,每个执行时间比较长,则顺序执行需要花费大量的时间. 此时并发就成为我们考虑的方向. 上篇<shell多线程>中我们已经简单实现 ...

  8. 解码mmo游戏服务器三:大地图同步(aoi)

    问题引入:aoi(area of interest).在大地图中,玩家只需要关心自己周围的对象变化,而不需要关心距离较远的对象的变化.所以大地图中的数据不需要全部广播,只要同步玩家自己视野范围的消息即 ...

  9. PHP实现WebService服务

    第一步,安装PHP扩展SOAP并开启扩展,是否开启成功以phpinfo为准. 第二步,创建服务端文件server.php <?php Class server { public function ...

  10. Spring Boot的学习之路(02):和你一起阅读Spring Boot官网

    官网是我们学习的第一手资料,我们不能忽视它.却往往因为是英文版的,我们选择了逃避它,打开了又关闭. 我们平常开发学习中,很少去官网上看.也许学完以后,我们连官网长什么样子,都不是很清楚.所以,我们在开 ...