上篇文章我们实现了展示菜品数据、口味数据、套餐数据、套餐内菜品数据,因为这些数据都存储在服务器的数据库之中,如果短时间内有大量的用户进行点餐操作,系统就会频繁的与数据库进行交互,数据库的访问压力随之增大,小程序端可能要等待一段时间才能获取各项信息,用户体验较差。
Redis便可解决这一系列问题,将访问的压力从MySQL一个数据库分散到Redis和MySQL两个数据库上。
缓存菜品
来分析基本流程:首先是小程序向后端发起查询请求,后端收到后首先判断缓存是否存在,如果存在则直接读取缓存,不存在则查询数据库,并将查询到的信息载入到缓存中。
小程序在展示菜品时是根据分类来展示的,因此我们缓存数据也应该将每个分类下的菜品作为一份缓存数据。因为Redis存储数据的结构是K-V键值对,所以我们可以用key存储分类分类id,约定命名形式为dish_1、dish_2。菜品作为一个集合的数据转为String存储到Value中(Redis的String和java并不对应,因此java的任何数据都能转为String)。同时如果数据库中的菜品数据变更时,需要及时的清理缓存,避免造成缓存数据和MySQL数据不一致的情况。简而言之有三点:
- 每个分类下的菜品作为一份缓存数据
- key为分类id,value为菜品集合转化的String
- MySQL数据变更时及时清理缓存
添加菜品缓存
接下来改造后端代码,因为查询菜品的请求是DishController接收的,因此从该文件开始。
因为有关Redis的导入依赖、配置数据源、提供配置类等操作在代码中已存在,所以直接注入RedisTemplate对象即可。
@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
@Api(tags = "C端-菜品浏览接口")
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
//构造Redis中的key,命名形式dish_"分类Id"
String key="dish_"+categoryId;
//查询Redis数据库是否存在菜品数据
List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
if(list!=null&&list.size()>0){
//如果存在则直接返回,无需查询数据库
return Result.success(list);
}
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品
//如果不存在,查询数据库,并将数据放入Redis中
list = dishService.listWithFlavor(dish);//上文已定义list,删除此处的定义语句,直接使用list
redisTemplate.opsForValue().set(key,list);
return Result.success(list);
}
}
回到小程序执行查询语句,数据便会自动缓存在Redis中(套餐不由DishController接收,因此尚不能缓存):
删除菜品缓存
上文说过,如果数据库中的菜品数据变更时,需要及时的清理缓存,避免造成缓存数据和MySQL数据不一致的情况,接下来实现该功能。
首先来分析什么时候需要清除缓存,首先是修改菜品和删除菜品时需要。然后是菜品起售停售,此时该分类ID下的有效菜品数量发生变动,原先的Redis数据也不再有效。同理,新增菜品时因为该分类ID下有效菜品数量发生变动,也需更改。
由此得出需要删除并重新添加缓存的操作:
- 新增菜品
- 修改菜品
- 批量删除菜品
- 起售、停售菜品
因为这些操作都只有在管理端才能进行,因此我们需修改admin包下的DishController类。注意一定要先操作MySQL数据库再操作Redis,否则可能会导致数据不一致。
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
dishService.saveWithFlavor(dishDTO);
//清理缓存数据
redisTemplate.delete("dish_"+dishDTO.getCategoryId());
return Result.success();
}
删除菜品较复杂,因为是批量删除,传入的可能不止一个菜品,而是一个list集合。我们选择简单粗暴的方法——直接删除Redis中所有以dish开头的key对应的数据,后续小程序再次查询时会自动添加回去。
而我们是无法使用通配符删除的key的,但查询可以,我们可以先使用通配符查询所有key,并放入Set集合中,然后直接将该Set集合传入delete方法中。
然后来看修改菜品,他比较复杂,如果只是修改单一的菜品信息删除该菜品对应分类Id的数据即可,但如果修改的是菜品的分类就较为复杂,其涉及到了两个分类ID对应的数据。因此我们仍采用简单粗暴的方式:删除所有数据。
起售停售同理,因为这三种操作都无法直接获取分类id,所以直接删除所有数据更加省时省力。新增菜品因为包含分类ID,所以只删除对应分类ID下的数据。
因为删除所有Redis数据这段代码的复用性较高,我们选择将其抽取出来单独封装成一个方法,并在改、删、变状态三方法中调用:
@RestController
@RequestMapping("/admin/dish")
@Slf4j
@Api(tags = "菜品相关接口")
public class DishController {
@Autowired
private RedisTemplate redisTemplate;
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
......
//清理缓存数据
cleanDish("dish_"+dishDTO.getCategoryId());
return Result.success();
}
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result delete(@RequestParam List<Long> ids) {
......
//删除Redis中所有以"dish_"开头的数据
cleanDish("dish_*");
return Result.success();
}
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
......
//删除Redis中所有以"dish_"开头的数据
cleanDish("dish_*");
return Result.success();
}
@ApiOperation("菜品起售、停售")
@PostMapping("/status/{status}")
public Result<String> updateStatus(@PathVariable Integer status, Long id) {
......
//删除Redis中所有以"dish_"开头的数据
cleanDish("dish_*");
return Result.success();
}
/**
* 清理缓存数据
* @param pattern 1
*/
private void cleanDish(String pattern){
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}
}
更新代码后重新运行后端,并尝试修改菜品信息、删除菜品、启售/停售菜品,观察Redis数据库是否删除对应数据、小程序是否显示/不显示对应的菜品、后端是否重新执行SQL语句。
缓存套餐
介绍缓存套餐前,我们需先来了解Spring Cache,它是由Spring提供的缓存框架,是一种缓存抽象机制,可用于简化应用中的缓存操作。
其提供了一系列的注解,当我们需要操作缓存数据时,只需在相应的方法上加上这些注解即可。这种使用方式类似于我们之前学习的事务管理。
Spring Cache
Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加入一个注解,就能够实现缓存功能。Spring Cache 是一个框架,提供了一个层抽象,底层可以使用不同的缓存实现,例如:EHCache、Caffeine、Redis。
本项目使用的是Redis,因此缓存实现也是Redis,想要使用该框架就需要先在pom.xml中导入对应的Maven坐标。该框架非常灵活,如果以后项目需要换一种具体的缓存实现,只需导入其相关jar包即可,而之前提供的注解都是通用的。
常用注解有四个:
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,通常加在启动类上 |
@Cacheable | 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
接下来我们通过资料中提供的springcache-demo案例来了解该框架的用法。
入门案例
使用idea打开该项目,新建spring_cache_demo数据库,并执行springcachedemo.sql中的代码新建user表。然后修改配置文件中MySQL数据库的密码。注意在该项目中,后端Tomcat的端口号被修改为为8888。Redis使用的1号数据库。
先在启动类上添加注解@EnableCaching来开启缓存注解功能。
再到UserController中,在save方法上添加注解@CachePut(),该注解生成的Redis数据的key的命名方式有多种,我们依次来介绍。
首先是同时以类似字符串的形式指定cacheNames和key的值,中间以","分隔。其插入的数据在Redis中Key的值就为"cacheNames::key",cacheNames通常命名为和业务相关的名称。
key命名则和动态SQL一样,为避免每次保存用户传入的key都相同,我们可以使用Spring Expression Language(简称SPEL),写法也和动态SQL类似,为key = "#user.id",其中user需与传入的user参数名称保持一致。
同时,如果该方法传入了多个参数,想要指定直接使用第几个参数,还可以使用#p0、#a0、#root.args[0]来代表第一个参数,然后使用例如".id"获取对象中的属性。
不仅可以使用该方法传入的参数的值,还可以使用该方法返回的参数的值,为key = "#result.id"。其中"."叫做对象导航,我们可以通过该导航得到对象中某个属性。
@CachePut(cacheNames="userCache",key="#user.id")
// @CachePut(cacheNames="userCache",key="#result.id")
// @CachePut(cacheNames="userCache",key="#p0.id")
// @CachePut(cacheNames="userCache",key="#a0.id")
// @CachePut(cacheNames="userCache",key="#root.args[0].id")
public User save(User user,Job job){
......
return user;
}
虽然方法多样,但如果条件允许,我们仍推荐使用第一种方法。
将项目运行起来并访问swagger接口文档http://localhost:8888/doc.html来测试save()方法,只需传入age和name,因为id是自增的:
因为在Redis中key的值为userCache::2,系统会自动为其生成二级目录,且第二级为空,这和java命名中以"."来区分层级类似,是以":"来区分层级的。
接下来来看getById()方法,我们需要先判断Redis缓存中是否存在对应的数据,存在则直接返回,不存在则查询MySQL、添加Redis缓存、返回。这一切都可以通过@Cacheable注解实现。
注意注解的引用路径为:org.springframework.cache.annotation.Cacheable,同时该注解不支持#result的命名方式,点击key按ctrl+b就可查看该注解支持的命名方式。
为与上文save方法添加的Redis数据保持一致,cacheNames = "userCache",key则直接使用传参的#id。
@GetMapping
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
User user = userMapper.getById(id);
return user;
}
最后是删除数据,对应的注解为@CacheEvict(),key的命名不再介绍。
@DeleteMapping
@CacheEvict(cacheNames = "userCache", key = "#id")
public void deleteById(Long id) {
userMapper.deleteById(id);
}
批量删除数据同理,我们先多次调用save()方法确保MySQL和Redis数据库中有多个数据。然后添加@CacheEvict注解,cacheNames = "userCache"照常写,但因为是删除全部数据,而非单一的key对应的数据,因此key不再使用,取而代之的是allEntries = true,意为删除userCache及该目录下的所有数据。
@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true)
public void deleteAll() {
userMapper.deleteAll();
}
完善项目
了解了这些注解后,我们来将其融入到代码中:
一、导入Spring Cache和Redis相关maven坐标
本项目中已经导入,了解即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
二、在启动类上加入@EnableCaching注解,开启缓存注解功能
@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching//开启缓存注解功能
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}
三、在用户端接口SetmealController的list方法上加入@Cacheable注解
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames = "setmealCache",key = "#categoryId")//key为setmealCache::12,对应的value就是该方法的返回结果
public Result<List<Setmeal>> list(Long categoryId) {
Setmeal setmeal = new Setmeal();
setmeal.setCategoryId(categoryId);
setmeal.setStatus(StatusConstant.ENABLE);
List<Setmeal> list = setmealService.list(setmeal);
return Result.success(list);
}
四、在管理端接口SetmealController的save、delete、update、startOrStop等方法上加入CacheEvict注解
public class SetmealController {
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmealCache", key = "setmealDTO.categoryId")
public Result save(@RequestBody SetmealDTO setmealDTO) {
setmealService.saveWithDish(setmealDTO);
return Result.success();
}
@DeleteMapping
@ApiOperation("批量删除套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result delete(@RequestParam List<Long> ids) {
setmealService.deleteByIds(ids);
return Result.success();
}
@PutMapping
@ApiOperation("修改套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result update(@RequestBody SetmealDTO setmealDTO) {
setmealService.update(setmealDTO);
return Result.success();
}
@PostMapping("/status/{status}")
@ApiOperation("套餐起售、停售")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result<String> updateStatus(@PathVariable Integer status, Long id) {
setmealService.updateStatus(status, id);
return Result.success();
}
}
完善套餐后再查询套餐系统就会将数据添加到Redis中:
添加购物车
套餐添加到购物车较简单,而菜品则分为两种情况:有口味和无口味,无口味的直接添加,但有口味的还需先选择口味再添加。
先来分析购物车的数据库设计,首先该数据表需要存放已选的商品、商品数量、同时每个用户的购物车都是一个单独的数据表,不能多个用户同时使用同一个数据表,否则会造成数据串联。因此该数据表(shopping_cart)的结构为:
字段名 | 数据类型 | 说明 | 备注 |
---|---|---|---|
id | bigint | 主键 | 自增 |
name | varchar(32) | 商品名称 | 冗余字段 |
image | varchar(255) | 商品图片路径 | 冗余字段 |
user_id | bigint | 用户id | 逻辑外键 |
dish_id | bigint | 菜品id | 逻辑外键 |
setmeal_id | bigint | 套餐id | 逻辑外键 |
dish_flavor | varchar(50) | 菜品口味 | - |
number | int | 商品数量 | - |
amount | decimal(10,2) | 商品单价 | 冗余字段 |
create_time | datetime | 创建时间 | - |
有几个特殊的字段:冗余字段,这几个字段并不是该表特有的,其他表中也存储了该信息,系统可以通过某个ID查询其他表来得到这些字段。
我们在点开购物车时,需要展示这些字段,如果没有这些冗余字段,我们每次查看购物车都需重新查询多张表,相当于多表的连接查询,有了这些冗余字段后,我们查看购物车时只需进行单表查询即可,这样可以提高查询的效率。
但注意,冗余字段不能大量使用,且这些冗余字段应该是比较稳定,不经常变化的。因为冗余字段会增加数据库的存储需求,同时因为冗余字段在多张表中存在,每个更改都需发起多个SQL请求,降低了系统性能。
因为添加同一种商品时,购物车内并不会展示两条数据,而是展示一条数据同时数量为2,也就是说在向购物车数据表中添加商品时,如果数据已存在则更新数量+1,如果不存在则执行insert操作。
每个用户的购物车都是一个单独的数据,我们将user_id作为用户的唯一标识。
介绍完思路后回到项目中,该方法请求路径为/user/shoppingCart/add,请求方法为post。传入的参数为套餐ID或菜品ID或菜品ID+口味,以json格式提交。后端使用ShoppingCartDTO来接收。
在server模块的controller包user包下创建ShoppingCartController,因为该操作为新增操作,只需返回状态码表示是否完成操作即可,无需返回值,所以方法不设泛型。
// Controller———————————————————
@RestController
@RequestMapping("/user/shoppingCart")
@Api(tags = "C端购物车相关接口")
@Slf4j
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
//添加购物车
@PostMapping("/add")
@ApiOperation("添加购物车")
public Result add(@RequestBody ShoppingCartDTO shoppingCartDTO) {
log.info("添加购物车,商品信息为:{}", shoppingCartDTO);
shoppingCartService.addShoppingCart(shoppingCartDTO);
return Result.success();
}
}
// Service———————————————————————
public interface ShoppingCartService {
//添加购物车
void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}
// ServiceImpl———————————————————
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;
//添加购物车
@Override
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
//判断当前添加的商品在购物车中是否已存在
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
shoppingCart.setUserId(BaseContext.getCurrentId());
//只可能有两者情况:1、查不到数据,2、查到一条数据
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
//商品已存在,数量+1
if(list != null && list.size() > 0) {
ShoppingCart cart = list.get(0);
cart.setNumber(cart.getNumber()+1);
shoppingCartMapper.updateById(cart);
}else {
//不存在,执行insert
//先判断本次添加的是套餐还是菜品
Long dishId = shoppingCartDTO.getDishId();
if (dishId != null) {
//dishId不为空,判断为菜品
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());//不能使用copy,因为dish中的菜品id会覆盖掉shoppingCart中的id
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
//dishId为空,判断为套餐
Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}
}
}
// Mapper———————————————————————
@Mapper
public interface ShoppingCartMapper {
//动态条件查询
List<ShoppingCart> list(ShoppingCart shoppingCart);
//根据ID修改商品数量
@Update("update shopping_cart set number =#{number} where id=#{id}")
void updateById(ShoppingCart shoppingCart);
@Insert("insert into shopping_cart (name, image, user_id, dish_id, setmeal_id, dish_flavor, number, amount, create_time) " +
"VALUES (#{name},#{image},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{number},#{amount},#{createTime})")
void insert(ShoppingCart shoppingCart);
}
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
<select id="list" resultType="com.sky.entity.ShoppingCart">
select * from shopping_cart
<where>
<if test="userId!=null">and user_id=#{userId}</if>
<if test="dishId != null">and dish_id = #{dishId}</if>
<if test="setmealId != null">and setmeal_id = #{setmealId}</if>
<if test="dishFlavor != null">and dish_flavor = #{dishFlavor}</if>
</where>
</select>
</mapper>
此时在小程序端添加商品,因为查看购物车功能还未实现,我们可在数据库中观察数据的变化:
查看购物车
查询购物车本质是查询操作,因此不需要传入任何参数,同时判断用户的唯一标识user_id可以通过解析token得到。
请求路径为/user/shoppingCart/list,请求方法为get。返回数据为list,包含各商品的信息。
因为上文已经有了动态查询方法list,因此我们无需编写Mapper层,可以直接调用并传入包含user_id的ShoppingCart对象即可。
// Controller———————————————————
@GetMapping("/list")
@ApiOperation("查看购物车")
public Result<List<ShoppingCart>> list() {
List<ShoppingCart> list = shoppingCartService.showShoppingCart();
return Result.success(list);
}
// Service———————————————————————
List<ShoppingCart> showShoppingCart();
// ServiceImpl———————————————————
@Override
public List<ShoppingCart> showShoppingCart() {
// 构建ShoppingCart对象,设置当前用户ID
ShoppingCart shoppingCart = ShoppingCart.builder()
.userId(BaseContext.getCurrentId())
.build();
// 查询当前用户购物车中的商品列表
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
// 返回购物车商品列表
return list;
}
清空购物车
请求路径为/user/shoppingCart/clean,请求方法为del。
// Controller———————————————————
@DeleteMapping("/clean")
@ApiOperation("清空购物车")
public Result clean(){
shoppingCartService.cleanShoppingCart();
return Result.success();
}
// Service———————————————————————
void cleanShoppingCart();
// ServiceImpl———————————————————
@Override
public void cleanShoppingCart() {
shoppingCartMapper.cleanByUserId(BaseContext.getCurrentId());
}
// Mapper———————————————————————
@Delete("delete from shopping_cart where user_id=#{userId}")
void cleanByUserId(Long userId);
删除一个商品
增加商品等同于添加购物车,无需再编写代码,因此只需完成删除一个商品的功能即可。
请求路径为/user/shoppingCart/sub,请求方法为post。传入的参数为套餐ID或菜品ID或菜品ID+口味,以json格式提交。后端使用ShoppingCartDTO来接收。
// Controller———————————————————
@PostMapping("/sub")
@ApiOperation("减少商品数量")
public Result reduce(@RequestBody ShoppingCartDTO shoppingCartDTO) {
shoppingCartService.reduceNums(shoppingCartDTO);
return Result.success();
}
// Service———————————————————————
void reduceNums(ShoppingCartDTO shoppingCartDTO);
// ServiceImpl———————————————————
@Override
public void reduceNums(ShoppingCartDTO shoppingCartDTO) {
// 与添加购物车同理,先查询,后改值
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
shoppingCart.setUserId(BaseContext.getCurrentId());
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
// 如果商品已存在,则修改数量
if (list != null && list.size() > 0) {
shoppingCart = list.get(0);
if (shoppingCart.getNumber() == 1)
shoppingCartMapper.del(shoppingCart.getId());
else {
shoppingCart.setNumber(shoppingCart.getNumber() - 1);
shoppingCartMapper.updateById(shoppingCart);
}
}
}
// Mapper———————————————————————
@Delete("delete from shopping_cart where id =#{id}")
void del(Long id);