Dunwu Blog

大道至简,知易行难

HBase 数据模型

HBase 是一个面向 的数据库管理系统,这里更为确切的而说,HBase 是一个面向 列族 的数据库管理系统。表 schema 仅定义列族,表具有多个列族,每个列族可以包含任意数量的列,列由多个单元格(cell)组成,单元格可以存储多个版本的数据,多个版本数据以时间戳进行区分。

HBase 逻辑存储结构

  • **Table**:Table 由 Row 和 Column 组成。
  • **Row**:Row 是列族(Column Family)的集合。
  • Row KeyRow Key 是用来检索记录的主键
    • Row Key 是未解释的字节数组,所以理论上,任何数据都可以通过序列化表示成字符串或二进制,从而存为 HBase 的键值。
    • 表中的行,是按照 Row Key 的字典序进行排序。这里需要注意以下两点:
      • 因为字典序对 Int 排序的结果是 1,10,100,11,12,13,14,15,16,17,18,19,2,20,21,…,9,91,92,93,94,95,96,97,98,99。如果你使用整型的字符串作为行键,那么为了保持整型的自然序,行键必须用 0 作左填充。
      • 行的一次读写操作是原子性的 (不论一次读写多少列)。
    • 所有对表的访问都要通过 Row Key,有以下三种方式:
      • 通过指定的 Row Key 进行访问;
      • 通过 Row Key 的 range 进行访问,即访问指定范围内的行;
      • 进行全表扫描。
  • **Column Family**:即列族。HBase 表中的每个列,都归属于某个列族。列族是表的 Schema 的一部分,所以列族需要在创建表时进行定义。
    • 一个表的列族必须作为表模式定义的一部分预先给出,但是新的列族成员可以随后按需加入。
    • 同一个列族的所有成员具有相同的前缀,例如 info:formatinfo:geo 都属于 info 这个列族。
  • **Column Qualifier**:列限定符。可以理解为是具体的列名,例如 info:formatinfo:geo 都属于 info 这个列族,它们的列限定符分别是 formatgeo。列族和列限定符之间始终以冒号分隔。需要注意的是列限定符不是表 Schema 的一部分,你可以在插入数据的过程中动态创建列。
  • **Column**:HBase 中的列由列族和列限定符组成,由 :(冒号) 进行分隔,即一个完整的列名应该表述为 列族名 :列限定符
  • **Cell**:Cell 是行,列族和列限定符的组合,并包含值和时间戳。HBase 中通过 row keycolumn 确定的为一个存储单元称为 Cell,你可以等价理解为关系型数据库中由指定行和指定列确定的一个单元格,但不同的是 HBase 中的一个单元格是由多个版本的数据组成的,每个版本的数据用时间戳进行区分。
    • Cell 由行和列的坐标交叉决定,是有版本的。默认情况下,版本号是自动分配的,为 HBase 插入 Cell 时的时间戳。Cell 的内容是未解释的字节数组。
  • **Timestamp**:Cell 的版本通过时间戳来索引,时间戳的类型是 64 位整型,时间戳可以由 HBase 在数据写入时自动赋值,也可以由客户显式指定。每个 Cell 中,不同版本的数据按照时间戳倒序排列,即最新的数据排在最前面。

img

HBase 物理存储结构

HBase Table 中的所有行按照 Row Key 的字典序排列。HBase Tables 通过行键的范围 (row key range) 被水平切分成多个 Region, 一个 Region 包含了在 start key 和 end key 之间的所有行。

img

每个表一开始只有一个 Region,随着数据不断增加,Region 会不断增大,当增大到一个阀值的时候,Region 就会等分为两个新的 Region。当 Table 中的行不断增多,就会有越来越多的 Region

img

Region 是 HBase 中分布式存储和负载均衡的最小单元。这意味着不同的 Region 可以分布在不同的 Region Server 上。但一个 Region 是不会拆分到多个 Server 上的。

img

HBase 数据模型示例

下图为 HBase 中一张表的:

  • RowKey 为行的唯一标识,所有行按照 RowKey 的字典序进行排序;
  • 该表具有两个列族,分别是 personal 和 office;
  • 其中列族 personal 拥有 name、city、phone 三个列,列族 office 拥有 tel、addres 两个列。

img

图片引用自 : HBase 是列式存储数据库吗 https://www.iteblog.com/archives/2498.html

HBase 表特性

Hbase 的表具有以下特点:

  • 容量大:一个表可以有数十亿行,上百万列;
  • 面向列:数据是按照列存储,每一列都单独存放,数据即索引,在查询时可以只访问指定列的数据,有效地降低了系统的 I/O 负担;
  • 稀疏性:空 (null) 列并不占用存储空间,表可以设计的非常稀疏 ;
  • 数据多版本:每个单元中的数据可以有多个版本,按照时间戳排序,新的数据在最上面;
  • 存储类型:所有数据的底层存储格式都是字节数组 (byte[])。

参考资料

HBase Java API 高级特性之协处理器

简述

在使用 HBase 时,如果你的数据量达到了数十亿行或数百万列,此时能否在查询中返回大量数据将受制于网络的带宽,即便网络状况允许,但是客户端的计算处理也未必能够满足要求。在这种情况下,协处理器(Coprocessors)应运而生。它允许你将业务计算代码放入在 RegionServer 的协处理器中,将处理好的数据再返回给客户端,这可以极大地降低需要传输的数据量,从而获得性能上的提升。同时协处理器也允许用户扩展实现 HBase 目前所不具备的功能,如权限校验、二级索引、完整性约束等。

参考资料

HBase Java API 高级特性之过滤器

HBase 中两种主要的数据读取方法是 get()scan(),它们都支持直接访问数据和通过指定起止 row key 访问数据。此外,可以指定列族、列、时间戳和版本号来进行条件查询。它们的缺点是不支持细粒度的筛选功能。为了弥补这种不足,GetScan 支持通过过滤器(Filter)对 row key、列或列值进行过滤。

HBase 提供了一些内置过滤器,也允许用户通过继承 Filter 类来自定义过滤器。所有的过滤器都在服务端生效,称为 谓词下推。这样可以保证被过滤掉的数据不会被传到客户端。

图片来自 HBase 权威指南

HBase 过滤器层次结构的最底层是 Filter 接口和 FilterBase 抽象类。大部分过滤器都直接继承自 FilterBase

比较过滤器

所有比较过滤器均继承自 CompareFilterCompareFilterFilterBase 多了一个 compare() 方法,它需要传入参数定义比较操作的过程:比较运算符和比较器。

创建一个比较过滤器需要两个参数,分别是比较运算符比较器实例

1
2
3
4
public CompareFilter(final CompareOp compareOp,final ByteArrayComparable comparator) {
this.compareOp = compareOp;
this.comparator = comparator;
}

比较运算符

  • LESS (<)
  • LESS_OR_EQUAL (<=)
  • EQUAL (=)
  • NOT_EQUAL (!=)
  • GREATER_OR_EQUAL (>=)
  • GREATER (>)
  • NO_OP (排除所有符合条件的值)

比较运算符均定义在枚举类 CompareOperator

1
2
3
4
5
6
7
8
9
10
@InterfaceAudience.Public
public enum CompareOperator {
LESS,
LESS_OR_EQUAL,
EQUAL,
NOT_EQUAL,
GREATER_OR_EQUAL,
GREATER,
NO_OP,
}

注意:在 1.x 版本的 HBase 中,比较运算符定义在 CompareFilter.CompareOp 枚举类中,但在 2.0 之后这个类就被标识为 @deprecated ,并会在 3.0 移除。所以 2.0 之后版本的 HBase 需要使用 CompareOperator 这个枚举类。

比较器

所有比较器均继承自 ByteArrayComparable 抽象类,常用的有以下几种:

  • BinaryComparator : 使用 Bytes.compareTo(byte [],byte []) 按字典序比较指定的字节数组。
  • BinaryPrefixComparator : 按字典序与指定的字节数组进行比较,但只比较到这个字节数组的长度。
  • RegexStringComparator : 使用给定的正则表达式与指定的字节数组进行比较。仅支持 EQUALNOT_EQUAL 操作。
  • SubStringComparator : 测试给定的子字符串是否出现在指定的字节数组中,比较不区分大小写。仅支持 EQUALNOT_EQUAL 操作。
  • NullComparator :判断给定的值是否为空。
  • BitComparator :按位进行比较。

BinaryPrefixComparatorBinaryComparator 的区别不是很好理解,这里举例说明一下:

在进行 EQUAL 的比较时,如果比较器传入的是 abcd 的字节数组,但是待比较数据是 abcdefgh

  • 如果使用的是 BinaryPrefixComparator 比较器,则比较以 abcd 字节数组的长度为准,即 efgh 不会参与比较,这时候认为 abcdabcdefgh 是满足 EQUAL 条件的;
  • 如果使用的是 BinaryComparator 比较器,则认为其是不相等的。

比较过滤器种类

比较过滤器共有五个(Hbase 1.x 版本和 2.x 版本相同):

  • RowFilter :基于行键来过滤数据;
  • FamilyFilterr :基于列族来过滤数据;
  • QualifierFilterr :基于列限定符(列名)来过滤数据;
  • ValueFilterr :基于单元格 (cell) 的值来过滤数据;
  • DependentColumnFilter :指定一个参考列来过滤其他列的过滤器,过滤的原则是基于参考列的时间戳来进行筛选 。

前四种过滤器的使用方法相同,均只要传递比较运算符和运算器实例即可构建,然后通过 setFilter 方法传递给 scan

1
2
3
Filter filter  = new RowFilter(CompareOperator.LESS_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("xxx")));
scan.setFilter(filter);

DependentColumnFilter 的使用稍微复杂一点,这里单独做下说明。

DependentColumnFilter

可以把 DependentColumnFilter 理解为一个 valueFilter 和一个时间戳过滤器的组合DependentColumnFilter 有三个带参构造器,这里选择一个参数最全的进行说明:

1
2
3
DependentColumnFilter(final byte [] family, final byte[] qualifier,
final boolean dropDependentColumn, final CompareOperator op,
final ByteArrayComparable valueComparator)
  • family :列族
  • qualifier :列限定符(列名)
  • dropDependentColumn :决定参考列是否被包含在返回结果内,为 true 时表示参考列被返回,为 false 时表示被丢弃
  • op :比较运算符
  • valueComparator :比较器

这里举例进行说明:

1
2
3
4
5
6
DependentColumnFilter dependentColumnFilter = new DependentColumnFilter(
Bytes.toBytes("student"),
Bytes.toBytes("name"),
false,
CompareOperator.EQUAL,
new BinaryPrefixComparator(Bytes.toBytes("xiaolan")));
  • 首先会去查找 student:name 中值以 xiaolan 开头的所有数据获得 参考数据集,这一步等同于 valueFilter 过滤器;
  • 其次再用参考数据集中所有数据的时间戳去检索其他列,获得时间戳相同的其他列的数据作为 结果数据集,这一步等同于时间戳过滤器;
  • 最后如果 dropDependentColumn 为 true,则返回 参考数据集+结果数据集,若为 false,则抛弃参考数据集,只返回 结果数据集

专用过滤器

专用过滤器通常直接继承自 FilterBase,用于更特定的场景。

单列列值过滤器 (SingleColumnValueFilter)

基于某列(参考列)的值决定某行数据是否被过滤。其实例有以下方法:

  • setFilterIfMissing(boolean filterIfMissing) :默认值为 false,即如果该行数据不包含参考列,其依然被包含在最后的结果中;设置为 true 时,则不包含;
  • setLatestVersionOnly(boolean latestVersionOnly) :默认为 true,即只检索参考列的最新版本数据;设置为 false,则检索所有版本数据。
1
2
3
4
5
6
7
SingleColumnValueFilter singleColumnValueFilter = new SingleColumnValueFilter(
"student".getBytes(),
"name".getBytes(),
CompareOperator.EQUAL,
new SubstringComparator("xiaolan"));
singleColumnValueFilter.setFilterIfMissing(true);
scan.setFilter(singleColumnValueFilter);

单列列值排除器 (SingleColumnValueExcludeFilter)

SingleColumnValueExcludeFilter 继承自上面的 SingleColumnValueFilter,过滤行为与其相反。

行键前缀过滤器 (PrefixFilter)

基于 RowKey 值决定某行数据是否被过滤。

1
2
PrefixFilter prefixFilter = new PrefixFilter(Bytes.toBytes("xxx"));
scan.setFilter(prefixFilter);

列名前缀过滤器 (ColumnPrefixFilter)

基于列限定符(列名)决定某行数据是否被过滤。

1
2
ColumnPrefixFilter columnPrefixFilter = new ColumnPrefixFilter(Bytes.toBytes("xxx"));
scan.setFilter(columnPrefixFilter);

分页过滤器 (PageFilter)

可以使用这个过滤器实现对结果按行进行分页,创建 PageFilter 实例的时候需要传入每页的行数。

1
2
3
4
public PageFilter(final long pageSize) {
Preconditions.checkArgument(pageSize >= 0, "must be positive %s", pageSize);
this.pageSize = pageSize;
}

下面的代码体现了客户端实现分页查询的主要逻辑,这里对其进行一下解释说明:

客户端进行分页查询,需要传递 startRow(起始 RowKey),知道起始 startRow 后,就可以返回对应的 pageSize 行数据。这里唯一的问题就是,对于第一次查询,显然 startRow 就是表格的第一行数据,但是之后第二次、第三次查询我们并不知道 startRow,只能知道上一次查询的最后一条数据的 RowKey(简单称之为 lastRow)。

我们不能将 lastRow 作为新一次查询的 startRow 传入,因为 scan 的查询区间是[startRow,endRow) ,即前开后闭区间,这样 startRow 在新的查询也会被返回,这条数据就重复了。

同时在不使用第三方数据库存储 RowKey 的情况下,我们是无法通过知道 lastRow 的下一个 RowKey 的,因为 RowKey 的设计可能是连续的也有可能是不连续的。

由于 Hbase 的 RowKey 是按照字典序进行排序的。这种情况下,就可以在 lastRow 后面加上 0 ,作为 startRow 传入,因为按照字典序的规则,某个值加上 0 后的新值,在字典序上一定是这个值的下一个值,对于 HBase 来说下一个 RowKey 在字典序上一定也是等于或者大于这个新值的。

所以最后传入 lastRow+0,如果等于这个值的 RowKey 存在就从这个值开始 scan,否则从字典序的下一个 RowKey 开始 scan。

25 个字母以及数字字符,字典排序如下:

1
'0' < '1' < '2' < ... < '9' < 'a' < 'b' < ... < 'z'

分页查询主要实现逻辑:

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
byte[] POSTFIX = new byte[] { 0x00 };
Filter filter = new PageFilter(15);

int totalRows = 0;
byte[] lastRow = null;
while (true) {
Scan scan = new Scan();
scan.setFilter(filter);
if (lastRow != null) {
// 如果不是首行 则 lastRow + 0
byte[] startRow = Bytes.add(lastRow, POSTFIX);
System.out.println("start row: " +
Bytes.toStringBinary(startRow));
scan.withStartRow(startRow);
}
ResultScanner scanner = table.getScanner(scan);
int localRows = 0;
Result result;
while ((result = scanner.next()) != null) {
System.out.println(localRows++ + ": " + result);
totalRows++;
lastRow = result.getRow();
}
scanner.close();
//最后一页,查询结束
if (localRows == 0) break;
}
System.out.println("total rows: " + totalRows);

需要注意的是在多台 Regin Services 上执行分页过滤的时候,由于并行执行的过滤器不能共享它们的状态和边界,所以有可能每个过滤器都会在完成扫描前获取了 PageCount 行的结果,这种情况下会返回比分页条数更多的数据,分页过滤器就有失效的可能。

时间戳过滤器 (TimestampsFilter)

1
2
3
4
List<Long> list = new ArrayList<>();
list.add(1554975573000L);
TimestampsFilter timestampsFilter = new TimestampsFilter(list);
scan.setFilter(timestampsFilter);

首次行键过滤器 (FirstKeyOnlyFilter)

FirstKeyOnlyFilter 只扫描每行的第一列,扫描完第一列后就结束对当前行的扫描,并跳转到下一行。相比于全表扫描,其性能更好,通常用于行数统计的场景,因为如果某一行存在,则行中必然至少有一列。

1
2
FirstKeyOnlyFilter firstKeyOnlyFilter = new FirstKeyOnlyFilter();
scan.set(firstKeyOnlyFilter);

包装过滤器

包装过滤器就是通过包装其他过滤器以实现某些拓展的功能。

SkipFilter 过滤器

SkipFilter 包装一个过滤器,当被包装的过滤器遇到一个需要过滤的 KeyValue 实例时,则拓展过滤整行数据。下面是一个使用示例:

1
2
3
4
5
// 定义 ValueFilter 过滤器
Filter filter1 = new ValueFilter(CompareOperator.NOT_EQUAL,
new BinaryComparator(Bytes.toBytes("xxx")));
// 使用 SkipFilter 进行包装
Filter filter2 = new SkipFilter(filter1);

WhileMatchFilter 过滤器

WhileMatchFilter 包装一个过滤器,当被包装的过滤器遇到一个需要过滤的 KeyValue 实例时,WhileMatchFilter 则结束本次扫描,返回已经扫描到的结果。下面是其使用示例:

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
Filter filter1 = new RowFilter(CompareOperator.NOT_EQUAL,
new BinaryComparator(Bytes.toBytes("rowKey4")));

Scan scan = new Scan();
scan.setFilter(filter1);
ResultScanner scanner1 = table.getScanner(scan);
for (Result result : scanner1) {
for (Cell cell : result.listCells()) {
System.out.println(cell);
}
}
scanner1.close();

System.out.println("--------------------");

// 使用 WhileMatchFilter 进行包装
Filter filter2 = new WhileMatchFilter(filter1);

scan.setFilter(filter2);
ResultScanner scanner2 = table.getScanner(scan);
for (Result result : scanner1) {
for (Cell cell : result.listCells()) {
System.out.println(cell);
}
}
scanner2.close();
rowKey0/student:name/1555035006994/Put/vlen=8/seqid=0
rowKey1/student:name/1555035007019/Put/vlen=8/seqid=0
rowKey2/student:name/1555035007025/Put/vlen=8/seqid=0
rowKey3/student:name/1555035007037/Put/vlen=8/seqid=0
rowKey5/student:name/1555035007051/Put/vlen=8/seqid=0
rowKey6/student:name/1555035007057/Put/vlen=8/seqid=0
rowKey7/student:name/1555035007062/Put/vlen=8/seqid=0
rowKey8/student:name/1555035007068/Put/vlen=8/seqid=0
rowKey9/student:name/1555035007073/Put/vlen=8/seqid=0
--------------------
rowKey0/student:name/1555035006994/Put/vlen=8/seqid=0
rowKey1/student:name/1555035007019/Put/vlen=8/seqid=0
rowKey2/student:name/1555035007025/Put/vlen=8/seqid=0
rowKey3/student:name/1555035007037/Put/vlen=8/seqid=0

可以看到被包装后,只返回了 rowKey4 之前的数据。

FilterList

以上都是讲解单个过滤器的作用,当需要多个过滤器共同作用于一次查询的时候,就需要使用 FilterListFilterList 支持通过构造器或者 addFilter 方法传入多个过滤器。

1
2
3
4
5
6
7
8
// 构造器传入
public FilterList(final Operator operator, final List<Filter> filters)
public FilterList(final List<Filter> filters)
public FilterList(final Filter... filters)

// 方法传入
public void addFilter(List<Filter> filters)
public void addFilter(Filter filter)

多个过滤器组合的结果由 operator 参数定义 ,其可选参数定义在 Operator 枚举类中。只有 MUST_PASS_ALLMUST_PASS_ONE 两个可选的值:

  • MUST_PASS_ALL :相当于 AND,必须所有的过滤器都通过才认为通过;
  • MUST_PASS_ONE :相当于 OR,只有要一个过滤器通过则认为通过。
1
2
3
4
5
6
7
@InterfaceAudience.Public
public enum Operator {
/** !AND */
MUST_PASS_ALL,
/** !OR */
MUST_PASS_ONE
}

使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<Filter> filters = new ArrayList<Filter>();

Filter filter1 = new RowFilter(CompareOperator.GREATER_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("XXX")));
filters.add(filter1);

Filter filter2 = new RowFilter(CompareOperator.LESS_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("YYY")));
filters.add(filter2);

Filter filter3 = new QualifierFilter(CompareOperator.EQUAL,
new RegexStringComparator("ZZZ"));
filters.add(filter3);

FilterList filterList = new FilterList(filters);

Scan scan = new Scan();
scan.setFilter(filterList);

参考资料

HBase Java API 基础特性

HBase Client API

HBase Java API 示例

引入依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.hbase</groupId>
<artifactId>hbase-client</artifactId>
<version>2.1.4</version>
</dependency>

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
public class HBaseUtils {

private static Connection connection;

static {
Configuration configuration = HBaseConfiguration.create();
configuration.set("hbase.zookeeper.property.clientPort", "2181");
// 如果是集群 则主机名用逗号分隔
configuration.set("hbase.zookeeper.quorum", "hadoop001");
try {
connection = ConnectionFactory.createConnection(configuration);
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 创建 HBase 表
*
* @param tableName 表名
* @param columnFamilies 列族的数组
*/
public static boolean createTable(String tableName, List<String> columnFamilies) {
try {
HBaseAdmin admin = (HBaseAdmin) connection.getAdmin();
if (admin.tableExists(TableName.valueOf(tableName))) {
return false;
}
TableDescriptorBuilder tableDescriptor = TableDescriptorBuilder.newBuilder(TableName.valueOf(tableName));
columnFamilies.forEach(columnFamily -> {
ColumnFamilyDescriptorBuilder cfDescriptorBuilder = ColumnFamilyDescriptorBuilder.newBuilder(Bytes.toBytes(columnFamily));
cfDescriptorBuilder.setMaxVersions(1);
ColumnFamilyDescriptor familyDescriptor = cfDescriptorBuilder.build();
tableDescriptor.setColumnFamily(familyDescriptor);
});
admin.createTable(tableDescriptor.build());
} catch (IOException e) {
e.printStackTrace();
}
return true;
}

/**
* 删除 hBase 表
*
* @param tableName 表名
*/
public static boolean deleteTable(String tableName) {
try {
HBaseAdmin admin = (HBaseAdmin) connection.getAdmin();
// 删除表前需要先禁用表
admin.disableTable(TableName.valueOf(tableName));
admin.deleteTable(TableName.valueOf(tableName));
} catch (Exception e) {
e.printStackTrace();
}
return true;
}

/**
* 插入数据
*
* @param tableName 表名
* @param rowKey 唯一标识
* @param columnFamilyName 列族名
* @param qualifier 列标识
* @param value 数据
*/
public static boolean putRow(String tableName, String rowKey, String columnFamilyName, String qualifier,
String value) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Put put = new Put(Bytes.toBytes(rowKey));
put.addColumn(Bytes.toBytes(columnFamilyName), Bytes.toBytes(qualifier), Bytes.toBytes(value));
table.put(put);
table.close();
} catch (IOException e) {
e.printStackTrace();
}
return true;
}

/**
* 插入数据
*
* @param tableName 表名
* @param rowKey 唯一标识
* @param columnFamilyName 列族名
* @param pairList 列标识和值的集合
*/
public static boolean putRow(String tableName, String rowKey, String columnFamilyName, List<Pair<String, String>> pairList) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Put put = new Put(Bytes.toBytes(rowKey));
pairList.forEach(pair -> put.addColumn(Bytes.toBytes(columnFamilyName), Bytes.toBytes(pair.getKey()), Bytes.toBytes(pair.getValue())));
table.put(put);
table.close();
} catch (IOException e) {
e.printStackTrace();
}
return true;
}

/**
* 根据 rowKey 获取指定行的数据
*
* @param tableName 表名
* @param rowKey 唯一标识
*/
public static Result getRow(String tableName, String rowKey) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Get get = new Get(Bytes.toBytes(rowKey));
return table.get(get);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

/**
* 获取指定行指定列 (cell) 的最新版本的数据
*
* @param tableName 表名
* @param rowKey 唯一标识
* @param columnFamily 列族
* @param qualifier 列标识
*/
public static String getCell(String tableName, String rowKey, String columnFamily, String qualifier) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Get get = new Get(Bytes.toBytes(rowKey));
if (!get.isCheckExistenceOnly()) {
get.addColumn(Bytes.toBytes(columnFamily), Bytes.toBytes(qualifier));
Result result = table.get(get);
byte[] resultValue = result.getValue(Bytes.toBytes(columnFamily), Bytes.toBytes(qualifier));
return Bytes.toString(resultValue);
} else {
return null;
}

} catch (IOException e) {
e.printStackTrace();
}
return null;
}

/**
* 检索全表
*
* @param tableName 表名
*/
public static ResultScanner getScanner(String tableName) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Scan scan = new Scan();
return table.getScanner(scan);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

/**
* 检索表中指定数据
*
* @param tableName 表名
* @param filterList 过滤器
*/

public static ResultScanner getScanner(String tableName, FilterList filterList) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Scan scan = new Scan();
scan.setFilter(filterList);
return table.getScanner(scan);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

/**
* 检索表中指定数据
*
* @param tableName 表名
* @param startRowKey 起始 RowKey
* @param endRowKey 终止 RowKey
* @param filterList 过滤器
*/

public static ResultScanner getScanner(String tableName, String startRowKey, String endRowKey,
FilterList filterList) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(startRowKey));
scan.withStopRow(Bytes.toBytes(endRowKey));
scan.setFilter(filterList);
return table.getScanner(scan);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

/**
* 删除指定行记录
*
* @param tableName 表名
* @param rowKey 唯一标识
*/
public static boolean deleteRow(String tableName, String rowKey) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Delete delete = new Delete(Bytes.toBytes(rowKey));
table.delete(delete);
} catch (IOException e) {
e.printStackTrace();
}
return true;
}

/**
* 删除指定行指定列
*
* @param tableName 表名
* @param rowKey 唯一标识
* @param familyName 列族
* @param qualifier 列标识
*/
public static boolean deleteColumn(String tableName, String rowKey, String familyName,
String qualifier) {
try {
Table table = connection.getTable(TableName.valueOf(tableName));
Delete delete = new Delete(Bytes.toBytes(rowKey));
delete.addColumn(Bytes.toBytes(familyName), Bytes.toBytes(qualifier));
table.delete(delete);
table.close();
} catch (IOException e) {
e.printStackTrace();
}
return true;
}

}

数据库连接

在上面的代码中,在类加载时就初始化了 Connection 连接,并且之后的方法都是复用这个 Connection,这时我们可能会考虑是否可以使用自定义连接池来获取更好的性能表现?实际上这是没有必要的。

首先官方对于 Connection 的使用说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Connection Pooling For applications which require high-end multithreaded
access (e.g., web-servers or application servers that may serve many
application threads in a single JVM), you can pre-create a Connection,
as shown in the following example:

对于高并发多线程访问的应用程序(例如,在单个 JVM 中存在的为多个线程服务的 Web 服务器或应用程序服务器),
您只需要预先创建一个 Connection。例子如下:

// Create a connection to the cluster.
Configuration conf = HBaseConfiguration.create();
try (Connection connection = ConnectionFactory.createConnection(conf);
Table table = connection.getTable(TableName.valueOf(tablename))) {
// use table as needed, the table returned is lightweight
}

之所以能这样使用,这是因为 Connection 并不是一个简单的 socket 连接,接口文档 中对 Connection 的表述是:

1
2
3
4
5
6
7
A cluster connection encapsulating lower level individual connections to actual servers and a
connection to zookeeper. Connections are instantiated through the ConnectionFactory class.
The lifecycle of the connection is managed by the caller, who has to close() the connection
to release the resources.

Connection 是一个集群连接,封装了与多台服务器(Matser/Region Server)的底层连接以及与 zookeeper 的连接。
连接通过 ConnectionFactory 类实例化。连接的生命周期由调用者管理,调用者必须使用 close() 关闭连接以释放资源。

之所以封装这些连接,是因为 HBase 客户端需要连接三个不同的服务角色:

  • Zookeeper :主要用于获取 meta 表的位置信息,Master 的信息;
  • HBase Master :主要用于执行 HBaseAdmin 接口的一些操作,例如建表等;
  • HBase RegionServer :用于读、写数据。

Connection 对象和实际的 Socket 连接之间的对应关系如下图:

在 HBase 客户端代码中,真正对应 Socket 连接的是 RpcConnection 对象。HBase 使用 PoolMap 这种数据结构来存储客户端到 HBase 服务器之间的连接。PoolMap 的内部有一个 ConcurrentHashMap 实例,其 key 是 ConnectionId(封装了服务器地址和用户 ticket),value 是一个 RpcConnection 对象的资源池。当 HBase 需要连接一个服务器时,首先会根据 ConnectionId 找到对应的连接池,然后从连接池中取出一个连接对象。

1
2
3
4
5
6
7
8
9
10
11
12
@InterfaceAudience.Private
public class PoolMap<K, V> implements Map<K, V> {
private PoolType poolType;

private int poolMaxSize;

private Map<K, Pool<V>> pools = new ConcurrentHashMap<>();

public PoolMap(PoolType poolType) {
this.poolType = poolType;
}
.....

HBase 中提供了三种资源池的实现,分别是 ReusableRoundRobinThreadLocal。具体实现可以通 hbase.client.ipc.pool.type 配置项指定,默认为 Reusable。连接池的大小也可以通过 hbase.client.ipc.pool.size 配置项指定,默认为 1,即每个 Server 1 个连接。也可以通过修改配置实现:

1
2
3
config.set("hbase.client.ipc.pool.type",...);
config.set("hbase.client.ipc.pool.size",...);
connection = ConnectionFactory.createConnection(config);

由此可以看出 HBase 中 Connection 类已经实现了对连接的管理功能,所以我们不必在 Connection 上在做额外的管理。

另外,Connection 是线程安全的,但 Table 和 Admin 却不是线程安全的,因此正确的做法是一个进程共用一个 Connection 对象,而在不同的线程中使用单独的 Table 和 Admin 对象。Table 和 Admin 的获取操作 getTable()getAdmin() 都是轻量级,所以不必担心性能的消耗,同时建议在使用完成后显示的调用 close() 方法来关闭它们。

概述

HBase 的主要客户端操作是由 org.apache.hadoop.hbase.client.HTable 提供的。创建 HTable 实例非常耗时,所以,建议每个线程只创建一次 HTable 实例。

HBase 所有修改数据的操作都保证了行级别的原子性。要么读到最新的修改,要么等待系统允许写入改行修改

用户要尽量使用批处理 (batch) 更新来减少单独操作同一行数据的次数

写操作中设计的列的数目并不会影响该行数据的原子性,行原子性会同时保护到所有列

创建 HTable 实例(指的是在 java 中新建该类),每个实例都要扫描。META. 表,以检查该表是否存在,推荐用户只创建一次 HTable 实例,而且是每个线程创建一个

如果用户需要多个 HTable 实例,建议使用 HTablePool 类(类似连接池)

CRUD 操作

put

Table 接口提供了两个 put 方法

1
2
3
4
// 写入单行 put
void put(Put put) throws IOException;
// 批量写入 put
void put(List<Put> puts) throws IOException;

Put 类提供了多种构造器方法用来初始化实例。

Put 类还提供了一系列有用的方法:

多个 add 方法:用于添加指定的列数据。

has 方法:用于检查是否存在特定的单元格,而不需要遍历整个集合

getFamilyMap 方法:可以遍历 Put 实例中每一个可用的 KeyValue 实例

getRow 方法:用于获取 rowkey
Put.heapSize() 可以计算当前 Put 实例所需的堆大小,既包含其中的数据,也包含内部数据结构所需的空间

KeyValue 类

特定单元格的数据以及坐标,坐标包括行键、列族名、列限定符以及时间戳
KeyValue(byte[] row, int roffset, int rlength, byte[] family, int foffoset, int flength, byte[] qualifier, int qoffset, int qlength, long timestamp, Type type, byte[] value, int voffset, int vlength)
每一个字节数组都有一个 offset 参数和一个 length 参数,允许用户提交一个已经存在的字节数组进行字节级别操作。
行目前来说指的是行键,即 Put 构造器里的 row 参数。

客户端的写缓冲区

每一个 put 操作实际上都是一个 RPC 操作,它将客户端数据传送到服务器然后返回。

HBase 的 API 配备了一个客户端的写缓冲区,缓冲区负责收集 put 操作,然后调用 RPC 操作一次性将 put 送往服务器。

1
2
void setAutoFlush(boolean autoFlush)
boolean isAutoFlush()

默认情况下,客户端缓冲区是禁用的。可以通过 table.setAutoFlush(false) 来激活缓冲区。

Put 列表

批量提交 put 列表:

1
void put(List<Put> puts) throws IOException

注意:批量提交可能会有部分修改失败。

原子性操作 compare-and-set

checkAndPut 方法提供了 CAS 机制来保证 put 操作的原子性。

get

1
Result get(Get get) throws IOException
1
2
3
4
Get(byte[] row)
Get(byte[] row, RowLock rowLock)
Get addColumn(byte[] family, byte[] qualifier)
Get addFamily(byte[] family)

Result 类

当用户使用 get() 方法获取数据,HBase 返回的结果包含所有匹配的单元格数据,这些数据被封装在一个 Result 实例中返回给用户。

Result 类提供的方法如下:

1
2
3
4
5
6
7
byte[] getValue(byte[] family, byte[] qualifier)
byte[] value()
byte[] getRow()
int size()
boolean isEmpty()
KeyValue[] raw()
List<KeyValue> list()

delete

1
void delete(Delete delete) throws IOException
1
2
Delte(byte[] row)
Delete(byte[] row, long timestamp, RowLock rowLock)
1
2
3
4
Delete deleteFamily(byte[] family)
Delete deleteFamily(byte[] family, long timestamp)
Delete deleteColumns(byte[] family, byte[] qualifier)
Delete deleteColumn(byte[] family, byte[] qualifier) // 只删除最新版本

批处理操作

Row 是 Put、Get、Delete 的父类。

1
2
void batch(List<Row> actions, Object[] results) throws IOException, InterruptedException
Object batch(List<Row> actions) throws IOException, InterruptedException

行锁

region 服务器提供了行锁特性,这个特性保证了只有一个客户端能获取一行数据相应的锁,同时对该行进行修改。

如果不显示指定锁,服务器会隐式加锁。

扫描

scan,类似数据库系统中的 cursor,利用了 HBase 提供的底层顺序存储的数据结构。

调用 HTable 的 getScanner 就可以返回扫描器

1
2
ResultScanner getScanner(Scan scan) throws IOException
ResultScanner getScanner(byte[] family) throws IOException

Scan 类构造器可以有 startRow,区间一般为 [startRow, stopRow)

1
2
Scan(byte[] startRow, Filter filter)
Scan(byte[] startRow)

ResultScanner

以行为单位进行返回

1
2
3
Result next() throws IOException
Result[] next(int nbRows) throws IOException
void close()

缓存与批量处理

每一个 next() 调用都会为每行数据生成一个单独的 RPC 请求

可以设置扫描器缓存

1
2
void setScannerCaching(itn scannerCaching)
int getScannerCaching()

缓存是面向行一级操作,批量是面向列一级操作

1
2
void setBatch(int batch)
int getBatch

RPC 请求的次数=(行数、*每行列数)/Min(每行的列数,批量大小)/扫描器缓存

各种特性

Bytes 类提供了一系列将原生 Java 类型和字节数组互转的方法。

参考资料

HBase Schema 设计

HBase Schema 设计要素

  • 这个表应该有多少 Column Family
  • Column Family 使用什么数据
  • 每个 Column Family 有有多少列
  • 列名是什么,尽管列名不必在建表时定义,但读写数据是要知道的
  • 单元应该存放什么数据
  • 每个单元存储多少时间版本
  • 行健(rowKey)结构是什么,应该包含什么信息

Row Key 设计

Row Key 的作用

在 HBase 中,所有对表的访问都要通过 Row Key,有三种访问方式:

  • 使用 get 命令,查询指定的 Row Key,即精确查找。
  • 使用 scan 命令,根据 Row Key 进行范围查找。
  • 全表扫描,即直接扫描表中所有行记录。

此外,在 HBase 中,表中的行,是按照 Row Key 的字典序进行排序的。

由此,可见,Row Key 的良好设计对于 HBase CRUD 的性能至关重要。

Row Key 的设计原则

长度原则

RowKey 是一个二进制码流,可以是任意字符串,最大长度为 64kb,实际应用中一般为 10-100byte,以 byte[]形式保存,一般设计成定长。建议越短越好,不要超过 16 个字节,原因如下:

  1. 数据的持久化文件 HFile 中时按照 Key-Value 存储的,如果 RowKey 过长,例如超过 100byte,那么 1000w 行的记录,仅 RowKey 就需占用近 1GB 的空间。这样会极大影响 HFile 的存储效率。
  2. MemStore 会缓存部分数据到内存中,若 RowKey 字段过长,内存的有效利用率就会降低,就不能缓存更多的数据,从而降低检索效率。
  3. 目前操作系统都是 64 位系统,内存 8 字节对齐,控制在 16 字节,8 字节的整数倍利用了操作系统的最佳特性。

唯一原则

必须在设计上保证 RowKey 的唯一性。由于在 HBase 中数据存储是 Key-Value 形式,若向 HBase 中同一张表插入相同 RowKey 的数据,则原先存在的数据会被新的数据覆盖。

排序原则

HBase 的 RowKey 是按照 ASCII 有序排序的,因此我们在设计 RowKey 的时候要充分利用这点。

散列原则

设计的 RowKey 应均匀的分布在各个 HBase 节点上。

热点问题

Region 是在 HBase 集群上分布数据的最小单位。每个 Region 由它所属的表的起始范围来表示(即起始 Row Key 和结束 Row Key)。

如果,Row Key 使用单调递增的整数或时间戳,就会产生一个问题:因为 Hbase 的 Row Key 是就近存储的,这会导致一段时间内大部分读写集中在某一个 Region 或少数 Region 上(根据二八原则,最近产生的数据,往往是读写频率最高的数据),即所谓 热点问题

反转(Reversing)

第一种咱们要分析的方法是反转,顾名思义它就是把固定长度或者数字格式的 RowKey 进行反转,反转分为一般数据反转和时间戳反转,其中以时间戳反转较常见。

  • 反转固定格式的数值 - 以手机号为例,手机号的前缀变化比较少(如 152、185 等),但后半部分变化很多。如果将它反转过来,可以有效地避免热点。不过其缺点就是失去了有序性。
  • 反转时间 - 如果数据访问以查找最近的数据为主,可以将时间戳存储为反向时间戳(例如: timestamp = Long.MAX_VALUE – timestamp),这样有利于扫描最近的数据。

加盐(Salting)

这里的“加盐”与密码学中的“加盐”不是一回事。它是指在 RowKey 的前面增加一些前缀,加盐的前缀种类越多,RowKey 就被打得越散。

需要注意的是分配的随机前缀的种类数量应该和我们想把数据分散到的那些 region 的数量一致。只有这样,加盐之后的 rowkey 才会根据随机生成的前缀分散到各个 region 中,避免了热点现象。

哈希(Hashing)

其实哈希和加盐的适用场景类似,但我们前缀不可以是随机的,因为必须要让客户端能够完整地重构 RowKey。所以一般会拿原 RowKey 或其一部分计算 Hash 值,然后再对 Hash 值做运算作为前缀。

HBase Schema 设计规则

Column Family 设计

HBase 不能很好处理 2 ~ 3 个以上的 Column Family,所以 HBase 表应尽可能减少 Column Family 数。如果可以,请只使用一个列族,只有需要经常执行 Column 范围查询时,才引入多列族。也就是说,尽量避免同时查询多个列族。

  • Column Family 数量多,会影响数据刷新。HBase 的数据刷新是在每个 Region 的基础上完成的。因此,如果一个 Column Family 携带大量导致刷新的数据,那么相邻的列族即使携带的数据量很小,也会被刷新。当存在许多 Column Family 时,刷新交互会导致一堆不必要的 IO。 此外,在表/区域级别的压缩操作也会在每个存储中发生。
  • Column Family 数量多,会影响查找效率。如:Column Family A 有 100 万行,Column Family B 有 10 亿行,那么 Column Family A 的数据可能会分布在很多很多区域(和 RegionServers)。 这会降低 Column Family A 的批量扫描效率。

Column Family 名尽量简短,最好是一个字符。Column Family 会在列限定符中被频繁使用,缩短长度有利于节省空间并提升效率。

Row 设计

HBase 中的 Row 按 Row Key 的字典顺序排序

  • 不要将 Row Key 设计为单调递增的,例如:递增的整数或时间戳

    • 问题:因为 Hbase 的 Row Key 是就近存储的,这样会导致一段时间内大部分写入集中在某一个 Region 上,即所谓热点问题。

    • 解决方法一、加盐:这里的不是指密码学的加盐,而是指将随机分配的前缀添加到行键的开头。这么做是为了避免相同前缀的 Row Key 数据被存储在相邻位置,从而导致热点问题。示例如下:

      • foo0001
        foo0002
        foo0003
        foo0004
        
        改为
        
        a-foo0003
        b-foo0001
        c-foo0003
        c-foo0004
        d-foo0002
        
        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

        - 解决方法二、Hash:Row Key 的前缀使用 Hash

        - **尽量减少行和列的长度**

        - **反向时间戳**:反向时间戳可以极大地帮助快速找到值的最新版本。

        - **行健不能改变**:唯一可以改变的方式是先删除后插入。

        - **Row Key 和 Column Family**:Row Key 从属于 Column Family,因此,相同的 Row Key 可以存在每一个 Column Family 中而不会出现冲突。

        ### Version 设计

        最大、最小 Row 版本号:表示 HBase 会保留的版本号数的上下限。均可以通过 HColumnDescriptor 对每个列族进行配置

        Row 版本号过大,会大大增加 StoreFile 的大小;所以,最大 Row 版本号应按需设置。HBase 会在主要压缩时,删除多余的版本。

        ### TTL 设计

        Column Family 会设置一个以秒为单位的 TTL,一旦达到 TTL 时,HBase 会自动删除行记录。

        仅包含过期行的存储文件在次要压缩时被删除。 将 hbase.store.delete.expired.storefile 设置为 false 会禁用此功能。将最小版本数设置为 0 以外的值也会禁用此功能。

        在较新版本的 HBase 上,还支持在 Cell 上设置 TTL,与 Column Family 的 TTL 不同的是,单位是毫秒。

        ### Column Family 属性配置

        - HFile 数据块,默认是 64KB,数据库的大小影响数据块索引的大小。数据块大的话一次加载进内存的数据越多,扫描查询效果越好。但是数据块小的话,随机查询性能更好

create ‘mytable’,{NAME => ‘cf1’, BLOCKSIZE => ‘65536’}
复制代码

1
2
3

- 数据块缓存,数据块缓存默认是打开的,如果一些比较少访问的数据可以选择关闭缓存

create ‘mytable’,{NAME => ‘cf1’, BLOCKCACHE => ‘FALSE’}
复制代码

1
2
3

- 数据压缩,压缩会提高磁盘利用率,但是会增加 CPU 的负载,看情况进行控制

create ‘mytable’,{NAME => ‘cf1’, COMPRESSION => ‘SNAPPY’}
复制代码

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

Hbase 表设计是和需求相关的,但是遵守表设计的一些硬性指标对性能的提升还是很有帮助的,这里整理了一些设计时用到的要点。

## Schema 设计案例

### 案例:日志数据和时序数据

假设采集以下数据

- Hostname
- Timestamp
- Log event
- Value/message

应该如何设计 Row Key?

1TimestampRow Key 头部

如果 Row Key 设计为 `[timestamp][hostname][log-event]` 形式,会出现热点问题。

如果针对时间的扫描很重要,可以采用时间戳分桶策略,即

bucket = timestamp % bucketNum

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

计算出桶号后,将 Row Key 指定为:`[bucket][timestamp][hostname][log-event]`

如上所述,要为特定时间范围选择数据,需要对每个桶执行扫描。 例如,100 个桶将在键空间中提供广泛的分布,但需要 100 次扫描才能获取单个时间戳的数据,因此需要权衡取舍。

2)Hostname 在 Row Key 头部

如果主机样本量很大,将 Row Key 设计为 `[hostname][log-event][timestamp]`,这样有利于扫描 hostname。

3Timestamp 还是反向 Timestamp

如果数据访问以查找最近的数据为主,可以将时间戳存储为反向时间戳(例如: `timestamp = Long.MAX_VALUE – timestamp`),这样有利于扫描最近的数据。

4Row Key 是可变长度还是固定长度

拼接 Row Key 的关键字长度不一定是固定的,例如 hostname 有可能很长,也有可能很短。如果想要统一长度,可以参考以下做法:

- 将关键字 Hash 编码:使用某种 Hash 算法计算关键字,并取固定长度的值(例如:8 位或 16 位)。
- 使用数字替代关键字:例如:使用事件类型 Code 替换事件类型;hostname 如果是 IP,可以转换为 long
- 截取关键字:截取后的关键字需要有足够的辨识度,长度大小根据具体情况权衡。

5)时间分片

[hostname][log-event][timestamp1]
[hostname][log-event][timestamp2]
[hostname][log-event][timestamp3]

1
2
3

上面的例子中,每个详细事件都有单独的行键,可以重写如下,即每个时间段存储一次:

[hostname][log-event][timerange]


## 参考资料

- [HBase 官方文档之 HBase and Schema Design](https://hbase.apache.org/book.html#schema)

《极客时间教程 - 大规模数据处理实战》笔记

00 丨开篇词丨从这里开始,带你走上硅谷一线系统架构师之路

01 丨为什么 MapReduce 会被硅谷一线公司淘汰?

高昂的维护成本

时间性能“达不到”用户的期待

02 | MapReduce 后谁主沉浮:怎样设计下一代数据处理技术?

03 | 大规模数据处理初体验:怎样实现大型电商热销榜?

不同量级 TOP K 算法的解决方案不同:

小规模:Hash 即可

大规模:由于单机的处理量不足以处理全量数据,势必分而治之:分片统计,然后聚合(即先 map 后 reduce)

04 丨分布式系统(上):学会用服务等级协议 SLA 来评估你的系统

SLA(Service-Level Agreement),也就是服务等级协议,指的是系统服务提供者(Provider)对客户(Customer)的一个服务承诺。

可用性:大厂一般要求可用性至少达到四个 9(即 99.99%)

准确性:准确率= 正确的有效请求数 / 有效的总请求数

系统容量:通常通过 QPS (Queries Per Second)来衡量

延迟:请求和响应的时间间隔

05 丨分布式系统(下):架构师不得不知的三大指标

  • 可扩展性(Scalability)
    • 水平扩展(Horizontal Scaling)
    • 垂直扩展(Vertical Scaling)
  • 一致性(Consistency)
    • 强一致性(Strong Consistency):系统中的某个数据被成功更新后,后续任何对该数据的读取操作都将得到更新
      后的值。所以在任意时刻,同一系统所有节点中的数据是一样的。
    • 弱一致性(Weak Consistency):系统中的某个数据被更新后,后续对该数据的读取操作可能得到更新后的值,
      也可能是更改前的值。但经过“不一致时间窗口”这段时间后,后续对该数据的读取都是更新后的值。
    • 最终一致性(Eventual Consistency):是弱一致性的特殊形式。存储系统保证,在没有新的更新的条件下,最终所有的访问都是最后更新的值。
  • 持久性(Data Durability):意味着数据一旦被成功存储就可以一直继续使用,即使系统中的节点下线、宕机或数据损坏也是如。

06 | 如何区分批处理还是流处理?

  • 无边界数据(Unbounded Data):是一种不断增长,可以说是无限的数据集。
  • 有边界数据(Bounded Data):是一种有限的数据集。
  • 事件时间(Event Time):指的是一个数据实际产生的时间点。
  • 处理时间(Precessing Time):指的是处理数据的系统架构实际接收到这个数据的时间点。
  • 批处理:绝大部分情况下,批处理的输入数据都是有边界数据,同样的,输出结果也一样是有边界数据。所以在批处理中,我们所关心的更多会是数据的事件时间。
    • 应用场景:
      • 日志分析:日志系统是在一定时间段(日,周或年)内收集的,而日志的数据处理分析是在不同的时间内执行,以得出有关系统的一些关键性能指标。
      • 计费应用程序:计费应用程序会计算出一段时间内一项服务的使用程度,并生成计费信息,例如银行在每个月末生成的信用卡还款单。
      • 数据仓库:数据仓库的主要目标是根据收集好的数据事件时间,将数据信息合并为静态快照 (static snapshot),并将它们聚合为每周、每月、每季度的报告等。
  • 流处理:流处理的输入数据基本上都是无边界数据。
    • 应用场景
      • 实时监控:捕获和分析各种来源发布的数据,如传感器,新闻源,点击网页等。
      • 实时商业智能:智能汽车,智能家居,智能病人护理等。
      • 销售终端(POS)系统:像是股票价格的更新,允许用户实时完成付款的系统等。

07 | Workflow 设计模式:让你在大规模数据世界中君临天下

08 | 发布/订阅模式:流处理架构中的瑞士军刀

09 丨 CAP 定理:三选二,架构师必须学会的取舍

10 丨 Lambda 架构:Twitter 亿级实时数据分析架构背后的倚天剑

Lambda 架构总共由三层系统组成:批处理层(Batch Layer),速度处理层(Speed Layer),以及用于响应查询的服务层(Serving Layer)。

11 丨 Kappa 架构:利用 Kafka 锻造的屠龙刀

12 | 我们为什么需要 Spark?

MapReduce 的缺点:

  • 高昂的维护成本
  • 时间性能“达不到”用户的期待
  • MapReduce 模型的抽象层次低
  • 只提供 Map 和 Reduce 两个操作
  • 在 Hadoop 中,每一个 Job 的计算结果都会存储在 HDFS 文件存储系统中,所以每一步计算都要进行硬盘的读取和写入,大大增加了系统的延迟。
  • 只支持批处理

Spark 的优点

  • 性能比 MapReduce 高很多
  • Spark 提供了很多对 RDD 的操作,如 Map、Filter、flatMap、groupByKey 和 Union 等等,极大地提升了对各种复杂场景的支持

13 丨弹性分布式数据集:Spark 大厦的地基(上)

Spark 最基本的数据抽象是弹性分布式数据集(Resilient Distributed Dataset)

RDD 表示已被分区、不可变的,并能够被并行操作的数据集合。

14 丨弹性分布式数据集:Spark 大厦的地基(下)

15 丨 SparkSQL:Spark 数据查询的利器

16 | Spark Streaming:Spark 的实时流计算 API

Spark Streaming 用时间片拆分了无限的数据流,然后对每一个数据片用类似于批处理的方法进行处理,输出的数据也是一块一块的

17 | Structured Streaming:如何用 DataFrame API 进行实时数据分析?

18 丨 WordCount:从零开始运行你的第一个 Spark 应用

19 丨综合案例实战:处理加州房屋信息,构建线性回归模型

20 丨流处理案例实战:分析纽约市出租车载客信息


读到此处,感觉收获甚少,暂时搁置阅读。

参考资料

《极客时间教程 - 从 0 开始学大数据》笔记

预习模块

01 丨预习 01 丨大数据技术发展史:大数据的前世今生

大数据技术,起源于 Google 在 2004 年前后发表的三篇论文:

Doug Cutting 根据 Google 论文开发了 Hadoop。

大数据处理的主要应用场景包括数据分析、数据挖掘与机器学习。

数据分析主要使用 Hive、Spark SQL 等 SQL 引擎完成;

数据挖掘与机器学习则有专门的机器学习框架 TensorFlow、Mahout 以及 MLlib 等,内置了主要的机器学习和数据挖掘算法。

大数据要存入分布式文件系统(HDFS),要有序调度 MapReduce 和 Spark 作业执行,并能把执行结果写入到各个应用系统的数据库中,还需要有一个大数据平台整合所有这些大数据组件和企业应用系统。

02 丨预习 02 丨大数据应用发展史:从搜索引擎到人工智能

大数据的应用领域:

  • 搜索引擎:GFS 和 MapReduce 开启了超大规模的分布式存储和分布式计算应用。
  • 数据仓库:Hive 实现了用更低廉的价格获得比以往多得多的数据存储与计算能力。
  • 数据挖掘:基于海量数据进行关联分析。应用有:关联推荐、用户画像、关系图谱
  • 机器学习:有了大数据,可以把全部的历史数据都收集起来,统计其规律,进而预测正在发生的事情。

03 丨预习 03 丨大数据应用领域:数据驱动一切

大数据的行业应用:

  • 医疗健康领域
    • 医学影像智能识别
    • 病历大数据智能诊疗
  • 教育领域
    • AI 外语老师
    • 智能解题
  • 社交媒体领域:舆情监控与分析
  • 金融领域:大数据风控
  • 新零售领域:全链路管理
  • 交通领域
    • 实时采集监控数据
    • 判断道路拥堵状态
    • 无人驾驶技术

模块一、Hadoop 大数据原理与架构

04 | 移动计算比移动数据更划算

传统计算模型:输入 -> 计算 -> 输出,面对海量数据(TB 级甚至 PB 级),无法应对。

移动计算将程序分发到数据所在的地方进行计算,也就是所谓的移动计算比移动数据更划算。

移动计算步骤:

  1. 将待处理的大规模数据存储在服务器集群的所有服务器上,主要使用 HDFS 分布式文件存储系统,将文件分成很多块(Block),以块为单位存储在集群的服务器上。
  2. 大数据引擎根据集群里不同服务器的计算能力,在每台服务器上启动若干分布式任务执行进程,这些进程会等待给它们分配执行任务。
  3. 使用大数据计算框架支持的编程模型进行编程,比如 Hadoop 的 MapReduce 编程模型,或者 Spark 的 RDD 编程模型。应用程序编写好以后,将其打包,MapReduce 和 Spark 都是在 JVM 环境中运行,所以打包出来的是一个 Java 的 JAR 包。
  4. 用 Hadoop 或者 Spark 的启动命令执行这个应用程序的 JAR 包,首先执行引擎会解析程序要处理的数据输入路径,根据输入数据量的大小,将数据分成若干片(Split),每一个数据片都分配给一个任务执行进程去处理。
  5. 任务执行进程收到分配的任务后,检查自己是否有任务对应的程序包,如果没有就去下载程序包,下载以后通过反射的方式加载程序。走到这里,最重要的一步,也就是移动计算就完成了。
  6. 加载程序后,任务执行进程根据分配的数据片的文件地址和数据在文件内的偏移量读取数据,并把数据输入给应用程序相应的方法

05 | 从 RAID 看垂直伸缩到水平伸缩的演化

海量数据存储核心问题

  • 数据存储容量的问题。既然大数据要解决的是数以 PB 计的数据计算问题,而一般的服务器磁盘容量通常 1 ~ 2TB,那么如何存储这么大规模的数据呢?
  • 数据读写速度的问题。一般磁盘的连续读写速度为几十 MB,以这样的速度,几十 PB 的数据恐怕要读写到天荒地老。
  • 数据可靠性的问题。磁盘大约是计算机设备中最易损坏的硬件了,通常情况一块磁盘使用寿命大概是一年,如果磁盘损坏了,数据怎么办?

解决方式是 RAID 技术:

  • 数据存储容量的问题。RAID 使用了 N 块磁盘构成一个存储阵列,如果使用 RAID 5,数据就可以存储在 N-1 块磁盘上,这样将存储空间扩大了 N-1 倍。
  • 数据读写速度的问题。RAID 根据可以使用的磁盘数量,将待写入的数据分成多片,并发同时向多块磁盘进行写入,显然写入的速度可以得到明显提高;同理,读取速度也可以得到明显提高。不过,需要注意的是,由于传统机械磁盘的访问延迟主要来自于寻址时间,数据真正进行读写的时间可能只占据整个数据访问时间的一小部分,所以数据分片后对 N 块磁盘进行并发读写操作并不能将访问速度提高 N 倍。
  • 数据可靠性的问题。使用 RAID 10、RAID 5 或者 RAID 6 方案的时候,由于数据有冗余存储,或者存储校验信息,所以当某块磁盘损坏的时候,可以通过其他磁盘上的数据和校验数据将丢失磁盘上的数据还原。

实现更强的计算能力和更大规模的数据存储有两种思路

  • 垂直伸缩(scaling up),即硬件升级
  • 水平伸缩(scaling out),即分布式系统

注:RAID 技术就是采用了垂直伸缩的方式。

06 | 新技术层出不穷,HDFS 依然是存储的王者

img

HDFS 有两个关键组件:DataNode 和 NameNode:

  • DataNode 负责文件数据的存储和读写操作,HDFS 将文件数据分割成若干数据块(Block),每个 DataNode 存储一部分数据块,这样文件就分布存储在整个 HDFS 服务器集群中。应用程序客户端(Client)可以并行对这些数据块进行访问,从而使得 HDFS 可以在服务器集群规模上实现数据并行访问,极大地提高了访问速度。
  • NameNode 负责整个分布式文件系统的元数据(MetaData)管理,也就是文件路径名、数据块的 ID 以及存储位置等信息,相当于操作系统中文件分配表(FAT)的角色。

为保证高可用,HDFS 会,会将一个数据块复制为多份(默认为 3 份),并将多份相同的数据块存储在不同的服务器上,甚至不同的机架上。

img

HDFS 故障容错:

  • 数据存储故障容错
    • 对于 DataNode 上的数据块进行计算并存储校验和(CheckSum)
    • 读取数据的时候,重新计算读取出来的数据的校验和,校验和不正确,则抛出异常
    • 发现异常后,从其他 DataNode 读取备份
  • 磁盘故障容错
    • 如果 DataNode 监测到本机磁盘损坏,将该磁盘的所有数据块 ID 报告给 NameNode
    • NameNode 检查这些数据块在哪些 DataNode 上有备份,复制一份到其他 DataNode 上
  • DataNode 故障容错
    • DataNode 会通过心跳和 NameNode 保持通信
    • 如果 DataNode 超时未发送心跳,NameNode 视其为宕机
    • NameNode 查找这个 DataNode 存储的所有数据块,复制一份到其他 DataNode 上
  • NameNode 故障容错
    • 基于 ZooKeeper 实现主从备份
    • 争夺 znode 锁

07 | 为什么说 MapReduce 既是编程模型又是计算框架?

MapReduce 既是编程模型,又是计算框架

MapReduce 编程模型只包含 Map 和 Reduce 两个过程,map 的主要输入是一对 <Key, Value> 值,经过 map 计算后输出一对 <Key, Value> 值;然后将相同 Key 合并,形成 <Key, Value 集合>;再将这个 <Key, Value 集合> 输入 reduce,经过计算输出零个或多个 <Key, Value> 对。

08 | MapReduce 如何让数据完成一次旅行?

MapReduce 作业启动和运行机制

大数据应用进程。这类进程是启动 MapReduce 程序的主入口,主要是指定 Map 和 Reduce 类、输入输出文件路径等,并提交作业给 Hadoop 集群,也就是下面提到的 JobTracker 进程。这是由用户启动的 MapReduce 程序进程,比如我们上期提到的 WordCount 程序。

JobTracker 进程。这类进程根据要处理的输入数据量,命令下面提到的 TaskTracker 进程启动相应数量的 Map 和 Reduce 进程任务,并管理整个作业生命周期的任务调度和监控。这是 Hadoop 集群的常驻进程,需要注意的是,JobTracker 进程在整个 Hadoop 集群全局唯一。

TaskTracker 进程。这个进程负责启动和管理 Map 进程以及 Reduce 进程。因为需要每个数据块都有对应的 map 函数,TaskTracker 进程通常和 HDFS 的 DataNode 进程启动在同一个服务器。也就是说,Hadoop 集群中绝大多数服务器同时运行 DataNode 进程和 TaskTracker 进程。

MapReduce 数据合并与连接机制

在 map 输出与 reduce 输入之间,MapReduce 计算框架处理数据合并与连接操作,这个操作有个专门的词汇叫 shuffle。分布式计算需要将不同服务器上的相关数据合并到一起进行下一步计算,这就是 shuffle。

09 | 为什么我们管 Yarn 叫作资源调度框架?

服务器集群资源调度管理和 MapReduce 执行过程耦合在一起,如果想在当前集群中运行其他计算任务,比如 Spark 或者 Storm,就无法统一使用集群中的资源了。

Yarn 包括两个部分:

ResourceManager 进程负责整个集群的资源调度管理,通常部署在独立的服务器上;

NodeManager 进程负责具体服务器上的资源和任务管理,在集群的每一台计算服务器上都会启动,基本上跟 HDFS 的 DataNode 进程一起出现。

Yarn 的工作流程

  1. 我们向 Yarn 提交应用程序,包括 MapReduce ApplicationMaster、我们的 MapReduce 程序,以及 MapReduce Application 启动命令。
  2. ResourceManager 进程和 NodeManager 进程通信,根据集群资源,为用户程序分配第一个容器,并将 MapReduce ApplicationMaster 分发到这个容器上面,并在容器里面启动 MapReduce ApplicationMaster。
  3. MapReduce ApplicationMaster 启动后立即向 ResourceManager 进程注册,并为自己的应用程序申请容器资源。
  4. MapReduce ApplicationMaster 申请到需要的容器后,立即和相应的 NodeManager 进程通信,将用户 MapReduce 程序分发到 NodeManager 进程所在服务器,并在容器中运行,运行的就是 Map 或者 Reduce 任务。
  5. Map 或者 Reduce 任务在运行期和 MapReduce ApplicationMaster 通信,汇报自己的运行状态,如果运行结束,MapReduce ApplicationMaster 向 ResourceManager 进程注销并释放所有的容器资源。

10 | 模块答疑:我们能从 Hadoop 学到什么?

Hadoop 几个主要产品的架构设计,就会发现它们都有相似性,都是一主多从的架构方案。

  • HDFS,一个 NameNode,多个 DataNode;
  • MapReduce,一个 JobTracker,多个 TaskTracker;
  • Yarn,一个 ResourceManager,多个 NodeManager。

事实上,很多大数据产品都是这样的架构方案:

Storm,一个 Nimbus,多个 Supervisor;

Spark,一个 Master,多个 Slave。

大数据因为要对数据和计算任务进行统一管理,所以和互联网在线应用不同,需要一个全局管理者。一言以蔽之:集中管理,分布存储与计算

模块二、大数据生态体系主要产品原理与架构

11 | Hive 是如何让 MapReduce 实现 SQL 操作的?

Hive 能够直接处理我们输入的 SQL 语句(Hive 的 SQL 语法和数据库标准 SQL 略有不同),调用 MapReduce 计算框架完成数据分析操作。

我们通过 Hive 的 Client(Hive 的命令行工具,JDBC 等)向 Hive 提交 SQL 命令。如果是创建数据表的 DDL(数据定义语言),Hive 就会通过执行引擎 Driver 将数据表的信息记录在 Metastore 元数据组件中,这个组件通常用一个关系数据库实现,记录表名、字段名、字段类型、关联 HDFS 文件路径等这些数据库的 Meta 信息(元信息)。

如果我们提交的是查询分析数据的 DQL(数据查询语句),Driver 就会将该语句提交给自己的编译器 Compiler 进行语法分析、语法解析、语法优化等一系列操作,最后生成一个 MapReduce 执行计划。然后根据执行计划生成一个 MapReduce 的作业,提交给 Hadoop MapReduce 计算框架处理。

12 | 我们并没有觉得 MapReduce 速度慢,直到 Spark 出现

RDD 是 Spark 的核心概念,是弹性数据集(Resilient Distributed Datasets)的缩写。RDD 既是 Spark 面向开发者的编程模型,又是 Spark 自身架构的核心元素。

Spark 上编写 WordCount 程序,主要代码只需要三行

1
2
3
4
5
val textFile = sc.textFile("hdfs://...")
val counts = textFile.flatMap(line => line.split(" "))
.map(word => (word, 1))
.reduceByKey(_ + _)
counts.saveAsTextFile("hdfs://...")

MapReduce 针对输入数据,将计算过程分为两个阶段,一个 Map 阶段,一个 Reduce 阶段,可以理解成是面向过程的大数据计算。

而 Spark 则直接针对数据进行编程,将大规模数据集合抽象成一个 RDD 对象,然后在这个 RDD 上进行各种计算处理,得到一个新的 RDD,继续计算处理,直到得到最后的结果数据。所以 Spark 可以理解成是面向对象的大数据计算。

RDD 上定义的函数分两种,一种是转换(transformation)函数,这种函数的返回值还是 RDD;另一种是执行(action)函数,这种函数不再返回 RDD。

RDD 定义了很多转换操作函数,比如有计算 map(func)、过滤 filter(func)、合并数据集 union(otherDataset)、根据 Key 聚合 reduceByKey(func, [numPartitions])、连接数据集 join(otherDataset, [numPartitions])、分组 groupByKey([numPartitions]) 等十几个函数。

13 | 同样的本质,为何 Spark 可以更高效?

Spark 有三个主要特性:RDD 的编程模型更简单,DAG 切分的多阶段计算过程更快速,使用内存存储中间计算结果更高效。这三个特性使得 Spark 相对 Hadoop MapReduce 可以有更快的执行速度,以及更简单的编程实现。

14 | BigTable 的开源实现:HBase

HBase 可伸缩架构

HBase 的伸缩性主要依赖其可分裂的 HRegion 及可伸缩的分布式文件系统 HDFS 实现。

HRegion 是 HBase 负责数据存储的主要进程,应用程序对数据的读写操作都是通过和 HRegion 通信完成。上面是 HBase 架构图,我们可以看到在 HBase 中,数据以 HRegion 为单位进行管理,也就是说应用程序如果想要访问一个数据,必须先找到 HRegion,然后将数据读写操作提交给 HRegion,由 HRegion 完成存储层面的数据操作。

HRegionServer 是物理服务器,每个 HRegionServer 上可以启动多个 HRegion 实例。当一个 HRegion 中写入的数据太多,达到配置的阈值时,一个 HRegion 会分裂成两个 HRegion,并将 HRegion 在整个集群中进行迁移,以使 HRegionServer 的负载均衡。

每个 HRegion 中存储一段 Key 值区间 [key1, key2) 的数据,所有 HRegion 的信息,包括存储的 Key 值区间、所在 HRegionServer 地址、访问端口号等,都记录在 HMaster 服务器上。为了保证 HMaster 的高可用,HBase 会启动多个 HMaster,并通过 ZooKeeper 选举出一个主服务器。

应用程序通过 ZooKeeper 获得主 HMaster 的地址,输入 Key 值获得这个 Key 所在的 HRegionServer 地址,然后请求 HRegionServer 上的 HRegion,获得所需要的数据。

HRegion 会把数据存储在若干个 HFile 格式的文件中,这些文件使用 HDFS 分布式文件系统存储,在整个集群内分布并高可用。当一个 HRegion 中数据量太多时,这个 HRegion 连同 HFile 会分裂成两个 HRegion,并根据集群中服务器负载进行迁移。如果集群中有新加入的服务器,也就是说有了新的 HRegionServer,由于其负载较低,也会把 HRegion 迁移过去并记录到 HMaster,从而实现 HBase 的线性伸缩。

HBase 可扩展数据模型

支持列族结构的 NoSQL 数据库,在创建表的时候,只需要指定列族的名字,无需指定字段(Column)。那什么时候指定字段呢?可以在数据写入时再指定。通过这种方式, 数据表可以包含数百万的字段,这样就可以随意扩展应用程序的数据结构了。并且这种数据库在查询时也很方便,可以通过指定任意字段名称和值进行查询。

HBase 这种列族的数据结构设计,实际上是把字段的名称和字段的值,以 Key-Value 的方式一起存储在 HBase 中。实际写入的时候,可以随意指定字段名称,即使有几百万个字段也能轻松应对。

HBase 的高性能存储

HBase 使用了一种叫作 LSM 树(Log 结构合并树)的数据结构进行数据存储。数据写入的时候以 Log 方式连续写入,然后异步对磁盘上的多个 LSM 树进行合并。

LSM 树可以看作是一个 N 阶合并树。数据写操作(包括插入、修改、删除)都在内存中进行,并且都会创建一个新记录(修改会记录新的数据值,而删除会记录一个删除标志)。这些数据在内存中仍然还是一棵排序树,当数据量超过设定的内存阈值后,会将这棵排序树和磁盘上最新的排序树合并。当这棵排序树的数据量也超过设定阈值后,会和磁盘上下一级的排序树合并。合并过程中,会用最新更新的数据覆盖旧的数据(或者记录为不同版本)。

15 | 流式计算的代表:Storm、Flink、Spark Streaming

16 | ZooKeeper 是如何保证数据一致性的?

分布式系统中的“脑裂”是指一个系统中的节点被分隔成两个或多个独立的部分,这些部分无法互相通信,导致系统出现不一致性和数据丢失的问题。通常情况下,“脑裂”是由于网络故障、硬件故障或者软件故障等因素导致的。

包括 HDFS 在内的很多大数据技术都选择了使用 ZooKeeper 来解决多台服务器的状态一致性问题。

ZooKeeper 使用了一种叫 ZAB 算法来解决一致性问题。ZAB 可视为 Paxos 算法的一种简化方案。

17 丨模块答疑:这么多技术,到底都能用在什么场景里?

大数据技术在实际部署的时候,通常会部署在同一个集群中,也就是说,在由很多台服务器组成的服务器集群中,某台服务器可能运行着 HDFS 的 DataNode 进程,负责 HDFS 的数据存储;同时也运行着 Yarn 的 NodeManager,负责计算资源的调度管理;而 MapReduce、Spark、Storm、Flink 这些批处理或者流处理大数据计算引擎则通过 Yarn 的调度,运行在 NodeManager 的容器(container)里面。至于 Hive、Spark SQL 这些运行在 MapReduce 或者 Spark 基础上的大数据仓库引擎,在经过自身的执行引擎将 SQL 语句解析成 MapReduce 或者 Spark 的执行计划以后,一样提交给 Yarn 去调度执行。

模块三、大数据开发实践

18 | 如何自己开发一个大数据 SQL 引擎?

19 | Spark 的性能优化案例分析(上)

性能指标:

  • 响应时间:完成一次任务(请求)花费的时间。
  • 并发数:同时处理的任务数(请求数)。
  • 吞吐量:单位时间完成的任务数(请求数、事务数、查询数……)。
  • 性能计数器:System Load,线程数,进程数,CPU、内存、磁盘、网络使用率等。

Spark 性能优化可以分解为下面几步。

  1. 性能测试,观察 Spark 性能特性和资源(CPU、Memory、Disk、Net)利用情况。
  2. 分析、寻找资源瓶颈。
  3. 分析系统架构、代码,发现资源利用关键所在,思考优化策略。
  4. 代码、架构、基础设施调优,优化、平衡资源利用。
  5. 性能测试,观察系统性能特性,是否达到优化目的,以及寻找下一个瓶颈点。

20 | Spark 的性能优化案例分析(下)

21 | 从阿里内部产品看海量数据处理系统的设计(上):Doris 的立项

22 | 从阿里内部产品看海量数据处理系统的设计(下):架构与创新

23 | 大数据基准测试可以带来什么好处?

大数据基准测试工具:

HiBench

24 丨从大数据性能测试工具 Dew 看如何快速开发大数据系统

25 | 模块答疑:我能从大厂的大数据开发实践中学到什么?

学习层次

  1. 练习
  2. 应用
  3. 开发

模块四、大数据平台与系统集成

26 | 互联网产品 + 大数据产品 = 大数据平台

  • 数据采集:数据库同步通常用 Sqoop,日志同步可以选择 Flume,打点采集的数据经过格式化转换后通过 Kafka 等消息队列进行传递
  • 数据处理:离线计算:MapReduce、Hive、Spark;实时计算:Storm、Spark Streaming、Flink
  • 数据展示:Lambda 架构

27 | 大数据从哪里来?

大数据平台的数据来源主要有数据库、日志、前端程序埋点、爬虫系统。

  • 数据库导入
    • Sqoop:Sqoop 是一个数据库批量导入导出工具,可以将关系数据库的数据批量导入到 Hadoop,也可以将 Hadoop 的数据导出到关系数据库。
    • Canal:Canal 是阿里巴巴开源的一个 MySQL binlog 获取工具,binlog 是 MySQL 的事务日志,可用于 MySQL 数据库主从复制,Canal 将自己伪装成 MySQL 从库,从 MySQL 获取 binlog。
  • 日志文件导入
    • Flume:Flume 是大数据日志收集常用的工具。
  • 前端程序埋点
    • 手动埋点
    • 自动埋点
  • 爬虫

28 | 知名大厂如何搭建大数据平台?

淘宝大数据平台

美团大数据平台

滴滴大数据平台

29 | 盘点可供中小企业参考的商业大数据平台

大数据解决方案提供商

CDH、TDH

CDH 是一个大数据集成平台,将主流大数据产品都集成到这个平台中,企业可以使用 CDH 一站式部署整个大数据技术栈。从架构分层角度,CDH 可以分为 4 层:系统集成,大数据存储,统一服务,过程、分析与计算。

  1. 系统集成:数据库导入导出用 Sqoop,日志导入导出用 Flume,其他实时数据导入导出用 Kafka。
  2. 大数据存储:文件系统用 HDFS,结构化数据用 Kudu,NoSQL 存储用 HBase,其他还有对象存储。
  3. 统一服务:资源管理用 Yarn,安全管理用 Sentry 和 RecordService 细粒度地管理不同用户数据的访问权限。
  4. 过程、分析与计算:批处理计算用 MapReduce、Spark、Hive、Pig,流计算用 SparkStreaming,快速 SQL 分析用 Impala,搜索服务用 Solr。

大数据云计算服务商

阿里云、亚马逊

大数据 SaaS 服务商

友盟、神策、百度统计

大数据开放平台

30 | 当大数据遇上物联网

  1. 智能网关通过消息队列将数据上传到物联网大数据平台,Storm 等流式计算引擎从消息队列获取数据,对数据的处理分三个方面。数据进行清理转换后写入到大数据存储系统。调用规则和机器学习模型,对上传数据进行计算,如果触发了某种执行规则,就将控制信息通过设备管理服务器下发给智能网关,并进一步控制终端智能设备。
  2. Spark 等离线计算引擎定时对写入存储系统的数据进行批量计算处理,进行全量统计分析和机器学习,并更新机器学习模型。
  3. 应用程序也可以通过设备管理服务器直接发送控制指令给智能网关,控制终端智能设备。

31 | 模块答疑:为什么大数据平台至关重要?

模块五、大数据分析与运营

32 | 互联网运营数据指标与可视化监控

运营常用数据指标

  • 新增用户数
  • 用户留存率
  • 活跃用户数
  • PV(Page View)
  • GMV(Gross Merchandise Volume),即成交总金额
  • 转化率 = 付费用户数 / 总用户数

33 丨一个电商网站订单下降的数据分析案例

34 丨 A-B 测试与灰度发布必知必会

A/B 测试的过程

A/B 测试的系统架构

A/B 测试系统最重要的是能够根据用户 ID(或者设备 ID)将实验配置参数分发给应用程序,应用程序根据配置参数决定给用户展示的界面和执行的业务逻辑

灰度发布

35 丨如何利用大数据成为“增长黑客”?

AARRR 用户增长模型:它描述了用户增长的 5 个关键环节,分别是:获取用户(Acquisition)、提高活跃度(Activation)、提高留存率(Retention)、获取收入(Revenue)和自传播(Refer)。

  • 获取用户:通过各种推广手段,使产品触达用户并吸引用户,让用户访问我们的产品。
  • 提高活跃度:用户访问我们的产品后,如果发现没意思、体验差,就很难再次打开,产品的价值也就无法实现。因此需要结合产品内容、运营活动各种手段吸引用户,提升产品的活跃度。
  • 提高留存率:留住一个老用户的成本远低于获取一个新用户,而真正为产品带来营收利润的通常是老用户,因此需要提高留存率。提高留存率的常用手段有:针对老用户推出各种优惠和活动;建立会员等级体系,注册时间越长等级越高;对于一段时间没有访问的疑似流失用户进行消息短信推送以实现用户挽回等。
  • 获取收入:做企业不是做慈善,开发、运营互联网产品的最终目的还是为了赚钱,即获取收入。互联网产品收入主要有用户付费和广告收入,有些互联网产品看起来是用户付费,但其实主要营收是广告收入,比如淘宝。
  • 自传播:让用户利用利用自己的社交网络进行产品推广就是自传播,几乎所有的互联网产品都有“分享到”这样一个功能按钮,促进用户社交传播。有些产品还会利用“帮我砍价”“帮我抢票”等产品功能推动用户进行分享,实现产品的裂变式传播、病毒式营销。

增长用户的手段主要有:

利用用户画像定位用户群体

  • 通过用户分析挽回用户
  • A/B 测试决定产品功能
  • 大数据反欺诈、反羊毛
  • 用户生命周期管理

36 丨模块答疑:为什么说数据驱动运营?

模块六、大数据算法

37 丨如何对数据进行分类和预测?

KNN 算法:KNN 算法,即 K 近邻(K Nearest Neighbour)算法,是一种基本的分类算法。其主要原理是:对于一个需要分类的数据,将其和一组已经分类标注好的样本集合进行比较,得到距离最近的 K 个样本,K 个样本最多归属的类别,就是这个需要分类数据的类别。

数据的距离:

  • 欧氏距离:计算空间距离
  • 余弦相似度:计算向量的夹角。更关注数据的相似性

文本的特征值:

  • TF-IDF 算法:TF 与 IDF 的乘积就是 TF-IDF。
    • TF 是词频(Term Frequency),表示某个单词在文档中出现的频率,一个单词在一个文档中出现的越频繁,TF 值越高。
    • IDF 是逆文档频率(Inverse Document Frequency),表示这个单词在所有文档中的稀缺程度,越少文档出现这个词,IDF 值越高。

贝叶斯分类:贝叶斯公式是一种基于条件概率的分类算法,如果我们已经知道 A 和 B 的发生概率,并且知道了 B 发生情况下 A 发生的概率,可以用贝叶斯公式计算 A 发生的情况下 B 发生的概率。

38 丨如何发掘数据之间的关系?

搜索排序:Google PageRank 算法

关联分析:

  • Apriori 算法:Apriori 算法极大地降低了需要计算的商品组合数目,这个算法的原理是,如果一个商品组合不满足最小支持度,那么所有包含这个商品组合的其他商品组合也不满足最小支持度。所以从最小商品组合,也就是一件商品开始计算最小支持度,逐渐迭代,进而筛选出所有满足最小支持度的频繁模式。其步骤如下:
    1. 设置最小支持度阈值。
    2. 寻找满足最小支持度的单件商品,也就是单件商品出现在所有订单中的概率不低于最小支持度。
    3. 从第 2 步找到的所有满足最小支持度的单件商品中,进行两两组合,寻找满足最小支持度的两件商品组合,也就是两件商品出现在同一个订单中概率不低于最小支持度。
    4. 从第 3 步找到的所有满足最小支持度的两件商品,以及第 2 步找到的满足最小支持度的单件商品进行组合,寻找满足最小支持度的三件商品组合。
    5. 以此类推,找到所有满足最小支持度的商品组合。

聚类:聚类就是对一批数据进行自动归类。

K-means 算法

39 丨如何预测用户的喜好?

基于人口统计的推荐:基于人口统计的推荐是相对比较简单的一种推荐算法,根据用户的基本信息进行分类,然后将商品推荐给同类用户。

基于产品属性的推荐:基于用户的属性进行分类,然后根据同类用户的行为进行推荐。而基于商品属性的推荐则是将商品的属性进行分类,然后根据用户的历史行为进行推荐。

基于用户的协同过滤推荐:基于用户的协同过滤推荐是根据用户的喜好进行用户分类,常用的就是 KNN 算法,寻找和当前用户喜好最相近的 K 个用户,然后根据这些用户的喜好为当前用户进行推荐。

基于商品的协同过滤推荐:根据用户的喜好对商品进行分类,如果两个商品,喜欢它们的用户具有较高的重叠性,就认为它们的距离相近,划分为同类商品,然后进行推荐

40 丨机器学习的数学原理是什么?

样本:样本就是通常我们常说的“训练数据”,包括输入和结果两部分。

模型:模型就是映射样本输入与样本结果的函数,可能是一个条件概率分布,也可能是一个决策函数。

算法:算法就是要从模型的假设空间中寻找一个最优的函数,使得样本空间的输入 X 经过该函数的映射得到的 f(X),和真实的 Y 值之间的距离最小。这个最优的函数通常没办法直接计算得到,即没有解析解,需要用数值计算的方法不断迭代求解。因此如何寻找到 f 函数的全局最优解,以及使寻找过程尽量高效,就构成了机器学习的算法。

41 丨从感知机到神经网络算法

42 丨模块答疑:软件工程师如何进入人工智能领域?

斯坦福大学的机器学习公开课

参考资料

Spring MVC 之视图技术

Spring MVC 中视图技术的使用是可插拔的。无论决定使用 Thymeleaf、Groovy 等模板引擎、JSP 还是其他技术,都可以通过配置来更改。

Spring MVC 的视图位于该应用程序的内部信任边界内。 视图可以访问应用程序上下文的所有 bean。 因此,不建议在模板可由外部源编辑的应用程序中使用 Spring MVC 的模板支持,因为这可能会产生安全隐患。

Thymeleaf

Thymeleaf 是一个现代服务器端 Java 模板引擎,它强调自然的 HTML 模板,可以通过双击在浏览器中预览,而无需运行服务器,这对于 UI 模板的独立工作(例如,由设计师)非常有帮助。

Thymeleaf 与 Spring MVC 的集成由 Thymeleaf 项目管理。 配置涉及一些 bean 声明,例如 ServletContextTemplateResolverSpringTemplateEngineThymeleafViewResolver。 有关详细信息,请参阅 Thymeleaf+Spring

FreeMarker

Apache FreeMarker 是一个模板引擎,用于生成从 HTML 到电子邮件等任何类型的文本内容。 Spring 框架内置了 Spring MVC 与 FreeMarker 模板结合使用的集成。

视图配置

以下示例显示了如何将 FreeMarker 配置为视图技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.freeMarker();
}

// Configure FreeMarker...

@Bean
public FreeMarkerConfigurer freeMarkerConfigurer() {
FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
configurer.setTemplateLoaderPath("/WEB-INF/freemarker");
return configurer;
}
}

以下示例显示了如何在 XML 中配置相同的内容:

1
2
3
4
5
6
7
8
9
10
<mvc:annotation-driven/>

<mvc:view-resolvers>
<mvc:freemarker/>
</mvc:view-resolvers>

<!-- Configure FreeMarker... -->
<mvc:freemarker-configurer>
<mvc:template-loader-path location="/WEB-INF/freemarker"/>
</mvc:freemarker-configurer>

或者,您也可以声明 FreeMarkerConfigurer 以完全控制所有属性,如以下示例所示:

1
2
3
<bean id="freemarkerConfig" class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer">
<property name="templateLoaderPath" value="/WEB-INF/freemarker/"/>
</bean>

您的模板需要存储在前面示例中所示的 FreeMarkerConfigurer 指定的目录中。鉴于前面的配置,如果您的控制器返回视图名称 welcome,解析器将查找 /WEB-INF/freemarker/welcome.ftl 模板。

FreeMarker 配置

可以通过在 FreeMarkerConfigurer 上设置适当的 bean 属性,将 FreeMarker ‘Settings’ 和 ‘SharedVariables’ 直接传递给 FreeMarker Configuration 对象(由 Spring 管理)。 freemarkerSettings 属性需要一个 java.util.Properties 对象,freemarkerVariables 属性需要一个 java.util.Map。 以下示例显示了如何使用 FreeMarkerConfigurer

1
2
3
4
5
6
7
8
9
10
<bean id="freemarkerConfig" class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer">
<property name="templateLoaderPath" value="/WEB-INF/freemarker/"/>
<property name="freemarkerVariables">
<map>
<entry key="xml_escape" value-ref="fmXmlEscape"/>
</map>
</property>
</bean>

<bean id="fmXmlEscape" class="freemarker.template.utility.XmlEscape"/>

有关应用于 Configuration 对象的设置和变量的详细信息,请参阅 FreeMarker 文档。

表单处理

Spring 提供了一个用于 JSP 的标记库,其中包含一个 <spring:bind/> 元素。 此元素主要让表单显示来自表单支持对象的值,并显示来自 Web 或业务层中的“验证器”的验证失败的结果。 Spring 还支持 FreeMarker 中的相同功能,以及用于生成表单输入元素的额外便利宏。

绑定宏

在 FreeMarker 的 spring-webmvc.jar 文件中维护了一组标准宏,因此它们始终可用于适当配置的应用程序。

Spring 模板库中定义的一些宏被认为是内部的(私有的),但宏定义中不存在这样的范围,这使得所有宏对调用代码和用户模板都是可见的。以下部分仅关注您需要从模板中直接调用的宏。如果您想直接查看宏代码,该文件名为 spring.ftl ,位于 org.springframework.web.servlet.view.freemarker 包中。

简单绑定

在基于充当 Spring MVC 控制器表单视图的 FreeMarker 模板的 HTML 表单中,您可以使用类似于下一个示例的代码来绑定到字段值,并以类似于 JSP 等价物的方式为每个输入字段显示错误消息。以下示例显示了一个 personForm 视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- FreeMarker macros have to be imported into a namespace.
We strongly recommend sticking to 'spring'. -->
<#import "/spring.ftl" as spring/>
<html>
...
<form action="" method="POST">
Name:
<@spring.bind "personForm.name"/>
<input type="text"
name="${spring.status.expression}"
value="${spring.status.value?html}"/><br />
<#list spring.status.errorMessages as error> <b>${error}</b> <br /> </#list>
<br />
...
<input type="submit" value="submit"/>
</form>
...
</html>

<@spring.bind> 需要一个 ‘path’ 参数,它由命令对象的名称(它是 ‘command’,除非您在控制器配置中更改它)组成,在您希望绑定的命令对象后跟一个句点和字段名称。 您还可以使用嵌套字段,例如 command.address.streetbind 宏采用 web.xml 中的 ServletContext 参数 defaultHtmlEscape 指定的默认 HTML 转义行为。

称为 <@spring.bindEscaped> 的宏的另一种形式采用第二个参数,该参数明确指定是否应在状态错误消息或值中使用 HTML 转义。 您可以根据需要将其设置为 truefalse 。 附加的表单处理宏简化了 HTML 转义的使用,您应该尽可能使用这些宏。

输入宏

FreeMarker 的附加便利宏简化了绑定和表单生成(包括验证错误显示)。 永远不需要使用这些宏来生成表单输入字段,您可以将它们与简单的 HTML 混合搭配,或者直接调用我们之前强调的 Spring 绑定宏。

下表中的可用宏显示了 FreeMarker 模板 (FTL) 定义和每个采用的参数列表:

macro FTL definition
message (output a string from a resource bundle based on the code parameter) <@spring.message code/>
messageText (output a string from a resource bundle based on the code parameter, falling back to the value of the default parameter) <@spring.messageText code, text/>
url (prefix a relative URL with the application’s context root) <@spring.url relativeUrl/>
formInput (standard input field for gathering user input) <@spring.formInput path, attributes, fieldType/>
formHiddenInput (hidden input field for submitting non-user input) <@spring.formHiddenInput path, attributes/>
formPasswordInput (standard input field for gathering passwords. Note that no value is ever populated in fields of this type.) <@spring.formPasswordInput path, attributes/>
formTextarea (large text field for gathering long, freeform text input) <@spring.formTextarea path, attributes/>
formSingleSelect (drop down box of options that let a single required value be selected) <@spring.formSingleSelect path, options, attributes/>
formMultiSelect (a list box of options that let the user select 0 or more values) <@spring.formMultiSelect path, options, attributes/>
formRadioButtons (a set of radio buttons that let a single selection be made from the available choices) <@spring.formRadioButtons path, options separator, attributes/>
formCheckboxes (a set of checkboxes that let 0 or more values be selected) <@spring.formCheckboxes path, options, separator, attributes/>
formCheckbox (a single checkbox) <@spring.formCheckbox path, attributes/>
showErrors (simplify display of validation errors for the bound field) <@spring.showErrors separator, classOrStyle/>

上述任何宏的参数具有一致的含义:

  • path: 要绑定到的字段的名称(例如,“command.name”)
  • options: 可在输入字段中选择的所有可用值的 Map。映射的键表示从表单回传并绑定到命令对象的值。针对键存储的 map 对象是在表单上显示给用户的标签,可能与表单回传的相应值不同。通常,这样的地图由控制器提供作为参考数据。您可以使用任何 Map 实现,具体取决于所需的行为。对于严格排序的映射,您可以使用带有合适的“比较器”的 SortedMap(例如 TreeMap),对于应按插入顺序返回值的任意映射,使用“LinkedHashMap”或“LinkedMap” 公共收藏
  • separator: 在多个选项可用作离散元素(单选按钮或复选框)的情况下,用于分隔列表中每个选项的字符序列(例如 <br>)。
  • attributes: 要包含在 HTML 标记本身中的任意标记或文本的附加字符串。该字符串按字面意思由宏回显。例如,在 textarea 字段中,您可以提供属性(例如“rows=”5” cols=”60”‘),或者您可以传递样式信息,例如 ‘style=”border:1px solid silver”‘。
  • classOrStyle: 对于 showErrors 宏,包装每个错误的 span 元素使用的 CSS 类的名称。如果未提供任何信息(或值为空),错误将包含在 <b></b> 标签中。

以下部分概述了宏的示例。

输入字段

formInput 宏采用 path 参数 (command.name) 和一个额外的 attributes 参数(在接下来的示例中为空)。该宏与所有其他表单生成宏一起对路径参数执行隐式 Spring 绑定。绑定在新绑定发生之前一直有效,因此 showErrors 宏不需要再次传递路径参数——它对上次创建绑定的字段进行操作。

showErrors 宏接受一个分隔符参数(用于分隔给定字段上的多个错误的字符),还接受第二个参数——这次是类名或样式属性。请注意,FreeMarker 可以为 attributes 参数指定默认值。以下示例显示了如何使用 formInputshowErrors 宏:

1
2
<@spring.formInput "command.name"/>
<@spring.showErrors "<br>"/>

下一个示例显示表单片段的输出,生成名称字段并在表单提交后显示验证错误,该字段中没有任何值。验证通过 Spring 的验证框架进行。

生成的 HTML 类似于以下示例:

1
2
3
4
5
6
Name:
<input type="text" name="name" value="">
<br>
<b>required</b>
<br>
<br>

formTextarea 宏的工作方式与 formInput 宏相同,并且接受相同的参数列表。通常,第二个参数 (attributes) 用于传递样式信息或 textarearowscols 属性。

选中字段

您可以使用四个选择字段宏在 HTML 表单中生成常见的 UI 值选择输入:

  • formSingleSelect
  • formMultiSelect
  • formRadioButtons
  • formCheckboxes

四个宏中的每一个都接受一个“Map”选项,其中包含表单字段的值和与该值对应的标签。值和标签可以相同。

下一个例子是 FTL 中的单选按钮。表单支持对象为此字段指定默认值“伦敦”,因此无需验证。渲染表单时,整个可供选择的城市列表作为参考数据提供在模型中,名称为 cityMap。以下清单显示了示例:

1
2
3
...
Town:
<@spring.formRadioButtons "command.address.town", cityMap, ""/><br><br>

前面的清单呈现一行单选按钮,一个用于 cityMap 中的每个值,并使用分隔符 ""。没有提供额外的属性(缺少宏的最后一个参数)。 cityMap 对地图中的每个键值对使用相同的 String。地图的键是表单实际作为 POST 请求参数提交的内容。地图值是用户看到的标签。在前面的示例中,给定三个知名城市的列表和表单支持对象中的默认值,HTML 类似于以下内容:

1
2
3
4
Town:
<input type="radio" name="address.town" value="London">London</input>
<input type="radio" name="address.town" value="Paris" checked="checked">Paris</input>
<input type="radio" name="address.town" value="New York">New York</input>

如果您的应用程序希望通过内部代码处理城市(例如),您可以使用合适的键创建代码映射,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
protected Map<String, ?> referenceData(HttpServletRequest request) throws Exception {
Map<String, String> cityMap = new LinkedHashMap<>();
cityMap.put("LDN", "London");
cityMap.put("PRS", "Paris");
cityMap.put("NYC", "New York");

Map<String, Object> model = new HashMap<>();
model.put("cityMap", cityMap);
return model;
}

代码现在生成输出,其中无线电值是相关代码,但用户仍然看到更用户友好的城市名称,如下所示:

1
2
3
4
Town:
<input type="radio" name="address.town" value="LDN">London</input>
<input type="radio" name="address.town" value="PRS" checked="checked">Paris</input>
<input type="radio" name="address.town" value="NYC">New York</input>

HTML 转义

前面描述的表单宏的默认使用导致 HTML 元素符合 HTML 4.01,并且使用 web.xml 文件中定义的 HTML 转义的默认值,如 Spring 的绑定支持所使用的那样。 要使元素符合 XHTML 或覆盖默认的 HTML 转义值,您可以在模板中指定两个变量(或在模型中,它们对模板可见)。 在模板中指定它们的好处是它们可以在稍后的模板处理中更改为不同的值,以便为表单中的不同字段提供不同的行为。

要为您的标签切换到 XHTML 合规性,请为名为 xhtmlCompliant 的模型或上下文变量指定 true 值,如以下示例所示:

1
2
<#-- for FreeMarker -->
<#assign xhtmlCompliant = true>

处理此指令后,Spring 宏生成的任何元素现在都符合 XHTML。

以类似的方式,您可以为每个字段指定 HTML 转义,如以下示例所示:

1
2
3
4
5
6
7
8
<#-- until this point, default HTML escaping is used -->

<#assign htmlEscape = true>
<#-- next field will use HTML escaping -->
<@spring.formInput "command.name"/>

<#assign htmlEscape = false in spring>
<#-- all future fields will be bound with HTML escaping off -->

Groovy

Groovy 标记模板引擎 主要用于生成类似 XML 的标记(XML、XHTML、HTML5 等),但可以使用它来生成任何基于文本的内容。 Spring Framework 具有将 Spring MVC 与 Groovy 标记结合使用的内置集成。

配置

以下示例显示如何配置 Groovy 标记模板引擎:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.groovy();
}

// Configure the Groovy Markup Template Engine...

@Bean
public GroovyMarkupConfigurer groovyMarkupConfigurer() {
GroovyMarkupConfigurer configurer = new GroovyMarkupConfigurer();
configurer.setResourceLoaderPath("/WEB-INF/");
return configurer;
}
}

以下示例显示了如何在 XML 中配置相同的内容:

1
2
3
4
5
6
7
8
<mvc:annotation-driven/>

<mvc:view-resolvers>
<mvc:groovy/>
</mvc:view-resolvers>

<!-- Configure the Groovy Markup Template Engine... -->
<mvc:groovy-configurer resource-loader-path="/WEB-INF/"/>

示例

与传统的模板引擎不同,Groovy 标记依赖于使用构建器语法的 DSL。以下示例显示了 HTML 页面的示例模板:

1
2
3
4
5
6
7
8
9
10
yieldUnescaped '<!DOCTYPE html>'
html(lang:'en') {
head {
meta('http-equiv':'"Content-Type" content="text/html; charset=utf-8"')
title('My page')
}
body {
p('This is an example of HTML contents')
}
}

脚本视图

Spring 有一个内置的集成,可以将 Spring MVC 与任何可以在 JSR-223 之上运行的模板库一起使用 Java 脚本引擎。 我们在不同的脚本引擎上测试了以下模板库:

脚本库 脚本引擎
Handlebars Nashorn
Mustache Nashorn
React Nashorn
EJS Nashorn
ERB JRuby
String templates Jython
Kotlin Script templating Kotlin

要求

需要在类路径中包含脚本引擎,具体细节因脚本引擎而异:

  • The Nashorn Java 8+ 提供了 JavaScript 引擎。强烈建议使用可用的最新更新版本。
  • JRuby 应该作为 Ruby 支持的依赖项添加。
  • Jython 应该作为 Python 支持的依赖项添加。
  • org.jetbrains.kotlin:kotlin-script-util 依赖项和包含 org.jetbrains.kotlin.script.jsr223.KotlinJsr223JvmLocalScriptEngineFactory 行的 META-INF/services/javax.script.ScriptEngineFactory 文件应该被添加 Kotlin 脚本支持。 有关详细信息,请参阅此示例

您需要有脚本模板库。 为 JavaScript 做到这一点的一种方法是通过 WebJars

脚本模板

可以声明一个 ScriptTemplateConfigurer 来指定要使用的脚本引擎、要加载的脚本文件、调用什么函数来渲染模板等等。 以下示例使用 Mustache 模板和 Nashorn JavaScript 引擎:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.scriptTemplate();
}

@Bean
public ScriptTemplateConfigurer configurer() {
ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
configurer.setEngineName("nashorn");
configurer.setScripts("mustache.js");
configurer.setRenderObject("Mustache");
configurer.setRenderFunction("render");
return configurer;
}
}

以下示例显示了 XML 中的相同配置:

1
2
3
4
5
6
7
8
9
<mvc:annotation-driven/>

<mvc:view-resolvers>
<mvc:script-template/>
</mvc:view-resolvers>

<mvc:script-template-configurer engine-name="nashorn" render-object="Mustache" render-function="render">
<mvc:script location="mustache.js"/>
</mvc:script-template-configurer>

对于 Java 和 XML 配置,controller 看起来没有什么不同,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
@Controller
public class SampleController {

@GetMapping("/sample")
public String test(Model model) {
model.addAttribute("title", "Sample title");
model.addAttribute("body", "Sample body");
return "template";
}
}

以下示例显示了 Mustache 模板:

1
2
3
4
5
6
7
8
<html>
<head>
<title>{{title}}</title>
</head>
<body>
<p>{{body}}</p>
</body>
</html>

使用以下参数调用渲染函数:

  • String template: 模板内容
  • 地图模型:视图模型
  • RenderingContext renderingContextRenderingContext 允许访问应用上下文、语言环境、模板加载器和 URL(自 5.0 起)

如果您的模板技术需要一些自定义,您可以提供一个实现自定义渲染功能的脚本。 例如,Handlerbars 需要在使用之前编译模板,并且需要一个 polyfill 来模拟一些浏览器工具,但在服务器端脚本引擎中不可用。

以下示例显示了如何执行此操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.scriptTemplate();
}

@Bean
public ScriptTemplateConfigurer configurer() {
ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
configurer.setEngineName("nashorn");
configurer.setScripts("polyfill.js", "handlebars.js", "render.js");
configurer.setRenderFunction("render");
configurer.setSharedEngine(false);
return configurer;
}
}

polyfill.js 只定义了 Handlebars 正常运行所需的 window 对象,如下:

1
var window = {}

这个基本的 render.js 实现在使用之前编译模板。 生产就绪的实现还应该存储任何重复使用的缓存模板或预编译模板。 您可以在脚本端这样做(并处理您需要的任何定制——管理模板引擎配置,例如)。 以下示例显示了如何执行此操作:

1
2
3
4
function render(template, model) {
var compiledTemplate = Handlebars.compile(template)
return compiledTemplate(model)
}

查看 Spring Framework 单元测试,Java资源,以获取更多配置示例。

JSP 和 JSTL

Spring Framework 具有将 Spring MVC 与 JSP 和 JSTL 结合使用的内置集成。

更多内容详见:Spring 官方文档之 JSP and JSTL

RSS and Atom

AbstractAtomFeedViewAbstractRssFeedView 都继承自 AbstractFeedView 基类,分别用于提供 Atom 和 RSS Feed 视图。 它们基于 ROME 项目,位于 org.springframework.web.servlet.view.feed 包中。

AbstractAtomFeedView 要求您实现 buildFeedEntries() 方法并可选择覆盖 buildFeedMetadata() 方法(默认实现为空)。 以下示例显示了如何执行此操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SampleContentAtomView extends AbstractAtomFeedView {

@Override
protected void buildFeedMetadata(Map<String, Object> model,
Feed feed, HttpServletRequest request) {
// implementation omitted
}

@Override
protected List<Entry> buildFeedEntries(Map<String, Object> model,
HttpServletRequest request, HttpServletResponse response) throws Exception {
// implementation omitted
}
}

类似的要求适用于实现 AbstractRssFeedView,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SampleContentRssView extends AbstractRssFeedView {

@Override
protected void buildFeedMetadata(Map<String, Object> model,
Channel feed, HttpServletRequest request) {
// implementation omitted
}

@Override
protected List<Item> buildFeedItems(Map<String, Object> model,
HttpServletRequest request, HttpServletResponse response) throws Exception {
// implementation omitted
}
}

buildFeedItems()buildFeedEntries() 方法传入 HTTP 请求,以防您需要访问 Locale。 传入 HTTP 响应仅用于设置 cookie 或其他 HTTP 标头。 方法返回后,提要会自动写入响应对象。

有关创建 Atom 视图的示例,请参阅 Alef Arendsen 的 Spring Team 博客 entry

PDF and Excel

Spring 提供了返回 HTML 以外的输出的方法,包括 PDF 和 Excel 电子表格。

文档视图简介

HTML 页面并不总是用户查看模型输出的最佳方式,Spring 使从模型数据动态生成 PDF 文档或 Excel 电子表格变得简单。 该文档是视图,从服务器流出正确的内容类型,(希望)使客户端 PC 能够运行他们的电子表格或 PDF 查看器应用程序作为响应。

为了使用 Excel 视图,您需要将 Apache POI 库添加到类路径中。 对于 PDF 生成,您需要添加(最好)OpenPDF 库。

PDF 视图

单词列表的简单 PDF 视图可以扩展 org.springframework.web.servlet.view.document.AbstractPdfView 并实现 buildPdfDocument() 方法,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
public class PdfWordList extends AbstractPdfView {

protected void buildPdfDocument(Map<String, Object> model, Document doc, PdfWriter writer,
HttpServletRequest request, HttpServletResponse response) throws Exception {

List<String> words = (List<String>) model.get("wordList");
for (String word : words) {
doc.add(new Paragraph(word));
}
}
}

控制器可以从外部视图定义(按名称引用它)或作为处理程序方法的 View 实例返回此类视图。

Excel 视图

从 Spring Framework 4.2 开始,org.springframework.web.servlet.view.document.AbstractXlsView 作为 Excel 视图的基类提供。 它基于 Apache POI,具有专门的子类(AbstractXlsxViewAbstractXlsxStreamingView),取代了过时的 AbstractExcelView 类。

编程模型类似于 AbstractPdfView,以 buildExcelDocument() 作为核心模板方法,控制器能够从外部定义(按名称)或作为处理程序方法的 View 实例返回此类视图。

Jackson

Spring 提供对 Jackson JSON 库的支持。

基于 Jackson 的 JSON MVC 视图

MappingJackson2JsonView 使用 Jackson 库的 ObjectMapper 将响应内容渲染为 JSON。 默认情况下,模型映射的全部内容(特定于框架的类除外)都编码为 JSON。 对于需要过滤 map 内容的情况,您可以使用 modelKeys 属性指定一组特定的模型属性进行编码。 您还可以使用 extractValueFromSingleKeyModel 属性直接提取和序列化单键模型中的值,而不是作为模型属性的映射。

您可以根据需要使用 Jackson 提供的注释自定义 JSON 映射。 当您需要进一步控制时,您可以通过 ObjectMapper 属性注入自定义 ObjectMapper,适用于需要为特定类型提供自定义 JSON 序列化器和反序列化器的情况。

基于 Jackson 的 XML 视图

MappingJackson2XmlView 使用 Jackson XML 扩展 XmlMapper 将响应内容渲染为 XML。 如果模型包含多个条目,您应该使用 modelKey bean 属性显式设置要序列化的对象。 如果模型包含单个条目,它会自动序列化。

您可以根据需要使用 JAXB 或 Jackson 提供的注释自定义 XML 映射。当您需要进一步控制时,您可以通过 ObjectMapper 属性注入自定义 XmlMapper,对于需要为特定类型提供序列化器和反序列化器的自定义 XML 的情况

XML

MarshallingView 使用 XML Marshaller(在 org.springframework.oxm 包中定义)将响应内容渲染为 XML。 您可以使用 MarshallingView 实例的 modelKey 属性显式设置要编组的对象。 或者,视图遍历所有模型属性并编组 Marshaller 支持的第一个类型。 有关 org.springframework.oxm 包中功能的更多信息,请参阅 Marshalling XML using O/X Mappers

XSLT

XSLT 是 XML 的一种转换语言,作为 Web 应用程序中的一种视图技术很受欢迎。 如果您的应用程序自然地处理 XML,或者如果您的模型可以很容易地转换为 XML,那么 XSLT 作为一种视图技术是一个不错的选择。 以下部分展示了如何生成 XML 文档作为模型数据,并在 Spring Web MVC 应用程序中使用 XSLT 对其进行转换。

此示例是一个简单的 Spring 应用程序,它在 Controller 中创建关键字列表并将它们添加到模型映射中。 返回映射以及我们的 XSLT 视图的视图名称。 有关 Spring Web MVC 的 Controller 接口的详细信息,请参阅 Annotated Controllers。 XSLT 控制器将单词列表转换为准备转换的简单 XML 文档。

Beans

配置是一个简单的 Spring Web 应用程序的标准配置:MVC 配置必须定义一个 XsltViewResolver 和常规 MVC 注释配置。以下示例显示了如何执行此操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
@EnableWebMvc
@ComponentScan
@Configuration
public class WebConfig implements WebMvcConfigurer {

@Bean
public XsltViewResolver xsltViewResolver() {
XsltViewResolver viewResolver = new XsltViewResolver();
viewResolver.setPrefix("/WEB-INF/xsl/");
viewResolver.setSuffix(".xslt");
return viewResolver;
}
}

Controller

我们还需要一个控制器来封装我们的单词生成逻辑。

控制器逻辑封装在一个 @Controller 类中,处理方法定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Controller
public class XsltController {

@RequestMapping("/")
public String home(Model model) throws Exception {
Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
Element root = document.createElement("wordList");

List<String> words = Arrays.asList("Hello", "Spring", "Framework");
for (String word : words) {
Element wordNode = document.createElement("word");
Text textNode = document.createTextNode(word);
wordNode.appendChild(textNode);
root.appendChild(wordNode);
}

model.addAttribute("wordList", root);
return "home";
}
}

到目前为止,我们只创建了一个 DOM 文档并将其添加到模型映射中。请注意,您还可以将 XML 文件作为 Resource 加载并使用它来代替自定义 DOM 文档。

有可用的软件包可以自动 ‘domify’ 一个对象图,但是在 Spring 中,您可以完全灵活地以您选择的任何方式从您的模型创建 DOM。这可以防止 XML 的转换在模型数据的结构中发挥太大作用,这在使用工具管理 DOMification 过程时是一种危险。

Transformation

最后,XsltViewResolver 解析 “home” XSLT 模板文件并将 DOM 文档合并到其中以生成我们的视图。如 XsltViewResolver 配置所示,XSLT 模板位于 WEB-INF/xsl 目录下的 war 文件中,并以 xslt 文件扩展名结尾。

以下示例显示了 XSLT 转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">

<xsl:output method="html" omit-xml-declaration="yes"/>

<xsl:template match="/">
<html>
<head><title>Hello!</title></head>
<body>
<h1>My First Words</h1>
<ul>
<xsl:apply-templates/>
</ul>
</body>
</html>
</xsl:template>

<xsl:template match="word">
<li><xsl:value-of select="."/></li>
</xsl:template>

</xsl:stylesheet>

前面的转换渲染为以下 HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hello!</title>
</head>
<body>
<h1>My First Words</h1>
<ul>
<li>Hello</li>
<li>Spring</li>
<li>Framework</li>
</ul>
</body>
</html>

参考资料

Spring MVC 之跨域

Spring MVC 支持跨域处理(CORS)。

简介

出于安全原因,浏览器禁止对当前源之外的资源进行 AJAX 调用。例如,可以在一个选项卡中使用您的银行帐户,而在另一个选项卡中使用 evil.com。来自 evil.com 的脚本不应该能够使用您的凭据向您的银行 API 发出 AJAX 请求——例如从您的账户中取款!

跨域(CORS)是由 大多数浏览器 实施的 W3C 规范,可让您指定哪种跨域请求是授权,而不是使用基于 IFRAME 或 JSONP 的不太安全和不太强大的解决方法。

处理

CORS 规范分为预检请求、简单请求和实际请求。要了解 CORS 的工作原理,可以阅读 Cross-Origin Resource Sharing (CORS) 等,或者查看规范了解更多详细信息。

Spring MVC HandlerMapping 实现提供了对 CORS 的内置支持。成功将请求映射到处理程序后,HandlerMapping 实现检查给定请求和处理程序的 CORS 配置并采取进一步的操作。预检请求被直接处理,而简单和实际的 CORS 请求被拦截、验证,并设置了所需的 CORS 响应标头。

为了启用跨源请求(即存在 Origin 标头并且与请求的主机不同),您需要有一些明确声明的 CORS 配置。如果未找到匹配的 CORS 配置,预检请求将被拒绝。没有 CORS 标头添加到简单和实际 CORS 请求的响应中,因此浏览器会拒绝它们。

每个 HandlerMapping 都可以[配置](https://docs.spring.io/spring-framework/docs/6.0.4/javadoc-api/org/springframework/web/servlet/handler/AbstractHandlerMapping.html#setCorsConfigurations- java.util.Map-) 单独使用基于 URL 模式的 CorsConfiguration 映射。 在大多数情况下,应用程序使用 MVC Java 配置或 XML 命名空间来声明此类映射,这会导致将单个全局映射传递给所有 HandlerMapping 实例。

可以将 HandlerMapping 级别的全局 CORS 配置与更细粒度的处理程序级别的 CORS 配置相结合。 例如,带注释的控制器可以使用类级或方法级的 @CrossOrigin 注释(其他处理程序可以实现 CorsConfigurationSource)。

The rules for combining global and local configuration are generally additive — for example, all global and all local origins. For those attributes where only a single value can be accepted, e.g. allowCredentials and maxAge, the local overrides the global value.

结合全局和局部配置的规则通常是附加的⟩——例如,所有全局和所有局部起源。 对于那些只能接受单个值的属性,例如 allowCredentialsmaxAge,局部覆盖全局值。

@CrossOrigin

@CrossOrigin 注解在带注解的 Controller 方法上启用跨源请求,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/account")
public class AccountController {

@CrossOrigin
@GetMapping("/{id}")
public Account retrieve(@PathVariable Long id) {
// ...
}

@DeleteMapping("/{id}")
public void remove(@PathVariable Long id) {
// ...
}
}

默认,@CrossOrigin 允许访问:

  • 所以 origin
  • 所以 header
  • 所以 Controller 方法映射到的 HTTP 方法

allowCredentials 默认情况下不启用,因为它建立了一个信任级别,可以公开敏感的用户特定信息(例如 cookie 和 CSRF 令牌),并且只应在适当的情况下使用。启用时,必须将 allowOrigins 设置为一个或多个特定域(但不是特殊值 "*"),或者 allowOriginPatterns 属性可用于匹配一组动态来源。

maxAge 单位为分钟

@CrossOrigin 也支持类级别,并且被所有方继承,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@CrossOrigin(origins = "https://domain2.com", maxAge = 3600)
@RestController
@RequestMapping("/account")
public class AccountController {

@GetMapping("/{id}")
public Account retrieve(@PathVariable Long id) {
// ...
}

@DeleteMapping("/{id}")
public void remove(@PathVariable Long id) {
// ...
}
}

可以同时在类级别和方法级别上使用 @CrossOrigin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@CrossOrigin(maxAge = 3600)
@RestController
@RequestMapping("/account")
public class AccountController {

@CrossOrigin("https://domain2.com")
@GetMapping("/{id}")
public Account retrieve(@PathVariable Long id) {
// ...
}

@DeleteMapping("/{id}")
public void remove(@PathVariable Long id) {
// ...
}
}

全局配置

除了细粒度的控制器方法级别配置之外,您可能还想定义一些全局 CORS 配置。您可以在任何 HandlerMapping 上单独设置基于 URL 的 CorsConfiguration 映射。但是,大多数应用程序使用 MVC Java 配置或 MVC XML 命名空间来执行此操作。

默认情况下,全局配置启用以下功能:

  • 所以 origin
  • 所以 header
  • GETHEADPOST 方法

allowCredentials 默认情况下不启用,因为它建立了一个信任级别,可以公开敏感的用户特定信息(例如 cookie 和 CSRF 令牌),并且只应在适当的情况下使用。启用时,必须将 allowOrigins 设置为一个或多个特定域(但不是特殊值 "*"),或者 allowOriginPatterns 属性可用于匹配一组动态来源。

maxAge 单位为分钟

Java 配置

要在 MVC Java 配置中启用 CORS,您可以使用 CorsRegistry 回调,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {

registry.addMapping("/api/**")
.allowedOrigins("https://domain2.com")
.allowedMethods("PUT", "DELETE")
.allowedHeaders("header1", "header2", "header3")
.exposedHeaders("header1", "header2")
.allowCredentials(true).maxAge(3600);

// Add more mappings...
}
}

XML 配置

要在 XML 命名空间中启用 CORS,可以使用 <mvc:cors> 元素,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
<mvc:cors>

<mvc:mapping path="/api/**"
allowed-origins="https://domain1.com, https://domain2.com"
allowed-methods="GET, PUT"
allowed-headers="header1, header2, header3"
exposed-headers="header1, header2" allow-credentials="true"
max-age="123" />

<mvc:mapping path="/resources/**"
allowed-origins="https://domain1.com" />

</mvc:cors>

CORS 过滤器

可以通过 Spring 内置的 CorsFilter 支持 CORS。

要配置过滤器,请将 CorsConfigurationSource 传递给它的构造函数,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CorsConfiguration config = new CorsConfiguration();

// Possibly...
// config.applyPermitDefaultValues()

config.setAllowCredentials(true);
config.addAllowedOrigin("https://domain1.com");
config.addAllowedHeader("*");
config.addAllowedMethod("*");

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);

CorsFilter filter = new CorsFilter(source);

参考资料

Spring Web 应用

Spring MVC 提供了一种基于注解的编程模型,@Controller@RestController 组件使用注解来表达请求映射、请求输入、异常处理等。注解控制器具有灵活的方法签名,并且不必扩展基类或实现特定接口。以下示例显示了一个由注解定义的控制器:

1
2
3
4
5
6
7
8
9
@Controller
public class HelloController {

@GetMapping("/hello")
public String handle(Model model) {
model.addAttribute("message", "Hello World!");
return "index";
}
}

在前面的示例中,该方法接受一个 Model 并以 String 形式返回一个视图名称,但还存在许多其他选项。

快速入门

下面,通过一个简单的示例来展示如何通过 Spring 创建一个 Hello World Web 服务。

(1)pom.xml 中引入依赖

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

(2)定义 Controller

Spring 构建 RESTful 服务的方法,HTTP 请求由 Controller 处理。 这些组件由 @RestController 注解标识。

【示例】下面的示例定义了一个处理 /greeting 的 GET 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class GreetingController {

@GetMapping("/greeting")
public String greeting(@RequestParam(name = "name", required = false, defaultValue = "World") String name,
Model model) {
model.addAttribute("name", name);
return "greeting";
}

}

(3)创建启动类

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class HelloWorldApplication {

public static void main(String[] args) {
SpringApplication.run(HelloWorldApplication.class);
}

}

(4)启动服务:执行 HelloWorldApplication.main 方法启动 web 服务

(5)测试

打开浏览器,访问 http://localhost:8080/greeting,页面会显示如下内容:

1
Hello, World!

打开浏览器,访问 http://localhost:8080/greeting?name=dunwu,页面会显示如下内容:

1
Hello, dunwu!

Spring Web 组件

组件扫描

可以使用 Servlet 的 WebApplicationContext 中的标准 Spring bean 定义来定义控制器。@Controller 构造型允许自动检测,与 Spring 对检测类路径中的 @Component 类并为它们自动注册 bean 定义的一般支持保持一致。它还充当带注解类的构造型,表明其作为 Web 组件的角色。

要启用此类 @Controller 的自动检测,可以将组件扫描添加到您的 Java 配置中,如以下示例所示:

1
2
3
4
5
6
@Configuration
@ComponentScan("org.example.web")
public class WebConfig {

// ...
}

以下示例显示了与上述示例等效的 XML 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd">

<context:component-scan base-package="org.example.web"/>

<!-- ... -->

</beans>

AOP 代理

在某些情况下,可能需要在运行时使用 AOP 代理装饰控制器。一个例子是,如果选择直接在控制器上使用 @Transactional 注解。在这种情况下,特别是对于控制器,建议使用基于类的代理。直接在控制器上使用此类注解会自动出现这种情况。

如果控制器实现了一个接口,并且需要 AOP 代理,您可能需要显式配置基于类的代理。例如,对于 @EnableTransactionManagement ,可以更改为 @EnableTransactionManagement(proxyTargetClass = true),对于 <tx:annotation-driven/> ,您可以更改为 <tx:annotation-driven proxy-target-class="true"/>

@Controller

@RestController 是一个组合注解,它本身使用 @Controller@ResponseBody 元注解进行标记,以指示控制器的每个方法继承了类型级别的 @ResponseBody 注解,因此直接写入响应主体,而不是使用 HTML 模板进行视图解析和渲染。

@RequestMapping

可以使用 @RequestMapping 注解将请求映射到控制器方法。它具有各种属性,可以通过 URL、HTTP 方法、请求参数、标头和媒体类型进行匹配。可以在类级别使用它来表达共享映射,或者在方法级别使用它来缩小到特定端点的映射。

@RequestMapping 的主要参数:

  • path / method 指定映射路径与方法
  • params / headers 限定映射范围
  • consumes / produces 限定请求与响应格式

Spring 还提供了以下 @RequestMapping 的变体:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

快捷方式是提供的自定义注解,因为可以说,大多数控制器方法应该映射到特定的 HTTP 方法,而不是使用 @RequestMapping,默认情况下,它与所有 HTTP 方法匹配。在类级别仍然需要 @RequestMapping 来表达共享映射。

以下示例具有类型和方法级别的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/persons")
class PersonController {

@GetMapping("/{id}")
public Person getPerson(@PathVariable Long id) {
// ...
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void add(@RequestBody Person person) {
// ...
}
}

URI 模式

@RequestMapping 方法可以使用 URL 模式进行映射。有两种选择:

  • PathPattern - 与 URL 路径匹配的预解析模式也预解析为 PathContainer。该解决方案专为网络使用而设计,可有效处理编码和路径参数,并高效匹配。
  • AntPathMatcher - 根据字符串路径匹配字符串模式。这是在 Spring 配置中也使用的原始解决方案,用于在类路径、文件系统和其他位置选择资源。它的效率较低,并且字符串路径输入对于有效处理 URL 的编码和其他问题是一个挑战。

PathPattern 是 Web 应用程序的推荐解决方案,它是 Spring WebFlux 中的唯一选择。它从 5.3 版开始在 Spring MVC 中使用,从 6.0 版开始默认启用。请参阅 MVC 配置 以自定义路径匹配选项。

PathPattern 支持与 AntPathMatcher 相同的模式语法。此外,它还支持捕获模式,例如 {spring},用于匹配路径末尾的 0 个或多个路径段。PathPattern 还限制使用 ** 来匹配多个路径段,这样它只允许出现在模式的末尾。这消除了在为给定请求选择最佳匹配模式时出现的许多歧义。有关完整模式语法,请参阅 PathPatternAntPathMatcher

一些示例模式:

  • "/resources/ima?e.png" -匹配一个字符
  • "/resources/*.png" - 匹配零个或多个字符
  • "/resources/**" - 匹配多个字符
  • "/projects/{project}/versions" - 匹配路径段并将其捕获为变量
  • "/projects/{project:[a-z]+}/versions" - 使用正则表达式匹配并捕获变量

可以使用 @PathVariable 访问捕获的 URI 变量。例如:

1
2
3
4
@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}

可以在类和方法级别声明 URI 变量,如以下示例所示:

1
2
3
4
5
6
7
8
9
@Controller
@RequestMapping("/owners/{ownerId}")
public class OwnerController {

@GetMapping("/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}
}

URI 变量会自动转换为适当的类型,否则会引发 TypeMismatchException。默认支持简单类型(intlongDate 等),可以注册对任何其他数据类型的支持。请参见类型转换DataBinder

可以显式命名 URI 变量(例如,@PathVariable("customId")),但如果名称相同并且代码是使用 -parameters 编译器标志编译的,则可以省略该细节。

语法 {varName:regex} 使用正则表达式声明一个 URI 变量。例如,给定 URL "/spring-web-3.0.5.jar",以下方法提取名称、版本和文件扩展名:

1
2
3
4
@GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}")
public void handle(@PathVariable String name, @PathVariable String version, @PathVariable String ext) {
// ...
}

URI 路径模式还可以嵌入 ${…} 占位符,这些占位符在启动时通过使用 PropertySourcesPlaceholderConfigurer 针对本地、系统、环境和其他属性源进行解析。例如,可以使用它来根据某些外部配置参数化基本 URL。

模式比较

当多个模式匹配一个 URL 时,必须选择最佳匹配。这是通过以下方式之一完成的,具体取决于是否启用了已解析的 PathPattern 以供使用:

两者都有助于对模式进行排序,更具体的模式位于顶部。如果模式具有较少的 URI 变量(计为 1)、单通配符(计为 1)和双通配符(计为 2),则模式不太具体。如果得分相同,则选择较长的模式。给定相同的分数和长度,选择 URI 变量多于通配符的模式。

默认映射模式 (/**) 被排除在评分之外并始终排在最后。此外,前缀模式(例如 /public/**)被认为不如其他没有双通配符的模式具体。

后缀匹配

从 5.3 开始,默认情况下 Spring MVC 不再执行 .* 后缀模式匹配,其中映射到 person 的控制器也隐式映射到 /person.*。因此,路径扩展不再用于解释请求的响应内容类型⟩——例如,/person.pdf/person.xml 等。

当浏览器过去发送难以一致解释的 Accept 请求头时,以这种方式使用文件扩展名是必要的。现在,这不再是必需的,使用 Accept 请求头应该是首选。

随着时间的推移,文件扩展名的使用在很多方面都被证明是有问题的。当使用 URI 变量、路径参数和 URI 编码覆盖时,它可能会导致歧义。关于基于 URL 的授权和安全性的推理也变得更加困难。

要在 5.3 之前的版本中完全禁用路径扩展,请设置以下内容:

除了通过 Accept 请求头之外,还有一种请求内容类型的方法仍然有用,例如在浏览器中键入 URL 时。路径扩展的一种安全替代方法是使用查询参数策略。如果您必须使用文件扩展名,请考虑通过 ContentNegotiationConfigurermediaTypes 属性将它们限制为明确注册的扩展名列表。

后缀匹配和 RFD

反射文件下载 (RFD) 攻击与 XSS 类似,因为它依赖于响应中反映的请求输入(例如,查询参数和 URI 变量)。然而,RFD 攻击不是将 JavaScript 插入 HTML,而是依赖于浏览器切换来执行下载,并在稍后双击时将响应视为可执行脚本。

在 Spring MVC 中,@ResponseBodyResponseEntity 方法存在风险,因为它们可以渲染不同的内容类型,客户端可以通过 URL 路径扩展请求这些内容类型。禁用后缀模式匹配并使用路径扩展进行内容协商可以降低风险,但不足以防止 RFD 攻击。

为了防止 RFD 攻击,在渲染响应主体之前,Spring MVC 添加了一个 Content-Disposition:inline;filename=f.txt 头以建议一个固定且安全的下载文件。仅当 URL 路径包含的文件扩展名既不安全也不明确注册用于内容协商时,才会执行此操作。但是,当 URL 直接输入浏览器时,它可能会产生副作用。

默认情况下,允许许多常见的路径扩展是安全的。具有自定义 HttpMessageConverter 实现的应用程序可以显式注册文件扩展名以进行内容协商,以避免为这些扩展名添加 Content-Disposition 头。请参阅 内容类型

关于 RFD 更多细节推荐参考 CVE-2015-5211

限定数据类型

您可以根据请求的 Content-Type 缩小请求映射,如以下示例所示:

1
2
3
4
@PostMapping(path = "/pets", consumes = "application/json")
public void addPet(@RequestBody Pet pet) {
// ...
}

consumes 属性还支持否定表达式 - 例如,!textplain 表示除 textplain 之外的任何内容类型。

您可以在类级别声明一个共享的 consumes 属性。然而,与大多数其他请求映射属性不同的是,当在类级别使用时,方法级别的 consumes 属性会覆盖而不是扩展类级别的声明。

Producible Media Types

可以根据 Accept 请求头和控制器方法生成的内容类型列表来缩小请求映射,如以下示例所示:

1
2
3
4
5
@GetMapping(path = "/pets/{petId}", produces = "application/json")
@ResponseBody
public Pet getPet(@PathVariable String petId) {
// ...
}

媒体类型可以指定一个字符集。支持否定表达式——例如,!textplain 表示除 “text/plain” 之外的任何内容类型。

可以在类级别声明一个共享的 produces 属性。然而,与大多数其他请求映射属性不同,当在类级别使用时,方法级别的 produces 属性会覆盖而不是扩展类级别的声明。

参数、请求头

可以根据请求参数条件缩小请求映射范围。可以测试是否存在请求参数 (myParam)、是否缺少请求参数 (!myParam) 或特定值 (myParam=myValue)。以下示例显示如何测试特定值:

1
2
3
4
@GetMapping(path = "/pets/{petId}", params = "myParam=myValue")
public void findPet(@PathVariable String petId) {
// ...
}

还可以使用相同的请求头条件,如以下示例所示:

1
2
3
4
@GetMapping(path = "/pets", headers = "myHeader=myValue")
public void findPet(@PathVariable String petId) {
// ...
}

HTTP HEAD, OPTIONS

@GetMapping(和 @RequestMapping(method=HttpMethod.GET))透明地支持 HTTP HEAD 以进行请求映射。控制器方法不需要改变。在 jakarta.servlet.http.HttpServlet 中应用的响应包装器确保将 Content-Length 头设置为写入的字节数(实际上没有写入响应)。

@GetMapping(和@RequestMapping(method=HttpMethod.GET))被隐式映射并支持 HTTP HEAD。HTTP HEAD 请求的处理方式就好像它是 HTTP GET 一样,除了不写入正文,而是计算字节数并设置 Content-Length 头。

默认情况下,通过将 Allow 响应头设置为所有具有匹配 URL 模式的 @RequestMapping 方法中列出的 HTTP 方法列表来处理 HTTP OPTIONS。

对于没有 HTTP 方法声明的 @RequestMappingAllow 头设置为 GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS。控制器方法应始终声明支持的 HTTP 方法(例如,通过使用 HTTP 方法特定变体:@GetMapping@PostMapping 等)。

You can explicitly map the @RequestMapping method to HTTP HEAD and HTTP OPTIONS, but that is not necessary in the common case.

可以显式地将 @RequestMapping 方法映射到 HTTP HEAD 和 HTTP OPTIONS,但在常见情况下这不是必需的。

自定义注解

Spring MVC 支持使用组合注解 进行请求映射。这些注解本身是用 @RequestMapping 进行元注解的,并且组合起来重新声明 @RequestMapping 属性的一个子集(或全部),具有更明确的目的。

@GetMapping@PostMapping@PutMapping@DeleteMapping@PatchMapping 是组合注解的示例。提供它们是因为,可以说,大多数控制器方法应该映射到特定的 HTTP 方法,而不是使用 @RequestMapping,默认情况下,它与所有 HTTP 方法匹配。如果您需要组合注解的示例,请查看这些注解的声明方式。

Spring MVC 还支持具有自定义请求匹配逻辑的自定义请求映射属性。这是一个更高级的选项,需要继承 RequestMappingHandlerMapping 并覆盖 getCustomMethodCondition 方法,您可以在其中检查自定义属性并返回您自己的 RequestCondition

显示注册

您可以以编程方式注册处理程序方法,您可以将其用于动态注册或高级情况,例如不同 URL 下的同一处理程序的不同实例。以下示例注册了一个处理程序方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class MyConfig {

@Autowired
public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler)
throws NoSuchMethodException {

RequestMappingInfo info = RequestMappingInfo
.paths("/user/{id}").methods(RequestMethod.GET).build();

Method method = UserHandler.class.getMethod("getUser", Long.class);

mapping.registerMapping(info, handler, method);
}
}
  1. 为控制器注入目标处理程序和处理程序映射。

  2. 准备请求映射元数据。

  3. 获取处理程序方法。

  4. 添加注册。

处理方法

请求数据

  • @RequestParam

  • @RequestBody

  • @PathVariable

  • @RequestHeader

更多 Spring Web 方法参数可以参考: Method Arguments

响应数据

  • @ResponseBody

  • @ResponseStatus

  • ResponseEntity

  • HttpEntity

更多 Spring Web 方法返回值可以参考:Return Values

@ModelAttribute

可以使用 @ModelAttribute 注解:

  • @RequestMapping 方法中的方法参数上,用于模型创建或访问对象,并通过 WebDataBinder 将其绑定到请求。
  • 作为 @Controller@ControllerAdvice 类中的方法级注解,有助于在任何 @RequestMapping 方法调用之前初始化模型。
  • @RequestMapping 方法上标记它的返回值是一个模型属性。

本节讨论 @ModelAttribute 方法——前面列表中的第二项。一个控制器可以有任意数量的 @ModelAttribute 方法。所有这些方法都在同一控制器中的 @RequestMapping 方法之前被调用。@ModelAttribute 方法也可以通过 @ControllerAdvice 在控制器之间共享。

@ModelAttribute 方法具有灵活的方法签名。它们支持许多与 @RequestMapping 方法相同的参数,除了 @ModelAttribute 本身或与请求主体相关的任何内容。

以下示例显示了 @ModelAttribute 方法:

1
2
3
4
5
@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
model.addAttribute(accountRepository.findAccount(number));
// add more ...
}

以下示例仅添加一个属性:

1
2
3
4
@ModelAttribute
public Account addAccount(@RequestParam String number) {
return accountRepository.findAccount(number);
}

还可以将 @ModelAttribute 用作 @RequestMapping 方法上的方法级注解,在这种情况下,@RequestMapping 方法的返回值被解释为模型属性。这通常不是必需的,因为它是 HTML 控制器中的默认行为,除非返回值是一个 String 否则将被解释为视图名称。 @ModelAttribute 还可以自定义模型属性名称,如下例所示:

1
2
3
4
5
6
@GetMapping("/accounts/{id}")
@ModelAttribute("myAccount")
public Account handle() {
// ...
return account;
}

@InitBinder

@Controller@ControllerAdvice 类可以用 @InitBinder 方法来初始化 WebDataBinder 的实例,而这些方法又可以:

  • 将请求参数(即表单或查询数据)绑定到模型对象。
  • 将基于字符串的请求值(例如请求参数、路径变量、标头、cookie 等)转换为控制器方法参数的目标类型。
  • 在渲染 HTML 表单时将模型对象值格式化为 String 值。

@InitBinder 方法可以注册指定控制器 java.beans.PropertyEditor 或 Spring ConverterFormatter 组件。此外,您可以使用 MVC 配置 在全局共享的 FormattingConversionService 中注册 ConverterFormatter 类型。

@InitBinder 方法支持许多与 @RequestMapping 方法相同的参数,除了 @ModelAttribute(命令对象)参数。通常,它们使用 WebDataBinder 参数(用于注册)和 void 返回值声明。下面展示了一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
@Controller
public class FormController {

@InitBinder
public void initBinder(WebDataBinder binder) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setLenient(false);
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
}

// ...
}

或者,当您通过共享的 FormattingConversionService 使用基于 Formatter 的设置时,您可以重复使用相同的方法并注册指定控制器的 Formatter 实现,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
@Controller
public class FormController {

@InitBinder
protected void initBinder(WebDataBinder binder) {
binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd"));
}

// ...
}

在 Web 应用程序的上下文中,数据绑定涉及将 HTTP 请求参数(即表单数据或查询参数)绑定到模型对象及其嵌套对象中的属性。

仅公开遵循 JavaBeans 命名约定public 属性用于数据绑定——例如,firstName 属性的 get/set 方法:public String getFirstName()public void setFirstName(String)

默认情况下,Spring 允许绑定到模型对象图中的所有公共属性。这意味着您需要仔细考虑模型具有哪些公共属性,因为客户端可以将任何公共属性路径作为目标,甚至是一些预计不会针对给定用例的公共属性路径。

例如,给定一个 HTTP 表单数据端点,恶意客户端可以为存在于模型对象图中但不属于浏览器中显示的 HTML 表单的属性提供值。这可能导致在模型对象及其任何嵌套对象上设置数据,这些数据预计不会更新。

荐的方法是使用一个专用模型对象,它只公开与表单提交相关的属性。例如,在用于更改用户电子邮件地址的表单上,模型对象应声明最少的一组属性,例如以下 ChangeEmailForm

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

private String oldEmailAddress;
private String newEmailAddress;

public void setOldEmailAddress(String oldEmailAddress) {
this.oldEmailAddress = oldEmailAddress;
}

public String getOldEmailAddress() {
return this.oldEmailAddress;
}

public void setNewEmailAddress(String newEmailAddress) {
this.newEmailAddress = newEmailAddress;
}

public String getNewEmailAddress() {
return this.newEmailAddress;
}

}

如果您不能或不想为每个数据绑定用例使用专用模型对象,则必须限制允许用于数据绑定的属性。理想情况下,可以通过 WebDataBinder 上的 setAllowedFields() 方法注册允许的字段模式 来实现这一点。

例如,要在您的应用程序中注册允许的字段模式,您可以在 @Controller@ControllerAdvice 组件中实现 @InitBinder 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
@Controller
public class ChangeEmailController {

@InitBinder
void initBinder(WebDataBinder binder) {
binder.setAllowedFields("oldEmailAddress", "newEmailAddress");
}

// @RequestMapping methods, etc.

}

除了注册允许的模式外,还可以通过 DataBinder及其子类中的 setDisallowedFields() 方法注册 _允许的字段模式_。但是请注意,“允许列表”比“拒绝列表”更安全。因此,setAllowedFields() 应该优于 setDisallowedFields()

请注意,匹配允许的字段模式是区分大小写的;然而,与不允许的字段模式匹配是不区分大小写的。此外,匹配不允许的模式的字段将不会被接受,即使它也恰好匹配允许列表中的模式。

表单处理

创建处理表单的 Controller

GreetingController 通过返回视图的名称处理 /greeting 的 GET 请求,这意味着返回的内容是名为 greeting.html 的视图内容。

greetingForm() 方法是通过使用 @GetMapping 专门映射到 GET 请求的,而 greetingSubmit() 是通过 @PostMapping 映射到 POST 请求的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class GreetingController {

@GetMapping("/greeting")
public String greetingForm(Model model) {
model.addAttribute("greeting", new Greeting());
return "greeting";
}

@PostMapping("/greeting")
public String greetingSubmit(@ModelAttribute Greeting greeting, Model model) {
model.addAttribute("greeting", greeting);
return "result";
}

}

定义需要提交的表单实体

1
2
3
4
5
6
7
8
9
10
import lombok.Data;

@Data
public class Greeting {

private long id;

private String content;

}

提交表单前端代码

提交实体的页面必须依赖某种视图技术,通过将视图名称转换为模板进行渲染,从而对HTML进行服务端渲染。在下面的例子中,使用了 Thymeleaf 模板引擎作为视图,它解析 greeting.html 的各种模板表达式以渲染表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>Getting Started: Handling Form Submission</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>Form</h1>
<form action="#" th:action="@{/greeting}" th:object="${greeting}" method="post">
<p>Id: <input type="text" th:field="*{id}" /></p>
<p>Message: <input type="text" th:field="*{content}" /></p>
<p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
</form>
</body>
</html>

文件上传

创建文件上传处理 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import java.io.IOException;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import com.example.uploadingfiles.storage.StorageFileNotFoundException;
import com.example.uploadingfiles.storage.StorageService;

@Controller
public class FileUploadController {

private final StorageService storageService;

@Autowired
public FileUploadController(StorageService storageService) {
this.storageService = storageService;
}

@GetMapping("/")
public String listUploadedFiles(Model model) throws IOException {

model.addAttribute("files", storageService.loadAll().map(
path -> MvcUriComponentsBuilder.fromMethodName(FileUploadController.class,
"serveFile", path.getFileName().toString()).build().toUri().toString())
.collect(Collectors.toList()));

return "uploadForm";
}

@GetMapping("/files/{filename:.+}")
@ResponseBody
public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

Resource file = storageService.loadAsResource(filename);
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.getFilename() + "\"").body(file);
}

@PostMapping("/")
public String handleFileUpload(@RequestParam("file") MultipartFile file,
RedirectAttributes redirectAttributes) {

storageService.store(file);
redirectAttributes.addFlashAttribute("message",
"You successfully uploaded " + file.getOriginalFilename() + "!");

return "redirect:/";
}

@ExceptionHandler(StorageFileNotFoundException.class)
public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
return ResponseEntity.notFound().build();
}

}

FileUploadController 类使用 @Controller 注解,以便 Spring 可以扫描并注册它。 每个方法都标有 @GetMapping@PostMapping ,将路径和 HTTP 操作映射到指定的控制器。

在这种情况下:

  • GET /:从 StorageService 中查找当前上传文件的列表,并将其加载到 Thymeleaf 模板中。 它使用 MvcUriComponentsBuilder 计算指向实际资源的链接。

  • GET /files/{filename}:加载资源(如果存在)并使用 Content-Disposition 响应标头将其发送到浏览器进行下载。

  • POST /:处理一个多部分的消息文件,并将其交给 StorageService 进行保存。

定义存储文件的 Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {

void init();

void store(MultipartFile file);

Stream<Path> loadAll();

Path load(String filename);

Resource loadAsResource(String filename);

void deleteAll();

}

一个加单的 StorageService 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;

@Service
public class FileSystemStorageServiceImpl implements StorageService {

private final Path rootLocation;

@Autowired
public FileSystemStorageServiceImpl(StorageProperties properties) {
this.rootLocation = Paths.get(properties.getLocation());
}

@Override
public void deleteAll() {
FileSystemUtils.deleteRecursively(rootLocation.toFile());
}

@Override
public void init() {
try {
Files.createDirectories(rootLocation);
} catch (IOException e) {
throw new StorageException("Could not initialize storage", e);
}
}

@Override
public Path load(String filename) {
return rootLocation.resolve(filename);
}

@Override
public Stream<Path> loadAll() {
try {
return Files.walk(this.rootLocation, 1).filter(path -> !path.equals(this.rootLocation))
.map(this.rootLocation::relativize);
} catch (IOException e) {
throw new StorageException("Failed to read stored files", e);
}
}

@Override
public Resource loadAsResource(String filename) {
try {
Path file = load(filename);
Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) {
return resource;
} else {
throw new StorageFileNotFoundException("Could not read file: " + filename);
}
} catch (MalformedURLException e) {
throw new StorageFileNotFoundException("Could not read file: " + filename, e);
}
}

@Override
public void store(MultipartFile file) {
String filename = StringUtils.cleanPath(file.getOriginalFilename());
try {
if (file.isEmpty()) {
throw new StorageException("Failed to store empty file " + filename);
}
if (filename.contains("..")) {
// This is a security check
throw new StorageException(
"Cannot store file with relative path outside current directory " + filename);
}
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, this.rootLocation.resolve(filename), StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new StorageException("Failed to store file " + filename, e);
}
}

}

创建文件上传表单

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
<html xmlns:th="https://www.thymeleaf.org">
<body>

<div th:if="${message}">
<h2 th:text="${message}"/>
</div>

<div>
<form method="POST" enctype="multipart/form-data" action="/">
<table>
<tr><td>File to upload:</td><td><input type="file" name="file" /></td></tr>
<tr><td></td><td><input type="submit" value="Upload" /></td></tr>
</table>
</form>
</div>

<div>
<ul>
<li th:each="file : ${files}">
<a th:href="${file}" th:text="${file}" />
</li>
</ul>
</div>

</body>
</html>

文件上传限制

如果使用 Spring Boot,可以使用一些属性设置来调整其自动配置的 MultipartConfigElement

将以下属性添加到现有属性设置中(在 src/main/resources/application.properties 中):

1
2
spring.servlet.multipart.max-file-size=128KB
spring.servlet.multipart.max-request-size=128KB
  • spring.servlet.multipart.max-file-size 设置为 128KB,表示总文件大小不能超过 128KB。
  • spring.servlet.multipart.max-request-size 设置为 128KB,这意味着 multipart/form-data 的总请求大小不能超过 128KB。

异常处理

@ExceptionHandler

@Controller@ControllerAdvice 类可以用 @ExceptionHandler 方法来处理来自控制器方法的异常,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
@Controller
public class SimpleController {

// ...

@ExceptionHandler
public ResponseEntity<String> handle(IOException ex) {
// ...
}
}

异常可能与正在传播的顶级异常(例如,抛出直接的 IOException)或包装器异常中的嵌套原因(例如,包装在 IllegalStateException 中的 IOException)相匹配。从 5.3 开始,这可以匹配任意原因级别,而以前只考虑直接原因。

对于匹配的异常类型,最好将目标异常声明为方法参数,如前面的示例所示。当多个异常方法匹配时,根异常匹配通常优先于原因异常匹配。更具体地说,ExceptionDepthComparator 用于根据抛出的异常类型的深度对异常进行排序。

或者,注解声明可以缩小要匹配的异常类型,如以下示例所示:

1
2
3
4
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(IOException ex) {
// ...
}

您甚至可以使用具有非常通用的参数签名的特定异常类型列表,如以下示例所示:

1
2
3
4
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(Exception ex) {
// ...
}

通常建议您在参数签名中尽可能具体,以减少根本和原因异常类型之间不匹配的可能性。考虑将一个多重匹配方法分解为单独的 @ExceptionHandler 方法,每个方法通过其签名匹配一个特定的异常类型。

在多 @ControllerAdvice 安排中,建议在具有相应顺序优先级的 @ControllerAdvice 上声明您的主要根异常映射。虽然根异常匹配优于原因,但这是在给定控制器或 @ControllerAdvice 类的方法中定义的。这意味着优先级较高的 @ControllerAdvice 上的原因匹配优于优先级较低的 @ControllerAdvice 上的任何匹配(例如,root)。

最后但同样重要的是, @ExceptionHandler 方法实现可以选择通过以原始形式重新抛出给定异常实例来退出处理。这在您只对根级匹配或无法静态确定的特定上下文中的匹配感兴趣的情况下很有用。重新抛出的异常通过剩余的解析链传播,就好像给定的 @ExceptionHandler 方法一开始就不会匹配一样。

Spring MVC 中对 @ExceptionHandler 方法的支持建立在 DispatcherServlet 级别 HandlerExceptionResolver 机制上。

附录:

@ExceptionHandler 支持的参数

@ExceptionHandler 支持返回值

参考资料