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, нам нужно
- Скажите классу-преобразователю, как преобразовать входной json в классы
- Сообщите 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.