架构设计:文件服务的设计与实现

面向架构编程一文中,我阐述了本身对架构和代码之间的关系的见解:「代码须要反映出架构」!前端

本文经过对文件服务核心功能的设计与实现,来验证这一观点。设计过程融合了「用例驱动设计」和「领域驱动设计」!web

本文及后续几篇文章会设计并开发几个实际的系统,同时尝试总结一套适用的架构设计与开发流程。欢迎探讨!redis

功能

文件服务器的核心功能就两个:「文件上传」和「文件下载」!其中上传可能须要支持断点续传、分片上传。而下载可能须要进行下载保护,例如非指定客户端没法下载。spring

除了这两个核心功能,通常都会有一个额外功能,就是「转换」!转换包括:数据库

  • 图片规格转换:一张图片须要切分多个不一样的尺寸
  • 添加水印:图片或视频须要添加水印
  • 格式转换
  • 文件格式转换:office转pdf,pdf转word,pdf转图片,office转图片等
  • 视频格式转换:mp4转m3u8,码率转换等

除了上面的业务功能外,还包括以下非功能性约束:编程

  • 安全性:是否须要认证后才能上传或下载
  • 伸缩性:是否支持扩容,提升访问量
  • 可用性:做为基础服务,可用性不低于4个9
  • 可配置性:对于转换方式、上传下载方式等内容须要提供可配置能力
  • 扩展性:能方便的进行功能扩展,例如对转换方式的扩展

初步流程

  • 上传流程

架构设计:文件服务的设计与实现

  • 下载流程

架构设计:文件服务的设计与实现

初步模块划分

根据功能,可划分以下功能模块:安全

  • 上传模块(核心模块):处理文件上传
  • 下载模块(核心模块):处理文件下载
  • 转换模块:处理文件类型转换
  • 配置模块:对文件服务进行配置
  • 安全模块:对文件服务进行安全保护

架构设计

首先经过分层架构对模块进行一个大体的划分,按照领域设计的分层方式:服务器

  • 应用层:配置模块,安全模块
  • 领域层:上传模块,下载模块,转换模块

架构设计:文件服务的设计与实现

从上面的流程能够看到「上传模块」对「转换模块」有必定的依赖,像下面这样:markdown

架构设计:文件服务的设计与实现

可是,「上传模块」是核心模块,而「转换模块」是非核心模块。核心模块的功能相对稳定,非核心模块的功能相对不稳定。让稳定的模块去依赖不稳定的模块,会致使稳定的模块也不稳定,因此须要对依赖进行「倒置」。架构

架构设计:文件服务的设计与实现

「依赖倒置」解决了模块依赖问题。可是转换是个很耗时的过程,例如用户上传视频,在不转换的状况下,只要上传完成就能够获得响应,可是若是转换的话,可能就须要双倍甚至三四倍的时间才能获得反馈,体验很是的很差。且通常上传和观看的时效性并不须要即时性,因此转换应该是个异步的过程。

异步执行的方式不少,好比基于事件,自定义线程等。这里经过事件的方式来进行处理。(领域事件可参考领域设计:领域事件

架构设计:文件服务的设计与实现

文件上传会建立UploadEvent,UploadListener监听UploadEvent事件,当监听到了UploadEvent,则执行转换。

转换流程异步化后,如何告知客户端转换结果呢?有几种方案:

  • 上传完成后,文件服务返回一个token,后续业务系统经过token来获取转换后的URL。此方案须要业务系统请求两次。
  • 文件服务转换完成后入库,业务系统从数据库获取。此方案也须要业务系统请求两次,且对不一样的业务须要有不一样的实现。
  • 文件服务转换完成后回调业务系统。此方案可能须要实现不一样的业务回调接口。
  • 文件服务器返回一个事先生成的URL,在文件转换完成时返回特定状态码,在转换完成后,返回文件。对于某些场景没法事先生成URL,例如office转图片,一个文档会转成多张图片,转换前没法得知图片URL

目前主流作法是第一种,不过为保证文件服务器的适用性,须要能支持多种方案。故对转换后的通知也基于事件进行处理,转换后建立对应事件,关注该事件的对象来作出对应的处理。一个可能处理流程以下:

  • 上传完成后,文件服务器返回原始文件地址以及token。业务系统在redis针对此token建立监听
  • 文件服务器在转换完成后建立转换事件,转换事件监听对象监听到此事件后,向redis发送通知
  • 业务系统接收到通知,更新URL

另外对于下载来讲,实际直接经过Nginx这样的web服务器就能够了,因此下载模块能够直接独立。

对于配置模块来讲,配置能够分为两种:

  • 文件服务自身须要的配置信息。例如:上传文件目录。这属于「静态配置」
  • 各个调用系统须要的各自的配置。例如:某些系统须要切100*100的图,而有些系统须要切200*200的图。这属于「动态配置」

「静态配置」可使用属性文件进行配置便可。「动态配置」须要根据不一样的系统进行相应的配置,故针对图片和视频等资源配置,建立对应的配置类,根据参数经过Respository动态构建。

总体结构以下:

架构设计:文件服务的设计与实现

流程调整

基于上面的设计,流程须要进行相应的调整。

  • 上传流程

架构设计:文件服务的设计与实现

下载流程不变,多了一个获取转换后文件连接的流程:

架构设计:文件服务的设计与实现

模块调整

相应的模块也有调整,新增了一个消息模块,用于处理消息的发送与监听。这个消息属于领域事件,因此也放在领域层。

架构设计:文件服务的设计与实现

架构验证

业务流程验证

上传流程

  • 客户端上传文件
  • 经过「安全模块」验证。若是验证失败,返回验证失败信息
  • 若是验证成功,经过「上传模块」上传文件
  • 「上传模块」构建「上传事件」,添加到消息总线中
  • 上传完成,返回用户消息。消息包含原始文件URL,若是须要转换的话,则包含转换对应的token
  • 「转换模块」监听到「上传事件」,根据「配置模块」的配置,进行转换
  • 「转换模块」构建转换消息,添加到消息总线中
  • 对应「监听模块」监听到转换消息,进行后续处理。例如信息入库或通知业务系统

下载流程

  • 客户端下载文件
  • 经过「安全模块」验证。若是验证失败,返回验证失败信息
  • 若是验证成功,经过「下载模块」下载文件

获取真实连接流程

  • 客户端携带token获取真实连接
  • 「下载模块」根据token查询文件是否转换成功
  • 若是转换成功,则返回转换后的URL
  • 不然返回未转换成功状态码

非功能性约束验证

  • 安全性:由「安全模块」保障
  • 伸缩性:对于下载来讲,可经过CDN处理。对于上传来讲,文件服务自己没有状态,可方便扩容
  • 可用性:支持多点部署,经常使用故障转移手段均可使用
  • 可配置性:由「配置模块」保障
  • 扩展性:基于事件的处理方式,经过添加事件响应对象来进行功能扩展

例如,如今要新增一个「秒传功能」,即对于服务器已经存在的文件,再也不进行上传操做,直接返回文件URL!那么须要作以下扩展:

  • 新增存储逻辑,用于保存文件地址与文件hash的关系
  • 新增一个检查文件hash的接口,若是hash已存在,返回文件URL,不然返回false
  • 添加一个UploadEvent同步监听事件,当文件上传成功后,对文件取hash,将数据保存到上面建立的表中

上面的修改不须要对现有流程作任何改动。

技术选型

  • 公司核心技术语言为Java,故优先选择使用Java语言开发
  • 框架基于SpringBoot,基于以下考虑:
  • SpringBoot是目前JavaEE开发事实上的标准框架
  • 可独立部署,亦能够升级到基于SpringCloud的微服务,方便向微服务架构迁移
  • 配置信息决定不使用数据库,而使用属性文件配置,基于以下考量:
  • 静态配置配置后基本不须要修改
  • 动态配置修改概率也不大,若是须要调整,SpringBoot自己支持实时刷新配置
  • 微服务部署,可结合分布式配置服务器实现动态配置
  • 不须要部署数据库,不须要设计表结构,节省部署与设计时间。可是考虑到扩展性,配置逻辑须要抽象,以支持其余持久化方式
  • 转换结果信息使用文件形式存储,基于以下考量:
  • 结果信息是一次读取内容,且频率不高
  • 自己就是文件服务,使用文件存储也合理
  • 不须要部署数据库,不须要设计表结构,节省部署与设计时间

实现

架构设计:文件服务的设计与实现

结构与架构图一致

事件实现

事件串联了整个上传流程:

  • 文件上传,触发UploadEvent
  • UploadListener监听到UploadEvent,委托各个Converter进行文件处理
  • 转换完成后触发ConvertEvent
  • ConvertListener监听到ConvertEvent后,进行转换后的信息处理

因为目前大部分是内部事件,故使用Spring事件来处理,代码逻辑以下:

// 配置线程池,Spring默认线程池没有设置大小,若是出现阻塞,可能会出现OOM@Bean("eventThread")
 public TaskExecutor taskExecutor() {
 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
 // 设置核心线程数,转换是个很耗时的过程,因此直接排队执行
 executor.setCorePoolSize(1);
 // 设置最大线程数
 executor.setMaxPoolSize(1);
 // 设置队列容量
 executor.setQueueCapacity(100);
 // 设置线程活跃时间(秒)
 executor.setKeepAliveSeconds(60);
 // 设置默认线程名称
 executor.setThreadNamePrefix("eventThread-");
 // 设置拒绝策略
 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
 // 等待全部任务结束后再关闭线程池
 executor.setWaitForTasksToCompleteOnShutdown(true);
 return executor;
 }
/**
 * 内部消息总线
 */@Service@EnableAsyncpublic class EventBus implements ApplicationEventPublisherAware {
 private ApplicationEventPublisher publisher;
 @Override
 public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
 this.publisher = applicationEventPublisher;
 }
 public void add(ApplicationEvent event) {
 publisher.publishEvent(event);
 }
}
// 事件类public class UploadEvent extends ApplicationEvent {
 public UploadEvent(Object source) {
 super(source);
 }
}
public class ConvertEvent extends ApplicationEvent {
 public ConvertEvent(Object source) {
 super(source);
 }
}
// 监听类@Componentpublic class UploadListener {
 @EventListener
 @Async("eventThread") // 使用自定义的线程池
 public void process(UploadEvent event) {
 }
}
@Componentpublic class ConvertListener {
 @EventListener
 @Async("eventThread")
 public void process(ConvertEvent event) {
 }
}
复制代码

配置管理实现

为了提升文件服务器的灵活性,对于转换逻辑可进行配置。若是没有进行相应的配置,则不会进行对应的处理。

下面的四个类是对各个文件类型的配置:

  • ImageConfig:切图大小
  • OfficeConfig:转换类型,是否获取页码
  • PdfConfig:转换类型,是否获取页码
  • VideoConfig:转换类型,是否获取长度,是否取帧

对应的Respository是对其保存与恢复的仓储类:

  • ImageConfigRespository
  • OfficeConfigRespository
  • PdfConfigRespository
  • VideoConfigRespository

此处基于属性配置来实现(缘由请见「技术选型」)!以VideoConfigRespository为例:

@Configuration@ConfigurationProperties(prefix = "fileupload.config")
public class VideoConfigRespository {
 private List<VideoConfig> videoConfigList;
 /**
 * 根据分组(系统)找到对应的视频配置
 *
 * @param group
 * @return
 */
 public List<VideoConfig> find(String group) {
 if (videoConfigList == null) {
 return new ArrayList<>();
 } else {
 return videoConfigList.stream().filter(it -> it.getGroup().equals(group)).collect(Collectors.toList());
 }
 }
 public List<VideoConfig> getVideoConfigList() {
 return videoConfigList;
 }
 public void setVideoConfigList(List<VideoConfig> videoConfigList) {
 this.videoConfigList = videoConfigList;
 }
}
复制代码

经过Spring的ConfigurationProperties注解,将属性文件中的属性配置到videoConfigList中。

# 视频配置
fileupload.config.videoConfigList[0].group=GROUP1 
# 默认配置
fileupload.config.videoConfigList[1].group=GROUP2
fileupload.config.videoConfigList[1].type=webm 
 # 转换为webm
fileupload.config.videoConfigList[1].frameSecondList[0]=3 # 取第3秒的图片
复制代码

转换结果实现

转换结果经过ConvertResult和ConvertFileInfo表示:

  • ConvertResult中包含了源文件信息,以及多个转换结果。ConvertFileInfo表示一个转换结果
  • ConvertResult是Entity而ConvertFileInfo是VO
  • ConvertResult与ConvertFileInfo是一对多的关系
  • 二者构成聚合,其中ConvertResult是聚合根(关于聚合与聚合根请参考领域设计:聚合与聚合根)

ConvertResultRespository是这个聚合的仓储,用于保存与恢复此聚合。此处没有使用数据库,而是直接使用的文本形式保存(缘由见「技术选型」)。

