Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)

前言

        多人协同开发确实是比较难的知识点,在技术实现上有一定挑战,但随着各种技术库的发展,目前已经有了比较成熟的解决方案。今介绍 Yjs 基于CRDT算法,用于构建自动同步的协作应用程序,与Quill富文本编辑器,快速构建多人协同编辑器。

        前几章是介绍Quill+Yjs的基础,看项目示例的直接前往  整体样例实现 章节。实现的整体效果如下:

协同编辑数据模型

        想要实现协同开发,就要对数据模型进行约束,目前比较有代表性的协同数据模型为:

Delta 数据模型:

         Deltas数据模型的实现是Quill.js富文本编辑器,Deltas是一种简单而富有表现力的格式,可以用来描述Quill的内容和改变。这种格式本质上是JSON,是人类可读的,也很容易被机器解析。Deltas可以描述任何Quill文档,包括所有的文本和格式信息,其中没有HTML的歧义和复杂性。

{ops: [{ insert: 'Gandalf', attributes: { bold: true } },{ insert: ' the ' },{ insert: 'Grey', attributes: { color: '#cccccc' } }]
}

如上Deltas数据,我们解析下:

{ insert: 'Gandalf', attributes: { bold: true } }【插入 Gandalf,并加粗】,   

{ insert: ' the ' },【插入 the 】,

 { insert: 'Grey', attributes: { color: '#cccccc' } }【插入 Grey,并设置颜色 #ccc】,

因此实现的效果如下(在线版没有颜色我就不加了),他是对每一项要操作的字符串进行属性描述:

 Slate 数据模型:

        而Slate数据模型的实现是Slate.js,Slate.js 是一款支持完全自定义的富文本编辑器,它在可扩展性、可定制性、丰富的 API 和 React 集成方面有着出色的表现。

{type: 'insert_text',path: [0, 0],offset: 15,text: 'A new string of text to be inserted.',
}

         我就不解析上面的Slate数据模型了,也比较简单。Quill与Slate.js在底层实现上还是有很大差别的,如下,仅是一个简单的文本,两者渲染的DOM结构完全不同:

        Slate.js嵌套的DOM太多了,可能这样才能实现 支持完全自定义,更多定制化功能。但是我更倾向于Quill,因此本文采用Quill来实现。

协同编辑的问题所在

        协同编辑最大的问题就是如何保持数据一致性?

 这便是协同编辑需要解决的问题。

数据一致性算法

        OT算法与CRDT 算法应该算是目前比较好的协同算法了,具体的算法实现我也没有深入了解,如果大家有需要,后续会出文章讲解算法部分。

        大家可以看看这篇文章:文档多人协同编辑底层算法是如何实现的?我的开发也受到该作者的启发,写的很好,包括文档编辑锁等协同思想,大家可以去看看。

Yjs

         在官网的介绍中,Yjs是一个高性能CRDT,用于构建自动同步的协作应用程序。它将其内部CRDT模型公开为可以并发操作的共享数据类型。共享类型类似于常见的数据类型,如Map和Array。它们可以被操纵,在发生更改时触发事件,并在没有合并冲突的情况下自动合并。

        Yjs支持以下的富文本编辑器,可以看出其生态还是非常完善的。

         到此,还是希望大家明确概念哈,Yjs仅是处理协同数据一致性算法的具体实现,我们很容易与Quill的功能相混淆,认为是Yjs提供了所有的技术支持,并不是。Quill才是文本编辑、协同数据的生产者,而Yjs仅是保证了多人的Delta数据一致性!这个很重要的,要分清楚你的操作对象。

        我们还是先搭建Quill + Yjs 协同编辑吧,然后再跟大家介绍API。

搭建Quill+Yjs协同编辑器

下载 Quill、Yjs 依赖

// 下载 Quill
npm install quill@1.3.4// 下载Yjs
npm install yjs

初始化Quill编辑器 

<template><div id="edit"></div>
</template><script setup>
import Quill from "quill";
import "quill/dist/quill.snow.css"; // 使用了 snow 主题色import { onMounted } from "vue";onMounted(() => {// 获取dom需要在mounted后new Quill("#edit", {theme: "snow",});
});
</script><style lang="less" scoped></style>

 到这里,Quill编辑器已经配置好了。

初始化Yjs

        Yjs提供了三种连接形式,websocket rtc dat,我们稍后会介绍websocket形式,rtc是官网的样例,我们先直接用 。

npm i y-webrtc # or
npm i y-websocket # or
npm i y-dat

下载yjs与quill的连接器:

npm i y-quill

 //   初始化YJS// A Yjs document holds the shared dataconst ydoc = new Y.Doc();// Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)const ytext = ydoc.getText("quill");//   绑定 quill与YJSconst binding = new QuillBinding(ytext, quill);//   使用webrtc实现连接const provider = new WebrtcProvider("quill-demo-room", ydoc);

        ytext对象是用于表示文本的共享数据结构。它还支持格式化属性(即粗体和斜体)。Yjs会自动解决共享数据上的并发更改,因此我们不再需要担心冲突的解决。然后我们将ytext与quill编辑器同步,并使用QuillBinding使它们保持同步,几乎所有的编辑器绑定都是这样工作的。

        创建绑定后,直接利用rtc实现数据共享,就能实现协同编辑了:

封装类

        因为后续的操作都需要使用到quill及yjs对象,考虑封装为类实现:

// 导出Quill实体类import Quill from "quill";
import "quill/dist/quill.snow.css"; // 使用了 snow 主题色export class myQuill {constructor(selector) {// 初始化 quill 文档操作对象this.quill = new Quill(selector, {theme: "snow",placeholder: "请输入内容...",});}
}// 导出 Yjs 实体类
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { QuillBinding } from "y-quill";export class myYjs {// 需要传入绑定对象constructor(quill) {// A Yjs document holds the shared datathis.ydoc = new Y.Doc();// Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)const ytext = this.ydoc.getText("quill");//   绑定 quill与YJSconst binding = new QuillBinding(ytext, quill.quill);//   使用webrtc实现连接const provider = new WebrtcProvider("quill-demo-room", this.ydoc);}
}

 直接初始化即可,后续在拿的是对象进行操作:

onMounted(() => {// 获取dom需要在mounted后const quill = new myQuill("#edit");//   初始化YJSconst yjs = new myYjs(quill);
});

添加用户光标

 我们需要添加用户光标,区分编辑用户:

npm i quill-cursors

 绑定光标信息:

 

这样在协同开发时,就能出现用户光标了 ,同时,还支持修改光标用户信息:

// 完善代码 创建自己的光标信息
createAwareness(name) {let { awareness } = this.provider;// 定义随机颜色let color = "#" + Math.random().toString(16).split(".")[1].slice(0, 6);awareness.setLocalStateField("user", { name, color });return awareness;
}

Yjs Shared Types

        Yjs也有自己的数据类型,允许我们通过API进行操作,但是我还是上面所说,这不是Yjs的事情,文档的编辑、删除、更新,都应该是Quill富文本编辑器的事,因此我不会介绍Yjs的API,下面章节会介绍Quill的API。

yarray.insert(0, ['some content']) // => delta: [{ insert: ['some content'] }]

Quill Apis

        我们已经搭建了最简单最基础的协同开发编辑器,用到的Yjs仅是做数据绑定,冲突处理是Yjs内部自己实现的,我们不需要过多关注。下面需要介绍Quill的相关API,因为我们编辑的是Quill富文本编辑器,因此,熟悉Quill API是非常重要的。

        Quill支持多种方式格式化,包括UI控件和API调用,UI控件就是顶部的菜单栏,我们重点看API调用的方式:

Quill菜单栏配置

 Quill支持我们自定义菜单栏,传入什么就显示什么,支持下列属性:

 属性后面的简写,才是tabbar配置项:

 toolbar: [['background']], // 添加背景颜色

  

 有些图标已经不显示了,因此,我们可以使用 iconfont图标,自定义菜单栏,通过调用API实现相同功能。

向编辑器中插入文本 insertText

quill.insertText(0, 'Hello', 'bold', true);quill.insertText(5, 'Quill', {'color': '#ffff00','italic': true
});

如何向末尾追加文本呢?

获取文本编辑器长度 getLength

         检索返回编辑器的内容长度。注意:即使Quill是空的,编辑器仍然有一个‘\n’表示的空行,所以getLength将返回1。

var length = quill.getLength();
  var length = quill.getLength();// 向末尾追加quill.insertText(length, "quill.getLength()");

效果换行了,考虑下原因:即使Quill是空的,编辑器仍然有一个‘\n’表示的空行,因此,

 quill.insertText(0, "Hello", "bold", true);var length = quill.getLength();// 向末尾追加quill.insertText(length, "quill.getLength()");

就被解析为:【'\n'】+'hello' => 【‘hello’,'\n'】 => length=2 (向2 的位置添加文本),【‘hello’,'\n',‘getLength’】。就跟数组的索引跟下标的关系类似,因此,正确的做法是 length -1

 不再换行。

insertText实际使用中的问题

1. 仅支持插入字符串:

源码中,text是需要进行正则匹配去除特殊符号的,因此,不支持传入其他

2.  getLength 使用需谨慎:

// 测试变量[1, 2, 3, 4, 5].forEach((i) => {console.log(i);quill.insertText(length, i.toString());});

上述代码,理论上,应该插入 12345,但是,实际的效果是

原因是length是实时变化的,因此,动态获取长度能避免很多问题:

[1, 2, 3, 4, 5].forEach((i) => {console.log(i);quill.insertText(quill.getLength(), i.toString());});

 formatting 格式化API

quill.formatText(0, 5, 'bold', true);      // 加粗 'hello'
quill.formatText(0, 5, {                   // 取消加粗 'hello' 并且设置颜色为blue'bold': false,'color': 'rgb(0, 0, 255)'
});

api比较简单

用户选择

        quill.getSelection(focus = false);这个是比较重要的API,可以实现外部API的格式化操作,对用户选中的内容进行单独格式化,可以进行参数传递,控制是否聚焦输入框,不然点击输入框外,就不能选中了

撤销与重做

quill.history.undo();

quill.history.redo();

整体样例实现

        我们利用上面的知识,做一个完整的案例,来体验一下多人协同编辑吧。

登录页实现

        我们协同是基于用户体系的,同时协同用户光标也有用户,因此需要登录,才能加入编辑。

首页实现

 协同编辑页实现

接口开发

 

         需要初始化 express、ws、socket的服务(ws的服务我们用在Yjs的y-websocket服务上,后面细说),这次使用数据库实现持久化数据存储,webAPI采用SSM的三层分离架构,controller、serviceImpl、xmlMapper分离,在node中,还多了路由模块,因此,数据流向是 :

axios => node_router =>node_conrtoller => node_service => node_mapper => axios.then()

        有过SSM开发经验的一看就懂了,不懂的,可以琢磨一下,不然看不懂这个,看代码也比较难。详细的接口设计开发部分,我就不展开说了,这是后端的知识,如果大家感兴趣,可以单独出一篇文章,说说前后端的开发,让大家都能成为全栈开发!

初始化WS服务

        Yjs提供了三种连接模式嘛,ws是可以自己实现服务器,使用也更稳定,因此,使用node创建ws服务,供Yjs调用,实现双向即时通信: 

module.exports = () => {console.log("等待初始化 WS 服务...");// 搭建ws服务器const { WebSocketServer } = require("ws");const wss = new WebSocketServer({port: 9000,});console.log(" WS 服务初始化成功,连接地址:ws://localhost:9000");wss.on("connection", (ws, req) => {console.log("Yjs 客户端连接 ws 服务");// ws.send("我是服务端"); // 向当前客户端发送消息});
};

Yjs客户端调用:

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'const doc = new Y.Doc()
const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc)wsProvider.on('status', event => {console.log(event.status) // logs "connected" or "disconnected"
})

在这里使用监听的目的是根据用户连接状态,决定是否启用本地连接,实现更加稳定的协同编辑,到此,已经完成了所有的静态开发,接口也差不多了,我们来实现关键的协同编辑:

协同编辑

 我们不使用Quill 原生的tabbar,自定义了icon,通过调用API实现富文本编辑。

 撤销与重做:

我们实现的思想还是封装的公共类哈:

// MyQuill 类// 撤销undo() {this.quill.history.undo();}// 重做redo() {this.quill.history.redo();}

调用:

 // 撤销case "icon-chexiao":quill.undo();break;// 重做case "icon-zhongzuo":quill.redo();break;

格式化

 // 格式化format(opt, color) {// 将加粗\斜体\删除线\下划线\颜色等操作 封装一个函数,因此,就需要先获取样式,才能判断是否已经有样式// 还需要获取用户的选择,可能是给某些字符添加样式// 获取用户选择 ** 这里需要传递参数,不然会导致焦点移出编辑器,选中失效,这个 true 非常关键var range = this.quill.getSelection(true);if (!range) return console.warn("User cursor is not in editor");let { index, length } = range; // index 是当前光标的索引,length 表示当前选择的长度// 获取样式 检索给定范围内文本的所用格式(加粗 斜体都是块作用域,是需要指定长度的,因此,用户没有选择,则默认不作用,不像标题等,是行作用域)let { bold, italic, strike, underline } = this.quill.getFormat(index,length);// "icon-cuti": bold,// "icon-italic": italic,// "icon-strikethrough": strike,// "icon-zitixiahuaxian": underline,// "icon-zitiyanse": color,// 拿到用户操作的映射,判断有没有当前属性,没有则添加,有,则删除if (opt === "icon-cuti")this.quill.formatText(index, length, "bold", !bold);if (opt === "icon-italic")this.quill.formatText(index, length, "italic", !italic);if (opt === "icon-strikethrough")this.quill.formatText(index, length, "strike", !strike);if (opt === "icon-zitixiahuaxian")this.quill.formatText(index, length, "underline", !underline);if (opt === "color") this.quill.formatText(index, length, "color", color);}

实现图片上传

insertEmbed 向编辑器中插入嵌入式内容,返回一个改变后的Delta对象:

quill.insertEmbed(10, 'image', 'http://quilljs.com/images/cloud.png');

因此,我们需要一个图片的服务器地址,才能实现插入图片,下面来说说文件上传:

 前端文件上传无非是两种方式,一个base64 一个FormData(是二进制文件的载体),两种方式都可以在node中解析并保存文件:

// 文件上传
const upload = async (e) => {// 创建的本地浏览文件,无法实现 quill 中的url请求,需要借助服务器// let url = window.URL.createObjectURL(files[0]);// quill.insertEmbed(0, "image", url);let baseURL = "http://localhost:5000";let { files } = e.target;let form = new FormData();form.append("file", files[0]);let res = await editUploadFile_API(form);// 上传成功后,直接拿到地址,添加到编辑器中if (res.code !== 200) return ElMessage.error(res.msg);quill.insertEmbed(null, "image", baseURL + res.data);
};

使用 express-fileupload 中间件,中间件作用在该上传文件之前哈,可以快速解析文件,放在 req.files上,大家也可以使用Multer:

// 上传文件
exports.uploadFile = async (req, res, next) => {console.log(req.files);if (req.files === null)return res.status(400).json({ code: 400, msg: "no file uploaded" });// 不然转存数据let { file } = req.files;let newfilename = file.md5 + "." + file.name.split(".")[1];let newpath = path.join(process.cwd(), "/public/images/") + newfilename;// 移动文件到第一参数指定位置 若有错误 返回500file.mv(newpath, (err) => {if (err) return res.status(500).json({ msg: "文件上传失败" });return httpCode(res, 200, "文件上传成功", `/static/images/${newfilename}`);});
};

 实现效果:

实现文件共享

通过分享链接,实现接口数据传递,绑定文件进而实现文件共享:

 跳到页面后,是没有登录的状态,因此进行登录后,返回invited页面进行确认。 考虑 router的特性,将当前路由信息转存到login页面,才能在login页面直接跳转到确认邀请页面:

// 考虑是否登录const user = JSON.parse(sessionStorage.getItem("user"));if (to.path !== "/login") {if (!user) {ElMessage.error("请先登录");// 进行数据转存if (to.matched[0].path === "/invited/:fileid") {// 向 login 添加信息let { fileid } = to.params;return next({ path: "/login", query: { fileid, ...to.query } });}return next({ path: "/login" });}}

登录按钮:

if (router.currentRoute.value.query.fileid) {let { fileid, filename, username } = router.currentRoute.value.query;return router.push({path: `/invited/${fileid}`,query: { filename, username },});}router.push("/home");

页面开发:

 效果如下:

 

 实现粘贴板:

const execContent = (text) => {if (navigator.clipboard) {// clipboard api 复制navigator.clipboard.writeText(text);} else {var textarea = document.createElement("textarea");document.body.appendChild(textarea);// 隐藏此输入框textarea.style.position = "fixed";textarea.style.clip = "rect(0 0 0 0)";textarea.style.top = "10px";// 赋值textarea.value = text;// 选中textarea.select();// 复制document.execCommand("copy", true);// 移除输入框document.body.removeChild(textarea);}};

文件版本控制

        这里有一个注意事项:

/*** 版本控制说明*  1. 客户端一定是永远调用一个接口,因从需要处理是否处于创建状态,*  2. 根据files 表的 currenthead 当前指针 是否为空 判断是否是第一次创建*      createVersion 中可以直接 next 跳过创建过程*  3. 更新版本还需要控制时间*  4. 更新版本的同时,还需要更新文件表信息 currenthead 字段*/// 更新版本(有一定的时间周期,不然一个文件会有很多版本)
router.post("/updateVersion", versionCtrl.createVersion, fileCtrl.updateFiles);

         创建文件的时候,是没有初始化版本currenthead 字段的,因此,当我们保存的时候,需要先判断当前是否有版本,没有则正常创建;如果已经有了版本,则需要判断当前版本是否超过时限,不然保存一次创建一个版本是不合理的。

        客户端初始化quill的时候,需要延时判断当前编辑器是否有内容 ,不能直接覆盖,因为可能别的编辑者正在编辑,会导致内容覆盖,还涉及到Delta的数据转换:

// 初始化文本编辑器init(data) {// 处理数据(最大程度还原数据)let _T = data.replace(/[\r]/g, "#r#").replace(/[\n]/g, "#n#").replace(/[\t]/g, "#t#");let delta = JSON.parse(_T);/*** 需要先处理特殊字符,不然转不了JSON* 然后再根据特性,转回来,不然该换行的地方没有换行*/delta.forEach((i, index) => {i.insert = i.insert.toString().replace(/#n#/g, "\n").replace(/#r#/g, "\r").replace(/#t#/g, "\t");});this.quill.setContents(delta);}

这里有一个小问题哈:Emoji表情是不可以直接存再 UTF8的数据库中,需要做转换,不然报错

// 表情转码
export const utf16toEntities = (str) => {const patt = /[\ud800-\udbff][\udc00-\udfff]/g; // 检测utf16字符正则str = str.replace(patt, (char) => {let H;let L;let code;let s;if (char.length === 2) {H = char.charCodeAt(0); // 取出高位L = char.charCodeAt(1); // 取出低位code = (H - 0xd800) * 0x400 + 0x10000 + L - 0xdc00; // 转换算法s = `&#${code};`;} else {s = char;}return s;});return str;
};
// 表情解码
export const entitiestoUtf16 = (strObj) => {const patt = /&#\d+;/g;const arr = strObj.match(patt) || [];let H;let L;let code;for (let i = 0; i < arr.length; i += 1) {code = arr[i];code = code.replace("&#", "").replace(";", "");// 高位H = Math.floor((code - 0x10000) / 0x400) + 0xd800;// 低位L = ((code - 0x10000) % 0x400) + 0xdc00;code = `&#${code};`;const s = String.fromCharCode(H, L);strObj = strObj.replace(code, s);}return strObj;
};

 初始化 socket 服务

        socket服务这块我已经讲了很多次了,就不细说了,不过这次使用的是 room ,更贴合房间概念,只有同一个编辑文件中才能交流。可以细看代码。

io.on("connection", (socket) => {socket.join("room 237");console.log(socket.rooms); // Set { <socket.id>, "room 237" }socket.join(["room 237", "room 238"]);io.to("room 237").emit("a new user has joined the room"); // broadcast to everyone in the room
});

实现效果如下:

整体效果

可优化点 

文件导入、删除、回收站、文档搜索等,项目基本上已经是完整的项目了,vue+node+mysql,也有数据存储,大家可以继续创作。

总结

        从Yjs的应用到Quill编辑器的API介绍,算是比较完整的讲述了协同编辑的思想与实现方案,同时,拓展了MySQL的应用,这个项目还是比较不错的,大家可以 fork 继续创作,最后,大家多多支持呀,点赞收藏哦

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

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

相关文章

从零开始学习 Java:简单易懂的入门指南之Collection集合及list集合(二十一)

Collection集合及list集合 1.Collection集合1.1数组和集合的区别1.2集合类体系结构1.3Collection 集合概述和使用1.4Collection集合的遍历1.4.1 迭代器遍历1.4.2 增强for1.4.3 lambda表达式 2.List集合2.1List集合的概述和特点2.2List集合的特有方法2.3List集合的五种遍历方式2…

仓库运行状况如何得知?数据挖掘是关键!

库存、订单、出入库记录、物流信息、货物状态等数据&#xff0c;是仓库管理的重要组成部分。 仓库数据的重要性 做好仓库数据管理对企业的重要性不言而喻。通过有效地管理数据&#xff0c;企业可以更好地了解市场需求和库存情况&#xff0c;快速响应市场变化&#xff0c;提高库…

医院小程序如何在线搭建?实战解析

在当今数字化时代&#xff0c;移动应用程序成为我们生活中必不可少的一部分。特别是在医疗领域&#xff0c;移动应用程序的需求更为迫切。为了满足这一需求&#xff0c;开发一个医疗小程序成为了许多医疗机构的优先选择。 在本文中&#xff0c;我们将分享一个实战攻略&#xff…

iOS开发Swift-5-自动布局AutoLayout-摇骰子App

1.在iOS坐标系中&#xff0c;以向左、向下为正方向。图片以左上角为基准点。 2.打开之前的摇骰子App&#xff0c;对它的界面做一些适应所有iPhone机型的效果。 3.先对上方logo做一个y轴约束和一个宽高约束。 宽高约束&#xff1a; 水平居中&#xff1a; 对y轴进行约束。将虚线点…

WebDAV之π-Disk派盘 + 思源笔记

思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。 融合块、大纲和双向链接,重构你的思维。 π-Disk派盘 – 知识管理专家 派盘是一款面向个人和企业的本地云存储解决方案,它可以帮助用户打造“数字第二大脑”,并支持KMS知识管理系…

专访远航汽车远勤山:踏踏实实做好产品 直面挑战乘风远航

8月25日&#xff0c;第二十六届成都国际汽车展览会在中国西部国际博览城隆重开幕。车展举办期间&#xff0c;远航汽车董事长远勤山先生、产品研发总监王震先生向媒体分享了远航汽车品牌发展、产品研发、技术创新以及市场布局等内容。 “通过我们的付出和努力&#xff0c;让我们…

全网最新最全软件测试工程师面试题,看完金九银十还怕拿不到offer?

与开发工程师相比&#xff0c;软件测试工程师前期可能不会太深&#xff0c;但涉及面还是很广的。 在一年左右的实习生或岗位的早期面试中&#xff0c;主要是问一些基本的问题。 涉及到的知识主要包括MySQL数据库的使用、Linux操作系统的使用、软件测试框架问题、测试环境搭建问…

kafka 命令脚本说明以及在java中使用

一、命令行使用 1.1、topic 命令 1、关于topic,这里用window 来示例 bin\windows\kafka-topics.bat2、创建 first topic,五个分区&#xff0c;1个副本 bin\windows\kafka-topics.bat --bootstrap-server localhost:9092 --create --partitions 5 --replication-factor 1 -…

本地缓存、Redis数据缓存策略

目录 需求看似简单&#xff0c;一取一传但是&#xff0c;又出现了一个新的问题&#xff0c;数据丢了。 一、缓存缓存有哪些分类&#xff1a; 二、分析一下本地缓存的优势三、本地缓存解决方案&#xff1f;1、基于Guava Cache实现本地缓存2、基于Caffeine实现本地缓存3、基于Enc…

无涯教程-Flutter - Dart简介

Dart是一种开源通用编程语言&#xff0c;它最初是由Google开发的&#xff0c; Dart是一种具有C样式语法的面向对象的语言&#xff0c;它支持诸如接口&#xff0c;类之类的编程概念&#xff0c;与其他编程语言不同&#xff0c;Dart不支持数组&#xff0c; Dart集合可用于复制数据…

来和稚晖君做同事,校招内推、社招、实习

先上校招官网投递链接和内推码。 投递链接&#xff1a;http://www.agibot.com/ 我的内推码&#xff1a;MVKRCCW 智元机器人开始校招了&#xff0c;众多岗位如下&#xff1a; 公司正在不断发展壮大中&#xff0c;需要优秀的毕业生加入&#xff0c;一块来搞点有意思的事情。稚…

访问 GitHub 方法

访问 GitHub 方法 方法一&#xff1a;最常见的就是 fq&#xff0c;但这个是违法的行为&#xff0c;自己私下搞可以&#xff0c;不能教你们。 方法二&#xff1a;利用加速器&#xff0c;这是正规合法操作。这里推荐一个免费的加速器&#xff0c;下载安装 Watt Toolkit加速器,原名…

spark支持深度学习批量推理

背景 在数据量较大的业务场景中&#xff0c;spark在数据处理、传统机器学习训练、 深度学习相关业务&#xff0c;能取得较明显的效率提升。 本篇围绕spark大数据背景下的推理&#xff0c;介绍一些优雅的使用方式。 spark适用场景 大数据量自定义方法处理、类sql处理传统机器…

1+X智慧安防系统实施与运维技能等级证产教融合基地建设方案

一、系统概述 1X智慧安防系统实施与运维技能等级证产教融合体系统融合了产业需求、教育培训和技能认证&#xff0c;通过课程培训、实训基地和实习实训等方式培养学员的技能水平&#xff0c;并通过技能认证来评估其能力&#xff0c;以满足智慧安防行业对人才的需求&#xff0c;并…

迈向无限可能, ATEN宏正领跑设备切换行业革命!

随着互联网在各个领域的广泛应用,线上办公这一不受时间和地点制约、不受发展空间限制的办公模式开始广受追捧,预示着经济的发展正朝着新潮与活跃的方向不断跃进。当然,在互联网时代的背景下,多线程、多设备的线上办公模式也催生了许多问题:多设备间无法进行高速传输、切换;为保…

能直接运营的发接任务平台小程序搭建开发演示

有个项目估计做过互联网的小伙伴都听说过——发接任务平台。 基本每年都有发接任务平台关站&#xff0c;但又有新的平台出来&#xff0c;往复循环&#xff0c;无比热闹。这在互联网圈不常见&#xff0c;互联网项目很多都是风头过去了就结束了&#xff0c;但发接任务年年似乎都…

电商项目part10 高并发缓存实战

缓存的数据一致性 只要使用到缓存&#xff0c;无论是本地内存做缓存还是使用 redis 做缓存&#xff0c;那么就会存在数据同步的问题。 先读缓存数据&#xff0c;缓存数据有&#xff0c;则立即返回结果&#xff1b;如果没有数据&#xff0c;则从数据库读数据&#xff0c;并且把…

spring 错误百科

一、使用Spring出错根源 1、隐式规则的存在 你可能忽略了 Sping Boot 中 SpringBootApplication 是有一个默认的扫描包范围的。这就是一个隐私规则。如果你原本不知道&#xff0c;那么犯错概率还是很高的。类似的案例这里不再赘述。 2、默认配置不合理 3、追求奇技淫巧 4、…

iOS系统修复软件 Fix My iPhone for Mac

Fix My iPhone for Mac是一款iOS系统恢复工具。修复您的iPhone卡在Apple徽标&#xff0c;黑屏&#xff0c;冻结屏幕&#xff0c;iTunes更新/还原错误和超过20个iOS 12升级失败。这个macOS桌面应用程序提供快速&#xff0c;即时的解决方案来修复您的iOS系统问题&#xff0c;而不…

记录一下自己对linux分区挂载的理解

一直狠模糊&#xff0c;分两个区&#xff0c;一个挂载/, 一个挂载/home 两者是什么关系 实测 先看挂载的内容 然后umount /home后创建一个新文件 再挂载回去 发现旧分区又回来了&#xff0c;说明路径只是个抽象的概念&#xff0c;分区挂载&#xff0c;互相之间数据是不影响…