1. 品牌的新增
1.1 url 异步请求
-
点击品牌管理下的新增品牌,填写品牌信息后提交
-
打开浏览器控制台
由此可以得知:
- 请求方式:POST
- 请求路径:/item/brand
- 请求参数:{name: “测试品牌”, image: “”, cids: “76,327”, letter: “C”}
- 返回参数:无
1.2 实现品牌新增
1.2.1 Controller
分析四个参数:
- 请求方式:POST
- 请求路径:/item/brand
- 请求参数:brand 对象,外加商品分类的 id 数组 cids
- 返回参数:无
/**
* 新增品牌
* @param cids
* @param brand
* @return
*/
@PostMapping
public ResponseEntity<Void> saveBrand(@RequestParam("cids") List<Long> cids, Brand brand) {
brandService.saveBrand(cids,brand);
// 响应 201
return ResponseEntity.status(HttpStatus.CREATED).build();
}
1.2.2 Service
我们不仅要新增品牌,还要新增品牌和商品分类的中间表。由于是新增操作,所以还需要加上事务管理。
/**
* 新增品牌
* @param cids
* @param brand
* @return
*/
@Transactional
public void saveBrand(List<Long> cids, Brand brand) {
// 先新增 Brand
brandMapper.insertSelective(brand);
// 再新增中间表
for (Long cid : cids) {
brandMapper.insertCategoryAndBrand(cid, brand.getId());
}
}
1.2.3 Mapper
通用 Mapper 只能处理单表,因此我们要手写新增商品分类和品牌的中间表数据的方法
/**
* 新增商品分类和品牌的中间表数据
* @param cid 商品分类 id
* @param bid 品牌 id
*/
@Insert("INSERT INTO tb_category_brand(category_id, brand_id) VALUES(#{cid}, #{bid})")
void insertCategoryAndBrand(@Param("cid") Long cid, @Param("bid") Long bid);
1.2.4 测试
响应状态码 400,请求参数不合法,那我们再看看请求体的内容
{name: "测试品牌", image: "", cids: "76,327", letter: "C"}
发现请求体的数据格式是 JSON 格式。
但我们 Controller 中接受参数是以 String 类型接受的,那么现在解决办法有两个:
- 将 Controller 中接受参数改为以 JSON 类型接受
- 将请求体内容改为以 String 类型发送
这两种方法都是可行的。第一种,如果将 Controller 中接受参数改为以 JSON 类型接受,我们就需要创建一个 Java 实体类,好用来反序列化,但这样为了一个方法去创建一个实体类,显然成本太大。
所以我们就采用第二种方法了。
1.2.5 解决问题
找到前端发送请求的代码
修改参数转换为以 String 类型发送
再次测试,新增品牌成功
打开控制台,可以看到请求体已经是以字符串形式发送了
2. 实现图片上传
刚才在实现品牌的新增中,我们并没有上传图片,接下来完成图片上传。
2.1 搭建工程
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
2.1.1 创建 leyou-upload
-
右键 leyou 项目 --> New Module --> Maven --> Next
-
填写项目信息 --> Next
-
填写保存的位置 --> Finish
2.1.2 添加依赖
<?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-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.upload</groupId>
<artifactId>leyou-upload</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
2.1.3 配置文件
server:
port: 8082
spring:
application:
name: upload-service
servlet:
multipart:
max-file-size: 5MB
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5
lease-expiration-duration-in-seconds: 10
注意:我们添加了限制文件大小的配置
2.1.4 引导类
@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouUploadApplication.class, args);
}
}
2.2 实现图片上传
2.2.1 Controller
参考自定义组件中的文件上传组件可以知道以下内容:
- 请求方式:上传肯定是 POST
- 请求路径:/upload/image
- 请求参数:参数名是 file,SpringMVC 会封装为一个接口:MultipartFile
- 返回结果:上传成功后得到的文件的 url 路径,返回类型 String
@RestController
@RequestMapping("/upload")
public class UploadController {
@Autowired
private UploadService uploadService;
/**
* 图片上传
* @param file
* @return
*/
@PostMapping("/image")
public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) {
String url = uploadService.uploadImage(file);
if(StringUtils.isBlank(url)) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.status(HttpStatus.CREATED).body(url);
}
}
2.2.2 Service
在上传文件过程中,我们需要对文件进行检验:
- 检验文件类型
- 检验文件内容
@Service
public class UploadService {
// 图片类型
private static final List<String> IMAGE_CONTENT_TYPES = Arrays.asList("image/png", "image/jpeg");
// 日志
private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
/**
* 图片上传
*
* @param file
* @return
*/
public String uploadImage(MultipartFile file) {
// 获取文件名
String filename = file.getOriginalFilename();
// 获取文件类型
String contentType = file.getContentType();
// 文件类型不合法,直接返回 null
if (!IMAGE_CONTENT_TYPES.contains(contentType)) {
LOGGER.info("文件类型不合法:{}", filename);
return null;
}
try {
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
// 如果文件内容不合法,直接返回 null
if (bufferedImage == null) {
LOGGER.info("文件内容不合法:{}", filename);
return null;
}
// 保存文件到服务器
file.transferTo(new File("C:\\Users\\admin\\Desktop\\img\\" + filename));
// 返回 url
return "http://image.leyou.com/" + filename;
} catch (IOException e) {
e.printStackTrace();
LOGGER.info("服务器内部错误:{}", filename);
}
return null;
}
}
注意:这里图片地址使用了另外的 url,原因如下:
- 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
- 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的 cookie,减小请求的数据量
2.2.3 测试
-
打开 Postman 接口测试工具
-
选择 Post 请求方式,输入请求地址
-
填写 Headers
-
填写 Body,选择文件
-
发送请求,得到响应的 url
-
去目录查看一下
2.2.4 通过浏览器访问图片
现在我们返回了图片的 url,但这个 url 浏览器还不能访问到,也就无法做到图片的回显。
通过 nginx 代理图片路径
-
打开 nginx 配置文件,添加如下配置
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 C:\\Users\\admin\\Desktop\\img; } }
-
重启 nginx
配置 host 文件
通过浏览器访问图片
2.3 绕过网关
图片上传是文件的传输,如果也经过 Zuul 网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul 网关不可用。这样我们的整个系统就瘫痪了。所以,我们上传文件的请求就不经过网关来处理了。
2.3.1 nginx 反向代理
这里可以看到请求路径为:api/upload/image,那么肯定会经过 Zuul 网关。如果要绕过 Zuul 网关,可以使用 nginx 反向代理到图片上传的服务地址,步骤如下:
-
修改 nginx 配置文件,将以 /api/upload 开头的请求拦截下来,转交到图片上传的服务地址
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; } location / { proxy_pass http://127.0.0.1:10010; proxy_connect_timeout 600; proxy_read_timeout 600; } }
这样请求路径变为了:http://127.0.0.1:8002/api/upload/image,但还是多了一个 /api。
nginx 提供了 rewrite 指令,用于对地址进行重写,格式如下:
rewrite "用来匹配路径的正则" 重写后的路径 [指令];
-
修改 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:重写路径结束后,不再重新匹配路径。
-
重启 nginx 服务器
2.3.2 跨域问题
原来在网关的服务中添加了跨域过滤器,解决了跨域问题。现在不经过网关了,那么同样需要在图片上传的服务中添加了跨域过滤器。
@Configuration
public class LeyouCorsConfigration {
@Bean
public CorsFilter corsFilter() {
// 初始化 cors 配置对象
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://manage.leyou.com"); //允许的域
config.setAllowCredentials(true); //是否发送 Cookie 信息
config.addAllowedMethod("*"); //允许的请求方式
config.addAllowedHeader("*"); //允许的头信息
//初始化 cors 配置源对象
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config); //添加映射路径,拦截一切请求
return new CorsFilter(configSource); //返回 CorsFilter
}
}
2.3.3 测试
-
重启 leyou-upload 服务
-
再次上传图片,成功回显
-
点击提交,成功新增品牌
2.3.4 文件上传的缺陷
上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:
- 单机器存储,存储能力有限
- 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
- 数据没有备份,有单点故障风险
- 并发能力差
这个时候,最好使用分布式文件存储来代替本地文件存储。
3. FastDFS
3.1 什么是分布式文件系统
分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。
通俗来讲:
- 传统文件系统管理的文件就存储在本机。
- 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问
3.2 什么是 FastDFS
FastDFS 是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯 C 语言开发,功能丰富:
- 文件存储
- 文件同步
- 文件访问(上传、下载)
- 存取负载均衡
- 在线扩容
适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的 GFS、HDFS(Hadoop)、TFS(淘宝)等。
3.3 FastDFS 的架构
3.3.1 架构图
FastDFS 两个主要的角色:Tracker Server 和 Storage Server 。
- Tracker Server:跟踪服务器,主要负责调度 storage 节点与 client 通信,在访问上起负载均衡的作用,和记录 storage 节点的运行状态,是连接 client 和 storage 节点的枢纽。
- Storage Server:存储服务器,保存文件和文件的 meta data(元数据),每个 storage server 会启动一个单独的线程主动向 Tracker cluster 中每个 tracker server 报告其状态信息,包括磁盘使用情况,文件同步情况及文件上传下载次数统计等信息
- Group:文件组,多台 Storage Server 的集群。上传一个文件到同组内的一台机器上后,FastDFS 会将该文件即时同步到同组内的其它所有机器上,起到备份的作用。不同组的服务器,保存的数据不同,而且相互独立,不进行通信。
- Tracker Cluster:跟踪服务器的集群,有一组 Tracker Server(跟踪服务器)组成。
- Storage Cluster :存储集群,有多个 Group 组成。
3.3.2 上传和下载流程
上传
- Client 通过 Tracker server 查找可用的 Storage server。
- Tracker server 向 Client 返回一台可用的 Storage server 的 IP 地址和端口号。
- Client 直接通过 Tracker server 返回的 IP 地址和端口与其中一台 Storage server 建立连接并进行文件上传。
- 上传完成,Storage server 返回 Client 一个文件 ID,文件上传结束。
下载
- Client 通过 Tracker server 查找要下载文件所在的的 Storage server。
- Tracker server 向 Client 返回包含指定文件的某个 Storage server 的 IP 地址和端口号。
- Client 直接通过 Tracker server 返回的 IP 地址和端口与其中一台 Storage server 建立连接并指定要下载文件。
- 下载文件成功。
3.4 安装 FastDFS
参考下载的 FastDFS 资料
注意:配置 FastDFS 服务器 ip 是你自己虚拟机的 ip
3.5 使用 FastDFS 客户端
3.5.1 添加依赖
在父工程中,我们已经锁定了依赖的版本:
<fastDFS.client.version>1.26.1-RELEASE</fastDFS.client.version>
所以直接在 leyou-upload 工程中直接引入依赖即可:
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
</dependency>
3.5.2 引入配置类
@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
}
3.5.3 配置文件
在 leyou-upload 工程中添加 FastDFS 配置
fdfs:
so-timeout: 1501 # 超时时间
connect-timeout: 601 # 连接超时时间
thumb-image: # 缩略图
width: 60
height: 60
tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
- 192.168.222.132:22122
3.5.4 配置 host 文件
将来通过 image.leyou.com 这个域名访问 fastDFS 服务器上的图片资源。所以需要配置 hosts 文件,将 image.leyou.com 代理到虚拟机地址。
192.168.222.132 image.leyou.com
3.5.5 测试
注意:测试的时候虚拟机上 FastDFS 和 Nginx 需要是启动的,并且防火请要是关闭的。
编写测试类
@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\\1.jpg");
// 上传并保存图片,参数:1-上传的文件流 2-文件的大小 3-文件的后缀 4-可以不写
StorePath storePath = this.storageClient.uploadFile(
new FileInputStream(file), file.length(), "jpg", null);
// 带分组的路径
System.out.println(storePath.getFullPath());
// 不带分组的路径
System.out.println(storePath.getPath());
}
@Test
public void testUploadAndCreateThumb() throws FileNotFoundException {
File file = new File("C:\\Users\\admin\\Desktop\\1.jpg");
// 上传并且生成缩略图
StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
new FileInputStream(file), file.length(), "jpg", null);
// 带分组的路径
System.out.println(storePath.getFullPath());
// 不带分组的路径
System.out.println(storePath.getPath());
// 获取缩略图路径
String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
System.out.println(path);
}
}
testUpload 运行结果
group1/M00/00/00/wKjehF5bzsuAab4AAADSZYQEtvY842.jpg
M00/00/00/wKjehF5bzsuAab4AAADSZYQEtvY842.jpg
testUploadAndCreateThumb 运行结果
group1/M00/00/00/wKjehF5b0u-AAX-AAADSZYQEtvY315.jpg
M00/00/00/wKjehF5b0u-AAX-AAADSZYQEtvY315.jpg
M00/00/00/wKjehF5b0u-AAX-AAADSZYQEtvY315_60x60.jpg
访问第一组第一个路径(正常上传的图片)
访问第二组第三个路径(上传的缩略图)
3.5.6 使用 FastDFS 改造项目
修改 leyou-upload 项目中的 UploadService,将文件上传到本地改为文件上传到 FastDFS
@Service
public class UploadService {
// 图片类型
private static final List<String> IMAGE_CONTENT_TYPES = Arrays.asList("image/png", "image/jpeg");
// 日志
private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
@Autowired
private FastFileStorageClient storageClient;
/**
* 图片上传
*
* @param file
* @return
*/
public String uploadImage(MultipartFile file) {
// 获取文件名
String filename = file.getOriginalFilename();
// 获取文件类型
String contentType = file.getContentType();
// 文件类型不合法,直接返回 null
if (!IMAGE_CONTENT_TYPES.contains(contentType)) {
LOGGER.info("文件类型不合法:{}", filename);
return null;
}
try {
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
// 如果文件内容不合法,直接返回 null
if (bufferedImage == null) {
LOGGER.info("文件内容不合法:{}", filename);
return null;
}
// 获取文件名后缀
String type = StringUtils.substringAfterLast(filename, ".");
// 保存文件到 FastDFS 服务器
StorePath storePath = this.storageClient.uploadFile(
file.getInputStream(), file.getSize(), type, null);
// 返回 url
return "http://image.leyou.com/" + storePath.getFullPath();
} catch (IOException e) {
e.printStackTrace();
LOGGER.info("服务器内部错误:{}", filename);
}
return null;
}
}
3.5.7 再次测试
新增品牌完成后,可以看到原来上传到本地的图片已经无法显示了,而上传到 FastDFS 上的图片可以显示
4. 品牌的修改
4.1 品牌的回显
要修改品牌信息,首先要让品牌信息回显。
4.1.1 前端部分
我们找到前端中的修改品牌方法
可以看到这里有一个根据品牌信息查询商品分类的请求,可以得到以下信息:
- 请求方式:GET
- 请求参数:品牌 id,这里用的是 Rest 风格的占位符
- 请求地址:/item/category/bid
- 返回参数:商品分类的集合
4.1.2 实现品牌的回显
4.1.2.1 Controller
在 CategoryController 中编写方法
/**
* 根据品牌 Id 查询品牌分类
* @param bid
* @return
*/
@GetMapping("/bid/{bid}")
public ResponseEntity<List<Category>> queryCategoryByBrandId(@PathVariable("bid") Long bid) {
if (bid == null || bid.longValue() < 0) {
return ResponseEntity.badRequest().build(); // 响应 400
}
List<Category> categories = categoryService.queryCategoryByBrandId(bid);
if(CollectionUtils.isEmpty(categories)) {
return ResponseEntity.notFound().build(); // 响应 404
}
return ResponseEntity.ok(categories);
}
4.1.2.2 Service
在 CategoryService 中编写方法
/**
* 根据品牌 Id 查询品牌分类
* @param bid
* @return
*/
public List<Category> queryCategoryByBrandId(Long bid) {
List<Category> categories = categoryMapper.queryCategoryByBrandId(bid);
return categories;
}
4.1.2.3 Mapper
在 CategoryMapper 中编写方法
/**
* 根据品牌 Id 查询品牌分类
* @param bid
* @return
*/
@Select("SELECT * FROM tb_category WHERE id IN (SELECT category_id FROM tb_category_brand WHERE brand_id = #{bid})")
List<Category> queryCategoryByBrandId(Long bid);
4.1.2.4 测试
-
点击修改按钮
-
品牌信息回显成功
4.2 品牌的修改
4.2.1 前端部分
找到前端提交表单的方法
可以看到当 isEdit 值为 true 时,发送 put 请求。
而在修改品牌方法中已经把 isEdit 值赋值为 true 了
于是可以得出四个参数:
- 请求方式:PUT
- 请求路径:/item/brand
- 请求参数:brand 对象,外加商品分类的 id 数组 cids
- 返回参数:无
4.2.2 实现品牌的修改
4.2.2.1 Controller
在 BrandController 编写方法
/**
* 更新品牌
* @param cids
* @param brand
* @return
*/
@PutMapping
public ResponseEntity<Void> updateBrand(@RequestParam("cids") List<Long> cids, Brand brand) {
brandService.updateBrand(cids,brand);
// 响应 204
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
4.2.2.2 Service
在 BrandService 编写方法
/**
* 更新品牌
* @param cids
* @param brand
*/
@Transactional
public void updateBrand(List<Long> cids, Brand brand) {
// 先更新 Brand
brandMapper.updateByPrimaryKey(brand);
// 通过品牌 id 删除中间表
brandMapper.deleteCategoryAndBrandByBid(brand.getId());
// 再新增中间表
for (Long cid : cids) {
brandMapper.insertCategoryAndBrand(cid, brand.getId());
}
}
4.2.2.3 Mapper
在 BrandMapper 编写方法
/**
* 通过品牌 id 删除中间表
* @param bid
*/
@Delete("DELETE FROM tb_category_brand WHERE brand_id = #{bid}")
void deleteCategoryAndBrandByBid(Long bid);
4.2.2.4 测试
-
修改品牌名称
-
点击提交,修改成功
5. 品牌的删除
5.1 前端部分
前端代码里没有删除函数,我就自己定义了一个,删除成功后重新加载数据
可以看到这里有一个删除品牌的请求,可以得到以下信息:
- 请求方式:DELETE
- 请求参数:品牌 id,这里用的是 Rest 风格的占位符
- 请求地址:/item/brand/bid
- 返回参数:无
5.2 实现品牌的删除
5.2.1 Controller
在 BrandController 中编写
/**
* 删除品牌
* @param bid
* @return
*/
@DeleteMapping("/bid/{bid}")
public ResponseEntity<Void> deleteBrand(@PathVariable("bid") Long bid) {
brandService.deleteBrand(bid);
// 响应 204
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
5.2.2 Service
在 BrandService 中编写
/**
* 删除品牌
* @param bid
*/
@Transactional
public void deleteBrand(Long bid) {
// 通过品牌 id 删除中间表
brandMapper.deleteCategoryAndBrandByBid(bid);
// 删除品牌
brandMapper.deleteByPrimaryKey(bid);
}
5.2.3 测试
点击删除按钮,删除成功