异常与日志
在计算机世界里, 在程序异常时, 发生了意料之外的事件, 阻止了程序的正常执行, 这种情况被称为程序异常, 处理程序异常, 需要处理以下3中问题:
- 1).哪里发生异常?
- 2).谁来处理异常?
- 3).如何处理异常?
首先, 需要明确在哪里发生异常, 在代码中通过try-catch来发现异常, 但是有些程序员往往将大段代码定义在一个try-catch块, 这样非常不利于定位问题, 是一种不负责任的做法
捕获异常时需要分清稳定代码和非稳定代码, 稳定代码指的是无论如何都不会出错的代码, 如int a=0; 异常捕获是针对非稳定代码的, 捕获时要区分异常类型并做相应的处理, 比如, 当用户输入了错误的用户名, 提示用户账号错误, 正确的用户名下, 错误的密码请重试, 重试次数超出限制, 则封锁账号等
throw是作用在方法内部抛出具体异常类对象的关键字, throws作用在方法上
如果异常在当前方法的处理能力范围之内且没有必要对外透出, 那么久直接捕获异常并做相应处理, 负责向上抛出, 由上层方法或框架来处理
(一), 异常分类
JDK中定义了一套完整的异常机制, 所有异常都是Throwable的子类, 分为Error(致命异常)和Exception(非致命异常), Error是一种非常特殊的异常类型, 它的出现表示着系统发生了不可控的错误, Exception又分为checked异常(受检异常)和unchecked异常(非受检异常)
checked异常是需要在代码中显示处理的异常, 否则会编译出错, 如果能自行处理则可以在当前方法中捕获异常, 如果无法处理, 则继续想调用方抛出异常对象, 常见的checked异常包括JDK中定义的SQLException, ClassNotFoundException等, checked异常可以进一步细分为两类:
- 无能为力, 引发注意型, 针对此类异常, 程序无法处理, 如字段超长等导致的SQLException,即使做再多的重试对解决异常也没有任何帮助, 一般处理此类异常的做法是完整的保存异常现场, 供开发工程师解决
- 力所能及, 坦然处置型, 如发生未授权异常(UnAuthorizedException), 程序可跳转至 权限申请页面
在Exception中, unchecked异常是运行时异常, 它们都继承自RuntmeException, 不需要程序进行显示的捕捉和处理, unchecked异常可以进一步细分为3类:
-
可预测异常(Predicted Exception), 常见的可预测异常包括IndexOutOfBoundsException,NullPointerException等, 基于对代码的性能和稳定性要求, 此类异常不应该被产生或者抛出, 而应该提前做好边界检查,空指针判断等处理, 显示的声明或者捕获此类异常会对程序的可读性和运行效率产生很大影响
-
需捕获异常(Caution Exception),例如在使用Dubbo框架进行RPC调用时产生的远程服务器超DubboTimeoutException,此类异常是客户端必须显示处理的异常, 不能因服务器端的异常导致客户端不可用, 此时处理方案可以是重试或者降级处理等
-
可透出异常(Ignred Exception), 主要是指框架或系统产生的且会自行处理的异常, 而程序无须关心,例如针对Spring框架中抛出的NoSuchRequestHandlingMethodException异常,Spring框架会自己完成异常的处理, 默认将自身抛出的异常自动映射到合适的状态码, 比如启动防护机制跳转到404页面
异常分类结构如图:
(二), try代码块
try-catch-finally是处理程序异常的三部曲, 当存在try时, 可以只有catch代码块, 也可以只有finally代码块, 就是不能只有try, 下面分别描述一下各个代码块的作用:
- 1) try代码块: 监视代码执行过程, 一旦发现异常则直接跳转至catch, 如果没有catch, 则直接跳转至finally
- 2) catch代码块: 可选执行的代码块, 如果没有任何异常发生则不会执行, 如果发现异常则进行处理或向上抛出,这一切都在catch代码块中执行
- 3) finally代码块: 必选执行的代码块, 不管是否有异常产生, 即使发生OutOfMemoryError也会执行,通常用于处理善后清理工作, 如果finally代码块没有执行, 那么有三种可能:
❶没有进入try块
❷进入try块,但是代码运行中出现了死循环或死锁状态
❸进入try块, 但是执行了System.exit()操作
注意: finally是在return表达式运行后执行的, 此时将要return的结果已经被暂存起来, 待finally代码块执行结束后再将之前暂存的结果返回
finally代码块的职责不在于对变量进行赋值等操作, 而是清理资源, 释放连接, 关闭管道流等操作, finally代码块中使用return语句, 使返回值的判断变得复杂, 所以避免返回值不可控, 我们不要在finally代码块中使用return语句
try代码块与锁的关系, lock方法可能会抛出unchecked异常, 如果放在try代码块中, 必然触发finally中的unlock方法执行, 对未加锁的对象解锁会抛出unchecked异常, 如IllegalMonitorStateException, 虽然是因为加锁失败而造成程序中断的, 但是真正加锁失败的原因可能会被后者覆盖, 所以try代码块之前调用lock()方法, 避免由于加锁失败导致finally调用unlock()抛出异常, 警示代码的注释下面的代码应该移到try代码块的上方, 示列:
Lock lock = new XxxLock();
preDo();
try{
//无论加锁是否成功, unlock都会执行
lock.lock();
doSomething();
}finally{
lock.unlock();
}
Lock, ThreadLocal, InputStream等这些需要进行强制释放和清除的对象都得在finally代码块中进行显示的清理, 避免产生内存泄漏, 或者资源消耗
(三), 异常的抛与接
我们要使捕获的异常与被抛出的异常是完全匹配的, 或者捕获的异常是被抛出异常的父类
传递异常信息的方式是通过抛出异常对象, 还是把异常信息转成信号量封装在特定对象中, 这需要方法提供者和方法调用者之间达成契约, 只有大家都照章办事了, 才不会产出误解, 推荐对外提供的开放接口使用错误码, 公司内部跨应用远程服务调用, 优先考虑使用Result对象来封装错误码, 而应用内部则推荐直接抛出异常对象
为什么在远程服务调用中推荐使用Result对象封装异常信息?
答: 如果使用抛异常的返回方式, 一旦调用方没有捕获, 就会产生运行时错误, 导致程序中断, 此外, 如果抛出的异常中不添加栈信息, 只是new自定义异常并加入自定义的错误信息, 对于调用端解决问题的帮助不会太大, 如果加了栈信息, 在频繁调用出错的情况下, 信息序列化和传输的性能损耗也是问题
我们都知道空指针异常(NPE)是程序世界里最常见的异常之一, 为了避免出现NPE, 应该是提供方需要明确可以返回null值, 调用方进行非空判断, 还是服务方保证返回类似于Optional, 空对象或者空集合?
答: 推荐方法的返回值可以为null, 不强制返回空集合或者空对象等, 但是必须添加注释充分说明什么情况下会返回null值, 防止NPE一定是调用方的责任, 需要调用方进行事先判断
日志
记录应用系统日志主要有三个原因: 记录操作轨迹, 监控系统运行状况, 回溯系统故障
(四), 日志规范
日志是有级别的, 针对不同的场景, 日志被分为五种不同的级别, 按照重要程度有低到高排序:
- DEBUG级别日志记录对调试程序有帮助的信息
- INFO级别日志用来记录程序运行现场, 虽然此处并未发生错误, 但是对排查其他错误具有指导意义
- WARN级别日志也可以用来记录程序运行现场, 但是更偏向于表明此处有出现潜在错误的可能
- ERROR级别日志表明当前程序运行发生了错误, 需要被关注, 但是当前发生的错误, 没有影响系统的继续运行
- FATAL级别日志表明当前程序运行出现了严重的错误事件, 并且将会导致应用程序中断
在打印日志时针对不同的日志级别要有不同的处理方式
1) 预先判断日志级别
对DEBUG, INFO级别的日志, 必须使用条件输出或者使用占位符的方式打印, 该约定总和考虑了程序的运行效率和日志打印需求
2) 避免无效日志打印
生产环境禁止输出DEBUG日志且有选择的输出INFO日志
使用INFO, WARN级别来记录 业务行为信息时, 一定要控制日志输出量, 以免磁盘空间不足, 同时要为日志文件设置合理的生命周期, 及时清理过期的日志
避免重复打印, 务必在日志配置文件中设置additivity=false, 示列:
<logger name="com.taobao.ecrm.member.config" additivity="false">
3) 区别对待错误日志
WARN, ERROR都是与错误有关的日志级别, 但不要一发生错误就笼统的输出ERROR级别日志, 一些业务异常是可以通过引导重试就能恢复正常的, 例如用户输入参数错误
4) 保证记录内容完整
日志记录的内容包括现场上下文信息与异常堆栈信息, 所以打印时需要注意以下两点:
❶记录异常时一定要输出异常堆栈, 例如logger.error(“xxx”+e.getMessae(),e)
❷日志中如果输出对象实列, 要确保实列类重写了toString()方法, 否则只会输出对象的hashCode值, 没有实际意义
(五), 日志框架
日志框架分为三大部分, 包括日志门面, 日志适配器, 日志库等, 利用门面设计模式, 即Facade来进行解耦, 使日志使用变得更加简单
日志结构框架:
1).日志门面
门面设计模式是面向对象设计模式中的一种, 日志框架采用的就是这种模式, 类似JDBC的设计理念
最为广泛的日志门面有两种:slf4j和commons-logging
2).日志库
主流的日志库有三个,分别是log4j, log-jdk, logback
3).日志适配器
日志适配器分两种场景:
❶工程里面想使用slf4j+log4j的模式, 就额外需要一个适配器(slf4j-log4j12)来解决接口不兼容的问题
❷老工程改日志, 需要一个适配器来完成从旧日志库的API到slf4j的路由, 这样在不改动原有代码的情况下也能使用slf4j来统一管理日志, 而且后续自由替换具体日志库也不成问题