GrabDuck

Как спроектировать и написать полноценную программу

:

«Инструкция создания функционального приложения», часть 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.
  • Сценарная функция строится из ряда небольших функций, которые представляют отдельные шаги в потоке данных.
  • Ошибки всех этапов объединяются в единый путь ошибок.