@Componentpublic class ConvertResultRespository {
 ......
 /**
 * 保存转换结果
 *
 * @param result
 * @return
 */
 public void save(ConvertResult result) {
 Path savePath = Paths.get(tokenPath, result.getToken());
 try {
 if(!Files.exists(savePath.getParent())) {
 Files.createDirectories(savePath.getParent());
 }
 Files.write(savePath, gson.toJson(result).getBytes(UTF8_CHARSET));
 } catch (IOException e) {
 logger.error("save ConvertResult[{}} error!", result, e);
 }
 }
 /**
 * 查找转换结果
 *
 * @param token
 * @return
 */
 public ConvertResult find(String token) {
 Path findPath = Paths.get(tokenPath, token);
 try {
 if (Files.exists(findPath)) {
 String result = new String(Files.readAllBytes(findPath), UTF8_CHARSET);
 return gson.fromJson(result, ConvertResult.class);
 }
 } catch (IOException e) {
 logger.error("find ConvertResult by token[{}} error!", token, e);
 }
 return null;
 }
}
复制代码

转换服务实现

转换服务根据配置委托对应的工具类来进行相应的操做(代码略):

  • 使用ffmpeg转换视频
  • 使用pdfbox转换pdf
  • 使用libreoffice转换office

安全实现

  • 安全经过Spring拦截器实现
  • 按需求增长对应拦截便可

使用

提供两个接口:

/**
* 获取转换后的信息
*/@ResponseBody@GetMapping(value = "/realUrl/{token}")
public ResponseEntity realUrl(@PathVariable String token) {
 .....
}
/**
* 上传文件
*/@ResponseBody@PostMapping(value = {"/partupload/{group}"})
public ResponseEntity upload(HttpServletRequest request, @PathVariable String group) {
 .....
}
复制代码
  • 经过upload接口上传文件,支持分片上传

  • 上传完成后,会返回上传结果,结构以下:

    { "code": 1, "message": "maps.mp4", "token": "key_286400710002612", "group": "GROUP1", "fileType": "VIDEO", "filePath": "www.abc.com/15561725229…" }

  • 其中的filePath是原始文件路径

  • 经过token,使用realUrl接口能够获取转换后的文件信息,结构以下:

    { "token": "key_282816586380196", "group": "SHILU", "fileType": "IMAGE", "filePath": "www.abc.com/SHILU/1/155…", "convertFileInfoList": [ { "fileLength": 0, "fileType": "IMAGE", "filePath": null, "imgPaths": [ "www.abc.com/SHILU/1/155…" ] } ] }

配置

## 对外提供服务的域名
fileupload.server.name=http://www.abc.com## libreoffice home路径
office.home=/snap/libreoffice/115/lib/libreoffice
# 文件上传保存路径
fileupload.upload.root=/home/files
# 文件服务器动态配置# 图片配置,切100*200的图fileupload.config.imageConfigList[0].group=group1
fileupload.config.imageConfigList[0].width=100
fileupload.config.imageConfigList[0].height=200
# 视频配置
# 默认配置,转换m3u8
fileupload.config.videoConfigList[0].group=group1
# 转换webm,切第3秒的图
fileupload.config.videoConfigList[1].group=group2
fileupload.config.videoConfigList[1].type=webm
fileupload.config.videoConfigList[1].frameSecondList[0]=3
# office配置,默认转png
fileupload.config.officeConfigList[0].group=group1
# 转PDF
fileupload.config.officeConfigList[0].type=PDF
# pdf配置,转png
fileupload.config.pdfConfigList[0].group=group1
# 上传文件大小,当前端不支持分片上传时设置
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1024MB
复制代码

总结

本文给出了一个文件服务相对完整的架构设计与实现过程。整个架构设计流程以下:

  • 梳理业务功能
  • 梳理用例流程
  • 基于业务功能,进行初步的模块划分
  • 结合用例流程进行架构设计,期间可能反过来对模块及流程进行调整
  • 对架构进行验证
  • 业务流程验证:将用例套用到架构中进行验证
  • 非功能性约束验证:模拟非功能性约束场景进行验证
  • 技术选型(架构设计是与技术无关的)
  • 遵循架构设计实现代码,测试(可能调整架构)
  • 完整流程验证,使用说明

整个过程对各个约束作出了对应的决策,并进行了验证。代码结构与架构设计彻底匹配。从架构设计图依图索骥便可理解代码逻辑。

若有不妥或纰漏之处,欢迎你们探讨指教!

相关文章
相关标签/搜索