Статьи

Разделение больших файлов XML в Java

На прошлой неделе меня попросили написать что-то на Java, способное разбить один XML-файл размером 30 ГБ на более мелкие части настраиваемого размера файла. Потребителем файла будет промежуточное приложение, которое имеет проблемы с большим размером XML. Под капотом он использует какую-то технику синтаксического анализа DOM, из-за которой через некоторое время ему не хватает памяти. Так как это промежуточное ПО, основанное на поставщиках, мы не можем исправить это сами. Нашим лучшим вариантом является создание некоторого инструмента предварительной обработки, который сначала разбивает большой файл на несколько более мелких кусков, прежде чем они будут обработаны промежуточным программным обеспечением.

Файл XML поставляется с соответствующей схемой W3C, состоящей из обязательной части заголовка, за которой следует элемент содержимого, в который вложено несколько элементов данных 0 .. *. Для демонстрационного кода я заново создал схему в упрощенном виде:


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

  • Каждая часть разделенного XML должна быть синтаксически допустимым XML, и каждая часть также должна проверяться на соответствие исходной схеме
  • Инструмент должен проверять XML в соответствии со схемой и сообщать о любых ошибках валидации. Валидация не должна быть блокирующей, а не валидирующие элементы или атрибуты не должны быть пропущены в выходных данных.
  • Для заголовка решено, что вместо того, чтобы копировать его в каждый из новых выходных файлов, заголовок будет сгенерирован заново для каждого нового выходного файла с некоторой информацией об обработке и некоторыми значениями по умолчанию

Таким образом, об использовании инструментов бинарного разделения, таких как Unix Split, не может быть и речи. Это разделится после фиксированного количества байтов, оставляя XML поврежденным наверняка. Я не совсем уверен, но такие инструменты, как Split, также ничего не знают о кодировании. Таким образом, разделение после байта «x» может привести не только к разделению в середине элемента XML (например), но даже в середине последовательности кодирования символов (при использовании Unicode, который, например, кодируется UTF8). Понятно, что нам нужно что-то более умное.

XSLT как основная технология тоже не годится. С первого взгляда можно поддаться искушению: используя XSLT2.0, можно создать несколько выходных файлов из одного входного файла. Должна быть возможность проверки входного файла при преобразовании. Однако дьявол, как всегда, в деталях. В противном случае простые операции в Java, такие как запись ошибок проверки в отдельный файл или проверка размера текущего выходного файла, вероятно, потребуют пользовательский код Java. Это возможно с Xalan и Saxon, чтобы иметь такие расширения, но Xalan не является реализацией XSLT2.0, поэтому оставляет нас с Saxon. И последнее, но не менее важное: XSLT1.0 / 2.0 не являются потоковыми, что означает, что они будут считывать весь исходный документ в память, поэтому это явно исключает XSLT из возможностей.

Это оставляет нам синтаксический анализ Java XML в качестве единственного оставшегося варианта. Идеальным кандидатом в этом случае является, конечно, StAX. Я не буду здесь сравнивать SAX с StAX, факт в том, что StAX может проверять на соответствие схемам (по крайней мере, некоторые парсеры), а также может писать XML. Более того, API намного проще в использовании, чем SAX, потому что он основан на извлечении, он дает больший контроль над итерацией документа и работает более приятным, чем толчок SAX. Хорошо, что нам нужно:

  • Реализация StAX, способная проверять XML

    • Oracle JDK поставляется по умолчанию с SJSXP в качестве реализации StAX, но этот, однако, не проверяется; так что я закончил с использованием Woodstox. Насколько я мог найти, проверка с помощью Woodstox возможна только с использованием API курсора StAX.
  • Желательно иметь некоторую технику отображения объектов / XML для (повторного) создания заголовка вместо ручной работы с элементами и необходимости искать правильные типы данных / формат

    • Ясно JAXB. Он поддерживает StAX, так что вы можете создать свою объектную модель и затем позволить ей напрямую записывать в выходной поток StAX.

Код немного велик, чтобы показать его здесь в целом. Обе исходные файлы, XSD и тестовый XML доступны здесь, на GitHub. В нем есть файл pom Maven, поэтому вы сможете импортировать его в выбранную вами среду IDE. Компилятор привязки JAXB автоматически скомпилирует схему и поместит сгенерированные источники в путь к классам.

public void startSplitting() throws Exception {
 XMLStreamReader2 xmlStreamReader = ((XMLInputFactory2) XMLInputFactory.newInstance())
   .createXMLStreamReader(BigXmlTest.class.getResource("/BigXmlTest.xml"));
 PrintWriter validationResults = enableValidationHandling(xmlStreamReader);

 int fileNumber = 0;
 int dataRepetitions = 0;
 XMLStreamWriter xmlStreamWriter = openOutputFileAndWriteHeader(++fileNumber); // Prepare first file

Первая строка создает наш потоковый ридер StAX, что означает, что мы используем курсор API. API итератора использует класс XMLEventReader. В имени класса также есть странная цифра «2», которая относится к функциям StAX 2 от Woodstox, одной из которых, вероятно, является поддержка валидации. От сюда :


StAX2 — это экспериментальный API, который предназначен для расширения базовых спецификаций StAX таким образом, чтобы реализации могли экспериментировать с функциями, прежде чем они попадут в настоящую спецификацию StAX (если они это сделают).
Как таковой, он предназначен для свободной реализации всеми реализациями StAX так же, как StAX, но без прохождения формального процесса JCP. В настоящее время Woodstox является единственной известной реализацией.

«enableValidationHandling» можно увидеть в исходном файле, если хотите. Я выделю важные части. Сначала загрузите схему XML:

XMLValidationSchema xmlValidationSchema = xmlValidationSchemaFactory.createSchema(BigXmlTest.class
  .getResource("/BigXmlTest.xsd"));

Обратный вызов для записи возможных результатов проверки в выходной файл;

public void reportProblem(XMLValidationProblem validationError) throws XMLValidationException {
 validationResults.write(validationError.getMessage()
   + "Location:"
   + ToStringBuilder.reflectionToString(validationError.getLocation(),
     ToStringStyle.SHORT_PREFIX_STYLE) + "\r\n");
}

«OpenOutputFileAndWriteHeader» создаст XMLStreamWriter (который снова является частью API курсора, API итератора имеет XMLEventWriter), в который мы можем вывести или часть исходного файла XML. Он также будет использовать JAXB для создания нашего заголовка и позволит ему записать в вывод. Объекты JAXB генерируются по умолчанию с помощью компилятора Schema (xjc).

private XMLStreamWriter openOutputFileAndWriteHeader(int fileNumber) throws Exception {
 XMLOutputFactory xmlOutputFactory = XMLOutputFactory.newInstance();
 xmlOutputFactory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true);
 XMLStreamWriter writer = xmlOutputFactory.createXMLStreamWriter(new FileOutputStream(new File(System
   .getProperty("java.io.tmpdir"), "BigXmlTest." + fileNumber + ".xml")));
 writer.setDefaultNamespace(DOCUMENT_NS);
 writer.writeStartDocument();
 writer.writeStartElement(DOCUMENT_NS, BIGXMLTEST_ROOT_ELEMENT);
 writer.writeDefaultNamespace(DOCUMENT_NS);

 HeaderType header = objectFactory.createHeaderType();
 header.setSomeHeaderElement("Something something darkside");
 marshaller.marshal(new JAXBElement<HeaderType>(new QName(DOCUMENT_NS, HEADER_ELEMENT, ""), HeaderType.class,
   HeaderType.class, header), writer);

 writer.writeStartElement(CONTENT_ELEMENT);
 return writer;
}

В строке 3 мы включаем «восстановление пространств имен». В спецификации есть это, чтобы сказать:


javax.xml.stream.isRepairingNamespaces: Функция: Создает префиксы по умолчанию и связывает их с URI пространства имен.
Тип: Boolean Значение по умолчанию: False Требуется: Да

