При разработке проекта мы повсеместно пользуемся различными библиотеками (например, библиотека для работы с базой данных), пишем собственные компоненты: выделяем сервисы (например, логгер) и выносим различные действия в отдельные функции (например, регистрация пользователя или публикация комментария).
Пишем и тестируем на Haskell с использованием паттерна Service/Handle
![keyboard](/images/keyboard.png)
Меня зовут Олег Ромашин, я занимаюсь разработкой на языке Haskell. Когда 1.5 года назад я начинал своё первое приложение на нём, у меня был скудный опыт написания учебных проектов на других языках. В итоге я делал как мог: проект получился негибким — приходилось переделывать весь код под небольшие изменения, было непонятно, как правильно выделить компоненты, к проекту нельзя было написать автоматизированные юнит-тесты, и я тратил много времени на то, что просто смотрел на дерево проекта и пытался придумать, как мне добиться хоть какой-то эстетики. В этой статье я постараюсь доступным языком рассказать про паттерн Service/Handle и построение архитектуры тем, кто за неимением опыта столкнулся с такими же проблемами.
Статья написана для тех, кто только начинает погружаться в разработку. Некоторые сокращения кода не сделаны намеренно, чтобы облегчить понимание тем, у кого ещё не натренирован глаз. Кроме этого, в статье не упомянуты некоторые приёмы работы с паттерном, сначала рекомендуется разобраться с причинами использования паттерна и механизмом. В конце статьи предложены материалы для дальнейшего изучения.
Небольшая ремарка: сложилось так, что в сообществе Haskell у паттерна, о котором мы будем говорить, есть несколько названий: The Service Pattern, The Handle Pattern, The Service Handle pattern, The Service/Handle pattern, но обычно все имеют в виду одно и то же. В статье будем пользоваться последним вариантом.
Как Service/Handle помогает в разработке
![](/templates/yootheme/cache/05/6125ee3f6b22949dcc1907de_Screenshot%202021-08-25%20at%2013.33-05371d76.png)
У таких частей проекта есть интерфейс, через который мы к ним обращаемся: типы, классы, функции. Обычно через имена функций (типы и классы нас сейчас не интересуют) мы напрямую обращаемся к реализации — к тому, что по факту делает функция. Например, пакет http-conduit в интерфейсе экспортирует функцию httpBS, которая выполняет HTTP-запрос и возвращает ответ в виде строки байтов. В таком виде интерфейс и реализация тесно связаны: вызываешь функцию — используешь реализацию. В некотором роде это усложняет написание и поддерживаемость проекта и сковывает движения.
Во-первых, если появится необходимость заменить библиотеку по каким-либо причинам, то придётся подстраивать под это изменение весь проект, поскольку проект завязан на интерфейсе этой библиотеки.
Во-вторых, когда проект большой, бывает необходимо использовать разные реализации одного сервиса, например, логгера, в разных частях проекта. В одном месте может требоваться запись в файл, а в другом — печать в терминал. В идеале мы бы хотели иметь возможность переключаться между реализациями, но на это опять же придётся потратить усилия, потому что конкретная часть проекта сильно связана с конкретной реализацией.
В-третьих, усложняется написание тестов логики. Чтобы протестировать, что, например, пользователю будет отправлено SMS-сообщение при определённых условиях, придётся фактически его отправить, так как функция напрямую использует все зависимости.
Паттерн Service/Handle позволяет создать дополнительный промежуточный интерфейс для отдельных частей проекта.
![](/templates/yootheme/cache/bd/6125f502d7c6258381d41758_2-bd7a361e.png)
Приложение разрабатывается с использованием интерфейса вместо прямого вызова функций внутри библиотек. Связь с библиотеками указывается один раз при заполнении нового интерфейса Service/Handle, а дальше этот интерфейс передаётся в другие функции. Таким образом мы легко и обособленно можем вносить любые изменения, связанные с зависимостями, меняя код только в одном месте. Из схемы выше видно, что интерфейс можно добавить к каждой части проекта, причём у каждой такой части он будет свой.
Имея выделенный промежуточный интерфейс, мы получаем возможность подменять реализацию.
Как Service/Handle позволяет легко менять реализацию и разрабатывать проект независимо от неё
Рассмотрим на примере логгера. В случае с данным паттерном интерфейс — это рекорды конструктора данных.
![](/templates/yootheme/cache/8e/6125f546d95e6104529de981_3-8ef5d940.png)
Чтобы связать этот интерфейс с реализацией, необходимо заполнить поле конструктора LogHandle. Чтобы пользоваться этим интерфейсом, его надо передавать в каждую функцию, где необходимо логирование.
![](/templates/yootheme/cache/be/6125f55cce0079493f2259ce_4-be588f6d.png)
Теперь, если потребуется поменять реализацию логирования, необходимо изменить всего лишь один участок кода, в котором определена реализация через putStrLn. Кроме этого, если потребуется использовать другую реализацию в одной из частей проекта, туда можно передать тот же интерфейс, но заполнить его другой реализацией.
![](/templates/yootheme/cache/26/6125f580403b2c232867b0e3_5-269f6be0.png)
Теперь давайте посмотрим, как можно абстрагироваться от IO, чтобы писать чистые тесты для функций с логированием:
![](/templates/yootheme/cache/ca/6125f5947b7fb0ff17b6dda8_6-ca5b716e.png)
Теперь из тестов можно запускать функцию doSomeWork, подставив реализацию, которая не будет обращаться в IO:
![](/templates/yootheme/cache/73/6125f5a83288ec8d85edec00_7-7303da05.png)
Ранее мы говорили, что интерфейс можно добавить как к библиотеке/сервису, так и к простым функциям, однако к функции doSomeWork интерфейс добавлять нет необходимости, поскольку она уже не имеет прямых зависимостей. Добавлять интерфейс к отдельным функциям имеет смысл, когда не для всех зависимостей определён промежуточный интерфейс. Например, когда для логирования выделен Service/Handle, а для обращения к базе данных нет.
Избавляемся от зависимостей в логике регистрации пользователя
Разберёмся с примером посложнее: представим, что у нас есть свой сервис, в котором нужна регистрация. Вот так в упрощённой форме мог бы выглядеть обработчик этой функции без использования паттерна (предположим, что функции для работы с базой данных и типы уже есть):
![](/templates/yootheme/cache/90/6125f5ca7cab074763deba64_8-90472596.png)
Давайте перепишем эту функцию с использованием паттерна Service/Handle. Наша цель — вынести все IO функции в отдельный интерфейс, чтобы логику можно было тестировать локально. Необходимо протестировать, что пользователь будет зарегистрирован только в случае, если логин и email не заняты. Интерфейс с зависимостями в нашем случае — это логирование, функция записи пользователя в базу данных и функции поиска пользователя в базе данных. Кроме этого, используем логгер, который мы переписали с использованием паттерна Service/Handle.
![](/templates/yootheme/cache/47/612626e2fab44b5199ad1f67_9-47a915a7.png)
![](/templates/yootheme/cache/eb/6125f61e3c9ac18290d777c6_10-eb6addf8.png)
Здесь у нас была необходимость создать дополнительный интерфейс для функции регистрации, так как у функции была прямая зависимость от базы данных. Можно было бы создать интерфейс для библиотеки через которую происходит работа с базой данных, но такое изменение не всегда легко сделать, особенно когда проект успел разрастись.
![](/templates/yootheme/cache/26/6125f97444f54bcda92b9abe_11-26abe558.png)
Также изменилось дерево проекта: раньше был один модуль App.Registration, в котором была функция регистрации. Теперь в этом модуле мы заполняем Handle, а логика находится в новой папке Handlers. Там создаётся модуль с идентичным именем App.Handlers.Registration, который вызывается после заполнения интерфейса.
![](/templates/yootheme/cache/b5/6125f9898ec6e9393fe308f4_12-b5eed97a.png)
Кстати, паттерн очень удобно использовать с расширением RecordWildCards. Оно позволяет не передавать handle явно как аргумент в каждую функцию интерфейса. С ним функция registerUser будет выглядеть так:
![](/templates/yootheme/cache/27/6125f99d942ce19136951881_13-2772266e.png)
Каким проектам подходит Haskell?
Рассказываем, как разрабатываем проекты на языке, в который влюблены с 2018 года.
Тестирование функций, написанных в стиле паттерна Service/Handle
Теперь в функцию registerUser можно подставлять другую реализацию, а это значит, её можно локально тестировать. Воспользуемся пакетом hspec. Как вы помните, у типов хэндлов есть параметр, который указывает, в какой монаде мы работаем. Раньше мы заполняли поля хэндла IO-функциями, поэтому при передаче заполненного хэндла в логику функция registerUser параметризировалась монадой IO. Теперь, чтобы написать чистые тесты, необходимо избавиться от IO, но все ещë нужна монада: отличное применение Identity. Раньше мы заполняли поля хэндла функциями, которые обращаются в IO: если функция делала запись в базу данных, то результатом было IO (), если функция доставала значение, то результатом было IO Value. Вместо IO будет Identity. Кроме этого, если базы данных нет, то и записывать нечего, поэтому функции записи данных будут игнорировать свой аргумент и возвращать Identity (). Функции, которые возвращают значения из базы данных, будут редактироваться под каждый тест. Если мы проверяем поведение, когда пользователь с указанным логином уже есть, то функция findUserByLogin будет возвращать Just User, когда логин свободен, функция будет возвращать Nothing. Такие данные, которые создаются специально для тестов, называют стабами (stub).
![](/templates/yootheme/cache/07/6125fa143288ecc328ee0f8e_Group%203-0773d047.png)
После запуска команды 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
Другие наши статьи
![ethereum_gas](/templates/yootheme/cache/cb/ethereum%20gas-cb7632ba.png)
![scroll](/templates/yootheme/cache/51/scroll-511454d9.png)
Алексей Куценко
Solidity разработчик
![bottle_wine](/templates/yootheme/cache/89/bottle_wine-89db8393.png)
![twa](/templates/yootheme/cache/83/twa-834e6b7d.png)
Елизавета Черная
Редактор Бренд-медиа
Статьи
![anonymus](/templates/yootheme/cache/07/metalamp_team_a_man_in_the_black_Zorro_mask_on_the_sky_backgrou_a84c5bec-bfeb-4105-9b3f-0612966e6f1e-074a6fd2.png)
![AA zksync](/templates/yootheme/cache/e9/AA%20zkp-e96134ba.png)
Роман Ярлыков
Solidity разработчик
![zero knowledge proofs](/templates/yootheme/cache/b7/zkp-b76aa6e2.png)
Роман Ярлыков
Solidity разработчик
![stock market chart](/templates/yootheme/cache/6c/stock%20chart-6c4b38ec.png)
![fundraising](/templates/yootheme/cache/e6/funds-e60aa811.png)
Микола Прындюк
Social Media Specialist
![wallet](/templates/yootheme/cache/29/AA%20wallet-296a272d.png)
Николай Бордуненко
Project manager at MetaLamp
![tokens](/templates/yootheme/cache/67/money-670ebc35.png)
Елизавета Черная
Редактор Бренд-медиа
![rocket computer](/templates/yootheme/cache/59/rocket%20computer-59d86bc4.png)
![nft](/templates/yootheme/cache/f1/market-f1ac3493.png)
Редакция MetaLamp
![crypto wallets](/templates/yootheme/cache/6c/wallets%201-6c58e91c.png)
![myths](/templates/yootheme/cache/4e/myths-4e1407fe.png)
![launching](/templates/yootheme/cache/09/launching-092e0dd1.png)
![galaxy](/templates/yootheme/cache/36/galaxy-36df8d6d.png)
Яна Гейдрович
Partnership manager at MetaLamp
Статьи
![magazine](/templates/yootheme/cache/ff/magazine-ffa890ca.png)
Микола Прындюк
Social Media Specialist
![spaceman](/templates/yootheme/cache/03/spaceman-030d8583.png)
![investors](/templates/yootheme/cache/c1/investors-c1a2396b.png)
![abstraction](/templates/yootheme/cache/2a/abstraction-2aa759e6.png)
Светлана Дульцева
Супервизор программы обучения
![keyboard](/templates/yootheme/cache/bc/keyboard-bc87cc2c.png)
Олег Ромашин
Haskell разработчик