NestJS разработан с использованием строго типизированных свойств моделей, но иногда полезно (и быстро!) разрешить динамические типы свойств и просто хранить некоторые данные бизнес-домена в виде динамического сериализованного большого двоичного объекта.

Это метод сериализованных больших объектов, рекомендованный Мартином Фаулером (https://martinfowler.com/eaaCatalog/serializedLOB.html).

Вот как вы можете иметь LOB в NestJS REST Api с безопасностью типов и поддержкой определений OpenAPI.

Типичные модели Nest Js

Вот типичная сущность в nestjs, которую вы можете сохранить в хранилище данных с помощью typeorm. Этот класс можно использовать для хранения данных конфигурации для запуска бота.

Есть несколько пользовательских классов (CustomBot), которые сохраняются в базе данных реляционным способом.

В перечислении есть дискриминатор, который устанавливает тип триггера.

@Entity()
export class Trigger {
  @PrimaryGeneratedColumn()
  @ApiProperty()
  public id!: number

  @Column('uuid', {
    name: 'uuid',
    default: () => 'uuid_generate_v4()',
  })
  @Generated('uuid')
  @ApiProperty()
  public uuid!: string

  @Column({
    type: 'enum',
    enum: TriggerTypeEnum,
    default: TriggerTypeEnum.NO_ACTION_DEFAULT,
  })
  @ApiProperty({ enum: TriggerTypeEnum, enumName: 'TriggerTypeEnum' })
  public triggerType!: TriggerTypeEnum

  @Exclude()
  @ManyToOne(() => CustomBot, (customBot) => customBot.triggers, {
    eager: true,
    onDelete: 'CASCADE',
  })
  @Index()
  @Type(() => CustomBot)
  @JoinColumn({ name: 'customBotId' })
  customBot!: CustomBot

  @Column()
  @ApiProperty()
  customBotId!: number
}

Эквивалентный API DTO для создания чего-то подобного был бы проще, потому что большинство свойств генерируются.

Пользовательский идентификатор бота для отношения будет в параметре URL, а не в теле dto. Так что это будет выглядеть примерно так.

export class CreateTriggerDto {
  @IsDefined()
  @IsEnum(TriggerTypeEnum)
  @ApiProperty({ enum: TriggerTypeEnum, enumName: 'TriggerTypeEnum' })
  public triggerType!: TriggerTypeEnum
}

Добавляем сюда метаданные

Итак, теперь, если мы хотим хранить здесь метаинформацию, нам нужно добавить свойство для ее хранения.

Postgres позволяет нам хранить большие двоичные объекты json в столбцах, поэтому большинство наших свойств могут быть строго определенными и реляционными.

Мы можем хранить json непосредственно в postgres, когда у нас есть несколько представлений данных. Type ORM поддерживает это, устанавливая тип столбца. например

@Column({ type: "jsonb" })
public meta!: MyComplexModel;

Это чрезвычайно полезно для метаинформации, связанной с объектом бизнес-домена. Просто сохраните его как есть, когда он будет получен, он будет преобразован в правильную модель.

Проблема с динамическими метаданными

Интересная проблема здесь заключается в том, как хранить и извлекать разные классы для разных значений TriggerTypeEnum?

Мы хотим иметь точную спецификацию OpenApi и обеспечить безопасность типов во всем коде.

Динамическое создание DTO

Чтобы создать динамическую модель Create DTO в NestJS, нам нужно

  1. Скажите классу-преобразователю, как преобразовать входной json в классы
  2. Сообщите OpenAPI, что для этого ввода существует несколько возможных типов.

Ниже я покажу, как использовать свойство discriminator в декораторе @Type, чтобы сообщить преобразователю классов, как мы хотим создать класс, назначенный свойству.

Вы также можете увидеть, как я устанавливаю свойство oneOf в декораторе @ApiProperty. Это создает действительную спецификацию OpenApi v3.

ПРИМЕЧАНИЕ. На данный момент существует проблема с oneOf для некоторых открытых плагинов API, поскольку они не были обновлены для работы с ним. Об этом я расскажу в конце статьи.

export class CreateTriggerDto {
  @IsDefined()
  @IsEnum(TriggerTypeEnum)
  @ApiProperty({ enum: TriggerTypeEnum, enumName: 'TriggerTypeEnum' })
  public triggerType!: TriggerTypeEnum

  @Type(() => TriggerMeta, {
    discriminator: {
      property: 'triggerType',
      subTypes: [
        {
          value: TwitterUserMentionMeta,
          name: TriggerTypeEnum.TWITTER_USER_MENTION,
        },
        {
          value: NoActionTestMeta,
          name: TriggerTypeEnum.NO_ACTION_DEFAULT,
        },
      ],
    },
  })
  @IsDefined()
  @ApiProperty({
    oneOf: [
      { $ref: getSchemaPath(TwitterUserMentionMeta) },
      { $ref: getSchemaPath(NoActionTestMeta) },
    ],
  })
  public meta!: TwitterUserMentionMeta | NoActionTestMeta
}

Сущность для хранения в БД аналогична — добавляем дискриминатор типов и свойство API anyof.

@Entity()
export class Trigger {
  @PrimaryGeneratedColumn()
  @ApiProperty()
  public id!: number

  @Column('uuid', {
    name: 'uuid',
    default: () => 'uuid_generate_v4()',
  })
  @Generated('uuid')
  @ApiProperty()
  public uuid!: string

  @Column({
    type: 'enum',
    enum: TriggerTypeEnum,
    default: TriggerTypeEnum.NO_ACTION_DEFAULT,
  })
  @ApiProperty({ enum: TriggerTypeEnum, enumName: 'TriggerTypeEnum' })
  public triggerType!: TriggerTypeEnum

  @Exclude()
  @ManyToOne(() => CustomBot, (customBot) => customBot.triggers, {
    eager: true,
    onDelete: 'CASCADE',
  })
  @Index()
  @Type(() => CustomBot)
  @JoinColumn({ name: 'customBotId' })
  customBot!: CustomBot

  @Column()
  @ApiProperty()
  customBotId!: number

  @Column({ type: 'jsonb' })
  @Type(() => TriggerMeta, {
    discriminator: {
      property: 'triggerType',
      subTypes: [
        {
          value: TwitterUserMentionMeta,
          name: TriggerTypeEnum.TWITTER_USER_MENTION,
        },
        {
          value: NoActionTestMeta,
          name: TriggerTypeEnum.NO_ACTION_DEFAULT,
        },
      ],
    },
  })
  @IsDefined()
  @ApiProperty()
  @ApiProperty({
    oneOf: [
      { $ref: getSchemaPath(TwitterUserMentionMeta) },
      { $ref: getSchemaPath(NoActionTestMeta) },
    ],
  })
  public meta!: TwitterUserMentionMeta | NoActionTestMeta
}

Текущая проблема с генератором открытых API anyof и typescript-fetch

Генератор API-интерфейса typescript fetch не поддерживает anyof.

Если вы создаете клиент java или клиент .net, у вас не возникнет проблем с использованием описанного здесь метода. Однако, если вы создаете машинописный клиент, он не будет работать.

Вам придется вручную различать класс. Я сделал это так, чтобы создать новую родительскую модель контейнера с необязательными свойствами, а затем вручную назначить ее там, где это необходимо.

Такой новый тип предоставит потребительские свойства API для предоставления метаданных в типизированном формате.

export default class AllMetaTypes {
  @ApiPropertyOptional()
  public twitterUserMentionMeta?: TwitterUserMentionMeta

  @ApiPropertyOptional()
  public noActionTestMeta?: NoActionTestMeta
}

Тогда ваша модель создания DTO будет использовать этот тип в мета-свойстве.

export class CreateTriggerDto {
  @IsDefined()
  @IsEnum(TriggerTypeEnum)
  @ApiProperty({ enum: TriggerTypeEnum, enumName: 'TriggerTypeEnum' })
  public triggerType!: TriggerTypeEnum

  @ApiProperty()
  @IsDefined()
  @Type(() => AllMetaTypes)
  public allMeta!: AllMetaTypes
}

Проблема в том, что вам нужно вручную сопоставить соответствующие данные из «allMeta» с сущностью при сохранении.

Если вы читаете это спустя долгое время после даты публикации, стоит проверить, не обновлялся ли уже генератор машинописного чтения.

Вывод

Метод сериализованных больших объектов Мартина Фаулера — хороший способ обработки метаданных. Postgres предоставляет нам формат jsonb для удобного хранения json. Нет никаких причин, по которым вы должны замыкаться только на реляционных данных.

Помните об этом методе в следующий раз, когда в домене вашего бизнеса появятся предоставленные пользователем свойства или метаданные.

Вы можете связаться со мной в Твиттере!

Первоначально опубликовано на https://www.darraghoriordan.com.