文章目录
1. 搭建搜索微服务
在门户系统中,用户一般会直接搜索自己想要的商品,所以我们需要一个搜索微服务。
面对复杂的搜索业务和数据量,使用传统数据库搜索就会显得力不从心,我们需要搜索的效率更高的全文检索技术——Elasticsearch。
1.1 创建工程
-
右键 leyou 项目 --> New Module --> Maven --> Next
-
填写项目信息 --> Next
-
填写保存的位置 --> Finish
-
添加依赖
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou</artifactId> <groupId>com.leyou.parent</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>com.leyou.search</groupId> <artifactId>leyou-search</artifactId> <version>1.0-SNAPSHOT</version> <dependencies> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- elasticsearch --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <!--eureka client--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <!--feign--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!--leyou-common--> <dependency> <groupId>com.leyou.common</groupId> <artifactId>leyou-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <!--leyou-item-interface--> <dependency> <groupId>com.leyou.item</groupId> <artifactId>leyou-item-interface</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <!--test--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> </dependencies> </project>
-
编写配置文件 application.yaml
server: port: 8083 spring: application: name: search-service data: elasticsearch: cluster-name: elasticsearch cluster-nodes: 192.168.222.132:9300 eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka instance: lease-renewal-interval-in-seconds: 5 lease-expiration-duration-in-seconds: 10
-
编写启动类
@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class LeyouSearchApplication { public static void main(String[] args) { SpringApplication.run(LeyouSearchApplication.class,args); } }
1.2 分析索引库数据格式
接下来我们需要将 MySQL 的商品数据导入 Elasticsearch 的索引库,以便用户搜索。
但索引库的数据格式应该是什么样的呢?我们先来看一下用户搜索后的结果。
另外,页面还有过滤条件
从上面搜索的结果可以分析出,我们需要的数据格式有:
- spuId
- skuId
- 商品分类 id
- 品牌 id
- 图片
- 价格
- 商品的创建时间
- SKU 信息集
- 可搜索的规格参数
1.3 创建实体类
根据上面分析的数据格式,创建实体类 Goods,我们可以根据这个实体类来创建索引库和映射。
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {
@Id
private Long id; // spuId
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String all; // 所有需要被搜索的信息,包含标题,分类,品牌
@Field(type = FieldType.Keyword, index = false)
private String subTitle;// 卖点
private Long brandId;// 品牌id
private Long cid1;// 1级分类id
private Long cid2;// 2级分类id
private Long cid3;// 3级分类id
private Date createTime;// 创建时间
private List<Long> price;// 价格
@Field(type = FieldType.Keyword, index = false)
private String skus;// List<sku>信息的json结构
private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值
}
特殊属性解释:
- all:用来进行全文检索的字段,里面包含标题、商品分类信息,
- price:价格数组,是所有 SKU 的价格集合。
- skus:用于页面展示的 SKU 信息,不索引,不搜索。包含 skuId、image、price、title 字段
1.4 调用商品微服务接口
索引库中的数据来自于数据库,我们不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以我们只能调用商品微服务提供的接口服务。
先思考我们需要的数据:
- SPU 信息
- SKU 信息
- SPU 详情
- 商品分类名称(拼接 all 字段)
- 品牌名称
- 规格参数
再思考我们需要哪些接口:
- 第一:分页查询 SPU 的接口,已经写过。
- 第二:根据 spuId 查询 SKU 的接口,已经写过。
- 第三:根据 spuId 查询 SpuDetail 的接口,已经写过。
- 第四:根据商品分类 id,查询商品分类名称的接口,没写过。
- 第五:根据商品品牌 id,查询商品品牌的接口,没写过。
- 第六:根据商品分类 id,查询可搜索的规格参数接口,已经写过。
1.4.1 提供查询商品分类名称接口
在 CategoryController 中添加 queryNamesByIds 方法
/**
* 根据商品分类 id,查询商品分类名称
* @param ids
* @return
*/
@GetMapping("/names")
public ResponseEntity<List<String>> queryNamesByIds(@RequestParam("ids") List<Long> ids) {
List<String> names = categoryService.queryNamesByIds(ids);
if(CollectionUtils.isEmpty(names)) {
return ResponseEntity.notFound().build(); // 响应 404
}
return ResponseEntity.ok(names);
}
1.4.2 提供查询商品品牌名称接口
-
在 BrandController 中添加 queryBrandById 方法
/** * 根据品牌 id 查询品牌 * @param id * @return */ @GetMapping("{id}") public ResponseEntity<Brand> queryBrandById(@PathVariable("id") Long id) { Brand brand = brandService.queryBrandById(id); if (brand == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(brand); }
-
在 BrandService 中添加 queryBrandById 方法
/** * 根据品牌 id 查询品牌 * @param id * @return */ public Brand queryBrandById(Long id) { Brand brand = brandMapper.selectByPrimaryKey(id); return brand; }
1.4.3 调用商品微服务接口
我们接下来需要在搜索微服务中调用商品微服务的接口,我们使用 Feign 来远程调用服务。
-
在 leyou-item-interface 中,提供对外的 API 接口
@RequestMapping("/spu") public interface SpuApi { /** * 根据查询条件分页查询商品信息 * @param key 搜索条件 * @param saleable 上下架 * @param page 当前页 * @param rows 每页大小 * @return */ @GetMapping("/page") public PageResult<SpuBo> querySpuByPage( @RequestParam(name = "key", required = false) String key, @RequestParam(name = "saleable", required = false) Boolean saleable, @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "rows", defaultValue = "5") Integer rows ); /** * 通过 spuId 查询 SpuDetail * @param spuId * @return */ @GetMapping("/detail/{spuId}") public SpuDetail querySpuDetailBySpuId(@PathVariable("spuId") Long spuId); /** * 通过 spuId 查询 Sku 集合 * @param spuId * @return */ @GetMapping("/sku/list") public List<Sku> querySkusBySpuId(@RequestParam("id") Long spuId); }
@RequestMapping("/category") public interface CategoryApi { /** * 根据商品分类 id,查询商品分类名称 * @param ids * @return */ @GetMapping("/names") public List<String> queryNamesByIds(@RequestParam("ids") List<Long> ids); }
@RequestMapping("/brand") public interface BrandApi { /** * 根据品牌 id 查询品牌 * @param id * @return */ @GetMapping("{id}") public Brand queryBrandById(@PathVariable("id") Long id); }
@RequestMapping("/spec") public interface SpecificationApi { /** * 根据条件查询规格参数 * * @param gid * @return */ @GetMapping("/params") public List<SpecParam> querySpecParams( @RequestParam(value = "gid", required = false) Long gid, @RequestParam(value = "cid", required = false) Long cid, @RequestParam(value = "generic", required = false) Boolean generic, @RequestParam(value = "searching", required = false) Boolean searching ); }
-
在服务调用方 leyou-search 中编写 FeignClient,直接继承 leyou-item-interface 提供的对外接口
@FeignClient(value = "item-service") public interface BrandClient extends BrandApi { }
@FeignClient(value = "item-service") public interface SpuClient extends SpuApi { }
@FeignClient(value = "item-service") public interface CategoryClient extends CategoryApi { }
@FeignClient(value = "item-service") public interface SpecificationClient extends SpecificationApi { }
1.4.4 测试
-
写一个 CategoryClient 的测试类
@RunWith(SpringRunner.class) @SpringBootTest(classes = LeyouSearchApplication.class) public class CategoryClientTest { @Autowired private CategoryClient categoryClient; @Test public void testQueryNamesByIds() { List<String> names = categoryClient.queryNamesByIds(Arrays.asList(1L, 2L, 3L)); for (String name : names) { System.out.println(name); } } }
-
运行结果
图书、音像、电子书刊 电子书刊 电子书
1.5 创建索引库和映射
-
创建 GoodsRepository
public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> { }
-
创建一个测试类,在里面进行数据的操作
@RunWith(SpringRunner.class) @SpringBootTest(classes = LeyouSearchApplication.class) public class ElasticsearchTest { @Autowired private GoodsRepository goodsRepository; @Autowired private ElasticsearchTemplate template; @Test public void createIndex(){ // 创建索引库,以及映射 this.template.createIndex(Goods.class); this.template.putMapping(Goods.class); } }
-
打开 kibana 查看映射,创建成功
1.6 导入商品数据
导入数据其实就是把查询到的 Spu 转变为 Goods 来保存。
-
编写一个 SearchService,创建 buildGoods 方法,把 Spu 转变为 Goods
@Service public class SearchService { @Autowired private BrandClient brandClient; @Autowired private CategoryClient categoryClient; @Autowired private SpuClient spuClient; @Autowired private SpecificationClient specificationClient; // Jackson 工具 private static final ObjectMapper MAPPER = new ObjectMapper(); /** * 把 Spu 转变为 Goods * * @param spu * @return * @throws IOException */ public Goods buildGoods(Spu spu) throws IOException { // 创建 Goods 对象 Goods goods = new Goods(); // 根据品牌 id 查询品牌 Brand brand = this.brandClient.queryBrandById(spu.getBrandId()); // 根据商品分类 id,查询商品分类名称 List<String> names = this.categoryClient.queryNamesByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3())); // 查询 spu 下的所有 sku List<Sku> skus = this.spuClient.querySkusBySpuId(spu.getId()); // 创建一个价格集合,用于存放所有 sku 价格 List<Long> prices = new ArrayList<>(); // 收集 sku 必要信息的集合 List<Map<String, Object>> skuMapList = new ArrayList<>(); // 遍历 skus skus.forEach(sku -> { prices.add(sku.getPrice()); Map<String, Object> skuMap = new HashMap<>(); skuMap.put("id", sku.getId()); skuMap.put("title", sku.getTitle()); skuMap.put("price", sku.getPrice()); // 没有图片时,返回空字符串;有图片时,返回第一张图片地址 skuMap.put("image", StringUtils.isNotBlank(sku.getImages()) ? StringUtils.split(sku.getImages(), ",")[0] : ""); skuMapList.add(skuMap); }); // 查询出所有的可搜索规格参数 List<SpecParam> params = this.specificationClient.querySpecParams(null, spu.getCid3(), null, true); // 通过 spuId 查询 SpuDetail SpuDetail spuDetail = this.spuClient.querySpuDetailBySpuId(spu.getId()); // 获取通用的规格参数 Map<Long, Object> genericSpecMap = MAPPER.readValue(spuDetail.getGenericSpec(), new TypeReference<Map<Long, Object>>() { }); // 获取特殊的规格参数 Map<Long, List<Object>> specialSpecMap = MAPPER.readValue(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<Object>>>() { }); // 定义 Map 接收 {规格参数名,规格参数值} Map<String, Object> paramMap = new HashMap<>(); params.forEach(param -> { // 判断是否是通用规格参数 if (param.getGeneric()) { // 获取通用规格参数值 String value = genericSpecMap.get(param.getId()).toString(); // 判断是否是数值类型 if (param.getNumeric()) { // 如果是数值的话,返回该数值落在的区间 value = chooseSegment(value, param); } // 把参数名和值放入结果集中 paramMap.put(param.getName(), value); } else { // 把参数名和值放入结果集中 paramMap.put(param.getName(), specialSpecMap.get(param.getId())); } }); // 设置参数 goods.setId(spu.getId()); goods.setCid1(spu.getCid1()); goods.setCid2(spu.getCid2()); goods.setCid3(spu.getCid3()); goods.setBrandId(spu.getBrandId()); goods.setCreateTime(spu.getCreateTime()); goods.setSubTitle(spu.getSubTitle()); // 拼接 all 字段,包括标题、品牌名称、分类名称 goods.setAll(spu.getTitle() + " " + brand.getName() + " " + StringUtils.join(names, " ")); // 获取 spu 下所有 sku 的价格 goods.setPrice(prices); // 获取 spu 下所有 sku,并转化成 json 字符串 goods.setSkus(MAPPER.writeValueAsString(skuMapList)); // 获取所有可搜索的规格参数 goods.setSpecs(paramMap); return goods; } /** * 返回该数值落在的区间 * @param value * @param p * @return */ private String chooseSegment(String value, SpecParam p) { double val = NumberUtils.toDouble(value); String result = "其它"; // 保存数值段 for (String segment : p.getSegments().split(",")) { String[] segs = segment.split("-"); // 获取数值范围 double begin = NumberUtils.toDouble(segs[0]); double end = Double.MAX_VALUE; if (segs.length == 2) { end = NumberUtils.toDouble(segs[1]); } // 判断是否在范围内 if (val >= begin && val < end) { if (segs.length == 1) { result = segs[0] + p.getUnit() + "以上"; } else if (begin == 0) { result = segs[1] + p.getUnit() + "以下"; } else { result = segment + p.getUnit(); } break; } } return result; } }
-
编写一个测试方法,先分页查询 Spu,然后将 Spu 转化成 Goods,最后导入 Goods 数据。循环以上过程,直到分页为最后一页。
@Test public void saveGoods() { Integer page = 1; Integer rows = 100; do { // 分页查询 Spu PageResult<SpuBo> pageResult = this.spuClient.querySpuByPage(null, null, page, rows); // 遍历 Spu 集合转化为 List<Goods> List<Goods> goodsList = pageResult.getItems().stream().map(spuBo -> { try { return this.searchService.buildGoods((Spu) spuBo); } catch (IOException e) { e.printStackTrace(); } return null; }).collect(Collectors.toList()); // 导入数据 this.goodsRepository.saveAll(goodsList); // 获取当前页的数据条数 rows = pageResult.getItems().size(); // 每次循环页码加一 page++; } while (rows == 100); }
-
打开 kibana 查询, 导入数据成功,共 183 条
2. 实现基本搜索
2.1 分析页面
-
在首页的顶部有一个搜索框,我们输入内容,点击搜索
-
就会跳转到搜索页 search.html,并且将搜索关键字以请求参数携带过来
2.2 发送异步请求
我们希望在 search.html 页面加载后,就展示出搜索结果。
具体做法就是在页面加载时,获取地址栏请求参数,并发起异步请求,查询后台数据,然后在页面渲染。
-
打开 leyou-portal 工程中的 search.html,找到提前定义好的 Vue 实例
<script type="text/javascript"> var vm = new Vue({ el: "#searchApp", data: { }, components:{ // 加载页面顶部组件 lyTop: () => import("./js/pages/top.js") } }); </script>
-
我们在 data 中定义一个对象,记录请求的参数
data: { search:{ key:"", // 搜索页面的关键字 } }
-
我们通过 created 函数,在页面加载时获取请求参数,并按请求条件搜索
created(){ // 判断是否有请求参数 if(!location.search){ return; } // 将请求参数转为对象 const search = ly.parse(location.search.substring(1)); // 记录在data的search对象中 this.search = search; // 发起请求,根据条件搜索 this.loadData(); }
-
发起异步请求,搜索数据
methods: { loadData(){ ly.http.post("/search/page", this.search).then(resp=>{ console.log(resp); }); } }
2.3 后台提供搜索接口
2.3.1 添加网关映射
在 leyou-gateway 工程的 Application.yaml 中添加网关映射
zuul:
prefix: /api
routes:
item-service: /item/**
search-service: /search/**
2.3.2 添加允许跨域
在 leyou-gateway 中的 CORS 配置类中,添加允许信任域名
@Configuration
public class LeyouCorsConfigration {
@Bean
public CorsFilter corsFilter() {
// 初始化 cors 配置对象
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); //是否发送 Cookie 信息
config.addAllowedOrigin("http://manage.leyou.com"); //允许的域
config.addAllowedOrigin("http://www.leyou.com"); //允许的域
config.addAllowedMethod("*"); //允许的请求方式
config.addAllowedHeader("*"); //允许的头信息
//初始化 cors 配置源对象
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config); //添加映射路径,拦截一切请求
return new CorsFilter(configSource); //返回 CorsFilter
}
}
2.3.3 实体类
在 leyou-search 工程 pojo 包中创建实体类 SearchRequest,用来接受搜索条件
public class SearchRequest {
private String key;// 搜索条件
private Integer page;// 当前页
private String sortBy; // 排序字段
private Boolean descending; // 是否降序
private static final Integer DEFAULT_SIZE = 20;// 每页大小,不从页面接收,而是固定大小
private static final Integer DEFAULT_PAGE = 1;// 默认页
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public Integer getPage() {
if(page == null){
return DEFAULT_PAGE;
}
// 获取页码时做一些校验,不能小于1
return Math.max(DEFAULT_PAGE, page);
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getSize() {
return DEFAULT_SIZE;
}
public String getSortBy() {
return sortBy;
}
public void setSortBy(String sortBy) {
this.sortBy = sortBy;
}
public Boolean getDescending() {
return descending;
}
public void setDescending(Boolean descending) {
this.descending = descending;
}
}
2.3.4 Controller
分析前端页面发送的请求,可以得知:
- 请求方式:POST
- 请求路径:/search/page
- 请求参数:JSON 格式,目前只有一个属性:key 搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有 page 属性。
- 返回结果:作为分页结果,我们可以使用之前定义的 PageResult 类
在 leyou-search 工程 controller 包中创建 SearchController
@RestController
@RequestMapping
public class SearchController {
@Autowired
private SearchService searchService;
/**
* 搜索商品
*
* @param request
* @return
*/
@PostMapping("/page")
public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request) {
PageResult<Goods> result = this.searchService.search(request);
if (result == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
2.3.5 Service
在 SearchService 中添加方法 search
/**
* 根据搜索条件搜索数据
*
* @param request
* @return
*/
public PageResult<Goods> search(SearchRequest request) {
// 获取搜索条件
String key = request.getKey();
// 判断是否有搜索条件,如果没有,直接返回 null。不允许搜索全部商品
if (StringUtils.isBlank(key)) {
return null;
}
// 构建查询条件
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 对 key 进行匹配查询
queryBuilder.withQuery(QueryBuilders.matchQuery("all", key).operator(Operator.AND));
// 通过 sourceFilter 设置返回的结果字段,我们只需要 id、skus、subTitle
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "skus", "subTitle"}, null));
// 分页
int page = request.getPage();
int size = request.getSize();
queryBuilder.withPageable(PageRequest.of(page - 1, size));
// 加入请求中的排序条件
String sortBy = request.getSortBy();
Boolean descending = request.getDescending();
if (StringUtils.isNotBlank(sortBy)) {
queryBuilder.withSort(SortBuilders.fieldSort(sortBy).order(descending ? SortOrder.DESC : SortOrder.ASC));
}
// 执行查询
Page<Goods> goodsPage = this.goodsRepository.search(queryBuilder.build());
// 封装结果并返回
return new PageResult<Goods>(goodsPage.getTotalElements(), goodsPage.getContent(), goodsPage.getTotalPages());
}
2.3.6 测试
-
重启 leyou-search 工程
-
刷新页面,请求成功
-
打开控制台,查看数据。数据是得到了,但由于我们查询结果只保留了三个字段,导致有很多字段都是 null。
-
解决办法很简单,在 leyou-search 的 application.yaml 中添加一行配置, json 处理时忽略空值。
spring: jackson: default-property-inclusion: non_null # 配置json处理时忽略空值
2.4 页面渲染
略,交给前端吧
2.5 测试
再次搜索手机,可以看到页面已经展示出搜索结果了,但除了我们之前自己新增的商品,其他商品图片都无法显示。这是因为我们之前忘了将这些商品的图片导入 FastDFS,接下就导入图片信息。
2.6 导入图片信息
-
在 leyou 下创建 static 目录下,并将 image.zip 上传到该目录下
-
使用命令解压缩
unzip images.zip
-
删除压缩包
rm -f images.zip
-
修改 nginx 配置,使 nginx 反向代理这些图片地址
vim opt/nginx/conf/nginx.conf
-
修改成如下配置
server { listen 80; server_name image.leyou.com; location ~/group([0-9])/ { ngx_fastdfs_module; } location / { root /leyou/static; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } }
-
重新加载 nginx 配置
nginx -s reload
-
重启 leyou-search 工程
2.7 再次测试
再次搜索手机,成功显示图片