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/Router2.xYIn package.jsonN
接下来,会自动完成项目的创建,在创建结束后,如果信息中没有错误,且出现了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.vuesrc/views/AboutView.vuesrc/views/HomeView.vuesrc/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-teacherGroup:cn.teduArtifact:csmall-productPackage:cn.tedu.csmall.productJava版本:
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,如果参数类型是数组(或可变参数),则此属性值为arrayitem:遍历过程中的每个元素的名称,是自定义的名称,并且,在<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方法名称:自定义
参数列表:按需设计,可以直接将所需的请求参数声明为方法的参数,或者,将多个请求参数封装到自定义类型中,并使用自定义类型作为处理请求的方法的参数,各参数可以按照期望的数据类型进行设计,如果有多个参数,不区分先后顺序
关于接收请求参数:
如果客户端正确的按照名称提交了请求参数,则服务器端可以正常接收到,如果不是字符串类型,会尝试自动的转换数据类型,如果转换失败,将出现错误,且响应
400http://localhost:9080/add-new?name=小米&pinyin=xiaomi
如果客户端提交了对应的请求参数名称,却没有提交值,则服务器端默认视为空字符串,如果请求参数是其它类型(例如
Integer),框架会放弃转换类型,仍保持为nullhttp://localhost:9080/add-new?name=&pinyin=
如果客户端没有提交对应名称的请求参数,则服务器端接收到的为
nullhttp://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@PostMappingPutMappingDeleteMappingPatchMapping
以上这些都相当于是限制了请求方式的@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、jjwtServiceCode:补充新的枚举值LoginPrincipalapplication.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.hostspring.redis.portspring.redis.usernamespring.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);
}
}