Як працювати з великими текстовими файлами в ZennoPoster

Бачу не один я зустрічався з такою проблемою, коли прив’язуєш текстовий файл до звичайного списка в програмі ZennoPoster, і бачиш, що все працює нормально в багатопотоковому режимі. А потім в один день файл виростає, тому що наприклад є багато даних які по черзі потрібно опрацювати, чи банально звідкись завантажили великий файл, з якого треба видалити мусорні рядки – і результатом такого звичайного підходу буде повне пожирання оперативної пам’яті програмою ZennoPoster.

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

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

// підкорегований чуть-чуть код, який видав ChatGPT який не годиться для роботи з великими файлами
string path = @"C:\file.txt";
string[] lines1 = File.ReadAllLines(path);
string line = lines1[0];
string[] lines2 = lines1.Skip(1).ToArray();
File.WriteAllLines(path, lines2);

Console.WriteLine(line);

Власне в цьому фрагменті коду, який нібито працює зразу є декілька проблем.
ReadAllLines зразу поміщає всі рядки з файлу в оперативну пам’ять компьютера.
.ToArray() знову виділить оперативну пам’ять компьютера.
В результаті у lines1 і lines2 буде виділена пам’ять на одині і тіж дані.

Адаптувати цей код для роботи в ZennoPoster не проблема, просто видалити рядок з Console.WriteLine. І хоча цей код працює з маленькими файлами, всерівно щоб він працював у багатопотоковому режимі потрібно синхронізувати потоки:

// підкорегований чуть-чуть код, який видав ChatGPT який не годиться для роботи з великими файлами
string path = @"C:\file.txt";
string line = "";
lock(SyncObjects.ListSyncer) {
	string[] lines1 = File.ReadAllLines(path);
	line = lines1[0];
	string[] lines2 = lines1.Skip(1).ToArray();
	File.WriteAllLines(path, lines2);
}

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

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

var lines = File.ReadLines(path);

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

line = lines.First(); // Прочитати перший рядок
line = lines.Skip(5).First(); // Прочитати 6-й рядок
line = lines.Last(); // Прочитати останній рядок

Важливо, що у такому випадку ми використаєм оперативну пам’ять тільки для одного рядка з даними, тобто мізерну кількість.
Код буде виглядати вже приблизно так (і як бачимо, ми не записуємо дані в файл):

string path = @"C:\file.txt";
string line = "";
lock(SyncObjects.ListSyncer) { // синхронізація потоків
	var lines = File.ReadLines(path);
	line = lines.Skip(5).First(); // Прочитати 6-й рядок
}
return line;

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

string path = @"C:\file.txt";
string line = "";
lock(SyncObjects.ListSyncer) { // синхронізація потоків
	try {
		var lines = File.ReadLines(path);
		line = lines.Skip(5).First(); // Прочитати 6-й рядок
	}
	catch (Exception e) {
        project.SendWarningToLog(e.Message, true);
        throw new Exception(e.Message);
    }
}
return line;

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

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

А логіку зробимо таку – синхронізуємо потоки, і першим ділом перевіримо наявність двух файлів – великого, з якого будемо читати рядки, і тимчасового, в якому буде зберігатись у вигляді json рядка кількість рядків і рядок який був взятий останнім.

Якщо немає великого файла – просто вийдемо по помилці.
Якщо немає маленького файла – тоді наше завдання створити його, і записати в нього 0 як номер рядка, і порахувати кількість рядків у великому файлі.

Думаю, сам код, скаже більше, ніж опис логіки:

string path_counter = Path.Combine(project.Directory, "counter.txt");
string path = Path.Combine(project.Directory, "file.txt");
string line = string.Empty;

lock(SyncObjects.ListSyncer) { // синхронізація потоків
	bool check_f1 = File.Exists(path_counter);
	bool check_f2 = File.Exists(path);
	if(!check_f2) throw new Exception("Немає великого файла"); // виходимо - немає звідки читати дані
	
	var dic = new Dictionary<string, int>();
	if(!check_f1) { // файл counter ще не створений - заповнюємо за змовчуванням
		dic["row"] = 0;
		dic["max"] = File.ReadLines(path).Count();
	}
	else {
		try {
			 // пробуємо прочитати дані
			dic =  Global.ZennoLab.Json.JsonConvert.DeserializeObject<Dictionary<string, int>>(File.ReadAllText(path_counter).Trim());
		}
		catch {
			 // не змогли прочитати дані - створюємо за змовчуванням
			dic["row"] = 0;
			dic["max"] = File.ReadLines(path).Count();
		}
	}
	
	if(!(dic["max"] > dic["row"])) {	
		 // якщо дійшли до кінця файла - створємо за змовчуванням
		dic["row"] = 0;
		dic["max"] = File.ReadLines(path).Count();
		 
	}
	
	try {
		// Просто читаємо необхідний рядок
		line = File.ReadLines(path).Skip(dic["row"]).First();
		// вказуємо який наступний рядок читати будемо
		dic["row"]++;
		// зберігаємо налаштування в файл
		File.WriteAllText(path_counter, Global.ZennoLab.Json.JsonConvert.SerializeObject(dic,  Global.ZennoLab.Json.Formatting.Indented));
	}
	catch (Exception e) {
		project.SendWarningToLog(e.Message, true);		
		// якщо виявилось що рядка з таким номером нема - перерахуємо кількість рядків
	 	dic["max"] = File.ReadLines(path).Count();
		// зберігаємо налаштування в файл
		File.WriteAllText(path_counter, Global.ZennoLab.Json.JsonConvert.SerializeObject(dic,  Global.ZennoLab.Json.Formatting.Indented));
		throw new Exception(e.Message);
	}
}

return line;

Власне такий варіант підходить для роботи з файлами, кількість рядків у яких не перевищує int.MaxValue, тобто 2 147 483 647. Якщо кількість рядків в файлі буде більшою – то думаю методи Skip і Count не зможуть працювати корректно. В такому випадку можливо варто порізати великий файл на менші файли і працювати вже з ними.

Ще можна звичайно використовувати StreamReader щоб читати файл по рядкам. Проте, в цьому випадку я не побачив ніякої переваги, тому File.ReadLines мені більш до душі підійшов для вирішення подібних завданнь.

А на рахунок ChatGPT – то звичайно він може без проблем написати хороший код, якщо йому детально описати весь алгоритм того, що саме хочеться одержати і які саме методи використовувати, і які саме помилки і яким чином їх потрібно обробляти. Проте, коли я знаю як такі завдання вирішуються, то самостійно написати код мені набагато простіше, ніж тратити час на те, щоб об’яснити ChatGPT що саме він повинен зібрати, щоб це могло корректно працювати.

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *