Русский
Русский
English
Статистика
Реклама

Spring-boot

Создаем plugin для IDEA для мониторинга транзакций в Spring

20.04.2021 10:21:38 | Автор: admin

Disclaimer: я не являюсь сотрудником JetBrains (а жаль), поэтому код может являться не оптимальным и служит только для примера и исследовательских целей.

Введение

Часто во время работы со Spring непонятно, правильно ли работает аннотация @Transaction:

  • в правильном ли месте мы ее поставили

  • правильно ли объявился interceptor

  • и т.д.

Самым простым способом для меня было остановиться в debug в IDEA в необходимом методе и исследовать, что возвращает

TransactionSynchronizationManager.isActualTransactionActive();

Но "я же программист" и захотелось это дело хоть как-то автоматизировать, заодно поизучать возможности написания plugin для IDEA.

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

Примерно вот что получилось:

Подробней про транзакции можно прочитать здесь.

С чего начать создания plugin?

Про настройку проекта для создание plugin можно прочитать здесь.

Повторюсь, что главными отправными точками будут:

Реализация

Исходный код получившегося решения размещен здесь.

Создание action

Прочитать про action можно здесь. Под action, в основном, понимается кнопка или элемент меню.

Сначала определим новый action. У action есть два метода update и actionPerformed.

  • update - вызывается idea несколько раз в секунду для того, чтобы установить корректное состояние (включен/не включен, виден/не виден)

  • actionPerformed - вызывается idea при выполнении действия (нажатия на кнопку).

public class PopupDialogAction extends AnAction {  @Override  public void update(AnActionEvent e) {    // Using the event, evaluate the context, and enable or disable the action.  }  @Override  public void actionPerformed(@NotNull AnActionEvent e) {    // Using the event, implement an action. For example, create and show a dialog.  }}

Для того, чтобы зарегистрировать новый action, требуется прописать в plugin.xml:

<actions>  <action id="TransactionStatusAction"          class="com.github.pyltsin.sniffer.debugger.TransactionStatusAction"          icon="SnifferIcons.RUNNING"          text="Current Transaction Status">    <add-to-group group-id="XDebugger.ToolWindow.TopToolbar" anchor="last"/>  </action></actions>

После этого должна была появиться новая кнопка на панели инструментов в debug-окне

Вычисление значений в debug

IDEA позволяет нам вычислять выражения при debug и взаимодействовать с памятью

И не только

Информация взята из twitter Тагира Валеева

В Evaluate даже встроен свой мини-интерпретатор Java, который позволяет выполнять прикольные вещи, например, такие:

Картинка из поста Тагира Валеева (http://personeltest.ru/aways/twitter.com/tagir_valeev/status/1360512527218728962)Картинка из поста Тагира Валеева (http://personeltest.ru/aways/twitter.com/tagir_valeev/status/1360512527218728962)

Поэтому, используя API IDEA, мы легко сможем узнать, когда транзакция активна, выполнив:

const val TRANSACTION_ACTIVE: String =    "org.springframework.transaction.support.TransactionSynchronizationManager"+  ".actualTransactionActive.get()==Boolean.TRUE"

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

override fun update(e: AnActionEvent) {  val presentation = e.presentation  val currentSession: XDebugSession? = getCurrentSession(e)  if (currentSession == null) {    setDisabled(presentation)    return  }}private fun getCurrentSession(e: AnActionEvent): XDebugSession? {  val project = e.project  return if (project == null) null else   XDebuggerManager.getInstance(project).currentSession}

Многие вещи в idea реализованы через статические методы и паттерн singleton (хотя это уже почти считается антипаттерном - это очень удобно, что мы можем получить требуемые значения из любого места через статические методы, например, XDebuggerManager.getInstance)

Евгений Борисов не одобряет singleton как pattern, когда есть SpringЕвгений Борисов не одобряет singleton как pattern, когда есть Spring

Теперь мы хотим получить значения из контекста Spring из текущей сессии Java. Для этого можно воспользоваться следующим методом:

public abstract class XDebuggerEvaluator {  public abstract void evaluate(@NotNull String expression,                                 @NotNull XEvaluationCallback callback,                                 @Nullable XSourcePosition expressionPosition);}

Например, так

val currentSourcePosition: XSourcePosition? = currentSession.currentStackFrame?.sourcePositioncurrentSession.debugProcess.evaluator?.evaluate(  TRANSACTION_ACTIVE, object : XDebuggerEvaluator.XEvaluationCallback {    override fun errorOccurred(errorMessage: String) {      TODO("Not yet implemented")    }    override fun evaluated(result: XValue) {      TODO("Not yet implemented")    }  },  currentSourcePosition)

В XValue теперь хранится вычисленное значение. Чтобы посмотреть его, можно выполнить:

(result as JavaValue).descriptor.value

Он возвращает объект класса - com.sun.jdi.Value

Часть JavaDoc для com.sun.jdi.Value

The mirror for a value in the target VM. This interface is the root of a value hierarchy encompassing primitive values and object values.

Мы научились вычислять значения в debug с использованием API IDEA.

Рабочая панель (Tool Window)

Теперь попробуем их вывести в рабочую панель (Tool Window). Как всегда начинаем с документации и примера.

Объявляем в plugin.xml новое окно

<toolWindow id="TransactionView" secondary="true" icon="AllIcons.General.Modified" anchor="right"factoryClass="com.github.pyltsin.sniffer.ui.MyToolWindowFactory"/>
public class MyToolWindowFactory implements ToolWindowFactory {  public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) {    MyToolWindow myToolWindow = new MyToolWindow(toolWindow, project);    ContentFactory contentFactory = ContentFactory.SERVICE.getInstance();    Content content = contentFactory.createContent(myToolWindow.getContent(), "", false);    final ContentManager contentManager = toolWindow.getContentManager();    contentManager.addContent(content);  }}

Само окно можно нарисовать во встроенном редакторе

IDEA сама сгенерирует для нас заготовку класса:

А дальше, вспоминая старый добрый Swing, описываем логику и добавляем необходимые Listener.

Получившееся окно инструментов (Tool Windows)Получившееся окно инструментов (Tool Windows)

Способы передачи данных

Вернемся к нашему action. При нажатии на кнопку вызывается метод actionPerformed.

Как из этого метода достучаться до нашего окна?

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

val toolWindow: ToolWindow? =ToolWindowManager.getInstance(project).getToolWindow("TransactionView")

И передать туда требуемые значения.

IDEA предоставляет еще один способ - Message Bus (детальное описание лучше смотреть в документации). Один из вариантов использования следующий:

Объявить интерфейс:

public interface ChangeActionNotifier {    Topic<ChangeActionNotifier> CHANGE_ACTION_TOPIC = Topic.create("custom name", ChangeActionNotifier.class)    void beforeAction(Context context);    void afterAction(Context context);}

В месте, где принимаем сообщения:

bus.connect().subscribe(ActionTopics.CHANGE_ACTION_TOPIC, new ChangeActionNotifier() {        @Override        public void beforeAction(Context context) {            // Process 'before action' event.        }        @Override        public void afterAction(Context context) {            // Process 'after action' event.        }});

В месте, где отправляем сообщения:

ChangeActionNotifier publisher = myBus.syncPublisher(  ActionTopics.CHANGE_ACTION_TOPIC);publisher.beforeAction(context);

В любом случае необходимо быть аккуратным с многопоточностью и не выполнять долгие операции на UI Thread (подробности).

Осталось собрать все вместе и протестировать.

Исходный код получившегося решения размещен здесь.

Краткий "как бы" вывод

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

Ссылки

Подробнее..

REST API с использованием Spring Security и JWT

05.03.2021 00:08:39 | Автор: admin

Рано или поздно каждый Java-разработчик столкнется с необходимостью реализовать защищенное REST API приложение. В этой статье хочу поделиться своей реализацией этой задачи.

1. Что такое REST?

REST (от англ. Representational State Transfer передача состояния представления) это общие принципы организации взаимодействия приложения/сайта с сервером посредством протокола HTTP.

Диаграмма ниже показывает общую модель.

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

  1. Получение данных с сервера (обычно в формате JSON, или XML);

  2. Добавление новых данных на сервер;

  3. Модификация существующих данных на сервере;

  4. Удаление данных на сервере

Более подробно можно прочесть в остальных источниках, статей о REST много.

2. Задача

Необходимо подготовить защищенное REST приложение, доступ к которому может быть осуществлен только для авторизованного пользователя. Авторизация с передачей логина и пароля выполняется отдельным запросом, при успешной авторизации система должна сгенерировать и вернуть токен. Валидация остальных запросов должна быть осуществлена по токену.
Схема нашего приложения будет выглядеть следующим образом:

3. Технологии

Для решения используем фреймворк Spring Boot и Spring Web, для него требуется:

  1. Java 8+;

  2. Apache Maven

Авторизация и валидация будет выполнена силами Spring Security и JsonWebToken (JWT).
Для уменьшения кода использую Lombok.

4. Создание приложения

Переходим к практике. Создаем Spring Boot приложение и реализуем простое REST API для получения данных пользователя и списка пользователей.

4.1 Создание Web-проекта

Создаем Maven-проект SpringBootSecurityRest. При инициализации, если вы это делаете через Intellij IDEA, добавьте Spring Boot DevTools, Lombok и Spring Web, иначе добавьте зависимости отдельно в pom-файле.

4.2 Конфигурация pom-xml

После развертывания проекта pom-файл должен выглядеть следующим образом:

  1. Должен быть указан parent-сегмент с подключенным spring-boot-starter-parent;

  2. И установлены зависимости spring-boot-starter-web, spring-boot-devtools и Lombok.

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://personeltest.ru/away/maven.apache.org/POM/4.0.0" xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://personeltest.ru/away/maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.5.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>com</groupId>    <artifactId>springbootsecurityrest</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>springbootsecurityrest</name>    <description>Demo project for Spring Boot</description>    <properties>        <java.version>15</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-devtools</artifactId>            <scope>runtime</scope>            <optional>true</optional>        </dependency>        <dependency>            <groupId>org.projectlombok</groupId>            <artifactId>lombok</artifactId>            <optional>true</optional>        </dependency>        <!--Test-->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-test</artifactId>            <scope>test</scope>            <exclusions>                <exclusion>                    <groupId>org.junit.vintage</groupId>                    <artifactId>junit-vintage-engine</artifactId>                </exclusion>            </exclusions>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>                <configuration>                    <excludes>                        <exclude>                            <groupId>org.projectlombok</groupId>                            <artifactId>lombok</artifactId>                        </exclude>                    </excludes>                </configuration>            </plugin>        </plugins>    </build></project>

4.3 Создание ресурса REST

Разделим все классы на слои, создадим в папке com.springbootsecurityrest четыре новые папки:

  • model для хранения POJO-классов;

  • repository в полноценных проектах используется для взаимодействия с БД, но т.к. у нас ее нет, то он будет содержать список пользователей;

  • service слой сервиса, прослойка между контролером и слоем ресурсов, используется для получения данных из ресурса, их проверки и преобразования (если это необходимо);

  • rest будет содержать в себе классы контроллеры.

В папке model создаем POJO класс User.

import lombok.AllArgsConstructor;import lombok.Data;@Data@AllArgsConstructorpublic class User {    private String login;    private String password;    private String firstname;    private String lastname;    private Integer age;}

В папке repository создаём класс UserRepository c двумя методами:

  1. getByLogin который будет возвращать пользователя по логину;

  2. getAll который будет возвращать список всех доступных пользователей. Чтобы Spring создал бин на основании этого класса, устанавливаем ему аннотацию @Repository.

@Repositorypublic class UserRepository {      private List<User> users;    public UserRepository() {        this.users = List.of(                new User("anton", "1234", "Антон", "Иванов", 20),                new User("ivan", "12345", "Сергей", "Петров", 21));    }    public User getByLogin(String login) {        return this.users.stream()                .filter(user -> login.equals(user.getLogin()))                .findFirst()                .orElse(null);    }    public List<User> getAll() {        return this.users;    }

В папке service создаем класс UserService. Устанавливаем классу аннотацию @Service и добавляем инъекцию бина UserRepository. В класс добавляем метод getAll, который будет возвращать всех пользователей и getByLogin для получения одного пользователя по логину.

@Servicepublic class UserService {    private UserRepository repository;    public UserService(UserRepository repository) {        this.repository = repository;    }    public List<User> getAll() {        return this.repository.getAll();    }    public User getByLogin(String login) {        return this.repository.getByLogin(login);    }}

Создаем контроллер UserController в папке rest, добавляем ему инъекцию UserService и создаем один метод getAll. С помощью аннотации @GetMapping указываем адрес контроллера, по которому он будет доступен клиенту и тип возвращаемых данных.

@RestControllerpublic class UserController {    private UserService service;    public UserController(UserService service) {        this.service = service;    }    @GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE)    public @ResponseBody List<User> getAll() {        return this.service.getAll();    }}

Запускаем приложение и проверяем, что оно работает, для этого достаточно в браузере указать адрес http://localhost:8080/users, если вы все сделали верно, то увидите следующее:

5. Spring Security

Простенькое REST API написано и пока оно открыто для всех. Двигаемся дальше, теперь его необходимо защитить, а доступ открыть только авторизованным пользователям. Для этого воспользуемся Spring Security и JWT.

Spring Security это Java/JavaEE framework, предоставляющий механизмы построения систем аутентификации и авторизации, а также другие возможности обеспечения безопасности для корпоративных приложений, созданных с помощью Spring Framework.

JSON Web Token (JWT) это открытый стандарт (RFC 7519) для создания токенов доступа, основанный на формате JSON. Как правило, используется для передачи данных для аутентификации в клиент-серверных приложениях. Токены создаются сервером, подписываются секретным ключом и передаются клиенту, который в дальнейшем использует данный токен для подтверждения своей личности.

5.1 Подключаем зависимости

Добавляем новые зависимости в pom-файл.

<!--Security--><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-security</artifactId></dependency><dependency>    <groupId>io.jsonwebtoken</groupId>    <artifactId>jjwt</artifactId>    <version>0.9.1</version></dependency><dependency>    <groupId>jakarta.xml.bind</groupId>    <artifactId>jakarta.xml.bind-api</artifactId>    <version>2.3.3</version></dependency>

5.2 Генерация и хранения токена

Начнем с генерации и хранения токена, для этого создадим папку security и в ней создаем класс JwtTokenRepository с имплементацией интерфейса CsrfTokenRepository (из пакета org.springframework.security.web.csrf).

Интерфейс указывает на необходимость реализовать три метода:

  1. Генерация токена в методе generateToken;

  2. Сохранения токена saveToken;

  3. Получение токена loadToken.

Генерируем токен силами Jwt, пример реализации метода.

@Repositorypublic class JwtTokenRepository implements CsrfTokenRepository {    @Getter    private String secret;    public JwtTokenRepository() {        this.secret = "springrest";    }    @Override    public CsrfToken generateToken(HttpServletRequest httpServletRequest) {        String id = UUID.randomUUID().toString().replace("-", "");        Date now = new Date();        Date exp = Date.from(LocalDateTime.now().plusMinutes(30)                .atZone(ZoneId.systemDefault()).toInstant());        String token = "";        try {            token = Jwts.builder()                    .setId(id)                    .setIssuedAt(now)                    .setNotBefore(now)                    .setExpiration(exp)                    .signWith(SignatureAlgorithm.HS256, secret)                    .compact();        } catch (JwtException e) {            e.printStackTrace();            //ignore        }        return new DefaultCsrfToken("x-csrf-token", "_csrf", token);    }    @Override    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {    }    @Override    public CsrfToken loadToken(HttpServletRequest request) {        return null;    }}

Параметр secret является ключом, необходимым для расшифровки токена, оно может быть постоянным для всех токенов, но лучше сделать его уникальным только для пользователя, например для этого можно использовать ip-пользователя или его логин. Дата exp является датой окончания токена, рассчитывается как текущая дата плюс 30 минут. Такой параметр как продолжительность жизни токена рекомендую вынести в application.properties.

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

    @Override    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {        if (Objects.nonNull(csrfToken)) {            if (!response.getHeaderNames().contains(ACCESS_CONTROL_EXPOSE_HEADERS))                response.addHeader(ACCESS_CONTROL_EXPOSE_HEADERS, csrfToken.getHeaderName());            if (response.getHeaderNames().contains(csrfToken.getHeaderName()))                response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());            else                response.addHeader(csrfToken.getHeaderName(), csrfToken.getToken());        }    }    @Override    public CsrfToken loadToken(HttpServletRequest request) {        return (CsrfToken) request.getAttribute(CsrfToken.class.getName());    }

Сохранение токена выполняем в response (ответ от сервера) в раздел headers и открываем параметр для чтения фронта указав имя параметра в Access-Control-Expose-Headers.

Добавляем к классу еще один метод по очистке токена из response, будем использовать его при ошибке авторизации.

    public void clearToken(HttpServletResponse response) {        if (response.getHeaderNames().contains("x-csrf-token"))            response.setHeader("x-csrf-token", "");    }

5.3 Создание нового фильтра для SpringSecurity

Создаем новый класс JwtCsrfFilter, который является реализацией абстрактного класса OncePerRequestFilter (пакет org.springframework.web.filter). Класс будет выполнять валидацию токена и инициировать создание нового. Если обрабатываемый запрос относится к авторизации (путь /auth/login), то логика не выполняется и запрос отправляется далее для выполнения базовой авторизации.

public class JwtCsrfFilter extends OncePerRequestFilter {    private final CsrfTokenRepository tokenRepository;    private final HandlerExceptionResolver resolver;    public JwtCsrfFilter(CsrfTokenRepository tokenRepository, HandlerExceptionResolver resolver) {        this.tokenRepository = tokenRepository;        this.resolver = resolver;    }    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)            throws ServletException, IOException {        request.setAttribute(HttpServletResponse.class.getName(), response);        CsrfToken csrfToken = this.tokenRepository.loadToken(request);        boolean missingToken = csrfToken == null;        if (missingToken) {            csrfToken = this.tokenRepository.generateToken(request);            this.tokenRepository.saveToken(csrfToken, request, response);        }        request.setAttribute(CsrfToken.class.getName(), csrfToken);        request.setAttribute(csrfToken.getParameterName(), csrfToken);        if (request.getServletPath().equals("/auth/login")) {            try {                filterChain.doFilter(request, response);            } catch (Exception e) {                resolver.resolveException(request, response, null, new MissingCsrfTokenException(csrfToken.getToken()));            }        } else {            String actualToken = request.getHeader(csrfToken.getHeaderName());            if (actualToken == null) {                actualToken = request.getParameter(csrfToken.getParameterName());            }            try {                if (!StringUtils.isEmpty(actualToken)) {                    Jwts.parser()                            .setSigningKey(((JwtTokenRepository) tokenRepository).getSecret())                            .parseClaimsJws(actualToken);                        filterChain.doFilter(request, response);                } else                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));            } catch (JwtException e) {                if (this.logger.isDebugEnabled()) {                    this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));                }                if (missingToken) {                    resolver.resolveException(request, response, null, new MissingCsrfTokenException(actualToken));                } else {                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));                }            }        }    }}

