{T}

桌面应用开发需要掌握哪些数据库知识(上)?

我们在上一节中成功引入了 better-sqlite 和 knex.js 库,现在我们有能力把业务数据存储到数据库中了,也有能力从数据库中读取业务数据了,但是对于一个前端开发人员来说,操作 SQLite 这种关系型数据库可能会有无从下手的感觉,本节我们就介绍一下在开发桌面应用的过程中需要掌握哪些最基本的数据库操作知识。

如果你对数据库相关的知识不熟悉,而且没找到适合你的 SQLite 学习资料,那么你不妨看看 MySQL 的资料,这两个数据库在很多基础知识上是很相似的。

关于使用Knex.js操作数据库的知识,请参阅Knex.js官方文档:Knex.js Doc

访问数据库

到目前为止,我们需要的工具还没有准备好,你需要一件趁手的数据库设计器,这里我推荐SQLite Expert,个人版是免费的,如下图所示是我使用这个工具设计的一个数据库:

数据库中有两张表 Message 和 Chat,截图是 Chat 表的列(后面的章节我们还会用到这个数据库),本节我们还不涉及到多表操作,所以暂时只介绍 Chat 表的内容。

数据库设计好之后,我们要创建一个数据库访问类,由于主进程的逻辑和渲染进程的逻辑都有可能会访问数据库,所以我们把数据库访问类放置在src\common目录下,方便两个进程的逻辑代码使用这个类,代码如下:

ts
//src\common\db.ts
import knex, { Knex } from "knex";
import fs from "fs";
import path from "path";
let dbInstance: Knex;
if (!dbInstance) {
  let dbPath = process.env.APPDATA || (process.platform == "darwin" ? process.env.HOME + "/Library/Preferences" : process.env.HOME + "/.local/share");
  dbPath = path.join(dbPath, "electron-jue-jin/db.db");
  let dbIsExist = fs.existsSync(dbPath);
  if (!dbIsExist) {
    let resourceDbPath = path.join(process.execPath, "../resources/db.db");
    fs.copyFileSync(resourceDbPath, dbPath);
  }
  dbInstance = knex({
    client: "better-sqlite3",
    connection: { filename: dbPath },
    useNullAsDefault: true,
  });
}
export let db = dbInstance;

这段代码导出一个数据库访问对象,只有第一次引入这个数据库访问对象的时候才会执行此对象的初始化逻辑,也就是说,无论我们在多少个组件中引入这个数据库访问对象,它只会被初始化一次,但这个约束只局限在一个进程内,也就是说对于整个应用而言,主进程有一个 db 实例,渲染进程也有一个 db 实例,两个实例是完全不同的

由于渲染进程内的数据库访问对象和主进程内的数据库访问对象不是同一个对象,所以会有并发写入数据的问题,你需要控制好你的业务逻辑,避免两个进程在同一时间写入相同的业务数据。

SQLite 不支持并发写入数据,两个或两个以上的写入操作同时执行时,只有一个写操作可以成功执行,其他写操作会失败。并发读取数据没有问题。

第一次初始化数据库链接对象时,我们会检查C:\Users\[username]\AppData\Roaming\[appname]\db.db文件是否存在,如果不存在,我们就从应用程序安装目录C:\Program Files\[appname]\resources\db.db拷贝一份到该路径下,所以我们要提前把数据库设计好,基础数据也要初始化好,制作安装包的时候,把数据库文件打包到安装包里

我们是通过为plugins\buildPlugin.ts增加配置来把数据库文件打包到安装包内的,其中关键的配置代码如下所示:

ts
//plugins\buildPlugin.ts
//buildInstaller方法内option.config的一个属性
extraResources: [{ from: `./src/common/db.db`, to: `./` }],

这是 electron-builder 的一项配置:extraResources,可以让开发者为安装包指定额外的资源文件,electron-builder 打包应用时会把这些资源文件附加到安装包内,当用户安装应用程序时,这些资源会释放在安装目录的 resources\子目录下。

关于extraResources的详细配置信息请参阅官方文档

可能有同学会问,为什么要如此麻烦把数据库拷贝到C:\Users\[username]\AppData\Roaming\[appname]\目录下再访问,为什么不直接访问安装目录下的数据库文件呢?这是因为当用户升级应用程序时安装目录下的文件都会被删除,因为我们可能会在数据库中放置很多用户数据,这样的话每次升级应用用户这些数据就都没了

我们假定数据库是整个应用的核心组件,没有它数据库应用程序无法正常运行,所以初始化数据库的逻辑都是同步操作(fs.copyFileSync),注意这类以 Sync 结尾的方法都是同步操作,它们是会阻塞 JavaScript 的执行线程的,也就是说在它们执行过程中,其他任何操作都会处于阻塞状态,比如以 setInterval 注册的定时器不会按照预期执行,只有等同步操作执行完成之后 JavaScript 的执行线程才会继续执行被阻塞的方法,所以应用中一定要谨慎使用同步操作。除了 Node.js 提供的类似 fs.copyFileSync 这样的方法外,还有 Electron 提供的 dialog.showOpenDialogSync 这样的方法,好在同步方法一般都有对应的异步方法来替代。

实际上对于真实的产品来说,不一定在这里使用同步操作,最好根据你的应用程序的情况来实现这部分逻辑,在需要使用数据库之前把数据库初始化好即可。

在应用程序开发调试阶段,开发者可以先把设计好的数据库文件放置在目标路径 AppData\Roaming[appname]\ 下,这样调试应用就会方便很多。

db.ts 文件导出的是一个 Knex 类型的对象,初始化这个对象时,我们传入了一个配置对象,配置对象的 client 属性代表着使用什么模块访问数据库,这里我们要求 Knex 使用better-sqlite3访问数据库,Knex 支持很多数据库,比如MySqlOracleSqlServer等,都有对应的数据库访问模块。由于 SQLite 是一个客户端数据库,所以我们只要把数据库的本地路径告知 Knex 即可,这个属性是通过配置对象的 connection 属性提供的。配置对象的 useNullAsDefault 属性告知 Knex 把开发者未明确提供的数据配置为 Null。

接下来我们就尝试使用这个数据库访问对象把 Chat 表的数据检索出来,代码如下所示:

ts
// src\renderer\main.ts
import { db } from "../common/db";
db("Chat")
  .first()
  .then((obj) => {
    console.log(obj);
  });

在这段代码中,我们首先创建了一个数据库连接对象 db,接着使用这个对象读取 Chat 表里的第一行记录,数据读取成功后把这行数据打印到控制台。

数据库的基本操作:增加数据

在上一节中我们创建了聊天会话表,值得注意的是这个表的 id 字段是表的主键,主键是不允许重复的,这也是为什么我们使用crypto.randomUUID()生成主键的原因(详见:《如何引入 pinia 并管控应用的数据状态》)。

先来看增加一行数据的操作,代码如下所示:

ts
//src\renderer\Window\WindowMain\Contact.vue
let insertData = async () => {
  let model = new ModelChat();
  model.fromName = "聊天对象";
  model.sendTime = Date.now();
  model.lastMsg = "这是此会话的最后一条消息";
  model.avatar = `https://pic3.zhimg.com/v2-306cd8f07a20cba46873209739c6395d_im.jpg?source=32738c0c`;
  await db("Chat").insert(model);
};

这段代码中用到了我们之前定义的ModelChat对象,这个对象所拥有的属性与数据库表中的列是同名的,这样db("Chat").insert(model)执行时,model对象中每个属性所持有的数据就会被插入对应的表的列中。

如果要在同一张表中增加多行数据,那么可以直接把一个数组提交给数据库,代码如下所示:

ts
//src\renderer\Window\WindowMain\Contact.vue
let insertMultiData = async () => {
  let result = [];
  for (let i = 0; i < 10; i++) {
    let model = new ModelChat();
    model.fromName = "聊天对象" + i;
    model.sendTime = Date.now();
    model.lastMsg = "这是此会话的最后一条消息" + i;
    model.avatar = `https://pic3.zhimg.com/v2-306cd8f07a20cba46873209739c6395d_im.jpg?source=32738c0c`;
    result.push(model);
  }
  result[5].isSelected = true;
  await db("Chat").insert(result);
};

最终数据库中的数据如下图所示:

数据的基本操作:查询数据

查询数据的代码如下所示:

ts
//src\renderer\Window\WindowMain\Contact.vue
let selectData = async () => {
  let data = await db("Chat").where({ id: `256d6532-fcfe-4b81-a3f8-ee940f2de3e3` }).first();
  console.log(data);
};

在上面的代码中,我们通过where方法定位 id 为256d6532-fcfe-4b81-a3f8-ee940f2de3e3的数据行,由于 id 是唯一的,所以我们这个查询只会得到一行记录。

但是 knex 事先无法知道查询结果的数量,所以任何查询都将返回一个数据列表,我们使用first方法得到数据列表的第一行。

knex 提供了很多查询方法,比如:whereBetweenwhereLikewhereIn等,这里不再赘述,你可以自行查阅 knex 官网。

数据的基本操作:修改数据

修改数据的代码如下所示:

ts
//src\renderer\Window\WindowMain\Contact.vue
let updateData = async () => {
  let data = await db("Chat").update({ fromName: "三岛由纪夫", lastMsg: "就在刀刃猛然刺入腹部的瞬间,一轮红日在眼睑背面粲然升了上来。" }).where({ id: `256d6532-fcfe-4b81-a3f8-ee940f2de3e3` });
  console.log(data);
};

我们使用 update 方法来修改数据库中的数据,哪些字段需要更新提供哪些字段的值即可。如果没有 where 方法确定更新范围,那么整个表的数据都将被修改掉。(数据库操作返回的值 data 为受影响的行数。)

数据的基本操作:删除数据

删除数据的代码如下所示:

ts
//src\renderer\Window\WindowMain\Contact.vue
let deleteData = async () => {
  let data = await db("Chat").where({ id: `256d6532-fcfe-4b81-a3f8-ee940f2de3e3` }).delete();
  console.log(data);
};

我们使用 delete 方法删除数据库中的数据,同样需要使用 where 方法确定删除范围,不然整个表的数据都将被删除(数据库操作返回的值 data 为受影响的行数)。

总结

本节我们介绍了一些数据库操作的基础知识,在正式操作数据库中的数据之前,我们首先介绍如何把数据库文件放置在终端用户的数据目录下。这是通过 electron-builder 的 extraResources 配置完成的。在这部分我们还介绍了为什么开发者要谨慎使用 Node.js 和 Electron 提供的同步接口。

接着我们介绍了如何建立数据库连接对象,而且还介绍了主进程和渲染进程并发写入数据库时要注意的问题。

最后我们介绍了如何使用 Knex.js 模块完成数据库基本的增删改查操作,这部分内容相对简单,主要是为了熟悉 Knex 链式访问数据的操作。

很显然,单单这些数据库知识是不够完成一个复杂的应用的,后续将进一步介绍更复杂的数据库操作知识。

源码

本节示例代码请通过如下地址自行下载:

源码仓储