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("...")), у вас в голове наверняка сидит простая модель: «есть путь на диске — по нему и читаем». В backend-проекте эта модель быстро ломается, потому что приложение часто запускается не из папки с исходниками, а из собранного артефакта (например, 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 -->|run: 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 not found")) {
// Явно указываем 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, правильная working directory), но это хрупко.
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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