本文总结了尚庭公寓项目中遇到的技术知识点。

1.项目目录结构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lease
├── common(公共模块——工具类、公用配置等)
│ ├── pom.xml (包含公共模块编写所需的全部依赖)
│ └── src
├── model(数据模型——与数据库相对应地实体类)
│ ├── pom.xml (包含数据模型编写所需的全部依赖)
│ └── src
├── web(Web模块)
│ ├── pom.xml (包含common和model这个两个模块的依赖,以及该模块下所需的其他依赖)
│ ├── web-admin(后台管理系统Web模块——包含mapper、service、controller)
│ │ ├── pom.xml (做为web模块的子类项目)
│ │ └── src
│ └── web-app(移动端Web模块——包含mapper、service、controller)
│ ├── pom.xml
│ └── src
└── pom.xml (统一管理子模块所有的依赖版本号)

根模块的pom.xml文件中对相关依赖的版本号进行统一管理。

common、model和web模块均以lease根模块为父级项目

1
2
3
4
5
<parent>
<groupId>com.ldy</groupId>
<artifactId>lease</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

而common和model模块做为公共模块,提供给web模块进行依赖调用

web模块的pom.xml文件如下所示

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.ldy</groupId>
<artifactId>common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.ldy</groupId>
<artifactId>model</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

各个模块所需的依赖清晰规划好,可以减少依赖的冗余和减少重复配置

2.实体类和接口的定义要点

  • 实体类中的公共字段(例如idcreate_timeupdate_timeis_deleted)抽取到一个基类,进行统一管理,然后让各实体类继承该基类。

  • 实体类中的状态字段(例如status)或类型字段(例如type),全部使用枚举类型。

  • 状态(类型)字段,在数据库中通常用一个数字表示一个状态(类型)。例如:订单状态(1:待支付,2:待发货,3:待收货,4:已收货,5:已完结)。若实体类中对应的字段也用数字类型,例如int,那么程序中就会有大量的如下代码:

    1
    2
    3
    4
    5
    order.setStatus(1);

    if (order.getStatus() == 1) {
    order.setStatus(2);
    }

    这些代码后期维护起来会十分麻烦,所以本项目中所有的此类字段均使用枚举类型。例如上述订单状态可定义为以下枚举:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public enum Status {
    CANCEL(0, "已取消"),
    WAIT_PAY(1, "待支付"),
    WAIT_TRANSFER(2, "待发货"),
    WAIT_RECEIPT(3, "待收货"),
    RECEIVE(4, "已收货"),
    COMPLETE(5, "已完结");

    private final Integer value;
    private final String desc;

    public Integer value() {
    return value;
    }
    public String desc() {
    return desc;
    }
    }

    订单实体类中的状态字段定义为Status类型:

    1
    2
    3
    4
    5
    6
    7
    @Data
    public class Order{
    private Integer id;
    private Integer userId;
    private Status status;
    ...
    }

    这样上述代码便可调整为如下效果,后期维护起来会容易许多。

    1
    order.setStatus(Status.WAIT_PAY);
  • 所有的实体类均实现了Serializable接口,方便对实体对象进行缓存。

    因为实现Serializable接口的类可以被序列化,即将对象的状态转换为字节流,以便于存储和传输。在该项目中,是为了让实体类对象缓存到redis当中,当需要从缓存中读取对象时,可以通过反序列化恢复对象状态。

  • 所有的Mapper接口均没有使用@Mapper注解,而是使用配置类中的@MapperScan注解统一扫描。

  • vo(View Object):用于封装或定义接口接收及返回的数据的结构。

  • 统一接口返回的数据结构:为方便前端对接口数据进行处理,统一接口返回数据结构是一个良好的习惯。

    以下是该项目所有接口统一返回的数据结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "code": 200,
    "message": "正常",
    "data": {
    "id": "1",
    "name": "zhangsan",
    "age": 10
    }
    }

    以下是与上述结构相对应的Java类

    • Result

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      @Data
      public class Result<T> {

      //返回码
      private Integer code;

      //返回消息
      private String message;

      //返回数据
      private T data;

      public Result() {
      }

      private static <T> Result<T> build(T data) {
      Result<T> result = new Result<>();
      if (data != null)
      result.setData(data);
      return result;
      }

      public static <T> Result<T> build(T body, ResultCodeEnum resultCodeEnum) {
      Result<T> result = build(body);
      result.setCode(resultCodeEnum.getCode());
      result.setMessage(resultCodeEnum.getMessage());
      return result;
      }

      public static <T> Result<T> ok(T data) {
      return build(data, ResultCodeEnum.SUCCESS);
      }

      public static <T> Result<T> ok() {
      return Result.ok(null);
      }

      public static <T> Result<T> fail() {
      return build(null, ResultCodeEnum.FAIL);
      }

      public static <T> Result<T> fail(Integer code, String message) {
      Result<T> result = build(null);
      result.setCode(code);
      result.setMessage(message);
      return result;
      }
      }
    • ResultCodeEnum

      为方便管理,可将返回码code和返回消息message封装到枚举类。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      @Getter
      public enum ResultCodeEnum {

      SUCCESS(200, "成功"),
      FAIL(201, "失败"),
      PARAM_ERROR(202, "参数不正确"),
      SERVICE_ERROR(203, "服务异常"),
      DATA_ERROR(204, "数据异常"),
      ILLEGAL_REQUEST(205, "非法请求"),
      REPEAT_SUBMIT(206, "重复提交"),
      DELETE_ERROR(207, "请先删除子集"),

      ADMIN_ACCOUNT_EXIST_ERROR(301, "账号已存在"),
      ADMIN_CAPTCHA_CODE_ERROR(302, "验证码错误"),
      ADMIN_CAPTCHA_CODE_EXPIRED(303, "验证码已过期"),
      ADMIN_CAPTCHA_CODE_NOT_FOUND(304, "未输入验证码"),


      ADMIN_LOGIN_AUTH(305, "未登陆"),
      ADMIN_ACCOUNT_NOT_EXIST_ERROR(306, "账号不存在"),
      ADMIN_ACCOUNT_ERROR(307, "用户名或密码错误"),
      ADMIN_ACCOUNT_DISABLED_ERROR(308, "该用户已被禁用"),
      ADMIN_ACCESS_FORBIDDEN(309, "无访问权限"),

      APP_LOGIN_AUTH(501, "未登陆"),
      APP_LOGIN_PHONE_EMPTY(502, "手机号码为空"),
      APP_LOGIN_CODE_EMPTY(503, "验证码为空"),
      APP_SEND_SMS_TOO_OFTEN(504, "验证法发送过于频繁"),
      APP_LOGIN_CODE_EXPIRED(505, "验证码已过期"),
      APP_LOGIN_CODE_ERROR(506, "验证码错误"),
      APP_ACCOUNT_DISABLED_ERROR(507, "该用户已被禁用"),


      TOKEN_EXPIRED(601, "token过期"),
      TOKEN_INVALID(602, "token非法");


      private final Integer code;

      private final String message;

      ResultCodeEnum(Integer code, String message) {
      this.code = code;
      this.message = message;
      }
      }

    枚举类的知识点

    枚举常量的初始化
    每个枚举常量(如 SUCCESS, FAIL, PARAM_ERROR 等)在声明时会调用

    1
    2
    3
    4
    ResultCodeEnum(Integer code, String message) {
    this.code = code;
    this.message = message;
    }

    这个构造函数,并传入相应的参数。例如:
    SUCCESS(200, “成功”):
    调用 ResultCodeEnum(200, “成功”),将 code 设为 200,message 设为 “成功”。
    FAIL(201, “失败”):
    调用 ResultCodeEnum(201, “失败”),将 code 设为 201,message 设为 “失败”。

    注意:

    由于ResultResultCodeEnum中使用@Data@Getter注解,因此需要再common模块中引入lombok依赖。

