JavaRush /Курсы /Java Server /Команды catalog в ...

Команды catalog в ReadLater

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

1. Место команд в ReadLaterApplication

Пока приложение не умеет нормально запускаться «снаружи» через args, оно остаётся чем-то вроде лабораторной работы: код есть, но его сложно воспроизвести, сложно показать другому человеку и сложно проверять. Команды catalog search и catalog details — это наш минимальный «пульт управления», который превращает проект в реальный артефакт, а не набор классов «на всякий случай».

Мы договоримся, что сегодня ReadLaterApplication понимает ровно две формы запуска:

Команда Пример запуска Смысл
catalog search <query...>
./gradlew run --args="catalog search clean code"
Ищем книги во внешнем каталоге по запросу
catalog details <externalId>
./gradlew run --args="catalog details OL12345M"
Берём подробности по внешнему идентификатору

Обратите внимание на важную деталь: у search запрос может состоять из нескольких слов, поэтому это «хвост» аргументов — всё, что после search. У details параметр ровно один — externalId. Это сильно упрощает разбор и делает поведение предсказуемым.

Чтобы этот CLI не превратился в сетевой комбайн, точка входа должна опираться на уже готовый CatalogClient. HTTP, provider DTO и mapping остаются внутри клиентского слоя, а ReadLaterApplication занимается только командами и пользовательским выводом.

И ещё один принцип, который сегодня держим как мантру: точка входа не должна строить URI, ходить по сети и читать JSON. Её задача — распознать команду и делегировать выполнение в «нормальный код» — наш внутренний клиентский слой, который уже умеет работать с DTO.

2. String[] args и токены

Когда вы запускаете Java-приложение, main(String[] args) получает не «одну строку команды», а уже разрезанные токены. Это удобно, но иногда удивляет: вам кажется, что вы передали clean code, а Java видит два аргумента: "clean" и "code". И это нормально — так устроен запуск команд в большинстве систем.

Чтобы не гадать, полезно хотя бы один раз вывести args и увидеть их глазами:

import java.util.Arrays;

public class ArgsDemo {
    public static void main(String[] args) {
        // Полезно вывести аргументы ровно в том виде, в каком их получил JVM.
        // Это помогает быстро понять, где заканчиваются "команда" и "параметры".
        System.out.println(Arrays.toString(args)); // [catalog, search, clean, code]
    }
}

Если вы запускаете через Gradle так:

./gradlew run --args="catalog search clean code"

то чаще всего, в типичном окружении, Java увидит именно массив:

[catalog, search, clean, code]

И из этого следует простое инженерное решение: для catalog search мы не пытаемся угадать, где у запроса начало и конец, мы просто берём все элементы массива, начиная с индекса 2, и склеиваем обратно пробелом.

И вот здесь появляется отличная новость: нам не нужно строить сложный парсер командной строки, не нужно городить кавычки, экранирование и прочие радости жизни. Наши требования очень простые, значит и код будет простым. А простой код — это редкость, которую надо беречь, как последний кусок пиццы в офисе.

3. Грамматика команды

Самая типичная проблема новичка — писать разбор команды прямо в main(), а потом «по дороге» добавлять туда всё подряд. Сначала один if, потом ещё один, потом «ну тут ещё распечатаю», потом «ну тут ещё попробую собрать URI», и внезапно у нас в main одновременно роутер, транспортный слой, JSON-мэппер и бог локального вывода. Примерно так и рождаются легенды о том, что Java — это «слишком многословно».

Сделаем аккуратно: заведём маленькую модель команды и отдельный метод-парсер. Модель будет настолько простой, что её можно держать даже вложенной в ReadLaterApplication.

public class ReadLaterApplication {

    // Две поддерживаемые команды каталога: поиск и детали.
    private enum CatalogCommandType { SEARCH, DETAILS }

    // Унифицированная модель результата парсинга:
    // - type: какая команда
    // - value: либо query (для SEARCH), либо externalId (для DETAILS)
    private record CatalogCommand(CatalogCommandType type, String value) {}

}

И теперь метод, который пытается разобрать args. Он либо возвращает команду, либо null (или Optional, но давайте не усложнять жизнь на старте).

import java.util.Arrays;

private CatalogCommand parseCatalogCommand(String[] args) {
    // Сначала проверяем минимальную длину, чтобы не словить ArrayIndexOutOfBoundsException.
    if (args.length < 2) return null;

    // Первая "часть" команды должна быть строго catalog.
    if (!args[0].equals("catalog")) return null;

    // catalog search <query...>
    if (args[1].equals("search") && args.length >= 3) {
        // ВАЖНО: query может состоять из нескольких слов, поэтому склеиваем "хвост" аргументов.
        String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
        return new CatalogCommand(CatalogCommandType.SEARCH, query);
    }

    // catalog details <externalId>
    if (args[1].equals("details") && args.length == 3) {
        // Для details параметр один, поэтому можем быть строгими по длине.
        return new CatalogCommand(CatalogCommandType.DETAILS, args[2]);
    }

    // Если форма не совпала ни с одной поддерживаемой командой — возвращаем null.
    return null;
}

Здесь важно, что мы проверяем длину массива до того, как обращаемся к args[1] или args[2]. Это банально, но спасает от классического ArrayIndexOutOfBoundsException, который обычно появляется в самый «показательный» момент — когда вы демонстрируете код кому-то ещё.

4. Роутинг в ReadLaterApplication

В этот момент у нас появляется приятная структура: run(args) занимается тем, что вызывает парсер, проверяет результат и запускает правильный сценарий. То есть делает ровно то, что делает «взрослая точка входа» в любом приложении: управляет потоками выполнения, а не «делает всю работу руками». Даже если это пока всего две команды.

Пример «ядра» run() может выглядеть так:

public void run(String[] args) {
    // 1) Парсим args в понятную модель команды.
    CatalogCommand command = parseCatalogCommand(args);

    // 2) Если команда не распознана — печатаем ошибку и подсказку по запуску.
    if (command == null) {
        System.err.println("Неизвестная команда или неверные аргументы.");
        printUsage();
        return;
    }

    try {
        // 3) Маршрутизируем выполнение по типу команды.
        switch (command.type()) {
            case SEARCH -> runCatalogSearch(command.value());    // value = query
            case DETAILS -> runCatalogDetails(command.value());  // value = externalId
        }
    } catch (RuntimeException e) {
        // Верхний CLI-слой завершает сценарий коротким сообщением, а не stacktrace на экран.
        System.err.println("Команда не выполнена.");
    }
}

Здесь важно отделить две разные поломки. Неверная форма команды — это проблема CLI, поэтому printUsage() живёт прямо тут. Сбой при выполнении команды — это уже история клиентского слоя; верхняя точка входа заканчивает сценарий коротким сообщением и не вываливает пользователю stacktrace. Технические детали должны жить рядом с самим HTTP-вызовом, а не в каждой команде по отдельности.

Чтобы картина была совсем прозрачной, можно представить это как маленький конвейер:

flowchart TD
    A["String[] args"] --> B["parseCatalogCommand(args)"]
    B -->|null| C["printUsage()"]
    B -->|CatalogCommand| D["switch(type)"]
    D --> E["runCatalogSearch(query)"]
    D --> F["runCatalogDetails(externalId)"]
    E --> G["CatalogClient.search(...)"]
    F --> H["CatalogClient.details(...)"]

Теперь ReadLaterApplication становится понятным даже человеку, который не знает нашего проекта: он видит, какие команды есть, где они разбираются, и куда делегируется выполнение.

5. catalog search: запрос

Команда поиска — это первый сценарий, который обычно запускают, потому что он самый «живой»: мы вводим фразу, и в ответ получаем список книг. Поэтому здесь особенно важно, чтобы запрос из нескольких слов не ломал всё приложение. Наша политика простая: всё, что после catalog search, склеиваем обратно в строку запроса.

Предположим, у нас уже есть внутренний API клиента CatalogClient, который возвращает нормализованные DTO:

import java.util.List;

public interface CatalogClient {
    // Выполняет поиск книг по строковому запросу и возвращает нормализованные элементы выдачи.
    List<CatalogBookSearchItem> search(String query);
}

Тогда runCatalogSearch(query) выглядит почти как псевдокод, и это прекрасно:

import java.util.List;

private void runCatalogSearch(String query) {
    // Делаем поиск через клиентский слой (а не руками через HTTP/JSON в точке входа).
    List<CatalogBookSearchItem> items = catalogClient.search(query);

    // Дружелюбный вывод: пользователь сразу видит, есть ли результаты.
    System.out.println("Найдено: " + items.size()); // Например: Найдено: 2

    // Печать списка лучше вынести отдельно, чтобы runCatalogSearch не разрастался.
    printSearchItems(items);
}

Метод печати лучше держать отдельным, чтобы не делать runCatalogSearch() «простынёй»:

import java.util.List;

private void printSearchItems(List<CatalogBookSearchItem> items) {
    // Печатаем нормализованные поля приложения, а не provider-specific DTO.
    for (CatalogBookSearchItem item : items) {
        System.out.println(item.externalId() + " | " + item.title() + " | " + item.author());
        // Пример строки:
        // OL12345M | Clean Code | Robert C. Martin
    }
}

Этот кусок работает только если клиентский слой вернул готовый список. Если во время HTTP-вызова или маппинга произошла ошибка, обычный вывод не продолжается: команда завершается верхним сообщением об ошибке, а не печатает полусырые данные.

Заметьте, что мы печатаем нормализованные поля externalId, title, author, а не provider-specific штуки вроде key или массив authors. Это важное следствие нашей архитектуры: точка входа и вывод работают на языке приложения, а не на языке внешнего сервиса.

Если поиск ничего не нашёл, items.size() будет 0 — и это тоже нормальный сценарий. Для пользователя полезно увидеть Найдено: 0 и, возможно, отдельную фразу Ничего не найдено, но сам по себе ноль — уже предсказуемое поведение.

6. catalog details: карточка

Если поиск — это «список», то детали — это «карточка». У команды catalog details параметр должен быть один. И это очень удобный случай, потому что мы можем быть строгими: если аргументов не 3 — значит, команда введена неправильно, и лучше сразу показать usage, чем пытаться «додумывать за пользователя».

Снова опираемся на внутренний API клиента:

public interface CatalogClient {
    // Возвращает детальную карточку книги по внешнему идентификатору.
    CatalogBookDetails details(String externalId);
}

Тогда выполнение команды максимально прямое:

private void runCatalogDetails(String externalId) {
    // Получаем детали через клиентский слой.
    CatalogBookDetails details = catalogClient.details(externalId);

    // Выводим основные поля "карточки".
    System.out.println("externalId: " + details.externalId());
    System.out.println("title: " + details.title());
    System.out.println("author: " + details.author());
}

Да, это пока «простая печать в консоль». И это нормально для нашей текущей стадии: мы сейчас не строим красивый UI, мы строим воспроизводимый backend-like сценарий. Наша цель — чтобы команда работала одинаково в real и mock режимах, а не чтобы карточка была визуально прекраснее LinkedIn-поста.

Важно ещё вот что: команда details очень чувствительна к тому, что вы передали в externalId. Если вы случайно передали пустую строку или опечатались, внешний сервис может вернуть 404, или 200 с пустыми данными, или странную ошибку. На уровне CLI мы хотя бы гарантируем, что параметр вообще присутствует, а дальше пусть клиентский слой решает, что делать, если книга не найдена. С деталями правило то же самое: либо получили целую карточку и печатаем её, либо команда заканчивается коротким сообщением об ошибке, без полуразобранного вывода.

7. Usage и ошибки

Плохой CLI отличается от хорошего одной простой вещью: когда пользователь ошибся, плохой CLI наказывает, а хороший — подсказывает. Нам, честно говоря, выгоднее второй вариант, потому что пользователь — это мы сами через 15 минут, когда забудем, как именно запускать проект. Мозг любит стирать команды из памяти, чтобы освободить место под важное, например под мемы про программистов.

Простейший printUsage() может быть таким:

private void printUsage() {
    // Text block здесь читается лучше, чем много println подряд.
    System.out.print("""
            Usage:
              ./gradlew run --args="catalog search <query>"
              ./gradlew run --args="catalog details <externalId>"
            """);
}

И в run(args) мы выводим эту подсказку при любой ошибке формы команды. Ещё полезно различать «обычный вывод» и «ошибку». В Java для этого есть System.err, и он существует не для красоты.

if (command == null) {
    // Сообщение об ошибке отправляем в stderr, чтобы его можно было отделять от "нормального" вывода.
    System.err.println("Ошибка: неверная команда. Ожидалось: catalog search|details ...");

    // Usage печатаем в stdout как подсказку пользователю.
    printUsage();
    return;
}

Заметьте, что мы не кидаем исключение вверх и не показываем stacktrace пользователю. Стектрейс полезен нам как разработчикам, но как help message он работает примерно так же, как инструкция от микроволновки, написанная на древнем эльфийском.

Если очень хочется быть ещё человечнее, можно добавить пару примеров:

System.out.println("Examples:");
System.out.println("  ./gradlew run --args=\"catalog search clean code\"");
System.out.println("  ./gradlew run --args=\"catalog details OL12345M\"");

Но важно не превращать usage в простыню. Чем короче и конкретнее подсказка, тем выше шанс, что ею реально воспользуются.

8. Типичные ошибки при работе с CLI

Ошибка №1: обращение к args[1] и args[2] без проверки длины массива.
Это классический путь к ArrayIndexOutOfBoundsException. Он появляется, когда вы запускаете приложение без аргументов или с неполной командой (catalog, но без search/details). Правильная привычка — сначала проверять args.length, и только потом индексировать массив.

Ошибка №2: для catalog search берётся только args[2], и теряются остальные слова запроса.
Если вы написали String query = args[2];, то запрос clean code превратится в clean, и вы будете долго удивляться, почему поиск «как будто работает хуже». Для поиска мы должны склеивать хвост аргументов через String.join(" ", ...).

Ошибка №3: попытка сделать «умный» парсер командной строки раньше времени.
Иногда хочется поддержать кавычки, флаги, --limit=10, алиасы и ещё половину возможностей Bash. В итоге вы пишете полпроекта CLI-фреймворка и забываете, зачем вообще начинали. Сегодня наша грамматика маленькая — и это плюс. Лучше держать парсер коротким и предсказуемым.

Ошибка №4: построение URI, HTTP-вызов и чтение JSON прямо в ReadLaterApplication.
Так ReadLaterApplication превращается в «суп из всего». Любое изменение внешнего контракта ломает точку входа, а тестировать такое неудобно. Гораздо спокойнее, когда ReadLaterApplication вызывает catalogClient.search(...), получает нормализованные DTO и просто печатает их.

Ошибка №5: печать provider DTO или работа с provider-полями в коде команды.
Если в runCatalogSearch() вы вдруг начинаете печатать doc.key() или лазить в authors().get(0), значит граница сломалась: внешний контракт протёк туда, где ему жить не нужно. Это не просто «косметика». Это риск того, что при изменении внешнего JSON вам придётся чинить половину проекта, а не один слой.

1
Задача
Java Server, 17 уровень, 2 лекция
Недоступна
Команда `catalog search` с многословным запросом
Команда `catalog search` с многословным запросом
1
Задача
Java Server, 17 уровень, 2 лекция
Недоступна
Команда `catalog details` по внешнему идентификатору
Команда `catalog details` по внешнему идентификатору
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