项目笔记 全

发布于:2022-11-09 ⋅ 阅读:(14) ⋅ 点赞:(0) ⋅ 评论:(0)

1. Vue脚手架

Vue脚手架是一个开发基于Vue框架的前端项目的软件。

Vue脚手架的项目是“单页面”的,也就是在这样的项目,只有1个HTML页面,它认为页面是由多个模块组成的,各个模块都是可以随时替换的,从而显示出不同的页面效果。

2. 关于Node.js

首先,需要安装Node.js软件,下载得到安装后直接安装即可,安装过程中没有需要特别关注的部分。

安装完成后,可以通过npm -v命令查看安装的npm软件的版本,顺便检查是否安装成功。

npm -v

以上安装Node.js主要是为了使用npm(Node Package Management),通常,应该修改“npm源”,以使得其下载软件将从配置的站点进行下载:

npm config set registry https://registry.npm.taobao.org

执行以上命令默认没有任何反馈,可以通过以下命令查看配置的值:

npm config get registry

3. 安装Vue CLI

Vue脚手架软件称之为:Vue CLI

通过npm命令可以安装此软件:

npm install -g @vue/cli

以上安装过程可能会提示WARN字样,可以无视,只要以上命令可以正常执行结束,并没有任何ERR字样的提示,即为成功!

安装Vue CLI主要是为了创建项目并管理项目(例如启动项目)。

4. 创建项目

首先,准备用于存放Vue脚手架项目的文件夹(任何你找得到的地方,不推荐操作系统的敏感文件夹),并且,在命令提示符窗口(或者终端窗口)中进入此文件夹。

然后,通过vue命令(来自前一步安装的@vue/cli)来创建项目,命令的基本格式是vue create 项目名称,例如:

vue create jsd2205-web-client-teacher

注意:执行创建项目的命令后,可能会有一点卡顿,此时不可以反复按回车键!

注意:如果创建项目的过程中选择错误,可以通过按下CTRL + C终止,并重新创建!

在创建选项中,需要选择”

  • Manually select features:手动选择功能

  • Babel / Vuex / Router

  • 2.x

  • Y

  • In package.json

  • N

接下来,会自动完成项目的创建,在创建结束后,如果信息中没有错误,且出现了Successfully created project 项目名称.的字样,则创建成功!

如果创建项目失败,应该先删除已经创建的项目的文件夹,然后检查npm源,确认无误后,再次执行vue create 项目名称命令来重新创建项目。

5. 启动项目

当项目创建成功后,可以通过IntelliJ IDEA打开此项目。

在IntelliJ IDEA中,打开Terminal窗口,默认的提示符的位置应该就是当前项目的文件夹,在此处执行命令即可启动项目:

npm run serve

当启动成功后,可以看到Compiled successfully in 7859ms字样。

提示:启动成功后,会提示访问此项目的URL,例如:http://localhost:8080,根据当前计算机的网络配置不同,接下来的其它URL提示可能不同,均可无视。

在浏览器中,可以通过http://localhost:8080来访问此项目。

6. 关于视图文件

在Vue CLI项目中,默认的视图文件(页面文件)在src/views文件夹中。默认的项目中,负责显示的是:

  • src/App.vue

  • src/views/AboutView.vue

  • src/views/HomeView.vue

  • src/components/HelloWorld.vue

每个视图文件都是以.vue作为扩展名的,每个文件都可以有3个根标签:

  • <template>:用于设计页面,例如页面中有哪些标签、显示什么内容等,注意:此标签的直接子级标签必须有且仅有1个!

  • <style>:【可选】用于设计样式,此标签下的都是CSS代码

  • <script>:【可选】用于编写JavaScript代码

7. 关于路由

Vue CLI的“路由”是一种配置了“访问路径”与“视图组件”的对应关系的对象!

路由是通过src/router/index.js文件的routes常量进行配置的,默认的代码是:

const routes = [
  {
    path: '/about',
    name: 'home',
    component: HomeView
  },
  {
    path: '/',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AdminIndexView.vue')
  }
]

以上routes常量的类型是数组,数组的元素是一个个的对象,以上代码中各对象配置了:

  • path:访问路径

  • name:名称,并不是必须的

  • component:视图组件,配置此属性时,视图组件有2种加载模式,分别是“预加载”和“懒加载”,其中,预加载需要通过当前文件顶部的import语句导入视图,而懒加载则是直接配置此属性时,直接使用箭头函数加上import函数进行配置即可,通常,在每个项目中,应该有且仅有1个视图是预加载的

【练习】在项目中添加“登录”页面(不需要具体的设计页面内容)

操作步骤:

  • 创建新的视图文件

    • src/views下创建LoginView.vue,并随意设计页面内容

  • 配置此视图的路由

    • src/router/index.js中的routes数组常量中添加新的数组元素,配置path值为/login,配置component值为() => import('../views/LoginView.vue')

    • 提示:完成此步骤后,就已经可以通过 http://localhost:8080/login 访问到新添加的登录页面

  • App.vue中添加新的链接(从功能上来说,并不是必须的)

    • 参考原有代码进行添加即可

8. 关于<router-view>

在设计视图时,使用<router-view>则表示:当前视图文件(例如默认的App.vue)不处理此部分的显示,将根据访问路径来决定由哪个视图进行显示(取决于src/router/index.jsroutes常量的配置)!

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次晚课安排

  1. 数据库与数据表设计规范

  2. 消息摘要算法与密码加密

  3. Mybatis的#{}与${}占位符

  4. Mybatis的缓存机制

  5. @Autowired的自动装配机制

  6. Spring MVC拦截器

1. Vue CLI中的嵌套路由

通常,在设计视图时,在App.vue中不会设计页面元素,只是添加一个<router-view/>即可!

在其它的视图中,设计的目标效果中可能存在多“页面”中相同的部分,反之,也可以理解为“某个页面的某个区域(不同的部分)是动态变化的”,则对应的区域(某个页面中会变化的部分)就可以设计为<router-view/>,同时,由于当前视图本身也是显示在App.vue设计的<router-view/>中的,就出现了“嵌套路由”!

当项目中多个视图中都使用到了<router-view/>时,某个视图组件到底显示在哪个<router-view/>,取决于路由的配置:

  • 如果某个视图的路由配置在src/router/index.jsroutes常量中,则此视图将显示在App.vue<router-view/>

    const routes = [
      {
        path: '/',
        component: HomeView
      },
      // 以下AboutView将显示在App.vue的<router-view/>中
      {
         path: '/about',
         component: () => import('../views/AboutView.vue')
       }
    ];
  • 如果某个视图的路由配置在src/router/index.jsroutes常量中的某个路由配置的子级,则此视图将显示在其父级路由的视图中

    const routes = [
      {
        path: '/',
        component: HomeView,
        // 以下AboutView将显示在HomeView的<router-view/>中
        children: [
          {
            path: '/about',
            component: () => import('../views/AdminIndexView.vue')
          }
        ]
      }
    ];

2. Vue CLI项目的启动端口

Vue CLI项目在启动时,默认将尝试占用8080端口,如果此端口已经被占用,则会顺延后一位端口号,即尝试占用8081……当然,如果8081也被占用,则会继续顺延至8082,以此类推。

建议为Vue CLI项目显式的指定端口,避免发生冲突,或多次启动时的端口号不一致。需要在package.json,原本有(通常在第6行):

"serve": "vue-cli-service serve",

在以上属性值的末尾添加--port 端口号,例如:

"serve": "vue-cli-service serve --port 8888",

3. 在Vue CLI项目中使用axios

首先,需要安装axios:

npm i axios -S

然后,需要在main.js中导入,并声明为Vue对象的成员:

import axios from 'axios';

Vue.prototype.axios = axios;

使用axios发请求并处理响应的代码示例:

let url = 'http://localhost:8080/login';
console.log('请求路径:' + url);
console.log('请求参数:');
console.log(this.form);

this.axios.post(url, this.form).then((response) => {
    console.log('服务器端的响应:');
    console.log(response);
    let responseBody = response.data;
    if (responseBody == 1) {
      // 登录成功
      this.$message({
        message: '登录成功!(暂不跳转)',
        type: 'success'
      });
    } else if (responseBody == 2) {
      // 用户名错误
      this.$message.error('登录失败!用户名不存在!');
    } else {
      // 密码错误
      this.$message.error('登录失败!密码错误!');
    }
});

注意:在then()内部,必须使用箭头函数(() => {}),不可以使用一般的function函数!

4. 关于跨域访问

默认情况下,不允许执行跨域访问(从某一台服务器向另一台服务器发起异步请求),如果出现跨域访问,在浏览器的错误提示大致如下:

Access to XMLHttpRequest at 'http://localhost:8080/login' from origin 'http://localhost:9000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

提示:以上错误的关键字是:CORS

在基于Spring Boot开发的服务器端项目中,添加一个Spring MVC的配置类即可允许跨域访问!

可以让Spring Boot项目的启动类实现WebMvcConfigurer接口,并在此类中添加以下代码:

@Override
public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
            .allowedOriginPatterns("*")
            .allowedHeaders("*")
            .allowedMethods("*")
            .allowCredentials(true)
            .maxAge(3600);
}

完成后,重启服务器端项目,然后,使用客户端再次发出请求,此请求可以正常发出(浏览器不会报告错误),且服务器端可以接收到请求参数。

1. 关于CSMall项目

CSMall项目:酷鲨商城,是一个定位为自营性质的电商平台项目。

CSMall Product项目:是整个项目的一部分,主要处理后台商品的数据管理。

商品相关的数据主要有:

  • 品牌

  • 类别

  • 图片与相册

  • 属性与属性模板

  • SPU与SKU

  • 以上数据的关联数据

2. 创建项目

在IntelliJ IDEA中,使用Spring Boot项目的创建向导来创建项目,相关参数:

  • 项目名称:jsd2205-csmall-product-teacher

  • Groupcn.tedu

  • Artifactcsmall-product

  • Packagecn.tedu.csmall.product

  • Java版本:8

在创建过程中,可以无视Spring Boot版本,且可以不勾选任何依赖项。

当创建成功后,在pom.xml中将版本指定为2.5.9

3. 数据库与数据表

在终端下,登录MySQL控制台,创建mall_pms数据库:

CREATE DATABASE mall_pms;

接下来,在IntelliJ IDEA打开项目,并配置Database面板,连接到mall_pms数据库,并通过mall_pms.sql(老师下发的文件)中的代码来创建所需的数据表(将mall_pms.sql中的所有代码全部复制到Database面板的Console中,全选并执行)。

至此,本项目所需的数据库和数据表创建完成!

关于配置Database面板的视频教程:IDEA-配置可视化数据库视图

作业

编写以下需求对应的SQL语句(使用记事本保存):

  • pms_brand表中插入数据

  • 根据id删除pms_brand表中的某1条数据

  • 根据若干个id批量删除pms_brand表中的数据

    • 如果没有足够多的测试数据,可以事先添加

  • 根据id修改pms_brand表中的name字段的值

  • 统计pms_brand表中的数据的数量

  • 根据name查询pms_brand表中的数据

  • 根据id查询pms_brand表中的数据

  • 查询pms_brand表中所有的数据

前次作业

编写以下需求对应的SQL语句(使用记事本保存):

  • pms_brand表中插入数据

    • INSERT INTO pms_brand (name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
  • 根据id删除pms_brand表中的某1条数据

    • DELETE FROM pms_brand WHERE id=?;
  • 根据若干个id批量删除pms_brand表中的数据

    • DELETE FROM pms_brand WHERE id=? OR id=? …… OR id=?;
    • DELETE FROM pms_brand WHERE id IN (?, ?, .... ?);
  • 根据id修改pms_brand表中的name字段的值

    • UPDATE pms_brand SET name=? WHERE id=?;
  • 统计pms_brand表中的数据的数量

    • SELECT count(*) FROM pms_brand;
    • 阿里巴巴Java开发手册:
      【强制】不要使用 count(列名)或 count(常量)来替代 count(*),count(*)是 SQL92 定义的
      标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。
      说明:count(*)会统计值为 NULL 的行,而 count(列名)不会统计此列为 NULL 值的行。
  • 根据name查询pms_brand表中的数据

    • SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand WHERE name=?;
    • 阿里巴巴Java开发手册:
      【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。
      说明:1)增加查询分析器解析成本。2)增减字段容易与 resultMap 配置不一致。
  • 根据id查询pms_brand表中的数据

    • SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand WHERE id=?;
  • 查询pms_brand表中所有的数据

    • SELECT id, name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified FROM pms_brand ORDER BY id

2. 添加数据库编程的相关依赖

首先,在pom.xml中添加数据库编程的必要依赖项:

<!-- Mybatis整合Spring Boot的依赖项 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>
<!-- MySQL的依赖项 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

在Spring Boot项目中,一旦添加以上依赖项,项目将不可以正常启动!

Spring Boot项目默认是开启了自动配置的,当添加了以上数据库编程的依赖项时,就会自动从配置文件中读取连接数据库的URL参数值,如果当前尚未配置此参数,就会启动失败!

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class

src/main/resources下的application.properties是Spring Boot项目的默认主配置文件,当启动项目时,会自动从此文件中读取相关配置,为保证能够正常的使用数据库编程,需要在此文件中添加配置:

spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

完成后,首先,在src/test/java下默认的包中,找到默认的测试类,先执行默认的contextLoads()测试方法,此测试应该能够通过,如果不能,则表示IDEA出错、依赖项出错。

contextLoads()通过后,在此测试类中补充:

@Autowired
DataSource dataSource;

@Test
void testGetConnection() throws Exception {
    dataSource.getConnection();
}

注意:以上使用到的DataSourcejavax.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.usernamespring.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>标签,必须配置resultTyperesultMap这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

作业

  1. 写出所有数据表的实体类

  2. 创建新的接口、新的XML文件,实现:插入类别数据、插入相册数据,完成后,使用新的测试类进行测试

1. 插入数据时获取自动编号的id

如果某张表的id被设计为自动编号的,在插入数据时,还可以获取自动编号的id值!

在配置SQL的<insert>标签上,配置useGeneratedKeyskeyProperty属性即可:

<insert id="insert" useGeneratedKeys="true" keyProperty="id">
	此处省略插入数据的SQL语句
</insert>

以上配置中,useGeneratedKeys="true"表示“需要获取自动编号的id值”,而keyProperty="id"表示将id值保存到参数对象(调用插入数据的方法时使用的参数)的id属性中。

2. 根据id删除数据

删除数据的开发步骤与插入数据的相同,只是配置SQL时使用的标签应该为<delete>

【需求】根据id删除某个品牌数据。

则在BrandMapper接口中添加抽象方法:

int deleteById(Long id);

然后,在BrandMapper.xml中配置SQL:

<delete id="deleteById">
    DELETE FROM pms_brand WHERE id=#{id}
</delete>

最后,在BrandMapperTests中编写并执行测试:

@Test
void testDeleteById() {
    Long id = 1L;
    int rows = mapper.deleteById(id);
    System.out.println("根据id删除品牌完成,受影响的行数=" + rows);
}

3. 动态SQL:根据若干个id删除数据

动态SQL:允许根据参数不同,来生成不同的SQL语句。

【需求】根据若干个id删除品牌数据

需要执行的SQL语句大致是:

DELETE FROM pms_brand WHERE id=? OR id=? …… OR id=?;
DELETE FROM pms_brand WHERE id IN (?, ?, .... ?);

在以上SQL中,需要被删除的数据的id的数量是不确定的!

在实现此需求时,抽象方法可以设计为:

int deleteByIds(List<Long> ids);
int deleteByIds(Long[] ids);
int deleteByIds(Long... ids); // deleteByIds(1,2,3,4,5)

在配置SQL时,需要使用到<foreach>标签对参数进行遍历:

<!-- int deleteByIds(List<Long> ids); -->
<delete id="deleteByIds">
    DELETE FROM pms_brand WHERE id IN (
    	<foreach collection="list" item="id" separator=",">
            #{id}
    	</foreach>
    )
</delete>

关于<foreach>标签的属性:

  • collection:表示被遍历的参数对象,当抽象方法的参数只有1个时,如果参数类型是List,则此属性值为list,如果参数类型是数组(或可变参数),则此属性值为array

  • item:遍历过程中的每个元素的名称,是自定义的名称,并且,在<foreach>标签内部,使用#{}时的名称也就是此属性的值(此处自定义的名称)

  • separator:遍历过程中在值之前添加的分隔符号

完成后,在BrandMapperTests中编写并执行测试:

@Test
void testDeleteByIds() {
    List<Long> ids = new ArrayList<>();
    ids.add(2L);
    ids.add(6L);
    ids.add(7L);
    
    int rows = mapper.deleteByIds(ids);
    System.out.println("根据id批量删除品牌完成,受影响的行数=" + rows);
}

4. 动态SQL:修改数据

【需求】根据id修改品牌的数据,参数中传入了哪些属性,就修改对应的那些字段的值

需要执行的SQL语句大致是:

update pms_brand set name=?, pinyin=?, logo=?, description=? ....(修改其它字段的值) where id=?

则抽象方法可以设计为:

int updateById(Brand brand);

然后,配置SQL语句:

<update id="updateById">
    UPDATE
        pms_brand
    <set>
        <if test="name != null">
            name=#{name},
        </if>
        <if test="pinyin != null">
            pinyin=#{pinyin},
        </if>
        <if test="logo != null">
            logo=#{logo},
        </if>
        <if test="description != null">
            description=#{description},
        </if>
        <if test="keywords != null">
            keywords=#{keywords},
        </if>
        <if test="sort != null">
            sort=#{sort},
        </if>
        <if test="sales != null">
            sales=#{sales},
        </if>
        <if test="productCount != null">
            product_count=#{productCount},
        </if>
        <if test="commentCount != null">
            comment_count=#{commentCount},
        </if>
        <if test="positiveCommentCount != null">
            positive_comment_count=#{positiveCommentCount},
        </if>
        <if test="enable != null">
            enable=#{enable},
        </if>
    </set>
    WHERE
        id=#{id}
</update>

以上代码中,使用到了2个标签:

  • <if>:用于对参数的值进行判断,从而决定SQL语句中是否包含<if>子级的SQL片段

  • <set>:用于取代SET关键字,通常结合若干个<if>一起使用,可以去除更新的SQL语句中的字段列表与值最后多余的逗号

注意:<if>标签并没有匹配的类似else的标签,如果需要实现类似Java代码中的if...else...的效果,可以:

<if test="某条件">
	满足条件时的SQL片段
</if>
<if test="与以上完全相反的条件">
	满足本if时的SQL片段
</if>

以上示例可以实现类似if...else...的效果,但是,更像是if...与另一个if...,本质上是执行了2次判断的!

另外,还可以使用<choose>系列标签,真正的实现类似if...else...的效果:

<choose>
    <when test="判断条件">
    	满足条件时的SQL片段
    </when>
    <otherwise>
    	不满足条件时的SQL片段
    </otherwise>
</choose>

5. 统计查询

【需求】统计品牌表中的数据的数量

需要执行的SQL语句大致是:

SELECT count(*) FROM pms_brand;

BrandMapper接口中添加抽象方法:

int count();

BrandMapper.xml中配置SQL语句:

<select id="count" resultType="int">
    SELECT count(*) FROM pms_brand
</select>

6. 查询最多1条数据

【需求】根据id查询品牌详情

需要执行的SQL语句大致是:

SELECT 
    id, name, pinyin, logo, description, 
    keywords, sort, sales, product_count, comment_count, 
    positive_comment_count, enable 
FROM 
	pms_brand 
WHERE 
	id=?

通常,在处理查询时,并不建议使用实体类型作为查询结果,因为绝大部分查询都不需要查询表中所有的字段,如果使用实体类型,必然导致查询结果对象调用某些Getter时得到的结果会是null,并且,这些Getter的返回结果永远会是null

建议使用其它的POJO类型作为封装查询结果的类型!

常见的POJO:

  • DO:Data Object

  • DTO:Data Transfer Object

  • VO:View Object / Value Object

关于POJO的使用:

阿里巴巴Java开发手册
【参考】
领域模型命名规约
1) 数据对象:xxxDO,xxx 即为数据表名。
2) 数据传输对象:xxxDTO,xxx 为业务领域相关的名称。
3) 展示对象:xxxVO,xxx 一般为网页名称。
4) POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。

另外:

阿里巴巴Java开发手册
【强制】
类名使用 UpperCamelCase 风格,必须遵从驼峰形式,但以下情形例外:DO / BO / DTO / VO / AO
正例:MarcoPolo / UserDO / XmlService / TcpUdpDeal / TaPromotion
反例:macroPolo / UserDo / XMLService / TCPUDPDeal / TAPromotion

此次查询时,应该在项目的根包下,创建pojo.vo.BrandStandardVO类:

@Data
public class BrandStandardVO implements Serializable {
	private Long id;
    private String name;
    private String pinyin;
    private String logo;
    private String description;
    private String keywords;
    private Integer sort;
    private Integer sales;
    private Integer productCount;
    private Integer commentCount;
    private Integer positiveCommentCount;
    private Integer enable;
}

BrandMapper接口中添加抽象方法:

BrandStandardVO getStandardById(Long id);

Mybatis会自动的将查询到的结果集中的数据封装到定义的返回结果类型中,但是,在默认情况下,只能处理列名(Column)与属性名(Property)一致的情况!在规范的软件开发中,推荐使用<resultMap>来配置列与属性的映射关系:

<resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandStandardVO">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <result column="product_count" property="productCount"/>
    <result column="comment_count" property="commentCount"/>
    <result column="positive_comment_count" property="positiveCommentCount"/>
</resultMap>

关于以上<resultMap>的配置:

  • id属性:是自定义的名称,在<select>标签中的resultMap属性的值就是对应的<resultMap>id

  • type属性:用于封装查询结果的类的全限定名

  • <id>子标签:用于配置主键的列、属性的映射关系

  • <result>子标签:用于配置普通(不是主键,也不是一对多、多对多的关联查询)的列、属性的映射关系

另外,还建议使用<sql>标签封装查询的字段列表,此标签需要与<include>标签配合使用,例如:

<select id="getStandardById" resultMap="StandardResultMap">
    SELECT
        <include refid="StandardQueryFields"/>
    FROM
        pms_brand
    WHERE
        id=#{id}
</select>

<sql id="StandardQueryFields">
    <if test="true">
        id, name, pinyin, logo, description,
        keywords, sort, sales, product_count, comment_count,
        positive_comment_count, enable
    </if>
</sql>

7. 关于IntelliJ IDEA错误的预判

问题1:使用Lombok后,不会自动提示Setter和Getter,且使用相当方法后提示错误

在IntelliJ IDEA中安装Lombok插件即可。

问题2:使用Mybatis时,尝试自动装配Mapper接口的对象时提示错误

  • 【推荐】在Mapper接口上添加@Repository注解即可

  • 【不推荐】使用@Resource替换@Autowired

另外,不使用@MapperScan,而是在每个Mapper接口上使用@Mapper也可以解决此问题!

问题3:在配置SQL的XML中,使用<sql>节点封装字段列表时提示错误

  • 【不推荐】在IntelliJ IDEA中进行配置,不检查SQL

  • 【推荐】使用某种合法的、不影响当前代码运行的代码片段,“骗”过IntelliJ IDEA即可,例如:

    <sql id="StandardQueryFields">
        <if test="true">
            id, name, pinyin, logo, description,
            keywords, sort, sales, product_count, comment_count,
            positive_comment_count, enable
        </if>
    </sql>

作业

实现:

  • 统计类别的数量

  • 统计相册的数量

  • 根据id查询类别详情

  • 根据id查询相册详情

1. 查询列表

相对查询最多1个数据,查询列表的区别在于:必须使用List类型作为抽象方法的返回值类型,另外,在配置<select>时,无论使用resultType还是resultMap,在指定封装返回结果的类型时,仍指定List中的元素类型即可。

【需求】查询品牌列表,暂不考虑分页,结果按照sort降序排列、id升序(降序)。

需要执行的SQL语句大致是:

select 
	id, name, pinyin, logo, description, 
	keywords, sort, sales, product_count, comment_count, 
	positive_comment_count, enable
from pms_brand order by sort desc, id

通常,查询列表时,与查询单个数据使用的VO类应该是不同的。

则在项目的根包下创建pojo.vo.BrandListItemVO类,在类中声明与以上字体列表匹配的属性:

package cn.tedu.csmall.product.pojo.vo;

import lombok.Data;

import java.io.Serializable;

@Data
public class BrandListItemVO implements Serializable {

    private Long id;
    private String name;
    private String pinyin;
    private String logo;
    private String description;
    private String keywords;
    private Integer sort;
    private Integer sales;
    private Integer productCount;
    private Integer commentCount;
    private Integer positiveCommentCount;
    private Integer enable;

}

BrandMapper.java接口中添加抽象方法:

/**
 * 查询品牌列表
 *
 * @return 品牌列表,如果没有匹配的品牌,将返回长度为0的列表
 */
List<BrandListItemVO> list();

BrandMapper.xml中配置SQL:

<select id="list" resultMap="ListResultMap">
    SELECT
        <include refid="ListQueryFields"/>
    FROM
        pms_brand
    ORDER BY
        sort DESC, id
</select>

<sql id="ListQueryFields">
    <if test="true">
        id, name, pinyin, logo, description,
        keywords, sort, sales, product_count, comment_count,
        positive_comment_count, enable
    </if>
</sql>

<resultMap id="ListResultMap"
           type="cn.tedu.csmall.product.pojo.vo.BrandListItemVO">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <result column="product_count" property="productCount"/>
    <result column="comment_count" property="commentCount"/>
    <result column="positive_comment_count" property="positiveCommentCount"/>
</resultMap>

完成后,在BrandMapperTests中编写并执行测试:

@Test
void testList() {
    List<BrandListItemVO> list = mapper.list();
    System.out.println("查询品牌列表,查询结果中的数据的数量:" + list.size());
    for (BrandListItemVO brand : list) {
        System.out.println(brand);
    }
}

2. 关于SLF4j日志

在Spring Boot项目中,在spring-boot-starter依赖项中,默认已经集成了SLF4j日志框架。

在添加了Lombok依赖项后,可以在需要输出日志的类上添加@Slf4j注解,则Lombok框架会在编译期自动添加名为log的日志变量。

日志的显示级别,根据日志信息内容的重要程度,从低到高依次为:

  • trace:跟踪信息

  • debug:调试信息

  • info:一般信息

  • warn:警告信息

  • error:错误信息

在SLF4j中,调用log变量时,有以上5种级别对应的方法,调用与日志显示级别对应的方法,则输出的日志就是对应的级别,例如调用log.debug()时,输出的日志就是DEBUG级别的日志,调用log.warn()时,输出的日志就是WARN级别的日志。

在Spring Boot项目中,默认的显示级别是info,相比info较低级别的日志不会被输出!

在Spring Boot项目中,可以在application.properties中通过logging.level.包名属性来设置日志的显示级别:

# 日志的显示级别
logging.level.cn.tedu.csmall=trace

关于以上配置:

  • 包名部分,必须至少写1级包名,例如logging.level.cn,如果没有包名,则是错误的

  • 配置的包名是”根包“,所以子孙包及其下的所有类的日志都会是以上配置的级别

  • 如果认为有必要的话,甚至可以配置到具体的类

使用日志的好处:

  • 可以通过简单的配置,实现控制日志的显示级别,所以,可以使得某些日志仅开发时能输出,在生产环境中不会输出

  • 使用日志时,可以使用常量字符串,输出结果中的变量值均在此常量字符串中使用占位符表示即可,所以,字符串的处理效率非常高,并且,代码简洁

3. 关于Spring MVC

Spring MVC框架主要解决了接收请求、响应结果的相关问题。

在Spring Boot项目中,当需要使用Spring MVC框架及相关的功能时,应该添加spring-boot-starter-web依赖项。由于spring-boot-starter-web是基于spring-boot-starter依赖项的,所以,spring-boot-starter-web包含了spring-boot-starter,在实际编码时,只需要将spring-boot-starter改为spring-boot-starter-web即可。

当添加了spring-boot-starter-web依赖项后,当启动项目时,默认情况下,就会自动将当前项目编译、打包并部署到内置的Tomcat中,并启动Tomcat,默认占用8080端口。

如果需要修改默认占用的端口号,可以在application.properties中配置server.port属性,例如:

# 服务端口
server.port=9080

4. 实现:增加品牌

在项目的根包下,创建pojo.dto.BrandAddNewDTO类,此类用于封装”增加品牌“时客户端需要提交的请求参数:

@Data
public class BrandAddNewDTO implements Serializable {

    private String name;
    private String pinyin;
    private String logo;
    private String description;
    private String keywords;
    private Integer sort;
    private Integer enable;

}

在项目的根包下,创建controller.BrandController类,在类上添加@RestController

@RestController
public class BrandController {

    @Autowired
    BrandMapper brandMapper;

    // http://localhost:9080/add-new?name=格力&pinyin=geli&sort=67&description=格力的简介&enable=1&keywords=不知道写什么关键词&logo=还没有上传logo
    @RequestMapping("/add-new")
    public String addNew(BrandAddNewDTO brandAddNewDTO) {
        System.out.println("即将处理【添加品牌】的请求……");
        System.out.println("brandAddNewDTO = " + brandAddNewDTO);

        // 检查品牌名称是否已经被占用
        int count = brandMapper.countByName(brandAddNewDTO.getName());
        if (count > 0) {
            return "增加品牌失败!品牌名称【" + brandAddNewDTO.getName() + "】已经被占用!";
        }

        Brand brand = new Brand();
        BeanUtils.copyProperties(brandAddNewDTO, brand);
        brand.setSales(0);
        brand.setProductCount(0);
        brand.setCommentCount(0);
        brand.setPositiveCommentCount(0);
        brandMapper.insert(brand);

        return "已经完成处理【添加品牌】的请求";
    }

    // http://localhost:9080/delete
    @RequestMapping("/delete")
    public String delete() {
        System.out.println("即将处理【删除品牌】的请求……");

        return "已经完成处理【删除品牌】的请求";
    }

}

关于控制器的基本使用:

  • 仅当添加了@Controller注解后,此类才算是”控制器类“(才可以接收请求、响应结果)

  • 在方法上使用@RequestMapping可以配置某个路径,后续,客户端可以向此路径发起请求,则此方法将自动执行,所以,此方法可称之为”处理请求的方法“

  • 在默认情况下,处理请求的方法的返回值是String时,返回的结果表示某个视图的名称

  • 在方法上添加@ResponseBody注解,将表示此方法是”响应正文“的,方法的返回结果将直接响应到客户端

  • @ResponseBody注解还可以添加在控制器类上,将表示此控制器类中所有处理请求的方法都是响应正文的

  • @RestController将同时具有@Controller@ResponseBody的效果,这一点,可以从@RestController中看到

关于处理请求的方法:

  • 访问权限:应该是public

  • 返回值类型:暂时使用String

  • 方法名称:自定义

  • 参数列表:按需设计,可以直接将所需的请求参数声明为方法的参数,或者,将多个请求参数封装到自定义类型中,并使用自定义类型作为处理请求的方法的参数,各参数可以按照期望的数据类型进行设计,如果有多个参数,不区分先后顺序

关于接收请求参数:

  • 如果客户端正确的按照名称提交了请求参数,则服务器端可以正常接收到,如果不是字符串类型,会尝试自动的转换数据类型,如果转换失败,将出现错误,且响应400

    • http://localhost:9080/add-new?name=小米&pinyin=xiaomi

  • 如果客户端提交了对应的请求参数名称,却没有提交值,则服务器端默认视为空字符串,如果请求参数是其它类型(例如Integer),框架会放弃转换类型,仍保持为null

    • http://localhost:9080/add-new?name=&pinyin=

  • 如果客户端没有提交对应名称的请求参数,则服务器端接收到的为null

    • http://localhost:9080/add-new

作业

作业内容:

  • 创建与12张数据表对应的12个实体类;

  • 完成【相册:pms_album】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、查询列表(不考虑分页问题,下同)

  • 完成【图片:pms_picture】的:插入、根据id删除、批量删除、根据id查询详情、根据相册(album_id)查询列表

  • 完成【属性:pms_attribute】的:插入、根据id删除、批量删除、更新、统计数量、根据id查询详情、根据属性模板(template_id)查询列表

  • 完成【属性模板:pms_attribute_template】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、查询列表

  • 完成【类别:pms_category】的:插入、根据id删除、批量删除、更新、统计数量、根据名称统计数量、根据id查询详情、根据父级类别(parent_id)查询列表

  • 完成【类别与品牌关联:pms_brand_category】:插入、根据id删除、批量删除、根据品牌(brand_id)统计数量、根据类别(category_id)统计数量、根据品牌(brand_id)和类别(category_id)统计数量、查询列表

作业要求:

  • 所有的声明部分(类、接口、抽象方法、属性)应该添加注释

  • 所有实现的功能必须添加相应测试,以确保功能可以正确运行

1. 消息摘要算法与密码加密

在Spring Boot项目中,提供了DigestUtils工具类,此工具类的方法可以轻松实现“使用MD5算法”进行运算,从而,可以实现将原始密码进行加密,得到一个加密后的结果。

package cn.tedu.csmall.product;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;

@Slf4j
public class DigestTests {

    @Test
    public void testEncode() {
        String rawPassword = "123456";
        String encodedPassword = DigestUtils
                .md5DigestAsHex(rawPassword.getBytes());
        log.debug("原始密码={},MD5运算结果={}", rawPassword, encodedPassword);
    }

}

首先,对密码进行加密后,再存储,是非常有必要的!并且,主要的防范对象通常是内部员工!

需要注意,对密码进行加密处理时,不可以使用加密算法!因为,所有的加密算法都是可以加密,也可以解密的!加密算法的核心价值在于保证数据在传输过程中是安全的,并不保证数据存储的安全!

对需要存储的密码进行加密处理时,应该使用消息摘要算法,其本质是一种哈希算法,是不可逆向运算的!

1. 关于业务逻辑层

业务逻辑层,也称之为“业务层”(Service Layer),主要:设计业务流程,处理业务逻辑,以保证数据的完整性和安全性。

业务层应该由接口(原因后续再解释)和实现类这2种文件组成!

关于Service中的方法的定义:

  • 返回值类型:仅以操作成功为前提来设计

  • 方法名称:自定义

  • 参数列表:通常是控制器调用时传入,典型的参数就是客户端提交的请求参数

  • 异常:处理业务时可能的“失败”,通常,使用RuntimeException或其子孙类异常,所以,在声明业务方法时,并不需要显式的声明抛出异常

关于异常,如果使用现有的异常(例如NullPointerException等),可能会产生歧义,所以,通常会自定义异常,继承自RuntimeException

如果在项目中只使用1种异常类型,不便于不区分同一个业务可能出现的多种“错误”,所以,应该在异常类型中添加某个属性,来区分多种“错误”!关于此属性,可以是intString等各种你认为合适的类型,但是,这些类型的取值范围(值的可能性)非常大,为了限制取值,可以使用枚举类型,例如:

public enum ServiceCode {
    ERR_INSERT(1), ERR_UPDATE, ERR_DELETE;
}

如果仅仅只是以上代码,当尝试输出某个枚举值,输出的结果就是以上名称,例如ERR_INSERT,不便于获取此值时编写条件判断相关的代码,通常,使用数值进行判断会更加方便,所以,可以为以上每个枚举值指定相关的数值,同时,需要添加枚举的构造方法,例如:

public enum ServiceCode {
    ERR_INSERT(1), 
    ERR_UPDATE(2), 
    ERR_DELETE(3);
    
    ServiceCode(int value) {}
}

为了保证各个值能够被使用,还需要添加全局属性,用于保存通过构造方法传入的值,并提供相应获取值的方法,使得后续得到得这些数值:

public enum ServiceCode {

    ERR_CONFLICT(1),
    ERR_INSERT(2),
    ERR_DELETE(3),
    ERR_UPDATE(4);

    private int value;

    ServiceCode(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "" + value;
    }

}

另外,各枚举值对应的数值编号应该是有一定规律的,而不是盲目的顺序编号,所以,应该自行设计一套编码规则,或者,如果没有比较成熟的编码规则,可大致参考已有的某套规则,例如参考HTTP响应码:

package cn.tedu.csmall.product.ex;

/**
 * 业务状态码的枚举
 */
public enum ServiceCode {

    OK(20000),
    ERR_NOT_FOUND(40400),
    ERR_CONFLICT(40900),
    ERR_INSERT(50000),
    ERR_DELETE(50100),
    ERR_UPDATE(50200);

    private int value;

    ServiceCode(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "" + value;
    }

}

提示:以上类型创建在项目的根包下的ex.ServiceCode

然后,在项目的根包下的ex包下创建ServiceException

public class ServiceException extends RuntimeException {
    
    private ServiceCode serviceCode;
    
    public ServiceException(ServiceCode serviceCode, String message) {
        super(message);
        this.serviceCode = serviceCode;
    }
    
    public ServiceCode getServiceCode() {
        return serviceCode;
    }
    
}

User login(String username, String password) throws 用户名错误异常, 密码错误异常, 账号禁用异常;

try {
    User user = service.login("root", "1234");
    // 处理成功
} catch (用户名错误异常 e) {
    // 处理用户名错误异常
} catch (密码错误异常 e) {
    // 处理密码错误异常
} catch (账号禁用异常 e) {
    // 处理账号禁用异常
}

2. 处理“添加品牌”的业务

在根包下创建service子包,并在此子包下创建IBrandService接口:

public interface IBrandService {
    // 添加品牌
    void addNew(BrandAddNewDTO brandAddNewDTO);
}

然后,在service包下再创建impl子包,并在此子包下创建BrandServiceImpl类,实现以上接口,并在类上添加@Service注解:

@Service
public class BrandServiceImpl implements IBrandService {
    
}

1. 添加“类别”的业务

在项目的根包下创建pojo.dto.CategoryAddNewDTO类:

@Data
public class CategoryAddNewDTO implements Serializable {

    private String name;
    private Long parentId;
    private String keywords;
    private Integer sort;
    private String icon;
    private Integer enable;
    private Integer isDisplay;

}

在项目的根包下创建service.ICategoryService接口:

public interface ICategoryService {
    void addNew(CategoryAddNewDTO categoryAddNewDTO);
}

在项目的根包下创建service.impl.CategoryServiceImpl类,实现以上接口,并添加@Service注解:

@Service
public class CategoryServiceImpl implements ICategoryService {
    
    @Autowired
    private CategoryMapper categoryMapper;
    
    @Override
    public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
        // 调用Mapper对象的【根据名称统计数量】方法进行统计
        // 判断统计结果是否大于0
        // 是:名称已经被占用,抛出异常(CONFLICT)
        
        // 声明局部变量depth,默认值为1
        // 取出参数中的parentId
        // 判断parentId是否不为0
        // 是:调用Mapper对象的【根据id查询详情】,使用parentId作为参数,执行查询
        // -- 判断查询结果是否不为null
        // -- 是:局部变量depth=父级depth+1
        // -- 否:父级类别不存在,抛出异常(NOT_FOUND)
 
        // 创建Category实体类的对象
        // 将参数DTO的各属性值复制到Category实体类对象中
        // 补全Category实体类对象的属性:depth
        // 补全Category实体类对象的属性:is_parent:固定为0
        // 调用Mapper对象的方法,将数据插入到数据库,并获取返回值
        // 判断返回值是否不为1
        // 是:抛出异常(ERR_INSERT)
        
        // 判断parentId是否不为0
        // 是:判断父级类别的isParent是否为0
        // -- 是:创建新的Category对象,封装:parentId,isParent(1)
        // -- -- 调用Mapper对象的【更新】方法,执行修改数据,并获取返回值
        // -- -- 判断返回值是否不为1
        // -- -- -- 是:抛出异常(ERR_UPDATE)
    }
    
}

具体实现为:

@Override
public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
    // 调用Mapper对象的【根据名称统计数量】方法进行统计
    String name = categoryAddNewDTO.getName();
    int count = categoryMapper.countByName(name);
    // 判断统计结果是否大于0
    if (count > 0) {
        // 是:名称已经被占用,抛出异常(CONFLICT)
        String message = "添加类别失败,尝试添加的类别名称【" + name + "】已经存在!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
    }

    // 声明父级类别对象
    CategoryStandardVO parentCategory = null;
    // 声明局部变量depth,默认值为1
    Integer depth = 1;
    // 取出参数中的parentId
    Long parentId = categoryAddNewDTO.getParentId();
    // 判断parentId是否不为0
    if (parentId != 0) {
        // 是:调用Mapper对象的【根据id查询详情】,使用parentId作为参数,执行查询
        parentCategory = categoryMapper.getStandardById(parentId);
        // 判断查询结果是否不为null
        if (parentCategory != null) {
        // -- 是:局部变量depth=父级depth+1
            depth += parentCategory.getDepth();
        } else {
            // -- 否:父级类别不存在,抛出异常(NOT_FOUND)
            String message = "添加类别失败,选定的父级类别不存在!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
        }
    }

    // 创建Category实体类的对象
    Category category = new Category();
    // 将参数DTO的各属性值复制到Category实体类对象中
    BeanUtils.copyProperties(categoryAddNewDTO, category);
    // 补全Category实体类对象的属性:depth
    category.setDepth(depth);
    // 补全Category实体类对象的属性:isParent:固定为0
    category.setIsParent(0);
    // 调用Mapper对象的方法,将数据插入到数据库,并获取返回值
    int rows = categoryMapper.insert(category);
    // 判断返回值是否不为1
    if (rows != 1) {
        // 是:抛出异常(ERR_INSERT)
        String message = "添加类别失败,服务器忙,请稍后再尝试!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_INSERT, message);
    }

    // 判断parentId是否不为0
    if (parentId != 0) {
        // 是:判断父级类别的isParent是否为0
        if (parentCategory.getIsParent() == 0) {
            // 是:创建新的Category对象,封装:parentId,isParent(1)
            Category updateParentCategory = new Category();
            updateParentCategory.setId(parentId);
            updateParentCategory.setIsParent(1);
            // 调用Mapper对象的【更新】方法,执行修改数据,并获取返回值
            int updateRows = categoryMapper.updateById(updateParentCategory);
            // 判断返回值是否不为1
            if (updateRows != 1) {
                // 是:抛出异常(ERR_UPDATE)
                String message = "添加类别失败,服务器忙,请稍后再尝试!";
                log.warn(message);
                throw new ServiceException(ServiceCode.ERR_UPDATE, message);
            }
        }
    }
}

完成后,测试:

package cn.tedu.csmall.product.service;

import cn.tedu.csmall.product.ex.ServiceException;
import cn.tedu.csmall.product.pojo.dto.CategoryAddNewDTO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
public class CategoryServiceTests {

    @Autowired
    ICategoryService service;

    @Test
    void testAddNew() {
        CategoryAddNewDTO categoryAddNewDTO = new CategoryAddNewDTO();
        categoryAddNewDTO.setName("休闲衬衣");
        categoryAddNewDTO.setParentId(23L);

        try {
            service.addNew(categoryAddNewDTO);
            log.debug("添加成功!");
        } catch (ServiceException e) {
            log.debug("serviceCode : " + e.getServiceCode());
            log.debug("message : " + e.getMessage());
        }
    }

}

2. 删除“类别”的业务

CategoryMapper.java接口中添加新的抽象方法:

int countByParentId(Long parentId);

CategoryMapper.xml中配置SQL:

<select id="countByParentId" resultType="int">
	SELECT count(*) FROM pms_category WHERE parent_id=#{parentId}
</select>

CategoryMapperTests中编写并执行测试:

@Test
void testCountByParentId() {
    Long parentId = 1L;
    int count = mapper.countByParentId(parentId);
    log.debug("根据父级id【{}】统计数量完成,数量:", parentId, count);
}

ICategoryService接口中添加抽象方法:

void deleteById(Long id);

CategoryServiceImpl类中实现以上抽象方法:

@Override
public void deleteById(Long id) {
    // 调用Mapper对象的【根据id查询详情】查询数据,是当前尝试删除的数据
    // 判断查询结果是否为null
    // 是:数据不存在,抛出异常(ERR_NOT_FOUND)
    
    // 检查当前尝试删除的类别是否存在子级类别:判断以上查询结果的isParent是否为1
    // 是:当前尝试删除的类别“是父级类别”(包含子级),抛出异常(ERR_CONFLICT)
    
    // 调用Mapper对象的【根据id删除】执行删除,并获取返回值
    // 判断返回值是否不为1
    // 是:抛出异常(ERR_DELETE)
    
    // ====== 如果这是父级类别中的最后一个子级,则将父级的isParent改为0 =====
    // 从当前尝试删除的类别对象中取出parentId
    // 调用Mapper对象的countByParentId(parentId)进行统计
    // 判断统计结果是否为0
    // 创建新的Category对象,用于更新父级,此Category对象中需要封装:id(parentId),isParent(0)
    // 调用Mapper对象的【更新】功能,执行修改数据,并获取返回值
    // 判断返回值是否不为1
    // 是:抛出异常(ERR_UPDATE)
}

具体实现为:

@Override
public void deleteById(Long id) {
    // 调用Mapper对象的【根据id查询详情】查询数据,是当前尝试删除的数据
    CategoryStandardVO currentCategory = categoryMapper.getStandardById(id);
    // 判断查询结果是否为null
    if (currentCategory == null) {
        // 是:数据不存在,抛出异常(ERR_NOT_FOUND)
        String message = "删除类别失败,尝试删除的类别不存在!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
    }

    // 检查当前尝试删除的类别是否存在子级类别:判断以上查询结果的isParent是否为1
    if (currentCategory.getIsParent() == 1) {
        // 是:当前尝试删除的类别“是父级类别”(包含子级),抛出异常(ERR_CONFLICT)
        String message = "删除类别失败,尝试删除的类别仍包含子级类别!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
    }

    // 调用Mapper对象的【根据id删除】执行删除,并获取返回值
    int rows = categoryMapper.deleteById(id);
    // 判断返回值是否不为1
    if (rows != 1) {
        // 是:抛出异常(ERR_DELETE)
        String message = "删除类别失败,服务器忙,请稍后再尝试!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_DELETE, message);
    }

    // ====== 如果这是父级类别中的最后一个子级,则将父级的isParent改为0 =====
    // 从当前尝试删除的类别对象中取出parentId
    Long parentId = currentCategory.getParentId();
    // 判断当前类别是否不为1级类别,即parentId不为0
    if (parentId != 0) {
        // 调用Mapper对象的countByParentId(parentId)进行统计
        int count = categoryMapper.countByParentId(parentId);
        // 判断统计结果是否为0
        if (count == 0) {
            // 创建新的Category对象,用于更新父级,此Category对象中需要封装:id(parentId),isParent(0)
            Category parentCategory = new Category();
            parentCategory.setId(parentId);
            parentCategory.setIsParent(0);
            // 调用Mapper对象的【更新】功能,执行修改数据,并获取返回值
            rows = categoryMapper.updateById(parentCategory);
            // 判断返回值是否不为1
            if (rows != 1) {
                // 是:抛出异常(ERR_UPDATE)
                String message = "删除类别失败,服务器忙,请稍后再尝试!";
                log.warn(message);
                throw new ServiceException(ServiceCode.ERR_UPDATE, message);
            }
        }
    }
}

完成后,测试:

@Test
void testDeleteById() {
    Long id = 18L;

    try {
        service.deleteById(id);
        log.debug("删除成功!");
    } catch (ServiceException e) {
        log.debug("serviceCode : " + e.getServiceCode());
        log.debug("message : " + e.getMessage());
    }
}

3. 事务(Transaction)

事务是数据库中的可以保证多个(至少2个)写操作(增、删、改)要么全部执行成功,要么全部执行失败的机制!

在基于Spring JDBC的项目中,使用@Transactional注解,即可使得注解的方法是事务性的。

关于@Transactional注解,可以添加在:

  • 接口上

    • 等效于在每个抽象方法上添加了此注解

  • 接口的抽象方法上

    • 仅作用于当前方法(重写的方法运行时)

  • 实现类上

    • 等效于在每个重写的接口的方法上添加了此注解

  • 实现类中重写的接口的方法上

    • 仅作用于当前方法

