0.学习目标

  • 实现商品分类查询功能
  • 掌握cors解决跨域
  • 实现品牌查询功能
  • 独立实现品牌新增
  • 实现图片上传
  • 使用OSS实现上传

2.实现商品分类查询

商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,其关系如图所示:

1525999005260

  • 一个商品分类下有很多商品
  • 一个商品分类下有很多品牌
  • 而一个品牌,可能属于不同的分类
  • 一个品牌下也会有很多商品

因此,我们需要依次去完成:商品分类、品牌、商品的开发。

2.1.导入数据

首先导入课前资料提供的sql:

1558192722551

我们先看商品分类表:

1525999774439

1
2
3
4
5
6
7
8
9
10
11
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 '排序指数,越小越靠前',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '数据创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '数据更新时间',
PRIMARY KEY (`id`),
KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品类目表,类目和商品(spu)是一对多关系,类目与品牌是多对多关系';

因为商品分类会有层级关系,因此这里我们加入了parent_id字段,对本表中的其它分类进行自关联。

我们这里的表,全部都没设置外键约束,约束在心中,减少外键维护成本

2.2.页面实现

2.2.1.页面分析

首先我们看下要实现的效果:

1533525736430

商品分类之间是会有层级关系的,采用树结构去展示是最直观的方式。

一起来看页面,对应的是/pages/item/Category.vue:

1526000313361

页面模板:

1
2
3
4
5
6
7
8
9
10
11
<v-card>
<v-flex xs12 sm10>
<v-tree url="/item/category/list"
:isEdit="isEdit"
@handleAdd="handleAdd"
@handleEdit="handleEdit"
@handleDelete="handleDelete"
@handleClick="handleClick"
/>
</v-flex>
</v-card>
  • v-card:卡片,是vuetify中提供的组件,提供一个悬浮效果的面板,一般用来展示一组数据。

    1526000692741

  • v-flex:布局容器,用来控制响应式布局。与BootStrap的栅格系统类似,整个屏幕被分为12格。我们可以控制所占的格数来控制宽度:

    1526001573140

    本例中,我们用sm10控制在小屏幕及以上时,显示宽度为10格

  • v-tree:树组件。Vuetify并没有提供树组件,这个是我们自己编写的自定义组件:

    1526001762446

    里面涉及一些vue的高级用法,大家暂时不要关注其源码,会用即可。

2.2.2.树组件的用法

也可参考课前资料中的:《自定义Vue组件的用法.md》

这里我贴出树组件的用法指南。

属性列表:

属性名称 说明 数据类型 默认值
url 用来加载数据的地址,即延迟加载 String -
isEdit 是否开启树的编辑功能 boolean false
treeData 整颗树数据,这样就不用远程加载了 Array -

这里推荐使用url进行延迟加载,每当点击父节点时,就会发起请求,根据父节点id查询子节点信息

当有treeData属性时,就不会触发url加载

远程请求返回的结果格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
{
"id": 74,
"name": "手机",
"parentId": 0,
"isParent": true,
"sort": 2
},
{
"id": 75,
"name": "家用电器",
"parentId": 0,
"isParent": true,
"sort": 3
}
]

事件:

事件名称 说明 回调参数
handleAdd 新增节点时触发,isEdit为true时有效 新增节点node对象,包含属性:name、parentId和sort
handleEdit 当某个节点被编辑后触发,isEdit为true时有效 被编辑节点的id和name
handleDelete 当删除节点时触发,isEdit为true时有效 被删除节点的id
handleClick 点击某节点时触发 被点击节点的node对象,包含全部信息

完整node的信息

回调函数中返回完整的node节点会包含以下数据:

1
2
3
4
5
6
7
8
{
"id": 76, // 节点id
"name": "手机", // 节点名称
"parentId": 75, // 父节点id
"isParent": false, // 是否是父节点
"sort": 1, // 顺序
"path": ["手机", "手机通讯", "手机"] // 所有父节点的名称数组
}

2.3.实现功能

2.3.1.url异步请求

给大家的页面中已经配置了url,我们刷新页面看看会发生什么:

1
2
3
4
5
6
7
<v-tree url="/item/category/of/parent"
:isEdit="isEdit"
@handleAdd="handleAdd"
@handleEdit="handleEdit"
@handleDelete="handleDelete"
@handleClick="handleClick"
/>

刷新页面,可以看到:

1552992441349

页面发起了一条请求:http://api.leyou.com/api/item/category/of/parent?pid=0

大家可能会觉得很奇怪,我们明明是使用的相对路径,讲道理发起的请求地址应该是:

http://manage.leyou.com/item/category/of/parent

但实际却是:

http://api.leyou.com/api/item/category/of/parent?pid=0

这是因为,我们有一个全局的配置文件,对所有的请求路径进行了约定:

1552922145908

1551274252988

路径是http://api.leyou.com/api,因此页面发起的一切请求都会以这个路径为前缀。

而我们的Nginx又完成了反向代理,将这个地址代理到了http://127.0.0.1:10010,也就是我们的Zuul网关,最终再被zuul转到微服务,有微服务来完成请求处理并返回结果。

因此接下来,我们要做的事情就是编写后台接口,返回对应的数据即可。

2.3.2.实体类

ly-item-service中添加category实体类,放到entity包下,代表与数据库交互的实体类:

1551274420088

类的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Table(name="tb_category")
@Data
public class Category {
@Id
@KeySql(useGeneratedKeys=true)
private Long id;
private String name;
private Long parentId;
private Boolean isParent;
private Integer sort;
private Date createTime;
private Date updateTime;
}

2.3.3.controller

编写一个controller一般需要知道四个内容:

  • 请求方式:决定我们用GetMapping还是PostMapping
  • 请求路径:决定映射路径
  • 请求参数:决定方法的参数
  • 返回值结果:决定方法的返回值

在刚才页面发起的请求中,我们就能得到绝大多数信息:

1552992441349

  • 请求方式:Get

  • 请求路径:/api/item/category/of/parent。其中/api是网关前缀,/item是网关的路由映射,真实的路径应该是/category/of/parent

  • 请求参数:pid=0,根据tree组件的说明,应该是父节点的id,第一次查询为0,那就是查询一级类目

  • 返回结果:??

    根据前面tree组件的用法我们知道,返回的应该是json数组:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    [
    {
    "id": 74,
    "name": "手机",
    "parentId": 0,
    "isParent": true,
    "sort": 2
    },
    {
    "id": 75,
    "name": "家用电器",
    "parentId": 0,
    "isParent": true,
    "sort": 3
    }
    ]

    对应的java类型可以是List集合,里面的元素就是与Category数据一致的对象了。

这里返回结果中并不包含createTime和updateTime字段,所以为了符合页面需求,我们需要给页面量身打造一个用来传递的数据转移对象(Data Transfer Object),简称为DTO。

流量要钱的,前端我们需要4个字段,后台返回7个字段,就超过了35%了,加入1M一块钱,就浪费了4毛钱。

另外带宽一定的话,返回的越多请求越慢。就好比,下载4G和下载2G视频的意思一样

我们在ly-item-pojo中来创建这样的对象:

1551275119791

代码:

1
2
3
4
5
6
7
8
@Data
public class CategoryDTO {
private Long id;
private String name;
private Long parentId;
private Boolean isParent;
private Integer sort;
}

因此我们查询的返回值就是List<CategoryDTO>

controller代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("category")
public class CategoryController {

@Autowired
private CategoryService categoryService;
/**
* 根据父节点查询商品类目
*
* @param pid
* @return
*/
@GetMapping("/of/parent")
public ResponseEntity<List<CategoryDTO>> queryByParentId(
@RequestParam(value = "pid", defaultValue = "0") Long pid) {
return ResponseEntity.ok(this.categoryService.queryListByParent(pid));
}
}

2.3.4.service

一般service层我们会定义接口和实现类,不过这里我们就偷懒一下,直接写实现类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class CategoryService {

@Autowired
private CategoryMapper categoryMapper;

public List<CategoryDTO> queryListByParent(Long pid) {
// 查询条件,mapper会把对象中的非空属性作为查询条件
Category t = new Category();
t.setParentId(pid);
List<Category> list = categoryMapper.select(t);
// 判断结果
if(CollectionUtils.isEmpty(list)){
throw new LyException(ExceptionEnum.CATEGORY_NOT_FOND);
}
// 使用自定义工具类,把Category集合转为DTO的集合
return BeanHelper.copyWithCollection(list, CategoryDTO.class);
}
}

