Bun技术评估 - 05 SQL

0 阅读13分钟

概述

本文是笔者的系列博文 《Bun技术评估》 中的第二篇。

本文主要探讨的内容,是研究和评估bun对于关系数据库,特别是Postgres(笔者主要使用)的支持程度。因为作为一个号称“All In One”的开发体系,bun内置了SQL(现在应当包括了SQLite和PostgreSQL)的模块,这个是笔者非常感兴趣的。

本章节相关主要内容,来自bun的官方技术文档:

bun.sh/docs/api/sq…

主要特性

在开始之前,我们先来了解一下bun sql模块的主要特性,也可以看出来bun sql实现支持的程度,还有主要技术特点:

  • Tagged template literals to protect against SQL injection,使用“标记模板字面量”来保护SQL注入攻击

  • Transactions,事务支持

  • Named & positional parameters,命名和位置参数

  • Connection pooling, 连接池

  • Connection timeouts, 连接超时配置

  • BigInt support, 大整数支持

  • SASL Auth support (SCRAM-SHA-256), MD5, and Clear Text, 多样的认证支持

  • Returning rows as data objects, arrays of arrays, or Buffer,多种查询结果数据形式

  • Binary protocol support makes it faster,更快更高效的二进制协议

  • TLS support (and auth mode), TLS支持

  • Automatic configuration with environment variable,环境变量自动配置

其实,这个特性列表,编写的并不是很好。但它展示了bun sql实现的一些特点,特别是和传统的nodejs数据库框架如pg等不同的地方。我们会在后面的内容中展开探讨。

这里笔者想要强调一下,由于接触bun sql技术的时间并不是很长,很多概念和方法,还在理解和尝试当中,也许很多阐述和做法并不是很恰当,也可能有错误或不合适的地方,需要读者自行判断和注意。

数据库连接

传统的数据库框架,数据库的连接逻辑非常简单,就是提供连接参数,执行连接方法,就可以返回一个连接好的数据库对象,后续就可以执行相关数据操作了。bun也有类似的方式,其标准形式如下:


import { SQL } from "bun";

const db = new SQL({
  // Required
  url: "postgres://user:pass@localhost:5432/dbname",

  // Optional configuration
  hostname: "localhost",
  port: 5432,
  database: "myapp",
  username: "dbuser",
  password: "secretpass",

  // Connection pool settings
  max: 20, // Maximum connections in pool
  idleTimeout: 30, // Close idle connections after 30s
  maxLifetime: 0, // Connection lifetime in seconds (0 = forever)
  connectionTimeout: 30, // Timeout when establishing new connections

  // SSL/TLS options
  tls: true,
  // tls: {
  //   rejectUnauthorized: true,
  //   requestCert: true,
  //   ca: "path/to/ca.pem",
  //   key: "path/to/key.pem",
  //   cert: "path/to/cert.pem",
  //   checkServerIdentity(hostname, cert) {
  //     ...
  //   },
  // },

  // Callbacks
  onconnect: client => {
    console.log("Connected to database");
  },
  onclose: client => {
    console.log("Connection closed");
  },
});

但是,从bun的技术文档来看,它更推崇的是一种“环境变量控制”的连接方法。代码中没有明显的数据库连接过程,而是环境变量中,配置好连接相关信息后,执行查询时,系统会自动建立一个连接好的数据库操作环境来。

下面是一个示例:

// .env 
PGURL=postgres://user:pass@localhost:5432/dbname

// index.ts
import { sql } from "bun";

const start = async()=>{
    let qr = await sql`select version()`;
    console.log("", qr);
}; start();


这个示例中,查询代码直接使用的是从bun中import进来的sql作为标签模板函数,来执行一个sql模板字符串。当然,这个操作需要在异步函数中操作,并且增加promise的解析,就是async-await的调用方式。执行结果是一个结果对象,包含了查询的结果和其他信息,我们会再后面讨论。

这里的关键是,没有任何数据库连接的过程和代码! 只需要在项目的环境配置文件(.env)中,增加相关数据库的连接信息就可以了。而且更妙的是,bun会自动判断数据库连接的类型,并且调用合适的数据库连接驱动程序,来完成连接。

这里可能开发者会有一个疑问,就是这种方法,会不会造成反复连接,影响性能,或者造成过多的连接状态呢? 答案是应该不用为此担心,因为按照bun技术文档的说法,它的数据库连接使用了连接池(默认是10个),不需要在所有的查询都新建连接,而是直接使用连接池中的连接,这样既提高了查询的效率,又避免的连接超额的问题。

这个基于.env文件进行环境变量控制的方式,虽然简单方便,但主要问题是,不好控制这个连接的生命周期,比如连接失败,或者显示连接状态,或者增加一些连接的选项等等,那可能就需要一个手动的连接控制的形式了。

执行查询

bun的标准查询操作,使用一种“模板字面量”的方式,刚开始的时候,笔者其实是有点不适应的,因为它必须要把数据库对象和SQL语句写在一起,SQL不能是变量的形式,在其他地方进行定义和操作,和笔者习惯的准备SQL+准备参数+执行的工作流程不太吻合。

我们先来看一个示例:


// Optional WHERE clauses
const filterAge = true;
const minAge = 21;
const ageFilter = sql`AND age > ${minAge}`;
await sql`
  SELECT * FROM users
  WHERE active = ${true}
  ${filterAge ? ageFilter : sql``}
`;


这里展示了bun sql建议的方式:

  • SQL语句的主体,是一个模板字符串(反引号围起来的)
  • 这里sql是一个标签模板函数,这里直接使用数据库对象就可以
  • 标签模板函数调用,是可以复用和组合的,从而构造更复杂的SQL语句
  • 变量作为参数,注入模板字符串,是一种更安全的方式(防SQL注入攻击)

当然,bun sql也支持笔者习惯的,比较传统的执行方式,如下:


// Multiple commands without parameters
const result = await sql.unsafe(`
  SELECT ${userColumns} FROM users;
  SELECT ${accountColumns} FROM accounts;
`);

// Using parameters (only one command is allowed)
const result = await sql.unsafe(
  "SELECT " + dangerous + " FROM users WHERE id = $1",
  [id],
);

简单总结一下,可以使用一个unsafe方法,就可以实现传统(其实就是pg库)的带参数占位符的SQL语句+参数列表的执行方式了。如果实在是不喜欢标签模板函数的形式,也可以用这种方式,但这个unsafe函数名,确实是挺奇怪的?

结果集

笔者使用bun sql,还有一个不是很适应的地方,就是它对结果集的处理方式。就是,通常执行一个SQL查询的结果,并不是一个标准的结果集(通常就是一个转换的结果记录的JSON数组),而是一个“复合”的结果对象,要获得真正的结果,还需要转换一下。

  • 原始形式

例如,在前面“数据库连接”章节中,示例代码在连接数据库之后,执行了一个数据库版本信息的查询,查询结果的原始内容,其实是这样的:

[
  {
    version: "PostgreSQL 17.5 (Ubuntu 17.5-1.pgdg20.04+1) on aarch64-unknown-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit",
  },
  count: 1,
  command: "SELECT"
]

  • 数据和信息

但实际上,这个结构可以当作一个JSON数组来使用。所以,要获取其中的版本信息,可以使用 qr[0]?.version来获取。

  • values()

bun sql结果集,提供了一个values()方法,可以将查询结果,输出成为一个二维数组(而非一个对象数组),里面就只有

const rows = await sql`SELECT * FROM users`.values();
console.log(rows);

[
  ["Alice", "alice@example.com"],
  ["Bob", "bob@example.com"],
];

  • raw()

这个方法在values基础上更进一步,直接输出buffer的二维数组,据说这些可能是基于性能的考虑。比如如果要直接将结果输出到接口上,应当可以增强输出的性能。


const rows = await sql`SELECT * FROM users`.raw();
console.log(rows); // [[Buffer, Buffer], [Buffer, Buffer], [Buffer, Buffer]]

参数使用

基于防止SQL注入攻击的考虑,现在所有的SQL执行框架,都不建议直接使用SQL字符串拼装的方式,来生成和执行字符串。都是推荐使用带参数的SQL语句+参数列表的方式来构造。

在实际的业务应用开发中,会遇到各种各样数据操作的场景,很多情况下,并不是简单的单个或者固定数量参数的构造,这就对SQL执行架构的设计,提出了很多要求。经过一段时间的应用和体会,笔者觉得这套bun sql的实现,还是不错的,考虑到了很多实际的应用场景,也设计的比较简单、易用和优雅。

在本章节中,笔者会列举和分析一些典型的场景和示例,来说明这一点。

基本形式

基本形式非常简单,就是使用所谓的命名参数,来注入sql模板字符串,在前面的示例中,已经有所展示。所有单个和确定性的参数,都可以使用这种模式。

插入数据

插入数据这个操作很常见,但其实又很多需要注意的细节。下面这段代码,就很好的展示了这一点:

// Using object helper for cleaner syntax
const userData = {
  name: "Alice",
  email: "alice@example.com"
};

const [newUser] = await sql`
  INSERT INTO users ${sql(userData)}
  RETURNING *
`;


  • 插入数据时,通常需要指定要插入的字段和对应的数据,通常就是列举字段名称和参数值
  • bun sql可以自动基于参数对象,来自动补足要填充的自动名称(使用sql构造方法)
  • 插入时,使用returning是一个比较好的操作,因为如果使用了自增字段,插入后就可以同时获取id的值

sql的构造方法非常灵活,前面的示例是构造单条记录的字段名称和值,但其实可以构造批量的数据,或者选择特定字段来处理:


const users = [
  { name: "Alice", email: "alice@example.com" },
  { name: "Bob", email: "bob@example.com" },
  { name: "Charlie", email: "charlie@example.com", age: 25 },
];

await sql`INSERT INTO users ${sql(users,"name","email")}`;

更新数据

在更新时,也可以利用sql构造,来确定更新时使用的字段和内容:


await sql`UPDATE users SET ${sql(user, "name", "email")} WHERE id = ${user.id}`;
// uses all keys from the object to update the row
await sql`UPDATE users SET ${sql(user)} WHERE id = ${user.id}`;

复合条件

下面的示例,是典型的where in查询模式

let schoolid = 1247;
let clslist= [2023000,2023001,2023002];

qr = await sql`
select * from b_student where clsid in ${sql(clslist)} and schoolid = ${schoolid} limit 100
`;

手动构建

笔者在使用的过程中发现,其实bun sql构造方法,其实也是某种helper的实现,也是有一定的使用方面的限制的,在某次应用,发现了以下抛出的错误:

"Helper are only allowed for INSERT, UPDATE and WHERE IN commands"

所以,如果确实有需求,在某种情况下,可能还是需要使用标准的带参数占位符的SQL构建结合参数数组使用的情况,如下面这个helper函数:


const sqlParam =(pdata)=>{
    let 
    pl = Object.keys(pdata[0]).length,
    ll = Array(pl).fill(0).map((_,i)=>i+1), // params 
    idata = pdata.reduce((c,v,i)=>{
        c.vlist += (i>0?",":"") + `(${ll.map(j => "$"+(i*pl+j)).join(",")})`;
        c.params.push(...Object.values(v));

        return c;
    } ,{ vlist: "" , params:[]});

    idata.keys = Object.keys(pdata[0]);
    return idata;
}

// 将对象数组转换为SQL占位和参数
let vparam = sqlParam([
    {id: 1, name :"John"},
    {id: 2, name :"Tom" },
]);

qr = await sql.unsafe(`with A(${vparam.keys}) as (values ${vparam.vlist}) select * from A`, vparam.params);


这个辅助工具,可以将一个标准的JSON对象数组,转换成为标准的SQL格式,例如在上面的案例中,转换结果为下面的内容,实现了完全的参数化:

  • vparam.keys: id,name
  • vparam.vlist: ($1,$2),($3,$4)
  • vparam.params: [1,"John",2,"Tom"]

使用这种模式,几乎可以处理所有的SQL语句和参数的执行情况。

事务支持

这一特性,笔者在实际的应用中,使用到的机会并不多,所以这里就简单的说明一下,让读者了解有这个特性即可。bun sql使用事务也很简单,执行begin就可以开启一个事务,同时将事务作为参数,注入事务的处理方法当中。在这个方法中,可以使用事务来执行一系列的SQL语句,它们会被自动的分配到同一个事务当中,保证了一致性。

await sql.begin(async tx => {
  // All queries in this function run in a transaction
  await tx`INSERT INTO users (name) VALUES (${"Alice"})`;
  await tx`UPDATE accounts SET balance = balance - 100 WHERE user_id = 1`;

  // Transaction automatically commits if no errors are thrown
  // Rolls back if any error occurs
});

不支持的特性

bun毕竟是一个比较新的技术框架,有很多地方,其实还没有完善。从其技术文档中,我们可以看到一些内容,读者也可以将其作为评估和决策的依据:

  • MySQL支持,正在进行中
  • 预加载连接,还在开发
  • 原生SQLite支持,已计划,但还未开始。笔者理解现有的SQLite是基于一个外部模块实现的。
  • 字段名称转换,未完成
  • 字段类型转换,未完成

关于Postgres,还有一些尚未完成的部分,但笔者觉得对于日常Web应用的开发影响不大:

  • COPY 复制,这个功能,在需要进行大量的数据导入导出时,比较有用

  • LISTEN 订阅

  • NOTIFY 通知,和订阅一样,对于实现一个实时数据库应用,还是比较关键的

此外,在文档没有提到,但笔者其实很关心的一个特性: cursor,似乎也没有支持和实现。

性能

笔者设计了一个简单的测试,来比较了一下,几乎相同的操作,使用bun实现和原有的nodejs操作,所需要的时间。不一定非常严谨和科学,只是想建立一个初步的性能概念和印象。

bun的代码实现如下:

const start = async()=>{
    console.time("DATABASE");

    // insert 10000
    let qr,u,l = 1000;
    for(let i=0;i< l;i++) {
        u = { 
            name: Math.random().toString(36).slice(2,8), 
            age: 0 | Math.random()*50 + 20
        }
        qr = await sql`insert into users ${sql(u)} returning id`; 
    } 

    for(let i= 300; i < 500;i++) {
        qr = await sql`select id,name,age from users where id = ${i} limit 1`; 
    } 

    console.timeEnd("DATABASE");
}; start();

// 执行结果
bun test/p1.ts
[41.55s] DATABASE

nodejs参考代码:


const { Client } = require('pg');
const client = new Client({
    connectionString: "..."
});

client.connect()
  .then(() => console.log('连接成功'))
  .catch(err => console.error('连接失败', err));

const start = async()=>{
    console.time("DATABASE");

    // insert 10000
    let qr,u,l = 1000;
    for(let i=0;i< l;i++) {
        u = { 
            name: Math.random().toString(36).slice(2,8), 
            age: 0 | Math.random()*50 + 20
        }
        qr = await client.query(`insert into users (name,age) values ($1,$2) returning id`,[u.name,u.age]); 
    } 

    for(let i= 300; i < 500;i++) {
        qr = await client.query(`select id,name,age from users where id = $1 limit 1`,[i]); 
    } 

    console.timeEnd("DATABASE");
}; setTimeout(start, 2000);


PS C:\Work\bundev\ntest> node p2.js
连接成功
DATABASE: 4.282s
PS C:\Work\bundev\ntest> bun  p2.js
连接成功
[2.61s] DATABASE

如果读者观察的比较仔细的话,可能会发现一个比较不好理解的问题。就是bun sql本身的性能是比较差的(也可能是我测试的或者执行的方法不当),而且相比而言差距是比较大的。为了排除是由于对象属性解析造成的影响,笔者还曾改成了unsafe方法进行了测试,也得到了类似的结果,说明应该确实是bun sql本身的问题。这和bun声称的基于原生代码实现和优化能够提供更好的数据库操作性能,就不能匹配了。

但有趣的是,如果使用bun来执行nodejs版本的代码,就是使用pg npm库,却可以得到比nodejs执行更好的性能。简单的结论就是,bun sql本身的性能比较差(可能是实现的问题),但bun执行的性能,确实是比较好的。如果这个情况属实的话,bun sql是不适合作为数据库性性能敏感性的应用的,或者配合选择pg npm来实现。

PG模块

bun还有一个最大的优势,就是几乎完整的兼容nodejs生态,所以,如果读者觉得bun确实不能满足需求,也可以退回到标准的 pg npm。比如现在笔者在bun sql中,没有发现有关于cursor的相关功能,如果确有需求,可能需要退后到pg npm来实现这个应用的场景。

可以通过 bun add pg 指令,将pg添加到当前项目当中,添加之后,就跟在nodejs中使用pg一样的方式来操作即可。而且前面我们也已经做了简单的测试,pg npm的性能,应当是比bun sql要好的。

SQLite

在一些情况下,数据操作比较简单,这时使用标准的SQL数据库系统如Postgres,就有点杀鸡用牛刀的感觉了。这时可以考虑使用SQLite。bun也内置了SQLite的支持,除了在本地,使用文件进行操作之外,和普通的SQL数据库并没有太大的差别。但是在bun中,并不是原生实现的SQL支持SQLite,而是通过一个内部的bun:sqlite模块来实现的。下面是一些简单的示例代码:

import { Database } from "bun:sqlite";

const 
SQL_TB_USER=`
CREATE TABLE IF NOT EXISTS users (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	name TEXT,
	age INTEGER DEFAULT (0)
)`,
SQL_INSERT_USER=`insert into users (name,age) values (?, ?)`,
SQL_QUERY_USER=`select * from users where name = ? and age = ? limit ? `;

const start = async()=>{
    const db = new Database("mydb.sqlite", { create: true });
    
    // in memory: const db = new Database();
    
    db.exec(SQL_TB_USER);
    db.exec(SQL_INSERT_USER,['John',30]);

    const users = db.query(SQL_QUERY_USER).all(["John", 30, 2]);

    console.log("users:", users);
    
    db.close();
}; start();

这里有一些要点:

  • 使用import可以从bun:sqlite模块引入Database类
  • 创建时指定文件名,可以连接或者创建一个sqlite文件
  • 如果不指定文件名,则会在内存中创建数据库
  • 执行exec方法,结合参数,可以执行创建表、插入、更新、删除等操作
  • 执行query方法,可以创建一个查询对象
  • 调用后续的all方法,结合参数,可以执行查询,并获得结果集
  • 调用后续的get方法,将会获取查询结果中的第一个记录
  • 作为文件数据库,应当及时关闭连接,减少文件操作冲突或错误

这里只是作为介绍,展示了bun:sqlite的最基本的使用方式。更多的信息,读者可以参考bun技术文档的相关章节。

小结

在本章节中,笔者讨论了内置于bun中对postgres支持的功能:SQL。展示和例举了如数据库连接,环境配置信息,模板字符串执行的形式、unsafe模式,参数的构造和使用,结果集,事务支持等相关的内容。并补充说明了对于pg npm和sqlite的支持和实现。