第 3 章:引入 Knex + Schema 自动加载

第 3 章:引入 Knex + Schema 自动加载

问题

上一章裸写 query.js 用 better-sqlite3 跑通了,但写的时候暴露了一堆方言问题:

  1. LIMIT/OFFSET SQL 每个方言不一样 (SQL Server 要写 OFFSET m ROWS FETCH NEXT n ROWS ONLY,Oracle 要套子查询)
  2. 自增 ID 返回方式不一样 (SQLite 是 .lastInsertRowid,PostgreSQL 要写 RETURNING id)
  3. 字段名转义引号不一样 (MySQL 用反引号 `````,PostgreSQL 用双引号 ")
  4. 连接管理 API 不一样 (同步驱动一个 new Database(path) 就够,异步驱动要连连接池)
  5. 手动 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 帮我处理了所有方言差异,我只写业务逻辑。

但有两个遗留问题:

  1. query 封装还没写。上面是手动拼的,应该像上一章那样封装成通用 query 函数。
  2. 手动 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
  }
}

三个关键点:

  1. 先存 Promise 再 await------这是防击穿的核心,先存后等,保证所有并发请求拿到的是同一个 Promise
  2. 失败时 delete cachetable------如果查库失败(表不存在、连接断了),不清理的话后续所有请求拿到的都是 rejected Promise,永远报错
  3. 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 函数虽然通用了,但还不够好用:

  1. 每次都要传 knex 和 table ,每张表的查询都要手动调 query(knex, "orders", ...),如果一个模块里有 5 个查询,就要写 5 遍 query(knex, table, ...)------我想封装成 ordersCrud.find({...}) 这样的调用方式
  2. 写操作呢? 只有读(find / query),还没有 create / update / remove。写操作需要额外保护:update 和 remove 绝对不能允许空条件(防止误改/误删全表),create 需要白名单过滤非法字段
  3. 错误处理乱 ,throw new Error 到处都是,没有区分参数错误(应该返回 400)、资源不存在(404)、权限错误(403)。但错误体系这时候先不急------先把 CRUD 封装出来再说

下一章:把 query + schema + 写操作封装成一张表一个 CRUD 实例。

相关推荐
flash俊杰1 小时前
合并式恢复的事务边界:幂等跳过、外键顺序与向量重建标记
后端
学长毕设1 小时前
基于springboot的实验室设备管理系统
java·spring boot·后端
学长毕设2 小时前
基于springboot的社区养老监护系统的设计与实现
java·spring boot·后端
会编程的吕洞宾2 小时前
RAG检索质量怎么评估?3个指标+LLM裁判实战
java·开发语言·人工智能·后端
wno7042 小时前
Springboot国际化信息(i18n)解析
java·spring boot·后端
郭小李3 小时前
spring 的 @CookieValue 注解 用法
java·后端·spring
god_cvz3 小时前
麒麟 V10(ARM64 / aarch64)源码编译 3dtiles 全步骤
后端·gis
武子康3 小时前
批量设备任务显示 COMPLETED,为什么还有设备失败?
人工智能·后端·agent
泡海椒4 小时前
折线图Y轴刻度悬空之谜-信用评级趋势
后端