JavaRush /Курсы /Java Server /Command-line args и приоритет конфигурации

Command-line args и приоритет конфигурации

Java Server
20 уровень , 4 лекция
Открыта

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 — это ошибка, и она должна быть явной.

1
Задача
Java Server, 20 уровень, 4 лекция
Недоступна
`LaunchCommandParser` для трёх режимов запуска
`LaunchCommandParser` для трёх режимов запуска
1
Задача
Java Server, 20 уровень, 4 лекция
Недоступна
Сводка запуска из `AppConfig` и `LaunchCommand`
Сводка запуска из `AppConfig` и `LaunchCommand`
1
Опрос
Настройки проекта, 20 уровень, 4 лекция
Недоступен
Настройки проекта
Файлы и приоритеты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