跳至主要內容

apzs...大约 154 分钟

5.4、商城业务-缓存,锁

5.4.1、缓存-缓存使用

image-20220721212905833
image-20220721212905833

1、本地缓存

把从数据库中查询的数据放到本类的属性里面,再次有请求进入后,发现该类已存储从数据库中查询的数据,直接返回即可

image-20220713195239272
1、修改CategoryServiceImpl

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里修改部分方法,使把查出的数据放到本地的cache

private Map<String, List<Catelog2Vo>> cache = null;

@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    if (cache!=null){
        return cache;
    }
    Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
    cache = catalogJsonForDb;
    return catalogJsonForDb;
}


public Map<String, List<Catelog2Vo>> getCatalogJsonForDb() {
    //一次查询所有
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(null);
    //1、查出所有一级分类
    List<CategoryEntity> level1Categories = this.getLevel1Categories();
    Map<String, List<Catelog2Vo>> result = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), l1 -> {
        //2、该一级分类的所有二级分类
        List<CategoryEntity> category2Entities = getCategoryEntities(categoryEntities,l1);
        List<Catelog2Vo> catelog2VoList = null;
        if (category2Entities != null) {
            catelog2VoList = category2Entities.stream().map(l2 -> {
                Catelog2Vo catelog2Vo = new Catelog2Vo();
                catelog2Vo.setCatalog1Id(l1.getCatId().toString());
                catelog2Vo.setId(l2.getCatId().toString());
                catelog2Vo.setName(l2.getName());
                //3、当前二级分类的所有三级分类
                List<CategoryEntity> category3Entities = getCategoryEntities(categoryEntities,l2);
                List<Catelog2Vo.Catelog3Vo> catelog3VoList = null;
                if (category3Entities!=null){
                    catelog3VoList = category3Entities.stream().map(l3 -> {
                        Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo();
                        catelog3Vo.setId(l3.getCatId().toString());
                        catelog3Vo.setName(l3.getName());
                        catelog3Vo.setCatalog2Id(l2.getCatId().toString());
                        return catelog3Vo;
                    }).collect(Collectors.toList());
                }
                catelog2Vo.setCatalog3List(catelog3VoList);
                return catelog2Vo;
            }).collect(Collectors.toList());
        }
        return catelog2VoList;
    }));
    return result;
}

private List<CategoryEntity> getCategoryEntities(List<CategoryEntity> categoryEntities,CategoryEntity l) {
    //LambdaQueryWrapper<CategoryEntity> category2QueryWrapper = new LambdaQueryWrapper<>();
    //category2QueryWrapper.eq(CategoryEntity::getParentCid, l1.getCatId());
    //return this.baseMapper.selectList(category2QueryWrapper);
    List<CategoryEntity> collect = categoryEntities.stream().filter(categoryEntity -> {
        return categoryEntity.getParentCid().equals(l.getCatId());
    }).collect(Collectors.toList());
    return collect;
}
image-20220713194616644
image-20220713194616644
2、准备工作

线程组线程属性里的线程数里输入50,表示启动50个线程

线程组线程属性里的Ramp-Up时间(秒) :里输入1,表示1秒内启动完成

线程组循环次数里勾选永远

image-20220713194704485
image-20220713194704485

HTTP请求基本里的Web服务器协议:输入http服务器名称或IP:输入localhost端口号:输入10000路径输入/index/catalog.json

image-20220713194722501
image-20220713194722501

HTTP请求高级里,取消勾选从HTML文件获取所有内含的资源

image-20220713194735626
image-20220713194735626

要压测的接口: http://localhost:10000/index/catalog.json

image-20220713194807049
image-20220713194807049
3、执行测试
GIF 2022-7-13 19-44-33
GIF 2022-7-13 19-44-33
4、查看压测报告

可以看到,使用本地缓存的方式接口的吞吐量非常高

察看结果树

image-20220713194911408
image-20220713194911408

汇总报告

image-20220713194923340
image-20220713194923340

聚合报告

image-20220713194935181
image-20220713194935181
5、问题

集群环境下,每一个gulimall-product服务都存储自己的一份cache,这极大的浪费了的内存,

而且更严重的是当其中一个gulimall-product服务修改了数据后,其他服务感知不到修改,其他服务还在使用旧的数据,这样就违反了数据的最终一致性。

image-20220713195353065

2、分布式缓存

在分布式项目中,应使用分布式缓存技术,所有gulimall-product服务都向缓存中间件里获取数据,这样防止浪费了本地缓存,最重要的是当其中一个服务修改了数据后,其他服务也能获取最新的数据(虽然不能达到严格的一致性,但可以确保数据的最终一致性)

image-20220713195507412
1、添加依赖

gulimall-product模块的pom.xml文件内添加redis依赖

<!--引入redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
image-20220713200117460
image-20220713200117460
2、配置redis主机地址和端口

gulimall-product模块的src/main/resources/application.yml配置文件内配置redis主机地址和端口

spring:
  redis:
    host: 192.168.56.10
    port: 6379
image-20220713200015211
image-20220713200015211
3、可使用的对象

org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration类里提供了RedisTemplateStringRedisTemplate两个对象来操作redis

@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
      throws UnknownHostException {
   RedisTemplate<Object, Object> template = new RedisTemplate<>();
   template.setConnectionFactory(redisConnectionFactory);
   return template;
}

@Bean
@ConditionalOnMissingBean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory)
      throws UnknownHostException {
   StringRedisTemplate template = new StringRedisTemplate();
   template.setConnectionFactory(redisConnectionFactory);
   return template;
}
image-20220713200342377
image-20220713200342377

由于String类型在redis里非常常用,因此封装了继承RedisTemplate类的StringRedisTemplate用于操作redis

image-20220713200628278
image-20220713200628278
4、简单使用

常用方法:

//操作字符串
stringRedisTemplate.opsForValue();
//操作hash
stringRedisTemplate.opsForHash();
//操作list
stringRedisTemplate.opsForList();
//操作set
stringRedisTemplate.opsForSet();
//操作有序set
stringRedisTemplate.opsForZSet();

gulimall-product模块的com.atguigu.gulimall.product.GulimallProductApplicationTests测试类里添加如下测试代码,在redis里添加keyhello的数据,然后执行测试

@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void stringRedisTemplateTest(){
   ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
   //保存
   ops.set("hello","world"+ UUID.randomUUID());
   //查询
   String hello = ops.get("hello");
   System.out.println("之前保存的数据是:"+ hello);
}
image-20220713201403494
image-20220713201403494

打开Redis Desktop Manager可以看到keyhello的数据已经存进redis里了

image-20220713202139733
image-20220713202139733

3、测试

1、准备工作

线程组线程属性里的线程数里输入50,表示启动50个线程

线程组线程属性里的Ramp-Up时间(秒) :里输入1,表示1秒内启动完成

线程组循环次数里勾选永远

image-20220713221119905
image-20220713221119905

HTTP请求基本里的Web服务器协议:输入http服务器名称或IP:输入localhost端口号:输入10000路径输入/index/catalog.json

image-20220713221142043
image-20220713221142043

HTTP请求高级里,取消勾选从HTML文件获取所有内含的资源

image-20220713221159131
image-20220713221159131

要压测的接口: http://localhost:10000/index/catalog.json

image-20220713221912300
image-20220713221912300
2、执行测试
GIF 2022-7-13 22-29-14
GIF 2022-7-13 22-29-14
3、堆外内存溢出

可以看到http://localhost:10000/index/catalog.json已无法访问

image-20220713223323423
image-20220713223323423

察看结果树

image-20220713223350313
image-20220713223350313

汇总报告

image-20220713223400875
image-20220713223400875

聚合报告

image-20220713223414080
image-20220713223414080

控制台报堆外内存溢出

2022-07-13 22:34:29.065  WARN 8940 --- [ioEventLoop-4-2] io.lettuce.core.protocol.CommandHandler  : null Unexpected exception during request: io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 37748736 byte(s) of direct memory (used: 67108864, max: 100663296)

io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 37748736 byte(s) of direct memory (used: 67108864, max: 100663296)
   at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:725) ~[netty-common-4.1.39.Final.jar:4.1.39.Final]
   at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:680) ~[netty-common-4.1.39.Final.jar:4.1.39.Final]
   at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:772) ~[netty-buffer-4.1.39.Final.jar:4.1.39.Final]
   at io.netty.buffer.PoolArena$DirectArena.newUnpooledChunk(PoolArena.java:762) ~[netty-buffer-4.1.39.Final.jar:4.1.39.Final]
image-20220713223441846
image-20220713223441846
4、原因

因为spring-boot-starter-data-redis-2.1.8.RELEASE在操作redis时,使用了lettuce

<dependency>
  <groupId>io.lettuce</groupId>
  <artifactId>lettuce-core</artifactId>
  <version>5.1.8.RELEASE</version>
  <scope>compile</scope>
</dependency>
image-20220715103407245
image-20220715103407245

lettuce-core-5.1.8.RELEASE引用了netty

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-common</artifactId>
</dependency>

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-handler</artifactId>
</dependency>

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-transport</artifactId>
</dependency>
image-20220715103430255
image-20220715103430255

所以会报netty相关的异常

5、报错的类

io.netty.util.internal.OutOfDirectMemoryError.java类里报了这个异常

/**
 * {@link OutOfMemoryError} that is throws if {@link PlatformDependent#allocateDirectNoCleaner(int)} can not allocate
 * a new {@link ByteBuffer} due memory restrictions.
 */
public final class OutOfDirectMemoryError extends OutOfMemoryError {
    private static final long serialVersionUID = 4228264016184011555L;

    OutOfDirectMemoryError(String s) {
        super(s);
    }
}
image-20220715150401848
image-20220715150401848

io.netty.util.internal.PlatformDependent.java类里当newUsedMemory(新的已用内存) > DIRECT_MEMORY_LIMIT(直接内存限制),就会抛出异常。(这个直接内存是netty自己在底层计数)

private static void incrementMemoryCounter(int capacity) {
    if (DIRECT_MEMORY_COUNTER != null) {
        long newUsedMemory = DIRECT_MEMORY_COUNTER.addAndGet(capacity);
        if (newUsedMemory > DIRECT_MEMORY_LIMIT) {
            DIRECT_MEMORY_COUNTER.addAndGet(-capacity);
            throw new OutOfDirectMemoryError("failed to allocate " + capacity
                    + " byte(s) of direct memory (used: " + (newUsedMemory - capacity)
                    + ", max: " + DIRECT_MEMORY_LIMIT + ')');
        }
    }
}
image-20220715150701735
image-20220715150701735

在当前类申明了DIRECT_MEMORY_LIMIT

private static final long DIRECT_MEMORY_LIMIT;
image-20220715151444404
image-20220715151444404

可以通过-Dio.netty.maxDirectMemory这个虚拟机参数设置最大内存,如果不设置默认就和堆内存大小-Xmx参数一样

通过-Dio.netty.maxDirectMemory这个虚拟机参数调大内存、或修改堆内存-Xmx参数后,可以延缓异常抛出的时间,但不能解决问题

logger.debug("-Dio.netty.maxDirectMemory: {} bytes", maxDirectMemory);
DIRECT_MEMORY_LIMIT = maxDirectMemory >= 1 ? maxDirectMemory : MAX_DIRECT_MEMORY;
image-20220715151559659
image-20220715151559659

4、调大内存后重试

1、修改运行参数

GulimallProductApplication模块的运行配置里的Environment栏的VM options里的右方框里输入-Xmx300m,重新限制GulimallProductApplication模块的最大内存占用为300m

-Xmx300m

重新启动GulimallProductApplication服务

image-20220715153737538
image-20220715153737538
2、执行测试

可以看到调大-Xmx参数后,异常出现时间明显延后,但是还是不能从根本上解决问题

GIF 2022-7-15 15-46-29
GIF 2022-7-15 15-46-29
3、查看压测报告

察看结果树

image-20220715155239718
image-20220715155239718

汇总报告

image-20220715155249984
image-20220715155249984

聚合报告

image-20220715155301898
image-20220715155301898

jvisualvmcom.atguigu.gulimall.product.GulimallProductApplication服务的Visual GC里查看新生代老年代使用情况

image-20220715155209605
image-20220715155209605

jvisualvmcom.atguigu.gulimall.product.GulimallProductApplication服务的监视里查看CPU等资源使用情况

image-20220715155151814
image-20220715155151814

5、根本上解决对外内存异常

1、使用jedis

spring-boot-starter-data-redis-2.1.8.RELEASE里使用了lettuce

<dependency>
  <groupId>io.lettuce</groupId>
  <artifactId>lettuce-core</artifactId>
  <version>5.1.8.RELEASE</version>
  <scope>compile</scope>
</dependency>
image-20220713224440576
image-20220713224440576

因此只需要排除lettuce,并重新引入jedis即可

<!--引入redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
image-20220713224540175
image-20220713224540175

org\springframework\boot\spring-boot-dependencies\2.1.8.RELEASE\spring-boot-dependencies-2.1.8.RELEASE.pom

里指定了jedis的版本,因此我们并不需要指定jedis的版本

GIF 2022-7-13 22-50-50
GIF 2022-7-13 22-50-50

要压测的接口: http://localhost:10000/index/catalog.json

image-20220715152455155
image-20220715152455155
2、执行测试
GIF 2022-7-15 15-27-41
GIF 2022-7-15 15-27-41
3、查看压测报告

可以发现jedislettuce在性能上差了不少

察看结果树

image-20220715152929879
image-20220715152929879

汇总报告

image-20220715153026558
image-20220715153026558

聚合报告

image-20220715153043208
image-20220715153043208

jvisualvmcom.atguigu.gulimall.product.GulimallProductApplication服务的Visual GC里查看新生代老年代使用情况

image-20220715153055591
image-20220715153055591

jvisualvmcom.atguigu.gulimall.product.GulimallProductApplication服务的监视里查看CPU等资源使用情况

image-20220715153112065
image-20220715153112065
4、内存调大-Xmx300m又测试了一次

察看结果树

image-20220715160023816
image-20220715160023816

汇总报告

image-20220715160047736
image-20220715160047736

聚合报告

image-20220715160103038
image-20220715160103038

6、说明

org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration类里导入了LettuceConnectionConfiguration.classJedisConnectionConfiguration.class

@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
image-20220715160337658
image-20220715160337658

org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration类里如果RedisConnectionFactory.class类在ioc容器中不存在,则向ioc容器注入RedisConnectionFactory

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public LettuceConnectionFactory redisConnectionFactory(ClientResources clientResources)
      throws UnknownHostException {
   LettuceClientConfiguration clientConfig = getLettuceClientConfiguration(clientResources,
         this.properties.getLettuce().getPool());
   return createLettuceConnectionFactory(clientConfig);
}
image-20220715160551114
image-20220715160551114

org.springframework.boot.autoconfigure.data.redis.JedisConnectionConfiguration类里如果RedisConnectionFactory.class类在ioc容器中不存在,则向ioc容器注入RedisConnectionFactory

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public JedisConnectionFactory redisConnectionFactory() throws UnknownHostException {
   return createJedisConnectionFactory();
}
image-20220715160746996
image-20220715160746996

org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration类里则会使用它们其中一个注入到ioc容器的RedisConnectionFactory来操作redis

相当于lettuce jedis都是操作redis底层的客户端,spring再次封装成了redisTemplate

