nestjs-entity添加subscribe监听

427 阅读6分钟

前言

在开发过程中是不是碰到这样一个场景,数据库直接获取某个对象的时候,需要额外生成一个参数给客户端,例如:file文件给用户时实际上要额外返回一个签名url,这个是不能从数据库直接获取的,这时候就可以通过typeorm提供的 subscribe 订阅监听来解决了

更多可以参考 这里

案例

只需要新建一个类,继承自 EntitySubscriberInterface,且使用 EventSubscriber 类装饰器,只需要设置监听 entity实现回调即可,这里实现的是加载 entity 后的回调 afterLoad,也就是我们查询生成对象的之后会进行回调,使用非常方便

实际使用可以不仅仅在加载后添加内容,还可以在 insert、remove 前后等做操作,具体可以点进接口查看

import { DataSource, EntitySubscriberInterface, EventSubscriber } from 'typeorm'

//监听
@EventSubscriber()
export class FileSubscriber implements EntitySubscriberInterface<File> {
    constructor(dataSource: DataSource, private minioServer: MinioService) {
        dataSource.subscribers.push(this)
    }

    listenTo() {
        return File
    }

    async afterLoad(entity: File) {
        //需要注意的是,如果存在关联,关联为空时,也会返回entity,只不过为 {id: null}的形式,这是需要注意处理的
        if (!entity.filename) return
        //假设我们使用 minio 对文件进行签名期限url
        entity.url = await this.minioServer.getPresignedUrl(entity.filename)
    }
}

//数据库 file entity
@Entity()
export class File {
    @PrimaryGeneratedColumn('uuid')
    id: string

    @Column('bigint', { default: 0 })
    size: number

    @Column({ nullable: true, default: null })
    filename: string

    @Column({ default: null })
    mimetype: string

    @CreateDateColumn()
    timestamp: Date

    //返回时获取的url,实际数据库不存在
    url: string
}

//modules 中间需要导入,不然不生效哈
providers: [FileSubscriber]

优缺点

优点很明显,使用方便了很多,不用频繁对我们查询的结果赋值了,能节省很多代码,且能专对 entity 操作前后进行我们额外的操作,用起来屡试不爽,且这是 typeorm 内部编写,因此支持最好,因此可以立即为比较适合 typeorm 的操作方式

缺点同时也很明显,我们的 entity 需要增加额外的参数,该监听也会与我们的 entity 耦合到一起,entity 不再纯粹对应关系数据库了,不管我们需不需要额外的参数,获取时一定会额外获取增加的参数,查询结果不再是单纯对应我们编写的 dto 了(甚至可能让你有了不想编写返回值文档的想法😂)

ps:如果 typeorm 能够在查询时,可以根据我们添加的参数选择是否使用监听那就更好了

简易版监听(@AfterLoad)

上面就是一个稍微重点的例子了,只能使用 subscribe 了(要导入 service,这里面直接导入 service 会出现一些问题,因此没办法直接写到 entity 中)

像下面的,可能只需要一个小小的加工即可生成,那么直接而使用 @AfterLoad 然后编写内容即可,这样就可以生成我们想要的 url 了

//数据库 file entity
@Entity()
export class File {
    @PrimaryGeneratedColumn('uuid')
    id: string

    @Column('bigint', { default: 0 })
    size: number

    @Column({ nullable: true, default: null })
    filename: string

    @Column({ default: null })
    mimetype: string

    @CreateDateColumn()
    timestamp: Date

    //返回时获取的url,实际数据库不存在
    url: string
    
    @AfterLoad()
    generateUrl() {
        this.url = 'askdfjaskdhfaksdhfakhl'
    }
}

AfterLoad扩展

如果我们就是懒得用上面的订阅,就想使用简单的 @AfterLoad怎么办,没问题,就这个例子来说,我们假设使用到了 minioServiceMinioService 一般也不会多个模块同时导入,则可以直接使用单例 + 异步解决,这样其他模块使用时该 service 的部分功能,都不用直接导入了,可以直接使用单例

//我们创建一个单例获取 minioService
let minioService: MinioService | null = null

export class MinioService {
    ...
    
    constructor() {
        ...
        minioService = this
    }

    static get share() {
        //单例,用于跨场景使用,最好不要多个模块导入该Service
        return minioService
    }
    ...
    
    //加入一个案例
    getTestUrl(): Promise<string> {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve('我是测试返回的假的url')
            }, 100);
        })
    }
}

再通过 AfterLoad 给我们的 url 赋值即可

url: string

@AfterLoad()
async generateUrl() {
    this.url = await MinioService.share.getTestUrl()
}

subscribe 小技巧(请权衡利弊后使用)

通过小技巧,将 entity 中额外增加的参数分离到 dto,可避免 entity 中出现新的参数,订阅时,将参数声明成 dto 的类型即可

//监听
@EventSubscriber()
export class FileSubscriber implements EntitySubscriberInterface<FileDto> {
    constructor(dataSource: DataSource, private minioServer: MinioService) {
        dataSource.subscribers.push(this)
    }

    listenTo() {
        return File
    }

    async afterLoad(entity: FileDto) {
        //假设我们使用 minio 对文件进行签名期限url
        entity.url = await this.minioServer.getPresignedUrl(entity.filename)
    }
}

//数据库 file entity,此时url就不存在了
@Entity()
export class File {
    @PrimaryGeneratedColumn('uuid')
    id: string

    @Column('bigint', { default: 0 })
    size: number

    @Column({ nullable: true, default: null })
    filename: string

    @Column({ default: null })
    mimetype: string

    @CreateDateColumn()
    timestamp: Date
}

原因

我们的 typescript 最终也会被编译成 javascript,且 typeorm 的仓库都是 js + d.ts 的声明方式出现,意味着里面都是 js 编写的,因此里面实际生成 entity 的时候是没有类型限制的,那时候新增加一个 url,并不会出现错误(这就是解释型语言的好处),因此我们可以将类型声明成我们的返回类型dto,那么就可以实现 entity 与返回结果分离了(实际查询 entity 过程中仍然会额外返回 url)

为什么这么做?

我们的 dto 实际上的参数,才是实际上对应这返回给客户端的参数,除了我们新增的 url,什么一对一,一对多这种,如果根本不会反向关联,在返回时 dto 中实际都不会存在该参数,因此我们这个小技巧更像是专门为返回给用户的 dto 为主而做的,并且 dto 更像是我们的 entity 派生而来(实际上不一定是继承关系,可能就是entity与dto基础参数组合的原型函数,他们存在继承关系)

因此这个参数我们再返回 entity 类型时使用,类型上并不可见(理解为派生类,因此实际存在),返回给用户确实时该派生类,符合面向对象的多态

扩展

有人可能觉得,虽然用着很方便,甚至提到了类似派生类的方式来解释,但仍然无法避免这个增加的参数,且每次查询都要额外生成,如果这个参数不常用,且很消耗性能,那样会造成不必要的消耗,这样怎么办呢

只想说,用最原始的办法,生成返回的 dto 类型时,dto 额外增加一个参数,返回数据时手动给该参数赋值,如果是数组对象,则需要访问并赋值

想直接传参就可以选择是否使用监听,那么告诉编写 typeorm 的团队更新一下,一劳永逸(还是老老实实手动赋值吧)