5.4 Реализация сервиса поиска пользователя

Теперь необходимо подготовить сервис для поиска пользователя по логину, которого будем авторизовывать. Для этого нам необходимо добавить к сервису UserService интерфейс UserDetailsService из пакета org.springframework.security.core.userdetails. Интерфейс требует реализовать один метод, выносить его в отдельный класс нет необходимости.

@Servicepublic class UserService implements UserDetailsService {    private UserRepository repository;    public UserService(UserRepository repository) {        this.repository = repository;    }    public List<User> getAll() {        return this.repository.getAll();    }    public User getByLogin(String login) {        return this.repository.getByLogin(login);    }    @Override    public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {        User u = getByLogin(login);        if (Objects.isNull(u)) {            throw new UsernameNotFoundException(String.format("User %s is not found", login));        }        return new org.springframework.security.core.userdetails.User(u.getLogin(), u.getPassword(), true, true, true, true, new HashSet<>());    }}

Полученного пользователя необходимо преобразовать в класс с реализацией интерфейса UserDetails или воспользоваться уже готовой реализацией из пакета org.springframework.security.core.userdetails. Последним параметром конструктора необходимо добавить список элементов GrantedAuthority, это роли пользователя, у нас их нет, оставим его пустым. Если пользователя по логину не нашли, то бросаем исключение UsernameNotFoundException.

5.5 Обработка авторизации

По результату успешно выполненной авторизации возвращаю данные авторизованного пользователя. Для этого создадим еще один контроллер AuthController с методом getAuthUser. Контроллер будет обрабатывать запрос /auth/login, а именно обращаться к контексту Security для получения логина авторизованного пользователя, по нему получать данные пользователя из сервиса UserService и возвращать их на фронт.

@RestController@RequestMapping("/auth")public class AuthController {    private UserService service;    public AuthController(UserService service) {        this.service = service;    }    @PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)    public @ResponseBody com.springbootsecurityrest.model.User getAuthUser() {        Authentication auth = SecurityContextHolder.getContext().getAuthentication();        if (auth == null) {            return null;        }        Object principal = auth.getPrincipal();        User user = (principal instanceof User) ? (User) principal : null;        return Objects.nonNull(user) ? this.service.getByLogin(user.getUsername()) : null;    }}

5.6 Обработка ошибок

Что бы видеть ошибки авторизации или валидации токена, необходимо подготовить обработчик ошибок. Для этого создаем новый класс GlobalExceptionHandler в корне com.springbootsecurityrest, который является расширением класса ResponseEntityExceptionHandler с реализацией метода handleAuthenticationException.

Метод будет устанавливать статус ответа 401 (UNAUTHORIZED) и возвращать сообщение в формате ErrorInfo.

@RestControllerAdvicepublic class GlobalExceptionHandler extends ResponseEntityExceptionHandler {    private JwtTokenRepository tokenRepository;    public GlobalExceptionHandler(JwtTokenRepository tokenRepository) {        this.tokenRepository = tokenRepository;    }    @ExceptionHandler({AuthenticationException.class, MissingCsrfTokenException.class, InvalidCsrfTokenException.class, SessionAuthenticationException.class})    public ErrorInfo handleAuthenticationException(RuntimeException ex, HttpServletRequest request, HttpServletResponse response){        this.tokenRepository.clearToken(response);        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);        return new ErrorInfo(UrlUtils.buildFullRequestUrl(request), "error.authorization");    }    @Getter public class ErrorInfo {        private final String url;        private final String info;        ErrorInfo(String url, String info) {            this.url = url;            this.info = info;        }    }}

5.7 Настройка конфигурационного файла Spring Security.

Все данные подготовили и теперь необходимо настроить конфигурационный файл. В папке com.springbootsecurityrest создаем файл SpringSecurityConfig, который является реализацией абстрактного класса WebSecurityConfigurerAdapter пакета org.springframework.security.config.annotation.web.configuration. Помечаем класс двумя аннотациями: Configuration и EnableWebSecurity.

Реализуем метод configure(AuthenticationManagerBuilder auth), в класс AuthenticationManagerBuilder устанавливаем сервис UserService, для того что бы Spring Security при выполнении базовой авторизации мог получить из репозитория данные пользователя по логину.

@Configuration@EnableWebSecuritypublic class SpringSecurityConfig extends WebSecurityConfigurerAdapter {    @Autowired    private UserService service;    @Autowired    private JwtTokenRepository jwtTokenRepository;    @Autowired    @Qualifier("handlerExceptionResolver")    private HandlerExceptionResolver resolver;    @Bean    public PasswordEncoder devPasswordEncoder() {        return NoOpPasswordEncoder.getInstance();    }      @Override    protected void configure(HttpSecurity http) throws Exception {         }    @Override    protected void configure(AuthenticationManagerBuilder auth) throws Exception {        auth.userDetailsService(this.service);    }}

Реализуем метод configure(HttpSecurity http):

    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .sessionManagement()                    .sessionCreationPolicy(SessionCreationPolicy.NEVER)                .and()                    .addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class)                    .csrf().ignoringAntMatchers("/**")                .and()                    .authorizeRequests()                    .antMatchers("/auth/login")                    .authenticated()                .and()                    .httpBasic()                    .authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e)));    }

Разберем метод детальнее:

  1. sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) - отключаем генерацию сессии;

  2. addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class).csrf().ignoringAntMatchers("/**") - указываем созданный нами фильтр JwtCsrfFilter в расположение стандартного фильтра, при этом игнорируем обработку стандартного;

  3. .authorizeRequests().antMatchers("/auth/login").authenticated() для запроса /auth/login выполняем авторизацию силами security. Что бы не было двойной валидации (по токену и базовой), запрос был добавлен в исключение к классу JwtCsrfFilter;

  4. .httpBasic().authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e))) - ошибки базовой авторизации отправляем в обработку GlobalExceptionHandler

6. Проверка функционала

Для проверки использую Postman. Запускаем бэкенд и выполняем запрос http://localhost:8080/users с типом GET.

Токена нет, валидация не пройдена, получаем сообщение с 401 статусом.

Пытаемся авторизоваться с неверными данными, выполняем запрос http://localhost:8080/auth/login с типом POST, валидация не выполнена, токен не получен, вернулась ошибка с 401 статусом.

Авторизуемся с корректными данными, авторизация выполнена, получен авторизованный пользователь и токен.

Повторяем запрос http://localhost:8080/users с типом GET, но с полученным токеном на предыдущем шаге. Получаем список пользователей и обновленный токен.

Заключение

В этой статье рассмотрели один из примеров реализации REST приложения с Spring Security и JWT. Надеюсь данный вариант реализации кому то окажется полезным.

Полный код проекта выложен доступен на github

Подробнее..

JPA Buddy Умный помощник половина работы

17.03.2021 14:12:14 | Автор: admin

От переводчика: это статья моего коллеги @aleksey-stukalov, которую мы опубликовали в блоге JPA Buddy пару месяцев назад. С тех пор мы выпустили JPA Buddy 2.0, но все сказанное в этой статье актуальности не потеряло.

Ну что ж, Hello World... После почти года разработки наконец-то вышла первая версия JPA Buddy! Это инструмент, который должен стать вашим верным помощником по написанию кода для проектов с JPA и всем, что с этим связано: Hibernate, Spring Data, Liquibase и другим ПО из типичного стека разработки.

Чем он вам поможет? Если кратко, JPA Buddy упростит работу с JPA и тем самым сэкономит ваше время. В этой статье мы взглянем на основные фичи JPA Buddy, немного обсудим его историю и поговорим о его преимуществах. Надеюсь, он займет достойное место среди любимых инструментов Java-разработчиков, которые пользуютсяJPA, Spring, Liquibase и, конечно же, самой продвинутой Java IDE IntelliJ IDEA.

Откуда растут ноги

Мы создатели CUBA Platform (кстати, не так давно мы переименовали ее в Jmix :)) среды быстрой разработки приложений на Java. Платформа CUBA уникальный продукт. Он состоит из двух частей: фреймворка и инструмента разработки CUBA Studio. Одной из самых полюбившихся частей CUBA Studio стал Entity Designer. В сообществе CUBA более 20 000 разработчиков, и почти все они отмечают, насколько легко и быстро он дает создавать модель данных. Даже для тех, кто никогда не слышал о JPA, такие вещи как создание JPA-сущностей, ограничений модели данных и DDL-скриптов становятся легкой задачей.

В 2016 году CUBA стала open-source проектом. С того момента мы приняли участие в десятках конференций по всему миру, собрали много отзывов и лучше поняли, что именно нужно разработчикам. Они нас часто спрашивали: Можно ли использовать ваш конструктор сущностей без CUBA Platform?. Рады сообщить, что с появлением JPA Buddy мы теперь можем смело ответить да!.

JPA Buddy не требует дополнительных зависимостей это просто инструмент. Вы можете пользоваться им не только на новых, но и на уже существующих проектах, которые используют JPA. Это плагин для IntelliJ IDEA, который помогает вам писать связанный с JPA код, находить и исправлять потенциальные ошибки, проводить рефакторинг и выполнять другие трудоемкие, но достаточно шаблонные операции.

Область применения

Перед тем, как написать первую строчку исходного кода JPA Buddy, мы провели опрос и собрали различные сценарии использования JPA и сопутствующих технологий. Результат оказался достаточно предсказуемым: среднестатистическое приложение в настоящее время это приложение на Spring Boot с Hibernate в качестве реализации ORM, Spring Data JPA в качестве механизма управления данными и Flyway или Liquibase для системы миграции базы данных. Ах да, чуть не забыли про Lombok... В общем, на этом стеке мы и сконцентрировались в первом релизе.

Говоря о предназначении JPA Buddy, мы поставили перед собой следующие цели:

  • Сократить написание шаблонного кода вручную: инструмент должен генерировать код быстрее ручного ввода

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

  • Оставить свободу выбора: инструмент должен не диктовать какойлибо конкретный стиль написания кода, а поддерживать его различные варианты

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

  • Обеспечить обзор проекта с точки зрения данных и удобную навигацию между связанными сущностями

В первом релизе мы смогли реализовать достаточно значительный объем функциональности, охватывающей большинство аспектов разработки модели данных. Хорошая новость для тех, кто использует Liquibase: инструменты для работы с ним уже реализованы и доступны. Не очень хорошая новость для пользователей Flyway: он входит в список наиболее приоритетных фич :)

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

  • Аннотации Hibernate: @Where, @NaturalId, @Formula, поисковые аннотации и т. п.

  • Визуальный конструктор запросов

  • Аудит с использованием Envers и Spring Data JPA

  • Генерация модели по существующей схеме базы данных

  • Поддержка Kotlin

В дальнейшем мы учтем и другие функции: поддержка Quarkus и Micronaut, REST API и генерация пользовательского интерфейса для CRUD-операций.

Как видите, впереди еще долгий путь, и мы были бы очень признательны за вашу помощь в расстановке приоритетов разработки не стесняйтесь высказывать свою точку зрения через нашу форму, Twitter или Discord.

Общий обзор

Давайте посмотрим, как выглядит JPA Buddy. После его установки у вас появятся 3 новые панели инструментов: JPA Structure, JPA Palette и JPA Inspector.

JPA Structure

Панель JPA Structure расположена в левом нижнем углу. Она позволяет посмотреть на проект с точки зрения модели данных. С ее помощью можно:

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

  2. Создавать объекты, связанные с данными: сущности, JPA-конвертеры/Hibernate-типы, Spring Data репозитории и Liquibase-скрипты.

  3. Увидеть, для каких сущностей созданы какие репозитории.

  4. Просматривать скрипты Liquibase и их внутреннюю структуру.

  5. Настраивать параметры плагина, такие как соединение с БД, Persistence Units и некоторые другие, которые плагин не обнаружил автоматически.

JPA Palette и JPA Inspector

Панель JPA Palette находится справа вверху, и ее содержимое зависит от контекста. Она доступна только тогда, когда Buddy готов предложить чтото для быстрой генерации кода. Сейчас панель появляется при редактировании следующих объектов: JPA Entity, Spring Data репозиториев и Liquibase-скриптов. Для генерации какого-либо элемента просто выберите нужный вариант в списке и кликните по нему дважды.

JPA Inspector размещается справа внизу, под JPA Palette, и открывается вместе с ним. С помощью JPAPalette можно генерировать новый код, а с помощью JPAInspector редактировать уже существующий. В нем видно, как можно сконфигурировать выбранную часть кода, будь то поле сущности или выражение изLiquibase-скрипта.

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

Интеграция с Liquibase

Хорошие новости для тех, кто использует Liquibase для управления версиями схемы базы данных: JPA Buddy тесно с ним интегрирован, он позволяет удобно редактировать и создавать Liquibase-скрипты, а также гибко управлять типами атрибутов. Давайте посмотрим, как именно JPA Buddy поможет вам работать с Liquibase.

Во-первых, в JPA Buddy есть визуальный конструктор для редактирования Liquibase-скриптов. Он показывает различные доступные команды плагина в JPAPalette, а панель инструментов JPAInspector дает увидеть настройки, которые можно применить к выбранному выражению.

Во-вторых, JPA Buddy дает определить свои собственные маппинги для сопоставления Java-типов (Конвертеров/Hibernateтипов) и типов, которые должны использоваться для каждой конкретной СУБД. Приведем несколько примеров, когда эта функция будет полезна:

  • Допустим, у вас в сущности есть поле типа byte[]. Что генератор схемы Hibernate, что Liquibase сопоставят ваш массив с довольно экзотическим типом OID. Думаю, многие разработчики предпочли бы использовать bytea вместо предлагаемого по умолчанию типа. Это можно легко сделать, создав маппинг с Java-типа byte[] на БД-тип bytea в настройках проекта в JPA Buddy.

  • Использование JPA-конвертера или кастомного Hibernate-типа вызывает непредсказуемое поведение или даже ошибки в стандартных генераторе схемы Hibernate и генераторе скриптов Liquibase. С помощью JPA Buddy можно явно указать нужный тип, чтобы решить эту проблему.

  • Поля типа String по-умолчанию хранятся как varchar, то есть не поддерживают Юникод. С помощью JPA Buddy легко изменить этот тип на nvarchar, с которым этой проблемы нет.

Все эти случаи обычно решаются с помощью атрибута columnDefinition, однако это решение не будет работать для проектов, где одновременно используется несколько СУБД. JPA Buddy позволяет указать, какой именно маппинг использовать для конкретной СУБД.

Наконец, функция, экономящая наибольшее количество времени наш генератор Liquibase-скриптов. Исходя из нашего опыта (который совпадает с опытом разработчиков принявших участие в ранее упомянутом опросе), есть два основных способа создания Liquibase-скриптов:

  • Написание вручную

  • Создание скриптов путем сравнения двух баз данных: исходной (представляющей фактическое состояние модели) и целевой (с предыдущим состоянием модели)

Для тех, кто предпочитает первый вариант, JPA Buddy включает уже упомянутый ранее конструктор Liquibase-скриптов.

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

Начнем с небольшой проблемы: может случиться так, что база данных, используемая для разработки на ноутбуке разработчика, не того же типа, что и база данных на продакшене. Это можно исправить, например, запустив MS SQL в Docker на Mac.

Более серьезная проблема состоит втом, что трудно найти чистую исходную базу данных, поскольку она обычно формируется впроцессе разработки генератором схемы Hibernate. Часто это приводит кбольшому количеству мусорных таблиц иатрибутов, которые вконечном итоге появляются вбазе данных продакшена. Чтобы избежать подобного рода мусора, приходится тратить еще час или два напросмотр схемы базы данных перед запуском генерации Liquibase-скриптов.

ВJPA Buddy есть замечательная функция генерации Liquibase-скриптов напрямую, путем сравнения ваших объектов JPA сцелевой базой данных (или snapshotом целевой базы данных). Выможете использовать H2в целях разработки ипо-прежнему генерировать правильные журналы изменений для Oracle, MSSQL или того, что выиспользуете впродакшене. Подобный подход гарантирует, что если увас есть мусор вжурналах изменений, это именно тот мусор, который есть увас висходном коде. Все, что вам нужно, это содержать модель данных вчистоте, итогда миграция неприведет кнежелательным артефактам вбазе данных продакшена.

Еще одна особенность генератора журнала изменений это возможность фильтровать полученные выражения по 3 категориям:

  • содержащие только безопасные операторы, которые не могут дать сбой при обновлении или полностью уронить ваше приложение, например, добавление нового столбца

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

  • содержащие операторы, которые вызовут потерю данных, например, удаление таблицы или столбца

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

Заключение

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

Говоря прямо, мы хотели бы вдохновить вас стать первыми пользователями JPA Buddy и сформировать сообщество энтузиастов. Установите JPA Buddy, попользуйтесь им и поделитесь своими отзывами с нашей командой разработчиков: это очень поможетнам выбрать правильное направление развития продукта. Также подпишитесь на наш Twitter: там мы показываем, какие еще фичи есть у JPA Buddy и как ими пользоваться. Думаю, вы найдете что-то полезное именно для вас.

Подробнее..

Hibernate и Spring Boot кто отвечает за имена таблиц?

28.04.2021 16:12:49 | Автор: admin

Когда мы добавляем зависимость в проект, мы подписываем контракт. Зачастую, многие условия в нем написаны мелким шрифтом. В этой статье мы рассмотрим кое-что, что легко пропустить при подписании трехстороннего контракта между вами, Hibernate и Spring Boot. Речь пойдет о стратегиях именования.

Значения по умолчанию в JPA

Главное правило для значений по умолчанию: они должны быть интуитивно понятными. Давайте проверим, следует ли этому правилу обычное приложение на Spring Boot с конфигурацией по умолчанию, Hibernate в качестве реализации JPA и PostgreSQL в качестве БД. Допустим, у нас есть сущность PetType. Давайте угадаем, как будет называться ее таблица в базе данных.

Первый пример:

@Entitypublic class PetType {    // fields omitted}

Я бы предположил, что именем таблицы станет имя класса, то есть PetType. Однако, после запуска приложения оказалось, что имя таблицы на самом деле pet_type.

Явно зададим имя с помощью @Table:

@Entity@Table(name = "PetType")public class PetType {    // fields omitted}

В этот раз имя точно должно быть PetType. Запустим приложение Таблица снова называется pet_type!

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

@Entity@Table(name = "\"PetType\"")public class PetType {  // fields omitted}

И опять наши ожидания не оправдались, имя снова "pet_type", но теперь в кавычках!

Стратегии именования Hibernate

На запрос имя таблицы поумолчанию для JPA-сущностей Google выдает следующий результат (англ.):

По умолчанию, имя таблицы в JPA это имя класса (без пакета) с заглавной буквы. Каждый атрибут класса хранится в столбце таблицы.

Именно это мы ожидали увидеть в первом примере, не так ли? Очевидно, что-то нарушает стандарт.

Углубимся в Hibernate. Согласно документации (англ.), в Hibernate есть два интерфейса, отвечающих за именование таблиц, столбцов и пр.: ImplicitNamingStrategy и PhysicalNamingStrategy.

ImplicitNamingStrategy отвечает за генерацию имен для всех объектов, которые не были явно названы разработчиком: имя сущности, имя таблицы, имя столбца, индекса, внешнего ключа и т.д.. Получившееся имя называется логическим, оно используется внутри Hibernate для идентификации объекта. Это не то имя, которое будет использовано в базе данных.

PhysicalNamingStrategy создает подлинное физическое имя на основе логического имени объекта JPA. Именно физическое имя используется в базе данных. Фактически, это означает, что с помощью Hibernate нельзя напрямую указать физическое имя объекта в БД, можно указать только логическое. Чтобы лучше понять, как это все работает, посмотрите на схему ниже.

По умолчанию, в Hibernate используются следующие реализации этих интерфейсов: ImplicitNamingStrategyJpaCompliantImpl и PhysicalNamingStrategyStandardImpl. Первый генерирует логические имена в соответствии со спецификацией JPA, а второй использует их как физические имена без каких-либо изменений. Лучше всего это описано в документации:

JPA определяет четкие правила автоматического именования. Если вам важна независимость от конкретной реализации JPA, или если вы хотите придерживаться определенных в JPA правил именования, используйте ImplicitNamingStrategyJpaCompliantImpl (стратегия по умолчанию). Кроме того, в JPA нет разделения между логическим и физическим именем. Согласно спецификации JPA, логическое имя это и есть физическое имя. Если вам важна независимость от реализации JPA, не переопределяйте PhysicalNamingStrategy.

Однако наше приложение ведет себя по-другому. И вот почему. Spring Boot переопределяет реализации Hibernate по умолчанию для обоих интерфейсов и вместо них использует SpringImplicitNamingStrategy и SpringPhysicalNamingStrategy.

SpringImplicitNamingStrategy фактически копирует поведение ImplicitNamingStrategyJpaCompliantImpl, есть только незначительное различие в именовании join таблиц. Значит, дело в SpringPhysicalNamingStrategy. В документации (англ.) указано следующее:

По умолчанию, в Spring Boot используется SpringPhysicalNamingStrategy в качестве физической стратегии именования. Эта реализация использует те же правила именования, что и Hibernate 4:
1. Все точки заменяются символами подчеркивания.
2. Заглавные буквы CamelCase приводятся к нижнему регистру, и между ними добавляются символы подчеркивания. Кроме того, все имена таблиц генерируются в нижнем регистре. Например, сущности TelephoneNumber соответствует таблица с именем telephone_number.

По сути, Spring Boot всегда преобразовывает camelCase и PascalCase в snake_case. Более того, использование чего-либо помимо snake_case вообще невозможно. Я бы не стал использовать camelCase или PascalCase для именования объектов базы данных, но иногда у нас нет выбора. Если ваше приложение на Spring Boot работает со сторонней базой данных, где используется PascalCase или camelCase, конфигурация Spring Boot по умолчанию для вас не подойдет. Обязательно убедитесь, что используемая PhysicalNamingStrategy совместима с именами в базе данных.

Получается, Hibernate соответствует спецификации JPA, а Spring Boot нет. Можно подумать, что это ошибка. Однако Spring Boot фреймворк, в котором множество решений уже принято за разработчика, в этом и есть его ценность. Другими словами, он имеет полное право по-своему реализовывать стандарты и спецификации тех технологий, которые он использует. Для разработчика это означает следующее:

  • Конечное поведение всегда определяется конкретной реализацией и может отличаться от спецификации.

  • Если что-то и работает изкоробки, всегда нужно разбираться, что именно оно делает под капотом.

  • Поведение по умолчанию может измениться с обновлением версии библиотеки, что может привести к непредсказуемым побочным эффектам;

Заключение

Магия конфигураций изкоробки это отлично, но может привести к неожиданному поведению. Один из способов избежать подобных проблем явно задавать все значения и не полагаться на автогенерацию. Для именования объектов JPA рекомендации следующие:

  1. Всегда называйте свои объекты JPA явно, чтобы никакая автоматическая стратегия именования не повлияла на ваш код.

  2. Используйте snake_case для имен столбцов, таблиц, индексов и других объектов JPA, чтобы все реализации PhysicalNamingStrategy никак их не преобразовывали.

  3. Если нельзя использовать snake_case (например, при использовании сторонней базы данных), используйте PhysicalNamingStrategyStandardImpl в качестве PhysicalNamingStrategy.

Еще один плюс явного именования объектов JPA вы никогда случайно не переименуете таблицу или атрибут в самой базе при рефакторинге Java-модели. Для этого придется менять имя в @Table или @Column.

Однако, введя эти правила мы просто переложили ответственность на разработчиков. Теперь нам надо следить, чтобы вся команда им следовала. К счастью, этот процесс можно автоматизировать с помощью инструментов разработки.

Если вы пользуетесь IntelliJ IDEA, попробуйте JPA Buddy плагин, который упрощает работу с JPA, Hibernate, Spring Data JPA, Liquibase и подобными технологиями. В настройках JPA Buddy есть специальная секция, в которой можно установить шаблоны для имен сущностей, атрибутов и пр.. Эти шаблоны применяются каждый раз, когда разработчики создают сущность или атрибут:

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

Подробнее..

Spring Boot приложение в Kubernetes с Postgresql и Loki

03.04.2021 22:15:26 | Автор: admin

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

Более подробно о всех комопонентах :

1) Spring Boot приложение, использующее в качестве БД PostgreSQL

2) Docker образ сервера базы данных

3) Docker Grafana( dashboard для отображеия логов)

4) Docker образ Loki(система сбора логов)

5) Promtail ( агент для отсылки логов в Loki).

Kubernetes cluster будет развернут при помощи microk8s. В качестве балансировщика нагрузки и по совместительству web-сервера будет выступать nginx, а точнее nginx-ingress-controller, который есть в microk8s.


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

Шаг 1: База данных

Для базы данных используем следующий yaml

apiVersion: v1kind: Servicemetadata:  name: dbspec:  ports:    - port: 5432  selector:    app: db  clusterIP: None---apiVersion: apps/v1kind: Deploymentmetadata:  name: dbspec:  selector:    matchLabels:      app: db  strategy:    type: Recreate  template:    metadata:      labels:        app: db    spec:      containers:        - image: postgres:9.6          name: db          env:            - name: POSTGRES_USER              value: admin            - name: POSTGRES_PASSWORD              value: admin            - name: POSTGRES_DB              value: dbname          ports:            - containerPort: 5432              name: db

В файле сразу описан и сервис,и развертывание базы. Как образ ,использован образ Postgres 9.6

Для создания развертывания исполним командуkubectl apply -f db.yaml

Шаг 2: Grafana

Для Grafana используем следующий yaml

apiVersion: v1kind: Servicemetadata:  name: grafanaspec:  ports:    - port: 3000  selector:    app: grafana  clusterIP: None---apiVersion: apps/v1kind: Deploymentmetadata:  name: grafanaspec:  selector:    matchLabels:      app: grafana  strategy:    type: Recreate  template:    metadata:      labels:        app: grafana    spec:      containers:        - image: grafana/grafana:master          name: grafana          ports:            - containerPort: 3000              name: grafana

Развертывание похоже на то,что использовано для базы данных. Разница в образе (grafana/grafana:master) и в выставляемом порте.

Аналогично выполним командуkubectl apply -f grafana.yaml

Шаг 3: Loki

Как и выше yaml

apiVersion: v1kind: Servicemetadata:  name: lokispec:  ports:    - port: 3100  selector:    app: loki  clusterIP: None---apiVersion: apps/v1kind: Deploymentmetadata:  name: lokispec:  selector:    matchLabels:      app: loki  strategy:    type: Recreate  template:    metadata:      labels:        app: loki    spec:      containers:        - image: grafana/loki:latest          name: loki          ports:            - containerPort: 3100              name: loki

И командаkubectl apply -f grafana.yaml

Шаг 4: Promtail

Для promtail понадобится Helm. Можно использовать helm, встроенный в microk8s(поддерживаются версии 2 и 3). Также можно установить Helm отдельно. В таком случае необходимо в файле config , располложенном в директории .kube, указать ip кластера. Можно выполнить microk8s config

Шаг 5: Ingress

Для nginx используем следующий файл.

apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  name: serverspec:  rules:     #for nginxinc controller host should be set    - http:        paths:          - path: /            pathType: Prefix            backend:              service:                name: server                port:                  number: 8024                            - path: /grafana            pathType: Prefix            backend:              service:                name: grafana                port:                  number: 3000       

И команду kubectl apply -f ingress.yaml

Шаг 7: Приложение

Этот шаг не похож ни на один предыдущий. Здесь не будет использовано ни одного yaml и ни одного готовго Docker образа. Нужное нам развертывание будет создано сразу после процесса сборки. Для этого используется Maven + jkube maven plugin

Сначала install соберирает jar с приложением,затем k8s:resource генерирует ресурсы, потом k8s:build создаст Docker oбраз и k8s:deploy сделает развертывание.

Ниже пример конфигурации плагина для данного процесса

<profile>            <id>kube</id>            <properties>                <spring.profiles.active>docker</spring.profiles.active>            </properties>            <build>                <plugins>                    <plugin>                        <groupId>org.eclipse.jkube</groupId>                        <artifactId>kubernetes-maven-plugin</artifactId>                        <version>1.1.1</version>                        <configuration>                            <verbose>true</verbose>                                      <images>                                <image>                                    <name>imagename:latest</name>                                    <alias>some-alias/alias>                                    <build>                                        <maintainer>John Smith</maintainer>                                        <from>fabric8/java-centos-openjdk11-jre</from>                                        <assembly>                                            <inline>                                                <baseDirectory>/deployments</baseDirectory>                                            </inline>                                        </assembly>                                    </build>                                </image>                            </images>                        </configuration>                        <executions>                            <execution>                                <id>run</id>                                <goals>                                    <goal>resource</goal>                                    <goal>build</goal>                                    <goal>deploy</goal>                                </goals>                            </execution>                        </executions>                    </plugin>                </plugins>            </build>        </profile>

Образ описан под тегом image. Также можно использовать один из генераторов.

Также необходимо создать сервис. Для этого выполним

kubectl expose deployment server --type=LoadBalancer --name=server --port=<some-port>

Почему был использован данный способ создания сервиса,ведь сервис можно сконфигрурировать также в плагине ? На данный момент,была обнарудена ошибка в момент старта приложения в поде : вместо ip сервиса,приходит строка tcp://<ip-service> . Это приводит к NumberFormatException.

Шаг 8: Проверка доступа

В браузере или с помощью curl проверить,что localhost возвращает страницу приложения, localhost/grafana покажет странцицу входа в Grafana.

Шаг 9: Отобразить логи

Для этого необходимо войти в Grafana с помощью логина/пароля admin . После необходимо указать ,в качестве источника данных Loki(http://personeltest.ru/away/loki:3000). Затем в explore ввести {app="название-приложения"} .


PS.

Сбор логов был основан на данной статье

Подробнее..
Категории: Kubernetes , Java , Grafana , Loki , Spring-boot

Trunk Based Development и Spring Boot, или ветвись оно все по абстракции

24.12.2020 20:04:36 | Автор: admin

Всем привет!

Закончилась осень, зима вступила в свои законные права, листья уже давно опали и перепутанные ветви кустарников наталкивают меня на мысли о моём рабочем Git репозитории Но вот начался новый проект: новая команда, чистый, как только что выпавший снег репозиторий. "Тут все будет по другому" - думаю я и начинаю "гуглить" про Trunk Based Development.

Если у вас никак не получается поддерживать git flow, вам надоели кучи этих непонятных веток и правил для них, если в вашем проекте появляются ветки вида "develop/ivanov", то добро пожаловать в под кат! Там я пробегусь по основным моментам Trunk Based Development и расскажу о том, как реализовать такой подход, используя Spring Boot.

Введение

Trunk Based Development (TBD) это подход, при котором вся разработка ведется на основе единственной ветки trunk (ствол). Чтобы воплотить такой подход в жизнь, нам нужно следовать трем основным правилам:

1) Любые коммиты в trunk не должны ломать сборку.

2) Любые коммиты в trunk должны быть маленькими, на столько, что review нового кода не должно занимать более 10 минут.

3) Релиз выпускается только на основе trunk.

Договорились? Теперь давайте разбираться на примере.

Начало разработки

Initial commit

Я не придумал ни чего лучше как написать приложения "оповещатель", REST сервис которому мы передаем оповещение в виде json, а он уже оповещает кого написано. Для начала собираем наш проект на spring initializr. Я сделал Maven Project, язык Java версия 8, Spring Boot 2.4.0. Зависимости нам понадобятся следующие:

Зависимости

Название

Тип

Описание

Spring Configuration Processor

DEVELOPER TOOLS

Generate metadata for developers to offer contextual help and "code completion" when working with custom configuration keys (ex.application.properties/.yml files).

Validation

I/O

JSR-303 validation with Hibernate validator.

Spring Web

WEB

Build web, including RESTful, applications using Spring MVC. Uses Apache Tomcat as the default embedded container.

Lombok

DEVELOPER TOOLS

Java annotation library which helps to reduce boilerplate code.

Инициализируем git репозиторий и пушим на GitHub или куда вам больше нравится. Основную ветку можно назвать по своему усмотрению: main, master или даже так и назвать - trunk, чтобы всем сразу было понятно чем вы тут занимаетесь. Все. Посадили деревце. Теперь будем бережено его выращивать.

Первая фича

Напишем первую реализацию которая будет отправлять сообщение на почту. Для начала опишем свойства нашего сервиса в виде ConfigurationProperties. У приложения пока будут только два свойства: sender-email - почтовый адрес отправителя и email-subject - тема письма в оповещении.

NotificationProperties
 @Getter @Setter @Component @Validated //говорим, что свойства должны проверяться @ConfigurationProperties(prefix = "notification") public class NotificationProperties {     @Email //проверяем что это почта     @NotBlank //проверяем что поле заполнено     private String senderEmail;     @NotBlank     private String emailSubject; }

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

Собственно реализация для данного примера нам вообще не понадобится.

EmailSender
@Slf4j@Componentpublic class EmailSender {    /**     * Отправляет сообщение на почту понарошку     */    public void sendEmail(String from, String to, String subject, String text){        log.info("Send email\nfrom: {}\nto: {}\nwith subject: {}\nwith\n text: {}", from, to, subject, text);    }}


Напишем простую модельку для оповещения:

Notification
@Getter@Setter@Builder@AllArgsConstructorpublic class Notification {    private String text;    private String recipient;}

Сервис оповещения:

NotificationService
@Service@RequiredArgsConstructorpublic class NotificationService {    private final EmailSender emailSender;    private final NotificationProperties notificationProperties;    public void notify(Notification notification){        String from = notificationProperties.getNotificationSenderEmail();        String to = notification.getRecipient();        String subject = notificationProperties.getNotificationEmailSubject();        String text = notification.getText();        emailSender.sendEmail(from, to, subject, text);    }}

И наконец контроллер:

NotificationController
@RestController@RequiredArgsConstructorpublic class NotificationController {    private final NotificationService notificationService;    @PostMapping("/notification/notify")    public void notify(Notification notification){        notificationService.sendNotification(notification);    }}

Ещё нам конечно понадобится тесты, без них TBD не получится. Напишем тест для NotificationService:

NotificationServiceTest
@SpringBootTestclass NotificationServiceTest {    @Autowired    NotificationService notificationService;    @Autowired    NotificationProperties properties;    @MockBean    EmailSender emailSender;    @Test    void emailNotification() {        Notification notification = Notification.builder()                .recipient("test@email.com")                .text("some text")                .build();        notificationService.notify(notification);        ArgumentCaptor<string> emailCapture = ArgumentCaptor.forClass(String.class);        verify(emailSender, times(1))                .sendEmail(emailCapture.capture(),emailCapture.capture(),emailCapture.capture(),emailCapture.capture());        assertThat(emailCapture.getAllValues())                .containsExactly(properties.getSenderEmail(),                                notification.getRecipient(),                                properties.getEmailSubject(),                                notification.getText()                );    }}

И для NotificationController

NotificationControllerTest
@WebMvcTest(controllers = NotificationController.class)class NotificationControllerTest {    @Autowired    MockMvc mockMvc;    @Autowired    ObjectMapper objectMapper;    @MockBean    NotificationService notificationService;    @SneakyThrows    @Test    void testNotify() {        ArgumentCaptor<notification> notificationArgumentCaptor = ArgumentCaptor.forClass(Notification.class);        Notification notification = Notification.builder()                .recipient("test@email.com")                .text("some text")                .build();        mockMvc.perform(post("/notification/notify")                .contentType(MediaType.APPLICATION_JSON)                .content(objectMapper.writeValueAsString(notification)))                .andExpect(status().isOk());        verify(notificationService, times(1)).notify(notificationArgumentCaptor.capture());        assertThat(notificationArgumentCaptor.getValue())                .usingRecursiveComparison()                .isEqualTo(notification);    }}

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

Для настоящих проектов я очень рекомендую делать первый коммит именно таким, чтобы он был как можно меньше и удовлетворял нашему второму правилу - code review меньше чем за 10 минут.

Профили

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

NotificationTask
@Component@EnableScheduling@RequiredArgsConstructorpublic class NotificationTask {    private final NotificationService notificationService;    private final NotificationProperties notificationProperties;    @Scheduled(fixedDelay = 1000)    public void notifySubscriber(){        notificationService.notify(Notification.builder()                .recipient(notificationProperties.getSubscriberEmail())                .text("Notification is worked")                .build());    }}

Теперь прогоним наши тесты и получим исключение для теста сервиса:

"org.mockito.exceptions.verification.TooManyActualInvocations".

Конечно, ведь в нашем тесте ожидался один вызов метода sendEmail, а получилось больше, так как теперь этот же метод вызывается в задаче.

Не порядок. Можно конечно выставить задаче initialDelay, что бы тест успел запустится раньше чем задача, но это будет костыль. Вместо этого как вы уже наверное догадались мы применим профиль. Вынесем аннотацию @EnableScheduling в отдельную конфигурацию и добавим аннотацию @Profile где скажем, что нужно запускать задачи всегда, кроме как в профиле "test".

SchedulingConfig
@Profile("!test")@Configuration@EnableSchedulingpublic class SchedulingConfig {}

В тестовых ресурсах, в application.yaml добавим включение профиля:

application.yaml
spring:  profiles:    active: testnotification:  email-subject: Auto notification  sender-email: robot@somecompany.com

Теперь все должно заработать, в тестах, задачи по расписанию больше не запускаются, но если просто запустить приложение из main метода, то задачи будут исправно тикать.

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

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

Для более точечного управления функциями приложения лучше использовать Feature flags, но этот способ мы рассмотри уже после нашего первого релиза. Сделали rebase, прогнали сборку с тестами и запушили в trunk.

Первый релиз

Давайте немного отвлечемся от кодирования и посмотрим, что делать с релизами. В TBD описано два способа выпускать релизы: первый из релизной ветки, второй прямо из trunk. Здесь я разберу первый способ.

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

Для git выкачать прошлый коммит можно так:

git checkout <hash>

Теперь создаем новую релизную ветку, обязательно ставим метку c версией, и пушим в удаленный репозиторий.

git checkout -b Release_1.0.0git tag 1.0.0git push -u origin Release_1.0.0git push origin 1.0.0

Готово! Можно разворачивать код из этой ветке в staging, а затем и в production.

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

1) Разработчики не ведут в релизной ветки какие-либо работы

2) Релизная ветка не сливается с trunk

3) Если нужен Hotfix, делаем Cherry-pick из trunk и добавляем метку с минорной версией

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

Feature flags

Для второй версии нашего приложения у нас стоит задача добавить немного аудита в нашу систему: теперь каждое оповещение должно сохраняться в базу данных. Только вот проблема в том, что не известно когда на production её развернут и подготовят. Тогда, чтобы ни кого не ждать и не откладывать, то, что можно сделать сейчас, мы обернем данную функциональность в feature flag.

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

Добавляем зависимости для взаимодействия с базой данных. БД на production у нас будет например oracle (это не особо важно для примера), а для тестов будем использовать h2.

Зависимости (БД)
    <dependency>        <groupid>org.springframework.boot</groupid>        <artifactid>spring-boot-starter-data-jpa</artifactid>    </dependency>    <dependency>        <groupid>com.oracle.ojdbc</groupid>        <artifactid>ojdbc10</artifactid>    </dependency>    <dependency>        <groupid>com.h2database</groupid>        <artifactid>h2</artifactid>    </dependency>

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

FeatureProperties
@Getter@Setter@Component@ConfigurationProperties(prefix = "features.active")public class FeatureProperties {    boolean persistence;}

Сразу запишем в application.yaml в тестовых ресурсах features.active.persistence: on (spring сам поймет, что on==true).

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

Нашу модель переделываем в Entity.

Осторожно много аннотаций!

Notification (Entity)
@Entity@Getter@Setter@Builder@NoArgsConstructor@AllArgsConstructorpublic class Notification {    @Id    @GeneratedValue    private Long id;    private String text;    private String recipient;    @CreationTimestamp    private LocalDateTime time;}

Добавляем репозиторий

NotificationRepository
public interface NotificationRepository extends CrudRepository<notification, long=""> {}

В NotificationService добавим NotificationRepository и FeatureProperties как зависимости, в конце метода notify вызовем метод репозитория save, обернув его в обычный if.

NotificationService (Feature flag)
@Service@RequiredArgsConstructorpublic class NotificationService {    private final EmailSender emailSender;    private final NotificationProperties notificationProperties;    private final FeatureProperties featureProperties;    @Nullable    private final NotificationRepository notificationRepository;    public void notify(Notification notification){        String from = notificationProperties.getSenderEmail();        String to = notification.getRecipient();        String subject = notificationProperties.getEmailSubject();        String text = notification.getText();        emailSender.sendEmail(from, to, subject, text);        if(featureProperties.isPersistence()){            notificationRepository.save(notification);        }    }}

Забегая немного вперед, аннотация @Nullable для поля NotificationRepository нам нужна, чтобы Spring не падал с ошибкой UnsatisfiedDependencyException, если не найдет такой бин у себя в контексте.

Теперь можно запустить тесты, и увидеть, что все они прошли, но если мы запустим наше приложение оно будет требовать указать url для базы данных и не будет запускаться.

Исправлять будем примерно так же как и для задач по расписанию. Создадим отдельную конфигурацию где укажем, что автоконфигурация для базы данных должна быть исключена, если флаг features.active.persistence: off (spring сам поймет, что off==false).

DataJpaConfig
@Configuration@ConditionalOnProperty(prefix = "features.active", name = "persistence",                        havingValue = "false", matchIfMissing = true)@EnableAutoConfiguration(exclude = {        DataSourceAutoConfiguration.class,        DataSourceTransactionManagerAutoConfiguration.class,        HibernateJpaAutoConfiguration.class})public class DataJpaConfig {}

Запускаем приложение с флагом features.active.persistence: off в свойствах. Приложение стартует, но не создает ни каких бинов связанных с работой базы данных.

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

--spring.config.additional-location=file:/etc/config/features.yaml

Или передать с помощью аргументов VM, например:

-Dfeatures.active.persistence=true

Правил с флагами будет два:

1) После того как функциональность полностью протестирована и стабильно работает, флаг этой функции нужно удалить

2) Мест в коде, где идет ветвление по одному и тому же feature флагу, должно быть минимальное количество

По второму правилу поясню подробнее. Если, ваша новая функциональность которую вы хотите обернуть в feature флаг, заставляет вас писать код вида: "if (flag) {}" в нескольких местах сразу, то вам стоит задуматься либо над дизайном вашей системы, либо о приеме "ветвления по абстракции", который как раз сейчас и разберем.

Branch by Abstraction

В третьей версии, настало время расширять функциональность оповещений.

Теперь с клиентской части нашего приложения в сообщениях, будет приходить тип оповещения: EMAIL, SMS или PUSH. Следовательно нам необходимо реализовать два дополнительных "отправщика" сообщений, а ещё логику в самом сервисе уведомлений которая будет определять реализацию.

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

Рецепт от Мартина Фаулера прост:

1) Выделить интерфейс для заменяемой функциональности

2) Заменить прямой вызов реализации в клиенте на обращение к интерфейсу

3) Создать новую реализацию которая реализует интерфейс

4) Подменить реализацию на новую

5) Удалить старую реализацию

Первым делом нам нужно сделать интерфейс NotificationService вместо класса, а сам класс переименовать в EmailNotificationService. В Inellij IDEA это можно провернуть с помощью рефакторинга:

1) Правой кнопкой по классу, выбрать Refactor/Extract interface

2) Выбрать опцию "Rename original class and use interface where possible"

3) В поле "Rename implementation class to" вписываем "EmailNotificationService"

4) В "Members to from interface" нажать галочку напротив метода "notify"

5) Нажать кнопку "Refactor"

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

Сделали rebase, прогнали сборку с тестами и запушили в trunk.

После этого можно спокойно продолжать работу уже над новой реализацией. Добавим в модель поле с типом оповещения, пусть это просто Enum.

NotificationType
public enum NotificationType {    EMAIL, SMS, PUSH, UNKNOWN}

Так же нам нужно будет добавить два новых компонента "отправителя":

SmsSender и PushSender.

Senders
@Slf4j@Componentpublic class SmsSender {    /**     * Отправляет сообщение на телефон     */    public void sendSms(String phoneNumber, String text){        log.info("Send sms {}\nto: {}\nwith text: {}", phoneNumber, text);    }    }@Slf4j@Componentpublic class PushSender {        /**     * Отправляет push уведомления     */    public void push(String id, String text){        log.info("Push {}\nto: {}\nwith text: {}", id, text);    }}

Новую реализацию сервиса назовем MultipleNotificationService, и для начала напишем "в лоб".

MultipleNotificationService - switch case
@Service@RequiredArgsConstructorpublic class MultipleNotificationService implements NotificationService {    private final EmailSender emailSender;    private final PushSender pushSender;    private final SmsSender smsSender;    private final NotificationProperties notificationProperties;    private final NotificationRepository notificationRepository;    @Override    public void notify(Notification notification) {        String from = notificationProperties.getSenderEmail();        String to = notification.getRecipient();        String subject = notificationProperties.getEmailSubject();        String text = notification.getText();        NotificationType notificationType = notification.getNotificationType();        switch (notificationType!=null ? notificationType : NotificationType.UNKNOWN) {            case PUSH:                pushSender.push(to, text);                break;            case SMS:                smsSender.sendSms(to, text);                break;            case EMAIL:                emailSender.sendEmail(from, to, subject, text);                break;            default:                throw new UnsupportedOperationException("Unknown notification type: " + notification.getNotificationType());         }        notificationRepository.save(notification);    }}

Запустив тесты, обнаружим, что NotificationServiceTest стал падать с ошибкой:

"expected single matching bean but found 2: emailNotificationService, multipleNotificationService".

Вылечить проблему можно например добавлением аннотации @Primary над старой реализацией сервиса - EmailNotificationService.

@Primary - сделает бин приоритетным для инъекции, но в тоже время бины с тем же типом все равно создадутся в контексте и мы сможем внедрить новую реализацию в тест.

Другой вариант - просто убрать аннотацию @Service из новой реализации тем самым исключив её из контекста, а для теста написать отдельную конфигурацию или вообще не писать Spring тест, а написать простой unit тест, где создавать компоненты самим через "new".

Я воспользуюсь первым вариантом и напишу отдельный Spring тест для новой реализации.

MultipleNotificationServiceTest
@SpringBootTestclass MultipleNotificationServiceTest {    @Autowired    MultipleNotificationService multipleNotificationService;    @Autowired    NotificationProperties properties;    @MockBean    EmailSender emailSender;    @MockBean    PushSender pushSender;    @MockBean    SmsSender smsSender;    @Test    void emailNotification() {        Notification notification = Notification.builder()                .recipient("test@email.com")                .text("some text")                .notificationType(NotificationType.EMAIL)                .build();        multipleNotificationService.notify(notification);        ArgumentCaptor<string> emailCapture = ArgumentCaptor.forClass(String.class);        verify(emailSender, times(1))                .sendEmail(emailCapture.capture(),emailCapture.capture(),emailCapture.capture(),emailCapture.capture());        assertThat(emailCapture.getAllValues())                .containsExactly(properties.getSenderEmail(),                        notification.getRecipient(),                        properties.getEmailSubject(),                        notification.getText()                );    }    @Test    void pushNotification() {        Notification notification = Notification.builder()                .recipient("id:1171110")                .text("some text")                .notificationType(NotificationType.PUSH)                .build();        multipleNotificationService.notify(notification);        ArgumentCaptor<string> captor = ArgumentCaptor.forClass(String.class);        verify(pushSender, times(1))                .push(captor.capture(),captor.capture());        assertThat(captor.getAllValues())                .containsExactly(notification.getRecipient(),  notification.getText());    }    @Test    void smsNotification() {        Notification notification = Notification.builder()                .recipient("+79157775522")                .text("some text")                .notificationType(NotificationType.SMS)                .build();        multipleNotificationService.notify(notification);        ArgumentCaptor<string> captor = ArgumentCaptor.forClass(String.class);        verify(smsSender, times(1))                .sendSms(captor.capture(),captor.capture());        assertThat(captor.getAllValues())                .containsExactly(notification.getRecipient(),  notification.getText());    }    @Test    void unsupportedNotification() {        Notification notification = Notification.builder()                .recipient("+79157775522")                .text("some text")                .build();        assertThrows(UnsupportedOperationException.class, () -> {            multipleNotificationService.notify(notification);        });    }}

Сделали rebase, прогнали сборку с тестами, запушили в trunk, получили от команды по шапке за switch-case.

Реализовать логику красиво позволит шаблон "Стратегия", но тут есть проблема в том, что у всех компонент "отправителей" разные интерфейсы, собственно как скорее всего и будет в реальности, ведь обычно такие компоненты предоставляются внешними библиотекам. Решить проблему с разными интерфейсами можно с помощью шаблона "Адаптер". Расписывать подробно здесь не буду, статья всё таки о другом, но код вы можете посмотреть у меня на GitHub.

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

На этот раз код не вызвал ни у кого негатива, и его можно включать в программу. Делать будем это с помощью все того же feature флага.

В класс с флагами добавляем новый:

  boolean multipleSenders;

Над классом EmailNotificationService добавляем аннотацию с условием (ни в коем случае не удалять @Primary):

"Выключить, только, если флаг features.active.multiple-senders установлен (matchIfMissing) и равен false"

@ConditionalOnProperty(prefix = "features.active",                        name = "multiple-senders",                        havingValue = "false",                        matchIfMissing = true)

Над MultipleNotificationService нужно добавить аннотацию с "зеркальным" условием:

"Включить, только, если флаг features.active.multiple-senders не установлен (matchIfMissing) или равен true"

@ConditionalOnProperty(prefix = "features.active",                        name = "multiple-senders",                        havingValue = "true",                       matchIfMissing = true)

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

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

И снова rebase, прогнали сборку с тестами, запушили в trunk. Вся команда проверила ваш код и благополучно забыла, что у вас ещё есть задача по расписанию, которой не сказали с каким типом ей отправлять оповещения.

На production заметили, что оповещения по расписанию больше не запускаются, но благодаря feature флагу все сразу же откатили обратно.

Команде разработки выдали логи, она принялась за исправление, и параллельно начала обдумывать как правильно сделать Hotfix, улучшить code review и тестирование проекта, чтобы более не сталкиваться с подобными проблемами но это уже совсем другая история, а нам пора подводить итоги.

Итоги

Trunk Based Development - отличная модель ветвления, которая наконец-то поможет вам избавится от кошмара слияния веток, позволит получить больше контроля над кодом, а команду сделать более дисциплинированной, превратит "теневое внедрение" из просто интересного приема в обыденность.

Trunk Based Development - очень гибкая методология, у неё есть несколько вариаций из которых вы сможете выбрать наиболее подходящий вариант, и конечно для её применения не обязательно использовать Spring Boot, но надеюсь я смог показать, что с ним это просто и удобно.

На этом всё, внизу будут все ссылки из статьи, спасибо за внимание!

Ссылки

Мой код на GitHub

TBD

spring initializr

ConfigurationProperties

Spring profiles

Feature flags

релизная ветка

релиз из trunk

Branch by abstraction

Стратегия

Адаптер

Подробнее..
Категории: Git , Devops , Java , Spring , Spring-boot , Trunk , Branching , Feature flags

Перевод Примеры GraphQL на Java для начинающих со Spring Boot

02.08.2020 22:16:32 | Автор: admin

В этой статье мы рассмотрим пример GraphQL на Java и создадим простой сервер GraphQL со Spring Boot.



Таким цыпочкам тоже нравятся примеры GraphQL на Java со Spring Boot!


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


В этой статье мы рассмотрим пример GraphQL на Java и создадим простой сервер GraphQL со Spring Boot.


Добавление зависимостей Maven


Создайте пример Spring Boot приложения и добавьте следующие зависимости.


  1. graphql-spring-boot-starter используется для включения сервлета GraphQL и будет доступен по пути /graphql. Он инициализирует GraphQLSchema бин.
  2. graphql-java позволяет нам писать схемы на языке схем GraphQL, который прост для понимания.
  3. graphiql-spring-boot-starter предоставляет пользовательский интерфейс, с помощью которого мы сможем тестировать наши запросы на GraphQL и просматривать определения запросов.
        <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphql-spring-boot-starter</artifactId>        <version>5.0.2</version>    </dependency>    <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphql-java-tools</artifactId>        <version>5.2.4</version>    </dependency>    <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphiql-spring-boot-starter</artifactId>        <version>5.0.2</version>    </dependency>
    

    Вот полное содержимое файла POM.

    <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://personeltest.ru/away/maven.apache.org/POM/4.0.0"     xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"     xsi:schemaLocation="http://personeltest.ru/away/maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.techshard.graphql</groupId><artifactId>springboot-graphql</artifactId><version>1.0-SNAPSHOT</version><parent>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-parent</artifactId>    <version>2.1.6.RELEASE</version>    <relativePath /></parent><properties>    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding></properties><dependencies>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-web</artifactId>    </dependency>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-data-jpa</artifactId>    </dependency>    <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphql-spring-boot-starter</artifactId>        <version>5.0.2</version>    </dependency>    <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphql-java-tools</artifactId>        <version>5.2.4</version>    </dependency>    <dependency>        <groupId>com.graphql-java</groupId>        <artifactId>graphiql-spring-boot-starter</artifactId>        <version>5.0.2</version>    </dependency>    <dependency>        <groupId>com.h2database</groupId>        <artifactId>h2</artifactId>        <scope>runtime</scope>    </dependency>    <dependency>        <groupId>org.projectlombok</groupId>        <artifactId>lombok</artifactId>        <version>1.18.8</version>        <optional>true</optional>    </dependency></dependencies><build>    <plugins>        <plugin>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-maven-plugin</artifactId>        </plugin>    </plugins></build></project>
    

    Создание сущности и репозитория JPA


    Давайте создадим простую сущность с именем Vehicle и соответствующий JPA репозиторий. Мы будем использовать Lombok, чтобы избежать написания шаблоного кода, такого как геттеры, сеттеры и так далее.



    package com.techshard.graphql.dao.entity;import lombok.Data;import lombok.EqualsAndHashCode;import javax.persistence.*;import java.io.Serializable;import java.time.LocalDate;@Data@EqualsAndHashCode@Entitypublic class Vehicle implements Serializable {    private static final long serialVersionUID = 1L;    @Id    @Column(name = "ID", nullable = false)    @GeneratedValue(strategy = GenerationType.AUTO)    private int id;    @Column(name = "type", nullable = false)    private String type;    @Column(name = "model_code", nullable = false)    private String modelCode;    @Column(name = "brand_name")    private String brandName;    @Column(name = "launch_date")    private LocalDate launchDate;    private transient  String formattedDate;    // Getter and setter    public String getFormattedDate() {        return getLaunchDate().toString();    }}
    

    Вот соответствующий репозиторий JPA.


    package com.techshard.graphql.dao.repository;import com.techshard.graphql.dao.entity.Vehicle;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.stereotype.Repository;@Repositorypublic interface VehicleRepository extends JpaRepository<Vehicle, Integer> {}
    

    Схема GraphQL


    GraphQL поставляется с собственным языком для написания схем GraphQL, который называется Schema Definition Language (SDL язык определения схемы). Определение схемы состоит из всех функций API, доступных в конечной точке.


    Типичный пример схемы GraphQL будет выглядеть так:


    type Vehicle {id: ID!,type: String,modelCode: String,brandName: String,launchDate: String}type Query {vehicles(count: Int):[Vehicle]vehicle(id: ID):Vehicle}type Mutation {createVehicle(type: String!, modelCode: String!, brandName: String, launchDate: String):Vehicle}
    

    Создайте папку graphql в папке src/main/resources и в ней создайте файл vehicleql.graphqls. Скопируйте вышеуказанное содержимое и вставьте его в файл vehicleql.graphqls. Обратите внимание, что именем файла может быть любое имя по вашему выбору. Просто убедитесь, что у имени файла есть расширение .graphqls.


    В приведенной выше схеме каждый объект имеет определенный тип. Система типов в GraphQL является базовым компонентом и она представляет тип объекта, который можно получить от сервиса и полей, которые содержит объект.


    В нашей схеме есть объект с именем Vehicle, который является нашим объектом домена. Тип Query представляет запрос, который можно сделать на сервер GraphQL для извлечения данных. Этот запрос является интерактивным, данные можно изменить, и новые результаты можно увидеть. Структура запроса и результат одинаковы. Это важно в мире GraphQL, потому что мы всегда получаем ожидаемый результат.


    Ниже в этой статье мы увидим рабочий пример.


    Тип Mutation представляет запросы, которые используются для выполнения операций записи данных.


    Root Query


    Объекты Query или Mutation являются основными объектами GraphQL. У них нет связанных классов данных. В таких случаях классы распознавателя (resolver) будут реализовывать GraphQLQueryResolver или GraphQLMutationResolver. Эти распознаватели будут искать методы, которые соответствуют полями в соответствующих основных типах.


    Давайте определим основные распознаватели для Vehicle.


    package com.techshard.graphql.query;import com.coxautodev.graphql.tools.GraphQLQueryResolver;import com.techshard.graphql.dao.entity.Vehicle;import com.techshard.graphql.service.VehicleService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.util.List;import java.util.Optional;@Componentpublic class VehicleQuery implements GraphQLQueryResolver {    @Autowired    private VehicleService vehicleService;    public List<Vehicle> getVehicles(final int count) {        return this.vehicleService.getAllVehicles(count);    }    public Optional<Vehicle> getVehicle(final int id) {        return this.vehicleService.getVehicle(id);    }}
    

    В этом классе реализованы методы для получения одного объекта Vehicle и списка объектов Vehicle. Обратите внимание, что мы определили эти методы в нашей схеме выше.


    Теперь давайте определим распознаватель мутаций.


    package com.techshard.graphql.mutation;import com.coxautodev.graphql.tools.GraphQLMutationResolver;import com.techshard.graphql.dao.entity.Vehicle;import com.techshard.graphql.service.VehicleService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.time.LocalDate;@Componentpublic class VehicleMutation implements GraphQLMutationResolver {    @Autowired    private VehicleService vehicleService;    public Vehicle createVehicle(final String type, final String modelCode, final String brandName, final String launchDate) {        return this.vehicleService.createVehicle(type, modelCode, brandName, launchDate);    }}
    

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


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


    package com.techshard.graphql.service;import com.techshard.graphql.dao.entity.Vehicle;import com.techshard.graphql.dao.repository.VehicleRepository;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.time.LocalDate;import java.util.List;import java.util.Optional;import java.util.stream.Collectors;@Servicepublic class VehicleService {    private final VehicleRepository vehicleRepository ;    public VehicleService(final VehicleRepository vehicleRepository) {        this.vehicleRepository = vehicleRepository ;    }    @Transactional    public Vehicle createVehicle(final String type,final String modelCode, final String brandName, final String launchDate) {        final Vehicle vehicle = new Vehicle();        vehicle.setType(type);        vehicle.setModelCode(modelCode);        vehicle.setBrandName(brandName);        vehicle.setLaunchDate(LocalDate.parse(launchDate));        return this.vehicleRepository.save(vehicle);    }    @Transactional(readOnly = true)    public List<Vehicle> getAllVehicles(final int count) {        return this.vehicleRepository.findAll().stream().limit(count).collect(Collectors.toList());    }    @Transactional(readOnly = true)    public Optional<Vehicle> getVehicle(final int id) {        return this.vehicleRepository.findById(id);    }}
    

    Тестирование приложения


    Приложение теперь готово к тестированию. Запустите приложение Spring Boot и откройте в браузере эту ссылку: http://localhost:8080/graphiql. Мы увидим хороший пользовательский интерфейс, как показано ниже.



    В правой части пользовательского интерфейса мы можем изучить документацию.



    Теперь запустите следующий запрос.


    mutation {  createVehicle(type: "car", modelCode: "XYZ0192", brandName: "XYZ", launchDate: "2016-08-16")   {    id  }}
    

    Это создаст строку в таблице Vehicle. Результат должен быть:


    {  "data": {    "createVehicle": {      "id": "1"    }  }}
    

    Давайте теперь запустим запрос, чтобы получить данные.


    query {  vehicles(count: 1)   {    id,     type,     modelCode}}
    

    Вывод будет выглядеть так:


    {  "data": {    "vehicles": [      {        "id": "1",        "type": "bus",        "modelCode": "XYZ123"      }    ]  }}
    

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


    Вывод


    В этой статье мы рассмотрели базовый пример GraphQL на Java со Spring Boot. Ознакомьтесь с подробной документацией здесь.


    Полный исходный код этого руководства можно найти на GitHub.


    Дальнейшее чтение


    Introduction to GraphQL


    GraphQL: Core Features, Architecture, Pros, and Cons


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


    Прим. Переводчика.
    На русском языке также есть Руководство по GraphQL для начинающих
Подробнее..
Категории: Java , Graphql , Spring-boot

Перевод Написание более читаемого кода с помощью Project Lombok

17.10.2020 18:18:02 | Автор: admin

В течение многих лет мы написали на Java много шаблонного кода, такого как методы getter, setter, equals, hashCode и т.д..В некоторых случаях это вызывает проблемы с точки зрения чистого и читаемого кода.

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

Фото Фачи Марина на UnsplashФото Фачи Марина на Unsplash

В этом руководстве я расскажу вам о Project Lombok и приведу пример использования Spring Boot.

  • Здесь используются проекты Spring Boot и Spring Data для разработки REST API.

  • Mavenиспользуется для автоматизации процесса сборки.

  • Используется типичный подход к разработке на основе предметной области (domain-driven), разделяющий классы модели, репозитория, сервиса и контроллера.

Вы можете скачать исходный код проекта по этойссылке на GitHub

Что такое Project Lombok?

Я скопировал описание проекта Ломбок отсюда(Официальный сайт).Я не мог бы придумать лучшего предложения, чтобы объяснить это :).Project Lombok - это java-библиотека, которая автоматически подключается к вашему редактору и инструментам сборки, делая вашу java более пикантной.

Фото Канвара на SlideshareФото Канвара на Slideshare

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

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

Как вы видите ниже, вместо почти восьмидесяти строк кода было использовано несколько аннотаций.

Класс Book, созданный с использование Lombok

package com.medium.libraryinfosystem.model.lombok;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import javax.persistence.Entity;import javax.persistence.GeneratedValue;import javax.persistence.Id;import javax.persistence.Table;/** * @author ragcrix */@Data@AllArgsConstructor@NoArgsConstructor@Entity@Tablepublic class Book {    @Id    @GeneratedValue    private Long id;    private String writer;    private String name;    private String genre;    private String year;}

Описание используемых аннотаций согласноLombokJavaDoc:

  • @Data: Создает методы getter для всех полей, метод toString и реализации hashCode и equals, которые проверяют * все не transient поля.Также будет генерировать конструктор, а также методы setter для всех не finalполей.

  • @AllArgsConstructor: Создает конструктор со всеми аргументами.

  • @NoArgsConstructor: Создает конструктор без аргументов.

  • @Slf4j: Заставляет ломбок генерировать поле logger.

Ниже Вы можете увидеть класс модели Book, которая также создана без использования Lombok.

Класс Book, созданный с использование стандартного кода

package com.medium.libraryinfosystem.model.classic;import javax.persistence.Entity;import javax.persistence.GeneratedValue;import javax.persistence.Id;import javax.persistence.Table;import java.util.Objects;/** * @author ragcrix */@Entity@Tablepublic class Book {    @Id    @GeneratedValue    private Long id;    private String writer;    private String name;    private String genre;    private String year;    public Book() {    }    public Book(Long id, String writer,                String name, String genre, String year) {        this.id = id;        this.writer = writer;        this.name = name;        this.genre = genre;        this.year = year;    }    public Long getId() {        return id;    }    public void setId(Long id) {        this.id = id;    }    public String getWriter() {        return writer;    }    public void setWriter(String writer) {        this.writer = writer;    }    public String getName() {        return name;    }    public void setName(String name) {        this.name = name;    }    public String getGenre() {        return genre;    }    public void setGenre(String genre) {        this.genre = genre;    }    public String getYear() {        return year;    }    public void setYear(String year) {        this.year = year;    }    @Override    public boolean equals(Object o) {        if (this == o) return true;        if (o == null || getClass() != o.getClass()) return false;        Book book = (Book) o;        return Objects.equals(id, book.id) &&                Objects.equals(writer, book.writer) &&                Objects.equals(name, book.name) &&                Objects.equals(genre, book.genre) &&                Objects.equals(year, book.year);    }    @Override    public int hashCode() {        return Objects.hash(id, writer, name, genre, year);    }    @Override    public String toString() {        return "Book{" +                "id=" + id +                ", writer='" + writer + '\'' +                ", name='" + name + '\'' +                ", genre='" + genre + '\'' +                ", year='" + year + '\'' +                '}';    }}

Project Lombok также обеспечивает удобство ведения журнала следующим образом.Например, вы можете использовать@Slf4jаннотацию для ведения журнала.

Аннотация @ Slf4j используется для ведения журнала

package com.medium.libraryinfosystem.service.impl;import com.medium.libraryinfosystem.model.lombok.Book;import com.medium.libraryinfosystem.repository.BookRepository;import com.medium.libraryinfosystem.service.BookService;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.util.List;/** * @author ragcrix */@Slf4j@Servicepublic class BookServiceImpl implements BookService {    @Autowired    private BookRepository bookRepository;    @Override    public Book findByName(String name) {        log.info("inside findByName()");        return bookRepository.findByName(name);    }    @Override    public List<Book> findAll() {        log.info("inside findAll()");        return bookRepository.findAll();    }    @Override    public Book save(Book book) {        log.info("inside save()");        return bookRepository.save(book);    }    @Override    public void delete(Book book) {        log.info("inside delete()");        bookRepository.delete(book);    }}

Как установить Lombok

Мы можем легко интегрировать Lombok в нашу IDE (IntelliJ, Eclipse и т.д.).

В этом разделе я расскажу вам об установке Lombok для IntelliJ Idea.Вы можете перейтисюда,чтобы увидеть другие варианты установки.

  1. Во-первых, вы должны выбрать Lombok при инициализации проекта Spring Boot, как показано ниже.

Инициализация проектаИнициализация проекта

2. После этого, если вы не добавили плагин Lombok в свою IDE раньше, IntelliJ задаст вам вопрос.Вы должны выбрать плагин Lombok и нажать кнопку ОК, чтобы установить плагин в вашу среду IDE.

Установка Lombok Установка Lombok

Вы можете убедиться, что Lombok установлен в вашей среде IDE, как показано ниже.Как только установка Lombok будет завершена, мы готовы к работе.

Плагин Lombok установленПлагин Lombok установлен

Информационная система библиотеки

В этом руководстведля кодирования, сборки и тестирования REST APIиспользуютсяIntelliJ IDEA,Maven иPostman.Я работаю вWindows 10с помощью PowerShell.

Все программное обеспечение написано на Java, с использованием Spring 5.2 и Spring Boot 2.2.2, база данных H2 в памяти используется для сохранения данных.

Далее я предполагаю, что у вас установлены JDK 13.0, IntelliJ,Maven и Postman.

Информационная система библиотеки предоставляет информацию о книгах и позволяет добавлять новые книги в систему или удалять книги из системы.

Фото Иво Раиньи на UnsplashФото Иво Раиньи на Unsplash

Моя цель здесь - показать, как использовать Lombok простым способом без перетасовки бизнес-логики.

  • Операция POST используется для добавления книг в систему.

  • Таблицаbookв базе данных H2 в памяти содержит данные о названии каждой книги, авторе, жанре и году.

  • Все, что хранится, получается и возвращается службой, форматируется как JSON.

Модель Book

Эта модель имеет 5 полей: id, name, writer, genre и year.

package com.medium.libraryinfosystem.model.lombok;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import javax.persistence.Entity;import javax.persistence.GeneratedValue;import javax.persistence.Id;import javax.persistence.Table;/** * @author ragcrix */@Data@AllArgsConstructor@NoArgsConstructor@Entity@Tablepublic class Book {    @Id    @GeneratedValue    private Long id;    private String writer;    private String name;    private String genre;    private String year;}

Все использованные аннотации Lombok объяснены выше.

Репозиторий Book

Вы можете легко создать интерфейс, расширяющийCrudRepository,с помощью Spring Data Project.При этом нет необходимости реализовывать этот интерфейс :).

package com.medium.libraryinfosystem.repository;import com.medium.libraryinfosystem.model.lombok.Book;import org.springframework.data.repository.CrudRepository;import java.util.List;/** * @author ragcrix */public interface BookRepository extends CrudRepository<Book, Long> {    Book findByName(String name);    List<Book> findAll();    Book save(Book book);    void delete(Book book);}

В BookRepository есть четыре метода.Это простыеоперацииCRUD(создание, чтение, обновление, удаление).

Сервис Book

Сервис Bookимеет четыре метода.Просто эти методы выдают список книг по некоторым критериям, сохраняют или обновляют данные о книге и удаляют книгу.

package com.medium.libraryinfosystem.service;import com.medium.libraryinfosystem.model.lombok.Book;import java.util.List;/** * @author ragcrix */public interface BookService {    Book findByName(String name);    List<Book> findAll();    Book save(Book book);    void delete(Book book);}

Вы можете увидеть реализацию этого интерфейса ниже.

package com.medium.libraryinfosystem.service.impl;import com.medium.libraryinfosystem.model.lombok.Book;import com.medium.libraryinfosystem.repository.BookRepository;import com.medium.libraryinfosystem.service.BookService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.util.List;/** * @author ragcrix */@Servicepublic class BookServiceImpl implements BookService {    @Autowired    private BookRepository bookRepository;    @Override    public Book findByName(String name) {        return bookRepository.findByName(name);    }    @Override    public List<Book> findAll() {        return bookRepository.findAll();    }    @Override    public Book save(Book book) {        return bookRepository.save(book);    }    @Override    public void delete(Book book) {        bookRepository.delete(book);    }}