3.逻辑删除功能

由于数据库中所有表均采用逻辑删除策略,所以查询数据时均需要增加过滤条件is_deleted=0

上述操作虽不难实现,但是每个查询接口都要考虑到,也显得有些繁琐。为简化上述操作,可以使用Mybatis-Plus提供的逻辑删除功能,它可以自动为查询操作增加is_deleted=0过滤条件,并将删除操作转为更新语句。具体配置如下,详细信息可参考官方文档

  • 步骤一:在application.yml中增加如下内容

    1
    2
    3
    4
    5
    6
    mybatis-plus:
    global-config:
    db-config:
    logic-delete-field: flag # 全局逻辑删除的实体字段名(配置后可以忽略不配置步骤二)
    logic-delete-value: 1 # 逻辑已删除值(默认为 1)
    logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  • 步骤二:在实体类中的删除标识字段上增加@TableLogic注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Data
    public class BaseEntity {

    @Schema(description = "主键")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @Schema(description = "创建时间")
    @JsonIgnore
    private Date createTime;

    @Schema(description = "更新时间")
    @JsonIgnore
    private Date updateTime;

    @Schema(description = "逻辑删除")
    @JsonIgnore
    @TableLogic
    @TableField("is_deleted")
    private Byte isDeleted;

    }

    注意

    逻辑删除功能只对Mybatis-Plus自动注入的sql起效,也就是说,对于手动在Mapper.xml文件配置的sql不会生效,需要单独考虑。

该项目中因为is_deleted字段取值符合默认值,且所有实体类均继承BaseEntity,所以可以跳过步骤一,只需在isDeleted属性上添加@TableLogic注解即可。

