2024年3月

你好呀,我是歪歪。

踩坑了啊,最近踩了一个 lombok 的坑,有点意思,给你分享一波。

我之前写过一个公共的服务接口,这个接口已经有好几个系统对接并稳定运行了很长一段时间了,长到这个接口都已经交接给别的同事一年多了。

因为是基础服务嘛,相对稳定,所以交出去之后他也一直没有动过这部分代码。

但是有一天有新服务要对接这个接口,同事反馈说遇到一个诡异的问题,这个新服务调用的时候,接口里面报了一个空指针异常。

根据日志来看,那一行代码大概是这样的:

//为了脱敏我用field1、2、3来代替了
if(reqDto.getField1() 
    && reqDto.getField2()!=null
    && reqDto.getField3()!=null){
        //满足条件则执行对应业务逻辑
    }

reqDto 是接口入参对象,有好多字段。具体到 field1、2、3 大概是这样的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto { 
    private Boolean field1 = true;
    private String field2;
    private String field3;
}

所以看到这一行抛出了空指针异常,我直接就给出了一个结论:首先排除 field1 为 null,因为有默认值。那只可能 reqDto 传进来的就是 null,导致在 get 字段的时候出现了空指针异常。

但是很不幸,这个结论一秒就被推翻了。

因为 reqDto 是请求入参,在方法入口处选了几个关键字段进行打印。

如果 reqDto 是 null 的话,那么日志打印的时候就会先抛出空指针异常了。

然后我又开始怀疑是部署的代码版本和我们看的版本不一致,可能并不是这一行报错。

和测试同学确认之后,也排除了这个方向。

盯着报错的那一行代码又看了几秒,排除所有不可能之后,我又下了一个结论:调用的时候,传递进来的 field1 主动设值为了 null。

也就是说调用方有这样的代码:

ReqDto reqDto = new ReqDto();
reqDto.setField1(null);

我知道,这样的代码看起来很傻,但是确实只剩下这一种可能了。

于是我去看了调用方构建参数的写法,准备吐槽一波为什么要写设置为 null 这样的坑爹代码。

然而,当时我就被打脸了,调用方的代码是这样的:

ReqDto reqDto = ReqDto.builder()
        .field2("why")
        .field3("max")
        .build();

用的是 builder 模式构建的对象,并不是直接 new 出来的对象。

我一眼看着这个代码也没有发现毛病,虽然没有对 Boolean 类型的 field1 进行设值,但是我有默认值啊。

问调用方为什么不设值,对方的回答也是一句话:我看你有默认值,我本来也是想传 true,但是一看你的默认值就是 true,所以就没有给值了。

对啊,这逻辑无懈可击啊,难道......

是 builder 在里面搞事情了?

于是我里面写了一个代码进行了验证:

好你个浓眉大眼的 @Builder,果然是你在搞事情。

问题现象基本上就算是定位到了,用 @Builder 注解的时候,丢失默认值了。

所以拿着 “@Builder 默认值” 这样的关键词一搜:

立马就能找到这样的一个注解:@Builder.Default

对应到我的案例应该是这样的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto { 
    @Builder.Default
    private Boolean field1 = true;
    private String field2;
    private String field3;
}

这样,再次运行 Demo 就会发现有默认值了:

同时我们从两个写法生成的 class 文件中也可以看出一些端倪。

没有@Builder.Default 注解的时候,class 文件中 ReqDtoBuilder 类中关于 field1 字段是这样的:

但是有 @Builder.Default 注解的时候,是这样的:

明显是不同的处理方式。

反正,网上一搜索,加上 @Builder.Default 注解,问题就算是解决了。

但是紧接着我想到了另外一个问题:为什么?

为什么我明明给了默认值,@Builder 不使用,非得给再显示的标记一下呢?

于是我带着这个问题在网上冲了一大圈,不说没有找到权威的回答了,甚至没有找到来自“民间”的回答。

所以我也只能个人猜测一下,我觉得可能是 Lombok 觉得这样的赋默认值的写法是 Java 语言的规范:

private Boolean field1 = true;

规范我 Lombok 肯定遵守,但是我怎么知道你这个字段有没有默认值呢?

我肯定是有手段去检查的,但是我必须要每个字段都盲目的去瞅一眼,这个方案对我不友好啊。

这样,我给使用者定一个规范:你给我打个标,主动告诉我那些字段是有默认值的。对于打了标的字段,我才去解析对应的默认值,否则我就不管了。

如果你直接 new 对象,那是 Java 的规范,我管不了。

但是如果你使用 Builder 模式,你就得遵守我的规范。不然出了问题也别赖我,谁叫你不准守我的规范。

打个标,就是 @Builder.Default。

必须要强调的是,这个观点是歪师傅纯粹的个人想法,不保真。如果你有其他的看法也可以提出来一起交流,学习一波。


吃个瓜

虽然我没有找到关于 @Builder.Default 注解存在的意义的官方说明,但是我在 github 上找到了这个一个链接:

https://github.com/projectlombok/lombok/issues/1347

里面的讨论的问题和我们这个注解有点关系,而且我认为这是一个非常明确的 bug,但是官方却当做 feature 给处理了。

简单的一起吃个瓜。

2017 年 3 月 29 日的时候,一个老哥抛出了一个问题。

首先我们看一下提出问题的老哥给的代码:

就上面这个代码,如果我们这样去创建对象:

MyClass myClass = new MyClass();

按照 Java 规范来说,我们附了默认值的,调用 myClass.getEntitlements() 方法返回的肯定是一个空集合嘛。

但是,这个老哥说当 new MyClass 对象的时候,这个字段变成了 null:

他就觉得很奇怪,于是抛出了这个问题。

然后另外有人立马补充了一下。说不仅是 list/set/map,任何其他 non-primitive 类型都会出现这个问题:

啥意思呢,拿我们前面的案例来说就是,你用 1.16.16 这个版本,不加 @Builder.Default 注解,运行结果是符合预期的:

但是加上 @Builder.Default 注解,运行结果会变成这样:

build 倒是正确了,但是 new 对象的时候,你把默认值直接给干没了。

看到这个运行结果的第一个感觉是很奇怪,第二个感觉是这肯定是 lombok 的 BUG。

问题抛出来之后,紧接着就有老哥来讨论了:

这个哥们直接喊话官方:造孽啊,这么大个 BUG 还有没有人管啦?

同时他还抛出了一个观点:老实说,为字段生成默认值的最直观方法就是从字段初始化中获取值,而不是需要额外的 Builder.Default 注解来标记。

这个观点,和我前面的想法倒是不谋而合。但是还是那句话:一切解释权归官方所有,你要用,就得遵守我制定的规范。

那么到底是改了啥导致产生了这么一个奇怪的 BUG 呢?

注意 omega09 这个老哥的发言的后半句:field it will be initialized twice.

initialized twice,初始化两次,哪来的两次?

我们把目光放到这里来:

@NoArgsConstructor,这是个啥东西?

这不就是让 lombok 给我们搞一个无参构造函数吗?

搞无参构造函数的时候,不是得针对有默认值的字段,进行一波默认值的初始化吗?

这个算一次了。

前面我们分析了 @Builder.Default 也要对有默认值的字段初始化一次。

所以是 twice,而且这两次干得都是同一个活。

开发者一看,这不行啊,得优化啊。

于是把 @NoArgsConstructor 的初始化延迟到了 @Builder.Default 里面去,让两次合并为一次了。

这样一看,用 Builder 模式的时候确实没问题了,但是用 new 的时候,默认值就没了。

这是一种经典的顾头不顾尾的解决问题的方式。

作者可能也没想到,大家在使用的时候会把 @Builder 和 @NoArgsConstructor 两个注解放在一起用。

作者可能还觉得委屈呢:这明明就是两种不同的对象构建方式啊,二选一就行了,你要放在一起?哎哟,你干嘛~

接着一个叫做 davidje13 的老哥接过了话茬,顺着 omega09 老哥的话往下说,他除了解释两个注解放在一起使用的场景外,还提到了一个词:least-surprise。

least-surprise,是一个软件设计方面的词汇,翻译过来就是最小惊吓原则。

简单来说就是我们的程序所表现出的行为,应该尽量满足在其领域内具有一致性、显而易见、可预测、遵循惯例。

比如我们认为的惯例是 new 对象的时候,如果有默认值会附上默认值。

结果你这个就搞没了,就不遵循惯例了。

当然,你还是可以拿出那句万金油的话:一切解释权归官方所有,你要用,就得遵守我制定的规范。我的规范就是不让你们混用。

这就是纯纯的耍无赖了,相当于是做了一个违背祖宗的决定。

然而这个问题似乎并没有官方人员参与讨论,直到这个时候,2018 年 3 月 27 日:

rspiller 就是官方人员,他说:我们正在调查此事。

此时,距离这个问题提出的时间已经过去了一年。

我是比较吃惊的,因为我认为这是一个比较严重的 BUG 了,程序员在使用的时候会遇到一些就类似于我认为这个字段一定是有默认值的,但是实际上却变成了 null 这种莫名其妙的问题。

在官方人员介入之后,这个问题再次活跃起来。

一位 xak2000 老哥也发表了自己的看法,并艾特了官方人员:

他的观点我是非常认同的,给你翻译一波。

他说,导致这个问题的原因是为了消除可能出现的重复初始化。但实际上,与修改 POJO 字段的默认初始化这种完全出乎意料的行为相比,重复初始化的问题要小得多。

当然,解决这个问题的最佳方法是以某种方式摆脱双重初始化,同时又不破坏字段初始化器。

但如果这不可能,或者太难,或者时间太长,那么,就让重复初始化发生吧!

然后把“重复初始化”写到 @Builder.Default javadocs 中,大不了再给这几个字加个粗。

如果有人确实写了一些字段初始化比较复杂的程序,这可能会导致一些问题,但比起该初始化却没有初始化带来的问题要少得多。

在当前的这个情况下,当突然抛出一个空指针异常的时候,我真的很蒙蔽啊。

当然了,也有人提出了不一样的看法:

这个哥们的核心思路刚刚相反,就是呼吁大家不要把 @Builder 和 @NoArgsConstructor 混着用。

从“点赞数”你也能看出来,大家都不喜欢这个方案。

而这个 BUG 是在 2018 年 7 月 26 日,1.18.2 版本中才最终解决的:

https://projectlombok.org/changelog

此时,距离这个问题提出,已经过去了一年又四个月。

值得注意的是,在官方的描述里面,用的是 FEATURE 而不是 BUGFIX。

个中差异,你可以自己去品一品。

但是现在 Lombok 都已经发展到 1.18.32 版本了,1.16.x 版本应该没有人会去使用了。

所以,大家大概率是不会踩到这个坑的。

我觉得这个事情,了解“坑”具体是啥不重要,而是稍微走进一下开源项目维护者的内心世界。

开源不易,有时候真的就挺崩溃的。


编译时注解

既然聊到 Lombok 了,顺便也简单聊聊它的工作原理。

Lombok 的核心工作原理就是编译时注解,这个你知道吧?

不知道其实也很正常,因为我们写业务代码的时候很少自定义编译时注解,顶天了搞个运行时注解就差不多了。

其实我了解的也不算深入,只是大概知道它的工作原理是什么样的,对于源码没有深入研究。

但是我可以给你分享一下两个需要注意的地方和可以去哪里了解这个玩意。

以 Lombok 的日志相关的注解为例。

首先第一个需要注意的地方是这里:

log 相关注解的源码位于这个部分,可以看到很奇怪啊,这些文件是以 SCL.lombok 结尾的,这是什么玩意?

这是 lombok 的小心思,其实这些都是 class 文件,但是为了避免污染用户项目,它做了特殊处理。

所以你打开这类文件的时候选择以 class 文件的形式打开就行了,就可以看到里面的具体内容。

比如你可以看看这个文件:

lombok.core.handlers.LoggingFramework

你会发现你们就像是枚举似的,写了很多日志的实现:

这个里面把每个注解需要生成的 log 都硬编码好了。正是因为这样,Lombok 才知道你用什么日志注解,应该给你生成什么样的 log。

比如 log4j 是这样的:

private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);

而 SLF4J 是这样的:

private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);

第二个需要注意的地方是找到入口:

这些 class 文件加载的入口在于这个地方,是基于 Java 的 SPI 机制:

AnnotationProcessorHider 这个类里面有两行静态内部类,我们看其中一个, AnnotationProcessor ,它是继承自 AbstractProcessor 抽象类:

javax.annotation.processing.AbstractProcessor

这个抽象类,就是入口中的入口,核心中的核心。

在这个入口里面,初始化了一个类加载器,叫做 ShadowClassLoader:

它干的事儿就是加载那些被标记为 SCL.lombok 的 class 文件。

然后我是怎么知道 Lombok 是基于编译时注解的呢?

其实这玩意在我看过的两本书里面都有写,有点模糊的印象,写文章的时候我又翻出来读了一遍。

首先是《深入理解 Java 虚拟机(第三版)》的第四部分程序编译与代码优化的第 10 章:前端编译与优化一节。

里面专门有一小节,说插入式注解的:

Lombok 的主要工作地盘,就在 javac 编译的过程中。

在书中的 361 页,提到了编译过程的几个阶段。

从 Java 代码的总体结构来看,编译过程大致可以分为一个准备过程和三个处理过程:

  • 1.准备过程:初始化插入式注解处理器。
  • 2.解析与填充符号表过程,包括:
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    • 填充符号表。产生符号地址和符号信息。
  • 3.插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
  • 4.分析与字节码生成过程,包括:
    • 标注检查。对语法的静态信息进行检查。
    • 数据流及控制流分析。对程序动态运行过程进行检查。
    • 解语法糖。将简化代码编写的语法糖还原为原有的形式。(java中的语法糖包括泛型、变长参数、自动装拆箱、遍历循环foreach等,JVM运行时并不支持这些语法,所以在编译阶段需要还原。)
    • 字节码生成。将前面各个步骤所生成的信息转换成字节码。

如果说 javac 编译的过程就是 Lombok 的工作地盘,那么其中的“插入式注解处理器的注解处理过程”就是它的工位了。

书中也提到了 Lombok 的工作原理:

第二本书是《深入理解 JVM 字节码》,在它的第 8 章,也详细的描述了插件化注解的处理原理,其中也提到了 Lombok:

最后画了一个示意图,是这样的:

如果你看懂了书中的前面的十几页的描述,那么看这个图就会比较清晰了。

总之,Lombok 的核心原理就是在编译期对于 class 文件的魔改,帮你生成了很多代码。

如果你有兴趣深入了解它的原理的话,可以去看看我前面提到的这两本书,里面都有手把手的实践开发。

我就不写了,一个原因是因为确实门槛较高,写出来生涩难懂,对我们日常业务开发帮助也不大。

另外一个原因那不是因为我懒嘛。

前言

去年又重新刷了路遥的《平凡的世界》,最近也在朋友推荐下,来看了路遥的另一部成名作《人生》。
故事中的主人公高加林,虽生在农村,面朝黄土背朝天,却不甘心像父辈一样或者,一心想着摆脱民语的束缚,追求他的理想生活。
然而命运多舛,在他所想象的理想生活中,一次次跌倒,最终不得不承认自己的平凡,生活总得继续。
现实世界如此,代码世界里,我们也希望一切都是理想的。
我们希望用户输入的数据格式永远是正确的,打开的资源也一定存在,用户的硬件是正常的,用户的操作系统四稳定的,用户的网络也一定是畅通的等等
还敢抛异常
然而事与愿违,愿望是好的,现实是残酷的。
引入异常机制的目的就在于,当“人生”中出现异常时,能够将其捕获并处理,保证我们能更好的走下去,不至于出现一点插曲,就停滞不前。

一、异常引入

因此呢就出现了异常处理,我们把可能出现异常的业务代码放到try块中定义,把异常处理放到catch块中进行处理,保证程序遇到异常依然能继续运行,保证程序的健壮性。

① 我们来看看高加林的一生中各种异常的处理

try{
	//业务逻辑,高加林的一生
	System.out.println("1、高中毕业,虽然没考上大学。却不断学习,在报纸上发表诗歌和散文,参加考试,谋得一份临时教师职位");
	System.out.println("2、高加林的叔叔从外地回到家乡工作,在县城担任劳动局局长,副局长为了讨好新上任的局长;");
	System.out.println("便私下给高加林走了后门,就这样高加林成为了公职人员");
	System.out.println("3、与高中同学黄亚萍相遇,再次相遇的两个人,志趣相投,相聊甚欢。高加林以为攀上黄亚萍这个高枝,能到大城市一展宏图");
}catch(ExceptionClass1 e1){
	System.out.println("第一个异常发生,教师职位被他人顶替");
	System.out.println("没有了工作,重新回到农村,成为最不想当的农民");
	System.out.println("很快加入村里的劳动队伍里,白天努力劳作,晚上看书学习,等待着东山再起的机会");
}catch(ExceptionClass2 e2){
	System.out.println("第二个异常发生,遭人举报走后门");
	System.out.println("再次丢了工作,一直想摆脱农名身份的他,再次成为了农民");
}catch(ExceptionClass3 e3){
	System.out.println("第三个异常发生,纸包不住火,黄亚萍及其家人知道了高加林遭遇举报");
	System.out.println("被打回原型,和黄亚萍断绝了关系");
	System.out.println("黄亚萍不想高加林回农村,希望高加林去找叔父帮忙,看是否可以继续留下来");
}catch(Exception e){
	System.out.println("再次跌倒的他,没再去找叔父");
	System.out.println("有了清醒的认知,自己的路还是得靠自己走下去");
}finally{
	System.out.println("接受现实,更好的走下去");
}

② 代码中

//未处理异常,程序遇到异常没法继续执行
public class ExceptionTest {
    public static void main(String[] args) {
        int num1 =7;
        int num2 =0;
        int res = num1/num2;
        System.out.println("程序继续执行......");
    }
}
//输出
Exception in thread "main" java.lang.ArithmeticException: / by zero
	at ExceptionTest.main(ExceptionTest.java:5)

加入异常处理

//加入异常处理,程序遇到异常后继续运行
public class ExceptionTest {
    public static void main(String[] args) {
        int num1 =7;
        int num2 =0;
        try {
            int res = num1/num2;
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
        System.out.println("程序继续执行......");
    }
}
//输出
/ by zero
程序继续执行......

小结:
如果执行try块里的业务逻辑代码出现异常,系统会自动生成一个异常对象,该异常对象被体骄傲给Java运行时环境,这个过程成为抛出异常
Java运行时环境收到异常对象时,会寻找能处理异常对象的catch块,如果找到合适的catch块,则把该异常对象交给该catch块处理,这个过程被成为异常捕获;
如果Java运行环境找不到捕获异常的catch块,则运行时环境终止,Java程序已将退出

二、基本概念

程序执行中发生的不正常情况(语法错误逻辑错误不是异常)称为异常,所有的异常都继承与
java.lang.Throwable

Throwable
有两个重要子类

  • Error(错误):Java虚拟机无法解决的严重问题,
    我们没办法通过 catch 来进行捕获
    。如:内存溢出(
    OutOfMemoryError
    )、Java 虚拟机运行错误(
    Virtual MachineError
    )、类定义错误(
    NoClassDefFoundError
    )等。error 是严重错误,Java虚拟机会选择线程终止
  • Exception(异常):编程错误或偶尔的外在因素导致的一般问题,程序本身可以处理的异常,可以通过
    catch
    来进行捕获。
    Exception
    又可以分
    运行时异常
    (程序运行时发生的异常)和
    编译时异常
    (程序编译期间产生的异常,必须要处理的异常,否则代码没法编译通过)。

在这里插入图片描述

三、异常继承体系


:虚线为实现,实线为继承
在这里插入图片描述

四、常见运行异常

4.1 NullPointerException 空指针异常

在这里插入图片描述

public class ExceptionTest {
    public static void main(String[] args) {
       String str = null;
        System.out.println(str.length());
    }
}
//输出
Exception in thread "main" java.lang.NullPointerException
	at ExceptionTest.main(ExceptionTest.java:4)

4.2 ArithmeticException 数学运算异常

在这里插入图片描述

public class ExceptionTest {
    public static void main(String[] args) {
      int num1=7;
      int num2=0;
      int res = num1/num2;
    }
}
//输出
Exception in thread "main" java.lang.ArithmeticException: / by zero
	at ExceptionTest.main(ExceptionTest.java:5)

4.3 ArrayIndexOutOfBoundsException 数组下标越界异常

在这里插入图片描述

public class ExceptionTest {
    public static void main(String[] args) {
      String strarr[] ={"个人博客","www.xiezhrspace.cn","公众号","XiezhrSpace"};
        //注意数组下标时从0开始的
        for (int i = 0; i <= strarr.length; i++) {
            System.out.println(strarr[i]);
        }
    }
}
//输出
个人博客
www.xiezhrspace.cn
公众号
XiezhrSpace
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 4
	at ExceptionTest.main(ExceptionTest.java:5)

4.4 ClassCastException 类型转换异常

在这里插入图片描述

public class ExceptionTest {
    public static void main(String[] args) {
        Class1 class1 = new Class2();  //向上转型
        Class2 class2 = (Class2)class1; //向下转型
        Class3 class3= (Class3)class1;  //两个类没有关系,转型失败
    }
}

class Class1{}

class Class2 extends Class1{};
class Class3 extends Class1{};

//输出
Exception in thread "main" java.lang.ClassCastException: Class2 cannot be cast to Class3
	at ExceptionTest.main(ExceptionTest.java:5)

4.5 NumberFormatException 数字格式不正确异常

在这里插入图片描述

public class ExceptionTest {
    public static void main(String[] args) {
      String str1 ="123";
      String str2 = "abc";
        System.out.println(Integer.valueOf(str1));   //可以转成功
        System.out.println(Integer.valueOf(str2));
    }
}
//输出
123
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
	at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
	at java.lang.Integer.parseInt(Integer.java:580)
	at java.lang.Integer.valueOf(Integer.java:766)
	at ExceptionTest.main(ExceptionTest.java:6)

五、常见编译时异常

编译期间就必须处理的异常,否则代码不能通过编译
在这里插入图片描述

5.1 FileNotFoundException 操作一个不存在的文件时候发生异常

在这里插入图片描述

5.2 ClassNotFoundException 加载类,类不存在时异常

在这里插入图片描述

5.3 SQLException 操作数据库,查询表时发生异常

在这里插入图片描述

5.4 IllegalArgumentException 参数不匹配时发生异常

在这里插入图片描述

六、异常处理

Java 的异常处理通过 5 个关键字来实现:
try

catch

throw

throws

finally

try catch
语句用于捕获并自行处理异常
finally
语句用于在任何情况下(除特殊情况外)都必须执行的代码
throw
手动生成异常对象
throws
将发生的异常抛出,交给调用者处理,最顶级的处理者是
JVM

6.1 异常处理方式

  • try-catch-finally :在代码中捕获异常自行处理
  • throws :将发生的异常抛出,交给调用者处理,最顶级处理者是
    JVM

注:
try-catch-finally 和throws 任选一种即可

6.2 异常处理

6.2.1 try-catch-finally

①原理图
在这里插入图片描述
②语法结构

try {
    逻辑程序块  //可能有异常的代码
} catch(Exception e) {
	/*
	①发生异常时,系统将异常封装成Exception对象e,并传递给catch
	②得到异常Exception e 后,程序员自行处理
	注:只有发生异常时候,catch代码块才执行
    */
    捕获异常
    throw(e);  
} finally {
    释放资源代码块
}

③实例
小提示:选中代码,按下快捷键ctrl+alt+t 可以呼出代码提示

public class TestException {
    public static void main(String[] args) {
        int num1 =10;
        int num2 =0;

        try {
            int num3=num1/num2;
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println("finally代码块被执行了");
        }

        System.out.println("程序继续执行...");
    }
}
//输出
java.lang.ArithmeticException: / by zero
	at com.xiezhr.TestException.main(TestException.java:9)
finally代码块被执行了
程序继续执行...

上述代码中,把可能出现异常的代码
num1/num2;
放到了try语句块中,当代码发生异常时,在catch中捕获异常,并打印异常。不管有没有发生异常,
finally
语句块里的代码都会执行。发生异常后程序并没有终止,最后输出 “程序继续执行...”

6.2.2 try-with-resources

Java中,对于文件操作IO流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过close方法将>其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。
关闭资源的常用方式就是在finally块里调用close方法将资源关闭

所以对于流的操作我们经常回用到如下代码

//读取文本文件的内容
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class TestException {
    public static void main(String[] args) {
        Scanner scanner = null;
        try {
            scanner = new Scanner(new File("D://xiezhr.txt"));
            while (scanner.hasNext()) {
                System.out.println(scanner.nextLine());
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (scanner != null) {
                scanner.close();
            }
        }
    }

}
//输出
个人博客:www.xiezhrspace.cn
个人公众号:XiezhrSpace
欢迎你关注

分析:
上述代码中我们通过io流读取D盘xiezhr.txt文件中的内容,并将其内容按行打印出来。最后在
finally
代码块中将scanner关闭

Java 7 之后,新的语法糖 try-with-resources 可以简写上述代码

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class TestException {
    public static void main(String[] args) {
        try(Scanner scanner = new Scanner(new File("D://xiezhr.txt"))){
            while (scanner.hasNext()) {
                System.out.println(scanner.nextLine());
            }
        }catch (FileNotFoundException e){
            e.printStackTrace();
        }
    }
}
//输出
个人博客:www.xiezhrspace.cn
个人公众号:XiezhrSpace
欢迎你关注

两段代码实现的功能是一样的,但明显try-with-resources 写法简单了好多。我们也更提倡
优先使用 try-with-resources 而不是try-finally

6.2.3 抛出异常

当一个方法产生某种异常,但是不确定如何处理这种异常,那么就需要在该方法的头部显示地申明抛出异常,表明该方法将不对这些异常进行处理,而用该方法
调用者负责处理

6.2.3.1 thorows

①语法格式
returnType method_name(paramList) throws Exception 1,Exception2,…{…}

  • returnType
    表示返回值类型
  • method_name
    表示方法名
  • paramList
    表示参数列表;
  • Exception 1,Exception2,…
    表示异常类
    如果有多个异常类,它们之间用逗号分隔。这些异常类可以是方法中调用了可能拋出异常的方法而产生的异常,也可以是方法体中生成并拋出的异常

②使用场景
当前方法不知道如何处理这种类型的异常,该异常应该由向上一级的调用者处理;
如果 main 方法也不知道如何处理这种类型的异常,也可以使用 throws 声明抛出异常,该异常将交给 JVM 处理。
JVM 对异常的处理方法是,打印异常的跟踪栈信息,并中止程序运行,这就是前面程序在遇到异常后自动结束的原因

③实践操作

import java.io.File;
import java.io.IOException;
import java.util.Scanner;

public class TestException {
    //定义方法时声明抛出异常,方法中出现的异常自己不处理,交由调用者处理
    public void readfile() throws IOException {
        // 读取文件
        Scanner scanner = new Scanner(new File("D://xiezhr.txt"));
        while (scanner.hasNext()) {
            System.out.println(scanner.nextLine());
        }
        scanner.close();
    }
    public static void main(String[] args)  {
        TestException tt = new TestException();
        try {
            //调用readfile方法
            tt.readfile();
        } catch (IOException e) {
            //打印异常
            e.printStackTrace();
        }
    }
}
//输出
个人博客:www.xiezhrspace.cn
个人公众号:XiezhrSpace
欢迎你关注

分析:
以上代码,首先在定义 readFile() 方法时用
throws
关键字声明在该方法中可能产生的异常,然后在
main()
方法中调用
readFile()
方法,并使用
catch
语句捕获产生的异常

④ 异常处理流程图
在这里插入图片描述

注意:子类方法声明抛出的异常类型应该是父类方法声明抛出的异常类型的子类或相同,子类方法声明抛出的异常不允许比父类方法声明抛出的异常多。

//下面程序编译就报错,原因时子类抛出比父类还大的异常

 public class OverrideThrows {
        public void test() throws IOException {
            FileInputStream fis = new FileInputStream("a.txt");
        }
    }
  class Sub extends OverrideThrows {
      // 子类方法声明抛出了比父类方法更大的异常
      public void test() throws Exception {
      }
  }
6.2.3.1 throw

throw用来直接抛出一个异常

①语法
throw ExceptionObject;

  • ExceptionObject 必须是 Throwable 类或其子类的对象
  • 如果是自定义异常类,也必须是 Throwable 的直接或间接子类

②执行原理
throw 语句执行时,它后面的语句将不执行;
此时程序转向调用者程序,寻找与之相匹配的 catch 语句,执行相应的异常处理程序。
如果没有找到相匹配的 catch 语句,则再转向上一层的调用程序。这样逐层向上,直到最外层的异常处理程序终止程序并打印出调用栈情况

③实践操作

import java.util.Scanner;
public class TestException {
    public boolean validateUserName(String username) {
        boolean con = false;
        if (username.length() >4) {
            // 判断用户名长度是否大于8位
            if ("admin".equals(username)) {
                con = true;
            }else{
                throw new IllegalArgumentException("你输入的用户名不对");
            }
        } else {
            throw new IllegalArgumentException("用户名长度必须大于 4 位!");
        }
        return con;
    }
    public static void main(String[] args) {
        TestException te = new TestException();
        Scanner input = new Scanner(System.in);
        System.out.println("请输入用户名:");
        String username = input.next();
        try {
            boolean con = te.validateUserName(username);
            if (con) {
                System.out.println("用户名输入正确!");
            }
        } catch (IllegalArgumentException e) {
            System.out.println(e);
        }
    }
}
//输出
①
请输入用户名:
abc
java.lang.IllegalArgumentException: 用户名长度必须大于 4 位!
②
请输入用户名:
abcdef
java.lang.IllegalArgumentException: 你输入的用户名不对
③
请输入用户名:
admin
用户名输入正确!
6.2.4 自定义异常

当Java提供的内置异常类型不能满足我们的需求时,我们可以设计自己的异常类型。

①语法格式
class XXXException extends Exception|RuntimeException

  • 一般将自定义异常类的类名命名为 XXXException,其中 XXX 用来代表该异常的作用
  • 自定义异常类需要继承 Exception 类或其子类,如果自定义运行时异常类需继承 RuntimeException 类或其子类
  • 自定义异常类一般包含两个构造方法:一个是无参的默认构造方法,另一个构造方法以字符串的形式接收一个定制的异常消息,并将该消息传递给超类的构造方法。

②实践操作

import java.util.Scanner;
public class TestException {

    public static void main(String[] args) {
       int age;
       Scanner scanner = new Scanner(System.in);
        System.out.println("请输入你的年龄");
        age=scanner.nextInt();
        try {
            if(age < 0) {
                throw new AgeException("您输入的年龄为负数!输入有误!");
            } else if(age > 100) {
                throw new AgeException("您输入的年龄大于100!输入有误!");
            } else {
                System.out.println("您的年龄为:"+age);
            }
        } catch (AgeException e) {
            e.printStackTrace();
        }
    }
}
//输出
①
请输入你的年龄
120
com.xiezhr.AgeException: 您输入的年龄大于100!输入有误!
	at com.xiezhr.TestException.main(TestException.java:15)
②
请输入你的年龄
-34
com.xiezhr.AgeException: 您输入的年龄为负数!输入有误!
	at com.xiezhr.TestException.main(TestException.java:13)
③
请输入你的年龄
30
您的年龄为:30

6.2.5 多异常捕获

Java7以后,catch 语句可以有多个,用来匹配多个异常

①语法格式

try{
    // 可能会发生异常的语句
} catch (IOException | ParseException e) {
    // 异常处理
}
  • 多种异常类型之间用竖线|隔开
  • 异常变量有隐式的 final 修饰,因此程序不能对异常变量重新赋值

② 异常书写方式变化

try{
    // 可能会发生异常的语句
} catch (FileNotFoundException e) {
    // 调用方法methodA处理
} catch (IOException e) {
    // 调用方法methodA处理
} catch (ParseException e) {
    // 调用方法methodA处理
}

变成

try{
    // 可能会发生异常的语句
} catch (FileNotFoundException | IOException | ParseException e) {
    // 调用方法处理
} 

③实践操作

import java.util.Scanner;
public class TestException {

    public static void main(String[] args) {
       int num1;
       int num2;
        try {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入num1的值");
            num1=scanner.nextInt();
            System.out.println("请输入num2的值");
            num2=scanner.nextInt();
            int num= num1/num2;
            System.out.println("你输入的两个数相除,结果是" + num);
        } catch (IndexOutOfBoundsException | NumberFormatException | ArithmeticException e){
            System.out.println("程序发生了数组越界、数字格式异常、算术异常之一");
            e.printStackTrace();
        }
        catch (Exception e) {
            System.out.println("未知异常");
            e.printStackTrace();
        }
    }
}
//输出
①
请输入num1的值
12
请输入num2的值
0
程序发生了数组越界、数字格式异常、算术异常之一
java.lang.ArithmeticException: / by zero
	at com.xiezhr.TestException.main(TestException.java:15)
②
请输入num1的值
6888888888
未知异常
java.util.InputMismatchException: For input string: "6888888888"
	at java.util.Scanner.nextInt(Scanner.java:2123)
	at java.util.Scanner.nextInt(Scanner.java:2076)
	at com.xiezhr.TestException.main(TestException.java:12)
③
请输入num1的值
12
请输入num2的值
4
你输入的两个数相除,结果是3

分析:
上面程序中
IndexOutOfBoundsException|NumberFormatException|ArithmeticException
来定义异常类型,这就表明该
catch 块
可以同时捕获这 3 种类型的异常

七、Throwable 类常用方法

  • String getMessage()
    : 返回异常发生时的简要描述
  • String toString()
    : 返回异常发生时的详细信息
  • String getLocalizedMessage()
    : 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
  • void printStackTrace()
    : 在控制台上打印
    Throwable
    对象封装的异常信息

八、易混概念

8.1 Error和Exception的异同

  • Error

    Exception
    都有共同的祖先
    Throwable
    ,即
    Error

    Exception
    都是
    Throwable
    的子类
  • Exception
    :程序本身可以处理的异常,可以通过
    try-catch
    来进行捕获。
    Exception
    又可以分为
    Checked Exception
    (受检查异常,必须处理) 和
    Unchecked Exception
    (不受检查异常,可以不处理)。
  • Error

    Error
    属于程序无法处理的错误 ,我们没办法通过
    try-catch
    来进行捕获 。例如 Java 虚拟机运行错误(
    Virtual MachineError
    )、虚拟机内存不够错误(
    OutOfMemoryError
    )、类定义错误(
    NoClassDefFoundError
    )等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止

8.2 throw和throws的区别

  • throws
    用来声明一个方法可能抛出的所有异常信息,表示出现异常的一种可能性,但并不一定会发生这些异常;
    throw
    则是指拋出的一个具体的异常类型,执行
    throw
    则一定抛出了某种异常对象。
  • 通常在一个方法(类)的声明处通过
    throws
    声明方法(类)可能拋出的异常信息,而在方法(类)内部通过
    throw
    声明一个具体的异常信息。
  • throws
    通常不用显示地捕获异常,可由系统自动将所有捕获的异常信息抛给上级方法;
    throw
    则需要用户自己捕获相关的异常,而后再对其进行相关包装,最后将包装后的异常信息抛出

8.3 Checked Exception 和 Unchecked Exception

  • Checked Exception
    受检查异常 Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译
  • 常见的受检查异常有: IO 相关的异常、
    ClassNotFoundException

    SQLException
  • 例如,下面就是
    -
    在这里插入图片描述
  • Unchecked Exception
    即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
  • 我们经常看到的有以下几种
    NullPointerException
    :空指针错误
    IllegalArgumentException
    :参数错误比如方法入参类型错误
    NumberFormatException
    :字符串转换为数字格式错误
    ArrayIndexOutOfBoundsException
    :数组越界错误
    ClassCastException
    :类型转换错误
    ArithmeticException
    :算术错误
    SecurityException
    :安全错误比如权限不够
    UnsupportedOperationException
    :不支持的操作错误比如重复创建同一用户

8.4 try-with-resources 与 try-catch-finally

  • try-with-resources
    是Java 1.7增加的新语法糖,在
    try
    代码块结束之前会自动关闭资源。
  • try-with-resources
    适用于任何实现
    java.lang.AutoCloseable
    或者
    java.io.Closeable
    的对象, 字节输入流(
    InputStream
    ),字节输出流(
    OutputStream
    ),字符输入流(
    Reader
    ),字符输出流(
    Writer
    )均实现了这接口
  • try-catch-finally
    没有限制条件,
    finally
    不仅可以关闭资源,还可以用于执行其他代码块;
  • try-with-resources
    代码更加简洁,有限制条件,资源会立即被关闭
  • finally
    关闭资源不会立即关闭,取决与网络和系统,可能会很快,也可能会等一两天,所以,最好不要使用finally作为业务流程的控制,在《Effective java》一书 的第9条:try-with-resources优先于try-finally 中有相关详细的介绍,其中提到了许多由于finally延迟导致的网络事件

九、SpringBoot 中优雅的处理统一异常返回

日常开发中,我们处理异常一般都会用到
try-catch

throw

throws
的方式抛出异常。
这种方式不经程序员处理麻烦,对用户来说也不太友好
我们都希望不用写过多的重复代码处理异常,又能提升用户体验。这时候
全局异常处理
就显得很便捷很重要了

9.1 全局异常捕获与处理

Springboot对提供了一个
@ControllerAdvice
注解以及
@ExceptionHandler
注解,分别用于开启全局的异常捕获和说明捕获哪些异常,对那些异常进行处理。

@ControllerAdvice
public class MyExceptionHandler {

    @ExceptionHandler(value =Exception.class)
	public String exceptionHandler(Exception e){
		System.out.println("出现了一个异常"+e);
       	return e.getMessage();
    }
}

分析
上面这段代码就是说,只要是代码运行过程中有异常就会进行捕获,并输出出这个异常。然后我们随便编写一个会发生异常的代码,测试出来的异常是这样的。

在这里插入图片描述
这对于前后端分离来说这样的报错对用户并不好,前后端分离之后唯一的交互就是json了,我们也希望将后端的异常变成json返回给前端处理。

9.2 用枚举类型记录已知错误信息与成功信息

ErrorEnum
枚举类中定义了常见的错误码以及错误的提示信息。
SuccEnum
枚举类中定义了成功码及成功提示信息

至于这里为什么用枚举就不具体说了,网上文章说说的也比较多了
具体可以参照:
Java 枚举(enum) 详解7种常见的用法

① 已知错误信息

public enum ErrorEnum {
    // 数据操作错误定义
    NO_PERMISSION(403,"没有权限访问"),
    NO_AUTH(401,"请先登录系统"),
    NOT_FOUND(404, "未找到该资源!"),
    USER_NOT_FIND(402, "未找到用户信息"),
    INTERNAL_SERVER_ERROR(500, "服务器出问题了"),
    UNKNOW_ERR(-1,"未知错误")
    ;

    /** 错误码 */
    private Integer errorCode;

    /** 错误信息 */
    private String errorMsg;

    ErrorEnum(Integer errorCode, String errorMsg) {
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }

    public Integer getErrorCode() {
        return errorCode;
    }

    public String getErrorMsg() {
        return errorMsg;
    }
}

② 成功信息

public enum SuccEnum  {
    SUCCESS(200, "success");

    /** 成功码 **/
    private Integer succCode;

    /* 成功信息*/
    private String succMsg;

    SuccEnum(Integer succCode, String succMsg) {
        this.succCode = succCode;
        this.succMsg = succMsg;
    }

    public Integer getSuccCode() {
        return succCode;
    }

    public String getSuccMsg() {
        return succMsg;
    }
}

9.3 定义统一结果返回与异常返回

  • success
    :用
    boolean
    类型标识,标识是否成功
  • code
    : 状态码,区分各种报错信息与成功返回
  • msg
    : 成功或错误提示信息
  • data
    : 返回的数据
@Data
public class Result<T> {
    //是否成功
    private Boolean success;
    //状态码
    private Integer code;
    //提示信息
    private String msg;
    //数据
    private T data;
    public Result() {

    }
    //自定义返回结果的构造方法
    public  Result(Boolean success,Integer code, String msg,T data) {
        this.success = success;
        this.code = code;
        this.msg = msg;
        this.data = data;
    }


}

9.4 封装工具类返回结果

这里我们定义好了统一的结果返回,其中里面的静态方法是用来当程序异常的时候转换成异常返回规定的格式。

public class ResultUtil {

    //成功,并返回具体数据
    public static Result success(SuccEnum succEnum,Object obj){
        Result result = new Result();
        result.setSuccess(true);
        result.setMsg(succEnum.getSuccMsg());
        result.setCode(succEnum.getSuccCode());
        result.setData(obj);
        return result;
    }

    //成功,无数据返回
    public static Result succes(SuccEnum succEnum){
        Result result = new Result();
        result.setSuccess(true);
        result.setMsg(succEnum.getSuccMsg());
        result.setCode(succEnum.getSuccCode());
        result.setData(null);
        return result;
    }

    //自定义异常返回的结果
    public static Result defineError(DefinitionException de){
        Result result = new Result();
        result.setSuccess(false);
        result.setCode(de.getErrorCode());
        result.setMsg(de.getErrorMsg());
        result.setData(null);
        return result;
    }
    //其他异常处理方法返回的结果
    public static Result otherError(ErrorEnum errorEnum){
        Result result = new Result();
        result.setSuccess(false);
        result.setMsg(errorEnum.getErrorMsg());
        result.setCode(errorEnum.getErrorCode());
        result.setData(null);
        return result;
    }
}

9.5 自定义异常

内置异常不能满足我们业务需求的时候,我们就需要自定义异常

public class DefinitionException extends RuntimeException {
    protected Integer errorCode;
    protected String errorMsg;

    public DefinitionException(){

    }
    public DefinitionException(Integer errorCode, String errorMsg) {
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }

    public Integer getErrorCode() {
        return errorCode;
    }

    public void setErrorCode(Integer errorCode) {
        this.errorCode = errorCode;
    }

    public String getErrorMsg() {
        return errorMsg;
    }

    public void setErrorMsg(String errorMsg) {
        this.errorMsg = errorMsg;
    }
}

9.6 定义全局异常处理类

我们自定义一个全局异常处理类,来处理各种异常,包括
自己定义的异常

内部异常
。这样可以简化不少代码,不用自己对每个异常都使用try,catch的方式来实现

@ControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 处理自定义异常
     *
     */
    @ExceptionHandler(value = DefinitionException.class)
    @ResponseBody
    public Result bizExceptionHandler(DefinitionException e) {
        return ResultUtil.defineError(e);
    }

    /**
     * 处理其他异常
     *
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public Result exceptionHandler( Exception e) {
        return ResultUtil.otherError(ErrorEnum.UNKNOW_ERR);
    }
}

说明:
方法上面加上一个
@ResponseBody
的注解,用于将对象解析成json,方便前后端的交互,也可以使用
@ResponseBody
放在异常类上面

9.7 代码测试

9.7.1 定义User实体类
@Data
public class User {
    //唯一标识id
    private Integer id;
    //姓名
    private String name;
    //性别
    private String sex;
    //年龄
    private Integer age;
}

9.7.2 定义controller类
@RestController
@RequestMapping("/result")
public class ExceptionController {

    @Autowired
    private GlobalExceptionHandler globalExceptionHandler;

    @GetMapping("/getUser")
    public Result getStudent(){
        User user = new User();
        user.setId(100);
        user.setName("xiezhr");
        user.setAge(21);
        user.setSex("男");

        Result result = ResultUtil.success(SuccEnum.SUCCESS, user);
        return result;
    }

    @GetMapping("/getDefException")
    public Result DeException(){
        throw new DefinitionException(400,"我出错了");
    }

    @GetMapping("/getException")
    public Result Exception(@RequestParam("name") String name, @RequestParam("pwd") String pwd){
        Result result = ResultUtil.success(SuccEnum.SUCCESS);
        try {
            if ("admin".equals(name)){
                User user = new User();
                user.setId(101);
                user.setName("xiezhr");
                user.setAge(18);
                user.setSex("男");
                result =  ResultUtil.success(SuccEnum.SUCCESS,user);
            }else if (name.equals("xiezhr")){
                result =  ResultUtil.otherError(ErrorEnum.USER_NOT_FIND);
            }else{
                int i = 1/0;
            }
        }catch (Exception e){
            result =  globalExceptionHandler.exceptionHandler(e);
        }

        return result;
    }
}

9.8 接口测试

9.8.1 获取没有异常的数据返回

http://localhost:8090/result/getUser
在这里插入图片描述

9.8.2 自定义异常返回

http://localhost:8090/result/getDefException
在这里插入图片描述
http://localhost:8090/result/getException?name=xiezhr&pwd=123
在这里插入图片描述

9.8.3 其他的异常 返回

http://localhost:8090/result/getException?name=ff&pwd=abc
在这里插入图片描述

十、异常处理及规约

异常的处理⽅式有两种。 1、 ⾃⼰处理。 2、 向上抛, 交给调⽤者处理。

异常, 千万不能捕获了之后什么也不做。 或者只是使⽤e.printStacktrace。

具体的处理⽅式的选择其实原则⽐较简明: ⾃⼰明确的知道如何处理的, 就要处理掉。 不知道如何处理的, 就交给调⽤者处理。

下面时
阿里巴巴Java开发手册
关于
异常处理规则

①【强制】
Java类库中定义的可以通过预检查方式规避的
RuntimeException
不应该通过
catch
的方式处理,如
NullPointerException

IndexOutOfBoundsException

说明:无法通过预检查的异常不在此列,比如当解析字符串形式的数字时,可能存在数字格式错误,通过
catch NumberFormatException
实现

正例:

if(obj!=null){....}

反例:

try{
	obj.method();
}catch(NullPointerException e){
	...
}

②【强制】
异常捕获后不要用来做流程控制和条件控制

说明:异常设计的初衷是解决程序运行中各种意外,且异常的处理效率比条件判断方式要第很多。

③【强制】
catch
时请分清稳定代码和非稳定代码。稳定代码一般指本机运行且执行结果确定性高的代码。对于非稳定代码的
catch
尽可能在进行异常类型的分区后,再做对应的异常处理

说明:对大段代码进行
try-catch
,将使程序无法根据不同的异常做出正确的“应激”反应,也不利于定位问题,这是一种不负责的表现

正例:在用户注册场景中,如果用户输入非法字符串,或用户名称已存在,或用户输入的密码过于简单,那么程序会作出分门别类的判断并提示用户。

④【强制】
捕获异常使为了处理异常,不要捕获了却说明都不处理而抛弃之,如果不想处理它,请将异常抛给它的调用者。最外层的业务使用者必须处理异常,将其转换为用户可以理解的内容。

⑤【强制】
在事务场景中,抛出异常被
catch
后,如果需要回滚,那么一定要注意手动回滚事务。

⑥【强制】
finally
块必须对资源对象、流对象进行关闭操作,如果有一次要做
try-catch
操作。

说明:对于JDK7即以上版本,可以使用
try-catch-resource
方式

⑦【强制】
不要在
finally
块中使用return

说明
try
块中
return
语句执行成功后,并不马上返回,而是继续执行
finally
块中的语句,如果此处存在
return
语句,则在此直接返回,无情地丢弃
try
块中的返回点。

正例:

private int x =0;
public int checkReturn(){
	try{
		//x=1,此处不返回
		return ++x;
	}finally{
		//返回的结果是2
		return ++x;
	}
}

⑧【强制】
捕获异常与抛出异常必须完全匹配,或者捕获异常时抛出异常的父类。

说明:如果以及对方抛出的时绣球,实际接收到的时铅球,就会产生意外

⑨【强制】
在调用RPC、二方包或动态生成类的相关方法时,捕获异常必须使用Throwable拦截。

说明:通过反射机制调用方法,如果找不到方法,则抛出
NoSuchMethodException

在说明情况下抛出
NoSuchMethodException
呢?二方包在类冲突时,仲裁机制可能导致引入非预期的版本使类的方法签名不匹配,或者在字节码修改框架(比如:ASM)动态创建或修改类时,修改了相应的方法签名。对于这些情况,即使在代码编译期是正确的,在代码运行期也会抛出
NoSuchMethodException

⑩【推荐】
方法的返回值可以为
null
,不强制返回空集合或者空对象等,必须添加注释充分说明在说明情况下会返回
null
值。此时数据库
id
不支持存入负数二抛出异常。

说明:本手册明确,防止产生NPE是调用者的责任。即使被调用方法返回空集合或者空对象,对调用者来说,也并非高枕无忧,必须考虑远程调用失败、序列化失败、运行时异常等场景返回
null
值的情况

⑪【推荐】
防止产生NPE时程序员的基本修养,注意NPE产生的场景。
1)当返回类型为基本数据类型,
return
包装数据类型的对象时,自动拆箱有可能产生NPE
反例:

public int f(){
	//如果为null,则自动拆箱,抛NPE。
	return Integer 对象;
}

2)数据库的查询结果可能为
null

  1. 集合里的元素即使
    isNotEmpty
    , 取出的数据元素也有可能为
    null
  2. 当远程调用返回对象时,一律要求进行空指针判断,以防止产生NPE。
    5)对于
    Session
    中获取的数据,建议进行NPE检查,以避免空指针
    6)级联调用obj.getA().getB().getC();的一连串调用容易产生NPE。

⑫【推荐】
定义时区分
unchecked/checked
异常,避免直接抛出
new RuntimeException()
,更不允许抛出
Exception
或者
Throwable
,应该使用业务含义的自定义异常。推荐业界已定义过的自定义异常,如:
DAOException / ServiceException

⑬ 【参考】
对于公司外的
HTTP/API
开放接口,必须使用
“errorCode”
:应用内部推荐异常抛出;
跨应用间RPC调用优先考虑使用
Result
方式,封装
isSuccess()
方法、
errorCode

errorMessage

说明:关于RPC方法返回方式使用Result方式的理由
1)使用抛出异常返回方式,调用方式如果没有捕获到,就会产生运行时错误
2)如果不加栈信息,知识
new
自定义异常,加入自己理解的
errorMesage
,对于调用解决问题的帮助不会太多。如果加了栈信息,在频繁调用出错的情况下,数据序列化和传输的性能损耗也是问题。

⑭【参考】
避免出现重复的代码(
Don't Repeat Yourself
),即DRY原则

说明: 随意复制和粘贴代码,必然导致代码的重复,当以后需要修改时,需要修改所有的副本,容易遗漏。必要时抽取共性方法或公共类,甚至将代码组件化。

正例: 一个类中由多个
public
方法,都需要进行数行相同的参数校验操作,这个时候请抽取:

private boolean checkParam(DTO dto){...}

本期内容到这就结束了,各位小伙伴们,我们下期见 (●'◡'●)

问题背景

介绍两个经典的网络问题,

问题1: 访问位于Azure Application Gateway之后的nodejs server, 偶尔会触发502

问题2: 请求一个Azure App Service, 如果在230s之内请求没有返回,必定timeout

问题1分析

首先来分析问题1,初步看502,那么很自然的认为是后台服务down了,但是检查server状态很正常,并且测试反馈只是偶尔某个请求502,再次刷新立马就恢复正常。由于重现概率很低,尝试去抓网络包,没抓到有用的信息,只能进行猜测。出现502既然不是server的状态有问题,那么就是server端即upstream意外的close了tcp connection。这种情况可能有多种原因比如Server端的资源紧张,比如Server端的keep-alive设置,已经检查过服务器状态正常,那么可能是这个keep-alive的设置导致。

什么是HTTP keep-alive

HTTP Keep-Alive,也称为HTTP persistent connection(持久连接),它允许在一个TCP连接上发送和接收多个HTTP请求和响应,而不需要为每一个新的请求/响应对重新建立和关闭连接。在HTTP/1.0中,默认不使用Keep-Alive,每一次请求/响应结束后,连接就会被关闭。为了启用Keep-Alive功能,客户端必须在请求头中包含Connection: keep-alive。在HTTP/1.1中,Keep-Alive是默认启用的,但是客户端和服务器都可以通过发送一个Connection: close的消息头来请求关闭连接。

那么默认的nodejs keepalive是多长时间,查阅官方资料,为5s。
https://nodejs.org/docs/latest/api/http.html#serverkeepAliveTimeout

image

写段代码简单的试验下:

const express = require('express');
const http = require('http');
const server = http.createServer(app);
app.get('/', async (req, res) => {
    await res.send("Hello World!");
});
server.listen(3000, () => {
    console.log("start server");
});

查看response header:

image

抓取网络包并分析:

image

从图中可以看到,正常情况下,当收到最后一个ACK 5s之后, 由Server端(port 3000)会发送FIN Flag,开始关闭连接。这证明了Server端会主动的close connection, 而某些情况下会发生如下情况:

image

这个是网上截取的图,但是情况类似,某些情况,TCP Conenction没有正常的断开连接,而是直接Reset, 此时就会发生502。

解决方案

那么解决方案也很明了,只要Server端保证比Client端也就是Application Gateway的Keep-Alive 长,Server端不会在Client端认为可用的时候发送消息,就不会发生此类问题,查阅官方资料,Azure Applicaiton Gateway的HttpAlive v1为120s, v2为75s, 我们用的是V2, 设置为了120s > 75s, 此问题改动之后不再发生。但是需要声明的是用的相同的配置和代码的另外一个产品则从来没发生过此类问题。网络世界很复杂,也许不是这个原因,咱们这里只是拿这个问题来重点介绍HTTP Keep-Alive。

问题2分析

问题2的描述是在230s之内请求没有返回, 那么也就是说230s之内TCP connection一直没有流量传输,那么大概率是触发了TCP Idle Timeout。

什么是TCP Keep-Alive和TCP Idle Timeout

TCP Idle Timeout 是指TCP连接在无数据交换时可以保持空闲状态的最大时间。在这段时间后,如果没有任何数据包(例如TCP段)在连接上发送,连接就可能被认为是不再需要的,并且会被网络设备或操作系统自动关闭。这样做的目的是为了回收不再使用的资源,避免无用连接。

TCP Keep-Alive 是一种网络协议的机制,设计用于在TCP(传输控制协议)连接中检测对方是否已停止响应。TCP 是一种面向连接的协议,用于在计算机网络中的应用程序之间可靠地传输数据。在长时间的空闲期间,一个端点可能在没有任何通知的情况下不再可用,例如电脑可能已经崩溃或网络故障。Keep-Alive 机制可以帮助检测这些情况,确保连接仍然是活跃的,或允许应用程序在连接被对方关闭时采取适当的行动。

image

注意网络设备和应用程序可以调整TCP Keep-Alive的参数比如时间间隔和重试次数

注意不要把TCP Keep-Alive和Http Keep-Alive混淆。

在Azure中, Azure Load Balancer有个默认的4分钟的TCP Idle timeout, 这也就导致了如果app service一直没返回,则client端大概在230s(windows是230s, linux是240s)抛出timeout异常。

解决方案

那解决方案也比较清晰,由于Azure Load Balancer的设置在AppService里的服务中是不可配置的,那么只能改为异步方式,对于请求来说,可以先返回一个Id,然后循环查询Id对应的状态,而不是一直等待。

上一章我们主要唠了RLHF训练相关的方案,这一章我们主要针对RLHF的样本构建阶段,引入机器标注来降低人工标注的成本。主要介绍两个方案:RLAIF,和IBM的SALMON。

RLAIF

  • RLAIF: Scaling Reinforcement Learning from Human Feedback with AI Feedback

RLAIF给出的方案是完全使用大模型来进行偏好样本的标注,论文主要测试了摘要任务,对话的无害性和有用性上,RLAIF能获得和RLHFg相似的效果。我们在后期也采用了机器标注,因为真的不标不知道一标吓一跳,想获得足够用来训练RL的高质量标注样本的成本大的吓人......

既然是用大模型来标注偏好样本,那核心又回到了如何写Prompt, RLAIF一个基础的Prompt构建如下:Preamble是如何进行评估的任务指令,Exampler是高质量的标注few-shot,然后是上文和两条待评估的摘要,最后是模型输出。

论文还提到了几个指令优化细节

  1. soft label:想让模型评估两个样本的优劣,有很多种打分方式,论文选择了输出样本序号的方式,这样既符合自然语言表达,同时因为就1个输出字符,可以很直观的计算1和2的解码概率,从而得到soft label,后续用于RM训练
  2. Positional bias: 考虑summary1和2的前后顺序会影响模型的评估结果,论文会分别对12,21的两组样本进行打分,并取soft label的平均值。这里论文验证了越小的模型位置偏差越严重。
  3. 加入COT: 在Ending的部分加入思维链指令"Consider the coherence, accuracy, coverage, and overall quality of each summary and explain which one is better. Rationale:"
  4. 解码策略:在附录中论文对比了self-consistency和greedy decode,效果上greedy更好,哈哈但是self-consistency的样本最多只用到16,所以这个对比做的不是非常充分。

获取到机器标注数据后,论文使用soft-label[0.6,0.4]来进行Reward模型的训练再对SFT之后的模型进行对齐。
注意这里有一个细节就是论文使用了soft label,从模型蒸馏的角度,soft label比[0,1]的hard label包含远远更多的信息,在蒸馏方案中往往可以显著提高蒸馏效果
,因此不排除RLAIF超过人工标注效果的部分原因,来自soft-label而非machine label。对齐后的模型效果对比如下,
RLAIF的无害率提升更加明显,胜率和RLHF齐平


细看会发现上面对比中还有两个细节

  1. same-size RLAIF:RLAIF的标注模型是PaLM2 Large,而SFT模型是PaLM2 XS。论文同时测试了当标注模型和SFT模型大小同样为PaLM2 XS的效果。
    说明RLAIF的效果不完全来自模型大到小的蒸馏,在同样大小上模型标注可以带来自我提升
  2. Direct RLAIF:是不使用Reward模型进行训练,直接使用大模型(PaLM2 XS)标注出1-10分的打分,并把打分概率归一化到sum(1).然后直接使用模型打分作为reward来进行对齐。但个人对这种方案表示存疑,因为在我们的尝试中模型的绝对打分能力并不高,哈哈不排除我们任务有点复杂prompt没写好。论文使用的prompt如下
prompt = """You are an expert summary rater. Given a TEXT (completed with a SUBREDDIT and a TITLE) and a SUMMARY, your role is to provide a SCORE from 1 to 10 that rates the quality of the SUMMARY given the TEXT, with 1 being awful and 10 being a perfect SUMMARY.
"""

IBM SALMON

SALMON在RLAIF的基础上优化了机器标注的部分,并给出了新的Reward模型的训练方案- Principle-Following Reward Model,背后其实也是用到了对比学习的思路。个人感觉这个思路更优的点在于,不直接让模型学习什么回答更好,什么回答更不好,因为好和不好的判定更容易陷入reward hacking,相反SALMON让模型学习每个具体的好和不好的偏好标准的表征。

先说偏好样本标注,同样是基于SFT模型采样两个候选回答,在使用大模型进行偏好标注时,SALMON对偏好进行了更为细致的拆分,总结了小20条偏好标准,包括事实性,数值 敏感,时间敏感等等。论文没有把所有原则合并成一个prompt,而是每一条原则,都会让模型对两个候选回答进行独立打分,打分是回答A、B解码概率的对数之差。这里同样考虑了position bias,因此会swap位置求平均。prompt模板如下

image

通过以上的标注我们能得到prompt_i,responseiA,responseiB,princle_j, score_iaj, score_ibj的偏好样本。
在RL模型训练时,SALMON没有像以上RLAIF直接使用soft label进行模型微调,而是采用了指令微调的方案,并引入了负向原则,例如非事实性,无用性等等,更全面的让Reward模型学习每一个偏好原则的具体表征。
具体的指令样本构建方式如下

  1. 对所有正面 原则,构建对应的负面原则描述,以下为简洁性原则的正负面principle

image

  1. 输入:对每个prompt,采样3个原则,并对采样的原则进行随机反转。在以下的指令样本中,随机采样的principle分别是Concise ,Ethical和precise, 其中Eithical被随机反转成负面原则。

image

  1. 输出:如果responseA 和resposneB 对应以上3个原则 的打分分别是(2,3,6)和(1,5,5),这时选择AB打分差异最大的一个principle来决定最终的输出结果,这时分差最大的是Ethical Principle,又因为该原则被反转,因此模型的解码结果是该维度上得分更低的A。

最后使用以上构建的指令样本进行微调得到可以理解正负面偏好原则并对回答进行打分的Reward模型。

同样因为训练了Reward模型的Principle理解能力,在推理使用Reward模型时,论文指出可以通过动态调整上文principle来解决一些已经发现的reward hacking的问题,哈哈所谓头疼医头,脚疼医脚。最开始读到这个思路时觉得有点逗,后来训练发现时不时就会发现Reward模型存在各种奇葩Bias时,才发现能快速通过prompt修复一两个小问题真的很香,虽然不是终极解决方案,但胜在临时好用。针对三个发现的Reward hacking问题,论文分别在推理时加入了以下的新principle

image

说完RL训练策略优化,和样本标注优化,过几章我们接着说RL相关的对抗训练和训练过程优化方案~

想看更全的大模型相关论文梳理·微调及预训练数据和框架·AIGC应用,移步Github >>
DecryPrompt

前言

习惯了在 css 文件里面编写样式,其实JavaScript 的 CSS对象模型也提供了强大的样式操作能力,
那就随文章一起看看,有多少能力是你不知道的吧。

样式来源

客从八方来, 样式呢, 样式五方来。

chrome旧版本用户自定义样式目录: %LocalAppData%/Google/Chrome/User Data/Default/User StyleSheets。 新版本已经不支持用户自定义样式。

用户代理样式(浏览器默认样式):

至于字体大小,颜色等等,这些都是浏览器默认样式。

前两种样式我们一般不会去修改,绝大部分场景我们都是在操作后面三种样式。

样式优先级

同一个节点的某个属性可能被定义多次,最后哪个生效呢? 这是有一个优先级规则的。

内联样式
>
ID选择器
>
类选择器
>
标签选择器

细心的同学会问,
伪类
呢,
important
呢, 答案也很简单

  • 伪类 优先级 同 类选择器
  • important
    你最大

到这里,各位可能都觉得没问题,那来张图:

截图_20245420045437.png

ID选择器
败给了
样式选择器
, 这是因为 CSS 新的
layer (级联层)
layer1的优先级更高特性导致的, 后续会有专门的文章介绍。

再提个问题: 如果 layer2 修改为
color: red !important
, 那又改显示什么颜色呢。

基本知识准备完毕,那就进入下一个阶段。

操作元素节点上的style属性

  • style属性名是驼峰语法
    想到react给style赋值,是不是呵呵一笑了。
    <style>
        .div {
            background-color: red;
            font-size: 30px;
        }
    </style>
    <script>
        const el = document.getElementById("test-div");
        el.style.backgroundColor = "red";
        el.style.fontSize = "30px";
    </script>
  • style.cssText 批量赋值
  • important! 也是可以生效的
    <div id="test-div">文本</div>
    <style>
        .div {
            background-color: red;
            font-size: 30px;
        }
    </style>
    <script>
        const el = document.getElementById("test-div");
         el.style.cssText ="background-color: green !important; font-size: 40px;"
    </script>

那可不可以直接把style赋值一个对象呢? 很不幸,style是一个只读属性,虽然你表面能赋值成功,实际没有任何变化。

// 例如
document.body.style = {color:"red"};

另外你也可以通过
attributeStyleMap
属性来设置style的值:

const buttonEl = document.querySelector("body");
// 更改背景色
buttonEl.attributeStyleMap.set("background-color", 'red');

目前掌握classList的style.cssText的你,是不有点小嘚瑟呢? 这才哪到哪,还有重头戏。

操作元素节点classList & className属性

className: 操作的是节点的class属性。

对比

属性 方法
className 字符串 字符串具备的方法
classList DOMTokenList
类数组
add, remove, contains, toggle等

没有classList属性之前,我们还需要手动封装类似的方法。 时代的进步真好!

DOMTokenList.toggle

定义: 从列表中删除一个给定的
标记
并返回 false 。 如果
标记
不存在,则添加并且函数返回 true。

语法:
tokenList.toggle(token, force)
;

force参数: 如果force为真,就变为单纯的添加。

用两个按钮分别来演示toggle true和toggle false.

toggle.gif
代码如下:

    <div>
        <button type="button" id="btnToggleFalse">toggle(false)</button>
        <button type="button" id="btnToggleTrue">toggle(true)</button>
    </div>

    <div id="container">
        <div>文字</div>
    </div>
    <style>
        .test-div {
            color: red
        }
    </style>

    <script>
        const el = container.firstElementChild;
        // toggle false
        btnToggleFalse.addEventListener("click", function () {
            el.classList.toggle("test-div");
        });
        // toggle true
        btnToggleTrue.addEventListener("click", function () {
            el.classList.toggle("test-div", true);
        })
    </script>

操作style节点内容

本质还是Node节点

style标签是不是节点,是,那,就可以为所欲为!!!

<style>
 	    .div {
            background-color: red;
            font-size: 30px;
        }
</style>

拿到文本内容替换,可不可以,当然是可以的。 剑走偏锋!

  <div>
        <button id="btnReplace" type="button">替换</button>
    </div>
    <div class="div">
        文本
    </div>
    <style id="ss-test">
        .div {
            background-color: red;
            font-size: 30px;
        }
    </style>
    <script>
        const ssEl = document.getElementById("ss-test");
        btnReplace.addEventListener("click", function () {
            ssEl.textContent = ssEl.textContent.replace("background-color: red", "background-color: blue")
        })
    </script>

动态创建style节点

    <div>
        <button type="button" id="btnAdd">添加style节点</button>
    </div>
    <div class="div">文本</div>

    <script>

        document.getElementById("btnAdd").addEventListener("click", createStyleNode)

        function createStyleNode() {
            const styleNode = document.createElement("style");

            // 设置textContent
            // styleNode.textContent = `
            //     .div {
            //         background-color: red;
            //         font-size: 30px;
            //     }
            // `;
            // append
            styleNode.append(`
                 .div {
                     background-color: red;
                     font-size: 30px;
                 }
            `)
            document.head.appendChild(styleNode);
        }

    </script>

操作已有的style节点

这个就得请专业选手
CSS Object Model
入场, 这是一组允许用JavaScript操纵CSS的API。 它是继DOM和HTML API之后,又一个操纵CSS的接口,从而能够动态地读取和修改CSS样式。

先看关系(不包含 layer)

截图_20242120052143.png

现在就做一件事情,把 .div的backgound-color的值从red修改green。从图上可以看到:

  1. CSSStyleSheet也提供了insertRule和deleteRule的方法
  2. StylePropertyMap提供能操作个规则属性的能力。

先看效果:

update_ex.gif

那代码就简单了:

<div>
    <button type="button" id="btnUpdate">更改style节点</button>
</div>
<div class="div">文本</div>
        <style id="ss-test">
            .div {
                background-color: red;
                font-size: 30px;
            }
            div {
                font-size: 26px
            }
        </style>
<script>
    document.getElementById("btnUpdate").addEventListener("click", updateStyleNode)

    function updateStyleNode() {
        const styleSheets = Array.from(document.styleSheets);
        // ownerNode获得styleSheet对应的节点
        const st = styleSheets.find(s=> s.ownerNode.id === "ss-test");
        // 选过选择器找到对应的rule  
        const rule = Array.from(st.cssRules).find(r=> r.selectorText === ".div");

        // 兼容性 
        const styleMap = rule.styleMap;
        styleMap.set("background-color", "blue");

    }
</script>

操作外部引入样式

动态创建link节点引入样式

我们首先看一下html页面里面通常是怎么引入样式的。

<link rel="stylesheet" href="http://x.com/c.css">

其本质依旧是节点,所以我们可以动态的创建节点,挂载到文档上即可。

function importCSSByUrl(url){
  var link = document.createElement('link');
      link.type = 'text/css';
      link.rel = 'stylesheet';
      link.href = url;
      document.head.appendChild(link);
}

更改外部引入的样式

那么我们外部引入的CSS,我们也能操作嘛?

答案是肯定的,外面引入的样式最终也会变成一个StyleSheet。 区别在于其href的属性有其全路径, 当然也可以通过
onwerNode
的值去识别是
link
还是
style
方式导入的。

所以,几乎上面的例子,代码只需少量改动。

function updateStyleNode() {
    const styleSheets = Array.from(document.styleSheets);
    // 通过href判断
    const st = styleSheets.find(s => s.href.endsWith("2.3.css"));
    const rule = Array.from(st.rules).find(r => r.selectorText === ".div");
    const styleMap = rule.styleMap;
    styleMap.set("background-color", "green");
}

window.getComputeStyle

功能

Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。

语法

let *style* = window.getComputedStyle(*element,* [*pseudoElt*]);

计算后的样式不等同于css和style里面设置的样式

比如font-size属性和transform属性:

效果:

代码:


    <div id="div-test" class="div">
        文本
    </div>
    <hr>
    <div>
        样式的值
        <pre>
            .div {
                font-size: 1.6rem;
                transform:rotate(3deg);
            }
        </pre>
    </div>
    <hr>
    <div>
        getComputedStyle的值:
        <pre id="divGc"></pre>
    </div>
    <style>
        .div {
            font-size: 1.6rem;
            transform:rotate(3deg);
        }
    </style>

    <script>
        const divEl = document.getElementById("div-test");
        const styleDeclaration = window.getComputedStyle(divEl);
        const fontSize = styleDeclaration.fontSize;
        const transform = styleDeclaration.transform;

        divGc.textContent = `
            fontSize: ${fontSize}
            transform: ${transform}
        `
    </script>

可以获取伪类样式

获取伪类的样式,就得利用第二个参数

  const styleDeclaration = window.getComputedStyle(divEl, "before");

效果:

代码:

    <div id="div-test" class="div">
        文本
    </div>

    <hr>
    <div>
        伪类的样式:
        <pre id="divGc"></pre>
    </div>
    <style>
        .div:before {
            content: '(你好)';
            font-size: 1.6rem;
        }
    </style>

    <script>
        const divEl = document.getElementById("div-test");
        const styleDeclaration = window.getComputedStyle(divEl, "before");
        const fontSize = styleDeclaration.fontSize;
        const content = styleDeclaration.content;

        divGc.textContent = `
            fontSize: ${fontSize}
            content: ${content}
        `
    </script>

此方法会引起重绘

重排
:元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为
重排

重绘
: 元素样式的改变并不影响它在文档流中的位置或者尺寸的时候,例如: color, backgound-color, outline-color等,浏览器会重新绘制元素,这个过程称为重绘。

这个在之后可能的加餐中详细说道。

这个是双刃剑。我们通过例子来认知他,动态创建一个create,想让他立马有动画。

下面的代码,没调用 getComputedStyle就不会有动画, 不取值也没有动画

    <div>
        <button id="btnAdd">动态创建节点并动画</button>
    </div>
    <div id="container">
    </div>
    <style>
        .ani {
            position: absolute;
            width: 50px;
            height: 50px;
            border-radius: 50%;
            background-color: blue;
            transition: all 3s;
        }
    </style>
    <script>
        btnAdd.addEventListener("click", createAni);
        function createAni() {
            var div = document.createElement('div')
            div.className = "ani";
            container.appendChild(div);

            div.style.left = "0px";
            // 去掉这行代码就不会有动画
            // window.getComputedStyle(div).height
          	// window.getComputedStyle(div) 依旧不会有动画
            div.style.left = "200px"
        }
    </script>

我们把样式从内联样式,到style节点(标签),到引入的外部的样式,挨个揍了一遍,一个能打的都没有,还有谁。额,不说了,会的交给你们啦,怎么玩就看你你们啦。

写在最后

不忘初衷,有所得,而不为所累,如果你觉得不错,你的一赞一评就是我前行的最大动力。

微信公众号:成长的程序世界 ,关注之后,海量电子书,打包拿走不送。

或者添加我的微信 dirge-cloud,一起学习。