Java业务开发常见错误100例(代码篇-2)

Java业务开发常见错误100例(代码篇-2) 11丨空值处理:分不清楚的null和恼人的空指针

  1. 业务代码中 5 种最容易出现空指针异常的写法
    1. 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
    2. 字符串比较出现空指针异常;
    3. 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
    4. A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
    5. 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常;
  2. 通过 Optional 配合 Stream 可以避免大多数冗长的 if-else 判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用 Arthas 来查看方法的调用栈和入参会更快捷。
  3. POJO 中字段的 null 定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了 null,因此我们尝试用 Optional类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL,只更新必要的字段。
  4. 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。会有 NULL、空字符串和字符串 null 三种状态
  5. MySQL sum 函数、count 函数,以及 NULL 值条件可能踩的坑
    1. sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0;
    2. count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方式。
    3. =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL。
12丨异常处理:别让自己在出问题的时候变为瞎子
大多数业务应用都采用的三层架构:
Java业务开发常见错误100例(代码篇-2)
文章图片

业务性质上异常可能分为业务异常和系统异常两大类:
对于自定义的业务异常:以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常:以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。
  • Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到。
  • Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
  • 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。
  1. 错误打log姿势:
    1. 捕获了异常后直接生吞
    2. 没有生吞,但是丢弃异常的原始信息
      @GetMapping("wrong1") public void wrong1(){ try { readFile(); } catch (IOException e) { //wrong1:原始异常信息丢失 //throw new RuntimeException("系统忙请稍后再试"); //wrong2:只保留了异常消息,栈没有记录 //log.error("文件读取错误, {}", e.getMessage()); //throw new RuntimeException("系统忙请稍后再试"); //correct1: log.error("文件读取错误", e); throw new RuntimeException("系统忙请稍后再试"); //correct2: throw new RuntimeException("系统忙请稍后再试", e); } }

    3. 抛出异常时不指定任何消息
      throw new RuntimeException();

  2. 如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有
    三种处理模式:
    1. 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。
    2. 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更
      严重,需要考虑当前情况是否适合重试。
    3. 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
  3. 小心 finally 中的异常
    1. 虽然 try 中的逻辑出现了异常,但却被 finally
      中的异常覆盖了
      @GetMapping("wrong") public void wrong() { try { log.info("try"); //异常丢失 throw new RuntimeException("try"); } finally { // wrong //log.info("finally"); //throw new RuntimeException("finally"); //correct log.info("finally"); try { throw new RuntimeException("finally"); } catch (Exception ex) { log.error("finally", ex); } } }

    2. 可以改为 try-with-resources 模式
      @GetMapping("useresourceright") public void useresourceright() throws Exception { try (TestResource testResource = new TestResource()){ testResource.read(); } }

  4. 千万别把异常定义为静态变量,务必确保异常是每次 new 出来的。否则可能会引起栈信息的错乱。
  5. 确保正确处理了线程池中任务的异常:
    1. 如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;
    2. 如果任务通过 submit 提交意味着我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。
13丨日志:日志记录真没你想象的那么简单
记录日志引起的坑,容易出错主要在于三个方面:
  • 日志框架的兼容问题
  • 日志文件配置复杂且容易出错
  • 日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等
    Java 体系的日志框架,确实非常多,而不同的类库,还可能选择使用不同的日志框架。这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了 SLF4J((SimpleLogging Facade For Java))。而一般我们也都是使用SLF4J去管理:
Java业务开发常见错误100例(代码篇-2)
文章图片

SLF4J 实现了三种功能:
  • 提供了统一的日志门面 API,即图中紫色部分,实现了中立的日志记录 API。
  • 提供桥接功能,即图中蓝色部分,用来把各种日志框架的 API(图中绿色部分)桥接到SLF4J API。这样一来,即便你的程序中使用了各种日志 API 记录日志,最终都可以桥接到 SLF4J 门面 API。
  • 提供适配功能,即图中红色部分,可以实现 SLF4J API 和实际日志框架(图中灰色部分)的绑定。SLF4J 只是日志标准,我们还是需要一个实际的日志框架。日志框架本身没有实现 SLF4J API,所以需要有一个前置转换。Logback 就是按照 SLF4J API 标准实现的,因此不需要绑定模块做转换。
常见问题:
  1. 如果程序启动时出现 SLF4J 的错误提示,那很可能是配置出现了问题,可以使用 Maven 的 dependency:tree 命令梳理依赖关系
  2. Logback 是 Java 最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于Appender、Layout、Filter 的配置,切记不要随意从其他地方复制别人的配置,避免出现错误或与当前需求不符。
  3. 使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
  4. 使用日志占位符,而不是 字符串拼接
14丨文件IO:实现高效正确的文件读写并非易事
  1. 如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致
    的,否则可能产生乱码。
    1. 不指定Charset(程序会自动以当前机器的默认字符集来读取文件的)
      char[] chars = new char[10]; String content = ""; try (FileReader fileReader = new FileReader("hello.txt")) { int count; while ((count = fileReader.read(chars)) != -1) { content += new String(chars, 0, count); } } log.info("result:{}", content);

    2. 指定Charset
      Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8)); byte[] content = Files.readAllBytes(Paths.get("hello2.txt")); log.info("bytes:{}",Hex.encodeHexString(content));

  2. 使用 Files 类的一些流式处理操作,注意使用 try-with-resources 包装 Stream,确保底层文件资源可以释放,避免产生 too many open files 的问题。
    1. 不使用 try-with-resources(后台不会关闭进程,而是会一直新开一个进程,直到无法再开新的线程)
      LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try { Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());

    2. 使用 try-with-resources
      LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try (Stream lines = Files.lines(Paths.get("demo.txt"))) { lines.forEach(line -> longAdder.increment()); } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());

  3. 进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少 IO 次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用 FileChannel 进行流转发。
    1. 不对数据进行处理,直接把原文件数据写入目标文件;
      private static void perByteOperation() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) int i; while ((i = fileInputStream.read()) != -1) { fileOutputStream.write(i); } } }

      复制一个 35MB 的文件,耗时 190 秒
    2. 改良后,使用 100 字节作为缓冲区
      private static void bufferOperationWith100Buffer() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) byte[] buffer = new byte[100]; int len = 0; while ((len = fileInputStream.read(buffer)) != -1) { fileOutputStream.write(buffer, 0, len); } } }

      复制一个 35MB 的文件,耗时 26秒
      (可以看到,在进行文件 IO 处理的时候,使用合适的缓冲区可以明显提高性能)
    3. 使用BufferedXXXStream,其内部实现了一个默认 8KB 大小的缓冲区(但是,在使用BufferedInputStream 和 BufferedOutputStream 时,还是建议大家再使用一个缓冲进行读写,不要因为它们实现了内部缓冲就进行逐字节的操作)。
      这里我直接贴出三种方式,具体代码 放在code repository里,可自行翻阅:
      Java业务开发常见错误100例
      1. 直接使用 BufferedInputStream 和 BufferedOutputStream;
      2. 额外使用一个 8KB 缓冲,使用 BufferedInputStream 和 BufferedOutputStream;
      3. 直接使用 FileInputStream 和 FileOutputStream,再使用一个 8KB 的缓冲。
        最后,三者的性能分别是 1.4、110 毫秒 和 110 毫秒
      4. 使用FileChannel,速度最快,可达 50 毫秒,比最原始的190秒,快了足足 数千倍
最后要强调一点的是,文件操作因为涉及操作系统和文件系统的实现,JDK 并不能确保所有IO API 在所有平台的逻辑一致性,代码迁移到新的操作系统(比如上到测试场或者生产场)或文件系统时,要重新进行功能测试和性能测试。
15丨序列化:一来一回你还是原来的你吗?
基于Redis 和 Web API 的入参和出参两个场景,介绍 序列化和反序列化时需要避开的几个坑
  1. 要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正确处理序列化后的数据就要使用相同的反序列化算法。
  2. Jackson 有大量的序列化和反序列化特性,可以用来微调序列化和反序列化的细节。需要注意的是,如果自定义 ObjectMapper 的 Bean,小心不要和 Spring Boot 自动配置的 Bean 冲突。
  3. 在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化
  4. 对于反序列化默认情况下,框架调用的是无参构造方法,如果要调用自定义的有参构造方法,那么需要告知框架如何调用。更合理的方式是,对于需要序列化的 POJO 考虑尽量不要自定义构造方法。
  5. 枚举不建议定义在 DTO 中跨服务传输,因为会有版本问题,并且涉及序列化反序列化时会很复杂,容易出错。因此,只建议在程序内部使用枚举。
16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑
Java Date系列已成为遗留产品,新的Java8中的时间新特性,已经可以全面替换旧的了,旧的不仅可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题,所以也强烈建议大家使用JDK8的。除了好用之外,二者有区别的地方还在于:
java.util.Date 类是因为使用 UTC 表示,所以没有时区概念,本质是时间戳;
而 LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一时间点。
Java业务开发常见错误100例(代码篇-2)
文章图片

  1. 初始化时间: (例子:2019 年 12 月 31 日 11 点 12 分 13秒)
    1. jdk8之前: Date date = new Date(2019 - 1900 , 11, 31, 11, 12, 13); 有国际化需求,需要使用到Calendar类
      jdk8之前: 年应该是和 1900 的差值,月应该是从0 到 11 而不是从 1 到 12。
    2. jdk8后: LocalDateTime date = LocalDateTime.of(2019, 12, 31, 11, 12, 13);
  2. 时区问题:处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
    1. 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一
      时间,前面我们说过,Date类就是存得是UTC的时间戳,
    2. 以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。
    时区因素会带来两个问题:
    1. Date存得是UTC时间戳,不同时区服务器读出的时间是不一样的,例如:拿 2020-01-02 22:00:00,这个时间作为例子,分别按照默认程序时区,和指定NewYork时区,输出 解析后的时间
      String stringDate = "2020-01-02 22:00:00"; SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date date1 = inputFormat.parse(stringDate); System.out.println(date1 + ":" + date1.getTime()); inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York")); Date date2 = inputFormat.parse(stringDate); System.out.println(date2 + ":" + date2.getTime());

      输出:(发现相差13个小时,所以说,如果你的公司服务器有跨时区的,)
      Thu Jan 02 22:00:00 CST 2020:1577973600000
      Fri Jan 03 11:00:00 CST 2020:1578020400000
      解决方案:务必指定 存和读的 时区是一致的。存的时候,需要使用正确的当前时区来保存,这样 UTC 时间才会正确;读的时候,也只有正确设置本地时区,才能把 UTC 时间转换为正确的当地时间。
    2. 更好的方案:Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime
      和 DateTimeFormatter,处理时区问题更简单清晰。
      LocalDateTime 不带有时区属性,所以命名为本地时区的日期时间;
      而 ZonedDateTime=LocalDateTime+ZoneId,具有时区属性。
      因此,LocalDateTime 只能认为是一个时间表示,ZonedDateTime 才是一个有效的时间
      我们拿上海、纽约和东京,举个例子: 依旧是 2020-01-02 22:00:00这个time
      1. 代码:
        String stringDate = "2020-01-02 22:00:00"; ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai"); ZoneId timeZoneNY = ZoneId.of("America/New_York"); ZoneId timeZoneJST = ZoneOffset.ofHours(9); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST); DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z"); System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date)); System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date)); System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));

      2. 输出:
        Asia/Shanghai2020-01-02 21:00:00 +0800
        America/New_York2020-01-02 08:00:00 -0500
        +09:002020-01-02 22:00:00 +0900
      3. 结论:要正确处理国际化时间问题,推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。
  3. 日期时间格式化和解析
    1. Date - ”YYYY-MM-dd 著名Bug“--提前跨年,”这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了“。
      1. 例如:初始化一个 Calendar,设置日期时间为 2019 年 12 月 29 日,使用大写的 YYYY 来初始化 SimpleDateFormat。但最后输出的,却是 2020 年 12 月 29 日,好家伙,直接多了一年!
      2. 其原因在于:开发人员混淆了 SimpleDateFormat 的各种格式化模式。JDK的文档中有说明:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。
      3. 而按照当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年。但是如果你把时区换成France,就不会有问题。
    2. Date - 定义的 static 的 SimpleDateFormat 可能会出现线程安全问题
    3. Date - 当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,例如:
      使用 yyyyMM 来解析 20160901,它居然不报错,但是结果是:
      2091 年 1 月 1 日,原因在于:把0901 当成了月份,相当于 75 年,无语子。。。
    4. 相比旧的Date,新的JDK8 Date就没有这些问题,也不用管是 YYYY 还是 yyyy,DateTimeFormatter要是线程安全的。
  4. 日期时间的计算:
    1. 日期时间的计算,一个很多开发常踩的坑。有人直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 newDate().getTime 方法得到的时间戳加 30 天对应的毫秒数,也就是 30 天 1000 毫秒 3600 秒 *24 小时。但是会发现 结果根本不对
      其原因在于 int发生溢出,修复方式就是把 30 改为 30L
      但还是很繁琐,且容易出错,所以jdk8之前,更推荐使用 Calendar
    2. jdk8后,日期时间类型,可以直接进行各种计算,更加简洁、方便和强大。
      但 计算两个日期差时可能会踩坑,
      Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。
17丨别以为“自动挡”就不可能出现OOM
通常而言,Java 程序的 OOM有如下几种可能:
  1. 程序确实需要超出 JVM 配置的内存上限的内存。不管是程序实现的不合理,还是因为各种框架对数据的重复处理、加工和转换,相同的数据在内存中不一定只占用一份空间。针对内存量使用超大的业务逻辑,比如缓存逻辑、文件上传下载和导出逻辑,我们在做容量评估时,可能还需要实际做一下 Dump,而不是进行简单的假设。
  2. 出现内存泄露,其实就是我们认为没有用的对象最终会被 GC,但却没有。GC 并不会回收强引用对象,我们可能经常在程序中定义一些容器作为缓存,但如果容器中的数据无限增长,要特别小心最终会导致 OOM。使用 WeakHashMap 是解决这个问题的好办法,但值得注意的是,如果强引用的 Value 有引用 Key,也无法回收 Entry。
  3. 不合理的资源需求配置,在业务量小的时候可能不会出现问题,但业务量一大可能很快就会撑爆内存。比如,随意配置 Tomcat 的 max-http-header-size 参数,会导致一个请求使用过多的内存,请求量大的时候出现 OOM。在进行参数配置的时候,我们要认识到,很多限制类参数限制的是背后资源的使用,资源始终是有限的,需要根据实际需求来合理设置参数。
最后想说的是,在出现 OOM 之后,也不用过于紧张。我们可以根据错误日志中的异常信息,再结合 jstat 等命令行工具观察内存使用情况,以及程序的 GC 日志,来大致定位出现 OOM 的内存区块和类型。其实,我们遇到的 90% 的 OOM 都是堆 OOM,对 JVM 进程进行堆内存 Dump,或使用 jmap 命令分析对象内存占用排行,一般都可以很容易定位到问题。
18丨当反射、注解和泛型遇到OOP时,会有哪些坑?
【Java业务开发常见错误100例(代码篇-2)】虽然我们日常业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之又少,没啥好学的。但是,只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解。
  1. 反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,所以需要特别注意这一点。
  2. 反射获取类成员,需要注意 getXXX 和 getDeclaredXXX 方法的区别,其中 XXX 包括 Methods、Fields、Constructors、Annotations。
  3. 泛型因为类型擦除会导致泛型方法 T 占位符被替换为 Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,所以需要特别注意这一点。
  4. 自定义注解可以通过标记元注解 @Inherited 实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用 Spring 的工具类AnnotatedElementUtils,并注意各种 getXXX 方法和 findXXX 方法的区别。
19丨Spring框架:IoC和AOP是扩展的核心
  1. 让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
  2. 如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为TARGET_CLASS。
  3. 如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执行顺序,是什么样的。
20丨Spring框架:框架帮我们做了很多工作也带来了复杂度
  1. Spring Cloud 会使用Spring Boot 的特性,根据当前引入包的情况做各种自动装配。如果我们要扩展 Spring 的组件,那么只有清晰了解 Spring 自动装配的运作方式,才能鉴别运行时对象在 Spring 容器中的情况,不能想当然认为代码中能看到的所有 Spring 的类都是 Bean。
  2. 对于配置优先级的案例,分析配置源优先级时,如果我们以为看到PropertySourcesPropertyResolver 就看到了真相,后续进行扩展开发时就可能会踩坑。我们一定要注意,分析 Spring 源码时,你看到的表象不一定是实际运行时的情况,还需要借助日志或调试工具来理清整个过程。如果没有调试工具,可以借助Arthas,来分析代码调用路径。

    推荐阅读