4.特定字段的忽略和自动填充

  • 特定字段的忽略
    通常情况下接口响应的Json对象中并不需要create_timeupdate_timeis_deleted等字段,这时只需在实体类中的相应字段添加@JsonIgnore注解,该字段就会在序列化时被忽略。

    具体配置如下,详细信息可参考Jackson官方文档

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Data
    public class BaseEntity {

    @Schema(description = "主键")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @Schema(description = "创建时间")
    @JsonIgnore
    @TableField(value = "create_time")
    private Date createTime;

    @Schema(description = "更新时间")
    @JsonIgnore
    @TableField(value = "update_time")
    private Date updateTime;

    @Schema(description = "逻辑删除")
    @JsonIgnore
    @TableField("is_deleted")
    private Byte isDeleted;

    }
  • 自动填充特定字段
    保存或更新数据时,前端通常不会传入isDeletedcreateTimeupdateTime这三个字段,因此我们需要手动赋值。但是数据库中几乎每张表都有上述字段,所以手动去赋值就显得有些繁琐。为简化上述操作,我们可采取以下措施。

    is_deleted字段:可在数据库中设置该字段的默认值为0。

    create_timeupdate_time:可使用mybatis-plus的自动填充功能,所谓自动填充,就是通过统一配置,在插入或更新数据时,自动为某些字段赋值,具体配置如下,详细信息可参考官方文档

    为相关字段配置触发填充的时机,例如create_time需要在插入数据时填充,而update_time需要在插入和更新数据时填充。具体配置如下,观察@TableField注解中的fill属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Data
    public class BaseEntity {

    @Schema(description = "主键")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @Schema(description = "创建时间")
    @JsonIgnore
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private Date createTime;

    @Schema(description = "更新时间")
    @JsonIgnore
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;

    @Schema(description = "逻辑删除")
    @JsonIgnore
    @TableLogic
    @TableField("is_deleted")
    private Byte isDeleted;

    }

    配置自动填充的内容,具体配置如下

    common模块下创建com.atguigu.lease.common.mybatisplus.MybatisMetaObjectHandler类,内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Component
    public class MybatisMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
    this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
    //插入操作时,更新时间也要赋值
    this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
    this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
    }
    }

    @TableField(fill = …) 和 MetaObjectHandler 的关系
    @TableField(fill = …) 注解和 MetaObjectHandler 类是协同工作的,但它们各自有不同的作用。为了确保自动填充功能正常生效,两者需要配合使用。

    1. @TableField(fill = …) 注解的作用
      指定自动填充规则:
      在实体类字段上使用 @TableField(fill = FieldFill.INSERT) 或 @TableField(fill = FieldFill.INSERT_UPDATE) 等注解,明确指定了该字段在插入或更新操作时需要自动填充。
      告知 MyBatis Plus:
      这些注解告诉 MyBatis Plus 框架哪些字段需要在特定的操作(如插入或更新)时进行自动填充。
    2. MetaObjectHandler 类的作用
      实现具体的填充逻辑:
      MetaObjectHandler 接口的实现类(如 MybatisMetaObjectHandler)定义了如何为这些字段赋值。
      insertFill 和 updateFill 方法中具体实现了在插入和更新操作时为字段赋值的逻辑。
    3. 两者的关系
      必须配合使用:
      注解:@TableField(fill = …) 注解用于标记哪些字段需要自动填充。
      处理器:MetaObjectHandler 实现类提供了具体的填充逻辑。
      如果只设置了注解而没有实现 MetaObjectHandler,则自动填充不会生效;反之,如果只实现了 MetaObjectHandler 而没有设置注解,框架也不会知道哪些字段需要自动填充。

    总之就是fill指定哪些字段可以在MetaObjectHandler 类的特定方法里进行填充,至于是否一定填充,则看是否在该特定方法里对该字段进行赋值。

    因为该项目原先update_time是只记录更新的,但我觉得update_time应该在插入时也要赋初值,所以我进行了fill=FieldFill.INSERT_UPDATE的改动,但因为当时在MetaObjectHandler 类中的insertFill方法中没有添加如下对update_time字段赋值的语句

    1
    2
    //插入操作时,更新时间也要赋值
    this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());

    导致插入时,update_time字段仍然没有自动填充,我想了挺久才定位到这个原因。

5.枚举类型转换

由于数据库中的状态值通常以数值类型表示,但我们在定义实体类的时候因为为了方便知道各个状态数值对应的状态而使用枚举类型表示。

以实体类的LabelInfo中的type为例,由于这个type字段在数据库、实体类、前后端交互的过程中有多种不同的形式,因此在请求和响应的过程中,type字段会涉及到多次类型转换。:

  • 数据库中

    数据库中的type字段为tinyint类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    +-------------+--------------+
    | Field | Type |
    +-------------+--------------+
    | id | bigint |
    | type | tinyint |
    | name | varchar(255) |
    | create_time | timestamp |
    | update_time | timestamp |
    | is_deleted | tinyint |
    +-------------+--------------+
  • 实体类

    实体类中的type字段为ItemType枚举类型

    LabelInfo实体类如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Schema(description = "标签信息表")
    @TableName(value = "label_info")
    @Data
    public class LabelInfo extends BaseEntity {

    private static final long serialVersionUID = 1L;

    @Schema(description = "类型")
    @TableField(value = "type")
    private ItemType type;

    @Schema(description = "标签名称")
    @TableField(value = "name")
    private String name;
    }

    ItemType枚举类如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public enum ItemType {

    APARTMENT(1, "公寓"),
    ROOM(2, "房间");

    private Integer code;
    private String name;

    ItemType(Integer code, String name) {
    this.code = code;
    this.name = name;
    }
    }
  • 前后端交互中

    前后端交互所传递的数据中type字段为数字(1/2)。

    请求参数和响应结果

具体转换过程如下图所示:

  • 请求流程:
    枚举类型转换过程-请求参数.drawio

    说明

    • SpringMVC中的WebDataBinder组件负责将HTTP的请求参数绑定到Controller方法的参数(请求体的JSON字符串类型转换则交给HTTPMessageConverter组件),并实现参数类型的转换。
    • Mybatis中的TypeHandler用于处理Java中的实体对象与数据库之间的数据类型转换。
  • 响应流程
    枚举类型转换过程-响应体.drawio

    说明

    • SpringMVC中的HTTPMessageConverter组件负责将Controller方法的返回值(Java对象)转换为HTTP响应体中的JSON字符串。
    • 或者将请求体中的JSON字符串转换为Controller方法中的参数(Java对象),如下图所示。
      枚举类型转换过程-请求体.drawio

下面介绍一下每个环节的类型转换原理

  • WebDataBinder枚举类型转换

    WebDataBinder依赖于Converter实现类型转换,若Controller方法声明的@RequestParam参数的类型不是StringWebDataBinder就会自动进行数据类型转换。SpringMVC提供了常用类型的转换器,例如StringIntegerStringDateStringBoolean等等,其中也包括String到枚举类型,但是String到枚举类型的默认转换规则是根据实例名称(”APARTMENT”)转换为枚举对象实例(ItemType.APARTMENT)。若想实现code属性到枚举对象实例的转换,需要自定义Converter,代码如下,具体内容可参考官方文档

    • web-admin模块自定义com.atguigu.lease.web.admin.custom.converter.StringToItemTypeConverter

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Component
      public class StringToItemTypeConverter implements Converter<String, ItemType> {
      @Override
      /*这里的code参数就是前端传过来需要进行类型转换的值
      所以这里转换器定义并添加FormatterRegistry后,主类下的所有Controller中的接收参数只要是ItemType类型,默认前端传过来的值都是他的code值*/
      public ItemType convert(String code) {

      for (ItemType value : ItemType.values()) {
      if (value.getCode().equals(Integer.valueOf(code))) {
      return value;
      }
      }
      throw new IllegalArgumentException("code非法");
      }
      }
    • 注册上述的StringToItemTypeConverter,在web-admin模块创建com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration,内容如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @Configuration
      public class WebMvcConfiguration implements WebMvcConfigurer {

      @Autowired
      private StringToItemTypeConverter stringToItemTypeConverter;

      @Override
      public void addFormatters(FormatterRegistry registry) {
      registry.addConverter(this.stringToItemTypeConverter);
      }
      }

    但是我们有很多的枚举类型都需要考虑类型转换这个问题,按照上述思路,我们需要为每个枚举类型都定义一个Converter,并且每个Converter的转换逻辑(code值–>枚举类)都完全相同,针对这种情况,我们使用ConverterFactory接口更为合适,这个接口可以将同一个转换逻辑应用到一个接口的所有实现类,因此我们可以定义一个BaseEnum接口,然后令所有的枚举类都实现该接口,然后就可以自定义ConverterFactory,集中编写各枚举类的转换逻辑了。具体实现如下:

    • model模块定义com.atguigu.lease.model.enums.BaseEnum接口

      1
      2
      3
      4
      public interface BaseEnum {
      Integer getCode();
      String getName();
      }
    • 令所有com.atguigu.lease.model.enums包下的枚举类都实现BaseEnun接口

    • web-admin模块自定义com.atguigu.lease.web.admin.custom.converter.StringToBaseEnumConverterFactory

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      @Component
      public class StringToBaseEnumConverterFactory implements ConverterFactory<String, BaseEnum> {
      //因为Converter<String(前端所传数据类型), T(后端所接受的数据类型)>是一个接口,所以还要进行内部类的convert方法重写
      @Override
      public <T extends BaseEnum> Converter<String, T> getConverter(Class<T> targetType) {
      return new Converter<String, T>() {
      @Override
      public T convert(String source) {
      //遍历目标枚举类型的所有枚举值,将其code值和source值进行比较
      for (T enumConstant : targetType.getEnumConstants()) {
      if (enumConstant.getCode().equals(Integer.valueOf(source))) {
      return enumConstant;
      }
      }
      throw new IllegalArgumentException("非法的枚举值:" + source);
      }
      };
      }
      }
    • 注册上述的ConverterFactory,在web-admin模块创建com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration,内容如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @Configuration
      public class WebMvcConfiguration implements WebMvcConfigurer {

      @Autowired
      private StringToBaseEnumConverterFactory stringToBaseEnumConverterFactory;

      @Override
      public void addFormatters(FormatterRegistry registry) {
      registry.addConverterFactory(this.stringToBaseEnumConverterFactory);
      }
      }

      注意:

      本项目最终采用的是ConverterFactory方案,因此StringToItemTypeConverter相关代码可以直接删除。

  • TypeHandler枚举类型转换

    Mybatis预置的TypeHandler可以处理常用的数据类型转换,例如StringIntegerDate等等,其中也包含枚举类型,但是枚举类型的默认转换规则是枚举对象实例(ItemType.APARTMENT)和实例名称(”APARTMENT”)相互映射。若想实现code属性到枚举对象实例的相互映射,需要自定义TypeHandler

    不过MybatisPlus提供了一个通用的处理枚举类型的TypeHandler。其使用十分简单,只需在ItemType枚举类的code属性上增加一个注解@EnumValue,Mybatis-Plus便可完成从ItemType对象到code属性之间的相互映射,具体配置如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public enum ItemType {

    APARTMENT(1, "公寓"),
    ROOM(2, "房间");

    @EnumValue
    private Integer code;
    private String name;

    ItemType(Integer code, String name) {
    this.code = code;
    this.name = name;
    }
    }
  • HTTPMessageConverter枚举类型转换

    HttpMessageConverter依赖于Json序列化框架(默认使用Jackson)。其对枚举类型的默认处理规则也是枚举对象实例(ItemType.APARTMENT)和实例名称(”APARTMENT”)相互映射。不过其提供了一个注解@JsonValue,同样只需在ItemType枚举类的code属性上增加一个注解@JsonValue,Jackson便可完成从ItemType对象到code属性之间的互相映射。具体配置如下,详细信息可参考Jackson官方文档

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Getter
    public enum ItemType {

    APARTMENT(1, "公寓"),
    ROOM(2, "房间");

    @EnumValue
    @JsonValue
    private Integer code;
    private String name;

    ItemType(Integer code, String name) {
    this.code = code;
    this.name = name;
    }
    }

