简介:这是一套面向本科/高职毕业设计的在线协同编辑系统毕设源码,基于微服务架构拆分后端业务,前端采用Vue,适合计算机相关专业学生用于课程设计、毕业设计或微服务实践学习。压缩包共253个文件,约2.9MB,涵盖82个Java后端服务源码、32个Vue前端页面、22个TypeScript与17个JavaScript交互逻辑,并搭配Dockerfile、YAML/JSON部署配置及SQL初始化脚本,便于按模块理解工程结构与本地启动。源码均经过本地编译验证,按文档配置好环境即可运行,项目难度适中,内容经过助教老师审定。目前已有195人学习下载,可作为快速上手微服务与前后端分离开发的完整参考。
1. 微服务架构的在线协同编辑系统:毕设源码解决了什么问题
你在文档中间选中一句话准备删掉,同事恰好在你选中的位置后方插入了一段新文字——最终结果是用你的删除覆盖他的插入,还是让他的插入保留?这不是网络延迟问题,而是协同编辑系统的并发冲突问题。这套基于微服务架构的在线协同编辑系统源码,把用户、文档、协作、通知拆成独立服务,最核心的难点落在 WebSocket 长连接和 OT 操作转换算法上。它适合三类人:正在选微服务方向毕设题目的同学、想从单体项目转向微服务开发的从业者、对协同编辑底层机制好奇但不想从零啃论文的读者。源码把最难的部分收在服务端,业务代码不绕,答辩时能拿出来的点却不少。
2. 架构与服务拆分:从用户登录到文档协同的消息链路
2.1 服务拆分:五个业务服务与一个网关的职责边界
这套系统的服务划分是典型的微服务课程设计思路:按业务域拆,不按技术层拆。网关单独成一个服务,负责路由和统一鉴权,业务服务之间不直接开放 HTTP 接口给对方调,所有跨服务请求都走网关。具体拆分如下:
| 服务 | 端口 | 职责 | 关键依赖 |
|---|---|---|---|
| gateway-service | 8080 | 路由转发、登录鉴权、WebSocket 握手入口 | Spring Cloud Gateway、JWT |
| user-service | 8101 | 用户注册、登录、用户信息维护 | MySQL、Redis |
| doc-service | 8102 | 文档 CRUD、文档快照存取、版本号管理 | MySQL、MinIO |
| collab-service | 8103 | WebSocket 长连接管理、OT 转换、消息广播 | Redis、MySQL |
| notify-service | 8104 | 协作者加入提醒、文档分享通知 | Redis 队列 |
注意 doc-service 和 collab-service 都操作文档数据,但侧重点不同:doc-service 管「文档长什么样」,collab-service 管「这次编辑怎么合入」。这样拆分的好处是协作服务可以独立水平扩展——如果线上有 500 个用户同时编辑,只需把 collab-service 扩容到多实例,doc-service 不必跟着动。代价是两份服务都访问 MySQL 同一条 doc 记录,所以在保存快照时必须有版本号机制兜底,这个放到第 4 章细说。
2.2 协同核心:OT 操作转换算法与 WebSocket 消息协议
选 OT 而不是 CRDT,是有意的取舍。CRDT(如 Yjs)更适合同步和离线优先场景,但引入现成库之后答辩时没有太多可展开的原理。OT 需要一个中心服务器做串行化和转换,正好和 collab-service 的定位匹配,而且算法部分可以自己实现并讲清楚。常见做法是只支持文本的 insert 和 delete 两种操作,不做富文本,这样 transform 逻辑能控制在一个类里。
// OperationTransform.java public class OperationTransform { // 服务端已按 revision 排序,opA 先到,opB 后到 public static Op transform(Op opA, Op opB) { if (opA.type == OpType.INSERT && opB.type == OpType.INSERT) { // 两个插入撞在同一位置:位置小的先落,后到的让位 if (opA.pos < opB.pos) { return opA; } // 位置相等时用 clientId 保证全序,避免随机结果 if (opA.clientId.compareTo(opB.clientId) < 0) { return opA; } return new Op(opA.type, opA.pos + opB.len(), opA.text, opA.clientId); } if (opA.type == OpType.INSERT && opB.type == OpType.DELETE) { // B 删在 A 插入位置之前,A 的位置需要左移 if (opB.pos <= opA.pos) { return new Op(opA.type, opA.pos - opB.len(), opA.text, opA.clientId); } return opA; } if (opA.type == OpType.DELETE && opB.type == OpType.INSERT) { // B 插入在 A 删除区间之前,A 的删除位置右移 if (opB.pos < opA.pos) { return new Op(opA.type, opA.pos + opB.len(), opA.text, opA.clientId); } return opA; } // delete 与 delete 的区间重叠判断较长,实际项目里放到单独方法 return opA; } }这段代码是 OT 里最核心的位置修正逻辑。insert/insert 冲突时让后到者向后挪 len 个位置,insert/delete 冲突时按删除点相对插入点的前后关系做位移,核心思想就是「把并发操作变换到同一时刻的文档视图上」。clientId是每个浏览器会话生成的唯一标识,用来解决位置完全相同时的次序问题,注意这不是登录用户 ID,一个用户可以同时开多个标签页。
服务端和客户端之间走 JSON 消息,格式定得越简单越不容易出错。实际项目里我会再加一个opId做幂等,防止重试造成重复插入,课堂演示做到下面这个粒度就够了:
{ "type": "op", "docId": "d_1001", "clientId": "c_8f2a3b", "revision": 12, "opType": "insert", "pos": 45, "text": "A" }| 字段 | 含义 | 说明 |
|---|---|---|
| type | 消息类型 | op / cursor / sync / ping 四类 |
| revision | 客户端基于的文档版本 | 服务端用来判断能否直接 apply |
| opType | 操作类型 | 只有 insert 和 delete 两种 |
| pos | 操作位置 | 基于当前 revision 的文档下标,从 0 开始 |
| text | 插入文本或删除长度 | delete 时 text 填删除的字符数 |
2.3 数据一致性与存储设计:Redis 管在线状态,MySQL 管文档快照
用户在线状态不适合放 MySQL,因为协作者列表是高频读写的。Redis 里用一个 set 存某个文档的在线成员,key 设计成doc:online:{docId},成员是 userId。光标位置用更短的 keydoc:cursor:{docId}:{userId},存一个整数下标,过期时间 60 秒,配合心跳续期。这样用户关掉页面最多 60 秒后自动从在线列表消失,不需要显式发离线消息。
文档正文存 MySQL,表结构里必须有一列revision记录版本号。每次收到合法操作,revision 就加一,保存快照时把最新文档内容整体写回。为什么不存操作日志再重放?因为重放几百条 op 既慢又难排查,快照加版本号的方式恢复成本最低,最多丢最后一次未落盘的修改。一次按键的完整链路是:浏览器 WebSocket 发送 op 到网关,网关转发给 collab-service,collab-service 做 OT 转换后把结果广播给协作者,同时异步调用 doc-service 的保存接口,doc-service 对 revision 做乐观锁更新 MySQL。这条链路第 3 章跑通之后,你会对微服务之间「同步调用做实时广播、异步调用做持久化」的分工有更直观的感觉。
3. 本地复现整套源码:环境版本对齐与双人协同编辑跑通
3.1 环境版本对齐:先把版本矩阵钉死,再谈启动
微服务毕设 90% 的启动失败不是代码问题,而是 Spring Cloud Alibaba、Spring Boot、Nacos 三者的版本不匹配。常见做法是直接照抄项目里pom.xml的依赖版本,不要自己升版本。我平时拆这类项目时,第一步永远是先画一张版本对照表:
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 17 | 配 Spring Boot 3.x 必须 17+ |
| Spring Boot | 3.2.x | 由 Spring Cloud Alibaba BOM 决定 |
| Spring Cloud Alibaba | 2022.0.0.0 | 适配 Spring Boot 3.2,不要用 2.x 老版 |
| Nacos Server | 2.2.3 | 2.x 配置格式与 1.x 不同 |
| MySQL | 8.0 | 字符集必须 utf8mb4 |
| Redis | 7.x | 小版本随意,Redis 6 以上均可 |
| Node.js | 18+ | 前端 Vue3 + Vite 构建,16 以下会报错 |
3.2 数据库与配置中心初始化:SQL 导入和 Nacos 配置
先起基础设施。MySQL 和 Redis 用 docker 拉起是最省时间的,注意给 MySQL 加--character-set-server=utf8mb4,否则中文存入后取出来是乱码。Nacos 用官方 bin 脚本单机模式启动即可。
# 启动 MySQL,指定字符集 docker run -d --name collab-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ mysql:8.0 \ --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci # 启动 Redis docker run -d --name collab-redis -p 6379:6379 redis:7-alpine # 启动 Nacos 单机模式,控制台地址 http://127.0.0.1:8848/nacos cd nacos/bin && sh startup.sh -m standalone数据库脚本在项目包的sql/目录下,核心是下面这两张表。doc_user 是文档与用户的关联表,控制谁能编辑这份文档:
CREATE DATABASE IF NOT EXISTS collab_doc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE collab_doc; CREATE TABLE doc ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_name VARCHAR(128) NOT NULL, content LONGTEXT NOT NULL, revision INT NOT NULL DEFAULT 0, owner_id BIGINT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_owner (owner_id) ) ENGINE=InnoDB; CREATE TABLE doc_user ( doc_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT '1 可编辑,2 只读', PRIMARY KEY (doc_id, user_id) ) ENGINE=InnoDB;revision是并发控制的锚点,第 4 章的保存冲突问题要靠它来解决。每个服务里都要有bootstrap.yml指向 Nacos,数据源配置统一放在 Nacos 的配置中心,不在本地写死,这是微服务配置和单体项目最直观的区别:
# collab-service/src/main/resources/bootstrap.yml spring: application: name: collab-doc-service cloud: nacos: config: server-addr: 127.0.0.1:8848 namespace: collab-dev file-extension: yaml discovery: server-addr: 127.0.0.1:8848namespace用来做环境隔离,dev、test、prod 各一个。如果你在 Nacos 控制台新建了命名空间,记得把生成的命名空间 ID 填进来,填名字是无效的。数据库连接、Redis 地址这些敏感配置都放到 Nacos 里维护,本地配置文件只留注册中心和配置中心地址。
3.3 后端服务启动顺序:先注册中心,再启动业务服务
启动顺序有讲究,但没网上说的那么玄。唯一硬性要求是 Nacos 先启动,业务服务后启动,服务之间不需要严格按顺序。因为 Spring Cloud 的负载均衡会在调用时动态发现服务,哪怕 doc-service 还没起来,collab-service 启动也不会失败,只是首次调用会报连接异常。稳妥起见,推荐按 user → doc → collab → notify → gateway 的顺序起:
# 以 doc-service 为例,项目根目录执行 mvn clean package -DskipTests java -jar doc-service/target/doc-service-1.0.0.jar # 验证 Nacos 服务列表是否已经有 4 个服务注册进来 curl -X GET "http://127.0.0.1:8848/nacos/v1/ns/catalog/services?namespaceId=collab-dev&pageNo=1&pageSize=10" # 或者直接在 Nacos 控制台"服务管理-服务列表"里看Nacos 控制台里能看到服务名、实例 IP 和端口,就说明注册成功了。常见的一个翻车点:服务启动成功但控制台里看不到,先检查 namespaceId 是否匹配,再看spring.application.name是否写错。如果用了 Maven 多模块,mvn clean package一定要在根目录执行,子模块单独打包会因为依赖不到 sibling 模块而失败。
3.4 前端 Vue 工程启动与联调
前端是标准的 Vue3 + Vite 工程,环境变量文件.env.development控制接口地址。网关地址是 8080,前端所有请求都走网关,不要直连 8103 端口,否则 Cookie 和鉴权头会乱掉:
// vite.config.js 项目根目录下创建 .env.development VITE_API_BASE=http://localhost:8080/api VITE_WS_BASE=ws://localhost:8080/wscd frontend npm install npm run dev这里的VITE_WS_BASE是 WebSocket 的入口,网关需要对/ws/**做转发。Vite 默认监听 5173 端口,登录时前端把账号密码 POST 到网关的/api/user/login,拿到的 JWT 存进 localStorage,之后每次建立 WebSocket 连接时把 token 放到 query 参数里传过去。
3.5 功能验收:两个浏览器窗口验证并发编辑不丢字
跑通之后先别急着改代码,按下面这套流程验收。注册两个账号,在 A 窗口创建文档,把文档 ID 发给 B 窗口,B 打开同一文档后,A 在开头输入 20 个字符,B 在末尾同时输入 20 个字符,最后检查正文——两端的内容合并结果应该等于 A 的内容加 B 的内容,且顺序符合操作先后。再把光标停在中间做交叉操作:A 选中中间一段准备删除,B 在删除区间末尾插入一行,释放删除后,B 插入的内容必须完整保留。
想验证并发压力下的 revision 变化,可以用 Node 脚本模拟两个 WebSocket 客户端同时发操作:
// stress-test.js const wsA = new WebSocket('ws://localhost:8080/ws/doc?docId=d_1001') const wsB = new WebSocket('ws://localhost:8080/ws/doc?docId=d_1001') let revision = 0 // 两个客户端交替发送 50 次 insert,位置随机 function sendOp(ws, text) { const pos = Math.floor(Math.random() * 20) ws.send(JSON.stringify({ type: 'op', docId: 'd_1001', clientId: 'test-a', revision, opType: 'insert', pos, text })) revision++ }脚本跑完后重点看服务端日志有没有revision mismatch的告警,如果出现,说明 OT 串行化逻辑需要修,具体排查方法见第 4 章的第三条避坑记录。
4. 协同编辑避坑排查:五个部署与并发场景的真实翻车点
4.1 Nacos 配置不生效:服务起了但连接的是本地默认配置
现象:服务能启动,但日志里数据源地址是127.0.0.1:3306而不是 Nacos 里配置的地址,甚至直接报Failed to configure a DataSource。
原因:Spring Boot 3.x 默认不再加载bootstrap.yml,需要显式引入spring-cloud-starter-bootstrap依赖,否则项目读取不到 Nacos 配置中心的数据源、Redis 等配置。
解决:在pom.xml中加入以下依赖再重新启动。注意这个依赖要加在每一个需要从 Nacos 拉取配置的服务模块里,漏一个就会翻一个。
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>也可以在application.yml里用spring.config.import: nacos:collab-dev.yaml替代,但改动面较大,还是加依赖最省事。从那以后我每次拆 Spring Cloud 项目,都会先看一眼依赖树里有没有 starter-bootstrap,这已经是肌肉记忆了。
4.2 WebSocket 握手被网关拦截:404 还是 403
现象:前端控制台里 WebSocket 连接一直pending,几秒后报Error during WebSocket handshake,后端日志看不到任何连接日志。
原因:Spring Cloud Gateway 默认对未知路径返回 404,WebSocket 升级请求(Upgrade: websocket)走到网关就被吞掉了,根本没到达 collab-service。另外网关开启了 JWT 鉴权过滤器,没有放行/ws/**路径。
解决:在网关配置里加一条路由,匹配/ws/**转发到 collab-service,并在鉴权过滤器白名单里加入该路径。下面是网关路由的关键配置:
spring: cloud: gateway: routes: - id: collab-websocket uri: lb://collab-doc-service predicates: - Path=/ws/** filters: - StripPrefix=1这里StripPrefix=1会把/ws前缀剥掉,collab-service 里的 WebSocket endpoint 就不用再写一层/ws路径。
4.3 并发编辑丢字:服务端处理顺序错了
现象:压力测试时偶尔丢字符,两个协作者各自看到的内容不一致,刷新后才恢复。
原因:collab-service 收到操作的顺序和客户端发出顺序不完全一致,服务端没有按revision做串行化就直接执行了 OT 转换。OT 的前提是操作基于同一文档版本依次到达,如果 clientId 为 A 的 revision 12 还没落库,B 的 revision 12 就进来了,转换结果必然错。
解决:collab-service 内部维护一个按 docId 路由的单线程执行器,同一篇文档的操作全部进同一个队列,消费时校验revision是否等于当前文档版本,不相等就丢弃并要求客户端重新拉取快照。注意 revision 从 0 开始,第一个操作是 revision 0,合法操作的 revision 必须严格递增,缺失即视为丢消息。
4.4 保存文档死锁:WebSocket 线程里做了同步写库
现象:两人同时编辑并触发保存时,偶发Lock wait timeout exceeded异常,文档服务接口耗时飙升。
原因:操作广播是实时的,全部跑在 WebSocket 的 Netty 线程里;如果直接在广播路径上同步调 doc-service 的保存接口,两个线程同时UPDATE doc SET content=?, revision=? WHERE id=?,InnoDB 行锁竞争激烈,且 Netty 线程被阻塞后整个服务的 WebSocket 心跳都断了。
解决:保存动作改成异步。collab-service 收到操作后先广播,把快照持久化消息丢进 Redis 队列,doc-service 监听队列做落库。更新语句必须带 revision 条件做乐观锁:
UPDATE doc SET content = ?, revision = revision + 1 WHERE id = ? AND revision = ?;受影响行数为 0 时说明版本已被其他协作者抢先更新,文档服务需要重新拉取最新快照再做合并,而不是盲目覆盖。
4.5 断线重连后本地状态过期:光标乱跳、内容回滚
现象:浏览器刷新页面后,文档内容短暂回滚到几分钟前的版本,光标位置错乱。
原因:WebSocket 断开重连时,前端没有重新拉取最新快照,而是拿本地旧内容继续渲染;服务端重新广播协作者的光标时,旧的revision和新快照对不上,OT 转换直接失配。
解决:前端断线重连后必须发sync消息,服务端收到后返回当前文档完整快照和最新 revision。前端以快照为基准重新渲染,再把离线期间缓存的未发送操作逐一转换后补发。这个机制是协同编辑不掉字的底裤,省略掉后续所有体验问题都会从这里冒头。重连逻辑可以简单封装为:
// 重连后先同步,再补发离线操作 ws.onopen = () => { ws.send(JSON.stringify({ type: 'sync', docId })) } // 收到快照后重置编辑器,再补发 pendingOps ws.onmessage = (e) => { const msg = JSON.parse(e.data) if (msg.type === 'snapshot') { editor.reset(msg.content, msg.revision) pendingOps.forEach(op => ws.send(op)) pendingOps = [] } }5. 进阶改造:把光标位置广播做成在线协作的仪式感
协同编辑做到不丢字只是及格,用户感知最明显的其实是远端光标——看到别人的光标在动,才有「一起写」的感觉。这个功能加在 collab-service 上非常顺手,不需要动 OT 核心。消息协议只需要再扩展一种cursor类型,载荷里带上 userId 和光标在文档中的下标;服务端收到后不落库,直接转发给同一文档的其他协作者,理论上毫秒级就能到另一端。
前端渲染远端光标的核心逻辑是光标跟随。用一个透明的<span>元素作为远端光标挂载点,样式做成和其他用户头像颜色呼应的竖条,元素跟随文本流走,天然具备插入和删除时的位置适应能力。每次收到 cursor 消息就重新定位一次:
// 远端光标渲染 function renderRemoteCursor(userId, pos, color) { let marker = cursorLayer[userId] if (!marker) { marker = document.createElement('span') marker.className = 'remote-cursor' marker.style.borderLeft = `2px solid ${color}` cursorLayer[userId] = marker } // 通过 Range 对象精确定位到 pos 下标 const range = document.createRange() range.setStart(editorTextNode, pos) range.collapse(true) range.insertNode(marker) }createRange的方式比 CSS 绝对定位可靠得多,它让光标成为文本流的一部分,协作者在你前面加了字,远端光标会自动往后挪,不需要额外换算坐标。注意服务端要做一层过滤,同一文档内 userId 相同的 cursor 消息直接丢弃,避免自己的光标被广播回来后绕一圈。
再配合 60 秒心跳机制:客户端每 20 秒发一次ping,服务端记录最后活动时间,Redis 里doc:cursor:{docId}:{userId}设置 60 秒过期。协作者列表靠这个 key 的扫描生成,用户关页面后最多 1 分钟自动消失,不需要处理浏览器关闭事件。这套光标广播加心跳的方案,思路和代码量都适合写进毕设的「系统创新点」章节。
可能有人会问:为什么不用现成的 y-websocket 或者 ShareDB?这些库确实几分钟就能接好,但对你理解这套系统没有帮助。自己实现一次 OT、自己广播一次光标,你才能真正说出「revision 不一致会发生什么」这种有深度的话。从那以后我每次拉下来一套微服务毕设源码,第一步一定不是看业务代码,而是先把服务拆分图、消息格式、版本对齐表钉在屏幕上,再逐个服务启动。这套在线协同编辑系统的坑,基本都集中在这三张表上,填平它们之后,剩下的代码顺着链路读就能读懂。希望帮到你。
本文还有配套的精品资源,点击获取