Dunwu Blog

大道至简,知易行难

JMH 快速入门

基准测试简介

什么是基准测试

基准测试是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。

现代软件常常都把高性能作为目标。那么,何为高性能,性能就是快,更快吗?显然,如果没有一个量化的标准,难以衡量性能的好坏。

不同的基准测试其具体内容和范围也存在很大的不同。如果是专业的性能工程师,更加熟悉的可能是类似 SPEC 提供的工业标准的系统级测试;而对于大多数 Java 开发者,更熟悉的则是范围相对较小、关注点更加细节的微基准测试(Micro-Benchmark)。何谓 Micro Benchmark 呢? 简单地说就是在 method 层面上的 benchmark,精度可以精确到 微秒级

何时需要微基准测试

微基准测试大多是 API 级别的性能测试。

微基准测试的适用场景:

  • 如果开发公共类库、中间件,会被其他模块经常调用的 API。
  • 对于性能,如响应延迟、吞吐量有严格要求的核心 API。

JMH 简介

JMH(即 Java Microbenchmark Harness),是目前主流的微基准测试框架。JMH 是由 Hotspot JVM 团队专家开发的,除了支持完整的基准测试过程,包括预热、运行、统计和报告等,还支持 Java 和其他 JVM 语言。更重要的是,它针对 Hotspot JVM 提供了各种特性,以保证基准测试的正确性,整体准确性大大优于其他框架,并且,JMH 还提供了用近乎白盒的方式进行 Profiling 等工作的能力。

为什么需要 JMH

死码消除

所谓死码,是指注释的代码,不可达的代码块,可达但不被使用的代码等等 。

常量折叠与常量传播

常量折叠 (Constant folding) 是一个在编译时期简化常数的一个过程,常数在表示式中仅仅代表一个简单的数值,就像是整数 2,若是一个变数从未被修改也可作为常数,或者直接将一个变数被明确地被标注为常数,例如下面的描述:

JMH 的注意点

  • 测试前需要预热。
  • 防止无用代码进入测试方法中。
  • 并发测试。
  • 测试结果呈现。

应用场景

  1. 当你已经找出了热点函数,而需要对热点函数进行进一步的优化时,就可以使用 JMH 对优化的效果进行定量的分析。
  2. 想定量地知道某个函数需要执行多长时间,以及执行时间和输入 n 的相关性
  3. 一个函数有两种不同实现(例如 JSON 序列化/反序列化有 Jackson 和 Gson 实现),不知道哪种实现性能更好

JMH 概念

  • Iteration - iteration 是 JMH 进行测试的最小单位,包含一组 invocations。
  • Invocation - 一次 benchmark 方法调用。
  • Operation - benchmark 方法中,被测量操作的执行。如果被测试的操作在 benchmark 方法中循环执行,可以使用@OperationsPerInvocation表明循环次数,使测试结果为单次 operation 的性能。
  • Warmup - 在实际进行 benchmark 前先进行预热。因为某个函数被调用多次之后,JIT 会对其进行编译,通过预热可以使测量结果更加接近真实情况。

JMH 快速入门

添加 maven 依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.*;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(8)
@Fork(2)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class StringBuilderBenchmark {

@Benchmark
public void testStringAdd() {
String a = "";
for (int i = 0; i < 10; i++) {
a += i;
}
// System.out.println(a);
}

@Benchmark
public void testStringBuilderAdd() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
// System.out.println(sb.toString());
}

public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringBuilderBenchmark.class.getSimpleName())
.output("d:/Benchmark.log")
.build();
new Runner(options).run();
}

}

执行 JMH

命令行

(1)初始化 benchmarking 工程

1
2
3
4
5
6
7
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0

(2)构建 benchmark

1
2
cd test/
mvn clean install

(3)运行 benchmark

1
java -jar target/benchmarks.jar

执行 main 方法

执行 main 方法,耐心等待测试结果,最终会生成一个测试报告,内容大致如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# JMH version: 1.22
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: C:\Program Files\Java\jdk1.8.0_181\jre\bin\java.exe
# VM options: -javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\lib\idea_rt.jar=58635:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each
# Measurement: 10 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringAdd

# Run progress: 0.00% complete, ETA 00:05:20
# Fork: 1 of 2
# Warmup Iteration 1: 21803.050 ops/ms
# Warmup Iteration 2: 22501.860 ops/ms
# Warmup Iteration 3: 20953.944 ops/ms
Iteration 1: 21627.645 ops/ms
Iteration 2: 21215.269 ops/ms
Iteration 3: 20863.282 ops/ms
Iteration 4: 21617.715 ops/ms
Iteration 5: 21695.645 ops/ms
Iteration 6: 21886.784 ops/ms
Iteration 7: 21986.899 ops/ms
Iteration 8: 22389.540 ops/ms
Iteration 9: 22507.313 ops/ms
Iteration 10: 22124.133 ops/ms

# Run progress: 25.00% complete, ETA 00:04:02
# Fork: 2 of 2
# Warmup Iteration 1: 22262.108 ops/ms
# Warmup Iteration 2: 21567.804 ops/ms
# Warmup Iteration 3: 21787.002 ops/ms
Iteration 1: 21598.970 ops/ms
Iteration 2: 22486.133 ops/ms
Iteration 3: 22157.834 ops/ms
Iteration 4: 22321.827 ops/ms
Iteration 5: 22477.063 ops/ms
Iteration 6: 22154.760 ops/ms
Iteration 7: 21561.095 ops/ms
Iteration 8: 22194.863 ops/ms
Iteration 9: 22493.844 ops/ms
Iteration 10: 22568.078 ops/ms


Result "io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringAdd":
21996.435 ±(99.9%) 412.955 ops/ms [Average]
(min, avg, max) = (20863.282, 21996.435, 22568.078), stdev = 475.560
CI (99.9%): [21583.480, 22409.390] (assumes normal distribution)


# JMH version: 1.22
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: C:\Program Files\Java\jdk1.8.0_181\jre\bin\java.exe
# VM options: -javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\lib\idea_rt.jar=58635:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each
# Measurement: 10 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringBuilderAdd

# Run progress: 50.00% complete, ETA 00:02:41
# Fork: 1 of 2
# Warmup Iteration 1: 241500.886 ops/ms
# Warmup Iteration 2: 134206.032 ops/ms
# Warmup Iteration 3: 86907.846 ops/ms
Iteration 1: 86143.339 ops/ms
Iteration 2: 74725.356 ops/ms
Iteration 3: 72316.121 ops/ms
Iteration 4: 77319.716 ops/ms
Iteration 5: 83469.256 ops/ms
Iteration 6: 87712.360 ops/ms
Iteration 7: 79421.899 ops/ms
Iteration 8: 80867.839 ops/ms
Iteration 9: 82619.163 ops/ms
Iteration 10: 87026.928 ops/ms

# Run progress: 75.00% complete, ETA 00:01:20
# Fork: 2 of 2
# Warmup Iteration 1: 228342.337 ops/ms
# Warmup Iteration 2: 124737.248 ops/ms
# Warmup Iteration 3: 82598.851 ops/ms
Iteration 1: 86877.318 ops/ms
Iteration 2: 89388.624 ops/ms
Iteration 3: 88523.558 ops/ms
Iteration 4: 87547.332 ops/ms
Iteration 5: 88376.087 ops/ms
Iteration 6: 88848.837 ops/ms
Iteration 7: 85998.124 ops/ms
Iteration 8: 86796.998 ops/ms
Iteration 9: 87994.726 ops/ms
Iteration 10: 87784.453 ops/ms


Result "io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringBuilderAdd":
84487.902 ±(99.9%) 4355.525 ops/ms [Average]
(min, avg, max) = (72316.121, 84487.902, 89388.624), stdev = 5015.829
CI (99.9%): [80132.377, 88843.427] (assumes normal distribution)


# Run complete. Total time: 00:05:23

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

Benchmark Mode Cnt Score Error Units
StringBuilderBenchmark.testStringAdd thrpt 20 21996.435 ± 412.955 ops/ms
StringBuilderBenchmark.testStringBuilderAdd thrpt 20 84487.902 ± 4355.525 ops/ms

JMH API

下面来了解一下 jmh 常用 API

@BenchmarkMode

基准测试类型。这里选择的是 Throughput 也就是吞吐量。根据源码点进去,每种类型后面都有对应的解释,比较好理解,吞吐量会得到单位时间内可以进行的操作数。

  • Throughput - 整体吞吐量,例如“1 秒内可以执行多少次调用”。
  • AverageTime - 调用的平均时间,例如“每次调用平均耗时 xxx 毫秒”。
  • SampleTime - 随机取样,最后输出取样结果的分布,例如“99%的调用在 xxx 毫秒以内,99.99%的调用在 xxx 毫秒以内”
  • SingleShotTime - 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为 0,用于测试冷启动时的性能。
  • All - 所有模式

@Warmup

上面我们提到了,进行基准测试前需要进行预热。一般我们前几次进行程序测试的时候都会比较慢, 所以要让程序进行几轮预热,保证测试的准确性。其中的参数 iterations 也就非常好理解了,就是预热轮数。

为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

@Measurement

度量,其实就是一些基本的测试参数。

  • iterations - 进行测试的轮次
  • time - 每轮进行的时长
  • timeUnit - 时长单位

都是一些基本的参数,可以根据具体情况调整。一般比较重的东西可以进行大量的测试,放到服务器上运行。

@Threads

每个进程中的测试线程,这个非常好理解,根据具体情况选择,一般为 cpu 乘以 2。

@Fork

进行 fork 的次数。如果 fork 数是 2 的话,则 JMH 会 fork 出两个进程来进行测试。

@OutputTimeUnit

这个比较简单了,基准测试结果的时间类型。一般选择秒、毫秒、微秒。

@Benchmark

方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。

@Param

属性级注解,@Param 可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。

@Setup

方法级注解,这个注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的。

@TearDown

方法级注解,这个注解的作用就是我们需要在测试之后进行一些结束工作,比如关闭线程池,数据库连接等的,主要用于资源的回收等。

@State

当使用 @Setup 参数的时候,必须在类上加这个参数,不然会提示无法运行。

State 用于声明某个类是一个“状态”,然后接受一个 Scope 参数用来表示该状态的共享范围。 因为很多 benchmark 会需要一些表示状态的类,JMH 允许你把这些类以依赖注入的方式注入到 benchmark 函数里。Scope 主要分为三种。

  • Thread - 该状态为每个线程独享。
  • Group - 该状态为同一个组里面所有线程共享。
  • Benchmark - 该状态在所有线程间共享。

关于 State 的用法,官方的 code sample 里有比较好的例子

参考资料

细说 Java 主流日志工具库

在项目开发中,为了跟踪代码的运行情况,常常要使用日志来记录信息。

在 Java 世界,有很多的日志工具库来实现日志功能,避免了我们重复造轮子。

我们先来逐一了解一下主流日志工具。

日志框架

java.util.logging (JUL)

JDK1.4 开始,通过 java.util.logging 提供日志功能。

它能满足基本的日志需要,但是功能没有 Log4j 强大,而且使用范围也没有 Log4j 广泛。

Log4j

Log4j 是 apache 的一个开源项目,创始人 Ceki Gulcu。

Log4j 应该说是 Java 领域资格最老,应用最广的日志工具。从诞生之日到现在一直广受业界欢迎。

Log4j 是高度可配置的,并可通过在运行时的外部文件配置。它根据记录的优先级别,并提供机制,以指示记录信息到许多的目的地,诸如:数据库,文件,控制台,UNIX 系统日志等。

Log4j 中有三个主要组成部分:

  • loggers - 负责捕获记录信息。
  • appenders - 负责发布日志信息,以不同的首选目的地。
  • layouts - 负责格式化不同风格的日志信息。

官网地址

Logback

Logback 是由 log4j 创始人 Ceki Gulcu 设计的又一个开源日记组件,目标是替代 log4j。

logback 当前分成三个模块:logback-corelogback-classiclogback-access

  • logback-core - 是其它两个模块的基础模块。
  • logback-classic - 是 log4j 的一个 改良版本。此外 logback-classic 完整实现 SLF4J API 使你可以很方便地更换成其它日记系统如 log4j 或 JDK14 Logging。
  • logback-access - 访问模块与 Servlet 容器集成提供通过 Http 来访问日记的功能。

官网地址

Log4j2

官网地址

按照官方的说法,Log4j2 是 Log4j 和 Logback 的替代。

Log4j2 架构:

img

Log4j vs Logback vs Log4j2

按照官方的说法,Log4j2 大大优于 Log4j 和 Logback。

那么,Log4j2 相比于先问世的 Log4j 和 Logback,它具有哪些优势呢?

  1. Log4j2 旨在用作审计日志记录框架。 Log4j 1.x 和 Logback 都会在重新配置时丢失事件。 Log4j 2 不会。在 Logback 中,Appender 中的异常永远不会对应用程序可见。在 Log4j 中,可以将 Appender 配置为允许异常渗透到应用程序。
  2. Log4j2 在多线程场景中,异步 Loggers 的吞吐量比 Log4j 1.x 和 Logback 高 10 倍,延迟低几个数量级。
  3. Log4j2 对于独立应用程序是无垃圾的,对于稳定状态日志记录期间的 Web 应用程序来说是低垃圾。这减少了垃圾收集器的压力,并且可以提供更好的响应时间性能。
  4. Log4j2 使用插件系统,通过添加新的 Appender、Filter、Layout、Lookup 和 Pattern Converter,可以非常轻松地扩展框架,而无需对 Log4j 进行任何更改。
  5. 由于插件系统配置更简单。配置中的条目不需要指定类名。
  6. 支持自定义日志等级
  7. 支持 lambda 表达式
  8. 支持消息对象
  9. Log4j 和 Logback 的 Layout 返回的是字符串,而 Log4j2 返回的是二进制数组,这使得它能被各种 Appender 使用。
  10. Syslog Appender 支持 TCP 和 UDP 并且支持 BSD 系统日志。
  11. Log4j2 利用 Java5 并发特性,尽量小粒度的使用锁,减少锁的开销。

