суббота, 12 января 2013 г.

DOM- и SAX-подходы к парсингу XML.


Всем привет.

Как и обещал - расскажу свои мысли о парсерах.
В прошлый раз я тщательно расписывал, что же такое парсер. А в этот раз приведу лишь одну цитату. (Цитата является ссылкой на источник).


Мы будем решать конкретную задачу. У нас будет небольшая база данных, несколько астероидов. Для данного поста я максимально упрощу эту базу данных. Для каждого астероида будет указано: координата по Х, координата по У и время появления на экране. Вот картинка, которая иллюстрирует все вышесказанное:


По просьбе RedHunter выкладываю полный исходник проекта.

ВОТ ОН.



У нас есть 2 основных варианта парсера. SAX и DOM. Есть еще промежуточные варианты.
Но я бы хотел рассмотреть именно 2 основных технологии, у них немного разный принцип действия и разные плюсы и минусы.

DOM  (Document Object Model) - технология, позволяющая "перенести" XML-документ прямо в ваш код. В данный момент для Java используется JDOM. Сам по себе XML-документ представляет собой некую древовидную структуру. А DOM переносит эту структуру в программу. Целиком и полностью. В коде вы создаете некую переменную типа Document , которая является полной интерпритацией XML-документа. DOM работает в обе стороны - вы не только можете читать XML-документы. Но и можете прямо в коде создавать и развивать некую структуру типа Document, а потом преобразовать ее в XML.

Решаем поставленную задачу при помощи DOM.

Во-первых скачаем JAR-файл отсюда. Там будет несколько файлов в архиве. Вам понадобится только jdom-2.0.4.jar .Копируем его себе в проект в папку libs. По идее этого должно хватить, но если вдруг позже у вас начнут возникать ошибки типа "компилятор не может найти jdom-2.0.4.jar" - вам нужно будет его вручную прописать в свойствах проекта в Java Build Path. Так же  у меня возникали какие-то странные проблемы с версией компилятора. Если вам пишет что-то вроде этого: "Android requires compiler compliance level 5.0 or 6.0. Found '1.7' instead. Please use Android Tools > Fix Project Properties.", то вам нужно зайти в свойства проекта во вкладку Java Compiler  и поставить версию компилятора 1.6.

Во-вторых напишем вот такой вот класс:

package ru.sapfil.AETest2;
import org.jdom2.Document;
import org.jdom2.input.SAXBuilder;
import android.content.res.AssetManager;
import android.util.Log;
import java.io.*;

public class DataBaseLoader {
 
 Document rDoc; // это будет база данных в программе
  
    DataBaseLoader (AssetManager pAssetManager) {
        try {
         SAXBuilder parser = new SAXBuilder(); // инициализируем парсер
            InputStream fr = pAssetManager.open("levels/level.xml"); //открываем файл БД
            rDoc = parser.build(fr); // скармливаем открытый файл парсеру 
            Log.d("Sapfil_Log", "XML Parsed"); // логирование   
        }
        catch (Exception ex) {
         Log.d("Sapfil_Log", ex.getMessage()); // логирование ошибки
        }
    }
}
Для открытия файла я воспользовался AssetManager-ом. Собственно вся магия происходит в трех строчках - 14,15,16. После выполнения 16й строчки у нас будет некий объект типа Document. О том, как он устроен, я в общих словах, рассказал выше.

В-третьих...
Давайте поподробнее рассмотрим - как же им полльзоваться. Переходим в наш MainActivity-файл и дописываем туда следующее:
(примечание - если кому-то очень надо - вот ссылка на полный код моего MainActivity текущей версии, а код ниже обрезан - он учебный, а не рабочий.)

package ru.sapfil.AETest2;

// ...много-много строчек импорта...

public class MainActivity extends SimpleBaseGameActivity implements SensorEventListener, GlobalConstants{
    // ===========================================================
    // Fields
    // ===========================================================
    // ...много-много переменных, в конец дописываем следующее:

    private DataBaseLoader mDB;
    private int counterDB = 0;
    private boolean endOfLevel = false;

    // ===========================================================
    // Methods for/from SuperClass/Interfaces
    // =========================================================== 
    
    // инициализируем движок
    public EngineOptions onCreateEngineOptions()
    {
     //...some code
    }

    protected void onCreateResources()
    {
     //...some code    
 this.mDB = new DataBaseLoader(this.getAssets());
    }

