乐优商城项目实战

29 篇文章 3 订阅
20 篇文章 10 订阅

项目背景

  • 了解电商行业
  • 了解乐优商城项目结构
  • 能独立搭建项目基本框架
  • 能参考使用ES6的新语法

项目分类

主要从需求方、盈利模式、技术侧重点这三个方面来看它们的不同

传统项目

各种企业里面用的管理系统(ERP、HR、OA、CRM、物流管理系统…)

  • 需求方:公司、企业内部
  • 盈利模式:项目本身卖钱
  • 技术侧重点:业务功能

互联网项目

门户网站、电商网站:baidu.com、qq.com、taobao.com、jd.com …

  • 需求方:广大用户群体
  • 盈利模式:虚拟币、增值服务、广告收益…
  • 技术侧重点:网站性能、业务功能

而我们今天要聊的就是互联网项目中的重要角色:电商

电商行业的发展

钱(前)景

近年来,中国的电子商务快速发展,交易额连创新高,电子商务在各领域的应用不断拓展和深化、相关服务业蓬勃发展、支撑体系不断健全完善、创新的动力和能力不断增强。电子商务正在与实体经济深度融合,进入规模性发展阶段,对经济社会生活的影响不断增大,正成为我国经济发展的新引擎。

中国电子商务研究中心数据显示,截止到 2012 年底,中国电子商务市场交易规模达 7.85万亿人民币,同比增长 30.83%。其中,B2B 电子商务交易额达 6.25 万亿,同比增长 27%。而 2011 年全年,中国电子商务市场交易额达 6 万亿人民币,同比增长 33%,占 GDP 比重上升到 13%;2012 年,电子商务占 GDP 的比重已经高达 15%。

数据

来看看双十一的成交数据:

2016双11开场30分钟,创造每秒交易峰值17.5万笔每秒支付峰值12万笔的新纪录。菜鸟单日物流订单量超过4.67亿,创历史新高。

技术特点

从上面的数据我们不仅要看到钱,更要看到背后的技术实力。正是得益于电商行业的高强度并发压力,促使了BAT等巨头们的技术进步。电商行业有些什么特点呢?

  • 技术范围广
  • 技术新
  • 高并发(分布式、静态化技术、缓存技术、异步并发、池化、队列)
  • 高可用(集群、负载均衡、限流、降级、熔断)
  • 数据量大
  • 业务复杂
  • 数据安全

常用电商模式

电商行业的一些常见模式:

  • B2C:商家对个人,如:亚马逊、当当等
  • C2C平台:个人对个人,如:闲鱼、拍拍网、ebay
  • B2B平台:商家对商家,如:阿里巴巴、八方资源网等
  • O2O:线上和线下结合,如:饿了么、电影票、团购等
  • P2P:在线金融,贷款,如:网贷之家、人人聚财等。
  • B2C平台:天猫、京东、一号店等

专业术语

  • SaaS:软件即服务

  • SOA:面向服务

  • RPC:远程过程调用

  • RMI:远程方法调用

  • PV:(page view),即页面浏览量;

    用户每1次对网站中的每个网页访问均被记录1次。用户对同一页面的多次访问,访问量累计

  • UV:(unique visitor),独立访客

    指访问某个站点或点击某条新闻的不同IP地址的人数。在同一天内,uv只记录第一次进入网站的具有独立IP的访问者,在同一天内再次访问该网站则不计数。

  • PV与带宽:

    • 计算带宽大小需要关注两个指标:峰值流量和页面的平均大小。
    • 计算公式是:网站带宽= ( PV * 平均页面大小(单位MB)* 8 )/统计时间(换算到秒)
    • 为什么要乘以8?
      • 网站大小为单位是字节(Byte),而计算带宽的单位是bit,1Byte=8bit
    • 这个计算的是平均带宽,高峰期还需要扩大一定倍数
  • PV、QPS、并发

    • QPS:每秒处理的请求数量。
      • 比如你的程序处理一个请求平均需要0.1S,那么1秒就可以处理10个请求。QPS自然就是10,多线程情况下,这个数字可能就会有所增加。
    • 由PV和QPS如何需要部署的服务器数量?
      • 根据二八原则,80%的请求集中在20%的时间来计算峰值压力:
      • (每日PV * 80%) / (3600s * 24 * 20%) * 每个页面的请求数 = 每个页面每秒的请求数量
      • 然后除以服务器的QPS值,即可计算得出需要部署的服务器数量

项目开发流程

项目经理:管人

技术经理:

产品经理:设计需求原型

测试:

前端:大前端:UI 前端页面。

后端:

移动端:

项目开发流程图:

公司现状:

乐优商城介绍

项目介绍

  • 乐优商城是一个全品类的电商购物网站(B2C)。
  • 用户可以在线购买商品、加入购物车、下单
  • 可以评论已购买商品
  • 管理员可以在后台管理商品的上下架、促销活动
  • 管理员可以监控商品销售状况
  • 客服可以在后台处理退款操作
  • 希望未来3到5年可以支持千万用户的使用

系统架构

架构图

乐优商城架构缩略图:

系统架构解读

整个乐优商城可以分为两部分:后台管理系统、前台门户系统。

  • 后台管理:
    • 后台系统主要包含以下功能:
      • 商品管理,包括商品分类、品牌、商品规格等信息的管理
      • 销售管理,包括订单统计、订单退款处理、促销活动生成等
      • 用户管理,包括用户控制、冻结、解锁等
      • 权限管理,整个网站的权限控制,采用JWT鉴权方案,对用户及API进行权限控制
      • 统计,各种数据的统计分析展示
    • 后台系统会采用前后端分离开发,而且整个后台管理系统会使用Vue.js框架搭建出单页应用(SPA)。
  • 前台门户
    • 前台门户面向的是客户,包含与客户交互的一切功能。例如:
      • 搜索商品
      • 加入购物车
      • 下单
      • 评价商品等等
    • 前台系统我们会使用Thymeleaf模板引擎技术来完成页面开发。出于SEO优化的考虑,我们将不采用单页应用。

无论是前台还是后台系统,都共享相同的微服务集群,包括:

  • 商品微服务:商品及商品分类、品牌、库存等的服务
  • 搜索微服务:实现搜索功能
  • 订单微服务:实现订单相关
  • 购物车微服务:实现购物车相关功能
  • 用户中心:用户的登录注册等功能
  • Eureka注册中心
  • Zuul网关服务

项目搭建

技术选型

前端技术:

  • 基础的HTML、CSS、JavaScript(基于ES6标准)
  • JQuery
  • Vue.js 2.0以及基于Vue的框架:Vuetify(UI框架)
  • 前端构建工具:WebPack
  • 前端安装包工具:NPM
  • Vue脚手架:Vue-cli
  • Vue路由:vue-router
  • ajax框架:axios
  • 基于Vue的富文本框架:quill-editor

后端技术:

  • 基础的SpringMVC、Spring 5.x和MyBatis3
  • Spring Boot 2.1.10版本
  • Spring Cloud Greenwich.SR4
  • Redis
  • RabbitMQ
  • Elasticsearch
  • nginx
  • FastDFS
  • MyCat
  • Thymeleaf
  • mysql 5.7

开发环境

为了保证开发环境的统一,希望每个人都按照我的环境来配置:

  • IDEA
  • JDK:JDK1.8
  • 项目构建:maven 3.6.2
  • 版本控制工具:git

域名

我们在开发的过程中,为了保证以后的生产、测试环境统一。尽量都采用域名来访问项目。

一级域名:www.leyou.com,leyou.com

二级域名:manage.leyou.com/item , api.leyou.com

我们可以通过switchhost工具来修改自己的host对应的地址,只要把这些域名指向127.0.0.1,那么跟你用localhost的效果是完全一样的。

switchhost下载连接:https://github.com/oldj/SwitchHosts/releases

创建父工程

创建工程

创建Maven工程,打包方式为pom,项目名为leyou-parent

添加依赖
<?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">
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>


    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR4</spring-cloud.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <mybatis.starter.version>2.1.1</mybatis.starter.version>
        <mapper.starter.version>2.1.5</mapper.starter.version>
        <druid.starter.version>1.1.10</druid.starter.version>
        <mysql.version>5.1.38</mysql.version>
        <pageHelper.starter.version>1.2.12</pageHelper.starter.version>
        <fastDFS.client.version>1.26.1-RELEASE</fastDFS.client.version>
        <mybatis.plus.version>3.3.1</mybatis.plus.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- springCloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- mybatis启动器 -->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis.starter.version}</version>
            </dependency>
            <!-- 通用Mapper启动器 -->
            <dependency>
                <groupId>tk.mybatis</groupId>
                <artifactId>mapper-spring-boot-starter</artifactId>
                <version>${mapper.starter.version}</version>
            </dependency>
            <!-- 分页助手启动器 -->
            <dependency>
                <groupId>com.github.pagehelper</groupId>
                <artifactId>pagehelper-spring-boot-starter</artifactId>
                <version>${pageHelper.starter.version}</version>
            </dependency>
            <!-- mysql驱动 -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>
            <!--FastDFS客户端-->
            <dependency>
                <groupId>com.github.tobato</groupId>
                <artifactId>fastdfs-client</artifactId>
                <version>${fastDFS.client.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

建议:可以将src文件夹删掉了,因为此模块仅仅做父模块,管理一些依赖。

创建EurekaServer

创建工程

创建工程,将模块命名为leyou-registry

添加依赖
<?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">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-registry</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
    </dependencies>

</project>
修改配置文件
server:
  port: 10086

spring:
  application:
    name: leyou-registery

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    register-with-eureka: false # 把自己注册到eureka服务列表
    fetch-registry: false # 拉取eureka服务信息
  server:
    eviction-interval-timer-in-ms: 5000 # 每隔5秒钟,进行一次服务列表的清理
    enable-self-preservation: false
   
logging:
  level: 
    top.codekiller.leyouRegistry: debug
编写启动类
package top.codekiller.leyouRegistry;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@EnableEurekaServer
@SpringBootApplication
public class LeyouSpringApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouSpringApplication.class);
    }
}

创建Zuul网关

创建模块

依旧是选择创建maven模块,项目名为leyou-gateway

添加依赖
<?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>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-gateway</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
    </dependencies>

</project>
修改配置文件
server:
  port: 10010

spring:
  application:
    name: leyou-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    fetch-registry: true
    registry-fetch-interval-seconds: 5
    # 还可配置registery的续约时间间隔和过期间隔

zuul:
  prefix: /api

#配置熔断
#hystrix:
#  command:
#    default:
#      execution:
#        isolation:
#          thread:
#            timeoutInMilliseconds: 2000 # 设置hystrix的超时时间2秒
编写启动类
package top.codekiller.leyouGateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
public class LeyouGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouGatewayApplication.class);
    }
}

创建商品微服务

既然是一个全品类的电商购物平台,那么核心自然就是商品。因此我们要搭建的第一个服务,就是商品微服务。其中会包含对于商品相关的一系列内容的管理,包括:

  • 商品分类管理
  • 品牌管理
  • 商品规格参数管理
  • 商品管理
  • 库存管理

微服务的结构

因为与商品的品类相关,我们的工程命名为leyou-item.

需要注意的是,我们的leyou-item是一个微服务,那么将来肯定会有其它系统需要来调用服务中提供的接口,获取的接口数据,也需要对应的实体类来封装,因此肯定也会使用到接口中关联的实体类。

因此这里我们需要使用聚合工程,将要提供的接口及相关实体类放到独立子工程中,以后别人引用的时候,只需要知道坐标即可。

我们会在leyou-item中创建两个子工程:

  • leyou-item-interface:主要是对外暴露的接口及相关实体类
  • leyou-item-service:所有业务逻辑及内部使用接口

调用关系如图所示:

leyou-item

因为是聚合工程,所以把项目打包方式设置为pom,创建完成后把src目录删除

<?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">
    <modelVersion>4.0.0</modelVersion>
    <modules>
        <module>leyou-item-interface</module>
        <module>leyou-item-service</module>
    </modules>

    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

</project>

leyou-item-interface

leyou-item下创建leyou-item-interface模块

<?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-item</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item-interface</artifactId>


</project>

leyou-item-service

leyou-item下创建leyou-item-service模块

引入依赖

思考一下我们需要什么?

  • Eureka客户端
  • web启动器
  • mybatis-plus
  • 连接池,我们用druid
  • mysql驱动
  • 千万不能忘了,我们自己也需要leyou-item-interface中的实体类

这些依赖,我们在顶级父工程:leyou中已经添加好了。所以直接引入即可:

<?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-item</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.20</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

</project>
修改配置文件
server:
  port: 8081
spring:
  application:
    name: item-service
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/leyoumall?characterEncoding=UTF-8&serverTimezone=UTC
    type: com.alibaba.druid.pool.DruidDataSource
    # 下面为连接池的补充设置,应用到上面所有数据源中
    # 初始化大小,最小,最大
    initialSize: 5   #初始化创建的连接数
    minIdle: 5  #最小空闲连接数
    maxActive: 20 #最大活跃连接数,不宜设置过多
    # 配置获取连接等待超时的时间
    maxWait: 60000
    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
    timeBetweenEvictionRunsMillis: 60000
    # 配置一个连接在池中最小生存的时间,单位是毫秒
    minEvictableIdleTimeMillis: 30000
    #用来检测连接是否有效的sql,要求是一个查询语句,常用select 'x'。
    validationQuery: select 'x';
    #建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,
    testWhileIdle: true
    #申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。
    testOnBorrow: false
    #归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。
    testOnReturn: false
    # 打开PSCache,并且指定每个连接上PSCache的大小
    #是否缓存preparedStatement,也就是PSCache。PSCache对支持游标的数据库性能提升巨大,比如说oracle。在mysql下建议关闭。
    poolPreparedStatements: true
    #要启用PSCache,必须配置大于0,当大于0时,poolPreparedStatements自动触发修改为true。
    #在Druid中,不会存在Oracle下PSCache占用内存过多的问题,可以把这个数值配置大一些,比如说100
    maxPoolPreparedStatementPerConnectionSize: 20
    # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,stat用于监控统计,'wall'用于防火墙,防御sql注入,slf4j用于日志
    filters: stat,wall,slf4j
    # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
    # 合并多个DruidDataSource的监控数据
    useGlobalDataSourceStat: true
eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    register-with-eureka: true
  instance:
    lease-renewal-interval-in-seconds: 5
    lease-expiration-duration-in-seconds: 15

mybatis-plus:
  type-aliases-package: top.codekiller.leyou.pojo
编写启动类
@SpringCloudApplication
public class LeyouItemServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouItemServiceApplication.class);
    }
}

添加商品微服务的路由规则

既然商品微服务已经创建,接下来肯定要添加路由规则到Zuul中,我们不使用默认的路由规则。

修改leyou-gateway工程的application.yaml配置文件:

server:
  port: 10010

spring:
  application:
    name: leyou-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    fetch-registry: true
    registry-fetch-interval-seconds: 5
    # 还可配置registery的续约时间间隔和过期间隔

zuul:
  prefix: /api
  routes: 
    item-service: /item/**

#配置熔断
#hystrix:
#  command:
#    default:
#      execution:
#        isolation:
#          thread:
#            timeoutInMilliseconds: 2000 # 设置hystrix的超时时间2秒

启动测试

我们分别启动:leyou-registryleyou-gatewayleyou-item-service

为了测试路由规则是否畅通,我们是不是需要在item-service中编写一个controller接口呢?

其实不需要,SpringBoot提供了一个依赖:actuator

只要我们添加了actuator的依赖,它就会为我们生成一系列的访问接口:

  • /info
  • /health
  • /refresh

item-service中添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

因为我们没有添加信息,所以是一个空的json,但是可以肯定的是:我们能够访问到item-service了。

接下来我们通过路由访问试试,根据路由规则,我们需要访问的地址是:http://localhost:10010/api/item/actuator/info

访问不到重启网关后再试

通用工具模块

有些工具或通用的约定内容,我们希望各个服务共享,因此需要创建一个工具模块:leyou-common

leyou-parent创建leyou-common模块

  • leyou-parent:父工程,管理版本号
    • leyou-common:通用工程(存放工具类类等)
    • leyou-gateway:网关工程,拦截并分发请求
    • leyou-item:商品聚合工程
      • leyou-item-interface:存放pojo对象
      • leyou-item-service:对外提供rest接口的具体实现
  • leyou-registry:eureka注册中心

搭建后台管理前台页面

将前端页面工程解压后移动到工作空间下,然后使用IDEA打开,安装依赖

安装依赖

我们只需要打开终端,进入项目目录,输入:npm install命令,即可安装这些依赖。

运行测试

在package.json文件中有scripts启动脚本配置,可以输入命令:npm run dev或者npm start

发现默认的端口是9001。访问:http://localhost:9001

目录结构

webpack:是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。并且提供了前端项目的热部署插件。

通用关系

我们最主要理清index.html、main.js、App.vue之间的关系:

理一下:

  • index.html:html模板文件。定义了空的div,其id为app

  • main.js:实例化vue对象,并且通过id选择器绑定到index.html的div中,因此main.js的内容都将在index.html的div中显示。main.js中使用了App组件,即App.vue,main.js中还定义了路由,路由的信息

    import router from './router',是引入当前文件夹下的router文件夹,由于router只有一个文件而且名称为index.js,所以可以直接写文件夹名称

  • index.js:定义请求路径和组件的映射关系。相当于之前的<vue-router>

  • App.vue中也没有内容,而是定义了vue-router的锚点:<router-view>,我们之前讲过,vue-router路由后的组件将会在锚点展示。

  • 最终结论:一切路由后的内容都将通过App.vue在index.html中显示。

  • 访问流程:用户在浏览器输入路径,例如:http://localhost:9001/#/item/brand --> index.js(/item/brand路径对应pages/item/Brand.vue组件) --> 该组件显示在App.vue的锚点位置 --> main.js使用了App.vue组件,并把该组件渲染在index.html文件中(id为“app”的div中)

也就是说index.html中最终展现的是App.vue中的内容。index.html引用它之后,就拥有了vue的内容(包括组件、样式等),所以,main.js也是webpack打包的入口

Vuetify

为什么学习UI框架

Vue虽然会帮我们进行视图的渲染,但样式还是由我们自己来完成。这显然不是我们的强项,因此后端开发人员一般都喜欢使用一些现成的UI组件,拿来即用,常见的例如:

  • BootStrap
  • LayUI
  • EasyUI
  • ZUI

然而这些UI组件的基因天生与Vue不合,因为他们更多的是利用DOM操作,借助于jQuery实现,而不是MVVM的思想。

而目前与Vue吻合的UI框架也非常的多,国内比较知名的如:

  • element-ui:饿了么出品
  • i-view:某公司出品

然而我们都不用,我们今天推荐的是一款国外的框架:Vuetify

官方网站:https://vuetifyjs.com/zh-Hans/

为什么选择Vuetify

有中国的为什么还要用外国的?原因如下:

  • Vuetify几乎不需要任何CSS代码,而element-ui许多布局样式需要我们来编写
  • Vuetify从底层构建起来的语义化组件。简单易学,容易记住。
  • Vuetify基于Material Design(谷歌推出的多平台设计规范),更加美观,动画效果酷炫,且风格统一

这是官网的说明:

缺陷:

  • 目前官网虽然有中文文档,但因为翻译问题,几乎不太能看。
怎么用

基于官方网站的文档进行学习:

我们重点关注UI components即可,里面有大量的UI组件,我们要用的时候再查看,不用现在学习,先看下有什么:

以后用到什么组件,就来查询即可。

项目页面布局

接下来我们一起看下页面布局。

Layout组件是我们的整个页面的布局组件:

一个典型的三块布局。包含左,上,中三部分:

里面使用了Vuetify中的2个组件和一个布局元素:

导航抽屉

v-navigation-drawer :导航抽屉,主要用于容纳应用程序中的页面的导航链接。

工具栏

v-toolbar:工具栏通常是网站导航的主要途径。可以与导航抽屉一起很好地工作,动态选择是否打开导航抽屉,实现可伸缩的侧边栏。

布局元素

v-content:并不是一个组件,而是标记页面布局的元素。可以根据您指定的app组件的结构动态调整大小,使得您可以创建高度可定制的组件。

那么问题来了:v-content中的内容来自哪里?

  • Layout映射的路径是/
  • 除了Login以外的所有组件,都是定义在Layout的children属性,并且路径都是/的下面
  • 因此当路由到子组件时,会在Layout中定义的锚点中显示。
  • 并且Layout中的其它部分不会变化,这就实现了布局的共享。

使用域名访问本地仓库

统一环境

我们现在访问页面使用的是:http://localhost:9001

有没有什么问题?

实际开发中,会有不同的环境:

  • 开发环境:自己的电脑
  • 测试环境:提供给测试人员使用的环境
  • 预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试
  • 生产环境:项目最终发布上线的环境

如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。

我们将使用以下域名:

但是最终,我们希望这些域名指向的还是我们本机的某个端口。

那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的ip和端口的呢?

域名解析

一个域名一定会被解析为一个或多个ip。这一般会包含两步:

  • 本地域名解析

    浏览器会首先在本机的hosts文件中查找域名映射的IP地址,如果查找到就返回IP ,没找到则进行域名服务器解析,一般本地解析都会失败,因为默认这个文件是空的。

    • Windows下的hosts文件地址:C:/Windows/System32/drivers/etc/hosts
    • Linux下的hosts文件所在路径: /etc/hosts

    样式:

    # My hosts
    127.0.0.1 localhost
    
  • 域名服务器解析

    本地解析失败,才会进行域名服务器解析,域名服务器就是网络中的一台计算机,里面记录了所有注册备案的域名和ip映射关系,一般只要域名是正确的,并且备案通过,一定能找到。

解决域名解析问题

我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com

这样就实现了域名的关系映射了。

每次在C盘寻找hosts文件并修改是非常麻烦的,给大家推荐一个快捷修改host的工具

下载连接

解压,运行exe文件,效果:

Linux版效果

我们添加了两个映射关系(中间用空格隔开):

  • 127.0.0.1 api.leyou.com :我们的网关Zuul
  • 127.0.0.1 manage.leyou.com:我们的后台系统地址

切换为生效状态然后访问:http://manage.leyou.com:9001

出现如下效果就代表配置成功

Invalid Host header解

原因:我们配置了项目访问的路径,虽然manage.leyou.com映射的ip也是127.0.0.1,但是webpack会验证host是否符合配置。

在webpack.dev.conf.js中取消host验证:disableHostCheck: true

退出重新启动,npm start,刷新浏览器

Nginx解决端口问题

域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.taotao.com:9001

这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?

这里就要用到反向代理工具:Nginx

nginx介绍

什么是Nginx

nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:

  • 反向代理
  • 负载均衡
  • 动态路由
  • 请求过滤
nginx作为web服务器

Web服务器分2类:

  • web应用服务器,如:
    • tomcat
    • resin
    • jetty
  • web服务器,如:
    • Apache 服务器
    • Nginx
    • IIS

区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。 并发:web服务器的并发能力远高于web应用服务器。

nginx做反向代理

什么是反向代理?

  • 代理:通过客户机的配置,实现让一台服务器代理客户机,客户的所有请求都交给代理服务器处理。
  • 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。

nginx可以当做反向代理服务器来使用:

  • 我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
  • 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能

利用反向代理,就可以解决我们前面所说的端口问题,如图

nginx安装
windows

安装

安装非常简单,下载后直接解压即可,绿色免安装,舒服!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S6Yiny9b-1603095206120)(https://cdn.static.note.zzrfdsn.cn/images/project/leyoumall/1575448111000.gif)]

解压后,目录结构:

  1. conf:配置目录
  2. contrib:第三方依赖
  3. html:默认的静态资源目录,类似于tomcat的webapps
  4. logs:日志目录
  5. nginx.exe:启动程序。可双击运行,但不建议这么做。

反向代理配置

nginx中的每个server就是一个反向代理配置,可以有多个server

完整配置:

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;

    keepalive_timeout  65;

    gzip  on;
    server {
        listen       80;
        server_name  manage.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        location / {
            proxy_pass http://127.0.0.1:9001;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
    server {
        listen       80;
        server_name  api.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        location / {
            proxy_pass http://127.0.0.1:20001;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
}Copy to clipboardErrorCopied

使用

nginx可以通过命令行来启动,操作命令:

  • 启动:start nginx.exe
  • 停止:nginx.exe -s stop
  • 重新加载:nginx.exe -s reload

启动过程会闪烁一下,启动成功后,任务管理器中会有两个nginx进程:

测试

启动nginx(如果已经启动,则使用reload命令重新加载即可),然后直接用域名访问后台管理系统:

现在实现了域名访问网站了,中间的流程是怎样的呢?

  1. 浏览器准备发起请求,访问http://mamage.leyou.com,但需要进行域名解析

  2. 优先进行本地域名解析,因为我们修改了hosts,所以解析成功,得到地址:127.0.0.1

  3. 请求被发往解析得到的ip,并且默认使用80端口:http://127.0.0.1:80

    本机的nginx一直监听80端口,因此捕获这个请求

  4. nginx中配置了反向代理规则,将manage.leyou.com代理到127.0.0.1:9001,因此请求被转发

  5. 后台系统的webpack server监听的端口是9001,得到请求并处理,完成后将响应返回到nginx

  6. nginx将得到的结果返回到浏览器

实现商品分类

商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,我们需要依次去完成:商品分类、品牌、商品的开发。

首先将sql文件导入数据库:leyou.sql

CREATE TABLE `tb_category` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目id',
  `name` varchar(20) NOT NULL COMMENT '类目名称',
  `parent_id` bigint(20) NOT NULL COMMENT '父类目id,顶级类目填0',
  `is_parent` tinyint(1) NOT NULL COMMENT '是否为父节点,0为否,1为是',
  `sort` int(4) NOT NULL COMMENT '排序指数,越小越靠前',
  PRIMARY KEY (`id`),
  KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品类目表,类目和商品(spu)是一对多关系,类目与品牌是多对多关系';

因为商品分类会有层级关系,因此这里我们加入了parent_id字段,对本表中的其它分类进行自关联。

实现功能

在浏览器页面点击“分类管理”菜单:

根据这个路由路径到路由文件(src/route/index.js),可以定位到分类管理页面:

由路由文件知,页面是src/pages/item/Category.vue

商品分类使用了树状结构,而这种结构的组件vuetify并没有为我们提供,这里自定义了一个树状组件。不要求实现或者查询组件的实现,只要求可以参照文档使用该组件即可:

异步请求

点击商品管理下的分类管理子菜单,在浏览器控制台可以看到:

页面中没有,只是发起了一条请求:http://api.leyou.com/api/item/category/list?pid=0

大家可能会觉得很奇怪,我们明明是使用的相对路径:/item/category/list,讲道理发起的请求地址应该是:

http://manage.leyou.com/item/category/list

但实际却是:

http://api.leyou.com/api/item/category/list?pid=0

这是因为,我们有一个全局的配置文件,对所有的请求路径进行了约定:

路径是http://api.leyou.com,并且默认加上了/api的前缀,这恰好与我们的网关设置匹配,我们只需要把地址改成网关的地址即可,因为我们使用了nginx反向代理,这里可以写域名。

接下来,我们要做的事情就是编写后台接口,返回对应的数据即可。

实体类

leyou-item-interface中添加category实体类:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class Category {

    @TableId
    private Long id;
    private String name;
    private Long parentId;
    private Boolean isParent;  //注意isParent生成的getter和setter方法需要手动加上is
    private Integer sort;


}

注意在这里加上mybatis-plus和lombok的依赖>

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
    </dependency>
</dependencies>
Controller

编写一个controller一般需要知道四个内容:

  • 请求方式:决定我们用GetMapping还是PostMapping
  • 请求路径:决定映射路径
  • 请求参数:决定方法的参数
  • 返回值结果:决定方法的返回值

在刚才页面发起的请求中,我们就能得到绝大多数信息:

  • 请求方式:Get,查询肯定是get请求

  • 请求路径:/api/item/category/list。其中/api是网关前缀,/item是网关的路由映射,真实的路径应该是/category/list

  • 请求参数:pid=0,根据tree组件的说明,应该是父节点的id,第一次查询为0,那就是查询一级类目

  • 返回结果:??

    根据前面tree组件的用法我们知道,返回的应该是json数组:

[
    { 
        "id": 74,
        "name": "手机",
        "parentId": 0,
        "isParent": true,
        "sort": 2
    },
     { 
        "id": 75,
        "name": "家用电器",
        "parentId": 0,
        "isParent": true,
        "sort": 3
    }
]

对应的java类型可以是List集合,里面的元素就是类目对象了。也就是List

controller代码:

@RestController
@RequestMapping("category")
public class CategoryController {

    @Autowired
    private ICategoryService categoryService;

    @GetMapping("list")
    public ResponseEntity<List<Category>> queryCategoryBypid(@RequestParam(value="pid",defaultValue = "0")Long pid){
        if(pid==null||pid<0){
            //400:参数不合法
            //return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
            //return ResponseEntity<>(HttpStatus.BAD_REQUEST);
            return ResponseEntity.badRequest().build();
        }
        List<Category> categories = this.categoryService.queryCategoryByPid(pid);
        if(CollectionUtils.isEmpty(categories)){
            //404:资源服务器未找到
            //return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
            return ResponseEntity.notFound().build();
        }
        //200:查询成功
        return ResponseEntity.ok(categories);
    }
Service
@Service
public class CategoryService implements ICategoryService {

    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    @Override
    public List<Category> queryCategoryByPid(Long pid) {
        return this.categoryMapper.selectList(new QueryWrapper<Category>().lambda()
                                        .eq(Category::getParentId,pid));

    }
}
mapper
public interface CategoryMapper extends BaseMapper<Category> {

}

要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?

我们在MP的配置类上添加一个扫描包功能:

@Configuration
@MapperScan("top.codekiller.leyou.mapper")
public class MybatisPlusconfig {
}
运行产生的问题

发现报错了!

浏览器直接访问没事,但是这里却报错,什么原因?

这其实是浏览器的同源策略造成的跨域问题。

跨域问题

跨域:浏览器对于javascript的同源策略的限制 。

以下情况都属于跨域:

跨域原因说明示例
域名不同www.jd.comwww.taobao.com
域名相同,端口不同www.jd.com:8080www.jd.com:8081
二级域名不同item.jd.commiaosha.jd.com

如果域名和端口都相同,但是请求路径不同,不属于跨域,如:

www.jd.com/item
www.jd.com/goods

http和https也属于跨域

而我们刚才是从manage.leyou.com去访问api.leyou.com,这属于二级域名不同,跨域了。

为什么会有跨域问题

跨域不一定都会有跨域问题。

因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。

因此:跨域问题 是针对ajax的一种限制

但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?

解决跨域问题的方案

目前比较常用的跨域解决方案有3种:

  • Jsonp

    最早的解决方案,利用script标签可以跨域的原理实现。

    限制:

    • 需要服务的支持
    • 只能发起GET请求
  • nginx反向代理

    思路是:利用nginx把跨域反向代理为不跨域,支持各种请求方式

    缺点:需要在nginx进行额外配置,语义不清晰

  • CORS

    规范化的跨域请求解决方案,安全可靠。

    优势:

    • 在服务端进行控制是否允许跨域,可自定义规则
    • 支持各种请求方式

    缺点:

    • 会产生额外的请求

我们这里会采用cors的跨域方案。

Cors解决跨域

什么是cors

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

  • 浏览器端:

    目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。

  • 服务端:

    CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。

原理

浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。

简单请求

只要同时满足以下两大条件,就属于简单请求。:

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现发起的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.

Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。

如果服务器允许跨域,需要在返回的响应头中携带下面信息:

Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8Copy to clipboardErrorCopied
  • Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*(代表任意域名)
  • Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true

有关cookie:

要想操作cookie,需要满足3个条件:

  • 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
  • 浏览器发起ajax需要指定withCredentials 为true
  • 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
特殊请求

不符合简单请求的条件,会被浏览器判定为特殊请求,,例如请求方式为PUT。

预检请求

特殊请求会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

一个“预检”请求的样板:

OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...Copy to clipboardErrorCopied

与简单请求相比,除了Origin以外,多了两个头:

  • Access-Control-Request-Method:接下来会用到的请求方式,比如PUT
  • Access-Control-Request-Headers:会额外用到的头信息

预检请求的响应

服务的收到预检请求,如果许可跨域,会发出响应:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plainCopy to clipboardErrorCopied

除了Access-Control-Allow-OriginAccess-Control-Allow-Credentials以外,这里又额外多出3个头:

  • Access-Control-Allow-Methods:允许访问的方式
  • Access-Control-Allow-Headers:允许携带的头
  • Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了

如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。

实现

虽然原理比较复杂,但是前面说过:

  • 浏览器端都有浏览器自动完成,我们无需操心
  • 服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。

事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。

leyou-gateway中编写一个配置类,并且注册CorsFilter:

@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter(){
        //初始化cors配置对象
        CorsConfiguration configuration=new CorsConfiguration();
        configuration.setAllowCredentials(true);
        //允许跨域的域名。如果要携带cookie,不能写*。*:代表所有的域名都可以跨域访问
        configuration.addAllowedOrigin("http://manage.leyou.com");
        configuration.addAllowedMethod("*");  //*:代表所有的请求方法 :GET,POST,PUT,DELETE
        configuration.addAllowedHeader("*"); //允许携带任何头信息
        //初始化cors配置源对象
        UrlBasedCorsConfigurationSource configurationSource=new UrlBasedCorsConfigurationSource();
        configurationSource.registerCorsConfiguration("/**",configuration);
        //返回corsFilter实例,参数:cors配置源对象
        return new CorsFilter(configurationSource);
    }
}

结构:

重启网关,然后刷新页面测试,访问是否正常正常:http://manage.leyou.com/#/item/category

品牌的查询

商品分类完成以后,自然轮到了品牌功能了。

先看看我们要实现的效果:

点击“品牌管理”菜单:

路由路径:/item/brand

根据路由文件知,对应的页面是:src/pages/item/Brand.vue

页面会发送如下请求:

数据库表
CREATE TABLE `tb_brand` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
  `name` varchar(50) NOT NULL COMMENT '品牌名称',
  `image` varchar(200) DEFAULT '' COMMENT '品牌图片地址',
  `letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';

简单的四个字段,不多解释。

这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:

CREATE TABLE `tb_category_brand` (
  `category_id` bigint(20) NOT NULL COMMENT '商品类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '品牌id',
  PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分类和品牌的中间表,两者是多对多关系';

但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?

  • 外键会严重影响数据库读写的效率
  • 数据删除时会比较麻烦

在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。

实体类

@Data
public class Brand {
    @TableId
    private Long id;
    private String name;
    private String image;
    private Character letter;

}
Controller

编写controller先思考四个问题,参照前端页面的控制台

  • 请求方式:查询,肯定是Get
  • 请求路径:分页查询,/brand/page
  • 请求参数:根据我们刚才编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:
    • page:当前页,int
    • rows:每页大小,int
    • sortBy:排序字段,String
    • desc:是否为降序,boolean
    • key:搜索关键词,String
  • 响应结果:分页结果一般至少需要两个数据
    • total:总条数
    • items:当前页数据
    • totalPage:有些还需要总页数

这里我们封装一个类,来表示分页结果

由于这个分页类可能不止商品服务中需要其他服务可能也需要,所以我们给它放在leyou-common

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult<T>  {

    /**
     * 当前数据总条数
     */
    private Long total;
    /**
     * 当前总页数
     */
    private Integer totalPage;
    /**
     * 当前页数据
     */
    private List<T> items;
}

然后在leyou-item-service工程的pom.xml中引入leyou-common的依赖

<!--leyou-common-->
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>leyou-common</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

接下来,我们编写Controller

@RestController
@RequestMapping("brand")
public class BrandController {
    @Autowired
    IBrandService brandService;


    /**
     * 根据查询条件分页查询品牌信息,并排序
     * @param key 关键字
     * @param page  查询页数
     * @param rows  显示行数
     * @param sortBy 通过那个字段进行排序
     * @param desc  是否是降序
     * @return
     */
    @GetMapping("page")
    public ResponseEntity<PageResult<Brand>> queryBrandsByPage(@RequestParam(value="key",required = false)String key,
                                                               @RequestParam(value="page",defaultValue = "1")Integer page,
                                                               @RequestParam(value="rows",defaultValue = "5")Integer rows,
                                                               @RequestParam(value="sortBy",required = false)String sortBy,
                                                               @RequestParam(value="desc",required = false)Boolean desc){
        PageResult<Brand> result = this.brandService.queryBrandByPage(key, page, rows, sortBy, desc);
        if( CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);
    }

}
Service
@Service
public class BrandService implements IBrandService {

    @Autowired
    private BrandMapper brandMapper;
    /**
     * 根据查询条件分页查询品牌信息,并排序
     * @param key 关键字
     * @param page  查询页数
     * @param rows  显示行数
     * @param sortBy 通过那个字段进行排序
     * @param desc  是否是降序
     * @return
     */
    @Override
    public PageResult<Brand> queryBrandByPage(String key, Integer page, Integer rows, String sortBy, Boolean desc) {
        QueryWrapper<Brand> queryWrapper=new QueryWrapper<Brand>();
        //根据name模糊查询,或者根据首字母查询
        if(StringUtils.isNotBlank(key)){
            queryWrapper.like("name",key).or().eq("letter",key);
        }

        //添加排序条件
        if(StringUtils.isNotBlank(sortBy)){
            if (desc) {
                queryWrapper.orderByDesc(sortBy);
            } else {
                queryWrapper.orderByAsc(sortBy);
            }
        }

        //通过分页查询
        IPage<Brand> rpage=this.brandMapper.selectPage(new Page<Brand>(page,rows),queryWrapper);
        return new PageResult<Brand>(rpage.getTotal(),(int)rpage.getPages(),rpage.getRecords());

    }
}
mapper
public interface BrandMapper extends BaseMapper<Brand> {}

异步查询工具axios

异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。

axios

Vue官方推荐的ajax请求框架叫做:axios,看下demo:

axios的Get请求语法:

axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
    .then(function(resp){
        // 成功回调函数
    })
    .catch(function(){
        // 失败回调函数
    })
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
        params:{
            pid:0
        }
    })
    .then(function(resp){})// 成功时的回调
    .catch(function(error){})// 失败时的回调Copy to clipboardErrorCopied

axios的POST请求语法:

比如新增一个用户

axios.post("/user",{
        name:"Jack",
        age:21
    })
    .then(function(resp){})
    .catch(function(error){})Copy to clipboardErrorCopied

注意,POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数

PUT和DELETE请求与POST请求类似

axios的全局配置

而在我们的项目中,已经引入了axios,并且进行了简单的封装,在src下的http.js中:

http.js中对axios进行了一些默认配置:

import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.leyou.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间

Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象

http.js中导入了config的配置,还记得吗?

  • http.js对axios进行了全局配置:baseURL=config.api,即http://api.leyou.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。
  • 通过Vue.property.$http = axios,将axios赋值给了 Vue原型中的$http。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。
项目中如何使用

我们在组件Brand.vue的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:

网络监视:

在这里插入图片描述

resp到底都有那些数据,查看控制台结果:

可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据。

响应结果中与我们设计的一致,包含3个内容:

  • total:总条数,目前是165
  • items:当前页数据
  • totalPage:总页数,我们没有返回

分页和过滤原理

分页

点击分页,会发起请求,通过浏览器工具查看,会发现pagination对象的属性一直在变化:

我们可以利用Vue的监视功能:watch,当pagination发生改变时,会调用我们的回调函数,我们在回调函数中进行数据的查询!

具体实现:

成功实现分页功能:

过滤

过滤字段对应的是search属性,我们只要监视这个属性即可:

查看网络请求:

页面结果:

品牌管理和图片上传

品牌新增

上节完成了品牌的查询,接下来就是新增功能。点击新增品牌按钮

Brand.vue页面有一个提交按钮:

点击触发addBrand方法:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O16RsKag-1603095206125)(https://cdn.static.note.zzrfdsn.cn/images/project/leyoumall/1545222464105.png)]

把数据模型之的show置为true,而页面中有一个弹窗与show绑定:

弹窗中有一个表单子组件,并且是一个局部子组件,有页面可以找到该组件:

###页面实现

重置表单

重置表单相对简单,因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了。

我们可以通过$refs内置对象来获取表单组件。

首先,在表单上定义ref属性:

然后,在页面查看this.$refs属性:

      reset(){
        // 重置表单
        console.log(this);
      }

查看如下:

在这里插入图片描述

看到this.$refs中只有一个属性,就是myBrandForm

我们在clear中来获取表单对象并调用reset方法:

要注意的是,这里我们还手动把this.categories清空了,因为我写的级联选择组件并没有跟表单结合起来。需要手动清空。

表单校验
校验规则

Vuetify的表单校验,是通过rules属性来指定的:

校验规则的写法:

说明:

  • 规则是一个数组
  • 数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:
    • 返回true,代表成功,
    • 返回错误提示信息,代表失败
编写校验

我们有四个字段:

  • name:做非空校验和长度校验,长度必须大于1
  • letter:首字母,校验长度为1,非空。
  • image:图片,不做校验,图片可以为空
  • categories:非空校验,自定义组件已经帮我们完成,不用写了

首先,我们定义规则:

然后,在页面标签中指定:

<v-text-field v-model="brand.name" label="请输入品牌名称" hint="例如:oppo" :rules="[rules.required, rules.nameLength]"></v-text-field>
<v-text-field v-model="brand.letter" label="请输入品牌首字母" hint="例如:O" :rules="[rules.letter]"></v-text-field>

效果:

表单提交

在submit方法中添加表单提交的逻辑:

submit() {
    console.log(this.$qs);
    // 表单校验
    if (this.$refs.myBrandForm.validate()) {
        // 定义一个请求参数对象,通过解构表达式来获取brand中的属性{categories letter name image}
        const {categories, letter, ...params} = this.brand; // params:{name, image, cids, letter}
        // 数据库中只要保存分类的id即可,因此我们对categories的值进行处理,只保留id,并转为字符串
        params.cids = categories.map(c => c.id).join(",");
        // 将字母都处理为大写
        params.letter = letter.toUpperCase();
        // 将数据提交到后台
        // this.$http.post('/item/brand', this.$qs.stringify(params))
        this.$http({
            method: this.isEdit ? 'put' : 'post',
            url: '/item/brand',
            data: params
        }).then(() => {
            // 关闭窗口
            this.$emit("close");
            this.$message.success("保存成功!");
        })
            .catch(() => {
            this.$message.error("保存失败!");
        });
    }
}
  1. 通过this.$refs.myBrandForm选中表单,然后调用表单的validate方法,进行表单校验。返回boolean值,true代表校验通过
  2. 通过解构表达式来获取brand中的值,categories需要处理,单独获取。其它的存入params对象中
  3. 品牌和商品分类的中间表只保存两者的id,而brand.categories中保存的是对象数组,里面有id和name属性,因此这里通过数组的map功能转为id数组,然后通过join方法拼接为字符串
  4. 发起请求
  5. 弹窗提示成功还是失败,这里用到的是我们的自定义组件功能message组件:

  1. 这个插件把$message对象绑定到了Vue的原型上,因此我们可以通过this.$message来直接调用。

    包含以下常用方法:

  • info、error、success、warning等,弹出一个带有提示信息的窗口,色调与为普通(灰)、错误(红色)、成功(绿色)和警告(黄色)。使用方法:this.$message.info(“msg”)
  • confirm:确认框。用法:this.$message.confirm("确认框的提示信息"),返回一个Promise。

后台实现新增

我们先看以下前台的请求参数信息,除了cids其他三个字段brand实体中都有,我们可以封装到实体中接收,cids直接用参数接收

Controller

还是一样,先分析四个内容:

  • 请求方式:POST
  • 请求路径:/brand
  • 请求参数:brand对象,外加商品分类的id数组cids
  • 返回值:无,只需要响应状态码

代码:

@PostMapping()
public ResponseEntity<Void> saveBrand(Brand brand,@RequestParam("cids") List<Long> cids){
    this.brandService.saveBrand(brand,cids);
    return ResponseEntity.status(HttpStatus.CREATED).build();

}
Service

这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。

/**
     * 增加品牌
     * @param brand 商品的信息
     * @param cids  商品的分类
     */
@Override
@Transactional
public void saveBrand(Brand brand, List<Long> cids) {
    //先新增brand
    this.brandMapper.insert(brand);
    //再新增中间表
    cids.forEach(cid->{
        this.brandMapper.insertCategoryAndBrand(cid,brand.getId());
    });

}

这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增

Mapper

通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:

    /**
     * 在中间表中插入数据
     * @param cid 分类id
     * @param bid 品牌id
     */
    @Insert("insert into tb_category_brand values(#{cid},#{bid})")
    void insertCategoryAndBrand(@Param("cid") Long cid, @Param("bid") Long bid);

测试

400:请求参数不合法

解决400

原因分析

我们填写表单并提交,发现报错了。查看控制台的请求详情:

发现请求的数据格式是JSON格式。

原因分析:

axios处理请求体的原则会根据请求数据的格式来定:

  • 如果请求体是对象:会转为json发送

  • 如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。

    如:name=jack&age=12

qs工具

QS是一个第三方库,我们可以用npm install qs --save来安装。不过我们在项目中已经集成了,大家无需安装:

这个工具的名字:QS,即Query String,请求参数字符串。

什么是请求参数字符串?例如: name=jack&age=21

QS工具可以便捷的实现 JS的Object与QueryString的转换。

在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs来获取这个工具:

我们将this.$qs对象打印到控制台:

created(){
    console.log(this.$qs);
}

发现其中有3个方法:

这里我们要使用的方法是stringify,它可以把Object转为QueryString。

测试一下,使用浏览器工具,把qs对象保存为一个临时变量temp1,然后调用stringify方法:

解决问题

修改页面,对参数处理后发送:

然后再次发起请求,发现请求成功:

完成后关闭窗口(已完成)

我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。

这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。

因此,我们需要在新增的ajax请求完成以后,关闭窗口

但问题在于,控制窗口是否显示的标记在父组件:MyBrand.vue中。子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?

之前我们讲过一个父子组件的通信,有印象吗?

  • 第一步:在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了。父组件在使用子组件时,绑定事件,关联到这个函数:Brand.vue
<!--对话框的内容,表单-->
<v-card-text class="px-5" style="height:400px">
    <brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/>
</v-card-text>

第二步,子组件通过this.$emit调用父组件的函数:BrandForm.vue

测试一下,保存成功:

我们优化一下,关闭的同时重新加载数据:

closeWindow(){
    // 关闭窗口
    this.show = false;
    // 重新加载数据
    this.getDataFromServer();
}

实现图片的上传

刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。

文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。

创建module

依赖

我们需要EurekaClient和web依赖:

<?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-parent</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-upload</artifactId>

    <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.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
         <!--可以生成配置类提示文件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
         <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
</project>
编写配置
server:
  port: 8082
spring:
  application:
    name: upload-service
  servlet:
    multipart:
      file-size-threshold: 5MB
eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
  instance:
    lease-expiration-duration-in-seconds: 15
    lease-renewal-interval-in-seconds: 5

需要注意的是,我们应该添加了限制文件大小的配置

引导类
@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouUploadApplication.class, args);
    }
