text-version 是一个面向文本内容的轻量级版本管理工具,通过差异、快照和版本引用等方式压缩保存多个文本版本。它采用类似 Git 的思路,为 CMS、在线编辑器和配置文件等场景提供 commit、show、log、reset、squash 和导出等能力,并通过反向差异、最优差异存储、混合引用、分离式存储和自定义压缩优化存储空间与最新版本读取效率。
前言
Git 在我心目中,是当之无愧的最佳文本版本管理工具。
它可以追踪版本、保存提交信息、查看历史差异,还可以通过增量的方式减少重复存储。对于代码来说,Git 基本已经是标准答案了。
最近(其实是半年前)我在重写 CMS 系统,需要给文章增加版本管理功能。最简单的方式当然是每次修改文章时,直接把完整内容保存到数据库里,再额外记录版本号和修改时间。不过这样做有一个比较明显的问题:每个版本都保存了一份完整文本。文章比较短的时候没什么问题,但如果文章数量和版本数量都增加了,数据库中就会出现大量重复内容。
所以我写了一个 NPM 库,尝试使用类似 Git 的方式管理文本版本:
简介
text-version 是一个针对文本内容的轻量级版本管理工具。它不负责管理文件,也不尝试替代 Git,而是把多个文本版本压缩成一个字符串,方便直接存进数据库、localStorage 或其他存储系统。
目前主要支持以下功能:
commit:提交新版本show:查看指定版本log:查看版本历史latest:获取最新版本reset:重置到指定版本,并删除之后的版本squash:删除指定版本之前的历史,减少存储空间export:导出版本数据- 差异存储:只保存版本之间的变化
- 反向差异:优先优化最新版本的读取
- 最优差异存储:比较多种存储方式并选择更短的结果
- 版本引用:相同内容不重复保存
- 混合引用:引用更早版本,再应用差异得到目标内容
- 分离式导出:将版本元数据和最新快照分开保存
- 自定义压缩:接入自己的压缩算法
安装
npm install text-version或者:
pnpm add text-versionyarn add text-version基本用法
导入
ES Module:
import { TextVersion } from "text-version";CommonJS:
const { TextVersion } = require("text-version");创建实例
const tv = new TextVersion();提交版本
const tv = new TextVersion();
tv.commit("你好,世界!", "v1");
tv.commit("你好,世界!\n这是第二行。", "v2");
tv.commit("你好,TypeScript!\n这是第二行。");第二次和第三次提交没有什么特殊的地方,和普通的 commit 类似。第三次没有传版本名,所以库会根据文本内容生成一个版本名。
如果手动指定的版本名已经存在,库会自动在后面追加 #,直到生成一个没有重复的版本名:
v1 → v1# → v1##查看版本历史
console.log(tv.log());输出类似这样:
[
{ version: "v1", isSnapshot: false },
{ version: "v2", isSnapshot: false },
{ version: "ycdf93", isSnapshot: true }
]这里的 isSnapshot 表示这个版本是否是完整快照,当前策略下,普通提交之后,最新版本通常是快照,之前的版本则保存为差异。启用最优差异存储后,中间也可能保留额外的快照,作为历史版本的存储锚点。
查看指定版本
console.log(tv.show("v1"));
// 你好,世界!如果版本不存在,会返回 null:
console.log(tv.show("not-found"));
// null查看最新版本
console.log(tv.latest());
// 你好,TypeScript!
// 这是第二行。由于非重复提交的最新版本通常直接保存为快照,所以读取最新版本不需要从第一个版本开始依次应用差异。
如果最新版本的内容和历史版本完全相同,则会使用版本引用。这种情况下 latest() 会自动解析引用,不影响使用。
保存版本数据
版本数据由实例内部管理,但最终还是要保存到数据库或其他地方。可以使用 export 导出:
const storage = tv.export();
console.log(storage);输出大致如下:
2:v1:R6D7
2:v2:R3D10I2:世界
:6:ycdf93:你好,TypeScript!\n这是第二行。之后可以把这个字符串保存到数据库:
await db.article.update({
id: articleId,
versionData: tv.export(),
});恢复时,再把保存的数据传给构造函数:
const storage = await db.article.getVersionData(articleId);
const restored = new TextVersion(storage);
console.log(restored.latest());
console.log(restored.show("v1"));例如在浏览器中,也可以直接使用 localStorage:
const tv = new TextVersion();
tv.commit("第一版内容", "v1");
tv.commit("第二版内容", "v2");
localStorage.setItem("article-version", tv.export());
const savedStorage = localStorage.getItem("article-version");
const restored = new TextVersion(savedStorage || "");
console.log(restored.latest());内部存储方式
text-version 内部使用长度前缀格式保存数据。快照版本的格式是:
:版本名长度:版本名:内容差异版本的格式是:
版本名长度:版本名:操作序列差异操作主要有三种:
R<数字>:保留指定数量的字符I<长度>:<文本>:插入文本D<数字>:删除指定数量的字符
例如:
2:v1:R6D7可以理解为对当前文本执行:
- 保留前 6 个字符;
- 删除接下来的 7 个字符。
文本差异由 diff-match-patch 计算,之后再转换成 text-version 自己的操作格式。
使用反向差异
旧版(v1)使用的是正向增量:
v1:完整快照
v2:从 v1 变成 v2 的差异
v3:从 v2 变成 v3 的差异这种方式有一个问题,如果想获取最新版本,就需要从 v1 开始,依次应用 v2、v3 的差异。版本越多,读取最新版本需要做的工作就越多。
现在(v2)的基础策略改成了反向差异:
v1:从 v2 还原到 v1 的差异
v2:从 v3 还原到 v2 的差异
v3:最新版本的完整快照也就是说,最新版本直接保存完整内容,历史版本保存从后往前还原的差异;读取最新版本时,直接读取最后一个快照即可。
读取历史版本时,则从目标版本向后寻找最近的快照,再向前应用反向差异。这样做是因为最优差异存储可能保留多个快照,如果直接跨到最后一个快照,反而可能跨过中间的存储边界。
另外,代码中还增加了缓存:
- 已解析的存储数据会缓存;
- 解压后的内容会缓存;
- 已经读取过的版本内容会缓存。
所以同一个版本重复读取时,不需要每次都重新解析全部数据。
最优差异存储
默认会比较多种存储方式:
- 直接使用当前版本和上一个版本之间的反向差异;
- 如果上一个版本本身是独立引用,继续保留这个引用;
- 引用更早的版本,再应用一段正向差异,生成混合引用。
库比较的不是单独某段差异字符串的长度,而是把候选方案放回完整的版本存储中,计算最终的整体长度。这样如果某个混合引用需要把被引用的版本提升为独立快照,这部分额外成本也会计算进去。下面是测试中使用的一种场景:
const v1 = "A".repeat(20) + "B".repeat(160);
const v2 = "B".repeat(160);
const v3 = "C";
const tv = new TextVersion();
tv.commit(v1, "v1");
tv.commit(v2, "v2");
tv.commit(v3, "v3");在第三次提交之后,v2 可能使用这样的混合引用:
:2:v1:AAAAAAAA...BBBBBBBB...
2:v2:=v1:D20
:2:v3:C这里的 =v1:D20 表示先读取 v1,再删除开头的 20 个字符,从而得到 v2。
由于 v1 原来可能依赖后续版本的反向差异,直接引用它有形成循环依赖的风险。因此当前实现会在需要时把 v1 提升为独立快照,再让 v2 引用它,最终库会在这些候选方案中选择完整存储结果更短的一种。
关闭最优差异存储
默认情况下,optimizeDiffStorage 是开启的;如果需要做基准对比,可以关闭它:
const baseline = new TextVersion(
undefined,
undefined,
{ optimizeDiffStorage: false },
);关闭之后,提交时只使用相邻版本的反向差异,不再尝试混合引用和其他存储优化方式。
这个选项主要用于性能测试,普通使用没有必要关闭。
相同内容使用版本引用
如果连续提交了完全相同的文本,继续保存差异也没有意义。
例如:
const tv = new TextVersion();
tv.commit("相同内容", "v1");
tv.commit("相同内容", "v2");
console.log(tv.export());第二个版本会使用引用:
:2:v1:相同内容
2:v2:=v1=v1 表示 v2 的内容直接引用 v1。
查看 v2 时,库会自动解析引用:
console.log(tv.show("v2"));
// 相同内容这里的判断是完全相同的字符串比较,并不是相似文本检测。
reset 和 squash
reset
reset 会保留目标版本以及它之前的版本,同时删除目标版本之后的版本。
const tv = new TextVersion();
tv.commit("版本 1", "v1");
tv.commit("版本 2", "v2");
tv.commit("版本 3", "v3");
tv.reset("v2");
console.log(tv.log());
// v1、v2
console.log(tv.show("v3"));
// null重置之后,v2 会成为最新版本,因此会重新构建为快照。
如果目标版本不存在,会抛出错误:
tv.reset("not-found");
// Error: 版本 not-found 不存在squash
squash 用来删除目标版本之前的历史。
const tv = new TextVersion();
tv.commit("版本 1", "v1");
tv.commit("版本 2", "v2");
tv.commit("版本 3", "v3");
tv.squash("v2");
console.log(tv.log());
// v2、v3
console.log(tv.show("v1"));
// null
console.log(tv.show("v2"));
// 版本 2这个操作是不可逆的,v1 被删除后就不能再恢复。
如果 squash("v2") 之后只剩下 v2,那么 v2 会是最新快照。但如果 v3、v4 这些后续版本还在,当前实现会重新以最后一个版本作为快照,v2 和中间版本保存为差异。
所以更准确地说,squash 是“删除目标版本之前的历史并重新整理存储”,并不是永远把目标版本设置成快照。
分离式存储
如果最新版本内容很大,直接把完整内容和版本元数据放在同一个字符串中,使用起来不太灵活。
这时可以使用分离式导出:
const tv = new TextVersion();
tv.commit("第一个版本", "v1");
tv.commit("第二个版本", "v2");
tv.commit("这是一个比较长的最新版本内容...", "v3");
const result = tv.export("separate");
console.log(result.metadata);
console.log(result.snapshot);返回值是:
{
metadata: "...##[[00ycdf93]]##",
snapshot: "这是一个比较长的最新版本内容..."
}metadata 中保存版本列表和差异数据,最新版本的完整内容会被替换为一个占位符;snapshot 则是最新版本的完整文本。
这样就可以分别保存:
const { metadata, snapshot } = tv.export("separate");
await db.article.update({
id: articleId,
versionMetadata: metadata,
latestSnapshot: snapshot,
});恢复时,需要同时传入 metadata 和 snapshot:
const restored = new TextVersion(metadata, snapshot);
console.log(restored.latest());
console.log(restored.show("v1"));分离式存储比较适合这些场景:
- 版本元数据保存到数据库;
- 最新快照保存到对象存储;
- metadata 放在 CDN,snapshot 按需加载;
- 给 metadata 和 snapshot 使用不同的缓存策略。
在导入分离数据时,库会计算 snapshot 的哈希,并和 metadata 中的占位符进行比较,用于完整性校验。
如果 snapshot 被修改,哈希不匹配,就会抛出错误。
自定义压缩
Text-Version 提供了一个简单的压缩接口:
interface CompressionProvider {
compress(data: string): string;
decompress(data: string): string;
}例如可以用 Base64 做一个简单演示:
const compressionProvider = {
compress(data) {
return Buffer.from(data).toString("base64");
},
decompress(data) {
return Buffer.from(data, "base64").toString();
},
};
const tv = new TextVersion("", compressionProvider);
tv.commit("测试内容", "v1");
tv.commit("更多测试内容", "v2");
const storage = tv.export();
const restored = new TextVersion(
storage,
compressionProvider,
);
console.log(restored.latest());
// 更多测试内容实际使用时,当然不建议把 Base64 当作压缩算法。这里主要是为了展示接口怎么接入,你可以根据项目情况接入 gzip、brotli 或其他压缩方案。
如果使用分离式存储,metadata 也会经过压缩提供者处理,但 snapshot 仍然是单独返回的原始文本:
const result = tv.export("separate");
const restored = new TextVersion(
result.metadata,
result.snapshot,
compressionProvider,
);浏览器中通过 CDN 使用
除了 NPM,也可以直接在浏览器中使用 UMD 构建版本。
需要先引入 diff-match-patch,再引入 Text-Version:
<script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<script src="https://cdn.jsdelivr.net/npm/text-version/dist/index.umd.js"></script>也可以使用 unpkg:
<script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<script src="https://unpkg.com/text-version/dist/index.umd.js"></script>引入之后,可以通过 window.TextVersion.TextVersion 创建实例:
<script>
const tv = new window.TextVersion.TextVersion();
tv.commit("第一版内容", "v1");
tv.commit("第二版内容", "v2");
console.log(tv.latest());
console.log(tv.log());
</script>API 参考
构造函数
普通存储:
new TextVersion(
initialStorage?: string,
compressionProvider?: CompressionProvider,
options?: TextVersionOptions,
)分离式存储:
new TextVersion(
metadata: string,
snapshot: string,
compressionProvider?: CompressionProvider,
options?: TextVersionOptions,
)如果不使用压缩提供者,也可以把 options 作为第三个参数传入:
const tv = new TextVersion(
undefined,
undefined,
{ optimizeDiffStorage: false },
);方法
| 方法 | 说明 |
|---|---|
commit(text, version?) | 提交新版本,版本名为空时自动生成 |
show(version) | 查看指定版本,不存在时返回 null |
log() | 获取版本历史 |
latest() | 获取最新版本内容 |
reset(version) | 删除指定版本之后的版本 |
squash(version) | 删除指定版本之前的版本 |
export() | 导出完整存储字符串 |
export("separate") | 分离导出 metadata 和 snapshot |
commit、reset 和 squash 返回当前实例,可以进行链式调用。
类型定义
interface VersionInfo {
version: string;
isSnapshot: boolean;
}
interface CompressionProvider {
compress(data: string): string;
decompress(data: string): string;
}
interface TextVersionOptions {
optimizeDiffStorage?: boolean;
}
interface DiffOperation {
type: "retain" | "insert" | "delete";
length?: number;
text?: string;
}后言
text-version不是 Git 的替代品,它做的事情非常简单:把一串文本的多个历史版本压缩保存起来,然后提供提交、查看、回滚和导出功能。对于 CMS 文章版本、配置文件、在线编辑器这类场景来说,这个库应当还是相当好用的。
不过历史版本越多,读取较早版本时需要还原的差异也越多。因此如果版本长期增长,建议定期使用 squash 清理旧历史。
评论