При разработке проекта мы повсеместно пользуемся различными библиотеками (например, библиотека для работы с базой данных), пишем собственные компоненты: выделяем сервисы (например, логгер) и выносим различные действия в отдельные функции (например, регистрация пользователя или публикация комментария).
Использование паттерна Service Handle в Haskell: руководство
Меня зовут Олег Ромашин, я занимаюсь разработкой на языке Haskell. Когда 1.5 года назад я начинал своё первое приложение на нём, у меня был скудный опыт написания учебных проектов на других языках. В итоге я делал как мог: проект получился негибким — приходилось переделывать весь код под небольшие изменения, было непонятно, как правильно выделить компоненты, к проекту нельзя было написать автоматизированные юнит-тесты, и я тратил много времени на то, что просто смотрел на дерево проекта и пытался придумать, как мне добиться хоть какой-то эстетики. В этой статье я постараюсь доступным языком рассказать про паттерн Service/Handle и построение архитектуры тем, кто за неимением опыта столкнулся с такими же проблемами.
Статья написана для тех, кто только начинает погружаться в разработку. Некоторые сокращения кода не сделаны намеренно, чтобы облегчить понимание тем, у кого ещё не натренирован глаз. Кроме этого, в статье не упомянуты некоторые приёмы работы с паттерном, сначала рекомендуется разобраться с причинами использования паттерна и механизмом. В конце статьи предложены материалы для дальнейшего изучения.
Небольшая ремарка: сложилось так, что в сообществе Haskell у паттерна, о котором мы будем говорить, есть несколько названий: The Service Pattern, The Handle Pattern, The Service Handle pattern, The Service/Handle pattern, но обычно все имеют в виду одно и то же. В статье будем пользоваться последним вариантом.
Как Service/Handle помогает в разработке
У таких частей проекта есть интерфейс, через который мы к ним обращаемся: типы, классы, функции. Обычно через имена функций (типы и классы нас сейчас не интересуют) мы напрямую обращаемся к реализации — к тому, что по факту делает функция. Например, пакет http-conduit в интерфейсе экспортирует функцию httpBS, которая выполняет HTTP-запрос и возвращает ответ в виде строки байтов. В таком виде интерфейс и реализация тесно связаны: вызываешь функцию — используешь реализацию. В некотором роде это усложняет написание и поддерживаемость проекта и сковывает движения.
Во-первых, если появится необходимость заменить библиотеку по каким-либо причинам, то придётся подстраивать под это изменение весь проект, поскольку проект завязан на интерфейсе этой библиотеки.
Во-вторых, когда проект большой, бывает необходимо использовать разные реализации одного сервиса, например, логгера, в разных частях проекта. В одном месте может требоваться запись в файл, а в другом — печать в терминал. В идеале мы бы хотели иметь возможность переключаться между реализациями, но на это опять же придётся потратить усилия, потому что конкретная часть проекта сильно связана с конкретной реализацией.
В-третьих, усложняется написание тестов логики. Чтобы протестировать, что, например, пользователю будет отправлено SMS-сообщение при определённых условиях, придётся фактически его отправить, так как функция напрямую использует все зависимости.
Паттерн Service/Handle позволяет создать дополнительный промежуточный интерфейс для отдельных частей проекта.
Приложение разрабатывается с использованием интерфейса вместо прямого вызова функций внутри библиотек. Связь с библиотеками указывается один раз при заполнении нового интерфейса Service/Handle, а дальше этот интерфейс передаётся в другие функции. Таким образом мы легко и обособленно можем вносить любые изменения, связанные с зависимостями, меняя код только в одном месте. Из схемы выше видно, что интерфейс можно добавить к каждой части проекта, причём у каждой такой части он будет свой.
Имея выделенный промежуточный интерфейс, мы получаем возможность подменять реализацию.
Как Service/Handle позволяет легко менять реализацию и разрабатывать проект независимо от неё
Рассмотрим на примере логгера. В случае с данным паттерном интерфейс — это рекорды конструктора данных.
Чтобы связать этот интерфейс с реализацией, необходимо заполнить поле конструктора LogHandle. Чтобы пользоваться этим интерфейсом, его надо передавать в каждую функцию, где необходимо логирование.
Теперь, если потребуется поменять реализацию логирования, необходимо изменить всего лишь один участок кода, в котором определена реализация через putStrLn. Кроме этого, если потребуется использовать другую реализацию в одной из частей проекта, туда можно передать тот же интерфейс, но заполнить его другой реализацией.
Теперь давайте посмотрим, как можно абстрагироваться от IO, чтобы писать чистые тесты для функций с логированием:
Теперь из тестов можно запускать функцию doSomeWork, подставив реализацию, которая не будет обращаться в IO:
Ранее мы говорили, что интерфейс можно добавить как к библиотеке/сервису, так и к простым функциям, однако к функции doSomeWork интерфейс добавлять нет необходимости, поскольку она уже не имеет прямых зависимостей. Добавлять интерфейс к отдельным функциям имеет смысл, когда не для всех зависимостей определён промежуточный интерфейс. Например, когда для логирования выделен Service/Handle, а для обращения к базе данных нет.
Избавляемся от зависимостей в логике регистрации пользователя
Разберёмся с примером посложнее: представим, что у нас есть свой сервис, в котором нужна регистрация. Вот так в упрощённой форме мог бы выглядеть обработчик этой функции без использования паттерна (предположим, что функции для работы с базой данных и типы уже есть):
Давайте перепишем эту функцию с использованием паттерна Service/Handle. Наша цель — вынести все IO функции в отдельный интерфейс, чтобы логику можно было тестировать локально. Необходимо протестировать, что пользователь будет зарегистрирован только в случае, если логин и email не заняты. Интерфейс с зависимостями в нашем случае — это логирование, функция записи пользователя в базу данных и функции поиска пользователя в базе данных. Кроме этого, используем логгер, который мы переписали с использованием паттерна Service/Handle.
Здесь у нас была необходимость создать дополнительный интерфейс для функции регистрации, так как у функции была прямая зависимость от базы данных. Можно было бы создать интерфейс для библиотеки через которую происходит работа с базой данных, но такое изменение не всегда легко сделать, особенно когда проект успел разрастись.
Также изменилось дерево проекта: раньше был один модуль App.Registration, в котором была функция регистрации. Теперь в этом модуле мы заполняем Handle, а логика находится в новой папке Handlers. Там создаётся модуль с идентичным именем App.Handlers.Registration, который вызывается после заполнения интерфейса.
Кстати, паттерн очень удобно использовать с расширением RecordWildCards. Оно позволяет не передавать handle явно как аргумент в каждую функцию интерфейса. С ним функция registerUser будет выглядеть так:
Каким проектам подходит Haskell?
Рассказываем, как разрабатываем проекты на языке, в который влюблены с 2018 года.
Тестирование функций, написанных в стиле паттерна Service/Handle
Теперь в функцию registerUser можно подставлять другую реализацию, а это значит, её можно локально тестировать. Воспользуемся пакетом hspec. Как вы помните, у типов хэндлов есть параметр, который указывает, в какой монаде мы работаем. Раньше мы заполняли поля хэндла IO-функциями, поэтому при передаче заполненного хэндла в логику функция registerUser параметризировалась монадой IO. Теперь, чтобы написать чистые тесты, необходимо избавиться от IO, но все ещë нужна монада: отличное применение Identity. Раньше мы заполняли поля хэндла функциями, которые обращаются в IO: если функция делала запись в базу данных, то результатом было IO (), если функция доставала значение, то результатом было IO Value. Вместо IO будет Identity. Кроме этого, если базы данных нет, то и записывать нечего, поэтому функции записи данных будут игнорировать свой аргумент и возвращать Identity (). Функции, которые возвращают значения из базы данных, будут редактироваться под каждый тест. Если мы проверяем поведение, когда пользователь с указанным логином уже есть, то функция findUserByLogin будет возвращать Just User, когда логин свободен, функция будет возвращать Nothing. Такие данные, которые создаются специально для тестов, называют стабами (stub).
После запуска команды stack test тесты проверяют, что всё пройдёт успешно при правильных входных данных, и что функция вернёт ошибку, если занят логин или e-mail. Такие тесты называются тестами чёрного ящика: они проверяют, что при определённых входных данных на выходе мы получаем то, что ожидалось. Мы также можем написать тесты белого ящика, когда можно проверить, что внутри функции всё работает в правильном порядке и параметры корректно используются, но это стоит отдельной статьи.
Резюмируя вышесказанное
Паттерн Service/Handle позволяет строить логику из интерфейса, абстрагируясь от реализации. Это даёт возможность различным частям нашего проекта не беспокоиться о конкретных механизмах реализации. Благодаря этому можно с гораздо меньшими усилиями переходить на другие библиотеки или использовать разные реализации в разных частях проекта. Кроме этого, появляется возможность писать чистые тесты логики.
Более наглядно код из статьи и тесты можно глянуть в репозитории. Кроме этого, там можно найти упражнение, чтобы попрактиковаться с заменой реализации:
https://github.com/olegromashin/service-handle-article
Материалы для дальнейшего изучения:
jaspervdj - Haskell Design Patterns: The Handle Pattern
The Service Pattern - School of Haskell
Exceptions Best Practices in Haskell
Другие наши статьи
5 правил от фаундера: как Edtech проекту привлечь инвестиции. Кейс онлайн-школы «Логопотам»
Алексей Литвинов
CEO, founder онлайн-академии «Логопотам»
3 причины выбрать коробочное решение для мини-приложений в Телеграме, а не разработку с нуля
Дмитрий Щипачев
CEO в Finch
Тапалки — всё. Какие мини-аппы в Telegram станут популярны совсем скоро
Филипп Листратов
CPO в MetaLamp и СЕО Cipher Consult
UMA протокол: как работает популярный оптимистичный оракул в блокчейне?
Павел Найданов
Solidity разработчик
Почему для токенизации премиального алкоголя используется блокчейн
Елизавета Черная
Редактор Бренд-медиа
Тренды блокчейна и криптовалюты на 2024 год: исследование Telegram Mini Apps
Елизавета Черная
Редактор Бренд-медиа
Как Zero-Knowledge Proofs и ZKSync улучшают масштабируемость блокчейна
Роман Ярлыков
Solidity разработчик
Как привлечь инвестиции для своего проекта: опыт успешных раундов 2023 года
Микола Прындюк
Social Media Specialist
Когда и как найти технического директора для вашего стартапа
Редакция MetaLamp
Как абстракция аккаунтов позволяет проводить безгазовые криптотранзакции
Николай Бордуненко
Бизнес-аналитик MetaLamp
Способы ускорить разработку: преимущества и недостатки аутстаффинга
Редакция MetaLamp
Распространённые мифы о разработке блокчейн-продуктов: объяснение
Николай Бордуненко
Бизнес-аналитик MetaLamp
Кого выбрать для разработки MVP стартапа: фрилансеров, агентство или сотрудников
Яна Гейдрович
Partnership manager at MetaLamp
Статьи
От корпоративного блога к бренд-медиа: запуск Metalamp Magazine
Микола Прындюк
Social Media Specialist
Как мы нанимаем инженеров Plutus через собственную программу обучения
Светлана Дульцева
Супервизор программы обучения