Использование ORM при разработке корпоративных приложений

в 8:24, , рубрики: .net, ERP, ERP-системы, linq, Oreodor, orm, sql, ооп, метки: , , , , , , ,

Есть много споров о плюсах и минусах ORM, попробуем сделать акцент на плюсах при его использовании в ERP приложениях.

Я 5 лет разрабатываю платформу для ERP, разработал три версии платформы. Всё начиналось с EAV, после была нормальная модель, хранимые процедуры, view-хи, и сейчас эволюционировало до использования ORM. Позвольте поделиться опытом, почему ORM — хорошо.

Для демонстрации преимуществ такого подхода я разработал небольшое приложение для риэлтерского агентства (вдохновение черпал из Циан, из него же и модель данных) и попробую описать, почему благодаря ORM я все сделал за 1 день.

image
Я являюсь сторонником CodeFirst подхода, т.к. это единственно правильно для планирования структуры бизнес-приложения.
В нашей последней платформе мы после долгого выбора решили использовать ORM DataObjects.Net, но суть статьи будет понятна для приверженца любой ORM, будь то NHibernate, Entity Framework и т.д.

И так, спланируем простое приложение для агентства недвижимости:
Риэлтор агентства недвижимости (Агент) — вносит в систему предложения по аренде и ждет запросов от арендаторов.
Арендатор просматривает предложения, отбирает по множеству критериев интересные для него и обращается к агенту для заключения сделки.

Дизайн модели данных

Создание модели — это создание классов на C#, добавление свойств-полей, атрибутов, комментариев.
Модель классов для агентства недвижимости
в коде это выглядит примерно так:

/// <summary>
/// Предложение от арендодателя
/// </summary>
[HierarchyRoot]
[Index("CreationTime", Clustered = true)]
public abstract class RentOfferBase : DocumentBase
{
    …
    /// <summary>
    /// Наименование
    /// </summary>
    [Hidden]
    [Field(Length = 64)]
    public string Name { get; set; }

    /// <summary>
    /// Дата публикации
    /// </summary>
    [Field]
    public DateTime CreationTime { get; set; }

    /// <summary>
    /// Стоимость
    /// </summary>
    [Field(Scale = 0)]
    public decimal Price { get; set; }


    /// <summary>
    /// Комиссия
    /// </summary>
    [Field(Scale = 0)]
    public decimal Comission { get; set; }

    /// <summary>
    /// Валюта
    /// Валюта в которой указаны цены
    /// </summary>
    [Field(Nullable = false)]
    public EnCurrency Currency { get; set; }

    /// <summary>
    /// Линия
    /// Линия метро
    /// </summary>
    [Field]
    public MetroLine Line { get; set; }

    /// <summary>
    /// Метро
    /// Станция метро расположенная рядом с объектом
    /// </summary>
    [Field]
    public MetroStation Metro { get; set; }    
}

Некоторые особенности, на которые имеет смысл обратить внимание:

  • Xml-комментарии
    Они сделаны не только для понятности кода, но и для использования в интерфейсе системы. То что находится на первой строчке — будет отображаемым наименованием поля. Всё что в последующих — всплывающей подсказкой. Таким подходом мы упростили себе поддержку документации кода и управлением наименованиями объектов.
  • Атрибуты
    [Field(Length = 64)]
    

    Field — общее указание того что свойство будет persistent-свойством
    Length — длина строки
    Scale = 0 — количество знаков после запятой в decimal

    [Index("CreationTime", Clustered = true)] 
    

    Атрибут сущности (класса) указывающий на создание индекса, ключевые поля, тип

    [HierarchyRoot]
    

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

В этом примере я применил наследование для предложений от арендодателя (RentOfferBase) — базовое предложение содержит некоторую часть полей, более детальные предложения, например предложение квартиры — содержит уточняющие поля — Площадь кухни, Количество комнат.

Наследование

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

Инкапсуляция

Кроме знакомой многим инкапсуляции из мира ООП, при использовании ORM мы инкапсулируем ещё и физическую модель хранения данных. Можем использовать любую схему наследования для одного и того-же бизнес кода. Т.е. меняем структуру бд, не изменяя код приложения, ну или почти не изменяя.
Из предыдущей структуры классов не совсем понятно, как будут выглядеть таблицы, содержащие данные предложений от арендодателя, а выглядеть они могут тремя различными способами, в зависимости от значения атрибута указывающего схему наследования:

ClassTable

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

SingleTable

Одна таблица для всех классов иерархии
Схема таблиц для модели наследования SingleTable

ConcreteTable

