


Для первого законченного пет-проекта на Java не обязательно сразу подключать Spring и базу данных. Консольный каталог книг позволяет отработать модель данных, коллекции, проверку ввода, исключения и файловое хранение. В этой статье соберём вариант, который сохраняет книги между запусками.
Это учебное однопользовательское приложение на Java 17. Оно не является библиотечной системой: здесь нет выдачи книг, учётных записей и сетевого API. На Mentoring.digital можно подобрать наставника для разбора такого проекта, а затем отдельно решить, нужны ли ему веб-интерфейс и сервер.
Одинаковые названия разрешены: различать записи будем по 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.
Файл содержит три поля: ID, статус и название в Base64. Между ними стоит табуляция. Кодирование названия позволяет не путать разделитель записи с символами внутри названия. Base64 не шифрует данные и не предназначен для хранения секретов.
При загрузке проверяются число полей, положительный уникальный ID и допустимый статус. Повреждённая строка останавливает открытие каталога с ошибкой. Программа не создаёт пустой каталог поверх непонятных данных: сначала нужно восстановить файл или отдельно выбрать новый путь.
При изменении сначала записывается временный файл рядом с основным. Затем выполняется замена. Если файловая система не поддерживает атомарное перемещение, используется обычная замена, которая не даёт той же гарантии. Пример не реализует резервные копии, блокировку нескольких процессов и защиту от всех сбоев питания.
| Проверка | Ожидаемый результат |
|---|---|
| Пустой новый каталог | Список пуст, ошибки нет |
| Название с пробелами по краям | Пробелы убраны, запись сохранена |
| Пустое или слишком длинное название | Ошибка, файл не изменился |
| Поиск JAVA при названии Java Core | Книга найдена |
| Отметка неизвестного ID | Ошибка, существующие книги не изменились |
| Перезапуск после изменения статуса | Порядок и статус сохранены |
| Кириллица в названии | После чтения файла возвращается то же название |
| Повреждённый файл | Открытие завершилось ошибкой, файл не перезаписан |
Проверять только вывод консоли недостаточно. Создайте временную папку для теста, вызовите методы Catalog, заново откройте тот же файл и сравните данные. Ошибочные операции проверяйте вместе с содержимым файла до и после вызова.
Для быстрого самостоятельного теста можно написать отдельный класс с main и проверками, выбрасывающими AssertionError. Когда сценариев станет больше, перенесите их в выбранный тестовый фреймворк. Само подключение фреймворка не заменяет содержательные ожидания.
Объясните, почему выбрали коллекцию с порядком вставки, как отличаете некорректный ввод от ошибки записи и что произойдёт при повреждении файла. Покажите, что данные в памяти меняются только после успешного сохранения. Отдельно назовите ограничения: один процесс, небольшой объём данных, линейный поиск и отсутствие полноценной транзакционной системы.
Полезное продолжение проекта выбирают из задачи. Нужны несколько пользователей и сложные выборки? Тогда можно обсудить базу данных. Нужен доступ из браузера? Тогда появляется смысл в HTTP API. Не добавляйте слои, пока не можете объяснить, какую проблему они решают.
Если коллекции и исключения пока не складываются в целое, помогут курсы Java с практикой на своём приложении. Наставник по Java может разобрать код каталога, границы ответственности классов и тесты перед переходом к более сложному стеку.