目录
1.7.1.Rest风格API(就是http请求可以访问,用postman也可以测试)
3.4.创建父工程
创建统一的父工程:leyou
然后将pom文件修改成我这个样子:
<?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>com.leyou.parent</groupId>
<artifactId>leyou</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>leyou</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Finchley.RC1</spring-cloud.version>
<mybatis.starter.version>1.3.2</mybatis.starter.version>
<mapper.starter.version>2.0.2</mapper.starter.version>
<druid.starter.version>1.1.9</druid.starter.version>
<mysql.version>5.1.32</mysql.version>
<pageHelper.starter.version>1.2.3</pageHelper.starter.version>
<leyou.latest.version>1.0.0-SNAPSHOT</leyou.latest.version>
<fastDFS.client.version>1.26.1-RELEASE</fastDFS.client.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- springCloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- mybatis启动器 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.starter.version}</version>
</dependency>
<!-- 通用Mapper启动器 -->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>${mapper.starter.version}</version>
</dependency>
<!-- 分页助手启动器 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pageHelper.starter.version}</version>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--FastDFS客户端-->
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
<version>${fastDFS.client.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
可以发现,我们在父工程中引入了SpringCloud等,很多以后需要用到的依赖,以后创建的子工程就不需要自己引入了。
可以删除src目录,工程结构如下:
3.5.创建EurekaServer
3.5.1.创建工程
我们的注册中心,起名为:leyou-registry
3.5.2.添加依赖
添加EurekaServer的依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-registry</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
</project>
3.5.3.编写启动类
@SpringBootApplication
@EnableEurekaServer
public class LeyouRegistryApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouRegistryApplication.class, args);
}
}
3.5.4.配置文件
server:
port: 10086
spring:
application:
name: leyou-registry
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:${server.port}/eureka
register-with-eureka: false # 把自己注册到eureka服务列表
fetch-registry: false # 拉取eureka服务信息
server:
enable-self-preservation: false # 关闭自我保护
eviction-interval-timer-in-ms: 5000 # 每隔5秒钟,进行一次服务列表的清理
3.6.创建Zuul网关
3.6.1.创建工程
我们命名为:leyou-gateway
3.6.2.添加依赖
这里我们需要添加Zuul和EurekaClient的依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-gateway</artifactId>
<version>1.0.0-SNAPSHOT</version>
<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>
<!-- springboot提供微服务检测接口,默认对外提供几个接口 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
3.6.3.编写启动类
@SpringBootApplication
@EnableDiscoveryClient
@EnableZuulProxy
public class LeyouGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouGatewayApplication.class, args);
}
}
3.6.4.配置文件
server:
port: 10010
spring:
application:
name: api-gateway
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
registry-fetch-interval-seconds: 5
instance:
prefer-ip-address: true
ip-address: 127.0.0.1
instance-id: ${spring.application.name}:${server.port}
zuul:
prefix: /api # 添加路由前缀
retryable: true
ribbon:
ConnectTimeout: 250 # 连接超时时间(ms)
ReadTimeout: 2000 # 通信超时时间(ms)
OkToRetryOnAllOperations: true # 是否对所有操作重试
MaxAutoRetriesNextServer: 1 # 同一服务不同实例的重试次数
MaxAutoRetries: 1 # 同一实例的重试次数
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMillisecond: 10000 # 熔断超时时长:10000ms
3.6.5.项目结构
目前,leyou下有两个子模块:
- leyou-registry:服务的注册中心(EurekaServer)
- leyou-gateway:服务网关(Zuul)
目前,服务的结构如图所示
3.7.创建商品微服务
既然是一个全品类的电商购物平台,那么核心自然就是商品。因此我们要搭建的第一个服务,就是商品微服务。其中会包含对于商品相关的一系列内容的管理,包括:
- 商品分类管理
- 品牌管理
- 商品规格参数管理
- 商品管理
- 库存管理
3.7.1.微服务的结构
因为与商品的品类相关,我们的工程命名为leyou-item
.
需要注意的是,我们的leyou-item是一个微服务,那么将来肯定会有其它系统需要来调用服务中提供的接口,获取的接口数据,也需要对应的实体类来封装,因此肯定也会使用到接口中关联的实体类。
因此这里我们需要使用聚合工程,将要提供的接口及相关实体类放到独立子工程中,以后别人引用的时候,只需要知道坐标即可。
我们会在leyou-item中创建两个子工程:
- leyou-item-interface:主要是对外暴露的接口及相关实体类
- leyou-item-service:所有业务逻辑及内部使用接口
调用关系如图所示:
因为是聚合工程,所以把项目打包方式设置为pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item</artifactId>
<version>1.0.0-SNAPSHOT</version>
<!-- 打包方式为pom -->
<packaging>pom</packaging>
</project>
3.7.5.整个微服务结构
如图所示:
3.7.6.添加依赖
接下来我们给leyou-item-service
中添加依赖:
思考一下我们需要什么?
思考一下我们需要什么?
- Eureka客户端
- web启动器
- mybatis启动器
- 通用mapper启动器
- 分页助手启动器
- 连接池,我们用默认的Hykira
- mysql驱动
- 千万不能忘了,我们自己也需要
ly-item-interface
中的实体类
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou-item</artifactId>
<groupId>com.leyou.item</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<!-- web启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- eureka客户端 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- mybatis的启动器 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- 通用mapper启动器 -->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
</dependency>
<!-- 分页助手启动器 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
<!-- jdbc启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- springboot检测服务启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
3.7.7.编写启动和配置
在整个ly-item工程
中,只有ly-item-service
是需要启动的。因此在其中编写启动类即可:
@SpringBootApplication
@EnableDiscoveryClient
public class LyItemService {
public static void main(String[] args) {
SpringApplication.run(LyItemService.class, args);
}
}
然后是全局属性文件:
server:
port: 8081
spring:
application:
name: item-service
datasource:
url: jdbc:mysql://localhost:3306/heima
username: root
password: 123
hikari:
maximum-pool-size: 30
minimum-idle: 10
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
prefer-ip-address: true
ip-address: 127.0.0.1
instance-id: ${spring.application.name}:${server.port}
3.8.添加商品微服务的路由规则
既然商品微服务已经创建,接下来肯定要添加路由规则到Zuul中,我们不使用默认的路由规则。
zuul:
prefix: /api # 添加路由前缀
retryable: true
routes:
item-service: /item/** # 将商品微服务映射到/item/**
3.9.启动测试
第六天
4.使用域名访问本地项目
4.1.统一环境
我们现在访问页面使用的是:http://localhost:9001
有没有什么问题?
实际开发中,会有不同的环境:
-
开发环境:自己的电脑
-
测试环境:提供给测试人员使用的环境
-
预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试
-
生产环境:项目最终发布上线的环境
如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。
我们将使用以下域名:
-
主域名是:www.leyou.com,
-
管理系统域名:manage.leyou.com
-
网关域名:api.leyou.com
-
...
但是最终,我们希望这些域名指向的还是我们本机的某个端口。
那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的ip和端口的呢?
4.2.域名解析
一个域名一定会被解析为一个或多个ip。这一般会包含两步:
-
本地域名解析
浏览器会首先在本机的hosts文件中查找域名映射的IP地址,如果查找到就返回IP ,没找到则进行域名服务器解析,一般本地解析都会失败,因为默认这个文件是空的。
-
Windows下的hosts文件地址:C:/Windows/System32/drivers/etc/hosts
-
Linux下的hosts文件所在路径: /etc/hosts
样式:
-
# My hosts
127.0.0.1 空格 localhost
0.0.0.0 空格 account.jetbrains.com
127.0.0.1 空格 www.xmind.net
4.3.解决域名解析问题
我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:
127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com
这样就实现了域名的关系映射了。
每次在C盘寻找hosts文件并修改是非常麻烦的,给大家推荐一个快捷修改host的工具,在课前资料中可以找到:
解压,运行exe文件,效果:
我们添加了两个映射关系(中间用空格隔开):
-
127.0.0.1 api.leyou.com :我们的网关Zuul
-
127.0.0.1 manage.leyou.com:我们的后台系统地址
现在,ping一下域名试试是否畅通:
OK!
通过域名访问:
原因:我们配置了项目访问的路径,虽然manage.leyou.com映射的ip也是127.0.0.1,但是webpack会验证host是否符合配置。
在webpack.dev.conf.js中取消host验证:
重新执行npm run dev
,刷新浏览器:
OK!
4.4.nginx解决端口问题
域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.taotao.com:9001
。
这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com
。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?
这里就要用到反向代理工具:Nginx
4.4.1.什么是Nginx
nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:
-
反向代理
-
负载均衡
-
动态路由
-
请求过滤
4.4.2.nginx作为web服务器
Web服务器分2类:
-
web应用服务器,如:
-
tomcat
-
resin
-
jetty
-
-
web服务器,如:
-
Apache 服务器
-
Nginx
-
IIS
-
区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。 并发:web服务器的并发能力远高于web应用服务器。
4.4.3.nginx作为反向代理
什么是反向代理?
-
代理:通过客户机的配置,选择一台其他国家的服务器,实现让一台服务器(代理服务器)代理客户机,客户的所有请求都交给代理服务器处理。就是选择一台服务器代理我的请求去访问,服务器不知道是谁进行访问的
-
反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。
nginx可以当做反向代理服务器来使用:
-
我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
-
当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功
利用反向代理,就可以解决我们前面所说的端口问题,如图
4.4.4.安装和使用
安装
安装非常简单,把课前资料提供的nginx直接解压即可,绿色免安装,舒服!
我们在本地安装一台nginx:
解压后,目录结构:
-
conf:配置目录
-
contrib:第三方依赖
-
html:默认的静态资源目录,类似于tomcat的webapps
-
logs:日志目录
-
nginx.exe:启动程序。可双击运行,但不建议这么做。
反向代理配置
示例:
nginx中的每个server就是一个反向代理配置,可以有多个server
完整配置:
#user nobody;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
server {
listen 80;
server_name manage.leyou.com;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location / {
proxy_pass http://127.0.0.1:9001;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
server {
listen 80;
server_name api.leyou.com;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location / {
proxy_pass http://127.0.0.1:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
}
使用
nginx可以通过命令行来启动,操作命令:
-
启动:
start nginx.exe
-
停止:
nginx.exe -s stop
-
重新加载:
nginx.exe -s reload
启动过程会闪烁一下,启动成功后,任务管理器中会有两个nginx进程:
4.5.测试
启动nginx,然后用域名访问后台管理系统:
现在实现了域名访问网站了,中间的流程是怎样的呢?
-
浏览器准备发起请求,访问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将得到的结果返回到浏览器
5.实现商品分类查询
商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,其关系如图所示:
-
一个商品分类下有很多商品
-
一个商品分类下有很多品牌
-
而一个品牌,可能属于不同的分类
-
一个品牌下也会有很多商品
因此,我们需要依次去完成:商品分类、品牌、商品的开发。
5.1.导入数据
首先导入课前资料提供的sql:
我们先看商品分类表:
CREATE TABLE `tb_category` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目id',
`name` varchar(20) NOT NULL COMMENT '类目名称',
`parent_id` bigint(20) NOT NULL COMMENT '父类目id,顶级类目填0',
`is_parent` tinyint(1) NOT NULL COMMENT '是否为父节点,0为否,1为是',
`sort` int(4) NOT NULL COMMENT '排序指数,越小越靠前',
PRIMARY KEY (`id`),
KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品类目表,类目和商品(spu)是一对多关系,类目与品牌是多对多关系';
因为商品分类会有层级关系,因此这里我们加入了parent_id
字段,对本表中的其它分类进行自关联。
5.2.页面实现
5.2.1.页面分析
首先我们看下要实现的效果:
商品分类之间是会有层级关系的,采用树结构去展示是最直观的方式。
一起来看页面,对应的是/pages/item/Category.vue:
页面模板:
<template>
<v-card>
<v-flex xs12 sm10>
<v-tree url="/item/category/list"
:treeData="treeData"
:isEdit="isEdit"
@handleAdd="handleAdd"
@handleEdit="handleEdit"
@handleDelete="handleDelete"
@handleClick="handleClick"
/>
</v-flex>
</v-card>
</template>
v-card
:卡片,是vuetify中提供的组件,提供一个悬浮效果的面板,一般用来展示一组数据。
v-flex
:布局容器,用来控制响应式布局。与BootStrap的栅格系统类似,整个屏幕被分为12格。我们可以控制所占的格数来控制宽度:
-
本例中,我们用
sm10
控制在小屏幕及以上时,显示宽度为10格 -
v-tree
:树组件。Vuetify并没有提供树组件,这个是我们自己编写的自定义组件:
-
里面涉及一些vue的高级用法,大家暂时不要关注其源码,会用即可。
5.2.2.树组件的用法
也可参考课前资料中的:《自定义Vue组件的用法.md》
这里我贴出树组件的用法指南。
属性列表:
也可参考课前资料中的:《自定义Vue组件的用法.md》
这里我贴出树组件的用法指南。
属性列表:
属性名称 | 说明 | 数据类型 | 默认值 |
---|---|---|---|
url | 用来加载数据的地址,即延迟加载 | String | - |
isEdit | 是否开启树的编辑功能 | boolean | false |
treeData | 整颗树数据,这样就不用延迟加载了 | Array | - |
这里推荐使用url进行延迟加载,每当点击父节点时,就会发起请求,根据父节点id查询子节点信息。
当有treeData属性时,就不会触发url加载
远程请求返回的结果格式:
这里推荐使用url进行延迟加载,每当点击父节点时,就会发起请求,根据父节点id查询子节点信息。
当有treeData属性时,就不会触发url加载
远程请求返回的结果格式:
[
{
"id": 74,
"name": "手机",
"parentId": 0,
"isParent": true,
"sort": 2
},
{
"id": 75,
"name": "家用电器",
"parentId": 0,
"isParent": true,
"sort": 3
}
]
事件:
事件名称 | 说明 | 回调参数 |
---|---|---|
handleAdd | 新增节点时触发,isEdit为true时有效 | 新增节点node对象,包含属性:name、parentId和sort |
handleEdit | 当某个节点被编辑后触发,isEdit为true时有效 | 被编辑节点的id和name |
handleDelete | 当删除节点时触发,isEdit为true时有效 | 被删除节点的id |
handleClick | 点击某节点时触发 | 被点击节点的node对象,包含完整的node信息 |
完整node的信息
回调函数中返回完整的node节点会包含以下数据:
{
"id": 76, // 节点id
"name": "手机", // 节点名称
"parentId": 75, // 父节点id
"isParent": false, // 是否是父节点
"sort": 1, // 顺序
"path": ["手机", "手机通讯", "手机"] // 所有父节点的名称数组
}
5.3.实现功能
5.3.1.url异步请求
给大家的页面中,treeData是假数据,我们删除数据treeData属性,只保留url看看会发生什么:
<v-tree url="/item/category/list"
:isEdit="isEdit"
@handleAdd="handleAdd"
@handleEdit="handleEdit"
@handleDelete="handleDelete"
@handleClick="handleClick"
/>
刷新页面,可以看到:
页面中的树没有了,并且发起了一条请求:http://localhost/api/item/category/list?pid=0
大家可能会觉得很奇怪,我们明明是使用的相对路径,讲道理发起的请求地址应该是:
http://manage.leyou.com/item/category/list
但实际却是:
http://localhost/api/item/category/list?pid=0
这是因为,我们有一个全局的配置文件,对所有的请求路径进行了约定:
路径是localhost,并且默认加上了/api的前缀,这恰好与我们的网关设置匹配,我们只需要把地址改成网关的地址即可,因为我们使用了nginx反向代理,这里可以写域名。
接下来,我们要做的事情就是编写后台接口,返回对应的数据即可。
5.3.2.实体类
在ly-item-interface
中添加category实体类:
@Table(name="tb_category")
public class Category {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
private String name;
private Long parentId;
private Boolean isParent; // 注意isParent生成的getter和setter方法需要手动加上Is
private Integer sort;
// getter和setter略
}
需要注意的是,这里要用到jpa的注解,因此我们在ly-item-iterface
中添加jpa依赖
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>1.0</version>
</dependency>
5.3.3.controller
编写一个controller一般需要知道四个内容:
-
请求方式:决定我们用GetMapping还是PostMapping
-
请求路径:决定映射路径
-
请求参数:决定方法的参数
-
返回值结果:决定方法的返回值
在刚才页面发起的请求中,我们就能得到绝大多数信息:
-
请求方式:Get
-
请求路径:/api/item/category/list。其中/api是网关前缀,/item是网关的路由映射,真实的路径应该是/category/list
-
请求参数:pid=0,根据tree组件的说明,应该是父节点的id,第一次查询为0,那就是查询一级类目
-
返回结果:??
根据前面tree组件的用法我们知道,返回的应该是json数组:
-
[ { "id": 74, "name": "手机", "parentId": 0, "isParent": true, "sort": 2 }, { "id": 75, "name": "家用电器", "parentId": 0, "isParent": true, "sort": 3 } ]
-
对应的java类型可以是List集合,里面的元素就是类目对象了。也就是
List<Category>
-
添加Controller:
controller代码:
@Controller
@RequestMapping("category")
public class CategoryController {
@Autowired
private CategoryService categoryService;
/**
* 根据parentId查询类目
* @param pid
* @return
*/
@RequestMapping("list")
public ResponseEntity<List<Category>> queryCategoryListByParentId(@RequestParam(value = "pid", defaultValue = "0") Long pid) {
try {
if (categoryList==null || categoryList.size()<=0),一定是null先,不然会空指针异常
if (pid == null || pid.longValue() < 0){
// pid为null或者小于等于0,响应400
return ResponseEntity.badRequest().build();
}
// 执行查询操作
List<Category> categoryList = this.categoryService.queryCategoryListByParentId(pid);
if (CollectionUtils.isEmpty(categoryList)){
// 返回结果集为空,响应404
return ResponseEntity.notFound().build();
}
// 响应200
return ResponseEntity.ok(categoryList);
} catch (Exception e) {
e.printStackTrace();
}
// 响应500
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
5.3.4.service
一般service层我们会定义接口和实现类,不过这里我们就偷懒一下,直接写实现类了:
@Service
public class CategoryService {
@Autowired
private CategoryMapper categoryMapper;
/**
* 根据parentId查询子类目
* @param pid
* @return
*/
public List<Category> queryCategoryListByParentId(Long pid) {
Category record = new Category();
record.setParentId(pid);
return this.categoryMapper.select(record);
}
}
5.3.5.mapper
我们使用通用mapper来简化开发:
public interface CategoryMapper extends Mapper<Category> {
}
要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?
我们在启动类上添加一个扫描包功能:
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.leyou.item.mapper") // mapper接口的包扫描
public class LeyouItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouItemServiceApplication.class, args);
}
}
5.3.6.启动并测试
我们不经过网关,直接访问:http://localhost:8081/category/list
然后试试网关是否畅通:http://api.leyou.com/api/item/category/list
一切OK!
然后刷新后台管理页面查看:
发现报错了!
浏览器直接访问没事,但是这里却报错,什么原因?
2.4.跨域问题:前提是ajax请求(只能同源问题)
2.4.1.什么是跨域
跨域是指跨域名的访问,以下情况都属于跨域:
跨域原因说明 | 示例 |
---|---|
域名不同 | www.jd.com 与 www.taobao.com |
域名相同,端口不同 | www.jd.com:8080 与 www.jd.com:8081 |
二级域名不同 | item.jd.com 与 miaosha.jd.com |
如果域名和端口都相同,但是请求路径不同,不属于跨域,如:
www.jd.com/item
www.jd.com/goods
而我们刚才是从manage.leyou.com
去访问api.leyou.com
,这属于二级域名不同,跨域了。
2.4.2.为什么有跨域问题?
跨域不一定会有跨域问题。
但是这却给我们的开发带来了不变,而且在实际生成环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?
2.4.3.解决跨域问题的方案
目前比较常用的跨域解决方案有3种:
1、Jsonp
最早的解决方案,利用script标签可以跨域的原理实现。
限制:
-
需要服务的支持
-
只能发起GET请求
2、nginx反向代理
思路是:利用nginx反向代理把跨域为不跨域,支持各种请求方式
缺点:需要在nginx进行额外配置,语义不清晰
3、CORS
规范化的跨域请求解决方案,安全可靠。
优势:
-
在服务端进行控制是否允许跨域,可自定义规则
-
支持各种请求方式
缺点:
-
会产生额外的请求
我们这里会采用cors的跨域方案。
2.5.cors解决跨域
2.5.1.什么是cors
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
-
浏览器端:
目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。
-
服务端:
CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否运行其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。
2.5.2.原理有点复杂
浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。
简单请求
只要同时满足以下两大条件,就属于简单请求。:
(1) 请求方法是以下三种方法之一:
-
HEAD
-
GET
-
POST
(2)HTTP的头信息不超出以下几种字段:
-
Accept
-
Accept-Language
-
Content-Language
-
Last-Event-ID
-
Content-Type:只限于三个值
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
当浏览器发现发现的ajax请求是简单请求时,会在请求头中携带一个字段:Origin
.
Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。
如果服务器允许跨域,需要在返回的响应头中携带下面信息:
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8
-
Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*,代表任意
-
Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true
注意:
如果跨域请求要想操作cookie,需要满足3个条件:
-
服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
-
浏览器发起ajax需要指定withCredentials 为true
-
响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
特殊请求
不符合简单请求的条件,会被浏览器判定为特殊请求,,例如请求方式为PUT。
预检请求
特殊请求会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest
请求,否则就报错。
一个“预检”请求的样板:
OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
与简单请求相比,除了Origin以外,多了两个头:
-
Access-Control-Request-Method:接下来会用到的请求方式,比如PUT
-
Access-Control-Request-Headers:会额外用到的头信息
预检请求的响应
服务的收到预检请求,如果许可跨域,会发出响应:
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
除了Access-Control-Allow-Origin
和Access-Control-Allow-Credentials
以外,这里又额外多出3个头:
-
Access-Control-Allow-Methods:允许访问的方式
-
Access-Control-Allow-Headers:允许携带的头
-
Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了
如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。
2.5.3.实现非常简单
虽然原理比较复杂,但是前面说过:
-
浏览器端都有浏览器自动完成,我们无需操心
-
服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。
事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。
在ly-api-gateway
中编写一个配置类,并且注册CorsFilter:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class GlobalCorsConfig {
@Bean
public CorsFilter corsFilter() {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin("http://manage.leyou.com");
//2) 是否发送Cookie信息
config.setAllowCredentials(true);
//3) 允许的请求方式
config.addAllowedMethod("OPTIONS");
config.addAllowedMethod("HEAD");
config.addAllowedMethod("GET");
config.addAllowedMethod("PUT");
config.addAllowedMethod("POST");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("PATCH");
// 4)允许的头信息
config.addAllowedHeader("*");
//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
//3.返回新的CorsFilter.
return new CorsFilter(configSource);
}
}
结构:
4.5.4.重启测试:
访问正常:
页面也OK了:
分类的增删改功能暂时就不做了,页面已经预留好了事件接口,有兴趣的同学可以完成一下。
3.品牌的查询
商品分类完成以后,自然轮到了品牌功能了。
先看看我们要实现的效果:
第八天
0.学习目标
-
独立实现品牌新增
-
实现图片上传
-
了解FastDFS的安装
-
使用FastDFS客户端实现上传
1.品牌的新增
昨天我们完成了品牌的查询,接下来就是新增功能。
1.1.页面实现
1.1.1.初步编写弹窗
当我们点击新增按钮,应该出现一个弹窗,然后在弹窗中出现一个表格,我们就可以填写品牌信息了。
我们查看Vuetify官网,弹窗是如何实现:
另外,我们可以通过文档看到对话框的一些属性:
-
value:控制窗口的可见性,true可见,false,不可见
-
max-width:控制对话框最大宽度
-
scrollable :是否可滚动,要配合v-card来使用,默认是false
-
persistent :点击弹窗以外的地方不会关闭弹窗,默认是false
现在,我们来使用一下。
首先,我们在data中定义一个show属性,来控制对话框的显示状态:
然后,在页面添加一个v-dialog
<!--弹出的对话框--> <v-dialog max-width="500" v-model="show" persistent> <v-card> <!--对话框的标题--> <v-toolbar dense dark color="primary"> <v-toolbar-title>新增品牌</v-toolbar-title> </v-toolbar> <!--对话框的内容,表单--> <v-card-text class="px-5"> 我是表单 </v-card-text> </v-card> </v-dialog>
说明:
-
我们给dialog指定了3个属性,分别是
-
max-width:限制宽度
-
v-model:value值双向绑定到show变量,用来控制窗口显示
-
persisitent:控制窗口不会被意外关闭
-
-
因为可滚动需要配合
v-card
使用,因此我们在对话框中加入了一个v-card
-
在
v-card
的头部添加了一个v-toolbar
,作为窗口的头部,并且写了标题为:新增品牌-
dense:紧凑显示
-
dark:黑暗主题
-
color:颜色,primary就是整个网站的主色调,蓝色
-
-
在
v-card
的内容部分,暂时空置,等会写表单
-
-
class=“px-5"
:vuetify的内置样式,含义是padding的x轴设置为5,这样表单内容会缩进一些,而不是顶着边框基本语法:
{property}{direction}-{size}
-
property:属性,有两种
padding
和margin
-
p
:对应padding
-
m
:对应margin
-
-
direction:只padding和margin的作用方向,
-
t
- 对应margin-top
或者padding-top
属性 -
b
- 对应margin-bottom
orpadding-bottom
-
l
- 对应margin-left
orpadding-left
-
r
- 对应margin-right
orpadding-right
-
x
- 同时对应*-left
和*-right
属性 -
y
- 同时对应*-top
和*-bottom
属性
-
-
size:控制空间大小,基于
$spacer
进行倍增,$spacer
默认是16px-
0
:将margin
或padding的大小设置为0 -
1
- 将margin
或者padding
属性设置为$spacer * .25
-
2
- 将margin
或者padding
属性设置为$spacer * .5
-
3
- 将margin
或者padding
属性设置为$spacer
-
4
- 将margin
或者padding
属性设置为$spacer * 1.5
-
5
- 将margin
或者padding
属性设置为$spacer * 3
-
-
1.1.2.实现弹窗的可见和关闭
窗口可见
接下来,我们要在点击新增品牌按钮时,将窗口显示,因此要给新增按钮绑定事件。
<v-btn color="primary" @click="addBrand">新增品牌</v-btn>
然后定义一个addBrand方法:
addBrand(){ // 控制弹窗可见: this.show = true; }
效果:
窗口关闭
现在,悲剧发生了,因为我们设置了persistent属性,窗口无法被关闭了。除非把show属性设置为false
因此我们需要给窗口添加一个关闭按钮:
<!--对话框的标题--> <v-toolbar dense dark color="primary"> <v-toolbar-title>新增品牌</v-toolbar-title> <v-spacer/> <!--关闭窗口的按钮--> <v-btn icon @click="closeWindow"><v-icon>close</v-icon></v-btn> </v-toolbar>
并且,我们还给按钮绑定了点击事件,回调函数为closeWindow。
接下来,编写closeWindow函数:
closeWindow(){ // 关闭窗口 this.show = false; }
效果:
1.1.3.新增品牌的表单页
接下来就是写表单了。我们有两种选择:
-
直接在dialog对话框中编写表单代码
-
另外编写一个组件,组件内写表单代码。然后在对话框引用组件
选第几种?
我们选第二种方案,优点:
-
表单代码独立组件,可拔插,方便后期的维护。
-
代码分离,可读性更好。
我们新建一个MyBrandForm.vue
组件:
将MyBrandForm引入到MyBrand中,这里使用局部组件的语法:
先导入自定义组件:
// 导入自定义的表单组件 import MyBrandForm from './MyBrandForm'
然后通过components属性来指定局部组件:
components:{ MyBrandForm }
然后在页面中引用:
页面效果:
1.1.4.编写表单
1.1.4.1.表单
查看文档,找到关于表单的部分:
v-form
,表单组件,内部可以有许多输入项。v-form
有下面的属性:
-
value:true,代表表单验证通过;false,代表表单验证失败
v-form
提供了两个方法:
-
reset:重置表单数据
-
validate:校验整个表单数据,前提是你写好了校验规则。返回Boolean表示校验成功或失败
我们在data中定义一个valid属性,跟表单的value进行双向绑定,观察表单是否通过校验,同时把等会要跟表单关联的品牌brand对象声明出来:
export default { name: "my-brand-form", data() { return { valid:false, // 表单校验结果标记 brand:{ name:'', // 品牌名称 letter:'', // 品牌首字母 image:'',// 品牌logo categories:[], // 品牌所属的商品分类数组 } } } }
然后,在页面先写一个表单:
<v-form v-model="valid"> </v-form>
1.1.4.2.文本框
我们的品牌总共需要这些字段:
-
名称
-
首字母
-
商品分类,有很多个
-
LOGO
表单项主要包括文本框、密码框、多选框、单选框、文本域、下拉选框、文件上传等。思考下我们的品牌需要哪些?
-
文本框:品牌名称、品牌首字母都属于文本框
-
文件上传:品牌需要图片,这个是文件上传框
-
下拉选框:商品分类提前已经定义好,这里需要通过下拉选框展示,提供给用户选择。
先看文本框,昨天已经用过的,叫做v-text-field
:
查看文档,v-text-field
有以下关键属性:
-
append-icon:文本框后追加图标,需要填写图标名称。无默认值
-
clearable:是否添加一个清空图标,点击会清空文本框。默认是false
-
color:颜色
-
counter:是否添加一个文本计数器,在角落显示文本长度,指定true或允许的组大长度。无默认值
-
dark:是否应用黑暗色调,默认是false
-
disable:是否禁用,默认是false
-
flat:是否移除默认的动画效果,默认是false
-
full-width:指定宽度为全屏,默认是false
-
hide-details:是否因此错误提示,默认是false
-
hint:输入框的提示文本
-
label:输入框的标签
-
multi-line:是否转为文本域,默认是false。文本框和文本域可以自由切换
-
placeholder:输入框占位符文本,focus后消失
-
required:是否为必填项,如果是,会在label后加*,不具备校验功能。默认是false
-
rows:文本域的行数,
multi-line
为true时才有效 -
rules:指定校验规则及错误提示信息,数组结构。默认[]
-
single-line:是否单行文本显示,默认是false
-
suffix:显示后缀
接下来,我们先添加两个字段:品牌名称、品牌的首字母,校验规则暂时不写:
<v-form v-model="valid"> <v-text-field v-model="brand.name" label="请输入品牌名称" required /> <v-text-field v-model="brand.letter" label="请输入品牌首字母" required /> </v-form>
-
千万不要忘了通过
v-model
把表单项与brand
的属性关联起来。
效果:
1.1.4.3.级联下拉选框
接下来就是商品分类了,按照刚才的分析,商品分类应该是下拉选框。
但是大家仔细思考,商品分类包含三级。在展示的时候,应该是先由用户选中1级,才显示2级;选择了2级,才显示3级。形成一个多级分类的三级联动效果。
这个时候,就不是普通的下拉选框,而是三级联动的下拉选框!
这样的选框,在Vuetify中并没有提供(它提供的是基本的下拉框)。因此我已经给大家编写了一个无限级联动的下拉选框,能够满足我们的需求。
具体请参考课前资料的《自定义组件用法指南.md》
我们在代码中使用:
<v-cascader url="/item/category/list" multiple required v-model="brand.categories" label="请选择商品分类"/>
-
url:加载商品分类选项的接口路径
-
multiple:是否多选,这里设置为true,因为一个品牌可能有多个分类
-
requried:是否是必须的,这里为true,会在提示上加*,提醒用户
-
v-model:关联我们brand对象的categories属性
-
label:文字说明
效果:
data中获取的结果:
1.1.4.4.文件上传项
在Vuetify中,也没有文件上传的组件。
还好,我已经给大家写好了一个文件上传的组件:
详细用法,参考《自定义组件使用指南.md》
我们添加上传的组件:
<v-layout row> <v-flex xs3> <span style="font-size: 16px; color: #444">品牌LOGO:</span> </v-flex> <v-flex> <v-upload v-model="brand.image" url="/upload" :multiple="false" :pic-width="250" :pic-height="90" /> </v-flex> </v-layout>
注意:
-
文件上传组件本身没有提供文字提示。因此我们需要自己添加一段文字说明
-
我们要实现文字和图片组件左右放置,因此这里使用了
v-layout
布局组件:-
layout添加了row属性,代表这是一行,如果是column,代表是多行
-
layout下面有
v-flex
组件,是这一行的单元,我们有2个单元-
<v-flex xs3>
:显示文字说明,xs3是响应式布局,代表占12格中的3格 -
剩下的部分就是图片上传组件了
-
-
-
v-upload
:图片上传组件,包含以下属性:-
v-model:将上传的结果绑定到brand的image属性
-
url:上传的路径,我们先随便写一个。
-
multiple:是否运行多图片上传,这里是false。因为品牌LOGO只有一个
-
pic-width和pic-height:可以控制l图片上传后展示的宽高
-
最终结果:
1.1.4.5.按钮
上面已经把所有的表单项写完。最后就差提交和清空的按钮了。
在表单的最下面添加两个按钮:
<v-layout class="my-4" row> <v-spacer/> <v-btn @click="submit" color="primary">提交</v-btn> <v-btn @click="clear" >重置</v-btn> </v-layout>
-
通过layout来进行布局,
my-4
增大上下边距 -
v-spacer
占用一定空间,将按钮都排挤到页面右侧 -
两个按钮分别绑定了submit和clear事件
我们先将方法定义出来:
methods:{ submit(){ // 提交表单 }, clear(){ // 重置表单 } }
重置表单相对简单,因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了。
我们可以通过$refs
内置对象来获取表单组件。
首先,在表单上定义ref
属性:
然后,在页面查看this.$refs
属性:
看到this.$refs
中只有一个属性,就是myBrandForm
我们在clear中来获取表单对象并调用reset方法:
methods:{ submit(){ // 提交表单 console.log(this); }, clear(){ // 重置表单 this.$refs.myBrandForm.reset(); // 需要手动清空商品分类 this.categories = []; } }
要注意的是,这里我们还手动把this.categories清空了,因为我写的级联选择组件并没有跟表单结合起来。需要手动清空。
1.1.5.表单校验
1.1.5.1.校验规则
Vuetify的表单校验,是通过rules属性来指定的:
校验规则的写法:
说明:
-
规则是一个数组
-
数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:
-
返回true,代表成功,
-
返回错误提示信息,代表失败
-
1.1.5.2.项目中代码
我们有四个字段:
-
name:做非空校验和长度校验,长度必须大于1
-
letter:首字母,校验长度为1,非空。
-
image:图片,不做校验,图片可以为空
-
categories:非空校验,自定义组件已经帮我们完成,不用写了
首先,我们定义规则:
nameRules:[ v => !!v || "品牌名称不能为空", v => v.length > 1 || "品牌名称至少2位" ], letterRules:[ v => !!v || "首字母不能为空", v => /^[A-Z]{1}$/.test(v) || "品牌字母只能是A~Z的大写字母" ]
然后,在页面标签中指定:
<v-text-field v-model="brand.name" label="请输入品牌名称" required :rules="nameRules" /> <v-text-field v-model="brand.letter" label="请输入品牌首字母" required :rules="letterRules" />
效果:
1.1.6.表单提交
在submit方法中添加表单提交的逻辑:
submit() { // 1、表单校验 if (this.$refs.myBrandForm.validate()) { // 2、定义一个请求参数对象,通过解构表达式来获取brand中的属性 const {categories ,letter ,...params} = this.brand; // 3、数据库中只要保存分类的id即可,因此我们对categories的值进行处理,只保留id,并转为字符串 params.cids = categories.map(c => c.id).join(","); // 4、将字母都处理为大写 params.letter = letter.toUpperCase(); // 5、将数据提交到后台 this.$http.post('/item/brand', params) .then(() => { // 6、弹出提示 this.$message.success("保存成功!"); }) .catch(() => { this.$message.error("保存失败!"); }); } }
-
1、通过
this.$refs.myBrandForm
选中表单,然后调用表单的validate
方法,进行表单校验。返回boolean值,true代表校验通过 -
2、通过解构表达式来获取brand中的值,categories和letter需要处理,单独获取。其它的存入params对象中
-
3、品牌和商品分类的中间表只保存两者的id,而brand.categories中保存的数对象数组,里面有id和name属性,因此这里通过数组的map功能转为id数组,然后通过join方法拼接为字符串
-
4、首字母都处理为大写保存
-
5、发起请求
-
6、弹窗提示成功还是失败,这里用到的是我们的自定义组件功能message组件:
这个插件把
$message
对象绑定到了Vue的原型上,因此我们可以通过this.$message
来直接调用。包含以下常用方法:
-
info、error、success、warning等,弹出一个带有提示信息的窗口,色调与为普通(灰)、错误(红色)、成功(绿色)和警告(黄色)。使用方法:this.$message.info("msg")
-
confirm:确认框。用法:
this.$message.confirm("确认框的提示信息")
,返回一个Promise
-
1.2.后台实现新增
1.2.1.controller
还是一样,先分析四个内容:
-
请求方式:刚才看到了是POST
-
请求路径:/brand
-
请求参数:brand对象,外加商品分类的id数组cids
-
返回值:无
代码:
/** * 新增品牌 * @param brand * @return */ @PostMapping public ResponseEntity<Void> saveBrand(Brand brand, @RequestParam("cids") List<Long> cids) { this.brandService.saveBrand(brand, cids); return new ResponseEntity<>(HttpStatus.CREATED); }
1.2.2.Service
这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。
@Transactional public void saveBrand(Brand brand, List<Long> cids) { // 新增品牌信息 this.brandMapper.insertSelective(brand); // 新增品牌和分类中间表 for (Long cid : cids) { this.brandMapper.insertCategoryBrand(cid, brand.getId()); } }
这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增
1.2.3.Mapper
通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:
public interface BrandMapper extends Mapper<Brand> { /** * 新增商品分类和品牌中间表数据 * @param cid 商品分类id * @param bid 品牌id * @return */ @Insert("INSERT INTO tb_category_brand (category_id, brand_id) VALUES (#{cid},#{bid})") int insertCategoryBrand(@Param("cid") Long cid, @Param("bid") Long bid); }
1.3.请求参数格式错误
1.3.1.原因分析
我们填写表单并提交,发现报错了:
查看控制台的请求详情:
发现请求的数据格式是JSON格式。
原因分析:
axios处理请求体的原则会根据请求数据的格式来定:
-
如果请求体是对象:会转为json发送
-
如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。
如:name=jack&age=12
1.3.2.QS工具
QS是一个第三方库,我们可以用npm install qs --save
来安装。不过我们在项目中已经集成了,大家无需安装:
这个工具的名字:QS,即Query String,请求参数字符串。
什么是请求参数字符串?例如: name=jack&age=21
QS工具可以便捷的实现 JS的Object与QueryString的转换。
在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs
来获取这个工具:
我们将this.$qs
对象打印到控制台:
created(){ console.log(this.$qs); }
发现其中有3个方法:
这里我们要使用的方法是stringify,它可以把Object转为QueryString。
测试一下,使用浏览器工具,把qs对象保存为一个临时变量:
然后调用stringify方法:
成功将person对象变成了 name=jack&age=21的字符串了
1.3.3.解决问题
修改页面,对参数处理后发送:
然后再次发起请求:
发现请求成功:
参数格式:
数据库:
1.4.新增完成后关闭窗口
我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。
这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。
因此,我们需要在新增的ajax请求完成以后,关闭窗口
但问题在于,控制窗口是否显示的标记在父组件:MyBrand.vue中。子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?
之前我们讲过一个父子组件的通信,有印象吗?
-
第一步,在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了,我们优化一下,关闭的同时重新加载数据:
closeWindow(){ // 关闭窗口 this.show = false; // 重新加载数据 this.getDataFromServer(); }
-
第二步,父组件在使用子组件时,绑定事件,关联到这个函数:
<!--对话框的内容,表单--> <v-card-text class="px-5"> <my-brand-form @close="closeWindow"/> </v-card-text>
-
第三步,子组件通过
this.$emit
调用父组件的函数:
测试一下
2.实现图片上传
刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
2.1.搭建项目
2.1.1.创建module
2.1.2.依赖
我们需要EurekaClient和web依赖:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou</artifactId> <groupId>com.leyou.parent</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>com.leyou.service</groupId> <artifactId>ly-upload</artifactId> <version>1.0.0-SNAPSHOT</version> <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> </dependencies> </project>
2.1.3.编写配置
server: port: 8082 spring: application: name: upload-service servlet: multipart: max-file-size: 5MB # 限制文件上传的大小 # Eureka eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka instance: lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳 lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期 prefer-ip-address: true ip-address: 127.0.0.1 instance-id: ${spring.application.name}:${server.port}
需要注意的是,我们应该添加了限制文件大小的配置
2.1.4.启动类
@SpringBootApplication @EnableDiscoveryClient public class LyUploadService { public static void main(String[] args) { SpringApplication.run(LyUploadService.class, args); } }
结构:
2.2.编写上传功能
2.2.1.controller
编写controller需要知道4个内容:
-
请求方式:上传肯定是POST
-
请求路径:/upload/image
-
请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipleFile
-
返回结果:上传成功后得到的文件的url路径
代码如下:
@RestController @RequestMapping("upload") public class UploadController { @Autowired private UploadService uploadService; /** * 上传图片功能 * @param file * @return */ @PostMapping("image") public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) { String url = this.uploadService.upload(file); if (StringUtils.isBlank(url)) { // url为空,证明上传失败 return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } // 返回200,并且携带url路径 return ResponseEntity.ok(url); } }
2.2.2.service
在上传文件过程中,我们需要对上传的内容进行校验:
-
校验文件大小
-
校验文件的媒体类型
-
校验文件的内容
文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。
具体代码:
@Service public class UploadService { private static final Logger logger = LoggerFactory.getLogger(UploadController.class); // 支持的文件类型 private static final List<String> suffixes = Arrays.asList("image/png", "image/jpeg"); public String upload(MultipartFile file) { try { // 1、图片信息校验 // 1)校验文件类型 String type = file.getContentType(); if (!suffixes.contains(type)) { logger.info("上传失败,文件类型不匹配:{}", type); return null; } // 2)校验图片内容 BufferedImage image = ImageIO.read(file.getInputStream()); if (image == null) { logger.info("上传失败,文件内容不符合要求"); return null; } // 2、保存图片 // 2.1、生成保存目录 File dir = new File("D:\\heima\\upload"); if (!dir.exists()) { dir.mkdirs(); } // 2.2、保存图片 file.transferTo(new File(dir, file.getOriginalFilename())); // 2.3、拼接图片地址 String url = "http://image.leyou.com/upload/" + file.getOriginalFilename(); return url; } catch (Exception e) { return null; } } }
这里有一个问题:为什么图片地址需要使用另外的url?
-
图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
-
一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量
2.2.3.测试上传
我们通过RestClient工具来测试:
结果:
去目录下查看:
上传成功!
2.2.4.绕过网关
图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。
所以,我们上传文件的请求就不经过网关来处理了。
2.2.4.1.Zuul的路由过滤
Zuul中提供了一个ignored-patterns属性,用来忽略不希望路由的URL路径,示例:
zuul.ignored-patterns: /upload/**
路径过滤会对一切微服务进行判定。
Zuul还提供了ignored-services
属性,进行服务过滤:
zuul.ignored-services: upload-servie
我们这里采用忽略服务:
zuul: ignored-services: - upload-service # 忽略upload-service服务
上面的配置采用了集合语法,代表可以配置多个
2.2.4.2.Nginx的rewrite指令
现在,我们修改页面的访问路径:
<v-upload v-model="brand.image" url="/upload/image" :multiple="false" :pic-width="250" :pic-height="90" />
查看页面的请求路径:
可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?
有同学会想:修改页面请求地址不就好了。
注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。
既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。
我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:
location /api/upload { proxy_pass http://127.0.0.1:8082; proxy_connect_timeout 600; proxy_read_timeout 600; }
这样写大家觉得对不对呢?
显然是不对的,因为ip和端口虽然对了,但是路径没变,依然是:http://127.0.0.1:8002/api/upload/image
前面多了一个/api
Nginx提供了rewrite指令,用于对地址进行重写,语法规则:
rewrite "用来匹配路径的正则" 重写后的路径 [指令];
我们的案例:
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://127.0.0.1:8082; proxy_connect_timeout 600; proxy_read_timeout 600; rewrite "^/api/(.*)$" /$1 break; } location / { proxy_pass http://127.0.0.1:10010; proxy_connect_timeout 600; proxy_read_timeout 600; } }
-
首先,我们映射路径是/api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload优先级更高。也就是说,凡是以/api/upload开头的路径,都会被第一个配置处理
-
proxy_pass
:反向代理,这次我们代理到8082端口,也就是upload-service服务 -
rewrite "^/api/(.*)$" /$1 break
,路径重写:-
"^/api/(.*)$"
:匹配路径的正则表达式,用了分组语法,把/api/
以后的所有部分当做1组 -
/$1
:重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),即/api/
后面的所有。这样新的路径就是除去/api/
以外的所有,就达到了去除/api
前缀的目的 -
break
:指令,常用的有2个,分别是:last、break-
last:重写路径结束后,将得到的路径重新进行一次路径匹配
-
break:重写路径结束后,不再重新匹配路径。
我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到8082端口了
-
-
修改完成,输入nginx -s reload
命令重新加载配置。然后再次上传试试。
2.2.5.跨域问题
重启nginx,再次上传,发现报错了:
不过庆幸的是,这个错误已经不是第一次见了,跨域问题。
我们在upload-service中添加一个CorsFilter即可:
@Configuration public class GlobalCorsConfig { @Bean public CorsFilter corsFilter() { //1.添加CORS配置信息 CorsConfiguration config = new CorsConfiguration(); //1) 允许的域,不要写*,否则cookie就无法使用了 config.addAllowedOrigin("http://manage.leyou.com"); //2) 是否发送Cookie信息 config.setAllowCredentials(false); //3) 允许的请求方式 config.addAllowedMethod("OPTIONS"); config.addAllowedMethod("POST"); config.addAllowedHeader("*"); //2.添加映射路径,我们拦截一切请求 UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource(); configSource.registerCorsConfiguration("/**", config); //3.返回新的CorsFilter. return new CorsFilter(configSource); } }
再次测试:
不过,非常遗憾的是,访问图片地址,却没有响应。
这是因为我们并没有任何服务器对应image.leyou.com这个域名。。
这个问题,我们暂时放下,回头再来解决。
2.2.6.之前上传的缺陷
先思考一下,之前上传的功能,有没有什么问题?
上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:
-
单机器存储,存储能力有限
-
无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
-
数据没有备份,有单点故障风险
-
并发能力差
这个时候,最好使用分布式文件存储来代替本地文件存储。
3.FastDFS
3.1.什么是分布式文件系统
分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。
通俗来讲:
-
传统文件系统管理的文件就存储在本机。
-
分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问
3.2.什么是FastDFS
FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:
-
文件存储
-
文件同步
-
文件访问(上传、下载)
-
存取负载均衡
-
在线扩容
适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。
3.3.FastDFS的架构
3.3.1.架构图
先上图:
fastDFS:客户端(集群)
tracker cluster:中继服务器-》管理中心(集群)
storage cluster:存储服务器(集群)
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组成。
3.3.2.上传和下载流程
上传
-
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建立连接并指定要下载文件。
-
下载文件成功。
3.4.安装和使用
参考课前资料的:《centos安装FastDFS.md》
1. Centos下安装FastDFS
1.1 上传
将课前资料中的文件上传到linux下的/home/leyou
目录:
1.2 安装依赖
FastDFS运行需要一些依赖,在课前资料提供的虚拟中已经安装好了这些依赖,如果大家想要从头学习,可以按下面方式安装:
1.2.1 安装GCC依赖
GCC用来对C语言代码进行编译运行,使用yum命令安装:
sudo yum -y install gcc
1.2.2 安装unzip工具
unzip工具可以帮我们对压缩包进行解压
sudo yum install -y unzip zip
1.2.3 安装libevent
sudo yum -y install libevent
1.2.4 安装Nginx所需依赖
sudo yum -y install pcre pcre-devel zlib zlib-devel openssl openssl-devel
1.2.5 安装libfastcommon-master
这个没有yum包,只能通过编译安装:
-
解压刚刚上传的
libfastcommon-master.zip
unzip libfastcommon-master.zip
-
进入解压完成的目录:
cd libfastcommon-master
-
编译并且安装:
sudo ./make.sh sudo ./makesh install
到这里为止,所有依赖都已经安装完毕,接下来我们安装FastDFS:
1.3 安装FastDFS
1.3.1 编译安装
这里我们也采用编译安装,步骤与刚才的编译安装方式一样:
-
解压
tar -xvf FastDFS_v5.08.tar.gz
-
进入目录
cd FastDFS
-
编译并安装
sudo ./make.sh sudo ./make.sh install
-
校验安装结果
1)安装完成,我们应该能在/etc/init.d/
目录,通过命令ll /etc/init.d/ | grep fdfs
看到FastDFS提供的启动脚本:
其中:
-
fdfs_trackerd
是tracker启动脚本 -
fdfs_storaged
是storage启动脚本
2)我们可以在 /etc/fdfs
目录,通过命令查看到以下配置文件模板:
其中:
-
tarcker.conf.sample
是tracker的配置文件模板 -
storage.conf.sample
是storage的配置文件模板 -
client.conf.sample
是客户端的配置文件模板
1.3.2 启动tracker
FastDFS的tracker和storage在刚刚的安装过程中,都已经被安装了,因此我们安装这两种角色的方式是一样的。不同的是,两种需要不同的配置文件。
我们要启动tracker,就修改刚刚看到的tarcker.conf
,并且启动fdfs_trackerd
脚本即可。
-
编辑tracker配置
首先我们将模板文件进行赋值和重命名:
sudo cp tracker.conf.sample tracker.conf sudo vim tracker.conf
打开tracker.conf
,修改base_path
配置:
base_path=/leyou/fdfs/tracker # tracker的数据和日志存放目录
-
创建目录
刚刚配置的目录可能不存在,我们创建出来
sudo mkdir -p /leyou/fdfs/tracker
-
注意:关闭防火墙
chkconfig iptables off
-
启动tracker
我们可以使用
sh /etc/init.d/fdfs_trackerd
启动,不过安装过程中,fdfs已经被设置为系统服务,我们可以采用熟悉的服务启动方式:
sudo service fdfs_trackerd start # 启动fdfs_trackerd服务,停止用stop
另外,我们可以通过以下命令,设置tracker开机启动:
sudo chkconfig fdfs_trackerd on
1.3.3 启动storage
我们要启动tracker,就修改刚刚看到的tarcker.conf
,并且启动fdfs_trackerd
脚本即可。
-
编辑storage配置
首先我们将模板文件进行赋值和重命名:
sudo cp storage.conf.sample storage.conf sudo vim storage.conf
打开storage.conf
,修改base_path
配置:
base_path=/leyou/fdfs/storage # storage的数据和日志存放目录 store_path0=/leyou/fdfs/storage # storage的上传文件存放路径 tracker_server=192.168.56.101:22122 # tracker的地址
-
创建目录
刚刚配置的目录可能不存在,我们创建出来
sudo mkdir -p /leyou/fdfs/storage
-
启动storage
我们可以使用
sh /etc/init.d/fdfs_storaged
启动,同样我们可以用服务启动方式:
sudo service fdfs_storaged start # 启动fdfs_storaged服务,停止用stop
另外,我们可以通过以下命令,设置tracker开机启动:
sudo chkconfig fdfs_storaged on
最后,通过ps -ef | grep fdfs
查看进程:
1.4 安装Nginx及FastDFS模块
1.4.1 FastDFS的Nginx模块
-
解压
tar -xvf fastdfs-nginx-module_v1.16.tar.gz
-
配置config文件
# 进入配置目录 cd fastdfs-nginx-module/src/ # 修改配置 vim config # 执行下面命令(将配置中的/usr/local改为/usr): :%s+/usr/local/+/usr/+g
-
配置mod_fastdfs.conf
# 将src目录下的mod_fastdfs.conf复制到 /etc/fdfs目录: sudo cp mod_fastdfs.conf /etc/fdfs/ # 编辑该文件 sudo vim /etc/fdfs/mod_fastdfs.cof
-
修改一下配置:
connect_timeout=10 # 客户端访问文件连接超时时长(单位:秒) tracker_server=192.168.56.101:22122 # tracker服务IP和端口 url_have_group_name=true # 访问链接前缀加上组名 store_path0=/leyou/fdfs/storage # 文件存储路径
-
复制 FastDFS的部分配置文件到/etc/fdfs目录
cd /usr/local/leyou/FastDFS/conf/ cp http.conf mime.types /etc/fdfs/
1.4.2 安装Nginx
-
安装nginx的依赖环境
yum -y install gcc pcre pcre-devel zlib zlib-devel openssl openssl-devel
-
解压
tar -xvf nginx-1.10.0.tar.gz
-
配置
sudo ./configure --prefix=/opt/nginx --sbin-path=/usr/bin/nginx --add-module=/usr/local/leyou/fastdfs-nginx-module/src
-
编译安装
sudo make && make install
-
配置nginx整合fastdfs-module模块
我们需要修改nginx配置文件,在/opt/nginx/config/nginx.conf文件中:
sudo vim /opt/nginx/conf/nginx.conf
将文件中,原来的
server 80{ ...}
部分代码替换为如下代码:server { listen 80; server_name image.taotao.com; # 监听域名中带有group的,交给FastDFS模块处理 location ~/group([0-9])/ { ngx_fastdfs_module; } location / { root html; index index.html index.htm; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } }
-
启动
nginx # 启动 nginx -s stop # 停止 nginx -s reload # 重新加载配置
-
设置nginx开机启动
创建一个开机启动的脚本:
vim /etc/init.d/nginx
添加以下内容:
#!/bin/sh # # nginx - this script starts and stops the nginx daemon # # chkconfig: - 85 15 # description: NGINX is an HTTP(S) server, HTTP(S) reverse \ # proxy and IMAP/POP3 proxy server # processname: nginx # config: /etc/nginx/nginx.conf # config: /etc/sysconfig/nginx # pidfile: /var/run/nginx.pid # Source function library. . /etc/rc.d/init.d/functions # Source networking configuration. . /etc/sysconfig/network # Check that networking is up. [ "$NETWORKING" = "no" ] && exit 0 nginx="/usr/bin/nginx" prog=$(basename $nginx) NGINX_CONF_FILE="/opt/nginx/conf/nginx.conf" [ -f /etc/sysconfig/nginx ] && . /etc/sysconfig/nginx lockfile=/var/lock/subsys/nginx make_dirs() { # make required directories user=`$nginx -V 2>&1 | grep "configure arguments:.*--user=" | sed 's/[^*]*--user=\([^ ]*\).*/\1/g' -` if [ -n "$user" ]; then if [ -z "`grep $user /etc/passwd`" ]; then useradd -M -s /bin/nologin $user fi options=`$nginx -V 2>&1 | grep 'configure arguments:'` for opt in $options; do if [ `echo $opt | grep '.*-temp-path'` ]; then value=`echo $opt | cut -d "=" -f 2` if [ ! -d "$value" ]; then # echo "creating" $value mkdir -p $value && chown -R $user $value fi fi done fi } start() { [ -x $nginx ] || exit 5 [ -f $NGINX_CONF_FILE ] || exit 6 make_dirs echo -n $"Starting $prog: " daemon $nginx -c $NGINX_CONF_FILE retval=$? echo [ $retval -eq 0 ] && touch $lockfile return $retval } stop() { echo -n $"Stopping $prog: " killproc $prog -QUIT retval=$? echo [ $retval -eq 0 ] && rm -f $lockfile return $retval } restart() { configtest || return $? stop sleep 1 start } reload() { configtest || return $? echo -n $"Reloading $prog: " killproc $nginx -HUP RETVAL=$? echo } force_reload() { restart } configtest() { $nginx -t -c $NGINX_CONF_FILE } rh_status() { status $prog } rh_status_q() { rh_status >/dev/null 2>&1 } case "$1" in start) rh_status_q && exit 0 $1 ;; stop) rh_status_q || exit 0 $1 ;; restart|configtest) $1 ;; reload) rh_status_q || exit 7 $1 ;; force-reload) force_reload ;; status) rh_status ;; condrestart|try-restart) rh_status_q || exit 0 ;; *) echo $"Usage: $0 {start|stop|status|restart|condrestart|try-restart|reload|force-reload|configtest}" exit 2 esac
-
修改文件权限,并加入服务列表
# 修改权限 chmod 777 /etc/init.d/nginx # 添加到服务列表 chkconfig --add /etc/init.d/nginx
-
设置开机启动
chkconfig nginx on
3.5.java客户端
余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。
这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。
配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。
3.5.1.引入依赖
在父工程中,我们已经管理了依赖,版本为:
<fastDFS.client.version>1.26.2</fastDFS.client.version>
因此,这里我们直接引入坐标即可:
<dependency> <groupId>com.github.tobato</groupId> <artifactId>fastdfs-client</artifactId> </dependency>
3.5.2.引入配置类
纯java配置:
@Configuration @Import(FdfsClientConfig.class) // 解决jmx重复注册bean的问题 @EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING) public class FastClientImporter { }
3.5.3.编写FastDFS属性
fdfs: so-timeout: 1501 connect-timeout: 601 thumb-image: # 缩略图 width: 60 height: 60 tracker-list: # tracker地址 - 192.168.56.101:22122
3.5.4.测试
@RunWith(SpringRunner.class) @SpringBootTest(classes = LyUploadService.class) public class FdfsTest { @Autowired private FastFileStorageClient storageClient; @Autowired private ThumbImageConfig thumbImageConfig; @Test public void testUpload() throws FileNotFoundException { File file = new File("D:\\test\\baby.png"); // 上传并且生成缩略图 StorePath storePath = this.storageClient.uploadFile( new FileInputStream(file), file.length(), "png", null); // 带分组的路径 System.out.println(storePath.getFullPath()); // 不带分组的路径 System.out.println(storePath.getPath()); } @Test public void testUploadAndCreateThumb() throws FileNotFoundException { File file = new File("D:\\test\\baby.png"); // 上传并且生成缩略图 StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage( new FileInputStream(file), file.length(), "png", null); // 带分组的路径 System.out.println(storePath.getFullPath()); // 不带分组的路径 System.out.println(storePath.getPath()); // 获取缩略图路径 String path = thumbImageConfig.getThumbImagePath(storePath.getPath()); System.out.println(path); } }
结果:
group1/M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630.png M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630.png M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630_60x60.png
访问第一个路径:
访问最后一个路径(缩略图路径),注意加组名:
3.5.5.改造上传逻辑
@Service public class UploadService { private static final Logger logger = LoggerFactory.getLogger(UploadController.class); // 支持的文件类型 private static final List<String> suffixes = Arrays.asList("image/png", "image/jpeg"); @Autowired FastFileStorageClient storageClient; public String upload(MultipartFile file) { try { // 1、图片信息校验 // 1)校验文件类型 String type = file.getContentType(); if (!suffixes.contains(type)) { logger.info("上传失败,文件类型不匹配:{}", type); return null; } // 2)校验图片内容 BufferedImage image = ImageIO.read(file.getInputStream()); if (image == null) { logger.info("上传失败,文件内容不符合要求"); return null; } // 2、将图片上传到FastDFS // 2.1、获取文件后缀名 String extension = StringUtils.substringAfterLast(file.getOriginalFilename(), "."); // 2.2、上传 StorePath storePath = this.storageClient.uploadFile( file.getInputStream(), file.getSize(), extension, null); // 2.3、返回完整路径 return "http://image.leyou.com/" + storePath.getFullPath(); } catch (Exception e) { return null; } } }
只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。
3.5.6.测试
通过RestClient测试:
3.6.页面测试上传
发现上传成功:
不过,当我们访问页面时:
这是因为我们图片是上传到虚拟机的,ip为:192.168.56.101
因此,我们需要将image.leyou.com映射到192.168.56.101
修改我们的hosts:
再次上传:
4.修改品牌(作业)
修改的难点在于回显。
当我们点击编辑按钮,希望弹出窗口的同时,看到原来的数据:
4.1.点击编辑出现弹窗
这个比较简单,修改show属性为true即可实现,我们绑定一个点击事件:
<v-btn color="info" @click="editBrand">编辑</v-btn>
然后编写事件,改变show 的状态:
如果仅仅是这样,编辑按钮与新增按钮将没有任何区别,关键在于,如何回显呢?
4.2.回显数据
回显数据,就是把当前点击的品牌数据传递到子组件(MyBrandForm)。而父组件给子组件传递数据,通过props属性。
-
第一步:在编辑时获取当前选中的品牌信息,并且记录到data中
先在data中定义属性,用来接收用来编辑的brand数据:
我们在页面触发编辑事件时,把当前的brand传递给editBrand方法:
<v-btn color="info" @click="editBrand(props.item)">编辑</v-btn>
然后在editBrand中接收数据,赋值给oldBrand:
editBrand(oldBrand){ // 控制弹窗可见: this.show = true; // 获取要编辑的brand this.oldBrand = oldBrand; },
-
第二步:把获取的brand数据 传递给子组件
<!--对话框的内容,表单--> <v-card-text class="px-5"> <my-brand-form @close="closeWindow" :oldBrand="oldBrand"/> </v-card-text>
-
第三步:在子组件中通过props接收要编辑的brand数据,Vue会自动完成回显
接收数据:
通过watch函数监控oldBrand的变化,把值copy到本地的brand:
watch: { oldBrand: {// 监控oldBrand的变化 handler(val) { if(val){ // 注意不要直接复制,否则这边的修改会影响到父组件的数据,copy属性即可 this.brand = Object.deepCopy(val) }else{ // 为空,初始化brand this.brand = { name: '', letter: '', image: '', categories: [], } } }, deep: true } }
-
Object.deepCopy 自定义的对对象进行深度复制的方法。
-
需要判断监听到的是否为空,如果为空,应该进行初始化
-
测试:发现数据回显了,除了商品分类以外:
4.3.商品分类回显
为什么商品分类没有回显?
因为品牌中并没有商品分类数据。我们需要在进入编辑页面之前,查询商品分类信息:
4.3.1.后台提供接口
controller
/** * 通过品牌id查询商品分类 * @param bid * @return */ @GetMapping("bid/{bid}") public ResponseEntity<List<Category>> queryByBrandId(@PathVariable("bid") Long bid) { List<Category> list = this.categoryService.queryByBrandId(bid); if (list == null || list.size() < 1) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list); }
Service
public List<Category> queryByBrandId(Long bid) { return this.categoryMapper.queryByBrandId(bid); }
mapper
因为需要通过中间表进行子查询,所以这里要手写Sql:
/** * 根据品牌id查询商品分类 * @param bid * @return */ @Select("SELECT * FROM tb_category WHERE id IN (SELECT category_id FROM tb_category_brand WHERE brand_id = #{bid})") List<Category> queryByBrandId(Long bid);
4.3.2.前台查询分类并渲染
我们在编辑页面打开之前,先把数据查询完毕:
editBrand(oldBrand){ // 根据品牌信息查询商品分类 this.$http.get("/item/category/bid/" + oldBrand.id) .then(({data}) => { // 控制弹窗可见: this.show = true; // 获取要编辑的brand this.oldBrand = oldBrand // 回显商品分类 this.oldBrand.categories = data; }) }
再次测试:数据成功回显了
4.3.3.新增窗口数据干扰
但是,此时却产生了新问题:新增窗口竟然也有数据?
原因:
如果之前打开过编辑,那么在父组件中记录的oldBrand会保留。下次再打开窗口,如果是编辑窗口到没问题,但是新增的话,就会再次显示上次打开的品牌信息了。
解决:
新增窗口打开前,把数据置空。
addBrand() { // 控制弹窗可见: this.show = true; // 把oldBrand变为null this.oldBrand = null; }
4.3.4.提交表单时判断是新增还是修改
新增和修改是同一个页面,我们该如何判断?
父组件中点击按钮弹出新增或修改的窗口,因此父组件非常清楚接下来是新增还是修改。
因此,最简单的方案就是,在父组件中定义变量,记录新增或修改状态,当弹出页面时,把这个状态也传递给子组件。
第一步:在父组件中记录状态:
第二步:在新增和修改前,更改状态:
第三步:传递给子组件
第四步,子组件接收标记:
标题的动态化:
表单提交动态:
axios除了除了get和post外,还有一个通用的请求方式:
// 将数据提交到后台 // this.$http.post('/item/brand', this.$qs.stringify(params)) this.$http({ method: this.isEdit ? 'put' : 'post', // 动态判断是POST还是PUT url: '/item/brand', data: this.$qs.stringify(this.brand) }).then(() => { // 关闭窗口 this.$emit("close"); this.$message.success("保存成功!"); }) .catch(() => { this.$message.error("保存失败!"); });
5.删除(作业)
小结
1、导入fastdfs-client客户端依赖
2、java配置类(默认提供有配置,我们只要导入就行)
3、覆盖默认配置,在application.yml中配置
4、提供了客户端类FastFileStorageClient类(需注入)方法uploadFile(流、文件大小,文件名后缀,默认null)
第八天
0.学习目标
2.2.3.后端代码
实体类
在leyou-item-interface
中添加实体类:
内容:
@Table(name = "tb_spec_group")
public class SpecGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long cid;
private String name;
@Transient
private List<SpecParam> params;
// getter和setter省略
}
因为在mysql中numeric是关键字,有需要@Column+··进行特殊处理
@Column(name = "`numeric`")
private Boolean numeric;
第九天:商品规格管理
0.学习目标
-
了解商品规格数据结构设计思路
-
实现商品规格查询
-
了解SPU和SKU数据结构设计思路
-
实现商品查询
-
了解商品新增的页面实现
-
独立编写商品新增后台功能
1.商品规格数据结构
吾爱程序猿(www.52programer.com)打造专业优质的IT教程分享社区
乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:
1.1.SPU和SKU
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
以图为例来看:
-
本页的 华为Mate10 就是一个商品集(SPU)
-
因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)
可以看出:
-
SPU是一个抽象的商品集概念,为了方便后台的管理。
-
SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU
1.2.数据库设计分析
1.2.1.思考并发现问题
弄清楚了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:库存 颜色? 内存? 硬盘?
碰到难题了,不同的商品分类,可能属性是不一样的,比如手机有内存,衣服有尺码,我们是全品类的电商网站,这些不同的商品的不同属性,如何设计到一张表中?
1.2.2.分析规格参数
仔细查看每一种商品的规格你会发现:
虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的,有图为证:
华为的规格:
三星的规格:
也就是说,商品的规格参数应该是与分类绑定的。每一个分类都有统一的规格参数模板,但不同商品其参数值可能不同。
如下图所示:
1.2.3.SKU的特有属性
SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。
不同种类的商品,一个手机,一个衣服,其SKU属性不相同。
同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。
这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察你会发现,SKU的特有属性是商品规格参数的一部分:
也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:
-
所有sku共享的规格属性(称为全局属性)
-
每个sku不同的规格属性(称为特有属性)
1.2.4.搜索属性
打开一个搜索页,我们来看看过滤的条件:
你会发现,过滤条件中的屏幕尺寸、运行内存、网路、机身内存、电池容量、CPU核数等,在规格参数中都能找到:
也就是说,规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:
1.3.规格参数表
1.3.1.表结构
先看下规格参数表:
CREATE TABLE `tb_specification` ( `category_id` bigint(20) NOT NULL COMMENT '规格模板所属商品分类id', `specifications` varchar(3000) NOT NULL DEFAULT '' COMMENT '规格参数模板,json格式', PRIMARY KEY (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品规格参数模板,json格式。';
很奇怪是吧,只有两个字段。特别需要注意的是第二个字段:
-
specificatons:规格参数模板,json格式
为什么是一个json?我们看下规格参数的格式:
如果按照传统数据库设计,这里至少需要3张表:
-
group:代表组,与商品分类关联
-
param_key:属性名,与组关联,一对多
-
param_value:属性备选值,与属性名关联,一对多
这样程序的复杂度大大增加,但是提高了数据的复用性。
我们的解决方案是,采用json来保存整个规格参数模板,不需要额外的表,一个字符串就够了。
1.3.2.json结构分析
先整体看一下:
-
因为规格参数分为很多组,所以json最外层是一个数组。
-
数组中是对象类型,每个对象代表一个组的数据,对象的属性包括:
-
group:组的名称
-
params:该组的所有属性
-
接下来是params:
以主芯片
这一组为例:
-
group:注明,这里是主芯片
-
params:该组的所有规格属性,因为不止一个,所以是一个数组。这里包含四个规格属性:CPU品牌,CPU型号,CPU频率,CPU核数。每个规格属性都是一个对象,包含以下信息:
-
k:属性名称
-
searchable:是否作为搜索字段,将来在搜索页面使用,boolean类型
-
global:是否是SPU全局属性,boolean类型。true为全局属性,false为SKU的特有属性
-
options:属性值的可选项,数组结构。起约束作用,不允许填写可选项以外的值,比如CPU核数,有人添10000核岂不是很扯淡
-
numerical:是否为数值,boolean类型,true则为数值,false则不是。为空也代表非数值
-
unit:单位,如:克,毫米。如果是数值类型,那么就需要有单位,否则可以不填。
-
上面的截图中所有属性都是全局属性,我们来看看内存,应该是特有属性:
总结下:
-
规格参数分组,每组有多个参数
-
参数的
k
代表属性名称,没有值,具体的SPU才能确定值 -
参数会有不同的属性:是否可搜索,是否是全局、是否是数值,这些都用boolean值进行标记:
-
SPU下的多个SKU共享的参数称为全局属性,用
global
标记 -
SPU下的多个SKU特有的参数称为特有属性
-
如果参数是数值类型,用
numerical
标记,并且指定单位unit
-
如果参数可搜索,用
searchable
标记
-
2.商品规格参数管理
吾爱程序猿(www.52programer.com)打造专业优质的IT教程分享社区
2.1.页面实现
页面比较复杂,这里就不带着大家去实现完整页面效果了,我们一起分析一下即可。
2.1.1.整体布局
打开规格参数页面,看到如下内容:
因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。一起了解下页面的实现:
可以看出页面分成3个部分:
-
v-card-title
:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板 -
v-tree
:这里用到的是我们之前讲过的树组件,展示商品分类树,不过现在是假数据,我们只要把treeData
属性删除,它就会走url
属性指定的路径去查询真实的商品分类树了。<v-tree url="/item/category/list" :isEdit="false" @handleClick="handleClick" />
-
v-dialog
:Vuetify提供的对话框组件,v-model绑定的dialog属性是boolean类型:-
true则显示弹窗
-
false则隐藏弹窗
-
2.1.2.data中定义的属性
接下来,看看Vue实例中data定义了哪些属性,对页面会产生怎样的影响:
-
specifications:选中一个商品分类后,需要查询后台获取规格参数信息,保存在这个对象中,Vue会完成页面渲染。
-
oldSpec:当前页兼具了规格的增、改、查等功能,这个对象记录被修改前的规格参数,以防用户撤销修改,用来恢复数据。
-
dialog:是否显示对话框的标记。true则显示,false则不显示
-
currentNode:记录当前选中的商品分类节点
-
isInsert:判断接下来是新增还是修改
2.2.规格参数的查询
点击树节点后要显示规格参数,因此查询功能应该编写在点击事件中。
了解一下:
2.2.1.树节点的点击事件
当我们点击树节点时,要将v-dialog
打开,因此必须绑定一个点击事件:
我们来看下handleClick
方法:
handleClick(node) {
// 判断点击的节点是否是父节点(只有点击到叶子节点才会弹窗)
if (!node.isParent) {
// 如果是叶子节点,那么就发起ajax请求,去后台查询商品规格数据。
this.$http.get("/item/spec/" + node.id)
.then(resp => {
// 查询成功后,把响应结果赋值给specifications属性,Vue会进行自动渲染。
this.specifications = resp.data;
// 记录下此时的规格数据,当页面撤销修改时,用来恢复原始数据
this.oldSpec = resp.data;
// 打开弹窗
this.dialog = true;
// 标记此时要进行修改操作
this.isInsert = false;
})
.catch(() => {
// 如果没有查询成功,那么询问是否添加规格
this.$message.confirm('该分类还没有规格参数,是否添加?')
.then(() => {
// 如果要添加,则将specifications初始化为空
this.specifications = [{
group: '',
params: []
}];
// 打开弹窗
this.dialog = true;
// 标记为新增
this.isInsert = true;
})
})
}
}
因此,我们接下来要做的事情,就是编写接口,实现规格参数的查询了。
2.2.2.后端代码
实体类:SpecGroup
@Table(name = "tb_spec_group")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SpecGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long cid;
private String name;
@Transient//如果数据库没有这个字段需要加这个注解,否则会报错
private List<SpecParam> params;
实体类:SpecParam
@Table(name = "tb_spec_param")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SpecParam {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; //主键
private Long cid; //商品分类id
private Long groupId; //分组id
private String name; //参数名
@Column(name = "`numeric`") //该字段在数据库中为关键字,需特殊处理
private Boolean numeric; //是否是数字类型参数
private String unit; //数字类型参数的单位
private Boolean generic; //是否是通用参数
private Boolean searching; //是否可用于查询
private String segments; //若数值类型为搜素,需添加一个数字范围
}
mapper:SpecGroupMapper
import com.leyou.item.pojo.SpecGroup; import tk.mybatis.mapper.common.Mapper; public interface SpecGroupMapper extends Mapper<SpecGroup> { }
mapper:SpecParamMapper
import com.leyou.item.pojo.SpecParam; import tk.mybatis.mapper.common.Mapper; public interface SpecParamMapper extends Mapper<SpecParam> { }
controller层:SpecificationController
package com.leyou.item.controller;
import com.leyou.item.pojo.SpecGroup;
import com.leyou.item.pojo.SpecParam;
import com.leyou.item.service.SpecificationService;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("spec")
public class SpecificationController {
@Autowired
private SpecificationService specificationService;
/**
* 根据分类id查找规格参数组
* @param id
* @return
*/
@GetMapping("groups/{cid}")
public ResponseEntity<List<SpecGroup>> querySpecificationByCategoryId(@PathVariable("cid") Long id){
List<SpecGroup> groups = this.specificationService.queryGroupById(id);
if (CollectionUtils.isEmpty(groups)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(groups);
}
/**
* 根据条件查询规格参数,不止是gid
* @param gid
* @return
*/
@GetMapping("params")
public ResponseEntity<List<SpecParam>> queryParams(@PathVariable(value = "gid",required = false) Long gid) {
List<SpecParam> params = this.specificationService.queryParams(gid);
if (CollectionUtils.isEmpty(params)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(params);
}
}
实体类
@Table(name = "tb_specification") public class Specification { @Id private Long categoryId; private String specifications; public Long getCategoryId() { return categoryId; } public void setCategoryId(Long categoryId) { this.categoryId = categoryId; } public String getSpecifications() { return specifications; } public void setSpecifications(String specifications) { this.specifications = specifications; } }
mapper
public interface SpecificationMapper extends Mapper<Specification> { }
controller
先分析下需要的东西,在页面的ajax请求中可以看出:
-
请求方式:查询,肯定是get
-
请求路径:/spec/{cid} ,这里通过路径占位符传递商品分类的id
-
请求参数:商品分类id
-
返回结果:页面是直接把
resp.data
赋值给了specifications:那么我们返回的应该是规格参数的字符串
代码:
@RestController @RequestMapping("spec") public class SpecificationController { @Autowired private SpecificationService specificationService; @GetMapping("{id}") public ResponseEntity<String> querySpecificationByCategoryId(@PathVariable("id") Long id){ Specification spec = this.specificationService.queryById(id); if (spec == null) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(spec.getSpecifications()); } }
service:
@Service public class SpecificationService { @Autowired private SpecificationMapper specificationMapper; public Specification queryById(Long id) { return this.specificationMapper.selectByPrimaryKey(id); } }
页面访问测试:
目前,我们数据库只提供了3条规格参数信息:
我们访问:http://api.leyou.com/api/item/spec/76
然后在后台系统中测试:
当我们点击一个还不存在的规格参数的商品分类:
2.3.增、删、改(作业)
增删改的作业就留给大家去完成了。页面中接口都已定义,你要做的就是实现后台接口。
3.SPU和SKU数据结构
吾爱程序猿(www.52programer.com)打造专业优质的IT教程分享社区
规格确定以后,就可以添加商品了,先看下数据库表
3.1.SPU表
3.1.1.表结构
SPU表:
CREATE TABLE `tb_spu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`sub_title` varchar(255) DEFAULT '' COMMENT '子标题',
`cid1` bigint(20) NOT NULL COMMENT '1级类目id',
`cid2` bigint(20) NOT NULL COMMENT '2级类目id',
`cid3` bigint(20) NOT NULL COMMENT '3级类目id',
`brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
`saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
`valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
`create_time` datetime DEFAULT NULL COMMENT '添加时间',
`last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';
与我们前面分析的基本类似,但是似乎少了一些字段,比如商品描述。
垂直拆分:存放大字段,比如说商品描述、规格参数、包装清单和售后服务,而且一般只是在商品详情页展示,如果大数据字段放在和spu同一张表会导致查询速度非常的慢
我们做了表的垂直拆分,将SPU的详情放到了另一张表:tb_spu_detail
CREATE TABLE `tb_spu_detail` (
`spu_id` bigint(20) NOT NULL,
`description` text COMMENT '商品描述信息',
`specifications` varchar(3000) NOT NULL DEFAULT '' COMMENT '全部规格参数数据',
`spec_template` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
`packing_list` varchar(1000) DEFAULT '' COMMENT '包装清单',
`after_service` varchar(1000) DEFAULT '' COMMENT '售后服务',
PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
这张表中的数据都比较大,为了不影响主表的查询效率我们拆分出这张表。
需要注意的是这两个字段:specifications和spec_template。
3.1.2.spu中的规格参数
前面讲过规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数,因此我们计划是这样:
-
SPU中保存全局的规格参数信息。
-
SKU中保存特有规格参数。
以手机为例,品牌、操作系统等肯定是全局属性,内存、颜色等肯定是特有属性。
当你确定了一个SPU,比如小米的:红米4X
全局属性举例:
品牌:小米 型号:红米4X
特有属性举例:
颜色:[香槟金, 樱花粉, 磨砂黑] 内存:[2G, 3G] 机身存储:[16GB, 32GB]
来看下我们的 表如何存储这些信息:
3.1.2.1.specifications字段
首先是specifications,其中保存全部规格参数信息,因此也是一个json格式:
整体来看:
整体看上去与规格参数表中的数据一样,也是一个数组,并且分组,每组下有多个参数
展开一组来看
可以看到,与规格参数表中的模板相比,最大的区别就是,这里指定了具体的值,因为商品确定了,其参数值肯定也确定了。
特有属性
刚才看到的是全局属性,那么特有属性在这个字段中如何存储呢?
我们发现特有属性也是有的,但是,注意看这里是不确定具体值的,因为特有属性只有在SKU中才能确定。这里只是保存了options,所有SKU属性的可选项。
在哪里会用到这个字段的值呢,商品详情页的规格参数信息中:
3.1.2.2.spec_template字段
既然specifications已经包含了所有的规格参数,那么为什么又多出了一个spec_template呢?
里面又有哪些内容呢?
来看数据格式:
可以看出,里面只保存了规格参数中的特有属性,而且格式进行了大大的简化,只有属性的key,和待选项。
为什么要冗余保存一份?
因为很多场景下我们只需要查询特有规格属性,如果放在一起,每次查询再去分离比较麻烦。
比如,商品详情页展示可选的规格参数时:
3.2.SKU表
3.2.1.表结构
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格式,反序列化时应使用linkedHashMap,保证有序',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
`create_time` datetime NOT NULL COMMENT '添加时间',
`last_update_time` datetime NOT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`),
KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';
还有一张表,代表库存:
CREATE TABLE `tb_stock` (
`sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
`seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
`seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
`stock` int(9) NOT NULL COMMENT '库存数量',
PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';
问题:为什么要将库存独立一张表?
因为库存字段写频率较高,而SKU的其它字段以读为主,因此我们将两张表分离,读写不会干扰。
特别需要注意的是sku表中的indexes
字段和own_spec
字段。sku中应该保存特有规格参数的值,就在这两个字段中。
3.2.2.sku中的特有规格参数
3.2.2.1.indexes字段
在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下:
{ "机身颜色": [ "香槟金", "樱花粉", "磨砂黑" ], "内存": [ "2GB", "3GB" ], "机身存储": [ "16GB", "32GB" ] }
这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。
比如:
-
红米4X,香槟金,2GB内存,16GB存储
-
红米4X,磨砂黑,2GB内存,32GB存储
你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:
-
红米4X,0,0,0
-
红米4X,2,0,1
既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。
这个设计在商品详情页会特别有用:
当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。
3.2.2.2.own_spec字段
看结构:
{"机身颜色":"香槟金","内存":"2GB","机身存储":"16GB"}
保存的是特有属性的键值对。
SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的键值对了。
这样,在页面展示规格参数信息时,就可以根据key来获取值,用于显示。
3.3.导入图片信息
现在商品表中虽然有数据,但是所有的图片信息都是无法访问的,我们需要把图片导入到虚拟机:
首先,把课前资料提供的数据上传到虚拟机下:/leyou/static
目录:
然后,使用命令解压缩:
unzip images.zip
修改Nginx配置,使nginx反向代理这些图片地址:
vim /opt/nginx/config/nginx.conf
修改成如下配置:
server { listen 80; server_name image.leyou.com; # 监听域名中带有group的,交给FastDFS模块处理 location ~/group([0-9])/ { ngx_fastdfs_module; } # 将其它图片代理指向本地的/leyou/static目录 location / { root /leyou/static/; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } }
4.商品查询
4.1.效果预览
接下来,我们实现商品管理的页面,先看下我们要实现的效果:
可以看出整体是一个table,然后有新增按钮。是不是跟昨天写品牌管理很像?
模板代码在分别在Goods.vue
4.2.从0开始
接下来,我们自己来实现一下,新建两个组件:MyGoods.vue和MyGoodsForm.vue
内容先随意:
<template>
<v-card>
MyGoods
</v-card>
</template>
<script>
export default {
name: "my-goods",
data() {
return {
}
}
}
</script>
<style scoped>
</style>
然后修改menu.js,新建一个菜单:
修改router/index.js,添加一个路由:
预览一下:
4.3.页面实现
4.3.1.页面基本表格
商品列表页与品牌列表页几乎一样,我们可以直接去复制一份过来,然后进行一些修改。
首先,字段不一样,商品列表也展示的SPU信息,包含以下字段:
id: title:标题 cname:商品分类名称 bname:品牌名称
完整代码:
<template>
<v-card>
<v-card-title>
<v-btn color="primary" @click="addGoods">新增商品</v-btn>
<!--搜索框,与search属性关联-->
<v-spacer/>
<v-text-field label="输入关键字搜索" v-model.lazy="search" append-icon="search" hide-details/>
</v-card-title>
<v-divider/>
<v-data-table
:headers="headers"
:items="goodsList"
:search="search"
:pagination.sync="pagination"
:total-items="totalGoods"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.title }}</td>
<td class="text-xs-center">{{props.item.cname}}</td>
<td class="text-xs-center">{{ props.item.bname }}</td>
<td class="justify-center layout">
<v-btn color="info" @click="editGoods(props.item)">编辑</v-btn>
<v-btn color="warning">删除</v-btn>
<v-btn >下架</v-btn>
</td>
</template>
</v-data-table>
<!--弹出的对话框-->
<v-dialog max-width="500" v-model="show" persistent>
<v-card>
<!--对话框的标题-->
<v-toolbar dense dark color="primary">
<v-toolbar-title>{{isEdit ? '修改' : '新增'}}商品</v-toolbar-title>
<v-spacer/>
<!--关闭窗口的按钮-->
<v-btn icon @click="closeWindow"><v-icon>close</v-icon></v-btn>
</v-toolbar>
<!--对话框的内容,表单-->
<v-card-text class="px-5">
<my-goods-form :oldGoods="oldGoods" />
</v-card-text>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
// 导入自定义的表单组件
import MyGoodsForm from './MyGoodsForm'
export default {
name: "my-goods",
data() {
return {
search: '', // 搜索过滤字段
totalGoods: 0, // 总条数
goodsList: [], // 当前页品牌数据
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [
{text: 'id', align: 'center', value: 'id'},
{text: '标题', align: 'center', sortable: false, value: 'title'},
{text: '商品分类', align: 'center', sortable: false, value: 'cname'},
{text: '品牌', align: 'center', value: 'bname', sortable: false,},
{text: '操作', align: 'center', sortable: false}
],
show: false,// 控制对话框的显示
oldGoods: {}, // 即将被编辑的商品信息
isEdit: false, // 是否是编辑
}
},
mounted() { // 渲染后执行
// 查询数据
this.getDataFromServer();
},
watch: {
pagination: { // 监视pagination属性的变化
deep: true, // deep为true,会监视pagination的属性及属性中的对象属性变化
handler() {
// 变化后的回调函数,这里我们再次调用getDataFromServer即可
this.getDataFromServer();
}
},
search: { // 监视搜索字段
handler() {
this.getDataFromServer();
}
}
},
methods: {
getDataFromServer() { // 从服务的加载数的方法。
// 发起请求
this.$http.get("/item/spu/page", {
params: {
key: this.search, // 搜索条件
page: this.pagination.page,// 当前页
rows: this.pagination.rowsPerPage,// 每页大小
sortBy: this.pagination.sortBy,// 排序字段
desc: this.pagination.descending// 是否降序
}
}).then(resp => { // 这里使用箭头函数
this.goodsList = resp.data.items;
this.totalGoods = resp.data.total;
// 完成赋值后,把加载状态赋值为false
this.loading = false;
})
},
addGoods() {
// 修改标记
this.isEdit = false;
// 控制弹窗可见:
this.show = true;
// 把oldBrand变为null
this.oldBrand = null;
},
editGoods(oldGoods){
// 修改标记
this.isEdit = true;
// 控制弹窗可见:
this.show = true;
// 获取要编辑的brand
this.oldGoods = oldGoods;
},
closeWindow(){
// 重新加载数据
this.getDataFromServer();
// 关闭窗口
this.show = false;
}
},
components:{
MyGoodsForm
}
}
</script>
<style scoped>
</style>
主要的改动点:
-
页面的
v-data-table
中的属性绑定修改。items指向goodsList,totalItems指向totalGoods -
页面渲染的字段名修改:字段改成商品的SPU字段:id、title,cname(商品分类名称),bname(品牌名称)
-
data属性修改了以下属性:
-
goodsList:当前页商品数据
-
totalGoods:商品总数
-
headers:头信息,需要修改头显示名称
-
oldGoods:准备要修改的商品
-
-
加载数据的函数:getDataFromServer,请求的路径进行了修改,另外去除了跟排序相关的查询。SPU查询不排序
-
新增商品的事件函数:清除了一些数据查询接口,只保留弹窗
查看效果:
因为没有编写查询功能,表格一直处于loading状态。
接下来看弹窗:
4.3.2.上下架状态按钮
另外,似乎页面少了对上下架商品的过滤,在原始效果图中是有的:
这在Vuetify中是一组按钮,我们查看帮助文档:
查看实例得到以下信息:
v-btn
:一个按钮
v-btn-toggle
:按钮组,内部可以有多个按钮,点击切换,有以下属性:
-
multiple:是否支持多选,默认是false
-
value:选中的按钮的值,如果是多选,结果是一个数组;单选,结果是点击的v-btn中的value值,因此按钮组的每个btn都需要指定value属性
改造页面:
首先在data中定义一个属性,记录按钮的值。
filter:{ saleable: false, // 上架还是下架 search: '', // 搜索过滤字段 }
这里我们的做法是定义一个filter属性,内部在定义search来关联过滤字段,saleable来关联上下架情况。
这样watch就必须监听filter,而不是只监听search了:
filter: {// 监视搜索字段 handler() { this.getDataFromServer(); }, deep:true }
另外,页面中与search有关的所有字段都需要修改成filter.search:
<!--搜索框,与search属性关联--> <v-text-field label="输入关键字搜索" v-model.lazy="filter.search" append-icon="search" hide-details/>
然后,在页面中添加按钮组:
<v-flex xs3> 状态: <v-btn-toggle v-model="filter.saleable"> <v-btn flat> 全部 </v-btn> <v-btn flat :value="true"> 上架 </v-btn> <v-btn flat :value="false"> 下架 </v-btn> </v-btn-toggle> </v-flex>
最后,不要忘了在查询时,将saleable携带上:
getDataFromServer() { // 从服务的加载数的方法。 // 发起请求 this.$http.get("/item/spu/page", { params: { key: this.filter.search, // 搜索条件 saleable: this.filter.saleable, // 上下架 page: this.pagination.page,// 当前页 rows: this.pagination.rowsPerPage,// 每页大小 } }).then(resp => { // 这里使用箭头函数 this.goodsList = resp.data.items; this.totalGoods = resp.data.total; // 完成赋值后,把加载状态赋值为false this.loading = false; }) }
4.4.后台提供接口
页面已经准备好,接下来在后台提供分页查询SPU的功能:
4.4.1.实体类
SPU
@Table(name = "tb_spu")
public class Spu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long brandId;
private Long cid1;// 1级类目
private Long cid2;// 2级类目
private Long cid3;// 3级类目
private String title;// 标题
private String subTitle;// 子标题
private Boolean saleable;// 是否上架
private Boolean valid;// 是否有效,逻辑删除用
private Date createTime;// 创建时间
private Date lastUpdateTime;// 最后修改时间
// 省略getter和setter
}
SPU详情
@Table(name="tb_spu_detail")
public class SpuDetail {
@Id
private Long spuId;// 对应的SPU的id
private String description;// 商品描述
private String specTemplate;// 商品特殊规格的名称及可选值模板
private String specifications;// 商品的全局规格属性
private String packingList;// 包装清单
private String afterService;// 售后服务
// 省略getter和setter
}
4.4.2.controller
先分析:
-
请求方式:GET
-
请求路径:/spu/page
-
请求参数:
-
page:当前页
-
rows:每页大小
-
key:过滤条件
-
saleable:上架或下架
-
-
返回结果:商品SPU的分页信息。
-
要注意,页面展示的是商品分类和品牌名称,而数据库中保存的是id,怎么办?
我们可以新建一个类,继承SPU,并且拓展cname和bname属性,写到
ly-item-interface
-
起名叫bo:表示是业务对象 public class SpuBo extends Spu { String cname;// 商品分类名称 String bname;// 品牌名称 // 略 。。 }
-
编写controller代码:
我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样
@RestController
public class GoodsController {
@Autowired
private GoodsService goodsService;
/**
* 分页查询SPU
* @param page
* @param rows
* @param key
* @return
*/
@GetMapping("/spu/page")
public ResponseEntity<PageResult<SpuBo>> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "key", required = false) String key) {
// 分页查询spu信息
PageResult<SpuBo> result = this.goodsService.querySpuByPageAndSort(page, rows, key);
if (result == null || result.getItems().size() == 0) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
4.4.3.service
所有商品相关的业务(包括SPU和SKU)放到一个业务下:GoodsService。
@Service
public class GoodsService {
@Autowired
private SpuMapper spuMapper;
@Autowired
private CategoryService categoryService;
@Autowired
private BrandMapper brandMapper;
public PageResult<SpuBo> querySpuByPageAndSort(Integer page, Integer rows, Boolean saleable, String key) {
// 1、查询SPU
// 分页,最多允许查100条
PageHelper.startPage(page, Math.min(rows, 100));
// 创建查询条件
Example example = new Example(Spu.class);
Example.Criteria criteria = example.createCriteria();
// 是否过滤上下架
if (saleable != null) {
criteria.orEqualTo("saleable", saleable);
}
// 是否模糊查询
if (StringUtils.isNotBlank(key)) {
criteria.andLike("title", "%" + key + "%");
}
Page<Spu> pageInfo = (Page<Spu>) this.spuMapper.selectByExample(example);
List<SpuBo> list = pageInfo.getResult().stream().map(spu -> {
// 2、把spu变为 spuBo
SpuBo spuBo = new SpuBo();
// 属性拷贝,把spu的属性拷贝到SpuBo
BeanUtils.copyProperties(spu, spuBo);
// 3、查询spu的商品分类名称,要查三级分类
List<String> names = this.categoryService.queryNameByIds(
Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
// 将分类名称拼接后存入
spuBo.setCname(StringUtils.join(names, "/"));
// 4、查询spu的品牌名称
Brand brand = this.brandMapper.selectByPrimaryKey(spu.getBrandId());
spuBo.setBname(brand.getName());
return spuBo;
}).collect(Collectors.toList());
return new PageResult<>(pageInfo.getTotal(), list);
}
}
4.4.4.mapper
public interface SpuMapper extends Mapper<Spu> { }
4.4.5.Category中拓展查询名称的功能
页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,
在CategoryService中添加功能:
public List<String> queryNameByIds(List<Long> ids) { return this.categoryMapper.selectByIdList(ids).stream().map(Category::getName).collect(Collectors.toList()); }
mapper的selectByIDList方法是来自于通用mapper。不过需要我们在mapper上继承一个通用mapper接口:
public interface CategoryMapper extends Mapper<Category>, SelectByIdListMapper<Category, Long> { // ...coding }
4.5.测试
刷新页面,查看效果:
基本与预览的效果一致,OK!
5.商品新增
吾爱程序猿(www.52programer.com)打造专业优质的IT教程分享社区
5.1.效果预览
新增商品窗口:
这个表单比较复杂,因为商品的信息比较多,分成了4个部分来填写:
-
基本信息
-
商品描述信息
-
规格参数信息
-
SKU信息
5.2.从0开始
我们刚刚在查询时,已经实现创建了MyGoodsForm.vue,并且已经在MyGoods中引入。
不过目前没有写代码:
<template> <v-card> my goods form </v-card> </template> <script> export default { name: "my-goods-form", props: { oldGoods: { type: Object }, isEdit: { type: Boolean, default: false } }, data() { return { } }, methods: { } } </script> <style scoped> </style>
然后在MyBrand中,已经引入了MyGoodsForm组件,并且页面中也形成了对话框:
// 导入自定义的表单组件 import MyGoodsForm from './MyGoodsForm'
<v-dialog max-width="500" v-model="show" persistent> <v-card> <!--对话框的标题--> <v-toolbar dense dark color="primary"> <v-toolbar-title>{{isEdit ? '修改' : '新增'}}商品</v-toolbar-title> <v-spacer/> <!--关闭窗口的按钮--> <v-btn icon @click="closeWindow"> <v-icon>close</v-icon> </v-btn> </v-toolbar> <!--对话框的内容,表单--> <v-card-text class="px-5"> <my-goods-form :oldGoods="oldGoods"/> </v-card-text> </v-card> </v-dialog>
并且也已经给新增按钮绑定了点击事件:
<v-btn color="primary" @click="addGoods">新增商品</v-btn>
addGoods方法中,设置对话框的show属性为true:
addGoods() { // 修改标记 this.isEdit = false; // 控制弹窗可见: this.show = true; // 把oldBrand变为null this.oldBrand = null; }
不过弹窗中没有任何数据:
5.3.新增商品页的基本框架
5.3.1.Steppers,步骤线
预览效果图中,分四个步骤显示商品表单的组件,叫做stepper,看下文档:
其基本结构如图:
一个步骤线(v-stepper)总的分为两部分:
-
v-stepper-header:代表步骤的头部进度条,只能有一个
-
v-stepper-step:代表进度条的每一个步骤,可以有多个
-
-
v-stepper-items:代表当前步骤下的内容组,只能有一个,内部有stepper-content
-
v-stepper-content:代表每一步骤的页面内容,可以有多个
-
v-stepper
-
value:其值是当前所在的步骤索引,可以用来控制步骤切换
-
dark:是否使用黑暗色调,默认false
-
non-linear:是否启用非线性步骤,用户不用按顺序切换,而是可以调到任意步骤,默认false
-
vertical:是否垂直显示步骤线,默认是false,即水平显示
v-stepper-header的属性:
-
无
v-stepper-step的属性
-
color:颜色
-
complete:当前步骤是否已经完成,布尔值
-
editable:是否可编辑任意步骤(非线性步骤)
-
step:步骤索引
v-stepper-items
-
无
v-stepper-content
-
step:步骤索引,需要与v-stepper-step中的对应
5.3.2.编写页面
首先我们在data中定义一个变量,记录当前的步骤数:
data() { return { step: 1, // 当前的步骤数,默认为1 } },
然后在模板页面中引入步骤线:
<v-stepper v-model="step"> <v-stepper-header> <v-stepper-step :complete="step > 1" step="1">基本信息</v-stepper-step> <v-divider/> <v-stepper-step :complete="step > 2" step="2">商品描述</v-stepper-step> <v-divider/> <v-stepper-step :complete="step > 3" step="3">规格参数</v-stepper-step> <v-divider/> <v-stepper-step step="4">SKU属性</v-stepper-step> </v-stepper-header> <v-stepper-items> <v-stepper-content step="1"> 基本信息 </v-stepper-content> <v-stepper-content step="2"> 商品描述 </v-stepper-content> <v-stepper-content step="3"> 规格参数 </v-stepper-content> <v-stepper-content step="4"> SKU属性 </v-stepper-content> </v-stepper-items> </v-stepper>
效果:
步骤线出现了!
那么问题来了:该如何让这几个步骤切换呢?
5.3.3.步骤切换按钮
分析
如果改变step的值与指定的步骤索引一致,就可以实现步骤切换了:
因此,我们需要定义两个按钮,点击后修改step的值,让步骤前进或后退。
那么这两个按钮放哪里?
如果放在MyGoodsForm内,当表单内容过多时,按钮会被挤压到屏幕最下方,不够友好。最好是能够悬停状态。
所以,按钮必须放到MyGoods组件中,也就是父组件。
父组件的对话框是一个card,card组件提供了一个滚动效果,scrollable,如果为true,card的内容滚动时,其头部和底部是可以静止的。
现在card的头部是弹框的标题,card的中间就是表单内容。如果我们把按钮放到底部,就可以实现悬停效果。
页面添加按钮
改造MyGoods的对话框组件:
查看页面:
添加点击事件
现在这两个按钮点击后没有任何反应。我们需要给他们绑定点击事件,来修改MyGoodsForm中的step的值。
也就是说,父组件要修改子组件的属性状态。想到什么了?
props属性。
我们先在父组件定义一个step属性:
然后在点击事件中修改它:
previous(){ if(this.step > 1){ this.step-- } }, next(){ if(this.step < 4){ this.step++ } }
页面绑定事件:
<!--底部按钮,用来操作步骤线--> <v-card-actions class="elevation-10"> <v-flex class="xs3 mx-auto"> <v-btn @click="previous" color="primary" :disabled="step === 1">上一步</v-btn> <v-btn @click="next" color="primary" :disabled="step === 4">下一步</v-btn> </v-flex> </v-card-actions>
然后把step属性传递给子组件:
<!--对话框的内容,表单--> <v-card-text class="px-3" style="height: 600px"> <my-goods-form :oldGoods="oldGoods" :step="step"/> </v-card-text>
子组件中接收属性:
测试效果:
5.4.商品基本信息
商品基本信息,主要是一些纯文本比较简单的SPU属性,例如:
商品分类、商品品牌、商品标题、商品卖点(子标题),包装清单,售后服务
接下来,我们一一添加这些表单项。
注:这里为了简化,我们就不进行form表单校验了。之前已经讲过。
5.4.1.在data中定义Goods属性
首先,我们需要定义一个goods对象,包括商品的上述属性。
data() { return { goods:{ categories:{}, // 商品3级分类数组信息 brandId: 0,// 品牌id信息 title: '',// 标题 subTitle: '',// 子标题 spuDetail: { packingList: '',// 包装列表 afterService: '',// 售后服务 }, } }
注意,这里我们在goods中定义了spuDetail属性,然后把包装列表和售后服务作为它的属性,这样符合数据库的结构。
5.4.2.商品分类选框
商品分类选框之前我们已经做过了。是级联选框。直接拿来用:
<v-cascader url="/item/category/list" required showAllLevels v-model="goods.categories" label="请选择商品分类"/>
跟以前使用有一些区别:
-
一个商品只能有一个分类,所以这里去掉了multiple属性
-
商品SPU中要保存3级商品分类,因此我们这里需要选择showAllLevels属性,显示所有3级分类
效果:
查看goods的属性,三级类目都在:
5.4.3.品牌选择
select组件
品牌不分级别,使用普通下拉选框即可。我们查看官方文档的下拉选框说明:
组件名:v-select
比较重要的一些属性:
-
item-text:选项中用来展示的字段名,默认是text
-
item-value:选项中用来作为value值的字段名,默认是value
-
items:待选项的对象数组
-
label:提示文本
-
multiple:是否支持多选,默认是false
其它次要属性:
-
autocomplete:是否根据用户输入的文本进行搜索过滤(自动),默认false
-
chips:是否以小纸片方式显示用户选中的项,默认false
-
clearable:是否添加清空选项图标,默认是false
-
color:颜色
-
dense:是否压缩选择框高度,默认false
-
editable:是否可编辑,默认false
-
hide-details:是否隐藏错误提示,默认false
-
hide-selected:是否在菜单中隐藏已选择的项
-
hint:提示文本
-
其它基本与
v-text-filed
组件类似,不再一一列举
页面实现
备选项items需要我们去后台查询,而且必须是在用户选择商品分类后去查询。
我们定义一个属性,保存品牌的待选项信息:
然后编写一个watch,监控goods.categories的变化:
watch: { 'goods.categories': { deep: true, handler(val) { // 判断商品分类是否存在,存在才查询 if (val && val.length > 0) { // 根据分类查询品牌 this.$http.get("/item/brand/cid/" + this.goods.categories[2].id) .then(({data}) => { this.brandOptions = data; }) } } } }
我们的品牌对象包含以下字段:id、name、letter、image。显然item-text应该对应name,item-value应该对应id
因此我们添加一个选框,指定item-text和item-value
<!--品牌--> <v-select :items="brandOptions" item-text="name" item-value="id" label="所属品牌" v-model="goods.brandId" required autocomplete clearable dense chips />
后台提供接口
页面需要去后台查询品牌信息,我们自然需要提供:
controller
/** * 根据分类查询品牌 * @param cid * @return */ @GetMapping("cid/{cid}") public ResponseEntity<List<Brand>> queryBrandByCategory(@PathVariable("cid") Long cid) { List<Brand> list = this.brandService.queryBrandByCategory(cid); if(list == null){ new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list); }
service
public List<Brand> queryBrandByCategory(Long cid) { return this.brandMapper.queryByCategoryId(cid); }
mapper
根据分类查询品牌有中间表,需要自己编写Sql:
@Select("SELECT b.* FROM tb_brand b LEFT JOIN tb_category_brand cb ON b.id = cb.brand_id WHERE cb.category_id = #{cid}") List<Brand> queryByCategoryId(Long cid);
测试效果
5.4.4.标题等其它字段
标题等字段都是普通文本,直接使用v-text-field
即可:
<v-text-field label="商品标题" v-model="goods.title" :counter="200" required /> <v-text-field label="商品卖点" v-model="goods.subTitle" :counter="200"/> <v-text-field label="包装清单" v-model="goods.spuDetail.packingList" :counter="1000" multi-line :rows="3"/> <v-text-field label="售后服务" v-model="goods.spuDetail.afterService" :counter="1000" multi-line :rows="3"/>
一些新的属性:
-
counter:计数器,记录当前用户输入的文本字数
-
rows:文本域的行数
-
multi-line:把单行文本变成文本域
5.5.商品描述信息
商品描述信息比较复杂,而且图文并茂,甚至包括视频。
这样的内容,一般都会使用富文本编辑器。
5.5.1.什么是富文本编辑器
百度百科:
通俗来说:富文本,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。
富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue
但是我们今天要说的,是一款支持Vue的富文本编辑器:vue-quill-editor
5.5.2.Vue-Quill-Editor
GitHub的主页:GitHub - surmon-china/vue-quill-editor: @quilljs editor component for @vuejs(2)
Vue-Quill-Editor是一个基于Quill的富文本编辑器:Quill的官网
5.5.3.使用指南
使用非常简单:
第一步:安装,使用npm命令:
npm install vue-quill-editor --save
第二步:加载,在js中引入:
全局使用:
import Vue from 'vue' import VueQuillEditor from 'vue-quill-editor' const options = {}; /* { default global options } */ Vue.use(VueQuillEditor, options); // options可选
局部使用:
import 'quill/dist/quill.core.css' import 'quill/dist/quill.snow.css' import 'quill/dist/quill.bubble.css' import {quillEditor} from 'vue-quill-editor' var vm = new Vue({ components:{ quillEditor } })
第三步:页面引用:
<quill-editor v-model="goods.spuDetail.description" :options="editorOption"/>
5.5.4.自定义的富文本编辑器
不过这个组件有个小问题,就是图片上传的无法直接上传到后台,因此我们对其进行了封装,支持了图片的上传。
使用也非常简单:
<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
5.5.5.效果:
5.6.规格参数
商品规格参数与商品分类绑定,因此我们需要在用户选择商品分类后,去后台查询对应的规格参数模板。
5.6.1.查询商品规格
首先,我们在data中定义变量,记录查询到的规格参数模板:
然后,我们通过watch监控goods.categories的变化,然后去查询规格:
查看是否查询到:
5.6.2.页面展示规格属性
获取到了规格参数,还需要把它展示到页面中。
现在查询到的规格参数只有key,并没有值。值需要用户来根据SPU信息填写,因此规格参数最终需要处理为表单。
整体结构
整体来看,规格参数是数组,每个元素是一组规格的集合。我们需要分组来展示。比如每组放到一个card中。
注意事项:
规格参数中的属性有一些需要我们特殊处理:
-
global:是否是全局属性,规格参数中一部分是SPU共享,属于全局属性,另一部是SKU特有,需要根据SKU来填写。因此,在当前版面中,只展示global为true的,即全局属性。sku特有属性放到最后一个面板
-
numerical:是否是数值类型,如果是,把单位补充在页面表单,不允许用户填写,并且要验证用户输入的数据格式
-
options:是否有可选项,如果有,则使用下拉选框来渲染。
页面代码:
<!--3、规格参数--> <v-stepper-content step="3"> <v-flex class="xs10 mx-auto px-3"> <!--遍历整个规格参数,获取每一组--> <v-card v-for="spec in specifications" :key="spec.group" class="my-2"> <!--组名称--> <v-card-title class="subheading">{{spec.group}}</v-card-title> <!--遍历组中的每个属性,并判断是否是全局属性,不是则不显示--> <v-card-text v-for="param in spec.params" :key="param.k" v-if="param.global" class="px-5"> <!--判断是否有可选项,如果没有,则显示文本框。还要判断是否是数值类型,如果是把unit显示到后缀--> <v-text-field v-if="param.options.length <= 0" :label="param.k" v-model="param.v" :suffix="param.unit || ''"/> <!--否则,显示下拉选项--> <v-select v-else :label="param.k" v-model="param.v" :items="param.options"/> </v-card-text> </v-card> </v-flex> </v-stepper-content>
效果:
5.7.SKU特有属性
sku特有属性也存在与specifications中,但是我们现在只想展示特有属性,而不是从头遍历一次。因此,我们应该从specifications中把特有规格属性拆分出来独立保存。
5.7.1.筛选特有规格参数
首先:我们在data中新建一个属性,保存特有的规格参数:
然后,在查询完成规格模板后,立刻对规格参数进行处理,筛选出特有规格参数,保存到specialSpecs中:
// 根据分类查询规格参数 this.$http.get("/item/spec/" + this.goods.categories[2].id) .then(({data}) => { // 保存全部规格 this.specifications = data; // 对特有规格进行筛选 const temp = []; data.forEach(({params}) => { params.forEach(({k, options, global}) => { if (!global) { temp.push({ k, options,selected:[] }) } }) }) this.specialSpecs = temp; })
要注意:我们添加了一个selected属性,用于保存用户填写的信息
查看数据:
5.7.2.页面渲染SKU属性
接下来,我们把筛选出的特有规格参数,渲染到SKU页面:
我们的目标效果是这样的:
可以看到,
-
每一个特有属性自成一组,都包含标题和选项。我们可以使用card达到这个效果。
-
无options选项的特有属性,展示一个文本框,有options选项的,展示多个checkbox,让用户选择
页面代码实现:
<!--4、SKU属性--> <v-stepper-content step="4"> <v-flex class="mx-auto"> <!--遍历特有规格参数--> <v-card flat v-for="spec in specialSpecs" :key="spec.k"> <!--特有参数的标题--> <v-card-title class="subheading">{{spec.k}}:</v-card-title> <!--特有参数的待选项,需要判断是否有options,如果没有,展示文本框,让用户自己输入--> <v-card-text v-if="spec.options.length <= 0" class="px-5"> <v-text-field :label="'输入新的' + spec.k" v-model="spec.selected"/> </v-card-text> <!--如果有options,需要展示成多个checkbox--> <v-card-text v-else class="container fluid grid-list-xs"> <v-layout row wrap class="px-5"> <v-checkbox color="primary" v-for="o in spec.options" :key="o" class="flex xs3" :label="o" v-model="spec.selected" :value="o"/> </v-layout> </v-card-text> </v-card> </v-flex> </v-stepper-content>
我们的实现效果:
测试下,勾选checkbox或填写文本会发生什么:
看下规格模板的值:
5.7.3.自由添加或删除文本框
刚才的实现中,普通文本项只有一个,如果用户想添加更多值就不行。我们需要让用户能够自由添加新的文本框,而且还能删除。
这里有个取巧的方法:
还记得我们初始化 特有规格参数时,新增了一个selected属性吗,用来保存用户填写的值,是一个数组。每当用户新加一个值,该数组的长度就会加1,而初始长度为0
另外,v-for指令有个特殊之处,就在于它可以遍历数字。比如 v-for="i in 10",你会得到1~10
因此,我们可以遍历selected的长度,每当我们输入1个文本,selected长度会加1,自然会多出一个文本框。
代码如下:
<v-card flat v-for="spec in specialSpecs" :key="spec.k"> <!--特有参数的标题--> <v-card-title class="subheading">{{spec.k}}:</v-card-title> <!--特有参数的待选项,需要判断是否有options,如果没有,展示文本框,让用户自己输入--> <v-card-text v-if="spec.options.length <= 0" class="px-5"> <div v-for="i in spec.selected.length+1" :key="i"> <v-text-field :label="'输入新的' + spec.k" v-model="spec.selected[i-1]" v-bind:value="i"/> </div> </v-card-text> <!--如果有options,需要展示成多个checkbox--> <v-card-text v-else class="container fluid grid-list-xs"> <v-layout row wrap class="px-5"> <v-checkbox color="primary" v-for="o in spec.options" :key="o" class="flex xs3" :label="o" v-model="spec.selected" :value="o"/> </v-layout> </v-card-text> </v-card>
效果:
而删除文本框相对就比较简单了,只要在文本框末尾添加一个按钮,添加点击事件即可,代码:
添加了一些布局样式,以及一个按钮,在点击事件中删除一个值。
5.8.展示SKU列表
5.8.1.效果预览
当我们选定SKU的特有属性时,就会对应出不同排列组合的SKU。
举例:
当你选择了上图中的这些选项时:
-
颜色共2种:土豪金,绚丽红
-
内存共2种:2GB,4GB
-
机身存储1种:64GB
此时会产生多少种SKU呢? 应该是 2 * 2 * 1 = 4种。
因此,接下来应该由用户来对这4种sku的信息进行详细填写,比如库存和价格等。而多种sku的最佳展示方式,是表格(淘宝、京东都是这么做的),如图:
而且这个表格应该随着用户选择的不同而动态变化。如何实现?
5.8.2.算法:求数组笛卡尔积
大家看这个结果就能发现,这其实是在求多个数组的笛卡尔积。作为一个程序员,这应该是基本功了吧。
两个数组笛卡尔积
假如有两个数组,求笛卡尔积,其基本思路是这样的:
-
在遍历一个数组的同时,遍历另一个数组,然后把元素拼接,放到新数组。
示例1:
const arr1 = ['1','2','3']; const arr2 = ['a','b','c']; const result = []; arr1.forEach(e1 => { arr2.forEach(e2 => { result.push(e1 + "_" + e2) }) }) console.log(result);
结果:
完美实现。
N个数组的笛卡尔积
如果是N个数组怎么办?
不确定数组数量,代码没有办法写死。该如何处理?
思路:
-
先拿其中两个数组求笛卡尔积
-
然后把前面运算的结果作为新数组,与第三个数组求笛卡尔积
把前两次运算的结果作为第三次运算的参数。大家想到什么了?
没错,之前讲过的一个数组功能:Reduce
reduce函数的声明:
reduce(callback,initvalue)
callback:是一个回调函数。这个callback可以接收2个参数:arg1,arg2
-
arg1代表的上次运算得到的结果
-
arg2是数组中正要处理的元素
initvalue,初始化值。第一次调用callback时把initvalue作为第一个参数,把数组的第一个元素作为第二个参数运算。如果未指定,则第一次运算会把数组的前两个元素作为参数。
reduce会把数组中的元素逐个用这个函数处理,然后把结果作为下一次回调函数的第一个参数,数组下个元素作为第二个参数,以此类推。
因此,我们可以把想要求笛卡尔积的多个数组先放到一个大数组中。形成二维数组。然后再来运算:
示例2:
const arr1 = ['1', '2', '3']; const arr2 = ['a', 'b']; // 用来作为运算的二维数组 const arr3 = [arr1, arr2, ['x', 'y']] const result = arr3.reduce((last, el) => { const arr = []; // last:上次运算结果 // el:数组中的当前元素 last.forEach(e1 => { el.forEach(e2 => { arr.push(e1 + "_" + e2) }) }) return arr }); console.log(result);
结果:
5.8.3.算法结合业务
来看我们的业务逻辑:
首先,我们已经有了一个特有参数的规格模板:
[ { "k": "机身颜色", "selected": ["红色","黑色"] }, { "k": "内存", "selected": ["8GB","6GB"] }, { "k": "机身存储", "selected": ["64GB","256GB"] } ]
可以看做是一个二维数组。
一维是参数对象。
二维是参数中的selected选项。
我们想要的结果:
[ {"机身颜色":"红色","内存":"6GB","机身存储":"64GB"}, {"机身颜色":"红色","内存":"6GB","机身存储":"256GB"}, {"机身颜色":"红色","内存":"8GB","机身存储":"64GB"}, {"机身颜色":"红色","内存":"8GB","机身存储":"256GB"}, {"机身颜色":"黑色","内存":"6GB","机身存储":"64GB"}, {"机身颜色":"黑色","内存":"6GB","机身存储":"256GB"}, {"机身颜色":"黑色","内存":"8GB","机身存储":"64GB"}, {"机身颜色":"黑色","内存":"8GB","机身存储":"256GB"}, ]
思路是这样:
-
我们的启点是一个空的对象数组:
[{}]
, -
然后先与第一个规格求笛卡尔积
-
然后再把结果与下一个规格求笛卡尔积,依次类推
如果:
代码:
我们在Vue中新增一个计算属性,按照上面所讲的逻辑,计算所有规格参数的笛卡尔积
computed: { skus() { // 过滤掉用户没有填写数据的规格参数 const arr = this.specialSpecs.filter(s => s.selected.length > 0); // 通过reduce进行累加笛卡尔积 return arr.reduce((last, spec) => { const result = []; last.forEach(o => { spec.selected.forEach(option => { const obj = {}; Object.assign(obj, o); obj[spec.k] = option; result.push(obj); }) }) return result }, [{}]) } }
结果:
优化:这里生成的是SKU的数组。因此只包含SKU的规格参数是不够的。结合数据库知道,还需要有下面的字段:
-
price:价格
-
stock:库存
-
enable:是否启用。虽然笛卡尔积对应了9个SKU,但用户不一定会需要所有的组合,用这个字段进行标记。
-
images:商品的图片
-
indexes:特有属性的索引拼接得到的字符串
我们需要给生成的每个sku对象添加上述字段,代码修改如下:
computed:{ skus(){ // 过滤掉用户没有填写数据的规格参数 const arr = this.specialSpecs.filter(s => s.selected.length > 0); // 通过reduce进行累加笛卡尔积 return arr.reduce((last, spec, index) => { const result = []; last.forEach(o => { for(let i = 0; i < spec.selected.length; i++){ const option = spec.selected[i]; const obj = {}; Object.assign(obj, o); obj[spec.k] = option; // 拼接当前这个特有属性的索引 obj.indexes = (o.indexes||'') + '_'+ i if(index === arr.length - 1){ // 如果发现是最后一组,则添加价格、库存等字段 Object.assign(obj, { price:0, stock:0,enable:false, images:[]}) // 去掉索引字符串开头的下划线 obj.indexes = obj.indexes.substring(1); } result.push(obj); } }) return result },[{}]) } }
查看生成的数据:
5.8.4.页面展现
页面展现是一个表格。我们之前已经用过。表格需要以下信息:
-
items:表格内的数据
-
headers:表头信息
刚才我们的计算属性skus得到的就是表格数据了。我们还差头:headers
头部信息也是动态的,用户选择了一个属性,就会多出一个表头。与skus是关联的。
既然如此,我们再次编写一个计算属性,来计算得出header数组:
headers(){ if(this.skus.length <= 0){ return [] } const headers = []; // 获取skus中的任意一个,获取key,然后遍历其属性 Object.keys(this.skus[0]).forEach(k => { let value = k; if(k === 'price'){ // enable,表头要翻译成“价格” k = '价格' }else if(k === 'stock'){ // enable,表头要翻译成“库存” k = '库存'; }else if(k === 'enable'){ // enable,表头要翻译成“是否启用” k = '是否启用' } else if(k === 'indexes' || k === 'images'){ // 图片和索引不在表格中展示 return; } headers.push({ text: k, align: 'center', sortable: false, value }) }) return headers; }
接下来编写页面,实现table。
需要注意的是,price、stock字段需要用户填写数值,不能直接展示。enable要展示为checkbox,让用户选择,如图:
代码:
<v-card> <!--标题--> <v-card-title class="subheading">SKU列表</v-card-title> <!--SKU表格,hide-actions因此分页等工具条--> <v-data-table :items="skus" :headers="headers" hide-actions item-key="indexes"> <template slot="items" slot-scope="props"> <!--价格和库存展示为文本框--> <td v-for="(v,k) in props.item" :key="k" v-if="['price', 'stock'].includes(k)" class="text-xs-center"> <v-text-field single-line v-model.number="props.item[k]"/> </td> <!--enable展示为checkbox--> <td class="text-xs-center" v-else-if="k === 'enable'"> <v-checkbox v-model="props.item[k]"/> </td> <!--indexes和images不展示,其它展示为普通文本--> <td class="text-xs-center" v-else-if="!['indexes','images'].includes(k)">{{v}}</td> </template> </v-data-table> </v-card>
效果:
5.8.5.图片上传列表
这个表格中只展示了基本信息,当用户需要上传图片时,该怎么做呢?
Vuetify的table有一个展开功能,可以提供额外的展示空间:
用法也非常简单,添加一个template,把其slot属性指定为expand即可:
效果:
接下来就是我们的图片上传组件:v-upload
5.9.表单提交
5.9.1.添加提交按钮
我们在step=4,也就是SKU属性列表页面, 添加一个提交按钮。
<!--提交按钮--> <v-flex xs3 offset-xs9> <v-btn color="info">保存商品信息</v-btn> </v-flex>
效果:
5.9.2点击事件
当用户点击保存,我们就需要对页面的数据进行整理,然后提交到后台服务。
现在我们页面包含了哪些信息呢?我们与数据库对比,看看少什么
-
goods:里面包含了SPU的几乎所有信息
-
title:标题
-
subtitle:子标题,卖点
-
categories:分类对象数组,需要进行整理 **
-
brandId:品牌id
-
spuDetail:商品详情
-
packingList:包装清单
-
afterService:售后服务
-
description:商品描述
-
缺少全局规格属性specifications **
-
缺少特有规格属性模板spec_template **
-
-
-
skus:包含了sku列表的几乎所有信息
-
price:价格,需要处理为以分为单位
-
stock:库存
-
enable:是否启用
-
indexes:索引
-
images:图片,数组,需要处理为字符串**
-
缺少其它特有规格,ows_spec **
-
缺少标题:需要根据spu的标题结合特有属性生成 **
-
-
specifications:全局规格参数的键值对信息
-
specialSpec:特有规格参数信息
在页面绑定点击事件:
<!--提交按钮--> <v-flex xs3 offset-xs9> <v-btn color="info" @click="submit">保存商品信息</v-btn> </v-flex>
编写代码,整理数据:
submit(){ // 表单校验。 略 // 先处理goods,用结构表达式接收,除了categories外,都接收到goodsParams中 const {categories: [{id:cid1},{id:cid2},{id:cid3}], ...goodsParams} = this.goods; // 处理规格参数 const specs = this.specifications.map(({group,params}) => { const newParams = params.map(({options,...rest}) => { return rest; }) return {group,params:newParams}; }); // 处理特有规格参数模板 const specTemplate = {}; this.specialSpecs.forEach(({k, selected}) => { specTemplate[k] = selected; }); // 处理sku const skus = this.skus.filter(s => s.enable).map(({price,stock,enable,images,indexes, ...rest}) => { // 标题,在spu的title基础上,拼接特有规格属性值 const title = goodsParams.title + " " + Object.values(rest).join(" "); return { price: this.$format(price+""),stock,enable,indexes,title,// 基本属性 images: !images ? '' : images.join(","), // 图片 ownSpec: JSON.stringify(rest), // 特有规格参数 } }); Object.assign(goodsParams, { cid1,cid2,cid3, // 商品分类 skus, // sku列表 }) goodsParams.spuDetail.specifications= JSON.stringify(specs); goodsParams.spuDetail.specTemplate = JSON.stringify(specTemplate); console.log(goodsParams) }
点击测试,看效果:
向后台发起请求,因为请求体复杂,我们直接发起Json请求:
this.$http.post("/item/goods",goodsParams) .then(() => { // 成功,关闭窗口 this.$emit('close'); // 提示成功 this.$message.success("新增成功了") }) .catch(() => { this.$message.error("保存失败!"); }); })
5.9.3.后台编写接口
实体类
Spu
@Table(name = "tb_spu") public class Spu { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long brandId; private Long cid1;// 1级类目 private Long cid2;// 2级类目 private Long cid3;// 3级类目 private String title;// 标题 private String subTitle;// 子标题 private Boolean saleable;// 是否上架 private Boolean valid;// 是否有效,逻辑删除用 private Date createTime;// 创建时间 private Date lastUpdateTime;// 最后修改时间 }
SpuDetail
@Table(name="tb_spu_detail") public class SpuDetail { @Id private Long spuId;// 对应的SPU的id private String description;// 商品描述 private String specTemplate;// 商品特殊规格的名称及可选值模板 private String specifications;// 商品的全局规格属性 private String packingList;// 包装清单 private String afterService;// 售后服务 }
Sku
@Table(name = "tb_sku") public class Sku { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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;// 最后修改时间 @Transient private Long stock;// 库存 }
注意:这里保存了一个库存字段,在数据库中是另外一张表保存的,方便查询。
Stock
@Table(name = "tb_stock") public class Stock { @Id private Long skuId; private Integer seckillStock;// 秒杀可用库存 private Integer seckillTotal;// 已秒杀数量 private Integer stock;// 正常库存 }
Controller
四个问题:
-
请求方式:POST
-
请求路径:/goods
-
请求参数:Spu的json格式的对象,spu中包含spuDetail和Sku集合。这里我们该怎么接收?我们之前定义了一个SpuBo对象,作为业务对象。这里也可以用它,不过需要再扩展spuDetail和skus字段:
public class SpuBo extends Spu { @Transient String cname;// 商品分类名称 @Transient String bname;// 品牌名称 @Transient SpuDetail spuDetail;// 商品详情 @Transient List<Sku> skus;// sku列表 }
-
返回类型:无
代码:
/** * 新增商品 * @param spu * @return */ @PostMapping public ResponseEntity<Void> saveGoods(@RequestBody Spu spu) { try { this.goodsService.save(spu); return new ResponseEntity<>(HttpStatus.CREATED); } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } }
注意:通过@RequestBody注解来接收Json请求
Service
这里的逻辑比较复杂,我们除了要对SPU新增以外,还要对SpuDetail、Sku、Stock进行保存
@Transactional public void save(SpuBo spu) { // 保存spu spu.setSaleable(true); spu.setValid(true); spu.setCreateTime(new Date()); spu.setLastUpdateTime(spu.getCreateTime()); this.spuMapper.insert(spu); // 保存spu详情 spu.getSpuDetail().setSpuId(spu.getId()); this.spuDetailMapper.insert(spu.getSpuDetail()); // 保存sku和库存信息 saveSkuAndStock(spu.getSkus(), spu.getId()); } private void saveSkuAndStock(List<Sku> skus, Long spuId) { for (Sku sku : skus) { if (!sku.getEnable()) { continue; } // 保存sku sku.setSpuId(spuId); // 默认不参与任何促销 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); } }
更新数据
Controller
/** * 新增商品 * @param spuBo * @return */ @PostMapping("goods") public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo){ this.goodsService.saveGoods(spuBo); return ResponseEntity.status(HttpStatus.CREATED).build(); }
Service
修改spu里的sku(增删改查)后,我们应该怎么更新呢
把对于spiid的sku全部删掉,然后重新添加
顺序:先执行删除再执行更新(删除是从小到大删,更新是从大到小删)
/**
* 更新商品
* @param spuBo
* @return
*/
public void upDateGoods(SpuBo spuBo) {
//1.先删除stock和sku
Sku record = new Sku();
record.setSpuId(spuBo.getId());
List<Sku> skus = this.skuMapper.select(record);
skus.forEach(sku -> {
this.stockMapper.deleteByPrimaryKey(sku.getId())
});
skuMapper.delete(record);
//2.再新增sku和stock
this.saveSkuAndStock(spuBo);
//3.更新spu和spuDetail
spuBo.setSaleable(null);//因为是否上架和下架是在另外页面操作,所以这里设置为null
spuBo.setValid(null);
spuBo.setCreateTime(null);
spuBo.setLastUpdateTime(new Date());
this.spuMapper.updateByPrimaryKeySelective(spuBo);
this.spuDetailMapper.updateByPrimaryKeySelective(spuBo.getSpuDetail());
}
private void saveSkuAndStock(@RequestBody 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.insertSelective(sku); //4.新增stock Stock stock = new Stock(); stock.setSkuId(sku.getId()); stock.setStock(sku.getStock()); this.stockMapper.insertSelective(stock); }); }
第十天:商品管理
0.学习目标
-
独立实现商品新增后台
-
独立实现商品编辑后台
-
独立搭建前台系统页面
1.商品新增
当我们点击新增商品按钮:
就会出现一个弹窗:
里面把商品的数据分为了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
:
1.1.弹窗事件
弹窗是一个独立组件:
并且在Goods组件中已经引用它:
并且在页面中渲染:
在新增商品
按钮的点击事件中,改变这个dialog
的show
属性:
1.2.基本数据
我们先来看下基本数据:
1.2.1.商品分类
商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:
刷新页面,可以看到请求已经发出:
效果:
1.2.2.品牌选择
1.2.2.1页面
品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。
所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:
选择商品分类后,可以看到请求发起:
接下来,我们只要编写后台接口,根据商品分类id,查询对应品牌即可。
1.2.2.2后台接口
页面需要去后台查询品牌信息,我们自然需要提供:
请求方式:GET
请求路径:/brand/cid/{cid}
请求参数:cid
响应数据:品牌集合
BrandController
/** * 根据分类查询品牌 * @param cid * @return */ @GetMapping("cid/{cid}") public ResponseEntity<List<Brand>> queryBrandListByCid(@PathVariable("cid")Long cid){ List<Brand> brandList = this.brandService.queryByCid(cid); if(CollectionUtils.isEmpty(brandList)){ // 响应404 return ResponseEntity.badRequest().build(); } // 响应200 return ResponseEntity.ok(brandList); }
BrandService
public List<Brand> queryBrandByCategory(Long cid) { return this.brandMapper.queryByCategoryId(cid); }
BrandMapper
根据分类查询品牌有中间表,需要自己编写Sql:
@Select("SELECT b.* FROM tb_brand b LEFT JOIN tb_category_brand cb ON b.id = cb.brand_id WHERE cb.category_id = #{cid}") List<Brand> queryByCategoryId(Long cid);
效果:
1.2.3.其它文本框
剩余的几个属性:标题、子标题等都是普通文本框,我们直接填写即可,没有需要特别注意的。
1.3.商品描述
商品描述信息比较复杂,而且图文并茂,甚至包括视频。
这样的内容,一般都会使用富文本编辑器。
1.3.1.什么是富文本编辑器
百度百科:
通俗来说:富文本,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。
富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue
但是我们今天要说的,是一款支持Vue的富文本编辑器:vue-quill-editor
1.3.2.Vue-Quill-Editor
GitHub的主页:GitHub - surmon-china/vue-quill-editor: @quilljs editor component for @vuejs(2)
Vue-Quill-Editor是一个基于Quill的富文本编辑器:Quill的官网
1.3.3.使用指南
使用非常简单:
第一步:安装,使用npm命令:
npm install vue-quill-editor --save
第二步:加载,在js中引入:
全局引入:
import Vue from 'vue' import VueQuillEditor from 'vue-quill-editor' const options = {}; /* { default global options } */ Vue.use(VueQuillEditor, options); // options可选
局部引入:
import 'quill/dist/quill.core.css' import 'quill/dist/quill.snow.css' import 'quill/dist/quill.bubble.css' import {quillEditor} from 'vue-quill-editor' var vm = new Vue({ components:{ quillEditor } })
我们这里采用局部引用:
第三步:页面使用:
<quill-editor v-model="goods.spuDetail.description" :options="editorOption"/>
1.3.4.自定义的富文本编辑器
不过这个组件有个小问题,就是图片上传的无法直接上传到后台,因此我们对其进行了封装,支持了图片的上传。
使用也非常简单:
<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
1.3.5.效果
1.4.商品规格参数
规格参数的查询我们之前也已经编写过接口,因为商品规格参数也是与商品分类绑定,所以需要在商品分类变化后去查询,我们也是通过watch监控来实现:
可以看到这里是根据商品分类id查询规格参数:SpecParam。我们之前写过一个根据gid(分组id)来查询规格参数的接口,我们接下来完成根据分类id查询规格参数。
改造查询规格参数接口
我们在原来的根据 gid(规格组id)查询规格参数的接口上,添加一个参数:cid,即商品分类id。
等一下, 考虑到以后可能还会根据是否搜索、是否为通用属性等条件过滤,我们多添加几个过滤条件:
@GetMapping("/params") public ResponseEntity<List<SpecParam>> querySpecParam( @RequestParam(value="gid", required = false) Long gid, @RequestParam(value="cid", required = false) Long cid, @RequestParam(value="searching", required = false) Boolean searching, @RequestParam(value="generic", required = false) Boolean generic ){ List<SpecParam> list = this.specificationService.querySpecParams(gid,cid,searching,generic); if(list == null || list.size() == 0){ return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list); }
改造service:
public List<SpecParam> querySpecParams(Long gid, Long cid, Boolean searching, Boolean generic) { SpecParam param = new SpecParam(); param.setGroupId(gid); param.setCid(cid); param.setSearching(searching); param.setGeneric(generic); return this.specParamMapper.select(param); }
如果param中有属性为null,则不会吧属性作为查询条件,因此该方法具备通用性,即可根据gid查询,也可根据cid查询。
测试:
刷新页面测试:
1.5.SKU信息
Sku属性是SPU下的每个商品的不同特征,如图:
当我们填写一些属性后,会在页面下方生成一个sku表格,大家可以计算下会生成多少个不同属性的Sku呢?
当你选择了上图中的这些选项时:
-
颜色共2种:迷夜黑,勃艮第红,绚丽蓝
-
内存共2种:4GB,6GB
-
机身存储1种:64GB,128GB
此时会产生多少种SKU呢? 应该是 3 * 2 * 2 = 12种,这其实就是在求笛卡尔积。
我们会在页面下方生成一个sku的表格:
1.6.页面表单提交
在sku列表的下方,有一个提交按钮:
并且绑定了点击事件:
点击后会组织数据并向后台提交:
submit() { // 表单校验。 if(!this.$refs.basic.validate){ this.$message.error("请先完成表单内容!"); } // 先处理goods,用结构表达式接收,除了categories外,都接收到goodsParams中 const { categories: [{ id: cid1 }, { id: cid2 }, { id: cid3 }], ...goodsParams } = this.goods; // 处理规格参数 const specs = {}; this.specs.forEach(({ id,v }) => { specs[id] = v; }); // 处理特有规格参数模板 const specTemplate = {}; this.specialSpecs.forEach(({ id, options }) => { specTemplate[id] = options; }); // 处理sku const skus = this.skus .filter(s => s.enable) .map(({ price, stock, enable, images, indexes, ...rest }) => { // 标题,在spu的title基础上,拼接特有规格属性值 const title = goodsParams.title + " " + Object.values(rest).map(v => v.v).join(" "); const obj = {}; Object.values(rest).forEach(v => { obj[v.id] = v.v; }); return { price: this.$format(price), // 价格需要格式化 stock, indexes, enable, title, // 基本属性 images: images ? images.join(",") : '', // 图片 ownSpec: JSON.stringify(obj) // 特有规格参数 }; }); Object.assign(goodsParams, { cid1, cid2, cid3, // 商品分类 skus // sku列表 }); goodsParams.spuDetail.genericSpec = JSON.stringify(specs); goodsParams.spuDetail.specialSpec = JSON.stringify(specTemplate); // 提交到后台 this.$http({ method: this.isEdit ? "put" : "post", url: "/item/goods", data: goodsParams }) .then(() => { // 成功,关闭窗口 this.$emit("close"); // 提示成功 this.$message.success("保存成功了"); }) .catch(() => { this.$message.error("保存失败!"); }); }
点击提交,查看控制台提交的数据格式:
整体是一个json格式数据,包含Spu表所有数据:
-
brandId:品牌id
-
cid1、cid2、cid3:商品分类id
-
subTitle:副标题
-
title:标题
-
spuDetail:是一个json对象,代表商品详情表数据
-
afterService:售后服务
-
description:商品描述
-
packingList:包装列表
-
specialSpec:sku规格属性模板
-
genericSpec:通用规格参数
-
-
skus:spu下的所有sku数组,元素是每个sku对象:
-
title:标题
-
images:图片
-
price:价格
-
stock:库存
-
ownSpec:特有规格参数
-
indexes:特有规格参数的下标
-
1.7.后台实现
1.7.1.实体类
SPU和SpuDetail实体类已经添加过,添加Sku和Stock对象:
Sku
@Table(name = "tb_sku") public class Sku { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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;// 最后修改时间 @Transient private Integer stock;// 库存 }
注意:这里保存了一个库存字段,在数据库中是另外一张表保存的,方便查询。
Stock
@Table(name = "tb_stock") public class Stock { @Id private Long skuId; private Integer seckillStock;// 秒杀可用库存 private Integer seckillTotal;// 已秒杀数量 private Integer stock;// 正常库存 }
1.7.2.GoodsController
请求方式:POST
请求路径:/goods
请求参数:Spu的json格式的对象,spu中包含spuDetail和Sku集合。这里我们该怎么接收?我们之前定义了一个SpuBo对象,作为业务对象。这里也可以用它,不过需要再扩展spuDetail和skus字段:
public class SpuBo extends Spu { @Transient String cname;// 商品分类名称 @Transient String bname;// 品牌名称 @Transient SpuDetail spuDetail;// 商品详情 @Transient List<Sku> skus;// sku列表 }
-
返回类型:无
代码:
/** * 新增商品 * @param spu * @return */ @PostMapping public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo) { try { this.goodsService.save(spu); return new ResponseEntity<>(HttpStatus.CREATED); } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } }
注意:通过@RequestBody注解来接收Json请求
1.7.3.GoodsService
这里的逻辑比较复杂,我们除了要对SPU新增以外,还要对SpuDetail、Sku、Stock进行保存
@Transactional public void save(SpuBo spu) { // 保存spu spu.setSaleable(true); spu.setValid(true); spu.setCreateTime(new Date()); spu.setLastUpdateTime(spu.getCreateTime()); this.spuMapper.insert(spu); // 保存spu详情 spu.getSpuDetail().setSpuId(spu.getId()); this.spuDetailMapper.insert(spu.getSpuDetail()); // 保存sku和库存信息 saveSkuAndStock(spu.getSkus(), spu.getId()); } private void saveSkuAndStock(List<Sku> skus, Long spuId) { for (Sku sku : skus) { if (!sku.getEnable()) { continue; } // 保存sku sku.setSpuId(spuId); // 初始化时间 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); } }
1.7.4.Mapper
都是通用Mapper,略
目录结构:
2.商品修改
2.1.编辑按钮点击事件
在商品详情页,每一个商品后面,都会有一个编辑按钮:
点击这个按钮,就会打开一个商品编辑窗口,我们看下它所绑定的点击事件:
对应的方法:
可以看到这里发起了两个请求,在查询商品详情和sku信息。
因为在商品列表页面,只有spu的基本信息:id、标题、品牌、商品分类等。比较复杂的商品详情(spuDetail)和sku信息都没有,编辑页面要回显数据,就需要查询这些内容。
因此,接下来我们就编写后台接口,提供查询服务接口。
2.2.查询SpuDetail接口
GoodsController
需要分析的内容:
-
请求方式:GET
-
请求路径:/spu/detail/{id}
-
请求参数:id,应该是spu的id
-
返回结果:SpuDetail对象
@GetMapping("/spu/detail/{id}") public ResponseEntity<SpuDetail> querySpuDetailById(@PathVariable("id") Long id) { SpuDetail detail = this.goodsService.querySpuDetailById(id); if (detail == null) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(detail); }
GoodsService
public SpuDetail querySpuDetailById(Long id) { return this.spuDetailMapper.selectByPrimaryKey(id); }
测试
2.3.查询sku
分析
-
请求方式:Get
-
请求路径:/sku/list
-
请求参数:id,应该是spu的id
-
返回结果:sku的集合
GoodsController
@GetMapping("sku/list") public ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id") Long id) { List<Sku> skus = this.goodsService.querySkuBySpuId(id); if (skus == null || skus.size() == 0) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(skus); }
GoodsService
需要注意的是,为了页面回显方便,我们一并把sku的库存stock也查询出来
public List<Sku> querySkuBySpuId(Long spuId) { // 查询sku Sku record = new Sku(); record.setSpuId(spuId); List<Sku> skus = this.skuMapper.select(record); for (Sku sku : skus) { // 同时查询出库存 sku.setStock(this.stockMapper.selectByPrimaryKey(sku.getId()).getStock()); } return skus; }
测试:
2.4.页面回显
随便点击一个编辑按钮,发现数据回显完成:
2.5.页面提交
这里的保存按钮与新增其实是同一个,因此提交的逻辑也是一样的,这里不再赘述。
随便修改点数据,然后点击保存,可以看到浏览器已经发出请求:
2.6.后台实现
接下来,我们编写后台,实现修改商品接口。
2.6.1.Controller
-
请求方式:PUT
-
请求路径:/
-
请求参数:Spu对象
-
返回结果:无
/** * 新增商品 * @param spu * @return */ @PutMapping public ResponseEntity<Void> updateGoods(@RequestBody SpuBo spu) { try { this.goodsService.update(spu); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } }
2.6.2.Service
spu数据可以修改,但是SKU数据无法修改,因为有可能之前存在的SKU现在已经不存在了,或者以前的sku属性都不存在了。比如以前内存有4G,现在没了。
因此这里直接删除以前的SKU,然后新增即可。
代码:
@Transactional public void update(SpuBo spu) { // 查询以前sku List<Sku> skus = this.querySkuBySpuId(spu.getId()); // 如果以前存在,则删除 if(!CollectionUtils.isEmpty(skus)) { List<Long> ids = skus.stream().map(s -> s.getId()).collect(Collectors.toList()); // 删除以前库存 Example example = new Example(Stock.class); example.createCriteria().andIn("skuId", ids); this.stockMapper.deleteByExample(example); // 删除以前的sku Sku record = new Sku(); record.setSpuId(spu.getId()); this.skuMapper.delete(record); } // 新增sku和库存 saveSkuAndStock(spu.getSkus(), spu.getId()); // 更新spu spu.setLastUpdateTime(new Date()); spu.setCreateTime(null); spu.setValid(null); spu.setSaleable(null); this.spuMapper.updateByPrimaryKeySelective(spu); // 更新spu详情 this.spuDetailMapper.updateByPrimaryKeySelective(spu.getSpuDetail()); }
2.6.3.mapper
与以前一样。
2.7.其它
商品的删除、上下架大家自行实现。
3.搭建前台系统
后台系统的内容暂时告一段落,有了商品,接下来我们就要在页面展示商品,给用户提供浏览和购买的入口,那就是我们的门户系统。
门户系统面向的是用户,安全性很重要,而且搜索引擎对于单页应用并不友好。因此我们的门户系统不再采用与后台系统类似的SPA(单页应用)。
依然是前后端分离,不过前端的页面会使用独立的html,在每个页面中使用vue来做页面渲染。
3.1.静态资源
webpack打包多页应用配置比较繁琐,项目结构也相对复杂。这里为了简化开发(毕竟我们不是专业的前端人员),我们不再使用webpack,而是直接编写原生的静态HTML。
3.1.1.创建工程
创建一个新的工程:
3.1.2.导入静态资源
将课前资料中的leyou-portal解压,并复制到这个项目下
解压缩:
项目结构:
3.2.live-server
没有webpack,我们就无法使用webpack-dev-server运行这个项目,实现热部署。
所以,这里我们使用另外一种热部署方式:live-server,
3.2.1.简介
这是一款带有热加载功能的小型开发服务器。用它来展示你的HTML / JavaScript / CSS,但不能用于部署最终的网站。
3.2.2.安装和运行参数
安装,使用npm命令即可,这里建议全局安装,以后任意位置可用
npm install -g live-server
运行时,直接输入命令:
live-server
另外,你可以在运行命令后,跟上一些参数以配置:
-
--port=NUMBER
- 选择要使用的端口,默认值:PORT env var或8080 -
--host=ADDRESS
- 选择要绑定的主机地址,默认值:IP env var或0.0.0.0(“任意地址”) -
--no-browser
- 禁止自动Web浏览器启动 -
--browser=BROWSER
- 指定使用浏览器而不是系统默认值 -
--quiet | -q
- 禁止记录 -
--verbose | -V
- 更多日志记录(记录所有请求,显示所有侦听的IPv4接口等) -
--open=PATH
- 启动浏览器到PATH而不是服务器root -
--watch=PATH
- 用逗号分隔的路径来专门监视变化(默认值:观看所有内容) -
--ignore=PATH
- 要忽略的逗号分隔的路径字符串(anymatch -compatible definition) -
--ignorePattern=RGXP
-文件的正则表达式忽略(即.*\.jade
)(不推荐使用赞成--ignore
) -
--middleware=PATH
- 导出要添加的中间件功能的.js文件的路径; 可以是没有路径的名称,也可以是引用middleware
文件夹中捆绑的中间件的扩展名 -
--entry-file=PATH
- 提供此文件(服务器根目录)代替丢失的文件(对单页应用程序有用) -
--mount=ROUTE:PATH
- 在定义的路线下提供路径内容(可能有多个定义) -
--spa
- 将请求从/ abc转换为/#/ abc(方便单页应用) -
--wait=MILLISECONDS
- (默认100ms)等待所有更改,然后重新加载 -
--htpasswd=PATH
- 启用期待位于PATH的htpasswd文件的http-auth -
--cors
- 为任何来源启用CORS(反映请求源,支持凭证的请求) -
--https=PATH
- 到HTTPS配置模块的路径 -
--proxy=ROUTE:URL
- 代理ROUTE到URL的所有请求 -
--help | -h
- 显示简洁的使用提示并退出 -
--version | -v
- 显示版本并退出
3.2.3.测试
我们进入leyou-portal目录,输入命令:
live-server --port=9002
3.3.域名访问
现在我们访问只能通过:http://127.0.0.1:9002
我们希望用域名访问:乐友网
第一步,修改hosts文件,添加一行配置:
127.0.0.1 www.leyou.com
第二步,修改nginx配置,将www.leyou.com反向代理到127.0.0.1:9002
#一般域名解析不到会找到第一个虚拟主机,所以门户虚拟主机一般会放在第一位
server { listen 80; server_name www.leyou.com; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; location / { proxy_pass http://127.0.0.1:9002; proxy_connect_timeout 600; proxy_read_timeout 600; } }
重新加载nginx配置:nginx.exe -s reload
3.4.common.js
为了方便后续的开发,我们在前台系统中定义了一些工具,放在了common.js中:
部分代码截图:
首先对axios进行了一些全局配置,请求超时时间,请求的基础路径,是否允许跨域操作cookie等
定义了对象 ly ,也叫leyou,包含了下面的属性:
-
getUrlParam(key):获取url路径中的参数
-
http:axios对象的别名。以后发起ajax请求,可以用ly.http.get()
-
store:localstorage便捷操作,后面用到再详细说明
-
formatPrice:格式化价格,如果传入的是字符串,则扩大100被并转为数字,如果传入是数字,则缩小100倍并转为字符串
为什么要这样操作呢:数据库的价格是以分为单位,为什么要以分为单位呢,防止数据失帧
面试:价格是怎么去防止失帧的
1.以分为单位
2.使用bigdecimal对象,专门用做价格处理,防止失帧的
-
formatDate(val, pattern):对日期对象val按照指定的pattern模板进行格式化
-
stringify:将对象转为参数字符串
-
parse:将参数字符串变为js对象
// 配置对象
const ly = leyou = {
/**
* 对encodeURI()编码过的 URI 进行解码。并且获取其中的指定参数
* @param name
* @returns {*}
*/
getUrlParam(name) {
/**
* 1.定义了一个正则表达式 `reg`,用于匹配 URL 中包含特定参数的部分
* 具体来说,它包含三个部分:
* "(^|&)":表示匹配参数名前的符号,可能是开头(`^`)或者`&`。
* `name`:表示匹配的参数名。
* `=([^&]*)(&|$)`:表示参数值的部分,其中 `[^&]*` 表示除了`&` 的任意字符(0个或多个),`(&|$)` 表示参数值后面可能跟着`&`(还有其他参数)、或者是 URL 的结尾。
*/
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
var r = window.location.search.substr(1).match(reg);
if (r != null) {
return decodeURI(r[2]);
}
return "";
},
/**
* 发起ajax请求工具,底层依然是axios
*/
http: axios,
store: {
set(key, value) {
localStorage.setItem(key, JSON.stringify(value));
},
get(key) {
return JSON.parse(localStorage.getItem(key));
},
del(key) {
return localStorage.removeItem(key);
}
},
/**
* 将整数价格变为小数
* @param val
* @returns {*}
*/
formatPrice(val) {
if(typeof val === 'string'){
if(isNaN(val)){
return null;
}
// 价格转为整数
const index = val.lastIndexOf(".");
let p = "";
if(index < 0){
// 无小数
p = val + "00";
}else if(index === p.length - 2){
// 1位小数
p = val.replace("\.","") + "0";
}else{
// 2位小数
p = val.replace("\.","")
}
return parseInt(p);
}else if(typeof val === 'number'){
if(val == null){
return null;
}
const s = val + '';
if(s.length === 0){
return "0.00";
}
if(s.length === 1){
return "0.0" + val;
}
if(s.length === 2){
return "0." + val;
}
const i = s.indexOf(".");
if(i < 0){
return s.substring(0, s.length - 2) + "." + s.substring(s.length-2)
}
const num = s.substring(0,i) + s.substring(i+1);
if(i === 1){
// 1位整数
return "0.0" + num;
}
if(i === 2){
return "0." + num;
}
if( i > 2){
return num.substring(0,i-2) + "." + num.substring(i-2)
}
}
},
/**
* 将日期格式化为指定格式
* @param val
* @param pattern
* @returns {null}
*/
formatDate(val, pattern) {
if (!val) {
return null;
}
if (!pattern) {
pattern = "yyyy-MM-dd hh:mm:ss"
}
return new Date(val).format(pattern);
},
/**
* 将js对象格式化为字符串参数对
* @param object
* @returns {*}
*/
stringify,
/**
* 将请求参数字符串格式化为js对象
*/
parse,
}
第十一天
0.学习目标
-
独立安装Elasticsearch
-
会使用Rest的API操作索引
-
会使用Rest的API查询数据
-
会使用Rest的API聚合数据
-
掌握Spring Data Elasticsearch使用
1.Elasticsearch介绍和安装
用户访问我们的首页,一般都会直接搜索来寻找自己想要购买的商品。
而商品的数量非常多,而且分类繁杂。如果能正确的显示出用户想要的商品,并进行合理的过滤,尽快促成交易,是搜索系统要研究的核心。
面对这样复杂的搜索业务和数据量,使用传统数据库搜索就显得力不从心,一般我们都会使用全文检索技术,比如之前大家学习过的Solr。
不过今天,我们要讲的是另一个全文检索技术:Elasticsearch。
1.1.简介
1.1.1.Elastic
Elastic官网:欢迎来到 Elastic — Elasticsearch 和 Kibana 的开发者 | Elastic
Elastic有一条完整的产品线及解决方案:Elasticsearch、Kibana、Logstash等,前面说的三个就是大家常说的ELK技术栈。
1.1.2.Elasticsearch
Elasticsearch官网:Elasticsearch:官方分布式搜索和分析引擎 | Elastic
如上所述,Elasticsearch具备以下特点:
-
分布式,无需人工搭建集群(solr就需要人为配置,使用Zookeeper作为注册中心)
-
Restful风格,一切API都遵循Rest原则,容易上手
-
近实时搜索,数据更新在Elasticsearch中几乎是完全同步的。
1.1.3.版本
目前Elasticsearch最新的版本是6.3.1,我们就使用6.3.0
需要虚拟机JDK1.8及以上
1.2.安装和配置
为了模拟真实场景,我们将在linux下安装Elasticsearch。
1.2.1.上传安装包,并解压
我们将安装包上传到:/home/leyou目录
解压缩:
tar -zxvf elasticsearch-6.2.4.tar.gz
我们把目录重命名:
进入,查看目录结构:
1.2.2.新建一个用户leyou
出于安全考虑,elasticsearch默认不允许以root账号运行。
创建用户:
user add leyou
设置密码:
passwd leyou
切换用户:
su - leyou
给leyou用户可以修改的权限
chown leyou:leyou elasticsearch-6.2.4 -R
拥有--leyou用户:leyou这个组-- elasticsearch-6.2.4这个文件---R表示修改里面所有的内容
1.2.3.修改配置
我们进入config目录:cd config
需要修改的配置文件有两个:
-
jvm.options
Elasticsearch基于Lucene的,而Lucene底层是java实现,因此我们需要配置jvm参数。
编辑jvm.options:
vim jvm.options
默认配置如下:
-Xms1g -Xmx1g
内存占用太多了,我们调小一些:
-Xms512m -Xmx512m一个是最大内存,一个是最小可用,一般是设置一样的,没有最小内存,不用空闲还要去做垃圾回收(垃圾回收会导致程序暂定),
1.避免频繁的¥¥回收: 如果最小可用内存比最大内存要小很多,则 JVM 在运行过程中可能会不断地进行¥¥回收,这会消耗大量的 CPU 时间和系统资源,并且可能会导致应用程序响应变慢。尽管 JVM 可以自动增加堆内存的大小,但它并不能保证在出现内存不足时会在正确的时间点增加内存。
2.提高应用程序的性能:应用程序通常需要处理各种各样的事务,并且使用的内存大小也会随时间变化。将最小内存和最大内存设置为相同的值可以确保 JVM 开始执行时具有足够的内存,从而提高应用程序的性能。
-
elasticsearch.yml
vim elasticsearch.yml
-
修改数据和日志目录:
path.data: /home/leyou/elasticsearch/data # 数据目录位置 path.logs: /home/leyou/elasticsearch/logs # 日志目录位置
我们把data和logs目录修改指向了elasticsearch的安装目录。但是这两个目录并不存在,因此我们需要创建出来。
进入elasticsearch的根目录,然后创建:
mkdir data mkdir logs
-
修改绑定的ip:
network.host: 0.0.0.0 # 绑定到0.0.0.0,允许任何ip来访问0.0.0.0表示任何主机都可以访问
默认只允许本机访问,修改为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 |
1.3.运行
进入elasticsearch/bin目录,可以看到下面的执行文件:
然后输入命令:
./elasticsearch
发现报错了,启动失败:
1.3.1.错误1:内核过低
我们使用的是centos6,其linux内核版本为2.6。而Elasticsearch的插件要求至少3.5以上版本。不过没关系,我们禁用这个插件即可。
修改elasticsearch.yml文件,在最下面添加如下配置:
bootstrap.system_call_filter: false
然后重启
1.3.2.错误2:文件权限不足
再次启动,又出错了:
[1]: max file descriptors [4096] for elasticsearch process likely too low, increase to at least [65536]
我们用的是leyou用户,而不是root,所以文件权限不足。
首先用root用户登录。
然后修改配置文件:
vim /etc/security/limits.conf
添加下面的内容:
* soft nofile 65536 * hard nofile 131072 * soft nproc 4096 * hard nproc 4096
1.3.3.错误3:线程数不够
刚才报错中,还有一行:
[1]: max number of threads [1024] for user [leyou] is too low, increase to at least [4096]
这是线程数不够。
继续修改配置:
vim /etc/security/limits.d/90-nproc.conf
修改下面的内容:
* soft nproc 1024
改为:
* soft nproc 4096
1.3.4.错误4:进程虚拟内存
[3]: max virtual memory areas vm.max_map_count [65530] likely too low, increase to at least [262144]
vm.max_map_count:限制一个进程可以拥有的VMA(虚拟内存区域)的数量,继续修改配置文件, :
vim /etc/sysctl.conf
添加下面内容:
vm.max_map_count=655360
然后执行命令:
sysctl -p
1.3.5.重启终端窗口
所有错误修改完毕,一定要重启你的 Xshell终端,否则配置无效。
exit后再重新连接
1.3.6.启动
再次启动,终于成功了!
可以看到绑定了两个端口:
-
9300:集群节点间通讯接口
-
9200:客户端访问接口
我们在浏览器中访问:http://192.168.56.101:9200
1.4.安装kibana
1.4.1.什么是Kibana?
Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。
而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示,非常有利于我们学习Elasticsearch的语法。
1.4.2.安装
因为Kibana依赖于node,我们的虚拟机没有安装node,而window中安装过。所以我们选择在window下使用kibana。
最新版本与elasticsearch保持一致,也是6.3.0(版本一致)
解压到特定目录即可
1.4.3.配置运行
配置
进入安装目录下的config目录,修改kibana.yml文件:
修改elasticsearch服务器的地址:
elasticsearch.url: "http://192.168.56.101:9200"
而如果设置store为true,就会在_source
以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。
运行
进入安装目录下的bin目录:
双击运行:
发现kibana的监听端口是5601
1.4.4.控制台
选择左侧的DevTools菜单,即可进入控制台页面:
在页面右侧,我们就可以输入请求,访问Elasticsearch了。
1.5.安装ik分词器
Lucene的IK分词器早在2012年已经没有维护了,现在我们要使用的是在其基础上维护升级的版本,并且开发为ElasticSearch的集成插件了,与Elasticsearch一起维护升级,版本也保持一致,最新版本:6.3.0
1.5.1.安装
上传课前资料中的zip包,解压到Elasticsearch目录的plugins目录中:
使用unzip命令解压:
unzip elasticsearch-analysis-ik-6.3.0.zip -d ik-analyzer
然后重启elasticsearch:
1.5.2.测试
大家先不管语法,我们先测试一波。
在kibana控制台输入下面的请求:
POST _analyze
{
"analyzer": "ik_max_word",
"text": "我是中国人"
}
运行得到结果:
{
"tokens": [
{
"token": "我",
"start_offset": 0,
"end_offset": 1,
"type": "CN_CHAR",
"position": 0
},
{
"token": "是",
"start_offset": 1,
"end_offset": 2,
"type": "CN_CHAR",
"position": 1
},
{
"token": "中国人",
"start_offset": 2,
"end_offset": 5,
"type": "CN_WORD",
"position": 2
},
{
"token": "中国",
"start_offset": 2,
"end_offset": 4,
"type": "CN_WORD",
"position": 3
},
{
"token": "国人",
"start_offset": 3,
"end_offset": 5,
"type": "CN_WORD",
"position": 4
}
]
}
1.7.API
Elasticsearch提供了Rest风格的API,即http请求接口,而且也提供了各种语言的客户端API
1.7.1.Rest风格API(就是http请求可以访问,用postman也可以测试)
文档地址:Elasticsearch Guide [8.7] | Elastic
1.7.2.客户端API
Elasticsearch支持的客户端非常多:Elasticsearch Clients | Elastic
点击Java Rest Client后,你会发现又有两个:
Low Level Rest Client是低级别封装,提供一些基础功能,但更灵活
High Level Rest Client,是在Low Level Rest Client基础上进行的高级别封装,功能更丰富和完善,而且API会变的简单
1.7.3.如何学习
建议先学习Rest风格API,了解发起请求的底层实现,请求体格式等。
2.操作索引
2.1.基本概念
Elasticsearch也是基于Lucene的全文检索库,本质也是存储数据,很多概念与MySQL类似的。
对比关系:
索引(indices)--------------------------------Databases 数据库
类型(type)-----------------------------Table 数据表
文档(Document)----------------Row 行
字段(Field)-------------------Columns 列
详细说明:
概念 | 说明 |
---|---|
索引库(indices) | indices是index的复数,代表许多的索引, |
类型(type) | 类型是模拟mysql中的table概念,一个索引库下可以有不同类型的索引,比如商品索引,订单索引,其数据格式不同。不过这会导致索引库混乱,因此未来版本中会移除这个概念 |
文档(document) | 存入索引库原始的数据。比如每一条商品信息,就是一个文档 |
字段(field) | 文档中的属性 |
映射配置(mappings) | 字段的数据类型、属性、是否索引、是否存储等特性 |
是不是与Lucene和solr中的概念类似。
另外,在SolrCloud中,有一些集群相关的概念,在Elasticsearch也有类似的:
-
索引集(Indices,index的复数):逻辑上的完整索引
-
分片(shard):数据拆分后的各个部分
-
副本(replica):每个分片的复制
要注意的是:Elasticsearch本身就是分布式的,因此即便你只有一个节点,Elasticsearch默认也会对你的数据进行分片和副本操作,当你向集群添加新数据时,数据也会在新加入的节点中进行平衡。
2.2.创建索引
2.2.1.语法
Elasticsearch采用Rest风格API,因此其API就是一次http请求,你可以用任何工具发起http请求
创建索引的请求格式:
-
请求方式:PUT
-
请求路径:/索引库名
-
请求参数:json格式:
{ "settings": { "number_of_shards": 3, "number_of_replicas": 2 } }
-
settings:索引库的设置
-
number_of_shards:分片数量
-
number_of_replicas:副本数量
-
-
2.2.2.测试
我们先用RestClient来试试
响应:
可以看到索引创建成功了。
2.2.3.使用kibana创建
kibana的控制台,可以对http请求进行简化,示例:
相当于是省去了elasticsearch的服务器地址
而且还有语法提示,非常舒服。
2.3.查看索引设置
语法
Get请求可以帮我们查看索引信息,格式:
GET /索引库名
或者,我们可以使用*来查询所有索引库配置:
2.4.删除索引
删除索引使用DELETE请求
语法
DELETE /索引库名
示例
再次查看heima2:
当然,我们也可以用HEAD请求,查看索引是否存在:
2.5.映射配置
索引有了,接下来肯定是添加数据。但是,在添加数据之前必须定义映射。
什么是映射?
映射是定义文档的过程,文档包含哪些字段,这些字段是否保存,是否索引,是否分词等
只有配置清楚,Elasticsearch才会帮我们进行索引库的创建(不一定)
2.5.1.创建映射字段
语法
请求方式依然是PUT
PUT /索引库名/_mapping/类型名称 { "properties": { "字段名": { "type": "类型", "index": true, "store": true, "analyzer": "分词器" } } }
-
类型名称:就是前面将的type的概念,类似于数据库中的不同表 字段名:任意填写 ,可以指定许多属性,例如:
-
type:类型,可以是text、long、short、date、integer、object等
-
index:是否索引,默认为true
-
store:是否存储,默认为false
-
analyzer:分词器,这里的
ik_max_word
即使用ik分词器
示例
发起请求:
PUT heima/_mapping/goods
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word"
},
"images": {
"type": "keyword",(图片表示不需要分词)
"index": "false"
},
"price": {
"type": "float"
}
}
}
响应结果:
{ "acknowledged": true }
2.5.2.查看映射关系
语法:
GET /索引库名/_mapping
示例:
GET /heima/_mapping
响应:
{ "heima": { "mappings": { "goods": { "properties": { "images": { "type": "keyword", "index": false }, "price": { "type": "float" }, "title": { "type": "text", "analyzer": "ik_max_word" } } } } } }
2.5.3.字段属性详解
2.5.3.1.type
Elasticsearch中支持的数据类型非常丰富:
我们说几个关键的:
-
String类型,又分两种:
-
text:可分词,不可参与聚合
-
keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
-
-
Numerical:数值类型,分两类
-
基本数据类型:long、interger、short、byte、double、float、half_float
-
浮点数的高精度类型:scaled_float
-
需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
-
-
-
Date:日期类型
elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间。
2.5.3.2.index
index影响字段的索引情况。
-
true:字段会被索引,则可以用来进行搜索。默认值就是true
-
false:字段不会被索引,不能用来搜索
index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。
但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。
2.5.3.3.store
是否将数据进行额外存储。
在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。
但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。
原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source
的属性中。而且我们可以通过过滤_source
来选择哪些要显示,哪些不显示。
而如果设置store为true,就会在_source
以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。
2.5.3.4.boost
激励因子,这个与lucene中一样
其它的不再一一讲解,用的不多,大家参考官方文档:
2.6.新增数据
2.6.1.随机生成id
通过POST请求,可以向一个已经存在的索引库中添加数据。
语法:
POST /索引库名/类型名 { "key":"value" }
示例:
POST /heima/goods/ { "title":"小米手机", "images":"http://image.leyou.com/12479122.jpg", "price":2699.00 }
响应:
{ "_index": "heima", "_type": "goods", "_id": "r9c1KGMBIhaxtY5rlRKv", "_version": 1, "result": "created", "_shards": { "total": 3, "successful": 1, "failed": 0 }, "_seq_no": 0, "_primary_term": 2 }
通过kibana查看数据:
get _search { "query":{ "match_all":{} } }
{ "_index": "heima", "_type": "goods", "_id": "r9c1KGMBIhaxtY5rlRKv", "_version": 1, "_score": 1, "_source": { "title": "小米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2699 } }
-
_source
:源文档信息,所有的数据都在里面。 -
_id
:这条文档的唯一标示,与文档自己的id字段没有关联
2.6.2.自定义id
如果我们想要自己新增的时候指定id,可以这么做:
POST /索引库名/类型/id值 { ... }
示例:
POST /heima/goods/2 { "title":"大米手机", "images":"http://image.leyou.com/12479122.jpg", "price":2899.00 }
得到的数据:
{ "_index": "heima", "_type": "goods", "_id": "2", "_score": 1, "_source": { "title": "大米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2899 } }
2.6.3.智能判断
在学习Solr时我们发现,我们在新增数据时,只能使用提前配置好映射属性的字段,否则就会报错。
不过在Elasticsearch中并没有这样的规定。
事实上Elasticsearch非常智能,你不需要给索引库设置任何mapping映射,它也可以根据你输入的数据来判断类型,动态添加数据映射。
测试一下:
POST /heima/goods/3 { "title":"超米手机", "images":"http://image.leyou.com/12479122.jpg", "price":2899.00, "stock": 200, "saleable":true }
我们额外添加了stock库存,和saleable是否上架两个字段。
来看结果:
{ "_index": "heima", "_type": "goods", "_id": "3", "_version": 1, "_score": 1, "_source": { "title": "超米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2899, "stock": 200, "saleable": true } }
在看下索引库的映射关系:
{ "heima": { "mappings": { "goods": { "properties": { "images": { "type": "keyword", "index": false }, "price": { "type": "float" }, "saleable": { "type": "boolean" }, "stock": { "type": "long" }, "title": { "type": "text", "analyzer": "ik_max_word" } } } } } }
stock和saleable都被成功映射了。
2.7.修改数据
把刚才新增的请求方式改为PUT,就是修改了。不过修改必须指定id,
-
id对应文档存在,则修改
-
id对应文档不存在,则新增
-
put既能新增又能修改,感觉可以替代post
比如,我们把id为3的数据进行修改:
PUT /heima/goods/3 { "title":"超大米手机", "images":"http://image.leyou.com/12479122.jpg", "price":3899.00, "stock": 100, "saleable":true }
结果:
{ "took": 17, "timed_out": false, "_shards": { "total": 9, "successful": 9, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 1, "hits": [ { "_index": "heima", "_type": "goods", "_id": "3", "_score": 1, "_source": { "title": "超大米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 3899, "stock": 100, "saleable": true } } ] } }
2.8.删除数据
删除使用DELETE请求,同样,需要根据id进行删除:
语法
DELETE /索引库名/类型名/id值
示例:
3.查询
我们从4块来讲查询:
-
基本查询
-
_source
过滤 -
结果过滤
-
高级查询
-
排序
3.1.基本查询:
基本语法
GET /索引库名/_search { "query":{ "查询类型":{ "查询条件":"查询条件值" } } }
这里的query代表一个查询对象,里面可以有不同的查询属性
-
查询类型:
-
例如:
match_all
,match
,term
,range
等等
-
-
查询条件:查询条件会根据类型的不同,写法也有差异,后面详细讲解
match是用来做全文检索查询
3.1.1 查询所有(match_all)
示例:
GET /heima/_search { "query":{ "match_all": {} } }
-
query
:代表查询对象 -
match_all
:代表查询所有
结果:
{ "took": 2, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 1, "hits": [ { "_index": "heima", "_type": "goods", "_id": "2", "_score": 1, "_source": { "title": "大米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2899 } }, { "_index": "heima", "_type": "goods", "_id": "r9c1KGMBIhaxtY5rlRKv", "_score": 1, "_source": { "title": "小米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2699 } } ] } }
-
took:查询花费时间,单位是毫秒
-
time_out:是否超时
-
_shards:分片信息
-
hits:搜索结果总览对象
-
total:搜索到的总条数
-
max_score:所有结果中文档得分的最高分
-
hits:搜索结果的文档对象数组,每个元素是一条搜索到的文档信息
-
_index:索引库
-
_type:文档类型
-
_id:文档id
-
_score:文档得分
-
_source:文档的源数据
-
-
3.1.2 匹配查询(match)
我们先加入一条数据,便于测试:
PUT /heima/goods/3 { "title":"小米电视4A", "images":"http://image.leyou.com/12479122.jpg", "price":3899.00 }
现在,索引库中有2部手机,1台电视:
-
or关系
match
类型查询,会把查询条件进行分词,然后进行查询,多个词条之间是or的关系
GET /heima/_search { "query":{ "match":{ "title":"小米电视" } } }
结果:
"hits": { "total": 2, "max_score": 0.6931472, "hits": [ { "_index": "heima", "_type": "goods", "_id": "tmUBomQB_mwm6wH_EC1-", "_score": 0.6931472, "_source": { "title": "小米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2699 } }, { "_index": "heima", "_type": "goods", "_id": "3", "_score": 0.5753642, "_source": { "title": "小米电视4A", "images": "http://image.leyou.com/12479122.jpg", "price": 3899 } } ] }
在上面的案例中,不仅会查询到电视,而且与小米相关的都会查询到,多个词之间是or
的关系。
-
and关系
某些情况下,我们需要更精确查找,我们希望这个关系变成and
,可以这样做:
GET /heima/_search { "query":{ "match": { "title": { "query": "小米电视", "operator": "and" } } } }
结果:
{ "took": 2, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 0.5753642, "hits": [ { "_index": "heima", "_type": "goods", "_id": "3", "_score": 0.5753642, "_source": { "title": "小米电视4A", "images": "http://image.leyou.com/12479122.jpg", "price": 3899 } } ] } }
本例中,只有同时包含小米
和电视
的词条才会被搜索到。
-
or和and之间?
在 or
与 and
间二选一有点过于非黑即白。 如果用户给定的条件分词后有 5 个查询词项,想查找只包含其中 4 个词的文档,该如何处理?将 operator 操作符参数设置成 and
只会将此文档排除。
有时候这正是我们期望的,但在全文搜索的大多数应用场景下,我们既想包含那些可能相关的文档,同时又排除那些不太相关的。换句话说,我们想要处于中间某种结果。
match
查询支持 minimum_should_match
最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数
,因为我们无法控制用户搜索时输入的单词数量:
GET /heima/_search { "query":{ "match":{ "title":{ "query":"小米曲面电视", "minimum_should_match": "75%" } } } }
本例中,搜索语句可以分为3个词,如果使用and关系,需要同时满足3个词才会被搜索到。这里我们采用最小品牌数:75%,那么也就是说只要匹配到总词条数量的75%即可,这里3*75% 约等于2。所以只要包含2个词条就算满足条件了。
结果:
3.1.3 多字段查询(multi_match)
multi_match
与match
类似,不同的是它可以在多个字段中查询
GET /heima/_search { "query":{ "multi_match": { "query": "小米", "fields": [ "title", "subTitle" ] } } }
本例中,我们会在title字段和subtitle字段中查询小米
这个词
3.1.4 词条匹配(term)
主要是针对不分词的类型
term
查询被用于精确值 匹配,这些精确值可能是数字、时间、布尔或者那些未分词的字符串
GET /heima/_search { "query":{ "term":{ "price":2699.00 } } }
结果:
{ "took": 2, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 1, "hits": [ { "_index": "heima", "_type": "goods", "_id": "r9c1KGMBIhaxtY5rlRKv", "_score": 1, "_source": { "title": "小米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2699 } } ] } }
3.1.5 多词条精确匹配(terms)
terms
查询和 term 查询一样,但它允许你指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么这个文档满足条件:
GET /heima/_search { "query":{ "terms":{ "price":[2699.00,2899.00,3899.00] } } }
结果:
{
"took": 4,
"timed_out": false,
"_shards": {
"total": 3,
"successful": 3,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "heima",
"_type": "goods",
"_id": "2",
"_score": 1,
"_source": {
"title": "大米手机",
"images": "http://image.leyou.com/12479122.jpg",
"price": 2899
}
},
{
"_index": "heima",
"_type": "goods",
"_id": "r9c1KGMBIhaxtY5rlRKv",
"_score": 1,
"_source": {
"title": "小米手机",
"images": "http://image.leyou.com/12479122.jpg",
"price": 2699
}
},
{
"_index": "heima",
"_type": "goods",
"_id": "3",
"_score": 1,
"_source": {
"title": "小米电视4A",
"images": "http://image.leyou.com/12479122.jpg",
"price": 3899
}
}
]
}
}
3.2.结果过滤
默认情况下,elasticsearch在搜索的结果中,会把文档中保存在_source
的所有字段都返回。
如果我们只想获取其中的部分字段,我们可以添加_source
的过滤
3.2.1.直接指定字段
示例:
GET /heima/_search
{
"_source": ["title","price"],
"query": {
"term": {
"price": 2699
}
}
}
返回的结果:
{ "took": 12, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 1, "hits": [ { "_index": "heima", "_type": "goods", "_id": "r9c1KGMBIhaxtY5rlRKv", "_score": 1, "_source": { "price": 2699, "title": "小米手机" } } ] } }
3.2.2.指定includes和excludes
我们也可以通过:
-
includes:来指定想要显示的字段
-
excludes:来指定不想要显示的字段
二者都是可选的。
示例:
GET /heima/_search { "_source": { "includes":["title","price"] }, "query": { "term": { "price": 2699 } } }
与下面的结果将是一样的:
GET /heima/_search { "_source": { "excludes": ["images"] }, "query": { "term": { "price": 2699 } } }
3.3 高级查询
3.3.1 布尔组合(bool)
bool
把各种其它查询通过must
(与)、must_not
(非)、should
(或)的方式进行组合
GET /heima/_search { "query":{ "bool":{ "must": { "match": { "title": "大米" }}, "must_not": { "match": { "title": "电视" }}, "should": { "match": { "title": "手机" }} } } }
结果:
{ "took": 10, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 0.5753642, "hits": [ { "_index": "heima", "_type": "goods", "_id": "2", "_score": 0.5753642, "_source": { "title": "大米手机", "images": "http://image.leyou.com/12479122.jpg", "price": 2899 } } ] } }
3.3.2 范围查询(range)
range
查询找出那些落在指定区间内的数字或者时间
GET /heima/_search { "query":{ "range": { "price": { "gte": 1000.0, "lt": 2800.00 } } } }
range
查询允许以下字符:
操作符 | 说明 |
---|---|
gt | 大于 |
gte | 大于等于 |
lt | 小于 |
lte | 小于等于 |
3.3.3 模糊查询(fuzzy)
我们新增一个商品:
POST /heima/goods/4 { "title":"apple手机", "images":"http://image.leyou.com/12479122.jpg", "price":6899.00 }
fuzzy
查询是 term
查询的模糊等价。它允许用户搜索词条与实际词条的拼写出现偏差,但是偏差的编辑距离不得超过2:
GET /heima/_search { "query": { "fuzzy": { "title": "appla" } } }
上面的查询,也能查询到apple手机
我们可以通过fuzziness
来指定允许的编辑距离:
GET /heima/_search { "query": { "fuzzy": { "title": { "value":"appla", "fuzziness":1 } } } }
3.4 过滤(filter)
需要注意的bool布尔组合的查询条件不能和过滤条件放在一起,不然会影响得分和高亮显示
条件查询中进行过滤
所有的查询都会影响到文档的评分及排名。如果我们需要在查询结果中进行过滤,并且不希望过滤条件影响评分,那么就不要把过滤条件作为查询条件来用。而是使用filter
方式:
GET /heima/_search { "query":{ "bool":{ "must":{ "match": { "title": "小米手机" }}, "filter":{ "range":{"price":{"gt":2000.00,"lt":3800.00}} } } } }
注意:filter
中还可以再次进行bool
组合条件过滤。
无查询条件,直接过滤
如果一次查询只有过滤,没有查询条件,不希望进行评分,我们可以使用constant_score
取代只有 filter 语句的 bool 查询。在性能上是完全相同的,但对于提高查询简洁性和清晰度有很大帮助。
GET /heima/_search { "query":{ "constant_score": { "filter": { "range":{"price":{"gt":2000.00,"lt":3000.00}} } } }
3.5 排序
3.4.1 单字段排序
sort
可以让我们按照不同的字段进行排序,并且通过order
指定排序的方式
GET /heima/_search { "query": { "match": { "title": "小米手机" } }, "sort": [ { "price": { "order": "desc" } } ] }
3.4.2 多字段排序
假定我们想要结合使用 price和 _score(得分) 进行查询,并且匹配的结果首先按照价格排序,然后按照相关性得分排序:
GET /goods/_search { "query":{ "bool":{ "must":{ "match": { "title": "小米手机" }}, "filter":{ "range":{"price":{"gt":200000,"lt":300000}} } } }, "sort": [ { "price": { "order": "desc" }}, { "_score": { "order": "desc" }} ] }
4. 聚合aggregations
聚合可以让我们极其方便的实现对数据的统计、分析。例如:
-
什么品牌的手机最受欢迎?
-
这些手机的平均价格、最高价格、最低价格?
-
这些手机每月的销售情况如何?
实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现实时搜索效果。
4.1 基本概念
Elasticsearch中的聚合,包含多种类型,最常用的两种,一个叫桶
,一个叫度量
:
桶对应的是分组
桶(bucket)
桶的作用,是按照某种方式对数据进行分组,每一组数据在ES中称为一个桶
,例如我们根据国籍对人划分,可以得到中国桶
、英国桶
,日本桶
……或者我们按照年龄段对人进行划分:0~10,10~20,20~30,30~40等。
Elasticsearch中提供的划分桶的方式有很多:
-
Date Histogram Aggregatio:根据日期阶梯分组,例如给定阶梯为周,会自动每周分为一组
-
Histogram Aggregation:(/ˌæɡrɪˈɡeɪʃn/)根据数值阶梯分组,与日期类似
-
Terms Aggregation(对应数据库的分组):根据词条内容分组,词条内容完全匹配的为一组
-
Range Aggregation:数值和日期的范围分组,指定开始和结束,然后按段分组
-
……
综上所述,我们发现bucket aggregations 只负责对数据进行分组,并不进行计算,因此往往bucket中往往会嵌套另一种聚合:metrics aggregations即度量
桶就是分组,比数据库分组的功能强大,词条还可以通过访问和阶段分桶
度量就是计算
度量(metrics)
分组完成以后,我们一般会对组中的数据进行聚合运算,例如求平均值、最大、最小、求和等,这些在ES中称为度量
比较常用的一些度量聚合方式:
-
Avg Aggregation:求平均值
-
Max Aggregation:求最大值
-
Min Aggregation:求最小值
-
Percentiles Aggregation:求百分比
-
Stats Aggregation:同时返回avg、max、min、sum、count等
-
Sum Aggregation:求和
-
Top hits Aggregation:求前几
-
Value Count Aggregation:求总数
-
……
为了测试聚合,我们先批量导入一些数据
创建索引:
PUT /cars { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "transactions": { "properties": { "color": { "type": "keyword" }, "make": { "type": "keyword" } } } } }
注意:在ES中,需要进行聚合、排序、过滤的字段其处理方式比较特殊,因此不能被分词。这里我们将color和make这两个文字类型的字段设置为keyword类型,这个类型不会被分词,将来就可以参与聚合
导入数据
POST /cars/transactions/_bulk { "index": {}} { "price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" } { "index": {}} { "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" } { "index": {}} { "price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" } { "index": {}} { "price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" } { "index": {}} { "price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" } { "index": {}} { "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" } { "index": {}} { "price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" } { "index": {}} { "price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" }
4.2 聚合为桶
首先,我们按照 汽车的颜色color
来划分桶
GET /cars/_search
{
"size" : 0,通过这个hits里面的数据清空,表示不需要查询数据
"aggs" : {
"popular_colors" : {
"terms" : {
"field" : "color"
}
}
}
}
-
size: 查询条数,这里设置为0,因为我们不关心搜索到的数据,只关心聚合结果,提高效率
-
aggs:声明这是一个聚合查询,是aggregations的缩写
-
popular_colors:给这次聚合起一个名字,任意。
-
terms:划分桶的方式,这里是根据词条划分
-
field:划分桶的字段
-
-
-
结果:
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 8,
"max_score": 0,
"hits": []
},
"aggregations": {
"popular_colors": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "red",
"doc_count": 4
},
{
"key": "blue",
"doc_count": 2
},
{
"key": "green",
"doc_count": 2
}
]
}
}
}
-
hits:查询结果为空,因为我们设置了size为0
-
aggregations:聚合的结果
-
popular_colors:我们定义的聚合名称
-
buckets:查找到的桶,每个不同的color字段值都会形成一个桶
-
key:这个桶对应的color字段的值
-
doc_count:这个桶中的文档数量
-
通过聚合的结果我们发现,目前红色的小车比较畅销!
4.3 桶内度量
前面的例子告诉我们每个桶里面的文档数量,这很有用。 但通常,我们的应用需要提供更复杂的文档度量。 例如,每种颜色汽车的平均价格是多少?
因此,我们需要告诉Elasticsearch使用哪个字段
,使用何种度量方式
进行运算,这些信息要嵌套在桶
内,度量
的运算会基于桶
内的文档进行
现在,我们为刚刚的聚合结果添加 求价格平均值的度量:
GET /cars/_search { "size" : 0, "aggs" : { "popular_colors" : { "terms" : { "field" : "color" }, "aggs":{ "avg_price": { "avg": { "field": "price" } } } } } }
-
aggs:我们在上一个aggs(popular_colors)中添加新的aggs。可见
度量
也是一个聚合,度量是在桶内的聚合 -
avg_price:聚合的名称
-
avg:度量的类型,这里是求平均值
-
field:度量运算的字段
结果:
... "aggregations": { "popular_colors": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "red", "doc_count": 4, "avg_price": { "value": 32500 } }, { "key": "blue", "doc_count": 2, "avg_price": { "value": 20000 } }, { "key": "green", "doc_count": 2, "avg_price": { "value": 21000 } } ] } } ...
可以看到每个桶中都有自己的avg_price
字段,这是度量聚合的结果
4.4 桶内嵌套桶
刚刚的案例中,我们在桶内嵌套度量运算。事实上桶不仅可以嵌套运算, 还可以再嵌套其它桶。也就是说在每个分组中,再分更多组。
比如:我们想统计每种颜色的汽车中,分别属于哪个制造商,按照make
字段再进行分桶
GET /cars/_search { "size" : 0, "aggs" : { "popular_colors" : { "terms" : { "field" : "color" }, "aggs":{ "avg_price": { "avg": { "field": "price" } }, "maker":{ "terms":{ "field":"make" } } } } } }
-
原来的color桶和avg计算我们不变
-
maker:在嵌套的aggs下新添一个桶,叫做maker
-
terms:桶的划分类型依然是词条
-
filed:这里根据make字段进行划分
部分结果:
... {"aggregations": { "popular_colors": { "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 } ] }, "avg_price": { "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 } ] }, "avg_price": { "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 } ] }, "avg_price": { "value": 21000 } } ] } } } ...
-
我们可以看到,新的聚合
maker
被嵌套在原来每一个color
的桶中。 -
每个颜色下面都根据
make
字段进行了分组 -
我们能读取到的信息:
-
红色车共有4辆
-
红色车的平均售价是 $32,500 美元。
-
其中3辆是 Honda 本田制造,1辆是 BMW 宝马制造。
-
4.5.划分桶的其它方式
前面讲了,划分桶的方式有很多,例如:
-
Date Histogram Aggregation:根据日期阶梯分组,例如给定阶梯为周,会自动每周分为一组
-
Histogram Aggregation:根据数值阶梯分组,与日期类似
-
Terms Aggregation:根据词条内容分组,词条内容完全匹配的为一组
-
Range Aggregation:数值和日期的范围分组,指定开始和结束,然后按段分组
刚刚的案例中,我们采用的是Terms Aggregation,即根据词条划分桶。
接下来,我们再学习几个比较实用的:
4.5.1.阶梯分桶Histogram
原理:
histogram是把数值类型的字段,按照一定的阶梯大小进行分组。你需要指定一个阶梯值(interval)来划分阶梯大小。
举例:
比如你有价格字段,如果你设定interval的值为200,那么阶梯就会是这样的:
0,200,400,600,...
上面列出的是每个阶梯的key,也是区间的启点。
如果一件商品的价格是450,会落入哪个阶梯区间呢?计算公式如下:
bucket_key = Math.floor((value - offset) / interval) * interval + offset
value:就是当前数据的值,本例中是450
offset:起始偏移量,默认为0
interval:阶梯间隔,比如200
因此你得到的key = Math.floor((450 - 0) / 200) * 200 + 0 = 400
操作一下:
比如,我们对汽车的价格进行分组,指定间隔interval为5000:
GET /cars/_search { "size":0, "aggs":{ "price":{ "histogram": { "field": "price", "interval": 5000 } } } }
结果:
{ "took": 21, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 8, "max_score": 0, "hits": [] }, "aggregations": { "price": { "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": 35000, "doc_count": 0 }, { "key": 40000, "doc_count": 0 }, { "key": 45000, "doc_count": 0 }, { "key": 50000, "doc_count": 0 }, { "key": 55000, "doc_count": 0 }, { "key": 60000, "doc_count": 0 }, { "key": 65000, "doc_count": 0 }, { "key": 70000, "doc_count": 0 }, { "key": 75000, "doc_count": 0 }, { "key": 80000, "doc_count": 1 } ] } } }
你会发现,中间有大量的文档数量为0 的桶,看起来很丑。
我们可以增加一个参数min_doc_count为1,来约束最少文档数量为1,这样文档数量为0的桶会被过滤
示例:
GET /cars/_search { "size":0, "aggs":{ "price":{ "histogram": { "field": "price", "interval": 5000, "min_doc_count": 1 } } } }
结果:
{ "took": 15, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 8, "max_score": 0, "hits": [] }, "aggregations": { "price": { "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 } ] } } }
完美,!
如果你用kibana将结果变为柱形图,会更好看:
4.5.2.范围分桶range
范围分桶与阶梯分桶类似,也是把数字按照阶段进行分组,只不过range方式需要你自己指定每一组的起始和结束大小。
5.Spring Data Elasticsearch
Elasticsearch提供的Java客户端有一些不太方便的地方:
-
很多地方需要拼接Json字符串,在java中拼接字符串有多恐怖你应该懂的
-
需要自己把对象序列化为json存储
-
查询到结果也需要自己反序列化为对象
因此,我们这里就不讲解原生的Elasticsearch客户端API了。
而是学习Spring提供的套件:Spring Data Elasticsearch。
5.1.简介
Spring Data Elasticsearch是Spring Data项目下的一个子模块。
查看 Spring Data的官网:Spring Data
Spring Data的使命是为数据访问提供熟悉且一致的基于Spring的编程模型,同时仍保留底层数据存储的特殊特性。
它使得使用数据访问技术,关系数据库和非关系数据库,map-reduce框架和基于云的数据服务变得容易。这是一个总括项目,其中包含许多特定于给定数据库的子项目。这些令人兴奋的技术项目背后,是由许多公司和开发人员合作开发的。
Spring Data 的使命是给各种数据访问提供统一的编程接口,不管是关系型数据库(如MySQL),还是非关系数据库(如Redis),或者类似Elasticsearch这样的索引数据库。从而简化开发人员的代码,提高开发效率。
包含很多不同数据操作的模块:
Spring Data Elasticsearch的页面:Spring Data Elasticsearch
特征:
-
支持Spring的基于
@Configuration
的java配置方式,或者XML配置方式 -
提供了用于操作ES的便捷工具类
ElasticsearchTemplate
。包括实现文档到POJO之间的自动智能映射。 -
利用Spring的数据转换服务实现的功能丰富的对象映射
-
基于注解的元数据映射方式,而且可扩展以支持更多不同的数据格式
-
根据持久层接口自动生成对应实现方法,无需人工编写基本操作代码(类似mybatis,根据接口自动得到实现)。当然,也支持人工定制查询
5.2.创建Demo工程
我们新建一个demo,学习Elasticsearch
pom依赖:
<?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>com.leyou.demo</groupId>
<artifactId>elasticsearch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>elasticsearch</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml文件配置:
spring:
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.56.101:9300
5.3.实体类及注解
首先我们准备好实体类:
public class Item {
Long id;
String title; //标题
String category;// 分类
String brand; // 品牌
Double price; // 价格
String images; // 图片地址
}
映射
Spring Data通过注解来声明字段的映射属性,有下面的三个注解:
-
@Document
作用在类,标记实体类为文档对象,一般有两个属性-
indexName:对应索引库名称
-
type:对应在索引库中的类型
-
shards:分片数量,默认5
-
replicas:副本数量,默认1
-
-
@Id
作用在成员变量,标记一个字段作为id主键 -
@Field
作用在成员变量,标记为文档的字段,并指定字段映射属性:-
type:字段类型,取值是枚举:FieldType
-
index:是否索引,布尔类型,默认是true
-
store:是否存储,布尔类型,默认是false
-
analyzer:分词器名称
-
示例:
@Document(indexName = "item",type = "docs", shards = 1, replicas = 0)
public class Item {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String title; //标题
@Field(type = FieldType.Keyword)
private String category;// 分类
@Field(type = FieldType.Keyword)
private String brand; // 品牌
@Field(type = FieldType.Double)
private Double price; // 价格
@Field(index = false, type = FieldType.Keyword)
private String images; // 图片地址
}
5.4.Template索引操作
5.4.1.创建索引和映射
创建索引
ElasticsearchTemplate中提供了创建索引的API:
可以根据类的信息自动生成,也可以手动指定indexName和Settings
映射
映射相关的API:
可以根据类的字节码信息(注解配置)来生成映射,或者手动编写映射
我们这里采用类的字节码信息创建索引并映射:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ItcastElasticsearchApplication.class)
public class IndexTest {
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Test
public void testCreate(){
// 创建索引,会根据Item类的@Document注解信息来创建
elasticsearchTemplate.createIndex(Item.class);
// 配置映射,会根据Item类中的id、Field等字段来自动完成映射
elasticsearchTemplate.putMapping(Item.class);
}
}
结果:
GET /item
{
"item": {
"aliases": {},
"mappings": {
"docs": {
"properties": {
"brand": {
"type": "keyword"
},
"category": {
"type": "keyword"
},
"images": {
"type": "keyword",
"index": false
},
"price": {
"type": "double"
},
"title": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
},
"settings": {
"index": {
"refresh_interval": "1s",
"number_of_shards": "1",
"provided_name": "item",
"creation_date": "1525405022589",
"store": {
"type": "fs"
},
"number_of_replicas": "0",
"uuid": "4sE9SAw3Sqq1aAPz5F6OEg",
"version": {
"created": "6020499"
}
}
}
}
}
5.3.2.删除索引
删除索引的API:
可以根据类名或索引名删除。
示例:
@Test
public void deleteIndex() {
esTemplate.deleteIndex("heima");
}
结果:
5.4.Repository文档操作
Spring Data 的强大之处,就在于你不用写任何DAO处理,自动根据方法名或类的信息进行CRUD操作。只要你定义一个接口,然后继承Repository提供的一些子接口,就能具备各种基本的CRUD功能。
我们只需要定义接口,然后继承它就OK了。
public interface ItemRepository extends ElasticsearchRepository<Item,Long> {
}
来看下Repository的继承关系:
我们看到有一个ElasticsearchRepository接口:
5.4.1.新增文档
@Autowired
private ItemRepository itemRepository;
@Test
public void index() {
Item item = new Item(1L, "小米手机7", " 手机",
"小米", 3499.00, "http://image.leyou.com/13123.jpg");
itemRepository.save(item);
}
去页面查询看看:
GET /item/_search
结果:
{
"took": 14,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 1,
"max_score": 1,
"hits": [
{
"_index": "item",
"_type": "docs",
"_id": "1",
"_score": 1,
"_source": {
"id": 1,
"title": "小米手机7",
"category": " 手机",
"brand": "小米",
"price": 3499,
"images": "http://image.leyou.com/13123.jpg"
}
}
]
}
}
5.4.2.批量新增
代码:
@Test
public void indexList() {
List<Item> list = new ArrayList<>();
list.add(new Item(2L, "坚果手机R1", " 手机", "锤子", 3699.00, "http://image.leyou.com/123.jpg"));
list.add(new Item(3L, "华为META10", " 手机", "华为", 4499.00, "http://image.leyou.com/3.jpg"));
// 接收对象集合,实现批量新增
itemRepository.saveAll(list);
}
再次去页面查询:
{
"took": 5,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "item",
"_type": "docs",
"_id": "2",
"_score": 1,
"_source": {
"id": 2,
"title": "坚果手机R1",
"category": " 手机",
"brand": "锤子",
"price": 3699,
"images": "http://image.leyou.com/13123.jpg"
}
},
{
"_index": "item",
"_type": "docs",
"_id": "3",
"_score": 1,
"_source": {
"id": 3,
"title": "华为META10",
"category": " 手机",
"brand": "华为",
"price": 4499,
"images": "http://image.leyou.com/13123.jpg"
}
},
{
"_index": "item",
"_type": "docs",
"_id": "1",
"_score": 1,
"_source": {
"id": 1,
"title": "小米手机7",
"category": " 手机",
"brand": "小米",
"price": 3499,
"images": "http://image.leyou.com/13123.jpg"
}
}
]
}
}
5.4.3.修改文档
修改和新增是同一个接口,区分的依据就是id,这一点跟我们在页面发起PUT请求是类似的。
5.4.4.基本查询
ElasticsearchRepository提供了一些基本的查询方法:
我们来试试查询所有:
@Test
public void testFind(){
// 查询全部,并安装价格降序排序
Iterable<Item> items = this.itemRepository.findAll(Sort.by(Sort.Direction.DESC, "price"));
items.forEach(item-> System.out.println(item));
}
结果:
5.4.5.自定义方法
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(Collection<String>names) | {"bool" : {"must" : {"bool" : {"should" : [ {"field" : {"name" : "?"}}, {"field" : {"name" : "?"}} ]}}}} |
NotIn | findByNameNotIn(Collection<String>names) | {"bool" : {"must_not" : {"bool" : {"should" : {"field" : {"name" : "?"}}}}}} |
Near | findByStoreNear | Not Supported Yet ! |
True | findByAvailableTrue | {"bool" : {"must" : {"field" : {"available" : true}}}} |
False | findByAvailableFalse | {"bool" : {"must" : {"field" : {"available" : false}}}} |
OrderBy | findByAvailableTrueOrderByNameDesc | {"sort" : [{ "name" : {"order" : "desc"} }],"bool" : {"must" : {"field" : {"available" : true}}}} |
例如,我们来按照价格区间查询,定义这样的一个方法:
public interface ItemRepository extends ElasticsearchRepository<Item,Long> { /** * 根据价格区间查询 * @param price1 * @param price2 * @return */ List<Item> findByPriceBetween(double price1, double price2); }
然后添加一些测试数据:
@Test public void indexList() { List<Item> list = new ArrayList<>(); list.add(new Item(1L, "小米手机7", "手机", "小米", 3299.00, "http://image.leyou.com/13123.jpg")); list.add(new Item(2L, "坚果手机R1", "手机", "锤子", 3699.00, "http://image.leyou.com/13123.jpg")); list.add(new Item(3L, "华为META10", "手机", "华为", 4499.00, "http://image.leyou.com/13123.jpg")); list.add(new Item(4L, "小米Mix2S", "手机", "小米", 4299.00, "http://image.leyou.com/13123.jpg")); list.add(new Item(5L, "荣耀V10", "手机", "华为", 2799.00, "http://image.leyou.com/13123.jpg")); // 接收对象集合,实现批量新增 itemRepository.saveAll(list); }
不需要写实现类,然后我们直接去运行:
@Test public void queryByPriceBetween(){ List<Item> list = this.itemRepository.findByPriceBetween(2000.00, 3500.00); for (Item item : list) { System.out.println("item = " + item); } }
结果:
虽然基本查询和自定义方法已经很强大了,但是如果是复杂查询(模糊、通配符、词条查询等)就显得力不从心了。此时,我们只能使用原生查询。
5.5.高级查询
5.5.1.基本查询
先看看基本玩法
@Test public void testQuery(){ // 词条查询 MatchQueryBuilder queryBuilder = QueryBuilders.matchQuery("title", "小米"); // 执行查询 Iterable<Item> items = this.itemRepository.search(queryBuilder); items.forEach(System.out::println); }
Repository的search方法需要QueryBuilder参数,elasticSearch为我们提供了一个对象QueryBuilders:
QueryBuilders提供了大量的静态方法,用于生成各种不同类型的查询对象,例如:词条、模糊、通配符等QueryBuilder对象。
结果:
elasticsearch提供很多可用的查询方式,但是不够灵活。如果想玩过滤或者聚合查询等就很难了。
5.5.2.自定义查询
先来看最基本的match query:
@Test public void testNativeQuery(){ // 构建查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 添加基本的分词查询 queryBuilder.withQuery(QueryBuilders.matchQuery("title", "小米")); // 执行搜索,获取结果 Page<Item> items = this.itemRepository.search(queryBuilder.build()); // 打印总条数 System.out.println(items.getTotalElements()); // 打印总页数 System.out.println(items.getTotalPages()); items.forEach(System.out::println); }
NativeSearchQueryBuilder:Spring提供的一个查询条件构建器,帮助构建json格式的请求体
Page<item>
:默认是分页查询,因此返回的是一个分页的结果对象,包含属性:
-
totalElements:总条数
-
totalPages:总页数
-
Iterator:迭代器,本身实现了Iterator接口,因此可直接迭代得到当前页的数据
-
其它属性:
结果:
5.5.4.分页查询
利用NativeSearchQueryBuilder
可以方便的实现分页:
@Test public void testNativeQuery(){ // 构建查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 添加基本的分词查询 queryBuilder.withQuery(QueryBuilders.termQuery("category", "手机")); // 初始化分页参数 int page = 0; int size = 3; // 设置分页参数 queryBuilder.withPageable(PageRequest.of(page, size)); // 执行搜索,获取结果 Page<Item> items = this.itemRepository.search(queryBuilder.build()); // 打印总条数 System.out.println(items.getTotalElements()); // 打印总页数 System.out.println(items.getTotalPages()); // 每页大小 System.out.println(items.getSize()); // 当前页 System.out.println(items.getNumber()); items.forEach(System.out::println); }
结果:
可以发现,Elasticsearch中的分页是从第0页开始。
5.5.5.排序
排序也通用通过NativeSearchQueryBuilder
完成:
@Test public void testSort(){ // 构建查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 添加基本的分词查询 queryBuilder.withQuery(QueryBuilders.termQuery("category", "手机")); // 排序 queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC)); // 执行搜索,获取结果 Page<Item> items = this.itemRepository.search(queryBuilder.build()); // 打印总条数 System.out.println(items.getTotalElements()); items.forEach(System.out::println); }
结果:
5.6.聚合
5.6.1.聚合为桶
桶就是分组,比如这里我们按照品牌brand进行分组:
@Test public void testAgg(){ NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 不查询任何结果 queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""}, null)); // 1、添加一个新的聚合,聚合类型为terms,聚合名称为brands,聚合字段为brand queryBuilder.addAggregation( AggregationBuilders.terms("brands").field("brand")); // 2、查询,需要把结果强转为AggregatedPage类型 AggregatedPage<Item> aggPage = (AggregatedPage<Item>) this.itemRepository.search(queryBuilder.build()); // 3、解析 // 3.1、从结果中取出名为brands的那个聚合, // 因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型 StringTerms agg = (StringTerms) aggPage.getAggregation("brands"); // 3.2、获取桶 List<StringTerms.Bucket> buckets = agg.getBuckets(); // 3.3、遍历 for (StringTerms.Bucket bucket : buckets) { // 3.4、获取桶中的key,即品牌名称 System.out.println(bucket.getKeyAsString()); // 3.5、获取桶中的文档数量 System.out.println(bucket.getDocCount()); } }
显示的结果:
关键API:
-
AggregationBuilders
:聚合的构建工厂类。所有聚合都由这个类来构建,看看他的静态方法: -
AggregatedPage
:聚合查询的结果类。它是Page<T>
的子接口:AggregatedPage
在Page
功能的基础上,拓展了与聚合相关的功能,它其实就是对聚合结果的一种封装,大家可以对照聚合结果的JSON结构来看。而返回的结果都是Aggregation类型对象,不过根据字段类型不同,又有不同的子类表示
我们看下页面的查询的JSON结果与Java类的对照关系:
5.6.2.嵌套聚合,求平均值
代码:
@Test public void testSubAgg(){ NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 不查询任何结果 queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""}, null)); // 1、添加一个新的聚合,聚合类型为terms,聚合名称为brands,聚合字段为brand queryBuilder.addAggregation( AggregationBuilders.terms("brands").field("brand") .subAggregation(AggregationBuilders.avg("priceAvg").field("price")) // 在品牌聚合桶内进行嵌套聚合,求平均值 ); // 2、查询,需要把结果强转为AggregatedPage类型 AggregatedPage<Item> aggPage = (AggregatedPage<Item>) this.itemRepository.search(queryBuilder.build()); // 3、解析 // 3.1、从结果中取出名为brands的那个聚合, // 因为是利用String类型字段来进行的term聚合,所以结果要强转为StringTerm类型 StringTerms agg = (StringTerms) aggPage.getAggregation("brands"); // 3.2、获取桶 List<StringTerms.Bucket> buckets = agg.getBuckets(); // 3.3、遍历 for (StringTerms.Bucket bucket : buckets) { // 3.4、获取桶中的key,即品牌名称 3.5、获取桶中的文档数量 System.out.println(bucket.getKeyAsString() + ",共" + bucket.getDocCount() + "台"); // 3.6.获取子聚合结果: InternalAvg avg = (InternalAvg) bucket.getAggregations().asMap().get("priceAvg"); System.out.println("平均售价:" + avg.getValue()); } }
结果:
第十二天
0.学习目标
-
独立编写数据导入功能
-
独立实现基本搜索
-
独立实现页面分页
-
独立实现结果排序
1.索引库数据导入
昨天我们学习了Elasticsearch的基本应用。今天就学以致用,搭建搜索微服务,实现搜索功能。
1.1.创建搜索服务
创建module:
Pom文件:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou</artifactId> <groupId>com.leyou.parent</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>com.leyou.search</groupId> <artifactId>leyou-search</artifactId> <version>1.0.0-SNAPSHOT</version> <dependencies> <!-- web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- elasticsearch --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <!-- eureka --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <!-- feign --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> </dependencies> </project>
application.yml:
server: port: 8083 spring: application: name: search-service data: elasticsearch: cluster-name: elasticsearch cluster-nodes: 192.168.56.101:9300 eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka instance: lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳 lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期 prefer-ip-address: true ip-address: 127.0.0.1 instance-id: ${spring.application.name}:${server.port}
启动类:
@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class LySearchService { public static void main(String[] args) { SpringApplication.run(LySearchService.class, args); } }
1.2.索引库数据格式分析
接下来,我们需要商品数据导入索引库,便于用户搜索。
那么问题来了,我们有SPU和SKU,到底如何保存到索引库?
1.2.1.以结果为导向
大家来看下搜索结果页:
可以看到,每一个搜索结果都有至少1个商品,当我们选择大图下方的小图,商品会跟着变化。
因此,搜索的结果是SPU,即多个SKU的集合。
既然搜索的结果是SPU,那么我们索引库中存储的应该也是SPU,但是却需要包含SKU的信息。
1.2.2.需要什么数据
再来看看页面中有什么数据:
直观能看到的:图片、价格、标题、副标题
暗藏的数据:spu的id,sku的id
另外,页面还有过滤条件:
这些过滤条件也都需要存储到索引库中,包括:
商品分类、品牌、可用来搜索的规格参数等
综上所述,我们需要的数据格式有:
spuId、SkuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数
1.2.3.最终的数据结构
我们创建一个类,封装要保存到索引库的数据,并设置映射属性:
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0) public class Goods { @Id private Long id; // spuId @Field(type = FieldType.Text, analyzer = "ik_max_word") private String all; // 所有需要被搜索的信息,包含标题,分类,甚至品牌 @Field(type = FieldType.Keyword, index = false) private String subTitle;// 卖点 private Long brandId;// 品牌id private Long cid1;// 1级分类id private Long cid2;// 2级分类id private Long cid3;// 3级分类id private Date createTime;// 创建时间 private List<Long> price;// 价格 @Field(type = FieldType.Keyword, index = false) private String skus;// sku信息的json结构 private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值 }
一些特殊字段解释:
-
all:用来进行全文检索的字段,里面包含标题、商品分类信息
-
price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤
-
skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段
-
specs:所有规格参数的集合。key是参数名,值是参数值。
例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:
{ "specs":{ "内存":[4G,6G], "颜色":"红色" } }
当存储到索引库时,elasticsearch会处理为两个字段:
-
specs.内存:[4G,6G]
-
specs.颜色:红色
另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。
-
specs.颜色.keyword:红色
-
1.3.商品微服务提供接口
索引库中的数据来自于数据库,我们不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以我们只能调用商品微服务提供的接口服务。
先思考我们需要的数据:
-
SPU信息
-
SKU信息
-
SPU的详情
-
商品分类名称(拼接all字段)
再思考我们需要哪些服务:
-
第一:分批查询spu的服务,已经写过。
-
第二:根据spuId查询sku的服务,已经写过
-
第三:根据spuId查询SpuDetail的服务,已经写过
-
第四:根据商品分类id,查询商品分类名称,没写过
-
第五:根据商品品牌id,查询商品的品牌,没写过
因此我们需要额外提供一个查询商品分类名称的接口。
1.3.1.商品分类名称查询
controller:
/** * 根据商品分类id查询名称 * @param ids 要查询的分类id集合 * @return 多个名称的集合 */ @GetMapping("names") public ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids){ List<String > list = this.categoryService.queryNameByIds(ids); if (list == null || list.size() < 1) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list); }
测试:
1.3.2.编写FeignClient
1.3.2.1.问题展现
操作leyou-search工程
现在,我们要在搜索微服务调用商品微服务的接口。
第一步要引入商品微服务依赖:leyou-item-interface
。
<!--商品微服务-->
<dependency>
<groupId>com.leyou.service</groupId>
<artifactId>ly-item-interface</artifactId>
<version>${leyou.latest.version}</version>
</dependency>
第二步,编写FeignClient
@FeignClient(value = "item-service")
@RequestMapping("/goods")
public interface GoodsClient {
/**
* 分页查询商品
* @param page
* @param rows
* @param saleable
* @param key
* @return
*/
@GetMapping("/spu/page")
ResponseEntity<PageResult<SpuBo>> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
@RequestParam(value = "key", required = false) String key);
/**
* 根据spu商品id查询详情
* @param id
* @return
*/
@GetMapping("/spu/detail/{id}")
ResponseEntity<SpuDetail> querySpuDetailById(@PathVariable("id") Long id);
/**
* 根据spu的id查询sku
* @param id
* @return
*/
@GetMapping("sku/list")
ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id") Long id);
}
以上的这些代码直接从商品微服务中拷贝而来,完全一致。差别就是没有方法的具体实现。大家觉得这样有没有问题?
而FeignClient代码遵循SpringMVC的风格,因此与商品微服务的Controller完全一致。这样就存在一定的问题:
-
代码冗余。尽管不用写实现,只是写接口,但服务调用方要写与服务controller一致的代码,有几个消费者就要写几次。
-
增加开发成本。调用方还得清楚知道接口的路径,才能编写正确的FeignClient。
1.3.2.2.解决方案
因此,一种比较友好的实践是这样的:
-
我们的服务提供方不仅提供实体类,还要提供api接口声明
-
调用方不用字自己编写接口方法声明,直接继承提供方给的Api接口即可,
第一步:服务的提供方在leyou-item-interface
中提供API接口,并编写接口声明:
商品分类服务接口:
@RequestMapping("category") public interface CategoryApi { @GetMapping("names") ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids); }
商品服务接口,返回值不再使用ResponseEntity:
@RequestMapping("/goods")
public interface GoodsApi {
/**
* 分页查询商品
* @param page
* @param rows
* @param saleable
* @param key
* @return
*/
@GetMapping("/spu/page")
PageResult<SpuBo> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
@RequestParam(value = "key", required = false) String key);
/**
* 根据spu商品id查询详情
* @param id
* @return
*/
@GetMapping("/spu/detail/{id}")
SpuDetail querySpuDetailById(@PathVariable("id") Long id);
/**
* 根据spu的id查询sku
* @param id
* @return
*/
@GetMapping("sku/list")
List<Sku> querySkuBySpuId(@RequestParam("id") Long id);
}
需要引入springMVC及leyou-common的依赖:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.0.6.RELEASE</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
第二步:在调用方leyou-search
中编写FeignClient,但不要写方法声明了,直接继承leyou-item-interface
提供的api接口:
商品的FeignClient:
@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}
商品分类的FeignClient:
@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
}
是不是简单多了?
项目结构:
1.3.2.3.测试
在leyou-search中引入springtest依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
创建测试类:
在接口上按快捷键:Ctrl + Shift + T
测试代码:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = LeyouSearchApplication.class)
public class CategoryClientTest {
@Autowired
private CategoryClient categoryClient;
@Test
public void testQueryCategories() {
List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(1L, 2L, 3L));
names.forEach(System.out::println);
}
}
结果:
1.4.导入数据
导入数据只做一次,以后的更新删除等操作通过消息队列来操作索引库
1.4.1.创建GoodsRepository
java代码:
public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
1.4.2.创建索引
我们新建一个测试类,在里面进行数据的操作:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = LeyouSearchApplication.class)
public class ElasticsearchTest {
@Autowired
private GoodsRepository goodsRepository;
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Test
public void createIndex(){
// 创建索引
this.elasticsearchTemplate.createIndex(Goods.class);
// 配置映射
this.elasticsearchTemplate.putMapping(Goods.class);
}
}
通过kibana查看:
1.4.3.导入数据
导入数据其实就是查询数据,然后把查询到的Spu转变为Goods来保存,因此我们先编写一个SearchService,然后在里面定义一个方法, 把Spu转为Goods
@Service
public class SearchService {
@Autowired
private CategoryClient categoryClient;
@Autowired
private GoodsClient goodsClient;
@Autowired
private SpecificationClient specificationClient;
private ObjectMapper mapper = new ObjectMapper();
public Goods buildGoods(Spu spu) throws IOException {
Goods goods = new Goods();
// 查询商品分类名称
List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
// 查询sku
List<Sku> skus = this.goodsClient.querySkuBySpuId(spu.getId());
// 查询详情
SpuDetail spuDetail = this.goodsClient.querySpuDetailById(spu.getId());
// 查询规格参数
List<SpecParam> params = this.specificationClient.querySpecParam(null, spu.getCid3(), true, null);
// 处理sku,仅封装id、价格、标题、图片,并获得价格集合
List<Long> prices = new ArrayList<>();
List<Map<String, Object>> skuList = new ArrayList<>();
skus.forEach(sku -> {
prices.add(sku.getPrice());
Map<String, Object> skuMap = new HashMap<>();
skuMap.put("id", sku.getId());
skuMap.put("title", sku.getTitle());
skuMap.put("price", sku.getPrice());
skuMap.put("image", StringUtils.isBlank(sku.getImages()) ? "" : StringUtils.split(sku.getImages(), ",")[0]);
skuList.add(skuMap);
});
// 处理规格参数
Map<String, Object> genericSpecs = mapper.readValue(spuDetail.getGenericSpec(), new TypeReference<Map<String, Object>>() {
});
Map<String, Object> specialSpecs = mapper.readValue(spuDetail.getSpecialSpec(), new TypeReference<Map<String, Object>>() {
});
// 获取可搜索的规格参数
Map<String, Object> searchSpec = new HashMap<>();
// 过滤规格模板,把所有可搜索的信息保存到Map中
Map<String, Object> specMap = new HashMap<>();
params.forEach(p -> {
if (p.getSearching()) {
if (p.getGeneric()) {
String value = genericSpecs.get(p.getId().toString()).toString();
if(p.getNumeric()){
value = chooseSegment(value, p);
}
specMap.put(p.getName(), StringUtils.isBlank(value) ? "其它" : value);
} else {
specMap.put(p.getName(), specialSpecs.get(p.getId().toString()));
}
}
});
goods.setId(spu.getId());
goods.setSubTitle(spu.getSubTitle());
goods.setBrandId(spu.getBrandId());
goods.setCid1(spu.getCid1());
goods.setCid2(spu.getCid2());
goods.setCid3(spu.getCid3());
goods.setCreateTime(spu.getCreateTime());
goods.setAll(spu.getTitle() + " " + StringUtils.join(names, " "));
goods.setPrice(prices);
goods.setSkus(mapper.writeValueAsString(skuList));
goods.setSpecs(specMap);
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;
}
然后编写一个测试类,循环查询Spu,然后调用IndexService中的方法,把SPU变为Goods,然后写入索引库:
@Test
public void loadData(){
// 创建索引
this.elasticsearchTemplate.createIndex(Goods.class);
// 配置映射
this.elasticsearchTemplate.putMapping(Goods.class);
int page = 1;
int rows = 100;
int size = 0;
do {
// 查询分页数据
PageResult<SpuBo> result = this.goodsClient.querySpuByPage(page, rows, true, null);
List<SpuBo> spus = result.getItems();
size = spus.size();
// 创建Goods集合
List<Goods> goodsList = new ArrayList<>();
// 遍历spu
for (SpuBo spu : spus) {
try {
Goods goods = this.searchService.buildGoods(spu);
goodsList.add(goods);
} catch (Exception e) {
break;
}
}
this.goodsRepository.saveAll(goodsList);
page++;
} while (size == 100);
}
通过kibana查询, 可以看到数据成功导入:
2.实现基本搜索
2.1.页面分析
2.1.1.页面跳转
在首页的顶部,有一个输入框:
当我们输入任何文本,点击搜索,就会跳转到搜索页search.html
了:
并且将搜索关键字以请求参数携带过来:
我们打开search.html
,在最下面会有提前定义好的Vue实例:
<script type="text/javascript">
var vm = new Vue({
el: "#searchApp",
data: {
},
components:{
// 加载页面顶部组件
lyTop: () => import("./js/pages/top.js")
}
});
</script>
这个Vue实例中,通过import导入的方式,加载了另外一个js:top.js并作为一个局部组件。top其实是页面顶部导航组件,我们暂时不管
2.1.2.发起异步请求
要想在页面加载后,就展示出搜索结果。我们应该在页面加载时,获取地址栏请求参数,并发起异步请求,查询后台数据,然后在页面渲染。
我们在data中定义一个对象,记录请求的参数:
data: { search:{ key:"", // 搜索页面的关键字 } }
我们通过钩子函数created,在页面加载时获取请求参数,并记录下来。
created(){ // 判断是否有请求参数 if(!location.search){ return; } // 将请求参数转为对象 const search = ly.parse(location.search.substring(1)); // 记录在data的search对象中 this.search = search; // 发起请求,根据条件搜索 this.loadData(); }
然后发起请求,搜索数据。
methods: { loadData(){ // ly.http.post("/search/page", ly.stringify(this.search)).then(resp=>{ ly.http.post("/search/page", this.search).then(resp=>{ console.log(resp); }); } }
-
我们这里使用
ly
是common.js中定义的工具对象。 -
这里使用的是post请求,这样可以携带更多参数,并且以json格式发送
在leyou-gateway中,添加允许信任域名:
并添加网关映射:
刷新页面试试:
因为后台没有提供接口,所以无法访问。没关系,接下来我们实现后台接口
2.2.后台提供搜索接口
2.2.1.controller
首先分析几个问题:
-
请求方式:Post
-
请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询
-
请求参数:json格式,目前只有一个属性:key-搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:
public class SearchRequest { private String key;// 搜索条件 private Integer page;// 当前页 private static final Integer DEFAULT_SIZE = 20;// 每页大小,不从页面接收,而是固定大小 private static final Integer DEFAULT_PAGE = 1;// 默认页 public String getKey() { return key; } public void setKey(String key) { this.key = key; } public Integer getPage() { if(page == null){ return DEFAULT_PAGE; } // 获取页码时做一些校验,不能小于1 return Math.max(DEFAULT_PAGE, page); } public void setPage(Integer page) { this.page = page; } public Integer getSize() { return DEFAULT_SIZE; } }
-
返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类
代码:
@RestController @RequestMapping public class SearchController { @Autowired private SearchService searchService; /** * 搜索商品 * * @param request * @return */ @PostMapping("page") public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request) { PageResult<Goods> result = this.searchService.search(request); if (result == null) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(result); } }
2.2.2.service
@Service public class SearchService { @Autowired private GoodsRepository goodsRepository; public PageResult<Goods> search(SearchRequest request) { String key = request.getKey(); // 判断是否有搜索条件,如果没有,直接返回null。不允许搜索全部商品 if (StringUtils.isBlank(key)) { return null; } // 构建查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 1、对key进行全文检索查询 queryBuilder.withQuery(QueryBuilders.matchQuery("all", key).operator(Operator.AND)); // 2、通过sourceFilter设置返回的结果字段,我们只需要id、skus、subTitle queryBuilder.withSourceFilter(new FetchSourceFilter( new String[]{"id","skus","subTitle"}, null)); // 3、分页 // 准备分页参数 int page = request.getPage(); int size = request.getSize(); queryBuilder.withPageable(PageRequest.of(page - 1, size)); // 4、查询,获取结果 Page<Goods> pageInfo = this.goodsRepository.search(queryBuilder.build()); // 封装结果并返回 return new PageResult<>(goodsPage.getTotalElements(), goodsPage.getTotalPages(), goodsPage.getContent()); } }
注意点:我们要设置SourceFilter,来选择要返回的结果,否则返回一堆没用的数据,影响查询效率。
2.2.3.测试
刷新页面测试:
数据是查到了,但是因为我们只查询部分字段,所以结果json 数据中有很多null,这很不优雅。
解决办法很简单,在leyou-search的application.yml中添加一行配置,json处理时忽略空值:
spring: jackson: default-property-inclusion: non_null # 配置json处理时忽略空值
结果:
2.3.页面渲染
页面已经拿到了结果,接下来就要渲染样式了。
2.3.1.保存搜索结果
首先,在data中定义属性,保存搜索的结果:
在loadData
的异步查询中,将结果赋值给goodsList
:
2.3.2.循环展示商品
在search.html的中部,有一个div
,用来展示所有搜索到的商品:
可以看到,div
中有一个无序列表ul
,内部的每一个li
就是一个商品spu了。
我们删除多余的,只保留一个li
,然后利用vue的循环来展示搜索到的结果:
2.3.3.多sku展示
2.3.3.1.分析
接下来展示具体的商品信息,来看图:
这里我们可以发现,一个商品位置,是多个sku的信息集合。当用户鼠标选择某个sku,对应的图片、价格、标题会随之改变!
我们先来实现sku的选择,才能去展示不同sku的数据。
可以看到,在列表中默认第一个是被选中的,那我们就需要做两件事情:
-
在搜索到数据时,先默认把第一个sku作为被选中的,记录下来
-
记录当前被选中的是哪一个sku,记录在哪里比较合适呢?显然是遍历到的goods对象自己内部,因为每一个goods都会有自己的sku信息。
2.3.3.2.初始化sku
查询出的结果集skus是一个json类型的字符串,不是js对象
我们在查询成功的回调函数中,对goods进行遍历,把skus转化成对象,并添加一个selected属性保存被选中的sku:
2.3.3.3.多sku图片列表
接下来,我们看看多个sku的图片列表位置:
看到又是一个无序列表,这里我们也一样删掉多余的,保留一个li
,需要注意选中的项有一个样式类:selected
我们的代码:
<!--多sku图片列表--> <ul class="skus"> <li :class="{selected: sku.id == goods.selected.id}" v-for="sku in goods.skus" :key="sku.id" @mouseEnter="goods.selected=sku"> <img :src="sku.image"> </li> </ul>
注意:
-
class样式通过 goods.selected的id是否与当前sku的id一致来判断
-
绑定了鼠标事件,鼠标进入后把当前sku赋值到goods.selected
2.3.4.展示sku其它属性
现在,我们已经可以通过goods.selected获取
用户选中的sku,那么我们就可以在页面展示了:
刷新页面:
看起来很完美是吧!
但其实有一些瑕疵
2.3.5.几个问题
2.3.5.1.价格显示的是分
首先价格显示就不正确,我们数据库中存放的是以分为单位,所以这里要格式化。
好在我们之前common.js中定义了工具类,可以帮我们转换。
改造:
结果报错:
为啥?
因为在Vue范围内使用任何变量,都会默认去Vue实例中寻找,我们使用ly,但是Vue实例中没有这个变量。所以解决办法就是把ly记录到Vue实例:
然后刷新页面:
2.3.5.2.标题过长
标题内容太长了,已经无法完全显示,怎么办?
截取一下:
最好在加个悬停展示所有内容的效果
2.3.5.3.sku点击不切换
还有一个错误比较隐蔽,不容易被发现。我们点击sku 的图片列表,发现没有任何变化。
这不科学啊,为什么?
通过控制台观察,发现数据其实是变化了,但是Vue却没有重新渲染视图。
这是因为Vue的自动渲染是基于对象的属性变化的。比如页面使用GoodsList进行渲染,如果GoodsList变化,或者其内部的任何子对象变化,都会Vue感知,从而从新渲染页面。
然而,这一切有一个前提,那就是当你第一次渲染时,对象中有哪些属性,Vue就只监视这些属性,后来添加的属性发生改变,是不会被监视到的。
而我们的goods对象中,本身是没有selected属性的,是我们后来才添加进去的:
这段代码稍微改造一下,即可:
也就是说,我们先把selected属性初始化完毕,然后才把整个对象赋值给goodsList,这样,goodsList已初始化时就有selected属性,以后就会被正常监控了。
3.页面分页效果
刚才的查询中,我们默认了查询的页码和每页大小,因此所有的分页功能都无法使用,接下来我们一起看看分页功能条
该如何制作。
这里要分两步,
-
第一步:如何生成分页条
-
第二步:点击分页按钮,我们做什么
3.1.如何生成分页条
先看下页面关于分页部分的代码:
可以看到所有的分页栏内容都是写死的。
3.1.1.需要的数据
分页数据应该是根据总页数、当前页、总条数等信息来计算得出。
-
当前页:肯定是由页面来决定的,点击按钮会切换到对应的页
-
总页数:需要后台传递给我们
-
总条数:需要后台传递给我们
我们首先在data中记录下这几个值:page-当前页,total-总条数,totalPage-总页数
data: { ly, search:{ key: "", page: 1 }, goodsList:[], // 接收搜索得到的结果 total: 0, // 总条数 totalPage: 0 // 总页数 }
因为page是搜索条件之一,所以记录在search对象中。
要注意:我们在created钩子函数中,会读取url路径的参数,然后赋值给search。如果是第一次请求页面,page是不存在的。因此为了避免page被覆盖,我们应该这么做:
不过,这个时候我们自己的search对象中的值就可有可无了
3.1.2.后台提供数据
后台返回的结果中,要包含total和totalPage,我们改造下刚才的接口:
在我们返回的PageResult对象中,其实是有totalPage字段的:
我们在返回时,把这个值填上:
页面测试一下:
OK
3.1.3.页面计算分页条
首先,把后台提供的数据保存在data中:
然后看下我们要实现的效果:
这里最复杂的是中间的1~5的分页按钮,它需要动态变化。
思路分析:
-
最多有5个按钮,因此我们可以用
v-for
循环从1到5即可 -
但是分页条不一定是从1开始:
-
如果当前页值小于等于3的时候,分页条位置从1开始到5结束
-
如果总页数小于等于5的时候,分页条位置从1开始到5结束
-
如果当前页码大于3,应该从page-3开始
-
但是如果当前页码大于totalPage-3,应该从totalPage-5开始
-
所以,我们的页面这样来做:
a标签中的分页数字通过index
函数来计算,需要把i
传递过去:
index(i){ if(this.search.page <= 3 || this.totalPage <= 5){ // 如果当前页小于等于3或者总页数小于等于5 return i; } else if(this.search.page > 3) { // 如果当前页大于3 return this.search.page - 3 + i; } else { return this.totalPage - 5 + i; } }
需要注意的是,如果总页数不足5页,我们就不应该遍历1~5,而是1~总页数,稍作改进:
分页条的其它部分就比较简单了:
<div class="sui-pagination pagination-large"> <ul style="width: 550px"> <li :class="{prev:true,disabled:search.page === 1}"> <a href="#">«上一页</a> </li> <li :class="{active: index(i) === search.page}" v-for="i in Math.min(5,totalPage)" :key="i"> <a href="#">{{index(i)}}</a> </li> <li class="dotted" v-show="totalPage > 5"><span>...</span></li> <li :class="{next:true,disabled:search.page === totalPage}"> <a href="#">下一页»</a> </li> </ul> <div> <span>共{{totalPage}}页 </span> <span> 到第 <input type="text" class="page-num" :value="search.page"> 页 <button class="page-confirm" οnclick="alert(1)">确定</button> </span> </div> </div>
3.2.点击分页做什么
点击分页按钮后,自然是要修改page
的值
所以,我们在上一页
、下一页
按钮添加点击事件,对page进行修改,在数字按钮上绑定点击事件,点击直接修改page:
prevPage(){ if(this.search.page > 1){ this.search.page-- } }, nextPage(){ if(this.search.page < this.totalPage){ this.search.page++ } }
当page
发生变化,我们应该去后台重新查询数据。
不过,如果我们直接发起ajax请求,那么浏览器的地址栏中是不会有变化的,没有记录下分页信息。如果用户刷新页面,那么就会回到第一页。
这样不太友好,我们应该把搜索条件记录在地址栏的查询参数中。
因此,我们监听search的变化,然后把search的过滤字段拼接在url路径后:
watch:{ search:{ deep:true, handler(val){ // 把search对象变成请求参数,拼接在url路径 window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val); } } },
刷新页面测试,然后就出现重大bug:页面无限刷新!为什么?
因为Vue实例初始化的钩子函数中,我们读取请求参数,赋值给search的时候,也触发了watch监视!也就是说,每次页面创建完成,都会触发watch,然后就会去修改window.location路径,然后页面被刷新,再次触发created钩子,又触发watch,周而复始,无限循环。
所以,我们需要在watch中进行监控,如果发现是第一次初始化,则不继续向下执行。
那么问题是,如何判断是不是第一次?
第一次初始化时,search中的key值肯定是空的,所以,我们这么做:
watch:{ search:{ deep:true, handler(val,old){ if(!old || !old.key){ // 如果旧的search值为空,或者search中的key为空,证明是第一次 return; } // 把search对象变成请求参数,拼接在url路径 window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val); } } }
再次刷新,OK了!
3.3.页面顶部分页条
在页面商品列表的顶部,也有一个分页条:
我们把这一部分,也加上点击事件:
4.排序(作业)
4.1.页面搜索排序条件
在搜索商品列表的顶部,有这么一部分内容:
这是用来做排序的,默认按照综合排序。点击新品,应该按照商品创建时间排序,点击价格应该按照价格排序。因为我们没有统计销量和评价,这里咱们以新品
和价格
为例,进行讲解,做法是想通的。
排序需要知道两个内容:
-
排序的字段
-
排序的方式
因此,我们首先在search
中记录这两个信息,因为created钩子函数会对search进行覆盖,因此我们在钩子函数中对这两个信息进行初始化即可:
然后,在页面上给按钮绑定点击事件,修改sortBy
和descending
的值:
<!--排序字段--> <ul class="sui-nav"> <li :class="{active:!search.sortBy}" @click="search.sortBy=''"> <a href="#">综合</a> </li> <li> <a href="#">销量</a> </li> <li @click="search.sortBy='createTime'" :class="{active: search.sortBy==='createTime'}"> <a href="#">新品</a> </li> <li> <a href="#">评价</a> </li> <li @click="search.sortBy='price'; search.descending = !search.descending" :class="{active: search.sortBy==='price'}"> <a href="#"> 价格 <v-icon v-show="search.descending">arrow_drop_down</v-icon> <v-icon v-show="!search.descending">arrow_drop_up</v-icon> </a> </li> </ul>
可以看到,页面请求参数中已经有了排序字段了:
4.2.后台添加排序逻辑
接下来,后台需要接收请求参数中的排序信息,然后在搜索中加入排序的逻辑。
现在,我们的请求参数对象SearchRequest
中,只有page、key两个字段。需要进行扩展:
然后在搜索业务逻辑中,添加排序条件:
注意,因为我们存储在索引库中的的价格是一个数组,因此在按照价格排序时,会进行智能处理:
-
如果是价格降序,则会把数组中的最大值拿来排序
-
如果是价格升序,则会把数组中的最小值拿来排序
第十五 rabbitmq及数据同步
0.学习目标
-
了解常见的MQ产品
-
了解RabbitMQ的5种消息模型
-
会使用Spring AMQP
-
利用MQ实现搜索和静态页的数据同步
1.RabbitMQ
1.1.搜索与商品服务的问题
目前我们已经完成了商品详情和搜索系统的开发。我们思考一下,是否存在问题?
-
商品的原始数据保存在数据库中,增删改查都在数据库中完成。
-
搜索服务数据来源是索引库,如果数据库商品发生变化,索引库数据不能及时更新。
-
商品详情做了页面静态化,静态页面数据也不会随着数据库商品发生变化。
如果我们在后台修改了商品的价格,搜索页面和商品详情页显示的依然是旧的价格,这样显然不对。该如何解决?
这里有两种解决方案:
-
方案1:每当后台对商品做增删改操作,同时要修改索引库数据及静态页面
-
方案2:搜索服务和商品页面服务对外提供操作接口,后台在商品增删改后,调用接口
以上两种方式都有同一个严重问题:就是代码耦合,后台服务中需要嵌入搜索和商品页面服务,违背了微服务的独立
原则。
所以,我们会通过另外一种方式来解决这个问题:消息队列
1.2.消息队列(MQ)
1.2.1.什么是消息队列
消息队列,即MQ,Message Queue。
消息队列是典型的:生产者、消费者模型。生产者不断向消息队列中生产消息,消费者不断的从队列中获取消息。因为消息的生产和消费都是异步的,而且只关心消息的发送和接收,没有业务逻辑的侵入,这样就实现了生产者和消费者的解耦。
结合前面所说的问题:
-
商品服务对商品增删改以后,无需去操作索引库或静态页面,只是发送一条消息,也不关心消息被谁接收。
-
搜索服务和静态页面服务接收消息,分别去处理索引库和静态页面。
如果以后有其它系统也依赖商品服务的数据,同样监听消息即可,商品服务无需任何代码修改。
1.3.下载和安装
1.3.1.下载
官网下载地址:Downloading and Installing RabbitMQ — RabbitMQ
目前最新版本是:3.7.5
我们的课程中使用的是:3.4.1版本
课前资料提供了安装包:
下载地址:
1.3.2.安装
详见课前资料中的:
0.安装文件准备
首先将课前资料提供的安装包上传到 /home/leyou/rabbit
目录:
这个是RabbitMQ的安装包:
1.安装Erlang
我们并没有提供Erlang安装包,直接采用yum仓库安装:
yum install esl-erlang_17.3-1~centos~6_amd64.rpm yum install esl-erlang-compat-R14B-1.el6.noarch.rpm
2.安装RabbitMQ
2.1.安装
进入文件所在目录:
cd /home/leyou/rabbit
然后输入命令:
rpm -ivh rabbitmq-server-3.4.1-1.noarch.rpm
2.2.修改配置文件
将配置文件模板复制到etc目录:
cp /usr/share/doc/rabbitmq-server-3.4.1/rabbitmq.config.example /etc/rabbitmq/rabbitmq.config
通过vim命令编辑:
vim /etc/rabbitmq/rabbitmq.config
修改下面内容:
注意要去掉后面的逗号
2.3.设置开机启动
输入下面命令
chkconfig rabbitmq-server on
2.4.启动命令
通过下面命令来控制RabbitMQ:
service rabbitmq-server start service rabbitmq-server stop service rabbitmq-server restart
2.5.开启web管理界面
RabbitMQ提供了用来管理的控制界面,十分方便,不过默认是关闭的。
我们通过命令开启web管理插件:
rabbitmq-plugins enable rabbitmq_management
然后重启RabbitMQ:
service rabbitmq-server restart
2.6.开放端口
RabbitMQ默认使用15672端口进行web访问,我们开启防火墙端口:
/sbin/iptables -I INPUT -p tcp --dport 15672 -j ACCEPT /etc/rc.d/init.d/iptables save
然后在主机中通过地址:http://192.168.56.101:15672即可访问到管理界面
3.管理界面介绍
第一次访问需要登录,默认的账号密码为:guest/guest
3.1.主页
-
connections:无论生产者还是消费者,都需要与RabbitMQ建立连接后才可以完成消息的生产和消费,在这里可以查看连接情况
-
channels:通道,建立连接后,会形成通道,消息的投递获取依赖通道。
-
Exchanges:交换机,用来实现消息的路由
-
Queues:队列,即消息队列,消息存放在队列中,等待消费,消费后被移除队列。
端口:
3.2.添加用户
上面的Tags选项,其实是指定用户的角色,可选的有以下几个:
-
超级管理员(administrator)
可登陆管理控制台,可查看所有的信息,并且可以对用户,策略(policy)进行操作。
-
监控者(monitoring)
可登陆管理控制台,同时可以查看rabbitmq节点的相关信息(进程数,内存使用情况,磁盘使用情况等)
-
策略制定者(policymaker)
可登陆管理控制台, 同时可以对policy进行管理。但无法查看节点的相关信息(上图红框标识的部分)。
-
普通管理者(management)
仅可登陆管理控制台,无法看到节点信息,也无法对策略进行管理。
-
其他
无法登陆管理控制台,通常就是普通的生产者和消费者。
3.3.创建虚拟主机(Virtual Hosts)
为了让各个用户可以互不干扰的工作,RabbitMQ添加了虚拟主机(Virtual Hosts)的概念。其实就是一个独立的访问路径,不同用户使用不同路径,各自有自己的队列、交换机,互相不会影响。
创建好虚拟主机,我们还要给用户添加访问权限:
点击添加好的虚拟主机:
进入虚拟主机设置界面: