JavaRush /Курси /Java Server /src/main/resources і...

src/main/resources і шлях ресурсів

Java Server
Рівень 4 , Лекція 1
Відкрита

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.

1
Задача
Java Server, 4 рівень, 1 лекція
Недоступна
Ресурс у підпапці та абсолютний шлях у classpath
Ресурс у підпапці та абсолютний шлях у classpath
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