自学内容网 自学内容网

缓存商品&购物车

        上篇文章我们实现了展示菜品数据、口味数据、套餐数据、套餐内菜品数据,因为这些数据都存储在服务器的数据库之中,如果短时间内有大量的用户进行点餐操作,系统就会频繁的与数据库进行交互,数据库的访问压力随之增大,小程序端可能要等待一段时间才能获取各项信息,用户体验较差。

        Redis便可解决这一系列问题,将访问的压力从MySQL一个数据库分散到Redis和MySQL两个数据库上。

缓存菜品

        来分析基本流程:首先是小程序向后端发起查询请求,后端收到后首先判断缓存是否存在,如果存在则直接读取缓存,不存在则查询数据库,并将查询到的信息载入到缓存中。

        小程序在展示菜品时是根据分类来展示的,因此我们缓存数据也应该将每个分类下的菜品作为一份缓存数据。因为Redis存储数据的结构是K-V键值对,所以我们可以用key存储分类分类id,约定命名形式为dish_1、dish_2。菜品作为一个集合的数据转为String存储到Value中(Redis的String和java并不对应,因此java的任何数据都能转为String)。同时如果数据库中的菜品数据变更时,需要及时的清理缓存,避免造成缓存数据和MySQL数据不一致的情况。简而言之有三点:

  1. 每个分类下的菜品作为一份缓存数据
  2. key为分类id,value为菜品集合转化的String
  3. 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)的结构为:

字段名数据类型说明备注
idbigint主键自增
namevarchar(32)商品名称冗余字段
imagevarchar(255)商品图片路径冗余字段
user_idbigint用户id逻辑外键
dish_idbigint菜品id逻辑外键
setmeal_idbigint套餐id逻辑外键
dish_flavorvarchar(50)菜品口味-
numberint商品数量-
amountdecimal(10,2)商品单价冗余字段
create_timedatetime创建时间-

         有几个特殊的字段:冗余字段,这几个字段并不是该表特有的,其他表中也存储了该信息,系统可以通过某个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);

原文地址:https://blog.csdn.net/qq_65501197/article/details/145199922

免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!