SpreadJS 协同MongoDB适配器

通过 MySQL 数据库适配的支持,企业能够在 SpreadJS 协同服务中获得更高的 稳定性、可扩展性和业务适配性,从而真正将前端在线表格能力与后端数据管理体系打通。

发布于 2025/08/19 16:11

SpreadJS

在协同编辑场景中,数据的实时同步与持久化是系统稳定性的核心。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 报表设计等业务场景需求,极大的降低企业研发成本和项目交付风险。

如下资源列表,可以为您评估产品提供帮助:

相关产品
推荐相关案例
推荐相关资源
关注微信
葡萄城社区二维码

关注“葡萄城社区”

加微信获取技术资讯

加微信获取技术资讯

想了解更多信息,请联系我们, 随时掌握技术资源和产品动态