Pull to refresh

Принципы SOLID в действии: от Slack до Twilio

Reading time 15 min
Views 19K
Original author: Micah Silverman


Похоже, что в наши дни RESTful API существует абсолютно для всего. От платежей до бронирования столиков, от простых уведомлений до развёртывания виртуальных машин — почти всё доступно через простое HTTP-взаимодействие.

Если вы разрабатываете собственный сервис, то часто хотите обеспечить его работу одновременно на нескольких платформах. Проверенные временем принципы ООД (объектно-ориентированного дизайна) сделают ваш код более отказоустойчивым и упростят расширяемость.

В этой статье мы изучим один конкретный подход к проектированию, который называется SOLID (это акроним). Используем его на практике в написании сервиса с интеграцией Slack, а затем расширим для использования с Twilio.

Этот сервис высылает вам случайную карту Magic the Gathering. Если хотите проверить его в действии прямо сейчас, то отправьте слово magic на номер 1-929-236-9306 (только США и Канада — вы получите изображение по MMS, так что могут примениться тарифы вашего оператора). Также можете присоединиться к моей организации Slack, нажав здесь. После входа наберите: /magic.

SOLID для «Магии»


Если вы ещё не знакомы с SOLID, это набор принципов объектно-ориентированного дизайна (ООД), которые популяризовал дядя Боб Мартин. SOLID — это акроним для:

  • S – SRP – Принцип единственной ответственности (Single Responsibility Principle)
  • O – OCP – Принцип открытости/закрытости (Open Closed Principle)
  • L – LSP – Принцип подстановки Барбары Лисков (Liskov Substitution Principle)
  • I – ISP – Принцип разделения интерфейса (Interface Segregation Principle)
  • D – DIP – Принцип инверсии зависимостей (Dependency Inversion Principle)

Если следовать этому набору принципов, вы сделаете код более отказоустойчивым и упростите расширяемость. Далее в статье мы подробнее поговорим о каждом из этих принципов.

Существует много хороших примеров SOLID на разнообразных языках. Вместо повторения известного примера Shape, Circle, Rectangle, Area я хотел бы показать преимущества SOLID в полнофункциональном приложении из реального мира.

Недавно я игрался со Slack API. Там действительно очень просто создавать свои команды со слэшем. Также я большой фанат Magic the Gathering, так что мне пришла идея сделать слэш-команду Slack, которая выдаёт изображение случайной карты Magic the Gathering.

Я быстро осуществил задуманное с помощью Spring Boot. Как вы убедитесь далее, Spring Boot соблюдает пару принципов SOLID прямо из коробки.

У Twilio великолепный API для голосовых и текстовых сообщений. Я подумал, будет интересно посмотреть, насколько легко взять мой пример Slack и интегрировать его с Twilio. Идея в том, что вы отправляете текстовое сообщение с командой на известный телефонный номер — и получаете случайное изображение Magic the Gathering.

Далее следует разбор принципов SOLID (не по порядку) в действии в процессе этого упражнения по программированию.

Весь код можно найти здесь. Позже мы ещё посмотрим, как применить этот код на вашем собственном аккаунте Slack и/или Twilio, если хотите.

Первый проход: «Магия» со Slack


Просто сам факт использования Spring Boot для создания приложения Magic сразу обеспечивает два из пяти принципов SOLID без специальных усилий с вашей стороны. Однако вы по-прежнему отвечаете за правильную архитектуру приложения.

Поскольку в процессе написания кода мы будем изучать разные принципы, вы можете посмотреть пример кода в любой момент, проверив соответствующие теги в проекте GitHub (вы найдёте их в разделе “Releases”). Полный код этой главы выводится по тегу slack-first-pass.

Посмотрим на код SlackController (все исходники Java здесь: magic-app/src/main/java/com/afitnerd/magic), который представляет пример принципов D и I в SOLID:

@RestController
@RequestMapping("/api/v1")
public class SlackController {
 
    @Autowired
    MagicCardService magicCardService;
 
    @Autowired
    SlackResponseService slackResponseService;
 
    @RequestMapping(
        value = "/slack", method = RequestMethod.POST,
        consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE
    )
    public @ResponseBody
    Map<String, Object> slack(@RequestBody SlackSlashCommand slackSlashCommand) throws IOException {
 
        return slackResponseService.getInChannelResponseWithImage(magicCardService.getRandomMagicCardImage());
    }
}

DIP: принцип инверсии зависимостей


Принцип DIP гласит:

A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

Б. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Java и Spring Boot предельно упрощают реализацию этого принципа. В SlackController *внедрён* сервис MagicCardService. Это *абстракция*, поскольку является интерфейсом Java. И поскольку это интерфейс, здесь нет деталей.

Реализация MagicCardService не зависит конкретно от SlackController. Позже мы увидим, как обеспечить такое разделение между интерфейсом и его реализацией, разбив приложение на модули. Дополнительно рассмотрим другие современные способы, как внедрять зависимости в Spring Boot.

ISP: принцип разделения интерфейса


Принцип ISP гласит:

Много отдельных клиентских интерфейсов лучше, чем один универсальный интерфейс.

В SlackController мы внедрили два отдельных интерфейса: MagicCardService и SlackResponseService. Один из них взаимодействует с сайтом Magic the Gathering. Другой взаимодействует со Slack. Создание единого интерфейса для выполнения этих двух отдельных функций нарушило бы принцип ISP.

Далее: «Магия» с Twilio


Для отслеживания кода из этой главы см. тег twilio-breaks-srp.

Посмотрим на код TwilioController:

@RestController
@RequestMapping("/api/v1")
public class TwilioController {
 
    private MagicCardService magicCardService;
 
    static final String MAGIC_COMMAND = "magic";
    static final String MAGIC_PROXY_PATH = "/magic_proxy";
 
    ObjectMapper mapper = new ObjectMapper();
 
    private static final Logger log = LoggerFactory.getLogger(TwilioController.class);
 
    public TwilioController(MagicCardService magicCardService) {
        this.magicCardService = magicCardService;
    }
 
    @RequestMapping(value = "/twilio", method = RequestMethod.POST, headers = "Accept=application/xml", produces=MediaType.APPLICATION_XML_VALUE)
    public TwilioResponse twilio(@ModelAttribute TwilioRequest command, HttpServletRequest req) throws IOException {
 
        log.debug(mapper.writeValueAsString(command));
 
        TwilioResponse response = new TwilioResponse();
        String body = (command.getBody() != null) ? command.getBody().trim().toLowerCase() : "";
 
        if (!MAGIC_COMMAND.equals(body)) {
            response
                .getMessage()
                .setBody("Send\n\n" + MAGIC_COMMAND + "\n\nto get a random Magic the Gathering card sent to you.");
            return response;
        }
 
        StringBuffer requestUrl = req.getRequestURL();
        String imageProxyUrl =
            requestUrl.substring(0, requestUrl.lastIndexOf("/")) +
            MAGIC_PROXY_PATH + "/" +
            magicCardService.getRandomMagicCardImageId();
        response.getMessage().setMedia(imageProxyUrl);
        return response;
    }
 
    @RequestMapping(value = MAGIC_PROXY_PATH + "/{card_id}", produces = MediaType.IMAGE_JPEG_VALUE)
    public byte[] magicProxy(@PathVariable("card_id") String cardId) throws IOException {
        return magicCardService.getRandomMagicCardBytes(cardId);
    }
}

Как упоминалось ранее, применим более современный подход к внедрению зависимости (лучшие практики). Как видите, мы сделали это с помощью Spring Boot Constructor Injection. Это просто красивый способ сказать, что в последней версии Spring Boot внедрение зависимости осуществляется следующим образом:

1. Установить одно или несколько скрытых полей в вашем классе, например:

private MagicCardService magicCardService;

2. Определить конструктор для установленных скрытых полей:

public TwilioController(MagicCardService magicCardService) {
    this.magicCardService = magicCardService;
}

Spring Boot автоматически обработает внедрение объекта во время выполнения. Преимущество в том, что здесь есть возможность запускать проверку ошибок и валидацию на внедрённом объекте внутри конструктора.

Контроллер содержит две части: /twilio и /magic_proxy/{card_id}. Путь magic_proxy требует небольшого пояснения, так что сначала разберём её, прежде чем говорить о нарушении принципа SRP.

Забавы с TwiML


TwiML — язык разметки Twilio Markup Language. Это основа всех ответов от Twilio, потому что TwiML представляет собой инструкции для Twilio. Одновременно это XML. Обычно такое не представляет проблемы. Однако URL'ы, которые возвращает сайт Magic the Gathering, представляет проблему для включения в документы TwiML.

URL, по которому извлекается картинка карты Magic the Gathering, выглядит примерно так:

http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&type=card

Обратите внимание на амперсанд (&) в URL. Есть только два валидных способа внедрить амперсанд в документы XML:

1. Escape-символы

<Response>
    <Message>
        <Body/>
        <Media>http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&​amp;type=card</Media>
    </Message>
</Response>

Здесь вместо амперсанда указан элемент &​amp;.

2. Фрагмент CDATA (символьные данные)

<Response>
    <Message>
        <Body/>
        <Media>
            <![CDATA[http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&type=card]]>
        </Media>
    </Message>
</Response>

Любой из этих вариантов легко реализовать на Java с расширением Jackson Dataformat XML в процессоре Jackson JSON, встроенном в Spring Boot.

Проблема в том, что первый вариант приводит к ошибке при получении изображения с сайта Wizards of the Coast (мейнтейнеры игры Magic the Gathering), а второй вариант не поддерживается в Twilio (эй Twilio: может быть, реализовать поддержку CDATA в TwiML?)

Я обошёл это ограничение с помощью прокси для запросов. В данном случае генерируется такой код TwiML:

<Response>
    <Message>
        <Body/>
        <Media>
            http://<my magic host>/api/v1/magic_proxy/144276
        </Media>
    </Message>
</Response>

При получении такого кода Twilio обращается к конечной точке /magic_proxy, а уже за сценой прокси получает картинку с сайта Magic the Gathering и выдаёт её.

Теперь продолжим изучение принципов SOLID.

SRP: принцип единственной ответственности


Принцип SRP гласит:

У класса должна быть только одна функция.

Вышеописанный контроллер работает как есть, но нарушает SRP, потому что отвечает и за возвращение ответа TwiML, и за прокси для картинок.

В данном примере это не слишком большая проблема, но несложно представить, как ситуация быстро выходит из-под контроля.

Если пройдёте по тегу twilio-fixes-srp, то увидите новый контроллер под названием MagicCardProxyController:

@RestController
@RequestMapping("/api/v1")
public class MagicCardProxyController {
 
    private MagicCardService magicCardService;
 
    public MagicCardProxyController(MagicCardService magicCardService) {
        this.magicCardService = magicCardService;
    }
 
    @RequestMapping(value = MAGIC_PROXY_PATH + "/{card_id}", produces = MediaType.IMAGE_JPEG_VALUE)
    public byte[] magicProxy(@PathVariable("card_id") String cardId) throws IOException {
        return magicCardService.getRandomMagicCardBytes(cardId);
    }
}

Его единственная задача — возвращать байты изображения, полученного на прокси с сайта Magic the Gathering.

Теперь единственная функция TwilioController — выдавать код TwiML.

Модули для реализации DIP


Maven позволяет легко разбить проект на модули. У них могут быть разные области (scopes), но есть одинаковые: компиляция (по умолчанию), выполнение и тест.

Области берут на себя управление, когда модули задействуются в данной области. Область runtime проверяет, что классы конкретного модуля *не* доступны во время компиляции. Они доступны только во время выполнения. Это помогает реализовать принцип DIP.

Проще показать на примере. Посмотрите код по тегу modules-ftw. Можно увидеть, что организация проекта радикально изменилась (как видно в IntelliJ):



Теперь здесь четыре модуля. Если посмотреть на модуль magic-app, то из pom.xml видно, как он полагается на другие модули:

<dependencies>
	...
    <dependency>
        <groupId>com.afitnerd</groupId>
        <artifactId>magic-config</artifactId>
    </dependency>
    <dependency>
        <groupId>com.afitnerd</groupId>
        <artifactId>magic-api</artifactId>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>com.afitnerd</groupId>
        <artifactId>magic-impl</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Заметьте, что magic-impl находится в области runtime, а magic-api — в области compile.

В TwilioController мы автоматически привязываемся к TwilioResponseService:

@RestController
@RequestMapping(API_PATH)
public class TwilioController {
 
    private TwilioResponseService twilioResponseService;
    …
}

А теперь посмотрите, что происходит, если попытаемся автоматически привязать реализованный класс таким образом:

@RestController
@RequestMapping(API_PATH)
public class TwilioController {
 
    private TwilioResponseServiceImpl twilioResponseService;
    …
}



IntelliJ не может найти класс TwilioResponseServiceImpl, потому что его *нет* в области compile.

По приколу можете попробовать удалить строку runtime из pom.xml — и увидите, что тогда IntelliJ радостно найдёт класс TwilioResponseServiceImpl.

Как мы убедились, модули maven в сочетании с областями (scopes) помогает реализовать принцип DIP.

Финишная прямая: рефакторинг Slack


Когда я написал это приложение в первый раз, то не думал о SOLID. Я просто хотел хакнуть приложение Slack, чтобы поиграться с функциональностью слэш-команд.

В первой версии все связанные со Slack сервисы и контроллеры просто выдавали Map<String, Object>. Это хороший трюк для приложений Spring Boot — выдавать любой ответ JSON, не беспокоясь о формальных моделях Java, представляющих структуру ответа.

По мере развития приложения возникло желание создать более формальные модели для читаемого и надёжного кода.

См. исходный код по тегу slack-violates-lsp.

Посмотрим на класс SlackResponse в модуле magic-api:

public abstract class SlackResponse {
 
    private List<Attachment> attachments = new ArrayList<>();
 
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    public List<Attachment> getAttachments() {
        return attachments;
    }
 
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public abstract String getText();
 
    @JsonProperty("response_type")
    public abstract String getResponseType();
 
    ...
}

Здесь мы видим, что в классе SlackResponse есть массив Attachments, текстовая строка и строка response_type.

SlackResponse объявил тип abstract, а функции реализации методов getText и getResponseType ложатся на дочерние классы.

Теперь взглянем на один из дочерних классов SlackInChannelImageResponse:

public class SlackInChannelImageResponse extends SlackResponse {
 
    public SlackInChannelImageResponse(String imageUrl) {
        getAttachments().add(new Attachment(imageUrl));
    }
 
    @Override
    public String getText() {
        return null;
    }
 
    @Override
    public String getResponseType() {
        return "in_channel";
    }
}

Метод getText() возвращает null. С таким ответом ответ будет содержать *только* изображение. Текст возвращается только в случае сообщения об ошибке. Тут *явно* пахнет LSP.

LSP: принцип подстановки Барбары Лисков


Принцип LSP гласит:

Объекты в программе должны иметь возможность замены на свои подтипы без изменения точности программы.

Когда вы имеете дело с иерархией наследования и дочерний класс *всегда* возвращает null, это явный признак нарушения принципа LSP. Потому что дочернему классу не нужен этот метод, но ему приходится реализовать его из-за интерфейса, описанного в родительском классе.

Посмотрите ветку master в проекте на GitHub. Там произведён рефакторинг иерархии SlackResponse для соответствия LSP.

public abstract class SlackResponse {
 
    @JsonProperty("response_type")
    public abstract String getResponseType();
}

Теперь единственное общее у всех дочерних классов, что они должны реализовать — это метод getResponseType().

В классе SlackInChannelImageResponse есть всё необходимое для правильного ответа с картинкой:

public class SlackInChannelImageResponse extends SlackResponse {
 
    private List<Attachment> attachments = new ArrayList<>();
 
    public SlackInChannelImageResponse(String imageUrl) {
        attachments.add(new Attachment(imageUrl));
    }
 
    public List<Attachment> getAttachments() {
        return attachments;
    }
 
    @Override
    public String getResponseType() {
        return "in_channel";
    }
    …
}

Больше не требуется никогда возвращать null.

Есть и другое небольшое улучшение: раньше у нас были некоторые аннотации JSON в классе SlackResponse: @JsonInclude(JsonInclude.Include.NON_EMPTY) и @JsonInclude(JsonInclude.Include.NON_NULL).

Они были нужны для гарантии, что в JSON не попадёт пустой массив аттачментов или текстовое поле с нулевым значением. Хотя это мощные аннотации, из-за них объекты нашей модели становятся хрупкими, а другим разработчикам может быть не ясно, что происходит.

OCP: принцип открытости/закрытости


Последний принцип, который мы рассмотрим в нашем путешествии по SOLID, это OCP.

Принцип OCP гласит:

Программные сущности … должны быть открыты для расширения, но закрыты для модификации.

Идея в том, что при изменении техзадания ваш код более эффективно справится с любыми новыми требованиями, если вы расширяете классы, а не добавляете код в существующие классы. Это помогает сдержать «расползание кода».

В вышеприведённом примере нет дополнительной причины изменять класс SlackResponse. Если мы хотим добавить в приложение поддержку других типов ответов Slack, то легко опишем эту специфику в подклассах.

Здесь опять проявляет себя сила Spring Boot. Взгляните на класс SlackResponseServiceImpl в модуле magic-impl.

@Service
public class SlackResponseServiceImpl implements SlackResponseService {
 
    MagicCardService magicCardService;
 
    public SlackResponseServiceImpl(MagicCardService magicCardService) {
        this.magicCardService = magicCardService;
    }
 
    @Override
    public SlackResponse getInChannelResponseWithImage() throws IOException {
        return new SlackInChannelImageResponse(magicCardService.getRandomMagicCardImageUrl());
    }
 
    @Override
    public SlackResponse getErrorResponse() {
        return new SlackErrorResponse();
    }
}

Согласно условиям интерфейса, методы getInChannelResponseWithImage и getErrorResponse возвращают объект SlackResponse.

Внутри этих методов создаются различные дочерние объекты SlackResponse. Spring Boot и его встроенный jackson-маппер для JSON достаточно умны, чтобы выдать правильный JSON для конкретного объекта, который характеризуется внутри.

Если хотите обеспечить интеграцию для своей собственной организации в Slack или реализовать поддержку для аккаунта Twilio (или и то, и другое), то читайте дальше! В противном случае можно перейти к резюме в конце статьи.

Развёртывание приложения


Если хотите на полную катушку использовать это приложение, то нужно правильно настроить Slack и Twilio после развёртывания приложения на Heroku.

Как вариант, можно установить или Slack, или Twilio. В любом случае, первым делом нужно развернуть приложение на Heroku. К счастью, это просто.

Развёртывание на Heroku


Проще всего развернуть приложение на Heroku — нажать дружелюбную фиолетовую кнопку в разделе README проекта GitHub. Вам понадобится указать две детали: BASE_URL и SLACK_TOKENS.

BASE_URL — это полный путь и название вашего приложения Heroku. Например, у меня приложение установлено здесь: https://random-magic-card.herokuapp.com. Придерживайтесь такого же формата при выборе названия приложения: https://<app name>.herokuapp.com.

Здесь есть своеобразная проблемка курицы и яйца, потому что приложению Heroku нужна некоторая информация из Slack, а для интеграции Slack нужна некоторая информация о приложении Heroku. Поначалу можно оставить значение по умолчанию в поле SLACK_TOKENS — позже мы вернёмся и обновим это значение настоящим токеном Slack API.

Можете проверить правильность установки, перейдя по адресу https://<app name>.herokuapp.com. Вы должны увидеть в браузере случайную карту Magic the Gathering. Если появляется ошибка, посмотрите журнал ошибок в веб-интерфейсе приложения Heroku. Вот пример веб-интерфейса в действии.

Настройка Slack


Перейдите по адресу https://api.slack.com/apps и нажмите кнопку Create New App для начала:



Введите название App Name и выберите рабочую среду Workspace, куда вы добавите приложение:



Далее нажмите на ссылку со слэш-командами Slash Commands слева, а там кнопку создания новой команды Create New Command:



Заполните значения для команды (например: /magic), Request URL (например: https://<your app name>.herokuapp.com/api/v1/slack) и короткого описания. Затем нажмите Save.



Теперь ваша слэш-команда Slack полностью настроена:



Перейдите в раздел Basic Information в левой панели и разверните на экране раздел Install app to your workspace section. Нажмите кнопку Install app to Workspace.



Затем кнопку для авторизации:



Прокрутите экран Basic Information, куда вы вернулись, и сделайте запись о токене верификации.



Если вы установили Heroku CLI, то корректно установить свойство SLACK_TOKENS можно такой командой:

heroku config:set \
SLACK_TOKENS=<comma separated tokens> \
--app <your heroku app name>

Как вариант, зайдите в панель мониторинга Heroku, перейдите к своему приложению и измените значение SLACK_TOKENS в настройках.

Теперь слэш-команда должна сработать на канале Slack вашей организации, и в ответ вы получите карту Magic the Gathering:



Настройка Twilio


Для настройки интеграции Twilio перейдите в панель мониторинга Twilio в консоли.



Нажмите на троеточие и выберите Programmable SMS:



Выберите Messaging Services:



Создайте новый сервис обмена сообщениями, нажав на кнопку с красным плюсом (или нажмите “Create new Messaging Service” если ещё никаких сервисов нет):



Введите Friendly Name, выберите Notifications, 2-Way в графе Use Case и нажмите кнопку Create:



Проверьте наличие галочки в Process Inbound Messages и введите Request URL для своего приложения Heroku (например, https://<your app name>.herokuapp.com/api/v1/twilio):



Нажмите кнопку Save для сохранения изменений.

Перейдите в раздел Numbers в левом меню и убедитесь, что для сервиса обмена сообщениями добавлен ваш номер Twilio:



Теперь вы можете протестировать службу Twilio, отправив на свой номер слово magic в виде текстового сообщения:



**Примечание:** Если отправить что-нибудь кроме слова magic (независимо от регистра), то выскочит сообщение об ошибке, показанное выше.

Резюме по SOLID


Ещё раз публикуем таблицу SOLID, на этот раз с тегами проекта Github, которые соответствуют каждому принципу:

  • S – SRP – Принцип единственной ответственности. Тег: twilio-fixes-srp. Разделяет контроллер TwilioController на две части, где у каждого контроллера только одна функция.
  • O – OCP – Принцип открытости/закрытости. Тег: master. Класс SlackResponse цельный и не подлежит изменению. Его можно расширить без изменения кода существующего сервиса.
  • L – LSP – Принцип подстановки Барбары Лисков. Тег: master. Никакой из дочерних классов SlackResponse не возвращает null, не содержит ненужных классов или аннотаций.
  • I – ISP – Принцип разделения интерфейса. Тег: slack-first-pass посредством master. Службы MagicCardService и SlackResponseService выполняют разные функции и поэтому отделены друг от друга.
  • D – DIP – Принцип инверсии зависимостей. Тег: slack-first-pass посредством master. Зависимые службы автоматически привязаны к контроллерам. Внедрение контроллера — это «лучшие практики» внедрения зависимости.

В разработке этого приложения есть некоторые сложности. Я уже говорил выше о проблеме с TwiML. Но со Slack возникают особые проблемы, которые я изложил в этой статье. TL;DR: Slack воспринимает для слэш-команд *только* POST-запросы application/x-www-form-urlencoded, а не более современные application/json. Из-за этого возникают сложности с обработкой входящих данных JSON со Spring Boot.

Основная идея в том, что принципы SOLID сделали код намного проще для работы и дальнейшего расширения.

На этом завершается наш обзор принципам SOLID. Надеюсь, он был полезнее, чем обычные простенькие примеры Java.
Tags:
Hubs:
+5
Comments 3
Comments Comments 3

Articles