架構設計:文件服務的設計與實現

面向架構編程一文中,我闡述了本身對架構和代碼之間的關係的見解:「代碼須要反映出架構」!前端

本文經過對文件服務核心功能的設計與實現,來驗證這一觀點。設計過程融合了「用例驅動設計」和「領域驅動設計」!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
複製代碼

總結

本文給出了一個文件服務相對完整的架構設計與實現過程。整個架構設計流程以下:

  • 梳理業務功能
  • 梳理用例流程
  • 基於業務功能,進行初步的模塊劃分
  • 結合用例流程進行架構設計,期間可能反過來對模塊及流程進行調整
  • 對架構進行驗證
  • 業務流程驗證:將用例套用到架構中進行驗證
  • 非功能性約束驗證:模擬非功能性約束場景進行驗證
  • 技術選型(架構設計是與技術無關的)
  • 遵循架構設計實現代碼,測試(可能調整架構)
  • 完整流程驗證,使用說明

整個過程對各個約束作出了對應的決策,並進行了驗證。代碼結構與架構設計徹底匹配。從架構設計圖依圖索驥便可理解代碼邏輯。

若有不妥或紕漏之處,歡迎你們探討指教!

相關文章
相關標籤/搜索