这篇笔记围绕 mall 项目的基础框架整合过程展开,核心目标是梳理一个电商后端在搭建初期通常会接入的基础设施,包括 ORM、接口文档、缓存、认证授权、定时任务、搜索、文档存储、消息队列以及对象存储等组件。
文中的代码与依赖版本对应 mall 早期技术栈,重点在于说明各组件在系统中的职责划分、整合方式与典型配置项。涉及版本演进较快的组件时,需要结合当前项目实际版本调整实现细节。
参考资料:
项目源码:macrozheng/mall
Spring 参考文档:Spring Boot Reference Guide 、 Spring Security Reference 、 Spring Framework Scheduling
数据访问与搜索:MyBatis Generator 、 Spring Data Redis 、 Spring Data Elasticsearch 、 Spring Data MongoDB
中间件与存储:RabbitMQ TTL 、 RabbitMQ Dead Letter Exchanges 、 JWT RFC 7519 、 阿里云 OSS 文档
[TOC]
整合全景
mall 项目本身并不是单纯地把若干中间件堆到一起,而是围绕电商业务链路将不同组件放到合适的位置上。下单、搜索、认证、缓存、消息投递和文件上传分别关注不同的系统能力,整合工作的关键在于明确每个组件解决的是什么问题,以及它和业务模块之间通过什么边界协作。
| 组件 | 主要用途 | 在 mall 中承担的职责 | 整合时需要优先关注的点 |
|---|---|---|---|
| MyBatis / MBG | 关系型数据访问 | 商品、订单、会员、权限等核心业务数据读写 | 实体生成、Mapper 扫描、分页插件、数据源配置 |
| Swagger-UI | 接口文档与联调 | 输出后台接口说明,辅助前后端联调 | 扫描包路径、鉴权头透传、模型注解生成 |
| Redis | 缓存与高频临时数据 | 验证码、订单号、会员信息等热点数据缓存 | Key 设计、过期时间、序列化策略 |
| Spring Security + JWT | 认证与授权 | 后台管理接口登录鉴权与权限控制 | 过滤器链、用户信息加载、Token 失效处理 |
| SpringTask | 定时任务 | 扫描超时未支付订单并触发业务处理 | Cron 表达式、任务粒度、幂等性 |
| Elasticsearch | 商品检索 | 商品搜索、筛选、推荐、聚合分析 | 索引映射、分词器、查询 DSL、排序权重 |
| MongoDB | 文档型数据存储 | 会员浏览记录等结构相对灵活的数据 | 集合设计、查询接口、按会员维度清理数据 |
| RabbitMQ | 异步解耦 | 订单超时取消等延迟处理链路 | TTL、死信交换机、路由键与消费队列 |
| OSS | 对象存储 | 图片等文件的上传与回调 | 服务端签名、回调校验、目录前缀与访问域名 |
graph TD
A[前台/后台请求] --> B[Spring Boot 应用]
B --> C[MyBatis MySQL]
B --> D[Redis 缓存]
B --> E[Spring Security JWT]
B --> F[SpringTask 定时任务]
B --> G[Elasticsearch 搜索]
B --> H[MongoDB 文档存储]
B --> I[RabbitMQ 异步消息]
B --> J[OSS 文件上传]
项目搭建
使用 IDEA 初始化一个 Spring Boot 项目,先引入通用依赖:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
<!--SpringBoot通用依赖模块-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
整合 MyBatis
mysql数据库环境搭建
- 下载并安装mysql5.7版本,下载地址:https://dev.mysql.com/downloads/installer/
- 设置数据库帐号密码:root root
- 下载并安装客户端连接工具 Navicat,下载地址:http://www.formysql.com/xiazai.html
- 创建数据库 mall
PageHelper
PageHelper 是 MyBatis 的常用分页插件,整合后可以直接在查询前声明分页参数,再通过 PageInfo 统一获取分页结果元数据。
1
2
3
4
5
PageHelper.startPage(pageNum, pageSize);
//之后进行查询操作将自动进行分页
List<PmsBrand> brandList = brandMapper.selectByExample(new PmsBrandExample());
//通过构造PageInfo对象获取分页信息,如当前页码,总页数,总条数
PageInfo<PmsBrand> pageInfo = new PageInfo<PmsBrand>(brandList);
Druid
阿里的 Druid 数据库连接池
Mybatis generator
mybatis 的代码生成器,参考资料 IDEA使用mybatis-generator
新建 generatorConfig.xml 配置文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
<properties resource="generator.properties"/>
<context id="MySqlContext" targetRuntime="MyBatis3" defaultModelType="flat">
<property name="beginningDelimiter" value="`"/>
<property name="endingDelimiter" value="`"/>
<property name="javaFileEncoding" value="UTF-8"/>
<!-- 为模型生成序列化方法-->
<plugin type="org.mybatis.generator.plugins.SerializablePlugin"/>
<!-- 为生成的Java模型创建一个toString方法 -->
<plugin type="org.mybatis.generator.plugins.ToStringPlugin"/>
<!--生成mapper.xml时覆盖原文件-->
<plugin type="org.mybatis.generator.plugins.UnmergeableXmlMappersPlugin" />
<commentGenerator type="com.macro.mall.CommentGenerator">
<!-- 是否去除自动生成的注释 true:是 : false:否 -->
<property name="suppressAllComments" value="true"/>
<property name="suppressDate" value="true"/>
<property name="addRemarkComments" value="true"/>
</commentGenerator>
<jdbcConnection driverClass="${jdbc.driverClass}"
connectionURL="${jdbc.connectionURL}"
userId="${jdbc.userId}"
password="${jdbc.password}">
<!--解决mysql驱动升级到8.0后不生成指定数据库代码的问题-->
<property name="nullCatalogMeansCurrent" value="true" />
</jdbcConnection>
<javaModelGenerator targetPackage="com.macro.mall.model" targetProject="mall-mbg\src\main\java"/>
<sqlMapGenerator targetPackage="com.macro.mall.mapper" targetProject="mall-mbg\src\main\resources"/>
<javaClientGenerator type="XMLMAPPER" targetPackage="com.macro.mall.mapper"
targetProject="mall-mbg\src\main\java"/>
<!--生成全部表tableName设为%-->
<table tableName="%">
<generatedKey column="id" sqlStatement="MySql" identity="true"/>
</table>
</context>
</generatorConfiguration>
新建类,通过main方法执行 Generator 生成代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Generator {
public static void main(String[] args) throws Exception {
//MBG 执行过程中的警告信息
List<String> warnings = new ArrayList<String>();
//当生成的代码重复时,覆盖原代码
boolean overwrite = true;
//读取 MBG 配置文件
InputStream is = Generator.class.getResourceAsStream("/generatorConfig.xml");
ConfigurationParser cp = new ConfigurationParser(warnings);
Configuration config = cp.parseConfiguration(is);
is.close();
DefaultShellCallback callback = new DefaultShellCallback(overwrite);
//创建 MBG
MyBatisGenerator myBatisGenerator = new MyBatisGenerator(config, callback, warnings);
//执行生成代码
myBatisGenerator.generate(null);
//输出警告信息
for (String warning : warnings) {
System.out.println(warning);
}
}
}
在上面刚刚新建的项目中添加依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!--MyBatis分页插件-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.2.10</version>
</dependency>
<!--集成druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<!-- MyBatis 生成器 -->
<dependency>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-core</artifactId>
<version>1.3.3</version>
</dependency>
<!--Mysql数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
application.yml 中添加数据源配置和 MyBatis 的 mapper.xml 的路径配置
1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
mybatis:
mapper-locations:
- classpath:mapper/*.xml
- classpath*:com/**/mapper/*.xml
整合 Swagger-UI 实现在线 API 文档
常见注解:
- @Api: 用于修饰Controller类,生成Controller相关文档信息
- @ApiOperation: 用于修饰Controller类中的方法,生成接口方法相关文档信息
- @ApiParam: 用于修饰接口中的参数,生成接口参数相关文档信息
- @ApiModelProperty: 用于修饰实体类的属性,当实体类是请求参数或返回结果时,直接生成相关文档信息
在 pom 文件中添加 Swagger-UI 依赖
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
添加 Swagger-UI 配置类
Swagger 对生成 API 文档的范围有三种不同的选择
- 生成指定包下面的类的API文档
- 生成有指定注解的类的API文档
- 生成有指定注解的方法的API文档
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
public abstract class BaseSwaggerConfig {
@Bean
public Docket createRestApi() {
SwaggerProperties swaggerProperties = swaggerProperties();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo(swaggerProperties))
.select()
.apis(RequestHandlerSelectors.basePackage(swaggerProperties.getApiBasePackage()))
.paths(PathSelectors.any())
.build();
if (swaggerProperties.isEnableSecurity()) {
docket.securitySchemes(securitySchemes()).securityContexts(securityContexts());
}
return docket;
}
private ApiInfo apiInfo(SwaggerProperties swaggerProperties) {
return new ApiInfoBuilder()
.title(swaggerProperties.getTitle())
.description(swaggerProperties.getDescription())
.contact(new Contact(swaggerProperties.getContactName(), swaggerProperties.getContactUrl(), swaggerProperties.getContactEmail()))
.version(swaggerProperties.getVersion())
.build();
}
private List<ApiKey> securitySchemes() {
//设置请求头信息
List<ApiKey> result = new ArrayList<>();
ApiKey apiKey = new ApiKey("Authorization", "Authorization", "header");
result.add(apiKey);
return result;
}
private List<SecurityContext> securityContexts() {
//设置需要登录认证的路径
List<SecurityContext> result = new ArrayList<>();
result.add(getContextByPath("/*/.*"));
return result;
}
private SecurityContext getContextByPath(String pathRegex) {
return SecurityContext.builder()
.securityReferences(defaultAuth())
.forPaths(PathSelectors.regex(pathRegex))
.build();
}
private List<SecurityReference> defaultAuth() {
List<SecurityReference> result = new ArrayList<>();
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
result.add(new SecurityReference("Authorization", authorizationScopes));
return result;
}
/**
* 自定义Swagger配置
*/
public abstract SwaggerProperties swaggerProperties();
}
自定义实现Swagger配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@EnableSwagger2
public class SwaggerConfig extends BaseSwaggerConfig {
@Override
public SwaggerProperties swaggerProperties() {
return SwaggerProperties.builder()
.apiBasePackage("com.macro.mall.controller")
.title("mall后台系统")
.description("mall后台相关接口文档")
.contactName("macro")
.version("1.0")
.enableSecurity(true)
.build();
}
}
在项目中使用Swagger注解样例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@Api(tags = "CmsPrefrenceAreaController", description = "商品优选管理")
@RequestMapping("/prefrenceArea")
public class CmsPrefrenceAreaController {
@Autowired
private CmsPrefrenceAreaService prefrenceAreaService;
@ApiOperation("获取所有商品优选")
@RequestMapping(value = "/listAll", method = RequestMethod.GET)
@ResponseBody
public CommonResult<List<CmsPrefrenceArea>> listAll() {
List<CmsPrefrenceArea> prefrenceAreaList = prefrenceAreaService.listAll();
return CommonResult.success(prefrenceAreaList);
}
}
加入Swagger后,需要修改Mybatis Generator注释的生成规则:
CommentGenerator 是 MyBatis Generator 的自定义注释生成器。这里重写 addFieldComment 方法,让生成的实体字段直接输出 Swagger 的 @ApiModelProperty 注解;同时补充 addJavaFileComment 方法,自动导入注解类型,避免批量生成实体类后再逐个手工修改 import。
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
public class CommentGenerator extends DefaultCommentGenerator {
private boolean addRemarkComments = false;
private static final String EXAMPLE_SUFFIX="Example";
private static final String API_MODEL_PROPERTY_FULL_CLASS_NAME="io.swagger.annotations.ApiModelProperty";
/**
* 设置用户配置的参数
*/
@Override
public void addConfigurationProperties(Properties properties) {
super.addConfigurationProperties(properties);
this.addRemarkComments = StringUtility.isTrue(properties.getProperty("addRemarkComments"));
}
/**
* 给字段添加注释
*/
@Override
public void addFieldComment(Field field, IntrospectedTable introspectedTable,
IntrospectedColumn introspectedColumn) {
String remarks = introspectedColumn.getRemarks();
//根据参数和备注信息判断是否添加备注信息
if(addRemarkComments&&StringUtility.stringHasValue(remarks)){
// addFieldJavaDoc(field, remarks);
//数据库中特殊字符需要转义
if(remarks.contains("\"")){
remarks = remarks.replace("\"","'");
}
//给model的字段添加swagger注解
field.addJavaDocLine("@ApiModelProperty(value = \""+remarks+"\")");
}
}
/**
* 给model的字段添加注释
*/
private void addFieldJavaDoc(Field field, String remarks) {
//文档注释开始
field.addJavaDocLine("/**");
//获取数据库字段的备注信息
String[] remarkLines = remarks.split(System.getProperty("line.separator"));
for(String remarkLine:remarkLines){
field.addJavaDocLine(" * "+remarkLine);
}
addJavadocTag(field, false);
field.addJavaDocLine(" */");
}
@Override
public void addJavaFileComment(CompilationUnit compilationUnit) {
super.addJavaFileComment(compilationUnit);
//只在model中添加swagger注解类的导入
if(!compilationUnit.isJavaInterface()&&!compilationUnit.getType().getFullyQualifiedName().contains(EXAMPLE_SUFFIX)){
compilationUnit.addImportedType(new FullyQualifiedJavaType(API_MODEL_PROPERTY_FULL_CLASS_NAME));
}
}
}
重新运行 Generator 的 main 方法,再生成一次代码,这个时候实体类会自动根据数据库的注释添加 @ApiModelProperty 注解
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
/**
* 订单修改收货人信息参数
* Created by macro on 2018/10/29.
*/
@Getter
@Setter
public class OmsCartItem implements Serializable {
...
...
@ApiModelProperty(value = "购买数量")
private Integer quantity;
@ApiModelProperty(value = "添加到购物车的价格")
private BigDecimal price;
@ApiModelProperty(value = "商品主图")
private String productPic;
@ApiModelProperty(value = "商品名称")
private String productName;
@ApiModelProperty(value = "商品副标题(卖点)")
private String productSubTitle;
@ApiModelProperty(value = "商品sku条码")
...
...
}
运行项目,查看结果
接口地址:http://localhost:8080/swagger-ui.html
这里原本预留的是 Swagger 页面截图位。如果后续补图,建议将图片放到站内统一的资源目录,避免出现空图片链接。
整合 Redis 实现缓存
windows版下载地址:https://github.com/MicrosoftArchive/redis/releases

下载完后解压到指定目录

在当前地址栏输入cmd后,执行redis的启动命令:redis-server.exe redis.windows.conf

添加 redis 依赖:
1
2
3
4
5
<!--redis依赖配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
项目 application.yml 文件中添加 Redis 的配置及 Redis 中自定义 key 的配置
1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
redis:
host: localhost # Redis服务器地址
database: 0 # Redis数据库索引(默认为0)
port: 6379 # Redis服务器连接端口
password: # Redis服务器连接密码(默认为空)
jedis:
pool:
max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
max-idle: 8 # 连接池中的最大空闲连接
min-idle: 0 # 连接池中的最小空闲连接
timeout: 3000ms # 连接超时时间(毫秒)
1
2
3
4
5
6
7
8
9
10
# 自定义redis key
redis:
database: mall
key:
authCode: 'ums:authCode'
orderId: 'oms:orderId'
member: 'ums:member'
expire:
authCode: 90 # 验证码超期时间
common: 86400 # 24小时
添加 RedisService 接口用于定义一些常用 Redis 操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
public interface RedisService {
/**
* 保存属性
*/
void set(String key, Object value, long time);
/**
* 保存属性
*/
void set(String key, Object value);
/**
* 获取属性
*/
Object get(String key);
/**
* 删除属性
*/
Boolean del(String key);
/**
* 批量删除属性
*/
Long del(List<String> keys);
/**
* 设置过期时间
*/
Boolean expire(String key, long time);
/**
* 获取过期时间
*/
Long getExpire(String key);
/**
* 判断是否有该属性
*/
Boolean hasKey(String key);
/**
* 按delta递增
*/
Long incr(String key, long delta);
/**
* 按delta递减
*/
Long decr(String key, long delta);
/**
* 获取Hash结构中的属性
*/
Object hGet(String key, String hashKey);
/**
* 向Hash结构中放入一个属性
*/
Boolean hSet(String key, String hashKey, Object value, long time);
/**
* 向Hash结构中放入一个属性
*/
void hSet(String key, String hashKey, Object value);
/**
* 直接获取整个Hash结构
*/
Map<Object, Object> hGetAll(String key);
/**
* 直接设置整个Hash结构
*/
Boolean hSetAll(String key, Map<String, Object> map, long time);
/**
* 直接设置整个Hash结构
*/
void hSetAll(String key, Map<String, ?> map);
/**
* 删除Hash结构中的属性
*/
void hDel(String key, Object... hashKey);
/**
* 判断Hash结构中是否有该属性
*/
Boolean hHasKey(String key, String hashKey);
/**
* Hash结构中属性递增
*/
Long hIncr(String key, String hashKey, Long delta);
/**
* Hash结构中属性递减
*/
Long hDecr(String key, String hashKey, Long delta);
/**
* 获取Set结构
*/
Set<Object> sMembers(String key);
/**
* 向Set结构中添加属性
*/
Long sAdd(String key, Object... values);
/**
* 向Set结构中添加属性
*/
Long sAdd(String key, long time, Object... values);
/**
* 是否为Set中的属性
*/
Boolean sIsMember(String key, Object value);
/**
* 获取Set结构的长度
*/
Long sSize(String key);
/**
* 删除Set结构中的属性
*/
Long sRemove(String key, Object... values);
/**
* 获取List结构中的属性
*/
List<Object> lRange(String key, long start, long end);
/**
* 获取List结构的长度
*/
Long lSize(String key);
/**
* 根据索引获取List中的属性
*/
Object lIndex(String key, long index);
/**
* 向List结构中添加属性
*/
Long lPush(String key, Object value);
/**
* 向List结构中添加属性
*/
Long lPush(String key, Object value, long time);
/**
* 向List结构中批量添加属性
*/
Long lPushAll(String key, Object... values);
/**
* 向List结构中批量添加属性
*/
Long lPushAll(String key, Long time, Object... values);
/**
* 从List结构中移除属性
*/
Long lRemove(String key, long count, Object value);
}
注入 RedisTemplate ,实现 RedisService 接口
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
public class RedisServiceImpl implements RedisService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void set(String key, Object value, long time) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
}
@Override
public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
@Override
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
@Override
public Boolean del(String key) {
return redisTemplate.delete(key);
}
@Override
public Long del(List<String> keys) {
return redisTemplate.delete(keys);
}
@Override
public Boolean expire(String key, long time) {
return redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
@Override
public Long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
@Override
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
@Override
public Long incr(String key, long delta) {
return redisTemplate.opsForValue().increment(key, delta);
}
@Override
public Long decr(String key, long delta) {
return redisTemplate.opsForValue().increment(key, -delta);
}
@Override
public Object hGet(String key, String hashKey) {
return redisTemplate.opsForHash().get(key, hashKey);
}
@Override
public Boolean hSet(String key, String hashKey, Object value, long time) {
redisTemplate.opsForHash().put(key, hashKey, value);
return expire(key, time);
}
@Override
public void hSet(String key, String hashKey, Object value) {
redisTemplate.opsForHash().put(key, hashKey, value);
}
@Override
public Map<Object, Object> hGetAll(String key) {
return redisTemplate.opsForHash().entries(key);
}
@Override
public Boolean hSetAll(String key, Map<String, Object> map, long time) {
redisTemplate.opsForHash().putAll(key, map);
return expire(key, time);
}
@Override
public void hSetAll(String key, Map<String, ?> map) {
redisTemplate.opsForHash().putAll(key, map);
}
@Override
public void hDel(String key, Object... hashKey) {
redisTemplate.opsForHash().delete(key, hashKey);
}
@Override
public Boolean hHasKey(String key, String hashKey) {
return redisTemplate.opsForHash().hasKey(key, hashKey);
}
@Override
public Long hIncr(String key, String hashKey, Long delta) {
return redisTemplate.opsForHash().increment(key, hashKey, delta);
}
@Override
public Long hDecr(String key, String hashKey, Long delta) {
return redisTemplate.opsForHash().increment(key, hashKey, -delta);
}
@Override
public Set<Object> sMembers(String key) {
return redisTemplate.opsForSet().members(key);
}
@Override
public Long sAdd(String key, Object... values) {
return redisTemplate.opsForSet().add(key, values);
}
@Override
public Long sAdd(String key, long time, Object... values) {
Long count = redisTemplate.opsForSet().add(key, values);
expire(key, time);
return count;
}
@Override
public Boolean sIsMember(String key, Object value) {
return redisTemplate.opsForSet().isMember(key, value);
}
@Override
public Long sSize(String key) {
return redisTemplate.opsForSet().size(key);
}
@Override
public Long sRemove(String key, Object... values) {
return redisTemplate.opsForSet().remove(key, values);
}
@Override
public List<Object> lRange(String key, long start, long end) {
return redisTemplate.opsForList().range(key, start, end);
}
@Override
public Long lSize(String key) {
return redisTemplate.opsForList().size(key);
}
@Override
public Object lIndex(String key, long index) {
return redisTemplate.opsForList().index(key, index);
}
@Override
public Long lPush(String key, Object value) {
return redisTemplate.opsForList().rightPush(key, value);
}
@Override
public Long lPush(String key, Object value, long time) {
Long index = redisTemplate.opsForList().rightPush(key, value);
expire(key, time);
return index;
}
@Override
public Long lPushAll(String key, Object... values) {
return redisTemplate.opsForList().rightPushAll(key, values);
}
@Override
public Long lPushAll(String key, Long time, Object... values) {
Long count = redisTemplate.opsForList().rightPushAll(key, values);
expire(key, time);
return count;
}
@Override
public Long lRemove(String key, long count, Object value) {
return redisTemplate.opsForList().remove(key, count, value);
}
}
整合 SpringSecurity 和 JWT 实现认证和授权
SpringSecurity
SpringSecurity 是一个强大的可高度定制的认证和授权框架,对于 Spring 应用来说它是一套 Web 安全标准。SpringSecurity 注重于为 Java 应用提供认证和授权功能,像所有的 Spring 项目一样,它对自定义需求具有强大的扩展性
JWT:
JWT 是 JSON WEB TOKEN 的缩写,它是基于 RFC 7519 标准定义的一种可以安全传输的的 JSON 对象,由于使用了数字签名,所以是可信任和安全的
JWT的组成:
- JWT token的格式:header.payload.signature
- header 中用于存放签名的生成算法
{"alg": "HS512"} - payload中用于存放用户名、token的生成时间和过期时间
{"sub":"admin","created":1489079981393,"exp":1489684781} - signature为以header和payload生成的签名,一旦header和payload被篡改,验证将失败
String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)secret为加密算法的密钥
JWT 字符串案例:
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE1NTY3NzkxMjUzMDksImV4cCI6MTU1NzM4MzkyNX0.d-iki0193X0bBOETf2UN3r3PotNIEAV7mzIxxeI5IxFyzzkOZxS0PGfF_SK6wxCv2K8S0cZjMkv6b5bCqc0VBw
可以在该网站上获得解析结果:https://jwt.io/

工作原理:
- 用户调用登录接口,登录成功后获取到 JWT 的 token;
- 之后用户每次调用接口都在 http 的 header 中添加一个叫 Authorization 的头,值为 JWT 的 token;
- 后台程序通过对 Authorization 头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权
Hutool:
Hutool 是一个 Java 工具集,能够将项目里零散的工具能力收敛到统一的 API 体系中,减少重复封装的 util 类。它对文件、流、加密解密、转码、正则、线程、XML、日期、HTTP 客户端等常见能力做了较完整的封装。
整合 SpringSecurity 及 JWT
在 pom.xml 中添加项目依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!--SpringSecurity依赖配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Hutool Java工具包-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.5.7</version>
</dependency>
<!--JWT(Json Web Token)登录支持-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
添加 JWT token 的工具类
用于生成和解析 JWT token 的工具类
- generateToken(UserDetails userDetails) :用于根据登录用户信息生成token
- getUserNameFromToken(String token):从token中获取登录用户的信息
- validateToken(String token, UserDetails userDetails):判断token是否还有效
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
@Component
public class JwtTokenUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
/**
* 根据负责生成JWT的token
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从token中获取JWT中的负载
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
LOGGER.info("JWT格式验证失败:{}",token);
}
return claims;
}
/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 从token中获取登录用户名
*/
public String getUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 验证token是否还有效
*
* @param token 客户端传入的token
* @param userDetails 从数据库中查询出来的用户信息
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断token是否已经失效
*/
private boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从token中获取过期时间
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 判断token是否可以被刷新
*/
public boolean canRefresh(String token) {
return !isTokenExpired(token);
}
/**
* 刷新token
*/
public String refreshToken(String token) {
Claims claims = getClaimsFromToken(token);
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
添加 SpringSecurity 的配置类
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
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UmsAdminService adminService;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf()// 由于使用 JWT,这里不再依赖 CSRF 防护
.disable()
.sessionManagement()// 基于token,所以不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, // 允许对于网站静态资源的无授权访问
"/",
"/*.html",
"/favicon.ico",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources/**",
"/v2/api-docs/**"
)
.permitAll()
.antMatchers("/admin/login", "/admin/register")// 对登录注册要允许匿名访问
.permitAll()
.antMatchers(HttpMethod.OPTIONS)//跨域请求会先进行一次options请求
.permitAll()
.anyRequest()// 除上面外的所有请求全部需要鉴权认证
.authenticated();
// 禁用缓存
httpSecurity.headers().cacheControl();
// 添加JWT filter
httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
//添加自定义未授权和未登录结果返回
httpSecurity.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthenticationEntryPoint);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService())
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService() {
//获取登录用户信息
return username -> {
UmsAdmin admin = adminService.getAdminByUsername(username);
if (admin != null) {
List<UmsPermission> permissionList = adminService.getPermissionList(admin.getId());
return new AdminUserDetails(admin,permissionList);
}
throw new UsernameNotFoundException("用户名或密码错误");
};
}
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
相关依赖及方法说明:
- configure(HttpSecurity httpSecurity): 用于配置需要拦截的url路径、jwt过滤器及出异常后的处理器
- configure(AuthenticationManagerBuilder auth): 用于配置UserDetailsService及PasswordEncoder
- RestfulAccessDeniedHandler: 当用户没有访问权限时的处理器,用于返回JSON格式的处理结果
- RestAuthenticationEntryPoint: 当未登录或token失效时,返回JSON格式的结果
- UserDetailsService: SpringSecurity定义的核心接口,用于根据用户名获取用户信息,需要自行实现
- UserDetails: SpringSecurity定义用于封装用户信息的类(主要是用户信息和权限),需要自行实现
- PasswordEncoder: SpringSecurity定义的用于对密码进行编码及比对的接口,目前使用的是BCryptPasswordEncoder
- JwtAuthenticationTokenFilter: 在用户名和密码校验前添加的过滤器,如果有jwt的token,会自行根据token信息进行登录
添加 RestfulAccessDeniedHandler 自定义返回结果,没有权限访问时
1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage())));
response.getWriter().flush();
}
}
添加 RestAuthenticationEntryPoint 自定义返回结果,没有登录或token过期时
1
2
3
4
5
6
7
8
9
10
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
response.getWriter().flush();
}
}
添加 AdminUserDetails 实现 UserDetails 接口
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
/**
* SpringSecurity 需要的用户详情
*/
public class AdminUserDetails implements UserDetails {
private UmsAdmin umsAdmin;
private List<UmsPermission> permissionList;
public AdminUserDetails(UmsAdmin umsAdmin, List<UmsPermission> permissionList) {
this.umsAdmin = umsAdmin;
this.permissionList = permissionList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//返回当前用户的权限
return permissionList.stream()
.filter(permission -> permission.getValue()!=null)
.map(permission ->new SimpleGrantedAuthority(permission.getValue()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return umsAdmin.getPassword();
}
@Override
public String getUsername() {
return umsAdmin.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return umsAdmin.getStatus().equals(1);
}
}
添加 JwtAuthenticationTokenFilter
在用户名和密码校验前添加的过滤器,如果请求中有 jwt 的 token 且有效,会取出 token 中的用户名,然后调用 SpringSecurity 的 API 进行登录操作
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
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
String username = jwtTokenUtil.getUserNameFromToken(authToken);
LOGGER.info("checking username:{}", username);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
LOGGER.info("authenticated user:{}", username);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
对于基于 JWT 的后台管理接口,认证链路可以概括为下面的过程:
sequenceDiagram
participant Client as 客户端
participant Login as 登录接口
participant Token as JwtTokenUtil
participant Filter as JwtAuthenticationTokenFilter
participant Security as SpringSecurity
participant API as 业务接口
Client->>Login: 提交用户名和密码
Login->>Token: 生成 JWT
Token-->>Client: 返回 token
Client->>Filter: 携带 Authorization 请求头访问接口
Filter->>Token: 解析并校验 token
Filter->>Security: 写入认证信息
Security->>API: 放行业务请求
API-->>Client: 返回接口结果
这类实现的关键并不在于“登录后保存 Session”,而在于将用户身份放入签名后的 Token 中,再由过滤器在每次请求进入业务层前完成解析、校验和上下文恢复。
整合 SpringTask 实现定时任务
SpringTask:
SpringTask 是 Spring 自主研发的轻量级定时任务工具,相比于 Quartz 更加简单方便,且不需要引入其他依赖即可使用
Cron表达式:
Cron 表达式是一个字符串,包括 6~7 个时间元素,在 SpringTask 中可以用于指定任务的执行时间
Cron的语法格式:
Seconds Minutes Hours DayofMonth Month DayofWeek
Cron 格式中每个时间元素的说明:
| 时间元素 | 可出现的字符 | 有效数值范围 |
|---|---|---|
| Seconds | , - * / | 0-59 |
| Minutes | , - * / | 0-59 |
| Hours | , - * / | 0-23 |
| DayofMonth | , - * / ? L W | 1-31 |
| Month | , - * / | 1-12 |
| DayofWeek | , - * / ? L # | 1-7 或 SUN-SAT |
Cron 格式中特殊字符说明:
| 字符 | 作用 | 举例 |
|---|---|---|
| , | 列出枚举值 | 在 Minutes 域使用 5,10,表示在 5 分和 10 分各触发一次 |
| - | 表示触发范围 | 在 Minutes 域使用 5-10,表示从 5 分到 10 分每分钟触发一次 |
| * | 匹配任意值 | 在 Minutes 域使用 *,表示每分钟都会触发一次 |
| / | 从起始值开始按固定间隔触发 | 在 Minutes 域使用 5/10,表示从 5 分开始每隔 10 分钟触发一次 |
| ? | 在 DayOfMonth 和 DayOfWeek 中表示“不指定” | 在 0 0 10 ? * MON-FRI 中,? 表示不指定具体日期,只按星期维度触发 |
| # | 在 DayOfWeek 中表示“第几个星期几” | 1#3 表示第三个星期日 |
| L | 表示最后 | 在 DayOfWeek 中使用 5L,表示当月最后一个星期四触发 |
| W | 表示离指定日期最近的工作日 | 在 DayOfMonth 使用 5W,如果 5 日是星期六,则在最近的工作日触发 |
业务场景说明
- 用户对某商品进行下单操作;
- 系统需要根据用户购买的商品信息生成订单并锁定商品的库存;
- 系统设置了60分钟用户不付款就会取消订单;
- 开启一个定时任务,每隔10分钟检查下,如果有超时还未付款的订单,就取消订单并取消锁定的商品库存。
整合SpringTask
由于 SpringTask 已经存在于 Spring 框架中,所以无需添加依赖
添加 SpringTask 的配置
只需要在配置类中添加一个 @EnableScheduling 注解即可开启 SpringTask 的定时任务能力
1
2
3
4
5
6
7
/**
* 定时任务配置
*/
@Configuration
@EnableScheduling
public class SpringTaskConfig {
}
添加 OrderTimeOutCancelTask 来执行定时任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
*订单超时取消并解锁库存的定时器
*/
@Component
public class OrderTimeOutCancelTask {
private Logger LOGGER = LoggerFactory.getLogger(OrderTimeOutCancelTask.class);
@Autowired
private OmsPortalOrderService portalOrderService;
/**
* cron表达式:Seconds Minutes Hours DayofMonth Month DayofWeek [Year]
* 每10分钟扫描一次,扫描设定超时时间之前下的订单,如果没支付则取消该订单
*/
@Scheduled(cron = "0 0/10 * ? * ?")
private void cancelTimeOutOrder() {
Integer count = portalOrderService.cancelTimeOutOrder();
LOGGER.info("取消订单,并根据sku编号释放锁定库存,取消订单数量:{}", count);
}
}
整合 Elasticsearch 实现商品搜索
Elasticsearch
Elasticsearch 是一个分布式、可扩展、实时的搜索与数据分析引擎,可用于实现全文搜索、聚合分析和实时统计等能力。
Elasticsearch 的安装和使用
下载 Elasticsearch6.2.2 的 zip 包,并解压到指定目录,下载地址:https://www.elastic.co/cn/downloads/past-releases/elasticsearch-6-2-2

安装中文分词插件,在 elasticsearch-6.2.2\bin 目录下执行以下命令:
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v6.2.2/elasticsearch-analysis-ik-6.2.2.zip

运行 bin 目录下的 elasticsearch.bat 启动 Elasticsearch
下载 Kibana ,作为访问 Elasticsearch 的客户端,请下载 6.2.2 版本的 zip 包,并解压到指定目录 下载地址:https://artifacts.elastic.co/downloads/kibana/kibana-6.2.2-windows-x86_64.zip

运行 bin 目录下的 kibana.bat,启动 Kibana 的用户界面
访问 http://localhost:5601 即可打开 Kibana 的用户界面

Spring Data Elasticsearch
Spring Data Elasticsearch是 Spring 提供的一种以 Spring Data 风格来操作数据存储的方式,它可以避免编写大量的样板代码
常见注解:
- @Document:标示映射到 Elasticsearch 文档上的领域对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Persistent
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE })
public @interface Document {
//索引库名称,对应关系型数据库中的 database 概念
String indexName();
//文档类型,mysql中表的概念
String type() default "";
//默认分片数
short shards() default 5;
//默认副本数量
short replicas() default 1;
}
- @Id:表示是文档的 id,文档可以认为是mysql中表行的概念
1
2
3
//表示是文档的id,文档可以认为是mysql中表行的概念
public @interface Id {
}
- @Field:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
@Inherited
public @interface Field {
//文档中字段的类型
FieldType type() default FieldType.Auto;
//是否建立倒排索引
boolean index() default true;
//是否进行存储
boolean store() default false;
//分词器名称
String analyzer() default "";
}
- 枚举类 FieldType :为文档自动指定元数据类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum FieldType {
Text,//会进行分词并建了索引的字符类型
Integer,
Long,
Date,
Float,
Double,
Boolean,
Object,
Auto,//自动判断字段类型
Nested,//嵌套对象类型
Ip,
Attachment,
Keyword//不会进行分词建立索引的类型
}
Spring Data 方式的数据操作
继承 ElasticsearchRepository 接口可以获得常用的数据操作方法

在接口中直接指定查询方法名称便可查询,无需进行实现,如商品表中有商品名称、标题和关键字,直接定义以下查询,就可以对这三个字段进行全文搜索
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 商品ES操作类
*/
public interface EsProductRepository extends ElasticsearchRepository<EsProduct, Long> {
/**
* 搜索查询
*
* @param name 商品名称
* @param subTitle 商品标题
* @param keywords 商品关键字
* @param page 分页信息
* @return
*/
Page<EsProduct> findByNameOrSubTitleOrKeywords(String name, String subTitle, String keywords,Pageable page);
}
使用@Query注解可以用Elasticsearch的DSL语句进行查询
1
2
@Query("{\"bool\" : {\"must\" : {\"field\" : {\"name\" : \"?0\"}}}}")
Page<EsProduct> findByName(String name,Pageable pageable);
添加 Elasticsearch 相关依赖
1
2
3
4
5
<!--Elasticsearch相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
修改application.yml文件,在spring节点下添加Elasticsearch相关配置
1
2
3
4
5
6
7
spring:
data:
elasticsearch:
repositories:
enabled: true
cluster-nodes: 127.0.0.1:9300 # es的连接地址及端口号
cluster-name: elasticsearch # es集群的名称
添加商品文档对象 EsProduct
不需要中文分词的字段设置成@Field(type = FieldType.Keyword)类型,需要中文分词的设置成@Field(analyzer = “ik_max_word”,type = FieldType.Text)类型
这里需要特别注意版本边界。文中的 indexName、type、IK 分词器配置以及部分查询 API 都对应 Elasticsearch 6.x 时代的常见写法;如果升级到 7.x/8.x,需要同步调整索引映射与客户端访问方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 搜索中的商品信息
*/
@Document(indexName = "pms", type = "product",shards = 1,replicas = 0)
public class EsProduct implements Serializable {
private static final long serialVersionUID = -1L;
@Id
private Long id;
@Field(type = FieldType.Keyword)
private String productSn;
private Long brandId;
@Field(type = FieldType.Keyword)
private String brandName;
private Long productCategoryId;
@Field(type = FieldType.Keyword)
private String productCategoryName;
private String pic;
@Field(analyzer = "ik_max_word",type = FieldType.Text)
private String name;
@Field(analyzer = "ik_max_word",type = FieldType.Text)
...
...
}
使用部分:
添加 EsProductRepository 继承 ElasticsearchRepository 接口用于操作 Elasticsearch
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 商品ES操作类
*/
public interface EsProductRepository extends ElasticsearchRepository<EsProduct, Long> {
/**
* 搜索查询
*
* @param name 商品名称
* @param subTitle 商品标题
* @param keywords 商品关键字
* @param page 分页信息
* @return
*/
Page<EsProduct> findByNameOrSubTitleOrKeywords(String name, String subTitle, String keywords,Pageable page);
}
添加 EsProductService 业务接口
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
/**
* 商品搜索管理Service
*/
public interface EsProductService {
/**
* 从数据库中导入所有商品到ES
*/
int importAll();
/**
* 根据id删除商品
*/
void delete(Long id);
/**
* 根据id创建商品
*/
EsProduct create(Long id);
/**
* 批量删除商品
*/
void delete(List<Long> ids);
/**
* 根据关键字搜索名称或者副标题
*/
Page<EsProduct> search(String keyword, Integer pageNum, Integer pageSize);
/**
* 根据关键字搜索名称或者副标题复合查询
*/
Page<EsProduct> search(String keyword, Long brandId, Long productCategoryId, Integer pageNum, Integer pageSize,Integer sort);
/**
* 根据商品id推荐相关商品
*/
Page<EsProduct> recommend(Long id, Integer pageNum, Integer pageSize);
/**
* 获取搜索词相关品牌、分类、属性
*/
EsProductRelatedInfo searchRelatedInfo(String keyword);
}
添加 EsProductService 业务接口的实现类 EsProductServiceImpl ,注入 EsProductRepository
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
/**
* 商品搜索管理Service实现类
*/
@Service
public class EsProductServiceImpl implements EsProductService {
private static final Logger LOGGER = LoggerFactory.getLogger(EsProductServiceImpl.class);
@Autowired
private EsProductDao productDao;
@Autowired
private EsProductRepository productRepository;
@Autowired
private ElasticsearchRestTemplate elasticsearchRestTemplate;
@Override
public int importAll() {
List<EsProduct> esProductList = productDao.getAllEsProductList(null);
Iterable<EsProduct> esProductIterable = productRepository.saveAll(esProductList);
Iterator<EsProduct> iterator = esProductIterable.iterator();
int result = 0;
while (iterator.hasNext()) {
result++;
iterator.next();
}
return result;
}
@Override
public void delete(Long id) {
productRepository.deleteById(id);
}
@Override
public EsProduct create(Long id) {
EsProduct result = null;
List<EsProduct> esProductList = productDao.getAllEsProductList(id);
if (esProductList.size() > 0) {
EsProduct esProduct = esProductList.get(0);
result = productRepository.save(esProduct);
}
return result;
}
@Override
public void delete(List<Long> ids) {
if (!CollectionUtils.isEmpty(ids)) {
List<EsProduct> esProductList = new ArrayList<>();
for (Long id : ids) {
EsProduct esProduct = new EsProduct();
esProduct.setId(id);
esProductList.add(esProduct);
}
productRepository.deleteAll(esProductList);
}
}
@Override
public Page<EsProduct> search(String keyword, Integer pageNum, Integer pageSize) {
Pageable pageable = PageRequest.of(pageNum, pageSize);
return productRepository.findByNameOrSubTitleOrKeywords(keyword, keyword, keyword, pageable);
}
@Override
public Page<EsProduct> search(String keyword, Long brandId, Long productCategoryId, Integer pageNum, Integer pageSize,Integer sort) {
Pageable pageable = PageRequest.of(pageNum, pageSize);
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
//分页
nativeSearchQueryBuilder.withPageable(pageable);
//过滤
if (brandId != null || productCategoryId != null) {
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
if (brandId != null) {
boolQueryBuilder.must(QueryBuilders.termQuery("brandId", brandId));
}
if (productCategoryId != null) {
boolQueryBuilder.must(QueryBuilders.termQuery("productCategoryId", productCategoryId));
}
nativeSearchQueryBuilder.withFilter(boolQueryBuilder);
}
//搜索
if (StringUtils.isEmpty(keyword)) {
nativeSearchQueryBuilder.withQuery(QueryBuilders.matchAllQuery());
} else {
List<FunctionScoreQueryBuilder.FilterFunctionBuilder> filterFunctionBuilders = new ArrayList<>();
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("name", keyword),
ScoreFunctionBuilders.weightFactorFunction(10)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("subTitle", keyword),
ScoreFunctionBuilders.weightFactorFunction(5)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("keywords", keyword),
ScoreFunctionBuilders.weightFactorFunction(2)));
FunctionScoreQueryBuilder.FilterFunctionBuilder[] builders = new FunctionScoreQueryBuilder.FilterFunctionBuilder[filterFunctionBuilders.size()];
filterFunctionBuilders.toArray(builders);
FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery(builders)
.scoreMode(FunctionScoreQuery.ScoreMode.SUM)
.setMinScore(2);
nativeSearchQueryBuilder.withQuery(functionScoreQueryBuilder);
}
//排序
if(sort==1){
//按新品从新到旧
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort("id").order(SortOrder.DESC));
}else if(sort==2){
//按销量从高到低
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort("sale").order(SortOrder.DESC));
}else if(sort==3){
//按价格从低到高
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC));
}else if(sort==4){
//按价格从高到低
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));
}else{
//按相关度
nativeSearchQueryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
}
nativeSearchQueryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
NativeSearchQuery searchQuery = nativeSearchQueryBuilder.build();
LOGGER.info("DSL:{}", searchQuery.getQuery().toString());
SearchHits<EsProduct> searchHits = elasticsearchRestTemplate.search(searchQuery, EsProduct.class);
if(searchHits.getTotalHits()<=0){
return new PageImpl<>(null,pageable,0);
}
List<EsProduct> searchProductList = searchHits.stream().map(SearchHit::getContent).collect(Collectors.toList());
return new PageImpl<>(searchProductList,pageable,searchHits.getTotalHits());
}
@Override
public Page<EsProduct> recommend(Long id, Integer pageNum, Integer pageSize) {
Pageable pageable = PageRequest.of(pageNum, pageSize);
List<EsProduct> esProductList = productDao.getAllEsProductList(id);
if (esProductList.size() > 0) {
EsProduct esProduct = esProductList.get(0);
String keyword = esProduct.getName();
Long brandId = esProduct.getBrandId();
Long productCategoryId = esProduct.getProductCategoryId();
//根据商品标题、品牌、分类进行搜索
List<FunctionScoreQueryBuilder.FilterFunctionBuilder> filterFunctionBuilders = new ArrayList<>();
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("name", keyword),
ScoreFunctionBuilders.weightFactorFunction(8)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("subTitle", keyword),
ScoreFunctionBuilders.weightFactorFunction(2)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("keywords", keyword),
ScoreFunctionBuilders.weightFactorFunction(2)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("brandId", brandId),
ScoreFunctionBuilders.weightFactorFunction(5)));
filterFunctionBuilders.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.matchQuery("productCategoryId", productCategoryId),
ScoreFunctionBuilders.weightFactorFunction(3)));
FunctionScoreQueryBuilder.FilterFunctionBuilder[] builders = new FunctionScoreQueryBuilder.FilterFunctionBuilder[filterFunctionBuilders.size()];
filterFunctionBuilders.toArray(builders);
FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery(builders)
.scoreMode(FunctionScoreQuery.ScoreMode.SUM)
.setMinScore(2);
//用于过滤掉相同的商品
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
boolQueryBuilder.mustNot(QueryBuilders.termQuery("id",id));
//构建查询条件
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder();
builder.withQuery(functionScoreQueryBuilder);
builder.withFilter(boolQueryBuilder);
builder.withPageable(pageable);
NativeSearchQuery searchQuery = builder.build();
LOGGER.info("DSL:{}", searchQuery.getQuery().toString());
SearchHits<EsProduct> searchHits = elasticsearchRestTemplate.search(searchQuery, EsProduct.class);
if(searchHits.getTotalHits()<=0){
return new PageImpl<>(null,pageable,0);
}
List<EsProduct> searchProductList = searchHits.stream().map(SearchHit::getContent).collect(Collectors.toList());
return new PageImpl<>(searchProductList,pageable,searchHits.getTotalHits());
}
return new PageImpl<>(null);
}
@Override
public EsProductRelatedInfo searchRelatedInfo(String keyword) {
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder();
//搜索条件
if(StringUtils.isEmpty(keyword)){
builder.withQuery(QueryBuilders.matchAllQuery());
}else{
builder.withQuery(QueryBuilders.multiMatchQuery(keyword,"name","subTitle","keywords"));
}
//聚合搜索品牌名称
builder.addAggregation(AggregationBuilders.terms("brandNames").field("brandName"));
//集合搜索分类名称
builder.addAggregation(AggregationBuilders.terms("productCategoryNames").field("productCategoryName"));
//聚合搜索商品属性,去除type=1的属性
AbstractAggregationBuilder aggregationBuilder = AggregationBuilders.nested("allAttrValues","attrValueList")
.subAggregation(AggregationBuilders.filter("productAttrs",QueryBuilders.termQuery("attrValueList.type",1))
.subAggregation(AggregationBuilders.terms("attrIds")
.field("attrValueList.productAttributeId")
.subAggregation(AggregationBuilders.terms("attrValues")
.field("attrValueList.value"))
.subAggregation(AggregationBuilders.terms("attrNames")
.field("attrValueList.name"))));
builder.addAggregation(aggregationBuilder);
NativeSearchQuery searchQuery = builder.build();
SearchHits<EsProduct> searchHits = elasticsearchRestTemplate.search(searchQuery, EsProduct.class);
return convertProductRelatedInfo(searchHits);
}
/**
* 将返回结果转换为对象
*/
private EsProductRelatedInfo convertProductRelatedInfo(SearchHits<EsProduct> response) {
EsProductRelatedInfo productRelatedInfo = new EsProductRelatedInfo();
Map<String, Aggregation> aggregationMap = response.getAggregations().getAsMap();
//设置品牌
Aggregation brandNames = aggregationMap.get("brandNames");
List<String> brandNameList = new ArrayList<>();
for(int i = 0; i<((Terms) brandNames).getBuckets().size(); i++){
brandNameList.add(((Terms) brandNames).getBuckets().get(i).getKeyAsString());
}
productRelatedInfo.setBrandNames(brandNameList);
//设置分类
Aggregation productCategoryNames = aggregationMap.get("productCategoryNames");
List<String> productCategoryNameList = new ArrayList<>();
for(int i=0;i<((Terms) productCategoryNames).getBuckets().size();i++){
productCategoryNameList.add(((Terms) productCategoryNames).getBuckets().get(i).getKeyAsString());
}
productRelatedInfo.setProductCategoryNames(productCategoryNameList);
//设置参数
Aggregation productAttrs = aggregationMap.get("allAttrValues");
List<? extends Terms.Bucket> attrIds = ((ParsedLongTerms) ((ParsedFilter) ((ParsedNested) productAttrs).getAggregations().get("productAttrs")).getAggregations().get("attrIds")).getBuckets();
List<EsProductRelatedInfo.ProductAttr> attrList = new ArrayList<>();
for (Terms.Bucket attrId : attrIds) {
EsProductRelatedInfo.ProductAttr attr = new EsProductRelatedInfo.ProductAttr();
attr.setAttrId((Long) attrId.getKey());
List<String> attrValueList = new ArrayList<>();
List<? extends Terms.Bucket> attrValues = ((ParsedStringTerms) attrId.getAggregations().get("attrValues")).getBuckets();
List<? extends Terms.Bucket> attrNames = ((ParsedStringTerms) attrId.getAggregations().get("attrNames")).getBuckets();
for (Terms.Bucket attrValue : attrValues) {
attrValueList.add(attrValue.getKeyAsString());
}
attr.setAttrValues(attrValueList);
if(!CollectionUtils.isEmpty(attrNames)){
String attrName = attrNames.get(0).getKeyAsString();
attr.setAttrName(attrName);
}
attrList.add(attr);
}
productRelatedInfo.setProductAttrs(attrList);
return productRelatedInfo;
}
}
整合 MongoDB 实现文档操作
mall 整合 MongoDB 的过程,以实现商品浏览记录在 MongoDB 中的添加、删除、查询为例
MongoDB
MongoDB 是面向文档的 NoSQL 数据库,适合承载结构较灵活、读写模式变化较快的数据。在 mall 里,会员浏览记录就是一个典型场景:字段结构稳定,但不需要复杂的关系型联表能力,更关注写入便捷和按用户维度的快速读取。
MongoDB 的安装和使用:
下载 MongoDB 安装包 下载地址:https://fastdl.mongodb.org/win32/mongodb-win32-x86_64-2008plus-ssl-3.2.21-signed.msi
选择安装路径进行安装


在安装路径下创建 data\db 和 data\log 两个文件夹

在安装路径下创建 mongod.cfg 配置文件
1
2
3
4
5
systemLog:
destination: file
path: D:\developer\env\MongoDB\data\log\mongod.log
storage:
dbPath: D:\developer\env\MongoDB\data\db
安装为服务(运行命令需要用管理员权限)
D:\developer\env\MongoDB\bin\mongod.exe --config "D:\developer\env\MongoDB\mongod.cfg" --install

服务相关命令
- 启动服务:net start MongoDB
- 关闭服务:net stop MongoDB
- 移除服务:D:\developer\env\MongoDB\bin\mongod.exe –remove
下载客户端程序
下载地址:https://download.robomongo.org/1.2.1/windows/robo3t-1.2.1-windows-x86_64-3e50a65.zip
解压到指定目录,打开 robo3t.exe 并连接到 localhost:27017

Spring Data MongoDB
和 Spring Data Elasticsearch 类似,Spring Data MongoDB 是 Spring 提供的一种以 Spring Data 风格来操作数据存储的方式,它可以避免编写大量的样板代码
常用注解:
- @Document: 标示映射到 MongoDB 文档上的领域对象
- @Id: 标示某个域为 ID 域
- @Indexed: 标示某个字段为 MongoDB 的索引字段
Spring Data 方式的数据操作
继承 MongoRepository 接口可以获得常用的数据操作方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 会员商品浏览历史Repository
*/
public interface MemberReadHistoryRepository extends MongoRepository<MemberReadHistory, String> {
/**
* 根据会员id按时间倒序获取浏览记录
*
* @param memberId 会员id
* @param pageable
* @return
*/
Page<MemberReadHistory> findByMemberIdOrderByCreateTimeDesc(Long memberId, Pageable pageable);
void deleteAllByMemberId(Long memberId);
}
使用 @Query 注解可以用 MongoDB 的 JSON 查询语句进行查询
1
2
@Query("{ 'memberId' : ?0 }")
List<MemberReadHistory> findByMemberId(Long memberId);
在 pom.xml 中添加相关依赖
1
2
3
4
5
<!---mongodb相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
修改 application.yml 文件,在 spring:data 节点下添加 MongoDB 相关配置
1
2
3
4
5
6
spring:
data:
mongodb:
host: localhost
port: 27017
database: mall-port
使用:
MemberReadHistoryRepository 接口继承 MongoRepository,用于操作 MongoDB
1
2
3
4
5
6
7
8
9
10
11
12
public interface MemberReadHistoryRepository extends MongoRepository<MemberReadHistory, String> {
/**
* 根据会员id按时间倒序获取浏览记录
*
* @param memberId 会员id
* @param pageable
* @return
*/
Page<MemberReadHistory> findByMemberIdOrderByCreateTimeDesc(Long memberId, Pageable pageable);
void deleteAllByMemberId(Long memberId);
}
业务层MemberReadHistoryService接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 会员浏览记录管理Service
*/
public interface MemberReadHistoryService {
/**
* 生成浏览记录
*/
int create(MemberReadHistory memberReadHistory);
/**
* 批量删除浏览记录
*/
int delete(List<String> ids);
/**
* 分页获取用户浏览历史记录
*/
Page<MemberReadHistory> list(Integer pageNum, Integer pageSize);
/**
* 清空浏览记录
*/
void clear();
}
业务层实现类MemberReadHistoryServiceImpl,注入MemberReadHistoryRepository
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
/**
* 会员浏览记录管理Service实现类
*/
@Service
public class MemberReadHistoryServiceImpl implements MemberReadHistoryService {
@Autowired
private MemberReadHistoryRepository memberReadHistoryRepository;
@Autowired
private UmsMemberService memberService;
@Override
public int create(MemberReadHistory memberReadHistory) {
UmsMember member = memberService.getCurrentMember();
memberReadHistory.setMemberId(member.getId());
memberReadHistory.setMemberNickname(member.getNickname());
memberReadHistory.setMemberIcon(member.getIcon());
memberReadHistory.setId(null);
memberReadHistory.setCreateTime(new Date());
memberReadHistoryRepository.save(memberReadHistory);
return 1;
}
@Override
public int delete(List<String> ids) {
List<MemberReadHistory> deleteList = new ArrayList<>();
for(String id:ids){
MemberReadHistory memberReadHistory = new MemberReadHistory();
memberReadHistory.setId(id);
deleteList.add(memberReadHistory);
}
memberReadHistoryRepository.deleteAll(deleteList);
return ids.size();
}
@Override
public Page<MemberReadHistory> list(Integer pageNum, Integer pageSize) {
UmsMember member = memberService.getCurrentMember();
Pageable pageable = PageRequest.of(pageNum-1, pageSize);
return memberReadHistoryRepository.findByMemberIdOrderByCreateTimeDesc(member.getId(),pageable);
}
@Override
public void clear() {
UmsMember member = memberService.getCurrentMember();
memberReadHistoryRepository.deleteAllByMemberId(member.getId());
}
}
整合 RabbitMQ 实现延迟消息
RabbitMQ
RabbitMQ 是一个被广泛使用的开源消息队列。它是轻量级且易于部署的,它能支持多种消息协议。RabbitMQ 可以部署在分布式和联合配置中,以满足高规模、高可用性的需求
这里实现的“延迟消息”本质上是通过 TTL 队列加死信交换机的组合来完成延迟投递,而不是 RabbitMQ 原生的 delayed message plugin。这个区别需要明确,因为两种方案在路由方式、可观察性和消息精度上并不完全相同。
RabbitMQ 的安装和使用:
安装Erlang:
下载地址:http://erlang.org/download/otp_win64_21.3.exe

安装RabbitMQ: 下载地址:https://dl.bintray.com/rabbitmq/all/rabbitmq-server/3.7.14/rabbitmq-server-3.7.14.exe

安装完成后,进入 RabbitMQ 安装目录下的 sbin 目录

在地址栏输入cmd并回车启动命令行,然后输入以下命令启动管理功能:
rabbitmq-plugins enable rabbitmq_management

访问地址查看是否安装成功:http://localhost:15672/

输入账号密码并登录:guest guest
创建帐号并设置其角色为管理员:mall mall

创建一个新的虚拟 host 为:/mall

点击 mall 用户进入用户配置页面

给 mall 用户配置该虚拟 host 的权限

在 pom.xml 中添加 RabbitMQ 相关依赖
1
2
3
4
5
6
7
8
9
10
11
12
<!--消息队列相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
修改 application.yml 文件,在spring节点下添加 RabbitMQ 相关配置
1
2
3
4
5
6
7
spring:
rabbitmq:
host: localhost
port: 5672
virtual-host: /mall
username: mall
password: mall
添加消息队列的枚举配置类 QueueEnum
用于延迟消息队列及处理取消订单消息队列的常量定义,包括交换机名称、队列名称、路由键名称
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
@Getter
public enum QueueEnum {
/**
* 消息通知队列
*/
QUEUE_ORDER_CANCEL("mall.order.direct", "mall.order.cancel", "mall.order.cancel"),
/**
* 消息通知ttl队列
*/
QUEUE_TTL_ORDER_CANCEL("mall.order.direct.ttl", "mall.order.cancel.ttl", "mall.order.cancel.ttl");
/**
* 交换名称
*/
private String exchange;
/**
* 队列名称
*/
private String name;
/**
* 路由键
*/
private String routeKey;
QueueEnum(String exchange, String name, String routeKey) {
this.exchange = exchange;
this.name = name;
this.routeKey = routeKey;
}
}
添加 RabbitMQ 的配置类
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
@Configuration
public class RabbitMqConfig {
/**
* 订单消息实际消费队列所绑定的交换机
*/
@Bean
DirectExchange orderDirect() {
return (DirectExchange) ExchangeBuilder
.directExchange(QueueEnum.QUEUE_ORDER_CANCEL.getExchange())
.durable(true)
.build();
}
/**
* 订单延迟队列队列所绑定的交换机
*/
@Bean
DirectExchange orderTtlDirect() {
return (DirectExchange) ExchangeBuilder
.directExchange(QueueEnum.QUEUE_TTL_ORDER_CANCEL.getExchange())
.durable(true)
.build();
}
/**
* 订单实际消费队列
*/
@Bean
public Queue orderQueue() {
return new Queue(QueueEnum.QUEUE_ORDER_CANCEL.getName());
}
/**
* 订单延迟队列(死信队列)
*/
@Bean
public Queue orderTtlQueue() {
return QueueBuilder
.durable(QueueEnum.QUEUE_TTL_ORDER_CANCEL.getName())
.withArgument("x-dead-letter-exchange", QueueEnum.QUEUE_ORDER_CANCEL.getExchange())//到期后转发的交换机
.withArgument("x-dead-letter-routing-key", QueueEnum.QUEUE_ORDER_CANCEL.getRouteKey())//到期后转发的路由键
.build();
}
/**
* 将订单队列绑定到交换机
*/
@Bean
Binding orderBinding(DirectExchange orderDirect, Queue orderQueue) {
return BindingBuilder
.bind(orderQueue)
.to(orderDirect)
.with(QueueEnum.QUEUE_ORDER_CANCEL.getRouteKey());
}
/**
* 将订单延迟队列绑定到交换机
*/
@Bean
Binding orderTtlBinding(DirectExchange orderTtlDirect, Queue orderTtlQueue) {
return BindingBuilder
.bind(orderTtlQueue)
.to(orderTtlDirect)
.with(QueueEnum.QUEUE_TTL_ORDER_CANCEL.getRouteKey());
}
}
在 RabbitMQ 管理页面可以看到以下交换机和队列

交换机及队列说明:
- mall.order.direct(取消订单消息队列所绑定的交换机): 绑定的队列为mall.order.cancel,一旦有消息以mall.order.cancel为路由键发过来,会发送到此队列
- mall.order.direct.ttl(订单延迟消息队列所绑定的交换机): 绑定的队列为mall.order.cancel.ttl,一旦有消息以mall.order.cancel.ttl为路由键发送过来,会转发到此队列,并在此队列保存一定时间,等到超时后会自动将消息发送到mall.order.cancel(取消订单消息消费队列)
添加延迟消息的发送者 CancelOrderSender
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 取消订单消息的发出者
*/
@Component
public class CancelOrderSender {
private static Logger LOGGER = LoggerFactory.getLogger(CancelOrderSender.class);
@Autowired
private AmqpTemplate amqpTemplate;
public void sendMessage(Long orderId, final long delayTimes) {
//给延迟队列发送消息
amqpTemplate.convertAndSend(QueueEnum.QUEUE_TTL_ORDER_CANCEL.getExchange(), QueueEnum.QUEUE_TTL_ORDER_CANCEL.getRouteKey(), orderId, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//给消息设置延迟毫秒值
message.getMessageProperties().setExpiration(String.valueOf(delayTimes));
return message;
}
});
LOGGER.info("send orderId:{}", orderId);
}
}
添加取消订单消息的接收者 CancelOrderReceiver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 取消订单消息的处理者
*/
@Component
@RabbitListener(queues = "mall.order.cancel")
public class CancelOrderReceiver {
private static Logger LOGGER = LoggerFactory.getLogger(CancelOrderReceiver.class);
@Autowired
private OmsPortalOrderService portalOrderService;
@RabbitHandler
public void handle(Long orderId) {
portalOrderService.cancelOrder(orderId);
LOGGER.info("process orderId:{}", orderId);
}
}
添加 OmsPortalOrderService 业务接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 前台订单管理Service
*/
public interface OmsPortalOrderService {
/**
* 根据提交信息生成订单
*/
@Transactional
CommonResult generateOrder(OrderParam orderParam);
/**
* 取消单个超时订单
*/
@Transactional
void cancelOrder(Long orderId);
}
添加 OmsPortalOrderService 的实现类 OmsPortalOrderServiceImpl
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
/**
* 前台订单管理Service
*/
@Service
public class OmsPortalOrderServiceImpl implements OmsPortalOrderService {
private static Logger LOGGER = LoggerFactory.getLogger(OmsPortalOrderServiceImpl.class);
@Autowired
private CancelOrderSender cancelOrderSender;
@Override
public CommonResult generateOrder(OrderParam orderParam) {
//todo 执行一系列下单操作,具体参考 mall 项目
LOGGER.info("process generateOrder");
//下单完成后开启一个延迟消息,用于当用户没有付款时取消订单(orderId应该在下单后生成)
sendDelayMessageCancelOrder(11L);
return CommonResult.success(null, "下单成功");
}
@Override
public void cancelOrder(Long orderId) {
//todo 执行一系列取消订单操作,具体参考 mall 项目
LOGGER.info("process cancelOrder orderId:{}",orderId);
}
private void sendDelayMessageCancelOrder(Long orderId) {
//获取订单超时时间,假设为60分钟
long delayTimes = 60 * 60 * 1000;
//发送延迟消息
cancelOrderSender.sendMessage(orderId, delayTimes);
}
}
整合 OSS 实现文件上传
mall 在整合 OSS 实现文件上传时,采用的是服务端签名后前端直传的方式。这样做的重点在于把安全控制保留在服务端,把大文件传输压力尽量留给 OSS。
OSS
阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。OSS 可用于图片、音视频、日志等海量文件的存储。各种终端设备、Web 网站程序、移动应用可以直接向OSS写入或读取数据
OSS 中的相关概念:
- Endpoint: 访问域名,通过该域名可以访问OSS服务的API,进行文件上传、下载等操作
- Bucket: 存储空间,是存储对象的容器,所有存储对象都必须隶属于某个存储空间
- Object: 对象,对象是 OSS 存储数据的基本单元,也被称为 OSS 的文件
- AccessKey: 访问密钥,指的是访问身份验证中用到的 AccessKeyId 和 AccessKeySecret
OSS 的相关设置
开通 OSS 服务
- 登录阿里云官网
- 将鼠标移至产品标签页,单击对象存储 OSS,打开OSS 产品详情页面
- 在OSS产品详情页,单击立即开通
创建存储空间
点击网页右上角控制台按钮进入控制台

选择我的云产品中的对象存储OSS

点击左侧存储空间的加号新建存储空间

新建存储空间并设置读写权限为公共读

跨域资源共享(CORS)的设置
由于浏览器的同源策略限制,浏览器端不能直接跨域访问任意资源,因此需要为 OSS 配置跨域资源共享(CORS)。
选择一个存储空间,打开其基础设置

点击跨域设置的设置按钮

进行跨域规则设置

服务端签名后前端直传的相关说明

流程介绍
- Web前端请求应用服务器,获取上传所需参数(如 OSS 的 accessKeyId、policy、callback 等参数)
- 应用服务器返回相关参数
- Web 前端直接向 OSS 服务发起上传文件请求
- 等上传完成后 OSS 服务会回调应用服务器的回调接口
- 应用服务器返回响应给 OSS 服务
- OSS 服务将应用服务器回调接口的内容返回给 Web 前端
整合 OSS 实现文件上传
在 pom.xml 中添加相关依赖
1
2
3
4
5
6
<!-- OSS SDK 相关依赖 -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>2.5.0</version>
</dependency>
修改 SpringBoot 配置文件
修改 application.yml 文件,添加 OSS 相关配置
endpoint、accessKeyId、accessKeySecret、bucketName、callback、prefix等参数都需要替换为实际 OSS 账号与存储空间配置,其中callback必须是 OSS 可以主动访问的公网地址。
1
2
3
4
5
6
7
8
9
10
11
12
aliyun:
oss:
endpoint: oss-cn-shenzhen.aliyuncs.com # oss对外服务的访问域名
accessKeyId: test # 访问身份验证中用到用户标识
accessKeySecret: test # 用户用于加密签名字符串和oss用来验证签名字符串的密钥
bucketName: macro-oss # oss的存储空间
policy:
expire: 300 # 签名有效期(S)
maxSize: 10 # 上传文件大小(M)
callback: http://localhost:8080/aliyun/oss/callback # 文件上传成功后的回调地址
dir:
prefix: mall/images/ # 上传文件夹路径前缀
添加 OSS 的相关 Java 配置:
用于配置 OSS 的连接客户端 OSSClient
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class OssConfig {
@Value("${aliyun.oss.endpoint}")
private String ALIYUN_OSS_ENDPOINT;
@Value("${aliyun.oss.accessKeyId}")
private String ALIYUN_OSS_ACCESSKEYID;
@Value("${aliyun.oss.accessKeySecret}")
private String ALIYUN_OSS_ACCESSKEYSECRET;
@Bean
public OSSClient ossClient(){
return new OSSClient(ALIYUN_OSS_ENDPOINT,ALIYUN_OSS_ACCESSKEYID,ALIYUN_OSS_ACCESSKEYSECRET);
}
}
添加 OSS 上传策略封装对象 OssPolicyResult
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取OSS上传文件授权返回结果
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class OssPolicyResult {
@ApiModelProperty("访问身份验证中用到用户标识")
private String accessKeyId;
@ApiModelProperty("用户表单上传的策略,经过base64编码过的字符串")
private String policy;
@ApiModelProperty("对policy签名后的字符串")
private String signature;
@ApiModelProperty("上传文件夹路径前缀")
private String dir;
@ApiModelProperty("oss对外服务的访问域名")
private String host;
@ApiModelProperty("上传成功后的回调设置")
private String callback;
}
添加 OSS 上传成功后的回调参数对象 OssCallbackParam
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* oss上传成功后的回调参数
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class OssCallbackParam {
@ApiModelProperty("请求的回调地址")
private String callbackUrl;
@ApiModelProperty("回调是传入request中的参数")
private String callbackBody;
@ApiModelProperty("回调时传入参数的格式,比如表单提交形式")
private String callbackBodyType;
}
OSS 上传成功后的回调结果对象 OssCallbackResult
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* oss上传文件的回调结果
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class OssCallbackResult {
@ApiModelProperty("文件名称")
private String filename;
@ApiModelProperty("文件大小")
private String size;
@ApiModelProperty("文件的mimeType")
private String mimeType;
@ApiModelProperty("图片文件的宽")
private String width;
@ApiModelProperty("图片文件的高")
private String height;
}
使用:
添加 OSS 业务接口 OssService
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* oss上传管理Service
*/
public interface OssService {
/**
* oss上传策略生成
*/
OssPolicyResult policy();
/**
* oss上传成功回调
*/
OssCallbackResult callback(HttpServletRequest request);
}
添加 OSS 业务接口 OssService 的实现类 OssServiceImpl
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
/**
* oss上传管理Service实现类
*/
@Service
public class OssServiceImpl implements OssService {
private static final Logger LOGGER = LoggerFactory.getLogger(OssServiceImpl.class);
@Value("${aliyun.oss.policy.expire}")
private int ALIYUN_OSS_EXPIRE;
@Value("${aliyun.oss.maxSize}")
private int ALIYUN_OSS_MAX_SIZE;
@Value("${aliyun.oss.callback}")
private String ALIYUN_OSS_CALLBACK;
@Value("${aliyun.oss.bucketName}")
private String ALIYUN_OSS_BUCKET_NAME;
@Value("${aliyun.oss.endpoint}")
private String ALIYUN_OSS_ENDPOINT;
@Value("${aliyun.oss.dir.prefix}")
private String ALIYUN_OSS_DIR_PREFIX;
@Autowired
private OSSClient ossClient;
/**
* 签名生成
*/
@Override
public OssPolicyResult policy() {
OssPolicyResult result = new OssPolicyResult();
// 存储目录
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
String dir = ALIYUN_OSS_DIR_PREFIX + sdf.format(new Date());
// 签名有效期
long expireEndTime = System.currentTimeMillis() + ALIYUN_OSS_EXPIRE * 1000;
Date expiration = new Date(expireEndTime);
// 文件大小
long maxSize = ALIYUN_OSS_MAX_SIZE * 1024 * 1024;
// 回调
OssCallbackParam callback = new OssCallbackParam();
callback.setCallbackUrl(ALIYUN_OSS_CALLBACK);
callback.setCallbackBody("filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");
callback.setCallbackBodyType("application/x-www-form-urlencoded");
// 提交节点
String action = "http://" + ALIYUN_OSS_BUCKET_NAME + "." + ALIYUN_OSS_ENDPOINT;
try {
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, maxSize);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String policy = BinaryUtil.toBase64String(binaryData);
String signature = ossClient.calculatePostSignature(postPolicy);
String callbackData = BinaryUtil.toBase64String(JSONUtil.parse(callback).toString().getBytes("utf-8"));
// 返回结果
result.setAccessKeyId(ossClient.getCredentialsProvider().getCredentials().getAccessKeyId());
result.setPolicy(policy);
result.setSignature(signature);
result.setDir(dir);
result.setCallback(callbackData);
result.setHost(action);
} catch (Exception e) {
LOGGER.error("签名生成失败", e);
}
return result;
}
@Override
public OssCallbackResult callback(HttpServletRequest request) {
OssCallbackResult result = new OssCallbackResult();
String filename = request.getParameter("filename");
filename = "http://".concat(ALIYUN_OSS_BUCKET_NAME).concat(".").concat(ALIYUN_OSS_ENDPOINT).concat("/").concat(filename);
result.setFilename(filename);
result.setSize(request.getParameter("size"));
result.setMimeType(request.getParameter("mimeType"));
result.setWidth(request.getParameter("width"));
result.setHeight(request.getParameter("height"));
return result;
}
}
添加 OssController 定义接口
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
/**
* Oss相关操作接口
*/
@Controller
@Api(tags = "OssController", description = "Oss管理")
@RequestMapping("/aliyun/oss")
public class OssController {
@Autowired
private OssServiceImpl ossService;
@ApiOperation(value = "oss上传签名生成")
@RequestMapping(value = "/policy", method = RequestMethod.GET)
@ResponseBody
public CommonResult<OssPolicyResult> policy() {
OssPolicyResult result = ossService.policy();
return CommonResult.success(result);
}
@ApiOperation(value = "oss上传成功回调")
@RequestMapping(value = "callback", method = RequestMethod.POST)
@ResponseBody
public CommonResult<OssCallbackResult> callback(HttpServletRequest request) {
OssCallbackResult ossCallbackResult = ossService.callback(request);
return CommonResult.success(ossCallbackResult);
}
}