Услуги
Экспертиза
Кейсы
Работы
Команда
Обучение
Связаться с нами
Перейти ко всем статьям

Higher-Kinded Data, или ещё один способ работать с сущностями базы данных (и не только)

Article preview

Важный дисклеймер

Перед началом хочу позволить себе небольшой, но важный дисклеймер.

Я не стараюсь вам продать этот cпособ как панацею.
Я лишь хочу рассказать вам ещё один способ представлять данные и показать, как его можно использовать, на конкретном примере.
Как и все остальные подходы, этот имеет свои недостатки. И кое-где придётся приседать. С этими приседаниями мы встретимся довольно скоро.

«Не думайте, что я сейчас буду развивать эту концепцию, а затем разочаруюсь в ней. Такой драматургии не будет. Я изначально уже в ней разочарован». © Роман Михайлов

Ещё хочется заметить, что далее все примеры кода будут приводиться на Haskell. Но в конце я покажу, как можно некоторые из них повторить на Scala.

Что такое HKD

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

Простейшие HKD

Обычно данные в Haskell определяются так:

Но что, если мы хотим добавить какой-то эффект нашим полям? Тогда можно попробовать обернуть поля в конструктор типа. Мы можем параметризовать User конструктором типа Maybe. Представить, для чего бы это могло быть полезно, несложно. Например, структура-патч. Мы можем воспользоваться ей, чтобы обновить только некоторые поля структуры.

И вот мы получили полноценный HKD. Но мы можем начать его улучшать! Например, если понадобится избавиться от каких-либо эффектов, мы вынуждены будем использовать Identity, который не является прозрачным, что заставляет нас распаковывать и запаковывать значения. Естественно, во время исполнения этой обёртки не будет, но это делает код более многословным.

Кстати, тот знак собачки/улитки в листинге — специальный синтаксис для Type Applications.

Ускоренный курс по TypeApplications

Усложняем: TypeFamily. Прячем Identity

Избавиться от недоразумения с Identity нам помогут Type Families.

Теперь перегрузка User стала для нас бесплатной.

Усложняем: кастомные эффекты. Конкретизируем смысл

Maybe, Either, [] — это всё очень хорошо. Но нужна более конкретная, привязанная к доменной области, семантика. Давайте создадим свои эффекты.

Мы дали нашим эффектам конкретные имена, и, кажется, сделали User чем-то более содержательным, чем просто набор полей. Научили его подстраиваться под наши нужды. А ещё мы можем наделить его характером.

Усложняем: больше TypeFamilies. Список модификаторов

С помощью следующей итерации мы сможем добавлять конкретные свойства конкретным рекордам. Вот так:

Здесь сказано, что поле login нельзя изменять, а по полю about запрещено искать. Я так хочу, такая у меня бизнес-логика. Можно сказать иначе, более конкретно: это означает, что User @Update не ждёт ничего содержательного в поле login (только ()), так же ведёт себя и поле about в User @Filter (до сих пор мы не вводили действия Filter, но он появится уже в следующем сниппете). Это поможет нам не совершить ошибку в процессе написания кода, ведь не получится запихнуть () на место строки. Давайте посмотрим, как этого добиться.

Вот, что я подразумевал, говоря о характере:
До внесённых выше изменений все наши сущности (пусть, например, Comment или Article) вели бы себя одинаково:

