第 3 章:引入 Knex + Schema 自动加载
问题
上一章裸写 query.js 用 better-sqlite3 跑通了,但写的时候暴露了一堆方言问题:
- LIMIT/OFFSET SQL 每个方言不一样 (SQL Server 要写
OFFSET m ROWS FETCH NEXT n ROWS ONLY,Oracle 要套子查询) - 自增 ID 返回方式不一样 (SQLite 是
.lastInsertRowid,PostgreSQL 要写RETURNING id) - 字段名转义引号不一样 (MySQL 用反引号 `````,PostgreSQL 用双引号
") - 连接管理 API 不一样 (同步驱动一个
new Database(path)就够,异步驱动要连连接池) - 手动 schema 每张表写一遍太烦 (上一章用的是手动
{ allowed: Set, types: {} },10 张表写 10 遍)
问题 1-4 指向同一个方向:我需要一个抽象层屏蔽方言差异。问题 5 顺手在同一个方向解决------这个抽象层说不定能帮我自动拉表结构。
选 Knex 的真实理由
上一章已经铺垫过:ORM(Prisma、Sequelize)抽象层太厚,会让我用 ORM 的模型定义替代原生建表;Knex 是 SQL 查询构建器,刚好卡中间。
核心原因就一条:Knex 有 .raw(sql, bindings) 能力,能接受我自己编译的 buildWhere 产物 。而我已经有了一个能编译 JSON → {sql, params} 的 buildWhere------我只需要一个把原生 SQL 片段喂给不同数据库的"万能接口",不需要重新学一套 ORM API。
bash
npm install knex
# better-sqlite3 上一章已经装了,Knex 用它当 client
# 生产环境换 MySQL 或 PostgreSQL 只需改 client: "mysql2" / "pg"
把原生 query.js 换成 Knex 版
上一章的原生 query.js 里分页 SQL 是这样的:
js
// 上一章裸写版(SQLite 专用)
const dataSql = sql + ` LIMIT ${size} OFFSET ${(page - 1) * size}`
const rows = _db.prepare(dataSql).all(...params)
用 Knex 之后------一行搞定,SQL 自动按方言生成:
js
// Knex 版(所有方言自动生成正确的分页 SQL)
const rows = await knex(table)
.whereRaw(sql, params)
.limit(size)
.offset((page - 1) * size)
同样的,自增 ID 也不用关心了:
js
// 上一章裸写版
const info = _db.prepare("INSERT INTO orders (status, amount) VALUES (?, ?)").run(1, 100)
const newId = info.lastInsertRowid // better-sqlite3 特有
// Knex 版
const inserted = await knex("orders").insert({ status: 1, amount: 100 }).returning("*")
// Knex 根据 client 自动生成:SQLite 用 RETURNING *,PostgreSQL 用 RETURNING *,SQL Server 用 OUTPUT INSERTED.*
// inserted 在 SQLite 下就是插入后的行
第一个完整封装:Knex 版 query.js
js
const knex = require("knex")({
client: "better-sqlite3",
connection: { filename: ":memory:" },
useNullAsDefault: true
})
// 建表(用 Knex 的 schema builder,SQL 自动按方言生成)
await knex.schema.createTable("orders", (t) => {
t.increments("id").primary()
t.integer("status").notNullable()
t.real("amount").notNullable()
t.string("name")
})
// buildWhere 编译 → 直接喂给 knex.whereRaw()
const { sql, params } = buildWhere({ status: 1, amount$gt: 50 }, ordersSchema) // 先手动 schema
const rows = await knex("orders").whereRaw(sql, params).limit(10).offset(0)
console.log(rows)
比上一章的原生版干净多了------没有手动拼分页 SQL,没有关心 .lastInsertRowid,没有字段名引号转义。Knex 帮我处理了所有方言差异,我只写业务逻辑。
但有两个遗留问题:
- query 封装还没写。上面是手动拼的,应该像上一章那样封装成通用 query 函数。
- 手动 schema 还是手动 。上一章的痛点"每张表写一遍
{ allowed: [...], types: {...} }"还没解决。
问题 2 的解法是自动从数据库拉表结构 。Knex 自己有 .columnInfo(),但返回格式各方言不统一。下一节就解决这个。
怎么拿表结构
一开始没想太多------Knex 自己有个 .columnInfo() 方法,直接用:
js
const cols = await knex("orders").columnInfo()
console.log(cols)
// better-sqlite3 返回:
// { id: { type: 'INTEGER', nullable: false, defaultValue: null },
// status: { type: 'INTEGER', nullable: false, defaultValue: null },
// name: { type: 'TEXT', nullable: true, defaultValue: null } }
能用。类型信息拿到了,types = { id: "number", status: "number", name: "string" } 能凑出来,手动 schema 可以开始自动加载了。
但过了两三天,我开始写 findOne(id) 的时候才发现问题 ------我想支持 crud.findOne(1)(按 id 查),但代码里怎么知道 id 就是主键?万一用户表主键叫 user_id 呢?万一一张表没主键呢?
columnInfo() 不给主键标记。而且我查了一下------MySQL 下 columnInfo() 返回格式是 { Field: 'id', Type: 'int', Null: 'NO', Key: 'PRI' },PostgreSQL 下又是 { column_name: 'id', data_type: 'integer', is_nullable: 'NO' },每个方言返回格式都不一样 ,MySQL 里虽然有个 Key: 'PRI' 能凑合识别主键,但我不能为了拿个 pk 写三份方言判断逻辑。
这是写了几天之后才遇到的卡壳点。然后我去 npm 搜 "knex schema inspector"、"knex column info unified"、"get primary key from knex"------搜了好半天,才找到 knex-schema-inspector 。它做的事情就是:封装各方言的差异,返回统一格式的列信息,而且列信息里有我最需要的 is_primary_key 标记。
js
const { SchemaInspector } = require("knex-schema-inspector")
const inspector = new SchemaInspector(knex)
const cols = await inspector.columnInfo("orders")
console.log(cols)
// [
// { name: 'id', data_type: 'INTEGER', is_primary_key: true, has_auto_increment: true, ... },
// { name: 'status', data_type: 'INTEGER', is_primary_key: false, ... },
// { name: 'amount', data_type: 'REAL', is_primary_key: false, ... },
// { name: 'name', data_type: 'TEXT', is_primary_key: false, ... },
// ]
完美------data_type 统一了,is_primary_key 直接告诉我哪个是 pk,name 统一了(不用管它在 MySQL 里叫 Field 还是在 PG 里叫 column_name)。
我把之前用 columnInfo() 写的那版 schema-cache 全部推翻重写------这个包才是我要的。
bash
npm install knex-schema-inspector
但每次查表结构太慢------加缓存
第一次查 columnInfo 要走一次 information_schema(SQLite 里是 PRAGMA table_info),如果一个列表查询里要调好几次 getSchema(比如 count 一次、find 一次,分别需要不同的 schema 字段),就会多好几次系统表查询。
表结构在运行时基本不变(除非在线 DDL),所以缓存命中率应该接近 100%。但缓存有个经典问题:缓存击穿。
想象一下:第一次查 orders 表,50 个并发请求同时发现缓存里没有,50 个请求就各自去查一次 columnInfo,50 次系统表查询。
解法:缓存 Promise 而不是缓存结果。第一个请求创建 Promise 存进缓存,后续并发请求拿到同一个 Promise,等它 resolve 后共享结果。只有一次查库。
js
const { SchemaInspector } = require("knex-schema-inspector")
const cache = {}
async function getSchema(knex, table) {
// 缓存命中:返回同一个 Promise(可能还在 pending,但共享同一个)
if (cache[table]) return cache[table]
// 缓存未命中:先创建 Promise 存缓存,再执行
const promise = (async () => {
const inspector = new SchemaInspector(knex)
const cols = await inspector.columnInfo(table)
// 提取有用的信息
const names = new Set(cols.map((c) => c.name))
const byName = Object.fromEntries(cols.map((c) => [c.name, c]))
const pk = cols.find((c) => c.is_primary_key)?.name ?? null
// 类型归并:数据库类型很多,我只关心 number / string 两类
// 因为 buildWhere 的类型转换只需要知道"要不要把字符串转成 number"
const types = {}
for (const c of cols) {
types[c.name] = /int|decimal|numeric|float|double/i.test(c.data_type) ? "number" : "string"
}
return { cols, names, byName, pk, types }
})()
cache[table] = promise
try {
return await promise
} catch (e) {
// 失败时清缓存,允许后续重试
delete cache[table]
throw e
}
}
三个关键点:
- 先存 Promise 再 await------这是防击穿的核心,先存后等,保证所有并发请求拿到的是同一个 Promise
- 失败时 delete cachetable------如果查库失败(表不存在、连接断了),不清理的话后续所有请求拿到的都是 rejected Promise,永远报错
- pk 不兜底 'id' ------有些表主键叫
user_id、uuid,甚至没主键。兜底'id'会让代码生成WHERE id = ?查一个不存在的列。宁可返回 null 让调用方处理,也不要猜
自动 schema 直接喂给 buildWhere,不用改一行代码
上一章已经写好了带手动 schema 的 buildWhere(json, schema)------schema 接受 { allowed: Set, types: {} } 格式,做字段白名单 + 类型转换 + 安全正则。
现在 getSchema 自动拉回来的格式是 { types: {}, names: Set, byName: {}, pk: null }。字段名和类型都在里面了,转一下就能直接喂给 buildWhere:
js
const autoSchema = await getSchema(knex, "orders")
// getSchema.types 直接就是 { id: "number", status: "number", amount: "number", ... }
// getSchema.names 直接就是 Set(["id", "status", "amount", ...])
// 跟上一章手动 schema 的结构完全对得上
const { sql, params } = buildWhere(whereJson, {
allowed: autoSchema.names,
types: autoSchema.types
})
buildWhere 一行代码都不用改 ------编译层(parseKey / buildExpr / buildAnd)和约束层(applySchema)解耦了,schema 来源从手动变成自动是调用方的事,编译层完全不感知。
这就是第 1 章留 schema 参数可选、编译层和约束层解耦的好处------当时只想到"以后 schema 可能换来源",没想到第 2 章就用上了。
封装通用查询函数 query.js
现在把 buildWhere + schema + knex 串起来,加分页、排序、字段投影:
js
async function query(knex, table, opts = {}, cb) {
// 1. 自动加载 schema
const schema = (await getSchema(knex, table)).types
// 2. 解构控制参数和 where 条件
let { fields, sort, top, page, size, ...where } = opts
// 3. 分页参数校验
if (top !== undefined) {
if (page !== undefined || size !== undefined) {
throw new Error("top 与 page/size 不能同时使用")
}
}
if ((page !== undefined) !== (size !== undefined)) {
throw new Error("page 和 size 必须同时传")
}
// 4. 编译 where
const { sql, params } = buildWhere(where, schema)
let base = knex(table).whereRaw(sql || "1=1", params)
// 5. 字段投影 + 排序
if (fields) base.select(...fields)
if (sort) {
for (const [f, d] of Object.entries(sort)) {
base.orderBy(f, String(d).toLowerCase())
}
}
// 6. 逃生口:允许用户在 base 上加 join 等扩展
if (typeof cb === "function") {
const result = cb(base, schema)
if (result !== undefined) {
if (result && typeof result.then === "function") return await result
return result
}
}
// 7. 最终返回
if (top) return base.limit(top)
if (page !== undefined) {
const [rows, info] = await Promise.all([
base
.clone()
.limit(size)
.offset((page - 1) * size),
base.clone().count({ total: "*" }).first()
])
return { rows, total: Number(info.total), page, size }
}
return base
}
cb 逃生口是干嘛的?buildWhere 只能编译单表 where,但有时候需要 join、子查询、group by------这时候用户可以传一个回调,拿到已经编译好 where 的 knex builder,自由扩展。cb 如果 return 了 Promise 就当最终结果,否则 query 继续加分页逻辑。
跑通标志
js
// demo-query.js
const query = require("./lib/query")
const knex = require("knex")({
client: "better-sqlite3",
connection: { filename: ":memory:" },
useNullAsDefault: true
})
async function main() {
await knex.schema.createTable("users", (t) => {
t.increments("id").primary()
t.string("name")
t.integer("age")
t.integer("status")
})
// 插入几条测试数据
await knex("users").insert([
{ name: "张三", age: 28, status: 1 },
{ name: "李四", age: 35, status: 2 },
{ name: "王五", age: 22, status: 1 }
])
const schema = (await getSchema(knex, "users")).types
// 测试 1:带 schema 类型转换
const r1 = await query(knex, "users", {
where: { status: "1", age$gte: "25" }, // "1" 和 "25" 是字符串
schema,
sort: { id: "asc" }
})
console.log(r1)
// 应该能查到张三和李四
// 测试 2:分页
const r2 = await query(knex, "users", { schema, page: 1, size: 2 })
console.log(r2.total, r2.rows.length) // total=3, rows.length=2
// 测试 3:非法字段被拦截
try {
await query(knex, "users", { where: { password: "hacked" }, schema })
} catch (e) {
console.log("被拦截了:", e.message) // 非法查询字段: password
}
}
main()
全部跑通就说明这一章 OK。到目前为止只用了 knex 和 knex-schema-inspector 两个 npm 包。
跑通之后的痛点
query 函数虽然通用了,但还不够好用:
- 每次都要传 knex 和 table ,每张表的查询都要手动调
query(knex, "orders", ...),如果一个模块里有 5 个查询,就要写 5 遍query(knex, table, ...)------我想封装成ordersCrud.find({...})这样的调用方式 - 写操作呢? 只有读(find / query),还没有 create / update / remove。写操作需要额外保护:update 和 remove 绝对不能允许空条件(防止误改/误删全表),create 需要白名单过滤非法字段
- 错误处理乱 ,
throw new Error到处都是,没有区分参数错误(应该返回 400)、资源不存在(404)、权限错误(403)。但错误体系这时候先不急------先把 CRUD 封装出来再说
下一章:把 query + schema + 写操作封装成一张表一个 CRUD 实例。