RSV: простая бинарная альтернатива формату CSV

Во всем мире активно применяется текстовый формат CSV (Comma Separated Value – значения, разделенные запятыми) и многие программы умеют работать с этим форматом. Да, формат прост, легко читается человеком и без проблем парсится, но что если, за такую простоту и наличие библиотек во всех языках программирования, мы получаем кучу различных проблем, а иногда и багов? Что если нужен такой же формат табличных данных, но данные должны быть в бинарном виде?

Действительно, формат CSV очень прост – в нем единственный разделитель – запятая (или иногда точка с запятой) и такой разделитель легко найти. В D даже в стандартной библиотеке есть модуль для работы с CSV (даже с примерами) – std.csv и формат очень даже популярен.

Данный формат несмотря на свою короткую и понятную спецификацию:

   - Каждая строка файла — это одна строка таблицы.
   - Разделителем (англ. delimiter) значений колонок является символ запятой (,). Однако на практике часто используются другие разделители, то есть формат путают с DSV и TSV (см. ниже).
   - Значения, содержащие зарезервированные символы (двойная кавычка, запятая, точка с запятой, новая строка) обрамляются двойными кавычками ("). Если в значении встречаются кавычки — они представляются в файле в виде двух кавычек подряд.

имеет ряд проблем, которые можно было бы решить. В частности, есть проблема с коллизией разделителей (т.е. потенциально могут быть проблемы с интерпретацией значений, которые содержат разделитель в числе своих символов), что приводит к усложнению спецификации правилами экранирования (см. третье правило в приведенной спецификации). Также есть определенные трудности при сливании нескольких CSV-файлов с таблицами в один.

Один программист, автор Youtube-канала Stenway задумался о том, что можно создать формат, который был бы также прост и удобен как CSV, но избавлен от недостатков последнего. Основным недостатком Stenway видел коллизию разделителей и для решения этой проблемы он создал новый формат – RSV.

RSV (Rows of String Values – строки из значений типа String) – это бинарный формат для хранения различных данных (в основном, табличных). Формат очень прост, а его правила как пишет автор “можно изложить на обычной кофейной салфетке“.

Документ в RSV представляет собой обычный массив массивов строковых значений, которые могут быть нулевыми (т.е. иметь null-значение):

NullableString[][]

Все значения в RSV являются строками, что позволяет не заботиться о типах данных: их интерпретация возлагается на программу, которая использует RSV. Это упрощает как запись, так и считывание, позволяя избежать сложного парсинга, а также проблем с представлением разных видов данных или определением порядка байтов в значениях.

Формат ориентирован прежде всего на табличные данные, но это никак не мешает использованию формата для нетабличных данных, поскольку RSV является разреженным массивом разреженных массивов строковых значений. На практике это означает, что в строках со значениями может быть любое произвольное количество элементов, в том числе и нулевое.

Как было сказано ранее, значения в RSV представлены строками. Иных типов значений нет, а сами строки представлены в кодировке UTF-8. Другие кодировки Unicode наподобие UTF-16 или UTF-32 не поддерживаются (почему, поймете далее). RSV представляет собой некую таблицу, которая состоит из строчек, в которых размещены значения. Значение представляет собой или строку или специальное значение NULL, которое указывает на “пустое значение”. Важный момент: пустая строка не равна пустому значению – это разные случаи. Поскольку в RSV значения группируются в строки и все они типа String (кроме значения с типом NULL), в этом формате используется специальная хитрость для определения того, где заканчивается одно значение и начинается следующее. Каждое значение (а пределах одной табличной строки RSV) заканчивается специальным маркером EOV (End of value – Конец значения), который представляет собой один байт со значением 255. Аналогично, каждая табличная строка формата завершается особым маркером, который также представлен одним байтом. Этот байт имеет значение 253 и называется EOR (End of Row – конец табличной строки). Также стоит упомянуть, что нулевое значение (NULL) также представлено одним байтом, который называется NUL и имеет значение 254. Значения 253, 254 и 255 выбраны не случайно, поскольку в кодировке UTF-8 и ее специфичном шаблоне байтов существует набор байтов, которые никогда не будут использованы схемой кодирования. По сути дела, выбранные для маркеров байты являются недопустимыми для декодеров UTF-8 и должны быть отклонены ими, но так RSV – бинарный, то мы можем использовать эти байты для выделения особых значений.

Прежде чем перейти к реализации формата на D, покажем ряд простых примеров, которые были позаимствованы у автора формата.

Учитывая тот факт, что в RSV два байта (EOV и EOR) являются терминаторами, а не разделителями (т.е. завершают последовательности, а не разделяют их), то самым простым случаем RSV является пустой RSV:

[
]

т.е. в данном случае, получается RSV без строк или полностью пустая таблица. Этот момент также показывает еще одно любопытное свойство файлов RSV: поскольку у нас специальные символы имеют завершающий характер, то файлы RSV можно просто объединять друг с другом.

Рассмотрим другой простой пример:

[
  ["Hello", "🌎"]
]

Здесь у нас одна табличная строка, которая содержит два значения – строку “Hello” и эмодзи с изображением планеты Земля. Кодирование в этом случае выглядело бы следующим образом: сначала мы кодируем в UTF-8 байты строки “Hello”. Строка содержит 5 символов и все они являются символами ASCII (подмножеством UTF-8), которые преобразуются в следующую последовательность байтов:

72 101 108 108 111

Поскольку это одно строковое значение, то мы должны его завершить специальным маркером EOV, который имеет значение 255, т.е. получается следующая последовательность байтов:

72 101 108 108 111|255
                   ^^^

Следующее строковое значение – это эмодзи с планетой Земля, который являясь дополнительным символом Unicode требует 4 байта для своего кодирования:

72 101 108 108 111|255|240 159 140 142
                       ^^^ ^^^ ^^^ ^^^

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

72 101 108 108 111|255|240 159 140 142|255
                                       ^^^

Так как строковые значения кончились, да еще и завершена одна табличная строка, то в итоговую последовательность байтов нужно поместить специальный маркер конца табличной строки – EOR:

72 101 108 108 111|255|240 159 140 142|255|253
                                           ^^^

И таким образом получена вся байтовая последовательность для RSV, никаких других шагов для обработки не требуется – это обычная байтовая запись (в файл).

И последний пример RSV (который мы не будем разбирать подробно) представлен следующим массивом:

[
  ["Hello", "🌎"],
  [],
  [null, ""]
]

и кодируется такой последовательностью байтов:

72 101 108 108 111|255|240 159 140 142|255|253|253|254|255|255|253
H  e   l   l   o   EOV      <emoji>    EOV EOR EOR NUL EOV EOV EOR

Формат хоть и бинарный, но является достаточно простым для чтения/записи и практически не вызывает трудностей в реализации.

Наша реализация для представления данных в виде RSV сделана прямолинейно (в лоб) и для представления строк, которые могут быть нулевыми, использована библиотека optibrev и ее структуры для представления опциональных значений. Поэтому первым делом осуществим импорт этой библиотеки и std.stdio, а также создадим ряд удобных псевдонимов для сложных типов:

module rsv;

import std.stdio;

import optibrev;

public {
	/// Одно значение RSV
	alias RSVCell  = Option!string;
	/// Одна табличная строка
	alias RSVRow   = RSVCell[];
	/// Сам RSV документ
	alias RSVTable = RSVRow[];
}

Всю логику работы с RSV, а также ряд констант для байтовых маркеров мы помещаем в класс RSVFile:

class RSVFile
{
	private {
		/// Все возможные разделители в RSV
		enum RSV_TERMINATOR {
			END_OF_ROW   = 0xFD, // End of RSV row (EOR)
			NULL_VALUE   = 0xFE, // Null value (NUL)
			END_OF_VALUE = 0xFF  // End of value (EOV)
		}
		
		RSVTable _table;
	}
	
	static {
		/// декодирование байтового массива в RSV
		RSVTable decodeRSV(ubyte[] rsvBytes) {
			RSVTable rsv;
			RSVRow row;
			
			if ((rsvBytes.length > 0) && (rsvBytes[$-1] != RSV_TERMINATOR.END_OF_ROW))
			{
				throw new Exception("Incomplete RSV document");
			}
			
			ulong startIndex = 0;
			
			Option!string option;
			
			foreach (i, e; rsvBytes)
			{	
				if (e == RSV_TERMINATOR.END_OF_VALUE)
				{
					auto length = i - startIndex;
					if (length == 0)
					{
						row ~= option.Some("");
					}
					else
					{
						if ((length == 1) && (rsvBytes[startIndex] == RSV_TERMINATOR.NULL_VALUE))
						{
							row ~= option.None();
						}
						else
						{
							row ~= option.Some(cast(string) rsvBytes[startIndex..i]);
						}
					}
					startIndex = i + 1;
				}
				else
				{
					if (e == RSV_TERMINATOR.END_OF_ROW)
					{
						if ((i > 0) && (startIndex != i))
						{
							throw new Exception("Incomplete RSV row");
					    }
					    
					    rsv ~= row;
					    row = [];
					    startIndex = i + 1;
				    }
				} 
			}
			
			return rsv;
		}
		
		/// кодирование RSV в байтовый массив
		ubyte[] encodeRSV(RSVTable rsvTable) 
		{
		    ubyte[] rsvBytes;
		    Option!string option;
		    
		    foreach (rsvRow; rsvTable)
		    {    
		        foreach (rsvCell; rsvRow)
		        {
		            if (rsvCell.isNone)
		            {
		                rsvBytes ~= RSV_TERMINATOR.NULL_VALUE;
		            }
		            else
		            {
		                rsvBytes ~= cast(ubyte[]) rsvCell.unwrap();
		            }
		            
		            rsvBytes ~= RSV_TERMINATOR.END_OF_VALUE;
		        }
		        
		        rsvBytes ~= RSV_TERMINATOR.END_OF_ROW;
		    }
		    
		    return rsvBytes;
		}
	}
	
	public {
		import std.file : exists, read, write;
		
		/// число табличных строк
		size_t length()
		{
			return cast(size_t) _table.length;
		}
		
		
		/// Загрузить RSV из файла
		auto load(string filepath)
		{
			if (!filepath.exists)
			{
				throw new Exception("File is not exists");
			}
			else
			{
				ubyte[] buffer = cast(ubyte[]) read(filepath);
				
				_table = decodeRSV(buffer);
			}
		}
		
		/// Add new row by index
		RSVRow opIndexAssign(RSVRow row, size_t index)
		{
			_table[index] = row;
			return row;
		}
		
		
		/// Добавить новое значение
		RSVCell opIndexAssign(RSVCell cell, size_t i, size_t j)
		{
			_table[i][j] = cell;
			return cell;
		}
		
		
		/// Достать табличную строку по индексу
		RSVRow opIndex(size_t index)
		{
			return _table[index];
		}
		
		
		/// Обратиться к значению по индексу
		RSVCell opIndex(size_t i, size_t j)
		{
			return _table[i][j];
		}
		
		/// Вернуть все строки
		RSVTable rows()
		{
			return _table;
		}
		
		
		/// Сохранить RSV в файл
		auto save(string filepath)
		{
			if (!filepath.exists)
			{
				throw new Exception("File is not exists");
			}
			else
			{
				ubyte[] buffer = encodeRSV(_table);
				
				write(filepath, buffer);
			}
		}
		
		/// Create new RSV
		this(size_t width = 0, size_t height = 0)
		{
			_table = new RSVCell[][](width, height);
		}
	}
}

Класс RSVFile содержит приватное поле _table, которое хранит таблицу RSV, и перечисление RSV_TERMINATOR, определяющее возможные терминаторы в формате RSV. Также в классе реализованы статические методы:

  • decodeRSV: Декодирует массив байтов в таблицу RSV. Проверяет наличие терминаторов и формирует табличные строки и ячейки;
  • encodeRSV: Кодирует таблицу RSV в массив байтов, добавляя соответствующие завершающие символы.

Помимо этого реализовано несколько публичных методов, упрощающих работу с табличными данными в RSV:

  • length: Возвращает количество строк в таблице.
  • load: Загружает RSV из файла.
  • opIndexAssign: Добавляет новую строку или ячейку по индексу.
  • opIndex: Возвращает строку или ячейку по индексу.
  • rows: Возвращает все табличные строки таблицы.
  • save: Сохраняет таблицу RSV в файл.
  • Конструктор this: Создает новую таблицу RSV с заданными размерами.

Полный код для испытаний прототипа работы с RSV:

module rsv;

import std.stdio;

import optibrev;

public {
	/// Single RSV unit
	alias RSVCell  = Option!string;
	/// One RSV Row
	alias RSVRow   = RSVCell[];
	/// Table of RSV rows
	alias RSVTable = RSVRow[];
}

class RSVFile
{
	private {
		/// All possible delimeters in RSV
		enum RSV_TERMINATOR {
			END_OF_ROW   = 0xFD, // End of RSV row (EOR)
			NULL_VALUE   = 0xFE, // Null value (NUL)
			END_OF_VALUE = 0xFF  // End of value (EOV)
		}
		
		RSVTable _table;
	}
	
	static {
		/// decode byte array to RSV data structure
		RSVTable decodeRSV(ubyte[] rsvBytes) {
			RSVTable rsv;
			RSVRow row;
			
			if ((rsvBytes.length > 0) && (rsvBytes[$-1] != RSV_TERMINATOR.END_OF_ROW))
			{
				throw new Exception("Incomplete RSV document");
			}
			
			ulong startIndex = 0;
			
			Option!string option;
			
			foreach (i, e; rsvBytes)
			{	
				if (e == RSV_TERMINATOR.END_OF_VALUE)
				{
					auto length = i - startIndex;
					if (length == 0)
					{
						row ~= option.Some("");
					}
					else
					{
						if ((length == 1) && (rsvBytes[startIndex] == RSV_TERMINATOR.NULL_VALUE))
						{
							row ~= option.None();
						}
						else
						{
							row ~= option.Some(cast(string) rsvBytes[startIndex..i]);
						}
					}
					startIndex = i + 1;
				}
				else
				{
					if (e == RSV_TERMINATOR.END_OF_ROW)
					{
						if ((i > 0) && (startIndex != i))
						{
							throw new Exception("Incomplete RSV row");
					    }
					    
					    rsv ~= row;
					    row = [];
					    startIndex = i + 1;
				    }
				} 
			}
			
			return rsv;
		}
		
		/// encode RSV
		ubyte[] encodeRSV(RSVTable rsvTable) 
		{
		    ubyte[] rsvBytes;
		    Option!string option;
		    
		    foreach (rsvRow; rsvTable)
		    {    
		        foreach (rsvCell; rsvRow)
		        {
		            if (rsvCell.isNone)
		            {
		                rsvBytes ~= RSV_TERMINATOR.NULL_VALUE;
		            }
		            else
		            {
		                rsvBytes ~= cast(ubyte[]) rsvCell.unwrap();
		            }
		            
		            rsvBytes ~= RSV_TERMINATOR.END_OF_VALUE;
		        }
		        
		        rsvBytes ~= RSV_TERMINATOR.END_OF_ROW;
		    }
		    
		    return rsvBytes;
		}
	}
	
	public {
		import std.file : exists, read, write;
		
		/// Number of RSV rows
		size_t length()
		{
			return cast(size_t) _table.length;
		}
		
		
		/// Load RSV from file
		auto load(string filepath)
		{
			if (!filepath.exists)
			{
				throw new Exception("File is not exists");
			}
			else
			{
				ubyte[] buffer = cast(ubyte[]) read(filepath);
				
				_table = decodeRSV(buffer);
			}
		}
		
		/// Add new row by index
		RSVRow opIndexAssign(RSVRow row, size_t index)
		{
			_table[index] = row;
			return row;
		}
		
		
		/// Add new cell
		RSVCell opIndexAssign(RSVCell cell, size_t i, size_t j)
		{
			_table[i][j] = cell;
			return cell;
		}
		
		
		/// Get row by index
		RSVRow opIndex(size_t index)
		{
			return _table[index];
		}
		
		
		/// Get cell by indices
		RSVCell opIndex(size_t i, size_t j)
		{
			return _table[i][j];
		}
		
		/// Return all rows of RSV
		RSVTable rows()
		{
			return _table;
		}
		
		
		/// Save RSV to file
		auto save(string filepath)
		{
			if (!filepath.exists)
			{
				throw new Exception("File is not exists");
			}
			else
			{
				ubyte[] buffer = encodeRSV(_table);
				
				write(filepath, buffer);
			}
		}
		
		/// Create new RSV
		this(size_t width = 0, size_t height = 0)
		{
			_table = new RSVCell[][](width, height);
		}
	}
}


unittest
{
	ubyte[] exampleRSVBytes = [
		72, 101, 108, 108, 111, 255, 
		240, 159, 140, 142, 255, 
		253,
		253, 
		254, 
		255, 255,
		253
	];
	
	auto a = RSVFile.decodeRSV(exampleRSVBytes);
	assert(a.length > 0, "Декодирование exampleRSVBytes не должно давать пустой результат");
	assert(RSVFile.encodeRSV(a) == exampleRSVBytes, "Кодирование и декодирование должны быть обратимыми операциями");
}

unittest
{
	ubyte[] exampleRSVBytes2 = [
		72, 101, 108, 108, 111, // "Hello"
		255,                    // EOV
		240, 159, 140, 142,     // world emoji
		255,                    // EOV
		255,                    // EOV
		253                     // EOR
	];
	
	auto b = RSVFile.decodeRSV(exampleRSVBytes2);
	assert(b.length > 0, "Декодирование exampleRSVBytes2 не должно давать пустой результат");
	assert(RSVFile.encodeRSV(b) == exampleRSVBytes2, "Кодирование и декодирование должны быть обратимыми операциями");
}


unittest
{
	auto r = new RSVFile;
	assert(r !is null, "Создание нового RSVFile не должно возвращать null");
}


void main()
{
    // Создаем новый RSVFile
    auto rsv = new RSVFile(2, 3);
    
    RSVCell t;


    //// Заполняем данными
    rsv[0, 0] = t.None();
    rsv[0, 1] = t.Some("Возраст");
    rsv[0, 2] = t.Some("Город");

    rsv[1, 0] = t.Some("Иван");
    rsv[1, 1] = t.Some("30");
    rsv[1, 2] = t.Some("Москва");

    // Выводим содержимое RSV
    writeln("Содержимое RSV:");
    foreach (row; rsv.rows)
    {
        foreach (cell; row)
        {
            if (cell.isSome)
            {
                write(cell.unwrap, "\t");
            }
            else
            {
                write("NULL\t");
            }
        }
        writeln();
    }

    // Кодируем RSV в байты
    ubyte[] encoded = RSVFile.encodeRSV(rsv.rows);

    // Декодируем обратно
    auto decoded = RSVFile.decodeRSV(encoded);

    // Проверяем, что декодированные данные совпадают с исходными
    assert(decoded == rsv.rows, "Ошибка: декодированные данные не совпадают с исходными");
    writeln("Кодирование и декодирование прошло успешно!");
}

Результат вывода программы:

Содержимое RSV:
NULL	Возраст	Город	
Иван	30	Москва	
Кодирование и декодирование прошло успешно!Содержимое RSV:

Кроме того, сам прототип содержит подготовленные unittest, основанные на предложенных автором формата примерах. Запустить тестирование можно командами:

ldc2 -unittest rsv
./rsv

Результатом выполнения будет фраза 1 modules passed unittests, которая свидетельствует о том, что все тесты пройдены и реализация соответствует спецификации.

После проведения всех испытаний можно убедиться в простоте реализации и использования формата данных RSV. Очень удобно его применять для хранения табличных значений, но в принципе, это не обязательно могут быть таблицы – сохранять в RSV можно любые данные, поскольку интерпретация их возлагается на программу. Стоит отметить тот факт, что Stenway, автор формата, активно продвигает данный формат и даже создает плагины для некоторых популярных программ, чтобы они поддерживали RSV. К примеру, нам известно о наличии плагинов для LibreOffice, в частности, для LibreOffice Calc. Кроме того, наша реализация данного формата отнюдь не первая, автором формата запущен целый репозиторий на GitHub под названием RSV Challenge в рамках которого любому участнику предлагается поучаствовать в создании парсера формата на любом удобном языке программирования. Как вы понимаете, реализация на D уже есть, и она к сожалению не от LHS, но… Наличие такого репозитория говорит о том, что все необходимое для работы с RSV уже давно есть, и D в этом плане не исключение.

А для чего применили бы вы RSV?

Использованные источники