WikiWiki
首页
Java开发
Java面试
Linux手册
  • AI相关
  • Python Flask
  • Pytorch
  • youlo8
SEO
uniapp小程序
Vue前端
work
数据库
软件设计师
入门指南
首页
Java开发
Java面试
Linux手册
  • AI相关
  • Python Flask
  • Pytorch
  • youlo8
SEO
uniapp小程序
Vue前端
work
数据库
软件设计师
入门指南
  • API工具笔记
  • Java基础
  • SpringAI开发问答系统
  • 前后端分离架构
  • 工作流Activity7
  • 微服务架构

一、服务器端程序的演进过程

阶段一:静态服务器

早期的服务器状态,编写好一些固定内容,让用户访问 功能单一,如果不修改代码,内容是不会变的,只能做信息的呈现或输出

阶段二:普通动态服务器

网页中的数据可能来自数据库,数据库中的数据可以在后台中进行修改 实现不修改页面代码,但是变化页面内容的效果 因为有了数据库的支持,动态网站开始支持登录注册,增删改查功能

阶段三: 以用户共享内容为主的互联网生态

随着互联网的普及,个人的社交需求提升 出现了很多由用户贡献内容的网站 微博,抖音,淘宝,大众点评或类似的网站

阶段四: 微服务时代(有高并发需求或特征的网站)

随着用户的增加,各种并发的增高,要求我们的服务器在繁忙的情况下,也需要快速的做出响应 用户体验必须保证,这样就要求我们的项目有下面三个目标

高并发,高可用,高性能

高并发:很多用户同时访问这个服务器,这个服务器不能失能

高可用:全年365天每天24小时都可以访问,不能因为个别服务器的异常,导致整个项目的瘫痪

高性能:当用户访问服务器时,响应速度要尽量的快,即使并发高,也要有正常的响应速度

微服务的"三高"

java服务器项目分类

现在市面上常见的java开发的项目可以分为两大类

1.企业级项目

一般指一个企业或机构内部使用的网站或服务器应用程序

使用的人群比较固定,并不向全国乃至全世界开放

例如,商业,企事业单位,医疗,金融,军事,政府等

所以这个项目没有代替品,对"三高"没有强烈要求

企业级项目一般会在权限和业务流程方面设计的比较复杂

2.互联网项目

能够向全国乃至全世界开放的网站或服务器应用程序

我们手机中安装的app大部分都是互联网应用

微信,支付宝,京东,淘宝,饿了么,美团,抖音,qq音乐,爱奇艺,高德地图等

它们因为商业竞争等原因,对服务器的性能有非常高的要求,就是我们之前提到的"三高"

但是互联网应用一般没有权限和业务非常复杂的需求

综上所述,企业级应用和互联网应用的偏重点不同

在当今java开发业界中,基本规律如下

  • 如果开发的是企业级应用,使用单体架构的情况比较多
  • 如果开发的是互联网应用,使用微服务架构的情况比较多

二、微服务概述

什么是微服务

微服务的概念是由Martin Fowler(马丁·福勒)在2014年提出的

image-20220428153622848

微服务是由以单一应用程序构成的小服务,自己拥有自己的行程与轻量化处理,服务依业务功能设计,以全自动的方式部署,与其他服务使用 HTTP API 通信。同时服务会使用最小的规模的集中管理能力,服务可以用不同的编程语言与数据库等组件实现。

简单来说,微服务就是将一个大型项目的各个业务模块拆分成多个互不相关的小项目,而这些小项目专心完成自己的功能,而且可以调用其他小项目的方法,从而完成整体功能

京东\淘宝这样的大型互联网应用程序,基本每个操作都是一个单独的微服务在支持:

  • 登录服务器
  • 搜索服务器
  • 商品信息服务器
  • 购物车服务器
  • 订单服务器
  • 支付服务器
  • 物流服务器
  • .....

为什么需要微服务

image-20220428154923065

左侧小餐馆就像单体项目 一旦服务器忙,所有业务都无法快速响应 ,即使添加了服务器,也不能很好的解决这个问题,不能很好的实现"高并发,高可用,高性能" 但是因为服务器数量少,所以成本低,适合并发访问少的项目 右侧大餐厅就是微服务项目 每个业务专门一批人来负责,业务之间互不影响 在某个模块性能不足时,针对这个模块添加服务器改善性能 万一一个服务器发生异常,并不会影响整体功能 但是完成部署的服务器数量多,成本高,需要较多投资,能够满足"高并发,高可用,高性能"的项目

怎么搭建微服务项目

在微服务概念提出之前(2014年),每个厂商都有自己的解决方案 但是Martin Fowler(马丁·福勒)提出了微服务的标准之后,为了技术统一和兼容性,很多企业开始支持这个标准 现在我们开发的微服务项目,大多数都是在马丁·福勒标准下的 如果我们自己编写支持这个标准的代码是不现实的,必须通过现成的框架或组件完成满足这个微服务标准的项目结构和格式 当今程序员要想快速开发满足上面微服务标准的项目结构,首选SpringCloud (cloud :云朵)

三、微服务项目的创建

1.创建父项目

创建项目名称csmall 我们微服务开发过程中,一般都会使用一个Idea中包含多个项目的形式 这个形式就是先创建一个"父项目",再向这个父项目中创建多个子项目的操作 我们首先创建一个项目:csmall 注意细节的修改

image-20220819094840190

上面图片next之后直接点击finish即可 首先,将当前csmall项目修剪为父项目的格式

  • 删除csmall项目的src文件夹,因为父项目不写代码
  • 修改pom文件

最终csmall的pom文件如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <!--     ↓↓↓↓↓       -->
        <version>2.5.9</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall</name>
    <description>Demo project for Spring Boot</description>
    <!--  
    父子项目结构是Maven提供的,而pom.xml文件就是maven的配置,
    所以需要在这里声明或设置当前项目是父项目结构
    -->
    <packaging>pom</packaging>
    
</project>

https://gitee.com/jtzhanghl/csmall2207.git

2.创建子项目

创建csmall-stock项目

image-20220819101409383

我们每次创建一个子项目之后 都要进行"父子相认" 在父项目的pom文件中,编写子项目的存在

<!--
父子项目结构是Maven提供的,而pom.xml文件就是maven的配置,
所以需要在这里声明或设置当前项目是父项目结构
-->
<packaging>pom</packaging>
<!-- 设置当前父项目有哪些子项目(子模块) module就是模块的意思  -->
<modules>
    <module>csmall-stock</module>
</modules>

还需要在子项目的pom文件中对父项目进行继承操作 父项目的第11行到第13行 复制到子项目的第6行到第8行 子项目pom文件修改后

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-stock</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

</project>

父子相认完成 这样当前子项目就可以读取父项目中的pom文件信息了 删除stock模块src\test文件夹

3.父项目管理依赖版本

在我们现在使用maven添加依赖的认知中 有些依赖时必须添加版本号才能执行 有些依赖则不必添加版本号 原因是我们继承的SpringBoot(2.5.9)父项目中,定义了一些常用依赖的版本号


如果我们自己编写的父项目想定义我们项目中需要的依赖版本号的话,也是可以实现的 这样做可以统一所有子项目的版本,在更新版本时,只需要修改父项目中定义的版本号即可 父项目的pom文件添加如下内容

<!-- 设置当前父项目有哪些子项目(子模块) module就是模块的意思  -->
<modules>
    <module>csmall-stock</module>
</modules>
<!--  在properties标签中,声明需要使用的各个版本号 -->
<properties>
    <java.version>1.8</java.version>
    <!--  这些标签本质就是在定义变量,定义了一个名为mybatis.version值为2.2.2的变量  -->
    <mybatis.version>2.2.0</mybatis.version>
</properties>
<!--下面的标签是用来定义子项目使用依赖时,选用哪个版本号的,也称之为"锁版本"   -->
<!--dependencyManagement标签中的内容,不是添加依赖,而只是指定依赖的版本,子项目真正添加依赖才会生效-->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子项目中如果需要mybatis的依赖只需要添加如下内容即可,无需再指定版本号

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>

上面的操作也称之为"锁版本"

前端Vant项目git地址 https://gitee.com/jtzhanghl/vant2207.git

包含今天笔记的csmall项目 https://gitee.com/jtzhanghl/csmall2207.git

4.加载正式项目pom文件

因为我们学习微服务的过程中需要很多微服务相关的依赖 这些依赖都需要在父项目中进行版本的管理的 所以我们直接使用分享给大家的完整版项目的父项目pom文件即可 父项目完整最终pom文件如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.9</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall</name>
    <description>Demo project for Spring Boot</description>
    <!--
    父子项目结构是Maven提供的,而pom.xml文件就是maven的配置,
    所以需要在这里声明或设置当前项目是父项目结构
    -->
    <packaging>pom</packaging>
    <!-- 设置当前父项目有哪些子项目(子模块) module就是模块的意思  -->
    <modules>
        <module>csmall-stock</module>
    </modules>
    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>2020.0.3</spring-cloud.version>
        <spring-cloud-alibaba.version>2.2.2.RELEASE</spring-cloud-alibaba.version>
        <spring-boot.version>2.5.4</spring-boot.version>
        <spring-boot-configuration-processor.version>2.3.0.RELEASE</spring-boot-configuration-processor.version>
        <spring-security-jwt.version>1.0.10.RELEASE</spring-security-jwt.version>
        <mybatis-spring-boot.version>2.2.0</mybatis-spring-boot.version>
        <mybaits-plus.version>3.4.1</mybaits-plus.version>
        <pagehelper-spring-boot.version>1.4.0</pagehelper-spring-boot.version>
        <mysql.version>8.0.26</mysql.version>
        <lombok.version>1.18.20</lombok.version>
        <knife4j-spring-boot.version>2.0.9</knife4j-spring-boot.version>
        <spring-rabbit-test.version>2.3.10</spring-rabbit-test.version>
        <spring-security-test.version>5.5.2</spring-security-test.version>
        <fastjson.version>1.2.45</fastjson.version>
        <druid.version>1.1.20</druid.version>
        <jjwt.version>0.9.0</jjwt.version>
        <seata-server.version>1.4.2</seata-server.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
    <!-- 依赖管理 -->
    <dependencyManagement>
        <dependencies>
            <!--seata-all-->
            <dependency>
                <groupId>io.seata</groupId>
                <artifactId>seata-all</artifactId>
                <version>${seata-server.version}</version>
            </dependency>
            <!-- Lombok -->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
            </dependency>
            <!-- MySQL -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
                <scope>runtime</scope>
            </dependency>
            <!-- Alibaba Druid -->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid</artifactId>
                <version>${druid.version}</version>
            </dependency>
            <!-- MyBatis Spring Boot:数据访问层MyBatis编程 -->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis-spring-boot.version}</version>
            </dependency>
            <!-- MyBatis Plus Spring Boot:MyBatis增强 -->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybaits-plus.version}</version>
            </dependency>
            <!-- MyBatis Plus Generator:代码生成器 -->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-generator</artifactId>
                <version>${mybaits-plus.version}</version>
            </dependency>
            <!-- PageHelper Spring Boot:MyBatis分页 -->
            <dependency>
                <groupId>com.github.pagehelper</groupId>
                <artifactId>pagehelper-spring-boot-starter</artifactId>
                <version>${pagehelper-spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot:基础框架 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Web:WEB应用 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Freemarker:MyBaits Plus Generator的辅助项 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-freemarker</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Validation:验证请求参数 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-validation</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Security:认证授权 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Oauth2:认证授权 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-oauth2-client</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot配置处理器 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-configuration-processor</artifactId>
                <version>${spring-boot-configuration-processor.version}</version>
            </dependency>
            <!-- Spring Security JWT -->
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-jwt</artifactId>
                <version>${spring-security-jwt.version}</version>
            </dependency>
            <!-- Knife4j Spring Boot:在线API -->
            <dependency>
                <groupId>com.github.xiaoymin</groupId>
                <artifactId>knife4j-spring-boot-starter</artifactId>
                <version>${knife4j-spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Data Redis:缓存 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Data MongoDB:缓存 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-mongodb</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Data Elasticsearch:文档搜索 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot AMQP:消息队列 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-amqp</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Actuator:健康监测 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-actuator</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Cloud家族 -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Spring Cloud Alibaba -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Alibaba FastJson -->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>${fastjson.version}</version>
            </dependency>
            <!-- JJWT -->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>${jjwt.version}</version>
            </dependency>
            <!-- Spring Boot Test:测试 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <version>${spring-boot.version}</version>
                <scope>test</scope>
            </dependency>
            <!-- Spring Rabbit Test:消息队列测试 -->
            <dependency>
                <groupId>org.springframework.amqp</groupId>
                <artifactId>spring-rabbit-test</artifactId>
                <version>${spring-rabbit-test.version}</version>
                <scope>test</scope>
            </dependency>
            <!-- Spring Security Test:Security测试 -->
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-test</artifactId>
                <version>${spring-security-test.version}</version>
                <scope>test</scope>
            </dependency>
            <!--seata整合springboot-->
            <dependency>
                <groupId>io.seata</groupId>
                <artifactId>seata-spring-boot-starter</artifactId>
                <version>${seata-server.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

</project>

5.创建通用模块(项目)commons

创建项目

在实际开发中 经常会出现有些类需要在多个微服务项目中使用的情况 为了减少代码的冗余 我们在父项目中创建一个子项目csmall-commons专门保存编写这样的类 然后哪个微服务需要使用,就添加对commons的依赖即可 我们先来创建csmall-commons这个项目 父子相认

<modules>
    <module>csmall-stock</module>
    <module>csmall-commons</module>
</modules>

子项目pom文件最终如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-commons</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-commons</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <!--在线api文档-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
        <!-- Spring Boot Web:WEB应用 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-json</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
</project>

当前模块只是编写通用类和代码 实际上不需要运行 所以删除运行相关的文件 删除test测试文件夹 删除resources文件夹 删除SpringBoot启动类

创建实体类

带大家编写一个我们需要的实体类 创建cn.tedu.csmall.commons.pojo.cart.dto

DTO:前端收集到数据发送给后端的信息

VO:后端从数据库查询出来要发送给前端的信息

包中创建CartAddDTO,代码如下

@Data
// 声明使用在Knife4j测试中
@ApiModel
public class CartAddDTO implements Serializable {

    @ApiModelProperty(value = "商品编号", name = "commodityCode",example = "PC100")
    private String commodityCode;
    @ApiModelProperty(value = "价格", name = "price",example = "20")
    private Integer price;
    @ApiModelProperty(value = "数量", name = "count",example = "5")
    private Integer count;
    @ApiModelProperty(value = "用户Id", name = "userId",example = "UU100")
    private String userId;

}

创建包 pojo.cart.model 包中创建类Cart

@Data
public class Cart implements Serializable {
    private Integer id;
    private String commodityCode;
    private Integer price;
    private Integer count;
    private String userId;

}

下面创建订单模块需要的类 pojo.order.dto.OrderAddDTO

@ApiModel("新增订单的DTO")
@Data
public class OrderAddDTO implements Serializable {
    @ApiModelProperty(value = "用户id",name="userId",example = "UU100")
    private String userId;
    @ApiModelProperty(value = "商品编号",name="commodityCode",example = "PC100")
    private String commodityCode;
    @ApiModelProperty(value = "商品数量",name="count",example = "5")
    private Integer count;
    @ApiModelProperty(value = "总金额",name="money",example = "50")
    private Integer money;
}

pojo.order.model.Order

@Data
public class Order implements Serializable {
    private Integer id;
    private String userId;
    private String commodityCode;
    private Integer count;
    private Integer money;
}

最后是库存相关的类 pojo.stock.dto.StockReduceCountDTO

@ApiModel("商品减少库存DTO")
@Data
public class StockReduceCountDTO implements Serializable {
    @ApiModelProperty(value = "商品编号",name="commodityCode",example = "PC100")
    private String commodityCode;
    @ApiModelProperty(value = "减库存数",name="reduceCount",example = "5")
    private Integer reduceCount;
}

pojo.stock.model.Stock

@Data
public class Stock implements Serializable {
    private Integer id;
    private String commodityCode;
    private Integer reduceCount;
}

创建异常相关类

除了实体类多个模块需要使用之外 像异常类和控制器返回的JsonResult类也是多个模块需要使用的类型 它们也要编写在commons中 创建cn.tedu.csmall.commons.restful包 在这个包中先创建异常响应码枚举

/**
 * 错误代码枚举类型
 */
public enum ResponseCode {

    OK(200),
    BAD_REQUEST(400),
    UNAUTHORIZED(401),
    FORBIDDEN(403),
    NOT_FOUND(404),
    NOT_ACCEPTABLE(406),
    CONFLICT(409),
    INTERNAL_SERVER_ERROR(500);

    private Integer value;

    ResponseCode(Integer value) {
        this.value = value;
    }

    public Integer getValue() {
        return value;
    }

}

下面定义自定义异常类 创建包cn.tedu.csmall.commons.exception 包中创建类CoolSharkServiceException

/**
 * 业务异常
 */
@Data
@EqualsAndHashCode(callSuper = false)
public class CoolSharkServiceException extends RuntimeException {

    private ResponseCode responseCode;

    public CoolSharkServiceException(ResponseCode responseCode, String message) {
        super(message);
        setResponseCode(responseCode);
    }

}

将restful包中用于控制器返回的JsonResult类复制

/**
 * 通用响应对象
 */
@Data
public class JsonResult<T> implements Serializable {

    /**
     * 状态码
     */
    @ApiModelProperty(value = "业务状态码", position = 1, example = "200, 400, 401, 403, 404, 409, 500")
    private Integer state;
    /**
     * 消息
     */
    @ApiModelProperty(value = "业务消息", position = 2, example = "登录失败!密码错误!")
    private String message;
    /**
     * 数据
     */
    @ApiModelProperty(value = "业务数据", position = 3)
    private T data;

    /**
     * 创建响应结果对象,表示"成功",不封装其它任何数据
     * @return 响应结果对象
     */
    public static JsonResult<Void> ok() {
        return ok("OK");
    }

    public static JsonResult ok(String message){
        JsonResult jsonResult=new JsonResult();
        jsonResult.setState(ResponseCode.OK.getValue());
        jsonResult.setMessage(message);
        jsonResult.setData(null);
        return jsonResult;
    }
    /**
     * 创建响应结果对象,表示"成功",且封装客户端期望响应的数据
     * @param data 客户端期望响应的数据
     * @return 响应结果对象
     */
    public static <T> JsonResult<T> ok(String message,T data) {
        JsonResult<T> jsonResult = new JsonResult<>();
        jsonResult.setState(ResponseCode.OK.getValue());
        jsonResult.setData(data);
        return jsonResult;
    }
    /**
     * 创建响应结果对象,表示"失败",且封装"失败"的描述
     *
     * @param e CoolSharkServiceException异常对象
     * @return 响应结果对象
     */
    public static JsonResult<Void> failed(CoolSharkServiceException e) {
        return failed(e.getResponseCode(), e);
    }

    /**
     * 创建响应结果对象,表示"失败",且封装"失败"的描述
     *
     * @param responseCode "失败"的状态码
     * @param e            "失败"时抛出的异常对象
     * @return 响应结果对象
     */
    public static JsonResult<Void> failed(ResponseCode responseCode, Throwable e) {
        return failed(responseCode, e.getMessage());
    }

    /**
     * 创建响应结果对象,表示"失败",且封装"失败"的描述
     *
     * @param responseCode "失败"的状态码
     * @param message      "失败"的描述文本
     * @return 响应结果对象
     */
    public static JsonResult<Void> failed(ResponseCode responseCode, String message) {
        JsonResult<Void> jsonResult = new JsonResult<>();
        jsonResult.setState(responseCode.getValue());
        jsonResult.setMessage(message);
        return jsonResult;
    }

}

我们编写的所有模块控制层发生异常时,也都由SpringMvc的统一异常处理类来处理 所以commons模块编写统一异常处理类也是常规操作

/**
 * 全局异常处理器
 */
@RestControllerAdvice
@Slf4j
public class GlobalControllerExceptionHandler {

    /**
     * 处理业务异常
     */
    @ExceptionHandler({CoolSharkServiceException.class})
    public JsonResult<Void> handleCoolSharkServiceException(CoolSharkServiceException e) {
        log.debug("出现业务异常,业务错误码={},描述文本={}", e.getResponseCode().getValue(), e.getMessage());
        e.printStackTrace();
        JsonResult<Void> result = JsonResult.failed(e);
        log.debug("即将返回:{}", result);
        return result;
    }

    /**
     * 处理绑定异常(通过Validation框架验证请求参数时的异常)
     */
    @ExceptionHandler(BindException.class)
    public JsonResult<Void> handleBindException(BindException e) {
        log.debug("验证请求数据时出现异常:{}", e.getClass().getName());
        e.printStackTrace();
        String message = e.getBindingResult().getFieldError().getDefaultMessage();
        JsonResult<Void> result = JsonResult.failed(ResponseCode.BAD_REQUEST, message);
        log.debug("即将返回:{}", result);
        return result;
    }

    /**
     * 处理系统(其它)异常
     */
    @ExceptionHandler({Throwable.class})
    public JsonResult<Void> handleSystemError(Throwable e) {
        log.debug("出现系统异常,异常类型={},描述文本={}", e.getClass().getName(), e.getMessage());
        e.printStackTrace();
        JsonResult<Void> result = JsonResult.failed(ResponseCode.INTERNAL_SERVER_ERROR, e);
        log.debug("即将返回:{}", result);
        return result;
    }
}

commons模块内容编写暂时告一段落

6.创建business模块

business:商业\生意\业务的意思 这个模块创建出来是为了触发订单业务的

2022-06-21_103922

创建项目

创建子项目csmall-business 父子相认 子项目pom文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-business</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-business</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-commons</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

删除测试的test文件夹

配置yml文件内容

business模块 创建application.yml文件,删除application.properties文件 yml文件内容如下

server:
  port: 20000
#公共配置
mybatis:
  configuration:
    cache-enabled: false   # 不启用mybatis缓存
    map-underscore-to-camel-case: true # 映射支持驼峰命名法
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 将运行的sql输出到控制台
knife4j:
  # 开启增强配置
  enable: true
  # 生产环境屏蔽,开启将禁止访问在线API文档
  production: false
  # Basic认证功能,即是否需要通过用户名、密码验证后才可以访问在线API文档
  basic:
    # 是否开启Basic认证
    enable: false
    # 用户名,如果开启Basic认证却未配置用户名与密码,默认是:admin/123321
    username: root
    # 密码
    password: root
spring:
  profiles:
    active: dev

我们在配置末尾看到了

spring:
  profiles:
    active: dev

上面的配置含义是让SpringBoot读取额外配置文件 我们参数值编写的是dev,是可以随意修改的,这里定义的名称,就是指定配置文件的名称 例如当前我们编写的dev,SpringBoot就会额外加载名称为application-dev.yml文件 我们创建application-dev.yml文件,以备添加配置

image-20220722155713748

SpringBoot相关配置

创建config包,编写一些必要配置 首先创建CommonsConfiguration

// 当前类是配置Spring扫描环境的配置类,必须添加配置注解@Configuration才能生效
@Configuration
// 我们要扫描commons模块中,统一异常处理类所在的包名,使异常处理功能生效
@ComponentScan("cn.tedu.csmall.commons.exception")
public class CommonsConfiguration {
}

下面配置knife4JConfiguration

这个类直接复制即可

/**
 * Knife4j(Swagger2)的配置
 */
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfiguration {

    /**
     * 【重要】指定Controller包路径
     */
    private String basePackage = "cn.tedu.csmall.business.controller";
    /**
     * 分组名称
     */
    private String groupName = "base-business";
    /**
     * 主机名
     */
    private String host = "http://java.tedu.cn";
    /**
     * 标题
     */
    private String title = "酷鲨商城项目案例在线API文档--基础business-web实例";
    /**
     * 简介
     */
    private String description = "构建基础business-web项目,实现购买";
    /**
     * 服务条款URL
     */
    private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";
    /**
     * 联系人
     */
    private String contactName = "项目研发部";
    /**
     * 联系网址
     */
    private String contactUrl = "http://java.tedu.cn";
    /**
     * 联系邮箱
     */
    private String contactEmail = "java@tedu.cn";
    /**
     * 版本号
     */
    private String version = "1.0-SNAPSHOT";

    @Autowired
    private OpenApiExtensionResolver openApiExtensionResolver;

    @Bean
    public Docket docket() {
        String groupName = "1.0-SNAPSHOT";
        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();
    }

}

开发business的业务代码

因为business模块是业务的触发者,所以不需要数据库操作,直接编写业务逻辑层即可 创建service包,包中创建IBusinessService接口

public interface IBusinessService {
    
    // 声明触发提交订单业务的方法
    void buy();
}

新建service.impl包 包中创建BusinessServiceImpl类 代码如下

@Service
@Slf4j
public class BusinessServiceImpl implements IBusinessService {

    @Override
    public void buy() {
        // 模拟购买业务
        // 先实例化一个用于新增订单的DTO对象
        OrderAddDTO orderAddDTO=new OrderAddDTO();
        orderAddDTO.setUserId("UU100");
        orderAddDTO.setCommodityCode("PC100");
        orderAddDTO.setCount(5);
        orderAddDTO.setMoney(100);
        // 因为是模拟购买,所以暂时只做输出信息效果即可
        log.info("新增订单信息为:{}",orderAddDTO);


    }
}

创建控制层controller 创建类BusinessController代码如下

@RestController
@RequestMapping("/base/business")
@Api(tags = "业务触发模块")
public class BusinessController {
    @Autowired
    private IBusinessService businessService;

    @PostMapping("/buy")
    @ApiOperation("执行业务的触发")
    public JsonResult buy(){
        businessService.buy();
        return JsonResult.ok("购买完成!");
    }


}

启动当前business项目 访问 http://localhost:20000/doc.html 点击测试,观察输出结果和控制台输出内容是否正常

image-20220819160024437

image-20220819160140496

7.将项目注册到Nacos

我们已经讲过,一个项目要想成为微服务项目体系的一部分 必须将当前项目的信息注册到Nacos 我们要添加一些配置,实现business模块启动时注册到Nacos的效果 首先business模块pom文件中添加依赖

<!-- 支持项目注册到Nacos注册中心的依赖   discovery是发现的意思(微服务项目的发现) -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

我们在创建好的application-dev.yml中编写对nacos注册的配置信息

spring:
  application:
    # 设置当前项目的名称,这个名字会提交到Nacos,注册为当前微服务的名称
    name: nacos-business
  cloud:
    nacos:
      discovery:
        # 配置Nacos的位置,用于提交当前项目信息
        server-addr: localhost:8848

按照学习Nacos时的方式,启动一下nacos 启动之后, 重启business模块,如果启动也正常,就应该将当前项目的信息提交给Nacos 在Nacos的服务管理->服务列表中,能看到nacos-business的名称

image-20220722174018971

8. 修改stock模块

创建csmall-stock-service项目

因为当前stock模块减少库存数业务是典型的生产者方法,需要被别的模块调用 那么其它模块就必须添加当前库存数减少的业务接口支持 为了减少添加这个给当前项目带来的负担 业界通用做法,是将生产者项目拆分为两个, 其中一个项目只有业务逻辑层接口 另一个项目包含正常的配置和所有业务代码 当消费者需要时只需要添加包含业务逻辑层接口的项目的依赖即可 创建csmall-stock-service项目 删除test\删除resources\删除SpringBoot启动类 csmall-stock升格为父项目,所以也要修改它的pom文件

<packaging>pom</packaging>
<modules>
    <module>csmall-stock-service</module>
</modules>

csmall-stock-service项目的pom文件添加最低要求的依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall-stock</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-stock-service</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-commons</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>

然后将原有的业务逻辑层接口IStockService复制到这个项目中即可

public interface IStockService {

    // 减少库存的业务逻辑层方法
    void reduceCommodityCount(StockReduceCountDTO stockReduceCountDTO);
}

创建csmall-stock-webapi项目

webapi项目包含stock项目原有的所有配置和业务代码 创建好项目之后删除test文件夹、删除application.properties文件 然后父子相认 最终csmall-stock项目的pom文件为

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-stock</name>
    <description>Demo project for Spring Boot</description>
    <packaging>pom</packaging>
    <modules>
        <module>csmall-stock-service</module>
        <module>csmall-stock-webapi</module>
    </modules>
    
</project>

csmall-stock-webapi项目的pom文件为

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall-stock</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock-webapi</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-stock-webapi</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <!--web实例-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mybatis整合springboot-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!--alibaba 数据源德鲁伊-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
        </dependency>
        <!--mysql驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!--all-common依赖-->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-commons</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <!--在线api文档-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
        <!--  Nacos注册依赖 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--  Dubbo 在SpringCloud中使用的依赖 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-dubbo</artifactId>
        </dependency>
        <!--  当前项目需要在业务逻辑层实现类中实现业务逻辑层接口
           所以业务逻辑层接口的依赖也要添加(依赖csmall-stock-service)-->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-stock-service</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

上面的pom文件不只是将原有的stock的pom文件依赖复制,而且添加了Dubbo和业务逻辑层接口的依赖

将csmall-stock项目的application.yml和application-dev.yml复制到csmall-stock-webapi项目的resources文件夹下

yml文件其它的不动,但是在dev.yml文件中要添加dubbo的配置信息

spring:
  application:
    name: nacos-stock # 定义当前服务名称
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 # 指定正在运行的Nacos服务器的位置
  datasource:
    url: jdbc:mysql://localhost:3306/csmall_db?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
    username: root
    password: root
dubbo:
  protocol:
    # port设置为-1 表示当前dubbo框架使用自动寻找端口的功能
    # 生成端口的规则是从20880开始寻找可用端口,如果当前端口号占用,就尝试+1,直到找到可用端口为止
    port: -1
    # 设置连接的名称,一般固定为dubbo即可
    name: dubbo
  registry:
    # 指定当前项目dubbo注册中心的类型和位置
    address: nacos://localhost:8848
  consumer:
    # 设置当前项目启动时,是否检查本项目需要的所有服务都可用
    # 设置它的值为false,表示不检查,以减少启动时因为需要的服务不可用导致的错误
    check: false

开始复制代码

我们先将csmall-stock模块中config\controller\mapper\service直接复制 粘贴到webapi项目cn.tedu.csmall.stock.webapi包下 删除IStockService接口 业务逻辑层实现类需要重新导入Mapper的包来实现正确编译 修改config包中 Knife4jConfiguration:

/**
 * 【重要】指定Controller包路径
 */
private String basePackage = "cn.tedu.csmall.stock.webapi.controller";

MyBatisConfiguration

@Configuration
// MyBatis框架扫描mapper接口包的注解
@MapperScan("cn.tedu.csmall.stock.webapi.mapper")
public class MyBatisConfiguration {
}

然后就可以删除原csmall-stock模块的src文件夹了 下面就可以配置实现Dubbo方法提供的步骤了 将业务逻辑层实现类方法声明为Dubbo可调用的方法 当前stock模块是单纯的生产者 StockServiceImpl修改代码

// @DubboService注解,标记的业务逻辑层实现类,其中的所有方法都会注册到Nacos
// 在其他服务启动"订阅"后,就会"发现"当前类中的所有服务,随时可以调用
@DubboService
@Service
@Slf4j
public class StockServiceImpl implements IStockService {
    // ....  内容略
}

如果当前项目是服务的提供者(生产者) 还需要在SpringBoot启动类上添加@EnableDubbo的注解,才能真正让Dubbo功能生效

@SpringBootApplication
// 如果当前项目是dubbo调用中的生产者,必须添加@EnableDubbo注解
// 添加之后,在服务启动时,当前项目的所有服务才能正确的注册到Nacos
@EnableDubbo
public class CsmallStockWebapiApplication {

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

}

先启动nacos 再启动stockWebapi项目

作业: 按上面stock模块的操作 改写cart模块的结构,以实现Dubbo生产者功能

修改cart模块 操作步骤和stock完全一致,参考stock模块即可

修改order模块支持Dubbo 因为order模块在Dubbo的调用关系中 既是生产者又是消费者 它消费cart和stock的服务 同时又为business模块提供服务 重构的过程和stock\cart有很多相似,但是也要注意不同

创建csmall-order-service项目 这个项目创建的过程和stock\cart模块service项目的步骤和注意事项完全一致

创建csmall-order-webapi项目 创建项目后父子相认正常

子项目的pom文件依赖需要额外添加下面内容

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-dubbo</artifactId>
</dependency>
<dependency>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-order-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>
<!--  Order模块作为消费者,需要添加消费的业务逻辑层接口模块依赖  -->
<dependency>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-cart-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

yml文件配置也和cart\stock模块一致

代码也都正常从csmall-order复制到csmall-order-webapi中

删除csmall-order的src目录

config包中的配置,修改为正确包名

在OrderServiceImpl业务逻辑层实现类中

添加生产者的注解,同时利用Dubbo消费stock和cart模块的方法

Reference:引用

// business模块要调用order,所以当前项目也是生产者,所以也要加@DubboService注解
@DubboService
@Service
@Slf4j
public class OrderServiceImpl implements IOrderService {
    @Autowired
    private OrderMapper orderMapper;

    // 添加@DubboReference注解,表示当前业务逻辑层中要消费其他模块的服务了
    // 注解后面声明的应该是Dubbo注册到Nacos其他模块声明的业务逻辑层接口
    // 业务逻辑层接口实现类会在Dubbo框架下自动获取
    @DubboReference
    private IStockService stockService;
    @DubboReference
    private ICartService cartService;

    @Override
    public void orderAdd(OrderAddDTO orderAddDTO) {
        // 1.先去减少订单中商品的库存数量(调用stock模块减少库存的方法)
        // 库存模块减少库存需要StockReduceCountDTO对象,才能运行,所以先实例化它
        StockReduceCountDTO countDTO=new StockReduceCountDTO();
        countDTO.setCommodityCode(orderAddDTO.getCommodityCode());
        countDTO.setReduceCount(orderAddDTO.getCount());
        // 利用dubbo调用stock模块的业务逻辑层方法实现库存的减少
        stockService.reduceCommodityCount(countDTO);

        // 2.从购物车中删除用户勾选的商品(调用cart模块删除购物车商品的方法)
        // 利用dubbo调用cart模块的业务逻辑层方法实现购物车中商品的删除
        cartService.deleteUserCart(orderAddDTO.getUserId(),
                                    orderAddDTO.getCommodityCode());

        // 3.新增订单信息
        Order order=new Order();
        BeanUtils.copyProperties(orderAddDTO,order);
        // 执行新增
        orderMapper.insertOrder(order);
        log.info("新增订单信息为:{}",order);

    }
}

因为order模块也是生产者@EnableDubbo注解仍然要写

@SpringBootApplication
@EnableDubbo
public class CsmallOrderWebapiApplication {

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

}

我们可以测试这个Dubbo的功能

首先保证Nacos启动

我们项目的启动顺序,尽量保证生产者先启动

启动消费者

stock\cart最后启动order

访问

http://localhost:20002/doc.html运行测试

注意运行前,数据库的数据状态和运行后的比较一下

启动服务时,可能发生RpcException或RemotingException异常,如果不影响运行,是可以无视掉的,出现的原因可能是电脑的防火墙\杀毒软件不让Dubbo访问某些网络资源导致的

也有和无线wifi网卡驱动冲突造成的

Dubbo调用常见错误

No provider available from registry localhost:8848 for service cn.tedu.csmall.stock.service.IStockService on consumer 192.168.126.1 use dubbo version 2.7.8, please check status of providers(disabled, not registered or in blacklist).

(disabled, not registered or in blacklist)是这个错误信息的特征

发生这个错误原因是消费者无法在指定的位置找到需要的服务

  • 检查调用目标的服务是否启动(上面示例中可能是因为stock模块没有启动导致的)
  • 检查被调用的目标服务SpringBoot启动类是否编写的@EnableDubbo注解
  • 检查被调用的模块的业务逻辑层实现类是否编写了@DubboService注解

四、Spring Cloud

什么是Spring Cloud

SpringCloud是由Spring提供的一套能够快速搭建微服务架构程序的框架集

框架集表示SpringCloud不是一个框架,而是很多框架的统称

SpringCloud就是为了搭建微服务架构项目出现的

有人将SpringCloud称之为"Spring全家桶",广义上指代Spring的所有产品

五、Spring Cloud的内容

内容的提供者

  • Spring自己提供的开发出来的框架或软件
  • Netflix(奈非):早期的很长一段时间,提供了大量的微服务解决方案
  • alibaba(阿里巴巴):新版本的SpringCloudAlibaba正在迅速占领市场(推荐使用)

课程中使用全套的阿里巴巴组件

功能上分类

  • 微服务的注册中心
  • 微服务间的调用
  • 微服务的分布式事务
  • 微服务的限流
  • 微服务的网关
  • ....

1.注册中心Nacos

1.1.什么是Nacos

Nacos是Spring Cloud Alibaba提供的一个软件 这个软件主要具有注册中心和配置中心(课程最后讲解)的功能

我们先学习它注册中心的功能 微服务中所有项目都必须注册到注册中心才能成为微服务的一部分

注册中心和企业中的人力资源管理部门有相似

image-20220505094542229

当前微服务项目中所有的模块,在启动前,必须添加注册到Nacos的配置

所谓注册,就是将自己的信息,提交到Nacos来保存

1.2Nacos的下载

https://github.com/alibaba/nacos/releases/download/1.4.3/nacos-server-1.4.3.zip

国外网站,下载困难可以多试几次

或直接向项目经理老师索取

1.3Nacos的启动

因为Nacos是java开发的

我们要启动Nacos必须保证当前系统配置了java环境变量

简单来说就是要环境变量中,有JAVA_HOME的配置,指向安装jdk的路径

确定了支持java后,就可以启动Nacos了

mac系统同学一定要到http://doc.canglaoshi.org/查看homebrew相关知识

mac系统安装Nacos推荐

https://blog.csdn.net/gongzi_9/article/details/123359171

windows的同学保证java环境变量正常后

将nacos-server-1.4.2.zip压缩包解压

双击打开解压得到的文件夹后,再双击打开其中的bin目录

image-20220505104318585

cmd结尾的文件是windows版本的

sh结尾的文件是linux和mac版本的

startup是启动文件,shutdown是停止文件

Windows下启动Nacos不能直接双击cmd文件

需要在dos窗口运行

在当前资源管理器地址栏输入cmd

E:\tools\nacos\bin>startup.cmd -m standalone

startup.cmd:windows启动nacos的命令文件

-m 表示要设置启动参数

standalone:翻译为标准的孤独的,意思是正常的使用单机模式启动

运行成功默认占用8848端口,并且在代码中提示

如果不输入standalone运行会失败

如果报了

"please set JAVA_HOME......."

表示当前项目没有配置java环境变量(主要是没有设置JAVA_HOME)

如果运行没有报错

打开浏览器输入地址

http://localhost:8848/nacos

image-20220505105822385

如果是首次访问,会出现这个界面

登录系统

用户名:nacos

密码:nacos

登录之后可以进入后台列表

不能关闭启动nacos的dos窗口

我们要让我们编写的项目注册到Nacos,才能真正是微服务项目

1.4Nacos心跳机制

常见面试题

心跳:周期性的操作,来表示自己是健康可用的机制

注册到Nacos的微服务项目(模块)都是会遵循这个心跳机制的

心跳机制的目的

1.是表示当前微服务模块运行状态正常的手段

2.是表示当前微服务模块和Nacos保持沟通和交换信息的机制

默认情况下,服务启动开始每隔5秒会向Nacos发送一个"心跳包",这个心跳包中包含了当前服务的基本信息

Nacos接收到这个心跳包,首先检查当前服务在不在注册列表中,如果不在按新服务的业务进行注册,如果在,表示当前这个服务是健康状态

如果一个服务连续3次心跳(默认15秒)没有和Nacos进行信息的交互,就会将当前服务标记为不健康的状态

如果一个服务连续6次心跳(默认30秒)没有和Nacos进行信息的交互,Nacos会将这个服务从注册列表中剔除

这些时间都是可以通过配置修改的

实例类型分类

实际上Nacos的服务类型还有分类

  • 临时实例(默认)
  • 持久化实例(永久实例)

默认每个服务都是临时实例

如果想标记一个服务为永久实例

cloud:
  nacos:
    discovery:
      # ephemeral设置当前项目启动时注册到nacos的类型 true(默认):临时实例 false:永久实例
      ephemeral: false 

持久化实例启动时向nacos注册,nacos会对这个实例进行持久化处理

心跳包的规则和临时实例一致,只是不会将该服务从列表中剔除

一般情况下,我们创建的服务都是临时实例

只有项目的主干业务才会设置为永久实例

1.5使用Idea启动Nacos

之前我们启动Nacos都是使用dos命令行

启动过程比较复杂,命令比较长

Idea实际上支持我们更加方便的启动Nacos

步骤1:

image-20220822092823703

步骤2:

image-20220505142258656

步骤3:

image-20220505142111568

配置成功之后

Nacos就会出现在idea的启动选项中了

这种方法实际上只是为了简便大家启动nacos的操作,还是需要掌握命令行的启动方式的!

1.6.将项目注册到Nacos

我们已经讲过,一个项目要想成为微服务项目体系的一部分

必须将当前项目的信息注册到Nacos

我们要添加一些配置,实现business模块启动时注册到Nacos的效果

首先business模块pom文件中添加依赖

<!-- 支持项目注册到Nacos注册中心的依赖   discovery是发现的意思(微服务项目的发现) -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

我们在创建好的application-dev.yml中编写对nacos注册的配置信息

spring:
  application:
    # 设置当前项目的名称,这个名字会提交到Nacos,注册为当前微服务的名称
    name: nacos-business
  cloud:
    nacos:
      discovery:
        # 配置Nacos的位置,用于提交当前项目信息
        server-addr: localhost:8848

按照学习Nacos时的方式,启动一下nacos

启动之后,

重启business模块,如果启动也正常,就应该将当前项目的信息提交给Nacos

在Nacos的服务管理->服务列表中,能看到nacos-business的名称

image-20220722174018971

2.Dubbo远程调用中心

2.1什么是RPC

RPC是Remote Procedure Call的缩写 翻译为:远程过程调用

目标是为了实现两台(多台)计算机\服务器,相互调用方法\通信的解决方案

RPC只是实现远程调用的一套标准

该标准主要规定了两部分内容

1.通信协议

2.序列化协议

为了方便大家理解RPC,下面的图片帮助理解

image-20220505143449423

上面图是老婆和老公在家的时,老婆让老公洗碗的调用流程

这个流程可以理解为项目内的功能的调用,类似面向对象编程实例化对象,调用方法的过程

但是这个调用关系如果是远程的,意思是老婆和老公现在是两个不同的项目

我们看到上图中,远程调用必须借助一个通信设备,图片中是手机

通信协议

通信协议指的就是远程调用的通信方式

实际上这个通知的方式可以有多种

例如:写信,飞鸽传书,发电报

在程序中,通信方法实际上也是有多种的,每种通信方式会有不同的优缺点

序列化协议

序列化协议指通信内容的格式,双方都要理解这个格式

上面的图片中,老婆给老公发信息,一定是双方都能理解的信息

发送信息是序列化过程,接收信息需要反序列化

程序中,序列化的方式也是多种的,每种序列化方式也会有不同的优缺点

2.2.什么是Dubbo

上面对RPC有基本认识之后,再学习Dubbo就简单了

Dubbo是一套RPC框架。既然是框架,我们可以在框架结构高度,定义Dubbo中使用的通信协议,使用的序列化框架技术,而数据格式由Dubbo定义,我们负责配置之后直接通过客户端调用服务端代码。

可以说Dubbo就是RPC概念的实现

Dubbo是SpringCloudAlibaba提供的框架

能够实现微服务相互调用的功能!

2.3.Dubbo的发展历程

image-20220505151527323

Dubbo历程

在dubbo2012年底停止更新后

国内很多公司在Dubbo的基础上进行修改,继续更新

比较知名的修改版本就是当当网的DubboX

2012年底dubbo停止更新后到2017年dubbo继续更新之前

2015SpringCloud开始兴起,当时没有阿里的框架

国内公司要从SpringCloud和Dubbo中抉择使用哪个微服务方案

我们学习的Dubbo指的都是2.7之后的版本

是能够和SpringCloudAlibaba配合使用的

2.4.Dubbo对协议的支持

RPC框架分通信协议和序列化协议

Dubbo框架支持多种通信协议和序列化协议,可以通过配置文件进行修改

Dubbo支持的通信协议

  • dubbo协议(默认)
  • rmi协议
  • hessian协议
  • http协议
  • webservice
  • .....

支持的序列化协议

  • hessian2(默认)
  • java序列化
  • compactedjava
  • nativejava
  • fastjson
  • dubbo
  • fst
  • kryo

Dubbo默认情况下,支持的协议有如下特征

  • 采用NIO单一长链接
  • 优秀的并发性能,但是处理大型文件的能力差

Dubbo方便支持高并发和高性能

2.5Dubbo服务的注册与发现

在Dubbo的调用过程中,必须包含注册中心的支持

项目调用服务的模块必须在同一个注册中心中

注册中心推荐阿里自己的Nacos,兼容性好,能够发挥最大性能

但是Dubbo也支持其它软件作为注册中心(例如Redis,zookeeper等)

服务发现,即消费端自动发现服务地址列表的能力,是微服务框架需要具备的关键能力,借助于自动化的服务发现,微服务之间可以在无需感知对端部署位置与 IP 地址的情况下实现通信。

上面RPC的示例中,老婆就是服务的消费端,她能发现老公具备的服务

如果老婆调用了老公的服务,就是完成了Dubbo调用

image-20220505153224006

consumer服务的消费者,指服务的调用者(使用者)也就是老婆的位置

provider服务的提供者,指服务的拥有者(生产者)也就是老公的位置

在Dubbo中,远程调用依据是服务的提供者在Nacos中注册的服务名称

一个服务名称,可能有多个运行的实例,任何一个空闲的实例都可以提供服务

常见面试题:Dubbo的注册发现流程

1.首先服务的提供者启动服务时,将自己的具备的服务注册到注册中心,其中包括当前提供者的ip地址和端口号等信息,Dubbo会同时注册该项目提供的远程调用的方法

2.消费者(使用者)启动项目,也注册到注册中心,同时从注册中心中获得当前项目具备的所有服务列表

3.当注册中心中有新的服务出现时,(在心跳时)会通知已经订阅发现的消费者,消费者会更新所有服务列表

4.RPC调用,消费者需要调用远程方法时,根据注册中心服务列表的信息,只需服务名称,不需要ip地址和端口号等信息,就可以利用Dubbo调用远程方法了

2.6.Dubbo实现微服务调用

确定调用结构

image-20220505155018517

  • order模块调用stock模块的减少库存的功能
  • order模块调用cart模块的删除购物车的功能
  • business模块调用order新增订单的功能

要想实现Dubbo调用

必须按照Dubbo规定的配置和行业标准的结构来实现

Dubbo调用的好处是直接将要消费的目标(例如order模块中消费stock的方法)编写在当前消费者的业务逻辑层中,无需编写新的代码结构,程序运行流程不会因为Dubbo而变化

csmall-stock-webapi项目的pom文件为

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall-stock</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-stock-webapi</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>csmall-stock-webapi</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <!--  Nacos注册依赖 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--  Dubbo 在SpringCloud中使用的依赖 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-dubbo</artifactId>
        </dependency>
        <!--  当前项目需要在业务逻辑层实现类中实现业务逻辑层接口
           所以业务逻辑层接口的依赖也要添加(依赖csmall-stock-service)-->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-stock-service</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

上面的pom文件不只是将原有的stock的pom文件依赖复制,而且添加了Dubbo和业务逻辑层接口的依赖

将csmall-stock项目的application.yml和application-dev.yml复制到csmall-stock-webapi项目的resources文件夹下

yml文件其它的不动,但是在dev.yml文件中要添加dubbo的配置信息

spring:
  application:
    name: nacos-stock # 定义当前服务名称
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 # 指定正在运行的Nacos服务器的位置
  datasource:
    url: jdbc:mysql://localhost:3306/csmall_db?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
    username: root
    password: root
dubbo:
  protocol:
    # port设置为-1 表示当前dubbo框架使用自动寻找端口的功能
    # 生成端口的规则是从20880开始寻找可用端口,如果当前端口号占用,就尝试+1,直到找到可用端口为止
    port: -1
    # 设置连接的名称,一般固定为dubbo即可
    name: dubbo
  registry:
    # 指定当前项目dubbo注册中心的类型和位置
    address: nacos://localhost:8848
  consumer:
    # 设置当前项目启动时,是否检查本项目需要的所有服务都可用
    # 设置它的值为false,表示不检查,以减少启动时因为需要的服务不可用导致的错误
    check: false

将业务逻辑层实现类方法声明为Dubbo可调用的方法

当前stock模块是单纯的生产者

StockServiceImpl修改代码

// @DubboService注解,标记的业务逻辑层实现类,其中的所有方法都会注册到Nacos
// 在其他服务启动"订阅"后,就会"发现"当前类中的所有服务,随时可以调用
@DubboService
@Service
@Slf4j
public class StockServiceImpl implements IStockService {
    // ....  内容略
}

如果当前项目是服务的提供者(生产者)

还需要在SpringBoot启动类上添加@EnableDubbo的注解,才能真正让Dubbo功能生效

@SpringBootApplication
// 如果当前项目是dubbo调用中的生产者,必须添加@EnableDubbo注解
// 添加之后,在服务启动时,当前项目的所有服务才能正确的注册到Nacos
@EnableDubbo
public class CsmallStockWebapiApplication {

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

}

先启动nacos

再启动stockWebapi项目

添加生产者的注解,同时利用Dubbo消费stock和cart模块的方法

Reference:引用

// business模块要调用order,所以当前项目也是生产者,所以也要加@DubboService注解
@DubboService
@Service
@Slf4j
public class OrderServiceImpl implements IOrderService {
    @Autowired
    private OrderMapper orderMapper;

    // 添加@DubboReference注解,表示当前业务逻辑层中要消费其他模块的服务了
    // 注解后面声明的应该是Dubbo注册到Nacos其他模块声明的业务逻辑层接口
    // 业务逻辑层接口实现类会在Dubbo框架下自动获取
    @DubboReference
    private IStockService stockService;
    @DubboReference
    private ICartService cartService;

    @Override
    public void orderAdd(OrderAddDTO orderAddDTO) {

        // 利用dubbo调用stock模块的业务逻辑层方法实现库存的减少
        stockService.reduceCommodityCount(countDTO);
        // 利用dubbo调用cart模块的业务逻辑层方法实现购物车中商品的删除
        cartService.deleteUserCart(orderAddDTO.getUserId(),
                                    orderAddDTO.getCommodityCode());


    }
}

因为order模块也是生产者@EnableDubbo注解仍然要写

@SpringBootApplication
@EnableDubbo
public class CsmallOrderWebapiApplication {

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

}

我们可以测试这个Dubbo的功能

首先保证Nacos启动

我们项目的启动顺序,尽量保证生产者先启动

注意运行前,数据库的数据状态和运行后的比较一下

启动服务时,可能发生RpcException或RemotingException异常,如果不影响运行,是可以无视掉的,出现的原因可能是电脑的防火墙\杀毒软件不让Dubbo访问某些网络资源导致的

也有和无线wifi网卡驱动冲突造成的

2.6.1.Dubbo调用常见错误

No provider available from registry localhost:8848 for service cn.tedu.csmall.stock.service.IStockService on consumer 192.168.126.1 use dubbo version 2.7.8, please check status of providers(disabled, not registered or in blacklist).

(disabled, not registered or in blacklist)是这个错误信息的特征

发生这个错误原因是消费者无法在指定的位置找到需要的服务

  • 检查调用目标的服务是否启动(上面示例中可能是因为stock模块没有启动导致的)
  • 检查被调用的目标服务SpringBoot启动类是否编写的@EnableDubbo注解
  • 检查被调用的模块的业务逻辑层实现类是否编写了@DubboService注解

2.7.负载均衡

2.7.1什么是负载均衡

在实际项目中,一个服务基本都是集群模式的,也就是多个功能相同的项目在运行,这样才能承受更高的并发

这时一个请求到这个服务,就需要确定访问哪一个服务器

在Dubbo实现远程调用的过程中,调用的关系可能如下图

1655891169062

Dubbo框架内部支持负载均衡算法,能够尽可能的让请求在相对空闲的服务器上运行

在不同的项目中,可能选用不同的负载均衡策略,以达到最好效果

Loadbalance:就是负载均衡的意思

2.7.2.Dubbo内置负载均衡策略算法

  • random loadbalance:随机分配策略(默认)
  • round Robin Loadbalance:权重平均分配
  • leastactive Loadbalance:活跃度自动感知分配
  • consistanthash Loadbalance:一致性hash算法分配

实际运行过程中,每个服务器性能不同

在负载均衡时,都会有性能权重,这些策略算法都考虑权重问题

实际运行过程中,每个服务器性能不同

在负载均衡时,都会有性能权重,这些策略算法都考虑权重问题

随机分配策略

假设我们当前3台服务器,经过测试它们的性能权重比值为5:3:1

下面可以生成一个权重模型

5:3:1

image-20220923162323931

生成随机数

在哪个范围内让哪个服务器运行

优点:

算法简单,效率高,长时间运行下,任务分配比例准确

缺点:

偶然性高,如果连续的几个随机请求发送到性能弱的服务器,会导致异常甚至宕机

权重平滑分配

如果几个服务器权重一致,那么就是依次运行

但是服务器的性能权重一致的可能性很小

所以我们需要权重平滑分配

一个优秀的权重分配算法,应该是让每个服务器都有机会运行的

如果一个集群服务器性能比为5:3:1服务为A,B,C

1>A 2>A 3>A 4>A 5>A

6>B 7>B 8>B

9>C

上面的安排中,连续请求一个服务器肯定是不好的,我们希望所有的服务器都能够穿插在一起运行

Dubbo2.7之后更新了这个算法使用"平滑加权算法"优化权重平均分配策略

1655955337434

优点:

能够尽可能的在权重要求的情况下,实现请求的穿插运行(交替运行),不会发生随机策略中的偶发情况

缺点

服务器较多时,可能需要减权和复权的计算,需要消耗系统资源

活跃度自动感知

记录每个服务器处理一次请求的时间

按照时间比例来分配任务数,运行一次需要时间多的分配的请求数较少

一致性Hash算法

根据请求的参数进行hash运算

以后每次相同参数的请求都会访问固定服务器

因为根据参数选择服务器,不能平均分配到每台服务器上

使用的也不多

2.8Dubbo生产者消费者配置小结

Dubbo生产者消费者相同的配置

pom文件添加dubbo依赖,yml文件配置dubbo信息

生产者

  • 要有service接口项目

  • 提供服务的业务逻辑层实现类要添加@DubboService注解

  • SpringBoot启动类要添加@EnableDubbo注解

消费者

  • pom文件添加消费模块的service依赖
  • 业务逻辑层远程调用前,模块使用@DubboReference注解获取业务逻辑层实现类对象

3.Seata 分布式事务

3.1.下载Seata

https://github.com/seata/seata/releases

https://github.com/seata/seata/releases/download/v1.4.2/seata-server-1.4.2.zip

我们可以直接从第五阶段资料压缩包中获取

3.2.什么是Seata

Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务

也是Spring Cloud Alibaba提供的组件

Seata官方文档

https://seata.io/zh-cn/

更多信息可以通过官方文档获取

3.3.为什么需要Seata

我们之前学习了单体项目中的事务

使用的技术叫Spring声明式事务

能够保证一个业务中所有对数据库的操作要么都成功,要么都失败,来保证数据库的数据完整性

但是在微服务的项目中,业务逻辑层涉及远程调用,当前模块发生异常,无法操作远程服务器回滚

这时要想让远程调用也支持事务功能,就需要使用分布式事务组件Seata

Seata保证微服务远程调用业务的原子性

Seata将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

3.4.Seata的运行原理

观察下面事务模型

上面结构是比较典型的远程调用结构

如果account操作数据库失败需要让order模块和storage模块撤销(回滚)操作

声明式事务不能完成这个操作

需要使用Seata来解决

image-20220506115157648

Seata构成部分包含

  • 事务协调器TC
  • 事务管理器TM
  • 资源管理器RM

我们项目使用AT(自动)模式完成分布式事务的解决

AT模式运行过程

1.事务的发起方(TM)会向事务协调器(TC)申请一个全局事务id,并保存

2.Seata会管理事务中所有相关的参与方的数据源,将数据操作之前和之后的镜像都保存在undo_log表中,这个表是seata组件规定的表,没有它就不能实现效果,依靠它来实现提交(commit)或回滚(roll back)的操作

3.事务的发起方(TM)会连同全局id一起通过远程调用,运行资源管理器(RM)中的方法

4.RM接收到全局id,去运行指定方法,并将运行结果的状态发送给TC

5.如果所有分支运行都正常,TC会通知所有分支进行提交,真正的影响数据库内容,

反之如果所有分支中有任何一个分支发生异常,TC会通知所有分支进行回滚,数据库数据恢复为运行之前的内容

3.5.seata的启动

seata也是java开发的,启动方式和nacos很像

只是启动命令不同

它要求配置环境变量中Path属性值有java的bin目录路径

解压后路径不要用中文,不要用空格

也是解压之后的bin目录下

1655972562021

在路径上输入cmd进入dos窗口

mac系统同学直接参考启动nacos的命令

D:\tools\seata\seata-server-1.4.2\bin>seata-server.bat -h 127.0.0.1 -m file

输入后,最后出现8091端口的提示即可!

如果想使用idea启动seata,和之前nacos启动相似

如果seata启动时发送内存不足的错误,可以参考下面的文章解决

https://blog.csdn.net/he_lei/article/details/116229467

保证当前seata的环境变量是java1.8

在windows系统中运行seata可能出现不稳定的情况,重启seata即可解决

3.6.使用Seata

3.7.配置Seata

cart\stock\order三个模块时需要Seata支持进行事务管理的模块

这三个模块webapi模块都需要添加下面pom依赖和配置

<!--   Seata和SpringBoot整合依赖     -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
</dependency>
<!--  Seata 完成分布式事务的两个相关依赖(Seata会自动使用其中的资源)  -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
</dependency>

下面修改cart\stock\order模块的application-dev.yml

代码如下

seata:
  # 定义事务的分组,一般是以项目为单位的,不同项目用它来区分
  tx-service-group: csmall_group
  service:
    vgroup-mapping:
      # 设置csmall_group分组使用默认(default)的seata配置
      csmall_group: default
    grouplist:
      # 设置seata的ip和端口号位置
      default: localhost:8091

注意同一个事务必须在同一个tx-service-group中

同时指定相同的seata地址和端口

business模块的配置

business模块作为当前分布式事务模型的触发者

它应该是事务的起点,但是它不连接数据库,所以配置稍有不同

pom文件seata依赖仍然需要,但是只需要seata依赖

<!--   Seata和SpringBoot整合依赖     -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
</dependency>

application-dev.yml是一样的

seata:
  tx-service-group: csmall_group
  service:
    vgroup-mapping:
      csmall_group: default
    grouplist:
      default: localhost:8091

添加完必要的配置之后

要想激活Seata功能非常简单,只要在起点业务的业务逻辑方法上添加专用的注解即可

添加这个注解的模块就是模型中的TM

他调用的所有远程模块都是RM

business模块添加订单的业务逻辑层开始的方法

@Service
@Slf4j
public class BusinessServiceImpl implements IBusinessService {

    // Dubbo调用order模块的新增订单的功能
    // business是单纯的消费者,不需要再类上编写@DubboService
    @DubboReference
    private IOrderService dubboOrderService;

    // Global:全局  Transactional:事务
    // 一旦这个方法标记为 @GlobalTransactional
    // 就相当于设置了分布式事务运行的起点,相当于AT事务模型中的TM(事务管理器)
    // 最终效果就是当前方法开始后,所有远程调用操作数据库的功能,都在同一个事务中
    // 也就是这些远程调用的数据库操作要么都执行,要么都不执行
    @GlobalTransactional
    @Override
    public void buy() {
         //  代码略...
    }
}

先启动nacos,再启动seata

然后按顺序启动四个服务 cart\stock\order\business

利用knife4j进行访问,

business模块 localhost:20000/doc.html

3.8.Seata效果

要想seata出现效果,我们要有一个发生异常的情况

当发生异常时,去观察是否会回滚

我们可以编写代码随机的抛出异常,来根据是否有异常,是否回滚,判断seata是否有效

OrderServiceImpl在新增订单方法前添加随机发送异常的方法

@Override
public void orderAdd(OrderAddDTO orderAddDTO) {
    // 1.先去减少订单中商品的库存数量(调用stock模块减少库存的方法)
    // 库存模块减少库存需要StockReduceCountDTO对象,才能运行,所以先实例化它
    StockReduceCountDTO countDTO=new StockReduceCountDTO();
    countDTO.setCommodityCode(orderAddDTO.getCommodityCode());
    countDTO.setReduceCount(orderAddDTO.getCount());
    // 利用dubbo调用stock模块的业务逻辑层方法实现库存的减少
    stockService.reduceCommodityCount(countDTO);

    // 2.从购物车中删除用户勾选的商品(调用cart模块删除购物车商品的方法)
    // 利用dubbo调用cart模块的业务逻辑层方法实现购物车中商品的删除
    cartService.deleteUserCart(orderAddDTO.getUserId(),
                                orderAddDTO.getCommodityCode());
	// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    if(Math.random()<0.5){
        // 随机发生业务异常
        throw new CoolSharkServiceException(
                ResponseCode.INTERNAL_SERVER_ERROR,"发生随机异常!");
    }
    // 3.新增订单信息
    Order order=new Order();
    BeanUtils.copyProperties(orderAddDTO,order);
    // 执行新增
    orderMapper.insertOrder(order);
    log.info("新增订单信息为:{}",order);

}

再次测试localhost:20000/doc.html

点击触发购买业务的功能,观察是否发生异常

以及发生异常时数据库是否没有变化

正常运行时,数据库数据是否正常变化

3.9.Seata其他模式介绍

上次课我们讲解了Seata软件AT模式的运行流程

AT模式的运行有一个非常明显的前提条件,这个条件不满足,就无法使用AT模式

这个条件就是事务分支都必须是操作关系型数据库(Mysql\MariaDB\Oracle)

因为关系型数据库才支持提交和回滚,其它非关系型数据库都是直接影响数据(例如Redis)

所以如果我们在业务过程中有一个节点操作的是Redis或其它非关系型数据库时,就无法使用AT模式

除了AT模式之外还有TCC、SAGA 和 XA 事务模式

3.9.1.TCC模式

简单来说,TCC模式就是自己编写代码完成事务的提交和回滚

在TCC模式下,我们需要为参与事务的业务逻辑编写一组共3个方法

(prepare\commit\rollback)

prepare:准备

commit:提交

rollback:回滚

  • prepare方法是每个模块都会运行的方法
  • 当所有模块的prepare方法运行
  • 都正常时,运行commit
  • 当任意模块运行的prepare方法有异常时,运行rollback

这样的话所有提交或回滚代码都由自己编写

优点:虽然代码是自己写的,但是事务整体提交或回滚的机制仍然可用(仍然由TC来调度)

缺点:每个业务都要编写3个方法来对应,代码冗余,而且业务入侵量大

3.9.2.SAGA模式

SAGA模式的思想是对应每个业务逻辑层编写一个新的类,可以设置指定的业务逻辑层方法发生异常时,运行当新编写的类中的代码

相当于将TCC模式中的rollback方法定义在了一个新的类中

这样编写代码不影响已经编写好的业务逻辑代码

一般用于修改已经编写完成的老代码

缺点是每个事务分支都要编写一个类来回滚业务,

会造成类的数量较多,开发量比较大

3.9.3.XA模式

支持XA协议的数据库分布式事务,使用比较少

4.Sentinel 流制、熔断降级、负载

官网地址 https://sentinelguard.io/zh-cn/

下载地址 https://github.com/alibaba/Sentinel/releases

文件在第五阶段资料中,jsd2203项目解压后的doc目录下

4.1.什么是Sentinel

Sentinel英文翻译"哨兵\门卫"

Sentinel也是Spring Cloud Alibaba的组件

随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。

4.2.为什么需要Sentinel

为了保证服务器运行的稳定性,在请求数到达设计最高值时,将过剩的请求限流,保证在设计的请求数内的请求能够稳定完成处理

  • 丰富的应用场景

    双11,秒杀,12306抢火车票

  • 完备的实时状态监控

    可以支持显示当前项目各个服务的运行和压力状态,分析出每台服务器处理的秒级别的数据

  • 广泛的开源生态

    很多技术可以和Sentinel进行整合,SpringCloud,Dubbo,而且依赖少配置简单

  • 完善的SPI扩展

    Sentinel支持程序设置各种自定义的规则

4.3.Sentinel启动

image-20220824114843759windows同学直接双击start-sentinel.bat文件

mac同学使用下面命令执行jar包

java -jar sentinel-dashboard-1.8.2.jar

启动之后

打开浏览器http://localhost:8080/

会看到下面的界面

image-20220727115123049

用户名和密码都是

sentinel

刚开始什么都没有,是空界面

后面我们有控制器的配置就会出现信息了

4.4.基本配置

我们的限流针对的是控制器方法

我们找一个简单的模块来测试和观察限流效果

在csmall-stock-webapi模块中

添加sentinel的依赖

<!--  Sentinel 整合SpringCloud依赖  -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

application-dev.yml文件添加配置

spring:  
  cloud:
    sentinel:
      transport:
        # 配置sentinel提供的仪表台服务器的位置
        dashboard: localhost:8080
        # 执行限流的端口号,每个项目必须设置不同的端口号,例如cart模块要限流就可以设置为8722
        port: 8721
    nacos:
      discovery:
        server-addr: localhost:8848 # 指定正在运行的Nacos服务器的位置

4.5.限流方法

我们以stock模块为例

演示限流的效果

StockController在减少库存的方法上添加限流的注解

@PostMapping("/reduce/count")
@ApiOperation("减少库存数")
// @SentinelResource注解需要标记在控制层方法上,在该方法第一次运行后,会被Sentinel仪表台检测
// 该方法在运行前,不会出现在仪表台中
// 括号中"减少库存数"这个描述会出现在仪表台上,代表这个方法
@SentinelResource("减少库存数")
public JsonResult reduceCommodityCount(StockReduceCountDTO stockReduceCountDTO){
    // 调用业务逻辑层
    stockService.reduceCommodityCount(stockReduceCountDTO);
    return JsonResult.ok("库存减少完成!");
}

nacos\seata\sentinel要启动

重启stock服务(其它服务都可以停掉)

如果不运行knife4j测试,sentinel的仪表盘不会有任何信息

在第一次运行了减少库存方法之后,sentinel的仪表盘才会出现nacos-stock的信息

选中这个信息点击"簇点链路"

找到我们编写的"减少库存数"方法,点 "+流控"

设置流控规则

我们先设置QPS为1也就是每秒请求数超过1时,进行限流

然后我们可以快速双击knife4j减少库存的方法,触发它的流控效果

image-20221124164232421

这样的流控没有正确的消息提示

我们需要自定义方法进行正确的提示给用户看到

4.6.自定义限流方法

对于被限流的请求,我们可以自定义限流的处理方法

默认情况下可能不能正确给用户提示,一般情况下,对被限流的请求也要有"服务器忙请重试"或类似的提示

StockController类中@SentinelResource注解中,可以自定义处理限流情况的方法

@PostMapping("/reduce/count")
@ApiOperation("减少库存数")
// @SentinelResource注解需要标记在控制层方法上,在该方法第一次运行后,会被Sentinel仪表台检测
// 该方法在运行前,不会出现在仪表台中
// 括号中"减少库存数"这个描述会出现在仪表台上,代表这个方法
// blockHandler可以设置当前控制器方法被限流时,要运行的自定义限流方法,blockError就是方法名称
@SentinelResource(value = "减少库存数",blockHandler = "blockError" )
public JsonResult reduceCommodityCount(StockReduceCountDTO stockReduceCountDTO){
    // 调用业务逻辑层
    stockService.reduceCommodityCount(stockReduceCountDTO);
    return JsonResult.ok("库存减少完成!");
}

// Sentinel自定义限流方法定义规则
// 1.访问修饰符必须是public
// 2.返回值类型必须和控制器方法一致
// 3.方法名必须是控制器方法注解中由blockHandler标记的方法名称
// 4.方法的参数必须包含控制器方法的所有参数,再额外添加一个BlockException的异常参数类型
public JsonResult blockError(StockReduceCountDTO stockReduceCountDTO,
                             BlockException e){
    // 这个方法运行表示当前请求被限流了,我们给与返回,提示它被限流即可
    return JsonResult.failed(
            ResponseCode.INTERNAL_SERVER_ERROR,"服务器忙,请稍后再试");
}

重启stock-webapi模块

再次尝试被限流,观察被限流的提示

4.7.QPS与并发线程数

  • QPS:是每秒请求数

    单纯的限制在一秒内有多少个请求访问控制器方法

  • 并发线程数:是当前正在使用服务器资源请求线程的数量

    限制的是使用当前服务器的线程数

并发线程数测试可能需要阻塞当前控制器方法一段时间,方便测试

stockService.reduceCommodityCount(stockReduceCountDTO);
try {
    Thread.sleep(5000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
return JsonResult.ok("库存减少完成!");

4.8.自定义降级方法

所谓降级就是正常运行控制器方法的过程中

控制器方法发生了异常,Sentinel支持我们运行别的方法来处理异常,或运行别的业务流程处理

我们也学习过处理控制器异常的统一异常处理类,和我们的降级处理有类似的地方

但是Sentinel降级方法优先级高,而且针对单一控制器方法编写

StockController类中@SentinelResource注解中,可以定义处理降级情况的方法

@PostMapping("/reduce/count")
@ApiOperation("减少库存数")
// @SentinelResource注解需要标记在控制层方法上,在该方法第一次运行后,会被Sentinel仪表台检测
// 该方法在运行前,不会出现在仪表台中
// 括号中"减少库存数"这个描述会出现在仪表台上,代表这个方法
// blockHandler可以设置当前控制器方法被限流时,要运行的自定义限流方法,blockError就是方法名称
@SentinelResource(  value = "减少库存数",
        blockHandler = "blockError",
        fallback = "fallbackError")
public JsonResult reduceCommodityCount(StockReduceCountDTO stockReduceCountDTO){
    // 调用业务逻辑层
    stockService.reduceCommodityCount(stockReduceCountDTO);
    // 随机发生异常,测试自定义降级方法效果
    if(Math.random()<0.5){
        throw new CoolSharkServiceException(
                ResponseCode.INTERNAL_SERVER_ERROR,"发生随机异常");
    }
    return JsonResult.ok("库存减少完成!");
}

// 限流方法略.....

// 自定义降级方法: 由@SentinelResource的fallback属性指定
// 自定义降级方法和自定义限流方法的格式规则基本一致
// 只是额外返回的异常类型使用Throwable
// 当上面的控制层方法发生异常时,会调用自定义降级方法
// 实际开发中,可能调用运行一些老版本的代码,所以称之为"降级"
public JsonResult fallbackError(StockReduceCountDTO stockReduceCountDTO,
                                Throwable throwable){
    // 输入异常信息
    throwable.printStackTrace();
    // 返回降级方法运行的信息
    return JsonResult.failed(ResponseCode.INTERNAL_SERVER_ERROR,"服务降级");
}

重启csmall-stock-webapi模块测试

当发生随机异常时,就运行降级方法

当没有发生随机异常时,就正常运行!

5.SpringGateway网关

5.1.奈非框架简介

早期(2020年前)奈非提供的微服务组件和框架受到了很多开发者的欢迎

这些框架和SpringCloud Alibaba的对应关系我们要了解

现在还有很多旧项目维护是使用奈非框架完成的微服务架构

Nacos对应Eureka都是注册中心

Dubbo对应Ribbon+feign都是实现微服务远程RPC调用的组件

Sentinel对应Hystrix都是做项目限流熔断降级的组件

Gateway对应Zuul都是网关组件

Gateway框架不是阿里写的,是Spring提供的

5.2.什么是网关

"网"指网络,"关"指关口或关卡

网关:就是指网络中的关口\关卡

网关就是当前微服务项目的"统一入口"

程序中的网关就是当前微服务项目对外界开放的统一入口

所有外界的请求都需要先经过网关才能访问到我们的程序

提供了统一入口之后,方便对所有请求进行统一的检查和管理

image-20220216164903290

网关项目git地址

https://gitee.com/jtzhanghl/gateway-demo.git

网关的主要功能有

  • 将所有请求统一经过网关
  • 网关可以对这些请求进行检查
  • 网关方便记录所有请求的日志
  • 网关可以统一将所有请求路由到正确的模块\服务上

路由的近义词就是"分配"

5.3.Spring Gateway简介

我们使用Spring Gateway作为当前项目的网关框架

Spring Gateway是Spring自己编写的,也是SpringCloud中的组件

SpringGateway官网

https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/

网关项目git地址

https://gitee.com/jtzhanghl/gateway-demo.git

5.4.简单网关演示

SpringGateway网关是一个依赖,不是一个软件

所以我们要使用它的话,必须先创建一个SpringBoot项目

这个项目也要注册到Nacos注册中心,因为网关项目也是微服务项目的一个组成部分

beijing和shanghai是编写好的两个项目

gateway项目就是网关项目,需要添加相关配置

<dependencies>
    <!--   SpringGateway的依赖   -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <!--   Nacos依赖   -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!--   网关负载均衡依赖    -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId>
    </dependency>
</dependencies>

我们从yml文件配置开始添加

server:
  port: 9000
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        # 网关也是微服务项目的一部分,所以也要注册到Nacos
        server-addr: localhost:8848
    gateway:
      # routes就是路由的意思,下面的内容就是配置路由,它是一个数组类型的属性
      routes:
          # 数组类型属性赋值时,每个数组元素都要以"-"开头,一个"-"之后的所有内容,都是这个元素包含的值
          # id表示当前路由的名称,和之前出现过的任何名字没有关联,唯一的要求就是不能和之后出现的路由id重复
        - id: gateway-beijing
          # uri配置路由的目标服务器,beijing是服务器注册到nacos的名称
          # lb就是LoadBalance的缩写
          uri: lb://beijing
          # predicates是断言的意思,指满足某些条件的之后执行某些操作
          predicates:
            # predicates属性也是数组类型,赋值要以"-"开头
            # 这个断言的含义就是如果访问网关项目的路径以/bj/开头,路由访问beijing服务器
            # ↓ P要大写     **是通配任何路径
            - Path=/bj/**

先启动nacos

再启动beijing

最后启动gateway

5.5.网关多路由配置

上面只配置了一个beijing的路由设置

下面我们修改yml文件也实现shanghai的路由设置

gateway:
  # routes就是路由的意思,下面的内容就是配置路由,它是一个数组类型的属性
  routes:
    - id: gateway-shanghai
      uri: lb://shanghai
      predicates:
        - Path=/sh/**
      # 数组类型属性赋值时,每个数组元素都要以"-"开头,一个"-"之后的所有内容,都是这个元素包含的值
      # id表示当前路由的名称,和之前出现过的任何名字没有关联,唯一的要求就是不能和之后出现的路由id重复
    - id: gateway-beijing
      # uri配置路由的目标服务器,beijing是服务器注册到nacos的名称
      # lb就是LoadBalance的缩写
      uri: lb://beijing
      # predicates是断言的意思,指满足某些条件的之后执行某些操作
      predicates:
        # predicates属性也是数组类型,赋值要以"-"开头
        # 这个断言的含义就是如果访问网关项目的路径以/bj/开头,路由访问beijing服务器
        # ↓ P要大写     **是通配任何路径
        - Path=/bj/**

在保证nacos启动的情况下

beijing服务器如果启动无需重启

启动shanghai项目

最后重启网关

测试网关路由到两个模块的效果

http://localhost:9000/bj/show可以访问beijing服务器的资源

http://localhost:9000/sh/show可以访问shanghai服务器的资源

以此类推,再有很多服务器时,我们都可以仅使用9000端口号来将请求路由到正确的服务器

就实现了gateway成为项目的统一入口的效果

5.6.动态路由

网关项目的配置会随着微服务模块数量增多而变得复杂,维护的工作量也会越来越大

所以我们希望gateway能够设计一套默认情况下自动路由到每个模块的路由规则

这样的话,不管当前项目有多少个路由目标,都不需要维护yml文件了

这就是我们SpringGateway的动态路由功能

配置文件中开启即可

server:
  port: 9000
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        # 网关也是微服务项目的一部分,所以也要注册到Nacos
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          # 开启网关动态路由的配置
          # 默认的路由规则:在网关端口号后,先编写要路由到的目标服务器
          #              注册到Nacos的名称,再编写要访问的具体路径
          # 例如 localhost:9001/bj/show  ->  localhost:9000/beijing/bj/show
          enabled: true

按上面修改完配置之后

我们可以重启gateway来测试动态路由路径是否生效

动态路由生成规则为:在网关端口号后先写要路由到的目标服务器在nacos注册的名称,再编写具体路径

5.7.内置断言

我们上次课在网关配置中使用了predicates(断言)的配置

断言的意思就是判断某个条件是否满足

我们之前使用了Path断言,判断请求的路径是不是满足条件,例如是不是/sh/** /bj/**

如果路径满足这个条件,就路由到指定的服务器

但是Path实际上只是SpringGateway提供的多种内置断言中的一种

还有很多其它断言

  • after
  • before
  • between
  • cookie
  • header
  • host
  • method
  • path
  • query
  • remoteaddr

时间相关

after,before,between

判断当前时间在指定时间之前,之后或之间的操作

如果条件满足可以执行路由操作,否则拒绝访问

表示时间的格式比较特殊,先使用下面代码获得时间

ZonedDateTime.now()

运行程序输出,可获得当前时间,这个时间的格式可能是

2022-11-25T11:47:18.087+08:00[Asia/Shanghai]

下面在yml配置中添加新的断言配置

使用After设置必须在指定时间之后访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      # After是时间断言,判断访问时间是否指定时间之后
      # 如果早于这个时间,访问结果时404,晚于这个时间才能正常访问, 和上面Path断言是"与"的关系
      - After=2022-11-25T11:53:50.087+08:00[Asia/Shanghai]

必须在指定时间之后才能访问服务

否则发生404错误拒绝访问

需要注意测试时,先启动Nacos,再启动shanghai之后启动gateway

测试时必须通过9000端口访问才能有效果

使用Before设置必须在指定时间之前访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      - Before=2022-11-25T14:17:30.087+08:00[Asia/Shanghai]

使用Between设置必须在指定时间之间访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      - Between=2022-11-25T14:20:15.087+08:00[Asia/Shanghai],2022-11-25T14:20:30.087+08:00[Asia/Shanghai]

要求指定参数的请求

Query断言,判断是否包含指定的参数名称,包含参数名称才能通过路由

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      # Query断言判断请求中是否包含指定的参数名称(username)的参数,如果不包含就发生404错误
      - Query=username

重启gateway测试

必须是包含username参数的请求才能访问到指定的页面

例如:http://localhost:9000/sh/show?username=tom

5.8.内置过滤器

Gateway还提供的内置过滤器

不要和我们学习的filter混淆

内置过滤器允许我们在路由请求到目标资源的同时,对这个请求进行一些加工或处理

常见过滤器也有一些

我们给大家演示一下AddRequestParameter过滤器

它的作用是在请求中添加参数和它对应的值

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    filters:
      - AddRequestParameter=age,18
    predicates:
      - Path=/sh/**
      # Query断言判断请求中是否包含指定的参数名称(username)的参数,如果不包含就发生404错误
      - Query=username

在shanghai的控制器方法中添加代码接收username,age的值

@GetMapping("/show")
public String show(String username,Integer age){
    System.out.println(ZonedDateTime.now());
    return "这里是上海!"+username+","+age;
}

重启shanghai和gateway进行测试

http://localhost:9000/sh/show?username=tom

因为过滤器的存在,控制器可以获取网关过滤器添加的参数值

1664247176594

其他内置过滤器和自定义过滤器的使用,同学们可以查阅相关文档自己了解

5.9.路由配置的设计规则

路由规则解释

路由规则一定是在开发之前就设计好的

一般可以使用约定好的路径开头来实现的

例如

gateway项目

如果路径以 /bj开头,就是要访问beijing项目

如果路径以 /sh开头.就是养访问shanghai项目

csmall项目

如果路径是 /base/business开头的, 就去找nacos-business服务器

如果路径是 /base/cart开头的, 就去找nacos-cart服务器

如果路径是 /base/order开头的, 就去找nacos-order服务器

如果路径是 /base/stock开头的, 就去找nacos-stock服务器

当我们输入路径

localhost:9000/bj/show时 相当于访问了 localhost:9001/bj/show

5.10.csmall项目网关

创建gateway网关子项目

创建网关项目,然后父子相认

修改子项目pom文件和依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>gateway</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gateway</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <!-- web实例 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--  Nacos注册依赖 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

</project>

也删除test测试文件夹

application.properties换为yml

配置如下

server:
  port: 19000
spring:
  application:
    name: gateway-server
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          # 网关开启动态路由
          enabled: true
  main:
    # 防止SpringMvc和SpringGateway依赖冲突的配置
    web-application-type: reactive

5.11.网关项目的knife4j配置

我们希望配置网关之后,在使用knife4j测试时

就不来回切换端口号了

我们需要在网关项目中配置Knife4j才能实现

而这个配置是固定的,

只要是网关项目配置各个子模块的knife4j功能,就直接复制这几个类即可

csmall-finish中直接复制config\controller\filter

cn.tedu.gateway.config

SwaggerProvider

@Component
public class SwaggerProvider implements SwaggerResourcesProvider {
    /**
     * 接口地址
     */
    public static final String API_URI = "/v2/api-docs";
    /**
     * 路由加载器
     */
    @Autowired
    private RouteLocator routeLocator;
    /**
     * 网关应用名称
     */
    @Value("${spring.application.name}")
    private String applicationName;

    @Override
    public List<SwaggerResource> get() {
        //接口资源列表
        List<SwaggerResource> resources = new ArrayList<>();
        //服务名称列表
        List<String> routeHosts = new ArrayList<>();
        // 获取所有可用的应用名称
        routeLocator.getRoutes().filter(route -> route.getUri().getHost() != null)
                .filter(route -> !applicationName.equals(route.getUri().getHost()))
                .subscribe(route -> routeHosts.add(route.getUri().getHost()));
        // 去重,多负载服务只添加一次
        Set<String> existsServer = new HashSet<>();
        routeHosts.forEach(host -> {
            // 拼接url
            String url = "/" + host + API_URI;
            //不存在则添加
            if (!existsServer.contains(url)) {
                existsServer.add(url);
                SwaggerResource swaggerResource = new SwaggerResource();
                swaggerResource.setUrl(url);
                swaggerResource.setName(host);
                resources.add(swaggerResource);
            }
        });
        return resources;
    }
}

cn.tedu.gateway.controller

SwaggerController类

@RestController
@RequestMapping("/swagger-resources")
public class SwaggerController {
    @Autowired(required = false)
    private SecurityConfiguration securityConfiguration;
    @Autowired(required = false)
    private UiConfiguration uiConfiguration;
    private final SwaggerResourcesProvider swaggerResources;
    @Autowired
    public SwaggerController(SwaggerResourcesProvider swaggerResources) {
        this.swaggerResources = swaggerResources;
    }
    @GetMapping("/configuration/security")
    public Mono<ResponseEntity<SecurityConfiguration>> securityConfiguration() {
        return Mono.just(new ResponseEntity<>(
                Optional.ofNullable(securityConfiguration).orElse(SecurityConfigurationBuilder.builder().build()), HttpStatus.OK));
    }
    @GetMapping("/configuration/ui")
    public Mono<ResponseEntity<UiConfiguration>> uiConfiguration() {
        return Mono.just(new ResponseEntity<>(
                Optional.ofNullable(uiConfiguration).orElse(UiConfigurationBuilder.builder().build()), HttpStatus.OK));
    }
    @GetMapping("")
    public Mono<ResponseEntity> swaggerResources() {
        return Mono.just((new ResponseEntity<>(swaggerResources.get(), HttpStatus.OK)));
    }
}

cn.tedu.gateway.filter

SwaggerHeaderFilter类

@Component
public class SwaggerHeaderFilter extends AbstractGatewayFilterFactory {
    private static final String HEADER_NAME = "X-Forwarded-Prefix";

    private static final String URI = "/v2/api-docs";

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();
            if (!StringUtils.endsWithIgnoreCase(path,URI )) {
                return chain.filter(exchange);
            }
            String basePath = path.substring(0, path.lastIndexOf(URI));
            ServerHttpRequest newRequest = request.mutate().header(HEADER_NAME, basePath).build();
            ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
            return chain.filter(newExchange);
        };
    }
}

测试网关路由效果,和knife4j效果

启动Nacos\Seata\Sentinel

启动cart\stock\order\business

最后启动gateway

可以通过19000端口测试各个业务模块的功能

http://localhost:19000/nacos-stock/doc.html

http://localhost:19000/nacos-cart/doc.html

http://localhost:19000/nacos-order/doc.html

http://localhost:19000/nacos-business/doc.html

如果不使用网关一切正常,但是启动网关访问失败的话,就是gateway项目配置问题

5.12.Gateway和SpringMvc依赖冲突问题和解决

之前网关的演示项目我们添加的网关依赖

<!-- Spring Gateway 网关依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

当前csmall项目需要配置knife4j的路由配置,需要编写一个控制器

所以我们添加了SpringMvc的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

这两个依赖在同一个项目中时,默认情况下启动会报错

SpringMvc框架依赖中自带一个Tomcat服务器

而SpringGateway框架中自带一个Netty的服务器

在启动项目时,两个框架中包含的服务器都想占用相同端口,因为争夺端口号的主动权而发生冲突

导致启动服务时报错

要想能够正常启动必须在yml文件配置

spring:
  main:
    web-application-type: reactive

reactive:反应的

添加这个配置之后,会Tomcat服务器会变成非阻塞的运行

6.Elasticsearch 概述

6.1.Elasticsearch下载

苍老师网站

image-20220728111046563

官方下载链接

https://www.elastic.co/cn/downloads/past-releases#elasticsearch

6.2.什么是Elasticsearch

elastic:富有弹性的

search:搜索

我们可以把它简称为ES,但是搜索它的资料时(例如百度)还是使用Elasticsearch进行搜索更准确

这个软件不再是SpringCloud提供的,它也不针对微服务环境的项目来开发

Elasticsearch和redis\mysql一样,不仅服务于java语言,其它语言也可以使用

它的功能也类似一个数据库,能高效的从大量数据中搜索匹配指定关键字的内容

它也将数据保存在硬盘中

这样的软件有一个名称全文搜索引擎

它本质就是一个java项目,使用它进行数据的增删改查就是访问这个项目的控制器方法(url路径)

ES的底层技术

ES使用了java的一套名为Lucene的API

这个API提供了全文搜索引擎核心操作的接口,相当于搜索引擎的核心支持,ES是在Lucene的基础上进行了完善,实现了开箱即用的搜索引擎软件

市面上和ES功能类似的软件有

Solr/MongoDB

6.3.为什么需要Elasticsearch

数据库进行模糊查询效率严重低下

所有关系型数据库都有这个缺点(mysql\mariaDB\oracle\DB2等)

在执行类似下面模糊查询时

select * from spu where spu_name like '%鼠标%'

测试证明一张千万级别的数据表进行模糊查询需要20秒以上

当前互联网项目要求"三高"的需求下,这样的效率肯定不能接受

Elasticsearch主要是为了解决数据库模糊查询性能低下问题的

ES进行优化之后,从同样数据量的ES中查询相同条件数据,效率能够提高100倍以上

6.4.数据库索引简介

所谓的索引(index)其实就是数据目录

通常情况下,索引是为了提高查询效率的

数据库索引分两大类

  • 聚集索引
  • 非聚集索引

聚集索引就是数据库保存数据的物理顺序依据,默认情况下就是主键id,所以按id查询数据库中的数据效率非常高

非聚集索引:如果想在非主键列上添加索引,就是非聚集索引了

例如我们在数据库表中存在一个姓名列,我们为姓名列创建索引

在创建索引时,会根据姓名内容来创建索引

例如"张三丰" 这个姓名,创建索引后查询效率就会明显提升

如果没有索引,这样的查询就会引起效率最低的"逐行搜索",就是一行一行的查这个数据的姓名是不是张三丰,效率就会非常低

模糊查询时因为'%鼠标%',使用的是前模糊条件,使用索引必须明确前面的内容是什么,前模糊查询是不能使用索引的,只能是全表的逐行搜索,所以效率非常低

所以当我们项目中设计了根据用户输入关键字进行模糊查询时,需要使用全文搜索引擎来优化

索引面试题

1.创建的索引会占用硬盘空间

2.创建索引之后,对该表进行增删改操作时,会引起索引的更新,所以效率会降低

3.对数据库进行批量新增时,先删除索引,增加完毕之后再创建

4.不要对数据样本少的列添加索引

5.模糊查询时,查询条件前模糊的情况,是无法启用索引的

6.每次从数据表中查询的数据的比例越高,索引的效果越低

6.5.Elasticsearch运行原理

要想使用ES提高模糊查询效率

首先要将数据库中的数据复制到ES中

在新增数据到ES的过程中,ES可以对指定的列进行分词索引保存在索引库中

形成倒排索引结构

image-20221125173646730

6.6.Elasticsearch的启动

课程中使用7.6.2的版本

压缩包280M左右,复制到没有中文,没有空格的目录下解压

双击bin\elasticsearch.bat运行

image-20220510103755913

双击之后可能会看到下面的dos界面

image-20220510104124826

这个界面不能关闭,一旦关闭ES就停止了

验证ES的运行状态

浏览器输入地址:localhost:9200看到如下内容即可

image-20220510104421448

mac系统启动

tar -xvf elasticsearch-7.6.2-darwin-x86_64.tar.gz 
cd elasticsearch-7.6.2/bin 
./elasticsearch

linux:

tar -xvf elasticsearch-7.6.2-linux-x86_64.tar.gz
cd elasticsearch-7.6.2/bin
./elasticsearch

6.7.ES基本使用

ES启动完成后,我们要学习如何操作它

我们已经讲过,操作ES是对ES发送请求

我们创建一个子项目search,在这个子项目中创建一个专门发送各种类型请求的文件来操作ES

创建search项目也要父子相认

然后子项目pom文件如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>search</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

下面创建一个能够向ES发送请求的文件

这种能够向指定url发送请求的文件格式称之为http client(http 客户端)

image-20220825162421614

文件类型叫HTTP Request文件

我们可以起名为elasticsearch

我们先从最简单的请求开始

向es发送指令

6.7Es执行分词

上次课我们创建了search模块

在模块中创建的Http client文件

执行了访问ES的测试

下面要测试ES的分词功能

### 三个#是注释,也是分隔符,http文件要求每个请求必须以分隔符开始,否则会报错
GET http://localhost:9200

### 测试ES的分词功能,运行请求,查看分词结果
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "my name is hanmeimei",
  "analyzer": "standard"
}

analyze:分析

analyzer:分析者(分词器)

standard是ES默认的分词器,"analyzer": "standard"是可以省略的

standard这个分词器只能对英文等西文字符(有空格的),进行正确分词

但是中文分词不能按空格分,按这个分词器分词,每个字都会形成分词,这样的结果不能满足我们日常的搜索需要

image-20220510112849958

我们解决中文不能正确分词的问题

实际上要引入一个中文常见词语的词库,分词时按照词库中的词语分词即可

我们可以使用免费的中文分词器词库插件IK来实现中文分词效果

image-20220510113345841

安装插件之后要重启ES才能生效

关闭Es窗口之后再双击elasticsearch.bat文件运行即可

ES启动之后,将中文分词器设置完成,在运行分词

{
  "text": "罗技激光鼠标",
  "analyzer": "ik_smart"
}

再次运行分词测试,应该看到正常的中文分词效果

但是词库的容量有限,比较新的网络名词和较新出现的人名是不在词库中的

6.8.ik分词插件的种类

我们安装的ik实际上不只一个分词器

实际上除了ik_smart之外还有ik_max_word

POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "北京冬季奥林匹克运动会顺利闭幕",
  "analyzer": "ik_smart"
}
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "北京冬季奥林匹克运动会顺利闭幕",
  "analyzer": "ik_max_word"
}

上面的两个分词器运行分词,结果会有非常明显的区别

总结区别如下

ik_smart

  • 优点:特征是粗略快速的将文字进行分词,占用空间小,查询速度快

  • 缺点:分词的颗粒度大,可能跳过一些重要分词,导致查询结果不全面,查全率低

ik_max_word

  • 优点:特征是详细的文字片段进行分词,查询时查全率高,不容易遗漏数据
  • 缺点:因为分词太过详细,导致有一些无用分词,占用空间较大,查询速度慢

6.9.使用ES操作数据

ES是一个数据库性质的软件

可以执行增删改查操作,只是他操作数据不使用sql,数据的结构和关系型数据库也不同

我们先了解一下ES保存数据的结构

image-20220510142049346

  • ES启动后,ES服务可以创建多个index(索引),index可以理解为数据库中表的概念

  • 一个index可以创建多个保存数据的document(文档),一个document理解为数据库中的一行数据

  • 一个document中可以保存多个属性和属性值,对应数据库中的字段(列)和字段值

项目csmall-finish项目中

node文件夹下共享了ES文档,命令都在里面,可以测试

所有的代码都在"ES文档"中, 笔记略

下面我们要学习使用java代码来操作ES

7.SpringBoot 操作 Elasticsearch

7.1Spring Data简介

原生状态下,我们使用JDBC连接数据库,因为代码过于繁琐,所以改为使用Mybatis框架

在ES的原生状态下,我们java代码需要使用socket访问ES,但是也是过于繁琐,我们可以使用SpringData框架简化

Spring Data是Spring提供的一套连接各种第三方数据源的框架集

我们需要使用的是其中连接ES的Spring Data Elasticseatrch

官方网站:https://spring.io/projects/spring-data

image-20220510152943325

官网中列出了SpringData支持连接操作的数据源列表

下面我们就按照SpringDataElasticsearch的步骤对ES进行操作

7.2添加依赖和配置

就使用我们之前创建的search模块来操作ES

pom文件添加依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>search</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Spring Data Elasticsearch 整合SpringBoot的依赖   -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
    </dependencies>

</project>

application.properties添加配置

# 配置ES的ip和端口
spring.elasticsearch.rest.uris=http://localhost:9200

# 设置日志门槛
logging.level.cn.tedu.search=debug
# SpringDataElasticsearch框架中日志输出专用类也要设置debug
logging.level.org.elasticsearch.client.RestClient=debug

7.3创建和ES关联的实体类

和数据库一样

我们操作ES时也需要一个类似实体类的数据类,作为操作ES的数据载体

search项目创建entity包

在包中创建Item(商品)类

@Data
@Accessors(chain = true)    // 支持链式set赋值
@AllArgsConstructor         // 自动生成包含全部参数的构造方法
@NoArgsConstructor          // 自动生成无参数的构造方法

// @Document注解标记当前类是ES框架对应的实体类
// 属性indexName指定ES中对应的索引名称,运行时,如果这个索引不存在,SpringData会自动创建它
@Document(indexName = "items")
public class Item implements Serializable {

    // SpringData通过@Id标记当前实体类的主键属性
    @Id
    private Long id;
    // @Field是SpringData标记普通属性的注解
    // type是定义这个属性的类型,FieldType.Text是支持分词的字符串,后面要定义两个分词器
    @Field(type = FieldType.Text,
            analyzer = "ik_max_word",
            searchAnalyzer = "ik_max_word")
    private String title;
    // Keyword是不需分词的字符串类型
    @Field(type = FieldType.Keyword)
    private String category;
    @Field(type = FieldType.Keyword)
    private String brand;
    @Field(type = FieldType.Double)
    private Double price;
    // imgPath是图片路径,路径不会成为搜索条件,所以这个列可以不创建索引,节省空间
    // index = false,就是不创建索引的设置
    // 但是需要注意,不创建索引并不是不保存这个数据,ES中仍然保存imgPath的值
    @Field(type = FieldType.Keyword,index = false)
    private String imgPath;

    // images/2022/11/28/18239adc-8ae913-abbf91.jpg
}

7.4创建操作ES的持久层

我们使用SpringData连接ES

需要知道SpringData框架对持久层的命名规则

持久层规范名称为repository(仓库),创建这个包,包中创建接口ItemRepository

// Repository是Spring家族对持久层包名\类名\接口名的规范
@Repository
public interface ItemRepository extends ElasticsearchRepository<Item,Long> {
    // ItemRepository接口要继承SpringData框架提供的父接口ElasticsearchRepository
    // 一旦继承,当前接口就可以编写使用父接口中提供的连接ES的方法了
    // 继承父接口后,SpringData会根据我们在泛型中指定的Item实体类,找到对应的索引
    // 并生成操作这个索引的基本增删改查方法,我们自己无需编写
    // ElasticsearchRepository<[要操作的实体类名称],[实体类主键的类型]>
}

测试ES

如果没有测试包,创建test测试包

如果没有测试类,创建测试类

编写测试

// springboot环境下的测试必须要@SpringBootTest注解才能成功
@SpringBootTest
class SearchApplicationTests {

    @Autowired
    private ItemRepository itemRepository;

    // 执行单增
    @Test
    void addOne() {
        // 实例化Item对象
        Item item=new Item()
                .setId(1L)
                .setTitle("罗技激光无线游戏鼠标")
                .setCategory("鼠标")
                .setBrand("罗技")
                .setPrice(188.0)
                .setImgPath("/1.jpg");
        // 利用SpringDataElasticsearch提供的方法,完成这个实体类新增到Es
        itemRepository.save(item);
        System.out.println("ok");
    }

    // 单查
    @Test
    void getOne(){
        // SpringDataElasticsearch提供了按id查询ES中数据的方法
        // 返回值是一个Optional类型对象,声明了一个泛型,我们理解为只能保存一个该泛型类型对象的集合
        Optional<Item> optional = itemRepository.findById(1L);
        Item item=optional.get();
        System.out.println(item);

    }

    // 批量增
    @Test
    void addList(){
        // 实例化一个List,把要保存到Es中的数据都添加到这个集合中
        List<Item> list=new ArrayList<>();
        list.add(new Item(2L,"罗技激光有线办公鼠标","鼠标",
                            "罗技",9.9,"/2.jpg"));
        list.add(new Item(3L,"雷蛇机械无线游戏键盘","键盘",
                            "雷蛇",268.0,"/3.jpg"));
        list.add(new Item(4L,"微软有线静音办公鼠标","鼠标",
                            "微软",199.0,"/4.jpg"));
        list.add(new Item(5L,"罗技机械有线背光键盘","键盘",
                            "罗技",228.0,"/5.jpg"));
        itemRepository.saveAll(list);
        System.out.println("ok");
    }

    // 全查
    @Test
    void getAll(){
        // SpringData框架提供的全查ES中对应实体类所有数据的方法
        Iterable<Item> items = itemRepository.findAll();
        for(Item item : items){
            System.out.println(item);
        }
        System.out.println("----------------------------------");
        items.forEach(item -> System.out.println(item));
    }

}

7.5SpringData自定义查询

SpringData框架提供的基本增删改查方法并不能完全满足我们的业务需要

如果是针对当前Es数据,进行个性化的自定义查询,那还是需要自己编写查询代码

就像我们要实现根据关键词查询商品信息一样,完成类似数据库中的模糊查询.

单条件查询

我们查询需求为输出所有数据中title属性包含"游戏"这个分词的商品信息

参考数据库中模糊查询

select * from item where title like '%游戏%'

我们使用SpringDataES进行查询,本质上还是相当于ES文档中执行的查询语句

在SpringData框架下,ItemRepository接口中实现更加简单

// SpringData自定义查询
// 我们要编写遵循SpringData给定的格式的方法名
// SpringData会根据方法名自动推断出查询意图,生成能够完成该查询的语句
// query(查询):表达当前的方法是一个查询方法,类似sql语句中的select
// Item/Items:是要查询的实体类名称,返回集合的查询应该带s
// By(通过\根据):标识开始设置查询条件的关键字,等价于sql语句中的where
// Title:要查询的字段,可以是Item实体类中的任何字段
// Matches(匹配):执行查询的条件,Matches是查询匹配字符串的关键字,类似于sql语句中的like

Iterable<Item> queryItemsByTitleMatches(String title);

下面可以开始在测试类中进行测试查询

// 单条件自定义查询
@Test
void queryOne(){
    // 查询Es的items索引中,title字段包含"游戏"分词的数据
    Iterable<Item> items=itemRepository.queryItemsByTitleMatches("游戏");
    items.forEach(item -> System.out.println(item));
}

上面代码运行时底层运行的查询语句为:

### 单条件搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {"match": { "title":  "游戏" }}
}

多条件查询

在相对复杂的查询逻辑下

经常使用多个条件来定位查询需要的数据

这样就需要逻辑运算符"and"/"or"

ItemRepository接口中添加多条件的查询方法

// 多条件自定义查询
// 多个条件之间要使用and或or来分隔,表示多个条件之间的逻辑关系
// 下面我们要使用title和brand字段进行多条件查询
// 多个条件时,方法名要按照规则编写多个条件,参数也要对应条件数量来变化
// 声明的参数会按照顺序依次赋值到需要值的条件中,和参数名称无关
Iterable<Item> queryItemsByTitleMatchesAndBrandMatches(
                                            String title,String brand);

测试代码如下

// 多条件自定义查询
@Test
void queryTwo(){
    // 查询ES中,items的索引里,title字段包含"游戏",并且品牌是"罗技"的数据
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatchesAndBrandMatches("游戏","罗技");
    items.forEach(item -> System.out.println(item));
}

底层运行的请求

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  }
}

当查询条件关系为And时,查询语句关键字为must

当查询条件关系为Or时,查询语句关键字为should

排序查询

默认情况下从ES中查询获得的数据排序依据是ES查询得出的相关性分数(score)

但是如果想改变这个排序就需要在查询方法上添加新的关键字

在ItemRepository接口添加具备排序功能的查询方法

// 排序查询
Iterable<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
       												 String title,String brand);

测试代码如下

@Test
void queryOrder(){
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                                                            "游戏","罗技");
    items.forEach(item -> System.out.println(item));
}

底层运行的代码

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  },"sort":[{"price":"desc"}]
}

分页查询

SpringData框架支持完成分页查询

需要在ItemRepository接口中修改方法的参数和返回值就可以实现

// 分页查询
// 返回值类型需要修改为Page类型,这个类型既可以保存从ES中查询出的数据
// 又可以保存当前分页查询的分页信息例如:当前页码,每页条数,总条数,总页数,有没有上一页,有没有下一页等
// 参数方面,需要在参数列表末尾添加一个Pageable类型的参数
// 这个类型的对象包含要查询的页码和每页的条数
Page<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                                     String title, String brand, Pageable pageable);

测试代码

// 自定义分页查询
@Test
void queryPage(){
    int page=1;                 // 设置要查询的页码 1表示查询第一页
    int pageSize=2;             // 每页条数的设置
    Page<Item> pages=itemRepository
            .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                    "游戏","罗技", PageRequest.of(page-1,pageSize));
    pages.forEach(item -> System.out.println(item));

    // pages对象包含的分页信息输出
    System.out.println("总页数:"+pages.getTotalPages());
    System.out.println("总条数:"+pages.getTotalElements());
    System.out.println("当前页:"+(pages.getNumber()+1));
    System.out.println("每页条数:"+pages.getSize());
    System.out.println("是否是首页:"+pages.isFirst());
    System.out.println("是否是末页:"+pages.isLast());

}

8.酷鲨商城概述

8.1一般项目开发流程

运营部分发起项目策划

提出项目的功能或特色,交由产品经理

产品经理细化功能,提出需求交给开发部门

以下是开发部分的操作

需求分析(需求分析文档)

根据需求分析结果,做数据库设计

针对业务做概要设计

找前端开发人员做一个页面原型

详细设计(可选)

开发阶段

测试

部署上线

8.2酷鲨项目进程

三阶段:酷鲨商城引流平台

SSM基本增删改查,完成的一个广告性质的展示页面

四阶段:酷鲨商城后台管理系统

单体管理项目的经典实现,完成商城的后台管理系统

五阶段:酷鲨商城前台访问页面(移动端)

8.3开发计划

1.分类搜索

2.商品列表

3.商品详情

4.购物车管理

5.新增订单和订单列表

6.搜索商品

7.高并发秒杀商品

9.开发分类功能

9.1分类功能实现逻辑

我们数据库mall_pms的category表使用自关联实现了三级分类

当前酷鲨商城项目使用固定的三级分类

1.从数据库中查询出所有分类信息,一次性全查

2.构建分类信息的父子结构,实现查询返回父子结构的分类信息

3.将查询到的结果保存在Redis中,以备后续用户直接获取

代码中要判断Redis中是否包含全部分类数据,不包含的话做上面操作

包含分类数据的话直接获得之后返回

9.2业务分析

查询全部分类的业务重点在构建三级分类树结构

我们需要将从数据库中查询出的分类对象构成下面的结构

[
    {id:1,name:"手机/运行商/数码",parentId:0,depth:1,children:[
        {id:2,name:"手机通讯",parentId:1,depth:2,children:[
            {id:3,name:"智能手机",parentId:2,depth:3,children:null},
            {id:4,name:"非智能手机",parentId:2,depth:3,children:null}
        ]},
    ]},
    {id:5,name:"电脑/办公",parentId:0,depth:1,children:[....]}
]

上面是我们需要获得的对象的结构

可以理解为下图

在数据库mall_pms中

有pms_category表,这个表就是保存全部分类信息的表格

id:主键

name:显示在页面上的分类名称

parentId:父分类的id 如果是一级分类父分类id为0

depth:分类深度,当前项目就是3级分类,1\2\3 分别代表它的等级

keyword:搜索关键字

sort:排序依据 正常查询时,根据此列进行排序,数字越小越出现在前面(升序)

icon:图标地址

enable:是否可用

isparent:是否为父分类 0 假 1真

isdisplay:是否显示在导航栏 0不显示 1显示

9.3实施开发

递归实现文章参考 : https://www.yht7.com/news/127281

front:前台 在csmall-front-webapi项目中开发 无需编写持久层代码,因为mall_pms数据库的所有操作均在product模块编写完成了 我们front模块只需dubbo调用即可 创建service.impl包 包中编写业务逻辑层实现类FrontCategoryServiceImpl 实现IFrontCategoryService

@Service
@Slf4j
public class FrontCategoryServiceImpl implements IFrontCategoryService {

    // front模块要dubbo调用product模块的方法,实现查询所有分类对象集合
    @DubboReference
    private IForFrontCategoryService dubboCategoryService;

    // 装配操作redis的对象
    @Autowired
    private RedisTemplate redisTemplate;

    // 开发过程中,使用Redis时规范要求需要定义一个常量来作为Redis的key,避免拼写错误
    public static final String CATEGORY_TREE_KEY="category_tree";

    // 返回三级分类树对象的方法
    @Override
    public FrontCategoryTreeVO categoryTree() {
        // 先检查Redis中是否已经保存了三级分类树对象
        if(redisTemplate.hasKey(CATEGORY_TREE_KEY)){
            // redis中如果已经有了这个key直接获取即可
            FrontCategoryTreeVO<FrontCategoryEntity> treeVO =
                    (FrontCategoryTreeVO<FrontCategoryEntity>)
                    redisTemplate.boundValueOps(CATEGORY_TREE_KEY).get();
            // 将查询出的数据返回
            return treeVO;
        }
        // Redis中没有三级分类树信息,表示本次请求可能是首次访问
        // 就需要从数据库中查询分类对象集合,再构建三级分类树,再保存到Redis的业务流程
        // dubbo调用查询所有分类对象的方法
        List<CategoryStandardVO> categoryStandardVOs =
                                dubboCategoryService.getCategoryList();
        // 记住CategoryStandardVO是没有children属性的,FrontCategoryEntity是有的!
        // 下面需要编写一个方法,将子分类对象保存到对应的父分类对象的children属性中
        // 大概思路就是先将CategoryStandardVO转换为FrontCategoryEntity类型,然后再将父子分类关联
        // 整个转换和关联的过程比较复杂,我们编写一个方法来完成
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                                            initTree(categoryStandardVOs);
        // 上面方法完成了三级分类树的构建,下面要将treeVO保存到Redis中
        redisTemplate.boundValueOps(CATEGORY_TREE_KEY)
                .set(treeVO,1, TimeUnit.MINUTES);
        // 上面时间定义了1分钟,是学习测试比较合适的,上线项目中,保存时间会比较长,例如24小时甚至更多
        // 最后也别忘了返回treeVO
        return treeVO;
    }

    private FrontCategoryTreeVO<FrontCategoryEntity> initTree(
                            List<CategoryStandardVO> categoryStandardVOs) {
        // 第一步:
        // 确定所有分类的父分类id
        // 以父分类的id为Key,以子分类对象为value保存在一个Map中
        // 一个父分类可以包含多个子分类对象,所以这个map的value是个list
        Map<Long,List<FrontCategoryEntity>> map= new HashMap<>();
        log.info("准备构建的三级分类树对象数量为:{}",categoryStandardVOs.size());
        // 遍历数据库查询出来的所有分类集合对象
        for(CategoryStandardVO categoryStandardVO: categoryStandardVOs){
            // 因为CategoryStandardVO对象没有children属性,不能保存关联的子分类对象
            // 所以要将categoryStandardVO中的值赋值给能保存children属性的FrontCategoryEntity对象
            FrontCategoryEntity frontCategoryEntity=new FrontCategoryEntity();
            // 同名属性赋值
            BeanUtils.copyProperties(categoryStandardVO,frontCategoryEntity);
            // 获取当前分类对象的父分类id,用作map元素的key值(如果父分类id为0,就是一级分类)
            Long parentId=frontCategoryEntity.getParentId();
            // 判断这个父分类id是否已经存在于map
            if(!map.containsKey(parentId)){
                // 如果map中没有当前遍历对象父分类id作为key的元素
                // 那么就要新建这个元素,就要确定key和value
                // key就是parentId,value是一个list,要实例化,而且list中还要保存当前正在遍历的对象
                List<FrontCategoryEntity> value=new ArrayList<>();
                value.add(frontCategoryEntity);
                // 最后将准备好的key和value保存到map中
                map.put(parentId,value);
            }else{
                // 如果map中已经有当前遍历对象父分类id作为key的元素
                map.get(parentId).add(frontCategoryEntity);
            }
        }
        // 第二步:
        // 将子分类对象添加到对应的父分类对象的childrens属性中
        // 先获取所有一级分类对象,也就是父分类id为0的对象
        List<FrontCategoryEntity> firstLevels=map.get(0L);
        // 判断一级分类集合如果为null(或没有元素),直接抛出异常,终止程序
        if (firstLevels==null || firstLevels.isEmpty()){
            throw new CoolSharkServiceException(
                    ResponseCode.INTERNAL_SERVER_ERROR,"没有一级分类对象!");
        }
        // 遍历一级分类集合
        for(FrontCategoryEntity oneLevel : firstLevels){
            // 一级分类对象的id就是二级分类对象的父id
            Long secondLevelParentId=oneLevel.getId(); // getId()!!!!!!!!!!
            // 根据上面二级分类的父id,获得这个一级分类包含的所有二级分类对象集合
            List<FrontCategoryEntity> secondLevels=map.get(secondLevelParentId);
            // 判断二级分类中是否有元素
            if(secondLevels==null || secondLevels.isEmpty()){
                // 二级分类缺失不用抛异常,日志输出警告即可
                log.warn("当前分类没有二级分类内容:{}",secondLevelParentId);
                // 如果二级分类对象缺失,可以直接跳过本次循环剩余内容,继续下次循环
                continue;
            }
            // 确定二级分类对象后,遍历二级分类对象集合
            for(FrontCategoryEntity twoLevel :  secondLevels){
                // 获取当前二级分类对象的id,作为三级分类的父id
                Long thirdLevelParentId=twoLevel.getId();  //getId()!!!!!!!
                // 根据这个id获得这个二级分类对象关联的所有三级分类对象集合
                List<FrontCategoryEntity> thirdLevels=map.get(thirdLevelParentId);
                // 再判断这个三级分类集合是否为null
                if(thirdLevels==null  || thirdLevels.isEmpty()){
                    log.warn("当前二级分类对象没有三级分类内容:{}",thirdLevelParentId);
                    continue;
                }
                // 将三级分类对象集合添加到关联的二级分类对象childrens属性中
                twoLevel.setChildrens(thirdLevels);
            }
            // 将二级分类对象集合添加到关联的一级分类对象childrens属性中
            oneLevel.setChildrens(secondLevels);
        }
        // 到此为止,所有的分类对象都应该确认了自己和父\子分类对象的关联关系
        // 最后我们要将一级分类的集合firstLevels,
        // 赋值给FrontCategoryTreeVO<FrontCategoryEntity>的list属性
        // 实例化对象
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                new FrontCategoryTreeVO<>();
        treeVO.setCategories(firstLevels);
        // 最后千万别忘了返回  treeVO!!!!
        return treeVO;
    }
}

image-20220801114335291

创建控制层 controller包 CategoryController类 代码如下

@RestController
@RequestMapping("/front/category")
@Api(tags = "前台分类查询")
public class CategoryController {

    @Autowired
    private IFrontCategoryService categoryService;

    @GetMapping("/all")
    @ApiOperation("查询获取三级分类树对象")
    public JsonResult<FrontCategoryTreeVO<FrontCategoryEntity>> getTreeVO(){
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                categoryService.categoryTree();
        return JsonResult.ok(treeVO);
    }


}

启动nacos\seata\redis 先启动生产者product\后启动消费者front 访问 localhost:10004/doc.html

10.分页查询

10.1.分页查询的优点

所谓分页,就是查询结果数据较多时,采用按页显示的方法,而不是一次性全部显示

分页的优点:

  1. 服务器:一次性查询所有信息,服务器压力大,分页查询服务器压力小
  2. 客户端:一次性显示所有信息,需要更多流量,加载时间也会更长,分页显示没有这个问题
  3. 用户体验上:一般最有价值的信息都会在前几页显示,也方便用户记忆,多查询出来的数据使用几率很低

实现分页查询需要我们开发过程中多几个步骤

10.2.PageHelper实现分页查询原理

我们可以使用sql语句中添加limit关键字的方法实现分页查询

但是查询分页内容时,我们要自己计算相关的分页信息和参数

limit 0,10 limit 10,10

分页逻辑无论什么业务都是类似的,所以有框架帮助我们高效实现分页功能

PageHelper框架可以实现我们提供页码和每页条数,自动实现分页效果,收集分页信息

PageHelper的分页原理就是在程序运行时,在sql语句尾部添加limit关键字,并按照分页信息向limit后追加分页数据

要想使用,首先还是添加依赖

我们在之前搭建的微服务项目中先编写学习,建议使用csmall-order模块

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>

在添加seata支持时已经添加了pagehepler依赖

10.3.PageHelper的基本使用

编写持久层

我们使用csmall-order-webapi模块来完成分页的测试

首先编写分页的持久层mapper,持久层功能是全查所有订单信息

OrderMapper添加方法

// 分页查询所有订单的方法
// PageHelper框架完成分页的原理是运行的sql语句后自动添加limit关键字
// 所以我们在编写查询方法时,无需关注分页操作,和普通查询没有区别(注解和xml文件都是)
@Select("select id,user_id,commodity_code,count,money from order_tbl")
List<Order> findAllOrders();

注意这个方法并不需要任何分页的参数或返回值,sql也不需要编写limit

都是在业务逻辑层中由PageHelper框架处理的

编写业务逻辑层

下面就转到业务逻辑层实现类,先编写一个方法使用PageHelper的功能

先不用写接口,直接在业务逻辑层中写方法

OrderServiceImpl添加方法

// 分页查询所有订单信息的方法
// page是页码,pageSize是每页条数
public PageInfo<Order> getAllOrdersByPage(Integer page,Integer pageSize){
    // PageHelper框架实现分页功能的核心代码,是要在执行查询数据库代码运行前
    // 编写PageHelper.startPage(page,pageSize)方法,设置分页的查询条件
    // page是页码从1开始,表示第一页
    PageHelper.startPage(page,pageSize);
    // 上面设置好的分页查询条件,会在下面的查询执行时,sql语句会自动追加limit关键字
    List<Order> list= orderMapper.findAllOrders();
	
    // 查询结果list只包含查询到的分页数据,并不能包含分页信息(总页数,总条数,有没有上一页下一页等)
    // 所以我们要利用PageHelper提供的PageInfo类型对象来进行返回
    // PageInfo对象既可以包含分页数据,又可以包含分页信息,且是自动计算的
    // 使用方式是在返回时直接实例化即可,构造方法()中直接传入查询到的数据list即可
    return new PageInfo<>(list);

}

编写控制层

打开OrderController新建方法

@Autowired
//      ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
private OrderServiceImpl orderService;

//...

@GetMapping("/page")
@ApiOperation("分页查询所有订单")
@ApiImplicitParams({
        @ApiImplicitParam(value = "页码",name="page",example ="1" ),
        @ApiImplicitParam(value = "每页条数",name="pageSize",example ="10" )
})
public JsonResult<PageInfo<Order>> pageOrder(Integer page,Integer pageSize){
    PageInfo<Order> pageInfo=
            orderService.getAllOrdersByPage(page,pageSize);
    return JsonResult.ok("查询完成",pageInfo);
}

启动Nacos\Seata

启动order

进行knife4j测试http://localhost:20002/doc.html#/home

可以观察控制台输出的运行的sql语句(会自动添加limit关键字)

PageInfo对象既包含查询数据结果,又包含分页信息

数据结构如下图

image-20220512103623894

附:PageInfo全部分页信息属性

//当前页
private int pageNum;
//每页的数量
private int pageSize;
//当前页的行数量
private int size;
//当前页面第一个元素在数据库中的行号
private int startRow;
//当前页面最后一个元素在数据库中的行号
private int endRow;
//总页数
private int pages;
//前一页页号
private int prePage;
//下一页页号
private int nextPage;
//是否为第一页
private boolean isFirstPage;
//是否为最后一页
private boolean isLastPage;
//是否有前一页
private boolean hasPreviousPage;
//是否有下一页
private boolean hasNextPage;
//导航条中页码个数
private int navigatePages;
//所有导航条中显示的页号
private int[] navigatepageNums;
//导航条上的第一页页号
private int navigateFirstPage;
//导航条上的最后一页号
private int navigateLastPage;

10.4.使用JsonPage返回结果

当前我们分页查询返回的类型是PageInfo

如果用这个类型来做业务逻辑层的返回值,当当前方法作为dubbo生产者对外提供服务时

消费者调用该服务需要使用PageInfo类型对象来接收,这样要求消费者也添加PageHepler依赖,这是不合理的

所以我们设计在commons模块中,添加一个专门用于返回分页结果的类JsonPage,代替PageInfo

这样当前微服务项目中,所有分页或类似的操作,就都可以使用这个类了

例如之前SpringDataElasticsearch框架也支持分页,返回类型为Page,它也可以替换为JsonPage

因为需要在commons模块中使用PageInfo类型,所以commons模块要添加pageHelper的依赖

<!--  为了将PageHelper框架汇总分页查询的结果对象PageInfo转换为JsonPage类型
        我们需要在当前commons模块中添加PageHelper的依赖  -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper</artifactId>
    <version>5.2.0</version>
</dependency>

在restful包中新建一个JsonPage类

代码如下

@Data
public class JsonPage<T> implements Serializable {

    // 当前类是Page\PageInfo类的代替品,应该既能保存分页数据,又能保存分页信息
    // 也就是要将分页数据和分页信息声明出属性,至于声明出哪些属性,是我们自己定义的
    // 原则就是满足前端的需要,现在我们只编写最基本的分页信息,今后有需要再添加
    @ApiModelProperty(value = "总页数",name = "totalPages")
    private Integer totalPages;
    @ApiModelProperty(value = "总条数",name = "totalCount")
    private Long totalCount;
    @ApiModelProperty(value = "页码",name = "page")
    private Integer page;
    @ApiModelProperty(value = "每页条数",name = "pageSize")
    private Integer pageSize;

    // JsonPage中能够保存分页数据的集合对象
    @ApiModelProperty(value = "分页数据",name = "list")
    private List<T> list;

    // 下面要编写一个方法,能够实现将PageInfo类型转换成JsonPage对象返回
    // 如果还需要将其他分页类型(例如SpringData的Page类型)转换为JsonPage,再编写另外的方法即可
    public static <T> JsonPage<T> restPage(PageInfo<T> pageInfo){
        // 因为BeanUtils.copyProperties方法只能给同名属性赋值,
        // 又因为JsonPage和PageInfo属性同名属性有限,所以我们使用手动赋值的方式完成转换
        JsonPage<T> jsonPage=new JsonPage<>();
        jsonPage.setTotalCount(pageInfo.getTotal());
        jsonPage.setTotalPages(pageInfo.getPages());
        jsonPage.setPage(pageInfo.getPageNum());
        jsonPage.setPageSize(pageInfo.getPageSize());
        // 上面是分页信息的赋值,别忘了还有分页数据的赋值
        jsonPage.setList(pageInfo.getList());
        // 最终返回转换完成的jsonPage对象
        return jsonPage;
    }
}

下面去使用这个类

csmall-order-service项目的IOrderService业务逻辑层接口添加方法

返回值使用JsonPage定义一个分页查询的方法

// 返回JsonPage类型的分页查询订单的方法
JsonPage<Order> getAllOrdersByPage(Integer page,Integer pageSize);

csmall-order-webapi项目OrderServiceImpl实现类中进行修改

//     ↓↓↓↓↓↓↓↓
public JsonPage<OrderTb> getAllOrdersByPage(Integer pageNum, Integer pageSize){

    PageHelper.startPage(pageNum,pageSize);

    List<OrderTb> list= orderMapper.findAllOrders();

    //     ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    return JsonPage.restPage(new PageInfo<>(list));
}

业务逻辑层返回值的修改影响控制器方法的调用

再去修改OrderController中方法调用的位置

@Autowired
//      ↓↓↓↓↓↓↓↓↓↓↓↓
private IOrderService orderService;

//...
//                ↓↓↓↓↓↓↓↓
public JsonResult<JsonPage<Order>> pageOrders(Integer pageNum, Integer pageSize){
      // 分页调用
      //↓↓↓↓↓↓        ↓↓↓↓↓↓↓↓↓  
      JsonPage<Order> jsonPage=orderService.getAllOrdersByPage(
          pageNum,pageSize);
      //                            ↓↓↓↓↓↓↓↓↓↓
      return JsonResult.ok("查询完成",jsonPage);
}

保证启动Nacos\Seata

启动order测试

能出现查询结果即可

11.开发酷鲨前台商品列表

11.1.按分类id分页查询spu列表

转回到酷鲨商城大项目

用户会根据分类树中的分类的名称,查询它需要的商品类别

点击商品分类名称时,实际上我们获得了它的分类id(categoryId)

我们可以根据这个id到pms_spu表中查询商品信息

并进行分页显示

这个查询目标仍然为mall-pms数据库,是product模块管理的范围

所以我们继续在Front模块业务逻辑层中编写利用dubbo调用即可,还是不需要写mapper

下面就在业务逻辑层中创建FrontProductServiceImpl

@Service
@Slf4j
public class FrontProductServiceImpl implements IFrontProductService {

    @DubboReference
    private IForFrontSpuService dubboSpuService;

    // 根据分类id分页查询spu列表
    @Override
    public JsonPage<SpuListItemVO> listSpuByCategoryId(Long categoryId, Integer page, Integer pageSize) {
        // dubbo调用的方法是product模块编写的业务逻辑层方法
        // 这个方法中实际上完成了分页的操作,我们只需调用即可
        JsonPage<SpuListItemVO> jsonPage=
                dubboSpuService.listSpuByCategoryId(categoryId, page, pageSize);
        // 别忘了返回jsonPage !!!
        return jsonPage;
    }

    @Override
    public SpuStandardVO getFrontSpuById(Long id) {
        return null;
    }

    @Override
    public List<SkuStandardVO> getFrontSkusBySpuId(Long spuId) {
        return null;
    }

    @Override
    public SpuDetailStandardVO getSpuDetail(Long spuId) {
        return null;
    }

    @Override
    public List<AttributeStandardVO> getSpuAttributesBySpuId(Long spuId) {
        return null;
    }
}

业务逻辑层实现类先只实现按分类id分页查询的功能即可

创建FrontSpuController编写调用代码如下

@RestController
@RequestMapping("/front/spu")
@Api(tags = "前台商品Spu模块")
public class FrontSpuController {
    @Autowired
    private IFrontProductService frontProductService;

    // localhost:10004/front/spu/list/3
    @GetMapping("/list/{categoryId}")
    @ApiOperation("根据分类id分页查询spu列表")
    @ApiImplicitParams({
            @ApiImplicitParam(value = "分类id",name="categoryId",example = "3"),
            @ApiImplicitParam(value = "页码",name="page",example = "1"),
            @ApiImplicitParam(value = "每页条数",name="pageSize",example = "2")
    })
    public JsonResult<JsonPage<SpuListItemVO>> listSpuByPage(
                    @PathVariable Long categoryId, Integer page,Integer pageSize){
        JsonPage<SpuListItemVO> jsonPage=frontProductService
                .listSpuByCategoryId(categoryId, page, pageSize);
        return JsonResult.ok(jsonPage);
    }
}

然后在Nacos\Seata启动的前提下

顺序启动Product\Front

进行测试

http://localhost:10004/doc.html

11.2查询商品详情页信息

上面章节完成了查询spu列表

在商品列表中选中商品后,会显示这个商品的详情信息

商品详情页我们需要显示的信息包括

  • 根据spuId查询spu信息
  • 根据spuId查询spuDetail详情
  • 根据spuId查询当前Spu包含的所有属性
  • 根据spuId查询对应的sku列表

其中根据spuId查询当前Spu包含的所有属性功能涉及了一个比较复杂的连表查询

根据spuId查询参数选项的思路

1.根据spu_id去pms_spu表查询category_id

2.根据category_id去pms_category表查询分类对象

3.根据category_id去pms_category_attribute_template表查询attribute_template_id

4.根据attribute_template_id去pms_attribute_template表查询attribute_template数据行

5.根据attribute_template_id去pms_attribute表查询对应所有属性信息行

实际上,上面的联表查询可以简化为3表联查,结果相同

运行参考sql语句如下

SELECT
	pa.id , pa.template_id, pa.name,
	pa.description , pa.type,
	pa.value_list , pa.unit
FROM pms_spu ps
JOIN pms_category pc ON ps.category_id=pc.id
JOIN pms_category_attribute_template pcat ON pc.id=pcat.category_id
JOIN pms_attribute_template pat ON pcat.attribute_template_id=pat.id
JOIN pms_attribute pa ON pat.id=pa.template_id
WHERE ps.id=4

继续编写FrontProductServiceImpl其他没有实现的方法

@Service
@Slf4j
public class FrontProductServiceImpl implements IFrontProductService {

    @DubboReference
    private IForFrontSpuService dubboSpuService;
    // 根据spuId查询sku信息的dubbo调用对象
    @DubboReference
    private IForFrontSkuService dubboSkuService;
    // 根据spuId查询属性的dubbo调用对象
    @DubboReference
    private IForFrontAttributeService dubboAttributeService;


    // 根据分类id分页查询spu列表
    @Override
    public JsonPage<SpuListItemVO> listSpuByCategoryId(Long categoryId, Integer page, Integer pageSize) {
        // dubbo调用的方法是product模块编写的业务逻辑层方法
        // 这个方法中实际上完成了分页的操作,我们只需调用即可
        JsonPage<SpuListItemVO> jsonPage=
                dubboSpuService.listSpuByCategoryId(categoryId, page, pageSize);
        // 别忘了返回jsonPage !!!
        return jsonPage;
    }

    // 根据spuId 查询spu信息
    @Override
    public SpuStandardVO getFrontSpuById(Long id) {
        SpuStandardVO spuStandardVO = dubboSpuService.getSpuById(id);
        return spuStandardVO;
    }
    // 根据spuId查询sku列表
    @Override
    public List<SkuStandardVO> getFrontSkusBySpuId(Long spuId) {
        List<SkuStandardVO> list = dubboSkuService.getSkusBySpuId(spuId);
        return list;
    }

    // 根据spuId查询spuDetail
    @Override
    public SpuDetailStandardVO getSpuDetail(Long spuId) {
        SpuDetailStandardVO spuDetailStandardVO =
                                dubboSpuService.getSpuDetailById(spuId);
        return spuDetailStandardVO;
    }
    // 根据spuId查询当前商品所有属性\规格信息
    @Override
    public List<AttributeStandardVO> getSpuAttributesBySpuId(Long spuId) {
        List<AttributeStandardVO> list =
                dubboAttributeService.getSpuAttributesBySpuId(spuId);
        return list;
    }
}

上面的实现类其实就是业务逻辑层正常调用dubbo方法即可

开始编写控制层

FrontSpuController添加两个方法

  • 根据spuId查询spu信息
  • 根据spuId查询参数列表的
// localhost:10004/front/spu/1
@GetMapping("/{spuId}")
@ApiOperation("根据spuId查询spu信息")
@ApiImplicitParam(value = "spuId", name="spuId",example = "1")
public JsonResult<SpuStandardVO> getFrontSpuById(@PathVariable Long spuId){
    SpuStandardVO spuStandardVO = frontProductService.getFrontSpuById(spuId);
    return JsonResult.ok(spuStandardVO);
}

// localhost:10004/front/spu/template/1
@GetMapping("/template/{id}")
@ApiOperation("根据spuId查询所有的属性和规格")
@ApiImplicitParam(value = "spuId",name = "id",example = "1")
public JsonResult<List<AttributeStandardVO>> getAttributesBySpuId(
                                            @PathVariable Long id){
    List<AttributeStandardVO> list = frontProductService.getSpuAttributesBySpuId(id);
    return JsonResult.ok(list);
}

创建FrontSkuController添加一个方法

  • 根据spuId查询sku列表
@RestController
@RequestMapping("/front/sku")
@Api(tags = "前台商品sku模块")
public class FrontSkuController {

    @Autowired
    private IFrontProductService frontProductService;

    // localhost:10004/front/sku/1
    @GetMapping("/{spuId}")
    @ApiOperation("根据spuId查询sku列表")
    @ApiImplicitParam(value = "spuId",name="spuId" ,example = "1")
    public JsonResult<List<SkuStandardVO>> getSkuListBySpuId(@PathVariable Long spuId){
        List<SkuStandardVO> list = frontProductService.getFrontSkusBySpuId(spuId);
        return JsonResult.ok(list);

    }
}

FrontSpuDetailController添加一个方法

  • 根据spuId查询spuDetail
@RestController
@RequestMapping("/front/spu/detail")
@Api(tags = "前台spuDetail模块")
public class FrontSpuDetailController {
    @Autowired
    private IFrontProductService frontProductService;

    @GetMapping("/{spuId}")
    @ApiOperation("根据spuId查询spuDetail对象")
    @ApiImplicitParam(value = "spuId",name="spuId",example = "1")
    public JsonResult<SpuDetailStandardVO> getSpuDetailBySpuId(
                                                    @PathVariable Long spuId){
        SpuDetailStandardVO spuDetailStandardVO =
                            frontProductService.getSpuDetail(spuId);
        return JsonResult.ok(spuDetailStandardVO);
    }

}

nacos\seata\redis保持启动

启动product 重起front

访问10004测试,测试上面四个控制器模块的方法

12.登录流程回顾

12.1.用户\角色\权限

用户是一个基本的单位

我们登录时都是在登录用户的

我们再登录后需要明确这个用户具有哪些角色

用户和角色的关系是多对多

用户是一张表,角色也是一张表,因为它们是多对多的关系所以要有一张保存用户和角色关系的中间表

角色也不能直接决定这个用户能做什么操作,有哪些权限

需要再关联权限表决定

角色和权限也是多对多的关系,也要有中间表

如果项目开发的权限比较全面,可能会出现临时用户权限关系表

12.2.Spring Security

Spring Security框架用于实现登录,内置加密,验证,放行等各种功能,可靠性强

同时还可以将当前登录用户的信息保存

特别的,对于用户具备的权限,有特殊的管理

在控制器运行前,可以使用注解来判断当前登录用户是否具备某个权限

@PreAuthorize("[权限名称]")

SpringSecurity在运行该方法之前进行核查

如果不具备这个权限会返回403状态码

12.3.关于单点登录

普通登录的问题

SSO是单点登录的缩写:SSO(Single Sign On)

微服务架构下,要解决单点登录实现会话保持的问题

首先我们分析一下普通登录和微服务登录的区别

先是单体项目登录之后的操作流程

主要依靠服务器的session保存用户信息

客户端发请求时,将sessionid同时发往服务器,根据sessionid就能确认用户身份

image-20221130165346780

分布式或微服务项目中,服务器不再只有一个

那么就会出现下面的问题

image-20221130165903349

上面的图片,表示我们在微服务系统中登录时遇到的问题

我们在用户模块中登录,只是将用户信息保存在用户模块的session中

而这个session不会和其他模块共享

所以在我们访问购物车模块或其他模块时,通过sessionid并不能获得在用户模块中登录成功的信息

这样就丢失了用户信息,不能完成业务,会话保持就失败了

市面上现在大多使用JWT来实现微服务架构下的会话保持

也就是在一个服务器上登录成功后,微服务的其他模块也能识别用户的登录信息

这个技术就是单点登录

单点登录解决方案

Session共享

Session共享是能够实现单点登录效果的

这种方式的核心思想是将用户的登录信息共享给其他模块

适用于小型的,用户量不大的微服务项目

上面这个结构实现起来比较简单,Spring有框架直接支持,添加配置和依赖即可实现单点登录

这样就能将登录成功的用户信息共享给Redis

其他模块根据sessionId获得Redis中保存的用户信息即可

  • 这样做最大的缺点就是内存严重冗余,不适合大量用户的微服务项目

JWT单点登录

Json Web Token(令牌)

这种登录方式,最大的优点就是不占用内存

image-20221130174648103

生成的JWT由客户端自己保存,不占用服务器内存

在需要表明自己用户身份\信息时,将JWT信息保存到请求头中发送请求即可

Jwt登录流程图

image-20220802154420677

SSO(Single Sign On):单点登录

13.开发购物车功能

13.1.新增sku到购物车

我们开发完成了显示商品详情的功能

可以通过选中具体规格之后确定要购买的sku信息

再点击"添加到购物车"按钮

就应该将选中的sku的信息保存在购物车中

打开mall-order-webapi模块

创建mapper\service.impl\controller包

当前mall-order模块,管理的数据库是mall-oms数据库

业务逻辑分析

  • 判断用户是否登录,只有登录后才能将商品新增到购物车
  • 验证购物车信息的完整性(SpringValidation)
  • 业务逻辑层要判断新增的sku是否在当前用户的购物车表中已经存在
    • 如果不存在是新增sku流程
    • 如果已经存在,是修改数量的流程

13.2新增sku到购物车

开发持久层

之前的课程中,我们完成了新增sku到购物车的业务分析

持久层要按之前分析的业务逻辑,开发多个方法

1.判断当前登录用户购物车中是否包含指定skuId商品的方法

2.新增sku到购物车表中

3.修改购物车指定sku数量的方法

在mapper包中创建OmsCartMapper接口,编写代码如下

@Repository
public interface OmsCartMapper {
    // 判断当前用户的购物车中是否存在指定商品
    OmsCart selectExistsCart(@Param("userId") Long userId,
                             @Param("skuId") Long skuId);

    // 新增sku信息到购物车
    int saveCart(OmsCart omsCart);

    // 修改购物车中的sku商品数量
    int updateQuantityById(OmsCart omsCart);
    

}

对应的OmsCartMapper.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.mall.order.mapper.OmsCartMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="cn.tedu.mall.pojo.order.model.OmsCart">
        <id column="id" property="id" />
        <result column="user_id" property="userId" />
        <result column="sku_id" property="skuId" />
        <result column="title" property="title" />
        <result column="main_picture" property="mainPicture" />
        <result column="price" property="price" />
        <result column="quantity" property="quantity" />
        <result column="gmt_create" property="gmtCreate" />
        <result column="gmt_modified" property="gmtModified" />
        <result column="bar_code" property="barCode"/>
        <result column="data" property="data"/>
    </resultMap>
    <!--   定义查询omsCart表时使用的列名sql片段   -->
    <sql id="SimpleQueryFields">
        <if test="true">
            id,
            user_id,
            sku_id,
            title,
            main_picture,
            price,
            quantity,
            gmt_create,
            gmt_modified
        </if>
    </sql>
    <!--  判断当前用户的购物车中是否存在指定商品  -->
    <select id="selectExistsCart" resultMap="BaseResultMap">
        select
            <include refid="SimpleQueryFields" />
        from
            oms_cart
        where
            user_id=#{userId}
        and
            sku_id=#{skuId}
    </select>

    <!--  新增sku信息到购物车  -->
    <insert id="saveCart" useGeneratedKeys="true" keyProperty="id" >
        insert into oms_cart(
            user_id,
            sku_id,
            title,
            main_picture,
            price,
            quantity
        )values(
            #{userId},
            #{skuId},
            #{title},
            #{mainPicture},
            #{price},
            #{quantity}
        )
    </insert>
    <!-- 修改购物车中的sku商品数量  -->
    <update id="updateQuantityById">
        update
            oms_cart
        set
            quantity=#{quantity}
        where
            id=#{id}

    </update>
</mapper>

开发业务逻辑层

上面完成了持久层的代码

下面开发业务逻辑层

创建OmsCartServiceImpl类实现IOmsCartService接口

@Service
@Slf4j
public class OmsCartServiceImpl implements IOmsCartService {

    @Autowired
    private OmsCartMapper omsCartMapper;

    // 新增sku信息到购物车
    @Override
    public void addCart(CartAddDTO cartDTO) {
        // 要查询当前登录用户的购物车中是否已经包含指定商品,需要先获得当前用户id
        // 利用封装好的方法直接从SpringSecurity上下文中获取
        Long userId=getUserId();
        // 根据用户Id和商品skuId,查询商品信息
        OmsCart omsCart=omsCartMapper.selectExistsCart(userId,cartDTO.getSkuId());
        // 判断查询出的omsCart是否为null
        if(omsCart == null){
            // omsCart为null,表示当前用户购物车中没有这个sku商品
            // 所以要执行新增操作,新增操作需要一个OmsCart对象
            OmsCart newCart=new OmsCart();
            // 将参数cartDTO中和OmsCart中同名的属性赋值到newCart对象
            BeanUtils.copyProperties(cartDTO,newCart);
            // cartDTO中没有userId属性,需要单独赋值
            newCart.setUserId(userId);
            // 执行新增操作
            omsCartMapper.saveCart(newCart);
        }else{
            // 如果omsCart不是null,表示当前用户购物车中已经有这个商品了
            // 我们需要做的就是将购物车中原有的数量和新增的数量相加,保存到数据库中
            // 购物车中原有的数量是omsCart.getQuantity(),新增的数量是cartDTO.getQuantity()
            // 所以我们可以将这两个数量相加的和赋值给omsCart属性
            omsCart.setQuantity(omsCart.getQuantity()+cartDTO.getQuantity());
            // 确定了数量之后,调用我们的持久层方法进行修改
            omsCartMapper.updateQuantityById(omsCart);

        }

    }

    @Override
    public JsonPage<CartStandardVO> listCarts(Integer page, Integer pageSize) {
        return null;
    }

    @Override
    public void removeCart(Long[] ids) {

    }

    @Override
    public void removeAllCarts() {

    }

    @Override
    public void removeUserCarts(OmsCart omsCart) {

    }

    @Override
    public void updateQuantity(CartUpdateDTO cartUpdateDTO) {

    }

    // 业务逻辑层中有获得当前登录用户信息的需求
    // 我们的项目会在控制器方法运行前运行的过滤器代码中,解析前端传入的JWT
    // 在过滤器中,将JWT解析的结果(用户信息)保存到SpringSecurity上下文
    // 所以里可以编写代码从SpringSecurity上下文中获得用户信息
    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity上下文中信息是不是null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录!");
        }
        // 从SpringSecurity上下文中获得用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo) authenticationToken.getCredentials();
        // 最终别忘了返回
        return csmallAuthenticationInfo;
    }
    // 业务逻辑层需求中,实际上只需要用户的id
    // 我们可以再编写一个方法,从用户对象中获取id
    public Long getUserId(){
        return getUserInfo().getId();
    }

}

开发控制层

创建OmsCartController

@RestController
@RequestMapping("/oms/cart")
@Api(tags = "购物车管理模块")
public class OmsCartController {

    @Autowired
    private IOmsCartService omsCartService;

    @PostMapping("/add")
    @ApiOperation("新增sku信息到购物车")
    // 在程序运行控制器方法前,已经运行了过滤器代码,解析了JWT
    // 解析正确的话,已经将用户信息保存在了SpringSecurity上下文中
    // 酷鲨商城项目前台用户登录时,登录代码中会给用户一个固定的权限名ROLE_user
    // 下面的注解,主要目的是判断用户是否登录,权限统一设置为ROLE_user
    // 判断SpringSecurity上下文中是否存在这个权限,如果没有登录返回401错误
    @PreAuthorize("hasAuthority('ROLE_user')")
    // @Validated注解是激活SpringValidation框架用的
    // 参数CartAddDTO中,各个属性设置了验证规则,如果有参数值不符合规则
    // 会抛出BindException异常,由统一异常处理类处理,控制方法就不会运行了
    public JsonResult addCart(@Validated CartAddDTO cartAddDTO){
        omsCartService.addCart(cartAddDTO);
        return JsonResult.ok("新增sku到购物车完成!");
    }

}

先注意sso模块application-test.yml的地址和端口号和密码(密码有两个)

也要注意order模块application-test.yml的地址和端口号和密码

都保证正确的前提下

先启动Nacos/Seata

启动 passport/order

sso:10002

order:10005

先访问10002前台用户登录获得JWT 用户名jackson密码123456

先登录看到JWT 然后复制JWT

转到10005 order模块 文档管理->全局参数设置->添加参数

参数名:Authorization

参数值:Bearer [粘贴JWT]

然后刷新当前10005的界面

然后进行发送请求即可成功!

如果测试结果中包含一个错误

错误信息里有"xml/bind"的错误信息

order/sso模块需要添加下面依赖即可解决

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0</version>
</dependency>

12.3.开发查询购物车功能

开发持久层

OmsCartMapper添加方法如下

// 根据用户id查询该用户购物车中的sku信息
List<CartStandardVO> selectCartsByUserId(Long userId);

OmsCartMapper.xml添加对应内容

<!--  根据用户id查询该用户购物车中的sku信息 -->
<!--
       我们使用resultType声明返回值类型时
       Mybatis内部会按照驼峰命名法的命名规则,自动生成列名和属性名的映射
       列名           属性名
       id              id
       user_id         userId
       sku_id          skuId
       title           title
-->
<select id="selectCartsByUserId"
        resultType="cn.tedu.mall.pojo.order.vo.CartStandardVO">
    select
        <include refid="SimpleQueryFields" />
    from
        oms_cart
    where
        user_id=#{id}
</select>

开发业务逻辑层

OmsCartServiceImpl业务实现

返回值支持分页结果,按分页条件查询

// 分页查询当前登录用户购物车信息
@Override
public JsonPage<CartStandardVO> listCarts(Integer page, Integer pageSize) {
    // 要先从SpringSecurity上下文中获得用户id
    Long userId=getUserId();
    // 执行查询之前,先设置分页条件,(page,pageSize)
    PageHelper.startPage(page,pageSize);
    // 设置完分页条件,执行查询,会自动在sql语句有添加limit关键字
    List<CartStandardVO> list=omsCartMapper.selectCartsByUserId(userId);
    // list的分页数据,实例化PageInfo对象,并转换为jsonPage返回
    return JsonPage.restPage(new PageInfo<>(list));
}

开发控制层

下面开发控制层,调用方法进行测试

OmsCartController添加方法如下

@GetMapping("/list")
@ApiOperation("根据用户Id分页查询购物车sku列表")
@ApiImplicitParams({
        @ApiImplicitParam(value = "页码",name="page",example = "1"),
        @ApiImplicitParam(value = "每页条数",name="pageSize",example = "10")
})
@PreAuthorize("hasAuthority('ROLE_user')")
public JsonResult<JsonPage<CartStandardVO>> listCartsByPage(
        // 控制器方法参数可以设置默认值,在调用时如果这个参数没有值则会使用默认值
        // @RequestParam注解来实现,WebConsts是我们自定定义的包含常量的类
        @RequestParam(required = false,defaultValue = WebConsts.DEFAULT_PAGE)
                                        Integer page,
        @RequestParam(required = false,defaultValue = WebConsts.DEFAULT_PAGE_SIZE)
                                        Integer pageSize){
    JsonPage<CartStandardVO> jsonPage=
            omsCartService.listCarts(page,pageSize);
    return JsonResult.ok(jsonPage);
}

在上面测试了新增购物车环境的基础上

重启order模块

再次测试http://localhost:10005/doc.html

12.4.删除\清空购物车

删除购物车的持久层

我们删除购物车的功能支持同时删除一个或多个购物车中的商品

基本思路就是将要删除的购物车商品的id数组传入到Mapper中进行删除

在OmsCartMapper中添加方法

// 根据用户选中的一个或多个id,删除购物车中的商品(支持批量删除)
int deleteCartsByIds(Long[] ids);

OmsCartMapper.xml新增代码

<!--  根据用户选中的一个或多个id,删除购物车中的商品(支持批量删除)  -->
<!--  foreach中collection属性赋值为数值时,使用array或ids
      item是定义编写元素的名称,必须和循环体中#{}中的内容匹配
      separator是分隔符
      open和close是循环开始前和结束后添加在sql中的内容
 -->
<delete id="deleteCartsByIds">
    delete from
        oms_cart
    where
        id in
    <foreach collection="array" item="id" separator=","
                        open="(" close=")">
        #{id}
    </foreach>

</delete>

删除购物车的业务逻辑层

OmsCartServiceImpl添加方法

// 支持批量删除的删除购物车信息的方法
@Override
public void removeCart(Long[] ids) {
    // 调用mapper中批量删除的方法
    int rows=omsCartMapper.deleteCartsByIds(ids);
    if(rows==0){
        throw new CoolSharkServiceException(
                ResponseCode.NOT_FOUND,"您要删除的商品已经删除了!");
    }
}

删除购物车的控制层

OmsCartController

@PostMapping("/delete")
@ApiOperation("根据id数组删除购物车sku信息")
@ApiImplicitParam(value = "要删除的id数组",name="ids",
                            required = true,dataType = "array")
@PreAuthorize("hasAuthority('ROLE_user')")
public JsonResult removeCartsByIds(Long[] ids){
    omsCartService.removeCart(ids);
    return JsonResult.ok("删除完成!");
}

重启Order模块,测试删除功能

**课上作业: **

开发清空当前登录用户购物车的功能

<delete id="deleteCartsByUserId">
    delete from
        oms_cart
    where
        user_id=#{userId}
</delete>
@Override
public void removeAllCarts() {

}

清空购物车的功能

OmsCartMapper

// 清空指定用户购物车中所有sku商品
int deleteCartsByUserId(Long userId);

OmsCartMapper.xml

<!--   清空指定用户购物车中所有sku商品 -->
<delete id="deleteCartsByUserId">
    delete from
        oms_cart
    where
        user_id=#{userId}
</delete>

OmsCartServiceImpl

@Override
public void removeAllCarts() {
    Long userId=getUserId();
    int rows=omsCartMapper.deleteCartsByUserId(userId);
    if(rows==0){
        throw new CoolSharkServiceException(
                ResponseCode.NOT_FOUND,"您的购物车是空的!");
    }
}

OmsCartController

@PostMapping("/delete/all")
@ApiOperation("清空当前登录用户的购物车信息")
// 当@PreAuthorize注解后面要判断的权限内容以ROLE_开头时
// 表示我们要判断的权限实际上是SpringSecurity框架约定的角色名称
// 判断这样的角色时,我们可以在@PreAuthorize注解的()里使用hasRole来简化对角色的判断
// hasRole('user')这样的判断实际上就是检查登录用户是否包含ROLE_user的权限
// 也就是下面两种写法判断判断登录用户权限是等价的
// @PreAuthorize("hasAuthority('ROLE_user')")
@PreAuthorize("hasRole('user')")
public JsonResult removeCartsByUserId(){
    omsCartService.removeAllCarts();
    return JsonResult.ok("购物车已清空");
}

重启Order模块,测试删除功能

修改购物车的商品数量

开发修改购物车数量的业务逻辑层

因为之前开发新增购物车功能时,我们已经完成了修改购物车数量的持久层,所以不需要再编写了,直接从业务层开始

OmsCartServiceImpl

// 修改购物车中商品数量的业务逻辑层方法
@Override
public void updateQuantity(CartUpdateDTO cartUpdateDTO) {
    // 因为执行修改的mapper方法参数是OmsCart类型
    // 所以要先实例化OmsCart
    OmsCart omsCart=new OmsCart();
    // 然后将参数cartUpdateDTO同名属性赋值到omsCart
    BeanUtils.copyProperties(cartUpdateDTO,omsCart);
    // omsCart执行修改
    omsCartMapper.updateQuantityById(omsCart);
}

控制层OmsCartController

@PostMapping("/update/quantity")
@ApiOperation("修改购物车中sku的数量")
@PreAuthorize("hasRole('user')")
public JsonResult updateQuantity(@Validated CartUpdateDTO cartUpdateDTO){
    omsCartService.updateQuantity(cartUpdateDTO);
    return JsonResult.ok("修改完成!");
}

重启order测试

14.新增订单

14.1.新增订单业务逻辑分析

用户选中购物车中的商品后,点击添加订单(去结算)

我们要收集订单信息(sku商品信息,价格信息,优惠和运费信息等)然后才能执行生成订单操作

具体步骤如下

1.首先将用户选中的sku库存减少相应的数量

2.用户购物车要删除对应的商品

3.对应oms_order表执行新增,也就是创建一个订单

4.在新增订单成功后,我们还要将订单中的每种商品和订单关系添加在oms_order_item表中

image-20220803164416586

除了理解业务之外我们还要确定要使用的技术

除了之前一直使用的Nacos\Dubbo之外,创建订单的业务在减少库存时,是Dubbo调用的pms中的sku表,这就涉及了分布式事务seata,删除购物车,新增订单和新增订单项是order模块的功能

减少库存的功能是product模块写好的

14.2.开发删除选中的购物车商品的功能

本次删除我们使用用户id和skuId来指定要删除的购物车商品

之前没有写过,在OmsCartMapper编写

// 根据用户id和Skuid删除商品
int deleteCartByUserIdAndSkuId(OmsCart omsCart);

OmsCartMapper.xml

<!--  根据用户id和Skuid删除商品  -->
<delete id="deleteCartByUserIdAndSkuId">
    delete from
        oms_cart
    where
        user_id=#{userId}
    and
        sku_id=#{skuId}
</delete>

当前删除购物车商品的功能是为生成订单准备的

所以只需要开发出业务逻辑层即可,不需要控制层的代码

OmsCartServiceImpl实现方法

@Override
public void removeUserCarts(OmsCart omsCart) {
    // 直接调用删除购物车的方法即可
    // 删除影响的行数不用判断,因为无论删除成功还是失败,都不影响生成订单
    omsCartMapper.deleteCartByUserIdAndSkuId(omsCart);
    
}

14.3开始编写新增订单功能

编写新增order_item的持久层

order_item表中保存每张订单包含什么商品的信息

我们新增这个表,要包含订单号,商品id和相关信息

mapper下创建OmsOrderItemMapper

@Repository
public interface OmsOrderItemMapper {


    // 新增订单项(oms_order_item)的方法
    // 一个订单可能包含多件商品,每件商品都单独新增到数据库的话,会造成连库次数多,效率低
    // 我们这里采用一次连库新增多条数据的方式,来减少连库次数,提升操作效率
    // 所以我们参数就换成了集合类型List<OmsOrderItem>
    int insertOrderItemList(List<OmsOrderItem> omsOrderItems);
}

OmsOrderItemMapper.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.mall.order.mapper.OmsOrderItemMapper">
    <!-- 新增订单项(oms_order_item)的方法  -->
    <insert id="insertOrderItemList" >
        insert into oms_order_item(
            id,
            order_id,
            sku_id,
            title,
            bar_code,
            data,
            main_picture,
            price,
            quantity
        )values
        <foreach collection="list" item="ooi" separator=",">
            (
                #{ooi.id},
                #{ooi.orderId},
                #{ooi.skuId},
                #{ooi.title},
                #{ooi.barCode},
                #{ooi.data},
                #{ooi.mainPicture},
                #{ooi.price},
                #{ooi.quantity}
            )
        </foreach>
    </insert>
</mapper>

编写新增order的持久层

上次课我们完成了新增OrderItem对象的方法

下面要继续完成Order的方法

mapper包下再创建OmsOrderMapper

添加新增Order的方法

@Repository
public interface OmsOrderMapper {

    // 新增订单的方法
    int insertOrder(OmsOrder omsOrder);
}

OmsOrderMapper.xml中添加方法

<!-- 新增订单的方法  -->
<insert id="insertOrder">
    insert into oms_order(
        id,
        sn,
        user_id,
        contact_name,
        mobile_phone,
        telephone,
        province_code,
        province_name,
        city_code,
        city_name,
        district_code,
        district_name,
        street_code,
        street_name,
        detailed_address,
        tag,
        payment_type,
        state,
        reward_point,
        amount_of_original_price,
        amount_of_freight,
        amount_of_discount,
        amount_of_actual_pay,
        gmt_pay,
        gmt_order,
        gmt_create,
        gmt_modified
    ) values(
        #{id},
        #{sn},
        #{userId},
        #{contactName},
        #{mobilePhone},
        #{telephone},
        #{provinceCode},
        #{provinceName},
        #{cityCode},
        #{cityName},
        #{districtCode},
        #{districtName},
        #{streetCode},
        #{streetName},
        #{detailedAddress},
        #{tag},
        #{paymentType},
        #{state},
        #{rewardPoint},
        #{amountOfOriginalPrice},
        #{amountOfFreight},
        #{amountOfDiscount},
        #{amountOfActualPay},
        #{gmtPay},
        #{gmtOrder},
        #{gmtCreate},
        #{gmtModified}
    )
</insert>

14.Leaf

14.1.什么是Leaf

leaf是叶子的意思

我们使用的Leaf是美团公司开源的一个分布式序列号(id)生成系统

我们可以在Github网站上下载项目直接使用

14.2.为什么需要Leaf

image-20221202102818781

上面的图片中

是一个实际开发中常见的读写分离的数据库部署格式

专门进行数据更新(写)的有两个数据库节点

它们同时新增数据可能产生相同的自增列id

一旦生成相同的id,数据同步就会有问题

会产生id冲突,甚至引发异常

我们为了在这种多数据库节点的环境下能够产生唯一id

可以使用Leaf来生成

14.3.Leaf的工作原理

Leaf底层支持通过"雪花算法"生成不同id

我们使用的是单纯的序列

要想使用,需要事先设置好leaf的起始值和缓存id数

举例,从1000开始缓存500

也就是从id1000~1499这些值,都会保存在Leaf的内存中,当有服务需要时,直接取出下一个值

取出过的值不会再次生成,当缓存的数据取完时,会往后再缓存500个,从1500-1999

image-20220831173850742

leaf要想设置起始值和缓存数

需要给leaf创建一个指定格式的数据库表

运行过程中会从数据库表获取信息

我们当前的信息保存在leafdb.leaf_alloc表中

续开发新增订单功能

开发新增订单的业务逻辑层

我们完成订单的新增业务是比较复杂的

可以将整个业务分成三大部分

第一部分是信息的收集

主要是参数类型数据的完整性验证,计算以及转换

第二部分是数据库操作

减少库存,删除购物车,新增订单,和新增订单项

第三部分是收集需要的返回值

我们新增订单成功后,要返回给前端一些信息,例如订单号,实际支付金额等

创建OmsOrderServiceImpl类,代码如下

//订单管理模块的业务逻辑层实现类,因为后面秒杀模块需要生成订单的功能,所以注册到dubbo
@DubboService
@Service
@Slf4j
public class OmsOrderServiceImpl implements IOmsOrderService {

    // dubbo调用减少库存数的方法
    @DubboReference
    private IForOrderSkuService dubboSkuService;
    @Autowired
    private IOmsCartService omsCartService;
    @Autowired
    private OmsOrderMapper omsOrderMapper;
    @Autowired
    private OmsOrderItemMapper omsOrderItemMapper;

    // 新增订单的方法
    // 这个方法dubbo调用了product模块的方法,操作了数据库,有分布式事务的需求
    // 需要使用注解激活Seata分布式事务的功能
    @GlobalTransactional
    @Override
    public OrderAddVO addOrder(OrderAddDTO orderAddDTO) {
        // 第一部分:收集信息,准备数据
        // 先实例化OmsOrder对象
        OmsOrder order=new OmsOrder();
        // 将参数orderAddDTO同名属性赋值到order对象中
        BeanUtils.copyProperties(orderAddDTO,order);
        // orderAddDTO中实际上只有一部分内容,order对象属性并不完全
        // 所以我们要编写一个方法,完成order未赋值属性的收集或生成
        loadOrder(order);
        // 到此为止,order对象的所有信息就收集完毕了
        // 下面要将参数orderAddDTO中包含的订单项集合属性:orderItems进行信息收集和赋值
        // 先从参数中获得这个集合
        List<OrderItemAddDTO> itemAddDTOs=orderAddDTO.getOrderItems();
        if(itemAddDTOs == null || itemAddDTOs.isEmpty()){
            // 如果订单项集合中没有商品,抛出异常终止程序
            throw new CoolSharkServiceException(ResponseCode.BAD_REQUEST,
                    "订单中必须至少包含一件商品");
        }
        // 我们最终的目标是将当前订单中包含的订单项新增到数据库
        // 当前集合泛型类型是OrderItemAddDTO,而我们向数据库进行订单项批量新增操作的泛型是OmsOrderItem
        // 所以我们要编写将上面集合转换为List<OmsOrderItem>类型的方法
        List<OmsOrderItem> omsOrderItems=new ArrayList<>();
        // 遍历itemAddDTOs
        for(OrderItemAddDTO addDTO : itemAddDTOs){
            // 实例化最终需要的OmsOrderItem类型对象
            OmsOrderItem orderItem=new OmsOrderItem();
            // 将addDTO同名属性赋值到orderItem中
            BeanUtils.copyProperties(addDTO,orderItem);
            // 将addDTO对象中没有的id属性和orderId属性赋值
            // 利用Leaf生成订单项的id并赋值
            Long itemId=IdGeneratorUtils.getDistributeId("order_item");
            orderItem.setId(itemId);
            // 赋值订单id
            orderItem.setOrderId(order.getId());
            // orderItem的所有值赋值完成,将这个对象添加到集合中
            omsOrderItems.add(orderItem);
            // 第二部分:执行数据库操作指令
            // 1.减少库存
            // 当前正在遍历的对象就是一个包含skuId和减少库存数的对象
            // 先获取skuId
            Long skuId=orderItem.getSkuId();
            // dubbo调用减少库存的方法
            int rows=dubboSkuService.reduceStockNum(skuId,orderItem.getQuantity());
            // 判断rows值是否为0
            if(rows==0){
                log.error("商品库存不足,skuId:{}",skuId);
                // 抛出异常终止程序,触发seata分布式事务回滚
                throw new CoolSharkServiceException(ResponseCode.BAD_REQUEST,
                        "库存不足");
            }
            // 2.删除勾选的购物车的商品
            OmsCart omsCart=new OmsCart();
            omsCart.setUserId(order.getUserId());
            omsCart.setSkuId(skuId);
            // 执行删除
            omsCartService.removeUserCarts(omsCart);
        }
        // 3.执行新增订单
        omsOrderMapper.insertOrder(order);
        // 4.新增订单项(批量新增集合中所有订单项数据)
        omsOrderItemMapper.insertOrderItemList(omsOrderItems);
        // 第三部分:准备返回值,返回给前端
        // 实例化返回值类型对象
        OrderAddVO addVO=new OrderAddVO();
        // 给各属性赋值
        addVO.setId(order.getId());
        addVO.setSn(order.getSn());
        addVO.setCreateTime(order.getGmtCreate());
        addVO.setPayAmount(order.getAmountOfActualPay());
        // 最后千万别忘了返回 addVO !!!!
        return addVO;
    }
    // 为order对象补全属性值的方法
    private void loadOrder(OmsOrder order) {
        // 我们设计新增订单的功能使用Leaf分布式序列生成系统
        Long id= IdGeneratorUtils.getDistributeId("order");
        order.setId(id);
        // 生成用户看到的订单编号,即生成UUID
        order.setSn(UUID.randomUUID().toString());
        // 赋值userId
        // 以后秒杀业务会调用这个方法,userId属性会被赋值
        // 被远程调用时,当前方法无法获取登录用户信息,所以要判断一下order的userId是否为null
        if(order.getUserId() == null) {
            // 从SpringSecurity上下文中获取当前登录用户id
            order.setUserId(getUserId());
        }

        // 判断订单状态,如果为null,设置默认值为0
        if(order.getState() == null){
            order.setState(0);
        }

        // 为了保证下单时间gmt_order和数据创建gmt_create时间一致
        // 我们在代码中为它们赋相同的值
        LocalDateTime now=LocalDateTime.now();
        order.setGmtOrder(now);
        order.setGmtCreate(now);
        order.setGmtModified(now);

        // 后端代码对实际应付金额进行验算,以求和前端数据一致
        // 实际应付金额=原价-优惠+运费
        // 金钱相关数据使用BigDecimal类型,防止浮点偏移的误差,取消取值范围限制
        BigDecimal price=order.getAmountOfOriginalPrice();
        BigDecimal freight=order.getAmountOfFreight();
        BigDecimal discount=order.getAmountOfDiscount();
        BigDecimal actualPay=price.subtract(discount).add(freight);
        // 最后将计算完成的金额赋值到order对象
        order.setAmountOfActualPay(actualPay);

    }

    @Override
    public void updateOrderState(OrderStateUpdateDTO orderStateUpdateDTO) {

    }

    @Override
    public JsonPage<OrderListVO> listOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO) {
        return null;
    }

    @Override
    public OrderDetailVO getOrderDetail(Long id) {
        return null;
    }


    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity上下文中信息是不是null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录!");
        }
        // 从SpringSecurity上下文中获得用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo) authenticationToken.getCredentials();
        // 最终别忘了返回
        return csmallAuthenticationInfo;
    }
    // 业务逻辑层需求中,实际上只需要用户的id
    // 我们可以再编写一个方法,从用户对象中获取id
    public Long getUserId(){
        return getUserInfo().getId();
    }
}

开发新增订单的控制层

下面开始编写控制层

新建OmsOrderController

@RestController
@RequestMapping("/oms/order")
@Api(tags = "订单管理模块")
public class OmsOrderController {

    @Autowired
    private IOmsOrderService omsOrderService;

    @PostMapping("/add")
    @ApiOperation("执行新增订单的方法")
    @PreAuthorize("hasRole('user')")
    public JsonResult<OrderAddVO> addOrder(@Validated OrderAddDTO orderAddDTO){
        OrderAddVO orderAddVO=omsOrderService.addOrder(orderAddDTO);
        return JsonResult.ok(orderAddVO);
    }
}

启动Nacos\seata

依次启动服务Leaf\product\[passport]\order

访问10005执行新增

Seata使用常见错误

Seata在开始工作时,会将方法相关对象序列化后保存在对应数据库的undo_log表中

但是Seata我们序列化的方式支持很多中,常见的jackson格式序列化的情况下,不支持java对象LocalDataTime类型的序列化,序列化运行时会发送错误:

image-20220804152826852

如果见到这样的错误, 就是因为jackson不能序列化LocalDataTime导致的

要想解决,两方面思路,

1.将序列化过程中LocalDataTime类型转换为Date

2.将Seata序列化转换为kryo类型,但是需要在pom文件中添加依赖(我们的项目中有)

<!--解决seata序列化问题-->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-serializer-kryo</artifactId>
</dependency>

yml文件使用kryo序列化对象的配置

#seata服务端
seata:
  tx-service-group: csmall_group
  service:
    vgroup-mapping:
      csmall_group: default
    grouplist:
      default: ${my.server.addr}:8091
  client:
    undo:
      log-serialization: kryo

14.4.订单查询功能

在新增订单成功之后,用户会看到订单列表

可以按时间查询一段时间范围内的订单列表

我们默认查询当前时间一个月以内的所有订单信息

订单信息要包括oms_order和oms_order_item两个表的信息

所以是一个连表查询

确定关联查询语句

SELECT 
	oo.id,
	oo.sn,
	oo.user_id,
	oo.contact_name,
	oo.state,
	oo.amount_of_actual_pay,
	oo.gmt_order,
	oo.gmt_pay,
	oo.gmt_create,
	oo.gmt_modified,
	ooi.id ooi_id,
	ooi.order_id,
	ooi.sku_id,
	ooi.title,
	ooi.price,
	ooi.quantity
FROM oms_order oo
JOIN oms_order_item ooi ON oo.id=ooi.order_id
WHERE
	oo.user_id=1
AND
	oo.gmt_create > '2022-11-2'
AND 
	oo.gmt_create < now()
ORDER BY oo.gmt_modified DESC

开发查询订单的持久层

确定了sql语句之后,要在xml文件中使用

OmsOrderMapper添加方法

// 查询当前用户指定时间范围内的所有订单信息(关联订单项)
List<OrderListVO> selectOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO);

OmsOrderMapper.xml文件添加对应的内容

<!--   支持查询订单和订单项关联关系的resultMap   -->
<resultMap id="OrderListMap" type="cn.tedu.mall.pojo.order.vo.OrderListVO">
    <id column="id" property="id" />
    <result column="sn" property="sn" />
    <result column="user_id" property="userId" />
    <result column="contact_name" property="contactName" />
    <result column="state" property="state" />
    <result column="amount_of_actual_pay" property="amountOfActualPay" />
    <result column="gmt_order" property="gmtOrder" />
    <result column="gmt_pay" property="gmtPay" />
    <result column="gmt_create" property="gmtCreate" />
    <result column="gmt_modified" property="gmtModified" />

    <collection property="orderItems"
                ofType="cn.tedu.mall.pojo.order.vo.OrderItemListVO">
        <id     column="ooi_id" property="id" />
        <result column="order_id" property="orderId" />
        <result column="sku_id" property="skuId" />
        <result column="title" property="title" />
        <result column="price" property="price" />
        <result column="quantity" property="quantity" />
    </collection>
</resultMap>


<!--  查询当前用户指定时间范围内的所有订单信息(关联订单项) -->
<select id="selectOrdersBetweenTimes" resultMap="OrderListMap">
    SELECT
        oo.id,
        oo.sn,
        oo.user_id,
        oo.contact_name,
        oo.state,
        oo.amount_of_actual_pay,
        oo.gmt_order,
        oo.gmt_pay,
        oo.gmt_create,
        oo.gmt_modified,
        ooi.id ooi_id,
        ooi.order_id,
        ooi.sku_id,
        ooi.title,
        ooi.price,
        ooi.quantity
    FROM oms_order oo
    JOIN oms_order_item ooi ON oo.id=ooi.order_id
    WHERE
        oo.user_id=#{userId}
    AND
        oo.gmt_create &gt; #{startTime}
    AND
        oo.gmt_create &lt; #{endTime}
    ORDER BY oo.gmt_modified DESC
</select>

编写持久层

上次课完成了Mapper.xml文件的编写如下

<!--   支持查询订单和订单项关联关系的resultMap   -->
<resultMap id="OrderListMap" type="cn.tedu.mall.pojo.order.vo.OrderListVO">
    <id column="id" property="id" />
    <result column="sn" property="sn" />
    <result column="user_id" property="userId" />
    <result column="contact_name" property="contactName" />
    <result column="state" property="state" />
    <result column="amount_of_actual_pay" property="amountOfActualPay" />
    <result column="gmt_order" property="gmtOrder" />
    <result column="gmt_pay" property="gmtPay" />
    <result column="gmt_create" property="gmtCreate" />
    <result column="gmt_modified" property="gmtModified" />
    <!-- OrderListVO类型中包含订单项的集合,需要进行映射编写  -->
    <!--  包含集合的映射使用 collection 标签 -->
    <!--   
        property(必须): 指定要映射的集合类型的属性名称
        javaType(可选): 指定当前集合的类型,默认类型是List,如果匹配无需编写
        ofType(必须):   指定集合的泛型类型
    -->
    <collection property="orderItems"
                ofType="cn.tedu.mall.pojo.order.vo.OrderItemListVO">
        <id     column="ooi_id" property="id" />
        <result column="order_id" property="orderId" />
        <result column="sku_id" property="skuId" />
        <result column="title" property="title" />
        <result column="price" property="price" />
        <result column="quantity" property="quantity" />
    </collection>
</resultMap>


<!--  查询当前用户指定时间范围内的所有订单信息(关联订单项) -->
<select id="selectOrdersBetweenTimes" resultMap="OrderListMap">
    SELECT
        oo.id,
        oo.sn,
        oo.user_id,
        oo.contact_name,
        oo.state,
        oo.amount_of_actual_pay,
        oo.gmt_order,
        oo.gmt_pay,
        oo.gmt_create,
        oo.gmt_modified,
        ooi.id ooi_id,
        ooi.order_id,
        ooi.sku_id,
        ooi.title,
        ooi.price,
        ooi.quantity
    FROM oms_order oo
    JOIN oms_order_item ooi ON oo.id=ooi.order_id
    WHERE
        oo.user_id=#{userId}
    AND
        oo.gmt_create &gt; #{startTime}
    AND
        oo.gmt_create &lt; #{endTime}
    ORDER BY oo.gmt_modified DESC
</select>

开发查询订单业务逻辑层

OmsOrderServiceImpl实现类添加实现方法

// 分页和查询当前登录用户,指定时间范围内所有订单
// 默认查询最近一个月订单的信息,查询的返回值OrderListVO,是包含订单和订单中商品信息的对象
// 查询OrderListVO的持久层是查询多张表返回值的特殊关联查询
@Override
public JsonPage<OrderListVO> listOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO) {
    // 方法一开始,需要先确定查询的时间范围,默认是一个月内
    // 要判断orderListTimeDTO参数中开始时间和结束时间是否有null值
    validateTimeAndLoadTime(orderListTimeDTO);
    // 将userId赋值到参数中
    orderListTimeDTO.setUserId(getUserId());
    // 设置分页条件
    PageHelper.startPage(orderListTimeDTO.getPage(),
                            orderListTimeDTO.getPageSize());
    List<OrderListVO> list=omsOrderMapper
                            .selectOrdersBetweenTimes(orderListTimeDTO);
    // 最后返回JsonPage
    return JsonPage.restPage(new PageInfo<>(list));
}

private void validateTimeAndLoadTime(OrderListTimeDTO orderListTimeDTO) {
    // 获取参数对象中的开始时间和结束时间
    LocalDateTime start=orderListTimeDTO.getStartTime();
    LocalDateTime end= orderListTimeDTO.getEndTime();
    // 为了使业务更简单,我们设计start或end任意一个值为null,就查询最近一个月订单
    if(start == null  ||  end == null){
        // start设置为一个月前的时间
        start=LocalDateTime.now().minusMonths(1);
        // end设置为现在即可
        end=LocalDateTime.now();
        // 将确定好值的时间赋到参数中
        orderListTimeDTO.setStartTime(start);
        orderListTimeDTO.setEndTime(end);
    }else{
        // 如果start和end都非null
        // 就要判断,如果end小于了start就要抛出异常
        if(end.toInstant(ZoneOffset.of("+8")).toEpochMilli()<
                start.toInstant(ZoneOffset.of("+8")).toEpochMilli()){
            // 如果结束时间小于开始时间,就要抛出异常
            throw new CoolSharkServiceException(ResponseCode.BAD_REQUEST,
                    "结束时间应大于起始时间");
        }
    }
}

开发查询订单的控制层代码

OmsOrderController

@GetMapping("/list")
@ApiOperation("分页查询当前用户指定时间内的订单")
@PreAuthorize("hasRole('user')")
public JsonResult<JsonPage<OrderListVO>> listUserOrder(
                        OrderListTimeDTO orderListTimeDTO){
    JsonPage<OrderListVO> jsonPage=
            omsOrderService.listOrdersBetweenTimes(orderListTimeDTO);
    return JsonResult.ok(jsonPage);
}

启动Nacos\Seata

如果没有登录的话启动sso(passport)做jwt复制

如果有可用的jwt

直接启动order即可

订单的新增和订单的查询一定要多练习几次

面试时一定要会讲述业务流程

14.5.开发更新订单状态的功能

订单的状态码

我们电商上面订单的状态修改是非常普通的业务

随着商品的购买流程,订单的状态有

状态:

0=未支付

1=已关闭(超时未支付)

2=已取消

3=已支付

4=已签收

5=已拒收

6=退款处理中

7=已退款

开发更新订单状态的持久层

修改订单状态就是根据订单id修改订单的state

我们随着业务的发展,订单可能需要更多修改的需求

订单的列(字段)比较多,如果每个字段修改,都需要编写一个方法的话,那么方法的数量会非常多

如果我们编写一个方法,能够接收订单对象的实体类参数(OmsOrder)

我们要实现可以根据OmsOrder对象的实际数据来实现动态的修改要修改的字段

Mybatis中可以通过编写动态修改sql语句完成这个需求

OmsOrderMapper接口添加方法

// 利用动态sql语句,实现对订单字段的修改
// 参数是OmsOrder类型,必须包含id属性值,id属性值不可修改
int updateOrderById(OmsOrder order);

OmsOrderMapper.xml编写sql

<!--
    利用动态sql语句,实现对订单字段的修改
    参数是OmsOrder类型,必须包含id属性值,id属性值不可修改
 -->
<!--
    OmsOrder对象动态修改数据库中数据的sql语句
    Mybatis框架根据OmsOrder对象属性的非空状态,生成sql语句,使用<set>标签
    1.在<set>表单的位置生成set关键字
    2.在<set></set>标签之间,动态生成的sql语句
        如果生成的内容最后一个字符是","就将它删除
-->
<update id="updateOrderById">
    update oms_order
    <set>
        <if test="contactName!=null">
            contact_name=#{contactName},
        </if>
        <if test="mobilePhone!=null">
            mobile_phone=#{mobilePhone},
        </if>
        <if test="telephone!=null">
            telephone=#{telephone},
        </if>
        <if test="streetCode!=null">
            street_code=#{streetCode},
        </if>
        <if test="streetName!=null">
            street_name=#{streetName},
        </if>
        <if test="detailedAddress!=null">
            detailed_address=#{detailedAddress},
        </if>
        <if test="tag!=null">
            tag=#{tag},
        </if>
        <if test="paymentType!=null">
            payment_type=#{paymentType},
        </if>
        <if test="state!=null">
            state=#{state},
        </if>
        <if test="rewardPoint!=null">
            reward_point=#{rewardPoint},
        </if>
        <if test="amountOfOriginalPrice!=null">
            amount_of_original_price=#{amountOfOriginalPrice},
        </if>
        <if test="amountOfFreight!=null">
            amount_of_freight=#{amountOfFreight},
        </if>
        <if test="amountOfDiscount!=null">
            amount_of_discount=#{amountOfDiscount},
        </if>
        <if test="amountOfActualPay!=null">
            amount_of_actual_pay=#{amountOfActualPay},
        </if>
        <if test="gmtPay!=null">
            gmt_pay=#{gmtPay},
        </if>
    </set>
    where
        id=#{id}
</update>

开发修改订单状态的业务逻辑层

OmsOrderServiceImpl

// 根据订单id,修改订单状态
@Override
public void updateOrderState(OrderStateUpdateDTO orderStateUpdateDTO) {
    // 实例化OmsOrder
    OmsOrder order=new OmsOrder();
    // orderStateUpdateDTO中包含id和state属性,赋值到order
    BeanUtils.copyProperties(orderStateUpdateDTO,order);
    // order中包含了id和state属性,可以执行修改操作
    omsOrderMapper.updateOrderById(order);
}

开发修改订单状态的控制层

OmsOrderController

@PostMapping("/update/state")
@ApiOperation("修改订单状态的方法")
@PreAuthorize("hasRole('user')")
public JsonResult updateOrderState(
        @Validated OrderStateUpdateDTO orderStateUpdateDTO){
    omsOrderService.updateOrderState(orderStateUpdateDTO);
    return JsonResult.ok("修改完成");
}

重启order模块

测试时根据实际数据库订单id,修改knife4j的数据然后再运行

运行后查看数据库中订单状态列是否修改

15.静态资源服务器

什么是静态资源服务器

我们无论做什么项目,都会有一些页面中需要显示的静态资源,例如图片,视频,文档等

我们一般会创建一个单独的项目,这个项目中保存静态资源

其他项目可以通过我们保存资源的路径访问

image-20220803103916106

使用静态资源服务器的原因是静态资源服务器可以将项目需要的所有图片统一管理起来

当其他模块需要图片时,可以从数据库中直接获得访问静态资源的路径即可

方便管理所有静态资源

16.搜索功能

16.1.Elasticsearch加载数据

我们要想完成高效的搜索任务,需要ES的支持

因为数据库的模糊查询效率太低了

我们在前端页面中完成的搜索是从ES中搜索数据

这样就要求,我们在查询之前,需要先将商品信息(spu)保存到ES中

一开始我们使用最原始的办法:从数据库查询出数据之后新增到ES中

16.2.确认实体类

搜索功能编写在mall-search模块中

它使用的实体类在cn.tedu.mall.pojo.search.eneity包下SpuForElastic

这个类有四个字段是具备分词功能的

所以支持我们使用这4个字段进行查询

/**
 * SPU名称
 */
@Field(name = "name",type = FieldType.Text,
        analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="SPU名称")
private String name;

//.....

/**
     * 标题
     */
@Field(name="title",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="标题")
private String title;

/**
     * 简介
     */
@Field(name="description",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="简介")
private String description;

//.....

/**
 * 类别名称(冗余)
 */
@Field(name="category_name",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="类别名称(冗余)")
private String categoryName;

//.....

16.3.开发ES的持久层

我们仍然使用SpringDataElasticsearch框架来操作ES

按照SpringData的规范,我们创建包repository

在这个包中创建SpuForElasticRepository接口,代码如下

//SpuForElastic实体类操作ES的持久层接口
//需要基础SpringData提供的一个父接口,父接口中提供了对应当前实体类基本的增删改查方法
@Repository
public interface SpuForElasticRepository extends 
                                ElasticsearchRepository<SpuForElastic,Long> {
}

这个接口提供了批量新增到ES数据的方法

但是要想获得数据库中的所有pms_spu表的数据,必须连接数据库查询这些数据

但是search模块是负责管理ES的,所以需要Dubbo调用Product模块获取所有数据

16.3.product模块提供的查询功能

我们需要使用Dubbo调用product的业务逻辑层获得数据库pms_spu表的数据

经过观察发现业务逻辑逻辑层调用ForFrontSpuServiceImpl类中

具有一个getSpuByPage的方法

他分页查询所有spu信息

@Override
public JsonPage<Spu> getSpuByPage(Integer pageNum, Integer pageSize) {
    PageHelper.startPage(pageNum,pageSize);
    List<Spu> list=spuMapper.findAllList();
    return JsonPage.restPage(new PageInfo<>(list));
}

16.4.search模块执行加载

mall-search-webapi模块创建service.impl包

包中创建SearchServiceImpl类,用于将数据库中的数据加载到ES中

代码如下

@Service
@Slf4j
public class SearchServiceImpl implements ISearchService {

    // dubbo调用product模块分页查询所有spu的方法
    @DubboReference
    private IForFrontSpuService dubboSpuService;
    @Autowired
    private SpuForElasticRepository spuRepository;

    @Override
    public void loadSpuByPage() {
        // 这个方法需要循环调用分页查询所有spu数据的方法,直到将所有数据都查出
        // 每次循环的操作就是将当前从数据库中查询的数据新增到ES
        // 循环条件应该是总页数,但是总页数需要查询一次之后才能得知,所以我们使用do-while循环
        int i=1;     // 循环变量,从1开始,因为可以直接当页码使用
        int page;  // 总页数,也是循环条件,是循环操作运行一次之后会被赋值,这里赋默认值或不赋值皆可

        do{
            // dubbo调用查询当前页的spu数据
            JsonPage<Spu> spus=dubboSpuService.getSpuByPage(i,2);
            // 查询出的List是Spu类型,不能直接新增到ES中,需要转换为SpuForElastic
            List<SpuForElastic> esSpus=new ArrayList<>();
            // 遍历分页查询出的数据库的集合
            for(Spu spu : spus.getList()){
                // 下面开始转换,实例化新实体类,并将同名属性赋值
                SpuForElastic esSpu=new SpuForElastic();
                BeanUtils.copyProperties(spu,esSpu);
                // 将esSpu新增到集合中
                esSpus.add(esSpu);
            }
            // esSpus集合中已经包含了本页所有数据,利用提供的批量新增完成新增到ES的操作
            spuRepository.saveAll(esSpus);
            log.info("成功加载了第{}页数据",i);
            // 为下次循环做自增
            i++;
            // 为page(总页数)赋值
            page=spus.getTotalPage();
        }while (i<=page);
    }

    @Override
    public JsonPage<SpuForElastic> search(
    					String keyword, Integer page, Integer pageSize) {
        return null;
    }
}

创建测试类运行即可

// 下面注解必须加!!!!
@SpringBootTest
public class SpuElasticTest {

    @Autowired
    private ISearchService searchService;
    @Test
    void loadData(){
        searchService.loadSpuByPage();
        System.out.println("ok");
    }


}

运行测试前保证

Nacos\Seata\ES启动

启动product模块

运行测试,没有报错即可

16.5.验证ES中的数据

我们再通过连接ES来进行全查

检验上面执行的加载工作是否达到效果

仍然在测试类中,在编写一个方法,使用SpringData提供的全查方法查询后遍历输出

检查输出内容,代码如下

@Autowired
private SpuForElasticRepository spuRepository;
@Test
void showData(){
    Iterable<SpuForElastic> spus=spuRepository.findAll();
    spus.forEach(spu -> System.out.println(spu));
}

16.6.搜索功能的实现

电商网站一定会有按用户输入的关键字进行搜索的功能

这样的搜索都是搜索ES查询到的结果

上面章节中,我们已经将所有spu信息保存到了ES中

下面通过查询逻辑将搜索结果显示出来

编写SpringData自定义查询

如果我们按照关键字"手机"进行搜索

可以在Repository接口中编写自定义方法搜索title字段包含"手机"的数据

@Repository
public interface SpuForElasticRepository extends
                                ElasticsearchRepository<SpuForElastic,Long> {
    
    // 查询title字段包含指定关键字(分词)的spu数据
    Iterable<SpuForElastic> querySpuForElasticsByTitleMatches(String title);
}

上面的查询可以通过测试类测试

@Test
void showTitle(){
    Iterable<SpuForElastic> spus=spuRepository
            .querySpuForElasticsByTitleMatches("手机");
    spus.forEach(spu -> System.out.println(spu));
}

尤其需要关注ES是否已经启动

不需要其它项目的支持,直接运行测试即可


我们业务中需要4个字段的条件查询,是可以通过方法名称的编写实现的

SpringData也支持我们在代码中编写查询语句,以避免过长的方法名

@Query("{\n" +
        "    \"bool\": {\n" +
        "      \"should\": [\n" +
        "        { \"match\": { \"name\": \"?0\"}},\n" +
        "        { \"match\": { \"title\": \"?0\"}},\n" +
        "        { \"match\": { \"description\": \"?0\"}},\n" +
        "        { \"match\": { \"category_name\": \"?0\"}}\n" +
        "        ]\n" +
        "     }\n" +
        "}")
// 上面指定了查询语句的情况下,自定义方法的方法名就可以随意起名了,参数对应查询语句中的?0
Iterable<SpuForElastic> querySearch(String keyword);

测试代码

@Test
void showQuery(){
    // 自定义查询4个字段包含指定关键字的方法
    Iterable<SpuForElastic> spus=spuRepository.querySearch("手机");
    spus.forEach(spu -> System.out.println(spu));
}

实际运行查询的逻辑是需要分页的

所以要按照SpringData支持的分页查询格式修改上面的查询代码

@Query("{\n" +
        "    \"bool\": {\n" +
        "      \"should\": [\n" +
        "        { \"match\": { \"name\": \"?0\"}},\n" +
        "        { \"match\": { \"title\": \"?0\"}},\n" +
        "        { \"match\": { \"description\": \"?0\"}},\n" +
        "        { \"match\": { \"category_name\": \"?0\"}}\n" +
        "        ]\n" +
        "     }\n" +
        "}")
// 上面指定了查询语句的情况下,自定义方法的方法名就可以随意起名了,参数对应查询语句中的?0
//↓↓↓                                           ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
Page<SpuForElastic> querySearch(String keyword, Pageable pageable);

修改了方法的定义,原有的调用会报错,注释掉测试中的调用代码即可!

@Test
void showQuery(){
    // 自定义查询4个字段包含指定关键字的方法
    //Iterable<SpuForElastic> spus=spuRepository.querySearch("手机");
    Page spus=spuRepository.querySearch("手机",
                                    PageRequest.of(1,2));
    spus.forEach(spu -> System.out.println(spu));
}

在实际开发中

我们数据库中的数据和Elasticsearch中的数据还存在同步问题

为了保持数据库中的数据和Elasticsearch中的数据一致

我们可以使用下面的办法

1.在所有对spu表进行增删改的操作代码运行后,也对ES中的数据进行相同的操作

​ 但是会有比较多的代码要编写,而且有比较明显的事务处理问题

2.实际上业界使用Elasticsearch有一个组合叫ELK,其中L(logstash)可以实现自动同步数据库和ES的信息

后面学习过程中,我们会在Linux虚拟机的学习中使用它

开发搜索功能的业务逻辑层

SearchServiceImpl类添加实现方法如下

// 根据用户指定的关键字分页查询ES中商品信息
@Override
public JsonPage<SpuForElastic> search(
                    String keyword, Integer page, Integer pageSize) {
    // 根据参数中的分页数据,执行分页查询,注意SpringData分页页码从0开始
    Page<SpuForElastic> spus=spuRepository.querySearch(
                                     keyword, PageRequest.of(page-1,pageSize));
    // 分页查询调用结束返回Page类型对象,我们要求返回JsonPage类型做统一分页查询的返回
    JsonPage<SpuForElastic> jsonPage=new JsonPage<>();
    // 赋值分页信息
    jsonPage.setPage(page);
    jsonPage.setPageSize(pageSize);
    jsonPage.setTotalPage(spus.getTotalPages());
    jsonPage.setTotal(spus.getTotalElements());
    // 赋值分页数据
    jsonPage.setList(spus.getContent());
    // 最后返回!!!
    return jsonPage;
}

开发控制层代码

创建controller包

包中创建SearchController编写搜索方法,代码如下

@RestController
@RequestMapping("/search")
@Api(tags = "搜索模块")
public class SearchController {

    @Autowired
    private ISearchService searchService;

    // 当前搜索模块就一个搜索功能,所以路径可以设置的尽量简短
    // localhost:10008/search
    // @GetMapping注解后什么都不写,表示直接使用类上声明的路径
    @GetMapping
    @ApiOperation("根据用户输入的关键字分页搜索商品信息")
    @ApiImplicitParams({
            @ApiImplicitParam(value = "搜索关键字",name="keyword", example = "手机"),
            @ApiImplicitParam(value = "页码",name="page", example = "1"),
            @ApiImplicitParam(value = "每页条数",name="pageSize", example = "2")
    })
    public JsonResult<JsonPage<SpuForElastic>> searchByKeyword(
            String keyword, Integer page,Integer pageSize){
        JsonPage<SpuForElastic> jsonPage=
                searchService.search(keyword, page, pageSize);
        return JsonResult.ok(jsonPage);
    }

}

保证Nacos\seata\ES启动

因为当前search项目过滤器解析JWT所以需要登录才能访问

启动search模块

建议启动passport模块去进行登录获得jwt

复制JWT后,粘贴到10008模块的全局参数,再测试运行search模块

测试路径http://localhost:10008/doc.html

17.Quartz

17.1.什么是Quartz

quartz:石英钟的意思

是一个当今市面上流行的高效的任务调度管理工具

所谓"调度"就是制定好的什么时间做什么事情的计划

由OpenSymphony开源组织开发

Symphony:交响乐

是java编写的,我们使用时需要导入依赖即可

17.2.为什么需要Quartz

所谓"调度"就是制定好的什么时间做什么事情的计划

我们使用过的最简单的调度方法就是Timer

但是Timer的调度功能过于单一,只能是指定时间的延时调用和周期运行

而Quartz可以更详细的指定时间,进行计划调用

17.3.Quartz核心组件

调度器:Scheduler

任务:job

触发器:Trigger

调度器来配置\计划什么时间触发什么任务

简单来说就是调度器规定什么时间做什么事情

  • job(工作\任务):Quartz 实现过程中是一个接口,接口中有一个方法execute(执行的意思)

​ 我们创建一个类,实现这个接口,在方法中编写要进行的操作(执行具体任务)

​ 我们还需要一个JobDetail的类型的对象,Quartz每次执行job时

​ 会实例化job类型对象,去调用这个方法,JobDetail是用来描述Job实现类的静态信息,

​ 比如任务运行时在Quartz中的名称

  • Trigger(触发器):能够描述触发指定job的规则,分为简单触发和复杂触发

    简单触发可以使用SimplTrigger实现类.功能类似timer

    复杂触发可以使用CronTrigger实现类,内部利用cron表达式描述各种复杂的时间调度计划

  • Scheduler(调度器):一个可以规定哪个触发器绑定哪个job的容器

    在调度器中保存全部的Quartz 保存的任务

    SpringBoot框架下,添加Quartz依赖后,调度器由SpringBoot管理,我们不需要编写

17.4.Cron表达式

Cron表达式是能够制定触发时间的一个格式

image-20220805164824675

表示2022年12月7日凌晨4点触发的cron表达式

0 0 4 ? 12 1L 2022

  • * 表示任何值,如果在分的字段上编写*,表示每分钟都会触发

  • , 是个分割符如果秒字段我想20秒和40秒时触发两次就写 20,40

  • - 表示一个区间 秒字段5-10 表示 5,6,7,8,9,10

  • / 表示递增触发 秒字段 5/10表示5秒开始每隔10秒触发一次

    日字段编写1/3表示从每月1日起每隔3天触发一次

  • ? 表示不确定值, 因为我们在定日期时,一般确定日期就不确定是周几,相反确定周几时就不确定日期

  • L 表示last最后的意思,我们可以设置当月的最后一天,就会在日字段用L表示,

    周字段使用L表示本月的最后一个周几,一般会和1-7的数字组合

    例如6L表示本月的最后一个周五

  • W (work)表示最近的工作日(单纯的周一到周五) 如果日字段编写15W表示

    每月15日最近的工作日触发,如果15日是周六就14日触发,如果15日是周日就16日触发

    LW通常一起使用,表示本月的最后一个工作日

  • # 表示第几个,只能使用在周字段上 6#3表示每月的第三个周五

    如果#后面数字写大了,是一个不存在的日期,那就不运行了

    适合设计在母亲节或父亲节这样的日期运行

推荐一个http://cron.ciding.cc/

每年的母亲节(5月份第三个周日)早上9点触发

0 0 9 ? 5 1#3

每个月10日发工资如果10日是休息日,就在最近的工作日发

0 0 9 10W * ?

每年双11运行

0 0 0 11 11 ?

17.5.SpringBoot使用Quartz

SpringBoot框架下使用Quartz格式还是非常固定的

我们选用之前学习微服务的项目csmall减少对大项目的影响

首先添加依赖

我们选择csmall-stock-webapi模块pom文件

<!--   SpringBoot 整合 Quartz 依赖   -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

先编写要执行的任务

当前项目模块中创建quartz包

包中创建一个QuartzJob的类,实现Job接口

代码如下

@Slf4j
public class QuartzJob implements Job {
    (一组是两个方法,多个任务可写多组)
    // 这个方法就是当前job要定时执行的任务代码
    @Override
    public void execute(JobExecutionContext jobExecutionContext) 
                                                throws JobExecutionException {
        // 一个简单的任务演示,输出当前系统时间,使用sout或log皆可
        log.info("-------------------"+ LocalDateTime.now() +"--------------------");
    }
}

上面编写的是Job接口的实现类,要想运行还需要将它封装为JobDetail对象保存在Spring容器中

还有要创建一个Trigger设置要运行的时机,也保存到Spring容器中

在quartz包下,再创建一个QuartzConfig类,其中编写它们的调度绑定关系

这个格式是固定的,后面再需要绑定,直接套用即可

// QuartzConfig类来绑定调用的方法和触发的关系
// 这个触发实际上会由Spring容器中的Scheduler对象调度
// 凡是Spring容器内容的配置都需要添加@Configuration注解
@Configuration
public class QuartzConfig {
    // Quartz任务调度生效条件是将JobDetail和Trigger对象保存到Spring容器中
    // JobDetail对象封装任务内容,Trigger对象封装触发时机

    // 利用@Bean注解,将JobDetail和Trigger对象保存到Spring容器中
    @Bean
    public JobDetail showTime(){
        // JobDetail对象中配置时要将欲触发的Job接口实现类配置在框架指定的方法中
        // JobBuilder.newJob方法是可以创建JobDetail对象的,方法参数就是Job接口实现类的反射即可
        return JobBuilder.newJob(QuartzJob.class)
                // 需要给当前任务起名,不要和其他任务重名
                .withIdentity("dateTime")
                // 默认情况下JobDetail生成后,如果没有触发器绑定会自动移除
                // 设置storeDurably()方法后,JobDetail生成后即使没有被绑定,也不会被移除了
                .storeDurably()
                .build();
    }

    // 下面开始编写触发器的配置,目标是绑定上面JobDetail,实现在指定时间触发
    @Bean
    public Trigger showTimeTrigger(){
        // 声明Cron表达式,定义触发时间
        CronScheduleBuilder cron=
                CronScheduleBuilder.cronSchedule("0 50 10 6 12 ?");
        return TriggerBuilder.newTrigger()
                // 绑定要运行的JobDetail对象
                .forJob(showTime())
                // 当前触发器也要起名字,名字也不要重复
                .withIdentity("dateTimeTrigger")
                // 绑定cron表达式
                .withSchedule(cron)
                .build();
    }



}

Nacos\Seata需要启动

其他服务和软件都可以关闭

然后启动csmall-stock-webapi模块

观察控制台输出

18.秒杀业务准备

18.1.准备工作概述

学习秒杀的目的是让同学们了解高并发在微服务项目中的处理流程

指定一些基本的高并发处理标准动作

酷鲨商城定时秒杀业务就是一个模拟高并发的业务场景

每秒请求数8000

并发500~800

网站在线用户20000(到30000)

日活跃用户50000(到80000)

学习完秒杀业务,我们能具备处理一般高并发业务的基本逻辑

18.2.开发查询秒杀商品列表功能

秒杀模块是mall-seckill,这个模块操作的数据库是mall-seckill

数据库中包含秒杀spu信息(seckill_spu)和秒杀sku信息(seckill_sku)以及秒杀成功记录(success)

首先我们先将秒杀列表的功能开发

开发持久层

mall-seckill-webapi项目

创建mapper包,创建SeckillSpuMapper,代码如下

@Repository
public interface SeckillSpuMapper {

    // 查询秒杀商品列表的方法
    List<SeckillSpu> findSeckillSpus();
}

SeckillSpuMapper.xml编写对应查询

<!--  秒杀spu表的sql语句字段片段  -->
<sql id="SimpleFields" >
    <if test="true">
        id,
        spu_id,
        list_price,
        start_time,
        end_time,
        gmt_create,
        gmt_modified
    </if>
</sql>
<!--   查询秒杀商品列表的方法  -->
<select id="findSeckillSpus" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
</select>

开发业务逻辑层

创建包service.impl

包中创建SeckillSpuServiceImpl实现ISeckillSpuService

代码如下

@Service
@Slf4j
public class SeckillSpuServiceImpl implements ISeckillSpuService {

    // 装配查询秒杀表信息的Mapper
    @Autowired
    private SeckillSpuMapper seckillSpuMapper;

    // 查询秒杀列表商品信息的方法中,返回值为SeckillSpuVO,其中包含常规spu信息和秒杀spu信息
    // 所以我们需要dubbo调用product模块,根据spuId查询出spu的常规信息
    @DubboReference
    private IForSeckillSpuService dubboSeckillSpuService;

    // 分页查询秒杀商品信息
    // 注意:返回值集合泛型SeckillSpuVO,既包含常规Spu信息又包含秒杀Spu信息
    @Override
    public JsonPage<SeckillSpuVO> listSeckillSpus(Integer page, Integer pageSize) {
        // 设置分页条件
        PageHelper.startPage(page,pageSize);
        // 执行查询秒杀表中所有商品信息的方法(会自动分页查询)
        List<SeckillSpu> seckillSpus=seckillSpuMapper.findSeckillSpus();
        // 实例化一个SeckillSpuVO泛型的集合,以备后续返回
        List<SeckillSpuVO> seckillSpuVOs=new ArrayList<>();
        // 遍历seckillSpus(没有常规信息的集合)
        for(SeckillSpu seckillSpu : seckillSpus){
            // 获得当前对象的spuId
            Long spuId=seckillSpu.getSpuId();
            // 获得了spuId,利用Dubbo查询这个spu的常规信息
            SpuStandardVO standardVO = dubboSeckillSpuService.getSpuById(spuId);
            // 秒杀信息在seckillSpu对象中,常规信息在standardVO对象中
            // 下面将两方信息都赋值到SeckillSpuVO中
            SeckillSpuVO seckillSpuVO=new SeckillSpuVO();
            // 将常规信息中同名属性赋值到seckillSpuVO
            BeanUtils.copyProperties(standardVO,seckillSpuVO);
            // 秒杀属性单独赋值
            seckillSpuVO.setSeckillListPrice(seckillSpu.getListPrice());
            seckillSpuVO.setStartTime(seckillSpu.getStartTime());
            seckillSpuVO.setEndTime(seckillSpu.getEndTime());
            // 此处seckillSpuVO就已经包含了常规信息和秒杀信息
            // 添加到集合中
            seckillSpuVOs.add(seckillSpuVO);
        }
        // 最后别忘了返回
        return JsonPage.restPage(new PageInfo<>(seckillSpuVOs));
    }

    @Override
    public SeckillSpuVO getSeckillSpu(Long spuId) {
        return null;
    }

    @Override
    public SeckillSpuDetailSimpleVO getSeckillSpuDetail(Long spuId) {
        return null;
    }
}

开发秒杀商品列表的控制层

上次课完成了查询秒杀商品列表的业务逻辑层

查询出了既包含常规信息,又包含秒杀信息的商品集合

下面在控制层中调用返回

创建controller包

创建SeckillSpuController类

代码如下

@RestController
@RequestMapping("/seckill/spu")
@Api(tags = "秒杀spu模块")
public class SeckillSpuController {

    @Autowired
    private ISeckillSpuService seckillSpuService;

    @GetMapping("/list")
    @ApiOperation("分页查询秒杀spu商品列表")
    @ApiImplicitParams({
            @ApiImplicitParam(value = "页码",name="page",example = "1"),
            @ApiImplicitParam(value = "每页条数",name="pageSize",example = "10")
    })
    public JsonResult<JsonPage<SeckillSpuVO>> listSeckillSpus(
            Integer page, Integer pageSize){
        JsonPage<SeckillSpuVO> jsonPage=seckillSpuService
                .listSeckillSpus(page,pageSize);
        return JsonResult.ok(jsonPage);
    }

}

启动服务

Nacos\Seata\Redis

启动我们的项目

product\passport\seckill

测试端口10007

18.3.开发根据SpuId查询秒杀Sku列表信息

我们将秒杀的商品Spu列表查询出来

当用户选择一个商品时

我们要将这个商品的sku也查询出来

也就是根据SpuId查询Sku的列表

创建SeckillSkuMapper

@Repository
public interface SeckillSkuMapper {
    // 根据spuId查询秒杀sku列表
    List<SeckillSku> findSeckillSkusBySpuId(Long spuId);
}

SeckillSkuMapper.xml文件添加内容

<sql id="SimpleFields">
    <if test="true">
        id,
        sku_id,
        spu_id,
        seckill_stock,
        seckill_price,
        gmt_create,
        gmt_modified,
        seckill_limit
    </if>
</sql>
<!--  根据spuId查询秒杀sku列表  -->
<select id="findSeckillSkusBySpuId" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_sku
    where
        spu_id=#{spuId}
</select>

18.4.根据当前时间查询正在进行秒杀的商品

根据给定时间查询出正在进行秒杀的商品列表

在秒杀过程中,一定会将当前时间正在进行秒杀商品查询出来的

首先保证数据库中的seckill_spu表的数据正在秒杀时间段(检查数据,如果不在秒杀时间段,将结束时间后移如2024年)

SeckillSpuMapper添加方法

// 根据指定时间,查询出正在进行秒杀的商品列表
List<SeckillSpu> findSeckillSpusByTime(LocalDateTime time);

SeckillSpuMapper.xml

<!-- 根据指定时间,查询出正在进行秒杀的商品列表 -->
<select id="findSeckillSpusByTime" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
    where
        start_time &lt; #{time}
    and
        end_time &gt; #{time}
</select>

18.5.根据SpuId查询秒杀商品信息

SeckillSpuMapper接口添加方法

// 根据spuId查询秒杀spu信息
SeckillSpu findSeckillSpuById(Long spuId);

SeckillSpuMapper.xml添加内容

<!--  根据spuId查询秒杀spu信息  -->
<select id="findSeckillSpuById" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
    where
        spu_id=#{spuId}
</select>

18.6.查询所有秒杀商品的SpuId

这个查询需要是为了后面布隆过滤器加载数据库中包含的所有SpuId时使用

因为布隆过滤器的特性,只需要查询出所有商品的spu_id即可

SeckillSpuMapper接口添加方法

// 布隆过滤器用:查询获得所有秒杀spu商品的spuId数组
Long[] findAllSeckillSpuIds();

SeckillSpuMapper.xml添加内容

<!-- 布隆过滤器用:查询获得所有秒杀spu商品的spuId数组 -->
<!--                                    ↓↓↓↓   -->
<select id="findAllSeckillSpuIds" resultType="long">
    select spu_id from seckill_spu
</select>

以上几个数据库操作都是为了缓存预热准备的!

18.7.缓存预热思路

在即将发生高并发业务之前,我们将一些高并发业务中需要的数据保存到Redis中,这种操作,就是"缓存预热",这样发生高并发时,这些数据就可以直接从Redis中获得,无需查询数据库了

我们要利用Quartz定时的将每个批次的秒杀商品,预热到Redis

例如每天的12:00 14:00 16:00 18:00进行秒杀

那么就在 11:55 13:55 15:55 17:55 进行预热

我们预热的内容有

  1. 我们预热的内容是将参与秒杀商品的sku查询出来,根据skuid将该商品的库存保存在Redis中

    还要注意为了预防雪崩,在向Redis保存数据时,都应该添加随机数

  2. (待完善).在秒杀开始前,生成布隆过滤器,访问时先判断布隆过滤器,如果判断商品存在,再继续访问

  3. 在秒杀开始之前,生成每个商品对应的随机码,保存在Redis中,随机码可以绑定给有Spu,保存在前端页面,用户提交时,验证随机码的正确性,只有正确的随机码才能购买商品

18.8.设置定时任务

将库存和随机码保存到Redis

1.创建Job接口实现类

2.创建配置类,配置JobDetail和Trigger

在seckill包下创建timer.job包

在seckill包下创建timer.config包

首先我们编写缓存预热的操作,在job包下创建类SeckillInitialJob

Redis操作数据是单线程的特性

@Slf4j
public class SeckillInitialJob implements Job {

    // 查询sku信息的mapper
    @Autowired
    private SeckillSkuMapper skuMapper;
    // 查询spu相关信息的mapper
    @Autowired
    private SeckillSpuMapper spuMapper;
    // 操作Redis的对象
    @Autowired
    private RedisTemplate redisTemplate;

    /*
    RedisTemplate对象在保存数据到Redis时,会将数据先序列化之后再保存
    这样做,对java对象或类似的数据在Redis中的读写效率高,缺点是不能在redis中修改这个数据
    现在我们要保存的是秒杀sku的库存数,如果这个数也用RedisTemplate保存,也会有上面的问题
    容易在高并发的情况下,由于线程安全问题导致"超卖"
    解决办法就是我们需要创建一个能够直接在Redis中修改数据的对象,避免线程安全问题防止"超卖"
    SpringDataRedis提供了StringRedisTemplate类型,它是可以直接操作redis中字符串的
    使用StringRedisTemplate向Redis保存数据,直接存字符串值,没有序列化过程
    而且它支持java中直接发送指令修改数值类型的内容,所以适合保存库存数
    这样就避免了java代码中对库存数修改带来的线程安全问题
    最后结合Redis操作数据是单线程的特性,避免线程安全问题,防止超卖
     */
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 当前方法是执行缓存预热工作的
        // 本方法运行的时机是秒杀开始前5分钟,所以要获取5分钟后进行秒杀的所有商品
        LocalDateTime time=LocalDateTime.now().plusMinutes(5);
        // 查询这个时间要进行秒杀的所有商品
        List<SeckillSpu> seckillSpus=spuMapper.findSeckillSpusByTime(time);
        // 遍历本批次秒杀商品的集合
        for(SeckillSpu spu : seckillSpus){
            // 第一个目标是将本批次所有spu对应的sku库存数预热到redis
            // 先根据spu对象的spuId去查询对应的sku列表
            List<SeckillSku> seckillSkus=skuMapper
                                .findSeckillSkusBySpuId(spu.getSpuId());
            // 遍历seckillSkus集合,获得其中的元素,以及元素中的库存信息
            for(SeckillSku sku : seckillSkus){
                log.info("开始将{}号sku的商品库存数预热到redis",sku.getSkuId());
                // 要操作Redis,先确定保存值用的key
                // SeckillCacheUtils.getStockKey是获取库存字符串常量的方法
                // 方法参数要传入skuId,方法会将这个值追加到常量后
                // skuStockKey最后的值可能为: "mall:seckill:sku:stock:1"
                String skuStockKey= SeckillCacheUtils.getStockKey(sku.getSkuId());
                // 检查Redis中是否已经包含这个Key
                if(redisTemplate.hasKey(skuStockKey)){
                    // 如果这个key已经存在了,证明之前缓存过,直接跳过
                    log.info("{}号sku的库存已经缓存过了",sku.getSkuId());
                }else{
                    // 如果这个key不存在,就需要将当前sku对象的库存数保持到redis
                    // stringRedisTemplate对象直接保存字符串格式的数据,方便后续修改
                    stringRedisTemplate.boundValueOps(skuStockKey)
                            .set(sku.getSeckillStock()+"",
                                    // 秒杀时间  +  提前5分钟+防雪崩随机数30秒
                                    //2*60*60*1000+5*60*1000+ RandomUtils.nextInt(30000),
                                    5*60*1000+RandomUtils.nextInt(10000),
                                    TimeUnit.MILLISECONDS);
                    log.info("{}号sku商品库存数成功预热到缓存!",sku.getSkuId());
                }
            }
            // 上面是内层循环结束,但是当前位置仍然在外层循环结构中,外层循环遍历spu
            // 下面是预热spu的随机码
            // 随机码就是一个随机数,随机范围自己定
            // 我们只需要将生成的随机码保存到Redis中即可
            // 确定随机码的key "mall:seckill:spu:url:rand:code:2"
            String randCodeKey=SeckillCacheUtils.getRandCodeKey(spu.getSpuId());
            // 判断当前随机码key是否在redis中存在
            if(redisTemplate.hasKey(randCodeKey)){
                // 如果已经存在了,不需要任何其他操作
                // 为了方便今后的测试,也需要将随机码输出到控制台
                int randCode=(int)redisTemplate.boundValueOps(randCodeKey).get();
                log.info("{}号spu商品的随机码已经缓存过了,值为:{}",spu.getSpuId(),randCode);
            }else{
                // 如果不存在,就要生成随机码
                // 生成随机码的范围自定即可,这里设计100000-999999
                int randCode=RandomUtils.nextInt(900000)+100000;
                redisTemplate.boundValueOps(randCodeKey)
                        .set(randCode,
                                5*60*1000+RandomUtils.nextInt(10000),
                                TimeUnit.MILLISECONDS);
                log.info("spuId为{}号的随机码生成成功!值为:{}",
                        spu.getSpuId(),randCode);
            }

        }
    }
}

​ RedisTemplate对象在保存数据到Redis时,会将数据先序列化之后再保存 ​ 这样做,对java对象或类似的数据在Redis中的读写效率高,缺点是不能在redis中修改这个数据 ​ 现在我们要保存的是秒杀sku的库存数,如果这个数也用RedisTemplate保存,也会有上面的问题 ​ 容易在高并发的情况下,由于线程安全问题导致"超卖" ​ 解决办法就是我们需要创建一个能够直接在Redis中修改数据的对象,避免线程安全问题防止"超卖" ​ SpringDataRedis提供了StringRedisTemplate类型,它是可以直接操作redis中字符串的 ​ 使用StringRedisTemplate向Redis保存数据,直接存字符串值,没有序列化过程 ​ 而且它支持java中直接发送指令修改数值类型的内容,所以适合保存库存数 ​ 这样就避免了java代码中对库存数修改带来的线程安全问题 ​ 最后结合Redis操作数据是单线程的特性,避免线程安全问题,防止超卖

配置Quartz触发

上面的类中的代码只是编写了预热操作

我们需要在Quartz中配置才能触发生效

在time.config包中创建QuartzConfig类编写Job的触发

@Configuration
public class QuartzConfig {

    // 向Spring容器中保存JobDetail
    @Bean
    public JobDetail initJobDetail(){
        return JobBuilder.newJob(SeckillInitialJob.class)
                .withIdentity("initSeckill")
                .storeDurably()
                .build();
    }

    // 向Spring容器中保存Trigger
    @Bean
    public Trigger initTrigger(){
        // 12:00  14:00 18:00秒杀的话,各提前5分钟秒杀预热的话
        // 0 55 11,13,17 * * ?
        // 为了方便学习和观察测试,我们设计每分钟都运行一次
        CronScheduleBuilder cron=
                CronScheduleBuilder.cronSchedule("0 0/1 * * * ?");
        return TriggerBuilder.newTrigger()
                .forJob(initJobDetail())
                .withIdentity("initTrigger")
                .withSchedule(cron)
                .build();
    }



}

启动Nacos\Redis\Seata

项目启动seckill

每分钟0秒时,观察日志输出状态

保证数据库中有数据在秒杀时间段内!!!!!!!

18.9.开发查询秒杀商品详情的功能

上面章节我们完成了缓存预热

下面要根据SpuId查询正在秒杀的商品

和普通的SpuId查询商品详情相比

它的业务判断更复杂

1.页面上显示秒杀价和剩余秒杀时间等信息

2.判断请求的spuId是否在布隆过滤器中(后续完成)

3.判断Redis 中是否包含商品信息

4.如果一切正常在返回详情信息前,要为url属性赋值,其实就是固定路径+随机码

根据SpuId查询秒杀商品详情

之前的章节已经完成了根据SpuId查询Spu信息的mapper

下面我们直接从业务逻辑层开始编写即可

SeckillSpuServiceImpl业务逻辑层实现类

// 装配Redis操作对象
@Autowired
private RedisTemplate redisTemplate;

// 根据spuId查询返回SeckillSpuVO对象
@Override
public SeckillSpuVO getSeckillSpu(Long spuId) {
    // 在后面完整版代码中,这里要先经过布隆过滤器的判断
    // 如果布隆过滤器判断spuId不存在,直接抛出异常终止方法,防止缓存穿透

    // SeckillSpuVO对象是既包含常规spu信息,又包含秒杀spu信息的对象
    // 获得Redis对应的key
    String spuVOKey= SeckillCacheUtils.getSeckillSpuVOKey(spuId);
    // 声明一个当前方法返回值类型的对象
    SeckillSpuVO seckillSpuVO=null;
    // 判断Redis中是否包含这个key
    if(redisTemplate.hasKey(spuVOKey)){
        // 如果Redis中已经包含这个key 直接获取
        seckillSpuVO=(SeckillSpuVO) redisTemplate
                .boundValueOps(spuVOKey).get();
    }else{
        // 如果Redis中不包含这个key,就需要从数据库查询
        // 查询分秒杀信息和常规信息,先查秒杀信息
        SeckillSpu seckillSpu = seckillSpuMapper.findSeckillSpuById(spuId);
        // 判断一下这个seckillSpu是否为null(以为要防止布隆过滤器误判)
        if(seckillSpu == null){
            throw new CoolSharkServiceException(ResponseCode.NOT_FOUND,
                    "您访问的商品不存在!");
        }
        // 获取了秒杀信息,再获取常规信息
        SpuStandardVO spuStandardVO = dubboSeckillSpuService.getSpuById(spuId);

        // 开始将秒杀信息和常规信息都赋值到seckillSpuVO中
        seckillSpuVO=new SeckillSpuVO();
        BeanUtils.copyProperties(spuStandardVO,seckillSpuVO);
        // 手动赋值秒杀信息
        seckillSpuVO.setSeckillListPrice(seckillSpu.getListPrice());
        seckillSpuVO.setStartTime(seckillSpu.getStartTime());
        seckillSpuVO.setEndTime(seckillSpu.getEndTime());
        // 将seckillSpuVO保存到Redis中,以便后续直接获取
        redisTemplate.boundValueOps(spuVOKey).set(
                seckillSpuVO,
                5*60*1000+ RandomUtils.nextInt(10000),
                TimeUnit.MILLISECONDS);
    }
    // 到此为止seckillSpuVO只有url属性未被赋值了
    // url属性是要发送给前端的,前端可以使用它来发起生成秒杀订单的请求
    // 所以我们必须先判断当前时间是否在允许秒杀购买该商品的时间段内
    // 获取当前时间
    LocalDateTime nowTime=LocalDateTime.now();
    // 对比时,为了降低系统资源消耗,尽量不连数据库,从seckillSpuVO中获取开始和结束时间
    // 判断逻辑是秒杀开始时间小于当前时间,并且当前时间小于秒杀结束时间
    if(seckillSpuVO.getStartTime().isBefore(nowTime) &&
                    nowTime.isBefore(seckillSpuVO.getEndTime())){
        // 进入if表示当前时间是在当前商品秒杀时间段内的,可以为url赋值
        // 我们要从Redis中获取已经预热的随机码
        String randCodeKey=SeckillCacheUtils.getRandCodeKey(spuId);
        // 判断Redis中是否存在这个key
        if(!redisTemplate.hasKey(randCodeKey)){
            // 如果不存在直接抛异常
            throw new CoolSharkServiceException(ResponseCode.NOT_FOUND,"当前随机码不存在");
        }
        // key存在获取随机码
        String randCode=redisTemplate.boundValueOps(randCodeKey).get()+"";
        // 将随机码赋值到url
        seckillSpuVO.setUrl("/seckill/"+randCode);
        log.info("被赋值的url为:{}",seckillSpuVO.getUrl());
    }
    // 别忘了返回!!!!!
    return seckillSpuVO;
}

开发控制层SeckillSpuController

// localhost:10007/seckill/spu/2
@GetMapping("/{spuId}")
@ApiOperation("根据spuId查询spu相关信息")
@ApiImplicitParam(value = "spuId",name="spuId",example = "2")
public JsonResult<SeckillSpuVO> getSeckillSpuVO(
        @PathVariable Long spuId){
    SeckillSpuVO seckillSpuVO=seckillSpuService.getSeckillSpu(spuId);
    return JsonResult.ok(seckillSpuVO);
}

启动product模块

重启Seckill模块

测试10007端口功能

18.10.完成根据SpuId查询商品detail详情

业务逻辑层SeckillSpuServiceImpl类中编写新的方法

// 项目中没有定义可用的SpuDetail的常量用于Redis的Key
// 我们就需要自己定义一个
public static final String SECKILL_SPU_DETAIL_VO_PREFIX="seckill:spu:detail:vo:";
// 根据spuId查询秒杀SpuDetail
@Override
public SeckillSpuDetailSimpleVO getSeckillSpuDetail(Long spuId) {
    // 先获取操作Redis的Key
    String spuDetailKey=SECKILL_SPU_DETAIL_VO_PREFIX+spuId;
    // 先声明一个返回值类型的null对象
    SeckillSpuDetailSimpleVO simpleVO=null;
    // 判断redis中是否包含
    if(redisTemplate.hasKey(spuDetailKey)){
        // 如果存在直接从redis中获取
        simpleVO=(SeckillSpuDetailSimpleVO) redisTemplate
                            .boundValueOps(spuDetailKey).get();
    }else{
        // 如果redis中不存在
        // 利用Dubbo从product模块查询
        SpuDetailStandardVO spuDetailStandardVO =
                dubboSeckillSpuService.getSpuDetailById(spuId);
        // 实例化simpleVO对象保证它不为null
        simpleVO=new SeckillSpuDetailSimpleVO();
        BeanUtils.copyProperties(spuDetailStandardVO,simpleVO);
        // 保存在Redis中
        redisTemplate.boundValueOps(spuDetailKey)
                .set(simpleVO,
                        5*60*1000+RandomUtils.nextInt(10000),
                        TimeUnit.MILLISECONDS);
    }
    // 千万别忘了返回
    return simpleVO;
}

完成控制层代码

上次课完成了该功能的业务逻辑层方法,下面编写控制层方法

// localhost:10007/seckill/spu/2/detail
@GetMapping("/{spuId}/detail")
@ApiOperation("根据spuId查询spuDetail")
@ApiImplicitParam(value = "spuId",name="spuId", example = "2")
public JsonResult<SeckillSpuDetailSimpleVO> getSeckillSpuDetail(
                            @PathVariable Long spuId){
    SeckillSpuDetailSimpleVO simpleVO=
            seckillSpuService.getSeckillSpuDetail(spuId);
    return JsonResult.ok(simpleVO);
}

启动Nacos\Seata\Redis

启动 product\seckill

访问localhost:10007/doc.html测试

18.11.根据SpuId查询sku列表

之前编写加载数据的Mapper时,完成了根据SpuId查Sku列表的功能

下面我们从业务逻辑层开始编写

开发业务逻辑层

我们也需要将SpuId对应的Sku信息保存到Redis

在service.impl包中创建SeckillSkuServiceImpl类中编写代码如下

@Service
@Slf4j
public class SeckillSkuServiceImpl implements ISeckillSkuService {
    @Autowired
    private SeckillSkuMapper skuMapper;
    @Autowired
    private RedisTemplate redisTemplate;

    // sku常规信息要用dubbo调用product模块获取
    @DubboReference
    private IForSeckillSkuService dubboSkuServivce;

    @Override
    public List<SeckillSkuVO> listSeckillSkus(Long spuId) {
        // 执行查询,根据spuId获取sku列表
        List<SeckillSku> seckillSkus=skuMapper.findSeckillSkusBySpuId(spuId);
        // 当前方法的返回值集合泛型为SeckillSkuVO
        // 是既包含秒杀信息,又包含常规信息的对象
        // 我们先实例化这个集合,以备后面循环遍历时向其添加元素
        List<SeckillSkuVO> seckillSkuVOs=new ArrayList<>();
        // 遍历根据spuId查询出的sku列表集合
        for(SeckillSku sku : seckillSkus){
            // 获取skuId,后面会多次使用
            Long skuId=sku.getSkuId();
            // 获取当前sku对应的key
            String skuVOKey= SeckillCacheUtils.getSeckillSkuVOKey(skuId);
            // 声明SeckillSkuVO对象,并赋值null
            SeckillSkuVO seckillSkuVO=null;
            // 判断redis中是否包含这个key
            if(redisTemplate.hasKey(skuVOKey)){
                seckillSkuVO=(SeckillSkuVO)redisTemplate
                        .boundValueOps(skuVOKey).get();
            }else{
                // 如果redis中不存在这个key,就要查询数据库
                // 利用dubbo查询当前sku的常规信息
                SkuStandardVO skuStandardVO=dubboSkuServivce.getById(skuId);
                // 实例化seckillSkuVO对象
                seckillSkuVO=new SeckillSkuVO();
                // 常规属性同名赋值操作
                BeanUtils.copyProperties(skuStandardVO,seckillSkuVO);
                // 秒杀信息手动赋值
                seckillSkuVO.setSeckillPrice(sku.getSeckillPrice());
                seckillSkuVO.setStock(sku.getSeckillStock());
                seckillSkuVO.setSeckillLimit(sku.getSeckillLimit());
                // seckillSkuVO完成了秒杀信息和常规信息的赋值,保存在redis中
                redisTemplate.boundValueOps(skuVOKey)
                        .set(seckillSkuVO,5*60*1000+ RandomUtils.nextInt(10000),
                                            TimeUnit.MILLISECONDS);
            }
            // 在if-else结构结束后,确定获取了seckillSkuVO对象后
            // 将seckillSkuVO对象添加到集合中以便返回
            seckillSkuVOs.add(seckillSkuVO);
        }
        // 返回这个集合!!!!!
        return seckillSkuVOs;
    }
}

编写控制层

新建SeckillSkuController添加方法

@RestController
@RequestMapping("/seckill/sku")
@Api(tags = "秒杀sku模块")
public class SeckillSkuController {

    @Autowired
    private ISeckillSkuService seckillSkuService;

    @GetMapping("/list/{spuId}")
    @ApiOperation("根据spuId查询sku列表")
    @ApiImplicitParam(value = "spuId",name="spuId", example = "2")
    public JsonResult<List<SeckillSkuVO>> listSeckillSkus(
            @PathVariable Long spuId){
        List<SeckillSkuVO> list=seckillSkuService.listSeckillSkus(spuId);
        return JsonResult.ok(list);
    }
}

启动Nacos\Redis\Seata

启动product

重启seckill

端口10007测试

19.消息队列

19.1.软件下载

doc.canglaoshi.org网站中的kafka

image-20220906114435678

19.2.Dubbo远程调用的性能问题

Dubbo调用普遍存在于我们的微服务项目中

这些Dubbo调用全部是同步的操作

这里的"同步"指:消费者A调用生产者B之后,A的线程会进入阻塞状态,等待生产者B运行结束返回之后,A才能运行之后的代码

image-20220519152055211

Dubbo消费者发送调用后进入阻塞状态,这个状态表示该线程仍占用内存资源,但是什么动作都不做

如果生产者运行耗时较久,消费者就一直等待,如果消费者利用这个时间,那么可以处理更多请求,业务整体效率会提升

实际情况下,Dubbo有些必要的返回值必须等待,但是不必要等待的服务返回值,我们可以不等待去做别的事情

这种情况下我们就要使用消息队列

19.3.什么是消息队列

消息队列(Message Queue)简称MQ,也称:"消息中间件"

消息队列是采用"异步(两个微服务项目并不需要同时完成请求)"的方式来传递数据完成业务操作流程的业务处理方式

19.4.消息队列的特征

image-20220519154952335

常见面试题:消息队列的特征(作用)

  • 利用异步的特性,提高服务器的运行效率,减少因为远程调用出现的线程等待\阻塞时间

  • 削峰填谷:在并发峰值超过当前系统处理能力时,我们将没处理的信息保存在消息队列中,在后面出现的较闲的时间中去处理,直到所有数据依次处理完成,能够防止在并发峰值时短时间大量请求而导致的系统不稳定

  • 消息队列的延时:因为是异步执行,请求的发起者并不知道消息何时能处理完,如果业务不能接受这种延迟,就不要使用消息队列

image-20220906175358420

19.5.常见消息队列软件

  • Kafka:性能好\功能弱:适合大数据量,高并发的情况,大数据领域使用较多
  • RabbitMQ:功能强\性能一般:适合发送业务需求复杂的消息队列,java业务中使用较多
  • RocketMQ:阿里的
  • ActiveMQ:前几年流行的,老项目可能用到
  • .....

19.6.消息队列的事务处理

当接收消息队列中信息的模块运行发生异常时,怎么完成事务的回滚?

当消息队列中(stock)发生异常时,在异常处理的代码中,我们可以向消息的发送者(order)发送消息,然后通知发送者(order)处理,消息的发送者(order)接收到消息后,一般要手写代码回滚,如果回滚代码过程中再发生异常,就又要思考回滚方式,如果一直用消息队列传递消息的话,可能发生异常的情况是无止境的

所以我们在处理消息队列异常时,经常会设置一个"死信队列",将无法处理的异常信息发送到这个队列中

死信队列没有任何处理者,通常情况下会有专人周期性的处理死信队列的消息

20.Kafka

20.1什么是Kafka

Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。该项目的目标是为处理实时数据提供一个统一、高吞吐、低延迟的平台。Kafka最初是由LinkedIn开发,并随后于2011年初开源。

20.2kafka软件结构

Kafka是一个结构相对简单的消息队列(MQ)软件

kafka软件结构图

image-20220519162807497

Kafka Cluster(Kafka集群)

Producer:消息的发送方,也就是消息的来源,Kafka中的生产者

order就是消息的发送方,在Dubbo中order是消费者,这个身份变化了

Consumer:消息的接收方,也是消息的目标,Kafka中的消费者

stock就是消息的接收方,在Dubbo中stock是生产者,这个身份变化了

Topic:话题或主题的意思,消息的收发双方要依据同一个话 题名称,才不会将信息错发给别人

Record:消息记录,就是生产者和消费者传递的信息内容,保存在指定的Topic中

20.3.Kafka的特征与优势

Kafka作为消息队列,它和其他同类产品相比,突出的特点就是性能强大

Kafka将消息队列中的信息保存在硬盘中

Kafka对硬盘的读取规则进行优化后,效率能够接近内存

硬盘的优化规则主要依靠"顺序读写,零拷贝,日志压缩等技术"

Kafka处理队列中数据的默认设置:

  • Kafka队列信息能够一直向硬盘中保存(理论上没有大小限制)
  • Kafka默认队列中的信息保存7天,可以配置这个时间,缩短这个时间可以减少Kafka的磁盘消耗

20.4.Kafka的安装和配置

必须将我们kafka软件的解压位置设置在一个根目录,文件夹名称尽量短(例如:kafka)

然后路径不要有空格和中文

image-20220519165423916

我们要创建一个空目录用于保存Kafka运行过程中产生的数据

本次创建名称为data的空目录

下面进行Kafka启动前的配置

先到D:\kafka\config下配置有文件zookeeper.properties

找到dataDir属性修改如下

dataDir=D:/data

修改完毕之后要Ctrl+S进行保存,否则修改无效!!!!

注意E盘和data文件夹名称,匹配自己电脑的真实路径和文件夹名称

还要修改server.properties配置文件

log.dirs=D:/data

修改注意事项和上面相同

20.5.启动kafka

要想启动Kafka必须先启动Zookeeper

Zookeeper介绍

zoo:动物园

keeper:园长

可以引申为管理动物的人

Linux服务器中安装的各种软件,很多都是有动物形象的

如果这些软件在Linux中需要修改配置信息的话,就需要进入这个软件,去修改配置,每个软件都需要单独修改配置的话,工作量很大

我们使用Zookeeper之后,可以创建一个新的管理各种软件配置的文件管理系统

Linux系统中各个软件的配置文件集中到Zookeeper中

实现在Zookeeper中,可以修改服务器系统中的各个软件配置信息

长此以往,很多软件就删除了自己写配置文件的功能,而直接从Zookeeper中获取

Kafka就是需要将配置编写在Zookeeper中的软件之一

所以要先启动zookeeper才能启动kafka

Zookeeper启动

进入路径D:\kafka\bin\windows

输入cmd进入dos命令行

D:\kafka\bin\windows>zookeeper-server-start.bat ..\..\config\zookeeper.properties

kafka启动

总体方式一样,输入不同指令

E:\kafka\bin\windows>kafka-server-start.bat ..\..\config\server.properties

附录

Mac系统启动Kafka服务命令(参考):

# 进入Kafka文件夹
cd Documents/kafka_2.13-2.4.1/bin/
# 动Zookeeper服务
./zookeeper-server-start.sh -daemon ../config/zookeeper.properties 
# 启动Kafka服务
./kafka-server-start.sh -daemon ../config/server.properties 

Mac系统关闭Kafka服务命令(参考):

# 关闭Kafka服务
./kafka-server-stop.sh 
# 启动Zookeeper服务
./zookeeper-server-stop.sh

在启动kafka时有一个常见错误

wmic不是内部或外部命令

这样的提示,需要安装wmic命令,安装方式参考

https://zhidao.baidu.com/question/295061710.html

如果启动kafka无响应

在“环境变量”的“用户变量路径”中Path属性添加一行后

%SystemRoot%\System32\Wbem;%SystemRoot%\System32\;%SystemRoot%

20.6.Kafka使用演示

启动的zookeeper和kafka的窗口不要关闭

我们在csmall项目中编写一个kafka使用的演示

csmall-cart-webapi模块

添加依赖

<!-- SpringBoot整合Kafka的依赖  -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<!--   google提供的可以将java对象转化为json格式字符串的工具   -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
</dependency>

修改yml文件进行配置

spring:
  kafka:
    # 定义kafka的位置
    bootstrap-servers: localhost:9092
    # consumer.group-id是spring-kafka框架要求必须配置的内容,不配置启动会报错
    # 作用是给话题分组,防止不同项目中恰巧相同的话题名称混淆
    # 本质上,在当前项目发送消息给kafka是,会使用我们配置的csmall作为话题名称前缀
    # 例如发送一个话题名称为message的消息,真正发送到kafka的话题名称为csmall_message
    consumer:
      group-id: csmall

在SpringBoot启动类中添加启动Kafka的注解

@SpringBootApplication
@EnableDubbo
// 项目启动时启用对kafka的支持
@EnableKafka
// 为了测试kafka收发消息
// 我们利用SpringBoot自带的定时任务工具,周期性的向kafka发送消息
// 明确我们SpringBoot自带定时任务和Kafka没有必然联系
@EnableScheduling
public class CsmallCartWebapiApplication {

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

}

下面我们就可以实现周期性的向kafka发送消息并接收的操作了

编写消息的发送

cart-webapi包下创建kafka包

包中创建Producer类来发送消息

// 启动时要将这个类实例化保存到Spring容器,才能执行周期运行的效果
@Component
public class Producer {

    // Spring-Kafka框架会自动将能够操作发送消息的对象注入到Spring容器
    // 我们直接使用@Autowired自动装配即可
    // KafkaTemplate<[话题类型],[消息类型]>
    @Autowired
    private KafkaTemplate<String,String> kafkaTemplate;

    // 定义话题常量
    public static final String TOPIC_KEY="myCart";
    
    int i=1;
    // 编写实现每隔8秒(8000毫秒)运行一次的方法来发送消息到Kafka
    @Scheduled(fixedRate = 8000)
    public void sendMessage(){
        // 实例化要发送的对象并赋值
        Cart cart=new Cart();
        cart.setId(i++);
        cart.setCommodityCode("PC100");
        cart.setUserId("UU100");
        cart.setPrice(10+ RandomUtils.nextInt(90));
        cart.setCount(1+ RandomUtils.nextInt(10));
        // 将cart对象转换为json格式字符串发送
        // {"id":"1","userId":"UU100","price":"50",....}
        // 利用Google提供的gson工具类转换
        Gson gson=new Gson();
        String json = gson.toJson(cart);
        System.out.println("要发送的json格式字符串为:"+json);
        // 执行发送
        kafkaTemplate.send(TOPIC_KEY,json);
    }
    
}

Zookeeper\Kafka\Nacos\Seata启动

然后启动cart每隔8秒会发送一次消息

如果没有报错,能确定功能基本正常

下面开始接收

kafka包中创建一个叫Consumer的类来接收消息

接收消息的类可以是本模块的类,也可以是其它模块的类,编写的代码是完全一致

// 当前类是来接收Kafka发送的消息用的
// 要求将这个类也保存到Spring容器中,因为SpringTemplate使用spring容器中的对象
@Component
public class Consumer {

    // spring-Kafka接收消息,使用了"监听机制"
    // 框架设计了一条线程,实时关注Kafka话题接收消息的情况
    // 我们指定一个话题名称(myCart),这个话题一旦接收消息,监听线程就会通知当前方法
    @KafkaListener(topics = Producer.TOPIC_KEY)
    // 上面就是设置监听器的注解,指定了myCart这个话题名称
    // 当kafka中出现myCart话题的消息时,监听器会自动调用下面的方法
    // 方法的参数和返回值是指定的不能修改
    public void received(ConsumerRecord<String,String> record){
        // 返回值必须是void 参数必须是ConsumerRecord类型
        // 泛型指定对应发送时的泛型<[话题类型],[消息类型]>
        // 这个参数record就是消息的发送者发送到kafka的内容,由监听器自动赋值
        // 从消息对象中获得消息内容
        String json=record.value();
        // json就是发送来的消息内容,它可能是这样值:{"id":"1","userId":"UU100","price":"50",....}
        // 下面再使用Gson工具类将json格式字符串转换为java对象
        Gson gson=new Gson();
        Cart cart=gson.fromJson(json,Cart.class);
        // 转换完成,输出Cart对象
        System.out.println(cart);
    }

}

重新启动cart测试

观察消息的收发情况

21.RabbitMQ

21.1.什么是RabbitMQ

RabbitMQ 是一个由 Erlang 语言开发的 AMQP 的开源实现。 AMQP :Advanced Message Queue,高级消息队列协议。它是应用层协议的一个开放标准,为面向消息的中间件设计,基于此协议的客户端与消息中间件可传递消息,并不受产品、开发语言等条件的限制。 RabbitMQ 最初起源于金融系统,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

21.2.RabbitMQ特征

1.可靠性(Reliability) RabbitMQ 使用一些机制来保证可靠性,如持久化、传输确认、发布确认。

2.灵活的路由(Flexible Routing) 在消息进入队列之前,通过 Exchange 来路由消息的。对于典型的路由功能,RabbitMQ已经提供了一些内置的 Exchange 来实现。针对更复杂的路由功能,可以将多个Exchange 绑定在一起,也通过插件机制实现自己的 Exchange 。

3.消息集群(Clustering) 多个 RabbitMQ 服务器可以组成一个集群,形成一个逻辑 Broker

4.高可用(Highly Available Queues) 队列可以在集群中的机器上进行镜像,使得在部分节点出问题的情况下队列仍然可用。

5.多种协议(Multi-protocol) RabbitMQ 支持多种消息队列协议,比如 STOMP、MQTT 等等。

6.多语言客户端(Many Clients) RabbitMQ 几乎支持所有常用语言,比如 Java、.NET、Ruby 等等。

7.管理界面(Management UI) RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker 的许多方面。

8.跟踪机制(Tracing) 如果消息异常,RabbitMQ 提供了消息跟踪机制,使用者可以找出发生了什么。

9.插件机制(Plugin System) RabbitMQ 提供了许多插件,来从多方面进行扩展,也可以编写自己的插件。

20.3.下载软件

苹果mac系统的同学直接苍老师网站看MacOS安装RabbitMQ的技术贴

RabbitMQ是Erlang语言开发的,所以要先安装Erlang语言的运行环境

下载Erlang的官方路径

https://erlang.org/download/otp_versions_tree.html

image-20220520103318825

安装的话就是双击

安装过程中都可以使用默认设置,需要注意的是

不要安装在中文路径和有空格的路径下!!!

下载RabbitMQ的官方网址

https://www.rabbitmq.com/install-windows.html

image-20220520104034920

安装也是双击即可

不要安装在中文路径和有空格的路径下!!!

20.4.配置Erlang的环境变量

要想运行RabbitMQ必须保证系统有Erlang的环境变量

配置Erlang环境变量

把安装Erlang的bin目录配置在环境变量Path的属性中

image-20220907151502819

20.5.启动RabbitMQ

手动启动(应用)

执行RabbitMQ启动命令:
rabbitmq-server.bat start

找到RabbitMQ的安装目录

例如

D:\tools\rabbit\rabbitmq_server-3.10.1\sbin

具体路径根据自己的情况寻找

地址栏运行cmd

输入启动指令如下

D:\tools\rabbit\rabbitmq_server-3.10.1\sbin>rabbitmq-plugins enable rabbitmq_management

结果如下

image-20220810153001965

运行完成后,验证启动状态

RabbitMQ自带一个管理的界面,所以我们可以访问这个界面来验证它的运行状态

http://localhost:15672

image-20220810153420567

登录界面用户名密码

guest

guest

登录成功后看到RabbitMQ运行的状态

如果启动失败,可以手动启动RabbitMQ

参考路径如下

https://baijiahao.baidu.com/s?id=1720472084636520996&wfr=spider&for=pc

20.6.Rabbitmq的工作模式

常见面试题

Rabbitmq的工作模式有六种:simple简单模式、work工作模式、publish/subscribe订阅模式、routing路由模式、topic 主题模式、RPC模式。

simple简单模式为一个队列中一条消息,只能被一个消费者消费。

Work工作模式为一个生产者,多个消费者,每个消费者获取到的消息唯一。

publish/subscribe订阅模式为一个生产者发送的消息被多个消费者获取。

routing路由模式为生产者发送的消息主要根据定义的路由规则决定往哪个队列发送。

topic 主题模式为生产者,一个交换机(topicExchange),模糊匹配路由规则,多个队列,多个消费者。

RPC模式为客户端 Client 先发送消息到消息队列,远程服务端 Server 获取消息,然后再写入另一个消息队列,向原始客户端 Client 响应消息处理结果。

20.7.RabbitMQ路由模式的结构

RabbitMQ软件支持很多种工作模式,我们学习其中的路由模式

路由模式比较常用,而且功能强大,但是结构比Kafka的主题模式复杂

image-20221208175130743

和Kafka不同,Kafka是使用话题名称来收发信息,结构简单

RabbitMQ路由模式是使用交换机\路由key指定要发送消息的队列

消息的发送者发送消息时,需要指定交换机和路由key名称

消息的接收方接收消息时,只需要指定队列的名称

在编写代码上,相比于Kafka,每个业务要编写一个配置类

这个配置类中要绑定交换机和路由key的关系,以及路由Key和队列的关系

20.8.利用RabbitMQ完成消息的收发

csmall-stock-webapi项目中测试RabbitMQ

可以利用之前我们使用Quartz实现的每隔一段时间输出当前日期信息的方法改为发送消息

添加依赖

<!--  RabbitMQ的依赖   -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

yml文件配置

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    # 设置虚拟host 单机模式下固定编写"/"即可
    virtual-host: /

交换机\路由Key\队列的配置类

RabbitMQ要求我们在java代码级别设置交换机\路由Key\队列的关系

我们在quartz包下,创建config包

包中创建配置信息类RabbitMQConfig

// 当前配置类配置RabbitMQ中 交换机\路由Key和队列的关系
// 因为它们的关系需要保存到Spring容器中才能生效,所以需要这个配置类
@Configuration
public class RabbitMQConfig {
    // 将业务中需要的所有交换机\路由Key\队列的名称都声明为常量
    public static final String STOCK_EX="stock_ex"; /*交换机*/
    public static final String STOCK_ROUT="stock_rout";/*路由*/
    public static final String STOCK_QUEUE="stock_queue";/*队列*/

    // 绑定关系中,交换机和队列是实际对象,直接实例化保存到Spring容器
    @Bean
    public DirectExchange stockDirectExchange(){
        return new DirectExchange(STOCK_EX);
    }
    @Bean
    public Queue stockQueue(){
        return new Queue(STOCK_QUEUE);
    }
    // 路由Key是关系对象,保存方式特殊
    @Bean
    public Binding stockBinding(){
        return BindingBuilder.bind(stockQueue())
                            .to(stockDirectExchange()).with(STOCK_ROUT);
    }

}

RabbitMQ发送消息

我们在QuartzJob类中输出时间的代码后继续编写代码

实现RabbitMQ消息的发送

@Slf4j
public class QuartzJob implements Job {

    // 装配能够向RabbitMQ发送消息的对象
    // 这个对象也是添加好依赖和配置之后,在springBoot启动时自动向容器中保存的
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    static int i=1;
    // 这个方法就是当前job要定时执行的任务代码
    @Override
    public void execute(JobExecutionContext jobExecutionContext)
            throws JobExecutionException {
        // 一个简单的任务演示,输出当前系统时间,使用sout或log皆可
        log.info("-------------------" + LocalDateTime.now() + "--------------------");
        // 实例化Stock对象用于发送
        Stock stock=new Stock();
        stock.setId(i++);
        stock.setCommodityCode("PC100");
        stock.setReduceCount(1+ RandomUtils.nextInt(20));
        // 下面开始发送消息的RabbitMQ
        rabbitTemplate.convertAndSend(
                RabbitMQConfig.STOCK_EX,
                RabbitMQConfig.STOCK_ROUT,
                stock);
        log.info("发送消息完成:{}",stock);


    }
}

我们可以通过修改QuartzConfig类中的Cron表达式修改调用的周期**

CronScheduleBuilder cron=
        CronScheduleBuilder.cronSchedule("0/10 * * * * ?");

按上面的cron修改之后,会每隔10秒运行一次发送消息的操作

启动服务,观察是否每隔10秒发送一条消息

启动Nacos\RabbitMQ\Seata

启动stock-webapi

根据Cron表达式,消息会在0/10/20/30/40/50秒数时运行

20.9.接收RabbitMQ中的消息

quartz包下再创建一个新的类用于接收信息

RabbitMQConsumer代码如下

// Spring连接RabbitMQ的依赖中提供的资源,需要接收消息的类保存到Spring容器中才能使用
@Component
// 和Kafka不同,RabbitMQ监听器注解要求写在类上
@RabbitListener(queues = RabbitMQConfig.STOCK_QUEUE)
@Slf4j
public class RabbitMQConsumer {

    // 类上添加了监听器注解,但是不能指定接收消息后要运行的方法
    // 使用@RabbitHandler注解标记,我们接收到消息后要运行的方法
    // 每个类只允许一个方法被这个注解标记
    // 注解下面编写方法,参数比Kafka简单
    @RabbitHandler
    public void process(Stock stock){
        // Stock就是发送到RabbitMQ的消息,直接使用即可
        log.info("接收到消息:{}",stock);
    }

}

其他项目不动,继续保持运行

重启stock-webapi模块

观察消息的接收

开发酷鲨商城秒杀业务

18.12.创建流控和降级的处理类

秒杀业务肯定是一个高并发的处理,并发数超过程序设计的限制时,就需要对请求的数量进行限流

Sentinel是阿里提供的SpringCloud组件,主要用于外界访问当前服务器的控制器方法的限流操作

之前的课程中,我们已经比较详细的学习的Sentinel使用的方式

下面我们要先编写Sentinel限流和服务降级时,运行的自定义异常处理类

我们酷鲨前台项目seckill-webapi模块

先来编写限流异常处理类

创建一个exception包,包中新建SeckillBlockHandler代码如下

// 秒杀业务限流异常处理类
@Slf4j
public class SeckillBlockHandler {

    // 声明限流的方法,返回值必须和被限流的控制器方法一致
    // 参数要包含全部控制器方法参数,还要在参数最后添加BlockException的声明
    // 当限流\降级方法和目标控制器方法不再同一个类中时,限流和降级方法要声明为static,否则报错
    public static JsonResult seckillBlock(String randCode,
                                          SeckillOrderAddDTO seckillOrderAddDTO,
                                          BlockException e){
        log.error("一个请求被限流了!");
        return JsonResult.failed(ResponseCode.INTERNAL_SERVER_ERROR,
                "对不起服务器忙,请稍后再试");
    }

}

再创建降级类SeckillFallBack

// 秒杀业务的降级处理类
@Slf4j
public class SeckillFallBack {

    // 降级方法参数和返回值和限流方法大部分一致,唯一区别就是异常类型换位Throwable类型
    public static JsonResult seckillFallBack(String randCode,
                                             SeckillOrderAddDTO seckillOrderAddDTO,
                                             Throwable e){
        log.error("一个请求被降级了");
        // 输出异常信息
        e.printStackTrace();
        return JsonResult.failed(ResponseCode.INTERNAL_SERVER_ERROR,
                "发生异常,异常信息为:"+e.getMessage());
    }
}

18.13.开发执行秒杀的业务逻辑层

我们之前完成了秒杀的预热,预热中完成了秒杀商品sku库存数,spu随机码(布隆过滤器)保存在redis中的操作

也完成了查询秒杀商品列表,和显示秒杀商品详情的方法

下面要开始进行秒杀商品生成订单的操作

如果用户选择商品规格(sku)提交订单,那么就要按照提交秒杀订单的业务流程处理

秒杀提交订单和普通订单的区别

1.判断用户是否为重复购买和Redis中该Sku是否有库存

2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法

3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中

4.秒杀订单信息返回

创建一个SeckillServiceImpl业务逻辑层实现类,完成上面的业务

@Service
@Slf4j
public class SeckillServiceImpl implements ISeckillService {

    // 秒杀业务中,使用Redis的代码都是在判断数值,直接使用字符串类型的Redis对象
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 需要dubbo调用mall_order模块的普通订单生成业务
    @DubboReference
    private IOmsOrderService dubboOrderService;
    //需要将秒杀成功信息发送给消息队列
    @Autowired
    private RabbitTemplate rabbitTemplate;

    /*
    1.判断用户是否为重复购买和Redis中该Sku是否有库存
    2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
    3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
    4.秒杀订单信息返回
    */
    @Override
    public SeckillCommitVO commitSeckill(SeckillOrderAddDTO seckillOrderAddDTO) {
        // 第一阶段:判断用户是否为重复购买和Redis中该Sku是否有库存
        // 从方法的参数中,获得用户想要购买的skuId
        Long skuId=seckillOrderAddDTO.getSeckillOrderItemAddDTO().getSkuId();
        // 从SpringSecurity上下文中获得用户Id
        Long userId=getUserId();
        // 我们明确了本次请求是哪个用户要购买哪个商品(userId和skuId的值)
        // 根据秒杀业务限制,每个用户只能购买skuId一次
        // 所以可以根据userId和skuId生成检查重复购买的key
        // mall:seckill:reseckill:2:1
        String reSeckillCheckKey= SeckillCacheUtils.getReseckillCheckKey(skuId,userId);
        // 用上面字符串做key,向redis中发送命令使用stringRedisTemplate的increment方法
        // increment是增长的意思,这个方法效果如下
        // 1.如果上面的key在redis中不存在,redis中会使用这个key,创建一个值,值为1
        // 2.如果上面的key已经在redis中,那么就会在当前的值基础上加1再保存,例如当前已经是1,会变为2
        // 3.无论当前key存在不存在,都会将处理之后的值返回
        // 综上,只要用户调用这个方法返回值为1,就表示这个用户没有买过这个商品
        Long seckillTimes=stringRedisTemplate.
                boundValueOps(reSeckillCheckKey).increment();
        // 如果seckillTimes值大于1,就是用户已经购买过这个商品
        if(seckillTimes>1){
            // 抛出异常,提示不能重复购买,终止程序
            throw new CoolSharkServiceException(ResponseCode.FORBIDDEN,
                    "您已经购买过这个商品了,谢谢您的支持");
        }
        // 程序运行到此处,表示当前用户第一次购买这个商品
        // 然后检查这个商品是否有库存
        // 根据要购买的skuId,获得这个sku在redis中的Key
        // mall:seckill:sku:stock:1
        String skuStockKey=SeckillCacheUtils.getStockKey(skuId);
        // 如果Redis中没有这个key要抛出异常
        if(!stringRedisTemplate.hasKey(skuStockKey)){
            throw new CoolSharkServiceException(ResponseCode.INTERNAL_SERVER_ERROR,
                    "缓存中没有库存信息,购买失败!");
        }
        // 和上面的increment方法相反,
        // 这里调用的decrement(减少)方法是将当前key保存在redis中的值减1之后返回
        Long leftStock=stringRedisTemplate.boundValueOps(skuStockKey).decrement();
        // leftStock是decrement方法对当前值减1之后返回的
        // 所以leftStock的值表示的是当前用户购买后剩余的库存数
        // 既leftStock等于0时,当前用户买到了最后一个,leftStock小于0,才是已经没有库存了
        if(leftStock<0){
            // 没有库存了,要抛出异常终止程序
            // 但是要先将当前用户购买这个商品的记录恢复为0
            stringRedisTemplate.boundValueOps(reSeckillCheckKey).decrement();
            throw new CoolSharkServiceException(ResponseCode.BAD_REQUEST,
                    "对不起,您要购买的商品暂时售罄");
        }
        // 到此为止,用户通过了重复购买和库存数的判断,可以开始生产订单了
        // 第二阶段:秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
        // 当前方法参数类型SeckillOrderAddDTO,dubbo调用需要的参数类型OrderAddDTO
        // 下面开始进行转换,转换代码较多,单独编写方法
        OrderAddDTO orderAddDTO=convertSeckillOrderToOrder(seckillOrderAddDTO);
        // 完成了转换操作,订单的所有属性都赋值完毕了,除了userId
        // 秒杀模块当前获得的userId值必须赋值到orderAddDTO中
        orderAddDTO.setUserId(userId);
        // dubbo调用生成订单的方法
        OrderAddVO orderAddVO=dubboOrderService.addOrder(orderAddDTO);
        // 第三阶段:使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
        // 业务要求我们记录秒杀成功信息,但是它不是迫切运行的,所以可以使用消息队列完成
        // 我们要创建Success秒杀记录对象,然后将它发送给RabbitMQ
        // 另寻时机编写处理消息队列的代码
        Success success=new Success();
        // Success大部分属性和秒杀Sku信息重叠,可以做同名属性赋值的操作
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                    success);
        // 将不足的信息补全
        success.setUserId(userId);
        success.setOrderSn(orderAddVO.getSn());
        success.setSeckillPrice(seckillOrderAddDTO.getSeckillOrderItemAddDTO()
                                    .getPrice());
        // success对象信息完备,执行发送给RabbitMQ
        rabbitTemplate.convertAndSend(
                RabbitMqComponentConfiguration.SECKILL_EX,
                RabbitMqComponentConfiguration.SECKILL_RK,
                success);
        // 这里只做消息的发送,无需考虑消息接收的问题
        // 第四阶段:秒杀订单信息返回
        // 当前方法要求返回的类型是SeckillCommitVO
        // 经观察,这个类和我们提交普通订单得到的返回值OrderAddVO属性完全一致
        // 所以做同名属性赋值之后,直接返回即可
        SeckillCommitVO commitVO=new SeckillCommitVO();
        BeanUtils.copyProperties(orderAddVO,commitVO);
        // 别忘了返回!!!!
        return commitVO;
    }
    // 秒杀订单转换成普通订单的方法
    private OrderAddDTO convertSeckillOrderToOrder(
                                    SeckillOrderAddDTO seckillOrderAddDTO) {
        // 实例化返回值对象
        OrderAddDTO orderAddDTO=new OrderAddDTO();
        // 将参数seckillOrderAddDTO的同名属性赋值到orderAddDTO
        BeanUtils.copyProperties(seckillOrderAddDTO,orderAddDTO);
        // 经过上面操作,大部分数据都已经完成赋值,区别主要在于两个订单对象包含的订单项
        // OrderAddDTO是普通订单对象,其中的订单项属性是一个集合List<OrderItemAddDTO>
        // 而SeckillOrderAddDTO是秒杀订单对象,其中的订单项属性是一个对象SeckillOrderItemAddDTO
        // 所以我们先将SeckillOrderItemAddDTO对象转化为普通订单项类型OrderItemAddDTO
        OrderItemAddDTO orderItemAddDTO=new OrderItemAddDTO();
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                        orderItemAddDTO);
        // 再实例化普通订单项集合List<OrderItemAddDTO>
        List<OrderItemAddDTO> list=new ArrayList<>();
        // 然后将转换好的普通订单项添加到集合中
        list.add(orderItemAddDTO);
        // 最后将添加完对象的订单项集合赋值到orderAddDTO中
        orderAddDTO.setOrderItems(list);
        // 完成了!返回转换结果
        return orderAddDTO;

    }

    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity上下文中信息是不是null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录!");
        }
        // 从SpringSecurity上下文中获得用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo) authenticationToken.getCredentials();
        // 最终别忘了返回
        return csmallAuthenticationInfo;
    }
    // 业务逻辑层需求中,实际上只需要用户的id
    // 我们可以再编写一个方法,从用户对象中获取id
    public Long getUserId(){
        return getUserInfo().getId();
    }
}

1657265202414

image-20221014152350296

18.14.开发控制层

随机码判断流程

image-20221014155301239

controller包下创建SeckillController

代码如下

@RestController
@RequestMapping("/seckill")
@Api(tags = "执行秒杀模块")
public class SeckillController {

    @Autowired
    private ISeckillService seckillService;
    @Autowired
    private RedisTemplate redisTemplate;

    @PostMapping("/{randCode}")
    @ApiOperation("验证随机码并提交秒杀订单")
    @ApiImplicitParam(value = "随机码",name="randCode",required = true)
    @PreAuthorize("hasRole('user')")
    @SentinelResource(value = "seckill",
        blockHandlerClass = SeckillBlockHandler.class,blockHandler = "seckillBlock",
        fallbackClass = SeckillFallBack.class,fallback = "seckillFallBack")
    public JsonResult<SeckillCommitVO> commitSeckill(
            @PathVariable String randCode,
            @Validated SeckillOrderAddDTO seckillOrderAddDTO){
        // 先获取spuId
        Long spuId=seckillOrderAddDTO.getSpuId();
        // 或者这个spuId对应的随机码的key
        String randCodeKey= SeckillCacheUtils.getRandCodeKey(spuId);
        // 判断Redis中是否有这个Key
        if(redisTemplate.hasKey(randCodeKey)){
            // 如果redis中有这个key,将它的value取出(获取随机码)
            String redisRandCode=redisTemplate.boundValueOps(randCodeKey).get()+"";
            // 判断前端发来的随机码和redis中保存的是否一致
            if(! redisRandCode.equals(randCode) ){
                // 前端随机码和redis随机码不一致,抛出异常
                throw new CoolSharkServiceException(ResponseCode.NOT_FOUND,
                        "没有找到指定商品(随机码不正确)");
            }
            // 程序运行到此处,表示随机码匹配,调用业务逻辑层
            SeckillCommitVO commitVO=
                    seckillService.commitSeckill(seckillOrderAddDTO);
            return JsonResult.ok(commitVO);
        }else{
            // 如果redis中没有这个key直接抛出异常
            throw new CoolSharkServiceException(ResponseCode.NOT_FOUND,
                    "没有找到指定商品");
        }
    }

}

启动Nacos\Seata\RabbitMQ\Redis\Sentinel

项目Leaf\product\passport\order\seckill

注意yml配置文件中的RabbitMQ的用户名和密码

如果说已经购买过,就修改允许购买的数量 >1为 >100

如果说没有库存,检查数据库库存,也可以把判断库存的if注释掉

测试成功即可

还可以测试sentinel的限流

续 开发酷鲨商城秒杀业务

18.15.success秒杀成功信息的处理

我们在上面章节提交秒杀信息业务最后

向RabbitMQ队列中,输出了添加秒杀成功信息的消息

但是我们没有任何处理

将秒杀成功信息发送到消息队列的原因:

秒杀成功信息用于统计秒杀数据,是秒杀结束后才需要统计的

所以在秒杀并发高时,消息队列的发送可以延缓,在服务器不忙时,再运行(削峰填谷)

开发持久层

秒杀数据库中有success表

其中的信息就是保存秒杀成功的数据(userId,skuId等)

我们要连接数据库,对这个表进行新增

还有对秒杀数据库sku库存的修改

SeckillSkuMapper接口中添加方法来修改指定skuId的库存数

// 根据skuId减少库存数的方法
int updateReduceStockBySkuId(@Param("skuId") Long skuId,
                             @Param("quantity") Integer quantity);

SeckillSkuMapper.xml

<!--  根据skuId减少库存数的方法  -->
<update id="updateReduceStockBySkuId">
    update
        seckill_sku
    set
        seckill_stock=seckill_stock- #{quantity}
    where
        sku_id=#{skuId}
</update>

下面再编写新增Success的方法

创建SuccessMapper接口编写方法

@Repository
public interface SuccessMapper {

    // 新增Success对象到数据库的方法
    int saveSuccess(Success success);
}

SuccessMapper.xml

<!--  新增Success对象到数据库的方法  -->
<insert id="saveSuccess">
    insert into success(
        user_id,
        user_phone,
        sku_id,
        title,
        main_picture,
        seckill_price,
        quantity,
        bar_code,
        data,
        order_sn
    ) values(
        #{userId},
        #{userPhone},
        #{skuId},
        #{title},
        #{mainPicture},
        #{seckillPrice},
        #{quantity},
        #{barCode},
        #{data},
        #{orderSn}
    )
</insert>

18.16.开发消息的接收功能

我们当前触发新增Success的方法并不是常规的业务逻辑层

而是由RabbitMQ消息收发机制中接收消息的对象来调用

所以我们编写一个接收消息的监听器类来完成这个操作

创建consumer包,包中创建类SeckillQueueConsumer代码如下

@Component
@RabbitListener(queues = RabbitMqComponentConfiguration.SECKILL_QUEUE)
@Slf4j
public class SeckillQueueConsumer {

    @Autowired
    private SeckillSkuMapper seckillSkuMapper;
    @Autowired
    private SuccessMapper successMapper;

    // 下面开始编写接收消息队列中消息的方法
    @RabbitHandler
    public void process(Success success){
        // 先减少库存
        seckillSkuMapper.updateReduceStockBySkuId(
                success.getSkuId(),success.getQuantity());
        // 新增success对象到数据库
        successMapper.saveSuccess(success);
        // 如果上面两个数据库操作发生异常
        // 可能会引发事务问题,如果统计不需要非常精确,不处理也可以
        // 如果统计需要精确,发生异常后,可以编写重试代码,如果重试不行,可以考虑使用死信队列
        // 但是因为死信队列需要人工处理,所以实际开发慎用

    }

}

环境方面

Nacos\Sentinel\Seata\redis\RabbitMQ

服务方面

Leaf\product\order\seckill

如果之前的测试没有关闭环境

只需要重启seckill即可

最近更新:: 2025/8/21 13:52
Contributors: yanpeng_
Prev
工作流Activity7