项目背景
- 了解电商行业
- 了解乐优商城项目结构
- 能独立搭建项目基本框架
- 能参考使用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值,即可计算得出需要部署的服务器数量
- 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
域名
我们在开发的过程中,为了保证以后的生产、测试环境统一。尽量都采用域名来访问项目。
二级域名: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-registry
,leyou-gateway
,leyou-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去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。
我们将使用以下域名:
- 主域名是:www.leyou.com,leyou.com
- 管理系统域名:manage.leyou.com
- 网关域名:api.leyou.com
- …
但是最终,我们希望这些域名指向的还是我们本机的某个端口。
那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的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可以作为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)]
解压后,目录结构:
- conf:配置目录
- contrib:第三方依赖
- html:默认的静态资源目录,类似于tomcat的webapps
- logs:日志目录
- 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命令重新加载即可),然后直接用域名访问后台管理系统:
现在实现了域名访问网站了,中间的流程是怎样的呢?
-
浏览器准备发起请求,访问http://mamage.leyou.com,但需要进行域名解析
-
优先进行本地域名解析,因为我们修改了hosts,所以解析成功,得到地址:127.0.0.1
-
请求被发往解析得到的ip,并且默认使用80端口:http://127.0.0.1:80
本机的nginx一直监听80端口,因此捕获这个请求
-
nginx中配置了反向代理规则,将manage.leyou.com代理到127.0.0.1:9001,因此请求被转发
-
后台系统的webpack server监听的端口是9001,得到请求并处理,完成后将响应返回到nginx
-
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.com 与 www.taobao.com |
域名相同,端口不同 | www.jd.com:8080 与 www.jd.com:8081 |
二级域名不同 | item.jd.com 与 miaosha.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-urlencoded
、multipart/form-data
、text/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-Origin
和Access-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("保存失败!");
});
}
}
- 通过
this.$refs.myBrandForm
选中表单,然后调用表单的validate
方法,进行表单校验。返回boolean值,true代表校验通过 - 通过解构表达式来获取brand中的值,categories需要处理,单独获取。其它的存入params对象中
- 品牌和商品分类的中间表只保存两者的id,而brand.categories中保存的是对象数组,里面有id和name属性,因此这里通过数组的map功能转为id数组,然后通过join方法拼接为字符串
- 发起请求
- 弹窗提示成功还是失败,这里用到的是我们的自定义组件功能message组件:
-
这个插件把
$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
在上传文件过程中,我们需要对上传的内容进行校验:
- 校验文件大小
- 校验文件的媒体类型
- 校验文件的内容
文件大小在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;
}
}
两种方式:
-
通过proxy_pass跳转
proxy_pass http://localhost:8082/upload;
-
通过重定向的方式
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组成。
上传和下载流程
上传
- Client通过Tracker server查找可用的Storage server。
- Tracker server向Client返回一台可用的Storage server的IP地址和端口号。
- Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并进行文件上传。
- 上传完成,Storage server返回Client一个文件ID,文件上传结束。
下载
- Client通过Tracker server查找要下载文件所在的的Storage server。
- Tracker server向Client返回包含指定文件的某个Storage server的IP地址和端口号。
- Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并指定要下载文件。
- 下载文件成功。
安装和使用
所需文件下载地址:https://github.com/happyfish100
参考资料:FastDFS的安装
java客户端
余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。
这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。
配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。
接下来,我们就用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:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
- 比如电池容量,0
2000mAh,2000mAh3000mAh,3000mAh~4000mAh
- 比如电池容量,0
数值类型
某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:
- 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的虚拟机上:
-
创建static文件夹
mkdir static
-
接着将图片压缩包上传到static文件下后解压
# 如果没安装unzip先安装
yum install unzip
unzip images.zipCopy to clipboardErrorCopied
- 修改Nginx配置,使nginx反向代理这些图片地址:
vim /opt/nginx/conf/nginx.confCopy to clipboardErrorCopied
- 修改成如下配置:
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
- 不要忘记重新加载nginx配置
nginx -s reloadCopy to clipboardErrorCopied
- 访问测试
http://image.leyou.com/images/6/8/1524297350205.jpg
商品请求
页面请求
先看整体页面结构(Goods.vue):
并且在Vue实例挂载后就会发起查询(mounted调用getDataFromServer方法初始化数据):
我们刷新页面,可以看到浏览器发起已经发起了查询商品数据的请求,但是却发现发起了两次请求:
发起两次请求的原因
可以看到页面有两处地方会导致发送请求,一个是在页面渲染之后的钩子函数中,另一个是在监听分页信息的函数中,因为在初始化的时候vue会给pagination
赋值一些初始化数据,而监听函数监听到之后就会调用发送请求的方法,所以我们只需要监听函数即可,钩子函数就不需要了
后端代码
页面已经准备好,接下来在后台提供分页查询SPU的功能。
先来看一下页面需要哪些数据
id
和 title
分别对应商品id和商品标题,这两个字段在spu表中都有,也就在实体类中也有
但是cname
和bname
是分类名称和品牌名称,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中的
cid1
,cid2
,cid3
属性 - 品牌:是spu中的
brandId
属性 - 标题:是spu中的
title
属性 - 子标题:是spu中的
subTitle
属性 - 售后服务:是SpuDetail中的
afterService
属性 - 包装列表:是SpuDetail中的
packingList
属性
- 商品分类:是SPU中的
- 商品描述:是SpuDetail中的
description
属性,数据较多,所以单独放一个页面 - 规格参数:商品规格信息,对应SpuDetail中的
genericSpec
属性 - SKU属性:spu下的所有Sku信息
对应到页面中的四个stepper-content
:
弹窗事件
弹窗是一个独立组件:
并且在Goods组件中已经引用它:
并且在页面中渲染:
在新增商品
按钮的点击事件中,改变这个dialog
的show
属性:
基本数据
我们先来看下基本数据:
商品分类
商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:
刷新页面,可以看到请求已经发出:
效果:
品牌选择
页面
品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。
所以页面编写了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
需要修改的配置文件有两个:
-
jvm.options
Elasticsearch基于Lucene的,而Lucene底层是java实现,因此我们需要配置jvm参数。
编辑jvm.options:
vim jvm.options
默认配置如下:
-Xms1g -Xmx1g
内存占用太多了,我们调小一些:
-Xms512m -Xmx512m
-
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
控制台
选择左侧的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:副本数量
- settings:索引的设置
测试
我们先用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_all
,match
,term
,range
等等
- 例如:
- 查询条件:查询条件会根据类型的不同,写法也有差异,后面详细讲解
查询所有
示例:
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之间?
在 or
与 and
间二选一有点过于非黑即白。 如果用户给定的条件分词后有 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_match
与match
类似,不同的是它可以在多个字段中查询
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:划分桶的字段
- terms:划分桶的方式,这里是根据词条划分
- popular_colors:给这次聚合起一个名字,任意。
结果:
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 8,
"max_score": 0,
"hits": []
},
"aggregations": {
"popular_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查询,然后自动帮你完成,无需写实现类。
当然,方法名称要符合一定的约定:
Keyword | Sample | Elasticsearch Query String |
---|---|---|
And | findByNameAndPrice | {"bool" : {"must" : [ {"field" : {"name" : "?"}}, {"field" : {"price" : "?"}} ]}} |
Or | findByNameOrPrice | {"bool" : {"should" : [ {"field" : {"name" : "?"}}, {"field" : {"price" : "?"}} ]}} |
Is | findByName | {"bool" : {"must" : {"field" : {"name" : "?"}}}} |
Not | findByNameNot | {"bool" : {"must_not" : {"field" : {"name" : "?"}}}} |
Between | findByPriceBetween | {"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : ?,"include_lower" : true,"include_upper" : true}}}}} |
LessThanEqual | findByPriceLessThan | {"bool" : {"must" : {"range" : {"price" : {"from" : null,"to" : ?,"include_lower" : true,"include_upper" : true}}}}} |
GreaterThanEqual | findByPriceGreaterThan | {"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : null,"include_lower" : true,"include_upper" : true}}}}} |
Before | findByPriceBefore | {"bool" : {"must" : {"range" : {"price" : {"from" : null,"to" : ?,"include_lower" : true,"include_upper" : true}}}}} |
After | findByPriceAfter | {"bool" : {"must" : {"range" : {"price" : {"from" : ?,"to" : null,"include_lower" : true,"include_upper" : true}}}}} |
Like | findByNameLike | {"bool" : {"must" : {"field" : {"name" : {"query" : "?*","analyze_wildcard" : true}}}}} |
StartingWith | findByNameStartingWith | {"bool" : {"must" : {"field" : {"name" : {"query" : "?*","analyze_wildcard" : true}}}}} |
EndingWith | findByNameEndingWith | {"bool" : {"must" : {"field" : {"name" : {"query" : "*?","analyze_wildcard" : true}}}}} |
Contains/Containing | findByNameContaining | {"bool" : {"must" : {"field" : {"name" : {"query" : "**?**","analyze_wildcard" : true}}}}} |
In | findByNameIn(Collectionnames) | {"bool" : {"must" : {"bool" : {"should" : [ {"field" : {"name" : "?"}}, {"field" : {"name" : "?"}} ]}}}} |
NotIn | findByNameNotIn(Collectionnames) | {"bool" : {"must_not" : {"bool" : {"should" : {"field" : {"name" : "?"}}}}}} |
Near | findByStoreNear | Not Supported Yet ! |
True | findByAvailableTrue | {"bool" : {"must" : {"field" : {"available" : true}}}} |
False | findByAvailableFalse | {"bool" : {"must" : {"field" : {"available" : false}}}} |
OrderBy | findByAvailableTrueOrderByNameDesc | {"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进行分组:
这里ElasticsearchTemplate
和ElasticsearchRestTemplate
有点不一样
使用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
的子接口:
AggregatedPage
在Page
功能的基础上,拓展了与聚合相关的功能,它其实就是对聚合结果的一种封装,大家可以对照聚合结果的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}}页 </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()"><</a>
<a class="btn-arrow" href="#" style="display: inline-block" @click="next()">></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()"><</a>
<a class="btn-arrow" href="#" style="display: inline-block" @click="next()">></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