乐忧商城

  • 10.商品管理
    • 10.1 商品新增
    • 10.2 商品修改
    • 10.3 搭建前台系统
  • 11.elasticsearch
    • 11.1 elasticsearch介绍及其安装
    • 11.2 操作索引
    • 11.3 查询
    • 11.4 聚合
    • 11.5 Spring Data Elasticsearch
  • 12.基本搜索
    • 12.1 索引库数据导入
    • 12.2 实现基本搜索
    • 12.3 页面分页效果
    • 12.4 排序

10.商品管理

10.1 商品新增

  • 基本信息:主要是一些简单的文本数据,包含了SPU和SpuDetail的部分数据,如

    • 商品分类:是SPU中的cid1,cid2,cid3属性
    • 品牌:是spu中的brandId属性
    • 标题:是spu中的title属性
    • 子标题:是spu中的subTitle属性
    • 售后服务:是SpuDetail中的afterService属性
    • 包装列表:是SpuDetail中的packingList属性
  • 商品描述:是SpuDetail中的description属性,数据较多,所以单独放一个页面
  • 规格参数:商品规格信息,对应SpuDetail中的genericSpec属性
  • SKU属性:spu下的所有Sku信息

1.商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成。
2.品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:

后台提供一下根据分类id查询品牌的接口即可,比较简单。

商品描述
商品描述信息比较复杂,而且图文并茂,甚至包括视频。这样的内容,一般都会使用富文本编辑器。

通俗来说:富文本编辑器,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue,但是本项目使用的是一款支持Vue的富文本编辑器:vue-quill-editor。
如何使用呢?还是分三步走:
1.安装

npm install vue-quill-editor --save

2.加载,分为全局加载和局部加载
全局加载:

import Vue from 'vue'
import VueQuillEditor from 'vue-quill-editor'const options = {}; /* { default global options } */Vue.use(VueQuillEditor, options); // options可选

局部加载:

import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'import {quillEditor} from 'vue-quill-editor'var vm = new Vue({components:{quillEditor}
})

3.页面使用

<quill-editor v-model="goods.spuDetail.description" :options="editorOption"/>

不过这个组件有个小问题,就是图片上传无法直接上传到后台,因此我们需要对其进行封装,以支持图片的上传。使用也很简单:

<v-stepper-content step="2"><v-editor v-model="goods.spuDetail.description" upload-url="/upload/image"/>
</v-stepper-content>
  • upload-url:是图片上传的路径
  • v-model:双向绑定,将富文本编辑器的内容绑定到goods.spuDetail.description

规格参数
规格参数的查询我们之前也已经编写过接口,因为商品规格参数也是与商品分类绑定,所以需要在商品分类变化后去查询,我们也是通过watch监控来实现:

"goods.categories": {deep: true,handler(val) {// 判断商品分类是否存在,存在才查询if (val && val.length > 0) {// 根据分类查询品牌this.$http.get("/item/brand/cid/" + this.goods.categories[2].id).then(({ data }) => {this.brandOptions = data;});// 根据分类查询规格参数this.$http.get("/item/spec/params?cid=" + this.goods.categories[2].id).then(({ data }) => {let specs = [];let template = [];if (this.isEdit){specs = JSON.parse(this.goods.spuDetail.genericSpec);template = JSON.parse(this.goods.spuDetail.specialSpec);}// 对特有规格进行筛选const arr1 = [];const arr2 = [];data.forEach(({id, name,generic, numeric, unit }) => {if(generic){const o = { id, name, numeric, unit};if(this.isEdit){o.v = specs[id];}arr1.push(o)}else{const o = {id, name, options:[]};if(this.isEdit){o.options = template[id];}arr2.push(o)}});this.specs = arr1;// 通用规格this.specialSpecs = arr2;// 特有规格});}}}

sku属性


在前端添加点击提交的事件:

methods: {submit() {// 表单校验。if(!this.$refs.basic.validate){this.$message.error("请先完成表单内容!");}// 先处理goods,用结构表达式接收,除了categories外,都接收到goodsParams中const {categories: [{ id: cid1 }, { id: cid2 }, { id: cid3 }],...goodsParams} = this.goods;// 处理规格参数const specs = {};this.specs.forEach(({ id,v }) => {specs[id] = v;});// 处理特有规格参数模板const specTemplate = {};this.specialSpecs.forEach(({ id, options }) => {specTemplate[id] = options;});// 处理skuconst skus = this.skus.filter(s => s.enable).map(({ price, stock, enable, images, indexes, ...rest }) => {// 标题,在spu的title基础上,拼接特有规格属性值const title = goodsParams.title + " " + Object.values(rest).map(v => v.v).join(" ");const obj = {};Object.values(rest).forEach(v => {obj[v.id] = v.v;});return {price: this.$format(price), // 价格需要格式化stock,indexes,enable,title, // 基本属性images: images ? images.join(",") : '', // 图片ownSpec: JSON.stringify(obj) // 特有规格参数};});Object.assign(goodsParams, {cid1,cid2,cid3, // 商品分类skus // sku列表});goodsParams.spuDetail.genericSpec = JSON.stringify(specs);goodsParams.spuDetail.specialSpec = JSON.stringify(specTemplate);this.$http({method: this.isEdit ? "put" : "post",url: "/item/goods",data: goodsParams}).then(() => {// 成功,关闭窗口this.$emit("close");// 提示成功this.$message.success("保存成功了");}).catch(() => {this.$message.error("保存失败!");});}},

经过一系列处理,最后数据转化为后台可以接受的格式。
整体是一个json格式数据,包含Spu表所有数据:

  • brandId:品牌id
  • cid1、cid2、cid3:商品分类id
  • subTitle:副标题
  • title:标题
  • spuDetail:是一个json对象,代表商品详情表数据
    • afterService:售后服务
    • description:商品描述
    • packingList:包装列表
    • specialSpec:sku规格属性模板
    • genericSpec:通用规格参数
  • skus:spu下的所有sku数组,元素是每个sku对象:
    • title:标题
    • images:图片
    • price:价格
    • stock:库存
    • ownSpec:特有规格参数
    • indexes:特有规格参数的下标

10.2 商品修改

前台页面已经对新增还是修改商品作了判断,修改商品首先需要将数据回显:

watch: {oldGoods: {deep: true,handler(val) {if (!this.isEdit) {Object.assign(this.goods, {categories: null, // 商品分类信息brandId: 0, // 品牌id信息title: "", // 标题subTitle: "", // 子标题spuDetail: {packingList: "", // 包装列表afterService: "", // 售后服务description: "" // 商品描述}});this.specs = [];this.specialSpecs = [];} else {this.goods = Object.deepCopy(val);// 先得到分类名称const names = val.cname.split("/");// 组织商品分类数据this.goods.categories = [{ id: val.cid1, name: names[0] },{ id: val.cid2, name: names[1] },{ id: val.cid3, name: names[2] }];// 将skus处理成mapconst skuMap = new Map();this.goods.skus.forEach(s => {skuMap.set(s.indexes, s);});this.goods.skus = skuMap;}}},

这里只有一点需要注意:spu数据可以修改,但是sku数据无法修改,因为有可能之前存在的sku现在已经不存在了,或者以前的sku属性都不存在了。比如以前内存有4G,现在没了。因此这里直接删除以前的sku,然后新增即可

10.3 搭建前台系统

至此,后台的主要功能已经实现完毕,现在开始转向前台。
门户系统面向的是用户,安全性很重要,而且搜索引擎对于单页应用并不友好。因此我们的门户系统不再采用与后台系统类似的SPA(单页应用)。依然是前后端分离,不过前端的页面会使用独立的html,在每个页面中使用vue来做页面渲染。
静态资源
webpack打包多页应用配置比较繁琐,项目结构也相对复杂。这里为了简化开发(毕竟我们不是专业的前端人员),我们不再使用webpack,而是直接编写原生的静态HTML。
live-server
没有webpack,我们就无法使用webpack-dev-server运行这个项目,实现热部署。
所以,这里我们使用另外一种热部署方式:live-server,这是一款带有热加载功能的小型开发服务器。用它来展示你的HTML / JavaScript / CSS,但不能用于部署最终的网站。

live-server --port=9002

域名访问
如果想通过域名来访问,则需要修改nginx配置文件和hosts文件
common.js
为了方便后续的开发,我们在前台系统中定义了一些工具,放在了common.js中:


// 字符串格式化
String.prototype.format = function () {const args = arguments;if (args.length <= 0) {return this;}return this.replace(/\{(\d+)\}/g, (m, i) => args[i]);
};String.format = function () {if (arguments.length === 0)return null;if (arguments.length === 1) {return arguments[0];}let str = arguments[0];return str.format(arguments.slice(1));
};
const parse = function (str, opts) {var options = opts ? utils.assign({}, opts) : {};if (options.decoder !== null && options.decoder !== undefined && typeof options.decoder !== 'function') {throw new TypeError('Decoder has to be a function.');}options.ignoreQueryPrefix = options.ignoreQueryPrefix === true;options.delimiter = typeof options.delimiter === 'string' || utils.isRegExp(options.delimiter) ? options.delimiter : defaults.delimiter;options.depth = typeof options.depth === 'number' ? options.depth : defaults.depth;options.arrayLimit = typeof options.arrayLimit === 'number' ? options.arrayLimit : defaults.arrayLimit;options.parseArrays = options.parseArrays !== false;options.decoder = typeof options.decoder === 'function' ? options.decoder : defaults.decoder;options.allowDots = typeof options.allowDots === 'boolean' ? options.allowDots : defaults.allowDots;options.plainObjects = typeof options.plainObjects === 'boolean' ? options.plainObjects : defaults.plainObjects;options.allowPrototypes = typeof options.allowPrototypes === 'boolean' ? options.allowPrototypes : defaults.allowPrototypes;options.parameterLimit = typeof options.parameterLimit === 'number' ? options.parameterLimit : defaults.parameterLimit;options.strictNullHandling = typeof options.strictNullHandling === 'boolean' ? options.strictNullHandling : defaults.strictNullHandling;if (str === '' || str === null || typeof str === 'undefined') {return options.plainObjects ? Object.create(null) : {};}var tempObj = typeof str === 'string' ? parseValues(str, options) : str;var obj = options.plainObjects ? Object.create(null) : {};// Iterate over the keys and setup the new objectvar keys = Object.keys(tempObj);for (var i = 0; i < keys.length; ++i) {var key = keys[i];var newObj = parseKeys(key, tempObj[key], options);obj = utils.merge(obj, newObj, options);}return utils.compact(obj);
};
const stringify = function(object, options) {let option =  {prefix : "",generateArrayPrefix : utils.generateArrayPrefix,strictNullHandling: null,skipNulls: null,encoder : utils.encode,filter: null,sort: null,allowDots : true,serializeDate: null,formatter : utils.formatter,encodeValuesOnly: true}Object.assign(option, options);let {prefix, generateArrayPrefix, strictNullHandling, skipNulls, encoder, filter,sort, allowDots, serializeDate, formatter, encodeValuesOnly} = option;var obj = object;if (typeof filter === 'function') {obj = filter(prefix, obj);} else if (obj instanceof Date) {obj = serializeDate(obj);} else if (obj === null) {obj = '';}var values = [];if (!obj) {return values;}if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean' || utils.isBuffer(obj)) {if (encoder) {var keyValue = encodeValuesOnly ? prefix : encoder(prefix, utils.encoder);if(allowDots){keyValue = keyValue.substring(1);}else{const arr =keyValue.match(/\[\w+\]/g);keyValue = arr[0].substring(1,arr[0].length-1) + keyValue.substring(arr[0].length);}return [keyValue + '=' + formatter(encoder(obj, utils.encoder))];}return [formatter(prefix) + '=' + formatter(String(obj))];}var objKeys;if (Array.isArray(filter)) {objKeys = filter;} else {var keys = Object.keys(obj);objKeys = sort ? keys.sort(sort) : keys;}for (var i = 0; i < objKeys.length; ++i) {var key = objKeys[i];if (skipNulls && obj[key] === null) {continue;}if (Array.isArray(obj)) {values = values.concat(this.stringify(obj[key],{prefix:generateArrayPrefix(prefix, key),generateArrayPrefix,strictNullHandling,skipNulls,encoder,filter,sort,allowDots,serializeDate,formatter,encodeValuesOnly}));} else {values = values.concat(this.stringify(obj[key],{prefix:prefix + (allowDots ? '.' + key : '[' + key + ']'),generateArrayPrefix,strictNullHandling,skipNulls,encoder,filter,sort,allowDots,serializeDate,formatter,encodeValuesOnly}));}}return values.join("&");
}axios.defaults.baseURL = "http://www.api.leyou.com/api";
axios.defaults.timeout = 5000;
axios.defaults.withCredentials = true// 配置对象
const ly = leyou = {/*** 对encodeURI()编码过的 URI 进行解码。并且获取其中的指定参数* @param name* @returns {*}*/getUrlParam(name) {var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");var r = window.location.search.substr(1).match(reg);if (r != null) {return decodeURI(r[2]);}return "";},/*** 发起ajax请求工具,底层依然是axios*/http: axios,store: {set(key, value) {localStorage.setItem(key, JSON.stringify(value));},get(key) {return JSON.parse(localStorage.getItem(key));},del(key) {return localStorage.removeItem(key);}},/*** 将整数价格变为小数* @param val* @returns {*}*/formatPrice(val) {if(typeof val === 'string'){if(isNaN(val)){return null;}// 价格转为整数const index = val.lastIndexOf(".");let p = "";if(index < 0){// 无小数p = val + "00";}else if(index === p.length - 2){// 1位小数p = val.replace("\.","") + "0";}else{// 2位小数p = val.replace("\.","")}return parseInt(p);}else if(typeof val === 'number'){if(val == null){return null;}const s = val + '';if(s.length === 0){return "0.00";}if(s.length === 1){return "0.0" + val;}if(s.length === 2){return "0." + val;}const i = s.indexOf(".");if(i < 0){return s.substring(0, s.length - 2) + "." + s.substring(s.length-2)}const num = s.substring(0,i) + s.substring(i+1);if(i === 1){// 1位整数return "0.0" + num;}if(i === 2){return "0." + num;}if( i > 2){return num.substring(0,i-2) + "." + num.substring(i-2)}}},/*** 将日期格式化为指定格式* @param val* @param pattern* @returns {null}*/formatDate(val, pattern) {if (!val) {return null;}if (!pattern) {pattern = "yyyy-MM-dd hh:mm:ss"}return new Date(val).format(pattern);},/*** 将js对象格式化为字符串参数对* @param object* @returns {*}*/stringify,/*** 将请求参数字符串格式化为js对象*/parse,/*** 发送验证请求,看用户是否已经登录*/verify(){//这里一定要写return//这里一定要写return//这里一定要写return//这里一定要写return//这里一定要写return//这里一定要写returnreturn ly.http.get("/auth/verify");}
}

首先对axios进行了一些全局配置,请求超时时间,请求的基础路径,是否允许跨域操作cookie等

定义了对象 ly ,也叫leyou,包含了下面的属性:

  • getUrlParam(key):获取url路径中的参数
  • http:axios对象的别名。以后发起ajax请求,可以用ly.http.get()
  • store:localstorage便捷操作,后面用到再详细说明
  • formatPrice:格式化价格,如果传入的是字符串,则扩大100被并转为数字,如果传入是数字,则缩小100倍并转为字符串
  • formatDate(val, pattern):对日期对象val按照指定的pattern模板进行格式化
  • stringify:将对象转为参数字符串
  • parse:将参数字符串变为js对象

11.elasticsearch

11.1 elasticsearch介绍及其安装

用户访问我们的首页,一般都会直接搜索来寻找自己想要购买的商品。而商品的数量非常多,而且分类繁杂。如何能正确的显示出用户想要的商品,并进行合理的过滤,尽快促成交易,是搜索系统要研究的核心。面对这样复杂的搜索业务和数据量,使用传统数据库搜索就显得力不从心,一般我们都会使用全文检索技术,本项目使用Elasticsearch。
Elasticsearch具备以下优点:

  • 分布式,无需人工搭建集群(solr就需要人为配置,使用Zookeeper作为注册中心)
  • Restful风格,一切API都遵循Rest原则,容易上手
  • 近实时搜索,数据更新在Elasticsearch中几乎是完全同步的。

安装及繁琐的配置细节就不啰嗦了,因为是学习使用,所以我把它安装到虚拟机上(192.168.124.121),最终启动后它会默认绑定两个端口:

  • 9300:集群节点间通讯接口
  • 9200:客户端访问接口

安装kibana
Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示,非常有利于我们学习Elasticsearch的语法。我的理解就是可以充当elasticsearch的一个很方便的交互和图形化工具。
安装ik分词器
安装这个的目的是使得elasticsearch支持中文检索。

11.2 操作索引

Elasticsearch是基于Lucene的全文检索库,本质也是存储数据,很多概念与MySQL类似。

概念 说明
索引库(indices) indices是index的复数,代表许多的索引
类型(type) 类型是模拟mysql中的table概念,一个索引库下可以有不同类型的索引,比如商品索引,订单索引,其数据格式不同。不过这会导致索引库混乱,从6.7版本就已经移除了这个概念
文档(document) 存入索引库原始的数据。比如每一条商品信息,就是一个文档
字段(field) 文档中的属性
映射配置(mappings) 字段的数据类型、属性、是否索引、是否存储等特性

在Elasticsearch中几个比较重要的概念:

  • 索引集(Indices,index的复数):逻辑上的完整索引
  • 分片(shard):数据拆分后的各个部分
  • 副本(replica):每个分片的复制
    要注意的是:Elasticsearch本身就是分布式的,因此即便你只有一个节点,Elasticsearch默认也会对你的数据进行分片和副本操作,当你向集群添加新数据时,数据也会在新加入的节点中进行平衡。
    创建索引

Elasticsearch采用Rest风格API,因此其API就是一次http请求,你可以用任何工具发起http请求
创建索引的请求格式:

  • 请求方式:PUT
  • 请求路径:/索引库名
  • 请求参数:json格式:
{"settings": {"number_of_shards": 3,"number_of_replicas": 2}
}

settings:索引库的设置

  • number_of_shards:分片数量
  • number_of_replicas:副本数量
    查看索引
GET /索引库名 (使用kibana)

删除索引

DELETE /索引库名 (使用kibana)

注意,也可以使用HEAD请求,来查看索引是否存在

映射配置
索引有了,接下来肯定是添加数据。但是,在添加数据之前必须定义映射。

什么是映射?
映射是定义文档的过程,文档包含哪些字段,这些字段是否保存,是否索引,是否分词等

我个人理解,创建索引相当于创建数据库,创建映射相当于创建表中的各个字段

创建映射字段

PUT /索引库名/_mapping/类型名称
{"properties": {"字段名": {"type": "类型","index": true,"store": true,"analyzer": "分词器"}}
}
  • 类型名称:就是前面将的type的概念,类似于数据库中的不同表
    字段名:任意填写 ,可以指定许多属性,例如:
  • type:类型,可以是text、long、short、date、integer、object等
  • index:是否索引,默认为true
  • store:是否存储,默认为false
  • analyzer:分词器,这里的ik_max_word即使用ik分词器
PUT heima/_mapping/goods
{"properties": {"title": {"type": "text","analyzer": "ik_max_word"},"images": {"type": "keyword","index": "false"},"price": {"type": "float"}}
}

查看映射关系

GET /索引库名/_mapping

字段属性详解
1.type
Elasticsearch中支持的数据类型非常丰富:

  • String类型,又分两种:

    • text:可分词,不可参与聚合
    • keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
  • Numerical:数值类型,分两类
    • 基本数据类型:long、interger、short、byte、double、float、half_float
    • 浮点数的高精度类型:scaled_float
      • 需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
  • Date:日期类型
    elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间。

2.index
index影响字段的索引情况。

  • true:字段会被索引,则可以用来进行搜索。默认值就是true
  • false:字段不会被索引,不能用来搜索
    index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。

3.store

是否将数据进行额外存储。

在我之前学习lucene时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source的属性中。而且我们可以通过过滤_source来选择哪些要显示,哪些不显示。而如果设置store为true,就会在_source以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。

新增数据
通过POST请求,可以向一个已经存在的索引库中添加数据

POST /索引库名/类型名
{"key":"value"
}

例如:

POST /heima/goods/
{"title":"小米手机","images":"http://image.leyou.com/12479122.jpg","price":2699.00
}

新增数据后,通过kibana查询到的数据如下:

{"_index": "heima","_type": "goods","_id": "r9c1KGMBIhaxtY5rlRKv","_version": 1,"_score": 1,"_source": {"title": "小米手机","images": "http://image.leyou.com/12479122.jpg","price": 2699}
}
  • _source:源文档信息,所有的数据都在里面。
  • _id:这条文档的唯一标示,与文档自己的id字段没有关联

自定义文档id
如果我们想要自己新增的时候指定id,可以这么做:

POST /索引库名/类型/id值
{...
}

例如:

POST /heima/goods/2
{"title":"大米手机","images":"http://image.leyou.com/12479122.jpg","price":2899.00
}

查询到的结果如下:

{"_index": "heima","_type": "goods","_id": "2","_score": 1,"_source": {"title": "大米手机","images": "http://image.leyou.com/12479122.jpg","price": 2899}
}

elasticsearch有一个非常好用的功能:我们在新增数据时,一般只使用提前配置好映射属性的字段,但是Elasticsearch非常智能,你不需要给索引库设置任何mapping映射,它也可以根据你输入的数据来判断类型,动态添加数据映射。
例如:我们额外添加stock库存,和saleable是否上架两个字段。

POST /heima/goods/3
{"title":"超米手机","images":"http://image.leyou.com/12479122.jpg","price":2899.00,"stock": 200,"saleable":true
}

查询到的结果如下:

{"_index": "heima","_type": "goods","_id": "3","_version": 1,"_score": 1,"_source": {"title": "超米手机","images": "http://image.leyou.com/12479122.jpg","price": 2899,"stock": 200,"saleable": true}
}

索引库的映射关系如下:

{"heima": {"mappings": {"goods": {"properties": {"images": {"type": "keyword","index": false},"price": {"type": "float"},"saleable": {"type": "boolean"},"stock": {"type": "long"},"title": {"type": "text","analyzer": "ik_max_word"}}}}}
}

可以看到,新增加的两个字段都已经被成功地映射了,所以这个功能非常好用。

修改数据
把刚才新增的请求方式改为PUT,就是修改了。不过修改必须指定id,

  • id对应文档存在,则修改
  • id对应文档不存在,则新增
    例如:
PUT /heima/goods/3
{"title":"超大米手机","images":"http://image.leyou.com/12479122.jpg","price":3899.00,"stock": 100,"saleable":true
}

删除数据

DELETE /索引库名/类型名/id值

11.3 查询

基本查询

GET /索引库名/_search
{"query":{"查询类型":{"查询条件":"查询条件值"}}
}

这里的query代表一个查询对象,里面可以有不同的查询属性

  • 查询类型:

    • 例如:match_all, match,term , range 等等
  • 查询条件:查询条件会根据类型的不同,写法也有差异,后面详细讲解

1.查询所有

GET /heima/_search
{"query":{"match_all": {}}
}
  • query:代表查询对象
  • match_all:代表查询所有

查询所有没啥好解释的

2.匹配查询(match)

  • or关系

match类型查询,会把查询条件进行分词,然后进行查询,多个词条之间是or的关系

GET /heima/_search
{"query":{"match":{"title":"小米电视"}}
}

在上面的案例中,不仅会查询到电视,而且与小米相关的都会查询到,多个词之间是or的关系。

  • and关系

某些情况下,我们需要更精确的查找,我们希望这个关系变成and,可以这样做(显示地指定and操作符即可):

GET /heima/_search
{"query":{"match": {"title": {"query": "小米电视","operator": "and"}}}
}
  • or和and是两个极端,实际中我们可能希望取一个中间结果
    match 查询支持 minimum_should_match 最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:
GET /heima/_search
{"query":{"match":{"title":{"query":"小米曲面电视","minimum_should_match": "75%"}}}
}

3.多字段查询(multi_match)

GET /heima/_search
{"query":{"multi_match": {"query":    "小米","fields":   [ "title", "subTitle" ]}}
}

在上面的例子中,我们会在title字段和subtitle字段中查询小米这个词
4.词条匹配(term)
term 查询被用于精确值 匹配,这些精确值可能是数字、时间、布尔或者那些未分词的字符串

GET /heima/_search
{"query":{"term":{"price":2699.00}}
}

5.多词条精确匹配(terms)
terms 查询和 term 查询一样,但它允许你指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么这个文档满足条件:

GET /heima/_search
{"query":{"terms":{"price":[2699.00,2899.00,3899.00]}}
}

结果过滤
默认情况下,elasticsearch在搜索的结果中,会把文档中保存在_source的所有字段都返回。
如果我们只想获取其中的部分字段,我们可以添加_source的过滤

1.直接指定字段

GET /heima/_search
{"_source": ["title","price"],"query": {"term": {"price": 2699}}
}

2.指定includes和excludes
我们也可以通过:

  • includes:来指定想要显示的字段
  • excludes:来指定不想要显示的字段

二者都是可选的。

GET /heima/_search
{"_source": {"includes":["title","price"]},"query": {"term": {"price": 2699}}
}

高级查询
1.布尔组合(bool)
bool把各种其它查询通过must(与)、must_not(非)、should(或)的方式进行组合

GET /heima/_search
{"query":{"bool":{"must":     { "match": { "title": "大米" }},"must_not": { "match": { "title":  "电视" }},"should":   { "match": { "title": "手机" }}}}
}

2.范围查询(range)
range 查询找出那些落在指定区间内的数字或者时间

GET /heima/_search
{"query":{"range": {"price": {"gte":  1000.0,"lt":   2800.00}}}
}

range查询允许以下字符:

操作符 说明
gt 大于
gte 大于等于
lt 小于
lte 小于等于

3.模糊查询(fuzzy)
fuzzy 查询是 term 查询的模糊等价。它允许用户搜索词条与实际词条的拼写出现偏差,但是偏差的编辑距离不得超过2:

GET /heima/_search
{"query": {"fuzzy": {"title": "appla"}}
}

上面的查询,也能查询到apple手机
我们可以通过fuzziness来指定允许的编辑距离:

GET /heima/_search
{"query": {"fuzzy": {"title": {"value":"appla","fuzziness":1}}}
}

过滤(filter)
1.条件查询中进行过滤
所有的查询都会影响到文档的评分及排名。如果我们需要在查询结果中进行过滤,并且不希望过滤条件影响评分,那么就不要把过滤条件作为查询条件来用。而是使用filter方式:

GET /heima/_search
{"query":{"bool":{"must":{ "match": { "title": "小米手机" }},"filter":{"range":{"price":{"gt":2000.00,"lt":3800.00}}}}}
}

2.无查询条件,直接过滤
如果一次查询只有过滤,没有查询条件,不希望进行评分,我们可以使用constant_score取代只有 filter 语句的 bool 查询。在性能上是完全相同的,但对于提高查询简洁性和清晰度有很大帮助:

GET /heima/_search
{"query":{"constant_score":   {"filter": {"range":{"price":{"gt":2000.00,"lt":3000.00}}}}
}

排序
1.单字段排序
sort 可以让我们按照不同的字段进行排序,并且通过order指定排序的方式

GET /heima/_search
{"query": {"match": {"title": "小米手机"}},"sort": [{"price": {"order": "desc"}}]
}

2.多字段排序
假定我们想要结合使用 price和 _score(得分) 进行查询,并且匹配的结果首先按照价格排序,然后按照相关性得分排序:

GET /goods/_search
{"query":{"bool":{"must":{ "match": { "title": "小米手机" }},"filter":{"range":{"price":{"gt":200000,"lt":300000}}}}},"sort": [{ "price": { "order": "desc" }},{ "_score": { "order": "desc" }}]
}

11.4 聚合

聚合可以让我们极其方便的实现对数据的统计、分析。例如:

  • 什么品牌的手机最受欢迎?
  • 这些手机的平均价格、最高价格、最低价格?
  • 这些手机每月的销售情况如何?
    实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现实时搜索效果。

Elasticsearch中的聚合,包含多种类型,最常用的两种,一个叫桶,一个叫度量:
桶(bucket)
桶的作用,是按照某种方式对数据进行分组,每一组数据在ES中称为一个桶,例如我们根据国籍对人划分,可以得到中国桶、英国桶,日本桶……
Elasticsearch中提供的划分桶的方式有很多:

  • Date Histogram Aggregation:根据日期阶梯分组,例如给定阶梯为周,会自动每周分为一组
  • Histogram Aggregation:根据数值阶梯分组,与日期类似
  • Terms Aggregation:根据词条内容分组,词条内容完全匹配的为一组
  • Range Aggregation:数值和日期的范围分组,指定开始和结束,然后按段分组
  • ……
    bucket aggregations 只负责对数据进行分组,并不进行计算,因此往往bucket中往往会嵌套另一种聚合:metrics aggregations即度量

度量(metrics)
分组完成以后,我们一般会对组中的数据进行聚合运算,例如求平均值、最大、最小、求和等,这些在ES中称为度量
比较常用的一些度量聚合方式:

  • Avg Aggregation:求平均值
  • Max Aggregation:求最大值
  • Min Aggregation:求最小值
  • Percentiles Aggregation:求百分比
  • Stats Aggregation:同时返回avg、max、min、sum、count等
  • Sum Aggregation:求和
  • Top hits Aggregation:求前几
  • Value Count Aggregation:求总数
  • ……

注意:在ES中,需要进行聚合、排序、过滤的字段其处理方式比较特殊,因此不能被分词

这里作为例子,首先新建一个索引库:

PUT /cars
{"settings": {"number_of_shards": 1,"number_of_replicas": 0},"mappings": {"transactions": {"properties": {"color": {"type": "keyword"},"make": {"type": "keyword"}}}}
}

聚合为桶
我们按照汽车的颜色color来划分桶

GET /cars/_search
{"size" : 0,"aggs" : { "popular_colors" : { "terms" : { "field" : "color"}}}
}
  • size: 查询条数,这里设置为0,因为我们不关心搜索到的数据,只关心聚合结果,提高效率
  • aggs:声明这是一个聚合查询,是aggregations的缩写
    • popular_colors:给这次聚合起一个名字,任意。

      • terms:划分桶的方式,这里是根据词条划分

        • field:划分桶的字段

查询结果如下:

{"took": 1,"timed_out": false,"_shards": {"total": 1,"successful": 1,"skipped": 0,"failed": 0},"hits": {"total": 8,"max_score": 0,"hits": []},"aggregations": {"popular_colors": {"doc_count_error_upper_bound": 0,"sum_other_doc_count": 0,"buckets": [{"key": "red","doc_count": 4},{"key": "blue","doc_count": 2},{"key": "green","doc_count": 2}]}}
}
  • hits:查询结果为空,因为我们设置了size为0
  • aggregations:聚合的结果
  • popular_colors:我们定义的聚合名称
  • buckets:查找到的桶,每个不同的color字段值都会形成一个桶
    • key:这个桶对应的color字段的值
    • doc_count:这个桶中的文档数量

桶内度量
前面的例子告诉我们每个桶里面的文档数量,这很有用。 但通常,我们的应用需要提供更复杂的文档度量。 例如,每种颜色汽车的平均价格是多少?因此,我们需要告诉Elasticsearch使用哪个字段,使用何种度量方式进行运算,这些信息要嵌套在桶内,度量的运算会基于桶内的文档进行
现在,我们为刚刚的聚合结果添加求价格平均值的度量:

GET /cars/_search
{"size" : 0,"aggs" : { "popular_colors" : { "terms" : { "field" : "color"},"aggs":{"avg_price": { "avg": {"field": "price" }}}}}
}
  • aggs:我们在上一个aggs(popular_colors)中添加新的aggs。可见度量也是一个聚合
  • avg_price:聚合的名称
  • avg:度量的类型,这里是求平均值
  • field:度量运算的字段
    查询结果如下:
  "aggregations": {"popular_colors": {"doc_count_error_upper_bound": 0,"sum_other_doc_count": 0,"buckets": [{"key": "red","doc_count": 4,"avg_price": {"value": 32500}},{"key": "blue","doc_count": 2,"avg_price": {"value": 20000}},{"key": "green","doc_count": 2,"avg_price": {"value": 21000}}]}}

桶内嵌套桶
刚刚的案例中,我们在桶内嵌套度量运算。事实上桶不仅可以嵌套运算, 还可以再嵌套其它桶。也就是说在每个分组中,再分更多组。
比如:我们想统计每种颜色的汽车中,分别属于哪个制造商,按照make字段再进行分桶

GET /cars/_search
{"size" : 0,"aggs" : { "popular_colors" : { "terms" : { "field" : "color"},"aggs":{"avg_price": { "avg": {"field": "price" }},"maker":{"terms":{"field":"make"}}}}}
}
  • 原来的color桶和avg计算我们不变
  • maker:在嵌套的aggs下新添一个桶,叫做maker
  • terms:桶的划分类型依然是词条
  • filed:这里根据make字段进行划分

划分桶的其它方式
前面讲了,划分桶的方式有很多,例如:

  • Date Histogram Aggregation:根据日期阶梯分组,例如给定阶梯为周,会自动每周分为一组
  • Histogram Aggregation:根据数值阶梯分组,与日期类似
  • Terms Aggregation:根据词条内容分组,词条内容完全匹配的为一组
  • Range Aggregation:数值和日期的范围分组,指定开始和结束,然后按段分组
    刚刚的案例中,我们采用的是Terms Aggregation,即根据词条划分桶。
    接下来再记录几个最常用的:
    1.阶梯分桶Histogram
    histogram是把数值类型的字段,按照一定的阶梯大小进行分组。你需要指定一个阶梯值(interval)来划分阶梯大小。
    举例:比如你有价格字段,如果你设定interval的值为200,那么阶梯就会是这样的:0,200,400,600,…。(这里列出的是每个阶梯的key,也是区间的启点。)
GET /cars/_search
{"size":0,"aggs":{"price":{"histogram": {"field": "price","interval": 5000,"min_doc_count": 1}}}
}

我们可以增加一个参数min_doc_count为1,来约束最少文档数量为1,这样文档数量为0的桶会被过滤

2.范围分桶range
范围分桶与阶梯分桶类似,也是把数字按照阶段进行分组,只不过range方式需要你自己指定每一组的起始和结束大小

11.5 Spring Data Elasticsearch

Elasticsearch提供的Java客户端有一些不太方便的地方:

  • 很多地方需要拼接Json字符串,在java中拼接字符串非常麻烦
  • 需要自己把对象序列化为json存储
  • 查询到结果也需要自己反序列化为对象

Spring Data 的使命是给各种数据访问提供统一的编程接口,不管是关系型数据库(如MySQL),还是非关系数据库(如Redis),或者类似Elasticsearch这样的索引数据库。从而简化开发人员的代码,提高开发效率。
特征:

  • 支持Spring的基于@Configuration的java配置方式,或者XML配置方式
  • 提供了用于操作ES的便捷工具类ElasticsearchTemplate。包括实现文档到POJO之间的自动智能映射。
  • 利用Spring的数据转换服务实现的功能丰富的对象映射
  • 基于注解的元数据映射方式,而且可扩展以支持更多不同的数据格式
  • 根据持久层接口自动生成对应实现方法,无需人工编写基本操作代码(类似mybatis,根据接口自动得到实现)。当然,也支持人工定制查询

至于Spring Data ElasticSearch具体怎么使用,就直接贴上例子和代码吧!
实体类:

public class Item {Long id;String title; //标题String category;// 分类String brand; // 品牌Double price; // 价格String images; // 图片地址
}

Spring Data通过注解来声明字段的映射属性,有下面的三个注解:

  • @Document 作用在类,标记实体类为文档对象,一般有四个属性

    • indexName:对应索引库名称
    • type:对应在索引库中的类型
    • shards:分片数量,默认5
    • replicas:副本数量,默认1
  • @Id 作用在成员变量,标记一个字段作为id主键
  • @Field 作用在成员变量,标记为文档的字段,并指定字段映射属性:
    • type:字段类型,取值是枚举:FieldType
    • index:是否索引,布尔类型,默认是true
    • store:是否存储,布尔类型,默认是false
    • analyzer:分词器名称:ik_max_word
@Document(indexName = "item",type = "docs", shards = 1, replicas = 0)
public class Item {@Idprivate Long id;@Field(type = FieldType.Text, analyzer = "ik_max_word")private String title; //标题@Field(type = FieldType.Keyword)private String category;// 分类@Field(type = FieldType.Keyword)private String brand; // 品牌@Field(type = FieldType.Double)private Double price; // 价格@Field(index = false, type = FieldType.Keyword)private String images; // 图片地址
}

这里采用类的字节码信息创建索引并映射:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ItcastElasticsearchApplication.class)
public class IndexTest {@Autowiredprivate ElasticsearchTemplate elasticsearchTemplate;@Testpublic void testCreate(){// 创建索引,会根据Item类的@Document注解信息来创建elasticsearchTemplate.createIndex(Item.class);// 配置映射,会根据Item类中的id、Field等字段来自动完成映射elasticsearchTemplate.putMapping(Item.class);}
}

删除索引

@Test
public void deleteIndex() {elasticsearchTemplate.deleteIndex("heima");
}

Repository文档操作
Spring Data 的强大之处,就在于你不用写任何DAO处理,自动根据方法名或类的信息进行CRUD操作。只要你定义一个接口,然后继承Repository提供的一些子接口,就能具备各种基本的CRUD功能。我们只需要定义接口,然后继承它就OK了。

public interface ItemRepository extends ElasticsearchRepository<Item,Long> {}

新增文档

@Autowired
private ItemRepository itemRepository;@Test
public void index() {Item item = new Item(1L, "小米手机7", " 手机","小米", 3499.00, "http://image.leyou.com/13123.jpg");itemRepository.save(item);
}

批量新增

@Test
public void indexList() {List<Item> list = new ArrayList<>();list.add(new Item(2L, "坚果手机R1", " 手机", "锤子", 3699.00, "http://image.leyou.com/123.jpg"));list.add(new Item(3L, "华为META10", " 手机", "华为", 4499.00, "http://image.leyou.com/3.jpg"));// 接收对象集合,实现批量新增itemRepository.saveAll(list);
}

修改文档
修改和新增是同一个接口,区分的依据就是id,这一点跟我们在页面发起PUT请求是类似的。
基本查询

@Test
public void testQuery(){Optional<Item> optional = this.itemRepository.findById(1l);System.out.println(optional.get());
}@Test
public void testFind(){// 查询全部,并按照价格降序排序Iterable<Item> items = this.itemRepository.findAll(Sort.by(Sort.Direction.DESC, "price"));items.forEach(item-> System.out.println(item));
}

自定义方法
Spring Data 的另一个强大功能,是根据方法名称自动实现功能。比如:你的方法名叫做:findByTitle,那么它就知道你是根据title查询,然后自动帮你完成,无需写实现类。当然,方法名称要符合一定的约定,具体怎么约定的这里就不列出来了。

虽然基本查询和自定义方法已经很强大了,但是如果是复杂查询(模糊、通配符、词条查询等)就显得力不从心了。此时,我们只能使用原生查询。

高级查询
1.基本查询

@Test
public void testQuery(){// 词条查询MatchQueryBuilder queryBuilder = QueryBuilders.matchQuery("title", "小米");// 执行查询Iterable<Item> items = this.itemRepository.search(queryBuilder);items.forEach(System.out::println);
}

Repository的search方法需要QueryBuilder参数,elasticSearch为我们提供了一个对象QueryBuilders:

QueryBuilders提供了大量的静态方法,用于生成各种不同类型的查询对象,例如:词条、模糊、通配符等QueryBuilder对象。

2.自定义查询

@Test
public void testNativeQuery(){// 构建查询条件NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 添加基本的分词查询queryBuilder.withQuery(QueryBuilders.matchQuery("title", "小米"));// 执行搜索,获取结果Page<Item> items = this.itemRepository.search(queryBuilder.build());// 打印总条数System.out.println(items.getTotalElements());// 打印总页数System.out.println(items.getTotalPages());items.forEach(System.out::println);
}

NativeSearchQueryBuilder:Spring提供的一个查询条件构建器,帮助构建json格式的请求体
Page<Item>:默认是分页查询,因此返回的是一个分页的结果对象,包含属性:

  • totalElements:总条数
  • totalPages:总页数
  • Iterator:迭代器,本身实现了Iterator接口,因此可直接迭代得到当前页的数据

3.分页查询

@Test
public void testNativeQuery(){// 构建查询条件NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 添加基本的分词查询queryBuilder.withQuery(QueryBuilders.termQuery("category", "手机"));// 初始化分页参数int page = 0;int size = 3;// 设置分页参数queryBuilder.withPageable(PageRequest.of(page, size));// 执行搜索,获取结果Page<Item> items = this.itemRepository.search(queryBuilder.build());// 打印总条数System.out.println(items.getTotalElements());// 打印总页数System.out.println(items.getTotalPages());// 每页大小System.out.println(items.getSize());// 当前页System.out.println(items.getNumber());items.forEach(System.out::println);
}

可以发现,Elasticsearch中的分页是从第0页开始,但是PageHelper却是从第一页开始

4.排序

public void testSort(){// 构建查询条件NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 添加基本的分词查询queryBuilder.withQuery(QueryBuilders.termQuery("category", "手机"));// 排序queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));// 执行搜索,获取结果Page<Item> items = this.itemRepository.search(queryBuilder.build());// 打印总条数System.out.println(items.getTotalElements());items.forEach(System.out::println);
}

聚合
1.聚合为桶

@Test
public void testAgg(){NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 不查询任何结果queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""}, null));// 1、添加一个新的聚合,聚合类型为terms,聚合名称为brands,聚合字段为brandqueryBuilder.addAggregation(AggregationBuilders.terms("brands").field("brand"));// 2、查询,需要把结果强转为AggregatedPage类型AggregatedPage<Item> aggPage = (AggregatedPage<Item>) this.itemRepository.search(queryBuilder.build());// 3、解析// 3.1、从结果中取出名为brands的那个聚合,// 因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型StringTerms agg = (StringTerms) aggPage.getAggregation("brands");// 3.2、获取桶List<StringTerms.Bucket> buckets = agg.getBuckets();// 3.3、遍历for (StringTerms.Bucket bucket : buckets) {// 3.4、获取桶中的key,即品牌名称System.out.println(bucket.getKeyAsString());// 3.5、获取桶中的文档数量System.out.println(bucket.getDocCount());}
}

2.嵌套聚合,求平均值

@Test
public void testSubAgg(){NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 不查询任何结果queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""}, null));// 1、添加一个新的聚合,聚合类型为terms,聚合名称为brands,聚合字段为brandqueryBuilder.addAggregation(AggregationBuilders.terms("brands").field("brand").subAggregation(AggregationBuilders.avg("priceAvg").field("price")) // 在品牌聚合桶内进行嵌套聚合,求平均值);// 2、查询,需要把结果强转为AggregatedPage类型AggregatedPage<Item> aggPage = (AggregatedPage<Item>) this.itemRepository.search(queryBuilder.build());// 3、解析// 3.1、从结果中取出名为brands的那个聚合,// 因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型StringTerms agg = (StringTerms) aggPage.getAggregation("brands");// 3.2、获取桶List<StringTerms.Bucket> buckets = agg.getBuckets();// 3.3、遍历for (StringTerms.Bucket bucket : buckets) {// 3.4、获取桶中的key,即品牌名称  3.5、获取桶中的文档数量System.out.println(bucket.getKeyAsString() + ",共" + bucket.getDocCount() + "台");// 3.6.获取子聚合结果:InternalAvg avg = (InternalAvg) bucket.getAggregations().asMap().get("priceAvg");System.out.println("平均售价:" + avg.getValue());}
}

12.基本搜索

12.1 索引库数据导入

对于搜索功能,我们将其整合为一个微服务-搜索微服务,命名为:leyou-search.
还是三步走:
1.导入依赖

<?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.0-SNAPSHOT</version></parent><modelVersion>4.0.0</modelVersion><groupId>com.leyou.search</groupId><artifactId>leyou-search</artifactId><properties><maven.compiler.source>11</maven.compiler.source><maven.compiler.target>11</maven.compiler.target></properties><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-eureka-client</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-elasticsearch</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId></dependency><dependency><groupId>com.leyou.item</groupId><artifactId>leyou-item-interface</artifactId><version>1.0.0-SNAPSHOT</version></dependency><dependency><groupId>com.leyou.common</groupId><artifactId>leyou-common</artifactId><version>1.0.0-SNAPSHOT</version></dependency><dependency><groupId>com.leyou.item</groupId><artifactId>leyou-item-interface</artifactId><version>1.0.0-SNAPSHOT</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency></dependencies>
</project>

2.在配置文件application.yml添加相应的配置:

server:port: 8083
spring:application:name: search-servicedata:elasticsearch:cluster-name: elasticsearchcluster-nodes: 192.168.56.101:9300
eureka:client:service-url:defaultZone: http://127.0.0.1:10086/eurekainstance:lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期

3.添加引导类:

package com.leyou;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignClient;@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LeyouSearchApplication {public static void main(String[] args) {SpringApplication.run(LeyouSearchApplication.class,args);}
}

索引库数据格式分析

可以看到,每一个搜索结果都有至少1个商品,当我们选择大图下方的小图,商品会跟着变化。因此,搜索的结果是SPU,即多个SKU的集合。既然搜索的结果是SPU,那么我们索引库中存储的应该也是SPU,但是却需要包含SKU的信息。
页面中需要的数据:图片、价格、标题、副标题,暗藏的数据:spu的id,sku的id
另外,页面还有过滤条件:

这些过滤条件也都需要存储到索引库中,包括:商品分类、品牌、可用来搜索的规格参数等
综上所述,我们需要的数据格式有:spuId、skuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数
我们创建一个类,封装要保存到索引库的数据,并设置映射属性:

package com.leyou.search.pojo;import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;import javax.persistence.Id;
import java.util.Date;
import java.util.List;
import java.util.Map;@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {@Idprivate 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;// 品牌idprivate Long cid1;// 1级分类idprivate Long cid2;// 2级分类idprivate Long cid3;// 3级分类idprivate Date createTime;// 创建时间private List<Long> price;// 价格@Field(type = FieldType.Keyword, index = false)private String skus;// List<sku>信息的json结构private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值public Long getId() {return id;}public void setId(Long id) {this.id = id;}public String getAll() {return all;}public void setAll(String all) {this.all = all;}public String getSubTitle() {return subTitle;}public void setSubTitle(String subTitle) {this.subTitle = subTitle;}public Long getBrandId() {return brandId;}public void setBrandId(Long brandId) {this.brandId = brandId;}public Long getCid1() {return cid1;}public void setCid1(Long cid1) {this.cid1 = cid1;}public Long getCid2() {return cid2;}public void setCid2(Long cid2) {this.cid2 = cid2;}public Long getCid3() {return cid3;}public void setCid3(Long cid3) {this.cid3 = cid3;}public Date getCreateTime() {return createTime;}public void setCreateTime(Date createTime) {this.createTime = createTime;}public List<Long> getPrice() {return price;}public void setPrice(List<Long> price) {this.price = price;}public String getSkus() {return skus;}public void setSkus(String skus) {this.skus = skus;}public Map<String, Object> getSpecs() {return specs;}public void setSpecs(Map<String, Object> specs) {this.specs = specs;}public Goods() {}
}

一些特殊字段解释:

  • all:用来进行全文检索的字段,里面包含标题、商品分类信息
  • price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤
  • skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段
  • specs:所有规格参数的集合。key是参数名,值是参数值。
    例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:
{"specs":{"内存":[4G,6G],"颜色":"红色"}
}

当存储到索引库时,elasticsearch会处理为两个字段:

  • specs.内存:[4G,6G]
  • specs.颜色:红色

另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。

  • specs.颜色.keyword:红色

商品微服务提供接口
索引库中的数据来自于数据库,我们不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以我们只能调用商品微服务提供的接口服务。
先思考我们需要的数据:

  • SPU信息
  • SKU信息
  • SPU的详情
  • 商品分类名称(拼接all字段)
  • 品牌名称
  • 规格参数

而查询这些字段就需要调用别的微服务提供的服务,这时候使用SpringCloud的Feign组件对解决这个问题就非常方便了。可以考虑在搜索微服务中编写各个字段对应的Client,这样接口中的代码直接从商品微服务中拷贝而来,完全一致。差别就是没有方法的具体实现。

而FeignClient代码遵循SpringMVC的风格,因此与商品微服务的Controller完全一致。这样就存在一定的问题:

  • 代码冗余。尽管不用写实现,只是写接口,但服务调用方要写与服务controller一致的代码,有几个消费者就要写几次。
  • 增加开发成本。调用方还得清楚知道接口的路径,才能编写正确的FeignClient。

为了解决这个问题,一种比较友好的办法是这样的:

  • 我们的服务提供方不仅提供实体类,还要提供api接口声明
  • 调用方不用自己编写接口方法声明,直接继承提供方给的Api接口即可

服务提供方提供的api接口如下:
BrandApi:

package com.leyou.item.api;import com.leyou.item.pojo.Brand;
import org.springframework.web.bind.annotation.*;@RequestMapping("brand")
public interface BrandApi {/*** 根据品牌id查询品牌* @param bid* @return*/@GetMapping("{bid}")public Brand queryByid(@PathVariable("bid") Long bid);
}

CategoryApi:

package com.leyou.item.api;import com.leyou.item.pojo.Category;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;import java.util.List;
import java.util.stream.Collectors;@RequestMapping("category")
public interface CategoryApi {@GetMappingpublic List<String> queryNamesById(@RequestParam("ids")List<Long> ids);
}

GoodsApi:

package com.leyou.item.api;import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.SpuBo;
import com.leyou.item.pojo.Sku;
import com.leyou.item.pojo.Spu;
import com.leyou.item.pojo.SpuDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;import java.util.List;public interface GoodsApi {/*** 根据条件分页查询spu* @param key* @param saleable* @param page* @param rows* @return*/@GetMapping("spu/page")public PageResult<SpuBo> queryByPage(@RequestParam(value = "key",required = false) String key,@RequestParam(value = "saleable",required = false) Boolean saleable,@RequestParam(value = "page",defaultValue = "1") Integer page,@RequestParam(value = "rows",defaultValue = "5") Integer rows);/*** 根据spuid查找对应的spu_detail* @param spuId* @return*/@GetMapping("spu/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);/*** 根据spuId查询spu*/@GetMapping("{id}")public Spu querySpuBySpuId(@PathVariable("id") Long spuId);/*** 根据skuId查询sku*/@GetMapping("sku/{skuId}")public Sku querySkuByskuId(@PathVariable("skuId") Long skuId);
}

SpecificationApi:

package com.leyou.item.api;import com.leyou.item.pojo.SpecGroup;
import com.leyou.item.pojo.SpecParam;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;import java.util.List;@RequestMapping("spec")
public interface SpecificationApi {/*** 根据指定条件查询规格参数组* @param gid* @param cid* @param generic* @param searching* @return*/@GetMapping("params")public List<SpecParam> queryParams(@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);/*** 根据cid查询所有参数组和组内所有的参数信息* @param cid* @return*/@GetMapping("{cid}")public List<SpecGroup> querySpecGroupsByCid(@PathVariable("cid") Long cid);
}

接下来开始导入数据:
1.编写GoodsRepository:

package com.leyou.search.repository;import com.leyou.search.pojo.Goods;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;public interface GoodsRepository extends ElasticsearchRepository<Goods,Long> {}

2.创建索引并导入数据:

package com.leyou.search;import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.SpuBo;
import com.leyou.search.client.GoodsClient;
import com.leyou.search.pojo.Goods;
import com.leyou.search.repository.GoodsRepository;
import com.leyou.search.service.SearchService;
import org.elasticsearch.common.recycler.Recycler;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.util.CollectionUtils;import java.io.IOException;
import java.util.ArrayList;
import java.util.List;@SpringBootTest
@RunWith(SpringRunner.class)
public class LeyouSearchTest {@Autowiredprivate ElasticsearchTemplate elasticsearchTemplate;@Autowiredprivate GoodsRepository goodsRepository;@Autowiredprivate GoodsClient goodsClient;@Autowiredprivate SearchService searchService;@Testpublic void testElasticsearch(){elasticsearchTemplate.createIndex(Goods.class);elasticsearchTemplate.putMapping(Goods.class);//大坑:pageHelper分页是从第一页开始的,真他妈坑!!!!Integer page = 1;Integer rows = 100;List<Goods> goods = new ArrayList<>();while(true){List<SpuBo> items = null;try {PageResult<SpuBo> pageResult = this.goodsClient.queryByPage(null, null, page, rows);items = pageResult.getItems();} catch (Exception e) {break;}System.out.println(items.size());if(CollectionUtils.isEmpty(items)){break;}items.forEach(item -> {try {Goods goods_ = this.searchService.spuToGoods(item);goods.add(goods_);} catch (IOException e) {//e.printStackTrace();}});this.goodsRepository.saveAll(goods);page++;System.out.println("page:"+page);};}
}

导入数据的过程:调用商品微服务查询所有spu,并编写方法将spu转化为goods对象存储,最后将所有goods对象通过saveAll方法保存到索引库中。spuToGoods方法如下:

/*** 将spu转化为Goods* @param spu* @return*/@Overridepublic Goods spuToGoods(Spu spu) throws IOException {Goods goods = new Goods();goods.setId(spu.getId());List<Long> ids = Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3());List<String> names = this.categoryClient.queryNamesById(ids);String brandName = this.brandClient.queryByid(spu.getBrandId()).getName();String all = spu.getTitle() + " " + StringUtils.join(names, " ") + " " + brandName;goods.setAll(all);goods.setSubTitle(spu.getSubTitle());goods.setBrandId(spu.getBrandId());goods.setCid1(spu.getCid1());goods.setCid2(spu.getCid2());goods.setCid3(spu.getCid3());goods.setCreateTime(spu.getCreateTime());List<Long> prices = new ArrayList<>();List<Map<String,Object>> skus = new ArrayList<>();List<Sku> skusList = this.goodsClient.querySkusBySpuId(spu.getId());skusList.forEach(sku -> {prices.add(sku.getPrice());Map<String,Object> map = new HashMap<>();map.put("id",sku.getId());map.put("title",sku.getTitle());String images = sku.getImages();map.put("images",StringUtils.isEmpty(images)?"":images.split(",")[0]);map.put("price",sku.getPrice());skus.add(map);});goods.setPrice(prices);goods.setSkus(MAPPER.writeValueAsString(skus));Map<String,Object> specs = new HashMap<>();List<SpecParam> params = this.specificationClient.queryParams(null, spu.getCid3(), null, true);SpuDetail spuDetail = this.goodsClient.querySpuDetailBySpuId(spu.getId());String genericSpec = spuDetail.getGenericSpec();String specialSpec = spuDetail.getSpecialSpec();Map<String,Object> genericSpecMap = MAPPER.readValue(genericSpec, new TypeReference<Map<String, Object>>() {});Map<String,List<Object>> specialSpecMap = MAPPER.readValue(specialSpec, new TypeReference<Map<String, List<Object>>>() {});params.forEach(param -> {if(param.getGeneric()){String value = genericSpecMap.get(param.getId().toString()).toString();if(param.getNumeric()){value = chooseSegment(value, param);}specs.put(param.getName(),value);}else{String value = specialSpecMap.get(param.getId().toString()).toString();specs.put(param.getName(),value);}});goods.setSpecs(specs);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;}

12.2 实现基本搜索

前台发起异步请求

<script type="text/javascript">var vm = new Vue({el: "#searchApp",data: {ly,search: {key: "",page: 1,filter: {}},totalPage: 0,total: 0,goodsList: [],filters: [],show: false},methods: {getDataFromServer(){/*ly.http.post("/search/page",this.search).then(resp =>{console.log(resp);}).catch();*//*ly.http.get("/search/page?"+this.search).then(resp =>{console.log(resp);}).catch();*/_this = this;/*{"key": ly.getUrlParam("key"),"page": _this.search.page}*/ly.http.post("/search/page",_this.search).then(({data}) =>{data.items.forEach(good => {good.skus = JSON.parse(good.skus);good.selected = good.skus[0];});// 不要交换两条语句的顺序,因为先增加selected属性然后赋值,该属性的变化才能被监测到_this.goodsList = data.items;_this.total = data.total;_this.totalPage = data.totalPage;_this.filters.push({key: "分类",options: data.categories});_this.filters.push({key: "品牌",options: data.brands});data.specs.forEach(spec => {spec.options = spec.options.map(o => ({name: o}));_this.filters.push(spec);});//console.log("totalPage",_this.totalPage);//console.log("total",_this.total);//console.log(_this.goodsList);}).catch();},index(i){if(this.search.page <= 3 || this.totalPage <= 5){return i;}else if(this.search.page >= this.totalPage-2){return this.totalPage - 5 + i;}else{return this.search.page - 3 + i;}},locateTo(page){this.search.page = page;window.location = "http://www.leyou.com/search.html?"+ly.stringify(this.search);//this.getDataFromServer();},next(){if(this.search.page < this.totalPage){this.search.page++;window.location = "http://www.leyou.com/search.html?"+ly.stringify(this.search);//this.getDataFromServer();}},prev(){if(this.search.page > 1){this.search.page--;window.location = "http://www.leyou.com/search.html?"+ly.stringify(this.search);//this.getDataFromServer();}},selectFilter(key,obj){// console.log("key",key);// console.log("fefef",obj.id);// let temp = {};// Object.assign(temp,this.search);// console.log("hhe",temp.page);if(key != "品牌" && key != "分类"){this.search.filter[key] = obj.name;}else{this.search.filter[key] = obj.id;}//console.log(this.search);window.location = "http://www.leyou.com/search.html?"+ly.stringify(this.search);}},// 为什么使用监听机制就没有任何反应啊,太假了// 为什么使用监听机制就没有任何反应啊,太假了// 为什么使用监听机制就没有任何反应啊,太假了// 为什么使用监听机制就没有任何反应啊,太假了// 为什么使用监听机制就没有任何反应啊,太假了/*watch: {search:{deep: true,immediate: true,handler(){this.getDataFromServer();}}},*/// 注意location.search和location.href不一样,居然在这里浪费了这么长时间created(){if(!location.search){return;}const search = ly.parse(location.search.substring(1));search.page = search.page? search.page : 1;search.filter = search.filter ? search.filter : {};this.search = search;//console.log(this.search);//this.search.page = 1;this.getDataFromServer();//console.log("hahah",this.search);},components:{lyTop: () => import("./js/pages/top.js")}});
</script>
  • 我们这里使用ly是common.js中定义的工具对象。
  • 这里使用的是post请求,这样可以携带更多参数,并且以json格式发送

注意这里也显然有跨域问题,所以在网关微服务leyou-gateway的Cors配置中,应该多添加一个信任的域名:www.leyou.com

后台实现接口

  • 请求方式:Post
  • 请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询
  • 请求参数:json格式,目前只有一个属性:key-搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:
package com.leyou.search.pojo;import java.util.Map;public class SearchRequest {private String key;// 搜索条件private Integer page;// 当前页private Map<String,Object> filter; //过滤条件public Map<String, Object> getFilter() {return filter;}public void setFilter(Map<String, Object> filter) {this.filter = filter;}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;}// 获取页码时做一些校验,不能小于1return Math.max(DEFAULT_PAGE, page);}public void setPage(Integer page) {this.page = page;}public Integer getSize() {return DEFAULT_SIZE;}
}

SearchResult类(至于为什么要继承PageResult等会儿再说):

package com.leyou.search.pojo;import com.leyou.common.pojo.PageResult;
import com.leyou.item.pojo.Brand;import java.util.List;
import java.util.Map;public class SearchResult extends PageResult<Goods> {private List<Brand> brands;private List<Map<String,Object>> categories;private List<Map<String,Object>> specs;public List<Map<String, Object>> getSpecs() {return specs;}public void setSpecs(List<Map<String, Object>> specs) {this.specs = specs;}public SearchResult(Long total, List<Goods> items, List<Brand> brands, List<Map<String, Object>> categories, List<Map<String, Object>> specs) {super(total, items);this.brands = brands;this.categories = categories;this.specs = specs;}public SearchResult(Long total, Integer totalPage, List<Goods> items, List<Brand> brands, List<Map<String, Object>> categories, List<Map<String, Object>> specs) {super(total, totalPage, items);this.brands = brands;this.categories = categories;this.specs = specs;}public SearchResult(List<Brand> brands, List<Map<String, Object>> categories, List<Map<String, Object>> specs) {this.brands = brands;this.categories = categories;this.specs = specs;}public List<Brand> getBrands() {return brands;}public void setBrands(List<Brand> brands) {this.brands = brands;}public List<Map<String, Object>> getCategories() {return categories;}public void setCategories(List<Map<String, Object>> categories) {this.categories = categories;}
}

SearchController类:

@RestController
@RequestMapping
public class SearchController {@Autowiredprivate 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);}
}

SearchServiceImpl类:

@Service
public class SearchService {@Autowiredprivate GoodsRepository goodsRepository;public PageResult<Goods> search(SearchRequest request) {String key = request.getKey();// 判断是否有搜索条件,如果没有,直接返回null。不允许搜索全部商品if (StringUtils.isBlank(key)) {return null;}// 构建查询条件NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();// 1、对key进行全文检索查询queryBuilder.withQuery(QueryBuilders.matchQuery("all", key).operator(Operator.AND));// 2、通过sourceFilter设置返回的结果字段,我们只需要id、skus、subTitlequeryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"}, null));// 3、分页// 准备分页参数int page = request.getPage();int size = request.getSize();queryBuilder.withPageable(PageRequest.of(page - 1, size));// 4、查询,获取结果Page<Goods> pageInfo = this.goodsRepository.search(queryBuilder.build());// 封装结果并返回return new PageResult<>(goodsPage.getTotalElements(), goodsPage.getTotalPages(), goodsPage.getContent());}
}

GoodsRepository类:

package com.leyou.search.repository;import com.leyou.search.pojo.Goods;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;public interface GoodsRepository extends ElasticsearchRepository<Goods,Long> {}

经过测试,数据是查到了,但是因为我们只查询部分字段,所以结果json 数据中有很多null,这很不优雅。解决办法很简单,在leyou-search的application.yml中添加一行配置,json处理时忽略空值:

spring:jackson:default-property-inclusion: non_null # 配置json处理时忽略空值

页面渲染
从后台拿到数据后,接下来便是前台的渲染过程了。具体渲染的过程就不详细描述了,代码也已经在前面粘贴过了。
这里只重点强调一下几个问题:
1.价格显示的是分(数据库中存放的是以分为单位,所以这里要格式化)
调用ly.formatPrice()即可,注意需要先在data中引入ly。
2.标题过长
运用substring截取一下即可
3.sku点击不切换
这是因为Vue的自动渲染是基于对象的属性变化的。比如页面使用GoodsList进行渲染,如果GoodsList变化,或者其内部的任何子对象变化,都会被Vue感知,从而从新渲染页面。然而,这一切有一个前提,那就是当你第一次渲染时,对象中有哪些属性,Vue就只监视这些属性,后来添加的属性发生改变,是不会被监视到的。而我们的goods对象中,本身是没有selected属性的,是我们后来才添加进去的:也就是说,我们先把selected属性初始化完毕,然后才把整个对象赋值给goodsList,这样,goodsList已初始化时就有selected属性,以后就会被正常监控了。

12.3 页面分页效果

刚才的查询中,我们默认了查询的页码和每页大小,因此所有的分页功能都无法使用,接下来复习一下分页功能。
需要的数据
分页数据应该是根据总页数、当前页、总条数等信息来计算得出。

  • 当前页:肯定是由页面来决定的,点击按钮会切换到对应的页
  • 总页数:需要后台传递给我们
  • 总条数:需要后台传递给我们

我们首先在data中记录下这几个值:page-当前页,total-总条数,totalPage-总页数

data: {ly,search:{key: "",page: 1},goodsList:[], // 接收搜索得到的结果total: 0, // 总条数totalPage: 0 // 总页数
}

因为page是搜索条件之一,所以记录在search对象中。要注意:我们在created钩子函数中,会读取url路径的参数,然后赋值给search。如果是第一次请求页面,page是不存在的。因此为了避免page被覆盖,我们应该这么做:

// 注意location.search和location.href不一样,居然在这里浪费了这么长时间created(){if(!location.search){return;}const search = ly.parse(location.search.substring(1));search.page = search.page? search.page : 1;search.filter = search.filter ? search.filter : {};this.search = search;//console.log(this.search);//this.search.page = 1;this.getDataFromServer();//console.log("hahah",this.search);},

页面计算分页条
我想要实现的效果如下:

这里最复杂的是中间的1~5的分页按钮,它需要动态变化。
思路分析:

  • 最多有5个按钮,因此我们可以用v-for循环从1到5即可
  • 但是分页条不一定是从1开始:
    • 如果当前页值小于等于3的时候,分页条位置从1开始到5结束
    • 如果总页数小于等于5的时候,分页条位置从1开始到总页数结束
    • 如果当前页码大于3,应该从page-3开始
    • 但是如果当前页码大于totalPage-3,应该从totalPage-5开始

具体怎么实现也不难,总能实现的,只是区别在于代码写得优不优雅。
点击分页做什么
点击分页按钮后,自然是要修改page的值。所以,我们在上一页、下一页按钮添加点击事件,对page进行修改,在数字按钮上绑定点击事件,点击直接修改page。当page发生变化,我们应该去后台重新查询数据。不过,如果我们直接发起ajax请求,那么浏览器的地址栏中是不会有变化的,没有记录下分页信息。如果用户刷新页面,那么就会回到第一页。这样不太友好,我们应该把搜索条件记录在地址栏的查询参数中。因此,我们监听search的变化,然后把search的过滤字段拼接在url路径后:

watch:{search:{deep:true,handler(val){// 把search对象变成请求参数,拼接在url路径window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);}}
},

注意:如果这样写页面会有一个bug:页面无限刷新!为什么?
因为Vue实例初始化的钩子函数中,我们读取请求参数,赋值给search的时候,也触发了watch监视!也就是说,每次页面创建完成,都会触发watch,然后就会去修改window.location路径,然后页面被刷新,再次触发created钩子,又触发watch,周而复始,无限循环。

所以,我们需要在watch中进行监控,如果发现是第一次初始化,则不继续向下执行。那么问题是,如何判断是不是第一次?第一次初始化时,search中的key值肯定是空的,所以,我们这么做:

watch:{search:{deep:true,handler(val,old){if(!old || !old.key){// 如果旧的search值为空,或者search中的key为空,证明是第一次return;}// 把search对象变成请求参数,拼接在url路径window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);}}
}

我在做这部分的时候一直出错,当时也没耐心解决了,后来曲线救国,在更新完page的值之后,直接调用axios请求重新获取数据。

12.4 排序

这部分是一个小作业,实现起来也比较简单。
在搜索商品列表的顶部,有这么一部分内容:

这是用来做排序的,默认按照综合排序。点击新品,应该按照商品创建时间排序,点击价格应该按照价格排序。因为我们没有统计销量和评价,这里咱们以新品和价格为例,进行讲解,做法是想通的。

排序需要知道两个内容:

  • 排序的字段
  • 排序的方式

因此,我们首先在search中记录这两个信息,因为created钩子函数会对search进行覆盖,因此我们在钩子函数中对这两个信息进行初始化即可:

然后,在页面上给按钮绑定点击事件,修改sortBy和descending的值:


<!--排序字段-->
<ul class="sui-nav"><li :class="{active:!search.sortBy}" @click="search.sortBy=''"><a href="#">综合</a></li><li><a href="#">销量</a></li><li @click="search.sortBy='createTime'" :class="{active: search.sortBy==='createTime'}"><a href="#">新品</a></li><li><a href="#">评价</a></li><li @click="search.sortBy='price'; search.descending = !search.descending":class="{active: search.sortBy==='price'}"><a href="#">价格<v-icon v-show="search.descending">arrow_drop_down</v-icon><v-icon v-show="!search.descending">arrow_drop_up</v-icon></a></li>
</ul>

接下来,后台需要接收请求参数中的排序信息,然后在搜索中加入排序的逻辑。现在,我们的请求参数对象SearchRequest中,只有page、key两个字段。需要进行扩展:

然后在搜索业务逻辑中,添加排序条件:

注意,因为我们存储在索引库中的的价格是一个数组,因此在按照价格排序时,会进行智能处理:

  • 如果是价格降序,则会把数组中的最大值拿来排序
  • 如果是价格升序,则会把数组中的最小值拿来排序

乐忧商城项目总结-3相关推荐

  1. 乐忧商城项目总结-1

    乐忧商城 1.springboot 1.1 springboot基本介绍 1.2 springboot快速入门 1.3 默认配置的原理 小结 1.4 springboot整合常用模块 整合spring ...

  2. 乐忧商城项目总结-4

    乐忧商城 13.搜索过滤 13.1 生成分类和品牌过滤 13.3 生成规格参数过滤 13.4 过滤条件的筛选 13.5 页面展示选择的过滤项 13.6 取消过滤项 13.7 优化 14.thymele ...

  3. 乐忧商城项目总结-2

    乐忧商城 6.商品分类 6.1 搭建后台管理的前端页面 6.2 Vuetify框架 6.3 使用域名访问本地项目 6.4 实现商品分类查询 7.品牌查询 8.品牌新增及fastDFS 8.1 品牌新增 ...

  4. java学习day58(乐友商城)乐友商城项目搭建、SE6语法使用

    复习springCloud总结: 今日内容: 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来 ...

  5. 商城项目介绍以及ES6的新语法

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  6. day04-乐优商城项目搭建

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  7. day01-乐优商城项目搭建

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  8. 【javaWeb微服务架构项目——乐优商城day15】——会调用订单系统接口,实现订单结算功能,实现微信支付功能

    0.学习目标 会调用订单系统接口 实现订单结算功能 实现微信支付功能 源码笔记及资料: 链接:https://pan.baidu.com/s/1_opfL63P1pzH3rzLnbFiNw 提取码:v ...

  9. 乐优商城之项目搭建(四)

    文章目录 (一)项目分类 (二)电商行业 (三)专业术语 (四)项目介绍 (五)技术选型 (六)开发环境 (七)搭建后台环境:父工程 (八)搭建后台环境:eureka (九)搭建后台环境:zuul ( ...

最新文章

  1. 税务审计SAP需要用表
  2. B树、B+树、LSM树以及其典型应用场景
  3. numpy.matrixlib.defmatrix.matrix写入csv文件
  4. 远程管理服务器的具体操作方法
  5. react16.8+的生命周期
  6. CSS 渐进增强与优雅降级
  7. 安卓应用安全指南 4.5.1 使用 SQLite 示例代码
  8. AD16修改规则加宽电源线与地线
  9. 你学会测试了吗(1):推荐工具
  10. telnet 检测端口是否开放
  11. 四个开放源代码审查工具【图文】
  12. 反编译exe文件并替换图片资源
  13. 万能地图下载器X3版本终止升级维护
  14. 怎么修改ftp服务器被动端口,ftp服务器改为被动模式
  15. 为什么五笔输入法打字那么快,现在使用的人却越来越少了?
  16. php5.5.12 yar,[原]PHP-yar拓展源码解读五-server篇
  17. uniapp实现版本更新
  18. facebook广告后台设置
  19. 4.4 给单元格快速添加斜线 [原创Excel教程]
  20. python 正则表达式 匹配身份证号

热门文章

  1. 用友U8案例教程销售报表
  2. 北京公积金自由还款计算器
  3. 【通知】▁▂▃ Himi 最新著作《iOS游戏编程之从零开始—Cocos2d-x与cocos2d引擎游戏开发》★书籍源码+第4/5/6样章★-免费下载★ ▃▂▁
  4. R绘图的图形布局,画布布置函数
  5. layui的数据表格展开
  6. 一套详细的安卓软件反编译教程
  7. json解析异常(转义符处理)
  8. 新能源车融资租赁市场现状研究分析报告 -
  9. 【JDBC-2】JDBCUtils,Blob字段的操作
  10. Audio在移动端的兼容性问题(1)