Тем не менее, получив в распоряжение ORM, программист зачастую возвращается к навигационным подходам обработки массивов данных вне РСУБД лишь с той разницей, что теперь этот массив, как хочется надеяться, является содержимым целой таблицы.
Почему? Недостаток знаний РСУБД пытаются возместить дополнительными уровнями абстракций. На деле же выходит обратное: уровни абстракции скрывают не детали слоя хранения объектов от программиста, а, наоборот, его некомпетентность в области баз данных от СУБД. До некоторого времени.
Несмотря на толстый слой абстракций, предоставляемый ORM типа Hibernate, заставить приложение эффективно работать с РСУБД без знаний соответствующих принципов ортогонального мира и языка SQL практически невозможно.
Но попытки продолжаются. Одни по-прежнему разрабатывают проекторы для своих внутренних нужд, зачастую очень лёгкие. Другие ищут упрощение и выход в noSQL. Но в выигрыше пока остаются программисты и консультанты, обладающие "базоданными" компетенциями и зарабатывающие на тех, кто ими не обладает.
Как обычно используют ORM
На софтостроительных презентациях часто рисуют красивые схемы по разделению слоёв представления, бизнес-логики и хранимых данных. Голубая мечта начинающего программиста – использовать только одну среду и язык для разработки всех слоёв и забыть про необходимость знаний реляционных СУБД, сведя их назначение к некоей "интеллектуальной файловой системе". Аббревиатура SQL вызывает негативные ассоциации, связанные с чем-то древним, не говоря уже про триггеры или хранимые процедуры. На горизонте появляются добрые люди, с книгами признанных гуру о домен-ориентированной разработке под мышкой, заявляющие новичкам примерно следующее: "Ребята, реляционные СУБД – пережиток затянувшейся эпохи 30-летней давности. Сейчас всё строится на ООП. И есть чудесная штука – ORM. Начните использовать её и забудьте про тяжёлое наследие прошлого!"
Ребята принимают предложение. Дальше эволюция разработки системы примерно следующая:
• Вначале происходит выбор ORM-фреймворка для отображения. Уже на этом этапе выясняется, что с теорией и стандартами дело обстоит плохо. Впору насторожиться бы, но презентация, показывающая, как за 10 минут создать основу приложения типа записной книжки контактов, очаровывает. Решено!
• Начинаем реализовывать модель предметной области. Добавляем классы, свойства, связи. Генерируем структуру базы данных или подключаемся к существующей. Строим интерфейс управления объектами типа CRUD. Все достаточно просто и на первый взгляд кажется вполне сравнимым с манипуляциями над DataSet – тем, кто о них знает, конечно – ведь не все подозревают о существовании табличных форм жизни данных в приложении за пределами сеток отображения DBGrid.
Как только разработчики реализовали CRUD-логику, начинается основное действо. Использовать сиквел напрямую теперь затруднительно. Если не касаться стратегий отображения и проблем переносимости приложения между СУБД, по сути каждый SQL-запрос с соединениями, поднявшись в домен, сопровождается специфической проекцией табличного результата на созданный по этому случаю класс. По этой причине приходится использовать собственный язык запросов ORM. Нестандартный, без средств отладки и профилирования. Если он, язык, вообще имеется в данном ORM. Для поддерживающих соответствующую интеграцию среда. NET предоставляет возможность использовать LINQ, позволяющий отловить некоторые ошибки на стадии компиляции.
Сравните выразительность языка на простом примере, который я оставлю без комментариев:
SQL
SELECT *
FROM task_queue
WHERE
id_task IN (2, 3, 15)
AND id_task_origin = 10
NHibernate HQL
IList<TaskQueue> queues = session
CreateQuery("from TaskQueue where Task.Id in (2, 3, 15) and TaskOrigin.Id = 10")
List<TaskQueue>();
NHibernate без HQL с критериями
IList<TaskQueue> queues = session.CreateCriteria()
Add(Expression.In("Task.Id", someTasks.ToArray()))
Add(Expression.Eq("TaskOrigin.Id", 10))
List<TaskQueue>();
LINQ (NHibernate)
IList<TaskQueue> queues = session
Query<TaskQueue>()
Where(q => someTasks.Contains(q.Task.Id) &&
q. TaskOrigin.Id == 10).ToList();
Внезапно оказывается, что собственный язык запросов генерирует далеко не самый оптимальный SQL. Когда БД относительно небольшая, сотня тысяч записей в наиболее длинных таблицах, а запросы не слишком сложны, то даже неоптимальный сиквел во многих случаях не вызовет явных проблем. Пользователь немного подождёт.
Однако запросы типа "выбрать сотрудников, зарплата которых в течение последнего года не превышала среднюю за предыдущий год" уже вызывают проблемы на уровне встроенного языка. Тогда разработчики идут единственно возможным путём: выбираем коллекцию объектов и в циклах фильтруем и обсчитываем, вызывая методы связанных объектов. Или используем тот же LINQ над выбранным массивом. Количество промежуточных коротких SQL-запросов к СУБД при такой обработке коллекций может исчисляться десятками тысяч.
Триггер как идеальная концепция для NHibernate
Обычно разработчикам баз данных я рекомендую избегать необоснованного использования триггеров. Потому что их сложнее программировать и отлаживать. Оставаясь скрытыми в потоке управления, они напрямую влияют на производительность и могут давать неожиданные побочные эффекты. Пользуйтесь декларативной ссылочной целостностью (DRI) и хранимыми процедурами, пока возможно. А если ваш администратор баз данных склонен к параноидальным практикам "запрещено всё, что не разрешено", избегайте программировать на уровне СУБД, исключая критичные по производительности участки. Приходится говорить это с сожалением…
Однако стоит посмотреть на слой домена, живущего под управлением NHibernate, как становится ясно, что триггер в СУБД – это достаточно простая и хорошо документированная технология. В то время как NHibernate предлагает прикладному разработчику целый зоопарк триггероподобных решений.
Во-первых, имеется древний способ реализации классом домена интерфейса из пространства имён NHibernate.Classic. Например, IValidate. Вроде бы удобно: реализовал и делай проверки, генерируя исключения для отмены транзакции. Но вот незадача: при удалении объекта этот метод не срабатывает, нужно использовать другие подходы.
Во-вторых, после осознания авторами недостаточности IValidate и ILifeCycle была введена система прерываний (interceptors). Это уже больше, чем бесплатный хлеб на завтрак. Однако в обработчиках типа Save или FlushDirty в качестве аргументов используются массивы состояний объекта. То есть изменять сам объект в них напрямую нельзя: в общем случае это просто не срабатывает, но могут быть и побочные эффекты. Нужно, ни много ни мало, поискать индекс элемента в массиве имён свойств объекта, затем по найденному номеру изменить значение в другом массиве текущего состояния объекта. Что-то вроде такого кода:
Изменение свойств объекта в обработчике NHibernate
int index = Array.IndexOf(propertyNames, "PhoneNumber");
if (index!= -1)
state[index] = "(123)456789";
Создавать новые, извлекать, изменять или удалять существующие объекты с последующим сохранением внутри обработчика совершенно не рекомендуется. Кроме собственно гонок (race condition), когда обработчик одного класса создаёт другой, а тот что-то делает с первым, могут быть и другие эффекты, включая бесконечную рекурсию. Шаг вправо, шаг влево – стреляют боевыми и без предупреждения. Неплохая иллюстрация к теме декларируемой безопасности языка C# или Java. Рекомендуемая практика обхода ловушек такого рода – запрограммировать собственную защищённую (thread safe) очередь, куда складывать все созданные или изменённые объекты, а в событиях BeforeCommit или AfterCommit эту очередь обрабатывать.
В-третьих, механизм прерываний также признан несовершенным, после чего был введён механизм событий (events), коим, начиная со второй версии, всем следует пользоваться.
Дело в том, что в сессии вы можете зарегистрировать только один класс, реализующий обработчики прерываний. И если у вас достаточно много разной обработки, то получается так называемый "волшебный класс", который реализует всё. Это неудобно, даже если использовать класс в качестве пустого фасада.
Теперь же вы можете зарегистрировать неограниченное количество классов-слушателей (listeners), ожидающих то или иное событие и соответствующим образом реагирующих на него. Один класс может реализовывать несколько обработчиков событий, будучи зарегистрированным для прослушивания нескольких их типов.
С точки зрения архитектуры это несомненный плюс, реализацию можно разбить на независимые классы. Однако для снижения побочных эффектов, прежде всего рекурсии и гонок, не сделано ничего. В том же SQL Server, напомню, рекурсия в триггерах отключена по умолчанию, поскольку трудно сходу придумать случай, когда она нужна. А в событиях NHibernate каждый сам себе вредитель. При этом однозначной методики и документации нет, нескольким десяткам типов событий в официальной документации отведено меньше страницы. Существует огромное количество записей в блогах, тиражирующих одни и те же конкретные примеры – аудит, прежде всего. Но однозначной выверенной практики нет, в каждом конкретном случае надо проводить тесты. Например, для манипуляции объектами рекомендуется создавать дочернюю сессию, что также не всегда избавляет от побочных эффектов.
В итоге имеем плохо документированную нестабильную систему, которая при отладке в разы труднее столь нелюбимых разработчиками триггеров баз данных. Тем не менее, если разработчик пишет код слоя домена, альтернатив практически нет. Генерация скелета кода по модели облегчает работу, но риски возникновения ловушек многопоточной обработки по-прежнему остаются на совести рядового программиста. А создать такие ситуации несложно. Поэтому надо признать, что использование обработчиков событий слоя домена должно быть рекомендовано еще в меньшей степени, чем использование триггеров слоя хранения данных.
ORM на софтостроительной площадке
На короткое время судьба забросила меня в качестве консультанта в лоно одной софтостроительной фирмы, разработавшей и поддерживающей специализированную систему документооборота для управления жизненным циклом товаров. Система относительно небольшая по функционалу, а вот клиенты хоть и мало-
численные, но крупные, то есть способные упорно настаивать на своих требованиях, иногда противоречивых, аргументируя их соответствующим бюджетом.
В процессах взаимодействия фирм весьма отчётливо действуют физические законы всемирного тяготения. Небольшой планете-фирме, чтобы не упасть на большую, разбившись вдребезги, необходимо развить как минимум первую космическую скорость. В этом случае она будет стабильно вращаться вокруг большой в качестве спутника. Чтобы оторваться от поля тяготения большой планеты и начать самостоятельный полет требуется уже вторая космическая скорость.
В течение последних месяцев в фирме происходила попытка выйти на вторую космическую. Поскольку процесс, обеспечивающий первую космическую, был близок к тому, что называют экстремальным программированием, было принято решение продолжать в том же духе, назвав все это звонким словечком "скрам".
В принципе, основные элементы процесса имелись в наличии. Коллективное владение кодом, также известное, как личная безответственность при его написании, утренние "пионерские линейки" вместо чётких спецификаций, практически полное отсутствие документации, частые, до одного раза в 1–2 недели, релизы и связанный с этим нескончаемый аврал, работа в тесном и жарком помещении общего зала. Последнее вызвано объективными причинами: для поддержания жизнедеятельности муравейника требуется всё больше работяг.
Вы резонно спросите: "А где рефакторинг?" Системе на тот момент исполнилось уже 2 года. Рефакторинг проводился раньше, но, в связи с тем, что его стоимость, прежде всего по требуемым срокам поставки, возрастала, количество реструктурируемого кода линейно уменьшалось. Это создало положительную обратную связь: реструктуризация становилась всё дороже.
На столах у некоторых программистов лежали книжки по рефакторингу от раскрученных апостолов веры в эволюционный дизайн. Но в связи с занятостью чётко выполнялась только первая часть моего любимого правила "Настоящий исследователь должен поменьше читать, чтобы иметь возможность больше думать головой". На вторую часть, к сожалению, у разработчиков не было времени.
Приведу пример подсистемы, для которой рефакторинг уже стал дороже полной переделки. Имелся модуль экспорта документов в форматы, пригодные для импорта системами клиентов уровня их внутренних АСУП. Коллективная ответственность привела к выбору написания императивного кода в размере 20 тысяч строк вместо разработки нескольких шаблонов XSLT из нескольких сотен строк. Почему? Во-первых, опасались потери производительности, а во-вторых, не имели достаточной компетенции в XSL. Цикломатическая сложность кода в отдельных методах превышала запредельное число 50 при рекомендованном пороге в 10–20. Глубина вложенности вызовов также была больше 10, при цикличности их части: this с верхнего уровня передаётся в качестве параметра, и где-то глубоко внизу дёргают этот this за какой-то метод. Объектно-ориентированная тарелка со спагетти.
О производительности следует сказать отдельно. Загрузка достаточно сложного документа перед его экспортом занимала порядка 30 секунд. Потому что было принято идеологическое решение "ни строчки SQL", несмотря на необходимость поддержи только одной РСУБД. Вся система работала через слой доступа под управлением NHibernate. Это был именно DAL, а не домен, так как парни не использовали всю мощь NHibernate, ограничиваясь отображением, и накручивали сверху слои бизнес-логики. При загрузке сложного документа с проверками подсистемы безопасности было насчитано порядка 20 тысяч (!) коротких SQL-запросов.
Почему именно такое решение? Было сказано некоторое количество красивых слов о чистоте объектной концепции. Это стандартный ответ. Тогда я просто предложил использовать в одном узком месте вместо сотен строк вложенных циклов сишарп-кода относительно короткий рекурсивный запрос из 40 строк сиквел-кода. Вид этого кода вначале вызвал у парней лёгкий ступор, а после совещания через сутки было принято решение отказаться от него. Но надо отдать должное: ребята честно признались, что после моего ухода никто не сможет этот сиквел-код поддерживать и модифицировать. Вот, собственно, и главная причина первоначального выбора. Красноречивое подтверждение тезиса о том, что слой объектной абстракции доступа к реляционной СУБД в большинстве случаев скрывает не базу данных от приложения, а некомпетентность разработчиков приложения в области баз данных.
Долго и неинтересно рассказывать, каким образом система с тремя сотнями таблиц умудрилась обрасти миллионом строк C#-кода, для поддержки которого требуются всё новые разработчики. Клиенты проявляли недовольство, так как сроки срывались, а задержка медленно, но неуклонно росла. Два совещания, на которых генеральный директор, милейший мсьё, пытался реанимировать дух прежнего стартапа, искренне желая, чтобы все, от стажера до его ближайших замов, смело поднимали и доносили проблемы, по всей видимости, прошли впустую. Состояние постоянного стресса передалось даже мне, формально не несущему ответственности за результат. Но ведь ещё есть риски социального капитала, репутации, которая долго зарабатывается и очень быстро может обесцениться. Пришлось даже посидеть с мыслями об оптимальных путях выхода из миссии за бокалом бургундского.
История закончилась типично. С большими усилиями систему дотянули до сдачи в эксплуатацию, хронические проблемы вследствие архитектурных просчётов названы особенностями, после чего развитие её было заморожено, а команда частично распущена, частично переориентирована на другие проекты.