Похоже, что в наши дни RESTful API существует абсолютно для всего. От платежей до бронирования столиков, от простых уведомлений до развёртывания виртуальных машин — почти всё доступно через простое HTTP-взаимодействие.
Если вы разрабатываете собственный сервис, то часто хотите обеспечить его работу одновременно на нескольких платформах. Проверенные временем принципы ООД (объектно-ориентированного дизайна) сделают ваш код более отказоустойчивым и упростят расширяемость.
В этой статье мы изучим один конкретный подход к проектированию, который называется SOLID (это акроним). Используем его на практике в написании сервиса с интеграцией Slack, а затем расширим для использования с Twilio.
Этот сервис высылает вам случайную карту Magic the Gathering. Если хотите проверить его в действии прямо сейчас, то отправьте слово magic на номер 1-929-236-9306 (только США и Канада — вы получите изображение по MMS, так что могут примениться тарифы вашего оператора). Также можете присоединиться к моей организации Slack, нажав здесь. После входа наберите: /magic.
Shape
, Circle
, Rectangle
, Area
я хотел бы показать преимущества SOLID в полнофункциональном приложении из реального мира.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());
}
}
A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
Б. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
SlackController
*внедрён* сервис MagicCardService
. Это *абстракция*, поскольку является интерфейсом Java. И поскольку это интерфейс, здесь нет деталей.MagicCardService
не зависит конкретно от SlackController
. Позже мы увидим, как обеспечить такое разделение между интерфейсом и его реализацией, разбив приложение на модули. Дополнительно рассмотрим другие современные способы, как внедрять зависимости в Spring Boot.Много отдельных клиентских интерфейсов лучше, чем один универсальный интерфейс.
SlackController
мы внедрили два отдельных интерфейса: MagicCardService
и SlackResponseService
. Один из них взаимодействует с сайтом Magic the Gathering. Другой взаимодействует со Slack. Создание единого интерфейса для выполнения этих двух отдельных функций нарушило бы принцип ISP.twilio-breaks-srp
.@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);
}
}
private MagicCardService magicCardService;
public TwilioController(MagicCardService magicCardService) {
this.magicCardService = magicCardService;
}
/twilio
и /magic_proxy/{card_id}
. Путь magic_proxy требует небольшого пояснения, так что сначала разберём её, прежде чем говорить о нарушении принципа SRP.http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&type=card
<Response>
<Message>
<Body/>
<Media>http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&?amp;type=card</Media>
</Message>
</Response>
&?amp;
.<Response>
<Message>
<Body/>
<Media>
<![CDATA[http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=144276&type=card]]>
</Media>
</Message>
</Response>
<Response>
<Message>
<Body/>
<Media>
http://<my magic host>/api/v1/magic_proxy/144276
</Media>
</Message>
</Response>
/magic_proxy
, а уже за сценой прокси получает картинку с сайта Magic the Gathering и выдаёт её.У класса должна быть только одна функция.
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);
}
}
TwilioController
— выдавать код TwiML.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;
…
}
compile
.runtime
из pom.xml
— и увидите, что тогда IntelliJ радостно найдёт класс TwilioResponseServiceImpl
.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.Объекты в программе должны иметь возможность замены на свои подтипы без изменения точности программы.
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
.SlackResponse
: @JsonInclude(JsonInclude.Include.NON_EMPTY)
и @JsonInclude(JsonInclude.Include.NON_NULL)
.Программные сущности … должны быть открыты для расширения, но закрыты для модификации.
SlackResponse
. Если мы хотим добавить в приложение поддержку других типов ответов Slack, то легко опишем эту специфику в подклассах.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 для конкретного объекта, который характеризуется внутри.BASE_URL
и SLACK_TOKENS
.BASE_URL
— это полный путь и название вашего приложения Heroku. Например, у меня приложение установлено здесь: https://random-magic-card.herokuapp.com. Придерживайтесь такого же формата при выборе названия приложения: https://<app name>.herokuapp.com
.SLACK_TOKENS
— позже мы вернёмся и обновим это значение настоящим токеном Slack API. https://<app name>.herokuapp.com
. Вы должны увидеть в браузере случайную карту Magic the Gathering. Если появляется ошибка, посмотрите журнал ошибок в веб-интерфейсе приложения Heroku. Вот пример веб-интерфейса в действии.Create New App
для начала:App Name
и выберите рабочую среду Workspace
, куда вы добавите приложение:Slash Commands
слева, а там кнопку создания новой команды Create New Command
:/magic
), Request URL
(например: https://<your app name>.herokuapp.com/api/v1/slack
) и короткого описания. Затем нажмите Save
.Basic Information
в левой панели и разверните на экране раздел Install app to your workspace section
. Нажмите кнопку Install app to Workspace
.Basic Information
, куда вы вернулись, и сделайте запись о токене верификации.SLACK_TOKENS
можно такой командой:heroku config:set SLACK_TOKENS=<comma separated tokens> --app <your heroku app name>
SLACK_TOKENS
в настройках.Programmable SMS
:Messaging Services
: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:magic
в виде текстового сообщения:magic
(независимо от регистра), то выскочит сообщение об ошибке, показанное выше.twilio-fixes-srp
. Разделяет контроллер TwilioController
на две части, где у каждого контроллера только одна функция.master
. Класс SlackResponse
цельный и не подлежит изменению. Его можно расширить без изменения кода существующего сервиса.master
. Никакой из дочерних классов SlackResponse
не возвращает null
, не содержит ненужных классов или аннотаций.slack-first-pass
посредством master
. Службы MagicCardService
и SlackResponseService
выполняют разные функции и поэтому отделены друг от друга.slack-first-pass
посредством master
. Зависимые службы автоматически привязаны к контроллерам. Внедрение контроллера — это «лучшие практики» внедрения зависимости.application/x-www-form-urlencoded
, а не более современные application/json
. Из-за этого возникают сложности с обработкой входящих данных JSON со Spring Boot.К сожалению, не доступен сервер mySQL