日志门面

何谓日志门面?

日志门面是对不同日志框架提供的一个 API 封装,可以在部署的时候不修改任何配置即可接入一种日志实现方案。

common-logging

common-logging 是 apache 的一个开源项目。也称Jakarta Commons Logging,缩写 JCL

common-logging 的功能是提供日志功能的 API 接口,本身并不提供日志的具体实现(当然,common-logging 内部有一个 Simple logger 的简单实现,但是功能很弱,直接忽略),而是在运行时动态的绑定日志实现组件来工作(如 log4j、java.util.loggin)。

官网地址

slf4j

全称为 Simple Logging Facade for Java,即 java 简单日志门面。

什么,作者又是 Ceki Gulcu!这位大神写了 Log4j、Logback 和 slf4j,专注日志组件开发五百年,一直只能超越自己。

类似于 Common-Logging,slf4j 是对不同日志框架提供的一个 API 封装,可以在部署的时候不修改任何配置即可接入一种日志实现方案。但是,slf4j 在编译时静态绑定真正的 Log 库。使用 SLF4J 时,如果你需要使用某一种日志实现,那么你必须选择正确的 SLF4J 的 jar 包的集合(各种桥接包)。

官网地址

img

common-logging vs slf4j

slf4j 库类似于 Apache Common-Logging。但是,他在编译时静态绑定真正的日志库。这点似乎很麻烦,其实也不过是导入桥接 jar 包而已。

slf4j 一大亮点是提供了更方便的日志记录方式:

不需要使用logger.isDebugEnabled()来解决日志因为字符拼接产生的性能问题。slf4j 的方式是使用{}作为字符串替换符,形式如下:

1
logger.debug("id: {}, name: {} ", id, name);

总结

综上所述,使用 slf4j + Logback 可谓是目前最理想的日志解决方案了。

接下来,就是如何在项目中实施了。

实施日志解决方案

使用日志解决方案基本可分为三步:

  1. 引入 jar 包
  2. 配置
  3. 使用 API

常见的各种日志解决方案的第 2 步和第 3 步基本一样,实施上的差别主要在第 1 步,也就是使用不同的库。

引入 jar 包

这里首选推荐使用 slf4j + logback 的组合。

如果你习惯了 common-logging,可以选择 common-logging+log4j。

强烈建议不要直接使用日志实现组件(logback、log4j、java.util.logging),理由前面也说过,就是无法灵活替换日志库。

还有一种情况:你的老项目使用了 common-logging,或是直接使用日志实现组件。如果修改老的代码,工作量太大,需要兼容处理。在下文,都将看到各种应对方法。

注:据我所知,当前仍没有方法可以将 slf4j 桥接到 common-logging。如果我孤陋寡闻了,请不吝赐教。

slf4j 直接绑定日志组件

slf4j + logback

添加依赖到 pom.xml 中即可。

logback-classic-1.0.13.jar 会自动将 slf4j-api-1.7.21.jarlogback-core-1.0.13.jar 也添加到你的项目中。

1
2
3
4
5
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.0.13</version>
</dependency>

slf4j + log4j

添加依赖到 pom.xml 中即可。

slf4j-log4j12-1.7.21.jar 会自动将 slf4j-api-1.7.21.jarlog4j-1.2.17.jar 也添加到你的项目中。

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.21</version>
</dependency>

slf4j + java.util.logging

添加依赖到 pom.xml 中即可。

slf4j-jdk14-1.7.21.jar 会自动将 slf4j-api-1.7.21.jar 也添加到你的项目中。

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>1.7.21</version>
</dependency>

slf4j 兼容非 slf4j 日志组件

在介绍解决方案前,先提一个概念——桥接

什么是桥接呢

假如你正在开发应用程序所调用的组件当中已经使用了 common-logging,这时你需要 jcl-over-slf4j.jar 把日志信息输出重定向到 slf4j-api,slf4j-api 再去调用 slf4j 实际依赖的日志组件。这个过程称为桥接。下图是官方的 slf4j 桥接策略图:

img

从图中应该可以看出,无论你的老项目中使用的是 common-logging 或是直接使用 log4j、java.util.logging,都可以使用对应的桥接 jar 包来解决兼容问题。

slf4j 兼容 common-logging

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>1.7.12</version>
</dependency>

slf4j 兼容 log4j

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>log4j-over-slf4j</artifactId>
<version>1.7.12</version>
</dependency>

slf4j 兼容 java.util.logging

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.7.12</version>
</dependency>

spring 集成 slf4j

做 java web 开发,基本离不开 spring 框架。很遗憾,spring 使用的日志解决方案是 common-logging + log4j。

所以,你需要一个桥接 jar 包:_logback-ext-spring_。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.1.3</version>
</dependency>
<dependency>
<groupId>org.logback-extensions</groupId>
<artifactId>logback-ext-spring</artifactId>
<version>0.1.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>1.7.12</version>
</dependency>

common-logging 绑定日志组件

common-logging + log4j

添加依赖到 pom.xml 中即可。

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>

使用 API

slf4j 用法

使用 slf4j 的 API 很简单。使用LoggerFactory初始化一个Logger实例,然后调用 Logger 对应的打印等级函数就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
private static final Logger log = LoggerFactory.getLogger(App.class);
public static void main(String[] args) {
String msg = "print log, current level: {}";
log.trace(msg, "trace");
log.debug(msg, "debug");
log.info(msg, "info");
log.warn(msg, "warn");
log.error(msg, "error");
}
}

common-logging 用法

common-logging 用法和 slf4j 几乎一样,但是支持的打印等级多了一个更高级别的:fatal

此外,common-logging 不支持{}替换参数,你只能选择拼接字符串这种方式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class JclTest {
private static final Log log = LogFactory.getLog(JclTest.class);

public static void main(String[] args) {
String msg = "print log, current level: ";
log.trace(msg + "trace");
log.debug(msg + "debug");
log.info(msg + "info");
log.warn(msg + "warn");
log.error(msg + "error");
log.fatal(msg + "fatal");
}
}

log4j2 配置

log4j2 基本配置形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>;
<Configuration>
<Properties>
<Property name="name1">value</property>
<Property name="name2" value="value2"/>
</Properties>
<Filter type="type" ... />
<Appenders>
<Appender type="type" name="name">
<Filter type="type" ... />
</Appender>
...
</Appenders>
<Loggers>
<Logger name="name1">
<Filter type="type" ... />
</Logger>
...
<Root level="level">
<AppenderRef ref="name"/>
</Root>
</Loggers>
</Configuration>

配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="debug" strict="true" name="XMLConfigTest"
packages="org.apache.logging.log4j.test">
<Properties>
<Property name="filename">target/test.log</Property>
</Properties>
<Filter type="ThresholdFilter" level="trace"/>

<Appenders>
<Appender type="Console" name="STDOUT">
<Layout type="PatternLayout" pattern="%m MDC%X%n"/>
<Filters>
<Filter type="MarkerFilter" marker="FLOW" onMatch="DENY" onMismatch="NEUTRAL"/>
<Filter type="MarkerFilter" marker="EXCEPTION" onMatch="DENY" onMismatch="ACCEPT"/>
</Filters>
</Appender>
<Appender type="Console" name="FLOW">
<Layout type="PatternLayout" pattern="%C{1}.%M %m %ex%n"/><!-- class and line number -->
<Filters>
<Filter type="MarkerFilter" marker="FLOW" onMatch="ACCEPT" onMismatch="NEUTRAL"/>
<Filter type="MarkerFilter" marker="EXCEPTION" onMatch="ACCEPT" onMismatch="DENY"/>
</Filters>
</Appender>
<Appender type="File" name="File" fileName="${filename}">
<Layout type="PatternLayout">
<Pattern>%d %p %C{1.} [%t] %m%n</Pattern>
</Layout>
</Appender>
</Appenders>

<Loggers>
<Logger name="org.apache.logging.log4j.test1" level="debug" additivity="false">
<Filter type="ThreadContextMapFilter">
<KeyValuePair key="test" value="123"/>
</Filter>
<AppenderRef ref="STDOUT"/>
</Logger>

<Logger name="org.apache.logging.log4j.test2" level="debug" additivity="false">
<AppenderRef ref="File"/>
</Logger>

<Root level="trace">
<AppenderRef ref="STDOUT"/>
</Root>
</Loggers>

</Configuration>

logback 配置

<configuration>

  • 作用:<configuration> 是 logback 配置文件的根元素。
  • 要点
    • 它有 <appender><logger><root> 三个子元素。

img

<appender>

  • 作用:将记录日志的任务委托给名为 appender 的组件。
  • 要点
    • 可以配置零个或多个。
    • 它有 <file><filter><layout><encoder> 四个子元素。
  • 属性
    • name:设置 appender 名称。
    • class:设置具体的实例化类。

<file>

  • 作用:设置日志文件路径。

<filter>

  • 作用:设置过滤器。
  • 要点
    • 可以配置零个或多个。

<layout>

  • 作用:设置 appender。
  • 要点
    • 可以配置零个或一个。
  • 属性
    • class:设置具体的实例化类。

<encoder>

  • 作用:设置编码。
  • 要点
    • 可以配置零个或多个。
  • 属性
    • class:设置具体的实例化类。

img

<logger>

  • 作用:设置 logger。
  • 要点
    • 可以配置零个或多个。
  • 属性
    • name
    • level:设置日志级别。不区分大小写。可选值:TRACE、DEBUG、INFO、WARN、ERROR、ALL、OFF。
    • additivity:可选值:true 或 false。

<appender-ref>

  • 作用:appender 引用。
  • 要点
    • 可以配置零个或多个。

<root>

  • 作用:设置根 logger。
  • 要点
    • 只能配置一个。
    • 除了 level,不支持任何属性。level 属性和 <logger> 中的相同。
    • 有一个子元素 <appender-ref>,与 <logger> 中的相同。

完整的 logback.xml 参考示例

在下面的配置文件中,我为自己的项目代码(根目录:org.zp.notes.spring)设置了五种等级:

TRACE、DEBUG、INFO、WARN、ERROR,优先级依次从低到高。

因为关注 spring 框架本身的一些信息,我增加了专门打印 spring WARN 及以上等级的日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
<?xml version="1.0" encoding="UTF-8" ?>

<!-- logback中一共有5种有效级别,分别是TRACE、DEBUG、INFO、WARN、ERROR,优先级依次从低到高 -->
<configuration scan="true" scanPeriod="60 seconds" debug="false">

<property name="DIR_NAME" value="spring-helloworld"/>

<!-- 将记录日志打印到控制台 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<!-- RollingFileAppender begin -->
<appender name="ALL" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/all.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>30MB</maxFileSize>
</triggeringPolicy>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/warn.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/debug.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="TRACE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/trace.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>TRACE</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>

<appender name="SPRING" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 根据时间来制定滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.dir}/logs/${DIR_NAME}/springframework.%d{yyyy-MM-dd}.log
</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>

<!-- 根据文件大小来制定滚动策略 -->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>

<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%-5p] %c{36}.%M - %m%n</pattern>
</encoder>
</appender>
<!-- RollingFileAppender end -->

<!-- logger begin -->
<!-- 本项目的日志记录,分级打印 -->
<logger name="org.zp.notes.spring" level="TRACE" additivity="false">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ERROR"/>
<appender-ref ref="WARN"/>
<appender-ref ref="INFO"/>
<appender-ref ref="DEBUG"/>
<appender-ref ref="TRACE"/>
</logger>

<!-- SPRING框架日志 -->
<logger name="org.springframework" level="WARN" additivity="false">
<appender-ref ref="SPRING"/>
</logger>

<root level="TRACE">
<appender-ref ref="ALL"/>
</root>
<!-- logger end -->

</configuration>

log4j 配置

完整的 log4j.xml 参考示例

log4j 的配置文件一般有 xml 格式或 properties 格式。这里为了和 logback.xml 做个对比,就不介绍 properties 了,其实也没太大差别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE log4j:configuration SYSTEM "log4j.dtd">

<log4j:configuration xmlns:log4j='http://jakarta.apache.org/log4j/'>

<appender name="STDOUT" class="org.apache.log4j.ConsoleAppender">
<layout class="org.apache.log4j.PatternLayout">
<param name="ConversionPattern"
value="%d{yyyy-MM-dd HH:mm:ss,SSS\} [%-5p] [%t] %c{36\}.%M - %m%n"/>
</layout>

<!--过滤器设置输出的级别-->
<filter class="org.apache.log4j.varia.LevelRangeFilter">
<param name="levelMin" value="debug"/>
<param name="levelMax" value="fatal"/>
<param name="AcceptOnMatch" value="true"/>
</filter>
</appender>


<appender name="ALL" class="org.apache.log4j.DailyRollingFileAppender">
<param name="File" value="${user.dir}/logs/spring-common/jcl/all"/>
<param name="Append" value="true"/>
<!-- 每天重新生成日志文件 -->
<param name="DatePattern" value="'-'yyyy-MM-dd'.log'"/>
<!-- 每小时重新生成日志文件 -->
<!--<param name="DatePattern" value="'-'yyyy-MM-dd-HH'.log'"/>-->
<layout class="org.apache.log4j.PatternLayout">
<param name="ConversionPattern"
value="%d{yyyy-MM-dd HH:mm:ss,SSS\} [%-5p] [%t] %c{36\}.%M - %m%n"/>
</layout>
</appender>

<!-- 指定logger的设置,additivity指示是否遵循缺省的继承机制-->
<logger name="org.zp.notes.spring" additivity="false">
<level value="error"/>
<appender-ref ref="STDOUT"/>
<appender-ref ref="ALL"/>
</logger>

<!-- 根logger的设置-->
<root>
<level value="warn"/>
<appender-ref ref="STDOUT"/>
</root>
</log4j:configuration>

参考

Reflections 快速入门

引入 pom

1
2
3
4
5
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.9.11</version>
</dependency>

典型应用

