Skip to main content
5 человек
в команде
5 месяцев
в работе
Спроектировали и разработали совместно с IOHK децентрализованное приложение на платформе Plutus. Созданный DApp — это один из первых NFT-маркетплейсов на Cardano
Узнать больше

Использование паттерна Service Handle в Haskell: руководство

keyboard

Меня зовут Олег Ромашин, я занимаюсь разработкой на языке 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 будет выглядеть так:

Тестирование функций, написанных в стиле паттерна 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_rules
горячее
layer_zero
горячее

Обзор и архитектура протокола LayerZero v2

Роман Ярлыков

Solidity разработчик

Статьи

ethereum
web3
Solana
новое
TON_Mintless_Jettons
новое
L2_Bitcoin
новое
polymarket_article

Что такое Polymarket и как работает рынок предсказаний?

Павел Найданов

Solidity разработчик

Статьи

web3
business
package_solutions
выбор редакции
tapalki
выбор редакции
uma_protocol
выбор редакции
AdsGram
выбор редакции

Способ монетизировать игры в Telegram

Алексей Федин

Исполнительный директор Magnetto.pro

Статьи

web3
mobile
TON
hamster_tma
выбор редакции

Как хомяк, но для трафика: привлекаем аудиторию тапалкой

Николай Бордуненко

Бизнес-аналитик MetaLamp

Статьи

web3
dApps
mobile
dao

Что такое DAO?

Павел Найданов

Solidity разработчик

Статьи

education
web3
ethereum_gas
scroll

Как работает блокчейн Scroll: технический обзор

Алексей Куценко

Solidity разработчик

Статьи

ethereum
web3
dApps
L2
nft_stacking
выбор редакции

Понимание стейкинга NFT: механизмы и преимущества

Павел Найданов

Solidity разработчик

Статьи

ethereum
web3
dApps
legendary_play
выбор редакции
payments
sharding
выбор редакции
ton
выбор редакции
bottle_wine
выбор редакции
launchpad
twa
выбор редакции
buildings
выбор редакции
anonymus

Zero-Knowledge Proofs: важный тренд в блокчейне на 2024 год

Евгений Биктимиров

Венчурный аналитик

Статьи

ethereum
web3
dApps
cpay
AA zksync
zero knowledge proofs
stock market chart
planets
fundraising
cto
wallet
tokens
выбор редакции
rocket computer
выбор редакции

Как создать дизайн для MVP за 7 дней

Юлия Черепанова

Head of Design Office

Статьи

startup
MVP
design
nft
AI
crypto wallets
выбор редакции
red space
выбор редакции
speed up development
myths
выбор редакции
launching
выбор редакции

Кого нанимать для успешного запуска MVP

Алексей Сухарев

Head of Sales Department

Статьи

business
startup
MVP
galaxy
magazine
spaceman
выбор редакции
coffee
investors
nft

Как мы создали первый NFT-маркетплейс на Cardano

Станислав Жданович

Haskell разработчик

Статьи

cardano
web3
nft
stair
выбор редакции
bridge
rocket
abstraction

Как мы нанимаем инженеров Plutus через собственную программу обучения

Светлана Дульцева

Супервизор программы обучения

Статьи

education
cardano
web3
mountains
salary
salary increase
app
developer with books
keyboard
abstract
blockchain