网盘系统 2.0
项目源码:Github
1. 网盘系统 前置准备¶
参考: 网盘系统 前置准备
项目介绍¶
- 基于Vue和Spring Boot等技术构建的前后端分离、分布式的网盘系统
- 支持文件&文件夹管理
- 支持文件&文件夹上传、断点续传、下载功能
- 支持文件搜索在线预览、分享功能
- 支持过期监听、消息推送功能
整个网盘项目,会从 整体设计->需求的技术方案设计-> 项目总结 进行更新,给大家阐述一个网盘系统是如何从0-1进行实现的,同时也希望里面的业务场景及其技术方案能够为已工作或者在读的同学们带来些许帮助。
通过该网盘项目,希望能够为各位提供如下帮助:
- 了解和学习到目前后端开发的常用技术栈
- 如何通过需求分析以及方案设计,将所学的技术应用到业务需求之中,提升个人系统设计的能力
- 常见设计模式的使用,如策略模式
项目亮点¶
- 认识并了解网盘的核心功能是如何实现的,如文件上传、秒传、下载、搜索等
- 如何基于WebSocke+Netty实现Web端的消息推送。
- 了解分布式锁原理,并懂得什么样的场景适合使用分布式锁,以及如何基于Redis、ZooKeeper实现分布式锁
- 常见设计模式在业务开发中的使用
项目架构¶
前端技术¶
- Vue + ElementUI 作为基础技术框架
后端技术¶
- Maven+SpringBoot+Mybatis作为基础架构
- Nacos作为配置中心
- Redis做分布式缓存、过期监听
- Zookeeper做分布式锁
- WebSocket+Netty做消息推送
- Elasticsearch做全文检索
- FastDFS做分布式文件系统
项目展示(已实现部分)¶
登陆页面
文件&文件夹操作
文件上传
2. 网盘系统 项目架构设计¶
前言¶
网盘系统第二章紧赶慢赶,还是没在23年完成,所以就以24年第一周末开篇吧。上一篇文章看到好多录友在催更,给各位道个歉,个人原因,导致拖更的有些久。后续保持正常情况下两周一更新的频率,异常情况会在上一篇文章的评论中告知:)
本章节主要用于讲解整个网盘系统项目架构设计。我们都知道一次全流程的项目交付会经历如下的阶段:
- 需求梳理和规划、原型图的设计:这部分由产品和UI/UE负责完成,其核心的目的就是将项目由"概念化"阶段进入到“图纸化”阶段,这期间产品会产出相关的PRD,这一流程好坏直接影响到研发部门是否能够明确产品的功能和性能。
- 需求设计:相关研发同学通过PRD信息,梳理出相关内容,包括但不限于:架构图的设计、流程图的设计、数据库模型、技术栈的选型等,该部分梳理完成,相关的开发排期也自然而然的完成,后续的编码阶段,就会轻松很多,按照既有设计硬编码即可。
- 需求开发:继续需求设计,完成PRD中的内容,说白了就是撸代码,在此过程中,可以将平时所学的十八般武艺都亮出来,怎么优雅的实现怎么来,当然通过CR才算是被认可的代码:)
- 功能测试:接口自测、接口联调、测试环境测试、灰度测试等
- 项目上线
作为开发同学,我们最为熟悉的是需求设计、需求开发阶段,相较于需求开发,需求设计能够促使我们对后续开发有整体的把控,把握好开发进度。在该阶段,通过各种流程图的梳理,技术难点自然而然就浮现出来了,之后针对这些难点做好技术方案选型,那么代码阶段就会非常的简单。本章的内容就是需求设计的一部分。
架构概述 (需要品味)¶
对于一个服务或者系统而言,常见的架构模式可分为单体架构、垂直架构、SOA、微服务架构模式等,对于目前大型项目而言,目前主流的架构模式还是微服务架构,原因如下:
- 灵活性高:它将应用程序分解为小型服务(松散耦合),使其开发、维护更快,更易于理解,可以提供更高的灵活性
- 独立扩展:它使每个服务能够独立扩展,将系统中的不同功能模块拆分成多个不同的服务,这些服务进行独立地开发和部署,每个服务都运行在自己的进程内,这样每个服务的更新都不会影响其他服务的运行
- 支持多种编程语言:微服务可通过最佳及最合适的不同的编程语言与工具进行开发,能够做到有的放矢地解决针对性问题
了解完微服务优点之后,我们再来看下微服务中一次正常的服务调用流程:
- 首先服务提供者按照一定格式的服务描述,向注册中心注册服务,声明自己能够提供哪些服务以及服务的地址是什么,完成服务发布。
- 接下来服务消费者请求注册中心,查询所需要调用服务的地址,然后以约定的通信协议向服务提供者发起请求,得到请求结果后再按照约定的协议解析结果。
- 在服务的调用过程中,服务的请求耗时、调用量以及成功率等指标都会被记录下来用作监控,调用经过的链路信息会被记录下来,用于故障定位和问题追踪。在这期间,如果调用失败,可以通过重试等服务治理手段来保证成功率
综上,在微服务架构下,服务调用主要依赖这几个基本组件:
- 服务描述
服务调用首先要解决的问题就是服务如何对外描述。比如,我们对外提供了一个服务,那么这个服务的服务名叫什么?调用这个服务需要提供哪些信息?调用这个服务返回的结果是什么格式的?该如何解析?这些就是服务描述要解决的问题。常用的服务描述方式包括RESTful API、XML配置以及IDL文件三种
- 注册中心
有了服务的接口描述,下一步要解决的问题就是服务的发布和订阅,也就是我们提供了一个服务,如何让外部想调用我们服务的人知道。这个时候就需要一个类似注册中心的角色,服务提供者将自己提供的服务以及地址登记到注册中心,服务消费者则从注册中心查询所需要调用的服务的地址,然后发起请求,注册中心的工作流程如下:
- 服务提供者在启动时,根据服务发布文件中配置的发布信息向注册中心注册自己的服务
- 服务消费者在启动时,根据消费者配置文件中配置的服务信息向注册中心订阅自己所需要的服务
- 注册中心返回服务提供者地址列表给服务消费者
-
当服务提供者发生变化,比如有节点新增或者销毁,注册中心将变更通知给服务消费者
-
服务框架
通过注册中心,服务消费者就可以获取到服务提供者的地址,有了地址后就可以发起调用。但是在此之前,我们还需要了解一些内容:
- 服务通信采用什么协议?即服务提供者和服务消费者之间以什么样的协议进行网络通信,是采用四层TCP、UDP协议,还是采用七层HTTP协议,还是采用其他协议?
- 数据传输采用什么方式?即服务提供者和服务消费者之间的数据传输采用哪种方式,是同步还是异步,是在单连接上传输,还是多路复用。
-
数据压缩采用什么格式?通常数据传输都会对数据进行压缩,来减少网络传输的数据量,从而减少带宽消耗和网络传输时间,比如常见的JSON序列化、Java对象序列化以及Protobuf序列化等
-
服务监控
一旦服务消费者与服务提供者之间能够正常发起服务调用,就需要对调用情况进行监控,以了解服务是否正常。通常分为三种:指标收集、数据处理、数据展示。
- 服务追踪
除了需要对服务调用情况进行监控之外,还需要记录服务调用经过的每一层链路,以便进行问题追踪和故障定位,即一次请求,无论最后依赖多少次服务调用、经过多少服务节点,都可以通过最开始生成的requestid串联所有节点,从而达到服务追踪的目的。
- 服务治理
服务监控能够发现问题,服务追踪能够定位问题所在,而解决问题就得靠服务治理了。服务治理就是通过一系列的手段来保证在各种意外情况下,服务调用仍然能够正常进行
项目架构设计¶
了解完上述内容,我们再来思考下网盘系统的项目架构如何设计,对于网盘系统而言,后端部分为两个核心部分:网盘后台(用于资源管理)、个人网盘管理。
而对于其他需求,例如用户信息管理、权限管理等, 这些就处于非核心业务,对于一个通用的网盘系统而言,提供标准的用户信息接入指南,方便快速接入已有用户系统才是关键。客户端可分为多种,如网页版、app 、小程序等。梳理完这些内容之后,开始着手整个系统模块的拆分。
- 先将整体的后端服务分为两部分内容:Controller模块、Service模块,避免接口或者业务逻辑重复,并且两个模块进行远程通信。考虑到网盘系统两个核心部分需求变更频率不同, 所以将Controller模块再次以业务功能进行细分。
- Service模块,其实最好的方式是也进行划分,分为个人和后台两个模块,但是拆分模块越多,整个项目架构也会越复杂,不方便后续的管理和发布,所以暂时不进行拆分,后续遇到单纯堆加服务数量都无解决的性能瓶颈之后,再考虑将Service模块进行拆分。
- 因为不可避免的需要进行用户的认证功能,但是又需要考虑到网盘系统的灵活性,所以新增一个用户服务的中间层,用于对接现有的用户服务。其内部提供网盘所需用户相关的标准接口,底层通过去调用已有用户服务来实现这些接口功能。
以上就是整个网盘的系统业务型的架构图。我们基于整个架构,开始完善技术架构。
采用Dubbo通信的原因:
不采用Dubbo进行服务间通信,而采用传统的HttpClient模拟Http发起请求,会有以下几个问题难以避免:
1. 服务迁移,导致被调用服务不可用,需要手动改调用方相关的IP地址,当然将IP这些可变配置写在配置中心也OK,可以避免服务的重新部署,但仍较为繁琐,始终需要耗费人力去配置相关信息
-
无法及时感知服务状况,被调用方宕机,调用方无法及时感知,无有效的容错机制
-
如果被调用方式集群模式,那么调用方需要手动实现负载均衡算法
-
以上的问题,Dubbo都有成熟的解决方案,帮助我们解决了因引入微服务架构而带来的大多数服务间通信问题。
采用Nacos作为配置中心原因:
- 除了Nacos之外,Spring Cloud Config和Apollo也可以作为配置中心,但是Spring Cloud Config 无可视化界面,需要借助Github使用,动态刷新较为麻烦,Apollo是携程开源的配置中心,但是接入的人工成本相较于Nacos较高,所以暂不考虑。
以下是系统中不同模块之间的关系图:
- 中间橙色部分内容是网盘系统的业务模块,独立部署,白色部分的内容是被集成的模块,主要是interface、工具类、以及网盘系统的特有的业务场景实现
基于上述架构图,分别详细阐述每个模块的作用:
知识拓展¶
在项目架构中只是粗略的说了Dubbo作为服务通信的原因,接下来我们详细看下Dubbo是如何参与到微服务架构中服务调用的。
- 服务发布与引用:对应实现是图里的Proxy服务代理层,Proxy根据客户端和服务端的接口描述,生成接口对应的客户端和服务端的Stub,使得客户端调用服务端就像本地调用一样。
- 服务注册与发现:对应实现是图里的Registry注册中心层,Registry根据客户端和服务端的接口描述,解析成服务的URL格式,然后调用注册中心的API,完成服务的注册和发现。
- 服务调用:对应实现是Protocol远程调用层,Protocol把客户端的本地请求转换成RPC请求。然后通过Transporter层来实现通信,Codec层来实现协议封装,Serialization层来实现数据序列化和反序列化。
- 服务监控:对应实现层是Monitor调用链层,通过MonitorFilter,实现对每一次调用的拦截,在调用前后进行埋点数据采集,上传给监控系统。
- 服务治理:对应实现层是Cluster层,负责服务节点管理、负载均衡、服务路由以及服务容错。
再来看下微服务架构各个组件是如何串联起来组成一个完整的微服务框架的,以Dubbo框架下一次服务调用的过程为例,先来看下客户端发起调用的过程。
- 首先根据接口定义,通过Proxy层封装好的透明化接口代理,发起调用。
- 然后在通过Registry层封装好的服务发现功能,获取所有可用的服务提供者节点列表。
- 再根据Cluster层的负载均衡算法从可用的服务节点列表中选取一个节点发起服务调用,如果调用失败,根据Cluster层提供的服务容错手段进行处理。
- 同时通过Monitor层拦截调用,实现客户端的监控统计。
- 最后在Protocol层,封装成Dubbo RPC请求,发给服务端节点。
这样的话,客户端的请求就从一个本地调用转化成一个远程RPC调用,经过服务调用框架的处理,通过网络传输到达服务端。其中服务调用框架包括通信协议框架Transporter、通信协议Codec、序列化Serialization三层处理。
服务端从网络中接收到请求后的处理过程是这样的:
- 首先在Protocol层,把网络上的请求解析成Dubbo RPC请求。
- 然后通过Monitor拦截调用,实现服务端的监控统计。
- 最后通过Proxy层的处理,把Dubbo RPC请求转化为接口的具体实现,执行调用
预告¶
下一章,将会阐述整个网盘系统的框架搭建,包括但不限于:
- 不同类型模块的结构规划和搭建,如:Interface类型、Service类型、Controller类型、被集成类型
- Dubbo、Nacos等外部依赖的集成
- 常用工具类的开发,如:判空工具类、安全工具类、后端响应通用结构体设计
- 通用业务逻辑的开发:登陆认证授权的处理、自定义业务异常、常用切面的开发
3. 网盘系统 项目框架搭建¶
项目结构¶
网盘项目的整体项目结构如下:
NetworkDisk
├── networkdisk-common -- 工具类及通用代码
├── networkdisk-filesearch -- 文件搜索相关功能
├── networkdisk-filestore -- 文件存储相关功能
├── networkdisk-log-provier -- 日志采集
├── networkdisk-manage -- 网盘后台
├── networkdisk-person -- 个人网盘
├── networkdisk-service-interface -- 网盘核心服务接口
├── networkdisk-service-provider -- 网盘核心服务
├── networkdisk-user-interface -- 网盘用户服务接口
├── networkdisk-user-provider -- 网盘用户服务
└── pom.xml -- Maven项目依赖配置
Maven 模块说明¶
根目录下的pom.xml
做用于管理多个模块的依赖,上述的多个模块都继承了父项目NetworkDisk,所以它们都可以使用父项目NetworkDisk中定义好的依赖版本来统一管理
<properties>
<java.version>11</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.6.13</spring-boot.version>
<lombok.version>1.18.0</lombok.version>
<guava.version>29.0-jre</guava.version>
<swagger.version>2.9.2</swagger.version>
<druid.version>1.2.6</druid.version>
<mysql-connector-java.version>8.0.11</mysql-connector-java.version>
<hibernate-validator.version>6.1.6.Final</hibernate-validator.version>
<log4j.version>1.2.17</log4j.version>
<juit.version>1.4.7</juit.version>
<commons-lang3.version>3.11</commons-lang3.version>
<dubbo.version>2.7.8</dubbo.version>
<zkclient.version>0.10</zkclient.version>
<jedis.version>3.2.0</jedis.version>
<pagehelper-starter.version>1.4.5</pagehelper-starter.version>
<pagehelper.version>5.3.2</pagehelper.version>
<springfox-swagger.version>3.0.0</springfox-swagger.version>
<swagger-models.version>1.6.0</swagger-models.version>
<swagger-annotations.version>1.6.0</swagger-annotations.version>
</properties>
并且在父项目的dependencyManagement中,已经声明好了依赖版本,子项目中均可以直接使用,无需再进行额外的版本声明,方便我们统一管理。
<dependencyManagement>
<dependencies>
...
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
模块结构¶
因为网盘项目我们划分了多个项目,为了方便开发,统一管理,规范化开发,我们先将整体结构进行规划,以下是networkdisk-common
模块的整体结构
networkdisk-common
├─pom.xml
└─src
└─main
├─java
│ └─com.macro.mall.common
│ ├─api
│ │ CommonPage.java -- 通用分页数据封装类
│ │ CommonResult.java -- 通用返回结果封装类
│ │ IErrorCode.java -- API返回码接口
│ │ ResultCode.java -- API返回码封装类
│ ├─config
│ │ BaseRedisConfig.java -- Redis基础配置
│ │ BaseSwaggerConfig.java -- Swagger基础配置
│ ├─distributed
│ │ DistributedLock.java -- 分布式锁
│ │ RedisDistributedLock.java -- 基于Redis实现的分布式锁
│ │ ZkDistributedLock.java -- 基于zk实现的分布式锁
│ ├─domain
│ │ SwaggerProperties.java -- Swagger自定义配置
│ ├─exception
│ │ ApiException.java -- 自定义API异常
│ │ Asserts.java -- 断言处理类,用于抛出各种API异常
│ │ GlobalExceptionHandler.java -- 全局异常处理类
│ ├─service
│ │ │ RedisService.java -- Redis操作Service
│ │ └─impl
│ │ RedisServiceImpl.java -- Redis操作Service实现类
│ └─util
│ RequestUtil.java -- 请求工具类
└─resources
logback-spring.xml -- Logback日志配置文件
相关内容梳理¶
Maven¶
- 依赖范围:常见的范围是 compile、test、provided、runtime、system
- compile:缺省值,适用于所有阶段,会随着项目一起发布
- test:范围依赖,在一般的编译和运行时都不需要
- provided:打包的时候可以不用包进去。比如
servlet.jar
在开发阶段用到,但是部署的时候,由于Tomcat\lib
有servlet.jar
了,打包的时候可以去掉它 - runtime:无需参与项目的编译,不过后期的测试和运行周期需要其参与。与 compile 相比,跳过编译而已
-
system:和 provide 相同,不过被依赖项不会从 maven 仓库抓,而是从本地系统文件拿,一定要配合 systemPath 使用
-
生命周期:Maven 生命周期定义了各个构建环节的执行顺序,有了这个清单,Maven 就可以自动化的执行构建命令了
-
Clean Lifecycle 在进行真正的构建之前进行一些清理工作
- Default Lifecycle 构建的核心部分,编译,测试,打包,安装,部署
- Site Lifecycle 生成项目报告,站点,发布站点
它们是相互独立的,你可以仅仅调用 clean 来清理工作目录,仅仅调用 site 来生成站点。当然你也可以直接运行 mvn clean install site 运行所有这三套生命周期
- 常见的命令
- mvn clean 表示清理
- mvn compile 表示编译主程序
- mvn test 表示执行测试
- mvn package 表示打包,打包之后再 target 目录下会生成打包文件
- mvn install:将包安装至本地仓库
- mvn deploay:将最终的包复制到远程的仓库
状态码&异常统一管理¶
对于前后端分离的项目,前后端通常采用HTTP进行通信,所以前后端需要指定通用的消息体,方便统一处理。当协商好通用结构体之后,前端可通过相关状态码或者message进行展示相关信息。在网盘项目中,通用的结构体如下,一个消息内部共含有三块内容,首先是状态码,用于响应请求是否正常,message用于展示系统错误或者异常操作时的信息,data用于填充页面需要的数据。
public class Result<T> {
/**
* 状态码
*/
private long code;
/**
* 提示信息
*/
private String message;
/**
* 数据封装
*/
private T data;
}
同时为了方便快速构造相关数据,我们创建了ResultUtil
工具类,用于快速返回各种类型响应数据。
public class ResultUtil {
/**
* 成功返回结果
*
* @param data 获取的数据
*/
public static <T> Result<T> success(T data) {
return new Result<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
/**
* 成功返回结果
*
* @param data 获取的数据
* @param message 提示信息
*/
public static <T> Result<T> success(T data, String message) {
return new Result<T>(ResultCode.SUCCESS.getCode(), message, data);
}
...
}
同时,为了我们方便自定义各种类型的业务异常,譬如文件内容过多、文件量过大等,需要重新定一个网盘系统特定的Exception类,该类集成 RuntimeException
。
@Data
@EqualsAndHashCode(callSuper = true)
public class NetworkDiskException extends RuntimeException {
private IErrorCode errorCode;
public NetworkDiskException(IErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public NetworkDiskException(String message) {
super(message);
}
public NetworkDiskException(Throwable cause) {
super(cause);
}
public NetworkDiskException(String message, Throwable cause) {
super(message, cause);
}
public IErrorCode getErrorCode() {
return errorCode;
}
}
逻辑校验¶
1、常用的参数校验注解
2、自定义注解
有时候框架提供的校验注解并不能满足我们的业务需要,我们可以定义一个自定义校验注解。譬如某个参数type,我们希望它只能是system或者custom,不能是其他类型,这时候我们就可以通过自定义注解来实现。
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD,ElementType.PARAMETER})
@Constraint(validatedBy = FlagValidatorClass.class)
public @interface TypeVerify {
String[] value() default {};
String message() default "flag is not found";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
然后创建TypeValidator作为校验逻辑的具体实现类,实现ConstraintValidator接口,这里需要指定两个泛型参数,第一个需要指定为自定义的校验注解类,第二个指定为要校验属性的类型,isValid方法中就是具体的校验逻辑。
public class TypeValidator implements ConstraintValidator<TypeVerify,String> {
private String[] values;
private final List<String> validStrList = Lists.newArrayList<>();
@Override
public void initialize(FlagValidator flagValidator) {
this.values = flagValidator.value();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
boolean isValid = false;
if(EmptyUtil.isEmpty(value)){
//当状态为空时使用默认值
return true;
}
for (String str : validStrList) {
if (Objects.equals(str, value)) {
isValid = true;
}
}
return isValid;
}
}
分布式锁¶
在同一个jvm进程中时,可以使用JUC提供的一些锁来解决多个线程竞争同一个共享资源时候的线程安全问题,但是当多个不同机器上的不同jvm进程共同竞争同一个共享资源时候,juc包的锁就无能无力了,这时候就需要分布式锁了。常见的有使用zk的最小版本,redis的set函数,数据库锁来实现。
常见的分布式锁实现的文章如下:
常用的这三种的分布式锁的选择建议如下:
- 数据库:db操作性能较差,并且有锁表的风险,一般不考虑。
- 优点:实现简单、易于理解
-
缺点:对数据库压力大
-
Redis:适用于并发量很大、性能要求很高而可靠性问题可以通过其他方案去弥补的场景。
- 优点:易于理解
- 缺点:自己实现、不支持阻塞
- Redisson:相对于Jedis其实更多用在分布式的场景。
优点:提供锁的方法,可阻塞
- Zookeeper:适用于高可靠(高可用),而并发量不是太高的场景。
- 优点:支持阻塞
- 缺点:需理解Zookeeper、程序复杂
Dubbo核心原理¶
- Registry,注册中心,通常使用 Zookeeper 作为注册中心,其主要功能是服务地址管理和服务监听(动态感知)
- Provider,服务提供者,顾名思义,就是提供接口给别人调用的工程
- Consumer,服务消费者,顾名思义,就是需要调用被人接口的工程
- Monitor,监控中心,这个对应整个架构来说不是必须的。
Dubbo 整个调用流程详细分析说明:
- Provider 工程在启动项目的时候,连接 Zookeeper,把相关信息上传 Zookeeper,主要信息:服务名称、所在服务 IP、通讯端口、接口信息等。
- Consumer 工程在启动项目的时候,连接 Zookeeper,并且订阅 dubbo 节点,获取该节点下面的信息,并且缓存到本地一份。
- Provider 在 Zookeeper 创建的是临时节点,如果 Provider 宕机了,则连接断开,临时节点被删除
- Consumer 订阅 Zookeeper 上的 Dubbo 节点,如果该节点有变动(新增节点、删除节点),Consumer 会受到通知,并且更新本地缓存
- Consumer 调用接口,首先根据接口的名称通过动态代理技术生成一个代理类,代理类根据接口名称从本地缓存获取其对应的服务器信息(ip/port),基于 Netty 发起请求,并且获取返回接口。
相比传统的 HttpClient,Dubbo 的好处:
- 采用 HttpClient 模式,则代码本身需要维护大量的 url 地址,并且一旦其中的一些服务变更服务器,则需要手工修改代码里面的地址,并且重新部署,非常的麻烦。引入 Dubbo 之后,服务不需要在手工维护大量的 url 地址,服务提供者把地址往 Zookeeper 发送,服务消费者从 Zookeeper 获取地址,服务提供者变更服务器也没关系,只要它重新往 Zookeeper 注册,消费者正常获取其信息。
- 如果被调用的服务(提供方)做集群部署,服务调用方(消费者)会通过接口名称获取其对应信息,如果发现有多个对应信息,则 Dubbo 已经封装好了负载均衡算法。
- 如果被调用的服务(提供方)宕机了,通过Zookeeper 的监听,双方和 Zookeeper 都保持长连接,消费方会动态维护一份地址,并且 Dubbo 内置有容错机制、超时机制等,让整个服务治理更加的安全和完善。
快速入门:
Nacos 核心原理¶
如果我们自己设计一个配置中心,那么大致的实现思路如上图
- 第一步:客户端和服务端,底层基于 Netty 通讯,客户端启动的时候发起长连接连接服务器,并且获取配置缓存到本地。
- 第二步:如果配置中心的配置发生变化,则推送到客户端,客户端更新本地文件;配置中心通过最新修改时间或者文件的MD5等来进行识别配置的变化。
- 第三步:客户端获取配置文件的值,首先查询本地是否存在,如果存在则使用本地的值,否则发起请求从服务端获取最新配置。
基于以上的思路我们看下Nacos具体实现原理。
那么Nacos是如何知道配置的变化,我们通过worker这个属性,继续跟踪代码。
SpringBoot注解¶
以下阐述部分SpringBoot相关的注解,对于常用的@Component、@Service等就不一一罗列了。
- @ControllerAdvice
常与@ExceptionHandler注解一起使用,用于捕获全局异常,能作用于所有controller中,以下是网盘系统中自定义的全局异常部分代码。
@ControllerAdvice
public class GlobalExceptionHandler {
@ResponseBody
@ExceptionHandler(value = NetworkDiskException.class)
public Result handle(NetworkDiskException e) {
if (e.getErrorCode() != null) {
return ResultUtil.failed(e.getErrorCode());
}
return ResultUtil.failed(e.getMessage());
}
}
- @Aspect、@Before、@After、@AfterReturning、@AfterThrowing、@Around、@PointCut、@Order
以上都是AOP相关的注解,通过这些注解我们可以做许多系统建设性的功能等,以下是统一日志处理的切面实现部分代码
@Slf4j
@Aspect
@Component
@Order(1)
public class WebLogAspect {
@Pointcut("execution()")
public void webLog() {
}
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
}
@AfterReturning(value = "webLog()", returning = "ret")
public void doAfterReturning(Object ret) throws Throwable {
}
@Around("webLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
//TODO 日志处理操作
Object result = joinPoint.proceed();
return result;
}
}
Swagger梳理¶
快速入门:
@Controller
@Api(tags = "FileController")
@Tag(name = "FileController", description = "文件管理")
@RequestMapping("/files")
public class FileController {
@Autowired
private FileSerivice fileService;
@ApiOperation("分页查询文件")
@RequestMapping(value = "/list", method = RequestMethod.GET)
@ResponseBody
@PreAuthorize("hasRole('ADMIN')")
public Result<CommonPage<FileVO>> listFile(@RequestParam(value = "pageNum", defaultValue = "1")
@ApiParam("页码") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "5")
@ApiParam("每页数量") Integer pageSize) {
List<FileDTO> fileList = fileService.listFile(pageNum, pageSize);
BeanUtil.copyListProperties(fileList, FileVO::new);
return ResultUtil.success(CommonPage.restPage(fileList));
}
}
4. 网盘系统 文件上传方案设计¶
业务需求¶
绝大多数系统都涉及到上传功能,单实现简单的文件上传,没啥大的难度,但是加上附加需求,如支持大文件上传等内容,则要求上传功能需要支持如下常见的内容:
- 希望支持文件秒传,对于相同的文件能够快速上传,避免占用额外的磁盘空间
- 避免相同文件重复上传,需要系统具有判断文件是否重复的能力
- 支持文件&文件夹上传,提升用户体验
- 支持超大文件上传,同时为了保护系统稳定性, 需要灵活限制单文件的最大值
设计思路¶
根据上述需求,通过分析可大致总结出如下的设计目标:
- 文件需要有唯一标识符,以实现文件的秒传和重复性问题
- 需要设计一个支持大文件上传的方案,避免服务内存被打爆,同时为了确保服务的稳定性,避免被攻击,还需要考虑到上传的容量阈值是多少,支持哪些文件类型上传。
秒传¶
顾名思义,就是希望对于已经上传过的能够快速上传,这就要求系统能够识别文件是否存在。
基于上述方案的分析,基于文件MD5的快传方案将作为网盘系统的秒传实现方案。
支持大文件上传¶
文件的上传首先需要把文件转换成流的形式输入到系统,如果用户选择了大文件(譬如我们基于系统性能,规定1G以上即为大文件)上传的话,那么可能会出现两种可能:
- 文件流过大,系统内存无法一次性全部读入,导致内存溢出。
- 系统内存能够正常读入,但是因为文件过大,导致网络传输、以及相关处理逻辑过长,接口超时,文件上传失败
为了避免上述问题,我们可以针对性的进行处理
- 对于上述的两个可能出现的问题,核心在于一次性上传文件的大小过大,导致系统资源耗尽,进而导致上传处理失败,既然无法一次性全部处理,我们可以每次处理部分信息,最后进行汇总,也就是将文件进行分块,然后严格按照分块的顺序进行上传。这样对于网络的传输和后台处理起来速度都是很快的,并且处理完成之后 JVM 会自动回收垃圾,有效避免内存溢出的情况。
- 对于已经上传过的大文件,因为秒传功能的支持,可以避免再次的上传
因为文件通过前端分块之后进行上传,后端无法得知何时文件上传完成,所以还需要新增一个交互用于前端将分片文件都上传完成之后,通知后端文件文件已经上传完成,可以做其他相关的业务逻辑处理了。这是后端可以将所有的上传记录进行存储,方便后续文件的下载。
对于后端而言,分块上传的核心功能:存储分块文件、保存上传过程中分块记录、存储最终的分块信息
分块相关信息如何存储¶
综上所述:
- 分片文件:存储到文件系统中
- 上传过程中分块记录: 存储到Redis中
分块记录Key设置¶
文件上传完成之后,我们还需要将一个文件的所有分块信息持久化到数据库中,这就要求我们从Redis中能够完整取出某位用户上传的某个文件中所有的切块记录,因此我们需要一个合理的 key 规则。
存储最终的分块信息¶
这一部分的逻辑就比较明朗了,其实主要是做了四部分工作
- 校验文件完整性:通过分块记录Key,我们获取相关用户当前的文件信息,然后判断文件是否真的上传成功(判断分片数量和总量是否一致)
- 判断文件是否存在:在高并发情况下,两个人同时上传同一份文件(md5 一样),上传之前, 系统判断文件不存在,则两人都往服务器上传切块,切块合并的时候,如果不再判断一次是否存在的话,就会出现重复上传的情况。
- 保存切块信息&文件夹信息
- 删除Redis中的上传过程中的分片信息:其实这里也可以设置Redis的过期时间,不用手动删除相关存储信息,这里画出来只是说明在上传完成之后,我们需要处理Redis中的分片信息,避免Redis中存在大量的垃圾切块信息。
文件夹上传¶
因为我们支持文件夹上传,所以在存储过程中,我们需要完整的保留文件夹目录结构,对于文件夹上传,它的本质其实也是文件上传,只是给每个文件标明它在本地的目录结构,文件夹是不会被上传到网盘系统的,需要网盘系统解析relativepath字段去创建对应的目录结构,所以如何在网盘系统中正确的创建文件夹目录结构称为了核心问题。
/a
/b
abc.txt
efg.txt
假设,按照上述的示例,用户上传了a文件夹(实际就是上传abc.txt、efg.txt两个文件)。abc.txt以及efg.txt都在执行创建文件夹信息的步骤,abc.txt在创建文件夹b的过程中,efg.txt开始判断b文件夹是否存在,然后判断到b文件不存在。最终,在这两个文件上传完成之后,给用户展示的最终的效果可能就是如下的情况:
## 情况1
/a
/b
abc.txt
/b
efg.txt
## 情况2
/a
/b
abc.txt
/a
/b
efg.txt
abc.txt、efg.txt被系统分配到了两个b文件夹下。所以为了保证abc.txt、efg.txt两个文件在创建文件夹的时候,数据是安全的,我们需要让两个文件排队执行,并保证数据的原子性。
我们将用户选择上传的文件夹id作为锁对象(在系统上选中的文件夹),譬如:用户在系统上选择测试文件夹下,上传了relativepath=/a/b的文件abc.txt,那么测试文件夹对应的文件夹Id作为锁标识就可以保证两个文件进行排队处理。因此我们采用分布式锁,对于文件上传或者文件夹上传,如果是文件上传那么filemd5作为锁对象,如果是文件夹上传那么最外层文件夹名称作为锁对象。
表结构设计¶
- folder:文件夹表
- file:文件表 本质上folder可以视为一个空文件,但是考虑到文件文件夹可能存在不同的业务功能,所以将两张表拆分,进行当初处理
- file_md5: 保证文件的唯一性,即使多个人上传同一份文件,则数据库和文件系统都只保留一份。
- file_chunk:主要是存储文件的切块,它和文件系统进行关联,store_path 表示切块在文件系统的存储位置
下图就是分片上传的整体时序图:
技术准备¶
在文件上传部分,对于前端而言,要做的功能:
- 计算文件MD5
- 对文件进行切块
当然,对于大文件的MD5计算,可能会有较多的耗时,同时这两部分的工作并无严格的顺序性,所以可以考虑多线程处理。
对于后端而言,需要做的内容:
- 提供预上传、分片上传、分片上传完成、文件列表相关接口
- 分块上传完成时,我们需要存储最终的分块信息,对于该功能的实现已经分析过了,为了确保二次判断文件是否存在&保证文件夹目录的完整性,我们需要引入分布式锁的功能