ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+OnlyOffice+MinIO一站式实现Word在线编辑协同

SpringBoot+OnlyOffice+MinIO一站式实现Word在线编辑协同 先把话说在前面如果你正打算在 SpringBoot 系统里实现“网页打开 Word、多人同时编辑、一键转换 PDF/图片”这类功能千万别自己从零写编辑器也别指望用 contenteditable 硬怼。这个领域已经有相当成熟的一条龙方案用 OnlyOffice Document Server 把 Word 的排版、编辑、协同能力整体变成“文档服务”后端 SpringBoot 只负责生成配置、接收保存回调、对接 MinIO 存储前端用 Vue3 OnlyOffice 的 JavaScript API 直接把编辑器挂到页面上。整条链路跑通之后比我想象中轻量太多。这篇文章把完整落地过程、代码骨架、回调状态机以及我踩过的几个高频坑一次讲清楚适合正在做 OA、合同审批、在线文档工具或者毕业设计里需要“在线 Word 编辑”的 SpringBoot 开发者参考。1. 需求拆解在线 Word 到底在解决什么问题1.1 三类典型使用场景先说需求。大部分项目要“在线 Word”并不是真的想做办公软件而是业务里确实存在这三类高频场景第一类是 OA 和合同审批。业务流程里要有人起草合同、法务批注修改、业务负责人定稿最后导出一版 PDF 盖章归档。这个场景对“编辑 协同 转化”三件套的需求是同时出现的。第二类是知识库和云盘系统。用户上传了一堆 doc/docx希望浏览器里直接打开预览偶尔改两笔甚至几个人在同一份文档上留评论。这个场景更看重“在线预览”和“批注协同”。第三类是行业文档工具比如论文模板、招标文件、考试试卷。这类系统经常要处理“公式图片转 Word”“Word 转 PDF”“批量设置表格列宽”“PDF 批量盖章”这些偏生产类的需求。如果你只是“预览”用前端纯 JS 解析 docx 就够了但一旦涉及“在线编辑 多人在线协同 格式保真转换”选型就完全不是一回事了。1.2 三条技术路线的取舍我在最开始接触这个需求时先后对比过三条路线。方案能做什么限制前端纯 JSdocx-preview、mammoth、html-docx-js预览、简单渲染、下载重命名不能真正编辑原格式不支持多人协同后端 Apache POI / docx4j批量生成、解析、修改 docx没有交互界面做不到实时在线编辑在线文档服务器OnlyOffice、Collabora在线编辑、多人协同、文档转换增加一个独立服务需要部署和维护结论很清楚SpringBoot 项目里最省心的做法是把整个 Word 引擎“外置”成独立服务后端不要试图去渲染 Word也不要自己去维护协同算法。OnlyOffice Document Server 解决了“谁来解析 Word 排版”这个最大的难题而且它自带转换接口在线编辑、协同、转化三件事都在一个服务里解决掉。2. 选型定格为什么是 SpringBoot OnlyOffice MinIO而不是“自己造编辑器”2.1 OnlyOffice Document Server 把 Word 变成了服务很多同学第一次接触 OnlyOffice 时以为它是像 Element UI 一样的前端组件。实际上它是一套完整的文档引擎服务通常以 Docker 镜像方式部署。它内部自带文档解析器、排版渲染引擎、WebSocket 协同通道浏览器端通过它暴露的 JavaScript API 打开编辑器页面。真正让我决定选它而不是 Collabora 的原因有三个第一它对 docx 格式的保真度非常高因为它的排版引擎天生就是按“兼容 MS Office”标准来实现的第二多人协同是原生能力同一份文档用相同 key 打开就自动进入同一个编辑会话不需要我们自己做任何协同协议第三它附带一个文档转换接口Word 转 PDF、HTML、ODT 这类高频需求可以直接调用省掉再接一堆第三方服务。SpringBoot 在这里的角色反而简单了它不需要理解 Word 内部结构只需要负责“告诉 OnlyOffice 哪份文档要打开、谁在打开、打开后怎么保存”。2.2 MinIO 负责给文档一个“家”在线文档系统缺不了对象存储。本地磁盘当然也能存但一旦要支持版本回溯、多节点部署还是 S3 协议的对象存储更省心。MinIO 是 Java 生态里最常用的一档选择原生支持 S3 协议SpringBoot 用 AWS SDK 或者 minio-java 客户端直接对接即可。MinIO 在整个链路里承担两件事第一给前端和 OnlyOffice 提供一个可下载的文档 URL第二接收 OnlyOffice 保存回调返回的新文件覆盖或追加为新版本。有一点要注意传给 OnlyOffice 的文档 URL 不能是私有的内网路径必须是一个文档服务器能访问到的 HTTP 地址。MinIO 的预签名 URLpresigned URL正好能满足这个需求而且可以设置有效期避免文档长期暴露。2.3 整体架构与数据流整条链路的调用顺序是这样的浏览器Vue3 页面请求 SpringBoot 的/api/doc/{id}/edit接口SpringBoot 从库里查出文档元数据从 MinIO 生成预签名下载 URL拼出一个 JSON 配置返回给前端。前端把这个配置交给DocsAPI.DocEditorOnlyOffice Document Server 拿到配置后自己去拉取 MinIO 上的原始 docx 文件转换成语义化编辑格式显示在编辑器里。编辑过程中浏览器和 OnlyOffice 服务器之间通过 WebSocket 实时同步多人看到的修改是毫秒级刷新的SpringBoot 完全不参与实时协同。当用户点保存或编辑器自动保存时OnlyOffice 服务器会往 SpringBoot 注册的 callbackUrl 发一个 HTTP 请求里面带有最新文件 URL。SpringBoot 收到后去下载新文件再把它传回 MinIO 覆盖旧版本这才算完成一次真正落盘的保存。这个设计的关键在于SpringBoot 永远不碰 Word 文件的二进制解析所有重活都让 OnlyOffice 干。3. 十分钟跑通从零搭出一个能编辑的 Word3.1 Docker 拉起 OnlyOffice Document Server先把文档服务跑起来。这里我建议直接固定版本号不要用 latest否则改天更新了一个大版本JWT 算法变了、回调格式变了排查起来非常难受。我本地示例用的是 8.2 这个系列。docker run -d \ --name onlyoffice-docserver \ -p 8088:80 \ -v /data/onlyoffice/logs:/var/log/onlyoffice \ -v /data/onlyoffice/cache:/var/lib/onlyoffice/documentserver/App_Data/cache \ -v /data/onlyoffice/data:/var/www/onlyoffice/Data \ onlyoffice/documentserver:8.2部署完成后先在浏览器访问http://服务器IP:8088/web-apps/apps/api/documents/api.js能看到 JS 文件就说明服务起来了。文档服务器建议至少给 2G 内存低于这个配置多人编辑时会出现 WebSocket 突然断开、保存失败这类诡异问题。3.2 SpringBoot 后端生成编辑配置OnlyOffice 的打开方式非常直接前端拿到的就是一个 JSON 配置其中最关键的是document和editorConfig两个对象。RestController RequestMapping(/api/doc) public class DocController { private final DocService docService; private final MinioService minioService; public DocController(DocService docService, MinioService minioService) { this.docService docService; this.minioService minioService; } GetMapping(/{id}/edit) public MapString, Object editConfig(PathVariable Long id, RequestParam String userId) { DocMeta meta docService.getById(id); MapString, Object document new HashMap(); document.put(fileType, docx); document.put(key, docService.buildKey(meta)); document.put(title, meta.getFileName()); document.put(url, minioService.presignedGetUrl(meta.getObjectName(), 3600)); MapString, Object permissions new HashMap(); permissions.put(edit, true); permissions.put(download, true); permissions.put(print, true); document.put(permissions, permissions); MapString, Object editorConfig new HashMap(); editorConfig.put(callbackUrl, http://10.0.0.5:8080/api/doc/callback/ id); editorConfig.put(lang, zh-CN); MapString, Object user new HashMap(); user.put(id, userId); user.put(name, userService.getNickname(userId)); editorConfig.put(user, user); MapString, Object config new HashMap(); config.put(document, document); config.put(documentType, word); config.put(editorConfig, editorConfig); return config; } }注意这里有几个细节。document.url必须是 OnlyOffice 服务器能访问的地址所以 MinIO 预签名 URL 用 3600 秒有效期比较稳妥之前我试过 10 分钟结果用户编辑到一半图片加载失败很容易被误会成系统 bug。callbackUrl同理不能写localhost必须写 OnlyOffice 能路由到的地址。3.3 前端 Vue3 接入前端接入比想象中简单核心就三步加载 OnlyOffice 的 JS API、向 SpringBoot 要配置、把配置丢给DocsAPI.DocEditor。template div refdocContainer stylewidth: 100%; height: 90vh/div /template script setup import { ref, onMounted } from vue const props defineProps({ docId: { type: [Number, String], required: true }, userId: { type: String, required: true } }) const docContainer ref(null) onMounted(async () { const script document.createElement(script) script.src http://文档服务器地址:8088/web-apps/apps/api/documents/api.js script.onload async () { const config await fetch( /api/doc/${props.docId}/edit?userId${props.userId} ).then(res res.json()) new DocsAPI.DocEditor(docContainer.value, config) } document.head.appendChild(script) }) /script这里不需要额外安装什么 Vue 专用组件库OnlyOffice 官方 JS API 已经够用。你要是习惯用 Vue2/Vue3 的v-html或自定义指令把它封装成组件也可以注意处理组件销毁时调用docEditor.destroyEditor()释放资源就行。3.4 关键参数逐个说刚开始接触这几个配置参数容易只抄别人的模板不知道每个字段是干嘛的。我整理了一张对照表参数作用常见坑document.key文档唯一标识决定是否复用编辑会话必须稳定且唯一不能每次随机生成document.url原始文件的下载地址必须是文档服务器可访问的 HTTP 地址document.fileType文件类型如 docx和后缀不一致会导致无法打开editorConfig.callbackUrl保存回调地址不能是 localhost响应要快editorConfig.user当前用户信息不同 userId 协同时会显示不同光标editorConfig.modeedit / review / view只读场景配 view 更安全document.permissionsedit / download / print只读也建议关掉 downloadkey这个字段我单独强调一下它是协同会话的身份证。同一份文档用同一个 key 打开的所有人共享一个编辑会话key 变了就相当于新建一个会话。所以 key 的生成规则不能用 UUID 随机生成而应该用“对象名 版本号 修改时间”的哈希。比如MD5(objectName version modifiedTime)这样内容变了 key 才会变同一内容任何用户打开都进同一个会话。4. 协同不是玄学回调状态机与多人在线编辑的实现细节4.1 打开编辑后发生了什么只要两份浏览器页面请求同一个文档 id而我们返回的key是一样的OnlyOffice 就会自动把两个人放进同一个编辑会话。你不需要自己实现任何协同算法不需要管操作转换、冲突合并、光标同步这些全部由文档服务器完成。但你要理解它的保存机制否则后面对接回调时会一头雾水。OnlyOffice 的保存不是一个永久的 WebSocket 推送通道而是文档服务器通过 HTTP 回调通知 SpringBoot“现在可以来拿新文件了”。这个回调本质上是状态机不同状态码代表不同事件。4.2 callback 状态机与保存流程SpringBoot 端要接收的回调请求体大致长这样{ status: 2, key: 7f3a9b8c1d2e, url: http://docserver/cache/files/xxx/output.docx, users: [1001, 1002], actions: [{type: 1, userid: 1001}] }其中status是最关键字段含义如下status含义后端处理建议1有人打开了文档记录在线状态即可2需要保存文档下载url里的新文件落库落存储3已保存成功且内容无变化忽略4文档关闭且无变化忽略6正在编辑但保存时出错记录日志人工干预7强制保存和 status 2 一样保存新文件回调接口很简单但有一个性能要求OnlyOffice 发出回调后会等待响应建议在 10 秒内返回固定结构{error:0}如果超时它会重试。重试本身不致命但高并发时同一个回调重复进来如果后端没有做幂等处理容易把一个新版本覆盖成旧版本。我建议后端在 status 为 2 或 7 时先校验 key 是否和当前文档最新 key 一致再做一次分布式锁保护最后下载并保存新版本PostMapping(/callback/{id}) public String callback(PathVariable Long id, RequestBody MapString, Object body) { Integer status (Integer) body.get(status); String key (String) body.get(key); String url (String) body.get(url); if (status 2 || status 7) { boolean locked lock.tryLock(doc:save: id, 10, TimeUnit.SECONDS); if (!locked) { return {\error\:1}; } try { if (!docService.isLatestKey(id, key)) { return {\error\:0}; } byte[] bytes httpClient.get(url, byte[].class); docService.saveNewVersion(id, key, bytes); } finally { lock.unlock(doc:save: id); } } return {\error\:0}; }4.3 权限控制与只读/评论模式协同场景不只是“谁都能改”。在实际业务里合同审批最好让普通员工只读、法务可批注、管理员可修改。这些通过document.permissions和editorConfig.mode配合实现。editorConfig.mode有三种view只读、review批注/修订、edit直接编辑。我一般根据用户角色动态生成配置而不是在 Controller 里写死。比如普通审批人返回mode: view法务返回mode: review文员返回mode: edit。同一个文档同一份 content不同的人打开后界面能力完全不同这个体验比我们自己写按钮控制权限要自然得多。另外user.id必须稳定唯一最好是系统里的用户主键。OnlyOffice 会用它在多人光标和评论里区分身份如果两个人 id 相同就会出现“两个光标并成一个”的幻觉问题。5. 一站式转化Word 转 PDF、PDF 转 Word、公式图片转 Word 的落地姿势5.1 调用 ConvertService 完成格式互转OnlyOffice 自带的转化接口是这个需求里最值钱的一部分。它不依赖前端打开编辑器可以直接由 SpringBoot 以后台任务的方式调用。接口地址是文档服务器根路径下的ConvertService.ashx。调用方式是一个 POST JSONcurl -X POST http://文档服务器地址:8088/ConvertService.ashx \ -H Content-Type: application/json \ -d { url: http://10.0.0.5:9000/docs/contract.docx, outputtype: pdf, filetype: docx, title: contract.docx, key: a1b2c3d4e5 }响应是异步的第一次可能直接返回endConvert: false或status: 1表示还在转换中需要在 SpringBoot 里轮询。直到返回下面这种结构才算转换完成{ endConvert: true, status: 2, fileUrl: http://文档服务器地址:8088/cache/files/xxx/output.pdf, fileType: pdf }拿到fileUrl后再把它下载下来存进 MinIO。流程没什么难度但轮询千万别做成 while(true)最好加个最多重试 20 次、间隔 2 秒的上限否则大文件转 PDF 卡住时线程会被拖死。5.2 PDF 转 Word 与公式图片转 Word 的补充OnlyOffice 的转换管线强在“从可编辑格式转 PDF”也就是输出很稳。但反向的 PDF 转 Word它支持得并不算好版式还原度完全取决于源文件质量尤其扫描件转出来基本是“有文字没排版”的状态。所以我现在的策略是批量场景先用 OnlyOffice 试失败率高就走 LibreOffice headless 兜底如果业务对版式要求极高再考虑 Aspose.Words 这种商业库。LibreOffice 的用法很直白soffice --headless --convert-to docx sample.pdf --outdir /output公式图片转 Word 是另一个高频需求。我的做法是后端接一个 LaTeX OCR 服务比如 Mathpix 或自建的 LaTeX-OCR。用户上传公式图片后端识别出 LaTeX再用 Java 把 LaTeX 转成 OMMLOffice Math Markup Language片段写入 docx。如果用 POI 实现核心思路是找一个占位符段落比如写一个特定的书签或{{公式}}文本然后用替换节点的方式插入m:oMathXML这一步需要你对 OOXML 结构稍微有点概念。5.3 配合 POI 处理复杂文档表格宽度、固定布局这类“小事”在线编辑之外SpringBoot 项目里还经常有批量改 Word 的需求比如把所有合同的表格列宽调整一下、给文档加页码。这种场景再走 OnlyOffice 就重了直接用 Apache POI 更高效。POI 最坑的一点是设置表格列宽的单位。它用的是 twips1 厘米约等于 567 twips。只设置单元格宽tcW往往不生效因为 Word 的表格还有一个布局算法需要把表格布局锁定为 fixed 模式否则打开时会被自动调整列宽吃掉。我处理固定列宽的代码骨架大致如下XWPFTable table doc.getTables().get(0); CTTblPr tblPr table.getCTTbl().getTblPr(); tblPr.getTblLayout().setType(STTblLayoutType.FIXED); for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { cell.getCTTc().getTcPr().getTcW().setW(BigInteger.valueOf(2400)); } }2400 twips 大约是 4.2 厘米。设置前记得先看一下原表格的实际分割别把合并单元格硬拆了。实际操作中我通常会先解析原表格先看有几列每列原始宽度是多少再按比例分配目标总宽这样能避免“设置完表格变乱”的问题。6. 上线前必须面对的现实五个高频坑与我的处理方案6.1 文档 key 生成与缓存策略这一节本来该放在前面但因为它太重要我单独拎出来再讲透一点。key的生成规则直接影响协同和保存很多项目上线后出现“两个用户打开同一文档却各改各的”“保存时提示文件已损坏”八成都是 key 的问题。正确做法是key 由“文件内容版本”驱动而不是由“用户请求”驱动。我用的是MD5(bucket objectName version lastModifiedTime)并且把这份映射写到缓存里。回调接口收到 key 后同缓存比对能立刻判断这次保存是不是过期回调。key 不允许包含空格和特殊符号长度控制在 128 以内别把整个文件路径直接怼进去。6.2 内网部署时的回调地址问题这个坑几乎每个初次接入的人都会踩一次。你在本机联调时SpringBoot 跑在localhost:8080回调用http://localhost:8080/api/doc/callback/1看上去没问题但 OnlyOffice 服务器在 Docker 容器里它去请求localhost时访问的是它自己根本到不了你的 SpringBoot。解决办法也很简单分两种情况如果文档服务器和 SpringBoot 在同一台机器用内网 IP如果在不同机器用对方的真实内网 IP。如果两边都在 Docker Compose 里直接用 service 名称互相通信比如 SpringBoot 的容器名叫app-server回调用http://app-server:8080/api/doc/callback/1。6.3 并发编辑和定时保存的冲突OnlyOffice 默认会自动保存间隔通常是 10 分钟再加上用户手动关闭时也会触发保存这就导致同一文档短时间内可能收到多个 status2 的回调。如果不加锁文件版本就会被来回覆盖A 保存的是第 3 版B 后保存的是第 2 版结果第 3 版丢了。我现在的方案是双保险第一层是 Redis 分布式锁锁粒度到文档 id第二层是版本号校验在保存新版本前检查当前 callback 的 key 是否等于数据库里最新 key。只有一致才允许覆盖否则直接忽略这次回调只记录日志。6.4 免费版连接数限制与部署形态OnlyOffice Document Server 的开源版本有同时连接数限制我印象中是 20 个连接。不是说只能有 20 个用户而是同时打开编辑器的浏览器页面数达到 20 后就无法继续分配连接了。这在内部 OA 系统里通常够用但如果是面向 C 端用户的在线文档产品就必须评估商业授权或者做负载均衡。做负载均衡时要注意协同会话依赖文档服务器节点之间的一致性不能简单地把请求随机分发到不同节点。要嘛用 OnlyOffice 官方推荐的会话保持方案要嘛把同一份文档的 key 路由到固定节点。这块建议上线前压测一轮别等用户投诉了才想起来。6.5 HTTPS 混合内容与跨域配置如果你的 SpringBoot 系统已经上了 HTTPS而 OnlyOffice 还是裸 HTTP浏览器会直接拦截编辑器 iframe因为页面里混入了“非安全内容”。解决方式是在 Nginx 层把 OnlyOffice 也代理成 HTTPS并且最好和主系统同域通过/onlyoffice/路径反代避免跨域带来的 cookie 和 WebSocket 连接问题。Nginx 反代时还要注意放行 WebSocket 的 Upgrade 请求头否则协同光标同步会被卡住表现为“打开能打开但别人改的字自己看不到”。我个人落地这套方案时习惯按四个阶段推进先在 Docker 里把 OnlyOffice 跑通用官方示例页打开一个本地 docx再写 SpringBoot 配置接口前端接进来然后做回调保存和 MinIO 落盘最后才扩展转换服务和并发加固。这套顺序能让你每走一步都能立刻验证结果而不是把一堆未知问题搅在一起排查。最后再提醒一句所有配置里出现的 URL都必须是文档服务端能从内网访问到的地址这句话理解了能帮你避开后面至少一半的坑。
返回列表