1. Ресурси поряд із Java-кодом
У виводі classes ми вже бачили поруч compileJava і processResources. І це не випадковість: для запуску застосунку потрібен не лише Java-код. Потрібні ще й файли, які мають так само чесно потрапити в результат збирання, як і .class.
Якщо ви звикли до навчальних консольних програм, легко потрапити в пастку: «усе важливе — у .java-файлах». Але щойно застосунок починає бодай трохи нагадувати реальний проєкт, з’ясовується, що йому потрібні дані: текстові шаблони, невеликі довідники, статичні файли, банери, налаштування за замовчуванням. І все це — не код.
Уявіть, що Java-код — це «мозок» застосунку, а ресурси — «памʼятки й документи», які цей мозок читає, щоб діяти правильно. Можна, звісно, захардкодити все в рядках і константах, але тоді код швидко перетворюється на полицю з папками, де папки приклеєні скотчем просто до стіни. Працює… доки не потрібно змінити один рядок тексту без перекомпіляції, покласти поруч файл із даними або просто перестати дивитися на величезний JSON усередині String.
У Gradle-проєкті для ресурсів є окреме місце, і збирання вміє працювати з ним за правилами. Це і є наша мета: зрозуміти, де зберігати такі файли, як Gradle їх підхоплює і як Java їх читає, не прив’язуючись до «шляху на диску».
Папка src/main/resources
Коли ви бачите структуру Gradle-проєкту, легко подумати, що src/main/resources — це просто ще одна сусідня папка. Але для Gradle вона має спеціальний зміст: Java-плагін вважає все всередині неї ресурсами застосунку і автоматично включає ці файли в результат збирання.
Погляньмо на типову мінімальну структуру:
readlater-starter
└── src
└── main
├── java
│ └── com
│ └── example
│ └── readlater
│ └── ReadLaterApplication.java
└── resources
└── banner.txt
У src/main/java лежить код, який компілюється в .class-файли. У src/main/resources лежать файли, які не компілюються, а потрапляють у збирання «як є»: їх потрібно просто скопіювати в результат, щоб застосунок міг прочитати їх під час запуску.
Дуже важливий нюанс: шлях усередині src/main/resources перетворюється на шлях усередині classpath. Тобто якщо ви поклали файл так:
src/main/resources/banner.txt
то під час виконання застосунок бачитиме його як ресурс за шляхом:
/banner.txt
А якщо ви поклали так:
src/main/resources/com/example/readlater/messages.txt
то шлях ресурсу буде:
/com/example/readlater/messages.txt
Це звучить трохи дивно, але на практиці дуже зручно: ви заздалегідь розумієте, за яким «віртуальним шляхом» Java шукатиме файл, і вам не потрібно думати про абсолютні шляхи на диску.
2. processResources: обробка ресурсів
На перший погляд здається, що ресурси «просто лежать у проєкті», і все. Але Gradle не запускає застосунок прямо з src/. Він збирає результат в окрему папку build/, і за це відповідає окрема задача: processResources.
Якщо сказати простіше, processResources бере все з src/main/resources і копіює в «робоче місце» збирання:
build/resources/main
Це частина життєвого циклу, який ми обговорювали в лекції 1: задача classes включає і компіляцію Java-коду, і обробку ресурсів. У виводі Gradle ви часто побачите приблизно таку послідовність задач:
> Task :compileJava
> Task :processResources
> Task :classes
Можна навіть запустити лише обробку ресурсів:
./gradlew processResources # лише копіювання ресурсів у build/
А потім перевірити, що вийшло:
build/resources/main/banner.txt
І ось тут з’являється дисципліна, яка рятує купу нервів. Папка src/main/resources — це «джерело істини», її ви редагуєте вручну. Папка build/resources/main — результат збирання, туди руками лізти не потрібно. Якщо ви відредагуєте файл у build/, а потім знову запустите збирання, зміни зникнуть так само раптово, як і надії на ранню відпустку.
3. Classpath і читання ресурсів
Якщо раніше ви читали файли через Files.readString(Path.of("...")), у вас, найімовірніше, у голові сидить проста модель: «є шлях на диску — по ньому й читаємо». У бекенд-проєкті ця модель швидко ламається, тому що застосунок часто запускається не з папки з вихідними файлами, а зі зібраного артефакта (наприклад, jar).
Classpath — це «список місць», звідки JVM уміє завантажувати класи й ресурси. Ці «місця» можуть бути папками (у режимі розробки) або архівами jar (в упакованому вигляді). І приємна частина в тому, що для коду читання ресурсу це неважливо: він запитує «дай мені ресурс /banner.txt», а JVM сама вирішує, де його шукати — у папці чи всередині архіву.
У режимі ./gradlew run (коли Gradle запускає застосунок) classpath зазвичай включає дві ключові частини:
- build/classes/java/main — скомпільовані .class;
- build/resources/main — оброблені ресурси.
Якщо намалювати шлях ресурсу як ланцюжок, вийде приблизно так:
flowchart TD
A["src/main/resources/banner.txt"] -->|processResources| B["build/resources/main/banner.txt"]
B -->|під час запуску: classpath| C[Classpath JVM]
C --> D["ReadLaterApplication.class.getResourceAsStream('banner.txt')"]
D --> E[Потік InputStream із вмістом]
Зверніть увагу: на етапі запуску в цій схемі вже немає src/main/resources. Під час виконання нас цікавить те, що лежить у classpath, а не вихідні файли проєкту. Саме тому ресурси правильно читати через classpath, а не через шлях до вихідної папки.
4. Перший ресурс: banner.txt
Щоб ресурси не залишилися абстракцією, зробімо просту й дуже життєву річ: банер застосунку. Це може бути один рядок тексту, який застосунок показує під час старту. Так, це маленька деталь, але на ній одразу видно, що ресурс — це «дані поруч із кодом», а не рядкова константа всередині main().
Створіть файл:
src/main/resources/banner.txt
Наприклад, такий вміст:
ReadLater Starter
Тепер важливий момент: ми будемо читати цей файл не з src/..., а з classpath. І це дасть змогу застосунку працювати однаково і під час запуску з IDE, і під час запуску через Gradle, і взагалі будь-де, де є зібраний результат.
5. Читання через getResourceAsStream()
Тепер напишемо код, який акуратно читає banner.txt із classpath. Найпростіший і зрозумілий новачкові шлях — використати getResourceAsStream(). Він повертає InputStream, тобто потік байтів, який можна перетворити на рядок.
Мініверсія для ReadLaterApplication може виглядати так:
package com.example.readlater;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
public class ReadLaterApplication {
public static void main(String[] args) throws Exception {
// 1) Шукаємо ресурс у classpath, а не на диску.
// 2) Початковий слеш означає "від кореня classpath".
// 3) requireNonNull дає зрозумілу помилку, якщо файл не потрапив у збирання.
try (InputStream in = Objects.requireNonNull(
ReadLaterApplication.class.getResourceAsStream("/banner.txt"),
"banner.txt не знайдено")) {
// Явно вказуємо UTF-8, щоб не залежати від кодування за замовчуванням на машині.
System.out.println(new String(in.readAllBytes(), StandardCharsets.UTF_8)); // ReadLater Starter
}
}
}
Тут варто запамʼятати три речі — як маленьке правило виживання:
По-перше, шлях "/banner.txt" починається зі слеша. Це означає: «шукати від кореня classpath». Якщо забути слеш, Java шукатиме відносно пакета класу, і ви довго дивитиметеся на null, думаючи, що «Gradle знову щось не так завантажив».
По-друге, getResourceAsStream() може повернути null, якщо ресурс не знайдено. Тому Objects.requireNonNull(...) — це маленька страховка: якщо файл не потрапив у збирання, ви отримаєте зрозумілу помилку з повідомленням, а не загадковий NullPointerException десь у глибині читання байтів.
По-третє, ми явно вказуємо StandardCharsets.UTF_8, щоб не залежати від «кодування за замовчуванням» на конкретній машині. На одній ОС це може бути UTF‑8, на іншій — щось інше, і тоді ваш прекрасний текст перетвориться на набір символів, схожий на заклинання виклику стародавнього демона.
Цього вже достатньо, щоб вважати ReadLaterApplication робочою версією проєкту: банер живе в ресурсі, код читає його через classpath, а застосунок не залежить від того, де лежать вихідні файли на диску. Саме таку версію класу й варто тримати в проєкті як робочу.
6. Абсолютні й відносні шляхи
Шляхи ресурсів у Java — один із тих моментів, де новачки найчастіше втрачають час. Плутанина не тому, що ви «не розумієте Java», а тому, що тут справді є два режими адресації, і виглядають вони майже однаково.
Якщо шлях починається зі слеша, він вважається абсолютним відносно кореня classpath:
// Абсолютний шлях: шукаємо від кореня classpath
ReadLaterApplication.class.getResourceAsStream("/banner.txt");
Якщо слеша немає, шлях вважається відносним до пакета класу. Наприклад, якщо клас лежить у пакеті com.example.readlater, то "banner.txt" буде інтерпретовано як:
/com/example/readlater/banner.txt
Тобто це спрацює лише в тому разі, якщо ви поклали ресурс саме туди:
src/main/resources/com/example/readlater/banner.txt
Мінідемонстрація (лише для розуміння, не обов’язково так робити):
package com.example.readlater;
import java.io.InputStream;
public class ReadLaterApplication {
public static void main(String[] args) {
// Шукаємо ресурс у корені classpath (підходить, якщо banner.txt лежить прямо в src/main/resources)
InputStream a = ReadLaterApplication.class.getResourceAsStream("/banner.txt");
// Шукаємо ресурс відносно пакета класу: /com/example/readlater/banner.txt
InputStream b = ReadLaterApplication.class.getResourceAsStream("banner.txt");
// Виводимо, чи знайшли ми ресурс (null означає "не знайдено")
System.out.println(a != null); // true/false залежно від розташування
System.out.println(b != null); // true/false залежно від розташування
}
}
У навчальних проєктах я зазвичай раджу починати з абсолютних шляхів (зі слешем), тому що вони простіші для голови: ресурс лежить у корені resources — шлях "/...". Ресурс лежить у підпапці — шлях "/підпапка/...".
7. Читання ресурсів без Files
Цей пункт обов’язковий, тому що він пояснює, навіщо ресурси взагалі існують як механізм, а не просто як «ще одна папка». Найчастіша помилка новачка — написати приблизно так, бо «ну файл же там лежить»:
package com.example.readlater;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReadLaterApplication {
public static void main(String[] args) throws Exception {
// Це шлях до вихідних файлів у репозиторії, а не до ресурсів у classpath під час запуску.
// Іноді "пощастить" (IDE, правильна робоча директорія), але це крихко.
String text = Files.readString(Path.of("src/main/resources/banner.txt"));
System.out.println(text); // ReadLater Starter (якщо пощастить)
}
}
І іноді це навіть працює. Поки ви запускаєте проєкт із кореня. Поки ви в IDE. Поки випадково не змінили робочу директорію. Поки не зібрали застосунок і не спробували запустити його як артефакт.
Проблема в тому, що шлях src/main/resources/banner.txt — це шлях усередині репозиторію, тобто шлях до вихідних файлів. А застосунок під час виконання взагалі не зобов’язаний мати поруч вихідні файли. Якщо ви передасте jar на інший компʼютер, там буде jar, а папки src/ може взагалі не бути. І це нормально.
Підхід із classpath якраз знімає цей біль: ресурс підхоплюється збиранням, потрапляє в результат і читається однаково в будь-якому середовищі. Це та сама бекенд-звичка, яка здається занудною, доки одного разу не рятує вам вечір.
8. Перевірка ресурсів після збирання
Коли щось не знаходиться, новачок часто починає хаотично змінювати код. На практиці краще спершу перевірити: ресурс узагалі потрапив у результат збирання? Gradle доволі прозорий: після processResources файл має лежати в build/resources/main.
Можна зробити просту перевірку на око:
build
└── resources
└── main
└── banner.txt
А якщо хочеться трохи більше впевненості, відкрийте файл і переконайтеся, що це саме той текст, який ви редагували в src/main/resources.
Іноді буває інша ситуація: файл є в src/main/resources, але ви випадково поклали його не туди — наприклад, у src/main/java або в корінь проєкту поруч із README.md. Тоді Gradle не вважає його ресурсом, processResources його не копіює, і під час запуску ви отримаєте banner.txt not found. Це не «помилка Java», а просто дисципліна структури проєкту.
Щоб мозку було легше тримати це в голові, ось компактна табличка «де що живе»:
| Де лежить | Що це | Хто кладе | Чи можна редагувати вручну |
|---|---|---|---|
| src/main/resources | вихідні ресурси | ви | так |
| build/resources/main | оброблені ресурси для запуску | Gradle (processResources) | ні, інакше втратите зміни |
| classpath застосунку | «віртуальний простір» ресурсів під час запуску | Gradle/JVM | руками туди не лізуть |
9. Типові помилки під час роботи з ресурсами
Робота з ресурсами виглядає просто рівно до першого null із getResourceAsStream(). Далі починається класичний етап «чому воно не працює, я ж усе правильно зробив». Це нормально: частина навчання якраз у тому, щоб зрозуміти шлях ресурсу й прийняти, що запускається не src/, а build/.
Помилка № 1: ресурс поклали не в src/main/resources, а в src/main/java або в корінь проєкту.
Таке часто трапляється «на автоматі»: ви бачите дерево проєкту і кидаєте файл туди, куди у вас у цей момент відкритий файловий менеджер. У підсумку processResources його не копіює, у build/resources/main файлу немає, і getResourceAsStream() чесно повертає null. Виправляється просто: ресурси живуть у src/main/resources, тому що Gradle так домовився за замовчуванням.
Помилка № 2: забули початковий слеш у шляху getResourceAsStream("banner.txt").
Якщо ви кладете banner.txt прямо в корінь resources, а читаєте його без слеша, Java починає шукати в /com/example/readlater/banner.txt. Вона не зобов’язана вам це пояснювати — вона просто не знаходить ресурс і повертає null. Якщо хочете шукати від кореня classpath, пишіть "/banner.txt".
Помилка № 3: ресурс є, але ви читаєте його як файл із src/main/resources/... через Files.
Це працює рівно до першого реального запуску поза IDE. Трохи змінилася робоча директорія, трохи змінився спосіб запуску — і все, файл не знайдено. Шлях src/... — це шлях до вихідних файлів, а не до результату збирання. Якщо ресурс має жити разом із застосунком, його читають через classpath.
Помилка № 4: getResourceAsStream() повернув null, а код одразу робить in.readAllBytes() і падає з NullPointerException.
NullPointerException тут не «зла доля», а цілком конкретний сигнал: ресурс не знайдено. Тому корисно або перевірити if (in == null), або використати Objects.requireNonNull(...) із зрозумілим повідомленням. Тоді ви заощаджуєте собі час: одразу видно, що проблема в шляху або розташуванні файлу.
Помилка № 5: проблеми з кодуванням: текст «кракозябрами».
Якщо в ресурсі є не-ASCII-символи (наприклад, українські літери), і ви перетворюєте байти на рядок без явного зазначення UTF-8, результат може залежати від системи. На вашій машині все красиво, на іншій — «Ð идлетер». Виправляється просто: завжди вказуйте StandardCharsets.UTF_8.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