1. Args: команда поверх уже собранного конфига
К этому моменту effective config уже собран: application.properties читается из classpath, env при необходимости перебивает отдельные значения, а на выходе мы получаем typed AppConfig. args решают другой вопрос — какой сценарий запускаем прямо сейчас.
Для ReadLater Starter это различие очень приземлённое: server.port, catalog.api.mode и catalog.api.base-url живут в конфиге. А catalog search clean code или catalog details OL12345M — это данные конкретного запуска. Если смешать эти две оси, main() очень быстро превратится в CLI-комбайн, где непонятно, что перенастраивает окружение, а что выбирает саму команду.
2. Канонические launch modes
Когда приложение поддерживает несколько режимов, самое ценное — договориться о канонической форме запуска. Не «ну примерно так», не «я в IDE по кнопке», а так, чтобы можно было написать в README одну строчку — и она работала у всех. Если вы хоть раз запускали чужой проект, где «как запускать» описано как «ну вы там сами разберитесь», вы понимаете, почему это важно.
У нас зафиксированы три launch mode, и мы не придумываем четвертый «на всякий случай», потому что это любимый способ начинающих усложнить жизнь себе же.
| Сценарий | Что означает | Пример запуска через Gradle |
|---|---|---|
| server | Поднимаем локальный HTTP-сервер | ./gradlew run --args="server" |
| catalog search <query...> | Ищем книги по запросу | ./gradlew run --args="catalog search clean code" |
| catalog details <externalId> | Получаем детали по внешнему ID | ./gradlew run --args="catalog details OL12345M" |
Обратите внимание на тонкость: поисковый запрос логически может состоять из нескольких слов. И в примере catalog search clean code Gradle передаст в main(String[] args) четыре элемента: ["catalog", "search", "clean", "code"]. Если вы напишете парсер «строго на 3 аргумента», то вы случайно ограничите поиск одним словом. А потом студент или вы же будете 20 минут думать, почему «clean code» ищется как «clean», и почему «code» вообще потерялся. Спойлер: он не потерялся, он просто оказался в args[3].
Поэтому мы будем считать корректным вот такое правило: catalog search принимает минимум один кусок запроса, а если кусков больше — мы их склеиваем обратно через пробел. Это не «сложный CLI-парсер», это просто проявление уважения к реальности.
Ещё один важный момент: args — это не место для выбора порта сервера и режима real/mock в рамках этой версии приложения. Порт и режим — конфигурация, и сегодня мы уже договорились, где она живёт: в application.properties с возможностью override через env vars. Если вы начнёте задавать порт то через args, то через env, то через файл — вы создадите три способа сделать одно и то же, и все три будут конфликтовать. Это как иметь три будильника на телефоне и удивляться, почему вы всё равно не выспались.
3. Парсер args: String[] → LaunchCommand
Студенты часто делают так: где-то в глубине кода они пишут if (args[0].equals("server")), потом ещё где-то — if (args[1].equals("search")), а потом удивляются, что приложение падает от ArrayIndexOutOfBoundsException при запуске без аргументов. Это нормально: мы все в какой-то момент верили в добрый мир, где пользователь всегда вводит всё правильно. Но backend-мышление как раз про то, что мир не добрый, а просто сложный.
Наша цель — превратить сырой массив строк String[] args в типизированную команду, которую дальше удобно обрабатывать без постоянных args.length и магических индексов. Для этого введём простой тип LaunchCommand. Пакетно это логично держать в com.example.readlater.app, потому что это история про запуск приложения, а не про каталог и не про reading list.
Минимальный вариант — plain Java, без усложнений — может выглядеть так:
// Единый тип для всех вариантов запуска приложения.
// Это позволяет дальше работать с командами как с объектами, а не с "магическими" строками.
public sealed interface LaunchCommand
permits ServerCommand, CatalogSearchCommand, CatalogDetailsCommand {}
// Команда: поднять локальный сервер.
public record ServerCommand() implements LaunchCommand {}
// Команда: поиск в каталоге (запрос уже склеен в одну строку).
public record CatalogSearchCommand(String query) implements LaunchCommand {}
// Команда: детали книги по внешнему идентификатору.
public record CatalogDetailsCommand(String externalId) implements LaunchCommand {}
Смысл тут не в «о, какие мы модные, sealed interface». Смысл в другом: дальше по коду мы будем работать не со строками server/catalog, а с понятными объектами. И это резко снижает количество ошибок.
Теперь нужен парсер. Не отдельный фреймворк, не библиотека, а честная функция, которая проверяет форму команды и либо возвращает LaunchCommand, либо падает с понятным сообщением «как правильно».
import java.util.Arrays;
public final class LaunchCommandParser {
public static LaunchCommand parse(String[] args) {
// 1) server
if (args.length == 1 && "server".equals(args[0])) {
return new ServerCommand();
}
// 2) catalog search <query...>
// Важно: запрос может быть из нескольких слов, поэтому берём "хвост" массива и склеиваем пробелами.
if (args.length >= 3 && "catalog".equals(args[0]) && "search".equals(args[1])) {
String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
return new CatalogSearchCommand(query);
}
// 3) catalog details <externalId>
if (args.length == 3 && "catalog".equals(args[0]) && "details".equals(args[1])) {
return new CatalogDetailsCommand(args[2]);
}
// Если команда не распознана — останавливаем запуск и показываем человеку, как правильно.
throw new IllegalArgumentException("""
Usage:
server
catalog search <query...>
catalog details <externalId>
""".trim());
}
}
Обратите внимание на args.length >= 3 в search. Это и есть наша поддержка запросов из нескольких слов. Да, мы просто берём остаток массива и склеиваем пробелом. Никакой магии. Просто удобство для человека.
Теперь вопрос: что делать с исключением? В учебном проекте нормально, если приложение падает на старте с понятным текстом, потому что запуск без команды — это не recoverable ситуация, это неправильный ввод. Но важно, чтобы сообщение было человекочитаемым, а не «Index 0 out of bounds».
Вот пример, как можно аккуратно показать usage. Да, через System.err.println — мы сегодня не про логирование:
try {
// На границе приложения превращаем "сырой" String[] в типизированную команду.
LaunchCommand cmd = LaunchCommandParser.parse(args);
} catch (IllegalArgumentException e) {
// Печатаем понятную подсказку и прекращаем запуск.
System.err.println(e.getMessage());
return;
}
И вот здесь уже чувствуется backend-привычка: входные данные проверяются на границе, а внутрь приложения попадает уже нормальная структура.
4. main() как точка сборки
В какой-то момент разработчик узнаёт слово «композиция» и начинает пытаться написать «универсальный фреймворк запуска приложения». Это весело, но мы держим себя в руках. Наша задача скромнее и полезнее: сделать так, чтобы main() не превратился в помойку из бизнес-логики, но при этом оставался честной точкой старта (composition root), где всё собирается вместе.
main() теперь собирает две независимые вещи: готовый AppConfig и LaunchCommand. И только потом отдаёт их раннеру.
Пример «склеивания» может выглядеть так:
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) {
Properties props = new PropertiesLoader().load();
AppConfig config = AppConfigFactory.from(props, System.getenv());
LaunchCommand cmd;
try {
cmd = LaunchCommandParser.parse(args);
} catch (IllegalArgumentException e) {
System.err.println(e.getMessage());
return;
}
ReadLaterRunner runner = new ReadLaterRunner(config);
runner.run(cmd);
}
}
Заметьте, в этом кусочке нет ни одной строки вроде «выбери server.port из env» или «распарсь catalog.api.mode». Это всё уже сделал конфиг-слой. main() только соединяет готовый AppConfig с готовой командой.
Чтобы мозг окончательно принял картину, полезно нарисовать схему запуска. Я люблю mermaid за то, что он дешевле, чем «рисовать в голове и страдать».
flowchart TD
A["Старт приложения (main)"] --> B["PropertiesLoader"]
B --> C["Properties"]
C --> D["AppConfigFactory.from(props, System.getenv())"]
D --> E["AppConfig"]
A --> F["LaunchCommandParser.parse(args)"]
F --> G["LaunchCommand: server / catalog search / catalog details"]
E --> H["ReadLaterRunner"]
G --> H
H --> I{"Выбор сценария"}
I -->|"server"| J["Server mode"]
I -->|"catalog search"| K["Catalog search"]
I -->|"catalog details"| L["Catalog details"]
Так main() остаётся входной дверью приложения, а не местом, где внезапно живёт вся логика мира.
5. Порядок важен: сначала AppConfig, потом команда
Правило приоритета значений уже зафиксировано в конфиг-слое: env → application.properties → default. Здесь важно только одно: LaunchCommandParser.parse(args) в этой цепочке не участвует. К моменту разбора args server.port, catalog.api.mode и другие настройки уже превратились в effective AppConfig.
Поэтому в main() удобно держать такой порядок: сначала PropertiesLoader, потом AppConfigFactory.from(props, System.getenv()), и только после этого LaunchCommandParser.parse(args). Так в коде сразу видно две отдельные оси: окружение собрали один раз, потом выбрали команду.
6. Типичные ошибки при args и конфиге
В этом месте обычно хочется сделать список из 12 пунктов, но мы договорились держать повествование гладким, как хорошо отполированный build.gradle.kts — ну, почти. Ошибки ниже — это не «вы плохие», это «так почти все делают, пока не набьют шишки». Наша задача — набить их виртуально и заранее.
Ошибка №1: смешивают launch mode и конфигурацию, превращая args в помойку.
Когда вы начинаете передавать через args и server, и port, и baseUrl, и таймауты, приложение становится непредсказуемым: часть настроек меняется через env, часть через файл, часть через args, и нигде нет одного «источника истины». В рамках курса лучше держать железное правило: args описывают команду и данные запуска, конфигурация живёт в properties/env.
Ошибка №2: парсят args «по месту использования» и ловят ArrayIndexOutOfBoundsException.
Если args[1] читается в одном методе, args[2] — в другом, а проверка args.length забыта в третьем, то вы гарантированно получите падение на каком-то нестандартном запуске. Лечение простое: один парсер на входе, который либо возвращает LaunchCommand, либо останавливает запуск с понятным usage.
Ошибка №3: делают catalog search строго из трёх аргументов и случайно ломают запросы из нескольких слов.
Это тонкий баг, потому что он не выглядит как ошибка: приложение «работает», но ищет не то. Если у вас catalog search clean code, а парсер берёт только args[2], вы фактически ищете «clean». Выход — принимать args.length >= 3 и склеивать остаток запроса обратно через пробел.
Ошибка №4: по-разному выбирают приоритет источников в разных местах.
Сегодня вы написали «env важнее файла», завтра в другом классе сделали наоборот, послезавтра добавили default «на всякий случай». Итог: вы выставляете переменную окружения, а она не работает, потому что конкретно этот ключ читается по-другому. Лечение: одно правило в ConfigReader или AppConfigFactory, а не локальные System.getenv() и props.getProperty(...) по всему коду.
Ошибка №5: неизвестную команду подменяют значением по умолчанию.
Иногда делают так: «если команда непонятна — запускаем server». Это кажется удобным, пока однажды вы не запустите ./gradlew run --args="sevrer" и не будете 10 минут смотреть на пустой терминал, думая, почему «ничего не происходит». Неизвестный launch mode — это ошибка, и она должна быть явной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