Alexey Suvorov dev blog

Мой разработческий блог

Archive for Июнь 2012

F# — парсинг XML экспортированного из Excel

leave a comment »

WCF сервисы, SQL и noSQL базы данных — это реалии крупных западных компаний. Местный малый бизнес до сих пор обменивается данными в Word документах, в лучшем случае в Excel. Правильный парсинг Excel должен начинаться как-то так:

Microsoft.Office.Interop.Excel.Application excelApp = new Microsoft.Office.Interop.Excel.Application();

Но на shared хостинге это решительно невозможно и тогда компромиссным решением может служить XML экспорт из Excel. Проблема этого подхода в том, что придётся делать всё с нуля. В силу привычки для парсинга я использую HtmlAgilityPack, но в данном случае возникла проблема с namespaces. Дело в том, что экспорт Excel в XML выглядит как то так:

<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
 xmlns:o="urn:schemas-microsoft-com:office:office"
 xmlns:x="urn:schemas-microsoft-com:office:excel"
 xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
 xmlns:html="http://www.w3.org/TR/REC-html40">
...
 <Worksheet ss:Name="Battery">
  <Table ss:ExpandedColumnCount="6" ss:ExpandedRowCount="537" x:FullColumns="1"
   x:FullRows="1" ss:DefaultRowHeight="15">
   <Column ss:Width="45"/>
   <Column ss:AutoFitWidth="0" ss:Width="374.25"/>
   <Column ss:Width="38.25"/>
   <Column ss:Width="57.75" ss:Span="2"/>
   <Row ss:Height="30">
    <Cell ss:StyleID="s64"><Data ss:Type="String">X-XX-951</Data></Cell>
    <Cell ss:StyleID="s66"><Data ss:Type="String">***</Data></Cell>
    <Cell ss:StyleID="s66"><Data ss:Type="String">        2600,00</Data></Cell>
    <Cell ss:StyleID="s66"><Data ss:Type="String">        2250,00</Data></Cell>
    <Cell ss:StyleID="s66"><Data ss:Type="String">        1850,00</Data></Cell>
   </Row>
  </Table>
 </Worksheet>
...
</Workbook>

Все ячейки имеют namespace и xpath выражение должно быть построено с учётом этого факта. Альтернативой был saxon, но стиль в котором написан API выжимает слезу — вспоминаются институтские годы и .net 1.0. Так что меньшим злом оказалась обёртка над стандартным XmlDocument. XmlNamespaceManager должен передаваться в каждый запрос, так что имеет его сохранить. Код:

module XmlHelpers =
    let EnumerableToSeq<'a> (x:System.Collections.IEnumerable) =
        let enumerator = x.GetEnumerator()
        seq {
            while enumerator.MoveNext() do 
                yield (enumerator.Current :?> 'a)
        }

    type NsEnabledDocument (fileName:string) =
        let doc = new XmlDocument()
        do doc.Load(fileName)
        let nsManager = new XmlNamespaceManager(doc.NameTable)
        //все запросы будут выполнены с namespace manager-ом
        let doSelect sel p = sel(p,nsManager)
        
        member public this.AddNamespace ns nsVal =
            nsManager.AddNamespace(ns,nsVal)
            nsManager.PushScope()

        member public this.Select path = 
            doSelect doc.DocumentElement.SelectNodes path 
            |> EnumerableToSeq<XmlNode>

        member public this.SelectSingle path =
            doSelect doc.DocumentElement.SelectSingleNode path

Использовать как-то так:

let doc = NsEnabledDocument(file)
doc.AddNamespace "ss" "urn:schemas-microsoft-com:office:spreadsheet"
let nodes = doc.Select "//ss:Worksheet[@ss:Name='Battery']/ss:Table/ss:Row"

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

let (|CountVal|DoubleVal|StringVal|) (s:string) =
    match s with
    | "****" -> CountVal ">30"
    | "***"  -> CountVal "11-30"
    | "**"   -> CountVal "4-10"
    | "*"    -> CountVal "1-3"
    | s ->
        let (b,d) = Double.TryParse(s,NumberStyles.Any,CultureInfo.CurrentCulture.NumberFormat)
        if b then DoubleVal(d)
        else StringVal(s)

Этот код полностью зависит от предметной области, в данном случае ячейка содержащая «*» — «****» означает примерное количество товара на складе.
Соответственно ячейки делятся на 4 типа: с данными не попадающие в другие категории (DataCell), ячейка с ценой (DoubleCell), ячейка с количеством (CountCell) и Xml не являющийся ячейкой (NotCell):

let (|DataCell|DoubleCell|CountCell|NotCell|) (n:XmlNode) 
    : Choice<string,double, string, unit> =
    if null = n then NotCell
    else
        match (n.Descendants() |> Seq.toList) with
        | (data : XmlNode) :: [] -> 
            match data.InnerText with 
            | CountVal x -> CountCell(x)
            | DoubleVal x -> DoubleCell(x)
            | StringVal x -> DataCell(x)
        | _ -> NotCell

Здесь .Descendants() — метод расширение

type System.Xml.XmlNode with
        member this.Descendants () =
            this.ChildNodes |> EnumerableToSeq<XmlNode>

Теперь код парсинга примитивно прост — мы задаём тип и последовательность ячеек которые должна содержать строка и конструируем готовый объект из уже типизированных объектов:

let PriceRowToObject (row:XmlNode) = 
    let cells = row.Descendants() |> FilterTextNodes |> Seq.toList
    match cells with
    | DataCell(a) :: DataCell(desc) :: CountCell(count) :: 
      DoubleCell(p1) :: DoubleCell(p2) :: DoubleCell(p3) :: _ -> 
        Some({Article = a; Count = count;  Desc = desc; PriceMax = p2; PriceMin = p3}: PriceItem)
    | _ -> None

Механизм не претендует на универсальность и требует перекомпиляции при изменении схему, но валиден в реалиях жёсткой схемы базы и структуры загружаемого документа.

Written by alexeysuvorov

17.06.2012 at 5:09 пп

Опубликовано в .net, F#