诉求
实现页面实时在线文本协同编辑,且显示当前同时编辑文本的用户。
相关技术
Springboot(2.7.0)+Websocket+javascript
思路展开
- 页面展示当前登陆用户
- 页面有文本输入框(包含编辑、保存按钮)
- 页面展示编辑当前文本的用户
- 服务端广播处理文本信息的以及协同用户
相关步骤
pom配置
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.0</version> </parent> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>war</packaging> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>8</java.version> <java.encoding>UTF-8</java.encoding> <slf4j.version>1.7.30</slf4j.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- springboot集成websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency> <!-- 引入日志管理相关依赖--> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>${slf4j.version}</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> <version>${slf4j.version}</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-to-slf4j</artifactId> <version>2.14.0</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.69</version> </dependency> </dependencies> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.1</version> <configuration> <target>${java.version}</target> <source>${java.version}</source> <encoding>${java.encoding}</encoding> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.6</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-release-plugin</artifactId> <configuration> <arguments>-Prelease</arguments> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-source-plugin</artifactId> <version>2.1</version> <configuration> <attach>true</attach> </configuration> <executions> <execution> <phase>compile</phase> <goals> <goal>jar</goal> </goals> </execution> </executions> </plugin> </plugins> </pluginManagement> </build> </project>
服务端相关配置
编写WebSocketConfig和WebSocketHandler配置类,实现对WebSocket的配置。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.*; import org.springframework.web.socket.server.standard.ServerEndpointExporter; /** * @author * @date 2023年01月31日 14:21 */ @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { // @Override // public void configureMessageBroker(MessageBrokerRegistry registry) { // registry.enableSimpleBroker("/topic"); // registry.setApplicationDestinationPrefixes("/app"); // } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/doc-collaboration").withSockJS(); } @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.ArrayList; import java.util.List; /** * handler * @date2023年01月31日 14:22 */ @Component public class WebSocketHandler extends TextWebSocketHandler { private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketHandler.class); private static final List<WebSocketSession> sessions = new ArrayList<>(); @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { LOGGER.info("Received message: {}", message.getPayload()); for (WebSocketSession webSocketSession : sessions) { try { webSocketSession.sendMessage(message); } catch (IOException e) { LOGGER.error("Error: {}", e.getMessage()); } } } @Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); } }
文本信息、用户广播处理逻辑
定义 WebSocket 端点以处理来自客户端的传入消息。
/** * @author * @date 2023年01月31日 11:19 */ import com.alibaba.fastjson.JSON; import com.google.gson.Gson; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.*; @ServerEndpoint("/doc-collaboration") @Component @Slf4j public class DocWebSocketServer { private static Set<Session> sessions = new HashSet<>(); private static Set<String> editingUsers = new HashSet<>(); private static String content = ""; @OnOpen public void onOpen(Session session) { sessions.add(session); } @OnClose public void onClose(Session session) { sessions.remove(session); String username = (String) session.getUserProperties().get("username"); if (username != null) { editingUsers.remove(username); broadcastEditingUsers(); } } @OnMessage public void onMessage(String message, Session session) { Gson gson = new Gson(); Map<String, Object> data = gson.fromJson(message, Map.class); String type = (String) data.get("type"); log.info("Message type: {}, message data: {}", type, data); String jsonStr = ""; switch (type) { case "connect": String username = (String) data.get("username"); session.getUserProperties().put("username", username); jsonStr = JSON.toJSONString(new HashMap<String, Object>() {{ put("type", "update"); put("content", content); }}); broadcast(jsonStr); break; case "update": content = (String) data.get("content"); jsonStr = JSON.toJSONString(new HashMap<String, Object>() {{ put("type", "update"); put("content", content); }}); broadcast(jsonStr); break; case "start-editing": username = (String) session.getUserProperties().get("username"); editingUsers.add(username); broadcastEditingUsers(); break; case "stop-editing": username = (String) session.getUserProperties().get("username"); editingUsers.remove(username); broadcastEditingUsers(); break; case "getUser": broadcastEditingUsers(); break; } } /** * 广播当前文本信息 * @param message */ private void broadcast(String message) { log.info("message {}", message); for (Session session : sessions) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } } /** * 广播当前正在编辑文本的用户 */ private void broadcastEditingUsers() { broadcast( JSON.toJSONString( new HashMap<String, Object>() {{ put("type", "editing"); put("editingUsers", new ArrayList<>(editingUsers)); }})); } }
前端功能代码
创建一个 JavaScript 客户端,它与端点建立 WebSocket 连接并将更新发送到服务器。展示当前用户以及同时编辑文本的人员名称。
好久没写前端了,写起来有点费劲!😂
<head xmlns="http://www.w3.org/1999/html" xmlns="http://www.w3.org/1999/html"> <style> .editing-users { background-color: lightgray; padding: 10px; } </style> <meta charset="UTF-8"> </head> <body> <div> <textarea id="content" readonly></textarea> </br> <button id="edit-button">编辑</button> <button id="save-button">保存</button> </div> </br> <div>当前用户:</div><div id="user-name-label"> </div> </br> <div className="editing-users"> <p id="editing-users-label">同时编辑的用户:</p> <ul id="editing-users-list"></ul> </div> <script> const socket = new WebSocket("ws://localhost:8080/doc-collaboration"); const content = document.getElementById("content"); const editButton = document.getElementById("edit-button"); const saveButton = document.getElementById("save-button"); const editingUsersLabel = document.getElementById("editing-users-label"); const editingUsersList = document.getElementById("editing-users-list"); const currentEditUserName = document.getElementById("user-name-label"); socket.onopen = function () { const username = prompt("Enter your username"); //用户创建登陆了 socket.send( JSON.stringify({ type: "connect", username: username }) ); //显示当前用户 currentEditUserName.innerHTML=username; //获取当前文本同时编辑的用户 socket.send( JSON.stringify({ type: "getUser", }) ); }; socket.onmessage = function (event) { const data = JSON.parse(event.data); if (data === null || typeof data.type === "undefined") { console.log("data:"+ data) return; } switch (data.type) { case "update": content.value = data.content; break; case "editing": editingUsersList.innerHTML = ""; data.editingUsers.forEach(function (username) { const li = document.createElement("li"); li.textContent = username; editingUsersList.appendChild(li); }); //可以选择没人编辑的时候隐藏当前列表 // if (data.editingUsers.length === 0) { // editingUsersLabel.style.display = "none"; // } else { // editingUsersLabel.style.display = "block"; // } break; } }; editButton.addEventListener("click", function () { content.removeAttribute("readonly"); socket.send( JSON.stringify({ type: "start-editing" }) ); }); saveButton.addEventListener("click", function () { //点击保存后输入框变为只读 content.setAttribute("readonly", "true"); socket.send( JSON.stringify({ type: "stop-editing" }) ); }); content.addEventListener("input", function () { console.log("变动信息:" + content.value); socket.send( JSON.stringify({ type: "update", content: content.value }) ); }); </script> </body> </html>
功能测试
同时打开多个页面,当编辑信息时会显示到同时编辑的用户列表。
当前用户点击保存时推出当前同时编辑的用户列表
小结
上面实现为简易实现,仅供参考,可能并不适用一些业务场景。
下面的是我的一些想法,在真实生产应用在线文档协同编辑有多个点
实时协作编辑:多人同时在线编辑同一文档,显示协同编辑的人员,将信息更新为最新
历史版本控制:记录并保存文档的历史版本,当出现不可修复的错误可会退或者前进版本,以及用户的一些修改轨迹
讨论评论:在文档中添加评论和讨论功能,在一些文字或者图片附近可加以评论
权限管理:控制团队成员对文档的访问和编辑权限.