提示:此注解可以配置一些参数,如果同时在接口/类、接口的抽象方法/类重写的方法上添加此注解并配置了不同的参数值,则以方法上的配置为准。

注意:Spring JDBC是基于接口的代理模式来实现事务管理的!所以,如果在实现类中的自定义方法上添加@Transactional注解是错误的做法!仅当对应的方法是在业务接口中已经声明的,使用@Transactional才是正确的!

关于事务处理过程中的几个概念:

  • 开启事务:BEGIN

  • 提交事务:COMMIT

  • 回滚事务:ROLLBACK

在Spring JDBC的事务管理中,大致是:

开启事务
try {
	执行你的业务方法
	提交事务
} catch (RuntimeException e) {
	回滚事务
}

可以看到,Spring JDBC的事务管理中,默认将根据RuntimeException进行回滚,可以通过@Transactional注解的rollbackFor / rollbackForClassName这2个属性中的某1个进行修改,设置为对特定的异常进行回滚,还可以配置noRollbackFor / noRollbackForClassName这2个属性,设置对特定的异常不回滚。

【小结】关于事务:

  • 如果某个业务方法涉及超过1次的增、删、改操作,需要保证此业务方法是事务性的;

  • 推荐在业务的抽象方法上添加@Transactional注解即可保证此业务方法是事务性

    • 对于初学者,更推荐在业务接口上添加@Transactional,则此接口中所有抽象方法都是事务性,可能其中的某些抽象方法并不需要是事务性的,但是,这种做法比较稳妥,能避免遗漏导致的错误

  • 为了保证事务能够按照预期进行回滚,需要:

    • 业务层必须由接口和实现类组成

      • Spring JDBC是基于接口代理模式实现事务管理的,如果没有接口,则无法实现

    • 所有增、删、改操作完成后,应该及时获取返回结果,并对结果进行判断,如果结果不符合预期,应该抛出异常,且异常应该是RuntimeException或其子孙类异常

      • Spring JDBC在管理事务时,默认按照RuntimeException进行回滚

4. 关于@RequestMapping注解

在Spring MVC框架中,@RequestMapping注解的主要作用是:绑定“请求路径”与“处理请求的方法”的映射关系。

关于此注解的声明的源代码:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
}

以上源代码中的@Target({ElementType.TYPE, ElementType.METHOD})就表示了此注解可以添加在哪里,其中,ElementType.TYPE表示可以添加在“类型”上,ElementType.METHOD表示可以添加在方法上。

在控制器类上,添加@RequestMapping配置的路径值,将作为当前控制器类中每个处理请求的路径的前缀部分!例如在类上配置为@RequestMapping("/brands"),在方法上配置为@RequestMapping("/delete"),则完整路径为/brands/delete,并且,此控制器类中所有处理请求的方法的路径上都有/brands前缀!

所以,强烈推荐在每个控制器类上都使用@RequestMapping配置路径的前缀部分。

关于注解内部的源代码,以@RequestMapping为例,其内部有:

String name() default "";

以上源代码中,name()表示此注解有名为name的属性,左侧的String表示此属性的数据类型,右侧的default ""表示此属性的默认值。例如:可以在@RequestMapping中配置名为name的属性:

@RequestMapping(name = "test")

@RequestMapping中,还有:

@AliasFor("path")
String[] value() default {};

则以上源代码表示:此注解中有名为value的属性,值是String[]类型的,则默认值是空数组。

在Java语言中,注解的value属性是默认的属性,如果注解只需要配置这1个属性的值,则可以不必显式的声明属性名称,例如:

@RequestMapping(value = {"value1", "value2", "value3"})
@RequestMapping({"value1", "value2", "value3"})

以上2种配置是完全等效的!

在Java语言中,如果某个属性的值是某种数组类型,但是,配置的数组值只有1个元素,则不必使用大括号将其框住,例如:

@RequestMapping(value = {"value1"})
@RequestMapping(value = "value1")

以上2种配置是完全等效的!

关于源代码中value属性的声明,还添加了@AliasFor("path"),表示此value属性等效于当前注解中的path属性!

@RequestMapping注解的源代码中,还有:

RequestMethod[] method() default {};

以上注解的作用是“限制请求方式”,在没有配置此属性的情况,任何请求方式都是允许的!例如:

@RequestMapping(value = "/add-new", method = RequestMethod.POST)

如果使用以上配置,则/add-new路径只允许通过POST方式提交请求,如果使用其它请求方式,将出现405错误!

强制推荐将所有请求都限制请求方式!

在Spring MVC框架中,还定义了相关注解,以简化限制请求方式的配置:

  • @GetMapping

  • @PostMapping

  • PutMapping

  • DeleteMapping

  • PatchMapping

以上这些都相当于是限制了请求方式的@RequestMapping

小结:

  • 推荐在每个控制器类上使用@RequestMapping配置请求路径前缀

  • 推荐在每个处理请求的方法上使用@GetMapping / @PostMapping配置请求路径

作业

完成以下页面设计:

1. 添加类别

菜单位置:临时页面 > 添加类别

文件名:/src/views/sys-admin/temp/CategoryAddNewView.vue

访问路径:/sys-admin/temp/category/add-new

2. 添加品牌

菜单位置:临时页面 > 添加品牌

文件名:/src/views/sys-admin/temp/BrandAddNewView.vue

访问路径:/sys-admin/temp/brand/add-new

1. Knife4j框架

Knife4j是一款基于Swagger 2的在线API文档框架。

在Spring Boot中,使用此框架时,需要:

  • 添加依赖

  • 在配置文件(application.properties)中开启增强模式

  • 编写配置类(代码相对固定,建议CV)

关于依赖的代码:

<!-- Knife4j Spring Boot:在线API -->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>2.0.9</version>
</dependency>

关于开启增强模式,在application.properties中添加:

# 开启Knife4j的增强模式
knife4j.enable=true

关于配置类,在项目的根包下创建config.Knife4jConfiguration,代码如下:

注意:请检查basePackage属性的值!

package cn.tedu.csmall.product.config;

import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;

/**
 * Knife4j配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfiguration {

    /**
     * 【重要】指定Controller包路径
     */
    private String basePackage = "cn.tedu.csmall.product.controller";
    /**
     * 分组名称
     */
    private String groupName = "product";
    /**
     * 主机名
     */
    private String host = "http://java.tedu.cn";
    /**
     * 标题
     */
    private String title = "酷鲨商城在线API文档--商品管理";
    /**
     * 简介
     */
    private String description = "酷鲨商城在线API文档--商品管理";
    /**
     * 服务条款URL
     */
    private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";
    /**
     * 联系人
     */
    private String contactName = "Java教学研发部";
    /**
     * 联系网址
     */
    private String contactUrl = "http://java.tedu.cn";
    /**
     * 联系邮箱
     */
    private String contactEmail = "java@tedu.cn";
    /**
     * 版本号
     */
    private String version = "1.0.0";

    @Autowired
    private OpenApiExtensionResolver openApiExtensionResolver;

    public Knife4jConfiguration() {
        log.debug("加载配置类:Knife4jConfiguration");
    }

    @Bean
    public Docket docket() {
        String groupName = "1.0.0";
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .host(host)
                .apiInfo(apiInfo())
                .groupName(groupName)
                .select()
                .apis(RequestHandlerSelectors.basePackage(basePackage))
                .paths(PathSelectors.any())
                .build()
                .extensions(openApiExtensionResolver.buildExtensions(groupName));
        return docket;
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title(title)
                .description(description)
                .termsOfServiceUrl(termsOfServiceUrl)
                .contact(new Contact(contactName, contactUrl, contactEmail))
                .version(version)
                .build();
    }

}

注意:以上代码适用于Spring Boot 2.6以下(不含2.6)版本!

完成后,重启项目,打开浏览器,通过 http://localhost:9080/doc.html 即可访问Knife4j的API文档。

关于Knife4j框架,还提供了一系列的注解,便于实现API文档的显示,包括:

  • @Api:添加在控制器类上,配置其tags属性,用于指定模块名称,在指定的模块名称,可以使用数字编号作为名称的前缀,则多个管理模块将按照编号顺序来显示,例如:

    • @RestController
      @RequestMapping("/albums")
      @Api(tags = "03. 相册管理模块")
      public class AlbumController {
      
          @GetMapping("/test")
          public void test() {}
      
      }
  • @ApiOperation:添加在控制器类中处理请求的方法上,配置其value属性,用于指定业务接口名称,例如:

    • @ApiOperation("删除品牌")
      @PostMapping("/delete")
      public String delete(Long id) {
      }
  • @ApiOperationSupport:添加在控制器类中处理请求的方法上,配置其order属性,用于指定业务接口的排序编号,最终,同一个模块中的多个业务接口将按此编号升序排列,例如:

    • @ApiOperation("删除品牌")
      @ApiOperationSupport(order = 200)
      @PostMapping("/delete")
      public String delete(Long id) {
      }
  • @ApiModelProperty:添加在POJO类的属性上,配置其value属性,用于指定请求参数的名称(说明),配置其required属性,用于指定“是否必须提交此请求参数”(仅用于显示,不具备检查功能),配置其example属性,用于指定“示例例”,例如:

    • @Data
      public class BrandAddNewDTO implements Serializable {
      
          /**
           * 是否启用,1=启用,0=未启用
           */
          @ApiModelProperty(value = "是否启用,1=启用,0=未启用", example = "1", required = true)
          private Integer enable;
      
      }
  • @ApiImplicitParam:添加在控制器类中处理请求的方法上,配置其name属性,指定方法的参数的变量名,配置其value属性,指定此参数的说明,配置其required属性,指定此参数“是否必须提交”,配置其dataType属性,指定此参数的数据类型,例如:

    @ApiOperation("删除品牌")
    @ApiOperationSupport(order = 200)
    @ApiImplicitParam(name = "id", value = "品牌id", required = true, dataType = "long")
    @PostMapping("/delete")
    public String delete(Long id) {
    }
  • @ApiImplicitParams:添加在控制器类中处理请求的方法上,当有多个参数需要配置时,使用此注解,且此注解的值是@ApiImplicitParam的数组,例如:

    • @ApiOperation("删除品牌")
      @ApiOperationSupport(order = 200)
      @ApiImplicitParams({
          @ApiImplicitParam(name = "id", value = "品牌id", 
                            required = true, dataType = "long")
      })
      @PostMapping("/delete")
      public String delete(Long id) {
      }
  • @ApiIgnore:添加在处理请求的方法的参数上,当某个参数不需要显示在API文档中,则需要在参数上添加此注解,例如HttpServletRequestHttpSession等,例如:

    @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框架调用控制器方法时捕获的异常,另外,可按需添加HttpServerRequestHttpServletResponse等少量限定类型的参数

例如:

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代码中,可以使用qsstringify()将对象转换成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

作业

  1. 完成“添加类别”的前后端交互

  2. 完成“添加相册”,包括后端与前端的全部内容

    1. 业务规则:相册名称不允许重复

    2. 页面文件名:AlbumAddNewView.vue

    3. 页面路径:/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-securityfastjsonjjwt

  • ServiceCode:补充新的枚举值

  • LoginPrincipal

  • application.properties中的配置:关于JWT的配置,且secretKey必须相同

  • JwtAuthorizationFilter过滤器

  • SecurityConfiguration配置类

    • 删除PasswordEncoder@Bean方法

    • 删除AuthenticationManager@Bean方法

      • 必须删除,否则,在product项目中执行测试时,会出现内存溢出

    • 调整配置的白名单

  • GlobalExceptionHandler:至少补充处理AccessDeniedException

完成后,可以先通过product项目的在线API文档进行测试访问,在没有携带JWT的情况下,所有请求都会响应403错误,需要先在passport项目的在线API文档中执行登录,得到JWT数据,并配置到product项目的API文档中,再次访问,则可以正常访问!

3. 显示类别列表

关于“类别”的显示,需要注意:通常并不需要一次性将所有的、各层级的类别全部查询或显示出来,只需要查询特定的一些类别,例如:查询所有1级类别,或查询某个1级类别的子级类别列表等。

目前,在Mapper层已经实现了List<CategoryListItemVO> listByParentId(Long parentId);,所以,Mapper层无需再开发。

则,在ICategoryService中添加:

/**
 * 根据父级类别的id查询类别列表
 *
 * @param parentId 父级类别的id
 * @return 类别列表
 */
List<CategoryListItemVO> listByParentId(Long parentId);

CategoryServiceImpl中实现:

@Override
public List<CategoryListItemVO> listByParentId(Long parentId) {
    log.debug("开始处理【根据父级类别查询子级类别列表】的业务");
    return categoryMapper.listByParentId(parentId);
}

CategoryServiceTests中测试:

@Test
public void testListByParentId() {
    Long parentId = 0L;
    List<?> list = service.listByParentId(parentId);
    log.info("查询列表完成,结果集中的数据的数量={}", list.size());
    for (Object item : list) {
        log.info("{}", item);
    }
}

CategoryController中添加处理请求的方法:

// http://localhost:9080/categories/list-by-parent
@ApiOperation("根据父级类别查询子级类别列表")
@ApiOperationSupport(order = 410)
@ApiImplicitParam(name = "parentId", value = "父级类别id,如果是一级类别,则此参数值应该为0",
            required = true, dataType = "long")
@GetMapping("/list-by-parent")
public JsonResult<List<CategoryListItemVO>> listByParentId(Long parentId) {
    if (parentId == null || parentId < 0) {
        parentId = 0L;
    }
    List<CategoryListItemVO> list = categoryService.listByParentId(parentId);
    return JsonResult.ok(list);
}

完成后,重启项目,通过API文档应该可以测试访问。

4. 添加属性模板

由于服务器端已经实现了“添加属性模板”的功能,所以,只需完成前端界面即可。

5. 显示属性模板列表

此前已经实现了Mapper层的查询,接下来,需要实现Service层、Controller层和前端界面的处理!

6. 在添加属性界面中显示属性模板列表的下拉菜单

作业

  • 完成“删除品牌”功能(从界面上点击按钮完成)

  • 完成“启用品牌”功能(从界面上点击按钮完成)

  • 完成“禁用品牌”功能(从界面上点击按钮完成)

  • 完成“启用类别”功能(从界面上点击按钮完成)

  • 完成“禁用类别”功能(从界面上点击按钮完成)

  • 完成“显示类别(是否显示在导航栏)”功能(从界面上点击按钮完成)

  • 完成“隐藏类别(是否显示在导航栏)”功能(从界面上点击按钮完成)

  • 完成“删除类别”功能(从界面上点击按钮完成)

  • 完成“删除相册”功能(从界面上点击按钮完成)

  • 完成“删除属性模板”功能(从界面上点击按钮完成)

1. 新增SPU的界面设计

视图文件名:SpuAddNewStep1View.vue

路由路径:/sys-admin/product/spu-add-new1

视图文件名:SpuAddNewStep2View.vue

路由路径:/sys-admin/product/spu-add-new2

视图文件名:SpuAddNewStep3View.vue

路由路径:/sys-admin/product/spu-add-new3

视图文件名:SpuAddNewStep4View.vue

路由路径:/sys-admin/product/spu-add-new4

在前端界面的设计中,HTML本身并没有富文本编辑器的控件,通常,都是使用第三方的。

第三方的富文本编辑器有许多种,以上演示图中使用的是wangeditor,在使用之前,需要先安装:

npm i wangeditor -S

然后,在main.js中导入,并声明为Vue对象的属性:

import wangEditor from 'wangeditor';

Vue.prototype.wangEditor = wangEditor;

在设计视图时,只需要使用某个标签用于后续wangEditor向其它插入源代码,以显示富编辑器即可,例如:

<div id="wangEditor"></div>

并且,在页面刚刚加载完成时,应该对wangEditor进行初始化:

<template>
  <div>
    <h1>新增SPU第4步</h1>
    <div id="wangEditor"></div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      editor: {}
    }
  },
  methods: {
    initWangEditor() {
      this.editor = new this.wangEditor('#wangEditor');
      // this.editor.config.zIndex = 1;
      this.editor.create();
    }
  },
  mounted() {
    this.initWangEditor();
  }
}
</script>

增加SPU之前的检查

在增加SPU之前,应该对输入(或选择等)的数据进行检查,例如:检查类别信息,则需要服务器端提供“根据类别id获取类别详情”的接口!

CategoryMapper接口中已经实现了CategoryStandardVO getStandardById(Long id);

ICategoryService接口中添加:

/**
 * 根据id获取类别的标准信息
 *
 * @param id 类别id
 * @return 返回匹配的类别的标准信息,如果没有匹配的数据,将返回null
 */
CategoryStandardVO getStandardById(Long id);

CategoryServiceImpl中实现:

@Override
public CategoryStandardVO getStandardById(Long id) {
    log.debug("开始处理【根据id查询类别详情】的业务");
    CategoryStandardVO category = categoryMapper.getStandardById(id);
    if (category == null) {
        // 是:此id对应的数据不存在,则抛出异常(ERR_NOT_FOUND)
        String message = "查询类别详情失败,尝试访问的数据不存在!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
    }
    return category;
}

CategoryServiceTests中测试:

@Test
public void testGetStandardById() {
    Long id = 2L;
    try {
        CategoryStandardVO category = service.getStandardById(id);
        log.debug("根据id={}查询完成,查询结果={}", id, category);
    } catch (ServiceException e) {
        log.debug("serviceCode : " + e.getServiceCode());
        log.debug("message : " + e.getMessage());
    }
}

CategoryController中处理请求:

// http://localhost:9080/categories/3
@ApiOperation("根据id查询类别详情")
@ApiOperationSupport(order = 400)
@ApiImplicitParam(name = "id", value = "类别id", required = true, dataType = "long")
@GetMapping("/{id:[0-9]+}")
public JsonResult<CategoryStandardVO> getStandardById(@PathVariable Long id) {
    log.debug("开始处理【根据id查询类别详情】的请求:id={}", id);
    CategoryStandardVO category = categoryService.getStandardById(id);
    return JsonResult.ok(category);
}

完成后,重启项目,可以通过在线API文档进行测试访问。

同理,还需要检查品牌信息,则需要服务器端提供“根据品牌id获取品牌详情”的接口!

1. 新增SPU

1.1. 分析

为了保证数据表的查询效率,SPU数据中的“详情”(通过富文本编辑器输入的内容)被设计在pms_spu_detail表中,而其它的一般数据在pms_spu表中,当“新增SPU”时,本质上需要同时对这2张表进行“插入数据”的操作!

需要执行的SQL语句大致是:

insert into pms_spu (字段列表) values (值列表);
insert into pms_spu_detail (字段列表) values (值列表);

注意:在pms_spu表中,主键(id)并不是自动编号的(要考虑分库分表时,相关的数据表的主键都不允许使用自动编号)!

另外,在插入数据之前,相关数据需要进行检查,包括:检查品牌、类别、相册的id是否存在、是否有效等,这些功能此前已经完成!需要注意:关于品牌、类别、相册的名称,前端是可以提交这些数据的,但是,服务器端不应该直接使用这些数据写入到数据表,只使用前端提交的品牌id、类别id、相册id,至于品牌名称、类别名称、相册名称,应该是在检查时一并查出来,并使用查询到的数据写入到数据表中!

1.2. 关于Mapper层

关于插入Spu数据

在根包下创建pojo.entity.Spu实体类。

在根包下创建mapper.SpuMapper接口,添加抽象方法:

@Repository
public interface SpuMapper {

    /**
     * 插入SPU数据
     *
     * @param spu SPU数据
     * @return 受影响的行数
     */
    int insert(Spu spu);
    
}

src/main/resources/mapper下粘贴得到SpuMapper.xml文件,配置SQL语句:

<mapper namespace="cn.tedu.csmall.product.mapper.SpuMapper">

    <!-- 由于pms_spu表的id不是自动编号的,在插入数据时,需要显式指定此字段的值 -->
	<!-- 所以,不需要配置useGeneratedKeys和keyProperty -->
    <!-- int insert(Spu spu); -->
    <insert id="insert">
        INSERT INTO pms_spu (
            id, name, type_number, title, description,
            list_price, stock, stock_threshold, unit, brand_id,
            brand_name, category_id, category_name, attribute_template_id, album_id,
            pictures, keywords, tags, sort, is_deleted,
            is_published, is_new_arrival, is_recommend, is_checked, gmt_check
        ) VALUES (
            #{id}, #{name}, #{typeNumber}, #{title}, #{description},
            #{listPrice}, #{stock}, #{stockThreshold}, #{unit}, #{brandId},
            #{brandName}, #{categoryId}, #{categoryName}, #{attributeTemplateId}, #{albumId},
            #{pictures}, #{keywords}, #{tags}, #{sort}, #{isDeleted},
            #{isPublished}, #{isNewArrival}, #{isRecommend}, #{isChecked}, #{gmtCheck}
         )
    </insert>
    
</mapper>

src/test/java的根包下创建SpuMapperTests测试类,测试以上抽象方法:

@Slf4j
@SpringBootTest
public class SpuMapperTests {

    @Autowired
    SpuMapper mapper;

    @Test
    public void testInsert() {
        Spu spu = new Spu();
        spu.setId(11000L); // 重要,必须
        spu.setName("小米13");

        log.debug("插入数据之前,参数={}", spu);
        int rows = mapper.insert(spu);
        log.debug("rows = {}", rows);
        log.debug("插入数据之后,参数={}", spu);
    }
    
}

关于插入SpuDetail数据

在根包下创建pojo.entity.SpuDetail实体类。

在根包下创建mapper.SpuDetailMapper接口,添加抽象方法:

@Repository
public interface SpuDetailMapper {

    /**
     * 插入SPU详情数据
     *
     * @param spuDetail SPU详情数据
     * @return 受影响的行数
     */
    int insert(SpuDetail spuDetail);
    
}

src/main/resources/mapper下粘贴得到SpuDetailMapper.xml文件,配置SQL语句:

<mapper namespace="cn.tedu.csmall.product.mapper.SpuDetailMapper">

    <!-- int insert(SpuDetail spuDetail); -->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO pms_spu_detail (
            spu_id,detail
        ) VALUES (
            #{spuId},#{detail}
        )
    </insert>
    
</mapper>

src/test/java的根包下创建SpuDetailMapperTests测试类,测试以上抽象方法:

@Slf4j
@SpringBootTest
public class SpuDetailMapperTests {

    @Autowired
    SpuDetailMapper mapper;

    @Test
    public void testInsert() {
        SpuDetail spuDetail = new SpuDetail();
        spuDetail.setSpuId(10000L);
        spuDetail.setDetail("这是1号Spu的详情");

        log.debug("插入数据之前,参数={}", spuDetail);
        int rows = mapper.insert(spuDetail);
        log.debug("rows = {}", rows);
        log.debug("插入数据之后,参数={}", spuDetail);
    }
    
}

1.3. 关于Service层

为了保证Spu的id的唯一性,且基于“不会高频率新增Spu”,可以使用时间加随机数字作为id值。

考虑到后续可能调整生成id的策略,则将生成id的代码写在专门的工具类中,不写在业务层。

在根包下创建util.IdUtils类,定义生成id的静态方法:

package cn.tedu.csmall.product.util;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Random;

/**
 * Id工具类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
public final class IdUtils {

    private IdUtils() {}

    private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");

    private static Random random = new Random();

    // 临时策略:使用“年月日时分秒毫秒”加2位随机数作为id
    public static Long getId() {
        LocalDateTime now = LocalDateTime.now();
        String dateTimeString = dateTimeFormatter.format(now);
        int randomNumber = random.nextInt(89) + 10;
        Long id = Long.valueOf(dateTimeString + randomNumber);
        return id;
    }

}

在根包下创建pojo.dto.SpuAddNewDTO类(注意:相对于实体类,需要删除不由客户端提交的数据,并且,需要补充detail属性,表示“Spu详情”):

package cn.tedu.csmall.product.pojo.dto;

import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;

/**
 * SPU(Standard Product Unit)
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class SpuAddNewDTO implements Serializable {

    /**
     * SPU名称
     */
    private String name;

    /**
     * SPU编号
     */
    private String typeNumber;

    /**
     * 标题
     */
    private String title;

    /**
     * 简介
     */
    private String description;

    /**
     * 价格(显示在列表中)
     */
    private BigDecimal listPrice;

    /**
     * 当前库存(冗余)
     */
    private Integer stock;

    /**
     * 库存预警阈值(冗余)
     */
    private Integer stockThreshold;

    /**
     * 计件单位
     */
    private String unit;

    /**
     * 品牌id
     */
    private Long brandId;

    /**
     * 类别id
     */
    private Long categoryId;

    /**
     * 属性模板id
     */
    // private Long attributeTemplateId;

    /**
     * 相册id
     */
    private Long albumId;

    /**
     * 组图URLs,使⽤JSON格式表示
     */
    // private String pictures;

    /**
     * 关键词列表,各关键词使⽤英⽂的逗号分隔
     */
    private String keywords;

    /**
     * 标签列表,各标签使⽤英⽂的逗号分隔,原则上最多3个
     */
    private String tags;

    /**
     * ⾃定义排序序号
     */
    private Integer sort;

    /**
     * Spu详情
     */
    private String detail;

}

在根包下创建ISpuService接口,并在接口中添加“新增SPU”的抽象方法:

@Transactional
public interface ISpuService {
    void addNew(SpuAddNewDTO spuAddNewDTO);
}

在根包下创建SpuServiceImpl类,是组件类,实现以上接口:

@Slf4j
@Service
public class SpuServiceImpl implements ISpuService {
    
    @Autowired
    private SpuMapper spuMapper;
    @Autowired
    private SpuDetailMapper spuDetailMapper;
    @Autowired
    private BrandMapper brandMapper;
    @Autowired
    private CategoryMapper categoryMapper;
    @Autowired
    private AlbumMapper albumMapper;
    
    @Override
    public void addNew(SpuAddNewDTO spuAddNewDTO) {
        // 从参数spuAddNewDTO中取出brandId
        // 调用brandMapper的getDetailsById()方法查询品牌
        // 判断查询结果是否为null
        // 是:抛出异常:选择的品牌不存在
        
        // 判断查询到的品牌的enable是否为0
        // 是:抛出异常
        
        // 从参数spuAddNewDTO中取出categoryId
        // 调用categoryMapper的getDetailsById()方法查询类别
        // 判断查询结果是否为null
        // 是:抛出异常:选择的类别不存在
        
        // 判断查询到的类别的enable是否为0
        // 是:抛出异常
        
        // 判断查询到的类别的isParent是否为1
        // 是:抛出异常
        
        // 从参数spuAddNewDTO中取出albumId
        // 调用albumMapper的getDetailsById()方法查询相册
        // 判断查询结果是否为null
        // 是:抛出异常:选择的相册不存在
        
        // 创建Spu对象
        // 将参数spuAddNewDTO的属性值复制到Spu对象中
        // 补全Spu对象的属性值:id >>> 自行决定
        // 补全Spu对象的属性值:brandName >>> 前序查询品牌的结果中取出
        // 补全Spu对象的属性值:categoryName >>> 前序查询类别的结果中取出
        // 补全Spu对象的属性值:sales / commentCount / positiveCommentCount >>> 0
        // 补全Spu对象的属性值:isDelete / isPublished >>> 0
        // 补全Spu对象的属性值:isNewArrival / isRecommend >>> 自行决定
        // 补全Spu对象的属性值:isChecked >>> 0
        // 补全Spu对象的属性值:checkUser / gmtCheck >>> null
        // 调用spuMapper的int insert(Spu spu)方法插入Spu数据,并获取返回值
        // 判断返回值是否不为1
        // 是:抛出异常
        
        // 创建SpuDetail对象
        // 补全SpuDetail对象的属性值:spuId >>> 同以上Spu对象的id
        // 补全SpuDetail对象的属性值:detail >>> 来自spuAddNewDTO参数
        // 调用spuDetailMapper的int insert(SpuDetail spuDetail)方法插入SpuDetail数据,并获取返回值
        // 判断返回值是否不为1
        // 是:抛出异常
    }
    
}

具体实现为:

package cn.tedu.csmall.product.service.impl;

import cn.tedu.csmall.product.ex.ServiceCode;
import cn.tedu.csmall.product.ex.ServiceException;
import cn.tedu.csmall.product.mapper.*;
import cn.tedu.csmall.product.pojo.dto.SpuAddNewDTO;
import cn.tedu.csmall.product.pojo.entity.Spu;
import cn.tedu.csmall.product.pojo.entity.SpuDetail;
import cn.tedu.csmall.product.pojo.vo.AlbumStandardVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import cn.tedu.csmall.product.pojo.vo.CategoryStandardVO;
import cn.tedu.csmall.product.service.ISpuService;
import cn.tedu.csmall.product.util.IdUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * 处理Spu业务的实现类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Service
@Slf4j
public class SpuServiceImpl implements ISpuService {

    @Autowired
    private SpuMapper spuMapper;
    @Autowired
    private SpuDetailMapper spuDetailMapper;
    @Autowired
    private BrandMapper brandMapper;
    @Autowired
    private CategoryMapper categoryMapper;
    @Autowired
    private AlbumMapper albumMapper;

    @Override
    public void addNew(SpuAddNewDTO spuAddNewDTO) {
        // 从参数spuAddNewDTO中取出brandId
        Long brandId = spuAddNewDTO.getBrandId();
        // 调用brandMapper的getDetailsById()方法查询品牌
        BrandStandardVO brand = brandMapper.getStandardById(brandId);
        // 判断查询结果是否为null
        if (brand == null) {
            // 是:抛出异常:选择的品牌不存在
            String message = "新增Spu失败,尝试绑定的品牌数据不存在!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
        }

        // 判断查询到的品牌的enable是否为0
        if (brand.getEnable() == 0) {
            // 是:抛出异常
            String message = "新增Spu失败,尝试绑定的品牌已经被禁用!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
        }

        // 从参数spuAddNewDTO中取出categoryId
        Long categoryId = spuAddNewDTO.getCategoryId();
        // 调用categoryMapper的getDetailsById()方法查询类别
        CategoryStandardVO category = categoryMapper.getStandardById(categoryId);
        // 判断查询结果是否为null
        if (category == null) {
            // 是:抛出异常:选择的类别不存在
            String message = "新增Spu失败,尝试绑定的类别数据不存在!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
        }

        // 判断查询到的类别的enable是否为0
        if (category.getEnable() == 0) {
            // 是:抛出异常
            String message = "新增Spu失败,尝试绑定的类别已经被禁用!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
        }

        // 判断查询到的类别的isParent是否为1
        if (category.getIsParent() == 1) {
            // 是:抛出异常
            String message = "新增Spu失败,尝试绑定的类别包含子级类别,不允许使用此类别!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
        }

        // 从参数spuAddNewDTO中取出albumId
        Long albumId = spuAddNewDTO.getAlbumId();
        // 调用albumMapper的getDetailsById()方法查询相册
        AlbumStandardVO album = albumMapper.getStandardById(albumId);
        // 判断查询结果是否为null
        if (album == null) {
            // 是:抛出异常:选择的相册不存在
            String message = "新增Spu失败,尝试绑定的相册数据不存在!";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
        }

        // 获取id(由别处生成)
        Long id = IdUtils.getId();

        // 创建Spu对象
        Spu spu = new Spu();
        // 将参数spuAddNewDTO的属性值复制到Spu对象中
        BeanUtils.copyProperties(spuAddNewDTO, spu);
        // 补全Spu对象的属性值:id >>> 自行决定
        spu.setId(id);
        // 补全Spu对象的属性值:brandName >>> 前序查询品牌的结果中取出
        spu.setBrandName(brand.getName());
        // 补全Spu对象的属性值:categoryName >>> 前序查询类别的结果中取出
        spu.setCategoryName(category.getName());
        // 补全Spu对象的属性值:sales / commentCount / positiveCommentCount >>> 0
        spu.setSales(0);
        spu.setCommentCount(0);
        spu.setPositiveCommentCount(0);
        // 补全Spu对象的属性值:isDelete / isPublished >>> 0
        spu.setIsDeleted(0);
        spu.setIsPublished(0);
        // 补全Spu对象的属性值:isNewArrival / isRecommend >>> 自行决定
        spu.setIsNewArrival(0);
        spu.setIsRecommend(0);
        // 补全Spu对象的属性值:isChecked >>> 0
        spu.setIsChecked(0);
        // 补全Spu对象的属性值:checkUser / gmtCheck >>> null
        // 调用spuMapper的int insert(Spu spu)方法插入Spu数据,并获取返回值
        int rows = spuMapper.insert(spu);
        // 判断返回值是否不为1
        if (rows != 1) {
            // 是:抛出异常
            String message = "新增Spu失败!服务器忙,请稍后再次尝试![错误代码:1]";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_INSERT, message);
        }

        // 创建SpuDetail对象
        SpuDetail spuDetail = new SpuDetail();
        // 补全SpuDetail对象的属性值:spuId >>> 同以上Spu对象的id
        spuDetail.setSpuId(id);
        // 补全SpuDetail对象的属性值:detail >>> 来自spuAddNewDTO参数
        spuDetail.setDetail(spuAddNewDTO.getDetail());
        // 调用spuDetailMapper的int insert(SpuDetail spuDetail)方法插入SpuDetail数据,并获取返回值
        rows = spuDetailMapper.insert(spuDetail);
        // 判断返回值是否不为1
        if (rows != 1) {
            // 是:抛出异常
            String message = "新增Spu失败!服务器忙,请稍后再次尝试![错误代码:2]";
            log.warn(message);
            throw new ServiceException(ServiceCode.ERR_INSERT, message);
        }
    }

}

src/test/java的根包下创建service.SpuServiceTests测试类,编写并执行测试:

@Slf4j
@SpringBootTest
public class SpuServiceTests {

    @Autowired
    ISpuService service;

    @Test
    void testAddNew() {
        try {
            SpuAddNewDTO spuAddNewDTO = new SpuAddNewDTO();
            spuAddNewDTO.setBrandId(2L);
            spuAddNewDTO.setCategoryId(3L);
            spuAddNewDTO.setAlbumId(2L);
            spuAddNewDTO.setName("测试Spu-001");
            service.addNew(spuAddNewDTO);
            log.debug("新增Spu成功!");
        } catch (ServiceException e) {
            log.debug("serviceCode : " + e.getServiceCode());
            log.debug("message : " + e.getMessage());
        }
    }

}

1.4. 关于Controller层

在根包下创建SpuController控制器类,并处理请求:

package cn.tedu.csmall.product.controller;

/**
 * 处理Spu相关请求的控制器
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@RestController
@RequestMapping("/spu")
@Api(tags = "08. SPU管理模块")
public class SpuController {

    @Autowired
    private ISpuService spuService;

    public SpuController() {
        log.info("创建控制器:SpuController");
    }

    // 添加SPU
    // http://localhost:9080/spu/add-new
    @ApiOperation("新增SPU")
    @ApiOperationSupport(order = 100)
    @PostMapping("/add-new")
    public JsonResult<Void> addNew(@Validated SpuAddNewDTO spuAddNewDTO) {
        log.debug("开始处理【新增SPU】的请求:{}", spuAddNewDTO);
        spuService.addNew(spuAddNewDTO);
        return JsonResult.ok();
    }

}

关于Redis

Redis是一款使用K-V结构的、基于内存实现数据存取的NoSQL非关系型数据库

使用Redis的主要目的是“缓存数据”,以提高查询数据的效率,对数据库也有一定的保护作用。

需要注意:缓存的数据可能存在“不一致”的问题,因为,如果修改了数据库(例如MySQL)中的数据,而缓存(例如Redis)中的数据没有一并更新,则缓存中的数据是不准确的!但是,并不是所有的场景都要求数据非常准确!

所以,使用Redis的前提条件:

  • 对数据的准确性要求不高

    • 例如:新浪微博的热搜排名、某个热门视频的播放量

  • 数据的修改频率不高

    • 例如:电商平台中的类别、电商平台中的品牌

反之,某些情况下是不应该使用Redis的,例如:在秒杀商品时,使用Redis记录一些频繁修改的数据!

在操作系统的终端下,通过redis-cli命令即可登录Redis控制台:

redis-cli

在Redis控制台中,使用setget命令就可以存取基本类型的数据:

set name liucangsong
get name

在Redis中,有5种典型数据类型:字符串、Hash、Set、zSet、List,在结合编程时,通常,只需要使用“字符串”即可,在程序中,非字符串类型的数据(例如对象、集合等)都会通过工具转换成JSON格式的字符串再存入到Redis中,后续,从Redis中取出的也是字符串,再通过工具转换成原本的类型(例如对象、集合等)即可。

作业

实现以下功能(含前端页面与后端服务)

  • 显示SPU列表

  • 删除SPU

    • 业务规则:数据必须存在

    • 注意:需删除pms_spupms_spu_detail这2张表中的相关数据

  • 逻辑删除SPU

    • 业务规则:数据必须存在

    • 注意:本质上是执行UPDATE操作,将is_delete改为1

  • 恢复SPU

    • 业务规则:数据必须存在

    • 注意:本质上是执行UPDATE操作,将is_delete改为0

  • 根据id查询SPU详情

    • 特别说明:只需要完成后端,不需要实现前端页面

Mybatis的缓存机制

Mybatis框架内置了一级缓存机制与二级缓存机制。

Mybatis框架的一级缓存又称之为会话(Session)缓存,默认是开启的,且无法关闭!

一级缓存必须满足:同一个SqlSession、同一个Mapper对象、执行相同的查询、且参数相同!

通过测试并观察日志【参见下文】,可以看到,第1次执行查询,但是,第2次并没有真正的执行查询,并且,2次查询结果的hashCode值是完全相同的!也就是说:Mybatis只执行了第1次的查询,当执行第2次的代码时,并没有真正连接到MySQL数据库执行查询,而是将第1次的查询结果直接返回了!也可以说:Mybatis在第1次查询后,就把查询结果缓存下来了!

一级缓存会因为以下任一原因消失:

  • 调用SqlSession对象的clearCache()方法,将清空缓存

  • 当前执行了任何写操作(增/删/改),无论任何数据有没有发生变化,都会清空缓存

package cn.tedu.csmall.product;

import cn.tedu.csmall.product.mapper.BrandMapper;
import cn.tedu.csmall.product.pojo.entity.Brand;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
public class MybatisCacheTests {

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Test
    void testL1Cache() {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        BrandMapper brandMapper = sqlSession.getMapper(BrandMapper.class);

        Long id = 1L;
        log.debug("开始执行第1次【id={}】的查询……", id);
        BrandStandardVO result1 = brandMapper.getStandardById(id);
        log.debug("第1次的查询结果:{}", result1);
        log.debug("第1次的查询结果的hashCode:{}", result1.hashCode());

        // sqlSession.clearCache();
        // log.debug("已经调用clearCache()方法清除缓存!");

        log.debug("开始执行第2次【id={}】的查询……", id);
        BrandStandardVO result2 = brandMapper.getStandardById(id);
        log.debug("第2次的查询结果:{}", result2);
        log.debug("第2次的查询结果的hashCode:{}", result2.hashCode());

        id = 4L;
        log.debug("开始执行第1次【id={}】的查询……", id);
        BrandStandardVO result3 = brandMapper.getStandardById(id);
        log.debug("第2次的查询结果:{}", result3);
        log.debug("第2次的查询结果的hashCode:{}", result3.hashCode());

        // sqlSession.clearCache();
        // log.debug("已经调用clearCache()方法清除缓存!");

        log.debug("准备修改【id={}】的数据……", 40000);
        Brand brand = new Brand();
        brand.setId(40000L);
        brand.setSort(99);
        brandMapper.updateById(brand);
        log.debug("完成修改【id={}】的数据", 40000);

        log.debug("开始执行第2次【id={}】的查询……", id);
        BrandStandardVO result4 = brandMapper.getStandardById(id);
        log.debug("第2次的查询结果:{}", result4);
        log.debug("第2次的查询结果的hashCode:{}", result4.hashCode());

        id = 1L;
        log.debug("开始执行第3次【id={}】的查询……", id);
        BrandStandardVO result5 = brandMapper.getStandardById(id);
        log.debug("第3次的查询结果:{}", result5);
        log.debug("第3次的查询结果的hashCode:{}", result5.hashCode());
    }

}

Mybatis框架的二级缓存也称之为namespace缓存,是作用于某个namespace的!

二级缓存的应用优先级高于一级缓存,也就是说,Mybatis框架将优先从二级缓存中查找数据,如果命中,将返回,如果未命中,则从一级缓存中查找数据,如果命中,将返回,如果仍未命中,将执行真正的查询!

在使用Spring Boot与Mybatis集成的框架的项目中,二级缓存默认是全局开启的,各namespace默认未开启,如果需要开启,需要在XML文件中添加<cache/>标签,则表示当前XML中所有的查询都开启了二级缓存!另外,在每个查询上都有useCache属性,默认为true,如果在同一个namespace中,有些查询需要使用二级缓存,也有一些并不需要使用,则可以将不需要使用二级缓存的查询配置为useCache="false"

当启用了二缓存后,每次尝试查询时,日志中都会出现 Cache Hit Ratio 的记录!

@Autowired
BrandMapper brandMapper;

@Test
void testL2Cache() {
    Long id = 1L;
    log.debug("开始执行第1次【id={}】的查询……", id);
    BrandStandardVO result1 = brandMapper.getStandardById(id);
    log.debug("第1次的查询结果:{}", result1);
    log.debug("第1次的查询结果的hashCode:{}", result1.hashCode());

    log.debug("准备修改【id={}】的数据……", 40000);
    Brand brand = new Brand();
    brand.setId(40000L);
    brand.setSort(99);
    brandMapper.updateById(brand);
    log.debug("完成修改【id={}】的数据", 40000);

    log.debug("开始执行第2次【id={}】的查询……", id);
    BrandStandardVO result2 = brandMapper.getStandardById(id);
    log.debug("第2次的查询结果:{}", result2);
    log.debug("第2次的查询结果的hashCode:{}", result2.hashCode());

    id = 4L;
    log.debug("开始执行第1次【id={}】的查询……", id);
    BrandStandardVO result3 = brandMapper.getStandardById(id);
    log.debug("第2次的查询结果:{}", result3);
    log.debug("第2次的查询结果的hashCode:{}", result3.hashCode());

    log.debug("开始执行第2次【id={}】的查询……", id);
    BrandStandardVO result4 = brandMapper.getStandardById(id);
    log.debug("第2次的查询结果:{}", result4);
    log.debug("第2次的查询结果的hashCode:{}", result4.hashCode());

    id = 1L;
    log.debug("开始执行第3次【id={}】的查询……", id);
    BrandStandardVO result5 = brandMapper.getStandardById(id);
    log.debug("第3次的查询结果:{}", result5);
    log.debug("第3次的查询结果的hashCode:{}", result5.hashCode());
}

二级缓存的数据是存储在磁盘上的,需要查询结果的数据类型实现了Serilizable接口,如果未实现此接口,则查询时直接报错:

org.apache.ibatis.cache.CacheException: Error serializing object.  Cause: java.io.NotSerializableException: cn.tedu.csmall.product.pojo.vo.BrandStandardVO

与一级缓存相同,只要发生了任何写操作(增/删/改),都会自动清除缓存数据!

1. Redis

1.1. Redis的简单操作

当已经安装Redis,并确保环境变量可用后,可以在命令提示符窗口(CMD)或终端(IDEA的Terminal,或MacOS/Linux的命令窗口)中执行相关命令。

在终端下,可以通过redis-cli登录Redis客户端:

redis-cli

在Redis客户端中,可以通过ping检测Redis是否正常工作,将得到PONG的反馈:

ping

在Redis客户端中,可以通过set命令向Redis中存入修改简单类型的数据:

set name jack

在Redis客户端中,可以通过get命令从Redis中取出简单类型的数据:

get name

如果使用的Key并不存在,使用get命令时,得到的结果将是(nil),等效于Java中的null

在Redis客户端中,可以通过keys命令检索Key:

keys *
keys a*

注意:默认情况下,Redis是单线程的,keys命令会执行整个Redis的检索,所以,执行时间可能较长,可能导致阻塞!

1.2. 在Spring Boot项目中读写Redis

1.2.1. 添加依赖

需要添加spring-boot-starter-data-redis依赖项:

<!-- Spring Data Redis:读写Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

以上依赖项默认会连接localhost:6379,并且无用户名、无密码,所以,当你的Redis符合此配置,则不需要在application.properties 中添加任何配置就可以直接编程。

如果需要显式的配置,各配置项的属性名分别为:

  • spring.redis.host

  • spring.redis.port

  • spring.redis.username

  • spring.redis.password

1.2.2. 配置RedisTemplate

在使用以上依赖项实现Redis编程时,需要使用到的工具类型为RedisTemplate,调用此类的对象的方法,即可实现读写Redis中的数据。

在使用之前,应该先在配置类中使用@Bean方法创建RedisTemplate,并实现对RedisTemplate的基础配置,则在项目的根包下创建config.RedisConfiguration类:

package cn.tedu.csmall.product.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.io.Serializable;

/**
 * Redis的配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
public class RedisConfiguration {
    
    public RedisConfiguration() {
        log.info("加载配置类:RedisConfiguration");
    }

    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(
            RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Serializable> redisTemplate 
                = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setValueSerializer(RedisSerializer.json());
        return redisTemplate;
    }

}

1.2.3. 使用ValueOperations读写一般值数据

使用RedisTemplate访问一般值(字符串、数值等)数据时,需要先获取ValueOperations对象,再调用此对象的API进行数据操作。

例如:测试向Redis中写入一个字符串:

@Autowired
RedisTemplate<String, Serializable> redisTemplate;

@Test
void testValueOpsSet() {
    ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
    String key = "username";
    String value = "admin";
    ops.set(key, value);
    log.debug("已经向Redis中写入Key={}且Value={}的数据!", key, value);
}

由于声明的RedisTemplate的值的泛型是Serializable,所以,从Redis中读取到的值的类型会是Serializable接口类型。

例如:测试从Redis中读取此前写入的字符串:

@Test
void testValueOpsGet() {
    ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
    // 从Redis中读取数据
    String key = "username";
    Serializable value = ops.get(key);
    log.debug("已经从Redis中读取Key={}的数据,Value={}", key, value);
}

由于配置RedisTemplate时,使用的值序列化器是JSON(redisTemplate.setValueSerializer(RedisSerializer.json());),所以,可以直接写入对象,会被自动处理为JSON格式的字符串。

另外,由于声明的RedisTemplate的值的泛型是Serializable,所以,写入的值的类型必须实现了Serializable接口。

例如:测试向Redis中写入一个对象:

@Test
void testValueOpsSetObject() {
    ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();

    String key = "brand1";
    Brand brand = new Brand();
    brand.setId(1L);
    brand.setName("大白象");
    brand.setEnable(1);
    
    ops.set(key, brand);
    log.debug("已经向Redis中写入Key={}且Value={}的数据!", key, brand);
}

例如:测试从Redis中读取此前写入的对象:

@Test
void testValueOpsGetObject() {
    ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();

    String key = "brand1";
    Serializable value = ops.get(key);
    log.debug("已经从Redis中读取Key={}的数据,Value={}", key, value);
    log.debug("读取到的值类型是:{}", value.getClass().getName());
    
    if (value instanceof Brand) {
        Brand brand = (Brand) value;
    	log.debug("将读取到的值类型转换为Brand类型,成功:{}", brand);
    } else {
        log.debug("读取到的值类型不是Brand类型,无法实现类型转换!");
    } 
}

1.2.4. 查询Redis中已有的Key

直接调用RedisTemplatekeys()方法,即可查询当前Redis中有哪些Key。

例如:测试查询Redis中所有的Key:

@Test
void testKeys() {
    Set<String> keys = redisTemplate.keys("*");
    for (String key : keys) {
        log.debug("{}", key);
    }
}

1.2.5. 删除Redis中的数据

删除数据时,不关心值的类型,只需要知道Key即可,所以,删除数据时直接调用RedisTemplatedelete()方法即可。

例如:测试删除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并向左侧递减的编号,如下图所示:

使用ListOperationsrange()方法可以获取列表的区间段子列表。

例如:测试获取列表数据:

@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_creategmt_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);
    }
}