本节我们将一起来实战完善分类接口、增加拖拽接口实现并采用redis来缓存门户端的分类树数据,废话不多说,开干。
API定义的调整
这里小卷强调下后台API定义的注意事项。API的定义一定要考虑前端交互的需要,该提供哪些参数,哪些参数可选,哪些参数不应该传,一定要清楚交互场景。
新增、修改分类DTO调整
审视下之前我们定义的新增分类DTO:
这里的level
和orderNum
字段并不是由前端计算并传入的,前端应该只关心新增的是一级分类还是子分类(pid
指定),这两个字段应该由后台计算出来。
同样的,更新分类接口,我们只更新分类名称字段,而pid
、level
、orderNum
这些字段的更新只有在前端进行分类拖拽调整时才会被涉及到。因此CategoryUpdateDTO
中我们只保留id
和name
字段即可,即,按照主键来更新名称。
区分列表和树查询结果DTO
前面我们对于列表查询和分类树查询的结果都采用CategoryInfoDTO
,这并不是一个好主意,因为列表中不需要children
字段,而树查询不关心level
、orderNum
、createTime
和updateTime
字段。因此我们对其分开定义:列表用CategoryInfoDTO
,树用CategoryItemDTO
。
components:
schemas:
...
CategoryInfo:
description: 分类信息DTO类
type: object
properties:
# 去掉children
...
CategoryItem:
description: 分类树条目DTO类
type: object
properties:
id:
description: 分类id
type: integer
format: int64
pid:
description: 父级分类id
type: integer
format: int64
name:
description: 分类名称
type: string
children:
description: 子分类列表
type: array
items:
$ref: '#/components/schemas/CategoryItem'
...
同时在API中将响应DTO的schema改过来:
paths:
...
/mall-api/portal/category/listTree:
get:
...
responses:
200:
description: 查询成功
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CategoryItem'
实际开发中,小伙伴们切记多个API之间不管是查询还是更新尽量不要重用DTO,以免因为冗余的字段造成一些歧义。
新增分类拖拽API定义
这是我们新加的一个分类接口,前端可以借助element ui的树组件的拖拽功能来完成分类的调整,当然要确保调整后的分类不能超过三级。前端开发这块我们先放一边,专注于后台接口的实现,但是需要清楚前后端在组件交互时,前端会给后台提供哪些参数,我们参考下element ui的官方文档:组件 | Element
很显然,这里前端只要传三个参数:
-
rootId
被拖拽的分类节点,可能会连带子分类,但我们只关心root分类,因此这里要提供其分类id即可。
-
targetId
拖拽释放所进入的分类节点的id
-
type
放置的位置,是一个整数值:
0-之前 1-之中 2-之后
再来看下openAPI3的文档定义:
openapi: 3.0.1
...
paths:
...
/mall-api/admin/categories/move:
post:
tags:
- categoryAdmin
summary: 分类拖拽
description: 对分类进行拖拽调整分类或排序
operationId: moveCategories
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryMove'
responses:
200:
description: 移动成功
...
components:
schemas:
...
CategoryMove:
description: 分类移动DTO类
type: object
properties:
rootId:
description: 要移动的分类id
type: integer
format: int64
x-validation: "@NotNull(message = MSG_DRAG_CATEGORY_ID_REQUIRED)"
targetId:
description: 目标分类id
type: integer
format: int64
x-validation: "@NotNull(message = MSG_DROP_TARGET_ID_REQUIRED)"
type:
description: 放置类型 0-之前 1-之中 2-之后
type: integer
x-validation: "@NotNull(message = MSG_DROP_TYPE_REQUIRED)"
这里我们对post的DTO对象字段增加了非空校验,消息维护在常量中:
package com.xiaojuan.boot.consts;
public interface ValidationConst {
...
String MSG_DRAG_CATEGORY_ID_REQUIRED = "拖拽分类id不能为空";
String MSG_DROP_TARGET_ID_REQUIRED = "目标分类id不能为空";
String MSG_DROP_TYPE_REQUIRED = "放置类型不能为空";
}
ok,当我们调整了mall.yaml
,重新用swagger生成器生成下即可。
Service层逻辑
随着前面API的重新生成,原先一些接口的DTO参数需要做相关调整,这里略过。我们主要关注业务逻辑层的调整和新的实现。简单的更新分类名称、删除分类、查询分页列表的实现逻辑,之前已经做过了,我们主要看下面几个接口。
新增分类
@Override
public void addCategory(CategoryAddDTO categoryAddDTO) {
// 校验分类名是否已存在
long pid = categoryAddDTO.getPid() == null ? 0 : categoryAddDTO.getPid();
int level = 1;
if (pid != 0) {
level = customCategoryMapper.getLevel(pid) + 1;
}
if (level > 3) {
throw new BusinessException("最多只能添加三级分类");
}
Category category = new Category();
category.setPid(pid);
category.setName(categoryAddDTO.getName());
category.setLevel(level);
category.setOrderNum(customCategoryMapper.getLastChildOrderNum(pid) + 1);
category.setCreateTime(new Date());
category.setUpdateTime(new Date());
categoryMapper.insertSelective(category);
}
代码说明
前端不传
pid
时,表示新增一级分类,这里我们设置为0
;如果有传pid
且不为0
,要新增的分类level
为父分类level
加1,这里我们写了一个自定义的mapper
方法来根据分类id获取分类level
,mapper
接口见后面mapper
小节。如果添加的分类超过三级,则抛出异常。这里的
orderNum
取父分类的子分类中orderNum
值最大的加1,确保新增的默认排在最后。
移动分类
该接口的实现逻辑稍有点复杂,我们直接看代码并分析:
@Transactional
@Override
public void moveCategories(CategoryMoveDTO moveCategoryDTO) {
// 释放位置
MoveType type = MoveType.typeOf(moveCategoryDTO.getType());
// 被拖拽的分类节结点
long rootId = moveCategoryDTO.getRootId();
// 要放置在哪个目标分类上
long targetId = moveCategoryDTO.getTargetId();
Category target = categoryMapper.selectByPrimaryKey(targetId).get();
int updateLevel = type == MoveType.INNER ? target.getLevel() + 1 : target.getLevel();
if (updateLevel + cascadeCalcDepth(rootId) > 3) {
throw new BusinessException("分类拖拽后不满足最多三级");
}
Category updateRoot = new Category();
updateRoot.setId(rootId);
updateRoot.setUpdateTime(new Date());
updateRoot.setLevel(updateLevel);
if (type == MoveType.INNER) {
// 放在目标结点的内部
updateRoot.setPid(targetId);
int orderNum = customCategoryMapper.getLastChildOrderNum(targetId) + 1;
updateRoot.setOrderNum(orderNum);
} else {
// 获取target的父级id
long pid = customCategoryMapper.getPid(targetId);
updateRoot.setPid(pid);
if (type == MoveType.BEFORE) {
updateRoot.setOrderNum(target.getOrderNum());
} else {
updateRoot.setOrderNum(target.getOrderNum() + 1);
}
customCategoryMapper.incrementOrderNumAfter(pid, updateRoot.getOrderNum());
}
categoryMapper.updateByPrimaryKeySelective(updateRoot);
// 级联处理被拖拽的子分类的层级
// 获取原来的层级
long level = customCategoryMapper.getLevel(rootId);
if (level != updateRoot.getLevel()) {
cascadeUpdateLevel(rootId, updateRoot.getLevel());
}
}
代码说明
该方法要保证数据库操作在一个事务中进行,因此在方法上增加了
@Transactional
注解。放置类型我们定义了一个枚举,类型的值对应了枚举项的索引:
package com.xiaojuan.boot.enums; public enum MoveType { BEFORE, INNER, AFTER; public static MoveType typeOf(int value) { return MoveType.values()[value]; } }
我们先根据
MoveType
和释放的目标分类的level
计算出被拖拽的分类的level
。然后我们基于此值和拖动分类树深度来判断是否超过了三级。这里计算树的深度(自己不算在内)我们写了一个方法,递归来实现的:public int cascadeCalcDepth(long pid) { List ids = customCategoryMapper.listIdsByPid(pid); if (CollectionUtils.isEmpty(ids)) return 0; List depths = ids.stream().map(this::cascadeCalcDepth).collect(Collectors.toList()); return Collections.max(depths) + 1; }
递归的思想:先根据父级分类id查找出所有的子分类id列表,如果是空,则表示除了它本身,深度为
0
;否则我们采用了stream()
的操作形式对每个子类id调用cascadeCalcDepth(pid)
方法,将所有子类计算的深度放到一个列表中。最后将子类的深度1
再加上各个子类计算出来的深度中最大的值作为结果返回。这种递归的思想,小伙伴们好好体会下。接下来,我们再计算被拖动的root分类的
orderNum
:如果作为target
的子分类,计算方式同新增子分类;如果插入在目标节点之前,则取target
分类的orderNum
,否则取其值加1。计算得到的root分类的orderNum
后面才会更新,首先我们会更新要插入的同级分类中所有大于等于该值的orderNum
字段,值自增1。最后再处理被拖动的分类有子分类且root分类被拖动放置后的层级发生变化时的子分类层级级联更新的情况。同样采用递归的实现:
private void cascadeUpdateLevel(long pid, int level) { List ids = customCategoryMapper.listIdsByPid(pid); if (CollectionUtils.isEmpty(ids)) return; customCategoryMapper.updateChildrenLevel(pid, level); for (long id : ids) { cascadeUpdateLevel(id, level + 1); } }
按照
pid
更新子分类的level
为传入的level
再加1。
本小节最后给出自定义mapper
的接口定义和通过方法注解映射的sql语句:
package com.xiaojuan.boot.dao.mapper;
import ...
public interface CustomCategoryMapper {
...
@Select("select max(ORDER_NUM) from TB_CATEGORY where pid = #{pid}")
int getLastChildOrderNum(long pid);
@Select("select pid from TB_CATEGORY where id = #{id}")
long getPid(long id);
@Select("select level from TB_CATEGORY where id = #{id}")
int getLevel(long id);
@Update("update TB_CATEGORY set ORDER_NUM = ORDER_NUM + 1 where PID = #{pid} and ORDER_NUM >= #{orderNum}")
int incrementOrderNumAfter(long pid, int orderNum);
@Select("select id from TB_CATEGORY where PID = #{pid}")
List listIdsByPid(long pid);
@Update("update TB_CATEGORY set LEVEL = #{level} + 1 where PID = #{pid}")
int updateChildrenLevel(long pid, int level);
}
我们发现这种写法自由度很大,对于增删改和不太复杂的查询sql,这种实现形式更简单直白。
redis缓存
加载分类树缓存实现
这里我们将对分类树数据的加载采用redis缓存的形式来提高访问效率。因为对分类进行后台管理的增删改的情况远远要少于门户端对分类树的数据查询请求,为此我们完全可以把整个分类树结构序列化成json字符串保存到redis中,让所有的门户端请求都从redis中获取分类树数据。这里我们也不考虑缓存失效的时间设置了,因为这并不是间歇性的访问,更像是把redis用作内存库的情况。而当后台改了数据后,要刷新缓存很简单,只要把redis中的key删掉即可。我们看下代码的实现:
package com.xiaojuan.boot.service.impl;
import ...
@Slf4j
@RequiredArgsConstructor
@Service
public class CategoryServiceImpl implements CategoryService {
private static final String REDIS_CATEGORY_DATA_KEY = "mall:categoryTreeData";
private final CategoryMapper categoryMapper;
private final CustomCategoryMapper customCategoryMapper;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final Object lock = new Object();
@Override
public void addCategory(CategoryAddDTO categoryAddDTO) {
// 逻辑省略
stringRedisTemplate.delete(REDIS_CATEGORY_DATA_KEY);
}
@Override
public void updateCategory(CategoryUpdateDTO updateDTO) {
// 逻辑省略
stringRedisTemplate.delete(REDIS_CATEGORY_DATA_KEY);
}
@Override
public void deleteCategory(Long categoryId) {
// 逻辑省略
stringRedisTemplate.delete(REDIS_CATEGORY_DATA_KEY);
}
@Transactional
@Override
public void moveCategories(CategoryMoveDTO moveCategoryDTO) {
// 逻辑省略
stringRedisTemplate.delete(REDIS_CATEGORY_DATA_KEY);
}
...
@SneakyThrows
@Override
public List listCategoryTree() {
String redisKey = REDIS_CATEGORY_DATA_KEY;
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(redisKey))) {
log.info("直接从redis读取");
return loadTreeDataFromRedis();
}
List result;
synchronized (lock) {
if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(redisKey))) {
result = loadTreeDataFromDB();
String json = objectMapper.writeValueAsString(result);
stringRedisTemplate.opsForValue().set(redisKey, json);
log.info("从库中读取并写入redis");
} else {
result = loadTreeDataFromRedis();
log.info("dcl下从redis读取");
}
}
return result;
}
@SneakyThrows
private List loadTreeDataFromRedis() {
String data = stringRedisTemplate.opsForValue().get(REDIS_CATEGORY_DATA_KEY);
return objectMapper.readValue(data, new TypeReference() {});
}
private List loadTreeDataFromDB() {
List categories = customCategoryMapper.listAll();
// list转map
Map categoriesMap = categories.stream().collect(Collectors.toMap(CategoryItemDTO::getId, category -> category));
for (CategoryItemDTO item : categories) {
if (item.getPid() != 0) {
categoriesMap.get(item.getPid()).addChildrenItem(item);
}
}
return categories.stream().filter(item -> item.getPid() == 0).collect(Collectors.toList());
}
}
代码说明
这里我们注入的操作redis的组件为
StringRedisTemplate
,相比与之前我们通过自定义序列化方式配置的RedisTemplate
,该组件bean对redis的键值都采用字符串的形式存储,我们可以对要保存的value按照自己想要的形式进行单个的序列化为string
和从string
反序列化处理即可。这里我们使用内部包装好的jackson框架的ObjectMapper
组件bean来完成json的序列化和反序列化操作。原先实现的
listCategoryTree()
方法,我们采用的是递归的从数据库按照pid
加载子分类的形式,会与数据库多次打交道;这里我们做了查询的优化,只从数据库查一次,按照level
和orderNum
字段排序后返回所有的分类列表,然后再对列表转换成树结构:package com.xiaojuan.boot.dao.mapper; import ... public interface CustomCategoryMapper { ... @Select("select id, pid, name from TB_CATEGORY order by LEVEL, ORDER_NUM") List listAll(); ... }
在
listCategoryTree()
方法中,我们采用了DCL(Double Check Lock)双重检查锁的形式来实现redis缓存。首先判断redis有没有缓存相应的key,有则从缓存取。否则,我们采用单机锁来控制并发请求线程对数据库读和redis写操作的互斥访问,也就是该操作只能有一个请求线程执行一次。这里我们考虑到要把该应用实现和部署为一个单体的形式,所以我们并没有应用分布式锁。而之所以在临界区中采用双重检查,是因为对于后续竞争到锁的请求线程当然让它们直接从redis缓存读取。
loadTreeDataFromDB()
方法实现了对列表形式的分类数据进行树结构的转换,这里我们采用了java8的stream
形式的写法极大的简化了代码。最后对其他的增删改操作,在数据库进行了更新后不要忘了要删除redis的key,在加载时可以刷新redis缓存。
好了,本节我们对商品分类的API进行了一些调整,补充了商品分类拖拽接口的实现,并对分类树的加载引入了redis的缓存实现。至于对这些功能的测试,小伙伴们自行验证,大家加油!