Только не переусердствуй

Профили Spring — это основная и очень полезная функция Spring Framework. Многие проекты Java используют их для загрузки разных значений конфигурации для разных сред (например, dev, stage и production). На самом деле их можно использовать для гораздо большего. С этой целью у инженеров может возникнуть соблазн злоупотребить ими.

Обо всем этом мы поговорим в этой статье.

Начнем с описания того, что такое Spring Profiles. Согласно собственной документации Spring Framework:

Профили Spring позволяют разделить части конфигурации вашего приложения и сделать их доступными только в определенных средах.

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

Вероятно, самые распространенные такие среды — и те, которые, скорее всего, сразу приходят на ум — это те, которые описывают различные этапы процесса развертывания; например

  • разработчик
  • этап
  • продукт

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

Определение профилей

Как определить профиль? С практической точки зрения определение профиля так же просто, как предоставление аргумента сценарию запуска нашего приложения. Например, используя команду java, мы бы предоставили аргумент spring.profiles.active. Итак, если бы мы хотели определить профиль dev, мы бы выполнили следующее:

java -Dspring.profiles.active=dev -jar myapp.jar

Это, наверное, самый распространенный способ установить профиль, под которым будет работать наше приложение. Но есть и другие способы. Например, перед запуском нашего приложения мы можем установить переменную среды spring_profiles_active:

export spring_profiles_active=dev

Мы также можем установить профиль в качестве параметра контекста в нашем файле web.xml:

<context-param>
 <param-name>spring.profiles.active</param-name>
 <param-value>dev</param-value>
</context-param>

Если в настоящее время мы используем профили Maven для определения сред нашего приложения, мы можем использовать их для установки наших профилей Spring. Например, добавив следующие профили Maven:

<profiles>
    <profile>
        <id>dev</id>
        <properties>
            <spring.profiles.active>dev</spring.profiles.active>
        </properties>
    </profile>
    <profile>
        <id>prod</id>
        <properties>
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
    </profile>
</profiles>

и включение фильтрации ресурсов в pom.xml позволит нам добавить следующее в application.properties:

[email protected]@

@spring.profiles.active@, в свою очередь, будет перезаписан выбранным профилем.

Использование профилей

Файлы конфигурации

Возможно, наиболее распространенное использование профилей Spring — это настройка наших приложений по-разному в зависимости от того, на каком этапе развертывания они выполняются. И самый простой способ сделать это — создать файлы свойств для конкретного профиля. Эти файлы свойств называются application-{profile}.properties (где {profile}, разумеется, заменяется именем профиля). Они служат для переопределения любых значений конфигурации, существующих в нашем стандартном файле application.properties.

Мы возьмем канонический пример настроек базы данных. Допустим, мы настроили параметры нашей базы данных по умолчанию (скажем, те, которые мы хотим использовать при локальном тестировании), например, в нашем файле application.properties:

spring.datasource.url=jdbc:mysql://localhost:3306/items
spring.datasource.username=dbuser
spring.datasource.password=password!
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

Когда мы запускаем наше приложение Spring без указания профиля (как мы могли бы запускать приложение локально), эти настройки вступят в силу. Но предположим, что нам нужно указать другой URL-адрес и использовать другой пароль при работе в нашей среде dev. Таким образом, мы создадим файл application-dev.properties следующим образом:

spring.datasource.url=jdbc:mysql://dev-host:3306/items
spring.datasource.password=dev*DB!password

Обратите внимание, что username и driver-class-name не переопределяются.

Наконец, мы создадим отдельный файл application-prod.properties для prod:

spring.datasource.url=jdbc:mysql://prod-host:3306/items
spring.datasource.username=db
spring.datasource.password=Zjk&1lm9%xew8*op7

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

java -Dspring.profiles.active=prod -jar myapp.jar

тогда наши настройки БД закончатся как наложение application-prod.properties на application.properties, так что наши настройки станут фактически такими:

spring.datasource.url=jdbc:mysql://prod-:3306/items
spring.datasource.username=db
spring.datasource.password=Zjk&1lm9%xew8*op7
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

Если мы не укажем профиль или укажем профиль, отличный от dev или prod, то настройки БД будут выглядеть так, как они отображаются в нашем файле application.properties.

ВНИМАНИЕ: это только пример. На самом деле мы не храним учетные данные нашей базы данных в файлах свойств; конечно, не как открытый текст.

Классы конфигурации

Подобно использованию файлов конфигурации, мы также можем определять профили, используя классы конфигурации (т. е. bean-компоненты с аннотацией @Component). Приведенный ниже класс конфигурации будет загружен при работе под профилем dev:

@Configuration
@Profile("dev")
/** The configs defined herein will be 
public class DevConfig {

    @Bean
    DbConfig dbConfig() {
        return new DevDbConfig();
    }

    // ...

}

тогда как этот будет загружаться при работе под профилем prod:

@Configuration
@Profile("prod")
/** The configs defined herein will be 
public class ProdConfig {

    @Bean
    DbConfig dbConfig() {
        return new ProdDbConfig();
    }

    // ...

}

Будет загружен только один из перечисленных выше компонентов конфигурации: DevConfig при работе в профиле dev и ProdConfig при работе в качестве prod. В свою очередь, каждый из них создаст экземпляр компонента DbConfig, соответствующий текущему профилю.

Но что еще мы можем сделать с профилями?

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

  • Аргумент, который мы используем для определения профиля, — spring.profiles.active. То есть профилиs; как во множественном числе. Означает ли это, что мы можем применять несколько профилей одновременно?
  • @Configuration — это просто стереотип Spring. Можем ли мы применить аннотацию @Profile к другим типам компонентов?

Ответ на оба вопроса: да. И это приводит к некоторым интересным возможностям.

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

  • Мобильный клиент
  • Две разные серверные службы, одна устаревшая, а другая — новая, призванная заменить старую. Здесь мы назовем их Omicron (от «старого») и Nu (от «нового»).
  • Служба мобильного шлюза, также известная как бэкэнд-для-интерфейса (назовем эту службу Gamma). Служба Gamma находится между мобильным клиентом и каждой серверной службой. Как приложение с интенсивным вводом-выводом, оно было написано как реактивное приложение Spring WebFlux. По своей сути он способен взаимодействовать либо с Omicron, либо с Nu, но любой работающий экземпляр Gamma взаимодействует только с одним или другим.

При этом компоненты могут работать в одной из следующих сред:

  • local (для запуска и тестирования на локальном ноутбуке разработчика)
  • dev (для совместного тестирования во время разработки)
  • этап (для подготовки к производству)
  • prod (очевидно, производство)

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

Предоставление поддержки в Gamma для различных бэкендов было более интересным. Мы начали с создания обработчиков для каждой конечной точки Gamma. Для этого для каждой конечной точки мы определили абстрактный класс. Затем мы создали три реализации: одну для Omicron, одну для Nu и третью «фиктивную» реализацию, позволяющую тестировать мобильный клиент без использования какой-либо серверной части.

Например, мы создали абстрактный класс AuthHandler, определяющий методы входа и выхода. Это выглядело примерно так:

public abstract class AuthHandler {
  
  @Resource
  protected ServerRequestUtils serverRequestUtils;
  
  public Mono<ServerResponse> authorize(ServerRequest request) {
    return request.bodyToMono(AuthenticationRequest.class)
        .flatMap(this::handleAuthorize) // handled by the Omicron or Nu subclass
        .flatMap(resp -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(resp))
        .onErrorResume(HttpUnauthorizedException.class, e -> ServerResponse.status(401).build());
  }

  public Mono<ServerResponse> logout(ServerRequest request) {
    return serverRequestUtils.extractCredentials(request)
        .flatMap(this::handleLogout)// handled by the Omicron or Nu subclass
        .flatMap(v -> ServerResponse.status(204).contentType(MediaType.APPLICATION_JSON).build())
        .onErrorResume(HttpUnauthorizedException.class, e -> ServerResponse.status(401).build());
  }
  
  protected abstract Mono<AuthenticationResponse> handleAuthorize(AuthenticationRequest authReq);
  
  protected abstract Mono<Void> handleLogout(Credentials creds);
  
}

Обратите внимание, что в методах authorize() и logout() абстрактный класс обрабатывает ненужную информацию, которая является общей для разных реализаций. Большая часть этого мусора связана с демаршалированием запроса и маршалингом ответа от и к мобильному клиенту. Таким образом, методы handleXX() оставлены для реализации конкретными классами, обрабатывающими уникальные взаимодействия с бэкендами.

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

Вот где в игру вступают профили. Мы просто аннотируем наши реализации обработчиков с помощью @Profile, передавая соответствующее имя профиля. Например, наша реализация Omicron выглядит примерно так:

@Component
@Profile(Profiles.OMICRON)
public class OmicronAuthHandler extends AuthHandler {

  private final WebClient webClient;
  
  public OmicronAuthHandler(WebClient.Builder wcBuilder, @Value("${com.us.omicron.backend-url}") String url) {
    this.webClient = wcBuilder.baseUrl(url).build();
  }

  @Override
  protected Mono<AuthenticationResponse> handleAuthorize(AuthenticationRequest authReq) {
    OmicronCredentials creds = new OmicronCredentials(authReq.getUsername(), authReq.getPassword());
    return webClient.get()
        .uri("users/me")
        .header("Authorization", "Basic " + AuthEncodeUtils.encodeBasicAuth(creds))
        .retrieve()
        .bodyToMono(AuthOmicronResponse.class)
        .map(r -> new AuthenticationResponse(encryptCredentials(creds), authReq.getUsername()));
  }

  @Override
  protected Mono<Void> handleLogout(Credentials creds) {
    // no concept of logging out of Omicron, which uses Basic Authentication
    return Mono.empty();
  }

}

Важно отметить, что этот класс специально обрабатывает аутентификацию с помощью серверной части Omicron. Например:

  • URL-адрес Omicron, который определен в каждом из наших файлов конфигурации для конкретной среды, передается конструктору класса.
  • Вызывается конкретный путь URI Omicron
  • В нашем случае Omicron использовала базовую аутентификацию для входа в систему.
  • Ответ от серверной части маршалируется в объект ответа Omicron.

Также важно отметить, что этот класс помечен @Profile(Profiles.OMICRON), что гарантирует, что этот класс создается и подключается к контейнеру Spring только тогда, когда приложение работает с определенным профилем «omicron».

Точно так же реализация Nu выглядит примерно так:

@Component
@Profile(Profiles.NU)
public class NuAuthHandler extends AuthHandler {

  private final WebClient webClient;
  
  public NuAuthHandler(WebClient.Builder wcBuilder, @Value("${com.us.nu.backend-url}") String url) {
    this.webClient = wcBuilder.baseUrl(url).build();
  }

  @Override
  protected Mono<AuthenticationResponse> handleAuthorize(AuthenticationRequest authReq) {
    var req = new AuthNuRequest(authReq.getUsername(), authReq.getPassword());
    return webClient.post()
        .uri("api/v1/user/auth")
        .bodyValue(req)
        .retrieve()
        .bodyToMono(AuthNuResponse.class)
        .map(r -> new AuthenticationResponse(r.getToken(), authReq.getUsername()));
  }

  @Override
  protected Mono<Void> handleLogout(Credentials creds) {
    return webClient.delete()
        .uri("api/v1/user/auth")
        .bodyValue(req)
        .retrieve()
        .bodyToMono(LogoutNuResponse.class)
        .flatMap(r -> Mono.empty());
  }

}

Здесь URL-адрес Nu, путь URI, объект ответа и аутентификация используются для обработки входа и выхода из системы Nu. Важно отметить, что этот класс помечен @Profile(Profiles.NU), что гарантирует его создание и внедрение в контейнер Spring только при работе с указанным профилем «nu».

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

@Configuration
public class RouterConfig {

  @Bean
  RouterFunction<ServerResponse> routes(
      AuthHandler     authHandler,
      ItemHandler   itemHandler
      ) {
    return   route(GET   ("/v1/my/items"), itemHandler::getItems))
        .and(route(DELETE("/v1/auth"), authHandler::logout))
        .and(route(POST  ("/v1/auth"), authHandler::authorize))
        ;
  }
}

Здесь мы используем аннотацию @Configuration, чтобы сообщить Spring, что этот класс должен выполняться при запуске. Метод routes() возвращает объект RouterFunction, который сообщает WebFlux, как обрабатывать запросы к определенным URL-адресам. Он помечен @Bean, так что Spring будет рассматривать этот объект как компонент конфигурации.

Вы заметите, что мы используем внедрение конструктора для передачи двух обработчиков, на которые наш маршрутизатор будет направлять вызовы. Важно отметить, что мы передаем в конструктор классы абстрактных обработчиков. Затем Spring найдет — на основе текущего профиля — уникальную реализацию каждого абстрактного класса для подключения во время запуска. Например, он найдет OmicronAuthHandler, если приложение работает под профилем omicron, и подключит его в качестве первого аргумента.

Это оказалось отличным применением Spring Profiles, и на этом мы могли бы остановиться. Но мы нашли другое интересное применение.

Напомним, что метод handleAuthorize() нашего AuthHandler возвращает клиенту AuthenticationResponse:

protected abstract Mono<AuthenticationResponse> handleAuthorize(AuthenticationRequest authReq)

Этот объект, в свою очередь, содержит один непрозрачный токен авторизации:

{
  "authToken": "..."
}

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

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

  • При работе с профилем omicron Gamma необходимо расшифровать и декодировать токен, чтобы получить учетные данные базовой аутентификации для передачи службе Omicron.
  • При работе с профилем nu Gamma просто передает токен службе Nu как есть.

Как часть «мусора», связанного с рассортировкой входящего запроса, эта логика лучше всего находится в абстрактном классе обработчика Gamma. Но как мы можем поместить специфичную для профиля логику в общий абстрактный класс?

Наш ответ состоял в том, чтобы инкапсулировать эту логику в классе; в частности, в абстрактном классе ServerRequestUtils с реализацией OmicronServerRequestUtils и NuServerRequestUtils. Каждый из этих классов содержит метод extractCredentials(), обрабатывающий внутреннюю логику.

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

Традиционно мы могли бы использовать паттерн метод шаблона. Используя этот шаблон, мы бы определили такой метод, как
abstract ServerRequestUtils getServerRequestUtils();
в базовом классе. Каждый из двух подклассов обработчиков, специфичных для профиля, будет возвращать соответствующий экземпляр. Наш OmicronItemHandler, например, вернет экземпляр OmicronServerRequestUtils, а NuItemHandler вернет экземпляр NuServerRequestUtils. Таким образом, метод getItems() в нашем реферате ItemHandler будет выглядеть так

public Mono<ServerResponse> getItems(ServerRequest request) {
    return getServerRequestUtils().extractCredentials(request)
        .flatMap(this::generateGetScriptsResponse)
        .flatMap(resp -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(resp))
        .onErrorResume(HttpUnauthorizedException.class, e -> ServerResponse.status(401).build());
  }

public abstract ServerRequestUtils getServerRequestUtils();

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

@Component
@Profile(Profiles.OMICRON)
public class OmicronServerRequestUtils extends ServerRequestUtils {
  public Mono<Credentials> extractCredentialsMono(ServerRequest request) {
    // omicron-specific implementation
  }
}
@Component
@Profile(Profiles.NU)
public class NuServerRequestUtils extends ServerRequestUtils {
  public Mono<Credentials> extractCredentialsMono(ServerRequest request) {
    // nu-specific implementation
  }
}

После этого нам просто нужно было добавить следующее к каждому из наших базовых классов обработчиков:

@Resource
protected ServerRequestUtils serverRequestUtils;

Вводится правильный экземпляр — на основе текущего профиля Spring. Метод getItems() из более раннего выглядит следующим образом:

public Mono<ServerResponse> getItems(ServerRequest request) {
    return serverRequestUtils.extractCredentials(request)
        .flatMap(this::generateGetScriptsResponse)
        .flatMap(resp -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(resp))
        .onErrorResume(HttpUnauthorizedException.class, e -> ServerResponse.status(401).build());
  }

Так какие минусы?

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

Итак, есть ли недостатки?

Завелся весной

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

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

Отсутствующие реализации

Второй недостаток заключается в том, что легко ввести ошибки, которые компилятор не может отловить. Например, предположим, что я создал только класс OmicronServerRequestUtils и забыл создать NuServerRequestUtils (или что я забыл добавить аннотацию @Profile к одному из них). Или что я допустил ошибку копирования и вставки, пометив оба как @Profile(Profiles.OMICRON).

Компилятор не поймает ни одну из этих ошибок. Скорее, они будут пойманы только во время выполнения.

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

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

Сложные рассуждения

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

Заключение

Профили Spring — мощная (и недооцененная) часть Spring Framework. Это позволяет легко активировать и деактивировать определенные компоненты приложения в зависимости от среды, в которой оно работает. Однако у чрезмерного использования профилей Spring есть и недостатки. К ним относятся:

  • Привязываем наше приложение к фреймворку Spring
  • Риск ошибок, которые могут быть обнаружены только во время выполнения
  • Усложнение рассуждений о том, как наше приложение будет вести себя в заданном наборе сред.

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

Вы также можете поддержать меня и мое творчество — и получить доступ к неограниченному количеству историй — став участником Medium сегодня.