1. application.properties как ресурс приложения
Если воспринимать конфигурационный файл как “ну это где-то на диске лежит”, вы очень быстро попадёте в ловушку: у вас всё будет работать в IDE, но перестанет работать при запуске через jar. Поэтому полезно сразу принять странную, но правильную мысль: когда приложение собрано, ваши ресурсы становятся частью артефакта. Они не “рядом”, они “внутри”.
В мире ReadLater Starter application.properties — это базовый источник настроек по умолчанию. Он должен лежать в src/main/resources, потому что именно эту папку Gradle трактует как “ресурсы приложения” и кладёт на classpath. Мы не делаем пока сложную систему конфигурации. Мы просто учимся читать аккуратный файл и не зависеть от конкретного расположения репозитория на диске.
Посмотрим, как обычно выглядит нужное нам расположение файлов (примерно так оно уже у вас устроено):
readlater-starter
└─ src
└─ main
├─ java/com/example/readlater/...
└─ resources
└─ application.properties
Если файл лежит здесь, он будет доступен приложению во время выполнения. Это и есть цель: чтобы конфиг читался одинаково при запуске из IDE, при ./gradlew run и даже при запуске собранного jar.
2. resources и classpath вместо пути к файлу
В какой-то момент новичку хочется сделать просто: “ну я же вижу файл, сейчас открою его по пути”. Проблема в том, что путь src/main/resources/application.properties существует только в исходниках. Когда вы собираете приложение, Gradle копирует ресурсы в build-директорию и дальше упаковывает их в jar. А внутри jar “путь на диске” уже не работает как вы ожидаете.
Представьте, что ваш проект — это чемодан. Пока чемодан открыт (исходники), вы действительно видите вещи “по папкам”. Но когда вы чемодан закрыли (собрали jar), вам нужно доставать вещи через “доступ к чемодану”, а не через “папку на полу”.
Небольшая схема того, что происходит с ресурсами при сборке:
flowchart TD
A["src/main/resources/application.properties"] --> B["Gradle processResources"]
B --> C["build/resources/main/application.properties"]
C --> D["Упаковка в jar"]
D --> E["classpath внутри приложения"]
И вот здесь появляется слово, которое поначалу звучит как заклинание: classpath. На человеческом языке это “набор мест, где JVM ищет классы и ресурсы”. Ресурс — это не только .class, это и ваш application.properties, и позже logback.xml, и sample JSON для mock-режима.
Поэтому главный вывод этого раздела простой: читать application.properties нужно через classpath, а не через FileInputStream по пути src/main/resources/....
Чтобы почувствовать разницу, посмотрим на антипример (не делайте так в реальном проекте, это учебный “плохой пример”):
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;
// Антипример: читаем ресурс по файловому пути из исходников.
// Это "везёт" в IDE, но ломается после сборки в jar и/или при запуске из другой директории.
Properties props = new Properties();
try (FileInputStream input = new FileInputStream("src/main/resources/application.properties")) {
// Загружаем пары ключ=значение
props.load(input);
}
В IDE это иногда “везёт” и работает. Но стоит вам собрать jar и запустить его из другой директории — и всё, файл не найден. Ничего личного, просто вы больше не живёте внутри папки исходников.
3. Формат .properties: ключи и строки
Чтобы уверенно читать application.properties, полезно понимать, что это за формат. Это очень старый, но очень живучий формат Java-мира: пары ключ=значение, комментарии и… в общем-то всё. Его сила не в красоте, а в том, что он прост и есть “из коробки” в JDK.
Вот пример минимального 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
Обратите внимание на два практических нюанса.
Первый: ключи мы называем группами через точки. Это не магия и не стандарт RFC, это просто удобная договорённость: читать глазами легче, и логически они “кучкуются”. Это важно, потому что конфиг быстро растёт, а мозг у нас один.
Второй: Properties читает значения как строки. Даже если вы написали server.port=8080, при чтении это будет строка "8080". Превращать в число мы будем в следующей лекции. Сегодня наша задача — только корректно загрузить и получить строки.
Ещё два микро-правила, которые спасают нервы.
Комментарии обычно пишут через #. Можно и через !, но давайте не плодить редкие традиции.
Пробелы вокруг = лучше не делать “как попало”. Формат допускает разные варианты, но чем меньше “вольностей”, тем меньше вопросов потом.
4. Загрузка через getResourceAsStream()
Теперь самое важное: как из Java-кода прочитать этот файл так, чтобы это работало и в IDE, и в собранном приложении. В JDK для этого есть классический путь: получить InputStream из classpath и загрузить его в Properties.
Ключевой метод дня: ClassLoader#getResourceAsStream("application.properties"). Он возвращает поток, или null, если ресурс не найден. И вот этот null обязательно надо проверять — иначе вы получите красивый NullPointerException и потратите 20 минут на вопрос “почему я не люблю Java”, хотя проблема просто в том, что файл лежит не там.
Самый минимальный пример (его можно даже временно вставить в main(), чтобы “почувствовать” механику):
import java.io.InputStream;
// Достаём ресурс из classpath (то есть "изнутри" jar / из ресурсов приложения)
InputStream input = ReadLaterApplication.class
.getClassLoader()
.getResourceAsStream("application.properties");
// Если ресурс найден — будет true. Если нет — null, и это надо уметь диагностировать.
System.out.println(input != null); // true (если файл лежит в resources)
Конечно, реальный код должен закрывать поток. Поэтому нормальная форма — try-with-resources:
import java.io.InputStream;
import java.util.Properties;
Properties props = new Properties();
try (InputStream input = ReadLaterApplication.class
.getClassLoader()
.getResourceAsStream("application.properties")) {
// Важно: getResourceAsStream может вернуть null, это не "исключение", это контракт метода
if (input == null) {
// Останавливаем запуск: без конфигурации приложение дальше жить не должно
throw new IllegalStateException("application.properties not found");
}
// Загружаем пары ключ=значение в Properties
props.load(input);
}
Здесь уже есть важные признаки “взрослого кода”.
Мы читаем ресурс через classpath, а значит не зависим от текущей директории запуска.
Мы явно говорим: если файл не найден — старт приложения должен быть прерван. Это правильнее, чем “ну ладно, продолжим”, потому что приложение без конфигурации часто работает непредсказуемо.
Мы закрываем InputStream автоматически.
И да, Properties#load() читает пары ключ=значение. Как только файл загружен, props можно спрашивать про значения.
Маленькая ремарка, чтобы избежать путаницы: есть ещё метод SomeClass.getResourceAsStream(). Он работает чуть иначе (там есть нюансы с ведущим /). Чтобы не собирать коллекцию граблей, сегодня придерживаемся варианта через ClassLoader — он проще для начинающих и соответствует нашей задаче “прочитать ресурс из корня classpath”.
5. getProperty(): читаем значения и smoke-check
Когда Properties загружен, хочется сразу “увидеть, что оно реально работает”. И это нормальное желание. Backend-разработчик вообще часто живёт в мире: “сначала убедимся, что данные приходят, потом уже построим красивую архитектуру”. Главное — не перепутать smoke-check с конечным дизайном.
Чтение значения выглядит просто:
// Читаем значение по ключу. Если ключа нет — вернётся null.
String appName = props.getProperty("app.name");
// Быстрый smoke-check: увидели ожидаемое значение — значит ресурс загрузился
System.out.println(appName); // readlater
Если ключа нет, getProperty вернёт null. Это нормально. Это не “ошибка Java”, это сигнал: “в файле нет такого ключа”. Уже в следующей лекции мы решим, что делать с обязательными ключами и дефолтами. Сейчас просто запомним правило: null возможен.
Давайте сделаем небольшой “снимок реальности” на примере наших ключей:
// Все значения приходят строками — даже порт.
String baseUrl = props.getProperty("catalog.api.base-url");
String mode = props.getProperty("catalog.api.mode");
String port = props.getProperty("server.port");
// Печатаем как проверку: конфиг прочитан, ключи доступны
System.out.println(baseUrl); // https://openlibrary.org
System.out.println(mode); // mock
System.out.println(port); // 8080
Обратите внимание: server.port всё ещё строка. И это хорошо, потому что мы пока не договорились о правилах валидации. Вдруг там написали server.port=котики — приложению нужно будет красиво объяснить, что это плохо. Это мы тоже сделаем дальше.
Есть ещё удобная перегрузка: getProperty(key, defaultValue). Она позволяет дать “план Б”, если ключа нет:
// Если ключ отсутствует, берём указанное значение по умолчанию
String host = props.getProperty("server.host", "localhost");
System.out.println(host); // localhost
Это хороший механизм, но пользоваться им надо осторожно. Для некоторых ключей (например, catalog.api.base-url) “дефолт” может скрыть ошибку конфигурации. Мы позже научимся отличать обязательные ключи от необязательных.
6. PropertiesLoader в пакете config
Если оставить загрузку Properties прямо в ReadLaterApplication, вы быстро получите “толстый main()”, который знает про всё: про аргументы запуска, про конфигурацию, про wiring, про логику. Мы уже договорились, что так делать не хотим. Поэтому сделаем маленький, но важный шаг: вынесем загрузку файла в отдельный класс в пакете config.
Пусть у нас будет простой класс PropertiesLoader. Он ничего “умного” не делает: просто загружает файл и возвращает Properties. Это ровно уровень сегодняшней лекции.
package com.example.readlater.config;
import java.io.InputStream;
import java.util.Properties;
public class PropertiesLoader {
// Загружаем application.properties из classpath и возвращаем Properties
public Properties load() {
Properties props = new Properties();
// try-with-resources гарантирует закрытие InputStream
try (InputStream input = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
// Явно проверяем отсутствие ресурса, чтобы ошибка была понятной
if (input == null) {
throw new IllegalStateException("application.properties not found");
}
// Загружаем пары ключ=значение
props.load(input);
return props;
} catch (Exception e) {
// Оборачиваем в понятную ошибку старта приложения
throw new IllegalStateException("Failed to load application.properties", e);
}
}
}
Код чуть длиннее 10 строк, но он цельный и важный: его стоит увидеть одним куском. Если хочется, можно потом микро-рефакторингом вынести try и catch, но сейчас лучше оставить читаемым.
Теперь в точке входа (ReadLaterApplication) мы можем сделать простой smoke-check: загрузили, прочитали пару значений, убедились, что всё живо.
package com.example.readlater.app;
import com.example.readlater.config.PropertiesLoader;
import java.util.Properties;
public class ReadLaterApplication {
public static void main(String[] args) {
// Загружаем конфигурацию на старте приложения
Properties props = new PropertiesLoader().load();
// Smoke-check: выводим пару значений, чтобы убедиться, что ресурсы читаются
System.out.println(props.getProperty("app.name")); // readlater
System.out.println(props.getProperty("catalog.api.base-url")); // https://openlibrary.org
}
}
Да, это временный вывод в консоль. Да, завтра у нас будет полноценное логирование. Но сегодня нам важно увидеть: ресурс читается, значения доступны, и мы больше не хардкодим URL прямо в коде.
И обратите внимание: ReadLaterApplication теперь не знает, как именно читается файл. Он знает только, что есть загрузчик. Это маленький, но правильный шаг к чистой структуре.
7. Нюансы: кодировка, пробелы, ключи
На первый взгляд Properties выглядит как “просто строки”. Но в реальной жизни детали начинают кусаться. Не смертельно, но лучше заранее знать, где можно встретить неожиданность.
Первая неожиданность — кодировка. Исторически Properties#load(InputStream) читает файл в формате ISO-8859-1, а не UTF-8. Пока в вашем конфиге только URL, числа и короткие английские значения — вам всё равно. Но если вы решите написать app.name=Список чтения (и это красиво!), вы можете получить кракозябры.
Если хочется жить спокойно, можно читать через Reader в UTF-8. Это выглядит так:
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
try (InputStream input = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
Properties props = new Properties();
// Читаем файл как UTF-8 (актуально, если в значениях есть кириллица и другие символы)
props.load(new InputStreamReader(input, StandardCharsets.UTF_8));
}
Вторая неожиданность — пробелы и пустые строки. Properties в целом терпеливый, но если вы начнёте писать значения вида server.host= localhost с хвостовыми пробелами, то эти пробелы могут остаться, и потом вы будете удивляться, почему Integer.parseInt(" 8080 ") внезапно ругается. В следующей лекции мы будем делать преобразования типов, и там мы обязательно добавим trim() или будем аккуратно работать с isBlank().
Третья неожиданность — отсутствие ключа. getProperty возвращает null, и это не “ошибка”, это “нет значения”. На этом этапе это нормально, потому что мы ещё не описали политику: какой ключ обязателен, какой нет. Сегодня мы фиксируем только механику: null возможен, и его нельзя игнорировать, если ключ важен.
По сути, сейчас мы делаем фундамент: научились доставать значения из файла. Следующим шагом мы научимся не просто доставать, а превращать их в “человеческую” конфигурационную модель и делать ошибки конфигурации понятными.
8. Типичные ошибки при загрузке application.properties
Ошибка №1: application.properties лежит не в src/main/resources.
Потом человек пишет правильный код с getResourceAsStream("application.properties"), получает null и начинает подозревать заговор Gradle, JDK и соседей по комнате. На практике это почти всегда просто неверная папка. Ресурс должен лежать именно в src/main/resources, потому что именно она попадает на classpath. Если файл лежит “где-то рядом”, JVM о нём не знает.
Ошибка №2: файл читается через путь src/main/resources/... с FileInputStream.
Это работает ровно до тех пор, пока вы запускаете приложение из корня репозитория и только из IDE. Как только меняется директория запуска или вы собираете jar, путь превращается в тыкву. Для ресурсов нужна модель “достань по имени из classpath”, а не “открой по файловому пути”. Это одна из тех вещей, которые в backend-жизни ломаются в самый неудобный момент.
Ошибка №3: не проверяется null после getResourceAsStream().
Метод честно говорит: “либо поток, либо null”. Если не проверить, вы получите NullPointerException внутри props.load(), и сообщение будет не про то, что файл не найден, а про то, что “что-то null”. В учебном проекте это особенно обидно: проблема простая, а диагностика получается мутная. Явная проверка и понятная ошибка экономят кучу времени.
Ошибка №4: ожидание, что Properties вернёт int, boolean и другие “нормальные типы”.
Properties — это словарь строк. Всегда. Даже если вы написали server.port=8080, это будет строка "8080". Если это принять, дальше всё становится логичным: сначала грузим строки, потом в конфигурационном слое преобразуем и валидируем. Если пытаться “пропустить шаг”, код расползётся: где-то Integer.parseInt(...), где-то default, где-то try/catch, и очень быстро вы потеряете единые правила.
Ошибка №5: хаотичные имена ключей без группировки.
Сегодня вы добавили baseUrl, завтра catalogUrl, послезавтра catalog.apiBaseUrl, а потом удивляетесь, почему никто не может найти нужную настройку. Схема catalog.api.*, server.*, app.* — не обязательная “по стандарту”, но очень полезная дисциплина. Она делает конфиг читаемым даже без IDE.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