Привет, Хабр! С вами снова Сергей Соловых, Java-разработчик в команде МТС Digital. Мы продолжаем изучать возможности и нюансы построения собственного Spring Boot Starter. В предыдущих частях мы разобрали структуру стартеров, автоконфигурацию и зависимости бинов. А сегодня давайте поговорим о параметрах приложения.

Умение работать с файлами конфигураций позволяет избежать hard-coding-данных в коде библиотеки. Это делает ее более гибкой и легко конфигурируемой, это значит, что ее можно адаптировать под конкретные требования. С помощью параметров можно настроить контекст или изменить поведение сервиса. Начнем с рассмотрения нескольких способов интеграции параметров из файла конфигурации в код приложения.

Работа с параметрами приложения

Активация через аннотацию

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

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

@ConfigurationProperties(prefix = "app.observatory")
@ConstructorBinding
@Value
public class ObservatoryProperties {

  String name;
  Integer telescopeDiameter;
  Boolean automaticMode;
}

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

public class Observatory {

  public Observatory(ObservatoryProperties properties) {
    System.out.println("ObservatoryProperties: " + properties);
  }

  //some code here
}

Добавим данные в application.properties:

app.observatory.name=Kopernik Station
app.observatory.telescope-diameter=8
app.observatory.automatic-mode=true

Создадим фабрику с методом, инициирующим бин сервиса. Обратите внимание на добавленную аннотацию @EnableConfigurationProperties:

@AutoConfiguration("observatoryFactory")
@EnableConfigurationProperties(ObservatoryProperties.class)
@RequiredArgsConstructor
public class ObservatoryFactory {

  @Bean
  public Observatory observatory(ObservatoryProperties properties) {
    return new Observatory(properties);
  }
}

Протестируем сервис:

@SpringBootTest(classes = ObservatoryFactory.class)
class ObservatoryFactoryTest {

  @Autowired
  private ApplicationContext context;

  @ParameterizedTest
  @MethodSource("beanNames")
  void applicationContextContainsBean(String beanName) {
    Assertions.assertTrue(context.containsBean(beanName));
  }

  private static Stream<String> beanNames() {
    return Stream.of(
        "observatoryFactory",
        "app.observatory-science.zoology.properties.ObservatoryProperties",
        "observatory"
    );
  }
}

Обратите внимание на имя бина класса параметров: "app.observatory-science.zoology.properties.ObservatoryProperties". В нем произошла конкатенация префикса настроек и полного имени класса через знак «-». Также в консоли можно увидеть строку:

ObservatoryProperties: ObservatoryProperties(name=Kopernik Station, telescopeDiameter=8, automaticMode=true)

Реализация как bean

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

public class Planetarium {

  public Planetarium(PlanetariumProperties properties) {
    System.out.println("PlanetariumProperties: " + properties);
  }

  //some code here
}

Класс настроек:

@ConfigurationProperties(prefix = "app.planetarium")
@Data
public class PlanetariumProperties {

  private String name;
  private Integer numberOfAuditoriums;
  private List<String> programmes;
}

Параметры конфигурации:

app.planetarium.name=Star Theatre
app.planetarium.number-of-auditoriums=16
app.planetarium.programmes=Travelling through the solar system, Secrets of the Universe, Is there life on Mars?

Теперь добавим фабрику:

@AutoConfiguration("planetariumFactory")
@RequiredArgsConstructor
public class PlanetariumFactory {

  @Bean
  public PlanetariumProperties planetariumProperties() {
    return new PlanetariumProperties();
  }

  @Bean
  public Planetarium planetarium(PlanetariumProperties properties) {
    return new Planetarium(properties);
  }
}

Можно тестировать:

@SpringBootTest(classes = PlanetariumFactory.class)
class PlanetariumFactoryTest {

  @Autowired
  private ApplicationContext context;

  @ParameterizedTest
  @MethodSource("beanNames")
  void applicationContextContainsBean(String beanName) {
    Assertions.assertTrue(context.containsBean(beanName));
  }

  private static Stream<String> beanNames() {
    return Stream.of(
        "planetariumFactory",
        "planetariumProperties",
        "planetarium"
    );
  }
}

Это два подхода получения параметров из файла конфигурации. Ключевая разница между ними в том, что в варианте с аннотацией @EnableConfigurationProperties в классе параметров можно использовать final-поля с инициализацией через конструктор. Иммутабельность — это хорошо. Но характеристики этих полей будут отсутствовать в spring-configuration-metadata.json — файле, автоматически генерируемом Spring'ом. Давайте разберем, что это за файл и зачем он нужен.

Описание параметров

С помощью процессора аннотаций Spring Boot формирует предоставление метаданных о конфигурации приложения. Файл spring-configuration-metadata.json содержит информацию о доступных параметрах конфигурации, их типах, значениях по умолчанию, описаниях и других атрибутах.

Например, если в файле application.properties приложения указано неверное значение для некоторого параметра конфигурации, то при запуске приложения Spring Boot может вывести сообщение об ошибке. В нем будет информация из файла spring-configuration-metadata.json, которая поможет разработчику исправить ошибку.

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

Для автогенерации этого файла в build.gradle нужно добавить такую строчку и собрать проект:

dependencies {
  annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:2.7.18"
}

Но сначала предлагаю дополнить ObservatoryProperties описанием каждого поля:

@ConfigurationProperties(prefix = "app.observatory")
@ConstructorBinding
@Value
public class ObservatoryProperties {

  /**
  * Название обсерватории.
  */
  String name;

  /**
  * Диаметр основного телескопа.
  */
  Integer telescopeDiameter;

  /**
  * Автоматический режим ИИ.
  */
  Boolean automaticMode;
 
  //more properties
}

В классе PlanetariumProperties в том числе указать значения по умолчанию:

@ConfigurationProperties(prefix = "app.planetarium")
@Data
public class PlanetariumProperties {

  /**
  * Название планетария.
  */
  private String name;

  /**
  * Количество аудиторий.
  */
  private Integer numberOfAuditoriums;

  /**
  * Список программ.
  */
  private List<String> programmes;
}

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

{
 "groups": [
  {
   "name": "app.observatory",
   "type": "science.zoology.properties.ObservatoryProperties",
   "sourceType": "science.zoology.properties.ObservatoryProperties"
  },
  {
   "name": "app.planetarium",
   "type": "science.zoology.properties.PlanetariumProperties",
   "sourceType": "science.zoology.properties.PlanetariumProperties"
  }
 ],
 "properties": [
  {
   "name": "app.planetarium.name",
   "type": "java.lang.String",
   "description": "Название планетария.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": "Stars"
  },
  {
   "name": "app.planetarium.number-of-auditoriums",
   "type": "java.lang.Integer",
   "description": "Количество аудиторий.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": 1
  },
  {
   "name": "app.planetarium.programmes",
   "type": "java.util.List<java.lang.String>",
   "description": "Список программ.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": "Teenagers in the Universe"
  }
 ],
 "hints": []
}

Сейчас хорошо видна разница между двумя подходами к работе с параметрами. У ObservatoryProperties — только общее описание типа, в то время как PlanetariumProperties содержат подробную информацию о каждом поле, в том числе "description", куда попало содержимое javadoc и значения по умолчанию.

Дополнительные метаданные

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

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

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

public ObservatoryProperties(@DefaultValue("Observatory") String name,
              @DefaultValue("42") Integer telescopeDiameter,
              @DefaultValue("false") Boolean automaticMode) {
  this.name = name;
  this.telescopeDiameter = telescopeDiameter;
  this.automaticMode = automaticMode;
}

Плюсы этого решения:

  • позволяет автоматически создать описание конфигурации;

  • данные из javadoc будут доступны потребителю данного стартера.

Недостатки:

  • нужно обязательно задать значения полей по умолчанию.

Обогащение данными

Удалим созданный конструктор из класса ObservatoryProperties и воспользуемся более гибкими возможностями по управлению метаданными. Для этого в директории META-INF создадим файл additional-spring-configuration-metadata.json с таким содержимым:

{
 "properties": [
  {
   "name": "app.observatory.automatic-mode",
   "type": "java.lang.Boolean",
   "description": "Автоматический режим ИИ.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": false
  },
  {
   "name": "app.observatory.name",
   "type": "java.lang.String",
   "description": "Название обсерватории.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": "Observatory"
  },
  {
   "name": "app.observatory.telescope-diameter",
   "type": "java.lang.Integer",
   "description": "Диаметр основного телескопа.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": 42
  }
 ],
 "hints": [
  {
   "name": "app.observatory.name",
   "values": [
    {
     "value": "Star Trek"
    },
    {
     "value": "Galactic beacon"
    },
    {
     "value": "Star Rider"
    }
   ]
  },
  {
   "name": "app.observatory.telescope-diameter",
   "values": [
    {
     "value": 4
    },
    {
     "value": 8
    },
    {
     "value": 16
    }
   ]
  },
  {
   "name": "app.observatory.automatic-mode",
   "values": [
    {
     "value": true
    },
    {
     "value": false
    }
   ]
  }
 ]
}

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

{
 "groups": [
  {
   "name": "app.observatory",
   "type": "science.zoology.properties.ObservatoryProperties",
   "sourceType": "science.zoology.properties.ObservatoryProperties"
  },
  {
   "name": "app.planetarium",
   "type": "science.zoology.properties.PlanetariumProperties",
   "sourceType": "science.zoology.properties.PlanetariumProperties"
  }
 ],
 "properties": [
  {
   "name": "app.observatory.automatic-mode",
   "type": "java.lang.Boolean",
   "description": "Автоматический режим ИИ.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": false
  },
  {
   "name": "app.observatory.name",
   "type": "java.lang.String",
   "description": "Название обсерватории.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": "Observatory"
  },
  {
   "name": "app.observatory.telescope-diameter",
   "type": "java.lang.Integer",
   "description": "Диаметр основного телескопа.",
   "sourceType": "science.zoology.properties.ObservatoryProperties",
   "defaultValue": 42
  },
  {
   "name": "app.planetarium.name",
   "type": "java.lang.String",
   "description": "Название планетария.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": "Stars"
  },
  {
   "name": "app.planetarium.number-of-auditoriums",
   "type": "java.lang.Integer",
   "description": "Количество аудиторий.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": 1
  },
  {
   "name": "app.planetarium.programmes",
   "type": "java.util.List<java.lang.String>",
   "description": "Список программ.",
   "sourceType": "science.zoology.properties.PlanetariumProperties",
   "defaultValue": "Teenagers in the Universe"
  }
 ],
 "hints": [
  {
   "name": "app.observatory.automatic-mode",
   "values": [
    {
     "value": true
    },
    {
     "value": false
    }
   ]
  },
  {
   "name": "app.observatory.name",
   "values": [
    {
     "value": "Star Trek"
    },
    {
     "value": "Galactic beacon"
    },
    {
     "value": "Star Rider"
    }
   ]
  },
  {
   "name": "app.observatory.telescope-diameter",
   "values": [
    {
     "value": 4
    },
    {
     "value": 8
    },
    {
     "value": 16
    }
   ]
  }
 ]
}

Добавленные значения по умолчанию строго ограничивают данные, которые считаются валидными для указанного поля. Любое другое значение подходящего типа добавить можно, но среда разработки будет подсказывать, что введены неправильные данные. Например, у нас указаны возможные имена обсерватории: Star Trek, Galactic beacon и Star Rider. А в application.properties указано Kopernik Station, что приводит к вот такому предупреждению:

Чтобы добавить любое другое значение в список допустимых, нужно указать providers: "any" в описании поля name:

{
 "name": "app.observatory.name",
 "values": [
  {
   "value": "Star Trek"
  },
  {
   "value": "Galactic beacon"
  },
  {
   "value": "Star Rider"
  }
 ],
 "providers": [
  {
   "name": "any"
  }
 ]
}

Основные блоки spring-configuration-metadata:

  • Groups — блоки параметров, значение равно префиксу конфигурации;

  • Properties — непосредственно параметры конфигурации, определяемые в файлах *.properties или *.yml. Содержат в себе:

    • name — имя параметра;

    • type — Java-тип;

    • description — описание, доступное в виде подсказки из IDE при определении параметра;

    • sourceType — класс-контейнер параметра;

    • defaultValue — значение по умолчанию;

  • Hints — блок подсказок, указывающих доступные значения при заполнении параметра.

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

Иерархия параметров

При интеграции стартера в приложение нужно учесть некоторые особенности работы с параметрами.

Приоритет формата

Конфигурация, указанная в application.properties, имеет приоритет перед application.yml. Соответственно, если у стартера есть описание в файле application.properties, а вы пытаетесь переопределить некий параметр в application.yml, то у вас ничего не выйдет.

Приоритет размещения

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

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

application-dev.properties
application-test.properties

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

@AutoConfiguration
@PropertySource("classpath:application-${spring.profiles.active:test}.properties")
public class PropertiesConfiguration {
}

В этом случае параметр аннотации @PropertySource принимает SpEL. Он позволяет динамически задействовать тот или иной профиль, в зависимости от активного профиля спринг. Или по умолчанию будет задействован профиль test.

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

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

cosmozoo-spring-boot-starter-application.properties

А потом добавить конфигурацию в контекст приложения:

@AutoConfiguration
@PropertySource("classpath:cosmozoo-spring-boot-starter-application.properties")
public class PropertiesConfiguration {
}

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

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

  2. Параметры, переопределенные в application.properties или application.yml приложения, будут иметь приоритет над значением по умолчанию.

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

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

Решить этот вопрос можно было и другим способом — добавить config-map в classpath непосредственно в переменных окружения. Но вариант с использованием аннотации @PropertySource мне кажется более наглядным и удобным для работы. Подробно об этой возможности мы еще поговорим в следующей части.


Предыдущие статьи из цикла про Spring Boot Starter:

  1. Spring Boot Starter: практически, принципиально и подробнее. Часть 1 — Чем хорош Spring Boot Starter и что такое автоконфигурация

  2. Spring Boot Starter: практически, принципиально и подробнее. Часть 2 — Условия и зависимости при создании бинов

Комментарии (3)


  1. LeshaRB
    30.07.2024 10:19

    Properties лучше делать не mutable, не ставить Data аннотацию
    https://andbin.dev/java/spring-value-annotation-with-lombok


    1. Antharas
      30.07.2024 10:19

      А как обыграете ситуацию с RefreshScope?


      1. LeshaRB
        30.07.2024 10:19

        RefreshScope
        A bean that is declared in @RefreshScope is created as a proxy. The actual target bean is also created on startup and stored in a cache with a key equal to its bean name. When a method call arrives at the proxy, it is passed down to the target. When the EnvironmentChangeEvent is consumed the cache is cleared and the BeanFactory callbacks on bean disposal are called by Spring, so the next method call on the proxy results in the target being re-created (the full Spring lifecycle, just as with any Scope).

        https://gist.github.com/dsyer/a43fe5f74427b371519af68c5c4904c7#refreshscope