大家好,又见面了。
在构建业务系统的时候,经常会涉及到对附件的支持,继而又会引申出对附件在线预览、在线编辑、多人协同编辑等种种能力的诉求。
对于人力不是特别充裕、或者项目投入预期规划不是特别大的公司或者项目而言,通常会选择基于一些开源方案来实现,但是开源组件选择之后,如何将其无缝对接融入到自己的业务系统中并完全支持自身诉求的实现,不仅要能用、而且要好用,其实也是一个需要好好思量的问题。
此前在项目中就曾遇到过这么个场景,下面一起分享下具体的架构设计调整演进与最终方案落地策略,以及过程中遇到的一些问题。
开源组件的选择
在正式开始构建在线的文件管理服务前,首先是分析下需要支持的功能诉求:
- 需要支持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需要的格式,然后调用OnlyOffice的API接口发送请求
这样就实现整体的交互封装,业务可以代码无耦合的方式来直接使用预览能力。具体的office文档在线预览与编辑的能力实现,由开源的OnlyOffice来提供。
具体使用的时候,交互逻辑如下:
- 向文件预览服务发送请求,指定要操作某个文档;
- 文件预览服务经过对请求的鉴权以及其他处理逻辑之后,浏览器会跳转出OnlyOffice在线文档预览编辑界面,此步骤也会携带上具体的文档操作属性数据(比如文件下载地址、文件更新保存回调地址等)、以及操作的用户信息、允许当前用户执行的具体操作权限等等信息;
- 在打开的界面上,用户可以执行查看或者编辑等操作;
- 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 | | 每个用户加入或者退出此文档的编辑的动作信息。其中具体type的取值0表示断开连接,1表示建立连接 |
key | | 目标文档在OnlyOffice中处理的唯一标识ID,注意这里的key与业务系统中目标文件实际的唯一ID并非一个概念,不能混为一谈,因为业务系统中某个文件的ID需要保持不变,但是在OnlyOffice中编辑的时候,这个key需要不停的变。 |
status | | 文档当前的操作状态类型,取值说明: 1: 文档正在被编辑 2:文件已准备好保存 3:文档保存发生错误 4:文件关闭,没有变化 6:文档正在被编辑,但是当前状态已经被保存 7:强制保存文档时发生错误 |
url | | 改动后的文档的下载地址,可以从这个地址下载到变更后的文件,然后存储更新业务系统中实际的文档 |
实际测试的时候发现,此处的回调接口被调用的情况非常的频繁,务必要注意当且仅当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);
按照上述思路改完后,再次尝试,发现:
- 当用户A打开文档未做任何改动的时候,用户B也去打开文档,然后两个用户A、B都可以加入到同一个文档的协同编辑中,也可以进行协同编辑了;
- 当用户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种方案:
- 根据每个文档的唯一ID计算hashcode值,然后与OnlyOffice节点数取余,决定每个文档分别有哪个OnlyOffice服务处理。此方案实现起来最为简单,但是存在的问题也不少(比如节点新增或者删除的时候存在问题,需要上一致性hash算法)。
- 通过随机分发+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的源码实现或者框架具体实现了解也不是很深入,所以本文阐述的相关方案,主要是基于其社区版本,在使用层面进行额外的封装,来达到自身诉求。
- 如有足够的精力或者能力,也可以考虑直接基于其源码进行二次开发定制来实现目的 —— 这块受限于业务交付的急迫性,没有尝试。