Веб-приложение на Kotlin + Spring Boot + Vue.js (дополнение)

Добрый день, дорогие обитатели Хабра!

Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.

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

Содержание

Настройка CI/CD (Heroku)

Рассмотрим реализацию непрерывной интеграции и доставки на примере облачной PaaS-платформы Heroku.

Первое, что нам необходимо сделать — разместить код приложения в репозитории на GitHub. Для того, чтобы в репозитории не оказалось ничего лишнего, рекомендую следующее содержание файла .gitignore:

.gitignore

*.class  # Help # backend/*.md  # Package Files # *.jar *.war *.ear  # Eclipse # .settings .project .classpath .studio target  # NetBeans # backend/nbproject/private/ backend/nbbuild/ backend/dist/ backend/nbdist/ backend/.nb-gradle/ backend/build/  # Apple #  .DS_Store  # Intellij # .idea *.iml *.log  # logback logback.out.xml  backend/src/main/resources/public/ backend/target backend/.mvn backend/mvnw frontend/dist/ frontend/node/ frontend/node_modules/ frontend/npm-debug.log frontend/target  !.mvn/wrapper/maven-wrapper.jar 

Важно: перед тем, как начать работу с Heroku, добавьте в корневую директорию файл с названием Procfile (без какого-либо расширения) со строкой:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar, где backend-0.0.1-SNAPSHOT.jar — имя собирающегося JAR-файла. И обязательно сделайте commit и push.

Примечание: также в корневую директорию можно добавить файл travis.yaml, чтобы сократить время сборки и развёртывания приложения на Heroku:

travis.yaml

language: java jdk: - oraclejdk8 script: mvn clean install jacoco:report coveralls:report cache:      directories:      - node_modules 

Затем:

#1 Зарегистрируйтесь на Heroku.

#2 Создайте новое приложение:

Создание нового приложения

#3 Heroku позволяет подключать к приложению дополнительные ресурсы, например, базу данных PostreSQL. Для того, чтобы сделать это, выполните: Application -> Resources -> Add-ons -> Heroku Postgres:

Heroku Postgres

#4 Выберите план:

Выбор плана

#5 Теперь вы можете увидеть подключённый ресурс:

Подключённый ресурс

#6 Посмотрите учётные данные, они понадобятся для настройки переменных окружения: Settings -> View Credentials:

View Credentials

#7 Настройте переменные окружения: Application -> Settings -> Reveal Config Vars:

Переменные окружения

#8 Задайте переменные окружения для подключения в следующем формате:

SPRING_DATASOURCE_URL = jdbc:postgresql://<i>hostname:port</i>/<i>db_name</i> SPRING_DATASOURCE_USERNAME = <i>username</i> SPRING_DATASOURCE_PASSWORD = <i>password</i> 

Как это выглядит

#9 Создайте все необходимые таблицы в новой базе данных.

#10 Файл application.properties, соотвественно, должен иметь примерно такой вид:

application.properties

spring.datasource.url=${SPRING_DATASOURCE_URL} spring.datasource.username=${SPRING_DATASOURCE_USERNAME} spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}  spring.jpa.generate-ddl=true spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults = false spring.jpa.database-platform=org.hibernate.dialect.PostgreSQL9Dialect spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true  

#11 Создайте новый пайплайн — Create new pipeline:

Create new pipeline

#12 Deployment method — GitHub (нажмите Connect to GitHub и следуйте инструкциям в новом окне).

#13 Активируйте автоматическое развёртывание — Enable Automatic Deploys:

Enable Automatic Deploys

#14 Manual Deploy — нажмите Deploy Branch для первого развёртывания. Прямо в браузере вы увидите вывод командной строки.

Manual Deploy

#15 Нажмите View после успешной сборки, чтобы открыть развёрнутое приложение:

View

Защита от ботов (reCAPTCHA)

Первый шаг для подключения проверки reCAPTCHA в нашем приложении — создание новой reCAPTCH’и в администраторской панели Google. Там создаём новый сайт (Add new site / Create) и устанавливаем следующие настройки:

Настройки reCAPTCHA

В разделе Domains стоит указать помимо адреса, по которому будет жить приложение, следует указать localhost, чтобы при отладке избежать неприятностей в виде невозможности авторизоваться в своём же приложении.

Бэкенд

Сохраним site key и secret key

site key / secret key

… чтобы потом присвоить их переменным окружения, а названия переменных, в свою очередь, присвоить новым свойствам application.properties:

google.recaptcha.key.site=${GOOGLE_RECAPTCHA_KEY_SITE} google.recaptcha.key.secret=${GOOGLE_RECAPTCHA_KEY_SECRET} 

Добавим новую зависимость в pom.xml для верификации на стороне Google reCAPTCHA-токенов, которые будет присылать нам клиент:

<dependency>      <groupId>com.mashape.unirest</groupId>      <artifactId>unirest-java</artifactId>      <version>1.4.9</version> </dependency> 

Теперь самое время обновить сущности, которые мы используем для авторизации и регистрации пользователей, добавив в них строковое поле для того самого reCAPTCHA-токена:

LoginUser.kt

import com.fasterxml.jackson.annotation.JsonProperty import java.io.Serializable  class LoginUser : Serializable {     @JsonProperty("username")    var username: String? = null     @JsonProperty("password")    var password: String? = null     @JsonProperty("recapctha_token")    var recaptchaToken: String? = null     constructor() {}     constructor(username: String, password: String, recaptchaToken: String) {        this.username = username        this.password = password        this.recaptchaToken = recaptchaToken    }     companion object {         private const val serialVersionUID = -1764970284520387975L    } } 

NewUser.kt

import com.fasterxml.jackson.annotation.JsonProperty import java.io.Serializable  class NewUser : Serializable {     @JsonProperty("username")    var username: String? = null     @JsonProperty("firstName")    var firstName: String? = null     @JsonProperty("lastName")    var lastName: String? = null     @JsonProperty("email")    var email: String? = null     @JsonProperty("password")    var password: String? = null     @JsonProperty("recapctha_token")    var recaptchaToken: String? = null     constructor() {}     constructor(username: String, firstName: String, lastName: String, email: String, password: String, recaptchaToken: String) {        this.username = username        this.firstName = firstName        this.lastName = lastName        this.email = email        this.password = password        this.recaptchaToken = recaptchaToken    }     companion object {         private const val serialVersionUID = -1764970284520387975L    } } 

Добавим небольшой сервис, который будет транслировать reCAPTCHA-токен специальному сервису Google и сообщать в ответ, прошёл ли токен верификацию:

ReCaptchaService.kt

import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.web.client.RestOperations import org.springframework.beans.factory.annotation.Autowired import com.mashape.unirest.http.HttpResponse import com.mashape.unirest.http.JsonNode import com.mashape.unirest.http.Unirest   @Service("captchaService") class ReCaptchaService {     val BASE_VERIFY_URL: String = "https://www.google.com/recaptcha/api/siteverify"     @Autowired    private val restTemplate: RestOperations? = null     @Value("\${google.recaptcha.key.site}")    lateinit var keySite: String     @Value("\${google.recaptcha.key.secret}")    lateinit var keySecret: String     fun validateCaptcha(token: String): Boolean {         val url: String = String.format(BASE_VERIFY_URL + "?secret=%s&response=%s", keySecret, token)         val jsonResponse: HttpResponse<JsonNode> = Unirest.get(url)                .header("accept", "application/json").queryString("apiKey", ~_~quot{quot~_~)                .asJson()         return (jsonResponse.getStatus() == 200)    }  } 

Этот сервис необходимо задействовать в контроллере регистрации и авторизации пользователей:

AuthController.kt

import com.kotlinspringvue.backend.service.ReCaptchaService  …   @Autowired lateinit var captchaService: ReCaptchaService  …   if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {      return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),      HttpStatus.BAD_REQUEST) } else [if]...  …   if (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) {      return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),      HttpStatus.BAD_REQUEST) } else... 

Фронтенд

Первым шагом установим и сохраним пакет reCAPTHA:

$ npm install --save vue-recaptcha

Затем подключим в скрипт в index.html:

<script src="https://www.google.com/recaptcha/api.js onload=vueRecaptchaApiLoaded&render=explicit" async defer></script> 

В любое свободное место на странице добавим саму капчу:

<vue-recaptcha      ref="recaptcha"      size="invisible"      :sitekey="sitekey"      @verify="onCapthcaVerified"      @expired="onCaptchaExpired" /> 

А кнопка целевого действия (авторзации или регистрации) теперь будет сначала вызывать метод валидации:

<b-button v-on:click="validateCaptcha" variant="primary">Login</b-button>

Добавим зависимость в компоненты:

import VueRecaptcha from 'vue-recaptcha'

Отредактируем export default:

 components: { VueRecaptcha }, … data() { … siteKey: <i>наш ключ сайта</i> … } 

И добавим новые методы:

  • validateCaptcha() — который вызывается кликом на кнопку
  • onCapthcaVerified(recaptchaToken) и onCaptchaExpired() — которые вызывает сама капча

Новые методы

 validateCaptcha() {      this.$refs.recaptcha.execute() },  onCapthcaVerified(recaptchaToken) {       AXIOS.post(`/auth/signin`, {'username': this.$data.username, 'password': this.$data.password, 'recapctha_token': recaptchaToken})      .then(response => {           this.$store.dispatch('login', {'token': response.data.accessToken, 'roles': response.data.authorities, 'username': response.data.username});           this.$router.push('/home')      }, error => {           this.showAlert(error.response.data.message);   })      .catch(e => {      console.log(e);             this.showAlert('Server error. Please, report this error website owners');   }) }, onCaptchaExpired() {    this.$refs.recaptcha.reset() } 

Результат

Отправка электронной почты

Рассмотрим возможность отправки писем нашим приложением через публичный почтовый сервер, например Google или Mail.ru.

Первым шагом, соотвественно, станет создание аккаунта на выбранном почтовом сервере, если его ещё нет.

Вторым шагом нам необходимо добавить следующие зависимости в pom.xml:

Зависимости

<dependency>      <groupId>org.springframework.boot</groupId>      <artifactId>spring-boot-starter-mail</artifactId> </dependency> <dependency>      <groupId>org.springframework.boot</groupId>      <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency>      <groupId>commons-io</groupId>      <artifactId>commons-io</artifactId>      <version>2.4</version> </dependency> 

Также необходимо добавить новые свойства в application.properties:

SMTP-свойства