Из этого я понимаю, что он необходим для обработки пространств имен по умолчанию. Дело в том, что если оно не включено, пространство имен по умолчанию не записывается никоим образом. В строке 6 мы устанавливаем пространство имен по умолчанию. Установка его на самом деле не записывает его в поток. Следовательно, требуется writeDefaultNamespace (строка 9), но это можно сделать только после того, как начальный элемент был записан. Таким образом, вы должны определить пространство имен по умолчанию перед записью каких-либо элементов, но вам нужно написать пространство имен по умолчанию после записи первого элемента. Обоснование заключается в том, что StAX нужно знать, должен ли он генерировать префикс для корневого элемента, который вы собираетесь написать, да или нет.

В строке 8 мы пишем корневой элемент. Важно указать, к какому пространству имен принадлежит этот элемент. Если вы не укажете префикс, префикс будет сгенерирован для вас, или, в нашем случае, префикс вообще не будет сгенерирован, потому что StAX знает, что мы уже установили пространство имен по умолчанию. Если вы удалите указание пространства имен по умолчанию в строке 6, к корневому элементу будет добавлен префикс (со случайным префиксом), например: <wstxns1: BigXmlTest xmlns: wstxns1 = «http: // www … Далее мы напишем наше значение по умолчанию namespace, это будет записано в элемент, начатый ранее (кстати, для более глубокого понимания этого порядка см. эту прекрасную статью ). В строке 11-14 мы используем нашу сгенерированную модель JAXB, чтобы создать заголовок и позволить нашему маршаллеру JAXB написать его напрямую в наш поток вывода StAX.

Важно: маршаллер JAXB инициализируется в режиме фрагмента, в противном случае он начнет добавлять декларацию XML, как это потребуется для автономных документов, и это, конечно, недопустимо в середине существующего документа:

marshaller.setProperty(Marshaller.JAXB_FRAGMENT, true);

Напомним, что интеграция JAXB в данном примере не очень полезна, она создает больше сложности и требует больше строк кода, чем простое добавление элементов с использованием XMLStreamWriter. Однако, если у вас есть более сложная структура, которую вам нужно создать и объединить с документом, очень удобно иметь автоматическое сопоставление объектов.

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

while (xmlStreamReader.hasNext()) {
    xmlStreamReader.next();

    if (xmlStreamReader.getEventType() == XMLEvent.START_ELEMENT
      && xmlStreamReader.getLocalName().equals(DATA_ELEMENT)) {

      if (dataRepetitions != 0 && dataRepetitions % 2 == 0) { // %2 = just for testing: replace this by for example checking the actual size of the current output file
       xmlStreamWriter.close(); // Also closes any open Element(s) and the document
       xmlStreamWriter = openOutputFileAndWriteHeader(++fileNumber); // Continue with next file
       dataRepetitions = 0;
      }
      // Transform the input stream at current position to the output stream
 transformer.transform(new StAXSource(xmlStreamReader), new StAXResult(
 new FragmentXMLStreamWriterWrapper(new AvoidDefaultNsPrefixStreamWriterWrapper(xmlStreamWriter, DOCUMENT_NS))));
      dataRepetitions++;
    }
}

Важным моментом является то, что мы продолжаем перебирать исходный документ и проверяем наличие начала элемента Data. Если это так, мы направляем соответствующий элемент и его элементы на выход. В нашем простом примере у нас нет братьев и сестер, только текстовое значение. Но если структура более сложная, все нижележащие узлы будут автоматически скопированы в выходные данные. Каждые два элемента данных мы будем циклически повторять наш выходной файл Модуль записи закрывается и инициализируется новый (эту проверку, конечно, можно заменить проверкой размера файла вместо% 2). Если средство записи закрыто, оно автоматически позаботится о закрытии открытых элементов и, наконец, о закрытии самого документа, нет необходимости делать это самостоятельно. Что касается механизма для потоковой передачи узлов от входа к выходу:

  • Поскольку мы вынуждены использовать API курсора из-за проверки, мы должны использовать XSLT для передачи узла и его элементов в выходные данные. XSLT имеет несколько шаблонов по умолчанию, которые будут вызываться, если вы не укажете XSL специально. В этом случае он преобразует входной сигнал в заданный выходной.
  • Необходим пользовательский FragmentXMLStreamWriterWrapper , я задокументировал это в JavaDoc. Эта оболочка снова обернута в AvoidDefaultNsPrefixStreamWriterWrapper . Причина последнего заключается в том, что шаблон XSLT по умолчанию не распознает пространство имен по умолчанию в нашем исходном документе. Подробнее об этом через минуту (или поиск AvoidDefaultNsPrefixStreamWriterWrapper).
  • Используемый вами преобразователь должен быть внутренней версией Oracle JDK. Там, где мы инициализируем преобразователь, мы непосредственно ссылаемся на экземпляр внутреннего TransformerFactory: com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl, который затем создает правильный преобразователь: transformer = new TransformerFactoryImpl (). NewTransformer ();Обычно вы будете использовать TransformerFactory.newInstance () и использовать преобразователь, доступный в пути к классам. Однако парсеры и преобразователи могут устанавливать себя, предоставляя сервисы META-INF /. Если другой преобразователь (например, Xalan по умолчанию, а не перепакованная версия JDK) окажется в пути к классам, преобразование завершится неудачно. Причина в том, что, по-видимому, только внутренняя версия JDK может преобразовывать StAXSource в StAXResult.
  • Преобразователь фактически позволяет нашему XMLStreamReader продолжать работу в процессе итерации. Таким образом, после обработки элемента данных курсор считывателя теоретически будет готов к следующему элементу данных. В теории это так, поскольку следующий тип события может быть пробелом, если ваш XML отформатирован. Таким образом, все еще могут потребоваться некоторые итерации для xmlStreamReader.next () в нашем цикле while, прежде чем следующий элемент Data будет фактически готов.

В результате мы имеем 3 выходных файла, каждый из которых соответствует исходной схеме, каждый из которых имеет 2 элемента данных:

Чтобы разделить XML-файл размером ~ 30 ГБ (я говорю о моем исходном XML-документе назначения с более сложной структурой, а не о демонстрационном XSD, используемом здесь) на части ~ 500 МБ с проверкой, это заняло около 25 минут. Чтобы проверить использование памяти, я намеренно установил Xmx на 32 МБ. Как вы можете видеть на графике, потребление памяти очень низкое и нет накладных расходов GV:

Жизнь хороша, но не полностью. Там, где я обнаружил некоторые неловкие вещи, о которых нужно быть осторожным.

В моем реальном сценарии входной XML не имел связанных с ним пространств имен, и я уверен, что никогда не будет. Вот почему я придерживаюсь этого решения. В демоверсии здесь есть единственное пространство имен, и это уже начинает делать настройку более хрупкой. Проблема не в StAX: обрабатывать пространства имен с помощью StAX довольно просто. Вы можете решить использовать пространство имен по умолчанию (при условии, что ваша Схема имеет значение elementFormDefault = qualified), соответствующее целевому пространству имен Схемы, и, возможно, объявить некоторые префиксные пространства имен для, возможно, других пространств имен, которые импортируются в Схеме. Проблемы начинаются (как вы уже могли заметить), когда XSLT начинает мешать выходному потоку. Очевидно, он не проверяет, какие пространства имен уже определены или что-то еще происходит.

