- PVSM.RU - https://www.pvsm.ru -
Есть много споров [1] о плюсах и минусах ORM [2], попробуем сделать акцент на плюсах при его использовании в ERP приложениях.
Я 5 лет разрабатываю платформу для ERP, разработал три версии платформы. Всё начиналось с EAV, после была нормальная модель, хранимые процедуры, view-хи, и сейчас эволюционировало до использования ORM. Позвольте поделиться опытом, почему ORM — хорошо.
Для демонстрации преимуществ такого подхода я разработал небольшое приложение для риэлтерского агентства (вдохновение черпал из Циан, из него же и модель данных) и попробую описать, почему благодаря ORM я все сделал за 1 день.
Я являюсь сторонником CodeFirst подхода, т.к. это единственно правильно для планирования структуры бизнес-приложения.
В нашей последней платформе мы после долгого выбора решили использовать ORM DataObjects.Net [3], но суть статьи будет понятна для приверженца любой 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; }
}
Некоторые особенности, на которые имеет смысл обратить внимание:
[Field(Length = 64)]
Field — общее указание того что свойство будет persistent-свойством
Length — длина строки
Scale = 0 — количество знаков после запятой в decimal
[Index("CreationTime", Clustered = true)]
Атрибут сущности (класса) указывающий на создание индекса, ключевые поля, тип
[HierarchyRoot]
Атрибут, указывающий, что класс является корнем иерархии сущностей, т.о. все экземпляры как этого класса, так и его наследников, являются хранимыми
В этом примере я применил наследование для предложений от арендодателя (RentOfferBase) — базовое предложение содержит некоторую часть полей, более детальные предложения, например предложение квартиры — содержит уточняющие поля — Площадь кухни, Количество комнат.
При работе с ORM мы можем воспользоваться таким мощным инструментом ООП как наследование.
Для базового класса предложения об аренде создаем наследников: Предложения квартир и Предложения комнат
При очевидной простоте, этот подход позволяет радикально сократить количество кода и упростить разработку схожих сущностей, особенно эффективно это при разработке похожих документов, отличающимися несколькими полями.
Кроме знакомой многим инкапсуляции из мира ООП, при использовании ORM мы инкапсулируем ещё и физическую модель хранения данных. Можем использовать любую схему наследования для одного и того-же бизнес кода. Т.е. меняем структуру бд, не изменяя код приложения, ну или почти не изменяя.
Из предыдущей структуры классов не совсем понятно, как будут выглядеть таблицы, содержащие данные предложений от арендодателя, а выглядеть они могут тремя различными способами, в зависимости от значения атрибута указывающего схему наследования:
Используется по умолчанию, и создает по таблице на каждый класс, начиная с корня иерархии
Одна таблица для всех классов иерархии
По таблице на каждый не абстрактный класс
В некоторых случаях удобно хранить нормализованные данные, а в других, для оптимизации, удобнее денормализировать таблицы. Преимущество ORM в том что это можно сделать очень просто — всего лишь изменив одну строчку — в нашем случае
[HierarchyRoot]
будет заменено на
[HierarchyRoot(InheritanceSchema.SingleTable)]
и
[HierarchyRoot(InheritanceSchema.ConcreteTable)]
соответственно. При этом, т.к. мы пишем запросы не на SQL, то все запросы будут автоматически транслированы для использования соответствующей схемы наследования. Т.е. отчет по предложениям об аренде/квартир/комнат написанный на LINQ и работающий через ORM будет работать с каждой схемой и не потребует никаких доработок.
Большинство платформ (как и наша) умеют автоматически генерировать формы по модели. Но нам мало статических форм, давайте оживим её, добавим динамики. В нашей системе мы ввели такое понятие как обработчик событий форм — класс, реализующий интерфейс обработчика с указанием на какие поля завязаны события. По изменению данных на клиенте происходит отправка данных на сервер, десериализация, обработка .net объекта, сериализация, отправка данных на клиент.
Например, изменяем на форме [4] Стоимость, сразу же, налету, пересчитывается Процент. И наоборот. А вот как лаконично это выглядит в коде:
/// <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 == "Актуально";
}
Это простой фильтр, используемый для ограничения прав доступа только к своим заявкам [5], либо к заявкам на статусе “Актуально”
Этот фильтр сейчас не привязан явно ни к какой сущности, generic параметр говорит лишь о том, что использовать его можно для RentOfferBase и любого из его наследников. Для кого он будет реально применен будет определяться позже, в момент настройки приложения.
Так же мы можем задать фильтрацию одного поля формы [6]в зависимости от другого
[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:
Но как же быть с обновлениями?
Представим, что мы готовим обновление, которое заказчик устанавливает на свою БД. Простые миграции выполняются полностью автоматически. Т.е. если мы внесли безопасные изменения в модель — то ORM сам смигрирует БД на новую версию.
Безопасные изменения. это изменения не удаляющие данные из БД, например:
Конечно, этих действий не достаточно при разработке серьёзных приложений, что же делать при задаче переименовать поле/сущность?
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 мы получили:
Автор: pil0t
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/net/4730
Ссылки в тексте:
[1] споров: http://habrahabr.ru/post/123293/
[2] ORM: http://ru.wikipedia.org/wiki/ORM
[3] DataObjects.Net: http://dataobjects.net/
[4] форме: http://demo.oreodor.com/RealEstate/Auth.aspx?Redirect=CreateRentOffer.aspx?operation_id=4ac3a6e6-3ddd-493c-be6b-a54daa1f84dd
[5] заявкам: http://demo.oreodor.com/RealEstate/Auth.aspx?Redirect=Main.aspx#RentOfferBase:Regular
[6] формы : http://demo.oreodor.com/RealEstate/Auth.aspx?Redirect=CreateRentOffer.aspx?operation_id=8257d7dc-9fae-4368-976c-efe6a7333635
Нажмите здесь для печати.