详解Electron中如何使用SQLite存储笔记

前言

上一篇,我们使用 remirror 实现了一个简单的 markdown 编辑器。接下来,我们要学习如何去存储这些笔记。

当然了,你也可以选择不使用数据库,不过若是你以后需要将该应用上架到 mac Apple Store ,就需要考虑这个了。因为上架 mac 应用需要启用 sandbox,当你第一次访问笔记中的媒体文件时,都要打开选择文件的弹窗,通过让用户主动选择来授权访问沙箱外的媒体文件。不过,如果你的媒体文件在第一次选择插入文档时复制到 sandbox 中,以后访问优先从沙箱容器中读取,那是不需要授权的。虽然我也可以这么做,但这里考虑到后面的功能,还是选择使用数据库,当需要导出笔记时再从数据库中导出。

数据库的选择

Electron 应用中常使用的数据库是 SQLiteIndexedDBIndexedDB 是在前端网页中去操作。有的文章里说 IndexedDB 的性能会比 SQLite 更好,大家看实际场景去选择使用。大多数桌面应用或者 App 需要使用数据库的时候一般都是用 SQLite

npm 上有两个最常用的 sqlite3 库,一是 better-sqlite3 ,一是 node-sqlite ,两种各有特点。前者是同步的 api ,执行速度快,后者是异步 api ,执行速度相对慢一点。值得注意的是,后者的编译支持 arm 机器,而且由于出的比较早,和其他库配合使用很方便。

安装

安装 node-sqlite

// 仓库名是 node-sqlite, package 名是 sqlite3
yarn add sqlite3

借助 Knex.js 简化数据库操作

Knex.js是为Postgres,MSSQL,MySQL,MariaDB,SQLite3,Oracle和Amazon Redshift设计的 SQL 查询构建器

安装 Knex.js

yarn add knex

创建表

现在,我们要开始设计数据库结构了。我们大概需要 3 张表,笔记本表,笔记表,还有一个媒体文件表。sqlite 支持 blob 数据类型,所以你也可以把媒体文件的二进制数据存到数据库中。这里我们就简单的记个 id ,把媒体文件存到沙箱内。

我们确定一下三张表的表名,notebooks, notes, media, 然后看一下该如何使用 Knex.js 创建表

import { app } from "electron";
import knex, { Knex } from "knex";
import { join } from "path";
import { injectable } from "inversify";
@injectable()
export class LocalDB {
 declare db: Knex;
 async init() {
 this.db = knex({
 client: "sqlite",
 useNullAsDefault: true,
 connection: {
 filename: join(app.getPath("userData"), "local.db"),
 },
 });
 // 新建表
 await this.sync();
 }
 async sync() {
 // notebooks
 await this.db.schema.hasTable("notebooks").then((exist) => {
 if (exist) return;
 return this.db.schema.createTable("notebooks", (table) => {
 table.bigIncrements("id", { primaryKey: true });
 table.string("name");
 table.timestamps(true, true);
 });
 });
 // notes
 await this.db.schema.hasTable("notes").then((exist) => {
 if (exist) return;
 return this.db.schema.createTable("notes", (table) => {
 table.bigIncrements("id", { primaryKey: true });
 table.string("name");
 table.text("content");
 table.bigInteger("notebook_id");
 table.timestamps(true, true);
 });
 });
 // media
 await this.db.schema.hasTable("media").then((exist) => {
 if (exist) return;
 return this.db.schema.createTable("media", (table) => {
 table.bigIncrements("id", { primaryKey: true });
 table.string("name");
 table.string("local_path"); // 本地实际路径
 table.string("sandbox_path"); // 沙盒中的地址
 table.bigInteger("note_id");
 table.timestamps(true, true);
 });
 });
 }
}

这里我用了一个 IOC 库 inversify, 后面遇到 injectableinjectioc.get等写法都是和这个有关,这里我就不多介绍了,具体用法可以看文档或其他文章。

注意:三张表中,notemedia 都一个外键,这里我简化了,并没有用 api 去创建。

Service

数据库表创建完了,接下来我们为表的操作写相关服务,这一块我是参考传统后端 api 的设计去写的,有 Service(数据库) 和 Controller(业务),以 Notebook 为例:

import { inject, injectable } from "inversify";
import { LocalDB } from "../db";
interface NotebookModel {
 id: number;
 name: string;
 create_at?: string | null;
 update_at?: string | null;
}
@injectable()
export class NotebooksService {
 name = "notebooks";
 constructor(@inject(LocalDB) public localDB: LocalDB) {}
 async create(data: { name: string }) {
 return await this.localDB.db.table(this.name).insert(data);
 }
 async get(id: number) {
 return await this.localDB.db
 .table<NotebookModel>(this.name)
 .select("*")
 .where("id", "=", id)
 .first();
 }
 async delete(id: number) {
 return await this.localDB.db.table(this.name).where("id", "=", id).delete();
 }
 async update(data: { id: number; name: string }) {
 return await this.localDB.db
 .table(this.name)
 .where("id", "=", data.id)
 .update({ name: data.name });
 }
 async getAll() {
 return await this.localDB.db.table<NotebookModel>(this.name).select("*");
 }
}

Service 只负责数据库的连接和表中数据的增删改查。

Controller

Controller 可以通过接入 Service 操作数据库,并做一些业务上的工作。

import { inject, injectable } from "inversify";
import { NotebooksService } from "../services/notebooks.service";
import { NotesService } from "../services/notes.service";
@injectable()
export class NotebooksController {
 constructor(
 @inject(NotebooksService) public service: NotebooksService,
 @inject(NotesService) public notesService: NotesService
 ) {}
 async create(name: string) {
 await this.service.create({
 name,
 });
 }
 async delete(id: number) {
 const row = await this.service.get(id);
 if (row) {
 const notes = await this.notesService.getByNotebookId(id);
 if (notes.length) throw Error("delete failed");
 await this.service.delete(id);
 }
 }
 async update(data: { id: number; name: string }) {
 return await this.service.update(data);
 }
 async getAll() {
 return await this.service.getAll();
 }
}

业务

如何创建笔记本?

我们先来实现创建笔记本,之后的删除笔记本,更新笔记本名称等等,依葫芦画瓢就行。我们在界面上添加一个创建按钮。

点击后就会出现这样一个弹窗,这里 UI 库我是用的 antd 做的。

看一下这个弹窗部分的逻辑

import { Modal, Form, Input } from "antd";
import React, { forwardRef, useImperativeHandle, useState } from "react";
interface CreateNotebookModalProps {
 onCreateNotebook: (name: string) => Promise<void>;
}
export interface CreateNotebookModalRef {
 setVisible: (visible: boolean) => void;
}
export const CreateNotebookModal = forwardRef<
 CreateNotebookModalRef,
 CreateNotebookModalProps
>((props, ref) => {
 const [modalVisible, setMoalVisible] = useState(false);
 const [form] = Form.useForm();
 const handleOk = () => {
 form.validateFields().then(async (values) => {
 await props.onCreateNotebook(values.name);
 setMoalVisible(false);
 });
 };
 useImperativeHandle(ref, (): CreateNotebookModalRef => {
 return {
 setVisible: setMoalVisible,
 };
 });
 return (
 <Modal
 visible={modalVisible}
 title="创建笔记本"
 onCancel={() => setMoalVisible(false)}
 onOk={handleOk}
 cancelText="取消"
 okText="确定"
 destroyOnClose
 >
 <Form form={form}>
 <Form.Item
 label="笔记本名称"
 name="name"
 rules={[
 {
 required: true,
 message: "请填写名称",
 },
 {
 whitespace: true,
 message: "禁止使用空格",
 },
 { min: 1, max: 100, message: "字符长度请保持在 1-100 之间" },
 ]}
 >
 <Input />
 </Form.Item>
 </Form>
 </Modal>
 );
});

外部提供的 onCreateNotebook 的实现:

const handleCreateNotebook = async (name: string) => {
 await window.Bridge?.createNotebook(name);
 const data = await window.Bridge?.getNotebooks();
 if (data) {
 setNotebooks(data);
 }
};

上面出现的 Bridge 是我在第一篇中讲的 preload.js 提供的对象,它可以帮我们和 electron 主进程通信。

接来写,我们具体看一下 preload 和 主进程部分的实现:

// preload.ts
import { contextBridge, ipcRenderer, MessageBoxOptions } from "electron";
contextBridge.exposeInMainWorld("Bridge", {
 showMessage: (options: MessageBoxOptions) => {
 ipcRenderer.invoke("showMessage", options);
 },
 createNotebook: (name: string) => {
 return ipcRenderer.invoke("createNotebook", name);
 },
 getNotebooks: () => {
 return ipcRenderer.invoke("getNotebooks");
 },
});

实际还是用 ipcRenderer 去通信,但是这种方式更好

// main.ts
import { ipcMain } from "electron"
ipcMain.handle("createNotebook", async (e, name: string) => {
 return await ioc.get(NotebooksController).create(name);
});
ipcMain.handle("getNotebooks", async () => {
 return await ioc.get(NotebooksController).getAll();
});

总结

最后,我们来看一下这部分的完整交互:

这一篇,我们主要学习了如何在 Elctron 使用 SQLite 数据库,并且简单完成了 CRUD 中的 C。相关代码在 Github 上,感兴趣的同学可以自行查看。

作者:夜焱辰

%s 个评论

要回复文章请先登录注册