Create? — Потребовать все поля!
Update? — Потребовать хоть что-нибудь :(
Filter? — Потребовать набор значений для поиска.

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

Что ещё можно изобразить, двигаясь в эту сторону?

Усложняем: DataKinds

Для того, чтобы сохранить данные, вероятно нам понадобятся имена их полей. А чего точно не хочется делать, так это использовать голые строки. Давайте зашьём имена там же, в описании модели.

Для того чтобы достать название поля, просто передадим в функцию nameOf рекорд структуры. Таким образом:

  1. Невозможно ошибиться в названии, очередной раз вбивая голый литерал
  2. Невозможно обратиться к полю, которого нет

Вероятно возможно даже как-то протащить тип самого поля в тип Named, чтобы случайно не перепутать рекорды. Но я не нашёл удобного способа это сделать и использовать. Для любителей поковырять Haskell это может стать интересной задачкой.

Усложняем: ещё больше TypeFamilies. Опциональные поля

В формате описания данных уже довольно много всего, но их всё ещё недостаточно. Может быть у кого-то из вас даже возникли вопросы к способу фильтрации. Неужели списка может хватить для полноценной фильтрации? Или что делать с опциональными полями? Просто оборачивать в Maybe? Может быть. Но мы сделаем иначе.

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

Давайте по порядку.

Опциональность.

Фильтрация:

Теперь должна быть ясна мотивация перегрузки эффекта в ApplyRequired, а так же почему я не стал использовать Maybe на самом типе поля:
Если поле является опциональным, то мы должны уметь фильтровать не только по набору значений как таковых, но и на факт отсутвия/существования их вообще. Однако если поле помечено как NotFiltered, но является Optional, мы всё ещё способны его фильтровать, но только на наличие его в записи. Можно ли это изменить? — Да. Но я не считаю, что это проблема. Оставляю решение о том, является ли это в действительности приседанием, на вас.

А в реальности?

Теперь давайте соберём в кучу все наши наработки и спроектируем нашу доменку. Так как теперь кода становится реально много, я буду давать ссылки на конкретные файлы в репозитории.

Первое, что нужно сделать, это разнести отдельные типы по файлам, так будет удобнее. Результат можно посмотреть здесь.

А теперь объявим сущности нашей доменной области. В качестве примера и доказательства, что это действительно можно использовать, я реализовал простейший CRUD с тремя сущностями:
User:

Здесь появляются несколько новых штук: Range и NotAllowedFromFront.
Новые фильтры определены здесь и в них нет ничего особенного. Но они хорошо работают с тем, что описано в базовом модуле.

А ещё я добавил новый action, который называется Front. Он определён здесь и нужен для того, чтобы задать некоторым полям особенное поведение для работы с фронтом. Совершенно естестественно, что мы не должны позволять обновлять системные поля снаружи. Поэтому просто запретим это делать.

Maybe здесь нужен исключительно для того, чтобы генеренные кодеки для JSON не падали, не найдя этого поля в документе. Если бы я, например, был готов писать эти кодеки руками, но этого можно было бы избежать (у вас ещё не сбился счётчик приседаний? :) ).

Далее: Article:

Здесь нет ничего нового, помимо ID, в котором нет ничего хитрого (относительно того, что есть уже). Вы можете в этом убедиться.

Тип Comment тоже довольно скучный, но предоставлен здесь для полноты:

В том случае, если читатель действительно заглянул в файлы, он мог заметить, что для каждой сущности определён набор инстансов. Давайте обговорим каждый из них.

  • EmptyData для Update и Filter.

Этот класс определён здесь и реализован по умолчанию для всех типов определённой формы с помощью Genericов.

Имея этот класс мы можем определить функции с забавной сигнатурой:

В том случае, если вам неясно их назначение, я думаю, что следующие примеры смогут внести ясность.

Просто приятный DSL для написания фильтров и патчей с использованием generic-lens (хоть в силу простоты нашего CRUD они не будут использованы, я посчитал нужным рассказать об этих функциях).

  • Кстати о generic-lens. Так как HKD имеют слишком хитрую форму, нельзя просто взять и обновить его поле generic-lens'ой. Это известная проблема, и решение для обхода для этой неприятности уже существует. Можете почитать об этом в этом issue. Именно в связи с этим для каждой сущности объявлен странный инстанс класса HasField.
  • From/ToJSON. Просто генерация кодеков для энкодинга и декодинга JSON. Ничего интересного.
  • ToDBFilter, ToDBFormat, FromDBFormat, DBEntity. Специфичные для конкретной базы данных инстансы, которые нужны для перегонки Filter, Create, Update сущностей в документы MongoDB и, наоборот, парсинга Create-сущности (сущности с наиболее полным набором полей) из документов MongoDB. И ещё один интанс, чтобы привязать к каждому типу сущностей название коллекции в MongoDB. Тут, как раз нам пригодилась Schema. Возьмём для примера реализацию ToDBFormat для (Article Create):

А теперь самое главное: сам CRUD :). Он полностью реализован здесь: Давайте посмотрим в деталях.

Входная точка, ничего особенного, но для полноты я покажу это здесь:

Далее. 3 набора хэндлеров для каждой сущности:

Я хочу обратить ваше внимание, что функция getByID полиморфна и в качестве главного аргумента принимает тип сущности, которая нам нужна.

Вот её определение.

Функция loadByFilter определена похожим образом, можете посмотреть сами.

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

На всякий случай, я поясню, что здесь происходит.

  • Пытаемся распарсить из JSON-структуры, формы Front Create. Это значит, что некоторые поля мы запрещаем определять снаружи.
  • Если всё прошло удачно, то пытаемся найти пользователя с ником, который имеет новый пользователь. Если такой пользователь уже есть. сообщаем об этом.
  • Если пользователя с таким ником ещё не существует. регистрируем его. Тут используется расширение RecordWildCards. {..} При матчинге вводит все рекорды структуры в скоуп, как значения. А при создании структуры {..} выискивает все имена с названиями рекордов и вставялет их в себя (подробнее о RecordWildCards). Однако компилятор нам явно скажет, что заполнить таким способом поля registered и modified явно не выйдет. Поэтому их придётся указать руками (что довольно естественно в данном случае).

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

Что в итоге?

Внимательные хаскелисты узнают в этом подходе подход Beam к работе со структурами. И, хоть и пример, который использует данная заметка имеет явный уклон в написание CRUDов, я хочу обратить внимание на отличие, о котором я уже упоминал вскользь: Beam (хотя и делает много других интересных штук, таких как DSL для SQL запросов) лишь предоставляет HKD для работы с БД. В нашем же случае структуры получили характер. Они получились связанными с доменной областью. Некоторые поля нельзя изменять, некоторые — нельзя задавать с фронта, потому что они считаются самой системой. Beam же не наделяет ваши поля особыми смыслами, он лишь делает из данных, которые вы создаёте, таблички в БД.
И CRUDы — это, конечно, не единственное, на что способны HKD. В интернете я нашёл ещё несколько примеров использования HKD:

  • Валидация (перевод на habr, оригинал)):
    С помощью Generic и HKD (роль Generic в этом случае ни чуть не меньше, чем HKD) можно из невалидированных данных получить валидированные. Я приведу небольшой пример того, как это работает снаружи, а для выяснения деталей реализации прошу обратиться к источнику.

TL;DR: Структура перегоняется в универсальное Generic-представление, "траверсится" по Maybe и собирается обратно.

  • Простой пример с данными о погоде. Аггрегация:
    В оригинале примеры приведены на Scala. Ниже — эквивалент на Haskell.
  • HKD также используются в довольно обыденных для функциональных программистов вещах.
    Можно вспомнить, что эмуляция type classов в Scala это тоже пример HKD. Вот пример из библиотеки cats:

На Haskell это можно изобразить так:

Не отходя от кассы, можно заметить, что по тому же принципу работает Service/Handle Pattern. Если вы с ним ещё не знакомы, ознакомиться можете здесь.

А что на Scala?

Некоторые вещи, которые я показал выше, без труда реализуются на Scala 3. Давайте разберём небольшой кусочек (этот пример вы можете вставить в Scastie и поиграться).

Стоит заметить, что в Scala нет открытых match type. Поэтому в таком виде добавлять "пользовательские" эффекты не получится. Выход, конечно есть, но вы потеряете часть "красоты". Можно избавиться от Field и дать всем эффектам принимать все 3 аргумента: опциональность, название, тип поля (да, список модификаторов в сниппете со Scala не предоставлен, я плохо знаю Scala, поэтому мучать себя и компилятор дальнейшими экспериментами не стал). Тогда можно писать сколько угодно эффектов, пусть и ценой лаконичности.

Ну и в конце ещё раз: ссылка на репозиторий.

Вы можете не заполнять это поле, и мы свяжемся с вами по почте

Спасибо, что написали нам!

Как только мы всё прочтём, свяжемся с вами тем способом, который вы указали в форме.
А пока что просто желаем вам хорошего настроения :D

Ваш браузер устарел :(

Вы открыли наш сайт в браузере IE, из-за этого некоторые элементы сайта могут работать некорректно. Чтобы вам было приятно пользоваться нашим сайтом, рекомендуем открыть его в другом браузере: