
写这篇文章的起因是企业知识库系统需要支持 Word、Excel 这类文档在浏览器里直接编辑而不是下载、修改、上传的老三样。接手需求后我调研了 OnlyOffice、Google Docs 以及 Collabora Online 几条路线最后选定了“SpringBoot 业务系统 Collabora Online 编辑器”两边通过 WOPI 协议打通。Collabora Online 本质上是 LibreOffice 的网页版负责渲染和编辑文档而真正存放文件、做权限校验、维护版本号的还是我们自己的服务。整套集成做下来发现协议本身并不复杂真正的坑都藏在并发保存、URL 跳转和容器网络这些小细节里。本文按部署、接口、前端、排障四步走记录完整过程给想在自己项目里做在线编辑功能的同学一个可直接参考的路径。1. 为什么选择 Collabora Online 而不是自己造轮子1.1 三种主流在线编辑方案对比先说我选型时对比过的方案。大概可以分成三条路一是用 Google Docs API 这类 SaaS 服务二是部署 OnlyOffice Document Server三是用 Collabora Online也就是本文主角。对国内大多数企业系统来说文档数据留在自己手里往往是硬要求SaaS 这条路天然受限。OnlyOffice 和 Collabora 都能私有化部署功能上也都覆盖了 docx、xlsx、pptx 等常见格式但细节上有差异。方案部署成本协议编辑能力开源可用性Google Docs API低但有外网限制私有 API强SaaS 计费OnlyOffice Document Server中半封闭 OOXML 协议强开源版有连接数限制Collabora Online (CODE)中Docker 一键启动WOPI 公开标准强AGPL可自部署我最后选 Collabora核心原因是 WOPI 协议把“文件宿主”和“编辑器”彻底解耦了。作为宿主我只需要在 SpringBoot 里实现几个固定的 HTTP 接口编辑器端具体怎么渲染、怎么协同都交给 Collabora 处理。对我们这种后端团队来说不需要去啃复杂的编辑器源码也不用被某个厂商的私有协议绑死。OnlyOffice 本身也很好只是它的集成方式是 editor 配置对象加回调事件看起来简单但涉及文件锁、版本同步的时候反而不如 WOPI 这种标准化接口好排查。1.2 一次编辑请求的完整链路理解整套方案之前得先想清楚一个问题用户编辑文件的过程中请求到底是怎么流动的。我看过不少新手在这里绕晕所以直接用文字把链路走一遍。用户打开业务系统里的一个文档详情页点击“在线编辑”浏览器会加载一个嵌入 iframe 的编辑页面。iframe 的 src 指向 Collabora Online 的编辑器地址并且带着一个后端生成的 access_token。Collabora 收到这个请求后并不会直接去读我们的数据库而是拿着 token 回调 SpringBoot 暴露出的 WOPI 接口先调 CheckFileInfo 拿文件元数据比如文件名、大小、是否可写、当前版本再调 GetFile 把文件内容作为二进制流拉取过去。用户在编辑器里改完点保存Collabora 再通过 PutFile 把新内容传回来由 SpringBoot 落盘并更新版本号。这个模型最大的好处是文件的所有读写都经过我们自己的接口权限校验、操作记录、版本管理都可以在 SpringBoot 这一层控制住。Collabora 在没有 token 的情况下连文件名都拿不到。对于需要过等保或者有严格数据审计需求的企业系统这个隔离关系很关键。2. 部署 Collabora Online 与关键配置2.1 Docker 启动命令与参数说明Collabora 官方提供了 Docker 镜像 collabora/code本地调试时直接跑一条命令就能把编辑器拉起来。docker run -it -d --name collabora \ -p 9980:9980 \ -e usernamecool \ -e passwordadmin123 \ -e domainlocalhost,192.168.1.100 \ -e extra_params--o:ssl.enablefalse \ --restartalways \ collabora/code这条命令里的环境变量每一个都要理解清楚不然启动后各种 403。username / passwordCollabora 管理控制台的登录凭证。启动后访问 /browser/dist/admin/admin.html 可以看到在线用户数、连接状态这些信息。domain允许访问这个 Collabora 实例的域名或 IP 列表多个值用逗号分隔。编辑器页面在 localhost 打开就写 localhost局域网通过 192.168.1.100 访问就把这个 IP 加进去。漏掉这个配置的典型表现是Collabora 服务看着正常但 iframe 里一直报访问被拒绝。extra_params传给 coolwsd 进程的附加参数。本地调试没有证书时用 --o:ssl.enablefalse 把 HTTPS 关掉否则浏览器会拦截 iframe 里的请求。如果你的 Collabora 镜像版本比较新官方也支持直接挂载 coolwsd.xml 配置文件。但对大多数团队来说先用环境变量跑通后面需要精细调优时再切配置文件更稳妥。2.2 服务状态与网络连通性验证启动后先别急着写代码先用几个地址确认 Collabora 是活着的。发现服务http://localhost:9980/hosting/discovery返回 XML里面是编辑器支持的文件格式和 URL 模板。能力检查http://localhost:9980/hosting/capabilities返回 JSON能快速判断进程是否正常。管理后台http://localhost:9980/browser/dist/admin/admin.html用启动时的账号密码登录。我习惯先 curl 一下 capabilities再用浏览器打开 discovery确认 XML 里能看到 docx、xlsx 这些扩展名。如果前者返回不了 JSON多半是容器启动失败直接 docker logs collabora 翻堆栈。如果 discovery 里缺了某个格式先检查镜像版本再确认是不是部署时裁剪过格式支持。还有一个容易踩的坑Collabora 默认监听 9980 端口如果你本机 9980 被占用容器会启动失败。换端口的话后面所有回调 URL 里的端口号都要跟着改这点要记牢。3. 在 SpringBoot 里实现 WOPI 接口3.1 WOPI 宿主需要实现的三个核心端点WOPI 协议里我们的 SpringBoot 服务被称为 WOPI HostCollabora 被称为 WOPI Client。作为 Host最少要实现三个接口Collabora 才能完成文件的打开和保存。接口方法作用/wopi/files/{fileId}GETCheckFileInfo返回文件元数据 JSON/wopi/files/{fileId}/contentsGETGetFile返回文件二进制内容/wopi/files/{fileId}/contentsPOSTPutFile接收编辑后的文件内容CheckFileInfo 的返回值虽然名字里带“检查”两个字但它直接决定了编辑器能干什么。BaseFileName 会显示在编辑器的下载名称里UserCanWrite 决定是否允许编辑Version 和 LastModifiedTime 会影响并发编辑时的版本判断。这里面的字段都是 WOPI 规范里写死的命名用的是大驼峰风格不要自己改成 Java 习惯的小驼峰否则 Collabora 识别不到。3.2 access_token 的生成与校验Collabora 会把 access_token 当成一个黑盒不解析内容只在回调 WOPI 接口时原样带回来。所以这个 token 必须在 SpringBoot 自己生成、自己校验。我直接用 JWT 实现把文件 ID 和用户 ID 放进 claim有效期控制在 10 分钟。这样即使 token 在 URL 传递过程中被截获短时间也会失效降低泄露风险。核心代码如下Component public class WopiTokenService { private final SecretKey key Keys.hmacShaKeyFor( change-me-32-byte-secret-key!.getBytes(StandardCharsets.UTF_8)); public String createToken(String fileId, String userId) { return Jwts.builder() .claim(fid, fileId) .claim(uid, userId) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() 10 * 60 * 1000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseAndValidate(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }这里需要提醒一句示例的密钥是写死的生产环境一定要放到配置中心或环境变量里并且保证 HMAC 密钥至少 32 字节。另外JWT 的判空逻辑也要补上token 过期、签名错误、传了空值都要和“正常请求”区分开否则 Collabora 在编辑过程中 token 过期用户会看到莫名其妙的加载失败。3.3 CheckFileInfo / GetFile / PutFile 实现下面是我实现的 WopiController 简化版本。读写文件我用了一个 DocFileService 抽象你可以换成数据库 BLOB、MinIO、OSS 都行只要保证按 fileId 能拿到字节流和元数据即可。RestController RequestMapping(/wopi) public class WopiController { private final WopiTokenService tokenService; private final DocFileService fileService; private final ConcurrentHashMapString, String lockStore new ConcurrentHashMap(); public WopiController(WopiTokenService tokenService, DocFileService fileService) { this.tokenService tokenService; this.fileService fileService; } private boolean validToken(String token, String fileId) { try { Claims claims tokenService.parseAndValidate(token); return fileId.equals(claims.get(fid, String.class)); } catch (Exception e) { return false; } } GetMapping(/files/{fileId}) public ResponseEntityMapString, Object checkFileInfo( PathVariable String fileId, RequestParam(access_token) String token) { if (!validToken(token, fileId)) { return ResponseEntity.status(401).build(); } DocFile doc fileService.find(fileId); MapString, Object info new HashMap(); info.put(BaseFileName, doc.getFileName()); info.put(OwnerId, doc.getCreateUser()); info.put(Size, doc.getSize()); info.put(UserId, tokenService.parseAndValidate(token).get(uid)); info.put(UserCanWrite, true); info.put(UserCanNotWriteRelative, true); info.put(Version, String.valueOf(doc.getVersion())); info.put(LastModifiedTime, doc.getUpdateTime().toInstant().toString()); return ResponseEntity.ok(info); } GetMapping(/files/{fileId}/contents) public ResponseEntitybyte[] getFile( PathVariable String fileId, RequestParam(access_token) String token) { if (!validToken(token, fileId)) { return ResponseEntity.status(401).build(); } byte[] content fileService.readContent(fileId); return ResponseEntity.ok() .header(Content-Type, application/octet-stream) .body(content); } PostMapping(/files/{fileId}/contents) public ResponseEntityVoid putFile( PathVariable String fileId, RequestParam(access_token) String token, RequestHeader(value X-WOPI-Lock, required false) String lock, RequestBody byte[] content) { if (!validToken(token, fileId)) { return ResponseEntity.status(401).build(); } String currentLock lockStore.get(fileId); if (currentLock ! null !currentLock.equals(lock)) { return ResponseEntity.status(409) .header(X-WOPI-Lock, currentLock) .build(); } fileService.updateContent(fileId, content); return ResponseEntity.noContent().build(); } }几个注意点说一下。GetFile 返回的文件流Content-Type 用 application/octet-stream 就好不要自定义成 application/json一方面 Collabora 只认二进制流另一方面也避免莫名其妙的编码问题。PutFile 的返回状态码也有讲究。保存成功时返回 204 No Contenttoken 不合法返回 401锁冲突返回 409同时必须在响应头里带回 X-WOPI-Lock 当前锁值。这三个分支务必备全否则 Collabora 会根据异常的响应头判断“文件被外部修改”然后给你弹一个“文档已被修改”的警告框。3.4 文件锁与并发控制WOPI 的锁机制是这套系统里最容易被偷懒跳过的部分但恰恰又是并发场景下不能少的。编辑器在保存时会在请求头里带上 X-WOPI-Lock宿主SpringBoot需要判断这个锁和当前文件锁是否一致。不一致就返回 409 Conflict同时告诉对方当前持锁人是谁。我用一个 ConcurrentHashMap 作为锁存储虽然简单但配合 version 字段已经能覆盖大多业务场景。生产环境建议把锁信息入库因为内存 Map 在应用重启后锁会全部丢失用户正在编辑的文档会变成“无锁”状态一旦两个浏览器同时保存还是可能互相覆盖。还有一个容易被忽略的点CheckFileInfo 返回值里可以带上 Lock 字段当前锁值这样编辑器在打开文件时就能感知到文件是否正被人编辑。如果返回的 Lock 是空的Collabora 会认为文件当前无人编辑后续拿到锁的可能性更高。具体规范细节可以在 WOPI 文档里查到我这里只提醒一句锁和版本号是两个不同维度锁管“谁能改”版本管“改没改过”别混在一起设计。3.5 CORS 与回调来源限制Collabora 的页面和 SpringBoot 接口在开发环境下往往不是一个域名加端口浏览器跨域请求会被拦。所以要在 SpringBoot 里放开 WOPI 接口的跨域限制。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/wopi/**) .allowedOrigins(http://localhost:9980) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true); } }allowedOrigins 要写 Collabora 的实际地址。如果生产环境用 Nginx 把 Collabora 代理到了 https://office.example.com这里就换成那个域名。只放信得过的来源别用 *否则 CORS 等于没配。4. 前端拉起编辑器的完整过程4.1 解析 Discovery 获取 URL 模板Collabora 的 /hosting/discovery 接口返回一段 XML里面按文件扩展名和动作view/edit列出了对应的 URL 模板。模板里有两个占位符{fileId} 和 {access_token}。我强烈建议不要硬编码 /lool/v2/files/{fileId}/edit 这类路径而是先请求 discovery 动态拼装。Collabora 升级后如果改了 URL 格式你的业务系统不用跟着动。解析代码大致是这样Service public class DiscoveryService { private final RestTemplate restTemplate; private String editTemplate; public DiscoveryService(RestTemplate restTemplate) { this.restTemplate restTemplate; } PostConstruct public void load() throws Exception { String xml restTemplate.getForObject( http://localhost:9980/hosting/discovery, String.class); DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); Document doc factory.newDocumentBuilder() .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); XPath xPath XPathFactory.newInstance().newXPath(); editTemplate xPath.evaluate( //app[namewriter]/action[nameedit]/urlsrc, doc); } public String getEditTemplate() { return editTemplate; } }这里取的是 writer 应用的 edit 模板对应 Word 文档。xlsx 对应 calcpptx 对应 impress。实际项目里别在 PostConstruct 里每次启动就请求改成首次访问时延迟加载并缓存否则 Collabora 临时重启或者网络抖动你的应用也跟着启动失败。4.2 生成编辑 URL 并嵌入 iframeSpringBoot 侧提供一个生成编辑器地址的接口RestController RequestMapping(/api) public class EditorApiController { private final DiscoveryService discoveryService; private final WopiTokenService tokenService; GetMapping(/editor-url) public MapString, String editorUrl(RequestParam String fileId) { String token tokenService.createToken(fileId, user123); String editorUrl discoveryService.getEditTemplate() .replace({fileId}, fileId) .replace({access_token}, token); return Map.of(editorUrl, editorUrl); } }前端页面用 iframe 嵌进去代码很简单!DOCTYPE html html langzh-CN head meta charsetUTF-8 title在线文档编辑/title style body { margin: 0; } #editor { width: 100vw; height: 100vh; border: none; } /style /head body iframe ideditor allowfullscreen/iframe script const fileId new URLSearchParams(location.search).get(fileId); fetch(/api/editor-url?fileId${fileId}) .then(res res.json()) .then(data { document.getElementById(editor).src data.editorUrl; }); /script /body /html到这里一个基本可用的在线编辑流程就通了用户打开编辑页iframe 加载 Collabora编辑器通过 WOPI 拉取文件内容保存时写回业务系统。4.3 常用附加参数与只读模式除了把 token 拼进 URLCollabora 的编辑器地址还支持一些实用参数。语言在 URL 上加 langzh-CN可以让编辑器界面显示中文。主题themedark 或者通过发现服务返回的模板后缀配置能让编辑器适配你系统的明暗风格。只读在你只希望用户预览、不允许修改的场景后端生成的 token 可以不授予写权限或者在 CheckFileInfo 里把 UserCanWrite 置为 false。前端不要依赖 URL 参数去控制权限WOPI 的判断最终以 CheckFileInfo 返回为准。只读模式有个细节即使 UserCanWrite 是 falseCollabora 还是会尝试调用 GetFile所以 GetFile 接口不能因为“只读”就不实现。另外只读模式下 Collabora 也不会发送锁请求相应的 PutFile 不会调用锁逻辑可以不用管。5. 常见问题与排查技巧实录5. 1 Collabora 容器访问不到宿主机这是我第一次联调时踩的最深的坑。Collabora 跑在 Docker 容器里我在 WOPI Host 地址上写的 localhost结果容器内部的 localhost 是容器自己当然连不上宿主机。解决办法是让 Collabora 容器能通过网络访问到运行 SpringBoot 的宿主机。本机开发时把 WOPI 接口的基础地址换成 http://host.docker.internal:8080这是 Docker Desktop 提供的特殊域名指向宿主机。生产环境则必须用同一个内网域名或服务发现地址只要从 Collabora 容器里能 curl 通就行。排查方法很简单进容器执行docker exec -it collabora bash curl http://host.docker.internal:8080/wopi/files/test-id?access_tokenxxx如果返回 401 或者“token 无效”之类的 JSON说明网络已经通了问题在 token 或者 SpringBoot 接口内部。5.2 打开编辑器提示 403 或访问被拒绝浏览器能打开 Collabora 首页但一进入编辑就显示拒绝访问先检查两处。一是 Docker 启动时 domain 参数是否包含了当前业务系统的域名或 IP。我把编辑器页面放到 192.168.1.100 访问但 domain 只写了 localhostCollabora 直接拒绝了我的容器请求。这个参数不是配置一次就一劳永逸业务系统换域名后要记得同步更新。二是 access_token 是否过期。token 有效期我前面建议设 10 分钟如果设得太短比如 1 分钟用户页面稍微加载慢一点 token 就失效了。这个坑在低配服务器上尤其明显网络带宽差一点加载个编辑器框架都要几十秒token 早过期了。可以在 WopiTokenService 里把过期时间设长一点再配合服务端定期清理别做得太短。5.3 PutFile 一直失败编辑器无限转圈编辑器打开文档正常但用户点保存后一直转圈最后提示保存失败。这种问题先抓 SpringBoot 日志看 PutFile 到底有没有被调用。我遇到过三种情况请求根本没到 SpringBoot通常网络不通或者 URL 里的 token 是错的排查 Collabora 容器里 curl 后端接口。请求到了但返回 401看日志里 token 校验是不是通过了重点检查 JWT 的密钥是否一致。我曾经在本地配置两个不同密钥结果编辑正常、保存失败排查半天才发现是密钥没同步。请求到了也保存了但返回了 500通常是文件写入存储时出错。磁盘满、权限不足、对象存储连接超时这类问题要根据具体存储服务去排查。一个打印日志的小技巧在 PutFile 的入口处把 fileId、lock、content 长度打出来保存完成后再打一条完成日志。两行日志之间如果只有几百毫秒说明存储很顺畅如果每次都卡几秒甚至超时先优化存储层。5.4 并发保存与“文件版本已更改”提示最近搜索“OnlyOffice 在线编辑时文件版本已更改”的人特别多这个现象在 Collabora 里也常见。本质是宿主端没有正确维护文件版本号或者编辑器和宿主的版本判断不一致。处理思路其实一样每次收到 PutFile 保存成功后版本号加 1并同步刷新 LastModifiedTime。前端再次打开时编辑器发现版本变化就会重新加载最新内容。如果每次都提示版本冲突先去确认是不是 PutFile 本身没保存成功再看版本号字段有没有变化。另外多人同时编辑一个文件时WOPI 的锁机制会介入。放在菜单栏上的保存动作如果返回 409 且响应头里的锁值和当前持有锁不一致Collabora 会弹提示问用户“是否另存为”。这时候正确的业务逻辑是让用户另存为而不是强制覆盖原文件否则另一方的修改内容就丢了。5.5 生产环境必做的安全加固本地开发关掉 HTTPS 没问题生产环境必须把 Collabora 放到反向代理后面用正规证书提供 TLS。WOPI 的 access_token 在 URL 里传递如果走明文 HTTP等于把文件访问凭证直接暴露给链路中的任何人。还有几个容易忽略的点。限制 Collabora 只允许业务系统的域名嵌入用前面讲的 domain 参数控制。SpringBoot 的 WOPI 接口不要对公网完全开放最好加一层来源 IP 白名单只允许 Collabora 服务器的出口网段访问。access_token 的密钥定期轮换轮换时旧 token 要有一段容忍期避免正在编辑的用户突然保存失败。可以考虑每次 PutFile 落盘前先写一份临时版本保存成功后再替换正式文件这样即使文件写坏了也能回退。很多在线网盘系统都是这么做的。写到这里整套集成链路已经通了大半。我个人的体会是WOPI 这套协议本身不复杂真正花时间的是把锁、版本、并发这些边界情况想清楚。如果你做完基础功能想继续扩展可以试试给 PutFile 加历史版本备份、在 CheckFileInfo 里返回缩略图预览或者把 Collabora 接入自己的用户权限体系能玩的方向其实很多。最后一个小提示调试阶段把 Collabora 的日志级别调成 trace能让你在 WOPI 回调失败时省下至少半天排查时间。