Клас ByteArrayOutputStream реалізує потік виведення, в якому дані пишуться в байтовий масив. Буфер автоматично зростає, оскільки дані пишуться йому.

Потік класу ByteArrayOutputStream створює буфер у пам’яті, і всі дані, відправлені в потік, зберігаються у буфері.

Конструктори ByteArrayOutputStream

У класу ByteArrayOutputStream є такі конструктори:

Конструктор
ByteArrayOutputStream() Конструктор створює буфер у пам’яті у 32 байти.
ByteArrayOutputStream(int a) Конструктор створює буфер у пам’яті певного розміру.

А ось так клас виглядає всередині:


//власне буфер, у якому зберігаються дані.
protected byte buf[];

//поточна кількість байтів, записаних у буфер.
protected int count;

public ByteArrayOutputStream() {
    this(32);
}

public ByteArrayOutputStream(int size) {
    if (size < 0) {
        throw new IllegalArgumentException("Negative initial size: "
                                           + size);
    }
    buf = new byte[size];
}
    

Методи ByteArrayOutputStream

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

Спробуємо покласти щось у наш потік. Для цього будемо використовувати метод write() — він може прийняти один байт або набір байтів, який йому потрібно записати.

Метод
void write(int b) Запис одного байта.
void write(byte b[], int off, int len) Запис масиву байтів певного розміру.
void writeBytes(byte b[]) Запис масиву байтів.
void writeTo(OutputStream out) Записує всі дані поточного вихідного потоку у вказаний вихідний потік.

Реалізація методів:


public static void main(String[] args) throws IOException {
   ByteArrayOutputStream outputByte = new ByteArrayOutputStream();
    //запис одного байта
   while(outputByte.size()!= 8) {
      outputByte.write("javarush".getBytes());
   }

   //запис масиву байтів
   String value = "\nWelcome to Java\n";
   byte[] arrBytes = value.getBytes();
   outputByte.write(arrBytes);

   //запис масиву за розмірністю
   String javaRush = "JavaRush";
   byte[] b = javaRush.getBytes();
   outputByte.write(b, 4, 4);

   //запис у файл
   FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
   outputByte.write(80);
   outputByte.writeTo(fileOutputStream);
}
    

В результаті виконання у нас створюється файл output.txt із текстом всередині:

Метод toByteArray() повертає поточний вміст цього вихідного потоку у вигляді масиву байтів. А за допомогою методу toString() можна отримати масив байтів buf у вигляді тексту:


public static void main(String[] args) throws IOException {
    ByteArrayOutputStream outputByte = new ByteArrayOutputStream();

    String value = "JavaRush";
    outputByte.write(value.getBytes());

    byte[] result = outputByte.toByteArray();
    System.out.println("Виведення вмісту:");

    for(int i = 0 ; i < result.length; i++) {
        // Виведення символів
        System.out.print((char)result[i]);
    }
}
    

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

Метод reset() скидає кількість дійсних байт у вихідному потоці байтового масиву до нуля (тому все накопичене на виході буде скинуто).


public static void main(String[] args) throws IOException {
   ByteArrayOutputStream outputByte = new ByteArrayOutputStream(120);

   String value = "JavaRush";
   outputByte.write(value.getBytes());
   byte[] result = outputByte.toByteArray();
   System.out.println("Виведення до скидання: ");

   for (byte b : result) {
      // Виведення символів
      System.out.print((char) b);
   }

   outputByte.reset();

   byte[] resultAfterReset = outputByte.toByteArray();
   System.out.println("\nВиведення вмісту после сброса:");

   for (byte b : resultAfterReset) {
      // Виведення символів
      System.out.print((char) b);
   }
}
    

В результаті під час виведення нашого буфера після методу reset() ми нічого не отримаємо.

Особливості методу close()

Цей метод заслуговує на особливу увагу. Щоб зрозуміти, що він робить, перейдемо всередину нього:


/**
 * Closing a {@code ByteArrayOutputStream} has no effect. The methods in
 * this class can be called after the stream has been closed without
 * generating an {@code IOException}.
 */
public void close() throws IOException {
}
    

Варто зазначити, що метод close() в ByteArrayOutputStream за фактом нічого не робить.

Чому ж так? ByteArrayOutputStream — це потік на основі пам’яті (тобто керується та заповнюється користувачем у коді), тому у разі виклику close() жодного ефекту не відбувається.

Практика

Тепер давайте спробуємо реалізувати файлову систему, використовуючи наш ByteArrayOutputStream та ByteArrayInputStream.

Напишемо клас FileSystem із застосуванням патерну Singleton і будемо використовувати статичну HashMap<String, byte[]>, де:

  • String — шлях до файлу;
  • byte[] — дані у збереженому файлі.

import java.io.*;
import java.util.HashMap;
import java.util.Map;

class FileSystem {
   private static final FileSystem fileSystem = new FileSystem();
   private static final Map<String, byte[]> files = new HashMap<>();

   private FileSystem() {
   }

   public static FileSystem getFileSystem() {
       return fileSystem;
   }

   public void create(String path) {
       validateNotExists(path);
       files.put(path, new byte[0]);
   }

   public void delete(String path) {
       validateExists(path);
       files.remove(path);
   }

   public boolean isExists(String path) {
       return files.containsKey(path);
   }

   public InputStream newInputStream(String path) {
       validateExists(path);
       return new ByteArrayInputStream(files.get(path));
   }

   public OutputStream newOutputStream(String path) {
       validateExists(path);
       return new ByteArrayOutputStream() {
           @Override
           public void flush() throws IOException {
               final byte[] bytes = toByteArray();
               files.put(path, bytes);
               super.flush();
           }

           @Override
           public void close() throws IOException {
               final byte[] bytes = toByteArray();
               files.put(path, bytes);
               super.close();
           }
       };
   }

   private void validateExists(String path) {
       if (!files.containsKey(path)) {
           throw new RuntimeException("File not found");
       }
   }

   private void validateNotExists(String path) {
       if (files.containsKey(path)) {
           throw new RuntimeException("File exists");
       }
   }
}
    

У цьому класі ми створили публічні методи:

  • стандартні методи CRUD (create, read, update, delete),
  • метод перевірки того, чи існує файл,
  • метод отримання інстансу файлової системи.

Для читання з файлу повертаємо користувачe InputStream. Під капотом — реалізація ByteArrayInputStream. Буфером виступає байтовий масив, що зберігається в мапі files.

Другий цікавий метод — newOutputStream. У разі виклику цього методу ми повертаємо користувачу новий об’єкт типу ByteArrayOutputStream із двома перевизначеними методами: flush та close. Виклик будь-якого із цих методів є тригером того, що потрібно зробити запис.

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

Для тестування написаної файлової системи (FS) використовуємо такий код:


import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import static java.nio.charset.StandardCharsets.UTF_8;

public class MyFileSystemTest {
   public static void main(String[] args) throws IOException {
       FileSystem fileSystem = FileSystem.getFileSystem();
       final String path = "/user/bin/data.txt";

       // Створюємо файл
       fileSystem.create(path);
       System.out.println("Файл успішно створений");

       // Переконуємося, що він порожній
       try (InputStream inputStream = fileSystem.newInputStream(path)) {
           System.out.print("Вміст файлу:\t");
           System.out.println(read(inputStream));
       }

       // Записуємо у нього дані
       try (final OutputStream outputStream = fileSystem.newOutputStream(path)) {
           outputStream.write("JavaRush".getBytes(UTF_8));
           System.out.println("Дані записані у файл");
       }

       // Читаємо дані
       try (InputStream inputStream = fileSystem.newInputStream(path)) {
           System.out.print("Вміст файлу:\t");
           System.out.println(read(inputStream));
       }

       // Видаляємо файл
       fileSystem.delete(path);

       // Перевіряємо, що такого файлу в FS не існує
       System.out.print("Файл існує:\t");
       System.out.println(fileSystem.isExists(path));

   }

   private static String read(InputStream inputStream) throws IOException {
       return new String(inputStream.readAllBytes(), UTF_8);
   }
}
    

Під час тестування перевіримо дії:

  1. Створюємо новий файл.
  2. Перевіряємо, що створений файл порожній.
  3. Записуємо будь-які дані у файл.
  4. Читаємо записані дані, переконуємося, що вони відповідають записаним.
  5. Видаляємо файл.
  6. Перевіряємо, що файл видалений.

В результаті роботи цього коду отримаємо висновок:

Файл успішно створений
Вміст файлу:
Дані записані у файл
Вміст файлу: JavaRush
Файл існує: false

Навіщо нам був потрібний цей приклад?

Все просто: будь-які дані — це набір байтів. Якщо потрібно часто і багато читати з диска та записувати на диск, код буде працювати повільно через проблеми IO. У такому випадку доречно тримати в RAM віртуальну файлову систему, з якою можна працювати так само, як і зі звичним диском. А що може бути простіше, ніж InputStream та OutputStream?

Звісно, це навчальний приклад, а не production read код. У ньому НЕ враховані (список не виключний):

  • багатопотоковий доступ;
  • обмеження щодо розміру файлів (обсяг доступної RAM для запущеної JVM);
  • немає перевірки структури шляхів;
  • немає перевірок на дані, отримані як параметри.

Цікавий інсайд:
У чомусь схожий підхід використовується на сервері валідації завдань JavaRush. Піднімається віртуальна FS, визначається, які саме тести потрібно запустити для валідації завдання, відбувається тестування та читання результатів.

Підсумок та головне питання

Найголовнішим питанням після прочитання лекції буде: «Чому я не можу просто використовувати byte[], адже це зручніше і у мене немає обмежень?»

Перевага ByteArrayInputStream — це дуже переконлива вказівка на те, що ви збираєтеся використовувати байти тільки для читання (оскільки потік не надає інтерфейс для їх зміни). Хоча важливо відзначити, що програміст может, як і раніше, безпосередньо звертатися до байтів.

Але якщо іноді це byte[], іноді — файл, іноді — мережеве з’єднання тощо, вам буде потрібна якась абстракція для "потоку байтів, і мені все одно, звідки вони". Ось що таке InputStream. Коли джерелом виявляється байтовий масив, ByteArrayInputStream є гарним InputStream для використання.

Це корисно в багатьох ситуаціях, але наведемо два конкретні приклади:

  1. Ви пишете бібліотеку, яка приймає байти і якось їх обробляє (наприклад, це бібліотека обробки зображень). Користувачі вашої бібліотеки можуть надавати байти із файлу, із byte[] у пам’яті або із будь-якого іншого джерела. Отже, ви надаєте інтерфейс, який приймає InputStream — це означає, що якщо в них є byte[], їм потрібно обернути його в ByteArrayInputStream.

  2. Ви пишете код, який зчитує мережеве з’єднання. Але для модульного тестування цього коду вам не потрібно відкривати з’єднання; ви хочете просто додати кілька байтів у код. Таким чином, код приймає InputStream, а ваш тест надає ByteArrayInputStream.