这里转换的工具类相当于不用自己写属性映射了

image-20220325145810073

2.3.5.mapper

我们使用通用mapper来简化开发:

1
2
public interface CategoryMapper extends Mapper<Category> {
}

要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?

我们在启动类上添加一个扫描包功能:

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.leyou.item.mapper") // 扫描mapper包
public class LyItemApplication {
public static void main(String[] args) {
SpringApplication.run(LyItemApplication.class, args);
}
}

项目结构:

1527483458604

2.3.6.启动并测试

我们不经过网关,直接访问:

1526009785484

然后试试网关是否畅通:

1526017422684

一切OK!

然后刷新页面查看:

1526017362418

发现报错了!

浏览器直接访问没事,但是这里却报错,什么原因?

2.4.跨域问题

2.4.1.什么是跨域

跨域是指跨域名的访问,以下情况都属于跨域:

跨域原因说明 示例
域名不同 www.jd.comwww.taobao.com
域名相同,端口不同 www.jd.com:8080www.jd.com:8081
二级域名不同 item.jd.commiaosha.jd.com

如果域名和端口都相同,但是请求路径不同,不属于跨域,如:

www.jd.com/item

www.jd.com/goods

而我们刚才是从manage.leyou.com去访问api.leyou.com,这属于二级域名不同,跨域了。

2.4.2.为什么有跨域问题?

跨域不一定会有跨域问题。

因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是于当前页同域名的路径,这能有效的阻止跨站攻击。

因此:跨域问题 是针对ajax的一种限制

但是这却给我们的开发带来了不变,而且在实际生成环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?

2.4.3.解决跨域问题的方案

目前比较常用的跨域解决方案有3种:

  • Jsonp

    最早的解决方案,利用script标签可以跨域的原理实现。

    限制:

    • 需要服务的支持
    • 只能发起GET请求
  • nginx反向代理

    思路是:利用nginx反向代理把跨域为不跨域,支持各种请求方式

    缺点:需要在nginx进行额外配置,语义不清晰

  • CORS

    规范化的跨域请求解决方案,安全可靠。

    优势:

    • 在服务端进行控制是否允许跨域,可自定义规则
    • 支持各种请求方式

    缺点:

    • 可能会产生额外的请求

我们这里会采用cors的跨域方案。

2.5.cors解决跨域

2.5.1.什么是cors

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

  • 浏览器端:

    目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。

  • 服务端:

    CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否运行其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。

2.5.2.原理有点复杂

浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。

简单请求

只要同时满足以下两大条件,就属于简单请求。:

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现发现的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.

1526019242125

Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。

如果服务器允许跨域,需要在返回的响应头中携带下面信息:

1
2
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
  • 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请求,否则就报错。

一个“预检”请求的样板:

1
2
3
4
5
6
7
8
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:会额外用到的头信息

预检请求的响应

服务的收到预检请求,如果许可跨域,会发出响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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-OriginAccess-Control-Allow-Credentials以外,这里又额外多出3个头:

  • Access-Control-Allow-Methods:允许访问的方式
  • Access-Control-Allow-Headers:允许携带的头
  • Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了

如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。

2.5.3.实现非常简单

虽然原理比较复杂,但是前面说过:

  • 浏览器端都有浏览器自动完成,我们无需操心
  • 服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。

事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。

ly-api-gateway中编写一个配置类,并且注册CorsFilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class GlobalCORSConfig {
@Bean
public CorsFilter corsFilter() {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin("http://manage.leyou.com");
//2) 是否发送Cookie信息
config.setAllowCredentials(true);
//3) 允许的请求方式
config.addAllowedMethod("OPTIONS");
config.addAllowedMethod("HEAD");
config.addAllowedMethod("GET");
config.addAllowedMethod("PUT");
config.addAllowedMethod("POST");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("PATCH");
// 4)允许的头信息
config.addAllowedHeader("*");

//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);

//3.返回新的CORSFilter.
return new CorsFilter(configSource);
}
}

结构:

1534343900118

4.5.4.重启测试:

访问正常:

1526021419016

页面也OK了:

1526021447335

商品分类的增删改功能暂时就不做了,页面已经预留好了事件接口,有兴趣的同学可以完成一下。

2.4.4.优化

把一些属性抽取到配置文件application.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ly:
cors:
allowedOrigins:
- http://manage.leyou.com
allowCredentials: true
allowedHeaders:
- "*"
allowedMethods:
- GET
- POST
- DELETE
- PUT
- OPTIONS
- HEAD
maxAge: 3600
filterPath: "/**"

然后定义类,加载这些属性:

1
2
3
4
5
6
7
8
9
10
@Data
@ConfigurationProperties(prefix = "ly.cors")
public class CORSProperties {
private List<String> allowedOrigins;
private Boolean allowCredentials;
private List<String> allowedMethods;
private List<String> allowedHeaders;
private Long maxAge;
private String filterPath;
}

修改CORS配置类,读取属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableConfigurationProperties(CORSProperties.class)
public class GlobalCORSConfig {
@Bean
public CorsFilter corsFilter(CORSProperties prop) {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
prop.getAllowedOrigins().forEach(config::addAllowedOrigin);
//2) 是否发送Cookie信息
config.setAllowCredentials(prop.getAllowCredentials());
//3) 允许的请求方式
prop.getAllowedMethods().forEach(config::addAllowedMethod);
// 4)允许的头信息
prop.getAllowedHeaders().forEach(config::addAllowedHeader);
// 5)配置有效期
config.setMaxAge(prop.getMaxAge());

//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration(prop.getFilterPath(), config);

//3.返回新的CORSFilter.
return new CorsFilter(configSource);
}
}

3.品牌的查询

商品分类完成以后,自然轮到了品牌功能了。

先看看我们要实现的效果:

1526021968036

接下来,我们从0开始,实现下从前端到后端的完整开发。

3.1.从0开始

为了方便看到效果,我们新建一个MyBrand.vue,从0开始搭建。

1526023142926

内容初始化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<span>
hello
</span>
</template>

<script>
export default {
name: "my-brand"
}
</script>

<style scoped>

</style>

改变router新的index.js,将路由地址指向MyBrand.vue

1526023276997

打开服务器,再次查看页面:

1526023471428

干干净净了。

3.2.品牌查询页面

3.2.1.data-tables组件

大家看到这个原型页面肯定能看出,其主体就是一个table。我们去Vuetify查看有关table的文档:

1526023540226

仔细阅读,发现v-data-table中有以下核心属性:

  • dark:是否使用黑暗色彩主题,默认是false

  • expand:表格的行是否可以展开,默认是false

  • headers:定义表头的数组,数组的每个元素就是一个表头信息对象,结构:

    1
    2
    3
    4
    5
    6
    7
    8
    {
    text: string, // 表头的显示文本
    value: string, // 表头对应的每行数据的key
    align: 'left' | 'center' | 'right', // 位置
    sortable: boolean, // 是否可排序,默认true
    class: string[] | string,// 样式
    width: string,// 宽度
    }
  • items:表格的数据的数组,数组的每个元素是一行数据的对象,对象的key要与表头的value一致

  • loading:是否显示加载数据的进度条,默认是false

  • no-data-text:当没有查询到数据时显示的提示信息,string类型,无默认值

  • pagination.sync:包含分页和排序信息的对象,将其与vue实例中的属性关联,表格的分页或排序按钮被触发时,会自动将最新的分页和排序信息更新。对象结构:

    1
    2
    3
    4
    5
    6
    {
    page: 1, // 当前页
    rowsPerPage: 5, // 每页大小
    sortBy: '', // 排序字段
    descending:false, // 是否降序
    }
  • total-items:分页的总条数信息,number类型,无默认值

  • select-all :是否显示每一行的复选框,Boolean类型,无默认值

  • value:当表格可选的时候,返回选中的行

我们向下翻,找找有没有看起来牛逼的案例。

找到这样一条:

1526023837773

其它的案例都是由Vuetify帮我们对查询到的当前页数据进行排序和分页,这显然不是我们想要的。我们希望能在服务端完成对整体品牌数据的排序和分页,而这个案例恰好合适。

点击按钮,我们直接查看源码,然后直接复制到MyBrand.vue中

