告别繁琐:用 better-sqlite3 玩转 Node.js SQLite 数据库 – wiki基地


告别繁琐:用 better-sqlite3 玩转 Node.js SQLite 数据库

引言:SQLite 在 Node.js 世界的定位与挑战

在构建各种 Node.js 应用时,我们常常需要一个轻量级、无需独立服务器、易于部署和管理的数据库。SQLite 凭借其零配置、文件存储、ACID 事务支持等特性,成为了许多场景下的理想选择,例如:

  • 桌面应用程序(Electron)
  • 命令行工具
  • 嵌入式系统
  • 小型网站或后端服务(特别是读多写少的场景)
  • 缓存层
  • 开发阶段的原型或测试数据库

然而,传统的 Node.js 生态中,与 SQLite 交互的常用库 sqlite3 长期以来依赖于回调或异步操作(如 Promise),这在处理多个连续数据库操作时,常常会导致代码变得复杂、难以阅读和维护,形成所谓的“回调地狱”或 Promise 链的嵌套。尽管 Promise 改进了异步流程,但与同步风格的代码相比,仍然增加了心智负担,尤其是在简单的 CRUD 操作或事务处理中。

幸运的是,Node.js 社区中出现了一个强大的替代品——better-sqlite3。正如其名字所示,它旨在提供一个“更好”的 SQLite 驱动。better-sqlite3 的核心优势在于它提供了一个同步(Synchronous)的 API。这意味着你可以像编写传统的同步代码一样与数据库交互,使得数据库操作的逻辑流程更加直观和清晰,极大地提高了开发效率,降低了理解成本。

本文将深入探讨 better-sqlite3 的各种特性、用法以及最佳实践,帮助你完全掌握这个 Node.js SQLite 领域的利器,彻底告别过去繁琐的异步处理,以更优雅、高效的方式玩转 SQLite 数据库。

第一章:为什么选择 better-sqlite3?告别繁琐的理由

在深入学习用法之前,我们先来详细了解一下 better-sqlite3 相较于传统方式的优势,这正是其能够帮助我们“告别繁琐”的核心所在。

1. 同步 API:代码流程一目了然

这是 better-sqlite3 最显著的特点。传统的 sqlite3 库是完全异步的,所有数据库操作都需要传入回调函数,或者使用 .get().then(...), .all().then(...) 这样的 Promise 链。

例如,使用 sqlite3(Promise 版本):

“`javascript
const sqlite3 = require(‘sqlite3’).verbose();
const { open } = require(‘sqlite’);

async function getUser(userId) {
const db = await open({
filename: ‘./mydb.sqlite’,
driver: sqlite3.Database
});

try {
const user = await db.get(‘SELECT * FROM users WHERE id = ?’, userId);
if (user) {
console.log(‘Found user:’, user);
const orders = await db.all(‘SELECT * FROM orders WHERE user_id = ?’, user.id);
console.log(‘User orders:’, orders);
} else {
console.log(‘User not found.’);
}
} catch (err) {
console.error(err);
} finally {
await db.close();
}
}

getUser(1);
“`

这段代码使用了 async/await 已经比纯回调清晰许多,但仍然需要 await 关键字,并且错误处理需要依赖 try...catch 包裹异步操作。

对比使用 better-sqlite3

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

function getUser(userId) {
let db;
try {
db = new Database(‘./mydb.sqlite’); // 打开/创建数据库

const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); // 获取单个用户
if (user) {
  console.log('Found user:', user);
  const orders = db.prepare('SELECT * FROM orders WHERE user_id = ?').all(user.id); // 获取用户订单
  console.log('User orders:', orders);
} else {
  console.log('User not found.');
}

} catch (err) {
console.error(err.message); // 同步错误直接捕获
} finally {
if (db) {
db.close(); // 关闭数据库
}
}
}

getUser(1);
“`

可以看到,better-sqlite3 的代码流程与我们的大脑思维方式更加一致:执行一个操作,等待结果,然后基于结果执行下一个操作。没有 await,没有 .then(),代码结构扁平且直观,极大降低了编写和阅读的复杂度,尤其是在需要执行一系列依赖前一步结果的数据库操作时。这对于编写脚本、命令行工具或简单的服务路由处理函数来说,简直是福音。

2. 卓越的性能

better-sqlite3 底层使用了 C++ 实现,与 SQLite C API 直接交互,这使得它拥有非常高的性能。它避免了 Node.js 异步回调或 Promise 带来的额外开销。此外,它默认并鼓励使用预处理语句(Prepared Statements),这进一步提升了重复执行相同结构 SQL 语句时的效率,同时也增强了安全性。

在许多基准测试中,better-sqlite3 的性能都远超 sqlite3,尤其是在大量插入或查询操作时。

3. 简洁直观的 API 设计

better-sqlite3 的 API 设计非常简洁。核心操作围绕 Database 对象和 Statement 对象展开。

  • new Database(...): 打开或创建一个数据库连接。
  • db.prepare(sql): 编译一个 SQL 语句,返回一个 Statement 对象。
  • statement.run(...): 执行非 SELECT 语句(INSERT, UPDATE, DELETE, CREATE TABLE等),返回有关更改的信息。
  • statement.get(...): 执行 SELECT 语句并返回一行结果。
  • statement.all(...): 执行 SELECT 语句并返回所有行结果。
  • statement.iterate(...): 执行 SELECT 语句并返回一个迭代器,适用于处理大量结果集而无需一次性加载到内存。
  • db.transaction(...): 方便地执行事务。

这种基于“准备语句 -> 执行”的模式,既安全又高效,是推荐的数据库交互方式。

4. 强大的预处理语句支持(默认且推荐)

预处理语句是防止 SQL 注入的关键,也是提高性能的常用手段。better-sqlite3 将预处理语句作为核心用法。通过 db.prepare(sql) 获取 Statement 对象,然后使用该对象的 run, get, all, iterate 方法传递参数,better-sqlite3 会自动为你处理参数绑定,确保安全和效率。

5. 事务支持的便利性

better-sqlite3 提供了非常便捷的方式来处理事务,特别是 db.transaction(() => { ... })() 语法,它能够自动处理事务的开始、提交和回滚(如果发生错误),让事务代码异常简洁和健壮。

6. 更好的错误处理

由于是同步 API,错误处理变得与常规的同步 JavaScript 代码一样:使用 try...catch 块就能捕获数据库操作抛出的异常。这比处理异步操作中的错误要直接得多。

7. TypeScript 支持

better-sqlite3 提供了良好的 TypeScript 类型定义,这对于使用 TypeScript 开发的开发者来说非常友好,可以在编译阶段捕获潜在的类型错误,提高代码质量。

总结:告别繁琐的底气

综上所述,better-sqlite3 通过提供同步 API、优异的性能、简洁直观的设计、强大的预处理语句和事务支持,显著降低了在 Node.js 中使用 SQLite 的复杂性,让开发者可以将更多精力放在业务逻辑而非繁琐的异步流程控制上。它确实是 Node.js SQLite 开发领域的一把“利器”,帮助我们“告别繁琐”。

第二章:Better-SQLite3 基础入门与核心用法

本章将带你一步步了解如何安装 better-sqlite3,并掌握其最基本也是最常用的操作。

1. 安装

使用 npm 或 yarn 进行安装:

“`bash
npm install better-sqlite3

yarn add better-sqlite3
“`

安装过程可能会编译一些 C++ 代码,需要本地有 Python 和 C++ 编译环境(如 Node.js 的 node-gyp 依赖),但通常情况下,Node.js 安装时已经包含了必要的工具链,或者 better-sqlite3 提供了预编译的二进制包,大部分用户可以直接安装成功。

2. 连接/创建数据库

使用 new Database(filename, [options]) 构造函数来连接或创建一个 SQLite 数据库文件。如果指定的文件不存在,它会被创建。

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

// 连接到或创建一个数据库文件
let db;
try {
db = new Database(‘my_app.db’); // 数据库文件名为 my_app.db
console.log(‘成功连接到数据库’);

// 你可以在 options 中指定一些参数
// 例如:readonly: true 打开一个只读连接
// verbose: console.log 打印所有执行的 SQL 语句 (调试时有用)
// db = new Database(‘my_app.db’, { verbose: console.log });

} catch (err) {
console.error(‘连接数据库时出错:’, err.message);
}

// … 执行数据库操作 …

// 操作完成后,务必关闭数据库连接
if (db) {
db.close();
console.log(‘数据库连接已关闭’);
}
“`

重要提示: 务必在应用退出或不再需要数据库连接时调用 db.close() 来关闭连接。虽然 Node.js 进程退出时会自动清理,但在长时间运行的服务中,显式关闭是良好的实践,可以确保所有写操作都已同步到磁盘。

3. 执行非查询语句 (INSERT, UPDATE, DELETE, CREATE TABLE 等) – 使用 run()

对于不返回结果集的 SQL 语句(如数据插入、更新、删除、表创建等),使用 db.prepare(sql).run(...) 方法。

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

try {
// 创建一个 users 表
db.prepare(CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER,
email TEXT UNIQUE
)
).run();
console.log(‘users 表已创建或已存在’);

// 插入一条数据
const insertStmt = db.prepare(‘INSERT INTO users (name, age, email) VALUES (?, ?, ?)’);
const result1 = insertStmt.run(‘张三’, 30, ‘[email protected]’);
console.log(‘插入用户 张三:’, result1.changes, ‘行受影响, 最后插入ID:’, result1.lastInsertRowid);

// 插入另一条数据
const result2 = insertStmt.run(‘李四’, 25, ‘[email protected]’);
console.log(‘插入用户 李四:’, result2.changes, ‘行受影响, 最后插入ID:’, result2.lastInsertRowid);

// 更新数据
const updateStmt = db.prepare(‘UPDATE users SET age = ? WHERE name = ?’);
const updateResult = updateStmt.run(31, ‘张三’);
console.log(‘更新用户 张三:’, updateResult.changes, ‘行受影响’);

// 删除数据
const deleteStmt = db.prepare(‘DELETE FROM users WHERE name = ?’);
const deleteResult = deleteStmt.run(‘李四’);
console.log(‘删除用户 李四:’, deleteResult.changes, ‘行受影响’);

} catch (err) {
console.error(‘执行SQL时出错:’, err.message);
} finally {
db.close();
}
“`

run() 方法返回一个对象,包含两个有用的属性:
* changes: 表示受当前 SQL 语句影响的行数(对于 INSERT, UPDATE, DELETE)。
* lastInsertRowid: 对于 INSERT 语句,表示新插入行的 ID。

重要: 注意我们使用了 db.prepare()。虽然对于单次执行的简单语句,可以直接用 db.exec(sql),但 prepare 是更安全、更高效的默认选择,特别是当语句包含参数时。db.exec() 适用于执行不带参数的 DDL (Data Definition Language) 语句或多个由分号分隔的语句。

4. 执行查询语句并获取单行结果 – 使用 get()

当你的查询预期只返回一行结果时(例如通过主键查询),使用 db.prepare(sql).get(...)

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

try {
// 假设 users 表已存在并包含数据
const userStmt = db.prepare(‘SELECT id, name, age, email FROM users WHERE id = ?’);

const user1 = userStmt.get(1);
if (user1) {
console.log(‘获取用户 (ID=1):’, user1); // 结果是一个对象 { id: 1, name: ‘张三’, age: 31, email: ‘[email protected]’ }
} else {
console.log(‘未找到用户 (ID=1)’); // 如果未找到,get() 返回 undefined
}

const user100 = userStmt.get(100);
if (user100) {
console.log(‘获取用户 (ID=100):’, user100);
} else {
console.log(‘未找到用户 (ID=100)’); // 未找到,get() 返回 undefined
}

} catch (err) {
console.error(‘执行查询时出错:’, err.message);
} finally {
db.close();
}
“`

get() 方法返回一个对象代表查询结果的第一行。如果没有匹配的行,则返回 undefined

5. 执行查询语句并获取所有行结果 – 使用 all()

当你的查询预期返回多行结果时,使用 db.prepare(sql).all(...)

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

try {
// 插入一些示例数据
db.prepare(‘INSERT OR IGNORE INTO users (name, age, email) VALUES (?, ?, ?)’).run(‘王五’, 28, ‘[email protected]’);
db.prepare(‘INSERT OR IGNORE INTO users (name, age, email) VALUES (?, ?, ?)’).run(‘赵六’, 35, ‘[email protected]’);

// 获取所有用户
const allUsersStmt = db.prepare(‘SELECT id, name, age FROM users’);
const allUsers = allUsersStmt.all(); // all() 不带参数时获取所有行

console.log(‘所有用户:’);
console.log(allUsers); // 结果是一个对象数组 [{ id: 1, name: ‘张三’, age: 31 }, { id: 3, name: ‘王五’, age: 28 }, …]

// 获取年龄大于等于 30 岁的用户
const adultUsersStmt = db.prepare(‘SELECT name, age FROM users WHERE age >= ?’);
const adultUsers = adultUsersStmt.all(30); // all() 也可以带参数

console.log(‘年龄 >= 30 的用户:’);
console.log(adultUsers); // 结果是符合条件的用户数组

} catch (err) {
console.error(‘执行查询时出错:’, err.message);
} finally {
db.close();
}
“`

all() 方法返回一个数组,其中每个元素是一个对象,代表查询结果的一行。如果没有匹配的行,则返回一个空数组 []

6. 使用参数绑定

在上面的例子中,我们已经看到了使用 ? 作为占位符,并在 run, get, all 方法中传入参数的方式。better-sqlite3 支持以下几种参数绑定方式:

  • 问号占位符 (?): 参数按顺序绑定。
    javascript
    stmt.run(param1, param2, ...);
    stmt.get(param1, param2, ...);
    stmt.all(param1, param2, ...);
  • 位置占位符 ($N): $1, $2, $3 等,参数按顺序绑定。
    javascript
    const stmt = db.prepare('SELECT * FROM users WHERE id = $1 AND age = $2');
    const user = stmt.get(userId, userAge); // $1 对应 userId, $2 对应 userAge
  • 命名占位符 (:name, @name, $name): 参数通过对象属性名绑定。
    javascript
    const stmt = db.prepare('SELECT * FROM users WHERE id = :userId AND age = @userAge');
    const user = stmt.get({ userId: 1, userAge: 30 }); // :userId 对应 obj.userId, @userAge 对应 obj.userAge

推荐使用问号占位符或命名占位符,它们可以有效地防止 SQL 注入攻击,并且在重复执行时 SQLite 可以重用查询计划,提高性能。永远不要直接将变量拼接到 SQL 字符串中

7. 错误处理

由于 better-sqlite3 是同步的,所有的数据库操作(除了数据库打开时的同步部分)如果失败,会直接抛出一个 Error。因此,你可以使用标准的 try...catch 块来捕获并处理这些错误。

“`javascript
const Database = require(‘better-sqlite3’);
let db;
try {
db = new Database(‘my_app.db’);
// 尝试插入一个 email 已存在的用户,会违反 UNIQUE 约束
db.prepare(‘INSERT INTO users (name, age, email) VALUES (?, ?, ?)’).run(‘测试用户’, 40, ‘[email protected]’);

} catch (err) {
console.error(‘发生数据库错误:’, err.message); // 捕获到 UNIQUE 约束错误
// err 对象通常包含更详细的 SQLite 错误信息
} finally {
if (db) {
db.close();
}
}
“`

确保在 try...catch...finally 结构中使用 finally 来保证数据库连接在任何情况下都能被尝试关闭。

第三章:深入 Better-SQLite3:高级特性与最佳实践

掌握了基础用法后,我们可以进一步探索 better-sqlite3 的更高级特性,并了解一些使用时的最佳实践,以充分发挥其优势。

1. 事务处理:确保操作的原子性

事务是数据库中非常重要的概念,它保证一组数据库操作要么全部成功,要么全部失败(回滚到初始状态),从而维护数据的完整性。better-sqlite3 提供了非常优雅的事务处理方式。

使用 db.transaction(func).deferred(), .immediate(), 或 .exclusive() 方法,其中 func 是一个包含事务逻辑的函数。调用返回的函数即可执行事务。

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

try {
// 假设我们有两个操作:给用户 A 减去积分,给用户 B 增加积分
// 这两个操作应该在一个事务里,确保要么都成功,要么都失败

const transferPoints = db.transaction((fromUserId, toUserId, points) => {
// 1. 给用户 A 减去积分
const deductStmt = db.prepare(‘UPDATE users SET age = age – ? WHERE id = ?’); // 用 age 模拟积分字段
const deductResult = deductStmt.run(points, fromUserId);

if (deductResult.changes === 0) {
  // 如果用户 A 不存在或积分不足(此处简化为用户不存在),则抛出错误,事务将回滚
  // 实际应用中需要更复杂的逻辑检查积分是否足够
  throw new Error(`User with ID ${fromUserId} not found or insufficient points`);
}

// 模拟一个可能失败的操作,比如数据库写入错误或其他业务逻辑错误
// if (Math.random() < 0.2) { // 20% 的概率模拟失败
//   throw new Error("Simulated network error during transfer");
// }

// 2. 给用户 B 增加积分
const addStmt = db.prepare('UPDATE users SET age = age + ? WHERE id = ?');
const addResult = addStmt.run(points, toUserId);

 if (addResult.changes === 0) {
  // 如果用户 B 不存在,则抛出错误,事务将回滚
  throw new Error(`User with ID ${toUserId} not found`);
}

// 如果函数没有抛出错误,事务将自动提交
console.log(`成功从用户 ${fromUserId} 转移 ${points} 积分到用户 ${toUserId}`);

});

// 执行事务
// transferPoints(1, 3, 5); // 调用事务函数,从用户1转移5积分给用户3
// console.log(‘事务执行完毕 (成功)’);

// 尝试执行一个会失败的事务 (例如,用户ID不存在)
console.log(‘\n尝试执行一个会失败的事务…’);
try {
transferPoints(1, 999, 10); // 用户 999 不存在
} catch (txErr) {
console.error(‘事务执行失败:’, txErr.message); // 捕获到用户不存在的错误,事务会自动回滚
}

} catch (err) {
console.error(‘发生其他错误:’, err.message);
} finally {
db.close();
}
“`

db.transaction(() => { ... })() 模式是 better-sqlite3 推荐的事务用法。它创建并返回一个函数。当你调用这个函数时,它会在一个事务中执行你提供的回调函数 (() => { ... })。
* 如果在回调函数内部没有抛出错误,事务会自动提交(COMMIT)。
* 如果在回调函数内部抛出了任何错误,事务会自动回滚(ROLLBACK)。

这种模式极大地简化了事务的管理,无需手动写 BEGIN TRANSACTION, COMMIT, ROLLBACK 以及复杂的 try...catch 逻辑来处理回滚。

transaction() 方法有几个变种 (.deferred(), .immediate(), .exclusive()),它们对应 SQLite 的不同锁定模式,影响并发写入时的行为。deferred() 是默认值,它会尽可能延迟获取写锁;immediate() 会在事务开始时尝试获取写锁;exclusive() 会获取排他锁,确保没有其他连接可以读写数据库。对于大多数简单的 Node.js 应用,使用默认的 deferred() 或明确使用 db.transaction(() => { ... })() 即可。

2. 批量插入:提升写入效率

向数据库插入大量数据时,逐条执行 INSERT 语句效率低下。将多次插入操作放在一个事务中执行可以显著提升性能,因为事务减少了磁盘 I/O 操作的次数。better-sqlite3 结合事务和预处理语句,使得批量插入非常方便和高效。

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

try {
const insertStmt = db.prepare(‘INSERT INTO users (name, age, email) VALUES (?, ?, ?)’);

const insertMany = db.transaction((users) => {
for (const user of users) {
insertStmt.run(user.name, user.age, user.email);
}
});

const newUsers = [
{ name: ‘钱七’, age: 22, email: ‘[email protected]’ },
{ name: ‘孙八’, age: 38, email: ‘[email protected]’ },
{ name: ‘周九’, age: 29, email: ‘[email protected]’ },
// … 更多用户数据
];

console.time(‘批量插入’);
insertMany(newUsers); // 调用批量插入事务函数
console.timeEnd(‘批量插入’);

console.log(成功插入 ${newUsers.length} 条用户数据);

} catch (err) {
console.error(‘批量插入失败:’, err.message);
} finally {
db.close();
}
“`

通过将 insertStmt.run() 调用放在 db.transaction 的回调函数中,所有插入操作都在一个事务中完成,即使是插入成千上万条数据也能保持较高的效率。

3. 迭代器模式:处理海量查询结果

对于可能返回巨量数据的查询,一次性使用 all() 将所有结果加载到内存可能会导致内存溢出。better-sqlite3 提供了 iterate() 方法,它返回一个迭代器,你可以逐行处理结果,而无需将整个结果集存储在内存中。

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

try {
// 假设 users 表中有大量数据
const selectStmt = db.prepare(‘SELECT id, name, email FROM users’);

console.log(‘开始迭代处理用户数据…’);
let count = 0;
// 使用 for…of 循环迭代结果
for (const user of selectStmt.iterate()) {
// 在这里处理每一行数据
// console.log(处理用户: ID=${user.id}, Name=${user.name});
count++;
if (count % 1000 === 0) { // 每处理1000行打印一次进度
console.log(已处理 ${count} 行...);
}
// 模拟一些处理时间
// await new Promise(resolve => setTimeout(resolve, 1)); // 如果在异步环境中,迭代器不可用
}

console.log(迭代处理完毕,共处理 ${count} 行数据。);

} catch (err) {
console.error(‘迭代查询失败:’, err.message);
} finally {
db.close();
}
“`

iterate() 返回的迭代器是同步的,非常适合在 Node.js 脚本或那些不需要阻塞主事件循环(比如在 Express 路由处理中通常需要异步)但需要处理大结果集的场景下使用。注意,在使用迭代器期间,数据库连接是忙碌的,不能同时执行其他操作。

4. 数据库配置与 PRAGMA

better-sqlite3 允许你在打开数据库时通过 options 配置,也允许你执行 SQLite 的 PRAGMA 语句来调整数据库设置。

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

// 启用 WAL 模式 (Write-Ahead Logging),可以改善并发读写性能
// 在多进程/线程同时读写同一个数据库文件时特别有用
let db = new Database(‘my_app.db’);
try {
db.pragma(‘journal_mode = WAL’);
console.log(‘数据库日志模式设置为 WAL’);

// 获取当前 journal 模式
const mode = db.pragma(‘journal_mode’, { simple: true }); // simple: true 返回单个值
console.log(‘当前 journal 模式:’, mode); // 输出如: WAL

// 设置 busy timeout (当数据库文件被锁定时等待的时间,单位毫秒)
// 对于可能会有多个进程/线程访问的场景,设置一个 busy timeout 可以避免程序立即因为锁而崩溃
db.pragma(‘busy_timeout = 5000’); // 等待 5 秒
console.log(‘数据库忙碌超时设置为 5000ms’);

// 获取 busy timeout
const timeout = db.pragma(‘busy_timeout’, { simple: true });
console.log(‘当前忙碌超时设置:’, timeout); // 输出: 5000

} catch (err) {
console.error(‘PRAGMA 操作失败:’, err.message);
} finally {
if (db) {
db.close();
}
}

“`

db.pragma(sql, [options]) 方法用于执行 PRAGMA 语句。
* sql: PRAGMA 语句字符串。
* options.simple: 如果设置为 true 且 PRAGMA 语句返回一个值,则直接返回该值而不是结果行数组。

配置 journal_modebusy_timeout 是优化 SQLite 在 Node.js 应用中表现的常见手段,特别是当你遇到数据库锁定相关的错误时。

5. 类型处理

SQLite 本身是动态类型的,一个列可以存储任何类型的数据,尽管通常会指定一个亲和类型 (affinity type)。better-sqlite3 会根据查询结果的 SQLite 类型尝试将其映射到合适的 JavaScript 类型:

  • SQLite INTEGER 映射到 JavaScript number。
  • SQLite REAL 映射到 JavaScript number。
  • SQLite TEXT 映射到 JavaScript string。
  • SQLite BLOB 映射到 Node.js Buffer。
  • SQLite NULL 映射到 JavaScript null。

布尔值通常存储为 0 或 1 的 INTEGER,better-sqlite3 默认不会自动转换,你需要根据需要手动处理。

对于日期和时间,SQLite 通常存储为 TEXT, REAL (Julian day number) 或 INTEGER (Unix epoch time)。better-sqlite3 也不会自动将其转换为 JavaScript Date 对象,你需要在查询后或插入前手动转换。

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

try {
db.prepare(CREATE TABLE IF NOT EXISTS data_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
count INTEGER,
price REAL,
is_active INTEGER, -- 存储 0 或 1
binary_data BLOB,
created_at INTEGER -- 存储 Unix 时间戳
)
).run();

const bufferData = Buffer.from(‘这是一个 BLOB 测试数据’);
const now = Math.floor(Date.now() / 1000); // 当前 Unix 时间戳

db.prepare(‘INSERT INTO data_types (name, count, price, is_active, binary_data, created_at) VALUES (?, ?, ?, ?, ?, ?)’).run(
‘示例项目’, 100, 19.99, 1, bufferData, now
);

const item = db.prepare(‘SELECT * FROM data_types WHERE id = ?’).get(1);

console.log(‘获取到的数据类型示例:’);
console.log(‘name (TEXT):’, typeof item.name, item.name); // string
console.log(‘count (INTEGER):’, typeof item.count, item.count); // number
console.log(‘price (REAL):’, typeof item.price, item.price); // number
console.log(‘is_active (INTEGER):’, typeof item.is_active, item.is_active); // number (0 or 1)
console.log(‘binary_data (BLOB):’, typeof item.binary_data, item.binary_data instanceof Buffer, item.binary_data.toString()); // object, true, “这是一个 BLOB 测试数据”
console.log(‘created_at (INTEGER):’, typeof item.created_at, item.created_at, new Date(item.created_at * 1000)); // number, unix timestamp, Date object

} catch (err) {
console.error(‘数据类型示例失败:’, err.message);
} finally {
db.close();
}
“`

理解 better-sqlite3 和 SQLite 的类型映射对于正确地存取和处理数据至关重要。

6. 运行时扩展 (Runtime Extensions)

SQLite 允许加载运行时扩展来增加新的功能,比如空间数据支持 (SpatiaLite)、全文搜索增强等。better-sqlite3 提供了 db.loadExtension(path) 方法来加载这些扩展(需要扩展是预编译好的 .so, .dylib, .dll 文件)。

javascript
// 假设你有一个 SQLite 扩展库文件,例如 libspatialite.so
// const Database = require('better-sqlite3');
// const db = new Database('my_app.db');
// try {
// db.loadExtension('/path/to/libspatialite.so');
// console.log('SpatiaLite 扩展加载成功');
// // 现在你可以使用 SpatiaLite 提供的 SQL 函数了
// // const result = db.prepare('SELECT ST_MakePoint(?, ?)').get(10, 20);
// // console.log(result);
// } catch (err) {
// console.error('加载扩展失败:', err.message);
// } finally {
// db.close();
// }

这个功能对于需要利用 SQLite 高级特性的应用非常有用。

第四章:实际案例:构建一个简单的命令行 Todo 应用

为了更好地理解 better-sqlite3 的实际应用,我们构建一个简单的命令行 Todo 应用,它可以添加、列出和完成 Todo 项。

“`javascript

!/usr/bin/env node

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

// 数据库文件放在用户主目录下的一个隐藏文件夹里
const dbPath = path.join(process.env.HOME || process.env.USERPROFILE, ‘.my_todo_app’, ‘todos.db’);
const fs = require(‘fs’);
const dbDir = path.dirname(dbPath);

// 确保数据库目录存在
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}

let db;

try {
db = new Database(dbPath);
console.log(使用数据库文件: ${dbPath});

// 创建 todos 表
db.prepare(`
    CREATE TABLE IF NOT EXISTS todos (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        task TEXT NOT NULL,
        completed INTEGER NOT NULL DEFAULT 0, -- 0 未完成, 1 已完成
        created_at INTEGER NOT NULL
    )
`).run();
console.log('Todos 表已就绪.');

const args = process.argv.slice(2); // 获取命令行参数 (跳过 node 和 脚本路径)
const command = args[0];

switch (command) {
    case 'add':
        if (args.length < 2) {
            console.error('用法: todo add <任务描述>');
            process.exit(1);
        }
        const task = args.slice(1).join(' ');
        const insertStmt = db.prepare('INSERT INTO todos (task, created_at) VALUES (?, ?)');
        const result = insertStmt.run(task, Math.floor(Date.now() / 1000));
        console.log(`添加任务成功 (ID: ${result.lastInsertRowid})`);
        break;

    case 'list':
        const listStmt = db.prepare('SELECT id, task, completed FROM todos ORDER BY created_at DESC');
        const todos = listStmt.all();
        if (todos.length === 0) {
            console.log('当前没有待办任务。');
        } else {
            console.log('待办任务列表:');
            todos.forEach(todo => {
                const status = todo.completed ? '[完成]' : '[待办]';
                console.log(`- [${todo.id}] ${status} ${todo.task}`);
            });
        }
        break;

    case 'done':
        if (args.length < 2) {
            console.error('用法: todo done <任务ID>');
            process.exit(1);
        }
        const taskId = parseInt(args[1], 10);
        if (isNaN(taskId)) {
             console.error('错误: 任务ID必须是数字。');
             process.exit(1);
        }
        const doneStmt = db.prepare('UPDATE todos SET completed = 1 WHERE id = ?');
        const doneResult = doneStmt.run(taskId);
        if (doneResult.changes > 0) {
            console.log(`任务 ${taskId} 已标记为完成。`);
        } else {
            console.log(`未找到任务 ID ${taskId} 或已完成。`);
        }
        break;

    case 'remove': // 添加一个删除功能
         if (args.length < 2) {
            console.error('用法: todo remove <任务ID>');
            process.exit(1);
         }
         const removeTaskId = parseInt(args[1], 10);
         if (isNaN(removeTaskId)) {
             console.error('错误: 任务ID必须是数字。');
             process.exit(1);
         }
         const removeStmt = db.prepare('DELETE FROM todos WHERE id = ?');
         const removeResult = removeStmt.run(removeTaskId);
         if (removeResult.changes > 0) {
             console.log(`任务 ${removeTaskId} 已删除。`);
         } else {
             console.log(`未找到任务 ID ${removeTaskId}。`);
         }
         break;


    default:
        console.log('未知命令。可用命令: add, list, done, remove');
        console.log('用法:');
        console.log('  todo add <任务描述>');
        console.log('  todo list');
        console.log('  todo done <任务ID>');
        console.log('  todo remove <任务ID>');
        process.exit(1);
}

} catch (err) {
console.error(‘发生错误:’, err.message);
process.exit(1); // 错误时退出并返回非零状态码
} finally {
// 确保数据库连接在程序退出前关闭
if (db) {
db.close();
}
}

// 为了作为命令行工具执行,你可能需要在文件顶部添加 #!/usr/bin/env node
// 并通过 chmod +x <文件名> 使其可执行
// 然后将其放到系统的 PATH 中,或直接使用 ./<文件名> <命令> 执行

“`

如何运行这个例子:

  1. 将上面的代码保存为 todo.js 文件。
  2. 在终端中,进入文件所在的目录。
  3. 运行 npm install better-sqlite3 安装依赖。
  4. 使脚本可执行:chmod +x todo.js
  5. 运行命令:
    • ./todo.js add 买牛奶
    • ./todo.js add 写代码
    • ./todo.js list
    • ./todo.js done 1 (假设买牛奶的任务ID是1)
    • ./todo.js list
    • ./todo.js remove 2 (假设写代码的任务ID是2)
    • ./todo.js list

这个例子清晰地展示了 better-sqlite3 在命令行工具中的优势:同步代码流程与脚本执行顺序完美契合,使得代码非常直观易懂。每个命令的处理都是一个简单的同步函数调用和数据库操作。

第五章:性能优化与注意事项

虽然 better-sqlite3 性能已经很出色,但在处理大量数据或并发访问时,仍然需要注意一些优化和限制。

1. 批量操作务必使用事务

再次强调,对于 INSERT, UPDATE, DELETE 的批量操作,务必将它们包裹在一个事务中。这是提升写入性能最有效的手段。

2. 重用预处理语句 (Statements)

如果你在循环或其他地方重复执行相同的 SQL 语句(只是参数不同),应该将 db.prepare() 调用放在循环外部,并在循环内部多次调用 statement.run()statement.get()/all()。预处理语句的编译和优化只需要执行一次。

3. 了解 SQLite 的并发限制

SQLite 是一个文件数据库,它的并发控制是通过文件锁实现的。在默认的 DELETE 日志模式下,写操作会锁定整个数据库文件,期间其他读写操作都会被阻塞。虽然 WAL (Write-Ahead Logging) 模式可以显著改善读写并发(允许多个读操作与一个写操作并行),但同一时间仍然只允许一个写操作。

这意味着 better-sqlite3 (或任何 Node.js SQLite 库)不适合高并发、多进程写入的场景。如果你的应用需要处理大量并发写请求,考虑使用客户端/服务器架构的数据库(如 PostgreSQL, MySQL, MongoDB)。

如果在 Node.js 应用中可能出现多个请求同时访问数据库(例如在 Web 服务器中),并且可能会触发写操作,可以考虑:
* 使用 WAL 模式 (PRAGMA journal_mode = WAL)
* 设置 busy_timeout 允许等待一段时间获取锁。
* 在更高层面上管理并发,例如使用队列来顺序化写入操作。
* 如果并发需求很高,认真考虑是否需要切换到其他数据库

better-sqlite3 在单线程 Node.js 进程内部执行同步操作时性能极佳,但如果多个 Node.js 进程或线程(通过 worker_threads)同时访问 同一个 数据库文件并尝试写入,就会遇到 SQLite 的并发写入瓶颈。

4. 索引的重要性

对于频繁用于查询条件的列,创建索引可以极大地提高 SELECT 语句的性能。

sql
CREATE INDEX index_name ON table_name (column_name);
CREATE INDEX index_name ON table_name (column1, column2); -- 复合索引

在应用启动时或数据库初始化时,确保你的表有合适的索引。

5. 避免在长时间运行的同步操作中持有数据库锁

在使用 iterate() 处理大量数据时,或者在自定义的同步函数中执行长时间运行的数据库操作时,要注意整个过程会阻塞 Node.js 事件循环。如果这是一个 Web 服务器,这意味着在此期间它无法响应其他请求。尽量将耗时的数据库操作(如果可能且需要)放在 Worker Threads 中执行,或者重新评估是否真的需要同步操作。然而,better-sqlite3 的核心优势就在于同步,所以在许多脚本或简单服务场景下,这并不是问题。只是在构建高并发 Web 服务时需要特别权衡。

总结:告别繁琐,拥抱高效

通过本文的详细介绍,你应该已经对 better-sqlite3 有了全面的认识。它的同步 API 设计、出色的性能、简洁直观的接口以及对预处理语句和事务的良好支持,使其成为在 Node.js 中使用 SQLite 数据库时的首选库,特别是在需要快速开发、代码逻辑清晰且对并发写入要求不那么极致的场景下。

使用 better-sqlite3,你可以:

  • 告别 复杂的异步回调或 Promise 链,用同步思维编写数据库交互代码。
  • 拥抱 简洁直观的 API,快速实现数据库操作。
  • 利用 预处理语句提升性能和安全性。
  • 便捷地 管理事务,确保数据完整性。
  • 高效地 进行批量数据处理。

当然,任何工具都有其适用范围。better-sqlite3 和 SQLite 的组合并非万能药。对于需要高并发写入、复杂分布式特性或超大规模数据集的应用,你仍然需要考虑更强大的数据库系统。但对于大多数轻量级 Node.js 应用、工具或开发原型,better-sqlite3 提供了一个无比强大且易用的解决方案,让你能够真正“告别繁琐”,更专注于构建你的核心功能。

现在,是时候在你的 Node.js 项目中引入 better-sqlite3,亲身体验它带来的开发效率提升和代码简洁性了!

发表评论

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

滚动至顶部