编写上传功能

文件上传功能,也是自定义组件完成的.

在页面中的使用:

Controller

编写controller需要知道4个内容:结合用法指南

  • 请求方式:上传肯定是POST
  • 请求路径:/upload/image
  • 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipartFile
  • 返回结果:上传成功后得到的文件的url路径,也就是返回String

代码如下:

@RestController
@RequestMapping("upload")
public class UploadController {

    @Autowired
    UploadService uploadService;

    /**
     * 上传图片
     * @param file
     * @return
     */
    @PostMapping("image")
    public ResponseEntity<String>  uploadImage(@RequestParam("file")MultipartFile file){
        String url=uploadService.uploadImage(file);
        if(StringUtils.isBlank(url)){
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(url);
    }
}
UploadProperties

可以通过配置文件编写允许的contentType的类型

@Data
@ConfigurationProperties(prefix = "uploadinfo")
@Component
public class UploadProperties {

    private List<String> contentTypes;

    private String imageUrl;

    private String savePath;
}

application.yml

uploadinfo:
  content-types:
    - image/gif
    - image/jpeg
    - image/png
  imageUrl: http://image.leyou.com/
  savePath: F:\\乐优商城上传的图片\\
Service

在上传文件过程中,我们需要对上传的内容进行校验:

  1. 校验文件大小
  2. 校验文件的媒体类型
  3. 校验文件的内容

文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。

具体代码:

package top.codekiller.leyou.upload.service.impl;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import top.codekiller.leyou.upload.properties.UploadProperties;
import top.codekiller.leyou.upload.service.IUploadService;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.UUID;


@Slf4j
@EnableConfigurationProperties(UploadProperties.class)
@Service
public class UploadService implements IUploadService {

    private UploadProperties uploadProperties;



    //构造器注入properties
    public UploadService(UploadProperties uploadProperties){
        this.uploadProperties=uploadProperties;
    }


    /**
     * 上传图片
     * @param file
     * @return
     */
    @Override
    public String uploadImage(MultipartFile file) {
        String originName=file.getOriginalFilename();
        //验证文件类型
        String contentType=file.getContentType();
        if(!uploadProperties.getContentTypes().contains(contentType)){
            //使用日志记录不合法的信息
            log.info("文件类型不合法: {}",originName);
            return null;
        }

        try {
            //校验文件的内容
            BufferedImage bufferedImage= ImageIO.read(file.getInputStream());
            if(bufferedImage==null){
                log.info("文件的内容不合法: {}",originName);
                return null;
            }
            //获取文件类型
            String suffix=StringUtils.substringAfterLast(originName,".");
            UUID uuid=UUID.randomUUID();
            String id=uuid.toString();
            //保存到服务器
            file.transferTo(new File(uploadProperties.getSavePath()+id+"."+suffix));
            //返回url,进行回显
            log.info("上传成功:{}"+originName);
            return uploadProperties.getImageUrl()+originName;
        } catch (IOException e){
            log.info("服务器内部错误:{}",originName);
            e.printStackTrace();
        }

        return null;

    }
}

这里有一个问题:为什么图片地址需要使用另外的url?

  • 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
  • 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量
配置nginx

虽然实现了文件上传功能并且返回了文件访问地址,但是我们无法通过返回的地址直接访问到图片,接下来我们配置Nginx静态资源访问来回显图片

找到nginx配置文件添加以下内容

server {
    listen       80;
    server_name  image.leyou.com;

    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
        root F:/乐优商城上传的图片/ ;
    }
}

然后配置本地hosts文件

# leyouMall

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com
127.0.0.1 image.leyou.com

然后访问上传后回显的url就可以访问到图片了

测试上传

有两个工具可以进行测试

Postman(需下载)和Advanced REST client(直接在谷歌商店里面搜)

绕过网关

图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。

所以,我们上传文件的请求就不经过网关来处理了。

zuul的路由过滤

Zuul中提供了一个ignored-patterns属性,用来忽略不希望路由的URL路径,示例:

zuul.ignored-patterns: /upload/**

路径过滤会对一切微服务进行判定。

Zuul还提供了ignored-services属性,进行服务过滤:

zuul.ignored-services: upload-servie

我们这里采用忽略服务:

zuul:
  ignored-services:
    - upload-service # 忽略upload-service服务

上面的配置采用了集合语法,代表可以配置多个。

nginx的rewirte指令

现在,我们查看页面的访问路径:

<v-upload
      v-model="brand.image" 
      url="/upload/image" 
      :multiple="false" 
      :pic-width="250" :pic-height="90"
      />

可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?

有同学会想:修改页面请求地址不就好了。

注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。

既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。

我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:

server {
    listen       80;
    server_name  api.leyou.com;

    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location /api/upload {
        proxy_pass http://localhost:8082/upload;

        #rewirte "^/api/(.*)$" /$1 break;
    }

    location / {
        proxy_pass http://127.0.0.1:10010;
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
    }
}

两种方式:

  1. 通过proxy_pass跳转

     proxy_pass http://localhost:8082/upload;
    
  2. 通过重定向的方式

    proxy_pass http://127.0.0.1:7002;
    rewrite "^/api/(.*)$" /$1 break; 
    
  • 首先,我们映射路径是/api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload优先级更高。也就是说,凡是以/api/upload开头的路径,都会被第一个配置处理

  • proxy_pass:反向代理,这次我们代理到7002端口,也就是upload-service服务

  • rewrite "^/api/(.*)$" /$1 break,路径重写:

    • "^/api/(.*)$":匹配路径的正则表达式,用了分组语法,把/api/以后的所有部分当做1组

    • /$1:重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),即/api/后面的所有。这样新的路径就是除去/api/以外的所有,就达到了去除/api前缀的目的

    • break:指令,常用的有2个,分别是:last、break

      • last:重写路径结束后,将得到的路径重新进行一次路径匹配
      • break:重写路径结束后,不再重新匹配路径。

      我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到7002端口了

修改完成,输入nginx -s reload命令重新加载配置。然后再次上传试试。

跨域问题

重启nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错:

不过庆幸的是,这个错误已经不是第一次见了,跨域问题。因为之前我们的跨域问题是在网关中解决的,现在不经过网关了,所以要在这里也添加一个CorsFilter

我们在upload-service中添加一个CorsFilter即可:

@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter() {
        //1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //1) 允许的域,不要写*,否则cookie就无法使用了
        config.addAllowedOrigin("http://manage.leyou.com");
        //3) 允许的请求方式
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("POST");
        // 4)允许的头信息
        config.addAllowedHeader("*");

        //2.添加映射路径,我们拦截一切请求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

再次测试:

文件上传的缺陷

先思考一下,现在上传的功能,有没有什么问题?

上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:

  • 单机器存储,存储能力有限
  • 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
  • 数据没有备份,有单点故障风险
  • 并发能力差

这个时候,最好使用分布式文件存储来代替本地文件存储。

FastDFS

什么是分布式文件系统

分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。

通俗来讲:

  • 传统文件系统管理的文件就存储在本机。
  • 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问

####什么是FastDFS

FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:

  • 文件存储
  • 文件同步
  • 文件访问(上传、下载)
  • 存取负载均衡
  • 在线扩容

适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。

FastDFS架构

先上图:

FastDFS两个主要的角色:Tracker Server 和 Storage Server 。

  • Tracker Server:跟踪服务器,主要负责调度storage节点与client通信,在访问上起负载均衡的作用,和记录storage节点的运行状态,是连接client和storage节点的枢纽。
  • Storage Server:存储服务器,保存文件和文件的meta data(元数据),每个storage server会启动一个单独的线程主动向Tracker cluster中每个tracker server报告其状态信息,包括磁盘使用情况,文件同步情况及文件上传下载次数统计等信息
  • Group:文件组,多台Storage Server的集群。上传一个文件到同组内的一台机器上后,FastDFS会将该文件即时同步到同组内的其它所有机器上,起到备份的作用。不同组的服务器,保存的数据不同,而且相互独立,不进行通信。
  • Tracker Cluster:跟踪服务器的集群,有一组Tracker Server(跟踪服务器)组成。
  • Storage Cluster :存储集群,有多个Group组成。
上传和下载流程

上传

  1. Client通过Tracker server查找可用的Storage server。
  2. Tracker server向Client返回一台可用的Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并进行文件上传。
  4. 上传完成,Storage server返回Client一个文件ID,文件上传结束。

下载

  1. Client通过Tracker server查找要下载文件所在的的Storage server。
  2. Tracker server向Client返回包含指定文件的某个Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并指定要下载文件。
  4. 下载文件成功。
安装和使用

所需文件下载地址:https://github.com/happyfish100

参考资料:FastDFS的安装

java客户端

余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。

这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。

配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。

地址:tobato/FastDFS_client

接下来,我们就用FastDFS改造leyou-upload工程。

引入依赖

在父工程中,我们已经管理了依赖,版本为:

<fastDFS.client.version>1.26.7</fastDFS.client.version>

因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:

<dependency>
    <groupId>com.github.tobato</groupId>
    <artifactId>fastdfs-client</artifactId>
</dependency>
引入配置类

纯java配置:

@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {

}
编写FastDFS属性

在application.yml配置文件中追加如下内容:

fdfs:
  so-timeout: 1501 # 超时时间
  connect-timeout: 601 # 连接超时时间
  thumb-image: # 缩略图
    width: 60
    height: 60
  tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
    - 172.16.145.141:22122
配置hosts

将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:

配置hosts文件,使image.leyou.com可以访问fastDFS服务器

测试

创建测试类:

把以下内容copy进去:

@SpringBootTest
@RunWith(SpringRunner.class)
public class FastDFSTest {

    @Autowired
    private FastFileStorageClient storageClient;

    @Autowired
    private ThumbImageConfig thumbImageConfig;

    @Test
    public void testUpload() throws FileNotFoundException {
        //找一张本地图片路径
        File file = new File("/home/cloudlandboy/Pictures/bg/4oyg9n.jpg");
        // 上传并保存图片,参数:1-上传的文件流 2-文件的大小 3-文件的后缀 4-可以不管他
        StorePath storePath = this.storageClient.uploadFile(
                new FileInputStream(file), file.length(), "jpg", null);
        // 带分组的路径
        System.out.println(storePath.getFullPath());
        // 不带分组的路径
        System.out.println(storePath.getPath());
    }

    @Test
    public void testUploadAndCreateThumb() throws FileNotFoundException {
        //找一张本地图片路径
        File file = new File("/home/cloudlandboy/Pictures/bg/201909232212.jpeg");
        // 上传并且生成缩略图
        StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
                new FileInputStream(file), file.length(), "jpeg", null);
        // 带分组的路径
        System.out.println(storePath.getFullPath());
        // 不带分组的路径
        System.out.println(storePath.getPath());
        // 获取缩略图路径
        String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
        System.out.println(path);
    }
}

如果出现 com.github.tobato.fastdfs.exception.FdfsServerException: 错误码:2,错误信息:找不到节点或文件,查看是不是没有创建文件夹 mkdir -p /leyou/storage,然后重新启动service fdfs_storaged restart

testUpload结果:

group1/M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg
M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg

testUploadAndCreateThumb结果:

group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png

访问http://image.leyou.com/+返回的地址路径(注意加组名(group1)

改造上传逻辑
@Slf4j
@EnableConfigurationProperties(UploadProperties.class)
@Service
public class UploadService implements IUploadService {

    private UploadProperties uploadProperties;

    @Autowired
    private FastFileStorageClient fastFileStorageClient;

    //构造器注入properties
    public UploadService(UploadProperties uploadProperties){
        this.uploadProperties=uploadProperties;
    }
    

    /**
     * 上传图片
     * @param file
     * @return
     */
    @Override
    public String uploadImage(MultipartFile file) {
        String originName=file.getOriginalFilename();
        //验证文件类型
        String contentType=file.getContentType();
        if(!uploadProperties.getContentTypes().contains(contentType)){
            //使用日志记录不合法的信息
            log.info("文件类型不合法: {}",originName);
            return null;
        }

        try {
            //校验文件的内容
            BufferedImage bufferedImage= ImageIO.read(file.getInputStream());
            if(bufferedImage==null){
                log.info("文件的内容不合法: {}",originName);
                return null;
            }
            //获取文件类型
            String suffix=StringUtils.substringAfterLast(originName,".");
//            UUID uuid=UUID.randomUUID();
//            String id=uuid.toString();
            //保存到服务器
//            file.transferTo(new File(uploadProperties.getSavePath()+id+"."+suffix));
            StorePath storePath=fastFileStorageClient.uploadFile(file.getInputStream(),file.getSize(),suffix,null);
            //返回url,进行回显
            log.info("上传成功:{}"+originName);
            return uploadProperties.getImageUrl()+storePath.getFullPath();
        } catch (IOException e){
            log.info("服务器内部错误:{}",originName);
            e.printStackTrace();
        }

        return null;

    }
}

只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。

页面上传测试

发现上传成功:

页面规格数据结构

乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:

SPU和SKU

SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集

SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品

以图为例来看:

  • 本页的 华为Mate10 就是一个商品集(SPU)
  • 因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)

可以看出:

  • SPU是一个抽象的商品集概念,为了方便后台的管理。
  • SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU

数据库设计

思考并发现问题

弄清楚了SPU和SKU的概念区分,接下来我们一起思考一下该如何设计数据库表。

首先来看SPU,大家一起思考下SPU应该有哪些字段来描述?

id:主键
title:标题
description:描述
specification:规格
packaging_list:包装
after_service:售后服务
comment:评价
category_id:商品分类
brand_id:品牌

似乎并不复杂,但是大家仔细思考一下,商品的规格字段你如何填写?

不同商品的规格不一定相同,数据库中要如何保存?

再看下SKU,大家觉得应该有什么字段?

id:主键
spu_id:关联的spu
price:价格
images:图片
stock:库存
颜色?
内存?
硬盘?

碰到难题了,不同的商品分类,可能属性是不一样的,比如手机有内存,衣服有尺码没有内存,我们是全品类的电商网站,这些不同的商品的不同属性,如何设计到一张表中?

其实颜色、内存、硬盘属性都是规格参数中的字段。所以,要解决这个问题,首先要能清楚规格参数。

分析规格参数

仔细查看每一种商品的规格你会发现:

虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的,有图为证:

华为的规格:

三星的规格:

SKU的特有属性

SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。

不同种类的商品,一个手机,一个衣服,其SKU属性不相同。

同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。

这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察你会发现,SKU的特有属性是商品规格参数的一部分

也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:

  • spu下所有sku共享的规格属性(称为全局属性)
  • 每个sku不同的规格属性(称为特有属性)
搜素属性

打开一个搜索页,我们来看看过滤的条件:

你会发现,过滤条件中的屏幕尺寸、运行内存、网路、机身内存、电池容量、CPU核数等,在规格参数中都能找到:

也就是说,规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:

规格参数表

表结构

我们看下规格参数的格式:

可以看到规格参数是分组的,每一组都有多个参数键值对。不过对于规格参数的模板而言,其值现在是不确定的,不同的商品值肯定不同,模板中只要保存组信息、组内参数信息即可。

因此我们设计了两张表:

  • tb_spec_group:组,与商品分类关联

  • tb_spec_param:参数名,与组关联,一对多

规格表

规格参数分组表:tb_spec_group

CREATE TABLE `tb_spec_group` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
  `name` varchar(50) NOT NULL COMMENT '规格组的名称',
  PRIMARY KEY (`id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';

规格组有3个字段:

  • id:主键
  • cid:商品分类id,一个分类下有多个模板
  • name:该规格组的名称。
规格参数

规格参数表:tb_spec_param

CREATE TABLE `tb_spec_param` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id',
  `group_id` bigint(20) NOT NULL,
  `name` varchar(255) NOT NULL COMMENT '参数名',
  `numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
  `unit` varchar(255) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
  `generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
  `searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
  `segments` varchar(1000) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
  PRIMARY KEY (`id`),
  KEY `key_group` (`group_id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';

按道理来说,我们的规格参数就只需要记录参数名、组id、商品分类id即可。但是这里却多出了很多字段,为什么?

还记得我们之前的分析吧,规格参数中有一部分是 SKU的通用属性,一部分是SKU的特有属性,而且其中会有一些将来用作搜索过滤,这些信息都需要标记出来。

通用属性

用一个布尔类型字段来标记是否为通用:

  • generic来标记是否为通用属性:
    • true:代表通用属性
    • false:代表sku特有属性

搜索过滤

与搜索相关的有两个字段:

  • searching:标记是否用作过滤
    • true:用于过滤搜索
    • false:不用于过滤
  • segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
    • 比如电池容量,02000mAh,2000mAh3000mAh,3000mAh~4000mAh

数值类型

某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:

  • numberic:是否为数值类型
    • true:数值类型
    • false:不是数值类型
  • unit:参数的单位

商品规格参数管理

整体布局

打开规格参数页面,看到如下内容:

商品分类树我们之前已经做过,所以这里可以直接展示出来。

因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。一起了解下页面的实现:

页面结构:

这里使用了v-layout来完成页面布局,并且添加了row属性,代表接下来的内容是行布局(左右)。

可以看出页面分成2个部分:

  • <v-flex xs3>:左侧,内部又分上下两部分:商品分类树及标题
    • v-card-title:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板
    • v-tree:这里用到的是我们之前讲过的树组件,展示商品分类树,
  • <v-flex xs9 class="px-1">:右侧:内部是规格参数展示
右侧规格

当我们点击一个分类时,最终要达到的效果:

可以看到右侧分为上下两部分:

  • 上部:面包屑,显示当前选中的分类
  • 下部:table,显示规格参数信息

页面实现:

可以看到右侧并不是我们熟悉的 v-data-table,而是一个spec-group组件(规格组)和spec-param组件(规格参数),这是我们定义的独立组件:

在SpecGroup中定义了表格:

规格组的查询

树节点的点击事件

当我们点击树节点时,要将v-dialog打开,因此必须绑定一个点击事件:(Specification.vue)

我们来看下handleClick方法:(Specification.vue)

点击事件发生时,发生了两件事:

  • 记录当前选中的节点,选中的就是商品分类
  • showGroup被置为true,则规格组就会显示了。

同时,我们把被选中的节点(商品分类)的id传递给了SpecGroup组件:(Specification.vue)

页面查询规格组

来看下SpecGroup.vue中的实现:

我们查看页面控制台,可以看到请求已经发出

后端代码

实体类

leyou-item-interface中添加实体类:

内容:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.sun.javafx.beans.IDProperty;
import lombok.Data;
import java.util.List;

@Data
public class SpecGroup {
    @TableId
    private Long id;
    private Long cid;
    private String name;
    @TableField(exist = false)
    private List<SpecParam> params;
}
package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class SpecParam {
    @TableId
    private Long id;
    private Long cid;
    private Long groupId;
    private String name;
    @TableField(value="`numeric`") //在mysql中是关键字
    private Boolean numeric;
    private String unit;
    private Boolean generic;
    private Boolean searching;
    private String segments;
}

leyou-item-service中编写业务:

Controller

先分析下需要的东西,在页面的ajax请求中可以看出:

  • 请求方式:get
  • 请求路径:/spec/groups/{cid} ,这里通过路径占位符传递商品分类的id
  • 请求参数:商品分类id
  • 返回结果:页面是直接把resp.data赋值给了groups:
package top.codekiller.leyou.controller;

import com.mysql.fabric.xmlrpc.base.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.impl.SpecificationService;

import java.util.List;

@RestController
@RequestMapping("spec")
public class SpecificationController {

    @Autowired
    private SpecificationService specificationService;

    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @GetMapping("groups/{cid}")
    public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid){
        List<SpecGroup> groups=this.specificationService.queryGroupByCid(cid);
        if(CollectionUtils.isEmpty(groups)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(groups);

    }
}

mapper

public interface SpecGroupMapper extends BaseMapper<SpecGroup> {
}
页面访问测试

目前,我们数据库只为手机分类(76)提供了规格组:

规格参数查询

表格切换

当我们点击规格组,会切换到规格参数显示,肯定是在规格组中绑定了点击事件:

我们看下事件处理:

可以看到这里是使用了父子通信,子组件触发了select事件:

再来看下父组件的事件绑定:

事件处理:

这里我们记录了选中的分组,并且把标记设置为false,这样规格组就不显示了,而是显示:SpecParam

并且,我们把group也传递到spec-param组件:

页面查询规格参数

我们来看SpecParam.vue的实现:

查看页面控制台,发现请求已经发出:

报404,因为我们还没有实现后台逻辑,接下来就去实现。

后台实现

SpecificationController

分析:

  • 请求方式:GET
  • 请求路径:/spec/params
  • 请求参数:gid,分组id
  • 返回结果:该分组下的规格参数集合List

代码:

package top.codekiller.leyou.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.impl.SpecificationService;
import java.util.List;

@RestController
@RequestMapping("spec")
public class SpecificationController {

    @Autowired
    private SpecificationService specificationService;

    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @GetMapping("groups/{cid}")
    public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid){
        List<SpecGroup> groups=this.specificationService.queryGroupByCid(cid);
        if(CollectionUtils.isEmpty(groups)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(groups);

    }

    /**
     * 根据组id查询组的参数
     */
    @GetMapping("params")
    public ResponseEntity<List<SpecParam>> queryParams(@RequestParam("gid")Integer gid){
        List<SpecParam> params=this.specificationService.queryParams(gid);
        if(CollectionUtils.isEmpty(params)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(params);
    }
}

Service

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.SpecGroupMapper;
import top.codekiller.leyou.mapper.SpecParamMapper;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.ISpecificationService;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class SpecificationService implements ISpecificationService {
    @Autowired
    private SpecGroupMapper specGroupMapper;

    @Autowired
    private SpecParamMapper specParamMapper;


    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @Override
    public List<SpecGroup> queryGroupByCid(Long cid) {
        Map<String,Object> map=new HashMap<>();
        map.put("cid",cid);
        return specGroupMapper.selectByMap(map);
    }


    /**
     * 根据组id查询组参数
     * @param gid
     * @return
     */
    @Override
    public List<SpecParam> queryParams(Integer gid) {
        List<SpecParam> params=this.specParamMapper.selectList(new QueryWrapper<SpecParam>().lambda()
                                                                .eq(SpecParam::getGroupId,gid));
        return params;
    }
}

Mapper

public interface SpecParamMapper extends BaseMapper<SpecParam> {
}

增、删、改

TODO 时间有限没做

页面中接口都已定义,要做的就是实现后台接口。

SPU和SKU数据结构

规格确定以后,就可以添加商品了,先看下数据库表

SPU表

SPU表:

CREATE TABLE `tb_spu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
  `title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
  `sub_title` varchar(255) DEFAULT '' COMMENT '子标题',
  `cid1` bigint(20) NOT NULL COMMENT '1级类目id',
  `cid2` bigint(20) NOT NULL COMMENT '2级类目id',
  `cid3` bigint(20) NOT NULL COMMENT '3级类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
  `saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
  `valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
  `create_time` datetime DEFAULT NULL COMMENT '添加时间',
  `last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';

与我们前面分析的基本类似,但是似乎少了一些字段,比如商品描述。

我们做了表的垂直拆分,将SPU的详情放到了另一张表:tb_spu_detail

CREATE TABLE `tb_spu_detail` (
  `spu_id` bigint(20) NOT NULL,
  `description` text COMMENT '商品描述信息',
  `generic_spec` varchar(10000) NOT NULL DEFAULT '' COMMENT '通用规格参数数据',
  `special_spec` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
  `packing_list` varchar(3000) DEFAULT '' COMMENT '包装清单',
  `after_service` varchar(3000) DEFAULT '' COMMENT '售后服务',
  PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这张表中的数据都比较大,为了不影响主表的查询效率我们拆分出这张表。

需要注意的是这两个字段:generic_spec和special_spec。

前面讲过规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数信息,因此我们计划是这样:

  • SPUDetail中保存通用的规格参数信息。
  • SKU中保存特有规格参数。

来看下我们的表如何存储这些信息。

generic_spec字段

首先是generic_spec,其中保存通用规格参数信息的值,这里为了方便查询,使用了json格式:

整体来看:

json结构,其中都是键值对:

  • key:对应的规格参数的spec_param的id
  • value:对应规格参数的值
special_spec

我们说spu中只保存通用规格参数,那么为什么有多出了一个special_spec字段呢?

以手机为例,品牌、操作系统等肯定是全局通用属性,内存、颜色等肯定是特有属性。

当你确定了一个SPU,比如小米的:红米4X

全局属性值都是固定的了:

品牌:小米
型号:红米4X

特有属性举例:

颜色:[香槟金, 樱花粉, 磨砂黑]
内存:[2G, 3G]
机身存储:[16GB, 32GB]

颜色、内存、机身存储,作为SKU特有属性,key虽然一样,但是SPU下的每一个SKU,其值都不一样,所以值会有很多,形成数组。

我们在SPU中,会把特有属性的所有值都记录下来,形成一个数组:

里面又有哪些内容呢?

来看数据格式:

也是json结构:

  • key:规格参数id
  • value:spu属性的数组

那么问题来:特有规格参数应该在sku中记录才对,为什么在spu中也要记录一份?

因为我们有时候需要把所有规格参数都查询出来,而不是只查询1个sku的属性。比如,商品详情页展示可选的规格参数时:

SKU表
CREATE TABLE `tb_sku` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'sku id',
  `spu_id` bigint(20) NOT NULL COMMENT 'spu id',
  `title` varchar(255) NOT NULL COMMENT '商品标题',
  `images` varchar(1000) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
  `price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
  `indexes` varchar(100) COMMENT '特有规格属性在spu属性模板中的对应下标组合',
  `own_spec` varchar(1000) COMMENT 'sku的特有规格参数,json格式',
  `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
  `create_time` datetime NOT NULL COMMENT '添加时间',
  `last_update_time` datetime NOT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`),
  KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';

还有一张表,代表库存:

CREATE TABLE `tb_stock` (
  `sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
  `seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
  `seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
  `stock` int(9) NOT NULL COMMENT '库存数量',
  PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';

问题:为什么要将库存独立一张表?

因为库存字段写频率较高,而SKU的其它字段以读为主,因此我们将两张表分离,读写不会干扰。

特别需要注意的是sku表中的indexes字段和own_spec字段。sku中应该保存特有规格参数的值,就在这两个字段中。

indexex字段

在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下:

{
    "4": [
        "香槟金",
        "樱花粉",
        "磨砂黑"
    ],
    "12": [
        "2GB",
        "3GB"
    ],
    "13": [
        "16GB",
        "32GB"
    ]
}

这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。

比如:

  • 红米4X,香槟金,2GB内存,16GB存储
  • 红米4X,磨砂黑,2GB内存,32GB存储

你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:

  • 红米4X,0,0,0
  • 红米4X,2,0,1

既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。

这个设计在商品详情页会特别有用:

当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。

own——spec字段

看结构:

{"4":"香槟金","12":"2GB","13":"16GB"}Copy to clipboardErrorCopied

保存的是特有属性的键值对。

SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的值。

导入图片信息

图片下载

现在商品表中虽然有数据,但是所有的图片信息都是无法访问的,我们需要把图片导入到安装fastdfs的虚拟机上:

  1. 创建static文件夹

    mkdir static
    
  2. 接着将图片压缩包上传到static文件下后解压

# 如果没安装unzip先安装
yum install unzip

unzip images.zipCopy to clipboardErrorCopied
  1. 修改Nginx配置,使nginx反向代理这些图片地址:
vim /opt/nginx/conf/nginx.confCopy to clipboardErrorCopied
  1. 修改成如下配置:
server {
    listen       80;
    server_name  image.leyou.com;

    # 监听域名中带有group的,交给FastDFS模块处理
    location ~/group([0-9])/ {
        ngx_fastdfs_module;
    }
    # 将其它图片代理指向本地的/leyou/static目录
    location / {
        root   /leyou/static/;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }

}Copy to clipboardErrorCopied
  1. 不要忘记重新加载nginx配置
nginx -s reloadCopy to clipboardErrorCopied
  1. 访问测试

http://image.leyou.com/images/6/8/1524297350205.jpg

商品请求

页面请求

先看整体页面结构(Goods.vue):

并且在Vue实例挂载后就会发起查询(mounted调用getDataFromServer方法初始化数据):

我们刷新页面,可以看到浏览器发起已经发起了查询商品数据的请求,但是却发现发起了两次请求:

发起两次请求的原因

可以看到页面有两处地方会导致发送请求,一个是在页面渲染之后的钩子函数中,另一个是在监听分页信息的函数中,因为在初始化的时候vue会给pagination赋值一些初始化数据,而监听函数监听到之后就会调用发送请求的方法,所以我们只需要监听函数即可,钩子函数就不需要了

后端代码

页面已经准备好,接下来在后台提供分页查询SPU的功能。

先来看一下页面需要哪些数据

idtitle分别对应商品id和商品标题,这两个字段在spu表中都有,也就在实体类中也有

但是cnamebname是分类名称和品牌名称,spu表中只有1-3级分类的id和品牌id,实体类也就没有这两个字段,而由不能直接修改实体类,所以需要新建一个bo类去继承spu实体类扩展属性字段

实体类

在leyou-item-interface工程中添加实体类:

Spu:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.util.Date;

@Data
public class Spu {
    @TableId
    private Long id;
    private Long brandId;
    private Long cid1;// 1级类目
    private Long cid2;// 2级类目
    private Long cid3;// 3级类目
    private String title;// 标题
    private String subTitle;// 子标题
    private Boolean saleable;// 是否上架
    private Boolean valid;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
}

SpuDetails:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class SpuDetail {
    @TableId
    private Long spuId;// 对应的SPU的id
    private String description;// 商品描述
    private String specialSpec;// 商品特殊规格的名称及可选值模板
    private String genericSpec;// 商品的全局规格属性
    private String packingList;// 包装清单
    private String afterService;// 售后服务
}

SpuBo:

package top.codekiller.leyou.pojo.bo;

import lombok.Data;
import top.codekiller.leyou.pojo.Spu;

@Data
public class SpuBo extends Spu {
    /**
     * 通过cid获取分类id
     */
    private String cname;
    /**
     * 通过bid获取品牌id
     */
    private String bname;
}
Controller

先分析:

  • 请求方式:GET
  • 请求路径:/spu/page
  • 请求参数:
    • page:当前页
    • rows:每页大小
    • key:过滤条件
    • saleable:上架或下架
  • 返回结果:商品SPU的分页信息。

编写controller代码:

我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样

package top.codekiller.leyou.controller;

import com.leyou.common.pojo.PageResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.codekiller.leyou.pojo.bo.SpuBo;
import top.codekiller.leyou.service.IGoodsService;

@RestController
public class GoodsController {
    @Autowired
    IGoodsService goodsService;

    @GetMapping("spu/page")
    public ResponseEntity<PageResult<SpuBo>> querySpuByPage(@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){
        PageResult<SpuBo> result=this.goodsService.querySpuByPage(key,saleable,page,rows);
        if(result==null|| CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);

    }
}

Service

所有商品相关的业务(包括SPU和SKU)放到一个业务下:GoodsService。

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.leyou.common.pojo.PageResult;
import com.netflix.discovery.util.StringUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.BrandMapper;
import top.codekiller.leyou.mapper.CategoryMapper;
import top.codekiller.leyou.mapper.SpuDetailMapper;
import top.codekiller.leyou.mapper.SpuMapper;
import top.codekiller.leyou.pojo.Brand;
import top.codekiller.leyou.pojo.Category;
import top.codekiller.leyou.pojo.Spu;
import top.codekiller.leyou.pojo.bo.SpuBo;
import top.codekiller.leyou.service.IGoodsService;

import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class GoodsService implements IGoodsService {

    @Autowired
    private SpuMapper spuMapper;

    @Autowired
    private SpuDetailMapper spuDetailMapper;

    @Autowired
    private BrandMapper brandMapper;

    @Autowired
    private CategoryService categoryService;

    /**
     * 根据条件来分页查询Spu
     * @param key 关键字
     * @param saleable 是否上架
     * @param page 查询的页码
     * @param rows 当前页的数据量
     * @return
     */
    @Override
    public PageResult<SpuBo> querySpuByPage(String key, Boolean saleable, Integer page, Integer rows) {
        LambdaQueryWrapper<Spu> queryWrapper=new QueryWrapper<Spu>().lambda();
        //添加查询条件
        if(StringUtils.isNotBlank(key)){
            queryWrapper.like(Spu::getTitle,key);
        }
        //添加上下架的过滤
        if(saleable!=null) {
            queryWrapper.eq(Spu::getSaleable, saleable);
        }
        //进行分页查询,获取当前页对象
        IPage<Spu> ipage=spuMapper.selectPage(new Page<Spu>(page,rows),queryWrapper);
        //获取spu集合
        List<Spu> spus=ipage.getRecords();
        //转化成spubo的集合
        List<SpuBo> spuBos=spus.stream().map(spu->{
            SpuBo spuBo = new SpuBo();
            BeanUtils.copyProperties(spu,spuBo);
            //查询品牌名称
            Brand brand=this.brandMapper.selectById(spu.getBrandId());
            spuBo.setBname(brand.getName());
            //查询分类名称
            List<String> categoryNames=this.categoryService.queryNamesByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3()));
            String cname=StringUtils.join(categoryNames,"-");
            spuBo.setCname(cname);
            return spuBo;
        }).collect(Collectors.toList());
        //返回pageResult<SpuBo>
        PageResult<SpuBo> pageResult=new PageResult<SpuBo>(ipage.getTotal(),(int)ipage.getPages(),spuBos);
        return pageResult;
    }
}
CategoryService中扩展查询名称的功能

页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,

在CategoryService中添加功能:

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.CategoryMapper;
import top.codekiller.leyou.pojo.Category;
import top.codekiller.leyou.service.ICategoryService;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class CategoryService implements ICategoryService {

    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    @Override
    public List<Category> queryCategoryByPid(Long pid) {
        return this.categoryMapper.selectList(new QueryWrapper<Category>().lambda()
                                        .eq(Category::getParentId,pid));
    }


    /**
     * 根据id查询分类名称
     * @param ids
     * @return
     */
    @Override
    public List<String> queryNamesByIds(List<Long> ids) {
        List<Category> categories=this.categoryMapper.selectBatchIds(ids);
        return categories.stream().map(category -> category.getName()).collect(Collectors.toList());
    }
}

Mapper
public interface SpuMapper extends BaseMapper<Spu> {
}
public interface SpuDetailMapper extends BaseMapper<SpuDetail> {
}
测试

在这里插入图片描述商品管理

商品新增

当我们点击新增商品按钮就会出现一个弹窗:

里面把商品的数据分为了4部分来填写:

  • 基本信息:主要是一些简单的文本数据,包含了SPU和SpuDetail的部分数据,如
    • 商品分类:是SPU中的cid1cid2cid3属性
    • 品牌:是spu中的brandId属性
    • 标题:是spu中的title属性
    • 子标题:是spu中的subTitle属性
    • 售后服务:是SpuDetail中的afterService属性
    • 包装列表:是SpuDetail中的packingList属性
  • 商品描述:是SpuDetail中的description属性,数据较多,所以单独放一个页面
  • 规格参数:商品规格信息,对应SpuDetail中的genericSpec属性
  • SKU属性:spu下的所有Sku信息

对应到页面中的四个stepper-content

弹窗事件

弹窗是一个独立组件:

并且在Goods组件中已经引用它:

并且在页面中渲染:

新增商品按钮的点击事件中,改变这个dialogshow属性:

基本数据

我们先来看下基本数据:

商品分类

商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:

刷新页面,可以看到请求已经发出:

效果:

品牌选择
页面

品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。

所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:

选择商品分类后,可以看到请求发起:

接下来,我们只要编写后台接口,根据商品分类id,查询对应品牌即可。

后端代码

页面需要去后台查询品牌信息,我们自然需要提供:

请求方式:GET

请求路径:/brand/cid/{cid}

请求参数:cid

响应数据:品牌集合

BrandController

/**
     * 通过分类id获取对应的品牌集合
     * @param cid
     * @return
     */
    @GetMapping("cid/{cid}")
    public ResponseEntity<List<Brand>> queryBrandsByCid(@PathVariable("cid") Long cid){
        List<Brand> brands=this.brandService.queryBrandByCid(cid);
        if(CollectionUtils.isEmpty(brands)){
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok(brands);
    }

IBrandService

    /**
     * 通过分类id获取对应的品牌集合
     * @param cid category.id
     * @return
     */
    List<Brand> queryBrandByCid(Long cid);

BrandService

/**
     * 通过分类id获取对应的品牌集合
     * @param cid category.id
     * @return
     */
    @Override
    public List<Brand> queryBrandByCid(Long cid) {
        return this.brandMapper.selectBrandByCid(cid);

    }

BrandMapper

根据分类查询品牌有中间表,需要自己编写Sql:

     /**
     * 通过分类id获取品牌集合
     * @param cid
     * @return
     */
    @Select("select * from tb_brand  a inner join  tb_category_brand b on a.id=b.brand_id where b.category_id=#{cid}")
    List<Brand> selectBrandByCid(Long cid);

效果:

其他文本框

剩余的几个属性:标题、子标题等都是普通文本框,我们直接填写即可,没有需要特别注意的。

商品描述

商品描述信息比较复杂,而且图文并茂,甚至包括视频。

这样的内容,一般都会使用富文本编辑器。

富文本编辑器

百度百科:

通俗来说:富文本,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。

富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue

但是我们今天要说的,是一款支持Vue的富文本编辑器:vue-quill-editor

vue-quill-editor

GitHub的主页:https://github.com/surmon-china/vue-quill-editor

Vue-Quill-Editor是一个基于Quill的富文本编辑器:Quill的官网

使用指南

使用非常简单:已经在项目中集成。以下步骤不需操作,仅供参考

第一步:安装,使用npm命令:

npm install vue-quill-editor --save

第二步:加载,在js中引入:

全局引入:

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
    }
})

我们这里采用局部引用:

第三步:页面使用:

<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
效果

商品规格参数(SPU)

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

可以看到这里是根据商品分类id查询规格参数:SpecParam。我们之前写过一个根据gid(分组id)来查询规格参数的接口,我们接下来完成根据分类id查询规格参数。

Controller

我们在原来的根据 gid(规格组id)查询规格参数的接口上,添加一个参数:cid,即商品分类id。

等一下, 考虑到以后可能还会根据是否搜索、是否为通用属性等条件过滤,我们多添加几个过滤条件:

     /**
     * 根据条件查询规格参数
     * @param cid 分类id
     * @param gid 组id
     * @param generic 是否是通用参数
     * @param searching 是否是特殊参数
     * @return
     */
@GetMapping("params")
public ResponseEntity<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){
    List<SpecParam> params=this.specificationService.queryParams(gid,cid,generic,searching);
    System.out.println(params);
    if(CollectionUtils.isEmpty(params)){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(params);
}
Service
    /**
     * 根据条件查询规格参数
     * @param cid 分类id
     * @param gid 组id
     * @param generic 是否是通用参数
     * @param searching 是否是特殊参数
     * @return
     */
    @Override
    public List<SpecParam> queryParams(Long gid, Long cid, Boolean generic, Boolean searching) {
        SpecParam params=new SpecParam();
        params.setCid(cid);
        params.setGroupId(gid);
        params.setGeneric(generic);
        params.setSearching(searching);
        return specParamMapper.selectList(new QueryWrapper<SpecParam>(params));
    }
页面

SKU信息

Sku属性是SPU下的每个商品的不同特征,如图:

当我们填写一些属性后,会在页面下方生成一个sku表格,大家可以计算下会生成多少个不同属性的Sku呢?

当你选择了上图中的这些选项时:

  • 颜色共2种:迷夜黑,勃艮第红,绚丽蓝
  • 内存共2种:4GB,6GB
  • 机身存储1种:64GB,128GB

此时会产生多少种SKU呢? 应该是 3 * 2 * 2 = 12种,这其实就是在求笛卡尔积。

我们会在页面下方生成一个sku的表格:

表单提交

在sku列表的下方,有一个提交按钮

并且绑定了事件

击后会组织数据并向后台提交:

    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:特有规格参数的下标

后端代码

实体类

SPU和SpuDetail实体类已经添加过,添加Sku和Stock对象并修改(SpuBo)[#spubo]:

SKU

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.util.Date;

@Data
public class Sku {
    @TableId
    private Long id;
    private Long spuId;
    private String title;
    private String images;
    private Long price;
    private String ownSpec;// 商品特殊规格的键值对
    private String indexes;// 商品特殊规格的下标
    private Boolean enable;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
    @TableField(exist = false)
    private Integer stock;// 库存
}

注意:这里保存了一个库存字段,在数据库中是另外一张表保存的,方便查询。

Stock

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class Stock {
    @TableId(type = IdType.INPUT)
    private Long skuId;
    private Integer seckillStock;// 秒杀可用库存
    private Integer seckillTotal;// 已秒杀数量
    private Integer stock;// 正常库存

}
Mapper

都是通用Mapper,略

目录结构:

GoodsController

结合浏览器页面控制台,可以发现:

请求方式:POST

请求路径:/goods

请求参数:Spu的json格式的对象,spu中包含spuDetail和Sku集合。这里我们该怎么接收?我们之前定义了一个SpuBo对象,作为业务对象。这里也可以用它,不过需要再扩展spuDetail和skus字段:

修改SpuBo:

package top.codekiller.leyou.pojo.bo;

import lombok.Data;
import top.codekiller.leyou.pojo.Sku;
import top.codekiller.leyou.pojo.Spu;
import top.codekiller.leyou.pojo.SpuDetail;
import java.util.List;

@Data
public class SpuBo extends Spu {
    /**
     * 通过cid获取分类name
     */
    private String cname;

    /**
     * 通过bid获取品牌name
     */
    private String bname;

    /**
     * 商品详情
     */
    private SpuDetail spuDetail;

    /**
     * sku集合
     */
    private List<Sku> skus;
}
    /**
     * 增加商品的spu和sku
     * @param spuBo
     * @return
     */
@PostMapping("goods")
public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo){
    this.goodsService.saveGoods(spuBo);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

注意:通过@RequestBody注解来接收Json请求,还有之前如果设置了全局映射路径为spu要注意下

GoodsService

这里的逻辑比较复杂,我们除了要对SPU新增以外,还要对SpuDetail、Sku、Stock进行保存


/**
* 增加商品的spu和sku
* @param spuBo
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void saveGoods(SpuBo spuBo) {
    //先新增spu
    //防止id注入
    spuBo.setId(null);
    spuBo.setSaleable(true);
    spuBo.setValid(true);
    spuBo.setCreateTime(new Date());
    spuBo.setLastUpdateTime(spuBo.getCreateTime());
    this.spuMapper.insert(spuBo);
    //再去新增spuDetail
    SpuDetail spuDetail=spuBo.getSpuDetail();
    spuDetail.setSpuId(spuBo.getId());
    this.spuDetailMapper.insert(spuDetail);

    List<Sku> skus=spuBo.getSkus();
    skus.forEach(sku->{
        //新增sku
        sku.setId(null);
        sku.setSpuId(spuBo.getId());
        sku.setCreateTime(new Date());
        sku.setLastUpdateTime(sku.getCreateTime());
        this.skuMapper.insert(sku);

        //新增stock
        Stock stock=new Stock();
        stock.setSkuId(sku.getId());
        stock.setStock(sku.getStock());
        this.stockMapper.insert(stock);
    });
}

商品修改

编辑按钮点击事件

在商品详情页,每一个商品后面,都会有一个编辑按钮:

点击这个按钮,就会打开一个商品编辑窗口,我们看下它所绑定的点击事件:(在item/Goods.vue)

对应的方法:

可以看到这里发起了两个请求,在查询商品详情和sku信息。

因为在商品列表页面,只有spu的基本信息:id、标题、品牌、商品分类等。比较复杂的商品详情(spuDetail)和sku信息都没有,编辑页面要回显数据,就需要查询这些内容。

因此,接下来我们就编写后台接口,提供查询服务接口。

查询SpuDetail接口

GoodsController

需要分析的内容:

  • 请求方式:GET
  • 请求路径:/spu/detail/{id}
  • 请求参数:id,应该是spu的id
  • 返回结果:SpuDetail对象
     /**
     * 根据spu的id查询SpuDetail
     * @param spuId
     * @return
     */
@GetMapping("spu/detail/{spuId}")
public ResponseEntity<SpuDetail> querySpuDetailBySpuId(@PathVariable("spuId") Long spuId){
    SpuDetail detail=this.goodsService.querySpuBySupId(spuId);
    if(detail==null){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(detail);
}

GoodsService

     /**
     * 根据spu的id查询SpuDetail
     * @param spuId
     * @return
     */
@Override
public SpuDetail querySpuBySupId(Long spuId) {
    return spuDetailMapper.selectById(spuId);
}
查询Sku

分析

  • 请求方式:Get
  • 请求路径:/sku/list
  • 请求参数:id,应该是spu的id
  • 返回结果:sku的集合

GoodsController

/**
     * 根据spu的id查询sku的集合
     * @param spuId
     * @return
     */
@GetMapping("sku/list")
public ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id")Long spuId){
    List<Sku> skus=this.goodsService.querySkuBySpuId(spuId);
    if(CollectionUtils.isEmpty(skus)){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(skus);
}

GoodsService

需要注意的是,为了页面回显方便,我们一并把sku的库存stock也查询出来

@Override
public List<Sku> querySkuBySpuId(Long spuId) {
    return this.skuMapper.querySkuBySpuId(spuId);
}
页面回显

随便点击一个编辑按钮,发现数据回显完成:

页面提交

这里的保存按钮与新增其实是同一个,因此提交的逻辑也是一样的,这里不再赘述。

随便修改点数据,然后点击保存,可以看到浏览器已经发出请求:

后台实现

接下来,我们编写后台,实现修改商品接口。

GoodsController

  • 请求方式:PUT
  • 请求路径:/
  • 请求参数:Spu对象
  • 返回结果:无
    /**
     * 更新商品
     * @param spuBo
     * @return
     */
    @PutMapping("goods")
    public ResponseEntity<Void> updateGoods(@RequestBody SpuBo spuBo){
        this.goodsService.updateGoods(spuBo);
        return ResponseEntity.noContent().build();
    }

GoodsService

spu数据可以修改,但是SKU数据无法修改,因为有可能之前存在的SKU现在已经不存在了,或者以前的sku属性都不存在了。比如以前内存有4G,现在没了。

因此这里直接删除以前的SKU,然后新增即可。

然后新增sku和开始新增商品中新增sku的代码一致,可以将保存sku和库存的方法抽取成一个方法

代码:

/**
     * 更新商品
     * @param spuBo
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateGoods(SpuBo spuBo) {
        //根据spuid查询要删除的sku
        List<Sku> skus=this.skuMapper.selectList(new QueryWrapper<Sku>().lambda().eq(Sku::getSpuId,spuBo.getId()));
        //删除stock
        skus.forEach(sku -> {
            this.stockMapper.deleteById(sku.getId());
        });

        //删除sku
        skus.forEach(sku->{
            this.skuMapper.deleteById(sku.getId());
        });

        //新增sku和stock
        this.saveSkuAndStock(spuBo);

        //更新spu和spuDetails
        spuBo.setCreateTime(null);
        spuBo.setLastUpdateTime(new Date());
        spuBo.setValid(null);
        spuBo.setSaleable(null);
        this.spuMapper.updateById(spuBo);
        this.spuDetailMapper.updateById(spuBo.getSpuDetail());
    }

    /**
     * 对sku和库存的插入进行了一个封装
     * @param spuBo
     */
    @Transactional(rollbackFor = Exception.class)
    public void saveSkuAndStock(SpuBo spuBo){
        List<Sku> skus=spuBo.getSkus();
        skus.forEach(sku->{
            //新增sku
            sku.setId(null);
            sku.setSpuId(spuBo.getId());
            sku.setCreateTime(new Date());
            sku.setLastUpdateTime(sku.getCreateTime());
            this.skuMapper.insert(sku);

            //新增stock
            Stock stock=new Stock();
            stock.setSkuId(sku.getId());
            stock.setStock(sku.getStock());
            this.stockMapper.insert(stock);
        });
    }
其他

TODO 商品的删除、上下架等有时间完善。

搭建前台系统

后台系统的内容暂时告一段落,有了商品,接下来我们就要在页面展示商品,给用户提供浏览和购买的入口,那就是我们的门户系统。

门户系统面向的是用户,安全性很重要,而且搜索引擎对于单页应用并不友好。因此我们的门户系统不再采用与后台系统类似的SPA(单页应用)。

依然是前后端分离,不过前端的页面会使用独立的html,在每个页面中使用vue来做页面渲染。

静态资源

webpack打包多页应用配置比较繁琐,项目结构也相对复杂。这里为了简化开发(毕竟我们不是专业的前端人员),我们不再使用webpack,而是直接编写原生的静态HTML。

导入静态资源

将静态资源压缩包移动到工作空间下解压,然后IDEA打开

live-server

没有webpack,我们就无法使用webpack-dev-server运行这个项目,实现热部署。

所以,这里我们使用另外一种热部署方式:live-server,

###简介

地址;https://www.npmjs.com/package/live-server

这是一款带有热加载功能的小型开发服务器。用它来展示你的HTML / JavaScript / CSS,但不能用于部署最终的网站。

安装和运行参数

安装,使用npm命令即可,这里建议全局安装,以后任意位置可用

npm install -g live-server

运行时,直接输入命令:

live-server

另外,你可以在运行命令后,跟上一些参数以配置:

  • --port=NUMBER - 选择要使用的端口,默认值:PORT env var或8080
  • --host=ADDRESS - 选择要绑定的主机地址,默认值:IP env var或0.0.0.0(“任意地址”)
  • --no-browser - 禁止自动Web浏览器启动
  • --browser=BROWSER - 指定使用浏览器而不是系统默认值
  • --quiet | -q - 禁止记录
  • --verbose | -V - 更多日志记录(记录所有请求,显示所有侦听的IPv4接口等)
  • --open=PATH - 启动浏览器到PATH而不是服务器root
  • --watch=PATH - 用逗号分隔的路径来专门监视变化(默认值:观看所有内容)
  • --ignore=PATH- 要忽略的逗号分隔的路径字符串(anymatch -compatible definition)
  • --ignorePattern=RGXP-文件的正则表达式忽略(即.*\.jade)(不推荐使用赞成--ignore
  • --middleware=PATH - 导出要添加的中间件功能的.js文件的路径; 可以是没有路径的名称,也可以是引用middleware文件夹中捆绑的中间件的扩展名
  • --entry-file=PATH - 提供此文件(服务器根目录)代替丢失的文件(对单页应用程序有用)
  • --mount=ROUTE:PATH - 在定义的路线下提供路径内容(可能有多个定义)
  • --spa - 将请求从/ abc转换为/#/ abc(方便单页应用)
  • --wait=MILLISECONDS - (默认100ms)等待所有更改,然后重新加载
  • --htpasswd=PATH - 启用期待位于PATH的htpasswd文件的http-auth
  • --cors - 为任何来源启用CORS(反映请求源,支持凭证的请求)
  • --https=PATH - 到HTTPS配置模块的路径
  • --proxy=ROUTE:URL - 代理ROUTE到URL的所有请求
  • --help | -h - 显示简洁的使用提示并退出
  • --version | -v - 显示版本并退出

测试

我们进入leyou-portal目录,输入命令:

live-server --port=9002

域名访问

现在我们访问只能通过:http://127.0.0.1:9002

我们希望用域名访问:http://www.leyou.com

第一步,修改hosts文件,添加一行配置:

127.0.0.1 www.leyou.com

第二步,修改nginx配置,将www.leyou.com反向代理到127.0.0.1:9002

server {
    listen       80;
    server_name  www.leyou.com;

    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_pass http://127.0.0.1:9002;
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
    }
}

注意:www域名要配置在其他域名的前面,否则会被其他域名先匹配到

重新加载nginx配置:nginx -s reload

common.js

为了方便后续的开发,我们在前台系统中定义了一些工具,放在了common.js中:

部分代码截图:

首先对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对象

Elasticsearch的安装和使用

用户访问我们的首页,一般都会直接搜索来寻找自己想要购买的商品。

而商品的数量非常多,而且分类繁杂。如何能正确的显示出用户想要的商品,并进行合理的过滤,尽快促成交易,是搜索系统要研究的核心。

面对这样复杂的搜索业务和数据量,使用传统数据库搜索就显得力不从心,一般我们都会使用全文检索技术,比如之前大家学习过的Solr。

不过今天,我们要讲的是另一个全文检索技术:Elasticsearch。

简介

Elastic官网:https://www.elastic.co/cn/

Elastic有一条完整的产品线及解决方案:Elasticsearch、Kibana、Logstash等,前面说的三个就是大家常说的ELK技术栈。

Elasticsearch官网:https://www.elastic.co/cn/products/elasticsearch

如上所述,Elasticsearch具备以下特点:

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

安装和配置

为了模拟真实场景,我们将在linux下安装Elasticsearch。

需要虚拟机JDK1.8及以上,为了方便直接安装openjdk

yum install java-1.8.0-openjdk -y

新建一个用户leyou

出于安全考虑,elasticsearch默认不允许以root账号运行。

创建用户:

useradd leyou

设置密码:

passwd leyou

切换用户:

su leyou

上传安装包,并解压

安装包下载:https://www.elastic.co/cn/downloads/elasticsearch

我们将安装包上传到:/home/leyou目录

解压缩:

tar -xvf elasticsearch-6.3.0-linux-x86_64.tar.gz

我们把目录重命名:

mv elasticsearch-6.3.0 elasticsearch

修改配置

我们进入config目录:cd config

需要修改的配置文件有两个:

  1. jvm.options

    Elasticsearch基于Lucene的,而Lucene底层是java实现,因此我们需要配置jvm参数。

    编辑jvm.options:

    vim jvm.options
    

    默认配置如下:

    -Xms1g
    -Xmx1g
    

    内存占用太多了,我们调小一些:

    -Xms512m
    -Xmx512m
    
  2. elasticsearch.yml

vim elasticsearch.yml

修改数据和日志目录:

path.data: /home/leyou/elasticsearch/data # 数据目录位置
path.logs: /home/leyou/elasticsearch/logs # 日志目录位置

我们把data和logs目录修改指向了elasticsearch的安装目录。但是data目录并不存在,因此我们需要创建出来。

进入elasticsearch的根目录,然后创建:

mkdir data

修改绑定的ip:

vim config/elasticsearch.yml
network.host: 0.0.0.0 # 绑定到0.0.0.0,允许任何ip来访问

默认只允许本机访问,修改为0.0.0.0后则可以远程访问

目前我们是做的单机安装,如果要做集群,只需要在这个配置文件中添加其它节点信息即可。

elasticsearch.yml的其它可配置信息:

属性名说明
cluster.name配置elasticsearch的集群名称,默认是elasticsearch。建议修改成一个有意义的名称。
node.name节点名,es会默认随机指定一个名字,建议指定一个有意义的名称,方便管理
path.conf设置配置文件的存储路径,tar或zip包安装默认在es根目录下的config文件夹,rpm安装默认在/etc/ elasticsearch
path.data设置索引数据的存储路径,默认是es根目录下的data文件夹,可以设置多个存储路径,用逗号隔开
path.logs设置日志文件的存储路径,默认是es根目录下的logs文件夹
path.plugins设置插件的存放路径,默认是es根目录下的plugins文件夹
bootstrap.memory_lock设置为true可以锁住ES使用的内存,避免内存进行swap
network.host设置bind_host和publish_host,设置为0.0.0.0允许外网访问
http.port设置对外服务的http端口,默认为9200。
transport.tcp.port集群结点之间通信端口
discovery.zen.ping.timeout设置ES自动发现节点连接超时的时间,默认为3秒,如果网络延迟高可设置大些
discovery.zen.minimum_master_nodes主结点数量的最少值 ,此值的公式为:(master_eligible_nodes / 2) + 1 ,比如:有3个符合要求的主结点,那么这里要设置为2

运行

进入elasticsearch/bin目录,可以看到下面的执行文件:

然后输入命令:

./elasticsearch

错误解决

我这里是报了两个错误

错误1:内核过低

如果使用的是centos6,其linux内核版本为2.6。而Elasticsearch的插件要求至少3.5以上版本。不过没关系,我们禁用这个插件即可。

修改elasticsearch.yml文件,在最下面添加如下配置:

bootstrap.system_call_filter: false

然后重启

错误2:文件权限不足

再次启动,又出错了:

[1]: max file descriptors [4096] for elasticsearch process likely too low, increase to at least [65536]

我们用的是leyou用户,而不是root,所以文件权限不足。

首先用root用户登录。

然后修改配置文件:

vim /etc/security/limits.conf

添加下面的内容:

* soft nofile 65536

* hard nofile 131072

* soft nproc 4096

* hard nproc 4096

错误3:线程数不够

centos7不用修改

[1]: max number of threads [1024] for user [leyou] is too low, increase to at least [4096]

这是线程数不够。

继续修改配置:

vim /etc/security/limits.d/20-nproc.conf 

修改下面的内容:

* soft nproc 1024

改为:

* soft nproc 4096
错误4:进程虚拟内存
[3]: max virtual memory areas vm.max_map_count [65530] likely too low, increase to at least [262144]

vm.max_map_count:限制一个进程可以拥有的VMA(虚拟内存区域)的数量,继续修改配置文件, :

vim /etc/sysctl.conf 

添加下面内容:

vm.max_map_count=655360

然后执行命令:

sysctl -p
错误5: 默认设置
the default discovery settings are unsuitable for production use; at least one of [discovery.seed_hosts, discovery.seed_providers, cluster.initial_master_nodes] must be configured

修改 elasticsearch.yml

取消注释保留一个节点

cluster.initial_master_nodes: ["node-1"]

并将node.name: "node-1"的注释打开

重启终端

所有错误修改完毕,一定要重启你的连接终端,否则配置无效(我这里没有重启完全ok)。

sysctl -p

启动

可以看到绑定了两个端口:

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

在浏览器中访问:http://172.16.145.141:9200

安装Kibana

什么是kibana?

Kibana是一个基于Node.js的Elasticsearch索引数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。

而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示,非常有利于我们学习Elasticsearch的语法。

安装

因为Kibana依赖于node,我们的虚拟机没有安装node,而开发系统中安装过。所以我们选择在开发系统下使用kibana。

与elasticsearch保持一致,也是6.3.0

下载地址:https://www.elastic.co/cn/downloads/kibana

解压到特定目录即可

运行配置

配置

进入安装目录下的config目录,修改kibana.yml文件:

修改elasticsearch服务器的地址:

elasticsearch.hosts: ["http://172.16.145.141:9200"]

运行

进入安装目录下的bin目录:

双击运行:

发现kibana的监听端口是5601

我们访问:http://127.0.0.1:5601

控制台

选择左侧的DevTools菜单,即可进入控制台页面:

在页面右侧,我们就可以输入请求,访问Elasticsearch了。

安装ik分词器

Lucene的IK分词器早在2012年已经没有维护了,现在我们要使用的是在其基础上维护升级的版本,并且开发为ElasticSearch的集成插件了,与Elasticsearch一起维护升级,版本也保持一致

下载地址:https://github.com/medcl/elasticsearch-analysis-ik/releases

安装

将下载后的压缩包上传的elasticsearch目录的plugins目录下

使用unzip命令解压:

unzip elasticsearch-analysis-ik-7.5.0.zip -d ik-analyzer

将压缩包删除

rm -rf elasticsearch-analysis-ik-7.5.0.zip

然后重启elasticsearch:

测试

大家先不管语法,我们先测试一波。

在kibana控制台输入下面的请求:

POST _analyze
{
  "analyzer": "ik_max_word",
  "text":     "我是中国人"
}

运行得到结果:

{
  "tokens": [
    {
      "token": "我",
      "start_offset": 0,
      "end_offset": 1,
      "type": "CN_CHAR",
      "position": 0
    },
    {
      "token": "是",
      "start_offset": 1,
      "end_offset": 2,
      "type": "CN_CHAR",
      "position": 1
    },
    {
      "token": "中国人",
      "start_offset": 2,
      "end_offset": 5,
      "type": "CN_WORD",
      "position": 2
    },
    {
      "token": "中国",
      "start_offset": 2,
      "end_offset": 4,
      "type": "CN_WORD",
      "position": 3
    },
    {
      "token": "国人",
      "start_offset": 3,
      "end_offset": 5,
      "type": "CN_WORD",
      "position": 4
    }
  ]
}

API

Elasticsearch提供了Rest风格的API,即http请求接口,而且也提供了各种语言的客户端API

Rest风格的API

文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html

客户端API

Elasticsearch支持的客户端非常多:https://www.elastic.co/guide/en/elasticsearch/client/index.html

点击Java Rest Client后,你会发现又有两个:

Low Level Rest Client是低级别封装,提供一些基础功能,但更灵活

High Level Rest Client,是在Low Level Rest Client基础上进行的高级别封装,功能更丰富和完善,而且API会变的简单

如何学习

建议先学习Rest风格API,了解发起请求的底层实现,请求体格式等。

操作索引

基本概念

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

对比关系:

索引(indices)--------------------------------Databases 数据库

  类型(type)-----------------------------Table 数据表

     文档(Document)----------------Row 行

       字段(Field)-------------------Columns 列 

详细说明:

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

是不是与Lucene和solr中的概念类似。

另外,在SolrCloud中,有一些集群相关的概念,在Elasticsearch也有类似的:

  • 索引集(Indices,index的复数):逻辑上的完整索引 collection1
  • 分片(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:副本数量
测试

我们先用postman来试试

可以看到索引创建成功了。

使用

kibana的控制台,可以对http请求进行简化,示例:

相当于是省去了elasticsearch的服务器地址

而且还有语法提示,非常舒服。

查看索引

语法

Get请求可以帮我们查看索引信息,格式:

GET /索引名

或者,我们可以使用*来查询所有索引配置:

删除索引

删除索引使用DELETE请求

语法

DELETE /索引名

示例

当然,我们也可以用HEAD请求,查看索引是否存在:

映射配置

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

什么是映射?

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

只有配置清楚,Elasticsearch才会帮我们进行索引的创建(不一定)

创建映射字段

语法

请求方式依然是PUT

PUT /索引库名/_mapping/类型名称
{
  "properties": {
    "字段名": {
      "type": "类型",
      "index": true,
      "store": true,
      "analyzer": "分词器"
    }
  }
}
  • 类型名称:就是前面讲的type的概念,类似于数据库中的不同表(已经不建议使用,7.x版本中需要配置,8.x后就会被删除)

  • 字段名:任意填写 ,可以指定许多属性,例如:
  • 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"
    }
    
  }
}

在7.0.0之后的版本,可能会报错

原因:

解决

PUT test_store/_mapping/goods?include_type_name=true
{
  "properties": {
    "title": {
      "type": "text",
      "analyzer": "ik_max_word"
    },
    "images": {
      "type": "keyword",
      "index": "false"
    },
    "price": {
      "type": "float"
    }
  }
}

可以用下面的方式替代

PUT test_store/_mapping
{
  "properties": {
    "goods": {
      "properties": {
        "title": {
          "type": "text",
          "analyzer": "ik_max_word"
        },
        "images": {
          "type": "keyword",
          "index": "false"
        },
        "price": {
          "type": "float"
        }
      }
    }
  }
}
查看映射关系

语法:

GET /索引名/_mapping

示例:

GET /test_store/_mapping

响应:

字段属性详解
type

Elasticsearch中支持的数据类型非常丰富:

我们说几个关键的:

  • String类型,又分两种:

    • text:可分词,不可参与聚合
    • keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
  • Numerical:数值类型,分两类

    • 基本数据类型:long、interger、short、byte、double、float、half_float
    • 浮点数的高精度类型:scaled_float
      • 需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
  • Date:日期类型

    elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间。

index

index影响字段的索引情况。

  • true:字段会被索引,则可以用来进行搜索。默认值就是true
  • false:字段不会被索引,不能用来搜索

index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。

但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。

store

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

在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。

但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。

原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source的属性中。而且我们可以通过过滤_source来选择哪些要显示,哪些不显示。

而如果设置store为true,就会在_source以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。

boost

激励因子,这个与lucene中一样

其它的不再一一讲解,用的不多,参考官方文档:

新增数据

随机生成id

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

语法:

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

响应:

{
  "_index" : "test_store",
  "_type" : "_doc",
  "_id" : "wa923W4BPaxfFKWjLa43",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 3,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 1,
  "_primary_term" : 1
}

通过kibana查看数据:

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

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

PUT /索引名/_doc/id值
{
    ...
}

示例:

PUT heima/goods/123
{
  "goods": {
    "title":"红米手机",
    "images":"http://image.leyou.com/12479122.jpg",
    "price":999.00
  }
}

得到的数据:

{
  "_index" : "test_store",
  "_type" : "_doc",
  "_id" : "123",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 3,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}

####智能判断

在学习Solr时我们发现,我们在新增数据时,只能使用提前配置好映射属性的字段,否则就会报错。

不过在Elasticsearch中并没有这样的规定。

事实上Elasticsearch非常智能,你不需要给索引设置任何mapping映射,它也可以根据你输入的数据来判断类型,动态添加数据映射。

测试一下:

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

我们额外添加了stock库存,和saleable是否上架两个字段。

来看结果:

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

在看下索引的映射关系:

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

stock和saleable都被成功映射了。

修改数据

请求方式为PUT,指定id

  • id对应文档存在,则修改
  • id对应文档不存在,则新增

比如,我们把id为5的数据进行修改:

POST(PUT) /heima/goods/5
{
  "title": "小米电视",
  "images": "http://image.leyou.com/154.jpg",
  "price": 1099.00,
  "saleable": true
}
{
    "_index": "heima",
    "_type": "goods",
    "_id": "5",
    "_score": 2.3491275,
    "_source": {
        "title": "小米电视",
        "images": "http://image.leyou.com/154.jpg",
        "price": 1099,
        "saleable": true
    }
},

删除数据

删除使用DELETE请求,同样,需要根据id进行删除:

语法

DELETE /索引名/_doc/id值

示例:

DELETE /heima/goods/4x3XnHEBx0JRCCpwPnSX

查询数据

我们从4块来讲查询:

  • 基本查询
  • _source过滤
  • 结果过滤
  • 高级查询
  • 排序

基本查询

基本语法

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

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

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

示例:

GET  /heima/_search
{
  "query": {
    "match_all": {}
  }
}
  • query:代表查询对象
  • match_all:代表查询所有
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 5,
    "max_score": 1,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "1",
        "_score": 1,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "2",
        "_score": 1,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      }
    ]
  }
}
  • took:查询花费时间,单位是毫秒

  • time_out:是否超时

  • _shards:分片信息

  • hits
    

    :搜索结果总览对象

    • total:搜索到的总条数

    • max_score:所有结果中文档得分的最高分

    • hits
      

      :搜索结果的文档对象数组,每个元素是一条搜索到的文档信息

      • _index:索引
      • _type:文档类型
      • _id:文档id
      • _score:文档得分
      • _source:文档的源数据
匹配查询
GET /heima/_search
{
  "query": {
    "match": {
      "title": {
        "query": "小米电视手机",
      }
    }
  }
}
  • or关系

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

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

结果:

"hits": {
    "total": 2,
    "max_score": 0.6931472,
    "hits": [
        {
            "_index": "test_store",
            "_type": "goods",
            "_id": "tmUBomQB_mwm6wH_EC1-",
            "_score": 0.6931472,
            "_source": {
                "title": "小米手机",
                "images": "http://image.leyou.com/12479122.jpg",
                "price": 2699
            }
        },
        {
            "_index": "test_store",
            "_type": "goods",
            "_id": "3",
            "_score": 0.5753642,
            "_source": {
                "title": "小米电视4A",
                "images": "http://image.leyou.com/12479122.jpg",
                "price": 3899
            }
        }
    ]
}

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

  • and关系

某些情况下,我们需要更精确查找,我们希望这个关系变成and,可以这样做:

GET /heima/_search
{
  "query": {
    "match": {
      "goods.title": {
        "query": "小米电视",
        "operator": "and"
      }
    }
  }
}

结果:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 3,
    "successful": 3,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 0.5753642,
    "hits": [
      {
        "_index": "test_store",
        "_type": "goods",
        "_id": "3",
        "_score": 0.5753642,
        "_source": {
          "title": "小米电视4A",
          "images": "http://image.leyou.com/12479122.jpg",
          "price": 3899
        }
      }
    ]
  }
}

本例中,只有同时包含小米电视的词条才会被搜索到。

  • or和and之间?

orand 间二选一有点过于非黑即白。 如果用户给定的条件分词后有 5 个查询词项,想查找只包含其中 4 个词的文档,该如何处理?将 operator 操作符参数设置成 and 只会将此文档排除。

有时候这正是我们期望的,但在全文搜索的大多数应用场景下,我们既想包含那些可能相关的文档,同时又排除那些不太相关的。换句话说,我们想要处于中间某种结果。

match 查询支持 minimum_should_match 最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:

GET /heima/_search
{
  "query": {
    "match": {
      "goods.title": {
        "query": "小米曲面电视",
        "minimum_should_match": "75%"
      }
    }
  }
}

本例中,搜索语句可以分为3个词,如果使用and关系,需要同时满足3个词才会被搜索到。这里我们采用最小品牌数:75%,那么也就是说只要匹配到总词条数量的75%即可,这里3*75% 约等于2。所以只要包含2个词条就算满足条件了。

结果:

多字段查询(multi_match)

multi_matchmatch类似,不同的是它可以在多个字段中查询

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

新增一条记录

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

本例中,我们会在title字段和subtitle字段中查询小米这个词

词条匹配(term)

term 查询被用于精确值 匹配,这些精确值可能是数字、时间、布尔或者那些未分词的字符串

GET  heima/_search
{
  "query": {
    "term": {
      "title": {
        "value": "小米"
      }
    }
  }
}

结果:

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.0630728,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 1.0630728,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 1.0630728,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      }
    ]
  }
}
多词条精确匹配(terms)

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

GET /heima/_search
{
  "query": {
    "terms": {
      "title": [
        "小米",
        "华为"
      ]
    }
  } 
}

结果:

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 3,
    "max_score": 1,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 1,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 1,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "6",
        "_score": 1,
        "_source": {
          "title": "华为手机",
          "subtitle": "小米",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      }
    ]
  }
}{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.1631508,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 1.1631508,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 1.1631508,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      }
    ]
  }
}

结果过滤

默认情况下,elasticsearch在搜索的结果中,会把文档中保存在_source的所有字段都返回。

如果我们只想获取其中的部分字段,我们可以添加_source的过滤

直接指定字段

示例:

GET /heima/_search
{
  "_source": ["title"], 
  "query": {
    "terms": {
      "title": [
        "小米",
        "华为"
      ]
    }
  }
  
}

返回的结果:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 3,
    "max_score": 1,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 1,
        "_source": {
          "title": "小米手机"
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 1,
        "_source": {
          "title": "小米电视"
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "6",
        "_score": 1,
        "_source": {
          "title": "华为手机"
        }
      }
    ]
  }
}

指定includes和excludes

我们也可以通过:

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

二者都是可选的。

示例:

GET /heima/_search
{
  "_source": {
    "excludes": ["title"],
    "includes": ["images"]
    }, 
  "query": {
    "terms": {
      "title": [
        "小米",
        "华为"
      ]
    }
  }
}
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 3,
    "max_score": 1,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 1,
        "_source": {
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 1,
        "_source": {
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "6",
        "_score": 1,
        "_source": {
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099
        }
      }
    ]
  }
}

高级查询

布尔查询(bool)

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

GET heima/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": "小米"
          }
        },
        {
          "terms": {
            "price": [
              "1099",
              "3099"
            ]
          }
        }
      ],
      "must": [
        {
          "term": {
            "saleable": {
              "value": "true"
            }
          }
        }
      ]
    }
    
  }
}
{
  "took": 3,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 7,
    "max_score": 2.2276893,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 2.2276893,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 2.2276893,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "6",
        "_score": 1.0645385,
        "_source": {
          "title": "华为手机",
          "subtitle": "小米",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "7",
        "_score": 1.0645385,
        "_source": {
          "title": "小",
          "subtitle": "小",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "1",
        "_score": 0.06453852,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "2",
        "_score": 0.06453852,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "3",
        "_score": 0.06453852,
        "_source": {
          "title": "中米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2099,
          "saleable": true
        }
      }
    ]
  }
}
范围查询(range)

range 查询找出那些落在指定区间内的数字或者时间

# 范围查询

GET heima/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 2300,
        "lte": 4000
      }
    }
  } 
}

range查询允许以下字符:

操作符说明
gt大于
gte大于等于
lt小于
lte小于等于
模糊查询(fuzzy)

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

# 模糊查询 
GET heima/_search
{
  "query": {
    "fuzzy": {
      "title": {
        "value": "小米手"
      }
    }
  }
  
}
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 0.5815754,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 0.5815754,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 0.5815754,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      }
    ]
  }
}

我们可以通过fuzziness来指定允许的编辑距离:

# 模糊查询 
GET heima/_search
{
  "query": {
    "fuzzy": {
      "title": {
        "value": "小米手",
        "fuzziness": 2
      }
    }
  }
  
}
{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 6,
    "max_score": 0.5815754,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": 0.5815754,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "5",
        "_score": 0.5815754,
        "_source": {
          "title": "小米电视",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "1",
        "_score": 0,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "2",
        "_score": 0,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "3",
        "_score": 0,
        "_source": {
          "title": "中米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2099,
          "saleable": true
        }
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "7",
        "_score": 0,
        "_source": {
          "title": "小",
          "subtitle": "小",
          "images": "http://image.leyou.com/154.jpg",
          "price": 1099,
          "saleable": true
        }
      }
    ]
  }
}

过滤(filter)

条件查询中进行过滤

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

# filter
GET  heima/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": "手机"
          }
        }
      ],
      "filter": {
        "range": {
          "price": {
            "gte": 2699,
            "lte": 3099
          }
        }
      }
    }
  }
}

注意:filter中还可以再次进行bool组合条件过滤。

无查询条件,直接过滤

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

# filter
GET  heima/_search
{
  "query": {
    "bool": {
      "filter": {
        "range": {
          "price": {
            "gte": 2699,
            "lte": 3099
          }
        }
      }
    }
  }
}

此刻评分为0

排序

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

可以单字段排序也可以多字段排序

多字段排序如果第一个字段相同,会按照第二个字段排序

# filter和sort
GET  heima/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": "手机"
          }
        }
      ],
      "filter": {
        "range": {
          "price": {
            "gte": 2699,
            "lte": 3099
          }
        }
      }
    }
  },
  "sort": [
    {
      "price": {
        "order": "desc"
      },
      "_id": {
        "order": "asc"
      }
    }
  ]
}
{
  "took": 35,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 3,
    "max_score": null,
    "hits": [
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "4",
        "_score": null,
        "_source": {
          "title": "小米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 3099,
          "saleable": true
        },
        "sort": [
          3099,
          "4"
        ]
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "1",
        "_score": null,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        },
        "sort": [
          2799,
          "1"
        ]
      },
      {
        "_index": "heima",
        "_type": "goods",
        "_id": "2",
        "_score": null,
        "_source": {
          "title": "大米手机",
          "images": "http://image.leyou.com/154.jpg",
          "price": 2799,
          "saleable": true
        },
        "sort": [
          2799,
          "2"
        ]
      }
    ]
  }
}

聚合aggregations

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

  • 什么品牌的手机最受欢迎?
  • 这些手机的平均价格、最高价格、最低价格?
  • 这些手机每月的销售情况如何?

实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现实时搜索效果。

基本概念

Elasticsearch中的聚合,包含多种类型,最常用的两种,一个叫,一个叫度量

桶(bucket)

桶的作用,是按照某种方式对数据进行分组,每一组数据在ES中称为一个,例如我们根据国籍对人划分,可以得到中国桶英国桶日本桶……或者我们按照年龄段对人进行划分:010,1020,2030,3040等。

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:求总数
  • ……

为了测试聚合,我们先批量导入一些数据

创建索引:

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

注意:在ES中,需要进行聚合、排序、过滤的字段其处理方式比较特殊,因此不能被分词。这里我们将color和make这两个文字类型的字段设置为keyword类型,这个类型不会被分词,将来就可以参与聚合

导入数据

POST /cars/transactions/_bulk
{ "index": {}}
{ "transactions":{"price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" }}
{ "index": {}}
{ "transactions":{"price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }}
{ "index": {}}
{ "transactions":{"price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" }}
{ "index": {}}
{ "transactions":{"price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" }}
{ "index": {}}
{ "transactions":{"price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" }}
{ "index": {}}
{ "transactions":{"price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }}
{ "index": {}}
{ "transactions":{"price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" }}
{ "index": {}}
{ "transactions":{"price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" }}
聚合为桶

首先,我们按照 汽车的颜色color来划分

GET cars/_search
{
  "size": 0, 
  "aggs": {
    "popular_color": {
      "terms": {
        "field": "transactions.color.keyword"
      }
    }
  } 
}

color的类型为keyword,所以必须加上keyword,不然会报错。

  • 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_color": {
      "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_color": {
      "terms": {
        "field": "transactions.color.keyword"
      },
      "aggs": {
        "price_avg": {
          "avg": {
            "field": "transactions.price"
          }
        }
      }
    }
  }
}
  • aggs:我们在上一个aggs(popular_colors)中添加新的aggs。可见度量也是一个聚合
  • avg_price:聚合的名称
  • avg:度量的类型,这里是求平均值
  • field:度量运算的字段

结果

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 8,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "popular_color": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "red",
          "doc_count": 4,
          "price_avg": {
            "value": 32500
          }
        },
        {
          "key": "blue",
          "doc_count": 2,
          "price_avg": {
            "value": 20000
          }
        },
        {
          "key": "green",
          "doc_count": 2,
          "price_avg": {
            "value": 21000
          }
        }
      ]
    }
  }
}

可以看到每个桶中都有自己的avg_price字段,这是度量聚合的结果

桶内嵌套桶

刚刚的案例中,我们在桶内嵌套度量运算。事实上桶不仅可以嵌套运算, 还可以再嵌套其它桶。也就是说在每个分组中,再分更多组。

比如:我们想统计每种颜色的汽车中,分别属于哪个制造商,按照make字段再进行分桶

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

结果:

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 8,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "popular_color": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "red",
          "doc_count": 4,
          "maker": {
            "doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": [
              {
                "key": "honda",
                "doc_count": 3
              },
              {
                "key": "bmw",
                "doc_count": 1
              }
            ]
          },
          "price_avg": {
            "value": 32500
          }
        },
        {
          "key": "blue",
          "doc_count": 2,
          "maker": {
            "doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": [
              {
                "key": "ford",
                "doc_count": 1
              },
              {
                "key": "toyota",
                "doc_count": 1
              }
            ]
          },
          "price_avg": {
            "value": 20000
          }
        },
        {
          "key": "green",
          "doc_count": 2,
          "maker": {
            "doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": [
              {
                "key": "ford",
                "doc_count": 1
              },
              {
                "key": "toyota",
                "doc_count": 1
              }
            ]
          },
          "price_avg": {
            "value": 21000
          }
        }
      ]
    }
  }
}
  • 我们可以看到,新的聚合maker被嵌套在原来每一个color的桶中。
  • 每个颜色下面都根据 make字段进行了分组
  • 我们能读取到的信息:
    • 红色车共有4辆
    • 红色车的平均售价是 $32,500 美元。
    • 其中3辆是 Honda 本田制造,1辆是 BMW 宝马制造。

划分桶的其他几种方式

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

  • Date Histogram Aggregation:根据日期阶梯分组,例如给定阶梯为周,会自动每周分为一组
  • Histogram Aggregation:根据数值阶梯分组,与日期类似
  • Terms Aggregation:根据词条内容分组,词条内容完全匹配的为一组
  • Range Aggregation:数值和日期的范围分组,指定开始和结束,然后按段分组

刚刚的案例中,我们采用的是Terms Aggregation,即根据词条划分桶。

接下来,我们再学习几个比较实用的:

阶梯分桶Histogram

原理:

histogram是把数值类型的字段,按照一定的阶梯大小进行分组。你需要指定一个阶梯值(interval)来划分阶梯大小。

举例:

比如你有价格字段,如果你设定interval的值为200,那么阶梯就会是这样的:

0,200,400,600,…

上面列出的是每个阶梯的key,也是区间的启点。

如果一件商品的价格是450,会落入哪个阶梯区间呢?计算公式如下:

bucket_key = Math.floor((value - offset) / interval) * interval + offset

value:就是当前数据的值,本例中是450

offset:起始偏移量,默认为0

interval:阶梯间隔,比如200

因此你得到的key = Math.floor((450 - 0) / 200) * 200 + 0 = 400

操作一下:

比如,我们对汽车的价格进行分组,指定间隔interval为5000:

GET /cars/_search
{
  "size": 0,
  "aggs": {
    "price_histogram": {
      "histogram": {
        "field": "transactions.price",
        "interval": 5000,
        "min_doc_count": 1
      }
    }
  } 
}

结果:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 8,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "price_histogram": {
      "buckets": [
        {
          "key": 10000,
          "doc_count": 2
        },
        {
          "key": 15000,
          "doc_count": 1
        },
        {
          "key": 20000,
          "doc_count": 2
        },
        {
          "key": 25000,
          "doc_count": 1
        },
        {
          "key": 30000,
          "doc_count": 1
        },
        {
          "key": 80000,
          "doc_count": 1
        }
      ]
    }
  }
}

所有的doc_cout都大于1,因为我们增加一个参数min_doc_count为1,来约束最少文档数量为1,这样文档数量为0的桶会被过滤

范围分桶range

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

GET cars/_search
{
  "size": 0,
  "aggs": {
    "price_range": {
      "range": {
        "field": "transactions.price",
        "ranges": [
          {
            "from": 50000,
            "to": 100000
          },
          {
            "from": 5000,
            "to": 50000
          }
        ]
      }
    }
  } 
}

结果:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 8,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "price_range": {
      "buckets": [
        {
          "key": "5000.0-50000.0",
          "from": 5000,
          "to": 50000,
          "doc_count": 7
        },
        {
          "key": "50000.0-100000.0",
          "from": 50000,
          "to": 100000,
          "doc_count": 1
        }
      ]
    }
  }
}

SpringData Elasticsearch

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

  • 很多地方需要拼接Json字符串,在java中拼接字符串有多恐怖你应该懂的
  • 需要自己把对象序列化为json存储
  • 查询到结果也需要自己反序列化为对象

因此,直接学习Spring提供的套件:Spring Data Elasticsearch。

简介

Spring Data Elasticsearch是Spring Data项目下的一个子模块。

查看 Spring Data的官网:http://projects.spring.io/spring-data/

Spring Data的使命是为数据访问提供熟悉且一致的基于Spring的编程模型,同时仍保留底层数据存储的特殊特性。

它使得使用数据访问技术,关系数据库和非关系数据库,map-reduce框架和基于云的数据服务变得容易。这是一个总括项目,其中包含许多特定于给定数据库的子项目。这些令人兴奋的技术项目背后,是由许多公司和开发人员合作开发的。

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

包含很多不同数据操作的模块:

Spring Data Elasticsearch的页面:https://projects.spring.io/spring-data-elasticsearch/

特征:

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

创建工程

我们使用spring脚手架新建一个demo,学习Elasticsearch

配置文件

application.yml文件配置,使用ElasticsearchRestTemplate如下配置:

spring:
  elasticsearch:
    rest:
      uris:
        - 172.16.145.141:9200

使用ElasticsearchTemplate如下配置

spring:
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 172.16.145.141:9300

实体类和注解

package top.codekiller.test.pojo;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "item",type = "docs",shards = 1,replicas = 0)
public class Item {
    @Id
    Long id;
    @Field(type = FieldType.Text,analyzer = "ik_max_word")
    String title; //标题
    @Field(type = FieldType.Keyword)
    String category;// 分类
    @Field(type = FieldType.Keyword)
    String brand; // 品牌
    @Field(type = FieldType.Double)
    Double price; // 价格
    @Field(type = FieldType.Keyword,index = false)
    String images; // 图片地址
}

映射

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

  • @Document

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

    • indexName:对应索引名称
    • type:对应在索引中的类型
    • shards:分片数量,默认5
    • replicas:副本数量,默认1
  • @Id 作用在成员变量,标记一个字段作为id主键

  • @Field

    作用在成员变量,标记为文档的字段,并指定字段映射属性:

    • type:字段类型,取值是枚举:FieldType
    • index:是否索引,布尔类型,默认是true
    • store:是否存储,布尔类型,默认是false
    • analyzer:分词器名称:ik_max_word

Template索引操作

创建索引

ElasticsearchTemplate中提供了创建索引的API:

@SpringBootTest
public class ElasticsearchTest {

    @Autowired
    private ElasticsearchTemplate elasticsearchTemplate;

    @Test
    public void testIndex() throws Exception{
        // 创建索引,会根据Item类的@Document注解信息来创建
        this.elasticsearchTemplate.createIndex(Item.class);
        // 配置映射,会根据Item类中的id、Field等字段来自动完成映射
        this.elasticsearchTemplate.putMapping(Item.class);
    }
}

Repository文档操作

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

我们只需要定义接口,然后继承它就OK了。

public interface ItemRepository extends ElasticsearchRepository<Item,Long> {
}
新增文档
@Autowired
private ItemRepository itemRepository;

/**
     * 新增数据
     */
@Test
public void testCreate(){
    //新增单个
    Item item = new Item(1L, "小米手机7", "手机",
                         "小米", 3499.00, "http://image.leyou.com/13123.jpg");
    this.itemRepository.save(item);

    //新增多个
    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 testUpdateDocument() throws Exception {
    //将title:小米手机改为 黑米手机666
    Item item = new Item(1L, "黑米手机666", "手机",
            "小米", 3499.00, "http://image.leyou.com/13123.jpg");
    itemRepository.save(item);
}
基本查询

ElasticsearchRepository提供了一些基本的查询方法:

我们来试试查询所有:

/**
* 查询数据
*/
@Test
public void testQuery(){
    //根据id查询单条
    Optional<Item> item=this.itemRepository.findById(1L);
    System.out.println(item);

    //查询多条,并按price降序排序
    Iterable<Item> items=this.itemRepository.findAll(Sort.by("price").descending());
    items.forEach(System.out::println);

}
}

自定义方法

Spring Data 的另一个强大功能,是根据方法名称自动实现功能。

比如:你的方法名叫做:findByTitle,那么它就知道你是根据title查询,然后自动帮你完成,无需写实现类。

当然,方法名称要符合一定的约定:

KeywordSampleElasticsearch Query String
AndfindByNameAndPrice{"bool" : {"must" : [ {"field" : {"name" : "?"}}, {"field" : {"price" : "?"}} ]}}
OrfindByNameOrPrice{"bool" : {"should" : [ {"field" : {"name" : "?"}}, {"field" : {"price" : "?"}} ]}}
IsfindByName{"bool" : {"must" : {"field" : {"name" : "?"}}}}
NotfindByNameNot{"bool" : {"must_not" : {"field" : {"name" : "?"}}}}
BetweenfindByPriceBetween{"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : ?,"include_lower" : true,"include_upper" : true}}}}}
LessThanEqualfindByPriceLessThan{"bool" : {"must" : {"range" : {"price" : {"from" : null,"to" : ?,"include_lower" : true,"include_upper" : true}}}}}
GreaterThanEqualfindByPriceGreaterThan{"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : null,"include_lower" : true,"include_upper" : true}}}}}
BeforefindByPriceBefore{"bool" : {"must" : {"range" : {"price" : {"from" : null,"to" : ?,"include_lower" : true,"include_upper" : true}}}}}
AfterfindByPriceAfter{"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : null,"include_lower" : true,"include_upper" : true}}}}}
LikefindByNameLike{"bool" : {"must" : {"field" : {"name" : {"query" : "?*","analyze_wildcard" : true}}}}}
StartingWithfindByNameStartingWith{"bool" : {"must" : {"field" : {"name" : {"query" : "?*","analyze_wildcard" : true}}}}}
EndingWithfindByNameEndingWith{"bool" : {"must" : {"field" : {"name" : {"query" : "*?","analyze_wildcard" : true}}}}}
Contains/ContainingfindByNameContaining{"bool" : {"must" : {"field" : {"name" : {"query" : "**?**","analyze_wildcard" : true}}}}}
InfindByNameIn(Collectionnames){"bool" : {"must" : {"bool" : {"should" : [ {"field" : {"name" : "?"}}, {"field" : {"name" : "?"}} ]}}}}
NotInfindByNameNotIn(Collectionnames){"bool" : {"must_not" : {"bool" : {"should" : {"field" : {"name" : "?"}}}}}}
NearfindByStoreNearNot Supported Yet !
TruefindByAvailableTrue{"bool" : {"must" : {"field" : {"available" : true}}}}
FalsefindByAvailableFalse{"bool" : {"must" : {"field" : {"available" : false}}}}
OrderByfindByAvailableTrueOrderByNameDesc{"sort" : [{ "name" : {"order" : "desc"} }],"bool" : {"must" : {"field" : {"available" : true}}}}
public interface ItemRepository extends ElasticsearchRepository<Item,Long> {

    /**
     * 按照title进行match查询
     * @param title
     * @return
     */
    List<Item> findByTitle(String title);

    /**
     * 按照price进行range查询
     * @param d1
     * @param d2
     * @return
     */
    List<Item> findByPriceBetween(Double d1,Double d2);
}

测试方法

    /**
     * 测试自定义方法
     */
@Test
public void testFindByTitle(){
    List<Item> items = this.itemRepository.findByTitle("手机");
    items.forEach(System.out::println);

    System.out.println("---------------------------------");

    List<Item> items2=this.itemRepository.findByPriceBetween(3699.0,4499.0);
    items2.forEach(System.out::println);
}

结果:

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

高级查询

基本查询
    /**
     * 高级查询
     */
@Test
public void testSearch(){
    //通过查询构建器工具构建查询条件
    MatchQueryBuilder queryBuilder = QueryBuilders.matchQuery("title", "手机");
    Iterable<Item> items = this.itemRepository.search(queryBuilder);
    items.forEach(System.out::println);

}

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

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

elasticsearch提供很多可用的查询方式,但是不够灵活。如果想玩过滤或者聚合查询等就很难了。

自定义查询
    /**
     * 高级查询-自定义查询
     */
@Test
public void testNative(){
    //构建自定义查询构建器
    NativeSearchQueryBuilder builder=new NativeSearchQueryBuilder();

    //添加基本的查询条件
    builder.withQuery(QueryBuilders.matchQuery("title","手机"));

    //添加排序
    builder.withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC));

    //添加分页条件,page:页码(从0开始),size:每页显示条目数
    builder.withPageable(PageRequest.of(2-1,1));

    //执行查询,获取分页结果集
    Page<Item> page = this.itemRepository.search(builder.build());
    System.out.println("总页数:" + page.getTotalPages());
    System.out.println("总条数:" + page.getTotalElements());
    System.out.println("每页显示条数:" + page.getSize());
    System.out.println("当前页码:" + page.getNumber());
    page.getContent().forEach(System.out::println);

}

结果:

NativeSearchQueryBuilder:Spring提供的一个查询条件构建器,帮助构建json格式的请求体

Page:默认是分页查询,因此返回的是一个分页的结果对象,包含属性:

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

聚合

聚合为桶

桶就是分组,比如这里我们按照品牌brand进行分组:

这里ElasticsearchTemplateElasticsearchRestTemplate有点不一样

使用ElasticsearchTemplate如下获取

@Test
public void testAggs(){
    //初始化自定义查询构建器
    NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();
    //添加聚合
    queryBuilder.addAggregation(AggregationBuilders.terms("brandAgg").field("brand")
                                .subAggregation(AggregationBuilders.avg("price_avg").field("price")));
    //添加结果集过滤,不包含任何字段
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{},null));
    //执行聚合查询
    AggregatedPage<Item>  itemPage= (AggregatedPage<Item>) this.itemRepository.search(queryBuilder.build());
    //解析聚合结果集.从结果中取出名为brands的那个聚合,因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型
    StringTerms brandAgg= (StringTerms)itemPage.getAggregation("brandAgg");
    List<StringTerms.Bucket> buckets = brandAgg.getBuckets();
    buckets.forEach(bucket -> {
        System.out.println(bucket.getKeyAsString());
        System.out.println(bucket.getDocCount());
        Map<String, Aggregation> subAggregation = bucket.getAggregations().asMap();
        InternalAvg avg =(InternalAvg)subAggregation.get("price_avg");
        System.out.println(avg.getValue());
    });
}

使用ElasticsearchRestTemplate

@Test
public void testNativeQueryAggregation() throws Exception {
    // 构建查询条件
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();

    // 不查询任何字段
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{}, null));

    //添加一个新的聚合,聚合类型为terms,聚合名称为brands,聚合字段为brand
    queryBuilder.addAggregation(AggregationBuilders.terms("brandAgg").field("brand"));

    //执行查询
    Page<Item> pageResult = this.itemRepository.search(queryBuilder.build());

    //把结果强转为AggregatedPage类型
    AggregatedPage<Item> aggregatedPageResult = (AggregatedPage<Item>) pageResult;

    //从结果中取出名为brands的那个聚合,因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型
    ParsedStringTerms parsedStringTerms = (ParsedStringTerms) aggregatedPageResult.getAggregation("brandAgg");
    //获取桶
    List<ParsedStringTerms.ParsedBucket> buckets = (List<ParsedStringTerms.ParsedBucket>) parsedStringTerms.getBuckets();

    for (ParsedStringTerms.ParsedBucket bucket : buckets) {
        //获取桶中的key,即品牌名称
        System.out.println(bucket.getKeyAsString());
        //获取桶中的文档数量
        System.out.println(bucket.getDocCount());
    }
}

关键API:

  • AggregationBuilders:聚合的构建工厂类。所有聚合都由这个类来构建,看看他的静态方法:

AggregatedPage:聚合查询的结果类。它是Page的子接口:

AggregatedPagePage功能的基础上,拓展了与聚合相关的功能,它其实就是对聚合结果的一种封装,大家可以对照聚合结果的JSON结构来看。

而返回的结果都是Aggregation类型对象,不过根据字段类型不同,又有不同的子类表示

我们看下页面的查询的JSON结果与Java类的对照关系:

基本商品搜索

创建搜索服务

创建module:leyou-search

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>leyou-search</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</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-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>top.codekiller.leyou</groupId>
            <artifactId>leyou-common</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

application

server:
  port: 8083

spring:
  application:
    name: serach-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.0.1:9300

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    registry-fetch-interval-seconds: 10
  instance:
    lease-renewal-interval-in-seconds: 5
    lease-expiration-duration-in-seconds: 15

引导类:

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LeyouSearchApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouSearchApplication.class, args);
    }

}

索引库数据格式分析

接下来,我们需要商品数据导入索引库,便于用户搜索。

那么问题来了,我们有SPU和SKU,到底如何保存到索引库?

以结果为导向

来看下京东的搜索结果页:

在这里插入图片描述

可以看到,每一个搜索结果都有至少1个商品,当我们选择大图下方的小图,商品会跟着变化。

因此,搜索的结果是SPU,即多个SKU的集合

既然搜索的结果是SPU,那么我们索引库中存储的应该也是SPU,但是却需要包含SKU的信息。

需要什么数据

再来看看页面中有什么数据:

直观能看到的:图片、价格、标题、副标题

暗藏的数据:spu的id,sku的id

另外,页面还有过滤条件:

这些过滤条件也都需要存储到索引库中,包括:

商品分类、品牌、可用来搜索的规格参数等

综上所述,我们需要的数据格式有:

spuId、SkuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数

最终的数据结构

我们创建一个类,封装要保存到索引库的数据,并设置映射属性:

@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "goods",type="docs",replicas = 0,shards = 1)
public class Goods {
    /**
     * spuid
     */
    @Id
    private Long id;
    /**
     * 所有需要搜索的信息,包括标题,分类,品牌
     */
    @Field(type = FieldType.Text,analyzer = "ik_max_word")
    private String all;
    /**
     * 卖点
     */
    @Field(type=FieldType.Keyword,index = false)
    private String subTitle;
    /**
     * 品牌id
     */
    private Long brandId;
    /**
     * 一级分类
     */
    private Long cid1;
    /**
     * 二级分类
     */
    private Long cid2;
    /**
     * 三级分类
     */
    private Long cid3;
    /**
     * 创建时间
     */
    private Date createTime;
    /**
     * 价格集合
     */
    private List<Long> price;
    /**
     * List<sku>信息的json结构
     */
    @Field(type=FieldType.Keyword,index=false)
    private String skus;
    /**
     * 可搜索的规格参数,key是参数名,值是参数值
     */
    private Map<String,Object> specs;
}

一些特殊字段解释:

  • 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信息(Spu)
  • SKU信息(Sku)
  • SPU的详情 (SpuDetail)
  • 商品分类名称(拼接all字段)(Category)
  • 品牌名称 (Brand)
  • 规格参数(SpuDetail.genericSpec,Sku.ownSpec)

再思考我们需要哪些服务:

  • 第一:分页查询spu的服务,已有。
  • 第二:根据spuId查询sku的服务,已有
  • 第三:根据spuId查询SpuDetail的服务,已有
  • 第四:根据商品分类id,查询商品分类名称,
  • 第五:根据商品品牌id,查询商品的品牌,
  • 第六:规格参数接口 已有

因此我们需要额外提供一个查询商品分类名称的接口。

商品分类名称查询

Controller

在CategoryController中添加接口:

/**
     * 通过id的集合获取分类名称的集合
     * @param ids
     * @return
     */
    @GetMapping
    public ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids){
        List<String> names = this.categoryService.queryNamesByIds(ids);
        if(CollectionUtils.isEmpty(names)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(names);

    }

service

@Override
public List<String> queryNamesByIds(List<Long> ids) {
    List<Category> categories=this.categoryMapper.selectBatchIds(ids);
    return categories.stream().map(category -> category.getName()).collect(Collectors.toList());
}
商品品牌查询

controller

/**
     * 通过id获取brand
     * @param id
     * @return
     */
@GetMapping("{id}")
public ResponseEntity<Brand> queryBrandById(@PathVariable("id") Long id){
    Brand brand=this.brandService.queryBrandById();
    if(brand==null){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(brand);
}

service

@Override
public Brand queryBrandById(Long id) {
    return this.brandMapper.selectById(id);
}
编写FeignClient
问题展现

操作leyou-search工程

现在,我们要在搜索微服务调用商品微服务的接口。

第一步要在leyou-search工程中,引入商品微服务依赖:leyou-item-interface

<!--商品微服务-->
<dependency>
    <groupId>com.leyou.item</groupId>
    <artifactId>leyou-item-interface</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>leyou-common</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

配置文件

server:
  port: 8083

spring:
  application:
    name: serach-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 39.97.180.158:9300
  main:
    allow-bean-definition-overriding: true

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    registry-fetch-interval-seconds: 10
  instance:
    lease-renewal-interval-in-seconds: 5
    lease-expiration-duration-in-seconds: 15

ribbon:
  ConnectTimeout: 50000 #请求连接的超时时间,默认时间为1秒
  ReadTimeout: 50000 #请求处理的超时时间
编写API
  • 我们的服务提供方不仅提供实体类,还要提供api接口声明
  • 调用方不用自己编写接口方法声明,直接继承提供方给的Api接口即可,

第一步:服务的提供方在leyou-item-interface中提供API接口,并编写接口声明:

商品分类服务接口:

返回值不再使用ResponseEntity:

GoodsApi

public interface GoodsApi {
    /**
     * 根据spu的id查询SpuDetail
     * @param spuId
     * @return
     */
    @GetMapping("spu/detail/{spuId}")
    SpuDetail querySpuDetailBySpuId(@PathVariable("spuId") Long spuId);

    /**
     * 根据分页条件查询spu
     * @param key 关键字
     * @param saleable 上架/下架
     * @param page 当前页码
     * @param rows 当前页行
     * @return
     */
    @GetMapping("spu/page")
    PageResult<Spu> querySpuByPage(@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);

    /**
     * 根据spu的id查询sku的集合
     * @param spuId
     * @return
     */
    @GetMapping("sku/list")
    List<Sku> querySkuBySpuId(@RequestParam("id") Long spuId);


}

BrandApi

public interface BrandApi {

    /**
     * 通过id获取brand
     * @param id
     * @return
     */
    @GetMapping("brand/{id}")
    Brand queryBrandById(@PathVariable("id") Long id);
}

CategoryApi

public interface CategoryApi {

    /**
     * 通过id的集合获取分类名称的集合
     * @param ids
     * @return
     */
    @GetMapping("category")
    List<String> queryNameByIds(@RequestParam("ids") List<Long> ids);
}

SpecificationApi

public interface SpecificationApi {

    /**
     * 根据条件查询规格参数
     * @param cid 分类id
     * @param gid 组id
     * @param generic 是否是通用参数
     * @param searching 是否是特殊参数
     * @return
     */
    @GetMapping("spec/params")
    public ResponseEntity<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);

    }
}

第二步:在调用方leyou-search中编写FeignClient,但不要写方法声明了,直接继承leyou-item-interface提供的api接口:

商品的FeignClient:

@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}

商品分类的FeignClient:

@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
}

品牌的FeignClient:

@FeignClient("item-service")
public interface BrandClient extends BrandApi {
}

规格参数的FeignClient:

@FeignClient("item-service")
public interface SpecificationClient extends SpecificationApi {
}

是不是简单多了?

导入数据

创建GoodsRepository

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
创建索引并导入数据

导入数据其实就是查询数据,然后把查询到的Spu转变为Goods来保存,因此我们先编写一个SearchService,然后在里面定义一个方法, 把Spu转为Goods

package top.codekiller.leyou.search.service.impl;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.discovery.converters.Auto;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.pojo.*;
import top.codekiller.leyou.search.client.BrandClient;
import top.codekiller.leyou.search.client.CategoryClient;
import top.codekiller.leyou.search.client.GoodsClient;
import top.codekiller.leyou.search.client.SpecificationClient;
import top.codekiller.leyou.search.pojo.Goods;
import top.codekiller.leyou.search.service.ISearchService;

import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Service
@Slf4j
public class SearchService  implements ISearchService {

    @Autowired
    private CategoryClient categoryClient;

    @Autowired
    private GoodsClient goodsClient;

    @Autowired
    private BrandClient brandClient;

    @Autowired
    private SpecificationClient specificationClient;

    private static final ObjectMapper MAPPER=new ObjectMapper();

    @Override
    public Goods buildGoods(Spu spu) throws IOException {
        //通过分类的id查询分类名称
        List<String> names=this.categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3()));

        //根据品牌id查询品牌
        Brand brand=this.brandClient.queryBrandById(spu.getBrandId());

        //根据spuId查询所有的sku
        List<Sku> skus = this.goodsClient.querySkuBySpuId(spu.getId());
        //初始化一个价格集合
        List<Long> prices=skus.stream().map(sku-> sku.getPrice()).collect(Collectors.toList());
        //收集sku的必要字段信息
        List<Map<String,Object>> skuMapList=new ArrayList<>();
        skus.forEach(sku->{
            Map<String,Object> map=new HashMap<>();
            map.put("id",sku.getId());
            map.put("title",sku.getTitle());
            map.put("price",sku.getPrice());
            //获取sku中的图片,数据库的图片可能是多张,多张以“,”分割,返回图片数组,我们获取第一张图片
            map.put("image",StringUtils.isBlank(sku.getImages())?"":StringUtils.split(sku.getImages(),",")[0]);
            skuMapList.add(map);
        });

        //获取spu中的cid3查询出所有的搜索规格参数
        List<SpecParam> params=this.specificationClient.queryParams(null, spu.getCid3(), null, true);

        //根据spuId获取spuDetail,并且获取通用参数
        SpuDetail spuDetail=this.goodsClient.querySpuDetailBySpuId(spu.getId());
        //把通用的规格参数值,进行反序列化
        Map<String,Object> genericSpecMap=MAPPER.readValue(spuDetail.getGenericSpec(),new TypeReference<Map<String,Object>>(){});
        //把特殊的规格参数值,进行序列化
        Map<String,List<Object>> specialSpecMap=MAPPER.readValue(spuDetail.getSpecialSpec(),new TypeReference<HashMap<String,List<Object>>>(){});



        Map<String,Object> specs=new HashMap<>();
        params.forEach(param->{
            //判断规格参数的类型,是否是通用的规格参数
            if(param.getGeneric()){
                //如果是通用类型的参数,则从genericSpecMap获取规格参数值
                String value= genericSpecMap.get(param.getId().toString()).toString();
                //判断是否为数字类型,如果是数字类型,应该返回一个区间
                if(param.getNumeric()){
                    specs.put(param.getName(),this.chooseSegment(value,param));
                }
                specs.put(param.getName(),value);
            }else {
                //如果是特殊的规格参数,从specialSpecMap中获取值
                List<Object> value =specialSpecMap.get(param.getId().toString());
                specs.put(param.getName(),value);
            }

        });


        Goods goods=new Goods();
        goods.setId(spu.getId());
        goods.setCid1(spu.getCid1());
        goods.setCid2(spu.getCid2());
        goods.setCid3(spu.getCid3());
        goods.setBrandId(spu.getBrandId());
        goods.setCreateTime(spu.getCreateTime());
        goods.setSubTitle(spu.getSubTitle());

        //拼接All字段,需要分类名称和品牌名称。“ ”设置空格分割是防止不同类别连着一起而被分词
        goods.setAll(spu.getTitle()+" "+ StringUtils.join(names," ") +" "+brand.getName());

        //获取spu下的所有sku价格
        goods.setPrice(prices);

        //获取spu下的所有sku,并且转为json字符串
        String skuInfo = null;
        try {
            skuInfo = MAPPER.writeValueAsString(skuMapList);
        } catch (JsonProcessingException e) {
            log.error("sku信息转化为json异常:"+e.toString());
        }
        goods.setSkus(skuInfo);
        //获取所有查询的规格参数(name: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;
    }
}

因为过滤参数中有一类比较特殊,就是数值区间:

所以我们在存入时要进行处理:

private String chooseSegment(String value, SpecParam p) {
    double val = NumberUtils.toDouble(value);
    String result = "其它";
    // 保存数值段
    for (String segment : p.getSegments().split(",")) {
        String[] segs = segment.split("-");
        // 获取数值范围
        double begin = NumberUtils.toDouble(segs[0]);
        double end = Double.MAX_VALUE;
        if(segs.length == 2){
            end = NumberUtils.toDouble(segs[1]);
        }
        // 判断是否在范围内
        if(val >= begin && val < end){
            if(segs.length == 1){
                result = segs[0] + p.getUnit() + "以上";
            }else if(begin == 0){
                result = segs[1] + p.getUnit() + "以下";
            }else{
                result = segment + p.getUnit();
            }
            break;
        }
    }
    return result;
}

然后编写一个测试类,循环查询Spu,然后调用IndexService中的方法,把SPU变为Goods,然后写入索引库:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LeyouSearchApplication.class)
public class ElasticTest {

    @Autowired
    private ElasticsearchTemplate elasticsearchTemplate;


    @Autowired
    private GoodsRepository goodsRepository;

    @Autowired
    private SearchService searchService;

    @Autowired
    private GoodsClient goodsClient;

    @Test
    public void test(){
        //创建索引和映射
        this.elasticsearchTemplate.createIndex(Goods.class);
        this.elasticsearchTemplate.putMapping(Goods.class);

        Integer page = 1;
        Integer rows = 100;
        do {

            //分页查询spu,获取分页结果集
            PageResult<SpuBo> result = this.goodsClient.querySpuByPage(null, null, page, rows);
            //获取当前页的数据
            List<SpuBo> items = result.getItems();
            //处理
            List<Goods> goods = items.stream().map(spuBo -> {
                try {
                    return this.searchService.buildGoods(spuBo);
                } catch (IOException e) {
                    e.printStackTrace();
                    return null;
                }
            }).collect(Collectors.toList());

            this.goodsRepository.saveAll(goods);
            rows=items.size();
            page++;
        }while (rows==100);
    }
}
异常报错

这里一共出现了三个错误,上面的仅仅是其中一处

错误1:多个FeignClient的value值相同

@FeignClient(value="item-service")

解决:加上 allow-bean-definition-overriding: true

spring:
  application:
    name: serach-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.1.1:9300
  main:
    allow-bean-definition-overriding: true

错误二:提示需要数据库的url

解决:直接禁掉自动配置类就可以了

@SpringBootApplication(exclude= {DataSourceAutoConfiguration.class})

@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication(exclude= {DataSourceAutoConfiguration.class})
public class LeyouSearchApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouSearchApplication.class, args);
    }

}

错误三:响应超时,而造成异常报错

解决:修改ribbon的连接超时时间和请求处理的超时时间

ribbon:
  ConnectTimeout: 50000 #请求连接的超时时间,默认时间为1秒
  ReadTimeout: 50000 #请求处理的超时时间

实现基本搜索

页面分析
页面跳转

在首页的顶部,有一个输入框:

当我们输入任何文本,点击搜索,就会跳转到搜索页search.html了:

并且将搜索关键字以请求参数携带过来:

我们打开search.html,在最下面会有提前定义好的Vue实例:

<script type="text/javascript">
    var vm = new Vue({
        el: "#searchApp",
        data: {
        },
        components:{
            // 加载页面顶部组件
            lyTop: () => import("./js/pages/top.js")
        }
    });
</script>

这个Vue实例中,通过import导入的方式,加载了另外一个js:top.js并作为一个局部组件。top其实是页面顶部导航组件,我们暂时不管

发起异步请求

要想在页面加载后,就展示出搜索结果。我们应该在页面加载时,获取地址栏请求参数,并发起异步请求,查询后台数据,然后在页面渲染。

我们在data中定义一个对象,记录请求的参数:

data: {
    search:{
        key:"", // 搜索页面的关键字
    }
}

我们通过钩子函数created,在页面加载时获取请求参数,并记录下来。

created(){
    // 判断是否有请求参数
    if(!location.search){
        return;
    }
    // 将请求参数转为对象 eg:location.search=(?key=手机) 转为{key: "小米"}
    const search = ly.parse(location.search.substring(1));
    // 记录在data的search对象中
    this.search = search;

    // 发起请求,根据条件搜索
    this.loadData();
}

然后发起请求,搜索数据。

methods: {
    loadData(){
        ly.http.post("/search/page", this.search).then(resp=>{
            console.log(resp);
        });
    }
}
  • 我们这里使用ly是common.js中定义的工具对象。
  • 这里使用的是post请求,这样可以携带更多参数,并且以json格式发送
<script type="text/javascript">
    var vm = new Vue({
        el: "#searchApp",
        data: {
            search: {
                key: "", // 搜索页面的关键字
            },
            goodList: []
        },
        created(){
            // 判断是否有请求参数
            if(!location.search){
                return;
            }
            // 将请求参数转为对象 eg:location.search=(?key=手机) 转为{key: "小米"}
            const search = ly.parse(location.search.substring(1));
            // 记录在data的search对象中
            this.search = search;

            // 发起请求,根据条件搜索
            this.loadData();
        },
        methods: {
            loadData(){
                alert("dsa");
                ly.http.post("/search/page",this.search).then(res=>{
                    alert("das");
                    console.log(res);
                }).catch(()=>{

                })
            }
        },
        components:{
            lyTop: () => import("./js/pages/top.js")
        }
    });
</script>

在leyou-gateway中的CORS配置类中,添加允许信任域名:

并在leyou-gateway工程的Application.yml中添加网关映射:

刷新页面试试:

因为后台没有提供接口,所以无法访问。没关系,接下来我们实现后台接口

后台代码
controller

首先分析几个问题:

  • 请求方式:Post
  • 请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询
  • 请求参数:json格式,目前只有一个属性:key-搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:
package top.codekiller.leyou.search.pojo;

public class SearchRequest {
    private String key;// 搜索条件

    private Integer page;// 当前页

    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;
    }
}
  • 返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类

代码:

@Controller
public class SearchController {

    @Autowired
    private ISearchService searchService;

    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request){
        PageResult<Goods> result=this.searchService.search(request);
        if(result==null|| CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);
    }
}
service

@Override
public PageResult<Goods> search(SearchRequest request) {
    if(StringUtils.isBlank(request.getKey())) return null;

    //自定义查询构建器
    NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();
    //添加查询条件
    queryBuilder.withQuery(QueryBuilders.matchQuery("all",request.getKey()).operator(Operator.AND));
    //添加分页,页码从0开始
    queryBuilder.withPageable(PageRequest.of(request.getPage()-1,request.getSize()));
    //添加结果集过滤
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"},null));

    //执行查询,获取结果集
    Page<Goods> goodsPage=this.goodsRepository.search(queryBuilder.build());
    return new PageResult<Goods>(goodsPage.getTotalElements(),goodsPage.getTotalPages(),goodsPage.getContent());

}

注意点:我们要设置SourceFilter,来选择要返回的结果,否则返回一堆没用的数据,影响查询效率。

测试

数据是查到了,但是因为我们只查询部分字段,所以结果json 数据中有很多null,这很不优雅。

解决办法很简单,在leyou-search的application.yml中添加一行配置,json处理时忽略空值:

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

页面渲染

页面已经拿到了结果,接下来就要渲染样式了。

保存搜索结果

首先,在data中定义属性,保存搜索的结果:

loadData的异步查询中,将结果赋值给goodsList

循环展示商品

在search.html的中部,有一个div,用来展示所有搜索到的商品:

可以看到,div中有一个无序列表ul,内部的每一个li就是一个商品spu了。

我们删除多余的,只保留一个li,然后利用vue的循环来展示搜索到的结果:

多sku展示

接下来展示具体的商品信息,来看图:

这里我们可以发现,一个商品位置,是多个sku的信息集合。当用户鼠标选择某个sku,对应的图片、价格、标题会随之改变!

我们先来实现sku的选择,才能去展示不同sku的数据。

可以看到,在列表中默认第一个是被选中的,那我们就需要做两件事情:

  • 在搜索到数据时,先默认把第一个sku作为被选中的,记录下来
  • 记录当前被选中的是哪一个sku,记录在哪里比较合适呢?显然是遍历到的goods对象自己内部,因为每一个goods都会有自己的sku信息。
初始化sku

查询出的结果集skus是一个json类型的字符串,不是js对象

我们在查询成功的回调函数中,对goods进行遍历,把skus转化成json对象集合,并添加一个selected属性保存被选中的sku:

多sku图片展示

看到又是一个无序列表,这里我们也一样删掉多余的,保留一个li,需要注意选中的项有一个样式类:selected

我们的代码:

<!--多sku图片列表-->
<ul class="skus">
    <li :class="{selected: sku.id == goods.selected.id}" v-for="sku in goods.skus" :key="sku.id"
        @mouseOver="goods.selected=sku">
        <img :src="sku.image">
    </li>
</ul>

注意:

  • class样式通过 goods.selected的id是否与当前sku的id一致来判断
  • 绑定了鼠标事件,鼠标进入后把当前sku赋值到goods.selected
展示sku其它属性

现在,我们已经可以通过goods.selected获取用户选中的sku,那么我们就可以在页面展示了:

刷新页面:

看起来很完美是吧!

但其实有一些瑕疵

其它问题
sku点击不切换

还有一个错误比较隐蔽,不容易被发现。我们点击sku 的图片列表,发现没有任何变化。

这不科学啊,为什么?

这是因为Vue的自动渲染是基于对象的属性变化的。比如页面使用GoodsList进行渲染,如果GoodsList变化,或者其内部的任何子对象变化,都会Vue感知,从而从新渲染页面。

然而,这一切有一个前提,那就是当你第一次渲染时,对象中有哪些属性,Vue就只监视这些属性,后来添加的属性发生改变,是不会被监视到的。

而我们的goods对象中,本身是没有selected属性的,是我们后来才添加进去的:

这段代码稍微改造一下,即可:

也就是说,我们先把selected属性初始化完毕,然后才把整个对象赋值给goodsList,这样,goodsList已初始化时就有selected属性,以后就会被正常监控了。

标题过长

标题内容太长了,已经无法完全显示,怎么办?

截取一下:

最好在加个悬停展示所有内容的效果

价格显示是分

首先价格显示就不正确,我们数据库中存放的是以分为单位,所以这里要格式化。

好在我们之前common.js中定义了工具类,可以帮我们转换。

改造:

结果报错:

为啥?

因为在Vue范围内使用任何变量,都会默认去Vue实例中寻找,我们使用ly,但是Vue实例中没有这个变量。所以解决办法就是把ly记录到Vue实例:

然后刷新页面:

页面分页效果

刚才的查询中,我们默认了查询的页码和每页大小,因此所有的分页功能都无法使用,接下来我们一起看看分页功能条该如何制作。

这里要分两步,

  • 第一步:如何生成分页条
  • 第二步:点击分页按钮,我们做什么
如何分成分页条

先看下页面关于分页部分的代码:

可以看到所有的分页栏内容都是写死的。

需要的数据

分页数据应该是根据总页数当前页总条数等信息来计算得出。

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

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

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

因为page是搜索条件之一,所以记录在search对象中。

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

不过,这个时候我们自己的search对象中的值就可有可无了

页面计算分条

首先,把后台提供的数据保存在data中:

然后看下我们要实现的效果:

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

思路分析:

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

所以,我们的页面这样来做:

在这里插入图片描述

标签中的分页数字通过index函数来计算,需要把i传递过去:

index(i){
    if(this.search.currentPage<=3||this.totalPage<=5) {
        return i;
    }else if(this.search.currentPage>=this.totalPage-2){
        return this.totalPage-5+i;
    }else{
        return this.search.currentPage-3+i;
    }
},

需要注意的是,如果总页数不足5页,我们就不应该遍历15,而是1总页数,稍作改进:

分页条的其它部分就比较简单了:

<div class="sui-pagination pagination-large">
    <ul style="width: 550px">
        <li :class="{prev:true,disabled:search.page === 1}">
            <a href="#">«上一页</a>
        </li>
        <li :class="{active: index(i) === search.page}" v-for="i in Math.min(5,totalPage)" :key="i">
            <a href="#">{{index(i)}}</a>
        </li>
        <li class="dotted" v-show="totalPage > 5"><span>...</span></li>
        <li :class="{next:true,disabled:search.page === totalPage}">
            <a href="#">下一页»</a>
        </li>
    </ul>
    <div>
        <span>共{{totalPage}}页&nbsp;</span>
        <span>
            到第
            <input type="text" class="page-num" :value="search.page">
            页 <button class="page-confirm" οnclick="alert(1)">确定</button>
        </span>
    </div>
</div>
点击分页做什么

点击分页按钮后,自然是要修改page的值

所以,我们在上一页下一页按钮添加点击事件,对page进行修改,在数字按钮上绑定点击事件,点击直接修改page:

翻页事件的方法:

    prevPage(){
        if(this.search.page > 1){
            this.search.page--
        }
    },
    nextPage(){
        if(this.search.page < this.totalPage){
            this.search.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);
        }
    }
}

再次刷新,OK了!

页面顶部分页条

在页面商品列表的顶部,也有一个分页条:

我们把这一部分,也加上点击事件:

<div class="top-pagination">
    <span><i style="color: #222;">{{totalPage}}+</i> 商品</span>
    <span><i style="color: red;">{{search.currentPage}}</i>/{{totalPage}}</span>
    <a class="btn-arrow" href="#" style="display: inline-block" @click="prev()">&lt;</a>
    <a class="btn-arrow" href="#" style="display: inline-block" @click="next()">&gt;</a>
</div>
代码
<!--上方页数跳转-->
    <div class="top-pagination">
        <span><i style="color: #222;">{{totalPage}}+</i> 商品</span>
        <span><i style="color: red;">{{search.currentPage}}</i>/{{totalPage}}</span>
        <a class="btn-arrow" href="#" style="display: inline-block" @click="prev()">&lt;</a>
        <a class="btn-arrow" href="#" style="display: inline-block" @click="next()">&gt;</a>
     </div>
    ------------------------------------

<!--sku展示-->
<div class="list-wrap">
    <div class="p-img">
        <a href="item.html" target="_blank"><img :src="goods.selected.image" height="200"/></a>
        <ul class="skus">
    <li :class="{selected: goods.selected.id==sku.id}" v-for="(sku,j) in goods.skus" :key="j"        @mouseOver="goods.selected=sku"><img :src="sku.image"></li>
</ul>
</div>
<div class="clearfix"></div>
<div class="price">
    <strong>
    <em>¥</em>
<i>{{ly.formatPrice(goods.selected.price)}}</i>
</strong>
</div>
<div class="attr">
    <em>{{goods.selected.title.length>20 ?   goods.selected.title.substring(0,20):goods.selected.title}}</em>
</div>
<div class="cu">
    <em>{{goods.subTitle.length>17 ? goods.subTitle.substring(0,17):goods.subTitle}}</em>
</div>
<div class="commit">
    <i class="command">已有2000人评价</i>
</div>
<div class="operate">
    <a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
    <a href="javascript:void(0);" class="sui-btn btn-bordered">对比</a>
    <a href="javascript:void(0);" class="sui-btn btn-bordered">关注</a>
</div>
</div>

------------------------------------------
<!--下方页面跳转-->
<ul>
    <li class="prev" :class="{disabled: search.currentPage==1}" @click="prev()">
        <a href="#">«上一页</a>
</li>
<li :class="{active: search.currentPage==index(i)}" v-for="i in Math.min(5,totalPage)" >
    <a href="#" @click="search.currentPage=index(i)">{{index(i)}}</a>
</li>
<li class="dotted"><span>...</span></li>
    <li class="next" :class="{disabled: search.currentPage==totalPage}" @click="next()">
        <a href="#">下一页»</a>
</li>
</ul>

   ---------------------------------------

            var vm = new Vue({
                el: "#searchApp",
                data: {
                    ly,
                    search: {
                        key: "", // 搜索页面的关键字
                        currentPage: 1
                    },
                    goodsList: [],
                    totalPage: 1,

                },
                created(){
                    // 判断是否有请求参数
                    if(!location.search){
                        return;
                    }
                    // 将请求参数转为对象 eg:location.search=(?key=手机) 转为{key: "小米"}
                    const search = ly.parse(location.search.substring(1));
                    // 记录在data的search对象中
                    this.search = search;
                    this.search.currentPage=1;

                    // 发起请求,根据条件搜索
                    this.loadData();
                },
                methods: {
                    loadData(){
                        ly.http.post("/search/page",this.search).then(({data})=>{
                            this.totalPage=data.totalPage;
                            data.items.forEach(goods=>{
                                goods.skus=JSON.parse(goods.skus);
                                goods.selected=goods.skus[0];
                            })
                            this.goodsList=data.items;  //放在后面是为了是selected属性可以被监控
                        }).catch(()=>{

                        })
                    },
                    index(i){
                        if(this.search.currentPage<=3||this.totalPage<=5) {
                            return i;
                        }else if(this.search.currentPage>=this.totalPage-2){
                            return this.totalPage-5+i;
                        }else{
                            return this.search.currentPage-3+i;
                        }
                    },
                    prev(){
                        if(this.search.currentPage>1){
                            this.search.currentPage--;
                        }
                    },

                    next(){
                        if(this.search.currentPage!=this.totalPage){
                            this.search.currentPage++;
                        }
                    }
                },
                components:{
                    lyTop: () => import("./js/pages/top.js")
                }
            });

商品搜索过滤

过滤功能分析

首先看下页面要实现的效果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xR8JOHmS-1603095206142)(https://cdn.static.note.zzrfdsn.cn/images/project/leyoumall/1526725119663.png)]

整个过滤部分有3块:

  • 顶部的导航,已经选择的过滤条件展示:
    • 商品分类面包屑,根据用户选择的商品分类变化
    • 其它已选择过滤参数
  • 过滤条件展示,又包含3部分
    • 商品分类展示
    • 品牌展示
    • 其它规格参数
  • 展开或收起的过滤条件的按钮

顶部导航要展示的内容跟用户选择的过滤条件有关。

  • 比如用户选择了某个商品分类,则面包屑中才会展示具体的分类
  • 比如用户选择了某个品牌,列表中才会有品牌信息。

所以,这部分需要依赖第二部分:过滤条件的展示和选择。因此我们先不着急去做。

展开或收起的按钮是否显示,取决于过滤条件有多少,如果很少,那么就没必要展示。所以也是跟第二部分的过滤条件有关。

这样分析来看,我们必须先做第二部分:过滤条件展示。

生成分类和品牌过滤

先来看分类和品牌。在我们的数据库中已经有所有的分类和品牌信息。在这个位置,是不是把所有的分类和品牌信息都展示出来呢?

显然不是,用户搜索的条件会对商品进行过滤,而在搜索结果中,不一定包含所有的分类和品牌,直接展示出所有商品分类,让用户选择显然是不合适的。

无论是分类信息,还是品牌信息,都应该从搜索的结果商品中进行聚合得到。

### 扩展返回的结果

原来,我们返回的结果是PageResult对象,里面只有total、totalPage、items 3个属性。但是现在要对商品分类和品牌进行聚合,数据显然不够用,我们需要对返回的结果进行扩展,添加分类和品牌的数据。

那么问题来了:以什么格式返回呢?

看页面:

分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name

品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据

我们新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合:

package top.codekiller.leyou.search.pojo;

import com.leyou.common.pojo.PageResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import top.codekiller.leyou.pojo.Brand;
import java.util.List;
import java.util.Map;

@Data
@NoArgsConstructor
public class SearchResult extends PageResult<Goods> {

    /**
     * 3个分类的集合
     */
    private List<Map<String,Object>> category;

    /**
     * 品牌集合
     */
    private List<Brand> brands;



    public SearchResult(List<Map<String, Object>> category, List<Brand> brands) {
        this.category = category;
        this.brands = brands;
    }

    public SearchResult(Long total, Integer totalPage, List<Goods> items, List<Map<String, Object>> category, List<Brand> brands) {
        super(total, totalPage, items);
        this.category = category;
        this.brands = brands;
    }
}
聚合商品分类和品牌

我们修改搜索的业务逻辑,对分类和品牌聚合。

因为索引库中只有id,所以我们根据id聚合,然后再根据id去查询完整数据。

所以,商品微服务需要提供一个接口:根据品牌id集合,批量查询品牌。

修改SearchService:

@Override
public SearchResult search(SearchRequest request) {
    if(StringUtils.isBlank(request.getKey())) return null;

    //自定义查询构建器
    NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();

    //添加查询条件
    queryBuilder.withQuery(QueryBuilders.matchQuery("all",request.getKey()).operator(Operator.AND));
    //添加分页,页码从0开始
    queryBuilder.withPageable(PageRequest.of(request.getPage()-1,request.getSize()));
    //添加结果集过滤
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"},null));

    //添加分类和品牌的聚合
    String categotyAggName="categories";
    String brandAggNa
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值