Как стать автором
Обновить

Как создать голосовой навык для Яндекс.Алисы, используя Spring Boot и Яндекс.Облако

Время на прочтение23 мин
Количество просмотров10K

В статье рассказывается, как разработать навык для платформы Яндекс.Диалоги, используя Java и фреймворк Spring Boot, а затем развернуть его в Яндекс.Облаке.

Для этого нужно:

  1. Написать код сервиса, взаимодействующего с платформой.

  2. Развернуть его в облачной инфраструктуре, подключив управляемую облаком (Managed Service) базу данных.

  3. Купить домен и настроить терминацию SSL.

  4. Опубликовать адрес сервиса на платформе.

Для примера описывается приложение, которое помогает запоминать английские слова. Алиса просит перевести русское или английское слово, а пользователь переводит это слово. Алиса проверяет, правильно ли слово переведено. Если перевод не верный, то Алиса говорит, как правильно перевести слово. Словарь хранится в СУБД.

Если вы знакомы с созданием приложений с использованием Spring Boot, то разделы про разработку можно пропустить и перейти к развёртыванию сервиса.

Как устроены Яндекс.Диалоги

API описано в разделе документации Яндекс.Диалогов. Платформа посылает POST-запросы на адрес бэкенда, который вы настраиваете в настройках навыка. В запросе передаётся фраза пользователя и некоторая служебная информация, которая помогает строить полноценный диалог и запоминать различные данные между сеансами общения.

В состав служебной информации входят:

  • идентификатор сессии;

  • информация о конечном устройстве;

  • идентификатор пользователя в Яндекс.Паспорте, если пользователь аутентифицирован.

Фраза пользователя передаётся не только как текст, но и разбирается платформой на отдельные типизированные токены, например, дата и время, число, адрес или имя и фамилию. Это сокращает время при разработке, так не нужно разбираться в тонкостях разбора естественных языков.

В ответ сервис навыка должен вернуть платформе фразу, которую произнесёт Алиса. Можно также указать ударения, подсказывая Алисе правильную интонацию.

Звучит довольно не сложно, так что можно начать писать сервис.

Пишем приложение на Sping Boot, используя TDD

TDD - Test Driven Development

TDD - одна из методик экстремального программирования, которая помогает писать надёжный и поддерживаемый код. Читатели, знакомые с ней, могут пропустить этот раздел. Ниже я коротко опишу суть метода, введённого Кентом Беком в 2003 году.

Какой подход используется в лоб, чтобы написать программу?

  1. Открываем свой любимый IDE.

  2. Пишем код с процедурой main.

  3. Компилируем, запускаем, вводим тестовые данные.

  4. Убеждаемся, что программа даёт ожидаемый ответ.

  5. Если программа сбоит, то правим программу, и повторяем действия с третьего по пятое.

Такими короткими повторениями обычно ведётся разработка. Разработчикам постоянно приходится повторять одинаковые действия при тестировании. Чем сложнее становится логика программы, тем больше тестов приходится проводить в ручную. А ведь некоторые тесты требуют подготовки тестовых данных и создания специальных предусловий!

Но мы же программисты и можем автоматизировать даже эту рутинную работу, и нам на помощь приходит автоматизированное тестирование. Циклы подготовки тестовых данных, вызовы нашей программы и проверку результатов можно занести в отдельную программу.

Методика рекомендует начать написание программы с теста, а затем циклы разработки будут выглядеть как Red, Green, Refactor.

  • Red - думаем о том, что нужно разработать. Пишем тест, который проверяет функционал. При первой проверке он падает, поэтому шаг называется Red.

  • Green - пишем код самым простым образом, чтобы тест работал.

  • Refactor - улучшаем наш код, чтобы он был лучше организован, но при этом тест работал.

После каждой такой итерации рекомендуется делать коммит. В результате, на каждом этапе мы имеем работающий код (он проверен автотестами) и не пишем лишний код, экономя своё время.

Проводя рефакторинг кода, разработчик может выделять абстракции и слои архитектуры и удалять дублирующийся код.

Шаблон проекта

Фреймфорк Spring Boot позволяет создать готовый проект с нуля, используя Sping Initializr.

За сборку проекта будет отвечать Maven. Из зависимостей потребуются:

  • Lombok - для уменьшения вспомогательного (boilerplate) кода.

  • Spring Web - для обработки HTTP-запросов.

  • Spring Data JPA - для доступа к СУБД.

  • PostgreSQL - драйвер к продуктивной СУБД.

  • H2 Database - драйвер для теста работы с СУБД.

  • Liquibase Migration - средство управления схемой базы данных.

После выбора зависимостей нажимаем кнопку "Generate" и скачиваем архив с шаблоном проекта. Архив распаковываем в локальную директорию. Открываем консоль и пробуем собрать.

cd C:\dev\dictionary-service
mvnw package

Проект падает с ошибкой.

Caused by: liquibase.exception.ChangeLogParseException: classpath:/db/changelog/db.changelog-master.yaml does not exist

[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR]   DictionaryServiceApplicationTests.contextLoads ? IllegalState Failed to load A...
[INFO]
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

Причина в том, что Liquibase в ресурсах приложения не нашёл журнал изменений /db/changelog/db.changelog-master.yaml, требуемый по умолчанию.

Заменим файл src/main/resources/application.properties на src/main/resources/application.yml и пропишем конфигурацию Liquibase.

spring:
  profiles: 
    default: local #профиль Spring по умолчанию
  datasource: #настройки соединения с СУБД по умолчанию
    driverClassName: org.postgresql.Driver 
    url: ${SPRING_DATASOURCE_URL} #будут опеределяться через переменную окружения
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}
  jpa:
    show-sql: false #по умолчанию отключим показ SQL
    hibernate.ddl-auto: validate #Hibernate будет только проверять схему 
    properties.hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
    open-in-view: false
  liquibase:
    enabled: true #включён по умолчанию
    contexts: schema, data #контексты по умолчанию
    change-log: classpath:db.changelog.xml #путь к журналу изменений Liquibase
    liquibaseSchema: liquibase #служебная схема СУБД для liquibase
    default-schema: dict #схема СУБД для приложения
    user: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}
---
spring:
  config.activate.on-profile: test #расширение основных настроек для тестового профиля 
  jpa:
    properties.hibernate.dialect: org.hibernate.dialect.H2Dialect #используем H2 для тестов
  datasource:
    driverClassName: org.h2.Driver
    url: jdbc:h2:mem:domain1-db;MODE=PostgreSQL;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;INIT=CREATE SCHEMA IF NOT EXISTS LIQUIBASE\;CREATE SCHEMA IF NOT EXISTS DICT\;SET SCHEMA DICT;
    username: sa
    password:
  liquibase:
    user: sa
    password:
    contexts: schema

Теперь в ресурсах (src/main/resources) создадим пустой журнал изменений db.changelog.xml.

<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.3.xsd">

</databaseChangeLog>

Чтобы в тестах использовался тестовый профиль, укажем его использования в тесте src/test/java/com/example/dictionary-service/DictionaryServiceApplicationTests.java

package com.example.dictionaryservice;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test") //активировать тестовый профиль Spring для тестов
class DictionaryServiceApplicationTests {
	@Test
	void contextLoads() {
	}
}

Теперь проект собирается. Можно инициализировать репозиторий git командой git init. После этого добавим в индекс git файл и выполним коммит.

git add *.cmd *.xml *.java *.yml *.jar *.properties .gitignore
git commit -m "Init repository"

Чтобы проект собирался на *nix системах, нужно ещё выставить права для запуска Shell-скрипта mnvw.

git update-index --chmod=+x mvnw 

Реализация API

Один из подходов к проектированию приложений является API First. Его преимущество в том, что разработчик фокусируется на реализации взаимодействия с приложением, а не инфраструктуре. Таким образом, мы избегаем зависимости логики приложения от деталей реализации, которые могут меняться.

В случае с платформой Яндекс.Диалоги API уже определено за нас. Можно сразу начинать писать код! И первым будет тест на API.

Когда вы просите Алису запустить навык, она делает POST-запрос к сервису, на который нужно ответить фразой, рассказывающей о навыке и как с ним общаться.

Создадим класс com.example.dictionaryservice.api.DictionaryApiTest в src/test/java

package com.example.dictionaryservice.api;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest //маркер теста
@ActiveProfiles("test") //профиль Spring для тестирования
@AutoConfigureMockMvc //автоконфигурация MockMvc
class DictionaryApiTest {
    @Autowired
    MockMvc mockMvc; //класс

    @Test
    @SneakyThrows
    void should_accept_request_from_yandex_alice_and_return_valid_response() {
        mockMvc.perform(
            post("/api/v1/dictionary/yandex-alice-skill/") //POST-запрос по указанному пути
            .contentType(MediaType.APPLICATION_JSON) 
            .accept(MediaType.APPLICATION_JSON)
            .content("{" //содержимое запроса от Яндекс.Алисы
                + "  \"meta\": {"
                + "  },"
                + "  \"request\": {"
                + "    \"command\": \"Алиса запусти навык переводчик\","
                + "    \"original_utterance\": \"алиса запусти навык переводчик\","
                + "    \"type\": \"SimpleUtterance\","
                + "    \"payload\": {},"
                + "    \"nlu\": {"
                + "      \"tokens\": ["
                + "        \"алиса\","
                + "        \"запусти\","
                + "        \"навык\","
                + "        \"переводчик\""
                + "      ]"
                + "    }"
                + "  },"
                + "  \"session\": {"
                + "    \"message_id\": 0,"
                + "    \"session_id\": \"2eac4854-fce721f3-b845abba-20d60\","
                + "    \"user\": {"
                + "      \"user_id\": \"47C73714B580EE\","
                + "      \"access_token\": \"AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT\""
                + "    },"
                + "    \"application\": {"
                + "      \"application_id\": \"47C73714B580ED\""
                + "    },"
                + "    \"new\": true"
                + "  },"
                + "  \"version\": \"1.0\""
                + "}")
        )
          .andExpect(status().isOk()) //проверить, что статус ответа - HTTP 200 OK
          .andExpect(content().json("{" //проверка содержимого ответа
                + "  \"response\": {"
                + "    \"text\": \"Привет! " 
                    + "Я помогу вам выучить английские слова.\","
                + "    \"end_session\": false"
                + "  },"
                + "  \"version\": \"1.0\""
                + "}"));
    }
}

Запускаем тест, и он падает с ошибкой.

[ERROR] Failures:
[ERROR]   DictionaryApiTest.should_accept_request_from_yandex_alice_and_return_valid_response:61 Status expected:<200> but was:<404>

Это вполне логично, так как мы не реализовали ни одного контроллера, который будет обрабатывать запросы. Мы сейчас на этапе Red процесса TDD.

Создадим класс com.example.dictionaryservice.api.DictionaryController в src/main/java.

package com.example.dictionaryservice.api;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController //контроллер обработки REST-запросов
@Slf4j //аннотация Lombok для журналирования через Slf4j
@RequestMapping("/api/v1/dictionary/yandex-alice-skill") //обрабатываемый путь
public class DictionaryController {
    @PostMapping //обработка POST-запросов
    public @ResponseBody /* Класс будет конвертирован в тело HTTP-ответа*/ YandexAliceResponse
        talkYandexAlice(
            @RequestBody /* Тело HTTP-запроса будет  сконвертировано в класс */ YandexAliceRequest request) {
        return new YandexAliceRespone(new YASkillResponse(
            "Привет! Я помогу вам выучить английские слова."));
    }
}

Теперь напишем код для DTO (Data Transfer Object), которые используются для вызовов API: YandexAliceRequest, YandexAliceRespone, YASkillResponse. Расположим их в пакете com.example.dictionaryservice.dto.

package com.example.dictionaryservice.dto;

import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;

@Data //автогенерация сеттеров, геттеров, hashCode, equals и toString
@NoArgsConstructor //генерация конструктора без аргументов 
                   // нужна для десериализации из JSON
@RequiredArgsConstructor //генерация конструктора для обязательных полей                  
@FieldDefaults(level = AccessLevel.PRIVATE) //по умолчанию доступ к членам - private
public class YandexAliceResponse {
    @NonNull //автопроверка аргументов на null
    YASkillResponse response;
    @NonNull
    String version = "1.0";
}
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class YASkillResponse {
    @NonNull
    String text;
 		//аннотация для правильной десериализации поля из JSON
    //так как в Java принята нотация CamelCase, а в API - snake_case
    @JsonProperty("end_session")                      
    boolean endSession = false;
}

Теперь можно ещё раз запустить тест, который отработает. Это фаза Green. Можно закоммитить новый код.

git add *.java
git commit -m "API test"

Попробуем запустить наше приложение.

mvnw spring-boot:run -Dspring-boot.run.profiles=test

Отправляем запрос

curl -H "Accept: application/json" -H "Content-Type: application/json" \ 
   http://localhost:8080/api/v1/dictionary/yandex-alice-skill -X POST -d "{}"

{"response":{"text":"Привет! Я помогу вам выучить английские слова.","end_session":false},"version":"1.0"}

Рефакторинг - отделяем слой бизнес-логики от API

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

package com.example.dictionaryservice.api;

import com.example.dictionaryservice.dto.YandexAliceRequest;
import com.example.dictionaryservice.dto.YandexAliceResponse;
import com.example.dictionaryservice.service.DictionaryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
@RequestMapping("/api/v1/dictionary/yandex-alice-skill")
@RequiredArgsConstructor //генерация конструктора для связывания dictionaryService
public class DictionaryController {
		//бизнес-логика реализовывается в сервисе
    private final DictionaryService dictionaryService;

    @PostMapping
    public @ResponseBody YandexAliceResponse talkYandexAlice(
        @RequestBody YandexAliceRequest request) {
        return dictionaryService.talkYandexAlice(request);
    }
}

Интерфейс DictionaryService создаём в пакете com.example.dictionaryservice.service

package com.example.dictionaryservice.service;

import com.example.dictionaryservice.dto.YandexAliceRequest;
import com.example.dictionaryservice.dto.YandexAliceResponse;

public interface DictionaryService {

    YandexAliceResponse talkYandexAlice(YandexAliceRequest request);
}

Реализация - в DictionaryServiceImpl в том же пакете.

package com.example.dictionaryservice.service;

import com.example.dictionaryservice.dto.YASkillResponse;
import com.example.dictionaryservice.dto.YandexAliceRequest;
import com.example.dictionaryservice.dto.YandexAliceResponse;
import org.springframework.stereotype.Service;

@Service //аннотация для автоматического связывания в Spring
public class DictionaryServiceImpl implements DictionaryService {

    @Override
    public YandexAliceResponse talkYandexAlice(YandexAliceRequest request) {
        return new YandexAliceResponse(new YASkillResponse(
            "Привет! Я помогу вам выучить английские слова."));
    }
}

Запускаем тест - он работает! Коммитим код. Теперь менять DictionaryController не придётся, так как API не меняется. Изменения бизнес-логики в сервисе на затрагивают API, что позволяет нам шаг за шагом добавлять новые функции.

Проверяем слова

Когда API готово, время написать основную бизнес-логику приложения. После приветствия навый сразу начнёт спрашивать слова. Диалог будет выглядеть так:

- Привет! Я помогу вам выучить английские слова. Как переводится на русский язык слово "objection"?

- Возражение

- Верно! Как переводится на английский язык слово "персик"?

- Apple

- Не верно. Слово "персик" по-английски будет "peach". Как переводится на английский язык слово "чипирование"?

И так далее. Приложению нужно иметь словарь и помнить, какое последнее слово было предложено для перевода пользователю. Словарь и сессии пользователей будут хранится в СУБД.

Пишем новый тест уже на логику приложения. Если пользователь начал новый диалог с Алисой, значит, надо его поприветствовать, предложить случайное слово для перевода и запомнить это слово. Создаём класс DictionaryServiceTest в пакете com.example.dictionaryservice.service.

package com.example.dictionaryservice.service;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.example.dictionaryservice.dto.YandexAliceRequest;
import com.example.dictionaryservice.dto.YandexAliceResponse;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test")
class DictionaryServiceTest {
    @Autowired
    DictionaryService sut; // sut = System Under Test
    private YandexAliceResponse response;
    private final String sessionId = "2eac4854-fce721f3-b845abba-20d60";
    @MockBean
    private SessionRepository sessionRepository;
    @MockBean
    private DictionaryRepository dictionaryRepository;

    @Test
    void should_greet_user_and_ask_to_translate_random_word_and_store_new_session_when_new_session_started() {
        // в хранилище нет сессии
        when(sessionRepository.findById(sessionId)).thenReturn(Optional.empty());
        // из хранилища получили случайное слово
        when(dictionaryRepository.findAll()).thenReturn(
            List.of(new TermEntity(Language.ENGLISH, "objection"))
        );

        response = sut.talkYandexAlice(
            new YandexAliceRequest(
                new YASkillRequest("Алиса, запусти навык переводчик"),
                new YASession(sessionId)));

        // проверяем, что Алиса предложила перевести слово
        assertEquals("Привет! Я помогу вам выучить английские слова. "
            + "Как переводится на русский язык слово objection?",
            response.getResponse().getText());
        // проверяем, что сервис запомнил слово
        verify(sessionRepository).save(
            new SessionEntity(sessionId, Language.ENGLISH, "objection"));
    }
}

Этот код не скомпилируется, так как много объектов из него мы ещё не написали. Но зато мы спроектировали понятный тест. Он следует паттерну ARRANGE-ACT-ASSERT: вначале готовим данные для тестирования, затем вызываем тестируемый объект и проверяем ожидаемые результаты. Через этот тест и начинаем проектировать наше приложение.

Для работы статических методов verify и when подключим библиотеку Mockito в pom.xml.

<dependencies>
  ...
  <dependency>
	  <groupId>org.mockito</groupId>
		<artifactId>mockito-core</artifactId>
		<scope>test</scope>
	</dependency>
</dependencies>

Определим сущности (entity) TermEntity и SessionEntity в пакете com.example.dictionaryservice.repository.entity.

package com.example.dictionaryservice.repository.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;

@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
@Entity(name = "term") //название таблицы
public class TermEntity {
    @Id // идентификатор
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @Enumerated(EnumType.STRING) //хранить в таблице, как строку
    @Column(nullable = false)
    Language language;

    @Column(nullable = false)
    String term;

    public enum Language {
        ENGLISH, RUSSIAN
    }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level =  AccessLevel.PRIVATE)
@Entity(name = "session")
public class SessionEntity {
    @Id
    @Column(nullable = false)
    String sessionId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    Language language;

    @Column(nullable = false)
    String term;
}

Пишем код для репозиториев SessionRepository и DictionaryRepository в пакете com.example.dictionaryservice.repository. Он совсем простой, так как уже реализован в Spring JPA.

package com.example.dictionaryservice.repository;

import com.example.dictionaryservice.repository.entity.SessionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SessionRepository extends JpaRepository<SessionEntity, String> {

}
package com.example.dictionaryservice.repository;

import com.example.dictionaryservice.repository.entity.TermEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface DictionaryRepository extends JpaRepository<TermEntity, Long> {
   
}

Чтобы работали тесты ещё, нужно и подготовить схему для СУБД. Добавим changeSet в журнал изменений Liquibase

<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.3.xsd">
    <changeSet id="1" author="author@example.com" context="schema">
        <sql>
            -- скрипты создания таблиц
            create table dict.term (
                id bigserial not null,
                language varchar(255) not null,
                term varchar(255) not null,
                constraint pk_term primary key (id)
            );
            create table dict.session (
                session_id varchar(255) not null,
                language varchar(255) not null,
                term varchar(255) not null,
                constraint pk_session primary key (session_id)
            );
        </sql>
        <rollback>
            -- скрипты удаления таблиц для отката
            drop table dict.session;
            drop table dict.term;
        </rollback>
    </changeSet>
</databaseChangeLog>

Теперь можно написать бизнес-логику в DictionaryServiceImpl

@Service
@Slf4j
@RequiredArgsConstructor
public class DictionaryServiceImpl implements DictionaryService {
    private final SessionRepository sessionRepository;
    private final DictionaryRepository dictionaryRepository;
    private final Random random = new Random();

    @Override
    public YandexAliceResponse talkYandexAlice(YandexAliceRequest request) {
        String responseText = "Привет! Я помогу вам выучить английские слова.";

        Objects.requireNonNull(request);
        Objects.requireNonNull(request.getRequest());
        Objects.requireNonNull(request.getRequest().getCommand());
        Objects.requireNonNull(request.getSession());
        String sessionId = request.getSession().getSessionId();
        Objects.requireNonNull(sessionId);

        if (sessionRepository.findById(sessionId).isEmpty()) {
            List<TermEntity> terms = dictionaryRepository.findAll();
            if (!terms.isEmpty()) {
                TermEntity term = terms.get(random.nextInt(terms.size()));
                responseText += String.format(" Как переводится на %s язык слово %s?",
                    Language.ENGLISH.equals(term.getLanguage()) ? "русский" : "английский",
                    term.getTerm()
                );

                sessionRepository.save(new SessionEntity(sessionId, term.getLanguage(), term.getTerm()));
            }
        }

        return new YandexAliceResponse(new YASkillResponse(responseText));
    }
}

Тест работает. Коммитим изменения.

Проверям перевод слова

Добавим проверку слова, которое перевёл пользователь. Снова начинаем с теста.

    @Test
    void should_check_term_translation_and_ask_to_translate_another_word_when_session_exists() {
        Long termId = 1001L;
        Long termTranslationId = 1002L;
        // в хранилище есть сессии
        when(sessionRepository.findById(sessionId)).thenReturn(Optional.of(new SessionEntity(sessionId, Language.ENGLISH, "objection")));
        when(dictionaryRepository.findTranslation(Language.ENGLISH.toString(), "objection")).thenReturn(
            Optional.of(new TermEntity(termId, Language.RUSSIAN, "возражение"))
        );
        when(dictionaryRepository.findAll()).thenReturn(
            List.of(new TermEntity(null, Language.RUSSIAN, "персик"))
        );

        response = sut.talkYandexAlice(
            new YandexAliceRequest(
                new YASkillRequest("возражение"),
                new YASession(sessionId)));

        // проверяем, что Алиса подтвердила правильность перевода и предложила перевести новое слово
        assertEquals("Верно! Как переводится на английский язык слово персик?",
            response.getResponse().getText());
        // проверяем, что сервис запомнил новое слово
        verify(sessionRepository).save(
            new SessionEntity(sessionId, Language.RUSSIAN, "персик"));
    }

