基於開源方案構建統一的檔案線上預覽與office協同編輯平臺的架構與實現歷程

2022-08-29 12:03:15

大家好,又見面了。

在構建業務系統的時候,經常會涉及到對附件的支援,繼而又會引申出對附件線上預覽線上編輯多人協同編輯等種種能力的訴求。

對於人力不是特別充裕、或者專案投入預期規劃不是特別大的公司或者專案而言,通常會選擇基於一些開源方案來實現,但是開源元件選擇之後,如何將其無縫對接融入到自己的業務系統中並完全支援自身訴求的實現,不僅要能用、而且要好用,其實也是一個需要好好思量的問題。

此前在專案中就曾遇到過這麼個場景,下面一起分享下具體的架構設計調整演進與最終方案落地策略,以及過程中遇到的一些問題。

開源元件的選擇

在正式開始構建線上的檔案管理服務前,首先是分析下需要支援的功能訴求:

  • 需要支援office檔案線上預覽、線上協同編輯能力
  • 需要支援常見的主流檔案的線上預覽,比如圖片視訊文字檔案PDF壓縮包之類的
  • 需要支援檔案的儲存管理能力

對於檔案的儲存管理,直接採用了公司內部私有云的OSS檔案託管服務進行實現,實現起來比較簡單。檔案線上預覽與Office檔案線上編輯的能力,則選用相關的開源方案來實現。經過一番對比分析,最終選定了兩個開源元件:

  • OnlyOffice
    用於支援office檔案的線上協同編輯、預覽等能力。

  • kkFileView
    用於支援常規檔案的線上預覽能力

選型確定之後,就是如何與現有業務系統進行整合了。因為開源元件往往都是通用邏輯設計的,而業務系統的邏輯又各不相同,所以如何去整合並方便擴充套件出自己需要的客製化化能力,成了下一步擺在眼前需要處理的問題。

整體適配對接策略

為了保證業務系統的穩定,避免業務系統中強耦合檔案預覽相關的開源模組,同時也為了方便業務層的呼叫,所以規劃構建一個統一的入口代理轉接服務,統一由此服務對業務系統提供預覽與線上編輯相關能力,對業務層遮蔽掉底層具體的開源方案整合邏輯。這樣的好處是,不管預覽與編輯服務這邊如何調整,甚至後面更換實現方案,都不會影響到業務層的呼叫邏輯。

系統邊界劃定,對業務系統整體的接入配合而言就簡單了:

  • 業務系統只需要與預覽編輯服務之間進行介面與實現層面的約定對接即可,其實也是系統內部的模組間規範定義
  • 預覽編輯服務負責完整的業務系統請求的鑑權、與開源元件之間的適配轉換、業務客製化化的預覽與編輯能力擴充套件等等。

預覽編輯服務,作為業務系統的邊緣代理介面卡模組,需要保證提供給左側業務系統的介面的穩定,而右側具體對接的開源方案、內部處理邏輯等,則可以隨意調整。

整合OnlyOffice實現Office檔案線上預覽與編輯

讓業務程式碼無耦合的方式使用預覽能力

OnlyOffice作為一個負責office線上預覽的功能元件,其提供了一個JS API方法。具體使用的時候,需要在HTML頁面中參照其提供的JS檔案並呼叫對應API方法將請求引數傳遞給OnlyOffice進行處理。這些請求引數裡面,既含有對檔案線上顯示相關的一些屬性約定,還包含一個重要的引數,也即需要操作的目標Office檔案的獲取地址url。在OnlyOffice收到請求之後,需要去給定的地址下載目標Office檔案,然後內部解析處理之後,按照請求引數的指定資訊,渲染展示到介面上。

在實際的系統規劃中,為了便於後續版本升級維護,以及避免OnlyOffice強耦合到各個業務系統中,所以不太傾向於讓前端介面直接去整合與呼叫OnlyOffice相關的JS檔案

所以在實施的時候,在伺服器端的檔案預覽編輯服務中進行了封裝,對外提供伺服器端API介面,伺服器端自帶一個簡單HTML介面(基於SpringBoot + Thymeleaf實現),業務請求對應伺服器端提供的獨立html介面,並在介面中完成使用OnlyOffice的JS api請求的操作。

具體步驟說明如下:

  • 對外提供伺服器端HttpGet介面,藉助Thymeleaf框架,介面跳轉出現對應html介面

  • 提供簡單的HTML介面,用於引入OnlyOffice JS檔案,作為最終顯示介面外殼:

  • 在獨立的JS檔案中,接收從JAVA邏輯中傳入的引數資訊,然後轉換封裝為OnlyOffice需要的格式,然後呼叫OnlyOfficeAPI介面傳送請求

這樣就實現整體的互動封裝,業務可以程式碼無耦合的方式來直接使用預覽能力。具體的office檔案線上預覽與編輯的能力實現,由開源的OnlyOffice來提供。

具體使用的時候,互動邏輯如下:

  1. 向檔案預覽服務傳送請求,指定要操作某個檔案;

  2. 檔案預覽服務經過對請求的鑑權以及其他處理邏輯之後,瀏覽器會跳轉出OnlyOffice線上檔案預覽編輯介面,此步驟也會攜帶上具體的檔案操作屬性資料(比如檔案下載地址、檔案更新儲存回撥地址等)、以及操作的使用者資訊、允許當前使用者執行的具體操作許可權等等資訊;

  3. 在開啟的介面上,使用者可以執行檢視或者編輯等操作;

  4. OnlyOffice會通過指定的介面地址,獲取要操作的檔案的資料,以及編輯之後呼叫指定的回撥介面,將更新後的內容儲存。

看似很複雜的邏輯,但是經過封裝之後,對於業務使用而言其實很簡單,只要在傳送給檔案預覽服務的請求中,給定一個檔案下載地址與檔案儲存回撥地址即可。

協同線上編輯能力的關注點

前面有提過,採用OnlyOffice來實現office檔案的線上協同編輯,關於OnlyOffice線上編輯的原理,其官網給出的介紹如下:

對上述步驟解釋如下:

也即當用戶關閉檔案編輯介面之後,會觸發檔案的儲存事件,回撥callback介面,將儲存事件推播給伺服器端,並告知伺服器端變更後的檔案地址,這樣伺服器端可以從給定的地址下載變更後的檔案,然後更新到自己的儲存中

結合到我們具體的專案使用中,其具體的互動過程展開闡述下,就是下圖的過程:

這裡,一個線上編輯操作的回撥請求內容範例如下:

{
    "actions": [{"type": 0, "userid": "78e1e841"}],
    "changesurl": "https://documentserver/url-to-changes.zip",
    "history": {
        "changes": changes,
        "serverVersion": serverVersion
    },
    "filetype": "docx",
    "key": "Khirz6zTPdfd7",
    "status": 2,
    "url": "https://documentserver/url-to-edited-document.docx",
    "users": ["6d5a81d0"]
}

關於回撥請求的各個引數的具體含義,可以參見官網介紹,需要特別關注的幾個欄位梳理如下:

欄位 欄位型別 含義說明
actions List<Object> 每個使用者加入或者退出此檔案的編輯的動作資訊。其中具體type的取值0表示斷開連線,1表示建立連線
key String 目標檔案在OnlyOffice中處理的唯一標識ID,注意這裡的key與業務系統中目標檔案實際的唯一ID並非一個概念,不能混為一談,因為業務系統中某個檔案的ID需要保持不變,但是在OnlyOffice中編輯的時候,這個key需要不停的變。
status Integer 檔案當前的操作狀態型別,取值說明:
1: 檔案正在被編輯
2:檔案已準備好儲存
3:檔案儲存發生錯誤
4:檔案關閉,沒有變化
6:檔案正在被編輯,但是當前狀態已經被儲存
7:強制儲存檔案時發生錯誤
url String 改動後的檔案的下載地址,可以從這個地址下載到變更後的檔案,然後儲存更新業務系統中實際的檔案

實際測試的時候發現,此處的回撥介面被呼叫的情況非常的頻繁,務必要注意當且僅當actions中所有的物件的type都等於0的時候,也即所有使用者均已經退出編輯且檔案已經準備好儲存的時候,回撥介面被呼叫的時候才需要去更新key值

這裡是在實際構建的時候踩坑較久的一個地方,下面章節中展開詳細說下踩坑過程。

OnlyOffice協同編輯踩坑記

在藉助OnlyOffice構建線上協同編輯能力的時候,遇到一個很奇怪的問題,開啟一篇檔案,線上對其內容進行編輯,然後編輯完成後關閉視窗,過了一段時間嘗試再次開啟檔案編輯的時候,卻會報錯:

看了下官網的問題原因解釋,就是因為檔案編輯之後,原來的key對應的檔案已經被編輯過,已經不能被開啟了(可以把key理解為不同的version,檔案被編輯之後,version變更了,原來老的version就不允許操作了)。最後官網還很貼心的提示:別忘了每次編輯之後要重新生成一個新的key

按照官網的介紹,在callback介面被呼叫的時候,重新為檔案生成一個key,後續新的使用者想要加入此檔案的編輯的時候,都是拿到新生成的一個key,這樣不就可以了嗎?

  • Step1: 檔案開啟的時候,先嚐試獲取已存在的key值,如果不存在則新生成一個key並快取起來
try {
    // 如果redis裡面有快取此檔案對應的key值,則直接使用
    fileUniqueKey = redisCacheOperateService.getFileUniqueKeyDetail(fileId);
} catch (Exception e) {
    // 如果redis裡面沒有快取此檔案對應的key值,則生成對應的key並加入快取中
    fileUniqueKey = FileUniqueKey.builder().build();
    redisCacheOperateService.saveOrUpdateFileUniqueKeyDetail(fileUniqueKey);
}
//獲取本次線上操作對應的key值
document.setKey(fileUniqueKey.generatekey());
  • Step2: 檔案編輯儲存回撥處理中,重新生成新的key值並更新快取的key值
// 編輯成功後,重新生成隨機碼,實現key值變化的目的
fileUniqueKey.updateRandomUniqueKey();
redisCacheOperateService.saveOrUpdateFileUniqueKeyDetail(fileUniqueKey);

按照上述思路改完後,再次嘗試,發現:

  1. 當用戶A開啟檔案未做任何改動的時候,使用者B也去開啟檔案,然後兩個使用者A、B都可以加入到同一個檔案的協同編輯中,也可以進行協同編輯了;

  2. 當用戶A或B做了改動之後,再有一個新的使用者C加入此檔案編輯的時候,卻沒有辦法和A、B加入到同一個協同編輯對談中,C的改動會覆蓋到A和B的改動,同理A或者B的改動也會覆蓋掉C的改動。

難道只有讓大家都約好了一起加入進去再開始編輯才行嗎?那這個線上編輯功能顯然就是個雞肋了 —— 顯然OnlyOffice也不太可能會是這種實現。再全面覆盤了下測試的現象,分析了下可能原因:

  • 因為A、B使用的同一個key,所以A和B可以加入到同一個協同編輯對談中

  • A或者B修改了檔案之後,在callback觸發的邏輯中,將此檔案對應的key更新成了一個新的值

  • C嘗試進行同一篇檔案的線上編輯的時候,因為使用的key和A、B使用的key不相同,所以這個時候對於OnlyOffice而言,其實C是在編輯一篇與A、B完全獨立的檔案

所以問題還是出在了key的處理策略上。在網上找了一圈的檔案沒找到答案,受限於時間約束,也沒有去看過OnlyOffice的原始碼,只能根據現象分析OnlyOffice內部是基於本地快取來處理的,而key是能否讓請求打到同一份本地快取的關鍵,猜測了下OnlyOffice內部的大致處理思路是下面這個樣子:

基於上述分析:

  • 要想多人蔘與到同一個共同作業編輯對談中,必須要保證所有人操作的key都是相同的一個

  • 要想編輯後的檔案能夠下次再被開啟,必須保證下次開啟的時候key使用新的值

  • key不變更的情況下,使用者A開啟編輯的時候,視窗未關閉的情況下,使用者B可以加入,但如果使用者A關閉,使用者B再用同一個key存取的時候,就會報錯。

所以說,如果每次只要有使用者還線上的時候,這個檔案的key就不應該變,只有等某篇檔案的所有使用者都關閉編輯視窗的時候,再去處理檔案key的變更,這樣不就解決問題了嗎?

那問題就簡單了,按照這個思路修改了下callback的程式碼邏輯,判斷下某篇檔案的所有使用者都退出編輯之後,再去重新生成新的key值。

程式碼演示如下:

@PostMapping("/callback")
public DocumentEditCallbackResponse saveDocumentFile(@RequestBody DocumentEditCallback callback) throws IOException {
    try {
        // 當且僅當所有使用者都退出後,才需要將key重新生成一下,否則下次再開啟的時候,就打不開了
        if (callback.getStatus() == DocumentStatus.READY_FOR_SAVING.getCode()
                || callback.getStatus() == DocumentStatus.BEING_EDITED_STATE_SAVED.getCode()) {
            // 儲存檔案內容
            documentService.saveDocumentFile(callback.getKey(), callback.getUrl());
            // 如果所有使用者都已退出,則更新此檔案對應的預覽key值
            boolean allUserExits = callback.getActions()
                    .stream().anyMatch(actionsBean -> actionsBean.getType() == 0);
            if (allUserExits) {
                fileUniqueKey.updateRandomUniqueKey();
                redisCacheOperateService.saveOrUpdateFileUniqueKeyDetail(fileUniqueKey);
            }
        }
        return DocumentEditCallbackResponse.success();
    } catch (Exception e) {
        return DocumentEditCallbackResponse.failue();
    }
}

程式碼改動完成後,再次測試,果然問題消失,線上預覽功能恢復正常。

OnlyOffice叢集化部署

為了保障預覽服務的可靠,在生產環境上規劃實施叢集化部署。 從上一章的闡述中,我們知道OnlyOffice的功能實現嚴重依賴單機原生的快取資料資訊,在叢集部署的場景下,過度依賴本地快取的弊端就顯現出來了。

叢集化部署,本以為會很簡單,直接部署多個docker節點,然後使用Nginx做一下反向代理以及負載均衡不就可以了嘛?但是實際實施的時候卻發現在協同編輯場景下出現了預期之外的問題。因為多人線上協同編輯的能力要求所有人對某篇檔案的編輯請求都在同一個OnlyOffice服務節點上才行,而Nginx隨機負載分發,會導致同一篇檔案的編輯請求分發到不同節點上,這樣就會導致編輯的內容相互覆蓋。

因為使用者的請求並不是直接打到OnlyOffice地址上的,而是先打到檔案預覽服務中,然後由檔案預覽服務經過某種策略處理後,再將請求重定向到OnlyOffice服務上進行檔案操作的,所以這裡我們可以通過增加一個簡單的分發策略,保證對同一個檔案的所有的請求操作,都被分發到固定的一個OnlyOffice服務上處理即可。

這裡的分發策略,考慮有2種方案:

  1. 根據每個檔案的唯一ID計算hashcode值,然後與OnlyOffice節點數取餘,決定每個檔案分別有哪個OnlyOffice服務處理。此方案實現起來最為簡單,但是存在的問題也不少(比如節點新增或者刪除的時候存在問題,需要上一致性hash演演算法)。

  2. 通過隨機分發+Redis記住檔案與節點對映的方式,先隨機選擇一個節點,然後記錄下此檔案與OnlyOffice節點之間的對映關係,然後後面對此檔案的請求始終分發到該OnlyOffice節點上。

這裡我們實現的時候採用了第2種方案,藉助redis快取來實現,整體策略如上圖示意。具體實現的時候對快取資料增加了一定的過期與續期策略,既保證同一檔案請求分發到同一節點,又保證一定時間之後檔案分發快取消失,可以重新分配空閒的OnlyOffice伺服器(因為開源版本OnlyOffice只支援最大20並行量,所以可以在此層級進行分配調整)。

具體程式碼邏輯如下:

public NodeServerInfo getOnlyOfficeServer(String fileUniqueId) {
    // 從redis中先看下是否有分配過,如果有,繼續使用
    NodeServerInfo existServer = redisCacheOperateService.getExistOnlyOfficeServerByFileId(fileUniqueId);
    if (existServer != null) {
        if (serverAvailable(existServer)) {
            // 延長有效期
            redisCacheOperateService.renewalOnlyOfficeMapExpireDays(fileUniqueId, onlyOfficeServerCacheDays);
            return existServer;
        } else {
            // 刪除無效的快取
            redisCacheOperateService.deleteExistOnlyOfficeServerMapping(fileUniqueId);
        }
    }
    // 重新選擇一個可用的server
    NodeServerInfo nodeServerInfo = chooseAvaliableServer();
    // 將檔案與伺服器之間對映關係儲存redis中
    redisCacheOperateService.saveFileAndOnlyOfficeServerMapping(fileUniqueId, nodeServerInfo,
            onlyOfficeServerCacheDays);
    return nodeServerInfo;
}

至此呢,叢集化部署的問題解決,可用性上得到的有效保證。並且通過定期探測機制,及時將不可用的OnlyOffice節點從候選列表中剔除掉,保證了請求始終在可用節點上,有效避免了單點問題的出現,也一定程度上緩解單個節點的壓力(社群版本同時僅支援20並行數、通過一定策略可以分散不同檔案的請求到不同節點上)。

整合kkFileView實現其他檔案的線上預覽

kkFileView作為一個基於JAVA構建的可獨立整合部署的檔案預覽開源元件,其在各種檔案的預覽上表現非常的優異,整合起來也非常的簡單,直接提供下檔案下載的地址就可以了。支援Office檔案圖片視訊音訊壓縮包等各種檔案的預覽。

對於kkFileView的整合,我們採用了與OnlyOffice整合截然不同的處理策略,因為kkFileView基於JAVA SpringBoot技術棧構建,與我們業務系統技術棧一致,所以我們基於kkFileView的原始碼進行了深度的客製化整改。主要包括幾方面:

  • 已經採用了OnlyOffice來提供Office檔案的預覽與編輯能力,這樣kkFileView就不需要此部分能力,去掉此部分能力之後,整個kkFileView部署包體積縮小300M左右

  • kkFileView打包的時候是打成了zip包,然後通過start.sh指令碼來進行啟動的,我們適配了下公司內CI構建工具的特點,改為了經典的SpringBoot的部署形態,即1個jar搞定

  • 由於我們的檔案獲取介面涉及到許可權校驗,我們客製化了下此部分的邏輯,對接了下統一的鑑權中心。

兩者融合:緩解OnlyOffice載入慢問題

基於前面整體的規劃策略,Office檔案使用OnlyOffice進行預覽操作,非Office檔案則由kkFileView實現預覽操作(業務呼叫方無感知,都是統一一個url地址)。開發完成部署上線之後,功能也都一切正常。

但是自從上線之後,使用者普遍吐槽線上Office檔案預覽的載入速度太慢,難以忍受。因為首次使用的時候OnlyOffice會在瀏覽器本地載入一個30M左右的快取資料,而我們的服務部署在公司內網機房裡面,通過多層代理開放到公網中,使用者在公司辦公網路中存取的時候,相當於繞了多層網路代理,且由於公司辦公網路對使用者端單機下行速率有限制,導致這個第一次載入快取資料的時間需要10-15s左右才能載入出檔案。

雖然僅僅是第一次的開啟速度比較慢(如果清理了瀏覽器快取之後,首次載入還是會慢),但是等待的時間確實也有點久,所以考慮進行優化,提升下使用者的體驗感知。

非同步Office轉PDF進行預覽

雖然系統支援了Office檔案的線上預覽與編輯能力,但是統計了下,其實近乎95%的Office檔案操作都是預覽操作,考慮到kkFileView預覽PDF的速度非常的快,因此決定通過kkFileView來支援Office檔案的預覽操作,而OnlyOffice只用來做Office檔案的線上協同編輯,或者用於某些kkFileView預覽效果不夠好的Office檔案的兜底預覽場景

因為kkFileView預覽Office檔案的策略是先將Office檔案轉換為PDF,然後採用預覽PDF的策略來實現的,為了進一步的提升速度,避免每次都實時去進行Office檔案轉PDF的操作,所以設計採用非同步事件的方式進行預處理轉換,非同步轉化Office檔案為PDF,然後對於Office檔案唯讀場景直接使用PDF預覽即可。

當業務系統中的檔案內容有新增或者變更的時候,具體的非同步轉換處理的時序操作邏輯如下:

線上協同編輯的時候,需要監聽下每個檔案的變更,如果編輯後的話,需要非同步重新轉換下檔案快取內容。

預留禁用快取預覽的介面

到這裡呢,對於快速預覽office檔案的邏輯,就算基本完成了。按照當前的策略,對於office檔案預覽的場景,預設都會使用轉換後的快取PDF檔案進行預覽。在實際驗證的時候,偶爾會遇到一些轉換後PDF預覽效果不佳的情況, 所以為了解決此類問題,又對處理流程的邏輯進行了一點優化,請求引數中,預留了個欄位,可以用於呼叫方設定是否禁用本地轉換快取結果檔案進行預覽:

@ApiModelProperty(value = "是否禁止使用轉換後的格式來預覽檔案以提升速度,預設false", required = false)
private boolean notUseConvertedResultForPreview;

這樣呢,在預覽介面上可以提供個切換按鈕。如果預覽效果不滿意,可以直接切換到原始檔案採用OnlyOffice服務進行預覽,雖然速度慢些、但是可以解決預覽效果的問題。

整體實現全貌

到此呢,整個檔案的線上預覽與編輯能力的構建,就算完成了。在處理具體的檔案的預覽或者線上編輯請求的時候,對應的處理判斷總體邏輯如下:

回顧下構建之初規劃的功能訴求,也已經全部支援:

功能點 支援情況
常規檔案線上預覽
office檔案線上預覽
office檔案協同編輯
叢集部署
業務解耦

整體系統層面的網元模組架構情況如下圖所示,整個預覽服務中,所有內部邏輯均封裝在內部,統一由預覽編輯服務對外提供API介面,供業務服務進行呼叫與互動。後續如果需要對預覽服務的實現策略進行調整,也無需變更外部業務側的邏輯,實現與業務邏輯解耦的效果。

總結

好啦,關於基於開源方案構建統一的檔案線上預覽與Office協同編輯平臺的架構考量與實現過程關鍵點,這裡就給大家分享到這裡咯。看到這裡,不知道你是否也有過此方面的經歷呢?針對文中的實現策略,是否還有什麼更好的見解呢?歡迎多多留言切磋交流。

需要補充一下:

  • 因為對OnlyOffice的原始碼實現或者框架具體實現瞭解也不是很深入,所以本文闡述的相關方案,主要是基於其社群版本,在使用層面進行額外的封裝,來達到自身訴求。

  • 如有足夠的精力或者能力,也可以考慮直接基於其原始碼進行二次開發客製化來實現目的 —— 這塊受限於業務交付的急迫性,沒有嘗試。

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。