模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<v-data-table
:headers="headers"
:items="desserts"
:pagination.sync="pagination"
:total-items="totalDesserts"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.name }}</td>
<td class="text-xs-right">{{ props.item.calories }}</td>
<td class="text-xs-right">{{ props.item.fat }}</td>
<td class="text-xs-right">{{ props.item.carbs }}</td>
<td class="text-xs-right">{{ props.item.protein }}</td>
<td class="text-xs-right">{{ props.item.iron }}</td>
</template>
</v-data-table>
</div>
</template>

3.2.2.分析

接下来,就分析一下案例中每一部分是什么意思,搞清楚了,我们也可以自己玩了。

先看模板中table上的一些属性:

1
2
3
4
5
6
7
8
9
<v-data-table
:headers="headers"
:items="desserts"
:pagination.sync="pagination"
:total-items="totalDesserts"
:loading="loading"
class="elevation-1"
>
</v-data-table>
  • headers:表头信息,是一个数组

  • items:要在表格中展示的数据,数组结构,每一个元素是一行

  • search:搜索过滤字段,用不到,直接删除

  • pagination.sync:分页信息,包含了当前页,每页大小,排序字段,排序方式等。加上.sync代表服务端排序,当用户点击分页条时,该对象的值会跟着变化。监控这个值,并在这个值变化时去服务端查询,即可实现页面数据动态加载了。

  • total-items:总条数

  • loading:boolean类型,true:代表数据正在加载,会有进度条。false:数据加载完毕。

    1526029254159

另外,在v-data-tables中,我们还看到另一段代码:

1
2
3
4
5
6
7
8
<template slot="items" slot-scope="props">
<td>{{ props.item.name }}</td>
<td class="text-xs-right">{{ props.item.calories }}</td>
<td class="text-xs-right">{{ props.item.fat }}</td>
<td class="text-xs-right">{{ props.item.carbs }}</td>
<td class="text-xs-right">{{ props.item.protein }}</td>
<td class="text-xs-right">{{ props.item.iron }}</td>
</template>

这段就是在渲染每一行的数据。Vue会自动遍历上面传递的items属性,并把得到的对象传递给这段template中的props.item属性。我们从中得到数据,渲染在页面即可。

我们需要做的事情,主要有:

  • 定义表头,编写headers

    • 根据我们的表数据结构,主要是4个字段:id、name、letter、image
  • 获取表内容:items

    • 这个需要去后台查询Brand列表,可以先弄点假数据
  • 获取总条数:totalItems

    • 这个也需要去后台查,先写个假的
  • 定义分页对象:pagination

    • 这个值会有vuetify传给我们,我们不用管
  • 数据加载进度条:loading

    • 当我们加载数据时把这个值改成true,加载完毕改成false
  • 完成页面数据渲染部分 slot=”items” 的那个template标签

    • 基本上把Brand的四个字段在这里渲染出来就OK了,需要跟表头(headers)对应

3.2.3.初步实现

我们先弄点假品牌数据,可以参考数据库表:

1
2
3
4
5
6
7
[
{id: 2032, name: "OPPO", image: "1.jpg", letter: "O"},
{id: 2033, name: "飞利浦", image: "2.jpg", letter: "F"},
{id: 2034, name: "华为", image: "3.jpg", letter: "H"},
{id: 2036, name: "酷派", image: "4.jpg", letter: "K"},
{id: 2037, name: "魅族", image: "5.jpg", letter: "M"}
]

品牌中有id,name,image,letter字段。

修改模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div>
<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>{{ 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>
</template>
</v-data-table>
</div>

我们修改了以下部分:

  • items:指向一个brands变量,等下在js代码中定义
  • total-items:指向了totalBrands变量,等下在js代码中定义
  • template模板中,渲染了四个字段:
    • id:
    • name
    • image,注意,我们不是以文本渲染,而是赋值到一个img标签的src属性中,并且做了非空判断
    • letter

编写数据

接下来编写要用到的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
data() {
return {
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,}
]
}
}
}

编写函数,初始化数据

接下来就是对brands和totalBrands完成赋值动作了。

我们编写一个函数来完成赋值,提高复用性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
methods:{
getDataFromServer(){ // 从服务的加载数据的方法。
// 打印一句话,证明在加载
console.log("开始加载了。。。。")
// 开启加载
this.loading = true;
// 模拟延迟一段时间,随后进行赋值
setTimeout(() => {
// 然后赋值给brands
this.brands = [
{"id": 2032,"name": "OPPO", "image": "1.jpg","letter": "O"},
{"id": 2033, "name": "飞利浦","image": "2.jpg", "letter": "F"},
{"id": 2034,"name": "华为(HUAWEI)","image": "3.jpg","letter": "H"},
{"id": 2036,"name": "酷派(Coolpad)","image": "4.jpg","letter": "K"},
{"id": 2037,"name": "魅族(MEIZU)","image": "5.jpg","letter": "M"}
];
// 总条数暂时写死
this.totalBrands = 20;
// 完成赋值后,把加载状态赋值为false
this.loading = false;
},400)
}
}

然后使用生命周期钩子函数,在Vue实例初始化完毕后调用这个方法,这里使用created函数:

1
2
3
4
mounted(){ // 渲染后执行
// 查询数据
this.getDataFromServer();
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<template>
<div>
<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>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img :src="props.item.image"></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
</template>
</v-data-table>
</div>
</template>

<script>
export default {
name: "my-brand",
data() {
return {
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,}
]
}
},
mounted(){ // 渲染后执行
// 查询数据
this.getDataFromServer();
},
methods:{
getDataFromServer(){ // 从服务的加载数据的方法。
console.log(this.pagination);
// 开启加载
this.loading = true;
// 模拟延迟一段时间,随后进行赋值
setTimeout(() => {
// 然后赋值给brands
this.brands = [
{"id": 2032,"name": "OPPO", "image": "1.jpg","letter": "O"},
{"id": 2033, "name": "飞利浦","image": "2.jpg", "letter": "F"},
{"id": 2034,"name": "华为","image": "3.jpg","letter": "H"},
{"id": 2036,"name": "酷派","image": "4.jpg","letter": "K"},
{"id": 2037,"name": "魅族","image": "5.jpg","letter": "M"}
];
this.totalBrands = brands.length;
// 完成赋值后,把加载状态赋值为false
this.loading = false;
},400)
}
}
}
</script>

刷新页面查看:

1526029445561

注意,我们上面提供的假数据,因此大家的页面可能看不到图片信息!

3.2.4.优化页面

编辑和删除按钮

我们将来要对品牌进行增删改,需要给每一行数据添加 修改删除的按钮,一般放到改行的最后一列:

1526029907794

其实就是多了一列,只是这一列没有数据,而是两个按钮而已。

我们先在头(headers)中添加一列:

1
2
3
4
5
6
7
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}
]

然后在模板中添加按钮:

1
2
3
4
5
6
7
8
9
<template slot="items" slot-scope="props">
<td>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img :src="props.item.image"></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="justify-center">
编辑/删除
</td>
</template>

因为不知道按钮怎么写,先放个普通文本看看:

1526030236992

然后在官方文档中找到按钮的用法:

1526030329303

修改我们的模板:

1
2
3
4
5
6
7
8
9
10
<template slot="items" slot-scope="props">
<td>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img :src="props.item.image"></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="justify-center layout">
<v-btn color="info">编辑</v-btn>
<v-btn color="warning">删除</v-btn>
</td>
</template>

1526030431704

新增按钮

因为新增根某个品牌无关,是独立的,因此我们可以放到表格的外面:

1526030663178

效果:

1526030540341

卡片(card)

为了不让按钮显得过于孤立,我们可以将按新增按钮表格放到一张卡片(card)中。

我们去官网查看卡片的用法:

1526031159242

卡片v-card包含四个基本组件:

  • v-card-media:一般放图片或视频
  • v-card-title:卡片的标题,一般位于卡片顶部
  • v-card-text:卡片的文本(主体内容),一般位于卡片正中
  • v-card-action:卡片的按钮,一般位于卡片底部

我们可以把新增的按钮放到v-card-title位置,把table放到下面,这样就成一个上下关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<v-card>
<!-- 卡片的头部 -->
<v-card-title>
<v-btn color="primary">新增</v-btn>
</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>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img :src="props.item.image"></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="justify-center layout">
<v-btn color="info">编辑</v-btn>
<v-btn color="warning">删除</v-btn>
</td>
</template>
</v-data-table>
</v-card>

效果:

1526031720583

添加搜索框

