ABP VNext + CRDT 打造实时协同编辑

news2025/6/3 11:26:47

🛠️ ABP VNext + CRDT 打造实时协同编辑器 🎉


📚 目录

  • 🛠️ ABP VNext + CRDT 打造实时协同编辑器 🎉
    • 🧠 背景与挑战
    • 🔹 系统架构
    • 🛣️ 端到端流程 🚦
    • 🔒 安全与鉴权 🔐
    • ✨ 后端核心代码 🖥️
      • CollaborationHub
      • DocumentStateManager
    • 🔧 前端组件 (Yjs + y-signalr) 🖌️
    • ⚙️ 环境搭建与快速启动 🏃‍♂️
    • 🚀 性能测试与运维 📈


🧠 背景与挑战

  • 💥 多用户并发:无锁场景下自动合并冲突
  • 实时同步:毫秒级广播到所有客户端
  • 🔄 缓存与持久化:重启后秒级恢复,支持版本回溯

基于 Yjs (CRDT) + ABP VNext + SignalR,打造企业级 Markdown 协作系统。🎉


🔹 系统架构

🏗️ 后端 (ABP VNext)
🖥️ 客户端
delta📤
CollaborationHub
Redis (TTL=1h) 🗄️
PostgreSQL (快照=10s) 📝
BackgroundWorker ⏰
SignalR Hub
Monaco + Yjs + y-signalr
  • Redis 缓存key=doc:{docId}:state,TTL=1 小时
  • PostgreSQL:二进制 CRDT 状态历史,定时快照(建议 10 秒一次)
  • CRDT GC:定期合并状态、剪枝历史更新,避免状态膨胀

🛣️ 端到端流程 🚦

Client 🚀 Hub 🏢 Redis 🗄️ DB 📝 Client Hub Redis DB Send delta + JWT 🔑 cache.Set(doc:{docId}:state) metrics.Counter("hub_messages_total").Inc() broadcast ReceiveDelta 🔄 BackgroundWorker persist state ⏳ Client 🚀 Hub 🏢 Redis 🗄️ DB 📝 Client Hub Redis DB

🔒 安全与鉴权 🔐

  1. JWT 鉴权

    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
      .AddJwtBearer(options => {
        options.Authority = "https://your-auth-server";
        options.Audience  = "collab-api";
      });
    
    [Authorize]
    public class CollaborationHub : Hub {}
    
  2. Rate Limiting 🛑

    builder.Services.AddRateLimiter(opts =>
      opts.AddFixedWindowLimiter("hubLimiter", o => {
        o.PermitLimit = 100;
        o.Window = TimeSpan.FromSeconds(1);
      }));
    app.UseRateLimiter();
    
  3. 熔断与重试 ♻️

    Policy.Handle<Exception>()
          .CircuitBreaker(5, TimeSpan.FromSeconds(30))
          .WrapAsync(Policy.Handle<Exception>().RetryAsync(3));
    

✨ 后端核心代码 🖥️

CollaborationHub

[Authorize]
public class CollaborationHub : Hub
{
    private readonly ILogger<CollaborationHub> _logger;
    private readonly Counter _msgCounter = Metrics.CreateCounter(
        "hub_messages_total", "Total messages processed by Hub");

    public CollaborationHub(ILogger<CollaborationHub> logger) => _logger = logger;

    public override async Task OnConnectedAsync()
    {
        var docId = Context.GetHttpContext()?.Request.Query["docId"].ToString();
        if (string.IsNullOrWhiteSpace(docId))
            throw new HubException("docId required");
        await Groups.AddToGroupAsync(Context.ConnectionId, docId);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? ex)
    {
        var docId = Context.GetHttpContext()?.Request.Query["docId"].ToString();
        if (!string.IsNullOrWhiteSpace(docId))
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, docId);
        await base.OnDisconnectedAsync(ex);
    }

    public async Task SyncOperation(string docId, byte[] delta)
    {
        if (delta.Length > 1_000_000)
            throw new HubException("Delta too large");
        _msgCounter.Inc();
        try
        {
            await Clients.OthersInGroup(docId).SendAsync("ReceiveDelta", delta);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Sync failed for {DocId}", docId);
            throw;
        }
    }
}

DocumentStateManager

public class DocumentStateManager : ITransientDependency
{
    private readonly IDistributedCache _cache;
    private readonly IRepository<DocumentSnapshot, Guid> _repo;
    private readonly Histogram _persistHist = Metrics.CreateHistogram(
        "persist_duration_seconds", "DB persist duration");

    public DocumentStateManager(IDistributedCache cache,
        IRepository<DocumentSnapshot, Guid> repo)
    {
        _cache = cache;
        _repo  = repo;
    }

    public async Task SaveAsync(string docId, byte[] crdtState)
    {
        await _cache.SetAsync(
            $"doc:{docId}:state", crdtState,
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) });
        using var _ = _persistHist.NewTimer();
        await _repo.InsertAsync(new DocumentSnapshot {
            Id        = Guid.NewGuid(),
            DocId     = docId,
            CrdtState = crdtState,
            Timestamp = Clock.Now
        }, autoSave: true);
    }

    public async Task<byte[]?> LoadAsync(string docId)
    {
        var cache = await _cache.GetAsync($"doc:{docId}:state");
        if (cache is not null) return cache;
        var latest = await _repo
          .Where(x => x.DocId == docId)
          .OrderByDescending(x => x.Timestamp)
          .FirstOrDefaultAsync();
        return latest?.CrdtState;
    }
}

🔧 前端组件 (Yjs + y-signalr) 🖌️

import * as Y from 'yjs';
import { MonacoBinding } from 'y-monaco';
import { SignalrProvider } from 'y-signalr';

const ydoc     = new Y.Doc();
const provider = new SignalrProvider(
  "wss://your-domain/hub?docId=mydoc",
  "markdown-room", ydoc);
const yText    = ydoc.getText("monaco");

const editor = monaco.editor.create(
  document.getElementById("editor"), { language: "markdown" });

new MonacoBinding(yText, editor.getModel(),
  new Set([editor]), provider.awareness);

⚙️ 环境搭建与快速启动 🏃‍♂️

# 指定版本
dotnet add package Volo.Abp.SignalR --version 7.4.0
dotnet add package Yjs.SignalR --version 1.2.3
npm install yjs@13.5.25 y-signalr@1.0.0 y-monaco@0.3.0 monaco-editor@0.45.0
title: docker-compose.yml
version: "3.8"
services:
  redis:
    image: redis:7.0
    ports: ["6379:6379"]
  postgres:
    image: postgres:15.2
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: mypwd
    ports: ["5432:5432"]
  app:
    build: .
    environment:
      ConnectionStrings__Default: "Host=postgres;User=postgres;Password=mypwd;Database=collab"
    depends_on: ["redis","postgres"]
    ports: ["5000:80"]

启动后:

docker-compose up -d
dotnet run --project src/Collab.Api
npm run dev --prefix src/Collab.Web

🚀 性能测试与运维 📈

  • Artillery 压测:并发 500 用户测试 SyncOperation
  • Prometheus & Grafana
    • hub_messages_total 🕹️
    • persist_duration_seconds ⏱️
    • redis_cache_hit_ratio 🔍
  • CRDT GC:使用 ydoc.gc() 定期回收冗余状态
  • 监控告警:缓存命中率 <80% 或持久化延迟 >500ms 时触发

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2394788.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

[春秋云镜] CVE-2023-23752 writeup

首先奉上大佬的wp表示尊敬&#xff1a;&#xff08;很详细&#xff09;[ 漏洞复现篇 ] Joomla未授权访问Rest API漏洞(CVE-2023-23752)_joomla未授权访问漏洞(cve-2023-23752)-CSDN博客 知识点 Joomla版本为4.0.0 到 4.2.7 存在未授权访问漏洞 Joomla是一套全球知名的内容管理…

CSS专题之水平垂直居中

前言 石匠敲击石头的第 16 次 在日常开发中&#xff0c;经常会遇到水平垂直居中的布局&#xff0c;虽然现在基本上都用 Flex 可以轻松实现&#xff0c;但是在某些无法使用 Flex 的情况下&#xff0c;又应该如何让元素水平垂直居中呢&#xff1f;这也是一道面试的必考题&#xf…

(新)MQ高级-MQ的可靠性

消息到达MQ以后&#xff0c;如果MQ不能及时保存&#xff0c;也会导致消息丢失&#xff0c;所以MQ的可靠性也非常重要。 一、数据持久化 为了提升性能&#xff0c;默认情况下MQ的数据都是在内存存储的临时数据&#xff0c;重启后就会消失。为了保证数据的可靠性&#xff0c;必须…

Leetcode 3231. 要删除的递增子序列的最小数量

1.题目基本信息 1.1.题目描述 给定一个整数数组 nums&#xff0c;你可以执行任意次下面的操作&#xff1a; 从数组删除一个 严格递增 的 子序列。 您的任务是找到使数组为 空 所需的 最小 操作数。 1.2.题目地址 https://leetcode.cn/problems/minimum-number-of-increas…

4.2.5 Spark SQL 分区自动推断

在本节实战中&#xff0c;我们学习了Spark SQL的分区自动推断功能&#xff0c;这是一种提升查询性能的有效手段。通过创建具有不同分区的目录结构&#xff0c;并在这些目录中放置JSON文件&#xff0c;我们模拟了一个分区表的环境。使用Spark SQL读取这些数据时&#xff0c;Spar…

【图像处理入门】2. Python中OpenCV与Matplotlib的图像操作指南

一、环境准备 import cv2 import numpy as np import matplotlib.pyplot as plt# 配置中文字体显示&#xff08;可选&#xff09; plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False二、图像的基本操作 1. 图像读取、显示与保存 使用OpenCV…

Spring Boot微服务架构(九):设计哲学是什么?

一、Spring Boot设计哲学是什么&#xff1f; Spring Boot 的设计哲学可以概括为 ​​“约定优于配置”​​ 和 ​​“开箱即用”​​&#xff0c;其核心目标是​​极大地简化基于 Spring 框架的生产级应用的初始搭建和开发过程​​&#xff0c;让开发者能够快速启动并运行项目…

TC/BC/OC P2P/E2E有啥区别?-PTP协议基础概念介绍

前言 时间同步网络中的每个节点&#xff0c;都被称为时钟&#xff0c;PTP协议定义了三种基本时钟节点。本文将介绍这三种类型的时钟&#xff0c;以及gPTP在同步机制上与其他机制的区别 本系列文章将由浅入深的带你了解gPTP&#xff0c;欢迎关注 时钟类型 在PTP中我们将各节…

Kafka数据怎么保障不丢失

在分布式消息系统中&#xff0c;数据不丢失是核心可靠性需求之一。Apache Kafka 通过生产者配置、副本机制、持久化策略、消费者偏移量管理等多层机制保障数据可靠性。以下从不同维度解析 Kafka 数据不丢失的核心策略&#xff0c;并附示意图辅助理解。 一、生产者端&#xff1a…

AI书签管理工具开发全记录(八):Ai创建书签功能实现

文章目录 AI书签管理工具开发全记录&#xff08;八&#xff09;&#xff1a;AI智能创建书签功能深度解析前言 &#x1f4dd;1. AI功能设计思路 &#x1f9e0;1.1 传统书签创建的痛点1.2 AI解决方案设计 2. 后端API实现 ⚙️2.1 新增url相关工具方法2.1 创建后端api2.2 创建crea…

X-plore v4.43.05 强大的安卓文件管理器-MOD解锁高级版 手机平板/电视TV通用

X-plore v4.43.05 强大的安卓文件管理器-MOD解锁高级版 手机平板/电视TV通用 应用简介&#xff1a; X-plore 是一款强大的安卓端文件管理器&#xff0c;它可以在电视或者手机上管理大量媒体文件、应用程序。…

使用多Agent进行海报生成的技术方案及评估套件-P2P、paper2poster

最近字节、滑铁卢大学相关团队同时放出了他们使用Agent进行海报生成的技术方案&#xff0c;P2P和Paper2Poster&#xff0c;传统方案如类似ppt生成等思路&#xff0c;基本上采用固定的模版&#xff0c;提取相关的关键元素进行模版填充&#xff0c;因此&#xff0c;海报生成的质量…

Redis--缓存工具封装

经过前面的学习&#xff0c;发现缓存中的问题&#xff0c;无论是缓存穿透&#xff0c;缓存雪崩&#xff0c;还是缓存击穿&#xff0c;这些问题的解决方案业务代码逻辑都很复杂&#xff0c;我们也不应该每次都来重写这些逻辑&#xff0c;我们可以将其封装成工具。而在封装的时候…

python:在 PyMOL 中如何查看和使用内置示例文件?

参阅&#xff1a;开源版PyMol安装保姆级教程 百度网盘下载 提取码&#xff1a;csub pip show pymol 简介: PyMOL是一个Python增强的分子图形工具。它擅长蛋白质、小分子、密度、表面和轨迹的3D可视化。它还包括分子编辑、射线追踪和动画。 可视化示例‌&#xff1a;打开 PyM…

SpringCloud——Docker

1.命令解读 docker run -d 解释&#xff1a;创建并运行一个容器&#xff0c;-d则是让容器以后台进程运行 --name mysql 解释&#xff1a; 给容器起个名字叫mysql -p 3306:3306 解释&#xff1a;-p 宿主机端口:容器内端口&#xff0c;设置端口映射 注意&#xff1a; 1、…

机器学习:欠拟合、过拟合、正则化

本文目录&#xff1a; 一、欠拟合二、过拟合三、拟合问题原因及解决办法四、正则化&#xff1a;尽量减少高次幂特征的影响&#xff08;一&#xff09;L1正则化&#xff08;二&#xff09;L2正则化&#xff08;三&#xff09;L1正则化与L2正则化的对比 五、正好拟合代码&#xf…

运用集合知识做斗地主案例

方法中可变参数 一种特殊形参&#xff0c;定义在方法&#xff0c;构造器的形参列表里&#xff0c;格式&#xff1a;数据类型...参数名称&#xff1b; 可变参数的特点和好处 特点&#xff1a;可以不传数据给它&#xff1b;可以传一个或者同时传多个数据给它&#xff1b;也可以…

《HelloGitHub》第 110 期

兴趣是最好的老师&#xff0c;HelloGitHub 让你对开源感兴趣&#xff01; 简介 HelloGitHub 分享 GitHub 上有趣、入门级的开源项目。 github.com/521xueweihan/HelloGitHub 这里有实战项目、入门教程、黑科技、开源书籍、大厂开源项目等&#xff0c;涵盖多种编程语言 Python、…

使用 Shell 脚本实现 Spring Boot 项目自动化部署到 Docker(Ubuntu 服务器)

使用 Shell 脚本实现 Spring Boot 项目自动化部署到 Docker&#xff08;Ubuntu 服务器&#xff09; 在日常项目开发中&#xff0c;我们经常会将 Spring Boot 项目打包并部署到服务器上的 Docker 环境中。为了提升效率、减少重复操作&#xff0c;我们可以通过 Shell 脚本实现自动…

day023-网络基础与OSI七层模型

文章目录 1. 网络基础知识点1.1 网络中的单位1.2 查看实时网速&#xff1a;iftop1.3 交换机、路由器 2. 路由表2.1 查看路由表的命令2.2 路由追踪命令 3. 通用网站网络架构4. 局域网上网原理-NAT5. 虚拟机上网原理6. 虚拟机的网络模式6.1 NAT模式6.2 桥接模式6.3 仅主机模式 7.…