原生 SQL 写起来又臭又长,还容易拼接出错、引入注入漏洞。ORM(对象关系映射)把数据库表映射成类,把行映射成对象,让开发者用操作对象的方式操作数据库——$user->save() 而非 INSERT INTO users ...。几乎所有现代建站框架都内置 ORM,但它是怎么把对象操作翻译成 SQL 的?本篇带你拆解 ORM 的核心原理。
一、查询构造器:链式调用的秘密
ORM 最直观的功能是链式查询:where('id', 1).orderBy('time').get()。这种写法优雅,但底层如何工作?秘密在于每个链式方法都返回当前查询对象自身(this),把条件暂存在对象属性里,直到调用 get() 或 first() 才真正执行 SQL。
// 查询构造器核心实现
class QueryBuilder {
constructor(table, db) {
this.table = table;
this.db = db;
this.wheres = []; // where 条件暂存
this.orders = []; // orderBy 暂存
this.limitNum = null; // limit 暂存
}
// 链式方法:返回 this 实现链式调用
where(column, operator, value) {
// 兼容 where('id', 1) 的简写形式
if (value === undefined) { value = operator; operator = '='; }
this.wheres.push({ column, operator, value, type: 'basic' });
return this; // 关键:返回自身
}
orWhere(column, operator, value) {
if (value === undefined) { value = operator; operator = '='; }
this.wheres.push({ column, operator, value, type: 'or' });
return this;
}
orderBy(column, direction = 'ASC') {
this.orders.push({ column, direction });
return this;
}
limit(num) {
this.limitNum = num;
return this;
}
// 终结方法:把暂存条件拼成 SQL 并执行
async get() {
let sql = `SELECT * FROM ${this.table}`;
const params = [];
// 拼接 WHERE
if (this.wheres.length) {
const clauses = this.wheres.map(w => {
params.push(w.value);
const link = w.type === 'or' ? 'OR' : 'AND';
return `${link} ${w.column} ${w.operator} ?`;
});
sql += ' WHERE ' + clauses.join(' ').replace(/^(AND|OR)\s/, '');
}
// 拼接 ORDER BY
if (this.orders.length) {
sql += ' ORDER BY ' + this.orders.map(o => `${o.column} ${o.direction}`).join(', ');
}
// 拼接 LIMIT
if (this.limitNum) sql += ` LIMIT ${this.limitNum}`;
return this.db.query(sql, params); // 参数绑定防注入
}
}
// 使用:链式调用,优雅直观
const users = await db.table('users')
.where('status', 1)
.where('age', '>', 18)
.orderBy('created_at', 'DESC')
.limit(10)
.get();
// 生成:SELECT * FROM users WHERE status = ? AND age > ? ORDER BY created_at DESC LIMIT 10
注意所有值都用 ? 占位符并通过参数绑定传入,这是防 SQL 注入的根本措施——参数不会被视为 SQL 语法的一部分。尧图项目里严禁用字符串拼接构造 SQL,即便只是内部管理后台也一样,因为注入漏洞往往出现在"以为不会有问题"的地方。
二、模型与对象映射
查询构造器返回的还是原始数据(关联数组),而 ORM 的精髓在于"对象映射"——把一行数据封装成一个模型对象,对象上能挂方法(如格式化日期、计算属性)。模型类继承基类,自动获得增删改查能力,开发者只需定义表名和字段。
// 模型基类
class Model {
// 子类覆盖
static table = '';
static fillable = []; // 允许批量赋值的字段(防 mass assignment)
constructor(attributes = {}) {
this.attributes = attributes;
}
// 魔术方法:$user->name 自动取 attributes.name
get(key) { return this.attributes[key]; }
set(key, value) { this.attributes[key] = value; }
// 新增:把当前对象插入数据库
async save() {
if (this.attributes.id) {
// 有主键,执行 UPDATE
const sets = [];
const params = [];
for (const key of Object.keys(this.attributes)) {
if (key === 'id') continue;
if (!this.constructor.fillable.includes(key)) continue; // 字段白名单
sets.push(`${key} = ?`);
params.push(this.attributes[key]);
}
params.push(this.attributes.id);
const sql = `UPDATE ${this.constructor.table} SET ${sets.join(', ')} WHERE id = ?`;
return db.query(sql, params);
} else {
// 无主键,执行 INSERT
const cols = this.constructor.fillable.filter(c => this.attributes[c] !== undefined);
const sql = `INSERT INTO ${this.constructor.table} (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`;
const params = cols.map(c => this.attributes[c]);
const result = await db.query(sql, params);
this.attributes.id = result.insertId; // 回填自增ID
return result;
}
}
// 静态查询:User.where(...).first()
static where(...args) {
return new QueryBuilder(this.table, db).where(...args);
}
static async find(id) {
const rows = await new QueryBuilder(this.table, db).where('id', id).get();
return rows[0] ? new this(rows[0]) : null; // 封装成模型对象
}
}
// 业务模型:只需声明表名和可填充字段
class Article extends Model {
static table = 'articles';
static fillable = ['title', 'content', 'author_id', 'status', 'publish_at'];
// 模型方法:格式化发布日期
getPublishDate() {
return new Date(this.get('publish_at')).toLocaleDateString('zh-CN');
}
}
// 使用:操作对象就是操作数据库
const article = new Article({
title: '尧图建站教程',
content: 'ORM 让数据库操作更优雅',
author_id: 1,
status: 1
});
await article.save(); // INSERT
article.set('title', '修改后的标题');
await article.save(); // UPDATE(因为有 id 了)
const found = await Article.find(1);
console.log(found.getPublishDate()); // 用模型方法
fillable 字段白名单是个重要安全机制——防止"批量赋值漏洞"。如果用户提交表单时偷偷加上 is_admin=1,没有白名单的话管理员权限就被非法提升。白名单只允许预期字段被写入,多余字段直接丢弃。这是尧图所有模型必配的防护。
三、关联关系与懒加载
真实业务里表与表有关联:一篇文章属于一个作者(belongs to)、一个作者有多篇文章(has many)。ORM 用关联关系方法描述这些联系,调用时自动发起额外查询。这里有个性能陷阱——N+1 查询问题:取出 10 篇文章,再循环取每篇的作者,就是 1+10=11 次查询。解法是"预加载"(eager loading),一次 JOIN 把关联数据全取回来。
// 关联关系定义
class Article extends Model {
static table = 'articles';
// 属于一个作者(articles.author_id → users.id)
author() {
return this.hasOne(User, 'id', 'author_id');
}
}
class User extends Model {
static table = 'users';
// 拥有多篇文章
articles() {
return this.hasMany(Article, 'author_id', 'id');
}
}
// 懒加载:用到时才查询(N+1 问题)
const articles = await Article.where('status', 1).limit(10).get();
for (const a of articles) {
const author = await a.author(); // 每篇都查一次 → 11次查询
console.log(a.get('title'), author.get('name'));
}
// 预加载:一次性带出关联数据(2次查询解决)
const articles = await Article.with('author') // 告诉 ORM 顺带取作者
.where('status', 1)
.limit(10)
.get();
// 第1次:SELECT * FROM articles WHERE status=1 LIMIT 10
// 第2次:SELECT * FROM users WHERE id IN (1,2,3,5,8) ← 收集所有 author_id 一次查
for (const a of articles) {
const author = a.relations['author']; // 已在内存,无需查询
console.log(a.get('title'), author.get('name'));
}
预加载的关键是"收集所有关联 ID,用 IN 一次查回,再按 ID 分配到各对象"。这个优化能把 N+1 次查询降到 2 次,列表页性能提升立竿见影。尧图在代码评审时会把 N+1 当作代码异味——任何循环里出现关联查询都要检查是否用了预加载。理解了查询构造器、模型映射、关联预加载这三大件,你就能读懂任意 ORM 源码,也能在框架 ORM 不够用时自己写查询构造器扩展。