    protected Scene onCreateScene()
    {
     // ... много кода - создание бэкграунда, героя и много чего еще...
        // ... для примера я оставил  строчки по созданию списка астероидов:
     mAsteroids = new BasicObjectsCollection(mSpaceObjectsTextureRegion, 
         this.getVertexBufferObjectManager(), sapCamera);
     scene.attachChild(mAsteroids);   
             
 // *** Main Game Loop ***
  
        scene.registerUpdateHandler(new IUpdateHandler() {
         int i = 0; //target number
            public void onUpdate(float pSecondsElapsed) { 
             // updating asteroids
             switch (mAsteroids.Update(pSecondsElapsed)) {
          case 0: {};    

       // ВОТ ТУТ САМЫЕ ВАЖНЫЕ СТРОЧКИ!!!:
             }   
if (!endOfLevel){
 if (Integer.parseInt(mDB.rDoc.getRootElement().getChildren("object").get(counterDB).
   getAttributeValue("time")) < mEngine.getSecondsElapsedTotal
    {                  
     mAsteroids.AddNewObject(Integer.parseInt(mDB.rDoc.getRootElement().getChildren("object").
        get(counterDB).getAttributeValue("x")),
      Integer.parseInt(mDB.rDoc.getRootElement().getChildren("object").
        get(counterDB).getAttributeValue("y")),
      0, // скорость по Х
      0, // скорость по У
      26,// ширина "тела" по Х(нужна для обработки столкновений)
      26,// высота "тела" по Y
      100);// "здоровье" объекта
   
     counterDB++;
     if(counterDB >= mDB.rDoc.getRootElement().getChildren("object").size())
       endOfLevel = true;      
}}
          // САМЫЕ ВАЖНЫЕ СТРОЧКИ ЗАКОНЧИЛИСЬ!!!
          // ниже представленная процедура предназначена 
          // больше для баловства, чем для дела

             else if (mAsteroids.getChildCount() == 0)
             {
              endOfLevel = false;
              counterDB = 0;
             }             
           // ...дальше еще некоторое количество кода - обновление героя, обработка столкновений etc.
                          
            }
            public void reset() {
                // TODO Auto-generated method stub
            }
        });
      return scene;
    }
}
В строчке 11 переменная для базы данных.
В строчке 12 - счетчик объектов. Каждый раз, когда мы будем загружать из базы данных объект - мы будем увеличивать счетчик, чтобы в следующий раз обращаться к следующему объекту базы данных.
В строчке 13 - флаг окончания базы данных. Когда мы загрузим все объекты - этот флажок установим true, чтобы не пытаться больше загружать объекты из базы данных. Если этого не сделать - вот что произойдет. Мы загрузим 4й объект, счетчик  увеличится. И в следующий раз программа попытается загрузить 5й объект из базы данных, а их там всего 4. Это приведет к ошибке доступа.
В строчке 28 - мы инициализируем нашу базу данных.
В строчках 35-37 мы создаем и добавляем в сцену контроллер астероидов. Этот код уже был в ранних версиях, я его сюда вставил для большей наглядности. Потому что ниже мы будем обращаться к переменной mAsteroids.
В строчке 50 начинается наше волшебство. Первой строчкой мы проверяем - а не кончился ли уровень.
Строчка 51 очень длинная.(строчка 52 - это ее продолжение). Буду подробно объяснять:
   Integer.ParseInt - стандартная функция. Преобразует строку, содержащую цифры - в целое число. Дело в том, что после парсинга кусочка вида x="50" мы получим не число 50, а строку из символов 5 и 0. И ее надо преобразовать в число. Это мы и сделали.
   Дальше. mDB - это наша база данных.
   rDoc - это та самая сложная переменная типа Document.
   У нее есть метод GetRootElement(), который предоставляет нам корень всей структуры. В нашем случае у нас двухуровневая структура. Корневой элемент 1, а дочерних 4.
   У корневого элемента (как и у любого другого) есть метод GetChilren("element_name"). Этот метод возвращает переменную типа List - список всех дочерних элементов с указанным именем. Кстати в нашем случае будет List.
   У переменной типа List есть метод get(i), возвращающий i-тый элемент списка.
   После того, как мы получили элемент списка - можно обратиться к одному из его атрибутов и добыть значение этого атрибута методом getAttributeValue("attribute_name"). В нашем случае имя атрибут time - и мы хотим получить это значение.


Уф... Ну и длиннющая конструкция. Попробую кратко перевести на русский то, что у нас произошло в строчке 51-52. Мы говорим программе буквально следующее:

Преобразуй в целое число значение, которое получи из следующей конструкции ->
Обратись к базе данных mDB ->
Найди в ней документ rDoc ->
Найди в нем корневой элемент ->
Выдай список его дочерних элементов с именами "object" ->
Найди в этом списке элемент с номером counterDB ->
Найди у этого элемента атрибут "time" и прочитай его значение.

Я очень надеюсь, что теперь стало чуть более понятно.
Строчки 54-55 и 56-57 по сути представляют собой ту же самую длинную конструкцию, только на этот раз мы добываем значения для X и для Y.
Далее задаются другие параметры новорожденного астероидика. Для примера я сделал скорость нулевую.

После выполнения строчек 54-62 у нас создан новый астероид. "Посчитаем" его, увеличив наш счетчик в строке 64. Это позволит нам в следующий раз обратиться к следующему элементу базы данных. Но теперь нам надо проверить - вдруг это был последний элемент базы данных. Мы должны получить общее количество элементов в нашей базе данных. Мы так же обращаемся к mDB.rDoc.getRootElement().getChildren("object"). Напомню, что метод GetChildren() возвращает переменную типа List. И у этой переменной есть метод GetSize(), который как раз и выдаст нам нужное значение. И если наш счетчик превысил это значение - значит мы добрались до конца базы данных.

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

Ну и в-четвертых. Если вы этого еще не сделали, то нужно положить XML-файл (level.xml) с базой данных в папочку assets/level. Кому очень надо - вот ссылка на содержание моего XML-файла.

Результат я заскринил и выложил в самом начале данного поста.

Теперь рассмотрим плюсы и минусы такого подхода. При этом будем рассматривать те особенности, которые важны именно нам именно для нашей игры.
ПЛЮСЫ:
+ сохраняется вся структура документа. Особенно ценно то, что сохраняются "родственные связи" между элементами.
+ очень простая реализация загрузки - см. первый листинг.
+ при внесении изменений в XML-документ (например вы хотите добавить всем объектам новый атрибут "speed") - не надо переписывать парсер.
МИНУСЫ:
- в базе данных будет храниться ВЕСЬ документ. А представьте, что в вашей базе данных много-много объектов. Это минус к производительности приложения.
- значения атрибутов могут быть прочитаны только в виде строки, требуется вызов дополнительной функции для преобразования значения атрибута в число. Эта функция будет вызываться каждый раз, что в перспективе просадит производительность.
ОСОБЕННОСТИ:
+-Видели, какая длиннющая конструкция у нас получается при обращении к какому-либо элементу. Сначала хотел отнести это к минусам. Однако, ничто нам не мешает создать локальную переменную типа List и записать в нее значение
mDB.rDoc.getRootElement().getChildren("object")
И тогда при каждом следующем обращении нам придется строить гораздо меньше заборов из многочисленных get-ов.

Так... На этом, дорогие читатели, мы заканчиваем обзор DOM-подхода к парсингу. В заключение хотелось бы вот что сказать. Данный пример является учебным. Особенно это относится к первому листингу. По хорошему там надо бы создать отдельные методы, геттер для rDoc, а сам rDoc сделать private-ным... Но это же не суть - к текущей теме это не относится. Я искренне надеюсь, что вы поняли главное - как пользоваться jdom. :)



Решение поставленной задачи при помощи SAX.

SAX (Simple Api for XML) - технология простого чтения XML. DOM переписывает всю структуру XML в свой собственный тип Document. SAX просто находит объекты в XML и для каждого из них выполняет определенные действия. При этом SAX не запоминает иерархию, вложенность объектов. У SAX просто есть несколько процедур для разных событий. Есть несколько видов событий:
- Найдено начало документа
- Найден конец документа
- Найдено начало нового элемента
- Найден конец нового элемента
- Найден текст (имеется ввиду текст между тэгами, имена объектов, имена атрибутов, значения атрибутов к этому понятию не относятся.
При этом у программиста (то есть у нас с Вами) имеется возможность самому вставить любой код, который будет вызываться при каждом из этих событий.
Думаю пришло время перейти к коду. Вот как выглядит мой парсер на основе SAX:
package ru.sapfil.AETest2;

import java.io.InputStream;
import java.util.ArrayList;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.andengine.util.SAXUtils;
import org.xml.sax.Attributes;
import org.xml.sax.XMLReader;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
import android.content.res.AssetManager;
import android.util.Log;

public class DataBaseParser extends DefaultHandler
{
 private String currentParent = "";
 public final ArrayList TimeList = new ArrayList();
 
 DataBaseParser (AssetManager pAssetManager) throws Exception {
  final SAXParserFactory spf = SAXParserFactory.newInstance();
  final SAXParser sp = spf.newSAXParser();
  XMLReader xr = sp.getXMLReader();
  xr.setContentHandler(this);
//  xr.setErrorHandler(this);
  InputStream mInputStream = pAssetManager.open("levels/level.xml");
  xr.parse(new InputSource(mInputStream));
    }

    ////////////////////////////////////////////////////////////////////
    // Event handlers.
    ////////////////////////////////////////////////////////////////////

    public void startDocument (){
     Log.d("Sapfil_Log", "XML Parsing Start");
    }
    public void endDocument () {
     Log.d("Sapfil_Log", "XML Parsing End");
    }

    public void startElement (String uri, String name,
         String qName, Attributes atts)
    {
     if (name == "objects")
      currentParent = name;
     else if (name == "object")
     {
      if (currentParent == "objects"){
       final int x = SAXUtils.getIntAttributeOrThrow(atts, "x");
       final int y = SAXUtils.getIntAttributeOrThrow(atts, "y");
       final int time = SAXUtils.getIntAttributeOrThrow(atts, "time");
       TimeList.add(new DataNode(x, y, time));
      }
        }      
    }

    public void endElement (String uri, String name, String qName){
 }
    
// мой собственный класс для хранения информации об объектах
    class DataNode{
  
     public int x,y;
     public int time;
     
     DataNode(int pX, int pY, int pTime){
      this.x = pX;
      this.y= pY;
      this.time = pTime;
     }
    }
}
А теперь сравните его с первым листингом в данном посте. Почувствовали разницу в объеме?
Вообще объем данного листинга несколько преувеличен. Можно убрать из него функции startDocument(), endDocument(), endElement(). Парсер по умолчанию имеет эти функции с пустым телом. Но я их все-таки решил привести, пусть будут для наглядности.

Строчки с 20й по 28ю частично стащены из LevelLoaderExample. Отдельно скажу по строчке 25. Можно делать не только обработчик полей, но и обработчик ошибок. Пока строка 25 закоментирована - код не защищен от ошибки и будет вылетать, если Вы забудете хоть один правильный символ в XML-документе (скобочку, слэшечку). Самая важная для нас строчка - это строка 24, в которой сказано, что именно наш класс будет заниматься обработкой всех событий. И кстати, чтобы он мог это делать - в строке 15 есть приписка extend DefaultHandler . ТО есть класс, занимающейся обработкой событий должен быть унаследован от этого самого DefaultHandler. 

Вообще нас больше интересует функция в...
строке 41. Вы видите, что эта функция работает по 4м параметрам. Их смысл мы можете изучить самостоятельно. для нас важен лишь второй параметр name. Это локальное имя найденного объекта. Как вы видите дальше,
в строках 44 и 46 мы как раз и проверяем это самое имя и в зависимости от того - какой объект нам попался - выполняем нужные нам действия.
В строках 49-52 мы читаем значения атрибутов объекта и записываем их в свой собственный объект. Еще раз напоминаю, что после парсинга у нас не сохранится никакой структуры документа. По-этому все нужные нам данные мы должны сохранить сами прямо во время парсинга. Это мы и делаем.
В строчках 61-71 как раз описана структура DataNode, хранящая для нас данные об объекте, а
в строке 18 объявлен контейнер, хранящий объекты DataNode.
Строка 72 является глюком блога. Ее копировать не надо. Кто скажет мне, как победить эту последнюю строчку - тому бублик!

КСТАТИ! Важное замечание. Если вы посмотрите XML, то увидите, что кроме трех используемых атрибутов у каждого объекта есть атрибут id. Это некая избыточная информация, нам не нужная. При DOM-подходе мы будем хранить всю информацию, в том числе ненужную, как например значения этих самых id. А вот при SAX-подходе мы сами выбираем - какие данные нам надо сохранить, что с ними делать. Мы можем вообще не загружать некоторые объекты, загружать из них информацию лишь частично, и т.п. То есть у нас в руках больше свободы и больше возможностей для оптимизации кода. В интернете очень часто встречается мнение, что SAX-подход меньше просаживает производительность, чем DOM-подход.

Так. Надо бы привести листинг использования вышеописанного класса в MainActivity.
package ru.sapfil.AETest2;

// ...много-много строчек импорта...

public class MainActivity extends SimpleBaseGameActivity implements SensorEventListener, GlobalConstants{
    // ===========================================================
    // Fields
    // ===========================================================
    // ...много-много переменных, в конец дописываем следующее:

    private DataBaseParser mDB;
    private int counterDB = 0;
    private boolean endOfLevel = false;

    // ===========================================================
    // Methods for/from SuperClass/Interfaces
    // =========================================================== 
    
    // инициализируем движок
    public EngineOptions onCreateEngineOptions()
    {
     //...some code
    }

    protected void onCreateResources()
    {
     //...some code    
 try {
   this.mDB = new DataBaseParser(this.getAssets());
  } catch (Exception ex) {
   Log.d("Sapfil_Log", ex.getMessage());;
  }
    }

    protected Scene onCreateScene()
    {
     // ... много кода - создание бэкграунда, героя и много чего еще...
        // ... для примера я оставил  строчки по созданию списка астероидов:
     mAsteroids = new BasicObjectsCollection(mSpaceObjectsTextureRegion, 
         this.getVertexBufferObjectManager(), sapCamera);
     scene.attachChild(mAsteroids);   
             
 // *** Main Game Loop ***
  
        scene.registerUpdateHandler(new IUpdateHandler() {
         int i = 0; //target number
            public void onUpdate(float pSecondsElapsed) { 
             // updating asteroids
             switch (mAsteroids.Update(pSecondsElapsed)) {
          case 0: {};    

       // ВОТ ТУТ САМЫЕ ВАЖНЫЕ СТРОЧКИ!!!:
             }   
if (!endOfLevel){
 if (mDB.TimeList.get(counterDB).time < mEngine.getSecondsElapsedTotal())
              {                  
               mAsteroids.AddNewObject(
                 mDB.TimeList.get(counterDB).x,
                 mDB.TimeList.get(counterDB).y,
                       0, // скорость по Х
                       0, // скорость по У
                       26,// ширина "тела" по Х(нужна для обработки столкновений)
                       26,// высота "тела" по Y
                       100);// "здоровье" объекта
                                  
               counterDB++;
               if(counterDB >= mDB.TimeList.size())
                endOfLevel = true;      
              }}
          // САМЫЕ ВАЖНЫЕ СТРОЧКИ ЗАКОНЧИЛИСЬ!!!
          // ниже представленная процедура предназначена 
          // больше для баловства, чем для дела

             else if (mAsteroids.getChildCount() == 0)
             {
              endOfLevel = false;
              counterDB = 0;
             }             
           // ...дальше еще некоторое количество кода - обновление героя, обработка столкновений etc.
                          
            }
            public void reset() {
                // TODO Auto-generated method stub
            }
        });
      return scene;
    }
}
Я думаю, что здесь понадобится гораздо меньше объяснений. Все тоже самое, что и во втором листинге, только нет этой длиннющей конструкции из геттеров. После выполнения 29й строчки наш mDB.TimeList заполнится данными об объектах. И в строчках 55,58,59,67 мы довольно просто обращаемся к этим данными.

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

ПЛЮСЫ:
+ в базу данных мы будем сохранять только те данные, какие нам нужны для работы, а не весь документ.
+ главный плюс - SAX-подход меньше просаживает производительность.

МИНУСЫ:
- реализацию загрузки надо писать самому. надо самому знать свой XML-документ. Представьте, что вы в коде написали "Objects" (с большой буквы), а в XML-файле у вас "objects". Этот объект не загрузится. То есть больше нагрузки на программиста.
- представьте, что вы хотите внести изменения в свой XML - например добавить новый атрибут "speed". Вам придется переписывать свой класс DataNode,чтобы он мог хранить эти данные. И вносить корректировки в функцию startElement(). Опять больше нагрузки на программиста.

ОСОБЕННОСТИ:
+- Структура документа не сохраняется - с одной стороны это минус. С другой, мы не будем хранить избыточную информацию, а это плюс.

Вместо заключения.

Во-первых хотелось бы сказать вот что. На самом деле DOM-парсер в свой глубине использует SAX-подход.

Во-вторых при использовании JDOM можно пользоваться и SAX- и DOM- подходом, а так же комбинировать их. Однако, JDOM это в первую очередь всетаки DOM-подход.

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





4 комментария:

  1. "Можно делать не только обработчик ошибок. но и обработчик ошибок." - ввело в ступор на долго :)

    ОтветитьУдалить
  2. Анонимный6 июня 2013 г., 23:47

    "как пользоваться jdom"-понравилось.

    ОтветитьУдалить
    Ответы
    1. Анонимный7 июня 2013 г., 0:55

      и еще , по поводу Представьте, что вы в коде написали "Objects" (с большой буквы), а в XML-файле у вас "objects".

      if(name.equalsIgnoreCase("objects")) и у вас с этим не будет никаких проблем.

      Удалить