В результате они серьезно загромождают документ, переопределяя существующие пространства имен с другими префиксами или сбрасывая пространство имен по умолчанию и другие вещи, которые вам не нужны. Вероятно, нужен XSL, если вам нужно больше манипулировать пространством имен, чем шаблон по умолчанию. XSLT также будет вызывать исключения, если во входном документе используются пространства имен по умолчанию. Он попытается зарегистрировать префикс с именем «xmlns». Это недопустимо, поскольку xmlns зарезервировано для указания пространства имен по умолчанию, которое нельзя использовать в качестве префикса. Исправление, которое я применил для этого теста, заключалось в том, чтобы игнорировать любой префикс, который является «xmlns», и игнорировать добавление целевого пространства имен в сочетании с префиксом xmlns (именно поэтому у нас есть AvoidDefaultNsPrefixStreamWriterWrapper).Префикс и пространство имен должны совпадать в AvoidDefaultNsPrefixStreamWriterWrapper, потому что если у вас будет входной документ без пространства имен по умолчанию, но с префиксами (например, <bigxml: BigXmlTest xmlns: bigxml = «http: // ….»> <bigxml : Header ….) тогда вы не можете игнорировать добавление пространства имен (тогда комбинация будет целевым пространством имен с префиксом «bigxml»), поскольку это даст только префиксы для элементов данных без привязки к ним пространств имен, например:префикс), поскольку это даст только префиксы для элементов данных без привязки к ним пространств имен, например:префикс), поскольку это даст только префиксы для элементов данных без привязки к ним пространств имен, например:

<?xml version='1.0' encoding='UTF-8'?>
<BigXmlTest xmlns="http://www.error.be/bigxmltest">
 <Header>
  <SomeHeaderElement>Something something darkside</SomeHeaderElement>
 </Header>
 <Content>
  <bigxml:Data>Data1</bigxml:Data>
  <bigxml:Data>Data2</bigxml:Data>
 </Content>
</BigXmlTest>

Помните, что производитель XML свободен (опять же в случае elementFormDefault = qualified), чтобы выбрать, использовать ли пространство имен по умолчанию или префикс каждого элемента. Код должен прозрачно работать с обоими сценариями. Код AvoidDefaultNsPrefixStreamWriterWrapper для удобства:

public class AvoidDefaultNsPrefixStreamWriterWrapper extends XMLStreamWriterAdapter {
...

 @Override
 public void writeNamespace(String prefix, String namespaceURI) throws XMLStreamException {
  if (defaultNs.equals(namespaceURI) && "xmlns".equals(prefix)) {
   return;
  }
  super.writeNamespace(prefix, namespaceURI);
 }

 @Override
 public void setPrefix(String prefix, String uri) throws XMLStreamException {
  if (prefix.equals("xmlns")) {
   return;
  }
  super.setPrefix(prefix, uri);
 }

Наконец, я также написал версию (нажмите здесь для GitHub), которая делает то же самое, но на этот раз с API-интерфейсом StAX. Вы заметите, что больше нет громоздкого XSLT, необходимого для потоковой передачи на выход. Каждое интересующее событие просто добавляется к выводу. Отсутствие проверки можно решить, сначала проверив ввод с помощью API курсора, а затем проанализировав его с помощью API Iterator. Это займет больше времени, но это может быть приемлемым в большинстве случаев. Самый важный кусок:

while (xmlEventReader.hasNext()) {
   XMLEvent event = xmlEventReader.nextEvent();

   if (event.isStartElement() && event.asStartElement().getName().getLocalPart().equals(CONTENT_ELEMENT)) {
    event = xmlEventReader.nextEvent();

    while (!(event.isEndElement() && event.asEndElement().getName().getLocalPart()
      .equals(CONTENT_ELEMENT))) {

     if (dataRepetitions != 0 && event.isStartElement()
       && event.asStartElement().getName().getLocalPart().equals(DATA_ELEMENT)
       && dataRepetitions % 2 == 0) { // %2 = just for testing: replace this by for example checking the actual size of the current
               // output file
      xmlEventWriter.close(); // Also closes any open Element(s) and the document
      xmlEventWriter = openOutputFileAndWriteHeader(++fileNumber); // Continue with next file
      dataRepetitions = 0;
     }
     // Write the current event to output
     xmlEventWriter.add(event);
     event = xmlEventReader.nextEvent();

     if (event.isEndElement() && event.asEndElement().getName().getLocalPart().equals(DATA_ELEMENT)) {
      dataRepetitions++;
     }
    }
   }
  }

В строке 2 вы увидите, что мы получаем XMLEvent, который содержит всю информацию о текущем узле. В строке 4 вы видите, что эту форму проще использовать для проверки типа элемента (вместо сравнения с константами вы можете использовать объектную модель). В строке 19, чтобы скопировать элемент из ввода в вывод, мы просто добавляем событие в XMLEventWriter.