По таблице на каждый не абстрактный класс
Схема таблиц для модели наследования ConcreteTable

Зачем всё это нужно?

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

[HierarchyRoot]

будет заменено на

[HierarchyRoot(InheritanceSchema.SingleTable)]

и

[HierarchyRoot(InheritanceSchema.ConcreteTable)]

соответственно. При этом, т.к. мы пишем запросы не на SQL, то все запросы будут автоматически транслированы для использования соответствующей схемы наследования. Т.е. отчет по предложениям об аренде/квартир/комнат написанный на LINQ и работающий через ORM будет работать с каждой схемой и не потребует никаких доработок.

Добавление бизнес-логики

События форм

Большинство платформ (как и наша) умеют автоматически генерировать формы по модели. Но нам мало статических форм, давайте оживим её, добавим динамики. В нашей системе мы ввели такое понятие как обработчик событий форм — класс, реализующий интерфейс обработчика с указанием на какие поля завязаны события. По изменению данных на клиенте происходит отправка данных на сервер, десериализация, обработка .net объекта, сериализация, отправка данных на клиент.

Например, изменяем на форме Стоимость, сразу же, налету, пересчитывается Процент. И наоборот. А вот как лаконично это выглядит в коде:

/// <summary>
/// Обработчик изменения поля Price и ComissionPercent
/// </summary>
[OnFieldChange("Price", "ComissionPercent")]
public class RentalPriceFormEvent : RentOfferFormEventsBase<RentOfferBase>
{
    public override void OnFieldChange(RentOfferBase item)
    {
        if (item.ComissionPercent != decimal.Zero)
        {
            item.Comission = item.Price * 0.01m * item.ComissionPercent;
        }
    }
}

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

Полиморфизм

В предыдущем примере мы написали событие только для одной сущности RentOfferBase, это событие будет работать и с наследниками, но что если у нас несколько сущностей с ценой/комиссией? Каждый раз писать один и тот же код?
Выделяем интерфейс

/// <summary>
/// С комиссией
/// </summary>
public interface IWithComission
{
    /// <summary>Стоимость</summary>
    decimal Price { get; set; }
    /// <summary>Комиссия</summary>
    decimal Comission { get; set; }
    /// <summary>%</summary>
    decimal ComissionPercent { get; set; }
}

и переписываем событие в виде

/// <summary>
/// Обработчик изменения поля RentalPrice и ComissionPercent
/// </summary>
/// <typeparam name="TEntity">Тип сущности</typeparam>
[OnFieldChange("Price", "ComissionPercent")]
public class RentalPriceFormEvent<TEntity> : RentOfferFormEventsBase<TEntity>
where TEntity : DocumentBase, IWithComission
{
    public override void OnFieldChange(TEntity item)
    {
        if (item.ComissionPercent != decimal.Zero)
        {
            item.Comission = item.Price * 0.01m * item.ComissionPercent;
        }
    }
}

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

События сущностей

События сущностей очень похожи на события форм, но срабатывают транзакционно в момент изменения сущности. Это некий аналог триггеров бд, но в отличие от триггеров и аналогично событиям форм позволяют использовать ООП подход. Например, нам нужно контролировать изменение сущностей на статусе “закрыт” так, чтобы никто кроме администратора не мог их изменять. Довольно простой код

/// <summary>
/// Событие для установки краткого наименования заявки
/// </summary>
[FireOn(EntityEventAction.Updated)]
public class CheckStatus<TEntity> : IEntityEvent<TEntity>
    where TEntity : EntityBase, IWithStatus
{
    /// <summary>
    /// Операция контроля
    /// </summary>
    /// <param name="item">Элемент сущности с измененными полями</param>
    public void Execute(TEntity item)
    {
        if (item.Status.Name == "Закрыт" && !Roles.IsUserInRole("admin"))
        {
            throw new ErrorException("Запрещено изменение сущностей на статусе 'Закрыт'!");
        }
    }

    /// <summary>
    /// Текущее действие выполняемое над элементом сущности
    /// </summary>
    public EntityEventAction CurrentAction { get; set; }
}

Который проверяет что если изменяемая сущность находится на статусе “Закрыт” и пользователь не принадлежит роли админ — то генерируется исключение. Аналогично событиям форм события сущностей будут применяться ко всем сущностям совместимым с ними, в данном случае реализующими интерфейс IWithStatus.

Разделение кода

В некоторых подходах используется RichDomainModel, у нас же она Anemic
и это значит, что в классе сущности практически отсутствует бизнес логика. (Для этого есть События Форм/Сущностей/Фильтры и т.п.)
Преимущество такого подхода в возможности модификации поведения внешних сущностей. Например, одна компания разработала модуль Адресов и поставляет его как библиотеку, мы не имеем доступа к исходному коду этой библиотеки и хотим добавить какое-нибудь поведение на форму, например при выборе некорректного адреса предупреждать.
Для этого мы можем написать событие формы, которое будет применено к внешнему компоненту.

Фильтры

Применение ORM позволяет воспользоваться для фильтрации таким мощным инструментом .net как ExpressionTrees. Мы можем заранее написать выражение фильтрации для использования как ограничения бизнес логики, можем на основе действий пользователя отфильтровать грид.

Например, для ограничения видимости неактуальных заявок, для менеджера применяется следующее выражение фильтрации из кода:

public static Expression<Func<TOffer, bool>> FilterOffers<TOffer>() 
    where TOffer : RentOfferBase
{
    return a => a.Creator.SysName == SecurityHelper.CurrentLogin || a.Status.Name == "Актуально";
}

Это простой фильтр, используемый для ограничения прав доступа только к своим заявкам, либо к заявкам на статусе “Актуально”
Этот фильтр сейчас не привязан явно ни к какой сущности, generic параметр говорит лишь о том, что использовать его можно для RentOfferBase и любого из его наследников. Для кого он будет реально применен будет определяться позже, в момент настройки приложения.

Так же мы можем задать фильтрацию одного поля формы в зависимости от другого

[FilterFor(typeof(RentOfferBase), "Metro")]
public static Expression<Func<MetroStation, bool>> MetroFilter([Source("Line")]MetroLine line)
{
    return a => line == null || a.MetroLine == line;
}

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

Внесение изменений в систему

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

Здесь необходимо сказать, что важно не просто ORM, а идеология CodeFirst. В предыдущей версии нашей системы мы тоже использовали ORM — Linq2SQL. При этом использовался Database-first подход, база данных хранилась в виде “мастер-базы” и скриптов обновления. Типовая ошибка, встречающаяся в таком подходе — код классов .net не соответствует БД. Для решения проблемы мы написали собственные валидаторы структуры бд.

Что же мы получаем в CodeFirst:

  • Быструю разработку сущностей — пишем .net класс редко задумываясь о БД
  • Удобный способ хранения БД в системе контроля версий — храним только код класса
  • Валидацию бизнес логики в момент компиляции

Но как же быть с обновлениями?

Миграция

Представим, что мы готовим обновление, которое заказчик устанавливает на свою БД. Простые миграции выполняются полностью автоматически. Т.е. если мы внесли безопасные изменения в модель — то ORM сам смигрирует БД на новую версию.
Безопасные изменения. это изменения не удаляющие данные из БД, например:

  • Создание нового поля
  • Создание новой сущности
  • Увеличение точности/длины поля

Конечно, этих действий не достаточно при разработке серьёзных приложений, что же делать при задаче переименовать поле/сущность?

  1. Применить рефакторинг переименования (мы используем ReSharper). При этом все использования этого поля в нашем коде переименовываются.
    В том числе переименовываются использования в Фильтрах, Событиях Формы, Событиях Сущности. Сложность может доставлять только встречающиеся в виде текста имена полей в Атрибутах, но если наименование поля достаточно уникально — то проблем не будет. При этом после переименования можно запустить компиляцию и убедиться что поля в атрибутах переименованы правильно.
  2. Добавить hint переименования. Hint — подсказка для ORM что же делать с БД при наличии различий между схемой построенной по классам и реальной схемой в SQL. hint переименования поля выглядит примерно так:
    public class RenameFieldUpgrader : ModelUpgraderBase
    {
        public override Version Version { get { return new Version("3.5.0.8764"); } }
    
        public override void AddUpgradeHints(ISet<UpgradeHint> hints)
        {
            hints.Add(new RenameFieldHint(typeof(RentOfferBase), "OldName", "NewName"));
        }
    }

Похожими хинтами мы можем указать на переименование сущности, удаление поля/сущности. При наличии такого хинта при следующем запуске ORM автоматически применит рефакторинг переименования для БД и переименует поле с сохранением данных.

Итоги

В результате применения ORM мы получили:

  • Удобную и быструю разработку как модели данных так и бизнес логики
  • Удобную, простотую и надежную поддержку приложения
  • Использовали преимущества ООП на всех этапах
  • Повторное использование кода (исключили copy-paste)
  • Валидацию кода в момент компиляции
  • Лаконичный, понятный код бизнес-логики

Автор: pil0t

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js