1. Vue脚手架
Vue脚手架是一个开发基于Vue框架的前端项目的软件。
Vue脚手架的项目是“单页面”的,也就是在这样的项目,只有1个HTML页面,它认为页面是由多个模块组成的,各个模块都是可以随时替换的,从而显示出不同的页面效果。
2. 关于Node.js
首先,需要安装Node.js软件,下载得到安装后直接安装即可,安装过程中没有需要特别关注的部分。
安装完成后,可以通过npm -v
命令查看安装的npm
软件的版本,顺便检查是否安装成功。
npm -v
以上安装Node.js主要是为了使用npm
(Node Package Management),通常,应该修改“npm源”,以使得其下载软件将从配置的站点进行下载:
npm config set registry https://registry.npm.taobao.org
执行以上命令默认没有任何反馈,可以通过以下命令查看配置的值:
npm config get registry
3. 安装Vue CLI
Vue脚手架软件称之为:Vue CLI
通过npm命令可以安装此软件:
npm install -g @vue/cli
以上安装过程可能会提示WARN
字样,可以无视,只要以上命令可以正常执行结束,并没有任何ERR
字样的提示,即为成功!
安装Vue CLI主要是为了创建项目并管理项目(例如启动项目)。
4. 创建项目
首先,准备用于存放Vue脚手架项目的文件夹(任何你找得到的地方,不推荐操作系统的敏感文件夹),并且,在命令提示符窗口(或者终端窗口)中进入此文件夹。
然后,通过vue
命令(来自前一步安装的@vue/cli)来创建项目,命令的基本格式是vue create 项目名称
,例如:
vue create jsd2205-web-client-teacher
注意:执行创建项目的命令后,可能会有一点卡顿,此时不可以反复按回车键!
注意:如果创建项目的过程中选择错误,可以通过按下CTRL + C
终止,并重新创建!
在创建选项中,需要选择”
Manually select features
:手动选择功能Babel
/Vuex
/Router
2.x
Y
In package.json
N
接下来,会自动完成项目的创建,在创建结束后,如果信息中没有错误,且出现了Successfully created project 项目名称.
的字样,则创建成功!
如果创建项目失败,应该先删除已经创建的项目的文件夹,然后检查npm源,确认无误后,再次执行vue create 项目名称
命令来重新创建项目。
5. 启动项目
当项目创建成功后,可以通过IntelliJ IDEA打开此项目。
在IntelliJ IDEA中,打开Terminal
窗口,默认的提示符的位置应该就是当前项目的文件夹,在此处执行命令即可启动项目:
npm run serve
当启动成功后,可以看到Compiled successfully in 7859ms
字样。
提示:启动成功后,会提示访问此项目的URL,例如:http://localhost:8080
,根据当前计算机的网络配置不同,接下来的其它URL提示可能不同,均可无视。
在浏览器中,可以通过http://localhost:8080
来访问此项目。
6. 关于视图文件
在Vue CLI项目中,默认的视图文件(页面文件)在src/views
文件夹中。默认的项目中,负责显示的是:
src/App.vue
src/views/AboutView.vue
src/views/HomeView.vue
src/components/HelloWorld.vue
每个视图文件都是以.vue
作为扩展名的,每个文件都可以有3个根标签:
<template>
:用于设计页面,例如页面中有哪些标签、显示什么内容等,注意:此标签的直接子级标签必须有且仅有1个!<style>
:【可选】用于设计样式,此标签下的都是CSS代码<script>
:【可选】用于编写JavaScript代码
7. 关于路由
Vue CLI的“路由”是一种配置了“访问路径”与“视图组件”的对应关系的对象!
路由是通过src/router/index.js
文件的routes
常量进行配置的,默认的代码是:
const routes = [ { path: '/about', name: 'home', component: HomeView }, { path: '/', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../views/AdminIndexView.vue') } ]
以上routes
常量的类型是数组,数组的元素是一个个的对象,以上代码中各对象配置了:
path
:访问路径name
:名称,并不是必须的component
:视图组件,配置此属性时,视图组件有2种加载模式,分别是“预加载”和“懒加载”,其中,预加载需要通过当前文件顶部的import
语句导入视图,而懒加载则是直接配置此属性时,直接使用箭头函数加上import
函数进行配置即可,通常,在每个项目中,应该有且仅有1个视图是预加载的
【练习】在项目中添加“登录”页面(不需要具体的设计页面内容)
操作步骤:
创建新的视图文件
在
src/views
下创建LoginView.vue
,并随意设计页面内容
配置此视图的路由
在
src/router/index.js
中的routes
数组常量中添加新的数组元素,配置path
值为/login
,配置component
值为() => import('../views/LoginView.vue')
提示:完成此步骤后,就已经可以通过
http://localhost:8080/login
访问到新添加的登录页面
在
App.vue
中添加新的链接(从功能上来说,并不是必须的)参考原有代码进行添加即可
8. 关于<router-view>
在设计视图时,使用<router-view>
则表示:当前视图文件(例如默认的App.vue
)不处理此部分的显示,将根据访问路径来决定由哪个视图进行显示(取决于src/router/index.js
中routes
常量的配置)!
9. 关于Vue CLI项目的目录结构
[node_modules]
:当前项目的依赖项(各依赖的框架、工具包对应的文件,类似于Maven所需的本地仓库),注意:通常,从GIT等位置获取的项目,将没有此文件夹及相关文件,则项目不可以直接运行!需要先执行npm install
以安装当前项目必须的软件,才可以执行npm run serve
来启动项目![public]
:静态资源文件夹,通常用于存放图片、.css
、.js
等文件favicon.ico
:网站的图标文件,必须在此位置,必须是此文件名index.html
:是VUE Cli项目中唯一的HTML文件,通常,不建议修改
[src]
:源文件的文件夹,此文件夹下的内容将会被编译后再用于执行[assets]
:资源文件夹,只能存放不会因为程序而发生变化的资源文件(例如:某张图片会因为js代码而改变显示状态,则此图片不可以放在此文件夹中)[components]
:存放可能共用的视图组件的文件夹,此处的视图组件可以理解是被封装后的视图部分,可以被其它的视图组件引用[router]
:存放路由配置文件的文件夹index.js
:默认的路由配置文件
[store]
:存放共享变量的文件夹index.js
:默认的存放共享变量的文件夹
[views]
:存放视图组件(.vue
文件)的文件夹App.vue
:项目的入口视图,默认是绑定到了index.html
中的main.js
:项目的主配置文件
package.json
:当前项目的配置文件,在此文件中,典型的配置包含:项目中的依赖项,例如,当执行npm install
时,将根据此文件中的配置来决定下载哪些软件package-lock.json
:是锁定的配置文件,通常,不建议手动维护此文件
10. 关于启动、停止、重启项目
在项目文件夹下,通过npm run serve
即可启动项目:
npm run serve
如果项目已经启动,当需要停止时,在启动项目的终端窗口中按下CTRL + C
即可停止项目!
提示:当按下CTRL + C
后,将提示“终止批处理操作吗(Y/N)?”,其实,此处无论选择Y或N,效果都是一样的!在按下CTRL + C
的那一刻,项目就已经停止运行了!
此项目没有直接“重启”的操作,只能停止后再次运行!
11. 在Vue CLI中安装Element UI
首先,在终端窗口中,在当前项目的文件夹下,执行安装命令:
npm i element-ui -S
经过以上操作后,会下载element ui相关的文件到本项目的node_modules
文件夹中!
注意:如果你此前已经从GIT拉取了老师的项目,并执行过npm install
,再次拉取添加了element ui的项目后,需要再次执行npm install
,否则,老师的项目将缺少element ui相关的文件,将不可以正常启动!
接下来,需要在项目的主配置文件(src/main.js
)中添加配置:
import ElementUI from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; Vue.use(ElementUI);
课后作业
在项目的HomeView.vue
中继续设计,完成如下图所示的:
页面头部
页面左边栏的菜单
提示:各颜色没有要求。
前6次晚课安排
数据库与数据表设计规范
消息摘要算法与密码加密
Mybatis的#{}与${}占位符
Mybatis的缓存机制
@Autowired的自动装配机制
Spring MVC拦截器
1. Vue CLI中的嵌套路由
通常,在设计视图时,在App.vue
中不会设计页面元素,只是添加一个<router-view/>
即可!
在其它的视图中,设计的目标效果中可能存在多“页面”中相同的部分,反之,也可以理解为“某个页面的某个区域(不同的部分)是动态变化的”,则对应的区域(某个页面中会变化的部分)就可以设计为<router-view/>
,同时,由于当前视图本身也是显示在App.vue
设计的<router-view/>
中的,就出现了“嵌套路由”!
当项目中多个视图中都使用到了<router-view/>
时,某个视图组件到底显示在哪个<router-view/>
,取决于路由的配置:
如果某个视图的路由配置在
src/router/index.js
的routes
常量中,则此视图将显示在App.vue
的<router-view/>
中const routes = [ { path: '/', component: HomeView }, // 以下AboutView将显示在App.vue的<router-view/>中 { path: '/about', component: () => import('../views/AboutView.vue') } ];
如果某个视图的路由配置在
src/router/index.js
的routes
常量中的某个路由配置的子级,则此视图将显示在其父级路由的视图中const routes = [ { path: '/', component: HomeView, // 以下AboutView将显示在HomeView的<router-view/>中 children: [ { path: '/about', component: () => import('../views/AdminIndexView.vue') } ] } ];
2. Vue CLI项目的启动端口
Vue CLI项目在启动时,默认将尝试占用8080
端口,如果此端口已经被占用,则会顺延后一位端口号,即尝试占用8081
……当然,如果8081
也被占用,则会继续顺延至8082
,以此类推。
建议为Vue CLI项目显式的指定端口,避免发生冲突,或多次启动时的端口号不一致。需要在package.json
,原本有(通常在第6行):
"serve": "vue-cli-service serve",
在以上属性值的末尾添加--port 端口号
,例如:
"serve": "vue-cli-service serve --port 8888",
3. 在Vue CLI项目中使用axios
首先,需要安装axios:
npm i axios -S
然后,需要在main.js
中导入,并声明为Vue对象的成员:
import axios from 'axios'; Vue.prototype.axios = axios;
使用axios发请求并处理响应的代码示例:
let url = 'http://localhost:8080/login'; console.log('请求路径:' + url); console.log('请求参数:'); console.log(this.form); this.axios.post(url, this.form).then((response) => { console.log('服务器端的响应:'); console.log(response); let responseBody = response.data; if (responseBody == 1) { // 登录成功 this.$message({ message: '登录成功!(暂不跳转)', type: 'success' }); } else if (responseBody == 2) { // 用户名错误 this.$message.error('登录失败!用户名不存在!'); } else { // 密码错误 this.$message.error('登录失败!密码错误!'); } });
注意:在then()
内部,必须使用箭头函数(() => {}
),不可以使用一般的function函数!
4. 关于跨域访问
默认情况下,不允许执行跨域访问(从某一台服务器向另一台服务器发起异步请求),如果出现跨域访问,在浏览器的错误提示大致如下:
Access to XMLHttpRequest at 'http://localhost:8080/login' from origin 'http://localhost:9000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
提示:以上错误的关键字是:CORS
!
在基于Spring Boot开发的服务器端项目中,添加一个Spring MVC的配置类即可允许跨域访问!
可以让Spring Boot项目的启动类实现WebMvcConfigurer
接口,并在此类中添加以下代码:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedHeaders("*") .allowedMethods("*") .allowCredentials(true) .maxAge(3600); }
完成后,重启服务器端项目,然后,使用客户端再次发出请求,此请求可以正常发出(浏览器不会报告错误),且服务器端可以接收到请求参数。
1. 关于CSMall项目
CSMall项目:酷鲨商城,是一个定位为自营性质的电商平台项目。
CSMall Product项目:是整个项目的一部分,主要处理后台商品的数据管理。
商品相关的数据主要有:
品牌
类别
图片与相册
属性与属性模板
SPU与SKU
以上数据的关联数据
2. 创建项目
在IntelliJ IDEA中,使用Spring Boot项目的创建向导来创建项目,相关参数:
项目名称:
jsd2205-csmall-product-teacher
Group
:cn.tedu
Artifact
:csmall-product
Package
:cn.tedu.csmall.product
Java版本:
8
在创建过程中,可以无视Spring Boot版本,且可以不勾选任何依赖项。
当创建成功后,在pom.xml
中将版本指定为2.5.9
。
3. 数据库与数据表
在终端下,登录MySQL控制台,创建mall_pms
数据库:
CREATE DATABASE mall_pms;
接下来,在IntelliJ IDEA打开项目,并配置Database面板,连接到mall_pms
数据库,并通过mall_pms.sql
(老师下发的文件)中的代码来创建所需的数据表(将mall_pms.sql
中的所有代码全部复制到Database面板的Console中,全选并执行)。
至此,本项目所需的数据库和数据表创建完成!
关于配置Database面板的视频教程:IDEA-配置可视化数据库视图
作业
编写以下需求对应的SQL语句(使用记事本保存):
向
pms_brand
表中插入数据根据id删除
pms_brand
表中的某1条数据根据若干个id批量删除
pms_brand
表中的数据如果没有足够多的测试数据,可以事先添加
根据id修改
pms_brand
表中的name
字段的值统计
pms_brand
表中的数据的数量根据
name
查询pms_brand
表中的数据根据
id
查询pms_brand
表中的数据查询
pms_brand
表中所有的数据
前次作业
编写以下需求对应的SQL语句(使用记事本保存):
向
pms_brand
表中插入数据INSERT INTO pms_brand (name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
根据id删除
pms_brand
表中的某1条数据DELETE FROM pms_brand WHERE id=?;
根据若干个id批量删除
pms_brand
表中的数据DELETE FROM pms_brand WHERE id=? OR id=? …… OR id=?;
DELETE FROM pms_brand WHERE id IN (?, ?, .... ?);
根据id修改
pms_brand
表中的name
字段的值UPDATE pms_brand SET name=? WHERE id=?;
统计
pms_brand
表中的数据的数量SELECT count(*) FROM pms_brand;
阿里巴巴Java开发手册: 【强制】不要使用 count(列名)或 count(常量)来替代 count(*),count(*)是 SQL92 定义的 标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。 说明:count(*)会统计值为 NULL 的行,而 count(列名)不会统计此列为 NULL 值的行。
根据
name
查询pms_brand
表中的数据SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand WHERE name=?;
阿里巴巴Java开发手册: 【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。 说明:1)增加查询分析器解析成本。2)增减字段容易与 resultMap 配置不一致。
根据
id
查询pms_brand
表中的数据SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand WHERE id=?;
查询
pms_brand
表中所有的数据SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand ORDER BY id
2. 添加数据库编程的相关依赖
首先,在pom.xml
中添加数据库编程的必要依赖项:
<!-- Mybatis整合Spring Boot的依赖项 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <!-- MySQL的依赖项 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>
在Spring Boot项目中,一旦添加以上依赖项,项目将不可以正常启动!
Spring Boot项目默认是开启了自动配置的,当添加了以上数据库编程的依赖项时,就会自动从配置文件中读取连接数据库的URL参数值,如果当前尚未配置此参数,就会启动失败!
*************************** APPLICATION FAILED TO START *************************** Description: Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured. Reason: Failed to determine a suitable driver class
在src/main/resources
下的application.properties
是Spring Boot项目的默认主配置文件,当启动项目时,会自动从此文件中读取相关配置,为保证能够正常的使用数据库编程,需要在此文件中添加配置:
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=root
完成后,首先,在src/test/java
下默认的包中,找到默认的测试类,先执行默认的contextLoads()
测试方法,此测试应该能够通过,如果不能,则表示IDEA出错、依赖项出错。
当contextLoads()
通过后,在此测试类中补充:
@Autowired DataSource dataSource; @Test void testGetConnection() throws Exception { dataSource.getConnection(); }
注意:以上使用到的DataSource
是javax.sql
包中的!
执行以上新测试时,会发生与数据库(MySQL)的连接,可以借此检验在application.properties
中的配置是否正确!
如果配置的spring.datasource.url
中的主机名(localhost
)或端口号(3306
)错误,或者,MySQL / MariaDB的服务根本没有启动,则会出现以下错误:
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
如果在spring.datasource.url
中指定的数据库名称有误,或MySQL / MariaDB中确实无此数据库,则会出现以下错误:
java.sql.SQLSyntaxErrorException: Unknown database 'mall_qms'
如果在spring.datasource.url
中指定的serverTimezone
有误,则会出现以下错误:
java.sql.SQLNonTransientConnectionException: Could not create connection to database server. Caused by: java.time.zone.ZoneRulesException: Unknown time-zone ID: Asia/ShangHai
如果在spring.datasource.username
或spring.datasource.password
中配置的MySQL / MariaDB的用户名或密码错误,则会出现以下某种错误:
java.sql.SQLException: Access denied for user 'rootx'@'localhost' (using password: YES)
java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: YES)
java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)
3. 创建实体类
关于数据表中的字段的数据类型,与Java类中的属性的数据类型的对应关系:
数据表的字段的数据类型 | Java类中的属性的数据类型 |
---|---|
tinyint / smallint / int |
Integer |
bigint |
Long |
char / varchar / text 系列 |
String |
date_time |
LocalDateTime (Java 8) |
decimal |
BigDecimal (Java 8) |
关于POJO的设计规范:
具有无参数的构造方法;
属性均声明为
private
的;生成所有属性对应的规范的Setter和Getter;
生成规范的
hashCode()
与equals()
方法;如果2个对象的
hashCode()
值相同,则必须保证它们equals()
对比的结果为true
;如果2个对象的hashCode()
值不同,则必须保证它们equals()
对比的结果为false
;通常,可以使用专业的开发工具生成这2个方法,不必关心这个方法的方法体代码。
【建议,不强制要求】生成(重写)
toString()
方法;实现
Serializable
接口。
使用Lombok可以简化POJO类的编写,在使用之前,需要在项目中添加依赖:
<!-- Lombok的依赖项,主要用于简化实体类的编写 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> <scope>provided</scope> </dependency>
当添加以上依赖项后,在各POJO类上,只需要添加@Data
注解,即可使得Lombok框架在编译期生成各属性对应的Setter & Getter、hashCode()
与equals()
、toString()
方法。
注意:使用Lombok时,应该(强烈推荐)在开发工具中安装Lombok插件(在IntelliJ IDEA中,点击File
> Settings
,在Plugins
中搜索Lombok
并安装),如果未安装,在调用由Lombok生成的方法,或使用相关变量时会提示错误,但不影响运行!
在src/main/java
的根包下,创建pojo.entity
子包,并在此子包下创建Brand
类:
package cn.tedu.csmall.product.pojo.entity; import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; @Data public class Brand implements Serializable { private Long id; private String name; private String pinyin; private String logo; private String description; private String keywords; private Integer sort; private Integer sales; private Integer productCount; private Integer commentCount; private Integer positiveCommentCount; private Integer enable; private LocalDateTime gmtCreate; private LocalDateTime gmtModified; }
4. 关于Mybatis框架
Mybatis框架的主要作用是简化数据库编程。
5. Mybatis的用法
使用Mybatis主要需要:
编写处理数据的抽象方法
抽象方法必须声明在接口中,因为Mybatis框架的底层实现是基于接口的代理模式
接口通常以
Mapper
作为名称的最后一个单词
配置抽象方法对应的SQL语句
关于接口,必须使得Mybatis框架能够明确这些Mapper接口的位置,或者说,使得Mybatis知道有哪些Mapper接口,可以采取的做法有(二选一):
【不推荐】在每一个Mapper接口上添加
@Mapper
注解【推荐】在配置类上添加
@MapperScan
注解,并在此注解中配置参数,参数值就是Mapper接口所在的根包,并且,确保各Mapper接口在此包下配置类:在项目的根包下(包含根包下的子孙包下),添加了
@Configuration
注解的类,就是配置类
关于抽象方法的声明原则:
返回值类型:如果要执行的SQL是增、删、改类型的,推荐使用
int
作为返回值类型,表示“受影响的行数”,其实,也可以使用void
,并不推荐这样使用;如果要执行的SQL是查询类型的,只需要保证返回值类型足以封装所需的查询结果即可方法名称:自定义,但不要重载
阿里巴巴Java开发手册: 【参考】 获取单个对象的方法用 get 做前缀 获取多个对象的方法用 list 做前缀 获取统计值的方法用 count 做前缀 插入的方法用 save/insert 做前缀 删除的方法用 remove/delete 做前缀 修改的方法用 update 做前缀
参数列表:如果需要执行的SQL语句有多个参数,并且具有相关性,则应该将这些参数进行封装,并使用封装的类型作为抽象方法的参数
关于配置抽象方法对应的SQL语句,可以(二选一):
【不推荐】使用
@Insert
等注解配置SQL语句,并使用相关注解(例如@Result
等)完成相关配置【推荐】使用专门的XML文件配置SQL语句及相关配置
SQL语句更加直观,易于阅读
相关配置更加直观,易于复用
易于实现与DBA(Database Administrator)协同工作
关于配置SQL语句的XML文件:
根标签必须是
<mapper>
必须配置
<mapper>
标签的namespace
属性,此属性的值是对应的Mapper接口的全限定名在
<mapper>
标签的子级,使用<insert>
/<delete>
/<update>
/<select>
标签配置SQL语句关于
<insert>
等标签,都必须配置id
属性,取值为对应的抽象方法的名称(不包括抽象方法的签名的其它部分,例如,不需要括号等)关于
<select>
标签,必须配置resultType
或resultMap
这2个属性中的其中1个在
<insert>
等标签的内部,编写SQL语句,注意:在<insert>
标签的内容不要写任何注释,因为写在此处的注释都会被视为SQL语句的一部分
6. 使用Mybatis实现:插入品牌数据
在src/main/java
下的根包下,创建config
子包,并在此子包下创建MybatisConfiguration
类,在此类上通过注解配置Mapper接口的根包:
@Configuration @MapperScan("cn.tedu.csmall.product.mapper") public class MybatisConfiguration { }
在src/main/java
下的根包下,创建mapper
子包,并在此子包下创建BrandMapper
接口,在接口中添加抽象方法:
public interface BrandMapper { int insert(Brand brand); }
从 http://doc.canglaoshi.org/config/Mapper.xml.zip 下载压缩包,解压得到SomeMapper.xml
文件。
在src/main/resources
下,创建mapper
文件夹(文件夹名称是自定义的),并将SomeMapper.xml
复制到此文件夹中,此XML文件就是用于配置抽象方法对应的SQL语句的文件。
然后,在application.properties
中添加配置,以指定这些XML文件的位置:
# 配置SQL的XML文件的位置 mybatis.mapper-locations=classpath:mapper/*.xml
并且,在SomeMapper.xml
重命名为BrandMapper.xml
,在此文件中配置:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="cn.tedu.csmall.product.mapper.BrandMapper"> <insert id="insert"> INSERT INTO pms_brand ( name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable ) VALUES ( #{name}, #{pinyin}, #{logo}, #{description}, #{keywords}, #{sort}, #{sales}, #{productCount}, #{commentCount}, #{positiveCommentCount}, #{enable} ) </insert> </mapper>
至此,“插入品牌数据”的功能已经完成!
接下来,应该及时测试以上功能是否可以正常运行!
在src/test/java
下的根包下,创建mapper
子包,在此子包中创建BrandMapperTests
测试类。
注意:所有的测试类必须在根包下!
注意:测试类的类名,不可以与被测试的类名/接口名相同!
测试代码如下:
package cn.tedu.csmall.product.mapper; import cn.tedu.csmall.product.pojo.entity.Brand; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest public class BrandMapperTests { @Autowired BrandMapper mapper; @Test void testInsert() { Brand brand = new Brand(); brand.setName("测试品牌998"); int rows = mapper.insert(brand); System.out.printf("插入品牌完成,受影响的行数=" + rows); } }
如果此前没有在配置类中正确的使用@MapperScan
配置Mapper接口的根包,将出现以下错误:
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'cn.tedu.csmall.product.mapper.BrandMapper' available: expected at least 1 bean which qualifies as autowire candidate.
如果:
此前没有在
application.properties
中正确的配置XML文件的位置在XML中的
<mapper>
标签上配置的namespace
值有误在XML中的
<insert>
等标签上配置的id
值有误
将出现以下错误:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): cn.tedu.csmall.product.mapper.BrandMapper.insert
作业
写出所有数据表的实体类
创建新的接口、新的XML文件,实现:插入类别数据、插入相册数据,完成后,使用新的测试类进行测试
1. 插入数据时获取自动编号的id
如果某张表的id被设计为自动编号的,在插入数据时,还可以获取自动编号的id值!
在配置SQL的<insert>
标签上,配置useGeneratedKeys
和keyProperty
属性即可:
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> 此处省略插入数据的SQL语句 </insert>
以上配置中,useGeneratedKeys="true"
表示“需要获取自动编号的id值”,而keyProperty="id"
表示将id值保存到参数对象(调用插入数据的方法时使用的参数)的id
属性中。
2. 根据id删除数据
删除数据的开发步骤与插入数据的相同,只是配置SQL时使用的标签应该为<delete>
。
【需求】根据id删除某个品牌数据。
则在BrandMapper
接口中添加抽象方法:
int deleteById(Long id);
然后,在BrandMapper.xml
中配置SQL:
<delete id="deleteById"> DELETE FROM pms_brand WHERE id=#{id} </delete>
最后,在BrandMapperTests
中编写并执行测试:
@Test void testDeleteById() { Long id = 1L; int rows = mapper.deleteById(id); System.out.println("根据id删除品牌完成,受影响的行数=" + rows); }
3. 动态SQL:根据若干个id删除数据
动态SQL:允许根据参数不同,来生成不同的SQL语句。
【需求】根据若干个id删除品牌数据
需要执行的SQL语句大致是:
DELETE FROM pms_brand WHERE id=? OR id=? …… OR id=?;
DELETE FROM pms_brand WHERE id IN (?, ?, .... ?);
在以上SQL中,需要被删除的数据的id的数量是不确定的!
在实现此需求时,抽象方法可以设计为:
int deleteByIds(List<Long> ids);
int deleteByIds(Long[] ids);
int deleteByIds(Long... ids); // deleteByIds(1,2,3,4,5)
在配置SQL时,需要使用到<foreach>
标签对参数进行遍历:
<!-- int deleteByIds(List<Long> ids); --> <delete id="deleteByIds"> DELETE FROM pms_brand WHERE id IN ( <foreach collection="list" item="id" separator=","> #{id} </foreach> ) </delete>
关于<foreach>
标签的属性:
collection
:表示被遍历的参数对象,当抽象方法的参数只有1个时,如果参数类型是List
,则此属性值为list
,如果参数类型是数组(或可变参数),则此属性值为array
item
:遍历过程中的每个元素的名称,是自定义的名称,并且,在<foreach>
标签内部,使用#{}
时的名称也就是此属性的值(此处自定义的名称)separator
:遍历过程中在值之前添加的分隔符号
完成后,在BrandMapperTests
中编写并执行测试:
@Test void testDeleteByIds() { List<Long> ids = new ArrayList<>(); ids.add(2L); ids.add(6L); ids.add(7L); int rows = mapper.deleteByIds(ids); System.out.println("根据id批量删除品牌完成,受影响的行数=" + rows); }
4. 动态SQL:修改数据
【需求】根据id修改品牌的数据,参数中传入了哪些属性,就修改对应的那些字段的值
需要执行的SQL语句大致是:
update pms_brand set name=?, pinyin=?, logo=?, description=? ....(修改其它字段的值) where id=?
则抽象方法可以设计为:
int updateById(Brand brand);
然后,配置SQL语句:
<update id="updateById"> UPDATE pms_brand <set> <if test="name != null"> name=#{name}, </if> <if test="pinyin != null"> pinyin=#{pinyin}, </if> <if test="logo != null"> logo=#{logo}, </if> <if test="description != null"> description=#{description}, </if> <if test="keywords != null"> keywords=#{keywords}, </if> <if test="sort != null"> sort=#{sort}, </if> <if test="sales != null"> sales=#{sales}, </if> <if test="productCount != null"> product_count=#{productCount}, </if> <if test="commentCount != null"> comment_count=#{commentCount}, </if> <if test="positiveCommentCount != null"> positive_comment_count=#{positiveCommentCount}, </if> <if test="enable != null"> enable=#{enable}, </if> </set> WHERE id=#{id} </update>
以上代码中,使用到了2个标签:
<if>
:用于对参数的值进行判断,从而决定SQL语句中是否包含<if>
子级的SQL片段<set>
:用于取代SET
关键字,通常结合若干个<if>
一起使用,可以去除更新的SQL语句中的字段列表与值最后多余的逗号
注意:<if>
标签并没有匹配的类似else
的标签,如果需要实现类似Java代码中的if...else...
的效果,可以:
<if test="某条件"> 满足条件时的SQL片段 </if> <if test="与以上完全相反的条件"> 满足本if时的SQL片段 </if>
以上示例可以实现类似if...else...
的效果,但是,更像是if...
与另一个if...
,本质上是执行了2次判断的!
另外,还可以使用<choose>
系列标签,真正的实现类似if...else...
的效果:
<choose> <when test="判断条件"> 满足条件时的SQL片段 </when> <otherwise> 不满足条件时的SQL片段 </otherwise> </choose>
5. 统计查询
【需求】统计品牌表中的数据的数量
需要执行的SQL语句大致是:
SELECT count(*) FROM pms_brand;
在BrandMapper
接口中添加抽象方法:
int count();
在BrandMapper.xml
中配置SQL语句:
<select id="count" resultType="int"> SELECT count(*) FROM pms_brand </select>
6. 查询最多1条数据
【需求】根据id查询品牌详情
需要执行的SQL语句大致是:
SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable FROM pms_brand WHERE id=?
通常,在处理查询时,并不建议使用实体类型作为查询结果,因为绝大部分查询都不需要查询表中所有的字段,如果使用实体类型,必然导致查询结果对象调用某些Getter时得到的结果会是null
,并且,这些Getter的返回结果永远会是null
。
建议使用其它的POJO类型作为封装查询结果的类型!
常见的POJO:
DO
:Data ObjectDTO
:Data Transfer ObjectVO
:View Object / Value Object
关于POJO的使用:
阿里巴巴Java开发手册 【参考】 领域模型命名规约 1) 数据对象:xxxDO,xxx 即为数据表名。 2) 数据传输对象:xxxDTO,xxx 为业务领域相关的名称。 3) 展示对象:xxxVO,xxx 一般为网页名称。 4) POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。
另外:
阿里巴巴Java开发手册 【强制】 类名使用 UpperCamelCase 风格,必须遵从驼峰形式,但以下情形例外:DO / BO / DTO / VO / AO 正例:MarcoPolo / UserDO / XmlService / TcpUdpDeal / TaPromotion 反例:macroPolo / UserDo / XMLService / TCPUDPDeal / TAPromotion
此次查询时,应该在项目的根包下,创建pojo.vo.BrandStandardVO
类:
@Data public class BrandStandardVO implements Serializable { private Long id; private String name; private String pinyin; private String logo; private String description; private String keywords; private Integer sort; private Integer sales; private Integer productCount; private Integer commentCount; private Integer positiveCommentCount; private Integer enable; }
在BrandMapper
接口中添加抽象方法:
BrandStandardVO getStandardById(Long id);
Mybatis会自动的将查询到的结果集中的数据封装到定义的返回结果类型中,但是,在默认情况下,只能处理列名(Column)与属性名(Property)一致的情况!在规范的软件开发中,推荐使用<resultMap>
来配置列与属性的映射关系:
<resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandStandardVO"> <id column="id" property="id"/> <result column="name" property="name"/> <result column="product_count" property="productCount"/> <result column="comment_count" property="commentCount"/> <result column="positive_comment_count" property="positiveCommentCount"/> </resultMap>
关于以上<resultMap>
的配置:
id
属性:是自定义的名称,在<select>
标签中的resultMap
属性的值就是对应的<resultMap>
的id
值type
属性:用于封装查询结果的类的全限定名<id>
子标签:用于配置主键的列、属性的映射关系<result>
子标签:用于配置普通(不是主键,也不是一对多、多对多的关联查询)的列、属性的映射关系
另外,还建议使用<sql>
标签封装查询的字段列表,此标签需要与<include>
标签配合使用,例如:
<select id="getStandardById" resultMap="StandardResultMap"> SELECT <include refid="StandardQueryFields"/> FROM pms_brand WHERE id=#{id} </select> <sql id="StandardQueryFields"> <if test="true"> id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable </if> </sql>
7. 关于IntelliJ IDEA错误的预判
问题1:使用Lombok后,不会自动提示Setter和Getter,且使用相当方法后提示错误
在IntelliJ IDEA中安装Lombok插件即可。
问题2:使用Mybatis时,尝试自动装配Mapper接口的对象时提示错误
【推荐】在Mapper接口上添加
@Repository
注解即可【不推荐】使用
@Resource
替换@Autowired
另外,不使用@MapperScan
,而是在每个Mapper接口上使用@Mapper
也可以解决此问题!
问题3:在配置SQL的XML中,使用<sql>
节点封装字段列表时提示错误
【不推荐】在IntelliJ IDEA中进行配置,不检查SQL
【推荐】使用某种合法的、不影响当前代码运行的代码片段,“骗”过IntelliJ IDEA即可,例如:
<sql id="StandardQueryFields"> <if test="true"> id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable </if> </sql>
作业
实现:
统计类别的数量
统计相册的数量
根据id查询类别详情
根据id查询相册详情
1. 查询列表
相对查询最多1个数据,查询列表的区别在于:必须使用List
类型作为抽象方法的返回值类型,另外,在配置<select>
时,无论使用resultType
还是resultMap
,在指定封装返回结果的类型时,仍指定List
中的元素类型即可。
【需求】查询品牌列表,暂不考虑分页,结果按照sort
降序排列、id
升序(降序)。
需要执行的SQL语句大致是:
select id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable from pms_brand order by sort desc, id
通常,查询列表时,与查询单个数据使用的VO类应该是不同的。
则在项目的根包下创建pojo.vo.BrandListItemVO
类,在类中声明与以上字体列表匹配的属性:
package cn.tedu.csmall.product.pojo.vo; import lombok.Data; import java.io.Serializable; @Data public class BrandListItemVO implements Serializable { private Long id; private String name; private String pinyin; private String logo; private String description; private String keywords; private Integer sort; private Integer sales; private Integer productCount; private Integer commentCount; private Integer positiveCommentCount; private Integer enable; }
在BrandMapper.java
接口中添加抽象方法:
/** * 查询品牌列表 * * @return 品牌列表,如果没有匹配的品牌,将返回长度为0的列表 */ List<BrandListItemVO> list();
在BrandMapper.xml
中配置SQL:
<select id="list" resultMap="ListResultMap"> SELECT <include refid="ListQueryFields"/> FROM pms_brand ORDER BY sort DESC, id </select> <sql id="ListQueryFields"> <if test="true"> id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable </if> </sql> <resultMap id="ListResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandListItemVO"> <id column="id" property="id"/> <result column="name" property="name"/> <result column="product_count" property="productCount"/> <result column="comment_count" property="commentCount"/> <result column="positive_comment_count" property="positiveCommentCount"/> </resultMap>
完成后,在BrandMapperTests
中编写并执行测试:
@Test void testList() { List<BrandListItemVO> list = mapper.list(); System.out.println("查询品牌列表,查询结果中的数据的数量:" + list.size()); for (BrandListItemVO brand : list) { System.out.println(brand); } }
2. 关于SLF4j日志
在Spring Boot项目中,在spring-boot-starter
依赖项中,默认已经集成了SLF4j日志框架。
在添加了Lombok
依赖项后,可以在需要输出日志的类上添加@Slf4j
注解,则Lombok框架会在编译期自动添加名为log
的日志变量。
日志的显示级别,根据日志信息内容的重要程度,从低到高依次为:
trace
:跟踪信息debug
:调试信息info
:一般信息warn
:警告信息error
:错误信息
在SLF4j中,调用log
变量时,有以上5种级别对应的方法,调用与日志显示级别对应的方法,则输出的日志就是对应的级别,例如调用log.debug()
时,输出的日志就是DEBUG级别的日志,调用log.warn()
时,输出的日志就是WARN级别的日志。
在Spring Boot项目中,默认的显示级别是info
,相比info
较低级别的日志不会被输出!
在Spring Boot项目中,可以在application.properties
中通过logging.level.包名
属性来设置日志的显示级别:
# 日志的显示级别 logging.level.cn.tedu.csmall=trace
关于以上配置:
包名部分,必须至少写1级包名,例如
logging.level.cn
,如果没有包名,则是错误的配置的包名是”根包“,所以子孙包及其下的所有类的日志都会是以上配置的级别
如果认为有必要的话,甚至可以配置到具体的类
使用日志的好处:
可以通过简单的配置,实现控制日志的显示级别,所以,可以使得某些日志仅开发时能输出,在生产环境中不会输出
使用日志时,可以使用常量字符串,输出结果中的变量值均在此常量字符串中使用占位符表示即可,所以,字符串的处理效率非常高,并且,代码简洁
3. 关于Spring MVC
Spring MVC框架主要解决了接收请求、响应结果的相关问题。
在Spring Boot项目中,当需要使用Spring MVC框架及相关的功能时,应该添加spring-boot-starter-web
依赖项。由于spring-boot-starter-web
是基于spring-boot-starter
依赖项的,所以,spring-boot-starter-web
包含了spring-boot-starter
,在实际编码时,只需要将spring-boot-starter
改为spring-boot-starter-web
即可。
当添加了spring-boot-starter-web
依赖项后,当启动项目时,默认情况下,就会自动将当前项目编译、打包并部署到内置的Tomcat中,并启动Tomcat,默认占用8080
端口。
如果需要修改默认占用的端口号,可以在application.properties
中配置server.port
属性,例如:
# 服务端口 server.port=9080
4. 实现:增加品牌
在项目的根包下,创建pojo.dto.BrandAddNewDTO
类,此类用于封装”增加品牌“时客户端需要提交的请求参数:
@Data public class BrandAddNewDTO implements Serializable { private String name; private String pinyin; private String logo; private String description; private String keywords; private Integer sort; private Integer enable; }
在项目的根包下,创建controller.BrandController
类,在类上添加@RestController
:
@RestController public class BrandController { @Autowired BrandMapper brandMapper; // http://localhost:9080/add-new?name=格力&pinyin=geli&sort=67&description=格力的简介&enable=1&keywords=不知道写什么关键词&logo=还没有上传logo @RequestMapping("/add-new") public String addNew(BrandAddNewDTO brandAddNewDTO) { System.out.println("即将处理【添加品牌】的请求……"); System.out.println("brandAddNewDTO = " + brandAddNewDTO); // 检查品牌名称是否已经被占用 int count = brandMapper.countByName(brandAddNewDTO.getName()); if (count > 0) { return "增加品牌失败!品牌名称【" + brandAddNewDTO.getName() + "】已经被占用!"; } Brand brand = new Brand(); BeanUtils.copyProperties(brandAddNewDTO, brand); brand.setSales(0); brand.setProductCount(0); brand.setCommentCount(0); brand.setPositiveCommentCount(0); brandMapper.insert(brand); return "已经完成处理【添加品牌】的请求"; } // http://localhost:9080/delete @RequestMapping("/delete") public String delete() { System.out.println("即将处理【删除品牌】的请求……"); return "已经完成处理【删除品牌】的请求"; } }
关于控制器的基本使用:
仅当添加了
@Controller
注解后,此类才算是”控制器类“(才可以接收请求、响应结果)在方法上使用
@RequestMapping
可以配置某个路径,后续,客户端可以向此路径发起请求,则此方法将自动执行,所以,此方法可称之为”处理请求的方法“在默认情况下,处理请求的方法的返回值是
String
时,返回的结果表示某个视图的名称在方法上添加
@ResponseBody
注解,将表示此方法是”响应正文“的,方法的返回结果将直接响应到客户端@ResponseBody
注解还可以添加在控制器类上,将表示此控制器类中所有处理请求的方法都是响应正文的@RestController
将同时具有@Controller
和@ResponseBody
的效果,这一点,可以从@RestController
中看到
关于处理请求的方法:
访问权限:应该是
public
返回值类型:暂时使用
String
方法名称:自定义
参数列表:按需设计,可以直接将所需的请求参数声明为方法的参数,或者,将多个请求参数封装到自定义类型中,并使用自定义类型作为处理请求的方法的参数,各参数可以按照期望的数据类型进行设计,如果有多个参数,不区分先后顺序
关于接收请求参数:
如果客户端正确的按照名称提交了请求参数,则服务器端可以正常接收到,如果不是字符串类型,会尝试自动的转换数据类型,如果转换失败,将出现错误,且响应
400
http://localhost:9080/add-new?name=小米&pinyin=xiaomi
如果客户端提交了对应的请求参数名称,却没有提交值,则服务器端默认视为空字符串,如果请求参数是其它类型(例如
Integer
),框架会放弃转换类型,仍保持为null
http://localhost:9080/add-new?name=&pinyin=
如果客户端没有提交对应名称的请求参数,则服务器端接收到的为
null
http://localhost:9080/add-new
作业
作业内容:
创建与12张数据表对应的12个实体类;
完成【相册:
pms_album
】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、查询列表(不考虑分页问题,下同)完成【图片:
pms_picture
】的:插入、根据id删除、批量删除、根据id查询详情、根据相册(album_id
)查询列表完成【属性:
pms_attribute
】的:插入、根据id删除、批量删除、更新、统计数量、根据id查询详情、根据属性模板(template_id
)查询列表完成【属性模板:
pms_attribute_template
】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、查询列表完成【类别:
pms_category
】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、根据父级类别(parent_id
)查询列表完成【类别与品牌关联:
pms_brand_category
】:插入、根据id删除、批量删除、根据品牌(brand_id
)统计数量、根据类别(category_id
)统计数量、根据品牌(brand_id
)和类别(category_id
)统计数量、查询列表
作业要求:
所有的声明部分(类、接口、抽象方法、属性)应该添加注释
所有实现的功能必须添加相应测试,以确保功能可以正确运行
1. 消息摘要算法与密码加密
在Spring Boot项目中,提供了DigestUtils
工具类,此工具类的方法可以轻松实现“使用MD5算法”进行运算,从而,可以实现将原始密码进行加密,得到一个加密后的结果。
package cn.tedu.csmall.product; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.util.DigestUtils; @Slf4j public class DigestTests { @Test public void testEncode() { String rawPassword = "123456"; String encodedPassword = DigestUtils .md5DigestAsHex(rawPassword.getBytes()); log.debug("原始密码={},MD5运算结果={}", rawPassword, encodedPassword); } }
首先,对密码进行加密后,再存储,是非常有必要的!并且,主要的防范对象通常是内部员工!
需要注意,对密码进行加密处理时,不可以使用加密算法!因为,所有的加密算法都是可以加密,也可以解密的!加密算法的核心价值在于保证数据在传输过程中是安全的,并不保证数据存储的安全!
对需要存储的密码进行加密处理时,应该使用消息摘要算法,其本质是一种哈希算法,是不可逆向运算的!
1. 关于业务逻辑层
业务逻辑层,也称之为“业务层”(Service Layer),主要:设计业务流程,处理业务逻辑,以保证数据的完整性和安全性。
业务层应该由接口(原因后续再解释)和实现类这2种文件组成!
关于Service中的方法的定义:
返回值类型:仅以操作成功为前提来设计
方法名称:自定义
参数列表:通常是控制器调用时传入,典型的参数就是客户端提交的请求参数
异常:处理业务时可能的“失败”,通常,使用
RuntimeException
或其子孙类异常,所以,在声明业务方法时,并不需要显式的声明抛出异常
关于异常,如果使用现有的异常(例如NullPointerException
等),可能会产生歧义,所以,通常会自定义异常,继承自RuntimeException
。
如果在项目中只使用1种异常类型,不便于不区分同一个业务可能出现的多种“错误”,所以,应该在异常类型中添加某个属性,来区分多种“错误”!关于此属性,可以是int
、String
等各种你认为合适的类型,但是,这些类型的取值范围(值的可能性)非常大,为了限制取值,可以使用枚举类型,例如:
public enum ServiceCode { ERR_INSERT(1), ERR_UPDATE, ERR_DELETE; }
如果仅仅只是以上代码,当尝试输出某个枚举值,输出的结果就是以上名称,例如ERR_INSERT
,不便于获取此值时编写条件判断相关的代码,通常,使用数值进行判断会更加方便,所以,可以为以上每个枚举值指定相关的数值,同时,需要添加枚举的构造方法,例如:
public enum ServiceCode { ERR_INSERT(1), ERR_UPDATE(2), ERR_DELETE(3); ServiceCode(int value) {} }
为了保证各个值能够被使用,还需要添加全局属性,用于保存通过构造方法传入的值,并提供相应获取值的方法,使得后续得到得这些数值:
public enum ServiceCode { ERR_CONFLICT(1), ERR_INSERT(2), ERR_DELETE(3), ERR_UPDATE(4); private int value; ServiceCode(int value) { this.value = value; } public int getValue() { return value; } @Override public String toString() { return "" + value; } }
另外,各枚举值对应的数值编号应该是有一定规律的,而不是盲目的顺序编号,所以,应该自行设计一套编码规则,或者,如果没有比较成熟的编码规则,可大致参考已有的某套规则,例如参考HTTP响应码:
package cn.tedu.csmall.product.ex; /** * 业务状态码的枚举 */ public enum ServiceCode { OK(20000), ERR_NOT_FOUND(40400), ERR_CONFLICT(40900), ERR_INSERT(50000), ERR_DELETE(50100), ERR_UPDATE(50200); private int value; ServiceCode(int value) { this.value = value; } public int getValue() { return value; } @Override public String toString() { return "" + value; } }
提示:以上类型创建在项目的根包下的ex.ServiceCode
。
然后,在项目的根包下的ex
包下创建ServiceException
:
public class ServiceException extends RuntimeException { private ServiceCode serviceCode; public ServiceException(ServiceCode serviceCode, String message) { super(message); this.serviceCode = serviceCode; } public ServiceCode getServiceCode() { return serviceCode; } }
User login(String username, String password) throws 用户名错误异常, 密码错误异常, 账号禁用异常;
try { User user = service.login("root", "1234"); // 处理成功 } catch (用户名错误异常 e) { // 处理用户名错误异常 } catch (密码错误异常 e) { // 处理密码错误异常 } catch (账号禁用异常 e) { // 处理账号禁用异常 }
2. 处理“添加品牌”的业务
在根包下创建service
子包,并在此子包下创建IBrandService
接口:
public interface IBrandService { // 添加品牌 void addNew(BrandAddNewDTO brandAddNewDTO); }
然后,在service
包下再创建impl
子包,并在此子包下创建BrandServiceImpl
类,实现以上接口,并在类上添加@Service
注解:
@Service public class BrandServiceImpl implements IBrandService { }
1. 添加“类别”的业务
在项目的根包下创建pojo.dto.CategoryAddNewDTO
类:
@Data public class CategoryAddNewDTO implements Serializable { private String name; private Long parentId; private String keywords; private Integer sort; private String icon; private Integer enable; private Integer isDisplay; }
在项目的根包下创建service.ICategoryService
接口:
public interface ICategoryService { void addNew(CategoryAddNewDTO categoryAddNewDTO); }
在项目的根包下创建service.impl.CategoryServiceImpl
类,实现以上接口,并添加@Service
注解:
@Service public class CategoryServiceImpl implements ICategoryService { @Autowired private CategoryMapper categoryMapper; @Override public void addNew(CategoryAddNewDTO categoryAddNewDTO) { // 调用Mapper对象的【根据名称统计数量】方法进行统计 // 判断统计结果是否大于0 // 是:名称已经被占用,抛出异常(CONFLICT) // 声明局部变量depth,默认值为1 // 取出参数中的parentId // 判断parentId是否不为0 // 是:调用Mapper对象的【根据id查询详情】,使用parentId作为参数,执行查询 // -- 判断查询结果是否不为null // -- 是:局部变量depth=父级depth+1 // -- 否:父级类别不存在,抛出异常(NOT_FOUND) // 创建Category实体类的对象 // 将参数DTO的各属性值复制到Category实体类对象中 // 补全Category实体类对象的属性:depth // 补全Category实体类对象的属性:is_parent:固定为0 // 调用Mapper对象的方法,将数据插入到数据库,并获取返回值 // 判断返回值是否不为1 // 是:抛出异常(ERR_INSERT) // 判断parentId是否不为0 // 是:判断父级类别的isParent是否为0 // -- 是:创建新的Category对象,封装:parentId,isParent(1) // -- -- 调用Mapper对象的【更新】方法,执行修改数据,并获取返回值 // -- -- 判断返回值是否不为1 // -- -- -- 是:抛出异常(ERR_UPDATE) } }
具体实现为:
@Override public void addNew(CategoryAddNewDTO categoryAddNewDTO) { // 调用Mapper对象的【根据名称统计数量】方法进行统计 String name = categoryAddNewDTO.getName(); int count = categoryMapper.countByName(name); // 判断统计结果是否大于0 if (count > 0) { // 是:名称已经被占用,抛出异常(CONFLICT) String message = "添加类别失败,尝试添加的类别名称【" + name + "】已经存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_CONFLICT, message); } // 声明父级类别对象 CategoryStandardVO parentCategory = null; // 声明局部变量depth,默认值为1 Integer depth = 1; // 取出参数中的parentId Long parentId = categoryAddNewDTO.getParentId(); // 判断parentId是否不为0 if (parentId != 0) { // 是:调用Mapper对象的【根据id查询详情】,使用parentId作为参数,执行查询 parentCategory = categoryMapper.getStandardById(parentId); // 判断查询结果是否不为null if (parentCategory != null) { // -- 是:局部变量depth=父级depth+1 depth += parentCategory.getDepth(); } else { // -- 否:父级类别不存在,抛出异常(NOT_FOUND) String message = "添加类别失败,选定的父级类别不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } } // 创建Category实体类的对象 Category category = new Category(); // 将参数DTO的各属性值复制到Category实体类对象中 BeanUtils.copyProperties(categoryAddNewDTO, category); // 补全Category实体类对象的属性:depth category.setDepth(depth); // 补全Category实体类对象的属性:isParent:固定为0 category.setIsParent(0); // 调用Mapper对象的方法,将数据插入到数据库,并获取返回值 int rows = categoryMapper.insert(category); // 判断返回值是否不为1 if (rows != 1) { // 是:抛出异常(ERR_INSERT) String message = "添加类别失败,服务器忙,请稍后再尝试!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_INSERT, message); } // 判断parentId是否不为0 if (parentId != 0) { // 是:判断父级类别的isParent是否为0 if (parentCategory.getIsParent() == 0) { // 是:创建新的Category对象,封装:parentId,isParent(1) Category updateParentCategory = new Category(); updateParentCategory.setId(parentId); updateParentCategory.setIsParent(1); // 调用Mapper对象的【更新】方法,执行修改数据,并获取返回值 int updateRows = categoryMapper.updateById(updateParentCategory); // 判断返回值是否不为1 if (updateRows != 1) { // 是:抛出异常(ERR_UPDATE) String message = "添加类别失败,服务器忙,请稍后再尝试!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_UPDATE, message); } } } }
完成后,测试:
package cn.tedu.csmall.product.service; import cn.tedu.csmall.product.ex.ServiceException; import cn.tedu.csmall.product.pojo.dto.CategoryAddNewDTO; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest public class CategoryServiceTests { @Autowired ICategoryService service; @Test void testAddNew() { CategoryAddNewDTO categoryAddNewDTO = new CategoryAddNewDTO(); categoryAddNewDTO.setName("休闲衬衣"); categoryAddNewDTO.setParentId(23L); try { service.addNew(categoryAddNewDTO); log.debug("添加成功!"); } catch (ServiceException e) { log.debug("serviceCode : " + e.getServiceCode()); log.debug("message : " + e.getMessage()); } } }
2. 删除“类别”的业务
在CategoryMapper.java
接口中添加新的抽象方法:
int countByParentId(Long parentId);
在CategoryMapper.xml
中配置SQL:
<select id="countByParentId" resultType="int"> SELECT count(*) FROM pms_category WHERE parent_id=#{parentId} </select>
在CategoryMapperTests
中编写并执行测试:
@Test void testCountByParentId() { Long parentId = 1L; int count = mapper.countByParentId(parentId); log.debug("根据父级id【{}】统计数量完成,数量:", parentId, count); }
在ICategoryService
接口中添加抽象方法:
void deleteById(Long id);
在CategoryServiceImpl
类中实现以上抽象方法:
@Override public void deleteById(Long id) { // 调用Mapper对象的【根据id查询详情】查询数据,是当前尝试删除的数据 // 判断查询结果是否为null // 是:数据不存在,抛出异常(ERR_NOT_FOUND) // 检查当前尝试删除的类别是否存在子级类别:判断以上查询结果的isParent是否为1 // 是:当前尝试删除的类别“是父级类别”(包含子级),抛出异常(ERR_CONFLICT) // 调用Mapper对象的【根据id删除】执行删除,并获取返回值 // 判断返回值是否不为1 // 是:抛出异常(ERR_DELETE) // ====== 如果这是父级类别中的最后一个子级,则将父级的isParent改为0 ===== // 从当前尝试删除的类别对象中取出parentId // 调用Mapper对象的countByParentId(parentId)进行统计 // 判断统计结果是否为0 // 创建新的Category对象,用于更新父级,此Category对象中需要封装:id(parentId),isParent(0) // 调用Mapper对象的【更新】功能,执行修改数据,并获取返回值 // 判断返回值是否不为1 // 是:抛出异常(ERR_UPDATE) }
具体实现为:
@Override public void deleteById(Long id) { // 调用Mapper对象的【根据id查询详情】查询数据,是当前尝试删除的数据 CategoryStandardVO currentCategory = categoryMapper.getStandardById(id); // 判断查询结果是否为null if (currentCategory == null) { // 是:数据不存在,抛出异常(ERR_NOT_FOUND) String message = "删除类别失败,尝试删除的类别不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } // 检查当前尝试删除的类别是否存在子级类别:判断以上查询结果的isParent是否为1 if (currentCategory.getIsParent() == 1) { // 是:当前尝试删除的类别“是父级类别”(包含子级),抛出异常(ERR_CONFLICT) String message = "删除类别失败,尝试删除的类别仍包含子级类别!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_CONFLICT, message); } // 调用Mapper对象的【根据id删除】执行删除,并获取返回值 int rows = categoryMapper.deleteById(id); // 判断返回值是否不为1 if (rows != 1) { // 是:抛出异常(ERR_DELETE) String message = "删除类别失败,服务器忙,请稍后再尝试!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_DELETE, message); } // ====== 如果这是父级类别中的最后一个子级,则将父级的isParent改为0 ===== // 从当前尝试删除的类别对象中取出parentId Long parentId = currentCategory.getParentId(); // 判断当前类别是否不为1级类别,即parentId不为0 if (parentId != 0) { // 调用Mapper对象的countByParentId(parentId)进行统计 int count = categoryMapper.countByParentId(parentId); // 判断统计结果是否为0 if (count == 0) { // 创建新的Category对象,用于更新父级,此Category对象中需要封装:id(parentId),isParent(0) Category parentCategory = new Category(); parentCategory.setId(parentId); parentCategory.setIsParent(0); // 调用Mapper对象的【更新】功能,执行修改数据,并获取返回值 rows = categoryMapper.updateById(parentCategory); // 判断返回值是否不为1 if (rows != 1) { // 是:抛出异常(ERR_UPDATE) String message = "删除类别失败,服务器忙,请稍后再尝试!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_UPDATE, message); } } } }
完成后,测试:
@Test void testDeleteById() { Long id = 18L; try { service.deleteById(id); log.debug("删除成功!"); } catch (ServiceException e) { log.debug("serviceCode : " + e.getServiceCode()); log.debug("message : " + e.getMessage()); } }
3. 事务(Transaction)
事务是数据库中的可以保证多个(至少2个)写操作(增、删、改)要么全部执行成功,要么全部执行失败的机制!
在基于Spring JDBC的项目中,使用@Transactional
注解,即可使得注解的方法是事务性的。
关于@Transactional
注解,可以添加在:
接口上
等效于在每个抽象方法上添加了此注解
接口的抽象方法上
仅作用于当前方法(重写的方法运行时)
实现类上
等效于在每个重写的接口的方法上添加了此注解
实现类中重写的接口的方法上
仅作用于当前方法
提示:此注解可以配置一些参数,如果同时在接口/类、接口的抽象方法/类重写的方法上添加此注解并配置了不同的参数值,则以方法上的配置为准。
注意:Spring JDBC是基于接口的代理模式来实现事务管理的!所以,如果在实现类中的自定义方法上添加@Transactional
注解是错误的做法!仅当对应的方法是在业务接口中已经声明的,使用@Transactional
才是正确的!
关于事务处理过程中的几个概念:
开启事务:BEGIN
提交事务:COMMIT
回滚事务:ROLLBACK
在Spring JDBC的事务管理中,大致是:
开启事务 try { 执行你的业务方法 提交事务 } catch (RuntimeException e) { 回滚事务 }
可以看到,Spring JDBC的事务管理中,默认将根据RuntimeException
进行回滚,可以通过@Transactional
注解的rollbackFor
/ rollbackForClassName
这2个属性中的某1个进行修改,设置为对特定的异常进行回滚,还可以配置noRollbackFor
/ noRollbackForClassName
这2个属性,设置对特定的异常不回滚。
【小结】关于事务:
如果某个业务方法涉及超过1次的增、删、改操作,需要保证此业务方法是事务性的;
推荐在业务的抽象方法上添加
@Transactional
注解即可保证此业务方法是事务性对于初学者,更推荐在业务接口上添加
@Transactional
,则此接口中所有抽象方法都是事务性,可能其中的某些抽象方法并不需要是事务性的,但是,这种做法比较稳妥,能避免遗漏导致的错误
为了保证事务能够按照预期进行回滚,需要:
业务层必须由接口和实现类组成
Spring JDBC是基于接口代理模式实现事务管理的,如果没有接口,则无法实现
所有增、删、改操作完成后,应该及时获取返回结果,并对结果进行判断,如果结果不符合预期,应该抛出异常,且异常应该是
RuntimeException
或其子孙类异常Spring JDBC在管理事务时,默认按照
RuntimeException
进行回滚
4. 关于@RequestMapping
注解
在Spring MVC框架中,@RequestMapping
注解的主要作用是:绑定“请求路径”与“处理请求的方法”的映射关系。
关于此注解的声明的源代码:
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Mapping public @interface RequestMapping { }
以上源代码中的@Target({ElementType.TYPE, ElementType.METHOD})
就表示了此注解可以添加在哪里,其中,ElementType.TYPE
表示可以添加在“类型”上,ElementType.METHOD
表示可以添加在方法上。
在控制器类上,添加@RequestMapping
配置的路径值,将作为当前控制器类中每个处理请求的路径的前缀部分!例如在类上配置为@RequestMapping("/brands")
,在方法上配置为@RequestMapping("/delete")
,则完整路径为/brands/delete
,并且,此控制器类中所有处理请求的方法的路径上都有/brands
前缀!
所以,强烈推荐在每个控制器类上都使用@RequestMapping
配置路径的前缀部分。
关于注解内部的源代码,以@RequestMapping
为例,其内部有:
String name() default "";
以上源代码中,name()
表示此注解有名为name
的属性,左侧的String
表示此属性的数据类型,右侧的default ""
表示此属性的默认值。例如:可以在@RequestMapping
中配置名为name
的属性:
@RequestMapping(name = "test")
在@RequestMapping
中,还有:
@AliasFor("path") String[] value() default {};
则以上源代码表示:此注解中有名为value
的属性,值是String[]
类型的,则默认值是空数组。
在Java语言中,注解的value
属性是默认的属性,如果注解只需要配置这1个属性的值,则可以不必显式的声明属性名称,例如:
@RequestMapping(value = {"value1", "value2", "value3"})
@RequestMapping({"value1", "value2", "value3"})
以上2种配置是完全等效的!
在Java语言中,如果某个属性的值是某种数组类型,但是,配置的数组值只有1个元素,则不必使用大括号将其框住,例如:
@RequestMapping(value = {"value1"})
@RequestMapping(value = "value1")
以上2种配置是完全等效的!
关于源代码中value
属性的声明,还添加了@AliasFor("path")
,表示此value
属性等效于当前注解中的path
属性!
在@RequestMapping
注解的源代码中,还有:
RequestMethod[] method() default {};
以上注解的作用是“限制请求方式”,在没有配置此属性的情况,任何请求方式都是允许的!例如:
@RequestMapping(value = "/add-new", method = RequestMethod.POST)
如果使用以上配置,则/add-new
路径只允许通过POST
方式提交请求,如果使用其它请求方式,将出现405
错误!
强制推荐将所有请求都限制请求方式!
在Spring MVC框架中,还定义了相关注解,以简化限制请求方式的配置:
@GetMapping
@PostMapping
PutMapping
DeleteMapping
PatchMapping
以上这些都相当于是限制了请求方式的@RequestMapping
!
小结:
推荐在每个控制器类上使用
@RequestMapping
配置请求路径前缀推荐在每个处理请求的方法上使用
@GetMapping
/@PostMapping
配置请求路径
作业
完成以下页面设计:
1. 添加类别
菜单位置:临时页面
> 添加类别
文件名:/src/views/sys-admin/temp/CategoryAddNewView.vue
访问路径:/sys-admin/temp/category/add-new
2. 添加品牌
菜单位置:临时页面
> 添加品牌
文件名:/src/views/sys-admin/temp/BrandAddNewView.vue
访问路径:/sys-admin/temp/brand/add-new
1. Knife4j框架
Knife4j是一款基于Swagger 2的在线API文档框架。
在Spring Boot中,使用此框架时,需要:
添加依赖
在配置文件(
application.properties
)中开启增强模式编写配置类(代码相对固定,建议CV)
关于依赖的代码:
<!-- Knife4j Spring Boot:在线API --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-spring-boot-starter</artifactId> <version>2.0.9</version> </dependency>
关于开启增强模式,在application.properties
中添加:
# 开启Knife4j的增强模式 knife4j.enable=true
关于配置类,在项目的根包下创建config.Knife4jConfiguration
,代码如下:
注意:请检查basePackage
属性的值!
package cn.tedu.csmall.product.config; import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.Contact; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc; /** * Knife4j配置类 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Configuration @EnableSwagger2WebMvc public class Knife4jConfiguration { /** * 【重要】指定Controller包路径 */ private String basePackage = "cn.tedu.csmall.product.controller"; /** * 分组名称 */ private String groupName = "product"; /** * 主机名 */ private String host = "http://java.tedu.cn"; /** * 标题 */ private String title = "酷鲨商城在线API文档--商品管理"; /** * 简介 */ private String description = "酷鲨商城在线API文档--商品管理"; /** * 服务条款URL */ private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0"; /** * 联系人 */ private String contactName = "Java教学研发部"; /** * 联系网址 */ private String contactUrl = "http://java.tedu.cn"; /** * 联系邮箱 */ private String contactEmail = "java@tedu.cn"; /** * 版本号 */ private String version = "1.0.0"; @Autowired private OpenApiExtensionResolver openApiExtensionResolver; public Knife4jConfiguration() { log.debug("加载配置类:Knife4jConfiguration"); } @Bean public Docket docket() { String groupName = "1.0.0"; Docket docket = new Docket(DocumentationType.SWAGGER_2) .host(host) .apiInfo(apiInfo()) .groupName(groupName) .select() .apis(RequestHandlerSelectors.basePackage(basePackage)) .paths(PathSelectors.any()) .build() .extensions(openApiExtensionResolver.buildExtensions(groupName)); return docket; } private ApiInfo apiInfo() { return new ApiInfoBuilder() .title(title) .description(description) .termsOfServiceUrl(termsOfServiceUrl) .contact(new Contact(contactName, contactUrl, contactEmail)) .version(version) .build(); } }
注意:以上代码适用于Spring Boot 2.6以下(不含2.6)版本!
完成后,重启项目,打开浏览器,通过 http://localhost:9080/doc.html 即可访问Knife4j的API文档。
关于Knife4j框架,还提供了一系列的注解,便于实现API文档的显示,包括:
@Api
:添加在控制器类上,配置其tags
属性,用于指定模块名称,在指定的模块名称,可以使用数字编号作为名称的前缀,则多个管理模块将按照编号顺序来显示,例如:@RestController @RequestMapping("/albums") @Api(tags = "03. 相册管理模块") public class AlbumController { @GetMapping("/test") public void test() {} }
@ApiOperation
:添加在控制器类中处理请求的方法上,配置其value
属性,用于指定业务接口名称,例如:@ApiOperation("删除品牌") @PostMapping("/delete") public String delete(Long id) { }
@ApiOperationSupport
:添加在控制器类中处理请求的方法上,配置其order
属性,用于指定业务接口的排序编号,最终,同一个模块中的多个业务接口将按此编号升序排列,例如:@ApiOperation("删除品牌") @ApiOperationSupport(order = 200) @PostMapping("/delete") public String delete(Long id) { }
@ApiModelProperty
:添加在POJO类的属性上,配置其value
属性,用于指定请求参数的名称(说明),配置其required
属性,用于指定“是否必须提交此请求参数”(仅用于显示,不具备检查功能),配置其example
属性,用于指定“示例例”,例如:@Data public class BrandAddNewDTO implements Serializable { /** * 是否启用,1=启用,0=未启用 */ @ApiModelProperty(value = "是否启用,1=启用,0=未启用", example = "1", required = true) private Integer enable; }
@ApiImplicitParam
:添加在控制器类中处理请求的方法上,配置其name
属性,指定方法的参数的变量名,配置其value
属性,指定此参数的说明,配置其required
属性,指定此参数“是否必须提交”,配置其dataType
属性,指定此参数的数据类型,例如:@ApiOperation("删除品牌") @ApiOperationSupport(order = 200) @ApiImplicitParam(name = "id", value = "品牌id", required = true, dataType = "long") @PostMapping("/delete") public String delete(Long id) { }
@ApiImplicitParams
:添加在控制器类中处理请求的方法上,当有多个参数需要配置时,使用此注解,且此注解的值是@ApiImplicitParam
的数组,例如:@ApiOperation("删除品牌") @ApiOperationSupport(order = 200) @ApiImplicitParams({ @ApiImplicitParam(name = "id", value = "品牌id", required = true, dataType = "long") }) @PostMapping("/delete") public String delete(Long id) { }
@ApiIgnore
:添加在处理请求的方法的参数上,当某个参数不需要显示在API文档中,则需要在参数上添加此注解,例如HttpServletRequest
、HttpSession
等,例如:@ApiOperation("删除品牌") @ApiOperationSupport(order = 200) @ApiImplicitParam(name = "id", value = "品牌id", required = true, dataType = "long") @PostMapping("/delete") public String delete(Long id, @ApiIgnore HttpSession session) { }
2. Spring MVC与RESTful
在Spring MVC框架中,接收请求参数的做法有:
将各请求参数声明为处理请求的方法的参数
将各请求参数封装到自定义的POJO类型中,并使用POJO类型作为处理请求的方法的参数
【见下文】在配置请求路径时使用占位符,并通过
@PathVariable
注解来接收请求参数的值
RESTful是一种设计软件的风格,其典型特征包括:将具有“唯一性”的请求参数值作为URL的一部分,例如:
https://blog.csdn.net/a6244353135_/article/details/1242675432835
Spring MVC框架很好的支持了RESTful,在使用@RequestMapping
系列注解配置请求路径时,可以使用{名称}
作为占位符来接收请求,例如配置为:
@PostMapping("/{id}/delete")
则以上路径中{id}
可以是任何值,均能匹配到以上路径!
在处理请求的方法上,仍使用Long id
来接收URL中的占位符对应的值,并且,此参数需要添加@PathVariable
注解,例如:
// http://localhost:9080/brands/3/delete @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { }
在使用@PathVariable
注解时,如果请求参数的名称与占位符不一致时,可以通过注解参数进行配置,例如:
// http://localhost:9080/brands/3/delete @PostMapping("/{id}/delete") public String delete(@PathVariable("id") Long brandId) { }
另外,在{}
占位符中,可以在自定义名称的右侧添加冒号(:
),并在冒号的右侧添加正则表达式,以实现按需匹配,例如:
// http://localhost:9080/brands/3/delete @PostMapping("/{id:[0-9]+}/delete") public String delete(@PathVariable Long id) { }
在同一个项目中,多个使用了占位符、且正则表达式不冲突的URL,是允许共存的!例如:
// http://localhost:9080/brands/3/delete @PostMapping("/{id:[0-9]+}/delete") public String delete(@PathVariable Long id) { } // http://localhost:9080/brands/hello/delete @PostMapping("/{id:[a-zA-Z]+}/delete") public String delete(@PathVariable String id) { }
另外,使用了占位符的URL,与不使用占位符的URL,也是允许共存的,例如:
// http://localhost:9080/brands/3/delete @PostMapping("/{id:[0-9]+}/delete") public String delete(@PathVariable Long id) { } // http://localhost:9080/brands/hello/delete @PostMapping("/{name:[a-zA-Z]+}/delete") public String delete(@PathVariable String name) { } // http://localhost:9080/brands/test/delete @PostMapping("/test/delete") public String delete() { }
在RESTful的设计风格中,如果没有更好的选择,在设计URL时,可以:
/数据类型的复数
:表示获取某类型的数据的列表例如:
/brands
表示获取品牌列表
/数据类型的复数/{id}
:表示获取某类型的id=?的数据例如:
/brands/{id}
,实际请求路径可能是/brands/1
,则表示获取id=1的品牌数据
/数据类型的复数/{id}/命令
:表示针对某类型的id=?的数据进行某操作例如:
/brands/{id}/delete
,实际请求路径可能是/brands/1/delete
,则表示删除id=1的品牌数据
/数据类型的复数/{id}/属性/命令
:表示针对某类型的id=?的数据的某属性进行某操作其它
另外,RESTful思想建议针对不同的需求,使用不同的请求方式,如下:
GET
:获取数据POST
:增加数据PUT
:修改数据DELETE
:删除数据
3. 关于响应结果
控制器处理完请求后,向客户端进行响应时,推荐使用JSON格式的响应数据,并且,此JSON格式的数据中至少应该包括:
业务状态码
提示信息
在Spring MVC框架中,当需要向客户端响应JSON格式的结果时,需要:
当前处理请求的方法必须是“响应正文”的
在处理请求的方法或控制器类上使用
@ResponseBody
,或控制器类上使用了@RestController
,就是“响应正文”的
在项目中添加
jackson-databind
的依赖包含在
spring-boot-starter-web
依赖项中
开启注解驱动
使用注解模式的Spring MVC项目(包括Spring Boot)均默认开启
使用自定义的类型作为处理请求的方法的返回值类型,并且,此类中应该包含响应的JSON中的各属性
则在项目的根包下创建web.JsonResult
类:
package cn.tedu.csmall.product.web; import cn.tedu.csmall.product.ex.ServiceCode; import cn.tedu.csmall.product.ex.ServiceException; import lombok.Data; import java.io.Serializable; @Data public class JsonResult<T> implements Serializable { /** * 业务状态码 */ private Integer state; /** * 错误时的提示消息 */ private String message; /** * 成功时响应的数据 */ private T data; public JsonResult() { } private JsonResult(Integer state, String message, T data) { this.state = state; this.message = message; this.data = data; } public static JsonResult<Void> ok() { return ok(null); } public static <T> JsonResult<T> ok(T data) { return new JsonResult(ServiceCode.OK.getValue(), null, data); } public static JsonResult<Void> fail(ServiceException e) { return fail(e.getServiceCode().getValue(), e.getMessage()); } public static JsonResult<Void> fail(Integer state, String message) { return new JsonResult(state, message, null); } }
然后,在处理请求的方法中,使用JsonResult
作为返回值类型,并返回此类型的结果:
@PostMapping("/{id:[0-9]+}/enable") public JsonResult<Void> setEnable(@PathVariable Long id) { log.debug("即将处理【启用品牌】的请求,id={}", id); try { brandService.setEnable(id); return JsonResult.ok(); } catch (ServiceException e) { return JsonResult.fail(e); } }
4. 关于处理异常
在Java语言中,异常的体系结构大致是:
Throwable -- Error -- -- OutOfMemoryError(OOM) -- Exception -- -- IOException -- -- RuntimeException -- -- -- NullPointerException(NPE) -- -- -- ClassCastException -- -- -- IndexOutOfBoundsException -- -- -- -- ArrayIndexOutOfBoundsException -- -- -- -- StringIndexOutOfBoundsException
如果调用的方法抛出了“非RuntimeException
”,则必须:
当前方法声明抛出此异常
使用
try...catch
代码块包裹此方法的调整代码真正意义上的“处理了异常”
关于“处理异常”,需要明确的告诉用户“这次操作失败了,失败的原因是XXXXX,你可以通过XXXXX再次尝试,并避免出现此类错误”!
所以,在整个项目,只有Controller才是适合且必须处理异常的组件,因为它可以将错误的描述文本响应到客户端去,而项目中的其它组件(例如Service等)不适合且不应该处理异常,因为它们不可以直接与客户端进行交互,且如果它们处理了异常,则Controller在调用时将无法知道曾经出现过异常,更加无法处理!
1. Spring MVC框架统一处理异常
在使用Spring MVC框架时,控制器(Controller)可以不处理异常(如果执行过程中出现异常,则自动抛出),框架提供了统一处理异常的机制。
关于统一处理异常:
统一处理异常的代码应该编写在专门的类中,并且,在此类上添加
@ControllerAdvice
/@RestControllerAdvice
注解@ControllerAdvice
/@RestControllerAdvice
注解的类中的特定方法将作用于每一次处理请求的过程中其实,统一处理异常的代码可以写在某个控制器中,但是,将只能作用于此控制器中各处理请求的方法,无法作用于其它控制器中处理请求的方法
在类中自定义方法来处理异常
注解:
@ExceptionHandler
访问权限:应该
public
返回值类型:设计原则可参考控制器中处理请求的方法
方法名称:自定义
参数列表:必须包含异常类型的参数,且此参数就是Spring MVC框架调用控制器方法时捕获的异常,另外,可按需添加
HttpServerRequest
、HttpServletResponse
等少量限定类型的参数
例如:
package cn.tedu.csmall.product.handler; import cn.tedu.csmall.product.ex.ServiceException; import cn.tedu.csmall.product.web.JsonResult; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler public JsonResult<Void> handleServiceException(ServiceException e) { log.debug("处理ServiceException,serviceCode={},message={}", e.getServiceCode(), e.getMessage()); return JsonResult.fail(e); } }
在以上方法中,方法的参数是ServiceException
,则表示此方法就是用于处理ServiceException
及其子孙类异常的,不可以处理其它种类的异常。
在同一个项目中,可以有多个以上处理异常的类,或同一个处理异常的类中可以有多个处理异常的方法,只要这些方法处理的异常不冲突即可!并且,这些方法处理的异常允许存在父子级继承关系,例如某个方法处理ServiceException
,另一个方法处理RuntimeException
,当出现ServiceException
,仍会按照处理ServiceException
的方法进行处理!
强烈建议在每个项目中都添加一个处理Throwable
的方法,避免项目出现500
错误!例如:
package cn.tedu.csmall.product.handler; import cn.tedu.csmall.product.ex.ServiceException; import cn.tedu.csmall.product.web.JsonResult; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler public JsonResult<Void> handleServiceException(ServiceException e) { log.debug("处理ServiceException,serviceCode={},message={}", e.getServiceCode(), e.getMessage()); return JsonResult.fail(e); } @ExceptionHandler public JsonResult<Void> handleThrowable(Throwable e) { log.debug("处理Throwable"); e.printStackTrace(); Integer serviceCode = 99999; String message = "程序运行过程中出现未知错误,请联系系统管理员!"; return JsonResult.fail(serviceCode, message); } }
注意:以上处理Throwable
的方法,并不是真正意义的“处理”了异常,在此方法中,应该通过日志输出异常的详情信息,并且,在后续出现相关异常时,在此类中补充针对这些异常的精准处理!
另外,在@ExceptionHandler
中,可以配置异常类型的参数,此参数是异常类型的数组,用于指定需要处理哪些种类的异常,但是,通常并不需要进行此项配置,因为方法的参数就可以直接表示处理哪种异常!此注解参数大多应用于“多种不相关的异常使用同一种处理方式”的情景!
2. 前后端交互
在默认情况下,不允许跨域访问,在前后端分离的模式下,要实现前后端交互,需要在服务器端进行配置,允许跨域访问!
在项目的根包下创建config.WebMvcConfiguration
类,在类上添加@Configuration
注解,并且,实现WebMvcConfiguruer
接口,重写接口中的addCorsMappings()
方法来配置允许跨域访问:
package cn.tedu.csmall.product.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedHeaders("*") .allowedMethods("*") .allowCredentials(true) .maxAge(3600); } }
另外,关于@RequestBody
注解:
如果控制器中处理请求的方法的参数添加了此注解,则客户端提交的请求参数必须是JSON格式的,例如:
{ name: '华为', pinyin: 'HuaWei' }
如果控制器中处理请求的方法的参数没有添加此注解,则客户端提交的请求参数必须是FormData格式的
name=华为&pinyin=HuaWei
在前端技术中,可以使用qs
框架可以轻松的将JavaScript对象转换为FormData格式!
在前端项目中,打开终端,安装qs
:
npm i qs -S
然后,需要在主配置文件main.js
中导入并声明为Vue对象的属性:
import qs from 'qs'; Vue.prototype.qs = qs;
后续,在JavaScript代码中,可以使用qs
的stringify()
将对象转换成FormData格式,例如:
let formData = this.qs.stringify(this.ruleForm);
3. 【框架小结】Spring
3.1. 关于Spring框架
Spring框架主要解决了创建对象、管理对象相关的问题。
Spring框架在创建了各对象之后,会持有这些对象的引用,相当于“把这些对象管理了起来”,所以,Spring框架也经常被称之为“Spring容器”。
3.2. 关于Spring框架创建对象
3.2.1. 关于通过组件扫描来创建组件对象
组件:每个项目中重要的组件部分,即可称之为组件。
在使用Spring框架时,可以通过@Component
/ @Controller
/ @Service
/ @Repository
这4个注解中的任意一个,将某个类标记为“组件”,并且,在Spring框架的作用范围内,这4个注解是完全等效的!之所以存在4个注解,主要是希望通过使用不同的注解,表现出对应的语义!
另外,在配置类上,可以使用@ComponentScan
的注解,开启“组件扫描”,此注解可以配置一个根包(basePackage),例如:
@ComponentScan("cn.tedu.csmall")
则Spring框架会扫描这个根包及其子孙包下所有的类,如果扫描到的类是组件,则Spring框架会自动创建这个类的对象,并把对象保存到Spring容器中!
另外,@Configuration
是比较特殊的组件注解,添加了此注解的类将是”配置类“,Spring在创建此类的对象时,会使用CGLib代理模式进行处理。
还有@RestController
、@ControllerAdvice
、@RestControllerAdvice
也能把类标记为”组件“,但是,这些注解是Spring MVC框架中定义的。
通过组件扫描创建的对象,这些对象在Spring容器中都称之为Spring Bean,每个Spring Bean都有一个Bean Name(Bean的名称),默认情况下,当此对象的类名第1个字母大写,且第2个字母小写时,Bean Name就是将类名首字母改为小写,例如BrandController
类型的对象的Bean Name就是brandController
,如果前2个字母的大小写不符合前序规则,则Bean Name就是类名。如果需要自行指定类名,可以配置@Component
等注解的参数,例如:@Component("beanName")
。
3.2.2. 使用@Bean
方法创建对象
使用Spring框架时,可以在配置类中自定义创建对象的方法,并在方法上添加@Bean
注解,则Spring框架会自动调用此方法,并将此方法返回的对象保存在Spring容器中,例如:
@Configuration public class BeanConfiguration { // 假设某Controller并没有通过组件扫描的做法来创建对象 @Bean public BrandController brandController() { return new BrandController(); } }
使用此做法,默认的Bean Name就是方法名称,或者,通过配置@Bean
的参数来指定名称!
3.2.3. 关于以上2种做法的选取
如果是自定义的类型,推荐使用组件扫描的做法,如果不是自定义的类型,只能使用@Bean
方法!
3.3. Spring管理的对象的作用域
Spring管理的对象,默认情况下,是单例的!
如果要修改,可以使用@Scope("prototype")
注解组件类或@Bean
方法。
单例:在任何时刻,此类型的对象最多只有1个!
注意:Spring并没有使用到设计模式中的单例模式,只是管理的对象具有相同的特征。
被Spring管理的单例的对象,默认情况下,是预加载的,相当于单例模式中的”饿汉式“!
如果要修改,可以使用@Lazy
注解组件类或@Bean
方法。
预加载:加载Spring环境时就会创建这些对象!与之相反的概念是单例模式中的”懒汉式“!
单例模式(饿汉式)示例:
public class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }
单例模式(懒汉式)示例:
public class Singleton { private static volatile Singleton instance; private static final Object lock = new Object(); private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (lock) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
3.4. 关于自动装配
自动装配:当被Spring管理对象的某个属性,或被Spring自动调用的方法的参数需要值时,Spring框架会自动从容器中查找合适的对象,为此属性或参数赋值。
关于”合适的对象“:要么类型匹配,要么名称匹配。
当属性需要被自动装配时,需要在属性上添加自动装配的注解,即添加@Autowired
注解,例如:
@RestController public class BrandController { @Autowired private IBrandService brandService; }
关于“被Spring自动调用的方法”,通常包括:
构造方法
@Bean
方法添加了
@Autowired
注解的方法,通常可能是Setter方法
关于@Autowired
的自动装配机制:
Spring会先从容器中查询匹配类型的Bean的数量
0个:取决于
@Autowired
注解的required
属性true
:装配失败,在加载Spring环境时直接抛出异常false
:放弃自动装配,则此属性的值为null
1个:直接装配,且成功
多个:将尝试按照名称来自动装配
存在名称匹配的Bean:成功装配
不存在名称匹配的Bean:装配失败,在加载Spring环境时直接抛出异常
提示:可以在被装配的值上添加
@Qualifier
注解以指定某个Bean Name
4. Spring Validation
服务器端程序需要对各请求参数进行检查!注意:即使客户端程序已经检查了请求参数,服务器端仍需要再次检查!
Spring Validation是专门用于检查请求参数的格式基本有效性的框架!
在Spring Boot项目中,需要添加依赖项:
<!-- Spring Boot Validation,用于检查请求参数的格式基本有效性 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
关于请求参数的检查,应该在控制器(Controller)中直接执行!
关于检查的做法:
在控制器类中处理请求的方法中,对于封装类型的请求参数,添加
@Valid
或@Validated
注解,表示此参数是需要被Spring Validation框架进行检查的在封装类型的各属性上,添加所需的检查注解,例如:
@NotNull
作业
完成“添加类别”的前后端交互
完成“添加相册”,包括后端与前端的全部内容
业务规则:相册名称不允许重复
页面文件名:AlbumAddNewView.vue
页面路径:/sys-admin/temp/album/add-new
1. Spring Validation(续)
默认情况下,Spring Validation框架会在检查所有的请求参数后再提示可能的失败,即“检查到某个错误时并不会直接中止,而是继续检查”,如果需要实现“快速失败”(即:检查到某个错误时直接视为失败,不会继续后续的检查),需要在配置类中使用@Bean
方法创建并配置Validator
对象!
则在项目的根包下创建config.ValidationConfiguration
类,在此配置类中创建并配置Validator
:
package cn.tedu.csmall.product.config; import lombok.extern.slf4j.Slf4j; import org.hibernate.validator.HibernateValidator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.validation.Validation; /** * Validation的配置类 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Configuration public class ValidationConfiguration { public ValidationConfiguration() { log.info("加载配置类:ValidationConfiguration"); } @Bean public javax.validation.Validator validator() { return Validation .byProvider(HibernateValidator.class) .configure() // 开始配置Validator .failFast(true) // 快速失败,即检查到错误就直接视为失败 .buildValidatorFactory() .getValidator(); } }
Spring Validation是通过不同的注解,实现不同的检查功能,常用的检查注解有:
@NotNull
:不允许为null
@NotEmpty
:不允许为空(长度为0的字符串)仅适用于字符串类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
@NotBlank
:不允许为空白仅适用于字符串类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
@Pattern
:使用正则表达式检查,需要通过此注解的regexp
属性来配置正则表达式仅适用于字符串类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
@Min
:值不得小于多少仅适用于数值类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
@Max
:值不得大于多少仅适用于数值类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
@Range
:值必须在指定的区间范围内仅适用于数值类型的请求参数
可以与
@NotNull
同时使用,且当通过@NotNull
后再执行检查
如果处理请求的方法的参数不是封装的数据类型,需要进行检查时,需要先在当前类上添加@Validated
注解,然后,再在参数上添加相关的检查注解,例如:
@Slf4j @RestController @RequestMapping("/brands") @Api(tags = "02. 品牌管理模块") @Validated // 【新增】 public class BrandController { @Autowired private IBrandService brandService; public BrandController() { log.info("创建控制器:BrandController"); } // http://localhost:9080/brands/test/delete @Deprecated @ApiOperation("测试:删除品牌") @ApiOperationSupport(order = 901) @ApiImplicitParam(name = "enable", dataType = "int", paramType = "query") // 【新增】 @GetMapping("/test/delete") // 【新增】以下方法的参数上添加了检查注解 public String delete(@Range(max = 1) Integer enable) { log.debug("接收到【删除品牌(测试)】的请求"); log.debug("enable = {}", enable); throw new RuntimeException("此接口仅用于测试,并未实现任何功能!"); } }
使用这种方式检查请求参数时,如果检查不通过,将抛出ConstraintViolationException
异常,所以,还需要在全局异常处理器(GlobalExceptionHandler
)中添加:
@ExceptionHandler public JsonResult<Void> handleConstraintViolationException(ConstraintViolationException e) { log.debug("处理ConstraintViolationException"); Integer serviceCode = ServiceCode.ERR_BAD_REQUEST.getValue(); StringBuilder messageBuilder = new StringBuilder(); Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations(); for (ConstraintViolation<?> constraintViolation : constraintViolations) { messageBuilder.append(constraintViolation.getMessage()); } String message = messageBuilder.toString(); return JsonResult.fail(serviceCode, message); }
1. 消息摘要算法
消息摘要算法的典型特征:
消息相同,则摘要相同
无论消息长度,摘要的长度是固定的(同一种算法)
消息不同,则摘要几乎不会相同
常见的消息摘要算法有:
MD系列:MD2(128bit)、MD4(128bit)、MD5(128bit)
SHA家族:SHA-1(160bit)、SHA-256(256bit)、SHA-384(384bit)、SHA-512(512bit)
以MD5为例,其运算结果是128个二进制组成,通常,会转换成十六进制数来表示,则是32位长度的十六进制数。
所以,MD5算法的运算结果的种类有2的128次方种,即:340282366920938463463374607431768211456
由于消息算法在运算过程中会丢失一部分的数据,所以,消息算法都是不可逆的!
使用消息摘要算法处理密码加密时,任何“破解”都不会是“根据密文计算得到原文”的!
在网络上,有一些平台记录了简单的明文密文的对应关系的数据库,以实现“根据密文查询出原文”的效果,但是,只要原文足够复杂(8位长度以上),这些平台不可能收录其对应关系!
为了保证密码原文的复杂性,应该使用“盐”,它将作为被运算数据的组件部分之一,例如:
String rawPassword = "123456"; String salt = "jhfdiu78543hjfdo8"; String encodedPassword = DigestUtils.md5DigestAsHex((rawPassword + salt).getBytes());
即使原密码非常简单,但是,对于运算过程而言,真正的原始数据已经变成了123456jhfdiu78543hjfdo8
,这样的原文与其密文的对应关系是不可能被各平台收录的!
所以,为了保障用户密码的安全,可行的做法有:
要求用户使用强度更高的原始密码
要求使用较长的密码
要求密码中包含的字符种类更多样化
加盐
理论上,盐值越复杂越好,但是,也没有必要过度复杂
盐值的具体使用也没有规定,原则上,只要能使得被运算数据变复杂即可
循环加密
将第1次运算得到的密文,作为原文,进行第2次的运算,如此循环多次
使用位长更长的消息摘要算法
综合使用以上各种做法
Mybatis的#{}与${}占位符
在使用Mybatis配置SQL语句时,SQL语句中的参数可以使用#{}
格式的占位符,例如:
<!-- AlbumStandardVO getStandardById(Long id); --> <select id="getStandardById" resultMap="StandardResultMap"> SELECT <include refid="StandardQueryFields"/> FROM pms_album WHERE id=#{id} </select>
其实,还可以使用${}
占位符!
以上配置,使用#{}
或${}
格式的占位符均可正常通过测试!
另外,再使用一段代码进行测试:
<!-- int countByName(String name); --> <select id="countByName" resultType="int"> SELECT count(*) FROM pms_album WHERE name=#{name} </select>
以上代码,使用#{}
格式的占位符可以正常通过测试,但是,使用${}
格式的占位符则会出现错误:
Cause: java.sql.SQLSyntaxErrorException: Unknown column '小米13的相册' in 'where clause' ; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Unknown column '小米13的相册' in 'where clause'
通过错误信息,可以看到,SQL语句中将测试参数 小米13的相册
作为了列名(字段名)!
其实,在SQL语句中,除了关键词、数值、运算符、函数名等非常固定的内容以外,所有内容都会被视为某个名字,例如:表名、字段名或其它自定义的名称(例如索引名等),比较典型的应用,例如:
update bank_account set money = money + 1000 where id = 1;
以上SQL语句的意思大致是:将id=1的账户的余额在现有基础之上增加1000。
可以看到,在以上set money = money + 1000
部分的代码片段中,等于号右侧的 money
会被视为“字段名”!
为了避免MySQL把 小米13的相册
视为字段名,必须在 小米13的相册
的两侧添加一对单引号,例如:
SELECT count(*) FROM pms_album WHERE name='${name}'
或者,在测试数据的两侧添加一对单引号也是可以的:
String name = "'小米13的相册'";
所以,在SQL语句中,只有将值使用一对双引号框住(数值除外),才会被认为是“值”!
------------------------- 分隔线 -------------------------
在MySQL处理SQL语句时,会经过词法分析、语义分析,然后再执行编译,最终执行!
在Mybatis中,使用#{}
格式的占位符,在底层实现时,会使用预编译(在编译时与参数值无关)的处理机制,值将会在执行时再代入!由于采用了预编译的做法,所有值都不用考虑数据类型的问题,例如,不需要给字符串类型的值添加一对单引号,并且,预编译是安全的,不会出现SQL注入问题!这种做法中,#{}
占位符只能表示SQL语句中的某个值,而不能表示其它内容!
使用${}
格式的占位符,在底层实现时,会先将值拼接到原SQL语句中,然后再执行编译的相关流程!由于不是预编译的,字符串、日期等类型的值需要添加一对单引号,否则,将被视为字段名、运算表达式等,由于是先代入值再执行编译相关流程,所以,代入的值是可以改变语义的,是存在SQL注入风险的!这种做法中,${}
占位符可以表示SQL语句中的任何片段,只需要保证拼接起来后可以通过编译即可!
附:SQL注入演示:
select * from user where username='xxx' and password='xxxxxxxxxxxx'; -- 1' or 'a'='a select * from user where username='xxx' and password='1' or '1'='1'; or true
1. 显示品牌列表
由于BrandMapper
已经实现了“查询品牌列表”功能,所以,Mapper层无需处理。
在IBrandService
接口中添加:
/** * 查询品牌列表 * * @return 品牌列表,如果没有匹配的品牌,将返回长度为0的列表 */ List<BrandListItemVO> list();
在BrandServiceImpl
中调用Mapper对象的查询方法直接实现:
@Override public List<BrandListItemVO> list() { log.debug("开始处理【查询品牌列表】的业务"); return brandMapper.list(); }
在BrandServiceTests
中测试:
@Test void testList() { List<?> list = service.list(); log.debug("查询品牌列表,查询结果中的数据的数量:{}", list.size()); for (Object brand : list) { log.debug("{}", brand); } }
然后,在BrandController
中添加处理请求的方法:
// http://localhost:9080/brands @ApiOperation("查询品牌列表") @ApiOperationSupport(order = 400) @GetMapping("") public JsonResult<List<BrandListItemVO>> list() { log.debug("开始处理【查询品牌列表】的请求……"); List<BrandListItemVO> list = brandService.list(); return JsonResult.ok(list); }
完成后,通过Knife4j的API文档可以进行调试(测试访问)。
2. SSO(Single Sign On:单点登录)
在集群甚至分布式系统中,通常只有某1种服务提供登录认证,无论是其它哪个服务需要用户登录,用户都应该在此专门提供登录认证的服务器端进行认证,并且,认证结果对于其它所有服务都是有效的!
SSO的典型实现方案就是使用Token。
关于商品管理的相关功能,也应该是需要经过认证的(需要先登录),并且,可能也会采取某些权限控制!
需要将passport
项目的相关代码复制到product
项目中:
pom.xml
中的依赖:spring-boot-starter-security
、fastjson
、jjwt
ServiceCode
:补充新的枚举值LoginPrincipal
application.properties
中的配置:关于JWT的配置,且secretKey
必须相同JwtAuthorizationFilter
过滤器SecurityConfiguration
配置类删除
PasswordEncoder
的@Bean
方法删除
AuthenticationManager
的@Bean
方法必须删除,否则,在
product
项目中执行测试时,会出现内存溢出
调整配置的白名单
GlobalExceptionHandler
:至少补充处理AccessDeniedException
完成后,可以先通过product
项目的在线API文档进行测试访问,在没有携带JWT的情况下,所有请求都会响应403
错误,需要先在passport
项目的在线API文档中执行登录,得到JWT数据,并配置到product
项目的API文档中,再次访问,则可以正常访问!
3. 显示类别列表
关于“类别”的显示,需要注意:通常并不需要一次性将所有的、各层级的类别全部查询或显示出来,只需要查询特定的一些类别,例如:查询所有1级类别,或查询某个1级类别的子级类别列表等。
目前,在Mapper层已经实现了List<CategoryListItemVO> listByParentId(Long parentId);
,所以,Mapper层无需再开发。
则,在ICategoryService
中添加:
/** * 根据父级类别的id查询类别列表 * * @param parentId 父级类别的id * @return 类别列表 */ List<CategoryListItemVO> listByParentId(Long parentId);
在CategoryServiceImpl
中实现:
@Override public List<CategoryListItemVO> listByParentId(Long parentId) { log.debug("开始处理【根据父级类别查询子级类别列表】的业务"); return categoryMapper.listByParentId(parentId); }
在CategoryServiceTests
中测试:
@Test public void testListByParentId() { Long parentId = 0L; List<?> list = service.listByParentId(parentId); log.info("查询列表完成,结果集中的数据的数量={}", list.size()); for (Object item : list) { log.info("{}", item); } }
在CategoryController
中添加处理请求的方法:
// http://localhost:9080/categories/list-by-parent @ApiOperation("根据父级类别查询子级类别列表") @ApiOperationSupport(order = 410) @ApiImplicitParam(name = "parentId", value = "父级类别id,如果是一级类别,则此参数值应该为0", required = true, dataType = "long") @GetMapping("/list-by-parent") public JsonResult<List<CategoryListItemVO>> listByParentId(Long parentId) { if (parentId == null || parentId < 0) { parentId = 0L; } List<CategoryListItemVO> list = categoryService.listByParentId(parentId); return JsonResult.ok(list); }
完成后,重启项目,通过API文档应该可以测试访问。
4. 添加属性模板
由于服务器端已经实现了“添加属性模板”的功能,所以,只需完成前端界面即可。
5. 显示属性模板列表
此前已经实现了Mapper层的查询,接下来,需要实现Service层、Controller层和前端界面的处理!
6. 在添加属性界面中显示属性模板列表的下拉菜单
作业
完成“删除品牌”功能(从界面上点击按钮完成)
完成“启用品牌”功能(从界面上点击按钮完成)
完成“禁用品牌”功能(从界面上点击按钮完成)
完成“启用类别”功能(从界面上点击按钮完成)
完成“禁用类别”功能(从界面上点击按钮完成)
完成“显示类别(是否显示在导航栏)”功能(从界面上点击按钮完成)
完成“隐藏类别(是否显示在导航栏)”功能(从界面上点击按钮完成)
完成“删除类别”功能(从界面上点击按钮完成)
完成“删除相册”功能(从界面上点击按钮完成)
完成“删除属性模板”功能(从界面上点击按钮完成)
1. 新增SPU的界面设计
视图文件名:SpuAddNewStep1View.vue
路由路径:/sys-admin/product/spu-add-new1
视图文件名:SpuAddNewStep2View.vue
路由路径:/sys-admin/product/spu-add-new2
视图文件名:SpuAddNewStep3View.vue
路由路径:/sys-admin/product/spu-add-new3
视图文件名:SpuAddNewStep4View.vue
路由路径:/sys-admin/product/spu-add-new4
在前端界面的设计中,HTML本身并没有富文本编辑器的控件,通常,都是使用第三方的。
第三方的富文本编辑器有许多种,以上演示图中使用的是wangeditor
,在使用之前,需要先安装:
npm i wangeditor -S
然后,在main.js
中导入,并声明为Vue对象的属性:
import wangEditor from 'wangeditor'; Vue.prototype.wangEditor = wangEditor;
在设计视图时,只需要使用某个标签用于后续wangEditor
向其它插入源代码,以显示富编辑器即可,例如:
<div id="wangEditor"></div>
并且,在页面刚刚加载完成时,应该对wangEditor
进行初始化:
<template> <div> <h1>新增SPU第4步</h1> <div id="wangEditor"></div> </div> </template> <script> export default { data() { return { editor: {} } }, methods: { initWangEditor() { this.editor = new this.wangEditor('#wangEditor'); // this.editor.config.zIndex = 1; this.editor.create(); } }, mounted() { this.initWangEditor(); } } </script>
增加SPU之前的检查
在增加SPU之前,应该对输入(或选择等)的数据进行检查,例如:检查类别信息,则需要服务器端提供“根据类别id获取类别详情”的接口!
在CategoryMapper
接口中已经实现了CategoryStandardVO getStandardById(Long id);
。
在ICategoryService
接口中添加:
/** * 根据id获取类别的标准信息 * * @param id 类别id * @return 返回匹配的类别的标准信息,如果没有匹配的数据,将返回null */ CategoryStandardVO getStandardById(Long id);
在CategoryServiceImpl
中实现:
@Override public CategoryStandardVO getStandardById(Long id) { log.debug("开始处理【根据id查询类别详情】的业务"); CategoryStandardVO category = categoryMapper.getStandardById(id); if (category == null) { // 是:此id对应的数据不存在,则抛出异常(ERR_NOT_FOUND) String message = "查询类别详情失败,尝试访问的数据不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } return category; }
在CategoryServiceTests
中测试:
@Test public void testGetStandardById() { Long id = 2L; try { CategoryStandardVO category = service.getStandardById(id); log.debug("根据id={}查询完成,查询结果={}", id, category); } catch (ServiceException e) { log.debug("serviceCode : " + e.getServiceCode()); log.debug("message : " + e.getMessage()); } }
在CategoryController
中处理请求:
// http://localhost:9080/categories/3 @ApiOperation("根据id查询类别详情") @ApiOperationSupport(order = 400) @ApiImplicitParam(name = "id", value = "类别id", required = true, dataType = "long") @GetMapping("/{id:[0-9]+}") public JsonResult<CategoryStandardVO> getStandardById(@PathVariable Long id) { log.debug("开始处理【根据id查询类别详情】的请求:id={}", id); CategoryStandardVO category = categoryService.getStandardById(id); return JsonResult.ok(category); }
完成后,重启项目,可以通过在线API文档进行测试访问。
同理,还需要检查品牌信息,则需要服务器端提供“根据品牌id获取品牌详情”的接口!
1. 新增SPU
1.1. 分析
为了保证数据表的查询效率,SPU数据中的“详情”(通过富文本编辑器输入的内容)被设计在pms_spu_detail
表中,而其它的一般数据在pms_spu
表中,当“新增SPU”时,本质上需要同时对这2张表进行“插入数据”的操作!
需要执行的SQL语句大致是:
insert into pms_spu (字段列表) values (值列表); insert into pms_spu_detail (字段列表) values (值列表);
注意:在pms_spu
表中,主键(id
)并不是自动编号的(要考虑分库分表时,相关的数据表的主键都不允许使用自动编号)!
另外,在插入数据之前,相关数据需要进行检查,包括:检查品牌、类别、相册的id是否存在、是否有效等,这些功能此前已经完成!需要注意:关于品牌、类别、相册的名称,前端是可以提交这些数据的,但是,服务器端不应该直接使用这些数据写入到数据表,只使用前端提交的品牌id、类别id、相册id,至于品牌名称、类别名称、相册名称,应该是在检查时一并查出来,并使用查询到的数据写入到数据表中!
1.2. 关于Mapper层
关于插入Spu数据
在根包下创建pojo.entity.Spu
实体类。
在根包下创建mapper.SpuMapper
接口,添加抽象方法:
@Repository public interface SpuMapper { /** * 插入SPU数据 * * @param spu SPU数据 * @return 受影响的行数 */ int insert(Spu spu); }
在src/main/resources/mapper
下粘贴得到SpuMapper.xml
文件,配置SQL语句:
<mapper namespace="cn.tedu.csmall.product.mapper.SpuMapper"> <!-- 由于pms_spu表的id不是自动编号的,在插入数据时,需要显式指定此字段的值 --> <!-- 所以,不需要配置useGeneratedKeys和keyProperty --> <!-- int insert(Spu spu); --> <insert id="insert"> INSERT INTO pms_spu ( id, name, type_number, title, description, list_price, stock, stock_threshold, unit, brand_id, brand_name, category_id, category_name, attribute_template_id, album_id, pictures, keywords, tags, sort, is_deleted, is_published, is_new_arrival, is_recommend, is_checked, gmt_check ) VALUES ( #{id}, #{name}, #{typeNumber}, #{title}, #{description}, #{listPrice}, #{stock}, #{stockThreshold}, #{unit}, #{brandId}, #{brandName}, #{categoryId}, #{categoryName}, #{attributeTemplateId}, #{albumId}, #{pictures}, #{keywords}, #{tags}, #{sort}, #{isDeleted}, #{isPublished}, #{isNewArrival}, #{isRecommend}, #{isChecked}, #{gmtCheck} ) </insert> </mapper>
在src/test/java
的根包下创建SpuMapperTests
测试类,测试以上抽象方法:
@Slf4j @SpringBootTest public class SpuMapperTests { @Autowired SpuMapper mapper; @Test public void testInsert() { Spu spu = new Spu(); spu.setId(11000L); // 重要,必须 spu.setName("小米13"); log.debug("插入数据之前,参数={}", spu); int rows = mapper.insert(spu); log.debug("rows = {}", rows); log.debug("插入数据之后,参数={}", spu); } }
关于插入SpuDetail数据
在根包下创建pojo.entity.SpuDetail
实体类。
在根包下创建mapper.SpuDetailMapper
接口,添加抽象方法:
@Repository public interface SpuDetailMapper { /** * 插入SPU详情数据 * * @param spuDetail SPU详情数据 * @return 受影响的行数 */ int insert(SpuDetail spuDetail); }
在src/main/resources/mapper
下粘贴得到SpuDetailMapper.xml
文件,配置SQL语句:
<mapper namespace="cn.tedu.csmall.product.mapper.SpuDetailMapper"> <!-- int insert(SpuDetail spuDetail); --> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO pms_spu_detail ( spu_id,detail ) VALUES ( #{spuId},#{detail} ) </insert> </mapper>
在src/test/java
的根包下创建SpuDetailMapperTests
测试类,测试以上抽象方法:
@Slf4j @SpringBootTest public class SpuDetailMapperTests { @Autowired SpuDetailMapper mapper; @Test public void testInsert() { SpuDetail spuDetail = new SpuDetail(); spuDetail.setSpuId(10000L); spuDetail.setDetail("这是1号Spu的详情"); log.debug("插入数据之前,参数={}", spuDetail); int rows = mapper.insert(spuDetail); log.debug("rows = {}", rows); log.debug("插入数据之后,参数={}", spuDetail); } }
1.3. 关于Service层
为了保证Spu的id的唯一性,且基于“不会高频率新增Spu”,可以使用时间加随机数字作为id值。
考虑到后续可能调整生成id的策略,则将生成id的代码写在专门的工具类中,不写在业务层。
在根包下创建util.IdUtils
类,定义生成id的静态方法:
package cn.tedu.csmall.product.util; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Random; /** * Id工具类 * * @author java@tedu.cn * @version 0.0.1 */ public final class IdUtils { private IdUtils() {} private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); private static Random random = new Random(); // 临时策略:使用“年月日时分秒毫秒”加2位随机数作为id public static Long getId() { LocalDateTime now = LocalDateTime.now(); String dateTimeString = dateTimeFormatter.format(now); int randomNumber = random.nextInt(89) + 10; Long id = Long.valueOf(dateTimeString + randomNumber); return id; } }
在根包下创建pojo.dto.SpuAddNewDTO
类(注意:相对于实体类,需要删除不由客户端提交的数据,并且,需要补充detail
属性,表示“Spu详情”):
package cn.tedu.csmall.product.pojo.dto; import lombok.Data; import java.io.Serializable; import java.math.BigDecimal; /** * SPU(Standard Product Unit) * * @author java@tedu.cn * @version 0.0.1 */ @Data public class SpuAddNewDTO implements Serializable { /** * SPU名称 */ private String name; /** * SPU编号 */ private String typeNumber; /** * 标题 */ private String title; /** * 简介 */ private String description; /** * 价格(显示在列表中) */ private BigDecimal listPrice; /** * 当前库存(冗余) */ private Integer stock; /** * 库存预警阈值(冗余) */ private Integer stockThreshold; /** * 计件单位 */ private String unit; /** * 品牌id */ private Long brandId; /** * 类别id */ private Long categoryId; /** * 属性模板id */ // private Long attributeTemplateId; /** * 相册id */ private Long albumId; /** * 组图URLs,使⽤JSON格式表示 */ // private String pictures; /** * 关键词列表,各关键词使⽤英⽂的逗号分隔 */ private String keywords; /** * 标签列表,各标签使⽤英⽂的逗号分隔,原则上最多3个 */ private String tags; /** * ⾃定义排序序号 */ private Integer sort; /** * Spu详情 */ private String detail; }
在根包下创建ISpuService
接口,并在接口中添加“新增SPU”的抽象方法:
@Transactional public interface ISpuService { void addNew(SpuAddNewDTO spuAddNewDTO); }
在根包下创建SpuServiceImpl
类,是组件类,实现以上接口:
@Slf4j @Service public class SpuServiceImpl implements ISpuService { @Autowired private SpuMapper spuMapper; @Autowired private SpuDetailMapper spuDetailMapper; @Autowired private BrandMapper brandMapper; @Autowired private CategoryMapper categoryMapper; @Autowired private AlbumMapper albumMapper; @Override public void addNew(SpuAddNewDTO spuAddNewDTO) { // 从参数spuAddNewDTO中取出brandId // 调用brandMapper的getDetailsById()方法查询品牌 // 判断查询结果是否为null // 是:抛出异常:选择的品牌不存在 // 判断查询到的品牌的enable是否为0 // 是:抛出异常 // 从参数spuAddNewDTO中取出categoryId // 调用categoryMapper的getDetailsById()方法查询类别 // 判断查询结果是否为null // 是:抛出异常:选择的类别不存在 // 判断查询到的类别的enable是否为0 // 是:抛出异常 // 判断查询到的类别的isParent是否为1 // 是:抛出异常 // 从参数spuAddNewDTO中取出albumId // 调用albumMapper的getDetailsById()方法查询相册 // 判断查询结果是否为null // 是:抛出异常:选择的相册不存在 // 创建Spu对象 // 将参数spuAddNewDTO的属性值复制到Spu对象中 // 补全Spu对象的属性值:id >>> 自行决定 // 补全Spu对象的属性值:brandName >>> 前序查询品牌的结果中取出 // 补全Spu对象的属性值:categoryName >>> 前序查询类别的结果中取出 // 补全Spu对象的属性值:sales / commentCount / positiveCommentCount >>> 0 // 补全Spu对象的属性值:isDelete / isPublished >>> 0 // 补全Spu对象的属性值:isNewArrival / isRecommend >>> 自行决定 // 补全Spu对象的属性值:isChecked >>> 0 // 补全Spu对象的属性值:checkUser / gmtCheck >>> null // 调用spuMapper的int insert(Spu spu)方法插入Spu数据,并获取返回值 // 判断返回值是否不为1 // 是:抛出异常 // 创建SpuDetail对象 // 补全SpuDetail对象的属性值:spuId >>> 同以上Spu对象的id // 补全SpuDetail对象的属性值:detail >>> 来自spuAddNewDTO参数 // 调用spuDetailMapper的int insert(SpuDetail spuDetail)方法插入SpuDetail数据,并获取返回值 // 判断返回值是否不为1 // 是:抛出异常 } }
具体实现为:
package cn.tedu.csmall.product.service.impl; import cn.tedu.csmall.product.ex.ServiceCode; import cn.tedu.csmall.product.ex.ServiceException; import cn.tedu.csmall.product.mapper.*; import cn.tedu.csmall.product.pojo.dto.SpuAddNewDTO; import cn.tedu.csmall.product.pojo.entity.Spu; import cn.tedu.csmall.product.pojo.entity.SpuDetail; import cn.tedu.csmall.product.pojo.vo.AlbumStandardVO; import cn.tedu.csmall.product.pojo.vo.BrandStandardVO; import cn.tedu.csmall.product.pojo.vo.CategoryStandardVO; import cn.tedu.csmall.product.service.ISpuService; import cn.tedu.csmall.product.util.IdUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * 处理Spu业务的实现类 * * @author java@tedu.cn * @version 0.0.1 */ @Service @Slf4j public class SpuServiceImpl implements ISpuService { @Autowired private SpuMapper spuMapper; @Autowired private SpuDetailMapper spuDetailMapper; @Autowired private BrandMapper brandMapper; @Autowired private CategoryMapper categoryMapper; @Autowired private AlbumMapper albumMapper; @Override public void addNew(SpuAddNewDTO spuAddNewDTO) { // 从参数spuAddNewDTO中取出brandId Long brandId = spuAddNewDTO.getBrandId(); // 调用brandMapper的getDetailsById()方法查询品牌 BrandStandardVO brand = brandMapper.getStandardById(brandId); // 判断查询结果是否为null if (brand == null) { // 是:抛出异常:选择的品牌不存在 String message = "新增Spu失败,尝试绑定的品牌数据不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } // 判断查询到的品牌的enable是否为0 if (brand.getEnable() == 0) { // 是:抛出异常 String message = "新增Spu失败,尝试绑定的品牌已经被禁用!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_CONFLICT, message); } // 从参数spuAddNewDTO中取出categoryId Long categoryId = spuAddNewDTO.getCategoryId(); // 调用categoryMapper的getDetailsById()方法查询类别 CategoryStandardVO category = categoryMapper.getStandardById(categoryId); // 判断查询结果是否为null if (category == null) { // 是:抛出异常:选择的类别不存在 String message = "新增Spu失败,尝试绑定的类别数据不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } // 判断查询到的类别的enable是否为0 if (category.getEnable() == 0) { // 是:抛出异常 String message = "新增Spu失败,尝试绑定的类别已经被禁用!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_CONFLICT, message); } // 判断查询到的类别的isParent是否为1 if (category.getIsParent() == 1) { // 是:抛出异常 String message = "新增Spu失败,尝试绑定的类别包含子级类别,不允许使用此类别!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_CONFLICT, message); } // 从参数spuAddNewDTO中取出albumId Long albumId = spuAddNewDTO.getAlbumId(); // 调用albumMapper的getDetailsById()方法查询相册 AlbumStandardVO album = albumMapper.getStandardById(albumId); // 判断查询结果是否为null if (album == null) { // 是:抛出异常:选择的相册不存在 String message = "新增Spu失败,尝试绑定的相册数据不存在!"; log.warn(message); throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message); } // 获取id(由别处生成) Long id = IdUtils.getId(); // 创建Spu对象 Spu spu = new Spu(); // 将参数spuAddNewDTO的属性值复制到Spu对象中 BeanUtils.copyProperties(spuAddNewDTO, spu); // 补全Spu对象的属性值:id >>> 自行决定 spu.setId(id); // 补全Spu对象的属性值:brandName >>> 前序查询品牌的结果中取出 spu.setBrandName(brand.getName()); // 补全Spu对象的属性值:categoryName >>> 前序查询类别的结果中取出 spu.setCategoryName(category.getName()); // 补全Spu对象的属性值:sales / commentCount / positiveCommentCount >>> 0 spu.setSales(0); spu.setCommentCount(0); spu.setPositiveCommentCount(0); // 补全Spu对象的属性值:isDelete / isPublished >>> 0 spu.setIsDeleted(0); spu.setIsPublished(0); // 补全Spu对象的属性值:isNewArrival / isRecommend >>> 自行决定 spu.setIsNewArrival(0); spu.setIsRecommend(0); // 补全Spu对象的属性值:isChecked >>> 0 spu.setIsChecked(0); // 补全Spu对象的属性值:checkUser / gmtCheck >>> null // 调用spuMapper的int insert(Spu spu)方法插入Spu数据,并获取返回值 int rows = spuMapper.insert(spu); // 判断返回值是否不为1 if (rows != 1) { // 是:抛出异常 String message = "新增Spu失败!服务器忙,请稍后再次尝试![错误代码:1]"; log.warn(message); throw new ServiceException(ServiceCode.ERR_INSERT, message); } // 创建SpuDetail对象 SpuDetail spuDetail = new SpuDetail(); // 补全SpuDetail对象的属性值:spuId >>> 同以上Spu对象的id spuDetail.setSpuId(id); // 补全SpuDetail对象的属性值:detail >>> 来自spuAddNewDTO参数 spuDetail.setDetail(spuAddNewDTO.getDetail()); // 调用spuDetailMapper的int insert(SpuDetail spuDetail)方法插入SpuDetail数据,并获取返回值 rows = spuDetailMapper.insert(spuDetail); // 判断返回值是否不为1 if (rows != 1) { // 是:抛出异常 String message = "新增Spu失败!服务器忙,请稍后再次尝试![错误代码:2]"; log.warn(message); throw new ServiceException(ServiceCode.ERR_INSERT, message); } } }
在src/test/java
的根包下创建service.SpuServiceTests
测试类,编写并执行测试:
@Slf4j @SpringBootTest public class SpuServiceTests { @Autowired ISpuService service; @Test void testAddNew() { try { SpuAddNewDTO spuAddNewDTO = new SpuAddNewDTO(); spuAddNewDTO.setBrandId(2L); spuAddNewDTO.setCategoryId(3L); spuAddNewDTO.setAlbumId(2L); spuAddNewDTO.setName("测试Spu-001"); service.addNew(spuAddNewDTO); log.debug("新增Spu成功!"); } catch (ServiceException e) { log.debug("serviceCode : " + e.getServiceCode()); log.debug("message : " + e.getMessage()); } } }
1.4. 关于Controller层
在根包下创建SpuController
控制器类,并处理请求:
package cn.tedu.csmall.product.controller; /** * 处理Spu相关请求的控制器 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @RestController @RequestMapping("/spu") @Api(tags = "08. SPU管理模块") public class SpuController { @Autowired private ISpuService spuService; public SpuController() { log.info("创建控制器:SpuController"); } // 添加SPU // http://localhost:9080/spu/add-new @ApiOperation("新增SPU") @ApiOperationSupport(order = 100) @PostMapping("/add-new") public JsonResult<Void> addNew(@Validated SpuAddNewDTO spuAddNewDTO) { log.debug("开始处理【新增SPU】的请求:{}", spuAddNewDTO); spuService.addNew(spuAddNewDTO); return JsonResult.ok(); } }
关于Redis
Redis是一款使用K-V结构的、基于内存实现数据存取的NoSQL非关系型数据库。
使用Redis的主要目的是“缓存数据”,以提高查询数据的效率,对数据库也有一定的保护作用。
需要注意:缓存的数据可能存在“不一致”的问题,因为,如果修改了数据库(例如MySQL)中的数据,而缓存(例如Redis)中的数据没有一并更新,则缓存中的数据是不准确的!但是,并不是所有的场景都要求数据非常准确!
所以,使用Redis的前提条件:
对数据的准确性要求不高
例如:新浪微博的热搜排名、某个热门视频的播放量
数据的修改频率不高
例如:电商平台中的类别、电商平台中的品牌
反之,某些情况下是不应该使用Redis的,例如:在秒杀商品时,使用Redis记录一些频繁修改的数据!
在操作系统的终端下,通过redis-cli
命令即可登录Redis控制台:
redis-cli
在Redis控制台中,使用set
和get
命令就可以存取基本类型的数据:
set name liucangsong
get name
在Redis中,有5种典型数据类型:字符串、Hash、Set、zSet、List,在结合编程时,通常,只需要使用“字符串”即可,在程序中,非字符串类型的数据(例如对象、集合等)都会通过工具转换成JSON格式的字符串再存入到Redis中,后续,从Redis中取出的也是字符串,再通过工具转换成原本的类型(例如对象、集合等)即可。
作业
实现以下功能(含前端页面与后端服务)
显示SPU列表
删除SPU
业务规则:数据必须存在
注意:需删除
pms_spu
和pms_spu_detail
这2张表中的相关数据
逻辑删除SPU
业务规则:数据必须存在
注意:本质上是执行
UPDATE
操作,将is_delete
改为1
恢复SPU
业务规则:数据必须存在
注意:本质上是执行
UPDATE
操作,将is_delete
改为0
根据id查询SPU详情
特别说明:只需要完成后端,不需要实现前端页面
Mybatis的缓存机制
Mybatis框架内置了一级缓存机制与二级缓存机制。
Mybatis框架的一级缓存又称之为会话(Session)缓存,默认是开启的,且无法关闭!
一级缓存必须满足:同一个SqlSession、同一个Mapper对象、执行相同的查询、且参数相同!
通过测试并观察日志【参见下文】,可以看到,第1次执行查询,但是,第2次并没有真正的执行查询,并且,2次查询结果的hashCode值是完全相同的!也就是说:Mybatis只执行了第1次的查询,当执行第2次的代码时,并没有真正连接到MySQL数据库执行查询,而是将第1次的查询结果直接返回了!也可以说:Mybatis在第1次查询后,就把查询结果缓存下来了!
一级缓存会因为以下任一原因消失:
调用
SqlSession
对象的clearCache()
方法,将清空缓存当前执行了任何写操作(增/删/改),无论任何数据有没有发生变化,都会清空缓存
package cn.tedu.csmall.product; import cn.tedu.csmall.product.mapper.BrandMapper; import cn.tedu.csmall.product.pojo.entity.Brand; import cn.tedu.csmall.product.pojo.vo.BrandStandardVO; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest public class MybatisCacheTests { @Autowired SqlSessionFactory sqlSessionFactory; @Test void testL1Cache() { SqlSession sqlSession = sqlSessionFactory.openSession(); BrandMapper brandMapper = sqlSession.getMapper(BrandMapper.class); Long id = 1L; log.debug("开始执行第1次【id={}】的查询……", id); BrandStandardVO result1 = brandMapper.getStandardById(id); log.debug("第1次的查询结果:{}", result1); log.debug("第1次的查询结果的hashCode:{}", result1.hashCode()); // sqlSession.clearCache(); // log.debug("已经调用clearCache()方法清除缓存!"); log.debug("开始执行第2次【id={}】的查询……", id); BrandStandardVO result2 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result2); log.debug("第2次的查询结果的hashCode:{}", result2.hashCode()); id = 4L; log.debug("开始执行第1次【id={}】的查询……", id); BrandStandardVO result3 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result3); log.debug("第2次的查询结果的hashCode:{}", result3.hashCode()); // sqlSession.clearCache(); // log.debug("已经调用clearCache()方法清除缓存!"); log.debug("准备修改【id={}】的数据……", 40000); Brand brand = new Brand(); brand.setId(40000L); brand.setSort(99); brandMapper.updateById(brand); log.debug("完成修改【id={}】的数据", 40000); log.debug("开始执行第2次【id={}】的查询……", id); BrandStandardVO result4 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result4); log.debug("第2次的查询结果的hashCode:{}", result4.hashCode()); id = 1L; log.debug("开始执行第3次【id={}】的查询……", id); BrandStandardVO result5 = brandMapper.getStandardById(id); log.debug("第3次的查询结果:{}", result5); log.debug("第3次的查询结果的hashCode:{}", result5.hashCode()); } }
Mybatis框架的二级缓存也称之为namespace缓存,是作用于某个namespace的!
二级缓存的应用优先级高于一级缓存,也就是说,Mybatis框架将优先从二级缓存中查找数据,如果命中,将返回,如果未命中,则从一级缓存中查找数据,如果命中,将返回,如果仍未命中,将执行真正的查询!
在使用Spring Boot与Mybatis集成的框架的项目中,二级缓存默认是全局开启的,各namespace默认未开启,如果需要开启,需要在XML文件中添加<cache/>
标签,则表示当前XML中所有的查询都开启了二级缓存!另外,在每个查询上都有useCache
属性,默认为true
,如果在同一个namespace中,有些查询需要使用二级缓存,也有一些并不需要使用,则可以将不需要使用二级缓存的查询配置为useCache="false"
。
当启用了二缓存后,每次尝试查询时,日志中都会出现 Cache Hit Ratio
的记录!
@Autowired BrandMapper brandMapper; @Test void testL2Cache() { Long id = 1L; log.debug("开始执行第1次【id={}】的查询……", id); BrandStandardVO result1 = brandMapper.getStandardById(id); log.debug("第1次的查询结果:{}", result1); log.debug("第1次的查询结果的hashCode:{}", result1.hashCode()); log.debug("准备修改【id={}】的数据……", 40000); Brand brand = new Brand(); brand.setId(40000L); brand.setSort(99); brandMapper.updateById(brand); log.debug("完成修改【id={}】的数据", 40000); log.debug("开始执行第2次【id={}】的查询……", id); BrandStandardVO result2 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result2); log.debug("第2次的查询结果的hashCode:{}", result2.hashCode()); id = 4L; log.debug("开始执行第1次【id={}】的查询……", id); BrandStandardVO result3 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result3); log.debug("第2次的查询结果的hashCode:{}", result3.hashCode()); log.debug("开始执行第2次【id={}】的查询……", id); BrandStandardVO result4 = brandMapper.getStandardById(id); log.debug("第2次的查询结果:{}", result4); log.debug("第2次的查询结果的hashCode:{}", result4.hashCode()); id = 1L; log.debug("开始执行第3次【id={}】的查询……", id); BrandStandardVO result5 = brandMapper.getStandardById(id); log.debug("第3次的查询结果:{}", result5); log.debug("第3次的查询结果的hashCode:{}", result5.hashCode()); }
二级缓存的数据是存储在磁盘上的,需要查询结果的数据类型实现了Serilizable
接口,如果未实现此接口,则查询时直接报错:
org.apache.ibatis.cache.CacheException: Error serializing object. Cause: java.io.NotSerializableException: cn.tedu.csmall.product.pojo.vo.BrandStandardVO
与一级缓存相同,只要发生了任何写操作(增/删/改),都会自动清除缓存数据!
1. Redis
1.1. Redis的简单操作
当已经安装Redis,并确保环境变量可用后,可以在命令提示符窗口(CMD)或终端(IDEA的Terminal,或MacOS/Linux的命令窗口)中执行相关命令。
在终端下,可以通过redis-cli
登录Redis客户端:
redis-cli
在Redis客户端中,可以通过ping
检测Redis是否正常工作,将得到PONG
的反馈:
ping
在Redis客户端中,可以通过set
命令向Redis中存入或修改简单类型的数据:
set name jack
在Redis客户端中,可以通过get
命令从Redis中取出简单类型的数据:
get name
如果使用的Key并不存在,使用
get
命令时,得到的结果将是(nil)
,等效于Java中的null
在Redis客户端中,可以通过keys
命令检索Key:
keys *
keys a*
注意:默认情况下,Redis是单线程的,keys
命令会执行整个Redis的检索,所以,执行时间可能较长,可能导致阻塞!
1.2. 在Spring Boot项目中读写Redis
1.2.1. 添加依赖
需要添加spring-boot-starter-data-redis
依赖项:
<!-- Spring Data Redis:读写Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
以上依赖项默认会连接localhost:6379
,并且无用户名、无密码,所以,当你的Redis符合此配置,则不需要在application.properties
中添加任何配置就可以直接编程。
如果需要显式的配置,各配置项的属性名分别为:
spring.redis.host
spring.redis.port
spring.redis.username
spring.redis.password
1.2.2. 配置RedisTemplate
在使用以上依赖项实现Redis编程时,需要使用到的工具类型为RedisTemplate
,调用此类的对象的方法,即可实现读写Redis中的数据。
在使用之前,应该先在配置类中使用@Bean
方法创建RedisTemplate
,并实现对RedisTemplate
的基础配置,则在项目的根包下创建config.RedisConfiguration
类:
package cn.tedu.csmall.product.config; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializer; import java.io.Serializable; /** * Redis的配置类 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Configuration public class RedisConfiguration { public RedisConfiguration() { log.info("加载配置类:RedisConfiguration"); } @Bean public RedisTemplate<String, Serializable> redisTemplate( RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(RedisSerializer.json()); return redisTemplate; } }
1.2.3. 使用ValueOperations
读写一般值数据
使用RedisTemplate
访问一般值(字符串、数值等)数据时,需要先获取ValueOperations
对象,再调用此对象的API进行数据操作。
例如:测试向Redis中写入一个字符串:
@Autowired RedisTemplate<String, Serializable> redisTemplate; @Test void testValueOpsSet() { ValueOperations<String, Serializable> ops = redisTemplate.opsForValue(); String key = "username"; String value = "admin"; ops.set(key, value); log.debug("已经向Redis中写入Key={}且Value={}的数据!", key, value); }
由于声明的RedisTemplate
的值的泛型是Serializable
,所以,从Redis中读取到的值的类型会是Serializable
接口类型。
例如:测试从Redis中读取此前写入的字符串:
@Test void testValueOpsGet() { ValueOperations<String, Serializable> ops = redisTemplate.opsForValue(); // 从Redis中读取数据 String key = "username"; Serializable value = ops.get(key); log.debug("已经从Redis中读取Key={}的数据,Value={}", key, value); }
由于配置RedisTemplate
时,使用的值序列化器是JSON(redisTemplate.setValueSerializer(RedisSerializer.json());
),所以,可以直接写入对象,会被自动处理为JSON格式的字符串。
另外,由于声明的RedisTemplate
的值的泛型是Serializable
,所以,写入的值的类型必须实现了Serializable
接口。
例如:测试向Redis中写入一个对象:
@Test void testValueOpsSetObject() { ValueOperations<String, Serializable> ops = redisTemplate.opsForValue(); String key = "brand1"; Brand brand = new Brand(); brand.setId(1L); brand.setName("大白象"); brand.setEnable(1); ops.set(key, brand); log.debug("已经向Redis中写入Key={}且Value={}的数据!", key, brand); }
例如:测试从Redis中读取此前写入的对象:
@Test void testValueOpsGetObject() { ValueOperations<String, Serializable> ops = redisTemplate.opsForValue(); String key = "brand1"; Serializable value = ops.get(key); log.debug("已经从Redis中读取Key={}的数据,Value={}", key, value); log.debug("读取到的值类型是:{}", value.getClass().getName()); if (value instanceof Brand) { Brand brand = (Brand) value; log.debug("将读取到的值类型转换为Brand类型,成功:{}", brand); } else { log.debug("读取到的值类型不是Brand类型,无法实现类型转换!"); } }
1.2.4. 查询Redis中已有的Key
直接调用RedisTemplate
的keys()
方法,即可查询当前Redis中有哪些Key。
例如:测试查询Redis中所有的Key:
@Test void testKeys() { Set<String> keys = redisTemplate.keys("*"); for (String key : keys) { log.debug("{}", key); } }
1.2.5. 删除Redis中的数据
删除数据时,不关心值的类型,只需要知道Key即可,所以,删除数据时直接调用RedisTemplate
的delete()
方法即可。
例如:测试删除Redis中的某个数据:
@Test void testDelete() { String key = "name"; Boolean result = redisTemplate.delete(key); log.debug("尝试删除Redis中Key={}的数据,操作结果为:{}", key, result); }
提示:RedisTemplate
的API中,还有批量删除的操作,例如(以下是RedisTemplate
的部分源代码):
public Long delete(Collection<K> keys) { if (CollectionUtils.isEmpty(keys)) { return 0L; } else { byte[][] rawKeys = this.rawKeys(keys); return (Long)this.execute((connection) -> { return connection.del(rawKeys); }, true); } }
1.2.6. 读写List列表数据
在操作List列表数据之前,需要先调用RedisTemplate
对象的opsForList()
方法,得到ListOperations
对象,再进行列表数据的操作。
在存入列表数据时,ListOperations
支持从左侧压栈来存入数据,或从右侧压栈来存入数据,这2者的区别如下图所示:
通常,从右侧压栈存入数据比较符合大多情况下的需求。
例如:测试写入列表数据:
@Test void testRightPushList() { // push:压栈(存入数据) // pop:弹栈(拿走数据) // 使用RedisTemplate向Redis存入List数据: // 1. 需要调用 opsForList() 得到 ListOperations 对象 // 2. ListOperations每次只能存入1个列表项数据 List<Brand> brands = new ArrayList<>(); for (int i = 1; i <= 8; i++) { Brand brand = new Brand(); brand.setId(i + 0L); brand.setName("测试品牌" + i); brands.add(brand); } ListOperations<String, Serializable> opsForList = redisTemplate.opsForList(); String key = "brandList"; for (Brand brand : brands) { opsForList.rightPush(key, brand); } }
调用ListOperations
对象的size()
方法即可获取列表的长度。
例如:获取列表的长度:
@Test void testListSize() { ListOperations<String, Serializable> opsForList = redisTemplate.opsForList(); String key = "brandList"; Long size = opsForList.size(key); log.debug("列表 key={} 的长度(元素数量)为:{}", key, size); }
如果需要读取数据,首先必须了解,Redis中的列表数据项即有正数的索引(下标),也有负数的索引(下标),正数的是从左侧第1位使用0
开始向右顺序编号,而负数的是从右侧第1位使用-1
并向左侧递减的编号,如下图所示:
使用ListOperations
的range()
方法可以获取列表的区间段子列表。
例如:测试获取列表数据:
@Test void testListRange() { ListOperations<String, Serializable> opsForList = redisTemplate.opsForList(); String key = "brandList"; long start = 0L; long end = -1L; List<Serializable> list = opsForList.range(key, start, end); for (Serializable serializable : list) { log.debug("列表项:{}", serializable); } }
1.3. 封装Redis的读写
在当前项目中,品牌、类别这些数据应该是适合使用Redis的!
以缓存品牌数据为例,可以先在根包下创建repository.IBrandCacheRepository
接口:
package cn.tedu.csmall.product.repository; import cn.tedu.csmall.product.pojo.vo.BrandListItemVO; import cn.tedu.csmall.product.pojo.vo.BrandStandardVO; import java.util.List; import java.util.Set; /** * 处理品牌缓存数据的存储接口 * * @author java@tedu.cn * @version 0.0.1 */ public interface IBrandCacheRepository { /** * 将某个品牌数据保存到Redis中 * * @param brandStandardVO 品牌数据 */ void save(BrandStandardVO brandStandardVO); /** * 从Redis中取出品牌数据 * * @param id 品牌id * @return 匹配的品牌数据,如果Redis中没有匹配的数据,将返回null */ BrandStandardVO get(Long id); /** * 将品牌列表数据保存到Redis中 * * @param brandList 品牌列表数据 */ void saveList(List<BrandListItemVO> brandList); /** * 从Redis中取出品牌列表数据 * * @return 品牌列表数据 */ List<BrandListItemVO> getList(); /** * 获取所有品牌缓存数据的Key * * @return 所有品牌缓存数据的Key */ Set<String> getAllKeys(); /** * 删除所有缓存的品牌数据 * * @param keys 所有缓存的品牌数据的Key的集合 * @return 删除的数据的数量 */ Long deleteAll(Set<String> keys); }
然后,在根包下创建repository.impl.BrandCacheRepositoryImpl
类,实现以上接口:
package cn.tedu.csmall.product.repository.impl; import cn.tedu.csmall.product.pojo.entity.Brand; import cn.tedu.csmall.product.pojo.vo.BrandListItemVO; import cn.tedu.csmall.product.pojo.vo.BrandStandardVO; import cn.tedu.csmall.product.repository.IBrandCacheRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.ListOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Repository; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Set; /** * 处理品牌缓存数据的存储实现类 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Repository public class BrandCacheRepositoryImpl implements IBrandCacheRepository { @Autowired private RedisTemplate<String, Serializable> redisTemplate; public static final String KEY_ITEM_PREFIX = "brand:item:"; public static final String KEY_LIST = "brand:list"; public BrandCacheRepositoryImpl() { log.debug("创建处理缓存的对象:BrandCacheRepositoryImpl"); } @Override public void save(BrandStandardVO brandStandardVO) { ValueOperations<String, Serializable> opsForValue = redisTemplate.opsForValue(); String key = getItemKey(brandStandardVO.getId()); opsForValue.set(key, brandStandardVO); } @Override public BrandStandardVO get(Long id) { ValueOperations<String, Serializable> opsForValue = redisTemplate.opsForValue(); String key = getItemKey(id); Serializable value = opsForValue.get(key); if (value != null) { if (value instanceof BrandStandardVO) { return (BrandStandardVO) value; } } return null; } @Override public void saveList(List<BrandListItemVO> brandList) { ListOperations<String, Serializable> opsForList = redisTemplate.opsForList(); for (BrandListItemVO brand : brandList) { opsForList.rightPush(KEY_LIST, brand); } } @Override public List<BrandListItemVO> getList() { ListOperations<String, Serializable> opsForList = redisTemplate.opsForList(); long start = 0L; long end = -1L; List<Serializable> list = opsForList.range(KEY_LIST, start, end); List<BrandListItemVO> brands = new ArrayList<>(); for (Serializable serializable : list) { brands.add((BrandListItemVO) serializable); } return brands; } @Override public Set<String> getAllKeys() { String allKeysPattern = "brand:*"; return redisTemplate.keys(allKeysPattern); } @Override public Long deleteAll(Set<String> keys) { return redisTemplate.delete(keys); } private String getItemKey(Long id) { return KEY_ITEM_PREFIX + id; } }
在src/test/java
的根包下创建repository.BrandCacheRepositoryTests
测试类,测试以上方法:
package cn.tedu.csmall.product.repository; import cn.tedu.csmall.product.pojo.vo.BrandStandardVO; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest public class BrandCacheRepositoryTests { @Autowired IBrandCacheRepository repository; @Test void testSave() { BrandStandardVO brandStandardVO = new BrandStandardVO(); brandStandardVO.setId(1L); brandStandardVO.setName("华为"); repository.save(brandStandardVO); log.debug("存入数据完成!"); } @Test void testGet() { Long id = 1L; BrandStandardVO brandStandardVO = repository.get(id); log.debug("获取数据完成:{}", brandStandardVO); } }
1.4. 缓存预热
缓存预热:当服务启动时,就将数据加载到缓存!
在Spring Boot项目中,可以使用组件类(添加了@Component
等注解的类)实现ApplicationRunner
接口,重写其中的run()
方法,此方法会在服务刚刚启动完成时自动执行!
package cn.tedu.csmall.product.preload; import cn.tedu.csmall.product.service.IBrandService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; @Slf4j @Component public class CachePreload implements ApplicationRunner { @Autowired private IBrandService brandService; public CachePreload() { log.debug("创建服务启动后自动执行任务的对象:CachePreload"); } @Override public void run(ApplicationArguments args) throws Exception { log.debug("CachePreload.run()"); brandService.loadBrandsToCache(); } }
1.5. 计划任务
计划任务:每间隔一段时间,或到了特定的时间点,就会自动执行的任务!
在Spring Boot项目中,组件类中添加了@Scheduled
注解的方法,就是计划任务的方法。
注意:在Spring Boot中,计划任务默认是禁用的,需要在配置类上添加@EnableScheudling
注解才可以启用!
在根包下创建config.ScheduleConfiguration
配置类,启用计划任务:
@Configuration @EnableScheduling public class ScheduleConfiguration { }
在根包下创建schedule.CacheSchedule
类:
package cn.tedu.csmall.product.schedule; import cn.tedu.csmall.product.service.IBrandService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * 处理缓存的计划任务类 * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Component public class CacheSchedule { @Autowired private IBrandService brandService; // 关于@Scheduled注解的配置 // 以下的各属性不可以同时配置 // >> fixedRate:每间隔多少毫秒执行1次 // >> fixedDelay:每执行结束后,过多少毫秒执行1次 // >> cron:使用1个字符串,字符串中包含6~7个值,各值之间使用空格分隔 // >> >> 各值分别表示:秒 分 时 日 月 周(星期) [年] // >> >> 例如:cron = "56 34 12 20 1 ? 2023",表示"2023年1月20日12:34:56秒将执行,无论这一天是星期几" // >> >> 以上各个值,均可使用星号(*)作为通配符,表示任意值 // >> >> 在“日”和“周”位置,还可以使用问号(?),表示不关心具体值 // >> >> 以上各个值,还可以使用“x/x”格式的值,例如在分钟位置使用 1/5,表示分钟值为1时执行,且每间隔5个单位(分钟)执行1次 @Scheduled(fixedRate = 1 * 60 * 60 * 1000) public void loadBrandsToCache() { log.debug("开始执行计划任务……"); brandService.loadBrandsToCache(); log.debug("本次计划任务执行完成!"); } }
2. Spring AOP
AOP:面向切面的编程。
AOP可以用于解决“在处理多种不同的业务时都需要执行相同的任务”的相关问题。
例如:统计每个业务方法的执行耗时。
首先,需要添加依赖项:
<!-- Spring Boot AOP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
在项目的根包下创建aop.TimerAspect
类:
package cn.tedu.csmall.product.aop; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Slf4j @Aspect @Component public class TimerAspect { // @Around(环绕):在匹配到的方法之前和之后都执行 @Around("execution(* cn.tedu.csmall.product.service.impl.*.*(..))") // 任何返回值类型 // 任何类 // 任何方法 // 无论参数数量多少 // 除了@Around以外,其实还有:@Before / @After / @AfterReturning / @AfterThrowing public Object a(ProceedingJoinPoint pjp) throws Throwable { log.debug("TimerAspect执行了切面方法……"); long start = System.currentTimeMillis(); // 执行以上@Around注解匹配到的方法 // 注意:不要try...catch异常 // 注意:必须获取返回值,并返回 Object result = pjp.proceed(); long end = System.currentTimeMillis(); log.debug("当前业务方法执行耗时:{}毫秒", end - start); return result; } }
附:Mybatis拦截器
生成gmt_create
、gmt_modified
字段值的Mybatis拦截器的代码:
package cn.tedu.csmall.product.interceptor.mybatis; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.executor.statement.StatementHandler; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.plugin.*; import java.lang.reflect.Field; import java.sql.Connection; import java.time.LocalDateTime; import java.util.Properties; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * <p>基于MyBatis的自动更新"最后修改时间"的拦截器</p> * * <p>需要SQL语法预编译之前进行拦截,则拦截类型为StatementHandler,拦截方法是prepare</p> * * <p>具体的拦截处理由内部的intercept()方法实现</p> * * <p>注意:由于仅适用于当前项目,并不具备范用性,所以:</p> * * <ul> * <li>拦截所有的update方法(根据SQL语句以update前缀进行判定),无法不拦截某些update方法</li> * <li>所有数据表中"最后修改时间"的字段名必须一致,由本拦截器的FIELD_MODIFIED属性进行设置</li> * </ul> * * @author java@tedu.cn * @version 0.0.1 */ @Slf4j @Intercepts({@Signature( type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class} )}) public class InsertUpdateTimeInterceptor implements Interceptor { /** * 自动添加的创建时间字段 */ private static final String FIELD_CREATE = "gmt_create"; /** * 自动更新时间的字段 */ private static final String FIELD_MODIFIED = "gmt_modified"; /** * SQL语句类型:其它(暂无实际用途) */ private static final int SQL_TYPE_OTHER = 0; /** * SQL语句类型:INSERT */ private static final int SQL_TYPE_INSERT = 1; /** * SQL语句类型:UPDATE */ private static final int SQL_TYPE_UPDATE = 2; /** * 查找SQL类型的正则表达式:INSERT */ private static final String SQL_TYPE_PATTERN_INSERT = "^insert\\s"; /** * 查找SQL类型的正则表达式:UPDATE */ private static final String SQL_TYPE_PATTERN_UPDATE = "^update\\s"; /** * 查询SQL语句片段的正则表达式:gmt_modified片段 */ private static final String SQL_STATEMENT_PATTERN_MODIFIED = ",\\s*" + FIELD_MODIFIED + "\\s*="; /** * 查询SQL语句片段的正则表达式:gmt_create片段 */ private static final String SQL_STATEMENT_PATTERN_CREATE = ",\\s*" + FIELD_CREATE + "\\s*[,)]?"; /** * 查询SQL语句片段的正则表达式:WHERE子句 */ private static final String SQL_STATEMENT_PATTERN_WHERE = "\\s+where\\s+"; /** * 查询SQL语句片段的正则表达式:VALUES子句 */ private static final String SQL_STATEMENT_PATTERN_VALUES = "\\)\\s*values?\\s*\\("; @Override public Object intercept(Invocation invocation) throws Throwable { // 日志 log.debug("准备拦截SQL语句……"); // 获取boundSql,即:封装了即将执行的SQL语句及相关数据的对象 BoundSql boundSql = getBoundSql(invocation); // 从boundSql中获取SQL语句 String sql = getSql(boundSql); // 日志 log.debug("原SQL语句:{}", sql); // 准备新SQL语句 String newSql = null; // 判断原SQL类型 switch (getOriginalSqlType(sql)) { case SQL_TYPE_INSERT: // 日志 log.debug("原SQL语句是【INSERT】语句,准备补充更新时间……"); // 准备新SQL语句 newSql = appendCreateTimeField(sql, LocalDateTime.now()); break; case SQL_TYPE_UPDATE: // 日志 log.debug("原SQL语句是【UPDATE】语句,准备补充更新时间……"); // 准备新SQL语句 newSql = appendModifiedTimeField(sql, LocalDateTime.now()); break; } // 应用新SQL if (newSql != null) { // 日志 log.debug("新SQL语句:{}", newSql); reflectAttributeValue(boundSql, "sql", newSql); } // 执行调用,即拦截器放行,执行后续部分 return invocation.proceed(); } public String appendModifiedTimeField(String sqlStatement, LocalDateTime dateTime) { Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_MODIFIED, Pattern.CASE_INSENSITIVE); if (gmtPattern.matcher(sqlStatement).find()) { log.debug("原SQL语句中已经包含gmt_modified,将不补充添加时间字段"); return null; } StringBuilder sql = new StringBuilder(sqlStatement); Pattern whereClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_WHERE, Pattern.CASE_INSENSITIVE); Matcher whereClauseMatcher = whereClausePattern.matcher(sql); // 查找 where 子句的位置 if (whereClauseMatcher.find()) { int start = whereClauseMatcher.start(); int end = whereClauseMatcher.end(); String clause = whereClauseMatcher.group(); log.debug("在原SQL语句 {} 到 {} 找到 {}", start, end, clause); String newSetClause = ", " + FIELD_MODIFIED + "='" + dateTime + "'"; sql.insert(start, newSetClause); log.debug("在原SQL语句 {} 插入 {}", start, newSetClause); log.debug("生成SQL: {}", sql); return sql.toString(); } return null; } public String appendCreateTimeField(String sqlStatement, LocalDateTime dateTime) { // 如果 SQL 中已经包含 gmt_create 就不在添加这两个字段了 Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_CREATE, Pattern.CASE_INSENSITIVE); if (gmtPattern.matcher(sqlStatement).find()) { log.debug("已经包含 gmt_create 不再添加 时间字段"); return null; } // INSERT into table (xx, xx, xx ) values (?,?,?) // 查找 ) values ( 的位置 StringBuilder sql = new StringBuilder(sqlStatement); Pattern valuesClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_VALUES, Pattern.CASE_INSENSITIVE); Matcher valuesClauseMatcher = valuesClausePattern.matcher(sql); // 查找 ") values " 的位置 if (valuesClauseMatcher.find()) { int start = valuesClauseMatcher.start(); int end = valuesClauseMatcher.end(); String str = valuesClauseMatcher.group(); log.debug("找到value字符串:{} 的位置 {}, {}", str, start, end); // 插入字段列表 String fieldNames = ", " + FIELD_CREATE + ", " + FIELD_MODIFIED; sql.insert(start, fieldNames); log.debug("插入字段列表{}", fieldNames); // 定义查找参数值位置的 正则表达 “)” Pattern paramPositionPattern = Pattern.compile("\\)"); Matcher paramPositionMatcher = paramPositionPattern.matcher(sql); // 从 ) values ( 的后面位置 end 开始查找 结束括号的位置 String param = ", '" + dateTime + "', '" + dateTime + "'"; int position = end + fieldNames.length(); while (paramPositionMatcher.find(position)) { start = paramPositionMatcher.start(); end = paramPositionMatcher.end(); str = paramPositionMatcher.group(); log.debug("找到参数值插入位置 {}, {}, {}", str, start, end); sql.insert(start, param); log.debug("在 {} 插入参数值 {}", start, param); position = end + param.length(); } if (position == end) { log.warn("没有找到插入数据的位置!"); return null; } } else { log.warn("没有找到 ) values ("); return null; } log.debug("生成SQL: {}", sql); return sql.toString(); } @Override public Object plugin(Object target) { // 本方法的代码是相对固定的 if (target instanceof StatementHandler) { return Plugin.wrap(target, this); } else { return target; } } @Override public void setProperties(Properties properties) { // 无须执行操作 } /** * <p>获取BoundSql对象,此部分代码相对固定</p> * * <p>注意:根据拦截类型不同,获取BoundSql的步骤并不相同,此处并未穷举所有方式!</p> * * @param invocation 调用对象 * @return 绑定SQL的对象 */ private BoundSql getBoundSql(Invocation invocation) { Object invocationTarget = invocation.getTarget(); if (invocationTarget instanceof StatementHandler) { StatementHandler statementHandler = (StatementHandler) invocationTarget; return statementHandler.getBoundSql(); } else { throw new RuntimeException("获取StatementHandler失败!请检查拦截器配置!"); } } /** * 从BoundSql对象中获取SQL语句 * * @param boundSql BoundSql对象 * @return 将BoundSql对象中封装的SQL语句进行转换小写、去除多余空白后的SQL语句 */ private String getSql(BoundSql boundSql) { return boundSql.getSql().toLowerCase().replaceAll("\\s+", " ").trim(); } /** * <p>通过反射,设置某个对象的某个属性的值</p> * * @param object 需要设置值的对象 * @param attributeName 需要设置值的属性名称 * @param attributeValue 新的值 * @throws NoSuchFieldException 无此字段异常 * @throws IllegalAccessException 非法访问异常 */ private void reflectAttributeValue(Object object, String attributeName, String attributeValue) throws NoSuchFieldException, IllegalAccessException { Field field = object.getClass().getDeclaredField(attributeName); field.setAccessible(true); field.set(object, attributeValue); } /** * 获取原SQL语句类型 * * @param sql 原SQL语句 * @return SQL语句类型 */ private int getOriginalSqlType(String sql) { Pattern pattern; pattern = Pattern.compile(SQL_TYPE_PATTERN_INSERT, Pattern.CASE_INSENSITIVE); if (pattern.matcher(sql).find()) { return SQL_TYPE_INSERT; } pattern = Pattern.compile(SQL_TYPE_PATTERN_UPDATE, Pattern.CASE_INSENSITIVE); if (pattern.matcher(sql).find()) { return SQL_TYPE_UPDATE; } return SQL_TYPE_OTHER; } }
注册Mybatis拦截器的代码(需定义在配置类中):
@Autowired private List<SqlSessionFactory> sqlSessionFactoryList; @PostConstruct public void addInterceptor() { InsertUpdateTimeInterceptor interceptor = new InsertUpdateTimeInterceptor(); for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) { sqlSessionFactory.getConfiguration().addInterceptor(interceptor); } }