乐忧商城项目总结-3

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:划分桶的字段

查询结果如下:

{
  "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两个字段。需要进行扩展:
在这里插入图片描述

然后在搜索业务逻辑中,添加排序条件:
在这里插入图片描述
注意,因为我们存储在索引库中的的价格是一个数组,因此在按照价格排序时,会进行智能处理:

  • 如果是价格降序,则会把数组中的最大值拿来排序
  • 如果是价格升序,则会把数组中的最小值拿来排序
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值