Почти каждый разработчик программного обеспечения работал с СУБД, по крайней мере каждый слышал о них. В мире существует множество способов для работы с базами данных и один из них - это ORM (англ. Oblect-Relational Mapping). Для разработчиков приложений, особенно бизнес-приложений, различного рода реализации данного способа стали в прямом смысле "спасательным кругом" в грубом мире работы с базами данных. Ещё начиная с .Net Framework компания Microsoft кидала такой круг разработчикам, который носил название Entity Framework (EF). И теперь, в .NET есть кроссплатформенная реализация старенького EF - Entity Framework Core (EF Core).

В EF Core существует несколько способов конфигурирования сущностей, все они рассмотрены в моей предыдущей статье. Лучший из них на мой взгляд - это реализация IEntityTypeConfiguration<>. Он позволяет отделить модель предметной области от хранилища, сделать структуру проекта более упорядоченной, а само решение более гибким. Далее по статье мы рассмотрим все преимущества данного способа. Итак, пришло время на не реальном реальном проекте разобраться наконец с этим способом описания отношений полей и сущностей.

Сделаем конфигурацию для магазина, который занимается продажей автомобилей. База данных у нас уже существует, нужно только "подружить" с ней наше решение на .NET. Будем считать, что структуру нашей будущей БД разрабатывал вменяемый человек, который соблюдал элементарные правила именования полей и т.п.

Имеем несколько таблиц Cars, EquipmentOptions, Makes, Models. Все они имеют поля, которые являются системными, имеют один и тот же тип и называются одинаково. Это поля Id, CreatedDateTime, UpdatedDateTime. Для описания этих полей, мы создадим базовую модель для будущих сущностей BaseEntity:

public class BaseEntity
{
    public virtual Guid Id { get; set; }
    public virtual DateTime CreatedDateTime { get; set; }
    public virtual DateTime? UpdatedDateTime { get; set; }
}

После этого мы уже можем не описывать эти три поля в будущих моделях сущностей. Далее приводится листинг сущностей вышеприведенных таблиц.

CarEntity
public class CarEntity : BaseEntity
{
    public virtual string Vin { get; set; }
    public virtual string EngineNum { get; set; }
    public virtual string ChassisNum { get; set; }
    public virtual string BodyNum { get; set; }
    public virtual Guid EquipmentVariantId { get; set; }
    public virtual EquipmentVariantEntity EquipmentVariant { get; set; }
}

MakeEntity
public class MakeEntity : BaseEntity
{
    public string Name { get; set; }
    public virtual EquipmentVariantEntity EquipmentVariant { get; set; }
    public virtual ICollection<ModelEntity> Models { get; set; }
}

ModelEntity
public class ModelEntity : BaseEntity
{
    public virtual string Name { get; set; }
    public virtual Guid MakeId { get; set; }
    public virtual MakeEntity Make { get; set; }
    public virtual EquipmentVariantEntity EquipmentVariant { get; set; }
}

EquipmentVariantEntity
public class EquipmentVariantEntity : BaseEntity
{
    public virtual string Engine { get; set; }
    public virtual Guid ModelId { get; set; }
    public virtual ModelEntity Model { get; set; }
    public virtual ICollection<CarEntity> Cars { get; set; }
}

Как видим, модели наши чисты и намерения наши светлы. Приступим к конфигурации сущностей. Подобно выделению базовой сущности - выделим базовую конфигурацию BaseEntityConfiguration<TEntity>:

internal class BaseEntityConfiguration<TEntity> : IEntityTypeConfiguration<TEntity> where TEntity : BaseEntity
{
    public virtual void Configure(EntityTypeBuilder<TEntity> builder)
    {
        builder.HasKey(k => k.Id);
        builder.Property(p => p.Id).HasColumnName("Id");
        builder.Property(p => p.CreatedDateTime).HasColumnName("CreatedDateTime").IsRequired();
        builder.Property(p => p.UpdatedDateTime).HasColumnName("UpdatedDateTime");
    }
}

Далее для каждой сущности создадим также конфигурации, но они уже будут расширять нашу базовую конфигурацию. Область видимости мы намеренно делаем internal, т.к. вне проекта, предназначенного для работы с БД никто не должен знать этих деталей.

Ниже листинг данных конфигураций.

CarEntityConfiguration
internal class CarEntityConfiguration : BaseEntityConfiguration<CarEntity>
{
    public override void Configure(EntityTypeBuilder<CarEntity> builder)
    {
        base.Configure(builder);

        builder.ToTable("Cars");

        builder.Property(p => p.Vin)
            .HasMaxLength(64)
            .IsRequired();

        builder.Property(p => p.BodyNum)
            .HasMaxLength(64);

        builder.Property(p => p.ChassisNum)
            .HasMaxLength(64);

        builder.Property(p => p.EngineNum)
            .HasMaxLength(64)
            .IsRequired();

        builder.Property(p => p.EquipmentVariantId)
            .HasColumnName("EquipmentVariantId")
            .IsRequired();

        builder.HasOne(o => o.EquipmentVariant)
            .WithMany(m => m.Cars)
            .HasForeignKey(fk => fk.EquipmentVariantId)
            .IsRequired();
    }
}

MakeEntityConfiguration
internal class MakeEntityConfiguration : BaseEntityConfiguration<MakeEntity>
{
    public override void Configure(EntityTypeBuilder<MakeEntity> builder)
    {
        base.Configure(builder);

        builder.ToTable("Makes");

        builder.Property(p => p.Name)
            .HasColumnName("Name")
            .HasMaxLength(256)
            .IsRequired();

        builder.HasMany(m => m.Models)
            .WithOne(o => o.Make)
            .HasForeignKey(fk => fk.MakeId)
            .IsRequired()
            .OnDelete(DeleteBehavior.Cascade);
    }
}

ModelEntityConfiguration
internal class ModelEntityConfiguration : BaseEntityConfiguration<ModelEntity>
{
    public override void Configure(EntityTypeBuilder<ModelEntity> builder)
    {
        base.Configure(builder);

        builder.ToTable("Models");

        builder.Property(p => p.Name)
            .HasColumnName("Name")
            .HasMaxLength(256)
            .IsRequired();

        builder.Property(p => p.MakeId)
            .HasColumnName("MakeId")
            .IsRequired();
    }
}

EquipmentVariantEntityConfiguration
internal class EquipmentVariantEntityConfiguration : BaseEntityConfiguration<EquipmentVariantEntity>
{
    public override void Configure(EntityTypeBuilder<EquipmentVariantEntity> builder)
    {
        base.Configure(builder);

        builder.ToTable("EquipmentOptions");

        builder.Property(p => p.Engine)
            .HasColumnName("Engine")
            .HasMaxLength(20)
            .IsRequired();

        builder.Property(p => p.ModelId)
            .HasColumnName("ModelId")
            .IsRequired();

        builder.HasOne(o => o.Model)
            .WithOne(o => o.EquipmentVariant)
            .HasForeignKey<EquipmentVariantEntity>(fk => fk.ModelId)
            .IsRequired();
    }
}

Вот и всё, конфигурация сущностей произведена. На первый взгляд кажется, что такой подход это оверхед - куча классов, лишняя работа, мы же не индусы????. Действительно, если весь проект - это монолит, то такой подход избыточен, хоть и лаконичен. Там вполне подойдут атрибуты аннотаций данных. Но если например мы разрабатываем сервисы в микросервисной архитектуре - то такой подход кажется единственно верным для поддержания чистой архитектуры и гибкости, к которым многие так сильно стремятся. Модели сущностей мы можем положить в Shared-проект, в то время как конфигурацию вынести в проект, отвечающий за работу с конкретной БД. Таких проектов может быть несколько и все они будут переиспользовать наши модели сущностей и иметь свои конфигурации. Тем самым мы больше не зависим от конкретного хранилища и особенностей работы с ним, мы имеем чистые модели сущностей.

Выводы каждый делает сам, всё конечно зависит от целевой архитектуры. Если монолит - то можно не заморачиваться написанием конфигураций, а сделать всё атрибутами аннотаций данных (хоть на мой взгляд это и сильно загрязняет код и лучше прибегнуть к FluentApi прямо в OnModelCreating контекста). Но если речь идёт о чём-то более гибком - то речи и не может идти об атрибутах данных. Реализация IEntityTypeConfiguration<> кажется единственно верной.

Комментарии (11)


  1. nronnie
    16.01.2023 07:17
    +1

    Всё, как бы верно, но только как пример, потому что на самом деле, в данном случае это все не нужно. Id будет автоматически ключом по соглашению, IsRequired будет добавлен или не добавлен на основе nullable/not-nullable типов свойств. Единственно что нужно это MaxLength, но вот как раз его-то лучше задать аттрибутом свойства, потому что это больше свойство самого домена/модели, а не меппинга данных.

    И еще, я бы не увлекался наследованием в моделях - из личного опыта, если модель начинает хоть немного усложняться и разрастаться, то это порождает намного больше проблем, чем сначала дает выгоды.


    1. Oceanshiver
      16.01.2023 09:59

      Поддерживаю, однако вот вынесение таких полей, как идентификатор, даты удаления/создания в базовый класс все же считаю допустимым. Но в остальном да, лучше не увлекаться наследованием


      1. eugene_naryshkin Автор
        16.01.2023 10:31

        Наследование нам может помочь в будущем в дженерик репо/сервисах. Но если не хочется прибегать к базовой сущности, то можно с этими общими полями определить интерфейс, вместо базового класса, чтобы также иметь все преимущества дженериков в будущем.


        1. nronnie
          16.01.2023 11:06
          +1

          Ну так вот именно, что намного лучше интерфейс, хотя это и стоит дополнительного кода. А иначе потом окажется, что в десятой сущности Id должен по какой-то причине быть не гуидным, а численным, в одинадцатой ничего никогда не обновляется поэтому UpdateDate там вообще лишний и так далее, и со временем вся эта изначальная красота с EntityBase превращается в полный бедлам в котором одни сущности наследуют от EntityBase, другие от какого-нибудь EntityBase2, третьи вообще ни от чего не наследуют, и тут появляются какие-то еще, которые вообще непонятно куда в эту имеющуюся схему пристроить. Наследование с целью полиморфизма (т.е. те же интерфейсы) это хорошо, но наследование с целью просто повторного использования кода далеко не всегда, и чаще всего это наоборот плохо и чревато последующими проблемами - про это уже давно писали и Саттер и Александреску, и Рихтер. Это как с принципом KISS - с одной стороны он правильный, а с другой стороны регулярно видишь, как создание разделямого кода для применения этого принципа превращается в еще большее зло чем дублирование этого кода.


          1. nronnie
            16.01.2023 16:49
            +1

            Конечно, я имел в виду не KISS, а DRY, прошу прощения.


    1. eugene_naryshkin Автор
      16.01.2023 10:39

      Всё как бы верно, но:
      На проект может попасть новый разработчик, который в принципе не слышал о таких соглашениях, и, для наглядности и более быстрого онбординга коллеги как раз такое конфигурирование (кроме builder.HasKey(k => k.Id)) будет очень даже полезным.


      1. nronnie
        16.01.2023 11:13

        Так ведь все соглашения описаны в официальной документации.


  1. ivanstus
    16.01.2023 09:59
    +1

    Да и virtual в ссылках на связанные сущности является пережитком старого Entity Framework и абсолютно избыточен, если только вы не хотите использовать Lazy loading.


    1. eugene_naryshkin Автор
      16.01.2023 10:32

      Согласен


  1. MDiMaI666
    18.01.2023 22:16
    -1

    Реально про такое статью надо написать? Тут уровень ниже плинтуса...

    Хоть бы рассмотрели вопросы наследования в реальном проекте. Как его маппит ef, миграции и другие тонкости. Там вагон всего.


    1. eugene_naryshkin Автор
      18.01.2023 22:54

      Эта статья - мнение, кому-то может оказаться полезной.

      Ну и если бы тема статьи была - "EF Core - про миграции и вагон всего..." то обязательно бы раскрыли эти вещи, не переживайте????