Добавим новый метод в репозиторий DictionaryRepository.

@Repository
public interface DictionaryRepository extends JpaRepository<TermEntity, Long> {
    @Query(value = " select t1.* "
        + " from dict.term t1 "
        + " join dict.term_reference r1 on r1.term1_id = t1.id "
        + " join dict.term t2 on r1.term2_id = t2.id "
        + " where t2.term = :termToTranslate and t2.language = :language", nativeQuery = true)
    Optional<TermEntity> findTranslation(String language, String termToTranslate);
}

Доработаем логику в DictionaryServiceImpl.

@Service
@Slf4j
@RequiredArgsConstructor
public class DictionaryServiceImpl implements DictionaryService {
    private final SessionRepository sessionRepository;
    private final DictionaryRepository dictionaryRepository;
    private final Random random = new Random();

    @Override
    public YandexAliceResponse talkYandexAlice(YandexAliceRequest request) {
        String responseText = "Привет! Я помогу вам выучить английские слова.";

        Objects.requireNonNull(request);
        Objects.requireNonNull(request.getRequest());
        Objects.requireNonNull(request.getRequest().getCommand());
        Objects.requireNonNull(request.getSession());
        String sessionId = request.getSession().getSessionId();
        Objects.requireNonNull(sessionId);

        Optional<SessionEntity> session = sessionRepository.findById(sessionId);
        if (session.isPresent()) {
            Optional<TermEntity> translation = dictionaryRepository.findTranslation(session.get().getLanguage().toString(), session.get().getTerm());
            if (translation.isPresent()) {
                if (translation.get().getTerm().equalsIgnoreCase(request.getRequest().getCommand())) {
                    responseText = "Верно!";
                } else {
                    responseText = "Не верно!";
                }
            }
        }
        List<TermEntity> terms = dictionaryRepository.findAll();
        if (!terms.isEmpty()) {
            TermEntity term = terms.get(random.nextInt(terms.size()));
            responseText += String.format(" Как переводится на %s язык слово %s?",
                Language.ENGLISH.equals(term.getLanguage()) ? "русский" : "английский",
                term.getTerm()
            );

            sessionRepository.save(new SessionEntity(sessionId, term.getLanguage(), term.getTerm()));
        }

        return new YandexAliceResponse(new YASkillResponse(responseText));
    }
}

Тест работает - делаем коммит.

Добавим в журнал изменений Liquibase таблицу связей и инициализацию данных.

    <changeSet id="2" author="author@example.com" context="schema">
        <sql>
            create table dict.term_reference (
                id bigserial not null,
                term1_id bigint not null,
                term2_id bigint not null,
                constraint pk_term_reference primary key (id)
            );
        </sql>
        <rollback>
            drop table dict.term_reference;
        </rollback>
    </changeSet>
    <changeSet id="3" author="author@example.com" context="data">
        <sql>
            insert into dict.term values (default, 'ENGLISH','peach');
            insert into dict.term values (default, 'RUSSIAN','персик');
            insert into dict.term_reference (term1_id, term2_id)
            select t1.id, t2.id
            from dict.term t1
            cross join dict.term t2
            where t1.term = 'peach' and t2.term = 'персик'
            ;
            insert into dict.term_reference (term1_id, term2_id)
            select t1.id, t2.id
            from dict.term t1
            cross join dict.term t2
            where t2.term = 'peach' and t1.term = 'персик'
            ;
        </sql>
        <rollback>
            delete from dict.term_reference;
            delete from dict.term;
        </rollback>
    </changeSet>

Можно собрать приложение и развернуть его в облачной платформе.

mvnw install

Развёртывание в Яндекс.Облаке

Для развёртывания потребуется сервер приложений и управляемый облаком сервер СУБД PostgreSQL. Для экспериментов подойдут минимальные конфигурации.

Сервер приложений

Платформа

Intel Cascase Lake

vCPU

2

Гарантированная доля vCPU

5%

RAM

2GB

OS

CentOS 8

HDD

20GB

Сервер PostgreSQL

Тип

burstable

Размер

b2.nano (2 vCPU, 5% vCPU rate, 2 GB RAM)

HDD

10 GB network-hdd

Настройка сервера приложений

После создания виртуальной машины логинимся на неё через SSH по публичному IP-адресу. Нужно установить JVM 11, как среду выполнения приложения Spring Boot, Nginx для терминации SSL и настроить сервис.

Созданная виртуальная машина
Созданная виртуальная машина

Копируем через SCP Spring Boot Jar.

scp -i C:\path\to\private_key C:\app\target\dictionary-service-0.0.1-SNAPSHOT.jar user@178.154.206.196:/home/user/app.jar
ssh -i C:\path\to\private_key user@178.154.206.196
#Так как CentOS закончил свою жизнь 31 декабря 2021, https://www.centos.org/centos-linux-eol/
#то требуется немного шаманства для работы менеджера пакетов yum

sudo sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-Linux-*
sudo sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-Linux-*

# Устанавливаем Java 11
sudo dnf install epel-release
sudo yum -y install java-11-openjdk-headless.x86_64
# Устанавливаем Nginx для проксирования запросов и терминации SSL
sudo yum -y install nginx
# полезные утилиты для работы
sudo yum -y install vim
sudo yum -y install wget
# Клиент PostgreSQL для проверки соединения с СУБД
sudo yum -y install postgresql

#создаём пользователя и рабочую директорию для приложения
sudo useradd app
sudo mkdir /opt/app
sudo cp /home/user/app.jar /opt/app/app.jar
sudo chown -R app:app /opt/app
sudo chmod -R 0500 /opt/app

#пробуем запустить сервис
java -Dspring.profiles.active=test -jar app.jar &
#отправляем тестовый запрос
curl -X POST http://localhost:8080/api/v1/dictionary/yandex-alice-skill -H 'Content-Type: application/json' -d '{"request":{"command":"test"},"session":{"session_id":"id"}}'
{"response":{"text":"Привет! Я помогу вам выучить английские слова.","end_session":false},"version":"1.0"}

#Успешно! Останавливаем Java.
fg
#Ctrl+C

Установка соединения с СУБД

Скопируем название сервера СУБД из консоли Яндекс.Облака.

Консоль Managed Service for PostgreSQL
Консоль Managed Service for PostgreSQL
#ssh -i C:\path\to\private_key user@178.154.206.196

psql -h your-db-host-name.mdb.yandexcloud.net -p 6432 -u your-db-user your-db-name
#Создадим схемы
app=> create schema liquibase;
CREATE SCHEMA
app=> create schema dict;
CREATE SCHEMA
app => \q #Ctrl+D

#Попробуем запустить приложение с настройками БД под пользователем app
sudo -s
sudo -s -u app

# скачиваем сертификат Яндекса, чтобы можно было подключаться к БД через SSL
# https://cloud.yandex.ru/docs/managed-postgresql/operations/connect
mkdir -p /home/app/.postgresql
wget "https://storage.yandexcloud.net/cloud-certs/CA.pem" -O ~/.postgresql/root.crt
chmod 0600 ~/.postgresql/root.crt
cd /opt/app/

export "SPRING_DATASOURCE_URL=jdbc:postgresql://your-db-host-name.mdb.yandexcloud.net:6432/your-db-name?targetServerType=master&ssl=true"
export SPRING_DATASOURCE_USERNAME=your-db-user
export SPRING_DATASOURCE_PASSWORD=your-db-user-password

java -Dspring.jpa.properties.hibernate.default_schema=dict -jar app.jar

2022-02-13 15:48:38.202  INFO 18807 --- [main] liquibase.lockservice  : Successfully acquired change log lock
2022-02-13 15:48:44.043  INFO 18807 --- [main] liquibase.changelog    : Creating database history table with name: liquibase.databasechangelog
2022-02-13 15:48:44.229  INFO 18807 --- [main] liquibase.changelog    : Reading from liquibase.databasechangelog
2022-02-13 15:48:44.749  INFO 18807 --- [main] liquibase.changelog    : Custom SQL executed
2022-02-13 15:48:44.786  INFO 18807 --- [main] liquibase.changelog    : ChangeSet db.changelog.xml::1::author@example.com ran successfully in 259ms
2022-02-13 15:48:44.983  INFO 18807 --- [main] liquibase.changelog    : Custom SQL executed
2022-02-13 15:48:45.048  INFO 18807 --- [main] liquibase.changelog    : ChangeSet db.changelog.xml::2::author@example.com ran successfully in 167ms
2022-02-13 15:48:45.357  INFO 18807 --- [main] liquibase.changelog    : Custom SQL executed
2022-02-13 15:48:45.375  INFO 18807 --- [main] liquibase.changelog    : ChangeSet db.changelog.xml::3::author@example.com ran successfully in 247ms
2022-02-13 15:48:45.536  INFO 18807 --- [main] liquibase.lockservice  : Successfully released change log lock

# К СУБД подключились успешно, нажимаем Ctrl+C для остановки приложения

Создадим сервис для приложения, создав файл /etc/systemd/system/dict.service со следующим содержимым.

[Unit]
Description=Dictionary Spring Boot Application
After=syslog.target network.target

[Service]
User=app
Environment=SPRING_DATASOURCE_URL=jdbc:postgresql://your-db-host-name.mdb.yandexcloud.net:6432/your-db-name?targetServerType=master&ssl=true
Environment=SPRING_DATASOURCE_USERNAME=your-db-user
Environment=SPRING_DATASOURCE_PASSWORD=your-db-user-password
WorkingDirectory=/opt/app
ExecStart=java -Dspring.jpa.properties.hibernate.default_schema=dict -jar app.jar
SuccessExitStatus=143

[Install]
WantedBy=multi-user.target

Сохраняем файл и перезапускаем системную службу.

sudo systemctl daemon-reload
# Включаем сервис приложения
sudo systemctl enable dict
# Запускаем сервис
sudo systemctl start dict
# Проверяем статус сервиса 
sudo systemctl status dict

dict.service - Dictionary Spring Boot Application
   Loaded: loaded (/etc/systemd/system/dict.service; enabled; vendor preset: disabled)
   Active: active (running) since Sun 2022-02-13 17:27:06 UTC; 1min 24s ago
 Main PID: 19448 (java)
    Tasks: 24 (limit: 11209)
   Memory: 285.8M
   CGroup: /system.slice/dict.service
           └─19448 /usr/bin/java -Dspring.jpa.properties.hibernate.default_schema=dict -jar app.jar

Терминация SSL

Сервис доступен через Интернет по порту 8080: можно запустить команду curl с указанием публичного IP-адреса и проверить это. Но доступа через HTTP по 8080 недостаточно. Яндекс.Диалоги требуют работы через HTTPS, так как незашифрованный трафик HTTP может перехватываться и подменяться, что небезопасно. Для работы сервиса через HTTPS требуется выпустить сертификат, например, на Let's Encrypt.

Для работы сервиса нужен домен. Купить его можно у разных регистраторов. После покупки домен привязывается к публичном IP-адресу виртуальной машины сервера приложений в консоли регистратора.

Для выпуска сертификата на Let's Encrypt нужен работающий HTTP сервер. Мы ранее устанавливали Nginx, и он запущен. Можно проверить это, зайдя через браузер, и увидеть страницу по умолчанию. Настроим в nginx перенаправление запросов с путём /api/v1 на порт 8080. Откроем конфигурацию sudo vim /etc/nginx/nginx.conf

        location / {
        }
        #добавляем секцию, где настраивается перенаправление на порт 8080
        location /api/v1/ {
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarder-Proto $scheme;
            proxy_pass http://127.0.0.1:8080/api/v1/;
        }

Перезапускаем nginx командой sudo systemctl restart nginx. Также надо разрешить http-серверу прокидывать соединения командой

sudo setsebool -P httpd_can_network_relay 1

После этого будет доступ к сервису через порт 80.

curl -X POST http://178.154.206.196/api/v1/dictionary/yandex-alice-skill -H "Content-Type: application/json" -d "{\"request\":{\"command\":\"peach\"},\"session\":{\"session_id\":\"id\"}}"
{"response":{"text":"Не верно! Как переводится на английский язык слово персик?","end_session":false},"version":"1.0"}

Можно закрыть сетевой порт 8080 для доступа из публичных сетей.

# Устанавливаем и включаем Firewall
sudo yum install -y firewalld
sudo systemctl enable firewalld
sudo systemctl start firewalld

#разрешим доступ по http и https
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-service=https --permanent

Теперь создадим сертификат на сайте Let's Encrypt с помощью CertBot. Действуйте по инструкции для CentOS 8: https://certbot.eff.org/instructions?ws=nginx&os=centosrhel8. CertBot самостоятельно внесёт настройки для Nginx.

Публикация в Яндекс.Диалогах

Для публикации нужно всего лишь указать адрес сервиса на платформе Яндекс.Диалоги.

Настройки навыка
Настройки навыка

Потестировать навык можно с Алисой в Яндекс.Браузере, если у вас нет под рукой устройств с поддержкой Алисы. После того, как вы убедились, что всё работает, можно отправлять на модерацию и затем опубликовать навык.

Дополнение

Код приложения доступен в GitHub. Там немного другая версия, так как навык был написан год назад, и появились другие версии библиотек.

Теги:
Хабы:
0
Комментарии4

Публикации

Изменить настройки темы

Истории

Работа

Java разработчик
356 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн