项目背景了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用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平台:天猫、京东、一号店等 专业术语 项目开发流程项目经理:管人
技术经理:
产品经理:设计需求原型
测试:
前端:大前端:UI 前端页面。
后端:
移动端:
项目开发流程图:
公司现状:
乐优商城介绍 项目介绍乐优商城是一个全品类的电商购物网站(B2C)。 用户可以在线购买商品、加入购物车、下单 可以评论已购买商品 管理员可以在后台管理商品的上下架、促销活动 管理员可以监控商品销售状况 客服可以在后台处理退款操作 希望未来3到5年可以支持千万用户的使用 系统架构 架构图乐优商城架构缩略图:
系统架构解读整个乐优商城可以分为两部分:后台管理系统、前台门户系统。
后台管理:后台系统主要包含以下功能:商品管理,包括商品分类、品牌、商品规格等信息的管理 销售管理,包括订单统计、订单退款处理、促销活动生成等 用户管理,包括用户控制、冻结、解锁等 权限管理,整个网站的权限控制,采用JWT鉴权方案,对用户及API进行权限控制 统计,各种数据的统计分析展示 后台系统会采用前后端分离开发,而且整个后台管理系统会使用Vue.js框架搭建出单页应用(SPA)。 前台门户前台门户面向的是客户,包含与客户交互的一切功能。例如: 前台系统我们会使用Thymeleaf模板引擎技术来完成页面开发。出于SEO优化的考虑,我们将不采用单页应用。
无论是前台还是后台系统,都共享相同的微服务集群,包括:
商品微服务:商品及商品分类、品牌、库存等的服务 搜索微服务:实现搜索功能 订单微服务:实现订单相关 购物车微服务:实现购物车相关功能 用户中心:用户的登录注册等功能 Eureka注册中心 Zuul网关服务 项目搭建 技术选型前端技术:
基础的HTML、CSS、JavaScript(基于ES6标准) JQuery Vue.js 2.0以及基于Vue的框架:Vuetify(UI框架) 前端构建工具:WebPack 前端安装包工具:NPM Vue脚手架:Vue-cli Vue路由:vue-router ajax框架:axios 基于Vue的富文本框架:quill-editor 后端技术:
基础的SpringMVC、Spring 5.x和MyBatis3 Spring Boot 2.1.10版本 Spring Cloud Greenwich.SR4 Redis RabbitMQ Elasticsearch nginx FastDFS MyCat Thymeleaf mysql 5.7 开发环境为了保证开发环境的统一,希望每个人都按照我的环境来配置:
IDEA JDK:JDK1.8 项目构建:maven 3.6.2 版本控制工具:git 域名我们在开发的过程中,为了保证以后的生产、测试环境统一。尽量都采用域名来访问项目。
一级域名:www.leyou.com,leyou.com
二级域名:manage.leyou.com/item , api.leyou.com
我们可以通过switchhost工具来修改自己的host对应的地址,只要把这些域名指向127.0.0.1,那么跟你用localhost的效果是完全一样的。
switchhost下载连接:https://github.com/oldj/SwitchHosts/releases
创建父工程 创建工程创建Maven工程,打包方式为pom
,项目名为leyou-parent
添加依赖1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 <?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 /> </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 > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-dependencies</artifactId > <version > ${spring-cloud.version}</version > <type > pom</type > <scope > import</scope > </dependency > <dependency > <groupId > org.mybatis.spring.boot</groupId > <artifactId > mybatis-spring-boot-starter</artifactId > <version > ${mybatis.starter.version}</version > </dependency > <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 > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > ${mysql.version}</version > </dependency > <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
添加依赖1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?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 >
修改配置文件1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 server: port: 10086 spring: application: name: leyou-registery eureka: client: service-url: defaultZone: http://localhost:10086/eureka register-with-eureka: false fetch-registry: false server: eviction-interval-timer-in-ms: 5000 enable-self-preservation: false logging: level: top.codekiller.leyouRegistry: debug
编写启动类1 2 3 4 5 6 7 8 9 10 11 12 13 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
添加依赖1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <?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 >
修改配置文件1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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 zuul: prefix: /api
编写启动类1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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目录删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?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
模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?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中已经添加好了。所以直接引入即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 <?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 >
修改配置文件1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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 validationQuery: select 'x' ; testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: true maxPoolPreparedStatementPerConnectionSize: 20 filters: stat,wall,slf4j connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000 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
编写启动类1 2 3 4 5 6 @SpringCloudApplication public class LeyouItemServiceApplication { public static void main (String[] args) { SpringApplication.run(LeyouItemServiceApplication.class); } }
添加商品微服务的路由规则既然商品微服务已经创建,接下来肯定要添加路由规则到Zuul中,我们不使用默认的路由规则。
修改leyou-gateway
工程的application.yaml配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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 zuul: prefix: /api routes: item-service: /item/**
启动测试我们分别启动:leyou-registry
,leyou-gateway
,leyou-item-service
为了测试路由规则是否畅通,我们是不是需要在item-service
中编写一个controller接口呢?
其实不需要,SpringBoot提供了一个依赖:actuator
只要我们添加了actuator的依赖,它就会为我们生成一系列的访问接口:
在item-service
中添加依赖:
1 2 3 4 <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组件,拿来即用,常见的例如:
然而这些UI组件的基因天生与Vue不合,因为他们更多的是利用DOM操作,借助于jQuery实现,而不是MVVM的思想。
而目前与Vue吻合的UI框架也非常的多,国内比较知名的如:
element-ui:饿了么出品 i-view:某公司出品 然而我们都不用,我们今天推荐的是一款国外的框架:Vuetify
官方网站:https://vuetifyjs.com/zh-Hans/
为什么选择Vuetify有中国的为什么还要用外国的?原因如下:
Vuetify几乎不需要任何CSS代码,而element-ui许多布局样式需要我们来编写 Vuetify从底层构建起来的语义化组件。简单易学,容易记住。 Vuetify基于Material Design(谷歌推出的多平台设计规范),更加美观,动画效果酷炫,且风格统一 这是官网的说明:
缺陷:
目前官网虽然有中文文档,但因为翻译问题,几乎不太能看。 怎么用基于官方网站的文档进行学习:
我们重点关注UI components
即可,里面有大量的UI组件,我们要用的时候再查看,不用现在学习,先看下有什么:
以后用到什么组件,就来查询即可。
项目页面布局接下来我们一起看下页面布局。
Layout组件是我们的整个页面的布局组件:
一个典型的三块布局。包含左,上,中三部分:
里面使用了Vuetify中的2个组件和一个布局元素:
导航抽屉v-navigation-drawer
:导航抽屉,主要用于容纳应用程序中的页面的导航链接。
工具栏v-toolbar
:工具栏通常是网站导航的主要途径。可以与导航抽屉一起很好地工作,动态选择是否打开导航抽屉,实现可伸缩的侧边栏。
布局元素v-content
:并不是一个组件,而是标记页面布局的元素。可以根据您指定的app 组件的结构动态调整大小,使得您可以创建高度可定制的组件。
那么问题来了:v-content
中的内容来自哪里?
Layout映射的路径是/
除了Login以外的所有组件,都是定义在Layout的children属性,并且路径都是/
的下面 因此当路由到子组件时,会在Layout中定义的锚点中显示。 并且Layout中的其它部分不会变化,这就实现了布局的共享。 使用域名访问本地仓库 统一环境我们现在访问页面使用的是:http://localhost:9001
有没有什么问题?
实际开发中,会有不同的环境:
开发环境:自己的电脑 测试环境:提供给测试人员使用的环境 预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试 生产环境:项目最终发布上线的环境 如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。
我们将使用以下域名:
但是最终,我们希望这些域名指向的还是我们本机的某个端口。
那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的ip和端口的呢?
域名解析一个域名一定会被解析为一个或多个ip。这一般会包含两步:
解决域名解析问题我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:
1 2 127.0 .0 .1 api.leyou.com127.0 .0 .1 manage.leyou.com
这样就实现了域名的关系映射了。
每次在C盘寻找hosts文件并修改是非常麻烦的,给大家推荐一个快捷修改host的工具
下载连接
解压,运行exe文件,效果:
Linux版效果
我们添加了两个映射关系(中间用空格隔开):
切换为生效状态然后访问:http://manage.leyou.com:9001
出现如下效果就代表配置成功
原因:我们配置了项目访问的路径,虽然manage.leyou.com映射的ip也是127.0.0.1,但是webpack会验证host是否符合配置。
在webpack.dev.conf.js中取消host验证:disableHostCheck: true
退出重新启动,npm start
,刷新浏览器
Nginx解决端口问题域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.taotao.com:9001
。
这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com
。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?
这里就要用到反向代理工具:Nginx
nginx介绍
什么是Nginx
nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:
nginx作为web服务器Web服务器分2类:
区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。 并发:web服务器的并发能力远高于web应用服务器。
nginx做反向代理什么是反向代理?
代理:通过客户机的配置,实现让一台服务器代理客户机,客户的所有请求都交给代理服务器处理。 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。 nginx可以当做反向代理服务器来使用:
我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能 利用反向代理,就可以解决我们前面所说的端口问题,如图
nginx安装安装
安装非常简单,下载后直接解压即可,绿色免安装,舒服!
解压后,目录结构:
conf:配置目录 contrib:第三方依赖 html:默认的静态资源目录,类似于tomcat的webapps logs:日志目录 nginx.exe:启动程序。可双击运行,但不建议这么做。 反向代理配置
nginx中的每个server就是一个反向代理配置,可以有多个server
完整配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 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
1 2 3 4 5 6 7 8 9 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实体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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; private Integer sort; }
注意在这里加上mybatis-plus和lombok的依赖 >
1 2 3 4 5 6 7 8 9 10 <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数组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [ { "id" : 74 , "name" : "手机" , "parentId" : 0 , "isParent" : true , "sort" : 2 }, { "id" : 75 , "name" : "家用电器" , "parentId" : 0 , "isParent" : true , "sort" : 3 } ]
对应的java类型可以是List集合,里面的元素就是类目对象了。也就是List
controller代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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 ){ return ResponseEntity.badRequest().build(); } List<Category> categories = this .categoryService.queryCategoryByPid(pid); if (CollectionUtils.isEmpty(categories)){ return ResponseEntity.notFound().build(); } return ResponseEntity.ok(categories); }
Service1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Service public class CategoryService implements ICategoryService { @Autowired private CategoryMapper categoryMapper; @Override public List<Category> queryCategoryByPid (Long pid) { return this .categoryMapper.selectList(new QueryWrapper<Category>().lambda() .eq(Category::getParentId,pid)); } }
mapper1 2 3 public interface CategoryMapper extends BaseMapper <Category > {}
要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?
我们在MP的配置类上添加一个扫描包功能:
1 2 3 4 @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
如果域名和端口都相同,但是请求路径不同 ,不属于跨域,如:
1 2 www.jd.com/item www.jd.com/goods
http和https也属于跨域
而我们刚才是从manage.leyou.com
去访问api.leyou.com
,这属于二级域名不同,跨域了。
为什么会有跨域问题跨域不一定都会有跨域问题。
因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是与当前页域名相同的路径 ,这能有效的阻止跨站攻击。
因此:跨域问题 是针对ajax的一种限制 。
但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?
解决跨域问题的方案目前比较常用的跨域解决方案有3种:
我们这里会采用cors的跨域方案。
Cors解决跨域 什么是corsCORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出XMLHttpRequest
请求,从而克服了AJAX只能同源 使用的限制。
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
原理浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。
简单请求只要同时满足以下两大条件,就属于简单请求。:
(1) 请求方法是以下三种方法之一:
(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中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。
如果服务器允许跨域,需要在返回的响应头中携带下面信息:
1 2 3 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
请求,否则就报错。
一个“预检”请求的样板:
1 2 3 4 5 6 7 8 OPTIONS /cors HTTP/1.1 Origin : http://manage.leyou.comAccess-Control-Request-Method : PUTAccess-Control-Request-Headers : X-Custom-HeaderHost : api.leyou.comAccept-Language : en-USConnection : keep-aliveUser-Agent : Mozilla/5.0...Copy to clipboardErrorCopied
与简单请求相比,除了Origin以外,多了两个头:
Access-Control-Request-Method:接下来会用到的请求方式,比如PUT Access-Control-Request-Headers:会额外用到的头信息 预检请求的响应
服务的收到预检请求,如果许可跨域,会发出响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 HTTP/1.1 200 OKDate : Mon, 01 Dec 2008 01:15:39 GMTServer : Apache/2.0.61 (Unix)Access-Control-Allow-Origin : http://manage.leyou.comAccess-Control-Allow-Credentials : trueAccess-Control-Allow-Methods : GET, POST, PUTAccess-Control-Allow-Headers : X-Custom-HeaderAccess-Control-Max-Age : 1728000Content-Type : text/html; charset=utf-8Content-Encoding : gzipContent-Length : 0Keep-Alive : timeout=2, max=100Connection : Keep-AliveContent-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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Configuration public class LeyouCorsConfiguration { @Bean public CorsFilter corsFilter () { CorsConfiguration configuration=new CorsConfiguration(); configuration.setAllowCredentials(true ); configuration.addAllowedOrigin("http://manage.leyou.com" ); configuration.addAllowedMethod("*" ); configuration.addAllowedHeader("*" ); UrlBasedCorsConfigurationSource configurationSource=new UrlBasedCorsConfigurationSource(); configurationSource.registerCorsConfiguration("/**" ,configuration); return new CorsFilter(configurationSource); } }
结构:
重启网关,然后刷新页面测试,访问是否正常正常:http://manage.leyou.com/#/item/category
品牌的查询商品分类完成以后,自然轮到了品牌功能了。
先看看我们要实现的效果:
点击“品牌管理”菜单:
路由路径:/item/brand
根据路由文件知,对应的页面是:src/pages/item/Brand.vue
页面会发送如下请求:
数据库表1 2 3 4 5 6 7 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),一对多关系';
简单的四个字段,不多解释。
这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:
1 2 3 4 5 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='商品分类和品牌的中间表,两者是多对多关系';
但是,你可能会发现,这张表中并没有设置外键约束 ,似乎与数据库的设计范式不符。为什么这么做?
外键会严重影响数据库读写的效率 数据删除时会比较麻烦 在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。
实体类
1 2 3 4 5 6 7 8 9 @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
中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Data @AllArgsConstructor @NoArgsConstructor public class PageResult <T > { private Long total; private Integer totalPage; private List<T> items; }
然后在leyou-item-service工程的pom.xml中引入leyou-common的依赖
1 2 3 4 5 6 <dependency > <groupId > com.leyou.common</groupId > <artifactId > leyou-common</artifactId > <version > 1.0-SNAPSHOT</version > </dependency >
接下来,我们编写Controller
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @RestController @RequestMapping("brand") public class BrandController { @Autowired IBrandService brandService; @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); } }
Service1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Service public class BrandService implements IBrandService { @Autowired private BrandMapper brandMapper; @Override public PageResult<Brand> queryBrandByPage (String key, Integer page, Integer rows, String sortBy, Boolean desc) { QueryWrapper<Brand> queryWrapper=new QueryWrapper<Brand>(); 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()); } }
mapper1 public interface BrandMapper extends BaseMapper <Brand > {}
异步查询工具axios异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。
axiosVue官方推荐的ajax请求框架叫做:axios,看下demo:
axios的Get请求语法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 axios.get("/item/category/list?pid=0" ) .then(function (resp ) { }) .catch(function ( ) { }) axios.get("/item/category/list" , { params:{ pid:0 } }) .then(function (resp ) {}) .catch(function (error ) {})
axios的POST请求语法:
比如新增一个用户
1 2 3 4 5 6 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进行了一些默认配置:
1 2 3 4 5 6 7 8 import Vue from 'vue' import axios from 'axios' import config from './config' axios.defaults.baseURL = config.api; axios.defaults.timeout = 2000 ; Vue.prototype.$http = axios;
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方法:
把数据模型之的show置为true,而页面中有一个弹窗与show绑定:
弹窗中有一个表单子组件,并且是一个局部子组件,有页面可以找到该组件:
###页面实现
重置表单重置表单相对简单,因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了。
我们可以通过$refs
内置对象来获取表单组件。
首先,在表单上定义ref
属性:
然后,在页面查看this.$refs
属性:
1 2 3 4 reset ( ) { console .log(this ); }
查看如下:
看到this.$refs
中只有一个属性,就是myBrandForm
我们在clear中来获取表单对象并调用reset方法:
要注意的是,这里我们还手动把this.categories清空了,因为我写的级联选择组件并没有跟表单结合起来。需要手动清空。
表单校验 校验规则Vuetify的表单校验,是通过rules属性来指定的:
校验规则的写法:
说明:
规则是一个数组 数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:返回true,代表成功, 返回错误提示信息,代表失败 我们有四个字段:
name:做非空校验和长度校验,长度必须大于1 letter:首字母,校验长度为1,非空。 image:图片,不做校验,图片可以为空 categories:非空校验,自定义组件已经帮我们完成,不用写了 首先,我们定义规则:
然后,在页面标签中指定:
1 2 <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方法中添加表单提交的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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 返回值:无,只需要响应状态码 代码:
1 2 3 4 5 6 @PostMapping() public ResponseEntity<Void> saveBrand (Brand brand,@RequestParam("cids") List<Long> cids) { this .brandService.saveBrand(brand,cids); return ResponseEntity.status(HttpStatus.CREATED).build(); }
Service这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override @Transactional public void saveBrand (Brand brand, List<Long> cids) { this .brandMapper.insert(brand); cids.forEach(cid->{ this .brandMapper.insertCategoryAndBrand(cid,brand.getId()); }); }
这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增
Mapper通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:
1 2 3 4 5 6 7 @Insert("insert into tb_category_brand values(#{cid},#{bid})") void insertCategoryAndBrand (@Param("cid") Long cid, @Param("bid") Long bid) ;
测试
400:请求参数不合法
解决400 原因分析我们填写表单并提交,发现报错了。查看控制台的请求详情:
发现请求的数据格式是JSON格式。
原因分析:
axios处理请求体的原则会根据请求数据的格式来定:
qs工具QS是一个第三方库,我们可以用npm install qs --save
来安装。不过我们在项目中已经集成了,大家无需安装:
这个工具的名字:QS,即Query String,请求参数字符串。
什么是请求参数字符串?例如: name=jack&age=21
QS工具可以便捷的实现 JS的Object与QueryString的转换。
在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs
来获取这个工具:
我们将this.$qs
对象打印到控制台:
1 2 3 created ( ) { console .log(this .$qs); }
发现其中有3个方法:
这里我们要使用的方法是stringify,它可以把Object转为QueryString。
测试一下,使用浏览器工具,把qs对象保存为一个临时变量temp1,然后调用stringify方法:
解决问题修改页面,对参数处理后发送:
然后再次发起请求,发现请求成功:
完成后关闭窗口(已完成)我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。
这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。
因此,我们需要在新增的ajax请求完成以后,关闭窗口
但问题在于,控制窗口是否显示的标记在父组件:MyBrand.vue中。子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?
之前我们讲过一个父子组件的通信,有印象吗?
第一步:在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了。父组件在使用子组件时,绑定事件,关联到这个函数:Brand.vue 1 2 3 4 <!--对话框的内容,表单--> <v-card-text class="px-5" style="height:400px"> <brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/> </v-card-text>
第二步,子组件通过this.$emit
调用父组件的函数:BrandForm.vue
测试一下,保存成功:
我们优化一下,关闭的同时重新加载数据:
1 2 3 4 5 6 closeWindow ( ) { this .show = false ; this .getDataFromServer(); }
实现图片的上传刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
创建module
依赖我们需要EurekaClient和web依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 <?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 >
编写配置1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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
需要注意的是,我们应该添加了限制文件大小的配置
引导类1 2 3 4 5 6 7 @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 代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @RestController @RequestMapping("upload") public class UploadController { @Autowired UploadService uploadService; @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的类型
1 2 3 4 5 6 7 8 9 10 11 @Data @ConfigurationProperties(prefix = "uploadinfo") @Component public class UploadProperties { private List<String> contentTypes; private String imageUrl; private String savePath; }
application.yml
1 2 3 4 5 6 7 uploadinfo: content-types: - image/gif - image/jpeg - image/png imageUrl: http://image.leyou.com/ savePath: F:\\乐优商城上传的图片\\
Service在上传文件过程中,我们需要对上传的内容进行校验:
校验文件大小 校验文件的媒体类型 校验文件的内容 文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。
具体代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 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; public UploadService (UploadProperties uploadProperties) { this .uploadProperties=uploadProperties; } @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)); log.info("上传成功:{}" +originName); return uploadProperties.getImageUrl()+originName; } catch (IOException e){ log.info("服务器内部错误:{}" ,originName); e.printStackTrace(); } return null ; } }
这里有一个问题:为什么图片地址需要使用另外的url?
图片不能保存在服务器内部,这样会对服务器产生额外的加载负担 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量 配置nginx虽然实现了文件上传功能并且返回了文件访问地址,但是我们无法通过返回的地址直接访问到图片,接下来我们配置Nginx静态资源访问来回显图片
找到nginx配置文件添加以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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文件
1 2 3 4 5 # 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路径,示例:
1 zuul.ignored-patterns : /upload/**
路径过滤会对一切微服务进行判定。
Zuul还提供了ignored-services
属性,进行服务过滤:
1 zuul.ignored-services : upload-servie
我们这里采用忽略服务:
1 2 3 zuul: ignored-services: - upload-service
上面的配置采用了集合语法,代表可以配置多个。
nginx的rewirte指令现在,我们查看页面的访问路径:
1 2 3 4 5 6 <v-upload v-model="brand.image" url="/upload/image" :multiple="false" :pic-width="250" :pic-height="90" />
可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?
有同学会想:修改页面请求地址不就好了。
注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。
既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。
我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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; } location / { proxy_pass http://127.0.0.1:10010; proxy_connect_timeout 600 ; proxy_read_timeout 600 ; } }
两种方式:
通过proxy_pass跳转
1 proxy_pass http://localhost:8082/upload;
通过重定向的方式
1 2 proxy_pass http://127.0.0.1:7002;rewrite "^/api/(.*)$" /$1 break ;
首先,我们映射路径是/api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload优先级更高。也就是说,凡是以/api/upload开头的路径,都会被第一个配置处理
proxy_pass
:反向代理,这次我们代理到7002端口,也就是upload-service服务
rewrite "^/api/(.*)$" /$1 break
,路径重写:
"^/api/(.*)$"
:匹配路径的正则表达式,用了分组语法,把/api/
以后的所有部分当做1组
/$1
:重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),即/api/
后面的所有。这样新的路径就是除去/api/
以外的所有,就达到了去除/api
前缀的目的
break
:指令,常用的有2个,分别是:last、break
last:重写路径结束后,将得到的路径重新进行一次路径匹配 break:重写路径结束后,不再重新匹配路径。 我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到7002端口了
修改完成,输入nginx -s reload
命令重新加载配置。然后再次上传试试。
跨域问题重启nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错:
不过庆幸的是,这个错误已经不是第一次见了,跨域问题。因为之前我们的跨域问题是在网关中解决的,现在不经过网关了,所以要在这里也添加一个CorsFilter
我们在upload-service中添加一个CorsFilter即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration public class LeyouCorsConfiguration { @Bean public CorsFilter corsFilter () { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://manage.leyou.com" ); config.addAllowedMethod("OPTIONS" ); config.addAllowedMethod("POST" ); config.addAllowedHeader("*" ); UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource(); configSource.registerCorsConfiguration("/**" , config); 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。
配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。
地址:tobato/FastDFS_client
接下来,我们就用FastDFS改造leyou-upload 工程。
引入依赖在父工程中,我们已经管理了依赖,版本为:
1 <fastDFS.client.version > 1.26.7</fastDFS.client.version >
因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:
1 2 3 4 <dependency > <groupId > com.github.tobato</groupId > <artifactId > fastdfs-client</artifactId > </dependency >
引入配置类
纯java配置:
1 2 3 4 5 6 7 @Configuration @Import(FdfsClientConfig.class) @EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING) public class FastClientImporter {}
编写FastDFS属性在application.yml配置文件中追加如下内容:
1 2 3 4 5 6 7 8 fdfs: so-timeout: 1501 connect-timeout: 601 thumb-image: width: 60 height: 60 tracker-list: - 172.16 .145 .141 :22122
配置hosts将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:
配置hosts文件,使image.leyou.com可以访问fastDFS服务器
测试创建测试类:
把以下内容copy进去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @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" ); 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结果:
1 2 group1/M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg
testUploadAndCreateThumb结果:
1 2 3 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) )
改造上传逻辑1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 @Slf4j @EnableConfigurationProperties(UploadProperties.class) @Service public class UploadService implements IUploadService { private UploadProperties uploadProperties; @Autowired private FastFileStorageClient fastFileStorageClient; public UploadService (UploadProperties uploadProperties) { this .uploadProperties=uploadProperties; } @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,"." ); StorePath storePath=fastFileStorageClient.uploadFile(file.getInputStream(),file.getSize(),suffix,null ); log.info("上传成功:{}" +originName); return uploadProperties.getImageUrl()+storePath.getFullPath(); } catch (IOException e){ log.info("服务器内部错误:{}" ,originName); e.printStackTrace(); } return null ; } }
只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。
页面上传测试发现上传成功:
页面规格数据结构乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:
SPU和SKUSPU: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
1 2 3 4 5 6 7 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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:标记是否用作过滤 segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间比如电池容量,02000mAh,2000mAh 3000mAh,3000mAh~4000mAh 数值类型
某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:
numberic:是否为数值类型 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
中添加实体类:
内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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`") 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: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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; @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
1 2 public interface SpecGroupMapper extends BaseMapper <SpecGroup > {}
页面访问测试目前,我们数据库只为手机分类(76)提供了规格组:
规格参数查询 表格切换当我们点击规格组,会切换到规格参数显示,肯定是在规格组中绑定了点击事件:
我们看下事件处理:
可以看到这里是使用了父子通信,子组件触发了select事件:
再来看下父组件的事件绑定:
事件处理:
这里我们记录了选中的分组,并且把标记设置为false,这样规格组就不显示了,而是显示:SpecParam
并且,我们把group也传递到spec-param
组件:
页面查询规格参数我们来看SpecParam.vue
的实现:
查看页面控制台,发现请求已经发出:
报404,因为我们还没有实现后台逻辑,接下来就去实现。
后台实现SpecificationController
分析:
请求方式:GET 请求路径:/spec/params 请求参数:gid,分组id 返回结果:该分组下的规格参数集合List
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 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; @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); } @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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 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; @Override public List<SpecGroup> queryGroupByCid (Long cid) { Map<String,Object> map=new HashMap<>(); map.put("cid" ,cid); return specGroupMapper.selectByMap(map); } @Override public List<SpecParam> queryParams (Integer gid) { List<SpecParam> params=this .specParamMapper.selectList(new QueryWrapper<SpecParam>().lambda() .eq(SpecParam::getGroupId,gid)); return params; } }
Mapper
1 2 public interface SpecParamMapper extends BaseMapper <SpecParam > {}
增、删、改TODO 时间有限没做
页面中接口都已定义,要做的就是实现后台接口。
SPU和SKU数据结构规格确定以后,就可以添加商品了,先看下数据库表
SPU表SPU表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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
1 2 3 4 5 6 7 8 9 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结构:
那么问题来:特有规格参数应该在sku中记录才对,为什么在spu中也要记录一份?
因为我们有时候需要把所有规格参数都查询出来,而不是只查询1个sku的属性。比如,商品详情页展示可选的规格参数时:
SKU表1 2 3 4 5 6 7 8 9 10 11 12 13 14 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';
还有一张表,代表库存:
1 2 3 4 5 6 7 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表中,已经对特有规格参数及可选项进行了保存,结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "4": [ "香槟金", "樱花粉", "磨砂黑" ], "12": [ "2GB", "3GB" ], "13": [ "16GB", "32GB" ] }
这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。
比如:
红米4X,香槟金,2GB内存,16GB存储 红米4X,磨砂黑,2GB内存,32GB存储 你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:
既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。
这个设计在商品详情页会特别有用:
当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。
own——spec字段看结构:
1 {"4":"香槟金","12":"2GB","13":"16GB"}Copy to clipboardErrorCopied
保存的是特有属性的键值对。
SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的值。
导入图片信息图片下载
现在商品表中虽然有数据,但是所有的图片信息都是无法访问的,我们需要把图片导入到安装fastdfs的虚拟机上:
创建static文件夹
接着将图片压缩包上传到static文件下后解压
1 2 3 4 # 如果没安装unzip先安装 yum install unzip unzip images.zipCopy to clipboardErrorCopied
修改Nginx配置,使nginx反向代理这些图片地址: 1 vim /opt/nginx/conf/nginx.confCopy to clipboardErrorCopied
修改成如下配置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 server { listen 80 ; server_name image.leyou.com; location ~/group([0 -9 ])/ { ngx_fastdfs_module; } location / { root /leyou/static/; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } }Copy to clipboardErrorCopied
不要忘记重新加载nginx配置 1 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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; private Long cid2; private Long cid3; private String title; private String subTitle; private Boolean saleable; private Boolean valid; private Date createTime; private Date lastUpdateTime; }
SpuDetails:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package top.codekiller.leyou.pojo;import com.baomidou.mybatisplus.annotation.TableId;import lombok.Data;@Data public class SpuDetail { @TableId private Long spuId; private String description; private String specialSpec; private String genericSpec; private String packingList; private String afterService; }
SpuBo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package top.codekiller.leyou.pojo.bo;import lombok.Data;import top.codekiller.leyou.pojo.Spu;@Data public class SpuBo extends Spu { private String cname; private String bname; }
Controller先分析:
请求方式:GET 请求路径:/spu/page 请求参数:page:当前页 rows:每页大小 key:过滤条件 saleable:上架或下架 返回结果:商品SPU的分页信息。 编写controller代码:
我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 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; @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); List<Spu> spus=ipage.getRecords(); 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=new PageResult<SpuBo>(ipage.getTotal(),(int )ipage.getPages(),spuBos); return pageResult; } }
CategoryService中扩展查询名称的功能页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,
在CategoryService中添加功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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; @Override public List<Category> queryCategoryByPid (Long pid) { return this .categoryMapper.selectList(new QueryWrapper<Category>().lambda() .eq(Category::getParentId,pid)); } @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()); } }
Mapper1 2 public interface SpuMapper extends BaseMapper <Spu > {}
1 2 public interface SpuDetailMapper extends BaseMapper <SpuDetail > {}
测试
商品管理 商品新增当我们点击新增商品按钮就会出现一个弹窗:
里面把商品的数据分为了4部分来填写:
基本信息:主要是一些简单的文本数据,包含了SPU和SpuDetail的部分数据,如商品分类:是SPU中的cid1
,cid2
,cid3
属性 品牌:是spu中的brandId
属性 标题:是spu中的title
属性 子标题:是spu中的subTitle
属性 售后服务:是SpuDetail中的afterService
属性 包装列表:是SpuDetail中的packingList
属性 商品描述:是SpuDetail中的description
属性,数据较多,所以单独放一个页面 规格参数:商品规格信息,对应SpuDetail中的genericSpec
属性 SKU属性:spu下的所有Sku信息 对应到页面中的四个stepper-content
:
弹窗事件弹窗是一个独立组件:
并且在Goods组件中已经引用它:
并且在页面中渲染:
在新增商品
按钮的点击事件中,改变这个dialog
的show
属性:
基本数据我们先来看下基本数据:
商品分类商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:
刷新页面,可以看到请求已经发出:
效果:
品牌选择 页面品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。
所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:
选择商品分类后,可以看到请求发起:
接下来,我们只要编写后台接口,根据商品分类id,查询对应品牌即可。
后端代码页面需要去后台查询品牌信息,我们自然需要提供:
请求方式:GET
请求路径:/brand/cid/{cid}
请求参数:cid
响应数据:品牌集合
BrandController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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
1 2 3 4 5 6 List<Brand> queryBrandByCid (Long cid) ;
BrandService
1 2 3 4 5 6 7 8 9 10 @Override public List<Brand> queryBrandByCid (Long cid) { return this .brandMapper.selectBrandByCid(cid); }
BrandMapper
根据分类查询品牌有中间表,需要自己编写Sql:
1 2 3 4 5 6 7 @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-editorGitHub的主页:https://github.com/surmon-china/vue-quill-editor
Vue-Quill-Editor是一个基于Quill的富文本编辑器:Quill的官网
使用指南使用非常简单:已经在项目中集成。以下步骤不需操作,仅供参考
第一步:安装,使用npm命令:
1 npm install vue-quill-editor --save
第二步:加载,在js中引入:
全局引入:
1 2 3 4 5 6 import Vue from 'vue' import VueQuillEditor from 'vue-quill-editor' const options = {}; Vue.use(VueQuillEditor, options);
局部引入:
1 2 3 4 5 6 7 8 9 10 11 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 } })
我们这里采用局部引用:
第三步:页面使用:
1 <quill-editor v-model ="goods.spuDetail.description" :options ="editorOption" />
自定义富文本编辑器不过这个组件有个小问题,就是图片上传的无法直接上传到后台,因此我们对其进行了封装,支持了图片的上传。
使用也非常简单:
1 2 3 <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。
等一下, 考虑到以后可能还会根据是否搜索、是否为通用属性等条件过滤,我们多添加几个过滤条件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @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); }
Service1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @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列表的下方,有一个提交按钮
并且绑定了事件
击后会组织数据并向后台提交:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 submit ( ) { if (!this .$refs.basic.validate){ this .$message.error("请先完成表单内容!" ); } 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; }); const skus = this .skus .filter(s => s.enable) .map(({ price, stock, enable, images, indexes, ...rest } ) => { 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 }); 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 { private String cname; private String bname; private SpuDetail spuDetail; private List<Sku> skus; }
1 2 3 4 5 6 7 8 9 10 @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进行保存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Override @Transactional(rollbackFor = Exception.class) public void saveGoods (SpuBo spuBo) { spuBo.setId(null ); spuBo.setSaleable(true ); spuBo.setValid(true ); spuBo.setCreateTime(new Date()); spuBo.setLastUpdateTime(spuBo.getCreateTime()); this .spuMapper.insert(spuBo); SpuDetail spuDetail=spuBo.getSpuDetail(); spuDetail.setSpuId(spuBo.getId()); this .spuDetailMapper.insert(spuDetail); List<Sku> skus=spuBo.getSkus(); skus.forEach(sku->{ sku.setId(null ); sku.setSpuId(spuBo.getId()); sku.setCreateTime(new Date()); sku.setLastUpdateTime(sku.getCreateTime()); this .skuMapper.insert(sku); 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对象 1 2 3 4 5 6 7 8 9 10 11 12 13 @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
1 2 3 4 5 6 7 8 9 @Override public SpuDetail querySpuBySupId (Long spuId) { return spuDetailMapper.selectById(spuId); }
查询Sku分析
请求方式:Get 请求路径:/sku/list 请求参数:id,应该是spu的id 返回结果:sku的集合 GoodsController
1 2 3 4 5 6 7 8 9 10 11 12 13 @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也查询出来
1 2 3 4 @Override public List<Sku> querySkuBySpuId (Long spuId) { return this .skuMapper.querySkuBySpuId(spuId); }
页面回显随便点击一个编辑按钮,发现数据回显完成:
页面提交这里的保存按钮与新增其实是同一个,因此提交的逻辑也是一样的,这里不再赘述。
随便修改点数据,然后点击保存,可以看到浏览器已经发出请求:
后台实现接下来,我们编写后台,实现修改商品接口。
GoodsController
请求方式:PUT 请求路径:/ 请求参数:Spu对象 返回结果:无 1 2 3 4 5 6 7 8 9 10 @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和库存的方法抽取成一个方法
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 @Override @Transactional(rollbackFor = Exception.class) public void updateGoods (SpuBo spuBo) { List<Sku> skus=this .skuMapper.selectList(new QueryWrapper<Sku>().lambda().eq(Sku::getSpuId,spuBo.getId())); skus.forEach(sku -> { this .stockMapper.deleteById(sku.getId()); }); skus.forEach(sku->{ this .skuMapper.deleteById(sku.getId()); }); this .saveSkuAndStock(spuBo); spuBo.setCreateTime(null ); spuBo.setLastUpdateTime(new Date()); spuBo.setValid(null ); spuBo.setSaleable(null ); this .spuMapper.updateById(spuBo); this .spuDetailMapper.updateById(spuBo.getSpuDetail()); } @Transactional(rollbackFor = Exception.class) public void saveSkuAndStock (SpuBo spuBo) { List<Sku> skus=spuBo.getSkus(); skus.forEach(sku->{ sku.setId(null ); sku.setSpuId(spuBo.getId()); sku.setCreateTime(new Date()); sku.setLastUpdateTime(sku.getCreateTime()); this .skuMapper.insert(sku); 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命令即可,这里建议全局安装,以后任意位置可用
1 npm install -g 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目录,输入命令:
域名访问现在我们访问只能通过:http://127.0.0.1:9002
我们希望用域名访问:http://www.leyou.com
第一步,修改hosts文件,添加一行配置:
第二步,修改nginx配置,将www.leyou.com反向代理到127.0.0.1:9002
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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
1 yum install java-1.8.0-openjdk -y
新建一个用户leyou出于安全考虑,elasticsearch默认不允许以root账号运行。
创建用户:
设置密码:
切换用户:
上传安装包,并解压安装包下载:https://www.elastic.co/cn/downloads/elasticsearch
我们将安装包上传到:/home/leyou目录
解压缩:
1 tar -xvf elasticsearch-6.3.0-linux-x86_64.tar.gz
我们把目录重命名:
1 mv elasticsearch-6.3.0 elasticsearch
修改配置我们进入config目录:cd config
需要修改的配置文件有两个:
jvm.options
Elasticsearch基于Lucene的,而Lucene底层是java实现,因此我们需要配置jvm参数。
编辑jvm.options:
默认配置如下:
内存占用太多了,我们调小一些:
elasticsearch.yml
修改数据和日志目录:
1 2 path.data: /home/leyou/elasticsearch/data # 数据目录位置 path.logs: /home/leyou/elasticsearch/logs # 日志目录位置
我们把data和logs目录修改指向了elasticsearch的安装目录。但是data目录并不存在,因此我们需要创建出来。
进入elasticsearch的根目录,然后创建:
修改绑定的ip:
1 2 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目录,可以看到下面的执行文件:
然后输入命令:
错误解决我这里是报了两个错误
错误1:内核过低
如果使用的是centos6,其linux内核版本为2.6。而Elasticsearch的插件要求至少3.5以上版本。不过没关系,我们禁用这个插件即可。
修改elasticsearch.yml文件,在最下面添加如下配置:
1 bootstrap.system_call_filter: false
然后重启
错误2:文件权限不足再次启动,又出错了:
1 [1]: max file descriptors [4096] for elasticsearch process likely too low, increase to at least [65536]
我们用的是leyou用户,而不是root,所以文件权限不足。
首先用root用户登录。
然后修改配置文件:
1 vim /etc/security/limits.conf
添加下面的内容:
1 2 3 4 5 6 7 * soft nofile 65536 * hard nofile 131072 * soft nproc 4096 * hard nproc 4096
错误3:线程数不够centos7不用修改
1 [1]: max number of threads [1024] for user [leyou] is too low, increase to at least [4096]
这是线程数不够。
继续修改配置:
1 vim /etc/security/limits.d/20-nproc.conf
修改下面的内容:
改为:
错误4:进程虚拟内存1 [3]: max virtual memory areas vm.max_map_count [65530] likely too low, increase to at least [262144]
vm.max_map_count:限制一个进程可以拥有的VMA(虚拟内存区域)的数量,继续修改配置文件, :
添加下面内容:
然后执行命令:
错误5: 默认设置1 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
取消注释保留一个节点
1 cluster.initial_master_nodes: ["node-1"]
并将node.name: "node-1"
的注释打开
重启终端所有错误修改完毕,一定要重启你的连接终端,否则配置无效(我这里没有重启完全ok)。
可以看到绑定了两个端口:
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服务器的地址:
1 elasticsearch.hosts: ["http://172.16.145.141:9200"]
运行
进入安装目录下的bin目录:
双击运行:
发现kibana的监听端口是5601
我们访问:http://127.0.0.1:5601
控制台选择左侧的DevTools菜单,即可进入控制台页面:
在页面右侧,我们就可以输入请求,访问Elasticsearch了。
安装ik分词器Lucene的IK分词器早在2012年已经没有维护了,现在我们要使用的是在其基础上维护升级的版本,并且开发为ElasticSearch的集成插件了,与Elasticsearch一起维护升级,版本也保持一致
下载地址:https://github.com/medcl/elasticsearch-analysis-ik/releases
安装将下载后的压缩包上传的elasticsearch目录的plugins目录下
使用unzip命令解压:
1 unzip elasticsearch-analysis-ik-7.5.0.zip -d ik-analyzer
将压缩包删除
1 rm -rf elasticsearch-analysis-ik-7.5.0.zip
然后重启elasticsearch:
测试大家先不管语法,我们先测试一波。
在kibana控制台输入下面的请求:
1 2 3 4 5 POST _analyze { "analyzer" : "ik_max_word" , "text" : "我是中国人" }
运行得到结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 { "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 } ] }
APIElasticsearch提供了Rest风格的API,即http请求接口,而且也提供了各种语言的客户端API
Rest风格的API文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html
客户端APIElasticsearch支持的客户端非常多: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类似的。
对比关系:
1 2 3 4 5 6 7 索引(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格式:
1 2 3 4 5 6 { "settings" : { "number_of_shards" : 3 , "number_of_replicas" : 2 } }
settings:索引的设置number_of_shards:分片数量 number_of_replicas:副本数量 测试我们先用postman来试试
可以看到索引创建成功了。
使用
kibana的控制台,可以对http请求进行简化,示例:
相当于是省去了elasticsearch的服务器地址
而且还有语法提示,非常舒服。
查看索引语法
Get请求可以帮我们查看索引信息,格式:
或者,我们可以使用*来查询所有索引配置:
删除索引删除索引使用DELETE请求
语法
示例
当然,我们也可以用HEAD请求,查看索引是否存在:
映射配置索引有了,接下来肯定是添加数据。但是,在添加数据之前必须定义映射。
什么是映射?
映射是定义文档的过程,文档包含哪些字段,这些字段是否保存,是否索引,是否分词等
只有配置清楚,Elasticsearch才会帮我们进行索引的创建(不一定)
创建映射字段语法
请求方式依然是PUT
1 2 3 4 5 6 7 8 9 10 11 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分词器 示例
发起请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 PUT heima/_mapping/goods { "properties" : { "title" : { "type" : "text" , "analyzer" : "ik_max_word" }, "images" : { "type" : "keyword" , "index" : false }, "price" : { "type" : "float" } } }
在7.0.0之后的版本,可能会报错
原因:
解决
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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" } } }
可以用下面的方式替代
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 PUT test_store/_mapping { "properties" : { "goods" : { "properties" : { "title" : { "type" : "text" , "analyzer" : "ik_max_word" }, "images" : { "type" : "keyword" , "index" : "false" }, "price" : { "type" : "float" } } } } }
查看映射关系语法:
示例:
1 GET /test_store/_mapping
响应:
字段属性详解 type Elasticsearch中支持的数据类型非常丰富:
我们说几个关键的:
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请求,可以向一个已经存在的索引中添加数据。
语法:
1 POST /索引名 { "key":"value" }
1 2 3 4 5 6 7 8 POST heima/goods { "goods" : { "title" :"小米手机" , "images" :"http://image.leyou.com/12479122.jpg" , "price" :2699.00 } }
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "_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查看数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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,可以这么做:
1 2 3 4 5 PUT /索引名/_doc/id值 { ... }
示例:
1 2 3 4 5 6 7 8 PUT heima/goods/123 { "goods" : { "title" :"红米手机" , "images" :"http://image.leyou.com/12479122.jpg" , "price" :999.00 } }
得到的数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "_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映射,它也可以根据你输入的数据来判断类型,动态添加数据映射。
测试一下:
1 2 3 4 5 6 7 8 9 10 put /heima/goods/3 { "goods" : { "title" :"超米手机" , "images" :"http://image.leyou.com/12479122.jpg" , "price" :2899.00 , "stock" : 200 , "saleable" :true } }
我们额外添加了stock库存,和saleable是否上架两个字段。
来看结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "_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 } }
在看下索引的映射关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 { "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的数据进行修改:
1 2 3 4 5 6 7 POST(PUT) /heima/goods/5 { "title" : "小米电视" , "images" : "http://image.leyou.com/154.jpg" , "price" : 1099.00 , "saleable" : true }
1 2 3 4 5 6 7 8 9 10 11 12 { "_index" : "heima" , "_type" : "goods" , "_id" : "5" , "_score" : 2.3491275 , "_source" : { "title" : "小米电视" , "images" : "http://image.leyou.com/154.jpg" , "price" : 1099 , "saleable" : true } },
删除数据删除使用DELETE请求,同样,需要根据id进行删除:
语法
示例:
1 DELETE /heima/goods/4x3XnHEBx0JRCCpwPnSX
查询数据我们从4块来讲查询:
基本查询基本语法
1 2 3 4 5 6 7 8 GET /索引名/_search { "query" :{ "查询类型" :{ "查询条件" :"查询条件值" } } }
这里的query代表一个查询对象,里面可以有不同的查询属性
查询类型:例如:match_all
, match
,term
, range
等等 查询条件:查询条件会根据类型的不同,写法也有差异,后面详细讲解 查询所有示例:
1 2 3 4 5 6 GET /heima/_search { "query" : { "match_all" : {} } }
query
:代表查询对象match_all
:代表查询所有1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 { "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 } } ] } }
匹配查询1 2 3 4 5 6 7 8 9 10 GET /heima/_search { "query" : { "match" : { "title" : { "query" : "小米电视手机" , } } } }
match
类型查询,会把查询条件进行分词,然后进行查询,多个词条之间是or的关系
1 2 3 4 5 6 7 8 GET /heima/_search { "query" : { "match" : { "goods.title" : "小米电视" } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 "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
,可以这样做:
1 2 3 4 5 6 7 8 9 10 11 GET /heima/_search { "query" : { "match" : { "goods.title" : { "query" : "小米电视" , "operator" : "and" } } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 { "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
间二选一有点过于非黑即白。 如果用户给定的条件分词后有 5 个查询词项,想查找只包含其中 4 个词的文档,该如何处理?将 operator 操作符参数设置成 and
只会将此文档排除。
有时候这正是我们期望的,但在全文搜索的大多数应用场景下,我们既想包含那些可能相关的文档,同时又排除那些不太相关的。换句话说,我们想要处于中间某种结果。
match
查询支持 minimum_should_match
最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数
,因为我们无法控制用户搜索时输入的单词数量:
1 2 3 4 5 6 7 8 9 10 11 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
类似,不同的是它可以在多个字段中查询
1 2 3 4 5 6 7 8 9 10 GET heima/_search { "query" : { "multi_match" : { "query" : "小米" , "fields" : ["title" ,"subtitle" ] } } }
新增一条记录
1 2 3 4 5 6 7 8 9 POST /heima/goods { "goods": { "title":"华为手机", "subTitle":"小米是小虾米", "images":"http://image.leyou.com/12479122.jpg", "price":2699.00 } }
本例中,我们会在title字段和subtitle字段中查询小米
这个词
词条匹配(term)term
查询被用于精确值 匹配,这些精确值可能是数字、时间、布尔或者那些未分词 的字符串
1 2 3 4 5 6 7 8 9 10 GET heima/_search { "query" : { "term" : { "title" : { "value" : "小米" } } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 { "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 查询一样,但它允许你指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么这个文档满足条件:
1 2 3 4 5 6 7 8 9 10 11 GET /heima/_search { "query" : { "terms" : { "title" : [ "小米" , "华为" ] } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 { "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
的过滤
直接指定字段示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 GET /heima/_search { "_source" : ["title" ], "query" : { "terms" : { "title" : [ "小米" , "华为" ] } } }
返回的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 { "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:来指定不想要显示的字段 二者都是可选的。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /heima/_search { "_source" : { "excludes" : ["title" ], "includes" : ["images" ] }, "query" : { "terms" : { "title" : [ "小米" , "华为" ] } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 { "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
(或)的方式进行组合
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 GET heima/_search { "query" : { "bool" : { "should" : [ { "match" : { "title" : "小米" } }, { "terms" : { "price" : [ "1099" , "3099" ] } } ], "must" : [ { "term" : { "saleable" : { "value" : "true" } } } ] } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 { "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
查询找出那些落在指定区间内的数字或者时间
1 2 3 4 5 6 7 8 9 10 11 12 13 # 范围查询 GET heima/_search { "query" : { "range" : { "price" : { "gte" : 2300 , "lte" : 4000 } } } }
range
查询允许以下字符:
操作符 说明 gt 大于 gte 大于等于 lt 小于 lte 小于等于
模糊查询(fuzzy)fuzzy
查询是 term
查询的模糊等价。它允许用户搜索词条与实际词条的拼写出现偏差
,但是偏差的编辑距离不得超过2:
1 2 3 4 5 6 7 8 9 10 11 12 # 模糊查询 GET heima/_search { "query" : { "fuzzy" : { "title" : { "value" : "小米手" } } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 { "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
来指定允许的编辑距离:
1 2 3 4 5 6 7 8 9 10 11 12 13 # 模糊查询 GET heima/_search { "query" : { "fuzzy" : { "title" : { "value" : "小米手" , "fuzziness" : 2 } } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 { "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
方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # filter GET heima/_search { "query" : { "bool" : { "must" : [ { "match" : { "title" : "手机" } } ], "filter" : { "range" : { "price" : { "gte" : 2699 , "lte" : 3099 } } } } } }
注意:filter
中还可以再次进行bool
组合条件过滤。
无查询条件,直接过滤
如果一次查询只有过滤,没有查询条件,不希望进行评分,我们可以使用constant_score
取代只有 filter 语句的 bool 查询。在性能上是完全相同的,但对于提高查询简洁性和清晰度有很大帮助。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # filter GET heima/_search { "query" : { "bool" : { "filter" : { "range" : { "price" : { "gte" : 2699 , "lte" : 3099 } } } } } }
此刻评分为0
排序sort
可以让我们按照不同的字段进行排序,并且通过order
指定排序的方式。
可以单字段排序
也可以多字段排序
。
多字段排序如果第一个字段相同,会按照第二个字段排序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 # filter和sort GET heima/_search { "query" : { "bool" : { "must" : [ { "match" : { "title" : "手机" } } ], "filter" : { "range" : { "price" : { "gte" : 2699 , "lte" : 3099 } } } } }, "sort" : [ { "price" : { "order" : "desc" }, "_id" : { "order" : "asc" } } ] }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 { "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,10 20,2030,30 40等。
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:求总数 …… 为了测试聚合,我们先批量导入一些数据
创建索引:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 PUT /cars { "settings" : { "number_of_replicas" : 0 , "number_of_shards" : 1 }, "mappings" : { "transactions" : { "properties" : { "color" : { "type" : "keyword" }, "make" : { "type" : "keyword" } } } } }
注意 :在ES中,需要进行聚合、排序、过滤的字段其处理方式比较特殊,因此不能被分词。这里我们将color和make这两个文字类型的字段设置为keyword类型,这个类型不会被分词,将来就可以参与聚合
导入数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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
来划分桶
1 2 3 4 5 6 7 8 9 10 11 GET cars/_search { "size" : 0 , "aggs" : { "popular_color" : { "terms" : { "field" : "transactions.color.keyword" } } } }
color的类型为keyword,所以必须加上keyword,不然会报错。
size: 查询条数,这里设置为0,因为我们不关心搜索到的数据,只关心聚合结果,提高效率 aggs:声明这是一个聚合查询,是aggregations的缩写popular_colors:给这次聚合起一个名字,任意。 结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 { "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使用哪个字段
,使用何种度量方式
进行运算,这些信息要嵌套在桶
内,度量
的运算会基于桶
内的文档进行
现在,我们为刚刚的聚合结果添加 求价格平均值的度量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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:度量运算的字段 结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 { "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
字段再进行分桶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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字段进行划分 结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 { "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,会落入哪个阶梯区间呢?计算公式如下:
1 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 GET /cars/_search { "size" : 0 , "aggs" : { "price_histogram" : { "histogram" : { "field" : "transactions.price" , "interval" : 5000 , "min_doc_count" : 1 } } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 { "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方式需要你自己指定每一组的起始和结束大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 GET cars/_search { "size" : 0 , "aggs" : { "price_range" : { "range" : { "field" : "transactions.price" , "ranges" : [ { "from" : 50000 , "to" : 100000 }, { "from" : 5000 , "to" : 50000 } ] } } } }
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 { "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 ElasticsearchElasticsearch提供的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
如下配置:
1 2 3 4 5 spring: elasticsearch: rest: uris: - 172.16 .145 .141 :9200
使用ElasticsearchTemplate
如下配置
1 2 3 4 5 spring: data: elasticsearch: cluster-name: elasticsearch cluster-nodes: 172.16 .145 .141 :9300
实体类和注解1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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通过注解来声明字段的映射属性,有下面的三个注解:
Template索引操作创建索引
ElasticsearchTemplate中提供了创建索引的API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @SpringBootTest public class ElasticsearchTest { @Autowired private ElasticsearchTemplate elasticsearchTemplate; @Test public void testIndex () throws Exception { this .elasticsearchTemplate.createIndex(Item.class); this .elasticsearchTemplate.putMapping(Item.class); } }
Repository文档操作Spring Data 的强大之处,就在于你不用写任何DAO处理,自动根据方法名或类的信息进行CRUD操作。只要你定义一个接口,然后继承Repository提供的一些子接口,就能具备各种基本的CRUD功能。
我们只需要定义接口,然后继承它就OK了。
1 2 public interface ItemRepository extends ElasticsearchRepository <Item ,Long > {}
新增文档1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @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请求是类似的。
1 2 3 4 5 6 7 @Test public void testUpdateDocument () throws Exception { Item item = new Item(1L , "黑米手机666" , "手机" , "小米" , 3499.00 , "http://image.leyou.com/13123.jpg" ); itemRepository.save(item); }
基本查询ElasticsearchRepository提供了一些基本的查询方法:
我们来试试查询所有:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Test public void testQuery () { Optional<Item> item=this .itemRepository.findById(1L ); System.out.println(item); 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}}}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public interface ItemRepository extends ElasticsearchRepository <Item ,Long > { List<Item> findByTitle (String title) ; List<Item> findByPriceBetween (Double d1,Double d2) ; }
测试方法
1 2 3 4 5 6 7 8 9 10 11 12 13 @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); }
结果:
虽然基本查询和自定义方法已经很强大了,但是如果是复杂查询(模糊、通配符、词条查询等)就显得力不从心了。此时,我们只能使用原生查询。
高级查询 基本查询1 2 3 4 5 6 7 8 9 10 11 @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提供很多可用的查询方式,但是不够灵活。如果想玩过滤或者聚合查询等就很难了。
自定义查询1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Test public void testNative () { NativeSearchQueryBuilder builder=new NativeSearchQueryBuilder(); builder.withQuery(QueryBuilders.matchQuery("title" ,"手机" )); builder.withSort(SortBuilders.fieldSort("price" ).order(SortOrder.ASC)); 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
如下获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @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()); 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Test public void testNativeQueryAggregation () throws Exception { NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{}, null )); queryBuilder.addAggregation(AggregationBuilders.terms("brandAgg" ).field("brand" )); Page<Item> pageResult = this .itemRepository.search(queryBuilder.build()); AggregatedPage<Item> aggregatedPageResult = (AggregatedPage<Item>) pageResult; ParsedStringTerms parsedStringTerms = (ParsedStringTerms) aggregatedPageResult.getAggregation("brandAgg" ); List<ParsedStringTerms.ParsedBucket> buckets = (List<ParsedStringTerms.ParsedBucket>) parsedStringTerms.getBuckets(); for (ParsedStringTerms.ParsedBucket bucket : buckets) { System.out.println(bucket.getKeyAsString()); System.out.println(bucket.getDocCount()); } }
关键API:
AggregationBuilders
:聚合的构建工厂类。所有聚合都由这个类来构建,看看他的静态方法:
AggregatedPage
:聚合查询的结果类。它是Page
的子接口:
AggregatedPage
在Page
功能的基础上,拓展了与聚合相关的功能,它其实就是对聚合结果的一种封装,大家可以对照聚合结果的JSON结构来看。
而返回的结果都是Aggregation类型对象,不过根据字段类型不同,又有不同的子类表示
我们看下页面的查询的JSON结果与Java类的对照关系:
基本商品搜索 创建搜索服务创建module:leyou-search
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 <?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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
引导类:
1 2 3 4 5 6 7 8 9 10 @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信息集、可搜索的规格参数
最终的数据结构我们创建一个类,封装要保存到索引库的数据,并设置映射属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 @Data @AllArgsConstructor @NoArgsConstructor @Document(indexName = "goods",type="docs",replicas = 0,shards = 1) public class Goods { @Id private Long id; @Field(type = FieldType.Text,analyzer = "ik_max_word") private String all; @Field(type=FieldType.Keyword,index = false) private String subTitle; private Long brandId; private Long cid1; private Long cid2; private Long cid3; private Date createTime; private List<Long> price; @Field(type=FieldType.Keyword,index=false) private String skus; private Map<String,Object> specs; }
一些特殊字段解释:
all:用来进行全文检索的字段,里面包含标题、商品分类信息
price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤
skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段
specs:所有规格参数的集合。key是参数名,值是参数值。
例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:
1 2 3 4 5 6 { "specs" :{ "内存" :[4 G,6 G], "颜色" :"红色" } }
当存储到索引库时,elasticsearch会处理为两个字段:
specs.内存:[4G,6G] specs.颜色:红色 另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。
商品微服务提供接口索引库中的数据来自于数据库,我们不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以我们只能调用商品微服务提供的接口服务。
先思考我们需要的数据:
SPU信息(Spu) SKU信息(Sku) SPU的详情 (SpuDetail) 商品分类名称(拼接all字段)(Category) 品牌名称 (Brand) 规格参数(SpuDetail.genericSpec,Sku.ownSpec) 再思考我们需要哪些服务:
第一:分页查询spu的服务,已有。 第二:根据spuId查询sku的服务,已有 第三:根据spuId查询SpuDetail的服务,已有 第四:根据商品分类id,查询商品分类名称,无
第五:根据商品品牌id,查询商品的品牌,无
第六:规格参数接口 已有 因此我们需要额外提供一个查询商品分类名称的接口。
商品分类名称查询Controller
在CategoryController中添加接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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
1 2 3 4 5 @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
1 2 3 4 5 6 7 8 9 10 11 12 13 @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
1 2 3 4 @Override public Brand queryBrandById (Long id) { return this .brandMapper.selectById(id); }
编写FeignClient 问题展现操作leyou-search工程
现在,我们要在搜索微服务调用商品微服务的接口。
第一步要在leyou-search工程中,引入商品微服务依赖:leyou-item-interface
。
1 2 3 4 5 6 7 8 9 10 11 <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 >
配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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 ReadTimeout: 50000
编写API我们的服务提供方不仅提供实体类,还要提供api接口声明 调用方不用自己编写接口方法声明,直接继承提供方给的Api接口即可, 第一步:服务的提供方在leyou-item-interface
中提供API接口,并编写接口声明:
商品分类服务接口:
返回值不再使用ResponseEntity:
GoodsApi
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public interface GoodsApi { @GetMapping("spu/detail/{spuId}") SpuDetail querySpuDetailBySpuId (@PathVariable("spuId") Long spuId) ; @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) ; @GetMapping("sku/list") List<Sku> querySkuBySpuId (@RequestParam("id") Long spuId) ; }
BrandApi
1 2 3 4 5 6 7 8 9 10 public interface BrandApi { @GetMapping("brand/{id}") Brand queryBrandById (@PathVariable("id") Long id) ; }
CategoryApi
1 2 3 4 5 6 7 8 9 10 public interface CategoryApi { @GetMapping("category") List<String> queryNameByIds (@RequestParam("ids") List<Long> ids) ; }
SpecificationApi
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public interface SpecificationApi { @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:
1 2 3 @FeignClient(value = "item-service") public interface GoodsClient extends GoodsApi {}
商品分类的FeignClient:
1 2 3 @FeignClient(value = "item-service") public interface CategoryClient extends CategoryApi {}
品牌的FeignClient:
1 2 3 @FeignClient("item-service") public interface BrandClient extends BrandApi {}
规格参数的FeignClient:
1 2 3 @FeignClient("item-service") public interface SpecificationClient extends SpecificationApi {}
是不是简单多了?
导入数据 创建GoodsRepository
1 2 public interface GoodsRepository extends ElasticsearchRepository <Goods , Long > {}
创建索引并导入数据导入数据其实就是查询数据,然后把查询到的Spu转变为Goods来保存,因此我们先编写一个SearchService,然后在里面定义一个方法, 把Spu转为Goods
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 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 { List<String> names=this .categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3())); Brand brand=this .brandClient.queryBrandById(spu.getBrandId()); List<Sku> skus = this .goodsClient.querySkuBySpuId(spu.getId()); List<Long> prices=skus.stream().map(sku-> sku.getPrice()).collect(Collectors.toList()); 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()); map.put("image" ,StringUtils.isBlank(sku.getImages())?"" :StringUtils.split(sku.getImages(),"," )[0 ]); skuMapList.add(map); }); List<SpecParam> params=this .specificationClient.queryParams(null , spu.getCid3(), null , true ); 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()){ 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 { 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()); goods.setAll(spu.getTitle()+" " + StringUtils.join(names," " ) +" " +brand.getName()); goods.setPrice(prices); String skuInfo = null ; try { skuInfo = MAPPER.writeValueAsString(skuMapList); } catch (JsonProcessingException e) { log.error("sku信息转化为json异常:" +e.toString()); } goods.setSkus(skuInfo); goods.setSpecs(specs); return goods; } 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; } }
因为过滤参数中有一类比较特殊,就是数值区间:
所以我们在存入时要进行处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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,然后写入索引库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 @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 { 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
1 2 3 4 5 6 7 8 9 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})
1 2 3 4 5 6 7 8 9 10 @EnableDiscoveryClient @EnableFeignClients @SpringBootApplication(exclude= {DataSourceAutoConfiguration.class}) public class LeyouSearchApplication { public static void main (String[] args) { SpringApplication.run(LeyouSearchApplication.class, args); } }
错误三:响应超时,而造成异常报错
解决:修改ribbon的连接超时时间和请求处理的超时时间
1 2 3 ribbon: ConnectTimeout: 50000 ReadTimeout: 50000
实现基本搜索 页面分析 页面跳转在首页的顶部,有一个输入框:
当我们输入任何文本,点击搜索
,就会跳转到搜索页search.html
了:
并且将搜索关键字以请求参数携带过来:
我们打开search.html
,在最下面会有提前定义好的Vue实例:
1 2 3 4 5 6 7 8 9 10 11 <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中定义一个对象,记录请求的参数:
1 2 3 4 5 data: { search:{ key:"", // 搜索页面的关键字 } }
我们通过钩子函数created,在页面加载时获取请求参数,并记录下来。
1 2 3 4 5 6 7 8 9 10 11 12 13 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(); }
然后发起请求,搜索数据。
1 2 3 4 5 6 7 methods: { loadData(){ ly.http.post("/search/page", this.search).then(resp=>{ console.log(resp); }); } }
我们这里使用ly
是common.js中定义的工具对象。 这里使用的是post请求,这样可以携带更多参数,并且以json格式发送 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 <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数据: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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; } return Math.max(DEFAULT_PAGE, page); } public void setPage (Integer page) { this .page = page; } public Integer getSize () { return DEFAULT_SIZE; } }
返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类 代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @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)); 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处理时忽略空值:
1 2 3 spring: jackson: default-property-inclusion: non_null
页面渲染页面已经拿到了结果,接下来就要渲染样式了。
保存搜索结果首先,在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
我们的代码:
1 2 3 4 5 6 7 <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-总页数
1 2 3 4 5 6 7 8 9 10 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
传递过去:
1 2 3 4 5 6 7 8 9 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 总页数,稍作改进:
分页条的其它部分就比较简单了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <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" onclick="alert(1)">确定</button> </span> </div> </div>
点击分页做什么点击分页按钮后,自然是要修改page
的值
所以,我们在上一页
、下一页
按钮添加点击事件,对page进行修改,在数字按钮上绑定点击事件,点击直接修改page:
翻页事件的方法:
1 2 3 4 5 6 7 8 9 10 prevPage ( ) { if (this .search.page > 1 ){ this .search.page-- } }, nextPage ( ) { if (this .search.page < this .totalPage){ this .search.page++ } }
当page
发生变化,我们应该去后台重新查询数据。
不过,如果我们直接发起ajax请求,那么浏览器的地址栏中是不会有变化的,没有记录下分页信息。如果用户刷新页面,那么就会回到第一页。
这样不太友好,我们应该把搜索条件记录在地址栏的查询参数中 。
因此,我们监听search的变化,然后把search的过滤字段拼接在url路径后:
1 2 3 4 5 6 7 8 9 watch:{ search:{ deep:true , handler (val ) { window .location.href = "http://www.leyou.com/search.html?" + ly.stringify(val); } } },
刷新页面测试,然后就出现重大bug:页面无限刷新!为什么?
因为Vue实例初始化的钩子函数中,我们读取请求参数,赋值给search的时候,也触发了watch监视!也就是说,每次页面创建完成,都会触发watch,然后就会去修改window.location路径,然后页面被刷新,再次触发created钩子,又触发watch,周而复始,无限循环。
所以,我们需要在watch中进行监控,如果发现是第一次初始化,则不继续向下执行。
那么问题是,如何判断是不是第一次?
第一次初始化时,search中的key值肯定是空的,所以,我们这么做:
1 2 3 4 5 6 7 8 9 10 11 12 13 watch:{ search:{ deep:true , handler (val,old ) { if (!old || !old.key){ return ; } window .location.href = "http://www.leyou.com/search.html?" + ly.stringify(val); } } }
再次刷新,OK了!
页面顶部分页条在页面商品列表的顶部,也有一个分页条:
我们把这一部分,也加上点击事件:
1 2 3 4 5 6 <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 >
代码1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 <!--上方页数跳转--> <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 ; } const search = ly.parse(location.search.substring(1 )); 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; }).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" ) } });
商品搜索过滤 过滤功能分析首先看下页面要实现的效果:
整个过滤部分有3块:
顶部的导航,已经选择的过滤条件展示:商品分类面包屑,根据用户选择的商品分类变化 其它已选择过滤参数 过滤条件展示,又包含3部分 展开或收起的过滤条件的按钮 顶部导航要展示的内容跟用户选择的过滤条件有关。
比如用户选择了某个商品分类,则面包屑中才会展示具体的分类 比如用户选择了某个品牌,列表中才会有品牌信息。 所以,这部分需要依赖第二部分:过滤条件的展示和选择。因此我们先不着急去做。
展开或收起的按钮是否显示,取决于过滤条件有多少,如果很少,那么就没必要展示。所以也是跟第二部分的过滤条件有关。
这样分析来看,我们必须先做第二部分:过滤条件展示。
生成分类和品牌过滤先来看分类和品牌。在我们的数据库中已经有所有的分类和品牌信息。在这个位置,是不是把所有的分类和品牌信息都展示出来呢?
显然不是,用户搜索的条件会对商品进行过滤,而在搜索结果中,不一定包含所有的分类和品牌,直接展示出所有商品分类,让用户选择显然是不合适的。
无论是分类信息,还是品牌信息,都应该从搜索的结果商品中进行聚合得到。
### 扩展返回的结果
原来,我们返回的结果是PageResult对象,里面只有total、totalPage、items 3个属性。但是现在要对商品分类和品牌进行聚合,数据显然不够用,我们需要对返回的结果进行扩展,添加分类和品牌的数据。
那么问题来了:以什么格式返回呢?
看页面:
分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name
品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据
我们新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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 > { 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 @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)); queryBuilder.withPageable(PageRequest.of(request.getPage()-1 ,request.getSize())); queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id" ,"skus" ,"subTitle" },null )); String categotyAggName="categories" ; String brandAggName="brands" ; queryBuilder.addAggregation(AggregationBuilders.terms(categotyAggName).field("cid3" )); queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId" )); AggregatedPage<Goods> goodsPage=(AggregatedPage<Goods>) this .goodsRepository.search(queryBuilder.build()); List<Map<String,Object>> categories=getCategotyAggResult(goodsPage.getAggregation(categotyAggName)); List<Brand> brands=getBrandAggResult(goodsPage.getAggregation(brandAggName)); return new SearchResult(goodsPage.getTotalElements(),goodsPage.getTotalPages(),goodsPage.getContent(),categories,brands); } private List<Map<String,Object>> getCategotyAggResult(Aggregation aggregation){ LongTerms terms = (LongTerms) aggregation; return ((LongTerms) aggregation).getBuckets().stream().map(bucket -> { Map<String,Object> map=new HashMap<>(); Long id=bucket.getKeyAsNumber().longValue(); List<String> names=this .categoryClient.queryNameByIds(Arrays.asList(id)); map.put("id" ,id); map.put("name" ,names.get(0 )); return map; }).collect(Collectors.toList()); } private List<Brand> getBrandAggResult (Aggregation aggregation) { LongTerms terms = (LongTerms) aggregation; return ((LongTerms) aggregation).getBuckets().stream().map(bucket -> this .brandClient.queryBrandById(bucket.getKeyAsNumber().longValue()) ).collect(Collectors.toList()); }
测试:
### 页面渲染数据
过滤参数数据结构来看下页面的展示效果:
虽然分类、品牌内容都不太一样,但是结构相似,都是key和value的结构。
而且页面结构也极为类似:
所以,我们可以把所有的过滤条件放入一个数组
中,然后在页面利用v-for
遍历一次生成。
其基本结构是这样的:
1 2 3 4 5 6 [ { k:"过滤字段名" , options:[{},{}] } ]
我们先在data中定义数组:filters,等待组装过滤参数:
1 2 3 4 5 6 7 8 9 10 11 data: { ly, search: { key: "" , currentPage: 1 }, goodsList: [], totalPage: 1 , filters: [] },
然后在查询搜索结果的回调函数中,对过滤参数进行封装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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属性可以被监控 //初始化分类过滤项 this.filters.push({ k: "分类", options: data.category }); //初始化品牌过滤项 this.filters.push({ k: "品牌", options: data.brands }); }).catch(()=>{ })
然后刷新页面,通过浏览器工具,查看封装的结果:
页面渲染数据首先看页面原来的代码
我们注意到,虽然页面元素是一样的,但是品牌会比其它搜索条件多出一些样式,因为品牌是以图片展示。需要进行特殊处理。数据展示是一致的,我们采用v-for处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <div class ="type-wrap" v-for ="(filter,index) in filters" :key ="index" v-if ="filter.k!='品牌'" > <div class ="fl key" > {{filter.k}}</div > <div class ="fl value" > <ul class ="type-list" > <li v-for ="option in filter.options" :key ="option.id" > <a > {{option.name}}</a > </li > </ul > </div > <div class ="fl ext" > </div > </div > <div class ="type-wrap logo" v-else > <div class ="fl key brand" > {{filter.k}}</div > <div class ="value logos" > <ul class ="logo-list" > <li v-for ="option in filter.options"