NestJS + TypeORM 如何优雅处理实体中Buffer类型ULID与DTO字符串类型的转换?
我太懂这种DTO和实体类型不匹配的糟心感了,既要符合DDD里DTO不能有业务逻辑的规则,又要少写重复代码,确实得找个优雅的路子。结合NestJS和TypeORM的生态,给你几个实际项目里验证过的方案,你可以按需选:
方案一:TypeORM 自定义ULID列类型(最推荐,一劳永逸)
这应该是最干净的解决方案了,直接在TypeORM层面统一类型,让实体的userId直接用string类型,底层自动帮你在string和Buffer之间转换,服务层和DTO全用string,完全不用额外处理转换逻辑。
步骤很简单:
- 先确保你已经安装了
ulid库(毕竟用ULID肯定少不了它) - 自定义一个ULID列类型,实现TypeORM的
ColumnType接口:
// src/common/types/ulid-column.ts import { ColumnType, ValueTransformer } from 'typeorm'; import { ulid } from 'ulid'; export class UlidColumn implements ColumnType { name = 'ulid'; default?: string; getColumnType(): string { return 'binary(16)'; } to(value: string): Buffer { return ulid.toBuffer(value); } from(value: Buffer): string { return ulid.fromBuffer(value); } getValueTransformer(): ValueTransformer { return { to: this.to.bind(this), from: this.from.bind(this), }; } }
- 在TypeORM的配置里注册这个自定义类型:
// src/config/typeorm.config.ts import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { UlidColumn } from './common/types/ulid-column'; export const typeOrmConfig: TypeOrmModuleOptions = { // 其他配置(数据库地址、账号密码等)... type: 'mysql', // 换成你实际用的数据库类型 entities: [__dirname + '/../**/*.entity{.ts,.js}'], extra: { connection: { types: { ulid: new UlidColumn(), }, }, }, };
- 最后修改你的实体类,直接用string类型:
@Entity('users', { schema: 'DB' }) export class Users { @ApiProperty({ example: '01J3Z772NCBMD9BETHBMB74HBW', description: 'User ULID', }) @Column('ulid', { primary: true, name: 'user_id' }) userId: string; }
这样一来,从数据库读出来的userId自动是string,存的时候TypeORM自动把string转成16位Buffer存到数据库,DTO的userId还是string,服务层全程用string,完全不用管转换,也不影响mapped-types的使用,完美符合你的需求。
方案二:TypeORM 实体订阅器(适合已存在的实体,无需修改列定义)
如果不想改现有的实体列定义,用TypeORM的实体订阅器也是个好办法,它能监听实体的加载、插入等生命周期事件,自动帮你转换类型,代码也很简洁,不会重复。
- 创建一个ULID转换的订阅器:
// src/common/subscribers/ulid-subscriber.ts import { EntitySubscriberInterface, EventSubscriber, LoadEvent, InsertEvent, UpdateEvent } from 'typeorm'; import { ulid } from 'ulid'; import { Users } from '../users/entities/user.entity'; // 可以多个实体共用这个订阅器,只要实体里有Buffer类型的userId @EventSubscriber() export class UlidSubscriber implements EntitySubscriberInterface { listenTo() { return [Users]; // 这里可以加所有需要处理的实体 } // 实体从数据库加载后,把Buffer转成string afterLoad(event: LoadEvent<any>) { if (event.entity.userId instanceof Buffer) { event.entity.userId = ulid.fromBuffer(event.entity.userId); } } // 实体插入数据库前,把string转成Buffer beforeInsert(event: InsertEvent<any>) { if (typeof event.entity.userId === 'string') { event.entity.userId = ulid.toBuffer(event.entity.userId); } } // 处理更新操作的类型转换 beforeUpdate(event: UpdateEvent<any>) { if (typeof event.entity.userId === 'string') { event.entity.userId = ulid.toBuffer(event.entity.userId); } } }
- 在TypeORM配置里注册这个订阅器:
// src/config/typeorm.config.ts export const typeOrmConfig: TypeOrmModuleOptions = { // 其他配置... subscribers: [__dirname + '/../**/*.subscriber{.ts,.js}'], };
这样服务层拿到的Users实体的userId就是string,存的时候自动转成Buffer,DTO也不用做任何修改,而且可以多个实体共用这个订阅器,几乎没有重复代码。
方案三:Class-Transformer 装饰器(解决你之前返回null的问题)
你之前用@Transform返回null,大概率是没正确设置转换规则或者用错了ulid的方法,其实class-transformer配合class-validator是可以搞定的,而且很灵活。
实体层转换(Buffer转string)
在实体的userId字段上加@Transform装饰器,设置toPlainOnly: true,这样当把实体转成普通对象时(比如服务层处理、返回给前端),自动转成string:
import { Transform } from 'class-transformer'; import { ulid } from 'ulid'; @Entity('users', { schema: 'DB' }) export class Users { @ApiProperty({ example: '01J3Z772NCBMD9BETHBMB74HBW', description: 'User ULID', }) @Column('binary', { primary: true, name: 'user_id', length: 16 }) @Transform(({ value }) => { // 确保只有Buffer类型才转换,避免重复转换出问题 return value instanceof Buffer ? ulid.fromBuffer(value) : value; }, { toPlainOnly: true }) userId: Buffer | string; // 这里类型写成联合类型,兼容转换前后的状态 }
DTO层转换(string转Buffer)
在DTO的userId字段上加@Transform,设置toClassOnly: true,这样当把DTO转成实体类时,自动把string转成Buffer:
import { Transform } from 'class-transformer'; import { ulid } from 'ulid'; export class CreateUserDto { @IsULID() @ApiProperty({ example: '01J3Z772NCBMD9BETHBMB74HBW', description: 'User ULID', }) @Transform(({ value }) => { // 这里@IsULID已经验证过是合法字符串,直接转Buffer即可 return typeof value === 'string' ? ulid.toBuffer(value) : value; }, { toClassOnly: true }) userId: string; }
这样在服务层用plainToClass或者Nest自动处理的时候,就能正确转换类型,而且不会返回null,你之前的问题可能是没调用正确的ulid转换方法,或者没设置toPlainOnly/toClassOnly导致双向转换出问题。
方案对比
- 方案一最推荐,属于从底层解决问题,代码最简洁,后续维护成本最低,完全不影响其他逻辑;
- 方案二更适合已经上线的项目,不想改动现有实体列定义的情况,订阅器可以批量处理多个实体;
- 方案三比较灵活,适合局部场景,但需要在实体和DTO上加装饰器,稍微有一点代码,但胜在不用改TypeORM配置。
备注:内容来源于stack exchange,提问作者Sandel

