本文基于 Sequelize v6 官方文档,示例使用 TypeScript、MySQL 与
mysql2。Getter、Setter 和 Virtual 都主要工作在 Sequelize 模型实例层;它们不会自动变成 MySQL 生成列、触发器或约束。
完成本章后,你应该能够:
DataTypes.VIRTUAL 定义不持久化到 MySQL 的派生属性;getDataValue 与 setDataValue,避免递归调用;raw: true 的差异;假设数据库中保存:
first_name = "PENELOPE"
last_name = "GUINESS"
三种能力分别解决不同问题:
| 能力 | 触发时机 | 是否对应真实列 | 典型用途 |
|---|---|---|---|
| Getter | 从模型实例读取属性时 | 通常是 | 展示转换、兼容旧存储格式 |
| Setter | 给模型实例属性赋值时 | 通常是 | 去空格、大小写归一化、组合存储 |
| Virtual | 读取或写入虚拟属性时 | 否 | 全名、派生状态、临时输入字段 |
它们不是 Express 的 req/res 属性,也不是 JavaScript 全局状态。数据跟随当前模型实例;实例不再被引用后可被垃圾回收。
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 只改变读取表现。上例 MySQL 中仍然保存 PENELOPE。如果希望以后统一保存为某种格式,应使用 setter、service 层处理,或者执行明确的数据迁移。
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
在 build、create、直接赋值以及通常的 set/update 流程中,字段 setter 会参与赋值。
setDataValue在 setter 内写 this.email = value 会再次触发 setter,形成递归。正确方式是:
set(value: string): void {
this.setDataValue("email", value.trim().toLowerCase());
}
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 悄悄“修复”本应拒绝的业务错误,否则调用者可能不知道输入已经被改变。
官方文档展示了将多个值组合存储、读取时再拆分的思路。下面用一个教学模型演示把标签数组保存为逗号分隔字符串:
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" },
);
这个例子能说明机制,但生产中不一定是好表结构:
如果标签需要查询、关联和约束,应该建立 article、tag、article_tag 三张规范化表。Getter/Setter 不能替代关系数据库建模。
上例的类型断言暴露了一个事实:TypeScript 业务属性是
string[],数据库列却是TEXT字符串,两层类型并不一致。对学习项目可以借此理解转换机制;生产项目更推荐用明确的持久化字段名,或把转换放入 service/DTO 层,减少类型绕行。
虚拟属性使用 DataTypes.VIRTUAL。它存在于 Sequelize 模型中,但不会成为 INSERT、UPDATE 的真实列。
为了让虚拟字段的创建类型清晰,下面显式定义属性接口:
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;
DataTypes.VIRTUAL 可以描述返回类型和依赖字段:
fullName: {
type: new DataTypes.VIRTUAL(DataTypes.STRING, ["firstName", "lastName"]),
get(): string {
return `${this.getDataValue("firstName")} ${this.getDataValue("lastName")}`;
},
},
依赖字段信息可帮助 Sequelize 在选择虚拟属性时把所需真实字段一并取回。即便如此,团队仍应测试实际生成 SQL,尤其是在 attributes 精简与关联查询组合时。
例如:
fullName 由名和姓组成;priceWithCurrency 由金额与货币组成;isOverdue 由到期时间与当前时间判断。若派生值依赖“当前时间”,同一实例在不同时刻读取可能得到不同结果,应避免把它误认为数据库中稳定保存的状态。
虚拟字段也可以设置 setter,把一个输入拆到多个真实字段。但复杂输入解析通常更适合 DTO/service 层,因为:
raw: true 会绕过实例能力const actors = await ActorWithFullName.findAll({
raw: true,
});
raw: true 不构造 Model 实例,因此不要期待实例 getter、virtual 和实例方法正常工作。若接口依赖 fullName,可以选择:
toJSON();选择应明确,不要让同一个 repository 有时返回实例、有时返回 raw 对象,却使用模糊的返回类型掩盖差异。
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 层通常最容易理解和排错。
可以使用 getter 隐藏或掩码某些值,但这不能代替访问控制:
getDataValue 仍能读取底层值;raw: true 可能绕过 getter;密码哈希、令牌等敏感字段应默认从查询 attributes 中排除,并通过明确 DTO 决定响应字段。不要把“getter 返回 undefined”当成完整防泄漏方案。
| 需求 | 更合适的位置 |
|---|---|
| 去除邮箱首尾空格、统一小写 | setter 或 service;数据库再加唯一约束 |
| 异步密码哈希 | service 或经过严格测试的 hook |
| 姓名展示组合 | virtual 或响应 DTO |
| 必须唯一、不能为空 | MySQL 约束,同时配合 Sequelize 校验 |
| 多表可查询的标签 | 规范化关联表 |
| API 专属字段重命名和脱敏 | DTO/序列化层 |
| 跨所有写入来源都必须成立的规则 | 数据库约束 |
MySQL 是持久化事实来源。Getter、Setter 和 Virtual 是应用层便利能力,其他程序直接写数据库时不会执行它们。若以后将 DTO 缓存在 Redis,应缓存 getter/virtual 处理后的稳定响应结构,并明确缓存版本;不要直接缓存带方法和内部状态的 Sequelize 实例。
排查数据问题时同时检查:
console.log(instance.fieldName);
console.log(instance.getDataValue("fieldName"));
并查看 MySQL 原始记录,避免把展示转换误认为数据库已经改变。
如果读取一个字段会执行大量业务逻辑,开发者很难从 actor.fullName 看出成本和副作用。Getter 应保持同步、轻量、确定,不能发数据库请求或调用远程服务。
转换后的值与原值不同,可能影响 Sequelize 的变更检测。更新问题应检查:
console.log(instance.changed());
console.log(instance.previous("email"));
新加 setter 只影响以后经过该模型写入的数据,不会自动清洗历史数据。上线规范化规则时,要评估旧数据迁移、唯一冲突和回滚方案。
getDataValue 获得;setDataValue 完成;raw: true 不创建实例,因此会绕过实例 getter 和 virtual;actor 模型添加只读虚拟属性 fullName,格式为 firstName + 空格 + lastName。customer.email 编写 setter:去除首尾空格并转成小写;再说明为什么 MySQL 仍需要唯一约束。country 模型添加虚拟属性 populationInMillions,把 Population 转换为“百万人”数值,保留两位小数。film.rating 转换为适合中文页面展示的说明;同时保留读取数据库原始评级的方法。raw: true 查询带 virtual 的模型,记录两者返回结构的差异。getDataValue、setDataValue。TEXT 字段中,写出 getter/setter 后,再分析何时应该迁移为多对多关联表。