编年史 (Chronicle) 是一个实验,旨在回答一个简单的问题:一个由第三方开发者为第三方开发者设计的 os_log 会是什么样子? 结果是,有点像这样
let chronicle = Chronicle(url: URL(fileURLWithPath: "log.chronicle"), bufferSize: 1 << 20)
let logger = try chronicle.logger(name: "test")
#log(logger, "Invocation: \(String(cString: getprogname())) [\(CommandLine.argc) arguments]")
#log(logger, "It's been \(Date.now.timeIntervalSince1970) seconds since the epoch")
编年史 (Chronicle) 是
编年史 (Chronicle) 不是
os_log 是 Apple 为其平台提供的高性能统一日志记录解决方案的一部分。 在公司内部,它为工程师用来诊断和调查代码问题的许多分析工具提供支持。 它具有许多简洁的功能:它从系统的所有部分收集日志,开销相对较低,可以在级别/子系统/类别基础上进行高度配置,并且与 Console 和 Instruments 等软件集成。 Apple 也希望您使用它,这就是他们向第三方开发者提供 API 的原因。 不幸的是,有时它并不特别适合这项工作。
应用开发者首先关心的是他们自己的日志。 统一日志虽然在尝试跨组件跟踪问题时很有帮助,但对于尝试诊断自己代码中的问题的人来说用处较小。 对于日志记录,共享日志意味着系统服务经常向其中喷射“垃圾信息”:这些消息很少有用,除非对系统 API 的直接开发者有用。 虽然有一些方法可以使来自嘈杂源的日志静音(至少,如果开发者使用适当的子系统和类别),但即使这样,源的数量也很难处理。 并且随着操作系统的增大,客户端的数量也在不断增长。
虽然您可以对日志持久性进行一些控制,但总的来说,日志会保存尽可能长的时间,只要系统愿意保留它们即可。 并且取回它们很麻烦:公共 API(不是 Apple 使用的!)受到限制且已损坏。 要求用户进行系统诊断以获取您的日志很麻烦且侵犯隐私,因为它包含来自其他应用的数据。
这种设计也限制了实现:进程中的任何缓冲区都由系统确定大小,如果您超出它们或记录速度太快,消息将被丢弃。 持久化到磁盘或流式传输的消息必然会产生 XPC 的成本。 即使是日志记录配置也是系统范围的,虽然已经投入大量工作来提高访问效率(commpage 等),但如果您不需要它,仍然会产生开销。
os_log 旨在满足 Apple 需要它做的事情。 这意味着他们在您的代码上运行特殊的编译器优化过程,以尝试将日志缓冲区优化为 API 想要的形状。 这意味着活动 ID 在进程之间传递,但不在不同的计算机之间传递。 您放入日志消息中的信息会被卡在那里,除非您使用 Apple 的工具将其提取出来,并且如果您想找到 99 和 90 百分位数的值,而 Apple 只向您显示中位数,那么您就不走运了。 如果您想向后部署到 Catalina 并且系统不支持您想要做的事情,您无能为力。 当然,Apple 官方的 os_log 包装解决方案是“不要”。
编年史 (Chronicle) 的设计有点像 os_log,但是位于您的进程本地。 默认情况下,它打开一个文件作为环形缓冲区并将其映射到内存中,以便它不必显式刷新任何数据。 这意味着它可以持续到崩溃,而无需安装任何特殊处理程序,并且它不会为每条消息访问磁盘(这既慢又对您的闪存存储不利)。 日志格式经过精心设计,采用线性双向链表的形式,即使在写入过程中中断,也可以恢复。
编年史 (Chronicle) 中的日志是类型化的,并且像 os_log 一样,内存中的格式尽可能少地写入,以进行性能优化。 例如,日志消息中的常量字符串不会被写出,而是被记录为相对于原始二进制文件的偏移量。 您可以写入日志的数据类型也受到限制:简单的整数和浮点类型、布尔值和字符串。 可以提取此信息以供以后分析,或将其格式化为人类可读的字符串日志消息。
虽然编年史 (Chronicle) 具有类型化的日志,但它在很大程度上没有规定日志消息的“模式”。 特别是,它没有对日志子系统、类别、活动或事件 ID 等内容提供特殊支持。 与您的消息一起写入的唯一元数据是高分辨率时间戳和原始记录器的 ID。 同样,编年史 (Chronicle) 中的记录器只是被命名并且可以选择性地禁用的东西。 在此之上的任何组织都取决于您。 建议并支持通过例如记录类似这样的内容来内联分层您自己的组织
let category: StaticString = "ImageDecoder"
#log(logger, "\(category): Started decode of \(image)")
与 os_log 不同,编年史 (Chronicle) 不使用特殊的编译器优化过程来减少日志记录开销。 相反,它使用 #log
宏和仔细的内联来将代码折叠为对日志缓冲区的直接写入。 由于它是您随应用一起发布的库,因此它除了日志本身的格式之外没有 ABI 问题。
默认情况下,编年史 (Chronicle) 将日志记录到“.chronicle”捆绑包。 在其中是一个目录和两个文件。 第一个文件是 metadata.json,其中包含重建日志所需的数据。 特别是,它包含日志版本、记录器名称、计时信息和一些字符串表信息。 字符串表(目前,是共享缓存之外的图像中 __TEXT,__cstring
的完整转储)本身存储在名为 strings 的目录中,文件名表示该节的加载地址,文件的内容是字符串数据。
实际的日志本身存储在另一个文件中(称为 buffer),它具有以下高级格式
[array of log messages]
[unused space]
[trailer]
日志消息始终从文件开头开始。 如果缓冲区从未完全填满,那么它们将使用缓冲区所需的所有空间。 如果缓冲区已环绕,那么消息仍将从文件开头开始,但最上面的消息不再是按时间顺序排列的第一条消息。 相反,从日志环绕的时间开始的消息可以从文件开头向前读取,遍历正在使用的消息链(见下文)。 更早的消息可以向后读取文件,遍历链表的另一端。 文件最底部的预告片指示到最后一条消息末尾的“偏移距离”,以反向 ULEB-128 字节编码(最低有效字节位于缓冲区的最末尾,第二个最低有效字节位于倒数第二个字节,依此类推)。 此预告片是必要的,因为日志消息可能无法填满整个缓冲区。 文件末尾始终至少有一个预告片字节。“偏移距离”是从最后一条消息的末尾到缓冲区末尾的真实距离,但从中减去 1。 这是因为完整的日志缓冲区仅扩展到文件中倒数第二个字节,因为必须至少有一个预告片字节。 请注意,预告片在较小时编码未使用空间的字节数,但随着预告片变大,它会发生变化以包含更多字节。
0x0000: [log messages]
0x0010: [log messages]
......: ...............
0x0ff0: [log messages]
-Log messages end here-
0x1000: [unused space]
0x1010: [unused space]
......: ...............
--Trailer starts here--
0x1ffe: 0x0f
0x1fff: 0xff
日志消息具有固定的格式,并且背靠背存储,没有填充。 单个消息的一般格式(也以无填充的方式存储)是
UInt8
标头UInt32
负载大小UInt64
时间戳UInt16
记录器 IDUInt8
日志组件计数[UInt8]
组件类型字符串[[UInt8]]
日志组件数据UInt32
消息大小标头是 0 到 5(包括 0 和 5)之间的数字,指示消息的完整程度。 如果上一条消息已完成,那么将保证下一条消息具有可以读取的有效标头(即,在将上一条消息标记为完成之前,会写入下一条消息的标头)。 这些值的含义如下
有效负载大小包括编码时间戳、记录器 ID、日志组件计数、组件类型字符串和日志组件数据的大小。
组件类型字符串的长度由日志组件计数指定。它使用单个字符指定每个日志组件的类型,如下所示:
类型 | 字符 |
---|---|
Bool |
b |
Int8 |
1 |
Int16 |
2 |
Int32 |
4 |
Int64 |
8 |
Int |
i |
UInt8 |
! |
UInt16 |
@ |
UInt32 |
$ |
UInt64 |
* |
UInt |
I |
Float |
f |
Double |
F |
String |
s |
StaticString |
S |
日志组件数据包括每个日志组件的数据,这些数据连接在一起,没有填充。
类型 | 编码 |
---|---|
Bool |
1 字节数据 |
Int8 |
1 字节数据 |
Int16 |
2 字节数据 |
Int32 |
4 字节数据 |
Int64 |
8 字节数据 |
Int |
指针大小的数据 |
UInt8 |
1 字节数据 |
UInt16 |
2 字节数据 |
UInt32 |
4 字节数据 |
UInt64 |
8 字节数据 |
UInt |
指针大小的数据 |
Float |
4 字节数据 |
Double |
8 字节数据 |
String |
指针大小的计数 + 计数字节 |
StaticString |
指针大小的数据 |
消息大小是有效负载大小,加上标头的大小、有效负载计数的大小和消息大小(本身)的大小。 换句话说,它是整个消息的大小,包括它本身。
#log
宏将作为第二个参数提供的字符串插值拆分为“组件”。 字符串的每个字面部分都编码为StaticString
。 插值被编码为单独的组件。 因此,像"Invocation: \(String(cString: getprogname())) [\(CommandLine.argc) arguments]"
这样的字符串被拆分成以下内容
let component1: StaticString = "Invocation: "
let component2: String = String(cString: getprogname())
let component3: StaticString = " ["
let component4: CInt = CommandLine.argc
let component5: Staticstring = " arguments]"
宏本身大致结构如下
let string1...N = /* Literal parts of the log message*/
if logger.enabled {
let component1...N = /* Each component of the log message */
let totalSize = /* Sum up the sizes of each component */
let buffer = logger.prepare(totalSize)
component1...N.log(into: buffer)
logger.complete()
}
这种设计意味着所有的写入和大小调整都在调用点内联发生,因此可以进行最大程度的优化。 实际上,每个组件都直接写入日志缓冲区,没有任何额外的副本。 这包括字符串(如果它们已经在内部以 UTF-8 格式布局)。
Chronicle 尚未准备好用于通用用途。 事实上,它可能永远不会准备好。 它被设计为一个测试,但也支持Ensemble。 它有很多严重的局限性
说真的,不要使用它。 它是为了让你思考你可能从你自己的日志框架中获得什么。