1
2
3
Reflections reflections = new Reflections("my.project");
Set<Class<? extends SomeType>> subTypes = reflections.getSubTypesOf(SomeType.class);
Set<Class<?>> annotated = reflections.getTypesAnnotatedWith(SomeAnnotation.class);

使用

基本上,使用 Reflections 首先使用 urls 和 scanners 对其进行实例化

1
2
3
4
5
6
7
8
9
10
//scan urls that contain 'my.package', include inputs starting with 'my.package', use the default scanners
Reflections reflections = new Reflections("my.package");

//or using ConfigurationBuilder
new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage("my.project.prefix"))
.setScanners(new SubTypesScanner(),
new TypeAnnotationsScanner().filterResultsBy(optionalFilter), ...),
.filterInputsBy(new FilterBuilder().includePackage("my.project.prefix"))
...);

然后,使用方便的查询方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 子类型扫描
Set<Class<? extends Module>> modules =
reflections.getSubTypesOf(com.google.inject.Module.class);
// 类型注解扫描
Set<Class<?>> singletons =
reflections.getTypesAnnotatedWith(javax.inject.Singleton.class);
// 资源扫描
Set<String> properties =
reflections.getResources(Pattern.compile(".*\\.properties"));
// 方法注解扫描
Set<Method> resources =
reflections.getMethodsAnnotatedWith(javax.ws.rs.Path.class);
Set<Constructor> injectables =
reflections.getConstructorsAnnotatedWith(javax.inject.Inject.class);
// 字段注解扫描
Set<Field> ids =
reflections.getFieldsAnnotatedWith(javax.persistence.Id.class);
// 方法参数扫描
Set<Method> someMethods =
reflections.getMethodsMatchParams(long.class, int.class);
Set<Method> voidMethods =
reflections.getMethodsReturn(void.class);
Set<Method> pathParamMethods =
reflections.getMethodsWithAnyParamAnnotated(PathParam.class);
// 方法参数名扫描
List<String> parameterNames =
reflections.getMethodParamNames(Method.class)
// 方法使用扫描
Set<Member> usages =
reflections.getMethodUsages(Method.class)

说明:

  • 如果未配置扫描程序,则将使用默认值 - SubTypesScanner 和 TypeAnnotationsScanner。
  • 还可以配置类加载器,它将用于从名称中解析运行时类。
  • Reflection 默认情况下会扩展超类型。 这解决了传输 URL 不被扫描的一些问题。

ReflectionUtils

1
2
3
4
5
6
7
8
9
10
11
import static org.reflections.ReflectionUtils.*;

Set<Method> getters = getAllMethods(someClass,
withModifier(Modifier.PUBLIC), withPrefix("get"), withParametersCount(0));

//or
Set<Method> listMethodsFromCollectionToBoolean =
getAllMethods(List.class,
withParametersAssignableTo(Collection.class), withReturnType(boolean.class));

Set<Field> fields = getAllFields(SomeClass.class, withAnnotation(annotation), withTypeAssignableTo(type));

JavaMail 快速入门

简介

邮件相关的标准

厂商所提供的 JavaMail 服务程序可以有选择地实现某些邮件协议,常见的邮件协议包括:

  • SMTP(Simple Mail Transfer Protocol) :即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。
  • POP3(Post Office Protocol - Version 3) :即邮局协议版本 3 ,用于接收电子邮件的标准协议。
  • IMAP(Internet Mail Access Protocol) :即 Internet 邮件访问协议。是 POP3 的替代协议。

这三种协议都有对应 SSL 加密传输的协议,分别是 SMTPSPOP3SIMAPS

MIME(Multipurpose Internet Mail Extensions) :即多用途因特网邮件扩展标准。它不是邮件传输协议。但对传输内容的消息、附件及其它的内容定义了格式。

JavaMail 简介

JavaMail 是由 Sun 发布的用来处理 email 的 API 。它并没有包含在 Java SE 中,而是作为 Java EE 的一部分。

  • mail.jar :此 JAR 文件包含 JavaMail API 和 Sun 提供的 SMTP 、 IMAP 和 POP3 服务提供程序;
  • activation.jar :此 JAR 文件包含 JAF API 和 Sun 的实现。

JavaMail 包中用于处理电子邮件的核心类是: PropertiesSessionMessageAddressAuthenticatorTransportStore 等。

邮件传输过程

如上图,电子邮件的处理步骤如下:

  1. 创建一个 Session 对象。
  2. Session 对象创建一个 Transport 对象 /Store 对象,用来发送 / 保存邮件。
  3. Transport 对象 /Store 对象连接邮件服务器。
  4. Transport 对象 /Store 对象创建一个 Message 对象 ( 也就是邮件内容 ) 。
  5. Transport 对象发送邮件; Store 对象获取邮箱的邮件。

Message 结构

  • MimeMessage 类:代表整封邮件。
  • MimeBodyPart 类:代表邮件的一个 MIME 信息。
  • MimeMultipart 类:代表一个由多个 MIME 信息组合成的组合 MIME 信息。

img

JavaMail 的核心类

JavaMail 对收发邮件进行了高级的抽象,形成了一些关键的的接口和类,它们构成了程序的基础,下面我们分别来了解一下这些最常见的对象。

java.util.Properties 类(属性对象)

java.util.Properties 类代表一组属性集合。

它的每一个键和值都是 String 类型。

由于 JavaMail 需要和邮件服务器进行通信,这就要求程序提供许多诸如服务器地址、端口、用户名、密码等信息, JavaMail 通过 Properties 对象封装这些属性信息。

例: 如下面的代码封装了几个属性信息:

1
2
3
4
5
Properties prop = new Properties();
prop.setProperty("mail.debug", "true");
prop.setProperty("mail.host", "[email protected]");
prop.setProperty("mail.transport.protocol", "smtp");
prop.setProperty("mail.smtp.auth", "true");

针对不同的的邮件协议, JavaMail 规定了服务提供者必须支持一系列属性,

下表是一些常见属性(属性值都以 String 类型进行设置,属性类型栏仅表示属性是如何被解析的):

关键词 类型 描述
mail.debug boolean debug 开关。
mail.host String 指定发送、接收邮件的默认邮箱服务器。
mail.store.protocol String 指定接收邮件的协议。
mail.transport.protocol String 指定发送邮件的协议。
mail.debug.auth boolean debug 输出中是否包含认证命令。默认是 false 。

详情请参考官方 API 文档:

https://javamail.java.net/nonav/docs/api/

javax.mail.Session 类(会话对象)

Session 表示一个邮件会话。

Session 的主要作用包括两个方面:

  • 接收各种配置属性信息:通过 Properties 对象设置的属性信息;
  • 初始化 JavaMail 环境:根据 JavaMail 的配置文件,初始化 JavaMail 环境,以便通过 Session 对象创建其他重要类的实例。

JavaMail 在 Jar 包的 META-INF 目录下,通过以下文件提供了基本配置信息,以便 session 能够根据这个配置文件加载提供者的实现类:

  • javamail.default.providers
  • javamail.default.address.map

img

例:

1
2
3
Properties props = new Properties();
props.setProperty("mail.transport.protocol", "smtp");
Session session = Session.getInstance(props);

javax.mail.Transport 类(邮件传输)

邮件操作只有发送或接收两种处理方式。

JavaMail 将这两种不同操作描述为传输( javax.mail.Transport )和存储( javax.mail.Store ),传输对应邮件的发送,而存储对应邮件的接收。

  • getTransport - Session 类中的 **getTransport()**有多个重载方法,可以用来创建 Transport 对象。
  • connect - 如果设置了认证命令—— mail.smtp.auth ,那么使用 Transport 类的 connect 方法连接服务器时,则必须加上用户名和密码。
  • sendMessage - Transport 类的 sendMessage 方法用来发送邮件消息。
  • close - Transport 类的 close 方法用来关闭和邮件服务器的连接。

javax.mail.Store 类(邮件存储 )

  • getStore - Session 类中的 getStore () 有多个重载方法,可以用来创建 Store 对象。
  • connect - 如果设置了认证命令—— mail.smtp.auth ,那么使用 Store 类的 connect 方法连接服务器时,则必须加上用户名和密码。
  • getFolder - Store 类的 getFolder 方法可以 获取邮箱内的邮件夹 Folder 对象
  • close - Store 类的 close 方法用来关闭和邮件服务器的连接。

javax.mail.Message 类(消息对象)

  • javax.mail.Message - 是个抽象类,只能用子类去实例化,多数情况下为 javax.mail.internet.MimeMessage
  • MimeMessage - 代表 MIME 类型的电子邮件消息。

要创建一个 Message ,需要将 Session 对象传递给 MimeMessage 构造器:

1
MimeMessage message = new MimeMessage(session);

注意:还存在其它构造器,如用按 RFC822 格式的输入流来创建消息。

  • setFrom - 设置邮件的发件人
  • setRecipient - 设置邮件的发送人、抄送人、密送人

三种预定义的地址类型是:

  • Message.RecipientType.TO - 收件人
  • Message.RecipientType.CC - 抄送人
  • Message.RecipientType.BCC - 密送人
  • setSubject - 设置邮件的主题
  • setContent - 设置邮件内容
  • setText - 如果邮件内容是纯文本,可以使用此接口设置文本内容。

javax.mail.Address 类(地址)

一旦您创建了 Session 和 Message ,并将内容填入消息后,就可以用 Address 确定信件地址了。和 Message 一样, Address 也是个抽象类。您用的是 javax.mail.internet.InternetAddress 类。

若创建的地址只包含电子邮件地址,只要传递电子邮件地址到构造器就行了。

例:

1
Address address = new InternetAddress("[email protected]");

Authenticator 类(认证者)

与 java.net 类一样, JavaMail API 也可以利用 Authenticator 通过用户名和密码访问受保护的资源。对于 JavaMail API 来说,这些资源就是邮件服务器。Authenticator 在 javax.mail 包中,而且它和 java.net 中同名的类 Authenticator 不同。两者并不共享同一个 Authenticator ,因为 JavaMail API 用于 Java 1.1 ,它没有 java.net 类别。

要使用 Authenticator ,先创建一个抽象类的子类,并从 getPasswordAuthentication() 方法中返回 PasswordAuthentication 实例。创建完成后,您必需向 session 注册 Authenticator 。然后,在需要认证的时候,就会通知 Authenticator 。您可以弹出窗口,也可以从配置文件中(虽然没有加密是不安全的)读取用户名和密码,将它们作为 PasswordAuthentication 对象返回给调用程序。

例:

1
2
3
Properties props = new Properties();
Authenticator auth = new MyAuthenticator();
Session session = Session.getDefaultInstance(props, auth);

实例

发送文本邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(String[] args) throws Exception {
Properties prop = new Properties();
prop.setProperty("mail.debug", "true");
prop.setProperty("mail.host", MAIL_SERVER_HOST);
prop.setProperty("mail.transport.protocol", "smtp");
prop.setProperty("mail.smtp.auth", "true");

// 1、创建session
Session session = Session.getInstance(prop);
Transport ts = null;

// 2、通过session得到transport对象
ts = session.getTransport();

// 3、连上邮件服务器
ts.connect(MAIL_SERVER_HOST, USER, PASSWORD);

// 4、创建邮件
MimeMessage message = new MimeMessage(session);

// 邮件消息头
message.setFrom(new InternetAddress(MAIL_FROM)); // 邮件的发件人
message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO)); // 邮件的收件人
message.setRecipient(Message.RecipientType.CC, new InternetAddress(MAIL_CC)); // 邮件的抄送人
message.setRecipient(Message.RecipientType.BCC, new InternetAddress(MAIL_BCC)); // 邮件的密送人
message.setSubject("测试文本邮件"); // 邮件的标题

// 邮件消息体
message.setText("天下无双。");

// 5、发送邮件
ts.sendMessage(message, message.getAllRecipients());
ts.close();
}

发送 HTML 格式的邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public static void main(String[] args) throws Exception {
Properties prop = new Properties();
prop.setProperty("mail.debug", "true");
prop.setProperty("mail.host", MAIL_SERVER_HOST);
prop.setProperty("mail.transport.protocol", "smtp");
prop.setProperty("mail.smtp.auth", "true");

// 1、创建session
Session session = Session.getInstance(prop);
Transport ts = null;

// 2、通过session得到transport对象
ts = session.getTransport();

// 3、连上邮件服务器
ts.connect(MAIL_SERVER_HOST, USER, PASSWORD);

// 4、创建邮件
MimeMessage message = new MimeMessage(session);

// 邮件消息头
message.setFrom(new InternetAddress(MAIL_FROM)); // 邮件的发件人
message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO)); // 邮件的收件人
message.setRecipient(Message.RecipientType.CC, new InternetAddress(MAIL_CC)); // 邮件的抄送人
message.setRecipient(Message.RecipientType.BCC, new InternetAddress(MAIL_BCC)); // 邮件的密送人
message.setSubject("测试HTML邮件"); // 邮件的标题

String htmlContent = "<h1>Hello</h1>" + "<p>显示图片<img src='cid:abc.jpg'>1.jpg</p>";
MimeBodyPart text = new MimeBodyPart();
text.setContent(htmlContent, "text/html;charset=UTF-8");
MimeBodyPart image = new MimeBodyPart();
DataHandler dh = new DataHandler(new FileDataSource("D:\\05_Datas\\图库\\吉他少年背影.png"));
image.setDataHandler(dh);
image.setContentID("abc.jpg");

// 描述数据关系
MimeMultipart mm = new MimeMultipart();
mm.addBodyPart(text);
mm.addBodyPart(image);
mm.setSubType("related");
message.setContent(mm);
message.saveChanges();

// 5、发送邮件
ts.sendMessage(message, message.getAllRecipients());
ts.close();
}

发送带附件的邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public static void main(String[] args) throws Exception {
Properties prop = new Properties();
prop.setProperty("mail.debug", "true");
prop.setProperty("mail.host", MAIL_SERVER_HOST);
prop.setProperty("mail.transport.protocol", "smtp");
prop.setProperty("mail.smtp.auth", "true");

// 1、创建session
Session session = Session.getInstance(prop);

// 2、通过session得到transport对象
Transport ts = session.getTransport();

// 3、连上邮件服务器
ts.connect(MAIL_SERVER_HOST, USER, PASSWORD);

// 4、创建邮件
MimeMessage message = new MimeMessage(session);

// 邮件消息头
message.setFrom(new InternetAddress(MAIL_FROM)); // 邮件的发件人
message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO)); // 邮件的收件人
message.setRecipient(Message.RecipientType.CC, new InternetAddress(MAIL_CC)); // 邮件的抄送人
message.setRecipient(Message.RecipientType.BCC, new InternetAddress(MAIL_BCC)); // 邮件的密送人
message.setSubject("测试带附件邮件"); // 邮件的标题

MimeBodyPart text = new MimeBodyPart();
text.setContent("邮件中有两个附件。", "text/html;charset=UTF-8");

// 描述数据关系
MimeMultipart mm = new MimeMultipart();
mm.setSubType("related");
mm.addBodyPart(text);
String[] files = {
"D:\\00_Temp\\temp\\1.jpg", "D:\\00_Temp\\temp\\2.png"
};

// 添加邮件附件
for (String filename : files) {
MimeBodyPart attachPart = new MimeBodyPart();
attachPart.attachFile(filename);
mm.addBodyPart(attachPart);
}

message.setContent(mm);
message.saveChanges();

// 5、发送邮件
ts.sendMessage(message, message.getAllRecipients());
ts.close();
}

获取邮箱中的邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 public static void main(String[] args) throws Exception {

// 创建一个有具体连接信息的Properties对象
Properties prop = new Properties();
prop.setProperty("mail.debug", "true");
prop.setProperty("mail.store.protocol", "pop3");
prop.setProperty("mail.pop3.host", MAIL_SERVER_HOST);

// 1、创建session
Session session = Session.getInstance(prop);

// 2、通过session得到Store对象
Store store = session.getStore();

// 3、连上邮件服务器
store.connect(MAIL_SERVER_HOST, USER, PASSWORD);

// 4、获得邮箱内的邮件夹
Folder folder = store.getFolder("inbox");
folder.open(Folder.READ_ONLY);

// 获得邮件夹Folder内的所有邮件Message对象
Message[] messages = folder.getMessages();
for (int i = 0; i < messages.length; i++) {
String subject = messages[i].getSubject();
String from = (messages[i].getFrom()[0]).toString();
System.out.println("第 " + (i + 1) + "封邮件的主题:" + subject);
System.out.println("第 " + (i + 1) + "封邮件的发件人地址:" + from);
}

// 5、关闭
folder.close(false);
store.close();
}

转发邮件

例:获取指定邮件夹下的第一封邮件并转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
 public static void main(String[] args) throws Exception {
Properties prop = new Properties();
prop.put("mail.store.protocol", "pop3");
prop.put("mail.pop3.host", MAIL_SERVER_POP3);
prop.put("mail.pop3.starttls.enable", "true");
prop.put("mail.smtp.auth", "true");
prop.put("mail.smtp.host", MAIL_SERVER_SMTP);

// 1、创建session
Session session = Session.getDefaultInstance(prop);

// 2、读取邮件夹
Store store = session.getStore("pop3");
store.connect(MAIL_SERVER_POP3, USER, PASSWORD);
Folder folder = store.getFolder("inbox");
folder.open(Folder.READ_ONLY);

// 获取邮件夹中第1封邮件信息
Message[] messages = folder.getMessages();
if (messages.length <= 0) {
return;
}
Message message = messages[0];

// 打印邮件关键信息
String from = InternetAddress.toString(message.getFrom());
if (from != null) {
System.out.println("From: " + from);
}

String replyTo = InternetAddress.toString(message.getReplyTo());
if (replyTo != null) {
System.out.println("Reply-to: " + replyTo);
}

String to = InternetAddress.toString(message.getRecipients(Message.RecipientType.TO));
if (to != null) {
System.out.println("To: " + to);
}

String subject = message.getSubject();
if (subject != null) {
System.out.println("Subject: " + subject);
}

Date sent = message.getSentDate();
if (sent != null) {
System.out.println("Sent: " + sent);
}

// 设置转发邮件信息头
Message forward = new MimeMessage(session);
forward.setFrom(new InternetAddress(MAIL_FROM));
forward.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO));
forward.setSubject("Fwd: " + message.getSubject());

// 设置转发邮件内容
MimeBodyPart bodyPart = new MimeBodyPart();
bodyPart.setContent(message, "message/rfc822");

Multipart multipart = new MimeMultipart();
multipart.addBodyPart(bodyPart);
forward.setContent(multipart);
forward.saveChanges();

Transport ts = session.getTransport("smtp");
ts.connect(USER, PASSWORD);
ts.sendMessage(forward, forward.getAllRecipients());

folder.close(false);
store.close();
ts.close();
System.out.println("message forwarded successfully....");
}

Jsoup 快速入门

简介

jsoup 是一款 Java 的 HTML 解析器,可直接解析某个 URL 地址、HTML 文本内容。它提供了一套非常省力的 API,可通过 DOM,CSS 以及类似于 JQuery 的操作方法来取出和操作数据。

jsoup 工作的流程主要如下:

  1. 从一个 URL,文件或字符串中解析 HTML,并加载为一个 Document 对象。
  2. 使用 DOM 或 CSS 选择器来取出数据;
  3. 可操作 HTML 元素、属性、文本。

jsoup 是基于 MIT 协议发布的,可放心使用于商业项目。

加载

从 HTML 字符串加载一个文档

使用静态 Jsoup.parse(String html) 方法或 Jsoup.parse(String html, String baseUri) 示例代码:

1
2
3
String html = "<html><head><title>First parse</title></head>"
+ "<body><p>Parsed HTML into a doc.</p></body></html>";
Document doc = Jsoup.parse(html);

说明

parse(String html, String baseUri) 这方法能够将输入的 HTML 解析为一个新的文档 (Document),参数 baseUri 是用来将相对 URL 转成绝对 URL,并指定从哪个网站获取文档。如这个方法不适用,你可以使用 parse(String html) 方法来解析成 HTML 字符串如上面的示例。

只要解析的不是空字符串,就能返回一个结构合理的文档,其中包含(至少) 一个 head 和一个 body 元素。

一旦拥有了一个 Document,你就可以使用 Document 中适当的方法或它父类 ElementNode中的方法来取得相关数据。

解析一个 body 片断

问题

假如你有一个 HTML 片断 (比如. 一个 div 包含一对 p 标签; 一个不完整的 HTML 文档) 想对它进行解析。这个 HTML 片断可以是用户提交的一条评论或在一个 CMS 页面中编辑 body 部分。

办法

使用Jsoup.parseBodyFragment(String html)方法.

1
2
3
String html = "<div><p>Lorem ipsum.</p>";
Document doc = Jsoup.parseBodyFragment(html);
Element body = doc.body();

说明

parseBodyFragment 方法创建一个空壳的文档,并插入解析过的 HTML 到body元素中。假如你使用正常的 Jsoup.parse(String html) 方法,通常你也可以得到相同的结果,但是明确将用户输入作为 body 片段处理,以确保用户所提供的任何糟糕的 HTML 都将被解析成 body 元素。

Document.body() 方法能够取得文档 body 元素的所有子元素,与 doc.getElementsByTag("body")相同。

保证安全 Stay safe

假如你可以让用户输入 HTML 内容,那么要小心避免跨站脚本攻击。利用基于 Whitelist 的清除器和 clean(String bodyHtml, Whitelist whitelist)方法来清除用户输入的恶意内容。

从 URL 加载一个文档

使用 Jsoup.connect(String url)方法

1
Document doc = Jsoup.connect("http://example.com/").get();

说明

connect(String url) 方法创建一个新的 Connection, 和 get() 取得和解析一个 HTML 文件。如果从该 URL 获取 HTML 时发生错误,便会抛出 IOException,应适当处理。

Connection 接口还提供一个方法链来解决特殊请求,具体如下:

1
2
3
4
5
6
Document doc = Jsoup.connect("http://example.com")
.data("query", "Java")
.userAgent("Mozilla")
.cookie("auth", "token")
.timeout(3000)
.post();

从一个文件加载一个文档

可以使用静态 Jsoup.parse(File in, String charsetName, String baseUri) 方法

1
2
File input = new File("/tmp/input.html");
Document doc = Jsoup.parse(input, "UTF-8", "http://example.com/");

说明

parse(File in, String charsetName, String baseUri) 这个方法用来加载和解析一个 HTML 文件。如在加载文件的时候发生错误,将抛出 IOException,应作适当处理。

baseUri 参数用于解决文件中 URLs 是相对路径的问题。如果不需要可以传入一个空的字符串。

另外还有一个方法parse(File in, String charsetName) ,它使用文件的路径做为 baseUri。 这个方法适用于如果被解析文件位于网站的本地文件系统,且相关链接也指向该文件系统。

解析

使用 DOM 方法来遍历一个文档

问题

你有一个 HTML 文档要从中提取数据,并了解这个 HTML 文档的结构。

方法

将 HTML 解析成一个Document之后,就可以使用类似于 DOM 的方法进行操作。示例代码:

1
2
3
4
5
6
7
8
9
File input = new File("/tmp/input.html");
Document doc = Jsoup.parse(input, "UTF-8", "http://example.com/");

Element content = doc.getElementById("content");
Elements links = content.getElementsByTag("a");
for (Element link : links) {
String linkHref = link.attr("href");
String linkText = link.text();
}

说明

Elements 这个对象提供了一系列类似于 DOM 的方法来查找元素,抽取并处理其中的数据。

具体如下:

查找元素

  • getElementById(String id)
  • getElementsByTag(String tag)
  • getElementsByClass(String className)
  • getElementsByAttribute(String key) (and related methods)
  • Element siblings: siblingElements(), firstElementSibling(), lastElementSibling();nextElementSibling(), previousElementSibling()
  • Graph: parent(), children(), child(int index)

元素数据

  • attr(String key)获取属性attr(String key, String value)设置属性
  • attributes()获取所有属性
  • id(), className() and classNames()
  • text()获取文本内容text(String value) 设置文本内容
  • html()获取元素内 HTMLhtml(String value)设置元素内的 HTML 内容
  • outerHtml()获取元素外 HTML 内容
  • data()获取数据内容(例如:script 和 style 标签)
  • tag() and tagName()

操作 HTML 和文本

  • append(String html), prepend(String html)
  • appendText(String text), prependText(String text)
  • appendElement(String tagName), prependElement(String tagName)
  • html(String value)

使用选择器语法来查找元素

问题

你想使用类似于 CSS 或 jQuery 的语法来查找和操作元素。

方法

可以使用Element.select(String selector)Elements.select(String selector) 方法实现:

1
2
3
4
5
6
7
8
9
10
11
File input = new File("/tmp/input.html");
Document doc = Jsoup.parse(input, "UTF-8", "http://example.com/");

Elements links = doc.select("a[href]"); //带有href属性的a元素
Elements pngs = doc.select("img[src$=.png]");
//扩展名为.png的图片

Element masthead = doc.select("div.masthead").first();
//class等于masthead的div标签

Elements resultLinks = doc.select("h3.r > a"); //在h3元素之后的a元素

说明

jsoup elements 对象支持类似于CSS (或jquery)的选择器语法,来实现非常强大和灵活的查找功能。.

这个select 方法在Document, Element,或Elements对象中都可以使用。且是上下文相关的,因此可实现指定元素的过滤,或者链式选择访问。

Select 方法将返回一个Elements集合,并提供一组方法来抽取和处理结果。

Selector 选择器概述

  • tagname: 通过标签查找元素,比如:a
  • ns|tag: 通过标签在命名空间查找元素,比如:可以用 fb|name 语法来查找 `` 元素
  • #id: 通过 ID 查找元素,比如:#logo
  • .class: 通过 class 名称查找元素,比如:.masthead
  • [attribute]: 利用属性查找元素,比如:[href]
  • [^attr]: 利用属性名前缀来查找元素,比如:可以用[^data-] 来查找带有 HTML5 Dataset 属性的元素
  • [attr=value]: 利用属性值来查找元素,比如:[width=500]
  • [attr^=value], [attr$=value], [attr*=value]: 利用匹配属性值开头、结尾或包含属性值来查找元素,比如:[href*=/path/]
  • [attr\~=regex]: 利用属性值匹配正则表达式来查找元素,比如: img[src\~=(?i)\.(png|jpe?g)]
  • *: 这个符号将匹配所有元素

Selector 选择器组合使用

  • el##id: 元素+ID,比如: div##logo
  • el.class: 元素+class,比如: div.masthead
  • el[attr]: 元素+class,比如: a[href]
  • 任意组合,比如:a[href].highlight
  • ancestor child: 查找某个元素下子元素,比如:可以用.body p 查找在”body”元素下的所有p元素
  • parent > child: 查找某个父元素下的直接子元素,比如:可以用div.content > p 查找 p 元素,也可以用body > * 查找 body 标签下所有直接子元素
  • siblingA + siblingB: 查找在 A 元素之前第一个同级元素 B,比如:div.head + div
  • siblingA \~ siblingX: 查找 A 元素之前的同级 X 元素,比如:h1 \~ p
  • el, el, el:多个选择器组合,查找匹配任一选择器的唯一元素,例如:div.masthead, div.logo

伪选择器 selectors

  • :lt(n): 查找哪些元素的同级索引值(它的位置在 DOM 树中是相对于它的父节点)小于 n,比如:td:lt(3) 表示小于三列的元素
  • :gt(n):查找哪些元素的同级索引值大于n``,比如div p:gt(2)表示哪些 div 中有包含 2 个以上的 p 元素
  • :eq(n): 查找哪些元素的同级索引值与n相等,比如:form input:eq(1)表示包含一个 input 标签的 Form 元素
  • :has(seletor): 查找匹配选择器包含元素的元素,比如:div:has(p)表示哪些 div 包含了 p 元素
  • :not(selector): 查找与选择器不匹配的元素,比如: div:not(.logo) 表示不包含 class=logo 元素的所有 div 列表
  • :contains(text): 查找包含给定文本的元素,搜索不区分大不写,比如: p:contains(jsoup)
  • :containsOwn(text): 查找直接包含给定文本的元素
  • :matches(regex): 查找哪些元素的文本匹配指定的正则表达式,比如:div:matches((?i)login)
  • :matchesOwn(regex): 查找自身包含文本匹配指定正则表达式的元素
  • 注意:上述伪选择器索引是从 0 开始的,也就是说第一个元素索引值为 0,第二个元素 index 为 1 等

可以查看Selector API 参考来了解更详细的内容

从元素抽取属性,文本和 HTML

问题

在解析获得一个 Document 实例对象,并查找到一些元素之后,你希望取得在这些元素中的数据。

方法

  • 要取得一个属性的值,可以使用Node.attr(String key) 方法
  • 对于一个元素中的文本,可以使用Element.text()方法
  • 对于要取得元素或属性中的 HTML 内容,可以使用Element.html(), 或 Node.outerHtml()方法

示例:

1
2
3
4
5
6
7
8
9
10
11
String html = "<p>An <a href='http://example.com/'><b>example</b></a> link.</p>";
Document doc = Jsoup.parse(html);//解析HTML字符串返回一个Document实现
Element link = doc.select("a").first();//查找第一个a元素

String text = doc.body().text(); // "An example link"//取得字符串中的文本
String linkHref = link.attr("href"); // "http://example.com/"//取得链接地址
String linkText = link.text(); // "example""//取得链接地址中的文本

String linkOuterH = link.outerHtml();
// "<a href="http://example.com"><b>example</b></a>"
String linkInnerH = link.html(); // "<b>example</b>"//取得链接内的html内容

说明

上述方法是元素数据访问的核心办法。此外还其它一些方法可以使用:

  • Element.id()
  • Element.tagName()
  • Element.className() and Element.hasClass(String className)

这些访问器方法都有相应的 setter 方法来更改数据

参见

处理 URLs

问题

你有一个包含相对 URLs 路径的 HTML 文档,需要将这些相对路径转换成绝对路径的 URLs。

方法

  1. 在你解析文档时确保有指定base URI,然后
  2. 使用 abs: 属性前缀来取得包含base URI的绝对路径。代码如下:
1
2
3
4
5
6
Document doc = Jsoup.connect("http://www.open-open.com").get();

Element link = doc.select("a").first();
String relHref = link.attr("href"); // == "/"
String absHref = link.attr("abs:href"); // "http://www.open-open.com/"

说明

在 HTML 元素中,URLs 经常写成相对于文档位置的相对路径: <a href="/download">...</a>. 当你使用 Node.attr(String key) 方法来取得 a 元素的 href 属性时,它将直接返回在 HTML 源码中指定定的值。

假如你需要取得一个绝对路径,需要在属性名前加 abs: 前缀。这样就可以返回包含根路径的 URL 地址attr("abs:href")

因此,在解析 HTML 文档时,定义 base URI 非常重要。

如果你不想使用abs: 前缀,还有一个方法能够实现同样的功能 Node.absUrl(String key)

数据修改

设置属性的值

问题

在你解析一个 Document 之后可能想修改其中的某些属性值,然后再保存到磁盘或都输出到前台页面。

方法

可以使用属性设置方法 Element.attr(String key, String value), 和 Elements.attr(String key, String value).

假如你需要修改一个元素的 class 属性,可以使用 Element.addClass(String className)Element.removeClass(String className) 方法。

Elements 提供了批量操作元素属性和 class 的方法,比如:要为 div 中的每一个 a 元素都添加一个rel="nofollow" 可以使用如下方法:

1
2
doc.select("div.comments a").attr("rel", "nofollow");

说明

Element中的其它方法一样,attr 方法也是返回当 Element (或在使用选择器是返回 Elements集合)。这样能够很方便使用方法连用的书写方式。比如:

1
doc.select("div.masthead").attr("title", "jsoup").addClass("round-box");

设置一个元素的 HTML 内容

问题

你需要一个元素中的 HTML 内容

方法

可以使用Element中的 HTML 设置方法具体如下:

1
2
3
4
5
6
7
8
9
Element div = doc.select("div").first(); // <div></div>
div.html("<p>lorem ipsum</p>"); // <div><p>lorem ipsum</p></div>
div.prepend("<p>First</p>");//在div前添加html内容
div.append("<p>Last</p>");//在div之后添加html内容
// 添完后的结果: <div><p>First</p><p>lorem ipsum</p><p>Last</p></div>

Element span = doc.select("span").first(); // <span>One</span>
span.wrap("<li><a href='http://example.com/'></a></li>");
// 添完后的结果: <li><a href="http://example.com"><span>One</span></a></li>

说明

  • Element.html(String html) 这个方法将先清除元素中的 HTML 内容,然后用传入的 HTML 代替。
  • Element.prepend(String first)Element.append(String last) 方法用于在分别在元素内部 HTML 的前面和后面添加 HTML 内容
  • Element.wrap(String around) 对元素包裹一个外部 HTML 内容。

参见

可以查看 API 参考文档中 Element.prependElement(String tag)Element.appendElement(String tag) 方法来创建新的元素并作为文档的子元素插入其中。

设置元素的文本内容

问题

你需要修改一个 HTML 文档中的文本内容

方法

可以使用Element的设置方法::

1
2
3
4
5
Element div = doc.select("div").first(); // <div></div>
div.text("five > four"); // <div>five &gt; four</div>
div.prepend("First ");
div.append(" Last");
// now: <div>First five &gt; four Last</div>

说明

文本设置方法与 HTML setter 方法一样:

  • Element.text(String text) 将清除一个元素中的内部 HTML 内容,然后提供的文本进行代替
  • Element.prepend(String first)Element.append(String last) 将分别在元素的内部 html 前后添加文本节点。

对于传入的文本如果含有像 <, > 等这样的字符,将以文本处理,而非 HTML。

HTML 清理

消除不受信任的 HTML (来防止 XSS 攻击)

问题

在做网站的时候,经常会提供用户评论的功能。有些不怀好意的用户,会搞一些脚本到评论内容中,而这些脚本可能会破坏整个页面的行为,更严重的是获取一些机要信息,此时需要清理该 HTML,以避免跨站脚本cross-site scripting攻击(XSS)。

方法

使用 jsoup HTML Cleaner 方法进行清除,但需要指定一个可配置的 Whitelist

1
2
3
4
String unsafe =
"<p><a href='http://example.com/' onclick='stealCookies()'>Link</a></p>";
String safe = Jsoup.clean(unsafe, Whitelist.basic());
// now: <p><a href="http://example.com/" rel="nofollow">Link</a></p>

说明

XSS 又叫 CSS (Cross Site Script) ,跨站脚本攻击。它指的是恶意攻击者往 Web 页面里插入恶意 html 代码,当用户浏览该页之时,嵌入其中 Web 里面的 html 代码会被执行,从而达到恶意攻击用户的特殊目的。XSS 属于被动式的攻击,因为其被动且不好利用,所以许多人常忽略其危害性。所以我们经常只让用户输入纯文本的内容,但这样用户体验就比较差了。

一个更好的解决方法就是使用一个富文本编辑器 WYSIWYG 如 CKEditorTinyMCE。这些可以输出 HTML 并能够让用户可视化编辑。虽然他们可以在客户端进行校验,但是这样还不够安全,需要在服务器端进行校验并清除有害的 HTML 代码,这样才能确保输入到你网站的 HTML 是安全的。否则,攻击者能够绕过客户端的 Javascript 验证,并注入不安全的 HMTL 直接进入您的网站。

jsoup 的 whitelist 清理器能够在服务器端对用户输入的 HTML 进行过滤,只输出一些安全的标签和属性。

jsoup 提供了一系列的 Whitelist 基本配置,能够满足大多数要求;但如有必要,也可以进行修改,不过要小心。

这个 cleaner 非常好用不仅可以避免 XSS 攻击,还可以限制用户可以输入的标签范围。

参见

  • 参阅XSS cheat sheet ,有一个例子可以了解为什么不能使用正则表达式,而采用安全的 whitelist parser-based 清理器才是正确的选择。
  • 参阅Cleaner ,了解如何返回一个 Document 对象,而不是字符串
  • 参阅Whitelist,了解如何创建一个自定义的 whitelist
  • nofollow 链接属性了解

参考

Thumbnailator 快速入门

简介

Thumbnailator 是一个开源的 Java 项目,它提供了非常简单的 API 来对图片进行缩放、旋转以及加水印的处理。

有多简单呢?简单到一行代码就可以完成图片处理。形式如下:

1
2
3
4
Thumbnails.of(new File("path/to/directory").listFiles())
.size(640, 480)
.outputFormat("jpg")
.toFiles(Rename.PREFIX_DOT_THUMBNAIL);

当然,Thumbnailator 还有一些使用细节,下面我会一一道来。

核心 API

Thumbnails

Thumbnails 是使用 Thumbnailator 创建缩略图的主入口。

它提供了一组初始化 Thumbnails.Builder 的接口。

先看下这组接口的声明:

1
2
3
4
5
6
7
8
9
10
11
12
// 可变长度参数列表
public static Builder<File> of(String... files) {...}
public static Builder<File> of(File... files) {...}
public static Builder<URL> of(URL... urls) {...}
public static Builder<? extends InputStream> of(InputStream... inputStreams) {...}
public static Builder<BufferedImage> of(BufferedImage... images) {...}
// 迭代器(所有实现 Iterable 接口的 Java 对象都可以,当然也包括 List、Set)
public static Builder<File> fromFilenames(Iterable<String> files) {...}
public static Builder<File> fromFiles(Iterable<File> files) {...}
public static Builder<URL> fromURLs(Iterable<URL> urls) {...}
public static Builder<InputStream> fromInputStreams(Iterable<? extends InputStream> inputStreams) {...}
public static Builder<BufferedImage> fromImages(Iterable<BufferedImage> images) {...}

很显然,Thumbnails 允许通过传入文件名、文件、网络图的 URL、图片流、图片缓存多种方式来初始化构造器

因此,你可以根据实际需求来灵活的选择图片的输入方式。

需要注意一点:如果输入是多个对象(无论你是直接输入容器对象或使用可变参数方式传入多个对象),则输出也必须选用输出多个对象的方式,否则会报异常。

Thumbnails.Builder

Thumbnails.BuilderThumbnails 的内部静态类。它用于设置生成缩略图任务的相关参数。

注:Thumbnails.Builder 的构造函数是私有函数。所以,它只允许通过 Thumbnails 的实例化函数来进行初始化。

设置参数的函数

Thumbnails.Builder 提供了一组函数链形式的接口来设置缩放图参数。

以设置大小函数为例:

1
2
3
4
5
6
7
8
9
10
11
public Builder<T> size(int width, int height)
{
updateStatus(Properties.SIZE, Status.ALREADY_SET);
updateStatus(Properties.SCALE, Status.CANNOT_SET);

validateDimensions(width, height);
this.width = width;
this.height = height;

return this;
}

通过返回 this 指针,使得设置参数函数可以以链式调用的方式来使用,形式如下:

1
2
3
4
5
6
Thumbnails.of(new File("original.jpg"))
.size(160, 160)
.rotate(90)
.watermark(Positions.BOTTOM_RIGHT, ImageIO.read(new File("watermark.png")), 0.5f)
.outputQuality(0.8)
.toFile(new File("image-with-watermark.jpg"));

好处,不言自明:那就是大大简化了代码。

输出函数

Thumbnails.Builder 提供了一组重载函数来输出生成的缩放图。

函数声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 返回图片缓存
public List<BufferedImage> asBufferedImages() throws IOException {...}
public BufferedImage asBufferedImage() throws IOException {...}
// 返回文件列表
public List<File> asFiles(Iterable<File> iterable) throws IOException {...}
public List<File> asFiles(Rename rename) throws IOException {...}
public List<File> asFiles(File destinationDir, Rename rename) throws IOException {...}
// 创建文件
public void toFile(File outFile) throws IOException {...}
public void toFile(String outFilepath) throws IOException {...}
public void toFiles(Iterable<File> iterable) throws IOException {...}
public void toFiles(Rename rename) throws IOException {...}
public void toFiles(File destinationDir, Rename rename) throws IOException {...}
// 创建输出流
public void toOutputStream(OutputStream os) throws IOException {...}
public void toOutputStreams(Iterable<? extends OutputStream> iterable) throws IOException {...}

工作流

Thumbnailator 的工作步骤十分简单,可分为三步:

  1. 输入Thumbnails 根据输入初始化构造器—— Thumbnails.Builder

  2. 设置Thumbnails.Builder 设置缩放图片的参数。

  3. 输出Thumbnails.Builder 输出图片文件或图片流。

更多详情可以参考: Thumbnailator 官网 javadoc

实战

前文介绍了 Thumbnailator 的核心 API,接下来我们就可以通过实战来看看 Thumbnailator 究竟可以做些什么。

Thumbnailator 生成什么样的图片,是根据设置参数来决定的。

安装

maven 项目中引入依赖:

1
2
3
4
5
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>[0.4, 0.5)</version>
</dependency>

图片缩放

Thumbnails.Buildersize 函数可以设置新图片精确的宽度和高度,也可以用 scale 函数设置缩放比例。

1
2
3
4
5
6
7
8
9
10
11
Thumbnails.of("oldFile.png")
.size(16, 16)
.toFile("newFile_16_16.png");

Thumbnails.of("oldFile.png")
.scale(2.0)
.toFile("newFile_scale_2.0.png");

Thumbnails.of("oldFile.png")
.scale(1.0, 0.5)
.toFile("newFile_scale_1.0_0.5.png");

oldFile.png

img

newFile_scale_1.0_0.5.png

img

图片旋转

Thumbnails.Buildersize 函数可以设置新图片的旋转角度。

1
2
3
4
5
6
7
8
9
Thumbnails.of("oldFile.png")
.scale(0.8)
.rotate(90)
.toFile("newFile_rotate_90.png");

Thumbnails.of("oldFile.png")
.scale(0.8)
.rotate(180)
.toFile("newFile_rotate_180.png");

newFile_rotate_90.png

img

加水印

Thumbnails.Builderwatermark 函数可以为图片添加水印图片。第一个参数是水印的位置;第二个参数是水印图片的缓存数据;第三个参数是透明度。

1
2
3
4
5
BufferedImage watermarkImage = ImageIO.read(new File("wartermarkFile.png"));
Thumbnails.of("oldFile.png")
.scale(0.8)
.watermark(Positions.BOTTOM_LEFT, watermarkImage, 0.5f)
.toFile("newFile_watermark.png");

wartermarkFile.png

img

newFile_watermark.png

img

批量处理图片

下面以批量给图片加水印来展示一下如何处理多个图片文件。

1
2
3
4
5
6
7
BufferedImage watermarkImage = ImageIO.read(new File("wartermarkFile.png"));

File destinationDir = new File("D:\\watermark\\");
Thumbnails.of("oldFile.png", "oldFile2.png")
.scale(0.8)
.watermark(Positions.BOTTOM_LEFT, watermarkImage, 0.5f)
.toFiles(destinationDir, Rename.PREFIX_DOT_THUMBNAIL);

需要参考完整测试例代码请 点击这里

参考

Thumbnailator 官方示例文档

ZXing 快速入门

简介

ZXing 是一个开源 Java 类库用于解析多种格式的 1D/2D 条形码。目标是能够对 QR 编码、Data Matrix、UPC 的 1D 条形码进行解码。 其提供了多种平台下的客户端包括:J2ME、J2SE 和 Android。

官网:ZXing github 仓库

实战

本例演示如何在一个非 android 的 Java 项目中使用 ZXing 来生成、解析二维码图片。

安装

maven 项目只需引入依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.3.0</version>
</dependency>

如果非 maven 项目,就去官网下载发布版本:下载地址

生成二维码图片

ZXing 生成二维码图片有以下步骤:

  1. com.google.zxing.MultiFormatWriter 根据内容以及图像编码参数生成图像 2D 矩阵。
  2. com.google.zxing.client.j2se.MatrixToImageWriter 根据图像矩阵生成图片文件或图片缓存 BufferedImage
1
2
3
4
5
6
7
8
9
public void encode(String content, String filepath) throws WriterException, IOException {
int width = 100;
int height = 100;
Map<EncodeHintType, Object> encodeHints = new HashMap<EncodeHintType, Object>();
encodeHints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, encodeHints);
Path path = FileSystems.getDefault().getPath(filepath);
MatrixToImageWriter.writeToPath(bitMatrix, "png", path);
}

解析二维码图片

ZXing 解析二维码图片有以下步骤:

  1. 使用 javax.imageio.ImageIO 读取图片文件,并存为一个 java.awt.image.BufferedImage 对象。

  2. java.awt.image.BufferedImage 转换为 ZXing 能识别的 com.google.zxing.BinaryBitmap 对象。

  3. com.google.zxing.MultiFormatReader 根据图像解码参数来解析 com.google.zxing.BinaryBitmap

1
2
3
4
5
6
7
8
9
10
public String decode(String filepath) throws IOException, NotFoundException {
BufferedImage bufferedImage = ImageIO.read(new FileInputStream(filepath));
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
Binarizer binarizer = new HybridBinarizer(source);
BinaryBitmap bitmap = new BinaryBitmap(binarizer);
HashMap<DecodeHintType, Object> decodeHints = new HashMap<DecodeHintType, Object>();
decodeHints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
Result result = new MultiFormatReader().decode(bitmap, decodeHints);
return result.getText();
}

完整参考示例:测试例代码

以下是一个生成的二维码图片示例:

img

参考

ZXing github 仓库

MyBatis 快速入门

MyBatis 的前身就是 iBatis ,是一个作用在数据持久层的对象关系映射(Object Relational Mapping,简称 ORM)框架。

img

Mybatis 简介

img

什么是 MyBatis

MyBatis 是一款持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java 的 POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

MyBatis vs. Hibernate

MyBatis 和 Hibernate 都是 Java 世界中比较流行的 ORM 框架。我们应该了解其各自的优势,根据项目的需要去抉择在开发中使用哪个框架。

Mybatis 优势

  • MyBatis 可以进行更为细致的 SQL 优化,可以减少查询字段。
  • MyBatis 容易掌握,而 Hibernate 门槛较高。

Hibernate 优势

  • Hibernate 的 DAO 层开发比 MyBatis 简单,Mybatis 需要维护 SQL 和结果映射。
  • Hibernate 对对象的维护和缓存要比 MyBatis 好,对增删改查的对象的维护要方便。
  • Hibernate 数据库移植性很好,MyBatis 的数据库移植性不好,不同的数据库需要写不同 SQL。
  • Hibernate 有更好的二级缓存机制,可以使用第三方缓存。MyBatis 本身提供的缓存机制不佳。

快速入门

这里,我将以一个入门级的示例来演示 Mybatis 是如何工作的。

注:本文后面章节中的原理、源码部分也将基于这个示例来进行讲解。

数据库准备

在本示例中,需要针对一张用户表进行 CRUD 操作。其数据模型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE IF NOT EXISTS user (
id BIGINT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Id',
name VARCHAR(10) NOT NULL DEFAULT '' COMMENT '用户名',
age INT(3) NOT NULL DEFAULT 0 COMMENT '年龄',
address VARCHAR(32) NOT NULL DEFAULT '' COMMENT '地址',
email VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮件',
PRIMARY KEY (id)
) COMMENT = '用户表';

INSERT INTO user (name, age, address, email)
VALUES ('张三', 18, '北京', 'xxx@163.com');
INSERT INTO user (name, age, address, email)
VALUES ('李四', 19, '上海', 'xxx@163.com');

添加 Mybatis

如果使用 Maven 来构建项目,则需将下面的依赖代码置于 pom.xml 文件中:

1
2
3
4
5
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>x.x.x</version>
</dependency>

Mybatis 配置

XML 配置文件中包含了对 MyBatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource)以及决定事务作用域和控制方式的事务管理器(TransactionManager)。

本示例中只是给出最简化的配置。

【示例】mybatis-config.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC" />
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver" />
<property name="url"
value="jdbc:mysql://127.0.0.1:3306/spring_tutorial?serverTimezone=UTC" />
<property name="username" value="root" />
<property name="password" value="root" />
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mybatis/mapper/UserMapper.xml" />
</mappers>
</configuration>

说明:上面的配置文件中仅仅指定了数据源连接方式和 User 表的映射配置文件。

Mapper

Mapper.xml

个人理解,Mapper.xml 文件可以看做是 Mybatis 的 JDBC SQL 模板。

【示例】UserMapper.xml 文件

下面是一个通过 Mybatis Generator 自动生成的完整的 Mapper 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.dunwu.spring.orm.mapper.UserMapper">
<resultMap id="BaseResultMap" type="io.github.dunwu.spring.orm.entity.User">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="age" jdbcType="INTEGER" property="age" />
<result column="address" jdbcType="VARCHAR" property="address" />
<result column="email" jdbcType="VARCHAR" property="email" />
</resultMap>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
delete from user
where id = #{id,jdbcType=BIGINT}
</delete>
<insert id="insert" parameterType="io.github.dunwu.spring.orm.entity.User">
insert into user (id, name, age,
address, email)
values (#{id,jdbcType=BIGINT}, #{name,jdbcType=VARCHAR}, #{age,jdbcType=INTEGER},
#{address,jdbcType=VARCHAR}, #{email,jdbcType=VARCHAR})
</insert>
<update id="updateByPrimaryKey" parameterType="io.github.dunwu.spring.orm.entity.User">
update user
set name = #{name,jdbcType=VARCHAR},
age = #{age,jdbcType=INTEGER},
address = #{address,jdbcType=VARCHAR},
email = #{email,jdbcType=VARCHAR}
where id = #{id,jdbcType=BIGINT}
</update>
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>
<select id="selectAll" resultMap="BaseResultMap">
select id, name, age, address, email
from user
</select>
</mapper>

Mapper.java

Mapper.java 文件是 Mapper.xml 对应的 Java 对象。

【示例】UserMapper.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserMapper {

int deleteByPrimaryKey(Long id);

int insert(User record);

User selectByPrimaryKey(Long id);

List<User> selectAll();

int updateByPrimaryKey(User record);

}

对比 UserMapper.java 和 UserMapper.xml 文件,不难发现:

UserMapper.java 中的方法和 UserMapper.xml 的 CRUD 语句元素( <insert><delete><update><select>)存在一一对应关系。

在 Mybatis 中,正是通过方法的全限定名,将二者绑定在一起。

数据实体.java

【示例】User.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
public class User {
private Long id;

private String name;

private Integer age;

private String address;

private String email;

}

<insert><delete><update><select>parameterType 属性以及 <resultMap>type 属性都可能会绑定到数据实体。这样就可以把 JDBC 操作的输入输出和 JavaBean 结合起来,更加方便、易于理解。

测试程序

【示例】MybatisDemo.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MybatisDemo {

public static void main(String[] args) throws Exception {
// 1. 加载 mybatis 配置文件,创建 SqlSessionFactory
// 注:在实际的应用中,SqlSessionFactory 应该是单例
InputStream inputStream = Resources.getResourceAsStream("mybatis/mybatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);

// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}
// 输出:user name: 张三
}

}

说明:

SqlSession 接口是 Mybatis API 核心中的核心,它代表了 Mybatis 和数据库的一次完整会话。

  • Mybatis 会解析配置,并根据配置创建 SqlSession
  • 然后,Mybatis 将 Mapper 映射为 SqlSession,然后传递参数,执行 SQL 语句并获取结果。

Mybatis xml 配置

配置的详细内容请参考:“ Mybatis 官方文档之配置 ” 。

MyBatis 的配置文件包含了会深深影响 MyBatis 行为的设置和属性信息。主要配置项有以下:

Mybatis xml 映射器

SQL XML 映射文件详细内容请参考:“ Mybatis 官方文档之 XML 映射文件 ”。

XML 映射文件只有几个顶级元素:

  • cache – 对给定命名空间的缓存配置。
  • cache-ref – 对其他命名空间缓存配置的引用。
  • resultMap – 是最复杂也是最强大的元素,用来描述如何从数据库结果集中来加载对象。
  • parameterMap – 已被废弃!老式风格的参数映射。更好的办法是使用内联参数,此元素可能在将来被移除。文档中不会介绍此元素。
  • sql – 可被其他语句引用的可重用语句块。
  • insert – 映射插入语句
  • update – 映射更新语句
  • delete – 映射删除语句
  • select – 映射查询语句

Mybatis 扩展

Mybatis 类型处理器

MyBatis 在设置预处理语句(PreparedStatement)中的参数或从结果集中取出一个值时, 都会用类型处理器将获取到的值以合适的方式转换成 Java 类型。下表描述了一些默认的类型处理器。

从 3.4.5 开始,MyBatis 默认支持 JSR-310(日期和时间 API) 。

你可以重写已有的类型处理器或创建你自己的类型处理器来处理不支持的或非标准的类型。 具体做法为:实现 org.apache.ibatis.type.TypeHandler 接口, 或继承一个很便利的类 org.apache.ibatis.type.BaseTypeHandler, 并且可以(可选地)将它映射到一个 JDBC 类型。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ExampleTypeHandler.java
@MappedJdbcTypes(JdbcType.VARCHAR)
public class ExampleTypeHandler extends BaseTypeHandler<String> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}
1
2
3
4
<!-- mybatis-config.xml -->
<typeHandlers>
<typeHandler handler="org.mybatis.example.ExampleTypeHandler"/>
</typeHandlers>

使用上述的类型处理器将会覆盖已有的处理 Java String 类型的属性以及 VARCHAR 类型的参数和结果的类型处理器。 要注意 MyBatis 不会通过检测数据库元信息来决定使用哪种类型,所以你必须在参数和结果映射中指明字段是 VARCHAR 类型, 以使其能够绑定到正确的类型处理器上。这是因为 MyBatis 直到语句被执行时才清楚数据类型。

Mybatis 插件

MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。

通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ExamplePlugin.java
@Intercepts({@Signature(
type= Executor.class,
method = "update",
args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
private Properties properties = new Properties();
public Object intercept(Invocation invocation) throws Throwable {
// implement pre processing if need
Object returnObject = invocation.proceed();
// implement post processing if need
return returnObject;
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}
1
2
3
4
5
6
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="org.mybatis.example.ExamplePlugin">
<property name="someProperty" value="100"/>
</plugin>
</plugins>

上面的插件将会拦截在 Executor 实例中所有的 “update” 方法调用, 这里的 Executor 是负责执行底层映射语句的内部对象。

参考资料

Mybatis 原理

Mybatis 的前身就是 iBatis ,是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。本文以一个 Mybatis 完整示例为切入点,结合 Mybatis 底层源码分析,图文并茂的讲解 Mybatis 的核心工作机制。

Mybatis 完整示例

这里,我将以一个入门级的示例来演示 Mybatis 是如何工作的。

注:本文后面章节中的原理、源码部分也将基于这个示例来进行讲解。

完整示例源码地址

数据库准备

在本示例中,需要针对一张用户表进行 CRUD 操作。其数据模型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE IF NOT EXISTS user (
id BIGINT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Id',
name VARCHAR(10) NOT NULL DEFAULT '' COMMENT '用户名',
age INT(3) NOT NULL DEFAULT 0 COMMENT '年龄',
address VARCHAR(32) NOT NULL DEFAULT '' COMMENT '地址',
email VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮件',
PRIMARY KEY (id)
) COMMENT = '用户表';

INSERT INTO user (name, age, address, email)
VALUES ('张三', 18, '北京', 'xxx@163.com');
INSERT INTO user (name, age, address, email)
VALUES ('李四', 19, '上海', 'xxx@163.com');

添加 Mybatis

如果使用 Maven 来构建项目,则需将下面的依赖代码置于 pom.xml 文件中:

1
2
3
4
5
<dependency>
<groupId>org.Mybatis</groupId>
<artifactId>Mybatis</artifactId>
<version>x.x.x</version>
</dependency>

Mybatis 配置

XML 配置文件中包含了对 Mybatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource)以及决定事务作用域和控制方式的事务管理器(TransactionManager)。

本示例中只是给出最简化的配置。

【示例】Mybatis-config.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE configuration PUBLIC "-//Mybatis.org//DTD Config 3.0//EN"
"http://Mybatis.org/dtd/Mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC" />
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver" />
<property name="url"
value="jdbc:mysql://127.0.0.1:3306/spring_tutorial?serverTimezone=UTC" />
<property name="username" value="root" />
<property name="password" value="root" />
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="Mybatis/mapper/UserMapper.xml" />
</mappers>
</configuration>

说明:上面的配置文件中仅仅指定了数据源连接方式和 User 表的映射配置文件。

Mapper

Mapper.xml

个人理解,Mapper.xml 文件可以看做是 Mybatis 的 JDBC SQL 模板。

【示例】UserMapper.xml 文件

下面是一个通过 Mybatis Generator 自动生成的完整的 Mapper 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//Mybatis.org//DTD Mapper 3.0//EN" "http://Mybatis.org/dtd/Mybatis-3-mapper.dtd">
<mapper namespace="io.github.dunwu.spring.orm.mapper.UserMapper">
<resultMap id="BaseResultMap" type="io.github.dunwu.spring.orm.entity.User">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="age" jdbcType="INTEGER" property="age" />
<result column="address" jdbcType="VARCHAR" property="address" />
<result column="email" jdbcType="VARCHAR" property="email" />
</resultMap>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
delete from user
where id = #{id,jdbcType=BIGINT}
</delete>
<insert id="insert" parameterType="io.github.dunwu.spring.orm.entity.User">
insert into user (id, name, age,
address, email)
values (#{id,jdbcType=BIGINT}, #{name,jdbcType=VARCHAR}, #{age,jdbcType=INTEGER},
#{address,jdbcType=VARCHAR}, #{email,jdbcType=VARCHAR})
</insert>
<update id="updateByPrimaryKey" parameterType="io.github.dunwu.spring.orm.entity.User">
update user
set name = #{name,jdbcType=VARCHAR},
age = #{age,jdbcType=INTEGER},
address = #{address,jdbcType=VARCHAR},
email = #{email,jdbcType=VARCHAR}
where id = #{id,jdbcType=BIGINT}
</update>
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>
<select id="selectAll" resultMap="BaseResultMap">
select id, name, age, address, email
from user
</select>
</mapper>

Mapper.java

Mapper.java 文件是 Mapper.xml 对应的 Java 对象。

【示例】UserMapper.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserMapper {

int deleteByPrimaryKey(Long id);

int insert(User record);

User selectByPrimaryKey(Long id);

List<User> selectAll();

int updateByPrimaryKey(User record);

}

对比 UserMapper.java 和 UserMapper.xml 文件,不难发现:

UserMapper.java 中的方法和 UserMapper.xml 的 CRUD 语句元素( <insert><delete><update><select>)存在一一对应关系。

在 Mybatis 中,正是通过方法的全限定名,将二者绑定在一起。

数据实体.java

【示例】User.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
public class User {
private Long id;

private String name;

private Integer age;

private String address;

private String email;

}

<insert><delete><update><select>parameterType 属性以及 <resultMap>type 属性都可能会绑定到数据实体。这样就可以把 JDBC 操作的输入输出和 JavaBean 结合起来,更加方便、易于理解。

测试程序

【示例】MybatisDemo.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MybatisDemo {

public static void main(String[] args) throws Exception {
// 1. 加载 Mybatis 配置文件,创建 SqlSessionFactory
// 注:在实际的应用中,SqlSessionFactory 应该是单例
InputStream inputStream = Resources.getResourceAsStream("Mybatis/Mybatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);

// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}
// 输出:user name: 张三
}

}

说明:

SqlSession 接口是 Mybatis API 核心中的核心,它代表了 Mybatis 和数据库的一次完整会话。

  • Mybatis 会解析配置,并根据配置创建 SqlSession
  • 然后,Mybatis 将 Mapper 映射为 SqlSession,然后传递参数,执行 SQL 语句并获取结果。

Mybatis 生命周期

img

SqlSessionFactoryBuilder

SqlSessionFactoryBuilder 的职责

SqlSessionFactoryBuilder 负责创建 SqlSessionFactory 实例SqlSessionFactoryBuilder 可以从 XML 配置文件或一个预先定制的 Configuration 的实例构建出 SqlSessionFactory 的实例。

Configuration 类包含了对一个 SqlSessionFactory 实例你可能关心的所有内容。

img

SqlSessionFactoryBuilder 应用了建造者设计模式,它有五个 build 方法,允许你通过不同的资源创建 SqlSessionFactory 实例。

1
2
3
4
5
SqlSessionFactory build(InputStream inputStream)
SqlSessionFactory build(InputStream inputStream, String environment)
SqlSessionFactory build(InputStream inputStream, Properties properties)
SqlSessionFactory build(InputStream inputStream, String env, Properties props)
SqlSessionFactory build(Configuration config)

SqlSessionFactoryBuilder 的生命周期

SqlSessionFactoryBuilder 可以被实例化、使用和丢弃,一旦创建了 SqlSessionFactory,就不再需要它了。 因此 SqlSessionFactoryBuilder 实例的最佳作用域是方法作用域(也就是局部方法变量)。你可以重用 SqlSessionFactoryBuilder 来创建多个 SqlSessionFactory 实例,但最好还是不要一直保留着它,以保证所有的 XML 解析资源可以被释放给更重要的事情。

SqlSessionFactory

SqlSessionFactory 职责

SqlSessionFactory 负责创建 SqlSession 实例。

img

SqlSessionFactory 应用了工厂设计模式,它提供了一组方法,用于创建 SqlSession 实例。

1
2
3
4
5
6
7
8
9
SqlSession openSession()
SqlSession openSession(boolean autoCommit)
SqlSession openSession(Connection connection)
SqlSession openSession(TransactionIsolationLevel level)
SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level)
SqlSession openSession(ExecutorType execType)
SqlSession openSession(ExecutorType execType, boolean autoCommit)
SqlSession openSession(ExecutorType execType, Connection connection)
Configuration getConfiguration();

方法说明:

  • 默认的 openSession() 方法没有参数,它会创建具备如下特性的 SqlSession
    • 事务作用域将会开启(也就是不自动提交)。
    • 将由当前环境配置的 DataSource 实例中获取 Connection 对象。
    • 事务隔离级别将会使用驱动或数据源的默认设置。
    • 预处理语句不会被复用,也不会批量处理更新。
  • TransactionIsolationLevel 表示事务隔离级别,它对应着 JDBC 的五个事务隔离级别。
  • ExecutorType 枚举类型定义了三个值:
    • ExecutorType.SIMPLE:该类型的执行器没有特别的行为。它为每个语句的执行创建一个新的预处理语句。
    • ExecutorType.REUSE:该类型的执行器会复用预处理语句。
    • ExecutorType.BATCH:该类型的执行器会批量执行所有更新语句,如果 SELECT 在多个更新中间执行,将在必要时将多条更新语句分隔开来,以方便理解。

SqlSessionFactory 生命周期

SqlSessionFactory 应该以单例形式在应用的运行期间一直存在。

SqlSession

SqlSession 职责

Mybatis 的主要 Java 接口就是 SqlSession。它包含了所有执行语句,获取映射器和管理事务等方法。

详细内容可以参考:“ Mybatis 官方文档之 SqlSessions ” 。

SqlSession 类的方法可以按照下图进行大致分类:

img

SqlSession 生命周期

SqlSessions 是由 SqlSessionFactory 实例创建的;而 SqlSessionFactory 是由 SqlSessionFactoryBuilder 创建的。

🔔 注意:当 Mybatis 与一些依赖注入框架(如 Spring 或者 Guice)同时使用时,SqlSessions 将被依赖注入框架所创建,所以你不需要使用 SqlSessionFactoryBuilder 或者 SqlSessionFactory

每个线程都应该有它自己的 SqlSession 实例。

SqlSession 的实例不是线程安全的,因此是不能被共享的,所以它的最佳的作用域是请求或方法作用域。 绝对不能将 SqlSession 实例的引用放在一个类的静态域,甚至一个类的实例变量也不行。 也绝不能将 SqlSession 实例的引用放在任何类型的托管作用域中,比如 Servlet 框架中的 HttpSession。 正确在 Web 中使用 SqlSession 的场景是:每次收到的 HTTP 请求,就可以打开一个 SqlSession,返回一个响应,就关闭它。

编程模式:

1
2
3
try (SqlSession session = sqlSessionFactory.openSession()) {
// 你的应用逻辑代码
}

映射器

映射器职责

映射器是一些由用户创建的、绑定 SQL 语句的接口。

SqlSession 中的 insertupdatedeleteselect 方法都很强大,但也有些繁琐。更通用的方式是使用映射器类来执行映射语句。一个映射器类就是一个仅需声明与 SqlSession 方法相匹配的方法的接口类

Mybatis 将配置文件中的每一个 <mapper> 节点抽象为一个 Mapper 接口,而这个接口中声明的方法和跟 <mapper> 节点中的 <select|update|delete|insert> 节点相对应,即 <select|update|delete|insert> 节点的 id 值为 Mapper 接口中的方法名称,parameterType 值表示 Mapper 对应方法的入参类型,而 resultMap 值则对应了 Mapper 接口表示的返回值类型或者返回结果集的元素类型。

Mybatis 会根据相应的接口声明的方法信息,通过动态代理机制生成一个 Mapper 实例;Mybatis 会根据这个方法的方法名和参数类型,确定 Statement Id,然后和 SqlSession 进行映射,底层还是通过 SqlSession 完成和数据库的交互。

下面的示例展示了一些方法签名以及它们是如何映射到 SqlSession 上的。

img

注意

  • 映射器接口不需要去实现任何接口或继承自任何类。只要方法可以被唯一标识对应的映射语句就可以了。
  • 映射器接口可以继承自其他接口。当使用 XML 来构建映射器接口时要保证语句被包含在合适的命名空间中。而且,唯一的限制就是你不能在两个继承关系的接口中拥有相同的方法签名(潜在的危险做法不可取)。

映射器生命周期

映射器接口的实例是从 SqlSession 中获得的。因此从技术层面讲,任何映射器实例的最大作用域是和请求它们的 SqlSession 相同的。尽管如此,映射器实例的最佳作用域是方法作用域。 也就是说,映射器实例应该在调用它们的方法中被请求,用过之后即可丢弃。

编程模式:

1
2
3
4
try (SqlSession session = sqlSessionFactory.openSession()) {
BlogMapper mapper = session.getMapper(BlogMapper.class);
// 你的应用逻辑代码
}
  • 映射器注解

Mybatis 是一个 XML 驱动的框架。配置信息是基于 XML 的,而且映射语句也是定义在 XML 中的。Mybatis 3 以后,支持注解配置。注解配置基于配置 API;而配置 API 基于 XML 配置。

Mybatis 支持诸如 @Insert@Update@Delete@Select@Result 等注解。

详细内容请参考:Mybatis 官方文档之 sqlSessions,其中列举了 Mybatis 支持的注解清单,以及基本用法。

Mybatis 的架构

从 Mybatis 代码实现的角度来看,Mybatis 的主要组件有以下几个:

  • SqlSession - 作为 Mybatis 工作的主要顶层 API,表示和数据库交互的会话,完成必要数据库增删改查功能。
  • Executor - Mybatis 执行器,是 Mybatis 调度的核心,负责 SQL 语句的生成和查询缓存的维护。
  • StatementHandler - 封装了 JDBC Statement 操作,负责对 JDBC statement 的操作,如设置参数、将 Statement 结果集转换成 List 集合。
  • ParameterHandler - 负责对用户传递的参数转换成 JDBC Statement 所需要的参数。
  • ResultSetHandler - 负责将 JDBC 返回的 ResultSet 结果集对象转换成 List 类型的集合。
  • TypeHandler - 负责 java 数据类型和 jdbc 数据类型之间的映射和转换。
  • MappedStatement - MappedStatement 维护了一条 <select|update|delete|insert> 节点的封装。
  • SqlSource - 负责根据用户传递的 parameterObject,动态地生成 SQL 语句,将信息封装到 BoundSql 对象中,并返回。
  • BoundSql - 表示动态生成的 SQL 语句以及相应的参数信息。
  • Configuration - Mybatis 所有的配置信息都维持在 Configuration 对象之中。

这些组件的架构层次如下:

img

配置层

配置层决定了 Mybatis 的工作方式。

Mybatis 提供了两种配置方式:

  • 基于 XML 配置文件的方式
  • 基于 Java API 的方式

SqlSessionFactoryBuilder 会根据配置创建 SqlSessionFactory

SqlSessionFactory 负责创建 SqlSessions

接口层

接口层负责和数据库交互的方式。

Mybatis 和数据库的交互有两种方式:

  • 使用 SqlSession:SqlSession 封装了所有执行语句,获取映射器和管理事务的方法。
    • 用户只需要传入 Statement Id 和查询参数给 SqlSession 对象,就可以很方便的和数据库进行交互。
    • 这种方式的缺点是不符合面向对象编程的范式。
  • 使用 Mapper 接口:Mybatis 会根据相应的接口声明的方法信息,通过动态代理机制生成一个 Mapper 实例;Mybatis 会根据这个方法的方法名和参数类型,确定 Statement Id,然后和 SqlSession 进行映射,底层还是通过 SqlSession 完成和数据库的交互。

数据处理层

数据处理层可以说是 Mybatis 的核心,从大的方面上讲,它要完成两个功能:

  • 根据传参 Statement 和参数构建动态 SQL 语句
    • 动态语句生成可以说是 Mybatis 框架非常优雅的一个设计,Mybatis 通过传入的参数值,使用 Ognl 来动态地构造 SQL 语句,使得 Mybatis 有很强的灵活性和扩展性。
    • 参数映射指的是对于 java 数据类型和 jdbc 数据类型之间的转换:这里有包括两个过程:查询阶段,我们要将 java 类型的数据,转换成 jdbc 类型的数据,通过 preparedStatement.setXXX() 来设值;另一个就是对 resultset 查询结果集的 jdbcType 数据转换成 java 数据类型。
  • 执行 SQL 语句以及处理响应结果集 ResultSet
    • 动态 SQL 语句生成之后,Mybatis 将执行 SQL 语句,并将可能返回的结果集转换成 List<E> 列表。
    • Mybatis 在对结果集的处理中,支持结果集关系一对多和多对一的转换,并且有两种支持方式,一种为嵌套查询语句的查询,还有一种是嵌套结果集的查询。

框架支撑层

  • 事务管理机制 - Mybatis 将事务抽象成了 Transaction 接口。Mybatis 的事务管理分为两种形式:

    • 使用 JDBC 的事务管理机制:即利用 java.sql.Connection 对象完成对事务的提交(commit)、回滚(rollback)、关闭(close)等。
    • 使用 MANAGED 的事务管理机制:Mybatis 自身不会去实现事务管理,而是让程序的容器如(JBOSS,Weblogic)来实现对事务的管理。
  • 连接池管理

  • SQL 语句的配置 - 支持两种方式:

    • xml 配置
    • 注解配置
  • 缓存机制 - Mybatis 采用两级缓存结构

    • 一级缓存是 Session 会话级别的缓存 - 一级缓存又被称之为本地缓存。一般而言,一个 SqlSession 对象会使用一个 Executor 对象来完成会话操作,Executor 对象会维护一个 Cache 缓存,以提高查询性能。
      • 一级缓存的生命周期是 Session 会话级别的。
    • 二级缓存是 Application 应用级别的缓存 - 用户配置了 "cacheEnabled=true",才会开启二级缓存。
      • 如果开启了二级缓存,SqlSession 会先使用 CachingExecutor 对象来处理查询请求。CachingExecutor 会在二级缓存中查看是否有匹配的数据,如果匹配,则直接返回缓存结果;如果缓存中没有,再交给真正的 Executor 对象来完成查询,之后 CachingExecutor 会将真正 Executor 返回的查询结果放置到缓存中,然后在返回给用户。
      • 二级缓存的生命周期是应用级别的。

img

SqlSession 内部工作机制

从前文,我们已经了解了,Mybatis 封装了对数据库的访问,把对数据库的会话和事务控制放到了 SqlSession 对象中。那么具体是如何工作的呢?接下来,我们通过源码解读来进行分析。

img

SqlSession 对于 insert、update、delete、select 的内部处理机制基本上大同小异。所以,接下来,我会以一次完整的 select 查询流程为例讲解 SqlSession 内部的工作机制。相信读者如果理解了 select 的处理流程,对于其他 CRUD 操作也能做到一通百通。

SqlSession 子组件

前面的内容已经介绍了:SqlSession 是 Mybatis 的顶层接口,它提供了所有执行语句,获取映射器和管理事务等方法。

实际上,SqlSession 是通过聚合多个子组件,让每个子组件负责各自功能的方式,实现了任务的下发。

在了解各个子组件工作机制前,先让我们简单认识一下 SqlSession 的核心子组件。

Executor

Executor 即执行器,它负责生成动态 SQL 以及管理缓存。

img

  • Executor 即执行器接口。
  • BaseExecutorExecutor 的抽象类,它采用了模板方法设计模式,内置了一些共性方法,而将定制化方法留给子类去实现。
  • SimpleExecutor 是最简单的执行器。它只会直接执行 SQL,不会做额外的事。
  • BatchExecutor 是批处理执行器。它的作用是通过批处理来优化性能。值得注意的是,批量更新操作,由于内部有缓存机制,使用完后需要调用 flushStatements 来清除缓存。
  • ReuseExecutor 是可重用的执行器。重用的对象是 Statement,也就是说,该执行器会缓存同一个 SQL 的 Statement,避免重复创建 Statement。其内部的实现是通过一个 HashMap 来维护 Statement 对象的。由于当前 Map 只在该 session 中有效,所以使用完后需要调用 flushStatements 来清除 Map。
  • CachingExecutor 是缓存执行器。它只在启用二级缓存时才会用到。

StatementHandler

StatementHandler 对象负责设置 Statement 对象中的查询参数、处理 JDBC 返回的 resultSet,将 resultSet 加工为 List 集合返回。

StatementHandler 的家族成员:

img

  • StatementHandler 是接口;
  • BaseStatementHandler 是实现 StatementHandler 的抽象类,内置一些共性方法;
  • SimpleStatementHandler 负责处理 Statement
  • PreparedStatementHandler 负责处理 PreparedStatement
  • CallableStatementHandler 负责处理 CallableStatement
  • RoutingStatementHandler 负责代理 StatementHandler 具体子类,根据 Statement 类型,选择实例化 SimpleStatementHandlerPreparedStatementHandlerCallableStatementHandler

ParameterHandler

ParameterHandler 负责将传入的 Java 对象转换 JDBC 类型对象,并为 PreparedStatement 的动态 SQL 填充数值。

ParameterHandler 只有一个具体实现类,即 DefaultParameterHandler

ResultSetHandler

ResultSetHandler 负责两件事:

  • 处理 Statement 执行后产生的结果集,生成结果列表
  • 处理存储过程执行后的输出参数

ResultSetHandler 只有一个具体实现类,即 DefaultResultSetHandler

TypeHandler

TypeHandler 负责将 Java 对象类型和 JDBC 类型进行相互转换。

SqlSession 和 Mapper

先来回忆一下 Mybatis 完整示例章节的 测试程序部分的代码。

MybatisDemo.java 文件中的代码片段:

1
2
3
4
5
6
7
8
9
// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}

示例代码中,给 sqlSession 对象的传递一个配置的 Sql 语句的 Statement Id 和参数,然后返回结果

io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey 是配置在 UserMapper.xml 的 Statement ID,params 是 SQL 参数。

UserMapper.xml 文件中的代码片段:

1
2
3
4
5
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>

Mybatis 通过方法的全限定名,将 SqlSession 和 Mapper 相互映射起来。

SqlSession 和 Executor

org.apache.ibatis.session.defaults.DefaultSqlSessionselectList 方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public <E> List<E> selectList(String statement) {
return this.selectList(statement, null);
}

@Override
public <E> List<E> selectList(String statement, Object parameter) {
return this.selectList(statement, parameter, RowBounds.DEFAULT);
}

@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
// 1. 根据 Statement Id,在配置对象 Configuration 中查找和配置文件相对应的 MappedStatement
MappedStatement ms = configuration.getMappedStatement(statement);
// 2. 将 SQL 语句交由执行器 Executor 处理
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}

说明:

Mybatis 所有的配置信息都维持在 Configuration 对象之中。中维护了一个 Map<String, MappedStatement> 对象。其中,key 为 Mapper 方法的全限定名(对于本例而言,key 就是 io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey ),value 为 MappedStatement 对象。所以,传入 Statement Id 就可以从 Map 中找到对应的 MappedStatement

MappedStatement 维护了一个 Mapper 方法的元数据信息,其数据组织可以参考下面的 debug 截图:

img

小结:

通过 “SqlSession 和 Mapper” 以及 “SqlSession 和 Executor” 这两节,我们已经知道:

SqlSession 的职能是:根据 Statement ID, 在 Configuration 中获取到对应的 MappedStatement 对象,然后调用 Executor 来执行具体的操作。

Executor 工作流程

继续上一节的流程,SqlSession 将 SQL 语句交由执行器 Executor 处理。Executor 又做了哪些事儿呢?

(1)执行器查询入口

1
2
3
4
5
6
7
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
// 1. 根据传参,动态生成需要执行的 SQL 语句,用 BoundSql 对象表示
BoundSql boundSql = ms.getBoundSql(parameter);
// 2. 根据传参,创建一个缓存Key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

执行器查询入口主要做两件事:

  • 生成动态 SQL:根据传参,动态生成需要执行的 SQL 语句,用 BoundSql 对象表示。
  • 管理缓存:根据传参,创建一个缓存 Key。

(2)执行器查询第二入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
// 略
List<E> list;
try {
queryStack++;
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
// 3. 缓存中有值,则直接从缓存中取数据;否则,查询数据库
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
// 略
return list;
}

实际查询方法主要的职能是判断缓存 key 是否能命中缓存:

  • 命中,则将缓存中数据返回;
  • 不命中,则查询数据库:

(3)查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
// 4. 执行查询,获取 List 结果,并将查询的结果更新本地缓存中
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}

queryFromDatabase 方法的职责是调用 doQuery,向数据库发起查询,并将返回的结果更新到本地缓存。

(4)实际查询方法

SimpleExecutor 类的 doQuery()方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
// 5. 根据既有的参数,创建StatementHandler对象来执行查询操作
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
// 6. 创建java.Sql.Statement对象,传递给StatementHandler对象
stmt = prepareStatement(handler, ms.getStatementLog());
// 7. 调用StatementHandler.query()方法,返回List结果
return handler.query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}

上述的 Executor.query()方法几经转折,最后会创建一个 StatementHandler 对象,然后将必要的参数传递给 StatementHandler,使用 StatementHandler 来完成对数据库的查询,最终返回 List 结果集。
从上面的代码中我们可以看出,Executor 的功能和作用是:

  1. 根据传递的参数,完成 SQL 语句的动态解析,生成 BoundSql 对象,供 StatementHandler 使用;

  2. 为查询创建缓存,以提高性能

  3. 创建 JDBC 的 Statement 连接对象,传递给 StatementHandler 对象,返回 List 查询结果。

prepareStatement() 方法的实现:

1
2
3
4
5
6
7
8
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
Statement stmt;
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection, transaction.getTimeout());
//对创建的Statement对象设置参数,即设置SQL 语句中 ? 设置为指定的参数
handler.parameterize(stmt);
return stmt;
}

对于 JDBC 的 PreparedStatement 类型的对象,创建的过程中,我们使用的是 SQL 语句字符串会包含 若干个? 占位符,我们其后再对占位符进行设值。

StatementHandler 工作流程

StatementHandler 有一个子类 RoutingStatementHandler,它负责代理其他 StatementHandler 子类的工作。

它会根据配置的 Statement 类型,选择实例化相应的 StatementHandler,然后由其代理对象完成工作。

【源码】RoutingStatementHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {

switch (ms.getStatementType()) {
case STATEMENT:
delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case CALLABLE:
delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
default:
throw new ExecutorException("Unknown statement type: " + ms.getStatementType());
}

}

【源码】RoutingStatementHandlerparameterize 方法源码

【源码】PreparedStatementHandlerparameterize 方法源码

StatementHandler 使用 ParameterHandler 对象来完成对 Statement 的赋值。

1
2
3
4
5
@Override
public void parameterize(Statement statement) throws SQLException {
// 使用 ParameterHandler 对象来完成对 Statement 的设值
parameterHandler.setParameters((PreparedStatement) statement);
}

【源码】StatementHandlerquery 方法源码

StatementHandler 使用 ResultSetHandler 对象来完成对 ResultSet 的处理。

1
2
3
4
5
6
7
@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
// 使用ResultHandler来处理ResultSet
return resultSetHandler.handleResultSets(ps);
}

ParameterHandler 工作流程

【源码】DefaultParameterHandlersetParameters 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 @Override
public void setParameters(PreparedStatement ps) {
// parameterMappings 是对占位符 #{} 对应参数的封装
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
// 不处理存储过程中的参数
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
// 获取对应的实际数值
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
// 获取对象中相应的属性或查找 Map 对象中的值
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}

TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) {
jdbcType = configuration.getJdbcTypeForNull();
}
try {
// 通过 TypeHandler 将 Java 对象参数转为 JDBC 类型的参数
// 然后,将数值动态绑定到 PreparedStaement 中
typeHandler.setParameter(ps, i + 1, value, jdbcType);
} catch (TypeException | SQLException e) {
throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
}
}
}
}
}

ResultSetHandler 工作流程

ResultSetHandler 的实现可以概括为:将 Statement 执行后的结果集,按照 Mapper 文件中配置的 ResultTypeResultMap 来转换成对应的 JavaBean 对象,最后将结果返回。

【源码】DefaultResultSetHandlerhandleResultSets 方法

handleResultSets 方法是 DefaultResultSetHandler 的最关键方法。其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Override
public List<Object> handleResultSets(Statement stmt) throws SQLException {
ErrorContext.instance().activity("handling results").object(mappedStatement.getId());

final List<Object> multipleResults = new ArrayList<>();

int resultSetCount = 0;
// 第一个结果集
ResultSetWrapper rsw = getFirstResultSet(stmt);
List<ResultMap> resultMaps = mappedStatement.getResultMaps();
// 判断结果集的数量
int resultMapCount = resultMaps.size();
validateResultMapsCount(rsw, resultMapCount);
// 遍历处理结果集
while (rsw != null && resultMapCount > resultSetCount) {
ResultMap resultMap = resultMaps.get(resultSetCount);
handleResultSet(rsw, resultMap, multipleResults, null);
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}

String[] resultSets = mappedStatement.getResultSets();
if (resultSets != null) {
while (rsw != null && resultSetCount < resultSets.length) {
ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
if (parentMapping != null) {
String nestedResultMapId = parentMapping.getNestedResultMapId();
ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
handleResultSet(rsw, resultMap, null, parentMapping);
}
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
}

return collapseSingleResultList(multipleResults);
}

参考资料