spring.mail.host=${SMTP_MAIL_HOST} spring.mail.port=${SMTP_MAIL_PORT} spring.mail.username=${SMTP_MAIL_USERNAME} spring.mail.password=${SMTP_MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.ssl.enable=true spring.mail.properties.mail.smtp.connectiontimeout=5000 spring.mail.properties.mail.smtp.timeout=5000 spring.mail.properties.mail.smtp.writetimeout=5000 

Настройки SMTP можно уточнить тут: Google и Mail.ru

Создадим интерфейс, где объявим несколько методов:

  • Для отправки обычного текстового письма
  • Для отправки HTML-письма
  • Для отправки письма с использованием шаблона

EmailService.kt

package com.kotlinspringvue.backend.email  import org.springframework.mail.SimpleMailMessage  internal interface EmailService {     fun sendSimpleMessage(to: String,                           subject: String,                           text: String)      fun sendSimpleMessageUsingTemplate(to: String,                                        subject: String,                                        template: String,                                        params:MutableMap<String, Any>)      fun sendHtmlMessage(to: String,                         subject: String,                         htmlMsg: String) } 

Теперь создадим реализацию этого интерфейса — сервис отправки электронных писем:

EmailServiceImpl.kt

package com.kotlinspringvue.backend.email  import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.core.io.FileSystemResource import org.springframework.mail.MailException import org.springframework.mail.SimpleMailMessage import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Component import org.thymeleaf.spring5.SpringTemplateEngine import org.thymeleaf.context.Context import java.io.File import javax.mail.MessagingException import javax.mail.internet.MimeMessage import org.apache.commons.io.IOUtils import org.springframework.core.env.Environment  @Component class EmailServiceImpl : EmailService {      @Value("\${spring.mail.username}")     lateinit var sender: String      @Autowired     lateinit var environment: Environment      @Autowired     var emailSender: JavaMailSender? = null      @Autowired     lateinit var templateEngine: SpringTemplateEngine      override fun sendSimpleMessage(to: String, subject: String, text: String) {         try {             val message = SimpleMailMessage()             message.setTo(to)             message.setFrom(sender)             message.setSubject(subject)             message.setText(text)              emailSender!!.send(message)         } catch (exception: MailException) {             exception.printStackTrace()         }      }      override fun sendSimpleMessageUsingTemplate(to: String,                                                 subject: String,                                                 template: String,                                                 params:MutableMap<String, Any>) {         val message = emailSender!!.createMimeMessage()         val helper = MimeMessageHelper(message, true, "utf-8")         var context: Context = Context()         context.setVariables(params)         val html: String = templateEngine.process(template, context)          helper.setTo(to)         helper.setFrom(sender)         helper.setText(html, true)         helper.setSubject(subject)          emailSender!!.send(message)     }      override fun sendHtmlMessage(to: String, subject: String, htmlMsg: String) {         try {             val message = emailSender!!.createMimeMessage()             message.setContent(htmlMsg, "text/html")              val helper = MimeMessageHelper(message, false, "utf-8")              helper.setTo(to)             helper.setFrom(sender)             helper.setSubject(subject)              emailSender!!.send(message)         } catch (exception: MailException) {             exception.printStackTrace()         }     } } 

  • Мы используем автоматически конфигурируемый JavaMailSender от Spring для отправки писем
  • Отправка обычных писем предельно проста — необходимо только добавить текст в тело письма и отправить его
  • HTML-письма определяются как сообщения Mime Type, а их содержание — как text/html
  • Для обработки HTML-шаблона сообщения мы используем Spring Template Engine

Давайте же создадим простенький шаблон для письма с использованием фреймворка Thymeleaf, поместив его в src/main/resources/templates/:

emailTemplate.html

<!DOCTYPE html> <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head>    <meta charset="UTF-8">    <title>Hello</title> </head> <body style="font-family: Arial, Helvetica, sans-serif;"> <h3>Hello!</h3> <div style="margin-top: 20px; margin-bottom: 30px; margin-left: 20px;">    <p>Hello, dear: <b><span th:text="${addresseeName}"></span></b></p> </div> <div>    <img th:src="${signatureImage}" width="200px;"/> </div> </body> </html> 

Изменяющиеся элементы шаблона (в нашем случае — имя адресата и путь картинки для подписи) объявлены с помощью плейсхолдеров.

Теперь создадим или обновим контроллер, который будет посылать письма:

BackendController.kt

import com.kotlinspringvue.backend.email.EmailServiceImpl import com.kotlinspringvue.backend.web.response.ResponseMessage import org.springframework.beans.factory.annotation.Value import org.springframework.http.ResponseEntity import org.springframework.http.HttpStatus  …   @Autowired lateinit var emailService: EmailService  @Value("\${spring.mail.username}") lateinit var addressee: String  …   @GetMapping("/sendSimpleEmail") @PreAuthorize("hasRole('USER')") fun sendSimpleEmail(): ResponseEntity<*> {      try {           emailService.sendSimpleMessage(addressee, "Simple Email", "Hello! This is simple email")      } catch (e: Exception) {           return ResponseEntity(ResponseMessage("Error while sending message"),           HttpStatus.BAD_REQUEST)      }      return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) }  @GetMapping("/sendTemplateEmail") @PreAuthorize("hasRole('USER')") fun sendTemplateEmail(): ResponseEntity<*> {      try {           var params:MutableMap<String, Any> = mutableMapOf()           params["addresseeName"] = addressee           params["signatureImage"] = "https://coderlook.com/wp-content/uploads/2019/07/spring-by-pivotal.png"           emailService.sendSimpleMessageUsingTemplate(addressee, "Template Email", "emailTemplate", params)      } catch (e: Exception) {           return ResponseEntity(ResponseMessage("Error while sending message"),           HttpStatus.BAD_REQUEST)      }      return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) }  @GetMapping("/sendHtmlEmail") @PreAuthorize("hasRole('USER')") fun sendHtmlEmail(): ResponseEntity<*> {      try {           emailService.sendHtmlMessage(addressee, "HTML Email", "<h1>Hello!</h1><p>This is HTML email</p>")      } catch (e: Exception) {           return ResponseEntity(ResponseMessage("Error while sending message"),           HttpStatus.BAD_REQUEST)      }      return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) } 

Примечание: Чтобы убедиться, что всё работает, для начала будем посылать письма самим себе.

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

Email.vue

 <template>    <div id="email">        <b-button  v-on:click="sendSimpleMessage" variant="primary">Simple Email</b-button><br/>        <b-button  v-on:click="sendEmailUsingTemplate" variant="primary">Template Email</b-button><br/>        <b-button v-on:click="sendHTMLEmail" variant="primary">HTML Email</b-button><br/>        </div> </template>  <script> import {AXIOS} from './http-common' export default {    name: 'EmailPage',    data() {        return {            counter: 0,            username: '',            header: {'Authorization': 'Bearer ' + this.$store.getters.getToken}        }    },    methods: {        sendSimpleMessage() {            AXIOS.get('/sendSimpleEmail', { headers: this.$data.header })            .then(response => {                console.log(response);                alert("OK");            })            .catch(error => {                console.log('ERROR: ' + error.response.data);            })        },        sendEmailUsingTemplate() {            AXIOS.get('/sendTemplateEmail', { headers: this.$data.header })            .then(response => {                console.log(response);                alert("OK")            })            .catch(error => {                console.log('ERROR: ' + error.response.data);            })        },        sendHTMLEmail() {            AXIOS.get('/sendHtmlEmail', { headers: this.$data.header })            .then(response => {                console.log(response);                alert("OK")            })            .catch(error => {                console.log('ERROR: ' + error.response.data);            })        }    } } </script>  <style> #email {    margin-left: 38%;    margin-top: 50px; } button {    width: 150px; } </style> 

Примечание: не забудьте обновить router.js и добавить ссылку в панель навигации App.vue, если создаёте новый компонент.

Миграция на Gradle

Сразу уточню: считать ли этот пункт усовершенствованием, пусть каждый решает сам для своего проекта. Мы же просто рассмотрим, как это сделать.

Вообще, можно воспользоваться инструкцией Moving from Maven to Gradle in under 5 minutes, но вряд ли результат оправдает ожидания. Я бы порекомендовал всё-таки осуществлять миграцию вручную, это займёт не намного больше времени.

Первое, что нам нужно сделать — установить Gradle.

Затем нам необходимо выполнить следующий порядок действий для обоих подпроектов — backend и fronted:

#1 Удалить файлы Maven — pom.xml, .mvn.

#2 В каталоге подпроект выполнить gradle init и ответить на вопросы:

  • Select type of project to generate: basic
  • Select implementation language: Kotlin
  • Select build script DSL: Kotlin (раз уж мы пишем проект на Kotlin)

#3 Удалить settings.gradle.kts — этот файл нужен только для корневого проекта.

#4 Выполнить gradle wrapper.

Теперь обратимся к нашему корневому проекту. Для него нужно выполнить шаги 1, 2 и 4 описанные выше для под проектов — всё то же самое, кроме удаления settings.gradle.kts.

Конфигурация сборки для проекта backend будет выглядеть следующим образом:

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile  plugins {     id("org.springframework.boot") version "2.1.3.RELEASE"     id("io.spring.dependency-management") version "1.0.8.RELEASE"     kotlin("jvm") version "1.3.50"     kotlin("plugin.spring") version "1.3.50"     id("org.jetbrains.kotlin.plugin.jpa") version "1.3.50" }  group = "com.kotlin-spring-vue" version = "0.0.1-SNAPSHOT" java.sourceCompatibility = JavaVersion.VERSION_1_8  repositories {     mavenCentral()     maven {         url = uri("https://plugins.gradle.org/m2/")     } }  dependencies {     runtimeOnly(project(":frontend"))     implementation("org.springframework.boot:spring-boot-starter-actuator:2.1.3.RELEASE")     implementation("org.springframework.boot:spring-boot-starter-web:2.1.3.RELEASE")     implementation("org.springframework.boot:spring-boot-starter-data-jpa:2.1.3.RELEASE")     implementation("org.springframework.boot:spring-boot-starter-mail:2.1.3.RELEASE")     implementation("org.springframework.boot:spring-boot-starter-security:2.1.3.RELEASE")     implementation("org.postgresql:postgresql:42.2.5")     implementation("org.springframework.boot:spring-boot-starter-thymeleaf:2.1.3.RELEASE")     implementation("commons-io:commons-io:2.4")     implementation("io.jsonwebtoken:jjwt:0.9.0")     implementation("io.jsonwebtoken:jjwt-api:0.10.6")     implementation("com.mashape.unirest:unirest-java:1.4.9")     implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8")     runtimeOnly("org.springframework.boot:spring-boot-devtools:2.1.3.RELEASE")     implementation("org.jetbrains.kotlin:kotlin-reflect")     implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")     implementation("org.jetbrains.kotlin:kotlin-noarg:1.3.50")     testImplementation("org.springframework.boot:spring-boot-starter-test") {         exclude(group = "org.junit.vintage", module = "junit-vintage-engine")     } }  tasks.withType<KotlinCompile> {     kotlinOptions {         freeCompilerArgs = listOf("-Xjsr305=strict")         jvmTarget = "1.8"     } } 

  • Следует указать все необходимые плагины Kotlin и Spring
  • Не следует забывать про плагин org.jetbrains.kotlin.plugin.jpa — он необходим для подключения к базе данных
  • В зависимостях необходимо указать runtimeOnly(project(":frontend")) — нам нужно собирать проект frontend в первую очередь

Конфигурация сборки для проекта frontend:

build.gradle.kts

plugins {     id("org.siouan.frontend") version "1.2.1"     id("java") }  group = "com.kotlin-spring-vue" version = "0.0.1-SNAPSHOT"  java {     targetCompatibility = JavaVersion.VERSION_1_8 }  buildscript {     repositories {         mavenCentral()         maven {             url = uri("https://plugins.gradle.org/m2/")         }     } }  frontend {     nodeVersion.set("10.16.0")     cleanScript.set("run clean")     installScript.set("install")     assembleScript.set("run build") }  tasks.named("jar", Jar::class) {     dependsOn("assembleFrontend")     from("$buildDir/dist")     into("static") } 

  • В моём примере для сборки проекта используется плагин org.siouan.frontend
  • В разделе frontend {...} следует указать версию Node.js, а также команды, вызывающие скрипты очистки, установки и сборки, прописанные в package.json
  • Теперь мы упаковываем наш подпроект frontend в JAR-файл и используем его как зависимость (runtimeOnly(project(":frontend")) в backend‘е), так что нам необходимо описать задание (task), которое копирует файлы из сборочной директории в /public и создаёт JAR-файл

Примечание:

  • Отредактируйте vue.config.js, изменив директорию сборки на build/dist.
  • Укажите в файле package.json build scriptvue-cli-service build или убедитесь, что он указан

Файл settings.gradle.kts в корневом проекте должен содержать следующий код:…

rootProject.name = "demo"  include(":frontend", ":backend") 

… — название проекта и подпроектов.

И теперь мы можем собрать проект, выполнив команду: ./gradlew build

Примечание: если для плейсхолдеров, указанных в application.properties (например, ${SPRING_DATASOURCE_URL}) нет соответствующих переменных среды, сборка завершится неудачно. Чтобы этого избежать, следует использовать /gradlew build -x

Проверить структуру проектов можно с помощью команды gradle -q projects, результат должен выглядеть подобным образом:

Root project 'demo' +--- Project ':backend' \--- Project ':frontend'

И, наконец, чтобы запустить приложение, необходимо выполнить ./gradlew bootRun.

.gitignore

В файл .gitignore следует добавить следующие файлы и папки:

  • backend/build/
  • frontend/build/
  • build
  • .gradle

Важно: не следует добавлять файлы gradlew в .gitignore — ничего опасного в них нет, однако они нужны для успешной сборки на удалённом сервере.

Деплой на Heroku

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

#1 Procfile

Нам необходимо задать Heroku новые инструкции для запуска приложения:

web: java -Dserver.port=$PORT -jar backend/build/libs/backend-0.0.1-SNAPSHOT.jar

#2 Переменные среды

Heroku способна переделать тип приложения (например, приложение Spring Boot) и выполнять соотвествующие инструкции для сборки. Но наше приложение (корневой проект) не выглядит для Heroku как приложение Spring Boot. Если мы оставим всё, как есть, Heroku попросит нас определить таску stage. Честно говоря, не знаю, где заканчивается этот путь, потому что я по нему не шёл. Проще определить переменную GRADLE_TASK со значением build:

#3 reCAPTCHA

При размещении приложения в новом домене не забудьте обновить капчу, переменные среды GOOGLE_RECAPTCHA_KEY_SITE и GOOGLE_RECAPTCHA_KEY_SECRET, а также обновить Site Key в подпроекте фронтенда.

Хранение токена JWT в Cookies

Прежде всего, настоятельно рекомендую ознакомиться со статьей Please Stop Using Local Storage, особенно с разделом Why Local Storage is Insecure and You Shouldn’t Use it to Store Sensitive Data.

Давайте рассмотрим, как можно хранить токен JWT в более безопасном месте — куках в флагом httpOnly, где он будет недоступен для чтения/изменения с помощью JavaScript’а.

#1 Удаление всей логики, относящейся к JWT из фронтенда:
Поскольку с новым способом хранения токен всё равно не доступен для JavaScript’а, можно смело удалить все упоминания о нём из нашего подпроекта.

А вот роль пользователя без привязки к каким-либо другим данных — не столь не столь важная информация, её можно по-прежнему хранить в Local Storage и определять, авторизован пользователь или нет, в зависимости от того, определена ли эта роль.

store/index.js

 import Vue from 'vue'; import Vuex from 'vuex';  Vue.use(Vuex);  const state = {   role: localStorage.getItem('user-role') || '',   username: localStorage.getItem('user-name') || '',   authorities: localStorage.getItem('authorities') || '', };  const getters = {   isAuthenticated: state => {     if (state.role != null && state.role != '') {       return true;     } else {       return false;     }   },   isAdmin: state => {     if (state.role === 'admin') {       return true;     } else {       return false;     }   },   getUsername: state => {     return state.username;   },   getAuthorities: state => {     return state.authorities;   } };  const mutations = {   auth_login: (state, user) => {     localStorage.setItem('user-name', user.username);     localStorage.setItem('user-authorities', user.roles);     state.username = user.username;     state.authorities = user.roles;     var isUser = false;     var isAdmin = false;     for (var i = 0; i < user.roles.length; i++) {       if (user.roles[i].authority === 'ROLE_USER') {         isUser = true;       } else if (user.roles[i].authority === 'ROLE_ADMIN') {         isAdmin = true;       }     }     if (isUser) {       localStorage.setItem('user-role', 'user');       state.role = 'user';     }     if (isAdmin) {       localStorage.setItem('user-role', 'admin');       state.role = 'admin';     }   },   auth_logout: () => {     state.token = '';     state.role = '';     state.username = '';     state.authorities = [];     localStorage.removeItem('user-role');     localStorage.removeItem('user-name');     localStorage.removeItem('user-authorities');   } };  const actions = {   login: (context, user) => {     context.commit('auth_login', user)   },   logout: (context) => {     context.commit('auth_logout');   } };  export const store = new Vuex.Store({   state,   getters,   mutations,   actions }); 

Будьте осторожны при рефакторинге store/index.js: если авторизация и деавторизация не будут работать корректно, в консоль будут назойливо сыпаться сообщения об ошибках.

#2 Возвращение JWT как cookie в контроллере авторизации (не в теле ответа):

AuthController.kt

@Value("\${ksvg.app.authCookieName}")     lateinit var authCookieName: String  @Value("\${ksvg.app.isCookieSecure}")     var isCookieSecure: Boolean = true  @PostMapping("/signin")     fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser, response: HttpServletResponse): ResponseEntity<*> {          val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!)          if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {             return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),                     HttpStatus.BAD_REQUEST)         }         else if (userCandidate.isPresent) {             val user: User = userCandidate.get()             val authentication = authenticationManager.authenticate(                     UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password))             SecurityContextHolder.getContext().setAuthentication(authentication)             val jwt: String = jwtProvider.generateJwtToken(user.username!!)              val cookie: Cookie = Cookie(authCookieName, jwt)             cookie.maxAge = jwtProvider.jwtExpiration!!             cookie.secure = isCookieSecure             cookie.isHttpOnly = true             cookie.path = "/"             response.addCookie(cookie)              val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())             return ResponseEntity.ok(SuccessfulSigninResponse(user.username, authorities))         } else {             return ResponseEntity(ResponseMessage("User not found!"),                     HttpStatus.BAD_REQUEST)         }     } 

Важно: обратите внимание, что я поместил параметры authCookieName и isCookieSecure в application.properties — отправка куков с флагом secure возможна только по https, что делает крайне затруднительным отладку на localhost. НО в продакшене, конечно, лучше использовать куки с этим флагом.

Также ответов контроллера теперь целесообразно использовать сущность без специального поля для JWT.

#3 Обновление JwtAuthTokenFilter:
Раньше мы брали токен из заголовка запроса, теперь мы берём его из куки:

JwtAuthTokenFilter.kt

@Value("\${ksvg.app.authCookieName}")           lateinit var authCookieName: String  ...  private fun getJwt(request: HttpServletRequest): String? {      for (cookie in request.cookies) {           if (cookie.name == authCookieName) {                return cookie.value           }      }      return null } 

#4 Включение CORS
Если в предыдущей моей статье ещё можно было незаметно пропустить этот вопрос, то сейчас было бы странно защищать JWT-токен, так и не включив CORS на стороне бэкенда.

Исправить это можно отредактировав WebSecurityConfig.kt:

WebSecurityConfig.kt

@Bean fun corsConfigurationSource(): CorsConfigurationSource {      val configuration = CorsConfiguration()      configuration.allowedOrigins = Arrays.asList("http://localhost:8080", "http://localhost:8081", "https://kotlin-spring-vue-gradle-demo.herokuapp.com")      configuration.allowedHeaders = Arrays.asList("*")      configuration.allowedMethods = Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")      configuration.allowCredentials = true      configuration.maxAge = 3600      val source = UrlBasedCorsConfigurationSource()      source.registerCorsConfiguration("/**", configuration)      return source }  @Throws(Exception::class) override fun configure(http: HttpSecurity) {      http           .cors().and()           .csrf().disable().authorizeRequests()           .antMatchers("/**").permitAll()           .anyRequest().authenticated()           .and()           .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()           .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)       http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter::class.java)      http.headers().cacheControl().disable() } 

И теперь можно удалить все аннотации @CrossOrigin из контроллеров.

Важно: параметр AllowCredentials необходим для отправки запросов со стороны фронтенда. Подробнее об этом можно почитать здесь.

#5 Актуализация заголовков на стороне фронтенда:

http-commons.js

 export const AXIOS = axios.create({      baseURL: `/api`,      headers: {           'Access-Control-Allow-Origin': ['http://localhost:8080', 'http://localhost:8081', 'https://kotlin-spring-vue-gradle-demo.herokuapp.com'],           'Access-Control-Allow-Methods': 'GET,POST,DELETE,PUT,OPTIONS',           'Access-Control-Allow-Headers': '*',           'Access-Control-Allow-Credentials': true      } }) 

Проверка

Давайте попробуем авторизоваться в приложении, зайдя с хоста, не входящего в список разрешённых в WebSecurityConfig.kt. Для этого запустим бэкенд на порту 8080, а фронтенд, например, на 8082 и попробуем авторизоваться:

Результат

Запрос авторизации отклонён политикой CORS.

Теперь давайте посмотрим, как вообще работаю куки с флагом httpOnly. Для этого зайдём, например, на сайт https://kotlinlang.org и выполним в консоли браузера:

document.cookie 

Результат

В консоли появятся не-httpOnly куки, относящиеся к этому сайту, которые, как мы видим, доступны через JavaScript.

Теперь зайдём в наше приложение, авторизуемся (чтобы браузер сохранил куку с JWT) и повторим то же самое:

Результат

Примечание: такой способ хранения JWT-токена является более надёжным, чем с использованием Local Storage, но стоит понимать, что он не является панацеей.

Подтверждение регистрации по электронной почте

Краткий алгоритм выполнения этой задачи таков:

  1. Для всех новых пользователей атрибуту isEnabled в базе данных присваивается значение false
  2. Из произвольных символов генерируется строковый токен, который будет служить ключом для подтверждения регистрации
  3. Токен отправляется пользователю на почте как часть ссылки
  4. Атрибут isEnabled принимает значение true, если пользователь переходит по ссылке в течение установленного периода времени

Теперь рассмотрим этот процесс более детально.

Нам понадобится таблица для хранения токен для подтверждения регистрации:

CREATE TABLE public.verification_token (      id serial NOT NULL,      token character varying,      expiry_date timestamp without time zone,      user_id integer,      PRIMARY KEY (id) );  ALTER TABLE public.verification_token ADD CONSTRAINT verification_token_users_fk FOREIGN KEY (user_id) REFERENCES public.users (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE; 

И, соотвественно, новая сущность для объектно-реляционного отображения…:

VerificationToken.kt

package com.kotlinspringvue.backend.jpa  import java.sql.* import javax.persistence.* import java.util.Calendar  @Entity @Table(name = "verification_token") data class VerificationToken(          @Id         @GeneratedValue(strategy = GenerationType.AUTO)         val id: Long? = 0,          @Column(name = "token")         var token: String? = null,          @Column(name = "expiry_date")         val expiryDate: Date,          @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST])         @JoinColumn(nullable = false, name = "user_id")         val user: User ) {     constructor(token: String?, user: User) : this(0, token, calculateExpiryDate(1440), user) }  private fun calculateExpiryDate(expiryTimeInMinutes: Int): Date {     val cal = Calendar.getInstance()     cal.time = Timestamp(cal.time.time)     cal.add(Calendar.MINUTE, expiryTimeInMinutes)     return Date(cal.time.time) } 

… и репозиторий:

VerificationTokenRepository.kt

package com.kotlinspringvue.backend.repository  import com.kotlinspringvue.backend.jpa.VerificationToken import org.springframework.data.jpa.repository.JpaRepository import java.util.*  interface VerificationTokenRepository : JpaRepository<VerificationToken, Long> {     fun findByToken(token: String): Optional<VerificationToken> } 

Теперь нам нужно реализовать средства для управления токенами — создания, верификации и отправки по электронной почте. Для этого модифицируем UserDetailsServiceImpl, добавив методы для создания и верификации токена:

UserDetailsServiceImpl.kt

override fun createVerificationTokenForUser(token: String, user: User) {      tokenRepository.save(VerificationToken(token, user)) }  override fun validateVerificationToken(token: String): String {      val verificationToken: Optional<VerificationToken> = tokenRepository.findByToken(token)       if (verificationToken.isPresent) {           val user: User = verificationToken.get().user           val cal: Calendar = Calendar.getInstance()           if ((verificationToken.get().expiryDate.time - cal.time.time) <= 0) {                tokenRepository.delete(verificationToken.get())                return TOKEN_EXPIRED           }            user.enabled = true           tokenRepository.delete(verificationToken.get())           userRepository.save(user)           return TOKEN_VALID      } else {           return TOKEN_INVALID      } } 

Теперь добавим метод для отправки письма с ссылкой для подтверждения в EmailServiceImpl:

EmailServiceImpl.kt

    @Value("\${host.url}")     lateinit var hostUrl: String      @Autowired     lateinit var userDetailsService: UserDetailsServiceImpl  ...      override fun sendRegistrationConfirmationEmail(user: User) {         val token = UUID.randomUUID().toString()         userDetailsService.createVerificationTokenForUser(token, user)         val link = "$hostUrl/?token=$token&confirmRegistration=true"         val msg = "<p>Please, follow the link to complete your registration:</p><p><a href=\"$link\">$link</a></p>"         user.email?.let{sendHtmlMessage(user.email!!, "KSVG APP: Registration Confirmation", msg)}     } 

Примечание:

  • Я бы рекомендовал хранить URL хоста в application.properties
  • В нашей ссылке мы передаём два GET-параметра (token и confirmRegistration) на адрес, где развёрнуто приложение. Чуть позже я объясню, для чего.

Модифицируем контроллер регистрации следующим образом:

  • Всем новым пользователям будем выставлять значение false для поля isEnabled
  • После создания нового аккаунта будем отправлять электронное письмо для подтверждения регистрации
  • Создадим отдельный контроллер для валидация токена
  • Важно: при авторизации будем проверять, подтверждена ли учётная запись:

AuthController.kt

package com.kotlinspringvue.backend.controller  import com.kotlinspringvue.backend.email.EmailService import javax.validation.Valid import java.util.* import java.util.stream.Collectors  import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.ui.Model  import com.kotlinspringvue.backend.model.LoginUser import com.kotlinspringvue.backend.model.NewUser import com.kotlinspringvue.backend.web.response.SuccessfulSigninResponse import com.kotlinspringvue.backend.web.response.ResponseMessage import com.kotlinspringvue.backend.jpa.User import com.kotlinspringvue.backend.repository.UserRepository import com.kotlinspringvue.backend.repository.RoleRepository import com.kotlinspringvue.backend.jwt.JwtProvider import com.kotlinspringvue.backend.service.ReCaptchaService import com.kotlinspringvue.backend.service.UserDetailsService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.web.bind.annotation.* import org.springframework.web.context.request.WebRequest import java.io.UnsupportedEncodingException import javax.servlet.http.Cookie import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_VALID import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_INVALID import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_EXPIRED  @RestController @RequestMapping("/api/auth") class AuthController() {      @Value("\${ksvg.app.authCookieName}")     lateinit var authCookieName: String      @Value("\${ksvg.app.isCookieSecure}")     var isCookieSecure: Boolean = true      @Autowired     lateinit var authenticationManager: AuthenticationManager      @Autowired     lateinit var userRepository: UserRepository      @Autowired     lateinit var roleRepository: RoleRepository      @Autowired     lateinit var encoder: PasswordEncoder      @Autowired     lateinit var jwtProvider: JwtProvider      @Autowired     lateinit var captchaService: ReCaptchaService      @Autowired     lateinit var userService: UserDetailsService      @Autowired     lateinit var emailService: EmailService      @PostMapping("/signin")     fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser, response: HttpServletResponse): ResponseEntity<*> {          val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!)          if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {             return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),                     HttpStatus.BAD_REQUEST)         }         else if (userCandidate.isPresent) {             val user: User = userCandidate.get()              if (!user.enabled) {                 return ResponseEntity(ResponseMessage("Account is not verified yet! Please, follow the link in the confirmation email."),                         HttpStatus.UNAUTHORIZED)             }              val authentication = authenticationManager.authenticate(                     UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password))             SecurityContextHolder.getContext().setAuthentication(authentication)             val jwt: String = jwtProvider.generateJwtToken(user.username!!)              val cookie: Cookie = Cookie(authCookieName, jwt)             cookie.maxAge = jwtProvider.jwtExpiration!!             cookie.secure = isCookieSecure             cookie.isHttpOnly = true             cookie.path = "/"             response.addCookie(cookie)              val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())             return ResponseEntity.ok(SuccessfulSigninResponse(user.username, authorities))         } else {             return ResponseEntity(ResponseMessage("User not found!"),                     HttpStatus.BAD_REQUEST)         }     }      @PostMapping("/signup")     fun registerUser(@Valid @RequestBody newUser: NewUser): ResponseEntity<*> {          val userCandidate: Optional <User> = userRepository.findByUsername(newUser.username!!)          if (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) {             return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),                     HttpStatus.BAD_REQUEST)         } else if (!userCandidate.isPresent) {             if (usernameExists(newUser.username!!)) {                 return ResponseEntity(ResponseMessage("Username is already taken!"),                         HttpStatus.BAD_REQUEST)             } else if (emailExists(newUser.email!!)) {                 return ResponseEntity(ResponseMessage("Email is already in use!"),                         HttpStatus.BAD_REQUEST)             }              try {                 // Creating user's account                 val user = User(                         0,                         newUser.username!!,                         newUser.firstName!!,                         newUser.lastName!!,                         newUser.email!!,                         encoder.encode(newUser.password),                         false                 )                 user.roles = Arrays.asList(roleRepository.findByName("ROLE_USER"))                 val registeredUser = userRepository.save(user)                  emailService.sendRegistrationConfirmationEmail(registeredUser)             } catch (e: Exception) {                 return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"),                         HttpStatus.SERVICE_UNAVAILABLE)             }              return ResponseEntity(ResponseMessage("Please, follow the link in the confirmation email to complete the registration."), HttpStatus.OK)         } else {             return ResponseEntity(ResponseMessage("User already exists!"),                     HttpStatus.BAD_REQUEST)         }     }      @PostMapping("/registrationConfirm")     @CrossOrigin(origins = ["*"])     @Throws(UnsupportedEncodingException::class)     fun confirmRegistration(request: HttpServletRequest, model: Model, @RequestParam("token") token: String): ResponseEntity<*> {          when(userService.validateVerificationToken(token)) {             TOKEN_VALID -> return ResponseEntity.ok(ResponseMessage("Registration confirmed"))             TOKEN_INVALID -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.BAD_REQUEST)             TOKEN_EXPIRED -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.UNAUTHORIZED)         }          return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"), HttpStatus.SERVICE_UNAVAILABLE)     }      @PostMapping("/logout")     fun logout(response: HttpServletResponse): ResponseEntity<*> {         val cookie: Cookie = Cookie(authCookieName, null)         cookie.maxAge = 0         cookie.secure = isCookieSecure         cookie.isHttpOnly = true         cookie.path = "/"         response.addCookie(cookie)          return ResponseEntity.ok(ResponseMessage("Successfully logged"))     }      private fun emailExists(email: String): Boolean {         return userRepository.findByUsername(email).isPresent     }      private fun usernameExists(username: String): Boolean {         return userRepository.findByUsername(username).isPresent     }  } 

Теперь поработаем на фронтендом:

#1 Создадим компонент RegistrationConfirmPage.vue

#2 Добавим новый путь в router.js с параметром :token:

 {         path: '/registration-confirm/:token',         name: 'RegistrationConfirmPage',         component: RegistrationConfirmPage } 

#3 Обновим SignUp.vue — после успешной отправки данных с форм будем сообщать им, что для завершения регистрации необходимо перейти по ссылке в письме.

#4 Важно: увы, мы не можем дать фиксированную ссылку на отдельный компонент, который выполнял бы валидацию токена и сообщал бы об успехе или неуспехе. Ссылки с прописанными через слэш путями всё равно приведут нас на исходную страницу приложения. Но мы можем сообщить нашему приложению о необходимости подтвердить регистрацию с помощью передаваемого GET-параметра confirmRegistration:

 methods: {     confirmRegistration() {       if (this.$route.query.confirmRegistration === 'true' && this.$route.query.token != null) {         this.$router.push({name: 'RegistrationConfirmPage', params: { token: this.$route.query.token}});       }     },  ...  mounted() {     this.confirmRegistration();   } 

#5 Создадим компонент, выполняющий валидацию токена и сообщающий о результате валидации:

RegistrationConfirmPage.vue

 <template>     <div id="registration-confirm">         <div class="confirm-form">             <b-card               title="Confirmation"               tag="article"               style="max-width: 20rem;"               class="mb-2"             >             <div v-if="isSuccess">                 <p class="success">Account is successfully verified!</p>                 <router-link to="/login">                     <b-button variant="primary">Login</b-button>                 </router-link>             </div>             <div v-if="isError">                 <p class="fail">Verification failed:</p>                 <p>{{ errorMessage }}</p>             </div>             </b-card>             </div>     </div> </template>  <script> import {AXIOS} from './http-common' export default {     name: 'RegistrationConfirmPage',     data() {         return {             isSuccess: false,             isError: false,             errorMessage: ''         }     },     methods: {         executeVerification() {             AXIOS.post(`/auth/registrationConfirm`, null, {params: { 'token': this.$route.params.token}})             .then(response => {               this.isSuccess = true;               console.log(response);             }, error => {                 this.isError = true;                 this.errorMessage = error.response.data.message;             })             .catch(e => {               console.log(e);               this.errorMessage = 'Server error. Please, report this error website owners';             })         }     },     mounted() {         this.executeVerification();     } } </script>  <style scoped> .confirm-form {     margin-left: 38%;     margin-top: 50px; } .success {     color: green; } .fail {     color: red; } </style> 

Результат

Вместо заключения

В завершение этого материала хотелось бы сделать небольшое лирическое отступление и сказать, что сама концепция приложения, рассмотренного в этой и предыдущей статье, не была нова уже на момент начала написания. Задачу быстрого создания full stack приложений на Spring Boot с использованием современных JavaScript-фреймворков Angular/React/Vue.js изящно решает Hipster.

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

Полезные ссылки

ссылка на оригинал статьи https://habr.com/ru/post/482222/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *