乐忧商城
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;
});
// 处理sku
const 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处理成map
const 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 object
var 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
//这里一定要写return
return 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:划分桶的字段
- terms:划分桶的方式,这里是根据词条划分
- popular_colors:给这次聚合起一个名字,任意。
查询结果如下:
{
"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 {
@Id
private 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 {
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Test
public 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,聚合字段为brand
queryBuilder.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,聚合字段为brand
queryBuilder.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-service
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.56.101:9300
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
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 {
@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是参数名,值是参数值
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 {
@GetMapping
public 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 {
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Autowired
private GoodsRepository goodsRepository;
@Autowired
private GoodsClient goodsClient;
@Autowired
private SearchService searchService;
@Test
public 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
*/
@Override
public 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;
}
// 获取页码时做一些校验,不能小于1
return 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 {
@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);
}
}
SearchServiceImpl类:
@Service
public class SearchService {
@Autowired
private 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、subTitle
queryBuilder.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两个字段。需要进行扩展:
然后在搜索业务逻辑中,添加排序条件:
注意,因为我们存储在索引库中的的价格是一个数组,因此在按照价格排序时,会进行智能处理:
- 如果是价格降序,则会把数组中的最大值拿来排序
- 如果是价格升序,则会把数组中的最小值拿来排序