在协同编辑场景中,数据的实时同步与持久化是系统稳定性的核心。SpreadJS 作为一款前端 Excel 表格控件,通过协同服务,可以支持多人同时在线编辑表格。然而,仅有内存级的同步并不足以满足企业级应用需求:如果服务器重启、内存溢出或用户需要回溯历史操作,数据极有可能丢失或无法查询。
为了解决这一问题,SpreadJS 在服务器端提供了 数据库适配器(IDatabaseAdapter) 机制。数据库适配器负责将协同编辑产生的 文档快照(snapshot) 和 操作流(operations,简称 op) 与持久化存储系统对接。这样,系统不仅能够保存文档的最新状态,还能完整追溯历史操作,支持版本回滚和数据分析。
目前官方内置了 MemoryDb、Postgres、Sqlite3 等适配器,但在实际业务系统中,MongoDB 作为一款 文档型数据库,以其灵活的 JSON 数据存储结构和良好的扩展性,被广泛应用于协作与内容管理系统。因此,本文将介绍如何基于 SpreadJS 的数据库适配器接口,实现 MongoDB 适配器,并将其集成到 DocumentServices 中,从而满足以下典型业务场景:
灵活的数据存储:操作记录和快照可直接存储为 JSON 文档,避免复杂的关系型表结构设计。
高效的版本与历史查询:依托 MongoDB 的文档查询和索引能力,可以快速回放或恢复任意版本的文档。
水平扩展与大数据支持:通过 MongoDB 的分片和集群机制,协同服务能够支持更大规模的并发编辑与海量操作存储。
通过 MongoDB 数据库适配的支持,企业能够在 SpreadJS 协同服务中获得更强的 灵活性、扩展性和云原生适配性,从而将前端实时表格能力与后端分布式文档存储深度结合。
具体实现如下:
新建协同Collections参考脚本,包括里程碑存储:
export async function InitCollections(client) {
// 需要的集合列表
const collectionsToCreate = [
{ name: 'documents', indexes: [{ key: { id: 1 }, unique: true }] },
{ name: 'operations', indexes: [{ key: { doc_id: 1, version: 1 }, unique: true }] },
{ name: 'snapshot_fragments', indexes: [{ key: { doc_id: 1, fragment_id: 1 }, unique: true }] },
{ name: 'milestone_snapshot', indexes: [{ key: { doc_id: 1, version: 1 }, unique: true }] },
];
await client.connect();
const db = client.db(dbName);
// 获取已存在集合
const existingCollections = (await db.listCollections().toArray()).map(c => c.name);
for (const col of collectionsToCreate) {
if (!existingCollections.includes(col.name)) {
await db.createCollection(col.name);
console.log(`Collection '${col.name}' created.`);
} else {
console.log(`Collection '${col.name}' already exists.`);
}
// 创建索引
for (const idx of col.indexes) {
await db.collection(col.name).createIndex(idx.key, { unique: idx.unique });
}
}
await client.close();
适配器参考实现,session可以看需求自行添加
export class MongoDb extends Db {
constructor(client) {
super();
this.client = client;
this.client.connect()
this.db = client.db(dbName);
}
async getDocument(docId) {
const documents = this.db.collection('documents');
let row = await documents.findOne({ id: docId });
if (row) {
return {
id: row.id,
type: row.type,
version: row.version,
snapshotVersion: row.snapshot_version
};
}
}
async getSnapshot(docId) {
const documents = this.db.collection('documents');
let row = await documents.findOne({ id: docId });
if (!row) {
return null;
}
const fragments = await this.getFragments(docId);
return {
id: row.id,
v: row.snapshot_version,
type: row.type,
fragments: fragments
};
}
async getFragments(docId) {
const fragments = this.db.collection('snapshot_fragments');
const rows = await fragments.find({ doc_id: docId }).toArray();
if (rows.length === 0) {
return {};
}
const results = {};
for (const row of rows) {
results[row.fragment_id] = JSON.parse(row.data);
}
return results;
}
async getFragment(docId, fragmentId) {
const fragments = this.db.collection('snapshot_fragments');
const row = await fragments.findOne({ doc_id: docId, fragment_id: fragmentId });
if (row) {
return JSON.parse(row.data);
}
return null;
}
async getOps(docId, from, to) {
const operations = this.db.collection('operations');
const query = { doc_id: docId, version: { $gte: from } };
if (to !== undefined) {
query.version.$lte = to;
}
const rows = await operations.find(query).toArray();
if (rows.length === 0) {
return [];
}
return rows.map(row => JSON.parse(row.operation));
}
async commitOp(docId, op, document) {
try {
const documents = this.db.collection('documents');
const operations = this.db.collection('operations');
const row = await documents.findOne({ id: docId });
if (op.create) {
if (row) {
throw new Error(`Document with id ${docId} already exists.`);
}
await documents.insertOne(
{
id: docId,
type: document.type,
version: document.version,
snapshot_version: document.snapshotVersion
},
);
await operations.insertOne(
{
doc_id: docId,
version: op.v,
operation: JSON.stringify(op)
},
);
return true;
}
else if (op.del) {
if (!row) {
throw new Error(`Document with id ${docId} does not exist.`);
}
await documents.deleteOne(
{ id: docId },
);
return true;
}
else {
if (!row || row.version !== op.v) {
throw new Error(`Document with id ${docId} does not exist or version mismatch.`);
}
await operations.insertOne(
{
doc_id: docId,
version: op.v,
operation: JSON.stringify(op)
},
);
await documents.updateOne(
{ id: docId },
{ $set: { version: document.version } },
);
return true;
}
}
catch (error) {
console.error('Error committing operation:', error);
return false;
}
finally {
}
}
async commitSnapshot(docId, snapshot) {
try {
const documents = this.db.collection('documents');
const fragments = this.db.collection('snapshot_fragments');
const row = await documents.findOne({ id: docId },);
if (!row) {
throw new Error(`Document with id ${docId} does not exist.`);
}
const currentSnapshotVersion = row.snapshot_version;
if (snapshot.fromVersion !== currentSnapshotVersion || snapshot.v <= currentSnapshotVersion) {
throw new Error(`Snapshot version mismatch: expected ${currentSnapshotVersion}, got ${snapshot.v}`);
}
await documents.updateOne(
{ id: docId },
{ $set: { snapshot_version: snapshot.v } },
);
if (snapshot.fragmentsChanges.deleteSnapshot) {
fragments.deleteMany(
{ doc_id: docId },
);
}
else {
const { createFragments, updateFragments, deleteFragments } = snapshot.fragmentsChanges;
if (createFragments) {
const createOps = Object.entries(createFragments).map(([id, data]) => ({
doc_id: docId,
fragment_id: id,
data: JSON.stringify(data)
}));
if (createOps.length > 0) {
await fragments.insertMany(
createOps,
);
}
}
if (updateFragments) {
const updateOps = Object.entries(updateFragments).map(([id, data]) => ({
updateOne: {
filter: { doc_id: docId, fragment_id: id },
update: { $set: { data: JSON.stringify(data) } }
}
}));
if (updateOps.length > 0) {
await fragments.bulkWrite(
updateOps,
// { session }
);
}
}
if (deleteFragments) {
const deleteOps = deleteFragments.map(id => ({
deleteOne: {
filter: { doc_id: docId, fragment_id: id }
}
}));
if (deleteOps.length > 0) {
await fragments.bulkWrite(deleteOps,
);
}
}
}
return true;
}
catch (error) {
console.error('Error committing snapshot:', error);
return false;
}
finally {
}
}
async close() {
await this.client.close();
}
}
里程碑数据存储实现:
export class MongoMilestoneDb {
constructor(client, interval) {
this.client = client;
this.interval = interval ? interval : 1000;
this.db = client.db(dbName);
}
async saveMilestoneSnapshot(snapshot) {
console.log("saveMilestoneSnapshot", snapshot);
const milestones = this.db.collection('milestone_snapshot');
await milestones.insertOne({
doc_id: snapshot.id,
version: snapshot.v,
snapshot: JSON.stringify(snapshot)
});
return true;
}
async getMilestoneSnapshot(id, version) {
console.log("getMilestoneSnapshot", id, version);
const milestones = this.db.collection('milestone_snapshot');
const row = await milestones.findOne(
{ doc_id: id, version: { $lte: version } },
{ sort: { version: -1 } }
);
if (row) {
console.log("Found milestone snapshot:", row);
return JSON.parse(row.snapshot);
}
return null;
}
}
在DocumentServices中配置db和milestoneDb
const documentServices = new OT.DocumentServices({
db: new MongoDb(mongoClient),
milestoneDb: new MongoMilestoneDb(mongoClient, 500)
});
以上便是SpreadJS 协同服务基于MongoDB的存储适配实现。
SpreadJS | 下载试用
纯前端表格控件SpreadJS,兼容 450 种以上的 Excel 公式,具备“高性能、跨平台、与 Excel 高度兼容”的产品特性,备受华为、苏宁易购、天弘基金等行业龙头企业的青睐,并被中国软件行业协会认定为“中国优秀软件产品”。SpreadJS 可为用户提供类 Excel 的功能,满足表格文档协同编辑、 数据填报、 类 Excel 报表设计等业务场景需求,极大的降低企业研发成本和项目交付风险。
如下资源列表,可以为您评估产品提供帮助:
葡萄城热门产品


