Привет! Я Кирилл Пронин, разработчик PIX RPA из PIX Robotics. В этой статье расскажу о том, как мы учили наш продукт работать с импортозамещенными табличными редакторами в Linux-подобных операционных системах, кто сильнее - XDocument или XmlDocument, и какие лайфхаки я унес с собой.

Как часто вы задумываетесь о выборе правильного типа данных в своей будущей разработке? Следите ли вы за последними изменениями в работе с XML-документами?

В этой статье расскажу о проблеме, с которой столкнулся в рамках работы по импортозамещению обработчика табличных редакторов офисных приложений в Linux-подобных операционных системах. Я рассказывал об этом на прошедшей конференции DOTNEXT 2024, а теперь решил сделать полноценную статью.

Как в короткие сроки разработать код, который умеет работать со всеми файлами Linux-аналогов Excel? И хотя каждый табличный редактор — это простейший XML, как разработать одну структуру, которая умеет работать со всеми? Правда ли, что XmlDocument — это старый добрый тип, который всегда актуален и до сих пор силен? Или появились новые типы, о которых просто не говорят так много и повсеместно?

Рассмотрим лучшие и худшие стороны XDocument и XmlDocument на практических примерах. Узнаем о лайфхаках использования и обратимся к бенчмаркам.

В PIX мы разрабатываем несколько продуктов, я работаю непосредственно над разработкой PIX Studio - визуальной средой разработки программного робота (RPA). RPA, скажем, больше заточено на визуальное программирование: у нас есть активности, они же действия, которые программный робот может выполнять, и как раз эти действия мы разрабатываем, публикуем, развиваем и т.д. и т.п. И взаимодействие с текстовыми и табличными редакторами – один из самых частых сценариев.

Пришла однажды мне интересная задачка. Научить работать наш кросс-платформенный продукт с офисными приложениями, а точнее с таблицами.

PIX Studio уже работал с таблицами Microsoft Excel, а теперь надо было сделать то же самое, но на другой ОС. Аналог Excel на Linux-подобных операционных системах.

Да, Linux-подобных операционных систем много, и сама суть всей этой прикольной истории в том, что каждый Linux-подобная ОС реализует свой аналог табличного редактора.

Грубо говоря, ситуация такая:

Получается, что нам всего-навсего нужно разработать универсальное решение, которое будет работать в принципе везде, и запускаться везде, и данные будут парситься везде.

Ну что ж, давайте теперь перейдем на путь решения.

Путь первый: API

Первый путь решения, который приходит на ум, это научиться обрабатывать самые распространенные офисные пакеты, создавать провайдеры или движки, брать всю логику, апишечки, потеть над каждым офисным пакетом. То есть сегодня реализовали LibreOffice, потом реализовали Polaris, потом MoйOffice, потом RedOffice, и так далее.

Создаем сейчас шесть штук, потом еще восемь, и впоследствии мы сделаем такой мини-заводик по производству функционала на основе апишечек разных офисных пакетов.

Но есть там огромное НО: каждая апишечка весит по 100 мегабайт плюсом, а ты будешь использовать 5% от нее. Твой проект становится огромным.

И тут подлетает другая проблема:

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

Таким образом, это не лучший вариант решения нашей проблемы. Мы потеряем не только время, но и разработчика. Задача на реализацию поддержки становится подпиской на реализацию этой задачи. Сегодня разработчик реализует 2 пакета, на следующей неделе еще 2. И так далее, пока не закончатся требования клиентов, или офисные пакеты перестанут выходить в свет.

Путь второй: ODF + общий предок

Все офисные пакеты Linux изначально основаны на ODF - это Open Document Format, а значит они между собой немножечко похожи. Надо найти общего предка и общаться универсально по стандартам этого общего предка, чтобы все его понимали. Значит, нужно понять, откуда все пошло, поэтому я прогуглил (универсальный шаг номер 0), и увидел такую вещь как иерархии офисных пакетов, и выглядит она вот так:

Классная ветка метро, мне прям все нравится.

Что же делать? Надо найти что-то найти общее. Соответственно, если долго смотреть, можно понять, что все пришло из Star Office, но тут есть маленькая проблемка.

Star Office — это история прям максимально допотопная, с которой нам будет очень тяжело работать. Чтобы наши современные офисные пакеты начали его понимать, нам придется реализовывать инструмент конвертации из типа стар офиса в какой-то чуть-чуть поновее, и чтобы этот чуть-чуть поновей, уже сам новый наш офисный пакет распознал и понял. 

Но если приглядеться в историю создания и развития, можно понять, что основой всех ветвей является Apache OpenOffice (поддерживается до сих пор, а значит, ее можно назвать современной технологией), плюс к этому мы получаем преемственность решений, так как другие движки могут нас с вами понять, ведь мы общаемся на языке общего предка (как будто версии приложения на единичку ниже), даже Libre Office (отличающийся особенной сложностью) пристроит свое решение под себя. Отлично. Значит, примерно поняли, куда мы двигаемся и что для чего будем разрабатывать.

Подводим первые итоги

Разрабатываем мы для Apache OpenOffice.

Для него нет никакого API.

Отсюда мы автоматически понимаем, что делаем все ручками.

Теперь погрузимся немножко в теорию.

Что такое OpenOffice таблица?

Open Office Calc это тот же самый Excel, который мы видим каждый день, просто немножечко визуально старее. Но технология, терминология, функционал - один в один.

По своей сути это *.ods файл, архив, внутри которого располагаются все наши настройки и данные. А сами данные, хранятся в виде XML файла Content.xml.

Если разархивировать такой файл, увидим такую картину.

Поздравляю, вы нашли данные. Ура!

Что мы делаем?

Мы подрубаем открытие архивного файла, считываем только Content.xml, и в результате получаем XML-код. Ура!

Все офисное ПО базируется на XML

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

Да, если мы с вами каждый день работаем с XML-кой, мы видим чаще всего использование просто элементов и текстового содержимого. Например, так

<book id="bk101">
	<author>Gambardella, Matthew</author>
	<title>XML Developer's Guide</title>
	<genre>Computer</genre>
	<price>44.95</price>
	<publish_date>2000-10-01</publish_date>
	<description>
		An in-depth look at creating applications
		with XML.
	</description>
</book>

В офисном документе эта история выглядит сложнее, все возможности XML в одном месте.

То есть у нас есть и элементы, и атрибуты, и текстовое содержимое, и неймспейсы.

А вот как это выглядит под капотом.

<office:spreadsheet>
	<table:table table:name="Лист1" table:style-name="ta1" table:print="false">
		<table:table-column table:style-name="co1" table:default-cell-style-name="ce2"/>
		<table:table-column table:style-name="co2" table:default-cell-style-name="ce4"/>
		<table:table-column table:style-name="co3" table:default-cell-style-name="ce6"/>
		<table:table-row table:style-name="ro1">
			<table:table-cell table:style-name="Default" table:number-columns-repeated="3"/>
		</table:table-row>
		<table:table-row table:style-name="ro2">
			<table:table-cell table:style-name="ce1" office:value-type="string">
				<text:p>№</text:p>
			</table:table-cell>
			<table:table-cell table:style-name="ce3" office:value-type="string">
				<text:p>Работы/материалы</text:p>
			</table:table-cell>
			<table:table-cell table:style-name="ce3" office:value-type="string">
				<text:p>Стоимость</text:p>
			</table:table-cell>
		</table:table-row>
		...

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

В принципе, мы рекурсивно можем считать и получить значение из ячейки.

Логика примерно ясна, просто это выглядит страшно, а на самом деле ничего страшного нет.

Как обработать XML-формат?

С# предоставляет множество классов, и самый популярный находится в системе XML, он называется XmlDocument.

Почему он популярен? Давайте прогуглим (снова!)!

Найдем миллион статей, чаще всего мы будем видеть XmlReader, XmlDocument.

XmlDocument вообще универсальная история, которая будет с нами всегда, и больше всего примеров Nuget-пакетов реализована на XmlDocument, больше всего примеров в статьях реализована на XmlDocument.

И создается такое впечатление, что XmlDocument настолько популярна история, что на нее можно найти ответ на каждом форуме, а значит она, наверное, и сильнее.

XDocument же как будто не такой популярный и имеет поменьше информации в интернете.

Окей, давайте попробуем в XmlDocument.

Время написать код для будущего обработчика табличек

using ZipArchive zArchive = new(stream);
ZipArchiveEntry? entry = zArchive.GetEntry("content.xml");

// Проверка на правильное считывание файла
if (entry is null)
{
    throw new InvalidOperationException();
}

var streamXML = entry.Open();
XmlDocument doc = new XmlDocument();
doc.Load(streamXML);

XmlNamespaceManager nmsManager = new XmlNamespaceManager(doc.NameTable);

foreach (KeyValuePair<string,string> eachNamespace in _namespaces)
    nmsManager.AddNamespace(eachNamespace.Key, eachNamespace.Value);

Берем путь к файлу, файловый поток, запихиваем zip-архив в стрим.

Зачем? Нам надо разархивировать, и взять только entry point под названием content.xml.

Почему я беру только его?

Главная задача стояла получить значение ячеек, уметь их модифицировать, сохранять.

Когда мы работаем с данными, которые лежат в офисном документе, и нам важно обрабатывать только данные, они лежат непосредственно только в content.xml.

Далее, нам необходимо этот content.xml запихнуть в XmlDocument.

Соответственно, мы создаем класс XmlDocument, передаем через .load информацию, и, поздравляю, XmlDocument у нас заполнен тем, что у нас было в офисном документе.

Подключаем NamespaceManager, заполнив его информацией о названии таблицы, и через цикл мы подключаем каждый namespace к нашему NamespaceManager.

И сразу появляется вопрос, а что это вообще такое namespace для офисного документа?

private static Dictionary<string,string> _namespaces = new Dictionary<string, string>
{
        {"table", "urn:oasis:names:tc:opendocument:xmlns:table:1.0"},
        {"office", "urn:oasis:names:tc:opendocument:xmlns:office:1.0"},
        {"style", "urn:oasis:names:tc:opendocument:xmlns:style:1.0"},
        {"text", "urn:oasis:names:tc:opendocument:xmlns:text:1.0"},
        {"draw", "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"},
        {"fo", "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"},
        {"dc", "http://purl.org/dc/elements/1.1/"},
        {"meta", "urn:oasis:names:tc:opendocument:xmlns:meta:1.0"},
        {"number", "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"},
        {"presentation", "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"},
        {"svg", "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"},
        {"chart", "urn:oasis:names:tc:opendocument:xmlns:chart:1.0"},
        {"dr3d", "urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0"},
        {"math", "http://www.w3.org/1998/Math/MathML"},
        {"form", "urn:oasis:names:tc:opendocument:xmlns:form:1.0"},
        {"script", "urn:oasis:names:tc:opendocument:xmlns:script:1.0"},
        {"ooo", "http://openoffice.org/2004/office"},
        {"ooow", "http://openoffice.org/2004/writer"},
        {"oooc", "http://openoffice.org/2004/calc"},
        {"dom", "http://www.w3.org/2001/xml-events"},
        {"xforms", "http://www.w3.org/2002/xforms"},
        {"xsd", "http://www.w3.org/2001/XMLSchema"},
        {"xsi", "http://www.w3.org/2001/XMLSchema-instance"},
        {"rpt", "http://openoffice.org/2005/report"},
        {"of", "urn:oasis:names:tc:opendocument:xmlns:of:1.2"},
        {"rdfa", "http://docs.oasis-open.org/opendocument/meta/rdfa#"},
        {"config", "urn:oasis:names:tc:opendocument:xmlns:config:1.0"}
};

Namespace - это все структуры, с которыми мы будем работать, грубо говоря, зависимости.

Соответственно, чем больше и богаче данные, которые лежат у нас в таблице, тем больше зависимости на Namespace у них будут.

Изначально, конечно, самые простые зависимости, то есть первые четыре: table, office, style и text.

Для сохранения информации в офисном документе типа таблицы этого достаточно.

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

И заранее говорю, я подключаю все.

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

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

Первый метод, который мы с вами пишем, это GetSheet

private DataTable GetSheet(XmlNode tableNode, XmlNamespaceManager nmsManager)
{
    DataTable sheet = new DataTable(tableNode.Attributes["table:name"].Value);

    XmlNodeList rowNodes = tableNode.SelectNodes("table:table-row", nmsManager);

    int rowIndex = 0;
    foreach (XmlNode rowNode in rowNodes)
        this.GetRow(rowNode, sheet, nmsManager, ref rowIndex);

    return sheet;
}

Соответственно, мы просто-напросто в XML-ноду запихиваем XML-документ, подключаем к нему еще дополнительно NamespaceManager, который мы заранее проинициализировали с помощью добавления неймспейсов.

Cначала создаем DataTable, говорю ему, что у меня нода, у нее есть атрибут table:name, мне нужно ее value.

Далее мы вызываем SelectNode и выбираем table-row.

В цикле foreach вызываем и делаем метод GetRow

private void GetRow(XmlNode rowNode, DataTable sheet, XmlNamespaceManager nmsManager, ref int rowIndex)
{
    XmlAttribute rowsRepeated = rowNode.Attributes["table:number-rows-repeated"];
    if (rowsRepeated == null || int.Parse(rowsRepeated.Value, CultureInfo.InvariantCulture) == 1)
    {
        while (sheet.Rows.Count < rowIndex)
            sheet.Rows.Add(sheet.NewRow());

        DataRow row = sheet.NewRow();

        XmlNodeList cellNodes = rowNode.SelectNodes("table:table-cell", nmsManager);
	int cellIndex = 0;
	foreach (XmlNode cellNode in cellNodes)
	{
    		var value = GetCell(cellNode, row, ref cellIndex);
		...

В GetRow пропихиваем весь лист, чтобы он понимал, где мы находимся и заполнял данные.

NamespaceManager, который мы там тоже будем использовать.

И row.index, чтобы мы не запутались.

Происходит все почти то же самое.

Кроме одного условия, мы запрашиваем список атрибутов у нашей row.node.

И обязательно говорим ему, что нам нужно number row.repeated.

То есть сначала нам надо понять, сколько строчек повторяются.

Уникальность этого решения в том, что офисные пакеты, почти все, которые основаны на Open Document Format, сжимают повторяющиеся значения. То есть, если у нас информация, например, находится где-нибудь в ячейке C3, все пустоты до С3, он будет объединять в узлы. Он не будет говорить вам, что есть ячейка A1, A2, A3, ... - они пустые. Нет. Он объединяет одинаковые значения. В документе будет один узел с названием numberRowRepeated, в котором будет написано, сколько строчек повторяется.

Вот мы дошли до этапа, где у нас есть значение, поняли, что она типа row, в которой есть ячейки, и там информация не повторяется.

Мы в данном случае пишем как раз метод SelectNodes и в нем говорим, что нам нужно.

Да, NamespaceManager мы продолжаем таскать с собой.

GetCell, аналогично предыдущему.

private string GetCell(XmlNode cellNode, DataRow row, ref int cellIndex)
{
    XmlAttribute cellRepeated = cellNode.Attributes["table:number-columns-repeated"];
    string cellValue = this.ReadCellValue(cellNode);
    return cellValue;
}

Мы просто-напросто берем ее атрибуты, чистим информацию по поводу того, сколько у нас колонок повторяется, потому что с ячейками такая же история, как со строками, и далее вызываем метод ReadCellValue по нужной нам ноде, и в нем берем атрибут office:value.

private string ReadCellValue(XmlNode cell)
{
    XmlAttribute cellVal = cell.Attributes["office:value"];

    if (cellVal == null)
        return cell.InnerText;
    else
        return cellVal.Value;
}

Мы добрались до значения!

Теперь вопрос. Вот мы получили значение, а что делать с их модификацией, с их сохранением?

Не нужно дополнительно внутри рассматривать логику, делать дополнительный движок на то, чтобы модифицировать информацию, например, узел удалить, узел вставить, и так далее. Внутри файла документа мы перезаписываем внутренность под названием content.xml.

А потом я вижу XDocument ...

Случайным образом, путем гугления и поиска какое-какой интересной информации, я заметил, что на самом деле существует XDocument.

 И попросил я нейроночку мне помочь: написать добавление узла в ячейку Open Office на основе XDocument? (да, мне пришлось самому доработать, но приятно же вовлечь еще кого-то).

string filePath = "путь_к_файлу.ods";

// Загрузка файла .ods в XDocument
XDocument document = XDocument.Load(filePath);

// Находим таблицу в файле
XElement table = document.Descendants().FirstOrDefault(e => e.Name.LocalName == "table");

// Создаем новую ячейку
XElement newCell = new XElement(XName.Get("table-cell", "urn:oasis:names:tc:opendocument:xmlns:table:1.0"));

// Устанавливаем значение ячейки
newCell.Add(new XElement(XName.Get("p", "urn:oasis:names:tc:opendocument:xmlns:text:1.0"), "Значение ячейки"));

// Добавляем новую ячейку в таблицу
table.Add(newCell);
        
// Сохраняем изменения обратно в файл .ods
document.Save(filePath);
Console.WriteLine("Ячейка успешно добавлена!");

Что получилось? У нас есть некий путь к файлу, который подгружается в XDocument, и потом начинается самое интересное. Новый для меня в .NET по работе с XDocument метод оказался Descendants(), который перечисляет все внутренние узлы. В нашем случае находим только табличные данные и вызываем add, то есть добавляем после него новый узел.

Сохраняем – и ячейка добавлена. Интересно! Давайте покопаемся.

Для начала создадим метод AppendChild, который реализует добавление какого-либо узла внутрь xml.

Как это выглядит с XmlDocument:

public void AppendChildNode_XmlDocument(XmlDocument doc)
{
    // Создание нового элемента
    XmlElement newElement = doc.CreateElement("book");

    // Добавление атрибута к элементу
    newElement.SetAttribute("category", "fiction");

    // Создание и добавление дочерних элементов
    XmlElement titleElement = doc.CreateElement("title");
    titleElement.InnerText = "The Great Gatsby";
    newElement.AppendChild(titleElement);

    // Добавление нового элемента в корневой элемент документа
    doc.DocumentElement.AppendChild(newElement);
}

Как это выглядит с XDocument:

public void AppendChildNode_XDocument(XDocument doc)
{
    // Создание нового элемента
    XElement newElement = new XElement("book",
        new XAttribute("category", "fiction"),
        new XElement("title", "The Great Gatsby"),
        new XElement("author", "F. Scott Fitzgerald")
    );

    // Добавление нового элемента в корневой элемент документа
    doc.Root.Add(newElement);
}

Да это магия в чистом виде! Стало удобно не только понимать и читать что происходит в коде, но и написание этого метода гораздо проще!

Что же такое XDocument?

Это класс в пространстве system.xml.linq. Представляет он xml-документ в виде объектов памяти. В отличие от XmlDocument из пространства имен system.xml, XDocument предоставляет более современные и удобные API для работы с xml в языке С#.

Eсли XmlDocument употреблялся еще с первых версий и языков, то XDocument добавился к нам с .Net Framework 3.5.

Но есть небольшая проблема – когда XDocument только зарелизился, многие разработчики начали его использовать и увидели в нем больше недостатков, чем плюсов по сравнению с XmlDocument. С тех времен мало кто использовал в разработке XDoc (XDocument), из-за этого этот тип данных стал серой лошадкой. И начиная с .NET6/.NET7 он приобрел огромное количество новых методов и расширений, что позволило его еще гибче использовать с LINQ. Начиная с .NET7 – XDoc стал мощнее и интереснее!

Некоторые из достоинств XDocument по сравнению с XmlDocument

1) Более простой и интуитивно понятный синтаксис

2) Удобное добавление и удаление элементов

3) Поддержка пространств имен

4) Использование нескольких файлов и фрагментов

5) Поддержка LINQ-запросов

Многие скажут: наверное, в XDocument проблема с запоминанием пустоты?

Нет! XmlDocument & XDocument помнят абсолютно всю структуру XML-документа. Но…

XmlNode (XmlDocument) работает, так как по-другому не умеет.

XNode (XDocument) дает возможность разработчику выбрать нужный вариант записи.

XDocument doc = XDocument.Load(ms);

doc.Save("SomeFile.xml", SaveOptions.DisableFormatting);

Важно уметь применять это в нужный момент!

Немного про память

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

XDocument использует более эффективный подход к загрузке и обработке XML-документов. Он использует ленивую загрузку, что означает, что он загружает только ту часть XML-документа, которая необходима для выполнения операций, и не загружает весь документ в память сразу.

Но! Если в решении присутствует использование всего XDocument сразу, а не его блочное деление и вызов определенных модулей, то никакой ленивой загрузки не будет. И преимущество окажется недостатком.

Описываем обработчик табличек на XDocument

Давайте попробуем написать то же самое, тот же обработчик табличек, только на другом типе данных.

XDocument doc = XDocument.Load(streamXML);

var reader = doc.CreateReader();
XmlNamespaceManager nmsManager = new XmlNamespaceManager(reader.NameTable);

foreach (KeyValuePair<string, string> eachNamespace in _namespaces)
    nmsManager.AddNamespace(eachNamespace.Key, eachNamespace.Value);

var nodes = doc.XPathSelectElements("/office:document-content/office:body/office:spreadsheet/table:table", nmsManager);
// Больше nmsManager не нужен

foreach (XElement node in nodes)
    GetSheet(node);

Берем вот этот вот стрим.xml, запихиваем его в метод.load. Создаем ему reader. Говорим ему:  давай на NamespaceManager подрубим. (Да, здесь он нам потребуется только для взятия XPath).

Далее с помощью XPathSeleceElements говорим, что нам необходимо получить только table с указанными родителями: как итог мы получим перечисление всех листов.

И то, что мы получили, передаем в новый метод GetSheet.

public static List<string> GetSheet(XElement tableNode)
{
    return tableNode.Elements()
                    .Where(x => x.Name.LocalName == "table-row")
                    .SelectMany(GetRow)
                    .ToList();
}

На вход GetSheet получили XElenent листа. Нам же нужно запросить все его элементы в виде перечисления (Elements()), выбрать только имена с table-row и передать их в метод GetRow.

public static List<string> GetRow(XElement rowNode)
{
    return rowNode.Elements()
                  .Where(x => x.Name.LocalName == "table-cell")
                  .Select(GetCell)
                  .ToList();
}

Внутри GetRow – аналогично, запрашиваем все внутренние элементы, забираем только table-cell, и передаем в GetCell метод

public static string GetCell(XElement cellNode)
{
    string? textValue = cellNode.Elements().FirstOrDefault(x => x.Name.LocalName == "p")?.Value;
    return textValue;
}

Внутри GetCell запрашиваем все элементы внутри ячейки и берем только «p», что означает параграф текста. Запрашиваем его значение и возвращаем.

Поздравляю! Табличный обработчик только для string значений готов.

Какие лайфхаки я могу посоветовать с XDocument?

Какие методы самые-самые для XDocument?

AddAfterSelf(Object)/AddBeforeSelf(Object) - Добавляет содержимое после/до данного узла.

Ancestors() - Возвращает коллекцию элементов-предков узла.

Descendants() - Возвращает коллекцию подчиненных узлов для данного документа или элемента.

Elements() - Возвращает коллекцию дочерних элементов.

IsAfter(XNode)/IsBefore(XNode) - Определяет, предшествует/следует ли текущий узел.

Для XElement:

Attributes() - Возвращает коллекцию атрибутов этого элемента.

Ну что же, мы выяснили, что с XDocument написать код проще и понятнее его прочитать, а он быстрее? Приведем несколько тестов.

Бенчмарк 1 - Load-Append-Remove на простом xml

Допустим, у меня есть библиотека книжек, и там указано около 10 книг по такой структуре

<book id="bk101">
	<author>Gambardella, Matthew</author>
	<title>XML Developer's Guide</title>
	<genre>Computer</genre>
	<price>44.95</price>
	<publish_date>2000-10-01</publish_date>
	<description>
		An in-depth look at creating applications
		with XML.
	</description>
</book>

Проведем тест на основе Load-Append-Remove для XMLDoc и XDoc.

Бенчмарк 2 - Взаимодействие с OpenOffice

Допустим, у меня есть заполненный табличный файл *.ods.

Проведем тест на основе Load-ReadData-Append-Remove для XMLDoc и XDoc.

Бенчмарк 3 - Взаимодействие с КЛАДР

Сделал выгрузку из ГАР (КЛАДР) и взял дельту изменений по одной улице.

Проведем тест на основе Load-Append-Remove для XMLDoc и XDoc.

Пора начинать подводить итоги

Всё в ваших … 

Как мы достигли быстрого выполнения обработки xml? – Попробовали XDocument и он помог в нашем случае.

Как так вышло, что решение работает на OpenOffice, Libre, МойОфис, РедОфис ..... ? – Общаемся на языке общего предка, а при открытии документа новые пакеты все под себя подстроят сами.

А как понять, можно ли внедрять XDocument? – Ориентироваться на опорные точки. Вот они:

1)     Версия .NET

2)     Размер *.xml файлов для обработки

3)     Возможность изменять легаси

Верю, у всех все получится, и иногда переизобретать велосипед – полезно.

А теперь, расскажите вы, уважаемые читатели. Как вы столкнулись с XMLDocument? Какие впечатления он оставил и пробовали ли XDocument? Или же вы суперспециалист, который может добавить то, что я не учел при написании статьи?

Буду рад любой оценке и любым комментариям, давайте покажем, что мир XML все еще хранит тайны и предпосылки к улучшениям.

Комментарии (22)


  1. Dhwtj
    13.02.2025 19:27

    Почти по теме

    Я тут недавно разбирался с типами парсеров XML на разных языках (но чаще в rust), насчитал три типа

    1. DOM-based (Древовидные)

    - Парсеры: DOM, XmlDocument, XDocument

    - Суть: Загружают весь XML-документ в память и представляют его в виде дерева.

    - Плюсы: Удобная работа с узлами, мощные инструменты навигации и модификации структуры.

    - Минусы: Высокое потребление памяти, неэффективен для больших XML-файлов.

    2. Streaming-based (Событийные, включая Pipe-based как подтип)

    - Парсеры: XmlReader, roxmltree (чистый Streaming), serde, xml-rm (Pipe-based)

    - Суть: Потоковая обработка XML по мере его поступления.

    - Pipe-based (часть Streaming-подхода): Вместо работы с сырыми XML-данными, происходит автоматическое преобразование XML листьев в объектные структуры.

    - Плюсы: Малое потребление памяти, высокая производительность, подходит для больших XML-файлов.

    - Минусы: Сложность работы с произвольными узлами, в Pipe-based требуется строго заданная модель данных.

    3. ORM-like (Объектная сериализация)

    - Парсеры: XmlSerializer, serde, xml-rm

    - Суть: XML автоматически сериализуется/десериализуется в объектные структуры классов.

    - Плюсы: Упрощает работу с XML, минимизирует ручное парсирование, удобен для интеграции с объектными моделями.

    - Минусы: Жёсткие требования к структуре XML, сложность в обработке неструктурированных данных.

    Итоговая схема:

    ✅ DOM – для удобного доступа и манипуляции деревом.

    ✅ Streaming (включая Pipe-based) – для потоковой обработки XML, подходит для больших файлов.

    ✅ ORM-like – для работы с объектами и автоматического преобразования XML в классы.


    1. Dhwtj
      13.02.2025 19:27

      ошибочка в списке

      serde (serialize / deserialize) - объектная сериализация - попал в оба списка...

      и Streaming называют еще pull parser


  1. Dhwtj
    13.02.2025 19:27

    Кстати, как .net у вас совмещается с импортозамещением? У нас из-за него все .net проекты на стоп.


    1. Heggi
      13.02.2025 19:27

      А в чем проблема? Net Core он опенсорс, можно исходники с гитхаба скачать и самому рантайм собрать, если что


      1. Dhwtj
        13.02.2025 19:27

        Министерских не убедить...

        Лично я учу Раст

        C# вещь хорошая, конечно...


        1. kirillka18 Автор
          13.02.2025 19:27

          Согласен с Вами, есть "особенности внедрения". У нас особенности проекта и продукта таковы, что министерских внедрений почти нет.
          Зато убедить банковский сектор или крупные компании один раз установить .Net в закрытый контур, выдать выгрузку о зависимых пакетах и скрин проверки антивирусов - даст положительный результат при внедрении.


    1. Vitimbo
      13.02.2025 19:27

      Единственное, что отсутствует, так это нормальные ide, которые были бы бесплатные или которые можно купить. Но на ide у нас закрыли глаза настолько, что можно поднять пиратский флаг (если никто не видит), а docker, nuget и npm мы развернули собственные.

      Сами импортозамещаем всякое.


      1. Drazd
        13.02.2025 19:27

        Примерно с осени 2024 года, почти сразу с "запретом на покупку из России", Rider "внезапно" стал доступен в режиме Community, что позволяет с удобством разрабатывать на C# под любой отечественной ОС.

        Те, для кого он перегружен - вполне довольны VS Code, который так же везде очень хорошо работает.

        Можете поделиться чем вас не устраивают данные IDE?


        1. Vitimbo
          13.02.2025 19:27

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

          VsCode же ИМХО очень хорош, когда надо поправить какой-то конфиг или поковырять js, но в большом проекте ему очень далеко по удобству до студии или Rider. Лично мне в нем работать весьма неудобно.

          Вот и получается, что легальную ide не особо купишь, vscode не имеет всех плюшек даже студии, а бесплатные альтернативы уже давно вымерли.


    1. Drazd
      13.02.2025 19:27

      Прекрасно совмещается с импортозамещением. Не совмещается только устаревший .NET Framework, а вот нормальный .NET Core вполне себе внедряется во всю.

      Прелесть .NET в том, что он с открытым исходным кодом и для нужного спокойствия можно даже собрать его из исходников, хотя по факту всех ИБ-шников с кем лично мне приходилось взаимодействовать это не беспокоило, брали все из наших поставок.

      Единственное, что обычно беспокоит ИБшников - поддержка последних версий. То етсь устаревший .NET 5 уже правда не вкатить, да и .NET 7 уже вышел из поддержки. Поэтому требуют LTS-ные версии 6 или 8. Это немного усложняет жизнь, так как приходится постоянно обновлять версию фреймворка и тратить ресурсы на обеспечение совместимости с новыми пакетами, но это так, житейские проблемы.

      Местами встречаются инциденты, когда не хотят ставить отдельно .NET Runtime, но в этом плане хорошо спасает возможность собрать автономную сборку, когда все нужные системные библиотеки встраиваются в релизную сборку. Тогда продуктами можно пользоваться на компьютерах пользователей как нативные без доустановки .NET.


    1. grebenval
      13.02.2025 19:27

      .NET Core проходит сертификацию ФСТЭК https://fstec.ru/gniii-ptzi-fstek-rossii/deyatelnost-v-oblasti-it

      А импортозамещение - соблюдайте требования к лицензиям ОС и используемых библиотек и т.д. требования на сайте, там же регистрируйте https://reestr.digital.gov.ru/


  1. Dhwtj
    13.02.2025 19:27

    А почему вы не используете epplus?

    Осень богатый пакет для Excel и в XML потроха лезть не надо.


    1. nightwolf_du
      13.02.2025 19:27

      А ещё раньше он был коммерческий и осень дорогой


      1. Dhwtj
        13.02.2025 19:27

        Старая версия вроде бесплатная.

        Короче, мы пользуемся


        1. kirillka18 Автор
          13.02.2025 19:27

          Рад, что есть определенные ходы и варианты внедрения, однако у нас есть особенности, например, важно уметь менять стили ячеек документов в разных пакетах, однако, старая версия не поддерживает этого.
          А новая - платная и будут огромные проблемы при распространении и внедрении продукта, т к тип лицензии EPPLUS будет динамический по внедрениям. Что не есть хорошо в современном мире.


          1. Dhwtj
            13.02.2025 19:27

            Выбора у нас и нет: нужны работающие формулы которые динамически пересчитают изменения. Только epplus это умеет


  1. shai_hulud
    13.02.2025 19:27

    Если все помещается в память, то почему не https://github.com/dotnet/Open-XML-SDK?


  1. Dhwtj
    13.02.2025 19:27

    в общем, я подумал, попробовал все типы парсеров

    и решил остаться на pull parser

    Pull parser — это подход к анализу XML-документов, основанный на событиях. В отличие от DOM-парсера, который загружает весь документ в память, или SAX-парсера, который отправляет события приложению, Pull parser позволяет приложению запрашивать (или «вытягивать») события анализа по необходимости.

    пробовал DOM парсеры, с красивым синтаксисом

    но уж очень легко ошибиться и не получить ничего

    пример

    так работает

    /// Разбирает XML-содержимое листа Excel и возвращает структурированные данные таблицы
    fn parse_worksheet(
        xml_content: &str,
        shared_strings: &[String],      // Общие строки документа
        merge_map: &HashMap<String, (i32, i32)>,  // Карта объединенных ячеек (ссылка -> (colspan, rowspan))
        styles_map: &HashMap<String, i32>         // Карта стилей (id стиля -> значение поворота)
    ) -> TableData {
        // Создаем XML парсер из входной строки
        let parser = EventReader::from_str(xml_content);
    
        // Инициализируем структуры для хранения данных
        let mut table_data = TableData { table_rows: Vec::new() };
        let mut current_row = RowData { row_cells: Vec::new() };
    
        // Переменные для хранения текущего состояния ячейки
        let mut current_cell_ref = String::new();      // Ссылка на ячейку (например, "A1")
        let mut current_cell_style = String::new();    // ID стиля ячейки
        let mut current_cell_type = String::new();     // Тип данных в ячейке
        let mut current_cell_value = String::new();    // Значение ячейки
        let mut in_value = false;                       // Флаг: находимся ли внутри тега значения
    
        // Обрабатываем каждое событие XML
        for event in parser {
            match event {
                // Обработка начальных тегов
                Ok(XmlEvent::StartElement { name, attributes, .. }) => {
                    match name.local_name.as_str() {
                        // Начало новой строки
                        "row" => {
                            current_row = RowData { row_cells: Vec::new() };
                        },
                        // Начало новой ячейки
                        "c" => {
                            // Извлекаем атрибуты ячейки
                            current_cell_ref = attributes.iter()
                                .find(|attr| attr.name.local_name == "r")
                                .map_or(String::new(), |attr| attr.value.clone());
    
                            current_cell_style = attributes.iter()
                                .find(|attr| attr.name.local_name == "s")
                                .map_or(String::new(), |attr| attr.value.clone());
    
                            current_cell_type = attributes.iter()
                                .find(|attr| attr.name.local_name == "t")
                                .map_or(String::new(), |attr| attr.value.clone());
    
                            current_cell_value = String::new();
                        },
                        // Начало тега значения
                        "v" => {
                            in_value = true;
                        },
                        _ => {}
                    }
                },
                // Обработка закрывающих тегов
                Ok(XmlEvent::EndElement { name }) => {
                    match name.local_name.as_str() {
                        // Конец строки
                        "row" => {
                            if !current_row.row_cells.is_empty() {
                                table_data.table_rows.push(current_row.clone());
                            }
                        },
                        // Конец ячейки
                        "c" => {
                            // Обработка значения в зависимости от типа ячейки
                            let value = if current_cell_type == "s" {
                                // Если тип "s", значение - это индекс в shared_strings
                                shared_strings.get(current_cell_value.parse::<usize>().unwrap_or(0))
                                    .cloned()
                                    .unwrap_or_default()
                            } else {
                                current_cell_value.clone()
                            };
    
                            // Получаем информацию об объединении ячеек
                            let (colspan, rowspan) = merge_map
                                .get(&current_cell_ref)
                                .copied()
                                .unwrap_or((1, 1));
    
                            // Получаем значение поворота из стилей
                            let rotation = if !current_cell_style.is_empty() {
                                styles_map.get(&current_cell_style).copied().unwrap_or(0)
                            } else {
                                0
                            };
    
                            // Создаем объект ячейки
                            let cell = CellData {
                                cell_id: current_cell_ref.clone(),
                                cell_value: value,
                                cell_colspan: colspan,
                                cell_rowspan: rowspan,
                                is_merged: colspan > 1 || rowspan > 1,
                                rotation,
                            };
    
                            // Добавляем ячейку только если она имеет допустимые размеры ???
                            if colspan > 0 && rowspan > 0 {
                                current_row.row_cells.push(cell);
                            }
                        },
                        // Конец тега значения
                        "v" => {
                            in_value = false;
                        },
                        _ => {}
                    }
                },
                // Обработка текстового содержимого внутри тега значения
                Ok(XmlEvent::Characters(text)) if in_value => {
                    current_cell_value = text;
                },
                _ => {}
            }
        }
    
        table_data
    }
    

    а так - нет
    хотя вроде должен

    // Структура для ячейки в XML.
    #[derive(Debug, Deserialize, PartialEq)]
    pub struct XmlCell {
        #[serde(rename = "r")]
        pub(crate) cell_id: String, // Атрибут "r" в теге <c> (например, "A1").
        #[serde(rename = "s", default)]
        pub(crate) style_id: String, // Атрибут "s" для стиля ячейки.
        #[serde(rename = "t", default)]
        pub(crate) cell_type: String, // Атрибут "t" для типа ячейки.
        #[serde(rename = "hidden", default)]
        hidden: bool, // Атрибут hidden для ячейки (по умолчанию false).
        #[serde(rename = "v")]
        pub(crate) value: Option<String>, // Тег <v> для значения ячейки.
    }
    
    // Структура для строки в XML.
    #[derive(Debug, Deserialize, PartialEq)]
    pub struct XmlRow {
        #[serde(rename = "c", default)]
        pub(crate) cells: Vec<XmlCell>, // Список ячеек в строке.
        #[serde(rename = "hidden", default)]
        pub(crate) hidden: bool, // Атрибут hidden для строки (по умолчанию false).
    }
    
    // Структура для столбца в XML.
    #[derive(Debug, Deserialize, PartialEq)]
    pub struct XmlCol {
        #[serde(rename = "hidden", default)]
        hidden: bool, // Атрибут hidden для столбца (по умолчанию false).
    }
    
    // Структура для всего листа в XML.
    #[derive(Debug, Deserialize, PartialEq)]
    pub struct XmlWorksheet {
        #[serde(rename = "row", default)]
        pub(crate) rows: Vec<XmlRow>, // Список строк.
        #[serde(rename = "col", default)]
        pub(crate) cols: Vec<XmlCol>, // Список столбцов.
    }
    
    pub fn parse_worksheet(
        xml_content: &str,
        shared_strings: &[String],                  // Общие строки документа
        merge_map: &HashMap<String, (i32, i32)>,    // Карта объединенных ячеек (ссылка -> (colspan, rowspan))
        styles_map: &HashMap<String, i32>           // Карта стилей (id стиля -> значение поворота)
    ) -> TableData {
        let xml_worksheet: XmlWorksheet = from_reader(xml_content.as_bytes()).unwrap();
    
        let mut table_data = TableData {
            table_rows: Vec::new(),
            table_cols: vec![ColData { hidden: false }; xml_worksheet.cols.len()],
        };
    
        // Обрабатываем строки и ячейки.
        for xml_row in xml_worksheet.rows {
            let mut row_data = RowData {
                row_cells: Vec::new(),
                hidden: xml_row.hidden,
            };
    
            for xml_cell in xml_row.cells {
                let mut cell_value = xml_cell.value.clone().unwrap_or_default();
                let cell_colspan = merge_map
                    .get(&xml_cell.cell_id)
                    .map_or(1, |(colspan, _)| *colspan);
                let cell_rowspan = merge_map
                    .get(&xml_cell.cell_id)
                    .map_or(1, |(_, rowspan)| *rowspan);
    
                // If the cell type is a shared string, resolve it
                if xml_cell.cell_type == "s" {
                    if let Some(idx) = cell_value.parse::<usize>().ok() {
                        cell_value = shared_strings.get(idx).cloned().unwrap_or_default();
                    }
                }
    
                // Map XMLCell to CellData, с добавлением логики merge and rotation
                let cell_data = CellData {
                    cell_id: xml_cell.cell_id,
                    cell_value,
                    cell_colspan,
                    cell_rowspan,
                    is_merged: cell_colspan > 1 || cell_rowspan > 1,
                    rotation: styles_map.get(&xml_cell.style_id).copied().unwrap_or(0),
                };
    
                row_data.row_cells.push(cell_data);
            }
    
            table_data.table_rows.push(row_data);
        }
    
        table_data
    }


    1. Dhwtj
      13.02.2025 19:27

      И главное, хрен пойми почему не работает парсер с мапингом в объект.

      Не тестируется нормально...


  1. starfair
    13.02.2025 19:27

    Изивните, а какое это имеет отношение к перечисленным импортозамезаемым, или же просто к опен соурсным офисным пакетам? То что вы пишите - прямая работа с документом и его структурами. И что с того? Как это позволит использовать конкретно ваш продукт PIX для взаимодействия с тем же Р7? Вопрос не праздный, так как мы пытались использовать ваш продукт для тестирования наших плагинов под Р7. И не сказал бы, что очень успешно.


    1. Drazd
      13.02.2025 19:27

      А зачем вообще привязываться к конкретным офисным пакетам? Если библиотека позволяет сделать так, чтобы документ был отредактирован таким образом, что его корректное отображение будет в большинстве редакторов (в том числе в МойОфис, ОпенОфисе итд) - это же лучше.

      Интересует как вы пытались использовать продукт для тестирования ваших плагинов, поделитесь, очень интересно!


  1. grebenval
    13.02.2025 19:27

    Ограничились стандартом Microsoft Office XML formats, т.е. для Excel

    Использование DOM модели (как и всевозможных библиотек с данной моделью), приводит к большому потреблению памяти, для больших файлов так и к резкому замедлению обработки.

    Пока не появились библиотеки https://github.com/dotnet/Open-XML-SDK, использовали XmlReader/XmlWriter - дорого, объёмно в разработке, но самый скоростной по времени чтения/модификации/созданию Excel файла при минимальном потреблении памяти.

    С использованием Open-XML-SDK механизм тот же, но писать быстрее и дешевле, и надежней т.к. есть готовые классы из xsd. Несколько медленнее обработка файла, но это проценты, ну и чуть выше потребление памяти, при таком подходе удобство и скорость разработки выше на порядок.