1. Properties — это только полшага
Если оставить Properties как «главный объект конфигурации», проект очень быстро начнёт напоминать кухню, где все специи пересыпаны в одинаковые банки с надписью “SPICE”. Вроде бы можно открыть, понюхать и догадаться… но почему-то на третьей банке ты уже не уверен в себе, а суп получается неожиданно с нотками корицы и сожаления. С конфигом так же: строки везде дают ощущение гибкости, но на практике превращаются в источник ошибок и дублирования.
Файл из application.properties мы уже умеем доставать через classpath. Теперь видно следующую проблему: сырой Properties хочется просто передать дальше в приложение, и вот это уже плохая идея.
Проблема не в том, что Properties плохой. Он честно выполняет свою задачу: загрузить пары ключ=значение. Но дальше появляются типичные «болячки»:
Представьте, что server.port нужен и в серверном режиме (когда поднимаем HttpServer), и где-то в «smoke» выводе старта приложения, и ещё в каком-нибудь отладочном сообщении. Если вы в каждом месте делаете Integer.parseInt(props.getProperty("server.port")), то вы:
Во-первых, размазываете знание о ключе ("server.port") по проекту, и одна опечатка превращается в null и дальше в неожиданный NullPointerException или NumberFormatException. Во-вторых, вы размазываете правила: где-то порт по умолчанию 8080, где-то 9090, а где-то “ну ладно, пусть будет 0, и посмотрим”. В-третьих, вы получаете ошибку поздно, когда приложение уже наполовину собрало зависимости и что-то начало делать, а не в момент старта, когда ещё проще всего сказать: “конфиг неправильный, я даже не начинаю”.
И наконец, есть чисто человеческая проблема: Properties — это «мешок строк». Если вы передаёте его в сервисы, они начинают зависеть от ключей и строковых значений, и в какой-то момент вы ловите себя на мысли, что бизнес‑код читает конфиг напрямую. Это почти гарантированный путь к тому, чтобы завтра переписать половину проекта, когда вы захотите добавить второй источник конфигурации или поменять ключи на более аккуратные.
2. Конфигурационная модель: типы вместо строк
Когда говорят «объект конфигурации», новички иногда представляют себе что-то огромное, тяжёлое и enterprise‑вкусное, как трёхтомник “Архитектура ради архитектуры”. Но в нашем курсе это ровно противоположная идея: мы делаем маленькие простые контейнеры, которые в одном месте собирают настройки, один раз их проверяют и один раз преобразуют в нужные типы. Это экономит нервы и вам, и вашему будущему тимлиду, который будет смотреть на ваш код без возможности развидеть.
Давайте зафиксируем цель: вместо того, чтобы таскать по приложению Properties, мы хотим иметь, например, AppConfig, в котором уже есть ServerConfig и CatalogConfig. Тогда серверная часть получает ServerConfig и не знает ни про ключи, ни про строки. Клиентская часть каталога получает CatalogConfig и тоже не знает, что где-то есть файл application.properties.
Начнём с простых record‑ов. Они идеально подходят под конфиг: данные неизменяемые, смысл — «контейнер значений», минимум кода.
Для ReadLater Starter на сегодня достаточно такого набора: имя приложения, настройки каталожного клиента и настройки локального сервера.
package com.example.readlater.config;
public record AppConfig(
// Человекочитаемое имя приложения (для логов/идентификации)
String appName,
// Настройки клиента каталога
CatalogConfig catalog,
// Настройки локального сервера
ServerConfig server
) {
}
package com.example.readlater.config;
import java.net.URI;
import java.time.Duration;
public record CatalogConfig(
// Базовый адрес внешнего API (например, https://openlibrary.org)
URI baseUri,
// Таймаут ожидания ответа от внешнего сервиса
Duration requestTimeout,
// Режим: реальный клиент или заглушка
CatalogApiMode mode
) {
}
package com.example.readlater.config;
public record ServerConfig(
// Хост, на котором слушает локальный сервер
String host,
// Порт локального сервера
int port
) {
}
И для режима каталога (чтобы не хранить “магические строки” real/mock по всему проекту) добавим небольшой enum:
package com.example.readlater.config;
public enum CatalogApiMode {
// Ходим в реальный внешний сервис
REAL,
// Используем мок/заглушку (удобно для разработки и тестов)
MOCK
}
Теперь важная мысль: эти record‑ы сами по себе ещё ничего не решают. Магия происходит в момент, когда вы создаёте их из Properties, и именно там вы должны:
Считать часть ключей обязательными, часть — необязательными. Для необязательных задать явные значения по умолчанию. Преобразовать типы (строку → число, строку → URI, строку → Duration, строку → enum). И, что особенно важно, проверить значения на адекватность. Порт должен быть числом, таймаут не должен быть отрицательным, а baseUri должен быть реальным URI, а не “openlibrary.org ну и так понятно”.
Чтобы видеть общую картину, полезно держать в голове простой конвейер загрузки:
flowchart TD
A["application.properties (classpath)"] --> B["Properties (все значения строки)"]
B --> C["ConfigReader: required/default + parsing"]
C --> D["AppConfig / CatalogConfig / ServerConfig"]
D --> E["Точка входа (composition root)"]
E --> F["Catalog client / Server startup"]
Обратите внимание: Properties — это не то, что мы тащим дальше. Это сырьё, из которого мы делаем нормальный продукт.
3. Обязательные ключи и defaults
На старте очень хочется сделать “как проще”: «если ключа нет — поставим default, если значение кривое — ну… тоже default». Но такая стратегия превращает конфигурацию в лотерею. Приложение запускается, но работает не так, как вы ожидаете, и вы тратите время на расследование “почему он стучится не туда”, хотя правильный ответ должен был прозвучать сразу при старте: “не хватает ключа catalog.api.base-url”.
Чтобы не гадать, удобно заранее договориться о правилах. Ниже — примерная таблица ключей для ReadLater Starter (она соответствует нашему проекту и не уводит нас в сложную инфраструктуру):
| Ключ в application.properties | Тип в коде | Обязательный? | Значение по умолчанию | Комментарий |
|---|---|---|---|---|
| app.name | String | нет | readlater | чисто для идентификации/логов |
| catalog.api.base-url | URI | да | — | адрес внешнего каталога |
| catalog.api.request-timeout-ms | Duration | нет | 2000ms | таймаут ожидания ответа |
| catalog.api.mode | CatalogApiMode | нет | MOCK | режим клиента: REAL или MOCK |
| server.host | String | нет | localhost | где слушает локальный сервер |
| server.port | int | нет | 8080 | порт локального сервера |
Тут есть принципиальный момент: обязательных ключей не должно быть слишком много, иначе вы превращаете запуск в “квест по заполнению анкеты”. Но обязательные ключи должны оставаться обязательными, иначе ошибки превращаются в странное поведение. Для нашего курса разумно считать catalog.api.base-url обязательным: мы хотим, чтобы студент осознанно видел и управлял адресом внешнего API, а не жил на хардкоде “где-то там openlibrary”.
Значение по умолчанию — это тоже не “просто число, чтобы было”. Хороший default должен быть разумным в учебном проекте, не ломать сценарий новичку и при этом не скрывать критических проблем. Таймауты можно дефолтить, порт можно дефолтить, имя приложения можно дефолтить. А вот адрес внешнего API лучше явно задать в файле — потому что это точка интеграции, а не просто “косметика”.
Пример минимального application.properties, который уже позволит проекту жить предсказуемо:
app.name=readlater
catalog.api.base-url=https://openlibrary.org
catalog.api.mode=mock
catalog.api.request-timeout-ms=2000
server.host=localhost
server.port=8080
Ещё раз: файл может быть маленьким. Мы не строим Spring Boot с тысячей опций. Мы строим привычку.
4. Преобразование типов при старте
Когда конфиг остаётся строками слишком долго, появляются два вида боли: “поздние ошибки” и “ошибки не там”. Поздние — это когда NumberFormatException вылетает в середине выполнения команды catalog search, а не при старте. “Не там” — это когда исключение вылетает из глубины CatalogClient, хотя причина — в том, что server.port кто-то написал как eightythousand.
Правило, которое мы фиксируем: все преобразования типов — в конфигурационном слое, один раз, при старте, и с нормальными сообщениями. Не “For input string: 'abc'”, а “Invalid integer for key server.port: abc”.
Начнём с небольшой собственной ошибки конфигурации. Она помогает отличать “конфиг плохой” от “в коде баг”.
package com.example.readlater.config;
public class ConfigException extends RuntimeException {
public ConfigException(String message) {
// Это наше «домашнее» исключение: с ним проще понять, что сломалось именно в конфиге.
super(message);
}
}
Теперь нам нужен небольшой помощник, который читает из Properties и умеет: доставать обязательные значения и доставать значения с default.
package com.example.readlater.config;
import java.util.Properties;
public class ConfigReader {
// Сырой источник данных: тут всё в виде строк.
private final Properties props;
public ConfigReader(Properties props) {
this.props = props;
}
public String required(String key) {
// Обязательное значение: либо есть и не пустое, либо падаем при старте.
String v = props.getProperty(key);
if (v == null || v.isBlank()) throw new ConfigException("Missing key: " + key);
// trim() защищает от случайных пробелов вокруг значения в файле.
return v.trim();
}
public String optional(String key, String defaultValue) {
// Необязательное значение: если ключа нет или он пустой — берём default.
String v = props.getProperty(key);
if (v == null || v.isBlank()) return defaultValue;
return v.trim();
}
}
Обратите внимание на trim(): пробелы вокруг значений — это классика жанра, особенно если кто-то редактирует файл руками.
Теперь добавим чтение числа с default. В этом методе важно: если значение есть, но оно не число — это ошибка, а не повод “ну ладно, пусть будет 8080”.
package com.example.readlater.config;
public class Parse {
public static int intOrDefault(String raw, int defaultValue, String key) {
// Пустое/отсутствующее значение — это сигнал использовать default.
if (raw == null || raw.isBlank()) return defaultValue;
try {
// Парсим строго: если в конфиге «abc», лучше упасть сейчас, чем позже.
return Integer.parseInt(raw.trim());
} catch (NumberFormatException e) {
throw new ConfigException("Invalid int for key " + key + ": " + raw);
}
}
}
Да, это не супер‑объектно, но зато читается новичком и работает предсказуемо.
Duration из *-ms
В нашем проекте мы договорились хранить таймауты в миллисекундах (...-timeout-ms). Это удобно: в файле просто число, а в коде мы получаем Duration. Тогда дальше, при сборке HttpClient, мы не думаем “это секунды или миллисекунды?”, и меньше шансов сделать ошибку в 1000 раз (а 1000 раз — это обычно разница между “работает нормально” и “почему всё висит”).
package com.example.readlater.config;
import java.time.Duration;
public class Durations {
public static Duration msOrDefault(String raw, long defaultMs, String key) {
// Сначала читаем число (миллисекунды) с дефолтом...
int ms = Parse.intOrDefault(raw, (int) defaultMs, key);
// ...потом валидируем, чтобы не получить «минус-таймаут».
if (ms < 0) throw new ConfigException(key + " must be >= 0, got: " + ms);
// И только здесь превращаем миллисекунды в Duration.
return Duration.ofMillis(ms);
}
}
Да, мы используем int как упрощение. В учебном проекте это нормально; если хочется, можно сделать long. Главное — правило и проверка.
URI для base-url
URI лучше распарсить сразу, потому что строка "https://openlibrary.org" и строка "openlibrary.org" выглядят похоже, но в HttpClient и вообще в “взрослой жизни” — это две разные вещи.
package com.example.readlater.config;
import java.net.URI;
public class Uris {
public static URI requiredUri(String raw, String key) {
try {
// Парсим URI на старте, чтобы дальше в коде не возиться со строками.
URI uri = URI.create(raw.trim());
// Минимальная проверка: без схемы (http/https) это почти наверняка ошибка.
if (uri.getScheme() == null) throw new ConfigException(key + " must have scheme");
return uri;
} catch (IllegalArgumentException e) {
// Перехватываем стандартное исключение и заворачиваем в понятное конфиг-ошибку.
throw new ConfigException("Invalid URI for key " + key + ": " + raw);
}
}
}
Тут специально нет “супер‑валидации” (что host не пустой, что это http/https и т.д.). Можно добавить, но без фанатизма. Наша цель — аккуратно поймать очевидно плохие значения на старте.
Режим real/mock
Режим каталога удобно сделать enum‑ом, но файл хранит строку. Значит, делаем преобразование с понятной ошибкой, если пришло что-то левое.
package com.example.readlater.config;
public class Modes {
public static CatalogApiMode parseMode(String raw, CatalogApiMode def, String key) {
// Если ключ отсутствует — используем разумный дефолт.
if (raw == null || raw.isBlank()) return def;
// Разрешаем real/mock в любом регистре, чтобы файл был дружелюбнее к человеку.
return switch (raw.trim().toLowerCase()) {
case "real" -> CatalogApiMode.REAL;
case "mock" -> CatalogApiMode.MOCK;
default -> throw new ConfigException("Invalid " + key + ": " + raw);
};
}
}
Почему не CatalogApiMode.valueOf(...)? Потому что valueOf ожидает точное имя enum‑константы и выдаёт исключение, которое выглядит как “No enum constant …”. Мы хотим сообщение, понятное человеку.
5. Сборка AppConfig из Properties
Когда у нас есть маленькие методы преобразования, сборка конфигурации превращается в довольно прямолинейный код. И это прекрасно: конфиг должен быть скучным. Если ваша конфигурационная подсистема стала “интересной”, чаще всего это значит, что вы случайно пишете мини‑Spring Boot (а мы договорились так не делать, иначе курс закончится на 47‑м дне и сломанной психике).
Соберём AppConfig из Properties. Обратите внимание: мы создаём ConfigReader и используем его, чтобы все правила находились в одном месте.
package com.example.readlater.config;
import java.util.Properties;
public class AppConfigFactory {
public static AppConfig from(Properties props) {
// Вся сборка конфигурации происходит в одном месте.
// Это и есть главный принцип: один раз читаем/парсим/валидируем и дальше раздаём типы.
ConfigReader r = new ConfigReader(props);
CatalogConfig catalog = new CatalogConfig(
Uris.requiredUri(r.required("catalog.api.base-url"), "catalog.api.base-url"),
Durations.msOrDefault(
r.optional("catalog.api.request-timeout-ms", "2000"),
2000,
"catalog.api.request-timeout-ms"
),
Modes.parseMode(
r.optional("catalog.api.mode", "mock"),
CatalogApiMode.MOCK,
"catalog.api.mode"
)
);
ServerConfig server = new ServerConfig(
r.optional("server.host", "localhost"),
Parse.intOrDefault(r.optional("server.port", "8080"), 8080, "server.port")
);
return new AppConfig(
r.optional("app.name", "readlater"),
catalog,
server
);
}
}
Да, это плотный фрагмент — и это нормально. Он в одном месте описывает всю конфигурационную модель и не расползается по проекту. Если завтра у проекта появится новый ключ, мы всё равно придём сюда, а не начнём читать его прямо из сервиса. Главная идея остаётся той же: точка сборки одна, и именно она определяет, что считается обязательным, что имеет default, что и как парсится.
Если хочется чуть аккуратнее, лучше не держать ключи строками прямо в коде. Добавим класс с ключами — это маленькая штука, но она отлично спасает от опечаток и делает автодополнение IDE вашим другом.
package com.example.readlater.config;
public final class ConfigKeys {
// Константы для ключей: меньше опечаток, больше автодополнения от IDE.
public static final String APP_NAME = "app.name";
public static final String CATALOG_BASE_URL = "catalog.api.base-url";
public static final String CATALOG_REQUEST_TIMEOUT_MS = "catalog.api.request-timeout-ms";
public static final String CATALOG_MODE = "catalog.api.mode";
public static final String SERVER_HOST = "server.host";
public static final String SERVER_PORT = "server.port";
private ConfigKeys() {
// Запрещаем создавать экземпляры: это чисто утилитный класс.
}
}
Полный список ключей можно вынести сюда по мере роста. Главное — не превращать это в “словарь на 800 строк”. Мы в учебном проекте, нам нужна здравость, а не космический корабль.
6. Применяем конфиг через конструкторы
Следующий шаг после сборки AppConfig — правильное использование: вы загружаете конфигурацию один раз в точке входа и дальше раздаёте её частям приложения. Это продолжение нашей архитектурной идеи из дня про ручную сборку зависимостей: точка входа — это composition root. Если вы будете читать Properties в разных местах приложения, вы снова откатитесь к хаосу, только уже “в красивой обёртке”.
В ReadLaterApplication (или в отдельном bootstrap‑классе) логика обычно выглядит так: загрузили Properties, сделали AppConfig, затем собираем сервисы.
Пример — максимально простой, просто чтобы увидеть “как пользоваться”:
package com.example.readlater.app;
import com.example.readlater.config.AppConfig;
import com.example.readlater.config.AppConfigFactory;
import com.example.readlater.config.PropertiesLoader;
import java.util.Properties;
public class ReadLaterApplication {
public static void main(String[] args) {
// Сначала читаем application.properties из classpath...
Properties props = new PropertiesLoader().load();
// ...потом один раз собираем типизированный конфиг.
AppConfig config = AppConfigFactory.from(props);
// Дальше в приложении мы работаем не со строками, а с готовыми типами.
System.out.println(config.server().port()); // 8080
}
}
Здесь уже видна полезная сцепка: PropertiesLoader знает про classpath, AppConfigFactory знает про ключи и парсинг, а ReadLaterApplication знает только, что на входе у него есть готовый AppConfig.
А теперь важный момент, который прямо пригодится дальше в проекте: например, CatalogClient должен получать не Properties, а конкретные параметры. Идеальный вариант — принимать CatalogConfig, потому что это “его язык”.
package com.example.readlater.catalog.client;
import com.example.readlater.config.CatalogConfig;
public class CatalogClient {
private final CatalogConfig config;
public CatalogClient(CatalogConfig config) {
// Клиент получает ровно то, что ему нужно, без знания про ключи и Properties.
this.config = config;
}
}
Смысл в том, что внутри CatalogClient не должно быть строк "catalog.api.base-url" и вызовов getProperty(). Он работает с уже понятными типами: URI, Duration, CatalogApiMode. В следующий раз, когда вы решите поменять источник конфигурации, CatalogClient даже не заметит: ему всё равно дают CatalogConfig.
7. Типичные ошибки при работе с конфигурацией
Ошибка №1: конфиг читается в разных местах приложения, а не в одном.
Очень легко начать с AppConfigFactory, а потом “быстренько” добавить props.getProperty("server.port") прямо в код запуска сервера, потому что “так быстрее”. Через пару дней у вас будет два разных правила: в одном месте порт дефолтится 8080, в другом 9090, а в третьем вообще не дефолтится. Спасает одно правило: читаем и парсим конфиг один раз в config‑слое (или в точке входа), и дальше раздаём уже готовые объекты.
Ошибка №2: отсутствие различия между “ключ отсутствует” и “значение неправильного типа”.
Если ключ обязателен, то отсутствие — это отдельная ошибка (“Missing key”). А если ключ есть, но значение не парсится, это другая ошибка (“Invalid int/URI”). Если вы всё сваливаете в один try/catch, вы усложняете себе жизнь: вы не понимаете, что именно чинить. Делайте сообщения конкретными, и вам будет проще даже без дебаггера.
Ошибка №3: “тихий” default для обязательного ключа.
Иногда хочется сказать: “ну ладно, если catalog.api.base-url нет — подставим https://openlibrary.org”. Это удобно в моменте, но очень вредно как привычка: вы перестаёте замечать, что ваш конфиг неполный, и перенос приложения в другое окружение превращается в игру “почему оно всё ещё ходит в OpenLibrary”. Если ключ важен, лучше сделать его обязательным и падать на старте.
Ошибка №4: парсинг миллисекунд как секунд (и наоборот).
Это классическая ошибка масштаба: в файле написали 2000, подразумевая миллисекунды, а в коде сделали Duration.ofSeconds(2000) и получили “таймаут 33 минуты” вместо “2 секунды”. Спасает дисциплина имен ключей (*-timeout-ms) и строгое преобразование именно в Duration.ofMillis().
Ошибка №5: отсутствие валидации диапазонов для чисел.
Integer.parseInt — это ещё не проверка. Порт -1 парсится прекрасно, но серверу от этого не веселее. То же самое с таймаутами. Даже в учебном проекте стоит сделать минимальные проверки: порт 1…65535, таймауты >= 0. Это очень дешёвая защита от странных запусков.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