在面向架構編程一文中,我闡述了自己對架構和代碼之間的關系的看法:「代碼需要反映出架構」!
本文通過對文件服務核心功能的設計與實現,來驗證這一觀點。設計過程融合了「用例驅動設計」和「領域驅動設計」!
本文及后續幾篇文章會設計并開發幾個實際的系統,同時嘗試總結一套適用的架構設計與開發流程。歡迎探討!
功能
文件服務器的核心功能就兩個:「文件上傳」和「文件下載」!其中上傳可能需要支持斷點續傳、分片上傳。而下載可能需要進行下載保護,例如非指定客戶端無法下載。
除了這兩個核心功能,一般都會有一個額外功能,就是「轉換」!轉換包括:
- 圖片規格轉換:一張圖片需要切分多個不同的尺寸
- 添加水印:圖片或視頻需要添加水印
- 格式轉換:
- 文件格式轉換:office轉pdf,pdf轉word,pdf轉圖片,office轉圖片等
- 視頻格式轉換:mp4轉m3u8,碼率轉換等
除了上面的業務功能外,還包括如下非功能性約束:
- 安全性:是否需要認證后才能上傳或下載
- 伸縮性:是否支持擴容,提高訪問量
- 可用性:作為基礎服務,可用性不低于4個9
- 可配置性:對于轉換方式、上傳下載方式等內容需要提供可配置能力
- 擴展性:能方便的進行功能擴展,例如對轉換方式的擴展
初步流程
- 上傳流程
- 下載流程
初步模塊劃分
根據功能,可劃分如下功能模塊:
- 上傳模塊(核心模塊):處理文件上傳
- 下載模塊(核心模塊):處理文件下載
- 轉換模塊:處理文件類型轉換
- 配置模塊:對文件服務進行配置
- 安全模塊:對文件服務進行安全保護
架構設計
首先通過分層架構對模塊進行一個大致的劃分,按照領域設計的分層方式:
- 應用層:配置模塊,安全模塊
- 領域層:上傳模塊,下載模塊,轉換模塊
從上面的流程可以看到「上傳模塊」對「轉換模塊」有一定的依賴,像下面這樣:
但是,「上傳模塊」是核心模塊,而「轉換模塊」是非核心模塊。核心模塊的功能相對穩定,非核心模塊的功能相對不穩定。讓穩定的模塊去依賴不穩定的模塊,會導致穩定的模塊也不穩定,所以需要對依賴進行「倒置」。
「依賴倒置」解決了模塊依賴問題。但是轉換是個很耗時的過程,例如用戶上傳視頻,在不轉換的情況下,只要上傳完成就可以得到響應,但是如果轉換的話,可能就需要雙倍甚至三四倍的時間才能得到反饋,體驗非常的不好。且一般上傳和觀看的時效性并不需要即時性,所以轉換應該是個異步的過程。
異步執行的方式很多,比如基于事件,自定義線程等。這里通過事件的方式來進行處理。(領域事件可參考領域設計:領域事件)
文件上傳會創建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": "http://www.abc.com/1556172522968_maps.mp4" }
- 其中的filePath是原始文件路徑
- 通過token,使用realUrl接口可以獲取轉換后的文件信息,結構如下:
{ "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
總結
本文給出了一個文件服務相對完整的架構設計與實現過程。整個架構設計流程如下:
- 梳理業務功能
- 梳理用例流程
- 基于業務功能,進行初步的模塊劃分
- 結合用例流程進行架構設計,期間可能反過來對模塊及流程進行調整
- 對架構進行驗證
- 業務流程驗證:將用例套用到架構中進行驗證
- 非功能性約束驗證:模擬非功能性約束場景進行驗證
- 技術選型(架構設計是與技術無關的)
- 遵循架構設計實現代碼,測試(可能調整架構)
- 完整流程驗證,使用說明
整個過程對各個約束做出了對應的決策,并進行了驗證。代碼結構與架構設計完全匹配。從架構設計圖依圖索驥即可理解代碼邏輯。
如有不妥或紕漏之處,歡迎大家探討指教!