关于后端:基于开源方案构建统一的文件在线预览与office协同编辑平台的架构与实现历程

62次阅读

共计 9873 个字符,预计需要花费 25 分钟才能阅读完成。

大家好,又见面了。

在构建业务零碎的时候,常常会波及到对附件的反对,继而又会引申出对附件 在线预览 在线编辑 多人协同编辑 等种种能力的诉求。

对于人力不是特地富余、或者我的项目投入预期布局不是特地大的公司或者我的项目而言,通常会抉择基于一些开源计划来实现,然而开源组件抉择之后,如何将其无缝对接融入到本人的业务零碎中并齐全反对本身诉求的实现,不仅要能用、而且要好用,其实也是一个须要好好思量的问题。

此前在我的项目中就曾遇到过这么个场景,上面一起分享下具体的架构设计调整演进与最终计划落地策略,以及过程中遇到的一些问题。

开源组件的抉择

在正式开始构建在线的文件治理服务前,首先是剖析下须要反对的性能诉求:

  • 须要反对 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 的源码实现或者框架具体实现理解也不是很深刻,所以本文论述的相干计划,次要是基于其社区版本,在应用层面进行额定的封装,来达到本身诉求。
  • 如有足够的精力或者能力,也能够思考间接基于其源码进行二次开发定制来实现目标 —— 这块受限于业务交付的急切性,没有尝试。

我是悟道,聊技术、又不仅仅聊技术~

如果感觉有用,请 点赞 + 关注 让我感触到您的反对。也能够关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的本人。

正文完
 0