Posts mit dem Label xml werden angezeigt. Alle Posts anzeigen
Posts mit dem Label xml werden angezeigt. Alle Posts anzeigen

Samstag, 13. Oktober 2012

Große XML Datei in mehrere kleine Dateien aufteilen

Problembeschreibung


Wer viel mit XML-Dateien zu tun hat, kennt vielleicht das Problem - man hat gerade im Programm (oder aus einer Datenbank) ein XML erzeugt, das witzige 50 MB groß ist. Eigentlich in der heutigen Zeit von Terabyte-Festplatten kein großer Wert, sollte man meinen, aber Visual Studio hängt sich beim Öffnen auf. Auch Notepad++ hat hier selbst mit SSD bedenkliche Probleme. Einzig das gute alte Notepad von Windows kann die Datei zumindest anzeigen - das war's aber dann auch, denn damit kann ich nicht wirklich viel anfangen, immerhin sind 50MB auf einer Zeile nicht wirklich übersichtlich.

Lösung

Ich habe mir ein kleines Programm geschrieben, dass ein Eingabe XML in mehrere kleinere XML aufteilt. Die Herausforderung lag darin die Datei nicht komplett einzulesen, sondern Knoten für Knoten zu verarbeiten. Außerdem musste sich das Porgramm merken, welche Knoten noch geöffnet sind, um diese dann zu schließen, sobald die maximale Anzahl an Bytes erreicht wurde. Dadurch ist gewährleistet, dass die resultierenden XML-Dateien auch valides XML repräsentieren.
 

Implementierung

Im Prinzip verwende ich für das Lesen der XML-Datei einen XmlTextReader , das hat den entscheidenden Vorteil, dass die XML-Elemente einzeln eingelesen werden und man XML spezifische Metainformationen zu jedem gelesenen Element erhält. Ich spar mir im Code das kopieren der Kommentare und der XML-Deklaration, wenn das jemand benötigt, ist das schnell zu ergänzen. Außerdem verzichte ich auf die Berücksichtigung von Namespaces.
 
 Der  Programmablauf ist recht einfach:
- Lese jeden Tag und puffere diesen
- öffnende Tags werden auf einen Stack gelegt
- bei schließenden Tags wird der letzte öffnende Tag vom Stack entfernt
- hat der Puffer die gewünschte Größe, so schließe alle noch offenen Tags und schreibe den Puffer auf Platte
- leere Puffer
- öffne die zuletzt offenen Tags wieder im aktuellen Puffer
 
Und hier nun der Code:
 
 
    class XmlUtility
    {
        #region XmlOpeningTag class
        class XmlOpeningTag
        {
            
            public XmlOpeningTag(string name, string fullOpeningTag)
            {
                TagName = name;
                FullOpeningTag = fullOpeningTag;
            }
            public string TagName { get; set; }
            public string FullOpeningTag { get; set; }
            public int ClosingTagSize
            {
                get 
                {
                    return TagName.Length + 3;
                }
            }
        }
        #endregion
        
        /// <summary>
        /// splits the given xml file into smaller parts
        /// </summary>
        /// <param name="pathToXmlFile">the path to the xml file, that is to be splitted</param>
        /// <param name="maxSizeInBytes">app. maximum size of the resulting parts</param>
        /// <param name="resultPath">where to write (path) the resulting files</param>
        /// <returns>list of filepaths, that were created</returns>
        public static List<string> Split(string pathToXmlFile, int maxSizeInBytes, string resultPath=null)
        {
            if (resultPath == null)
                resultPath=System.IO.Path.GetDirectoryName(pathToXmlFile);
            List<string> res = new List<string>();
            // initialize variables
            int currentFileNumber = 1;
            int sizeOfClosingTags = 0;
            string fileWithoutExtension = System.IO.Path.Combine(
                resultPath,               
                System.IO.Path.GetFileNameWithoutExtension(pathToXmlFile));
            StringBuilder output = new StringBuilder();
            Stack<XmlOpeningTag> _openElements = new Stack<XmlOpeningTag>();

            // open the xml file within a xmlreader
            System.Xml.XmlTextReader xmlReader = new System.Xml.XmlTextReader(pathToXmlFile);
            // read from the file, element by element
            while (xmlReader.Read())
            {
                string currentXmlLine = string.Empty;
                XmlOpeningTag newOpeningTag = null;
                switch (xmlReader.NodeType)
                {
                    case XmlNodeType.Element: //its an xml tag element (opening)
                        string xmlElementName = xmlReader.Name;
                        // start with the tag
                        currentXmlLine = "<" + xmlElementName;
                        bool isEmptyElement = xmlReader.IsEmptyElement;
                        // add all attributes
                        while (xmlReader.MoveToNextAttribute()) 
                            currentXmlLine += " " + xmlReader.Name + "='" + xmlReader.Value + "'";
                        // if the element is empty, close it in line
                        if (isEmptyElement )
                            currentXmlLine += "/>";
                        //otherwise just close the tag
                        else
                        {
                            currentXmlLine += ">";
                            newOpeningTag =
                             new XmlOpeningTag(
                                    xmlElementName,
                                    currentXmlLine
                                    );
                            sizeOfClosingTags += newOpeningTag.ClosingTagSize;
                        }
                        break;
                    case XmlNodeType.Text: // text node
                        currentXmlLine = xmlReader.Value;
                        break;
                    case XmlNodeType.EndElement: //end of element tag
                       // pop the last opening tag
                       XmlOpeningTag openingTag = _openElements.Pop();
                       sizeOfClosingTags -= openingTag.ClosingTagSize;
                       currentXmlLine = "</" + xmlReader.Name + ">";
                       break;
                }
                
                // lets see, if we can add this new line
                if (output.Length + sizeOfClosingTags + currentXmlLine.Length >= maxSizeInBytes
                    && xmlReader.NodeType != XmlNodeType.EndElement 
                    // endelements will be added anyways, we cant close the xml 
                    // before adding the endelement
                    )
                {
                    // na we cannot
                    // close all open tags
                    Stack<XmlOpeningTag> helperStack = new Stack<XmlOpeningTag>();
                    while (_openElements.Count > 0)
                    {
                        XmlOpeningTag ol = _openElements.Pop();
                        output.Append("</" + ol.TagName + ">");
                        helperStack.Push(ol);
                    }
                    
                    string filePathToSave = fileWithoutExtension + "." + currentFileNumber + ".xml";
                    res.Add(filePathToSave);
                    System.IO.File.WriteAllText(filePathToSave, output.ToString());
                    currentFileNumber++;
                    // clear the string builder, the old stuff was saved
                    output.Clear();
                    // open the "old open" tags in the new output
                    while (helperStack.Count > 0)
                    {
                        XmlOpeningTag ol = helperStack.Pop();
                        output.Append(ol.FullOpeningTag);
                        _openElements.Push(ol);
                    }

                }

                if (newOpeningTag != null)
                    _openElements.Push(newOpeningTag);
                output.Append(currentXmlLine);
            }
            if (output.Length > 0)
            {
                string filePathToSave = fileWithoutExtension + "." + currentFileNumber + ".xml";
                res.Add(filePathToSave);
                System.IO.File.WriteAllText(filePathToSave, output.ToString());
                currentFileNumber++;
            }
            
            xmlReader.Close();
            return res;
        }
    }

Dienstag, 19. Juni 2012

XAML: XPath Binding erweitern durch XPathHelper als MultiConverter

Vor kurzem habe ich eine eher komplexe Lösung implementiert, bei der aus technischen Gründen ein XAML komplett an ein XML gebunden wird. (nicht ganz, es gibt ein ViewModel für das generische Piping der Button Commands) An sich bietet WPF hier durch die Verwendung von XPath-Ausdrücken direkt im XAML eine sehr gute Möglichkeit, und für viele Aufgaben ist dies auch ausreichend.

Jedoch bin ich dabei über ein etwas komplexeres Problem gestolpert, denn nicht jeder XPath-Ausdruck kann zu einem XmlNode evaluiert werden, was dann zur Laufzeit folgenden Fehler hervorbringt:



System.Windows.Data Error: 45 : XML binding failed. Cannot obtain result node collection because of bad source node or bad Path.; SourceNode='#document'; Path='count(/ROOT/Adresse)' BindingExpression:Path=/; DataItem='XmlDocument' (HashCode=9035653); target element is 'Label' (Name=''); target property is 'Content' (type 'Object') XPathException:'System.Xml.XPath.XPathException: Der Ausdruck muss in einem Knotensatz resultieren.
bei System.Xml.XPath.XPathNavigator.Select(XPathExpression expr)
bei System.Xml.XmlNode.SelectNodes(String xpath)
bei MS.Internal.Data.XmlBindingWorker.SelectNodes()'

Beispiel: Eine Adress-Verwaltung im XML

Kommen wir erstmal kurz zu einem Beispiel an dem ich vereinfacht die Herausforderung deutlich machen möchte. Angenommen unser XML besteht aus einer Liste von Adressen - unser Programm bietet nun die Möglichkeit, Adressen hinzuzufügen, jedoch mit einer Einschränkung: Die gleiche Person darf nur 2 mal auftauchen (gleicher Vor- und Nachname). Um das ganze nun im Code nicht abfangen zu müssen, wollen wir den Button zum Speichern deaktivieren, wenn die besprochene Regel greift.
Das XML hat folgende Struktur:

   


Grundsätzlich besteht unser XAML aus einer ListBox.
       <ListBox
            HorizontalContentAlignment="Stretch"
            ItemsSource="{Binding XPath=/root/adresse}" DataContext="{Binding DataAsXmlDocument}" ItemContainerStyle="{StaticResource ContainerStyle}" >
Außerdem haben wir in den Window.Resources entsprechend für den unselektierten und den selektierten Zustand ein DataTemplate, dass über deinen Style-Trigger gesetzt wird. Im selektierten Zustand kann der gewählte Adresssatz bearbeitet werden und mit einem Button wird das Speichern bestätigt.
                <DataTemplate x:Key="ItemTemplate">
                    <Grid HorizontalAlignment="Stretch"  Width="Auto">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                        </Grid.RowDefinitions>
                        <Label Content="{Binding XPath=@vorname}"       Grid.Column="0" Grid.Row="0"/>
                        <Label Content="{Binding XPath=@name}"          Grid.Column="1" Grid.Row="0"/>

                        <Label Content="{Binding XPath=@strasse}"       Grid.Column="0" Grid.Row="1"/>
                        <Label Content="{Binding XPath=@hausnummer}"    Grid.Column="1" Grid.Row="1"/>

                        <Label Content="{Binding XPath=@plz}"           Grid.Column="0" Grid.Row="2"/>
                        <Label Content="{Binding XPath=@ort}"           Grid.Column="1" Grid.Row="2"/>
                    </Grid>
                </DataTemplate>

                <DataTemplate x:Key="SelectedItemTemplate">
                    <Grid HorizontalAlignment="Stretch"  Width="Auto">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                        </Grid.RowDefinitions>
                        <TextBox Text="{Binding XPath=@vorname}"       Grid.Column="0" Grid.Row="0"/>
                        <TextBox Text="{Binding XPath=@name}"          Grid.Column="1" Grid.Row="0"/>

                        <TextBox Text="{Binding XPath=@strasse}"       Grid.Column="0" Grid.Row="1"/>
                        <TextBox Text="{Binding XPath=@hausnummer}"    Grid.Column="1" Grid.Row="1"/>

                        <TextBox Text="{Binding XPath=@plz}"           Grid.Column="0" Grid.Row="2"/>
                        <TextBox Text="{Binding XPath=@ort}"           Grid.Column="1" Grid.Row="2"/>
                        
                        <Button Content="Speichern" Grid.Column="1" Grid.Row="3"/>
                    </Grid>
                </DataTemplate>

                <Style TargetType="{x:Type ListBoxItem}" x:Key="ContainerStyle">
                    <Setter Property="ContentTemplate" Value="{StaticResource ItemTemplate}" />
                    <Style.Triggers>
                        <Trigger Property="IsSelected" Value="True">
                            <Setter Property="ContentTemplate" Value="{StaticResource SelectedItemTemplate}" />
                        </Trigger>
                    </Style.Triggers>
                </Style>

Das ganze sieht dann visualisiert (mit gebundenem Beispiel-XML) sieht so aus.
Nicht schön, aber ich bin ja auch kein Designer ;-)

Unabhängig davon, wie diese Adress-Verwaltung neue Einträge hinzufügt, oder Einträge speichert, liegt die Herausforderung nun darin, den Speichern-Button auszublenden, wenn bereits genau 2 andere Personen existieren, die den gleichen Vor- und Nachnamen haben.


Ansatz 1: Die Konservative Lösung (code behind)

Naja, der naive Ansatz wäre jetzt die Logik für diese Regel einfach im code behind zu realisieren.
Dazu müssen im XAML einige Anpassungen gemacht werden. Zum einen müssen wir die zugehörigen Textboxen an das TextChangedEvent hängen und dort dann auf unsere ListBox zugreifen, dh. wir müssen diese über den Namen refernzieren können. Um den Speichern-Button zu deaktivieren, gilt das gleiche.
<!-- [..] -->
<TextBox Text="{Binding XPath=@vorname,UpdateSourceTrigger=PropertyChanged}"       
   Grid.Column="0" Grid.Row="0"  
   TextChanged="TextBox_TextChanged" />
<TextBox Text="{Binding XPath=@name,UpdateSourceTrigger=PropertyChanged}"          
   Grid.Column="1" Grid.Row="0"  
   TextChanged="TextBox_TextChanged" />
<!-- [..] -->
<Button Content="Speichern" 
           Grid.Column="1" Grid.Row="3" 
           Name="Button_Speichern"/>

<!-- [..] -->
        <ListBox
            Name="lstbAdressen"
            HorizontalAlignment="Stretch"
            HorizontalContentAlignment="Stretch"
            ItemsSource="{Binding XPath=/root/adresse}" 
   DataContext="{Binding DataAsXmlDocument}" 
   ItemContainerStyle="{StaticResource ContainerStyle}" >
<!-- [..] -->

Jetzt fehlt uns nurnoch der code behind:
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
 XmlNode selectedItem = (XmlNode)lstbAdressen.SelectedItem;
 if (selectedItem != null)
 { 
  XmlDocument adressenXml = lstbAdressen.DataContext as XmlDocument;
  if (adressenXml != null)
  {
    XmlNodeList sameNameNodes = 
        adressenXml.SelectNodes(
                 "/root/adresse[@vorname = '" 
                 + selectedItem.Attributes["vorname"].Value 
                 + "' and @name = '" 
                 + selectedItem.Attributes["name"].Value 
                 + "']");
                  
    for (
      int i = 0; 
      i < VisualTreeHelper.GetChildrenCount((sender as FrameworkElement).Parent); 
      i++)
    {
     DependencyObject child = 
         VisualTreeHelper.GetChild((sender as FrameworkElement).Parent, i);
     if (child != null 
         && child is FrameworkElement 
         && ((FrameworkElement)child).Name == "Button_Speichern")
     {
       if (sameNameNodes.Count == 3)
       {
        ((FrameworkElement)child).IsEnabled = false;
       }
       else
       {
        ((FrameworkElement)child).IsEnabled = true;
       }

     }
    }
   }
  }
}


Wie man sieht ist diese Lösung mit den ca. 10 Codezeilen nicht ganz so trivial wie angenommen. Prinzipiell liegt ein größerer Aufwand darin, den Speichern-Button zu finden, da dieser in einem DataTemplate gekapselt ist. Nichtsdestotrotz funktioniert dieser Ansatz ganz gut, vielleicht gibt performancemäßige Probleme, aber bei kleinen Datensätzen sollte es ganz gut klappen.

Ansatz 2: MVVM-Pattern

Die Lösung mit dem code behind wird bei vielen WPF-Entwicklern arge Bauchschmerzen verursachen, da es ein datentechnisches Problem über visuelle Events und damit verbunden direkten Zugriff auf die View verursacht als Lösung heranzieht. Sauberer ist natürlich das ganze über ein Command-Property im ViewModel zu lösen. Wir sparen uns dadurch die Iteration durch den VisualTree und können komplett von der View abstrahieren.

Ansatz 3: Die Lösung im XAML

Eine Lösung komplett im XAML ist vermutlich nicht möglich, denn XPath kann keine Variablen speichern. Um durch alle Adress-Knoten zu iterieren und nach einem Attribut aus dem aktuellen Kontext zu vergleichen ist dies unabdingbar. Es müssste etwas geben um im XPath Platzhalter zu definieren, um diese wiederum dann durch weitere Bindings zu ersetzen, so etwas wie:

{Binding XPath=/root/adresse[@vorname={Binding XPath=@vorname} and @name={Binding XPath=@name}] }

Abgesehen davon, dass das vollkommener Quatsch ist, kommt noch hinzu, dass wir die Anzahl der Nodes zählen müssten:
{Binding XPath=count(/root/adresse[@vorname={Binding XPath=@vorname} and @name={Binding XPath=@name}]) }

Ich hab lange überlegt wie man auf elegante Weise dieses Problem lösen kann, mit einer MarkupExtension ? Einem Converter? Oder doch was ganz anderem?
Die meines Erachtens beste Lösung ist die Verwendung eines MultiBinding. Die Idee dahinter ist, die Funktion String.Format mit einem SelectNodes zu kombinieren.
Das MultiBinding an sich soll folgendermaßen funktionieren:

<MultiBinding Converter="{StaticResource XPathHelper}" 
ConverterParameter="count:/root/adresse[@vorname={0} and @name={1}]"> 
  <Binding XPath="@vorname" />
  <Binding XPath="@name" />
  <Binding XPath="." />
</MultiBinding>

Prinzipiell sollte jetzt jedem klar sein, wie der Converter implementiert wird, für alle die gern kopieren & pasten, hier ein Implementierungsvorschlag:
    public class XPathHelper : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            string xpath = (string)parameter;
            string.Format(xpath, values);
            bool justCountResult = false;
            if (xpath.StartsWith("count:", StringComparison.InvariantCultureIgnoreCase))
            {
                xpath = xpath.Substring(6);
                justCountResult = true;
            }

            XmlDocument doc = (XmlDocument) values[values.Count() - 1];
            if (doc != null)
            {
                XmlNodeList nodesFound = doc.SelectNodes(xpath);
                if (justCountResult) // return count only
                    return nodesFound.Count.ToString();
                if (nodesFound.Count > 0) //return only the value of the first node though
                    return nodesFound[0].Value;
            }
            return null; // nothing found or no xmldocument given
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }