Canvas图形编辑器-数据结构与History(undo/redo)
Canvas图形编辑器-数据结构与History(undo/redo)
这是作为 社区老给我推Canvas,于是我也学习Canvas做了个简历编辑器 的后续内容,主要是介绍了对数据结构的设计以及History
能力的实现。
- 在线编辑: https://windrunnermax.github.io/CanvasEditor
- 开源地址: https://github.com/WindrunnerMax/CanvasEditor
关于Canvas
简历编辑器项目的相关文章:
- 社区老给我推Canvas,我也学习Canvas做了个简历编辑器
- Canvas图形编辑器-数据结构与History(undo/redo)
- Canvas图形编辑器-我的剪贴板里究竟有什么数据
- Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
- Canvas简历编辑器-Monorepo+Rspack工程实践
描述
对于编辑器而言,History
也就是undo
和redo
是必不可少的能力,实现历史记录的方法通常有两种:
-
存储全量快照,也就是说我我们每进行一个操作,都需要将全量的数据通常也就是
JSON
格式的数据存到一个数组里,如果用户此时触发了redo
就将全量的数据取出应用到Editor
对象当中。这种实现方式的优点是简单,不需要过多的设计,缺点就是一旦操作的多了就容易炸内存。 -
基于
Op
的实现,Op
就是对于一个操作的原子化记录,举个例子如果将图形A
向右移动3px
,那么这个Op
就可以是type: "MOVE", offset: [3, 0]
,那么如果想要做回退操作依然很简单,只需要将其反向操作即type: "MOVE", offset: [-3, 0]
就可以了,这种方式的优点是粒度更细,存储压力小,缺点是需要复杂的设计以及计算。
既然我们是从零开始设计一个编辑器,那么大概率是不会采用方案1
的,我们更希望能够设计原子化的Op
来实现History
,所以从这个方向开始我们就需要先设计数据结构。
数据结构
我特别推荐大家去看一下 quill-delta 的数据结构设计,这个数据结构的设计非常棒,其可以用来描述一篇富文本,同时也可以用来构建change
对富文本做完整的增删改操作,对于数据的compose
、invert
、diff
等操作也一应俱全,而且quill-delta
也可以是富文本OT
协同算法的实现,这其中的设计还是非常牛逼的。
其实我之前也没有设计过数据结构,更不用谈设计Op
去实现历史记录功能了,所以我在设计数据结构的时候是抓耳挠腮、寝食难安,想设计出 quill-delta
这种级别的数据描述几乎是不可能了,所以只能依照我的想法来简单地设计,这其中有很多不完善的地方后边可能还会有所改动。
因为之前也没有接触过Canvas
,所以我的主要目标是学习,所以我希望任何的实现都以尽可能简单的方向走。那么在这里我认为任何元素都是矩形,因为绘制矩阵是比较简单的,所以图形元素基类的x, y, width, height
属性是确定的,再加上还有层级结构,那么就再加一个z
,此外由于需要标识图形,所以还需要给其设置一个id
。
class Delta {public readonly id: string;protected x: number;protected y: number;protected z: number;protected width: number;protected height: number;
}
因为我想做一个插件化的实现,也就是说所有的图形都应该继承这个类,那么这个自定义的函数体肯定是需要存储自己的数据,所以在这里加一个attrs
属性,又因为想简单实现整个功能,所以这个数据类型就被定义为Record<string, string>
。因为是插件化的,每个图形的绘制应该由子类来实现,所以需要定义绘制函数的抽象方法,于是一个数据结构就这么设计好了,关于插件化的设计我们后续可以再继续聊。
abstract class Delta {public readonly id: string;protected x: number;protected y: number;protected z: number;protected width: number;protected height: number;public attrs: DeltaAttributes;public abstract drawing: (ctx: CanvasRenderingContext2D) => void;
}
那么现在已经有了基本的数据结构,我们可以设想一下究竟应该有哪几种操作,经过考虑大概无非是 插入INSERT
、删除DELETE
、移动MOVE
、调整大小RESIZE
、修改属性REVISE
,这五个Op
就可以覆盖我们对于当前编辑器图形的所有操作了,所以我们后续的设计都要围绕着这五个操作来进行。
看起来其实并不难,但实际上想要将其设计好并不容易,因为我们目标是History
所以我们不光要顾及正向的操作,还需要设计好invert
也就是反向操作,依旧以之前的MOVE
操作举例,我们移动一个元素可以使用MOVE(3, 0)
,反向操作就可以直接生成也就是MOVE(3, 0).invert = MOVE(-3, 0)
,那么RESIZE
操作呢,尤其是在多选操作时的RESIZE
,我们需要想办法让其能够实现invert
操作,一种方法是记录每个点的移动距离,但是这样对于每个Op
存储的信息有点过多,我们在构造一个正向的Op
时也需要将相关的数据拉到Op
中,同样对于REVISE
而言我们需要将属性的前值和后值都放在Op
中才可以继续执行。
那么如何比较好的解决这个问题呢,很明显如果我们想用轻量的数据来承载内容,那么先前的数据在不一定会使用的情况下我们是没必要存储的,那是不是可以自动提取相关的内容作为invert-op
呢,当然是可以的,我们可以在进行invert
的时候,将未操作前的Delta
一并作为参数传入就好了,我们可以来验证一下,我们的函数签名将会是Op.invert(Delta) = Op'
。
// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2})
// Next DeltaSet
[{id: "xxx", x: x1 + x2, y: y1 + y2, width: w1, height: w1}]
// Invert InsertOp
RESIZE({id: "xxx", x: -x2, y: -y2})// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2, width: w2, height: h2})
// Next DeltaSet
[{id: "xxx", x: x2, y: y2, width: w2, height: h2}]
// Invert InsertOp
RESIZE({id: "xxx", x: x1, y: y1, width: w1, height: h1})
看起来是没有问题的,所以我们现在可以设计全量的Op
和Invert
方法了,在这里因为我最开始是预计要设计组合也就是将几个图形组合在一起操作的能力,所以还预留了一个parentId
作为后期开发拓展用,但是暂时是用不上的所以这个字段暂时可以忽略。下面的Invert
实际上就是case by case
地进行转换,INSERT -> DELETE
、DELETE -> INSERT
、MOVE -> MOVE
、RESIZE -> RESIZE
、REVISE -> REVISE
。这其中的DeltaSet
可以理解为当前的所有Delta
数据,类型签名类似于Record<string, Delta>
,是扁平的结构,便于数据查找。
export type OpPayload = {[OP_TYPE.INSERT]: { delta: Delta; parentId: string };[OP_TYPE.DELETE]: { id: string; parentId: string };[OP_TYPE.MOVE]: { ids: string[]; x: number; y: number };[OP_TYPE.RESIZE]: { id: string; x: number; y: number; width: number; height: number };[OP_TYPE.REVISE]: { id: string; attrs: DeltaAttributes };
};export class Op<T extends OpType> {public readonly type: T;public readonly payload: OpPayload[T];constructor(type: T, payload: OpPayload[T]) {this.type = type;this.payload = payload;}public invert(prev: DeltaSet) {switch (this.type) {case OP_TYPE.INSERT: {const payload = this.payload as OpPayload[typeof OP_TYPE.INSERT];const { delta, parentId } = payload;return new Op(OP_TYPE.DELETE, { id: delta.id, parentId });}case OP_TYPE.DELETE: {const payload = this.payload as OpPayload[typeof OP_TYPE.DELETE];const { id, parentId } = payload;const delta = prev.get(id);if (!delta) return null;return new Op(OP_TYPE.INSERT, { delta, parentId });}case OP_TYPE.MOVE: {const payload = this.payload as OpPayload[typeof OP_TYPE.MOVE];const { x, y, ids } = payload;return new Op(OP_TYPE.MOVE, { ids, x: -x, y: -y });}case OP_TYPE.RESIZE: {const payload = this.payload as OpPayload[typeof OP_TYPE.RESIZE];const { id } = payload;const delta = prev.get(id);if (!delta) return null;const { x, y, width, height } = delta.getRect();return new Op(OP_TYPE.RESIZE, { id, x, y, width, height });}case OP_TYPE.REVISE: {const payload = this.payload as OpPayload[typeof OP_TYPE.REVISE];const { id, attrs } = payload;const delta = prev.get(id);if (!delta) return null;const prevAttrs: DeltaAttributes = {};for (const key of Object.keys(attrs)) {prevAttrs[key] = delta.getAttr(key);}return new Op(OP_TYPE.REVISE, { id, attrs: prevAttrs });}default:break;}return null;}
}
History
既然我们已经设计好了基于Op
的原子化操作以及数据结构,那么紧接着我们就可以开始做History
能力了,在这里首先需要注意我们先前对于Invert
的思想是让其根据DeltaSet
自动先生成InvertOp
,在这里我们可以有两种方案来实现。
-
第一种方式是在应用
Op
之前我们先根据当前的DeltaSet
自动生成一个InvertOp
,然后将这个Op
交给History
模块存储起来作为Undo
的组操作即可。 -
第二种方式是我们在应用
Op
之前首先生成一遍新的Previous DeltaSet
,是一个immer
的副本,然后将Prev DeltaSet
以及Next DeltaSet
一并作为OnChangeEvent
交给History
模块进行后续的操作。
最终我是选择了方案二作为整体实现,倒是没有什么具体依据,只是觉得这个immer
的副本可能不仅会在这里使用,作为事件的一部分分发先前的数据值我认为是合理的,所以在应用Op
的时候大致实现如下。
public apply(op: OpSetType, applyOptions?: ApplyOptions) {const options = applyOptions || { source: "user", undoable: true };const previous = new DeltaSet(this.editor.deltaSet.getDeltas());switch (op.type) {// 根据不同的`Op`执行不同的操作}this.editor.event.trigger(EDITOR_EVENT.CONTENT_CHANGE, {previous,current: this.editor.deltaSet,changes: op,options,});
}
其实我们也可以看到,整个编辑器内部的通信是依赖于event
这个模块的,也就是说这个apply
函数不会直接调用History
的相关内容,我们的History
模块是独立挂载CONTENT_CHANGE
事件的。那么紧接着,我们需要设计History
模块的数据存储,我们先来明确一下想要实现的内容,现在原子化的Op
已经设计好了,所以在设计History
模块时就不需要全量保存快照了,但是如果每个操作都需要并入History Stack
的话可能并不是很好,通常都是有N
个Op
的一并Undo/Redo
,所以这个模块应该有一个定时器与缓存数组还有最大时间,如果在N
毫秒秒内没有新的Op
加入的话就将Op
并入History Stack
,还有就是常规的undo stack
以及redo stack
,栈存储的内容也不应该很大,所以还需要设置最大存储量。
export class History {private readonly DELAY = 800;private readonly STACK_SIZE = 100;private temp: OpSetType[];private undoStack: OpSetType[][];private redoStack: OpSetType[][];private timer: ReturnType<typeof setTimeout> | null;
}
前边也提到过我们都是通过事件来进行通信的,所以这里需要先挂载事件,并且在这里将Invert
的Op
构建好,将其置入批量操作的缓存中。
constructor(private editor: Editor) {this.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange, 10);}destroy() {this.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange);}private onContentChange = (e: ContentChangeEvent) => {if (!e.options.undoable) return void 0;this.redoStack = [];const { previous, changes } = e;const invert = changes.invert(previous);if (invert) {this.temp.push(invert);if(!this.timer) {this.timer = setTimeout(this.collectImmediately, this.DELAY);}}};
后来我在思考一个问题,如果这N
毫秒内用户进行了Undo
操作应该怎么办,后来想想实际上很简单,此时只需要清除定时器,将暂存的Op[]
立即放置于Redo Stack
即可。
private collectImmediately = () => {if (!this.temp.length) return void 0;this.undoStack.push(this.temp);this.temp = [];this.redoStack = [];this.timer && clearTimeout(this.timer);this.timer = null;if (this.undoStack.length > this.STACK_SIZE) this.undoStack.shift();};
后边就是实际进行redo
和undo
的操作了,只不过在这里批量操作是使用循环每个Op
都需要单独Apply
的,这样感觉并不是很好,毕竟需要修改多次,虽然后边的渲染我只会进行一次批量渲染,但是这里事件触发的次数有点多,另外这里有个点还需要注意,我们在History
模块里进行的操作,本身不应该再记入History
中,所以这里还有一个ApplyOptions
的设置需要注意。此外,在undo
之后需要将这部分内容再次invert
之后入redo stack
,反过来也是一样的,此时我们直接取当前编辑器的DeltaSet
即可。
public undo() {this.collectImmediately();if (!this.undoStack.length) return void 0;const ops = this.undoStack.pop();if (!ops) return void 0;this.editor.canvas.mask.clearWithOp();this.redoStack.push(ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]);this.editor.logger.debug("UNDO", ops);ops.forEach(op => this.editor.state.apply(op, { source: "undo", undoable: false }));}public redo() {if (!this.redoStack.length) return void 0;const ops = this.redoStack.pop();if (!ops) return void 0;this.editor.canvas.mask.clearWithOp();this.undoStack.push(ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]);this.editor.logger.debug("REDO", ops);ops.forEach(op => this.editor.state.apply(op, { source: "redo", undoable: false }));}
最后
本文我们介绍总结了我们的图形编辑器中数据结构的设计以及History
模块的实现,虽然暂时不涉及到Canvas
本身,但是这都是作为编辑器本身的基础能力,也是通用的能力可以学习。后边我们可以介绍的能力还有很多,例如复制粘贴模块、画布分层、事件管理、无限画布、按需绘制、性能优化、焦点控制、参考线、富文本、快捷键、层级控制、渲染顺序、事件模拟、PDF
排版等等,整体来说还是比较有意思的,欢迎关注我并留意后续的文章。
相关文章:
Canvas图形编辑器-数据结构与History(undo/redo)
Canvas图形编辑器-数据结构与History(undo/redo) 这是作为 社区老给我推Canvas,于是我也学习Canvas做了个简历编辑器 的后续内容,主要是介绍了对数据结构的设计以及History能力的实现。 在线编辑: https://windrunnermax.github.io/CanvasEditor开源地…...

阿里云Centos7下编译glibc
编译glibc 原来glibc版本 编译前需要的环境: CentOS7 gcc 8.3.0 gdb 8.3.0 make 4.0 binutils 2.39 (ld -v) python 3.6.8 其他看INSTALL, 但有些版本也不易太高 wget https://mirrors.aliyun.com/gnu/glibc/glibc-2.37.tar.gz tar -zxf glibc-2.37.tar.gz cd glibc-2.37/ …...

UE5数字孪生系列笔记(四)
场景的切换 创建一个按钮的用户界面UMG 创建一个Actor,然后将此按钮UMG添加到组件Actor中 调节几个全屏的背景 运行结果 目标点切换功能制作 设置角色到这个按钮的位置效果 按钮被点击就进行跳转 多个地点的切换与旋转 将之前的目标点切换逻辑替换成旋转的逻…...

品牌故事化:Kompas.ai如何塑造深刻的品牌形象
在这个信息爆炸的时代,品牌故事化已经成为企业塑造独特形象、与消费者建立情感联系的重要手段。一个引人入胜的品牌故事不仅能够吸引消费者的注意力,还能够在消费者心中留下持久的印象,建立起强烈的情感连接。本文将深入探讨品牌故事化对于构…...
5g和2.4g频段有什么区别
运行的频段不同 2.4G和5G频段的主要区别在于它们运行的频段不同,2.4G频段运行在2.4GHz的频段上,而5G频段(这里指的是5GHz频段)运行在5GHz的频段上。12 这导致了两者在传输速度、覆盖范围、抗干扰能力等方面的明显差异。以下是详…...

交通管理在线服务系统|基于Springboot的交通管理系统设计与实现(源码+数据库+文档)
交通管理在线服务系统目录 目录 基于Springboot的交通管理系统设计与实现 一、前言 二、系统功能设计 三、系统实现 1、用户信息管理 2、驾驶证业务管理 3、机动车业务管理 4、机动车业务类型管理 四、数据库设计 1、实体ER图 五、核心代码 六、论文参考 七、最新计…...
konva.js 工具类
konva.js 工具类 class KonvaCanvas {/*** 初始化画布* param {String} domId 容器dom id*/constructor(domId) {this.layer null;this.stage null;this.scale 1;this.init(domId);}/*** 聚焦到指定元素* param {String} elementId 元素dom id*/focusOn(elementId) {if (!t…...

php未能在vscode识别?
在设置里搜php,找到settings.json,设置你的安装路径即可。 成功...

解读MongoDB官方文档获取mongo7.0版本的安装步骤与基本使用
mongo式一款NOSQL数据库,用于存储非结构化数据,mongo是一种用于存储json的数据数据,可以通过mongo提供的命令解析json获取想要的值。 数据模型 了解关系数据库会很熟悉database,table,row,column的概念,分别是数据库,…...

【数据结构|C语言版】顺序表
前言1. 初步认识数据结构2. 线性表3. 顺序表3.1 顺序表的概念3.1 顺序表的分类3.2 动态顺序表的实现 结语 前言 各位小伙伴大家好!小编来给大家讲解一下数据结构中顺序表的相关知识。 1. 初步认识数据结构 【概念】数据结构是计算机存储、组织数据的⽅式。 数据…...

Unity类银河恶魔城学习记录12-17 p139 In game UI源代码
Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释,可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili UI.cs using UnityEngine;public class UI : MonoBehaviour {[SerializeFie…...

MongoDB学习【一】MongoDB简介和部署
MongoDB简介 MongoDB是一种开源的、面向文档的、分布式的NoSQL数据库系统,由C语言编写而成。它的设计目标是为了适应现代Web应用和大数据处理场景的需求,提供高可用性、横向扩展能力和灵活的数据模型。 主要特点: 文档模型: Mon…...

html 引入vue Element ui 的方式
第一种:使用CDN的方式引入 <!--引入 element-ui 的样式,--> <link rel"stylesheet" href"https://unpkg.com/element-ui/lib/theme-chalk/index.css"> <!-- 必须先引入vue, 后使用element-ui --> <…...

曾经备受追捧的海景房,为何如今却没人要了?
独家首发 ------------ 全国多地的海景房如威海乳山、惠州大亚湾、北海银滩等多地的海景房如今大跌也难以卖出,与当初各地对海景房的追捧形成了鲜明对比,为何这些海景房变成如此样子,在于现实与宣传存在着很大的区别。 曾几何时面朝大海鸟语花…...

[docker] 镜像部分补充
[docker] 镜像部分补充 这里补充一下比较少用的,关于镜像的内容 检查镜像 ❯ docker images REPOSITORY TAG IMAGE ID CREATED SIZE <none> <none> ca61c1748170 2 hours ago 1.11GB node latest 5212d…...
Android(Kotlin) 委托(by) 封装 SharedPreferences
在 Kotlin 中,委托是一种通过将自身的某个功能交给另一个对象来实现代码重用的技术。通过委托,我们可以将某个属性或方法的实现委托给另一个对象,从而减少重复代码的编写。委托可以用于实现多重继承、代码复用和扩展现有类的功能。 Kotlin 中…...

2022年蓝桥杯省赛软件类C/C++B组----积木画
想借着这一个题回顾一下动态规划问题的基本解法,让解题方法清晰有条理,希望更多的人可以更轻松的理解动态规划! 目录 【题目】 【本题解题思路】 【DP模版】 总体方针: 具体解题时的套路: 【题目】 【本题解题思…...
Python数据挖掘项目开发实战:使用朴素贝叶斯进行社会媒体挖掘
注意:本文下载的资源,与以下文章的思路有相同点,也有不同点,最终目标只是让读者从多维度去熟练掌握本知识点。 Python数据挖掘项目开发实战:使用朴素贝叶斯进行社会媒体挖掘 一、项目背景与目标 在社交媒体时代&…...

【DM8】ET SQL性能分析工具
通过统计SQL每个操作符的时间花费,从而定位到有性能问题的操作,指导用户去优化。 开启ET工具 INI参数: ENABLE_MONITOR1 MONITOR_SQL_EXEC1 查看参数 select * FROM v$dm_ini WHERE PARA_NAMEMONITOR_SQL_EXEC;SELECT * FROM v$dm_ini WH…...

001-谷粒商城-微服务剖析
1、架构图 还是很强的,该有的都有 2、微服务模块 SpringCloudAlibaba组件包括 SentinelNacosRocketMQSeata 搭配SpringCloudAlibaba组件 OpenFeignGateWayRibbn gateway使用了SpringWebFlux,前几天研究到,为什么springboot不直接使用Spri…...

装饰模式(Decorator Pattern)重构java邮件发奖系统实战
前言 现在我们有个如下的需求,设计一个邮件发奖的小系统, 需求 1.数据验证 → 2. 敏感信息加密 → 3. 日志记录 → 4. 实际发送邮件 装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其…...

Unity3D中Gfx.WaitForPresent优化方案
前言 在Unity中,Gfx.WaitForPresent占用CPU过高通常表示主线程在等待GPU完成渲染(即CPU被阻塞),这表明存在GPU瓶颈或垂直同步/帧率设置问题。以下是系统的优化方案: 对惹,这里有一个游戏开发交流小组&…...

dedecms 织梦自定义表单留言增加ajax验证码功能
增加ajax功能模块,用户不点击提交按钮,只要输入框失去焦点,就会提前提示验证码是否正确。 一,模板上增加验证码 <input name"vdcode"id"vdcode" placeholder"请输入验证码" type"text&quo…...

Python实现prophet 理论及参数优化
文章目录 Prophet理论及模型参数介绍Python代码完整实现prophet 添加外部数据进行模型优化 之前初步学习prophet的时候,写过一篇简单实现,后期随着对该模型的深入研究,本次记录涉及到prophet 的公式以及参数调优,从公式可以更直观…...
python爬虫:Newspaper3k 的详细使用(好用的新闻网站文章抓取和解析的Python库)
更多内容请见: 爬虫和逆向教程-专栏介绍和目录 文章目录 一、Newspaper3k 概述1.1 Newspaper3k 介绍1.2 主要功能1.3 典型应用场景1.4 安装二、基本用法2.2 提取单篇文章的内容2.2 处理多篇文档三、高级选项3.1 自定义配置3.2 分析文章情感四、实战案例4.1 构建新闻摘要聚合器…...
MySQL中【正则表达式】用法
MySQL 中正则表达式通过 REGEXP 或 RLIKE 操作符实现(两者等价),用于在 WHERE 子句中进行复杂的字符串模式匹配。以下是核心用法和示例: 一、基础语法 SELECT column_name FROM table_name WHERE column_name REGEXP pattern; …...
2023赣州旅游投资集团
单选题 1.“不登高山,不知天之高也;不临深溪,不知地之厚也。”这句话说明_____。 A、人的意识具有创造性 B、人的认识是独立于实践之外的 C、实践在认识过程中具有决定作用 D、人的一切知识都是从直接经验中获得的 参考答案: C 本题解…...
Pinocchio 库详解及其在足式机器人上的应用
Pinocchio 库详解及其在足式机器人上的应用 Pinocchio (Pinocchio is not only a nose) 是一个开源的 C 库,专门用于快速计算机器人模型的正向运动学、逆向运动学、雅可比矩阵、动力学和动力学导数。它主要关注效率和准确性,并提供了一个通用的框架&…...

用机器学习破解新能源领域的“弃风”难题
音乐发烧友深有体会,玩音乐的本质就是玩电网。火电声音偏暖,水电偏冷,风电偏空旷。至于太阳能发的电,则略显朦胧和单薄。 不知你是否有感觉,近两年家里的音响声音越来越冷,听起来越来越单薄? —…...
Java毕业设计:WML信息查询与后端信息发布系统开发
JAVAWML信息查询与后端信息发布系统实现 一、系统概述 本系统基于Java和WML(无线标记语言)技术开发,实现了移动设备上的信息查询与后端信息发布功能。系统采用B/S架构,服务器端使用Java Servlet处理请求,数据库采用MySQL存储信息࿰…...