"Я думаю что? понимаю функциональное программирование на микро уровне и написал несколько игрушечных программ. Но как мне написать завершенную программу, с реальными данными, реальной обработкой ошибок, и тд?"
Это на самом деле довольно распространенный вопрос, поэтому в этой серии постов описать несколько способов, как это делать, включая проектирование, валидацию, обработку ошибок, управление зависимостями, организацией кода, итд.?"
Сначала несколько комментариев и предупреждений:
- Я буду фокусироваться на одном варианте использования, нежели на целом приложении. Надеюсь, что будет очевидно, как расширить код, если будет нужно.
- Мы умышленно будем использовать простой поток данных без специальных фокусов и продвинутых техник. Если вы только начали изучать данную тему, думаю, будет полезным иметь какие-нибудь простые шаги, которым можно следовать и получить предсказуемый результат. Я не утверждаю, что это единственный истинный путь, как это можно реализовать. Разные сценарии требуют разных подходов, и кончено, вы с опытом можете видеть эти способы слишком простыми или ограниченными.
- Для простого перехода с объектно-ориентированной парадигмы я буду использовать близкие концепты, такие как "шаблоны", "сервисы", "внедрение зависимостей" и т.д., попутно объясняя, как они отображаются на функциональную парадигму.
- Советы умышленно немного императивны, и используют явный пошаговый процесс. Надеюсь, этот подход облегчит переход с ООП на ФП.
- Для простоты (и практичности простые скрипты на F#) буду использовать поддельную инфраструктуру и избегать взаимодействия с графическим интерфейсом.
Обзор
Небольшой обзор того, что планирую включить в эту серию:
- Конвертирование варианта использования (use case) в функцию. В первом посте мы рассмотрим простой пример использования и как он мог бы быть реализован, используя функциональный подход.
- Соединение простых функций. В следующем посте мы рассмотрим простую метафору для комбинирования простых функций в более сложные.
- Проектирование с использованием типов. В третьем посте мы построим типы, необходимые для варианта использования, и обсудим специальные типы ошибок и неудачные пути.
- Конфигурация и управление зависимостями. В этом посте мы поговорим о том, как подключать все функции.
- Валидация. В этом посте мы поговорим о разных путях реализации валидации и конвертирования с опасного внешнего мира в безопасный теплый и пушистый мир типов.
- Инфраструктура. В этом посте ми поговорим о разных компонентах инфраструктуры, например легирование, работа с внешним кодом и т.д.
- Предметный слой. В этом посте мы поговорим как предметно-ориентированное проектирование работает в функциональной среде.
- Слой представления. Здесь мы поговорим о том, как передавать результаты и ошибки назад в графический интерфейс.
- Изменения в требованиях. Поговорим о том, как вести дела с изменениями в требованиях, и как они влияют на код.
Начнем
Давайте выберем простой пример использования, а точнее обновления информации пользователя через веб-сервис.
Вот базовые требования:
- Пользователь отправляет свои данные (userid, name и email).
- Мы проверяем что имя и почтовый адрес - правильные.
- Обновляем соответствующую запись в базе данных новыми данными.
- Если был изменен почтовый адрес, то отправляем контрольное сообщение на этот адрес.
- Отображаем результат операции пользователю.
Это обычный ориентированный на данные вариант использования. В некотором виде запрос вызывает вариант использования, после данные текут сквозь систему, будучи обработанными на каждом шагу. Этот сценарий довольно распространен в корпоративном программном обеспечении, поэтому я его и взял за пример.
Вот схема из компонентов:
Но диаграмма описывает только "удачный путь". Реальность несколько сложнее. Что случиться, если идентификатор пользователя не найден в базе, почтовый адрес не правильный или в базе произошла ошибка?
Давайте обновим схему и покажем все что могло бы пойти не так:
На этом сценарии на каждом шаге, как показано, могут случиться разные ошибки. Одна из целей этой серии - научить элегантно обрабатывать эти ошибки.
Функциональное мышление
Теперь, когда мы поняли шаги в нашем варианте использования, как нам спроектировать решение используя функциональный подход?
Для начала, мы должны найти несоответствие между оригинальным вариантом использования и функциональным мышлением.
В данном случае мы обычно думаем в модели запрос-ответ. Запрос был послан и назад получаем ответ. Если что-нибудь пошло не так, то поток короткий и будет возвращен "ранний" ответ.
Вот схема, которая демонстрирует то что, я имел ввиду, основана на упрощенной версии нашего варианта использования:
Только вот в функциональной модели функция это черная коробка, которая имеет ввод и вывод, как здесь:
Как же можем адаптировать эту модель под наш вариант использования?
Только однонаправленный поток
Первое, что вы должны осознать, это то, что функциональный подход только однонаправленный. Вы не можете сделать ранний разворот или вернуть результат заранее.
В нашем случае это значит, что все ошибки должны быть переданы до конца, так же как и в успешном случае.
После этого мы можем конвертировать целый поток в одну функцию:
И, конечно же, если вы посмотрите внутрь большой функции, то увидите, что она сделана ("составлена [composed]" функционально говоря) из меньших функций для каждого шага, соединенных в единственный "трубопровод".
Обработка ошибок
Из последней схемы видно, что мы имеем один успешный вывод и три с ошибками. В этом и есть основная проблема, так как функции могут иметь один вывод, а не четыре!
Как же мы можем обработать это?
Ответ: использовать объединение типов, что в нашем случае представляет каждый из возможных выводов. И тогда функция в целом будет иметь один вывод.
Вот пример возможного описания типа для вывода:
type UseCaseResult =
| Success
| ValidationError
| UpdateError
| SmtpError
А вот здесь переработанная схема для отображения одиночного вывода с четырьмя встроенными случаями:
Упрощение обработки ошибок
Это, конечно, решит проблему, но имея по одному ошибочному случаю для каждого шага в потоке, получим очень хрупкий и повторно неиспользуемый код. Можем ли мы сделать лучше?
ДА! На самом деле нам нужны только два случая. Один для успешного пути и один для всех других, не успешных, например:
type UseCaseResult =
| Success
| Failure
Этот тип очень обобщенный и будет работать с любыми другими вариантами использования. Однако, вы скоро увидите, как создать хорошую библиотеку функций, которая будет работать с этим типом и будет повторно использована у всех видах сценариев.
Еще одна деталь: в этом результате нет данных вообще, только статус успех-неудача. Нужно еще немного скорректировать, чтоб он мог иметь реальный объект со значением успеха или неудачи. Мы уточним тип успеха и неудачи используя обобщения (параметры типа).
Вот окончательная, совершенно обобщенная и повторно используемая версия:
type Result<'TSuccess,'TFailure> =
| Success of 'TSuccess
| Failure of 'TFailure
На самом деле, у нас уже есть практически такой же тип как мы определили в библиотеке F#. И называется он Choice
(прм. пер. аналог в Java Optional
). Для ясности буду использовать тип Result
описанный выше. Когда будем писать более серьезный код, мы это пересмотрим.
Сейчас опять рассмотрим индивидуальные шаги, и мы сможем увидеть, что скомбинировали ошибки с каждого шага в один "неудачный" путь.
Как это реализовать, будет темой следующего поста.
Заключение и советы
На данный момент у нас следующие советы.
- Каждый вариант использования будет состоять из одной функции.
- Каждая функция будет возвращать объединенный тип с парой состояний:
Success
иFailure
. - Вариант использования будет построен из последовательности мелких функций, каждая из которых представляет один шаг в потоке.
-
Ошибки из каждого шага будут комбинированы в один "неудачный" путь.