文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
1.搭建项目
1.1.创建module
1.2.依赖
我们需要EurekaClient和web依赖:
代码语言:xml复制<?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>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
1.3.编写配置
代码语言:yml复制server:
port: 8082
spring:
application:
name: upload-service
servlet:
multipart:
max-file-size: 5MB # 限制文件上传的大小
# Eureka
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
需要注意的是,我们应该添加了限制文件大小的配置
1.4.引导类
代码语言:java复制@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouUploadApplication.class, args);
}
}
结构:
2.编写上传功能
文件上传功能,也是自定义组件完成的,参照自定义组件用法指南:
在页面中的使用:
2.1.controller
编写controller需要知道4个内容:结合用法指南
- 请求方式:上传肯定是POST
- 请求路径:/upload/image
- 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipartFile
- 返回结果:上传成功后得到的文件的url路径,也就是返回String
代码如下:
代码语言:java复制@Controller
@RequestMapping("upload")
public class UploadController {
@Autowired
private UploadService uploadService;
/**
* 图片上传
* @param file
* @return
*/
@PostMapping("image")
public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file){
String url = this.uploadService.upload(file);
if (StringUtils.isBlank(url)) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.status(HttpStatus.CREATED).body(url);
}
}
2.2.service
在上传文件过程中,我们需要对上传的内容进行校验:
- 校验文件大小
- 校验文件的媒体类型
- 校验文件的内容
文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。
代码语言:java复制@Service
public class UploadService {
private static final List<String> CONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif");
private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
public String upload(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
// 校验文件的类型
String contentType = file.getContentType();
if (!CONTENT_TYPES.contains(contentType)){
// 文件类型不合法,直接返回null
LOGGER.info("文件类型不合法:{}", originalFilename);
return null;
}
try {
// 校验文件的内容
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
if (bufferedImage == null){
LOGGER.info("文件内容不合法:{}", originalFilename);
return null;
}
// 保存到服务器
file.transferTo(new File("C:\leyou\images\" originalFilename));
// 生成url地址,返回
return "http://image.leyou.com/" originalFilename;
} catch (IOException e) {
LOGGER.info("服务器内部错误:{}", originalFilename);
e.printStackTrace();
}
return null;
}
}
为什么图片地址需要使用另外的url?
- 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
- 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量
2.3.测试上传
我们通过RestClient工具来测试:
结果:
去目录下查看:
3.绕过网关
图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。
所以,我们上传文件的请求就不经过网关来处理了。
3.1.Zuul的路由过滤
Zuul中提供了一个ignored-patterns属性,用来忽略不希望路由的URL路径,示例:
代码语言:请yml复制zuul.ignored-patterns: /upload/**
路径过滤会对一切微服务进行判定。
Zuul还提供了ignored-services
属性,进行服务过滤:
zuul.ignored-services: upload-servie
我们这里采用忽略服务:
代码语言:yml复制zuul:
ignored-services:
- upload-service # 忽略upload-service服务
3.2.Nginx的rewrite指令
现在,我们修改页面的访问路径:
代码语言:html复制<v-upload
v-model="brand.image"
url="/upload/image"
:multiple="false"
:pic-width="250" :pic-height="90"
/>
查看页面的请求路径:
可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?
有同学会想:修改页面请求地址不就好了。
注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。
既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。
我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:
代码语言:nginx复制location /api/upload {
proxy_pass http://127.0.0.1:8082;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
这样写大家觉得对不对呢?
显然是不对的,因为ip和端口虽然对了,但是路径没变,依然是:http://127.0.0.1:8002/api/upload/image
前面多了一个/api
Nginx提供了rewrite指令,用于对地址进行重写,语法规则:
代码语言:nginx复制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、breaklast
:重写路径结束后,将得到的路径重新进行一次路径匹配break
:重写路径结束后,不再重新匹配路径。
我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到8082端口了
修改完成,输入nginx -s reload命令重新加载配置。然后再次上传试试。
4.跨域问题
重启nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错:
不过庆幸的是,这个错误已经不是第一次见了,跨域问题。
我们在upload-service中添加一个CorsFilter即可:
代码语言:java复制@Configuration
public class LeyouCorsConfiguration {
@Bean
public CorsFilter corsFilter() {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin("http://manage.leyou.com");
//3) 允许的请求方式
config.addAllowedMethod("OPTIONS");
config.addAllowedMethod("POST");
// 4)允许的头信息
config.addAllowedHeader("*");
//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
//3.返回新的CorsFilter.
return new CorsFilter(configSource);
}
}
再次测试:
不过,非常遗憾的是,访问图片地址,却没有响应。
这是因为我们并没有任何服务器对应image.leyou.com这个域名。。
这个问题,我们暂时放下,回头再来解决。
5.文件上传的缺陷
先思考一下,现在上传的功能,有没有什么问题?
上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:
- 单机器存储,存储能力有限
- 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
- 数据没有备份,有单点故障风险
- 并发能力差
这个时候,最好使用分布式文件存储来代替本地文件存储
6.FastDFS
6.1.什么是分布式文件系统
分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。
通俗来讲:
- 传统文件系统管理的文件就存储在本机。
- 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问
6.2.什么是FastDFS
FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:
- 文件存储
- 文件同步
- 文件访问(上传、下载)
- 存取负载均衡
- 在线扩容
适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。
6.3.FastDFS的架构
6.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组成。
6.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建立连接并指定要下载文件。
- 下载文件成功。
6.4.安装和使用
6.5.java客户端
余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。
这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。
配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。
地址:tobato/FastDFS_client
接下来,我们就用FastDFS改造leyou-upload工程。
6.5.1.引入依赖
在父工程中,我们已经管理了依赖,版本为:
代码语言:xml复制<fastDFS.client.version>1.26.2</fastDFS.client.version>
因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:
代码语言:xml复制<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
</dependency>
6.5.2.引入配置类
纯java配置:
代码语言:java复制@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
}
6.5.3.编写FastDFS属性
在application.yml配置文件中追加如下内容:
代码语言:yml复制fdfs:
so-timeout: 1501 # 超时时间
connect-timeout: 601 # 连接超时时间
thumb-image: # 缩略图
width: 60
height: 60
tracker-list: # tracker地址:你的虚拟机服务器地址 端口(默认是22122)
- 192.168.56.101:22122
6.5.4.配置hosts
将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:
配置hosts文件,使image.leyou.com可以访问fastDFS服务器
6.5.5.测试
创建测试类:
把以下内容copy进去:
代码语言:java复制@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\joedy\Pictures\xbx1.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\joedy\Pictures\xbx1.jpg");
// 上传并且生成缩略图
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);
}
}
结果:
代码语言:txt复制group1/M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
代码语言:txt复制group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png
访问第二组第一个路径:
访问最后一个路径(缩略图路径),注意加组名(group1):
6.5.6.改造上传逻辑
代码语言:java复制@Service
public class UploadService {
@Autowired
private FastFileStorageClient storageClient;
private static final List<String> CONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif");
private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
public String upload(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
// 校验文件的类型
String contentType = file.getContentType();
if (!CONTENT_TYPES.contains(contentType)){
// 文件类型不合法,直接返回null
LOGGER.info("文件类型不合法:{}", originalFilename);
return null;
}
try {
// 校验文件的内容
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
if (bufferedImage == null){
LOGGER.info("文件内容不合法:{}", originalFilename);
return null;
}
// 保存到服务器
// file.transferTo(new File("C:\leyou\images\" originalFilename));
String ext = StringUtils.substringAfterLast(originalFilename, ".");
StorePath storePath = this.storageClient.uploadFile(file.getInputStream(), file.getSize(), ext, null);
// 生成url地址,返回
return "http://image.leyou.com/" storePath.getFullPath();
} catch (IOException e) {
LOGGER.info("服务器内部错误:{}", originalFilename);
e.printStackTrace();
}
return null;
}
}
只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。
6.5.7.测试
通过RestClient测试: