文章背景
某天在微信群里看到一个大佬的截图,写nodejs从22.5开始官方支持了sqlite的驱动库。
sqlite作为一个很常用的小型嵌入式数据库,在一些简单的数据持久化(其实也可以弄复杂项目)场合是非常好用的,但是在node上,之前一直都是由社区提供的方案,比如sqlite
和sqlite3
,其中sqlite3
是驱动的绑定库,用原生编写,因此在安装的时候需要通过node-gyp
进行动态的编译,属于非常折磨的过程了。
因此nodejs官方默认内置sqlite,是一个非常具有突破性质的好事情。所以我立刻创建了一个新项目,打算看看官方这次搞出来的这个大宝贝到底如何。
环境准备
- nodejs@22.5+
理论上,如果你只是想要尝鲜,只需要准备好一个22.5版本的nodejs。不过为了让自己写的舒心一些,我还额外准备一些其他的工具。
- typescript
- tsx
- @types/node
- pnpm
typescript不必说了,就是为了让自己写的代码没那么乱,tsx是一个让你"直接运行"ts文件的一个插件包,并不是react里写的那个tsx(实际上应该是jsx),它的同类插件是ts-node。
@types/node则是nodejs的类型提示包,为了能在typescript下也能提供良好的类型提示。 pnpm其实现在被我拿来当作nodejs的版本管理工具,只需要执行以下命令:
bash
pnpm env use --global 22.5.0
但是要注意,最好使用二进制包安装的pnpm进行这类操作。
更多pnpm的env操作可以自行查看 pnpm env cmd ,这里不做赘述。
开始写代码
新建一个src/index.ts
文件,然后写入以下代码
ts
// src/index.ts
import db from "node:sqlite";
console.log(db)
然后vscode很快给出了一个错误,真是让人神清气爽。
提示node:sqlite
并不存在,这个其实是很正常的现象,因为我们目前安装的@types/node的版本如果通过package.json进行查看,可以发现版本为22.0,而这个sqlite包是在22.5中被加入,并且是实验性质的包,因此在类型提示文件中并没有提供对应的类型定义。
但是,这种简单的小错误,解决起来还是很轻松的。
编写类型定义文件
typescript支持一种通过定义模块名直接扩展模块的写法,当然,这种写法也支持让我们无中生有一种模块。
因此,我们只需要通过编写module "node:sqlite"
就可以消除上面的错误,但是定义类型也不能随便来,要遵守标准,而这部分标准只需要通过查看官方文档即可:SQLite | Node.js v22.5.1 Documentation (nodejs.org)
通过参考官方文档,我们成功写出了一些简单的定义
ts
// global.d.ts
declare module "node:sqlite" {
type PVal = null | number | bigint | string | Uint8Array | ArrayBuffer
type RunRes = {
lastInsertRowid: number
changes: number
}
export class StatementSync<T extends {}> {
all<V = T>(namedParameters: Partial<T>): V[]
all<V = T>(...anonymousParameters: PVal[]): V[]
all<V = T>(namedParameters: Partial<T>, ...anonymousParameters: PVal[]): V[]
all<V = T>(): V[]
get<V = T>(namedParameters: Partial<T>): V
get<V = T>(...anonymousParameters: PVal[]): V
get<V = T>(namedParameters: Partial<T>, ...anonymousParameters: PVal[]): V
get<V = T>(): V
run<V = T>(namedParameters: Partial<T>): RunRes
run<V = T>(...anonymousParameters: PVal[]): RunRes
run<V = T>(namedParameters: Partial<T>, ...anonymousParameters: PVal[]): RunRes
run<V = T>(): RunRes
sourceSQL(): string
expandedSQL(): string
setAllowBareNamedParameters(enable: boolean): void
setReadBigInts(enable: boolean): void
}
export class DatabaseSync {
constructor(memory: ':memory:', options?: {
open?: boolean
})
constructor(location: string, options?: {
open?: boolean
})
close(): void
exec(sql: string): void
open(): void
prepare<T extends {} = Record<string, any>>(sql: string): StatementSync<T>
}
}
写完以后,要记得在tsconfig
中,添加include
json
{
"compilerOptions": {
"allowJs": true,
"baseUrl": "./",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": [
"src/**/*.ts",
"global.d.ts"
]
}
运行代码
我们是通过tsx进行代码运行的,一般只需要执行以下指令就可以让代码跑起来
bash
npx tsx --tsconfig ./tsconfig.json src/index.ts
不过这种方式,每次都需要指定tsconfig的路径,非常的麻烦,并且npx是从global环境下查询tsx的,而这种方式查询到的tsx也会使用global下的typescript,会导致版本不稳定的问题,所以我们可以选择将tsx跟typescript安装到项目目录下,然后通过npm-scripts来进行运行。完成后的package.json如下:
json
{
"name": "node22-5-sqlite-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "tsx src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.16.2"
},
"engines": {
"node": "^22.5.0"
}
}
然后我们通过pnpm去调用对应的script就可以:
bash
pnpm start
不过很不幸,我们马上就能看到第二个非常刺激的错误:
没有发现这个内置模块,其实这个问题也很简单,跟上面说到的一个重点有关,那就是,目前这个功能还是一个实验性质的模块,因此我们需要通过nodejs的flag来开启这个功能。 nodejs的flag其实就是一个特定前缀的CLI参数,即为:--experimental-*
,我们要使用sqlite,则修改如下:
json
{
"start":"tsx --experimental-sqlite src/index.js"
}
修改完毕后,再次运行pnpm start
,可以看到如下输出,代表成功:
输出了模块的完成定义,并且下发提示这是一个实验功能,后续可能会发生一些改变,不过这不是本文章关心的内容。
模块分析
node:sqlite
模块目前仅提供了两个导出,分别是两个类(class):DatabaseSync
跟StatementSync
,从名字上分析,其实就是数据库的操作对象跟语句操作对象(SQL),并且都是同步(sync)的api。
虽然目前nodejs官方并没有完全推送这个模块,但是好的是,官方文档已经对这两个api提供了比较详细的文档(也因此上面才能写出dts),所以我们直接根据官方文档来对这两个api做一个简单的了解。
DatabaseSync
这是针对数据库对象本身提供的一个构造函数,其本体是一个原生binding对象,并且其底层也是使用的sqlite3
。此部分可以直接通过查看nodejs的源码看到其实现:
我们在nodejs中实例化的其实是一个原生的C对象,实例化对象的几个api也是通过napi的形式进行bingding的,因此我们并不需要关心这些api的具体实现细节,直接来看如何使用即可。
new DatabaseSync(location[, options])
构造函数,提供了两个参数,其中第一个参数location
是必须的,为sqlite的存储文件路径,可以使用绝对路径也可以是相对路径;同时支持内存读取模式,使用固定的字符串":memory:""
进行访问。第二个参数options
是可选的配置对象,不过目前仅有open
一个选项值,代表是否在实例化对象时直接打开数据库的连接(connection),默认为true
。
ts
// 通过内存创建数据库,将在node进程结束时清空
const mem_db = new DatabaseSync(':memory:')
// 将会创建db.sqlite文件(如果不存在),操作过程中的结果将会持久化在这个文件里
const db = new DatabaseSync('./db.sqlite')
close()
用于关闭数据库的连接,这个方法内部是对sqlite3_close_v2
的包装实现。
- 如果对一个被关闭的数据库对象进行操作,会抛出一个异常。
- 如果对一个已经关闭的数据库对象执行这个方法,也会抛出一个异常。
open()
用于开启数据库的连接,但是只能对未被开启的数据库对象进行操作,否则会抛出异常。
exec(sql)
用来立刻执行一段SQL语句,传递一个字符串参数。这个方法并不关心返回的结果,一般用来进行数据库初始化的一些的操作。
ts
// 创建数据表
db.exec(`CREATE TABLE IF NOT EXISTS table1 (
key INTEGER PRIMARY KEY,
value TEXT
) STRICT`)
prepare(sql)
用来创建一个SQL预处理对象,返回的类型为StatementSync
,支持通过预处理语句实现类似字符串模板插入的功能,会对拆入SQL语句的值做特殊处理,保证一些SQL语句的安全。
ts
const query = db.prepare(`SELECT * FROM table1 WHERE key=?`)
StatementSync
这是模块提供的SQL语句预处理对象,本身是一个构造函数,但是目前文档里并没有提供直接对构造函数进行实例化的案例,所以推荐通过DatabaseSync::prepare
进行创建。
StatementSync
创建的实例对象提供的是一些快捷的查询操作,还有对查询语句的配置接口。不过根据实践过程来看,目前并不需要关心所有的API,因此我只在下面介绍了部分接口的用法。
需要查看更多文档,可以点击查看:SQLite | Node.js v22.5.1 Documentation (nodejs.org)
all([namedParameters][, ...anonymousParameters])
/ get([namedParameters][, ...anonymousParameters])
都是用来获取查询结果的,一般配合各类查询语句进行查询。它们都支持通过在prepare中设置占位,然后传递对应的外部参数。 其中all获取的是一个数组,而get则获得是第一个匹配结果。
run([namedParameters][, ...anonymousParameters])
与get
或者all
用法类似,但是不同的是,不再返回查询的表对象,而是只返回操作的结果。在nodejs中,返回的是一个固定格式的对象
ts
{
changes:number|bigint // 受到影响的行数
lastInsertRowid:number|bigint // 最近被插入的行id
}
写一个KV存储
既然了解完了node:sqlite
的用法,那么也应该写一个简单的案例来验证一下可行性。
其实为什么本人如此关心这个包的关心,很大一部分原因就是deno跟bun。
deno在实现上直接提供了对WebStorage的兼容,这意味着在deno天生自带了KV库,而bun则是内置了bun:sqlite
。但是这两个目前都还不适合用在生产上,不过确实成功让nodejs社区产生了紧迫感,这点是非常好的。
所以在第一时间,就完成了一个简单的KV库实现。不过仅作为可行性的demo,并不具备任何生产能力,请勿将此demo直接用于生产代码!!! 这里先贴上demo代码的地址:mowtwo/node22-5-sqlite-demo: 用nodejs22.5的sqlite库实现一个KV (github.com)
核心代码
本人作为一个切图仔,编码能力并不是很强,因此简单的展示一下实现
ts
// src/storage.ts
import { DatabaseSync } from "node:sqlite";
export class NodeStorage implements Storage {
private static _storage: NodeStorage
private static get storage() {
if (!this._storage) {
NodeStorage._storage = new NodeStorage()
}
return NodeStorage._storage
}
private db: DatabaseSync
private tableName: string
length: number
private constructor(location?: string, tableName?: string) {
this.tableName = tableName ?? `__nodeStorage_${Date.now()}`
this.db = new DatabaseSync(location ?? ':memory:')
this.db.exec(`
CREATE TABLE IF NOT EXISTS ${this.tableName}(
key TEXT PRIMARY KEY,
value TEXT
) STRICT
`)
this.length = 0
}
private reLength() {
this.length = this.db.prepare<{
'COUNT(*)': number
}>(`SELECT COUNT(*) FROM ${this.tableName}`).get()?.["COUNT(*)"] ?? 0
}
clear(): void {
this.db.exec(`DELETE FROM ${this.tableName}`)
this.length = 0
}
getItem(key: string): string | null {
return this.db.prepare<{
value: string
}>(
`SELECT value FROM ${this.tableName} WHERE key=?`
).get(key)?.value
}
key(index: number): string | null {
return this.db.prepare<{
value: string
}>(
`SELECT value FROM ${this.tableName} WHERE rowid=?`
).get(index + 1)?.value
}
removeItem(key: string): void {
this.db.prepare(
`DELETE FROM ${this.tableName} where key=?`
).run(key)
}
setItem(key: string, value: string): void {
this.db.prepare(
`INSERT OR REPLACE INTO ${this.tableName} (key, value) VALUES (?, ?)`
).run(key, value)
this.reLength()
}
static get length() {
return NodeStorage.storage.length
}
static clear() {
return NodeStorage.storage.clear()
}
static getItem(key: string) {
return NodeStorage.storage.getItem(key)
}
static key(index: number) {
return NodeStorage.storage.key(index)
}
static removeItem(key: string) {
return NodeStorage.storage.removeItem(key)
}
static setItem(key: string, value: string) {
return NodeStorage.storage.setItem(key, value)
}
static __fork(location: string, tableName?: string) {
return new NodeStorage(location, tableName)
}
}
通过一个NodeStorage类构建主要的代码实现,并对外释放一个默认的单例存储对象,此方法创建的数据库在使用:memory:
形式进行存储。
提供了静态的__fork
方法可以构建一个指定存储路径的对象,并且可以指定存储的表名。 通过此方法就可以分别实现类似localStorage(本地持久化)和sessionStorage(单次会话持久化)。
使用案例
ts
// src/index.ts
import { NodeStorage as _ } from "./libs/storage.js";
const NodeStorage = _.__fork('data.sqlite', 'wtf')
console.log(NodeStorage.getItem('name'))
NodeStorage.setItem('name', 'Mowtwo')
NodeStorage.setItem('age', '21')
console.log(NodeStorage.getItem('name'))
console.log(NodeStorage.getItem('age'))
console.log(NodeStorage.length)
NodeStorage.removeItem('age')
console.log(NodeStorage.length)
console.log(NodeStorage.key(0))
NodeStorage.setItem('age', '26')
console.log(NodeStorage.getItem('age'))
console.log(NodeStorage.length)
运行结果
文章总结
总体上仅作为对nodejs22.5.0更新的node:sqlite
库做一个体验,本次更新其实还带来了很多其他的变化,不过对本人来说并没有特别大的兴趣。听闻后续nodejs也在跟进typescript的运行时支持,所以接下来就可以期待一下能否支持内置node:jsx
,nodejs今年的变化是非常让人惊喜的,来自另外两个竞争对手的压力让它不再摆烂,这是一件好事。