swift基础运算符

分類 编程语言, swift

基本运算符

运算符是检查、改变、合并值的特殊符号或短语。例如,加号(+)将两个数相加(如 let i = 1 + 2)。更复杂的运算例子包括逻辑与运算符 &&(如 if enteredDoorCode && passedRetinaScan)。

Swift 支持大部分标准 C 语言的运算符,且为了减少常见编码错误做了部分改进。如:赋值符(=)不再有返回值,这样就消除了手误将判等运算符(==)写成赋值符导致代码错误的缺陷。算术运算符(+-*/% 等)的结果会被检测并禁止值溢出,以此来避免保存变量时由于变量大于或小于其类型所能承载的范围时导致的异常结果。当然允许你使用 Swift 的溢出运算符来实现溢出。详情参见 溢出运算符

Swift 还提供了 C 语言没有的区间运算符,例如 a..<ba...b,这方便我们表达一个区间内的数值。

本章节只描述了 Swift 中的基本运算符,高级运算符 这章会包含 Swift 中的高级运算符,及如何自定义运算符,及如何进行自定义类型的运算符重载。

术语

运算符分为一元、二元和三元运算符:

  • 一元运算符对单一操作对象操作(如 -a)。一元运算符分前置运算符和后置运算符,前置运算符需紧跟在操作对象之前(如 !b),后置运算符需紧跟在操作对象之后(如 c!)。
  • 二元运算符操作两个操作对象(如 2 + 3),是中置的,因为它们出现在两个操作对象之间。
  • 三元运算符操作三个操作对象,和 C 语言一样,Swift 只有一个三元运算符,就是三目运算符(a ? b : c)。

受运算符影响的值叫操作数,在表达式 1 + 2 中,加号 + 是二元运算符,它的两个操作数是值 12

赋值运算符

赋值运算符a = b),表示用 b 的值来初始化或更新 a 的值:

1
2
3
4
let b = 10
var a = 5
a = b
// a 现在等于 10

如果赋值的右边是一个多元组,它的元素可以马上被分解成多个常量或变量:

1
2
let (x, y) = (1, 2)
// 现在 x 等于 1,y 等于 2

与 C 语言和 Objective-C 不同,Swift 的赋值操作并不返回任何值。所以下面语句是无效的:

1
2
3
if x = y {
// 此句错误,因为 x = y 并不返回任何值
}

通过将 if x = y 标记为无效语句,Swift 能帮你避免把 (==)错写成(=)这类错误的出现。

算术运算符

Swift 中所有数值类型都支持了基本的四则算术运算符

  • 加法(+
  • 减法(-
  • 乘法(*
  • 除法(/
1
2
3
4
1 + 2       // 等于 3
5 - 3 // 等于 2
2 * 3 // 等于 6
10.0 / 2.5 // 等于 4.0

与 C 语言和 Objective-C 不同的是,Swift 默认情况下不允许在数值运算中出现溢出情况。但是你可以使用 Swift 的溢出运算符来实现溢出运算(如 a &+ b)。详情参见 溢出运算符

加法运算符也可用于 String 的拼接:

1
"hello, " + "world"  // 等于 "hello, world"

求余运算符

求余运算符a % b)是计算 b 的多少倍刚刚好可以容入 a,返回多出来的那部分(余数)。

注意

求余运算符(%)在其他语言也叫取模运算符。但是严格说来,我们看该运算符对负数的操作结果,「求余」比「取模」更合适些。

我们来谈谈取余是怎么回事,计算 9 % 4,你先计算出 4 的多少倍会刚好可以容入 9 中:

Art/remainderInteger_2x.png

你可以在 9 中放入两个 4,那余数是 1(用橙色标出)。

在 Swift 中可以表达为:

1
9 % 4    // 等于 1

为了得到 a % b 的结果,% 计算了以下等式,并输出 余数作为结果:

1
a = (b × 倍数) + 余数

倍数取最大值的时候,就会刚好可以容入 a 中。

94 代入等式中,我们得 1

1
9 = (4 × 2) + 1

同样的方法,我们来计算 -9 % 4

1
-9 % 4   // 等于 -1

-94 代入等式,-2 是取到的最大整数:

1
-9 = (4 × -2) + -1

余数是 -1

在对负数 b 求余时,b 的符号会被忽略。这意味着 a % ba % -b 的结果是相同的。

一元负号运算符

数值的正负号可以使用前缀 -(即一元负号符)来切换:

1
2
3
let three = 3
let minusThree = -three // minusThree 等于 -3
let plusThree = -minusThree // plusThree 等于 3, 或 "负负3"

一元负号符(-)写在操作数之前,中间没有空格。

一元正号运算符

一元正号符+)不做任何改变地返回操作数的值:

1
2
let minusSix = -6
let alsoMinusSix = +minusSix // alsoMinusSix 等于 -6

虽然一元正号符什么都不会改变,但当你在使用一元负号来表达负数时,你可以使用一元正号来表达正数,如此你的代码会具有对称美。

组合赋值运算符

如同 C 语言,Swift 也提供把其他运算符和赋值运算(=)组合的组合赋值运算符,组合加运算(+=)是其中一个例子:

1
2
3
var a = 1
a += 2
// a 现在是 3

表达式 a += 2a = a + 2 的简写,一个组合加运算就是把加法运算和赋值运算组合成进一个运算符里,同时完成两个运算任务。

注意

复合赋值运算没有返回值,let b = a += 2 这类代码是错误。这不同于上面提到的自增和自减运算符。

更多 Swift 标准库运算符的信息,请看 运算符声明。 ‌

比较运算符(Comparison Operators)

所有标准 C 语言中的比较运算符都可以在 Swift 中使用:

  • 等于(a == b
  • 不等于(a != b
  • 大于(a > b
  • 小于(a < b
  • 大于等于(a >= b
  • 小于等于(a <= b

注意

Swift 也提供恒等(===)和不恒等(!==)这两个比较符来判断两个对象是否引用同一个对象实例。更多细节在 类与结构 章节的 Identity Operators 部分。

每个比较运算都返回了一个标识表达式是否成立的布尔值:

1
2
3
4
5
6
1 == 1   // true, 因为 1 等于 1
2 != 1 // true, 因为 2 不等于 1
2 > 1 // true, 因为 2 大于 1
1 < 2 // true, 因为 1 小于2
1 >= 1 // true, 因为 1 大于等于 1
2 <= 1 // false, 因为 2 并不小于等于 1

比较运算多用于条件语句,如 if 条件:

1
2
3
4
5
6
7
let name = "world"
if name == "world" {
print("hello, world")
} else {
print("I'm sorry \(name), but I don't recognize you")
}
// 输出“hello, world", 因为 `name` 就是等于 "world”

关于 if 语句,请看 控制流

如果两个元组的元素相同,且长度相同的话,元组就可以被比较。比较元组大小会按照从左到右、逐值比较的方式,直到发现有两个值不等时停止。如果所有的值都相等,那么这一对元组我们就称它们是相等的。例如:

1
2
3
(1, "zebra") < (2, "apple")   // true,因为 1 小于 2
(3, "apple") < (3, "bird") // true,因为 3 等于 3,但是 apple 小于 bird
(4, "dog") == (4, "dog") // true,因为 4 等于 4,dog 等于 dog

在上面的例子中,你可以看到,在第一行中从左到右的比较行为。因为 1 小于 2,所以 (1, "zebra") 小于 (2, "apple"),不管元组剩下的值如何。所以 "zebra" 大于 "apple" 对结果没有任何影响,因为元组的比较结果已经被第一个元素决定了。不过,当元组的第一个元素相同时候,第二个元素将会用作比较-第二行和第三行代码就发生了这样的比较。

当元组中的元素都可以被比较时,你也可以使用这些运算符来比较它们的大小。例如,像下面展示的代码,你可以比较两个类型为 (String, Int) 的元组,因为 IntString 类型的值可以比较。相反,Bool 不能被比较,也意味着存有布尔类型的元组不能被比较。

1
2
("blue", -1) < ("purple", 1)       // 正常,比较的结果为 true
("blue", false) < ("purple", true) // 错误,因为 < 不能比较布尔类型

注意

Swift 标准库只能比较七个以内元素的元组比较函数。如果你的元组元素超过七个时,你需要自己实现比较运算符。

三元运算符(Ternary Conditional Operator)

三元运算符的特殊在于它是有三个操作数的运算符,它的形式是 问题 ? 答案 1 : 答案 2。它简洁地表达根据 问题成立与否作出二选一的操作。如果 问题 成立,返回 答案 1 的结果;反之返回 答案 2 的结果。

三元运算符是以下代码的缩写形式:

1
2
3
4
5
if question {
answer1
} else {
answer2
}

这里有个计算表格行高的例子。如果有表头,那行高应比内容高度要高出 50 点;如果没有表头,只需高出 20 点:

1
2
3
4
let contentHeight = 40
let hasHeader = true
let rowHeight = contentHeight + (hasHeader ? 50 : 20)
// rowHeight 现在是 90

上面的写法比下面的代码更简洁:

1
2
3
4
5
6
7
8
9
let contentHeight = 40
let hasHeader = true
var rowHeight = contentHeight
if hasHeader {
rowHeight = rowHeight + 50
} else {
rowHeight = rowHeight + 20
}
// rowHeight 现在是 90

第一段代码例子使用了三元运算,所以一行代码就能让我们得到正确答案。这比第二段代码简洁得多,无需将 rowHeight 定义成变量,因为它的值无需在 if 语句中改变。

三元运算为二选一场景提供了一个非常便捷的表达形式。不过需要注意的是,滥用三元运算符会降低代码可读性。所以我们应避免在一个复合语句中使用多个三元运算符。

空合运算符(Nil Coalescing Operator)

空合运算符a ?? b)将对可选类型 a 进行空判断,如果 a 包含一个值就进行解包,否则就返回一个默认值 b。表达式 a 必须是 Optional 类型。默认值 b 的类型必须要和 a 存储值的类型保持一致。

空合运算符是对以下代码的简短表达方法:

1
a != nil ? a! : b

上述代码使用了三元运算符。当可选类型 a 的值不为空时,进行强制解封(a!),访问 a 中的值;反之返回默认值 b。无疑空合运算符(??)提供了一种更为优雅的方式去封装条件判断和解封两种行为,显得简洁以及更具可读性。

注意

如果 a 为非空值(non-nil),那么值 b 将不会被计算。这也就是所谓的短路求值

下文例子采用空合运算符,实现了在默认颜色名和可选自定义颜色名之间抉择:

1
2
3
4
5
let defaultColorName = "red"
var userDefinedColorName: String? //默认值为 nil

var colorNameToUse = userDefinedColorName ?? defaultColorName
// userDefinedColorName 的值为空,所以 colorNameToUse 的值为 "red"

userDefinedColorName 变量被定义为一个可选的 String 类型,默认值为 nil。由于 userDefinedColorName 是一个可选类型,我们可以使用空合运算符去判断其值。在上一个例子中,通过空合运算符为一个名为 colorNameToUse 的变量赋予一个字符串类型初始值。 由于 userDefinedColorName 值为空,因此表达式 userDefinedColorName ?? defaultColorName 返回 defaultColorName 的值,即 red

如果你分配一个非空值(non-nil)给 userDefinedColorName,再次执行空合运算,运算结果为封包在 userDefaultColorName 中的值,而非默认值。

1
2
3
userDefinedColorName = "green"
colorNameToUse = userDefinedColorName ?? defaultColorName
// userDefinedColorName 非空,因此 colorNameToUse 的值为 "green"

区间运算符(Range Operators)

Swift 提供了几种方便表达一个区间的值的区间运算符

闭区间运算符

闭区间运算符a...b)定义一个包含从 ab(包括 ab)的所有值的区间。a 的值不能超过 b

闭区间运算符在迭代一个区间的所有值时是非常有用的,如在 for-in 循环中:

1
2
3
4
5
6
7
8
for index in 1...5 {
print("\(index) * 5 = \(index * 5)")
}
// 1 * 5 = 5
// 2 * 5 = 10
// 3 * 5 = 15
// 4 * 5 = 20
// 5 * 5 = 25

关于 for-in 循环,请看 控制流

半开区间运算符

半开区间运算符a..<b)定义一个从 ab 但不包括 b 的区间。 之所以称为半开区间,是因为该区间包含第一个值而不包括最后的值。

半开区间的实用性在于当你使用一个从 0 开始的列表(如数组)时,非常方便地从0数到列表的长度。

1
2
3
4
5
6
7
8
9
let names = ["Anna", "Alex", "Brian", "Jack"]
let count = names.count
for i in 0..<count {
print("第 \(i + 1) 个人叫 \(names[i])")
}
// 第 1 个人叫 Anna
// 第 2 个人叫 Alex
// 第 3 个人叫 Brian
// 第 4 个人叫 Jack

数组有 4 个元素,但 0..<count 只数到3(最后一个元素的下标),因为它是半开区间。关于数组,请查阅 数组

单侧区间

闭区间操作符有另一个表达形式,可以表达往一侧无限延伸的区间 —— 例如,一个包含了数组从索引 2 到结尾的所有值的区间。在这些情况下,你可以省略掉区间操作符一侧的值。这种区间叫做单侧区间,因为操作符只有一侧有值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
for name in names[2...] {
print(name)
}
// Brian
// Jack

for name in names[...2] {
print(name)
}
// Anna
// Alex
// Brian

半开区间操作符也有单侧表达形式,附带上它的最终值。就像你使用区间去包含一个值,最终值并不会落在区间内。例如:

1
2
3
4
5
for name in names[..<2] {
print(name)
}
// Anna
// Alex

单侧区间不止可以在下标里使用,也可以在别的情境下使用。你不能遍历省略了初始值的单侧区间,因为遍历的开端并不明显。你可以遍历一个省略最终值的单侧区间;然而,由于这种区间无限延伸的特性,请保证你在循环里有一个结束循环的分支。你也可以查看一个单侧区间是否包含某个特定的值,就像下面展示的那样。

1
2
3
4
let range = ...5
range.contains(7) // false
range.contains(4) // true
range.contains(-1) // true

逻辑运算符(Logical Operators)

逻辑运算符的操作对象是逻辑布尔值。Swift 支持基于 C 语言的三个标准逻辑运算。

  • 逻辑非(!a
  • 逻辑与(a && b
  • 逻辑或(a || b

逻辑非运算符

逻辑非运算符!a)对一个布尔值取反,使得 truefalsefalsetrue

它是一个前置运算符,需紧跟在操作数之前,且不加空格。读作 非 a ,例子如下:

1
2
3
4
5
let allowedEntry = false
if !allowedEntry {
print("ACCESS DENIED")
}
// 输出“ACCESS DENIED”

if !allowedEntry 语句可以读作「如果非 allowedEntry」,接下一行代码只有在「非 allowedEntry」为 true,即 allowEntryfalse 时被执行。

在示例代码中,小心地选择布尔常量或变量有助于代码的可读性,并且避免使用双重逻辑非运算,或混乱的逻辑语句。

逻辑与运算符 #{logical_and_operator}

逻辑与运算符a && b)表达了只有 ab 的值都为 true 时,整个表达式的值才会是 true

只要任意一个值为 false,整个表达式的值就为 false。事实上,如果第一个值为 false,那么是不去计算第二个值的,因为它已经不可能影响整个表达式的结果了。这被称做短路计算(short-circuit evaluation)

以下例子,只有两个 Bool 值都为 true 的时候才允许进入 if:

1
2
3
4
5
6
7
8
let enteredDoorCode = true
let passedRetinaScan = false
if enteredDoorCode && passedRetinaScan {
print("Welcome!")
} else {
print("ACCESS DENIED")
}
// 输出“ACCESS DENIED”

逻辑或运算符 #{logical_or_operator}

逻辑或运算符(a || b)是一个由两个连续的 | 组成的中置运算符。它表示了两个逻辑表达式的其中一个为 true,整个表达式就为 true

同逻辑与运算符类似,逻辑或也是「短路计算」的,当左端的表达式为 true 时,将不计算右边的表达式了,因为它不可能改变整个表达式的值了。

以下示例代码中,第一个布尔值(hasDoorKey)为 false,但第二个值(knowsOverridePassword)为 true,所以整个表达是 true,于是允许进入:

1
2
3
4
5
6
7
8
let hasDoorKey = false
let knowsOverridePassword = true
if hasDoorKey || knowsOverridePassword {
print("Welcome!")
} else {
print("ACCESS DENIED")
}
// 输出“Welcome!”

逻辑运算符组合计算

我们可以组合多个逻辑运算符来表达一个复合逻辑:

1
2
3
4
5
6
if enteredDoorCode && passedRetinaScan || hasDoorKey || knowsOverridePassword {
print("Welcome!")
} else {
print("ACCESS DENIED")
}
// 输出“Welcome!”

这个例子使用了含多个 &&|| 的复合逻辑。但无论怎样,&&|| 始终只能操作两个值。所以这实际是三个简单逻辑连续操作的结果。我们来解读一下:

如果我们输入了正确的密码并通过了视网膜扫描,或者我们有一把有效的钥匙,又或者我们知道紧急情况下重置的密码,我们就能把门打开进入。

前两种情况,我们都不满足,所以前两个简单逻辑的结果是 false,但是我们是知道紧急情况下重置的密码的,所以整个复杂表达式的值还是 true

注意

Swift 逻辑操作符 &&|| 是左结合的,这意味着拥有多元逻辑操作符的复合表达式优先计算最左边的子表达式。

使用括号来明确优先级

为了一个复杂表达式更容易读懂,在合适的地方使用括号来明确优先级是很有效的,虽然它并非必要的。在上个关于门的权限的例子中,我们给第一个部分加个括号,使它看起来逻辑更明确:

1
2
3
4
5
6
if (enteredDoorCode && passedRetinaScan) || hasDoorKey || knowsOverridePassword {
print("Welcome!")
} else {
print("ACCESS DENIED")
}
// 输出“Welcome!”

这括号使得前两个值被看成整个逻辑表达中独立的一个部分。虽然有括号和没括号的输出结果是一样的,但对于读代码的人来说有括号的代码更清晰。可读性比简洁性更重要,请在可以让你代码变清晰的地方加个括号吧!

留言與分享

swift基础

分類 编程语言, swift

基础部分

Swift 是一门开发 iOS, macOS, watchOS 和 tvOS 应用的新语言。然而,如果你有 C 或者 Objective-C 开发经验的话,你会发现 Swift 的很多内容都是你熟悉的。

Swift 包含了 C 和 Objective-C 上所有基础数据类型,Int 表示整型值; DoubleFloat 表示浮点型值; Bool 是布尔型值;String 是文本型数据。 Swift 还提供了三个基本的集合类型,ArraySetDictionary ,详见 集合类型

就像 C 语言一样,Swift 使用变量来进行存储并通过变量名来关联值。在 Swift 中,广泛的使用着值不可变的变量,它们就是常量,而且比 C 语言的常量更强大。在 Swift 中,如果你要处理的值不需要改变,那使用常量可以让你的代码更加安全并且更清晰地表达你的意图。

除了我们熟悉的类型,Swift 还增加了 Objective-C 中没有的高阶数据类型比如元组(Tuple)。元组可以让你创建或者传递一组数据,比如作为函数的返回值时,你可以用一个元组可以返回多个值。

Swift 还增加了可选(Optional)类型,用于处理值缺失的情况。可选表示 “那儿有一个值,并且它等于 x ” 或者 “那儿没有值” 。可选有点像在 Objective-C 中使用 nil ,但是它可以用在任何类型上,不仅仅是类。可选类型比 Objective-C 中的 nil 指针更加安全也更具表现力,它是 Swift 许多强大特性的重要组成部分。

Swift 是一门类型安全的语言,这意味着 Swift 可以让你清楚地知道值的类型。如果你的代码需要一个 String ,类型安全会阻止你不小心传入一个 Int 。同样的,如果你的代码需要一个 String,类型安全会阻止你意外传入一个可选的 String 。类型安全可以帮助你在开发阶段尽早发现并修正错误。

常量和变量

常量和变量把一个名字(比如 maximumNumberOfLoginAttempts 或者 welcomeMessage )和一个指定类型的值(比如数字 10 或者字符串 "Hello" )关联起来。常量的值一旦设定就不能改变,而变量的值可以随意更改。

声明常量和变量

常量和变量必须在使用前声明,用 let 来声明常量,用 var 来声明变量。下面的例子展示了如何用常量和变量来记录用户尝试登录的次数:

1
2
let maximumNumberOfLoginAttempts = 10
var currentLoginAttempt = 0

这两行代码可以被理解为:

“声明一个名字是 maximumNumberOfLoginAttempts 的新常量,并给它一个值 10 。然后,声明一个名字是 currentLoginAttempt 的变量并将它的值初始化为 0 。”

在这个例子中,允许的最大尝试登录次数被声明为一个常量,因为这个值不会改变。当前尝试登录次数被声明为一个变量,因为每次尝试登录失败的时候都需要增加这个值。

你可以在一行中声明多个常量或者多个变量,用逗号隔开:

1
var x = 0.0, y = 0.0, z = 0.0

注意

如果你的代码中有不需要改变的值,请使用 let 关键字将它声明为常量。只将需要改变的值声明为变量。

类型注解

当你声明常量或者变量的时候可以加上类型注解(type annotation),说明常量或者变量中要存储的值的类型。如果要添加类型注解,需要在常量或者变量名后面加上一个冒号和空格,然后加上类型名称。

这个例子给 welcomeMessage 变量添加了类型注解,表示这个变量可以存储 String 类型的值:

1
var welcomeMessage: String

声明中的冒号代表着*“是…类型”*,所以这行代码可以被理解为:

“声明一个类型为 String ,名字为 welcomeMessage 的变量。”

“类型为 String ”的意思是“可以存储任意 String 类型的值。”

welcomeMessage 变量现在可以被设置成任意字符串:

1
welcomeMessage = "Hello"

你可以在一行中定义多个同样类型的变量,用逗号分割,并在最后一个变量名之后添加类型注解:

1
var red, green, blue: Double

注意

一般来说你很少需要写类型注解。如果你在声明常量或者变量的时候赋了一个初始值,Swift 可以推断出这个常量或者变量的类型,请参考 类型安全和类型推断。在上面的例子中,没有给 welcomeMessage 赋初始值,所以变量 welcomeMessage 的类型是通过一个类型注解指定的,而不是通过初始值推断的。

常量和变量的命名

常量和变量名可以包含任何字符,包括 Unicode 字符:

1
2
3
let π = 3.14159
let 你好 = "你好世界"
let 🐶🐮 = "dogcow"

常量与变量名不能包含数学符号,箭头,保留的(或者非法的)Unicode 码位,连线与制表符。也不能以数字开头,但是可以在常量与变量名的其他地方包含数字。

一旦你将常量或者变量声明为确定的类型,你就不能使用相同的名字再次进行声明,或者改变其存储的值的类型。同时,你也不能将常量与变量进行互转。

注意

如果你需要使用与 Swift 保留关键字相同的名称作为常量或者变量名,你可以使用反引号(`)将关键字包围的方式将其作为名字使用。无论如何,你应当避免使用关键字作为常量或变量名,除非你别无选择。

你可以更改现有的变量值为其他同类型的值,在下面的例子中,friendlyWelcome 的值从 "Hello!" 改为了 "Bonjour!":

1
2
3
var friendlyWelcome = "Hello!"
friendlyWelcome = "Bonjour!"
// friendlyWelcome 现在是 "Bonjour!"

与变量不同,常量的值一旦被确定就不能更改了。尝试这样做会导致编译时报错:

1
2
3
let languageName = "Swift"
languageName = "Swift++"
// 这会报编译时错误 - languageName 不可改变

输出常量和变量

你可以用 print(_:separator:terminator:) 函数来输出当前常量或变量的值:

1
2
print(friendlyWelcome)
// 输出“Bonjour!”

print(_:separator:terminator:) 是一个用来输出一个或多个值到适当输出区的全局函数。如果你用 Xcode,print(_:separator:terminator:) 将会输出内容到“console”面板上。separatorterminator 参数具有默认值,因此你调用这个函数的时候可以忽略它们。默认情况下,该函数通过添加换行符来结束当前行。如果不想换行,可以传递一个空字符串给 terminator 参数–例如,print(someValue, terminator:"") 。关于参数默认值的更多信息,请参考 默认参数值

Swift 用*字符串插值(string interpolation)*的方式把常量名或者变量名当做占位符加入到长字符串中,Swift 会用当前常量或变量的值替换这些占位符。将常量或变量名放入圆括号中,并在开括号前使用反斜杠将其转义:

1
2
print("The current value of friendlyWelcome is \(friendlyWelcome)")
// 输出“The current value of friendlyWelcome is Bonjour!”

注意

字符串插值所有可用的选项,请参考 字符串插值

注释

请将你的代码中的非执行文本注释成提示或者笔记以方便你将来阅读。Swift 的编译器将会在编译代码时自动忽略掉注释部分。

Swift 中的注释与 C 语言的注释非常相似。单行注释以双正斜杠(//)作为起始标记:

1
// 这是一个注释

你也可以进行多行注释,其起始标记为单个正斜杠后跟随一个星号(/*),终止标记为一个星号后跟随单个正斜杠(*/):

1
2
/* 这也是一个注释,
但是是多行的 */

与 C 语言多行注释不同,Swift 的多行注释可以嵌套在其它的多行注释之中。你可以先生成一个多行注释块,然后在这个注释块之中再嵌套成第二个多行注释。终止注释时先插入第二个注释块的终止标记,然后再插入第一个注释块的终止标记:

1
2
3
/* 这是第一个多行注释的开头
/* 这是第二个被嵌套的多行注释 */
这是第一个多行注释的结尾 */

通过运用嵌套多行注释,你可以快速方便的注释掉一大段代码,即使这段代码之中已经含有了多行注释块。

分号

与其他大部分编程语言不同,Swift 并不强制要求你在每条语句的结尾处使用分号(;),当然,你也可以按照你自己的习惯添加分号。有一种情况下必须要用分号,即你打算在同一行内写多条独立的语句:

1
2
let cat = "🐱"; print(cat)
// 输出“🐱”

整数

整数就是没有小数部分的数字,比如 42-23 。整数可以是 有符号(正、负、零)或者 无符号(正、零)。

Swift 提供了8、16、32和64位的有符号和无符号整数类型。这些整数类型和 C 语言的命名方式很像,比如8位无符号整数类型是 UInt8,32位有符号整数类型是 Int32 。就像 Swift 的其他类型一样,整数类型采用大写命名法。

整数范围

你可以访问不同整数类型的 minmax 属性来获取对应类型的最小值和最大值:

1
2
let minValue = UInt8.min  // minValue 为 0,是 UInt8 类型
let maxValue = UInt8.max // maxValue 为 255,是 UInt8 类型

minmax 所传回值的类型,正是其所对的整数类型(如上例 UInt8, 所传回的类型是 UInt8),可用在表达式中相同类型值旁。

Int

一般来说,你不需要专门指定整数的长度。Swift 提供了一个特殊的整数类型 Int,长度与当前平台的原生字长相同:

  • 在32位平台上,IntInt32 长度相同。
  • 在64位平台上,IntInt64 长度相同。

除非你需要特定长度的整数,一般来说使用 Int 就够了。这可以提高代码一致性和可复用性。即使是在32位平台上,Int 可以存储的整数范围也可以达到 -2,147,483,648 ~ 2,147,483,647,大多数时候这已经足够大了。

UInt

Swift 也提供了一个特殊的无符号类型 UInt,长度与当前平台的原生字长相同:

  • 在32位平台上,UIntUInt32 长度相同。
  • 在64位平台上,UIntUInt64 长度相同。

注意

尽量不要使用 UInt,除非你真的需要存储一个和当前平台原生字长相同的无符号整数。除了这种情况,最好使用 Int,即使你要存储的值已知是非负的。统一使用 Int 可以提高代码的可复用性,避免不同类型数字之间的转换,并且匹配数字的类型推断,请参考 类型安全和类型推断

浮点数

浮点数是有小数部分的数字,比如 3.141590.1-273.15

浮点类型比整数类型表示的范围更大,可以存储比 Int 类型更大或者更小的数字。Swift 提供了两种有符号浮点数类型:

  • Double 表示64位浮点数。当你需要存储很大或者很高精度的浮点数时请使用此类型。
  • Float 表示32位浮点数。精度要求不高的话可以使用此类型。

注意

Double 精确度很高,至少有15位数字,而 Float 只有6位数字。选择哪个类型取决于你的代码需要处理的值的范围,在两种类型都匹配的情况下,将优先选择 Double

类型安全和类型推断

Swift 是一个*类型安全(type safe)*的语言。类型安全的语言可以让你清楚地知道代码要处理的值的类型。如果你的代码需要一个 String,你绝对不可能不小心传进去一个 Int

由于 Swift 是类型安全的,所以它会在编译你的代码时进行类型检查(type checks),并把不匹配的类型标记为错误。这可以让你在开发的时候尽早发现并修复错误。

当你要处理不同类型的值时,类型检查可以帮你避免错误。然而,这并不是说你每次声明常量和变量的时候都需要显式指定类型。如果你没有显式指定类型,Swift 会使用*类型推断(type inference)*来选择合适的类型。有了类型推断,编译器可以在编译代码的时候自动推断出表达式的类型。原理很简单,只要检查你赋的值即可。

因为有类型推断,和 C 或者 Objective-C 比起来 Swift 很少需要声明类型。常量和变量虽然需要明确类型,但是大部分工作并不需要你自己来完成。

当你声明常量或者变量并赋初值的时候类型推断非常有用。当你在声明常量或者变量的时候赋给它们一个字面量(literal value 或 literal)即可触发类型推断。(字面量就是会直接出现在你代码中的值,比如 423.14159 。)

例如,如果你给一个新常量赋值 42 并且没有标明类型,Swift 可以推断出常量类型是 Int ,因为你给它赋的初始值看起来像一个整数:

1
2
let meaningOfLife = 42
// meaningOfLife 会被推测为 Int 类型

同理,如果你没有给浮点字面量标明类型,Swift 会推断你想要的是 Double

1
2
let pi = 3.14159
// pi 会被推测为 Double 类型

当推断浮点数的类型时,Swift 总是会选择 Double 而不是 Float

如果表达式中同时出现了整数和浮点数,会被推断为 Double 类型:

1
2
let anotherPi = 3 + 0.14159
// anotherPi 会被推测为 Double 类型

原始值 3 没有显式声明类型,而表达式中出现了一个浮点字面量,所以表达式会被推断为 Double 类型。

数值型字面量

整数字面量可以被写作:

  • 一个十进制数,没有前缀
  • 一个二进制数,前缀是 0b
  • 一个八进制数,前缀是 0o
  • 一个十六进制数,前缀是 0x

下面的所有整数字面量的十进制值都是 17:

1
2
3
4
let decimalInteger = 17
let binaryInteger = 0b10001 // 二进制的17
let octalInteger = 0o21 // 八进制的17
let hexadecimalInteger = 0x11 // 十六进制的17

浮点字面量可以是十进制(没有前缀)或者是十六进制(前缀是 0x )。小数点两边必须有至少一个十进制数字(或者是十六进制的数字)。十进制浮点数也可以有一个可选的指数(exponent),通过大写或者小写的 e 来指定;十六进制浮点数必须有一个指数,通过大写或者小写的 p 来指定。

如果一个十进制数的指数为 exp,那这个数相当于基数和10^exp 的乘积:

  • 1.25e2 表示 1.25 × 10^2,等于 125.0
  • 1.25e-2 表示 1.25 × 10^-2,等于 0.0125

如果一个十六进制数的指数为 exp,那这个数相当于基数和2^exp 的乘积:

  • 0xFp2 表示 15 × 2^2,等于 60.0
  • 0xFp-2 表示 15 × 2^-2,等于 3.75

下面的这些浮点字面量都等于十进制的 12.1875

1
2
3
let decimalDouble = 12.1875
let exponentDouble = 1.21875e1
let hexadecimalDouble = 0xC.3p0

数值类字面量可以包括额外的格式来增强可读性。整数和浮点数都可以添加额外的零并且包含下划线,并不会影响字面量:

1
2
3
let paddedDouble = 000123.456
let oneMillion = 1_000_000
let justOverOneMillion = 1_000_000.000_000_1

数值型类型转换

通常来讲,即使代码中的整数常量和变量已知非负,也请使用 Int 类型。总是使用默认的整数类型可以保证你的整数常量和变量可以直接被复用并且可以匹配整数类字面量的类型推断。

只有在必要的时候才使用其他整数类型,比如要处理外部的长度明确的数据或者为了优化性能、内存占用等等。使用显式指定长度的类型可以及时发现值溢出并且可以暗示正在处理特殊数据。

整数转换

不同整数类型的变量和常量可以存储不同范围的数字。Int8 类型的常量或者变量可以存储的数字范围是 -128~127,而 UInt8 类型的常量或者变量能存储的数字范围是 0~255。如果数字超出了常量或者变量可存储的范围,编译的时候会报错:

1
2
3
4
let cannotBeNegative: UInt8 = -1
// UInt8 类型不能存储负数,所以会报错
let tooBig: Int8 = Int8.max + 1
// Int8 类型不能存储超过最大值的数,所以会报错

由于每种整数类型都可以存储不同范围的值,所以你必须根据不同情况选择性使用数值型类型转换。这种选择性使用的方式,可以预防隐式转换的错误并让你的代码中的类型转换意图变得清晰。

要将一种数字类型转换成另一种,你要用当前值来初始化一个期望类型的新数字,这个数字的类型就是你的目标类型。在下面的例子中,常量 twoThousandUInt16 类型,然而常量 oneUInt8 类型。它们不能直接相加,因为它们类型不同。所以要调用 UInt16(one) 来创建一个新的 UInt16 数字并用 one 的值来初始化,然后使用这个新数字来计算:

1
2
3
let twoThousand: UInt16 = 2_000
let one: UInt8 = 1
let twoThousandAndOne = twoThousand + UInt16(one)

现在两个数字的类型都是 UInt16,可以进行相加。目标常量 twoThousandAndOne 的类型被推断为 UInt16,因为它是两个 UInt16 值的和。

SomeType(ofInitialValue) 是调用 Swift 构造器并传入一个初始值的默认方法。在语言内部,UInt16 有一个构造器,可以接受一个 UInt8 类型的值,所以这个构造器可以用现有的 UInt8 来创建一个新的 UInt16。注意,你并不能传入任意类型的值,只能传入 UInt16 内部有对应构造器的值。不过你可以扩展现有的类型来让它可以接收其他类型的值(包括自定义类型),请参考 扩展

整数和浮点数转换

整数和浮点数的转换必须显式指定类型:

1
2
3
4
let three = 3
let pointOneFourOneFiveNine = 0.14159
let pi = Double(three) + pointOneFourOneFiveNine
// pi 等于 3.14159,所以被推测为 Double 类型

这个例子中,常量 three 的值被用来创建一个 Double 类型的值,所以加号两边的数类型须相同。如果不进行转换,两者无法相加。

浮点数到整数的反向转换同样行,整数类型可以用 Double 或者 Float 类型来初始化:

1
2
let integerPi = Int(pi)
// integerPi 等于 3,所以被推测为 Int 类型

当用这种方式来初始化一个新的整数值时,浮点值会被截断。也就是说 4.75 会变成 4-3.9 会变成 -3

注意

结合数字类常量和变量不同于结合数字类字面量。字面量 3 可以直接和字面量 0.14159 相加,因为数字字面量本身没有明确的类型。它们的类型只在编译器需要求值的时候被推测。

类型别名

*类型别名(type aliases)*就是给现有类型定义另一个名字。你可以使用 typealias 关键字来定义类型别名。

当你想要给现有类型起一个更有意义的名字时,类型别名非常有用。假设你正在处理特定长度的外部资源的数据:

1
typealias AudioSample = UInt16

定义了一个类型别名之后,你可以在任何使用原始名的地方使用别名:

1
2
var maxAmplitudeFound = AudioSample.min
// maxAmplitudeFound 现在是 0

本例中,AudioSample 被定义为 UInt16 的一个别名。因为它是别名,AudioSample.min 实际上是 UInt16.min,所以会给 maxAmplitudeFound 赋一个初值 0

布尔值

Swift 有一个基本的布尔(Boolean)类型,叫做 Bool。布尔值指逻辑上的值,因为它们只能是真或者假。Swift 有两个布尔常量,truefalse

1
2
let orangesAreOrange = true
let turnipsAreDelicious = false

orangesAreOrangeturnipsAreDelicious 的类型会被推断为 Bool,因为它们的初值是布尔字面量。就像之前提到的 IntDouble 一样,如果你创建变量的时候给它们赋值 true 或者 false,那你不需要将常量或者变量声明为 Bool 类型。初始化常量或者变量的时候如果所赋的值类型已知,就可以触发类型推断,这让 Swift 代码更加简洁并且可读性更高。

当你编写条件语句比如 if 语句的时候,布尔值非常有用:

1
2
3
4
5
6
if turnipsAreDelicious {
print("Mmm, tasty turnips!")
} else {
print("Eww, turnips are horrible.")
}
// 输出“Eww, turnips are horrible.”

条件语句,例如 if,请参考 控制流

如果你在需要使用 Bool 类型的地方使用了非布尔值,Swift 的类型安全机制会报错。下面的例子会报告一个编译时错误:

1
2
3
4
let i = 1
if i {
// 这个例子不会通过编译,会报错
}

然而,下面的例子是合法的:

1
2
3
4
let i = 1
if i == 1 {
// 这个例子会编译成功
}

i == 1 的比较结果是 Bool 类型,所以第二个例子可以通过类型检查。类似 i == 1 这样的比较,请参考 基本操作符

和 Swift 中的其他类型安全的例子一样,这个方法可以避免错误并保证这块代码的意图总是清晰的。

元组

*元组(tuples)*把多个值组合成一个复合值。元组内的值可以是任意类型,并不要求是相同类型。

下面这个例子中,(404, "Not Found") 是一个描述 *HTTP 状态码(HTTP status code)*的元组。HTTP 状态码是当你请求网页的时候 web 服务器返回的一个特殊值。如果你请求的网页不存在就会返回一个 404 Not Found 状态码。

1
2
let http404Error = (404, "Not Found")
// http404Error 的类型是 (Int, String),值是 (404, "Not Found")

(404, "Not Found") 元组把一个 Int 值和一个 String 值组合起来表示 HTTP 状态码的两个部分:一个数字和一个人类可读的描述。这个元组可以被描述为“一个类型为 (Int, String) 的元组”。

你可以把任意顺序的类型组合成一个元组,这个元组可以包含所有类型。只要你想,你可以创建一个类型为 (Int, Int, Int) 或者 (String, Bool) 或者其他任何你想要的组合的元组。

你可以将一个元组的内容分解(decompose)成单独的常量和变量,然后你就可以正常使用它们了:

1
2
3
4
5
let (statusCode, statusMessage) = http404Error
print("The status code is \(statusCode)")
// 输出“The status code is 404”
print("The status message is \(statusMessage)")
// 输出“The status message is Not Found”

如果你只需要一部分元组值,分解的时候可以把要忽略的部分用下划线(_)标记:

1
2
3
let (justTheStatusCode, _) = http404Error
print("The status code is \(justTheStatusCode)")
// 输出“The status code is 404”

此外,你还可以通过下标来访问元组中的单个元素,下标从零开始:

1
2
3
4
print("The status code is \(http404Error.0)")
// 输出“The status code is 404”
print("The status message is \(http404Error.1)")
// 输出“The status message is Not Found”

你可以在定义元组的时候给单个元素命名:

1
let http200Status = (statusCode: 200, description: "OK")

给元组中的元素命名后,你可以通过名字来获取这些元素的值:

1
2
3
4
print("The status code is \(http200Status.statusCode)")
// 输出“The status code is 200”
print("The status message is \(http200Status.description)")
// 输出“The status message is OK”

作为函数返回值时,元组非常有用。一个用来获取网页的函数可能会返回一个 (Int, String) 元组来描述是否获取成功。和只能返回一个类型的值比较起来,一个包含两个不同类型值的元组可以让函数的返回信息更有用。请参考 函数参数与返回值

注意

当遇到一些相关值的简单分组时,元组是很有用的。元组不适合用来创建复杂的数据结构。如果你的数据结构比较复杂,不要使用元组,用类或结构体去建模。欲获得更多信息,请参考 结构体和类

可选类型

使用*可选类型(optionals)*来处理值可能缺失的情况。可选类型表示两种可能: 或者有值, 你可以解析可选类型访问这个值, 或者根本没有值。

注意

C 和 Objective-C 中并没有可选类型这个概念。最接近的是 Objective-C 中的一个特性,一个方法要不返回一个对象要不返回 nilnil 表示“缺少一个合法的对象”。然而,这只对对象起作用——对于结构体,基本的 C 类型或者枚举类型不起作用。对于这些类型,Objective-C 方法一般会返回一个特殊值(比如 NSNotFound)来暗示值缺失。这种方法假设方法的调用者知道并记得对特殊值进行判断。然而,Swift 的可选类型可以让你暗示任意类型的值缺失,并不需要一个特殊值。

来看一个例子。Swift 的 Int 类型有一种构造器,作用是将一个 String 值转换成一个 Int 值。然而,并不是所有的字符串都可以转换成一个整数。字符串 "123" 可以被转换成数字 123 ,但是字符串 "hello, world" 不行。

下面的例子使用这种构造器来尝试将一个 String 转换成 Int

1
2
3
let possibleNumber = "123"
let convertedNumber = Int(possibleNumber)
// convertedNumber 被推测为类型 "Int?", 或者类型 "optional Int"

因为该构造器可能会失败,所以它返回一个可选类型(optional)Int,而不是一个 Int。一个可选的 Int 被写作 Int? 而不是 Int。问号暗示包含的值是可选类型,也就是说可能包含 Int 值也可能不包含值。(不能包含其他任何值比如 Bool 值或者 String 值。只能是 Int 或者什么都没有。)

nil

你可以给可选变量赋值为 nil 来表示它没有值:

1
2
3
4
var serverResponseCode: Int? = 404
// serverResponseCode 包含一个可选的 Int 值 404
serverResponseCode = nil
// serverResponseCode 现在不包含值

注意

nil 不能用于非可选的常量和变量。如果你的代码中有常量或者变量需要处理值缺失的情况,请把它们声明成对应的可选类型。

如果你声明一个可选常量或者变量但是没有赋值,它们会自动被设置为 nil

1
2
var surveyAnswer: String?
// surveyAnswer 被自动设置为 nil

注意

Swift 的 nil 和 Objective-C 中的 nil 并不一样。在 Objective-C 中,nil 是一个指向不存在对象的指针。在 Swift 中,nil 不是指针——它是一个确定的值,用来表示值缺失。任何类型的可选状态都可以被设置为 nil,不只是对象类型。

if 语句以及强制解析

你可以使用 if 语句和 nil 比较来判断一个可选值是否包含值。你可以使用“相等”(==)或“不等”(!=)来执行比较。

如果可选类型有值,它将不等于 nil

1
2
3
4
if convertedNumber != nil {
print("convertedNumber contains some integer value.")
}
// 输出“convertedNumber contains some integer value.”

当你确定可选类型确实包含值之后,你可以在可选的名字后面加一个感叹号(!)来获取值。这个惊叹号表示“我知道这个可选有值,请使用它。”这被称为可选值的强制解析(forced unwrapping)

1
2
3
4
if convertedNumber != nil {
print("convertedNumber has an integer value of \(convertedNumber!).")
}
// 输出“convertedNumber has an integer value of 123.”

更多关于 if 语句的内容,请参考 控制流

注意

使用 ! 来获取一个不存在的可选值会导致运行时错误。使用 ! 来强制解析值之前,一定要确定可选包含一个非 nil 的值。

可选绑定

使用*可选绑定(optional binding)*来判断可选类型是否包含值,如果包含就把值赋给一个临时常量或者变量。可选绑定可以用在 ifwhile 语句中,这条语句不仅可以用来判断可选类型中是否有值,同时可以将可选类型中的值赋给一个常量或者变量。ifwhile 语句,请参考 控制流

像下面这样在 if 语句中写一个可选绑定:

1
2
3
if let constantName = someOptional {
statements
}

你可以像上面这样使用可选绑定来重写 在 可选类型 举出的 possibleNumber 例子:

1
2
3
4
5
6
if let actualNumber = Int(possibleNumber) {
print("\'\(possibleNumber)\' has an integer value of \(actualNumber)")
} else {
print("\'\(possibleNumber)\' could not be converted to an integer")
}
// 输出“'123' has an integer value of 123”

这段代码可以被理解为:

“如果 Int(possibleNumber) 返回的可选 Int 包含一个值,创建一个叫做 actualNumber 的新常量并将可选包含的值赋给它。”

如果转换成功,actualNumber 常量可以在 if 语句的第一个分支中使用。它已经被可选类型 包含的 值初始化过,所以不需要再使用 ! 后缀来获取它的值。在这个例子中,actualNumber 只被用来输出转换结果。

你可以在可选绑定中使用常量和变量。如果你想在 if 语句的第一个分支中操作 actualNumber 的值,你可以改成 if var actualNumber,这样可选类型包含的值就会被赋给一个变量而非常量。

你可以包含多个可选绑定或多个布尔条件在一个 if 语句中,只要使用逗号分开就行。只要有任意一个可选绑定的值为 nil,或者任意一个布尔条件为 false,则整个 if 条件判断为 false,这时你就需要使用嵌套 if 条件语句来处理,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
if let firstNumber = Int("4"), let secondNumber = Int("42"), firstNumber < secondNumber && secondNumber < 100 {
print("\(firstNumber) < \(secondNumber) < 100")
}
// 输出“4 < 42 < 100”

if let firstNumber = Int("4") {
if let secondNumber = Int("42") {
if firstNumber < secondNumber && secondNumber < 100 {
print("\(firstNumber) < \(secondNumber) < 100")
}
}
}
// 输出“4 < 42 < 100”

注意

if 条件语句中使用常量和变量来创建一个可选绑定,仅在 if 语句的句中(body)中才能获取到值。相反,在 guard 语句中使用常量和变量来创建一个可选绑定,仅在 guard 语句外且在语句后才能获取到值,请参考 提前退出

隐式解析可选类型

如上所述,可选类型暗示了常量或者变量可以“没有值”。可选可以通过 if 语句来判断是否有值,如果有值的话可以通过可选绑定来解析值。

有时候在程序架构中,第一次被赋值之后,可以确定一个可选类型总会有值。在这种情况下,每次都要判断和解析可选值是非常低效的,因为可以确定它总会有值。

这种类型的可选状态被定义为隐式解析可选类型(implicitly unwrapped optionals)。把想要用作可选的类型的后面的问号(String?)改成感叹号(String!)来声明一个隐式解析可选类型。

当可选类型被第一次赋值之后就可以确定之后一直有值的时候,隐式解析可选类型非常有用。隐式解析可选类型主要被用在 Swift 中类的构造过程中,请参考 无主引用以及隐式解析可选属性

一个隐式解析可选类型其实就是一个普通的可选类型,但是可以被当做非可选类型来使用,并不需要每次都使用解析来获取可选值。下面的例子展示了可选类型 String 和隐式解析可选类型 String 之间的区别:

1
2
3
4
5
let possibleString: String? = "An optional string."
let forcedString: String = possibleString! // 需要感叹号来获取值

let assumedString: String! = "An implicitly unwrapped optional string."
let implicitString: String = assumedString // 不需要感叹号

你可以把隐式解析可选类型当做一个可以自动解析的可选类型。你要做的只是声明的时候把感叹号放到类型的结尾,而不是每次取值的可选名字的结尾。

注意

如果你在隐式解析可选类型没有值的时候尝试取值,会触发运行时错误。和你在没有值的普通可选类型后面加一个惊叹号一样。

你仍然可以把隐式解析可选类型当做普通可选类型来判断它是否包含值:

1
2
3
4
if assumedString != nil {
print(assumedString!)
}
// 输出“An implicitly unwrapped optional string.”

你也可以在可选绑定中使用隐式解析可选类型来检查并解析它的值:

1
2
3
4
if let definiteString = assumedString {
print(definiteString)
}
// 输出“An implicitly unwrapped optional string.”

注意

如果一个变量之后可能变成 nil 的话请不要使用隐式解析可选类型。如果你需要在变量的生命周期中判断是否是 nil 的话,请使用普通可选类型。

错误处理

你可以使用 错误处理(error handling) 来应对程序执行中可能会遇到的错误条件。

相对于可选中运用值的存在与缺失来表达函数的成功与失败,错误处理可以推断失败的原因,并传播至程序的其他部分。

当一个函数遇到错误条件,它能报错。调用函数的地方能抛出错误消息并合理处理。

1
2
3
func canThrowAnError() throws {
// 这个函数有可能抛出错误
}

一个函数可以通过在声明中添加 throws 关键词来抛出错误消息。当你的函数能抛出错误消息时,你应该在表达式中前置 try 关键词。

1
2
3
4
5
6
do {
try canThrowAnError()
// 没有错误消息抛出
} catch {
// 有一个错误消息抛出
}

一个 do 语句创建了一个新的包含作用域,使得错误能被传播到一个或多个 catch 从句。

这里有一个错误处理如何用来应对不同错误条件的例子。

1
2
3
4
5
6
7
8
9
10
11
12
func makeASandwich() throws {
// ...
}

do {
try makeASandwich()
eatASandwich()
} catch SandwichError.outOfCleanDishes {
washDishes()
} catch SandwichError.missingIngredients(let ingredients) {
buyGroceries(ingredients)
}

在此例中,makeASandwich()(做一个三明治)函数会抛出一个错误消息如果没有干净的盘子或者某个原料缺失。因为 makeASandwich() 抛出错误,函数调用被包裹在 try 表达式中。将函数包裹在一个 do 语句中,任何被抛出的错误会被传播到提供的 catch 从句中。

如果没有错误被抛出,eatASandwich() 函数会被调用。如果一个匹配 SandwichError.outOfCleanDishes 的错误被抛出,washDishes() 函数会被调用。如果一个匹配 SandwichError.missingIngredients 的错误被抛出,buyGroceries(_:) 函数会被调用,并且使用 catch 所捕捉到的关联值 [String] 作为参数。

抛出,捕捉,以及传播错误会在 错误处理 章节详细说明。

断言和先决条件

断言和先决条件是在运行时所做的检查。你可以用他们来检查在执行后续代码之前是否一个必要的条件已经被满足了。如果断言或者先决条件中的布尔条件评估的结果为 true(真),则代码像往常一样继续执行。如果布尔条件评估结果为 false(假),程序的当前状态是无效的,则代码执行结束,应用程序中止。

你使用断言和先决条件来表达你所做的假设和你在编码时候的期望。你可以将这些包含在你的代码中。断言帮助你在开发阶段找到错误和不正确的假设,先决条件帮助你在生产环境中探测到存在的问题。

除了在运行时验证你的期望值,断言和先决条件也变成了一个在你的代码中的有用的文档形式。和在上面讨论过的 错误处理 不同,断言和先决条件并不是用来处理可以恢复的或者可预期的错误。因为一个断言失败表明了程序正处于一个无效的状态,没有办法去捕获一个失败的断言。

使用断言和先决条件不是一个能够避免出现程序出现无效状态的编码方法。然而,如果一个无效状态程序产生了,断言和先决条件可以强制检查你的数据和程序状态,使得你的程序可预测的中止(译者:不是系统强制的,被动的中止),并帮助使这个问题更容易调试。一旦探测到无效的状态,执行则被中止,防止无效的状态导致的进一步对于系统的伤害。

断言和先决条件的不同点是,他们什么时候进行状态检测:断言仅在调试环境运行,而先决条件则在调试环境和生产环境中运行。在生产环境中,断言的条件将不会进行评估。这个意味着你可以使用很多断言在你的开发阶段,但是这些断言在生产环境中不会产生任何影响。

使用断言进行调试

你可以调用 Swift 标准库的 assert(_:_:file:line:) 函数来写一个断言。向这个函数传入一个结果为 true 或者 false 的表达式以及一条信息,当表达式的结果为 false 的时候这条信息会被显示:

1
2
3
let age = -3
assert(age >= 0, "A person's age cannot be less than zero")
// 因为 age < 0,所以断言会触发

在这个例子中,只有 age >= 0true 时,即 age 的值非负的时候,代码才会继续执行。如果 age 的值是负数,就像代码中那样,age >= 0false,断言被触发,终止应用。

如果不需要断言信息,可以就像这样忽略掉:

1
assert(age >= 0)

如果代码已经检查了条件,你可以使用 assertionFailure(_:file:line:) 函数来表明断言失败了,例如:

1
2
3
4
5
6
7
if age > 10 {
print("You can ride the roller-coaster or the ferris wheel.")
} else if age > 0 {
print("You can ride the ferris wheel.")
} else {
assertionFailure("A person's age can't be less than zero.")
}

强制执行先决条件

当一个条件可能为假,但是继续执行代码要求条件必须为真的时候,需要使用先决条件。例如使用先决条件来检查是否下标越界,或者来检查是否将一个正确的参数传给函数。

你可以使用全局 precondition(_:_:file:line:) 函数来写一个先决条件。向这个函数传入一个结果为 true 或者 false 的表达式以及一条信息,当表达式的结果为 false 的时候这条信息会被显示:

1
2
// 在一个下标的实现里...
precondition(index > 0, "Index must be greater than zero.")

你可以调用 preconditionFailure(_:file:line:) 方法来表明出现了一个错误,例如,switch 进入了 default 分支,但是所有的有效值应该被任意一个其他分支(非 default 分支)处理。

注意

如果你使用 unchecked 模式(-Ounchecked)编译代码,先决条件将不会进行检查。编译器假设所有的先决条件总是为 true(真),他将优化你的代码。然而,fatalError(_:file:line:) 函数总是中断执行,无论你怎么进行优化设定。

你能使用 fatalError(_:file:line:) 函数在设计原型和早期开发阶段,这个阶段只有方法的声明,但是没有具体实现,你可以在方法体中写上 fatalError(“Unimplemented”)作为具体实现。因为 fatalError 不会像断言和先决条件那样被优化掉,所以你可以确保当代码执行到一个没有被实现的方法时,程序会被中断。

留言與分享

理解RESTful架构

分類 编程语言, RESTful架构

越来越多的人开始意识到,网站即软件,而且是一种新型的软件。

这种"互联网软件"采用客户端/服务器模式,建立在分布式体系上,通过互联网通信,具有高延时(high latency)、高并发等特点。

网站开发,完全可以采用软件开发的模式。但是传统上,软件和网络是两个不同的领域,很少有交集;软件开发主要针对单机环境,网络则主要研究系统之间的通信。互联网的兴起,使得这两个领域开始融合,现在我们必须考虑,如何开发在互联网环境中使用的软件。

RESTful架构,就是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便,所以正得到越来越多网站的采用。

但是,到底什么是RESTful架构,并不是一个容易说清楚的问题。下面,我就谈谈我理解的RESTful架构。

一、起源

REST这个词,是Roy Thomas Fielding在他2000年的博士论文中提出的。

Fielding是一个非常重要的人,他是HTTP协议(1.0版和1.1版)的主要设计者、Apache服务器软件的作者之一、Apache基金会的第一任主席。所以,他的这篇论文一经发表,就引起了关注,并且立即对互联网开发产生了深远的影响。

他这样介绍论文的写作目的:

“本文研究计算机科学两大前沿----软件和网络----的交叉点。长期以来,软件研究主要关注软件设计的分类、设计方法的演化,很少客观地评估不同的设计选择对系统行为的影响。而相反地,网络研究主要关注系统之间通信行为的细节、如何改进特定通信机制的表现,常常忽视了一个事实,那就是改变应用程序的互动风格比改变互动协议,对整体表现有更大的影响。我这篇文章的写作目的,就是想在符合架构原理的前提下,理解和评估以网络为基础的应用软件的架构设计,得到一个功能强、性能好、适宜通信的架构。

(This dissertation explores a junction on the frontiers of two research disciplines in computer science: software and networking. Software research has long been concerned with the categorization of software designs and the development of design methodologies, but has rarely been able to objectively evaluate the impact of various design choices on system behavior. Networking research, in contrast, is focused on the details of generic communication behavior between systems and improving the performance of particular communication techniques, often ignoring the fact that changing the interaction style of an application can have more impact on performance than the communication protocols used for that interaction. My work is motivated by the desire to understand and evaluate the architectural design of network-based application software through principled use of architectural constraints, thereby obtaining the functional, performance, and social properties desired of an architecture. )

二、名称

Fielding将他对互联网软件的架构原则,定名为REST,即Representational State Transfer的缩写。我对这个词组的翻译是"表现层状态转化"。

如果一个架构符合REST原则,就称它为RESTful架构。

**要理解RESTful架构,最好的方法就是去理解Representational State Transfer这个词组到底是什么意思,它的每一个词代表了什么涵义。**如果你把这个名称搞懂了,也就不难体会REST是一种什么样的设计。

三、资源(Resources)

REST的名称"表现层状态转化"中,省略了主语。“表现层"其实指的是"资源”(Resources)的"表现层"。

**所谓"资源",就是网络上的一个实体,或者说是网络上的一个具体信息。**它可以是一段文本、一张图片、一首歌曲、一种服务,总之就是一个具体的实在。你可以用一个URI(统一资源定位符)指向它,每种资源对应一个特定的URI。要获取这个资源,访问它的URI就可以,因此URI就成了每一个资源的地址或独一无二的识别符。

所谓"上网",就是与互联网上一系列的"资源"互动,调用它的URI。

四、表现层(Representation)

"资源"是一种信息实体,它可以有多种外在表现形式。我们把"资源"具体呈现出来的形式,叫做它的"表现层"(Representation)。

比如,文本可以用txt格式表现,也可以用HTML格式、XML格式、JSON格式表现,甚至可以采用二进制格式;图片可以用JPG格式表现,也可以用PNG格式表现。

URI只代表资源的实体,不代表它的形式。严格地说,有些网址最后的".html"后缀名是不必要的,因为这个后缀名表示格式,属于"表现层"范畴,而URI应该只代表"资源"的位置。它的具体表现形式,应该在HTTP请求的头信息中用Accept和Content-Type字段指定,这两个字段才是对"表现层"的描述。

五、状态转化(State Transfer)

访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。

互联网通信协议HTTP协议,是一个无状态协议。这意味着,所有的状态都保存在服务器端。因此,如果客户端想要操作服务器,必须通过某种手段,让服务器端发生"状态转化"(State Transfer)。而这种转化是建立在表现层之上的,所以就是"表现层状态转化"。

客户端用到的手段,只能是HTTP协议。具体来说,就是HTTP协议里面,四个表示操作方式的动词:GET、POST、PUT、DELETE。它们分别对应四种基本操作:GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT用来更新资源,DELETE用来删除资源。

六、综述

综合上面的解释,我们总结一下什么是RESTful架构:

(1)每一个URI代表一种资源;

(2)客户端和服务器之间,传递这种资源的某种表现层;

(3)客户端通过四个HTTP动词,对服务器端资源进行操作,实现"表现层状态转化"。

七、误区

RESTful架构有一些典型的设计误区。

**最常见的一种设计错误,就是URI包含动词。**因为"资源"表示一种实体,所以应该是名词,URI不应该有动词,动词应该放在HTTP协议中。

举例来说,某个URI是/posts/show/1,其中show是动词,这个URI就设计错了,正确的写法应该是/posts/1,然后用GET方法表示show。

如果某些动作是HTTP动词表示不了的,你就应该把动作做成一种资源。比如网上汇款,从账户1向账户2汇款500元,错误的URI是:

POST /accounts/1/transfer/500/to/2

正确的写法是把动词transfer改成名词transaction,资源不能是动词,但是可以是一种服务:

POST /transaction HTTP/1.1
  Host: 127.0.0.1

from=1&to=2&amount=500.00

另一个设计误区,就是在URI中加入版本号

http://www.example.com/app/1.0/foo

http://www.example.com/app/1.1/foo

http://www.example.com/app/2.0/foo

因为不同的版本,可以理解成同一种资源的不同表现形式,所以应该采用同一个URI。版本号可以在HTTP请求头信息的Accept字段中进行区分(参见Versioning REST Services):

Accept: vnd.example-com.foo+json; version=1.0

Accept: vnd.example-com.foo+json; version=1.1

Accept: vnd.example-com.foo+json; version=2.0

(完)

留言與分享

在大型应用程序中,配置主从数据库并使用读写分离是常见的设计模式。在Spring应用程序中,要实现读写分离,最好不要对现有代码进行改动,而是在底层透明地支持。

Spring内置了一个AbstractRoutingDataSource,它可以把多个数据源配置成一个Map,然后,根据不同的key返回不同的数据源。因为AbstractRoutingDataSource也是一个DataSource接口,因此,应用程序可以先设置好key, 访问数据库的代码就可以从AbstractRoutingDataSource拿到对应的一个真实的数据源,从而访问指定的数据库。它的结构看起来像这样:

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
   ┌───────────────────────────┐
│ controller │
│ set routing-key = "xxx" │
└───────────────────────────┘


┌───────────────────────────┐
│ logic code │
└───────────────────────────┘


┌───────────────────────────┐
│ routing datasource │
└───────────────────────────┘

┌─────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ read-write │ │ read-only │
│ datasource │ │ datasource │
└─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Master DB │────▶│ Slave DB │
└─────────────┘ └─────────────┘

第一步:配置多数据源

首先,我们在SpringBoot中配置两个数据源,其中第二个数据源是ro-datasource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
datasource:
jdbc-url: jdbc:mysql://localhost/test
username: rw
password: rw_password
driver-class-name: com.mysql.jdbc.Driver
hikari:
pool-name: HikariCP
auto-commit: false
...
ro-datasource:
jdbc-url: jdbc:mysql://localhost/test
username: ro
password: ro_password
driver-class-name: com.mysql.jdbc.Driver
hikari:
pool-name: HikariCP
auto-commit: false
...

在开发环境下,没有必要配置主从数据库。只需要给数据库设置两个用户,一个rw具有读写权限,一个ro只有SELECT权限,这样就模拟了生产环境下对主从数据库的读写分离。

在SpringBoot的配置代码中,我们初始化两个数据源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@SpringBootApplication
public class MySpringBootApplication {
/**
* Master data source.
*/
@Bean("masterDataSource")
@ConfigurationProperties(prefix = "spring.datasource")
DataSource masterDataSource() {
logger.info("create master datasource...");
return DataSourceBuilder.create().build();
}

/**
* Slave (read only) data source.
*/
@Bean("slaveDataSource")
@ConfigurationProperties(prefix = "spring.ro-datasource")
DataSource slaveDataSource() {
logger.info("create slave datasource...");
return DataSourceBuilder.create().build();
}

...
}

第二步:编写RoutingDataSource

然后,我们用Spring内置的RoutingDataSource,把两个真实的数据源代理为一个动态数据源:

1
2
3
4
5
6
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return "masterDataSource";
}
}

对这个RoutingDataSource,需要在SpringBoot中配置好并设置为主数据源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootApplication
public class MySpringBootApplication {
@Bean
@Primary
DataSource primaryDataSource(
@Autowired @Qualifier("masterDataSource") DataSource masterDataSource,
@Autowired @Qualifier("slaveDataSource") DataSource slaveDataSource
) {
logger.info("create routing datasource...");
Map<Object, Object> map = new HashMap<>();
map.put("masterDataSource", masterDataSource);
map.put("slaveDataSource", slaveDataSource);
RoutingDataSource routing = new RoutingDataSource();
routing.setTargetDataSources(map);
routing.setDefaultTargetDataSource(masterDataSource);
return routing;
}
...
}

现在,RoutingDataSource配置好了,但是,路由的选择是写死的,即永远返回"masterDataSource"

现在问题来了:如何存储动态选择的key以及在哪设置key?

在Servlet的线程模型中,使用ThreadLocal存储key最合适,因此,我们编写一个RoutingDataSourceContext,来设置并动态存储key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RoutingDataSourceContext implements AutoCloseable {

// holds data source key in thread local:
static final ThreadLocal<String> threadLocalDataSourceKey = new ThreadLocal<>();

public static String getDataSourceRoutingKey() {
String key = threadLocalDataSourceKey.get();
return key == null ? "masterDataSource" : key;
}

public RoutingDataSourceContext(String key) {
threadLocalDataSourceKey.set(key);
}

public void close() {
threadLocalDataSourceKey.remove();
}
}

然后,修改RoutingDataSource,获取key的代码如下:

1
2
3
4
5
public class RoutingDataSource extends AbstractRoutingDataSource {
protected Object determineCurrentLookupKey() {
return RoutingDataSourceContext.getDataSourceRoutingKey();
}
}

这样,在某个地方,例如一个Controller的方法内部,就可以动态设置DataSource的Key:

1
2
3
4
5
6
7
8
9
10
11
@Controller
public class MyController {
@Get("/")
public String index() {
String key = "slaveDataSource";
try (RoutingDataSourceContext ctx = new RoutingDataSourceContext(key)) {
// TODO:
return "html... www.liaoxuefeng.com";
}
}
}

到此为止,我们已经成功实现了数据库的动态路由访问。

这个方法是可行的,但是,需要读从数据库的地方,就需要加上一大段try (RoutingDataSourceContext ctx = ...) {}代码,使用起来十分不便。有没有方法可以简化呢?

有!

我们仔细想想,Spring提供的声明式事务管理,就只需要一个@Transactional()注解,放在某个Java方法上,这个方法就自动具有了事务。

我们也可以编写一个类似的@RoutingWith("slaveDataSource")注解,放到某个Controller的方法上,这个方法内部就自动选择了对应的数据源。代码看起来应该像这样:

1
2
3
4
5
6
7
8
@Controller
public class MyController {
@Get("/")
@RoutingWith("slaveDataSource")
public String index() {
return "html... www.liaoxuefeng.com";
}
}

这样,完全不修改应用程序的逻辑,只在必要的地方加上注解,自动实现动态数据源切换,这个方法是最简单的。

想要在应用程序中少写代码,我们就得多做一点底层工作:必须使用类似Spring实现声明式事务的机制,即用AOP实现动态数据源切换。

实现这个功能也非常简单,编写一个RoutingAspect,利用AspectJ实现一个Around拦截:

1
2
3
4
5
6
7
8
9
10
11
@Aspect
@Component
public class RoutingAspect {
@Around("@annotation(routingWith)")
public Object routingWithDataSource(ProceedingJoinPoint joinPoint, RoutingWith routingWith) throws Throwable {
String key = routingWith.value();
try (RoutingDataSourceContext ctx = new RoutingDataSourceContext(key)) {
return joinPoint.proceed();
}
}
}

注意方法的第二个参数RoutingWith是Spring传入的注解实例,我们根据注解的value()获取配置的key。编译前需要添加一个Maven依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

到此为止,我们就实现了用注解动态选择数据源的功能。最后一步重构是用字符串常量替换散落在各处的"masterDataSource""slaveDataSource"

使用限制

受Servlet线程模型的局限,动态数据源不能在一个请求内设定后再修改,也就是@RoutingWith不能嵌套。此外,@RoutingWith@Transactional混用时,要设定AOP的优先级。

本文代码需要SpringBoot支持,JDK 1.8编译并打开-parameters编译参数。

留言與分享

JAVA-Spring Boot开发

分類 编程语言, Java

使用Conditional

使用Profile能根据不同的Profile进行条件装配,但是Profile控制比较糙,如果想要精细控制,例如,配置本地存储,AWS存储和阿里云存储,将来很可能会增加Azure存储等,用Profile就很难实现。

Spring本身提供了条件装配@Conditional,但是要自己编写比较复杂的Condition来做判断,比较麻烦。Spring Boot则为我们准备好了几个非常有用的条件:

  • @ConditionalOnProperty:如果有指定的配置,条件生效;
  • @ConditionalOnBean:如果有指定的Bean,条件生效;
  • @ConditionalOnMissingBean:如果没有指定的Bean,条件生效;
  • @ConditionalOnMissingClass:如果没有指定的Class,条件生效;
  • @ConditionalOnWebApplication:在Web环境中条件生效;
  • @ConditionalOnExpression:根据表达式判断条件是否生效。

我们以最常用的@ConditionalOnProperty为例,把上一节的StorageService改写如下。首先,定义配置storage.type=xxx,用来判断条件,默认为local

1
2
storage:
type: ${STORAGE_TYPE:local}

设定为local时,启用LocalStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalStorageService implements StorageService {
...
}

设定为aws时,启用AwsStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "aws")
public class AwsStorageService implements StorageService {
...
}

设定为aliyun时,启用AliyunStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "aliyun")
public class AliyunStorageService implements StorageService {
...
}

注意到LocalStorageService的注解,当指定配置为local,或者配置不存在,均启用LocalStorageService

可见,Spring Boot提供的条件装配使得应用程序更加具有灵活性。

练习

使用Spring Boot提供的条件装配。

下载练习

小结

Spring Boot提供了几个非常有用的条件装配注解,可实现灵活的条件装配。



Profile本身是Spring提供的功能,我们在使用条件装配中已经讲到了,Profile表示一个环境的概念,如开发、测试和生产这3个环境:

  • native
  • test
  • production

或者按git分支定义master、dev这些环境:

  • master
  • dev

在启动一个Spring应用程序的时候,可以传入一个或多个环境,例如:

1
-Dspring.profiles.active=test,master

大多数情况下,使用一个环境就足够了。

Spring Boot对Profiles的支持在于,可以在application.yml中为每个环境进行配置。下面是一个示例配置:

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
spring:
application:
name: ${APP_NAME:unnamed}
datasource:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver
hikari:
auto-commit: false
connection-timeout: 3000
validation-timeout: 3000
max-lifetime: 60000
maximum-pool-size: 20
minimum-idle: 1

pebble:
suffix:
cache: false

server:
port: ${APP_PORT:8080}

---

spring:
config:
activate:
on-profile: test

server:
port: 8000

---

spring:
config:
activate:
on-profile: production

server:
port: 80

pebble:
cache: true

注意到分隔符---,最前面的配置是默认配置,不需要指定Profile,后面的每段配置都必须以spring.config.activate.on-profile: xxx开头,表示一个Profile。上述配置默认使用8080端口,但是在test环境下,使用8000端口,在production环境下,使用80端口,并且启用Pebble的缓存。

如果我们不指定任何Profile,直接启动应用程序,那么Profile实际上就是default,可以从Spring Boot启动日志看出:

1
2
...
2022-11-25T11:10:34.006+08:00 INFO 13537 --- [ main] com.itranswarp.learnjava.Application : No active profile set, falling back to 1 default profile: "default"

上述日志显示未设置Profile,使用默认的Profile为default

要以test环境启动,可输入如下命令:

1
2
3
4
5
6
7
$ java -Dspring.profiles.active=test -jar springboot-profiles-1.0-SNAPSHOT.jar

...
2022-11-25T11:09:02.946+08:00 INFO 13510 --- [ main] com.itranswarp.learnjava.Application : The following 1 profile is active: "test"
...
2022-11-25T11:09:05.124+08:00 INFO 13510 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8000 (http) with context path ''
...

从日志看到活动的Profile是test,Tomcat的监听端口是8000

通过Profile可以实现一套代码在不同环境启用不同的配置和功能。假设我们需要一个存储服务,在本地开发时,直接使用文件存储即可,但是,在测试和生产环境,需要存储到云端如S3上,如何通过Profile实现该功能?

首先,我们要定义存储接口StorageService

1
2
3
4
5
6
7
8
public interface StorageService {

// 根据URI打开InputStream:
InputStream openInputStream(String uri) throws IOException;

// 根据扩展名+InputStream保存并返回URI:
String store(String extName, InputStream input) throws IOException;
}

本地存储可通过LocalStorageService实现:

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
@Component
@Profile("default")
public class LocalStorageService implements StorageService {
@Value("${storage.local:/var/static}")
String localStorageRootDir;

final Logger logger = LoggerFactory.getLogger(getClass());

private File localStorageRoot;

@PostConstruct
public void init() {
logger.info("Intializing local storage with root dir: {}", this.localStorageRootDir);
this.localStorageRoot = new File(this.localStorageRootDir);
}

@Override
public InputStream openInputStream(String uri) throws IOException {
File targetFile = new File(this.localStorageRoot, uri);
return new BufferedInputStream(new FileInputStream(targetFile));
}

@Override
public String store(String extName, InputStream input) throws IOException {
String fileName = UUID.randomUUID().toString() + "." + extName;
File targetFile = new File(this.localStorageRoot, fileName);
try (OutputStream output = new BufferedOutputStream(new FileOutputStream(targetFile))) {
input.transferTo(output);
}
return fileName;
}
}

而云端存储可通过CloudStorageService实现:

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
@Component
@Profile("!default")
public class CloudStorageService implements StorageService {
@Value("${storage.cloud.bucket:}")
String bucket;

@Value("${storage.cloud.access-key:}")
String accessKey;

@Value("${storage.cloud.access-secret:}")
String accessSecret;

final Logger logger = LoggerFactory.getLogger(getClass());

@PostConstruct
public void init() {
// TODO:
logger.info("Initializing cloud storage...");
}

@Override
public InputStream openInputStream(String uri) throws IOException {
// TODO:
throw new IOException("File not found: " + uri);
}

@Override
public String store(String extName, InputStream input) throws IOException {
// TODO:
throw new IOException("Unable to access cloud storage.");
}
}

注意到LocalStorageService使用了条件装配@Profile("default"),即默认启用LocalStorageService,而CloudStorageService使用了条件装配@Profile("!default"),即非default环境时,自动启用CloudStorageService。这样,一套代码,就实现了不同环境启用不同的配置。

练习

使用Profile启动Spring Boot应用。

下载练习

小结

Spring Boot允许在一个配置文件中针对不同Profile进行配置;

Spring Boot在未指定Profile时默认为default

使用Actuator

在生产环境中,需要对应用程序的状态进行监控。前面我们已经介绍了使用JMX对Java应用程序包括JVM进行监控,使用JMX需要把一些监控信息以MBean的形式暴露给JMX Server,而Spring Boot已经内置了一个监控功能,它叫Actuator。

使用Actuator非常简单,只需添加如下依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

然后正常启动应用程序,Actuator会把它能收集到的所有信息都暴露给JMX。此外,Actuator还可以通过URL/actuator/挂载一些监控点,例如,输入http://localhost:8080/actuator/health,我们可以查看应用程序当前状态:

1
2
3
{
"status": "UP"
}

许多网关作为反向代理需要一个URL来探测后端集群应用是否存活,这个URL就可以提供给网关使用。

Actuator默认把所有访问点暴露给JMX,但处于安全原因,只有healthinfo会暴露给Web。Actuator提供的所有访问点均在官方文档列出,要暴露更多的访问点给Web,需要在application.yml中加上配置:

1
2
3
4
5
management:
endpoints:
web:
exposure:
include: info, health, beans, env, metrics

要特别注意暴露的URL的安全性,例如,/actuator/env可以获取当前机器的所有环境变量,不可暴露给公网。

练习

使用Actuator实现监控。

下载练习

小结

Spring Boot提供了一个Actuator,可以方便地实现监控,并可通过Web访问特定类型的监控。



打包Spring Boot应用

我们在Maven的使用插件一节中介绍了如何使用maven-shade-plugin打包一个可执行的jar包。在Spring Boot应用中,打包更加简单,因为Spring Boot自带一个更简单的spring-boot-maven-plugin插件用来打包,我们只需要在pom.xml中加入以下配置:

1
2
3
4
5
6
7
8
9
10
11
<project ...>
...
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

无需任何配置,Spring Boot的这款插件会自动定位应用程序的入口Class,我们执行以下Maven命令即可打包:

1
$ mvn clean package

springboot-exec-jar项目为例,打包后我们在target目录下可以看到两个jar文件:

1
2
3
4
5
6
7
$ ls
classes
generated-sources
maven-archiver
maven-status
springboot-exec-jar-1.0-SNAPSHOT.jar
springboot-exec-jar-1.0-SNAPSHOT.jar.original

其中,springboot-exec-jar-1.0-SNAPSHOT.jar.original是Maven标准打包插件打的jar包,它只包含我们自己的Class,不包含依赖,而springboot-exec-jar-1.0-SNAPSHOT.jar是Spring Boot打包插件创建的包含依赖的jar,可以直接运行:

1
$ java -jar springboot-exec-jar-1.0-SNAPSHOT.jar

这样,部署一个Spring Boot应用就非常简单,无需预装任何服务器,只需要上传jar包即可。

在打包的时候,因为打包后的Spring Boot应用不会被修改,因此,默认情况下,spring-boot-devtools这个依赖不会被打包进去。但是要注意,使用早期的Spring Boot版本时,需要配置一下才能排除spring-boot-devtools这个依赖:

1
2
3
4
5
6
7
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludeDevtools>true</excludeDevtools>
</configuration>
</plugin>

如果不喜欢默认的项目名+版本号作为文件名,可以加一个配置指定文件名:

1
2
3
4
5
6
7
<project ...>
...
<build>
<finalName>awesome-app</finalName>
...
</build>
</project>

这样打包后的文件名就是awesome-app.jar

练习

使用Spring Boot插件打包可执行jar。

下载练习

小结

Spring Boot提供了一个Maven插件用于打包所有依赖到单一jar文件,此插件十分易用,无需配置。



在开发阶段,我们经常要修改代码,然后重启Spring Boot应用。经常手动停止再启动,比较麻烦。

Spring Boot提供了一个开发者工具,可以监控classpath路径上的文件。只要源码或配置文件发生修改,Spring Boot应用可以自动重启。在开发阶段,这个功能比较有用。

要使用这一开发者功能,我们只需添加如下依赖到pom.xml

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>

然后,没有然后了。直接启动应用程序,然后试着修改源码,保存,观察日志输出,Spring Boot会自动重新加载。

默认配置下,针对/static/public/templates目录中的文件修改,不会自动重启,因为禁用缓存后,这些文件的修改可以实时更新。

练习

使用devtools检测修改并自动重启。

下载练习

小结

Spring Boot提供了一个开发阶段非常有用的spring-boot-devtools,能自动检测classpath路径上文件修改并自动重启。

要了解Spring Boot,我们先来编写第一个Spring Boot应用程序,看看与前面我们编写的Spring应用程序有何异同。

我们新建一个springboot-hello的工程,创建标准的Maven目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
springboot-hello
├── pom.xml
├── src
│   └── main
│   ├── java
│   └── resources
│   ├── application.yml
│   ├── logback-spring.xml
│   ├── static
│   └── templates
└── target

其中,在src/main/resources目录下,注意到几个文件:

application.yml

这是Spring Boot默认的配置文件,它采用YAML格式而不是.properties格式,文件名必须是application.yml而不是其他名称。

YAML格式比key=value格式的.properties文件更易读。比较一下两者的写法:

使用.properties格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# application.properties

spring.application.name=${APP_NAME:unnamed}

spring.datasource.url=jdbc:hsqldb:file:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver

spring.datasource.hikari.auto-commit=false
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.validation-timeout=3000
spring.datasource.hikari.max-lifetime=60000
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=1

使用YAML格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# application.yml

spring:
application:
name: ${APP_NAME:unnamed}
datasource:
url: jdbc:hsqldb:file:testdb
username: sa
password:
driver-class-name: org.hsqldb.jdbc.JDBCDriver
hikari:
auto-commit: false
connection-timeout: 3000
validation-timeout: 3000
max-lifetime: 60000
maximum-pool-size: 20
minimum-idle: 1

可见,YAML是一种层级格式,它和.properties很容易互相转换,它的优点是去掉了大量重复的前缀,并且更加易读。

提示

也可以使用application.properties作为配置文件,但不如YAML格式简单。

使用环境变量

在配置文件中,我们经常使用如下的格式对某个key进行配置:

1
2
3
4
5
app:
db:
host: ${DB_HOST:localhost}
user: ${DB_USER:root}
password: ${DB_PASSWORD:password}

这种${DB_HOST:localhost}意思是,首先从环境变量查找DB_HOST,如果环境变量定义了,那么使用环境变量的值,否则,使用默认值localhost

这使得我们在开发和部署时更加方便,因为开发时无需设定任何环境变量,直接使用默认值即本地数据库,而实际线上运行的时候,只需要传入环境变量即可:

1
$ DB_HOST=10.0.1.123 DB_USER=prod DB_PASSWORD=xxxx java -jar xxx.jar

logback-spring.xml

这是Spring Boot的logback配置文件名称(也可以使用logback.xml),一个标准的写法如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
</appender>

<appender name="APP_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
<file>app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<maxIndex>1</maxIndex>
<fileNamePattern>app.log.%i</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>1MB</MaxFileSize>
</triggeringPolicy>
</appender>

<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="APP_LOG" />
</root>
</configuration>

它主要通过<include resource="..." />引入了Spring Boot的一个缺省配置,这样我们就可以引用类似${CONSOLE_LOG_PATTERN}这样的变量。上述配置定义了一个控制台输出和文件输出,可根据需要修改。

static是静态文件目录,templates是模板文件目录,注意它们不再存放在src/main/webapp下,而是直接放到src/main/resources这个classpath目录,因为在Spring Boot中已经不需要专门的webapp目录了。

以上就是Spring Boot的标准目录结构,它完全是一个基于Java应用的普通Maven项目。

我们再来看源码目录结构:

1
2
3
4
5
6
7
8
9
10
11
src/main/java
└── com
└── itranswarp
└── learnjava
├── Application.java
├── entity
│   └── User.java
├── service
│   └── UserService.java
└── web
└── UserController.java

在存放源码的src/main/java目录中,Spring Boot对Java包的层级结构有一个要求。注意到我们的根package是com.itranswarp.learnjava,下面还有entityserviceweb等子package。Spring Boot要求main()方法所在的启动类必须放到根package下,命名不做要求,这里我们以Application.java命名,它的内容如下:

1
2
3
4
5
6
@SpringBootApplication
public class Application {
public static void main(String[] args) throws Exception {
SpringApplication.run(Application.class, args);
}
}

启动Spring Boot应用程序只需要一行代码加上一个注解@SpringBootApplication,该注解实际上又包含了:

  • @SpringBootConfiguration
    • @Configuration
  • @EnableAutoConfiguration
    • @AutoConfigurationPackage
  • @ComponentScan

这样一个注解就相当于启动了自动配置和自动扫描。

我们再观察pom.xml,它的内容如下:

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
<project ...>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.0</version>
</parent>

<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>springboot-hello</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
<pebble.version>3.2.0</pebble.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<!-- 集成Pebble View -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble-spring-boot-starter</artifactId>
<version>${pebble.version}</version>
</dependency>

<!-- JDBC驱动 -->
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
</dependency>
</dependencies>
</project>

使用Spring Boot时,强烈推荐从spring-boot-starter-parent继承,因为这样就可以引入Spring Boot的预置配置。

紧接着,我们引入了依赖spring-boot-starter-webspring-boot-starter-jdbc,它们分别引入了Spring MVC相关依赖和Spring JDBC相关依赖,无需指定版本号,因为引入的<parent>内已经指定了,只有我们自己引入的某些第三方jar包需要指定版本号。这里我们引入pebble-spring-boot-starter作为View,以及hsqldb作为嵌入式数据库。hsqldb已在spring-boot-starter-jdbc中预置了版本号3.0.0,因此此处无需指定版本号。

根据pebble-spring-boot-starter文档,加入如下配置到application.yml

1
2
3
4
5
pebble:
# 默认为".peb",改为"":
suffix:
# 开发阶段禁用模板缓存:
cache: false

Application稍作改动,添加WebMvcConfigurer这个Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootApplication
public class Application {
...

@Bean
WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
return new WebMvcConfigurer() {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 映射路径`/static/`到classpath路径:
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
};
}
}

现在就可以直接运行Application,启动后观察Spring Boot的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  .   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.0.0)

2022-11-25T10:49:31.100+08:00 INFO 13105 --- [ main] com.itranswarp.learnjava.Application : Starting Application using Java 17 with PID 13105 (/Users/liaoxuefeng/Git/springboot-hello/target/classes started by liaoxuefeng in /Users/liaoxuefeng/Git/springboot-hello)
2022-11-25T10:49:31.107+08:00 INFO 13105 --- [ main] com.itranswarp.learnjava.Application : No active profile set, falling back to 1 default profile: "default"
2022-11-25T10:49:32.404+08:00 INFO 13105 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2022-11-25T10:49:32.423+08:00 INFO 13105 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2022-11-25T10:49:32.426+08:00 INFO 13105 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.1]
2022-11-25T10:49:32.549+08:00 INFO 13105 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2022-11-25T10:49:32.551+08:00 INFO 13105 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1327 ms
2022-11-25T10:49:32.668+08:00 WARN 13105 --- [ main] com.zaxxer.hikari.HikariConfig : HikariPool-1 - idleTimeout is close to or more than maxLifetime, disabling it.
2022-11-25T10:49:32.669+08:00 INFO 13105 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2022-11-25T10:49:32.996+08:00 INFO 13105 --- [ main] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Driver does not support get/set network timeout for connections. (feature not supported)
2022-11-25T10:49:32.998+08:00 INFO 13105 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.hsqldb.jdbc.JDBCConnection@31a2a9fa
2022-11-25T10:49:33.002+08:00 INFO 13105 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2022-11-25T10:49:33.391+08:00 WARN 13105 --- [ main] ocalVariableTableParameterNameDiscoverer : Using deprecated '-debug' fallback for parameter name resolution. Compile the affected code with '-parameters' instead or avoid its introspection: io.pebbletemplates.boot.autoconfigure.PebbleServletWebConfiguration
2022-11-25T10:49:33.398+08:00 WARN 13105 --- [ main] ocalVariableTableParameterNameDiscoverer : Using deprecated '-debug' fallback for parameter name resolution. Compile the affected code with '-parameters' instead or avoid its introspection: io.pebbletemplates.boot.autoconfigure.PebbleAutoConfiguration
2022-11-25T10:49:33.619+08:00 INFO 13105 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2022-11-25T10:49:33.637+08:00 INFO 13105 --- [ main] com.itranswarp.learnjava.Application : Started Application in 3.151 seconds (process running for 3.835)

Spring Boot自动启动了嵌入式Tomcat,当看到Started Application in xxx seconds时,Spring Boot应用启动成功。

现在,我们在浏览器输入localhost:8080就可以直接访问页面。那么问题来了:

前面我们定义的数据源、声明式事务、JdbcTemplate在哪创建的?怎么就可以直接注入到自己编写的UserService中呢?

这些自动创建的Bean就是Spring Boot的特色:AutoConfiguration。

当我们引入spring-boot-starter-jdbc时,启动时会自动扫描所有的XxxAutoConfiguration

  • DataSourceAutoConfiguration:自动创建一个DataSource,其中配置项从application.ymlspring.datasource读取;
  • DataSourceTransactionManagerAutoConfiguration:自动创建了一个基于JDBC的事务管理器;
  • JdbcTemplateAutoConfiguration:自动创建了一个JdbcTemplate

因此,我们自动得到了一个DataSource、一个DataSourceTransactionManager和一个JdbcTemplate

类似的,当我们引入spring-boot-starter-web时,自动创建了:

  • ServletWebServerFactoryAutoConfiguration:自动创建一个嵌入式Web服务器,默认是Tomcat;
  • DispatcherServletAutoConfiguration:自动创建一个DispatcherServlet
  • HttpEncodingAutoConfiguration:自动创建一个CharacterEncodingFilter
  • WebMvcAutoConfiguration:自动创建若干与MVC相关的Bean。

引入第三方pebble-spring-boot-starter时,自动创建了:

  • PebbleAutoConfiguration:自动创建了一个PebbleViewResolver

Spring Boot大量使用XxxAutoConfiguration来使得许多组件被自动化配置并创建,而这些创建过程又大量使用了Spring的Conditional功能。例如,我们观察JdbcTemplateAutoConfiguration,它的代码如下:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ DataSource.class, JdbcTemplate.class })
@ConditionalOnSingleCandidate(DataSource.class)
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties(JdbcProperties.class)
@Import({ JdbcTemplateConfiguration.class, NamedParameterJdbcTemplateConfiguration.class })
public class JdbcTemplateAutoConfiguration {
}

当满足条件:

  • @ConditionalOnClass:在classpath中能找到DataSourceJdbcTemplate
  • @ConditionalOnSingleCandidate(DataSource.class):在当前Bean的定义中能找到唯一的DataSource

JdbcTemplateAutoConfiguration就会起作用。实际创建由导入的JdbcTemplateConfiguration完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(JdbcOperations.class)
class JdbcTemplateConfiguration {
@Bean
@Primary
JdbcTemplate jdbcTemplate(DataSource dataSource, JdbcProperties properties) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
JdbcProperties.Template template = properties.getTemplate();
jdbcTemplate.setFetchSize(template.getFetchSize());
jdbcTemplate.setMaxRows(template.getMaxRows());
if (template.getQueryTimeout() != null) {
jdbcTemplate.setQueryTimeout((int) template.getQueryTimeout().getSeconds());
}
return jdbcTemplate;
}
}

创建JdbcTemplate之前,要满足@ConditionalOnMissingBean(JdbcOperations.class),即不存在JdbcOperations的Bean。

如果我们自己创建了一个JdbcTemplate,例如,在Application中自己写个方法:

1
2
3
4
5
6
7
8
@SpringBootApplication
public class Application {
...
@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}

那么根据条件@ConditionalOnMissingBean(JdbcOperations.class),Spring Boot就不会再创建一个重复的JdbcTemplate(因为JdbcOperationsJdbcTemplate的父类)。

可见,Spring Boot自动装配功能是通过自动扫描+条件装配实现的,这一套机制在默认情况下工作得很好,但是,如果我们要手动控制某个Bean的创建,就需要详细地了解Spring Boot自动创建的原理,很多时候还要跟踪XxxAutoConfiguration,以便设定条件使得某个Bean不会被自动创建。

练习

使用Spring Boot编写hello应用程序。

下载练习

小结

Spring Boot是一个基于Spring提供了开箱即用的一组套件,它可以让我们基于很少的配置和代码快速搭建出一个完整的应用程序。

Spring Boot有非常强大的AutoConfiguration功能,它是通过自动扫描+条件装配实现的。

Spring Boot开发

我们已经在前面详细介绍了Spring框架,它的主要功能包括IoC容器、AOP支持、事务支持、MVC开发以及强大的第三方集成功能等。

那么,Spring Boot又是什么?它和Spring是什么关系?

Spring Boot是一个基于Spring的套件,它帮我们预组装了Spring的一系列组件,以便以尽可能少的代码和配置来开发基于Spring的Java应用程序。

以汽车为例,如果我们想组装一辆汽车,我们需要发动机、传动、轮胎、底盘、外壳、座椅、内饰等各种部件,然后把它们装配起来。Spring就相当于提供了一系列这样的部件,但是要装好汽车上路,还需要我们自己动手。而Spring Boot则相当于已经帮我们预装好了一辆可以上路的汽车,如果有特殊的要求,例如把发动机从普通款换成涡轮增压款,可以通过修改配置或编写少量代码完成。

因此,Spring Boot和Spring的关系就是整车和零部件的关系,它们不是取代关系,试图跳过Spring直接学习Spring Boot是不可能的。

Spring Boot的目标就是提供一个开箱即用的应用程序架构,我们基于Spring Boot的预置结构继续开发,省时省力。

本章我们将详细介绍如何使用Spring Boot。

本教程使用的Spring Boot版本是3.x版,如果使用Spring Boot 2.x则需注意,两者有以下不同:

Spring Boot 2.x Spring Boot 3.x
Spring版本 Spring 5.x Spring 6.x
JDK版本 >= 1.8 >= 17
Tomcat版本 9.x 10.x
Annotation包 javax.annotation jakarta.annotation
Servlet包 javax.servlet jakarta.servlet
JMS包 javax.jms jakarta.jms
JavaMail包 javax.mail jakarta.mail

如果使用Spring Boot的其他版本,则需要根据需要调整代码。

Spring Boot的官网入口是这里,建议添加到浏览器收藏夹。



加载配置文件

加载配置文件可以直接使用注解@Value,例如,我们定义了一个最大允许上传的文件大小配置:

1
2
3
storage:
local:
max-size: 102400

在某个FileUploader里,需要获取该配置,可使用@Value注入:

1
2
3
4
5
6
7
@Component
public class FileUploader {
@Value("${storage.local.max-size:102400}")
int maxSize;

...
}

在另一个UploadFilter中,因为要检查文件的MD5,同时也要检查输入流的大小,因此,也需要该配置:

1
2
3
4
5
6
7
@Component
public class UploadFilter implements Filter {
@Value("${storage.local.max-size:100000}")
int maxSize;

...
}

多次引用同一个@Value不但麻烦,而且@Value使用字符串,缺少编译器检查,容易造成多处引用不一致(例如,UploadFilter把缺省值误写为100000)。

为了更好地管理配置,Spring Boot允许创建一个Bean,持有一组配置,并由Spring Boot自动注入。

假设我们在application.yml中添加了如下配置:

1
2
3
4
5
6
7
8
9
10
storage:
local:
# 文件存储根目录:
root-dir: ${STORAGE_LOCAL_ROOT:/var/storage}
# 最大文件大小,默认100K:
max-size: ${STORAGE_LOCAL_MAX_SIZE:102400}
# 是否允许空文件:
allow-empty: false
# 允许的文件类型:
allow-types: jpg, png, gif

可以首先定义一个Java Bean,持有该组配置:

1
2
3
4
5
6
7
8
9
public class StorageConfiguration {

private String rootDir;
private int maxSize;
private boolean allowEmpty;
private List<String> allowTypes;

// TODO: getters and setters
}

保证Java Bean的属性名称与配置一致即可。然后,我们添加两个注解:

1
2
3
4
5
@Configuration
@ConfigurationProperties("storage.local")
public class StorageConfiguration {
...
}

注意到@ConfigurationProperties("storage.local")表示将从配置项storage.local读取该项的所有子项配置,并且,@Configuration表示StorageConfiguration也是一个Spring管理的Bean,可直接注入到其他Bean中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class StorageService {
final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
StorageConfiguration storageConfig;

@PostConstruct
public void init() {
logger.info("Load configuration: root-dir = {}", storageConfig.getRootDir());
logger.info("Load configuration: max-size = {}", storageConfig.getMaxSize());
logger.info("Load configuration: allowed-types = {}", storageConfig.getAllowTypes());
}
}

这样一来,引入storage.local的相关配置就很容易了,因为只需要注入StorageConfiguration这个Bean,这样可以由编译器检查类型,无需编写重复的@Value注解。

练习

用Spring Boot加载配置文件。

下载练习

小结

Spring Boot提供了@ConfigurationProperties注解,可以非常方便地把一段配置加载到一个Bean中。



Spring Boot大量使用自动配置和默认配置,极大地减少了代码,通常只需要加上几个注解,并按照默认规则设定一下必要的配置即可。例如,配置JDBC,默认情况下,只需要配置一个spring.datasource

1
2
3
4
5
6
spring:
datasource:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver

Spring Boot就会自动创建出DataSourceJdbcTemplateDataSourceTransactionManager,非常方便。

但是,有时候,我们又必须要禁用某些自动配置。例如,系统有主从两个数据库,而Spring Boot的自动配置只能配一个,怎么办?

这个时候,针对DataSource相关的自动配置,就必须关掉。我们需要用exclude指定需要关掉的自动配置:

1
2
3
4
5
6
@SpringBootApplication
// 启动自动配置,但排除指定的自动配置:
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
public class Application {
...
}

现在,Spring Boot不再给我们自动创建DataSourceJdbcTemplateDataSourceTransactionManager了,要实现主从数据库支持,怎么办?

让我们一步一步开始编写支持主从数据库的功能。首先,我们需要把主从数据库配置写到application.yml中,仍然按照Spring Boot默认的格式写,但datasource改为datasource-masterdatasource-slave

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource-master:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver
datasource-slave:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver

注意到两个数据库实际上是同一个库。如果使用MySQL,可以创建一个只读用户,作为datasource-slave的用户来模拟一个从库。

下一步,我们分别创建两个HikariCP的DataSource

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
public class MasterDataSourceConfiguration {
@Bean("masterDataSourceProperties")
@ConfigurationProperties("spring.datasource-master")
DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}

@Bean("masterDataSource")
DataSource dataSource(@Autowired @Qualifier("masterDataSourceProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}

public class SlaveDataSourceConfiguration {
@Bean("slaveDataSourceProperties")
@ConfigurationProperties("spring.datasource-slave")
DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}

@Bean("slaveDataSource")
DataSource dataSource(@Autowired @Qualifier("slaveDataSourceProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}

注意到上述class并未添加@Configuration@Component,要使之生效,可以使用@Import导入:

1
2
3
4
5
6
@SpringBootApplication
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
@Import({ MasterDataSourceConfiguration.class, SlaveDataSourceConfiguration.class})
public class Application {
...
}

此外,上述两个DataSource的Bean名称分别为masterDataSourceslaveDataSource,我们还需要一个最终的@Primary标注的DataSource,它采用Spring提供的AbstractRoutingDataSource,代码实现如下:

1
2
3
4
5
6
7
class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从ThreadLocal中取出key:
return RoutingDataSourceContext.getDataSourceRoutingKey();
}
}

RoutingDataSource本身并不是真正的DataSource,它通过Map关联一组DataSource,下面的代码创建了包含两个DataSourceRoutingDataSource,关联的key分别为masterDataSourceslaveDataSource

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
public class RoutingDataSourceConfiguration {
@Primary
@Bean
DataSource dataSource(
@Autowired @Qualifier("masterDataSource") DataSource masterDataSource,
@Autowired @Qualifier("slaveDataSource") DataSource slaveDataSource) {
var ds = new RoutingDataSource();
// 关联两个DataSource:
ds.setTargetDataSources(Map.of(
"masterDataSource", masterDataSource,
"slaveDataSource", slaveDataSource));
// 默认使用masterDataSource:
ds.setDefaultTargetDataSource(masterDataSource);
return ds;
}

@Bean
JdbcTemplate jdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}

@Bean
DataSourceTransactionManager dataSourceTransactionManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}

仍然需要自己创建JdbcTemplatePlatformTransactionManager,注入的是标记为@PrimaryRoutingDataSource

这样,我们通过如下的代码就可以切换RoutingDataSource底层使用的真正的DataSource

1
2
RoutingDataSourceContext.setDataSourceRoutingKey("slaveDataSource");
jdbcTemplate.query(...);

只不过写代码切换DataSource即麻烦又容易出错,更好的方式是通过注解配合AOP实现自动切换,这样,客户端代码实现如下:

1
2
3
4
5
6
7
8
@Controller
public class UserController {
@RoutingWithSlave // <-- 指示在此方法中使用slave数据库
@GetMapping("/profile")
public ModelAndView profile(HttpSession session) {
...
}
}

实现上述功能需要编写一个@RoutingWithSlave注解,一个AOP织入和一个ThreadLocal来保存key。由于代码比较简单,这里我们不再详述。

如果我们想要确认是否真的切换了DataSource,可以覆写determineTargetDataSource()方法并打印出DataSource的名称:

1
2
3
4
5
6
7
8
9
10
class RoutingDataSource extends AbstractRoutingDataSource {
...

@Override
protected DataSource determineTargetDataSource() {
DataSource ds = super.determineTargetDataSource();
logger.info("determin target datasource: {}", ds);
return ds;
}
}

访问不同的URL,可以在日志中看到两个DataSource,分别是HikariPool-1hikariPool-2

1
2
2020-06-14 17:55:21.676  INFO 91561 --- [nio-8080-exec-7] c.i.learnjava.config.RoutingDataSource   : determin target datasource: HikariDataSource (HikariPool-1)
2020-06-14 17:57:08.992 INFO 91561 --- [io-8080-exec-10] c.i.learnjava.config.RoutingDataSource : determin target datasource: HikariDataSource (HikariPool-2)

我们用一个图来表示创建的DataSource以及相关Bean的关系:

1
2
3
4
5
6
7
8
9
10
┌────────────────────┐       ┌──────────────────┐
│@Primary │<──────│ JdbcTemplate │
│RoutingDataSource │ └──────────────────┘
│ ┌────────────────┐ │ ┌──────────────────┐
│ │MasterDataSource│ │<──────│DataSource │
│ └────────────────┘ │ │TransactionManager│
│ ┌────────────────┐ │ └──────────────────┘
│ │SlaveDataSource │ │
│ └────────────────┘ │
└────────────────────┘

注意到DataSourceTransactionManagerJdbcTemplate引用的都是RoutingDataSource,所以,这种设计的一个限制就是:在一个请求中,一旦切换了内部数据源,在同一个事务中,不能再切到另一个,否则,DataSourceTransactionManagerJdbcTemplate操作的就不是同一个数据库连接。

练习

禁用DataSourceAutoConfiguration并配置多数据源。

下载练习

小结

可以通过@EnableAutoConfiguration(exclude = {...})指定禁用的自动配置;

可以通过@Import({...})导入自定义配置。

添加Filter

我们在Spring中已经学过了集成Filter,本质上就是通过代理,把Spring管理的Bean注册到Servlet容器中,不过步骤比较繁琐,需要配置web.xml

在Spring Boot中,添加一个Filter更简单了,可以做到零配置。我们来看看在Spring Boot中如何添加Filter

Spring Boot会自动扫描所有的FilterRegistrationBean类型的Bean,然后,将它们返回的Filter自动注册到Servlet容器中,无需任何配置。

我们还是以AuthFilter为例,首先编写一个AuthFilterRegistrationBean,它继承自FilterRegistrationBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class AuthFilterRegistrationBean extends FilterRegistrationBean<Filter> {
@Autowired
UserService userService;

@Override
public Filter getFilter() {
setOrder(10);
return new AuthFilter();
}

class AuthFilter implements Filter {
...
}
}

FilterRegistrationBean本身不是Filter,它实际上是Filter的工厂。Spring Boot会调用getFilter(),把返回的Filter注册到Servlet容器中。因为我们可以在FilterRegistrationBean中注入需要的资源,然后,在返回的AuthFilter中,这个内部类可以引用外部类的所有字段,自然也包括注入的UserService,所以,整个过程完全基于Spring的IoC容器完成。

再注意到AuthFilterRegistrationBean使用了setOrder(10),因为Spring Boot支持给多个Filter排序,数字小的在前面,所以,多个Filter的顺序是可以固定的。

我们再编写一个ApiFilter,专门过滤/api/*这样的URL。首先编写一个ApiFilterRegistrationBean

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class ApiFilterRegistrationBean extends FilterRegistrationBean<Filter> {
@PostConstruct
public void init() {
setOrder(20);
setFilter(new ApiFilter());
setUrlPatterns(List.of("/api/*"));
}

class ApiFilter implements Filter {
...
}
}

这个ApiFilterRegistrationBeanAuthFilterRegistrationBean又有所不同。因为我们要过滤URL,而不是针对所有URL生效,因此,在@PostConstruct方法中,通过setFilter()设置一个Filter实例后,再调用setUrlPatterns()传入要过滤的URL列表。

练习

在Spring Boot中添加Filter并指定顺序。

下载练习

小结

在Spring Boot中添加Filter更加方便,并且支持对多个Filter进行排序。



和Spring相比,使用Spring Boot通过自动配置来集成第三方组件通常来说更简单。

我们将详细介绍如何通过Spring Boot集成常用的第三方组件,包括:

  • Open API
  • Redis
  • Artemis
  • RabbitMQ
  • Kafka

spring-boot

Open API是一个标准,它的主要作用是描述REST API,既可以作为文档给开发者阅读,又可以让机器根据这个文档自动生成客户端代码等。

在Spring Boot应用中,假设我们编写了一堆REST API,如何添加Open API的支持?

我们只需要在pom.xml中加入以下依赖:

  • org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.0

然后呢?没有然后了,直接启动应用,打开浏览器输入http://localhost:8080/swagger-ui.html

swagger-ui

立刻可以看到自动生成的API文档,这里列出了3个API,来自api-controller(因为定义在ApiController这个类中),点击某个API还可以交互,即输入API参数,点“Try it out”按钮,获得运行结果。

是不是太方便了!

因为我们引入springdoc-openapi-ui这个依赖后,它自动引入Swagger UI用来创建API文档。可以给API加入一些描述信息,例如:

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/api")
public class ApiController {
...
@Operation(summary = "Get specific user object by it's id.")
@GetMapping("/users/{id}")
public User user(@Parameter(description = "id of the user.") @PathVariable("id") long id) {
return userService.getUserById(id);
}
...
}

@Operation可以对API进行描述,@Parameter可以对参数进行描述,它们的目的是用于生成API文档的描述信息。添加了描述的API文档如下:

api-description

大多数情况下,不需要任何配置,我们就直接得到了一个运行时动态生成的可交互的API文档,该API文档总是和代码保持同步,大大简化了文档的编写工作。

要自定义文档的样式、控制某些API显示等,请参考springdoc文档

配置反向代理

如果在服务器上,用户访问的域名是https://example.com,但内部是通过类似Nginx这样的反向代理访问实际的Spring Boot应用,比如http://localhost:8080,这个时候,在页面https://example.com/swagger-ui.html上,显示的URL仍然是http://localhost:8080,这样一来,就无法直接在页面执行API,非常不方便。

这是因为Spring Boot内置的Tomcat默认获取的服务器名称是localhost,端口是实际监听端口,而不是对外暴露的域名和80443端口。要让Tomcat获取到对外暴露的域名等信息,必须在Nginx配置中传入必要的HTTP Header,常用的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
# Nginx配置
server {
...
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
...
}

然后,在Spring Boot的application.yml中,加入如下配置:

1
2
3
4
5
server:
# 实际监听端口:
port: 8080
# 从反向代理读取相关的HTTP Header:
forward-headers-strategy: native

重启Spring Boot应用,即可在Swagger中显示正确的URL。

练习

利用springdoc实现API文档。

下载练习

小结

使用springdoc让其自动创建API文档非常容易,引入依赖后无需任何配置即可访问交互式API文档。

可以对API添加注解以便生成更详细的描述。

在Spring Boot中,要访问Redis,可以直接引入spring-boot-starter-data-redis依赖,它实际上是Spring Data的一个子项目——Spring Data Redis,主要用到了这几个组件:

  • Lettuce:一个基于Netty的高性能Redis客户端;
  • RedisTemplate:一个类似于JdbcTemplate的接口,用于简化Redis的操作。

因为Spring Data Redis引入的依赖项很多,如果只是为了使用Redis,完全可以只引入Lettuce,剩下的操作都自己来完成。

本节我们稍微深入一下Redis的客户端,看看怎么一步一步把一个第三方组件引入到Spring Boot中。

首先,我们添加必要的几个依赖项:

  • io.lettuce:lettuce-core
  • org.apache.commons:commons-pool2

注意我们并未指定版本号,因为在spring-boot-starter-parent中已经把常用组件的版本号确定下来了。

第一步是在配置文件application.yml中添加Redis的相关配置:

1
2
3
4
5
6
7
spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
ssl: ${REDIS_SSL:false}
database: ${REDIS_DATABASE:0}

然后,通过RedisConfiguration来加载它:

1
2
3
4
5
6
7
8
9
@ConfigurationProperties("spring.redis")
public class RedisConfiguration {
private String host;
private int port;
private String password;
private int database;

// getters and setters...
}

再编写一个@Bean方法来创建RedisClient,可以直接放在RedisConfiguration中:

1
2
3
4
5
6
7
8
9
10
11
12
13
@ConfigurationProperties("spring.redis")
public class RedisConfiguration {
...

@Bean
RedisClient redisClient() {
RedisURI uri = RedisURI.Builder.redis(this.host, this.port)
.withPassword(this.password)
.withDatabase(this.database)
.build();
return RedisClient.create(uri);
}
}

在启动入口引入该配置:

1
2
3
4
5
@SpringBootApplication
@Import(RedisConfiguration.class) // 加载Redis配置
public class Application {
...
}

注意:如果在RedisConfiguration中标注@Configuration,则可通过Spring Boot的自动扫描机制自动加载,否则,使用@Import手动加载。

紧接着,我们用一个RedisService来封装所有的Redis操作。基础代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RedisService {
@Autowired
RedisClient redisClient;

GenericObjectPool<StatefulRedisConnection<String, String>> redisConnectionPool;

@PostConstruct
public void init() {
GenericObjectPoolConfig<StatefulRedisConnection<String, String>> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(5);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
this.redisConnectionPool = ConnectionPoolSupport.createGenericObjectPool(() -> redisClient.connect(), poolConfig);
}

@PreDestroy
public void shutdown() {
this.redisConnectionPool.close();
this.redisClient.shutdown();
}
}

注意到上述代码引入了Commons Pool的一个对象池,用于缓存Redis连接。因为Lettuce本身是基于Netty的异步驱动,在异步访问时并不需要创建连接池,但基于Servlet模型的同步访问时,连接池是有必要的。连接池在@PostConstruct方法中初始化,在@PreDestroy方法中关闭。

下一步,是在RedisService中添加Redis访问方法。为了简化代码,我们仿照JdbcTemplate.execute(ConnectionCallback)方法,传入回调函数,可大幅减少样板代码。

首先定义回调函数接口SyncCommandCallback

1
2
3
4
5
@FunctionalInterface
public interface SyncCommandCallback<T> {
// 在此操作Redis:
T doInConnection(RedisCommands<String, String> commands);
}

编写executeSync方法,在该方法中,获取Redis连接,利用callback操作Redis,最后释放连接,并返回操作结果:

1
2
3
4
5
6
7
8
9
10
public <T> T executeSync(SyncCommandCallback<T> callback) {
try (StatefulRedisConnection<String, String> connection = redisConnectionPool.borrowObject()) {
connection.setAutoFlushCommands(true);
RedisCommands<String, String> commands = connection.sync();
return callback.doInConnection(commands);
} catch (Exception e) {
logger.warn("executeSync redis failed.", e);
throw new RuntimeException(e);
}
}

有的童鞋觉得这样访问Redis的代码太复杂了,实际上我们可以针对常用操作把它封装一下,例如setget命令:

1
2
3
4
5
6
7
public String set(String key, String value) {
return executeSync(commands -> commands.set(key, value));
}

public String get(String key) {
return executeSync(commands -> commands.get(key));
}

类似的,hgethset操作如下:

1
2
3
4
5
6
7
8
9
10
11
public boolean hset(String key, String field, String value) {
return executeSync(commands -> commands.hset(key, field, value));
}

public String hget(String key, String field) {
return executeSync(commands -> commands.hget(key, field));
}

public Map<String, String> hgetall(String key) {
return executeSync(commands -> commands.hgetall(key));
}

常用命令可以提供方法接口,如果要执行任意复杂的操作,就可以通过executeSync(SyncCommandCallback<T>)来完成。

完成了RedisService后,我们就可以使用Redis了。例如,在UserController中,我们在Session中只存放登录用户的ID,用户信息存放到Redis,提供两个方法用于读写:

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
@Controller
public class UserController {
public static final String KEY_USER_ID = "__userid__";
public static final String KEY_USERS = "__users__";

@Autowired ObjectMapper objectMapper;
@Autowired RedisService redisService;

// 把User写入Redis:
private void putUserIntoRedis(User user) throws Exception {
redisService.hset(KEY_USERS, user.getId().toString(), objectMapper.writeValueAsString(user));
}

// 从Redis读取User:
private User getUserFromRedis(HttpSession session) throws Exception {
Long id = (Long) session.getAttribute(KEY_USER_ID);
if (id != null) {
String s = redisService.hget(KEY_USERS, id.toString());
if (s != null) {
return objectMapper.readValue(s, User.class);
}
}
return null;
}
...
}

用户登录成功后,把ID放入Session,把User实例放入Redis:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/signin")
public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password, HttpSession session) throws Exception {
try {
User user = userService.signin(email, password);
session.setAttribute(KEY_USER_ID, user.getId());
putUserIntoRedis(user);
} catch (RuntimeException e) {
return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
}
return new ModelAndView("redirect:/profile");
}

需要获取User时,从Redis取出:

1
2
3
4
5
6
7
8
@GetMapping("/profile")
public ModelAndView profile(HttpSession session) throws Exception {
User user = getUserFromRedis(session);
if (user == null) {
return new ModelAndView("redirect:/signin");
}
return new ModelAndView("profile.html", Map.of("user", user));
}

从Redis读写Java对象时,序列化和反序列化是应用程序的工作,上述代码使用JSON作为序列化方案,简单可靠。也可将相关序列化操作封装到RedisService中,这样可以提供更加通用的方法:

1
2
3
4
5
6
7
public <T> T get(String key, Class<T> clazz) {
...
}

public <T> T set(String key, T value) {
...
}

练习

在Spring Boot中访问Redis。

下载练习

小结

Spring Boot默认使用Lettuce作为Redis客户端,同步使用时,应通过连接池提高效率。

集成Artemis

ActiveMQ Artemis是一个JMS服务器,在集成JMS一节中我们已经详细讨论了如何在Spring中集成Artemis,本节我们讨论如何在Spring Boot中集成Artemis。

我们还是以实际工程为例,创建一个springboot-jms工程,引入的依赖除了spring-boot-starter-webspring-boot-starter-jdbc等以外,新增spring-boot-starter-artemis

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-artemis</artifactId>
</dependency>

同样无需指定版本号。

如何创建Artemis服务器我们已经在集成JMS一节中详细讲述了,此处不再重复。创建Artemis服务器后,我们在application.yml中加入相关配置:

1
2
3
4
5
6
7
8
9
10
spring:
artemis:
# 指定连接外部Artemis服务器,而不是启动嵌入式服务:
mode: native
# 服务器地址和端口号:
host: 127.0.0.1
port: 61616
# 连接用户名和口令由创建Artemis服务器时指定:
user: admin
password: password

和Spring版本的JMS代码相比,使用Spring Boot集成JMS时,只要引入了spring-boot-starter-artemis,Spring Boot会自动创建JMS相关的ConnectionFactoryJmsListenerContainerFactoryJmsTemplate等,无需我们再手动配置了。

发送消息时只需要引入JmsTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MessagingService {
@Autowired
JmsTemplate jmsTemplate;

public void sendMailMessage() throws Exception {
String text = "...";
jmsTemplate.send("jms/queue/mail", new MessageCreator() {
public Message createMessage(Session session) throws JMSException {
return session.createTextMessage(text);
}
});
}
}

接收消息时只需要标注@JmsListener

1
2
3
4
5
6
7
8
9
@Component
public class MailMessageListener {
final Logger logger = LoggerFactory.getLogger(getClass());

@JmsListener(destination = "jms/queue/mail", concurrency = "10")
public void onMailMessageReceived(Message message) throws Exception {
logger.info("received message: " + message);
}
}

可见,应用程序收发消息的逻辑和Spring中使用JMS完全相同,只是通过Spring Boot,我们把工程简化到只需要设定Artemis相关配置。

练习

在Spring Boot中使用Artemis。

下载练习

小结

在Spring Boot中使用Artemis作为JMS服务时,只需引入spring-boot-starter-artemis依赖,即可直接使用JMS。



前面我们讲了ActiveMQ Artemis,它实现了JMS的消息服务协议。JMS是JavaEE的消息服务标准接口,但是,如果Java程序要和另一种语言编写的程序通过消息服务器进行通信,那么JMS就不太适合了。

AMQP是一种使用广泛的独立于语言的消息协议,它的全称是Advanced Message Queuing Protocol,即高级消息队列协议,它定义了一种二进制格式的消息流,任何编程语言都可以实现该协议。实际上,Artemis也支持AMQP,但实际应用最广泛的AMQP服务器是使用Erlang编写的RabbitMQ

安装RabbitMQ

我们先从RabbitMQ的官网下载并安装RabbitMQ,安装和启动RabbitMQ请参考官方文档。要验证启动是否成功,可以访问RabbitMQ的管理后台http://localhost:15672,如能看到登录界面表示RabbitMQ启动成功:

rabbitmq-manage

RabbitMQ后台管理的默认用户名和口令均为guest

AMQP协议

AMQP协议和前面我们介绍的JMS协议有所不同。在JMS中,有两种类型的消息通道:

  1. 点对点的Queue,即Producer发送消息到指定的Queue,接收方从Queue收取消息;
  2. 一对多的Topic,即Producer发送消息到指定的Topic,任意多个在线的接收方均可从Topic获得一份完整的消息副本。

但是AMQP协议比JMS要复杂一点,它只有Queue,没有Topic,并且引入了Exchange的概念。当Producer想要发送消息的时候,它将消息发送给Exchange,由Exchange将消息根据各种规则投递到一个或多个Queue:

1
2
3
4
5
6
7
8
9
10
11
                                    ┌───────┐
┌──▶│Queue-1│
┌──────────┐ │ └───────┘
┌──▶│Exchange-1│──┤
┌──────────┐ │ └──────────┘ │ ┌───────┐
│Producer-1│──┤ ├──▶│Queue-2│
└──────────┘ │ ┌──────────┐ │ └───────┘
└──▶│Exchange-2│──┤
└──────────┘ │ ┌───────┐
└──▶│Queue-3│
└───────┘

如果某个Exchange总是把消息发送到固定的Queue,那么这个消息通道就相当于JMS的Queue。如果某个Exchange把消息发送到多个Queue,那么这个消息通道就相当于JMS的Topic。和JMS的Topic相比,Exchange的投递规则更灵活,比如一个“登录成功”的消息被投递到Queue-1和Queue-2,而“登录失败”的消息则被投递到Queue-3。这些路由规则称之为Binding,通常都在RabbitMQ的管理后台设置。

我们以具体的业务为例子,在RabbitMQ中,首先创建3个Queue,分别用于发送邮件、短信和App通知:

queues

创建Queue时注意到可配置为持久化(Durable)和非持久化(Transient),当Consumer不在线时,持久化的Queue会暂存消息,非持久化的Queue会丢弃消息。

紧接着,我们在Exchanges中创建一个Direct类型的Exchange,命名为registration,并添加如下两个Binding:

exchange-registration

上述Binding的规则就是:凡是发送到registration这个Exchange的消息,均被发送到q_mailq_sms这两个Queue。

我们再创建一个Direct类型的Exchange,命名为login,并添加如下Binding:

exchange-login

上述Binding的规则稍微复杂一点,当发送消息给login这个Exchange时,如果消息没有指定Routing Key,则被投递到q_appq_mail,如果消息指定了Routing Key=“login_failed”,那么消息被投递到q_sms

配置好RabbitMQ后,我们就可以基于Spring Boot开发AMQP程序。

使用RabbitMQ

我们首先创建Spring Boot工程springboot-rabbitmq,并添加如下依赖引入RabbitMQ:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

然后在application.yml中添加RabbitMQ相关配置:

1
2
3
4
5
6
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest

我们还需要在Application中添加一个MessageConverter

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.amqp.support.converter.MessageConverter;

@SpringBootApplication
public class Application {
...

@Bean
MessageConverter createMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}

MessageConverter用于将Java对象转换为RabbitMQ的消息。默认情况下,Spring Boot使用SimpleMessageConverter,只能发送Stringbyte[]类型的消息,不太方便。使用Jackson2JsonMessageConverter,我们就可以发送JavaBean对象,由Spring Boot自动序列化为JSON并以文本消息传递。

因为引入了starter,所有RabbitMQ相关的Bean均自动装配,我们需要在Producer注入的是RabbitTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MessagingService {
@Autowired
RabbitTemplate rabbitTemplate;

public void sendRegistrationMessage(RegistrationMessage msg) {
rabbitTemplate.convertAndSend("registration", "", msg);
}

public void sendLoginMessage(LoginMessage msg) {
String routingKey = msg.success ? "" : "login_failed";
rabbitTemplate.convertAndSend("login", routingKey, msg);
}
}

发送消息时,使用convertAndSend(exchange, routingKey, message)可以指定Exchange、Routing Key以及消息本身。这里传入JavaBean后会自动序列化为JSON文本。上述代码将RegistrationMessage发送到registration,将LoginMessage发送到login,并根据登录是否成功来指定Routing Key。

接收消息时,需要在消息处理的方法上标注@RabbitListener

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
@Component
public class QueueMessageListener {
final Logger logger = LoggerFactory.getLogger(getClass());

static final String QUEUE_MAIL = "q_mail";
static final String QUEUE_SMS = "q_sms";
static final String QUEUE_APP = "q_app";

@RabbitListener(queues = QUEUE_MAIL)
public void onRegistrationMessageFromMailQueue(RegistrationMessage message) throws Exception {
logger.info("queue {} received registration message: {}", QUEUE_MAIL, message);
}

@RabbitListener(queues = QUEUE_SMS)
public void onRegistrationMessageFromSmsQueue(RegistrationMessage message) throws Exception {
logger.info("queue {} received registration message: {}", QUEUE_SMS, message);
}

@RabbitListener(queues = QUEUE_MAIL)
public void onLoginMessageFromMailQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_MAIL, message);
}

@RabbitListener(queues = QUEUE_SMS)
public void onLoginMessageFromSmsQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_SMS, message);
}

@RabbitListener(queues = QUEUE_APP)
public void onLoginMessageFromAppQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_APP, message);
}
}

上述代码一共定义了5个Consumer,监听3个Queue。

启动应用程序,我们注册一个新用户,然后发送一条RegistrationMessage消息。此时,根据registration这个Exchange的设定,我们会在两个Queue收到消息:

1
2
3
4
... c.i.learnjava.service.UserService        : try register by bob@example.com...
... c.i.learnjava.web.UserController : user registered: bob@example.com
... c.i.l.service.QueueMessageListener : queue q_mail received registration message: [RegistrationMessage: email=bob@example.com, name=Bob, timestamp=1594559871495]
... c.i.l.service.QueueMessageListener : queue q_sms received registration message: [RegistrationMessage: email=bob@example.com, name=Bob, timestamp=1594559871495]

当我们登录失败时,发送LoginMessage并设定Routing Key为login_failed,此时,只有q_sms会收到消息:

1
2
... c.i.learnjava.service.UserService        : try login by bob@example.com...
... c.i.l.service.QueueMessageListener : queue q_sms received message: [LoginMessage: email=bob@example.com, name=(unknown), success=false, timestamp=1594559886722]

登录成功后,发送LoginMessage,此时,q_mailq_app将收到消息:

1
2
3
... c.i.learnjava.service.UserService        : try login by bob@example.com...
... c.i.l.service.QueueMessageListener : queue q_mail received message: [LoginMessage: email=bob@example.com, name=Bob, success=true, timestamp=1594559895251]
... c.i.l.service.QueueMessageListener : queue q_app received message: [LoginMessage: email=bob@example.com, name=Bob, success=true, timestamp=1594559895251]

RabbitMQ还提供了使用Topic的Exchange(此Topic指消息的标签,并非JMS的Topic概念),可以使用*进行匹配并路由。可见,掌握RabbitMQ的核心是理解其消息的路由规则。

直接指定一个Queue并投递消息也是可以的,此时指定Routing Key为Queue的名称即可,因为RabbitMQ提供了一个default exchange用于根据Routing Key查找Queue并直接投递消息到指定的Queue。但是要实现一对多的投递就必须自己配置Exchange。

练习

在Spring Boot中使用RabbitMQ。

下载练习

小结

Spring Boot提供了AMQP的集成,默认使用RabbitMQ作为AMQP消息服务器。

使用RabbitMQ发送消息时,理解Exchange如何路由至一个或多个Queue至关重要。

我们在前面已经介绍了JMS和AMQP,JMS是JavaEE的标准消息接口,Artemis是一个JMS实现产品,AMQP是跨语言的一个标准消息接口,RabbitMQ是一个AMQP实现产品。

Kafka也是一个消息服务器,它的特点一是快,二是有巨大的吞吐量,那么Kafka实现了什么标准消息接口呢?

Kafka没有实现任何标准的消息接口,它自己提供的API就是Kafka的接口。

哥没有实现任何标准,哥自己就是标准。

—— Kafka

Kafka本身是Scala编写的,运行在JVM之上。Producer和Consumer都通过Kafka的客户端使用网络来与之通信。从逻辑上讲,Kafka设计非常简单,它只有一种类似JMS的Topic的消息通道:

1
2
3
4
5
6
7
8
9
                           ┌──────────┐
┌──▶│Consumer-1│
│ └──────────┘
┌────────┐ ┌─────┐ │ ┌──────────┐
│Producer│───▶│Topic│──┼──▶│Consumer-2│
└────────┘ └─────┘ │ └──────────┘
│ ┌──────────┐
└──▶│Consumer-3│
└──────────┘

那么Kafka如何支持十万甚至百万的并发呢?答案是分区。Kafka的一个Topic可以有一个至多个Partition,并且可以分布到多台机器上:

1
2
3
4
5
6
7
8
9
10
11
12
13
            ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Topic
│ │
┌───────────┐ ┌──────────┐
│┌─▶│Partition-1│──┐│┌──▶│Consumer-1│
│ └───────────┘ │ │ └──────────┘
┌────────┐ ││ ┌───────────┐ │││ ┌──────────┐
│Producer│───┼─▶│Partition-2│──┼─┼──▶│Consumer-2│
└────────┘ ││ └───────────┘ │││ └──────────┘
│ ┌───────────┐ │ │ ┌──────────┐
│└─▶│Partition-3│──┘│└──▶│Consumer-3│
└───────────┘ └──────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

Kafka只保证在一个Partition内部,消息是有序的,但是,存在多个Partition的情况下,Producer发送的3个消息会依次发送到Partition-1、Partition-2和Partition-3,Consumer从3个Partition接收的消息并不一定是Producer发送的顺序,因此,多个Partition只能保证接收消息大概率按发送时间有序,并不能保证完全按Producer发送的顺序。这一点在使用Kafka作为消息服务器时要特别注意,对发送顺序有严格要求的Topic只能有一个Partition。

Kafka的另一个特点是消息发送和接收都尽量使用批处理,一次处理几十甚至上百条消息,比一次一条效率要高很多。

最后要注意的是消息的持久性。Kafka总是将消息写入Partition对应的文件,消息保存多久取决于服务器的配置,可以按照时间删除(默认3天),也可以按照文件大小删除,因此,只要Consumer在离线期内的消息还没有被删除,再次上线仍然可以接收到完整的消息流。这一功能实际上是客户端自己实现的,客户端会存储它接收到的最后一个消息的offsetId,再次上线后按上次的offsetId查询。offsetId是Kafka标识某个Partion的每一条消息的递增整数,客户端通常将它存储在ZooKeeper中。

有了Kafka消息设计的基本概念,我们来看看如何在Spring Boot中使用Kafka。

安装Kafka

首先从Kafka官网下载最新版Kafaka,解压后在bin目录找到两个文件:

  • zookeeper-server-start.sh:启动ZooKeeper(已内置在Kafka中);
  • kafka-server-start.sh:启动Kafka。

先启动ZooKeeper:

1
$ ./zookeeper-server-start.sh ../config/zookeeper.properties 

再启动Kafka:

1
./kafka-server-start.sh ../config/server.properties

看到如下输出表示启动成功:

1
... INFO [KafkaServer id=0] started (kafka.server.KafkaServer)

如果要关闭Kafka和ZooKeeper,依次按Ctrl-C退出即可。注意这是在本地开发时使用Kafka的方式,线上Kafka服务推荐使用云服务厂商托管模式(AWS的MSK,阿里云的消息队列Kafka版)。

使用Kafka

在Spring Boot中使用Kafka,首先要引入依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>

注意这个依赖是spring-kafka项目提供的。

然后,在application.yml中添加Kafka配置:

1
2
3
4
5
6
7
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
auto-offset-reset: latest
max-poll-records: 100
max-partition-fetch-bytes: 1000000

除了bootstrap-servers必须指定外,consumer相关的配置项均为调优选项。例如,max-poll-records表示一次最多抓取100条消息。配置名称去哪里看?IDE里定义一个KafkaProperties.Consumer的变量:

1
KafkaProperties.Consumer c = null;

然后按住Ctrl查看源码即可。

发送消息

Spring Boot自动为我们创建一个KafkaTemplate用于发送消息。注意到这是一个泛型类,而默认配置总是使用String作为Kafka消息的类型,所以注入KafkaTemplate<String, String>即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class MessagingService {
@Autowired ObjectMapper objectMapper;

@Autowired KafkaTemplate<String, String> kafkaTemplate;

public void sendRegistrationMessage(RegistrationMessage msg) throws IOException {
send("topic_registration", msg);
}

public void sendLoginMessage(LoginMessage msg) throws IOException {
send("topic_login", msg);
}

private void send(String topic, Object msg) throws IOException {
ProducerRecord<String, String> pr = new ProducerRecord<>(topic, objectMapper.writeValueAsString(msg));
pr.headers().add("type", msg.getClass().getName().getBytes(StandardCharsets.UTF_8));
kafkaTemplate.send(pr);
}
}

发送消息时,需指定Topic名称,消息正文。为了发送一个JavaBean,这里我们没有使用MessageConverter来转换JavaBean,而是直接把消息类型作为Header添加到消息中,Header名称为type,值为Class全名。消息正文是序列化的JSON。

接收消息

接收消息可以使用@KafkaListener注解:

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
@Component
public class TopicMessageListener {
private final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
ObjectMapper objectMapper;

@KafkaListener(topics = "topic_registration", groupId = "group1")
public void onRegistrationMessage(@Payload String message, @Header("type") String type) throws Exception {
RegistrationMessage msg = objectMapper.readValue(message, getType(type));
logger.info("received registration message: {}", msg);
}

@KafkaListener(topics = "topic_login", groupId = "group1")
public void onLoginMessage(@Payload String message, @Header("type") String type) throws Exception {
LoginMessage msg = objectMapper.readValue(message, getType(type));
logger.info("received login message: {}", msg);
}

@KafkaListener(topics = "topic_login", groupId = "group2")
public void processLoginMessage(@Payload String message, @Header("type") String type) throws Exception {
LoginMessage msg = objectMapper.readValue(message, getType(type));
logger.info("process login message: {}", msg);
}

@SuppressWarnings("unchecked")
private static <T> Class<T> getType(String type) {
// TODO: use cache:
try {
return (Class<T>) Class.forName(type);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}

在接收消息的方法中,使用@Payload表示传入的是消息正文,使用@Header可传入消息的指定Header,这里传入@Header("type"),就是我们发送消息时指定的Class全名。接收消息时,我们需要根据Class全名来反序列化获得JavaBean。

上述代码一共定义了3个Listener,其中有两个方法监听的是同一个Topic,但它们的Group ID不同。假设Producer发送的消息流是A、B、C、D,Group ID不同表示这是两个不同的Consumer,它们将分别收取完整的消息流,即各自均收到A、B、C、D。Group ID相同的多个Consumer实际上被视作一个Consumer,即如果有两个Group ID相同的Consumer,那么它们各自收到的很可能是A、C和B、D。

运行应用程序,注册新用户后,观察日志输出:

1
2
3
... c.i.learnjava.service.UserService        : try register by bob@example.com...
... c.i.learnjava.web.UserController : user registered: bob@example.com
... c.i.l.service.TopicMessageListener : received registration message: [RegistrationMessage: email=bob@example.com, name=Bob, timestamp=1594637517458]

用户登录后,观察日志输出:

1
2
3
... c.i.learnjava.service.UserService        : try login by bob@example.com...
... c.i.l.service.TopicMessageListener : received login message: [LoginMessage: email=bob@example.com, name=Bob, success=true, timestamp=1594637523470]
... c.i.l.service.TopicMessageListener : process login message: [LoginMessage: email=bob@example.com, name=Bob, success=true, timestamp=1594637523470]

因为Group ID不同,同一个消息被两个Consumer分别独立接收。如果把Group ID改为相同,那么同一个消息只会被两者之一接收。

有细心的童鞋可能会问,在Kafka中是如何创建Topic的?又如何指定某个Topic的分区数量?

实际上开发使用的Kafka默认允许自动创建Topic,创建Topic时默认的分区数量是2,可以通过server.properties修改默认分区数量。

在生产环境中通常会关闭自动创建功能,Topic需要由运维人员先创建好。和RabbitMQ相比,Kafka并不提供网页版管理后台,管理Topic需要使用命令行,比较繁琐,只有云服务商通常会提供更友好的管理后台。

练习

在Spring Boot中使用Kafka。

下载练习

小结

Spring Boot通过KafkaTemplate发送消息,通过@KafkaListener接收消息;

配置Consumer时,指定Group ID非常重要。

留言與分享

Spring框架不仅提供了标准的IoC容器、AOP支持、数据库访问以及WebMVC等标准功能,还可以非常方便地集成许多常用的第三方组件:

  • 可以集成JavaMail发送邮件;
  • 可以集成JMS消息服务;
  • 可以集成Quartz实现定时任务;
  • 可以集成Redis等服务。

本章我们介绍如何在Spring中简单快捷地集成这些第三方组件。

我们在发送Email接收Email中已经介绍了如何通过JavaMail来收发电子邮件。在Spring中,同样可以集成JavaMail。

因为在服务器端,主要以发送邮件为主,例如在注册成功、登录时、购物付款后通知用户,基本上不会遇到接收用户邮件的情况,所以本节我们只讨论如何在Spring中发送邮件。

在Spring中,发送邮件最终也是需要JavaMail,Spring只对JavaMail做了一点简单的封装,目的是简化代码。为了在Spring中集成JavaMail,我们在pom.xml中添加以下依赖:

  • org.springframework:spring-context-support:6.0.0
  • jakarta.mail:jakarta.mail-api:2.0.1
  • com.sun.mail:jakarta.mail:2.0.1

以及其他Web相关依赖。

我们希望用户在注册成功后能收到注册邮件,为此,我们先定义一个JavaMailSender的Bean:

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
@Bean
JavaMailSender createJavaMailSender(
// smtp.properties:
@Value("${smtp.host}") String host,
@Value("${smtp.port}") int port,
@Value("${smtp.auth}") String auth,
@Value("${smtp.username}") String username,
@Value("${smtp.password}") String password,
@Value("${smtp.debug:true}") String debug)
{
var mailSender = new JavaMailSenderImpl();
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", auth);
if (port == 587) {
props.put("mail.smtp.starttls.enable", "true");
}
if (port == 465) {
props.put("mail.smtp.socketFactory.port", "465");
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
}
props.put("mail.debug", debug);
return mailSender;
}

这个JavaMailSender接口的实现类是JavaMailSenderImpl,初始化时,传入的参数与JavaMail是完全一致的。

另外注意到需要注入的属性是从smtp.properties中读取的,因此,AppConfig导入的就不止一个.properties文件,可以导入多个:

1
2
3
4
5
6
7
8
@Configuration
@ComponentScan
@EnableWebMvc
@EnableTransactionManagement
@PropertySource({ "classpath:/jdbc.properties", "classpath:/smtp.properties" })
public class AppConfig {
...
}

下一步是封装一个MailService,并定义sendRegistrationMail()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class MailService {
@Value("${smtp.from}")
String from;

@Autowired
JavaMailSender mailSender;

public void sendRegistrationMail(User user) {
try {
MimeMessage mimeMessage = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8");
helper.setFrom(from);
helper.setTo(user.getEmail());
helper.setSubject("Welcome to Java course!");
String html = String.format("<p>Hi, %s,</p><p>Welcome to Java course!</p><p>Sent at %s</p>", user.getName(), LocalDateTime.now());
helper.setText(html, true);
mailSender.send(mimeMessage);
} catch (MessagingException e) {
throw new RuntimeException(e);
}
}
}

观察上述代码,MimeMessage是JavaMail的邮件对象,而MimeMessageHelper是Spring提供的用于简化设置MimeMessage的类,比如我们设置HTML邮件就可以直接调用setText(String text, boolean html)方法,而不必再调用比较繁琐的JavaMail接口方法。

最后一步是调用JavaMailSender.send()方法把邮件发送出去。

在MVC的某个Controller方法中,当用户注册成功后,我们就启动一个新线程来异步发送邮件:

1
2
3
4
5
6
User user = userService.register(email, password, name);
logger.info("user registered: {}", user.getEmail());
// send registration mail:
new Thread(() -> {
mailService.sendRegistrationMail(user);
}).start();

因为发送邮件是一种耗时的任务,从几秒到几分钟不等,因此,异步发送是保证页面能快速显示的必要措施。这里我们直接启动了一个新的线程,但实际上还有更优化的方法,我们在下一节讨论。

练习

使用Spring发送邮件。

下载练习

小结

Spring可以集成JavaMail,通过简单的封装,能简化邮件发送代码。其核心是定义一个JavaMailSender的Bean,然后调用其send()方法。

JMS即Java Message Service,是JavaEE的消息服务接口。JMS主要有两个版本:1.1和2.0。2.0和1.1相比,主要是简化了收发消息的代码。

所谓消息服务,就是两个进程之间,通过消息服务器传递消息:

1
2
3
┌────────┐    ┌──────────────┐    ┌────────┐
│Producer│───▶│Message Server│───▶│Consumer│
└────────┘ └──────────────┘ └────────┘

使用消息服务,而不是直接调用对方的API,它的好处是:

  • 双方各自无需知晓对方的存在,消息可以异步处理,因为消息服务器会在Consumer离线的时候自动缓存消息;
  • 如果Producer发送的消息频率高于Consumer的处理能力,消息可以积压在消息服务器,不至于压垮Consumer;
  • 通过一个消息服务器,可以连接多个Producer和多个Consumer。

因为消息服务在各类应用程序中非常有用,所以JavaEE专门定义了JMS规范。注意到JMS是一组接口定义,如果我们要使用JMS,还需要选择一个具体的JMS产品。常用的JMS服务器有开源的ActiveMQ,商业服务器如WebLogic、WebSphere等也内置了JMS支持。这里我们选择开源的ActiveMQ作为JMS服务器,因此,在开发JMS之前我们必须首先安装ActiveMQ。

现在问题来了:从官网下载ActiveMQ时,蹦出一个页面,让我们选择ActiveMQ Classic或者ActiveMQ Artemis,这两个是什么关系,又有什么区别?

实际上ActiveMQ Classic原来就叫ActiveMQ,是Apache开发的基于JMS 1.1的消息服务器,目前稳定版本号是5.x,而ActiveMQ Artemis是由RedHat捐赠的HornetQ服务器代码的基础上开发的,目前稳定版本号是2.x。和ActiveMQ Classic相比,Artemis版的代码与Classic完全不同,并且,它支持JMS 2.0,使用基于Netty的异步IO,大大提升了性能。此外,Artemis不仅提供了JMS接口,它还提供了AMQP接口,STOMP接口和物联网使用的MQTT接口。选择Artemis,相当于一鱼四吃。

所以,我们这里直接选择ActiveMQ Artemis。从官网下载最新的2.x版本,解压后设置环境变量ARTEMIS_HOME,指向Artemis根目录,例如C:\Apps\artemis,然后,把ARTEMIS_HOME/bin加入PATH环境变量:

  • Windows下添加%ARTEMIS_HOME%\bin到Path路径;
  • Mac和Linux下添加$ARTEMIS_HOME/bin到PATH路径。

Artemis有个很好的设计,就是它把程序和数据完全分离了。我们解压后的ARTEMIS_HOME目录是程序目录,要启动一个Artemis服务,还需要创建一个数据目录。我们把数据目录直接设定在项目spring-integration-jmsjms-data目录下。执行命令artemis create jms-data

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
$ pwd
/Users/liaoxuefeng/workspace/spring-integration-jms

$ artemis create jms-data
Creating ActiveMQ Artemis instance at: /Users/liaoxuefeng/workspace/spring-integration-jms/jms-data

--user: is a mandatory property!
Please provide the default username:
admin

--password: is mandatory with this configuration:
Please provide the default password:
********

--allow-anonymous | --require-login: is a mandatory property!
Allow anonymous access?, valid values are Y,N,True,False
N

Auto tuning journal ...
done! Your system can make 0.09 writes per millisecond, your journal-buffer-timeout will be 11392000

You can now start the broker by executing:

"/Users/liaoxuefeng/workspace/spring-integration-jms/jms-data/bin/artemis" run

Or you can run the broker in the background using:

"/Users/liaoxuefeng/workspace/spring-integration-jms/jms-data/bin/artemis-service" start

在创建过程中,会要求输入连接用户和口令,这里我们设定adminpassword,以及是否允许匿名访问(这里选择N)。

此数据目录jms-data不仅包含消息数据、日志,还自动创建了两个启动服务的命令bin/artemisbin/artemis-service,前者在前台启动运行,按Ctrl+C结束,后者会一直在后台运行。

我们把目录切换到jms-data/bin,直接运行artemis run即可启动Artemis服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./artemis run
_ _ _
/ \ ____| |_ ___ __ __(_) _____
/ _ \| _ \ __|/ _ \ \/ | |/ __/
/ ___ \ | \/ |_/ __/ |\/| | |\___ \
/_/ \_\| \__\____|_| |_|_|/___ /
Apache ActiveMQ Artemis 2.13.0

...

2020-06-02 07:50:21,718 INFO [org.apache.activemq.artemis] AMQ241001: HTTP Server started at http://localhost:8161
2020-06-02 07:50:21,718 INFO [org.apache.activemq.artemis] AMQ241002: Artemis Jolokia REST API available at http://localhost:8161/console/jolokia
2020-06-02 07:50:21,719 INFO [org.apache.activemq.artemis] AMQ241004: Artemis Console available at http://localhost:8161/console

启动成功后,Artemis提示可以通过URLhttp://localhost:8161/console访问管理后台。注意不要关闭命令行窗口

注意

如果Artemis启动时显示警告:AMQ222212: Disk Full! … Clients will report blocked.这是因为磁盘空间不够,可以在etc/broker.xml配置中找到并改为99。

在编写JMS代码之前,我们首先得理解JMS的消息模型。JMS把生产消息的一方称为Producer,处理消息的一方称为Consumer。有两种类型的消息通道,一种是Queue:

1
2
3
┌────────┐    ┌────────┐    ┌────────┐
│Producer│───▶│ Queue │───▶│Consumer│
└────────┘ └────────┘ └────────┘

一种是Topic:

1
2
3
4
5
6
7
8
9
                            ┌────────┐
┌─▶│Consumer│
│ └────────┘
┌────────┐ ┌────────┐ │ ┌────────┐
│Producer│───▶│ Topic │─┼─▶│Consumer│
└────────┘ └────────┘ │ └────────┘
│ ┌────────┐
└─▶│Consumer│
└────────┘

它们的区别在于,Queue是一种一对一的通道,如果Consumer离线无法处理消息时,Queue会把消息存起来,等Consumer再次连接的时候发给它。设定了持久化机制的Queue不会丢失消息。如果有多个Consumer接入同一个Queue,那么它们等效于以集群方式处理消息,例如,发送方发送的消息是A,B,C,D,E,F,两个Consumer可能分别收到A,C,E和B,D,F,即每个消息只会交给其中一个Consumer处理。

Topic则是一种一对多通道。一个Producer发出的消息,会被多个Consumer同时收到,即每个Consumer都会收到一份完整的消息流。那么问题来了:如果某个Consumer暂时离线,过一段时间后又上线了,那么在它离线期间产生的消息还能不能收到呢?

这取决于消息服务器对Topic类型消息的持久化机制。如果消息服务器不存储Topic消息,那么离线的Consumer会丢失部分离线时期的消息,如果消息服务器存储了Topic消息,那么离线的Consumer可以收到自上次离线时刻开始后产生的所有消息。JMS规范通过Consumer指定一个持久化订阅可以在上线后收取所有离线期间的消息,如果指定的是非持久化订阅,那么离线期间的消息会全部丢失。

细心的童鞋可以看出来,如果一个Topic的消息全部都持久化了,并且只有一个Consumer,那么它和Queue其实是一样的。实际上,很多消息服务器内部都只有Topic类型的消息架构,Queue可以通过Topic“模拟”出来。

无论是Queue还是Topic,对Producer没有什么要求。多个Producer也可以写入同一个Queue或者Topic,此时消息服务器内部会自动排序确保消息总是有序的。

以上是消息服务的基本模型。具体到某个消息服务器时,Producer和Consumer通常是通过TCP连接消息服务器,在编写JMS程序时,又会遇到ConnectionFactoryConnectionSession等概念,其实这和JDBC连接是类似的:

  • ConnectionFactory:代表一个到消息服务器的连接池,类似JDBC的DataSource;
  • Connection:代表一个到消息服务器的连接,类似JDBC的Connection;
  • Session:代表一个经过认证后的连接会话;
  • Message:代表一个消息对象。

在JMS 1.1中,发送消息的典型代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
Connection connection = null;
try {
// 创建连接:
connection = connectionFactory.createConnection();
// 创建会话:
Session session = connection.createSession(false,Session.AUTO_ACKNOWLEDGE);
// 创建一个Producer并关联到某个Queue:
MessageProducer messageProducer = session.createProducer(queue);
// 创建一个文本消息:
TextMessage textMessage = session.createTextMessage(text);
// 发送消息:
messageProducer.send(textMessage);
} finally {
// 关闭连接:
if (connection != null) {
connection.close();
}
}
} catch (JMSException ex) {
// 处理JMS异常
}

JMS 2.0改进了一些API接口,发送消息变得更简单:

1
2
3
try (JMSContext context = connectionFactory.createContext()) {
context.createProducer().send(queue, text);
}

JMSContext实现了AutoCloseable接口,可以使用try(resource)语法,代码更简单。

有了以上预备知识,我们就可以开始开发JMS应用了。

首先,我们在pom.xml中添加如下依赖:

  • org.springframework:spring-jms:6.0.0
  • org.apache.activemq:artemis-jakarta-client:2.27.0

Artemis的Client接口依赖了jakarta.jms:jakarta.jms-api,因此不必再引入JMS API的依赖。

在AppConfig中,通过@EnableJms让Spring自动扫描JMS相关的Bean,并加载JMS配置文件jms.properties

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
@EnableWebMvc
@EnableJms // 启用JMS
@EnableTransactionManagement
@PropertySource({ "classpath:/jdbc.properties", "classpath:/jms.properties" })
public class AppConfig {
...
}

首先要创建的Bean是ConnectionFactory,即连接消息服务器的连接池:

1
2
3
4
5
6
7
8
@Bean
ConnectionFactory createJMSConnectionFactory(
@Value("${jms.uri:tcp://localhost:61616}") String uri,
@Value("${jms.username:admin}") String username,
@Value("${jms.password:password}") String password)
{
return new ActiveMQJMSConnectionFactory(uri, username, password);
}

因为我们使用的消息服务器是ActiveMQ Artemis,所以ConnectionFactory的实现类就是消息服务器提供的ActiveMQJMSConnectionFactory,它需要的参数均由配置文件读取后传入,并设置了默认值。

我们再创建一个JmsTemplate,它是Spring提供的一个工具类,和JdbcTemplate类似,可以简化发送消息的代码:

1
2
3
4
@Bean
JmsTemplate createJmsTemplate(@Autowired ConnectionFactory connectionFactory) {
return new JmsTemplate(connectionFactory);
}

下一步要创建的是JmsListenerContainerFactory

1
2
3
4
5
6
@Bean("jmsListenerContainerFactory")
DefaultJmsListenerContainerFactory createJmsListenerContainerFactory(@Autowired ConnectionFactory connectionFactory) {
var factory = new DefaultJmsListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
return factory;
}

除了必须指定Bean的名称为jmsListenerContainerFactory外,这个Bean的作用是处理和Consumer相关的Bean。我们先跳过它的原理,继续编写MessagingService来发送消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MessagingService {
@Autowired ObjectMapper objectMapper;
@Autowired JmsTemplate jmsTemplate;

public void sendMailMessage(MailMessage msg) throws Exception {
String text = objectMapper.writeValueAsString(msg);
jmsTemplate.send("jms/queue/mail", new MessageCreator() {
public Message createMessage(Session session) throws JMSException {
return session.createTextMessage(text);
}
});
}
}

JMS的消息类型支持以下几种:

  • TextMessage:文本消息;
  • BytesMessage:二进制消息;
  • MapMessage:包含多个Key-Value对的消息;
  • ObjectMessage:直接序列化Java对象的消息;
  • StreamMessage:一个包含基本类型序列的消息。

最常用的是发送基于JSON的文本消息,上述代码通过JmsTemplate创建一个TextMessage并发送到名称为jms/queue/mail的Queue。

注意:Artemis消息服务器默认配置下会自动创建Queue,因此不必手动创建一个名为jms/queue/mail的Queue,但不是所有的消息服务器都会自动创建Queue,生产环境的消息服务器通常会关闭自动创建功能,需要手动创建Queue。

再注意到MailMessage是我们自己定义的一个JavaBean,真正的JMS消息是创建的TextMessage,它的内容是JSON。

当用户注册成功后,我们就调用MessagingService.sendMailMessage()发送一条JMS消息,此代码十分简单,这里不再贴出。

下面我们要详细讨论的是如何处理消息,即编写Consumer。从理论上讲,可以创建另一个Java进程来处理消息,但对于我们这个简单的Web程序来说没有必要,直接在同一个Web应用中接收并处理消息即可。

处理消息的核心代码是编写一个Bean,并在处理方法上标注@JmsListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class MailMessageListener {
final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired ObjectMapper objectMapper;
@Autowired MailService mailService;

@JmsListener(destination = "jms/queue/mail", concurrency = "10")
public void onMailMessageReceived(Message message) throws Exception {
logger.info("received message: " + message);
if (message instanceof TextMessage) {
String text = ((TextMessage) message).getText();
MailMessage mm = objectMapper.readValue(text, MailMessage.class);
mailService.sendRegistrationMail(mm);
} else {
logger.error("unable to process non-text message!");
}
}
}

注意到@JmsListener指定了Queue的名称,因此,凡是发到此Queue的消息都会被这个onMailMessageReceived()方法处理,方法参数是JMS的Message接口,我们通过强制转型为TextMessage并提取JSON,反序列化后获得自定义的JavaBean,也就获得了发送邮件所需的所有信息。

下面问题来了:Spring处理JMS消息的流程是什么?

如果我们直接调用JMS的API来处理消息,那么编写的代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建JMS连接:
Connection connection = connectionFactory.createConnection();
// 创建会话:
Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
// 创建一个Consumer:
MessageConsumer consumer = session.createConsumer(queue);
// 为Consumer指定一个消息处理器:
consumer.setMessageListener(new MessageListener() {
public void onMessage(Message message) {
// 在此处理消息...
}
});
// 启动接收消息的循环:
connection.start();

我们自己编写的MailMessageListener.onMailMessageReceived()相当于消息处理器:

1
2
3
4
5
consumer.setMessageListener(new MessageListener() { 
public void onMessage(Message message) {
mailMessageListener.onMailMessageReceived(message);
}
});

所以,Spring根据AppConfig的注解@EnableJms自动扫描带有@JmsListener的Bean方法,并为其创建一个MessageListener把它包装起来。

注意到前面我们还创建了一个JmsListenerContainerFactory的Bean,它的作用就是为每个MessageListener创建MessageConsumer并启动消息接收循环。

再注意到@JmsListener还有一个concurrency参数,10表示可以最多同时并发处理10个消息,5-10表示并发处理的线程可以在5~10之间调整。

因此,Spring在通过MessageListener接收到消息后,并不是直接调用mailMessageListener.onMailMessageReceived(),而是用线程池调用,因此,要时刻牢记,onMailMessageReceived()方法可能被多线程并发执行,一定要保证线程安全。

我们总结一下Spring接收消息的步骤:

通过JmsListenerContainerFactory配合@EnableJms扫描所有@JmsListener方法,自动创建MessageConsumerMessageListener以及线程池,启动消息循环接收处理消息,最终由我们自己编写的@JmsListener方法处理消息,可能会由多线程同时并发处理。

要验证消息发送和处理,我们注册一个新用户,可以看到如下日志输出:

1
2
3
4
2020-06-02 08:04:27 INFO  c.i.learnjava.web.UserController - user registered: bob@example.com
2020-06-02 08:04:27 INFO c.i.l.service.MailMessageListener - received message: ActiveMQMessage[ID:9fc5...]:PERSISTENT/ClientMessageImpl[messageID=983, durable=true, address=jms/queue/mail, ...]]
2020-06-02 08:04:27 INFO c.i.learnjava.service.MailService - [send mail] sending registration mail to bob@example.com...
2020-06-02 08:04:30 INFO c.i.learnjava.service.MailService - [send mail] registration mail was sent to bob@example.com.

可见,消息被成功发送到Artemis,然后在很短的时间内被接收处理了。

使用消息服务对发送Email进行改造的好处是,发送Email的能力通常是有限的,通过JMS消息服务,如果短时间内需要给大量用户发送Email,可以先把消息堆积在JMS服务器上慢慢发送,对于批量发送邮件、短信等尤其有用。

练习

使用JMS。

下载练习

小结

JMS是Java消息服务,可以通过JMS服务器实现消息的异步处理。

消息服务主要解决Producer和Consumer生产和处理速度不匹配的问题。

在很多应用程序中,经常需要执行定时任务。例如,每天或每月给用户发送账户汇总报表,定期检查并发送系统状态报告,等等。

定时任务我们在使用线程池一节中已经讲到了,Java标准库本身就提供了定时执行任务的功能。在Spring中,使用定时任务更简单,不需要手写线程池相关代码,只需要两个注解即可。

我们还是以实际代码为例,建立工程spring-integration-schedule,无需额外的依赖,我们可以直接在AppConfig中加上@EnableScheduling就开启了定时任务的支持:

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
@EnableWebMvc
@EnableScheduling
@EnableTransactionManagement
@PropertySource({ "classpath:/jdbc.properties", "classpath:/task.properties" })
public class AppConfig {
...
}

接下来,我们可以直接在一个Bean中编写一个public void无参数方法,然后加上@Scheduled注解:

1
2
3
4
5
6
7
8
9
@Component
public class TaskService {
final Logger logger = LoggerFactory.getLogger(getClass());

@Scheduled(initialDelay = 60_000, fixedRate = 60_000)
public void checkSystemStatusEveryMinute() {
logger.info("Start check system status...");
}
}

上述注解指定了启动延迟60秒,并以60秒的间隔执行任务。现在,我们直接运行应用程序,就可以在控制台看到定时任务打印的日志:

1
2
3
2020-06-03 18:47:32 INFO  [pool-1-thread-1] c.i.learnjava.service.TaskService - Start check system status...
2020-06-03 18:48:32 INFO [pool-1-thread-1] c.i.learnjava.service.TaskService - Start check system status...
2020-06-03 18:49:32 INFO [pool-1-thread-1] c.i.learnjava.service.TaskService - Start check system status...

如果没有看到定时任务的日志,需要检查:

  • 是否忘记了在AppConfig中标注@EnableScheduling
  • 是否忘记了在定时任务的方法所在的class标注@Component

除了可以使用fixedRate外,还可以使用fixedDelay,两者的区别我们已经在使用线程池一节中讲过,这里不再重复。

有的童鞋在实际开发中会遇到一个问题,因为Java的注解全部是常量,写死了fixedDelay=30000,如果根据实际情况要改成60秒怎么办,只能重新编译?

我们可以把定时任务的配置放到配置文件中,例如task.properties

1
task.checkDiskSpace=30000

这样就可以随时修改配置文件而无需动代码。但是在代码中,我们需要用fixedDelayString取代fixedDelay

1
2
3
4
5
6
7
8
9
@Component
public class TaskService {
...

@Scheduled(initialDelay = 30_000, fixedDelayString = "${task.checkDiskSpace:30000}")
public void checkDiskSpaceEveryMinute() {
logger.info("Start check disk space...");
}
}

注意到上述代码的注解参数fixedDelayString是一个属性占位符,并配有默认值30000,Spring在处理@Scheduled注解时,如果遇到String,会根据占位符自动用配置项替换,这样就可以灵活地修改定时任务的配置。

此外,fixedDelayString还可以使用更易读的Duration,例如:

1
@Scheduled(initialDelay = 30_000, fixedDelayString = "${task.checkDiskSpace:PT2M30S}")

以字符串PT2M30S表示的Duration就是2分30秒,请参考LocalDateTime一节的Duration相关部分。

多个@Scheduled方法完全可以放到一个Bean中,这样便于统一管理各类定时任务。

使用Cron任务

还有一类定时任务,它不是简单的重复执行,而是按时间触发,我们把这类任务称为Cron任务,例如:

  • 每天凌晨2:15执行报表任务;
  • 每个工作日12:00执行特定任务;
  • ……

Cron源自Unix/Linux系统自带的crond守护进程,以一个简洁的表达式定义任务触发时间。在Spring中,也可以使用Cron表达式来执行Cron任务,在Spring中,它的格式是:

1
秒 分 小时 天 月份 星期 年

年是可以忽略的,通常不写。每天凌晨2:15执行的Cron表达式就是:

1
0 15 2 * * *

每个工作日12:00执行的Cron表达式就是:

1
0 0 12 * * MON-FRI

每个月1号,2号,3号和10号12:00执行的Cron表达式就是:

1
0 0 12 1-3,10 * *

在Spring中,我们定义一个每天凌晨2:15执行的任务:

1
2
3
4
5
6
7
8
9
@Component
public class TaskService {
...

@Scheduled(cron = "${task.report:0 15 2 * * *}")
public void cronDailyReport() {
logger.info("Start daily report task...");
}
}

Cron任务同样可以使用属性占位符,这样修改起来更加方便。

Cron表达式还可以表达每10分钟执行,例如:

1
0 */10 * * * *

这样,在每个小时的0:00,10:00,20:00,30:00,40:00,50:00均会执行任务,实际上它可以取代fixedRate类型的定时任务。

集成Quartz

在Spring中使用定时任务和Cron任务都十分简单,但是要注意到,这些任务的调度都是在每个JVM进程中的。如果在本机启动两个进程,或者在多台机器上启动应用,这些进程的定时任务和Cron任务都是独立运行的,互不影响。

如果一些定时任务要以集群的方式运行,例如每天23:00执行检查任务,只需要集群中的一台运行即可,这个时候,可以考虑使用Quartz

Quartz可以配置一个JDBC数据源,以便存储所有的任务调度计划以及任务执行状态。也可以使用内存来调度任务,但这样配置就和使用Spring的调度没啥区别了,额外集成Quartz的意义就不大。

Quartz的JDBC配置比较复杂,Spring对其也有一定的支持。要详细了解Quartz的集成,请参考Spring的文档

思考:如果不使用Quartz的JDBC配置,多个Spring应用同时运行时,如何保证某个任务只在某一台机器执行?

练习

使用Scheduler执行定时任务。

下载练习

小结

Spring内置定时任务和Cron任务的支持,编写调度任务十分方便。

在Spring中,可以方便地集成JMX。

那么第一个问题来了:什么是JMX?

JMX是Java Management Extensions,它是一个Java平台的管理和监控接口。为什么要搞JMX呢?因为在所有的应用程序中,对运行中的程序进行监控都是非常重要的,Java应用程序也不例外。我们肯定希望知道Java应用程序当前的状态,例如,占用了多少内存,分配了多少内存,当前有多少活动线程,有多少休眠线程等等。如何获取这些信息呢?

为了标准化管理和监控,Java平台使用JMX作为管理和监控的标准接口,任何程序,只要按JMX规范访问这个接口,就可以获取所有管理与监控信息。

实际上,常用的运维监控如Zabbix、Nagios等工具对JVM本身的监控都是通过JMX获取的信息。

因为JMX是一个标准接口,不但可以用于管理JVM,还可以管理应用程序自身。下图是JMX的架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    ┌─────────┐  ┌─────────┐
│jconsole │ │ Web │
└─────────┘ └─────────┘
│ │
┌ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─
JVM ▼ ▼ │
│ ┌─────────┐ ┌─────────┐
┌─┤Connector├──┤ Adaptor ├─┐ │
│ │ └─────────┘ └─────────┘ │
│ MBeanServer │ │
│ │ ┌──────┐┌──────┐┌──────┐ │
└─┤MBean1├┤MBean2├┤MBean3├─┘ │
│ └──────┘└──────┘└──────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

JMX把所有被管理的资源都称为MBean(Managed Bean),这些MBean全部由MBeanServer管理,如果要访问MBean,可以通过MBeanServer对外提供的访问接口,例如通过RMI或HTTP访问。

注意到使用JMX不需要安装任何额外组件,也不需要第三方库,因为MBeanServer已经内置在JavaSE标准库中了。JavaSE还提供了一个jconsole程序,用于通过RMI连接到MBeanServer,这样就可以管理整个Java进程。

除了JVM会把自身的各种资源以MBean注册到JMX中,我们自己的配置、监控信息也可以作为MBean注册到JMX,这样,管理程序就可以直接控制我们暴露的MBean。因此,应用程序使用JMX,只需要两步:

  1. 编写MBean提供管理接口和监控数据;
  2. 注册MBean。

在Spring应用程序中,使用JMX只需要一步:

  1. 编写MBean提供管理接口和监控数据。

第二步注册的过程由Spring自动完成。我们以实际工程为例,首先在AppConfig中加上@EnableMBeanExport注解,告诉Spring自动注册MBean:

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
@EnableWebMvc
@EnableMBeanExport // 自动注册MBean
@EnableTransactionManagement
@PropertySource({ "classpath:/jdbc.properties" })
public class AppConfig {
...
}

剩下的全部工作就是编写MBean。我们以实际问题为例,假设我们希望给应用程序添加一个IP黑名单功能,凡是在黑名单中的IP禁止访问,传统的做法是定义一个配置文件,启动的时候读取:

1
2
3
4
5
# blacklist.txt
1.2.3.4
5.6.7.8
2.2.3.4
...

如果要修改黑名单怎么办?修改配置文件,然后重启应用程序。

但是每次都重启应用程序实在是太麻烦了,能不能不重启应用程序?可以自己写一个定时读取配置文件的功能,检测到文件改动时自动重新读取。

上述需求本质上是在应用程序运行期间对参数、配置等进行热更新并要求尽快生效。如果以JMX的方式实现,我们不必自己编写自动重新读取等任何代码,只需要提供一个符合JMX标准的MBean来存储配置即可。

还是以IP黑名单为例,JMX的MBean通常以MBean结尾,因此我们遵循标准命名规范,首先编写一个BlacklistMBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BlacklistMBean {
private Set<String> ips = new HashSet<>();

public String[] getBlacklist() {
return ips.toArray(String[]::new);
}

public void addBlacklist(String ip) {
ips.add(ip);
}

public void removeBlacklist(String ip) {
ips.remove(ip);
}

public boolean shouldBlock(String ip) {
return ips.contains(ip);
}
}

这个MBean没什么特殊的,它的逻辑和普通Java类没有任何区别。

下一步,我们要使用JMX的客户端来实时热更新这个MBean,所以要给它加上一些注解,让Spring能根据注解自动把相关方法注册到MBeanServer中:

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
@Component
@ManagedResource(objectName = "sample:name=blacklist", description = "Blacklist of IP addresses")
public class BlacklistMBean {
private Set<String> ips = new HashSet<>();

@ManagedAttribute(description = "Get IP addresses in blacklist")
public String[] getBlacklist() {
return ips.toArray(String[]::new);
}

@ManagedOperation
@ManagedOperationParameter(name = "ip", description = "Target IP address that will be added to blacklist")
public void addBlacklist(String ip) {
ips.add(ip);
}

@ManagedOperation
@ManagedOperationParameter(name = "ip", description = "Target IP address that will be removed from blacklist")
public void removeBlacklist(String ip) {
ips.remove(ip);
}

public boolean shouldBlock(String ip) {
return ips.contains(ip);
}
}

观察上述代码,BlacklistMBean首先是一个标准的Spring管理的Bean,其次,添加了@ManagedResource表示这是一个MBean,将要被注册到JMX。objectName指定了这个MBean的名字,通常以company:name=Xxx来分类MBean。

对于属性,使用@ManagedAttribute注解标注。上述MBean只有get属性,没有set属性,说明这是一个只读属性。

对于操作,使用@ManagedOperation注解标准。上述MBean定义了两个操作:addBlacklist()removeBlacklist(),其他方法如shouldBlock()不会被暴露给JMX。

使用MBean和普通Bean是完全一样的。例如,我们在BlacklistInterceptor对IP进行黑名单拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Order(1)
@Component
public class BlacklistInterceptor implements HandlerInterceptor {
final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
BlacklistMBean blacklistMBean;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String ip = request.getRemoteAddr();
logger.info("check ip address {}...", ip);
// 是否在黑名单中:
if (blacklistMBean.shouldBlock(ip)) {
logger.warn("will block ip {} for it is in blacklist.", ip);
// 发送403错误响应:
response.sendError(403);
return false;
}
return true;
}
}

下一步就是正常启动Web应用程序,不要关闭它,我们打开另一个命令行窗口,输入jconsole启动JavaSE自带的一个JMX客户端程序:

jconsole

通过jconsole连接到一个Java进程最简单的方法是直接在Local Process中找到正在运行的AppConfig,点击Connect即可连接到我们当前正在运行的Web应用,在jconsole中可直接看到内存、CPU等资源的监控。

我们点击MBean,左侧按分类列出所有MBean,可以在java.lang查看内存等信息:

mbean

细心的童鞋可以看到HikariCP连接池也是通过JMX监控的。

sample中可以看到我们自己的MBean,点击可查看属性blacklist

mbean-value

点击Operations-addBlacklist,可以填入127.0.0.1并点击addBlacklist按钮,相当于jconsole通过JMX接口,调用了我们自己的BlacklistMBeanaddBlacklist()方法,传入的参数就是填入的127.0.0.1

mbean-invoke-ok

再次查看属性blacklist,可以看到结果已经更新了:

mbean-modified

我们可以在浏览器中测试一下黑名单功能是否已生效:

403

可见,127.0.0.1确实被添加到了黑名单,后台日志打印如下:

1
2
2020-06-06 20:22:12 INFO  c.i.l.web.BlacklistInterceptor - check ip address 127.0.0.1...
2020-06-06 20:22:12 WARN c.i.l.web.BlacklistInterceptor - will block ip 127.0.0.1 for it is in blacklist.

注意:如果使用IPv6,那么需要把0:0:0:0:0:0:0:1这个本机地址加到黑名单。

如果从jconsole中调用removeBlacklist移除127.0.0.1,刷新浏览器可以看到又允许访问了。

使用jconsole直接通过Local Process连接JVM有个限制,就是jconsole和正在运行的JVM必须在同一台机器。如果要远程连接,首先要打开JMX端口。我们在启动AppConfig时,需要传入以下JVM启动参数:

  • -Dcom.sun.management.jmxremote.port=19999
  • -Dcom.sun.management.jmxremote.authenticate=false
  • -Dcom.sun.management.jmxremote.ssl=false

第一个参数表示在19999端口监听JMX连接,第二个和第三个参数表示无需验证,不使用SSL连接,在开发测试阶段比较方便,生产环境必须指定验证方式并启用SSL。详细参数可参考Oracle官方文档。这样jconsole可以用ip:19999的远程方式连接JMX。连接后的操作是完全一样的。

许多JavaEE服务器如JBoss的管理后台都是通过JMX提供管理接口,并由Web方式访问,对用户更加友好。

在实际项目中,通过JMX实现配置的实时更新其实并不常用,JMX更多地用于收集JVM的运行状态和应用程序的性能数据,然后通过监控服务器汇总数据后实现监控与报警。一个典型的监控系统架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌───────────────┐   ┌───────────────┐
│ Web Console │◀──│Metrics Server │
└───────────────┘ └───────────────┘


┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ┐
┌───────────────┐ │
│ │ App │ │ │
├─────────┬─────┤ ┌─────┐
│ │ │ JMX │──▶│Agent│ │
│ └─────┤ └─────┘
│ │ JVM │ │
└───────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

其中,App自身和JVM的的统计数据都通过JMX收集并发送给本机的一个Agent,Agent再将数据发送至监控服务器,最后以可视化的形式将监控数据通过Web等形式展示给用户。常用的监控系统有开源的Prometheus和以云服务方式提供的DataDog等。

练习

编写一个MBean统计当前注册用户数量,并在jconsole中查看:

下载练习

小结

在Spring中使用JMX需要:

  • 通过@EnableMBeanExport启用自动注册MBean;
  • 编写MBean并实现管理属性和管理操作。

留言與分享

Web开发一章中,我们已经详细介绍了JavaEE中Web开发的基础:Servlet。具体地说,有以下几点:

  1. Servlet规范定义了几种标准组件:Servlet、JSP、Filter和Listener;
  2. Servlet的标准组件总是运行在Servlet容器中,如Tomcat、Jetty、WebLogic等。

直接使用Servlet进行Web开发好比直接在JDBC上操作数据库,比较繁琐,更好的方法是在Servlet基础上封装MVC框架,基于MVC开发Web应用,大部分时候,不需要接触Servlet API,开发省时省力。

我们在MVC开发MVC高级开发已经由浅入深地介绍了如何编写MVC框架。当然,自己写的MVC主要是理解原理,要实现一个功能全面的MVC需要大量的工作以及广泛的测试。

因此,开发Web应用,首先要选择一个优秀的MVC框架。常用的MVC框架有:

  • Struts:最古老的一个MVC框架,目前版本是2,和1.x有很大的区别;
  • WebWork:一个比Struts设计更优秀的MVC框架,但不知道出于什么原因,从2.0开始把自己的代码全部塞给Struts 2了;
  • Turbine:一个重度使用Velocity,强调布局的MVC框架;
  • 其他100+MVC框架……(略)

Spring虽然都可以集成任何Web框架,但是,Spring本身也开发了一个MVC框架,就叫Spring MVC。这个MVC框架设计得足够优秀以至于我们已经不想再费劲去集成类似Struts这样的框架了。

本章我们会详细介绍如何基于Spring MVC开发Web应用。

我们已经介绍了Java Web的基础:Servlet容器,以及标准的Servlet组件:

  • Servlet:能处理HTTP请求并将HTTP响应返回;
  • JSP:一种嵌套Java代码的HTML,将被编译为Servlet;
  • Filter:能过滤指定的URL以实现拦截功能;
  • Listener:监听指定的事件,如ServletContext、HttpSession的创建和销毁。

此外,Servlet容器为每个Web应用程序自动创建一个唯一的ServletContext实例,这个实例就代表了Web应用程序本身。

MVC高级开发中,我们手撸了一个MVC框架,接口和Spring MVC类似。如果直接使用Spring MVC,我们写出来的代码类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller
public class UserController {
@GetMapping("/register")
public ModelAndView register() {
...
}

@PostMapping("/signin")
public ModelAndView signin(@RequestParam("email") String email, @RequestParam("password") String password) {
...
}
...
}

但是,Spring提供的是一个IoC容器,所有的Bean,包括Controller,都在Spring IoC容器中被初始化,而Servlet容器由JavaEE服务器提供(如Tomcat),Servlet容器对Spring一无所知,他们之间到底依靠什么进行联系,又是以何种顺序初始化的?

在理解上述问题之前,我们先把基于Spring MVC开发的项目结构搭建起来。首先创建基于Web的Maven工程,引入如下依赖:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-webmvc:6.0.0
  • org.springframework:spring-jdbc:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • io.pebbletemplates:pebble-spring6:3.2.0
  • ch.qos.logback:logback-core:1.4.4
  • ch.qos.logback:logback-classic:1.4.4
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.0

以及provided依赖:

  • org.apache.tomcat.embed:tomcat-embed-core:10.1.1
  • org.apache.tomcat.embed:tomcat-embed-jasper:10.1.1

这个标准的Maven Web工程目录结构如下:

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
spring-web-mvc
├── pom.xml
└── src
└── main
├── java
│   └── com
│   └── itranswarp
│   └── learnjava
│   ├── AppConfig.java
│   ├── DatabaseInitializer.java
│   ├── entity
│   │   └── User.java
│   ├── service
│   │   └── UserService.java
│   └── web
│   └── UserController.java
├── resources
│   ├── jdbc.properties
│   └── logback.xml
└── webapp
├── WEB-INF
│   ├── templates
│   │   ├── _base.html
│   │   ├── index.html
│   │   ├── profile.html
│   │   ├── register.html
│   │   └── signin.html
│   └── web.xml
└── static
├── css
│   └── bootstrap.css
└── js
└── jquery.js

其中,src/main/webapp是标准web目录,WEB-INF存放web.xml,编译的class,第三方jar,以及不允许浏览器直接访问的View模版,static目录存放所有静态文件。

src/main/resources目录中存放的是Java程序读取的classpath资源文件,除了JDBC的配置文件jdbc.properties外,我们又新增了一个logback.xml,这是Logback的默认查找的配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT"
class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</Pattern>
</layout>
</appender>

<logger name="com.itranswarp.learnjava" level="info" additivity="false">
<appender-ref ref="STDOUT" />
</logger>

<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>

上面给出了一个写入到标准输出的Logback配置,可以基于上述配置添加写入到文件的配置。

src/main/java中就是我们编写的Java代码了。

配置Spring MVC

和普通Spring配置一样,我们编写正常的AppConfig后,只需加上@EnableWebMvc注解,就“激活”了Spring MVC:

1
2
3
4
5
6
7
8
@Configuration
@ComponentScan
@EnableWebMvc // 启用Spring MVC
@EnableTransactionManagement
@PropertySource("classpath:/jdbc.properties")
public class AppConfig {
...
}

除了创建DataSourceJdbcTemplatePlatformTransactionManager外,AppConfig需要额外创建几个用于Spring MVC的Bean:

1
2
3
4
5
6
7
8
9
@Bean
WebMvcConfigurer createWebMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("/static/");
}
};
}

WebMvcConfigurer并不是必须的,但我们在这里创建一个默认的WebMvcConfigurer,只覆写addResourceHandlers(),目的是让Spring MVC自动处理静态文件,并且映射路径为/static/**

另一个必须要创建的Bean是ViewResolver,因为Spring MVC允许集成任何模板引擎,使用哪个模板引擎,就实例化一个对应的ViewResolver

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
ViewResolver createViewResolver(@Autowired ServletContext servletContext) {
var engine = new PebbleEngine.Builder().autoEscaping(true)
// cache:
.cacheActive(false)
// loader:
.loader(new Servlet5Loader(servletContext))
.build();
var viewResolver = new PebbleViewResolver(engine);
viewResolver.setPrefix("/WEB-INF/templates/");
viewResolver.setSuffix("");
return viewResolver;
}

ViewResolver通过指定prefixsuffix来确定如何查找View。上述配置使用Pebble引擎,指定模板文件存放在/WEB-INF/templates/目录下。

剩下的Bean都是普通的@Component,但Controller必须标记为@Controller,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Controller使用@Controller标记而不是@Component:
@Controller
public class UserController {
// 正常使用@Autowired注入:
@Autowired
UserService userService;

// 处理一个URL映射:
@GetMapping("/")
public ModelAndView index() {
...
}
...
}

如果是普通的Java应用程序,我们通过main()方法可以很简单地创建一个Spring容器的实例:

1
2
3
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(AppConfig.class);
}

但是问题来了,现在是Web应用程序,而Web应用程序总是由Servlet容器创建,那么,Spring容器应该由谁创建?在什么时候创建?Spring容器中的Controller又是如何通过Servlet调用的?

在Web应用中启动Spring容器有很多种方法,可以通过Listener启动,也可以通过Servlet启动,可以使用XML配置,也可以使用注解配置。这里,我们只介绍一种最简单的启动Spring容器的方式。

第一步,我们在web.xml中配置Spring MVC提供的DispatcherServlet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0"?>
<web-app>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.itranswarp.learnjava.AppConfig</param-value>
</init-param>
<load-on-startup>0</load-on-startup>
</servlet>

<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>

初始化参数contextClass指定使用注解配置的AnnotationConfigWebApplicationContext,配置文件的位置参数contextConfigLocation指向AppConfig的完整类名,最后,把这个Servlet映射到/*,即处理所有URL。

上述配置可以看作一个样板配置,有了这个配置,Servlet容器会首先初始化Spring MVC的DispatcherServlet,在DispatcherServlet启动时,它根据配置AppConfig创建了一个类型是WebApplicationContext的IoC容器,完成所有Bean的初始化,并将容器绑到ServletContext上。

因为DispatcherServlet持有IoC容器,能从IoC容器中获取所有@Controller的Bean,因此,DispatcherServlet接收到所有HTTP请求后,根据Controller方法配置的路径,就可以正确地把请求转发到指定方法,并根据返回的ModelAndView决定如何渲染页面。

最后,我们在AppConfig中通过main()方法启动嵌入式Tomcat:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) throws Exception {
Tomcat tomcat = new Tomcat();
tomcat.setPort(Integer.getInteger("port", 8080));
tomcat.getConnector();
Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
WebResourceRoot resources = new StandardRoot(ctx);
resources.addPreResources(
new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
ctx.setResources(resources);
tomcat.start();
tomcat.getServer().await();
}

上述Web应用程序就是我们使用Spring MVC时的一个最小启动功能集。由于使用了JDBC和数据库,用户的注册、登录信息会被持久化:

spring-mvc

编写Controller

有了Web应用程序的最基本的结构,我们的重点就可以放在如何编写Controller上。Spring MVC对Controller没有固定的要求,也不需要实现特定的接口。以UserController为例,编写Controller只需要遵循以下要点:

总是标记@Controller而不是@Component

1
2
3
4
@Controller
public class UserController {
...
}

一个方法对应一个HTTP请求路径,用@GetMapping@PostMapping表示GET或POST请求:

1
2
3
4
5
6
7
@PostMapping("/signin")
public ModelAndView doSignin(
@RequestParam("email") String email,
@RequestParam("password") String password,
HttpSession session) {
...
}

需要接收的HTTP参数以@RequestParam()标注,可以设置默认值。如果方法参数需要传入HttpServletRequestHttpServletResponse或者HttpSession,直接添加这个类型的参数即可,Spring MVC会自动按类型传入。

返回的ModelAndView通常包含View的路径和一个Map作为Model,但也可以没有Model,例如:

1
return new ModelAndView("signin.html"); // 仅View,没有Model

返回重定向时既可以写new ModelAndView("redirect:/signin"),也可以直接返回String:

1
2
3
4
5
6
7
public String index() {
if (...) {
return "redirect:/signin";
} else {
return "redirect:/profile";
}
}

如果在方法内部直接操作HttpServletResponse发送响应,返回null表示无需进一步处理:

1
2
3
4
5
6
7
8
public ModelAndView download(HttpServletResponse response) {
byte[] data = ...
response.setContentType("application/octet-stream");
OutputStream output = response.getOutputStream();
output.write(data);
output.flush();
return null;
}

对URL进行分组,每组对应一个Controller是一种很好的组织形式,并可以在Controller的class定义出添加URL前缀,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@RequestMapping("/user")
public class UserController {
// 注意实际URL映射是/user/profile
@GetMapping("/profile")
public ModelAndView profile() {
...
}

// 注意实际URL映射是/user/changePassword
@GetMapping("/changePassword")
public ModelAndView changePassword() {
...
}
}

实际方法的URL映射总是前缀+路径,这种形式还可以有效避免不小心导致的重复的URL映射。

可见,Spring MVC允许我们编写既简单又灵活的Controller实现。

练习

使用Spring MVC,在注册、登录等功能的基础上增加一个修改口令的页面。

下载练习

小结

使用Spring MVC时,整个Web应用程序按如下顺序启动:

  1. 启动Tomcat服务器;
  2. Tomcat读取web.xml并初始化DispatcherServlet
  3. DispatcherServlet创建IoC容器并自动注册到ServletContext中。

启动后,浏览器发出的HTTP请求全部由DispatcherServlet接收,并根据配置转发到指定Controller的指定方法处理。

使用Spring MVC开发Web应用程序的主要工作就是编写Controller逻辑。在Web应用中,除了需要使用MVC给用户显示页面外,还有一类API接口,我们称之为REST,通常输入输出都是JSON,便于第三方调用或者使用页面JavaScript与之交互。

直接在Controller中处理JSON是可以的,因为Spring MVC的@GetMapping@PostMapping都支持指定输入和输出的格式。如果我们想接收JSON,输出JSON,那么可以这样写:

1
2
3
4
5
6
7
@PostMapping(value = "/rest",
consumes = "application/json;charset=UTF-8",
produces = "application/json;charset=UTF-8")
@ResponseBody
public String rest(@RequestBody User user) {
return "{\"restSupport\":true}";
}

对应的Maven工程需要加入Jackson这个依赖:com.fasterxml.jackson.core:jackson-databind:2.14.0

注意到@PostMapping使用consumes声明能接收的类型,使用produces声明输出的类型,并且额外加了@ResponseBody表示返回的String无需额外处理,直接作为输出内容写入HttpServletResponse。输入的JSON则根据注解@RequestBody直接被Spring反序列化为User这个JavaBean。

使用curl命令测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ curl -v -H "Content-Type: application/json" -d '{"email":"bob@example.com"}' http://localhost:8080/rest      
> POST /rest HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 27
>
< HTTP/1.1 200
< Content-Type: application/json;charset=utf-8
< Content-Length: 20
< Date: Sun, 10 May 2020 09:56:01 GMT
<
{"restSupport":true}

输出正是我们写入的字符串。

直接用Spring的Controller配合一大堆注解写REST太麻烦了,因此,Spring还额外提供了一个@RestController注解,使用@RestController替代@Controller后,每个方法自动变成API接口方法。我们还是以实际代码举例,编写ApiController如下:

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
@RestController
@RequestMapping("/api")
public class ApiController {
@Autowired
UserService userService;

@GetMapping("/users")
public List<User> users() {
return userService.getUsers();
}

@GetMapping("/users/{id}")
public User user(@PathVariable("id") long id) {
return userService.getUserById(id);
}

@PostMapping("/signin")
public Map<String, Object> signin(@RequestBody SignInRequest signinRequest) {
try {
User user = userService.signin(signinRequest.email, signinRequest.password);
return Map.of("user", user);
} catch (Exception e) {
return Map.of("error", "SIGNIN_FAILED", "message", e.getMessage());
}
}

public static class SignInRequest {
public String email;
public String password;
}
}

编写REST接口只需要定义@RestController,然后,每个方法都是一个API接口,输入和输出只要能被Jackson序列化或反序列化为JSON就没有问题。我们用浏览器测试GET请求,可直接显示JSON响应:

user-api

要测试POST请求,可以用curl命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ curl -v -H "Content-Type: application/json" -d '{"email":"bob@example.com","password":"bob123"}' http://localhost:8080/api/signin
> POST /api/signin HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 47
>
< HTTP/1.1 200
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Sun, 10 May 2020 08:14:13 GMT
<
{"user":{"id":1,"email":"bob@example.com","password":"bob123","name":"Bob",...

注意观察上述JSON的输出,User能被正确地序列化为JSON,但暴露了password属性,这是我们不期望的。要避免输出password属性,可以把User复制到另一个UserBean对象,该对象只持有必要的属性,但这样做比较繁琐。另一种简单的方法是直接在Userpassword属性定义处加上@JsonIgnore表示完全忽略该属性:

1
2
3
4
5
6
7
8
9
10
public class User {
...

@JsonIgnore
public String getPassword() {
return password;
}

...
}

但是这样一来,如果写一个register(User user)方法,那么该方法的User对象也拿不到注册时用户传入的密码了。如果要允许输入password,但不允许输出password,即在JSON序列化和反序列化时,允许写属性,禁用读属性,可以更精细地控制如下:

1
2
3
4
5
6
7
8
9
10
public class User {
...

@JsonProperty(access = Access.WRITE_ONLY)
public String getPassword() {
return password;
}

...
}

同样的,可以使用@JsonProperty(access = Access.READ_ONLY)允许输出,不允许输入。

练习

使用REST实现API。

下载练习

小结

使用@RestController可以方便地编写REST服务,Spring默认使用JSON作为输入和输出。

要控制序列化和反序列化,可以使用Jackson提供的@JsonIgnore@JsonProperty注解。

在Spring MVC中,DispatcherServlet只需要固定配置到web.xml中,剩下的工作主要是专注于编写Controller。

但是,在Servlet规范中,我们还可以使用Filter。如果要在Spring MVC中使用Filter,应该怎么做?

有的童鞋在上一节的Web应用中可能发现了,如果注册时输入中文会导致乱码,因为Servlet默认按非UTF-8编码读取参数。为了修复这一问题,我们可以简单地使用一个EncodingFilter,在全局范围类给HttpServletRequestHttpServletResponse强制设置为UTF-8编码。

可以自己编写一个EncodingFilter,也可以直接使用Spring MVC自带的一个CharacterEncodingFilter。配置Filter时,只需在web.xml中声明即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<web-app>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>

<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
...
</web-app>

因为这种Filter和我们业务关系不大,注意到CharacterEncodingFilter其实和Spring的IoC容器没有任何关系,两者均互不知晓对方的存在,因此,配置这种Filter十分简单。

我们再考虑这样一个问题:如果允许用户使用Basic模式进行用户验证,即在HTTP请求中添加头Authorization: Basic email:password,这个需求如何实现?

编写一个AuthFilter是最简单的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class AuthFilter implements Filter {
@Autowired
UserService userService;

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
// 获取Authorization头:
String authHeader = req.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Basic ")) {
// 从Header中提取email和password:
String email = prefixFrom(authHeader);
String password = suffixFrom(authHeader);
// 登录:
User user = userService.signin(email, password);
// 放入Session:
req.getSession().setAttribute(UserController.KEY_USER, user);
}
// 继续处理请求:
chain.doFilter(request, response);
}
}

现在问题来了:在Spring中创建的这个AuthFilter是一个普通Bean,Servlet容器并不知道,所以它不会起作用。

如果我们直接在web.xml中声明这个AuthFilter,注意到AuthFilter的实例将由Servlet容器而不是Spring容器初始化,因此,@Autowire根本不生效,用于登录的UserService成员变量永远是null

所以,得通过一种方式,让Servlet容器实例化的Filter,间接引用Spring容器实例化的AuthFilter。Spring MVC提供了一个DelegatingFilterProxy,专门来干这个事情:

1
2
3
4
5
6
7
8
9
10
11
12
<web-app>
<filter>
<filter-name>authFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>

<filter-mapping>
<filter-name>authFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
...
</web-app>

我们来看实现原理:

  1. Servlet容器从web.xml中读取配置,实例化DelegatingFilterProxy,注意命名是authFilter
  2. Spring容器通过扫描@Component实例化AuthFilter

DelegatingFilterProxy生效后,它会自动查找注册在ServletContext上的Spring容器,再试图从容器中查找名为authFilter的Bean,也就是我们用@Component声明的AuthFilter

DelegatingFilterProxy将请求代理给AuthFilter,核心代码如下:

1
2
3
4
5
6
7
8
9
public class DelegatingFilterProxy implements Filter {
private Filter delegate;
public void doFilter(...) throws ... {
if (delegate == null) {
delegate = findBeanFromSpringContainer();
}
delegate.doFilter(req, resp, chain);
}
}

这就是一个代理模式的简单应用。我们画个图表示它们之间的引用关系如下:

1
2
3
4
5
6
7
8
9
10
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────────┐ ┌───────────┐ │
│ │DelegatingFilterProxy│─│─│─ ─▶│AuthFilter │
└─────────────────────┘ └───────────┘ │
│ ┌─────────────────────┐ │ │ ┌───────────┐
│ DispatcherServlet │─ ─ ─ ─▶│Controllers│ │
│ └─────────────────────┘ │ │ └───────────┘

│ Servlet Container │ │ Spring Container
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

如果在web.xml中配置的Filter名字和Spring容器的Bean的名字不一致,那么需要指定Bean的名字:

1
2
3
4
5
6
7
8
9
<filter>
<filter-name>basicAuthFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<!-- 指定Bean的名字 -->
<init-param>
<param-name>targetBeanName</param-name>
<param-value>authFilter</param-value>
</init-param>
</filter>

实际应用时,尽量保持名字一致,以减少不必要的配置。

要使用Basic模式的用户认证,我们可以使用curl命令测试。例如,用户登录名是tom@example.com,口令是tomcat,那么先构造一个使用URL编码的用户名:口令的字符串:

1
tom%40example.com:tomcat

对其进行Base64编码,最终构造出的Header如下:

1
Authorization: Basic dG9tJTQwZXhhbXBsZS5jb206dG9tY2F0

使用如下的curl命令并获得响应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ curl -v -H 'Authorization: Basic dG9tJTQwZXhhbXBsZS5jb206dG9tY2F0' http://localhost:8080/profile
> GET /profile HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Authorization: Basic dG9tJTQwZXhhbXBsZS5jb206dG9tY2F0
>
< HTTP/1.1 200
< Set-Cookie: JSESSIONID=CE0F4BFC394816F717443397D4FEABBE; Path=/; HttpOnly
< Content-Type: text/html;charset=UTF-8
< Content-Language: en-CN
< Transfer-Encoding: chunked
< Date: Wed, 29 Apr 2020 00:15:50 GMT
<
<!doctype html>
...HTML输出...

上述响应说明AuthFilter已生效。

注意

Basic认证模式并不安全,本节只用来作为使用Filter的示例。

练习

使用DelegatingFilterProxy实现AuthFilter。

下载练习

小结

当一个Filter作为Spring容器管理的Bean存在时,可以通过DelegatingFilterProxy间接地引用它并使其生效。

在Web程序中,注意到使用Filter的时候,Filter由Servlet容器管理,它在Spring MVC的Web应用程序中作用范围如下:

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
         │   ▲
▼ │
┌───────┐
│Filter1│
└───────┘
│ ▲
▼ │
┌───────┐
┌ ─ ─ ─│Filter2│─ ─ ─ ─ ─ ─ ─ ─ ┐
└───────┘
│ │ ▲ │
▼ │
│ ┌─────────────────┐ │
│DispatcherServlet│◀───┐
│ └─────────────────┘ │ │
│ ┌────────────┐
│ │ │ModelAndView││
│ └────────────┘
│ │ ▲ │
│ ┌───────────┐ │
│ ├───▶│Controller1│────┤ │
│ └───────────┘ │
│ │ │ │
│ ┌───────────┐ │
│ └───▶│Controller2│────┘ │
└───────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

上图虚线框就是Filter2的拦截范围,Filter组件实际上并不知道后续内部处理是通过Spring MVC提供的DispatcherServlet还是其他Servlet组件,因为Filter是Servlet规范定义的标准组件,它可以应用在任何基于Servlet的程序中。

如果只基于Spring MVC开发应用程序,还可以使用Spring MVC提供的一种功能类似Filter的拦截器:Interceptor。和Filter相比,Interceptor拦截范围不是后续整个处理流程,而是仅针对Controller拦截:

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
       │   ▲
▼ │
┌───────┐
│Filter1│
└───────┘
│ ▲
▼ │
┌───────┐
│Filter2│
└───────┘
│ ▲
▼ │
┌─────────────────┐
│DispatcherServlet│◀───┐
└─────────────────┘ │
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ┐
│ │
│ │ ┌────────────┐ │
│ │ Render │
│ │ └────────────┘ │
│ ▲
│ │ │ │
│ ┌────────────┐
│ │ │ModelAndView│ │
│ └────────────┘
│ │ ▲ │
│ ┌───────────┐ │
├─┼─▶│Controller1│────┤ │
│ └───────────┘ │
│ │ │ │
│ ┌───────────┐ │
└─┼─▶│Controller2│────┘ │
└───────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

上图虚线框就是Interceptor的拦截范围,注意到Controller的处理方法一般都类似这样:

1
2
3
4
5
6
7
@Controller
public class Controller1 {
@GetMapping("/path/to/hello")
ModelAndView hello() {
...
}
}

所以,Interceptor的拦截范围其实就是Controller方法,它实际上就相当于基于AOP的方法拦截。因为Interceptor只拦截Controller方法,所以要注意,返回ModelAndView并渲染后,后续处理就脱离了Interceptor的拦截范围。

使用Interceptor的好处是Interceptor本身是Spring管理的Bean,因此注入任意Bean都非常简单。此外,可以应用多个Interceptor,并通过简单的@Order指定顺序。我们先写一个LoggerInterceptor

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
@Order(1)
@Component
public class LoggerInterceptor implements HandlerInterceptor {

final Logger logger = LoggerFactory.getLogger(getClass());

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.info("preHandle {}...", request.getRequestURI());
if (request.getParameter("debug") != null) {
PrintWriter pw = response.getWriter();
pw.write("<p>DEBUG MODE</p>");
pw.flush();
return false;
}
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.info("postHandle {}.", request.getRequestURI());
if (modelAndView != null) {
modelAndView.addObject("__time__", LocalDateTime.now());
}
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
logger.info("afterCompletion {}: exception = {}", request.getRequestURI(), ex);
}
}

一个Interceptor必须实现HandlerInterceptor接口,可以选择实现preHandle()postHandle()afterCompletion()方法。preHandle()是Controller方法调用前执行,postHandle()是Controller方法正常返回后执行,而afterCompletion()无论Controller方法是否抛异常都会执行,参数ex就是Controller方法抛出的异常(未抛出异常是null)。

preHandle()中,也可以直接处理响应,然后返回false表示无需调用Controller方法继续处理了,通常在认证或者安全检查失败时直接返回错误响应。在postHandle()中,因为捕获了Controller方法返回的ModelAndView,所以可以继续往ModelAndView里添加一些通用数据,很多页面需要的全局数据如Copyright信息等都可以放到这里,无需在每个Controller方法中重复添加。

我们再继续添加一个AuthInterceptor,用于替代上一节使用AuthFilter进行Basic认证的功能:

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
@Order(2)
@Component
public class AuthInterceptor implements HandlerInterceptor {

final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
UserService userService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
logger.info("pre authenticate {}...", request.getRequestURI());
try {
authenticateByHeader(request);
} catch (RuntimeException e) {
logger.warn("login by authorization header failed.", e);
}
return true;
}

private void authenticateByHeader(HttpServletRequest req) {
String authHeader = req.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Basic ")) {
logger.info("try authenticate by authorization header...");
String up = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8);
int pos = up.indexOf(':');
if (pos > 0) {
String email = URLDecoder.decode(up.substring(0, pos), StandardCharsets.UTF_8);
String password = URLDecoder.decode(up.substring(pos + 1), StandardCharsets.UTF_8);
User user = userService.signin(email, password);
req.getSession().setAttribute(UserController.KEY_USER, user);
logger.info("user {} login by authorization header ok.", email);
}
}
}
}

这个AuthInterceptor是由Spring容器直接管理的,因此注入UserService非常方便。

最后,要让拦截器生效,我们在WebMvcConfigurer中注册所有的Interceptor:

1
2
3
4
5
6
7
8
9
10
11
@Bean
WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
return new WebMvcConfigurer() {
public void addInterceptors(InterceptorRegistry registry) {
for (var interceptor : interceptors) {
registry.addInterceptor(interceptor);
}
}
...
};
}

注意

如果拦截器没有生效,请检查是否忘了在WebMvcConfigurer中注册。

处理异常

在Controller中,Spring MVC还允许定义基于@ExceptionHandler注解的异常处理方法。我们来看具体的示例代码:

1
2
3
4
5
6
7
8
@Controller
public class UserController {
@ExceptionHandler(RuntimeException.class)
public ModelAndView handleUnknowException(Exception ex) {
return new ModelAndView("500.html", Map.of("error", ex.getClass().getSimpleName(), "message", ex.getMessage()));
}
...
}

异常处理方法没有固定的方法签名,可以传入ExceptionHttpServletRequest等,返回值可以是void,也可以是ModelAndView,上述代码通过@ExceptionHandler(RuntimeException.class)表示当发生RuntimeException的时候,就自动调用此方法处理。

注意到我们返回了一个新的ModelAndView,这样在应用程序内部如果发生了预料之外的异常,可以给用户显示一个出错页面,而不是简单的500 Internal Server Error或404 Not Found。例如B站的错误页:

ERROR

可以编写多个错误处理方法,每个方法针对特定的异常。例如,处理LoginException使得页面可以自动跳转到登录页。

使用ExceptionHandler时,要注意它仅作用于当前的Controller,即ControllerA中定义的一个ExceptionHandler方法对ControllerB不起作用。如果我们有很多Controller,每个Controller都需要处理一些通用的异常,例如LoginException,思考一下应该怎么避免重复代码?

练习

使用Interceptor。

下载练习

小结

Spring MVC提供了Interceptor组件来拦截Controller方法,使用时要注意Interceptor的作用范围。

在开发REST应用时,很多时候,是通过页面的JavaScript和后端的REST API交互。

在JavaScript与REST交互的时候,有很多安全限制。默认情况下,浏览器按同源策略放行JavaScript调用API,即:

  • 如果A站在域名a.com页面的JavaScript调用A站自己的API时,没有问题;
  • 如果A站在域名a.com页面的JavaScript调用B站b.com的API时,将被浏览器拒绝访问,因为不满足同源策略。

同源要求域名要完全相同(a.comwww.a.com不同),协议要相同(httphttps不同),端口要相同 。

那么,在域名a.com页面的JavaScript要调用B站b.com的API时,还有没有办法?

办法是有的,那就是CORS,全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。如果A站的JavaScript访问B站API的时候,B站能够返回响应头Access-Control-Allow-Origin: http://a.com,那么,浏览器就允许A站的JavaScript访问B站的API。

注意到跨域访问能否成功,取决于B站是否愿意给A站返回一个正确的Access-Control-Allow-Origin响应头,所以决定权永远在提供API的服务方手中。

关于CORS的详细信息可以参考MDN文档,这里不再详述。

使用Spring的@RestController开发REST应用时,同样会面对跨域问题。如果我们允许指定的网站通过页面JavaScript访问这些REST API,就必须正确地设置CORS。

有好几种方法设置CORS,我们来一一介绍。

使用@CrossOrigin

第一种方法是使用@CrossOrigin注解,可以在@RestController的class级别或方法级别定义一个@CrossOrigin,例如:

1
2
3
4
5
6
@CrossOrigin(origins = "http://local.liaoxuefeng.com:8080")
@RestController
@RequestMapping("/api")
public class ApiController {
...
}

上述定义在ApiController处的@CrossOrigin指定了只允许来自local.liaoxuefeng.com跨域访问,允许多个域访问需要写成数组形式,例如origins = {"http://a.com", "https://www.b.com"})。如果要允许任何域访问,写成origins = "*"即可。

如果有多个REST Controller都需要使用CORS,那么,每个Controller都必须标注@CrossOrigin注解。

使用CorsRegistry

第二种方法是在WebMvcConfigurer中定义一个全局CORS配置,下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
WebMvcConfigurer createWebMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://local.liaoxuefeng.com:8080")
.allowedMethods("GET", "POST")
.maxAge(3600);
// 可以继续添加其他URL规则:
// registry.addMapping("/rest/v2/**")...
}
};
}

这种方式可以创建一个全局CORS配置,如果仔细地设计URL结构,那么可以一目了然地看到各个URL的CORS规则,推荐使用这种方式配置CORS。

使用CorsFilter

第三种方法是使用Spring提供的CorsFilter,我们在集成Filter中详细介绍了将Spring容器内置的Bean暴露为Servlet容器的Filter的方法,由于这种配置方式需要修改web.xml,也比较繁琐,所以推荐使用第二种方式。

测试

当我们配置好CORS后,可以在浏览器中测试一下规则是否生效。

我们先用http://localhost:8080在Chrome浏览器中打开首页,然后打开Chrome的开发者工具,切换到Console,输入一个JavaScript语句来跨域访问API:

1
$.getJSON("http://local.liaoxuefeng.com:8080/api/users", (data) => console.log(JSON.stringify(data)));

上述源站的域是http://localhost:8080,跨域访问的是http://local.liaoxuefeng.com:8080,因为配置的CORS不允许localhost访问,所以不出意外地得到一个错误:

cors-deny

浏览题打印了错误原因就是been blocked by CORS policy

我们再用http://local.liaoxuefeng.com:8080在Chrome浏览器中打开首页,在Console中执行JavaScript访问localhost

1
$.getJSON("http://localhost:8080/api/users", (data) => console.log(JSON.stringify(data)));

因为CORS规则允许来自http://local.liaoxuefeng.com:8080的访问,因此访问成功,打印出API的返回值:

cors-ok

练习

使用CORS控制跨域。

下载练习

小结

CORS可以控制指定域的页面JavaScript能否访问API。

在开发应用程序的时候,经常会遇到支持多语言的需求,这种支持多语言的功能称之为国际化,英文是internationalization,缩写为i18n(因为首字母i和末字母n中间有18个字母)。

还有针对特定地区的本地化功能,英文是localization,缩写为L10n,本地化是指根据地区调整类似姓名、日期的显示等。

也有把上面两者合称为全球化,英文是globalization,缩写为g11n。

在Java中,支持多语言和本地化是通过MessageFormat配合Locale实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// MessageFormat
import java.text.MessageFormat;
import java.util.Locale;

public class Time {
public static void main(String[] args) {
double price = 123.5;
int number = 10;
Object[] arguments = { price, number };
MessageFormat mfUS = new MessageFormat("Pay {0,number,currency} for {1} books.", Locale.US);
System.out.println(mfUS.format(arguments));
MessageFormat mfZH = new MessageFormat("{1}本书一共{0,number,currency}。", Locale.CHINA);
System.out.println(mfZH.format(arguments));
}
}

对于Web应用程序,要实现国际化功能,主要是渲染View的时候,要把各种语言的资源文件提出来,这样,不同的用户访问同一个页面时,显示的语言就是不同的。

我们来看看在Spring MVC应用程序中如何实现国际化。

获取Locale

实现国际化的第一步是获取到用户的Locale。在Web应用程序中,HTTP规范规定了浏览器会在请求中携带Accept-Language头,用来指示用户浏览器设定的语言顺序,如:

1
Accept-Language: zh-CN,zh;q=0.8,en;q=0.2

上述HTTP请求头表示优先选择简体中文,其次选择中文,最后选择英文。q表示权重,解析后我们可获得一个根据优先级排序的语言列表,把它转换为Java的Locale,即获得了用户的Locale。大多数框架通常只返回权重最高的Locale

Spring MVC通过LocaleResolver来自动从HttpServletRequest中获取Locale。有多种LocaleResolver的实现类,其中最常用的是CookieLocaleResolver

1
2
3
4
5
6
7
8
@Primary
@Bean
LocaleResolver createLocaleResolver() {
var clr = new CookieLocaleResolver();
clr.setDefaultLocale(Locale.ENGLISH);
clr.setDefaultTimeZone(TimeZone.getDefault());
return clr;
}

CookieLocaleResolverHttpServletRequest中获取Locale时,首先根据一个特定的Cookie判断是否指定了Locale,如果没有,就从HTTP头获取,如果还没有,就返回默认的Locale

当用户第一次访问网站时,CookieLocaleResolver只能从HTTP头获取Locale,即使用浏览器的默认语言。通常网站也允许用户自己选择语言,此时,CookieLocaleResolver就会把用户选择的语言存放到Cookie中,下一次访问时,就会返回用户上次选择的语言而不是浏览器默认语言。

提取资源文件

第二步是把写死在模板中的字符串以资源文件的方式存储在外部。对于多语言,主文件名如果命名为messages,那么资源文件必须按如下方式命名并放入classpath中:

  • 默认语言,文件名必须为messages.properties
  • 简体中文,Locale是zh_CN,文件名必须为messages_zh_CN.properties
  • 日文,Locale是ja_JP,文件名必须为messages_ja_JP.properties
  • 其它更多语言……

每个资源文件都有相同的key,例如,默认语言是英文,文件messages.properties内容如下:

1
2
3
4
language.select=Language
home=Home
signin=Sign In
copyright=Copyright©{0,number,#}

文件messages_zh_CN.properties内容如下:

1
2
3
4
language.select=语言
home=首页
signin=登录
copyright=版权所有©{0,number,#}

创建MessageSource

第三步是创建一个Spring提供的MessageSource实例,它自动读取所有的.properties文件,并提供一个统一接口来实现“翻译”:

1
2
// code, arguments, locale:
String text = messageSource.getMessage("signin", null, locale);

其中,signin是我们在.properties文件中定义的key,第二个参数是Object[]数组作为格式化时传入的参数,最后一个参数就是获取的用户Locale实例。

创建MessageSource如下:

1
2
3
4
5
6
7
8
9
@Bean("i18n")
MessageSource createMessageSource() {
var messageSource = new ResourceBundleMessageSource();
// 指定文件是UTF-8编码:
messageSource.setDefaultEncoding("UTF-8");
// 指定主文件名:
messageSource.setBasename("messages");
return messageSource;
}

注意到ResourceBundleMessageSource会自动根据主文件名自动把所有相关语言的资源文件都读进来。

再注意到Spring容器会创建不只一个MessageSource实例,我们自己创建的这个MessageSource是专门给页面国际化使用的,因此命名为i18n,不会与其它MessageSource实例冲突。

实现多语言

要在View中使用MessageSource加上Locale输出多语言,我们通过编写一个MvcInterceptor,把相关资源注入到ModelAndView中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class MvcInterceptor implements HandlerInterceptor {
@Autowired
LocaleResolver localeResolver;

// 注意注入的MessageSource名称是i18n:
@Autowired
@Qualifier("i18n")
MessageSource messageSource;

public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
if (modelAndView != null // 返回了ModelAndView
&& modelAndView.getViewName() != null // 设置了View
&& !modelAndView.getViewName().startsWith("redirect:") // 不是重定向
) {
// 解析用户的Locale:
Locale locale = localeResolver.resolveLocale(request);
// 放入Model:
modelAndView.addObject("__messageSource__", messageSource);
modelAndView.addObject("__locale__", locale);
}
}
}

不要忘了在WebMvcConfigurer中注册MvcInterceptor。现在,就可以在View中调用MessageSource.getMessage()方法来实现多语言:

1
<a href="/signin">{{ __messageSource__.getMessage('signin', null, __locale__) }}</a>

上述这种写法虽然可行,但格式太复杂了。使用View时,要根据每个特定的View引擎定制国际化函数。在Pebble中,我们可以封装一个国际化函数,名称就是下划线_,改造一下创建ViewResolver的代码:

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
@Bean
ViewResolver createViewResolver(@Autowired ServletContext servletContext, @Autowired @Qualifier("i18n") MessageSource messageSource) {
var engine = new PebbleEngine.Builder()
.autoEscaping(true)
.cacheActive(false)
.loader(new Servlet5Loader(servletContext))
// 添加扩展:
.extension(createExtension(messageSource))
.build();
var viewResolver = new PebbleViewResolver();
viewResolver.setPrefix("/WEB-INF/templates/");
viewResolver.setSuffix("");
viewResolver.setPebbleEngine(engine);
return viewResolver;
}

private Extension createExtension(MessageSource messageSource) {
return new AbstractExtension() {
@Override
public Map<String, Function> getFunctions() {
return Map.of("_", new Function() {
public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
String key = (String) args.get("0");
List<Object> arguments = this.extractArguments(args);
Locale locale = (Locale) context.getVariable("__locale__");
return messageSource.getMessage(key, arguments.toArray(), "???" + key + "???", locale);
}
private List<Object> extractArguments(Map<String, Object> args) {
int i = 1;
List<Object> arguments = new ArrayList<>();
while (args.containsKey(String.valueOf(i))) {
Object param = args.get(String.valueOf(i));
arguments.add(param);
i++;
}
return arguments;
}
public List<String> getArgumentNames() {
return null;
}
});
}
};
}

这样,我们可以把多语言页面改写为:

1
<a href="/signin">{{ _('signin') }}</a>

如果是带参数的多语言,需要把参数传进去:

1
<h5>{{ _('copyright', 2020) }}</h5>

使用其它View引擎时,也应当根据引擎接口实现更方便的语法。

切换Locale

最后,我们需要允许用户手动切换Locale,编写一个LocaleController来实现该功能:

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
@Controller
public class LocaleController {
final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
LocaleResolver localeResolver;

@GetMapping("/locale/{lo}")
public String setLocale(@PathVariable("lo") String lo, HttpServletRequest request, HttpServletResponse response) {
// 根据传入的lo创建Locale实例:
Locale locale = null;
int pos = lo.indexOf('_');
if (pos > 0) {
String lang = lo.substring(0, pos);
String country = lo.substring(pos + 1);
locale = new Locale(lang, country);
} else {
locale = new Locale(lo);
}
// 设定此Locale:
localeResolver.setLocale(request, response, locale);
logger.info("locale is set to {}.", locale);
// 刷新页面:
String referer = request.getHeader("Referer");
return "redirect:" + (referer == null ? "/" : referer);
}
}

在页面设计中,通常在右上角给用户提供一个语言选择列表,来看看效果:

i18n-en

切换到中文:

i18n-zh-cn

练习

在Spring MVC程序中实现国际化。

下载练习

小结

多语言支持需要从HTTP请求中解析用户的Locale,然后针对不同Locale显示不同的语言;

Spring MVC应用程序通过MessageSourceLocaleResolver,配合View实现国际化。

在Servlet模型中,每个请求都是由某个线程处理,然后,将响应写入IO流,发送给客户端。从开始处理请求,到写入响应完成,都是在同一个线程中处理的。

实现Servlet容器的时候,只要每处理一个请求,就创建一个新线程处理它,就能保证正确实现了Servlet线程模型。在实际产品中,例如Tomcat,总是通过线程池来处理请求,它仍然符合一个请求从头到尾都由某一个线程处理。

这种线程模型非常重要,因为Spring的JDBC事务是基于ThreadLocal实现的,如果在处理过程中,一会由线程A处理,一会又由线程B处理,那事务就全乱套了。此外,很多安全认证,也是基于ThreadLocal实现的,可以保证在处理请求的过程中,各个线程互不影响。

但是,如果一个请求处理的时间较长,例如几秒钟甚至更长,那么,这种基于线程池的同步模型很快就会把所有线程耗尽,导致服务器无法响应新的请求。如果把长时间处理的请求改为异步处理,那么线程池的利用率就会大大提高。Servlet从3.0规范开始添加了异步支持,允许对一个请求进行异步处理。

我们先来看看在Spring MVC中如何实现对请求进行异步处理的逻辑。首先建立一个Web工程,然后编辑web.xml文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<web-app>
<display-name>Archetype Created Web Application</display-name>

<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.itranswarp.learnjava.AppConfig</param-value>
</init-param>
<load-on-startup>0</load-on-startup>
<async-supported>true</async-supported>
</servlet>

<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>

和前面普通的MVC程序相比,这个web.xml主要对DispatcherServlet的配置多了一个<async-supported>,默认值是false,必须明确写成true,这样Servlet容器才会支持async处理。

下一步就是在Controller中编写async处理逻辑。我们以ApiController为例,演示如何异步处理请求。

第一种async处理方式是返回一个Callable,Spring MVC自动把返回的Callable放入线程池执行,等待结果返回后再写入响应:

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/users")
public Callable<List<User>> users() {
return () -> {
// 模拟3秒耗时:
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return userService.getUsers();
};
}

第二种async处理方式是返回一个DeferredResult对象,然后在另一个线程中,设置此对象的值并写入响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@GetMapping("/users/{id}")
public DeferredResult<User> user(@PathVariable("id") long id) {
DeferredResult<User> result = new DeferredResult<>(3000L); // 3秒超时
new Thread(() -> {
// 等待1秒:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
User user = userService.getUserById(id);
// 设置正常结果并由Spring MVC写入Response:
result.setResult(user);
} catch (Exception e) {
// 设置错误结果并由Spring MVC写入Response:
result.setErrorResult(Map.of("error", e.getClass().getSimpleName(), "message", e.getMessage()));
}
}).start();
return result;
}

使用DeferredResult时,可以设置超时,超时会自动返回超时错误响应。在另一个线程中,可以调用setResult()写入结果,也可以调用setErrorResult()写入一个错误结果。

运行程序,当我们访问http://localhost:8080/api/users/1时,假定用户存在,则浏览器在1秒后返回结果:

deferred-result-ok

访问一个不存在的User ID,则等待1秒后返回错误结果:

deferred-result-error

使用Filter

当我们使用async模式处理请求时,原有的Filter也可以工作,但我们必须在web.xml中添加<async-supported>并设置为true。我们用两个Filter:SyncFilter和AsyncFilter分别测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<web-app ...>
...
<filter>
<filter-name>sync-filter</filter-name>
<filter-class>com.itranswarp.learnjava.web.SyncFilter</filter-class>
</filter>

<filter>
<filter-name>async-filter</filter-name>
<filter-class>com.itranswarp.learnjava.web.AsyncFilter</filter-class>
<async-supported>true</async-supported>
</filter>

<filter-mapping>
<filter-name>sync-filter</filter-name>
<url-pattern>/api/version</url-pattern>
</filter-mapping>

<filter-mapping>
<filter-name>async-filter</filter-name>
<url-pattern>/api/*</url-pattern>
</filter-mapping>
...
</web-app>

一个声明为支持<async-supported>的Filter既可以过滤async处理请求,也可以过滤正常的同步处理请求,而未声明<async-supported>的Filter无法支持async请求,如果一个普通的Filter遇到async请求时,会直接报错,因此,务必注意普通Filter的<url-pattern>不要匹配async请求路径。

logback.xml配置文件中,我们把输出格式加上[%thread],可以输出当前线程的名称:

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</Pattern>
</layout>
</appender>
...
</configuration>

对于同步请求,例如/api/version,我们可以看到如下输出:

1
2
3
4
5
2020-05-16 11:22:40 [http-nio-8080-exec-1] INFO  c.i.learnjava.web.SyncFilter - start SyncFilter...
2020-05-16 11:22:40 [http-nio-8080-exec-1] INFO c.i.learnjava.web.AsyncFilter - start AsyncFilter...
2020-05-16 11:22:40 [http-nio-8080-exec-1] INFO c.i.learnjava.web.ApiController - get version...
2020-05-16 11:22:40 [http-nio-8080-exec-1] INFO c.i.learnjava.web.AsyncFilter - end AsyncFilter.
2020-05-16 11:22:40 [http-nio-8080-exec-1] INFO c.i.learnjava.web.SyncFilter - end SyncFilter.

可见,每个Filter和ApiController都是由同一个线程执行。

对于异步请求,例如/api/users,我们可以看到如下输出:

1
2
3
4
2020-05-16 11:23:49 [http-nio-8080-exec-4] INFO  c.i.learnjava.web.AsyncFilter - start AsyncFilter...
2020-05-16 11:23:49 [http-nio-8080-exec-4] INFO c.i.learnjava.web.ApiController - get users...
2020-05-16 11:23:49 [http-nio-8080-exec-4] INFO c.i.learnjava.web.AsyncFilter - end AsyncFilter.
2020-05-16 11:23:52 [MvcAsync1] INFO c.i.learnjava.web.ApiController - return users...

可见,AsyncFilterApiController是由同一个线程执行的,但是,返回响应的是另一个线程。

DeferredResult测试,可以看到如下输出:

1
2
3
2020-05-16 11:25:24 [http-nio-8080-exec-8] INFO  c.i.learnjava.web.AsyncFilter - start AsyncFilter...
2020-05-16 11:25:24 [http-nio-8080-exec-8] INFO c.i.learnjava.web.AsyncFilter - end AsyncFilter.
2020-05-16 11:25:25 [Thread-2] INFO c.i.learnjava.web.ApiController - deferred result is set.

同样,返回响应的是另一个线程。

在实际使用时,经常用到的就是DeferredResult,因为返回DeferredResult时,可以设置超时、正常结果和错误结果,易于编写比较灵活的逻辑。

使用async异步处理响应时,要时刻牢记,在另一个异步线程中的事务和Controller方法中执行的事务不是同一个事务,在Controller中绑定的ThreadLocal信息也无法在异步线程中获取。

此外,Servlet 3.0规范添加的异步支持是针对同步模型打了一个“补丁”,虽然可以异步处理请求,但高并发异步请求时,它的处理效率并不高,因为这种异步模型并没有用到真正的“原生”异步。Java标准库提供了封装操作系统的异步IO包java.nio,是真正的多路复用IO模型,可以用少量线程支持大量并发。使用NIO编程复杂度比同步IO高很多,因此我们很少直接使用NIO。相反,大部分需要高性能异步IO的应用程序会选择Netty这样的框架,它基于NIO提供了更易于使用的API,方便开发异步应用程序。

练习

使用Spring MVC实现异步处理请求。

下载练习

小结

在Spring MVC中异步处理请求需要正确配置web.xml,并返回CallableDeferredResult对象。

WebSocket是一种基于HTTP的长链接技术。传统的HTTP协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高。

因为HTTP本身是基于TCP连接的,所以,WebSocket在HTTP协议的基础上做了一个简单的升级,即建立TCP连接后,浏览器发送请求时,附带几个头:

1
2
3
4
GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade

就表示客户端希望升级连接,变成长连接的WebSocket,服务器返回升级成功的响应:

1
2
3
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

收到成功响应后表示WebSocket“握手”成功,这样,代表WebSocket的这个TCP连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可随时向服务器推送消息。双方推送的消息既可以是文本消息,也可以是二进制消息,一般来说,绝大部分应用程序会推送基于JSON的文本消息。

现代浏览器都已经支持WebSocket协议,服务器则需要底层框架支持。Java的Servlet规范从3.1开始支持WebSocket,所以,必须选择支持Servlet 3.1或更高规范的Servlet容器,才能支持WebSocket。最新版本的Tomcat、Jetty等开源服务器均支持WebSocket。

我们以实际代码演示如何在Spring MVC中实现对WebSocket的支持。首先,我们需要在pom.xml中加入以下依赖:

  • org.apache.tomcat.embed:tomcat-embed-websocket:10.1.1
  • org.springframework:spring-websocket:6.0.0

第一项是嵌入式Tomcat支持WebSocket的组件,第二项是Spring封装的支持WebSocket的接口。

接下来,我们需要在AppConfig中加入Spring Web对WebSocket的配置,先增加一个@EnableWebSocket注解,然后创建一个WebSocketConfigurer实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@ComponentScan
@EnableWebMvc
@EnableWebSocket // 启用WebSocket支持
public class AppConfig {
@Bean
WebSocketConfigurer createWebSocketConfigurer(
@Autowired ChatHandler chatHandler,
@Autowired ChatHandshakeInterceptor chatInterceptor)
{
return new WebSocketConfigurer() {
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 把URL与指定的WebSocketHandler关联,可关联多个:
registry.addHandler(chatHandler, "/chat").addInterceptors(chatInterceptor);
}
};
}
...
}

此实例在内部通过WebSocketHandlerRegistry注册能处理WebSocket的WebSocketHandler,以及可选的WebSocket拦截器HandshakeInterceptor。我们注入的这两个类都是自己编写的业务逻辑,后面我们详细讨论如何编写它们,这里只需关注浏览器连接到WebSocket的URL是/chat

处理WebSocket连接

和处理普通HTTP请求不同,没法用一个方法处理一个URL。Spring提供了TextWebSocketHandlerBinaryWebSocketHandler分别处理文本消息和二进制消息,这里我们选择文本消息作为聊天室的协议,因此,ChatHandler需要继承自TextWebSocketHandler

1
2
3
4
@Component
public class ChatHandler extends TextWebSocketHandler {
...
}

当浏览器请求一个WebSocket连接后,如果成功建立连接,Spring会自动调用afterConnectionEstablished()方法,任何原因导致WebSocket连接中断时,Spring会自动调用afterConnectionClosed方法,因此,覆写这两个方法即可处理连接成功和结束后的业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class ChatHandler extends TextWebSocketHandler {
// 保存所有Client的WebSocket会话实例:
private Map<String, WebSocketSession> clients = new ConcurrentHashMap<>();

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 新会话根据ID放入Map:
clients.put(session.getId(), session);
session.getAttributes().put("name", "Guest1");
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
clients.remove(session.getId());
}
}

每个WebSocket会话以WebSocketSession表示,且已分配唯一ID。和WebSocket相关的数据,例如用户名称等,均可放入关联的getAttributes()中。

用实例变量clients持有当前所有的WebSocketSession是为了广播,即向所有用户推送同一消息时,可以这么写:

1
2
3
4
5
6
String json = ...
TextMessage message = new TextMessage(json);
for (String id : clients.keySet()) {
WebSocketSession session = clients.get(id);
session.sendMessage(message);
}

我们发送的消息是序列化后的JSON,可以用ChatMessage表示:

1
2
3
4
5
public class ChatMessage {
public long timestamp;
public String name;
public String text;
}

每收到一个用户的消息后,我们就需要广播给所有用户:

1
2
3
4
5
6
7
8
9
10
@Component
public class ChatHandler extends TextWebSocketHandler {
...
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String s = message.getPayload();
String r = ... // 根据输入消息构造待发送消息
broadcastMessage(r); // 推送给所有用户
}
}

如果要推送给指定的几个用户,那就需要在clients中根据条件查找出某些WebSocketSession,然后发送消息。

注意到我们在注册WebSocket时还传入了一个ChatHandshakeInterceptor,这个类实际上可以从HttpSessionHandshakeInterceptor继承,它的主要作用是在WebSocket建立连接后,把HttpSession的一些属性复制到WebSocketSession,例如,用户的登录信息等:

1
2
3
4
5
6
7
@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
public ChatHandshakeInterceptor() {
// 指定从HttpSession复制属性到WebSocketSession:
super(List.of(UserController.KEY_USER));
}
}

这样,在ChatHandler中,可以从WebSocketSession.getAttributes()中获取到复制过来的属性。

客户端开发

在完成了服务器端的开发后,我们还需要在页面编写一点JavaScript逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建WebSocket连接:
var ws = new WebSocket('ws://' + location.host + '/chat');
// 连接成功时:
ws.addEventListener('open', function (event) {
console.log('websocket connected.');
});
// 收到消息时:
ws.addEventListener('message', function (event) {
console.log('message: ' + event.data);
var msgs = JSON.parse(event.data);
// TODO:
});
// 连接关闭时:
ws.addEventListener('close', function () {
console.log('websocket closed.');
});
// 绑定到全局变量:
window.chatWs = ws;

用户可以在连接成功后任何时候给服务器发送消息:

1
2
var inputText = 'Hello, WebSocket.';
window.chatWs.send(JSON.stringify({text: inputText}));

最后,连调浏览器和服务器端,如果一切无误,可以开多个不同的浏览器测试WebSocket的推送和广播。

和上一节我们介绍的异步处理类似,Servlet的线程模型并不适合大规模的长链接。基于NIO的Netty等框架更适合处理WebSocket长链接,我们将在后面介绍。

练习

使用WebSocket编写一个聊天室。

下载练习

小结

在Servlet中使用WebSocket需要3.1及以上版本;

通过spring-websocket可以简化WebSocket的开发。

留言與分享

数据库基本上是现代应用程序的标准存储,绝大多数程序都把自己的业务数据存储在关系数据库中,可见,访问数据库几乎是所有应用程序必备能力。

我们在前面已经介绍了Java程序访问数据库的标准接口JDBC,它的实现方式非常简洁,即:Java标准库定义接口,各数据库厂商以“驱动”的形式实现接口。应用程序要使用哪个数据库,就把该数据库厂商的驱动以jar包形式引入进来,同时自身仅使用JDBC接口,编译期并不需要特定厂商的驱动。

使用JDBC虽然简单,但代码比较繁琐。Spring为了简化数据库访问,主要做了以下几点工作:

  • 提供了简化的访问JDBC的模板类,不必手动释放资源;
  • 提供了一个统一的DAO类以实现Data Access Object模式;
  • SQLException封装为DataAccessException,这个异常是一个RuntimeException,并且让我们能区分SQL异常的原因,例如,DuplicateKeyException表示违反了一个唯一约束;
  • 能方便地集成Hibernate、JPA和MyBatis这些数据库访问框架。

本章我们将详细讲解在Spring中访问数据库的最佳实践。

我们在前面介绍JDBC编程时已经讲过,Java程序使用JDBC接口访问关系数据库的时候,需要以下几步:

  • 创建全局DataSource实例,表示数据库连接池;
  • 在需要读写数据库的方法内部,按如下步骤访问数据库:
    • 从全局DataSource实例获取Connection实例;
    • 通过Connection实例创建PreparedStatement实例;
    • 执行SQL语句,如果是查询,则通过ResultSet读取结果集,如果是修改,则获得int结果。

正确编写JDBC代码的关键是使用try ... finally释放资源,涉及到事务的代码需要正确提交或回滚事务。

在Spring使用JDBC,首先我们通过IoC容器创建并管理一个DataSource实例,然后,Spring提供了一个JdbcTemplate,可以方便地让我们操作JDBC,因此,通常情况下,我们会实例化一个JdbcTemplate。顾名思义,这个类主要使用了Template模式

编写示例代码或者测试代码时,我们强烈推荐使用HSQLDB这个数据库,它是一个用Java编写的关系数据库,可以以内存模式或者文件模式运行,本身只有一个jar包,非常适合演示代码或者测试代码。

我们以实际工程为例,先创建Maven工程spring-data-jdbc,然后引入以下依赖:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-jdbc:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.1

AppConfig中,我们需要创建以下几个必须的Bean:

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
@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {

@Value("${jdbc.url}")
String jdbcUrl;

@Value("${jdbc.username}")
String jdbcUsername;

@Value("${jdbc.password}")
String jdbcPassword;

@Bean
DataSource createDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(jdbcUsername);
config.setPassword(jdbcPassword);
config.addDataSourceProperty("autoCommit", "true");
config.addDataSourceProperty("connectionTimeout", "5");
config.addDataSourceProperty("idleTimeout", "60");
return new HikariDataSource(config);
}

@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}

在上述配置中:

  1. 通过@PropertySource("jdbc.properties")读取数据库配置文件;
  2. 通过@Value("${jdbc.url}")注入配置文件的相关配置;
  3. 创建一个DataSource实例,它的实际类型是HikariDataSource,创建时需要用到注入的配置;
  4. 创建一个JdbcTemplate实例,它需要注入DataSource,这是通过方法参数完成注入的。

最后,针对HSQLDB写一个配置文件jdbc.properties

1
2
3
4
5
6
# 数据库文件名为testdb:
jdbc.url=jdbc:hsqldb:file:testdb

# Hsqldb默认的用户名是sa,口令是空字符串:
jdbc.username=sa
jdbc.password=

可以通过HSQLDB自带的工具来初始化数据库表,这里我们写一个Bean,在Spring容器启动时自动创建一个users表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class DatabaseInitializer {
@Autowired
JdbcTemplate jdbcTemplate;

@PostConstruct
public void init() {
jdbcTemplate.update("CREATE TABLE IF NOT EXISTS users (" //
+ "id BIGINT IDENTITY NOT NULL PRIMARY KEY, " //
+ "email VARCHAR(100) NOT NULL, " //
+ "password VARCHAR(100) NOT NULL, " //
+ "name VARCHAR(100) NOT NULL, " //
+ "UNIQUE (email))");
}
}

现在,所有准备工作都已完毕。我们只需要在需要访问数据库的Bean中,注入JdbcTemplate即可:

1
2
3
4
5
6
@Component
public class UserService {
@Autowired
JdbcTemplate jdbcTemplate;
...
}

JdbcTemplate用法

Spring提供的JdbcTemplate采用Template模式,提供了一系列以回调为特点的工具方法,目的是避免繁琐的try...catch语句。

我们以具体的示例来说明JdbcTemplate的用法。

首先我们看T execute(ConnectionCallback<T> action)方法,它提供了Jdbc的Connection供我们使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public User getUserById(long id) {
// 注意传入的是ConnectionCallback:
return jdbcTemplate.execute((Connection conn) -> {
// 可以直接使用conn实例,不要释放它,回调结束后JdbcTemplate自动释放:
// 在内部手动创建的PreparedStatement、ResultSet必须用try(...)释放:
try (var ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
ps.setObject(1, id);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
}
});
}

也就是说,上述回调方法允许获取Connection,然后做任何基于Connection的操作。

我们再看T execute(String sql, PreparedStatementCallback<T> action)的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public User getUserByName(String name) {
// 需要传入SQL语句,以及PreparedStatementCallback:
return jdbcTemplate.execute("SELECT * FROM users WHERE name = ?", (PreparedStatement ps) -> {
// PreparedStatement实例已经由JdbcTemplate创建,并在回调后自动释放:
ps.setObject(1, name);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
});
}

最后,我们看T queryForObject(String sql, RowMapper<T> rowMapper, Object... args)方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public User getUserByEmail(String email) {
// 传入SQL,参数和RowMapper实例:
return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email = ?",
(ResultSet rs, int rowNum) -> {
// 将ResultSet的当前行映射为一个JavaBean:
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
},
email);
}

queryForObject()方法中,传入SQL以及SQL参数后,JdbcTemplate会自动创建PreparedStatement,自动执行查询并返回ResultSet,我们提供的RowMapper需要做的事情就是把ResultSet的当前行映射成一个JavaBean并返回。整个过程中,使用ConnectionPreparedStatementResultSet都不需要我们手动管理。

RowMapper不一定返回JavaBean,实际上它可以返回任何Java对象。例如,使用SELECT COUNT(*)查询时,可以返回Long

1
2
3
4
5
6
public long getUsers() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", (ResultSet rs, int rowNum) -> {
// SELECT COUNT(*)查询只有一列,取第一列数据:
return rs.getLong(1);
});
}

如果我们期望返回多行记录,而不是一行,可以用query()方法:

1
2
3
4
5
6
7
public List<User> getUsers(int pageIndex) {
int limit = 100;
int offset = limit * (pageIndex - 1);
return jdbcTemplate.query("SELECT * FROM users LIMIT ? OFFSET ?",
new BeanPropertyRowMapper<>(User.class),
limit, offset);
}

上述query()方法传入的参数仍然是SQL、SQL参数以及RowMapper实例。这里我们直接使用Spring提供的BeanPropertyRowMapper。如果数据库表的结构恰好和JavaBean的属性名称一致,那么BeanPropertyRowMapper就可以直接把一行记录按列名转换为JavaBean。

如果我们执行的不是查询,而是插入、更新和删除操作,那么需要使用update()方法:

1
2
3
4
5
6
public void updateUser(User user) {
// 传入SQL,SQL参数,返回更新的行数:
if (1 != jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId())) {
throw new RuntimeException("User not found by id");
}
}

只有一种INSERT操作比较特殊,那就是如果某一列是自增列(例如自增主键),通常,我们需要获取插入后的自增值。JdbcTemplate提供了一个KeyHolder来简化这一操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public User register(String email, String password, String name) {
// 创建一个KeyHolder:
KeyHolder holder = new GeneratedKeyHolder();
if (1 != jdbcTemplate.update(
// 参数1:PreparedStatementCreator
(conn) -> {
// 创建PreparedStatement时,必须指定RETURN_GENERATED_KEYS:
var ps = conn.prepareStatement("INSERT INTO users(email, password, name) VALUES(?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setObject(1, email);
ps.setObject(2, password);
ps.setObject(3, name);
return ps;
},
// 参数2:KeyHolder
holder)
) {
throw new RuntimeException("Insert failed.");
}
// 从KeyHolder中获取返回的自增值:
return new User(holder.getKey().longValue(), email, password, name);
}

JdbcTemplate还有许多重载方法,这里我们不一一介绍。需要强调的是,JdbcTemplate只是对JDBC操作的一个简单封装,它的目的是尽量减少手动编写try(resource) {...}的代码,对于查询,主要通过RowMapper实现了JDBC结果集到Java对象的转换。

我们总结一下JdbcTemplate的用法,那就是:

  • 针对简单查询,优选query()queryForObject(),因为只需提供SQL语句、参数和RowMapper
  • 针对更新操作,优选update(),因为只需提供SQL语句和参数;
  • 任何复杂的操作,最终也可以通过execute(ConnectionCallback)实现,因为拿到Connection就可以做任何JDBC操作。

实际上我们使用最多的仍然是各种查询。如果在设计表结构的时候,能够和JavaBean的属性一一对应,那么直接使用BeanPropertyRowMapper就很方便。如果表结构和JavaBean不一致怎么办?那就需要稍微改写一下查询,使结果集的结构和JavaBean保持一致。

例如,表的列名是office_address,而JavaBean属性是workAddress,就需要指定别名,改写查询如下:

1
SELECT id, email, office_address AS workAddress, name FROM users WHERE email = ?

练习

使用JdbcTemplate。

下载练习

小结

Spring提供了JdbcTemplate来简化JDBC操作;

使用JdbcTemplate时,根据需要优先选择高级方法;

任何JDBC操作都可以使用保底的execute(ConnectionCallback)方法。

使用Spring操作JDBC虽然方便,但是我们在前面讨论JDBC的时候,讲到过JDBC事务,如果要在Spring中操作事务,没必要手写JDBC事务,可以使用Spring提供的高级接口来操作事务。

Spring提供了一个PlatformTransactionManager来表示事务管理器,所有的事务都由它负责管理。而事务由TransactionStatus表示。如果手写事务代码,使用try...catch如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TransactionStatus tx = null;
try {
// 开启事务:
tx = txManager.getTransaction(new DefaultTransactionDefinition());
// 相关JDBC操作:
jdbcTemplate.update("...");
jdbcTemplate.update("...");
// 提交事务:
txManager.commit(tx);
} catch (RuntimeException e) {
// 回滚事务:
txManager.rollback(tx);
throw e;
}

Spring为啥要抽象出PlatformTransactionManagerTransactionStatus?原因是JavaEE除了提供JDBC事务外,它还支持分布式事务JTA(Java Transaction API)。分布式事务是指多个数据源(比如多个数据库,多个消息系统)要在分布式环境下实现事务的时候,应该怎么实现。分布式事务实现起来非常复杂,简单地说就是通过一个分布式事务管理器实现两阶段提交,但本身数据库事务就不快,基于数据库事务实现的分布式事务就慢得难以忍受,所以使用率不高。

Spring为了同时支持JDBC和JTA两种事务模型,就抽象出PlatformTransactionManager。因为我们的代码只需要JDBC事务,因此,在AppConfig中,需要再定义一个PlatformTransactionManager对应的Bean,它的实际类型是DataSourceTransactionManager

1
2
3
4
5
6
7
8
9
10
@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {
...
@Bean
PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}

使用编程的方式使用Spring事务仍然比较繁琐,更好的方式是通过声明式事务来实现。使用声明式事务非常简单,除了在AppConfig中追加一个上述定义的PlatformTransactionManager外,再加一个@EnableTransactionManagement就可以启用声明式事务:

1
2
3
4
5
6
7
@Configuration
@ComponentScan
@EnableTransactionManagement // 启用声明式
@PropertySource("jdbc.properties")
public class AppConfig {
...
}

然后,对需要事务支持的方法,加一个@Transactional注解:

1
2
3
4
5
6
7
8
@Component
public class UserService {
// 此public方法自动具有事务支持:
@Transactional
public User register(String email, String password, String name) {
...
}
}

或者更简单一点,直接在Bean的class处加上,表示所有public方法都具有事务支持:

1
2
3
4
5
@Component
@Transactional
public class UserService {
...
}

Spring对一个声明式事务的方法,如何开启事务支持?原理仍然是AOP代理,即通过自动创建Bean的Proxy实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target = ...
PlatformTransactionManager txManager = ...

public User register(String email, String password, String name) {
TransactionStatus tx = null;
try {
tx = txManager.getTransaction(new DefaultTransactionDefinition());
target.register(email, password, name);
txManager.commit(tx);
} catch (RuntimeException e) {
txManager.rollback(tx);
throw e;
}
}
...
}

注意:声明了@EnableTransactionManagement后,不必额外添加@EnableAspectJAutoProxy

回滚事务

默认情况下,如果发生了RuntimeException,Spring的声明式事务将自动回滚。在一个事务方法中,如果程序判断需要回滚事务,只需抛出RuntimeException,例如:

1
2
3
4
5
6
7
8
9
@Transactional
public buyProducts(long productId, int num) {
...
if (store < num) {
// 库存不够,购买失败:
throw new IllegalArgumentException("No enough products");
}
...
}

如果要针对Checked Exception回滚事务,需要在@Transactional注解中写出来:

1
2
3
4
@Transactional(rollbackFor = {RuntimeException.class, IOException.class})
public buyProducts(long productId, int num) throws IOException {
...
}

上述代码表示在抛出RuntimeExceptionIOException时,事务将回滚。

为了简化代码,我们强烈建议业务异常体系从RuntimeException派生,这样就不必声明任何特殊异常即可让Spring的声明式事务正常工作:

1
2
3
4
5
6
7
8
9
10
11
public class BusinessException extends RuntimeException {
...
}

public class LoginException extends BusinessException {
...
}

public class PaymentException extends BusinessException {
...
}

事务边界

在使用事务的时候,明确事务边界非常重要。对于声明式事务,例如,下面的register()方法:

1
2
3
4
5
6
7
@Component
public class UserService {
@Transactional
public User register(String email, String password, String name) { // 事务开始
...
} // 事务结束
}

它的事务边界就是register()方法开始和结束。

类似的,一个负责给用户增加积分的addBonus()方法:

1
2
3
4
5
6
7
@Component
public class BonusService {
@Transactional
public void addBonus(long userId, int bonus) { // 事务开始
...
} // 事务结束
}

它的事务边界就是addBonus()方法开始和结束。

在现实世界中,问题总是要复杂一点点。用户注册后,能自动获得100积分,因此,实际代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class UserService {
@Autowired
BonusService bonusService;

@Transactional
public User register(String email, String password, String name) {
// 插入用户记录:
User user = jdbcTemplate.insert("...");
// 增加100积分:
bonusService.addBonus(user.id, 100);
}
}

现在问题来了:调用方(比如RegisterController)调用UserService.register()这个事务方法,它在内部又调用了BonusService.addBonus()这个事务方法,一共有几个事务?如果addBonus()抛出了异常需要回滚事务,register()方法的事务是否也要回滚?

问题的复杂度是不是一下子提高了10倍?

事务传播

要解决上面的问题,我们首先要定义事务的传播模型。

假设用户注册的入口是RegisterController,它本身没有事务,仅仅是调用UserService.register()这个事务方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class RegisterController {
@Autowired
UserService userService;

@PostMapping("/register")
public ModelAndView doRegister(HttpServletRequest req) {
String email = req.getParameter("email");
String password = req.getParameter("password");
String name = req.getParameter("name");
User user = userService.register(email, password, name);
return ...
}
}

因此,UserService.register()这个事务方法的起始和结束,就是事务的范围。

我们需要关心的问题是,在UserService.register()这个事务方法内,调用BonusService.addBonus(),我们期待的事务行为是什么:

1
2
3
4
5
6
7
@Transactional
public User register(String email, String password, String name) {
// 事务已开启:
User user = jdbcTemplate.insert("...");
// ???:
bonusService.addBonus(user.id, 100);
} // 事务结束

对于大多数业务来说,我们期待BonusService.addBonus()的调用,和UserService.register()应当融合在一起,它的行为应该如下:

UserService.register()已经开启了一个事务,那么在内部调用BonusService.addBonus()时,BonusService.addBonus()方法就没必要再开启一个新事务,直接加入到BonusService.register()的事务里就好了。

其实就相当于:

  1. UserService.register()先执行了一条INSERT语句:INSERT INTO users ...
  2. BonusService.addBonus()再执行一条INSERT语句:INSERT INTO bonus ...

因此,Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。

我们观察UserService.register()方法,它在RegisterController中执行,因为RegisterController没有事务,因此,UserService.register()方法会自动创建一个新事务。

UserService.register()方法内部,调用BonusService.addBonus()方法时,因为BonusService.addBonus()检测到当前已经有事务了,因此,它会加入到当前事务中执行。

因此,整个业务流程的事务边界就清晰了:它只有一个事务,并且范围就是UserService.register()方法。

有的童鞋会问:把BonusService.addBonus()方法的@Transactional去掉,变成一个普通方法,那不就规避了复杂的传播模型吗?

去掉BonusService.addBonus()方法的@Transactional,会引来另一个问题,即其他地方如果调用BonusService.addBonus()方法,那就没法保证事务了。例如,规定用户登录时积分+5:

1
2
3
4
5
6
7
8
9
10
11
@Controller
public class LoginController {
@Autowired
BonusService bonusService;

@PostMapping("/login")
public ModelAndView doLogin(HttpServletRequest req) {
User user = ...
bonusService.addBonus(user.id, 5);
}
}

可见,BonusService.addBonus()方法必须要有@Transactional,否则,登录后积分就无法添加了。

默认的事务传播级别是REQUIRED,它满足绝大部分的需求。还有一些其他的传播级别:

SUPPORTS:表示如果有事务,就加入到当前事务,如果没有,那也不开启事务执行。这种传播级别可用于查询方法,因为SELECT语句既可以在事务内执行,也可以不需要事务;

MANDATORY:表示必须要存在当前事务并加入执行,否则将抛出异常。这种传播级别可用于核心更新逻辑,比如用户余额变更,它总是被其他事务方法调用,不能直接由非事务方法调用;

REQUIRES_NEW:表示不管当前有没有事务,都必须开启一个新的事务执行。如果当前已经有事务,那么当前事务会挂起,等新事务完成后,再恢复执行;

NOT_SUPPORTED:表示不支持事务,如果当前有事务,那么当前事务会挂起,等这个方法执行完成后,再恢复执行;

NEVER:和NOT_SUPPORTED相比,它不但不支持事务,而且在监测到当前有事务时,会抛出异常拒绝执行;

NESTED:表示如果当前有事务,则开启一个嵌套级别事务,如果当前没有事务,则开启一个新事务。

上面这么多种事务的传播级别,其实默认的REQUIRED已经满足绝大部分需求,SUPPORTSREQUIRES_NEW在少数情况下会用到,其他基本不会用到,因为把事务搞得越复杂,不仅逻辑跟着复杂,而且速度也会越慢。

定义事务的传播级别也是写在@Transactional注解里的:

1
2
3
4
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Product createProduct() {
...
}

现在只剩最后一个问题了:Spring是如何传播事务的?

我们在JDBC中使用事务的时候,是这么个写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Connection conn = openConnection();
try {
// 关闭自动提交:
conn.setAutoCommit(false);
// 执行多条SQL语句:
insert(); update(); delete();
// 提交事务:
conn.commit();
} catch (SQLException e) {
// 回滚事务:
conn.rollback();
} finally {
conn.setAutoCommit(true);
conn.close();
}

Spring使用声明式事务,最终也是通过执行JDBC事务来实现功能的,那么,一个事务方法,如何获知当前是否存在事务?

答案是使用ThreadLocal。Spring总是把JDBC相关的ConnectionTransactionStatus实例绑定到ThreadLocal。如果一个事务方法从ThreadLocal未取到事务,那么它会打开一个新的JDBC连接,同时开启一个新的事务,否则,它就直接使用从ThreadLocal获取的JDBC连接以及TransactionStatus

因此,事务能正确传播的前提是,方法调用是在一个线程内才行。如果像下面这样写:

1
2
3
4
5
6
7
8
9
@Transactional
public User register(String email, String password, String name) { // BEGIN TX-A
User user = jdbcTemplate.insert("...");
new Thread(() -> {
// BEGIN TX-B:
bonusService.addBonus(user.id, 100);
// END TX-B
}).start();
} // END TX-A

在另一个线程中调用BonusService.addBonus(),它根本获取不到当前事务,因此,UserService.register()BonusService.addBonus()两个方法,将分别开启两个完全独立的事务。

换句话说,事务只能在当前线程传播,无法跨线程传播。

那如果我们想实现跨线程传播事务呢?原理很简单,就是要想办法把当前线程绑定到ThreadLocalConnectionTransactionStatus实例传递给新线程,但实现起来非常复杂,根据异常回滚更加复杂,不推荐自己去实现。

练习

使用声明式事务。

下载练习

小结

Spring提供的声明式事务极大地方便了在数据库中使用事务,正确使用声明式事务的关键在于确定好事务边界,理解事务传播级别。

在传统的多层应用程序中,通常是Web层调用业务层,业务层调用数据访问层。业务层负责处理各种业务逻辑,而数据访问层只负责对数据进行增删改查。因此,实现数据访问层就是用JdbcTemplate实现对数据库的操作。

编写数据访问层的时候,可以使用DAO模式。DAO即Data Access Object的缩写,它没有什么神秘之处,实现起来基本如下:

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
public class UserDao {

@Autowired
JdbcTemplate jdbcTemplate;

User getById(long id) {
...
}

List<User> getUsers(int page) {
...
}

User createUser(User user) {
...
}

User updateUser(User user) {
...
}

void deleteUser(User user) {
...
}
}

Spring提供了一个JdbcDaoSupport类,用于简化DAO的实现。这个JdbcDaoSupport没什么复杂的,核心代码就是持有一个JdbcTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class JdbcDaoSupport extends DaoSupport {

private JdbcTemplate jdbcTemplate;

public final void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
initTemplateConfig();
}

public final JdbcTemplate getJdbcTemplate() {
return this.jdbcTemplate;
}

...
}

它的意图是子类直接从JdbcDaoSupport继承后,可以随时调用getJdbcTemplate()获得JdbcTemplate的实例。那么问题来了:因为JdbcDaoSupportjdbcTemplate字段没有标记@Autowired,所以,子类想要注入JdbcTemplate,还得自己想个办法:

1
2
3
4
5
6
7
8
9
10
11
@Component
@Transactional
public class UserDao extends JdbcDaoSupport {
@Autowired
JdbcTemplate jdbcTemplate;

@PostConstruct
public void init() {
super.setJdbcTemplate(jdbcTemplate);
}
}

有的童鞋可能看出来了:既然UserDao都已经注入了JdbcTemplate,那再把它放到父类里,通过getJdbcTemplate()访问岂不是多此一举?

如果使用传统的XML配置,并不需要编写@Autowired JdbcTemplate jdbcTemplate,但是考虑到现在基本上是使用注解的方式,我们可以编写一个AbstractDao,专门负责注入JdbcTemplate

1
2
3
4
5
6
7
8
9
public abstract class AbstractDao extends JdbcDaoSupport {
@Autowired
private JdbcTemplate jdbcTemplate;

@PostConstruct
public void init() {
super.setJdbcTemplate(jdbcTemplate);
}
}

这样,子类的代码就非常干净,可以直接调用getJdbcTemplate()

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@Transactional
public class UserDao extends AbstractDao {
public User getById(long id) {
return getJdbcTemplate().queryForObject(
"SELECT * FROM users WHERE id = ?",
new BeanPropertyRowMapper<>(User.class),
id
);
}
...
}

倘若肯再多写一点样板代码,就可以把AbstractDao改成泛型,并实现getById()getAll()deleteById()这样的通用方法:

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
public abstract class AbstractDao<T> extends JdbcDaoSupport {
private String table;
private Class<T> entityClass;
private RowMapper<T> rowMapper;

public AbstractDao() {
// 获取当前类型的泛型类型:
this.entityClass = getParameterizedType();
this.table = this.entityClass.getSimpleName().toLowerCase() + "s";
this.rowMapper = new BeanPropertyRowMapper<>(entityClass);
}

public T getById(long id) {
return getJdbcTemplate().queryForObject("SELECT * FROM " + table + " WHERE id = ?", this.rowMapper, id);
}

public List<T> getAll(int pageIndex) {
int limit = 100;
int offset = limit * (pageIndex - 1);
return getJdbcTemplate().query("SELECT * FROM " + table + " LIMIT ? OFFSET ?",
new Object[] { limit, offset },
this.rowMapper);
}

public void deleteById(long id) {
getJdbcTemplate().update("DELETE FROM " + table + " WHERE id = ?", id);
}
...
}

这样,每个子类就自动获得了这些通用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@Transactional
public class UserDao extends AbstractDao<User> {
// 已经有了:
// User getById(long)
// List<User> getAll(int)
// void deleteById(long)
}

@Component
@Transactional
public class BookDao extends AbstractDao<Book> {
// 已经有了:
// Book getById(long)
// List<Book> getAll(int)
// void deleteById(long)
}

可见,DAO模式就是一个简单的数据访问模式,是否使用DAO,根据实际情况决定,因为很多时候,直接在Service层操作数据库也是完全没有问题的。

练习

使用DAO模式访问数据库。

下载练习

小结

Spring提供了JdbcDaoSupport来便于我们实现DAO模式;

可以基于泛型实现更通用、更简洁的DAO模式。

使用JdbcTemplate的时候,我们用得最多的方法就是List<T> query(String, RowMapper, Object...)。这个RowMapper的作用就是把ResultSet的一行记录映射为Java Bean。

这种把关系数据库的表记录映射为Java对象的过程就是ORM:Object-Relational Mapping。ORM既可以把记录转换成Java对象,也可以把Java对象转换为行记录。

使用JdbcTemplate配合RowMapper可以看作是最原始的ORM。如果要实现更自动化的ORM,可以选择成熟的ORM框架,例如Hibernate

我们来看看如何在Spring中集成Hibernate。

Hibernate作为ORM框架,它可以替代JdbcTemplate,但Hibernate仍然需要JDBC驱动,所以,我们需要引入JDBC驱动、连接池,以及Hibernate本身。在Maven中,我们加入以下依赖项:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-orm:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • jakarta.persistence:jakarta.persistence-api:3.1.0
  • org.hibernate:hibernate-core:6.1.4.Final
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.1

AppConfig中,我们仍然需要创建DataSource、引入JDBC配置文件,以及启用声明式事务:

1
2
3
4
5
6
7
8
9
10
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() {
...
}
}

为了启用Hibernate,我们需要创建一个LocalSessionFactoryBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AppConfig {
@Bean
LocalSessionFactoryBean createSessionFactory(@Autowired DataSource dataSource) {
var props = new Properties();
props.setProperty("hibernate.hbm2ddl.auto", "update"); // 生产环境不要使用
props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
props.setProperty("hibernate.show_sql", "true");
var sessionFactoryBean = new LocalSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
// 扫描指定的package获取所有entity class:
sessionFactoryBean.setPackagesToScan("com.itranswarp.learnjava.entity");
sessionFactoryBean.setHibernateProperties(props);
return sessionFactoryBean;
}
}

注意我们在定制Bean中讲到过FactoryBeanLocalSessionFactoryBean是一个FactoryBean,它会再自动创建一个SessionFactory,在Hibernate中,Session是封装了一个JDBC Connection的实例,而SessionFactory是封装了JDBC DataSource的实例,即SessionFactory持有连接池,每次需要操作数据库的时候,SessionFactory创建一个新的Session,相当于从连接池获取到一个新的ConnectionSessionFactory就是Hibernate提供的最核心的一个对象,但LocalSessionFactoryBean是Spring提供的为了让我们方便创建SessionFactory的类。

注意到上面创建LocalSessionFactoryBean的代码,首先用Properties持有Hibernate初始化SessionFactory时用到的所有设置,常用的设置请参考Hibernate文档,这里我们只定义了3个设置:

  • hibernate.hbm2ddl.auto=update:表示自动创建数据库的表结构,注意不要在生产环境中启用;
  • hibernate.dialect=org.hibernate.dialect.HSQLDialect:指示Hibernate使用的数据库是HSQLDB。Hibernate使用一种HQL的查询语句,它和SQL类似,但真正在“翻译”成SQL时,会根据设定的数据库“方言”来生成针对数据库优化的SQL;
  • hibernate.show_sql=true:让Hibernate打印执行的SQL,这对于调试非常有用,我们可以方便地看到Hibernate生成的SQL语句是否符合我们的预期。

除了设置DataSourceProperties之外,注意到setPackagesToScan()我们传入了一个package名称,它指示Hibernate扫描这个包下面的所有Java类,自动找出能映射为数据库表记录的JavaBean。后面我们会仔细讨论如何编写符合Hibernate要求的JavaBean。

紧接着,我们还需要创建HibernateTransactionManager

1
2
3
4
5
6
public class AppConfig {
@Bean
PlatformTransactionManager createTxManager(@Autowired SessionFactory sessionFactory) {
return new HibernateTransactionManager(sessionFactory);
}
}

HibernateTransactionManager是配合Hibernate使用声明式事务所必须的。到此为止,所有的配置都定义完毕,我们来看看如何将数据库表结构映射为Java对象。

考察如下的数据库表:

1
2
3
4
5
6
7
8
9
CREATE TABLE user
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(100) NOT NULL,
password VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
createdAt BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
);

其中,id是自增主键,emailpasswordnameVARCHAR类型,email带唯一索引以确保唯一性,createdAt存储整型类型的时间戳。用JavaBean表示如下:

1
2
3
4
5
6
7
8
9
10
public class User {
private Long id;
private String email;
private String password;
private String name;
private Long createdAt;

// getters and setters
...
}

这种映射关系十分易懂,但我们需要添加一些注解来告诉Hibernate如何把User类映射到表记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
public Long getId() { ... }

@Column(nullable = false, unique = true, length = 100)
public String getEmail() { ... }

@Column(nullable = false, length = 100)
public String getPassword() { ... }

@Column(nullable = false, length = 100)
public String getName() { ... }

@Column(nullable = false, updatable = false)
public Long getCreatedAt() { ... }
}

如果一个JavaBean被用于映射,我们就标记一个@Entity。默认情况下,映射的表名是user,如果实际的表名不同,例如实际表名是users,可以追加一个@Table(name="users")表示:

1
2
3
4
5
@Entity
@Table(name="users")
public class User {
...
}

每个属性到数据库列的映射用@Column()标识,nullable指示列是否允许为NULLupdatable指示该列是否允许被用在UPDATE语句,length指示String类型的列的长度(如果没有指定,默认是255)。

对于主键,还需要用@Id标识,自增主键再追加一个@GeneratedValue,以便Hibernate能读取到自增主键的值。

细心的童鞋可能还注意到,主键id定义的类型不是long,而是Long。这是因为Hibernate如果检测到主键为null,就不会在INSERT语句中指定主键的值,而是返回由数据库生成的自增值,否则,Hibernate认为我们的程序指定了主键的值,会在INSERT语句中直接列出。long型字段总是具有默认值0,因此,每次插入的主键值总是0,导致除第一次外后续插入都将失败。

createdAt虽然是整型,但我们并没有使用long,而是Long,这是因为使用基本类型会导致findByExample查询会添加意外的条件,这里只需牢记,作为映射使用的JavaBean,所有属性都使用包装类型而不是基本类型。

注意

使用Hibernate时,不要使用基本类型的属性,总是使用包装类型,如Long或Integer。

类似的,我们再定义一个Book类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
public Long getId() { ... }

@Column(nullable = false, length = 100)
public String getTitle() { ... }

@Column(nullable = false, updatable = false)
public Long getCreatedAt() { ... }
}

如果仔细观察UserBook,会发现它们定义的idcreatedAt属性是一样的,这在数据库表结构的设计中很常见:对于每个表,通常我们会统一使用一种主键生成机制,并添加createdAt表示创建时间,updatedAt表示修改时间等通用字段。

不必在UserBook中重复定义这些通用字段,我们可以把它们提到一个抽象类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@MappedSuperclass
public abstract class AbstractEntity {

private Long id;
private Long createdAt;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
public Long getId() { ... }

@Column(nullable = false, updatable = false)
public Long getCreatedAt() { ... }

@Transient
public ZonedDateTime getCreatedDateTime() {
return Instant.ofEpochMilli(this.createdAt).atZone(ZoneId.systemDefault());
}

@PrePersist
public void preInsert() {
setCreatedAt(System.currentTimeMillis());
}
}

对于AbstractEntity来说,我们要标注一个@MappedSuperclass表示它用于继承。此外,注意到我们定义了一个@Transient方法,它返回一个“虚拟”的属性。因为getCreatedDateTime()是计算得出的属性,而不是从数据库表读出的值,因此必须要标注@Transient,否则Hibernate会尝试从数据库读取名为createdDateTime这个不存在的字段从而出错。

再注意到@PrePersist标识的方法,它表示在我们将一个JavaBean持久化到数据库之前(即执行INSERT语句),Hibernate会先执行该方法,这样我们就可以自动设置好createdAt属性。

有了AbstractEntity,我们就可以大幅简化UserBook

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
public class User extends AbstractEntity {

@Column(nullable = false, unique = true, length = 100)
public String getEmail() { ... }

@Column(nullable = false, length = 100)
public String getPassword() { ... }

@Column(nullable = false, length = 100)
public String getName() { ... }
}

注意到使用的所有注解均来自jakarta.persistence,它是JPA规范的一部分。这里我们只介绍使用注解的方式配置Hibernate映射关系,不再介绍传统的比较繁琐的XML配置。通过Spring集成Hibernate时,也不再需要hibernate.cfg.xml配置文件,用一句话总结:

提示

使用Spring集成Hibernate,配合JPA注解,无需任何额外的XML配置。

类似UserBook这样的用于ORM的Java Bean,我们通常称之为Entity Bean。

最后,我们来看看如果对user表进行增删改查。因为使用了Hibernate,因此,我们要做的,实际上是对User这个JavaBean进行“增删改查”。我们编写一个UserService,注入SessionFactory

1
2
3
4
5
6
@Component
@Transactional
public class UserService {
@Autowired
SessionFactory sessionFactory;
}

Insert操作

要持久化一个User实例,我们只需调用persist()方法。以register()方法为例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public User register(String email, String password, String name) {
// 创建一个User对象:
User user = new User();
// 设置好各个属性:
user.setEmail(email);
user.setPassword(password);
user.setName(name);
// 不要设置id,因为使用了自增主键
// 保存到数据库:
sessionFactory.getCurrentSession().persist(user);
// 现在已经自动获得了id:
System.out.println(user.getId());
return user;
}

Delete操作

删除一个User相当于从表中删除对应的记录。注意Hibernate总是用id来删除记录,因此,要正确设置Userid属性才能正常删除记录:

1
2
3
4
5
6
7
8
public boolean deleteUser(Long id) {
User user = sessionFactory.getCurrentSession().byId(User.class).load(id);
if (user != null) {
sessionFactory.getCurrentSession().remove(user);
return true;
}
return false;
}

通过主键删除记录时,一个常见的用法是先根据主键加载该记录,再删除。注意到当记录不存在时,load()返回null

Update操作

更新记录相当于先更新User的指定属性,然后调用merge()方法:

1
2
3
4
5
public void updateUser(Long id, String name) {
User user = sessionFactory.getCurrentSession().byId(User.class).load(id);
user.setName(name);
sessionFactory.getCurrentSession().merge(user);
}

前面我们在定义User时,对有的属性标注了@Column(updatable=false)。Hibernate在更新记录时,它只会把@Column(updatable=true)的属性加入到UPDATE语句中,这样可以提供一层额外的安全性,即如果不小心修改了UseremailcreatedAt等属性,执行update()时并不会更新对应的数据库列。但也必须牢记:这个功能是Hibernate提供的,如果绕过Hibernate直接通过JDBC执行UPDATE语句仍然可以更新数据库的任意列的值。

最后,我们编写的大部分方法都是各种各样的查询。根据id查询我们可以直接调用load(),如果要使用条件查询,例如,假设我们想执行以下查询:

1
SELECT * FROM user WHERE email = ? AND password = ?

我们来看看可以使用什么查询。

使用HQL查询

一种常用的查询是直接编写Hibernate内置的HQL查询:

1
2
3
4
List<User> list = sessionFactory.getCurrentSession()
.createQuery("from User u where u.email = ?1 and u.password = ?2", User.class)
.setParameter(1, email).setParameter(2, password)
.list();

和SQL相比,HQL使用类名和属性名,由Hibernate自动转换为实际的表名和列名。详细的HQL语法可以参考Hibernate文档

除了可以直接传入HQL字符串外,Hibernate还可以使用一种NamedQuery,它给查询起个名字,然后保存在注解中。使用NamedQuery时,我们要先在User类标注:

1
2
3
4
5
6
7
8
9
10
11
12
@NamedQueries(
@NamedQuery(
// 查询名称:
name = "login",
// 查询语句:
query = "SELECT u FROM User u WHERE u.email = :e AND u.password = :pwd"
)
)
@Entity
public class User extends AbstractEntity {
...
}

注意到引入的NamedQueryjakarta.persistence.NamedQuery,它和直接传入HQL有点不同的是,占位符使用:e:pwd

使用NamedQuery只需要引入查询名和参数:

1
2
3
4
5
6
7
8
public User login(String email, String password) {
List<User> list = sessionFactory.getCurrentSession()
.createNamedQuery("login", User.class) // 创建NamedQuery
.setParameter("e", email) // 绑定e参数
.setParameter("pwd", password) // 绑定pwd参数
.list();
return list.isEmpty() ? null : list.get(0);
}

直接写HQL和使用NamedQuery各有优劣。前者可以在代码中直观地看到查询语句,后者可以在User类统一管理所有相关查询。

练习

集成Hibernate操作数据库。

下载练习

小结

在Spring中集成Hibernate需要配置的Bean如下:

  • DataSource;
  • LocalSessionFactory;
  • HibernateTransactionManager。

推荐使用Annotation配置所有的Entity Bean。

上一节我们讲了在Spring中集成Hibernate。Hibernate是第一个被广泛使用的ORM框架,但是很多小伙伴还听说过JPA:Java Persistence API,这又是啥?

在讨论JPA之前,我们要注意到JavaEE早在1999年就发布了,并且有Servlet、JMS等诸多标准。和其他平台不同,Java世界早期非常热衷于标准先行,各家跟进:大家先坐下来把接口定了,然后,各自回家干活去实现接口,这样,用户就可以在不同的厂家提供的产品进行选择,还可以随意切换,因为用户编写代码的时候只需要引用接口,并不需要引用具体的底层实现(想想JDBC)。

JPA就是JavaEE的一个ORM标准,它的实现其实和Hibernate没啥本质区别,但是用户如果使用JPA,那么引用的就是jakarta.persistence这个“标准”包,而不是org.hibernate这样的第三方包。因为JPA只是接口,所以,还需要选择一个实现产品,跟JDBC接口和MySQL驱动一个道理。

我们使用JPA时也完全可以选择Hibernate作为底层实现,但也可以选择其它的JPA提供方,比如EclipseLink。Spring内置了JPA的集成,并支持选择Hibernate或EclipseLink作为实现。这里我们仍然以主流的Hibernate作为JPA实现为例子,演示JPA的基本用法。

和使用Hibernate一样,我们只需要引入如下依赖:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-orm:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • jakarta.persistence:jakarta.persistence-api:3.1.0
  • org.hibernate:hibernate-core:6.1.4.Final
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.1

实际上我们这里引入的依赖和上一节集成Hibernate引入的依赖完全一样,因为Hibernate既提供了它自己的接口,也提供了JPA接口,我们用JPA接口就相当于通过JPA操作Hibernate。

然后,在AppConfig中启用声明式事务管理,创建DataSource

1
2
3
4
5
6
7
8
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() { ... }
}

使用Hibernate时,我们需要创建一个LocalSessionFactoryBean,并让它再自动创建一个SessionFactory。使用JPA也是类似的,我们也创建一个LocalContainerEntityManagerFactoryBean,并让它再自动创建一个EntityManagerFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Bean
public LocalContainerEntityManagerFactoryBean createEntityManagerFactory(@Autowired DataSource dataSource) {
var emFactory = new LocalContainerEntityManagerFactoryBean();
// 注入DataSource:
emFactory.setDataSource(dataSource);
// 扫描指定的package获取所有entity class:
emFactory.setPackagesToScan(AbstractEntity.class.getPackageName());
// 使用Hibernate作为JPA实现:
emFactory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
// 其他配置项:
var props = new Properties();
props.setProperty("hibernate.hbm2ddl.auto", "update"); // 生产环境不要使用
props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
props.setProperty("hibernate.show_sql", "true");
emFactory.setJpaProperties(props);
return emFactory;
}

观察上述代码,除了需要注入DataSource和设定自动扫描的package外,还需要指定JPA的提供商,这里使用Spring提供的一个HibernateJpaVendorAdapter,最后,针对Hibernate自己需要的配置,以Properties的形式注入。

最后,我们还需要实例化一个JpaTransactionManager,以实现声明式事务:

1
2
3
4
@Bean
PlatformTransactionManager createTxManager(@Autowired EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}

这样,我们就完成了JPA的全部初始化工作。有些童鞋可能从网上搜索得知JPA需要persistence.xml配置文件,以及复杂的orm.xml文件。这里我们负责地告诉大家,使用Spring+Hibernate作为JPA实现,无需任何配置文件。

所有Entity Bean的配置和上一节完全相同,全部采用Annotation标注。我们现在只需关心具体的业务类如何通过JPA接口操作数据库。

还是以UserService为例,除了标注@Component@Transactional外,我们需要注入一个EntityManager,但是不要使用Autowired,而是@PersistenceContext

1
2
3
4
5
6
@Component
@Transactional
public class UserService {
@PersistenceContext
EntityManager em;
}

我们回顾一下JDBC、Hibernate和JPA提供的接口,实际上,它们的关系如下:

JDBC Hibernate JPA
DataSource SessionFactory EntityManagerFactory
Connection Session EntityManager

SessionFactoryEntityManagerFactory相当于DataSourceSessionEntityManager相当于Connection。每次需要访问数据库的时候,需要获取新的SessionEntityManager,用完后再关闭。

但是,注意到UserService注入的不是EntityManagerFactory,而是EntityManager,并且标注了@PersistenceContext。难道使用JPA可以允许多线程操作同一个EntityManager

实际上这里注入的并不是真正的EntityManager,而是一个EntityManager的代理类,相当于:

1
2
3
public class EntityManagerProxy implements EntityManager {
private EntityManagerFactory emf;
}

Spring遇到标注了@PersistenceContextEntityManager会自动注入代理,该代理会在必要的时候自动打开EntityManager。换句话说,多线程引用的EntityManager虽然是同一个代理类,但该代理类内部针对不同线程会创建不同的EntityManager实例。

简单总结一下,标注了@PersistenceContextEntityManager可以被多线程安全地共享。

因此,在UserService的每个业务方法里,直接使用EntityManager就很方便。以主键查询为例:

1
2
3
4
5
6
7
public User getUserById(long id) {
User user = this.em.find(User.class, id);
if (user == null) {
throw new RuntimeException("User not found by id: " + id);
}
return user;
}

与HQL查询类似,JPA使用JPQL查询,它的语法和HQL基本差不多:

1
2
3
4
5
6
7
8
9
10
public User fetchUserByEmail(String email) {
// JPQL查询:
TypedQuery<User> query = em.createQuery("SELECT u FROM User u WHERE u.email = :e", User.class);
query.setParameter("e", email);
List<User> list = query.getResultList();
if (list.isEmpty()) {
return null;
}
return list.get(0);
}

同样的,JPA也支持NamedQuery,即先给查询起个名字,再按名字创建查询:

1
2
3
4
5
6
7
public User login(String email, String password) {
TypedQuery<User> query = em.createNamedQuery("login", User.class);
query.setParameter("e", email);
query.setParameter("pwd", password);
List<User> list = query.getResultList();
return list.isEmpty() ? null : list.get(0);
}

NamedQuery通过注解标注在User类上,它的定义和上一节的User类一样:

1
2
3
4
5
6
7
8
9
10
@NamedQueries(
@NamedQuery(
name = "login",
query = "SELECT u FROM User u WHERE u.email=:e AND u.password=:pwd"
)
)
@Entity
public class User {
...
}

对数据库进行增删改的操作,可以分别使用persist()remove()merge()方法,参数均为Entity Bean本身,使用非常简单,这里不再多述。

练习

使用JPA操作数据库。

下载练习

小结

在Spring中集成JPA要选择一个实现,可以选择Hibernate或EclipseLink;

使用JPA与Hibernate类似,但注入的核心资源是带有@PersistenceContext注解的EntityManager代理类。

使用Hibernate或JPA操作数据库时,这类ORM干的主要工作就是把ResultSet的每一行变成Java Bean,或者把Java Bean自动转换到INSERT或UPDATE语句的参数中,从而实现ORM。

而ORM框架之所以知道如何把行数据映射到Java Bean,是因为我们在Java Bean的属性上给了足够的注解作为元数据,ORM框架获取Java Bean的注解后,就知道如何进行双向映射。

那么,ORM框架是如何跟踪Java Bean的修改,以便在update()操作中更新必要的属性?

答案是使用Proxy模式,从ORM框架读取的User实例实际上并不是User类,而是代理类,代理类继承自User类,但针对每个setter方法做了覆写:

1
2
3
4
5
6
7
8
public class UserProxy extends User {
boolean _isNameChanged;

public void setName(String name) {
super.setName(name);
_isNameChanged = true;
}
}

这样,代理类可以跟踪到每个属性的变化。

针对一对多或多对一关系时,代理类可以直接通过getter方法查询数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class UserProxy extends User {
Session _session;
boolean _isNameChanged;

public void setName(String name) {
super.setName(name);
_isNameChanged = true;
}

/**
* 获取User对象关联的Address对象:
*/
public Address getAddress() {
Query q = _session.createQuery("from Address where userId = :userId");
q.setParameter("userId", this.getId());
List<Address> list = query.list();
return list.isEmpty() ? null : list(0);
}
}

为了实现这样的查询,UserProxy必须保存Hibernate的当前Session。但是,当事务提交后,Session自动关闭,此时再获取getAddress()将无法访问数据库,或者获取的不是事务一致的数据。因此,ORM框架总是引入了Attached/Detached状态,表示当前此Java Bean到底是在Session的范围内,还是脱离了Session变成了一个“游离”对象。很多初学者无法正确理解状态变化和事务边界,就会造成大量的PersistentObjectException异常。这种隐式状态使得普通Java Bean的生命周期变得复杂。

此外,Hibernate和JPA为了实现兼容多种数据库,它使用HQL或JPQL查询,经过一道转换,变成特定数据库的SQL,理论上这样可以做到无缝切换数据库,但这一层自动转换除了少许的性能开销外,给SQL级别的优化带来了麻烦。

最后,ORM框架通常提供了缓存,并且还分为一级缓存和二级缓存。一级缓存是指在一个Session范围内的缓存,常见的情景是根据主键查询时,两次查询可以返回同一实例:

1
2
User user1 = session.load(User.class, 123);
User user2 = session.load(User.class, 123);

二级缓存是指跨Session的缓存,一般默认关闭,需要手动配置。二级缓存极大的增加了数据的不一致性,原因在于SQL非常灵活,常常会导致意外的更新。例如:

1
2
3
4
5
// 线程1读取:
User user1 = session1.load(User.class, 123);
...
// 一段时间后,线程2读取:
User user2 = session2.load(User.class, 123);

当二级缓存生效的时候,两个线程读取的User实例是一样的,但是,数据库对应的行记录完全可能被修改,例如:

1
2
-- 给老用户增加100积分:
UPDATE users SET bonus = bonus + 100 WHERE createdAt <= ?

ORM无法判断id=123的用户是否受该UPDATE语句影响。考虑到数据库通常会支持多个应用程序,此UPDATE语句可能由其他进程执行,ORM框架就更不知道了。

我们把这种ORM框架称之为全自动ORM框架。

对比Spring提供的JdbcTemplate,它和ORM框架相比,主要有几点差别:

  1. 查询后需要手动提供Mapper实例以便把ResultSet的每一行变为Java对象;
  2. 增删改操作所需的参数列表,需要手动传入,即把User实例变为[user.id, user.name, user.email]这样的列表,比较麻烦。

但是JdbcTemplate的优势在于它的确定性:即每次读取操作一定是数据库操作而不是缓存,所执行的SQL是完全确定的,缺点就是代码比较繁琐,构造INSERT INTO users VALUES (?,?,?)更是复杂。

所以,介于全自动ORM如Hibernate和手写全部如JdbcTemplate之间,还有一种半自动的ORM,它只负责把ResultSet自动映射到Java Bean,或者自动填充Java Bean参数,但仍需自己写出SQL。MyBatis就是这样一种半自动化ORM框架。

我们来看看如何在Spring中集成MyBatis。

首先,我们要引入MyBatis本身,其次,由于Spring并没有像Hibernate那样内置对MyBatis的集成,所以,我们需要再引入MyBatis官方自己开发的一个与Spring集成的库:

  • org.mybatis:mybatis:3.5.11
  • org.mybatis:mybatis-spring:3.0.0

和前面一样,先创建DataSource是必不可少的:

1
2
3
4
5
6
7
8
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() { ... }
}

再回顾一下Hibernate和JPA的SessionFactoryEntityManagerFactory,MyBatis与之对应的是SqlSessionFactorySqlSession

JDBC Hibernate JPA MyBatis
DataSource SessionFactory EntityManagerFactory SqlSessionFactory
Connection Session EntityManager SqlSession

可见,ORM的设计套路都是类似的。使用MyBatis的核心就是创建SqlSessionFactory,这里我们需要创建的是SqlSessionFactoryBean

1
2
3
4
5
6
@Bean
SqlSessionFactoryBean createSqlSessionFactoryBean(@Autowired DataSource dataSource) {
var sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
return sqlSessionFactoryBean;
}

因为MyBatis可以直接使用Spring管理的声明式事务,因此,创建事务管理器和使用JDBC是一样的:

1
2
3
4
@Bean
PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}

和Hibernate不同的是,MyBatis使用Mapper来实现映射,而且Mapper必须是接口。我们以User类为例,在User类和users表之间映射的UserMapper编写如下:

1
2
3
4
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
User getById(@Param("id") long id);
}

注意:这里的Mapper不是JdbcTemplateRowMapper的概念,它是定义访问users表的接口方法。比如我们定义了一个User getById(long)的主键查询方法,不仅要定义接口方法本身,还要明确写出查询的SQL,这里用注解@Select标记。SQL语句的任何参数,都与方法参数按名称对应。例如,方法参数id的名字通过注解@Param()标记为id,则SQL语句里将来替换的占位符就是#{id}

如果有多个参数,那么每个参数命名后直接在SQL中写出对应的占位符即可:

1
2
@Select("SELECT * FROM users LIMIT #{offset}, #{maxResults}")
List<User> getAll(@Param("offset") int offset, @Param("maxResults") int maxResults);

注意:MyBatis执行查询后,将根据方法的返回类型自动把ResultSet的每一行转换为User实例,转换规则当然是按列名和属性名对应。如果列名和属性名不同,最简单的方式是编写SELECT语句的别名:

1
2
-- 列名是created_time,属性名是createdAt:
SELECT id, name, email, created_time AS createdAt FROM users

执行INSERT语句就稍微麻烦点,因为我们希望传入User实例,因此,定义的方法接口与@Insert注解如下:

1
2
@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);

上述方法传入的参数名称是user,参数类型是User类,在SQL中引用的时候,以#{obj.property}的方式写占位符。和Hibernate这样的全自动化ORM相比,MyBatis必须写出完整的INSERT语句。

如果users表的id是自增主键,那么,我们在SQL中不传入id,但希望获取插入后的主键,需要再加一个@Options注解:

1
2
3
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);

keyPropertykeyColumn分别指出JavaBean的属性和数据库的主键列名。

执行UPDATEDELETE语句相对比较简单,我们定义方法如下:

1
2
3
4
5
@Update("UPDATE users SET name = #{user.name}, createdAt = #{user.createdAt} WHERE id = #{user.id}")
void update(@Param("user") User user);

@Delete("DELETE FROM users WHERE id = #{id}")
void deleteById(@Param("id") long id);

有了UserMapper接口,还需要对应的实现类才能真正执行这些数据库操作的方法。虽然可以自己写实现类,但我们除了编写UserMapper接口外,还有BookMapperBonusMapper……一个一个写太麻烦,因此,MyBatis提供了一个MapperFactoryBean来自动创建所有Mapper的实现类。可以用一个简单的注解来启用它:

1
2
3
4
5
@MapperScan("com.itranswarp.learnjava.mapper")
...其他注解...
public class AppConfig {
...
}

有了@MapperScan,就可以让MyBatis自动扫描指定包的所有Mapper并创建实现类。在真正的业务逻辑中,我们可以直接注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
@Transactional
public class UserService {
// 注入UserMapper:
@Autowired
UserMapper userMapper;

public User getUserById(long id) {
// 调用Mapper方法:
User user = userMapper.getById(id);
if (user == null) {
throw new RuntimeException("User not found by id.");
}
return user;
}
}

可见,业务逻辑主要就是通过XxxMapper定义的数据库方法来访问数据库。

XML配置

上述在Spring中集成MyBatis的方式,我们只需要用到注解,并没有任何XML配置文件。MyBatis也允许使用XML配置映射关系和SQL语句,例如,更新User时根据属性值构造动态SQL:

1
2
3
4
5
6
7
8
9
<update id="updateUser">
UPDATE users SET
<set>
<if test="user.name != null"> name = #{user.name} </if>
<if test="user.hobby != null"> hobby = #{user.hobby} </if>
<if test="user.summary != null"> summary = #{user.summary} </if>
</set>
WHERE id = #{user.id}
</update>

编写XML配置的优点是可以组装出动态SQL,并且把所有SQL操作集中在一起。缺点是配置起来太繁琐,调用方法时如果想查看SQL还需要定位到XML配置中。这里我们不介绍XML的配置方式,需要了解的童鞋请自行阅读官方文档

使用MyBatis最大的问题是所有SQL都需要全部手写,优点是执行的SQL就是我们自己写的SQL,对SQL进行优化非常简单,也可以编写任意复杂的SQL,或者使用数据库的特定语法,但切换数据库可能就不太容易。好消息是大部分项目并没有切换数据库的需求,完全可以针对某个数据库编写尽可能优化的SQL。

练习

集成MyBatis操作数据库。

下载练习

小结

MyBatis是一个半自动化的ORM框架,需要手写SQL语句,没有自动加载一对多或多对一关系的功能。

我们从前几节可以看到,所谓ORM,也是建立在JDBC的基础上,通过ResultSet到JavaBean的映射,实现各种查询。有自动跟踪Entity修改的全自动化ORM如Hibernate和JPA,需要为每个Entity创建代理,也有完全自己映射,连INSERT和UPDATE语句都需要手动编写的MyBatis,但没有任何透明的Proxy。

而查询是涉及到数据库使用最广泛的操作,需要最大的灵活性。各种ORM解决方案各不相同,Hibernate和JPA自己实现了HQL和JPQL查询语法,用以生成最终的SQL,而MyBatis则完全手写,每增加一个查询都需要先编写SQL并增加接口方法。

还有一种Hibernate和JPA支持的Criteria查询,用Hibernate写出来类似:

1
2
3
4
DetachedCriteria criteria = DetachedCriteria.forClass(User.class);
criteria.add(Restrictions.eq("email", email))
.add(Restrictions.eq("password", password));
List<User> list = (List<User>) hibernateTemplate.findByCriteria(criteria);

上述Criteria查询写法复杂,但和JPA相比,还是小巫见大巫了:

1
2
3
4
5
6
7
var cb = em.getCriteriaBuilder();
CriteriaQuery<User> q = cb.createQuery(User.class);
Root<User> r = q.from(User.class);
q.where(cb.equal(r.get("email"), cb.parameter(String.class, "e")));
TypedQuery<User> query = em.createQuery(q);
query.setParameter("e", email);
List<User> list = query.getResultList();

此外,是否支持自动读取一对多和多对一关系也是全自动化ORM框架的一个重要功能。

如果我们自己来设计并实现一个ORM,应该吸取这些ORM的哪些特色,然后高效实现呢?

设计ORM接口

任何设计,都必须明确设计目标。这里我们准备实现的ORM并不想要全自动ORM那种自动读取一对多和多对一关系的功能,也不想给Entity加上复杂的状态,因此,对于Entity来说,它就是纯粹的JavaBean,没有任何Proxy。

此外,ORM要兼顾易用性和适用性。易用性是指能覆盖95%的应用场景,但总有一些复杂的SQL,很难用ORM去自动生成,因此,也要给出原生的JDBC接口,能支持5%的特殊需求。

最后,我们希望设计的接口要易于编写,并使用流式API便于阅读。为了配合编译器检查,还应该支持泛型,避免强制转型。

以User类为例,我们设计的查询接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 按主键查询: SELECT * FROM users WHERE id = ?
User u = db.get(User.class, 123);

// 条件查询唯一记录: SELECT * FROM users WHERE email = ? AND password = ?
User u = db.from(User.class)
.where("email=? AND password=?", "bob@example.com", "bob123")
.unique();

// 条件查询多条记录: SELECT * FROM users WHERE id < ? ORDER BY email LIMIT ?, ?
List<User> us = db.from(User.class)
.where("id < ?", 1000)
.orderBy("email")
.limit(0, 10)
.list();

// 查询特定列: SELECT id, name FROM users WHERE email = ?
User u = db.select("id", "name")
.from(User.class)
.where("email = ?", "bob@example.com")
.unique();

这样的流式API便于阅读,也非常容易推导出最终生成的SQL。

对于插入、更新和删除操作,就相对比较简单:

1
2
3
4
5
6
7
8
// 插入User:
db.insert(user);

// 按主键更新更新User:
db.update(user);

// 按主键删除User:
db.delete(User.class, 123);

对于Entity来说,通常一个表对应一个。手动列出所有Entity是非常麻烦的,一定要传入package自动扫描。

最后,ORM总是需要元数据才能知道如何映射。我们不想编写复杂的XML配置,也没必要自己去定义一套规则,直接使用JPA的注解就行。

实现ORM

我们并不需要从JDBC底层开始编写,并且,还要考虑到事务,最好能直接使用Spring的声明式事务。实际上,我们可以设计一个全局DbTemplate,它注入了Spring的JdbcTemplate,涉及到数据库操作时,全部通过JdbcTemplate完成,自然天生支持Spring的声明式事务,因为这个ORM只是在JdbcTemplate的基础上做了一层封装。

AppConfig中,我们初始化所有Bean如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() { ... }

@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}

@Bean
DbTemplate createDbTemplate(@Autowired JdbcTemplate jdbcTemplate) {
return new DbTemplate(jdbcTemplate, "com.itranswarp.learnjava.entity");
}

@Bean
PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}

以上就是我们所需的所有配置。

编写业务逻辑,例如UserService,写出来像这样:

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
@Component
@Transactional
public class UserService {
@Autowired
DbTemplate db;

public User getUserById(long id) {
return db.get(User.class, id);
}

public User getUserByEmail(String email) {
return db.from(User.class)
.where("email = ?", email)
.unique();
}

public List<User> getUsers(int pageIndex) {
int pageSize = 100;
return db.from(User.class)
.orderBy("id")
.limit((pageIndex - 1) * pageSize, pageSize)
.list();
}

public User register(String email, String password, String name) {
User user = new User();
user.setEmail(email);
user.setPassword(password);
user.setName(name);
user.setCreatedAt(System.currentTimeMillis());
db.insert(user);
return user;
}
...
}

上述代码给出了ORM的接口,以及如何在业务逻辑中使用ORM。下一步,就是如何实现这个DbTemplate。这里我们只给出框架代码,有兴趣的童鞋可以自己实现核心代码:

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
public class DbTemplate {
private JdbcTemplate jdbcTemplate;

// 保存Entity Class到Mapper的映射:
private Map<Class<?>, Mapper<?>> classMapping;

public <T> T fetch(Class<T> clazz, Object id) {
Mapper<T> mapper = getMapper(clazz);
List<T> list = (List<T>) jdbcTemplate.query(mapper.selectSQL, new Object[] { id }, mapper.rowMapper);
if (list.isEmpty()) {
return null;
}
return list.get(0);
}

public <T> T get(Class<T> clazz, Object id) {
...
}

public <T> void insert(T bean) {
...
}

public <T> void update(T bean) {
...
}

public <T> void delete(Class<T> clazz, Object id) {
...
}
}

实现链式API的核心代码是第一步从DbTemplate调用select()from()时实例化一个CriteriaQuery实例,并在后续的链式调用中设置它的字段:

1
2
3
4
5
6
7
8
9
10
11
public class DbTemplate {
...
public Select select(String... selectFields) {
return new Select(new Criteria(this), selectFields);
}

public <T> From<T> from(Class<T> entityClass) {
Mapper<T> mapper = getMapper(entityClass);
return new From<>(new Criteria<>(this), mapper);
}
}

然后以此定义SelectFromWhereOrderByLimit等。在From中可以设置Class类型、表名等:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class From<T> extends CriteriaQuery<T> {
From(Criteria<T> criteria, Mapper<T> mapper) {
super(criteria);
// from可以设置class、tableName:
this.criteria.mapper = mapper;
this.criteria.clazz = mapper.entityClass;
this.criteria.table = mapper.tableName;
}

public Where<T> where(String clause, Object... args) {
return new Where<>(this.criteria, clause, args);
}
}

Where中可以设置条件参数:

1
2
3
4
5
6
7
8
9
10
11
public final class Where<T> extends CriteriaQuery<T> {
Where(Criteria<T> criteria, String clause, Object... params) {
super(criteria);
this.criteria.where = clause;
this.criteria.whereParams = new ArrayList<>();
// add:
for (Object param : params) {
this.criteria.whereParams.add(param);
}
}
}

最后,链式调用的尽头是调用list()返回一组结果,调用unique()返回唯一结果,调用first()返回首个结果。

在IDE中,可以非常方便地实现链式调用:

db

需要复杂查询的时候,总是可以使用JdbcTemplate执行任意复杂的SQL。

练习

设计并实现一个微型ORM。

下载练习

小结

ORM框架就是自动映射数据库表结构到JavaBean的工具,设计并实现一个简单高效的ORM框架并不困难。

留言與分享

JAVA-Spring开发-AOP

分類 编程语言, Java

AOP是Aspect Oriented Programming,即面向切面编程。

那什么是AOP?

我们先回顾一下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承和多态。

而AOP是一种新的编程方式,它和OOP不同,OOP把系统看作多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。

要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:

  • createBook:添加新的Book;
  • updateBook:修改Book;
  • deleteBook:删除Book。

对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BookService {
public void createBook(Book book) {
securityCheck();
Transaction tx = startTransaction();
try {
// 核心业务逻辑
tx.commit();
} catch (RuntimeException e) {
tx.rollback();
throw e;
}
log("created book: " + book);
}
}

继续编写updateBook(),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BookService {
public void updateBook(Book book) {
securityCheck();
Transaction tx = startTransaction();
try {
// 核心业务逻辑
tx.commit();
} catch (RuntimeException e) {
tx.rollback();
throw e;
}
log("updated book: " + book);
}
}

对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。

考察业务模型可以发现,BookService关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。

一种可行的方式是使用Proxy模式,将某个功能,例如,权限检查,放入Proxy中:

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
public class SecurityCheckBookService implements BookService {
private final BookService target;

public SecurityCheckBookService(BookService target) {
this.target = target;
}

public void createBook(Book book) {
securityCheck();
target.createBook(book);
}

public void updateBook(Book book) {
securityCheck();
target.updateBook(book);
}

public void deleteBook(Book book) {
securityCheck();
target.deleteBook(book);
}

private void securityCheck() {
...
}
}

这种方式的缺点是比较麻烦,必须先抽取接口,然后,针对每个方法实现Proxy。

另一种方法是,既然SecurityCheckBookService的代码都是标准的Proxy样板代码,不如把权限检查视作一种切面(Aspect),把日志、事务也视为切面,然后,以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。

如果我们以AOP的视角来编写上述业务,可以依次实现:

  1. 核心逻辑,即BookService;
  2. 切面逻辑,即:
    1. 权限检查的Aspect;
    2. 日志的Aspect;
    3. 事务的Aspect。

然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来,就不必编写复杂而冗长的Proxy模式。

AOP原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。

在Java平台上,对于AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
  2. 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  3. 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。

最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。

AOP技术看上去比较神秘,但实际上,它本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。

需要特别指出的是,AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。

在AOP编程中,我们经常会遇到下面的概念:

  • Aspect:切面,即一个横跨多个核心逻辑的功能,或者称之为系统关注点;
  • Joinpoint:连接点,即定义在应用程序流程的何处插入切面的执行;
  • Pointcut:切入点,即一组连接点的集合;
  • Advice:增强,指特定连接点上执行的动作;
  • Introduction:引介,指为一个已有的Java对象动态地增加新的接口;
  • Weaving:织入,指将切面整合到程序的执行流程中;
  • Interceptor:拦截器,是一种实现增强的方式;
  • Target Object:目标对象,即真正执行业务的核心逻辑对象;
  • AOP Proxy:AOP代理,是客户端持有的增强后的对象引用。

看完上述术语,是不是感觉对AOP有了进一步的困惑?其实,我们不用关心AOP创造的“术语”,只需要理解AOP本质上只是一种代理模式的实现方式,在Spring的容器中实现AOP特别方便。

我们以UserServiceMailService为例,这两个属于核心业务逻辑,现在,我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:

首先,我们通过Maven引入Spring对AOP的支持:

  • org.springframework:spring-aspects:6.0.0

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。

然后,我们定义一个LoggingAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Aspect
@Component
public class LoggingAspect {
// 在执行UserService的每个方法前执行:
@Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}

// 在执行MailService的每个方法前后执行:
@Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
System.err.println("[Around] start " + pjp.getSignature());
Object retVal = pjp.proceed();
System.err.println("[Around] done " + pjp.getSignature());
return retVal;
}
}

观察doAccessCheck()方法,我们定义了一个@Before注解,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService的每个public方法前执行doAccessCheck()代码。

再观察doLogging()方法,我们定义了一个@Around注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。

LoggingAspect类的声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前,@Around标注的方法需要注入到MailService的每个public方法执行前后。

紧接着,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:

1
2
3
4
5
6
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
...
}

Spring的IoC容器看到这个注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before@Around等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:

1
2
3
4
5
6
7
8
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
Welcome, test!
[Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
[Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)

这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect的方法)。

有些童鞋会问,LoggingAspect定义的方法,是如何注入到其他Bean的呢?

其实AOP的原理非常简单。我们以LoggingAspect.doAccessCheck()为例,要把它注入到UserService的每个public方法中,最简单的方法是编写一个子类,并持有原始实例的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public UserServiceAopProxy extends UserService {
private UserService target;
private LoggingAspect aspect;

public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
this.target = target;
this.aspect = aspect;
}

public User login(String email, String password) {
// 先执行Aspect的代码:
aspect.doAccessCheck();
// 再执行UserService的逻辑:
return target.login(email, password);
}

public User register(String email, String password, String name) {
aspect.doAccessCheck();
return target.register(email, password, name);
}

...
}

这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService(原始的UserService实例作为内部变量隐藏在UserServiceAopProxy中)。如果我们打印从Spring容器获取的UserService实例类型,它类似UserService$$EnhancerBySpringCGLIB$$1f44e01c,实际上是Spring使用CGLIB动态创建的子类,但对于调用方来说,感觉不到任何区别。

注意

Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。

可见,虽然Spring容器内部实现AOP的逻辑比较复杂(需要使用AspectJ解析注解,并通过CGLIB实现代理类),但我们使用AOP非常简单,一共需要三步:

  1. 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用此方法;
  2. 标记@Component@Aspect
  3. @Configuration类上标注@EnableAspectJAutoProxy

至于AspectJ的注入语法则比较复杂,请参考Spring文档

Spring也提供其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了,所以我们不再作介绍。

拦截器类型

顾名思义,拦截器有以下类型:

  • @Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
  • @After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

练习

使用AOP实现日志。

下载练习

小结

在Spring容器中使用AOP非常简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处触发并执行。

Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。

上一节我们讲解了使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(..))语法来定义应该如何装配AOP。

在实际项目中,这种写法其实很少使用。假设你写了一个SecurityAspect

1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class SecurityAspect {
@Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")
public void check() {
if (SecurityContext.getCurrentUser() == null) {
throw new RuntimeException("check failed");
}
}
}

基本能实现无差别全覆盖,即某个包下面的所有Bean的所有方法都会被这个check()方法拦截。

还有的童鞋喜欢用方法名前缀进行拦截:

1
2
3
4
5
6
7
8
@Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
// 对update开头的方法切换数据源:
String old = setCurrentDataSource("master");
Object retVal = pjp.proceed();
restoreCurrentDataSource(old);
return retVal;
}

这种非精准打击误伤面更大,因为从方法前缀区分是否是数据库操作是非常不可取的。

我们在使用AOP时,要注意到虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且,后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。

使用AOP时,被装配的Bean最好自己能清清楚楚地知道自己被安排了。例如,Spring提供的@Transactional就是一个非常好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class UserService {
// 有事务:
@Transactional
public User createUser(String name) {
...
}

// 无事务:
public boolean isValidName(String name) {
...
}

// 有事务:
@Transactional
public void updateUser(User user) {
...
}
}

或者直接在class级别注解,表示“所有public方法都被安排了”:

1
2
3
4
5
@Component
@Transactional
public class UserService {
...
}

通过@Transactional,某个方法是否启用了事务就一清二楚了。因此,装配AOP的时候,使用注解是最好的方式。

我们以一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:

1
2
3
4
5
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
String value();
}

在需要被监控的关键方法上标注该注解:

1
2
3
4
5
6
7
8
9
@Component
public class UserService {
// 监控register()方法性能:
@MetricTime("register")
public User register(String email, String password, String name) {
...
}
...
}

然后,我们定义MetricAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Aspect
@Component
public class MetricAspect {
@Around("@annotation(metricTime)")
public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
String name = metricTime.value();
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long t = System.currentTimeMillis() - start;
// 写入日志或发送至JMX:
System.err.println("[Metrics] " + name + ": " + t + "ms");
}
}
}

注意metric()方法标注了@Around("@annotation(metricTime)"),它的意思是,符合条件的目标方法是带有@MetricTime注解的方法,因为metric()方法参数类型是MetricTime(注意参数名是metricTime不是MetricTime),我们通过它获取性能监控的名称。

有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。运行代码,输出结果如下:

1
2
Welcome, Bob!
[Metrics] register: 16ms

练习

使用注解+AOP实现性能监控。

下载练习

小结

使用注解实现AOP需要先定义注解,然后使用@Around("@annotation(name)")实现装配;

使用注解既简单,又能明确标识AOP装配,是使用AOP推荐的方式。

无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式

因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。

我们来看一个实际的例子。

假设我们定义了一个UserService的Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class UserService {
// 成员变量:
public final ZoneId zoneId = ZoneId.systemDefault();

// 构造方法:
public UserService() {
System.out.println("UserService(): init...");
System.out.println("UserService(): zoneId = " + this.zoneId);
}

// public方法:
public ZoneId getZoneId() {
return zoneId;
}

// public final方法:
public final ZoneId getFinalZoneId() {
return zoneId;
}
}

再写个MailService,并注入UserService

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MailService {
@Autowired
UserService userService;

public String sendMail() {
ZoneId zoneId = userService.zoneId;
String dt = ZonedDateTime.now(zoneId).toString();
return "Hello, it is " + dt;
}
}

最后用main()方法测试一下:

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MailService mailService = context.getBean(MailService.class);
System.out.println(mailService.sendMail());
}
}

查看输出,一切正常:

1
2
3
UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下一步,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect

1
2
3
4
5
6
7
8
@Aspect
@Component
public class LoggingAspect {
@Before("execution(public * com..*.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}
}

别忘了在AppConfig上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个NullPointerException

1
2
3
4
5
6
Exception in thread "main" java.lang.NullPointerException: zone
at java.base/java.util.Objects.requireNonNull(Objects.java:246)
at java.base/java.time.Clock.system(Clock.java:203)
at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MailService {
@Autowired
UserService userService;

public String sendMail() {
ZoneId zoneId = userService.zoneId;
System.out.println(zoneId); // null
...
}
}

我们还故意在UserService中特意用final修饰了一下成员变量:

1
2
3
4
5
@Component
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
...
}

final标注的成员变量为null?逗我呢?

怎么肥四?

为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:

第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;

第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect

1
2
3
4
5
6
7
8
9
10
11
12
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target;
LoggingAspect aspect;

public UserService$$EnhancerBySpringCGLIB() {
}

public ZoneId getZoneId() {
aspect.doAccessCheck();
return target.getZoneId();
}
}

如果我们观察Spring创建的AOP代理,它的类名总是类似UserService$$EnhancerBySpringCGLIB$$1c76af9d(你没看错,Java的类名实际上允许$字符)。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有publicprotected方法,并在内部将调用委托给原始的UserService实例。

这里出现了两个UserService实例:

一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:

1
UserService original = new UserService();

第二个UserService实例实际上类型是UserService$$EnhancerBySpringCGLIB,它引用了原始的UserService实例:

1
2
3
UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
proxy.target = original;
proxy.aspect = ...

注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。

那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null

原因在于,UserService成员变量的初始化:

1
2
3
4
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
...
}

UserService$$EnhancerBySpringCGLIB中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。

实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:

1
2
3
4
5
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
public UserService() {
}
}

这是编译器实际编译的代码:

1
2
3
4
5
6
7
public class UserService {
public final ZoneId zoneId;
public UserService() {
super(); // 构造方法的第一行代码总是调用super()
zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
}
}

然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。

有的童鞋会问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?

这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:

注意

Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!

再考察MailService的代码:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MailService {
@Autowired
UserService userService;

public String sendMail() {
ZoneId zoneId = userService.zoneId;
System.out.println(zoneId); // null
...
}
}

如果没有启用AOP,注入的是原始的UserService实例,那么一切正常,因为UserService实例的zoneId字段已经被正确初始化了。

如果启动了AOP,注入的是代理后的UserService$$EnhancerBySpringCGLIB实例,那么问题大了:获取的UserService$$EnhancerBySpringCGLIB实例的zoneId字段,永远为null

那么问题来了:启用了AOP,如何修复?

修复很简单,只需要把直接访问字段的代码,改为通过方法访问:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MailService {
@Autowired
UserService userService;

public String sendMail() {
// 不要直接访问UserService的字段:
ZoneId zoneId = userService.getZoneId();
...
}
}

无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:

1
2
3
4
5
6
7
8
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target = ...
...

public ZoneId getZoneId() {
return target.getZoneId();
}
}

注意到我们还给UserService添加了一个public+final的方法:

1
2
3
4
5
6
7
@Component
public class UserService {
...
public final ZoneId getFinalZoneId() {
return zoneId;
}
}

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null

实际上,如果我们加上日志,Spring在启动时会打印一个警告:

1
10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。

因此,正确使用AOP,我们需要一个避坑指南:

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

这样才能保证有没有AOP,代码都能正常工作。

思考

为什么Spring刻意不初始化Proxy继承的字段?

如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?

练习

修复启用AOP导致的NPE。

下载练习

小结

由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。

遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。

留言與分享

JAVA-Spring开发-IoC

分類 编程语言, Java

在学习Spring框架时,我们遇到的第一个也是最核心的概念就是容器。

什么是容器?容器是一种为某种特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。

通常来说,使用容器运行组件,除了提供一个组件运行环境之外,容器还提供了许多底层服务。例如,Servlet容器底层实现了TCP连接,解析HTTP协议等非常复杂的服务,如果没有容器来提供这些服务,我们就无法编写像Servlet这样代码简单,功能强大的组件。早期的JavaEE服务器提供的EJB容器最重要的功能就是通过声明式事务服务,使得EJB组件的开发人员不必自己编写冗长的事务处理代码,所以极大地简化了事务处理。

Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等。

本章我们讨论的IoC容器,主要介绍Spring容器如何对组件进行生命周期管理和配置组装服务。

Spring提供的容器又称为IoC容器,什么是IoC?

IoC全称Inversion of Control,直译为控制反转。那么何谓IoC?在理解IoC之前,我们先看看通常的Java组件是如何协作的。

我们假定一个在线书店,通过BookService获取书籍:

1
2
3
4
5
6
7
8
9
10
11
public class BookService {
private HikariConfig config = new HikariConfig();
private DataSource dataSource = new HikariDataSource(config);

public Book getBook(long bookId) {
try (Connection conn = dataSource.getConnection()) {
...
return book;
}
}
}

为了从数据库查询书籍,BookService持有一个DataSource。为了实例化一个HikariDataSource,又不得不实例化一个HikariConfig

现在,我们继续编写UserService获取用户:

1
2
3
4
5
6
7
8
9
10
11
public class UserService {
private HikariConfig config = new HikariConfig();
private DataSource dataSource = new HikariDataSource(config);

public User getUser(long userId) {
try (Connection conn = dataSource.getConnection()) {
...
return user;
}
}
}

因为UserService也需要访问数据库,因此,我们不得不也实例化一个HikariDataSource

在处理用户购买的CartServlet中,我们需要实例化UserServiceBookService

1
2
3
4
5
6
7
8
9
10
11
12
public class CartServlet extends HttpServlet {
private BookService bookService = new BookService();
private UserService userService = new UserService();

protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
long currentUserId = getFromCookie(req);
User currentUser = userService.getUser(currentUserId);
Book book = bookService.getBook(req.getParameter("bookId"));
cartService.addToCart(currentUser, book);
...
}
}

类似的,在购买历史HistoryServlet中,也需要实例化UserServiceBookService

1
2
3
4
public class HistoryServlet extends HttpServlet {
private BookService bookService = new BookService();
private UserService userService = new UserService();
}

上述每个组件都采用了一种简单的通过new创建实例并持有的方式。仔细观察,会发现以下缺点:

  1. 实例化一个组件其实很难,例如,BookServiceUserService要创建HikariDataSource,实际上需要读取配置,才能先实例化HikariConfig,再实例化HikariDataSource
  2. 没有必要让BookServiceUserService分别创建DataSource实例,完全可以共享同一个DataSource,但谁负责创建DataSource,谁负责获取其他组件已经创建的DataSource,不好处理。类似的,CartServletHistoryServlet也应当共享BookService实例和UserService实例,但也不好处理。
  3. 很多组件需要销毁以便释放资源,例如DataSource,但如果该组件被多个组件共享,如何确保它的使用方都已经全部被销毁?
  4. 随着更多的组件被引入,例如,书籍评论,需要共享的组件写起来会更困难,这些组件的依赖关系会越来越复杂。
  5. 测试某个组件,例如BookService,是复杂的,因为必须要在真实的数据库环境下执行。

从上面的例子可以看出,如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。

因此,核心问题是:

  1. 谁负责创建组件?
  2. 谁负责根据依赖关系组装组件?
  3. 销毁时,如何按依赖顺序正确销毁?

解决这一问题的核心方案就是IoC。

传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制,例如:

CartServlet创建了BookService,在创建BookService的过程中,又创建了DataSource组件。这种模式的缺点是,一个组件如果要使用另一个组件,必须先知道如何正确地创建它。

在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,例如,BookService自己并不会创建DataSource,而是等待外部通过setDataSource()方法来注入一个DataSource

1
2
3
4
5
6
7
public class BookService {
private DataSource dataSource;

public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
}

不直接new一个DataSource,而是注入一个DataSource,这个小小的改动虽然简单,却带来了一系列好处:

  1. BookService不再关心如何创建DataSource,因此,不必编写读取数据库配置之类的代码;
  2. DataSource实例被注入到BookService,同样也可以注入到UserService,因此,共享一个组件非常简单;
  3. 测试BookService更容易,因为注入的是DataSource,可以使用内存数据库,而不是真实的MySQL配置。

因此,IoC又称为依赖注入(DI:Dependency Injection),它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且,由IoC容器负责管理组件的生命周期。

因为IoC容器要负责实例化所有的组件,因此,有必要告诉容器如何创建组件,以及各组件的依赖关系。一种最简单的配置是通过XML文件来实现,例如:

1
2
3
4
5
6
7
8
9
<beans>
<bean id="dataSource" class="HikariDataSource" />
<bean id="bookService" class="BookService">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="userService" class="UserService">
<property name="dataSource" ref="dataSource" />
</bean>
</beans>

上述XML配置文件指示IoC容器创建3个JavaBean组件,并把id为dataSource的组件通过属性dataSource(即调用setDataSource()方法)注入到另外两个组件中。

在Spring的IoC容器中,我们把所有组件统称为JavaBean,即配置一个组件就是配置一个Bean。

依赖注入方式

我们从上面的代码可以看到,依赖注入可以通过set()方法实现。但依赖注入也可以通过构造方法实现。

很多Java类都具有带参数的构造方法,如果我们把BookService改造为通过构造方法注入,那么实现代码如下:

1
2
3
4
5
6
7
public class BookService {
private DataSource dataSource;

public BookService(DataSource dataSource) {
this.dataSource = dataSource;
}
}

Spring的IoC容器同时支持属性注入和构造方法注入,并允许混合使用。

无侵入容器

在设计上,Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:

  1. 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
  2. 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。

我们前面讨论了为什么要使用Spring的IoC容器,因为让容器来为我们创建并装配Bean能获得很大的好处,那么到底如何使用IoC容器?装配好的Bean又如何使用?

我们来看一个具体的用户注册登录的例子。整个工程的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring-ioc-appcontext
├── pom.xml
└── src
└── main
├── java
│   └── com
│   └── itranswarp
│   └── learnjava
│   ├── Main.java
│   └── service
│   ├── MailService.java
│   ├── User.java
│   └── UserService.java
└── resources
└── application.xml

首先,我们用Maven创建工程并引入spring-context依赖:

  • org.springframework:spring-context:6.0.0

我们先编写一个MailService,用于在用户登录和注册成功后发送邮件通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MailService {
private ZoneId zoneId = ZoneId.systemDefault();

public void setZoneId(ZoneId zoneId) {
this.zoneId = zoneId;
}

public String getTime() {
return ZonedDateTime.now(this.zoneId).format(DateTimeFormatter.ISO_ZONED_DATE_TIME);
}

public void sendLoginMail(User user) {
System.err.println(String.format("Hi, %s! You are logged in at %s", user.getName(), getTime()));
}

public void sendRegistrationMail(User user) {
System.err.println(String.format("Welcome, %s!", user.getName()));

}
}

再编写一个UserService,实现用户注册和登录:

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
public class UserService {
private MailService mailService;

public void setMailService(MailService mailService) {
this.mailService = mailService;
}

private List<User> users = new ArrayList<>(List.of( // users:
new User(1, "bob@example.com", "password", "Bob"), // bob
new User(2, "alice@example.com", "password", "Alice"), // alice
new User(3, "tom@example.com", "password", "Tom"))); // tom

public User login(String email, String password) {
for (User user : users) {
if (user.getEmail().equalsIgnoreCase(email) && user.getPassword().equals(password)) {
mailService.sendLoginMail(user);
return user;
}
}
throw new RuntimeException("login failed.");
}

public User getUser(long id) {
return this.users.stream().filter(user -> user.getId() == id).findFirst().orElseThrow();
}

public User register(String email, String password, String name) {
users.forEach((user) -> {
if (user.getEmail().equalsIgnoreCase(email)) {
throw new RuntimeException("email exist.");
}
});
User user = new User(users.stream().mapToLong(u -> u.getId()).max().getAsLong() + 1, email, password, name);
users.add(user);
mailService.sendRegistrationMail(user);
return user;
}
}

注意到UserService通过setMailService()注入了一个MailService

然后,我们需要编写一个特定的application.xml配置文件,告诉Spring的IoC容器应该如何创建并组装Bean:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="userService" class="com.itranswarp.learnjava.service.UserService">
<property name="mailService" ref="mailService" />
</bean>

<bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
</beans>

注意观察上述配置文件,其中与XML Schema相关的部分格式是固定的,我们只关注两个<bean ...>的配置:

  • 每个<bean ...>都有一个id标识,相当于Bean的唯一ID;
  • userServiceBean中,通过<property name="..." ref="..." />注入了另一个Bean;
  • Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。

把上述XML配置文件用Java代码写出来,就像这样:

1
2
3
UserService userService = new UserService();
MailService mailService = new MailService();
userService.setMailService(mailService);

只不过Spring容器是通过读取XML文件后使用反射完成的。

如果注入的不是Bean,而是booleanintString这样的数据类型,则通过value注入,例如,创建一个HikariDataSource

1
2
3
4
5
6
7
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
<property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test" />
<property name="username" value="root" />
<property name="password" value="password" />
<property name="maximumPoolSize" value="10" />
<property name="autoCommit" value="true" />
</bean>

最后一步,我们需要创建一个Spring的IoC容器实例,然后加载配置文件,让Spring容器为我们创建并装配好配置文件中指定的所有Bean,这只需要一行代码:

1
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");

接下来,我们就可以从Spring容器中“取出”装配好的Bean然后使用它:

1
2
3
4
// 获取Bean:
UserService userService = context.getBean(UserService.class);
// 正常调用:
User user = userService.login("bob@example.com", "password");

完整的main()方法如下:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}

ApplicationContext

我们从创建Spring容器的代码:

1
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");

可以看到,Spring容器就是ApplicationContext,它是一个接口,有很多实现类,这里我们选择ClassPathXmlApplicationContext,表示它会自动从classpath中查找指定的XML配置文件。

获得了ApplicationContext的实例,就获得了IoC容器的引用。从ApplicationContext中我们可以根据Bean的ID获取Bean,但更多的时候我们根据Bean的类型获取Bean的引用:

1
UserService userService = context.getBean(UserService.class);

Spring还提供另一种IoC容器叫BeanFactory,使用方式和ApplicationContext类似:

1
2
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("application.xml"));
MailService mailService = factory.getBean(MailService.class);

BeanFactoryApplicationContext的区别在于,BeanFactory的实现是按需创建,即第一次获取Bean时才创建这个Bean,而ApplicationContext会一次性创建所有的Bean。实际上,ApplicationContext接口是从BeanFactory接口继承而来的,并且,ApplicationContext提供了一些额外的功能,包括国际化支持、事件和通知机制等。通常情况下,我们总是使用ApplicationContext,很少会考虑使用BeanFactory

练习

在上述示例的基础上,继续给UserService注入DataSource,并把注册和登录功能通过数据库实现。

下载练习

小结

Spring的IoC容器接口是ApplicationContext,并提供了多种实现类;

通过XML配置文件创建IoC容器时,使用ClassPathXmlApplicationContext

持有IoC容器后,通过getBean()方法获取Bean的引用。

使用Spring的IoC容器,实际上就是通过类似XML这样的配置文件,把我们自己的Bean的依赖关系描述出来,然后让容器来创建并装配Bean。一旦容器初始化完毕,我们就直接从容器中获取Bean使用它们。

使用XML配置的优点是所有的Bean都能一目了然地列出来,并通过配置注入能直观地看到每个Bean的依赖。它的缺点是写起来非常繁琐,每增加一个组件,就必须把新的Bean配置到XML中。

有没有其他更简单的配置方式呢?

有!我们可以使用Annotation配置,可以完全不需要XML,让Spring自动扫描Bean并组装它们。

我们把上一节的示例改造一下,先删除XML配置文件,然后,给UserServiceMailService添加几个注解。

首先,我们给MailService添加一个@Component注解:

1
2
3
4
@Component
public class MailService {
...
}

这个@Component注解就相当于定义了一个Bean,它有一个可选的名称,默认是mailService,即小写开头的类名。

然后,我们给UserService添加一个@Component注解和一个@Autowired注解:

1
2
3
4
5
6
7
@Component
public class UserService {
@Autowired
MailService mailService;

...
}

使用@Autowired就相当于把指定类型的Bean注入到指定的字段中。和XML配置相比,@Autowired大幅简化了注入,因为它不但可以写在set()方法上,还可以直接写在字段上,甚至可以写在构造方法中:

1
2
3
4
5
6
7
8
9
@Component
public class UserService {
MailService mailService;

public UserService(@Autowired MailService mailService) {
this.mailService = mailService;
}
...
}

我们一般把@Autowired写在字段上,通常使用package权限的字段,便于测试。

最后,编写一个AppConfig类启动容器:

1
2
3
4
5
6
7
8
9
10
@Configuration
@ComponentScan
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}

除了main()方法外,AppConfig标注了@Configuration,表示它是一个配置类,因为我们创建ApplicationContext时:

1
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

使用的实现类是AnnotationConfigApplicationContext,必须传入一个标注了@Configuration的类名。

此外,AppConfig还标注了@ComponentScan,它告诉容器,自动搜索当前类所在的包以及子包,把所有标注为@Component的Bean自动创建出来,并根据@Autowired进行装配。

整个工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
spring-ioc-annoconfig
├── pom.xml
└── src
└── main
└── java
└── com
└── itranswarp
└── learnjava
├── AppConfig.java
└── service
├── MailService.java
├── User.java
└── UserService.java

使用Annotation配合自动扫描能大幅简化Spring的配置,我们只需要保证:

  • 每个Bean被标注为@Component并正确使用@Autowired注入;
  • 配置类被标注为@Configuration@ComponentScan
  • 所有Bean均在指定包以及子包内。

使用@ComponentScan非常方便,但是,我们也要特别注意包的层次结构。通常来说,启动配置AppConfig位于自定义的顶层包(例如com.itranswarp.learnjava),其他Bean按类别放入子包。

思考

如果我们想给UserService注入HikariDataSource,但是这个类位于com.zaxxer.hikari包中,并且HikariDataSource也不可能有@Component注解,如何告诉IoC容器创建并配置HikariDataSource?或者换个说法,如何创建并配置一个第三方Bean?

练习

使用Annotation配置IoC容器。

下载练习

小结

使用Annotation可以大幅简化配置,每个Bean通过@Component@Autowired注入;

必须合理设计包的层次结构,才能发挥@ComponentScan的威力。

Scope

对于Spring容器来说,当我们把一个Bean标记为@Component后,它就会自动为我们创建一个单例(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)获取到的Bean总是同一个实例。

还有一种Bean,我们每次调用getBean(Class),容器都返回一个新的实例,这种Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的@Scope注解:

1
2
3
4
5
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // @Scope("prototype")
public class MailSession {
...
}

注入List

有些时候,我们会有一系列接口相同,不同实现类的Bean。例如,注册用户时,我们要对email、password和name这3个变量进行验证。为了便于扩展,我们先定义验证接口:

1
2
3
public interface Validator {
void validate(String email, String password, String name);
}

然后,分别使用3个Validator对用户参数进行验证:

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
@Component
public class EmailValidator implements Validator {
public void validate(String email, String password, String name) {
if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
throw new IllegalArgumentException("invalid email: " + email);
}
}
}

@Component
public class PasswordValidator implements Validator {
public void validate(String email, String password, String name) {
if (!password.matches("^.{6,20}$")) {
throw new IllegalArgumentException("invalid password");
}
}
}

@Component
public class NameValidator implements Validator {
public void validate(String email, String password, String name) {
if (name == null || name.isBlank() || name.length() > 20) {
throw new IllegalArgumentException("invalid name: " + name);
}
}
}

最后,我们通过一个Validators作为入口进行验证:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class Validators {
@Autowired
List<Validator> validators;

public void validate(String email, String password, String name) {
for (var validator : this.validators) {
validator.validate(email, password, name);
}
}
}

注意到Validators被注入了一个List<Validator>,Spring会自动把所有类型为Validator的Bean装配为一个List注入进来,这样一来,我们每新增一个Validator类型,就自动被Spring装配到Validators中了,非常方便。

因为Spring是通过扫描classpath获取到所有的Bean,而List是有序的,要指定List中Bean的顺序,可以加上@Order注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@Order(1)
public class EmailValidator implements Validator {
...
}

@Component
@Order(2)
public class PasswordValidator implements Validator {
...
}

@Component
@Order(3)
public class NameValidator implements Validator {
...
}

可选注入

默认情况下,当我们标记了一个@Autowired后,Spring如果没有找到对应类型的Bean,它会抛出NoSuchBeanDefinitionException异常。

可以给@Autowired增加一个required = false的参数:

1
2
3
4
5
6
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();
...
}

这个参数告诉Spring容器,如果找到一个类型为ZoneId的Bean,就注入,如果找不到,就忽略。

这种方式非常适合有定义就使用定义,没有就使用默认值的情况。

创建第三方Bean

如果一个Bean不在我们自己的package管理之内,例如ZoneId,如何创建它?

答案是我们自己在@Configuration类中编写一个Java方法创建并返回它,注意给方法标记一个@Bean注解:

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
public class AppConfig {
// 创建一个Bean:
@Bean
ZoneId createZoneId() {
return ZoneId.of("Z");
}
}

Spring对标记为@Bean的方法只调用一次,因此返回的Bean仍然是单例。

初始化和销毁

有些时候,一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。我们通常会定义一个init()方法进行初始化,定义一个shutdown()方法进行清理,然后,引入JSR-250定义的Annotation:

  • jakarta.annotation:jakarta.annotation-api:2.1.1

在Bean的初始化和清理方法上标记@PostConstruct@PreDestroy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();

@PostConstruct
public void init() {
System.out.println("Init mail service with zoneId = " + this.zoneId);
}

@PreDestroy
public void shutdown() {
System.out.println("Shutdown mail service");
}
}

Spring容器会对上述Bean做如下初始化流程:

  • 调用构造方法创建MailService实例;
  • 根据@Autowired进行注入;
  • 调用标记有@PostConstructinit()方法进行初始化。

而销毁时,容器会首先调用标记有@PreDestroyshutdown()方法。

Spring只根据Annotation查找无参数方法,对方法名不作要求。

使用别名

默认情况下,对一种类型的Bean,容器只创建一个实例。但有些时候,我们需要对一种类型的Bean创建多个实例。例如,同时连接多个数据库,就必须创建多个DataSource实例。

如果我们在@Configuration类中创建了多个同类型的Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@ComponentScan
public class AppConfig {
@Bean
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}

@Bean
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}

Spring会报NoUniqueBeanDefinitionException异常,意思是出现了重复的Bean定义。

这个时候,需要给每个Bean添加不同的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@ComponentScan
public class AppConfig {
@Bean("z")
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}

@Bean
@Qualifier("utc8")
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}

可以用@Bean("name")指定别名,也可以用@Bean+@Qualifier("name")指定别名。

存在多个同类型的Bean时,注入ZoneId又会报错:

1
NoUniqueBeanDefinitionException: No qualifying bean of type 'java.time.ZoneId' available: expected single matching bean but found 2

意思是期待找到唯一的ZoneId类型Bean,但是找到两。因此,注入时,要指定Bean的名称:

1
2
3
4
5
6
7
@Component
public class MailService {
@Autowired(required = false)
@Qualifier("z") // 指定注入名称为"z"的ZoneId
ZoneId zoneId = ZoneId.systemDefault();
...
}

还有一种方法是把其中某个Bean指定为@Primary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Primary // 指定为主要Bean
@Qualifier("z")
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}

@Bean
@Qualifier("utc8")
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}

这样,在注入时,如果没有指出Bean的名字,Spring会注入标记有@Primary的Bean。这种方式也很常用。例如,对于主从两个数据源,通常将主数据源定义为@Primary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Primary
DataSource createMasterDataSource() {
...
}

@Bean
@Qualifier("slave")
DataSource createSlaveDataSource() {
...
}
}

其他Bean默认注入的就是主数据源。如果要注入从数据源,那么只需要指定名称即可。

使用FactoryBean

我们在设计模式的工厂方法中讲到,很多时候,可以通过工厂模式创建对象。Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。

用工厂模式创建Bean需要实现FactoryBean接口。我们观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {

String zone = "Z";

@Override
public ZoneId getObject() throws Exception {
return ZoneId.of(zone);
}

@Override
public Class<?> getObjectType() {
return ZoneId.class;
}
}

当一个Bean实现了FactoryBean接口后,Spring会先实例化这个工厂,然后调用getObject()创建真正的Bean。getObjectType()可以指定创建的Bean的类型,因为指定类型不一定与实际类型一致,可以是接口或抽象类。

因此,如果定义了一个FactoryBean,要注意Spring创建的Bean实际上是这个FactoryBeangetObject()方法返回的Bean。为了和普通Bean区分,我们通常都以XxxFactoryBean命名。

由于可以用@Bean方法创建第三方Bean,本质上@Bean方法就是工厂方法,所以,FactoryBean已经用得越来越少了。

练习

定制Bean。

下载练习

小结

Spring默认使用Singleton创建Bean,也可指定Scope为Prototype;

可将相同类型的Bean注入List或数组;

可用@Autowired(required=false)允许可选注入;

可用带@Bean标注的方法创建Bean;

可使用@PostConstruct@PreDestroy对Bean进行初始化和清理;

相同类型的Bean只能有一个指定为@Primary,其他必须用@Qualifier("beanName")指定别名;

注入时,可通过别名@Qualifier("beanName")指定某个Bean;

可以定义FactoryBean来使用工厂模式创建Bean。

使用Resource

在Java程序中,我们经常会读取配置文件、资源文件等。使用Spring容器时,我们也可以把“文件”注入进来,方便程序读取。

例如,AppService需要读取logo.txt这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开InputStream。

Spring提供了一个org.springframework.core.io.Resource(注意不是jarkata.annotation.Resourcejavax.annotation.Resource),它可以像Stringint一样使用@Value注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class AppService {
@Value("classpath:/logo.txt")
private Resource resource;

private String logo;

@PostConstruct
public void init() throws IOException {
try (var reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
this.logo = reader.lines().collect(Collectors.joining("\n"));
}
}
}

注入Resource最常用的方式是通过classpath,即类似classpath:/logo.txt表示在classpath中搜索logo.txt文件,然后,我们直接调用Resource.getInputStream()就可以获取到输入流,避免了自己搜索文件的代码。

也可以直接指定文件的路径,例如:

1
2
@Value("file:/path/to/logo.txt")
private Resource resource;

但使用classpath是最简单的方式。上述工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
spring-ioc-resource
├── pom.xml
└── src
└── main
├── java
│   └── com
│   └── itranswarp
│   └── learnjava
│   ├── AppConfig.java
│   └── AppService.java
└── resources
└── logo.txt

使用Maven的标准目录结构,所有资源文件放入src/main/resources即可。

练习

使用Spring的Resource注入app.properties文件,然后读取该配置文件。

下载练习

小结

Spring提供了Resource类便于注入资源文件。

最常用的注入是通过classpath以classpath:/path/to/file的形式注入。



注入配置

在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value的形式写在.properties文件中。

例如,MailService根据配置的app.zone=Asia/Shanghai来决定使用哪个时区。要读取配置文件,我们可以使用上一节讲到的Resource来读取位于classpath下的一个app.properties文件。但是,这样仍然比较繁琐。

Spring容器还提供了一个更简单的@PropertySource来自动读取配置文件。我们只需要在@Configuration配置类上再添加一个注解:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@ComponentScan
@PropertySource("app.properties") // 表示读取classpath的app.properties
public class AppConfig {
@Value("${app.zone:Z}")
String zoneId;

@Bean
ZoneId createZoneId() {
return ZoneId.of(zoneId);
}
}

Spring容器看到@PropertySource("app.properties")注解后,自动读取这个配置文件,然后,我们使用@Value正常注入:

1
2
@Value("${app.zone:Z}")
String zoneId;

注意注入的字符串语法,它的格式如下:

  • "${app.zone}"表示读取key为app.zone的value,如果key不存在,启动将报错;
  • "${app.zone:Z}"表示读取key为app.zone的value,但如果key不存在,就使用默认值Z

这样一来,我们就可以根据app.zone的配置来创建ZoneId

还可以把注入的注解写到方法参数中:

1
2
3
4
@Bean
ZoneId createZoneId(@Value("${app.zone:Z}") String zoneId) {
return ZoneId.of(zoneId);
}

可见,先使用@PropertySource读取配置文件,然后通过@Value${key:defaultValue}的形式注入,可以极大地简化读取配置的麻烦。

另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class SmtpConfig {
@Value("${smtp.host}")
private String host;

@Value("${smtp.port:25}")
private int port;

public String getHost() {
return host;
}

public int getPort() {
return port;
}
}

然后,在需要读取的地方,使用#{smtpConfig.host}注入:

1
2
3
4
5
6
7
8
@Component
public class MailService {
@Value("#{smtpConfig.host}")
private String smtpHost;

@Value("#{smtpConfig.port}")
private int smtpPort;
}

注意观察#{}这种注入语法,它和${key}不同的是,#{}表示从JavaBean读取属性。"#{smtpConfig.host}"的意思是,从名称为smtpConfig的Bean读取host属性,即调用getHost()方法。一个Class名为SmtpConfig的Bean,它在Spring容器中的默认名称就是smtpConfig,除非用@Qualifier指定了名称。

使用一个独立的JavaBean持有所有属性,然后在其他Bean中以#{bean.property}注入的好处是,多个Bean都可以引用同一个Bean的某个属性。例如,如果SmtpConfig决定从数据库中读取相关配置项,那么MailService注入的@Value("#{smtpConfig.host}")仍然可以不修改正常运行。

练习

注入SMTP配置。

下载练习

小结

Spring容器可以通过@PropertySource自动读取配置,并以@Value("${key}")的形式注入;

可以通过${key:defaultValue}指定默认值;

#{bean.property}形式注入时,Spring容器自动把指定Bean的指定属性值注入。



开发应用程序时,我们会使用开发环境,例如,使用内存数据库以便快速启动。而运行在生产环境时,我们会使用生产环境,例如,使用MySQL数据库。如果应用程序可以根据自身的环境做一些适配,无疑会更加灵活。

Spring为应用程序准备了Profile这一概念,用来表示不同的环境。例如,我们分别定义开发、测试和生产这3个环境:

  • native
  • test
  • production

创建某个Bean时,Spring容器可以根据注解@Profile来决定是否创建。例如,以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Profile("!test")
ZoneId createZoneId() {
return ZoneId.systemDefault();
}

@Bean
@Profile("test")
ZoneId createZoneIdForTest() {
return ZoneId.of("America/New_York");
}
}

如果当前的Profile设置为test,则Spring容器会调用createZoneIdForTest()创建ZoneId,否则,调用createZoneId()创建ZoneId。注意到@Profile("!test")表示非test环境。

在运行程序时,加上JVM参数-Dspring.profiles.active=test就可以指定以test环境启动。

实际上,Spring允许指定多个Profile,例如:

1
-Dspring.profiles.active=test,master

可以表示test环境,并使用master分支代码。

要满足多个Profile条件,可以这样写:

1
2
3
4
5
@Bean
@Profile({ "test", "master" }) // 满足test或master
ZoneId createZoneId() {
...
}

使用Conditional

除了根据@Profile条件来决定是否创建某个Bean外,Spring还可以根据@Conditional决定是否创建某个Bean。

例如,我们对SmtpMailService添加如下注解:

1
2
3
4
5
@Component
@Conditional(OnSmtpEnvCondition.class)
public class SmtpMailService implements MailService {
...
}

它的意思是,如果满足OnSmtpEnvCondition的条件,才会创建SmtpMailService这个Bean。OnSmtpEnvCondition的条件是什么呢?我们看一下代码:

1
2
3
4
5
public class OnSmtpEnvCondition implements Condition {
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return "true".equalsIgnoreCase(System.getenv("smtp"));
}
}

因此,OnSmtpEnvCondition的条件是存在环境变量smtp,值为true。这样,我们就可以通过环境变量来控制是否创建SmtpMailService

Spring只提供了@Conditional注解,具体判断逻辑还需要我们自己实现。Spring Boot提供了更多使用起来更简单的条件注解,例如,如果配置文件中存在app.smtp=true,则创建MailService

1
2
3
4
5
@Component
@ConditionalOnProperty(name="app.smtp", havingValue="true")
public class MailService {
...
}

如果当前classpath中存在类javax.mail.Transport,则创建MailService

1
2
3
4
5
@Component
@ConditionalOnClass(name = "javax.mail.Transport")
public class MailService {
...
}

后续我们会介绍Spring Boot的条件装配。我们以文件存储为例,假设我们需要保存用户上传的头像,并返回存储路径,在本地开发运行时,我们总是存储到文件:

1
2
3
4
5
@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "file", matchIfMissing = true)
public class FileUploader implements Uploader {
...
}

在生产环境运行时,我们会把文件存储到类似AWS S3上:

1
2
3
4
5
@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "s3")
public class S3Uploader implements Uploader {
...
}

其他需要存储的服务则注入Uploader

1
2
3
4
5
@Component
public class UserImageService {
@Autowired
Uploader uploader;
}

当应用程序检测到配置文件存在app.storage=s3时,自动使用S3Uploader,如果存在配置app.storage=file,或者配置app.storage不存在,则使用FileUploader

可见,使用条件注解,能更灵活地装配Bean。

练习

使用@Profile进行条件装配。

下载练习

小结

Spring允许通过@Profile配置不同的Bean;

Spring还提供了@Conditional来进行条件装配,Spring Boot在此基础上进一步提供了基于配置、Class、Bean等条件进行装配。

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau