在面向架构编程一文中,我阐述了自己对架构和代码之间的关系的看法:「代码需要反映出架构」!
本文通过对文件服务核心功能的设计与实现,来验证这一观点。设计过程融合了「用例驱动设计」和「领域驱动设计」!
本文及后续几篇文章会设计并开发几个实际的系统,同时尝试总结一套适用的架构设计与开发流程。欢迎探讨!
文件服务器的核心功能就两个:「文件上传」和「文件下载」!其中上传可能需要支持断点续传、分片上传。而下载可能需要进行下载保护,例如非指定客户端无法下载。
除了这两个核心功能,一般都会有一个额外功能,就是「转换」!转换包括:
除了上面的业务功能外,还包括如下非功能性约束:
根据功能,可划分如下功能模块:
首先通过分层架构对模块进行一个大致的划分,按照领域设计的分层方式:
从上面的流程可以看到「上传模块」对「转换模块」有一定的依赖,像下面这样:
但是,「上传模块」是核心模块,而「转换模块」是非核心模块。核心模块的功能相对稳定,非核心模块的功能相对不稳定。让稳定的模块去依赖不稳定的模块,会导致稳定的模块也不稳定,所以需要对依赖进行「倒置」。
「依赖倒置」解决了模块依赖问题。但是转换是个很耗时的过程,例如用户上传视频,在不转换的情况下,只要上传完成就可以得到响应,但是如果转换的话,可能就需要双倍甚至三四倍的时间才能得到反馈,体验非常的不好。且一般上传和观看的时效性并不需要即时性,所以转换应该是个异步的过程。
异步执行的方式很多,比如基于事件,自定义线程等。这里通过事件的方式来进行处理。(领域事件可参考领域设计:领域事件)
文件上传会创建UploadEvent,UploadListener监听UploadEvent事件,当监听到了UploadEvent,则执行转换。
转换流程异步化后,如何告知客户端转换结果呢?有几种方案:
目前主流做法是第一种,不过为保证文件服务器的适用性,需要能支持多种方案。故对转换后的通知也基于事件进行处理,转换后创建对应事件,关注该事件的对象来做出对应的处理。一个可能处理流程如下:
另外对于下载来说,实际直接通过Nginx这样的web服务器就可以了,所以下载模块可以直接独立。
对于配置模块来说,配置可以分为两种:
「静态配置」可以使用属性文件进行配置即可。「动态配置」需要根据不同的系统进行相应的配置,故针对图片和视频等资源配置,创建对应的配置类,根据参数通过Respository动态构建。
整体结构如下:
基于上面的设计,流程需要进行相应的调整。
下载流程不变,多了一个获取转换后文件链接的流程:
相应的模块也有调整,新增了一个消息模块,用于处理消息的发送与监听。这个消息属于领域事件,所以也放在领域层。
上传流程:
下载流程:
获取真实链接流程:
例如,现在要新增一个「秒传功能」,即对于服务器已经存在的文件,不再进行上传操作,直接返回文件URL!那么需要做如下扩展:
上面的修改不需要对现有流程做任何改动。
结构与架构图一致
事件串联了整个上传流程:
由于目前大部分是内部事件,故使用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) { } }
为了提高文件服务器的灵活性,对于转换逻辑可进行配置。如果没有进行相应的配置,则不会进行对应的处理。
下面的四个类是对各个文件类型的配置:
对应的Respository是对其保存与恢复的仓储类:
此处基于属性配置来实现(原因请见「技术选型」)!以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表示:
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; } }
转换服务根据配置委托对应的工具类来进行相应的操作(代码略):
提供两个接口:
/** * 获取转换后的信息 */@ResponseBody@GetMapping(value = "/realUrl/{token}") public ResponseEntity realUrl(@PathVariable String token) { ..... } /** * 上传文件 */@ResponseBody@PostMapping(value = {"/partupload/{group}"}) public ResponseEntity upload(HttpServletRequest request, @PathVariable String group) { ..... }
{ "code": 1, "message": "maps.mp4", "token": "key_286400710002612", "group": "GROUP1", "fileType": "VIDEO", "filePath": "http://www.abc.com/1556172522968_maps.mp4" }
{ "token": "key_282816586380196", "group": "SHILU", "fileType": "IMAGE", "filePath": "http://www.abc.com/SHILU/1/1556164891252_0.jpeg", "convertFileInfoList": [ { "fileLength": 0, "fileType": "IMAGE", "filePath": null, "imgPaths": [ "http://www.abc.com/SHILU/1/1556164891252_0_100_200.jpeg" ] } ] }
## 对外提供服务的域名 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
本文给出了一个文件服务相对完整的架构设计与实现过程。整个架构设计流程如下:
整个过程对各个约束做出了对应的决策,并进行了验证。代码结构与架构设计完全匹配。从架构设计图依图索骥即可理解代码逻辑。
如有不妥或纰漏之处,欢迎大家探讨指教!