­

基於開源方案構建統一的文件在線預覽與office協同編輯平台的架構與實現歷程

大家好,又見面了。

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

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

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

開源組件的選擇

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

  • 需要支援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": "//documentserver/url-to-changes.zip",
    "history": {
        "changes": changes,
        "serverVersion": serverVersion
    },
    "filetype": "docx",
    "key": "Khirz6zTPdfd7",
    "status": 2,
    "url": "//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的源碼實現或者框架具體實現了解也不是很深入,所以本文闡述的相關方案,主要是基於其社區版本,在使用層面進行額外的封裝,來達到自身訴求。

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

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

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

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