[Перевод - recovery mode ] Как спроектировать и написать полноценную программу
«Инструкция создания функционального приложения», часть 1.
«Мне кажется, что понимаю функциональное программирование на базовом уровне, и я даже писал простые программы, но как мне создать полноценное приложение, с реальными данными, с обработкой ошибок и прочим?»
Это очень распространенный вопрос, поэтому я решил, что в этой серии статей опишу инструкцию, охватывающую проектирование, валидацию, обработку ошибок, персистентность, управление зависимостями, организацию кода и так далее.
Сначала, несколько комментариев и предостережений:
- Я буду описывать только один сценарий, а не всё приложение. Надеюсь, будет очевидно как расширить код при необходимости.
- Это намеренно очень простая инструкция без особых ухищрений и продвинутой техники, ориентированная на поточную обработку данных. Но если вы начинающий, я думаю, вам будет полезно иметь последовательность простых шагов, которые вы сможете повторить и получить ожидаемый результат. Я не утверждаю, что это единственный верный способ. Различные сценарии будут требовать различных подходов, и конечно с ростом собственной экспертизы вы можете обнаружить, что эта инструкция слишком простая и ограниченная.
- Чтобы облегчить переход с объектно-ориентированного проектирования, я постараюсь использовать знакомые концепции такие как «шаблоны», «сервисы», «внедрение зависимости» и т.д., а также объяснять как они соотносятся с функциональным подходом.
- Инструкция также намеренно сделана в некоторой степени императивной, т.е. используется явный пошаговый процесс. Я надеюсь, этот подход облегчит переход от ООП к ФП.
- Для простоты (и возможности использовать F# script) я установлю заглушку на всю инфраструктуру и уклонюсь от взаимодействия с UI напрямую.
Обзор
Обзор того, что я планирую описать в этой серии статей:
- Преобразование сценария в функцию. В первой статье мы рассмотрим простой сценарий и увидим как он может быть реализован с помощью функционального подхода.
- Объединение небольших функций. В следующей статье, мы обсудим простую метафору об объединении небольших функций в более крупные.
- Проектирование с помощью типов и типы ошибки. В третьей статье мы создадим необходимые для сценария типы и обсудим специальные типы для обработки ошибок.
- Настройка и управление зависимостями. В этой статье мы поговорим о том, как связать все функции.
- Валидация. В этой статье мы обсудим различные пути реализации проверок и преобразование из опасного внешнего мира в теплый пушистый мир типобезопасности.
- Инфраструктура. В этой статье мы обсудим различные компоненты инфраструктуры, такие как журналирование, работа с внешним кодом и т.д.
- Предметный уровень. В этой статье мы обсудим, как предметно-ориентированное проектирование работает в функциональном мире.
- Уровень представления. В этой статье мы обсудим, как вывести в UI результаты и ошибки.
- Работа с изменяющимися требованиями. В этой статье мы обсудим, что делать с изменяющимися требованиями и как они влияют на код.
Приступим
Давайте возьмем очень простой пример, а именно обновление некоторой информации о клиенте через веб-сервис.
И так, наши основные требования:
- Пользователь отправляет некоторые данные (идентификатор пользователя, имя и адрес почтового ящика).
- Мы проверяем корректность имени и адреса ящика.
- В базе данных в соответствующей пользовательской записи обновляются имя и адрес почтового ящика.
- Если адрес почтового ящика изменен, отправляем на этот адрес проверочное письмо.
- Выводим пользователю результат операции.
Это обычный сценарий обработки данных. Здесь присутствует определенный запрос, который запускает сценарий, после чего данные из запроса «протекают» через систему, подвергаясь обработке на каждом шаге. Я использую этот сценарий в качестве примера, потому что он распространен в корпоративном ПО.
Вот диаграмма составных частей процесса:
Но это описание только успешного варианта событий. Реальность никогда не бывает столь простой! Что произойдёт, если идентификатор пользователя не найдется в базе данных, или почтовый адрес будет некорректный, или в базе данных есть ошибка?
Давайте изменим диаграмму и отметим всё, что может пойти не так.
Как видим, на каждом шаге сценария могут возникнуть ошибки по различным причинам. Одна из целей серии этих статей — объяснить как элегантно управлять ошибками.
Функциональное мышление
Теперь, когда мы разобрались с этапами нашего сценария, как его реализовать с помощью функционального подхода?
Сначала обратимся к различиям между исходным сценарием и функциональным мышлением.
В сценарии мы обычно подразумеваем модель запрос-ответ. Отправляется запрос, обратно приходит ответ. Если что-то пошло не так, то поток действий завершается и ответ приходит «досрочно» (прим. переводчика: Речь исключительно о процессе, не о затраченном времени.).
Что я имею ввиду, можно увидеть на диаграмме упрощенной версии сценария.
Но в функциональной модели, функция — это черный ящик с входом и выходом, как здесь:
Как мы можем приспособить наш сценарий к такой модели?
Однонаправленный поток
Во-первых, вы должны осознать, что функциональный поток данных распространяется только вперед. Вы не можете вернуться «досрочно».
В нашем случае, это означает, что все ошибки должны передаваться до окончания сценария по альтернативному пути.
Как только мы это сделаем, у нас появится возможность превратить весь поток в единственную функцию — чёрный ящик:
Конечно, если вы загляните внутрь этой большой функции, то обнаружите, что она сделана из («является композицией» в терминах функциональной методологии) меньших функций, по одной на каждый этап сценария, соединенных последовательно друг за другом.
Управление ошибками
На последней диаграмме изображены один успешный выход и три выхода для ошибок. Это проблема, так как функции могут иметь только один выход, а не четыре!
Что мы можем с этим сделать?
Ответ в том, чтобы использовать тип Объединение, где каждый вариант представляет один из возможных выходов. Тогда у функции действительно будет только один выход.
Вот пример возможного определения типа для вывода результата:
type UseCaseResult =
| Success
| ValidationError
| UpdateError
| SmtpError
И вот переделанная диаграмма, на которой изображён единственный выход с четырьмя различными вариантами, включёнными в него:
Упрощение управления ошибками
Это решает проблему, но наличие ошибки для каждого шага — это хрупкая и мало пригодная для повторного использования конструкция. Можем ли мы сделать лучше?
Да! Нам в действительности нужны только два метода. Один для успешного случая и другой для всех ошибочных:
type UseCaseResult =
| Success
| Failure
Этот тип очень универсальный и будет работать с любым процессом! Собственно вы скоро увидите, что для работы с этим типом мы можем сделать хорошую библиотеку полезных функций, которая подойдет для любых сценариев.
Ещё один момент — в результате, который возвращает функция, совсем нет данных, только статус успех/неудача. Нам потребуется кое-что поправить, чтобы результат функции содержал фактический успешный или сбойный объект. Мы объявим успешный и сбойный типы как универсальные (с помощью параметров типов).
Наконец, наша итоговая, универсальная версия:
type Result<'TSuccess,'TFailure> =
| Success of 'TSuccess
| Failure of 'TFailure
На самом деле, в библиотеке F# уже есть подобный тип. Он называется Choice. Для ясности я всё же продолжу использовать в этой и последующих статьях созданный ранее тип Result. Мы вернемся к этому вопросу, когда подойдём к более серьезным задачам.
Теперь, снова взглянув на сценарий с отдельными шагами, мы увидим, что должны соединить ошибки каждого шага в единый «сбойный» путь.
Как это сделать — тема следующей статьи.
Итог и методические указания
Итак, у нас есть следующие положения к инструкции:
Методические указания
- Каждый сценарий равносилен элементарной функции.
- Возвращаемый тип сценарной функции — объединение с двумя вариантами: Success и Failure.
- Сценарная функция строится из ряда небольших функций, которые представляют отдельные шаги в потоке данных.
- Ошибки всех этапов объединяются в единый путь ошибок.