@k3000/store
v1.9.3
Published
storage
Downloads
606
Readme
结构化存储工具
*1、低版本升级到1.3.0请执行upgrade和submit更新存储文件。
*2、1.5.0版本请注意page的用法。
*3、1.6.1修改String中文存储的BUG建议更新,更新请注意第1、2条和page的用法。
*4、1.7.0修复中文路径问题。
*5、1.8.0添加存储目录的备份与迁移的描述(无功能调整)。
*6、1.9.0让Trae builder优化代码添加了注释。
*7、反馈与讨论:[email protected]
初始化和更新升级
import upgrade from 'dmb3'
const {appendSet, submit} = upgrade('test', {version: 1, password: '', newPwd: ''})
appendSet(/* */)
submit()文件结构为:假设存储名为:test
test ┳ 1.2 ┳ buffer ┳
┃ ┃ ┇ // buffer格式或者文本格式的存储目录
┃ ┃ ┗
┃ ┣ data // 数据存储文件
┃ ┣ index // 数据索引文件
┃ ┗ index.mjs // 数据访问文件,可以通过该文件访问指定版本
┣ ...
┣ 3 ┳ ...
┃ ┗ index.mjs // 数据访问文件,可以通过该文件访问指定版本
┣ 4 ...
┗ index.mjs // 默认入口文件,指向最新版本数据类型
类型:Id, Uint, Int, Bool, Time, Float, String, Buffer, Text
| 类型 | 说明 | 默认长度(字节) | 默认值 | 备注 |
| :--- | :--- | :--- | :--- | :--- |
| Id | 自增主键 | 4 | 自动自增 | 默认为索引 |
| Uint | 无符号整数 | 4 | 0 | 可选 1/2/3/4 字节 |
| Int | 有符号整数 | 4 | 0 | 可选 1/2/3/4 字节 |
| Float | 双精度浮点 | 8 | 0 | - |
| Bool | 布尔值 | 1 | false | 实际存储为 Uint(1) |
| Time | 时间 | 6 | 0 | 可设 Time.now / Time.update |
| String | 定长字符串 | 12 | '' | 超出长度会被截断 |
| Bigint | 64位整数 | 8 | 0n | - |
| Buffer | 二进制数据 | 1 | - | 存储在外置文件,主文件存标志位 |
| Text | 长文本 | 1 | - | 存储在外置文件,主文件存标志位 |
import upgrade, {
Bigint,
bigintSerialize,
BigUint,
Buffer,
Float,
Id,
Int,
String,
Text,
Time,
Uint
} from 'dmb3'
const {appendSet, submit} = upgrade('test')
appendSet('user', {
uid: String,
pwd: String('存MD5指').length(32),
loginTime: Time().value(Time.update)
}, '用户表')
submit().then(() => console.log('完成'))缺省写法与完整写法
// Time.value(Time.update) 和 Time().value(Time.update) 两种写法一样。
/* 例如:
appendSet('test', {
id: Id,
})
等价
appendSet('test', {
id: Id(),
})
*/
// Id 用法
Id('备注') // 会补全默认值
Id()
.index(false) // 是否索引,此类型默认为 true
.value('2') // 默认值,此类型缺省为 1
.step(2) // 缺省自增值,此类型缺省为 1
.remark('备注') // 可缺省
// Uint 用法
Uint(4)
.index(false) // 是否索引,此类型默认为 false
.value(2) // 默认值,此类型没有缺省值
.step(2) // 缺省自增值,此类型没有缺省值
.remark('备注') // 可缺省
// Int 用法
Int()
.index(false) // 是否索引,此类型默认为 false
.value(2) // 默认值,此类型没有缺省值
.step(2) // 缺省自增值,此类型没有缺省值
.length(4) // 长度,此类型默认为 4
.remark('备注') // 可缺省
// Bool 用法
Bool()
.index(false) // 是否索引,此类型默认为 false
.value(false) // 默认值,此类型缺省为 false
.remark('备注') // 可缺省
// Time 用法
Time()
.index(false) // 是否索引,此类型默认为 false
.value(Time.update) // 默认值,此类型没有缺省值,可取值:Time.update、Time.now
.remark('备注') // 可缺省
// Float 用法
Float()
.index(false) // 是否索引,此类型默认为 false
.value(0.1) // 默认值,此类型没有缺省值
.step(0.1) // 缺省自增值,此类型没有缺省值
.remark('备注') // 可缺省
// String 用法
String()
.index(false) // 是否索引,此类型默认为 false
.value('123456') // 默认值,此类型没有缺省值
.length(8) // 长度,此类型默认为 12
.remark('备注') // 可缺省
// Buffer 用法
Buffer().remark('备注') // 可缺省
// Text 用法
Text().remark('备注') // 可缺省存储结构更新写法
// 新增集合
appendSet('user', {
uid: String,
pwd: String('存MD5指').length(32)
}, '用户表')
// 更新集合备注
updateSetRemark('log', '日志表')
// 更新列
updateCol('user', {
loginTime: Time().value(Time.update).remark('登录时间')
})
// 更新索引
updateIndex('test', {
uid: true,
pwd: false,
})
// 更名操作
renameSet('test', 'test2')
renameCol('user', {
uid: 'usn',
test: 'test2',
})
// 删除操作
deleteSet('test2')
deleteCol('user', 'test2')
const {submit} = upgrade('test', {
version: 7,
password: '123123',
newPwd: '111111' // 新密码
})
submit()打开使用已有存储文件
使用
import storage from './test/index.mjs' // 使用最新版本
import storage from './test/7/index.mjs'// 使用指定版本
import storage, {remark, getStruct} from './test/7/index.mjs' // 获取存储结构和集合备注查:
storage.admin.find(admin => admin.usn.includes('test')).role
if (storage.admin.indexByUid('admin')[0].pwd === '123456');
// 带索引的时间
storage.admin.indexByLoginTime(-Infinity, '2022/01/01')
// 简单分页查询
storage.admin.page(admin => predicate(admin), index, size)
// 结果缓存的分页查询,缓存10秒
const params = {key: 'name', index: 1, size: 10}
storage.admin.page(admin => predicate(admin), 'index', 'size', params)
// 联合查询 eachFlat类似LEFT JOIN,filterFlat类似INNER JOIN
storage.user.eachFlat(storage.userRole, (a, b) => a.userId === b.userId)
.filterFlat(storage.role, 'roleId')增:
storage.admin.push(
{
usn: 'admin',
pwd: '123456'
},
{
usn: 'test',
pwd: '123456'
}
)改:
storage.admin.find(admin => admin.usn === 'admin').role = 'root'删:
storage.admin.remove(...storage.admin.filter(admin => admin.usn.startsWith('test')))更多使用方法参考 test.mjs
保存
没3秒会自动保存,也可以手工操作
import {close} from './test/index.mjs'
close() // 关闭并保存备份与迁移
当使用upgrade升级的时候会按照版本号备份历史版本。
也能直接将整个存储目录保存,复制妥存。
同样道理,迁移就是将存储目录拷贝到相应项目目录,使用相应版本的库运行即可。
概述
- 本工具提供一种轻量的、可版本化的本地结构化存储方案,面向 Node.js 项目;以“集合/列”定义模式,生成访问层代码,数据落盘到二进制文件并配合加密索引文件。
- 适合嵌入式、桌面端或需要无需外部数据库的应用,支持索引查询、范围查询、分页与联合查询,且提供平滑的结构升级与密码迁移能力。
核心概念
Storage:底层存储运行时,负责数据读写、缓存、加解密、结构与记录的持久化以及并发打开检查。Entity:单条记录对象,字段访问器封装了二进制读写与索引维护;支持 Buffer/Text 外置文件字段。Entities:集合的代理,提供push/pop/unshift/splice/remove/page等数组语义与indexBy<Field>查询。- 集合/列定义:通过类型 DSL(
Id/Uint/Int/BigUint/Bigint/Time/Float/String/Buffer/Text)声明列类型、索引、默认值、长度、步进。
目录结构详解
index:二进制索引文件,包含两段加密 JSON:结构(struct)与记录(record)。头部额外包含一个 4 字节整数作为“打开计数”,用于并发打开检测。data:二进制数据文件,按集合总长度定长布局存储;每条记录按列position/length定位读取。buffer/:外置文件目录,存放Buffer/Text字段实际内容;字段本体存储一个字节作为标志位,值为 1 表示存在外置内容。<version>/index.mjs:访问层代码,按结构自动生成集合实体类与集合代理;顶层index.mjs始终重导出最新版本。
加密与密码
- 结构与记录的加解密使用
AES-192-CBC,密钥由md5(password)+salt派生,IV 来自 MD5 的中间字节;密码来源于Storage(import.meta.url)的查询参数或upgrade传参。 - 密码迁移:通过
upgrade('name', { newPwd, password })在提交时重新加密结构与记录;若密码不匹配或存储文件损坏会抛出错误。
版本与升级流程
upgrade(name, options)会:- 计算最新版本
v并将其目录完整复制到新版本目录version; - 打开旧/新
Storage(支持密码切换),准备生成路径(index.mjs/type.ts); - 返回结构变更 API;
submit()会写入加密结构与记录、关闭句柄、生成访问代码,并在集合结构有更新时把旧版数据迁移到新版集合。
- 计算最新版本
索引与查询
- 为列开启索引可使用
updateIndex('set', { col: true })或在 DSL 中String().index(true)等;索引是按字段二进制值排序的记录位置列表。 - 字段写入使用两种模式:索引列通过
set2维护有序索引(二分定位插入),普通列使用set直接写入。 - 查询方法:
- 值查询:
indexBy<Field>(value)返回与二进制值相等的所有记录; - 范围查询:
indexBy<Field>(value1, value2)或针对Time用indexByTime({ after, before }); - 普通数组查询:
filter/find/some/findIndex等对集合进行内存遍历。
- 值查询:
分页与缓存
page(predicate, index, size, params)支持两种模式:- 简单分页:
page(fn, 1, 10)返回第 1 页、大小 10 的结果(按记录顺序); - 缓存分页:
page(fn, 'index', 'size', params)将params作为缓存键,命中时不重复计算;内部每轮(3 秒)标记过期,下一轮未访问的缓存将被移除。
- 简单分页:
大字段(Buffer/Text)
Buffer/Text字段内容存储在buffer/目录,以记录起始位置+列偏移作为文件名;字段本体只存放 1 字节标记位。- 删除或替换记录时,会自动清理关联的外置文件;读取时通过
readFileSync返回二进制或文本内容。
性能与注意事项
- 自动保存与清理:每 3 秒写回需要保存的值、刷新记录并清理过期缓存;
- 缓存上限:Value/Entity/Cache 的 Map 最多各 99999 条;超过上限的写入会直接落盘而不进入缓存;
- 并发打开:打开计数变化会抛错提示“已在其他地方打开”,防止同目录并发写入;
- 路径解析:
Storage(import.meta.url)在 file URL 情况下使用process.cwd()作为目录基础,请在正确工作目录下运行或传显式路径; - 时间编码:
Time字段以定长十六进制 Buffer 表示,使用d2b/b2d辅助范围比较;最大时间常量为MaxTime。
API 参考(结构变更)
const gen = upgrade(name, { version, password, newPwd, store })
gen.appendSet(name, set, remark) // 新增集合
gen.updateCol(name, set) // 更新集合列
gen.renameSet(name, newName) // 集合更名
gen.renameCol(name, { old: new }) // 列更名
gen.deleteSet(name) // 删除集合
gen.deleteCol(name, colName) // 删除列
gen.updateSetRemark(name, remark) // 更新集合备注
gen.updateIndex(name, { col: true }) // 索引开关
gen.submit() // 提交生成与数据迁移最小示例
import upgrade, { Id, String, Time, Uint } from '@k3000/store/generator.mjs'
const { appendSet, submit } = upgrade('myStore')
appendSet('user', {
id: Id,
uid: String('账号').index(true),
pwd: String('密码').length(64),
createdAt: Time('创建时间').index(true).value(Time.now),
status: Uint(1).remark('状态位')
}, '用户表')
await submit()
// 使用
import storage from './myStore/index.mjs'
storage.user.push({ uid: 'alice', pwd: '***', status: 1 })
const recent = storage.user.indexByCreatedAt({ after: new Date('2024-01-01') })常见问题(FAQ)
Error: ENOENT: no such file or directory, open '.../data':当前工作目录与版本目录不匹配或目录未初始化。请先执行upgrade生成版本,并在该目录下运行。密码不匹配或者存储文件不正确:传入密码与存储目录加密信息不一致,或索引文件损坏。请确认密码或重新初始化。索引查询无结果:检查是否为该列开启索引(定义或updateIndex),以及值/范围是否与列类型与长度匹配。page 返回空:检查size是否为正数,predicate是否正确,或缓存参数键名是否与'index'/'size'一致。
备份与迁移补充
- 每次
upgrade会将旧版本目录完整复制为备份;迁移仅需复制整个存储目录到目标项目,并确保使用兼容版本的库与正确密码即可。
缺点与不足
这个项目实现了一个轻量级的、无依赖的、基于文件的结构化存储系统(类似简易数据库),虽然设计精巧且自给自足,但在生产环境中使用存在显著的风险,“不建议用作主数据库”。
以下是存在的主要缺点与不足:
1. 严重的安全与稳定性隐患 (Critical)
非异步 I/O 阻塞事件循环: architect.mjs 中大量使用了 readSync, writeSync, openSync 等同步文件操作。Node.js 是单线程的,这意味着每次读写数据库都会完全卡死整个应用程序的事件循环,直到文件操作完成。在高并发或大文件读写时,会导致服务器无响应。 缺乏 Crash Safety (崩溃安全): 没有发现 Write-Ahead Log (WAL) 或事务日志机制。依赖内存缓存 (needSave 标志) 和每 3 秒的定时器 (Storage) 落盘。 数据丢失风险: 如果进程在 3 秒间隔内崩溃或断电,这期间的所有数据修改将永久丢失。 文件损坏风险: 如果在写入数据的瞬间崩溃,文件可能只写了一半,导致数据库彻底损坏且无法恢复。 并发控制脆弱: 仅依赖文件头部的“打开计数器”来防止并发,并建议“防止同目录并发写入”。这无法有效处理多进程环境,容易导致竞态条件(Race Condition)和数据错乱。
2. 性能与扩展性瓶颈
内存占用高: 索引(Indexes)似乎是全量加载到内存中的 (#index Set)。对于大数据集,这会消耗大量 RAM。 写入性能随数据量下降: 索引维护使用了 splice 插入 (set2 方法中),这是一个 O(N) 操作。随着数据量增加,插入新数据并维护排序索引的速度会越来越慢。 缓存限制: 硬编码了 MaxSize = 99999 的缓存上限,超过后直接落盘。这种策略比较生硬。
3. 功能局限性
查询能力有限: 虽然支持 filter, find, indexBy...,但本质上很多操作是在遍历内存或文件。不支持复杂的 SQL 语义(如复杂的 JOIN, GROUP BY, 子查询等)。flat 系列方法仅提供了非常基础的关联能力。 缺乏生态工具: 作为一个自定义的二进制格式,没有现成的 GUI 客户端、备份工具、数据迁移工具(除了自带的 upgrade)、可视化监控等。调试数据非常困难,只能写代码查。
4. 安全性细节
密码学实践不标准: 使用 md5(key) 作为后续密钥派生的基础。MD5 如今被认为是不够安全的(抗碰撞性弱),虽然后续用了 scrypt,但起手式用 MD5 会降低整体熵值。 IV 来源于 MD5 的中间字节,这种非随机生成的 IV 在某些加密场景下可能是不安全的。 测试代码中暗示用户密码可能只存了 MD5 (String('存MD5指')),这是过时的做法,现代应用应使用 bcrypt/argon2。 总结建议
