Кажется, что в наши дни для всего существует RESTful API. От платежей до бронирования столиков, от уведомлений до запуска виртуальных машин — почти всё можно сделать с помощью простого HTTP-запроса.
Если вы создаёте собственную службу, вам часто захочется использовать её на нескольких платформах одновременно. Следование проверенным принципам ООД (объектно-ориентированного проектирования) делает ваш код более устойчивым и простым для расширения.
В этой статье мы рассмотрим один из подходов к проектированию, называемый SOLID (это аббревиатура). Мы применим его на практике, создав службу интеграции со Slack, а затем расширим её для работы с Twilio.
Служба отправляет вам случайную карту из Magic the Gathering. Если вы хотите увидеть её в действии прямо сейчас, отправьте слово *magic* по номеру: 1-929-236-9306 (только для США и Канады — вы получите изображение по MMS, могут применяться тарифы вашего оператора). Также вы можете присоединиться к моей организации в Slack, нажав сюда. После вступления введите: */magic*.
Если вы ещё не знакомы с SOLID, это набор принципов объектно-ориентированного проектирования (ООД), популяризированный дядей Бобом Мартином. SOLID расшифровывается как:
— S – SRP – Принцип единственной ответственности
— O – OCP – Принцип открытости/закрытости
— L – LSP – Принцип подстановки Лисков
— I – ISP – Принцип разделения интерфейса
— D – DIP – Принцип инверсии зависимостей
Следуя этому набору принципов, ваш код становится более поддерживаемым и простым для расширения. Мы подробнее рассмотрим каждый из этих принципов в этой статье.
Существует множество хороших примеров применения SOLID на различных языках программирования. Вместо того чтобы повторять типичный пример с `Shape`, `Circle`, `Rectangle` и `Area`, я хотел показать преимущества SOLID на реальном, полностью функциональном приложении.
Недавно я экспериментировал с API Slack. Создавать собственные команды с косой чертой очень просто. Я также большой поклонник Magic the Gathering, поэтому решил создать команду для Slack, которая возвращает изображение случайной карты из этой игры.
Я сделал это довольно быстро с помощью Spring Boot. Как вы увидите ниже, Spring Boot изначально поддерживает некоторые принципы SOLID.
У Twilio отличный API для голосовых и текстовых сообщений. Мне было интересно, насколько просто адаптировать мой пример для Slack под Twilio. Идея заключалась в том, что вы отправляете команду на известный номер телефона и получаете обратно случайное изображение карты из Magic the Gathering.
Далее следует разбор принципов SOLID (не по порядку) на примере этого упражнения по разработке ПО.
Весь код можно найти здесь. Позже мы также рассмотрим, как развернуть код и запустить его в своём аккаунте Slack и/или Twilio, если вы захотите это сделать.
Просто используя Spring Boot для создания приложения Magic, вы автоматически получаете 2 из 5 принципов SOLID. Однако вы всё ещё отвечаете за правильную архитектуру приложения.
Поскольку мы будем рассматривать различные принципы в процессе разработки кода, вы можете ознакомиться с примером кода на любом этапе, проверив соответствующие теги в проекте на GitHub (они находятся в разделе «Releases»). Для полного кода этого раздела посмотрите тег `slack-first-pass`.
Давайте рассмотрим код `SlackController` (весь исходный код на Java находится в: magic-app/src/main/java/com/afitnerd/magic), чтобы разобрать буквы `D` и `I` в SOLID:
Принцип инверсии зависимостей (DIP) гласит:
A. Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Java и Spring Boot делают это очень просто. В `SlackController` внедряется `MagicCardService`. `MagicCardService` — это абстракция, так как это интерфейс Java. И поскольку это интерфейс, у него нет деталей реализации.
Реализация `MagicCardService` не имеет значения для `SlackController`. Позже мы увидим, как можно усилить это разделение между интерфейсом и его реализацией, разбив приложение на модули. Кроме того, мы рассмотрим другие, более современные способы внедрения зависимостей в Spring Boot.
Принцип разделения интерфейса (ISP) гласит:
Лучше иметь множество клиентских интерфейсов, чем один универсальный.
В `SlackController` внедряются два отдельных интерфейса: `MagicCardService` и `SlackResponseService`. Один отвечает за взаимодействие с сайтом Magic the Gathering, другой — за взаимодействие со Slack. Наличие одного интерфейса для этих двух разных функций нарушило бы ISP.
Чтобы следовать за кодом в этом разделе, посмотрите тег `twilio-breaks-srp`.
Давайте рассмотрим код `TwilioController`:
Как упоминалось ранее, теперь мы используем более современный (лучший) подход к внедрению зависимостей. Это видно из того, что мы применяем конструкторное внедрение в Spring Boot. Это модный способ сказать, что в последней версии Spring Boot можно реализовать внедрение зависимостей следующим образом:
1. Определите одно или несколько приватных полей в классе, например:
2. Определите конструктор, который принимает одно или несколько из определённых вами приватных полей, например:
Spring Boot автоматически обрабатывает внедрение объекта реализации во время выполнения. Преимущество заключается в том, что в конструкторе можно выполнять проверку ошибок и валидацию внедряемого объекта.
В этом контроллере есть два пути: `/twilio` и `/magic_proxy/{card_id}`. Путь magic_proxy требует небольшого пояснения, поэтому давайте сначала рассмотрим его, прежде чем говорить о том, как это нарушает принцип единственной ответственности (SRP).
TwiML — это язык разметки Twilio. Он является основой всех ответов от Twilio, так как служит инструкцией для Twilio, что делать. Это также XML. Обычно это не проблема. Однако URL-адреса, возвращаемые с сайта Magic the Gathering, создают проблему для включения в документы TwiML.
URL-адрес для получения изображения карты Magic the Gathering выглядит так:
Обратите внимание на амперсанд (&) в URL. Существует только два допустимых способа включения амперсанда в XML-документ:
1. Экранирование
Обратите внимание, что амперсанд преобразуется в сущность: `&`
2. Заключение в CDATA (символьные данные)
Любой из этих подходов легко реализовать с помощью Java и расширения Jackson Dataformat XML для встроенного в Spring Boot процессора JSON Jackson.
Проблема в том, что первый способ вызывает ошибку при получении изображения с сайта Wizards of the Coast (владельцев Magic the Gathering), а второй вызывает проблему с Twilio (эй, Twilio, разве TwiML не должен поддерживать CDATA?).
Решение, которое я придумал, — проксировать запросы. Фактический TwiML, который возвращается, выглядит так:
Когда этот TwiML возвращается, Twilio обращается к конечной точке `/magic_proxy`, и за кулисами код получает изображение с сайта Magic the Gathering и возвращает его.
Теперь мы можем продолжить рассмотрение принципов SOLID.
Принцип единственной ответственности (SRP) гласит:
Класс должен иметь только одну ответственность.
Контроллер выше работает как есть, но нарушает SRP. Это потому, что контроллер отвечает как за возврат ответа TwiML, так и за проксирование изображений.
В этом примере это не так критично, но можно представить, как это может быстро выйти из-под контроля.
Если вы посмотрите тег `twilio-fixes-srp`, то найдёте новый контроллер под названием `MagicCardProxyController`:
Его единственная ответственность — возвращать байты изображения, проксированные с сайта Magic the Gathering.
Теперь единственная ответственность `TwilioController` — возвращать TwiML.
Maven позволяет легко разбить проект на модули. Эти модули могут иметь разные области видимости, наиболее распространённые из которых: `compile` (по умолчанию), `runtime` и `test`.
Области видимости контролируют, когда модули подключаются. Область `runtime` гарантирует, что классы в указанном модуле *не* доступны на этапе компиляции. Они доступны только во время выполнения. Это помогает нам соблюдать DIP.
Лучше всего это продемонстрировать на примере. Посмотрите тег `modules-ftw`. Мы видим, что структура проекта изменилась довольно радикально (как видно в IntelliJ):
Теперь есть 4 модуля. Если мы посмотрим на модуль `magic-app`, то увидим, как он зависит от других модулей, изучив его `pom.xml`:
Обратите внимание, что `magic-impl` имеет область `runtime`, а `magic-api` — область `compile`.
В `TwilioController` мы автоматически внедряем `TwilioResponseService`:
Теперь давайте посмотрим, что произойдёт, если мы попытаемся внедрить класс реализации вот так:
IntelliJ не сможет найти класс `TwilioResponseServiceImpl`, так как он *не* находится в области `compile`.
Для развлечения можно попробовать удалить строку `
Как мы видели, использование модулей Maven в сочетании с областями видимости помогает соблюдать DIP.
Когда я впервые написал это приложение, я не думал о SOLID. Я просто хотел быстро создать приложение для Slack, чтобы поиграть с функциональностью команд с косой чертой.
В первой итерации все сервисы и контроллеры, связанные со Slack, просто возвращали `Map
По мере развития приложения мы хотим создавать более формальные модели для читаемого и устойчивого кода.
Посмотрите исходный код тега `slack-violates-lsp`.
Давайте рассмотрим класс `SlackResponse` в модуле `magic-api`:
Из этого видно, что `SlackResponse` содержит массив `Attachments`, строку `text` и строку `response_type`.
`SlackResponse` объявлен как `abstract`, и задача дочерних классов — реализовать методы `getText` и `getResponseType`.
Теперь давайте посмотрим на один из дочерних классов, `SlackInChannelImageResponse`:
Метод `getText()` возвращает `null`. В этом типе ответа *только* изображение. Текст включается только в ответ об ошибке. Это *серьёзный* признак нарушения LSP.
Принцип подстановки Лисков (LSP) гласит:
Объекты программы должны быть заменяемы экземплярами их подтипов без изменения корректности программы.
Когда вы имеете дело с иерархией наследования и дочерний класс *всегда* возвращает null, это верный признак нарушения LSP. Это происходит потому, что дочернему классу не нужен этот метод, но он должен его реализовать из-за интерфейса, определённого в родительском классе.
Посмотрите ветку `master` в проекте на GitHub. Иерархия `SlackResponse` переработана в соответствии с LSP.
Теперь единственное, что объединяет все дочерние классы и что они должны реализовать, — это метод `getResponseType()`.
Класс `SlackInChannelImageResponse` содержит всё необходимое для корректного ответа с изображением:
Теперь нам больше не нужно возвращать `null`.
Есть ещё одно небольшое улучшение: раньше в `SlackResponse` были аннотации JSON: `@JsonInclude(JsonInclude.Include.NON_EMPTY)` и `@JsonInclude(JsonInclude.Include.NON_NULL)`.
Это было сделано для того, чтобы в JSON не было пустого массива вложений и не было поля text, если оно равно null. Хотя эти аннотации мощные, они делают наши модели объектов хрупкими, и другим разработчикам может быть неясно, что происходит.
Последний принцип, который мы рассмотрим в нашем путешествии по SOLID, — это OCP.
Принцип открытости/закрытости (OCP) гласит:
Программные сущности… должны быть открыты для расширения, но закрыты для модификации.
Идея заключается в том, что по мере изменения требований к программному обеспечению ваш код будет эффективнее справляться с неожиданностями, если вы будете расширять классы, а не добавлять код в существующие классы. Это помогает сдерживать «расползание кода».
В нашем примере выше нет причин изменять `SlackResponse`. Если мы захотим, чтобы приложение поддерживало другие типы ответов Slack, мы легко сможем создать эти специфические подклассы.
Здесь снова проявляется мощь Spring Boot. Взгляните на класс `SlackResponseServiceImpl` в модуле `magic-impl`.
Согласно нашему контракту интерфейса, оба метода `getInChannelResponseWithImage` и `getErrorResponse` возвращают объект `SlackResponse`.
Внутри этих методов создаются разные дочерние объекты `SlackResponse`. Spring Boot и его встроенный маппер Jackson для JSON достаточно умны, чтобы возвращать правильный JSON для конкретного объекта, создаваемого внутри.
Если вы заинтересованы в интеграции с вашей собственной организацией в Slack или в предоставлении её через ваш аккаунт Twilio (или и то, и другое), читайте дальше! В противном случае вы можете перейти к разделу резюме `Итоги SOLID` в конце.
Чтобы в полной мере использовать это приложение, вам нужно правильно настроить Slack и Twilio после развёртывания приложения на Heroku.
Вы можете настроить только Slack или только Twilio. В любом случае первым шагом будет развёртывание на Heroku. К счастью, это самая простая часть.
Самый простой способ развернуть приложение на Heroku — использовать дружественную фиолетовую кнопку в README проекта на GitHub. Вам нужно будет предоставить два параметра: `BASE_URL` и `SLACK_TOKENS`.
`BASE_URL` — это полное доменное имя вашего приложения на Heroku. Например, у меня приложение развёрнуто по адресу: https://random-magic-card.herokuapp.com. Используйте тот же формат, указав имя вашего приложения: `https://<ваше-имя-приложения>.herokuapp.com`.
Здесь есть небольшая проблема курицы и яйца: приложению Heroku нужна информация от Slack, а интеграции Slack нужна информация о приложении Heroku. Пока вы можете оставить значение по умолчанию в `SLACK_TOKENS`, а позже мы вернёмся и обновим это значение на реальный токен API Slack.
Вы можете проверить, что развёртывание прошло успешно, перейдя по адресу: `https://<ваше-имя-приложения>.herokuapp.com`. Вы должны увидеть случайную карту Magic the Gathering в своём браузере. Если возникнет ошибка, вы можете посмотреть журнал ошибок в веб-интерфейсе приложения Heroku. Посмотрите пример на https://random-magic-card.herokuapp.com.
Перейдите на https://api.slack.com/apps и нажмите кнопку `Create New App`, чтобы начать:
Введите значения для `App Name` и выберите `Workspace`, в которую вы будете добавлять приложение:
Затем нажмите ссылку `Slash Commands` на левой панели, а затем кнопку `Create New Command`:
Заполните значения для `Command` (например: `/magic`), `Request URL` (например: `https://<ваше-имя-приложения>.herokuapp.com/api/v1/slack`) и `Short Description`. После этого нажмите `Save`.
На этом этапе ваша команда с косой чертой в Slack полностью настроена:
Перейдите в раздел `Basic Information` на левой панели и разверните секцию `Install app to your workspace`. Нажмите кнопку `Install`.
После этого нажмите кнопку `Authorize`:
Прокрутите вниз на экране `Basic Information`, на который вас вернули, и запишите `Verification Token`.
Если вы установили Heroku CLI, вы можете выполнить эту команду, чтобы правильно установить свойство `SLACK_TOKENS`:
Альтернативно перейдите в панель управления Heroku, выберите ваше приложение и измените значение `SLACK_TOKENS` в разделе Settings.
Теперь вы сможете использовать команду с косой чертой в канале вашей организации в Slack и получать в ответ карту Magic the Gathering:
Нажмите на три точки и выберите `Programmable SMS`:
Выберите `Messaging Services`:
Создайте новый сервис обмена сообщениями, нажав красную кнопку с плюсом (`+`) (или нажмите «Create new Messaging Service», если у вас ещё нет ни одного):
Введите `Friendly Name`, выберите `Notifications, 2-Way` для `Use Case` и нажмите кнопку `Create`:
Установите флажок `Process Inbound Messages` и введите `Request URL` вашего приложения на Heroku (например, `https://<ваше-имя-приложения>.herokuapp.com/api/v1/twilio`):
Нажмите кнопку `Save`, чтобы сохранить изменения.
Перейдите в раздел `Numbers` на левой панели и убедитесь, что ваш номер Twilio добавлен в сервис обмена сообщениями:
Теперь вы можете протестировать сервис Twilio, отправив слово `magic` в виде текстового сообщения на ваш номер Twilio:
**Примечание:** Если вы отправите что-либо, кроме слова `magic` (регистр не имеет значения), вы получите ответ об ошибке, как показано выше.
Вот таблица SOLID ещё раз, на этот раз с тегами проекта на GitHub для каждого принципа:
— S – Принцип единственной ответственности – Тег: `twilio-fixes-srp` – разделите `TwilioController` на два, чтобы каждый контроллер имел одну ответственность.
— O – Принцип открытости/закрытости – Тег: `master` – `SlackResponse` завершён и не требует изменений. Его можно расширять без изменения существующего кода сервиса.
— L – Принцип подстановки Лисков – Тег: `master` – ни один из дочерних классов `SlackResponse` не возвращает `null` и не имеет ненужных методов или аннотаций.
— I – Принцип разделения интерфейса – Теги: от `slack-first-pass` до `master` – `MagicCardService` и `SlackResponseService` выполняют разные функции и поэтому являются отдельными сервисами.
— D – Принцип инверсии зависимостей – Теги: от `slack-first-pass` до `master` – зависимые сервисы автоматически внедряются в контроллеры. Конструкторное внедрение — это «лучший практики» способ внедрения зависимостей.
В процессе разработки этого приложения возникли некоторые трудности. Я уже говорил о проблеме с TwiML выше. У Slack были свои сложности, как я описал в этой статье. Кратко: Slack *только* отправляет POST-запросы с `application/x-www-form-urlencoded` для команд с косой чертой, в отличие от более современного `application/json`. Это усложнило обработку входящих данных JSON в Spring Boot.
Суть в том, что использование принципов SOLID сделало код намного проще для работы и расширения в процессе.
На этом завершается наш обзор принципов SOLID. Надеюсь, он оказался полезным не только в рамках обычных упрощённых примеров на Java. Хочу выразить благодарность Spring Framework Guru за его трактовку SOLID, особенно OCP и LSP.
Есть вопросы? Возникли трудности при настройке примера проекта самостоятельно? Вы можете оставить комментарий ниже или написать мне в Twitter: @afitnerd.