【原文信息】
作者:星际码仔
链接:www.jianshu.com/p/fdec5942d...
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
前言
「日志」对于客户端开发人员来讲,可以算是既熟悉又陌生了,它和代码注释、编程风格一样,本身不会为功能带来任何增益,也通常不会与你的KPI挂钩。但当有线上问题产生而你无从排查的时候,多少曾经感叹过"我当时要是在这里打印一条日志就好了",由此可见打印日志的重要性。
但由于长期缺少一个合理的日志规范,导致滥用、乱用日志打印的现象层出不穷。为了解决这个痛点,今天,我们将以「为什么要打印日志」、「应该怎样打印日志?」以及「什么时候该打日志」三个方面为切入点,来拟定一份适合客户端开发中使用的《日志打印规范》。
WHY:为什么要打印日志?
就客户端开发来讲,常见的需要打印日志的原因不外乎以下几点:
1.验证逻辑执行的对错
与低效的断点调试方式相比,通过在逻辑执行过程中的关键节点输出有效的日志,可以快速地验证相关的参数、流程、结果等是否符合预期,以及时做出相应的代码调整,从而提高开发/测试阶段的调试效率。
2.监控组件运行的状态
当产品的功能依赖于某一项或几项长时间运行的组件或服务时,以日志系统为核心的应用监控体系,能够实时监控其运行状态,以在故障发生时及时预警,并通知相关开发人员处理,避免故障进一步扩大化。
3.还原故障发生的现场
由于终端设备的碎片化问题,以及用户行为的不可预知性,产品功能上线前的测试阶段通常无法覆盖所有的意外情况。
当线上问题发生时,日志是否能提供足够多的信息来还原用户当时的场景和行为,以快速定位问题的原因,减少无谓的争执和甩锅,就显得尤为重要了。
4.记录用户操作的轨迹
以日志数据为画笔,并借助数据化分析工具,可以分析用户习惯和偏好,勾勒出用户画像,从而为用户提供个性化的定制服务,提高产品竞争力。
HOW:应该怎样打印日志?
在讲之前,我们先来理清都有哪些日志级别:
日志级别
按重要性级别从低到高分别是:
· DEBUG(调试):仅在开发期间有用的调试信息。
该级别的日志主要应对以上原因1的场景,主要集中在开发/测试阶段使用,输出的日志内容及形式可以根据开发人员的实际调试需要来调整,灵活度较大,一般会包含参数信息/流程信息/返回值信息等内容。
特别要注意的是,该级别的日志不能被带到生产环境,建议在封装的日志工具类API中加上当前是否是调试模式的判断。
· INFO(信息):常规使用情况的预期日志信息。
该级别的日志主要是应对以上原因2、3、4的场景,应作为默认的输出级别,用于记录具体的业务行为信息,需要有选择地使用,只输出对结果有实际意义的内容,避免日志输出量过大,造成设备存储空间不足。
· WARN(警告):尚未引发严重错误的潜在问题信息。
该级别的日志主要应对以上原因2、3的场景,涉及的通常是可提前预知并且影响范围可控的问题,一般不影响业务流程的正常执行,包括但不限于参数缺失/参数错误/任务超时等情况。对该级别的日志,要求对于问题发生时的上下文信息要尽可能详尽地记录下来,以便事后的日志分析。
· ERROR(错误):已经引发严重错误的问题信息。
该级别的日志主要应对以上原因2、3的场景,涉及的通常是不可预知的并且影响范围较广的异常或错误,可能会导致应用崩溃,或者严重阻塞业务流程正常执行,需要人工及时干预。对该级别的日志,除了要记录问题发生时的上下文信息,还要包括完整的异常堆栈信息,以便快速定位问题发生的地方并及时修复。
下面以常见的登录模块流程来对不同日志级别的使用进行举例:
- 首先假设我们的登录模块包含三种登录方式,分别是验证码登录、密码登录以及第三方平台登录,我们可以为不同的登录方式定义不同的TAG:
arduino
// 父模块-登录
public static final String TAG_LOGIN = "login";
// 子模块-验证码登录
public static final String TAG_LOGIN_IDENTIFYING_CODE = "login_identifying_code";
// 子模块-密码登录
public static final String TAG_LOGIN_PASSWORD = "login_password";
// 子模块-第三方平台登录
public static final String TAG_LOGIN_THIRD_PARTY = "login_third_party";
- 假设用户选择了验证码登录,输入了手机号码之后点击「获取验证码」按钮,此时需要请求获取验证码接口,并将按钮置为不可用,然后开始计时,超过一分钟后才将按钮恢复为可用,允许重新获取验证码。在开始计时之前不允许用户重复点击按钮,以免重复发起请求。
- 为了验证防止重复获取验证码和计时按钮恢复可用的代码逻辑是否生效,我们可以使用验证码登录的TAG,分别打印以下DEBUG级别的日志,验证流程是否如预期执行:
erlang
LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "处于重复点击判断时间区间,返回不处理")
...
LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "开始计时,按钮不可用");
...
LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "当前剩余秒数:" + second);
...
LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "计时结束,按钮恢复可用");
通过以上日志,我们还可以覆盖其他测试场景下的情况,比如应用退到后台之后倒计时是否还能正常执行,以及中断验证码登录切到其他登录方式后倒计时有没有正常结束等仅通过肉眼难以验证的问题。
- 假设用户正常收到了验证码短信,并且输入了验证码后成功登陆,我们需要将过程中的具体的业务行为用INFO级别打印出来,以便后期日志分析时可以还原用户的行为轨迹以及有故障发生时的现场信息:
vbscript
LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "请求获取验证码接口, phone:" + phone);
...
LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "请求获取验证码接口成功, response: " + response.toString());
...
LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "请求验证码登录接口, phone:" + phone + ", identifyingCode: " + identifyingCode);
...
LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "请求验证码登录接口成功, response: " + response.toString());
...
LogUtil.i(TAG_LOGIN, "开始同步用户配置...");
...
LogUtil.i(TAG_LOGIN, "开始同步消息通知...");
...
- 当偶现获取验证码超时的情况,虽然用户可以通过再次点击「获取验证码」按钮来重新获取,但是仍要将该情况记录到WARN级别的日志,并提供当时的上下文信息,以便后期统计出现此情况的频率,从而寻找可以优化的空间。
go
String msg = new StringBuilder("获取验证码接口超时:")
.append("countryCode").append(countryCode)
.append("phone:").append(phone)
.append("time:").append(DateUtil.format(System.currentTimeMillis()))
.append("networkAvailable:").append(NetworkUtil.isNetworkAvailable(getContext()))
.append("networkType:").append(NetworkUtil.getNetworkType(getContext()));
LogUtil.w(TAG_LOGIN_IDENTIFYING_CODE, msg);
- 而当「获取验证码」接口不可用,用户登录流程受阻时,我们则要在接口请求失败回调方法中用ERROR级别打印出接口返回的错误码和错误信息,或者抛出的异常堆栈,以协助快速定位问题的原因并及时修复:
vbscript
@Override
public void onFailure(Response response, IOException e) {
if(response != null) {
LogUtil.e(TAG_LOGIN_IDENTIFYING_CODE, "获取验证码接口请求失败,code: " + response.getCode() + ", msg: " + response.getMsg());
} else {
LogUtil.e(TAG_LOGIN_IDENTIFYING_CODE, "获取验证码接口请求失败", e);
}
}
其他两种情况雷同,这里不再赘述,注意使用正确的TAG就是。接下来,我们就来列举一些具体的日志规范:
日志规范
1.把控日志级别,严禁出现日志信息与日志级别不符的情况
不同的日志级别代表不同的日志重要程度,乱用日志级别将对排查的重要日志信息产生严重干扰。
arduino
// 反例:仅仅出于红色更显眼的原因,用ERROR级别打印调试信息
LogUtil.e(TAG, "Send a Msg")
2.合理使用TAG,以便分析时能快速过滤出指定日志
这里建议的TAG命名方式是按不同模块粒度划分、根据从属关系从大到小进行排列、模块名间以下划线分隔来命名,这样做的结果是可以更细致地查看不同粒度下的模块功能是否正常执行。
但要注意Android旧版本系统对logcat的TAG长度支持最长只有23个字符长度,建议使用合理且易懂的单词缩写,且尽量不超过三个模块层级。
比如以下两个TAG分别代表的是:
- msgserv_ws_keepalive------消息接入服务/WebSocket模块/心跳保活功能
- msgserv_ws_msgqueue------消息接入服务/WebSocket模块/消息队列功能
如果我只想关注心跳保活功能,我可以筛选完整的msgserv_ws_keepalive。而如果我想关注整个WebSocket模块的各项功能是否都运行正常,我可以筛选msgserv_ws。
arduino
// 正例:合理的TAG命名与使用
public static final String TAG = "msgserv_ws_keepalive";
...
LogUtil.i(TAG, "Received a pong frame, do nothing")
// 反例:以开发者名字为TAG的无意义命名
LogUtil.i("zhangsan", "onResume()")
// 反例:出于方便省略TAG
LogUtil.i("onPause()")
3.确保重要信息的完整,避免打印无效的日志
大量无效的日志不仅占用了设备的存储空间,更为获取真正有效的日志增加了干扰度,不利于快速定位和解决问题。为此,在打印日志之前请先思考:打印该日志的目的是什么?该日志是否真的有助于解决问题?
arduino
// 正例:抛出异常时打印异常堆栈信息
LogUtil.e(TAG, "Websocket connection was closed", e)
// 正例:问题发生时输出相关上下文信息
LogUtil.w(TAG, "Request failed with code: " + code);
// 反例:冗余日志------频繁下载进度回调打印干扰正常的日志打印
LogUtil.d(TAG, "Current download progress: " + progress)
// 反例:无效日志------缺少描述失败原因的返回码及描述
LogUtil.w("Download failed")
// 反例:意义不明的日志---为了验证流程而输出无意义的数字
LogUtil.d(TAG, "1")
LogUtil.d(TAG, "2")
LogUtil.d(TAG, "3")
4.力求日志内容描述简洁、清晰,又不影响可读性
scss
// 正例:只取出关心的字段,描述其代表含义
String msg = new StringBuilder()
.append("Request method:").append(request.method()).append("\n")
.append("Request url:").append(request.url()).append("\n")
.append("Request headers:").append(request.headers()).append("\n")
.append("Request body:").append(request.body()).append("\n")
.toString();
LogUtil.d(TAG, msg);
// 反例:直接打印请求实体,没有任何描述
LogUtil.i(TAG, request.toString())
5.用StringBuilder替代字符串拼接以处理参数较多的情形
采用Java语言开发时,使用字符串拼接会产生大量的String对象。当参数较多时,建议使用StringBuilder替代字符串拼接。
less
// 反例:以用字符串拼接输出日志
LogUtil.d(TAG, "Request method:" + request.method() + "\n"
+ "Request url:" + request.url() + "\n"
+ "Request headers:" + request.headers() + "\n"
+ "Request body:" + request.body());
6.包含敏感信息的日志内容需要脱敏,进行加密或不输出
平时打印的日志信息就要注意避免敏感信息的泄漏,如果有持久化到本地的操作要注意对日志内容进行加密。
7.打印Java语言定义的实体类必须重写toString()方法
用Java语言定义的实体类,默认只输出此对象的hashCode值,没有任何参考意义。
**
typescript
// 正例:重写toString()方法,将实体类转换为JSON字符串
@Override
public String toString() {
return JSONUtil.toJSON(this);
}
8.避免因日志系统的引入,为应用增加不稳定性及额外的性能损耗
前面说过,日志本身并不能为功能带来增益,但日志打印毕竟也是编码的一部分,是编码就会有隐藏的稳定性风险和性能损耗,需要开发人员特别注意。最好能支持线上的降级手段,当出现了因日志造成的不良影响时,能停止打印某个级别的日志或直接不再打印日志。
less
// 反例:调用对象方法没有先进行非空判断,有隐藏的空指针异常风险
LogUtil.d(TAG, "Insert a new message:" + message.getId())
9.为日志文件制定合理的缓存时间,定时清理过期日志
建议以FIFO的清除策略,按日期顺序移除过期的日志文件,日志文件的最长缓存时间可根据产品的业务特性(比如是否有定期的周活动/月活动)来制定,可以选择在每次进行读写操作时才去检查日志文件是否过期,也可以专门建立一个后台任务定期检查过期日志文件并删除。
10.禁止直接使用第三方日志框架API,避免产生方案碎片化问题
规范建立之后,具体的日志相关处理可以交由第三方日志框架来实现,但为了保证方案的统一性和可替换性,需要基于外观模式,将打印日志的行为统一封装到作为外观角色的Log工具类,项目中统一使用Log工具类来打印日志。
WHEN:什么时候该打日志?
本文无法囊括所有该打日志的场景,在此只列举一些常见的场景,可根据项目实际需要进行扩展。
1.产品核心业务的执行流程
这个自不必多说,产品核心业务的正常执行与否,决定着产品的最终质量,影响着公司在业界的口碑,并与实际收益挂钩。一方面需要全面的日志系统协助排查隐藏的技术漏洞,另一方面页需要在用户反馈问题时能用日志及时定位并快速给予答复。
2.跨端/跨应用/跨模块等的通信过程
常见的如外部接口请求与响应过程,内容主要包含影响接口请求成功率及数据展示的请求方法/请求头/请求参数/响应码/响应内容等,同理还有不同应用间的数据分享过程以及同一应用不同业务模块间的路由跳转过程等。
3.重要组件的初始启动配置
重要组件的初始启动配置会直接影响到应用的整体表现,我们可以通过打印组件的初始启动配置参数,验证是否存在由参数配置错误导致的异常。
4.长时间运行组件的行为/状态切换
长时间运行的组件受设备内存/电量/网络及用户操作等的影响比较大,需要通过日志实时关注其运行状态,确保其运行正常。
5.多个分支逻辑的判断
典型的情况就是代码中出现了多个条件分支,或者存在多个可供选择的策略类,需要确定是进入了哪个分支或策略,从而验证流程有没有按预期的情况执行。
6.对结果有影响的用户交互
比较有代表意义的就是搜索模块,用户通过输入框搜索/历史搜索词/热门搜索词/热搜榜单/联想词等模块都可以触发搜索行为,为了定位是由哪个模块触发的搜索,就有必要记录具体的用户交互行为。
7.调用大概率可能失败的方法时
当功能的实现依赖于外部条件的成立与否时,调用该功能的实现方法就有大概率可能失败,比如持久化数据需要有足够的存储空间,访问外部接口需要当前网络可用等。调用此类方法时我们通常需要验证外部条件是否成立,并提供条件不成立时的相应处理方式,适当的日志打印可以帮助我们验证流程是否合理且可行。
8.第三方SDK的调用过程
由于对第三方SDK提供方技术的不可把控,我们无法保证引入第三方SDK会不会对应用的稳定性造成影响。
为了避免这种情况,我们需要在对第三方SDK的API调用过程中,打印出第三方SDK提供给我们的信息,以便出现问题时能和第三方SDK提供方及时有效地沟通。
结语
拟定规范只是第一步,如何长期坚持执行才是重点。不过,我们也必须明白,好日志不是一次就能写好的,而是要在实际使用中根据发现的问题不断调整的。建议在以后的项目代码评审过程中加入对日志输出的关注,团队成员一起探讨更好的输出内容及方式,并及时改正不好的打印习惯。