Иллюстрация возможности развернуть фронт на основе встроенного в spring web-сервера.
Зачем это может быть нужно
Если вы DevOps, настраиваете CI/CD, но не хотите заморачиваться со стендами и докерами.
Если вы разработчик и от вас требуют саморазвёртывающийся дистрибутив который сможет установить любая бабушка. Что может быть проще чем просто запустить jar-файл и открыть браузер? Кстати, браузер тоже можно запускать автоматически, при запуске jar- ника.
Наконец реальный случай из практики. Требовалось развёртывать и время от времени обновлять приложение на нескольких изолированных машинах в секретных комнатах имеющих только канал к общей базе данных. Соответственно развёртывать на каждой отдельной машине (а это, на минутку, винда не самых последних версий) фронт и бэк кажется очень излишним.
Тем более, что общее количество пользователей не превышало двух десятков человек, а одновременно работали примерно 3 - 5 человек.
И да, доступа в секретную комнату не имелось, то есть всё через третьи руки.
Описание проектов
back-web-server - проект бэка (gradle). Spring Web + Swagger. В него мы будем встраивать наш фронт.
front-js - проект фронта на чистом js
front-angular - проект фронта на ангуляре
Ссылка на исходники: https://github.com/upswet/FrontInBack
Создадим проект бэка (back-web-server)
Создадим стандартный gradle-проект
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.1'
id 'io.spring.dependency-management' version '1.1.5'
}
group = 'ru.back'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
//spring
implementation 'org.springframework.boot:spring-boot-starter-web'
//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' //https://www.baeldung.com/spring-rest-openapi-documentation, https://struchkov.dev/blog/ru/api-swagger/
//lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'javax.annotation:javax.annotation-api:1.3.2' //for PostConstruct and other annotation
}
Объяснения полагаю излишними, но учитывая что это учебный материал:
В секции plugins мы указываем необходимые плагины для нашего gradle-сборщика.
Плагин "org.springframework.boot" упрощает создание автономных приложений сприга.
Плагин "io.spring.dependency-management" обеспечивает согласование версий компонент спринга.
Секция repositories описывает откуда мы будем брать библиотеки. В нашем случае только из общедоступного репозитория
Секция java требует конкретную версию java-ы. Вам не обязательно ставить именно 21-ую, как в примере. Можете попробовать установить ту, которой пользуетесь. В данном случае это не должно быть принципиально.
Секция configurations разрешает при компиляции нашего кода использовать аннотации изменяющие сам код. Необходимо для работы такого инструмента как lombok позволяющем порождать код путём использования аннотаций.
Наконец секция dependencies указывает какие библиотеки мы будем использовать
application.properties
spring.application.name=server
#web
server.port=8080
# inout answer on ResponseStatusException
server.error.include-message=always
#swagger
#http://localhost:8080/api-docs
#http://localhost:8080/api-docs.yaml
#http://localhost:8080/swagger-ui/index.html
springdoc.api-docs.path=/api-docs
конфиги
Первым делом создадим конфиг для сваггера. Это не обязательно, но позволяет сваггеру выглядеть чуть более приятно.
На случай если вы не знаете что такое сваггер - это инструмент позволяющий вызывать апи вашего веб-приложения из браузера, а также получить полное описание всех апи в виде yaml-файла (а ещё он умеет создавать апи по описанию в yaml-файле, но это за пределами данного руководства)
OpenApiConfig.java
package ru.back.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
@OpenAPIDefinition(
info = @Info(
title = "Тестовое приложение",
description = "Тестовый пример бэка",
version = "1.0.0",
contact = @Contact(
name = "Иван Иванов",
email = "zz@zzzz.ru",
url = "https://localhost:8080/index.html"
)
)
)
public class OpenApiConfig {
// Конфигурация для Swagger
}
Затем создадим настройку изменяющую поведение веб-сервера встроенного в spring-web
WebConfig.java
package ru.back.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public final void addResourceHandlers(final ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.setCachePeriod(0);//.setCachePeriod(3600);
}
}
Аннотация @Configuration указывает на то, что это именно конфиг
Аннотация @EnableWebMvcвключает spring mvc, то есть функциональность веб-сервиса.
В методе addResourceHandlers мы указываем что все НЕОБРАБОТАННЫЕ ранее запросы по локальному адресу "/**" следует пытаться мапить на контент внутри папки static лежащий в ресурсах нашего проекта.
Что значит "необработанные"? То есть те, для которых нет явно описанного контроллера для их обработки.
Соответственно последняя строчка setCachePeriod даёт возможность кэшировать ответы на стороне браузера что может немного сократить количество запросов к бэку.
Не будем полагаться на автоматический импорт конфигураций спрингом и применим их явным образом
@SpringBootApplication
@Import({
WebConfig.class,
OpenApiConfig.class
})
public class ServerApplication {
public static void main(String[] args) throws InterruptedException {
SpringApplication.run(ServerApplication.class, args);
}
}
Наш единственный контроллер для примера
import org.springframework.web.bind.annotation.RestController;
import java.util.Random;
@RestController
@Tag(name = "Тестовый контроллер", description = "Просто тестовый контроллер для примера")
@RequestMapping("/api/v1/test")
@Slf4j
@RequiredArgsConstructor
public class MyTestController {
Random randomizer = new Random();
@Operation(summary = "Получить случайное значение")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "целое случайное значение в промежутке от 0 до 666")
})
@GetMapping(value = "/random")
public ResponseEntity<Integer> randomApi(){
return ResponseEntity.ok(randomizer.nextInt(666));
}
}
Всё, что мы сделали это объявили единственное апи по адресу /api/v1/test/random возвращающее случайное целое число в ответ на get-запрос
Общая структура проекта
Для проверки работы апи запускаем проект через gradle-задачу
Воспользуемся сваггером для проверки работы нашего единственного тестового апи.
Для этого заходим на http://localhost:8080/swagger-ui/index.html
Как видим: всё работает, случайное число возвращается.
Обратите внимание, что так как запрос по пути http://localhost:8080/api/v1/test/random обрабатывает наш контроллер, а запрос по пути http://localhost:8080/swagger-ui/index.html обрабатывает сваггер и поэтому эти запросы на мапятся на содержимое папки static в ресурсах.
Создадим проект фронта на js (front-js)
Собственно, весь наш проект будет состоять из одного единственного файла index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Тестовый фронт</title>
</head>
<body>
<script>
var baseUrl = window.location.origin;
console.log(baseUrl);
const interval = setInterval(function() {
//запрос апи бэка. Подробнее см https://fruntend.com/posts/sposoby-otpravki-http-zaprosov-v-javascript
var url=baseUrl+'/api/v1/test/random';
const otherParam = {
headers: {
"content-type": "application/json; charset=UTF-8",
},
method: "GET",
};
fetch(url, otherParam)
.then(data => data.json())
.then(response => {
document.getElementsByClassName('text')[0].textContent='полученное число: '+response
console.log(response);
})
.catch(error => console.log(error));
}, 3000);
</script>
<p class="text">Здесь должно отображаться случайное число полученное с бэка</p>
</body>
</html>
Важный момент: как узнать имя хоста на котором развёрнут бэк чтобы запросить его апи?
Очень просто - бэк будет развёрнут на том же хосте, на котором и фронт. По другому и быть не может, ведь мы собираемся упаковать фонт внутрь бэка.
Хорошо, но как тогда узнать адрес хоста на котором развёрнут наш фронт? Тоже не сложно - узнаем его из адресной строки используя объект window.location
Далее мы добавляем к базовому урлу (то есть имени хоста, номеру порта и протоколу) путь к вызываемому апи и периодически, по таймеру, каждые пять секунд делаем get-запрос и полученный результат выводим на страницу используя обращение к единственному на нашей странице dom-элементу.
Отлично! Поместим наш "фронт" внутрь бэка, точнее в папку static в ресурсах
Запустим бэк через градл-задачу bootRun или же скомпилируем исполняемый jar-файл нашего проекта бэка+фронта с помощью задачи bootJar и впоследствии запустим сформированный в \FrontInBack\back-web-server\build\libs\ файл back-web-server-1.0-SNAPSHOT.jar через команду java -jar back-web-server-1.0-SNAPSHOT.jar
И зайдя в браузере на адресс http://localhost:8080/index.html мы увидим нашу страничку лежащую в resources\static\index.html и увидим что всё работает
Создадим проект фронта на ангуляре (front-angular)
Отлично, мы видели, что с простым html/js всё работает. Давайте теперь создадим фронт на ангуляре
Выполним команду ng new front-angular для создания скелета ангуляр-проекта
Добавим в файл app.config.ts провайдер provideHttpClient() для получения возможности использовать http-клиент
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
};
В файле app.component.html оставим только следующий код
<p>От сервера получено: {{data}}</p>
<router-outlet />
Для нас важна ссылка на переменную data в которую мы будем складывать ответ бэка, то есть случайное число
Наконец сам компонент app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { timer } from 'rxjs';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
baseUrl:string=window.location.origin;
data:string = 'пусто';
constructor(private httpClient: HttpClient) {}
ticks =0;
ngOnInit(){
let timer$ = timer(2000,3000);
timer$.subscribe((t: number)=>{
this.ticks = t
this.httpClient.get(this.baseUrl+"/api/v1/test/random").subscribe(
value =>{
this.data=value.toString();
},
error => {console.log(error)}
);
});
}
}
Здесь всё аналогично. Запускаем таймер, который каждые три секунды делает запрос к апи бэка получая его адрес через window.location и, в случае получения успешного ответа, сохраняет его в переменной data
Скомпилируем полученный код командой npm run build которая создаст каталог dist
Скопируем содержимое каталога \FrontInBack\front-angular\dist\front-angular\browser\ в папку static нашего бэка
Запустим бэк через задачу запуска bootRun или задачу компиляции jar-файла bootJar с последующим запуском через java -jar имя-файла-сгенерированного-в-back-web-server\build\libs\
Зайдём браузером на тот же адрес http://localhost:8080/index.html и увидим что всё работает
Теперь у вас есть рабочий способ использовать сприговский веб-сервер для развёртывания фронта помещённого внутрь jar-файла.
Разумеется, этот способ не для высоконагруженных приложений. Но... почему бы и нет?