业务代码开发流程
in Projects with 0 comment

业务代码开发流程

in Projects with 0 comment

连接数据库

必须先完成必要的配置,包括:

应该先在csmall-product-webapisrc\main\resources下创建application-dev.properties文件,在其中添加连接数据库的参数:

# 连接数据库的配置信息
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

然后,在application.properties中添加配置:

# 激活Profile配置
spring.profiles.active=dev
# 连接数据库的固定配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource

# Mybatis的XML文件位置
mybatis.mapper-locations=classpath:mapper/*.xml

然后,需要在src/main/resources下自行创建mapper文件夹,与以上配置对应。

还需要自行创建配置类,用于配置Mapper接口文件所在的包,则在cn.tedu.csmall.product.webapi下创建mapper子包,并在cn.tedu.csmall.product.webapi下创建config.MybatisConfiguration配置类,进行配置:


@Configuration
@MapperScan("cn.tedu.csmall.product.webapi.mapper")
public class MybatisConfiguration {

}

完成后,可以尝试测试连接到数据库,则在src/test/java下找到默认即存在的测试类,编写并执行测试:


@SpringBootTest
class CsmallProductWebapiApplicationTests {

    @Autowired
    DataSource dataSource;

    @Test
    void contextLoads() {
    }
    
    @Test
    void testConnection() {
        Assertions.assertDoesNotThrow(() -> {
            dataSource.getConnection();
        });
    }

}

执行整个测试,应该能够通过测试。

持久层

  1. 规划需要执行的SQL语句
  2. 定义接口, 添加@Repository注解.
/**
 * 处理“类别”数据的持久层接口
 */
@Repository
public interface CategoryMapper {

    /**
     * 插入“类别”数据
     * @param category 类别
     * @return 受影响的行数
     */
    int insert(Category category);
    
    // 查询--待定

}
  1. 创建实体类
  2. 在接口中添加抽象方法
  3. 配置SQL语句-------Mapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.tedu.csmall.product.webapi.mapper.CategoryMapper">

    <!-- int insert(Category category); -->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        insert into pms_category (
            name, parent_id, depth, keywords, sort,
            icon, enable, is_parent, is_display, gmt_create,
            gmt_modified
        ) values (
            #{name}, #{parentId}, #{depth}, #{keywords}, #{sort},
            #{icon}, #{enable}, #{isParent}, #{isDisplay}, #{gmtCreate},
            #{gmtModified}
        )
    </insert>

</mapper>
  1. 测试Mapper

业务逻辑层

  1. 定义接口和抽象方法
  2. 定义实现类, 添加@Service注解, 重写抽象方法.
  3. 业务分析—输出注释
  4. 实现接口
    @Autowired
    CategoryMapper mapper;

1-规划SQL语句

2-创建VO类, 封装查询结果

3-添加数据层接口和抽象方法

4-if-else

5-处理全局异常: 建立异常工具类–全局异常配置.

  1. 单元测试

控制器层

  1. 创建Controller类, 在类上添加@RestController和@RequestMapping(value = “/categories”, produces = “application/json; charset=utf-8”)这2个注解.
  2. 添加处理请求的方法.
  3. 控制器测试, 添加注解@SpringBootTest和@AutoConfigureMockMvc

示例

1. 关于此项目

此项目是一个自营性质电商类型的项目。

当前目标是设计后台管理相关功能。

2. 关于项目的开发流程

在具体开发时,应该先创建数据库、数据表,然后创建项目进行开发。

3. 创建数据库与数据表

创建mall_pms数据库:

CREATE DATABASE mall_pms;

在此数据库中创建数据表:

-- 数据库:mall_pms

-- 相册表:创建数据表
drop table if exists pms_album;
create table pms_album
(
    id           bigint unsigned auto_increment comment '记录id',
    name         varchar(50)      default null comment '相册名称',
    description  varchar(255)     default null comment '相册简介',
    sort         tinyint unsigned default 0 comment '自定义排序序号',
    gmt_create   datetime         default null comment '数据创建时间',
    gmt_modified datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment '相册' charset utf8mb4;

-- 相册表:为相册名称字段添加索引
create index idx_album_name on pms_album (name);

-- 图片表:创建数据表
drop table if exists pms_picture;
create table pms_picture
(
    id           bigint unsigned auto_increment comment '记录id',
    album_id     bigint unsigned   default null comment '相册id',
    url          varchar(255)      default null comment '图片url',
    description  varchar(255)      default null comment '图片简介',
    width        smallint unsigned default null comment '图片宽度,单位:px',
    height       smallint unsigned default null comment '图片高度,单位:px',
    is_cover     tinyint unsigned  default 0 comment '是否为封面图片,1=是,0=否',
    sort         tinyint unsigned  default 0 comment '自定义排序序号',
    gmt_create   datetime          default null comment '数据创建时间',
    gmt_modified datetime          default null comment '数据最后修改时间',
    primary key (id)
) comment '图片' charset utf8mb4;

-- 品牌表:创建数据表
drop table if exists pms_brand;
create table pms_brand
(
    id                     bigint unsigned auto_increment comment '记录id',
    name                   varchar(50)      default null comment '品牌名称',
    pinyin                 varchar(50)      default null comment '品牌名称的拼音',
    logo                   varchar(255)     default null comment '品牌logo的URL',
    description            varchar(255)     default null comment '品牌简介',
    keywords               varchar(255)     default null comment '关键词列表,各关键词使用英文的逗号分隔',
    sort                   tinyint unsigned default 0 comment '自定义排序序号',
    sales                  int unsigned     default 0 comment '销量(冗余)',
    product_count          int unsigned     default 0 comment '商品种类数量总和(冗余)',
    comment_count          int unsigned     default 0 comment '买家评论数量总和(冗余)',
    positive_comment_count int unsigned     default 0 comment '买家好评数量总和(冗余)',
    enable                 tinyint unsigned default 0 comment '是否启用,1=启用,0=未启用',
    gmt_create             datetime         default null comment '数据创建时间',
    gmt_modified           datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment '品牌' charset utf8mb4;

-- 品牌表:为品牌名称字段添加索引
create index idx_brand_name on pms_brand (name);

-- 类别表:创建数据表
drop table if exists pms_category;
create table pms_category
(
    id           bigint unsigned auto_increment comment '记录id',
    name         varchar(50)      default null comment '类别名称',
    parent_id    bigint unsigned  default 0 comment '父级类别id,如果无父级,则为0',
    depth        tinyint unsigned default 1 comment '深度,最顶级类别的深度为1,次级为2,以此类推',
    keywords     varchar(255)     default null comment '关键词列表,各关键词使用英文的逗号分隔',
    sort         tinyint unsigned default 0 comment '自定义排序序号',
    icon         varchar(255)     default null comment '图标图片的URL',
    enable       tinyint unsigned default 0 comment '是否启用,1=启用,0=未启用',
    is_parent    tinyint unsigned default 0 comment '是否为父级(是否包含子级),1=是父级,0=不是父级',
    is_display   tinyint unsigned default 0 comment '是否显示在导航栏中,1=启用,0=未启用',
    gmt_create   datetime         default null comment '数据创建时间',
    gmt_modified datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment '类别' charset utf8mb4;

-- 类别表:为类别名称字段添加索引
create index idx_category_name on pms_category (name);

-- 品牌类别关联表:创建数据表
drop table if exists pms_brand_category;
create table pms_brand_category
(
    id           bigint unsigned auto_increment comment '记录id',
    brand_id     bigint unsigned default null comment '品牌id',
    category_id  bigint unsigned default null comment '类别id',
    gmt_create   datetime        default null comment '数据创建时间',
    gmt_modified datetime        default null comment '数据最后修改时间',
    primary key (id)
) comment '品牌与类别关联' charset utf8mb4;

-- 属性表:创建数据表
drop table if exists pms_attribute;
create table pms_attribute
(
    id                 bigint unsigned auto_increment comment '记录id',
    template_id        bigint unsigned  default null comment '所属属性模版id',
    name               varchar(50)      default null comment '属性名称',
    description        varchar(255)     default null comment '简介(某些属性名称可能相同,通过简介补充描述)',
    type               tinyint unsigned default 0 comment '属性类型,1=销售属性,0=非销售属性',
    input_type         tinyint unsigned default 0 comment '输入类型,0=手动录入,1=单选,2=多选,3=单选(下拉列表),4=多选(下拉列表)',
    value_list         varchar(255)     default null comment '备选值列表',
    unit               varchar(50)      default null comment '计量单位',
    sort               tinyint unsigned default 0 comment '自定义排序序号',
    is_allow_customize tinyint unsigned default 0 comment '是否允许自定义,1=允许,0=禁止',
    gmt_create         datetime         default null comment '数据创建时间',
    gmt_modified       datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment '属性' charset utf8mb4;

-- 属性模版表:创建数据表
drop table if exists pms_attribute_template;
create table pms_attribute_template
(
    id           bigint unsigned auto_increment comment '记录id',
    name         varchar(50)      default null comment '属性模版名称',
    pinyin       varchar(50)      default null comment '属性模版名称的拼音',
    keywords     varchar(255)     default null comment '关键词列表,各关键词使用英文的逗号分隔',
    sort         tinyint unsigned default 0 comment '自定义排序序号',
    gmt_create   datetime         default null comment '数据创建时间',
    gmt_modified datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment '属性模版' charset utf8mb4;

-- 属性模版表:为属性模版名称字段添加索引
create index idx_attribute_template_name on pms_attribute_template (name);

-- 类别与属性模版关联表:创建数据表
drop table if exists pms_category_attribute_template;
create table pms_category_attribute_template
(
    id                    bigint unsigned auto_increment comment '记录id',
    category_id           bigint unsigned default null comment '类别id',
    attribute_template_id bigint unsigned default null comment '属性模版id',
    gmt_create            datetime        default null comment '数据创建时间',
    gmt_modified          datetime        default null comment '数据最后修改时间',
    primary key (id)
) comment '类别与属性模版关联' charset utf8mb4;

-- SPU(Standard Product Unit)表:创建数据表
drop table if exists pms_spu;
create table pms_spu
(
    id                     bigint unsigned not null comment '记录id',
    name                   varchar(50)      default null comment 'SPU名称',
    type_number            varchar(50)      default null comment 'SPU编号',
    title                  varchar(255)     default null comment '标题',
    description            varchar(255)     default null comment '简介',
    list_price             decimal(10, 2)   default null comment '价格(显示在列表中)',
    stock                  int unsigned     default 0 comment '当前库存(冗余)',
    stock_threshold        int unsigned     default 0 comment '库存预警阈值(冗余)',
    unit                   varchar(50)      default null comment '计件单位',
    brand_id               bigint unsigned  default null comment '品牌id',
    brand_name             varchar(50)      default null comment '品牌名称(冗余)',
    category_id            bigint unsigned  default null comment '类别id',
    category_name          varchar(50)      default null comment '类别名称(冗余)',
    attribute_template_id  bigint unsigned  default null comment '属性模版id',
    album_id               bigint unsigned  default null comment '相册id',
    pictures               varchar(500)     default null comment '组图URLs,使用JSON数组表示',
    keywords               varchar(255)     default null comment '关键词列表,各关键词使用英文的逗号分隔',
    tags                   varchar(255)     default null comment '标签列表,各标签使用英文的逗号分隔,原则上最多3个',
    sales                  int unsigned     default 0 comment '销量(冗余)',
    comment_count          int unsigned     default 0 comment '买家评论数量总和(冗余)',
    positive_comment_count int unsigned     default 0 comment '买家好评数量总和(冗余)',
    sort                   tinyint unsigned default 0 comment '自定义排序序号',
    is_deleted             tinyint unsigned default 0 comment '是否标记为删除,1=已删除,0=未删除',
    is_published           tinyint unsigned default 0 comment '是否上架(发布),1=已上架,0=未上架(下架)',
    is_new_arrival         tinyint unsigned default 0 comment '是否新品,1=新品,0=非新品',
    is_recommend           tinyint unsigned default 0 comment '是否推荐,1=推荐,0=不推荐',
    is_checked             tinyint unsigned default 0 comment '是否已审核,1=已审核,0=未审核',
    check_user             varchar(50)      default null comment '审核人(冗余)',
    gmt_check              datetime         default null comment '审核通过时间(冗余)',
    gmt_create             datetime         default null comment '数据创建时间',
    gmt_modified           datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment 'SPU(Standard Product Unit)' charset utf8mb4;

-- SPU详情表:创建数据表
drop table if exists pms_spu_detail;
create table pms_spu_detail
(
    id           bigint unsigned auto_increment comment '记录id',
    spu_id       bigint unsigned default null comment 'SPU id',
    detail       text            default null comment 'SPU详情,应该使用HTML富文本,通常内容是若干张图片',
    gmt_create   datetime        default null comment '数据创建时间',
    gmt_modified datetime        default null comment '数据最后修改时间',
    primary key (id)
) comment 'SPU详情' charset utf8mb4;

-- SKU(Stock Keeping Unit)表:创建数据表
drop table if exists pms_sku;
create table pms_sku
(
    id                     bigint unsigned not null comment '记录id',
    spu_id                 bigint unsigned  default null comment 'SPU id',
    title                  varchar(255)     default null comment '标题',
    bar_code               varchar(255)     default null comment '条型码',
    attribute_template_id  bigint unsigned  default null comment '属性模版id',
    specifications         varchar(2500)    default null comment '全部属性,使用JSON格式表示(冗余)',
    album_id               bigint unsigned  default null comment '相册id',
    pictures               varchar(500)     default null comment '组图URLs,使用JSON格式表示',
    price                  decimal(10, 2)   default null comment '单价',
    stock                  int unsigned     default 0 comment '当前库存',
    stock_threshold        int unsigned     default 0 comment '库存预警阈值',
    sales                  int unsigned     default 0 comment '销量(冗余)',
    comment_count          int unsigned     default 0 comment '买家评论数量总和(冗余)',
    positive_comment_count int unsigned     default 0 comment '买家好评数量总和(冗余)',
    sort                   tinyint unsigned default 0 comment '自定义排序序号',
    gmt_create             datetime         default null comment '数据创建时间',
    gmt_modified           datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment 'SKU(Stock Keeping Unit)' charset utf8mb4;

-- SKU规格参数表(存储各SKU的属性与值,即规格参数):创建数据表
drop table if exists pms_sku_specification;
create table pms_sku_specification
(
    id              bigint unsigned auto_increment comment '记录id',
    sku_id          bigint unsigned  default null comment 'SKU id',
    attribute_id    bigint unsigned  default null comment '属性id',
    attribute_name  varchar(50)      default null comment '属性名称',
    attribute_value varchar(50)      default null comment '属性值',
    unit            varchar(10)      default null comment '自动补充的计量单位',
    sort            tinyint unsigned default 0 comment '自定义排序序号',
    gmt_create      datetime         default null comment '数据创建时间',
    gmt_modified    datetime         default null comment '数据最后修改时间',
    primary key (id)
) comment 'SKU数据' charset utf8mb4;

-- -------------------------- --
-- 以下是插入测试数据及一些测试访问 --
-- -------------------------- --

-- 品牌表:插入测试数据
insert into pms_brand (name, pinyin, description, keywords, enable)
values ('华为', 'huawei', '华为专注网络设备三十年', '华为,huawei,mate,magicbook', 1),
       ('小米', 'xiaomi', '小米,为发烧而生', '小米,xiaomi,发烧', 1),
       ('苹果', 'pingguo', '苹果,全球知名品牌', '苹果,apple,pingguo,iphone,mac', 1);

-- 类别表:插入测试数据
insert into pms_category (name, parent_id, depth, is_parent, keywords, enable, is_display)
values ('手机 / 运营商 / 数码', 0, 1, 1, null, 1, 1),
       ('手机通讯', 1, 2, 1, '手机,电话', 1, 1),
       ('智能手机', 2, 3, 0, null, 1, 1),
       ('非智能手机', 2, 3, 0, null, 1, 1),
       ('电脑 / 办公', 0, 1, 1, null, 1, 1),
       ('电脑整机', 5, 2, 1, '电脑,计算机,微机,服务器,工作站', 1, 1),
       ('电脑配件', 5, 2, 1, '配件,组装,CPU,内存,硬盘', 1, 1),
       ('笔记本', 6, 3, 0, '电脑,笔记本,微机,便携', 1, 1),
       ('台式机 / 一体机', 6, 3, 0, '台式机,一体机', 1, 1);

-- 品牌类别表:插入测试数据
insert into pms_brand_category (brand_id, category_id)
values (1, 3),
       (2, 3),
       (3, 3),
       (1, 8),
       (2, 8),
       (3, 8),
       (1, 9),
       (3, 9);

-- 关联测试查询:各品牌有哪些类别的产品
select pms_brand_category.id, pms_brand.name, pms_category.name
from pms_brand_category
         left join pms_brand
                   on pms_brand_category.brand_id = pms_brand.id
         left join pms_category
                   on pms_brand_category.category_id = pms_category.id
order by pms_brand.pinyin;

-- 属性表:插入测试数据
insert into pms_attribute (name, description, type, input_type, value_list, unit, is_allow_customize)
values ('屏幕尺寸', '智能手机屏幕尺寸', 0, 1, '6.1,6.3', '英寸', 1),
       ('屏幕尺寸', '笔记本电脑屏幕尺寸', 0, 1, '14,15', '英寸', 1),
       ('颜色', '智能手机颜色', 0, 1, '黑色,金色,白色', null, 1),
       ('颜色', '衬衣颜色', 0, 1, '白色,蓝色,灰色,黑色', null, 1),
       ('运行内存', '智能手机运行内存', 0, 1, '4,8,16', 'GB', 1),
       ('CPU型号', '智能手机CPU型号', 0, 1, '骁龙870,骁龙880', null, 1),
       ('机身毛重', '智能手机机身毛重', 0, 0, null, 'g', 0),
       ('机身存储', '智能手机机身存储', 0, 1, '64,128,256,512', 'GB', 0),
       ('操作系统', '智能手机操作系统', 0, 1, 'Android,iOS', null, 0),
       ('操作系统', '电脑操作系统', 0, 1, '无,Windows 7,Windows 10,Ubuntu,Mac OS', null, 0);

-- 属性模版表:插入测试数据
insert into pms_attribute_template (name, pinyin, keywords)
values ('智能手机', 'zhinengshouji', '手机'),
       ('服装-上身', 'fuzhuang', '服装,上衣'),
       ('服装-裤子', 'fuzhuang', '服装,裤'),
       ('笔记本电脑', 'bijibendiannao', '电脑,笔记本'),
       ('台式电脑', 'taishidiannao', '电脑,台式电脑,台式机');

-- 相册表:插入测试数据
insert into pms_album (name, description)
values ('iPhone 13', null),
       ('Mi 11 Ultra', null);

-- 图片表:插入测试数据
insert into pms_picture (album_id, url, description, width, height)
values (1, '模拟数据:iPhone 13图片URL-1', null, 1024, 768),
       (1, '模拟数据:iPhone 13图片URL-2', null, 1024, 768),
       (1, '模拟数据:iPhone 13图片URL-3', null, 1024, 768),
       (1, '模拟数据:iPhone 13图片URL-4', null, 1024, 768),
       (1, '模拟数据:iPhone 13图片URL-5', null, 1024, 768),
       (2, '模拟数据:Mi 11 Ultra图片URL-1', null, 1024, 768),
       (2, '模拟数据:Mi 11 Ultra图片URL-2', null, 1024, 768),
       (2, '模拟数据:Mi 11 Ultra图片URL-3', null, 1024, 768),
       (2, '模拟数据:Mi 11 Ultra图片URL-4', null, 1024, 768),
       (2, '模拟数据:Mi 11 Ultra图片URL-5', null, 1024, 768);

-- SPU表:插入测试数据
insert into pms_spu (id, name, type_number, title, description, list_price, stock, stock_threshold, unit, brand_id,
                     brand_name, category_id, category_name, keywords, tags)
values (202112010000001, 'iPhone 13', 'A2404', '苹果手机iPhone 13(A2404)', '2021年新款,全网首发',
        5199.99, 5000, 20, '部', 3, '苹果', 3, '智能手机', 'ip13,iPhone13,苹果13', '20w快充,NFC,无线充电'),
       (202112010000002, '小米11 Ultra', 'M112021', '小米11 Ultra(M112021)', '2021年最新旗舰机',
        5899.99, 8000, 20, '部', 2, '小米', 3, '智能手机', 'mi11,xiaomi11,ultra', '67w快充,1亿像素,5000毫安电池');

-- SPU详情表:插入测试数据
insert into pms_spu_detail (spu_id, detail)
values (1, '<div>iPhone 13的详情HTML</div>'),
       (2, '<div>小米11 Ultra的详情HTML</div>');

-- SKU(Stock Keeping Unit)表:插入测试数据
insert into pms_sku (id, spu_id, title, attribute_template_id, specifications, price, stock, stock_threshold)
values (202112010000001, 2, '2021年新款,小米11 Ultra黑色512G,16G超大内存120Hz高刷67w快充', 1,
        '{"attributes":[{"id":1,"name":"屏幕尺寸","value":"6.1寸"},{"id":3,"name":"颜色","value":"黑色"},{"id":5,"name":"运行内存","value":"16GB"}]}',
        6999.99, 3000, 50),
       (202112010000002, 2, '2021年新款,小米11 Ultra白色512G,8G超大内存120Hz高刷67w快充', 1,
        '{"attributes":[{"id":1,"name":"屏幕尺寸","value":"6.1寸"},{"id":3,"name":"颜色","value":"白色"},{"id":5,"name":"运行内存","value":"8GB"}]}',
        6499.99, 3000, 50);

-- SKU规格参数表(存储各SKU的属性与值,即规格参数):插入测试数据
insert into pms_sku_specification (sku_id, attribute_id, attribute_name, attribute_value, unit)
values (1, 1, '屏幕尺寸', '6.1', '寸'),
       (1, 3, '颜色', '黑色', null),
       (1, 5, '运行内存', '16', 'GB'),
       (2, 1, '屏幕尺寸', '6.1', '寸'),
       (2, 3, '颜色', '白色', null),
       (2, 5, '运行内存', '8', 'GB');

-- 查看数据表结构
desc pms_album;
desc pms_picture;
desc pms_category;
desc pms_brand;
desc pms_brand_category;
desc pms_attribute;
desc pms_attribute_template;
desc pms_spu;
desc pms_spu_detail;
desc pms_sku;
desc pms_sku_specification;

4. 关于Project与Module

Project:项目 / 工程

Module:模块

在较大规范的项目开发中,可能会把代码区分为多个Module进行开发,即某1个Project中可能有多个Module,各Module允许独立开发、独立运行,并且,可以在Project或某个父级Module中,对其子级的依赖项等部分的内容进行统筹管理。

需要注意:并不是每个Module都是可以独立运行的,在开发实践中,如果某些代码是多个Module都需要使用的,可以把这部分代码写在专门的Module中,其它Module依赖这个专门的Module即可(就像添加某个依赖项一样)。

当使用Project结合多个Module开发时,Project基本上不需要编写任何与功能相关的代码,只需要配置好pom.xml即可。

5. 创建Project

使用Spring Boot创建向导来创建Project,相关参数:

由于Project并不需要运行(也不能运行),对于各依赖项,只需要管理即可,并不需要实际的添加!

在Project的pom.xml中,如果直接使用<dependencies>节点配置依赖项,则每个Module可以直接使用这些依赖项!但是,不推荐这样使用,因为Spring Boot的许多依赖都是支持自动配置的,如果在Module中自带了不需要使用的依赖项,反而容易导致错误,即使不会导致错误,也会因为自带了不需要使用的依赖项而导致最终的目标文件大。

通常,会将依赖项的代码放在<dependencyManagement>节点中,则在<dependencyManagement>之下的依赖项都不会默认出现在当前Project及各子级的Module中,其主要作用是配置各依赖项的版本,接下来,无论是在当前Project还是子级Module中,使用<dependencies>添加依赖项时,都可以不指定<version>节点来配置版本,默认使用的就是在<dependencyManagement>中配置的版本。

为了在当前pom.xml集中管理各依赖的版本号,推荐在<properties>中添加一些“变量”,用于配置版本号,在<dependencyManagement><dependencies>中添加依赖时,都可以引用此处的“变量”表示版本,例如:

<properties>
    <java.version>1.8</java.version>
    <spring-boot.version>2.5.9</spring-boot.version>
    <mybatis-boot.version>2.2.2</mybatis-boot.version>
    <mysql.version>8.0.28</mysql.version>
    <lombok.version>1.18.22</lombok.version>
</properties>

另外,由于Project并不实现的编写需要运行的功能代码,更不会直接编译打包或运行,所以,在Project的pom.xml中的<build>节点是不必要的!

最后,把Project的src全部删除!

6. 商品管理模块项目

应该在Project下创建某个Module,用于开发商品管理相关的功能,为了便于过渡到后续将要使用的微服务架构(需要将业务逻辑层的接口声明在专门的Module中,便于被其它微服务Module依赖),商品管理的Module需要再细分为2个,所以,目前项目结构应该是:

cgb2202-csmall-server[project]
	csmall-product
		csmall-product-webapi
		csmall-product-service

当存在Project和Module的“父子关系”或后续可能存在Module与Module的“父子关系”时,应该在父级的pom.xml中添加:

<packaging>pom</packaging>

并且,在父级的pom.xml中,通过<modules>节点配置各子级Module,例如:

<modules>
	<module>csmall-product</module>
</modules>

创建Module的操作步骤为:在cgb2202-csmall-server下创建csmall-product,再在csmall-product下创建csmall-product-webapi

当Project和各Module创建出来后,调整各pom.xml文件。

最终,cgb2202-csmall-server根级Project的pom.xml为:

<?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>

    <!-- Spring Boot父项目 -->
    <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-server</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- 聚合项目中的父级Project或父级Module都应该做以下配置 -->
    <packaging>pom</packaging>

    <!-- 当前Project的各子级Module -->
    <modules>
        <module>csmall-product</module>
    </modules>

    <!-- 属性配置,主要配置各依赖项的版本号对应的“变量” -->
    <properties>
        <java.version>1.8</java.version>
        <spring-boot.version>2.5.9</spring-boot.version>
        <mybatis-boot.version>2.2.2</mybatis-boot.version>
        <mysql.version>8.0.28</mysql.version>
        <lombok.version>1.18.22</lombok.version>
        <druid.version>1.1.20</druid.version>
    </properties>

    <!-- 依赖管理,主要管理各依赖项的版本,使得子级Module添加依赖时不必指定版本 -->
    <dependencyManagement>
        <dependencies>
            <!-- Spring Boot Validation:验证请求参数的基本格式 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-validation</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Spring Boot Web:支持Spring MVC -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
                <version>${spring-boot.version}</version>
            </dependency>
            <!-- Mybatis Spring Boot:Mybatis及对Spring Boot的支持 -->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis-boot.version}</version>
            </dependency>
            <!-- MySQL -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <scope>runtime</scope>
                <version>${mysql.version}</version>
            </dependency>
            <!-- Lombok -->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <optional>true</optional>
                <version>${lombok.version}</version>
            </dependency>
            <!-- Druid数据库连接池 -->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid</artifactId>
                <version>${druid.version}</version>
            </dependency>
            <!-- Spring Boot Test:测试 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
                <version>${spring-boot.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

</project>

csmall-productpom.xml为:

<?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-server</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <!-- 当前项目的信息 -->
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-product</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- 聚合项目中的父级Project或父级Module都应该做以下配置 -->
    <packaging>pom</packaging>

    <!-- 当前Project的各子级Module -->
    <modules>
        <module>csmall-product-webapi</module>
    </modules>

</project>

最后,在csmall-product-webapipom.xml为:

<?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-product</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <!-- 当前项目的信息 -->
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-product-webapi</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- 当前项目需要使用的依赖项 -->
    <dependencies>
        <!-- Spring Boot Validation:验证请求参数的基本格式 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- Spring Boot Web:支持Spring MVC -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Mybatis Spring Boot:Mybatis及对Spring Boot的支持 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!-- MySQL -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- Druid数据库连接池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
        </dependency>
        <!-- Spring Boot Test:测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

7. 关于编写代码

根据已经添加的数据表,目前此项目中需要管理的数据类型大致有:

首先,需要分析以上数据类型的开发先后顺序,部分数据之间存在依赖与被依赖关系,例如SKU肯定归属于某个SPU,在开发时,必须先开发SPU,再开发SKU,同理,必须先开发品牌,才可以开发SPU……

根据分析,必要的顺序为:(品牌 | 类别 | (相册 >>> 图片) | (属性模板 >>> 属性)) >>> SPU >>> SKU。

分析出必要顺序后,存在一些不需要严格区分顺序的数据类型,例如以上的品牌和类型,实际的开发顺序可以是先简单、后复杂,例如品牌数据通常比类别数据更加简单,则应该先开发品牌数据的管理,再开发类别数据的管理。

本次先开发类别.

当确定了需要处理类别的数据时,需要规划需要开发此数据的哪些管理功能,例如:添加类别、启用类别、禁用类别、修改类别的基本信息、根据id查询、根据parent_id查询列表……

以上管理类别数据的功能,开发顺序应该是:添加类别 >>> (根据id查询 | 根据parent_id查询列表) >>> (启用类别 | 禁用类别 | 修改类别的基本信息)

8. 类别管理–添加类别–持久层

8.1. 配置

由于目前是本项目第1次开发持久层,必须先完成必要的配置,包括:

应该先在csmall-product-webapisrc\main\resources下创建application-dev.properties文件,在其中添加连接数据库的参数:

# 连接数据库的配置信息
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

然后,在application.properties中添加配置:

# 激活Profile配置
spring.profiles.active=dev
# 连接数据库的固定配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource

# Mybatis的XML文件位置
mybatis.mapper-locations=classpath:mapper/*.xml

然后,需要在src/main/resources下自行创建mapper文件夹,与以上配置对应。

还需要自行创建配置类,用于配置Mapper接口文件所在的包,则在cn.tedu.csmall.product.webapi下创建mapper子包,并在cn.tedu.csmall.product.webapi下创建config.MybatisConfiguration配置类,进行配置:

package cn.tedu.csmall.product.webapi.config;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@MapperScan("cn.tedu.csmall.product.webapi.mapper")
public class MybatisConfiguration {

}

完成后,可以尝试测试连接到数据库,则在src/test/java下找到默认即存在的测试类,编写并执行测试:

package cn.tedu.csmall.product.webapi;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.sql.DataSource;

@SpringBootTest
class CsmallProductWebapiApplicationTests {

    @Autowired
    DataSource dataSource;

    @Test
    void contextLoads() {
    }
    
    @Test
    void testConnection() {
        Assertions.assertDoesNotThrow(() -> {
            dataSource.getConnection();
        });
    }

}

执行整个测试,应该能够通过测试。

8.2. 规划需要执行的SQL语句

此次需要执行的是“增加类别”,其数据操作的本质是向pms_category表中插入数据,需要执行的SQL语句大致是:

insert into pms_category (除了id以外的字段的列表) values (与字段列表匹配的各字段值);

具体表现为:

insert into pms_category (name, parent_id, depth, keywords, sort, icon, enable, is_parent, is_display, gmt_create, gmt_modified) values (值列表);

另外,还应该考虑在“添加类别”时,是否需要执行相关的检查,因为这些检查很可能是通过查询数据库来实现的,则在持久层也需要实现这些功能!

暂定规则“类别的名称不允许重复”,则后续在Service层进行处理时,应该先根据尝试添加的类别的名称进行查询,如果查询结果为null,表示此名称对应的类别尚不存在,将允许添加,如果查询结果不为null,表示此名称对应的类别已经存在,将不允许添加!

要实现以上检查的效果,需要执行的SQL语句可以是:

select * from pms_category where name=?;

或者:

select count(*) from pms_category where name=?;

以上2种做法,第1种做法的查询效率相对较低,或者说性能消耗略高,第2种做法的查询性能消耗更低,但是,第1种做法的查询可能具有复用性,而第2种做法的查询的复用性相对较低。

暂定可以使用以上第1种做法。

8.3. 接口与抽象方法

在插入数据之前,需要先创建“类别”对应的实体类型,而这个实体类型不能直接创建在csmall-product-webapi模块中,而是应该创建在另一个新的模块中,以便于其它各模块都可以使用到相同的实体类型(例如商品的实体类型,在商品管理模块和订单管理模块中都将需要使用)。

在Project中创建新的子模块csmall-pojo,创建出来后,需要:

cn.tedu.csmall.product.webapi.mapper包下创建CategoryMapper接口,并在接口上添加@Repository注解(主要是避免IntelliJ IDEA在自动装配时的误判错误),并在接口中添加抽象方法:

@Repository
public interface CategoryMapper {
    // 插入数据
    int insert(Category category);
    
    // 根据名称查询数据
}

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

select id from pms_category where name=?;

csmall-pojo的根包下创建vo.CategorySimpleVO类,用于封装以上查询结果:

@Data
public class CategorySimpleVO implements Serializable {
    private Long id;
}

csmall-product-webapiCategoryMapper接口中添加抽象方法:

CategorySimpleVO getByName(String name);

8.4. 配置SQL语句

csmall-product-webapiCategoryMapper.xml中添加配置:

<!-- CategorySimpleVO getByName(String name); -->
<select id="getByName" resultMap="SimpleResultMap">
    select id from pms_category where name=#{name}
</select>

<resultMap id="SimpleResultMap">
    <id column="id" property="id" />
</resultMap>

8.5. 测试

csmall-product-webapisrc\test\resources下创建insert_data.sql文件,用于插入测试数据:

insert into pms_category (name) value ('类别001'), ('类别002');

然后,在CategoryMapperTests中添加测试方法:

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testGetByNameSuccessfully() {
    // 测试数据
    String name = "类别001";
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行查询
        CategorySimpleVO category = mapper.getByName(name);
        // 断言查询结果不为null
        assertNotNull(category);
    });
}

@Test
@Sql({"classpath:truncate.sql"})
public void testGetByNameFailBecauseNotFound() {
    // 测试数据
    String name = "类别999";
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行查询
        CategorySimpleVO category = mapper.getByName(name);
        // 断言查询结果为null
        assertNull(category);
    });
}

完成后,执行整个测试类(将执行此类中所有测试方法),应该全部通过测试。

9. 类别管理–添加类别–业务逻辑层

9.1. 接口与抽象方法

在使用Dubbol的微服务架构中,需要将业务逻辑层的接口声明在专门的Module中,便于被其它微服务Module依赖,所以,先在csmall-product下创建新的Module,名为csmall-product-service,创建参数:

首先,应该调用新Module的pom.xml如下:

<?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-product</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <!-- 当前项目的信息 -->
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-product-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- 当前项目需要使用的依赖项 -->
    <dependencies>
        <!-- Csmall POJO -->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-pojo</artifactId>
        </dependency>
    </dependencies>

</project>

然后,在csmall-productpom.xml中补充此子级Module:

<!-- 当前Project的各子级Module -->
<modules>
    <module>csmall-product-webapi</module>
    <module>csmall-product-service</module> <!-- 新增 -->
</modules>

接下来,需要删除不必要的文件:

接下来,需要创建接口并添加抽象方法,方法的参数应该是封装的对象(因为一个StringLong等简单数据不足以完成添加类别的操作),则先在csmall-pojo的根包下创建dto.CategoryAddNewDTO类,并在类中添加必要的属性:

package cn.tedu.csmall.pojo.dto;

import lombok.Data;

import java.io.Serializable;

@Data
public class CategoryAddNewDTO implements Serializable {

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

}

然后,在csmall-product-service中,在cn.tedu.csmall.product.service下创建ICategoryService接口:

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

9.2. 实现

csmall-product-service中只存放业务逻辑层的接口,而业务逻辑层的实现类仍在csmall-product-webapi中,所以,需要在csmall-product-webapi中依赖csmall-product-service

先在Project的pom.xml中添加对csmall-product-service的依赖管理:

<!-- ===== 原有其它代码 ===== -->

<!-- 依赖管理,主要管理各依赖项的版本,使得子级Module添加依赖时不必指定版本 -->
<dependencyManagement>
    <dependencies>
        <!-- Csmall Product Service -->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-product-service</artifactId>
            <version>${csmall.version}</version>
        </dependency>
        
        <!-- ===== 原有其它代码 ===== -->

然后,在csmall-product-webapi中添加依赖:

<!-- ===== 原有其它代码 ===== -->

<!-- 当前项目需要使用的依赖项 -->
<dependencies>
    <!-- Csmall Product Service -->
    <dependency>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall-product-service</artifactId>
    </dependency>
    
    <!-- ===== 原有其它代码 ===== -->

cn.tedu.csmall.product.webapi下创建service.CategoryServiceImpl类,此类应该实现ICategoryService接口,此类还应该添加@Service注解:

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

import cn.tedu.csmall.pojo.dto.CategoryAddNewDTO;
import cn.tedu.csmall.product.service.ICategoryService;
import org.springframework.stereotype.Service;

@Service
public class CategoryServiceImpl implements ICategoryService {
    
    @Override
    public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
    }
    
}

关于以上业务的实现分析:

@Autowired
private CategoryMapper categoryMapper;

// 注意:需要创建异常
// 注意:需要在CategoryMapper中补充getById()方法,至少返回:depth
// 注意:需要在CategoryMapper中补充updateIsParentById()方法
public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
    // 从参数中取出尝试添加的类别的名称
    // 调用categoryMapper.getByName()方法查询
    // 判断查询结果是否不为null
    // 是:抛出ServiceException
    
    // 从参数中取出父级类别的id:parentId
    // 判断parentId是否为0
    // 是:此次尝试添加的是一级类别,没有父级类别,则当前depth >>> 1
    // 否:此次尝试添加的不是一级类别,则应该存在父级类别,调用categoryMapper.getById()方法查询父级类别的信息
    // -- 判断查询结果是否为null
    // -- 是:抛出ServiceException
    // -- 否:当前depth >>> 父级depth + 1

    // 创建Category对象
    // 调用BeanUtils.copyProperties()将参数对象中的属性值复制到Category对象中
    // 补全Category对象中的属性值:depth >>> 前序运算结果
    // 补全Category对象中的属性值:enable >>> 1(默认即启用)
    // 补全Category对象中的属性值:isParent >>> 0
    // 补全Category对象中的属性值:gmtCreate, gmtModified >>> LocalDateTime.now()
    // 调用categoryMapper.insert(Category)插入类别数据,获取返回的受影响的行数
    // 判断返回的受影响的行数是否不为1
    // 是:抛出ServiceException
    
    // 判断父级类别的isParent是否为0
    // 是:调用categoryMapper.updateIsParentById()方法,将父级类别的isParent修改为1,获取返回的受影响的行数
    // 判断返回的受影响的行数是否不为1
    // 是:抛出ServiceException
}

要实现以上业务,需要先在持久层完成“根据id查询类别信息”的功能,则在CategorySimpleVO中添加private Integer depth;属性(原getByName()方法对应的查询也作对应的修改,虽然不是必须的)。

然后,还需要在CategorySimpleVO中补充private Integer isParent;属性,并且,必须在接下的查询中,查出此值。

然后CategeoryMapper接口中添加:

CategorySimpleVO getById(Long id);

然后在CategoryMapper.xml中配置以上方法映射的SQL:

<!-- CategorySimpleVO getById(Long id); -->
<select id="getById" resultMap="SimpleResultMap">
    select id, depth from pms_category where id=#{id}
</select>

完成后,还需要在CategoryMapperTests中添加2个测试,以检验以上功能是否正常运行:

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testGetByIdSuccessfully() {
    // 测试数据
    Long id = 1L;
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行查询
        CategorySimpleVO category = mapper.getById(id);
        // 断言查询结果不为null
        assertNotNull(category);
    });
}

@Test
@Sql({"classpath:truncate.sql"})
public void testGetByIdFailBecauseNotFound() {
    // 测试数据
    Long id = -1L;
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行查询
        CategorySimpleVO category = mapper.getById(id);
        // 断言查询结果为null
        assertNull(category);
    });
}

CategoryMapper接口中添加:

int updateIsParentById(@Param("id") Long id, @Param("isParent") Integer isParent);

然后在CategoryMapper.xml中配置以上方法映射的SQL:

<!-- int updateIsParentById(@Param("id") Long id, @Param("isParent") Integer isParent); -->
<update id="updateIsParentById">
    update pms_category set is_parent=#{isParent} where id=#{id}
</update>

完成后,还需要在CategoryMapperTests中添加2个测试,以检验以上功能是否正常运行:

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testUpdateIsParentByIdSuccessfully() {
    // 测试数据
    Long id = 1L;
    Integer isParent = 1;
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行测试
        int rows = mapper.updateIsParentById(id, isParent);
        // 断言受影响的行数为1
        assertEquals(1, rows);
    });
}

@Test
@Sql({"classpath:truncate.sql"})
public void testUpdateIsParentByIdFailBecauseNotFound() {
    // 测试数据
    Long id = -1L;
    Integer isParent = 1;
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行测试
        int rows = mapper.updateIsParentById(id, isParent);
        // 断言受影响的行数为0
        assertEquals(0, rows);
    });
}

在实现业务逻辑之前,还需要创建自定义的异常类型,由于后续还有不少需要被多个Module共同使用的类、接口等,所以,此异常类型和后续可能被共用的类、接口都应该放在一个公共的Module中,则在Project下创建csmall-common这个新的Module,创建成功后,需要:

csmall-common的根包下创建ex.ServiceException类:

public class ServiceException extends RuntimeException {
	// 暂时不加构造方法
}

然后,在csmall-product-webapi中的CategoryServiceImpl中实现业务:

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

import cn.tedu.csmall.common.ex.ServiceException;
import cn.tedu.csmall.pojo.dto.CategoryAddNewDTO;
import cn.tedu.csmall.pojo.entity.Category;
import cn.tedu.csmall.pojo.vo.CategorySimpleVO;
import cn.tedu.csmall.product.service.ICategoryService;
import cn.tedu.csmall.product.webapi.mapper.CategoryMapper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service
public class CategoryServiceImpl implements ICategoryService {

    @Autowired
    CategoryMapper categoryMapper;

    @Override
    public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
        // 从参数中取出尝试添加的类别的名称
        String name = categoryAddNewDTO.getName();
        // 调用categoryMapper.getByName()方法查询
        CategorySimpleVO queryResult = categoryMapper.getByName(name);
        // 判断查询结果是否不为null
        if (queryResult != null) {
            // 是:抛出ServiceException
            throw new ServiceException();
        }

        // 从参数中取出父级类别的id:parentId
        Long parentId = categoryAddNewDTO.getParentId();
        // 判断parentId是否为0,当前尝试新增的类别的depth默认为1
        Integer depth = 1;
        CategorySimpleVO parentCategory = null;
        if (parentId != 0) {
            // 否:此次尝试添加的不是一级类别,则应该存在父级类别,调用categoryMapper.getById()方法查询父级类别的信息
            parentCategory = categoryMapper.getById(parentId);
            // -- 判断查询结果是否为null
            if (parentCategory == null) {
                // -- 是:抛出ServiceException
                throw new ServiceException();
            }
            // -- 否:当前depth >>> 父级depth + 1
            depth = parentCategory.getDepth() + 1;
        }

        // 创建Category对象
        Category category = new Category();
        // 调用BeanUtils.copyProperties()将参数对象中的属性值复制到Category对象中
        BeanUtils.copyProperties(categoryAddNewDTO, category);
        // 补全Category对象中的属性值:depth >>> 前序运算结果
        category.setDepth(depth);
        // 补全Category对象中的属性值:enable >>> 1(默认即启用)
        category.setEnable(1);
        // 补全Category对象中的属性值:isParent >>> 0
        category.setIsParent(0);
        // 补全Category对象中的属性值:gmtCreate, gmtModified >>> LocalDateTime.now()
        LocalDateTime now = LocalDateTime.now();
        category.setGmtCreate(now);
        category.setGmtModified(now);
        // 调用categoryMapper.insert(Category)插入类别数据,获取返回的受影响的行数
        int rows = categoryMapper.insert(category);
        // 判断返回的受影响的行数是否不为1
        if (rows != 1) {
            // 是:抛出ServiceException
            throw new ServiceException();
        }

        // 判断父级类别的isParent是否为0
        // 以下判断条件有部分多余,但不会报错
        if (parentId != 0 && parentCategory != null && parentCategory.getIsParent() == 0) {
            // 是:调用categoryMapper.updateIsParentById()方法,将父级类别的isParent修改为1,获取返回的受影响的行数
            rows = categoryMapper.updateIsParentById(parentId, 1);
            // 判断返回的受影响的行数是否不为1
            if (rows != 1) {
                // 是:抛出ServiceException
                throw new ServiceException();
            }
        }
    }

}

9.3. 测试

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

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

import cn.tedu.csmall.common.ex.ServiceException;
import cn.tedu.csmall.pojo.dto.CategoryAddNewDTO;
import cn.tedu.csmall.product.service.ICategoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
public class CategoryServiceTests {

    @Autowired
    ICategoryService service;

    @Test
    @Sql("classpath:truncate.sql")
    public void testAddNewSuccessfully() {
        // 测试数据
        CategoryAddNewDTO category = new CategoryAddNewDTO();
        category.setName("大屏智能手机");
        category.setParentId(0L);
        category.setIcon("未上传类别图标");
        category.setKeywords("未设置关键字");
        category.setSort(88);
        category.setIsDisplay(1);
        // 断言不会抛出异常
        assertDoesNotThrow(() -> {
            // 执行测试
            service.addNew(category);
        });
    }

    @Test
    @Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
    public void testAddNewFailBecauseNameDuplicate() {
        // 测试数据
        CategoryAddNewDTO category = new CategoryAddNewDTO();
        category.setName("类别001");
        // 断言不会抛出异常
        assertThrows(ServiceException.class, () -> {
            // 执行测试
            service.addNew(category);
        });
    }

    @Test
    @Sql({"classpath:truncate.sql"})
    public void testAddNewFailBecauseParentNotFound() {
        // 测试数据
        CategoryAddNewDTO category = new CategoryAddNewDTO();
        category.setName("类别001");
        category.setParentId(-1L);
        // 断言不会抛出异常
        assertThrows(ServiceException.class, () -> {
            // 执行测试
            service.addNew(category);
        });
    }

}

目前,由于csmall-product-service没有添加相关依赖,所以,并不能直接在接口中使用@Transactional注解(因为尚不可识别),则应该在此Module中添加依赖:

<!-- Mybatis Spring Boot:Mybatis及对Spring Boot的支持 -->
<!-- 仅需要保留spring-jdbc,使得业务接口可以使用@Transactional注解 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-autoconfigure</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </exclusion>
        <exclusion>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
        </exclusion>
    </exclusions>
</dependency>

目前,在业务实现中,视为“错误”时始终抛出ServiceException,且没有任何异常信息,是不合理的!

在略大规模的项目中,“错误”的种类可能较大,如果为每一种“错误”都创建一个对应的异常,则需要创建的异常类型就比较多,但是,这些异常类除了名称不一样以外,几乎没有不同,所以,存在不利于管理和维护的问题。

其实,也可以只使用1个异常类型(或者少量异常类型),但是,每次抛出时,也需要明确的表示“是哪一种错误”,则可以在异常类型中添加“业务状态码”。

则首先需要业务状态码的类型,可以从前缀项目中复制State文件,例如:

package cn.tedu.csmall.common.web;

public enum State {

    OK(20000),
    ERR_CATEGORY_NAME_DUPLICATE(40100), // 客户端引起的--类别--名称冲突(被占用)
    ERR_CATEGORY_NOT_FOUND(40101), // 客户端引起的--类别--数据不存在(查询参数值不正确)
    ERR_INSERT(50000), // 服务端引起的--插入数据错误
    ERR_UPDATE(50001); // 服务端引起的--更新数据错误

    private Integer value;

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

    public Integer getValue() {
        return value;
    }

}

然后,在ServiceException中,自定义构造方法,强制要求传入State stateString message参数,并且,为State类型参数提供公有的获取值的方法:

public class ServiceException extends RuntimeException {
    private State state;
    
    public ServiceException(State state, String message) {
        super(message);
        if (state == null) {
            throw new IllegalArgumentException("使用ServiceException必须指定错误时的业务状态码!");
        }
        this.state  = state;
    }
    
    public State getState() {
        return state;
    }
}

在后续抛出异常时,应该传入State stateString message

throw new ServiceException(State.ERR_CATEGORY_NOT_FOUND, "添加类别失败,父级类别不存在!");

当捕获到异常时,就可以调用方法,获取到异常中封装的State值,例如:

State state = ex.getState();
state.getValue();

10. 类别管理–添加类别–控制器层

10.1. 处理跨域(一次性配置)

csmall-product-webapi的根包下config包下创建SpringMvcConfiguration类,实现WebMvcConfigururer接口,重写其中的方法,以解决跨域问题:

package cn.tedu.csmall.product.webapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class SpringMvcConfiguration implements WebMvcConfigurer {

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

}

提示:以上代码从此前的案例中复制过来即可。

10.2. 控制器类与处理请求的方法

先将此前项目中的JsonResult复制到csmall-common的根包下的web子包中(需要在csmall-common中补充依赖lombok)。

csmall-product-webapi的根包下创建controller.CategoryController类,在类上添加@RestController@RequestMapping(value = "/categories", produces = "application/json; charset=utf-8")这2个注解:

@RestController
@RequestMapping(value = "/categories", produces = "application/json; charset=utf-8")
public class CategoryController {
    
}

然后,在类中添加处理请求的方法:

@Autowired
private ICategoryService categoryService;

@PostMapping("/add-new")
public JsonResult<Void> addNew(CategoryAddNewDTO categoryAddNewDTO) {
    categoryService.addNew(categoryAddNewDTO);
    return JsonResult.ok();
}

10.3. 控制器层测试

csmall-product-webapi的测试的根包下创建controller.CategoryControllerTests测试类,编写并执行测试:

package cn.tedu.csmall.product.webapi.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc
public class CategoryControllerTests {
    @Autowired
    MockMvc mockMvc;

    @Test
    @Sql("classpath:truncate.sql")
    public void testAddNewSuccessfully() throws Exception {
        // 准备测试数据,不需要封装,应该全部声明为String类型
        String name = "水果";
        String parentId = "0"; // 即使目标类型是Long,参数值也不要加L
        String keywords = "水果的关键字是啥";
        String sort = "66";
        String icon = "图标待定";
        String isDisplay = "1";
        // 请求路径,不需要写协议、服务器主机和端口号
        String url = "/categories/add-new";
        // 执行测试
        // 以下代码相对比较固定
        mockMvc.perform( // 执行发出请求
                MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                        .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                        .param("name", name) // 请求参数,有多个时,多次调用param()方法
                        .param("parentId", parentId)
                        .param("keywords", keywords)
                        .param("icon", icon)
                        .param("sort", sort)
                        .param("isDisplay", isDisplay)
                        .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
                .andExpect( // 预判结果,类似断言
                        MockMvcResultMatchers
                                .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                                .value(200)) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
                .andDo( // 需要执行某任务
                        MockMvcResultHandlers.print()); // 打印日志
    }


}

10.4. 处理异常

csmall-common中添加依赖项:

<!-- Spring Boot Web:支持Spring MVC -->
<!-- 需要使用到@RestControllerAdvice等注解 -->
<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>

csmall-common根包下的ex包下创建handler.GlobalExceptionHandler,并在此类中处理异常:

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ServiceException.class)
    public JsonResult<Void> handleServiceException(ServiceException ex) {
        return JsonResult.fail(ex.getState(), ex.getMessage());
    }

}

完成后,使用错误的测试数据时,会发现根本不会处理异常,是因为在csmall-product-webapi中默认执行的组件扫描不会扫描到以上GlobalExceptionHandler所在的包,为了解决此问题,应该先在csmall-common的根包下创建config.CsmallCommonConfiguration类,此类应该是配置类,且配置组件扫描:

package cn.tedu.csmall.common.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("cn.tedu.csmall.common.ex.handler")
public class CsmallCommonConfiguration {
}

然后,在csmall-product-webapi的启动类引用此配置类:

package cn.tedu.csmall.product.webapi;

@SpringBootApplication
@Import({CsmallCommonConfiguration.class}) // 新增
public class CsmallProductWebapiApplication {

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

}

接下来,即可执行测试:

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testAddNewFailBecauseNameDuplicate() throws Exception {
    // 准备测试数据,不需要封装,应该全部声明为String类型
    String name = "类别001";
    String parentId = "0"; // 即使目标类型是Long,参数值也不要加L
    String keywords = "水果的关键字是啥";
    String sort = "66";
    String icon = "图标待定";
    String isDisplay = "1";
    // 请求路径,不需要写协议、服务器主机和端口号
    String url = "/categories/add-new";
    // 执行测试
    // 以下代码相对比较固定
    mockMvc.perform( // 执行发出请求
            MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                    .param("name", name) // 请求参数,有多个时,多次调用param()方法
                    .param("parentId", parentId)
                    .param("keywords", keywords)
                    .param("icon", icon)
                    .param("sort", sort)
                    .param("isDisplay", isDisplay)
                    .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
            .andExpect( // 预判结果,类似断言
                    MockMvcResultMatchers
                            .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                            .value(State.ERR_CATEGORY_NAME_DUPLICATE.getValue())) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
            .andDo( // 需要执行某任务
                    MockMvcResultHandlers.print()); // 打印日志
}

@Test
@Sql({"classpath:truncate.sql"})
public void testAddNewFailBecauseParentNotFound() throws Exception {
    // 准备测试数据,不需要封装,应该全部声明为String类型
    String name = "类别001";
    String parentId = "-1"; // 即使目标类型是Long,参数值也不要加L
    String keywords = "水果的关键字是啥";
    String sort = "66";
    String icon = "图标待定";
    String isDisplay = "1";
    // 请求路径,不需要写协议、服务器主机和端口号
    String url = "/categories/add-new";
    // 执行测试
    // 以下代码相对比较固定
    mockMvc.perform( // 执行发出请求
            MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                    .param("name", name) // 请求参数,有多个时,多次调用param()方法
                    .param("parentId", parentId)
                    .param("keywords", keywords)
                    .param("icon", icon)
                    .param("sort", sort)
                    .param("isDisplay", isDisplay)
                    .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
            .andExpect( // 预判结果,类似断言
                    MockMvcResultMatchers
                            .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                            .value(State.ERR_CATEGORY_NOT_FOUND.getValue())) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
            .andDo( // 需要执行某任务
                    MockMvcResultHandlers.print()); // 打印日志
}

10.5 验证请求参数格式的基本有效性

关于Validation框架的基本使用

先在csmall-product-webapi中添加依赖(如果已经添加,则不需要重复添加):

<!-- Spring Boot Validation:验证请求参数的基本格式 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

CategoryController处理请求的方法的参数之前添加@Validated / @Valid注解:

@PostMapping("/add-new")
// ===== 在以下方法的参数前添加@Validated / @Valid注解 =====
public JsonResult<Void> addNew(@Validated CategoryAddNewDTO categoryAddNewDTO) {
    categoryService.addNew(categoryAddNewDTO);
    return JsonResult.ok();
}

由于CategoryAddNewDTO等类在csmall-pojo模块中的,要在此类中添加@NotNull等注解,则必须在csmall-pojo中添加依赖:

<!-- Spring Boot Validation:验证请求参数的基本格式 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <scope>provided</scope>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-el</artifactId>
        </exclusion>
    </exclusions>
</dependency>

然后,CategoryAddNewDTOname属性上添加@NotNull约束(其它的约束等到测试通过之后再补充):

@Data
public class CategoryAddNewDTO implements Serializable {

    @NotNull(message = "添加类别失败,必须填写类别名称!") // 新增
    private String name;
    
    // ===== 其它原有代码 =====
    
}

State中添加对应“请求参数格式错误”的枚举值:

public enum State {

    OK(20000),
    // ===== 下行为新增 ======
    ERR_BAD_REQUEST(40000), // 客户端引起的--请求参数格式错误
    
    // ===== 其它原有代码 =====
}

GlobalExceptionHandler中添加新的处理异常的方法:

@ExceptionHandler(BindException.class)
public JsonResult<Void> handleBindException(BindException ex) {
    List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
    StringBuffer stringBuffer = new StringBuffer();
    for (FieldError fieldError : fieldErrors) {
        stringBuffer.append(";");
        stringBuffer.append(fieldError.getDefaultMessage());
    }
    String message = stringBuffer.substring(1);
    return JsonResult.fail(State.ERR_BAD_REQUEST, message);
}

最后,添加测试:

@Test
@Sql({"classpath:truncate.sql"})
public void testAddNewFailBecauseBadRequest() throws Exception {
    // 准备测试数据,注意:此次没有提交必要的name属性值
    String parentId = "0"; // 即使目标类型是Long,参数值也不要加L
    String keywords = "水果的关键字是啥";
    String sort = "66";
    String icon = "图标待定";
    String isDisplay = "1";
    // 请求路径,不需要写协议、服务器主机和端口号
    String url = "/categories/add-new";
    // 执行测试
    // 以下代码相对比较固定
    mockMvc.perform( // 执行发出请求
            MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                    // .param("name", name) // 注意:此处不提交必要的name属性
                    .param("parentId", parentId)
                    .param("keywords", keywords)
                    .param("icon", icon)
                    .param("sort", sort)
                    .param("isDisplay", isDisplay)
                    .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
            .andExpect( // 预判结果,类似断言
                    MockMvcResultMatchers
                            .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                            .value(State.ERR_BAD_REQUEST.getValue())) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
            .andDo( // 需要执行某任务
                    MockMvcResultHandlers.print()); // 打印日志
}

测试成功后,应该在CategoryAddNewDTO的各属性中补充更多的、必要的注解进行约束,并且,添加更多约束后,还应该编写更多的测试。

13. 类别管理–根据父级类别查询其所有子级类别–持久层

13.1. 规划SQL语句

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

select * from pms_category where parent_id=? and enable=1 and is_display=1 order by sort desc, gmt_modified desc;

关于字段列表,应该包括:

id, name, sort, icon, is_parent

13.2. 抽象方法(可能需要创建VO类)

csmall-pojo的根包下的vo包下创建CategorySimpleListItemVO类,封装以上设计的5个字段对应的属性:

package cn.tedu.csmall.pojo.vo;

import lombok.Data;

import java.io.Serializable;

@Data
public class CategorySimpleListItemVO implements Serializable {

    private Long id;
    private String name;
    private Integer sort;
    private String icon;
    private Integer isParent;

}

CategoryMapper接口中添加:

List<CategorySimpleListItemVO> listByParentId(Long parentId);

13.3. 在XML中配置SQL

CategoryMapper.xml中添加配置:

<!-- List<CategorySimpleListItemVO> listByParentId(Long parentId); -->
<select id="listByParentId" resultMap="SimpleListResultMap">
    select
        <include refid="SimpleListQueryFields" />
    from
        pms_category
    where
        parent_id=#{id} and enable=1 and is_display=1
    order by
        sort desc, gmt_modified desc
</select>

<sql id="SimpleListQueryFields">
    <if test="true">
        id, name, sort, icon, is_parent
    </if>
</sql>

<resultMap id="SimpleListResultMap" type="cn.tedu.csmall.pojo.vo.CategorySimpleListItemVO">
    <id column="id" property="id" />
    <result column="name" property="name" />
    <result column="sort" property="sort" />
    <result column="icon" property="icon" />
    <result column="is_parent" property="isParent" />
</resultMap>

13.4. 测试

本次测试推荐使用人工检查查询结果。

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testListByParentId() {
    // 测试数据
    Long parentId = 1L;
    // 执行测试,获取查询结果
    List<CategorySimpleListItemVO> list = mapper.listByParentId(parentId);
    // 查看结果
    System.out.println("查询结果数量:" + list.size());
    for (CategorySimpleListItemVO item : list) {
        System.out.println(item);
    }
}

14. 类别管理–根据父级类别查询其所有子级类别–业务逻辑层

14.1. 接口和抽象方法

ICategoryService中添加:

List<CategorySimpleListItemVO> listByParentId(Long parentId);

14.2. 实现

CategoryServiceImpl中直接调用categoryMapper执行查询并返回即可。

@Override
public List<CategorySimpleListItemVO> listByParentId(Long parentId) {
    return categoryMapper.listByParentId(parentId);
}

14.3. 测试

与持久层测试类似。

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testListByParentId() {
    // 测试数据
    Long parentId = 1L;
    // 执行测试,获取查询结果
    List<CategorySimpleListItemVO> list = service.listByParentId(parentId);
    // 查看结果
    System.out.println("查询结果数量:" + list.size());
    for (CategorySimpleListItemVO item : list) {
        System.out.println(item);
    }
}

15. 类别管理–根据父级类别查询其所有子级类别–控制器层

CategoryController中添加:

@GetMapping("/list-by-parent")
public JsonResult<List<CategorySimpleListItemVO>> listByParentId(Long parentId) {
    // 调用service并将结果封装到JsonResult中
    List<CategorySimpleListItemVO> list = categoryService.listByParentId(parentId);
    return JsonResult.ok(list);
}

CategoryControllerTests中测试:

@Test
@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
public void testListByParentId() throws Exception {
    // 准备测试数据,注意:此次没有提交必要的name属性值
    String parentId = "0";
    // 请求路径,不需要写协议、服务器主机和端口号
    String url = "/categories/list-by-parent";
    // 执行测试
    // 以下代码相对比较固定
    mockMvc.perform( // 执行发出请求
            MockMvcRequestBuilders.get(url) // 根据请求方式决定调用的方法
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                    .param("parentId", parentId)
                    .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
            .andExpect( // 预判结果,类似断言
                    MockMvcResultMatchers
                            .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                            .value(State.OK.getValue())) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
            .andDo( // 需要执行某任务
                    MockMvcResultHandlers.print()); // 打印日志
}

16. 控制器层的测试

关于控制器层,也可以写测试方式进行测试,在Spring Boot项目中,可以使用MockMvc进行模拟测试,例如:

package cn.tedu.boot.demo.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc // 自动配置MockMvc
public class AdminControllerTests {

    @Autowired
    MockMvc mockMvc; // Mock:模拟

    @Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
    @Test
    public void testLoginSuccessfully() throws Exception {
        // 准备测试数据,不需要封装
        String username = "admin001";
        String password = "123456";
        // 请求路径,不需要写协议、服务器主机和端口号
        String url = "/admins/login";
        // 执行测试
        // 以下代码相对比较固定
        mockMvc.perform( // 执行发出请求
                MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                .param("username", username) // 请求参数,有多个时,多次调用param()方法
                .param("password", password)
                .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
                .andExpect( // 预判结果,类似断言
                        MockMvcResultMatchers
                                .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                                .value(200)) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
                .andDo( // 需要执行某任务
                        MockMvcResultHandlers.print()); // 打印日志
    }

}

执行以上测试时,并不需要启动当前项目即可测试。

17. 处理登录

17.1. 开发流程

正常的项目开发流程大致是:

17.2. 管理员登录-持久层

17.2.1. 创建或配置

如果是整个项目第1次开发持久层,在Spring Boot项目中,需要配置:

如果第1次处理某种类型数据的持久层访问,需要:

本次需要开发的“管理员登录”并不需要再做以上操作

17.2.2. 规划需要执行的SQL语句

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

select * from ams_admin where username=?

由于在ams_admin表中有大量字段,同时,不允许使用星号表示字段列表,则以上SQL语句应该细化为:

select id, username, password, nickname, avatar, is_enable from ams_admin where username=?

提示:理论上,还应该查出login_count,当登录成功后,还应该更新login_countgmt_last_login等数据,此次暂不考虑。

17.2.3. 在接口中添加抽象方法(含创建必要的VO类)

提示:所有的查询结果,都应该使用VO类,而不要使用实体类,根据阿里的开发规范,每张数据表中都应该有idgmt_creategmt_modified这3个字段,而gmt_creategmt_modified这2个字段都是用于特殊情况下排查问题的,一般情况下均不会使用,所以,如果使用实体类,必然存在多余的属性,同时,由于不使用星号作为字段列表,则一般也不会查询这2个字段的值,会导致实体类对象中永远至少存在2个属性为null

根据以上提示,以前已经写好的getByUsername()是不规范的,应该调整已存在此方法,本次并不需要添加新的抽象方法。

则先创建cn.tedu.boot.demo.pojo.vo.AdminSimpleVO类,添加此次查询时需要的属性:

package cn.tedu.boot.demo.pojo.vo;

@Data
public class AdminSimpleVO implements Serializable {
    private Long id;
    private String username;
    private String password; 
    private String nickname; 
    private String avatar;
    private Integer isEnable;
}

然后,在AdminMapper接口文件中,将原有的Admin getByUsername(String username);改为:

AdminSimpleVO getByUsername(String username);

注意:一旦修改了原有代码,则调用了原方法的代码都会出现错误,包括:

应该及时修改错误的代码,但是,由于此时还未完成SQL配置,所以,相关代码暂时并不能运行。

17.2.4. 在XML中配置SQL

AdminMapper.xml中,需要调整:

<select id="getByUsername" resultMap="BaseResultMap">
    select
        <include refid="BaseQueryFields" />
    from
         ams_admin
    where
         username=#{username}
</select>

<sql id="BaseQueryFields">
    <if test="true">
        id,
        username,
        password,
        nickname,
        avatar,
        is_enable
    </if>
</sql>

<resultMap id="BaseResultMap" type="cn.tedu.boot.demo.pojo.vo.AdminSimpleVO">
    <id column="id" property="id" />
    <result column="username" property="username" />
    <result column="password" property="password" />
    <result column="nickname" property="nickname" />
    <result column="avatar" property="avatar" />
    <result column="is_enable" property="isEnable" />
</resultMap>

17.2.5. 编写并执行测试

此次并不需要编写新的测试,使用原有的测试即可!

注意:由于本次是修改了原“增加管理员”就已经使用的功能,应该检查原功能是否可以正常运行。

17.3. 管理员登录-业务逻辑层

17.3.1. 创建

如果第1次处理某种类型数据的业务逻辑层访问,需要:

本次需要开发的“管理员登录”并不需要再做以上操作

17.3.2. 在接口中添加抽象方法(含创建必要的DTO类)

在设计抽象方法时,如果参数的数量超过1个,且多个参数具有相关性(是否都是客户端提交的,或是否都是控制器传递过来的等),就应该封装!

在处理登录时,需要客户端提交用户名和密码,则可以将用户名、密码封装起来:

package cn.tedu.boot.demo.pojo.dto;

@Data
public class AdminLoginDTO implements Serializable {
    private String username;
    private String password;
}

IAdminService中添加抽象方法:

AdminSimpleVO login(AdminLoginDTO adminLoginDTO);

17.3.3. 在实现类中设计(打草稿)业务流程与业务逻辑(含创建必要的异常类)

此次业务执行过程中,可能会出现:

关于用户名不存在的问题,可以自行创建新的异常类,例如,在cn.tedu.boot.demo.ex包下创建UserNotFoundException类表示用户数据不存在的异常,继承自ServiceException,且添加5款基于父类的构造方法:

package cn.tedu.boot.demo.ex;

public class UserNotFoundException extends ServiceException {
    // 自动生成5个构造方法
}

再创建UserStateException表示用户状态异常:

package cn.tedu.boot.demo.ex;

public class UserStateException extends ServiceException {
    // 自动生成5个构造方法
}

再创建PasswordNotMatchException表示密码错误异常:

package cn.tedu.boot.demo.ex;

public class PasswordNotMatchException extends ServiceException {
    // 自动生成5个构造方法
}

登录过程大致是:

public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) {
    // 通过参数得到尝试登录的用户名
    // 调用adminMapper.getByUsername()方法查询
    // 判断查询结果是否为null
    // 是:表示用户名不存在,则抛出UserNotFoundException异常
    
    // 【如果程序可以执行到此步,则可以确定未抛出异常,即查询结果不为null】
    // 【以下可视为:存在与用户名匹配的管理员数据】
    // 判断查询结果中的isEnable属性值是否不为1
    // 是:表示此用户状态是【禁用】的,则抛出UserStateException异常
    
    // 【如果程序可以执行到此步,表示此用户状态是【启用】的】
    // 从参数中取出此次登录时客户端提交的密码
    // 调用PasswordEncoder对象的matches()方法,对客户端提交的密码和查询结果中的密码进行验证
    // 判断以上验证结果
    // true:密码正确,视为登录成功
    // -- 将查询结果中的password、isEnable设置为null,避免响应到客户端
    // -- 返回查询结果
    // false:密码错误,视为登录失败,则抛出PasswordNotMatchException异常
}

17.3.4. 在实现类中实现业务

AdminServiceImpl中重写接口中新增的抽象方法:

@Override
public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("即将处理管理员登录的业务,尝试登录的管理员信息:{}", adminLoginDTO);
    // 通过参数得到尝试登录的用户名
    String username = adminLoginDTO.getUsername();
    // 调用adminMapper.getByUsername()方法查询
    AdminSimpleVO queryResult = adminMapper.getByUsername(username);
    // 判断查询结果是否为null
    if (queryResult == null) {
        // 是:表示用户名不存在,则抛出UserNotFoundException异常
        log.warn("登录失败,用户名不存在!");
        throw new UserNotFoundException("登录失败,用户名不存在!");
    }

    // 【如果程序可以执行到此步,则可以确定未抛出异常,即查询结果不为null】
    // 【以下可视为:存在与用户名匹配的管理员数据】
    // 判断查询结果中的isEnable属性值是否不为1
    if (queryResult.getIsEnable() != 1) {
        // 是:表示此用户状态是【禁用】的,则抛出UserStateException异常
        log.warn("登录失败,此账号已经被禁用!");
        throw new UserNotFoundException("登录失败,此账号已经被禁用!");
    }

    // 【如果程序可以执行到此步,表示此用户状态是【启用】的】
    // 从参数中取出此次登录时客户端提交的密码
    String rawPassword = adminLoginDTO.getPassword();
    // 调用PasswordEncoder对象的matches()方法,对客户端提交的密码和查询结果中的密码进行验证
    boolean matchResult = passwordEncoder.matches(rawPassword, queryResult.getPassword());
    // 判断以上验证结果
    if (!matchResult) {
        // false:密码错误,视为登录失败,则抛出PasswordNotMatchException异常
        log.warn("登录失败,密码错误!");
        throw new PasswordNotMatchException("登录失败,密码错误!");
    }

    // 密码正确,视为登录成功
    // 将查询结果中的password、isEnable设置为null,避免响应到客户端
    queryResult.setPassword(null);
    queryResult.setIsEnable(null);
    // 返回查询结果
    log.debug("登录成功,即将返回:{}", queryResult);
    return queryResult;
}

17.3.5. 编写并执行测试

AdminServiceTests中添加测试:

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginSuccessfully() {
    // 测试数据
    String username = "admin001";
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行测试
        AdminSimpleVO adminSimpleVO = service.login(adminLoginDTO);
        log.debug("登录成功:{}", adminSimpleVO);
        // 断言测试结果
        assertEquals(1L, adminSimpleVO.getId());
        assertNull(adminSimpleVO.getPassword());
        assertNull(adminSimpleVO.getIsEnable());
    });
}

@Sql({"classpath:truncate.sql"})
@Test
public void testLoginFailBecauseUserNotFound() {
    // 测试数据
    String username = "admin001";
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出UserNotFoundException
    assertThrows(UserNotFoundException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginFailBecauseUserDisabled() {
    // 测试数据
    String username = "admin005"; // 通过SQL脚本插入的此数据,is_enable为0
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出UserStateException
    assertThrows(UserStateException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginFailBecausePasswordNotMatch() {
    // 测试数据
    String username = "admin001";
    String password = "000000000000000000";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出PasswordNotMatchException
    assertThrows(PasswordNotMatchException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

17.4. 管理员登录-控制器层

17.4.1. 创建

如果是整个项目第1次开发控制器层,需要:

如果第1次处理某种类型数据的控制器层访问,需要:

本次需要开发的“管理员登录”并不需要再做以上操作

17.4.2. 添加处理请求的方法,验证请求参数的基本有效性

AdminLoginDTO的各属性上添加验证基本有效性的注解,例如:

package cn.tedu.boot.demo.pojo.dto;

import lombok.Data;

import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
public class AdminLoginDTO implements Serializable {
    
    @NotNull(message = "登录失败,请提交用户名!") // 新增
    private String username;
    
    @NotNull(message = "登录失败,请提交密码!") // 新增
    private String password;
    
}

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

@RequestMapping("/login") // 暂时使用@RequestMapping,后续改成@PostMapping
public JsonResult<AdminSimpleVO> login(@Validated AdminLoginDTO adminLoginDTO) {
    AdminSimpleVO adminSimpleVO = adminService.login(adminLoginDTO);
    return JsonResult.ok(adminSimpleVO);
}

17.4.3. 处理异常(按需)

先在State中添加新创建的异常对应枚举:

public enum State {

    OK(200),
    ERR_USERNAME(201),
    ERR_PASSWORD(202),
    ERR_STATE(203), // 新增
    ERR_BAD_REQUEST(400),
    ERR_INSERT(500);
    
    // ===== 原有其它代码 =====
}

GlobalExceptionHandlerhandleServiceException()方法中添加更多分支,针对各异常进行判断,并响应不同结果:

@ExceptionHandler(ServiceException.class)
public JsonResult<Void> handleServiceException(ServiceException e) {
    if (e instanceof UsernameDuplicateException) {
        return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
    } else if (e instanceof UserNotFoundException) {				// 从此行起,是新增的
        return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
    } else if (e instanceof UserStateException) {
        return JsonResult.fail(State.ERR_STATE, e.getMessage());
    } else if (e instanceof PasswordNotMatchException) {
        return JsonResult.fail(State.ERR_PASSWORD, e.getMessage());	// 新增结束标记
    } else {
        return JsonResult.fail(State.ERR_INSERT, e.getMessage());
    }
}

17.4.4. 测试

启动项目,暂时通过 http://localhost:8080/admins/login?username=admin001&password=123456 类似的URL测试访问。注意:在测试访问之前,必须保证数据表中的数据状态是符合预期的。