实现应用与项目数据分库及事务封装

49 阅读8分钟

介绍

今年,我计划独立开发一款桌面端绘图软件。目前,该软件的最小可行产品(MVP)核心功能已经完成,用户可以新建项目、保存项目以及打开历史项目。软件采用了一种独特的数据管理架构:每个绘图项目都被设计为一个独立的文件,文件中包含该项目的完整数据库。这种设计不仅允许用户以文件形式存储和分享项目数据,还确保了每个项目的独立性和完整性。此外,软件还在本地磁盘的固定位置维护了一个应用数据库,用于存储全局配置、用户信息以及跨项目的数据。这种分库设计不仅提高了数据管理的灵活性,还增强了系统的可扩展性和安全性。

为什么采用这种设计?

  • 项目独立性:每个项目作为一个独立的文件存储,便于用户在不同设备之间迁移和共享项目数据,同时也避免了项目之间的数据干扰。用户可以轻松地将项目文件复制到外部存储设备或通过邮件发送给他人,而无需担心数据丢失或混淆。
  • 灵活性与安全性:应用数据库与项目数据库分离,使得全局配置和用户信息更加安全,同时也便于对项目数据进行独立操作和管理。这种分离确保了用户数据的隐私性,即使项目文件被共享,应用数据库中的敏感信息也不会被泄露。
  • 性能优化:通过读写分离的数据库连接策略,优化了数据访问性能,特别是在高并发场景下,能够显著提升系统的响应速度。即使在离线环境下,用户也能享受到流畅的操作体验。

这种设计不仅满足了离线桌面端软件的需求,如在线协作功能可以作为独立的项目上传。

实现应用与项目数据分库

连接数据库有哪些方式

  1. 使用 TypeOrmModule.forRootAsync

    • 这种方式通常在主模块中使用。
    • 支持异步加载数据库配置,可以通过工厂函数、类或现有提供者来动态配置。
    • 示例代码:
    @Injectable()
     export class SystemDatabaseConfig implements TypeOrmOptionsFactory {
       createTypeOrmOptions(): TypeOrmModuleOptions {
         return {
           type: 'better-sqlite3',
           database: join(process.cwd(), 'data', 'system.db'),
           entities: [ApplicationProject],
           synchronize: true,
           logging: true,
         };
       }
     }
    
    
    TypeOrmModule.forRootAsync({
      useClass: SystemDatabaseConfig,
    })
    
  2. 使用 createConnection 手动连接数据库

    • 这种方式适用于需要动态创建多个数据库连接的场景。
    • 可以在服务中根据需要手动创建和管理数据库连接。
    • 示例代码:
      const connection = await createConnection(
        {
           name: `project`,
           type: 'better-sqlite3',
           database: join(process.cwd(), 'data', 'projects', `project.db`),
           entities: [ShapeEntity],
           synchronize: true,
           logging: true,
       })
      

连接数据库的时机

  1. 主数据库连接

    • 在应用启动时通过 TypeOrmModule.forRootAsync 进行连接
    • 配置在 SystemDatabaseConfig 类中
    • 连接的系统数据库文件路径为 data/system.db
    • 包含的实体为 ApplicationProject
  2. 项目数据库连接

    • 在创建项目时通过 createConnection 手动连接
    • 配置在 ProjectDatabaseConfig.createConfig 方法中
    • 每个项目有独立的数据库文件,路径为 data/projects/{projectId}.db
    • 包含的实体为 ShapeEntity

这种设计实现了主从数据库的分离,系统数据库用于管理项目元数据,项目数据库用于存储具体项目的数据。

事务封装的设计与实现

在我上一篇文章 # 服务端如何实现撤销恢复功能中写了关于如何通过,服务端采用 step 表和 currentStep 表记录变更,客户端通过 Socket 实时接收步骤数据。

为了确保数据操作的原子性和一致性,我们设计了一个通用的事务封装函数 transaction。并通过该函数支持以下功能,使得代码抽象度更高,功能也可以配置化。

  • 数据操作的原子性和一致性:确保每个事务要么完全成功,要么完全失败,不会出现部分执行的情况。
  • 灵活的项目锁定机制:支持对特定项目加锁,防止并发操作导致的数据冲突。
  • 操作步骤记录:支持生成操作步骤记录,为撤销和恢复功能提供基础。
  • 高性能的数据访问:通过读写分离的连接策略,优化了数据访问性能,特别适合高并发场景。
  • 错误处理与资源释放:在事务失败时自动回滚,并在完成后释放项目锁,确保系统的稳定性和资源的有效利用。
  • 实时通知客户端:在事务提交后,通过 WebSocket 服务将变更信息发送给订阅的客户端,支持多用户协作和实时更新。

核心代码结构

/**
 * 启动一个事务
 *
 * @param tranOption 事务配置项
 * @param run 执行事务的方法
 * @returns
 */
export async function transaction<T>(tranOption: TranOption, run: (stepManager: StepManager) => Promise<T>): Promise<T> {
    const {
        projectId,
        lockProject = projectId ? true : false, // 有项目id则默认加锁
        initStep =  false
    } = tranOption;
    const conName = tranOption.useConnectionName || (lockProject ? WRITE_CONNECTION_NAME : READ_CONNECTION_NAME);
    let manager = getManager(conName); // 获取应用级别数据库
    let projectManager: EntityManager;
    if (projectId) {        
        const databaseName = `project_${projectId}`;
        let conn: ExtConnection;
        if (conName === WRITE_CONNECTION_NAME) {
            conn = await pcmm.getWriteConn(databaseName);
    
        } else {
            conn = await pcmm.getReadConn(databaseName);
        }
        conn.inUse = true;
        try {
            if (!conn.isConnected) {
                await conn.connect();
            }
            projectManager = conn.manager;
        } catch (e) {
            conn.inUse = false;
            throw e;
        }
    }
    /**
    * 1. 如果接口中指名是项目操作,例如:有 projectId 则使用项目 projectManager
    * 2. 否则使用应用的 manager 
    */
    return (projectManager || manager).transaction(async m => {
        // m 代表一个 EntityManager 实例
        let stepManager = new StepManager(manager, m, projectId);
        try {
            if (initStep) await stepManager.init(); // 创建步骤
            // 执行业务 service
            const ret = await run(stepManager);
            // 业务方法结束,提交此过程的所有步骤
            await stepManager.commitStep();
            return { ret, stepManager };
        } catch (error) { // 统一捕获错误处理
            throw new HttpException({
                status: HttpStatus.INTERNAL_SERVER_ERROR,
                error: 'Transaction failed',
                message: error.message,
            }, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }).then((res) => {
        // 判断 stepManager 是否有步骤变更
        if (res.stepManager.step?.changes?.length) {
            // 如果有变更将对应的消息发送给指定的客户端的 socket
            res.stepManager.wsService.sendToSubscribedClient(projectId, {
                type: WsMessageType.step,
                data: {
                    projectId,
                    changes: res.stepManager.step?.changes,
                    stepType: StepType.edit
                }
            });
        }
        return res.ret;
    })

}

StepManager 步骤管理器

StepManager 核心功能有,初始化新步骤、合并多次变更、提交步骤到数据库、管理步骤索引和回退操作。
该类还提供了各种服务实例(如 ShapeService、StepService 等)的访问接口,并负责项目级数据库连接的管理。这里是直接初始化 Service 是因为每个实例需要独立的数据库连接和项目上下文。这种方式虽不符合依赖注入原则,但能更好地处理多项目并行场景,确保数据隔离。

export class StepManager {
  constructor(manager: EntityManager, projectManager?:EntityManager, projectId?: string) {
    this.projectId = projectId;
    this.manager = manager;
    this.projectManager = projectManager;
  }
  /**
   * 获取Shape服务实例
   * 如果实例不存在则创建新实例
   */
  get shapeService(): ShapeService {
    return this._shapeService || (this._shapeService = new ShapeService(this));

  }

  /**
   * 获取Step服务实例
   * 如果实例不存在则创建新实例
   */
  get stepService() {
    return this._stepService || (this._stepService = new StepService(this));

  }

  wsService: WsService = wsService

  /**
   * 获取Project服务实例
   * 如果实例不存在则创建新实例
   */
  get projectService():ProjectService {
    return this._projectService || (this._projectService = new ProjectService(this));
  }

  /**
   * 获取CurrentStep服务实例
   * 如果实例不存在则创建新实例
   */
  get currentStepService():CurrentStepService {
    return this._currentStepService || (this._currentStepService = new CurrentStepService(this));
  }
    async init() {
        await this.initStep();
    }
  /**
   * 初始化步骤
   * 创建新的步骤实体并设置其初始状态
   */
    async initStep() {
        const stepId = getUid();
        this.step = this.projectManager.create(StepEntity, { id_:stepId, projectId: this.projectId, modelChangeIds: [], desc: '', index: 0 });
        this.step.changes = [];
        const curStep = await this.projectManager.getRepository(CurrentStep).findOne({ where: { projectId: this.projectId, stepId } });

        //
        if (!curStep) {
          this.step.index = 0;
        } else {
          this.step.index = curStep.index + 1;
        }

        this.curStep = curStep;

  }
    /**
   * 提交当前步骤到数据库
   * 如果当前步骤不是最后一步,会清除之后的步骤
   * 同时更新当前步骤的状态
   */
    async commitStep() {
        // 有步骤变更
      if (this.step?.changes?.length) {
          const stepRep  = this.stepRep;
          // 如果当前步骤不是最后一步,则清除大于这个index的步骤
          if (this.step.index < this.curStep?.stepSize) {
            const steps = await stepRep.find({ select: ['id_'], where: { projectId: this.projectId, index: MoreThanOrEqual(this.step.index) } });
            if (steps.length > 0) {
              const stepIds: string[] = steps.map(it => it.id_);
              await stepRep.delete(stepIds);
            }
          }
          await this.projectManager.save(StepEntity, this.step);
          const currentStepOne = await this.currentStepRep.findOne({ where: { projectId: this.projectId } });
          if (!currentStepOne) {
            const currentStep = new CurrentStep();
            currentStep.projectId = this.projectId;
            currentStep.stepId = this.step.id_;
            currentStep.stepSize = this.step.index + 1;
            currentStep.index = this.step.index;
            await this.currentStepRep.save(currentStep);
          } else {
            await this.currentStepRep.update({ projectId: this.projectId },{
              stepId: this.step.id_,
              stepSize: currentStepOne.stepSize + 1,
              index: currentStepOne.index + 1,
            });
          }
        }
    }
    // .....
}

ProjectConnectionManager

ProjectConnectionManager 通过单例写连接和连接池读连接的设计,实现了 SQLite 数据库的串行写入和并行读取,支持自动连接( autoConnect )和数据库创建( autoCreateDb ),同时提供连接状态检查和资源释放机制。

import { Connection } from "typeorm";
import * as AsyncLock from "async-lock";

export type ExtConnection = Connection & { inUse?: boolean };

export class ProjectConnectionManager {
  private writeConnection: ExtConnection;
  private readConnections: ExtConnection[] = [];
  private lock = new AsyncLock();
  private readonly LOCK = 'LOCK';

  constructor(public dataBaseName: string) {}

  async getWriteConnection(autoConnect = false, autoCreateDb = false) {
    return this.lock.acquire(this.LOCK, async () => {
      if (this.writeConnection) {
        if (autoConnect && !this.writeConnection.isConnected) {
          await this.writeConnection.connect();
        }
        return this.writeConnection;
      } else {
        const conn = await this.createProjectConnection(this.dataBaseName, false, autoCreateDb);
        if (autoConnect && !conn.isConnected) {
          await conn.connect();
        }
        this.writeConnection = conn;
        return conn;
      }
    });
  }

  async getReadConnection() {
    return this.lock.acquire(this.LOCK, async () => {
      const readConn = this.readConnections.find(it => !it.inUse);
      if (readConn) {
        readConn.inUse = true;
        return readConn;
      } else {
        const conn = await this.createProjectConnection(this.dataBaseName, true);
        this.readConnections.push(conn);
        conn.inUse = true;
        return conn;
      }
    });
  }

  async closeAllConnection() {
    await this.writeConnection?.close();
    for (let conn of this.readConnections) {
      await conn.close();
    }
  }

   /**
   * 创建一个项目的数据库连接
   * @param projectId
   * @param isRead 是否为读连接
   * @param autoCreateDb 是否自动创建数据库文件,如果是false,则会检查数据文件是否存在,不存在则会报错,只有新建项目时才应该传true
   * @returns
   */
  async createProjectConnection(dataBaseName:string, isRead = false, autoCreateDb = false) {
    const config = dbConfig as any;
    let connectionName = isRead ? dataBaseName + '_read_' + getUid() : dataBaseName + '_write';
    if (!autoCreateDb) {
      // 必须校验当前项目db文件已存在,否则会自动创建空的db文件
      const dbPath = join(resourceUtil.projectDbDir, `${dataBaseName}.db`);
      const existFile = existsSync(dbPath);
      if (!existFile) {
        throw new ResException(ApiCode.NO_TIP_ERROR, "项目不存在");
      }
    }
    const connection = await createConnection({
      ...config,
      name: connectionName,
      entities: [...ProjectEntityList],
      database: `./db/${dataBaseName}.db`
    });
    return connection as ExtConnection;

  }
}

getWriteConnection :

  1. 使用单例模式,整个项目只有一个写连接
  2. 如果已有连接就复用,没有就创建新的
  3. 创建的连接名称固定为 dataBaseName_write

getReadConnection :

  1. 使用连接池模式,支持多个并发读连接
  2. 优先查找空闲连接( inUse=false )
  3. 没有空闲连接且未超上限时创建新连接
  4. 创建的连接名称为 dataBaseName_read_随机ID

PCMM

PCMM是一个单例的数据库连接管理器,也就是管理我们上面的 ProjectConnectionManager ,负责管理多个项目的数据库连接。它通过 pcmMap 维护每个项目的连接管理器,提供读写连接的获取方法,确保写操作串行执行、读操作并行处理,并支持通过数据库名或项目ID操作,实现了对SQLite数据库连接的统一管理和资源复用。

  • getPcm 和 addPcm 是内部使用的工具方法:
    • 它们主要被其他公共方法调用来管理连接池
  • 对外暴露的主要是这些便捷方法:
    • getWriteConn :获取写连接,如果没有则会创建连接
    • getReadConn :获取读连接,如果没有则会创建连接
    • closePcm / closePcmByProjectId :关闭连接
/**
 * 项目数据库连接管理器的管理器(PCMM: ProjectConnectionManagerManager)
 * 单例模式,用于统一管理所有项目的数据库连接池
 */
export class PCMM {
  /** 存储数据库名称到连接管理器的映射关系 */
  pcmMap = new Map<string, ProjectConnectionManager>()

  /**
   * 根据数据库名称获取对应的连接管理器
   * @param dataBaseName 数据库名称
   * @returns 对应的连接管理器,如果不存在则返回undefined
   */
  private getPcm(dataBaseName: string) {
    return this.pcmMap.get(dataBaseName);
  }

  /**
   * 添加新的连接管理器
   * @param dataBaseName 数据库名称
   * @description 如果已存在相同数据库名称的连接管理器,则不会重复添加
   */
  private addPcm(dataBaseName: string) {
    if (this.pcmMap.has(dataBaseName)) return;
    this.pcmMap.set(dataBaseName, new ProjectConnectionManager(dataBaseName));
  }

  /**
   * 获取写连接
   * @param dataBaseName 数据库名称
   * @param autoConnect 是否自动连接,默认为false
   * @returns 写连接实例
   * @description 自动创建连接管理器(如果不存在),并返回写连接。写连接为单例模式,用于串行写操作
   */
  getWriteConn(dataBaseName: string, autoConnect = false) {
    this.addPcm(dataBaseName);
    const pcm = this.getPcm(dataBaseName);
    return pcm.getWriteConnection(autoConnect);
  }

  /**
   * 获取读连接
   * @param dataBaseName 数据库名称
   * @returns 读连接实例
   * @description 自动创建连接管理器(如果不存在),并返回读连接。读连接支持并发,使用完需要将inUse标记为false
   */
  getReadConn(dataBaseName: string) {
    this.addPcm(dataBaseName);
    const pcm = this.getPcm(dataBaseName);
    return pcm.getReadConnection();
  }

  /**
   * 关闭指定数据库的所有连接并移除连接管理器
   * @param dataBaseName 数据库名称
   * @description 关闭所有连接(包括读连接、写连接)并从管理器中移除
   */
  async closePcm(dataBaseName: string) {
    const pcm = this.getPcm(dataBaseName);
    if (!pcm) return;
    await pcm.closeAllConnection();
    this.pcmMap.delete(dataBaseName);
  }
}

/** 导出PCMM的全局单例实例 */
export const pcmm = new PCMM();

基本使用

// 获取读连接
const readConn = await pcmm.getReadConn(dataBaseName);
try {
  // 使用读连接进行查询操作
  const result = await readConn.query(...);
} finally {
  // 重要:使用完后必须将连接标记为未使用
  readConn.inUse = false;
}

// 获取写连接
const writeConn = await pcmm.getWriteConn(dataBaseName);
try {
  // 使用写连接进行事务操作
  await writeConn.transaction(async manager => {
    // 执行写操作
    await manager.save(...);
  });
} catch (error) {
  // 处理错误
  console.error(error);
}

总结

本文详细介绍了桌面端绘图软件的数据管理架构与事务封装设计。以下是关键要点回顾:

  1. 数据管理架构

    • 采用应用数据库与项目数据库分离的设计,应用数据库存储全局配置和用户信息,项目数据库以独立文件形式存储每个项目的完整数据。
    • 该设计实现了项目独立性,便于用户迁移和共享项目数据,同时确保数据的安全性和隐私性。
    • 通过读写分离的数据库连接策略,优化了数据访问性能,提升了系统的响应速度。
  2. 事务封装设计

    • 设计了通用的transaction函数,支持数据操作的原子性和一致性,确保事务要么完全成功,要么完全失败。
    • 提供灵活的项目锁定机制,防止并发操作导致的数据冲突。
    • 支持操作步骤记录,为撤销和恢复功能提供基础。
    • 在事务提交后,通过WebSocket实时通知客户端,支持多用户协作和实时更新。
  3. StepManager步骤管理器

    • 负责初始化新步骤、合并变更、提交步骤到数据库,并管理步骤索引和回退操作。
    • 提供服务实例的访问接口,确保每个项目实例独立运行,支持多项目并行操作。

通过上述设计,软件不仅实现了高效、灵活的数据管理,还提升了用户体验和系统的可扩展性。