Назад Зміст Вперед

11.11 Символьні потоки. Текстові файли: запис та читання. Клас RandomAccessFile

Символьні потоки

Для читання даних із чогось в програмі створюється вхідний потік, для виводу даних кудись - створюється вихідний потік. З практичної точки зору, при об'єктно-орієнтованому програмуванні, вам необхідно створити два об'єкти, які уособлюватимуть ці потоки і вказати в кожному з них з чим вони зв'язані (наприклад, задати шлях до файлу, чи ідентифікатор порту комп'ютера, чи мережеву URL адресу і т.п.). Якщо потрібно прийняти дані, то використовується один об'єкт, якщо передати дані — звертаємось до іншого об'єкта.
Streams.jpg

Ієрархія потоків вводу/виводу. Класи потоків в Java формують дві ієрархії класів: символьні потоки (Character Streams) та байтові потоки (Byte Streams). Як зрозуміло з назв, перші потоки орієнтовані на роботу з символами Юнікоду, а інші з послідовностями байт. Практично будь-яку передачу інформації можна реалізувати за допомогою байтових потоків, проте доволі часто така інформація представляється у вигляді символів, тож для таких даних зручніше використовувати символьні потоки. В java для організації потоків вводу-виводу, програмістам надано біля півсотні класів. Насправді, для створення вводу-виводу достатньо лише кілька класів, проте в залежності від задачі деякі класи реалізовують більш зручні засоби для отримання, передачі і деякої попередньої обробки даних.


Розглянемо приклад роботи з файлами за допомогою байтових потоків. Є два файли, необхідно скопіювати один файл у інший. Для цього створюємо два байтові потоки: вхідний потік, через який читатиметься наш файл first.txt і створюємо інший вихідний (output) потік, який записуватиме прочитані дані у second.txt. Для цього будемо використовувати два класи FileInputStream та FileOutputStream.
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class CopyBytes {
    public static void main(String[] args) throws IOException {
        
        //створюємо об'єктні змінні, які посилатимуться на наші потоки
        FileInputStream in = null;
        FileOutputStream out = null;

        // При помилках читання/запису можуть генеруватися винятки, тож потрібно перехопити їх
        // Наприклад, помилка може виникнути, при відсутності файлу first.txt у вказаному місці
        try { 
            
            // створюємо вхідний і вихідний потік
            // файл first.txt повинен вже існувати
            // якщо second.txt не буде існувати,
            // то буде створений при спробі запису
            in = new FileInputStream("d:\\first.txt");
            out = new FileOutputStream("d:\\second.txt");
            int c; 

            //Допоки з файлу first.txt не буде прочитано всі байти,
            //читаємо байти з файлу first.txt і записуємо даний байт у second.txt
            //якщо потік не повертає -1(не кінець файлу),
            //то копіюємо наступний байт
            while ((c = in.read()) != -1) {
                out.write(c);
            }
        } finally { //дії коли не знайдено файли
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }
}
Як бачимо алгоритм роботи з файлами доволі простий. Спочатку створюємо вхідний потік, далі створюємо вихідний потік. Читаємо перший байт, першого файлу і записуємо його у другий файл, далі переходимо до наступного байту і так допоки усі байти першого файлу не будуть прочитані. В разі виникнення виняткової ситуації, закриваємо наші потоки, а разом з ними і наші файли(не варто забувати закривати потоки, задля звільнення ресурсів комп'ютера. Необхідно згадати, що при читанні/записі файлу застосовується своєрідний вказівник на вміст файлу(неявно для нас). В даному випадку після кожного прочитаного байту вказівник неявним для нас чином переміщується по вмісту файлу на один байт і т.д.
В даному випадку ми використали низькорівневі байтові потоки. Вони годяться для копіювання даних з одного місця в інший, проте символи можуть представлятися не одним байтом. Тому коли ми захочемо, наприклад, вивести вміст файлу на екран, ми отримаємо проблеми із виводом символів. Так у нашому випадку, якщо файл буде латиницею, то використавши у циклі інструкцію:
System.out.print(Character.toChars(c)); 
ми одержимо текст у читабельному виді. Оскільки в Юнікоді для представлення латиниці достатньо одного байту. Якщо ж файл кирилицею, текст буде виведений карлючками, оскільки робота перетворення байт у символи пройшло не зовсім вірно (кириличні літери представляються двома байтами). Дану проблему можна вирішити кількома способами. Найбільш підковані програмісти можуть спробувати власноруч здійснити перетворення байтів у символи з використанням бітових операцій, проте це дещо незручний спосіб і потрібно враховувати кодування символів. Інший спосіб більш легший: замість того, щоб читати по одному байту, ми можемо прочитати зразу ж увесь вміст файлу у масив і перетворити його у текстовий рядок потрібного кодування:
    byte []b=new byte[10000]; // масив для вмісту файлу 
    int k=in.read(b); // читаємо в масив та отримуємо кількість прочитаних байт
    //використовуємо конструктор String, 
    //який перетворює масив у рядок 
    //із відповідним кодуванням символів     
    String s = new String(b, 0, k, "cp1251");
    System.out.println(s); 
Якщо необхідно визначити потрібне кодування, то це можна зробити за допомогою читання відповідних системних властивостей:
String encoding= System.getProperty("file.encoding");
І все ж, коли наперед відомо, що ми працюємо із текстовими файлами, то більш елегантним і простішим рішенням буде використання символьних потоків. Тоді Java візьме на себе правильну роботу із кодуванням символів.


Символьні потоки. Наступний приклад вирішує проблему з читанням кирилиці із файлу з наступним її відображенням на екран. На відміну від попереднього прикладу, тепер використовуються символьні потоки, що створюються на основі класів: FileReader, FileWriter.
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

public class CopyCharacters {
    public static void main(String[] args) throws IOException {

        FileReader inputStream = null;
        FileWriter outputStream = null;

        try {
            inputStream = new FileReader("d:\\first.txt");
            outputStream = new FileWriter("d:\\second.txt");

            int c;
            while ((c = inputStream.read()) != -1) {
                //посимвольно записуємо у файл і виводимо на екран
                outputStream.write(c);
                System.out.print(Character.toChars(c));
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
}

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

Ось результат по виводу на екран вмісту файлу із прикладу CopyBytes.java(байтові потоки):
?????? ????, ??????????? ?????!
а ось при використанні прикладу із CopyCharacters.java(символьні потоки):
Привіт тобі, божевільний світе!

Буферизовані потоки. Читання вмісту файлу по байтах не дуже хороша ідея, якщо файл доволі великий. Адже це зайве навантаження на обчислювальні ресурси комп'ютера. Тому більш кращим варіантом є читання тексту цілими блоками. Наприклад, рядками. Рядки у файлах прийнято завершувати символом нового рядка("\n") та символом переходу на новий рядок("\r"). Може бути присутній як один з цих символів так і обидва ("\r\n"), в залежності від того хто і яким чином створював файл.

Читання блоків файлу відбувається через так звані буферизовані потоки, що працюють через буфер в пам'яті комп'ютера. При читанні даних з файлу, дані передаються в програму коли буфер буде порожнім, при записі у файл буфер спочатку повинен заповнитись. Для буферизованого вводу/виводу існує чотири класи. Для буферизованих байтових потоків: BufferedInputStream та BufferedOutputStreamДля буферизованих символьних потоків: BufferedReader та BufferedWriter

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

Далі наведено, дещо модифікований вищенаведений приклад, що використовує буферизовані потоки:

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

public class CopyLines {
    public static void main(String[] args) throws IOException {

        BufferedReader inputStream = null;
        PrintWriter outputStream = null;

        try {
            inputStream = new BufferedReader(new FileReader("d:\\first.txt"));
            outputStream = new PrintWriter(new FileWriter("d:\\second.txt"));

            String l;
            //тепер читаємо дані цілими рядками
            while ((l = inputStream.readLine()) != null) {
                outputStream.println(l);
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
} 
Як бачимо наші буферизовані потоки обгорнули звичайні небуферизовані потоки:
inputStream = new BufferedReader(new FileReader("d:\\first.txt"));
outputStream = new PrintWriter(new FileWriter("d:\\second.txt")); 
Тепер ми можемо використовувати метод readLine і читати дані з файлу цілими рядками тексту:
inputStream.readLine()) 
та записувати дані у файл також цілими рядками:
outputStream.println(l); 
Деякі потоки можуть обгортати інші потоки не лише заради буферизації.


Текстові файли: запис та читання

Створимо простий проект та клас WorkInFile.java і напишіть туди стандартну конструкцію main:
public static void main(String[] args){
//тут будуть викликатися методи
}

Тепер створимо клас який буде мати методи для роботи з файлами, і назвемо його FileWorker.java всі методи в ньому що не є private будуть статичними для того щоб ми отримували до них доступ без примірника цього класу.

Як записувати в файл?
У класі FileWorker.java створимо статичний метод який буде здійснювати запис в файл і назвемо цей метод write (String text; String nameFile):
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void write(String fileName, String text) {
    //Визначаємо файл
    File file = new File(fileName);
    try {
        //перевіряємо, що якщо файл не існує, то створюємо його
        if(!file.exists()){
            file.createNewFile();
        }
        //PrintWriter забезпечити можливості запису в файл
        PrintWriter out = new PrintWriter(file.getAbsoluteFile());
        try {
            //Записуємо текст у файл
            out.print(text);
        } finally {
            // Після чого ми повинні закрити файл             // Інакше файл не запишеться
            out.close();
        }
    } catch(IOException e) {
        throw new RuntimeException(e);
    }
}
Зверніть особливу увагу на те, що після запису будь-яких даних в файл ми повинні його закрити, тільки після цього дії дані запишуться в файл.
private static String text = "This new text \nThis new text2\nThis new text3\nThis new text4\n";private static String fileName = "C://blog/a.txt";
public static void main(String[] args) throws FileNotFoundException {
//Запис у файл
FileWorker.write(fileName, text);
}
Після чого ми отримуємо новий файл «a.txt» наступного змісту:
This new text
This new text2
This new text3
This new text4


Як читати файл?Тепер в класі FileWorker створимо метод для читання файлу, також статичний:
public static String read(String fileName) throws FileNotFoundException {
//Цей спец. об'єкт для побудови рядка
StringBuilder sb = new StringBuilder();
exists(fileName);
try {
//Об'єкт для читання файлу в буфер
BufferedReader in = new BufferedReader(new FileReader( file.getAbsoluteFile()));
try {
//У циклі через підрядник вважати файл
String s;
while ((s = in.readLine()) != null) {
sb.append(s);
sb.append("\n");
}
} finally {
//Також не забуваємо закрити файл
in.close();
}
} catch(IOException e) {
throw new RuntimeException(e);
}
//Повертаємо отриманий текст з файлу
return sb.toString();


StringBuilder — в чому різниця між звичайним String? В тому, що коли ви в StringBuilder додаєте текст він не перестворюється, а String перестворює себе.
Також якщо файлу немає то метод викине Exception.
Для перевірки на наявність файлу створимо метод:
private static void exists(String fileName) throws FileNotFoundException {
File file = new File(fileName);
if (!file.exists()){
throw new FileNotFoundException(file.getName());
}
}
Тепер перевіримо його:
private static String text = "This new text \nThis new text2\nThis new text3\nThis new text4\n";
private static String fileName = "C://blog/a.txt";
public static void main(String[] args) throws FileNotFoundException {
//Спроба прочитати неіснуючий файл
FileWorker.read("no_file.txt");
//Читання файлу
String textFromFile = FileWorker.read(fileName);
System.out.println(textFromFile);
}


У першому випадку коли файл не існує ми отримаємо це:
Exception in thread "main" java.io.FileNotFoundException: no_file.txt
at com.devcolibri.tools.FileWorker.read(FileWorker.java:31)


У другому випадку, ми отримуємо вміст файлу у вигляді рядка. (Для цього закоментуйте перший випадок)

Як оновити файл?
Як такого Update для файлів немає, але спосіб оновити його є, для цього можна його перезаписати.

Давайте створимо метод update в класі FileWorker:
public static void update(String nameFile, String newText) throws FileNotFoundException {
exists(fileName);
StringBuilder sb = new StringBuilder();
String oldFile = read(nameFile);
sb.append(oldFile);
sb.append(newText);
write(nameFile, sb.toString());
}
Тут ми зчитуємо старий файл в StringBuilder після чого додаємо до нього новий текст і записуємо знову. Зверніть увагу що для цього ми використовуємо наші методи.

В результаті оновлення файлу:
private static String text = "This new text \nThis new text2\nThis new text3\nThis new text4\n";
private static String fileName = "C://blog/a.txt";
public static void main(String[] args) throws FileNotFoundException {
//Оновлення файлу
FileWorker.update(fileName, "This new text");
}
ми отримаємо наступне вміст файлу "a.txt":
This new text
This new text2
This new text3
This new text4
This new text 


Як видалити файл?В той же наш утілітний клас FileWorker додамо метод delete, він буде дуже простим так як у об'єкту File вже є метод delete ():
public static void delete(String nameFile) throws FileNotFoundException {
exists(nameFile);
new File(nameFile).delete();
}

Перевірка:
private static String fileName = "C://blog/a.txt";
public static void main(String[] args) throws FileNotFoundException {
//Видалення файлу
FileWorker.delete(fileName);
}

Після чого файл буде видалений.

Клас RandomAccessFile

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

RandomAccessFile використовується для файлів, що містять записи відомого розміру, так що ви можете переміститися від одного запису до іншого, використовуючи seek (), потім прочитати або змінити запис. Записи можуть і не бути однакового розміру; ви просто здатні визначити їх розмір і їх положення в файлі.

Спочатку трохи важко повірити, що RandomAccessFile не є частиною ієрархії InputStream або OutputStream. Однак він не має асоціацій з цими ієрархіями, за винятком того, що він реалізує інтерфейси DataInput і DataOutput (які так само реалізуються DataInputStream і DataOutputStream). Він навіть не використовує будь-яку функціональність існуючих класів InputStream або OutputStream - це повністю окремий клас, написаний для пошуку, який має всі свої власні (в більшості своїй рідні) методи. Поясненням цього може бути те, що RandomAccessFile має багато в чому іншу поведінку в порівнянні з іншими типами введення / виведення, так як ви можете переміщатися вперед і назад у межах файлу. У будь-якому випадку, він стоїть окремо, як прямий нащадок від Object.

По суті, RandomAccessFile працює як DataInputStream суміщений з DataOutputStream, завдяки використанню методів 
getFilePointer () для знаходження місця розташування у файлі, 
seek () для переміщення в нову точку в файлі і 
length () для визначення максимального розміру файлу. Крім того, конструктор вимагає другий аргумент, який вказує будете ви робити тільки читання в довільному порядку ( "r") або читання і запис ( "rw"). 
Немає підтримки для файлів тільки для читання, що може сказати про те, що RandomAccessFile міг би добре працювати, якщо він дістався у спадок б від DataInputStream.

Метод пошуку є тільки у RandomAccessFile, який працює тільки з файлами. BufferedInputStream дозволяє вам виконувати маркування позиції за допомогою методу mark () (чиє значення міститься в єдиній внутрішньої змінної) і скидання цієї позиції методом reset (), але це обмежена і не дуже корисно.


У класі RandomAccessFile визначено два конструктора, прототипи яких показані нижче:

public RandomAccessFile(
String name, String mode); 

public RandomAccessFile( 

File file, String mode);

Перший з них дозволяє вказувати ім'я файлу name, і режим mode, в якому відкривається файл. Другий конструктор замість імені передбачає використання об'єкта класу File.
Якщо файл відкривається тільки для читання, ви повинні передати конструктору текстовий рядок режиму "r". Якщо ж файл відкривається і для читання, і для запису, конструктору передається рядок "rw".
Позиціонування всередині файлу забезпечується методом seek, як параметр pos якому передається абсолютне зміщення файлу:
public void seek(long pos);
Після виклику цього методу поточна позиція у файлі встановлюється відповідно до значення параметра pos.
У будь-який момент часу ви можете визначити поточну позицію всередині файлу, викликавши метод getFilePointer:
public long getFilePointer();
За допомогою методу close ви повинні закрити файл, після того як робота з ним завершена:
public void close();
За допомогою методу length можна визначити довжину файлу в байтах:
public long length();
Ряд методів призначений для виконання як звичайного, так і форматованого введення з файлу. Цей набір аналогічний методам, визначеним для потоків. Існують також методи, що дозволяють виконувати звичайну або форматований запис в файл з прямим доступом.

Опис прикладу
У цьому розділі приведено нескладний приклад автономної програми Java, яка працює з файлом методом прямого доступу. Спочатку вона створює новий файл і записує в нього десять рядків виду "Record 0", "Record 1" і так далі.
Потім програма читає ці рядки в зворотному порядку і відображає на консолі:
* Random Access File demonstration 
Record 9
Record 8
Record 7
Record 6
Record 5
Record 4
Record 3
Record 2
Record 1
Record 0
Розглянемо вихідний текст програми. Усередині статичного методу main, що одержує управління при запуску програми, визначили декілька змінних. Змінна i використовується як змінна циклу: int i; В змінній data зберігаємо посилання на файл, до якого здійснюється прямий доступ:
RandomAccessFile data;
Поточна позиція в файлі зберігається в змінної dataPointer:
long dataPointer = 0;
Масив idx призначений для зберігання позицій всіх доданих в файл на першому етапі роботи програми:
Vector idx = new Vector();
Рядок s використовується для тимчасового зберігання записів, витягнутих з файлу на другому етапі роботи програми:
String s;
Всі операції, пов'язані з файлами, можуть порушувати виключення, тому їх виконуємо в тілі оператора try-catch:
try
{
. . .
}catch(Exception ex){
System.out.println(ex.toString());
}
Перш за все ми створюємо файл з ім'ям direct.dat:
data = new RandomAccessFile("direct.dat", "rw");
Файл відкривається для читання і запису. Далі додаємо в файл десять записів (текстових рядків):
for(i = 0; i < 10; i++) {
dataPointer = data.getFilePointer();
idx.addElement(new Long(dataPointer));
data.writeBytes("Record " + i + "\n");
}

Перед додаванням чергового запису ми отримуємо поточну позицію в файлі, викликаючи для цього метод getFilePointer, і зберігаємо її в масиві idx класу Vector. Перед збереженням нам потрібно перетворити тип даних з long в Long, що ми зробили за допомогою відповідного конструктора.
Запис зберігається методом writeBytes, здатним записувати в файл текстові рядки класу String.
На другому етапі додані записи читаються в зворотному порядку і відображаються на консолі:
for(i = 9; i >= 0; i--)
{
((Long)idx.elementAt(i)).doubleValue();
dataPointer = (long)
data.seek(dataPointer); s = data.readLine();
}
System.out.println(s);

Тут спочатку витягаємо з масиву idx позицію запису з заданим номером і перетворимо її тип в long.
Далі ми виконуємо позиціонування на запис методом seek.
Потім запис читається методом readLine і відображається на консолі.

.