Book контроллер

BookController.java- это класс REST контроллера, который имеет два методас аннотацией @GetMapping,один с @PostMappingи один с@DeleteMapping.

package com.medium.libraryinfosystem.controller;import com.medium.libraryinfosystem.dto.BookDTO;import com.medium.libraryinfosystem.model.lombok.Book;import com.medium.libraryinfosystem.service.BookService;import com.medium.libraryinfosystem.util.ObjectMapperUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;/** * @ragcrix */@RestController@RequestMapping("/books")public class BookController {    @Autowired    private BookService bookService;    @GetMapping(value = "/")    public List<BookDTO> getAllBooks() {        return ObjectMapperUtils.mapAll(bookService.findAll(), BookDTO.class);    }    @GetMapping(value = "/byName/{bookName}")    public BookDTO getBookByName(@PathVariable("bookName") String bookName) {        return ObjectMapperUtils.map(bookService.findByName(bookName), BookDTO.class);    }    @PostMapping(value = "/save")    public ResponseEntity<?> saveOrUpdateBook(@RequestBody BookDTO bookDTO) {        bookService.save(ObjectMapperUtils.map(bookDTO, Book.class));        return new ResponseEntity("Book added successfully", HttpStatus.OK);    }    @DeleteMapping(value = "/delete/{bookName}")    public ResponseEntity<?> deleteBookByName(@PathVariable String bookName) {        bookService.delete(bookService.findByName(bookName));        return new ResponseEntity("Book deleted successfully", HttpStatus.OK);    }}

Конфигурация базы данных

Чтобы подключиться к базе данных H2, отредактируйте настройки вфайлеapplication.properties,который расположен в директори resources.Достаточно следующей конфигурации.

# Serverserver.port=9090# Enabling H2 Consolespring.h2.console.enabled=true# Datasourcespring.datasource.url=jdbc:h2:mem:testdbspring.datasource.driverClassName=org.h2.Driverspring.datasource.username=saspring.datasource.password=# JPAspring.jpa.show-sql=truespring.jpa.generate-ddl=truespring.jpa.database-platform=org.hibernate.dialect.H2Dialect

После запуска информационной системы библиотеки вы можете легко получить доступ к базе данных H2 по этойссылке.

Примечание. Каждый раз, когда запускается этот проект, будут выполняться скрипт в файле data.sql, который находится в директории resources.Конечно, для этого используется Spring Data.

insert into book values(1, 'SCI-FI', 'Frankenstein', 'Mary Shelley', '1818'); insert into book values(2, 'Fantastic', 'Harry Potter', 'J. K. Rowling', '1997');

Запуск службы и тестирование с помощью Postman

Вы можете легко запустить этот проект для тестирования службы REST.

Если вы хотите добавить книгу в информационную систему библиотеки, вы можете использоватьфайл data.jsonв корне проекта.

Запустите проект в IntelliJЗапустите проект в IntelliJ

После запуска проекта вы можете протестировать службу REST.Для этого откройте Postman, введите URL-адрес и выберитеметодGET,какпоказанониже.

Список книгСписок книг

Снова введите ссылку с названием книги в базу данных.

ФранкенштейнФранкенштейн

Вы можете добавить книгу в систему с помощьюметодаPOSTв Postman.Сначала вы должны ввестипару ключ-значение заголовкаContent-Type:application/json.Во-вторых, во вкладке body вы должны ввести информацию о книге, которую вы хотите сохранить в базе данных в формате JSON.

Добавление книгиДобавление книги

Если вы хотите удалить книгу, вам следует выбратьметодDELETEв Postman и добавить название книги, которую вы хотите удалить, в конец ссылки.

Удаление книгиУдаление книги

Ресурсы

Примечание. Вэтом руководстве рассматривается сочетание Spring Data, H2 в памяти и REST API.В проекте нет проверки для полей объекта.

Спасибо за прочтение!Ваши мысли очень ценны для меня.Пожалуйста, поделитесь.

Подробнее..
Категории: Java , Spring-boot , Lombok

Что было раньше код или документация? OpenApi (OAS 3.0) и проблемы кодогенерации на Java

12.11.2020 14:14:33 | Автор: admin

Задача интеграции сервисов и различных систем является чуть ли не одной из основных проблем современного IT. На сегодняшний день самым популярным архитектурным стилем для проектирования распределенных систем является REST. Но, как известно, единого стандарта для RESTfulсервисов нет, а у кажущейся простоты и свободы есть обратная сторона медали. Не менее важной является задача интеграции систем разными командами или даже компаниями, с которой приходит вопрос об актуальности документации на протяжении жизни всего проекта, и удобного способа передачи потребителю.

Эту проблему отчасти удалось решить при помощи спецификации OpenAPI(OAS3.0)[1], но все равно часто встает вопрос о правильном применении и подводных камнях кодогенерации, например на языке Java. И можно ли полностью предоставить аналитикам написание функциональных требований, документации и моделей в ymlформе для OpenAPI, а разработчикам предоставить возможность только написание бизнес-логики?

Что было раньше: код или документация?

Я уже около десяти лет занимаюсь разработкой на Java,и почти в каждом проекте так или иначе приходилось сталкиваться с необходимостью поделиться разработанным сервисом с другими людьми. Иногда достаточно просто составить понятное описание APIсуществующего сервиса, но в серьезной enterprise-разработке такое описание часто создается еще на этапе аналитики в виде полноценного документа и является основой для грядущей работы.

В некоторых случаях такие документы составляются на этапе проектирования архитектуры в обычном текстовом формате, а потом передаются разработчику для реализации. Однако, для разработки REST-APIтакой подход не очень удобен и усложняет жизнь как программистам, которые должны еще правильно понять написанное, так и командам, которые будут с получившимся продуктом интегрироваться.К тому же документы могут интерпретироваться людьми по-разному, что обязательно приведет к необходимости дополнительных обсуждений, согласований и переделок, что в конечном счете срывает сроки и добавляет дефекты в продукт. Хотя, метод не очень удобен, иногда он до сих пор применяется при проектировании проектов без OpenAPI(например файловая интеграция, интеграция через БД, интеграция через очереди и общую шину).

Бывают подходы, когда требования создаются программистами уже в процессе работы, тогда финальная документация формируется на основе реализованного кода и передается потребителям сервиса. Часто это оправдано при быстрой реализации гипотез, единоличной разработке или очень сжатых cроках, например, при создании стартапов или при участиив хакатонах. В этом случае удобно генерировать документацию на основе написанного кода и предоставлять потребителю вместе с сервисом.

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

В случае крупных и серьезных проектов, ручное документирование приводит к ряду больших проблем. В SOAPсервисах потребители, получая ссылку на WSDL-документ, генерируют на своей стороне модель данных и клиентский код. В отличие от SOAPпри интеграции OAS3.0 или OpenAPISpecificationявляется не просто спецификацией, а целой экосистемой инструментов для создания RESTсервисов, и она позволяет решить как задачу генерации документации по существующему коду, так и обратную - генерацию кода по документации. Нопрежде чем ответить на вечный вопрос, что первично, нужно определиться с целями и потребностями проекта.

Генерация документации по коду

Первым рассмотрим подход с автодокументируемым кодом[2,3]. Простота здесь заключается в том, что при использовании SpringMVCот разработчиков требуется всего лишь подключить пару библиотек, и на выходе уже получается вполне сносная документация с возможностью интерактивного взаимодействия с API.

Зависимости:

Spoiler

Maven

<dependency>   <groupId>org.springdoc</groupId>   <artifactId>springdoc-openapi-ui</artifactId>   <version>1.4.8</version></dependency>
<dependency>   <groupId>org.springdoc</groupId>   <artifactId>springdoc-openapi-webflux-ui</artifactId>   <version>1.4.8</version></dependency>

Gradle

compile group: 'org.springdoc', name: 'springdoc-openapi-ui', version: '1.4.8'
compile group: 'org.springdoc', name: 'springdoc-openapi-webflux-ui', version: '1.4.8'

Результат доступен на: http://localhost:8080/swagger-ui.html

Сгенерированная документация по ссылке: http://localhost:8080/v3/api-docs

Плюсы подхода:

  • Возможность быстро сгенерировать документацию по существующему коду

  • Простота интеграции

  • Актуальная онлайн документация

  • Встроенная возможность интерактивного взаимодействия

Минусы подхода:

  • Документация написанная для разработчиков, содержит немного бизнес информации

  • При требовании подробного описания моделей и API возникает необходимость внедрять в код аннотации.

  • Для любых изменений требуется участие разработчика и правки кода

  • Требует от разработчика полного понимания архитектуры приложения или дополнительного документа с ее описанием

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

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

Генерация кода по документации

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

Плюсы подхода

  • Ведение документации становится доступно не только разработчикам, но и техническим и системным аналитикам

  • Есть возможность разделить код и документацию

  • Можно объединить описание моделей, API и функциональных/бизнес требований

  • Любые изменения, инициированные бизнесом, сразу же применяются к модели и контроллерам

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

  • Документацию в yaml формате легко предоставить потребителям сервиса при помощи сторонних библиотек, например redoc[4]

  • На основе yaml файлов можно описать сервисы на стороне потребителя, например, OpenApi для получения нотификаций от основного сервиса

  • На основе этой документации генерировать у себя тестовые сервисы для интеграционных и функциональных тестов

Минусы подхода

  • Более актуально для крупных и долгоживущих проектов (от одного года разработки)

  • Требует знания yaml формата для описания моделей

  • Требует навыки работы с markdown для описания функциональных требований

  • Нужно хорошее взаимодействие в команде, чтобы результаты изменения документации и бизнес-логики в сервисе были синхронизированы

Подводные камни и опыт реализации:

Задача по применению второго подхода впервые появилась, когда потребовалось предоставить потребителям актуальную веб-версию документации с подробным описанием функциональных требований и бизнес-кейсов. Написание такой документации и ее актуализацию предстояло вести техническим аналитикам без возможности напрямую редактировать код. И еще одним важным требованием было красивое визуальное представление документации. Далее опишу базовый набор конфигураций для достижения этой цели и возможные подводные камни и проблемы[2,3]

Первым делом добавляем зависимость на mavenплагин

Spoiler
<dependency>   <groupId>io.swagger.codegen.v3</groupId>   <artifactId>swagger-codegen-maven-plugin</artifactId>   <version>3.0.21</version></dependency>
   <artifactId>springdoc-openapi-webflux-ui</artifactId>
   <version>1.4.8</version>

И конфигурируем генератор кода

Spoiler
<plugin>   <groupId>io.swagger.codegen.v3</groupId>   <artifactId>swagger-codegen-maven-plugin</artifactId>   <version>3.0.21</version>   <executions>       <execution>           <id>1</id>           <goals>               <goal>generate</goal>           </goals>           <configuration>               <groupId>com.habr</groupId>               <artifactId>oas3</artifactId>               <inputSpec>${project.basedir}/src/main/resources/habr-1.yaml</inputSpec>               <output>${project.build.directory}/generated-sources/</output>               <language>spring</language>               <configOptions>                   <sourceFolder>src/gen/java/main</sourceFolder>                   <library>spring-mvc</library>                   <interfaceOnly>true</interfaceOnly>                   <useBeanValidation>true</useBeanValidation>                   <dateLibrary>java8</dateLibrary>                   <java8>true</java8>                   <apiTests>false</apiTests>                   <modelTests>false</modelTests>               </configOptions>               <modelPackage>com.habr.oas3.model</modelPackage>               <apiPackage>com.habr.oas3.controller</apiPackage>           </configuration>       </execution>       <execution>...</execution>     ...   </executions>

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

InputSpecсодержит путь к документации, а modelPackageи apiPackageпакеты для сгенерированных моделей. Также можно генерировать моковые реализации интерфейсов для тестов (при генерации некоторый контроллер, что отвечает типичным ответом).

Флаг interfaceOnlyпозволяет генерировать только интерфейсы контроллеров и предоставить разработчику лишь контракты для реализации.

Общая структура yaml документации:

  1. openApi - Содержит версию

  2. info - метаданные для api

  3. servers - информация о серверах c api

  4. tags - дополнительные метаданные, в этом блоке нужно писать текст ФТ и регламента взаимодействия

  5. paths - описание endpoints и интерфейсов контроллеров

  6. components - описание модели данных

  7. security - схема безопасности

Исходный код на github и пример документации можно посмотреть здесь

Визуализация и предоставление потребителю: swagger-ui vs redoc

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

Swagger-ui

Удобная реализация с возможностью интерактивного взаимодействия. Идет из коробки в проекте swagger.io[5]. Можно подключить как библиотеку вместе с сервисом или развернуть статикой отдельно.

Пример визуализации документации тестового сервиса:

Redoc

Он позволяет отобразить и кастомизировать документацию альтернативным способом. Более удобная и красивая структура документации[4].

Пример визуализации документации тестового сервиса:

Хотя, redocреализован на react, его также можно использовать и как VueJSкомпонент:

Spoiler
<template>    <div v-if="hasYmlContent">        <RedocStandalone :spec='ymlObject' :options='ropt' />    </div></template><style...><script>    import AuthService from '../services/auth.service';    import DocumentationService from '../services/documentation.service'    import {RedocStandalone} from 'redoc'    import {API_URL} from "../services/auth-header";    import YAML from "yamljs"    import Jquery from "jquery"    export default {        name: 'documentation',        components: {'RedocStandalone': RedocStandalone},        props: ['yml'],        data: function() {            return {                ymlContent: false,                ropt:{...},            }        },        created() {...},        computed: {...}    };</script>

Выводы

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

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

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

Источники

  1. https://swagger.io/specification/

  2. https://www.baeldung.com/spring-rest-openapi-documentation

  3. https://github.com/springdoc/springdoc-openapi

  4. https://redocly.github.io/redoc/

  5. https://swagger.io/tools/swagger-ui/

Подробнее..

Разбираемся, как работает Spring Data Repository, и создаем свою библиотеку по аналогии

28.01.2021 18:09:16 | Автор: admin

На habr уже была статья о том, как создать библиотеку в стиле Spring Data Repository (рекомендую к чтению), но способы создания объектов довольно сильно отличаются от "классического" подхода, используемого в Spring Boot. В этой статье я постарался быть максимально близким к этому подходу. Такой способ создания бинов (beans) применяется не только в Spring Data, но и, например, в Spring Cloud OpenFeign.

Содержание

ТЗ

Начнем с краткого технического задания: что мы в итоге хотим получить. Весь бизнес-слой является полностью выдумкой и не является примером качественного программирования, основная его цель - показать, как можно взаимодействовать со Spring.

Итак, у нас прошли праздники, но мы хотим иметь возможность создавать на лету бины (beans), которые позволили бы нам поздравлять всех, кого мы в них перечислим.

Пример:

public interface FamilyCongratulator extends Congratulator {    void сongratulateМамаAndПапа();}

При вызове метода мы хотим получать:

Мама,Папа! Поздравляю с Новым годом! Всегда ваш

Или вот так

@Congratulate("С уважением, Пупкин")public interface ColleagueCongratulator {    @CongratulateTo("Коллега")    void сongratulate();}

и получать

Коллега! Поздравляю с Новым годом! С уважением, Пупкин

Т.е. мы должны найти все интерфейсы, которые расширяют интерфейс Congratulator или имеют аннотацию @Congratulate

В этих интерфейсах мы должны найти все методы, начинающиеся с congratulate , и сгенерировать для них метод, выводящий в лог соответствующее сообщение.

@Enable

Как и любая взрослая библиотека у нас будет аннотация, которая включает наш механизм (как @EnableFeignClients и @EnableJpaRepositories).

@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.TYPE)@Documented@Import(FeignClientsRegistrar.class)public @interface EnableFeignClients {...}@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@Import(JpaRepositoriesRegistrar.class)public @interface EnableJpaRepositories {...}

Если посмотреть внимательно, то можно заметить, что обе этиx аннотации содержат @Import, где есть ссылка на класс, расширяющий интерфейс ImportBeanDefinitionRegistrar

public interface ImportBeanDefinitionRegistrar { default void registerBeanDefinitions(    AnnotationMetadata importingClassMetadata,     BeanDefinitionRegistry registry,     BeanNameGenerator importBeanNameGenerator) {registerBeanDefinitions(importingClassMetadata, registry);} default void registerBeanDefinitions(    AnnotationMetadata importingClassMetadata,    BeanDefinitionRegistry registry) {}}

Напишем свою аннотацию

@Target({ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Import(CongratulatorsRegistrar.class)public @interface EnableCongratulation {}

Не забудем прописать @Retention(RetentionPolicy.RUNTIME), чтобы аннотация была видна во время выполнения.

ImportBeanDefinitionRegistrar

Посмотрим, что происходит в ImportBeanDefinitionRegistrar у Spring Cloud Feign:

class FeignClientsRegistrarimplements ImportBeanDefinitionRegistrar,    ResourceLoaderAware,     EnvironmentAware {...  @Override public void registerBeanDefinitions(AnnotationMetadata metadata,BeanDefinitionRegistry registry) { //создаются beans для конфигураций по умолчанию  registerDefaultConfiguration(metadata, registry); //создаются beans для создания клиентов  registerFeignClients(metadata, registry);}...   public void registerFeignClients(AnnotationMetadata metadata,BeanDefinitionRegistry registry) {  LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();... //выполняется поиск кандидатов на создание  ClassPathScanningCandidateComponentProvider scanner = getScanner();  scanner.setResourceLoader(this.resourceLoader);  scanner.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));  Set<String> basePackages = getBasePackages(metadata);  for (String basePackage : basePackages) {    candidateComponents.addAll(scanner.findCandidateComponents(basePackage));  }... for (BeanDefinition candidateComponent : candidateComponents) {  if (candidateComponent instanceof AnnotatedBeanDefinition) {...  //заполняем контекст   registerFeignClient(registry, annotationMetadata, attributes);   }  } }   private void registerFeignClient(BeanDefinitionRegistry registry,AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {String className = annotationMetadata.getClassName(); //Создаем описание для FactoryBeanDefinitionBuilder definition = BeanDefinitionBuilder    .genericBeanDefinition(FeignClientFactoryBean.class);...  //Регистрируем это описание BeanDefinitionHolder holder = new BeanDefinitionHolder(  beanDefinition, className, new String[] { alias }); BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry); }      ...}

В Spring Cloud OpenFeign сначала создаются бины конфигурации, затем выполняется поиск кандидатов и для каждого кандидата создается Factory.

В Spring Data подход аналогичный, но так как Spring Data состоит из множества модулей, то основные моменты разнесены по разным классам (см. например org.springframework.data.repository.config.RepositoryBeanDefinitionBuilder#build)

Можно заметить, что сначала создаются Factory, а не сами bean. Это происходит потому, что мы не можем в BeanDefinitionHolder описать, как должен работать наш bean.

Сделаем по аналогии наш класс (полный код класса можно посмотреть здесь)

public class CongratulatorsRegistrar implements         ImportBeanDefinitionRegistrar,        ResourceLoaderAware, //используется для получения ResourceLoader        EnvironmentAware { //используется для получения Environment    private ResourceLoader resourceLoader;    private Environment environment;    @Override    public void setResourceLoader(ResourceLoader resourceLoader) {        this.resourceLoader = resourceLoader;    }    @Override    public void setEnvironment(Environment environment) {        this.environment = environment;    }...

ResourceLoaderAware и EnvironmentAware используется для получения объектов класса ResourceLoader и Environment соответственно. При создании экземпляра CongratulatorsRegistrar Spring вызовет соответствующие set-методы.

Чтобы найти требуемые нам интерфейсы, используется следующий код:

//создаем scannerClassPathScanningCandidateComponentProvider scanner = getScanner();scanner.setResourceLoader(this.resourceLoader);//добавляем необходимые фильтры //AnnotationTypeFilter - для аннотаций//AssignableTypeFilter - для наследованияscanner.addIncludeFilter(new AnnotationTypeFilter(Congratulate.class));scanner.addIncludeFilter(new AssignableTypeFilter(Congratulator.class));//указываем пакет, где будем искать//importingClassMetadata.getClassName() - возвращает имя класса,//где стоит аннотация @EnableCongratulationString basePackage = ClassUtils.getPackageName(  importingClassMetadata.getClassName());//собственно сам поискLinkedHashSet<BeanDefinition> candidateComponents =   new LinkedHashSet<>(scanner.findCandidateComponents(basePackage));...private ClassPathScanningCandidateComponentProvider getScanner() {  return new ClassPathScanningCandidateComponentProvider(false,                                                    this.environment) {    @Override    protected boolean isCandidateComponent(      AnnotatedBeanDefinition beanDefinition) {      //требуется, чтобы исключить родительский класс - Congratulator      return !Congratulator.class.getCanonicalName()        .equals(beanDefinition.getMetadata().getClassName());    }  };}

Регистрация Factory:

String className = annotationMetadata.getClassName();// Используем класс CongratulationFactoryBean как наш Factory, // реализуем в дальнейшемBeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(CongratulationFactoryBean.class);// описываем, какие параметры и как передаем,// здесь выбран - через конструкторdefinition.addConstructorArgValue(className);definition.addConstructorArgValue(configName);AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();beanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, className);// aliasName - создается из наших CongratulatorString aliasName = AnnotationBeanNameGenerator.INSTANCE.generateBeanName(  candidateComponent, registry);String name = BeanDefinitionReaderUtils.generateBeanName(  beanDefinition, registry);BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition,  name, new String[]{aliasName});BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);

Попробовав разные способы, я советую остановиться на передаче параметров через конструктор, этот способ работает наиболее стабильно. Если вы захотите передать параметры не через конструктор, а через поля, то в параметры (beanDefinition.setAttribute) обязательно надо положить переменную FactoryBean.OBJECT_TYPE_ATTRIBUTE и соответствующий класс (именно класс, а не строку). Без этого наш Factory создаваться не будет. И Sping Data и Spring Feign передают строку: скорее всего это действует как соглашение, так как найти место, где эта строка используется, я не смог (если кто подскажет - дополню).

Что, если мы хотим иметь возможность получать наши beans по имени, например, так

@Autowiredprivate Congratulator familyCongratulator;

это тоже возможно, так как во время создания Factory в качестве alias было передано имя bean (AnnotationBeanNameGenerator.INSTANCE.generateBeanName(candidateComponent, registry))

FactoryBean

Теперь займемся Factory.

Стандартный интерфейс FactoryBean имеет 2 метода, которые нужно имплементировать

public interface FactoryBean<T> {  Class<?> getObjectType();  T getObject() throws Exception;  default boolean isSingleton() {return true;}}

Заметим, что есть возможность указать, является ли объект, который будет создаваться, Singleton или нет.

Есть абстрактный класс (AbstractFactoryBean), который расширяет интерфейс дополнительной логикой (например, поддержка destroy-методов). Он так же имеет 2 абстрактных метода

public abstract class AbstractFactoryBean<T> implements FactoryBean<T>{...@Overridepublic abstract Class<?> getObjectType();protected abstract T createInstance() throws Exception;}

Первый метод getObjectType требует вернуть класс возвращаемого объекта - это просто, его мы передали в конструктор.

@Overridepublic Class<?> getObjectType() {return type;}

Второй метод требует вернуть уже сам объект, а для этого нужно его создать. Для этого есть много способов. Здесь представлен один из них.

Сначала создадим обработчик для каждого метода:

Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<>();for (Method method : type.getMethods()) {    if (!AopUtils.isEqualsMethod(method) &&            !AopUtils.isToStringMethod(method) &&            !AopUtils.isHashCodeMethod(method) &&            !method.getName().startsWith(СONGRATULATE)    ) {        throw new UnsupportedOperationException(        "Method " + method.getName() + " is unsupported");    }    String methodName = method.getName();    if (methodName.startsWith(СONGRATULATE)) {         if (!"void".equals(method.getReturnType().getCanonicalName())) {            throw new UnsupportedOperationException(              "Congratulate method must return void");        }        List<String> members = new ArrayList<>();        CongratulateTo annotation = method.getAnnotation(          CongratulateTo.class);        if (annotation != null) {            members.add(annotation.value());        }        members.addAll(Arrays.asList(methodName.replace(СONGRATULATE, "").split(AND)));        MethodHandler handler = new MethodHandler(sign, members);        methodToHandler.put(method, handler);    }}

Здесь MethodHandler - простой класс, который мы создаем сами.

Теперь нам нужно создать объект. Можно, конечно, напрямую вызвать Proxy.newInstance, но лучше воспользоваться классами Spring, которые, например, дополнительно создадут для нас методы hashCode и equals.

//Класс Spring для создания proxy-объектовProxyFactory pf = new ProxyFactory();//указываем список интерфейсов, которые этот bean должен реализовыватьpf.setInterfaces(type);//добавляем advice, который будет вызываться при вызове любого метода proxy-объектаpf.addAdvice((MethodInterceptor) invocation -> {    Method method = invocation.getMethod();    //добавляем какой-нибудь toString метод    if (AopUtils.isToStringMethod(method)) {        return "proxyCongratulation, target:" + type.getCanonicalName();    }    //находим и вызываем наш созданный ранее MethodHandler    MethodHandler methodHandler = methodToHandler.get(method);    if (methodHandler != null) {        methodHandler.congratulate();        return null;    }    return null;});target = pf.getProxy();

Объект готов.

Теперь при старте контекста Spring создает бины (beans) на основе наших интерфейсов.

Исходный код можно посмотреть здесь.

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

Подробнее..
Категории: Java , Spring , Spring-boot

Кастомная (де) сериализация даты и времени в Spring

24.02.2021 18:11:35 | Автор: admin

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

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

  2. В ответ возвращать дату и время с указанием серверного часового пояса

Для решения этой задачи Spring предоставляет удобный механизм для написания кастомной сериализации и десериализации. Главным его преимуществом является возможность вынести преобразования дат (и других типов данных) в отдельный конфигурационный класс и не вызывать методы преобразования каждый раз в исходном коде.

Десериализация

Для того, чтобы Spring понимал, что именно наш класс нужно использовать для (де)сериализации, его необходимо пометить аннотацией @JsonComponent

Ну а чтобы код был максимально кратким, я буду использовать внутренний статический класс, который должен быть унаследован от JsonDeserializerи параметризован нужным нам типом данных. Поскольку JsonDeserializerэто абстрактный класс, нам необходимо переопределить его абстрактный метод deserialize()

@JsonComponentpublic class CustomDateSerializer {        public static class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {            @Override        public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) {            return null;        }    }}

Из параметров метода получаем переданную клиентом строку, проверяем её на null и получаем из неё объект класса ZonedDateTime

public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) {    String date = jsonParser.getText();    if (date.isEmpty() || isNull(date) {        return null;    }    ZonedDateTime userDateTime = ZonedDateTime.parse(date);}

Для получения разницы во времени, у переменной userDateTime нужно вызвать метод withZoneSameInstant() и передать в него текущую серверную таймзону. Нам остаётся лишь преобразовать полученную дату кLocalDateTime

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

public static class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {    @Override    public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {        String date = jsonParser.getText();        if (date.isEmpty()) {            return null;        }        try {            ZonedDateTime userDateTime = ZonedDateTime.parse(date);            ZonedDateTime serverTime = userDateTime.withZoneSameInstant(ZoneId.systemDefault());            return serverTime.toLocalDateTime();        } catch (DateTimeParseException e) {            try {                return LocalDateTime.parse(date);            } catch (DateTimeParseException ex) {                throw new IllegalArgumentException("Error while parsing date", ex);            }        }    }}

Предположим, что серверное времяUTC+03. Таким образом, когда клиент передаёт дату 2021-01-21T22:00:00+07:00, в нашем контроллере мы уже можем работать с серверным временем

public class Subscription {    private LocalDateTime startDate;      // standart getters and setters}
@RestController public class TestController {    @PostMapping  public void process(@RequestBody Subscription subscription) {    // к этому моменту поле startDate объекта subscription будет равно 2021-01-21T18:00  }}

Сериализация

С сериализацией алгоритм действий похожий. Нам нужно унаследовать класс от JsonSerializer, параметризовать его и переопределить абстрактный метод serialize()

Внутри метода мы проверим нашу дату на null, добавим к ней серверную таймзону и получим из неё строку. Остаётся лишь отдать её клиенту

public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {    @Override    public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {        if (isNull(localDateTime)) {            return;        }        OffsetDateTime timeUtc = localDateTime.atOffset(ZoneOffset.systemDefault().getRules().getOffset(LocalDateTime.now()));        jsonGenerator.writeString(timeUtc.toString());    }}

Круто? Можно в прод? Не совсем. В целом, этот код будет работать, но могут начаться проблемы, если серверная таймзона будет равна UTC+00. Дело в том, что конкретно для этого часового пояса id таймзоны отличается от стандартного формата. Посмотрим в документацию класса ZoneOffset

Таким образом, имея серверную таймзону UTC+03, на выходе мы получим строку следующего вида: 2021-02-21T18:00+03:00.Но если же оно UTC+00, то получим 2021-02-21T18:00Z

Поскольку мы работаем со строкой, нам не составит труда немного изменить код, дабы на выходе мы всегда получали дату в одном формате. Объявим две константы одна из них будет равна дефолтному id UTC+00, а вторая которую мы хотим отдавать клиенту, и добавим проверку - если серверное время находится в нулевой таймзоне, то заменим Z на +00:00. В итоге наш сериализотор будет выглядеть следующим образом

public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {    private static final String UTC_0_OFFSET_ID = "Z";    private static final String UTC_0_TIMEZONE = "+00:00";    @Override    public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {        if (!isNull(localDateTime)) {            String date;            OffsetDateTime timeUtc = localDateTime.atOffset(ZoneOffset.systemDefault().getRules().getOffset(LocalDateTime.now()));            if (UTC_0_OFFSET_ID.equals(timeUtc.getOffset().getId())) {                date = timeUtc.toString().replace(UTC_0_OFFSET_ID, UTC_0_TIMEZONE);            } else {                date = timeUtc.toString();            }            jsonGenerator.writeString(date);        }    }}

Итого

Благодаря встроенным механизмам спринга мы получили возможность автоматически конвертировать дату и время в требуемом формате, без каких-либо явных вызовов методов в коде

Полностью исходный код можно посмотреть здесь

Подробнее..
Категории: Java , Spring , Json , Maven , Spring-boot , Serialization , Date , Deserialization

Подключение Keycloak к Spring Boot приложению

14.04.2021 12:16:12 | Автор: admin

Привет Хабр!

Как известно, spring OAuth2.0.x переведен в режим поддержки уже почти как 2 года назад , а большая часть его функциональности теперь доступна в spring-security (матрица сопоставления). В spring-security отказались переносить Authorization service (roadmap) и предлагают использовать вместо него свободные или платные аналоги, в частности keycloak. В этом посте мы хотели бы поделится различными вариантами подключения keycloak к приложениям spring-boot.

Содержание

Немного о Keycloak

Это реализация SSO (Single sign on) с открытым исходным кодом для управления идентификацией и доступом пользователей.

Основной функционал, поддерживаемый в Keycloak:

  • Single-Sign On and Single-Sign Out.

  • OpenID/OAuth 2.0/SAML.

  • Identity Brokering аутентификация с помощью внешних OpenID Connect или SAML.

  • Social Login поддержка Google, GitHub, Facebook, Twitter.

  • User Federation синхронизация пользователей из LDAP и Active Directory серверов.

  • Kerberos bridge использование Kerberos сервера для автоматической аутентификации пользователей.

  • Гибкое управление политиками через realm.

  • Адаптеры для JavaScript, WildFly, JBoss EAP, Fuse, Tomcat, Jetty, Spring.

  • Возможность расширения с использованием плагинов.

  • И многое-многое другое...

Запускаем и настраиваем keycloak

Для запуска keycloak на машине разработчика удобно использовать docker-compose. В этом случае мы можем в разное время для разных приложений запускать свой сервис авторизации, тем самым избавляя себя от кучи проблем, связанных с конфигурацией под различные приложения. Ниже приведен один из вариантов конфигурации docker-compose для запуска standalone сервера с базой данных postgres:

docker-compose.yml
version: "3.8"services:  postgres:    container_name: postgres    image: library/postgres    environment:      POSTGRES_USER: ${POSTGRES_USER:-postgres}      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}      POSTGRES_DB: keycloak_db    ports:      - "5432:5432"    restart: unless-stopped  keycloak:    image: jboss/keycloak    container_name: keycloak    environment:      DB_VENDOR: POSTGRES      DB_ADDR: postgres      DB_DATABASE: keycloak_db      DB_USER: ${POSTGRES_USER:-postgres}      DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}      KEYCLOAK_USER: admin      KEYCLOAK_PASSWORD: admin_password    ports:      - "8484:8080"    depends_on:      - postgres    links:      - "postgres:postgres"

После успешного запуска необходимо произвести настройки realm, клиентов, ролей и пользователей.

Произведем некоторые первоначальные настройки. Создадим realm "my_realm":

После этого создадим клиент "my_client", через который будем производить авторизацию пользователей (оставим все настройки по-умолчанию):

Не забываем указывать redirect_url. В нашем случае он будет равен: http://localhost:8080/*

Создадим роли для пользователей нашей системы - "ADMIN", "USER":

Добавляем пользователей "admin" с ролью "ADMIN":

И пользователя "user" с ролью "USER". Не забываем устанавливать пароли на вкладке "Credentials":

Основная настройка закончена, теперь можно приступить к подключению spring boot приложений.

Подключаем Keycloak при помощи адаптера

В официальной документации к keycloak для использования в приложениях рекомендуют использовать готовые библиотеки - адаптеры, которые дают возможность избавиться от boilerplate кода и излишнего конфигурирования. Есть реализация для большинства популярных языков и фреймворков (supported-platforms). Мы будем использовать Spring Boot Adapter.

Создадим небольшое демонстрационное, приложение на spring-boot (исходники можно найти здесь) и подключим к нему Keycloak Spring Boot адаптер. Конфигурационный файл maven будет выглядеть так:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.9.RELEASE</version><relativePath /> <!-- lookup parent from repository --></parent><groupId>org.akazakov.keycloak</groupId><artifactId>demo-keycloak-adapter</artifactId><version>0.0.1-SNAPSHOT</version><name>Demo Keycloak Adapter</name><description>Demo project for Spring Boot and Keycloak</description><properties><java.version>11</java.version></properties><dependencyManagement><dependencies><dependency><groupId>org.keycloak.bom</groupId><artifactId>keycloak-adapter-bom</artifactId><version>12.0.3</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.keycloak</groupId><artifactId>keycloak-spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies></project>

Для целей проверки добавим контроллер, который будет выставлять методы для различных ролей пользователей и информацию о текущем пользователе (этот же контроллер мы будем использовать в других примерах ниже):

@RestController@RequestMapping("/api")public class SampleController {    @GetMapping("/anonymous")    public String getAnonymousInfo() {        return "Anonymous";    }    @GetMapping("/user")    @PreAuthorize("hasRole('USER')")    public String getUserInfo() {        return "user info";    }    @GetMapping("/admin")    @PreAuthorize("hasRole('ADMIN')")    public String getAdminInfo() {        return "admin info";    }    @GetMapping("/service")    @PreAuthorize("hasRole('SERVICE')")    public String getServiceInfo() {        return "service info";    }    @GetMapping("/me")    public Object getMe() {        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();        return authentication.getName();    }}

Чтобы наше приложение успешно запустилось и подключилось к keycloak, нам необходимо добавить соответствующую конфигурацию. Первое, что мы сделаем, это в application.yml добавим настройки клиента и подключения к серверу авторизации:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak}keycloak:  auth-server-url: http://localhost:8484/auth  realm: my_realm  resource: my_client  public-client: true

После этого добавим конфигурацию spring-security, переопределим KeycloakWebSecurityConfigurerAdapter, поставляемый вместе с адаптером:

@KeycloakConfiguration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {    @Override    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {        return new NullAuthenticatedSessionStrategy();    }    @Autowired    public void configureGlobal(AuthenticationManagerBuilder authManagerBuilder) {        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());        authManagerBuilder.authenticationProvider(keycloakAuthenticationProvider);    }    @Bean    public KeycloakConfigResolver keycloakConfigResolver() {        return new KeycloakSpringBootConfigResolver();    }    @Override    protected void configure(HttpSecurity http) throws Exception {        super.configure(http);        http                .authorizeRequests()                .antMatchers("/api/anonymous/**").permitAll()                .anyRequest().fullyAuthenticated();    }}

Теперь проверим работу нашего приложения. Запустим приложение и попробуем зайти пользователем на соответствующий url. Например: http://localhost:8080/api/admin. В результате, браузер перенаправит нас на окно логина пользователя:

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

Если перейдем по адресу получения информации о текущем пользователе (http://localhost:8080/api/me), то получим в результате uuid пользователя в keycloak:

Если нам нужно, чтобы сервис только проверял токен доступа и не инициализировал процедуру аутентификации пользователя, достаточно включить bearer-only: true в конфигурацию приложения:

keycloak:  auth-server-url: http://localhost:8484/auth  realm: my_realm  resource: my_client  public-client: true  bearer-only: true

Используем OAuth2 Client из spring-security

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

Одной из ключевых особенностей spring security 5 является поддержка протоколов OAuth2 и OIDC. Мы можем использовать OAuth2 клиент из пакета spring-security для интеграции с сервером keycloak.

Итак, для использования клиента подключим соответствующую библиотеку в зависимости от проекта (исходный код примера). Полный текст pom.xml:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"         xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.9.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>org.akazakov.keycloak</groupId>    <artifactId>demo-keycloak-oauth</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>demo-keycloak-oauth</name>    <description>Demo project for Spring Boot OAuth and Keycloak</description>    <properties>        <java.version>11</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-oauth2-client</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-security</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-test</artifactId>            <scope>test</scope>        </dependency>        <dependency>            <groupId>org.springframework.security</groupId>            <artifactId>spring-security-test</artifactId>            <scope>test</scope>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>

Далее в application.yaml необходимо указать параметры подключения к сервису авторизации:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak-oauth}  security:    oauth2:      client:        provider:          keycloak:            issuer-uri: http://localhost:8484/auth/realms/my_realm        registration:          keycloak:            client-id: my_client

По умолчанию роли пользователей будут вычисляться на основе значения "scope" в access token, и к ним прибавляется "ROLE_USER" для всех авторизованных пользователей системы. Можно оставить как есть и перейти на модель scope. Но в нашем примере мы будем использовать роли пользователей в рамках realm'а. Все, что нам нужно, это переопределить oidcUserService и задать свой маппинг ролей для пользователя. Нужные роли приходят в разделе "groups" токена доступа, его мы и будем использовать для определения ролей пользователя. В результате, наша конфигурация для spring security с переопределенным oidcUserService будет выглядеть так:

@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/api/anonymous/**").permitAll()                        .anyRequest().authenticated())                .oauth2Login(oauth2Login -> oauth2Login                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint                                .oidcUserService(this.oidcUserService())                        )                );    }    @Bean    public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {        final OidcUserService delegate = new OidcUserService();        return (userRequest) -> {            OidcUser oidcUser = delegate.loadUser(userRequest);            final Map<String, Object> claims = oidcUser.getClaims();            final JSONArray groups = (JSONArray) claims.get("groups");            final Set<GrantedAuthority> mappedAuthorities = groups.stream()                    .map(role -> new SimpleGrantedAuthority(("ROLE_" + role)))                    .collect(Collectors.toSet());            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());        };    }}

В данном случае работа приложения будет практически аналогична работе с использованием keycloak адаптера.

Подключаем приложение как ResourceService

Довольно часто не нужно, чтобы наше приложение инициировало аутентификацию пользователя. Достаточно лишь проверки авторизации пользователя по предоставляемому токену доступа. Вариантом подключения авторизации с keycloak без использования адаптера является настройка приложения как resource server. В этом случае приложение не может инициировать аутентификацию пользователя, а только авторизует пользователя и проверяет подпись токена доступа. Подключим соответствующие библиотеки: spring-security-oauth2-resource-server и spring-security-oauth2-jose (исходный код). Полный файл pom.xml будет выглядеть так:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.9.RELEASE</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>org.akazakov.keycloak</groupId><artifactId>demo-keycloak-resource</artifactId><version>0.0.1-SNAPSHOT</version><name>demo-keycloak-resource</name><description>Demo project for Spring Boot and Spring security and Keycloak</description><properties><java.version>11</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-resource-server</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>

Далее нам необходимо указать путь к JWK (JSON Web Key) набору ключей, с помощью которых наше приложение будет проверять токены доступа. В keycloak они доступны по адресу: http://${host}/auth/realms/${realm)/protocol/openid-connect/certs. В итоге application.yml будет выгдядеть следующим образом:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak-resource}  security:    oauth2:      resourceserver:        jwt:          jwk-set-uri: ${KEYCLOAK_REALM_CERT_URL:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/certs}

Как и в случае с OAuth2 Client нам также необходимо переопределить конвертер ролей пользователя. В данном случае мы можем переопределить jwtAuthenticationConverter.

Полный текст WebSecurityConfiguration:

@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/api/anonymous/**").permitAll()                        .anyRequest().authenticated())                .oauth2ResourceServer(resourceServerConfigurer -> resourceServerConfigurer                        .jwt(jwtConfigurer -> jwtConfigurer                                .jwtAuthenticationConverter(jwtAuthenticationConverter()))                );    }    @Bean    public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());        return jwtAuthenticationConverter;    }    @Bean    public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {        JwtGrantedAuthoritiesConverter delegate = new JwtGrantedAuthoritiesConverter();        return new Converter<>() {            @Override            public Collection<GrantedAuthority> convert(Jwt jwt) {                Collection<GrantedAuthority> grantedAuthorities = delegate.convert(jwt);                if (jwt.getClaim("realm_access") == null) {                    return grantedAuthorities;                }                JSONObject realmAccess = jwt.getClaim("realm_access");                if (realmAccess.get("roles") == null) {                    return grantedAuthorities;                }                JSONArray roles = (JSONArray) realmAccess.get("roles");                final List<SimpleGrantedAuthority> keycloakAuthorities = roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).collect(Collectors.toList());                grantedAuthorities.addAll(keycloakAuthorities);                return grantedAuthorities;            }        };    }}

Здесь мы создаем конвертер (jwtGrantedAuthoritiesConverter), который принимает токен и извлекает из секции "realm_access" роли пользователя. Далее мы можем либо сразу вернуть их, либо, как в данном случае, расширить список, который извлекается конвертером по умолчанию.

Проверим работу. Воспользуемся встроенным в Intellij idea http клиентом, либо плагином к VSCode - Rest Client. В начале получим токен пользователя, произведем запрос к keycloak, используя логин и пароль зарегистрированного пользователя:

###POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token>Content-Type: application/x-www-form-urlencodedclient_id=my_client&grant_type=password&scope=openid&username=admin&password=admin> {% client.global.set("auth_token", response.body.access_token); %}

Ответ будет примерно следующего содержания:

Ответ
POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token>HTTP/1.1 200 OK...Content-Type: application/json{  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMGQwMjg2YWUtYTlmYy00MzcxLWFmM2ItZjJlNTM5N2I4NzViIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjkzMGIxMTNmLWI0NzUtNDhkMC05NTQxLWMyYzI2MWZlYmRmZCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiQURNSU4iLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.dvGvYhhhfH8r6EP8k_spFwBS35ulYMTWNL4lcz9PR2e-p4FU-ehre1EQA8xpbkYzYEWRB_elzTya5IhbYR8KArrujplIDNAOlqJ9W6a4Tx-r44QCteM0DW4BNzbZAH2L0Bg7aSstRKUuULceRNYQcdCvSFjEU5DsHk26a6TM5KCrkv0ryGo11pam-pnbs2Z2jOSfSHvOAfMNL9OVJYRBjlTmsEzzgH9dHSa_pT2Q-SvgvfCcwfY0XkgUZkMPUtz85-lqchROb4XpHOiy3Cfn8MgrGNwhf-MsmN5wiAGe0DI_LW2Jxr3boZMLS4AuuNQ7agr65g-JuO9-LhlgndxN8g",  "expires_in": 300,  "refresh_expires_in": 1800,  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNGEwNWQxNy0yNWU4LTRjMjEtOTMyMC0zMzcwODlhNTg5MjQifQ.eyJleHAiOjE2MTY2NTU4NjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMjNmNDBiZWUtNmQ3Ny00ZTIxLTg0NTItNDg1NDc2OTk1ZDUyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwic3ViIjoiOTMwYjExM2YtYjQ3NS00OGQwLTk1NDEtYzJjMjYxZmViZGZkIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIn0.r4BrjwfavKFF8dst3AyRi0LTfymbSVfDKDT9KyMpmzk",  "token_type": "bearer",  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwiYXV0aF90aW1lIjowLCJqdGkiOiJiN2UwNDhmZS01ZTRjLTQxMWYtYTBjMC0xNGExYzhlOGJhYWEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0ODQvYXV0aC9yZWFsbXMvbXlfcmVhbG0iLCJhdWQiOiJteV9jbGllbnQiLCJzdWIiOiI5MzBiMTEzZi1iNDc1LTQ4ZDAtOTU0MS1jMmMyNjFmZWJkZmQiLCJ0eXAiOiJJRCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhdF9oYXNoIjoiRlh2VzB2Z3pwd3R6N1FabEZtTFhJdyIsImFjciI6IjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.ZDeZg4Z-PPmn2fVm7opGLRutzDh6l8uRYqZzbqIX7wk0GhgtMHV1CW8RvDd51AuYw81WyoMyRAD_-T6ne58Rt9f5XNZZfS8xoXzTFV1xH6XigOVQH2jIHN-2VIM1IgJnteo7nuTz9zo4OXIFvEjaFHq4AXDkiq6jhThv0qPS3WrAA-MutyW8G37GM0fsCgANvlGKoWm1_1wKyeTZ0Gfug32Vf6gUikfxA9bmaS4oGYGc6lqFE6EHgtjIn0q9gNUfpEXaqpiL3mCBu9V6sJG5Rp_MOqp-aXrM9NbLTz2JTXevtClHI6qVUIoh8OXXXT98QmKrVr9Cyr9BRUrQyt0Zzg",  "not-before-policy": 0,  "session_state": "5d29d46e-b926-4d59-89f8-2436edcae4f0",  "scope": "openid profile email"}Response code: 200 (OK); Time: 114ms; Content length: 2987 bytes

Теперь проверим, что методы доступны пользователю с соответствующими правами:

GET <http://localhost:8080/api/admin>Authorization: Bearer {{auth_token}}Content-Type: application/json

В ответ получим:

GET <http://localhost:8080/api/admin>HTTP/1.1 200 ...admin infoResponse code: 200; Time: 34ms; Content length: 10 bytes

Авторизация вызовов сервисов с использованием keycloak

При работе с микросервисной архитектурой иногда возникают требования авторизованных вызовов между сервисами. В случаях, когда инициатором взаимодействия является какой-то внутренний процесс или служба, нам где-то нужно брать токен доступа. В качестве решения данного вопроса мы можем использовать Client Credentials Flow, чтобы получить токен из keycloak (исходный код примера доступен по ссылке).

Для начала создадим нового клиента, под которым будут авторизоваться наши сервисы:

Для возможности авторизации сервиса нам нужно изменить тип доступа ("Access Type") на "confidential" и включить флаг "Service accounts Enabled". В остальном конфигурация не отличается от конфигурации по умолчанию:

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

Далее эту роль необходимо добавить клиенту. На вкладке "Service Account Roles" выбираем необходимую роль - в нашем случае роль "SERVICE":

Сохраняем client_id и client_secret для дальнейшего использования в сервисах для авторизации:

Для демонстрации создадим небольшое приложение, которое будет получать информацию доступную по адресу http://localhost:8080/api/service из предыдущих примеров.

Для начала создадим компонент, который будет авторизовывать наш сервис в keycloak:

@Componentpublic class KeycloakAuthClient {    private static final Logger log = LoggerFactory            .getLogger(KeycloakAuthClient.class);    private static final String TOKEN_PATH = "/token";    private static final String GRANT_TYPE = "grant_type";    private static final String CLIENT_ID = "client_id";    private static final String CLIENT_SECRET = "client_secret";    public static final String CLIENT_CREDENTIALS = "client_credentials";    @Value("${app.keycloak.auth-url:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect}")    private String authUrl;    @Value("${app.keycloak.client-id:service_client}")    private String clientId;    @Value("${app.keycloak.client-secret:acb719cf-4afd-42d3-91f2-93a60b3f2023}")    private String clientSecret;    private final RestTemplate restTemplate;    public KeycloakAuthClient(RestTemplate restTemplate) {        this.restTemplate = restTemplate;    }    public KeycloakAuthResponse authenticate() {        MultiValueMap<String, String> paramMap = new LinkedMultiValueMap<>();        paramMap.add(CLIENT_ID, clientId);        paramMap.add(CLIENT_SECRET, clientSecret);        paramMap.add(GRANT_TYPE, CLIENT_CREDENTIALS);        HttpHeaders headers = new HttpHeaders();        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);        String url = authUrl + TOKEN_PATH;        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(paramMap, headers);        log.info("Try to authenticate");        ResponseEntity<KeycloakAuthResponse> response =                restTemplate.exchange(url,                        HttpMethod.POST,                        entity,                        KeycloakAuthResponse.class);        if (!response.getStatusCode().is2xxSuccessful()) {            log.error("Failed to authenticate");            throw new RuntimeException("Failed to authenticate");        }        log.info("Authentication success");        return response.getBody();    }}

Метод authenticate производит вызов к keycloak и в случае успешного ответа возвращает объект KeycloakAuthResponse:

public class KeycloakAuthResponse {    @JsonProperty("access_token")    private String accessToken;    @JsonProperty("expires_in")    private Integer expiresIn;    @JsonProperty("refresh_expires_in")    private Integer refreshExpiresIn;    @JsonProperty("refresh_token")    private String refreshToken;    @JsonProperty("token_type")    private String tokenType;    @JsonProperty("id_token")    private String idToken;    @JsonProperty("session_state")    private String sessionState;    @JsonProperty("scope")    private String scope;    // Getters and setters or lombok ...}

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

@SpringBootApplicationpublic class DemoServiceAuthApplication implements CommandLineRunner {    private static final String BEARER = "Bearer ";    private static final String SERVICE_INFO_URL = "http://localhost:8080/api/service";    private final KeycloakAuthClient keycloakAuthClient;    private final RestTemplate restTemplate;    private static final Logger log = LoggerFactory            .getLogger(DemoServiceAuthApplication.class);    public DemoServiceAuthApplication(KeycloakAuthClient keycloakAuthClient, RestTemplate restTemplate) {        this.keycloakAuthClient = keycloakAuthClient;        this.restTemplate = restTemplate;    }    public static void main(String[] args) {        SpringApplication.run(DemoServiceAuthApplication.class, args);    }    @Override    public void run(String... args) {        final KeycloakAuthResponse authenticate = keycloakAuthClient.authenticate();        HttpHeaders headers = new HttpHeaders();        headers.setContentType(MediaType.APPLICATION_JSON);        headers.setBearerAuth(authenticate.getAccessToken());        log.info("Make request to resource server");        final ResponseEntity<String> responseEntity = restTemplate.exchange(SERVICE_INFO_URL, HttpMethod.GET, new HttpEntity(headers), String.class);        if (!responseEntity.getStatusCode().is2xxSuccessful()) {            log.error("Failed to request");            throw new RuntimeException("Failed to request");        }        log.info("Response data: {}", responseEntity.getBody());    }}

Сначала мы авторизуем наш сервис через keycloak, потом производим запрос к защищенному ресурсу, добавив в HTTP Headers параметр Authorization: Bearer ...

В результате выполнения программы мы получим содержимое защищенного метода:

.   ____          _            __ _ _ /\\\\ / ___'_ __ _ _(_)_ __  __ _ \\ \\ \\ \\( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\ \\\\/  ___)| |_)| | | | | || (_| |  ) ) ) )  '  |____| .__|_| |_|_| |_\\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot ::                (v2.4.4)2021-04-13 16:04:36.672  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Starting DemoServiceAuthApplication using Java 14.0.1 on MacBook-Pro.local with PID 19240 (/Users/akazakov/Projects/spring-boot-keycloak/demo-service-auth/target/classes started by akazakov in /Users/akazakov/Projects/spring-boot-keycloak)2021-04-13 16:04:36.674  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : No active profile set, falling back to default profiles: default2021-04-13 16:04:37.199  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Started DemoServiceAuthApplication in 0.814 seconds (JVM running for 6.425)2021-04-13 16:04:37.203  INFO 19240 --- [           main] o.akazakov.keycloak.KeycloakAuthClient   : Try to authenticate2021-04-13 16:04:53.697  INFO 19240 --- [           main] o.akazakov.keycloak.KeycloakAuthClient   : Authentication success2021-04-13 16:04:53.697  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Make request to resource server2021-04-13 16:04:54.088  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Response data: service infoDisconnected from the target VM, address: '127.0.0.1:57479', transport: 'socket'Process finished with exit code 0

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

Выводы

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

Спасибо за внимание!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru