项目结构
SpringBoot版本2.0.2.RELEASE
SpringCloud版本Finchley.SR4
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
服务都需要注册到`注册中心`,请求只能通过网关进行访问(上传文件除外 太大了)
<a name="ly-registry"></a>
### ly-registry
![image-20210614115440848.png](https://cdn.nlark.com/yuque/0/2021/png/1656653/1629790357944-5f1b0c13-202b-47a5-a45e-adb84f9e50d2.png#clientId=uf1f604a7-2306-4&from=drop&id=uf0d2ca8c&margin=%5Bobject%20Object%5D&name=image-20210614115440848.png&originHeight=273&originWidth=416&originalType=binary&ratio=1&size=56361&status=done&style=none&taskId=u3f892b92-b663-4252-a76a-bd3c6843567)
```java
<?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>ly-registry</artifactId>
<properties>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<!-- 解决JDK版本过高问题 -->
<!-- java.lang.TypeNotPresentException: Type javax.xml.bind.JAXBContext not present -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>
</project>
yml配置
server:
port: 10086
spring:
application:
name: ly-registry
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka/
register-with-eureka: false # 自己不注册
fetch-registry: false
# server:
# enable-self-preservation: false #关闭自我保护机制,开发环境使用,生成环境一定要关闭(true)
启动类EnableEurekaServer
@EnableEurekaServer
@SpringBootApplication
public class LyRegisterApplication {
public static void main(String[] args) {
SpringApplication.run(LyRegisterApplication.class,args);
}
}
ly-common
统一异常处理,通用Mapper的BaseMapper,工具类都放在这里面,不需要注册到注册中心
ly-item
包含两个服务
ly-item-interface
不需要注册到中心,包含item服务数据库pojo类,api(暴露部分ly-item-service
接口,提供给@FeignClient
使用)
需要在pom中导入相关的包(如 springmvc包)
public interface BrandApi {
@GetMapping("/brand/{id}")
Brand queryBrandById(@PathVariable("id") Long id);
@GetMapping("/brand/list")
List<Brand> queryBrandByIdList(@RequestParam("ids") List<Long> ids);
}
调用方把ly-item-interface
添加到pom,使用@FeignClient
注册
@FeignClient("item-service")
public interface BrandClient extends BrandApi {
}
ly-item-service
需要注册到注册中心,添加ly-item-interface
服务,上面的api包中类之所以没有添加@FeignClient
,是因为添加了就是自己服务引用自己了
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>ly-item</artifactId>
<groupId>com.leyou.service</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ly-item-service</artifactId>
<properties>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.service</groupId>
<artifactId>ly-item-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>ly-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
yml
server:
port: 8081
spring:
application:
name: item-service
datasource:
username: root
password: 123456
url: jdbc:mysql://localhost:3306/leyou?useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf-8
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka/
instance:
prefer-ip-address: true #设置eureka页面链接地址为ip地址
# instance-id: ${spring.application.name} #自定义列表名称
# ip-address: 127.0.0.1
#logging:
# level:
# cn.chy.mapper: debug
logging:
level:
com.leyou.item.mapper: debug
ly-gataway
需要注册到注册中心,所有服务请求通过网关访问,网关再到注册中心找到对应的服务(文件上传服务需要绕过网关)
如何找到:网关配置一个api前缀 http://localhost:10010/api
这就是请求网关服务,在网关服务中给其他服务添加前缀,如/item
,这样就是访问item服务http://localhost:10010/api/item
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.common</groupId>
<artifactId>ly-gateway</artifactId>
<properties>
</properties>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
yml
下面的 ribbon配置是由zuul才会有
server:
port: 10010
spring:
application:
name: api-gateway
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka/
instance:
prefer-ip-address: true #设置eureka页面链接地址为ip地址
# instance-id: ${spring.application.name} #自定义列表名称
# ip-address: 127.0.0.1
zuul:
prefix: /api #添加路由前缀
routes:
item-service: /item/** # item模块的访问都需要添加 /item
search-service: /search/**
upload-service:
path: /upload/**
serviceId: upload-service # false ,转发到 upload-service服务时候,会带上/upload前缀
stripPrefix: false # 确定在转发之前是否应 删除 此路由的前缀 ,默认为true
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 5000
ribbon:
ConnectionTimeout: 1000 # ribbon连接超时时长 abstractribboncommand 使用zuul才后有
ReadTimeout: 3500 # ribbon的读取超时
MaxAutoRetries: 0 # 当前服务的超时时间
MaxAutoRetriesNextServer: 0 # 切换服务重试次数
ly-upload
一个单独的文件上传服务,上传成功后返回文件地址给前台。
需要注册
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.service</groupId>
<artifactId>upload</artifactId>
<properties>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- 读取yml到java-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>ly-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
yml
server:
port: 8082
spring:
application:
name: upload-service
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka/
instance:
prefer-ip-address: true #设置eureka页面链接地址为ip地址
# instance-id: ${spring.application.name} #自定义列表名称
# ip-address: 127.0.0.1
fdfs:
so-timeout: 1501
connect-timeout: 601
thumb-image: # 缩略图
width: 60
height: 60
tracker-list: # tracker地址
- 192.168.32.129:22122
ly:
upload:
baseUrl: http://image.leyou.com/
allowTypes:
- image/png
- image/jpeg
- image/bmp
- image/gif
ly-search
使用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">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou</groupId>
<artifactId>ly-search</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.service</groupId>
<artifactId>ly-item-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
yml
server:
port: 8083
spring:
application:
name: search-service
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.32.129:9300
jackson:
default-property-inclusion: non_null # 为空的字段不返回
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka/
instance:
prefer-ip-address: true #设置eureka页面链接地址为ip地址
# instance-id: ${spring.application.name} #自定义列表名称
ip-address: 127.0.0.1
ElasticSearch
基本概念
Elasticsearch也是基于Lucene的全文检索库,本质也是存储数据,很多概念与MySQL类似的。
Near Realtime(NRT) 近实时。数据提交索引后,立马就可以搜索到。
Cluster 集群,一个集群由一个唯一的名字标识,默认为“elasticsearch”。集群名称非常重要,具有相同集群名的节点才会组成一个集群。集群名称可以在配置文件中指定。
Node 节点:存储集群的数据,参与集群的索引和搜索功能。像集群有名字,节点也有自己的名称,默认在启动时会以一个随机的UUID的前七个字符作为节点的名字,你可以为其指定任意的名字。通过集群名在网络中发现同伴组成集群。一个节点也可是集群。
Index 索引: 一个索引是一个文档的集合(等同于solr中的集合)。每个索引有唯一的名字,通过这个名字来操作它。一个集群中可以有任意多个索引。
Type 类型:指在一个索引中,可以索引不同类型的文档,如用户数据、博客数据。从6.0.0 版本起已废弃,一个索引中只存放一类数据。
Document 文档:被索引的一条数据,索引的基本信息单元,以JSON格式来表示。
Shard 分片:在创建一个索引时可以指定分成多少个分片来存储。每个分片本身也是一个功能完善且独立的“索引”,可以被放置在集群的任意节点上。
Replication 备份: 一个分片可以有多个备份(副本)
操作索引(库)
创建索引
创建索引的请求格式:
- 请求方式:PUT
- 请求路径:/索引库名
请求参数:json格式
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 2
}
}
- settings:索引库的设置
- number_of_shards:分片数量
- number_of_replicas:副本数量(单机情况副本设置为0)
测试:创建一个索引为 heima
PUT http://192.168.32.129:9200/heima
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 0
}
}
查看索引
可以显示字段和字段类型等…
GET /索引库名GET http://192.168.32.129:9200/heima
查看所以索引库配置
GET http://192.168.32.129:9200/*
3)删除索引库
DELETE /索引库名DELETE http://192.168.32.129:9200/heima
映射配置(表)
什么是映射?
映射是定义文档的过程,文档包含哪些字段,这些字段是否保存,是否索引,是否分词等
创建映射字段
PUT /索引库名/_mapping/类型名称
{
"properties": {
"字段名": {
"type": "类型",
"index": true,
"store": true,
"analyzer": "分词器"
}
}
}
- 类型名称:就是前面将的type的概念,类似于数据库中的不同表字段名:任意填写 ,可以指定许多属性,例如:
- type:类型,可以是text、long、short、date、integer、object等
- index:是否索引,默认为 true
- store:是否存储,默认为 false
- analyzer:分词器,这里的
ik_max_word
即使用ik分词器
测试:
PUT heima/_mapping/goods
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word"
},
"images": {
"type": "keyword",
"index": "false"
},
"price": {
"type": "float"
}
}
}
查看映射
GET /索引库名/_mapping
GET /heima/_mapping
响应: 索引(heima),映射(goods)
{
"heima": {
"mappings": {
"goods": {
"properties": {
"images": {
"type": "keyword",
"index": false
},
"price": {
"type": "float"
},
"title": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
}
}
映射字段解释
type
- String类型,又分两种:
- text:可分词,不可参与聚合
- keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
- Numerical:数值类型,分两类
- 基本数据类型:long、interger、short、byte、double、float、half_float
- 浮点数的高精度类型:scaled_float
- 需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
- Date:日期类型
elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间。
index
index影响字段的索引情况。
- true:字段会被索引,则可以用来进行搜索。默认值就是true
- false:字段不会被索引,不能用来搜索
index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。
但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false
store
是否将数据进行额外存储。
在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。
但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。
原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source
的属性中。而且我们可以通过过滤_source
来选择哪些要显示,哪些不显示。
而如果设置store为true,就会在_source
以外额外存储一
boost
激励因子,这个与lucene中一样
其它的不再一一讲解,用的不多,大家参考官方文档:
数据
添加数据
智能判断,没有的字段也能添加数据
随机生成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
}
查看
GET http://192.168.32.129:9200/heima/_search
或者
GET http://192.168.32.129:9200/heima/_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字段没有关联
自定义id
POST /索引库名/类型/id值{ ...}
示例:
POST http://192.168.32.129:9200/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
}
}
添加数据时,有个智能判断,就是说 添加时使用没有配置映射的字段一样能够成功,它也可以根据你输入的数据来判断类型,动态添加数据映射。
只添加了 字段 title,images,price
POST http://192.168.32.129:9200/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都被成功映射了。
修改数据
把刚才新增的请求方式改为PUT,就是修改了。不过修改必须指定id,
- id对应文档存在,则修改
- id对应文档不存在,则新增
比如,我们把id为3的数据进行修改:
PUT http://192.168.32.129:9200/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
}
}
]
}
}
删除数据
删除使用DELETE请求,同样,需要根据id进行删除:
语法
DELETE /索引库名/类型名/id值
DELETE http://192.168.32.129:9200/heima/goods/3
功能
ES6
let 和 const 命令
var
之前,js定义变量只有一个关键字:var
var
有一个问题,就是定义的变量有时会莫名奇妙的成为全局变量。
例如这样的一段代码:
for(var i = 0; i < 5; i++){
console.log(i);
}
console.log("循环外:" + i)
你猜下打印的结果是什么?
let
let
所声明的变量,只在let
命令所在的代码块内有效。
我们把刚才的var
改成let
试试:
for(let i = 0; i < 5; i++){ console.log(i);}console.log("循环外:" + i)
结果:
const
const
声明的变量是常量,不能被修改
字符串扩展
新的API
ES6为字符串扩展了几个新的API:
includes()
:返回布尔值,表示是否找到了参数字符串。startsWith()
:返回布尔值,表示参数字符串是否在原字符串的头部。endsWith()
:返回布尔值,表示参数字符串是否在原字符串的尾部。
实验一下:
字符串模板
ES6中提供了`来作为字符串模板标记。我们可以这么玩:
在两个`之间的部分都会被作为字符串的值,不管你任意换行,甚至加入js脚本
解构表达式
数组解构
比如有一个数组:
let arr = [1,2,3]
我想获取其中的值,只能通过角标。ES6可以这样:
const [x,y,z] = arr;// x,y,z将与arr中的每个位置对应来取值
// 然后打印
console.log(x,y,z);
结果:
对象解构
例如有个person对象:
const person = {
name:"jack",
age:21,
language: ['java','js','css']
}
我们可以这么做:
// 解构表达式获取值
const {name,age,language} = person;
// 打印
console.log(name);
console.log(age);
console.log(language);
结果:
如过想要用其它变量接收,需要额外指定别名:
{name:n}
:name是person中的属性名,冒号后面的n是解构后要赋值给的变量。
函数优化
函数参数默认值
在ES6以前,我们无法给一个函数参数设置默认值,只能采用变通写法:
function add(a , b) {
// 判断b是否为空,为空就给默认值1
b = b || 1;
return a + b;
}
// 传一个参数
console.log(add(10));
现在可以这么写:
function add(a , b = 1) {
return a + b;
}
// 传一个参数
console.log(add(10));
箭头函数
ES6中定义函数的简写方式:
一个参数时:
var print = function (obj) {
console.log(obj);
}
// 简写为:
var print2 = obj => console.log(obj);
多个参数:
// 两个参数的情况:
var sum = function (a , b) {
return a + b;
}
// 简写为:
var sum2 = (a,b) => a+b;
代码不止一行,可以用{}
括起来
var sum3 = (a,b) => {
return a + b;
}
对象的函数属性简写
比如一个Person对象,里面有eat方法:
let person = {
name: "jack",
// 以前:
eat: function (food) {
console.log(this.name + "在吃" + food);
},
// 箭头函数版:
eat2: food => console.log(person.name + "在吃" + food),// 这里拿不到this
// 简写版:
eat3(food){
console.log(this.name + "在吃" + food);
}
}
箭头函数结合解构表达式
比如有一个函数:
const person = {
name:"jack",
age:21,
language: ['java','js','css']
}
function hello(person) {
console.log("hello," + person.name)
}
如果用箭头函数和解构表达式
var hi = ({name}) => console.log("hello," + name);
map和reduce
数组中新增了map和reduce方法。
map
map()
:接收一个函数,将原数组中的所有元素用这个函数处理后放入新数组返回。
举例:有一个字符串数组,我们希望转为int数组
let arr = ['1','20','-5','3'];
console.log(arr)
arr = arr.map(s => parseInt(s));
console.log(arr)
reduce
reduce()
:接收一个函数(必须)和一个初始值(可选)。
第一个参数(函数)接收两个参数:
- 第一个参数是上一次reduce处理的结果
- 第二个参数是数组中要处理的下一个元素
reduce()
会从左到右依次把数组中的元素用reduce处理,并把处理的结果作为下次reduce的第一个参数。如果是第一次,会把前两个元素作为计算参数,或者把用户指定的初始值作为起始参数
举例:
const arr = [1,20,-5,3]
没有初始值:
指定初始值:
对象扩展
ES6给Object拓展了许多新的方法,如:
- keys(obj):获取对象的所有key形成的数组
- values(obj):获取对象的所有value形成的数组
- entries(obj):获取对象的所有key和value形成的二维数组。格式:
[[k1,v1],[k2,v2],...]
- assign(dest, …src) :将多个src对象的值 拷贝到 dest中(浅拷贝)。
数组扩展
ES6给数组新增了许多方法:
- find(callback):数组实例的find方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined。
- findIndex(callback):数组实例的findIndex方法的用法与find方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1。
- includes(数组元素):与find类似,如果匹配到元素,则返回true,代表找到了。
Vue简单分页
v-for="i in Math.min(5,totalPage)"
,遍历5个页码,总页数小于5就遍历总页数
index(i)
:计算页码
search.page = index(i)
:点击后把页码赋值给当前页码
<div class="fr">
<div class="sui-pagination pagination-large">
<ul>
<li :class="{prev:true,disabled:search.page===1}">
<a href="#" @click.prevent.stop="prePage()">«上一页</a>
</li>
<li :class="{active:index(i)===search.page}" v-for="i in Math.min(5,totalPage)">
<a href="#" @click.prevent.stop="search.page = index(i)" v-text="index(i)"></a>
</li>
<li class="dotted" v-show="totalPage-search.page>2 && totalPage> 5"><span>...</span></li>
<li :class="{next:true,disabled:search.page===totalPage}">
<a href="#"
@click.prevent.stop="nextPage()">下一页»</a>
</li>
</ul>
<div><span>共{{totalPage}}页 </span><span>
到第
<input type="text" class="page-num">
页 <button class="page-confirm" onclick="alert(1)">确定</button></span></div>
</div>
</div>
vue
prePage()
:上一页
nextPage()
:下一页
index(i)()
:下一页
search
:里面包含,当前页(search.page
)和搜索关键字(search.key
,是从头部传递过来的)
输入关键字一搜索,跳转到search.html(window.location = 'search.html?key=' + this.key;
),search.html一来构建search对象加载
const search = ly.parse(location.search.substring(1))
search.page = parseInt(search.page) || 1
this.search = search
ly是封装的
var vm = new Vue({
el: "#searchApp",
data: {
ly,
search: {},
total: 0,
totalPage: 0,
goodsList: [],
filters: [],
showMore: false
},
components: {
lyTop: () => import("./js/pages/top.js")
},
// 这是为了刷新页面时,继续显示刷新前的页面数据
watch: {
search: {
deep: true,
handler(val, oldVal) {
// 第一次加载search的时候里面是空的,就不加载 (如果search为空 或者create在初始化就返回)
if (!oldVal || !oldVal.key) {
return;
}
location.search = "?" + ly.stringify(this.search) // location.search 发生改变 页面会自动 刷新浏览器
}
}
},
created() {
const search = ly.parse(location.search.substring(1))
search.page = parseInt(search.page) || 1
this.search = search
this.loadDate();
},
methods: {
loadDate() {
ly.http.post("/search/page", this.search).then(resp => {
this.total = resp.data.total
this.totalPage = resp.data.totalPage
resp.data.items.forEach(goods => {
goods.skus = JSON.parse(goods.skus)
goods.selectedSku = goods.skus[0] //提供选择
});
this.goodsList = resp.data.items
//分类
this.filters.push({
key: 'cid3',
options: resp.data.categories
})
//品牌
this.filters.push({
key: 'brandId',
options: resp.data.brands
})
//规格参数
resp.data.specs.forEach(spec => {
this.filters.push(spec)
})
}).catch(error => {
console.log(error)
})
},
//-------------------------------
prePage() {
//当前页大于1
if (this.search.page > 1) this.search.page--
},
nextPage() {
//当前页小于总页数
if (this.search.page < this.totalPage) this.search.page++
},
//计算页码
index(i) {
if (this.search.page <= 3 || this.totalPage <= 5) { // 页首 [123]45
return i
} else if (this.search.page >= this.totalPage - 2) { // 页尾 67 [8 9 10]
return i + this.totalPage - 5
} else {
return i + this.search.page - 3 // 页中 23456
}
}
},
});
RabbitMQ
为什么要用它:发送消息!商品信息发生增删改通知search服务和page服务操作,还有发送短信
添加,修改商品发送通知
- 添加(添加对应的index、添加对应的网页),修改(修改对应的index、根据spuId文件后,创建新的文件)
删除商品
- 删除索引和page页面
saveGoods
发送消息
amqpTemplate.convertAndSend("item.insert",spu.getId());
page服务匹配 item.update
,item.insert
@Component
public class PageListener {
@Autowired
private PageService pageService;
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(value = "page.item.insert.queue", durable = "true"),
exchange = @Exchange(value = "ly.item.exchange",type = ExchangeTypes.TOPIC),
key = {"item.update","item.insert"}
)
)
public void insertAndUpdatePage(Long spuId){
if(spuId==null){
return;
}
pageService.createHtml(spuId);
}
}
Search服务 同样匹配item.update
,item.insert
@Component
public class GoodsListener {
@Autowired
private SearchService searchService;
@Autowired
private GoodsRepository goodsRepository;
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(value = "index.item.insert.queue", durable = "true"),
exchange = @Exchange(value = "ly.item.exchange",type = ExchangeTypes.TOPIC),
key = {"item.update","item.insert"}
)
)
public void insertAndUpdateIndex(Long spuId){
if(spuId==null){
return;
}
Spu spu = searchService.querySpuById(spuId);
Goods goods = searchService.buildGoods(spu);
goodsRepository.save(goods);
}
}
Nginx
本地需要配置hosts,把域名请求转发到 本地
反向代理配置
示例:
nginx 中的每个server就是一个反向代理配置,可以有多个server
server {
listen 80;
server_name 192.168.32.129;
location / {
root /leyou/static/; # 访问这个目录下的
index index.html index.htm; # 默认访问页面
}
}
配置上传服务绕过网关
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;
proxy_set_header Host $host; # 转发后还是携带自身的host(api.leyou.com),而不要用本机ip(30.40.65.40)
# 上传路径的映射
location /api/upload {
proxy_pass http://192.168.1.103:8082;
proxy_connect_timeout 600;
proxy_read_timeout 600;
rewrite "^/api/(.*)$" /$1 break; # $1就是匹配前面的一组(小括号)
}
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
命令重新加载配置。
nginx代理静态页面
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 /item {
# 先找本地
root html;
if (!-f $request_filename) { #请求的文件不存在,就反向代理
proxy_pass http://127.0.0.1:8084;
break;
}
}
location / {
proxy_pass http://127.0.0.1:9002;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
完整配置
# user nginx;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
client_max_body_size 100M;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
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://192.168.1.103:9001;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
# 前端:前台页面
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 /item {
# proxy_pass http://192.168.1.103:8084;
# proxy_connect_timeout 600;
# proxy_read_timeout 600;
# }
# 先找本地
location /item {
root html;
if (!-f $request_filename) { #请求的文件不存在,就反向代理
proxy_pass http://192.168.1.103:8084;
break;
}
}
location / {
proxy_pass http://192.168.1.103:9002;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
server {
listen 80;
server_name manage.leyou.com;
location / {
proxy_pass http://192.168.1.103:9001;
}
}
# 后端接口
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;
proxy_set_header Host $host; # 转发后还是携带自身的host(api.leyou.com),而不要用本机ip(30.40.65.40)
# 上传
location /api/upload {
proxy_pass http://192.168.1.103:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
rewrite "^/(.*)$" /zuul/$1 break;
}
# api入口(zuul网关)
location / {
proxy_pass http://192.168.1.103:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
# 图片服务器
server {
listen 80;
server_name image.leyou.com;
# 监听域名中带有group的,交给FastDFS模块处理
location ~/group([0-9])/ {
ngx_fastdfs_module;
}
# 将其它图片代理指向本地的/leyou/static目录,/leyou/static已经挂载到fastdfs内部
location / {
root /leyou/static/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
server {
listen 80;
server_name 192.168.32.129;
location / {
root /leyou/static/;
index index.html index.htm;
}
}
}
fastDFS
<!-- fastDFS客户端-->
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
<version>1.26.2</version>
</dependency>
<!-- 加载yml中的配置使用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency
添加一个配置文件,解决jmx重复注册bean的问题
@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
}
测试类
@RunWith(SpringRunner.class)
@SpringBootTest
public class FdfsTest {
@Autowired
private FastFileStorageClient storageClient;
@Autowired
private ThumbImageConfig thumbImageConfig;
// 上传文件
@Test
public void testUpload() throws FileNotFoundException {
File file = new File("G:\\image\\simpledesktops.png");
// 上传并且生成缩略图
StorePath storePath = this.storageClient.uploadFile(
new FileInputStream(file), file.length(), "jpg", null);
// 带分组的路径
System.out.println(storePath.getFullPath());
// 不带分组的路径
System.out.println(storePath.getPath());
System.out.println("========================");
System.out.println("========================");
}
// 上传文件并获取缩略图
@Test
public void testUploadAndCreateThumb() throws FileNotFoundException {
File file = new File("G:\\image\\simpledesktops.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);
}
}
application.yml 添加
fdfs:
so-timeout: 1501
connect-timeout: 601
thumb-image: # 缩略图
width: 60
height: 60
tracker-list: # tracker地址
- 192.168.32.129:22122
ly:
upload:
baseUrl: http://image.leyou.com/
allowTypes:
- image/png
- image/jpeg
- image/bmp
- image/gif
读取配置
@Component
@ConfigurationProperties(prefix = "ly.upload")
@Data
public class UploadProperties {
private String baseUrl;
private List<String> allowTypes;
}
上传服务
package com.leyou.upload.service;
import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import com.leyou.enums.ExceptionEnum;
import com.leyou.exception.LyException;
import com.leyou.upload.config.UploadProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* @author chy
* @since 2021-05-12 17:48
*/
@Slf4j
@Service
//@EnableConfigurationProperties(UploadService.class)
public class UploadService {
@Autowired
private FastFileStorageClient storageClient;
// 自己编写的上传文件配置 包含上传地址 和 允许类型
@Autowired
private UploadProperties uploadProperties;
public String uploadImage(MultipartFile file) {
try {
//校验是否包含文件类型
if (!uploadProperties.getAllowTypes().contains(file.getContentType())) {
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}
//校验文件内容
BufferedImage read = ImageIO.read(file.getInputStream());
if (read == null) {
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}
//校验通过 上传到 fastDFS
//文件后缀
String suffix = StringUtils.substringAfterLast(file.getOriginalFilename(), ".");
StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(), suffix, null);
log.info("上传到的地址:{}", uploadProperties.getBaseUrl() + storePath.getFullPath());
return uploadProperties.getBaseUrl() + storePath.getFullPath();
} catch (Exception e) {
log.error("【文件上传】文件上传失败:{}", e);
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}
}
}
通用Mapper使用PageHelper
通用Mapper和PageHelper pom
<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>
启动类添加
//import tk.mybatis.spring.annotation.MapperScan
@MapperScan("com.leyou.item.mapper") ;
查询代码
@Autowired
private BrandMapper brandMapper;
// page: 当前页码
// rows: 显示大小
// sortBy: 排序字段
// desc: 是否倒序
// key: 搜索关键字
public PageResult<Brand> queryBrandByPage(Integer page, Integer rows, String sortBy , Boolean desc, String key) {
//分页
PageHelper.startPage(page, rows);
/**
* 过滤
* where `name` like '%x%' or letter =='x'
* order by id desc
*/
Example example = new Example(Brand.class);
if(StringUtils.isNoneBlank(key)){
example.createCriteria().andLike("name", "%" + key + "%")
.orEqualTo("letter",key.toUpperCase());
}
//排序
if(StringUtils.isNoneBlank(sortBy)){
example.setOrderByClause(sortBy + (desc ? " DESC":" ASC"));
}
List<Brand> brands = brandMapper.selectByExample(example);
if(CollectionUtils.isEmpty(brands)){
throw new LyException(ExceptionEnum.BRAND_NO_FOUND);
}
PageInfo<Brand> pageInfo = new PageInfo<>(brands);
return new PageResult<Brand>(pageInfo.getTotal(),pageInfo.getList());
}
解决跨域
@Configuration
public class GateWayCorsConfig {
@Bean
public CorsFilter corsFilter() {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin("http://www.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("*");
// 5)有效时长
config.setMaxAge(3600L);
//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
//3.返回新的CorsFilter.
return new CorsFilter(configSource);
}
}
页面静态化
就是直接产生html页面,然后把页面放在nginx下面
@Autowired
private TemplateEngine templateEngine;
public void createHtml(Long spuId){
//上下文
Context context = new Context();
//数据
context.setVariables(loadModel(spuId));
//输出流
File dest = new File("G:\\java\\SpringCloud\\html",spuId+ ".html");
if(dest.exists()){
dest.delete();
}
try( PrintWriter writer = new PrintWriter(dest,"UTF-8")) {
templateEngine.process("item",context,writer);
} catch (Exception e) {
log.info("【静态页服务】文生成静态页异常!{}",e);
e.printStackTrace();
}
}
public void deleteHtml(Long spuId){
File dest = new File("G:\\java\\SpringCloud\\html",spuId+ ".html");
if(dest.exists()){
dest.delete();
}
}
Cookie相关
前台界面,访问需要用户信息的服务时(如购物车),每次请求之前都需要先发送一次校验请求,目的是刷新token
解决host地址的变化
那么问题来了:为什么我们这里的请求serverName变成了:127.0.0.1:8087呢?
这里的server name其实就是请求时的主机名:Host,之所以改变,有两个原因:
- 我们使用了nginx反向代理,当监听到api.leyou.com的时候,会自动将请求转发至127.0.0.1:10010,即Zuul。
- 而后请求到达我们的网关Zuul,Zuul就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了 127.0.0.1:8087 ,即我们的授权中心。
我们首先去更改nginx配置,让它不要修改我们的host:proxy_set_header Host $host;
zuul配置add-host-header: true sensitive-headers:
无内容
zuul:
prefix: /api #添加路由前缀
routes:
item-service: /item/** # item模块的访问都需要添加 /item
search-service: /search/** # 搜索微服务
user-service: /user/**
auth-service: /auth/**
add-host-header: true #添加host头信息
sensitive-headers: #配置禁止使用的头信息,这里设置为null,否则set-cookie无效
登录成功返回token,写入到cookie,设置cookie有效期cookie.setMaxAge();
主要是这个两个
cookie.setDomain("leyou.com");
cookie.setPath("/");
以后每次访问前,都需要发送一个请求来刷新token
品牌分类
商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,其关系如图所示:
- 一个商分类下有很多商品
- 一个商品分类下有很多品牌
- 而一个品牌,可能属于不同的分类
- 一个品牌下也会有很多商品
因此,我们需要依次去完成:商品分类、品牌、商品的开发。
商品规格参数
表结构
我们看下规格参数的格式:
可以看到规格参数是分组的,每一组都有多个参数键值对。不过对于规格参数的模板而言,其值现在是不确定的,不同的商品值肯定不同,模板中只要保存组信息、组内参数信息即可。
因此我们设计了两张表:
- tb_spec_group:组,与商品分类关联
- tb_spec_param:参数名,与组关联,一对多
规格组
规格参数分组表:tb_spec_group
CREATE TABLE `tb_spec_group` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
`name` varchar(50) NOT NULL COMMENT '规格组的名称',
PRIMARY KEY (`id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';
规格组有3个字段:
- id:主键
- cid:商品分类id,一个分类下有多个模板
- name:该规格组的名称。
规格参数
规格参数表:tb_spec_param
CREATE TABLE `tb_spec_param` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id',
`group_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL COMMENT '参数名',
`numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
`unit` varchar(255) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
`generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
`searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
`segments` varchar(1000) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
PRIMARY KEY (`id`),
KEY `key_group` (`group_id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';
按道理来说,我们的规格参数就只需要记录参数名、组id、商品分类id即可。但是这里却多出了很多字段,为什么?
还记得我们之前的分析吧,规格参数中有一部分是 SKU的通用属性,一部分是SKU的特有属性,而且其中会有一些将来用作搜索过滤,这些信息都需要标记出来。
通用属性
用一个布尔类型字段来标记是否为通用:
- generic来标记是否为通用属性:
- true:代表通用属性
- false:代表sku特有属性
搜索过滤
与搜索相关的有两个字段:
- searching:标记是否用作过滤
- true:用于过滤搜索
- false:不用于过滤
- segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
- 比如电池容量,0~2000mAh,2000mAh 3000mAh,3000mAh~4000mAh
数值类型
某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:
- numberic:是否为数值类型
- true:数值类型
- false:不是数值类型
- unit:参数的单位
SPU和SKU数据结构(重点)
规格确定以后,就可以添加商品了,先看下数据库表
SPU表
SPU表:
CREATE TABLE `tb_spu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`sub_title` varchar(255) DEFAULT '' COMMENT '子标题',
`cid1` bigint(20) NOT NULL COMMENT '1级类目id',
`cid2` bigint(20) NOT NULL COMMENT '2级类目id',
`cid3` bigint(20) NOT NULL COMMENT '3级类目id',
`brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
`saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
`valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
`create_time` datetime DEFAULT NULL COMMENT '添加时间',
`last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';
与我们前面分析的基本类似,但是似乎少了一些字段,比如商品描述。
我们做了表的垂直拆分,将SPU的详情放到了另一张表:tb_spu_detail
CREATE TABLE `tb_spu_detail` (
`spu_id` bigint(20) NOT NULL,
`description` text COMMENT '商品描述信息',
`generic_spec` varchar(10000) NOT NULL DEFAULT '' COMMENT '通用规格参数数据',
`special_spec` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
`packing_list` varchar(3000) DEFAULT '' COMMENT '包装清单',
`after_service` varchar(3000) DEFAULT '' COMMENT '售后服务',
PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
这张表中的数据都比较大,为了不影响主表的查询效率我们拆分出这张表。
需要注意的是这两个字段:generic_spec和special_spec。
前面讲过规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数信息,因此我们计划是这样:
- SPUDetail中保存通用的规格参数信息。
- SKU中保存特有规格参数。
来看下我们的表如何存储这些信息。
generic_spec字段
首先是generic_spec
,其中保存通用规格参数信息的值,这里为了方便查询,使用了json格式:
整体来看:
json结构,其中都是键值对:
- key:对应的规格参数的
spec_param
的id - value:对应规格参数的值
special_spec字段
注:为了搜索完成后显示
我们说spu中只保存通用规格参数,那么为什么有多出了一个special_spec
字段呢?
以手机为例,品牌、操作系统等肯定是全局通用属性,内存、颜色等肯定是特有属性。
当你确定了一个SPU,比如小米的:红米4X
全局属性值都是固定的了:
品牌:小米
型号:红米4X
特有属性举例:
颜色:[香槟金, 樱花粉, 磨砂黑]
内存:[2G, 3G]
机身存储:[16GB, 32GB]
颜色、内存、机身存储,作为SKU特有属性,key虽然一样,但是SPU下的每一个SKU,其值都不一样,所以值会有很多,形成数组。
我们在SPU中,会把特有属性的所有值都记录下来,形成一个数组:
里面又有哪些内容呢?
来看数据格式:
也是json结构:
- key:规格参数id
- value:spu属性的数组
那么问题来:特有规格参数应该在sku中记录才对,为什么在spu中也要记录一份?
因为我们有时候需要把所有规格参数都查询出来,而不是只查询1个sku的属性。比如,商品详情页展示可选的规格参数时:
刚好符合我们的结构,这样页面渲染就非常方便了。
SKU表
CREATE TABLE `tb_sku` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'sku id',
`spu_id` bigint(20) NOT NULL COMMENT 'spu id',
`title` varchar(255) NOT NULL COMMENT '商品标题',
`images` varchar(1000) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
`price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
`indexes` varchar(100) COMMENT '特有规格属性在spu属性模板中的对应下标组合',
`own_spec` varchar(1000) COMMENT 'sku的特有规格参数,json格式',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
`create_time` datetime NOT NULL COMMENT '添加时间',
`last_update_time` datetime NOT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`),
KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';
还有一张表,代表库存:
CREATE TABLE `tb_stock` (
`sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
`seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
`seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
`stock` int(9) NOT NULL COMMENT '库存数量',
PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';
问题:为什么要将库存独立一张表?
因为库存字段写频率较高,而SKU的其它字段以读为主,因此我们将两张表分离,读写不会干扰。
特别需要注意的是sku表中的indexes
字段和own_spec
字段。sku中应该保存特有规格参数的值,就在这两个字段中。
indexes字段
注:就只是为了选择时候定位
在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下:
{
"4": [
"香槟金",
"樱花粉",
"磨砂黑"
],
"12": [
"2GB",
"3GB"
],
"13": [
"16GB",
"32GB"
]
}
这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。
比如:
- 红米4X,香槟金,2GB内存,16GB存储
- 红米4X,磨砂黑,2GB内存,32GB存储
你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:
- 红米4X,0,0,0
- 红米4X,2,0,1
既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。
这个设计在商品详情页会特别有用:
当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。
own_spec字段
看结构:
{ "4":"香槟金","12":"2GB","13":"16GB"}
保存的是特有属性的键值对。
SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的值。
购物车
1.实现未登录状态的购物车
2.实现登陆状态下的购物车
流程图
这幅图主要描述了两个功能:新增商品到购物车、查询购物车。
新增商品:
- 判断是否登录
- 是:则添加商品到后台Redis中
- 否:则添加商品到本地的Localstorage
无论哪种新增,完成后都需要查询购物车列表:
- 判断是否登录
- 否:直接查询localstorage中数据并展示
- 是:已登录,则需要先看本地是否有数据,
- 有:需要提交到后台添加到redis,合并数据,而后查询
- 否:直接去后台查询redis,而后返回
添加购物车:
先发送一个axios请求去获取用户信息(判断是否登录)
已登陆
添加商品到后台Redis中
我们要知道是谁发送的请求,才能在Redis中获取数据,编写一个拦截器拦截所有请求,从中获取cookie中token内的用户信息,存入threadLocal(每次请求完成一定要释放threadLocal)
@Autowired
private StringRedisTemplate redisTemplate;
public static final String prefix = "cart:user:id:";
public void addCart(Cart cart) {
UserInfo userInfo = ThreadUtils.get();
String key = prefix + userInfo.getId();
String skuIdKey = cart.getSkuId().toString();
//获取加入的数量
int num = cart.getNum();
//另一种操作hashkey的api
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(key);
//如果存在这个商品 就在原来的基础上加数量
if (operations.hasKey(skuIdKey)) {
String jsonCart = Objects.requireNonNull(operations.get(skuIdKey)).toString();
cart = JsonUtils.parse(jsonCart, Cart.class);
cart.setNum(cart.getNum() + num);
}
operations.put(skuIdKey, JsonUtils.serialize(cart));
}
未登录
添加商品到本地的Localstorage
获取localStorage中的购物车(可能为空,空就设置为[]),判断是否有新添加的商品,有就添加数量,没有就添加商品
// 获取以前的购物车
const carts = ly.store.get("LY_CART") || [];
// 获取与当前商品id一致的购物车数据
const cart = carts.find(c => c.skuId === this.sku.id);
if (cart) {
// 存在,修改数量
cart.num += this.num;
} else {
// 不存在,新增
carts.push({
skuId: this.sku.id,
title: this.sku.title,
image: this.images[0],
price: this.sku.price,
num: this.num,
ownSpec: JSON.stringify(this.ownSpec)
})
}
// 未登录
ly.store.set("LY_CART", carts);
// 跳转到购物车列表页
window.location.href = "http://www.leyou.com/cart.html";
查询购物车
同样发送一个axios请求去获取用户信息(判断是否登录)
已登录
获取用户信息,然后在redis中进行查询
public List<Cart> list() {
UserInfo userInfo = ThreadUtils.get();
String key = prefix + userInfo.getId();
//如果没有这个key
if (!redisTemplate.hasKey(key)) {
throw new LyException(ExceptionEnum.CART_NOT_FOUND);
}
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(key);
return operations.values().stream().map(o -> JsonUtils.parse(o.toString(), Cart.class)).collect(Collectors.toList());
}
未登录
从localStorage取出商品信息进行显示
登录后购物车合并(包括了查询)
当跳转到购物车页面,查询购物车列表前,需要判断用户登录状态,
- 如果登录:
- 首先检查用户的LocalStorage中是否有购物车信息,
- 如果有,则提交到后台保存,
- 清空LocalStorage
- 查询购物车
- 查询商品列表(主要是提示价格变化)
- 如果未登录,直接查询即可
工具
雪花算法
public class IdWorker {
// 时间起始标记点,作为基准,一般取系统的最近时间(一旦确定不能变动)
private final static long twepoch = 1288834974657L;
// 机器标识位数
private final static long workerIdBits = 5L;
// 数据中心标识位数
private final static long datacenterIdBits = 5L;
// 机器ID最大值
private final static long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 数据中心ID最大值
private final static long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
// 毫秒内自增位
private final static long sequenceBits = 12L;
// 机器ID偏左移12位
private final static long workerIdShift = sequenceBits;
// 数据中心ID左移17位
private final static long datacenterIdShift = sequenceBits + workerIdBits;
// 时间毫秒左移22位
private final static long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private final static long sequenceMask = -1L ^ (-1L << sequenceBits);
/* 上次生产id时间戳 */
private static long lastTimestamp = -1L;
// 0,并发控制
private long sequence = 0L;
private final long workerId;
// 数据标识id部分
private final long datacenterId;
public IdWorker(){
this.datacenterId = getDatacenterId(maxDatacenterId);
this.workerId = getMaxWorkerId(datacenterId, maxWorkerId);
}
/**
* @param workerId
* 工作机器ID
* @param datacenterId
* 序列号
*/
public IdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 获取下一个ID
*
* @return
*/
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp) {
// 当前毫秒内,则+1
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 当前毫秒内计数满了,则等待下一秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// ID偏移组合生成最终的ID,并返回ID
long nextId = ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift) | sequence;
return nextId;
}
private long tilNextMillis(final long lastTimestamp) {
long timestamp = this.timeGen();
while (timestamp <= lastTimestamp) {
timestamp = this.timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
/**
* <p>
* 获取 maxWorkerId
* </p>
*/
protected static long getMaxWorkerId(long datacenterId, long maxWorkerId) {
StringBuffer mpid = new StringBuffer();
mpid.append(datacenterId);
String name = ManagementFactory.getRuntimeMXBean().getName();
if (!name.isEmpty()) {
/*
* GET jvmPid
*/
mpid.append(name.split("@")[0]);
}
/*
* MAC + PID 的 hashcode 获取16个低位
*/
return (mpid.toString().hashCode() & 0xffff) % (maxWorkerId + 1);
}
/**
* <p>
* 数据标识id部分
* </p>
*/
protected static long getDatacenterId(long maxDatacenterId) {
long id = 0L;
try {
InetAddress ip = InetAddress.getLocalHost();
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
if (network == null) {
id = 1L;
} else {
byte[] mac = network.getHardwareAddress();
id = ((0x000000FF & (long) mac[mac.length - 1])
| (0x0000FF00 & (((long) mac[mac.length - 2]) << 8))) >> 6;
id = id % (maxDatacenterId + 1);
}
} catch (Exception e) {
System.out.println(" getDatacenterId: " + e.getMessage());
}
return id;
}
}
yml
ly:
worker:
workerId: 1
datacenterId: 1
IdWorkerProperties
@Component
@Data
@ConfigurationProperties(prefix = "ly.worker")
public class IdWorkerProperties {
private long workerId;// 当前机器id
private long datacenterId;// 序列号
}
IdWorkerConfig
@Configuration
public class IdWorkerConfig {
@Bean
public IdWorker idWorker(IdWorkerProperties prop) {
return new IdWorker(prop.getWorkerId(), prop.getDatacenterId());
}
}
使用
@Autowired
private IdWorker idWorker;
Long orderId = idWorker.nextId();
JsonUtil
public class JsonUtils {
public static final ObjectMapper mapper = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(JsonUtils.class);
public static String serialize(Object obj) {
if (obj == null) {
return null;
}
if (obj.getClass() == String.class) {
return (String) obj;
}
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
logger.error("json序列化出错:" + obj, e);
return null;
}
}
public static <T> T parse(String json, Class<T> tClass) {
try {
return mapper.readValue(json, tClass);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <E> List<E> parseList(String json, Class<E> eClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructCollectionType(List.class, eClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <K, V> Map<K, V> parseMap(String json, Class<K> kClass, Class<V> vClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructMapType(Map.class, kClass, vClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <T> T nativeRead(String json, TypeReference<T> type) {
try {
return mapper.readValue(json, type);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
}
使用
// 获取通用规格参数
Map<Long, String> genericMap = JsonUtils.parseMap(spuDetail.getGenericSpec(), Long.class, String.class);
// 获取特有规格参数
Map<Long, List<String>> specialMap = JsonUtils.nativeRead(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {
});