Маппинг данных из реляционной БД
Иногда возникают ситуации, когда решение задачи выборки данных из реляционной БД не укладывается в возможности используемой в проекте ОРМ, например, либо из-за недостаточной скорости работы самой ОРМ, либо не совсем оптимальных SQL запросов генерируемых ею. В таком случае обычно приходится писать запросы вручную.
Проблема в том, что данные из БД (в т.ч. в ответ на JOIN запрос) возвращаются в виде “плоского” двухмерного массива никак не отражающего сложную “древовидную” структуру данных приложения. Работать с таким массивом дальше крайне неудобно, поэтому требуется более-менее универсальное решение, позволяющее привести этот массив в более подходящий вид по заданному шаблону.
Решение было найдено, удобное и достаточно быстрое.
На сколько быстрое
Для оценки скорости работы библиотеки я собрал небольшой испытательный стенд на котором скорость работы моей библиотеки сравнивается со скоростью работы Eloquent. Для замеров использовался пакет phpbench.
Для того чтобы развернуть стенд у себя:
Здесь я использовал инструмент описанный в моей предыдущей статье.
Затем в меню выбираем: 1 Develop, затем: 1 Build, затем 2 Deploy and Up;
Затем запускаем тесты 5. Run tests
В базе 3000 книг. Результаты получились следующие:
benchEloquent — вытаскивает все книги с авторами с использованием Eloquent
benchEloquentId — вытаскивает определенную книгу с авторами с использованием Eloquent (10 раз)
benchProc — вытаскивает все книги с авторами с использованием библиотеки
benchProcId — вытаскивает определенную книгу с авторами с использованием библиотеки (10 раз)
Возможно приведенные тесты недостаточно репрезентативны, но разница заметна, как по времени выполнения, так и по расходованию памяти.
Как это работает
Далее, для примера (крайне простого), представим, что у нас имеется БД книг и авторов со следующей структурой.
Задача — вытащить все книги с их авторами.
Запрос будет выглядеть как-то так:
В ответ мы получим примерно такой массив данных.
| book.id | book.name | author.id | author.name |
| 1 | book1 | 2 | author2 |
| 1 | book1 | 4 | author4 |
| 1 | book1 | 6 | author6 |
| 2 | book2 | 2 | author2 |
| 2 | book2 | 3 | author3 |
| 2 | book2 | 6 | author6 |
| 2 | book2 | 7 | author7 |
Для этого немного изменим наш запрос:
Здесь мы в секции SELECT задали алиасы: для полей с данными о книгах алиасы с префиксом ‘book_’, а для полей с информацией об авторах с префиксом ‘author’.
Далее преобразуем ответ БД
$rows — ответ БД в виде массива объектов /stdClass()
$config — ассоциативный массив отражающий структуру данных итогового массива
Что такое ModelMapper и зачем он нужен?
Авторизуйтесь
Что такое ModelMapper и зачем он нужен?
Senior Java разработчик Usetech
В ходе разработки любого приложения программист сталкивается с необходимостью работать с моделями различных объектов, созданных для разных целей. И соответственно, с необходимостью конвертировать их между собой. Если ваш проект на начальном этапе развития, то можно, конечно, использовать рукописные конверторы. Но рано или поздно проект станет больше, и вы столкнётесь с необходимостью использовать уже готовое решение для конвертации моделей.
Одним из таких решений является МodelMapper. Его очень просто использовать в самом начале проекта без особых знаний. Попытаюсь разобрать основные моменты использования фреймворка.
Как начать использовать ModelMapper?
Сначала добавляем его в зависимости. Если вы используете maven, то:
implementation group: ‘org.modelmapper’, name: ‘modelmapper’, version: ‘2.3.0’
Предположим, у нас простой проект с базой данных и RestAPI и нам необходимо превратить entity в dto и обратно. На этапе прототипа проекта могут полностью совпадать, и в таком простейшем примере нам вообще не нужно ничего дополнительного писать. МodelMapper всё сделает за нас.
В примере, представленном ниже, я буду использовать аннотации Lombok, чтобы было проще =)
Наши entity:
Наша dto:
Для того, чтобы начать маппить entity в dto нам достаточно написать вот такой простой конвертор:
А для наполнения базы использовать следующие entity:
И наш контроллер с одним единственным методом:
После выполнения запроса http://localhost:8080/book мы получаем следующий ответ:
МodelMapper по названию полей сам догадывается, что на что нужно маппить. Это очень удобно, если у вас есть множество моделек, которые в целом похожи друг на друга. Весь процесс можно разбить на две части: распознание и связь полей, а также перенос значений.
Если описать работу маппера простыми словами, то: он сканирует поля в соответствии с AccessLevel, парсит их и бьёт на токены, сравнивая эти токены он пытается понять подходит ли поле для маппинга. Стратегии настраивают степень точности:
Пример настройки значений для ModelMapper:
Это далеко не все настройки МodelMapper, больше настроек можно посмотреть в классе InheritingConfiguration.
Маппинг отдельных полей
Для начинающего специалиста, показанного выше, вполне достаточно. Но для серьёзного приложения нужен больший контроль над маппингом определённых полей. Также было бы удобно маппить вложенные сущности. В этом разделе мы рассмотрим, как нам с этим поможет МodelMapper.
Давайте немного усложним наш маппинг. Предположим, что нам в нашем поле index не нужна подстрока ISBN:. Как нам изменить условия маппинга, чтобы для одного поля мы удаляли эту подстроку?
Можно использовать Converter :
В данном примере мы создали TypeMap для двух наших объектов и указали поле, для которого мы хотим использовать этот конвертер.
Теперь наш запрос возвращает следующее:
Мы научились модифицировать правила конвертации отдельных полей и целых объектов. С этим уже можно полноценно работать. Но бывают случаи, когда нам не нужно модифицировать значение, а необходимо просто связать два названных по-разному поля.
Добавим в наши объекты поля: comment в BookEntity и review в BookDto и модифицируем наш BookConverter:
И тогда запрос будет выглядеть вот так:
Теперь при маппинге отдельных полей у нас будет меньше мороки.
А что, если нам нужно маппить ещё и вложенную сущность? Для этого мы снова модифицируем BookDto и добавляем туда поле author вместо authorName. А также создаём класс AuthorDto, содержащий только поле name.
И наш BookConverter теперь будет выглядеть следующим образом:
А в ответе на запрос книг получаем:
Маппинг и наследование
Самое просто мы разобрали ранее. Теперь давайте посмотрим, как же ModelМapper работает с наследованием. Для этого мы изменим модель наших данных, добавив наследников для книг.
Теперь наша модель будет выглядеть так:
А наши начальные данные так:
И вот так мы поменяем наш конвертор:
Для того, чтобы ModelМapper понял, что AudioBookEntity и HardCoverBookEntity — это наследники BookEntity, мы должны к TypeMap вызвать include и добавить маппинги. Но, к сожалению, для внутренних маппингов нам надо будет указывать вручную маппинг всех полей, как показано в примере. Эта особенность может стать проблемой если у вас на проекте примитивные базовые классы и развитая иерархия наследования классов.
В ответе на запрос теперь мы получаем:
Заключение
ModelМapper — это удобный фреймворк, который можно использовать как на старте вашего проекта, так и на более поздних этапах. Но у него, как и у любого инструмента, есть свои слабые стороны и ограничения, о которых стоит знать и которые стоит учитывать.
Свой mapper или немного про ExpressionTrees
Сегодня мы поговорим про то, как написать свой AutoMapper. Да, мне бы очень хотелось рассказать вам об этом, но я не смогу. Дело в том, что подобные решения очень большие, имеют историю проб и ошибок, а также прошли долгий путь применения. Я лишь могу дать понимание того, как это работает, дать отправную точку для тех, кто хотел бы разобраться с самим механизмом работы «мапперов». Можно даже сказать, что мы напишем свой велосипед.
Отказ от ответственности
Я ещё раз напоминаю: мы напишем примитивный mapper. Если вам вдруг вздумается его доработать и использовать в проде — не делайте этого. Возьмите готовое решение, которое знает стек проблем этой предметной области и уже умеет их решать. Есть несколько более-менее весомых причинам писать и использовать свой вело-mapper:
Что называют словом «mapper»?
Это подсистема, которая отвечает за то, чтобы взять некий объект и преобразовать (скопировать его значения) его в другой. Типичная задача: преобразовать DTO в объект бизнес слоя. Самый примитивный mapper «бежит» по свойствам (property) источника данных и сопоставляет их со свойствами типа данных, который будет на выходе. После сопоставления происходит извлечение значений из источника и их запись в объект, который будет результатом преобразования. Где-то по пути, скорее всего, нужно будет ещё создать этот самый «результат».
Для потребителя mapper — это сервис, который предоставляет следующий интерфейс:
Подчеркиваю: это наиболее примитивный интерфейс, который, с моей точки зрения, удобен для объяснения. В реальности мы, скорее всего, будем иметь дело с более конкретным маппером (IMapper ) или с более общим фасадом (IMapper), который сам подберет конкретный mapper под заданные типы объектов входа-выхода.
Наивная реализация
Ремарка: даже наивная реализация mapper’a требует элементарных знаний в области Reflection и ExpressionTrees. Если вы ещё не прошли по ссылкам или ничего не слышали об этих технологиях — сделайте это, прочтите. Обещаю, мир уже никогда не будет прежним.
Впрочем, мы с вами пишем свой mapper. Для начала давайте получим все свойства (PropertyInfo) того типа данных, который будет на выходе (далее я буду называть его TOut). Это сделать достаточно просто: тип мы знаем, так как пишем имплементацию generic-класса, параметризированного типом TOut. Далее, используя экземпляр класса Type, мы получаем все его свойства.
При получении свойств я опускаю особенности. Например, некоторые из них могут быть без setter-функции, некоторые могут быть помечены аттрибутом как игнорируемые, некоторые могут быть со специальным доступом. Мы рассматриваем самый простой вариант.
Идём далее. Было бы неплохо уметь создавать экземпляр типа TOut, то есть того самого объекта, в который мы «мапим» входящий объект. В C# это можно сделать несколькими способами. Например, мы можем сделать так: System.Activator.CreateInstance(). Или даже просто new TOut(), но для этого вам нужно создать ограничение для TOut, чего в обобщенном интерфейсе делать не хотелось бы. Впрочем, мы с вами что-то знаем об ExpressionTrees, а значит можем сделать вот так:
Почему именно так? Потому что мы знаем, что экземпляр класса Type может дать информацию о том, какие у него есть конструкторы — это весьма удобно для случаев, когда мы решим развить свой mapper настолько, что будем передавать в конструктор какие-либо данные. Также, мы ещё немного узнали про ExpressionTrees, а именно — они позволяют налету создать и скомпилировать код, который потом можно будет многократно использовать. В данном случае это функция, которая на самом деле выглядит как () => new TOut().
Теперь нужно написать основной метод mapper’a, который будет копировать значения. Мы пойдем по самому простому пути: идём по свойствам объекта, который пришёл к нам на вход, и ищем среди свойств исходящего объекта свойство с таким же названием. Если нашли — копируем, если нет — идём дальше.
Таким образом у нас полностью сформировался класс BasicMapper. С его тестами можно ознакомиться вот тут. Обратите внимание, что источником может быть как объект какого-то конкретного типа, так и анонимный объект.
Производительность и boxing
Reflection отличная, но медленная штука. Более того, её частое использование увеличивает memory traffic, а значит нагружает GC, а значит ещё больше замедляет работу приложения. Например, только что мы использовали методы PropertyInfo.SetValue и PropertyInfo.GetValue. Метод GetValue возвращает object, в которой завернуто (boxing) некое значение. Это значит, что мы получили аллокацию на пустом месте.
Mapper’ы обычно находятся там, где нужно превратить один объект в другой… Нет, не один, а множество объектов. Например, когда мы забираем что-то из базы данных. В этом месте хотелось бы видеть нормальную производительность и не терять память на элементарной операции.
Компилируемый mapper
На самом деле, всё относительно просто: мы уже делали new с помощью Expression.New(ConstructorInfo). Наверное вы заметили, что статический метод New называется точно так же, как и оператор. Дело в том, что почти у всего синтаксиса C# есть отражение в виде статических методов класса Expression. Если чего-то нет, то это значит, что вы ищите т.н. «синтаксический сахар».
Вот несколько операций, которые мы будем использовать в нашем mapper’e:
К сожалению, код получается не очень компактный, поэтому предлагаю сразу взглянуть на имплементацию CompiledMapper. Я вынес сюда лишь узловые моменты.
Для начала мы создаем объектное представление параметра нашей функции. Так как она принимает на вход object, то и параметром будет объект типа object.
Далее мы создаем две переменные и список Expression, в который будем последовательно складывать выражения присваивания. Порядок важен, ведь именно так команды будут выполнены, когда мы вызовем скомпилированный метод. Например, мы не можем присвоить значение переменной, которая ещё не объявлена.
Далее мы точно также, как и в случае с наивной имплементацией, идём по списку свойств типов и пытаемся их сопоставить по имени. Однако, вместо того, чтобы немедленно присваивать значения — мы создаем выражения извлечения значений и присваивания значений для каждого сопоставленного свойства.
Важный момент: после того, как мы создали все операции присваивания нам нужно вернуть результат из функции. Для этого последним выражением в списке должно быть Expression, содержащее экземпляр класса, который мы создали. Я оставил комментарий рядом с этой строчкой. Почему поведение, соответствующее ключевому слову return в ExpressionTree выглядит именно так? Боюсь, что это отдельная тема. Сейчас я предлагаю это просто запомнить.
Ну и в самом конце мы должны скомпилировать все выражения, которые мы построили. Что нам тут интересно? Переменная body содержит «тело» функции. У «обычных функций» ведь есть тело, верно? Ну, которое мы заключаем в фигурные скобки. Так вот, Expression.Block — это именно оно. Так как фигурные скобки — это ещё и область видимости, то мы должны передать туда переменные, которые там будут использоваться — в нашем случае sourceInstance и outInstance.
Почему не будет boxing? Потому что скомпилированный ExpressionTree это настоящий IL и для runtime он выглядит также (почти), как и ваш код. Почему «скомпилированный mapper» работает быстрее? Снова: потому что это просто обычный IL. Кстати, скорость мы можем легко подтвердить с помощью библиотеки BenchmarkDotNet, а сам бенчмарк можно посмотреть тут.
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| AutoMapper | 1,291.6 us | 3.3173 us | 3.1030 us | 1.00 | 312.5 KB |
| Velo_BasicMapper | 11,987.0 us | 33.8389 us | 28.2570 us | 9.28 | 3437.5 KB |
| Velo_CompiledMapper | 341.3 us | 2.8230 us | 2.6407 us | 0.26 | 312.5 KB |
В колонке Ratio «скомпилированный mapper» (CompiledMapper) показал очень неплохой результат, даже по сравнению с AutoMapper (он baseline, т.е. 1). Впрочем, давайте не будем радоваться: AutoMapper обладает значительно большими возможностями по сравнению с нашим велосипедом. Этой табличкой я лишь хотел показать, что ExpressionTrees значительно быстрее, чем «подход классического Reflection».
Резюме
А что mapper? Mapper — отличный пример, на котором всему этому можно научиться.
Введение в Example Mapping
Прежде чем взяться за работу над user story, очень важно определить для себя критерии приемки. Это можно сделать, когда вы детализируете бэклог или планируете ближайший спринт. Некоторые команды для этого проводят специальные встречи, которые называются 3 Амиго (подробнее о них в прошлой статье), митинги, kick-off по спецификации или встречи-исследования.
Как не назови, большинству команд это дается с трудом. Главная сложность в том, что такие встречи неструктурированы, а их результат непонятен. Они отнимают много времени и попросту скучные. В итоге, сессии становятся нерегулярными или от них совсем отказываются.
Но есть простой способ сделать такие встречи короткими и очень продуктивными. И называется этот способ Example Mapping или составление карт тест-кейсов.
Как это работает
Конкретные тест-кейсы (примеры) — это отличный способ исследовать предметную область. Они могут стать хорошей основой для ваших приемочных тестов. Когда мы обсуждаем тест-кейсы, всплывают и другие аспекты, которые тоже нужно проговаривать.
Для начала на желтом стикере нужно записать саму историю и поместить в верхнюю часть доски. Далее, на синих стикерах указываем каждый из критериев приемки или правил, которые у нас выработались ранее. Синие карточки размещаем под желтой.
Каждое правило обычно можно проиллюстрировать несколькими тест-кейсами. Для каждого тест-кейса свой зеленый стикер, который помещается под соответствующее правило.
Пока составляем карту и обсуждаем кейсы, могут возникнуть вопросы, на которые никто из присутствующих не может ответить. Их фиксируем на красных стикерах и продолжаем обсуждение.
Встреча продолжается до тех пор, пока все не убедятся в том, что история полностью понятна, или кончится выделенное на неё время.
Мгновенная обратная связь
В процессе такого разговора легко и быстро строится визуальное представление о текущем понимании истории.
Думать за ограниченное время
Группа из нескольких амиго должна составлять понятную историю приличного размера примерно за 25 минут.
Если у вас не получается уложиться в выделенное время, то возможно несколько вариантов:
Matt Wynne из Cucumber предлагает участникам встречи через 25 минут проголосовать, готова ли история для передачи в разработку. Даже если некоторые вопросы остались нерешенными, команда может принять решение, что неопределенности не так много, и ее можно дорабатывать по ходу дела.
Выгода
Example mapping помогает сменить масштаб и сосредоточиться на мельчайших фрагментах поведения истории. Составляя карту, можно выделить правила, найти суть желаемого поведения, а остальное отложить на потом. Из-за такой тщательности исследования example mapping действует как фильтр, не давая большим жирным историям попасть в спринт, чтобы потом преподнести неприятные сюрпризы в последнюю минуту. К тому же этот подход экономит время и помогает поддерживать вовлеченность в процесс заинтересованных людей.
Некоторым кажется, что 3 амиго должны написать приемочные тесты во время этой встречи (например сценарии для Cucumber). В принципе, в некоторых случаях это может иметь смысл, но чаще такой подход будет только отвлекать от истинной цели разговора.
Понятно, откуда берется такое мнение: очевидная цель состоит в том, чтобы взять user story, у которой уже есть некоторые заранее определенные критерии приемки, и найти тест-кейсы, которые можно превратить в приемочные тесты.
Настоящая же цель в том, чтобы достичь общего понимания того, что нужно для создания истории. Можно быстро двигаться к это цели и без высоких технологий.
Упрощайте запись
Поэтому вместо того, чтобы использовать формальные сценарии Gherkin, просто попробуйте быстро собрать список тест-кейсов, используя соглашение об именовании.
Например:
Если результат («тогда») неясен, пример не получится, зато получится вопрос.
Известные неизвестные
Если разговор начинает идти по кругу, значит информации недостаточно. Возможно, на встрече нет нужного человека, или нужно провести какое-то исследование или воспользоваться Spike.
Вместо того, чтобы выслушивать мнение каждого участника о том, каким с их точки зрения должен быть результат, просто запишите вопрос на красной карточке и двигайтесь дальше. Так неизвестное превратится в известное неизвестное. Это большой прогресс.
По опыту даже этот аспект составления карт примеров может превратить встречи 3-х Амиго из скучных в быстрые и продуктивные.
Кто должен участвовать?
Минимум — это ваши 3 амиго: разработчик, тестировщик и владелец продукта (бизнес-аналитик). Это всего лишь минимум. Кроме этого, можно пригласить кого-то из эксплуатации, специалиста по UX или еще кого угодно, кто имеет отношение к обсуждаемой истории. Любой, кто может помочь найти новые вопросы или превратить вопросы в ответы во время беседы, будет полезен.
Пока вы осваиваете эту технику, удобно найти кого-то на роль фасилитатора. Его формальная задача будет в том, чтобы все сказанное сразу же записывалось на стикеры. Тест-кейсы и вопросы быстро обсуждаются во время сессии, и требуется дисциплина, чтобы своевременно записывать их на стикерах, чтобы видеть, о чем идет речь.
Итак, когда писать на Gherkin?
Не поймите неправильно, в использовании Gherkin есть огромная ценность, особенно в первые дни проекта, когда вы вырабатываете общий язык. Жизненно важно, чтобы сценарии были выражены так, чтобы все в команде верили им.
Но для описания тест-кейсов таким образом требуется иной способ мышления. Нужно не просто решить, какие кейсы попадают в рассматриваемую область, и установить для них общие правила.
Для команды, которая работает с достаточно зрелым доменным языком, владельцу продукта выгоднее тратить свое время и энергию на составление карт, а задачу написания Gherkin оставить двум другим амиго. После того, как они разработают спецификацию Gherkin, владелец продукта сможет дать фидбек. Задавая себе вопрос: «Я бы так написал?» можно проверить, насколько эффективным было составление карты с точки зрения передачи знаний о продукте своим амиго.
Как часто это делать?
На практике рекомендуется встречаться через день. Просто выберите одну user story и уделите ей 25 минут внимания, а затем возвращайтесь к работе. Если попытаетесь сделать больше, только зря потратите энергию.
Но у меня распределенная команда!
Для этого уже придумали решения, например, списки стикеров в Google keep или онлайн-доски с цветными стикерами. Можно использовать mind-map. Главное, чтобы вам было просто и быстро работать, чтобы вы могли сосредоточиться на разговоре.
Несколько заключительных советов
Важно четко различать правила и тест-кейсы, прежде чем использовать example mapping. Для этого есть забавное упражнение.
Помните, конечная цель такой встречи — обнаружить то, что вы еще не знаете. Глупых вопросов не бывает, все они помогают по-настоящему исследовать проблему.
Работая по этой методике вы обнаружите, что правила создают естественные линии развития вашей истории. Постарайтесь спокойно относится к вопросам, разделяйте их и откладывайте в сторону. Тогда сможете сосредоточиться на решении основной проблемы. Усложнить и довести до идеала можно и позже.
О практике 3 Амиго для проработки требований и построении карт тест-кейсов мы будем говорить на конференции QualityConf. Кроме того, в списке принятых докладов есть и другие крайне интересные практические подходы для создания качественного IT-продукта. Конференция QualityConf впервые пройдет в рамках фестиваля РИТ++ 27 и 28 мая, успевайте присоединиться.


