Sequelize v6 教程 02:Model Instances

对应官方文档:Model Instances

1. 本章目标

完成本章后,你应该能够:

本章沿用上一章的 Sakila Actor 模型,并补充 World City 模型用于数值增减示例。

本文带 await 的片段默认写在 async 函数、Express 异步路由或服务层方法中。当前工程使用 CommonJS 编译模式,不应把它们直接作为文件顶层的 await 语句。

2. 实例是什么

当 Sequelize 查询到 actor 表的一行数据时,默认不会只返回一个普通对象,而是返回 Actor 类的实例:

const actor = await Actor.findByPk(1);

如果查询成功,actor 中既有数据,也有实例方法:

if (actor !== null) {
  console.log(actor.firstName);
  await actor.reload();
  await actor.update({ lastName: "SMITH" });
}

实例通常包含:

因此,不建议通过 console.log(actor) 判断所有业务数据,也不要依赖 _previousDataValues 等内部属性。需要普通对象时使用公开 API。

3. build():只在内存中构造

const actor = Actor.build({
  firstName: "ALICE",
  lastName: "CHEN",
});

这一步不会执行 INSERT。对象只存在于当前 Node.js 进程内存中:

console.log(actor.isNewRecord); // true

只有保存后才会写入 MySQL:

await actor.save();
console.log(actor.isNewRecord); // false
console.log(actor.actorId); // MySQL 返回的自增主键

大致 SQL:

INSERT INTO `actor` (`actor_id`, `first_name`, `last_name`, `last_update`)
VALUES (DEFAULT, 'ALICE', 'CHEN', CURRENT_TIMESTAMP);

注意:实际 SQL 可能使用参数绑定,并因模型默认值和方言细节而不同。

4. create()build()save() 的快捷方式

const actor = await Actor.create({
  firstName: "BOB",
  lastName: "WANG",
});

概念上等价于:

const actor = Actor.build({
  firstName: "BOB",
  lastName: "WANG",
});

await actor.save();

区别是 create() 一次完成构造和持久化,适合普通新增;build() 适合保存前还要执行多步同步处理、调用实例方法或检查实例状态的情况。

4.1 使用 fields 建立写入白名单

假设 HTTP 请求体是:

interface CreateActorBody {
  firstName: string;
  lastName: string;
  actorId?: number;
}

不要未经筛选就把整个请求体传给模型。可以明确允许写入的字段:

const body: CreateActorBody = req.body;

const actor = await Actor.create(
  {
    firstName: body.firstName,
    lastName: body.lastName,
  },
  {
    fields: ["firstName", "lastName"],
  },
);

这既使代码意图明确,也能降低客户端越权修改主键、权限或审计字段的风险。字段白名单不能取代请求校验和权限判断。

5. 读取实例数据

5.1 直接读取属性

const actor = await Actor.findByPk(1);

if (actor !== null) {
  console.log(actor.actorId);
  console.log(actor.firstName);
}

这是 TypeScript 项目中最直观的方式。

5.2 get()toJSON()

if (actor !== null) {
  const firstName = actor.get("firstName");
  const plainActor = actor.get({ plain: true });
  const jsonActor = actor.toJSON();

  console.log(firstName, plainActor, jsonActor);
}

Express 中可以执行 res.json(actor),因为 JSON 序列化会调用 toJSON();但在服务层显式转换为 DTO 往往更安全,因为可以稳定控制对外字段。

interface ActorDto {
  id: number;
  fullName: string;
}

function toActorDto(actor: Actor): ActorDto {
  return {
    id: actor.actorId,
    fullName: `${actor.firstName} ${actor.lastName}`,
  };
}

不要把 Sequelize 实例直接写入 Redis。若以后需要缓存查询结果,应缓存稳定的普通 DTO/JSON,并在成功提交 MySQL 更新后失效或更新缓存。

6. 修改属性与 save()

可以先修改实例属性,再调用 save()

const actor = await Actor.findByPk(1);

if (actor !== null) {
  actor.firstName = "PENELOPE";
  await actor.save();
}

Sequelize 会追踪哪些属性发生了变化,通常只更新变化的列。大致 SQL:

UPDATE `actor`
SET `first_name` = 'PENELOPE'
WHERE `actor_id` = 1;

Sakila 的 last_update 是否自动变化,取决于真实 MySQL DDL、模型设置以及数据库默认行为。应以日志和数据库结构为准。

6.1 检查变更

if (actor !== null) {
  actor.lastName = "GUINESS";

  console.log(actor.changed("lastName")); // true
  console.log(actor.previous("lastName")); // 上一次持久化时的值

  await actor.save();
  console.log(actor.changed("lastName")); // 通常为 false
}

changed()previous() 适合调试、审计逻辑或有条件地执行副作用,但不要依赖未公开的内部存储结构。

6.2 限定 save() 更新字段

if (actor !== null) {
  actor.firstName = "MARY";
  actor.lastName = "JONES";

  await actor.save({ fields: ["firstName"] });
}

这次只持久化 firstName。内存中的 lastName 与数据库可能暂时不同,随后应谨慎处理,必要时调用 reload()

7. set():批量设置实例属性

if (actor !== null) {
  actor.set({
    firstName: "NICK",
    lastName: "WAHLBERG",
  });

  await actor.save();
}

set() 默认只改内存,不会自动执行 SQL。它与 save() 是两个步骤。

如果数据来自 HTTP 请求,仍应先做校验和字段挑选:

actor.set({
  firstName: body.firstName,
  lastName: body.lastName,
});

不推荐 actor.set(req.body)。这种批量赋值很容易把以后新增的敏感字段暴露给客户端。

8. update():设置并立即保存

const actor = await Actor.findByPk(1);

if (actor !== null) {
  await actor.update({
    firstName: "ED",
    lastName: "CHASE",
  });
}

实例方法 update() 会修改实例并持久化。可以把它理解为常见场景下 set() + save() 的快捷方式。

请区分:

await actor.update({ firstName: "ED" }); // 实例方法,通常带主键条件

await Actor.update(
  { firstName: "ED" },
  { where: { actorId: 1 } },
); // 静态批量方法,必须认真编写 where

静态 Model.update() 返回的不是更新后的模型实例。在 MySQL 中通常可从返回值获得受影响行数;如果需要最新行,应再查询或在已有实例上使用实例方法并按需 reload()

9. reload():以数据库为准重新读取

const actor = await Actor.findByPk(1);

if (actor !== null) {
  actor.firstName = "NOT_SAVED";
  await actor.reload();
  console.log(actor.firstName); // 恢复为 MySQL 中的值
}

reload() 会重新执行 SELECT,并用数据库结果覆盖当前实例。常见用途:

它并不能解决并发写覆盖问题。并发修改需要根据业务使用事务、行锁或乐观锁等方案。

10. save() 的校验与保存范围

save() 默认运行模型校验。若校验失败,SQL 不会执行:

try {
  await actor.save();
} catch (error: unknown) {
  console.error("保存失败", error);
}

可以通过 validate: false 跳过模型校验,但普通业务代码很少应该这样做:

await actor.save({ validate: false });

即便 Sequelize 校验通过,MySQL 仍可能因唯一约束、外键约束、非空约束或死锁而拒绝写入,所以数据库错误处理不可省略。

11. destroy():删除当前实例对应的记录

const actor = await Actor.findByPk(201);

if (actor !== null) {
  await actor.destroy();
}

大致 SQL:

DELETE FROM `actor` WHERE `actor_id` = 201;

如果该演员已经被 film_actor 外键引用,MySQL 可能拒绝删除。这正是关系数据库约束在保护数据完整性。

工程中应先明确:

不要为了“让删除成功”而随意关闭外键检查。

12. increment()decrement()

World 数据库的 city.Population 是适合演示原子增减的数值列。简化模型如下:

import {
  CreationOptional,
  DataTypes,
  InferAttributes,
  InferCreationAttributes,
  Model,
} from "sequelize";
import { sequelize } from "./sequelize";

class City extends Model<
  InferAttributes<City>,
  InferCreationAttributes<City>
> {
  declare id: CreationOptional<number>;
  declare name: string;
  declare countryCode: string;
  declare district: string;
  declare population: number;
}

City.init(
  {
    id: {
      type: DataTypes.INTEGER.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
      field: "ID",
    },
    name: { type: DataTypes.STRING(35), allowNull: false, field: "Name" },
    countryCode: {
      type: DataTypes.CHAR(3),
      allowNull: false,
      field: "CountryCode",
    },
    district: {
      type: DataTypes.STRING(20),
      allowNull: false,
      field: "District",
    },
    population: {
      type: DataTypes.INTEGER,
      allowNull: false,
      field: "Population",
    },
  },
  {
    sequelize,
    tableName: "city",
    timestamps: false,
  },
);

执行增减:

const city = await City.findByPk(1);

if (city !== null) {
  await city.increment("population", { by: 1000 });
  await city.reload();
  console.log(city.population);
}

大致 SQL 使用列自身计算:

UPDATE `city`
SET `Population` = `Population` + 1000
WHERE `ID` = 1;

这比“先在 Node.js 中读取数值,再计算并保存”更能避免并发请求互相覆盖。decrement() 的用法相同。

不同数据库方言对 increment() 后返回值的处理可能不同。在 MySQL 中,如果后续逻辑依赖最新值,明确调用 reload() 更容易理解。

13. JSON 字段的变更检测陷阱

虽然 Sakila 和 World 的核心表没有典型 JSON 列,但实际项目经常使用 MySQL JSON。假设实例属性 settings 是对象,直接修改内部属性可能无法被识别为变化:

user.settings.theme = "dark";
await user.save();

更稳妥的方法是替换整个对象:

user.settings = {
  ...user.settings,
  theme: "dark",
};

await user.save();

也可以在明确理解后手动标记:

user.changed("settings", true);
await user.save();

这是因为 ORM 的变更追踪通常更容易识别顶层引用变化,而不是任意深度的对象突变。

14. 多步写操作需要事务

单个实例 save() 只保证一条写入的数据库行为。若业务需要“新增租赁记录并更新库存状态”同时成功,就必须使用同一个事务:

await sequelize.transaction(async (transaction) => {
  const rental = await Rental.create(
    {
      rentalDate: new Date(),
      inventoryId,
      customerId,
      staffId,
    },
    { transaction },
  );

  await inventory.update(
    { available: false },
    { transaction },
  );

  console.log(rental.rentalId);
});

关键点:事务对象必须传给事务内的每一个 Sequelize 操作。漏传一次,该 SQL 就会使用事务外的连接,导致“部分提交”。模型实例本身不会自动让多个 save() 形成一个事务。

15. 实例生命周期中的常见坑

15.1 长时间持有实例

实例只代表某次读取时的数据库状态。不要在全局变量中长期保存它,否则内容会过期,也可能意外保留大量关联数据。

15.2 先查再改并不天然安全

const city = await City.findByPk(1);
city.population += 1;
await city.save();

两个请求同时执行时可能丢失更新。简单计数使用 increment();复杂一致性逻辑使用事务和合适的锁或乐观锁。

15.3 save() 不是“保存整个 JavaScript 对象”

它只会按照模型定义、变更状态和 fields 选项生成 SQL。未定义属性不会自动成为数据库列。

15.4 日志中不要输出敏感实例

模型实例可能包含密码哈希、令牌、内部备注等字段。日志和 API 响应都应该输出经过挑选的 DTO,而不是整个实例。

16. 本章检查清单

17. 练习题(暂不提供答案)

  1. 使用 Actor.build() 构造一名 Sakila 演员,在保存前后分别检查 isNewRecordactorId,并记录执行了几条 SQL。
  2. 使用 Actor.create() 新增一名演员,只允许写入 firstNamelastName。尝试在输入中加入 actorId,验证字段白名单的效果。
  3. 查询 Sakila 中的一名演员,修改姓和名,但只使用 save({ fields: [...] }) 保存其中一项;随后使用 reload() 比较内存值和数据库值。
  4. 对某个 Actor 实例使用 changed()previous(),记录修改前、保存前、保存后三个阶段的结果。
  5. 使用 World 的 City 模型,将某城市人口原子增加 500,再 reload() 并验证结果。不要使用“读取后 + 500 再保存”的写法。
  6. 查询一条 World city 记录,将其转换为只包含 idnamepopulation 的普通 DTO,并说明为什么这个对象比模型实例更适合返回给前端。
  7. 尝试删除一名已参与电影的 Sakila 演员,观察 MySQL 外键约束错误,并记录应用层应如何把该错误转换为业务响应。
  8. 为 Sakila rental 表设计一个“归还影片”的实例更新流程:设置 returnDate,保存后重新读取;列出并发归还时可能出现的业务问题。
  9. 设计一个事务练习:在 Sakila 中新增一条 payment 后更新相关客户统计字段(统计字段可放在自建练习表),故意让第二步失败,验证第一步是否回滚。

18. 官方参考