Пет-проект на Java Core: консольный каталог книг с сохранением

Пет-проект на Java Core: консольный каталог книг с сохранением
Содержание статьи

Для первого законченного пет-проекта на Java не обязательно сразу подключать Spring и базу данных. Консольный каталог книг позволяет отработать модель данных, коллекции, проверку ввода, исключения и файловое хранение. В этой статье соберём вариант, который сохраняет книги между запусками.

Это учебное однопользовательское приложение на Java 17. Оно не является библиотечной системой: здесь нет выдачи книг, учётных записей и сетевого API. На Mentoring.digital можно подобрать наставника для разбора такого проекта, а затем отдельно решить, нужны ли ему веб-интерфейс и сервер.

Что входит в первую версию

  • Добавить книгу с непустым названием длиной до 120 символов в принятой проверке Java String.
  • Показать список в порядке добавления.
  • Найти книги по части названия без учёта регистра.
  • Отметить книгу прочитанной по её идентификатору.
  • Сохранить данные в файл и восстановить при следующем запуске.
  • Отклонить неправильную команду, пустое название и неизвестный идентификатор.

Одинаковые названия разрешены: различать записи будем по ID. Ограничение длины использует String.length(), то есть число кодовых единиц UTF-16, а не количество видимых символов. Для интерфейса с эмодзи и сложными символами правило можно уточнить отдельно.

Как разделить данные, операции и консоль

Book является record с ID, названием и признаком прочтения. Класс Catalog отвечает за операции и сохранение. Метод main разбирает команды и печатает сообщения. Такое разделение позволяет проверить каталог напрямую, не имитируя каждый раз ввод в терминале.

Для хранения в памяти используется LinkedHashMap: ключом служит ID, а обход сохраняет порядок вставки. Поиск по названию проходит по всем книгам. Для небольшого упражнения это осознанное простое решение, а не обещание эффективности на большом каталоге.

Полный код консольного приложения

Сохраните код в BookCatalog.java. Имена команд и диагностические сообщения оставлены на английском, чтобы пример не зависел от языка интерфейса. Названия книг сохраняются в UTF-8.

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;

public class BookCatalog {
    public record Book(int id, String title, boolean read) {}

    public static final class Catalog {
        private final Path file;
        private LinkedHashMap<Integer, Book> books = new LinkedHashMap<>();

        public Catalog(Path file) throws IOException {
            this.file = file.toAbsolutePath();
            if (!Files.exists(this.file)) return;
            try {
                for (String line : Files.readAllLines(this.file, StandardCharsets.UTF_8)) {
                    String[] parts = line.split("\t", -1);
                    if (parts.length != 3) throw new IllegalArgumentException();
                    int id = Integer.parseInt(parts[0]);
                    if (id < 1 || books.containsKey(id)) throw new IllegalArgumentException();
                    if (!parts[1].equals("true") && !parts[1].equals("false"))
                        throw new IllegalArgumentException();
                    String title = new String(Base64.getDecoder().decode(parts[2]),
                                              StandardCharsets.UTF_8);
                    books.put(id, new Book(id, validTitle(title), Boolean.parseBoolean(parts[1])));
                }
            } catch (IllegalArgumentException error) {
                throw new IOException("Invalid catalog file; it was not overwritten", error);
            }
        }

        private static String validTitle(String title) {
            String clean = title.strip();
            if (clean.isEmpty() || clean.length() > 120)
                throw new IllegalArgumentException("Title must contain 1..120 characters");
            return clean;
        }

        public List<Book> all() { return List.copyOf(books.values()); }

        public List<Book> find(String query) {
            String needle = query.strip().toLowerCase(Locale.ROOT);
            if (needle.isEmpty()) throw new IllegalArgumentException("Empty search");
            return books.values().stream()
                .filter(b -> b.title().toLowerCase(Locale.ROOT).contains(needle)).toList();
        }

        public Book add(String title) throws IOException {
            String clean = validTitle(title);
            int id = Math.addExact(books.keySet().stream().mapToInt(Integer::intValue).max().orElse(0), 1);
            Book book = new Book(id, clean, false);
            var next = new LinkedHashMap<>(books);
            next.put(id, book);
            persist(next);
            books = next;
            return book;
        }

        public void markRead(int id) throws IOException {
            Book old = books.get(id);
            if (old == null) throw new IllegalArgumentException("Unknown book id");
            var next = new LinkedHashMap<>(books);
            next.put(id, new Book(id, old.title(), true));
            persist(next);
            books = next;
        }

        private void persist(LinkedHashMap<Integer, Book> next) throws IOException {
            List<String> lines = next.values().stream().map(b -> b.id() + "\t" + b.read()
                + "\t" + Base64.getEncoder().encodeToString(b.title().getBytes(StandardCharsets.UTF_8)))
                .toList();
            Files.createDirectories(file.getParent());
            Path temporary = Files.createTempFile(file.getParent(), "books-", ".tmp");
            try {
                Files.write(temporary, lines, StandardCharsets.UTF_8);
                try {
                    Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
                               StandardCopyOption.REPLACE_EXISTING);
                } catch (AtomicMoveNotSupportedException error) {
                    Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);
                }
            } finally {
                Files.deleteIfExists(temporary);
            }
        }
    }

    private static void show(List<Book> books) {
        if (books.isEmpty()) System.out.println("No books");
        for (Book b : books)
            System.out.println(b.id() + " | " + (b.read() ? "read" : "new") + " | " + b.title());
    }

    public static void main(String[] args) throws IOException {
        Path file = Path.of(args.length == 0 ? "books.tsv" : args[0]);
        Catalog catalog = new Catalog(file);
        System.out.println("add|title, list, find|text, read|id, exit");
        try (Scanner input = new Scanner(System.in, StandardCharsets.UTF_8)) {
            while (input.hasNextLine()) {
                String[] parts = input.nextLine().split("[|]", 2);
                String command = parts[0].strip();
                String value = parts.length == 2 ? parts[1] : "";
                if (command.equals("exit")) break;
                try {
                    switch (command) {
                        case "add" -> show(List.of(catalog.add(value)));
                        case "list" -> show(catalog.all());
                        case "find" -> show(catalog.find(value));
                        case "read" -> {
                            catalog.markRead(Integer.parseInt(value.strip()));
                            System.out.println("Saved");
                        }
                        default -> System.out.println("Unknown command");
                    }
                } catch (IllegalArgumentException | ArithmeticException | IOException error) {
                    System.out.println("Error: " + error.getMessage());
                }
            }
        }
    }
}

Компиляция и первый запуск

Понадобится JDK 17 или совместимая более новая версия. Проверьте java -version и javac -version, затем из папки с исходником выполните:

javac -encoding UTF-8 BookCatalog.java
java BookCatalog ./data/books.tsv

Путь после имени класса задаёт файл данных. При отсутствии аргумента используется books.tsv в текущей папке. Для первого запуска выбирайте отдельную учебную директорию, а не существующий файл с важной информацией.

Введите по одной строке:

add|Java Core
add|Algorithms
list
find|java
read|1
exit

Повторите команду запуска и введите list. Должны вернуться обе книги, первая со статусом read. Если русские буквы искажаются только в терминале, проверьте его кодировку отдельно от содержимого файла: программа читает стандартный ввод как UTF-8.

Зачем кодировать название внутри TSV

Файл содержит три поля: ID, статус и название в Base64. Между ними стоит табуляция. Кодирование названия позволяет не путать разделитель записи с символами внутри названия. Base64 не шифрует данные и не предназначен для хранения секретов.

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

При изменении сначала записывается временный файл рядом с основным. Затем выполняется замена. Если файловая система не поддерживает атомарное перемещение, используется обычная замена, которая не даёт той же гарантии. Пример не реализует резервные копии, блокировку нескольких процессов и защиту от всех сбоев питания.

Что проверить до добавления новых возможностей

ПроверкаОжидаемый результат
Пустой новый каталогСписок пуст, ошибки нет
Название с пробелами по краямПробелы убраны, запись сохранена
Пустое или слишком длинное названиеОшибка, файл не изменился
Поиск JAVA при названии Java CoreКнига найдена
Отметка неизвестного IDОшибка, существующие книги не изменились
Перезапуск после изменения статусаПорядок и статус сохранены
Кириллица в названииПосле чтения файла возвращается то же название
Повреждённый файлОткрытие завершилось ошибкой, файл не перезаписан

Проверять только вывод консоли недостаточно. Создайте временную папку для теста, вызовите методы Catalog, заново откройте тот же файл и сравните данные. Ошибочные операции проверяйте вместе с содержимым файла до и после вызова.

Для быстрого самостоятельного теста можно написать отдельный класс с main и проверками, выбрасывающими AssertionError. Когда сценариев станет больше, перенесите их в выбранный тестовый фреймворк. Само подключение фреймворка не заменяет содержательные ожидания.

Что рассказать о проекте на собеседовании

Объясните, почему выбрали коллекцию с порядком вставки, как отличаете некорректный ввод от ошибки записи и что произойдёт при повреждении файла. Покажите, что данные в памяти меняются только после успешного сохранения. Отдельно назовите ограничения: один процесс, небольшой объём данных, линейный поиск и отсутствие полноценной транзакционной системы.

Полезное продолжение проекта выбирают из задачи. Нужны несколько пользователей и сложные выборки? Тогда можно обсудить базу данных. Нужен доступ из браузера? Тогда появляется смысл в HTTP API. Не добавляйте слои, пока не можете объяснить, какую проблему они решают.

Если коллекции и исключения пока не складываются в целое, помогут курсы Java с практикой на своём приложении. Наставник по Java может разобрать код каталога, границы ответственности классов и тесты перед переходом к более сложному стеку.

Похожие статьи по данной теме