手把手教你使用 better-sqlite3:Node.js SQLite 数据库连接与查询 – wiki基地


手把手教你使用 better-sqlite3:Node.js SQLite 数据库连接与查询

在现代的 Node.js 应用开发中,数据存储是不可或缺的一环。对于需要轻量级、无需独立服务器进程、且能够直接在文件系统中存储数据的场景,SQLite 无疑是最佳选择之一。无论是桌面应用、移动应用(通过某些框架集成)、本地缓存,还是开发原型,SQLite 都展现出其独特的优势。

在 Node.js 生态系统中,有多种与 SQLite 交互的库,但 better-sqlite3 以其卓越的性能、同步的 API 设计以及极佳的易用性脱颖而出。它通过 C++ 插件直接与 SQLite 引擎交互,避免了 Node.js 异步回调的复杂性,使得数据库操作更加直观和高效。

本篇文章将手把手地带领你从零开始,深入学习 better-sqlite3 的使用。我们将涵盖从环境搭建、数据库连接、基本的 CRUD(创建、读取、更新、删除)操作,到事务管理、预处理语句、用户自定义函数以及性能优化等高级特性。目标是让你全面掌握 better-sqlite3,并能在实际项目中自信地运用它。


第一章:前置准备与环境搭建

在开始之前,请确保你的开发环境已经满足以下条件:

  1. Node.js 环境: 请确保你的机器上安装了 Node.js。你可以在 Node.js 官网 下载并安装最新版本。
  2. npm 或 yarn: 这是 Node.js 的包管理器,通常随 Node.js 一起安装。

1.1 创建项目目录并初始化

首先,我们创建一个新的项目目录,并使用 npmyarn 初始化项目。

bash
mkdir better-sqlite3-demo
cd better-sqlite3-demo
npm init -y # 或 yarn init -y

npm init -y 命令会快速创建一个 package.json 文件,其中包含项目的基础信息。

1.2 安装 better-sqlite3

接下来,我们安装核心库 better-sqlite3

bash
npm install better-sqlite3 # 或 yarn add better-sqlite3

安装完成后,你会在 node_modules 目录中看到 better-sqlite3,并且 package.jsondependencies 字段中会添加相应的依赖。


第二章:初探 better-sqlite3:连接与创建数据库

现在,我们已经准备好开始编写代码了。首先,我们将学习如何连接到 SQLite 数据库以及如何创建表。

2.1 引入 better-sqlite3

在你的 JavaScript 文件(例如 app.js)中,引入 better-sqlite3 模块:

javascript
const Database = require('better-sqlite3');

2.2 连接到数据库

better-sqlite3Database 构造函数用于建立与 SQLite 数据库的连接。它接受一个文件路径作为参数。

“`javascript
// app.js

const Database = require(‘better-sqlite3’);

// 1. 连接到数据库
// 如果文件不存在,better-sqlite3 会自动创建它
const db = new Database(‘my_database.db’);
console.log(‘成功连接到数据库 my_database.db’);

// 2. 关闭数据库连接
// 这是一个非常重要的步骤,确保在程序退出时关闭数据库连接
// 否则可能会导致数据损坏或文件锁定
db.close();
console.log(‘数据库连接已关闭’);
“`

Database 构造函数的参数选项:

  • filePath (字符串): 数据库文件的路径。
    • 如果文件不存在,better-sqlite3 会创建它。
    • 传入 ':memory:' 可以创建一个纯内存数据库,这在测试或临时数据存储时非常有用,因为它不会在磁盘上留下任何文件,并且程序退出后数据就会消失。
    • 传入 '/path/to/my.db', { readonly: true } 可以以只读模式打开数据库。
  • options (对象,可选):
    • readonly (布尔值):如果为 true,则以只读模式打开数据库。默认 false
    • memory (布尔值):如果为 true,则在内存中创建数据库。等同于 filePath = ':memory:'
    • verbose (函数):一个可选的回调函数,用于打印所有执行的 SQL 语句到控制台,这对于调试非常有用。
    • fileMustExist (布尔值):如果为 true,并且数据库文件不存在,则会抛出错误而不是创建文件。
    • timeout (数字):如果数据库繁忙被锁定,等待的毫秒数。默认 5000ms。

示例:使用内存数据库和 verbose 模式

“`javascript
const Database = require(‘better-sqlite3’);

function logSql(sql) {
console.log(‘[SQL]’, sql);
}

const dbInMemory = new Database(‘:memory:’, { verbose: logSql });
console.log(‘成功连接到内存数据库’);

dbInMemory.exec(CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
);
);
console.log(‘在内存数据库中创建了 users 表’);

dbInMemory.close();
console.log(‘内存数据库连接已关闭’);
“`

2.3 创建表(Schema 定义)

数据库连接建立后,我们就可以执行 SQL 语句来创建表了。better-sqlite3 提供了 db.exec() 方法来执行不需要返回结果的 SQL 语句,例如 CREATE TABLEALTER TABLEDROP TABLE 等。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

try {
// 使用 db.exec() 执行多行 SQL 语句
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);

    CREATE TABLE IF NOT EXISTS products (
        product_id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        price REAL NOT NULL,
        stock INTEGER DEFAULT 0
    );
`);
console.log('数据库表创建或已存在。');

} catch (error) {
console.error(‘创建表时发生错误:’, error.message);
} finally {
db.close();
}
“`

CREATE TABLE IF NOT EXISTS 语句非常有用,它会检查表是否已经存在。如果存在,就不会尝试再次创建,从而避免了重复创建表的错误。


第三章:核心操作:数据查询与写入 (CRUD)

现在我们有了数据库和表,是时候学习如何进行数据的增删改查(CRUD)操作了。better-sqlite3 主要通过 db.prepare() 结合 .run(), .get(), .all(), .iterate() 来执行 SQL 语句。

3.1 预处理语句 (Prepared Statements) 的重要性

better-sqlite3 中,强烈推荐使用预处理语句 (db.prepare()) 来执行所有涉及到变量的 SQL 操作。预处理语句带来了多方面的好处:

  1. 安全性: 有效防止 SQL 注入攻击。通过占位符绑定参数,数据库会区分代码和数据,不会将用户输入作为 SQL 代码的一部分执行。
  2. 性能: 数据库引擎会对预处理语句进行编译和优化。如果同一条 SQL 语句需要执行多次(只是参数不同),复用预处理语句可以显著提高性能,避免重复编译。
  3. 可读性: 使 SQL 语句更清晰,参数与 SQL 逻辑分离。

预处理语句的创建:

javascript
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');

占位符:
better-sqlite3 支持多种占位符:
* ? 位置占位符,按照参数的顺序传入。
* ?N 具名位置占位符,?1, ?2 等。
* $name:name@name 具名占位符,传入一个对象,属性名与占位符匹配。

3.2 插入数据 (CREATE)

我们使用 db.prepare().run() 来执行 INSERT 语句。run() 方法会返回一个 info 对象,其中包含 changes (受影响的行数) 和 lastInsertRowid (最后插入行的 ID)。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

// 插入单个用户
function insertUser(name, email, age) {
const stmt = db.prepare(‘INSERT INTO users (name, email, age) VALUES (?, ?, ?)’);
const info = stmt.run(name, email, age);
console.log(插入用户 ${name},ID: ${info.lastInsertRowid},影响行数: ${info.changes});
return info.lastInsertRowid;
}

// 插入多个用户
const userId1 = insertUser(‘Alice’, ‘[email protected]’, 30);
const userId2 = insertUser(‘Bob’, ‘[email protected]’, 25);
// 尝试插入一个邮箱重复的用户,会因为 UNIQUE 约束而报错
try {
insertUser(‘Charlie’, ‘[email protected]’, 35);
} catch (error) {
console.error(‘插入 Charlie 失败:’, error.message);
}

db.close();
“`

注意: 如果你尝试插入一个与 UNIQUE 约束冲突的记录,或者其他违反数据库约束的操作,better-sqlite3 会抛出异常,你需要使用 try...catch 来捕获这些错误。

3.3 查询数据 (READ)

better-sqlite3 提供了多种方法来查询数据:get() (获取单行)、all() (获取所有匹配行)、iterate() (迭代器)。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

// 确保有数据可以查询
db.exec(CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Alice', '[email protected]', 30);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Bob', '[email protected]', 25);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Charlie', '[email protected]', 35);
);

// 1. 获取单行数据 (get())
function getUserById(id) {
const stmt = db.prepare(‘SELECT * FROM users WHERE id = ?’);
const user = stmt.get(id); // 返回一个对象或 undefined
return user;
}

console.log(‘\n— 获取单个用户 —‘);
const user1 = getUserById(1);
console.log(‘ID 为 1 的用户:’, user1); // { id: 1, name: ‘Alice’, email: ‘[email protected]’, age: 30 }
const user99 = getUserById(99);
console.log(‘ID 为 99 的用户:’, user99); // undefined

// 2. 获取所有匹配数据 (all())
function getUsersByAgeGreaterThan(minAge) {
const stmt = db.prepare(‘SELECT name, email FROM users WHERE age > ? ORDER BY name’);
const users = stmt.all(minAge); // 返回一个对象数组
return users;
}

console.log(‘\n— 获取年龄大于 28 的用户 —‘);
const usersOver28 = getUsersByAgeGreaterThan(28);
console.log(‘年龄大于 28 的用户:’, usersOver28);
// [
// { name: ‘Alice’, email: ‘[email protected]’ },
// { name: ‘Charlie’, email: ‘[email protected]’ }
// ]

// 3. 迭代获取数据 (iterate()) – 适用于处理大量数据以节省内存
function iterateAllUsers() {
const stmt = db.prepare(‘SELECT id, name FROM users’);
console.log(‘\n— 迭代所有用户 —‘);
for (const user of stmt.iterate()) {
console.log(用户 ID: ${user.id}, 姓名: ${user.name});
}
}
iterateAllUsers();
// 用户 ID: 1, 姓名: Alice
// 用户 ID: 2, 姓名: Bob
// 用户 ID: 3, 姓名: Charlie

// 4. 查询 count
function getUserCount() {
const stmt = db.prepare(‘SELECT COUNT(*) AS count FROM users’);
const result = stmt.get();
return result.count;
}
console.log(‘\n— 用户总数 —‘);
console.log(‘用户总数:’, getUserCount()); // 3

db.close();
“`

3.4 更新数据 (UPDATE)

更新操作也使用 db.prepare().run()info.changes 会告诉你实际更新了多少行。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

db.exec(CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Alice', '[email protected]', 30);
);

function updateUserAge(id, newAge) {
const stmt = db.prepare(‘UPDATE users SET age = ? WHERE id = ?’);
const info = stmt.run(newAge, id);
console.log(更新用户 ID ${id} 的年龄到 ${newAge},影响行数: ${info.changes});
return info.changes;
}

console.log(‘\n— 更新用户 —‘);
updateUserAge(1, 31); // 影响行数: 1
updateUserAge(99, 40); // 影响行数: 0 (因为 ID 99 不存在)

// 验证更新
const updatedUser = db.prepare(‘SELECT * FROM users WHERE id = ?’).get(1);
console.log(‘更新后的用户 1:’, updatedUser); // age 变为 31

db.close();
“`

3.5 删除数据 (DELETE)

删除操作同样使用 db.prepare().run()info.changes 会告诉你实际删除了多少行。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

db.exec(CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Alice', '[email protected]', 30);
INSERT OR IGNORE INTO users (name, email, age) VALUES ('Bob', '[email protected]', 25);
);

function deleteUserById(id) {
const stmt = db.prepare(‘DELETE FROM users WHERE id = ?’);
const info = stmt.run(id);
console.log(删除用户 ID ${id},影响行数: ${info.changes});
return info.changes;
}

console.log(‘\n— 删除用户 —‘);
console.log(‘删除前用户总数:’, db.prepare(‘SELECT COUNT(*) AS count FROM users’).get().count); // 2

deleteUserById(1); // 影响行数: 1
deleteUserById(99); // 影响行数: 0

console.log(‘删除后用户总数:’, db.prepare(‘SELECT COUNT(*) AS count FROM users’).get().count); // 1

db.close();
“`


第四章:进阶特性:让你的数据库操作更强大

better-sqlite3 不仅仅是简单的 CRUD,它还提供了许多高级特性,可以帮助你构建更健壮、高效和功能丰富的应用。

4.1 事务 (Transactions)

事务是一系列数据库操作的逻辑单元,这些操作要么全部成功提交(COMMIT),要么全部失败回滚(ROLLBACK)。事务保证了数据库的 ACID 特性(原子性、一致性、隔离性、持久性)。

better-sqlite3 中,处理事务非常简单和强大。它提供了 db.transaction() 方法,这是一个高阶函数,接收一个包含所有事务操作的函数作为参数。

事务的重要性:
* 数据完整性: 确保一组操作要么全部完成,要么全部不完成,避免部分数据更新导致的不一致状态。
* 性能提升: 对于批量插入、更新或删除操作,将它们包装在一个事务中可以显著提高性能,因为磁盘I/O和日志写入的开销可以被分摊。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

db.exec(CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
balance REAL NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO accounts (name, balance) VALUES ('Alice', 100);
INSERT OR IGNORE INTO accounts (name, balance) VALUES ('Bob', 50);
);

// 使用 db.transaction()
const transfer = db.transaction((fromAccountId, toAccountId, amount) => {
const deductStmt = db.prepare(‘UPDATE accounts SET balance = balance – ? WHERE id = ?’);
const addStmt = db.prepare(‘UPDATE accounts SET balance = balance + ? WHERE id = ?’);

// 尝试扣款
const deductInfo = deductStmt.run(amount, fromAccountId);
if (deductInfo.changes === 0) {
    throw new Error(`账户 ID ${fromAccountId} 不存在或余额不足`);
}

// 模拟一个潜在的错误,如果注释掉,事务将成功
// if (toAccountId === 99) {
//     throw new Error('目标账户不存在,模拟错误回滚');
// }

// 尝试加款
const addInfo = addStmt.run(amount, toAccountId);
if (addInfo.changes === 0) {
    throw new Error(`账户 ID ${toAccountId} 不存在`);
}

console.log(`成功从账户 ${fromAccountId} 转账 ${amount} 到账户 ${toAccountId}`);

});

console.log(‘\n— 事务转账示例 —‘);
try {
// 转账成功案例
console.log(‘转账前:’);
console.log(db.prepare(‘SELECT * FROM accounts WHERE id IN (1, 2)’).all());
transfer(1, 2, 20); // 从 Alice 转 20 给 Bob
console.log(‘转账后:’);
console.log(db.prepare(‘SELECT * FROM accounts WHERE id IN (1, 2)’).all()); // Alice: 80, Bob: 70
} catch (error) {
console.error(‘转账失败 (捕获到错误):’, error.message);
}

try {
// 转账失败案例(目标账户不存在)
console.log(‘\n转账前 (再次查询确保数据一致):’);
console.log(db.prepare(‘SELECT * FROM accounts WHERE id IN (1, 2)’).all());
transfer(1, 99, 10); // 从 Alice 转 10 给一个不存在的账户
console.log(‘转账后 (如果成功):’); // 这行不会执行
console.log(db.prepare(‘SELECT * FROM accounts WHERE id IN (1, 2)’).all());
} catch (error) {
console.error(‘转账失败 (捕获到错误):’, error.message);
console.log(‘事务回滚后:’); // 数据应该保持不变
console.log(db.prepare(‘SELECT * FROM accounts WHERE id IN (1, 2)’).all()); // Alice: 80, Bob: 70
}

db.close();
``
在上述例子中,
db.transaction()方法确保了转账操作的原子性。如果在transfer` 函数内部抛出任何错误,整个事务都会被回滚,数据库状态将恢复到事务开始之前的状态。

批量插入的性能提升:
考虑插入 10000 条记录:

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘:memory:’); // 使用内存数据库测试

db.exec(CREATE TABLE IF NOT EXISTS test_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
value TEXT
);
);

const insertStmt = db.prepare(‘INSERT INTO test_data (value) VALUES (?)’);
const dataToInsert = Array.from({ length: 10000 }, (_, i) => Value-${i});

console.time(‘批量插入(无事务)’);
for (const value of dataToInsert) {
insertStmt.run(value);
}
console.timeEnd(‘批量插入(无事务)’); // 可能会比较慢

db.exec(‘DELETE FROM test_data;’); // 清空数据

const insertMany = db.transaction((data) => {
for (const value of data) {
insertStmt.run(value);
}
});

console.time(‘批量插入(使用事务)’);
insertMany(dataToInsert);
console.timeEnd(‘批量插入(使用事务)’); // 明显更快

db.close();
“`

你会发现,使用事务进行批量插入的速度比逐条插入快几个数量级。

4.2 用户自定义函数 (User-Defined Functions – UDFs)

better-sqlite3 允许你注册自定义的 JavaScript 函数,让它们可以在 SQL 语句中像内置函数一样使用。这极大地扩展了 SQLite 的功能。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

db.exec(CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL
);
INSERT OR IGNORE INTO messages (text) VALUES ('Hello World');
INSERT OR IGNORE INTO messages (text) VALUES ('Node.js is awesome');
);

// 注册一个将字符串反转的 SQL 函数
db.function(‘reverse_string’, (text) => {
if (typeof text !== ‘string’) return null;
return text.split(”).reverse().join(”);
});

// 注册一个计算字符串长度的函数 (SQLite 已有 LENGTH,这里仅作示例)
db.function(‘js_length’, (text) => {
if (typeof text !== ‘string’) return null;
return text.length;
});

console.log(‘\n— 用户自定义函数示例 —‘);

const reversedMessage = db.prepare(‘SELECT reverse_string(text) AS reversedText FROM messages WHERE id = 1’).get();
console.log(‘反转后的消息:’, reversedMessage.reversedText); // dlroW olleH

const messageLength = db.prepare(‘SELECT text, js_length(text) AS textLength FROM messages WHERE id = 2’).get();
console.log(消息 "${messageLength.text}" 的长度: ${messageLength.textLength}); // Node.js is awesome 长度: 18

// 带有参数和复杂逻辑的函数
db.function(‘capitalize_words’, (text, separator = ‘ ‘) => {
if (typeof text !== ‘string’) return null;
return text.split(separator).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(separator);
});

const capitalizedMessage = db.prepare(‘SELECT capitalize_words(text) AS capitalized FROM messages WHERE id = 2’).get();
console.log(‘首字母大写后的消息:’, capitalizedMessage.capitalized); // Node.js Is Awesome

db.close();
“`

4.3 BLOB 数据类型

SQLite 支持 BLOB (Binary Large Object) 数据类型,用于存储二进制数据,如图片、文件、加密数据等。在 better-sqlite3 中,你可以直接使用 Node.js 的 Buffer 对象来存储和检索 BLOB 数据。

“`javascript
const Database = require(‘better-sqlite3’);
const db = new Database(‘my_database.db’);

db.exec(CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
data BLOB NOT NULL
);
);

// 准备一些二进制数据
const imageBuffer = Buffer.from(‘This is some simulated image data.’, ‘utf8’);
const documentBuffer = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]); // 示例二进制数据

// 插入 BLOB 数据
const insertFile = db.prepare(‘INSERT INTO files (filename, data) VALUES (?, ?)’);
insertFile.run(‘sample_image.jpg’, imageBuffer);
insertFile.run(‘secure_doc.bin’, documentBuffer);
console.log(‘\n— 插入 BLOB 数据成功 —‘);

// 查询 BLOB 数据
const getFile = db.prepare(‘SELECT filename, data FROM files WHERE filename = ?’);
const image = getFile.get(‘sample_image.jpg’);
const doc = getFile.get(‘secure_doc.bin’);

if (image) {
console.log(读取文件: ${image.filename});
console.log(‘数据类型:’, typeof image.data, ‘是 Buffer:’, Buffer.isBuffer(image.data)); // data 是 Buffer 对象
console.log(‘数据内容 (UTF-8):’, image.data.toString(‘utf8’));
}

if (doc) {
console.log(读取文件: ${doc.filename});
console.log(‘数据类型:’, typeof doc.data, ‘是 Buffer:’, Buffer.isBuffer(doc.data));
console.log(‘数据内容 (Hex):’, doc.data.toString(‘hex’));
}

db.close();
“`

4.4 异步化与 worker_threads (处理同步 API 的场景)

better-sqlite3 的 API 是同步的,这意味着当执行数据库操作时,Node.js 的事件循环会被阻塞,直到数据库操作完成。对于大多数桌面应用或小规模服务器应用来说,这通常不是问题,因为它简化了代码逻辑。然而,在以下场景中,同步 API 可能会成为瓶颈:

  1. 高并发的 Web 服务器: 如果数据库操作耗时较长,同步阻塞会影响服务器同时处理请求的能力。
  2. 长时间运行的复杂查询: 即使不是高并发,单个耗时查询也会冻结整个 Node.js 进程,影响用户体验。

为了在不改变 better-sqlite3 同步特性的前提下,使其在 Node.js 中表现得“异步”,我们可以利用 Node.jsworker_threads 模块。worker_threads 允许你在独立的线程中运行 JavaScript 代码,从而避免阻塞主事件循环。

工作原理:
1. 主线程创建并启动一个 Worker 线程。
2. 主线程将数据库操作请求发送给 Worker 线程。
3. Worker 线程在自己的独立上下文中执行 better-sqlite3 的同步数据库操作。
4. Worker 线程将结果或错误发送回主线程。
5. 主线程接收到结果后,通过 Promise 或回调进行处理。

示例:使用 worker_threads 封装 better-sqlite3

db.worker.js (Worker 线程文件):

“`javascript
const { parentPort, workerData } = require(‘worker_threads’);
const Database = require(‘better-sqlite3’);

const db = new Database(workerData.filePath, workerData.options);

parentPort.on(‘message’, (message) => {
try {
const { id, method, sql, params } = message;

    let result;
    const stmt = db.prepare(sql);

    switch (method) {
        case 'run':
            result = stmt.run(...params);
            break;
        case 'get':
            result = stmt.get(...params);
            break;
        case 'all':
            result = stmt.all(...params);
            break;
        case 'exec': // For non-query SQL like CREATE, DROP
            result = db.exec(sql);
            break;
        case 'transaction':
            // For transactions, we pass the function string and re-evaluate it
            // Or, more robustly, define a set of named transaction functions
            // and call them by name, passing data.
            // For simplicity here, we'll assume the transaction logic is in the worker.
            // A better approach would be to pass individual operations.
            // This example focuses on single queries, expanding to transactions
            // needs careful design.
            console.warn('Transaction execution in worker not fully implemented for this simple example.');
            break;
        default:
            throw new Error(`Unknown method: ${method}`);
    }
    parentPort.postMessage({ id, error: null, result });
} catch (error) {
    parentPort.postMessage({ id, error: error.message, result: null });
}

});

// Important: Close the database when the worker exits
parentPort.on(‘exit’, () => {
db.close();
console.log(‘Worker数据库连接已关闭。’);
});
“`

app.js (主线程文件):

“`javascript
const { Worker } = require(‘worker_threads’);
const path = require(‘path’);

class AsyncDatabase {
constructor(filePath, options = {}) {
this.worker = new Worker(path.join(__dirname, ‘db.worker.js’), {
workerData: { filePath, options }
});
this.messageIdCounter = 0;
this.pendingMessages = new Map();

    this.worker.on('message', (message) => {
        const { id, error, result } = message;
        const { resolve, reject } = this.pendingMessages.get(id);
        this.pendingMessages.delete(id);

        if (error) {
            reject(new Error(error));
        } else {
            resolve(result);
        }
    });

    this.worker.on('error', (err) => {
        console.error('Worker thread error:', err);
        // Reject all pending messages
        this.pendingMessages.forEach(({ reject }) => reject(err));
        this.pendingMessages.clear();
    });

    this.worker.on('exit', (code) => {
        if (code !== 0) {
            console.error(`Worker stopped with exit code ${code}`);
            this.pendingMessages.forEach(({ reject }) => reject(new Error(`Worker exited with code ${code}`)));
            this.pendingMessages.clear();
        }
    });
}

_sendToWorker(method, sql, params = []) {
    return new Promise((resolve, reject) => {
        const id = this.messageIdCounter++;
        this.pendingMessages.set(id, { resolve, reject });
        this.worker.postMessage({ id, method, sql, params });
    });
}

run(sql, ...params) {
    return this._sendToWorker('run', sql, params);
}

get(sql, ...params) {
    return this._sendToWorker('get', sql, params);
}

all(sql, ...params) {
    return this._sendToWorker('all', sql, params);
}

exec(sql) {
    return this._sendToWorker('exec', sql);
}

close() {
    return this.worker.terminate();
}

}

// — 使用 AsyncDatabase —
async function main() {
const asyncDb = new AsyncDatabase(‘my_async_database.db’);

try {
    await asyncDb.exec(`
        CREATE TABLE IF NOT EXISTS async_users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            age INTEGER
        );
    `);
    console.log('异步:async_users 表创建成功');

    const insertInfo = await asyncDb.run('INSERT INTO async_users (name, age) VALUES (?, ?)', 'Grace', 28);
    console.log('异步:插入用户 Grace,ID:', insertInfo.lastInsertRowid);

    const users = await asyncDb.all('SELECT * FROM async_users WHERE age > ?', 20);
    console.log('异步:查询年龄大于 20 的用户:', users);

    const user = await asyncDb.get('SELECT * FROM async_users WHERE id = ?', 1);
    console.log('异步:查询 ID 为 1 的用户:', user);

} catch (error) {
    console.error('异步数据库操作失败:', error);
} finally {
    await asyncDb.close();
    console.log('异步数据库连接已关闭。');
}

}

main();
``
这个
AsyncDatabase封装提供了一个Promise接口,让你可以在主线程中以async/await的方式调用better-sqlite3操作,而实际的数据库操作则在独立的Worker` 线程中同步执行,从而避免阻塞主事件循环。


第五章:性能优化与最佳实践

为了充分发挥 better-sqlite3 的性能并保证应用的健壮性,以下是一些重要的优化策略和最佳实践。

5.1 复用预处理语句

这是性能优化的基石。每次调用 db.prepare() 都会让 SQLite 编译一次 SQL 语句。如果你的应用需要多次执行相同的 SQL 逻辑(只是参数不同),请务必将 db.prepare() 语句放在循环外部或函数初始化时,并在需要时重复调用其 .run(), .get(), .all() 方法。

“`javascript
const insertUserStmt = db.prepare(‘INSERT INTO users (name, email, age) VALUES (?, ?, ?)’);

function bulkInsertUsers(users) {
const insertTransaction = db.transaction((usersToInsert) => {
for (const user of usersToInsert) {
insertUserStmt.run(user.name, user.email, user.age);
}
});
insertTransaction(users);
}

// 这样就避免了在循环中重复 prepare
“`

5.2 使用事务进行批量操作

如前所述,对于大量的 INSERTUPDATEDELETE 操作,将它们封装在一个事务中是至关重要的。这可以将数百甚至数万次单独的磁盘写入操作聚合成一次,显著减少 I/O 开销。

5.3 合理使用索引 (Indexes)

当你在 WHERE 子句、ORDER BY 子句或 JOIN 条件中频繁使用某个列时,为该列创建索引可以极大地加快查询速度。

“`sql
— 为 users 表的 email 列创建唯一索引,加速按 email 查询并确保唯一性
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email);

— 为 users 表的 age 列创建普通索引,加速按 age 范围查询
CREATE INDEX IF NOT EXISTS idx_users_age ON users (age);
“`

注意: 索引会占用额外的磁盘空间,并且在 INSERTUPDATEDELETE 操作时会带来额外的开销,因为数据库需要维护索引结构。因此,不要为所有列都创建索引,只为那些频繁用于查询条件的列创建。

5.4 考虑 WAL 模式 (Write-Ahead Logging)

SQLite 默认使用回滚日志 (rollback journal) 模式。WAL (Write-Ahead Logging) 模式是一种更现代的日志记录机制,它通常能提供更好的并发性能和数据恢复能力。

WAL 模式的优势:
* 并发读取和写入: 在 WAL 模式下,读取操作不会阻塞写入操作,写入操作也不会阻塞读取操作。这意味着你的应用可以在一个连接写入数据的同时,另一个连接读取数据。
* 更好的性能: 尤其是在有大量并发读写或频繁小事务的场景。
* 原子提交: 即使发生崩溃,数据也能保持原子性和一致性。

启用 WAL 模式:

“`javascript
const db = new Database(‘my_database.db’);
db.pragma(‘journal_mode = WAL’); // 启用 WAL 模式

console.log(‘数据库已切换到 WAL 模式。’);
``
一旦启用,WAL 模式会创建额外的
-wal-shm` 文件与你的数据库文件一起存储。

5.5 错误处理

始终使用 try...catch 块来包裹数据库操作,尤其是在执行可能失败的操作(如违反唯一约束的插入、事务处理等)时。这有助于你的应用程序优雅地处理错误,而不是崩溃。

5.6 数据库连接管理

通常,一个 Node.js 进程只需要一个 better-sqlite3 数据库连接实例。在应用程序启动时打开连接,在应用程序关闭时关闭连接。

“`javascript
// app.js
let db;

function initializeDatabase() {
if (!db) {
db = new Database(‘my_application.db’);
db.pragma(‘journal_mode = WAL’); // 启用 WAL 模式
console.log(‘数据库连接已初始化。’);
}
// 创建表等初始化操作
db.exec(CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
);
return db;
}

function getDatabase() {
if (!db) {
throw new Error(‘Database not initialized. Call initializeDatabase() first.’);
}
return db;
}

// 在应用退出时关闭数据库连接
process.on(‘exit’, () => {
if (db && !db.closed) { // 检查 db 对象是否存在且未关闭
db.close();
console.log(‘数据库连接已安全关闭。’);
}
});

// 在你的应用主入口调用
initializeDatabase();
const myDb = getDatabase();
// … 使用 myDb 进行操作
“`

5.7 避免 SQL 注入

再次强调: 永远不要直接拼接用户输入到 SQL 语句中。始终使用预处理语句的参数绑定来处理用户输入。

错误示例 (❌ 存在 SQL 注入风险):

javascript
const unsafeName = "Robert'); DROP TABLE users; --"; // 恶意用户输入
db.run(`INSERT INTO users (name) VALUES ('${unsafeName}')`);

正确示例 (✅ 安全):

javascript
const safeName = "Robert'); DROP TABLE users; --"; // 用户输入
db.prepare('INSERT INTO users (name) VALUES (?)').run(safeName);

5.8 备份策略

虽然 better-sqlite3 是文件数据库,但为了数据安全,定期备份数据库文件(.db 文件)仍然是最佳实践。在 WAL 模式下,也需要备份 -wal-shm 文件,或者先将 WAL 模式切换回 DELETE 模式,再进行备份。


第六章:实际案例:构建一个简单的 TODO 应用

让我们把所学知识应用到一个实际的、虽然简单的 TODO 应用中。

“`javascript
const Database = require(‘better-sqlite3’);
const path = require(‘path’);

// 数据库文件路径
const DB_PATH = path.join(__dirname, ‘todo_app.db’);
let db;

/*
* 初始化数据库连接和表
/
function initDb() {
try {
db = new Database(DB_PATH, { verbose: console.log });
db.pragma(‘journal_mode = WAL’); // 启用 WAL 模式
console.log(‘数据库连接成功,并已切换到 WAL 模式。’);

    db.exec(`
        CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            task TEXT NOT NULL,
            is_completed INTEGER DEFAULT 0,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
        );
    `);
    console.log('Todos 表创建或已存在。');
} catch (error) {
    console.error('数据库初始化失败:', error.message);
    process.exit(1); // 退出应用
}

}

/*
* 添加一个待办事项
* @param {string} taskText 待办事项内容
* @returns {number} 新插入的待办事项 ID
/
function addTodo(taskText) {
const stmt = db.prepare(‘INSERT INTO todos (task) VALUES (?)’);
const info = stmt.run(taskText);
console.log(添加待办事项:"${taskText}",ID: ${info.lastInsertRowid});
return info.lastInsertRowid;
}

/*
* 获取所有待办事项
* @returns {Array} 待办事项列表
/
function getTodos(completed = null) {
let sql = ‘SELECT id, task, is_completed, created_at FROM todos’;
let params = [];
if (completed !== null) {
sql += ‘ WHERE is_completed = ?’;
params.push(completed ? 1 : 0);
}
sql += ‘ ORDER BY created_at DESC’;
const stmt = db.prepare(sql);
return stmt.all(…params);
}

/*
* 标记待办事项为完成或未完成
* @param {number} id 待办事项 ID
* @param {boolean} completed 是否完成
* @returns {number} 影响的行数
/
function markTodoCompleted(id, completed = true) {
const stmt = db.prepare(‘UPDATE todos SET is_completed = ? WHERE id = ?’);
const info = stmt.run(completed ? 1 : 0, id);
console.log(更新待办事项 ID ${id} 为 ${completed ? '已完成' : '未完成'},影响行数: ${info.changes});
return info.changes;
}

/*
* 删除待办事项
* @param {number} id 待办事项 ID
* @returns {number} 影响的行数
/
function deleteTodo(id) {
const stmt = db.prepare(‘DELETE FROM todos WHERE id = ?’);
const info = stmt.run(id);
console.log(删除待办事项 ID ${id},影响行数: ${info.changes});
return info.changes;
}

/*
* 清空所有待办事项
* @returns {number} 影响的行数
/
function clearAllTodos() {
const info = db.exec(‘DELETE FROM todos’); // 这里直接使用 exec 因为不需要参数
console.log(清空所有待办事项,影响行数: ${info.changes});
return info.changes;
}

// ——————– 应用程序主逻辑 ——————–
async function main() {
initDb();

console.log('\n--- 开始操作 ---');

// 1. 添加一些待办事项
const todoId1 = addTodo('学习 better-sqlite3');
const todoId2 = addTodo('准备晚餐');
const todoId3 = addTodo('跑步 30 分钟');

// 2. 查看所有待办事项
console.log('\n--- 当前所有待办事项 ---');
const allTodos = getTodos();
console.table(allTodos);

// 3. 标记一个待办事项为完成
markTodoCompleted(todoId1, true);

// 4. 查看未完成的待办事项
console.log('\n--- 未完成的待办事项 ---');
const incompleteTodos = getTodos(false);
console.table(incompleteTodos);

// 5. 再次查看所有待办事项
console.log('\n--- 再次查看所有待办事项 (包括已完成的) ---');
console.table(getTodos());

// 6. 删除一个待办事项
deleteTodo(todoId3);

// 7. 再次查看所有待办事项
console.log('\n--- 删除后所有待办事项 ---');
console.table(getTodos());

// 8. 演示事务 - 批量添加(假设有多种类型的任务,并且需要在同一个事务中完成)
const bulkAddTasks = db.transaction((tasks) => {
    const stmt = db.prepare('INSERT INTO todos (task, is_completed) VALUES (?, ?)');
    for (const task of tasks) {
        stmt.run(task.text, task.completed ? 1 : 0);
    }
    console.log(`批量添加了 ${tasks.length} 个任务。`);
});

try {
    console.log('\n--- 演示事务批量添加 ---');
    bulkAddTasks([
        { text: '购买牛奶', completed: false },
        { text: '写周报', completed: false },
        { text: '电话会议', completed: true }
    ]);
    console.table(getTodos());
} catch (error) {
    console.error('批量添加任务失败:', error.message);
}

// 9. 清空所有待办事项
// console.log('\n--- 清空所有待办事项 ---');
// clearAllTodos();
// console.table(getTodos());

}

// 确保在程序退出时关闭数据库连接
process.on(‘exit’, () => {
if (db && !db.closed) {
db.close();
console.log(‘数据库连接已安全关闭。’);
}
});

// 运行主函数
main();
“`

要运行这个示例,请将代码保存为 todo_app.js,然后在终端中执行 node todo_app.js。你将看到详细的日志输出以及数据库操作的结果。


总结

至此,你已经全面了解了 better-sqlite3 的核心功能和高级特性。我们从环境搭建开始,逐步深入到数据库的连接、表的创建,再到数据的增删改查(CRUD)。我们还探讨了预处理语句的重要性、事务的原子性和性能优势、用户自定义函数的扩展能力,以及如何利用 worker_threads 在 Node.js 异步环境中处理同步数据库操作。最后,我们通过一个简单的 TODO 应用案例,将这些知识融会贯通。

better-sqlite3 以其简洁、高效和可靠的特性,成为 Node.js 生态系统中处理 SQLite 数据库的首选库之一。它特别适合以下场景:

  • 桌面应用: 使用 Electron 等框架构建的桌面应用,可以本地存储用户数据。
  • 本地缓存或配置: 作为轻量级的数据存储方案,无需复杂的服务器设置。
  • 开发原型: 快速搭建数据存储,验证业务逻辑。
  • IoT 设备: 资源受限设备上的本地数据管理。
  • 测试: 作为快速、独立的测试数据库。

希望这篇详细的教程能够帮助你完全掌握 better-sqlite3,并激发你将其应用于未来的 Node.js 项目中。现在,你已经具备了在 Node.js 中高效、安全地使用 SQLite 数据库的技能!祝你编程愉快!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部