免责声明:本文为私人早期存货,内容营养无质保,请选择性阅读 peace 👐
前言
开发过程中日志的重要性不言而喻,它帮助开发者了解代码执行状态梳理业务流程,通过输出的日志能发现隐患或排查问题。移动端开发过程中可通过 IDE 的控制台来输出、查看日志,也就是所谓的在线调试。在线调试适合追踪可稳定复现的问题,但对于随机性或有严格边界条件的问题却无能为力。
一方面大多数不可稳定复现的问题多发生在用户侧,用户侧的问题不可能有在线调试的机会(即便有成本也会相对较大)。另一方面移动端出于性能、安全等因素的考虑会在线上版本关闭日志输出,由于缺失日志导致在用户侧出现问题时开发往往只能根据现象去猜测问题原因,有点开盲盒的感觉。靠猜测解决问题严重影响工作效率,已成为本团队开发中的一大阻碍,相信其它团队也会有类似的困扰。
其本质原因是开发者对用户侧的问题没有有效日志获取手段,能像开发阶段一样获取用户侧的全量日志来分析问题将是本篇所讨论的重点。
关于写日志
在开发过程中输出有效的日志是一个开发者的基本素养,如同不写注释的开发不是好程序员一样,不输出有效日志的程序员也不是一个合格的开发者。
完善的日志能帮助开发快递定位问题,一个有水平的开发往往都有良好注释习惯,但好开发对于日志的输出却是有会所顾虑因为他潜意识里会考虑到日志对性能的影响、会不会有安全方面的隐患,基于此大多数开发实际是不太愿意打印日志的,当然也会有开发考虑不到性能和安全问题,这另当别论。所以在实际项目中大概率会出现的四种情况:
- 日志与注释基本没有整个项目处在裸奔状态
- 有少量注释但日志随处可见
- 有注释,基本无日志,这种情况有可能是开发洁癖在上线前删掉了日志代码,
- 日志注释相互交错没有规律,何时打日志何时写注释是随心所欲。
日志 == 注释 ?
在开发过程即要写注释又要写日志,他们在功能上似乎有重叠的部分,在实际工作中似乎难以区分什么时候去写日志,什么时侯去写注释。有开发可能会把日志当成注释在用,疯狂写日志来方便日后出问题可以快速分析、排查。
在此需要明确的是:日志决不等同于注释。
- 日志要表达的是代码执行的过程,能通过日志还原代码执行状态
- 注释要表达的是开发思路&意图,能通过注释快速理解代码
日志与注释是两个维度的东西,不可混为一谈。对于代码阅读者能通过注释快速了解整个系统的结构,阅读者能以上帝视角审视整个系统的运行流程,分析局部时再通过日志了解代码执行细节,审视每个流程的走向,异常逻辑分支的处理。注释过多日志太少就无法了解代码动态执行过程,日志过多注释缺失会对阅读&理解代码造成困扰,无关紧要的日志过多会掩盖关键细节。 日志与注释结合才能实现 1 + 1 > 2 的效果,把注释与日志的价值同时最大化。关于日志与注释有一点跑题,扯远了,回到写日志本身。
如何写有效的日志
关于写日志的一些个人总结,不一定对欢迎探讨:
- 流程开始&结束时
- 检查边界条件不满足时
- 二选一的逻辑分支选其一输出日志
- 多选一的逻辑分支输出判断结果
- 异常逻辑处理时
- 内部状态发生变化时
- 避免在遍历或循环中打印日
- IO 操作失败时
- 调用三方库时的输入与输出
- 决定流程流向的关键变量
- 耗时操作的时长打印
- 主动进行线程切换时
整体方案对比
我们的目的是在有需要时拿到用户端的日志来帮忙分析问题,达到这个目的似乎很简单。只需要将日志通过接口在合适的时机上传到服务端供开发者查阅即可。将日志上传有两个选择:
方案一
在产线上开启日志输出,将日志写入内存缓存。后端提供日志上传接口,可实时按条或批量上传日志。后端做日志清洗、聚合,开发有需要时去查日志即可。
方案二
在产线上开启日志输出,将日志写入日志文件中,设计一套机制在需要时读取日志文件上传服务端存储,然后交由开发下载查阅。
论证
方案一正是埋点事件统计类工具的思路。由于对实时性要求较高并且数据量相对较小,对埋点这类场景方案一是可行的。但套用到日志系统上就会有问题,我们需要全量日志来分析问题,代码中全量日志数据量会非常多,全部上传后端会占用很多存储资源。并且大多情况下上传的日志是无用数据,只有出问题极少数用户的日志才开发人员关心的。并且在内存中缓存的日志在遇到异常闪退时缓存数据会丢失,在上传及后端处等环节也不可能排除数据丢失的问题。
方案二似乎可行,将日志写入文件而文件系统相对写入内存更加可靠,通过下发指令的方式来上传日志文件可以做到按需使用并且日志不会在服务端处理环节丢失,缺点在于写文件明显没有写内存速度快,日志时效性不如实时上报。
方案一存在致命缺点:丢日志,方案二在时效性和性能上又有所折扣。实时性的日志不是必需,因为从用户发现问题到开发收到反馈是会有相当长时延,实时日志没有实际意义,只需要拿到出问题那一刻之前一段时间完整日志即可。这里的完整非常重要,如此方案二可以做全量日志系统基本方向。方案二两个重点,一是将日志存储到闪存(持久化)、二是在合适的时机将日志上传。
持久化方案探索
不同于开发阶段日志工具,在线日志系统需要考虑更加全面。用户设备的碎片化、操作高度随机,网络环境之复杂都对日志系统提出更高的要求。这里最关键的是日志的持久化性能,从业务代码中接收日志字符到向存入闪存的过程中不应存在性能瓶颈。而这个过程中往往会穿插一些耗时操作,格式化、压缩、加密、持久化等等。
对低端机而言持久化日志不应对性能产生影响,换而言之不能因为某一场景日志较多而造成界面卡顿。闪退时用户端产生的最后一条日志不能丢失,否则不能精确还原闪退原因。开发有可能把账号、鉴权或其它敏感信息写入日志,所以日志明文不可泄露。日志文件不应占用过多的存储空间,代码级的日志量是非常大,随着时间的积累日志占用的存储也不可忽视。
基于以上这些考量,需要对在线日志系统的持久化提出一些要求:高性能、高可靠、高安全、低存储占用。
高性能
是的,一个能让开发放手打日志的系统首要考虑的是 「性能」。
无论是 Android 还是 iOS,用系统函数写下一条日志代码其背后的逻辑都是将字符写入到日志系统中(在线调试时会同步输出到 IDE)。写日志本质上是一个写文件的过程,写文件也就意味着磁盘 I/O。当日志量不大时,用写文件的方式持久化日志是可行的,但当日志量较大时,较多的磁盘 I/O 将严重影响性能。写日志的 I/O 操作可能阻塞业务 I/O 操作从而对业务产生影响。
每一次写文件操作都会有两次内存数据的拷贝过程。1、从用户态拷贝到内核态 2、从内核态到闪存。数据从内核态拷贝到磁盘这个过程的时机是不可控且从内核态到闪存会涉及到内核态与用户态的频繁切换。除此之外对于智能手机 NAND 存储还会存在写入放大的问题。
写入放大:由于闪存在写入新数据前必须先擦除原始数据(全新的闪存都是擦除状态),写入以页(page, 4KB)为单位,擦除以块(block, 128 个 page)为单位。当一个 block 写满了,就要找一个新的 block 来继续写。当所有空闲 block 用完了或快用完时,就要对那些已被标记删除的 block 进行回收操作,把尚存数据不多的脏block擦除干净,重新利用。而要擦除一个block,必须先把其尚存的有用数据 page 移走,重写到其他block里面去,这就需要额外的写入操作。
在 Andrioid 平台上高频的日志写入也会产生大量的 GC(Flutter 同理),这些都将对性能产生负面影响,严重时会影响用户正常操作。
直接写文件会有性能问题,那能不能去直接写内存同时保证异常退出时数据不丢失呢?Memory Map 刚好可以做到!
mmap
mmap 是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。如此使用 mmap 写日志相当于直接把数据写入内存,相对于写文件其佣有无可比拟的速度优势。
可靠性
移动端 App 可能会因为各种异常情况发生闪退,理想中的情况是:当 App 发生闪退,异常捕获模块捕获到异常,然后调用日志模块把日志输出到日志文件。然而现实的情况是 App 被系统杀掉的情况非常多,不是每次被杀掉都有事件产生。Android 这种既允许后台运行又保留随时杀掉 App 权利的平台这种情况更为严重,这些不能被捕获的异常往往又是我们关注的重点,如果不能完整的记录日志那分析问题无从谈起。
所以一个好的日志系统应该具有非常高的可靠性,在任何极端情况都不应丢失日志、保证日志的完整性。如此在发生问题是才可能精准还原用户端运行时状态。
同样使用 mmap 可以解决可靠性的问题, mmap 相当于在用户空间开辟一块专属虚拟内存,让这块内存和文件印射起来。只需要往内存中写入数据,操作系统负责在合适的时机把内存数据同步到闪存文件。mmap 同步内存到闪存文件时机有:
- 调用
msync
函数主动进行数据同步(主动) - 调用
munmap
函数对文件进行解除映射关系时(主动) - 进程退出时(被动)
- 系统关机时(被动)
由于内存数据回写到闪存由操作系统控制,App 即便发生异常闪退也能保证数据不会丢失,同时保证了与写内存相同的速度。
安全性
开发过程不可避免的需要打印一些敏感信息的日志来辅助问题分析,将包含敏感信息的日志持久化后那如何保证敏感信息的安全性是不得不考虑的问题。
我们可以通过加密来解决安全的问题。但选择哪种加密方式才是最佳选择呢?对称加密,性能好但密钥的保存是个问题,密钥保存在客户端,一旦泄露加密将失去意义。非对称加密相对于对称加密性能较差,公钥放在客户端,用公钥加密日志后需要用私钥解密,不泄露私钥安全性会大大提高。毫无疑问,需要选择对非称加密做为加密手段。
使用非对称加密后加密时机又存在问题,上传服务端前统一加密, 保存在磁盘上的未加密日志还是有泄露的风险并且文件级的加密操作使 CPU 负载出现峰值对性能产生影响。单条日志加密后写入日志似乎是一个可行的方案,加密量小可以平滑 CPU 曲线避免峰值产生。
压缩率
与加密一样,是整体压缩还是单行压缩看起来是个选择题。文件整体压缩的压缩率能达到最大但若压缩后的数据在任意位置发生损坏则日志将无法还原,且存在 CPU 峰值对性能产生影响。单行压缩,文本过短会严重影响压缩率,但单行压缩量小,有利于平滑 CPU 负载曲线,任意行损坏不影响其它日志数据。
单行压缩与整体压缩都无法满足要求,那可不可分块压缩?把需要压缩的字符串分成固定大小的段,累积到指定大小时再进行压缩。只需要找个一个合适的块大小使得压缩率和 CPU 负载都达到一个可接受的水平。这个方法是可行的,只不过还是可以先进行单行压缩,压缩后的数据再分块进行进二次压缩可以进一步提高压缩率。即便发生数据损坏也只是影响某一个数据块,其它数据块仍能还原成日志明文。
持久化方案最终形态
前面提到性能、可靠、加密、压缩等问题的解决方案,在移动端单独实现每个方案都是一个不小的挑战。在造轮子之前先看看有没有现成的轮子可用,毕竟我们是要解决问题而不是为了造一个轮子。
xlog
通过调研发现微信开源了跨平台组件集 Mars, 包含很多实用小组件,其中最核心最基础的日志组件 「xlog」。xlog 是基于 mmap 用 C++ 开发的跨平台高性能日志组件,完美的解决了性能、可靠、加密、压缩等问题。实际上上面提到的解决方案正是 xlog 所用到的。依赖微信海量用户长时间的打磨,xlog 验证了其在 iOS/Android 上的稳定性。关于 xlog 的原理说明可以参考链接:高性能日志模块xlog
xlog 做到了开箱即用,但因其为跨平台组件,不能在业务代码里直接使用 xlog 接口来打印日志。需要平台做好业务层封装,并需要兼容接入 xlog 后在 IDE 控制台日志输出。考虑到日志输出的扩展性,平台应该按如下结构封装日志接口:
多端兼容
WebView
我们的目标是设计一个全量日志系统,所以仅仅只是收集原生平台日志还远远不够。需要将日志收集范围扩展到 Webview & Futter。将收集到的日志统一通过 xlog 收集并持久化。
对于 Webview 可以通过向每个 Webview 对象注入 Javascript 代码的方式 Hook 掉 console 对象的方法实现。在 Hook 方法里先把日志发送到原生日志接口层,原生再将日志异步发送到 xlog 进行加密、压缩、持久化。
Flutter
对于 Flutter 就稍微复杂一点,Flutter 端目前不支持 mmap (Dart 库函数限制),所以在 Flutter 端不存在和 xlog 类似的解决方案,只能借助原生的能力来实现 Flutter 端高性能高可靠的日志持久化。前端同学都知道 Flutter 与原生进行通信都是通过 Channel 的方式进行,但 Channel 因存在跨端数据编/解码操作导致通信效率偏低,如果用 Channel 进行日志收集势必会影响低端机流畅性。好在 Dart 提供一套调用 C/C++ 语言接口的解决方案:FFI, FFI 在 Flutter 2.0 之后的版本达到了生产可用。
FFI (Foreign Function Interface) 是用来与其它语言交互的接口,在有些语言里面称为语言绑定(language bindings),Java 里面一般称为 JNI(Java Native Interface) 或 JNA(Java Native Access)。由于现实中很多程序是由不同编程语言写的,必然会涉及到跨语言调用,比如 A 语言写的函数如果想在 B 语言里面调用,这时一般有两种解决方案:一种是将函数做成一个服务,通过进程间通信(IPC)或网络协议通信(RPC, RESTful等);另一种就是直接通过 FFI 调用。前者需要至少两个独立的进程才能实现,而后者直接将其它语言的接口内嵌到本语言中,所以调用效率比前者高。
Dart 通过 FFI 调用 xlog 提供的 C 接口比 Channel 的方法调用拥有更高的效率,这个方案在抹平了 Dart 与 C/C++ 言语鸿沟的同时可以充分利分 xlog 提供的优势,避免重复造轮子、提升整个方案的开发效率与稳定性。
解决了 xlog 在 Flutter 侧的调用之后,Flutter 侧的日志收集也应该采用原生平台类似结构进行封装,最终依赖结构如下
Flutter FFI 集成 xlog
实操
参考 mars 的官方指引进行静态库(iOS)、so (Android)编译。直接运行 build_ios.py/build_android.py 脚本即可。iOS 示例:
shell
cd ~/Donwload/mars/
python build_ios.py
接下来选择「Clean && build xlog.」 就可完成 iOS 静态库编译。但是要在 Flutter FFI 使用还有几个问题需要解决。
根据文档 在 iOS 中使用 dart:ffi 调用本地代码 FFI 只能与 C 函数绑定,也就是说 Dart 只能直接调用 C 函数。Dart 侧的类型只会转成 C 的基本类型(int char * stract 等),但 xlog 内部使用 C++ 对象实现配置,FFI 无法转换 C++ 对象。因此首先要解决的问题便是 C 基础类型与 C++ 对象之间的转换。
好在 XLogConfig 对象内部都是基础类型, 解决方案是在 C++ 函数上再包装一层 C 函数接口曝露给 FFI 使用。最直接的办法是把 XLogConfig 内的属性都拆到 C 函数参数内,但这样参数会过长,不太优雅。
最终我选择将 XLogConfig 结构体内的 sdt::string 类型替换换为 char * 类型,由 Dart 直接申请内存构造 XLogConfig 结体实例,然后 xlog 内使用原 XLogConfig 内 C++ string 对象的位置用 std::string(config->logdir) 进行还原。
1、替换为 C 的基本类型
2、在原结构体属性引用地方还原为 C++ 对象
3、包装 C 函数 (需要注意这里的 config 结构体是 Dart 侧 calloc 出来的,你需要在 Dart 侧动手 free 它)
4、Dart 侧调用(Dart 侧 FFI 调用代码使用 ffigen 生成)
你需要注意
Dart 类型转 C 类型
Dart 侧可使用 toNativeUtf8 函数将 Dart String 类型转换为 C char* 类型以供 C 侧访问,但这里需要注意内存释放问题,看注释!
只看最后一句话,返回的指针指向由 allocator 额外分配的内存。
因此在使用完 toNativeUti8 函数后你需要注意他的释放问题。这里 Dart/C 双侧都可以释放此块内存,谁负责释放呢?我的建议是「谁创建,谁释放」,意思是由 Dart 侧申请的内存,Dart 侧负责释放超出作用域的内存 。如遇到异步的 C 函数,C 函数内部再复制一份自行进行内存管理。
xlog日志限制
xlog 默认有单条 16K 大小的限制,16K刚好为一个页存页,mmap 在进行内存申请时能减少内存碎片。你也可以修改源码来改变这个限制,但我仍然建议你在上层应用做好日志截断,将大日志拆分为多条日志写入。
性能对比
日志上传方案
通过一定手段获取在用户设备存储的日志,获取包含两层意思:
- 主动上传
- 被动上传
被动上传
是指通过后台向指定用户发送上传指令,指定用户接收到后再通过接口上传日志文件。由于日志文件往往较大,被动上传需要用户具有较高的使用频次否则时效生难以保证。
日志被动上传的关键是接收上传指含,也就是说开发者可根据自定义策略决哪些设备需要上传指定时间范围的日志。这里我们可以根据现有的离线包或 App 灰度策略来指定设备或用户进行上传。
指令的接收用推拉结合的方式,利用 App 的推送能力发送静默通知,App 收到静默通知后会被唤醒,解析通知携带的上报字符命令后再通过接口去查询当前设备是否需要上传日志以及上传的具体信息。整流程如下图所示:
被动上传最核心的点在于查询上报信息,上报信息需要带上此上报的唯一标识,此标识用于在管理后台标记此次上传任务的状态。之所以需要任务状态是考虑到上报过程是一个长异步过程会存在中断的可能性所以需要一个唯一标记告诉管理后台任务成功的状态,如果没有成功,下次启动去查询时会返回相同的结果。根据此标记 App 端还可以恢复上次未完成的上传任务。
主动上报
能解决时效性的问题,在发生可预见的异常或指定等级的异常发生后可以触发一次主动上传日志文件,在用户反馈问题后可以直接拿到日志而不再需要下发指令等待上传。
主动上传是指根据实际需要决定是否需要将当前日志进行打包上传。需要主动上传的场景有:
- App 崩溃
- 可以预见的严重业务错误
- 严格的边界条件被触发
- 未被捕获的异常
如果能穷举出所有可能出问题的场景,便可以在收到任何反馈后第一时间拿到日志,但在实际项目中我们不可能穷举出所有场景,那些不能被举列出的场景就可以用被动上报来覆盖,在实际项目中被动上报的使用频次会占大多数。这里需要注意的是开发者切不可图省事,不区分等级一股脑的将所有错误/异常都上传,这样不仅会造成资源浪费还会导致严重错误因信息太多被忽视。主动上传的流程相对于被动上传要简单得多,整体流程如下:
通过上面的流程发现由于不需要同步任务状态所以整个流程比较简单,App 端做好文件分片及继传后将结果上报给管理后台,后台管理系统只需收集不同用户、不同设备日志下载地址。
总结
以上便是移动端全量日志系的整体设计思路,整体的设计思路解释的相对比较细。日志作为一个基础工具以往长期被开发忽视,仅仅是会用远远不够还需要用好。充分发挥日志工具作为基础组件的职能,日志的重要性不仅仅只在开发中体现,更重要的是服务产品的全生命周期。
后记
由于这篇水文所涉内容实践、成文、发布,时间跨度比较大,本想开源基于 Dart FFI 桥接 xlog 源码但时至今日(2023-12-10零时)所涉技术已有过期的嫌疑就先不贴代码地址了。
如果你仍然非常需要本文涉及到的 Dart FFI 桥接 xlog 相关代码,可以在评论区留言告知我,呼声较高的话我整理一下代码给大家开箱即用的版本。
最后 love&peace 🖖🏻