@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
      throws UnknownHostException {
   RedisTemplate<Object, Object> template = new RedisTemplate<>(`;
   template.setConnectionFactory(redisConnectionFactory);
   return template;
}

@Bean
@ConditionalOnMissingBean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory)
      throws UnknownHostException {
   StringRedisTemplate template = new StringRedisTemplate();
   template.setConnectionFactory(redisConnectionFactory);
   return template;
}
image-20220715161018842
image-20220715161018842

5.4.2、缓存-redis锁

1、常见问题

1、缓存穿透

缓存穿透: 指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是 数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不 存在的数据每次请求都要到存储层去查询,失去了缓存的意义

风险: 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃

解决: null结果缓存,并加入短暂过期时间

image-20220715162001456
2、缓存雪崩

缓存雪崩: 缓存雪崩是指在我们设置缓存时key采用了相同的过期时间, 导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时 压力过重雪崩。

解决: 原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这 样每一个缓存的过期时间的重复率就会降低,就很难引发集体 失效的事件。

image-20220715162139708
3、缓存穿透

缓存穿透

  • 对于一些设置了过期时间的key,如果这些key可能会在某些 时间点被超高并发地访问,是一种非常“热点”的数据。

  • 如果这个key在大量请求同时进来前正好失效,那么所有对 这个key的数据查询都落到db,我们称为缓存击穿。

解决: 加锁

大量并发只让一个去查,其他人等待,查到以后释放锁,其他 人获取到锁,先查缓存,就会有数据,不用去db

image-20220715162219429

2、本地锁(单体架构)

通过本地锁(性能高),只能锁住当前进程,在分布式环境下如果部署了100台机器,如果只锁当前进程,虽然放行了100个线程查询数据库,看起来性能也还行。但是如果一台机器修改该数据,其他机器却感知不到数据被修改了,这就引起了数据最终不一致的问题。因此在分布式架构下,应使用分布式锁(性能低)。

image-20220721205546480
image-20220721205546480

本地锁,只能锁住当前进程,所以我们需要分布式锁

1、本地锁代码

修改gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的代码

/**
 * 1、空结果缓存:解诀缓存穿透
 * 2、设置过期时间(加随机值) :解诀缓存雪崩
 * 3、加锁:解决缓存击穿
 */
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    //1、加入缓存逻辑,缓存中存的数据是json字符串。
    //JSON跨语言,跨平台兼容。
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    String catalogJson = ops.get("catalogJson");
    if (!StringUtils.hasText(catalogJson)) {
        //2、缓存中没有,查询数据库
        System.out.println("缓存不命中...查询数据库...");
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
        //3.查到的数据再放入缓存,将对象转为json放在缓存中
        String s = JSON.toJSONString(catalogJsonForDb);
        ops.set("catalogJson",s);
        return catalogJsonForDb;
    }
    System.out.println("缓存命中...直接返回");
    TypeReference<Map<String, List<Catelog2Vo>>> typeReference = new TypeReference<Map<String, List<Catelog2Vo>>>() {
    };
    return JSON.parseObject(catalogJson,typeReference);
}


public Map<String, List<Catelog2Vo>> getCatalogJsonForDb() {
    //得到锁以后,我们应该再去缓存中确定一 次,如果没有才需要继续查询(双检锁)
    //只要是同一把锁,就能锁住需要这个锁的所有线程
    //synchronized (this): SpringBoot所有的组件在容器中都是单例的。
    //TODO 本地锁: synchronized, JUC(Lock) 在分布式情况下,想要锁住所有,必须使用分布式锁
    synchronized (this) {
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String catalogJson = ops.get("catalogJson");
        if (StringUtils.hasText(catalogJson)) {
            TypeReference<Map<String, List<Catelog2Vo>>> typeReference = new TypeReference<Map<String, List<Catelog2Vo>>>() {
            };
            return JSON.parseObject(catalogJson,typeReference);
        }
        System.out.println("查询了数据库......");
        //一次查询所有
        List<CategoryEntity> categoryEntities = this.baseMapper.selectList(null);
        //1、查出所有一级分类
        List<CategoryEntity> level1Categories = this.getLevel1Categories();
        Map<String, List<Catelog2Vo>> result = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), l1 -> {
            //2、该一级分类的所有二级分类
            List<CategoryEntity> category2Entities = getCategoryEntities(categoryEntities, l1);
            List<Catelog2Vo> catelog2VoList = null;
            if (category2Entities != null) {
                catelog2VoList = category2Entities.stream().map(l2 -> {
                    Catelog2Vo catelog2Vo = new Catelog2Vo();
                    catelog2Vo.setCatalog1Id(l1.getCatId().toString());
                    catelog2Vo.setId(l2.getCatId().toString());
                    catelog2Vo.setName(l2.getName());
                    //3、当前二级分类的所有三级分类
                    List<CategoryEntity> category3Entities = getCategoryEntities(categoryEntities, l2);
                    List<Catelog2Vo.Catelog3Vo> catelog3VoList = null;
                    if (category3Entities != null) {
                        catelog3VoList = category3Entities.stream().map(l3 -> {
                            Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo();
                            catelog3Vo.setId(l3.getCatId().toString());
                            catelog3Vo.setName(l3.getName());
                            catelog3Vo.setCatalog2Id(l2.getCatId().toString());
                            return catelog3Vo;
                        }).collect(Collectors.toList());
                    }
                    catelog2Vo.setCatalog3List(catelog3VoList);
                    return catelog2Vo;
                }).collect(Collectors.toList());
            }
            return catelog2VoList;
        }));
        return result;
    }
}
image-20220715163745249
image-20220715163745249
2、删除rediskeycatalogJson的数据

使用Redis Desktop Manager工具,删除rediskeycatalogJson的数据

image-20220716085225713
image-20220716085225713
3、重新测试(锁-时序问题)
image-20220721205904236

重新运行GulimallProductApplication服务,不要刷新前端页面,确保在压力测试之前、redis里面没有catalogJson的数据

可以发现查询了两次数据库,这是因为加锁加小了,查询完数据库后就释放锁了,释放锁以后才把最新数据发送给redis,在发送给redis的这段时间内,又一个线程进来了它从redis获取数据发现没有获取到(此时先查询数据库的线程还未完全把数据放到redis里),因此查询了两次数据库。正确的做法应该是先放入缓存,再释放锁

GIF 2022-7-16 8-58-13
GIF 2022-7-16 8-58-13
4、修改CategoryServiceImpl类代码

修改gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类,把getCatalogJson方法里的以下代码

//3.查到的数据再放入缓存,将对象转为json放在缓存中
String s = JSON.toJSONString(result);
ops.set("catalogJson", s);

放到getCatalogJsonForDb方法的锁里面,保证只有一个线程查询数据库

image-20220716091107158
image-20220716091107158

最后我又修改成了这样:

/**
 * 1、空结果缓存:解诀缓存穿透
 * 2、设置过期时间(加随机值) :解诀缓存雪崩
 * 3、加锁:解决缓存击穿
 */
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    //1、加入缓存逻辑,缓存中存的数据是json字符串。
    //JSON跨语言,跨平台兼容。
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    String catalogJson = ops.get("catalogJson");
    if (!StringUtils.hasText(catalogJson)) {
        //2、缓存中没有,查询数据库
        System.out.println("缓存不命中...查询数据库...");
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDbWithLocalLock();
        //3.查到的数据再放入缓存,将对象转为json放在缓存中
        String s = JSON.toJSONString(catalogJsonForDb);
        ops.set("catalogJson",s);
        return catalogJsonForDb;
    }
    System.out.println("缓存命中...直接返回");
    TypeReference<Map<String, List<Catelog2Vo>>> typeReference = new TypeReference<Map<String, List<Catelog2Vo>>>() {
    };
    return JSON.parseObject(catalogJson,typeReference);
}

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithLocalLock() {
    //得到锁以后,我们应该再去缓存中确定一 次,如果没有才需要继续查询(双检锁)
    //只要是同一把锁,就能锁住需要这个锁的所有线程
    //synchronized (this): SpringBoot所有的组件在容器中都是单例的。
    //TODO 本地锁: synchronized, JUC(Lock) 在分布式情况下,想要锁住所有,必须使用分布式锁
    synchronized (this) {
        return getCatalogJsonForDb();
    }
}

private Map<String, List<Catelog2Vo>> getCatalogJsonForDb() {
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    String catalogJson = ops.get("catalogJson");
    if (StringUtils.hasText(catalogJson)) {
        TypeReference<Map<String, List<Catelog2Vo>>> typeReference = new TypeReference<Map<String, List<Catelog2Vo>>>() {
        };
        return JSON.parseObject(catalogJson, typeReference);
    }
    System.out.println("查询了数据库......");
    //一次查询所有
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(null);
    //1、查出所有一级分类
    List<CategoryEntity> level1Categories = this.getLevel1Categories();
    Map<String, List<Catelog2Vo>> result = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), l1 -> {
        //2、该一级分类的所有二级分类
        List<CategoryEntity> category2Entities = getCategoryEntities(categoryEntities, l1);
        List<Catelog2Vo> catelog2VoList = null;
        if (category2Entities != null) {
            catelog2VoList = category2Entities.stream().map(l2 -> {
                Catelog2Vo catelog2Vo = new Catelog2Vo();
                catelog2Vo.setCatalog1Id(l1.getCatId().toString());
                catelog2Vo.setId(l2.getCatId().toString());
                catelog2Vo.setName(l2.getName());
                //3、当前二级分类的所有三级分类
                List<CategoryEntity> category3Entities = getCategoryEntities(categoryEntities, l2);
                List<Catelog2Vo.Catelog3Vo> catelog3VoList = null;
                if (category3Entities != null) {
                    catelog3VoList = category3Entities.stream().map(l3 -> {
                        Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo();
                        catelog3Vo.setId(l3.getCatId().toString());
                        catelog3Vo.setName(l3.getName());
                        catelog3Vo.setCatalog2Id(l2.getCatId().toString());
                        return catelog3Vo;
                    }).collect(Collectors.toList());
                }
                catelog2Vo.setCatalog3List(catelog3VoList);
                return catelog2Vo;
            }).collect(Collectors.toList());
        }
        return catelog2VoList;
    }));
    //3.查到的数据再放入缓存,将对象转为json放在缓存中
    String s = JSON.toJSONString(result);
    ops.set("catalogJson", s);
    return result;
}
image-20220716191130234
image-20220716191130234
5、重新测试

重启GulimallProductApplication服务,删除redis里的catalogJson的数据,开始测试。这时就只有一个线程查询了数据库

GIF 2022-7-16 9-13-46
GIF 2022-7-16 9-13-46

3、集群测试

1、启动多个product服务

选中Service里的GulimallProductApplication服务,右键选择Copy Configuration..或者按Ctrl+D 快捷键,复制一个配置

name里输入GulimallProductApplication - 10001,在Program arguments:里输入--server.port=10001用于在启动的参数里指定运行的端口

同理在复制2个

GulimallProductApplication - 10001
--server.port=10001

GulimallProductApplication - 10002
--server.port=10002

GulimallProductApplication - 10003
--server.port=10003
GIF 2022-7-16 9-29-47
GIF 2022-7-16 9-29-47

一共复制了3个配置

image-20220716191356029
image-20220716191356029
2、启动这些服务

启动GulimallProductApplication - 10001服务、GulimallProductApplication - 10002服务、GulimallProductApplication - 10003服务

GIF 2022-7-16 9-36-02
GIF 2022-7-16 9-36-02
3、准备工作

使用Redis Desktop Manager工具,删除rediskeycatalogJson的数据

5.4.1.7.2.9
5.4.1.7.2.9

线程组线程属性里的线程数里输入100,表示启动100个线程

线程组线程属性里的Ramp-Up时间(秒) :里输入1,表示1秒内启动完成

线程组循环次数里输入5

image-20220716093748583
image-20220716093748583

HTTP请求基本里的Web服务器协议:输入http服务器名称或IP:输入gulimall端口号:输入80路径输入/index/catalog.json

通过nginx负载均衡到这些project服务

image-20220716094914063
image-20220716094914063
4、执行测试
GIF 2022-7-16 9-52-32
GIF 2022-7-16 9-52-32
5、查看结果

可以看到这次只查询了一次数据库

GIF 2022-7-16 9-54-15
GIF 2022-7-16 9-54-15

察看结果树

image-20220716100401468
image-20220716100401468

汇总报告

image-20220716100423673
image-20220716100423673

聚合报告

image-20220716100440990
image-20220716100440990

4、手动分布式锁

分布式锁演进-基本原理

image-20220721210115751
image-20220721210115751

我们可以同时去一个地方“占坑”,如果占到,就执行逻辑。否则就必须等待,直到释放锁。

“占坑”可以去redis,可以去数据库,可以去任何大家都能访问的地方。 等待可以自旋的方式。

1、参考文档

中文文档地址: https://www.redis.com.cn/commands/set.html

SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX] [GET]

Redis SET 命令用于将键 key 设定为指定的“字符串”值。

如果 key 已经保存了一个值,那么这个操作会直接覆盖原来的值,并且忽略原始类型。

set 命令执行成功之后,之前设置的过期时间都将失效

选项

从2.6.12版本开始,redis为SET命令增加了一系列选项:

  • EX seconds – 设置键key的过期时间,单位时秒
  • PX milliseconds – 设置键key的过期时间,单位时毫秒
  • NX – 只有键key不存在的时候才会设置key的值
  • XX – 只有键key存在的时候才会设置key的值
  • KEEPTTL -- 获取 key 的过期时间
  • GETopen in new window -- 返回 key 存储的值,如果 key 不存在返回空

注意: 由于SET命令加上选项已经可以完全取代SETNX, SETEXopen in new window, PSETEXopen in new window, GETSETopen in new window,的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。

返回值

字符串open in new window: 如果SET命令正常执行那么回返回OK 多行字符串open in new window: 使用 GET 选项,返回 key 存储的值,如果 key 不存在返回空 open in new window: 否则如果加了NX 或者 XX选项,SETopen in new window 没执行,那么会返回nil。

历史

  • >= 2.6.12: Added the EX, PX, NX and XX options.
  • >= 6.0: Added the KEEPTTL option.
  • >= 6.2: Added the GETopen in new window option.

例子

redis> SET mykey "Hello"
"OK"
redis> GET mykey
"Hello"
redis> SET anotherkey "will expire in a minute" EX 60
"OK"
redis>

SETopen in new window

Note: 下面这种设计模式并不推荐用来实现redis分布式锁。应该参考 the Redlock algorithmopen in new window 的实现,虽然这个方法只是复杂一点,但是却能保证更好的使用效果。

命令 SET resource-name anystring NX EX max-lock-time 是一种用 Redis 来实现锁机制的简单方法。

如果上述命令返回OK,那么客户端就可以获得锁(如果上述命令返回Nil,那么客户端可以在一段时间之后重新尝试),并且可以通过DEL命令来释放锁。

客户端加锁之后,如果没有主动释放,会在过期时间之后自动释放。

可以通过如下优化使得上面的锁系统变得更加鲁棒:

  • 不要设置固定的字符串,而是设置为随机的大字符串,可以称为token。
  • 通过脚步删除指定锁的key,而不是DEL命令。

上述优化方法会避免下述场景:a客户端获得的锁(键key)已经由于过期时间到了被redis服务器删除,但是这个时候a客户端还去执行DEL命令。而b客户端已经在a设置的过期时间之后重新获取了这个同样key的锁,那么a执行DEL就会释放了b客户端加好的锁。

解锁脚本的一个例子将类似于以下:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

这个脚本执行方式如下:

EVAL ...script... 1 resource-name token-value

可用版本>= 1.0.0.

时间复杂度: O(1)

GIF 2022-7-16 10-00-15
GIF 2022-7-16 10-00-15
2、redis命令获取锁

进入redis的客户端,执行set lock haha NX即可获取锁

docker exec -it redis redis-cli
set lock haha NX
1、复制当前会话

点击1 电商 ,右键选择复制会话(D)即可复制当前会话

双击1 电商 也可以复制当前会话

GIF 2022-7-16 10-16-12
GIF 2022-7-16 10-16-12
2、多个会话同时执行命令

Xshell里依次点击 查看(V) -> 撰写(C) -> 撰写栏(B) 即可在Xshell下方打开撰写栏

点击撰写栏的左边,选择全部会话(A),在撰写栏发送的所有命名都会发送给所有会话

如在撰写栏里输入docker exec -it redis redis-cli命令,所有会话都执行了该命令

GIF 2022-7-16 10-19-01
GIF 2022-7-16 10-19-01

Xshell里点击工具(T)里 的发送键输入到所有会话(K),在一个会话里输入的命令,也会同步发送给其他发送键输入到所有会话。状态为的会话

GIF 2022-7-16 10-22-25
GIF 2022-7-16 10-22-25

在一个会话的输入命令的地方右键选择发送键输入到所有会话(K),在这个会话里输入的命令,也会同步发送给其他发送键输入到所有会话。状态为的会话

GIF 2022-7-16 10-25-22
GIF 2022-7-16 10-25-22

点击发送键输入到所有会话。右侧的OFF按钮后,别的会话发送发送键输入到所有会话(K)类型的命令该会话不会执行

GIF 2022-7-16 20-11-40
GIF 2022-7-16 20-11-40
3、获取锁

所有会话执行set lock haha NX命令,可以看到只有2 电商获取到了锁

GIF 2022-7-16 10-20-14
GIF 2022-7-16 10-20-14
3、简单分布式锁
image-20220721210223375

问题:setnx占好了位,业务代码异常或者程序在页面过程 中宕机。没有执行删除锁逻辑,这就造成了死锁

解决:设置锁的自动过期,即使没有删除,会自动删除

本次可以完成分布式锁的功能,但是getCatalogJsonForDb()方法有可能抛出异常 stringRedisTemplate.delete("lock");删除锁就无法执行,导致锁一直无法释放,从而导致死锁

即使把stringRedisTemplate.delete("lock");删除锁的代码放到finally里面,也有可能出现机器宕机停电等意外情况,导致finally里面的代码无法执行,从而导致无法删除锁

org.springframework.data.redis.core.ValueOperations接口里的setIfAbsent方法相当于SETNX命令(与set key value NX一样)

image-20220716104241003
image-20220716104241003

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里,添加getCatalogJsonForDbWithRedisLock方法

点击查看CategoryServiceImpl类完整代码

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisLock() {
    //获取redis锁
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
    if (lock){
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
        //删除锁
        stringRedisTemplate.delete("lock");
        return catalogJsonForDb;
    }else {
        //加锁失败,休眠100ms后重试,synchronized
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //自旋锁
        return getCatalogJsonForDbWithRedisLock();
    }
}
image-20220716105028700
image-20220716105028700
4、添加过期时间

问题:setnx设置好,正要去设置过期时间,宕机。又死锁了。

解决:设置过期时间和占位必须是原子的。redis支持使用setnx ex命令

image-20220721210446105

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里,修改getCatalogJsonForDbWithRedisLock方法

获取锁后可以修改该锁的过期时间,这样看似解决了机器宕机停电等意外情况,导致无法删除锁的问题。

但是有可能在获取锁后,stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);修改该锁的过期时间之前机器宕机了,这样锁还是不会释放

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisLock() {
    //获取redis锁
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
    if (lock){
        //设置过期时间,必须和加锁是同步的,原子的
        stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
        //删除锁
        stringRedisTemplate.delete("lock");
        return catalogJsonForDb;
    }else {
        //加锁失败,休眠100ms后重试,synchronized
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //自旋锁
        return getCatalogJsonForDbWithRedisLock();
    }
}
image-20220716105732979
image-20220716105732979
5、获取锁并设置过期时间
image-20220721210625076

问题:

1、删除锁直接删除???

如果由于业务时间很长,锁自己过期了,我们 直接删除,有可能把别人正在持有的锁删除了。

解决:占锁的时候,值指定为uuid,每个人匹配是自己 的锁才删除。

上一步不能解决问题的原因根本上是获取锁设置过期时间不是原子操作,这样就确保这两步都执行都不执行

因此设置过期时间,必须和加锁是同步的,原子的

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里,修改getCatalogJsonForDbWithRedisLock方法,保证获取锁设置过期时间是原子的

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisLock() {
    //获取redis锁
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111",30, TimeUnit.SECONDS);
    if (lock){
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
        //删除锁
        stringRedisTemplate.delete("lock");
        return catalogJsonForDb;
    }else {
        //加锁失败,休眠100ms后重试,synchronized
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //自旋锁
        return getCatalogJsonForDbWithRedisLock();
    }
}
image-20220716105834671
image-20220716105834671

执行语句大概相当于以下命令

#进入redis的客户端
docker exec -it redis redis-cli
#获取锁并设置过期时间为30s
set lock 111 EX 30 NX
#查看该锁的过期时间(最终为-2)
ttl lcok
GIF 2022-7-16 21-05-26
GIF 2022-7-16 21-05-26
6、释放锁之前先判断
image-20220721210734185

问题:如果正好判断是当前值,正要删除锁的时候,锁已经过期, 别人已经设置到了新的值。那么我们删除的是别人的锁

解决:删除锁必须保证原子性。使用redis+Lua脚本完成

有可能当前线程获取的锁已经过期了。然后别的线程也进来了,此时别的线程获取了这把锁。然后当前线程执行完业务代码后,把别的线程获取的锁给释放了。因此释放锁之前应先判断是不是自己的锁

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里,修改getCatalogJsonForDbWithRedisLock方法,释放锁之前应先判断是不是自己的锁

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisLock() {
    //获取redis锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,30, TimeUnit.SECONDS);
    if (lock){
        Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDb();
        //删除锁
        String lockValue = stringRedisTemplate.opsForValue().get("lock");
        if (uuid.equals(lockValue)) {
            stringRedisTemplate.delete("lock");
        }
        return catalogJsonForDb;
    }else {
        //加锁失败,休眠100ms后重试,synchronized
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //自旋锁
        return getCatalogJsonForDbWithRedisLock();
    }
}
image-20220716110950969
image-20220716110950969
7、判断并删除
image-20220721210834600

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性。 更难的事情,锁的自动续期

释放锁之前应先判断是不是自己的锁这个阶段,先获取当前锁的值,在当前线程获取到该锁的值后,有可能当前线程获取的锁到了过期时间,此时别的线程进入后,重新获取到了锁,此时当前线程获取的锁的值还是自己锁的值,从而导致删除了别的线程的锁

出现这种情况的原因还是因为判断锁的值删除该锁不是原子操作,可以使用lua脚本,保证判断锁的值删除该锁是原子操作

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里,修改getCatalogJsonForDbWithRedisLock方法,保证判断锁的值删除该锁是原子操作

该操作可以保证不会死锁释放了别的线程的锁,但是当前线程未执行完,锁有可能已经过期了,此时别的线程就可以占用了这个锁,因此应加上自动续期续期的功能

中文文档: Redis SET 命令open in new window

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end
image-20220716121842148
image-20220716121842148
public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisLock() {
    //获取redis锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,30, TimeUnit.SECONDS);
    if (lock){
        Map<String, List<Catelog2Vo>> catalogJsonForDb;
        try {
            catalogJsonForDb = getCatalogJsonForDb();
        }finally {
            //删除锁
            String script = "if redis.call('get',KEYS[1]) == ARGV[1] then  return redis.call('del',KEYS[1]) else return 0 end";
            // KEYS[1] 为 Arrays.asList("lock") ;ARGV[1] 为 uuid
            Long lockValue = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script,Long.class)
                    ,Arrays.asList("lock"),uuid);
            if (lockValue!=null && lockValue==1){
                System.out.println("删除成功...");
            }else {
                System.out.println("删除失败...");
            }
        }
        return catalogJsonForDb;
    }else {
        //加锁失败,休眠100ms后重试,synchronized
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //自旋锁
        return getCatalogJsonForDbWithRedisLock();
    }
}
image-20220716123404456
image-20220716123404456

5.4.3、缓存-Redisson分布式锁

官方文档: https://github.com/redisson/redisson/wiki/

GIF 2022-7-16 14-36-13
GIF 2022-7-16 14-36-13

1、使用Redisson

可以使用Redisson(Redis Java client with features of In-Memory Data Grid)来操作redis,进而了解Redisson的使用过程

后续可以使用Redisson/Spring Boot Starter来操作redis

1、引入依赖

Maven Repository 里查找Redisson: https://mvnrepository.com/artifact/org.redisson/redisson

image-20220716145200593
image-20220716145200593

引入redisson,做分布式锁和分布式对象

<!-- 引入redisson,做分布式锁和分布式对象 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.0</version>
</dependency>
image-20220716145354907
image-20220716145354907
2、添加配置

gulimall-product模块的com.atguigu.gulimall.product.config包里新建MyRedissonConfig类,在MyRedissonConfig类里新建redisson方法

package com.atguigu.gulimall.product.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;

/**
 * @author 无名氏
 * @date 2022/7/16
 * @Description:
 */
@Configuration
public class MyRedissonConfig {

    /**
     * 所有对Redisson的使用都是通过RedissonClient对象
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod="shutdown")
    RedissonClient redisson() throws IOException {
        //1、创建配置
        Config config = new Config();
        //config.useSingleServer().setAddress("192.168.56.10:6379").setPassword("");
        config.useSingleServer().setAddress("192.168.56.10:6379");
        //2、根据Config创建出RedissonClient示例
        return Redisson.create(config);
    }

}
image-20220716150239076
image-20220716150239076

第三方框架整合的参考文档:

https://github.com/redisson/redisson/wiki/14.-第三方框架整合
@Configuration
@ComponentScan
@EnableCaching
public static class Application {

    @Bean(destroyMethod="shutdown")
    RedissonClient redisson() throws IOException {
        Config config = new Config();
        config.useClusterServers()
              .addNodeAddress("127.0.0.1:7004", "127.0.0.1:7001");
        return Redisson.create(config);
    }

    @Bean
    CacheManager cacheManager(RedissonClient redissonClient) {
        Map<String, CacheConfig> config = new HashMap<String, CacheConfig>();
        // 创建一个名称为"testMap"的缓存,过期时间ttl为24分钟,同时最长空闲时maxIdleTime为12分钟。
        config.put("testMap", new CacheConfig(24*60*1000, 12*60*1000));
        return new RedissonSpringCacheManager(redissonClient, config);
    }

}
image-20220716150830328
image-20220716150830328

单Redis节点模式的参考文档:

https://github.com/redisson/redisson/wiki/2.-配置方法#26-单redis节点模式
// 默认连接地址 127.0.0.1:6379
RedissonClient redisson = Redisson.create();

Config config = new Config();
config.useSingleServer().setAddress("myredisserver:6379");
RedissonClient redisson = Redisson.create(config);
image-20220716150909929
image-20220716150909929
3、添加测试方法

gulimall-product模块的com.atguigu.gulimall.product.GulimallProductApplicationTests测试类里添加如下代码

@Autowired
RedissonClient redissonClient;
@Test
public void redissonTest(){
   System.out.println(redissonClient);
}
image-20220716150604827
image-20220716150604827
4、执行测试

执行redissonTest方法,可以看到报了如下错误:错误的原因也指出来了java.lang.IllegalArgumentException: Redis url should start with redis:// or rediss:// (for SSL connection)

java.lang.IllegalStateException: Failed to load ApplicationContext

   at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:125)
   at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:108)
   at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:190)
   at org.springframework.test.context.web.ServletTestExecutionListener.prepareTestInstance(ServletTestExecutionListener.java:132)
   at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:246)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.createTest(SpringJUnit4ClassRunner.java:227)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner$1.runReflectiveCall(SpringJUnit4ClassRunner.java:289)
   at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.methodBlock(SpringJUnit4ClassRunner.java:291)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:246)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
   at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
   at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
   at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
   at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
   at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
   at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
   at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
   at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
   at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
   at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
   at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:69)
   at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
   at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:220)
   at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:53)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'redisson' defined in class path resource [com/atguigu/gulimall/product/config/MyRedissonConfig.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.redisson.api.RedissonClient]: Factory method 'redisson' threw exception; nested exception is java.lang.IllegalArgumentException: Redis url should start with redis:// or rediss:// (for SSL connection)
image-20220716150731143
image-20220716150731143

参考文档:https://github.com/redisson/redisson/wiki/2.-配置方法#21-程序化配置方法

Config config = new Config();
config.setTransportMode(TransportMode.EPOLL);
config.useClusterServers()
      //可以用"rediss://"来启用SSL连接
      .addNodeAddress("redis://127.0.0.1:7181");
image-20220716151118513
image-20220716151118513
5、修改配置

gulimall-product模块的com.atguigu.gulimall.product.config.MyRedissonConfig类里修改redisson方法

/**
 * 所有对Redisson的使用都是通过RedissonClient对象
 * @return
 * @throws IOException
 */
@Bean(destroyMethod="shutdown")
RedissonClient redisson() throws IOException {
    //1、创建配置
    Config config = new Config();
    //Redis url should start with redis:// or rediss:// (for SSL connection)
    //config.useSingleServer().setAddress("192.168.56.10:6379").setPassword("");
    config.useSingleServer().setAddress("redis://192.168.56.10:6379");
    //2、根据Config创建出RedissonClient示例
    return Redisson.create(config);
}
image-20220716213623896
image-20220716213623896
6、重新测试

gulimall-product模块的com.atguigu.gulimall.product.GulimallProductApplicationTests测试类里执行redissonTest方法,可以看到这次执行成功了

image-20220716151536203
image-20220716151536203

2、 可重入锁(Reentrant Lock)

基于Redis的Redisson分布式可重入锁RLockopen in new window Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)open in new window反射式(Reactive)open in new windowRxJava2标准open in new window的接口。

RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();

大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeoutopen in new window来另行指定。

另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       lock.unlock();
   }
}

Redisson同时还为分布式锁提供了异步执行的相关方法:

RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);

RLock对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphoreopen in new window 对象.

官方文档:https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

http://localhost:10000/hello

1、有看门狗

修改gulimall-product模块的com.atguigu.gulimall.product.web.IndexController类的hello方法

@Autowired
RedissonClient redissonClient;

@ResponseBody
@GetMapping("/hello")
public String hello(){
    //1、获取一把锁,只要锁的名字一样,就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    //2、加锁
    //阻塞式等待(其他线程不断地尝试获取这把锁)。默认加的锁都是30s时间。
    //1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
    //2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。
    lock.lock();
    try {
        System.out.println("加锁成功。。。执行业务。。。"+Thread.currentThread().getId());
        Thread.sleep(30000);
    }catch (Exception e){
        e.printStackTrace();
    }finally {
        //3、解锁
        System.out.println("释放锁。。。"+Thread.currentThread().getId());
        lock.unlock();
    }
    return "hello";
}
image-20220716153633748
image-20220716153633748

启动GulimallProductApplication服务,访问: http://localhost:10000/hello 进行测试

可以看到当该线程未处理完时,会自动给锁延长过期时间(已过去1/3过期时间看门狗开始自动续期),不会出现该线程业务未处理完,别的线程可以获取到该锁的情况

GIF 2022-7-18 9-51-02
GIF 2022-7-18 9-51-02

开启两个商品服务(GulimallProductApplicationGulimallProductApplication - 10001),然后通过GulimallGatewayApplication网关访问,模拟集群环境,然后停掉已获得锁的服务,模拟宕机的情况,查看别的机器是否能获得锁

访问路径: http://localhost:88/hello

可以看到GulimallProductApplication - 10001获得锁后,停掉该服务。GulimallProductApplication服务在GulimallProductApplication - 10001服务的锁过期后依旧能获取到锁

GIF 2022-7-18 10-04-16
GIF 2022-7-18 10-04-16

调用lock.lock();方法,如果没有获取到这把锁,没有获取到锁的线程不断地尝试获取这把锁(阻塞式等待)

image-20220718102213193
image-20220718102213193
2、无看门狗

使用lock.lock(10, TimeUnit.SECONDS); 方法,该线程业务没有处理完不会自动续期,别的线程也可以进入

@ResponseBody
@GetMapping("/hello")
public String hello() {
    //1、获取一把锁,只要锁的名字一样,就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    //2、加锁
    //阻塞式等待(其他线程不断地尝试获取这把锁)。默认加的锁都是30s时间。
    //1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
    //2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。
    //lock.lock();

    //问题: lock. lock(10, TimeUnit. SECONDS); 在锁时间到了以后,不会自动续期。
    //1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
    //2、如果我们未指定锁的超时时间,就使用30 * 1000 [LockWatchdogTimeout看门狗的默认时间] ;
    //只要占锁成功,就会启动一一个定时任务[重新给锁设置过期时间,新的过期时间就是看门门狗的默认时间]
    //internallockleaseTime [看i门狗时间] / 3, 10s
    lock.lock(10, TimeUnit.SECONDS); //10秒自动解锁,自动解锁时间一-定要大于业务的执行时间。
    //最佳实战 :lock.lock(30, TimeUnit. SECONDS);省掉了整个续期操作。手动解锁
    try {
        System.out.println("加锁成功。。。执行业务。。。" + Thread.currentThread().getId());
        Thread.sleep(30000);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        //3、解锁
        System.out.println("释放锁。。。" + Thread.currentThread().getId());
        lock.unlock();
    }
    return "hello";
}
image-20220718163902337
image-20220718163902337

当前线程业务没有处理完不会自动续期,处理完后释放锁会报异常

GIF 2022-7-18 16-48-34
GIF 2022-7-18 16-48-34
加锁成功。。。执行业务。。。99
释放锁。。。99
2022-07-18 16:56:22.611 ERROR 12492 --- [io-10000-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: c4f38ca8-3108-40a2-a8da-3c9d7ec1213f thread-id: 99] with root cause

java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: c4f38ca8-3108-40a2-a8da-3c9d7ec1213f thread-id: 99
   at org.redisson.RedissonLock.lambda$unlockAsync$3(RedissonLock.java:580) ~[redisson-3.12.0.jar:3.12.0]
   at org.redisson.misc.RedissonPromise.lambda$onComplete$0(RedissonPromise.java:187) ~[redisson-3.12.0.jar:3.12.0]
   at io.netty.util.concurrent.DefaultPromise.notifyListener0(DefaultPromise.java:500) ~[netty-common-4.1.39.Final.jar:4.1.39.Final]
image-20220718165656843
image-20220718165656843

(我的这个测试了几次,都是前一个线程执行完了,后一个线程才能获取到锁。按理说redis里面都没有锁了,应该不会获取不到锁呀)

GIF 2022-7-18 17-04-18
GIF 2022-7-18 17-04-18
3、非阻塞式等待
@ResponseBody
@GetMapping("/hello")
public String hello() {
    //1、获取一把锁,只要锁的名字一样,就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    //2、加锁
    //阻塞式等待(其他线程不断地尝试获取这把锁)。默认加的锁都是30s时间。
    //1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
    //2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。
    //lock.lock();

    //问题: lock. lock(10, TimeUnit. SECONDS); 在锁时间到了以后,不会自动续期。
    //1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
    //2、如果我们未指定锁的超时时间,就使用30 * 1000 [LockWatchdogTimeout看门狗的默认时间] ;
    //只要占锁成功,就会启动一一个定时任务[重新给锁设置过期时间,新的过期时间就是看门门狗的默认时间]
    //internallockleaseTime [看i门狗时间] / 3, 10s
    //lock.lock(10, TimeUnit.SECONDS); //10秒自动解锁,自动解锁时间一-定要大于业务的执行时间。
    //最佳实战 :lock.lock(30, TimeUnit. SECONDS);省掉了整个续期操作。手动解锁

    boolean b = lock.tryLock();
    System.out.println(b);
    if (b){
        try {
            System.out.println("加锁成功。。。执行业务。。。" + Thread.currentThread().getId());
            Thread.sleep(30000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //3、解锁
            System.out.println("释放锁。。。" + Thread.currentThread().getId());
            lock.unlock();
        }
    }
    return "hello=>"+b;
}
image-20220718172229790
image-20220718172229790
GIF 2022-7-18 17-19-14
GIF 2022-7-18 17-19-14

3、 公平锁(Fair Lock)

公平锁是按照请求发出的先后顺序来处理请求,是先进先出的,而非向其他锁一样是抢占式

基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。同时还提供了异步(Async)open in new window反射式(Reactive)open in new windowRxJava2标准open in new window的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。

RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();

大家都知道,如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeoutopen in new window来另行指定。

另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
fairLock.lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
...
fairLock.unlock();

Redisson同时还为分布式可重入公平锁提供了异步执行的相关方法:

RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lockAsync();
fairLock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = fairLock.tryLockAsync(100, 10, TimeUnit.SECONDS);

官方文档:https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

4、读写锁(ReadWriteLock)

基于Redis的Redisson分布式可重入读写锁RReadWriteLockopen in new window Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLockopen in new window接口。

分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();

大家都知道,如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeoutopen in new window来另行指定。

另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();

官方文档:https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

1、读写锁测试

http://localhost:10000/read

http://localhost:10000/write

@Autowired
StringRedisTemplate redisTemplate;

@GetMapping("/read")
@ResponseBody
public String readValue() {
    RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
    //ReentrantReadwriteLock writeLock = new ReentrantReadWriteLock();
    String s = "";
    //加读锁
    RLock rLock = lock.readLock();
    rLock.lock();
    try {
        s = redisTemplate.opsForValue().get("writeValue");
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return s;
}

//保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁、独享锁)。读锁是一个共享锁
//写锁没释放读就必须等待
@GetMapping("/write")
@ResponseBody
public String writeValue() {
    RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
    String s = "";
    RLock rLock = lock.writeLock();
    try {
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        s = UUID.randomUUID().toString();
        Thread.sleep(10000);
        redisTemplate.opsForValue().set("writeValue", s);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return s;
}
image-20220718145647660
image-20220718145647660
GIF 2022-7-18 14-49-32
GIF 2022-7-18 14-49-32
GIF 2022-7-18 14-54-38
GIF 2022-7-18 14-54-38
2、修改代码
@Autowired
StringRedisTemplate redisTemplate;

@GetMapping("/read")
@ResponseBody
public String readValue() {
    RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
    //ReentrantReadwriteLock writeLock = new ReentrantReadWriteLock();
    String s = "";
    //加读锁
    RLock rLock = lock.readLock();
    rLock.lock();
    try {
        System.out.println("读锁加锁成功" + Thread.currentThread().getId());
        s = redisTemplate.opsForValue().get("writeValue");
        Thread.sleep(8000);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
        System.out.println("读锁释放" + Thread.currentThread().getId());
    }
    return s;
}

//保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁、独享锁)。读锁是一个共享锁
//写锁没释放读就必须等待
//读 + 读: 相当于无锁,并发读,只会在redis中记录好,所有当前的读锁。他们都会同时加锁成功
//写 + 读: 等待写锁释放
//写 + 写: 阻塞方式
//读 + 写: 有读锁。写也需要等待。
//只要有写的存在,都必须等待
@GetMapping("/write")
@ResponseBody
public String writeValue() {
    RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
    String s = "";
    RLock rLock = lock.writeLock();
    try {
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        System.out.println( "写锁加锁成功... "+Thread.currentThread().getId());
        s = UUID.randomUUID().toString();
        Thread.sleep(8000);
        redisTemplate.opsForValue().set("writeValue", s);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
        System.out.println( "写锁释放"+Thread.currentThread().getId());
    }
    return s;
}
image-20220718160814759
image-20220718160814759
3、测试读 + 读

第一次测试

GIF 2022-7-18 15-55-55
GIF 2022-7-18 15-55-55

第二次测试

GIF 2022-7-18 15-57-29
GIF 2022-7-18 15-57-29

第三次测试

GIF 2022-7-18 15-58-29
GIF 2022-7-18 15-58-29
4、测试写 + 读

第一次测试

GIF 2022-7-18 15-50-12
GIF 2022-7-18 15-50-12

第二次测试

GIF 2022-7-18 15-53-02
GIF 2022-7-18 15-53-02

第三次测试

GIF 2022-7-18 15-26-15
GIF 2022-7-18 15-26-15
5、测试写 + 写

第一次测试

GIF 2022-7-18 15-39-31
GIF 2022-7-18 15-39-31

第二次测试

GIF 2022-7-18 15-46-46
GIF 2022-7-18 15-46-46

第三次测试

GIF 2022-7-18 15-47-45
GIF 2022-7-18 15-47-45
6、测试读 + 写

第一次测试

GIF 2022-7-18 16-02-03
GIF 2022-7-18 16-02-03

第二次测试

GIF 2022-7-18 16-03-38
GIF 2022-7-18 16-03-38

第三次测试

GIF 2022-7-18 16-04-59
GIF 2022-7-18 16-04-59
7、读写锁存储结构
GIF 2022-7-18 16-14-13
GIF 2022-7-18 16-14-13

5、信号量(Semaphore)

基于Redis的Redisson的分布式信号量(Semaphoreopen in new window)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)open in new window反射式(Reactive)open in new windowRxJava2标准open in new window的接口。

RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();

官方文档:https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

1、阻塞式等待

访问: http://localhost:10000/park

访问: http://localhost:10000/go

@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park");
    //阻塞式
    park.acquire();//获取一个信号,,占一个车位
    return "ok";
}
@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
    RSemaphore park =redissonClient.getSemaphore("park");
    park.release();//释放一个车位
    return "ok";
}
image-20220718162522921
image-20220718162522921
GIF 2022-7-18 16-24-35
GIF 2022-7-18 16-24-35
2、非阻塞式等待
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park");
    //阻塞式等待
    //park.acquire();//获取一个信号,,占一个车位
    //非阻塞式等待
    boolean b = park.tryAcquire();
    return "ok=>" + b;
}

@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park");
    park.release();//释放一个车位
    return "ok";
}
image-20220718163318725
image-20220718163318725
GIF 2022-7-18 16-32-01
GIF 2022-7-18 16-32-01
3、分布式限流
/**
 * 车库停车
 * 3车位
 * 信号量也可以用作分布式限流;
 */
@GetMapping("/park" )
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park") ;
    //阻塞式等待
    //park.acquire();//获取一个信号,,占一个车位
    //非阻塞式等待
    boolean b = park.tryAcquire();
    if(b){
        //执行业务
    }else {
        return "error" ;
    }
    return "ok=>"+b;
}


@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park");
    park.release();//释放一个车位
    return "ok";
}
image-20220718172703326
image-20220718172703326

6、闭锁(CountDownLatch)

基于Redisson的Redisson分布式闭锁(CountDownLatchopen in new window)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。

RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();

// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();

官方文档:https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

/**
 * 放假,锁门
 * 1班没人了,2
 * 5个班全部走完,我们可以锁大门
 */
@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.trySetCount(5);
    door.await(); //等待闭锁都完成
    return "放假了...";
}

@GetMapping("/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) {
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.countDown();//计数减一;
    return id + "班的人都走了...";
}
image-20220718183034909
image-20220718183034909
GIF 2022-7-18 18-16-16
GIF 2022-7-18 18-16-16

一定要保证先访问: http://localhost:10000/lockDoor 再访问 http://localhost:10000/gogogo/1

GIF 2022-7-18 18-17-15
GIF 2022-7-18 18-17-15

7、添加

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里添加getCatalogJsonForDbWithRedisson方法

public Map<String, List<Catelog2Vo>> getCatalogJsonForDbWithRedisson() {
    //1、锁的名字。锁的粒度, 越细越快。
    //锁的粒度:具体缓存的是某个数据,11-号商品; product- 11-lock product-12-lock
    RLock lock = redissonClient.getLock("catalogJson-lock");
    lock.lock();
    Map<String, List<Catelog2Vo>> catalogJsonForDb;
    try {
        catalogJsonForDb = getCatalogJsonForDb();
    }finally {
        lock.unlock();
    }
    return catalogJsonForDb;
}

修改getCatalogJson方法,把

Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDbWithRedisLock();

修改为:

Map<String, List<Catelog2Vo>> catalogJsonForDb = getCatalogJsonForDbWithRedisson();
image-20220718190606032
image-20220718190606032

8、缓存数据一致性

1、双写模式
image-20220721211214914
image-20220721211214914

由于卡顿等原因,导致写缓存2在最前,写缓存1在后面就出现了不一致脏数据问题:

这是暂时性的脏数据问题,但是在数据稳定,缓存过期以后,又能得到最新的正确数据

读到的最新数据有延迟:最终一致性

2、失效模式
image-20220721211304919
image-20220721211304919

我们系统的一致性解决方案:

1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新

2、读写数据的时候,加上分布式的读写锁。 经常写,经常读

3、解决方案

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可

  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。

  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。

  4. 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略);

总结:

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保 证每天拿到当前最新数据即可。

  • 我们不应该过度设计,增加系统的复杂性

  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

4、强一致性解决方案-Canal

使用Canal更新缓存

image-20220721211610540

使用Canal解决数据异构

image-20220721211810088
image-20220721211810088

5.4.4、SpringCache

image-20220721212944809
image-20220721212944809

官方文档: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache

GIF 2022-7-18 19-15-46
GIF 2022-7-18 19-15-46

1、SpringCache组成

1、CacheManager

双击Shift,在搜索框中搜索CacheManager,可以看到org.springframework.cache.CacheManager类有getCachegetCacheNames两个方法

/*
 * Copyright 2002-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.cache;

import java.util.Collection;

import org.springframework.lang.Nullable;

/**
 * Spring's central cache manager SPI.
 *
 * <p>Allows for retrieving named {@link Cache} regions.
 *
 * @author Costin Leau
 * @author Sam Brannen
 * @since 3.1
 */
public interface CacheManager {

   /**
    * Get the cache associated with the given name.
    * <p>Note that the cache may be lazily created at runtime if the
    * native provider supports it.
    * @param name the cache identifier (must not be {@code null})
    * @return the associated cache, or {@code null} if such a cache
    * does not exist or could be not created
    */
   @Nullable
   Cache getCache(String name);

   /**
    * Get a collection of the cache names known by this manager.
    * @return the names of all caches known by the cache manager
    */
   Collection<String> getCacheNames();

}
image-20220718192613795
image-20220718192613795
2、Cache

点击Cache类里面,可以看到在org.springframework.cache.Cache类里面定义的有缓存的增删查改等方法

image-20220718192541559
image-20220718192541559
3、ConcurrentMapCacheManager

CacheManager类里,按ctrl+H快捷键,可以看到Spring支持非常多的缓存管理器,打开ConcurrentMapCacheManager

image-20220718193327516
image-20220718193327516

org.springframework.cache.concurrent.ConcurrentMapCacheManager类的构造器里可以输入需要管理的缓存的名字

该构造方法会调用本类的setCacheNames方法,并把传进来的不定长的cacheNames转为List

/**
 * Construct a static ConcurrentMapCacheManager,
 * managing caches for the specified cache names only.
 */
public ConcurrentMapCacheManager(String... cacheNames) {
   setCacheNames(Arrays.asList(cacheNames));
}


/**
 * Specify the set of cache names for this CacheManager's 'static' mode.
 * <p>The number of caches and their names will be fixed after a call to this method,
 * with no creation of further cache regions at runtime.
 * <p>Calling this with a {@code null} collection argument resets the
 * mode to 'dynamic', allowing for further creation of caches again.
 */
public void setCacheNames(@Nullable Collection<String> cacheNames) {
   if (cacheNames != null) {
      for (String name : cacheNames) {
         this.cacheMap.put(name, createConcurrentMapCache(name));
      }
      this.dynamic = false;
   }
   else {
      this.dynamic = true;
   }
}
image-20220718193810047
image-20220718193810047

setCacheNames方法里会遍历传进来的cacheNames,并把这些name作为k、调用createConcurrentMapCache(name)方法的返回值作为v放入本类的cacheMap属性里,本类的cacheMap对象其实就是ConcurrentHashMap类型

private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);
image-20220718193947559
image-20220718193947559

createConcurrentMapCache(name)方法的返回的形式参数为org.springframework.cache.Cache,实际参数为org.springframework.cache.concurrent.ConcurrentMapCache,而ConcurrentMapCache继承org.springframework.cache.support.AbstractValueAdaptingCacheAbstractValueAdaptingCache实现org.springframework.cache.Cache

public class ConcurrentMapCache extends AbstractValueAdaptingCache
public abstract class AbstractValueAdaptingCache implements Cache
image-20220718194203552
image-20220718194203552
4、ConcurrentMapCache

点进ConcurrentMapCache里面,可以看到org.springframework.cache.concurrent.ConcurrentMapCache类里有类型为ConcurrentMap<Object, Object>store属性,该属性用于存储数据

image-20220718194415502
image-20220718194415502

往下看,可以看到都是从store中获取数据的

image-20220718194619323
image-20220718194619323

2、引入SpringCache

1、导入依赖

gulimall-product模块的pom.xml里引入SpringCache

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
image-20220718195140052
image-20220718195140052
2、CacheAutoConfiguration

缓存的自动配置类是org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration类,在CacheAutoConfiguration类里,缓存的所有属性都在CacheProperties类里封装着

image-20220718195906577
image-20220718195906577
3、CacheProperties

org.springframework.boot.autoconfigure.cache.CacheProperties类里的属性,在配置文件中都以spring.cache开始

image-20220718200219087
image-20220718200219087
4、切换到CacheAutoConfiguration

返回CacheAutoConfiguration类,其内部的CacheConfigurationImportSelector选择器里面又导了很多配置

/**
 * {@link ImportSelector} to add {@link CacheType} configuration classes.
 */
static class CacheConfigurationImportSelector implements ImportSelector {

   @Override
   public String[] selectImports(AnnotationMetadata importingClassMetadata) {
      CacheType[] types = CacheType.values();
      String[] imports = new String[types.length];
      for (int i = 0; i < types.length; i++) {
         imports[i] = CacheConfigurations.getConfigurationClass(types[i]);
      }
      return imports;
   }

}
image-20220718200424084
image-20220718200424084
5、CacheConfigurations

org.springframework.boot.autoconfigure.cache.CacheConfigurations类的getConfigurationClass方法里,按照缓存的类型进行映射

public static String getConfigurationClass(CacheType cacheType) {
   Class<?> configurationClass = MAPPINGS.get(cacheType);
   Assert.state(configurationClass != null, () -> "Unknown cache type " + cacheType);
   return configurationClass.getName();
}
image-20220718200709725
image-20220718200709725

如果使用的是redis,就使用RedisCacheConfiguration配置类

private static final Map<CacheType, Class<?>> MAPPINGS;

static {
   Map<CacheType, Class<?>> mappings = new EnumMap<>(CacheType.class);
   mappings.put(CacheType.GENERIC, GenericCacheConfiguration.class);
   mappings.put(CacheType.EHCACHE, EhCacheCacheConfiguration.class);
   mappings.put(CacheType.HAZELCAST, HazelcastCacheConfiguration.class);
   mappings.put(CacheType.INFINISPAN, InfinispanCacheConfiguration.class);
   mappings.put(CacheType.JCACHE, JCacheCacheConfiguration.class);
   mappings.put(CacheType.COUCHBASE, CouchbaseCacheConfiguration.class);
   mappings.put(CacheType.REDIS, RedisCacheConfiguration.class);
   mappings.put(CacheType.CAFFEINE, CaffeineCacheConfiguration.class);
   mappings.put(CacheType.SIMPLE, SimpleCacheConfiguration.class);
   mappings.put(CacheType.NONE, NoOpCacheConfiguration.class);
   MAPPINGS = Collections.unmodifiableMap(mappings);
}
image-20220718201112614
image-20220718201112614
6、RedisCacheConfiguration

org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类里,配置了缓存管理器

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
      ResourceLoader resourceLoader) {
   RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
         .cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
   List<String> cacheNames = this.cacheProperties.getCacheNames();
   if (!cacheNames.isEmpty()) {
      builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
   }
   return this.customizerInvoker.customize(builder.build());
}
image-20220718201922910
image-20220718201922910
7、CacheProperties

org.springframework.boot.autoconfigure.cache.CacheProperties类里如果配置了spring.cache.cacheNames,就可以获取到这些缓存的名字

image-20220718202018699
image-20220718202018699
8、切换到RedisCacheConfiguration

切换到RedisCacheConfiguration类,在cacheManager方法获取到缓存的名字后,在builder.initialCacheNames(new LinkedHashSet<>(cacheNames));初始化缓存

image-20220718202231757
image-20220718202231757
9、RedisCacheManager

org.springframework.data.redis.cache.RedisCacheManager类的initialCacheNames方法里

遍历cacheNames,使用默认缓存配置,把它们放到cacheConfigMap里面,并初始化缓存配置

/**
 * Append a {@link Set} of cache names to be pre initialized with current {@link RedisCacheConfiguration}.
 * <strong>NOTE:</strong> This calls depends on {@link #cacheDefaults(RedisCacheConfiguration)} using whatever
 * default {@link RedisCacheConfiguration} is present at the time of invoking this method.
 *
 * @param cacheNames must not be {@literal null}.
 * @return this {@link RedisCacheManagerBuilder}.
 */
public RedisCacheManagerBuilder initialCacheNames(Set<String> cacheNames) {

   Assert.notNull(cacheNames, "CacheNames must not be null!");

   Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap<>(cacheNames.size());
   //遍历`cacheNames`,使用默认缓存配置,把它们放到`cacheConfigMap`里面
   cacheNames.forEach(it -> cacheConfigMap.put(it, defaultCacheConfiguration));
   //初始化缓存配置
   return withInitialCacheConfigurations(cacheConfigMap);
}
image-20220718202707460
image-20220718202707460

withInitialCacheConfigurations(cacheConfigMap)方法会把类型为Map<String, RedisCacheConfiguration>cacheConfigMap放到同样类型的initialCaches里面

private final Map<String, RedisCacheConfiguration> initialCaches = new LinkedHashMap<>();

/**
 * Append a {@link Map} of cache name/{@link RedisCacheConfiguration} pairs to be pre initialized.
 *
 * @param cacheConfigurations must not be {@literal null}.
 * @return this {@link RedisCacheManagerBuilder}.
 */
public RedisCacheManagerBuilder withInitialCacheConfigurations(
      Map<String, RedisCacheConfiguration> cacheConfigurations) {

   Assert.notNull(cacheConfigurations, "CacheConfigurations must not be null!");
   cacheConfigurations.forEach((cacheName, configuration) -> Assert.notNull(configuration,
         String.format("RedisCacheConfiguration for cache %s must not be null!", cacheName)));

   this.initialCaches.putAll(cacheConfigurations);

   return this;
}
image-20220718202932830
image-20220718202932830

initialCaches属性的类型为Map<String, RedisCacheConfiguration>,其中String存放了缓存的名字,RedisCacheConfiguration存放了RedisCacheConfiguration.defaultCacheConfig()方法返回的默认的RedisCacheConfiguration

private final Map<String, RedisCacheConfiguration> initialCaches = new LinkedHashMap<>();
image-20220718203136409
image-20220718203136409
10、切换到RedisCacheConfiguration

切换到RedisCacheConfiguration,在cacheManager方法里调用了determineConfiguration方法,determineConfiguration方法里如果ioc容器中不存在RedisCacheConfiguration则会进行默认配置

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
      ResourceLoader resourceLoader) {
   RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
         .cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
   List<String> cacheNames = this.cacheProperties.getCacheNames();
   if (!cacheNames.isEmpty()) {
      builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
   }
   return this.customizerInvoker.customize(builder.build());
}

private org.springframework.data.redis.cache.RedisCacheConfiguration determineConfiguration(
      ClassLoader classLoader) {
   if (this.redisCacheConfiguration != null) {
      return this.redisCacheConfiguration;
   }
   Redis redisProperties = this.cacheProperties.getRedis();
   org.springframework.data.redis.cache.RedisCacheConfiguration config = org.springframework.data.redis.cache.RedisCacheConfiguration
         .defaultCacheConfig();
   config = config.serializeValuesWith(
         SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader))); //序列化机制
   if (redisProperties.getTimeToLive() != null) { //过期时间
      config = config.entryTtl(redisProperties.getTimeToLive());
   }
   if (redisProperties.getKeyPrefix() != null) { //前缀
      config = config.prefixKeysWith(redisProperties.getKeyPrefix());
   }
   if (!redisProperties.isCacheNullValues()) { //要不要缓存空数据
      config = config.disableCachingNullValues();
   }
   if (!redisProperties.isUseKeyPrefix()) { //是否使用缓存的前缀
      config = config.disableKeyPrefix();
   }
   return config;
}
image-20220718203626286
image-20220718203626286

3、添加配置

gulimall-product模块的src/main/resources目录下新建application.properties配置文件,在该配置文件内添加配置

#缓存的类型
spring.cache.type=redis

#缓存的名字
#如果配置了缓存的名字,则只能使用这些名字
#系统中用到哪些缓存了,帮你创建出来
#spring.cache.cache-names=qq,qqq
image-20220718204448764
image-20220718204448764

org.springframework.boot.autoconfigure.cache.CacheProperties类的cacheNames字段上有一段注释

如果底层缓存管理器支持,要创建的缓存名称的逗号分隔列表。 通常,这会禁用动态创建附加缓存的能力。

/**
 * Comma-separated list of cache names to create if supported by the underlying cache
 * manager. Usually, this disables the ability to create additional caches on-the-fly.
 */
private List<String> cacheNames = new ArrayList<>();
image-20220718204336191
image-20220718204336191

4、常用注解(1)

For caching declaration, Spring’s caching abstraction provides a set of Java annotations:

  • @Cacheable: Triggers cache population. 触发缓存填充 (触发将数据保存到缓存的操作)
  • @CacheEvict: Triggers cache eviction. 触发缓存驱逐 (触发将数据从缓存删除的操作)
  • @CachePut: Updates the cache without interfering with the method execution. 在不干扰方法执行的情况下更新缓存 (不影响方法执行更新缓存)
  • @Caching: Regroups multiple cache operations to be applied on a method. 重新组合多个缓存操作以应用于一个方法 (组合以上多个操作)
  • @CacheConfig: Shares some common cache-related settings at class-level. 在类级别共享一些常见的缓存相关设置 (在类级别共享缓存的相同配置)
1、@EnableCaching开启缓存功能

gulimall-product模块的com.atguigu.gulimall.product.GulimallProductApplication启动类上添加@EnableCaching注解

@EnableCaching
@EnableFeignClients(basePackages = "com.atguigu.gulimall.product.feign")
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.product.dao")
@SpringBootApplication
public class GulimallProductApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallProductApplication.class,args);
    }
}
image-20220718205852014
image-20220718205852014
2、@Cacheable添加缓存

修改gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的getLevel1Categories方法。添加@Cacheable注解,代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存。value = {"category"},指定放到名为category的分区下

//每一个需要缓存的数据我们都来指定要放到那个名字的缓存。[ 缓存的分区(按照业务类型分)]
//代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
@Cacheable({"category"})
@Override
public List<CategoryEntity> getLevel1Categories() {
    System.out.println("getLevel1Categories...");
    LambdaQueryWrapper<CategoryEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.eq(CategoryEntity::getParentCid, 0);
    lambdaQueryWrapper.select(CategoryEntity::getCatId, CategoryEntity::getName);
    //long start = System.currentTimeMillis();
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(lambdaQueryWrapper);
    //long end = System.currentTimeMillis();
    //System.out.println("消耗时间:"+(end-start));
    return categoryEntities;
}
image-20220718210308329
image-20220718210308329
3、测试

如果缓存中没有数据,会调用方法,最后将方法的结果放入缓存。如果缓存中有,方法不用调用,直接返回数据

可以看到已经自动缓存数据了,但是key不是我们指定的,过期时间也为-1(永不过期)、数据的格式也不为JSON

GIF 2022-7-18 21-11-35
4、@Cacheable可选参数

org.springframework.cache.annotation.Cacheable注解接口里

value起了个别名叫cacheNames

cacheNames起了个别名叫value

package org.springframework.cache.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.Callable;

import org.springframework.core.annotation.AliasFor;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {

  /**
   * Alias for {@link #cacheNames}.
   */
  @AliasFor("cacheNames")
  String[] value() default {};

  /**
   * Names of the caches in which method invocation results are stored.
   * <p>Names may be used to determine the target cache (or caches), matching
   * the qualifier value or bean name of a specific bean definition.
   * @since 4.2
   * @see #value
   * @see CacheConfig#cacheNames
   */
  @AliasFor("value")
  String[] cacheNames() default {};

  String key() default "";

  String keyGenerator() default "";

  String cacheManager() default "";

  String cacheResolver() default "";

  String condition() default "";

  String unless() default "";

  boolean sync() default false;

}
image-20220718212829162
image-20220718212829162

其他参数代表如下意思:

//指定k值是什么(支持SpringEL)(如果不是EL表达式,需要加上单引号)
String key() default "";
//k的生成器
String keyGenerator() default "";
//指定使用哪个缓存管理器
String cacheManager() default "";
//缓存用的条件(把哪些数据放到缓存里面,可以接收一个表达式)
String condition() default "";
//除了指定的情况外,其他情况都向缓存中保存数据
String unless() default "";
//设置是否使用同步方式(如果是同步方式,unless就不可用)
boolean sync() default false;
image-20220718212426919
image-20220718212426919

官方文档: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache

支持的EL表达式

NameLocationDescriptionExample
methodNameRoot objectThe name of the method being invoked#root.methodName
methodRoot objectThe method being invoked#root.method.name
targetRoot objectThe target object being invoked#root.target
targetClassRoot objectThe class of the target being invoked#root.targetClass
argsRoot objectThe arguments (as array) used for invoking the target#root.args[0]
cachesRoot objectCollection of caches against which the current method is run#root.caches[0].name
Argument nameEvaluation contextName of any of the method arguments. If the names are not available (perhaps due to having no debug information), the argument names are also available under the #a<#arg> where #arg stands for the argument index (starting from 0).#iban or #a0 (you can also use #p0 or #p<#arg> notation as an alias).
resultEvaluation contextThe result of the method call (the value to be cached). Only available in unless expressions, cache put expressions (to compute the key), or cache evict expressions (when beforeInvocation is false). For supported wrappers (such as Optional), #result refers to the actual object, not the wrapper.#result
image-20220718213355681
image-20220718213355681

5、需求

1、指定key

可以在key的值的字符串里加上'单引号(key = "'xxx'"),指明不使用SpEL

/**
 * 1、每一个需要缓存的数据我们都来指定要放到那个名字的缓存。[ 缓存的分区(按照业务类型分)]
 * 2、@Cacheable({"category"})
 *   代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。
 *   如果缓存中没有,会调用方法,最后将方法的结果放入缓存
 * 3、默认行为
 *   1)、如果缓存中有,方法不用调用。
 *   2)、key默认自动生成;缓存的名字::SimpleKey []( 自主生成的key值)
 *   3)、缓存的value的值。默认使用jdk序列化机制,将序列化后的数据存到redis
 * 4)、默认ttl时间-1; .
 * 自定义:
 *   1)、指定生成的缓存使用的key:
 *     key属性指定,接受一个SpEL
 *     SpEL的详细https ://docs.spring. io/spring/docs/5.1.12. REL EASE/spring-framework-re.
 *   2)、指定缓存的数据的存活时间:配置文件中 修改ttL
 *   3)、将数据保存为json格式
 **/

@Cacheable(value = {"category"}, key = "'level1Categories'")
@Override
public List<CategoryEntity> getLevel1Categories() {
    System.out.println("getLevel1Categories...");
    LambdaQueryWrapper<CategoryEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.eq(CategoryEntity::getParentCid, 0);
    lambdaQueryWrapper.select(CategoryEntity::getCatId, CategoryEntity::getName);
    //long start = System.currentTimeMillis();
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(lambdaQueryWrapper);
    //long end = System.currentTimeMillis();
    //System.out.println("消耗时间:"+(end-start));
    return categoryEntities;
}
image-20220718214321219
image-20220718214321219
2、指定过期时间

gulimall-product模块的src/main/resources/application.properties类里添加keyspring.cache.redis.time-to-live的属性可以指定缓存的过期时间(以ms(毫秒)为单位)

spring.cache.redis.time-to-live=3600000
image-20220718214404174
image-20220718214404174
3、使用JSON存储

使用JSON存储,不可以直接通过配置文件或参数的方式指定,需要自定义RedisCacheConfiguration

6、自定义RedisCacheConfiguration

1、RedisCacheConfiguration

查看org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类,Redis缓存管理器调用了确定配置的方法,用于确定使用什么配置

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
      ResourceLoader resourceLoader) {
   RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
         .cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
   List<String> cacheNames = this.cacheProperties.getCacheNames();
   if (!cacheNames.isEmpty()) {
      builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
   }
   return this.customizerInvoker.customize(builder.build());
}

确定配置的方法里,判断如果本类的redis缓存配置存在,就使用存在的缓存配置

如果不存在就是使用默认的缓存配置

如果ioc容器中存在redis缓存配置,就赋值到本类的redis缓存配置属性里

点击查看RedisCacheConfiguration类完整代码

private org.springframework.data.redis.cache.RedisCacheConfiguration determineConfiguration(
      ClassLoader classLoader) {
   if (this.redisCacheConfiguration != null) {
      return this.redisCacheConfiguration;
   }
   Redis redisProperties = this.cacheProperties.getRedis();
   org.springframework.data.redis.cache.RedisCacheConfiguration config = org.springframework.data.redis.cache.RedisCacheConfiguration
         .defaultCacheConfig();
   config = config.serializeValuesWith(
         SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader)));
   if (redisProperties.getTimeToLive() != null) {
      config = config.entryTtl(redisProperties.getTimeToLive());
   }
   if (redisProperties.getKeyPrefix() != null) {
      config = config.prefixKeysWith(redisProperties.getKeyPrefix());
   }
   if (!redisProperties.isCacheNullValues()) {
      config = config.disableCachingNullValues();
   }
   if (!redisProperties.isUseKeyPrefix()) {
      config = config.disableKeyPrefix();
   }
   return config;
}
image-20220720145109609
image-20220720145109609
2、RedisCacheConfiguration

点击

private final org.springframework.data.redis.cache.RedisCacheConfiguration redisCacheConfiguration;

里的RedisCacheConfiguration,即可看到org.springframework.data.redis.cache.RedisCacheConfigurationRedisCacheConfiguration

这里就有keySerializationPair(键序列化对)和valueSerializationPair(值序列化对)

image-20220720145752451
image-20220720145752451

defaultCacheConfig方法的文档注释里可以看到,key使用了StringRedisSerializer来序列化,value使用JdkSerializationRedisSerializer来序列化

点击查看RedisCacheConfiguration类完整代码

/**
 * Default {@link RedisCacheConfiguration} using the following:
 * <dl>
 * <dt>key expiration</dt>
 * <dd>eternal</dd>
 * <dt>cache null values</dt>
 * <dd>yes</dd>
 * <dt>prefix cache keys</dt>
 * <dd>yes</dd>
 * <dt>default prefix</dt>
 * <dd>[the actual cache name]</dd>
 * <dt>key serializer</dt>
 * <dd>{@link org.springframework.data.redis.serializer.StringRedisSerializer}</dd>
 * <dt>value serializer</dt>
 * <dd>{@link org.springframework.data.redis.serializer.JdkSerializationRedisSerializer}</dd>
 * <dt>conversion service</dt>
 * <dd>{@link DefaultFormattingConversionService} with {@link #registerDefaultConverters(ConverterRegistry) default}
 * cache key converters</dd>
 * </dl>
 *
 * @return new {@link RedisCacheConfiguration}.
 */
public static RedisCacheConfiguration defaultCacheConfig() {
   return defaultCacheConfig(null);
}
image-20220720145809659
image-20220720145809659
3、编写MyRedisCacheConfig

可以参考org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类的determineConfiguration方法的写法,编写org.springframework.data.redis.cache.RedisCacheConfiguration类型的Bean

image-20220720151648207
image-20220720151648207

删掉gulimall-product模块的com.atguigu.gulimall.product.GulimallProductApplication启动类的@EnableCaching注解

image-20220720150049487
image-20220720150049487

gulimall-product模块的com.atguigu.gulimall.product.config包下新建MyRedisCacheConfig配置类,在该配置类里编写类型为org.springframework.data.redis.cache.RedisCacheConfigurationBean (不要导错包了)

image-20220720151543631
image-20220720151543631

进入org.springframework.data.redis.serializer.RedisSerializer,然后按ctrl+H查看其子类,可以看到有String类型和Json类型的序列化方式,spring框架提供的org.springframework.data.redis.serializer.StringRedisSerializer ,阿里提供的com.alibaba.fastjson.support.spring.FastJsonRedisSerializer,阿里提供的com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer 带了Generic的可以兼容任意类型,因此需要选择带Generic

image-20220720151527159
image-20220720151527159

完整代码:

package com.atguigu.gulimall.product.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author 无名氏
 * @date 2022/7/20
 * @Description:
 */
@Configuration
@EnableCaching
public class MyRedisCacheConfig {

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
    }

}
image-20220720152347608
image-20220720152347608
4、测试

删掉以前的在redis里的category(1)数据,刷新 http://localhost:10000/ 页面,在新生成的category(1)可以看到,数据并没有变为JSON格式

GIF 2022-7-20 15-29-31
GIF 2022-7-20 15-29-31
5、修改redisCacheConfiguration方法

重新修改gulimall-product模块的com.atguigu.gulimall.product.config.MyRedisCacheConfig类的redisCacheConfiguration方法

每一步设置都会返回一个新的RedisCacheConfiguration,因此应该覆盖老的config。(参照org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类的determineConfiguration方法的做法)

package com.atguigu.gulimall.product.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author 无名氏
 * @date 2022/7/20
 * @Description:
 */
@Configuration
@EnableCaching
public class MyRedisCacheConfig {

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return config;
    }

}
image-20220720160907935
image-20220720160907935
6、再次测试

删掉以前的在redis里的category(1)数据,刷新 http://localhost:10000/ 页面,在新生成的category(1)可以看到,这次成功返回了JSON数据,但是TTL-1

GIF 2022-7-20 16-08-11
GIF 2022-7-20 16-08-11
📝 JSON序列化

进入org.springframework.data.redis.serializer.RedisSerializer,然后按ctrl+H查看其子类,可以看到有String类型和Json类型的序列化方式,spring框架提供的org.springframework.data.redis.serializer.StringRedisSerializerspring框架提供的org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer,阿里提供的com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer,带了Generic的可以兼容任意类型,因此需要选择带Generic

image-20220720153113504
image-20220720153113504

7、读取yml数据

1、查看如何把不能修改的类注入容器

之所以ttl-1,是因为org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类的determineConfiguration方法,首先会判断org.springframework.data.redis.cache.RedisCacheConfiguration是否存在,如果存在,则直接返回,根本不会走下面的逻辑,因此可以直接复制后面序列化后的代码

private org.springframework.data.redis.cache.RedisCacheConfiguration determineConfiguration(
      ClassLoader classLoader) {
   if (this.redisCacheConfiguration != null) {
      return this.redisCacheConfiguration;
   }
   Redis redisProperties = this.cacheProperties.getRedis();
   org.springframework.data.redis.cache.RedisCacheConfiguration config = org.springframework.data.redis.cache.RedisCacheConfiguration
         .defaultCacheConfig();
   config = config.serializeValuesWith(
         SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader)));
   if (redisProperties.getTimeToLive() != null) {
      config = config.entryTtl(redisProperties.getTimeToLive());
   }
   if (redisProperties.getKeyPrefix() != null) {
      config = config.prefixKeysWith(redisProperties.getKeyPrefix());
   }
   if (!redisProperties.isCacheNullValues()) {
      config = config.disableCachingNullValues();
   }
   if (!redisProperties.isUseKeyPrefix()) {
      config = config.disableKeyPrefix();
   }
   return config;
}
image-20220720161252722
image-20220720161252722

由于org.springframework.boot.autoconfigure.cache.CacheProperties类没有放入ioc容器中,因此我们不能直接获取

其实注入进去了,@ConfigurationProperties(prefix = "spring.redis")注解指明当前RedisProperties类与配置文件的spring.redis绑定,但不会把该RedisProperties类注入到ioc容器,可以在本类(RedisProperties类)使用@Component注解把该类放入到ioc容器中,但是如果在不能修改源码的情况下,还可以使用@EnableConfigurationProperties(RedisProperties.class)注解,把RedisProperties类放入到ioc容器中

image-20220720161550757
image-20220720161550757

可以看到org.springframework.boot.autoconfigure.data.redis包的RedisAutoConfiguration类,使用@EnableConfigurationProperties(RedisProperties.class)绑定了本包下的RedisProperties类,该注解(@EnableConfigurationProperties(RedisProperties.class)注解)会把RedisProperties类放到ioc容器中

(如果只使用@EnableConfigurationProperties注解,不指明具体的类,则不会把RedisProperties类放到ioc容器中)

image-20220723102142594
image-20220723102142594

因此,可以参照org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类的写法

@Configuration
@ConditionalOnClass(RedisConnectionFactory.class)
@AutoConfigureAfter(RedisAutoConfiguration.class)
@ConditionalOnBean(RedisConnectionFactory.class)
@ConditionalOnMissingBean(CacheManager.class)
@Conditional(CacheCondition.class)
class RedisCacheConfiguration {

   private final CacheProperties cacheProperties;

   private final CacheManagerCustomizers customizerInvoker;

   private final org.springframework.data.redis.cache.RedisCacheConfiguration redisCacheConfiguration;

   RedisCacheConfiguration(CacheProperties cacheProperties, CacheManagerCustomizers customizerInvoker,
         ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration> redisCacheConfiguration) {
      this.cacheProperties = cacheProperties;
      this.customizerInvoker = customizerInvoker;
      this.redisCacheConfiguration = redisCacheConfiguration.getIfAvailable();
   }
image-20220720161334346
image-20220720161334346
2、修改MyRedisCacheConfig

用传参的方式使用CacheProperties(但是这种方式好像需要CacheProperties类在容器吧😥(亲测必须在容器内),老师讲的好像有问题,如果CacheProperties没在容器,不能通过这种方式,这样能成功的原因是CacheProperties类在ioc容器里,而老师讲的是不在ioc容器中该怎么做)

package com.atguigu.gulimall.product.config;

import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author 无名氏
 * @date 2022/7/20
 * @Description:
 */
@Configuration
@EnableCaching
public class MyRedisCacheConfig {

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

}
image-20220720161738193
image-20220720161738193

如果CacheProperties不在ioc容器,这才是正确的做法(当然最好不要直接通过@Autowired注入,最好通过传参的方式注入)

或者可以学习org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration类的做法在类上加上@EnableConfigurationProperties(CacheProperties.class)注解(我试了以下,不加``@EnableConfigurationProperties(CacheProperties.class)注解,直接注入也可以用)

package com.atguigu.gulimall.product.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author 无名氏
 * @date 2022/7/20
 * @Description:
 */
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyRedisCacheConfig {

    @Autowired
    CacheProperties cacheProperties;

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

}
image-20220720162428178
image-20220720162428178
3、测试

删掉以前的在redis里的category(1)数据,刷新 http://localhost:10000/ 页面,在新生成的category(1)可以看到,这次成功返回了JSON数据,ttl也是指定的过期时间了

GIF 2022-7-20 16-19-09
GIF 2022-7-20 16-19-09

数据的格式:

image-20220720163602260
image-20220720163602260
EnableConfigurationProperties参考文档

EnableConfigurationProperties注解参考文档: https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/context/properties/EnableConfigurationProperties.html

Annotation Type EnableConfigurationProperties


@Target(value=TYPE)
@Retention(value=RUNTIME)
@Documented
@Import(value=org.springframework.boot.context.properties.EnableConfigurationPropertiesRegistrar.class)
public @interface EnableConfigurationProperties

Enable support for @ConfigurationPropertiesopen in new window annotated beans. @ConfigurationProperties beans can be registered in the standard way (for example using @Beanopen in new window methods) or, for convenience, can be specified directly on this annotation.

详细使用说明: https://docs.spring.io/spring-boot/docs/2.1.3.RELEASE/reference/html/boot-features-external-config.html#boot-features-external-config-typesafe-configuration-properties

package com.example;

import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("acme")
public class AcmeProperties {

  private boolean enabled;

  private InetAddress remoteAddress;

  private final Security security = new Security();

  public boolean isEnabled() { ... }

  public void setEnabled(boolean enabled) { ... }

  public InetAddress getRemoteAddress() { ... }

  public void setRemoteAddress(InetAddress remoteAddress) { ... }

  public Security getSecurity() { ... }

  public static class Security {

    private String username;

    private String password;

    private List<String> roles = new ArrayList<>(Collections.singleton("USER"));

    public String getUsername() { ... }

    public void setUsername(String username) { ... }

    public String getPassword() { ... }

    public void setPassword(String password) { ... }

    public List<String> getRoles() { ... }

    public void setRoles(List<String> roles) { ... }

  }
}

The preceding POJO defines the following properties:

  • acme.enabled, with a value of false by default.
  • acme.remote-address, with a type that can be coerced from String.
  • acme.security.username, with a nested "security" object whose name is determined by the name of the property. In particular, the return type is not used at all there and could have been SecurityProperties.
  • acme.security.password.
  • acme.security.roles, with a collection of String.

You also need to list the properties classes to register in the @EnableConfigurationProperties annotation, as shown in the following example:

@Configuration
@EnableConfigurationProperties(AcmeProperties.class)
public class MyConfiguration {
}

Even if the preceding configuration creates a regular bean for AcmeProperties, we recommend that @ConfigurationProperties only deal with the environment and, in particular, does not inject other beans from the context. Having said that, the @EnableConfigurationProperties annotation is also automatically applied to your project so that any existing bean annotated with @ConfigurationProperties is configured from the Environment. You could shortcut MyConfiguration by making sure AcmeProperties is already a bean, as shown in the following example:

@Component
@ConfigurationProperties(prefix="acme")
public class AcmeProperties {

  // ... see the preceding example

}

This style of configuration works particularly well with the SpringApplication external YAML configuration, as shown in the following example:

# application.yml

acme:
  remote-address: 192.168.1.1
  security:
    username: admin
    roles:
      - USER
      - ADMIN

# additional configuration as required

To work with @ConfigurationProperties beans, you can inject them in the same way as any other bean, as shown in the following example:

@Service
public class MyService {

  private final AcmeProperties properties;

  @Autowired
  public MyService(AcmeProperties properties) {
      this.properties = properties;
  }

  //...

  @PostConstruct
  public void openConnection() {
    Server server = new Server(this.properties.getRemoteAddress());
    // ...
  }

}

8、可选配置

1、cache-null-value是否缓存空值

gulimall-product模块的src/main/resources/application.properties配置文件中,配置spring.cache.redis.cache-null-values=true,设置缓存空值

#缓存的类型
spring.cache.type=redis

#缓存的名字
#如果配置了缓存的名字,则只能使用这些名字
#系统中用到哪些缓存了,帮你创建出来
#spring.cache.cache-names=qq,qqq
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
spring.cache.redis.key-prefix=CACHE_
#是否开启前缀
spring.cache.redis.use-key-prefix=true
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
image-20220720163351170
image-20220720163351170

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的getLevel1Categories方法的返回值设置为null

image-20220720163452069
image-20220720163452069

删掉以前的在redis里的category(1)数据,刷新 http://localhost:10000/ 页面,在新生成的category(1)可以看到,空值也缓存了

GIF 2022-7-20 16-38-33
GIF 2022-7-20 16-38-33
2、use-key-prefix是否开启前缀

gulimall-product模块的src/main/resources/application.properties配置文件中,配置spring.cache.redis.use-key-prefix=false,设置不使用前缀

#缓存的类型
spring.cache.type=redis

#缓存的名字
#如果配置了缓存的名字,则只能使用这些名字
#系统中用到哪些缓存了,帮你创建出来
#spring.cache.cache-names=qq,qqq
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
spring.cache.redis.key-prefix=CACHE_
#是否开启前缀
spring.cache.redis.use-key-prefix=false
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
image-20220720163957315
image-20220720163957315

删掉以前的在redis里的category(1)数据,刷新 http://localhost:10000/ 页面,在新生成的category(1)可以看到,直接把注解里设置的key作为redis里面的key而不加自定义前缀默认前缀

GIF 2022-7-20 16-42-54
GIF 2022-7-20 16-42-54
3、测试完后,修改为最初配置

gulimall-product模块的src/main/resources/application.properties配置文件中,修改为最初配置

#缓存的类型
spring.cache.type=redis

#缓存的名字
#如果配置了缓存的名字,则只能使用这些名字
#系统中用到哪些缓存了,帮你创建出来
#spring.cache.cache-names=qq,qqq
#以`ms`(毫秒)为单位
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
#是否开启前缀
spring.cache.redis.use-key-prefix=true
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
image-20220720165738274
image-20220720165738274

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的getLevel1Categories方法的返回值设置为categoryEntities

image-20220720165749301
image-20220720165749301

9、常用注解(2)

1、@CacheEvict

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类里的updateCascade方法上添加@CacheEvict(value = {"category"}, key = "'level1Categories'")注解,表示使用失效模式(即删除指定的数据)

image-20220720171836317
image-20220720171836317

使用npm run dev启动前端项目,账号密码都是admin

启动后台的GulimallGatewayApplication服务、GulimallProductApplication服务、RenrenApplication服务

删除rediskeylevel1Categories的数据,刷新http://localhost:10000/页面,可以看到在redis里已经有keyCACHE_level1Categories的数据了

访问http://localhost:8001/网址,打开后台页面,在商品系统/分类维护,修改手机图标信息,可以看到在rediskeyCACHE_level1Categories的数据已经被删除了

GIF 2022-7-20 17-15-42
GIF 2022-7-20 17-15-42
2、重写getCatalogJson方法

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的getCatalogJson方法重命名为getCatalogJson2,然后重新写一个getCatalogJson方法,添加@Cacheable(value = "category",key = "#root.methodName")注解,指定key为方法名

@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    System.out.println("查询了数据库......");
    //一次查询所有
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(null);
    //1、查出所有一级分类
    List<CategoryEntity> level1Categories = this.getLevel1Categories();
    Map<String, List<Catelog2Vo>> result = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), l1 -> {
        //2、该一级分类的所有二级分类
        List<CategoryEntity> category2Entities = getCategoryEntities(categoryEntities, l1);
        List<Catelog2Vo> catelog2VoList = null;
        if (category2Entities != null) {
            catelog2VoList = category2Entities.stream().map(l2 -> {
                Catelog2Vo catelog2Vo = new Catelog2Vo();
                catelog2Vo.setCatalog1Id(l1.getCatId().toString());
                catelog2Vo.setId(l2.getCatId().toString());
                catelog2Vo.setName(l2.getName());
                //3、当前二级分类的所有三级分类
                List<CategoryEntity> category3Entities = getCategoryEntities(categoryEntities, l2);
                List<Catelog2Vo.Catelog3Vo> catelog3VoList = null;
                if (category3Entities != null) {
                    catelog3VoList = category3Entities.stream().map(l3 -> {
                        Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo();
                        catelog3Vo.setId(l3.getCatId().toString());
                        catelog3Vo.setName(l3.getName());
                        catelog3Vo.setCatalog2Id(l2.getCatId().toString());
                        return catelog3Vo;
                    }).collect(Collectors.toList());
                }
                catelog2Vo.setCatalog3List(catelog3VoList);
                return catelog2Vo;
            }).collect(Collectors.toList());
        }
        return catelog2VoList;
    }));
    return result;
}
image-20220720184549647
image-20220720184549647

删除rediskeyCACHE_level1Categories的数据,由于商城首页会调用getCatalogJson方法,因此可以刷新http://gulimall.com/页面,可以看到在redis里已经有keyCACHE_getCatalogJson的数据了

GIF 2022-7-20 18-43-13
GIF 2022-7-20 18-43-13
3、批量删除数据

方法一:

gulimall-product模块的com.atguigu.gulimall.product.service.impl.CategoryServiceImpl类的updateCascade上添加如下注解,执行两个删除语句

/**
 * 级联更新所有的数据
 *@CacheEvict: 失效模式
 * @param category
 */
@Caching(evict = {
        @CacheEvict(value = {"category"}, key = "'level1Categories'"),
        @CacheEvict(value = {"category"}, key = "'getCatalogJson'")
}
)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
    this.updateById(category);
    categoryBrandRelationService.updateCategory(category);
}
image-20220720185106905
image-20220720185106905

删除rediskeyCACHE_level1CategoriescatalogJson的数据,由于商城首页会调用getCatalogJsongetLevel1Categories方法,因此可以刷新http://gulimall.com/页面,可以看到在redis里已经有keyCACHE_getCatalogJsonCACHE_level1Categories的数据了,,在商品系统/分类维护,修改手机图标信息,可以看到在rediskeyCACHE_getCatalogJsonCACHE_level1Categories的数据已经被删除了

GIF 2022-7-20 18-57-47
GIF 2022-7-20 18-57-47

方法二:

@CacheEvict注解的allEntries = true属性可以把该分区的所有数据都删除,这样也可以达到批量删除数据的目的

/**
 * 级联更新所有的数据
 *@CacheEvict: 失效模式
 * @Caching: 批量操作
 * @CacheEvict(value = {"category"},allEntries = true) 删除该分区的所有数据
 * @param category
 */
//@Caching(evict = {
//        @CacheEvict(value = {"category"}, key = "'level1Categories'"),
//        @CacheEvict(value = {"category"}, key = "'getCatalogJson'")
//})
@CacheEvict(value = {"category"},allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
    this.updateById(category);
    categoryBrandRelationService.updateCategory(category);
}
image-20220720190209669
image-20220720190209669
4、不指定key-prefix

spring.cache.redis.key-prefix=CACHE_注释掉

#缓存的类型
spring.cache.type=redis

#缓存的名字
#如果配置了缓存的名字,则只能使用这些名字
#系统中用到哪些缓存了,帮你创建出来
#spring.cache.cache-names=qq,qqq
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
#是否开启前缀
spring.cache.redis.use-key-prefix=true
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
image-20220720190805585
image-20220720190805585

重新刷新http://gulimall.com/页面,可以看到不指定前缀后,会生成名为分区名的文件夹,在该文件夹内存放该分区的数据,这样显得更有层级关系

GIF 2022-7-20 19-13-46
GIF 2022-7-20 19-13-46
5、双写模式

如果想使用双写模式,可以使用@CachePut注解,由于该方法没有返回值,所以用不了双写模式,这里就不演示了,大概结构如下

@CachePut(value = {"category"}, key = "'level1Categories'")

@CachePut(value = {"category"}, key = "'level1Categories'")
@Transactional
@Override
public Object updateCascade(CategoryEntity category) {
    this.updateById(category);
    return categoryBrandRelationService.updateCategory(category);
}

10 172

4、Spring-Cache的不 足; 1)、读模式: 缓存穿透:查询一个null数据。解决:缓存空数据; spring.cache.redis.cache-null-values=true 缓存击穿:大量并发进来同时查询一一个正好过期的数据。解决:加锁; ? @Cacheable(value = {"category"}, key = "'level1Categories'",sync = true) 只有@Cacheable注解可以加锁 缓存雪崩:大量的key同时过期。解决:加随机时间。(加随机时间有可能弄巧成拙,本来是2s、1s时间过期,加随机时间变为2s+2s=4s1s+3s=4s时间过期) spring.cache.redis.time-to-live=3600000

2)、写模式: (缓存与数据库一 致) 1)、读写加锁。 2)、引入Canal,感知到MySQL的更新去更新数据库 3)、读多写多,直接去数据库查询就行

总结: 常规数据(读多写少,即时性,- 致性要求不高的数据) ;完全可以使用Spring-Cache(只要缓存的数据有过期时间就足够了): 特殊数据:特殊设计

5.5、商城业务-检索服务

5.5.1、初始化

1、添加配置

1、引入依赖

gulimall-search模块的pom.xml文件里引入thymeleaf依赖和devtools依赖

<!--引入thymeleaf-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--引入devtools-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-devtools</artifactId>
   <optional>true</optional>
</dependency>
image-20220720210415704
image-20220720210415704
2、关闭缓存

gulimall-search模块的src/main/resources/application.properties配置文件里配置关闭缓存

image-20220720210556603
image-20220720210556603
3、导入index.html

在资料里的2.分布式高级篇(微服务架构篇\资料源码\代码\html\搜索页文件夹下,复制index.html文件,粘贴到gulimall-search模块的src/main/resources/templates目录下

GIF 2022-7-20 19-37-45
GIF 2022-7-20 19-37-45
4、导入静态资源到nginx

在资料里的D:\尚硅谷\谷粒商城\2.分布式高级篇(微服务架构篇)\资料源码\代码\html\搜索页文件夹下,复制所有文件夹(除index.html),在虚拟机的/mydata/nginx/html/static/目录下,新建search文件夹。 粘贴到虚拟机里的 /mydata/nginx/html/static/search目录下

GIF 2022-7-20 20-46-29
GIF 2022-7-20 20-46-29
5、修改index.html

修改gulimall-search模块的src/main/resources/templates/index.html文件,使所有静态文件都访问nigix

点击查看index.html完整代码

image-20220724100623230
image-20220724100623230
6、访问检索页

启动GulimallSearchApplication服务,访问: http://localhost:12000/

可以看到可以获得数据,但没有访问到静态资源

image-20220720201949003
image-20220720201949003

2、首页通过检索跳转到检索页

1、在首页中检索

在首页中检索时,应该来到search.gulimall.com,但此时无法访问,是因为search.gulimall.com域名没有配置

GIF 2022-7-20 20-25-47
GIF 2022-7-20 20-25-47
2、Host文件添加域名

SwitchHosts工具里,编辑本地方案/gulimall,然后点击对勾,应用此方案

# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
image-20220720202806012
image-20220720202806012
3、再次测试

在首页中检索后,来到了nginx配置的默认首页,此时http://search.gulimall.com/域名下的Hostsearch.gulimall.com

image-20220720203018405
image-20220720203018405

3、修改search.gulimall.com配置

1、修改gulimall.conf文件

执行以下命令,修改/mydata/nginx/conf/conf.d/gulimall.conf文件,并重启nginx

cd /mydata/nginx
ls
cd conf/
ls
cd conf.d/
ls
vi gulimall.conf
docker restart nginx
docker ps
image-20220720203705064
image-20220720203705064

server_name gulimall.com;修改为server_name *.gulimall.com;

image-20220720203443206
image-20220720203443206
2、访问到了首页

访问 http://search.gulimall.com/ ,可以看到来到了首页,而不是检索页

image-20220720203821469
image-20220720203821469
3、修改gateway配置

gulimall-gateway模块的src/main/resources/application.yml文件里,修改id为 gulimall_host_route的配置,并添加id为 gulimall_search_route的配置

然后重启GulimallGatewayApplication服务

- id: gulimall_host_route
  uri: lb://gulimall-product
  predicates:
    - Host=gulimall.com

- id: gulimall_search_route
  uri: lb://gulimall-search
  predicates:
    - Host=search.gulimall.com
image-20220720205136681
image-20220720205136681
4、访问的检索页

访问 http://search.gulimall.com/ ,这次来到了检索页

image-20220720205237222
image-20220720205237222

有几个文件没有找到不用管,本来就没有

image-20220720205803381
image-20220720205803381

4、Nginx转发效果

image-20220720205507777

5、点击logo返回首页

1、查看logo所在标签

在 http://search.gulimall.com/ 页面里,打开控制台,定位到logo,复制src="/static/search/./image/logo1.jpg"

image-20220720211104053
image-20220720211104053
2、修改该标签

gulimall-search模块的src/main/resources/templates/list.html文件里搜索刚刚复制的内容

把该a标签href修改为"http://gulimall.com,使其访问首页

<a href="http://gulimall.com"><img src="/static/search/./image/logo1.jpg" alt=""></a>
image-20220720211434554
image-20220720211434554
3、测试

重启GulimallSearchApplication服务,在 http://search.gulimall.com/ 页面里,点击logo,可以看到跳转到了nginx的默认首页

GIF 2022-7-20 21-15-25
4、修改nginx配置

进入虚拟机的/mydata/nginx/conf/conf.d/gulimall.conf文件,修改配置,然后重启nginx

cd /mydata/nginx/conf/conf.d/
vi gulimall.conf
docker restart nginx

进入gulimall.conf后,把

server_name  *.gulimall.com;

修改为(gulimall.com*.gulimall.com;中间有空格)

server_name  gulimall.com *.gulimall.com;
image-20220720212048919
image-20220720212048919
5、再次测试
GIF 2022-7-22 15-02-08
GIF 2022-7-22 15-02-08

6、点击谷粒商城首页返回首页

1、查看谷粒商城首页所在标签

在 http://search.gulimall.com/ 页面里,打开控制台,定位到谷粒商城首页,复制谷粒商城首页

image-20220722145830936
image-20220722145830936
2、修改该标签

gulimall-search模块的src/main/resources/templates/list.html文件里搜索刚刚复制的内容

把该a标签href修改为"http://gulimall.com,使其访问首页

<a href="http://gulimall.com" class="header_head_p_a1" style="width:73px;">
    谷粒商城首页
</a>
image-20220722150037921
image-20220722150037921
3、测试
GIF 2022-7-24 10-59-14
GIF 2022-7-24 10-59-14

7、首页通过分类跳转到检索页

1、测试

在 http://gulimall.com/ 首页里,在左侧的导航栏里,鼠标悬浮到手机,在手机通讯里点击手机,可以看到来到了

http://search.gmall.com/list.html?catalog3Id=225 ,而不是 http://search.gulimall.com/list.html?catalog3Id=225

GIF 2022-7-22 15-18-20
GIF 2022-7-22 15-18-20
2、修改catalogLoader.js

修改虚拟机里/mydata/nginx/html/static/index/js下的catalogLoader.js,搜索gmall,把gmall修改为gulimall,然后重启nginx

GIF 2022-7-20 21-33-03
GIF 2022-7-20 21-33-03
3、再次测试

先清除浏览器的缓存,再在 http://gulimall.com/ 首页里,在左侧的导航栏里,鼠标悬浮到手机,在手机通讯里点击手机,可以看到来到了 http://search.gulimall.com/list.html?catalog3Id=225

GIF 2022-7-20 21-36-27
GIF 2022-7-20 21-36-27

8、通过list.html访问检索页

1、关闭thymeleaf缓存

gulimall-search模块的src/main/resources/application.yml配置文件里,关闭thymeleaf缓存

spring:
  thymeleaf:
    #关闭thymeleaf缓存
    cache: false
image-20220722145204078
image-20220722145204078
2、index.html重命名为list.html

gulimall-search模块的src/main/resources/templates/index.html重命名为list.html

GIF 2022-7-22 15-09-31
GIF 2022-7-22 15-09-31
3、添加listPage方法

gulimall-search模块的com.atguigu.gulimall.search.controller.SearchController类里添加listPage方法,跳转到list.html页面

package com.atguigu.gulimall.search.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @author 无名氏
 * @date 2022/7/22
 * @Description:
 */
@Controller
public class SearchController {

    @RequestMapping("/list.html")
    public String listPage(){
        return "list";
    }
}
image-20220722153722746
image-20220722153722746
4、测试

在 http://gulimall.com/ 首页里,在左侧的导航栏里,鼠标悬浮到手机,在手机通讯里点击手机,可以看到来到了 http://search.gulimall.com/list.html?catalog3Id=225 并成功访问到页面

GIF 2022-7-22 15-25-39
GIF 2022-7-22 15-25-39

9、首页通过检索跳转到了search.html

1、检索跳转到了search.html

在 http://gulimall.com/ 里,通过搜索,跳转到了 http://search.gulimall.com/search.html?keyword=111 而不是 http://search.gulimall.com/list.html?keyword=111

GIF 2022-7-22 15-28-44
GIF 2022-7-22 15-28-44
2、查找搜索所在标签

在 http://gulimall.com/ 里,打开控制台,找到搜索图标,复制search()

image-20220722153047210
image-20220722153047210
3、修改跳转的路径

gulimall-search模块的src/main/resources/templates/list.html页面里搜索刚刚复制的内容

修改script里的 window.location.href="http://search.gulimall.com/search.html?keyword="+keyword;为以下内容

<script type="text/javascript">
  function search() {
    var keyword=$("#searchText").val()
    window.location.href="http://search.gulimall.com/list.html?keyword="+keyword;
  }

</script>
image-20220722153411321
image-20220722153411321
4、修改不生效

重启gulimall-product服务,在 http://gulimall.com/ 里,通过搜索,跳转到了 http://search.gulimall.com/search.html?keyword=111 而不是 http://search.gulimall.com/list.html?keyword=111 很明显配置没生效

GIF 2022-7-22 15-32-40
GIF 2022-7-22 15-32-40
5、关闭thymeleaf缓存

gulimall-product模块的src/main/resources/application.yml配置文件里,关闭thymeleaf缓存

image-20220722153254270
image-20220722153254270
6、再次测试

重启GulimallProductApplication服务,在 http://gulimall.com/ 里,通过搜索,成功跳转到了 http://search.gulimall.com/list.html?keyword=111

GIF 2022-7-22 15-35-30
GIF 2022-7-22 15-35-30

gulimall-product模块的src/main/resources/templates/index.html文件里。如果不行的话,可以在a标签里调javascript的代码

<a href="javascript:search();" ><img src="/static/index/img/img_09.png" /></a>
image-20220722154519044
image-20220722154519044

5.5.2、编写检索数据代码

1、新建SearchParam

gulimall-search模块里,在com.atguigu.gulimall.search包下新建vo文件夹,在vo文件夹下新建SearchParam

用来获取查询商品请求的数据参数

package com.atguigu.gulimall.search.vo;

import lombok.Data;

import java.util.List;

/**
 * @author 无名氏
 * @date 2022/7/22
 * @Description: 封装可能的所有的查询条件
 *  catalog3Id=225&keyword=小米&sort=saleCount_asc&hasStock=0/1
 */
@Data
public class SearchParam {

    /**
     * 页面传过来的全文匹配的关键字
     * keyword=小米
     */
    private String keyword;

    /**
     * 三级分类的id
     * catalog3Id=225
     */
    private Long catalog3Id;

    /**
     * 排序条件
     * 按照销量升序或降序     sort=saleCount_asc/desc
     * 按照sku价格升序或降序  sort=skuPrice_asc/desc
     * 按照热度评分升序或降序  sort=hotScore_asc/desc
     */
    private String sort;

    /**
     * 品牌的id(可以指定多个品牌)
     * brandId=1&brandId=2
     */
    private List<Long> brandId;

    /**
     * 是否有货(是否只显示有货)
     * hasStock=0/1
     */
    private Integer hasStock;

    /**
     * 价格区间
     * 价格在1~500之间  skuPrice=1_500
     * 价格不高于500    skuPrice=_500
     * 价格不低于1      skuPrice=1_
     */
    private String skuPrice;

    /**
     * 指定属性
     * attrs=1_安卓:其他&attrs=2_5寸:6寸
     *
     * 1号属性(系统)值为 `安卓`或`其他`
     * 2号属性(屏幕尺寸)值为 `5寸`或`6寸`
     */
    private List<String> attrs;

    /**
     * 页码
     */
    private Integer pageNum;

}
image-20220722163637498
image-20220722163637498
2、新建SearchResult

gulimall-search模块的com.atguigu.gulimall.search.vo包下新建SearchResult类,用来封装查询商品请求所响应的数据

package com.atguigu.gulimall.search.vo;

import com.atguigu.common.to.es.SkuEsModel;
import lombok.Data;

import java.util.List;

/**
 * @author 无名氏
 * @date 2022/7/22
 * @Description: 根据查询条件返回的结果
 */
@Data
public class SearchResult {

    /**
     * 查询到的所有商品信息
     */
    private List<SkuEsModel> products;

    /**
     * 当前页码
     */
    private Integer pageNum;

    /**
     * 总记录数
     */
    private Long total;

    /**
     * 总页码
     */
    private Integer totalPages;

    /**
     * 当前查询到的结果涉及到的所有品牌
     */
    private List<BrandVo> brands;

    /**
     * 当前查询到的结果涉及到的所有分类
     */
    private List<CatalogVo> catalogs;

    /**
     *  当前查询到的结果涉及到的所有属性
     */
    private List<AttrVo> attrs;

    /**
     * 品牌vo
     */
    @Data
    public static class BrandVo{
        /**
         * 品牌id
         */
        private Long brandId;
        /**
         * 品牌名
         */
        private String brandName;
        /**
         * 品牌图片
         */
        private String brandImg;
    }

    /**
     * 分类vo
     */
    @Data
    public static class CatalogVo{
        /**
         * 分类id
         */
        private Long catalogId;
        /**
         * 分类名
         */
        private String catalogName;

    }

    /**
     * 属性vo
     */
    @Data
    public static class AttrVo{
        /**
         * 属性的id
         */
        private Long attrId;
        /**
         * 属性名
         */
        private String attrName;
        /**
         * 属性值
         */
        private List<String> attrValue;

    }
}
image-20220722170225469
image-20220722170225469
3、新建MallSearchService

gulimall-search模块里,在com.atguigu.gulimall.search.service包下新建MallSearchService接口

package com.atguigu.gulimall.search.service;

import com.atguigu.gulimall.search.vo.SearchParam;
import com.atguigu.gulimall.search.vo.SearchResult;

/**
 * @author 无名氏
 * @date 2022/7/22
 * @Description:
 */
public interface MallSearchService {

    /**
     * 查询商品
     * @param searchParam 检索的参数
     * @return 根据检索的参数查询到的结果,包含页面需要的所有信息
     */
    public SearchResult search(SearchParam searchParam);
}
image-20220722170144372
image-20220722170144372
4、修改listPage方法

修改gulimall-search模块的com.atguigu.gulimall.search.controller.SearchController类的listPage方法

@Autowired
MallSearchService mallSearchService;

/**
 * 自动将页面提交过来的所有请求查询参数封装成指定的对象
 * @return
 */
@RequestMapping("/list.html")
public String listPage(SearchParam searchParam, Model model){

    SearchResult result = mallSearchService.search(searchParam);
    model.addAttribute("result",result);
    return "list";
}
image-20220722170844284
image-20220722170844284

5.5.3、测试请求ES格式

访问kibana: http://192.168.56.10:5601/

1、nested嵌入式查询

skuTitlemust里,通过skuTitle来获取权重。其他在filter里,只做查询不设权重,以加快查询速度。

GET product/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "skuTitle": "华为"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "catalogId": "225"
          }
        },
        {
          "terms": {
            "brandId": [
              "1",
              "2",
              "9"
            ]
          }
        },
        {
          "nested": {
            "path": "attrs",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attrs.attrId": {
                        "value": "1"
                      }
                    }
                  },
                  {
                    "terms": {
                      "attrs.attrValue": [
                        "LIO-A00",
                        "A2100"
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "term": {
            "hasStock": {
              "value": "true"
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "skuPrice": {
        "order": "desc"
      }
    }
  ]
}
image-20220722191738587
image-20220722191738587

其中如果想查attrs里的属性(如attrs.attrId),则必须先写nested,并指定path,然后通过query来进行查询

GET product/_search
{
  "query": {
    "bool": {
      "filter": {
        "nested": {
          "path": "attrs",
          "query": {
            "bool": {
              "must": [
                {
                  "term": {
                    "attrs.attrId": {
                      "value": "1"
                    }
                  }
                },
                {
                  "terms": {
                    "attrs.attrValue": [
                      "LIO-A00",
                      "A2100"
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
}

参考文档: https://www.elastic.co/guide/en/elasticsearch/reference/8.3/query-dsl-nested-query.html

Nested query allows to query nested objects / docs (see nested mappingopen in new window). The query is executed against the nested objects / docs as if they were indexed as separate docs (they are, internally) and resulting in the root parent doc (or parent nested mapping). Here is a sample mapping we will work with:

{
    "type1" : {
        "properties" : {
            "obj1" : {
                "type" : "nested"
            }
        }
    }
}

And here is a sample nested query usage:

{
    "nested" : {
        "path" : "obj1",
        "score_mode" : "avg",
        "query" : {
            "bool" : {
                "must" : [
                    {
                        "match" : {"obj1.name" : "blue"}
                    },
                    {
                        "range" : {"obj1.count" : {"gt" : 5}}
                    }
                ]
            }
        }
    }
}

The query path points to the nested object path, and the query (or filter) includes the query that will run on the nested docs matching the direct path, and joining with the root parent docs.

The score_mode allows to set how inner children matching affects scoring of parent. It defaults to avg, but can be total, max and none.

Multi level nesting is automatically supported, and detected, resulting in an inner nested query to automatically match the relevant nesting level (and not root) if it exists within another nested query.

GIF 2022-7-24 18-45-46
GIF 2022-7-24 18-45-46

2、加入range查不出数据

filter里加了个range就查不出数据了

{
  "range": {
    "skuPrice": {
      "gte": 0,
      "lte": 6000
    }
  }
}
image-20220722195006076
image-20220722195006076

删掉range,再次查询可以发现,数据里少了skuPrice这个属性,这是因为在Elasticsearch里,最初设置的skuPrice的属性类型为double,而SkuEsModel类里的skuPrice属性的类型为BigDecimalElasticsearch不能兼容这两个数据类型,因此要在Elasticsearch里,设置的skuPrice的属性类型为keyword (在Elasticsearch里,设置的skuPrice的属性类型为keyword也不行,设置的skuPrice的属性类型为keyword可以,我代码里没有设置skuImgskuPrice所以不行))

image-20220722194900142
image-20220722194900142

完整查询:

GET product/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "skuTitle": "华为"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "catalogId": "225"
          }
        },
        {
          "terms": {
            "brandId": [
              "1",
              "2",
              "9"
            ]
          }
        },
        {
          "nested": {
            "path": "attrs",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attrs.attrId": {
                        "value": "1"
                      }
                    }
                  },
                  {
                    "terms": {
                      "attrs.attrValue": [
                        "LIO-A00",
                        "A2100"
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "term": {
            "hasStock": {
              "value": "true"
            }
          }
        },
        {
          "range": {
            "skuPrice": {
              "gte": 0,
              "lte": 6000
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "skuPrice": {
        "order": "desc"
      }
    }
  ]
}

3、数据迁移(不推荐)

可以通过数据迁移(不推荐),来增加skuPrice属性,但是后面还要修改数据,这次迁移后,还要再次迁移,可以先把range注释掉,最后再数据迁移

1、获取product映射

使用GET product/_mapping,获取product的映射信息,然后复制结果

GET product/_mapping
image-20220723202123465
image-20220723202123465
2、向product2创建映射

然后输入PUT product2,并粘贴刚刚复制的结果,再删除粘贴过来的结果里的"product" :{ }右括号随便在最后删除一个就行了

然后点击工具图标,选择Auto indent格式化一下代码,再在里面修改映射信息

这里需要把skuPrice里的"type" : "double"修改为"type": "keyword"

image-20220723202738147
image-20220723202738147

完整代码:(attrs里面应该还有"type" : "nested",,最开始弄错了)

PUT product
{
  "mappings": {
    "properties": {
      "attrs": {
        "type": "nested",
        "properties": {
          "attrId": {
            "type": "long"
          },
          "attrName": {
            "type": "keyword",
            "index": false,
            "doc_values": false
          },
          "attrValue": {
            "type": "keyword"
          }
        }
      },
      "brandId": {
        "type": "long"
      },
      "brandImg": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "brandName": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "catalogId": {
        "type": "long"
      },
      "catalogName": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "hasStock": {
        "type": "boolean"
      },
      "hotScore": {
        "type": "long"
      },
      "saleCount": {
        "type": "long"
      },
      "skuId": {
        "type": "long"
      },
      "skuImg": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "skuPrice": {
        "type": "keyword"
      },
      "skuTitle": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "spuId": {
        "type": "keyword"
      }
    }
  }
}
3、写错后,可以通过DELETE删除

如果写错了可以使用DELETE product2删除,product2重新添加就行了

DELETE product2
image-20220723203114440
image-20220723203114440
4、迁移数据

然后使用如下命令,进行数据迁移

POST _reindex
{
  "source": {
    "index": "product"
  },
  "dest": {
    "index": "product2"
  }
}

POST _reindex为固定写法,source里的index里写老数据的索引,dest写想要迁移到的索引

image-20220723204346825
image-20220723204346825
5、查询数据

使用GET product2/_search命令,查看数据,可以看到数据已经迁移过来了

GET product2/_search
image-20220723204514369
image-20220723204514369
6、添加skuPrice属性

使用如下方式,可以把匹配到的数据里,添加skuPrice属性

POST /product2/_update_by_query
{
  "query": {
     "match_all": {}
  },
  "script": {
    "inline": "ctx._source['skuPrice'] = '5000'"
  }
}
image-20220723205423193
image-20220723205423193

1.源生API

在这里没有用官方提供的bulk API,而是用的另外一种方式。

POST /infomations/infomations/_update_by_query
JSON请求格式
{
    "query": {
        "match": {
            "status": "UP_SHELF"
        }
    },
    "script": {
        "inline": "ctx._source['status'] = 'DOWN_SHELF'"
    }
}
POST请求/索引/文档名/_update_by_query

主要看一下下面的script

ctx._source[字段名] = “值”;ctx._source[字段名] = “值”;
多个的话就用分号隔开。

2.JAVA API操作

//集群模式,获取链接        
Client client = elasticsearchTemplate.getClient();        
UpdateByQueryRequestBuilder updateByQuery = UpdateByQueryAction.INSTANCE.newRequestBuilder(client);
String name = "修改数值";
updateByQuery.source("索引")         //查询要修改的结果集
.filter(QueryBuilders.termQuery("field", 412))         //修改操作 
.script(new Script( "ctx._source['field']='"+ name+"';ctx._source['field']='"+name+"'"));
//响应结果集        
BulkByScrollResponse response = updateByQuery.get();
long updated = response.getUpdated();
7、查看数据

使用GET product2/_search命令,查看数据,可以看到已有skuPrice属性,并附上默认值了

GET product2/_search
image-20220723205510858
image-20220723205510858
8、再迁移回product

再通过相同的方式,删掉product,再迁移过来

DELETE product
image-20220723210155470
image-20220723210155470
GET product2/_mapping
image-20220723210322868
image-20220723210322868

这里的attrs里面应该还有"type" : "nested",,这里没有,所以后面报错了

image-20220723210305651
image-20220723210305651
POST _reindex
{
  "source": {
    "index": "product2"
  },
  "dest": {
    "index": "product"
  }
}
image-20220723210438315
image-20220723210438315
9、查询出错

然后执行查询,可以发现执行出错了

image-20220723213928183
image-20220723213928183
GET product/_mapping

这里的attrs里面应该还有"type" : "nested",

image-20220723214306034
image-20220723214306034
10、重新映射

删掉product

DELETE product
image-20220724160104844
image-20220724160104844

重新映射

PUT product
{
  "mappings": {
    "properties": {
      "attrs": {
        "type": "nested",
        "properties": {
          "attrId": {
            "type": "long"
          },
          "attrName": {
            "type": "keyword",
            "index": false,
            "doc_values": false
          },
          "attrValue": {
            "type": "keyword"
          }
        }
      },
      "brandId": {
        "type": "long"
      },
      "brandImg": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "brandName": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "catalogId": {
        "type": "long"
      },
      "catalogName": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "hasStock": {
        "type": "boolean"
      },
      "hotScore": {
        "type": "long"
      },
      "saleCount": {
        "type": "long"
      },
      "skuId": {
        "type": "long"
      },
      "skuImg": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "skuPrice": {
        "type": "keyword"
      },
      "skuTitle": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "spuId": {
        "type": "keyword"
      }
    }
  }
}
image-20220724160201531
image-20220724160201531

这次有attrs属性有"type": "nested",

image-20220724161122333
image-20220724161122333

skuPricetype也为keyword

image-20220724161159451
image-20220724161159451
11、重新上架

打开Navicat软件,点击gulimall_pms数据库,打开pms_spu_info表,修改华为IPhonepublish_status0

image-20220724155727408
image-20220724155727408

启动Unnamed,即启动GulimallCouponApplicationGulimallGatewayApplicationGulimallMemberApplicationGulimallProductApplicationGulimallSearchApplicationGulimallThirdPartyApplicationGulimallWareApplicationRenrenApplication,共8个服务(注意启动nacos)

image-20220724155937981
image-20220724155937981

然后启动后台的vue项目,用户名和密码都为admin,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮

image-20220724161631575
image-20220724161631575

通过kibana查询数据,可以看到还是没有skuPrice属性

GET product/_search
image-20220724161903319
image-20220724161903319

使用如下方式,可以把匹配到的数据里,添加skuPrice属性

POST /product/_update_by_query
{
  "query": {
     "match_all": {}
  },
  "script": {
    "inline": "ctx._source['skuPrice'] = '5000'"
  }
}
image-20220724163508606
image-20220724163508606
GET /product/_search
image-20220724163524123
image-20220724163524123

4、高亮显示

添加分页从0开始,向后查询5个数据,并把匹配到的skuTitle颜色设置为红色

"from": 0,
"size": 5,
"highlight": {
"fields": {"skuTitle": {}}, 
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
}
image-20220722213436383
image-20220722213436383

完整查询语句:

GET product/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "skuTitle": "华为"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "catalogId": "225"
          }
        },
        {
          "terms": {
            "brandId": [
              "1",
              "2",
              "9"
            ]
          }
        },
        {
          "nested": {
            "path": "attrs",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attrs.attrId": {
                        "value": "1"
                      }
                    }
                  },
                  {
                    "terms": {
                      "attrs.attrValue": [
                        "LIO-A00",
                        "A2100"
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "term": {
            "hasStock": {
              "value": "true"
            }
          }
        },
        {
          "range": {
            "skuPrice": {
              "gte": 0,
              "lte": 6000
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "skuPrice": {
        "order": "desc"
      }
    }
  ],
  "from": 0,
  "size": 5,
  "highlight": {
    "fields": {"skuTitle": {}}, 
    "pre_tags": "<b style='color:red'>",
    "post_tags": "</b>"
  }
}

5、聚合查询

1、子聚合报错

聚合后只能获取到brandId,想要获取brandName可以用到子聚合,子聚合可以根据上一步的聚合结果再次进行聚合

GET product/_search
{
  "query": {
    "match_all": {}
  }
  , "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": {
        "brand_name_agg": {
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brand_img_agg":{
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalog_agg": {
      "terms": {
        "field": "catalogId",
        "size": 10
      }
    }
  },
  "size": 0
}

但是通过子聚合报错了

"reason": "Can't load fielddata on [brandName] because fielddata is unsupported on fields of type [keyword]. Use doc values instead."
image-20220724164420419
image-20220724164420419
2、原因

这是因为当时在创建映射时,对这些字段设置"index" : false,不进行查询,"doc_values" : false不进行聚合,从而节省内存。要想可以聚合只能进行数据迁移,然后再修改映射

image-20220724164941728
image-20220724164941728

6、数据迁移

1、获取product映射

使用GET product/_mapping,获取product的映射信息,然后复制结果

GET product/_mapping
image-20220724165209526
image-20220724165209526
2、向gulimall_product创建映射

输入PUT gulimall_product,并粘贴刚刚复制的结果,再删除粘贴过来的结果里的"product" :{ }右括号随便在最后删除一个就行了

然后点击工具图标,选择Auto indent格式化一下代码,再在里面修改映射信息

这里需要把所有的"index" : false, "doc_values" : false都删掉,然后点击执行

PUT gulimall_product
{
  "mappings": {
    "properties": {
      "attrs": {
        "type": "nested",
        "properties": {
          "attrId": {
            "type": "long"
          },
          "attrName": {
            "type": "keyword"
          },
          "attrValue": {
            "type": "keyword"
          }
        }
      },
      "brandId": {
        "type": "long"
      },
      "brandImg": {
        "type": "keyword"
      },
      "brandName": {
        "type": "keyword"
      },
      "catalogId": {
        "type": "long"
      },
      "catalogName": {
        "type": "keyword"
      },
      "hasStock": {
        "type": "boolean"
      },
      "hotScore": {
        "type": "long"
      },
      "saleCount": {
        "type": "long"
      },
      "skuId": {
        "type": "long"
      },
      "skuImg": {
        "type": "keyword"
      },
      "skuPrice": {
        "type": "keyword"
      },
      "skuTitle": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "spuId": {
        "type": "keyword"
      }
    }
  }
}
image-20220724165940837
image-20220724165940837
3、迁移数据
POST _reindex
{
  "source": {
    "index": "product"
  },
  "dest": {
    "index": "gulimall_product"
  }
}
image-20220724170234036
image-20220724170234036
4、查询数据

可以看到数据都迁移过来了

GET gulimall_product/_search
image-20220724170343570
image-20220724170343570
5、修改常量

gulimall-search模块的com.atguigu.gulimall.search.constant.EsConstant常量类里修改PRODUCT_INDEX字段,使用新的索引

public static final String PRODUCT_INDEX = "gulimall_product";
image-20220724170527735
image-20220724170527735
6、再次查询

再次使用gulimall_product查询,就可以查到数据了

GET gulimall_product/_search
{
  "query": {
    "match_all": {}
  }
  , "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": {
        "brand_name_agg": {
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brand_img_agg":{
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalog_agg": {
      "terms": {
        "field": "catalogId",
        "size": 10
      }
    }
  },
  "size": 0
}
image-20220724182331880
image-20220724182331880

7、嵌入式聚合

使用如下查询回查询不到数据

GET gulimall_product/_search
{
  "query": {
    "match_all": {}
  }
  , "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": {
        "brand_name_agg": {
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brand_img_agg":{
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalog_agg": {
      "terms": {
        "field": "catalogId",
        "size": 10
      },
      "aggs": {
        "catalog_name_agg": {
          "terms": {
            "field": "catalogName",
            "size": 10
          }
        }
      }
    },
        "attr_agg":{
          "terms": {
            "field": "attrs.attrId",
            "size": 10
          }
        }
  },
  "size": 0
}
image-20220725161115201
image-20220725161115201

如果是嵌入式的属性,查询,聚合,分析都应该用嵌入式的,因此不能直接获取attrs.attrId的聚合结果

GET gulimall_product/_search
{
  "query": {
    "match_all": {}
  },
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": {
        "brand_name_agg": {
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brand_img_agg": {
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalog_agg": {
      "terms": {
        "field": "catalogId",
        "size": 10
      },
      "aggs": {
        "catalog_name_agg": {
          "terms": {
            "field": "catalogName",
            "size": 10
          }
        }
      }
    },
    "attr_agg": {
      "nested": {
        "path": "attrs"
      },
      "aggs": {
        "attr_id_agg": {
          "terms": {
            "field": "attrs.attrId",
            "size": 10
          }
        }
      }
    }
  },
  "size": 0
}
image-20220725160847972
image-20220725160847972

参考文档: https://www.elastic.co/guide/en/elasticsearch/reference/8.3/query-dsl-nested-query.html

GIF 2022-7-24 18-47-57
GIF 2022-7-24 18-47-57

Nested aggregation

A special single bucket aggregation that enables aggregating nested documents.

For example, lets say we have an index of products, and each product holds the list of resellers - each having its own price for the product. The mapping could look like:

PUT /products
{
  "mappings": {
    "properties": {
      "resellers": { 
        "type": "nested",
        "properties": {
          "reseller": {
            "type": "keyword"
          },
          "price": {
            "type": "double"
          }
        }
      }
    }
  }
}

The following request adds a product with two resellers:

PUT /products/_doc/0?refresh
{
  "name": "LED TV", 
  "resellers": [
    {
      "reseller": "companyA",
      "price": 350
    },
    {
      "reseller": "companyB",
      "price": 500
    }
  ]
}

The following request returns the minimum price a product can be purchased for:

GET /products/_search?size=0
{
  "query": {
    "match": {
      "name": "led tv"
    }
  },
  "aggs": {
    "resellers": {
      "nested": {
        "path": "resellers"
      },
      "aggs": {
        "min_price": {
          "min": {
            "field": "resellers.price"
          }
        }
      }
    }
  }
}

As you can see above, the nested aggregation requires the path of the nested documents within the top level documents. Then one can define any type of aggregation over these nested documents.

8、完整查询

点击查看完整查询

image-20220724190807948
image-20220724190807948

5.5.4、Java发送请求给ES

1、封装请求数据

1、指定分页大小

gulimall-search模块的com.atguigu.gulimall.search.constant.EsConstant类里,添加PRODUCT_PAGE_SIZE属性,用来指定分页的大小

/**
 * 分页大小
 */
public static final Integer PRODUCT_PAGE_SIZE = 2;
image-20220725151744774
image-20220725151744774
2、模糊匹配过滤

gulimall-search模块的com.atguigu.gulimall.search.service.impl包下,新建ProductSaveServiceImpl类,用来进行商品的检索

首先封装模糊匹配,过滤,可以把IDEA水平分割,在IDEA里的左边放想发送的请求,在IDEA里的右边放要用代码实现的请求

每写一部分,把该部分想发送的请求JSON展开,把其他部分的JSON折叠,这样可以一步一步实现对应功能

点击查看MallSearchServiceImpl类完整代码

image-20220725144145427
image-20220725144145427
3、排序分页高亮

gulimall-search模块的com.atguigu.gulimall.search.service.impl.ProductSaveServiceImpl类里的buildSearchRequest方法里,添加如下代码,用来设置排序分页高亮等信息

//排序
//sort=saleCount_asc/desc
String sort = searchParam.getSort();
if (StringUtils.hasText(sort)){
    String[] s = sort.split("_");
    if (s.length==2 && !sort.startsWith("_")){
        SortOrder sortOrder = "asc".equalsIgnoreCase(s[1]) ? SortOrder.ASC:SortOrder.DESC;
        //SortOrder sortOrder = SortOrder.fromString(s[1]);
        sourceBuilder.sort(s[0],sortOrder);
    }
}

//分页
Integer pageNum = searchParam.getPageNum();
if (pageNum==null || pageNum<=0){
    pageNum = 1;
}
int from = (pageNum-1) * EsConstant.PRODUCT_PAGE_SIZE;
sourceBuilder.from(from).size(EsConstant.PRODUCT_PAGE_SIZE);

//高亮
if (StringUtils.hasText(searchParam.getKeyword())) {
    HighlightBuilder highlightBuilder = new HighlightBuilder();
    highlightBuilder.field("skuTitle");
    highlightBuilder.preTags("<b style='color:red'>");
    highlightBuilder.postTags("</b>");
    sourceBuilder.highlighter(highlightBuilder);
}
image-20220725145835141
image-20220725145835141
4、测试(1)
1、输出sourceBuilder信息

gulimall-search模块的com.atguigu.gulimall.search.service.impl.ProductSaveServiceImpl类里的buildSearchRequest方法返回前,输出sourceBuilder信息

System.out.println(sourceBuilder.toString());
image-20220725150952617
image-20220725150952617
2、发送请求

使用Postman发送如下请求,查看默认返回的商品信息

http://localhost:12000/list.html
image-20220725151033746
image-20220725151033746
3、复制发送的请求

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"filter":[{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}}}
image-20220725152405404
image-20220725152405404
4、查看请求

随便找一个JSON格式化工具,比如 在线JSON校验格式化工具(Be JSON)open in new window ,粘贴刚刚复制的请求,然后点击格式化校验

可以看到,此时的查询条件只有hasStocktrue

{
	"from": 0,
	"size": 2,
	"query": {
		"bool": {
			"filter": [{
				"term": {
					"hasStock": {
						"value": true,
						"boost": 1.0
					}
				}
			}],
			"adjust_pure_negative": true,
			"boost": 1.0
		}
	}
}
image-20220725152445606
image-20220725152445606
5、在kibana中执行请求

kibana中执行如下请求,然后查看返回的结果,可以看到,已经查询出所有hasStocktrue的数据了

GET gulimall_product/_search
{
	"from": 0,
	"size": 2,
	"query": {
		"bool": {
			"filter": [{
				"term": {
					"hasStock": {
						"value": true,
						"boost": 1.0
					}
				}
			}],
			"adjust_pure_negative": true,
			"boost": 1.0
		}
	}
}
image-20220725152528831
image-20220725152528831
5、测试(2)

使用Postman发送如下请求,查看skuTitle华为catalogId225的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225
image-20220725152803797
image-20220725152803797

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"catalogId":{"value":225,"boost":1.0}}},{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220725152907806
image-20220725152907806

kibana中执行如下请求,然后查看返回的结果,可以看到,已经查询出所有hasStocktrueskuTitle华为catalogId225的数据了

GET gulimall_product/_search
{
  "from": 0,
  "size": 2,
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "skuTitle": {
              "query": "华为",
              "operator": "OR",
              "prefix_length": 0,
              "max_expansions": 50,
              "fuzzy_transpositions": true,
              "lenient": false,
              "zero_terms_query": "NONE",
              "auto_generate_synonyms_phrase_query": true,
              "boost": 1
            }
          }
        }
      ],
      "filter": [
        {
          "term": {
            "catalogId": {
              "value": 225,
              "boost": 1
            }
          }
        },
        {
          "term": {
            "hasStock": {
              "value": true,
              "boost": 1
            }
          }
        }
      ],
      "adjust_pure_negative": true,
      "boost": 1
    }
  },
  "highlight": {
    "pre_tags": [
      "<b style='color:red'>"
    ],
    "post_tags": [
      "</b>"
    ],
    "fields": {
      "skuTitle": {}
    }
  }
}
image-20220725152956547
image-20220725152956547
6、测试(3)

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A13 的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13
image-20220725153318989
image-20220725153318989

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"catalogId":{"value":225,"boost":1.0}}},{"nested":{"query":{"bool":{"must":[{"term":{"attrs.attrId":{"value":"1","boost":1.0}}},{"terms":{"attrs.attrValue":["LIO-A00","A13"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"path":"attrs","ignore_unmapped":false,"score_mode":"none","boost":1.0}},{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220725153352316
image-20220725153352316

kibana中执行如下请求,然后查看返回的结果,可以看到,已经查询出所有hasStocktrueskuTitle华为catalogId2251attrId其属性值为LIO-A00A13 的数据了,点击查看完整请求

image-20220725153503635
image-20220725153503635
7、测试(4)

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A132attrId其属性值为HUAWEI Kirin 970的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13&attrs=2_HUAWEI Kirin 970
image-20220725154316824
image-20220725154316824

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"catalogId":{"value":225,"boost":1.0}}},{"nested":{"query":{"bool":{"must":[{"term":{"attrs.attrId":{"value":"1","boost":1.0}}},{"terms":{"attrs.attrValue":["LIO-A00","A13"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"path":"attrs","ignore_unmapped":false,"score_mode":"none","boost":1.0}},{"nested":{"query":{"bool":{"must":[{"term":{"attrs.attrId":{"value":"2","boost":1.0}}},{"terms":{"attrs.attrValue":["HUAWEI Kirin 970"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"path":"attrs","ignore_unmapped":false,"score_mode":"none","boost":1.0}},{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220725154357224
image-20220725154357224

kibana中执行如下请求,然后查看返回的结果,可以看到,已经查询出所有hasStocktrueskuTitle华为catalogId2251attrId其属性值为LIO-A00A132attrId其属性值为HUAWEI Kirin 970的数据了,此时没有一条数据符合要求

点击查看完整请求

image-20220725154517396
image-20220725154517396
8、测试(5)

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A13skuPrice大于6000的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13&skuPrice=_6000
image-20220725154730511
image-20220725154730511

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"catalogId":{"value":225,"boost":1.0}}},{"nested":{"query":{"bool":{"must":[{"term":{"attrs.attrId":{"value":"1","boost":1.0}}},{"terms":{"attrs.attrValue":["LIO-A00","A13"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"path":"attrs","ignore_unmapped":false,"score_mode":"none","boost":1.0}},{"term":{"hasStock":{"value":true,"boost":1.0}}},{"range":{"skuPrice":{"from":null,"to":"6000","include_lower":true,"include_upper":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220725154813592
image-20220725154813592

kibana中执行如下请求,然后查看返回的结果,可以看到,已经查询出所有hasStocktrueskuTitle华为catalogId2251attrId其属性值为LIO-A00A13skuPrice大于6000的数据了

点击查看完整请求

image-20220725154927216
image-20220725154927216
9、聚合分析

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchRequest方法里的返回前面,添加如下代码:,用于品牌聚合分类聚合属性聚合

//聚合分析

//品牌聚合
//"aggs": {"brand_agg": {"terms": {"field": "brandId","size": 10},
//                         "aggs": {"brand_name_agg": {"terms": {"field": "brandName","size": 1}},
//                                  "brand_img_agg":{"terms": {"field": "brandImg","size": 1}}}}}
TermsAggregationBuilder brandAgg = AggregationBuilders.terms("brand_agg");
brandAgg.field("brandId").size(10);
brandAgg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brandAgg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
sourceBuilder.aggregation(brandAgg);

//分类聚合
//"aggs": {"catalog_agg": {"terms": {"field": "catalogId","size": 10},
//                          "aggs": {"catalog_name_agg": {"terms": {"field": "catalogName","size": 1}}}},}
TermsAggregationBuilder catalogAgg = AggregationBuilders.terms("catalog_agg");
catalogAgg.field("catalogId").size(10);
catalogAgg.subAggregation(AggregationBuilders.terms("catalogName").size(1));
sourceBuilder.aggregation(catalogAgg);

//属性聚合
//"aggs": {"attr_agg":{"nested": {"path": "attrs"},
//         "aggs": {"attr_id_agg":{"terms": {"field": "attrs.attrId","size": 10},
//            "aggs": {"attr_name_agg": {"terms": {"field": "attrs.attrName","size": 1}},
//                   "attr_value_agg":{"terms": {"field": "attrs.attrValue","size": 10}}}}}}}
NestedAggregationBuilder attrAgg = AggregationBuilders.nested("attr_agg","attrs");
TermsAggregationBuilder attrIdAgg = AggregationBuilders.terms("attr_id_agg");
attrIdAgg.field("attrs.attrId").size(10);
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(10));
attrAgg.subAggregation(attrIdAgg);
sourceBuilder.aggregation(attrAgg);
image-20220725185608463
image-20220725185608463
10、测试

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A13skuPrice大于6000的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13&skuPrice=_6000
image-20220725185732014
image-20220725185732014

复制GulimallSearchApplication服务的控制台输出的请求信息

{"from":0,"size":2,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"catalogId":{"value":225,"boost":1.0}}},{"nested":{"query":{"bool":{"must":[{"term":{"attrs.attrId":{"value":"1","boost":1.0}}},{"terms":{"attrs.attrValue":["LIO-A00","A13"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"path":"attrs","ignore_unmapped":false,"score_mode":"none","boost":1.0}},{"term":{"hasStock":{"value":true,"boost":1.0}}},{"range":{"skuPrice":{"from":null,"to":"6000","include_lower":true,"include_upper":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"aggregations":{"brand_agg":{"terms":{"field":"brandId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"brand_name_agg":{"terms":{"field":"brandName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"brand_img_agg":{"terms":{"field":"brandImg","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"catalog_agg":{"terms":{"field":"catalogId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"catalog_name_agg":{"terms":{"field":"catalogName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"attr_agg":{"nested":{"path":"attrs"},"aggregations":{"attr_id_agg":{"terms":{"field":"attrs.attrId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"attr_name_agg":{"terms":{"field":"attrs.attrName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"attr_value_agg":{"terms":{"field":"attrs.attrValue","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}}}}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220725185835546
image-20220725185835546

kibana中执行如下请求,然后查看返回的结果,可以看到,品牌聚合分类聚合属性聚合都已经成功展示了

image-20220727154908170
image-20220727154908170
11、完整代码

点击查看MallSearchServiceImpl类完整代码

2、处理响应数据

1、打断点

可以通过debug的方式查看ES返回的数据,进而对返回的数据进行相应处理

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里添加如下代码,并在该方法的第一行打上断点。以debug方式重新启动GulimallSearchApplication服务

/**
 * 根据查询到的数据,构建返回结果
 *
 * @param searchParam
 * @param response
 * @return
 */
private SearchResult buildSearchResponse(SearchParam searchParam, SearchResponse response) {
    SearchHits searchHits = response.getHits();
    SearchResult searchResult = new SearchResult();
    ////1、返回的所有查询到的商品
    //searchResult.setProducts();
    ////2、当前所有商品涉及到的所有属性信息
    //searchResult.setAttrs();
    ////3、当前所有商品涉及到的所有品牌信息
    //searchResult.setBrands();
    ////4、当前所有商品涉及到的所有分类信息
    //searchResult.setCatalogs();
    //5、分页信息-页码
    searchResult.setPageNum(searchParam.getPageNum());
    //6、分页信息-总记录树
    long total = searchHits.getTotalHits().value;
    searchResult.setTotal(total);
    //7、分页信息-总页码
    long totalPage = (long) Math.ceil((total/(double)EsConstant.PRODUCT_PAGE_SIZE));
    //long totalPage = (total-1)%EsConstant.PRODUCT_PAGE_SIZE +1;
    if (totalPage> Integer.MAX_VALUE){
        totalPage = Integer.MAX_VALUE;
    }
    searchResult.setTotalPages((int)totalPage);

    return searchResult;
}
image-20220725184629044
image-20220725184629044
2、发送请求

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A13skuPrice大于6000的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13&skuPrice=_6000
image-20220725190023037
image-20220725190023037
3、查看响应的response对象

切换到IDEA,可以看到在response对象里的internalResponse属性里即有hits命中的记录和aggregations聚合分析的结果

image-20220725190134099
image-20220725190134099
4、查看返回类型

ES中查出的数据是什么类型,aggregations.get("catalog_agg");返回的类型就写什么,不要使用IDEA生成的Aggregation类型

image-20220725193128221
image-20220725193128221
/**
 * 根据查询到的数据,构建返回结果
 *
 * @param searchParam
 * @param response
 * @return
 */
private SearchResult buildSearchResponse(SearchParam searchParam, SearchResponse response) {
    SearchHits searchHits = response.getHits();
    SearchResult searchResult = new SearchResult();
    //1、返回的所有查询到的商品
    SearchHit[] hits = searchHits.getHits();
    List<SkuEsModel> skuEsModels = null;
    if (hits !=null && hits.length>0){
        skuEsModels = new ArrayList<>();
        for (SearchHit hit : hits) {
            String s = hit.getSourceAsString();
            SkuEsModel skuEsModel = JSON.parseObject(s, SkuEsModel.class);
            skuEsModels.add(skuEsModel);
        }
    }
    searchResult.setProducts(skuEsModels);

    Aggregations aggregations = response.getAggregations();
    ////2、当前所有商品涉及到的所有属性信息
    //searchResult.setAttrs();
    ////3、当前所有商品涉及到的所有品牌信息
    //searchResult.setBrands();
    ////4、当前所有商品涉及到的所有分类信息
    ParsedLongTerms catalogAgg = aggregations.get("catalog_agg");
    //searchResult.setCatalogs();
    //5、分页信息-页码
    searchResult.setPageNum(searchParam.getPageNum());
    //6、分页信息-总记录树
    long total = searchHits.getTotalHits().value;
    searchResult.setTotal(total);
    //7、分页信息-总页码
    long totalPage = (long) Math.ceil((total/(double)EsConstant.PRODUCT_PAGE_SIZE));
    //long totalPage = (total-1)%EsConstant.PRODUCT_PAGE_SIZE +1;
    if (totalPage> Integer.MAX_VALUE){
        totalPage = Integer.MAX_VALUE;
    }
    searchResult.setTotalPages((int)totalPage);

    return searchResult;
}
5、打断点

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里添加如下代码,获取聚合结果里的需要数据,并在该方法中根据获取到的聚合结果对searchResult对象设置各种属性的关键位置打上断点,点击查看buildSearchResponse方法完整代码

image-20220725203447427
6、测试

使用Postman发送如下请求,查看skuTitle华为catalogId2251attrId其属性值为LIO-A00A13skuPrice大于6000的数据

http://localhost:12000/list.html?keyword=华为&catalog3Id=225&attrs=1_LIO-A00:A13&skuPrice=_6000
image-20220725190023037
image-20220725190023037

点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到searchResult.setProducts(skuEsModels);这里,可以看到,在skuEsModels对象了,封装了skuId1skuId2的两条数据,一个skuTitle8GB+128GB,另一个skuTitle8GB+256GB,但是查到的skuImg为空,而数据库中是有该图片的

image-20220725202950392
image-20220725202950392

这与在kibana中查询的数据一致,但是在通过keyword华为进行搜索时,skuTitle里的华为并没有红色加粗样式

而且没有skuImg属性,而数据库中是有该图片的

image-20220725204650097
image-20220725204650097

登录后台系统,在商品系统/商品维护/商品管理里面,可以看到华为的商品都是有图片的

image-20220727164425971
image-20220727164425971

继续点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到searchResult.setAttrs(attrVos);这里,可以看到attrVos里封装了一个attrVo对象

image-20220725203020614
image-20220725203020614

继续点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到searchResult.setBrands(brandVos);这里,可以看到已经封装了brandVos对象的信息

image-20220725203234794
image-20220725203234794

继续点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到searchResult.setCatalogs(catalogVos);这里,可以看到已经封装了catalogVos对象的信息

image-20220725203257449
image-20220725203257449

继续点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到return searchResult;这里,可以看到已成功封装了searchResult的相关数据,但是pageNumnull

image-20220725203400694
image-20220725203400694

3、处理bug

1、设置当前页

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里,在searchResult.setPageNum(pageNum);前面加一处判断,如果当前页pageNumnull<=0,就赋上默认值1

然后重启GulimallSearchApplication服务

Integer pageNum = searchParam.getPageNum();
if (pageNum ==null|| pageNum<=0){
    pageNum = 1;
}
searchResult.setPageNum(pageNum);
image-20220725210716273
image-20220725210716273

使用Postman发送如下请求,查看skuTitle华为的数据

http://localhost:12000/list.html?keyword=华为
image-20220726150930269
image-20220726150930269

一直点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到return searchResult;这里

image-20220726151842483
image-20220726151842483

查看searchResult对象数据,可以看到当前页pageNum已赋上默认值1,此时的searchResult对象里的products属性的SkuEsModel列表里的skuTitle里的华为还没有红色加粗样式

image-20220726151826495
image-20220726151826495

skuTitle里的华为还没有红色加粗样式在response对象的internalResponse属性里的hits中的相应的hit里的highlightFields属性的第一个列表里

image-20220726150926735
image-20220726150926735
2、对关键字加粗

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里,在skuEsModels.add(skuEsModel);之前加个判断,如果请求中带有keyword,就把skuTitle里的keyword相关的部分应用红色加粗样式

if (StringUtils.hasText(searchParam.getKeyword())) {
    HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
    if (skuTitle != null) {
        Text[] fragments = skuTitle.getFragments();
        if (fragments != null && fragments.length > 0) {
            skuEsModel.setSkuTitle(fragments[0].string());
        }
    }
}
image-20220726152129230
image-20220726152129230

然后重启GulimallSearchApplication服务,使用Postman发送如下请求,查看skuTitle华为的数据

http://localhost:12000/list.html?keyword=华为
image-20220726150930269
image-20220726150930269

一直点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到return searchResult;这里

image-20220726152009525
image-20220726152009525

查看searchResult对象数据,可以看到searchResult对象里的products属性的SkuEsModel列表里的skuTitle里的华为已应用红色加粗样式

image-20220726151955731
image-20220726151955731
3、完整代码

点击查看MallSearchServiceImpl类完整代码

5.5.5、完善检索页(1)

1、设置ES里的商品数据

1、页面结构

打开gulimall-search模块的src/main/resources/templates/list.html,可以看到页面由头部搜索导航热卖促销手机商品筛选和排序商品精选猜你喜欢我的足迹底部右侧侧边栏几部分组成

image-20220726152506150
2、rig_tab包裹4个商品div

在 http://search.gulimall.com/list.html 里,打开控制台,定位到商品div ,可以看到每一个classrig_tabdiv里面有4个商品的div

image-20220726150521869
image-20220726150521869

gulimall-search模块的src/main/resources/templates/list.html里搜索rig_tab,也可以看出来,每一个classrig_tabdiv里面有4个商品的div,代表一行的商品

image-20220726152654607
image-20220726152654607
3、修改代码

只保留一个classrig_tabdiv,删掉其他classrig_tabdiv。并在这个classrig_tabdiv里面,只保留里面的一个div,删掉其他div,修改里面的代码,修改为如下所示:

<div class="rig_tab">
    <div th:each="prodect:${result.getProducts()}">
        <div class="ico">
            <i class="iconfont icon-weiguanzhu"></i>
            <a href="#">关注</a>
        </div>
        <p class="da">
            <a href="#" th>
                <img th:src="${prodect.skuImg}" class="dim">
            </a>
        </p>
        <ul class="tab_im">
            <li>
                <a href="#" title="黑色">
                <img th:src="${prodect.skuImg}"></a>
            <li>
        </ul>
        <p class="tab_R">
            <span th:text="'¥'+${prodect.skuPrice}">¥5199.00</span>
        </p>
        <p class="tab_JE">
            <a href="#" th:text="${prodect.skuTitle}">
                Apple iPhone 7 Plus (A1661) 32G 黑色 移动联通电信4G手机
            </a>
        </p>
        <p class="tab_PI">已有<span>11万+</span>热门评价
            <a href="#">二手有售</a>
        </p>
        <p class="tab_CP"><a href="#" title="谷粒商城Apple产品专营店">谷粒商城Apple产品...</a>
            <a href='#' title="联系供应商进行咨询">
                <img src="/static/search/img/xcxc.png">
            </a>
        </p>
        <div class="tab_FO">
            <div class="FO_one">
                <p>自营
                    <span>谷粒商城自营,品质保证</span>
                </p>
                <p>满赠
                    <span>该商品参加满赠活动</span>
                </p>
            </div>
        </div>
    </div>
</div>
image-20220726155055223
image-20220726155055223
4、测试

在浏览器中输入如下网址,可以看到已显示ES里的商品数据,但是图片没有显示出来

http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为
image-20220726155237785
image-20220726155237785

2、添加图片url

启动Unnamed,即启动GulimallCouponApplicationGulimallGatewayApplicationGulimallMemberApplicationGulimallProductApplicationGulimallSearchApplicationGulimallThirdPartyApplicationGulimallWareApplicationRenrenApplication,共8个服务(注意启动nacos)

1、重新上架

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里的第一行打上断点。以debug方式重新启动GulimallSearchApplication服务,并刷新浏览器http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面,切换到IDEA,可以看到skuImgnull

image-20220726155136252
image-20220726155136252

而在kibana中作以下查询,也会发现数据里面没有skuImg

GET gulimall_product/_search
{"from":0,"size":16,"query":{"bool":{"must":[{"match":{"skuTitle":{"query":"华为","operator":"OR","prefix_length":0,"max_expansions":50,"fuzzy_transpositions":true,"lenient":false,"zero_terms_query":"NONE","auto_generate_synonyms_phrase_query":true,"boost":1.0}}}],"filter":[{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"aggregations":{"brand_agg":{"terms":{"field":"brandId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"brand_name_agg":{"terms":{"field":"brandName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"brand_img_agg":{"terms":{"field":"brandImg","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"catalog_agg":{"terms":{"field":"catalogId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"catalog_name_agg":{"terms":{"field":"catalogName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"attr_agg":{"nested":{"path":"attrs"},"aggregations":{"attr_id_agg":{"terms":{"field":"attrs.attrId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"attr_name_agg":{"terms":{"field":"attrs.attrName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"attr_value_agg":{"terms":{"field":"attrs.attrValue","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}}}}},"highlight":{"pre_tags":["<b style='color:red'>"],"post_tags":["</b>"],"fields":{"skuTitle":{}}}}
image-20220726155016758
image-20220726155016758

打开Navicat软件,点击gulimall_pms数据库,打开pms_spu_info表,修改华为publish_status0

image-20220726161322223
image-20220726161322223

然后启动后台的vue项目,用户名和密码都为admin,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮

image-20220726161515229
image-20220726161515229

通过kibana查询数据,可以看到还是没有skuPrice属性

image-20220726160010185
image-20220726160010185
2、打断点测试

gulimall-search模块的com.atguigu.gulimall.search.service.impl.ProductSaveServiceImpl类的productStatusUp方法的第一行打上断点,然后重启GulimallSearchApplication服务

image-20220726155921886
image-20220726155921886

打开Navicat软件,点击gulimall_pms数据库,打开pms_spu_info表,重新修改华为publish_status字段,使其为0

image-20220726161348561
image-20220726161348561

打开后台的vue项目,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮

image-20220726161520362
image-20220726161520362

可以看到skuEsModels里的skuImgskuPrice都为null,所有就没有把skuImgskuPrice的数据上传到ES

image-20220726160121631
image-20220726160121631

而后台的vue项目里,在商品系统/商品维护/商品管理里面,可以看到华为的商品都是有图片的

image-20220726160144185
image-20220726160144185
3、再次测试

重新以debug方式启动GulimallProductApplication服务和GulimallGatewayApplication服务,在gulimall-product模块的com.atguigu.gulimall.product.service.impl.SpuInfoServiceImpl类的up方法里的第一行R r = searchFeignService.productStatusUp(collect);这两个地方打上断点

image-20220726161650347
image-20220726161650347

打开后台的vue项目,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮

💬如果放行了上次请求,可以打开Navicat软件,点击gulimall_pms数据库,打开pms_spu_info表,重新修改华为publish_status字段,使其为0,再点击上传

可以看到在数据中查询的skuInfoEntities是有skuImgskuPrice数据的,不过叫skuDefaultImgprice罢了

image-20220726160514310
image-20220726160514310

点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到R r = searchFeignService.productStatusUp(collect);这里,可以看到这里想向GulimallSearchApplication服务传的collect数据里的skuImgskuPrice已经都为null

image-20220726160933888
image-20220726160933888

可以看到在整理向GulimallSearchApplication服务发送数据的代码里,就注释了要写skuPriceskuImg,怪不得前面ES里没有skuPrice的数据

image-20220726160949540
image-20220726160949540

gulimall-product模块的com.atguigu.gulimall.product.service.impl.SpuInfoServiceImpl类的up方法里,在List<SkuEsModel> collect = skuInfoEntities.stream().map(skuInfoEntity -> {里添加如下代码

//skuPrice
skuEsModel.setSkuPrice(skuInfoEntity.getPrice());
//skuImg
skuEsModel.setSkuImg(skuInfoEntity.getSkuDefaultImg());
image-20220726161058461
image-20220726161058461

打开后台的vue项目,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮

image-20220726161706872
image-20220726161706872

点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点停到R r = searchFeignService.productStatusUp(collect);这里,可以看到这里想向GulimallSearchApplication服务传的collect数据里的skuImgskuPrice已经都有数据了

image-20220726161758500
image-20220726161758500

点击GulimallProductApplication服务的Step Over(步过)按钮,跳转到gulimall-search模块的com.atguigu.gulimall.search.service.impl.ProductSaveServiceImpl类的productStatusUp方法的第一行,

BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);上打个断点,点击8: Services里的Resume Program F9按钮,跳转到下一处断点,使断点听到刚打的断点的位置,可以看到bulkRequest对象里,已经有skuImgskuPrice的数据了

image-20220726162655583
image-20220726162655583

kibana里执行以下查询语句,就可以看到有8条数据,并且有正确的skuImgskuPrice

GET gulimall_product/_search
{
  "query": {
    "match": {
      "skuTitle": "华为"
    }
  }
}
image-20220727202654983
image-20220727202654983

💬如果在kibana里执行GET gulimall_product/_search查询语句,则只有这一条华为的数据,这是因为ES分页了

image-20220726162739434
image-20220726162739434
4、收尾

停掉GulimallProductApplication服务和GulimallSearchApplication服务,然后再运行这两个服务

image-20220726161855276
image-20220726161855276

打开Navicat软件,点击gulimall_pms数据库,打开pms_spu_info表,修改华为IPhonepublish_status0

image-20220727212558069
image-20220727212558069

打开后台的vue项目,在商品系统/商品维护/spu管理里点击华为IPhone右侧操作里的上架按钮,就行了

image-20220727212654009
image-20220727212654009

3、对匹配到的关键词标红加粗

打开http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面,可以看到它标签对他进行了转义,显示了原本的<b style="color:red">华为</b>,而不是对华为应用了标红加粗的样式

image-20220726164052529
image-20220726164052529

打开gulimall-search模块的src/main/resources/templates/list.html文件,搜索${prodect.skuTitle},然后把<a href="#" th:text="${prodect.skuTitle}">修改为<a href="#" th:utext="${prodect.skuTitle}">

th:utext表示不对文本进行转义,点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

image-20220726164238113
image-20220726164238113

刷新http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面,可以看到已经对华为应用了标红加粗的样式

image-20220726164306730
image-20220726164306730

4、设置ES里的品牌数据

http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面里,打开控制台,定位到品牌,复制品牌

image-20220726164420581
image-20220726164420581

然后在gulimall-search模块的src/main/resources/templates/list.html文件里,搜索品牌,即可看到相应标签,在里面找到classsl_value_logodiv,只保留里面的一个<li>标签,删除其他多余的<li>标签

image-20220726164642485
image-20220726164642485

修改其内容,如下所示:

<!--品牌-->
<div class="JD_nav_wrap">
    <div class="sl_key">
        <span>品牌:</span>
    </div>
    <div class="sl_value">
        <div class="sl_value_logo">
            <ul>
                <li th:each="brand:${result.brands}">
                    <a href="#">
                        <img th:src="${brand.brandImg}" alt="">
                        <div th:text="${brand.brandName}">
                            华为(HUAWEI)
                        </div>
                    </a>
                </li>
            </ul>
        </div>
    </div>
image-20220726164956622
image-20220726164956622

点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

然后刷新http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面,可以看到已经成功显示相应品牌图标

image-20220726165058838
image-20220726165058838

5、其他需要展示的属性

gulimall-search模块的src/main/resources/templates/list.html文件里,在品牌的下面,删掉价格系统热点机身颜色机身内存,保留一个屏幕尺寸,修改里面的内容

image-20220726165247466
image-20220726165247466

修改为如下内容,然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<!--其他的所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}">
    <div class="sl_key">
        <span th:text="${attr.attrName}">屏幕尺寸:</span>
    </div>
    <div class="sl_value">
        <ul th:each="val:${attr.attrValue}">
            <li><a href="#" th:text="${val}"
                   th:href="${'javascript:searchProducts(&quot;attrs&quot;,&quot;'+attr.attrId+'_'+val+'&quot;)'}" >
                5.56英寸及以上</a></li>
        </ul>
    </div>
</div>
image-20220727205559815
image-20220727205559815

可以看到入网型号属性已经显示出来了

image-20220726165741784
image-20220726165741784

6、增加苹果库存

浏览器输入http://search.gulimall.com/list.html?catalog3Id=225&keyword=苹果可以看到,都没有检索到数据,这是因为苹果的商品都没货,所以检索不到数据

image-20220726171025918
image-20220726171025918

登录后台系统,在商品系统/商品维护/商品管理里面,可以看到苹果的商品有一个skuId9

image-20220726170523635
image-20220726170523635

点击库存系统/商品库存里的新增按钮,新增一个商品库存

sku_id输入9仓库选择1号仓库库存数输入10sku_name输入苹果手机锁定库存输入0,然后点击确定

image-20220726170531116
image-20220726170531116

kibana里,执行以下命令,将匹配到的数据的hasStock字段修改为true

GET gulimall_product/_update_by_query
{
  "query": {
    "match": {
      "skuTitle": "苹果"
    }
  },
  "script": {
    "inline": "ctx._source['hasStock'] = 'true'"
  }
}
image-20220726170900220
image-20220726170900220

kibana里,执行GET gulimall_product/_search命令,可以看到苹果hasStock已经变为true

image-20220726171241380
image-20220726171241380

刷新http://search.gulimall.com/list.html?catalog3Id=225&keyword=苹果页面,可以看到已经查出数据了

image-20220726171202781
image-20220726171202781

7、增加可检索到的数据

kibana里,执行以下命令,修改ESid10的数据,修改1attrIdattrValueA13,修改2attrIdattrValue8GattrName内存容量

POST gulimall_product/_doc/10
{
  "brandImg": "https://gulimall-anonymous.oss-cn-beijing.aliyuncs.com/2022-05-10/94d6c446-3d06-4e6e-8ddf-8da8f346f391_apple.png",
  "hasStock": "true",
  "skuTitle": "Apple IPhoneXS 苹果XS手机 银色 256GB",
  "brandName": "Apple",
  "hotScore": 0,
  "saleCount": 0,
  "skuPrice": "5000",
  "attrs": [
    {
      "attrId": 1,
      "attrValue": "A13",
      "attrName": "入网型号"
    },
    {
      "attrId": 2,
      "attrValue": "8G",
      "attrName": "内存容量"
    }
  ],
  "catalogName": "手机",
  "catalogId": 225,
  "brandId": 4,
  "spuId": 2,
  "skuId": 10
}
image-20220726173519836
image-20220726173519836

然后在kibana里,执行以下命令,查询刚刚修改的id10的数据,可以看到数据已经修改了

GET gulimall_product/_doc/10
image-20220726173527413
image-20220726173527413

刷新http://search.gulimall.com/list.html?catalog3Id=225&keyword=苹果页面,可以看到入网型号多了A13,属性多了内存容量,其值为8G

image-20220726173451002
image-20220726173451002

输入http://search.gulimall.com/list.html?catalog3Id=225&keyword=手机网址,可以看到华为苹果品牌入网型号内存容量都显示出来了

http://search.gulimall.com/list.html?catalog3Id=225&keyword=手机
image-20220726173421220
image-20220726173421220

每次结果只能显示两个,结果有点少,因此可以修改gulimall-search模块的com.atguigu.gulimall.search.constant.EsConstant类的PRODUCT_PAGE_SIZE字段,把该字段修改为16

public static final Integer PRODUCT_PAGE_SIZE = 16;
image-20220726194412699
image-20220726194412699

刷新http://search.gulimall.com/list.html?catalog3Id=225&keyword=手机页面,可以看到该页显示了更多的商品数据

image-20220726194648709
image-20220726194648709

8、添加分类

品牌修改为<b>品牌:</b>,复制<!--其他的所有需要展示的属性-->里的内容,粘贴到其上面,然后修改成分类的信息

点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<!--分类-->
<div class="JD_pre">
    <div class="sl_key">
        <span><b>分类:</b></span>
    </div>
    <div class="sl_value">
        <ul >
            <li th:each="catalog:${result.catalogs}">
                <a href="#" th:text="${catalog.catalogName}"></a>
            </li>
        </ul>
    </div>
</div>
image-20220726193026530
image-20220726193026530

可以看到在商品筛选里,已经多了分类条件,其可选值为手机

image-20220727211057758
image-20220727211057758

9、添加筛选条件

1、添加品牌筛选

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里添加如下方法,用于添加条件

function searchProducts(name,value){
    location.href = location.href+"&"+name+"="+value;
}
image-20220726194444104
image-20220726194444104

list.html文件的可选的品牌classsl_valuediv里的<a>标签改为如下代码,用于加参数跳转

<a href="#" th:href="${'javascript:searchProducts(&quot;brandId&quot;,'+brand.brandId+')'}">

其中&quot;"双引号,该代码相当于执行javascript:searchProducts("brandId",brand.brandId)

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

image-20220726194530101
image-20220726194530101

http://search.gulimall.com/list.html?catalog3Id=225&keyword=手机页面里,点击品牌里的华为,可以看到已经跳转到了``http://search.gulimall.com/list.html?catalog3Id=225&keyword=手机&brandId=1`页面

GIF 2022-7-26 19-48-28
GIF 2022-7-26 19-48-28
2、添加分类筛选

list.html文件的可选的分类里的classsl_valuediv里的<a>标签修改为如下代码,用于加参数跳转

<a th:href="${'javascript:searchProducts(&quot;catalog3Id&quot;,'+catalog.catalogId+')'}" th:text="${catalog.catalogName}"></a>

其中&quot;"双引号,该代码相当于执行javascript:searchProducts("catalog3Id",catalog.catalogId)

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

image-20220726195357623
image-20220726195357623

http://search.gulimall.com/list.html页面里,随便点击一个分类,此时的urlhttp://search.gulimall.com/list.html&catalog3Id=225,而不是http://search.gulimall.com/list.html?catalog3Id=225

GIF 2022-7-26 19-54-36
GIF 2022-7-26 19-54-36

修改gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里的 searchProducts(name,value)方法,当没有参数的时候用?拼接,而不是用&拼接

function searchProducts(name,value){
    if (location.href.toString().indexOf("?") < 0){
        location.href = location.href+"?"+name+"="+value;
    }else {
        location.href = location.href+"&"+name+"="+value;
    }
}
image-20220726200128068
image-20220726200128068

再在http://search.gulimall.com/list.html页面里,随便点击一个分类,此时的urlhttp://search.gulimall.com/list.html?catalog3Id=225,已正确拼接了条件

GIF 2022-7-26 20-01-54
GIF 2022-7-26 20-01-54
3、添加属性筛选

list.html文件的可选的属性里的classsl_valuediv里的<a>标签修改为如下代码,用于加参数跳转

<li><a href="#" th:text="${val}" th:href="${'javascript:searchProducts(&quot;attrs&quot;,&quot;'+attr.attrId+'_'+val+'&quot;)'}" >5.56英寸及以上</a></li>

其中&quot;"双引号,该代码相当于执行javascript:searchProducts("attrs","attr.attrId_val")

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

image-20220726201041691
image-20220726201041691

打开http://search.gulimall.com/list.html页面,随便点一个可选属性属性值,就来到了http://search.gulimall.com/list.html?attrs=1_A2100页面,可以看到以正确拼装了条件

GIF 2022-7-26 20-09-25
GIF 2022-7-26 20-09-25
4、修复bug
1、多属性添加

http://search.gulimall.com/list.html页面里,点击入网型号里的A13,会来到http://search.gulimall.com/list.html?attrs=1_A13页面。但点击内存容量里的8G后来到了http://search.gulimall.com/list.html?attrs=2_8G页面,变成了替换要筛选的属性,而不是继续添加属性。

GIF 2022-7-30 19-25-45
GIF 2022-7-30 19-25-45

list.html文件的可选的属性里的classsl_valuediv里的<a>标签修改为如下代码,使其调用attrAddOrReplace(paramName, replaceVal)方法,用来进行特殊处理

<li><a href="#" th:text="${val}" th:href="${'javascript:attrAddOrReplace(&quot;attrs&quot;,&quot;'+attr.attrId+'_'+val+'&quot;)'}" >5.56英寸及以上</a></li>
image-20220730200503761
image-20220730200503761

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里添加如下方法,用来属性的添加与替换

function attrAddOrReplace(paramName, replaceVal) {
    var oUrl = location.href.toString();
    let nUrl = oUrl;
    if (oUrl.endsWith("#")){
        oUrl = oUrl.substring(0,oUrl.length-1)
    }
    let dif = paramName + replaceVal.split("_")[0] +"_"
    //如果url没有该参数名就添加,有就替换;
    if (oUrl.indexOf(dif) != -1) {
        var re = eval('/(' + dif + '=)([^&]*)/gi');
        nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
    } else {
        if (oUrl.indexOf("?") != -1) {
            nUrl = oUrl + "&" + paramName + '=' + replaceVal;
        } else {
            nUrl = oUrl + "?" + paramName + '=' + replaceVal;
        }
    }
    location.href = nUrl;
}
image-20220730200644618
image-20220730200644618

http://search.gulimall.com/list.html页面里,点击入网型号里的A13,会来到http://search.gulimall.com/list.html?attrs=1_A13页面。但点击内存容量里的8G后来正确来到了http://search.gulimall.com/list.html?attrs=1_A13&attrs=2_8G页面

GIF 2022-7-30 20-03-05
GIF 2022-7-30 20-03-05
2、总是添加属性而不进行替换

当多次点击同一个可选条件的相同或不同的可选值时,同一个可选条件会不断添加,并不会替换为当前点击的``可选值`

比如在http://search.gulimall.com/list.html页面里,不断点击品牌华为的可选值,该参数会不断添加成http://search.gulimall.com/list.html?brandId=1&brandId=1&brandId=1&brandId=1&brandId=1&brandId=1这样的url

GIF 2022-7-26 20-18-46
GIF 2022-7-26 20-18-46

修改gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里的 searchProducts(name,value)方法,修改相同可选条件可选值为最新可选值

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

function searchProducts(name,value){

    var href = location.href.toString();
    var startIndex = href.indexOf(name);
    //如果url中有name参数
    if (startIndex >= 0){
        var endIndex = href.indexOf("&",startIndex);
        //如果name参数不是最后一个参数,替换掉name参数后,则还要加上后面的参数
        if (endIndex >= 0){
            location.href = href.substring(0,startIndex) + name +"=" + value + href.substring(endIndex)
        }else {
            location.href = href.substring(0,startIndex) + name +"=" + value
        }
    }else {
        //url中没有name参数,则新的url中name有可能是第一个参数
        if (location.href.toString().indexOf("?") < 0){
            location.href = location.href+"?"+name+"="+value;
        }else {
            location.href = location.href+"&"+name+"="+value;
        }
    }
}
image-20220726204624453
image-20220726204624453

http://search.gulimall.com/list.html?brandId=1&catalog3Id=225&attrs=1_LIO-A00页面里,不断点击入网型号LIO-A00的可选值,该参数的url始终为http://search.gulimall.com/list.html?brandId=1&catalog3Id=225&attrs=1_LIO-A00

GIF 2022-7-26 20-41-48
GIF 2022-7-26 20-41-48
5、添加关键字筛选

http://search.gulimall.com/list.html?catalog3Id=225&keyword=华为页面里,打开控制台,定位到搜索框,复制header_form

image-20220726201255304
image-20220726201255304

gulimall-search模块的src/main/resources/templates/list.html文件里,将classheader_formdiv里的<input>标签和<a>标签改为如下代码

<div class="header_form">
    <input id="keyword_input" type="text" placeholder="手机" />
    <a href="javascript:searchByKeyword();">搜索</a>
</div>
image-20220726204735176
image-20220726204735176

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里添加如下方法,用于关键字搜索,使用关键字搜索会清除其他筛选条件

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

function searchByKeyword(){
    var href = location.href.toString();
    var index = href.indexOf("?");
    if (index>=0){
        location.href = href.substring(0,index) + "?keyword"+"="+$("#keyword_input").val();
    }else{
        location.href = href + "?keyword"+"="+$("#keyword_input").val();
    }
}
image-20220726210238614
image-20220726210238614

打开http://search.gulimall.com/list.html?brandId=1&attrs=1_LIO-A00页面,进行关键词搜索,可以看到已经清楚了其他筛选条件,来到了http://search.gulimall.com/list.html?keyword=华为页面

GIF 2022-7-26 21-00-44
GIF 2022-7-26 21-00-44

如果不起作用,可以将classheader_formdiv里的<a>标签,使用src属性,调用js代码

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件,再进行尝试

<div class="header_form">
    <input id="keyword_input" type="text" placeholder="手机" />
    <a src="javascript:searchByKeyword();">搜索</a>
</div>
image-20220726205119808
image-20220726205119808
6、回显关键词

<input>标签里添加th:value="${param.keyword},表示在url里面获取keyword参数的值作为value属性的值。

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<div class="header_form">
    <input id="keyword_input" type="text" placeholder="手机" th:value="${param.keyword}"/>
    <a href="javascript:searchByKeyword();">搜索</a>
</div>
image-20220729135851552
image-20220729135851552

可以看到,当在搜索框里搜索商品时,可以在搜索框里正确回显搜索的数据

GIF 2022-7-29 13-52-47
GIF 2022-7-29 13-52-47

10、分页

1、遍历页数&显示总页数

http://search.gulimall.com/list.html页面里,打开控制台,定位到分页,复制filter_page

image-20220726210456252
image-20220726210456252

然后在gulimall-search模块的src/main/resources/templates/list.html文件里,搜索filter_page,可以看到分页相关代码

修改页数代码,使其从1遍历到result.totalPagesth:each="index:${#numbers.sequence(1,result.totalPages)}"),并且如果遍历到result.pageNum(当前页)时对其应用border: 0;color:#ee2222;background: #fff样式,否则不应用(th:style="${index == result.pageNum ? 'border: 0;color:#ee2222;background: #fff' : ''}")

并修改为动态的共多少页<em>共<b>[[${result.totalPages}]]</b>页&nbsp;&nbsp;到第</em>

<!--分页-->
<div class="filter_page">
    <div class="page_wrap">
        <span class="page_span1">
            <a href="#">
                < 上一页
            </a>
            <a href="#"
               th:each="index:${#numbers.sequence(1,result.totalPages)}"
               th:style="${index == result.pageNum ? 'border: 0;color:#ee2222;background: #fff' : ''}"
            >[[${index}]]</a>
            <!--<a href="#" style="border: 0;font-size: 20px;color: #999;background: #fff">...</a>-->
            <!--<a href="#">169</a>-->
            <a href="#">
                下一页 >
            </a>
        </span>
        <span class="page_span2">
            <em><b>[[${result.totalPages}]]</b>&nbsp;&nbsp;到第</em>
            <input type="number" value="1">
            <em></em>
            <a href="#">确定</a>
        </span>
    </div>
</div>
image-20220726214221219
image-20220726214221219

修改gulimall-search模块的com.atguigu.gulimall.search.constant.EsConstant类的PRODUCT_PAGE_SIZE字段,把该字段修改为6,使数据能够多几页展示

然后重启GulimallSearchApplication服务

image-20220728202822382
image-20220728202822382

kibana里执行如下命令,把苹果商品都修改为有库存

GET gulimall_product/_update_by_query
{
  "query": {
    "match": {
      "skuTitle": "苹果"
    }
  },
  "script": {
    "inline": "ctx._source['hasStock'] = 'true'"
  }
}
image-20220728201454971
image-20220728201454971

执行以下命令,查询苹果商品的数据,可以看到都已经修改为有库存了

GET gulimall_product/_search
{
  "query": {
    "match": {
      "skuTitle": "苹果"
    }
  }
}
image-20220728201625835
image-20220728201625835

访问http://search.gulimall.com/list.html?pageNum=2页面,可以看到遍历1~3没问题,当前pageNum=2页(第二页)也应用了不同的样式,共多少页也正确显示了

但是上一页下一页的样式还有问题

image-20220728203022631
image-20220728203022631
2、修改上一页下一页样式

修改list.html文件里分页相关代码,如果当前页为1就对上一页置灰,否则对上一页不置灰。th:style="${result.pageNum==1? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"

如果当前页为最后一页就对下一页置灰,否则对下一页不置灰。th:style="${result.pageNum==result.totalPages? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<!--分页-->
<div class="filter_page">
    <div class="page_wrap">
        <span class="page_span1">
            <a href="#"
               th:style="${result.pageNum==1? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"
            >
                < 上一页
            </a>
            <a href="#"
               th:each="index:${#numbers.sequence(1,result.totalPages)}"
               th:style="${index == result.pageNum ? 'border: 0;color:#ee2222;background: #fff' : ''}"
            >[[${index}]]</a>
            <!--<a href="#" style="border: 0;font-size: 20px;color: #999;background: #fff">...</a>-->
            <!--<a href="#">169</a>-->
            <a href="#"
               th:style="${result.pageNum==result.totalPages? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"
            >
                下一页 >
            </a>
        </span>
        <span class="page_span2">
            <em><b>[[${result.totalPages}]]</b>&nbsp;&nbsp;到第</em>
            <input type="number" value="1">
            <em></em>
            <a href="#">确定</a>
        </span>
    </div>
</div>
image-20220728203204858
image-20220728203204858

访问http://search.gulimall.com/list.html?pageNum=1页面,可以看到当前页是第一页时,已经对上一页置灰了

image-20220728203240624
image-20220728203240624

访问http://search.gulimall.com/list.html?pageNum=2页面,可以看到如果当前页既不是第一页,也不是最后一页,则上一页下一页都不会置灰

image-20220728203314064
image-20220728203314064

访问http://search.gulimall.com/list.html?pageNum=3页面,可以看到如果当前页是最后一页,则下一页置灰

image-20220728203337157
image-20220728203337157
3、跳转到上一页下一页指定页

list.html文件里,上一页对应的<a>标签里添加th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+(result.pageNum>1 ? result.pageNum -1 : 1)+')'}"属性,如果是当前页是第一页就跳转到第一页,如果不是第一页就跳转到上一页。并删除该标签的 href="#"属性

页码对应的<a>标签里添加th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+index+')'}"属性,跳转到该页码对应的页数。并删除该标签的 href="#"属性

下一页对应的<a>标签里添加th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+(result.pageNum<result.totalPages ? result.pageNum +1 : result.totalPages)+')'}"属性,如果是当前页是最后一页就跳转到最后一页,如果不是最后一页就跳转到下一页。并删除该标签的 href="#"属性

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<!--分页-->
<div class="filter_page">
    <div class="page_wrap">
        <span class="page_span1">
            <a
               th:style="${result.pageNum==1? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"
               th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+(result.pageNum>1 ? result.pageNum -1 : 1)+')'}"
            >
                < 上一页
            </a>
            <a
               th:each="index:${#numbers.sequence(1,result.totalPages)}"
               th:style="${index == result.pageNum ? 'border: 0;color:#ee2222;background: #fff' : ''}"
               th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+index+')'}"
            >[[${index}]]</a>
            <!--<a href="#" style="border: 0;font-size: 20px;color: #999;background: #fff">...</a>-->
            <!--<a href="#">169</a>-->
            <a
               th:style="${result.pageNum==result.totalPages? 'color: #ccc;background: #fff;' : 'color: #000;background: #F0F0F1;'}"
               th:href="${'javascript:searchProducts(&quot;pageNum&quot;,'+(result.pageNum<result.totalPages ? result.pageNum +1 : result.totalPages)+')'}"
            >
                下一页 >
            </a>
        </span>
        <span class="page_span2">
            <em><b>[[${result.totalPages}]]</b>&nbsp;&nbsp;到第</em>
            <input type="number" value="1">
            <em></em>
            <a href="#">确定</a>
        </span>
    </div>
</div>
image-20220729142051353
image-20220729142051353

经测试可以看到跳转到指定页跳转到上一页跳转到下一页功能都正常

GIF 2022-7-29 14-17-12
GIF 2022-7-29 14-17-12
4、跳转到输入页

list.html文件里,到第中间的<input>标签里添加id="pn"属性,给该标签一个id,方便获取其值

添加th:value="${param.pageNum}"属性,表示在url里面获取pageNum参数的值作为value属性的值。然后删除value="1" 属性

<span class="page_span2">
    <em><b>[[${result.totalPages}]]</b>&nbsp;&nbsp;到第</em>
    <input id="pn" type="number" th:value="${param.pageNum}">
    <em></em>
    <a th:href="${'javascript:toDirectPage()'}"
    >确定</a>
</span>
image-20220729143931531
image-20220729143931531

<script>标签里添加如下方法,用来获取要指定跳转的页的值,然后再跳转

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

function toDirectPage(){
    var pn = $("#pn").val();
    searchProducts("pageNum",pn);
}
image-20220729144431616
image-20220729144431616

经测试可以发现已经可以成功跳转到输入页

GIF 2022-7-29 14-45-58
GIF 2022-7-29 14-45-58

5.5.6、完善检索页(2)

1、按综合排序销量价格排序

1、加红显示

http://search.gulimall.com/list.html?pageNum=2页面里,打开控制台,定位到综合排序,复制综合排序

image-20220729145326656
image-20220729145326656

然后在gulimall-search模块的src/main/resources/templates/list.html文件里,搜索综合排序,可以看到综合排序相关代码

image-20220729145338358
image-20220729145338358

综合排序销量价格评论分上架时间都加上class="sort_a"属性

<!--综合排序-->
<div class="filter_top">
    <div class="filter_top_left">
        <a class="sort_a" href="#">综合排序</a>
        <a class="sort_a" href="#">销量</a>
        <a class="sort_a" href="#">价格</a>
        <a class="sort_a" href="#">评论分</a>
        <a class="sort_a" href="#">上架时间</a>
    </div>
    <div class="filter_top_right">
        <span class="fp-text">
           <b>1</b><em>/</em><i>169</i>
       </span>
        <a href="#" class="prev"><</a>
        <a href="#" class="next"> > </a>
    </div>
</div>
image-20220729145540034
image-20220729145540034

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里添加如下方法,用于对点击的<a>标签添加红色背景样式,并清除其他类含sort_a<a>标签的红色背景样式(如果有该红色背景样式的话)

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

$(".sort_a").click(function () {
    $(".sort_a").css({"color":"#333","border-color":"#ccc","background":"#fff"})
    $(this).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"})
    // 禁用默认行为(防止<a>标签进行页面跳转)
    return false;
})
image-20220729150155480
image-20220729150155480

测试后可以看到当点击综合排序销量价格评论分上架时间的其中一个后,会给该<a>标签添加红色背景样式,并清除了其他同类的红色背景样式(如果有该红色背景样式的话)

GIF 2022-7-29 15-06-59
GIF 2022-7-29 15-06-59
2、跳转页面

综合排序销量价格分别添加自定义sort="hotScore"sort="saleCount"sort="skuPrice"属性,方便获取该标签传参所用的参数名

<div class="filter_top_left">
    <a class="sort_a" sort="hotScore">综合排序</a>
    <a class="sort_a" sort="saleCount">销量</a>
    <a class="sort_a" sort="skuPrice">价格</a>
    <a class="sort_a" href="#">评论分</a>
    <a class="sort_a" href="#">上架时间</a>
</div>
image-20220729190028764
image-20220729190028764

list.html文件里,修改.sort_a绑定的点击事件,使其url添加sort字段

$(".sort_a").click(function () {
    var href = location.href.toString();
    //如果url为升序,再点击就降序
    console.log(href.indexOf("asc"))
    if(href.indexOf("asc")>=0){
        searchProducts("sort",$(this).attr("sort")+"_desc")
    }else {
        searchProducts("sort",$(this).attr("sort")+"_asc")
    }
    // 禁用默认行为(防止<a>标签进行页面跳转)
    return false;
})
image-20220729190117170
image-20220729190117170

添加页面加载时,自动触发的js代码,用于回显添加红色背景样式的<a>标签

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

window.onload = function (){
    let sortValue = getURLParamValue("sort");
    if (sortValue != null){
        changeStyle(sortValue)
    }
}

function changeStyle(sortValue){
    var split = sortValue.split("_");
    $("a[sort]").each(function () {
        if(split[0]==$(this).attr("sort")){
            //设置被点击的标签的样式为选中状态
            $(this).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"})
            let text = $(this).text();
            if (split[1] == "asc"){
                text = text +"⬆"
            }else {
                text = text + "⬇"
            }
            $(this).text(text)
        }else {
            //清除该类的选中样式
            $(this).css({"color":"#333","border-color":"#ccc","background":"#fff"})
        }
    })
}

function getURLParamValue(param){
    let str = location.href.toString()
    let index = str.indexOf(param);
    if (index >=0){
        let startIndex = index + param.length + 1
        let endIndex = str.indexOf("&",startIndex);
        //如果parm是最后一个参数
        if (endIndex <= 0){
            endIndex = str.length;
        }
        return str.substring(startIndex, endIndex);
    }
    return null;
}
image-20220729190227067
image-20220729190227067

测试后可以发现,可以正确的添加请求参数,并且回显了添加红色背景样式的<a>标签。但是在鼠标第一次悬浮到价格的时候,鼠标的样式为I,而不是小手的样式,而且没有红色边框,而且页面有很明显的抖动,对用户体验很不好,其实这些应该都是前端来做,而且这样请求很浪费服务器资源

GIF 2022-7-29 18-55-22
GIF 2022-7-29 18-55-22
3、添加鼠标经过样式

changeStylejs方法的$("a[sort]").each(function () {下面添加如下代码,用于添加鼠标经过红色边框样式和鼠标离开默认的灰色边框样式

$(this).hover方法有两个参数,第一个参数为鼠标经过样式,第二个参数为鼠标离开样式,必须写鼠标离开样式,否则鼠标离开了还是保持着鼠标经过的样式,和css样式不一样,css里写鼠标经过样式,鼠标离开后就变化的原来的样式

if (getURLParamValue($(this).attr("sort")) == null){
    $(this).hover(function (){
        $(this).css({"cursor":"pointer","border":"1px solid #e4393c","color":"#e4393c","z-index": "1"})
    },function (){
        $(this).css({"cursor":"default","border":"1px solid #ccc","color":"#333","z-index": "0"})
    })
}
image-20220729195054068
image-20220729195054068

<head>标签里添加如下样式,给sort_a类的<a>标签的鼠标经过都应用pointer的鼠标样式

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<style  type="text/css">
    a.sort_a:hover{
        cursor: pointer;
    }
</style>
image-20220729195331121
image-20220729195331121

鼠标经过综合排序销量价格中任何一个时,都能变为小手的样式,并且也加上了红色边框的样式

GIF 2022-7-29 19-53-57
GIF 2022-7-29 19-53-57

⚠️不可以直接设置如下样式(可能是之前使用JQuerychangeStyle方法里对每个sort_a类的<a>标签都应用了$(this).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"})

$(this).css({"color":"#333","border-color":"#ccc","background":"#fff"})颜色边框背景的样式吧,所以设置鼠标经过颜色边框的样式不成功,但设置鼠标样式却可以成功)

点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<style  type="text/css">
    a.sort_a:hover{
        cursor: pointer;
        border: 1px solid #e4393c;
        color: #e4393c;
        z-index: 1;
    }
</style>
image-20220729194021594
image-20220729194021594

如果这样设置就不会有鼠标经过的红色边框样式

GIF 2022-7-29 19-44-30
GIF 2022-7-29 19-44-30

📑在list.html文件里使用JQuerychangeStyle方法里对每个sort_a类的<a>标签都应用了$(this).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"})

$(this).css({"color":"#333","border-color":"#ccc","background":"#fff"})颜色边框背景的样式

function changeStyle(sortValue){
    var split = sortValue.split("_");
    $("a[sort]").each(function () {

        if(split[0]==$(this).attr("sort")){
            //设置被点击的标签的样式为选中状态
            $(this).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"})
            let text = $(this).text();
            if (split[1] == "asc"){
                text = text +"⬆"
            }else {
                text = text + "⬇"
            }
            $(this).text(text)
        }else {
            //清除该类的选中样式
            $(this).css({"color":"#333","border-color":"#ccc","background":"#fff"})
        }
    })
}
image-20220729194547892
image-20220729194547892

如果在window.onload里注释掉对changeStyle(sortValue)的方法调用,再在<head>标签里添加鼠标形状颜色边框样式

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<style  type="text/css">
    a.sort_a:hover{
        cursor: pointer;
        border: 1px solid #e4393c;
        color: #e4393c;
        z-index: 1;
    }
</style>
image-20220729194732377
image-20220729194732377

这样就能成功使用原生的css里成功应用鼠标经过时的鼠标形状颜色边框等样式(由于还要调用changeStyle(sortValue)方法,所有并不能使用这种方式,测试完后别忘了改回来哟)

GIF 2022-7-29 19-47-04
GIF 2022-7-29 19-47-04

2、按价格区间排序

1、添加价格区间

修改filter_top_left类的div的标签内容,注释掉评论分上架时间,并添加价格区间

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<div class="filter_top_left">
    <a class="sort_a" sort="hotScore">综合排序</a>
    <a class="sort_a" sort="saleCount">销量</a>
    <a class="sort_a" sort="skuPrice">价格</a>
    <!--<a class="sort_a" href="#">评论分</a>-->
    <!--<a class="sort_a" href="#">上架时间</a>-->
    <span style="margin-left: 30px;font-size: 11px">价格区间</span>
    <input style="width: 100px;"> -
    <input style="width: 100px;">
    <input type="button" value="确定">
</div>
image-20220729200604500
image-20220729200604500

可以看到评论分上架时间已经没有了,也添加了价格区间

image-20220729200625658
image-20220729200625658
2、修改错误替换参数名的bug

价格区间确定按钮添加名为skuPriceBtnid

<div class="filter_top_left">
    <a class="sort_a" sort="hotScore">综合排序</a>
    <a class="sort_a" sort="saleCount">销量</a>
    <a class="sort_a" sort="skuPrice">价格</a>
    <!--<a class="sort_a" href="#">评论分</a>-->
    <!--<a class="sort_a" href="#">上架时间</a>-->
    <span style="margin-left: 30px;font-size: 11px">价格区间</span>
    <input style="width: 100px;"> -
    <input style="width: 100px;">
    <input id="skuPriceBtn" type="button" value="确定">
</div>

并在<script>标签里给idskuPriceBtn的标签添加点击事件,获取到最小值最大值后带着skuPrice参数进行跳转

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

$("#skuPriceBtn").click(function () {
    let from = $("#skuPriceFrom").val()
    let to = $("#skuPriceTo").val()
    searchProducts("skuPrice",from+"_"+to)
})
image-20220729202313226
image-20220729202313226

http://search.gulimall.com/list.html?sort=skuPrice_asc页面里,在最大值的输入框里输入6000后,点击确定按钮

来到了http://search.gulimall.com/list.html?sort=skuPrice=_6000的错误页面,这是因为查找到了sort参数里的skuPrice_asc,误以为这里的skuPrice是参数名,就将该参数名进行了替换

GIF 2022-7-29 20-21-52
GIF 2022-7-29 20-21-52

修改list.html文件里的searchProducts(name,value)方法,如果该namelocation.href里不止出现一次,就调用 findParamIndex(href,name,0)方法,判断哪个才是正真的参数名,而不是参数值里含有该name。该name只出现一次或者调用 findParamIndex(href,name,0)方法获取到参数名的正真索引后,需要判断该索引是否存在,并且该索引是否是参数名的索引(如果该name只出现一次,有可能是参数值里含有该name)

(其实可以不判断该索引前一个元素是不是&?,而判断该索引后一个元素是不是=,这样findParamIndex(href,name,0)根本就不用写,直接写if (startIndex >= 0 && href.charAt(startIndex + 1) == '='就行了。我这里想试一下js的递归,js的递归我还每写过😊)

function searchProducts(name,value){
    let href = location.href.toString();
    if (href.endsWith("#")){
        href = href.substring(0,href.length-1)
    }
    let startIndex = href.indexOf(name);
    //如果url中有name参数
    console.log(href.indexOf(name),href.lastIndexOf(name))
    if ( startIndex != href.lastIndexOf(name)) {
        startIndex = findParamIndex(href,name,0);
    }
    if (startIndex >= 0 && (href.charAt(startIndex - 1) == '&' || href.charAt(startIndex - 1) == '?')) {
        var endIndex = href.indexOf("&", startIndex);
        //如果name参数不是最后一个参数,替换掉name参数后,则还要加上后面的参数
        if (endIndex >= 0) {
            location.href = href.substring(0, startIndex) + name + "=" + value + href.substring(endIndex)
        } else {
            location.href = href.substring(0, startIndex) + name + "=" + value
        }
    } else {
        //url中没有name参数,则新的url中name有可能是第一个参数
        if (location.href.toString().indexOf("?") < 0) {
            location.href = location.href + "?" + name + "=" + value;
        } else {
            location.href = location.href + "&" + name + "=" + value;
        }
    }

}

function findParamIndex(str,param,index) {
    if (index<0){
        return -1;
    }else {
        let startIndex = str.indexOf(param,index);
        if (startIndex!=-1 && (str.charAt(startIndex - 1) == '&' || str.charAt(startIndex - 1) == '?')){
            console.log(startIndex)
            return startIndex;
        }else {
            if (index+param.length < str.length){
                return findParamIndex(str,param,startIndex+param.length)
            }else {
                return -1;
            }
        }
    }
}
image-20220801101135056
image-20220801101135056

并在<script>标签里修改idskuPriceBtn的标签点的击事件,最小值最大值不能同时为空时,才带着skuPrice参数进行跳转

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

$("#skuPriceBtn").click(function () {
    let from = $("#skuPriceFrom").val()
    let to = $("#skuPriceTo").val()
    if (from!="" || to!=""){
        searchProducts("skuPrice",from+"_"+to)
    }else {
        alert("价格区间不能为空!")
    }
})
image-20220801105238406
image-20220801105238406

http://search.gulimall.com/list.html页面里当最小值最大值同时为空时,点击确定按钮,会弹出价格区间不能为空!的提示,当最小值最大值其中任何一个不为空时,点击确定按钮,才可以进行跳转

GIF 2022-7-29 21-19-40
GIF 2022-7-29 21-19-40

http://search.gulimall.com/list.html?sort=skuPrice_asc页面里测试也符合正确的逻辑,不会变为错误的url

GIF 2022-7-29 21-22-24
GIF 2022-7-29 21-22-24

http://search.gulimall.com/list.html?sort=skuPrice_asc&skuPrice=1_6666&pageNum=2页面里测试,skuPrice=1_6666在所有参数中间也符合正确的逻辑,不会变为错误的url

GIF 2022-7-29 21-25-48
GIF 2022-7-29 21-25-48
3、页码错误

没有商品数据时,页面遍历的页数开始为1

image-20220729212306463
image-20220729212306463

把遍历页码用到的th:each="index:${#numbers.sequence(1,result.totalPages)}"修改为如下代码,如果result.totalPages0就只遍历0

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

th:each="index: ${result.totalPages>0 ? #numbers.sequence(1,result.totalPages) : #numbers.sequence(0,0)}"
image-20220729213045932
image-20220729213045932

http://search.gulimall.com/list.html?skuPrice=_33页码里测试,可以发现只会显示第0页了,即使手动在url后面添加&pageNum=2参数也同样只能显示第0

GIF 2022-7-29 21-32-13
GIF 2022-7-29 21-32-13

3、仅显示有货

1、默认有货无货都显示

修改gulimall-search模块com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的boolSearch方法的相关设置是否有库存的逻辑,修改为默认有货无货都显示,只有指定了仅显示有货才仅显示有货的商品

然后重启GulimallSearchApplication服务

//是否有库存
//"filter": [{"term": {"hasStock": {"value": "true"}}}]
if (searchParam.getHasStock()!=null) {
    boolean hasStock =  searchParam.getHasStock() == 1;
    boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", hasStock));
}
image-20220730090539985
image-20220730090539985

刷新浏览器的如下网址的页面,然后查看GulimallSearchApplication服务的控制台

http://search.gulimall.com/list.html?pageNum=2

可以看到没有hasStock的信息

{"from":6,"size":6,"query":{"bool":{"adjust_pure_negative":true,"boost":1.0}},"aggregations":{"brand_agg":{"terms":{"field":"brandId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"brand_name_agg":{"terms":{"field":"brandName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"brand_img_agg":{"terms":{"field":"brandImg","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"catalog_agg":{"terms":{"field":"catalogId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"catalog_name_agg":{"terms":{"field":"catalogName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"attr_agg":{"nested":{"path":"attrs"},"aggregations":{"attr_id_agg":{"terms":{"field":"attrs.attrId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"attr_name_agg":{"terms":{"field":"attrs.attrName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"attr_value_agg":{"terms":{"field":"attrs.attrValue","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}}}}}}
image-20220730090715703
image-20220730090715703

在浏览器里输入如下网址,然后查看GulimallSearchApplication服务的控制台

http://search.gulimall.com/list.html?pageNum=2&hasStock=1

可以看到只有显式的指明了hasStock=1,才在查询字段里添加hasStock=true的查询条件

{"from":6,"size":6,"query":{"bool":{"filter":[{"term":{"hasStock":{"value":true,"boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"aggregations":{"brand_agg":{"terms":{"field":"brandId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"brand_name_agg":{"terms":{"field":"brandName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"brand_img_agg":{"terms":{"field":"brandImg","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"catalog_agg":{"terms":{"field":"catalogId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"catalog_name_agg":{"terms":{"field":"catalogName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}},"attr_agg":{"nested":{"path":"attrs"},"aggregations":{"attr_id_agg":{"terms":{"field":"attrs.attrId","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]},"aggregations":{"attr_name_agg":{"terms":{"field":"attrs.attrName","size":1,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}},"attr_value_agg":{"terms":{"field":"attrs.attrValue","size":10,"min_doc_count":1,"shard_min_doc_count":0,"show_term_doc_count_error":false,"order":[{"_count":"desc"},{"_key":"asc"}]}}}}}}}}
image-20220730090847529
image-20220730090847529
2、添加hasStock请求参数

http://search.gulimall.com/list.html?pageNum=2页面里,打开控制台,定位到仅显示有货,复制仅显示有货

image-20220730090258883
image-20220730090258883

然后在gulimall-search模块的src/main/resources/templates/list.html文件里,搜索仅显示有货,可以看到仅显示有货相关代码,将相关代码修改为如下样式,在仅显示有货上面删除<i>标签,添加加一个<input>标签,在其<a>父标签里添加th:with="hasSelect = ${param.hasStock}"属性,用于暂存url里的hasStock参数,并给<input>添加名为showHasStockid方便获取到该标签,并在<input>标签里添加th:checked="${#strings.equals(hasSelect,'1')}"用于回显是否选择了这个复选框

<li>
    <a href="#" th:with="hasSelect = ${param.hasStock}">
        <input id="showHasStock" type="checkbox" th:checked="${#strings.equals(hasSelect,'1')}">
        仅显示有货
    </a>
</li>
image-20220730101430137
image-20220730101430137

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里添加如下几个方法,在该复选框状态改变后,跳转到新的url

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

$("#showHasStock").change(function () {
    if($(this).prop("checked")){
        location.href = replaceOrAddParamVal(location.href,"hasStock","1")
    }else {
        location.href = removeParamVal(location.href,"hasStock")
    }

   return false;
})


function replaceOrAddParamVal(url, paramName, replaceVal) {
    var oUrl = url.toString();
    //如果url没有该参数名就添加,有就替换;
    if (oUrl.indexOf(paramName) != -1) {
        var re = eval('/(' + paramName + '=)([^&]*)/gi');
        var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
        return nUrl;
    } else {
        var nUrl = "";
        if (oUrl.indexOf("?") != -1) {
            nUrl = oUrl + "&" + paramName + '=' + replaceVal;
        } else {
            nUrl = oUrl + "?" + paramName + '=' + replaceVal;
        }
        return nUrl;
    }
}

function removeParamVal(url, paramName) {
    var oUrl = url.toString();
    //如果url有该参数名就删除参数
    let index = oUrl.indexOf(paramName);
    if (index != -1) {
        //pre为 ? 或 &
        let pre = oUrl.charAt(index-1);
        var re = eval('/(' + pre + paramName + '=)([^&]*)/gi');
        var nUrl = oUrl.replace(re, '');
        return nUrl;
    }
    return oUrl;
}
image-20220730095547605
image-20220730095547605

在以下页面里,点击仅显示有货的左边复选框

http://search.gulimall.com/list.html?pageNum=2#

来到了如下页面,由于页面很多地方设置了href="#",所有很有可能在url末尾添加上#,因此在添加新的参数之前需要删除最后面的#,然后再进行替换添加属性(其实不添加该删除代码,替换并不会搜到影响)

http://search.gulimall.com/list.html?pageNum=2#&hasStock=1
GIF 2022-7-30 9-57-25
GIF 2022-7-30 9-57-25
3、修复bug(1)

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里,在function replaceOrAddParamVal(url, paramName, replaceVal) {方法的var oUrl = url.toString();下面添加一个判断,如果url最后为#,就去掉这个#

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

if (oUrl.endsWith("#")){
    oUrl = oUrl.substring(0,oUrl.length-1)
}
image-20220730101335302
image-20220730101335302

http://search.gulimall.com/list.html?pageNum=2#页面里点击仅显示有货的左边复选框,然后就来到了http://search.gulimall.com/list.html?pageNum=2&hasStock=1页面,成功把最后的#截掉了

http://search.gulimall.com/list.html?pageNum=2&hasStock=1#,取消仅显示有货的左边复选框的选中状态,然后就来到了http://search.gulimall.com/list.html?pageNum=2页面,成功把最后的#截掉了,不过这是碰巧截掉的(并不是我刚刚添加的代码戒掉的),删除该参数时会匹配到后面所有不为&的参数,所以也把#一起截掉了

GIF 2022-7-30 10-15-46
GIF 2022-7-30 10-15-46

但是如果在http://search.gulimall.com/list.html?hasStock=1,取消仅显示有货的左边复选框的选中状态,控制台就报错了,这是因为在js正则里,?为特殊符号,需要转义

Uncaught SyntaxError: Invalid regular expression: /(?hasStock=)([^&]*)/: Invalid group
    at removeParamVal (list.html?hasStock=1:2360:50)
    at HTMLInputElement.<anonymous> (list.html?hasStock=1:2325:29)
    at HTMLInputElement.dispatch (jquery-1.12.4.js:5226:27)
    at elemData.handle (jquery-1.12.4.js:4878:28)
GIF 2022-7-30 10-19-42
GIF 2022-7-30 10-19-42
4、修复bug(2)

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里,修改removeParamVal(url, paramName)方法

对该参数名的前一个字符转义并删除,防止其是?,不过这里有问题,如果该paramName是第一个参数,但不是最后一个参数,就会把前面的?一起删了,后面就用&来拼接参数了

想要的js正则/(\?hasStock=)([^&]*)/gi,使用两个\\为对\的转义,如果只使用一个\js就会误以为是对后面'的转义,其实这里就是想要\,方便对拼接的?&转义(&不需要转义)

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

function removeParamVal(url, paramName) {
    var oUrl = url.toString();
    //如果url有该参数名就删除参数
    let index = oUrl.indexOf(paramName);
    if (index != -1) {
        //pre为 ? 或 &
        let pre = oUrl.charAt(index-1);
        var re = eval('/(' +'\\' + pre + paramName + '=)([^&]*)/gi');
        var nUrl = oUrl.replace(re, '');
        return nUrl;
    }
    return oUrl;
}
image-20220730102159120
image-20220730102159120

http://search.gulimall.com/list.html#页面里点击仅显示有货的左边复选框,然后就来到了http://search.gulimall.com/list.html?hasStock=1页面,成功把最后的#截掉了

http://search.gulimall.com/list.html?hasStock=1#页面里,取消仅显示有货的左边复选框的选中状态,就来到了http://search.gulimall.com/list.html页面,也成功把最后的#截掉了

此时的正则为/(\?hasStock=)([^&]*)/gi

GIF 2022-7-30 10-23-10
GIF 2022-7-30 10-23-10

http://search.gulimall.com/list.html?pageNum=2#页面里点击仅显示有货的左边复选框,然后就来到了http://search.gulimall.com/list.html?pageNum=2&hasStock=1页面,成功把最后的#截掉了

http://search.gulimall.com/list.html?pageNum=2&hasStock=1#,取消仅显示有货的左边复选框的选中状态,然后就来到了http://search.gulimall.com/list.html?pageNum=2页面,成功把最后的#截掉了,不过这是碰巧截掉的(并不是我刚刚添加的代码戒掉的),删除该参数时会匹配到后面所有不为&的参数,所以也把#一起截掉了

此时的正则为/(\&hasStock=)([^&]*)/gi

GIF 2022-7-30 10-24-02
GIF 2022-7-30 10-24-02

但是如果该paramName是第一个参数,但不是最后一个参数,就会把前面的?一起删了,后面就用&来拼接参数了,比如在http://search.gulimall.com/list.html?hasStock=1&sort=saleCount_asc页面里,取消仅显示有货的左边复选框的选中状态,就错误地拼接为http://search.gulimall.com/list.html&sort=saleCount_asc

GIF 2022-7-30 10-34-41
GIF 2022-7-30 10-34-41
5、修复bug(3)

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里,修改removeParamVal(url, paramName)方法,判断该参数名的前一个字符是是什么,如果是?并且该参数后面没有别的参数后,在删除时还需要对?进行转义

function removeParamVal(url, paramName) {
    var oUrl = url.toString();
    //如果url有该参数名就删除参数
    let index = oUrl.indexOf(paramName);
    if (index != -1) {
        //tag为 ? 或 &
        let tag = oUrl.charAt(index-1);
        let re;
        if (tag=='&'){
            re = eval('/(' + tag + paramName + '=)([^&]*)/gi');
        }else {
            // http://search.gulimall.com/list.html?hasStock=1&sort=saleCount_asc
            // http://search.gulimall.com/list.html?sort=saleCount_asc
            if (oUrl.indexOf("&",tag+paramName.length)!=-1){
                re = eval('/(' + paramName + '=)([^&]*&)/gi');
            }else {
                re = eval('/(' +"\?" + paramName + '=)([^&]*)/gi');
            }
        }

        console.log(re)
        var nUrl = oUrl.replace(re, '');
        return nUrl;
    }
    return oUrl;
}
image-20220730104953971
image-20220730104953971

http://search.gulimall.com/list.html?attrs=1_A2100&hasStock=1&sort=saleCount_asc页面里,取消仅显示有货的左边复选框的选中状态,就正确拼接为http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc了,

再次点击仅显示有货的左边复选框,也能正确拼接为http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc&hasStock=1

GIF 2022-7-30 10-47-02
GIF 2022-7-30 10-47-02

但是如果在http://search.gulimall.com/list.html?hasStock=1页面里,取消仅显示有货的左边复选框的选中状态,控制台又报错了

VM5495:1 Uncaught SyntaxError: Invalid regular expression: /(?hasStock=)([^&]*)/: Invalid group
    at removeParamVal (list.html?hasStock=1:2369:54)
    at HTMLInputElement.<anonymous> (list.html?hasStock=1:2325:29)
    at HTMLInputElement.dispatch (jquery-1.12.4.js:5226:27)
    at elemData.handle (jquery-1.12.4.js:4878:28)
GIF 2022-7-30 10-53-17
GIF 2022-7-30 10-53-17
6、修复bug(4)

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里,修改removeParamVal(url, paramName)方法,把 re = eval('/(' +"\?" + paramName + '=)([^&]*)/gi');,修改为

re = eval('/(' +"\\?" + paramName + '=)([^&]*)/gi');

此时的正则为/(\\?hasStock=)([^&]*)/gi,比上次的正则多了一个\,上次的正则为``/(?hasStock=)([^&]*)/gi`

我也不知道为什么要将\再次转义,而上次不用

image-20220730105422250
image-20220730105422250

http://search.gulimall.com/list.html?attrs=1_A2100&hasStock=1&sort=saleCount_asc#页面里,取消仅显示有货的左边复选框的选中状态,成功来到了http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc#页面

GIF 2022-7-30 10-56-20
GIF 2022-7-30 10-56-20
📝修改searchProducts方法

gulimall-search模块的src/main/resources/templates/list.html文件里的<script>标签里,修改searchProducts(name,value)方法,在et href = location.href.toString();后面也加一条判断,如果最后的字符为#,也去掉该字符

function searchProducts(name,value){
    let href = location.href.toString();
    if (href.endsWith("#")){
        href = href.substring(0,href.length-1)
    }
    let startIndex = href.indexOf(name);
    //如果url中有name参数
    console.log(href.indexOf(name),href.lastIndexOf(name))
    if ( startIndex != href.lastIndexOf(name)) {
        startIndex = findParamIndex(href,name,0);
    }
    if (startIndex >= 0 && (href.charAt(startIndex - 1) == '&' || href.charAt(startIndex - 1) == '?')) {
        var endIndex = href.indexOf("&", startIndex);
        //如果name参数不是最后一个参数,替换掉name参数后,则还要加上后面的参数
        if (endIndex >= 0) {
            location.href = href.substring(0, startIndex) + name + "=" + value + href.substring(endIndex)
        } else {
            location.href = href.substring(0, startIndex) + name + "=" + value
        }
    } else {
        //url中没有name参数,则新的url中name有可能是第一个参数
        if (location.href.toString().indexOf("?") < 0) {
            location.href = location.href + "?" + name + "=" + value;
        } else {
            location.href = location.href + "&" + name + "=" + value;
        }
    }

}
image-20220730094502450
image-20220730094502450

4、属性添加面包屑导航

1、添加远程调用

复制gulimall-product模块pom.xml文件的<spring-cloud.version>,然后粘贴到gulimall-search模块pom.xml文件的相应位置

<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
image-20220730155823834
image-20220730155823834

gulimall-search模块pom.xml文件里的<dependencyManagement>里指定spring-cloud的依赖的版本,该配置不会添加依赖,只会用于控制版本

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
image-20220730155939119
image-20220730155939119

gulimall-search模块pom.xml文件里的<dependencies>里添加如下依赖,用于远程调用

<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
image-20220730160200795
image-20220730160200795

gulimall-search模块的com.atguigu.gulimall.search.GulimallSearchApplication启动类上添加如下注解,用来开启远程调用功能

@EnableFeignClients
image-20220730160253460
image-20220730160253460

gulimall-product模块的com.atguigu.gulimall.product.controller.AttrController类里,修改info方法的@RequestMapping("/info/{attrId}")注解,将其改为@GetMapping("/info/{attrId}"),只接受get方法的调用

然后复制该方法的头部信息(@GetMapping("/info/{attrId}") public R info(@PathVariable("attrId") Long attrId)

image-20220730160417143
image-20220730160417143

gulimall-search模块的com.atguigu.gulimall.search包下新建feign文件夹,在feign文件夹里添加ProductFeignService类,粘贴刚刚复制的头部信息,修改方法名,并将映射地址添加完整

package com.atguigu.gulimall.search.feign;

import com.atguigu.common.utils.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * @author 无名氏
 * @date 2022/7/30
 * @Description:
 */
@FeignClient("gulimall-product")
public interface ProductFeignService {

    @GetMapping("/product/attr/info/{attrId}")
    public R attrInfo(@PathVariable("attrId") Long attrId);
}
image-20220730160711351
image-20220730160711351
2、进行远程调用

gulimall-common模块的com.atguigu.common.to包下新建AttrRespTo类,用于gulimall-search模块获取从gulimall-product传来的数据

由于AttrRespVo继承了AttrVo,关系比较复杂,因此不适合直接移动位置,因此可以写个完整的AttrRespTo用于数据传输

package com.atguigu.common.to;

import lombok.Data;

/**
 * @author 无名氏
 * @date 2022/7/30
 * @Description:
 */
@Data
public class AttrRespTo {

    /**
     * 属性id
     */
    private Long attrId;
    /**
     * 属性名
     */
    private String attrName;
    /**
     * 是否需要检索[0-不需要,1-需要]
     */
    private Integer searchType;
    /**
     * 属性图标
     */
    private String icon;
    /**
     * 可选值列表[用逗号分隔]
     */
    private String valueSelect;
    /**
     * 属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
     */
    private Integer attrType;
    /**
     * 启用状态[0 - 禁用,1 - 启用]
     */
    private Long enable;
    /**
     * 所属分类
     */
    private Long catelogId;
    /**
     * 快速展示【是否展示在介绍上;0-否 1-是】,在sku中仍然可以调整
     */
    private Integer showDesc;

    /**
     * 值类型【0-只能单个值,1-允许多个值】
     */
    private Integer valueType;

    private Long attrGroupId;

    /**
     * 所属分类名   /手机/数码/手机
     */

    private String catelogName;
    /**
     * 所属分组名  主机
     */
    private String groupName;

    /**
     * 所属分类的完整路径
     */
    private Long[] catelogPath;
}
image-20220730164710466
image-20220730164710466

gulimall-search模块的com.atguigu.gulimall.search.vo.SearchResult类里添加如下代码,用于面包屑导航(其实面包屑导航更适合前端做)

/**
 * 面包屑导航
 */
private List<NavVo> navs = new ArrayList<>();

@Data
public static class NavVo{
    /**
     * 导航名
     */
    private String navName;
    /**
     * 导航值
     */
    private String navValue;
    /**
     * 导航链接
     */
    private String link;
}
image-20220730155710887
image-20220730155710887

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里添加如下代码,用于构造面包屑

@Autowired
ProductFeignService productFeignService;

List<SearchResult.NavVo> navs = null;
if (!CollectionUtils.isEmpty(searchParam.getAttrs())) {
    navs = searchParam.getAttrs().stream().map(attr -> {
        SearchResult.NavVo navVo = new SearchResult.NavVo();
        //attrs=1_安卓:其他
        //s[0]=1   s[1]=安卓:其他
        String[] s = attr.split("_");
        // "1_安卓:其他" ==> [1,安卓:其他]  length=2
        // "_安卓:其他"  ==>  [,_安卓:其他]  length=2
        if (s.length==2 && !attr.startsWith("_")){
            String attrId = s[0];
            //如果远程服务调用失败,就用id作为属性值
            String name = attrId;
            try {
                R r = productFeignService.attrInfo(Long.parseLong(attrId));
                if (r.getCode()==0){
                    Object attrVo = r.get("attr");
                    String attrString = JSON.toJSONString(attrVo);
                    AttrRespTo attrRespTo = JSON.parseObject(attrString, AttrRespTo.class);
                    name = attrRespTo.getAttrName();
                }
            }catch (Exception e){
                e.printStackTrace();
            }
            navVo.setNavName(name);
            //设置属性值
            navVo.setNavValue(s[1]);

            //取消这个导航栏需要跳转到的url
        }
        return navVo;
    }).collect(Collectors.toList());
}
searchResult.setNavs(navs);
image-20220730165358765
image-20220730165358765
3、取消这个导航栏要跳转到的url

gulimall-search模块的com.atguigu.gulimall.search.vo.SearchParam类里添加字段,调用原生的方法,获取所有参数所组成的字符串(比如访问http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_ascqueryString就保存attrs=1_A2100&sort=saleCount_asc

/**
 * 调用原生的方法,获取所有参数的字符串
 */
private String queryString;
image-20220730165546344
image-20220730165546344

gulimall-search模块的com.atguigu.gulimall.search.controller.SearchController类里修改listPage方法,用于调用原生的HttpServletRequest类的getQueryString方法,获取所有参数的字符串并赋值给SearchParam类的queryString字段

/**
 * 自动将页面提交过来的所有请求查询参数封装成指定的对象
 * @return
 */
@RequestMapping("/list.html")
public String listPage(SearchParam searchParam, Model model, HttpServletRequest httpServletRequest){
    searchParam.setQueryString(httpServletRequest.getQueryString());
    SearchResult result = mallSearchService.search(searchParam);
    model.addAttribute("result",result);
    return "list";
}
image-20220730165854263
image-20220730165854263

gulimall-search模块的com.atguigu.gulimall.search.constant.EsConstant常量类里添加searchURI字段,用于保存检索页的域名(其实放在该类并不合适,不过我懒得写了)

/**
 * 检索页的url
 */
public static final String searchURI = "http://search.gulimall.com/list.html";
image-20220730170318248
image-20220730170318248

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里修改 navs = searchParam.getAttrs().stream().map里的代码,用于设置取消这个导航栏需要跳转到的url

List<SearchResult.NavVo> navs = null;
if (!CollectionUtils.isEmpty(searchParam.getAttrs())) {
    navs = searchParam.getAttrs().stream().map(attr -> {
        SearchResult.NavVo navVo = new SearchResult.NavVo();
        //attrs=1_安卓:其他
        //s[0]=1   s[1]=安卓:其他
        String[] s = attr.split("_");
        // "1_安卓:其他" ==> [1,安卓:其他]  length=2
        // "_安卓:其他"  ==>  [,_安卓:其他]  length=2
        if (s.length==2 && !attr.startsWith("_")){
            String attrId = s[0];
            //如果远程服务调用失败,就用id作为属性值
            String name = attrId;
            try {
                R r = productFeignService.attrInfo(Long.parseLong(attrId));
                if (r.getCode()==0){
                    Object attrVo = r.get("attr");
                    String attrString = JSON.toJSONString(attrVo);
                    AttrRespTo attrRespTo = JSON.parseObject(attrString, AttrRespTo.class);
                    name = attrRespTo.getAttrName();
                }
            }catch (Exception e){
                e.printStackTrace();
            }
            navVo.setNavName(name);
            //设置属性值
            navVo.setNavValue(s[1]);

            //取消这个导航栏需要跳转到的url
            String queryString = searchParam.getQueryString();
            String replace = "";
            String attrString = "attrs="+attr;
            int attrIndex = queryString.indexOf(attrString);
            if (queryString.startsWith(attrString)) {
                //判断该参数后面还有没有参数
                if (queryString.indexOf("&",attrIndex+1) >=0) {
                    //该属性是第一个参数,且不是最后一个参数
                    //http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc
                    replace = queryString.replace("attrs=" + attr +"&", "");
                }else {
                    //该参数是第一个参数,也是最后一个参数
                    //http://search.gulimall.com/list.html?attrs=1_A2100
                    replace = queryString.replace("attrs=" + attr, "");
                }
            }else {
                //该属性不是第一个参数
                //http://search.gulimall.com/list.html?hasStock=1&attrs=1_A2100
                replace = queryString.replace("&attrs=" + attr, "");
            }
            if (StringUtils.hasText(replace)){
                navVo.setLink(EsConstant.searchURI + "?" + replace);
            }else {
                navVo.setLink(EsConstant.searchURI);
            }
            
        }
        return navVo;
    }).collect(Collectors.toList());
}
searchResult.setNavs(navs);
image-20220730185710988
image-20220730185710988

由于gulimall-product模块的com.atguigu.gulimall.product.service.impl.AttrServiceImpl类的getAttrInfo方法经常使用,因此可以把结果放入到缓存中,供下次使用

productFeignService.attrInfo(Long.parseLong(attrId));方法会调用gulimall-product模块的com.atguigu.gulimall.product.controller.AttrController#info方法,而info方法又会调用com.atguigu.gulimall.product.service.impl.AttrServiceImpl#getAttrInfo方法,由于是远程调用可能非常耗时,因此可以加入缓存以提高查询效率

@Cacheable(value = "attr",key = "'attrInfo:'+ #root.args[0]")
image-20220730203558184
image-20220730203558184
4、测试

重启GulimallProductApplication服务和GulimallSearchApplication服务,在gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里的return navVo;这里打上断点

在浏览器中输入如下网址

http://search.gulimall.com/list.html?hasStock=1&attrs=1_A2100

此时SearchParam类的queryString字段为hasStock=1&attrs=1_A2100

NavVo类的navValueA2100时,该类的link字段的值为

http://search.gulimall.com/list.html?hasStock=1

取消该属性确实是跳转到该link所在的页面

然后一直点击Pause Program按钮,放行完该请求,方便下次测试

image-20220730181259014
image-20220730181259014

在浏览器中输入如下网址

http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc

此时SearchParam类的queryString字段为attrs=1_A2100&sort=saleCount_asc

NavVo类的navValueA2100时,该类的link字段的值为

http://search.gulimall.com/list.html?sort=saleCount_asc

取消该属性确实是跳转到该link所在的页面

然后一直点击Pause Program按钮,放行完该请求,方便下次测试

image-20220730181453448
image-20220730181453448

在浏览器中输入如下网址

http://search.gulimall.com/list.html?attrs=1_A2100

此时SearchParam类的queryString字段为attrs=1_A2100

NavVo类的navValueA2100时,该类的link字段的值为

http://search.gulimall.com/list.html

取消该属性确实是跳转到该link所在的页面

然后一直点击Pause Program按钮,放行完该请求,方便下次测试

image-20220730181628394
image-20220730181628394

但是在请求的参数里有中文或空格就会出现问题

先在kibana里修改skuId10的数据,使其出现空格和中文

POST gulimall_product/_doc/10
{
  "brandImg": "https://gulimall-anonymous.oss-cn-beijing.aliyuncs.com/2022-05-10/94d6c446-3d06-4e6e-8ddf-8da8f346f391_apple.png",
  "hasStock": "true",
  "skuTitle": "Apple IPhoneXS 苹果XS手机 银色 256GB",
  "brandName": "Apple",
  "hotScore": 0,
  "saleCount": 0,
  "skuPrice": "5000",
  "attrs": [
    {
      "attrId": 1,
      "attrValue": "A13 plus加强版",
      "attrName": "入网型号"
    },
    {
      "attrId": 2,
      "attrValue": "8G",
      "attrName": "内存容量"
    }
  ],
  "catalogName": "手机",
  "catalogId": 225,
  "brandId": 4,
  "spuId": 2,
  "skuId": 10
}
image-20220730182241428
image-20220730182241428

在浏览器中输入如下网址

http://search.gulimall.com/list.html?attrs=1_A13 plus加强版

此时SearchParam类的queryString字段为attrs=1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88

attr的值为1_A13 plus加强版,这样肯定匹配不到该字符串,肯定无法删除该字符串

image-20220730182552437
image-20220730182552437
5、修改代码后再次测试

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里的 navs = searchParam.getAttrs().stream().map里的 String queryString = searchParam.getQueryString();下面添加如下代码,用于将attr属性修改为url对应的格式

然后重启GulimallSearchApplication服务

try {
    attr = URLEncoder.encode(attr, "UTF-8");
} catch (UnsupportedEncodingException e) {
    e.printStackTrace();
}

刷新http://search.gulimall.com/list.html?attrs=1_A13 plus加强版页面,可以看到queryString字段的值为

attrs=1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88

attr字段的值为

1_A13+plus%E5%8A%A0%E5%BC%BA%E7%89%88

jdk的把 空格,转为了+,而浏览器则把空格转换为了%20

image-20220730183333093
image-20220730183333093

gulimall-search模块的com.atguigu.gulimall.search.GulimallSearchApplicationTests测试类里添加testCoder测试方法

@Test
public void testCoder() throws Exception{
   //jdk自带编码 java.net.URLDecoder; java.net.URLEncoder;
   System.out.println("jdk自带编码:");
   System.out.println("错误的编码: " + URLEncoder.encode("1_A13 plus加强版","UTF-8"));
   System.out.println("正确的编码: " + URLEncoder.encode("1_A13 plus加强版","UTF-8").replace("+","%20"));
   System.out.println(URLDecoder.decode("attrs=1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88","UTF-8"));
   //Spring提供的编码 org.springframework.web.util.UriUtils;
   System.out.println();
   System.out.println("Spring提供编码:");
   System.out.println(UriUtils.encode("1_A13 plus加强版", "UTF-8"));
   System.out.println(UriUtils.decode("attrs=1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88","UTF-8"));
}

可以看到jdk会把空格加码为+,而解码可以成功把%20解码为空格,使用Spring提供的UriUtils则不会出现这个问题

jdk自带编码:
错误的编码: 1_A13+plus%E5%8A%A0%E5%BC%BA%E7%89%88
正确的编码: 1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88
attrs=1_A13 plus加强版

Spring提供编码:
1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88
attrs=1_A13 plus加强版
image-20220730184823672
image-20220730184823672

try {
    attr = URLEncoder.encode(attr, "UTF-8");
} catch (UnsupportedEncodingException e) {
    e.printStackTrace();
}

修改为如下代码,使用Spring提供的加码方法

attr = UriUtils.encode(attr, "UTF-8");

然后重启GulimallSearchApplication服务,刷新http://search.gulimall.com/list.html?attrs=1_A13 plus加强版页面

此时SearchParam类的queryString字段为attrs=1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88

attr字段的值为1_A13%20plus%E5%8A%A0%E5%BC%BA%E7%89%88,与queryString字段的attrs参数值一致

NavVo类的navValueA13 plus加强版时,该类的link字段的值为

http://search.gulimall.com/list.html
image-20220730185201829
image-20220730185201829
6、检索页添加属性面包屑导航

http://search.gulimall.com/list.html页面里,打开控制台,定位到手机,复制JD_ipone_one c

image-20220730213102247
image-20220730213102247

然后在gulimall-search模块的src/main/resources/templates/list.html文件里,搜索JD_ipone_one c,可以看到手机面包屑相关代码,复制classJD_ipone_one cdiv,并粘贴到其下面,并修改部分代码,遍历result.navs里的navNamenavValue

然后点击Build -> Recompile "index.html' 或按快捷键Ctrl+ Shift+F9,重新编译当前静态文件

<!--面包屑导航-->
<div class="JD_ipone_one c">
    <a th:each="nav : ${result.navs}" th:href="${nav.link}">
        <span th:text="${nav.navName}"></span><span th:text="${nav.navValue}"></span> ×
    </a>
</div>
<i><img src="/static/search/image/[email protected]" alt=""></i>
image-20220730215338331
image-20220730215338331

http://search.gulimall.com/list.html页面里,依次点击A13 plus加强版8G,可以看到面包屑的链接地址正确,并且点击×后可以来到正确的页面

GIF 2022-7-30 21-52-00
GIF 2022-7-30 21-52-00

5、品牌添加面包屑导航

1、远程查询品牌列表

gulimall-product模块的com.atguigu.gulimall.product.controller.BrandController类里,复制info方法,粘贴到其下面,修改部分代码,使其在BrandServiceImpl类里根据brandIds查询Brands(品牌列表)

复制该方法的头信息(@GetMapping("/infos") public R info(@RequestParam("brandIds") List<Long> brandIds))

/**
 * 根据品牌id批量查询
 */
@GetMapping("/infos")
public R info(@RequestParam("brandIds") List<Long> brandIds) {

    List<BrandEntity> brand = brandService.getBrandsByIds(brandIds);

    return R.ok().put("brand", brand);
}
image-20220730201920706
image-20220730201920706

gulimall-product模块的com.atguigu.gulimall.product.service.BrandService接口里添加getBrandsByIds抽象方法

List<BrandEntity> getBrandsByIds(List<Long> brandIds);
image-20220730201954416
image-20220730201954416

gulimall-product模块的com.atguigu.gulimall.product.service.impl.BrandServiceImpl类里实现getBrandsByIds抽象方法,用于根据brandIds查询Brands(品牌列表)

@Override
public List<BrandEntity> getBrandsByIds(List<Long> brandIds) {
    LambdaQueryWrapper<BrandEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.in(BrandEntity::getBrandId, brandIds);
    return this.baseMapper.selectList(lambdaQueryWrapper);
}
image-20220730202231316
image-20220730202231316

gulimall-search模块的com.atguigu.gulimall.search.feign.ProductFeignService接口里粘贴刚刚复制的头信息,并修改方法名为branInfo

@GetMapping("/product/brand/infos")
public R branInfo(@RequestParam("brandIds") List<Long> brandIds);
image-20220730202357233
image-20220730202357233

选中gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法的里用来实现面包屑取消功能删除该参数的这部分代码,按ctrl + alt + M快捷键,将其抽取为一个方法,并修改部分代码

private String replaceQueryString(String queryString,String key,String value) {
    String param = key + "=" + value;
    String replace;
    int attrIndex = queryString.indexOf(param);
    if (queryString.startsWith(param)) {
        //判断该参数后面还有没有参数
        if (queryString.indexOf("&",attrIndex+1) >=0) {
            //该属性是第一个参数,且不是最后一个参数
            //http://search.gulimall.com/list.html?attrs=1_A2100&sort=saleCount_asc
            replace = queryString.replace(key +"=" + value +"&", "");
        }else {
            //该参数是第一个参数,也是最后一个参数
            //http://search.gulimall.com/list.html?attrs=1_A2100
            replace = queryString.replace(key+"=" + value, "");
        }
    }else {
        //该属性不是第一个参数
        //http://search.gulimall.com/list.html?hasStock=1&attrs=1_A2100
        replace = queryString.replace("&"+key+"=" + value, "");
    }
    return replace;
}
image-20220730211452452
image-20220730211452452

此时,在gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法的里用来实现面包屑取消功能删除该参数的这部分代码可以直接使用以下代码

String queryString = searchParam.getQueryString();
String value = UriUtils.encode(attr,"UTF-8");
String replace = replaceQueryString(queryString,"attrs", value);
image-20220730215002607
image-20220730215002607
2、设置品牌是否在检索页显示

gulimall-search模块的src/main/resources/templates/list.html文件里修改品牌相关的代码,在class="JD_nav_logo"<div>里添加th:with="brandId = ${param.brandId}"属性,用于暂存url里的brandId,并在class="JD_nav_wrap"<div>里添加th:if="${#strings.isEmpty(brandId)}"属性,当brandId为空时才显示

image-20220731094816752
image-20220731094816752
<div class="JD_nav_logo" th:with="brandId = ${param.brandId}">
    <!--品牌-->
    <div class="JD_nav_wrap" th:if="${#strings.isEmpty(brandId)}">
        <div class="sl_key">
            <span><b>品牌:</b></span>
        </div>
        <div class="sl_value">
            <div class="sl_value_logo">
                <ul>
                    <li th:each="brand:${result.brands}">
                        <a href="#" th:href="${'javascript:searchProducts(&quot;brandId&quot;,'+brand.brandId+')'}">
                            <img th:src="${brand.brandImg}" alt="">
                            <div th:text="${brand.brandName}">
                                华为(HUAWEI)
                            </div>
                        </a>
                    </li>
                </ul>
            </div>
        </div>
        <div class="sl_ext">
            <a href="#">
                更多
                <i style='background: url("/static/search/image/search.ele.png")no-repeat 3px 7px'></i>
                <b style='background: url("/static/search/image/search.ele.png")no-repeat 3px -44px'></b>
            </a>
            <a href="#">
                多选
                <i>+</i>
                <span>+</span>
            </a>
        </div>
    </div>
    <!--分类-->
    <div class="JD_pre">
        <div class="sl_key">
            <span><b>分类:</b></span>
        </div>
        <div class="sl_value">
            <ul >
                <li th:each="catalog:${result.catalogs}">
                    <a th:href="${'javascript:searchProducts(&quot;catalog3Id&quot;,'+catalog.catalogId+')'}" th:text="${catalog.catalogName}"></a>
                </li>
            </ul>
        </div>
    </div>
    <!--其他的所有需要展示的属性-->
    <div class="JD_pre" th:each="attr:${result.attrs}">
        <div class="sl_key">
            <span th:text="${attr.attrName}">屏幕尺寸:</span>
        </div>
        <div class="sl_value">
            <ul th:each="val:${attr.attrValue}">
                <li><a href="#" th:text="${val}" th:href="${'javascript:attrAddOrReplace(&quot;attrs&quot;,&quot;'+attr.attrId+'_'+val+'&quot;)'}" >5.56英寸及以上</a></li>
            </ul>
        </div>
    </div>
</div>
3、测试

重启GulimallProductApplication服务和GulimallSearchApplication服务,访问如下页面,可以看到页面已经报错了

http://search.gulimall.com/list.html?brandId=1
image-20220731094733723
image-20220731094733723

切换到GulimallSearchApplication服务的控制台,可以看到MallSearchServiceImpl类的440行报空指针

2022-07-31 09:46:19.277 ERROR 3232 --- [io-12000-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause

java.lang.NullPointerException: null
	at com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl.buildSearchResponse(MallSearchServiceImpl.java:440) ~[classes/:na]
	at com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl.search(MallSearchServiceImpl.java:70) ~[classes/:na]
	at com.atguigu.gulimall.search.controller.SearchController.listPage(SearchController.java:31) ~[classes/:na]
image-20220731094938202
image-20220731094938202

navs的定义那里设置List<SearchResult.NavVo> navs = null;,有可能searchParam.getAttrs())为空,这样navs就不能赋值,此时在navs里添加就报空指针异常了,可以让navs = new ArrayList<>();,这样即使searchParam.getAttrs())为空了页不会空指针了

List<SearchResult.NavVo> navs = new ArrayList<>();
image-20220731095017619
image-20220731095017619

重启GulimallProductApplication服务和GulimallSearchApplication服务,访问http://search.gulimall.com/list.html页面,点击A13 plus加强版,由于华为入网型号里没有A13 plus加强版属性值,所以华为品牌不显示。再次点击8G,可以看到取消A13 plus加强版8G后请求的url都正确

GIF 2022-7-30 21-52-00
GIF 2022-7-30 21-52-00

6、不显示已经选择过的属性

gulimall-search模块的com.atguigu.gulimall.search.vo.SearchResult类里添加attrIds字段,用于判断路径是否包含该attr,如果包含该attr,在面包屑上就已经显示了,不需要在筛选里面显示了

/**
 * 路径是否包含该attr,如果包含该attr,在面包屑上就已经显示了,不需要在筛选里面显示了
 */
private List<Long> attrIds = new ArrayList<>();
image-20220731101339203
image-20220731101339203

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的buildSearchResponse方法里的String attrId = s[0];下面添加如下代码,用于表面该字段已经在面包屑中显示了

searchResult.getAttrIds().add(Long.parseLong(attrId));
image-20220731101304593
image-20220731101304593

他的所有需要展示的属性里的class="JD_pre"<div>里添加th:if="${!#lists.contains(result.attrIds,attr.attrId)}"属性,表示如果result.attrIds里面存在此次遍历的attr.attrId,就不显示该attr.attrName

<!--其他的所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}" th:if="${!#lists.contains(result.attrIds,attr.attrId)}">
    <div class="sl_key">
        <span th:text="${attr.attrName}">屏幕尺寸:</span>
    </div>
    <div class="sl_value">
        <ul th:each="val:${attr.attrValue}">
            <li><a href="#" th:text="${val}" th:href="${'javascript:attrAddOrReplace(&quot;attrs&quot;,&quot;'+attr.attrId+'_'+val+'&quot;)'}" >5.56英寸及以上</a></li>
        </ul>
    </div>
</div>
image-20220731101651964
image-20220731101651964

重启GulimallSearchApplication服务,可以看到在点击某个品牌属性后,该品牌属性会在面包屑中显示,而不会在筛选里面显示了。当取消面包屑里的品牌属性后,会再次在筛选里面显示

GIF 2022-7-31 10-17-36
GIF 2022-7-31 10-17-36

gulimall-search模块的com.atguigu.gulimall.search.service.impl.MallSearchServiceImpl类的完整代码

点击查看MallSearchServiceImpl类的完整代码

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.0.0-alpha.8