乐忧商城
6.商品分类
6.1 搭建后台管理的前端页面
webpack:是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。并且提供了前端项目的热部署插件。
我们最主要理清index.html、main.js、App.vue之间的关系(这部分属于前端,目前能看懂会用就行):
- index.html:html模板文件。定义了空的div,其id为app。
- main.js:实例化vue对象,并且通过id选择器绑定到index.html的div中,因此main.js的内容都将在index.html的div中显示。main.js中使用了App组件,即App.vue,也就是说index.html中最终展现的是App.vue中的内容。index.html引用它之后,就拥有了vue的内容(包括组件、样式等),所以,main.js也是webpack打包的入口。
- index.js:定义请求路径和组件的映射关系。相当于之前的
- App.vue中也没有内容,而是定义了vue-router的锚点:,我们之前讲过,vue-router路由后的组件将会在锚点展示。
- 最终结论:一切路由后的内容都将通过App.vue在index.html中显示。
- 访问流程:用户在浏览器输入路径,例如:http://localhost:9001/#/item/brand --> index.js(/item/brand路径对应pages/item/Brand.vue组件) --> 该组件显示在App.vue的锚点位置 --> main.js使用了App.vue组件,并把该组件渲染在index.html文件中(id为“app”的div中)
6.2 Vuetify框架
Vue虽然会帮我们进行视图的渲染,但样式还是由我们自己来完成。这显然不是我们的强项,因此后端开发人员一般都喜欢使用一些现成的UI组件,拿来即用,常见的例如:
- BootStrap(基于Jquery,之前写第一个的时候用过)
- LayUI
- EasyUI
- ZUI
然而这些UI组件的基因天生与Vue不合,因为他们更多的是利用DOM操作,借助于jQuery实现,而不是MVVM的思想。
使用Vuetify的原因:
- Vuetify几乎不需要任何CSS代码,而element-ui许多布局样式需要我们来编写
- Vuetify从底层构建起来的语义化组件。简单易学,容易记住。
- Vuetify基于Material Design(谷歌推出的多平台设计规范),更加美观,动画效果酷炫,且风格统一
项目页面布局
里面使用了Vuetify中的2个组件和一个布局元素:
- v-navigation-drawer :导航抽屉,主要用于容纳应用程序中的页面的导航链接。
- v-toolbar:工具栏通常是网站导航的主要途径。可以与导航抽屉一起很好地工作,动态选择是否打开导航抽屉,实现可伸缩的侧边栏。
- v-content:并不是一个组件,而是标记页面布局的元素。可以根据您指定的app组件的结构动态调整大小,使得您可以创建高度可定制的组件。
v-content中的内容来自哪里?
- Layout映射的路径是/
- 除了Login以外的所有组件,都是定义在Layout的children属性,并且路径都是/的下面
- 因此当路由到子组件时,会在Layout中定义的锚点中显示。
- 并且Layout中的其它部分不会变化,这就实现了布局的共享。
6.3 使用域名访问本地项目
我们现在访问页面使用的是ip访问
实际开发中,会有不同的环境:
- 开发环境:自己的电脑
- 测试环境:提供给测试人员使用的环境
- 预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试
- 生产环境:项目最终发布上线的环境
如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。
当前后台前端项目leyou-manage-web的访问ip地址为http://localhost:9001,我们希望将其域名改为www.manage.leyou.com
域名解析的原理
一个域名一定会被解析为一个或多个ip。这一般会包含两步:
-
本地域名解析
浏览器会首先在本机的hosts文件中查找域名映射的IP地址,如果查找到就返回IP ,没找到则进行域名服务器解析,一般本地解析都会失败,因为默认这个文件是空的。- Windows下的hosts文件地址:C:/Windows/System32/drivers/etc/hosts
- Linux下的hosts文件所在路径: /etc/hosts
-
域名服务器解析
- 本地解析失败,才会进行域名服务器解析,域名服务器就是网络中的一台计算机,里面记录了所有注册备案的域名和ip映射关系,一般只要域名是正确的,并且备案通过,一定能找到。
我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:
# SwitchHosts!
# leyou
127.0.0.1 www.manage.leyou.com # 后台页面
127.0.0.1 www.api.leyou.com # zuul网关
192.168.124.121 image.leyou.com # 图片服务器地址,使用虚拟机来模拟图片服务器
127.0.0.1 www.leyou.com # 前台页面
nginx解决端口问题
nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:
-
反向代理
-
负载均衡
-
动态路由
-
请求过滤
Web服务器分2类: -
web应用服务器,如:
- tomcat
- resin
- jetty
-
web服务器,如:
- Apache 服务器
- Nginx
- IIS
区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。
并发:web服务器的并发能力远高于web应用服务器。
个人总结的nginx的几个特点:1.作为web服务器使用时能够支持50000个并发量,一个tomcat的并发量好像也就200?2.nginx安装很简单,配置文件也简洁,启动容易,而且几乎不会出什么bug,可以一直运行,甚至可以在不间断服务的情况下进行软件升级。3.nginx同时也是一款非常优秀的邮件代理服务器。
nginx的反向代理
什么是反向代理?
- 代理:通过客户机的配置,实现让一台服务器代理客户机,客户的所有请求都交给代理服务器处理。
- 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。
nginx可以当做反向代理服务器来使用:
- 我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
- 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能
利用反向代理,就可以解决我们前面所说的端口问题,如图
项目编写完成后我本机的nginx配置文件如下:
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
#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 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 /api/goods/item {
# 先找本地
# root html;
# if (!-f $request_filename) { #请求的文件不存在,就反向代理
proxy_pass http://127.0.0.1:10010;
# break;
# }
}
location /api/goods {
proxy_pass http://127.0.0.1:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
location / {
proxy_pass http://127.0.0.1:9002;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
server {
listen 80;
server_name www.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 image.leyou.com;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location / {
# root D:\\data\\java-program\\IdeaProjects\\leyou\\leyou-upload\\src\\images;
proxy_pass http://192.168.124.121;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
server {
listen 80;
server_name www.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;
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;
}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;
# location / {
# root html;
# index index.html index.htm;
# }
#}
# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
#}
}
下图是对配置文件的一些解析:
nginx中的每个server就是一个反向代理配置,可以有多个server
下图展示了经过域名和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将得到的结果返回到浏览器
6.4 实现商品分类查询
下面是category表的一些字段:
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字段,对本表中的其它分类进行自关联。
注意sort字段用来表示同一个分类层级的排序顺序
当进入分类管理页面时,浏览器会向服务器发送一条请求:http://www.api.leyou.com/api/item/category/list?pid=0
至于为什么会发送这样一条请求,这就和用vuetify写的后台页面有关了,这里就不具体分析了。
如果刚开始什么其它操作都不做,就会出现一个关键问题:跨域问题
跨域问题
跨域:浏览器对于javascript的同源策略的限制 。
以下情况都属于跨域:
跨域原因说明 | 示例 |
---|---|
域名不同 | www.jd.com 与 www.taobao.com |
域名相同,端口不同 | www.jd.com:8080 与 www.jd.com:8081 |
二级域名不同 | item.jd.com 与 miaosha.jd.com |
- 如果域名和端口都相同,但是请求路径不同,不属于跨域,如:
www.jd.com/item
www.jd.com/goods
- http和https也属于跨域
而我们刚才是从manage.leyou.com去访问www.api.leyou.com,这属于二级域名不同,跨域了。
为什么有跨域问题?
跨域不一定都会有跨域问题。
因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。
因此:跨域问题 是针对ajax的一种限制。
但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?
解决跨域问题的方案
目前比较常用的跨域解决方案有3种:
- Jsonp
最早的解决方案,利用script标签可以跨域的原理实现。
限制:- 需要服务的支持
- 只能发起GET请求
- nginx反向代理
思路是:利用nginx把跨域反向代理为不跨域,支持各种请求方式
缺点:需要在nginx进行额外配置,语义不清晰 - CORS
规范化的跨域请求解决方案,安全可靠。
优势:- 在服务端进行控制是否允许跨域,可自定义规则
- 支持各种请求方式
缺点: - 会产生额外的请求
我们这里会采用cors的跨域方案。
cors解决跨域
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
- 浏览器端:
目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。 - 服务端:
CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。
注意:浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。
只要同时满足以下两大条件,就属于简单请求:
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
当浏览器发现发起的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.
Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。
如果服务器允许跨域,需要在返回的响应头中携带下面信息:
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-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请求就无需再次进行预检了
如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。
虽然原理比较复杂,但是实现起来比较简单:
- 浏览器端都有浏览器自动完成,我们无需操心
- 服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。
事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。
这里直接粘上我写的代码:
package com.leyou.gateway.config;
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 LeyouCorsConfiguration {
@Bean
public CorsFilter corsFilter(){
// 初始化cors配置对象
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://www.manage.leyou.com"); //允许跨域的域名,如果要携带cookie,不能写*。 *:代表所有域名都可以跨域访问
configuration.addAllowedOrigin("http://www.leyou.com"); //允许跨域的域名,如果要携带cookie,不能写*。 *:代表所有域名都可以跨域访问
//configuration.addAllowedOrigin("http://localhost:9001");
// configuration.addAllowedOrigin("http://www.api.leyou.com");
configuration.setAllowCredentials(true); //允许携带cookie
configuration.addAllowedHeader("*"); //跨域时允许携带任何头信息
configuration.addAllowedMethod("*"); // 代表所有的请求方法:GET,POST,PUT,...
// 初始化cors配置源对象
UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**",configuration);
// 返回corsFilter实例,参数: cors配置源对象
CorsFilter corsFilter = new CorsFilter(urlBasedCorsConfigurationSource);
return corsFilter;
}
}
项目写完后再来访问时发现了403错误:
可是我明明已经写了Cors的配置类啊,nginx,zuul,eureka都运行正常,zuul网关配置的过滤器也直接放行了,可为什么还是403错误呢?弄了半天发现原因竟是我没有通过域名来访问:
可以发现我直接在一个新的页面直接输入访问地址是可以访问的:
我是直接从http://localhost:9001来访问后台页面的,直接绕过了nginx和zuul网关,这时候明显跨域了,但是我之前自己编写的Cors配置类没有允许http://localhost:9001跨域,所以自然就会出现403错误,想通了这一点,只需要在代码中加上这一行即可:
configuration.addAllowedOrigin("http://localhost:9001");
经过测试没问题了,果然是因为这个原因:
既然已经解决了跨域问题,那么关于商品分类查询的实现就直接粘上我写的代码吧!在leyou-item-service模块中新建三个包分别表示控制层(Controller),业务层(Service)和持久层(mapper,之前用ssm框架的时候是dao层,由于这里使用了通用框架mapper于是写为mapper层)
Category实体类:
package com.leyou.item.pojo;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
@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;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getParentId() {
return parentId;
}
public void setParentId(Long parentId) {
this.parentId = parentId;
}
public Boolean getIsParent() {
return isParent;
}
public void setIsParent(Boolean parent) {
isParent = parent;
}
public Integer getSort() {
return sort;
}
public void setSort(Integer sort) {
this.sort = sort;
}
}
CategoryController类:
package com.leyou.item.controller;
import com.leyou.item.pojo.Category;
import com.leyou.item.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
import java.util.stream.Collectors;
@Controller
@RequestMapping("category")
public class CategoryController {
@Autowired
private CategoryService categoryService;
/**
* 根据父亲节点的id查询所有的子节点
* @param pid
* @return
*/
@GetMapping("list")
public ResponseEntity<List<Category>> queryByPid(@RequestParam(value = "pid",defaultValue = "0") Long pid){
if(pid == null || pid < 0){
// 400:参数不合法
//return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
//return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
return ResponseEntity.badRequest().build();
}
List<Category> categories = this.categoryService.queryByPid(pid);
if(CollectionUtils.isEmpty(categories)){
// 404: 资源服务器未找到
//return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
System.out.println("resource is not found!");
return ResponseEntity.notFound().build();
}
// 200 查询成功
return ResponseEntity.ok(categories);
// 500 服务器内部错误, 这个只要出错,会自动报状态码500,因此这里可以不使用try,catch来处理.
}
@GetMapping
public ResponseEntity<List<String>> queryNamesById(@RequestParam("ids")List<Long> ids){
if(CollectionUtils.isEmpty(ids)){
return ResponseEntity.badRequest().build();
}
List<Category> categories = this.categoryService.queryByIds(ids);
List<String> strings = categories.stream().map(category -> category.getName()).collect(Collectors.toList());
if(CollectionUtils.isEmpty(strings)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(strings);
}
}
CategoryServiceImpl类:
package com.leyou.item.service.impl;
import com.leyou.item.pojo.Category;
import com.leyou.item.mapper.CategoryMapper;
import com.leyou.item.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CategoryServiceImpl implements CategoryService {
@Autowired
private CategoryMapper categoryMapper;
/**
* 根据父节点的id查询所有的子节点
* @param pid
* @return
*/
@Override
public List<Category> queryByPid(Long pid) {
Category category = new Category();
category.setParentId(pid);
return categoryMapper.select(category);
}
/**
* 根据id数组查询所有节点
* @param ids
* @return
*/
@Override
public List<Category> queryByIds(List<Long> ids){
return this.categoryMapper.selectByIdList(ids);
}
}
CategoryMapper类:
package com.leyou.item.mapper;
import com.leyou.item.pojo.Category;
import tk.mybatis.mapper.additional.idlist.SelectByIdListMapper;
import tk.mybatis.mapper.common.Mapper;
public interface CategoryMapper extends Mapper<Category>, SelectByIdListMapper<Category,Long> {
}
需要注意的是,这里要用到jpa的注解,因此我们在leyou-item-iterface中添加jpa依赖:
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>1.0</version>
</dependency>
还需要注意的是:我们并没有在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);
}
}
7.品牌查询
首先是品牌对应的数据库表:
CREATE TABLE `tb_brand` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
`name` varchar(50) NOT NULL COMMENT '品牌名称',
`image` varchar(200) DEFAULT '' COMMENT '品牌图片地址',
`letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';
这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:
CREATE TABLE `tb_category_brand` (
`category_id` bigint(20) NOT NULL COMMENT '商品类目id',
`brand_id` bigint(20) NOT NULL COMMENT '品牌id',
PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分类和品牌的中间表,两者是多对多关系';
但是,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?
- 外键会严重影响数据库读写的效率
- 数据删除时会比较麻烦
在电商行业,性能是非常重要的。宁可在代码中通过逻辑来维护表关系,也不设置外键。
接下来是实体类:
package com.leyou.item.pojo;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
@Table(name = "tb_brand")
public class Brand {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;// 品牌名称
private String image;// 品牌图片
private Character letter;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Character getLetter() {
return letter;
}
public void setLetter(Character letter) {
this.letter = letter;
}
}
BrandMapper类:
package com.leyou.item.mapper;
import com.leyou.item.pojo.Brand;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tk.mybatis.mapper.common.Mapper;
import java.util.List;
public interface BrandMapper extends Mapper<Brand> {
@Insert("insert into tb_category_brand(category_id, brand_id) values(#{cid},#{bid})")
public abstract void saveCategoryAndBrand(@Param("cid")Long cid, @Param("bid")Long bid);
@Select("select * from tb_brand where id in (select brand_id from tb_category_brand where category_id = #{cid})")
public abstract List<Brand> queryByCid(Long cid);
}
一般来说,每张表对应一个实体类,也对应一个mapper用于操作这张表,但是在该表对应的mapper里也可以操作其它的表,这只是一种规范而已。
BrandController类:
package com.leyou.item.controller;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.pojo.Brand;
import com.leyou.item.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("brand")
public class BrandController {
@Autowired
private BrandService brandService;
/**
* 根据指定条件返回查询结果
* @param key
* @param page
* @param rows
* @param sortBy
* @param desc
* @return
*/
@GetMapping("page")
public ResponseEntity<PageResult<Brand>> queryByPage(
@RequestParam(value="key",required = false) String key,
@RequestParam(value="page",defaultValue = "1") Integer page,
@RequestParam(value="rows",defaultValue = "5") Integer rows,
@RequestParam(value="sortBy",required = false) String sortBy,
@RequestParam(value="desc",required = false) Boolean desc
){
PageResult<Brand> pageResult = this.brandService.queryByPage(key, page, rows, sortBy, desc);
if(CollectionUtils.isEmpty(pageResult.getItems())){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(pageResult);
}
/**
* 保存一个新建的品牌
* @param brand
* @param cids
* @return
*/
@PostMapping
public ResponseEntity<Void> save(Brand brand, @RequestParam("cids") List<Long> cids){
this.brandService.save(brand,cids);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
/**
* 根据分类id查询所有品牌
* @param cid
* @return
*/
@GetMapping("cid/{cid}")
public ResponseEntity<List<Brand>> queryByCid(@PathVariable("cid") Long cid){
List<Brand> brands = this.brandService.queryByCid(cid);
if(CollectionUtils.isEmpty(brands)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(brands);
}
/**
* 根据品牌id查询品牌
* @param bid
* @return
*/
@GetMapping("{bid}")
public ResponseEntity<Brand> queryByid(@PathVariable("bid") Long bid){
Brand brand = this.brandService.queryById(bid);
if(brand == null){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(brand);
}
}
- 请求方式:查询,肯定是Get
- 请求路径:分页查询,/brand/page
- 请求参数:根据我们刚才编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:
- page:当前页,int
- rows:每页大小,int
- sortBy:排序字段,String
- desc:是否为降序,boolean
- key:搜索关键词,String
- 响应结果:分页结果一般至少需要两个数据
- total:总条数
- items:当前页数据
- totalPage:有些还需要总页数
这里我们封装一个类,来表示分页结果(注意该类在模块leyou-common下,因为别的模块也很有可能需要PageResult<>对象,因此将该类写在一个公用的模块下是非常合适的.):
package com.leyou.common.pojo;
import java.util.List;
public class PageResult<T> {
private Long total;
private Integer totalPage;
private List<T> items;
public PageResult(Long total, List<T> items) {
this.total = total;
this.items = items;
}
public PageResult(Long total, Integer totalPage, List<T> items) {
this.total = total;
this.totalPage = totalPage;
this.items = items;
}
public PageResult() {
}
public Long getTotal() {
return total;
}
public void setTotal(Long total) {
this.total = total;
}
public Integer getTotalPage() {
return totalPage;
}
public void setTotalPage(Integer totalPage) {
this.totalPage = totalPage;
}
public List<T> getItems() {
return items;
}
public void setItems(List<T> items) {
this.items = items;
}
}
因此不要忘了引入依赖:
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
BrandServiceImpl类:
package com.leyou.item.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.mapper.BrandMapper;
import com.leyou.item.pojo.Brand;
import com.leyou.item.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import tk.mybatis.mapper.entity.Example;
import java.util.List;
@Service
public class BrandServiceImpl implements BrandService {
@Autowired
private BrandMapper brandMapper;
/**
* 根据指定条件返回查询结果
* @param key // 根据key实现模糊查询
* @param page //指定要查询哪一页
* @param rows //指定每页显示多少条数据
* @param sortBy //根据哪个字段排序
* @param desc // 升序还是降序
* @return
*/
@Override
public PageResult<Brand> queryByPage(String key, Integer page, Integer rows, String sortBy, Boolean desc) {
Example example = new Example(Brand.class);
Example.Criteria criteria = example.createCriteria();
//加入查询条件
if(!StringUtils.isEmpty(key)){
criteria.andLike("name","%"+key+"%").orEqualTo("letter",key);
}
//设置分页
PageHelper.startPage(page,rows);
//设置排序方式
if(!StringUtils.isEmpty(sortBy)){
example.setOrderByClause(sortBy+" "+(desc?"desc":"asc"));
}
List<Brand> brands = this.brandMapper.selectByExample(example);
PageInfo<Brand> pageInfo = new PageInfo<>(brands);
return new PageResult<Brand>(pageInfo.getTotal(),pageInfo.getList());
}
/**
* 新增一个品牌并同时更新品牌和类别的对应关系
* @param brand
* @param cids
*/
@Override
@Transactional
public void save(Brand brand, List<Long> cids) {
//先保存该品牌
this.brandMapper.insertSelective(brand);
//保存后id参数会注入到brand对象之中
cids.forEach(cid -> this.brandMapper.saveCategoryAndBrand(cid,brand.getId()));
}
/**
* 根据分类的id查找所有品牌
* @param cid
* @return
*/
@Override
public List<Brand> queryByCid(Long cid) {
return this.brandMapper.queryByCid(cid);
}
/**
* 根据品牌id查找相应的品牌
* @param bid
* @return
*/
@Override
public Brand queryById(Long bid) {
return this.brandMapper.selectByPrimaryKey(bid);
}
}
异步查询工具axios
异步查询数据,自然是通过ajax查询,之前我一直用的jQuery框架封装的ajax进行查询。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。于是乎Vue官方也开发了一款ajax请求框架,名为axios
axios的Get请求语法:
axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
.then(function(resp){
// 成功回调函数
})
.catch(function(){
// 失败回调函数
})
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
params:{
pid:0
}
})
.then(function(resp){})// 成功时的回调
.catch(function(error){})// 失败时的回调
axios的POST请求语法:
比如新增一个用户
axios.post("/user",{
name:"Jack",
age:21
})
.then(function(resp){})
.catch(function(error){})
注意,POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数
PUT和DELETE请求与POST请求类似
axios的全局配置
http.js中对axios进行了一些默认配置:
import Vue from 'vue'
import axios from 'axios'
import config from './config'
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间
// axios.interceptors.request.use(function (config) {
// // console.log(config);
// return config;
// })
axios.loadData = async function (url) {
const resp = await axios.get(url);
return resp.data;
}
Vue.prototype.$http = axios;// 将axios添加到 Vue的原型,这样一切vue实例都可以使用该对象
- http.js对axios进行了全局配置:baseURL=config.api,即http://api.leyou.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。
- 通过Vue.property.$http = axios,将axios赋值给了 Vue原型中的$http。 这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。
在页面端,如果需要与后端进行交互,直接利用axios发送相应的请求就可以了,这里直接贴上对应于品牌组件的前端页面(Brand.vue)代码:
<template>
<v-card>
<v-card-title>
<v-btn color="primary" @click="addBrand">新增品牌</v-btn>
<!--搜索框,与search属性关联-->
<v-spacer/>
<v-flex xs3>
<v-text-field label="输入关键字搜索" v-model.lazy="search" append-icon="search" hide-details/>
</v-flex>
</v-card-title>
<v-divider/>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center">
<img v-if="props.item.image" :src="props.item.image" width="130" height="40">
<span v-else>无</span>
</td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="justify-center layout px-0">
<v-btn icon @click="editBrand(props.item)">
<i class="el-icon-edit"/>
</v-btn>
<v-btn icon @click="deleteBrand(props.item)">
<i class="el-icon-delete"/>
</v-btn>
</td>
</template>
</v-data-table>
<!--弹出的对话框-->
<v-dialog max-width="500" v-model="show" persistent scrollable>
<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" style="height:400px">
<brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/>
</v-card-text>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
// 导入自定义的表单组件
import BrandForm from './BrandForm'
export default {
name: "brand",
data() {
return {
search: '', // 搜索过滤字段
totalBrands: 0, // 总条数
brands: [], // 当前页品牌数据
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', sortable: false, value: 'name'},
{text: 'LOGO', align: 'center', sortable: false, value: 'image'},
{text: '首字母', align: 'center', value: 'letter', sortable: true,},
{text: '操作', align: 'center', value: 'id', sortable: false}
],
show: false,// 控制对话框的显示
oldBrand: {}, // 即将被编辑的品牌数据
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/brand/page", {
params: {
key: this.search, // 搜索条件
page: this.pagination.page,// 当前页
rows: this.pagination.rowsPerPage,// 每页大小
sortBy: this.pagination.sortBy,// 排序字段
desc: this.pagination.descending// 是否降序
}
}).then(resp => { // 这里使用箭头函数
this.brands = resp.data.items;
this.totalBrands = resp.data.total;
// 完成赋值后,把加载状态赋值为false
this.loading = false;
})
},
addBrand() {
// 修改标记
this.isEdit = false;
// 控制弹窗可见:
this.show = true;
// 把oldBrand变为null
this.oldBrand = null;
},
editBrand(oldBrand){
// 根据品牌信息查询商品分类
this.$http.get("/item/category/bid/" + oldBrand.id)
.then(({data}) => {
// 修改标记
this.isEdit = true;
// 控制弹窗可见:
this.show = true;
// 获取要编辑的brand
this.oldBrand = oldBrand
// 回显商品分类
this.oldBrand.categories = data;
})
},
closeWindow(){
// 重新加载数据
this.getDataFromServer();
// 关闭窗口
this.show = false;
}
},
components:{
BrandForm
}
}
</script>
<style scoped>
</style>
vue有一个神奇的监听机制,通过监听机制可以实现动态的分页查询和搜索过滤,很有意思。
8.品牌新增及fastDFS
8.1 品牌新增
品牌新增的页面又是一个新的组件(BrandForm.vue):
<template>
<v-form v-model="valid" ref="myBrandForm">
<v-text-field v-model="brand.name" label="请输入品牌名称" required :rules="nameRules"/>
<v-text-field v-model="brand.letter" label="请输入品牌首字母" required :rules="letterRules"/>
<v-cascader
url="/item/category/list"
multiple
required
v-model="brand.categories"
label="请选择商品分类"/>
<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/image" :multiple="false" :pic-width="250" :pic-height="90"
/>
</v-flex>
</v-layout>
<v-layout class="my-4" row>
<v-spacer/>
<v-btn @click="submit" color="primary">提交</v-btn>
<v-btn @click="clear">重置</v-btn>
</v-layout>
</v-form>
</template>
<script>
export default {
name: "brand-form",
props: {
oldBrand: {
type: Object
},
isEdit: {
type: Boolean,
default: false
}
},
data() {
return {
valid: false, // 表单校验结果标记
brand: {
name: '', // 品牌名称
letter: '', // 品牌首字母
image: '',// 品牌logo
categories: [], // 品牌所属的商品分类数组
},
nameRules: [
v => !!v || "品牌名称不能为空",
v => v.length > 1 || "品牌名称至少2位"
],
letterRules: [
v => !!v || "首字母不能为空",
v => /^[a-zA-Z]{1}$/.test(v) || "品牌字母只能是1个字母"
]
}
},
methods: {
submit() {
// 表单校验
if (this.$refs.myBrandForm.validate()) {
// 定义一个请求参数对象,通过解构表达式来获取brand中的属性
const {categories, letter, ...params} = this.brand;
// 数据库中只要保存分类的id即可,因此我们对categories的值进行处理,只保留id,并转为字符串
params.cids = categories.map(c => c.id).join(",");
// 将字母都处理为大写
params.letter = letter.toUpperCase();
// 将数据提交到后台
// this.$http.post('/item/brand', this.$qs.stringify(params))
this.$http({
method: this.isEdit ? 'put' : 'post',
url: '/item/brand',
data: this.$qs.stringify(params)
}).then(() => {
// 关闭窗口
this.$emit("close");
this.$message.success("保存成功!");
})
.catch(() => {
this.$message.error("保存失败!");
});
}
},
clear() {
// 重置表单
this.$refs.myBrandForm.reset();
// 需要手动清空商品分类
this.categories = [];
}
},
watch: {
oldBrand: {// 监控oldBrand的变化
handler(val) {
if (val) {
// 注意不要直接复制,否则这边的修改会影响到父组件的数据,copy属性即可
this.brand = Object.deepCopy(val)
} else {
// 为空,初始化brand
this.brand = {
name: '',
letter: '',
image: '',
categories: [],
}
}
},
deep: true
}
}
}
</script>
<style scoped>
</style>
重置表单
重置表单相对简单,因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了。
我们可以通过$refs内置对象来获取表单组件。
首先,在表单上定义ref属性:
我们在clear中来获取表单对象并调用reset方法:
要注意的是,需要手动把this.categories清空了,因为我写的级联选择组件并没有跟表单结合起来。
表单校验
Vuetify的表单校验,是通过rules属性来指定的:
校验规则的说明:
- 规则是一个数组
- 数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:
- 返回true,代表成功,
- 返回错误提示信息,代表失败
我们有四个字段:
- name:做非空校验和长度校验,长度必须大于1
- letter:首字母,校验长度为1,非空。
- image:图片,不做校验,图片可以为空
- categories:非空校验,自定义组件已经帮我们完成,不用写了
表单提交
在submit方法中添加表单提交的逻辑:
1.通过this.KaTeX parse error: Expected 'EOF', got '#' at position 478: …,t_70,g_se,x_16#̲pic_center) 这个插…message对象绑定到了Vue的原型上,因此我们可以通过this.$message来直接调用。
包含以下常用方法:
- info、error、success、warning等,弹出一个带有提示信息的窗口,色调与为普通(灰)、错误(红色)、成功(绿色)和警告(黄色)。使用方法:this.$message.info(“msg”)
- confirm:确认框。用法:this.$message.confirm(“确认框的提示信息”),返回一个Promise。
接下来是后台的编写,这部分我感觉还是比较简单的,代码上面已经贴过了,就不多叙述了。
不过这里还有一个地方值得注意,不然会报400(请求的参数不合法)错误:
axios处理请求体的原则会根据请求数据的格式来定:
- 如果请求体是对象:会转为json发送
- 如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。
如:name=jack&age=12
注意save方法的参数:一个是brand对象,另一个是一个类型为Long的id数组,如果前端页面传输的是一个js对象,那么会直接被转为json发送,那么接收端接收的时候就只能用一个对象来接收(如果该对象中没有对应的字段则会忽略),不能用第二个参数继续接收。为了解决这个问题,我们可以使用第三方库QS的stringify方法来讲json对象转为请求参数字符串,这样问题就解决了。
新增完成后关闭窗口
新增不管成功还是失败,窗口都一致在这里,不会关闭。这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。因此,我们需要在新增的ajax请求完成以后,关闭窗口,这就需要在子组件中关闭窗口,但是控制窗口是否标记的参数在父组件中,这时候就需要用到父子组件之间的通信了。
第一步:在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了。父组件在使用子组件时,绑定事件,关联到这个函数:Brand.vue
<!--对话框的内容,表单-->
<v-card-text class="px-5" style="height:400px">
<brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/>
</v-card-text>
第二步,子组件通过this.$emit调用父组件的函数closeWindow:
8.2 图片上传
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
三步走:
1.引入文件上传微服务的依赖:
<?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.upload</groupId>
<artifactId>leyou-upload</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.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</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>
</dependencies>
</project>
2.更改配置:
server:
port: 8082
spring:
application:
name: leyou-upload
servlet:
multipart:
max-file-size: 5MB
eureka:
client:
service-url:
defaultZone: http://localhost:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5
lease-expiration-duration-in-seconds: 15
fdfs:
so-timeout: 1501 # 超时时间
connect-timeout: 601 # 连接超时时间
thumb-image: # 缩略图
width: 60
height: 60
tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
- 192.168.124.121:22122
3.编写引导类
package com.leyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouUploadApplication.class,args);
}
}
然后是controller类:
编写controller需要知道4个内容:
- 请求方式:上传肯定是POST
- 请求路径:/upload/image
- 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipartFile
- 返回结果:上传成功后得到的文件的url路径,也就是返回String
package com.leyou.upload.controller;
import com.leyou.upload.service.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
@Controller
@RequestMapping("upload")
public class UploadController {
@Autowired
private UploadService uploadService;
@PostMapping("image")
public ResponseEntity<String> upload(@RequestParam("file")MultipartFile file, HttpServletRequest request){
String path = this.uploadService.upload(file,request);
if(StringUtils.isEmpty(path)){
return ResponseEntity.badRequest().build();
}
return ResponseEntity.status(HttpStatus.CREATED).body(path);
}
}
在上传文件过程中,我们需要对上传的内容进行校验:
- 校验文件大小
- 校验文件的媒体类型
- 校验文件的内容
文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。
具体代码:
package com.leyou.upload.service.impl;
import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import com.leyou.upload.service.UploadService;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
@Service
public class UploadServiceImpl implements UploadService {
private static final List<String> CONTENTTYPES = Arrays.asList("application/x-jpg","image/jpeg","application/x-png","application/x-bmp","image/png");
private static Logger logger = LoggerFactory.getLogger(UploadServiceImpl.class);
@Autowired
private FastFileStorageClient storageClient;
/**
* 上传文件
* @param file
*/
@Override
public String upload(MultipartFile file, HttpServletRequest request) {
String originalFilename = file.getOriginalFilename();
//System.out.println("file:"+file);
//System.out.println("filename:"+originalFilename);
//校验文件类型是否合法
String contentType = file.getContentType();
//System.out.println(contentType);
if(!CONTENTTYPES.contains(contentType)){
logger.info("{}的文件类型不合法",originalFilename);
return null;
}
//校验文件内容是否合法
BufferedImage bufferedImage = null;
try {
bufferedImage = ImageIO.read(file.getInputStream());
} catch (IOException e) {
e.printStackTrace();
}
if(bufferedImage == null){
logger.info("{}的文件内容不合法",originalFilename);
return null;
}
//上传文件
StorePath storePath = null;
try {
//file.transferTo(new File("D:\\data\\java-program\\IdeaProjects\\leyou\\leyou-upload\\src\\images",originalFilename));
String last = StringUtils.substringAfterLast(originalFilename, ".");
storePath = this.storageClient.uploadFile(file.getInputStream(), file.getSize(), last, null);
} catch (IOException e) {
e.printStackTrace();
}
//返回文件的访问路径
// return "http://www.image.leyou.com/"+originalFilename;
return "http://image.leyou.com/"+storePath.getFullPath();
}
}
这里有一个问题:为什么图片地址需要使用另外的url?
- 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
- 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量
绕过网关
图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。
所以,我们可以考虑让上传文件的请求绕过网关。
如何做到这一点呢?在nginx配置文件中进行如下配置即可:
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命令重新加载配置。然后再次上传即可。
注意,由于我们越过了网关,所以这里又会出现跨域问题,因此需要加上一个配置类
8.3 FastDFS
上传本身没有任何问题,问题出在保存文件的方式,如果我们保存在服务器机器,就会有下面的问题:
- 单机器存储,存储能力有限
- 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
- 数据没有备份,有单点故障风险
- 并发能力差
这个时候,最好使用分布式文件存储来代替本地文件存储。
分布式文件系统
分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。
通俗来讲:
-
传统文件系统管理的文件就存储在本机。
-
分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问
FastDFS
FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富: -
文件存储
-
文件同步
-
文件访问(上传、下载)
-
存取负载均衡
-
在线扩容
适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。
FastDFS的架构图
FastDFS两个主要的角色:Tracker Server 和 Storage Server 。
- Tracker Server:跟踪服务器,主要负责调度storage节点与client通信,在访问上起负载均衡的作用,和记录storage节点的运行状态,是连接client和storage节点的枢纽。
- Storage Server:存储服务器,保存文件和文件的meta data(元数据),每个storage server会启动一个单独的线程主动向Tracker cluster中每个tracker server报告其状态信息,包括磁盘使用情况,文件同步情况及文件上传下载次数统计等信息
- Group:文件组,多台Storage Server的集群。上传一个文件到同组内的一台机器上后,FastDFS会将该文件即时同步到同组内的其它所有机器上,起到备份的作用。不同组的服务器,保存的数据不同,而且相互独立,不进行通信。
- Tracker Cluster:跟踪服务器的集群,有一组Tracker Server(跟踪服务器)组成。
- Storage Cluster :存储集群,有多个Group组成。
上传和下载流程
- Client通过Tracker server查找可用的Storage server。
- Tracker server向Client返回一台可用的Storage server的IP地址和端口号。
- Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并进行文件上传。
- 上传完成,Storage server返回Client一个文件ID,文件上传结束。
- Client通过Tracker server查找要下载文件所在的的Storage server。
- Tracker server向Client返回包含指定文件的某个Storage server的IP地址和端口号。
- Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并指定要下载文件。
- 下载文件成功。
至于具体怎么使用,还是三步走的方式,比较特殊的是第三步,得添加如下的配置类:
package com.leyou.upload.config;
import com.github.tobato.fastdfs.FdfsClientConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableMBeanExport;
import org.springframework.context.annotation.Import;
import org.springframework.jmx.support.RegistrationPolicy;
@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
}
当然也可以编写一个测试方法:
package com.leyou.upload.test;
import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.domain.ThumbImageConfig;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
@SpringBootTest
@RunWith(SpringRunner.class)
public class FastDFSTest {
@Autowired
private FastFileStorageClient storageClient;
@Autowired
private ThumbImageConfig thumbImageConfig;
@Test
public void testUpload() throws FileNotFoundException {
// 要上传的文件
File file = new File("C:\\Users\\admin\\Desktop\\hehe.png");
// 上传并保存图片,参数:1-上传的文件流 2-文件的大小 3-文件的后缀 4-可以不管他
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("C:\\Users\\admin\\Desktop\\hehe.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);
}
}
最后是改造上传逻辑,代码已经在前面贴好。
最后记录一下修改品牌和删除品牌
修改品牌
首先在前端页面添加edit方法:
editBrand(oldBrand){
// 根据品牌信息查询商品分类
this.$http.get("/item/category/bid/" + oldBrand.id)
.then(({data}) => {
// 修改标记
this.isEdit = true;
// 控制弹窗可见:
this.show = true;
// 获取要编辑的brand
this.oldBrand = oldBrand
// 回显商品分类
this.oldBrand.categories = data;
})
},
然后完成后台的实现:
BrandController:
/**
* 对修改后的品牌进行保存
* @param brand
* @param cids
* @return
*/
@PutMapping
public ResponseEntity<Void> update(Brand brand, @RequestParam("cids") List<Long> cids){
this.brandService.update(brand,cids);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
BrandServiceImpl:
/**
* 更新一个品牌并同时更新中间表的关系
* @param brand
* @param cids
*/
@Override
@Transactional
public void update(Brand brand, List<Long> cids) {
//更新品牌信息
this.brandMapper.updateByPrimaryKey(brand);
//先删除中间表中原有的对应关系
this.brandMapper.deleteCategoryAndBrand(brand.getId());
//然后添加新的关系
cids.forEach(cid->this.brandMapper.saveCategoryAndBrand(cid,brand.getId()));
}
注意更新中间表采用先删除再添加的方式
BrandMapper:
package com.leyou.item.mapper;
import com.leyou.item.pojo.Brand;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tk.mybatis.mapper.common.Mapper;
import java.util.List;
public interface BrandMapper extends Mapper<Brand> {
@Insert("insert into tb_category_brand(category_id, brand_id) values(#{cid},#{bid})")
public abstract void saveCategoryAndBrand(@Param("cid")Long cid, @Param("bid")Long bid);
@Select("select * from tb_brand where id in (select brand_id from tb_category_brand where category_id = #{cid})")
public abstract List<Brand> queryByCid(Long cid);
@Delete("delete from tb_category_brand where brand_id = #{id}")
void deleteCategoryAndBrand(Long id);
}
删除品牌
删除品牌就比较简单了,同样先写前端页面,发送删除请求后记得重新请求数据:
//发送删除品牌的请求
deleteBrand(brand){
this.$http.delete("/item/brand/bid/"+brand.id)
.then(
()=>{
this.getDataFromServer();
}
).catch();
},
BrandController:
/**
* 根据bid删除指定的品牌
*/
@DeleteMapping("bid/{bid}")
public ResponseEntity<Void> delete(@PathVariable("bid")Long bid){
if(bid==null){
ResponseEntity.badRequest().build();
}
this.brandService.delete(bid);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
BrandServiceImpl:
/**
* 根据bid删除指定的品牌
* @param bid
*/
@Override
@Transactional
public void delete(Long bid) {
Brand brand = new Brand();
brand.setId(bid);
this.brandMapper.deleteByPrimaryKey(brand);
this.brandMapper.deleteCategoryAndBrand(bid);
}
9.规格参数
9.1 商品规格数据结构
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
- SPU是一个抽象的商品集概念,为了方便后台的管理。
- SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU
不同的商品分类,可能属性是不一样的,比如手机有内存,衣服有尺码,这是一个全品类的电商网站,这些不同的商品的不同属性,如何设计到一张表中?其实颜色、内存、硬盘属性都是规格参数中的字段。所以,要解决这个问题,首先要能清楚规格参数。
虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的:
SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。不同种类的商品,一个手机,一个衣服,其SKU属性不相同。同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。SKU的特有属性也是商品规格参数的一部分。
也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:
- spu下所有sku共享的规格属性(称为全局属性)
- 每个sku不同的规格属性(称为特有属性)
注意规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:
规格参数表
可以看到规格参数是分组的,每一组都有多个参数键值对。不过对于规格参数的模板而言,其值现在是不确定的,不同的商品值肯定不同,模板中只要保存组信息、组内参数信息即可
因此可以考虑设计两张表:
- tb_spec_group:组,与商品分类关联
- tb_spec_param:参数名,与组关联,一对多
规格参数分组表:tb_spec_group
CREATE TABLE `tb_spec_group` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
`name` varchar(50) NOT NULL COMMENT '规格组的名称',
PRIMARY KEY (`id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';
规格组有3个字段:
- id:主键
- cid:商品分类id,一个分类下有多个模板
- name:该规格组的名称。
规格参数表:tb_spec_param
CREATE TABLE `tb_spec_param` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id',
`group_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL COMMENT '参数名',
`numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
`unit` varchar(255) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
`generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
`searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
`segments` varchar(1000) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
PRIMARY KEY (`id`),
KEY `key_group` (`group_id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';
我们的规格参数首先需要记录参数名、组id、商品分类id。另外,规格参数中有一部分是 SKU的通用属性,一部分是SKU的特有属性,而且其中会有一些将来用作搜索过滤,这些信息都需要标记出来。于是乎多了很多属性:
1.通用属性:用一个布尔类型字段来标记是否为通用:
- generic来标记是否为通用属性:
- true:代表通用属性
- false:代表sku特有属性
2.搜索过滤:与搜索相关的有两个字段:
- searching:标记是否用作过滤
- true:用于过滤搜索
- false:不用于过滤
- segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
- 比如电池容量,02000mAh,2000mAh3000mAh,3000mAh~4000mAh
3.数值类型:某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:
- numberic:是否为数值类型
- true:数值类型
- false:不是数值类型
- unit:参数的单位
9.2 商品规格参数管理
这里完成规格参数组的查询,修改,增添和删除。
前端页面的分析过于复杂,这里就省略吧,后台的编写只涉及到查询,剩下的三部分有空再完成吧!
SpecGroup类:
package com.leyou.item.pojo;
import javax.persistence.*;
import java.util.List;
@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省略
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCid() {
return cid;
}
public void setCid(Long cid) {
this.cid = cid;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<SpecParam> getParams() {
return params;
}
public void setParams(List<SpecParam> params) {
this.params = params;
}
}
SpecParam类:
package com.leyou.item.pojo;
import javax.persistence.*;
@Table(name = "tb_spec_param")
public class SpecParam {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long cid;
private Long groupId;
private String name;
@Column(name = "`numeric`")
private Boolean numeric; // numeric是mysql数据库中的一个关键字,必须加引号标识其不是关键字
private String unit;
private Boolean generic;
private Boolean searching;
private String segments;
// getter和setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCid() {
return cid;
}
public void setCid(Long cid) {
this.cid = cid;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Boolean getNumeric() {
return numeric;
}
public void setNumeric(Boolean numeric) {
this.numeric = numeric;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public Boolean getGeneric() {
return generic;
}
public void setGeneric(Boolean generic) {
this.generic = generic;
}
public Boolean getSearching() {
return searching;
}
public void setSearching(Boolean searching) {
this.searching = searching;
}
public String getSegments() {
return segments;
}
public void setSegments(String segments) {
this.segments = segments;
}
}
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.stereotype.Controller;
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.RequestParam;
import java.util.List;
@Controller
@RequestMapping("spec")
public class SpecificationController {
@Autowired
private SpecificationService specificationService;
/**
* 根据分类Id查询规格参数组
* @param cid
* @return
*/
@GetMapping("groups/{cid}")
public ResponseEntity<List<SpecGroup>> queryGroupsByCid(@PathVariable("cid") Long cid){
List<SpecGroup> groups = this.specificationService.queryGroupsByCid(cid);
if(CollectionUtils.isEmpty(groups)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(groups);
}
/**
* 根据查询条件查询所有参数
* @param gid
* @param cid
* @param generic
* @param searching
* @return
*/
@GetMapping("params")
public ResponseEntity<List<SpecParam>> queryParams(
@RequestParam(value = "gid",required = false) Long gid,
@RequestParam(value = "cid",required = false) Long cid,
@RequestParam(value = "generic",required = false) Boolean generic,
@RequestParam(value = "searching",required = false) Boolean searching){
List<SpecParam> params = this.specificationService.queryParams(gid,cid,generic,searching);
if(CollectionUtils.isEmpty(params)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(params);
}
/**
* 根据cid查询所有参数组和组内所有的参数信息
* @param cid
* @return
*/
@GetMapping("{cid}")
public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid){
List<SpecGroup> groups = this.specificationService.querySpecGroupsByCid(cid);
if(CollectionUtils.isEmpty(groups)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(groups);
}
}
SpecificationServiceImpl类:
package com.leyou.item.service.impl;
import com.leyou.item.mapper.SpecGroupMapper;
import com.leyou.item.mapper.SpecParamMapper;
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.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class SpecificationServiceImpl implements SpecificationService {
@Autowired
private SpecGroupMapper specGroupMapper;
@Autowired
private SpecParamMapper specParamMapper;
/**
* 根据cid查询所有参数组信息
* @param cid
* @return
*/
@Override
public List<SpecGroup> queryGroupsByCid(Long cid) {
SpecGroup record = new SpecGroup();
record.setCid(cid);
return this.specGroupMapper.select(record);
}
/**
* 根据查询条件查询所有参数信息
* @param gid
* @param cid
* @param generic
* @param searching
* @return
*/
@Override
public List<SpecParam> queryParams(Long gid,Long cid,Boolean generic,Boolean searching) {
SpecParam record = new SpecParam();
record.setGroupId(gid);
record.setCid(cid);
record.setSearching(searching);
record.setGeneric(generic);
return this.specParamMapper.select(record);
}
/**
* 根据cid查询所有参数组信息和组内所有的参数信息
* @param cid
* @return
*/
@Override
public List<SpecGroup> querySpecGroupsByCid(Long cid) {
List<SpecGroup> specGroups = this.queryGroupsByCid(cid);
return specGroups.stream().map(specGroup -> {
List<SpecParam> params = this.queryParams(specGroup.getId(), null, null, null);
specGroup.setParams(params);
return specGroup;
}).collect(Collectors.toList());
}
}
9.3 SPU和SKU数据结构
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的属性。比如,商品详情页展示可选的规格参数时:
在spu中也记录一份会使页面渲染非常方便。
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中应该保存特有规格参数的值,就在这两个字段中。
9.4 商品查询
简单来说,就是完成如下的页面:
这部分的业务逻辑很复杂,具体的就不复习了,直接贴上写好的代码吧!
Spu实体类:
package com.leyou.item.pojo;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;
@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
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getBrandId() {
return brandId;
}
public void setBrandId(Long brandId) {
this.brandId = brandId;
}
public Long getCid1() {
return cid1;
}
public void setCid1(Long cid1) {
this.cid1 = cid1;
}
public Long getCid2() {
return cid2;
}
public void setCid2(Long cid2) {
this.cid2 = cid2;
}
public Long getCid3() {
return cid3;
}
public void setCid3(Long cid3) {
this.cid3 = cid3;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSubTitle() {
return subTitle;
}
public void setSubTitle(String subTitle) {
this.subTitle = subTitle;
}
public Boolean getSaleable() {
return saleable;
}
public void setSaleable(Boolean saleable) {
this.saleable = saleable;
}
public Boolean getValid() {
return valid;
}
public void setValid(Boolean valid) {
this.valid = valid;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
public Date getLastUpdateTime() {
return lastUpdateTime;
}
public void setLastUpdateTime(Date lastUpdateTime) {
this.lastUpdateTime = lastUpdateTime;
}
}
Sku实体类:
package com.leyou.item.pojo;
import javax.persistence.*;
import java.util.Date;
@Table(name = "tb_sku")
public class Sku {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long spuId;
private String title;
private String images;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getSpuId() {
return spuId;
}
public void setSpuId(Long spuId) {
this.spuId = spuId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImages() {
return images;
}
public void setImages(String images) {
this.images = images;
}
public Long getPrice() {
return price;
}
public void setPrice(Long price) {
this.price = price;
}
public String getOwnSpec() {
return ownSpec;
}
public void setOwnSpec(String ownSpec) {
this.ownSpec = ownSpec;
}
public String getIndexes() {
return indexes;
}
public void setIndexes(String indexes) {
this.indexes = indexes;
}
public Boolean getEnable() {
return enable;
}
public void setEnable(Boolean enable) {
this.enable = enable;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
public Date getLastUpdateTime() {
return lastUpdateTime;
}
public void setLastUpdateTime(Date lastUpdateTime) {
this.lastUpdateTime = lastUpdateTime;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
private Long price;
private String ownSpec;// 商品特殊规格的键值对
private String indexes;// 商品特殊规格的下标
private Boolean enable;// 是否有效,逻辑删除用
private Date createTime;// 创建时间
private Date lastUpdateTime;// 最后修改时间
@Transient
private Integer stock;// 库存
}
注意由于商品查询页面包含了Spu中不具备的关键字,因此我们需要创建一个新的类SpuBo来继承现有的Spu,而不能直接修改Spu类,因为别的微服务可能使用了Spu类,如果此时修改Spu,那么将会产生大范围的影响,所以新建一个类来继承原有类才是一个可行的解决办法。
SpuBo类:
package com.leyou.item.bo;
import com.leyou.item.pojo.Sku;
import com.leyou.item.pojo.Spu;
import com.leyou.item.pojo.SpuDetail;
import java.util.List;
public class SpuBo extends Spu {
private String cname;
private String bname;
private SpuDetail spuDetail;
private List<Sku> skus;
public SpuDetail getSpuDetail() {
return spuDetail;
}
public void setSpuDetail(SpuDetail spuDetail) {
this.spuDetail = spuDetail;
}
public List<Sku> getSkus() {
return skus;
}
public void setSkus(List<Sku> skus) {
this.skus = skus;
}
public String getCname() {
return cname;
}
public void setCname(String cname) {
this.cname = cname;
}
public String getBname() {
return bname;
}
public void setBname(String bname) {
this.bname = bname;
}
}
GoodsController类:
package com.leyou.item.controller;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.SpuBo;
import com.leyou.item.pojo.Sku;
import com.leyou.item.pojo.Spu;
import com.leyou.item.pojo.SpuDetail;
import com.leyou.item.service.GoodsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
public class GoodsController {
@Autowired
private GoodsService goodsService;
/**
* 根据条件分页查询spu
* @param key
* @param saleable
* @param page
* @param rows
* @return
*/
@GetMapping("spu/page")
public ResponseEntity<PageResult<SpuBo>> queryByPage(
@RequestParam(value = "key",required = false) String key,
@RequestParam(value = "saleable",required = false) Boolean saleable,
@RequestParam(value = "page",defaultValue = "1") Integer page,
@RequestParam(value = "rows",defaultValue = "5") Integer rows
){
PageResult<SpuBo> pageResult = this.goodsService.queryByPage(key,saleable,page,rows);
if(pageResult == null || CollectionUtils.isEmpty(pageResult.getItems())){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(pageResult);
}
/**
*根据接收到的SpuBo数据更新数据库的相应表:tb_spu,tb_spu_detail,tb_stock,tb_sku
*/
@PostMapping("goods")
public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo){
this.goodsService.saveGoods(spuBo);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
/**
* 根据spuid查找对应的spu_detail
* @param spuId
* @return
*/
@GetMapping("spu/detail/{spuId}")
public ResponseEntity<SpuDetail> querySpuDetailBySpuId(@PathVariable("spuId") Long spuId){
SpuDetail spuDetail = this.goodsService.querySpuDetailBySpuId(spuId);
if(spuDetail == null){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(spuDetail);
}
/**
* 根据spuid查找所有的sku
* @param spuId
* @return
*/
@GetMapping("sku/list")
public ResponseEntity<List<Sku>> querySkusBySpuId(@RequestParam("id") Long spuId){
List<Sku> skus = this.goodsService.querySkusBySpuId(spuId);
if(CollectionUtils.isEmpty(skus)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(skus);
}
/**
* 更新商品信息
* @param spuBo
*/
@PutMapping("goods")
public ResponseEntity<Void> upateGoods(@RequestBody SpuBo spuBo){
this.goodsService.updateGoods(spuBo);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
/**
* 根据spuId查询spu
*/
@GetMapping("{id}")
public ResponseEntity<Spu> querySpuBySpuId(@PathVariable("id") Long spuId){
Spu spu = this.goodsService.querySpuBySpuId(spuId);
if(spu == null){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(spu);
}
/**
* 根据skuId查询sku
* @param skuId
* @return
*/
@GetMapping("sku/{skuId}")
public ResponseEntity<Sku> querySkuByskuId(@PathVariable("skuId") Long skuId){
Sku sku = this.goodsService.querySkuByskuId(skuId);
if(sku == null){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(sku);
}
}
GoodsServiceImpl类:
package com.leyou.item.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.SpuBo;
import com.leyou.item.mapper.*;
import com.leyou.item.pojo.Sku;
import com.leyou.item.pojo.Spu;
import com.leyou.item.pojo.SpuDetail;
import com.leyou.item.pojo.Stock;
import com.leyou.item.service.CategoryService;
import com.leyou.item.service.GoodsService;
import com.netflix.discovery.converters.Auto;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.apache.commons.lang.StringUtils;
import org.springframework.transaction.annotation.Transactional;
import tk.mybatis.mapper.entity.Example;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class GoodsServiceImpl implements GoodsService {
@Autowired
private SpuMapper spuMapper;
@Autowired
private BrandMapper brandMapper;
@Autowired
private CategoryService categoryService;
@Autowired
private SpuDetailMapper spuDetailMapper;
@Autowired
private SkuMapper skuMapper;
@Autowired
private StockMapper stockMapper;
@Autowired
private AmqpTemplate amqpTemplate;
/**
* 根据条件分页查询spu
* @param key
* @param saleable
* @param page
* @param rows
* @return
*/
@Override
public PageResult<SpuBo> queryByPage(String key, Boolean saleable, Integer page, Integer rows) {
Example example = new Example(Spu.class);
Example.Criteria criteria = example.createCriteria();
// 指定模糊查询
if(!StringUtils.isEmpty(key)){
criteria.andLike("title","%"+key+"%");
}
// 指定过滤条件
if(saleable != null){
criteria.andEqualTo("saleable",saleable);
}
// 使用分页查询
PageHelper.startPage(page,rows);
List<Spu> spus = this.spuMapper.selectByExample(example);
PageInfo<Spu> pageInfo = new PageInfo<>(spus);
// 查询结果处理后再封装
List<SpuBo> spubos = spus.stream().map(spu -> {
SpuBo spuBo = new SpuBo();
BeanUtils.copyProperties(spu,spuBo);
// 设置剩余的参数值
// 设置品牌名称
spuBo.setBname(this.brandMapper.selectByPrimaryKey(spu.getBrandId()).getName());
// 设置商品分类的名称
List<Long> ids = Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3());
List<String> strings = this.categoryService.queryByIds(ids).stream().map(category -> category.getName()).collect(Collectors.toList());
spuBo.setCname(StringUtils.join(strings,"-"));
return spuBo;
}).collect(Collectors.toList());
// 返回需要的pageResult对象
return new PageResult<SpuBo>(pageInfo.getTotal(),spubos);
}
/**
* 将传递过来的商品信息保存到数据库之中
* @param spuBo
*/
@Override
@Transactional
public void saveGoods(SpuBo spuBo) {
// 保存spu
spuBo.setId(null);
spuBo.setSaleable(true);
spuBo.setValid(true);
spuBo.setCreateTime(new Date());
spuBo.setLastUpdateTime(spuBo.getCreateTime());
this.spuMapper.insertSelective(spuBo);
// 保存spu_detail
SpuDetail spuDetail = spuBo.getSpuDetail();
spuDetail.setSpuId(spuBo.getId());
this.spuDetailMapper.insertSelective(spuDetail);
saveSkuAndStock(spuBo);
//保存后将消息传递给消息队列,如果发送消息的过程出了问题,则应该手动处理,不要因为消息发送不成功而触发了事务,因为保存数据不应该受到发送消息的影响
//已经在配置中指定了默认的交换机: LEYOU_ITEM_EXCHANGE
try {
sendMessage("insert",spuBo.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 工作函数,根据指定的routingKey和要发送的消息这两个数据来发送消息
* @param type
* @param id
*/
private void sendMessage(String type,Long id){
//已经在配置中指定了默认的交换机: LEYOU_ITEM_EXCHANGE
this.amqpTemplate.convertAndSend("item."+type, id);
}
/**
* 根据spuid查找对应的spu_detail
* @param spuId
* @return
*/
@Override
public SpuDetail querySpuDetailBySpuId(Long spuId) {
return this.spuDetailMapper.selectByPrimaryKey(spuId);
}
/**
* 根据spuid查找所有的sku
* @param spuId
* @return
*/
@Override
public List<Sku> querySkusBySpuId(Long spuId) {
Sku record = new Sku();
record.setSpuId(spuId);
List<Sku> skus = this.skuMapper.select(record);
skus.forEach(sku -> {
Stock stock = this.stockMapper.selectByPrimaryKey(sku.getId());
sku.setStock(stock.getStock());
});
return skus;
}
/**
* 更新商品信息
* @param spuBo
*/
@Override
@Transactional
public void updateGoods(SpuBo spuBo) {
// 删除stock
List<Sku> skus = spuBo.getSkus();
skus.forEach(sku -> this.stockMapper.deleteByPrimaryKey(sku.getId()));
// 删除sku
Sku record = new Sku();
record.setSpuId(spuBo.getId());
this.skuMapper.delete(record);
// 添加sku
// 添加stock
saveSkuAndStock(spuBo);
// 更新spu和spu_detail
// spuBo.setCreateTime(null);
spuBo.setLastUpdateTime(new Date());
//spuBo.setSaleable(null); 设置为null值依然会将其更新为Null值,如何设置null值的字段不更新?
//spuBo.setValid(null);
this.spuMapper.updateByPrimaryKey(spuBo);
this.spuDetailMapper.updateByPrimaryKey(spuBo.getSpuDetail());
//发送消息
try {
sendMessage("update",spuBo.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 根据spuId查询spu
* @param spuId
* @return
*/
@Override
public Spu querySpuBySpuId(Long spuId) {
Spu spu = this.spuMapper.selectByPrimaryKey(spuId);
return spu;
}
/**
* 根据skuId查询sku
* @param skuId
* @return
*/
@Override
public Sku querySkuByskuId(Long skuId) {
return this.skuMapper.selectByPrimaryKey(skuId);
}
/**
* 工作函数,用于将spuBo对象中的数据保存到sku和stock表之中
* @param spuBo
*/
private void saveSkuAndStock(SpuBo spuBo) {
List<Sku> skus = spuBo.getSkus();
skus.forEach(sku -> {
// 保存sku与stock
sku.setId(null);
sku.setSpuId(spuBo.getId());
sku.setCreateTime(new Date());
sku.setLastUpdateTime(sku.getCreateTime());
this.skuMapper.insertSelective(sku);
Stock stock = new Stock();
stock.setSkuId(sku.getId());
stock.setStock(sku.getStock());
this.stockMapper.insertSelective(stock);
});
}
}