Привет, %username%! Меня зовут Антон Жеронкин, я Data Scientist в Сбере, участник профессионального сообщества NTA. Сегодня поговорим о том, как можно сделать лучше жизнь разработчиков, которые часто сталкиваются с базами данных. Дело в том, что, когда разработчики вручную пишут функциональные модули, ответственные за связь с БД, они проделывают следующую работу:

  • описывают таблицы в виде классов;

  • описывают отдельные атрибуты таблиц в виде атрибутов классов. При этом требуется следить за тем, чтобы типы и форматы данных совпадали;

  • на CRUD-операции пишут много SQL-кода, который зашивается в методы языка программирования и помогает остальным модулям при необходимости использовать связь с БД.

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

В чём вообще проблема?

Когда в БД таблиц, их связей и атрибутов немного либо когда прорабатываешь структуру большой модели данных в первый раз, это вполне можно пережить. Работать со всем этим даже интересно. Но при ручной разработке Data Access Layer (DAL) на обширной базе через 4 часа хочется всё бросить, а прописывать те же самые сущности во второй раз в коде приложения уже нет желания — становится скучно.

Знатоки, конечно же, скажут, что SQL-скрипты можно «распотрошить» регулярными выражениями и из этого частично сгенерировать нужный код. Но, во-первых, какую-то часть работы всё равно придётся делать руками, а во-вторых, DDL-скрипты не всегда можно достать.

ORM-фреймворки (англ. Object-Relational Mapping), предназначенные для автоматизации рутины, уже существуют. ORM — это та самая прослойка между приложением и БД, с помощью которой можно, управляя объектами в приложении, синхронизировать их с объектами в БД, а также избавиться от необходимости вручную реализовывать DAL. То есть не прописывать, как должен выглядеть SQL-запрос на CRUD-операцию, не раскладывать переменные объекта по местам в запросе, не задавать приведение к типам/размерность и т. д. При этом ORM стоит с осторожностью использовать там, где для работы с данными требуются сложные SQL-запросы, так как на них ORM часто работает неоптимально либо же вообще не работает.

Для большинства языков разработана масса ORM-фреймворков — например, SQLAlchemy для Python, Entity Framework для .NET, Hibernate для Java. Опираясь на свои предпочтения, разберу типовые фишки ORM с помощью .NET Core/EF Core/MS SQL.

Для начала создадим пустой консольный проект на .NET Core, а после этого установим 3 пакета из NuGet:

  • Microsoft.EntityFrameworkCore;

  • Microsoft.EntityFrameworkCore.Tools;

  • Microsoft.EntityFrameworkCore.<DATABASE> — провайдер БД под конкретную СУБД.

Список доступных провайдеров БД в Entity Framework Core можно найти здесь.

Нюансы работы с Entity Framework

К работе с Entity Framework применимы 3 подхода, два из которых мы сейчас и рассмотрим.

1. Database First

Этот подход применяется тогда, когда есть уже готовая БД, а в приложении требуется реализовать интерфейс взаимодействия с ней. Для примера создадим на MS SQL вот такую базу:

USE DatabaseFirstDB
GO

CREATE TABLE [dbo].[company](
id INTEGER IDENTITY(1,1) PRIMARY KEY,
company_name VARCHAR(300) NOT NULL
)

CREATE TABLE [dbo].[department](
id INTEGER IDENTITY(1,1) PRIMARY KEY,
dep_name VARCHAR(300) NOT NULL,
add_info VARCHAR(3999) NULL,
company_id INTEGER NOT NULL FOREIGN KEY REFERENCES company(id)
)

CREATE TABLE [dbo].[employee](
id INTEGER IDENTITY(1,1) PRIMARY KEY,
fullname VARCHAR(300) NOT NULL,
birth_date DATE NOT NULL,
department_id INTEGER NOT NULL FOREIGN KEY REFERENCES department(id)
)

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

Давайте создадим пустое консольное приложение на .NET Core с помощью шаблона.

После этого обозначенные ранее библиотеки установлены, в Visual Studio нужно открыть консоль пакетного менеджера и выполнить всего одну команду:

Scaffold-DbContext -Provider Microsoft.EntityFrameworkCore.SqlServer -Connection "Data Source=(localdb)\MSSQLLOCALDB; Initial Catalog=DatabaseFirstDB"

В параметре Provider указываем полное имя библиотеки-провайдера БД, которую установили ранее. В параметре Connection — полную строку подключения к БД. Команда Scaffold-DbContext на основе подключённой базы автоматически реконструирует её структуру в виде моделей данных под каждую таблицу + класса DbContext.

Как мы видим, в проекте появились 4 новых файла:

  • [Company/Employee/Department].cs — модели данных;

  • DatabaseFirstDBContext.cs — контекст данных.

Сгенерированный код можно с лёгкостью кастомизировать, разложить по папкам/пространствам имён. Модели данных устроены просто — посмотрим на примере таблиц Employee и Department:

    public partial class Employee
    {
        public int Id { get; set; }
        public string Fullname { get; set; }
        public DateTime BirthDate { get; set; }
        public int DepartmentId { get; set; }

        public virtual Department Department { get; set; }
    }


    public partial class Department
    {
        public Department()
        {
            Employee = new HashSet<Employee>();
        }

        public int Id { get; set; }
        public string DepName { get; set; }
        public string AddInfo { get; set; }
        public int CompanyId { get; set; }

        public virtual Company Company { get; set; }
        public virtual ICollection<Employee> Employee { get; set; }
    }

Это самая что ни на есть обычная модель данных, знакомая тем, кто часто использует паттерны MVVM, MVC, и т.  д. в своих приложениях. Каждый столбец таблицы реализован с помощью стандартных автосвойств. Связь с другими таблицами реализуется в две стороны. В «нижестоящей» модели (Employee) присутствует экземпляр «вышестоящей» модели (Department). В «вышестоящей» модели, в свою очередь, присутствует коллекция экземпляров тех моделей, которые ссылаются на неё. Это позволяет без лишних хлопот ходить по их связям в обе стороны и не изобретать для этого «велосипед».

Теперь рассмотрим класс DatabaseFirstDBContext по частям:

public DatabaseFirstDBContext()
        {
        }

        public DatabaseFirstDBContext(DbContextOptions<DatabaseFirstDBContext> options)
            : base(options)
        {
        }

        public virtual DbSet<Company> Company { get; set; }
        public virtual DbSet<Department> Department { get; set; }
        public virtual DbSet<Employee> Employee { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer("Data Source=(localdb)\\MSSQLLOCALDB; Initial Catalog=DatabaseFirstDB");
            }
        }
    //=====остальной контент….=====
}

Здесь всё достаточно просто: 3 коллекции DbSet по числу моделей, куда EntityFramework перекладывает данные, которые потом будут доступны в приложении. Есть обработчик OnConfiguring, который в момент создания контекста задаёт опции по умолчанию. Соответственно, опции и их значения можно задавать как извне, подавая их потом конструктору, так и изнутри.

Также в контексте данных есть ещё один интересный обработчик OnModelCreating, через который настраиваются связи и параметры моделей:

protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Company>(entity =>
            {
                entity.ToTable("company");

                entity.Property(e => e.Id).HasColumnName("id");

                entity.Property(e => e.CompanyName)
                    .IsRequired()
                    .HasColumnName("company_name")
                    .HasMaxLength(300)
                    .IsUnicode(false);
            });

            modelBuilder.Entity<Department>(entity =>
            {
                entity.ToTable("department");

                entity.Property(e => e.Id).HasColumnName("id");

                entity.Property(e => e.AddInfo)
                    .HasColumnName("add_info")
                    .HasMaxLength(3999)
                    .IsUnicode(false);

                entity.Property(e => e.CompanyId).HasColumnName("company_id");

                entity.Property(e => e.DepName)
                    .IsRequired()
                    .HasColumnName("dep_name")
                    .HasMaxLength(300)
                    .IsUnicode(false);

                entity.HasOne(d => d.Company)
                    .WithMany(p => p.Department)
                    .HasForeignKey(d => d.CompanyId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK__departmen__compa__25869641");
            });

            modelBuilder.Entity<Employee>(entity =>
            {
                entity.ToTable("employee");

                entity.Property(e => e.Id).HasColumnName("id");

                entity.Property(e => e.BirthDate)
                    .HasColumnName("birth_date")
                    .HasColumnType("date");

                entity.Property(e => e.DepartmentId).HasColumnName("department_id");

                entity.Property(e => e.Fullname)
                    .IsRequired()
                    .HasColumnName("fullname")
                    .HasMaxLength(300)
                    .IsUnicode(false);

                entity.HasOne(d => d.Department)
                    .WithMany(p => p.Employee)
                    .HasForeignKey(d => d.DepartmentId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK__employee__depart__2C3393D0");
            });

            OnModelCreatingPartial(modelBuilder);
        }

Если посмотреть на него внимательней, можно увидеть, что в данном примере настройка моделей сводится к настройке свойств и синхронизации видов 3 сущностей (по числу моделей):

  • Entity<Employee>;

  • Entity<Department>;

  • Entity<Company>.

В свою очередь, настройки конкретной сущности в примере можно разбить на 3 группы:

А. Настройка названия таблицы в БД.

Методом entity.ToTable("employee") «подсказываем» Entity Framework, с какой таблицей синхронизировать коллекцию сущностей, если их название не совпадает с названием таблицы в БД.

Б. Настройка атрибутов сущности.

  • HasColumnName — действует так же, как метод ToTable, только для столбца;

  • IsRequired — аналог признака NOT NULL из SQL. Если оставить этот параметр пустым, будет вызвано исключение;

  • HasColumnType — задаёт тип данных поля в БД, если в .NET такие типы данных отсутствуют;

  • HasMaxLength — задаёт максимальную длину поля для типов данных VARCHAR, VARBINARY и т. д.;

  • IsUnicode — задаёт, закодирован ли столбец в Юникоде.

В. Настройка связи с другими сущностями.

  • HasOne — сущность, на которую мы ссылаемся (связь 1->M);

  • WithMany — сущности, которые ссылаются на настраиваемую сущность (связь M<-1);

  • HasForeignKey — продолжение метода WithMany — указывается внешний ключ, который связывает сущность из WithMany с текущей;

  • OnDelete/OnUpdate — указывается метод обеспечения ссылочной целостности при удалении/изменении текущей сущности.

2. Code First

Теперь обсудим второй подход. Приём с автоматическим созданием модели данных можно проделать и в обратном направлении, если у вас в приложении она уже есть и теперь её надо переложить в БД со всеми связями и типами данных. Это и есть Code First. В случае его применения всё делается в обратном порядке: сначала вы пишете в приложении модели и контекст, затем маппируете эту структуру на базу. Для примера в уже готовом приложении сменим БД на пустую и добавим в конструктор контекста следующий код:

public DatabaseFirstDBContext()
        {
            Database.EnsureCreated();
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer("Data Source=(localdb)\\MSSQLLOCALDB; Initial Catalog=CodeFirstDB");
            }
        }

С помощью метода Database.EnsureCreated Entity Framework при каждом запуске приложения будет проверять структуру БД на соответствие модели данных приложения и, если необходимо, переносить модель в БД.

Практическое применение

После настройки контекста данных проверим его работоспособность на простом запросе, в котором соединим сотрудников и департамент:

DatabaseFirstDBContext context = new DatabaseFirstDBContext();
var employees = context.Employee
                       .Include(x=>x.Department)
                       .ToList()

Я создал экземпляр контекста данных, через который я запросил список сотрудников. Чтобы привязанный к ним департамент был также доступен для взаимодействия, я использовал метод Include, который выполнит присоединение департамента. Без него в записях значение переменной Department будет равно null.

Теперь попробуем в список сотрудников добавить нового сотрудника.

var department = context.Department.Find(3);
var employee = new Employee 
{ 
     Fullname = "new test user",
     BirthDate = DateTime.Parse("2022-10-21"),
     DepartmentId = department.Id
};
context.Employee.Add(employee);
context.SaveChanges();

С этим тоже всё просто — мы сделали запись о новом сотруднике, задали её параметры, после чего поместили её в список сотрудников и зафиксировали изменения. Проверим, отображается ли новый сотрудник в БД и в приложении.

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

Что в итоге?

При помощи Entity Framework можно автоматически создавать низкоуровневые интерфейсы взаимодействия приложения с БД либо саму БД. В случае если работа с данными сводится к CRUD и простым запросам, ORM-фреймворки помогают избавиться от рутины и сэкономить время. Если у вас есть свои предпочтения, методы работы, подходы и технологии, поделитесь ими в комментариях, пожалуйста.

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


  1. Stormbringer-s
    17.01.2023 14:37
    +2

    Всегда интересовало как решается проблема ограничения колонок запроса при использовании орм на практике. После каждого запроса делать select new и анонимный класс? Или обычно можно оставить как есть и выбрать всё ибо хлопотно?


    1. NewTechAudit Автор
      19.01.2023 07:02

      Зависит от конкретной ситуации:

      В случае, если речь идет про одну таблицу (без джойнов, агрегатов, etc) – можно не отсекать лишние столбцы, а просто обращаться к нужным.

      Обратная ситуация возникает, если у нас столбцы в разных связанных таблицах, мы их джойним и имеем результирующую таблицу с кучей столбцов – а вам нужно всего несколько - тогда да, делаем отбор столбцов через select new и анонимный класс.

      + это еще зависит от потребления памяти


  1. Oceanshiver
    17.01.2023 16:24

    В 2023 году писать на Хабре статью про ORM/EF уровня "для самых маленьких" - ну не знаю даже


    1. eugene_naryshkin
      18.01.2023 10:53
      +1

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

      В любом случае я уверен, что свой читатель под это найдётся, ведь например, только выпустившиеся инженеры и знать не знают о таких инструментах, а тут без лишней воды своеобразный Quick start guide????


    1. NewTechAudit Автор
      18.01.2023 12:41

      Среди читателей есть начинающие и те, кто по каким-либо причинам не соприкасался с темой ORM и EF в частности. Для них такие статьи вполне котируются и в 2023