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

Пишем и тестируем на Haskell с использованием паттерна Service/Handle

Article preview

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

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

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

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

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

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