由于终端设备的碎片化问题,以及用户行为的不可预知性,产品功能上线前的测试阶段通常无法覆盖所有的意外情况。
当线上问题发生时,日志是否能提供足够多的信息来还原用户当时的场景和行为,以快速定位问题的原因,减少无谓的争执和甩锅,就显得尤为重要了。
主要集中在开发/测试阶段使用,输出的日志内容及形式可以根据开发人员的实际调试需要来调整,灵活度较大,一般会包含参数信息/流程信息/返回值信息等内容。
特别要注意的是,该级别的日志不能被带到生产环境,建议在封装的日志工具类API中加上当前是否是调试模式的判断。
用于记录具体的业务行为信息,需要有选择地使用,只输出对结果有实际意义的内容,避免 日志输出量过大,造成设备存储空间不足。一般作为默认的输出级别。
涉及的通常是可提前预知并且影响范围可控的问题,一般不影响业务流程的正常执行,包括 但不限于参数缺失/参数错误/任务超时等情况。对该级别的日志,要求对于问题发生时的上下 文信息要尽可能详尽地记录下来,以便事后的日志分析。
涉及的通常是不可预知的并且影响范围较广的异常或错误,可能会导致应用崩溃,或者严重 阻塞业务流程正常执行,需要人工及时干预。对该级别的日志,除了要记录问题发生时的上 下文信息,还要包括完整的异常堆栈信息,以便快速定位问题发生的地方并及时修复。
// 父模块-登录
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级别的日志,验证流程是否如预期执行:
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, "计时结束,按钮恢复可用");
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, "开始同步消息通知...");
...
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级别打印出接口返回的错误码和错误信息,或者抛出的异常堆栈,以协助快速定位问题的原因并及时修复:
@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);
}
}
不同的日志级别代表不同的日志重要程度,乱用日志级别将对排查的重要日志信息产生严重干扰。
// 反例:仅仅出于红色更显眼的原因,用ERROR级别打印调试信息
LogUtil.e(TAG, "Send a Msg")
这里建议的TAG命名方式是按不同模块粒度划分、根据从属关系从大到小进行排列、模块名间以下划线分隔来命名,这样做的结果是可以更细致地查看不同粒度下的模块功能是否正常执行。
但要注意Android旧版本系统对logcat的TAG长度支持最长只有23个字符长度,建议使用合理且易懂的单词缩写,且尽量不超过三个模块层级。
比如以下两个TAG分别代表的是:
如果我只想关注心跳保活功能,我可以筛选完整的msgserv_ws_keepalive。而如果我想关注整个WebSocket模块的各项功能是否都运行正常,我可以筛选msgserv_ws。
// 正例:合理的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()")
大量无效的日志不仅占用了设备的存储空间,更为获取真正有效的日志增加了干扰度,不利于快速定位和解决问题。为此,在打印日志之前请先思考:打印该日志的目的是什么?该日志是否真的有助于解决问题?
// 正例:抛出异常时打印异常堆栈信息
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")
// 正例:只取出关心的字段,描述其代表含义
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())
采用Java语言开发时,使用字符串拼接会产生大量的String对象。当参数较多时,建议使用StringBuilder替代字符串拼接。
// 反例:以用字符串拼接输出日志
LogUtil.d(TAG, "Request method:" + request.method() + "\n"
+ "Request url:" + request.url() + "\n"
+ "Request headers:" + request.headers() + "\n"
+ "Request body:" + request.body());
平时打印的日志信息就要注意避免敏感信息的泄漏,如果有持久化到本地的操作要注意对日志内容进行加密。
// 正例:重写toString()方法,将实体类转换为JSON字符串
@Override
public String toString() {
return JSONUtil.toJSON(this);
}
前面说过,日志本身并不能为功能带来增益,但日志打印毕竟也是编码的一部分,是编码就会有隐藏的稳定性风险和性能损耗,需要开发人员特别注意。最好能支持线上的降级手段,当出现了因日志造成的不良影响时,能停止打印某个级别的日志或直接不再打印日志。
// 反例:调用对象方法没有先进行非空判断,有隐藏的空指针异常风险
LogUtil.d(TAG, "Insert a new message:" + message.getId())
建议以FIFO的清除策略,按日期顺序移除过期的日志文件,日志文件的最长缓存时间可根据产品的业务特性(比如是否有定期的周活动/月活动)来制定,可以选择在每次进行读写操作时才去检查日志文件是否过期,也可以专门建立一个后台任务定期检查过期日志文件并删除。
规范建立之后,具体的日志相关处理可以交由第三方日志框架来实现,但为了保证方案的统一性和可替换性,需要基于外观模式,将打印日志的行为统一封装到作为外观角色的Log工具类,项目中统一使用Log工具类来打印日志。
这个自不必多说,产品核心业务的正常执行与否,决定着产品的最终质量,影响着公司在业界的口碑,并与实际收益挂钩。一方面需要全面的日志系统协助排查隐藏的技术漏洞,另一方面页需要在用户反馈问题时能用日志及时定位并快速给予答复。
常见的如外部接口请求与响应过程,内容主要包含影响接口请求成功率及数据展示的请求方法/请求头/请求参数/响应码/响应内容等,同理还有不同应用间的数据分享过程以及同一应用不同业务模块间的路由跳转过程等。
重要组件的初始启动配置会直接影响到应用的整体表现,我们可以通过打印组件的初始启动配置参数,验证是否存在由参数配置错误导致的异常。
长时间运行的组件受设备内存/电量/网络及用户操作等的影响比较大,需要通过日志实时关注其运行状态,确保其运行正常。
典型的情况就是代码中出现了多个条件分支,或者存在多个可供选择的策略类,需要确定是进入了哪个分支或策略,从而验证流程有没有按预期的情况执行。
比较有代表意义的就是搜索模块,用户通过输入框搜索/历史搜索词/热门搜索词/热搜榜单/联想词等模块都可以触发搜索行为,为了定位是由哪个模块触发的搜索,就有必要记录具体的用户交互行为。
当功能的实现依赖于外部条件的成立与否时,调用该功能的实现方法就有大概率可能失败,比如持久化数据需要有足够的存储空间,访问外部接口需要当前网络可用等。调用此类方法时我们通常需要验证外部条件是否成立,并提供条件不成立时的相应处理方式,适当的日志打印可以帮助我们验证流程是否合理且可行。
由于对第三方SDK提供方技术的不可把控,我们无法保证引入第三方SDK会不会对应用的稳定性造成影响。
为了避免这种情况,我们需要在对第三方SDK的API调用过程中,打印出第三方SDK提供给我们的信息,以便出现问题时能和第三方SDK提供方及时有效地沟通。