Лабораторна робота 2

Розширені можливості роботи з файлами

1 Завдання на лабораторну роботу

1.1 Індивідуальне завдання

Створити новий Maven-проєкт, в який перенести раніше створені класи, які представляють сутності індивідуальних завдань лабораторних робіт № 2 і № 3 курсу "Основи програмування Java". Стоврити похідні класи, в яких перевизначити реалізацію всіх методів, пов'язаних з обробкою послідовностей через використання засобів Stream API. Якщо в класі, який представляє другу сутність, немає обробки послідовностей, клас можна не перевизначати й користуватися базовим класом.

Програма повинна демонструвати:

  • відтворення реалізації завдань лабораторних робіт № 2 і № 3 (без використання інтерфейсу Set) курсу "Основи програмування Java";
  • використання засобів Stream API для обробки та виведення послідовностей;
  • виведення даних в текстовий файл засобами Stream API з подальшим читанням;
  • серіалізацію об'єктів у XML-файл і JSON-файл і відповідну десеріалізацію із застосуванням бібліотеки XStream.
  • запис подій, пов'язаних з виконанням програми, в системний журнал;
  • тестування окремих класів з використанням JUnit.

Примітка: локалізацію і переклад текстів можна проводити за бажанням студента.

1.2 Список файлів усіх підкаталогів

Увести з клавіатури ім'я певної теки. Вивести на екран імена усіх файлів цієї теки, а також усіх файлів підкаталогів, їхніх підкаталогів тощо. Реалізувати два підходи:

  • пошук за допомогою класу java.io.File через рекурсивну функцію;
  • пошук засобами пакету java.nio.file.

Обидва результати послідовно вивести на екран. Якщо тека не існує, вивести повідомлення про помилку.

1.3 Пошук усіх дільників

З використанням засобів Stream API організувати пошук усіх дільників цілого додатного числа. Створити окрему статичну функцію, яка приймає ціле число і повертає масив цілих чисел. Всередині функції створювати потік IntStream. Застосувати функцію range() і фільтр. Не використовувати явних циклів. Забезпечити тестування класу з використанням JUnit.

1.4 Робота з текстовими файлами засобами Stream API

Прочитати функцією Files.lines() рядки з текстового файлу, розсортувати за збільшенням довжини й вивести в інший файл рядки, які містять літеру "a".

1.5 Створення файлів і читання з файлів даних про студента і академічну групу

Описати класи Студент і Академічна група (з полем – масивом студентів). Створити об'єкти. Забезпечити створення файлів і читання з файлів, застосувавши такі підходи:

  • використання засобів Stream API для роботи з текстовими файлами;
  • серіалізація й десеріалізація в XML і JSON(засобами XStream).

1.6 Робота з бібліотекою org.json (додаткове завдання)

Виконати завдання 1.5 із застосуванням засобів для роботи з JSON-файлами бібліотеки org.json.

2 Методичні вказівки

2.1 Тестування в Java. Використання JUnit

2.1.1 Основні концепції тестування

Тестування є однією з найважливіших складових процесу розробки програмного забезпечення. Тестування програмного забезпечення проводиться з метою отримання інформації про якість програмного продукту. Існує багато підходів і технік, що дозволяють здійснити тестування і верифікацію ПЗ.

Парадигма розробки через тестування (розробки, керованої тестами, test-driven development) визначає техніку створення програмного забезпечення, побудовану на використанні тестів як для стимулювання написання коду, так і для його перевірки. Розробка коду зводиться до повторення циклу тест-код-тест з подальшим рефакторингом.

Рівень тестування, на якому тестується мінімально можливий для тестування компонент, наприклад, окремий клас або функція, має назву модульного тестування (unit testing). Відповідна технологія тестування передбачає, що тести розробляються заздалегідь, до написання реального коду, а розробка коду модуля (класу) завершується тоді, коли його код проходить усі тести.

2.1.2 Засоби Java для діагностики помилок під час виконання

Багато сучасних мов програмування, зокрема Java, включають синтаксичні механізми перевірки тверджень (assertions). Ключове слово assert з'явилося в Java починаючи з версії JDK 1.4 (Java 2). Роботу assert можна вмикати або вимикати. Якщо виконання діагностичних тверджень увімкнено, робота assert полягає у такому: виконується вираз типу boolean і якщо результат дорівнює true, робота програми продовжується далі, в протилежному випадку виникає виняток java.lang.AssertionError. Припустимо, відповідно до логіки програми змінна c повинна завжди бути додатною. Виконання такого фрагмента програми не призведе до будь-яких наслідків (винятків, аварійної зупинки програми тощо):

int a = 10;
int b = 1;
int c = a - b;
assert c > 0;

Якщо через хибну програмну реалізацію алгоритму змінна c все ж таки отримала від'ємне значення, виконання фрагмента програми призведе до генерації винятку й аварійної зупинки програми, якщо обробка цього винятку не була передбачена:

int a = 10;
int b = 11;
int c = a - b;
assert c > 0; // генерація винятку

Після твердження можна поставити двокрапку, після якої вказати деякий рядок повідомлення. Наприклад:

int a = 10;
int b = 11;
int c = a - b;
assert c > 0 : "c cannot be negative";

В цьому випадку відповідний рядок є рядком повідомлення винятку.

Робота діагностичних тверджень зазвичай вимкнена в інтегрованих середовищах. Для того, щоб увімкнути виконання assert в середовищі IntelliJ IDEA слід скористатися функцією меню Run | Edit Configurations. У вікні Run/Debug Configurations уводимо -ea в рядку введення VM Options.

У наведених прикладах значення, які перевіряються за допомогою assert, не вводяться з клавіатури, а визначаються у програмі для того, щоб продемонструвати коректне використання assert – пошук логічних помилок, а не перевірка коректності введення даних користувачем. Для перевірки коректності введених даних слід використовувати винятки, умовні твердження тощо. Використання перевірки тверджень не припустиме, оскільки на певному етапі програма буде завантажена без опції -ea і всі твердження assert будуть проігноровані. Саме через це також не слід у виразі, вказаному в твердженні, передбачати дії, важливі з точки зору функціональності програми. Наприклад, якщо перевірка твердження буде єдиним місцем у програмі, з якого здійснюється виклик дуже важливої функції,

public static void main(String[] args) {
    //...
    assert f() : "failed";
    //...
}

public static boolean f() {
    // Very important calculations
    return true;
}

то після відключення перевірки тверджень функція не буде викликатися взагалі.

2.1.3 Основи використання JUnit

На відміну від використання тверджень діагностики, яка здійснює тестування алгоритмів "зсередини", модульне тестування забезпечує перевірку певного модуля в цілому, тестування "ззовні" функціональності модуля.

Найбільш розповсюдженим засобом підтримки модульного тестування для програмного забезпечення мовою Java є JUnit – відкрита бібліотека модульного тестування. JUnit дозволяє:

  • створювати тести для окремих класів;
  • створювати набори тестів;
  • створювати серії тестів над наборами об'єктів, що повторюються.

Зараз актуальною є версія JUnit 5. Але також вельми розповсюдженою є версія JUnit 4.

Для створення тесту необхідно створити клас, який необхідно тестувати, а також створити відкритий клас для тестування з набором методів, що реалізують конкретні тести. Кожен тестовий метод повинен бути public, void, без параметрів. Метод повинен бути маркованим анотацією @Test:

public class MyTestCase { 
    ...
    @Test
    public void testXXX() { 
    ...
    } 
    ...
}

Примітка: для використання анотації @Test та інших подібних анотацій слід додати твердження імпорту import org.junit.jupiter.api.*; (для JUnit 5) або import org.junit.*; (для JUnit 4).

Усередині таких методів можна використовувати методи перевірки:

assertTrue(вираз);                // Якщо false - завершує тест невдачею
assertFalse(вираз);               // Якщо true - завершує тест невдачею
assertEquals(expected, actual);       // Якщо не еквівалентні - завершує тест невдачею
assertNotNull(new MyObject(params));  // Якщо null - завершує тест невдачею
assertNull(new MyObject(params));     // Якщо не null - завершує тест невдачею
assertNotSame(вираз1, вираз2);// Якщо обидва посилання посилаються на один об'єкт - завершує тест невдачею
assertSame(вираз1, вираз2);   // Якщо об'єкти різні - завершує тест невдачею  
fail(повідомлення)            // Негайно завершує тест невдачею з виведенням повідомлення.

Тут MyObject – клас, який тестується. Доступ до цих методів класу Assertion (Assert для JUnit 4) здійснюється за допомогою статичного імпорту import static org.junit.jupiter.api.Assertion.*; (для JUnit 5) або import static org.junit.Assert.*; (для JUnit 4). Ці методи існують також з додатковим параметром message типу String, який задає повідомлення, що буде відображатися в разі невдалого виконання тесту.

Середовище IntelliJ IDEA забезпечує вбудовану підтримку JUnit. Припустимо, створено новий проєкт. проєкт містить клас з двома функціями (статичною і нестатичною), які підлягають тестуванню:

package ua.inf.iwanoff.java.advanced.second;

public class MathFuncs {
    public static int sum(int a, int b) {
        return a + b;
    }

    public int mult(int a, int b) {
        return a * b;
    }
}

У проєкті можна вручну створити теку, наприклад, tests. Далі за допомогою контекстного меню встановити Mark Directory as | Test Sources Root.

Повертаючись до класу MathFuncs, обравши його в редакторі коду, через контекстне меню можна здійснити генерацію тестів: Generate... | Test.... У діалоговому вікні, яке відкрилось, вибираємо версію бібліотеки JUnit. Бажаний варіант – JUnit5. Можемо також скорегувати ім'я класу, яке нам пропонують: MathFuncsTest. У більшості випадків корекція цього імені не є доцільною. Обов'язково треба вибрати імена методів, які підлягають тестуванню. В нашому випадку це sum() і mult(). Отримаємо такий код:

package ua.inf.iwanoff.java.advanced.second;

import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {

    @org.junit.jupiter.api.Test
    void sum() {
    }

    @org.junit.jupiter.api.Test
    void mult() {
    }
}

У цьому коді IntelliJ IDEA вказує на наявність помилок (Cannot resolve symbol 'junit'). Натиснувши Alt+Enter, отримуємо підказку: Add 'JUnit 5.7.0' to classpath. Скориставшись цією підказкою, додаємо відповідну бібліотеку і отримуємо код без помилок.

Можна оптимізувати код, додавши імпорт. До коду методів класу MathFuncsTest додаємо перевірку методів класу MathFuncs. Для перевірки роботи mult() необхідно створити об'єкт:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {

    @Test
    void sum() {
        assertEquals(MathFuncs.sum(4, 5), 9);
    }

    @Test
    void mult() {
        assertEquals(new MathFuncs().mult(3, 4), 12);
    }
}

Запустити тести на виконання можна через меню Run. Нормальне завершення процесу свідчить про відсутність помилок під час перевірки. Якщо, скажімо, додати код, який спотворює обчислення у класі MathFuncs, наприклад:

    public int mult(int a, int b) {
        return a * b + 1;
    }

запуск тестів на виконання призведе до отримання повідомлення AssertionFailedError. При цьому вказується, скільки тестів пройдено успішно, а скільки не пройдено.

Якщо перед виконанням функції тестування необхідно зробити деякі дії, наприклад, форматувати значення змінних, то така ініціалізація виноситься в окремий статичний метод, якому передує анотація @BeforeAll(@BeforeClass у JUnit 4):

@BeforeAll
public static void setup(){
      ...
}

Аналогічно методам, в яких виконуються дії, необхідні після тестування, передує анотація @AfterAll (@AfterClass у JUnit 4). Відповідні методи повинні бути public static void.

В нашому прикладі можна створити об'єкт заздалегідь, а також додати повідомлення після завершення тестів:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {
    private static MathFuncs funcs;

    @BeforeAll
    public static void init() {
        funcs = new MathFuncs();
    }

    @Test
    void sum() {
        assertEquals(MathFuncs.sum(4, 5), 9);
    }

    @Test
    void mult() {
        assertEquals(funcs.mult(3, 4), 12);
    }

    @AfterAll
    public static void done() {
        System.out.println("Tests finished");
    }
}

Анотація @BeforeEach (@Before у JUnit 4) вказує, що метод викликається перед кожним тестовим методом. Відповідно @AfterEach (@After у JUnit 4) вказує, що метод викликається після кожного успішного тестового методу. Методи, помічені цими анотаціями, не повинні бути статичними.

Можна також тестувати методи, які повертають void. Виклик такого методу передбачає виконання якоїсь дії (наприклад, створення файлу, зміна значення поля тощо). Потрібно перевірити, чи мала така дія місце. Наприклад:

void setValue(into value) {
    this.value = value;
}

...

@Test
public void testSetValue() {
    someObject.setValue(123);
    assertEquals(123, someObject.getValue());
}

Однак, як правило, тестувати найпростіші методи доступу до властивостей і установки властивостей (сеттери і геттери) видається надмірним і не рекомендується

2.2 Використання засобів автоматизації складання проєктів

2.2.1 Системи автоматизації складання

Під автоматизацією складання (build automation, автоматизация сборки) розуміють використання спеціальних засобів для автоматичного відстеження взаємних залежностей між файлами в рамках проєкту. Автоматизація складання також передбачає виконання типових дій, таких як, наприклад,

  • компіляція сирцевого коду;
  • збирання програми з окремих частин;
  • підготовка супровідної документації;
  • створення jar-архіву;
  • розгортання програми.

Інтегровані середовища розробки (IDE) найчастіше беруть на себе управління складанням проєкту. Однак ці засоби, як правило, обмежені і не сумісні в різних IDE. Разом з тим, потреба в перенесенні проєкту в іншу IDE виникає досить часто. Крім того, зручно було б описати і зафіксувати послідовність деяких дій над артефактами проєкту під час виконання типового набору завдань процесу розробки.

Альтернативу пропонують незалежні засоби автоматизації збирання. Зараз серед найбільш популярних засобів автоматизації збирання можна назвати Apache Ant, Gradle і Apache Maven.

Apache Ant (ant – мураха) – заснований на Java сумісний із різними платформами набір засобів для автоматизації процесу складання програмного продукту (проєкт організації Apache Software Foundation). Управління процесом складання відбувається за допомогою XML-сценарію – так званого build-файлу (усталено називається build.xml), який відповідає певним правилам. Дії, які можна виконувати за допомогою Ant, описуються цілями (targets). Цілі можуть залежати одна від одної. Якщо до виконання певної мети повинна бути виконана інша мета, то можна визначити залежність однієї мети від іншої. Цілі містять виклики команд-завдань (завдань) Tasks. Кожне завдання є командою, що виконує деяку елементарну дію. Існує декілька наперед визначених завдань, які призначені для опису типових дій: компіляція з допомогою javac, запуск програми, створення jar, розгортання тощо. Існує можливість самостійного розширення множини завдань Ant. Завдання Ant включають роботу з файловою системою (створення каталогів, копіювання і видалення файлів), виклик компілятора, створення jar-архіву, виконання Java-застосунку, генерацію документації тощо.

На сьогодні засоби Ant стали менш популярні, у порівнянні з Gradle і Maven через їх обмеженість. Крім того, в порівнянні з Maven, Ant пропонує імперативний (командний) підхід до складання проєкту: розробник повинен описати послідовність дій, які виконуються під час збирання, а не очікуваний результат.

Засіб автоматизації збирання Gradle вперше створено в 2007 році під ліцензією Apache License 2.0. У вересні 2012 року вийшла стабільна реалізація 2.7. Gradle використовує концепції Apache Ant and Apache Maven, але замість XML використовує мову, побудовану на синтаксисі мови Groovy. Засоби Gradle використовуються переважно в Android-розробці.

2.2.2 Apache Maven

Apache Maven – це набір управління складанням проєктів, який використовує синтаксис XML для специфікації опцій складання, але у порівнянні з Ant забезпечує більш високий рівень автоматизації. Maven створюється і публікується Apache Software Foundation починаючи з 2004 р. Для визначення опцій складання використовують побудовану на XML мову POM (Project Object Model). На відміну від Apache Ant, Maven, забезпечує декларативне, а не імперативне складання проєкту в файлах проєкту pom.xml міститься його декларативний опис (що ми хочемо отримати), а не окремі команди.

Як і Ant, Maven дозволяє здійснити запуск процесів компіляції, створення jar-файлів, створення дистрибутиву програми, генерації документації тощо.

Найважливішою функцією Maven є управління залежностями, які присутні в проєктах, що використовують сторонні бібліотеки (які, своєю чергою, використовують інші сторонні бібліотеки). Також Maven дозволяє вирішувати конфлікти версій бібліотек.

Maven базується на Plugin-архітектурі, яка дозволяє застосовувати плагіни для різних завдань (compile, test, build, deploy, checkstyle, pmd, scp-transfer) без необхідності інсталювати їх в конкретний проєкт. Існує велика кількість готових плагінів.

Інформація для проєкту, що підтримується Maven, міститься у файлі pom.xml, в якому, зокрема, задають залежності проєкту, керованого Maven, від інших пакетів і бібліотек.

Для кожного Maven-проєкту визначають так звані координати артефакту (artifact coordinates), в які входить так звана "трійка" GAV (groupId, artifactId, version).

  • groupId – посилання на автора або організацію (підрозділ), де створено проєкт; відповідний ідентифікатор будують за правилами побудови імен пакетів – інвертоване доменне ім'я;
  • artifactId – назва проєкту; вона не обов'язково повинна збігатися з ім'ям проєкту IntelliJ IDEA, але використання однакових імен в цьому контексті є бажаним; під час створення проєкту це поле автоматично заповнюється ім'ям проєкту;
  • version – версія проєкту; усталено визначається 1.0-SNAPSHOT, тобто це перша версія проєкту, який знаходиться у стані розробки; для нового проєкту – це 1.0-SNAPSHOT; це означає що проєкт знаходиться у стадії розробки.

Середовище IntelliJ IDEA усталено містить у собі підтримку роботи з Maven-проєктами. Для створення нового проєкту з підтримкою Maven у вікні New Project у правій частині окрім визначення імені (Name), місця розташування проєкту в файловій системі (Location) і версії JDK (припустимо, це JDK 17), у рядку Build system слід вибрати кнопку Maven. Коректне створення Maven-проєкту передбачає визначення координат артефакту. Це можна зробити, розкривши додаткові налаштування (Advanced Settings), в яких необхідні дані вводяться в рядках GroupId (для проєктів автора в цьому курсі – ua.inf.iwanoff.java.advanced) і ArtifactId (наприклад, HelloMaven). Версію можна буде визначити пізніше в коді файлу.

Примітка: проєкт можна створити, базуючись на архетипі (archetype) – готовому шаблоні проєкту, але для першого проєкту Maven можна обійтись без архетипів.

Тепер можна натиснути Create та отримати новий порожній проєкт. Для нашого першого проєкту IntelliJ IDEA автоматично створює файл pom.xml з таким вмістом:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>ua.inf.iwanoff.java.advanced.second</groupId>
    <artifactId>HelloMaven</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

</project>

Як видно, версія артефакту автоматично визначається як 1.0-SNAPSHOT (робоча версія, пре-реліз).

Примітка: у секції properties можуть бути також розташовані додаткові опції, наприклад project.build.sourceEncoding.

Слід звернути увагу на структуру проєкту. Це типова структура проєктів Maven. На рівні проєкту створюється тека src з такою структурою:

src
    main
        java
        resources
    test
        java

Каталог src – це кореневий каталог сирцевого коду і коду тестових класів. Каталог main – це кореневий каталог для сирцевого коду, пов'язаного безпосередньо з програмою (без тестів). Каталог test містить сирцевий код тесту. Безпосередньо пакети сирцевого коду розташовують у підкаталогах java. Каталог resources містить інші ресурси, необхідні для проєкту. Це можуть бути файли властивостей (properties), які використовують для інтернаціоналізації програми, файли розмічення вікон графічного інтерфейсу користувача, стилів, або щось інше.

Після компіляції проєкту до структури тек на рівні проєкту буде додано каталог target зі скомпільованими класами.

Слід звернути увагу на інструментальне вікно Maven, ярлик якого зазвичай розташований праворуч. Якщо розкрити це вікно, можна побачити список команд Maven, які забезпечують життєвий цикл проєкту:

  • clean – очищення проєкту і видалення всіх файлів, які були створені попереднім складанням;
  • validate – перевірка коректності метаінформації про проєкт;
  • compile – компіляція проєкту;
  • test – тестування за допомогою JUnit;
  • package – створення архіву jar, war або ear;
  • verify – перевірка коректності пакета і відповідності вимогам якості;
  • install – інсталяція (копіювання) файлів .jar, war або ear в локальний репозиторій;
  • site – генерація сайту;
  • deploy – публікація файлів в віддалений репозиторій.

Примітка: якщо використовувати Maven поза IntelliJ IDEA, ці команди вводяться у командному рядку, наприклад: mvn clean; для використання Maven без IntelliJ IDEA, його слід завантажити і встановити окремо.

Деякі команди для свого успішного виконання вимагають виконання попередніх команд життєвого циклу. Наприклад, package передбачає виконання compile. Виконання попередніх команд здійснюється автоматично. Виконання команд передбачає виведення в консольному вікні характерних для Maven повідомлень.

Якщо під час створення проєкту було вибрано опцію Add sample code, у каталозі java було згенеровано пакет і клас з функцією main(), яка виводить "Hello world!":

package ua.inf.iwanoff.java.advanced.second;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}

Код класу можна змінити:

package ua.inf.iwanoff.java.advanced.second;

public class Main {
    public static int multiply(int i, int k) {
        return i * k;
    }

    public static void main(String[] args) {
        System.out.println("Hello, Maven!");
        System.out.println("2 * 2 = " + multiply(2, 2));
    }
}

Серед команд Maven немає безпосереднього виконання програми. Для того, щоб завантажити програму на виконання, слід скористатися засобами IntelliJ IDEA (через меню Run). Але при цьому автоматично виконуються необхідні команди Maven, які покривають певні фази життєвого циклу.

Примітка: набір стандартних команд Maven може бути розширений за допомогою механізму плагінів.

Дуже важлива функція Maven – управління залежностями. Зазвичай реальний проєкт окрім API, яке надає JDK, використовує численні бібліотеки, для підключення яких необхідно завантажувати JAR-файли. Ці бібліотеки базуються на використанні інших бібліотек, які теж треба завантажувати тощо. Окрема проблема виникає з версіями бібліотек та їх сумісністю.

Maven надає простий декларативний підхід до управління залежностями. Достатньо додати відомості про необхідну бібліотеку в розділі <dependencies>. Наприклад, для тестування коду нашого проєкту доцільно додати можливість використовувати JUnit 5. Можна, звичайно додати необхідну залежність вручну, але краще скористатися інтерактивними можливостями IntelliJ IDEA. Вибравши у вікні редактору файл pom.xml, слід натиснути Alt+Insert, далі у списку Generate вибрати Dependency Template і до тексту файлу буде додано секцію <dependencies>. Потім у списку артефактів необхідно вибрати junit-jupiter-api, вказати groupId рядок org.junit.jupiter і версію 5.8.1 . Отримаємо таку секцію <dependencies>:

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.8.1</version>
        </dependency>
    </dependencies>

Тепер можна додати тест. Можна використати функцію контекстного меню Generate... | Test... у вікні редактору сирцевого коду. Паралельну ієрархію пакетів, а також необхідний клас буде додано до гілки test проєкту.

Якщо у згенерованому коді деякі рядки помічені як помилка, необхідно перезавантажити проєкт Maven. В інструментальному вікні Maven знаходимо першу кнопку (Reload All Maven Projects). Помилки в pom.xml повинні зникнути.

2.3 Робота з файловою системою

2.3.1 Загальні концепції

Java надає можливість роботи не тільки з вмістом файлів, але також з файловою системою в цілому. Файлова система – це спосіб організації даних, який використовується операційною системою для зберігання інформації у вигляді файлів на носіях інформації. Також цим поняттям позначають сукупність файлів і каталогів (тек), які розміщуються на логічному або фізичному пристрої.

До типових функцій взаємодії з файловою системою можна віднести:

  • перевірку існування файлу або каталогу
  • отримання списку файлів і підкаталогів заданого каталогу
  • створення файлів і посилань на файли
  • копіювання файлів
  • перейменування і переміщення файлів
  • управління атрибутами файлів
  • видалення файлів
  • обхід дерева підкаталогів
  • відстеження змін файлів

Для роботи з файловою системою Java надає два підходи:

  • використання класу java.io.File;
  • використання засобів пакету java.nio.file.

2.3.2 Використання класу File

Пакет java.io надає можливість роботи як із вмістом файлів, так і з файловою системою в цілому. Цю можливість реалізує клас File. Для створення об'єкта цього класу як параметр конструктора слід визначити повний або відносний шлях до файлу. Наприклад:

File dir = new File("C:\\Users");
File currentDir = new File("."); // Тека проєкту (поточна)

Клас File містить методи для отримання списку файлів визначеної теки (list(), listFiles()), отримання та модифікації атрибутів файлів (setLastModified(), setReadOnly(), isHidden(), isDirectory() тощо), створення нового файлу (createNewFile(), createTempFile()), створення тек (mkdir()), видалення файлів та тек (delete()) та багато інших. Роботу деяких з цих методів можна продемонструвати на наведеному нижче прикладі:

package ua.inf.iwanoff.java.advanced.second;

import java.io.*;
import java.util.*;

public class FileTest {

    public static void main(String[] args) throws IOException {
        Scanner scanner = new Scanner(System.in);
        System.out.print("Уведіть ім\'я теки, яку ви хочете створити:");
        String dirName = scanner.next();
        File dir = new File(dirName);
     // Створюємо нову теку:
        if (!dir.mkdir()) {
            System.out.println("Не можна створити теку!");
            return;
        }
     // Створюємо новий файл всередині нової теки: 
        File file = new File(dir + "\\temp.txt");
        file.createNewFile();
     // Показуємо список файлів теки:
        System.out.println(Arrays.asList(dir.list()));
        file.delete(); // Видаляємо файл
        dir.delete();  // Видаляємо теку
    }

}

Функція list() без параметрів дозволяє отримати масив рядків – усіх файлів та підкаталогів теки, визначеної під час створення об'єкта типу File. Виводяться відносні імена файлів (без шляхів). У наведеному нижче прикладі ми отримуємо список файлів та підкаталогів теки, ім'я якої вводиться з клавіатури:

package ua.inf.iwanoff.java.advanced.second;

import java.io.File;
import java.io.FilenameFilter;
import java.util.Scanner;

public class ListOfFiles {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.print("Уведіть ім\'я теки:");
        String dirName = scanner.next();
        File dir = new File(dirName);
        if (!dir.isDirectory()) {
            System.out.println("Хибне ім\'я теки!");
            return;
        }
        String[] list = dir.list();
        for(String name : list) {
            System.out.println(name);
        }
    }

}

На відміну від list(), функція listFiles() повертає масив об'єктів типу File. Це надає додаткові можливості – отримання імен файлів з повним шляхом, перевірки значень атрибутів файлів, окрему роботу з теками тощо. Ці додаткові можливості продемонструємо на такому прикладі:

    File[] list = dir.listFiles();
 // Виводяться дані про файли в усталеній формі:
    for(File file : list) {
        System.out.println(file);
    }
 // Виводиться повний шлях:
    for(File file : list) {
        System.out.println(file.getCanonicalPath());
    }
 // Виводяться тільки підкаталоги:
    for(File file : list) {
        if (file.isDirectory())
            System.out.println(file.getCanonicalPath());
    }

Для визначення маски-фільтру необхідно створювати об'єкт класу, який реалізує інтерфейс FilenameFilter. У наведеному нижче прикладі ми отримуємо список файлів та підкаталогів, імена яких починаються з літери 's':

    String[] list = dir.list(new FilenameFilter() {
        @Override
        public boolean accept(File dir, String name) {
            return name.toLowerCase().charAt(0) == 's';
        }
    });
    for(String name : list) {
        System.out.println(name);
    }

Аналогічний параметр типу FilenameFilter можна застосувати до функції listFiles()

2.3.3 Робота з пакетом java.nio

Пакет java.nio, який з'явився в JDK 1.4, спочатку включав альтернативні засоби введення-виведення.

Версія Java 7 надає альтернативний підхід до роботи з файловою системою – набір класів, описаних в пакеті java.nio.file. Пакет java.nio.file надає клас Path, який забезпечує надання шляху в файловій системі. Окремі складові цього шляху можна уявити деякою колекцією імен проміжних підкаталогів і імені самого файлу (підкаталогу). Отримати об'єкт класу Path можна за допомогою методу get() класу Path. Методу get() передається рядок – шлях:

Path path = Paths.get("c:/Users/Public");

Тепер можна отримати інформацію про шлях:

System.out.println(path.toString());      // c:\Users\Public
System.out.println(path.getFileName());   // Public
System.out.println(path.getName(0));      // Users
System.out.println(path.getNameCount());  // 2
System.out.println(path.subpath(0, 2));   // Users\Public
System.out.println(path.getParent());     // c:\Users
System.out.println(path.getRoot());       // c:\

Після того, як об'єкт класу Path створений, його можна використовувати як аргумент статичних функцій класу java.nio.file.Files. Для перевірки наявності (відсутності) файлу використовують відповідно функції exists() і notExists():

Path dir = Paths.get("c:/Windows");
System.out.println(Files.exists(dir));    // швидше за все, true
System.out.println(Files.notExists(dir)); // швидше за все, false

Наявність двох окремих функцій пов'язано з можливістю отримання невизначеного результату (заборонений доступ до файлу).

Для того, щоб переконатися, що програма може отримати необхідний доступ до файлу, можна використовувати методи isReadable(Path), isWritable(Path) і isExecutable(Path). Припустимо, створений об'єкт file типу Path і заданий шлях до файлу. Наведений нижче фрагмент коду перевіряє, чи існує конкретний файл, і чи можна завантажити його на виконання:

boolean isRegularExecutableFile = Files.isRegularFile(file) & 
                                  Files.isReadable(file) & Files.isExecutable(file);

Для отримання метаданих (даних про файлах і каталогах) клас Files надає низку статичних методів:

Методи Пояснення
size(Path) Повертає розмір зазначеного файлу в байтах
isDirectory(Path, LinkOption...)

Повертає true, якщо вказаний Path вказує на каталог

isRegularFile(Path, LinkOption...) Повертає true, якщо вказаний Path вказує на звичайний файл
isHidden(Path) Повертає true, якщо вказаний Path вказує на прихований файл
getLastModifiedTime(Path, LinkOption...) setLastModifiedTime(Path, FileTime) Повертає / встановлює час останньої зміни зазначеного файлу
getOwner(Path, LinkOption...) setOwner(Path, UserPrincipal) Повертає / встановлює власника файлу
getAttribute(Path, String, LinkOption...)
setAttribute(Path, String, Object, LinkOption...)
Повертає або встановлює значення атрибута файлу

Для ОС Windows різних версій рядок атрибута повинен починатися з префікса "dos:". Наприклад, так можна встановити необхідні атрибути деякого файлу:

Path file = ...
Files.setAttribute(file, "dos:archive", false);
Files.setAttribute(file, "dos:hidden", true);
Files.setAttribute(file, "dos:readonly", true);
Files.setAttribute(file, "dos:system", true);

Читання необхідних атрибутів може також здійснюватися за допомогою методу readAttributes(). Його другий параметр – метадані тип результату. Ці метадані можуть бути отримані через значення поля class (метадані типів будуть розглянуті пізніше). Найбільш відповідний тип результату – це клас java.nio.file.attribute.BasicFileAttributes. Наприклад, так можна отримати деякі дані про файл:

package ua.inf.iwanoff.java.advanced.second;

import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Scanner;

public class Attributes {

    public static void main(String[] args) throws Exception {
        System.out.println("Введіть ім\'я файлу або каталогу:");
        Path path = Paths.get(new Scanner(System.in).nextLine());
        BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class);
        System.out.println("Час створення: " + attr.creationTime());
        System.out.println("Час останнього доступу: " + attr.lastAccessTime());
        System.out.println("Час останньої зміни: " + attr.lastModifiedTime());
        System.out.println("Каталог: " + attr.isDirectory());
        System.out.println("Звичайний файл: " + attr.isRegularFile());
        System.out.println("Розмір: " + attr.size());
    }

}

Клас DosFileAttributes, похідний від BasicFileAttributes, надає також функції isReadOnly(), isHidden(), isArchive() і isSystem().

На відміну від java.io, клас java.nio.file.Files надає функцію copy() для копіювання файлів. Наприклад:

Files.copy(Paths.get("c:/autoexec.bat"), Paths.get("c:/Users/autoexec.bat"));
Files.copy(Paths.get("c:/autoexec.bat"), 
           Paths.get("c:/Users/autoexec.bat"), StandardCopyOption.REPLACE_EXISTING);

Існують також опції StandardCopyOption.ATOMIC_MOVE і StandardCopyOption.COPY_ATTRIBUTES. Опції можна вказувати через кому.

Для переміщення файлів використовують функцію move() (з аналогічними атрибутами або без них). Перейменування виконується тією ж функцією:

Files.move(Paths.get("c:/Users/autoexec.bat"), Paths.get("d:/autoexec.bat"));// переміщення
Files.move(Paths.get("d:/autoexec.bat"), Paths.get("d:/unnecessary.bat"));// перейменування

Створення нових каталогів здійснюється за допомогою функції createDirectory() класу Files. Параметр функції має тип Path.

Path dir = Paths.get("c:/NewDir");
Files.createDirectory(dir);

Для створення каталогу кількох рівнів в глибину, коли один або кілька батьківських каталогів, можливо, ще не існує, можна використовувати метод createDirectories():

Path dir = Paths.get("c:/NewDir/1/2");
Files.createDirectories(dir);

Для отримання списку файлів підкаталогу можна скористатися класом DirectoryStream.

package ua.inf.iwanoff.java.advanced.second;

import java.io.IOException;
import java.nio.file.*;

public class FileListDemo {

    public static void main(String[] args) {
        Path dir = Paths.get("c:/Windows");
        try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
            for (Path p : ds) {
                System.out.println(p.getFileName());
           }
        } 
        catch (IOException e) {
            e.printStackTrace();
        }
    }

}

Видалення файлів і тек здійснюється за допомогою функцій delete() і deleteIfExists():

Files.delete(Paths.get("d:/unnecessary.bat"));
Files.deleteIfExists(Paths.get("d:/unnecessary.bat"));

Для обходу дерева каталогів пакет java.nio.file надає засоби, які потребують реалізації рекурсивних алгоритмів. Існує метод walkFileTree() класу Files, що забезпечує обхід дерева підкаталогів. Як параметри необхідно вказати початковий каталог (об'єкт типу Path), а також об'єкт, який реалізує узагальнений інтерфейс FileVisitor.

Примітка: існує інший варіант методу, що дозволяє задавати також опції обходу каталогів і обмеження на глибину обходу підкаталогів.

Для реалізації інтерфейсу FileVisitor треба визначити методи preVisitDirectory(), postVisitDirectory(), visitFile() і visitFileFailed(). Результат цих функцій – перелік типу FileVisitResult. Можливі значення цього перерахування – CONTINUE (продовжувати пошук), TERMINATE (припинити пошук), SKIP_SUBTREE (пропустити піддерево) і SKIP_SIBLINGS (пропустити елементи того ж рівня).

Щоб кожного разу не реалізовувати всі методи інтерфейсу FileVisitor, можна скористатися узагальненим класом SimpleFileVisitor. Цей клас надає усталену реалізацію функцій інтерфейсу. В цьому випадку необхідно тільки перекрити потрібні функції. У наведеному нижче прикладі здійснюється пошук всіх файлів заданого каталогу і його підкаталогів:

package ua.inf.iwanoff.java.advanced.second;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Scanner;

public class FindAllFiles {

    private static class Finder extends SimpleFileVisitor<Path> {

        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                                                            throws IOException {
            System.out.println(file);
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
                                                            throws IOException {
            System.out.println("----------------" + dir + "----------------");
            return FileVisitResult.CONTINUE;
        }

    }

    public static void main(String[] args) {
        String dirName = new Scanner(System.in).nextLine();
        try {
            Files.walkFileTree(Paths.get(dirName), new Finder()); // Поточний каталог
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }

}

Для пошуку файлів можна користуватися масками (так звані "glob"-маски), які активно застосовують в усіх операційних системах. Приклади таких масок – "a*.*" (Імена файлів починаються з літери a), "*.txt" (файли з розширенням *.txt) тощо. Припустимо, рядок pattern містить таку маску. Далі створюємо об'єкт PathMatcher:

PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);

У наведеному нижче прикладі у визначеному каталозі здійснюється пошук файлів за вказаною маскою:

package ua.inf.iwanoff.java.advanced.second;

import java.io.IOException;
import java.nio.file.*;
import java.util.Scanner;

public class FindMatched {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String dirName = scanner.nextLine();
        String pattern = scanner.nextLine();
        Path dir = Paths.get(dirName);
        PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
        try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
            for (Path file : ds) {
                if (matcher.matches(file.getFileName())) {
                    System.out.println(file.getFileName());
                }
            }
        } 
        catch (IOException e) {
            e.printStackTrace();
        }
    }

}

Маски можуть поєднуватися з обходом дерева каталогів.

Одне із завдань файлової системи – відстеження стану зазначеного каталогу. Наприклад, програма повинна оновлювати дані про файли і підкаталогах деякого каталогу, якщо інші процеси або потоки управління зумовили виникнення, зміну, видалення файлів і тек тощо. Пакет java.nio.file надає засоби для реєстрації таких каталогів і відстеження їх стану. Для відстеження змін можна реалізувати інтерфейс WatchService. Відповідну реалізацію можна отримати за допомогою функції FileSystems.getDefault().newWatchService()). Клас StandardWatchEventKinds надає необхідні константи для можливих подій.

Спочатку необхідно зареєструвати необхідний каталог, а потім в нескінченному циклі читати інформацію про події пов'язані з його змінами. Інтерфейс WatchEvent надає опис можливої події. Наприклад, можна запропонувати таку програму:

package ua.inf.iwanoff.java.advanced.second;

import java.nio.file.*;
import java.util.Scanner;
import static java.nio.file.StandardWatchEventKinds.*;

public class WatchDir {

    public static void main(String[] args) throws Exception {
        System.out.println("Введіть ім\'я каталогу:");
        Path dir = Paths.get(new Scanner(System.in).nextLine());
    // Створюємо об'єкт WatchService:
        WatchService watcher = FileSystems.getDefault().newWatchService();
    // Реєструємо події, які ми відслідковуємо:
        WatchKey key = dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
        while (true) { // нескінчений цикл
            key = watcher.take(); // чекаємо на наступний набір подій
            for (WatchEvent<?> event: key.pollEvents()) {
                WatchEvent<Path> ev = (WatchEvent<Path>)event;
                System.out.printf("%s: %s\n", ev.kind().name(), dir.resolve(ev.context()));
            }
            key.reset(); // оновлюємо стан набору подій
        }
    }

}

Бібліотека java.nio.file підтримує роботу як з символьними посиланнями (symlinks, soft links), так і з жорсткими посиланнями (hard links). Метод createSymbolicLink(нове_посилання, об'єкт_що_існує) класу Files створює символьне посилання, метод createLink(нове_посилання, файл_що_існує) створює жорстке посилання. Метод isSymbolicLink() повертає true, якщо переданий йому об'єкт – символьне посилання. Метод readSymbolicLink()) дозволяє знайти об'єкт, на який посилається символьне посилання.

2.4 Використання засобів java.nio для читання і запису даних

2.4.1 Загальні концепції

У порівнянні з традиційними потоками введення-виведення, java.nio забезпечує більш високу ефективність операцій введення-виведення. Це досягається за рахунок того, що традиційні засоби введення-виведення працюють з даними в потоках, в той час як java.nio працює з даними в блоках. Центральними об'єктами в java.nio є "Канал" (Channel) і "Буфер" (Buffer).

  • Канали використовують для забезпечення передачі даних, наприклад, між файлами та буферами. Окрім роботи з файлами, канали також застосовують для роботи з дейтаграмами й сокетами.
  • Буфер – це контейнерний об'єкт. Всі дані, які передаються в канал, повинні бути спочатку поміщені в буфер.

Будь-які дані, які зчитуються з каналу, потрапляють в буфер. Додаткова ефективність забезпечується через інтеграцію об'єктів-буферів з буферами операційної системи.

Засоби java.nio ефективні при роботі з двійковими файлами, в першу чергу, в умовах багатопотоковості, де використовують спеціальні об'єкти – селектори.

2.4.2 Використання класу Files для роботи з текстовими файлами

Окрім складних механізмів роботи з каналами, буферами і селекторами, пакет java.nio.file надає прості засоби читання з текстових файлів і запису в текстові файли.

Для читання даних можна використати такі статичні функції:

  • readString() читає поточний рядок зі вказаного файлу (змінна типу Path);
  • readAllLines() читає поточний рядок зі вказаного файлу.

Так, наприклад можна прочитати перший рядок з файлу:

Path path = Paths.get("SomeFile.txt");
String s = Files.readString(path);

А так можна прочитати всі рядки текстового файлу:

Path path = Paths.get("SomeFile.txt");
List<String> lines = Files.readAllLines(path);
for (String s: lines) {
    System.out.println(s);
}

Наведені нижче функції використовують для запису:

  • writeString() записує рядок в поточну позицію файлу
  • write() – більш універсальна функція, яка записує масив байтів.

Приклад використання функції writeString():

Path path = Paths.get("newFile.txt");
String question = "Бути чи не бути?";
Files.writeString(path, question);

Усталено використовується кодування проєкту. Для того, щоб, наприклад явно вказати кодову таблицю UTF-8, слід скористатися більш складною формою функції. Можна визначати додаткові опції:

Files.writeString(path, question, StandardCharsets.UTF_8, StandardOpenOption.CREATE);

Приклад використання функції write():

Path path = Paths.get("newFile.txt");
String question = "Бути чи не бути?";
Files.write(path, question.getBytes());

Є також статичні функції для взаємодії з потоками java.io. Наприклад, можна отримати потік BufferedReader:

BufferedReader bufferedReader = Files.newBufferedReader(Paths.get("SomeFile.txt"));
System.out.println(bufferedReader.readLine());

Можна також скористатися класом Scanner:

Scanner scanner = new Scanner(Files.newBufferedReader(Paths.get("SomeFile.txt")));
System.out.println(scanner.nextLine());

Аналогічно можна здійснювати виведення за допомогою потоків java.io. Наприклад:

PrintWriter printWriter = new PrintWriter(Files.newBufferedWriter(Paths.get("newFile.txt")));
printWriter.println("Done!");

Слід не забувати закривати потоки. Для коректного закриття файлу дії з потоками доцільно виконувати з використанням конструкції try-with-resources.

2.5 Використання Stream API

2.5.1 Загальні концепції

Потоки для роботи з колекціями, або потоки елементів, потоки даних (Stream API) призначені для високорівневої обробки даних, що зберігаються в контейнерах. Їх не слід плутати з потоками введення-виведення (input / output streams) і потоками управління (threads).

Засоби Stream API були додані до стандарту починаючи з Java 8.

Потоковий API використовують для пошуку, фільтрації, перетворення, знаходження мінімальних і максимальних значень, а також іншого маніпулювання даними. Важливою перевагою Stream API є можливість надійної і ефективної роботи в багатопотоковому (multithreading) оточенні.

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

Можна створювати послідовні (sequential) і паралельні (parallel) потоки елементів. Паралельні потоки безпечні з точки зору багатопотоковості. З наявного паралельного потоку можна отримати послідовний і навпаки.

Для роботи з потоками реалізований пакет java.util.stream, що надає набір інтерфейсів і класів, що забезпечують операції над потоками елементів в стилі функціонального програмування. Потік представлений об'єктом, що реалізує інтерфейс java.util.stream.Stream. В свою чергу, цей інтерфейс успадковує методи узагальненого інтерфейсу java.util.stream.BaseStream.

Операції над потоками (методи), визначені в інтерфейсах BaseStream, Stream, та інших, похідних від BaseStream, діляться на проміжні та кінцеві. Проміжні операції отримують і генерують потоки даних і служать для створення так званих конвеєрів (pipeline), в яких над послідовністю виконується ряд дій. Кінцеві операції дають остаточний результат і при цьому "споживають" вихідний потік. Це означає, що вихідний потік не може бути використаний повторно і в разі необхідності повинен бути створений заново.

2.5.2 Основні методи для роботи з потоками

Найбільш істотні методи узагальненого інтерфейсу java.util.stream.BaseStream наведені в таблиці (S – тип потоку, E – тип елемента, R – тип контейнера):

Метод Опис Примітка
S parallel() повертає паралельний потік даних, отриманий з поточного проміжна операція
S sequential() повертає послідовний потік даних, отриманий з поточного проміжна операція
boolean isParallel() повертає true, якщо викликає потік даних є паралельним, або false, якщо він є послідовним проміжна операція
S unordered() повертає невпорядкований потік даних, отриманий з поточного проміжна операція
Iterator<T> iterator() повертає посилання на ітератор потоку даних кінцева операція
Spliterator<T> spliterator() повертає посилання на ітератор-роздільник потоку даних кінцева операція

Використання ітераторів потоків буде розглянуто нижче.

Інтерфейс Stream розширює набір методів для роботи з потоками елементів. Це також узагальнений інтерфейс і він придатний для роботи з будь-якими посилальними типами. Нижче наведені найбільш вживані методи інтерфейсу Stream:

Метод Опис Примітка
void forEach(Consumer<? super T> action) виконує код, заданий дією (action) для кожного елемента потоку кінцева операція
Stream<T> filter(Predicate<? super T> pred) повертає потік елементів, що задовольняють предикату проміжна операція
Stream<T> sorted() повертає потік елементів, розсортованих в природному порядку проміжна операція
Stream<T> sorted(Comparator<? super T> comparator) повертає потік елементів, розсортованих в зазначеному порядку проміжна операція

<R> Stream<R> map(Function<? super T,
? extends R> mapFunc)

застосовує задану функцію до елементів потоку і повертає новий потік проміжна операція
Optional<T> min(Comparator<? super T> comp) повертає мінімальне значення з використанням заданого порівняння кінцева операція
Optional<T> max(Comparator<? super T> comp) повертає максимальне значення з використанням заданого порівняння кінцева операція
long count() повертає кількість елементів в потоці кінцева операція
Stream<T> distinct() повертає потік різних елементів проміжна операція
Optional<T> reduce(BinaryOperator<T> accumulator) повертає скалярний результат, обчислений за значеннями елементів кінцева операція
Object[] toArray() створює і повертає масив елементів потоку кінцева операція

2.5.3 Створення потоків

Існує кілька способів створення потоку. Можна скористатися "фабричними" методами, що були додані до інтерфейсу Collection (з усталеними реалізаціями) – відповідно stream() (для послідовної роботи) и parallelStream() (для багатопотокової роботи). Так можна створити потік для послідовної роботи:

List<Integer> intList = List.of(3, 4, 1, 2);
Stream<Integer> fromList = intList.stream();

Можна створити потік з масиву:

Integer[] a = { 1, 2, 3 };
Stream<Integer> fromArray = Arrays.stream(a);

Можна створити джерело даних із зазначеними елементами. Для цього слід скористатися "фабричним" методом of():

Stream<Integer> newStream = Stream.of(4, 5, 6);

Потоки елементів можна створити з потоків введення (BufferedReader.lines()), заповнити випадковими значеннями (Random.ints()), а також отримати з архівів, бітових наборів тощо.

З потоку можна отримати масив за допомогою методу toArray(). У наведеному нижче прикладі створюється потік, а потім виводиться на консоль через створення масиву і приведення до подання рядком за допомогою статичного методу Arrays.toString():

Stream<Integer> s = Stream.of(1, -2, 3);
Object[] a = s.toArray();
System.out.println(Arrays.toString(a)); // [1, -2, 3]

2.5.4 Ітерація за елементами

Потоки забезпечують ітерацію за елементами даних за допомогою методу forEach(). Параметр функції - стандартний функціональний інтерфейс Consumer, що визначає метод з одним параметром і типом результату void. Наприклад:

fromList.forEach(System.out::println);
fromArray.forEach(System.out::println);
newStream.forEach(System.out::println);

Проміжні операції характеризуються так званою відкладеною поведінкою (lazy behaviour): вони виконуються не миттєво, а за потреби – коли кінцева операція працює з новим потоком даних. Відкладена поведінка підвищує ефективність роботи з потоками елементів.

Потоки елементів надають ітератори. Метод iterator() інтерфейсу Stream повертає об'єкт, який реалізує інтерфейс java.util.Iterator. Ітератор можна використовувати в явному вигляді:

s = Stream.of(11, -2, 3);
Iterator<Integer> it = s.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

Існує також спеціальний вид ітератора – ітератор-роздільник (реалізований інтерфейсом Spliterator). Він, зокрема, дозволяє розділити потік на декілька, з якими можна працювати паралельно. Метод forEachRemaining() забезпечує ітерацію для Spliterator. Наприклад:

List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7, 8);
Spliterator<Integer> spliterator1 = list.spliterator();
Spliterator<Integer> spliterator2 = spliterator1.trySplit();
spliterator1.forEachRemaining(System.out::println);
System.out.println("========");
spliterator2.forEachRemaining(System.out::println);

Результат роботи цього фрагменту програми буде таким:

5
6
7
8
========
1
2
3
4

Тепер з двома частинами списку можна працювати окремо.

2.5.5 Операції з потоками

Найбільш проста операція з потоками – фільтрація. Проміжна операція filter() повертає фільтрований потік, приймаючи параметр типу Predicate. Тип Predicate – це функціональний інтерфейс, що описує метод з одним параметром і типом результату boolean. Наприклад, можна відфільтрувати з потоку s тільки парні числа:

s.filter(k -> k % 2 == 0).forEach(System.out::println);

Попередній приклад ілюструє використання лямбда-виразів під час роботи з потоками, а також невеличкий конвеєр, що включає одну проміжну операцію.

Проміжна операція sorted() повертає відсортоване представлення потоку. Елементи упорядковано в природному порядку (якщо він визначений). В інших випадках слід реалізовувати інтерфейс Comparator, наприклад, за допомогою лямбда-виразу:

// Сортуємо за зростанням:
Stream<Integer> s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted().forEach(System.out::println);
// Сортуємо в порядку зменшення:
s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted((k1, k2) -> Integer.compare(k2, k1)).forEach(System.out::println);

Останній приклад показує, що після кожного виклику кінцевої операції потік потрібно створювати знову.

Більшість операцій реалізовані так, що дії над окремими елементами не залежать від інших елементів. Такі операції називаються операціями без збереження стану. Інші операції, що вимагають роботи одразу над всіма елементами (наприклад, sorted()), називаються операціями зі збереженням стану.

Проміжна операція map() отримує функціональний інтерфейс, що задає деяку функцію перетворення і формування нового потоку з отриманих перетворених елементів. Наприклад, обчислюємо квадрати чисел:

s = Stream.of(1, 2, 3);
s.map(x -> x * x).forEach(System.out::println);

За допомогою методу distinct() з колекції можна отримати потік, що містить тільки різні елементи. Наприклад:

s = Stream.of(1, 1, -2, 3, 3);
System.out.println(Arrays.toString(s.distinct().toArray())); // [1, -2, 3]

Кінцева операція count() з типом результату long повертає кількість елементів в потоці:

s = Stream.of(4, 5, 6, 1, 2, 3);
System.out.println(s.count()); // 6

Кінцеві операції min() і max() повертають об'єкти Optional з відповідно мінімальним і максимальним значенням. Для порівняння використовується параметр типу Comparator. Наприклад:

s = Stream.of(11, -2, 3);
System.out.println(s.min(Integer::compare).get()); // -2

За допомогою кінцевої операції reduce() можна обчислити деяке скалярне значення. Операція reduce() у своїй простій формі здійснює вказану дію з двома операндами, перший з яких – результат виконання дії над попередніми елементами, а другий – поточний елемент. У наведеному нижче прикладі ми знаходимо суму елементів потоку даних:

s = Stream.of(1, 1, -2, 3, 3);
Optional<Integer> sum = s.reduce((s1, s2) -> s1 + s2);
sum.ifPresent(System.out::println); // 6

Операції min(), max() і reduce() отримують скалярне значення з потоку, тому вони мають назву операцій зведення.

2.5.6 Використання потоків для роботи з примітивними типами

Існують також потоки для роботи з примітивними типами – IntStream, LongStream і DoubleStream. Розглянемо роботу з IntStream і DoubleStream.

Найпростіший спосіб створення потоків – використання статичної функції of():

IntStream intStream = IntStream.of(1, 2, 4, 8);
DoubleStream doubleStream = DoubleStream.of(1, 1.5, 2);

Можна створити потоки з відповідних масивів:

int[] intArr = { 10, 11, 12 };
double[] doubleArr = { 10, 10.5, 11, 11.5, 12 };
intStream = Arrays.stream(intArr);
doubleStream = Arrays.stream(doubleArr);

За допомогою метода range() класу IntStream можна створити потоки заповнивши їх послідовними значеннями. Можна також одночасно визначити фільр:

intStream = IntStream.range(0, 10).filter(n -> n % 2 == 0); // 0 2 4 6 8

За допомогою метода iterate() можна створити нескінчений потік. Наступний елемент обчислюється з попереднього. Обмежити потік можна за допомогою функції limit(). Так, наприклад, можна отримати послідовні степені числа 3:

intStream = IntStream.iterate(1, i -> i * 3).limit(6); // 1 3 9 27 81 243

Метод generate() також дозволяє згенерувати елементи, але без урахування попередніх. Наприклад, можна заповнити масив випадковими числами:

doubleStream = DoubleStream.generate(() -> (Math.random() * 10000)).limit(20);

Подальша робота аналогічна роботі з потоками Stream. Наприклад, можна здійснити сортування і вивести тільки непарні значення:

intStream = IntStream.of(11, 2, 43, 81, 8, 0, 5, 3); 
intStream.sorted().filter(n -> n % 2 != 0).forEach(System.out::println);

Отримані потоки можна застосувати для створення нових масивів:

int[] newIntArr = intStream.toArray();
double[] newDoubleArr = doubleStream.toArray();

Примітка: передбачається, що потоки intStream і doubleStream не були використані в кінцевих операціях.

2.5.7 Використання потоків для запису в текстові файли й читання з текстових файлів

Потоки Stream API інтегровані з роботою з текстовими файлами і засобами java.nio.file.

Можливості читання з текстових файлів продемонструємо, читаючи з файлу source.txt. Припустимо, такий файл розташовано в теці проєкту і він має такий вміст:

First
Second
Third

Статичний метод lines() класу Files використовують для читання з текстового файлу рядків і створення потоку. В наведеному нижче прикладі всі рядки файлу source.txt читаються й виводяться на консоль. Створення потоку доцільно розташувати в блоку try-with-resources:

try (Stream<String> strings = Files.lines(Path.of("source.txt"))) {
    strings.forEach(System.out::println);
}
catch (IOException e) {
    throw new RuntimeException(e);
}

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

Такі ж результати можна отримати, скориставшись класом java.io.BufferedReader:

try (BufferedReader bufferedReader = Files.newBufferedReader(Paths.get("source.txt"))) {
    Stream<String> strings = bufferedReader.lines();
    strings.forEach(System.out::println);
}
catch (IOException e) {
    throw new RuntimeException(e);
}

Можна також створити список:

List<String> list = Files.readAllLines(Path.of("source.txt"));
Stream<String> lines = list.stream();

Для запису в файл можна скористатися функцією Files.write():

Stream<String> digits = Stream.of("1", "3", "2", "4");
Files.write(Path.of("digits.txt"), digits.toList());

Приклад 3.2 демонструє роботу з файлами у поєднанні зі Stream API.

2.6 Робота з файлами формату JSON

2.6.1 Формат файлів JSON та його особливості

JSON - це легковагий формат обміну даних, який переважно використовують для обміну даними між комп'ютерами на різних рівнях взаємодії. Назва JSON - скорочення від JavaScript Object Notation (нотація запису об'єктів JavaScript). Попри те, що синтаксис JSON - це синтаксис для об'єктів JavaScript, файли JSON можна використовувати окремо від JavaScript. Зараз робота з цим форматом підтримується багатьма мовами програмування.

JSON можна розглядати як легку і сучасну альтернативу XML. Є багато спільного в XML-документів та файлів JSON:

  • файли завжди текстові;
  • формат не вимагає додаткових пояснень для людини;
  • дані передбачають можливість ієрархічного представлення.

Але на відміну від XML-документів, файли JSON більш короткі, більш легкі для читання, а також пропонують деякі додаткові можливості.

Припустимо, раніше було створено такий XML-документ:

<students>
  <student>
    <firstName>Frodo</firstName>
    <lastName>Baggins</lastName>
  </student>
  <student>
    <firstName>Samwise</firstName>
    <lastName>Gamgee</lastName>
  </student>
</students>

Відповідний файл JSON буде таким (students.json):

{"students":[
  { "firstName":"Frodo", "lastName":"Baggins" },
  { "firstName":"Samwise", "lastName":"Gamgee" }
]}

У наведеному файлі присутні основні елементи синтаксису JSON:

  • масив "students", елементи якого розташовані в квадратних дужках;
  • об'єкти, розташовані в фігурних дужках;
  • рядки.

Окрім рядків, значеннями можуть бути числа, булеві значення (false і true) і null.

2.6.2 Використання бібліотеки org.json для роботи з JSON-файлами

Бібліотека org.json існує з кінця 2010 року і спочатку була реалізована Дугласом Крокфордом, автором JSON. Тому можна розглядати цю бібліотеку як еталонну реалізацію для JSON в Java.

Найпростіший шлях підключення бібліотеки org.json до проєкту – додати до файлу pom.xml необхідну залежність:

<dependencies>
    <dependency>
        <groupId>org.json</groupId>
        <artifactId>json</artifactId>
        <version>20230227</version>
    </dependency>
</dependencies>

Примітка: актуальна версія бібліотеки може змінюватися.

Відповідно до типів значень в JSON-файлі в бібліотеці визначені типи JSONObject і JSONArray. Є різні способи створення об'єкту JSONObject. Можна створити його вручну, наприклад:

JSONObject someObject = new JSONObject()
        .put("number", 10)
        .put("object", new JSONObject()
                .put("greetings", "Hello"))
        .put("array", new JSONArray()
                .put(12.95)
                .put("Some text"));

Можна прочитати його з рядку:

JSONObject theSame = new JSONObject(new JSONTokener("""
    {
        "number": 10,
        "object": {
            "greetings":"Hello"
        },
        "array": [
            12.95,
            "Some text"
        ]
    }
"""));

Для того, щоб прочитати дані з JSON-файлу, який існує, можна скористатися статичною функцією readAllBytes() класу Files. Створивши об'єкт JSONObject, можна розділити його на окремі об'єкти і масиви. За допомогою класу FileWriter можна записати дані в новий файл. Ці дії ми розглянемо на прикладі.

Раніше було створено файл students.json:

{"students":[
  { "firstName":"Frodo", "lastName":"Baggins" },
  { "firstName":"Samwise", "lastName":"Gamgee" }
]}

Прочитавши з цього файлу дані, ми можемо додати ще двох студентів і записати в новий JSON-файл. Код програми може бути таким:

package ua.inf.iwanoff.java.advanced.second;

import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import org.json.JSONArray;
import org.json.JSONObject;

public class JsonTest {
    public static void main(String[] args) throws IOException {
        JSONObject object = new JSONObject(new String(Files.readAllBytes(Paths.get("students.json"))));
        System.out.println(object.toString(1));
        JSONArray students = object.getJSONArray("students");
        for (int i = 0; i < students.length(); i++) {
            JSONObject student = students.getJSONObject(i);
            System.out.println("  - " + student.getString("firstName"));
        }
        students.put(new JSONObject().put("firstName", "Merry").put("lastName", "Brandybuck"));
        students.put(new JSONObject().put("firstName", "Pippin").put("lastName", "Took"));
        try (FileWriter file = new FileWriter("newStudents.json")) {
            file.write(object.toString(1));
        }
    }
}

Використання методу toString(1) дозволяє отримати форматований JSON-файл:

{"students": [
 {
  "firstName": "Frodo",
  "lastName": "Baggins"
 },
 {
  "firstName": "Samwise",
  "lastName": "Gamgee"
 },
 {
  "firstName": "Merry",
  "lastName": "Brandybuck"
 },
 {
  "firstName": "Pippin",
  "lastName": "Took"
 }
]}

Існують також інші бібліотеки для роботи з JSON-файлами – Gson (від Google), Jackson, JSON-P, JSON-B тощо.

2.7 Серіалізація в файли XML і JSON за допомогою засобів XStream

В курсі "Основи програмування Java" були розглянуті технології серіалізації й десеріалізації об'єктів – запису й відтворення стану об'єктів з використанням послідовних потоків, зокрема, файлів.

Окрім бінарної серіалізації були розглянуті стандартні засоби XML-серіалізації. Недоліками стандартних засобів серіалізації в XML є:

  • обмеження на типи об'єктів (JavaBeans);
  • можливість серіалізації лише властивостей, які визначені публічними сеттерами й геттерами;
  • відсутність можливості керувати форматом та іменами тегів.

Існують нестандартні реалізації XML-серіалізації. Одна з найбільш поширених бібліотек – XStream. Ця бібліотека, яка вільно розповсюджується, дозволяє дуже легко серіалізувати та десеріалізувати файли XML. Для роботи з цією бібліотекою достатньо завантажити необхідні JAR-файли. Але більш зручний і сучасний підхід забезпечує використання Maven для підключення бібліотеки. Слід додати до файлу pom.xml необхідну залежність:

<dependencies>
    <dependency>
        <groupId>com.thoughtworks.xstream</groupId>
        <artifactId>xstream</artifactId>
        <version>1.4.20</version>
    </dependency>
</dependencies>

Бібліотека також дозоляє серіалізувати та десеріалізувати файли JSON. У прикладі 3.3 наведено програмний код, який дозволяє здійснити серіалізацію й десеріалізацію даних.

2.8 Робота з системним журналом

2.8.1 Загальні відомості про логування

Робота з системним журналом (логування) використовується для реєстрації в спеціальному файлі (як правило, текстовому) протоколу подій, які відбуваються під час роботи програми. Це, наприклад, трасування виконання конструкторів, методів, блоків обробки критичних ситуацій та інші повідомлення, пов'язані зі зневадженням.

Логер (logger) являє собою точку входу в систему логування. Кожен логер можна уявити як іменований канал для повідомлень, в який вони відправляються для обробки.

Важливим поняттям логування є рівень логування (log level), що визначає відносну важливість повідомлень, які протоколюються. Коли повідомлення передається в логер, рівень логування повідомлення порівнюється з рівнем логування логеру. Якщо рівень логування повідомлення вище або дорівнює рівню логування логеру, повідомлення буде оброблено, в іншому випадку – проігноровано.

2.8.2 Стандартні засоби Java для логування

Стандартні засоби пакету java.util.logging надають можливість протоколювання подій. Є такі рівні логування за зростанням: FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE, а також ALL і OFF, що вмикає та вимикає всі рівні відповідно. Для створення логеру слід використовувати статичні методи класу java.util.logging.Logger. Наприклад:

Logger log = Logger.getLogger("MyLog");
log.setLevel(Level.ALL);

Ім'я журналу визначають довільно. Тепер у створений таким чином журнал можна писати дані, зокрема, повідомлення:

log.log(Level.INFO, "Все OK"); // виведення на консоль

Якщо ми хочемо, щоб повідомлення також заносилися в файл, слід скористатися класом java.util.logging.FileHandler:

FileHandler fileHandler = new FileHandler("C:/MyFile.log");
log.addHandler(fileHandler);
log.log(Level.INFO, "Все OK"); // виведення на консоль і у файл

Примітка: використання запису журналу в файл передбачає перехоплення винятку java.io.IOException.

У наведеному нижче прикладі створюється об'єкт-журнал, в який заноситимуться повідомлення всіх рівнів. Одночасно з виведенням на консоль здійснюється запис у визначений файл:

package ua.inf.iwanoff.java.advanced.second;

import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;

public class LogDemo {
    public static void main(String[] args) throws IOException {
        Logger log = Logger.getLogger("MyLog");
        log.setLevel(Level.ALL);
        FileHandler fileHandler = new FileHandler("C:/MyFile.log");
        log.addHandler(fileHandler);
        log.log(Level.INFO, "Все OK"); // виведення на консоль і у файл
    }
}

Для конфігурації стандартних засобів логування використовують спеціальний файл властивостей (файл з розширенням .properties). Зокрема, можна окремо задавати параметри логування для виведення на консоль і в файл.

2.8.3 Використання бібліотеки Log4j

Є істотні недоліки стандартних інструментів логування (java.util.logging). Це труднощі налаштування, низька ефективність, обмежені можливості журналювання, недостатньо інтуїтивна конфігурація. Ці недоліки стимулювали незалежний розвиток альтернативних бібліотек логування.

Apache Log4j 2 - бібліотека логування (протоколювання) програм Java, яка фактично стала промисловим стандартом. Вона забезпечує значні покращення у порівнянні зі своїм попередником Log4j 1. З 2015 р версія Log4j 1 не рекомендується до використання.

Зараз актуальною є версія 2.20. API Log4j можна завантажити за адресою: https://logging.apache.org/log4j/2.x/.

Для того, щоб скористатися можливостями бібліотеки Log4j 2, можна в середовищі IntelliJ IDEA створити новий проєкт з підтримкою Maven, наприклад, log4j-test. До файлу pom.xml додаємо залежності:

<dependencies>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-api</artifactId>
        <version>2.20.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-core</artifactId>
        <version>2.20.0</version>
    </dependency>
</dependencies>

Після перезавантаження проєкту (кнопка Reload All Maven Projects) у проєкті можна користатися Log4j 2.

Створюємо клас з функцією main(). Для здійснення логування у програмі слід створити об'єкт класу org.apache.logging.log4j.Logger. Цей об'єкт дозволяє здійснювати запис в журнал повідомлення відповідно до встановленого рівня.

package ua.inf.iwanoff.java.advanced.second;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class HelloLog4J {
    public static void main(String[] args) {
        Logger logger = LogManager.getLogger(HelloLog4J.class);
        logger.fatal("Hello, Log4j!");
    }
}

Повідомленню "Hello, Log4j!" передує інформація про дату та час, функцію і клас.

Параметри логування зберігаються в спеціальному файлі конфігурації. Оскільки конфігурацію логування поки не визначено (немає відповідного файлу), працює усталена (default) конфігурація, згідно з якою виводяться тільки повідомлення рівня error та fatal. Рівень логування fatal, який використано для виведення повідомлення, має найвищий пріоритет. Усталено всі повідомлення виводяться на консоль.

Для того, щоб змінити політику логування, слід створити файл конфігурації. Його ім'я – log4j2.xml. Такий файл слід створити у теці java\src\resources. Його вміст в найпростішому випадку буде таким:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="ConsoleAppender" target="SYSTEM_OUT"/>
        <File name="FileAppender" fileName="hello-app-${date:yyyyMMdd}.log" immediateFlush="false" append="true"/>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="ConsoleAppender" />
            <AppenderRef ref="FileAppender"/>
        </Root>
    </Loggers>
</Configuration>

У файлі присутня група <Appenders>, в якій вказано, що виведення здійснюється на консоль і в файл, ім'я якого будується з "hello-app" і поточної дати. У групі <Loggers> вказані рівні виведення з напрямками виведення. В нашому випадку це "debug".

Log4J підтримує такі рівні виведення, в порядку зростання пріоритету:

trace – трасування всіх повідомлень в зазначений апендер
debug - детальна інформація для зневадження
info  - інформація
warn  - попередження
error - помилка
fatal - фатальна помилка - у цього повідомлення найвищий пріоритет.

Встановлення певного рівня означає, що будуть запротокольовані тільки повідомлення, що виводяться з цим або більш високим пріоритетом. Тому в нашому випадку здійснюється також виведення повідомлень рівня fatal.

Оскільки усталена конфігурація більше не працює, з повідомлень зникла інформація про дату та час, функцію і клас. Її можна відтворити змінивши вміст файлу log4j2.xml:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="ConsoleAppender" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %logger{36} - %msg%n" />
        </Console>
        <File name="FileAppender" fileName="hello-app-${date:yyyyMMdd}.log" immediateFlush="false" append="true">
            <PatternLayout pattern="%d{yyy-MM-dd HH:mm:ss.SSS} [%t] %logger{36} - %msg%n"/>
        </File>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="ConsoleAppender" />
            <AppenderRef ref="FileAppender"/>
        </Root>
    </Loggers>
</Configuration>

Окрім формату XML, конфігураційний файл можна створювати у форматах JSON, YAML, або PROPERTIES.

3 Приклади програм

3.1 Отримання таблиці простих чисел за допомогою потоків даних

Наведена нижче програма дозволяє отримати таблицю простих чисел в заданому діапазоні. Для отримання простих чисел доцільно використовувати потік IntStream:

package ua.inf.iwanoff.java.advanced.second;

import java.util.stream.IntStream;

public class PrimeFinder {
    private static boolean isPrime(int n) {
        return n > 1 && IntStream.range(2, n - 1).noneMatch(k -> n % k == 0);
    }

    public static void printAllPrimes(int from, int to) {
        IntStream primes = IntStream.range(from, to + 1).filter(PrimeFinder::isPrime);
        primes.forEach(System.out::println);
    }

    public static void main(String[] args) {
        printAllPrimes(6, 199);
    }
}

Метод isPrime() перевіряє, чи є число n простим. Для цього для чисел понад 1, формується набір послідовних цілих чисел, для кожного з яких перевіряється, чи ділиться n на це число. У методі printAllPrimes() формуємо потік простих цілих чисел з використанням фільтра і виводимо числа за допомогою методу forEach().

3.2 Читання з файлу й сортування рядків

Припустимо, нам необхідно прочитати з текстового файлу рядки, розсортувати за порядком, зворотним до алфавітного та записати в новий файл рядки, які починаються з літери "F".

Файл з рядками (strings.txt) може бути таким:

First
Second
Third
Fourth
Fifth

Програма може бути такою:

package ua.inf.iwanoff.java.advanced.second;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class ReadAndSort {
    public static void main(String[] args) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(Paths.get("strings.txt"))) {
            Stream<String> stream = reader.lines().sorted((s1, s2) -> s2.compareTo(s1)).
                    filter(s -> s.startsWith("F"));
            Files.write(Paths.get("results.txt"), stream.toList());
        }
    }
}

Після робти програми отримаємо файл results.txt:

Fourth
First
Fifth

3.3 Серіалізація і десеріалізація за допомогою бібліотеки XStream

Припустимо, необхідно здійснити серіалізацію і десеріалізацію даних про лінію, яка описується двома точками. Створюємо новий Maven-проєкт LineAndPoints. До файлу pom.xml додаємо залежність від бібліотеки xstream. Отримаємо такий файл pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>ua.inf.iwanoff.java.advanced.second</groupId>
    <artifactId>LineAndPoints</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.thoughtworks.xstream</groupId>
            <artifactId>xstream</artifactId>
            <version>1.4.20</version>
        </dependency>
    </dependencies>

</project>

Створюємо класи Line і Point. Ці класи не мають конструкторів без параметрів і публічних влистивостей, тому вони не можуть бути серіалізовані за допомогою java.beans.XMLEncoder і java.beans.XMLDecoder. Але XStream дозволяє їх серіалізувати, оскільки ця бібліотека орієнтується на поля, а не на властивості.

Клас Point:

package ua.inf.iwanoff.java.advanced.second;

public class Point {
    private double x, y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return x + " " + y;
    }
}

Клас Line:

package ua.inf.iwanoff.java.advanced.second;

public class Line {
    private Point first, second;

    public Line(double firstX, double firstY, double secondX, double secondY) {
        first = new Point(firstX, firstY);
        second = new Point(secondX, secondY);
    }

    @Override
    public String toString() {
        return first + " " + second;
    }
}

Для запису в файл можна створити такий клас:

package ua.inf.iwanoff.java.advanced.second;

import com.thoughtworks.xstream.XStream;

import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class XMLSerialization {
    public static void main(String[] args) {
        XStream xStream = new XStream();
        Line line = new Line(1, 2, 3, 4);
        xStream.alias("line", Line.class);
        String xml = xStream.toXML(line);
        try (FileWriter fw = new FileWriter("Line.xml"); PrintWriter out = new PrintWriter(fw)) {
            out.println(xml);
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Отримуємо XML-файл:

<line>
  <first>
    <x>1.0</x>
    <y>2.0</y>
  </first>
  <second>
    <x>3.0</x>
    <y>4.0</y>
  </second>
</line>

Примітка: якщо не використовувати аліас, кореневий тег буде таким: <ua.inf.iwanoff.java.advanced.second.Line>

В іншій програмі здійснюємо читання:

package ua.inf.iwanoff.java.advanced.second;

import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.security.AnyTypePermission;

import java.io.File;

public class XMLDeserialization {
    public static void main(String[] args) {
        XStream xStream = new XStream();
        xStream.addPermission(AnyTypePermission.ANY);
        xStream.alias("line", Line.class);
        Line newLine = (Line) xStream.fromXML(new File("Line.xml"));
        System.out.println(newLine);
    }
}

Для того щоб скористатися засобами роботи XStream з JSON-файлами, до файлу pom.xml необхідно додати ще одну залежність:

        <dependency>
            <groupId>org.codehaus.jettison</groupId>
            <artifactId>jettison</artifactId>
            <version>1.5.2</version>
        </dependency>

Програма серіалізації в JSON-файл буде такою:

package ua.inf.iwanoff.java.advanced.second;

import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.json.JsonHierarchicalStreamDriver;

import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class JSONSerialization {
    public static void main(String[] args) {
        XStream xStream = new XStream(new JsonHierarchicalStreamDriver());
        Line line = new Line(1, 2, 3, 4);
        xStream.alias("line", Line.class);
        String xml = xStream.toXML(line);
        try (FileWriter fw = new FileWriter("Line.json");
             PrintWriter out = new PrintWriter(fw)) {
            out.println(xml);
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Отримаємо такий JSON-файл:

{"line": {
  "first": {
    "x": 1.0,
    "y": 2.0
  },
  "second": {
    "x": 3.0,
    "y": 4.0
  }
}}

Програма десеріалізації з JSON-файлу буде такою:

package ua.inf.iwanoff.java.advanced.second;

import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.json.JettisonMappedXmlDriver;
import com.thoughtworks.xstream.security.AnyTypePermission;

import java.io.File;

public class JSONDeserialization {
    public static void main(String[] args) {
        XStream xStream = new XStream(new JettisonMappedXmlDriver());
        xStream.addPermission(AnyTypePermission.ANY);
        xStream.alias("line", Line.class);
        Line newLine = (Line) xStream.fromXML(new File("Line.json"));
        System.out.println(newLine);
    }
}

3.4 Класи "Країна" та "Перепис населення"

Припустимо, ми поставили за мету переробити попередньо створений проєкт, пов'язаний з країною й переписами. Базові класи, які реалізують основну функціональність класів для представлення країни й переписів, були представлені в прикладах лабораторних робіт № 2, № 3 і № 4 курсу "Основи програмування Java". До класів, створених в прикладах лабораторних робіт № 2 і № 3, необхідно додати похідні класи, в яких перевизначити реалізацію всіх методів, пов'язаних з обробкою послідовностей через використання засобів Stream API. Окрім відтворення реалізації функціональності, новий проєкт повинен включати:

  • виведення даних в текстовий файл засобами Stream API з подальшим читанням;
  • серіалізацію об'єктів у XML-файл і JSON-файл і відповідну десеріалізацію із застосуванням бібліотеки XStream.
  • запис подій, пов'язаних з виконанням програми, в системний журнал;
  • тестування окремих класів з використанням JUnit.

С урахуванням додавання залежностей від зовнішніх бібліотек доцільно створити новий Maven-проєкт, в який перенести раніше створені класи. Скопіювати файли з одного проєкту в інший можна через буфер обміну: в підвікні Projects вибираємо необхідні файли та копіюємо їх у буфер обміну (функція Copy контекстного меню); в іншому проєкті вибираємо необхідний пакет і вставляємо файли за допомогою функції Paste. Також можна скопіювати пакет цілком.

Тепер можна створити новий пакет – ua.inf.iwanoff.java.advanced.second. До пакету додаємо клас CensusWithStreams. В ньому доцільно створити конструктори й перевизначити метод containsWord(), реалізувавши його за допомогою потоків. Наприклад, код класу може бути таким:

package ua.inf.iwanoff.java.advanced.second;

import ua.inf.iwanoff.java.second.Census;
import java.util.Arrays;

/**
 * Клас відповідає за представлення перепису населення.
 * Для обробки послідовності слів застосовано засоби Stream API
 */
public class CensusWithStreams extends Census {
    /**
     * Конструктор ініціалізує об'єкт усталеними значеннями
     */
    public CensusWithStreams() {
    }

    /**
     * Конструктор ініціалізує об'єкт вказаними значеннями
     *
     * @param year рік перепису
     * @param population кількість населення
     * @param comments текст коментаря
     */
    public CensusWithStreams(int year, int population, String comments) {
        setYear(year);
        setPopulation(population);
        setComments(comments);
    }

    /**
     * Перевіряє, чи міститься слово в тексті коментаря
     *
     * @param word слово, яке ми шукаємо в коментарі
     * @return {@code true}, якщо слово міститься в тексті коментаря
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean containsWord(String word) {
        return Arrays.stream(getComments().split("\s")).anyMatch(s -> s.equalsIgnoreCase(word));
    }
}

Можна було б також визначити функцію main() для здійснення тестування, але кращий підхід – скористатися можливостями модульного тестування (JUnit). У вікні коду вибираємо ім'я класу й через контекстне меню Generate... | Test... вибираємо функції, для яких слід згенерувати тестові методи. В нашому випадку – це метод containsWord(). IntelliJ IDEA автоматично генерує всі необхідні паралельні пакети гілки test і створює клас CensusWithStreamsTest. Від початку він такий:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CensusWithStreamsTest {

    @Test
    void containsWord() {
    }
}

Якщо у згенерованому коді підсвічуються помилки, до файлу pom.xml слід додати необхідну залежність. Це може зробити IntelliJ IDEA: серед варіантів виправлення помилки можна вибрати More actions... | Add Maven dependency... і вибрати Assertions. До pom.xml буде додано такий код:

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.8.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

Цей код також можна було додати вручну.

Тепер можна додати необхідне тестування, яке частково відтворює роботу методу testWord() класу Census. Код файлу CensusWithStreamsTest.java буде таким:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CensusWithStreamsTest {

    @Test
    void containsWord() {
        CensusWithStreams census = new CensusWithStreams();
        census.setComments("Перший перепис у незалежній Україні");
        assertTrue(census.containsWord("Україні"));
        assertTrue(census.containsWord("ПЕРШИЙ"));
        assertFalse(census.containsWord("Країні"));
        assertFalse(census.containsWord("Україна"));
    }
}

Виконавши тести, ми отримаємо код успішного завершення. Якщо в коді змінити очікувані результати, тести згенерують виняток, а в коді буде підкреслено твердження, яке не виконалося.

У класі, який відповідає за країну, теж можна перевизначити всі методи через застосування потоків. Є два варіанти реалізації створення нового класу:

  • похідний клас від CountryWithArray;
  • похідний клас від CountryWithList.

Переваги другого варіанту – в роботі зі списком ArrayList, що в порівнянні зі звичайним масивом є більш зручним, а у випадку додавання нових елементів ще й більш ефективним. Недолік другого варіанту в порівнянні з першим – необхідність забезпечення безпосереднього доступу до списку, що в нашому випадку передбачає додавання методів до класу CountryWithList, який було створено раніше. Внесення змін до базових класів взагалі не є бажаним, але, якщо треба це зробити, не слід змінювати набір публічних методів класу.

Обираючи другий варіант, ми повинні зберегти набір публічних методів без змін. Щоб забезпечити це, ми можемо додати захищені (protected) методи доступу до класу CountryWithList:

     protected List<Census> getList() {
         return list;
     }

     protected void setList(List<Census> list) {
         this.list = list;
     }

Окрім методів для роботи з послідовністю sortByPopulation(), sortByComments() і maxYear() слід перевизначити методи для достпупу до списку, оскільки слід відстежувати, щоб у список потрапили лише посилання типу CensusWithStreams. Це методи setCensus(), addCensus() в двох варіантах і setCensuses(). Доцільно також додати функції, які створюють список рядків з даних про об'єкт і навпаки. Сирцевий код класу CountryWithStreams буде таким:

package ua.inf.iwanoff.java.advanced.second;

import ua.inf.iwanoff.java.second.Census;
import ua.inf.iwanoff.java.third.CountryWithList;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

/**
 * Клас для представлення країни, в якій здійснюється перепис населення.
 * Для обробки послідовностей застосовано засоби Stream API
 */
public class CountryWithStreams extends CountryWithList {
    /**
     * Встановлює посилання на новий перепис всередині позиції послідовностей
     * за вказаним індексом.
     *
     * @param i      номер (індекс) позиції в послідовності
     * @param census посилання на новий перепис
     */
    @Override
    public void setCensus(int i, Census census) {
        if (census instanceof CensusWithStreams) {
            super.setCensus(i, census);
        }
        else {
            new RuntimeException();
        }
    }

    /**
     * Додає посилання на новий перепис в кінець послідовності
     *
     * @param census посилання на новий перепис
     * @return {@code true}, якщо посилання вдалося додати
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean addCensus(Census census) {
        if (census instanceof CensusWithStreams) {
            return super.addCensus(census);
        }
        return false;
    }

    /**
     * Створює новий перепис та додає посилання на нього в кінець послідовності.
     *
     * @param year       рік перепису
     * @param population кількість населення
     * @param comments   текст коментаря
     * @return {@code true}, якщо посилання вдалося додати
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean addCensus(int year, int population, String comments) {
        return super.addCensus(new CensusWithStreams(year, population, comments));
    }

    /**
     * Переписує дані з масиву переписів у послідовність
     *
     * @param censuses довільний масив переписів
     */
    @Override
    public void setCensuses(Census[] censuses) {
        if (Arrays.stream(censuses).anyMatch(c -> c instanceof CensusWithStreams)) {
            super.setCensuses(censuses);
        }
        else {
            new RuntimeException();
        }
    }

    /**
     * Здійснює сортування послідовності переписів за кількістю населення
     */
    @Override
    public void sortByPopulation() {
        setList(getList().stream().sorted().toList());
    }

    /**
     * Здійснює сортування послідовності переписів за алфавітом коментарів
     */
    @Override
    public void sortByComments() {
        setList(getList().stream().sorted(Comparator.comparing(Census::getComments)).toList());
    }

    /**
     * Знаходить і повертає рік з максимальним населенням
     *
     * @return рік з максимальним населенням
     */
    @Override
    public int maxYear() {
        return getList().stream().max(Comparator.comparing(Census::getPopulation)).get().getYear();
    }

    /**
     * Створює та повертає масив переписів зі вказаним словом в коментарях
     *
     * @param word слово, яке відшукується
     * @return масив переписів зі вказаним словом в коментарях
     */
    @Override
    public Census[] findWord(String word) {
        return getList().stream().filter(c -> c.containsWord(word)).toArray(Census[]::new);
    }

    /**
     * Створює й повертає список рядків, в який послідовно заносяться дані
     * про країну в цілому та про всі переписи населення
     *
     * @return список рядків з даними про країну
     */
    public List<String> toListOfStrings() {
        ArrayList<String> list = new ArrayList<>();
        list.add(getName() + " " + getArea());
        Arrays.stream(getCensuses()).forEach(c -> list.add(
                c.getYear() + " " + c.getPopulation() + " " + c.getComments()));
        return list;
    }

    /**
     * Читає зі списку рядків дані про країну та заносить їх у відповідні поля
     *
     * @param list список рядків з даними про країну
     */
    public void fromListOfStrings(List<String> list) {
        String[] words = list.get(0).split("\s");
        setName(words[0]);
        setArea(Double.parseDouble(words[1]));
        list.remove(0);
        list.stream().forEach(s -> { String[] line = s.split("\s");
                    addCensus(Integer.parseInt(line[0]), Integer.parseInt(line[1]),
                    s.substring(s.indexOf(line[2]))); });
    }

    /**
     * Створює та повертає об'єкт типу CountryWithStreams для тестування
     * @return об'єкт типу CountryWithStreams
     */
    public static CountryWithStreams createCountryWithStreams() {
        CountryWithStreams country = new CountryWithStreams();
        country.setName("Україна");
        country.setArea(603628);
        country.addCensus(1959, 41869000, "Перший післявоєнний перепис");
        country.addCensus(1970, 47126500, "Нас побільшало");
        country.addCensus(1979, 49754600, "Просто перепис");
        country.addCensus(1989, 51706700, "Останній радянський перепис");
        country.addCensus(2001, 48475100, "Перший перепис у незалежній Україні");
        return country;
    }
}

У наведеному коді застосовано виклик функції toArray(Census[]::new). Це забезпечує створення масиву необхідного типу (посилань на Census), а не масиву посилань на Object, яку повертає відповідна функція без параметрів.

Додаємо клас для тестування CountryWithStreams. Це здійснюється так, як і для класу CensusWithStreams. Доцільно вибрати методи sortByPopulation(), sortByComments(), maxYear() і findWord(). Отримаємо такий код:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CountryWithStreamsTest {

    @Test
    void sortByPopulation() {
    }

    @Test
    void sortByComments() {
    }

    @Test
    void maxYear() {
    }

    @Test
    void findWord() {
    }
}

Примітка: перевірка працездатності методів toListOfStrings() і fromListOfStrings() здійснюватиметься під час роботи з текстовими файлами.

Оскільки необхідно виконати декілька тестів над об'єктом і ці тести повинні бути незалежними, об'єкт доцільно створювати перед виконанням кожного тестового методу. Для цього слід додати метод з анотацією @BeforeEach. Відповідний метод також можна згенерувати автоматично (в контекстному меню Generate... | SetUp Method). У методі setUp() створюємо новий об'єкт. Відповідне поле country типу CountryWithStreams створюємо вручну. Об'єкт буде використаний в тестових методах.

Для зручного тестування функцій, пов'язаних з пошуком та сортуванням, можна створити функцію getYears(), яка отримує масив років з масиву переписів. Ця статична функція використовуватиме статичну змінну index для заповнення конкретних елементів масиву. Змінна не може бути локальною, оскільки ми використовуємо її в лямбда-виразі. Отримуємо такий код:

package ua.inf.iwanoff.java.advanced.second;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import ua.inf.iwanoff.java.second.Census;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.*;

class CountryWithStreamsTest {
    private CountryWithStreams country;

    static int index;

    static int[] getYears(Census[] censuses) {
        int[] years = new int[censuses.length];
        index = 0;
        Arrays.stream(censuses).forEach(c -> years[index++] = c.getYear());
        return years;
    }

    @BeforeEach
    void setUp() {
        country = new CountryWithStreams();
        country.addCensus(1959, 41869000, "Перший післявоєнний перепис");
        country.addCensus(1970, 47126500, "Нас побільшало");
        country.addCensus(1979, 49754600, "Просто перепис");
        country.addCensus(1989, 51706700, "Останній радянський перепис");
        country.addCensus(2001, 48475100, "Перший перепис у незалежній Україні");
    }

    @Test
    void sortByPopulation() {
        country.sortByPopulation();
        assertArrayEquals(getYears(country.getCensuses()), new int[] { 1959, 1970, 2001, 1979, 1989 });
    }

    @Test
    void sortByComments() {
        country.sortByComments();
        assertArrayEquals(getYears(country.getCensuses()), new int[] { 1970, 1989, 2001, 1959, 1979 });
    }

    @Test
    void maxYear() {
        assertEquals(country.maxYear(), 1989);
    }

    @Test
    void findWord() {
        assertArrayEquals(getYears(country.findWord("перепис")), new int[] { 1959, 1979, 1989, 2001 });
    }
}

Масиви років, які відповідають правильним результатам сортування й пошуку, були підготовлені вручну.

Клас FileUtils відповідатиме за зберігання в текстовому файлі, читання з текстового файлу, серіалізацію і десеріалізацію даних (з XML і JSON), а також за запис у журнал подій у програмі. Запис у журнал здійснюється кожного разу, коли ми читаємо або записуємо дані у файли різних форматів. Для роботи з засобами XStream і log4j до файлу pom.xml необхідно додати такі залежності:

        <dependency>
            <groupId>com.thoughtworks.xstream</groupId>
            <artifactId>xstream</artifactId>
            <version>1.4.20</version>
        </dependency>
        <dependency>
            <groupId>org.codehaus.jettison</groupId>
            <artifactId>jettison</artifactId>
            <version>1.5.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.20.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.20.0</version>
        </dependency>

Необхідно також налаштувати засоби log4j, додавши файл log4j2.xml до теки java\src\resources. В нашому випадку цей файл буде таким:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="ConsoleAppender" target="SYSTEM_OUT"/>
        <File name="FileAppender" fileName="country-${date:yyyyMMdd}.log" immediateFlush="false" append="true"/>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="FileAppender"/>
        </Root>
    </Loggers>
</Configuration>

Окремі дії, пов'язані з читанням і записом, можна реалзіувати як статичні методи. Код класу FileUtils буде таким:

package ua.inf.iwanoff.java.advanced.second;

import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.json.JettisonMappedXmlDriver;
import com.thoughtworks.xstream.security.AnyTypePermission;
import org.apache.logging.log4j.Logger;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

/**
 * Клас реалізує запис і читання даних форматів TXT, XML і JSON.
 * Читаються й записуються дані про країни та переписи населення.
 * Одночасно події фіксуються в системному журналі.
 */
public class FileUtils {
    private static Logger logger = null;

    public static Logger getLogger() {
        return logger;
    }

    public static void setLogger(Logger logger) {
        FileUtils.logger = logger;
    }

    /**
     * Здійснює запис даних про країну та переписи в указаний файл
     *
     * @param country країна
     * @param fileName ім'я файлу
     */
    public static void writeToTxt(CountryWithStreams country, String fileName) {
        if (logger != null) {
            logger.info("Write to text file");
        }
        try {
            Files.write(Path.of(fileName), country.toListOfStrings());
        }
        catch (IOException e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
    }

    /**
     * Здійснює читання даних про країну та переписи зі вказаного файлу
     *
     * @param fileName ім'я файлу
     * @return об'єкт, який було створено
     */
    public static CountryWithStreams readFromTxt(String fileName) {
        CountryWithStreams country = new CountryWithStreams();
        if (logger != null) {
            logger.info("Read from text file");
        }
        try {
            List<String> list = Files.readAllLines(Path.of(fileName));
            country.fromListOfStrings(list);
        }
        catch (IOException e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
        return country;
    }

    /**
     * Здійснює серіалізацію даних про країну та переписи в указаний XML-файл
     *
     * @param country країна
     * @param fileName ім'я файлу
     */
    public static void serializeToXML(CountryWithStreams country, String fileName) {
        if (logger != null) {
            logger.info("Serializing to XML");
        }
        XStream xStream = new XStream();
        xStream.alias("country", CountryWithStreams.class);
        xStream.alias("census", CensusWithStreams.class);
        String xml = xStream.toXML(country);
        try (FileWriter fw = new FileWriter(fileName); PrintWriter out = new PrintWriter(fw)) {
            out.println(xml);
        }
        catch (IOException e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
    }

    /**
     * Здійснює десеріалізацію даних про країну та переписи зі вказаного XML-файлу
     *
     * @param fileName ім'я файлу
     * @return об'єкт, який було створено
     */
    public static CountryWithStreams deserializeFromXML(String fileName) {
        if (logger != null) {
            logger.info("Deserializing from XML");
        }
        try {
            XStream xStream = new XStream();
            xStream.addPermission(AnyTypePermission.ANY);
            xStream.alias("country", CountryWithStreams.class);
            xStream.alias("census", CensusWithStreams.class);
            CountryWithStreams country = (CountryWithStreams) xStream.fromXML(new File(fileName));
            return country;
        }
        catch (Exception e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
    }

    /**
     * Здійснює серіалізацію даних про країну та переписи в указаний JSON-файл
     *
     * @param country країна
     * @param fileName ім'я файлу
     */
    public static void serializeToJSON(CountryWithStreams country, String fileName) {
        if (logger != null) {
            logger.info("Serializing to JSON");
        }
        XStream xStream = new XStream(new JettisonMappedXmlDriver());
        xStream.alias("country", CountryWithStreams.class);
        xStream.alias("census", CensusWithStreams.class);
        String xml = xStream.toXML(country);
        try (FileWriter fw = new FileWriter(fileName); PrintWriter out = new PrintWriter(fw)) {
            out.println(xml);
        }
        catch (IOException e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
    }

    /**
     * Здійснює десеріалізацію даних про країну та переписи зі вказаного JSON-файлу
     *
     * @param fileName ім'я файлу
     * @return об'єкт, який було створено
     */
    public static CountryWithStreams deserializeFromJSON(String fileName) {
        if (logger != null) {
            logger.info("Deserializing from JSON");
        }
        try {
            XStream xStream = new XStream(new JettisonMappedXmlDriver());
            xStream.addPermission(AnyTypePermission.ANY);
            xStream.alias("country", CountryWithStreams.class);
            xStream.alias("census", CensusWithStreams.class);
            CountryWithStreams country = (CountryWithStreams) xStream.fromXML(new File(fileName));
            return country;
        }
        catch (Exception e) {
            if (logger != null) {
                logger.error(e.toString());
            }
            throw new RuntimeException(e);
        }
    }

}

Код класу Program, в якому здійснюється демонстрація всіх створених функцій, буде таким:

package ua.inf.iwanoff.java.advanced.second;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

/**
 * Клас демонструє запис і читання даних форматів TXT, XML і JSON.
 * Одночасно події фіксуються в системному журналі.
 */
public class Program {
    
    /**
     * Демонстрація роботи програми.
     * Послідовно записуються й читаються дані форматів TXT, XML і JSON.
     * Одночасно події фіксуються в системному журналі
     *
     * @param args аргументи командного рядка (не використовуються)
     */
    public static void main(String[] args) {
        Logger logger = LogManager.getLogger(Program.class);
        FileUtils.setLogger(logger);
        logger.info("Program started");
        CountryWithStreams country = CountryWithStreams.createCountryWithStreams();
        writeToTxt(country, "Country.txt");
        country = readFromTxt("Country.txt");
        System.out.println(country);
        serializeToXML(country, "Country.xml");
        country = deserializeFromXML("Country.xml");
        System.out.println(country);
        serializeToJSON(country, "Country.json");
        country = deserializeFromJSON("Country.json");
        System.out.println(country);
        logger.info("Program finished");
    }

}

Результатом роботи буде виведення на консоль даних про країну, прочитаних з різних джерел. У кореневій теці проєкту з'являться нові файли. Текстовий файл (Country.txt):

Україна 603628.0
1959 41869000 Перший післявоєнний перепис
1970 47126500 Нас побільшало
1979 49754600 Просто перепис
1989 51706700 Останній радянський перепис
2001 48475100 Перший перепис у незалежній Україні

Файл XML (Country.xml):

<country>
  <name>Україна</name>
  <area>603628.0</area>
  <list>
    <census>
      <year>1959</year>
      <population>41869000</population>
      <comments>Перший післявоєнний перепис</comments>
    </census>
    <census>
      <year>1970</year>
      <population>47126500</population>
      <comments>Нас побільшало</comments>
    </census>
    <census>
      <year>1979</year>
      <population>49754600</population>
      <comments>Просто перепис</comments>
    </census>
    <census>
      <year>1989</year>
      <population>51706700</population>
      <comments>Останній радянський перепис</comments>
    </census>
    <census>
      <year>2001</year>
      <population>48475100</population>
      <comments>Перший перепис у незалежній Україні</comments>
    </census>
  </list>
</country>

На жаль файл JSON (Country.json), який згенерує програма, буде погано відформатований (весь вміст файлу в одному рядку). Але якщо цей файл відкрити в середовищі IntelliJ IDEA і застосувати форматування коду (Code | Reformat Code), його вміст у вікні редактора буде таким:

{
  "country": {
    "name": "Україна",
    "area": 603628,
    "list": [
      {
        "census": [
          {
            "year": 1959,
            "population": 41869000,
            "comments": "Перший післявоєнний перепис"
          },
          {
            "year": 1970,
            "population": 47126500,
            "comments": "Нас побільшало"
          },
          {
            "year": 1979,
            "population": 49754600,
            "comments": "Просто перепис"
          },
          {
            "year": 1989,
            "population": 51706700,
            "comments": "Останній радянський перепис"
          },
          {
            "year": 2001,
            "population": 48475100,
            "comments": "Перший перепис у незалежній Україні"
          }
        ]
      }
    ]
  }
}

Крім того буде створено файл журналу з розширенням .log, до якого після кожного запуску програми дописуватиметься такий фрагмент тексту:

Program started
Write to text file
Read from text file
Serializing to XML
Deserializing from XML
Serializing to JSON
Deserializing from JSON
Program finished

Крім того, до журналу записуватиметься інформація про винятки, які виникали під час роботи з файлами.

4 Вправи для контролю

  1. Прочитати з текстового файлу дійсні значення (до кінця файлу), знайти їх суму та вивести в інший текстовий файл. Застосувати засоби Stream API.
  2. Прочитати з текстового файлу цілі значення, замінити від'ємні значення модулями, додатні нулями та вивести отримані значення в інший текстовий файл. Застосувати засоби Stream API.
  3. Прочитати з текстового файлу цілі значення, розділити парні елементи на 2, непарні – збільшити у 2 рази та вивести отримані значення в інший текстовий файл. Застосувати засоби Stream API.
  4. Описати класи "Бібліотека" (з полем – масивом книг) та "Книга". Створити об'єкти, здійснити їх серіалізацію й десеріалізацію в XML і JSON засобами XStream.
  5. Описати класи Факультет та Інститут (з полем – масивом факультетів). Створити об'єкти, здійснити їх серіалізацію й десеріалізацію в XML і JSON засобами XStream.

5 Контрольні запитання

  1. Які стандартні засоби перевірки тверджень в Java?
  2. Що таке модульне тестування?
  3. Що таке JUnit?
  4. Як здійснюється анотування методів тестування в JUnit?
  5. Як здійснити логічне групування тестів?
  6. Як скористатися JUnit у програмному середовищі?
  7. Які завдання вирішують системи автоматизації складання?
  8. Яка основна відмінність між Apache Maven та Apache Ant?
  9. Що таке "трійка" GAV?
  10. Яка структура файлу pom.xml?
  11. Які є типові завдання роботи з файловою системою?
  12. Які стандартні засоби Java надають можливість роботи з файловою системою? В чому полягають відмінності між цими засобами?
  13. Які є способи отримання інформації про файли та підкаталоги?
  14. У чому переваги і особливості Stream API?
  15. Як отримати потік з колекції?
  16. Як отримати потік з масиву?
  17. Чим відрізняються проміжні і кінцеві операції?
  18. Які є потоки для роботи з примітивними типами?
  19. Як здійснюється читання й запис даних засобами Stream API?
  20. Що таке формат JSON, які його переваги?
  21. Які є основні елементи JSON-файлу?
  22. Які засоби існують для підтримки роботи з JSON-файлами?
  23. Які здійснюється XML-серіалізація засобами XStream?
  24. Які здійснюється JSON-серіалізація засобами XStream?
  25. Що таке логер і рівень логування?
  26. Які засоби існують для підтримки системного журналу?
  27. Які переваги бібліотеки Log4j у порівнянні зі стандартними засобами логування?
  28. Що таке рівні (пріоритети) виведення?

 

up