6.图片上传管理

由于公寓、房间等实体均包含图片信息,所以在新增或修改公寓、房间信息时,需要上传图片,因此我们需要实现一个上传图片的接口。

1. 图片上传流程

下图展示了新增房间或公寓时,上传图片的流程。

图片存储方案.drawio

可以看出图片上传接口接收的是图片文件,返回的Minio对象的URL。

2. 图片上传接口开发

下面为该接口的具体实现

  • 配置Minio Client

    • 引入Minio Maven依赖

      common模块pom.xml文件增加如下内容:

      1
      2
      3
      4
      <dependency>
      <groupId>io.minio</groupId>
      <artifactId>minio</artifactId>
      </dependency>
    • 配置Minio相关参数

      application.yml中配置Minio的endpointaccessKeysecretKeybucketName等参数

      1
      2
      3
      4
      5
      minio:
      endpoint: http://<hostname>:<port>
      access-key: <access-key>
      secret-key: <secret-key>
      bucket-name: <bucket-name>

      注意:上述<hostname><port>等信息需根据实际情况进行修改。

    • common模块中创建com.atguigu.lease.common.minio.MinioProperties,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @ConfigurationProperties(prefix = "minio")
      @Data
      public class MinioProperties {

      private String endpoint;

      private String accessKey;

      private String secretKey;

      private String bucketName;
      }
    • common模块中创建com.atguigu.lease.common.minio.MinioConfiguration,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Configuration
      @EnableConfigurationProperties(MinioProperties.class)
      public class MinioConfiguration {

      @Autowired
      private MinioProperties properties;

      @Bean
      public MinioClient minioClient() {
      return MinioClient.builder().endpoint(properties.getEndpoint()).credentials(properties.getAccessKey(), properties.getSecretKey()).build();
      }
      }
  • 开发图片上传接口

    • 编写Controller层逻辑

      FileUploadController中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Tag(name = "文件管理")
      @RequestMapping("/admin/file")
      @RestController
      public class FileUploadController {
      @Autowired
      FileService fileService;
      @Operation(summary = "上传文件")
      @PostMapping("upload")
      public Result<String> upload(@RequestParam MultipartFile file) {
      try{
      String url = fileService.upload(file);
      return Result.ok(url);
      }catch (Exception e){
      return Result.fail();
      }
      }
      }

      说明:MultipartFile是Spring框架中用于处理文件上传的类,它包含了上传文件的信息(如文件名、文件内容等)。

    • 编写Service层逻辑

      • FileService中增加如下内容

        1
        String upload(MultipartFile file);
      • FileServiceImpl中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        @Autowired
        MinioClient minioClient;
        @Autowired
        MinioProperties minioProperties;
        @Override
        public String upload(MultipartFile file) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {

        boolean b = minioClient.bucketExists(BucketExistsArgs.builder().bucket(minioProperties.getBucketName()).build());
        if(!b){
        //若该桶不存在,则创建桶并设置访问权限
        minioClient.makeBucket(MakeBucketArgs.builder().bucket(minioProperties.getBucketName()).build());
        minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().
        bucket(minioProperties.getBucketName()).
        config(createBucketPolicyConfig(minioProperties.getBucketName())).build());
        }
        //文件命名为 日期+UUID+文件名
        String filename = new SimpleDateFormat("yyyyMMdd").format(new Date()) +
        "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
        minioClient.putObject(
        PutObjectArgs.builder()
        .bucket(minioProperties.getBucketName())
        .stream(file.getInputStream(), file.getSize(), -1)
        .object(filename)
        .contentType(file.getContentType())
        .build()
        );
        //返回图片的url
        return String.join("/", minioProperties.getEndpoint(), minioProperties.getBucketName(), filename);
        }

        private String createBucketPolicyConfig(String bucketName) {

        return """
        {
        "Statement" : [ {
        "Action" : "s3:GetObject",
        "Effect" : "Allow",
        "Principal" : "*",
        "Resource" : "arn:aws:s3:::%s/*"
        } ],
        "Version" : "2012-10-17"
        }
        """.formatted(bucketName);
        }
        }

        注意

        上述createBucketPolicyConfig方法的作用是生成用于描述指定bucket访问权限的JSON字符串。最终生成的字符串格式如下,其表示,允许(Allow)所有人(*)获取(s3:GetObject)指定桶(<bucket-name>)的内容。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        {
        "Statement" : [ {
        "Action" : "s3:GetObject",
        "Effect" : "Allow",
        "Principal" : "*",
        "Resource" : "arn:aws:s3:::<bucket-name>/*"
        } ],
        "Version" : "2012-10-17"
        }

        由于公寓、房间的图片为公开信息,所以将其设置为所有人可访问。

7.全局异常处理

在处理信息时,若遇到有问题的数据,可以手动抛出异常,配置全局异常处理器对该异常进行捕获并返回相应的响应结果,只需要自定义一个异常类,并构造接收合适参数的构造器即可。

为灵活设置响应信息,可自定义异常类,如下

common模块创建com.atguigu.lease.common.exception.LeaseException类,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Data
public class LeaseException extends RuntimeException {

//异常状态码
private Integer code;
/**
* 通过状态码和错误消息创建异常对象
* @param message
* @param code
*/
public LeaseException(String message, Integer code) {
super(message);
this.code = code;
}

/**
* 根据响应结果枚举对象创建异常对象
* @param resultCodeEnum
*/
public LeaseException(ResultCodeEnum resultCodeEnum) {
super(resultCodeEnum.getMessage());
this.code = resultCodeEnum.getCode();
}

@Override
public String toString() {
return "LeaseException{" +
"code=" + code +
", message=" + this.getMessage() +
'}';
}
}

全局异常处理器设置于common模块com.atguigu.lease.common.exception.GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
@ResponseBody
public Result exception(Exception e){
e.printStackTrace();
return Result.fail();
}

@ExceptionHandler(LeaseException.class)
@ResponseBody
public Result LeaseException(LeaseException e){
return Result.build(e.getCode(), e.getMessage());
}
}

为Result新增一个构造方法,如下

1
2
3
4
5
6
public static <T> Result<T> build(Integer code, String message) {
Result<T> result = build(null);
result.setCode(code);
result.setMessage(message);
return result;
}

在认为有问题,可以直接返回结果时,即可通过抛出异常(注意带上结构枚举参数或者msg值+code值)让全局异常处理器返回响应结果,不需要再经由controller返回。

8.登陆管理

以后台管理的登录为例,跟移动端差不多,就少了个短信验证功能。

8.1 背景知识

1. 认证方案概述

有两种常见的认证方案,分别是基于Session的认证和基于Token的认证,下面逐一进行介绍

  • 基于Session

    基于Session的认证流程如下图所示

    该方案的特点

    • 登录用户信息保存在服务端内存中,若访问量增加,单台节点压力会较大
    • 随用户规模增大,若后台升级为集群,则需要解决集群中各服务器登录状态共享的问题。
  • 基于Token

    基于Token的认证流程如下图所示

    该方案的特点

    • 登录状态保存在客户端,服务器没有存储开销
    • 客户端发起的每个请求自身均携带登录状态,所以即使后台为集群,也不会面临登录状态共享的问题。

2. Token详解

本项目采用基于Token的登录方案,下面详细介绍Token这一概念。

我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。

JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.分隔。三个部分分别被称为

  • header(头部)
  • payload(负载)
  • signature(签名)

各部分的作用如下

  • Header(头部)

    Header部分是由一个JSON对象经过base64url编码得到的,这个JSON对象用于保存JWT 的类型(typ)、签名算法(alg)等元信息,例如

    1
    2
    3
    4
    {
    "alg": "HS256",
    "typ": "JWT"
    }
  • Payload(负载)

    也称为 Claims(声明),也是由一个JSON对象经过base64url编码得到的,用于保存要传递的具体信息。JWT规范定义了7个官方字段,如下:

    • iss (issuer):签发人
    • exp (expiration time):过期时间
    • sub (subject):主题
    • aud (audience):受众
    • nbf (Not Before):生效时间
    • iat (Issued At):签发时间
    • jti (JWT ID):编号

    除此之外,我们还可以自定义任何字段,例如

    1
    2
    3
    4
    5
    {
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
    }
  • Signature(签名)

    由头部、负载和秘钥一起经过(header中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。

8.2 登录流程

后台管理系统的登录流程如下图所示

根据上述登录流程,可分析出,登录管理共需三个接口,分别是获取图形验证码登录获取登录用户个人信息,除此之外,我们还需为所有受保护的接口增加验证JWT合法性的逻辑,这一功能可通过HandlerInterceptor来实现。

8.3 代码实现

1.获取图形验证码
  • 查看响应的数据结构

    查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.CaptchaVo,内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Data
    @Schema(description = "图像验证码")
    @AllArgsConstructor
    public class CaptchaVo {

    @Schema(description="验证码图片信息")
    private String image;

    @Schema(description="验证码key") //校验时,会通过key在redis中查找答案,再和填入值进行比较
    private String key;
    }
  • 配置所需依赖

    • 验证码生成工具

      本项目使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档

      common模块的pom.xml文件中增加如下内容

      1
      2
      3
      4
      <dependency>
      <groupId>com.github.whvcse</groupId>
      <artifactId>easy-captcha</artifactId>
      </dependency>
    • Redis

      common模块的pom.xml中增加如下内容

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>

      application.yml中增加如下配置

      1
      2
      3
      4
      5
      6
      spring:
      data:
      redis:
      host: <hostname>
      port: <port>
      database: 0

      注意:上述hostnameport需根据实际情况进行修改

  • 编写Controller层逻辑

    LoginController中增加如下内容

    1
    2
    3
    4
    5
    6
    @Operation(summary = "获取图形验证码")
    @GetMapping("login/captcha")
    public Result<CaptchaVo> getCaptcha() {
    CaptchaVo captcha = service.getCaptcha();
    return Result.ok(captcha);
    }
  • 编写Service层逻辑

    • LoginService中增加如下内容

      1
      CaptchaVo getCaptcha();
    • LoginServiceImpl中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Autowired
      private StringRedisTemplate redisTemplate;

      @Override
      public CaptchaVo getCaptcha() {
      SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4); //长、宽、位数
      specCaptcha.setCharType(Captcha.TYPE_DEFAULT);

      String code = specCaptcha.text().toLowerCase(); //验证码
      String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
      String image = specCaptcha.toBase64();
      redisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);

      return new CaptchaVo(image, key);
      }

      知识点

      • 本项目Reids中的key需遵循以下命名规范:项目名:功能模块名:其他,例如admin:login:123456

      • spring-boot-starter-data-redis已经完成了StringRedisTemplate的自动配置,我们直接注入即可。

      • 为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。这些常量统一定义在common模块下的com.atguigu.lease.common.constant.RedisConstant类中

        1
        2
        3
        4
        5
        6
        7
        8
        public class RedisConstant {
        public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
        public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
        public static final String APP_LOGIN_PREFIX = "app:login:";
        public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
        public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
        public static final String APP_ROOM_PREFIX = "app:room:";
        }
2. 登录接口
  • 登录校验逻辑

    用户登录的校验逻辑分为三个主要步骤,分别是校验验证码校验用户状态校验密码,具体逻辑如下

    • 前端发送usernamepasswordcaptchaKeycaptchaCode请求登录。
    • 判断captchaCode是否为空,若为空,则直接响应验证码为空;若不为空进行下一步判断。
    • 根据captchaKey从Redis中查询之前保存的code,若查询出来的code为空,则直接响应验证码已过期;若不为空进行下一步判断。
    • 比较captchaCodecode,若不相同,则直接响应验证码不正确;若相同则进行下一步判断。
    • 根据username查询数据库,若查询结果为空,则直接响应账号不存在;若不为空则进行下一步判断。
    • 查看用户状态,判断是否被禁用,若禁用,则直接响应账号被禁;若未被禁用,则进行下一步判断。
    • 比对password和数据库中查询的密码,若不一致,则直接响应账号或密码错误,若一致则进行入最后一步。
    • 创建JWT,并响应给浏览器。
  • 配置所需依赖

    登录接口需要为登录成功的用户创建并返回JWT,本项目使用开源的JWT工具Java-JWT,配置如下,具体内容可参考官方文档

    • 引入Maven依赖

      common模块的pom.xml文件中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-api</artifactId>
      </dependency>

      <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-impl</artifactId>
      <scope>runtime</scope>
      </dependency>

      <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-jackson</artifactId>
      <scope>runtime</scope>
      </dependency>
    • 创建JWT工具类

      common模块下创建com.atguigu.lease.common.utils.JwtUtil工具类,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      public class JwtUtil {

      private static long tokenExpiration = 60 * 60 * 1000L;
      private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());

      public static String createToken(Long userId, String username) {
      String token = Jwts.builder().
      setSubject("USER_INFO").
      setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)).
      claim("userId", userId).
      claim("username", username).
      signWith(tokenSignKey).
      compact();
      return token;
      }

      }

    注意,claim(自定义添加字段)用于向 JWT 中添加用户信息,因为本项目中在认证通过后需要获取用户的数据,通过jwt的解析,用户id和用户名username得到后可以满足该项目移动端的接口需求。

  • 编写HandlerInterceptor拦截器

    我们需要为所有受保护的接口增加校验JWT合法性的逻辑。具体实现如下

    • JwtUtil中增加parseToken方法,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public static Claims parseToken(String token){
      //判断token是否为null
      if(token == null){
      throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
      }
      //返回Claims(含有负载信息)给拦截器使用,若token存在问题则会直接抛出异常
      try {
      JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(tokenSingKey).build();
      return jwtParser.parseClaimsJws(token).getBody();
      }catch (ExpiredJwtException e){
      throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
      }catch (JwtException e){
      throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
      }
      }
    • 编写HandlerInterceptor

      web-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下,有关HanderInterceptor的相关内容,可参考官方文档

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Component
      public class AuthenticationInterceptor implements HandlerInterceptor {
      @Override
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      String token = request.getHeader("access-token");
      Claims claims = JwtUtil.parseToken(token);
      Long userId = claims.get("userId", Long.class);
      String username = claims.get("username", String.class);
      LoginUserHolder.setLoginUser(new LoginUser(userId, username));
      return true;
      }

      @Override
      public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
      LoginUserHolder.clear();
      }
      }

      注意

      我们约定,前端登录后,后续请求都将JWT,放置于HTTP请求的Header中,其Header的key为access-token

    • 注册HandlerInterceptor

      web-admin模块com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      @Autowired
      private AuthenticationInterceptor authenticationInterceptor;

      @Override
      public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(this.authenticationInterceptor).addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
      }
3. 获取登录用户个人信息
  • 查看请求和响应的数据结构

    • 响应的数据结构

      查看web-admin模块下的com.atguigu.lease.web.admin.vo.system.user.SystemUserInfoVo,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @Schema(description = "员工基本信息")
      @Data
      public class SystemUserInfoVo {

      @Schema(description = "用户姓名")
      private String name;

      @Schema(description = "用户头像")
      private String avatarUrl;
      }
    • 请求的数据结构

      按理说,前端若想获取当前登录用户的个人信息,需要传递当前用户的id到后端进行查询。但是由于请求中携带的JWT中就包含了当前登录用户的id,故请求个人信息时,就无需再传递id

  • 编写ThreadLocal工具类

    理论上我们可以在Controller方法中,使用@RequestHeader获取JWT,然后在进行解析,如下

    1
    2
    3
    4
    5
    6
    7
    8
    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
    Claims claims = JwtUtil.parseToken(token);
    Long userId = claims.get("userId", Long.class);
    SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
    return Result.ok(userInfo);
    }

    上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。

    ThreadLocal概述

    ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,使每个线程都可以操作自己的变量,而不会互相干扰,其用法如下图所示。

    common模块中创建com.atguigu.lease.common.login.LoginUserHolder工具类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class LoginUserHolder {
    public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

    public static void setLoginUser(LoginUser loginUser) {
    threadLocal.set(loginUser);
    }

    public static LoginUser getLoginUser() {
    return threadLocal.get();
    }

    public static void clear() {
    threadLocal.remove();
    }
    }

    同时在common模块中创建com.atguigu.lease.common.login.LoginUser

    1
    2
    3
    4
    5
    6
    7
    @Data
    @AllArgsConstructor
    public class LoginUser {

    private Long userId;
    private String username;
    }
  • AuthenticationInterceptor拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Component
    public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String token = request.getHeader("access-token");
    Claims claims = JwtUtil.parseToken(token);
    Long userId = claims.get("userId", Long.class);
    String username = claims.get("username", String.class);
    //将结果保存到ThreadLocal中
    LoginUserHolder.setLoginUser(new LoginUser(userId, username));
    return true;
    }
    }
  • Controller层逻辑

    LoginController中增加如下内容

    1
    2
    3
    4
    5
    6
    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info() {
    SystemUserInfoVo userInfo = service.getLoginUserInfo(LoginUserHolder.getLoginUser().getUserId());
    return Result.ok(userInfo);
    }

9.Mybatis插件分页注意事项

使用Mybatis-Plus的分页插件进行分页查询时,如果结果需要使用<collection>进行映射,只能使用**嵌套查询(Nested Select for Collection),而不能使用嵌套结果映射(Nested Results for Collection)**。

嵌套查询嵌套结果映射是Collection映射的两种方式,下面通过一个案例进行介绍

例如有room_infograph_info两张表,其关系为一对多,如下

现需要查询房间列表及其图片信息,期望返回的结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[
{
"id": 1,
"number": 201,
"rent": 2000,
"graphList": [
{
"id": 1,
"url": "http://",
"roomId": 1
},
{
"id": 2,
"url": "http://",
"roomId": 1
}
]
},
{
"id": 2,
"number": 202,
"rent": 3000,
"graphList": [
{
"id": 3,
"url": "http://",
"roomId": 2
},
{
"id": 4,
"url": "http://",
"roomId": 2
}
]
}
]

为得到上述结果,可使用以下两种方式

  • 嵌套结果映射

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <select id="selectRoomPage" resultMap="RoomPageMap">
    select ri.id room_id,
    ri.number,
    ri.rent,
    gi.id graph_id,
    gi.url,
    gi.room_id
    from room_info ri
    left join graph_info gi on ri.id=gi.room_id
    </select>

    <resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
    <id column="room_id" property="id"/>
    <collection property="graphInfoList" ofType="GraphInfo" autoMapping="true">
    <id column="graph_id" property="id"/>
    </collection>
    </resultMap>

    这种方式的执行原理如下图所示

  • 嵌套查询

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <select id="selectRoomPage" resultMap="RoomPageMap">
    select id,
    number,
    rent
    from room_info
    </select>

    <resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
    <id column="id" property="id"/>
    <collection property="graphInfoList" ofType="GraphInfo" select="selectGraphByRoomId" column="id"/>
    </resultMap>

    <select id="selectGraphByRoomId" resultType="GraphInfo">
    select id,
    url,
    room_id
    from graph_info
    where room_id = #{id}
    </select>

    这种方法使用两个独立的查询语句来获取一对多关系的数据。首先,Mybatis会执行主查询来获取room_info列表,然后对于每个room_info,Mybatis都会执行一次子查询来获取其对应的graph_info

若现在使用MybatisPlus的分页插件进行分页查询,假如查询的内容是第1页,每页2条记录,则上述两种方式的查询结果分别是

  • 嵌套结果映射

  • 嵌套查询

总结起来就是,嵌套结果映射是对最后的数据表进行limit,而最后的数据表的多条数据会合并成一个对象,导致最终得到的结果并没有设置的size一样多,所以分页逻辑是存在问题的。而嵌套查询会先对主查询(不包含映射对象的列表属性对应的字段)进行limit,然后在子查询中查询并添加集合字段,这样映射出来的结果是正确的。