背景
一条工单引发的日志需求
我们的业务收到了一个信息泄漏的工单,漏洞是明文打印日志。
我们当时使用的是某网络库自带的日志系统(以下简称 A 系统),会直接将明文写入到了日志文件当中,这样就有信息泄漏的风险。
我们搜集了当前使用 A 系统的痛点:
- 明文打印,造成隐私泄漏;
- 关键日志丢失;
- 无法方便的打印当前的堆栈,线程等信息;
- 不支持各种格式化输出。
为了解决这个问题,我们设计并实现了 TinyLog。
TinyLog 能支持日志的加密和压缩,减少日志文件大小并确保隐私不会泄漏;能够支持各种格式化输出,且比较方便的打印出当前的堆栈,线程等信息;我们在设计的时候也考虑到了日志丢失的问题,在 crash 的场景,也能够确保当前的 crash 堆栈能够被记录下来;此外我们还提供了图形化展示日志的接口,能够将日志图形化的展示出来,方便开发同学快速的定位到问题。
TinyLog 的总体设计
作为一个日志组件,安全和高效是我们的一个核心需求。安全包括两点,日志不丢失以及隐私不泄漏;高效就是日志组件不能影响应用的性能。我们在设计 TinyLog 的时候也充分考虑到了这两点,以下是 TinyLog 的总体设计图:
客户端加密,服务端解密。服务端提供明文下载以及图形化展示日志的功能。客户端来保证打印日志的高效性,服务端来保证安全性。
1.TinyLog SDK
如何才能支持多种格式化输出,并能保持良好的性能呢?
以下是 TinyLog SDK 的结构图:
我们将 TinyLog SDK 分为三层,分别是输入层,处理层和输出层。其中处理层包含了很多日志的配置项,能够满足各种场景的格式化输出;另外,在输出层,我们将加密,压缩以及文件写入的功能下沉到 c++,保证了良好的性能。
输入层:
输入层为对外提供的接口,主要提供了初始化方法以及日志打印的方法。我们参考了系统打印日志的接口,并在其基础上进行扩充,降低业务方的接入成本。以下为 TinyLog 和系统日志打印的接口对比,以 info 等级为例:
系统原有的两个日志打印的接口,我们将其扩充为4个,并在其基础上进行了增强。如系统接口 i(String tag,String msg),第二个参数只能是字符串对象,而我们将其增强为 i(String tag,T obj),第二个参数可以是任何对象,我们会匹配对应的格式化方法,将其进行格式化处理。比如第二个参数是 json 对象,我们就会使用 JsonFormatter 将 json 对象进行格式化,若输入的对象在 TinyLog 内部未匹配到任何 Formatter ,则会直接调用该对象的 toString 方法。
处理层:
在这一层,日志会被分发给 Logger,每个 Logger 都包含一个 LogConfig(配置信息)和 Printer(输出), Logger 会根据 LogConfig 将日志封装成为一个日志对象,然后传递给 Printer 输出。LogConfig 的配置信息包括日志等级过滤,是否打印线程,是否打印边框等,以及各种格式化方法。
我们将格式化抽象成了一个 Formatter 接口,并默认实现了各种场景的格式化方法,具体如下表格所示,业务方也可根据需要自己实现 Formatter 接口。
格式化 | 备注 |
---|---|
DefaultBorderFormatter | 边框格式化 |
DefaultJsonArrayFormatter | Json数组格式化 |
DefaultJsonFormatter | Json对象格式化 |
DefaultStackTraceFormatter | 堆栈格式化 |
DefaultThreadFormatter | 线程格式化 |
DefaultThrowableFormatter | 异常信息格式化 |
DefaultXmlFormatter | xml格式化 |
输出层:
我们将输出抽象成了 Printer 接口,TinyLog 默认实现了 ConsolePrinter 和 FilePrinter,分别将日志输出到控制台和文件当中。若输出到文件,则可以将日志上传到服务端。业务方也可以自定义 Printer,将日志输出到任何地方。
让日志的输出更加友好
TinyLog 能够很轻松的打印当前的线程信息,堆栈信息以及常见的对象,并默认实现了各种格式化方法,包括边框,json,线程,堆栈等。
例如,以下是打印一个 json 对象,分别使用系统 Log 和 TinyLog 的对比:
防止日志丢失
如何防止日志丢失是一个非常重要的问题,我们最初使用 A 系统的时候,经常有同事抱怨关键信息丢失。因此在设计 TinyLog 的时候,我们也考虑到了这个问题。
(1)使用 mmap
我们分析了 A 系统,发现其日志打印的流程如下图左边所示:
这样的结构就会有日志丢失的风险,因为使用了内存缓存,一旦发生 crash,缓存当中的日志就丢失了。
怎样才能防止日志丢失呢?最好的办法就是打印每条日志的时候立刻写进入文件,可是这样就会频繁的触发 IO,存在性能的问题。经过调研之后,我们使用 mmap 的方式,既能够防止日志丢失,又能保证性能,如下图右边所示。
为什么使用 mmap 性能又好,还不会造成数据丢失呢?以下是标准 IO 和 mmap 在写场景的对比图,标准 IO 下,当我们需要将写入的数据从用户空间拷贝到内核空间,然后系统会定期的将数据写入磁盘;而使用 mmap,用户空间和内核空间通过映射同一个普通文件实现内存共享,我们在用户空间内写入的数据相当于直接写入到了内核空间,减少了一次内存拷贝,性能得到提升。而且一旦数据写入到了内核空间,此时 app 即使发生了 crash 也不会造成数据的丢失,仍然能够由系统保证写入到磁盘上。
以下是我做的一个对比实验,把50 byte 的日志分别写入内存,mmap和文件 10w 次,以下是实验结果:
机器 | 写入内存 | 写入mmap | 写入文件 |
---|---|---|---|
三星(s20) | 16ms | 32ms | 815ms |
iphone 6s | 4.9ms | 4.3ms | 987ms |
可以看出,mmap 在性能上接近于内存。
(2)异常捕获
Android 版本 sdk 我们在 java 层做了全局的异常捕获,当 TinyLog 监测到异常发生时,会立刻将当前的异常信息堆栈写入到日志当中,当日志写完之后才释放异常。以此确保当 crash 发生时,能够将 crash 的堆栈信息保留下来。
ios 版本 sdk 需要依赖 bugly 或其他崩溃采集组件,在其 crash 回调函数中调用 TinyLog 接口写入 crash 堆栈。
安全兼顾性能
加密算法通常分为对称加密和非对称加密,以下为各自的特点:
分类 | 特点 | 速度 |
---|---|---|
对称加密 | 加密和解密的密钥是一样的 | 快 |
非对称加密 | 公钥加密,私钥解密 | 慢 |
对于日志 sdk 这个场景,若选用对称加密,则必须要将密钥放到 sdk 当中,才能够完成日志文件的加密,这样会有密钥泄漏的风险;
若选用非对称加密,公钥存入 sdk,私钥存在服务端,能够保证安全,但是性能不太好。
因此我们综合了一下,使用了混合加密的方法,既能保证安全又能兼顾性能,具体的流程如下图所示:
- 客户端和服务端先要约定一套公钥和私钥;
- 当客户端创建日志块时,随机生成一串字符串 key,此时使用公钥加密随机生成的 key 得到 key’,将 key’ 记录到日志文件当中,然后 key 当作对称加密的密钥来加密日志;
- 服务端在解密时,需要先将 key’ 从日志文件当中读出,然后使用私钥解密出 key,再使用对称加密的方式解密日志得到明文。
虽然非对称加密方式比较慢,但在混合加密的场景只有在创建日志块的时候才会使用一次,而真正加密日志的方法使用的是性能较好的对称加密,每个日志块的密钥均为随机生成,而真正记录在日志文件当中的密钥为加密过后的密文。因此在性能上接近对称加密,而安全性又接近于非对称加密。
以下为加密 1w 条 100 byte 日志的对比实验,使用的是三星 s20:
分类 | 时间 |
---|---|
非对称加密 | 1070ms |
对称加密 | 33ms |
混合加密 | 50ms |
图形化显示日志接口
如何让日志更加清晰和直观的展示?
目前我们团队的开发同学使用 fancylog 的插件来查看日志,fancylog 是一个能够可视化显示日志的插件,其原理是根据配置好的正则表达式规则,将给定的日志文件图形化的显示出来,能够极大的提高查看日志的效率。
既然是通过正则表达式来匹配,那我们何不将一些通用的方法抽象出来形成接口,然后为这些接口提供对应的正则表达式,这样只要使用这个接口打印出来的日志,就能够被 fancylog 解析出来,而无需自己配置了。
以下为 TinyLog 当中提供的图形化显示日志的接口:
类别 | 接口 | 备注 |
---|---|---|
场景 | loadScene(String sceneName) | 加载场景 |
loadSceneSuccess(String sceneName) | 加载成功 | |
loadSceneFailed(String sceneName) | 加载失败 | |
操作 | switchFront(String msg) | 切前台 |
switchBack(String msg) | 切后台 | |
click(String msg) | 点击事件 | |
异常 | exception(String msg,Exception exception) | 出现异常 |
crash(String msg,Exception exception) | crash | |
过程 | processStart(String msg) | 开始 |
processing(String msg) | 进行中 | |
processEnd(String msg) | 结束 | |
其他 | event(String event) | 自定义event |
当我们的日志上传到日志网站之后就能够自动的图形化展示了,如下图所示
非侵入式的打印日志
有同事提出,目前 TinyLog 所提供的图形化展示接口,是一种侵入式的结构化日志实现方式,业务方需要在代码中显示的调用 fancylog,才能够实现日志的结构化展示。既然这样我们何不使用注解的方式,实现无侵入式的打印日志呢。
因此,我们设计并实现了 FancyLogger 组件,我们自定义了一些日志的注解,并针对被注解标记的方法增加切面,当对应的方法执行完成时,便触发日志打印的逻辑。
以下为 FancyLogger 当中包含的注解:
注解 | 备注 |
---|---|
ActivityCycle | Activity生命周期,类注解 |
FragmentCycle | Fragment生命周期,类注解 |
Click | 点击事件,方法注解 |
Crash | crash,方法注解 |
Event | 自定义事件,方法注解 |
ExceptionLog | 异常,方法注解 |
LoadScene | 加载场景,方法注解 |
ProcessLog | 过程方法注解 |
Switch | 切前后台,方法注解 |
FancyLogger 当中提供了两种注解,分别是方法注解和类注解,其中类注解为 ActivityCycle 和 FragmentCycle 能够自动的打印 Activity 和 Fragment 的生命周期。
以下分别为方法注解和类注解的示例。
方法注解
如下所示,在 testEvent 方法上面添加 Click 注解
@Click(msg = "testEvent")
public void testEvent(){
Log.i("测试","执行了testEvent");
}
当我们执行 testEvent 方法时,会有如下打印,其中第一条日志为执行 testEvent 方法内部的日志,第二条日志为 FancyLogger 打印的日志。
类注解
如下所示,在 MainActivity 类上增加 ActivityCycle 注解
@ActivityCycle(tag="MainActivity")
public class MainActivity extends AppCompatActivity {
...
}
当 Activity 执行了对应的生命周期的方法时,会有如下日志打印:
TinyLog 的性能测试
以下为瞬间写入 10w 条 50byte 日志信息在各个不同维度的数据,其中内存,cpu峰值均使用 PerfDog 进行测试。
时间
机器 | 不压缩不加密 | 压缩加密 |
---|---|---|
三星s20 | 269.2ms | 276.6ms |
iphone11 | 604.9ms | 815.8ms |
日志文件大小
机器 | 不压缩不加密 | 压缩加密 |
---|---|---|
三星s20 | 12.3MB | 851KB |
iphone11 | 10.3MB | 904KB |
内存占用
以下场景使用 TinyLog demo 进行测试。
三星s20
场景 | 不压缩不加密 | 压缩加密 |
---|---|---|
打开应用 | 60MB | 63MB |
初始化 TinyLog | 63MB | 66MB |
写入10w条日志峰值 | 83MB | 91MB |
写入10w条之后的内存 | 67MB | 70MB |
iphone11
场景 | 不压缩不加密 | 压缩加密 |
---|---|---|
打开应用 | 38MB | 38MB |
初始化 TinyLog | 38.2MB | 38.3MB |
写入10w条日志峰值 | 38.7MB | 39.8MB |
写入10w条之后的内存 | 38.6MB | 39MB |
cpu 峰值
机器 | 不压缩不加密 | 压缩加密 |
---|---|---|
三星s20 | 18% | 18% |
iphone11 | 10% | 10% |
2.TinyLog 服务端
日志多维度搜索
TinyLog 上传日志时候,支持自定义 tag。例如,用户反馈的场景可以增加 type 为 user,crash 场景可以增加 type 为 crash。我们除了可以按照日志上传时间进行搜索之外,还可以根据自定义的 tag 进行搜索。这样我们就可以根据不同的 tag 更加精准的定位到日志。
一键图形化展示日志
日志上传到服务端之后,点击在线查看,便可以将日志图形化展示出来。如下图所示,我们通过右边的图形就可以快速的定位到问题。点击右边红色的菱形模块,就能够定位到左边日志。
3.本地解压解密工具
如果我们遇到一些特殊场景,不方便上传日志,TinyLog 提供了本地解密工具,我们只需要将日志导出,用本地解密工具进行解密。本地解密工具只是将大部分解密的步骤放在本地,核心的非对称解密步骤仍然放在服务端,不会造成密钥的泄漏。