概述
本文是笔者的系列博文 《Bun技术评估》 中的第二篇。
本文主要探讨的内容,是研究和评估bun对于关系数据库,特别是Postgres(笔者主要使用)的支持程度。因为作为一个号称"All In One"的开发体系,bun内置了SQL(现在应当包括了SQLite和PostgreSQL)的模块,这个是笔者非常感兴趣的。
本章节相关主要内容,来自bun的官方技术文档:
主要特性
在开始之前,我们先来了解一下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也有类似的方式,其标准形式如下:
js
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的技术文档来看,它更推崇的是一种"环境变量控制"的连接方法。代码中没有明显的数据库连接过程,而是环境变量中,配置好连接相关信息后,执行查询时,系统会自动建立一个连接好的数据库操作环境来。
下面是一个示例:
js
// .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+准备参数+执行的工作流程不太吻合。
我们先来看一个示例:
js
// 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也支持笔者习惯的,比较传统的执行方式,如下:
js
// 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数组),而是一个"复合"的结果对象,要获得真正的结果,还需要转换一下。
- 原始形式
例如,在前面"数据库连接"章节中,示例代码在连接数据库之后,执行了一个数据库版本信息的查询,查询结果的原始内容,其实是这样的:
js
[
{
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()方法,可以将查询结果,输出成为一个二维数组(而非一个对象数组),里面就只有
js
const rows = await sql`SELECT * FROM users`.values();
console.log(rows);
[
["Alice", "[email protected]"],
["Bob", "[email protected]"],
];
- raw()
这个方法在values基础上更进一步,直接输出buffer的二维数组,据说这些可能是基于性能的考虑。比如如果要直接将结果输出到接口上,应当可以增强输出的性能。
js
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模板字符串,在前面的示例中,已经有所展示。所有单个和确定性的参数,都可以使用这种模式。
插入数据
插入数据这个操作很常见,但其实又很多需要注意的细节。下面这段代码,就很好的展示了这一点:
js
// Using object helper for cleaner syntax
const userData = {
name: "Alice",
email: "[email protected]"
};
const [newUser] = await sql`
INSERT INTO users ${sql(userData)}
RETURNING *
`;
- 插入数据时,通常需要指定要插入的字段和对应的数据,通常就是列举字段名称和参数值
- bun sql可以自动基于参数对象,来自动补足要填充的自动名称(使用sql构造方法)
- 插入时,使用returning是一个比较好的操作,因为如果使用了自增字段,插入后就可以同时获取id的值
sql的构造方法非常灵活,前面的示例是构造单条记录的字段名称和值,但其实可以构造批量的数据,或者选择特定字段来处理:
js
const users = [
{ name: "Alice", email: "[email protected]" },
{ name: "Bob", email: "[email protected]" },
{ name: "Charlie", email: "[email protected]", age: 25 },
];
await sql`INSERT INTO users ${sql(users,"name","email")}`;
更新数据
在更新时,也可以利用sql构造,来确定更新时使用的字段和内容:
js
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查询模式
js
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函数:
js
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语句,它们会被自动的分配到同一个事务当中,保证了一致性。
js
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的代码实现如下:
p1.ts
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参考代码:
p2.js
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模块来实现的。下面是一些简单的示例代码:
js
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的支持和实现。