Nest系列(十二)class-transformer的使用

3,620 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第14天,点击查看活动详情

class-transformer

typestack/class-transformer: Decorator-based transformation, serialization, and deserialization between objects and classes. (github.com)

星标5.4K

现在是ES6Typescript的时代了!现在我们每天都在和类打交道,class-transformer可以帮助我们

  • 实现字面量对象plain object和类实例对象instance of class之间的转换
  • 基于一定条件序列化和反序列化对象(感觉已经不再使用了,都是过时的方法,也就不做深究了)

这个工具在前后端都非常有用

本文是写nestjs设置环境变量那一节遇到的一个小知识点,主要是一些api的使用方法,在这里做一个简单小结

一、什么是class-transformer

首先我们来区分一下plain(literal) objectclass(constructor) objects

 // literal
 const literralObj = {
   name: 'li',
   age: 18
 }
 // class
 class User {
   constructor(public name: string, public age: number){
     this.name = name
     this.age = age
   }
 }
 const user = new User('jing', 20)
  • plain(literal) object是通过{ ... }直接定义出来的
  • class(constructor) objects是通过类new出来的

问题引出

我们在前端处理后端传送过来的数据时,经过反序列化转换的对象全是plain(literal) object,如下:

 {
   "id": 1,
   "firstName": "Johny",
   "lastName": "Cage",
   "age": 27
 },

我们在前端定义了User

 export class User {
   id: number;
   firstName: string;
   lastName: string;
   age: number;
   
   getName() {
     return this.firstName + ' ' + this.lastName;
   }
   
   isAdult() {
     return this.age > 36 && this.age < 60;
   }
 }

我们在前端接收数据的时候,可以使用ts语法显示标注接收对象的类型,可以给我们属性提示,但是我们没法使用User类给我们提供的方法!

 fetch('user.json').then((user: User) => {
   // you can use user here, and type hinting also will be available to you,
   //  but user is not actually instance of User class
   // this means that you can't use methods of User class
   
   // error!
   user.getName()
   user.isAdult()
 });

如果我们想要相关的方法,那必须重建整个对象,复制对应的属性值,这对于一个复杂对象来说很麻烦,且是一件很容易出错的事,如果使用class-transform可以直接将收到的user字面量对象转换为User实例对象,进而可以使用其中的方法。

 fetch('user.json').then((user: Object) => {
   const realUsers = plainToClass(Use, user);
   // now user is an instance of User class
 });

二、安装

1. Node.js

 npm install class-transformer --save
 npm install reflect-metadata --save

需要保证reflect-metadata在全局引入,例如在app.ts

 import 'reflect-metadata';

2. Browser

 npm install class-transformer --save
 npm install reflect-metadata --save

官方说需要在index.html中插入这个script,不过我尝试的时候没做这一步也运行正常.

<html>
  <head>
    <!-- ... -->
    <script src="node_modules/reflect-metadata/Reflect.js"></script>
  </head>
  <!-- ... -->
</html>

三、简单实操验证

  • 前端代码
import ddRequest from '@/service'
import { plainToClass } from 'class-transformer'

export class User {
  public username: string
  public password: string
  public age: number
  constructor(username: string, password: string, age: number) {
    this.username = username
    this.password = password
    this.age = age
  }

  getName() {
    return this.username
  }
  isAdult() {
    return this.age > 36 && this.age < 60
  }
}

console.log('test.ts')
ddRequest
  .get({
    baseURL: 'http://localhost:4000/user'
  })
  .then((user: User) => {
    // 书写代码有提示
    console.log(user)
    console.log(user.username)
    // 有提示,但是会报错
    console.log(user.getName())
    console.log(user.isAdult())
  })
  • 后端代码
  @Get()
  findAll() {
    const user = new User('coder', '12345', 18);
    return user;
  }

可以看到上面的前端代码中还没有使用class-transformer,运行后会报错

image-20221216115948245

如果使用上class-transformer,稍作改造如下:

ddRequest
  .get({
    baseURL: 'http://localhost:4000/user'
  })
  .then((user: User) => {
    const userRes = plainToClass(User, user)
    console.log(userRes.username)
    console.log(userRes.getName())
    console.log(userRes.isAdult())
  })

立马见效了,不仅有提示,还可以正确调用类里面的方法

image-20221216120332885

四、方法介绍

我看了一下源码,很多github readme中的示例方法都过时了,带class的都不用了,换成了带instance的,然后序列化和反序列化的方法也都过时了

// 实验数据
const plainUser = {
  username: 'coder',
  password: '123',
  age: 18
}
const plainUsers = [
  {
    username: 'coder1',
    password: '123',
    age: 18
  },
  {
    username: 'coder2',
    password: '123',
    age: 30
  }
]

为了方便,就不模拟前后端交互了,仅对方法做一些测试和验证

1. plainToInstance

将字面量转为类实例,支持数组转换

/**
 * Converts plain (literal) object to class (constructor) object. Also works with arrays.
 */
export declare function plainToInstance<T, V>(cls: ClassConstructor<T>, plain: V[], options?: ClassTransformOptions): T[];
export declare function plainToInstance<T, V>(cls: ClassConstructor<T>, plain: V, options?: ClassTransformOptions): T;
const user: User = plainToInstance(User, plainUser)
console.log(user.isAdult()) // false
const userList: User[] = plainToInstance(User, plainUsers)
console.log(userList)

2. InstanceToPlain

将类实例转为字面量对象,支持数组

/**
 * Converts class (constructor) object to plain (literal) object. Also works with arrays.
 */
export declare function instanceToPlain<T>(object: T, options?: ClassTransformOptions): Record<string, any>;
export declare function instanceToPlain<T>(object: T[], options?: ClassTransformOptions): Record<string, any>[];
const instanceUser = new User('coder3', '123', 33)
const plain = instanceToPlain(instanceUser)

3. instanceToInstance

将一个类实例转换到另一个类实例,可以看做是做一次深拷贝

/**
 * Converts class (constructor) object to new class (constructor) object. Also works with arrays.
 */
export declare function instanceToInstance<T>(object: T, options?: ClassTransformOptions): T;
export declare function instanceToInstance<T>(object: T[], options?: ClassTransformOptions): T[];
const instanceUser2 = new User('coder4', '123', 33)
const instanceUser3 = instanceToInstance(instanceUser2)
console.log(instanceUser3.isAdult())

4. serialize

序列化对象,原来是serialize,现在建议使用JSON.stringify(instanceToPlain(object, options))

5. deserializedeserializeArray

反序列化,现在建议分别使用如下替代

instanceToClass(cls, JSON.parse(json), options)

JSON.parse(json).map(value => instanceToClass(cls, value, options))

关于序列化和反序列化我们一般也不用这两种方式,主要掌握一下上面对象之间转换的方法

五、方法可选配置参数

对于转换方法,我们能看到其实每一个方法都有一个配置选项,它的类型是ClassTransformOptions,我们看一下它的源码

import { TargetMap } from './target-map.interface';

export interface ClassTransformOptions {
    strategy?: 'excludeAll' | 'exposeAll';
    excludeExtraneousValues?: boolean;
    groups?: string[];
    /**
     * Only properties with "since" > version < "until" gonna be transformed.
     */
    version?: number;
    /**
     * Excludes properties with the given prefixes. For example, if you mark your private properties with "_" and "__". you can set this option's value to ["_", "__"] and all private properties will be skipped.
     * This works only for "exposeAll" strategy.
     */
    excludePrefixes?: string[];
    /**
     * If set to true then class transformer will ignore the effect of all @Expose and @Exclude decorators. This option is useful if you want to kinda clone your object but do not apply decorators affects.
     * __NOTE:__ You may still have to add the decorators to make other options work.
     */
    ignoreDecorators?: boolean;
    /**
     * Target maps allows to set a Types of the transforming object without using @Type decorator. This is useful when you are transforming external classes, or if you already have type metadata for objects and you don't want to set it up again.
     */
    targetMaps?: TargetMap[];
    /**
     * If set to true then class transformer will perform a circular check. (circular check is turned off by default). This option is useful when you know for sure that your types might have a circular dependency.
     */
    enableCircularCheck?: boolean;
    /**
     * If set to true then class transformer will try to convert properties implicitly to their target type based on their typing information.
     * DEFAULT: `false`
     */
    enableImplicitConversion?: boolean;
    /**
     * If set to true then class transformer will take default values for unprovided fields. This is useful when you convert a plain object to a class and have an optional field with a default value.
     */
    exposeDefaultValues?: boolean;
    /**
     * When set to true, fields with `undefined` as value will be included in class to plain transformation. Otherwise those fields will be omitted from the result.
     * DEFAULT: `true`
     */
    exposeUnsetFields?: boolean;
}

配置项比较多,下面仅看几个示例,其它的大家可以自己探索一下。

1. strategy

  • exposeAll:转换所有
  • excludeAll:都不转换
const plainUser = {
  username: 'coder',
  password: '123',
  age: 18,
  height: 1.78
}

const user: User = plainToInstance(User, plainUser, {
  strategy: 'exposeAll'
})
// User类中没有height属性,但是依然会被转换出来
console.log(user) //  {username: 'coder', password: '123', age: 18, height: 1.78}

const user1: User = plainToInstance(User, plainUser, {
  strategy: 'excludeAll'
})
// 任何属性都不转换
console.log(user1) // {username: undefined, password: undefined, age: undefined}

2. excludeExtraneousValues

当从一个plain对象转类实例的时候,用来标识无关的属性(即plain上有的属性在类中没有)要不要排除在外,需要在类的属性上加@Expose()@Exclude()装饰器

image-20221216133513414

可以看到装饰器是一个实验特性,需要进行设置,在tsconfig.json中添加如下配置即可

"ExperimentalDecorators": true,
export class User {
  @Expose()
  public username: string
  @Exclude()
  public password: string
  @Expose()
  public age: number
}
const user: User = plainToInstance(User, plainUser, {
  strategy: 'exposeAll',
  excludeExtraneousValues: true
})
console.log(user) //  {username: 'coder', password: undefined, age: 18}

默认情况下类定义中什么装饰器都不加相当于@Exclude(),相当于该属性被排除在外了,不做转换

@Expose()还可以设置属性名对应关系,如下:

// 代表的是将plain对象中的name属性值转换到类实例的username属性下
@Expose({ name: 'name' })
 public username: string

3. groups

只有在groups数组里的才会被转换,这样说很多人可能会误解,其使用需要配合@Expose({groups:[]})使用:

export class User {
  @Expose({ name: 'name' })
  public username: string
  
  @Expose({ groups: ['admins'] })
  public password: string
  
  @Expose({ groups: ['users', 'admins'] })
  public age: number
}

const user: User = plainToInstance(User, plainUser, {
  excludeExtraneousValues: true,
  groups: ['users']
})
// user1: User {username: 'coder', password: undefined, age: 18}
console.log('user1:', user)
const user2: User = plainToInstance(User, plainUser, {
  excludeExtraneousValues: true,
  groups: ['admins']
})
// user2: User {username: 'coder', password: '123', age: 18}
console.log('user2:', user2)

这个可以用来做鉴权方向的事情,如上,在类的定义中,我们可以在属性上定义相应的groups,用来标识哪些用户角色可以访问,同时在转换配置中通过groups来标识该转换的对象可以提供给哪些角色使用,可以看到上面的示例,password属性值只暴露给admins

4. enableImplicitConversion

强制类型转换,这个在环境变量的验证那一章节也用到过,可以将plain对象中的属性强制转换为类里对应属性的类型,能转则转不能转则直接报错!注意这个属性默认为false,此时即使两边对应属性类型不一致也会转过去,但是如果设置了这个配置项为true则会强制转,可能产生错误,用的时候需谨慎。

 // class 中:public age: number

const plainUser = {
  username: 'hello',
  name: 'coder',
  password: '123',
  // age是一个字符串
  age: 'hello',
  height: 1.78
}
const user1: User = plainToInstance(User, plainUser, {
  excludeExtraneousValues: true,
})
//user1: User {username: 'coder', password: undefined, age: 'hello'}
console.log('user1:', user1)

设置了enableImplicitConversion: true后:直接报错了,因为hello不能转为数字类型!

image-20221216150219069

六、几个装饰器

  • @Exclude()可以作用于类上,表示所有属性都被排除,也可以作用于具体某个属性上
export interface ExcludeOptions {
    toClassOnly?: boolean;
    toPlainOnly?: boolean;
}
  • @Expose()标识需要转换,也可以作用于类和属性,与上面刚好相反

可以有以下配置项:

export interface ExposeOptions {
    name?: string;
    since?: number;
    until?: number;
    groups?: string[];
    toClassOnly?: boolean;
    toPlainOnly?: boolean;
}
  • @Type()

这里还没有完全搞明白,用的时候一直报错,不加@Type工作好好的,一加了就报错!希望有用过的大佬指点指点。

export declare function Type(typeFunction?: (type?: TypeHelpOptions) => Function, options?: TypeOptions): PropertyDecorator;

官方的案例如下:(自己没有成功)

import { Type } from 'class-transformer';
export class User {
  id: number;
  email: string;
  password: string;
  @Type(() => Date)
  registrationDate: Date;
}
  • @Transform()

更加自由化的转换,可以在里面拿到转换前的数据,根据需要进行相应的转换

export declare function Transform(transformFn: (params: TransformFnParams) => any, options?: TransformOptions): PropertyDecorator;
export interface TransformFnParams {
    // plain对象中对应的属性值,转换前
    value: any;
    // 该属性的属性名
    key: string;
    // 整个plain对象
    obj: any;
    // 
    type: TransformationType;
    // 可选配置
    options: ClassTransformOptions;
}
/**
 * Possible transformation options for the @Transform decorator.
 */
export interface TransformOptions {
    since?: number;
    until?: number;
    groups?: string[];
    toClassOnly?: boolean;
    toPlainOnly?: boolean;
}

很多东西都需要自己去尝试才行,用到了再回来看看吧!