Sequelize v6 教程 05:Getters, Setters & Virtuals

本文基于 Sequelize v6 官方文档,示例使用 TypeScript、MySQL 与 mysql2。Getter、Setter 和 Virtual 都主要工作在 Sequelize 模型实例层;它们不会自动变成 MySQL 生成列、触发器或约束。

学习目标

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

1. 三个概念先分清

假设数据库中保存:

first_name = "PENELOPE"
last_name  = "GUINESS"

三种能力分别解决不同问题:

能力 触发时机 是否对应真实列 典型用途
Getter 从模型实例读取属性时 通常是 展示转换、兼容旧存储格式
Setter 给模型实例属性赋值时 通常是 去空格、大小写归一化、组合存储
Virtual 读取或写入虚拟属性时 全名、派生状态、临时输入字段

它们不是 Express 的 req/res 属性,也不是 JavaScript 全局状态。数据跟随当前模型实例;实例不再被引用后可被垃圾回收。

2. 自定义 Getter

Getter 允许“数据库原始值”和“业务代码看到的值”不同。

下面将 Sakila 演员的 first_name 读取为首字母大写形式。为了聚焦概念,只展示关键模型配置:

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

const sequelize = new Sequelize("sakila", "root", "password", {
  dialect: "mysql",
  host: "127.0.0.1",
  logging: console.log,
});

class Actor extends Model<
  InferAttributes<Actor>,
  InferCreationAttributes<Actor>
> {
  declare actorId: CreationOptional<number>;
  declare firstName: string;
  declare lastName: string;
}

Actor.init(
  {
    actorId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
      field: "actor_id",
    },
    firstName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "first_name",
      get(): string {
        const rawValue = this.getDataValue("firstName");
        return rawValue.charAt(0).toUpperCase()
          + rawValue.slice(1).toLowerCase();
      },
    },
    lastName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "last_name",
    },
  },
  {
    sequelize,
    tableName: "actor",
    timestamps: false,
  },
);

读取属性会触发 getter:

const actor = await Actor.findByPk(1);

if (actor !== null) {
  console.log(actor.firstName); // 例如 Penelope
  console.log(actor.getDataValue("firstName")); // 例如 PENELOPE
}

为什么必须使用 getDataValue

firstName 自己的 getter 中再次访问 this.firstName,会再次触发同一个 getter,形成无限递归:

// 错误示例
get(): string {
  return this.firstName.toLowerCase();
}

正确方式是 this.getDataValue("firstName"),它读取实例内部保存的底层字段值,不再次调用自定义 getter。

Getter 不会改写数据库

自定义 getter 只改变读取表现。上例 MySQL 中仍然保存 PENELOPE。如果希望以后统一保存为某种格式,应使用 setter、service 层处理,或者执行明确的数据迁移。

3. 自定义 Setter

Setter 在给实例属性赋值时执行,适合做同步、确定性的规范化。

class Customer extends Model<
  InferAttributes<Customer>,
  InferCreationAttributes<Customer>
> {
  declare customerId: CreationOptional<number>;
  declare email: string;
}

Customer.init(
  {
    customerId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
      field: "customer_id",
    },
    email: {
      type: DataTypes.STRING(50),
      allowNull: false,
      set(value: string): void {
        this.setDataValue("email", value.trim().toLowerCase());
      },
    },
  },
  {
    sequelize,
    tableName: "customer",
    timestamps: false,
  },
);
const customer = Customer.build({
  email: "  MARY@example.com ",
});

console.log(customer.email); // mary@example.com

buildcreate、直接赋值以及通常的 set/update 流程中,字段 setter 会参与赋值。

为什么必须使用 setDataValue

在 setter 内写 this.email = value 会再次触发 setter,形成递归。正确方式是:

set(value: string): void {
  this.setDataValue("email", value.trim().toLowerCase());
}

Setter 与校验的次序

setter 先把输入转换为实例中的值,随后校验器通常针对转换后的值执行。例如:

email: {
  type: DataTypes.STRING(50),
  allowNull: false,
  set(value: string): void {
    this.setDataValue("email", value.trim().toLowerCase());
  },
  validate: {
    isEmail: true,
  },
},

这适合把 " USER@EXAMPLE.COM " 先标准化,再校验邮箱格式。但不要用 setter 悄悄“修复”本应拒绝的业务错误,否则调用者可能不知道输入已经被改变。

4. Getter 和 Setter 可以组合

官方文档展示了将多个值组合存储、读取时再拆分的思路。下面用一个教学模型演示把标签数组保存为逗号分隔字符串:

class Article extends Model {
  declare tags: string[];
}

Article.init(
  {
    tags: {
      type: DataTypes.TEXT,
      get(): string[] {
        const rawValue = this.getDataValue("tags") as unknown as string;
        return rawValue === "" ? [] : rawValue.split(",");
      },
      set(value: string[]): void {
        this.setDataValue("tags", value.join(",") as unknown as string[]);
      },
    },
  },
  { sequelize, tableName: "article" },
);

这个例子能说明机制,但生产中不一定是好表结构:

如果标签需要查询、关联和约束,应该建立 articletagarticle_tag 三张规范化表。Getter/Setter 不能替代关系数据库建模。

上例的类型断言暴露了一个事实:TypeScript 业务属性是 string[],数据库列却是 TEXT 字符串,两层类型并不一致。对学习项目可以借此理解转换机制;生产项目更推荐用明确的持久化字段名,或把转换放入 service/DTO 层,减少类型绕行。

5. Virtual:不写入数据库的模型属性

虚拟属性使用 DataTypes.VIRTUAL。它存在于 Sequelize 模型中,但不会成为 INSERTUPDATE 的真实列。

为了让虚拟字段的创建类型清晰,下面显式定义属性接口:

import { DataTypes, Model, Optional } from "sequelize";

interface ActorAttributes {
  actorId: number;
  firstName: string;
  lastName: string;
  fullName: string;
}

type ActorCreationAttributes = Optional<
  ActorAttributes,
  "actorId" | "fullName"
>;

class ActorWithFullName extends Model<
  ActorAttributes,
  ActorCreationAttributes
> implements ActorAttributes {
  declare actorId: number;
  declare firstName: string;
  declare lastName: string;
  declare fullName: string;
}

ActorWithFullName.init(
  {
    actorId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
      field: "actor_id",
    },
    firstName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "first_name",
    },
    lastName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "last_name",
    },
    fullName: {
      type: DataTypes.VIRTUAL,
      get(): string {
        return `${this.getDataValue("firstName")} ${this.getDataValue("lastName")}`;
      },
      set(_value: string): never {
        throw new Error("fullName 是只读虚拟属性");
      },
    },
  },
  {
    sequelize,
    tableName: "actor",
    timestamps: false,
  },
);
const actor = await ActorWithFullName.findByPk(1);

if (actor !== null) {
  console.log(actor.fullName); // PENELOPE GUINESS
  console.log(actor.toJSON()); // 序列化时通常包含 fullName
}

近似 SQL 只选择真实列,不会查询 full_name

SELECT actor_id, first_name, last_name
FROM actor
WHERE actor_id = 1;

Virtual 可以声明返回类型

DataTypes.VIRTUAL 可以描述返回类型和依赖字段:

fullName: {
  type: new DataTypes.VIRTUAL(DataTypes.STRING, ["firstName", "lastName"]),
  get(): string {
    return `${this.getDataValue("firstName")} ${this.getDataValue("lastName")}`;
  },
},

依赖字段信息可帮助 Sequelize 在选择虚拟属性时把所需真实字段一并取回。即便如此,团队仍应测试实际生成 SQL,尤其是在 attributes 精简与关联查询组合时。

6. 虚拟字段的典型用途

6.1 派生展示值

例如:

若派生值依赖“当前时间”,同一实例在不同时刻读取可能得到不同结果,应避免把它误认为数据库中稳定保存的状态。

6.2 接收临时输入

虚拟字段也可以设置 setter,把一个输入拆到多个真实字段。但复杂输入解析通常更适合 DTO/service 层,因为:

7. raw: true 会绕过实例能力

const actors = await ActorWithFullName.findAll({
  raw: true,
});

raw: true 不构造 Model 实例,因此不要期待实例 getter、virtual 和实例方法正常工作。若接口依赖 fullName,可以选择:

  1. 查询实例并调用 toJSON()
  2. 查询普通数据后在 DTO 映射函数中计算;
  3. 使用 SQL 表达式计算,但要评估数据库兼容性和查询成本。

选择应明确,不要让同一个 repository 有时返回实例、有时返回 raw 对象,却使用模糊的返回类型掩盖差异。

8. 不要在 Setter 中做异步密码哈希

Setter 是同步属性访问机制,不适合直接执行需要 await 的密码哈希:

// 不推荐:setter 无法自然地等待异步哈希完成
set(password: string): void {
  // const hash = await bcrypt.hash(password, 12); // 不能这样写
}

更清晰的方案是在 service 中先完成异步哈希,再写入模型:

async function createUser(input: {
  email: string;
  password: string;
}): Promise<User> {
  const passwordHash = await hashPassword(input.password);

  return User.create({
    email: input.email.trim().toLowerCase(),
    passwordHash,
  });
}

也可以使用 Sequelize hooks,但 hook 的隐式行为更多,批量操作是否触发 hook 也需要明确配置和测试。对初学项目,service 层通常最容易理解和排错。

9. Getter 不是安全边界

可以使用 getter 隐藏或掩码某些值,但这不能代替访问控制:

密码哈希、令牌等敏感字段应默认从查询 attributes 中排除,并通过明确 DTO 决定响应字段。不要把“getter 返回 undefined”当成完整防泄漏方案。

10. 哪一层负责什么

需求 更合适的位置
去除邮箱首尾空格、统一小写 setter 或 service;数据库再加唯一约束
异步密码哈希 service 或经过严格测试的 hook
姓名展示组合 virtual 或响应 DTO
必须唯一、不能为空 MySQL 约束,同时配合 Sequelize 校验
多表可查询的标签 规范化关联表
API 专属字段重命名和脱敏 DTO/序列化层
跨所有写入来源都必须成立的规则 数据库约束

MySQL 是持久化事实来源。Getter、Setter 和 Virtual 是应用层便利能力,其他程序直接写数据库时不会执行它们。若以后将 DTO 缓存在 Redis,应缓存 getter/virtual 处理后的稳定响应结构,并明确缓存版本;不要直接缓存带方法和内部状态的 Sequelize 实例。

11. 常见问题与经验

11.1 Getter 隐藏了真实数据

排查数据问题时同时检查:

console.log(instance.fieldName);
console.log(instance.getDataValue("fieldName"));

并查看 MySQL 原始记录,避免把展示转换误认为数据库已经改变。

11.2 过多魔法降低可维护性

如果读取一个字段会执行大量业务逻辑,开发者很难从 actor.fullName 看出成本和副作用。Getter 应保持同步、轻量、确定,不能发数据库请求或调用远程服务。

11.3 Setter 导致“脏字段”变化

转换后的值与原值不同,可能影响 Sequelize 的变更检测。更新问题应检查:

console.log(instance.changed());
console.log(instance.previous("email"));

11.4 数据迁移仍然必要

新加 setter 只影响以后经过该模型写入的数据,不会自动清洗历史数据。上线规范化规则时,要评估旧数据迁移、唯一冲突和回滚方案。

12. 小结

练习题(暂不提供答案)

  1. 为 Sakila actor 模型添加只读虚拟属性 fullName,格式为 firstName + 空格 + lastName
  2. 为 Sakila customer.email 编写 setter:去除首尾空格并转成小写;再说明为什么 MySQL 仍需要唯一约束。
  3. 为 World country 模型添加虚拟属性 populationInMillions,把 Population 转换为“百万人”数值,保留两位小数。
  4. 编写一个 getter,把 Sakila film.rating 转换为适合中文页面展示的说明;同时保留读取数据库原始评级的方法。
  5. 分别使用实例查询和 raw: true 查询带 virtual 的模型,记录两者返回结构的差异。
  6. 设计一个错误的递归 getter 和 setter 示例,解释为什么会递归,并改为使用 getDataValuesetDataValue
  7. 假设旧系统把多个标签以逗号分隔形式保存在一个 TEXT 字段中,写出 getter/setter 后,再分析何时应该迁移为多对多关联表。
  8. 设计一个创建用户的 service:对邮箱进行规范化,异步哈希密码,并明确说明哪些逻辑不应放在同步 setter 中。

官方文档