我们还可以在卡片头部添加一个搜索框,其实就是一个文本输入框。

查看官网中,文本框的用法:

1526031897445

  • name:字段名,表单中会用到
  • label:提示文字
  • value:值。可以用v-model代替,实现双向绑定

修改模板,添加输入框:

1
2
3
4
5
<v-card-title>
<v-btn color="primary">新增品牌</v-btn>
<!--搜索框,与search属性关联-->
<v-text-field label="输入关键字搜索" v-model="search"/>
</v-card-title>

需要在data中添加属性:search

1534374295695

效果:

1526032177687

发现输入框变的超级长!!!

这个时候,我们可以使用Vuetify提供的一个空间隔离工具:

1526032321057

修改代码:

1
2
3
4
5
6
7
<v-card-title>
<v-btn color="primary">新增品牌</v-btn>
<!--空间隔离组件-->
<v-spacer />
<!--搜索框,与search属性关联-->
<v-text-field label="输入关键字搜索" v-model="search"/>
</v-card-title>

1526032398630

给搜索框添加搜索图标

查看textfiled的文档,发现:

1526033007616

通过append-icon属性可以为 输入框添加后置图标,所有可用图标名称可以到 material-icons官网去查看。

修改我们的代码:

1
<v-text-field label="输入关键字搜索" v-model="search" append-icon="search"/>

1526033167381

把文本框变紧凑

搜索框看起来高度比较高,页面不够紧凑。这其实是因为默认在文本框下面预留有错误提示空间。通过下面的属性可以取消提示:

1526033439890

修改代码:

1
<v-text-field label="输入关键字搜索" v-model="search" append-icon="search" hide-details/>

效果:

1526033500219

几乎已经达到了原来一样的效果了吧!

3.3.后台提供查询接口

前台页面已经准备好,接下来就是后台提供数据接口了。

3.3.1.数据库表

1
2
3
4
5
6
7
8
9
CREATE TABLE `tb_brand` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
`name` varchar(64) NOT NULL COMMENT '品牌名称',
`image` varchar(256) DEFAULT '' COMMENT '品牌图片地址',
`letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325403 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';

简单的四个字段,不多解释。

这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:

1
2
3
4
5
6
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='商品分类和品牌的中间表,两者是多对多关系';

但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?

  • 外键会严重影响数据库读写的效率
  • 数据删除时会比较麻烦

在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。

3.3.2.实体类

1552922819904

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Table(name = "tb_brand")
public class Brand {
@Id
@KeySql(useGeneratedKeys = true)
private Long id;
private String name;
private String image;
private Character letter;
private Date createTime;
private Date updateTime;
}

3.3.3.mapper

通用mapper来简化开发:

1
2
public interface BrandMapper extends Mapper<Brand> {
}

3.3.4.controller

编写controller先思考四个问题,这次没有前端代码,需要我们自己来设定

  • 请求方式:查询,肯定是Get

  • 请求路径:分页查询,/brand/page

  • 请求参数:根据我们刚才编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:

    • page:当前页,int
    • rows:每页大小,int
    • sortBy:排序字段,String
    • desc:是否为降序,boolean
    • key:搜索关键词,String
  • 响应结果:分页结果一般至少需要两个数据

    • total:总条数
    • items:当前页数据
    • totalPage:有些还需要总页数

    这里我们封装一个类,来表示分页结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Data
    public class PageResult<T> {
    private Long total;// 总条数
    private Integer totalPage;// 总页数
    private List<T> items;// 当前页数据

    public PageResult() {
    }

    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;
    }
    }

    另外,这个PageResult以后可能在其它项目中也有需求,因此我们将其抽取到ly-common中,提高复用性:

    1534373131667

    而后,PageResult中的数据,应该是Brand。跟Category一样,我们需要定义BrandDTO在ly-item-pojo中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    package com.leyou.item.dto;


    @Data
    public class BrandDTO {
    private Long id;
    private String name;
    private String image;
    private Character letter;
    }

接下来,我们编写Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("brand")
public class BrandController {

@Autowired
private BrandService brandService;

@GetMapping("page")
public ResponseEntity<PageResult<BrandDTO>> queryBrandByPage(
@RequestParam(value = "page", defaultValue = "1")Integer page,
@RequestParam(value = "rows", defaultValue = "5")Integer rows,
@RequestParam(value = "key", required = false)String key,
@RequestParam(value = "sortBy", required = false)String sortBy,
@RequestParam(value = "desc", defaultValue = "false")Boolean desc
){
return ResponseEntity
.ok(brandService.queryBrandByPage(page,rows, key, sortBy, desc));
}
}

3.3.5.Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public PageResult<BrandDTO> queryBrandByPage(Integer page, Integer rows, String key, String sortBy, Boolean desc) {
// 分页
PageHelper.startPage(page, rows);
// 过滤条件
Example example = new Example(Brand.class);
if(StringUtils.isNoneBlank(key)) {
example.createCriteria().orLike("name", "%" + key + "%")
.orEqualTo("letter", key.toUpperCase());
}
// 排序
if(StringUtils.isNoneBlank(sortBy)) {
String orderByClause = sortBy + (desc ? " DESC" : " ASC");
example.setOrderByClause(orderByClause);// id desc
}
// 查询
List<Brand> brands = brandMapper.selectByExample(example);

// 判断是否为空
if(CollectionUtils.isEmpty(brands)){
throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
}

// 解析分页结果
PageInfo<Brand> info = new PageInfo<>(brands);

// 转为BrandDTO
List<BrandDTO> list = BeanHelper.copyWithCollection(brands, BrandDTO.class);

// 返回
return new PageResult<>(info.getTotal(), list);
}

完整结构:

1527483582757

3.3.6.测试

通过浏览器访问试试:http://api.leyou.com/api/item/brand/page

1526047708748

接下来,去页面请求数据并渲染

3.4.异步查询工具axios

异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。

https://www.kancloud.cn/yunye/axios/234845

3.3.1.axios入门

Vue官方推荐的ajax请求框架叫做:axios,看下demo:

1526033988251

axios支持Http的所有7种请求方式,并且有对应的方法如:Get、POST与其对应。另外这些方法最终返回的是一个Promise,对异步调用进行封装。因此,我们可以用.then() 来接收成功时回调,.catch()完成失败时回调

axios的Get请求语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
.then(function(resp){
// 成功回调函数
})
.catch(function(){
// 失败回调函数
})
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
params:{
pid:0
}
})
.then(function(resp){})// 成功时的回调
.catch(function(error){})// 失败时的回调

axios的POST请求语法:

比如新增一个用户

1
2
3
4
5
6
axios.post("/user",{
name:"Jack",
age:21
})
.then(function(resp){})
.catch(function(error){})
  • 注意,POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数

PUT和DELETE请求与POST请求类似

3.3.2.axios的全局配置

而在我们的项目中,已经引入了axios,并且进行了简单的封装,在src下的http.js中:

1552922899940

http.js中对axios进行了一些默认配置:

1
2
3
4
5
6
7
8
9
import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.leyou.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间

Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象

  • http.js中导入了config的配置,还记得吗?

    1551274252988

  • http.js对axios进行了全局配置:baseURL=config.api,即http://api.leyou.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。

  • 通过Vue.property.$http = axios,将axios赋值给了 Vue原型中的$http。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。

3.3.3.测试一下:

我们在组件MyBrand.vue的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:

1526048221750

网络监视:

1526048143014

控制台结果:

1526048275064

可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据。

响应结果中与我们设计的一致,包含3个内容:

  • total:总条数,目前是165
  • items:当前页数据
  • totalPage:总页数,我们没有返回

3.5.异步加载品牌数据

虽然已经通过ajax请求获取了品牌数据,但是刚才的请求没有携带任何参数,这样显然不对。我们后端接口需要5个参数:

  • page:当前页,int
  • rows:每页大小,int
  • sortBy:排序字段,String
  • desc:是否为降序,boolean
  • key:搜索关键词,String

而页面中分页信息应该是在pagination对象中,我们通过浏览器工具,查看pagination中有哪些属性:

分别是:

  • descending:是否是降序,对应请求参数的desc
  • page:当前页,对应参数的page
  • rowsPerpage:每页大小,对应参数中的rows
  • sortBy:排序字段,对应参数的sortBy

缺少一个搜索关键词,这个应该是通过v-model与输入框绑定的属性:search。这样,所有参数就都有了。

另外,不要忘了把查询的结果赋值给brands和totalBrands属性,Vuetify会帮我们渲染页面。

接下来,我们在getDataFromServer方法中完善请求参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 发起请求
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;
})

查看网络请求:

1526049810351

效果:

1526049139244

3.6.完成分页和过滤

3.6.1.分页

现在我们实现了页面加载时的第一次查询,你会发现你点击分页并没发起请求,怎么办?

虽然点击分页,不会发起请求,但是通过浏览器工具查看,会发现pagination对象的属性一直在变化:

我们可以利用Vue的监视功能:watch,当pagination发生改变时,调用我们的回调函数,我们在回调函数中进行数据的查询即可!

具体实现:

1526049643506

刷新页面测试,成功实现分页功能:

1526049720200

3.6.2.过滤

分页实现了,过滤也很好实现了。过滤字段对应的是search属性,我们只要监视这个属性即可:

1526049939985

查看网络请求:

1526050032436

页面结果:

1526050071442

3.7.完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<template>
<v-card>
<v-card-title>
<v-btn color="primary" @click="addBrand">新增品牌</v-btn>
<!--搜索框,与search属性关联-->
<v-spacer/>
<v-text-field label="输入关键字搜索" v-model.lazy="search" append-icon="search" hide-details/>
</v-card-title>
<v-divider/>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img :src="props.item.image"></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="justify-center layout">
<v-btn color="info">编辑</v-btn>
<v-btn color="warning">删除</v-btn>
</td>
</template>
</v-data-table>
</v-card>
</template>

<script>
import MyBrandForm from './MyBrandForm'
export default {
name: "my-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}
]
}
},
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;
})
}
}
}
</script>

<style scoped>

</style>

-

4.品牌的新增

品牌新增的功能,我们就不再编写页面了,使用课前资料中给出的页面即可。

1.1 页面请求

点击页面品牌管理按钮,可以看到品牌的列表页面:

1558714984825

此时点击新增品牌按钮,即可看到品牌新增的页面:

1552139501851

填写基本信息,此时,点击提交按钮,可以看到页面已经发出请求:

1552139565844

1.2.后台实现新增

1.2.1.controller

还是一样,先分析四个内容:

  • 请求方式:刚才看到了是POST
  • 请求路径:/brand
  • 请求参数:brand对象的三个属性,可以用BrandDTO接收,外加商品分类的id数组cids
  • 返回值:无

代码:

1
2
3
4
5
6
7
8
9
10
/**
* 新增品牌
* @param brand
* @return
*/
@PostMapping
public ResponseEntity<Void> saveBrand(BrandDTO brand, @RequestParam("cids") List<Long> ids) {
brandService.saveBrand(brand, ids);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

1.2.2.Service

这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
public void saveBrand(BrandDTO brandDTO, List<Long> ids) {
// 新增品牌
Brand brand = BeanHelper.copyProperties(brandDTO, Brand.class);
brand.setId(null);
int count = brandMapper.insertSelective(brand);
if(count != 1){
// 新增失败,抛出异常
throw new LyException(ExceptionEnum.INSERT_OPERATION_FAIL);
}
// 新增品牌和分类中间表
count = brandMapper.insertCategoryBrand(brand.getId(), ids);
if(count != ids.size()){
// 新增失败,抛出异常
throw new LyException(ExceptionEnum.INSERT_OPERATION_FAIL);
}
}

这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增

1.2.3.Mapper

通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.leyou.item.mapper;

import com.leyou.item.entity.Brand;
import org.apache.ibatis.annotations.Param;
import tk.mybatis.mapper.common.Mapper;

import java.util.List;


public interface BrandMapper extends Mapper<Brand> {
/**
* 新增商品分类和品牌中间表数据
* @param ids 商品分类id集合
* @param bid 品牌id
* @return 新增的条数
*/
int insertCategoryBrand(@Param("bid") Long bid, @Param("ids") List<Long> ids);
}

在resource下新建一个目录:mappers,并在下面新建文件:BrandMapper.xml

1552143745932

然后在application.yml文件中配置mapper文件的地址:

1
2
3
4
5
mybatis:
type-aliases-package: com.leyou.item.entity
configuration:
map-underscore-to-camel-case: true
mapper-locations: mappers/*.xml

BrandMapper.xml中定义Sql的statement:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.leyou.item.mapper.BrandMapper">

<insert id="insertCategoryBrand">
INSERT INTO tb_category_brand (category_id, brand_id)
<foreach collection="ids" open="VALUES" separator="," item="id">
(#{id}, #{bid})
</foreach>
</insert>
</mapper>

5.实现图片上传

刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。

文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。

2.1.搭建项目

2.1.1.创建module

1552144636424

保存路径:

1552144664438

2.1.2.依赖

我们需要EurekaClient和web依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>ly-upload</artifactId>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

2.1.3.编写配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server:
port: 8082
spring:
application:
name: upload-service
servlet:
multipart:
max-file-size: 5MB # 限制文件上传的大小
# Eureka
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
ip-address: 127.0.0.1
prefer-ip-address: true

需要注意的是,我们应该添加了限制文件大小的配置

2.1.4.启动类

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableDiscoveryClient
public class LyUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LyUploadApplication.class, args);
}
}

结构:

1552144792255

2.2.编写上传功能

2.2.1.controller

页面的上传组件,需要做一个简单的修改,去除一个属性need-signature:

1556611899176

另外,地址修改成/upload/image

点击新增品牌页面的上传图片按钮,即可看到上传图片请求:

1552144440922

从图中很容易看出编写controller需要知道4个内容:

  • 请求方式:上传肯定是POST
  • 请求路径:/upload/image
  • 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipleFile
  • 返回结果:这里上传与表单分离,文件不是跟随表单一起提交,而是单独上传并得到结果,随后把结果跟随表单提交。因此上传成功后需要返回一个可以访问的文件的url路径

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class UploadController {

@Autowired
private UploadService uploadService;

/**
* 上传图片功能
* @param file
* @return
*/
@PostMapping("image")
public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) {
// 返回200,并且携带url路径
return ResponseEntity.ok(this.uploadService.upload(file));
}
}

2.2.2.service

在上传文件过程中,我们需要对上传的内容进行校验:

  1. 校验文件大小
  2. 校验文件的媒体类型
  3. 校验文件的内容

文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。

具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
public class UploadService {

// 支持的文件类型
private static final List<String> suffixes = Arrays.asList("image/png", "image/jpeg", "image/bmp");

public String upload(MultipartFile file) {
// 1、图片信息校验
// 1)校验文件类型
String type = file.getContentType();
if (!suffixes.contains(type)) {
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}

// 2)校验图片内容
BufferedImage image = null;
try {
image = ImageIO.read(file.getInputStream());
} catch (IOException e) {
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}
if (image == null) {
throw new LyException(ExceptionEnum.INVALID_FILE_TYPE);
}

// 2、保存图片
// 2.1、生成保存目录,保存到nginx所在目录的html下,这样可以直接通过nginx来访问到
File dir = new File("C:\\develop\\nginx-1.12.2\\html\\");
if (!dir.exists()) {
dir.mkdirs();
}
try {
// 2.2、保存图片
file.transferTo(new File(dir, file.getOriginalFilename()));

// 2.3、拼接图片地址
return "http://image.leyou.com/" + file.getOriginalFilename();
} catch (Exception e) {
throw new LyException(ExceptionEnum.FILE_UPLOAD_ERROR);
}
}
}

这里有一个问题:为什么图片地址需要使用另外的url?

  • 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
  • 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量

当然,为了能够通过域名http://image.leyou.com来访问我们的图片,我们还需要结合hosts和nginx来实现。

首先将http://image.leyou.com的地址指向本机:

1552264759518

然后修改nginx,反向代理到本地的html目录:

1
2
3
4
5
6
7
server {
listen 80;
server_name image.leyou.com;
location / {
root html;
}
}

2.2.3.测试上传

我们通过RestClient工具来测试:

1526196967376

结果:

1526197027688

2.2.4.Nginx的请求大小限制

我们上传一个超过1M大小的文件

1552145659243

发现收到错误响应:

1552145695272

这是nginx对文件大小的限制,我们leyou.conf中的server的外面,设置大小限制为5M:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
client_max_body_size 5m;	

server {
listen 80;
server_name api.leyou.com;

proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

location / {
proxy_pass http://127.0.0.1:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}

再次测试,发现Nginx不报错了,但是出现了新的问题:

1552145925766

这次的错误似乎是服务端报错的。是什么原因呢?

2.2.5.绕过网关缓存

上述错误是经过查看是网关Zuul报错的。为什么呢?因为文件上传会先经过zuul,再到ly-upload。而zuul这里发现文件过大,于是就报错了。

默认情况下,所有的请求经过Zuul网关的代理,默认会通过SpringMVC预先对请求进行处理,缓存。普通请求并不会有什么影响,但是对于文件上传,==就会造成造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用==。这样我们的整个系统就瘫痪了。

所以,我们上传文件的请求需要绕过请求的缓存,直接通过路由到达目标微服务:

Zuul is implemented as a Servlet. For the general cases, Zuul is embedded into the Spring Dispatch mechanism. This lets Spring MVC be in control of the routing. In this case, Zuul buffers requests. If there is a need to go through Zuul without buffering requests (for example, for large file uploads), the Servlet is also installed outside of the Spring Dispatcher. By default, the servlet has an address of /zuul. This path can be changed with the zuul.servlet-path property.

现在,查看页面的请求路径:

1526196446765

我们需要修改到以/zuul为前缀,可以通过nginx的rewrite指令实现这一需求:

Nginx提供了rewrite指令,用于对地址进行重写,语法规则:

1
rewrite "用来匹配路径的正则" 重写后的路径 [指令];

我们的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
client_max_body_size 5m;	
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 {
rewrite "^/(.*)$" /zuul/$1;
}

location / {
proxy_pass http://127.0.0.1:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
  • 首先,我们映射路径是/api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload优先级更高。也就是说,凡是以/api/upload开头的路径,都会被第一个配置处理

  • rewrite "^/(.*)$" /zuul/$1,路径重写:

  • "^/(.*)$":匹配路径的正则表达式,用了分组语法,把/以后的所有部分当做1组

  • /zuul/$1:重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),在原始路径的基础上,添加了/zuul前缀。

修改完成,输入nginx -s reload命令重新加载配置。

然后测试:

1526200606487

2.2.6.之前上传的缺陷

先思考一下,之前上传的功能,有没有什么问题?

上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:

  • 单机器存储,存储能力有限
  • 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
  • 数据没有备份,有单点故障风险
  • 并发能力差

这个时候,最好使用分布式文件存储来代替本地文件存储。

6.阿里云对象存储OSS

3.1.什么是分布式文件系统

分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。

通俗来讲:

  • 传统文件系统管理的文件就存储在本机。
  • 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问

常见的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)、Fast DFS(淘宝)等。

不过,企业自己搭建分布式文件系统成本较高,对于一些中小型企业而言,使用云上的文件存储,是性价比更高的选择。

3.2.阿里云OSS

阿里的OSS就是一个文件云存储方案:

1552265269170

简介:

阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。其数据设计持久性不低于99.999999999%,服务设计可用性不低于99.99%。具有与平台无关的RESTful API接口,您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。

您可以使用阿里云提供的API、SDK接口或者OSS迁移工具轻松地将海量数据移入或移出阿里云OSS。数据存储到阿里云OSS以后,您可以选择标准类型(Standard)的阿里云OSS服务作为移动应用、大型网站、图片分享或热点音视频的主要存储方式,也可以选择成本更低、存储期限更长的低频访问类型(Infrequent Access)和归档类型(Archive)的阿里云OSS服务作为不经常访问数据的备份和归档。

3.2.1 开通oss访问

首先登陆阿里云,然后找到对象存储的产品:

1552307302707

点击进入后,开通服务:

1552307241886

随后即可进入管理控制台:

1552307724634

3.2.2.基本概念

OSS中包含一些概念,我们来认识一下:

  • 存储类型(Storage Class)

    OSS提供标准、低频访问、归档三种存储类型,全面覆盖从热到冷的各种数据存储场景。其中标准存储类型提供高可靠、高可用、高性能的对象存储服务,能够支持频繁的数据访问;低频访问存储类型适合长期保存不经常访问的数据(平均每月访问频率1到2次),存储单价低于标准类型;归档存储类型适合需要长期保存(建议半年以上)的归档数据,在三种存储类型中单价最低。详情请参见存储类型介绍

  • 存储空间(Bucket)

    存储空间是您用于存储对象(Object)的容器,所有的对象都必须隶属于某个存储空间。您可以设置和修改存储空间属性用来控制地域、访问权限、生命周期等,这些属性设置直接作用于该存储空间内所有对象,因此您可以通过灵活创建不同的存储空间来完成不同的管理功能。

  • 对象/文件(Object)

    对象是 OSS 存储数据的基本单元,也被称为OSS的文件。对象由元信息(Object Meta),用户数据(Data)和文件名(Key)组成。对象由存储空间内部唯一的Key来标识。对象元信息是一组键值对,表示了对象的一些属性,比如最后修改时间、大小等信息,同时您也可以在元信息中存储一些自定义的信息。

  • 地域(Region)

    地域表示 OSS 的数据中心所在物理位置。您可以根据费用、请求来源等综合选择数据存储的地域。详情请参见OSS已开通的Region

  • 访问域名(Endpoint

    Endpoint 表示OSS对外服务的访问域名。OSS以HTTP RESTful API的形式对外提供服务,当访问不同地域的时候,需要不同的域名。通过内网和外网访问同一个地域所需要的域名也是不同的。具体的内容请参见各个Region对应的Endpoint

  • 访问密钥(AccessKey)

    AccessKey,简称 AK,指的是访问身份验证中用到的AccessKeyId 和AccessKeySecret。OSS通过使用AccessKeyId 和AccessKeySecret对称加密的方法来验证某个请求的发送者身份。AccessKeyId用于标识用户,AccessKeySecret是用户用于加密签名字符串和OSS用来验证签名字符串的密钥,其中AccessKeySecret 必须保密。

以上概念中,跟我们开发中密切相关的有三个:

  • 存储空间(Bucket)
  • 访问域名(Endpoint)
  • 访问密钥(AccessKey):包含了AccessKeyId 和AccessKeySecret。

3.2.3.创建一个bucket

在控制台的右侧,可以看到一个新建Bucket按钮:

1552308905874

点击后,弹出对话框,填写基本信息:

1552309049398

注意点:

  • bucket:存储空间名称,名字只能是字母、数字、中划线
  • 区域:即服务器的地址,这里选择了上海
  • Endpoint:选中区域后,会自动生成一个Endpoint地址,这将是我们访问OSS服务的域名的组成部分
  • 存储类型:默认
  • 读写权限:这里我们选择公共读,否则每次访问都需要额外生成签名并校验,比较麻烦。敏感数据不要请都设置为私有!
  • 日志:不开通

3.2.4.创建AccessKey

有了bucket就可以进行文件上传或下载了。不过,为了安全考虑,我们给阿里云账户开通一个子账户,并设置对OSS的读写权限。

点击屏幕右上角的个人图像,然后点击访问控制:

1552309424324

在跳转的页面中,选择用户,并新建一个用户:

1552309517332

然后填写用户信息:

1552309580867

然后会为你生成用户的AccessKeyID和AccessKeySecret:

1552309726968

妥善保管,不要告诉任何人!

接下来,我们需要给这个用户添加对OSS的控制权限。

进入这个新增的用户详情页面:

1552309892306

点击添加权限,会进入权限选择页面,输入oss进行搜索,然后选择管理对象存储服务(OSS)权限:

1552309962457

3.3.上传文件最佳实践

在控制台的右侧,点击开发者指南按钮,即可查看帮助文档:

1552310900485

然后在弹出的新页面的左侧菜单中找到开发者指南:

1552310990458

可以看到上传文件中,支持多种上传方式,并且因为提供的Rest风格的API,任何语言都可以访问OSS实现上传。

我们可以直接使用java代码来实现把图片上传到OSS,不过这样以来文件会先从客户端浏览器上传到我们的服务端tomcat,然后再上传到OSS,效率较低,如图:

1552311281042

以上方法有三个缺点:

  • 上传慢。先上传到应用服务器,再上传到OSS,网络传送比直传到OSS多了一倍。如果直传到OSS,不通过应用服务器,速度将大大提升,而且OSS采用BGP带宽,能保证各地各运营商的速度。
  • 扩展性差。如果后续用户多了,应用服务器会成为瓶颈。
  • 费用高。需要准备多台应用服务器。由于OSS上传流量是免费的,如果数据直传到OSS,不通过应用服务器,那么将能省下几台应用服务器。

在阿里官方的最佳实践中,推荐了更好的做法:

1552311136676

直接从前端(客户端浏览器)上传文件到OSS。

3.3.1.web前端直传分析

阿里官方文档中,对于web前端直传又给出了3种不同方案:

  • JavaScript客户端签名直传:客户端通过JavaScript代码完成签名,然后通过表单直传数据到OSS。
  • 服务端签名后直传:客户端上传之前,由服务端完成签名,前端获取签名,然后通过表单直传数据到OSS。
  • 服务端签名直传并设置上传回调:服务端完成签名,并且服务端设置了上传后回调,然后通过表单直传数据到OSS。OSS回调完成后,再将应用服务器响应结果返回给客户端。

各自有一些优缺点:

  • JavaScript客户端签名直传:
    • 优点:在客户端通过JavaScript代码完成签名,无需过多配置,即可实现直传,非常方便。
    • 问题:客户端通过JavaScript把AccesssKeyID 和AccessKeySecret写在代码里面有泄露的风险
  • 服务端签名,JavaScript客户端直传:
    • 优点:Web端向服务端请求签名,然后直接上传,不会对服务端产生压力,而且安全可靠
    • 问题:服务端无法实时了解用户上传了多少文件,上传了什么文件
  • 服务端签名直传并设置上传回调:
    • 优点:包含服务端签名后客户端直传的所有优点,并且可以设置上传回调接口路径。上传结束后,OSS会主动将上传的结果信息发送给回调接口。

经过一番分析,大家觉得我们会选哪种方案?

这里我们选择第二种,因为我们并不需要了解用户上传的文件的情况。

3.3.2.服务端签名后直传流程

服务端签名后直传的原理如下:

  1. 用户发送上传Policy请求到应用服务器(我们的微服务)。
  2. 应用服务器返回上传Policy和签名给用户。
  3. 用户直接上传数据到OSS。

流程图:

1552311833528

根据上面的流程,我们需要做的事情包括:

  • 改造文件上传组件,达成下面的目的:
    • 上传文件前,向服务端发起请求,获取签名
    • 上传时,修改上传目的地到阿里OSS服务,并携带上传的签名
  • 编写服务端接口,接收请求,生成签名后返回

3.4.改造上传组件

文件上传组件是我们自定义组件,用法可以参考课前资料的:《自定义组件用法》。

而且,我们的上传组件内部已经实现了对阿里OSS的支持了:

1552314430650

所以我们只需要在BrandForm页面中,给v-upload组件,加上need-signature属性,并且指定一个获取签名的url地址即可,并且要修改上传地址:upload/signature

1552314746644

自定义的上传组件中的核心代码:

1552315238995

这段代码的核心思路:

  • 判断needSignature是否为true,true代表需要签名,false代表不需要
  • 如果false,直接上传文件到url
  • 如果为true,则把url作为签名接口路径,访问获取签名
  • 拿到响应结果后,其中可不仅仅包括签名,而是包括:
    • host:上传到阿里的url路径(bucket + endpoint
    • policy:上传的策略
    • signature:签名
    • accessId:就是AccessKeyID
    • dir:在oss的bucket中的子文件夹的名称,可以不写。

因此我们需要在签名返回时,携带这些参数。

刷新页面,可以看到请求已经发出:

1552315014021

3.5.编写签名接口

我们依然在ly-upload项目中完成签名接口编写。

3.5.1.思路分析

参考阿里的官方文档

1556548616524

特别需要注意这里的步骤3,我们向阿里提交的请求属于跨域请求,因此需要配置跨域许可:

1556549242811

设置我们的域名,请求方式是POST,因为是文件上传。

1556549324534

下面给出了一段示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {

String accessId = "<yourAccessKeyId>"; // 请填写您的AccessKeyId。
String accessKey = "<yourAccessKeySecret>"; // 请填写您的AccessKeySecret。
String endpoint = "oss-cn-hangzhou.aliyuncs.com"; // 请填写您的 endpoint。
String bucket = "bucket-name"; // 请填写您的 bucketname 。
String host = "http://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
// callbackUrl为 上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实信息。
String callbackUrl = "http://88.88.88.88:8888";
String dir = "user-dir-prefix/"; // 用户上传文件时指定的前缀。

OSSClient client = new OSSClient(endpoint, accessId, accessKey);

try {
long expireTime = 30;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

String postPolicy = client.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = client.calculatePostSignature(postPolicy);

Map<String, String> respMap = new LinkedHashMap<String, String>();
respMap.put("accessid", accessId);
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("expire", String.valueOf(expireEndTime / 1000));
// respMap.put("expire", formatISO8601Date(expiration));

JSONObject jasonCallback = new JSONObject();
jasonCallback.put("callbackUrl", callbackUrl);
jasonCallback.put("callbackBody",
"filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");
jasonCallback.put("callbackBodyType", "application/x-www-form-urlencoded");
String base64CallbackBody = BinaryUtil.toBase64String(jasonCallback.toString().getBytes());
respMap.put("callback", base64CallbackBody);

JSONObject ja1 = JSONObject.fromObject(respMap);
// System.out.println(ja1.toString());
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST");
response(request, response, ja1.toString());

} catch (Exception e) {
// Assert.fail(e.getMessage());
System.out.println(e.getMessage());
}
}

可以看到代码分4部分:

  • 初始化OSSClient,这是阿里SDK提供的工具
  • 生成文件上传策略policy(文件大小、文件存储目录等)
  • 生成许可签名
  • 封装结果并返回

接下来,我们逐个完成。

首先要做的是引入阿里sdk的依赖:

1
2
3
4
5
6
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.4.2</version>
<scope>compile</scope>
</dependency>

3.5.2.配置OSSClient

OSSClient底层使用的是HttpClient,如果每次访问都创建新的客户端,资源浪费较多。我们可以把其注册到Spring,单例来使用。

首先,把需要用到的数据统一抽取的配置文件:application.yml中

1
2
3
4
5
6
7
8
9
ly:
oss:
accessKeyId: LTAID13FA8uCV
accessKeySecret: CgWgVzbulWQ2fagXvScAmYBniQL
host: http://ly-images.oss-cn-shanghai.aliyuncs.com # 访问oss的域名,很重要bucket + endpoint
endpoint: oss-cn-shanghai.aliyuncs.com # 你的服务的端点,不一定跟我一样
dir: "" # 保存到bucket的某个子目录
expireTime: 20 # 过期时间,单位是S
maxFileSize: 5242880 #文件大小限制,这里是5M

然后通过配置类加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.leyou.upload.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;


@Data
@Component
@ConfigurationProperties("ly.oss")
public class OSSProperties {
private String accessKeyId;
private String accessKeySecret;
private String bucket;
private String host;
private String endpoint;
private String dir;
private long expireTime;
private long maxFileSize;
}

并通过一个Bean来配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.leyou.upload.config;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OSSConfig {

@Bean
public OSS ossClient(OSSProperties prop){
return new OSSClientBuilder()
.build(prop.getEndpoint(), prop.getAccessKeyId(), prop.getAccessKeySecret());
}
}

3.5.2.编写web层

上面的页面请求已经清楚的说明的请求接口的要素:

  • 请求方式:GET
  • 请求路径:signature
  • 请求参数:无
  • 返回结果:应该是一个对象,包含下列属性:
    • host:上传到阿里的url路径(bucket + endpoint)
    • policy:上传的策略
    • signature:签名
    • accessId:就是AccessKeyID
    • dir:在oss的bucket中的子文件夹的名称,可以不写。

代码:

1
2
3
4
@GetMapping("signature")
public ResponseEntity<Map<String,Object>> getAliSignature(){
return ResponseEntity.ok(uploadService.getSignature());
}

3.5.3.service

业务代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.leyou.upload.service;

import com.aliyun.oss.OSS;
import com.aliyun.oss.common.utils.BinaryUtil;
import com.aliyun.oss.model.MatchMode;
import com.aliyun.oss.model.PolicyConditions;
import com.leyou.common.enums.ExceptionEnum;
import com.leyou.common.exceptions.LyException;
import com.leyou.upload.config.OSSProperties;
import org.springframework.beans.factory.annotation.Autowired;
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.sql.Date;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Service
public class UploadService {

@Autowired
private OSSProperties prop;

@Autowired
private OSS client;

// ...

public Map<String, Object> getSignature() {
try {
long expireTime = prop.getExpireTime();
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, prop.getMaxFileSize());
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, prop.getDir());

String postPolicy = client.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = client.calculatePostSignature(postPolicy);

Map<String, Object> respMap = new LinkedHashMap<>();
respMap.put("accessId", prop.getAccessKeyId());
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", prop.getDir());
respMap.put("host", prop.getHost());
respMap.put("expire", expireEndTime);
return respMap;
}catch (Exception e){
throw new LyException(ExceptionEnum.UPDATE_OPERATION_FAIL);
}
}
}

3.5.4.测试

签名:

1552317644809

结果:

1552317675905

上传测试结果:

1552317761492

在页面访问:

1552317847336

3.6.隐藏阿里服务地址

在刚才的业务中,我们直接把我们在阿里的域名对外暴露了,如果要隐藏域名信息,我们可以使用一个自定义域名。

另外,最好保持与我们自己的域名一致,我们可以使用:http://image.leyou.com,然后使用nginx反向代理,最终再指向阿里服务器域名。

首先,修改服务器端返回的请求域名,修改ly-upload中的application.yml文件:

1
2
3
ly:
oss:
host: http://image.leyou.com

然后在nginx中设置反向代理:

1
2
3
4
5
6
7
server {
listen 80;
server_name image.leyou.com;
location / {
proxy_pass https://ly-images.oss-cn-shanghai.aliyuncs.com;
}
}

重启后测试,一切OK

1552480202164

7.品牌修改

4.1.数据回显

修改一般都需要先回显,我们来看看品牌的修改如何完成

4.1.1.页面请求

点击品牌页面后面的 编辑按钮:

1552480477888

可以看到并没有弹出修改品牌的页面,而是在控制台发起了一个请求:

1552992682525

这里显然是要查询品牌下对应的分类信息,因为品牌的table中已经有了品牌数据,缺少的恰好是品牌相关联的商品分类信息。

4.1.2.后台查询分类

controller实现

首先分析一下四个条件:

  • 请求方式:Get
  • 请求路径:/category/list/of/brand
  • 请求参数:id,这里的值应该是品牌的id,因为是根据品牌查询分类
  • 返回结果:一个品牌对应多个分类, 应该是分类的集合List

具体代码:

1
2
3
4
5
6
7
8
9
10
/**
* 根据品牌查询商品类目
*
* @param brandId
* @return
*/
@GetMapping("/of/brand")
public ResponseEntity<List<CategoryDTO>> queryByBrandId(@RequestParam(value = "id") Long brandId) {
return ResponseEntity.ok(this.categoryService.queryListByBrandId(brandId));
}

Service

业务层代码没有什么特殊的,只是调用的mapper方法是自定义方法,因为有中间表:

1
2
3
4
5
6
7
8
9
public List<CategoryDTO> queryListByBrandId(Long brandId) {
List<Category> list = categoryMapper.queryByBrandId(brandId);
// 判断结果
if(CollectionUtils.isEmpty(list)){
throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
}
// 使用自定义工具类,把Category集合转为DTO的集合
return BeanHelper.copyWithCollection(list, CategoryDTO.class);
}

mapper

这里定义mapper没有采用xml定义,而是用了注解方式:

1
2
@Select("SELECT tc.id, tc.`name`, tc.parent_id, tc.is_parent, tc.sort FROM tb_category_brand tcb LEFT JOIN tb_category tc ON tcb.category_id = tc.id WHERE tcb.brand_id = #{id}")
List<Category> queryByBrandId(@Param("id") Long id);

再次点击回显按钮,查看效果,发现回显成功:

1552482952384

4.1.3.页面优化

image-20220326212333683

空字段也返回了

可以修改配置文件忽略空字段

1
2
3
spring:
jackson:
default-property-inclusion: non_null # json返回结果不包含为空的属性

空的被过滤掉了

image-20220326212613721

4.2.修改品牌

4.2.1.页面请求

我们修改一些数据,然后再次点击提交,看看页面的反应:

1552483902099

分析:

  • 请求方式:PUT
  • 请求路径:brand
  • 请求参数:与新增时类似,但是多了id属性
  • 返回结果:新增应该是void

4.2.2.后端代码

首先是controller

1
2
3
4
5
6
7
8
9
10
/**
* 修改品牌
* @param brand
* @return
*/
@PutMapping
public ResponseEntity<Void> updateBrand(BrandDTO brand, @RequestParam("cids") List<Long> ids) {
brandService.updateBrand(brand, ids);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

几乎与新增的controller一样。

然后是service,需要注意的是,业务逻辑并不是简单的修改就可以了,因为还有中间表要处理,此处中间表因为没有其它数据字段,只包含分类和品牌的id,建议采用先删除,再新增的策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
public void updateBrand(BrandDTO brandDTO, List<Long> ids) {
Brand brand = BeanHelper.copyProperties(brandDTO, Brand.class);
// 修改品牌
int count = brandMapper.updateByPrimaryKeySelective(brand);
if(count != 1){
// 更新失败,抛出异常
throw new LyException(ExceptionEnum.UPDATE_OPERATION_FAIL);
}
// 删除中间表数据
brandMapper.deleteCategoryBrand(brand.getId());

// 重新插入中间表数据
count = brandMapper.insertCategoryBrand(brand.getId(), ids);
if(count != ids.size()){
// 新增失败,抛出异常
throw new LyException(ExceptionEnum.INSERT_OPERATION_FAIL);
}
}

最后是BrandMapper,我们在里面加入了一个删除中间表的方法:

1
2
@Delete("DELETE from tb_category_brand WHERE brand_id = #{bid}")
int deleteCategoryBrand(@Param("bid") Long bid);

8.删除品牌

5.1、实现逻辑

删除分单个和多个两种情况。

单个删除传入后端的是被删数据的id,多个删除则是将全部id用“-”连接成字符串传入后端,而后端通过判断传入的数据是否包含“-”来决定是单个删除还是删除多个

选中后点击删除即可。删除的时候先从tb_brand中删除数据,然后维护中间表tb_category_brand。

5.2、controller

1
2
3
4
5
6
7
8
9
10
/**
* 根据品牌id删除品牌
* @param ids
* @return
*/
@DeleteMapping
public ResponseEntity<Void> deleteBrand(@RequestParam("id")String ids){
this.brandService.deleteBrand(ids);
return ResponseEntity.ok().build();
}

5.3、service

1
2
3
4
5
6
/**
* 根据品牌id删除品牌
* @param ids
* @return
*/
void deleteBrand(String ids);

实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 根据品牌id删除品牌
*
* @param ids
* @return
*/
@Override
@Transactional
public void deleteBrand(String ids) {
if (ids.contains("-")){
String[] idList = ids.split("-");
for (String s : idList) {
this.brandMapper.deleteByPrimaryKey(Long.parseLong(s));
this.brandMapper.deleteByBrandIdInCategoryBrand(Long.parseLong(s));
}
}else {
this.brandMapper.deleteByPrimaryKey(Long.parseLong(ids));
this.brandMapper.deleteByBrandIdInCategoryBrand(Long.parseLong(ids));
}
}

5.4、mapper

1
2
3
4
5
6
/**
* 根据品牌Id 删除中间表的数据
* @param bid
*/
@Delete("DELETE FROM tb_category_brand WHERE brand_id = #{bid}")
void deleteByBrandIdInCategoryBrand(@Param("bid") Long bid);

5.5、前端逻辑代码

单个删除

1
2
3
4
5
6
7
8
9
10
11
12
13
deleteBrand(item){
this.$message.confirm("此操作将永久删除该品牌,是否继续?").then(() => {
//发起删除请求
this.$http.delete("/item/brand?id=" + item.id)
.then(() => {
//删除成功,重新加载数据
this.$message.success("删除成功!");
this.getDataFromServer();
})
}).catch(() => {
this.$message.info("删除失败!")
});
},

多个删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
deleteCheckBrand(){
//映射所选中的id数组
const ids = this.selected.map(s => s.id);
this.$message.confirm("此操作将永久删除该品牌,是否继续?").then(() => {
//发起删除请求
this.$http.delete("/item/brand?id=" + ids.join("-"))
.then(() => {
//删除成功,重新加载数据
this.$message.success("删除成功!");
this.getDataFromServer();
})
}).catch(() => {
this.$message.info("删除失败!")
});
}