Archive for Июнь 2012
F# — парсинг XML экспортированного из Excel
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
Механизм не претендует на универсальность и требует перекомпиляции при изменении схему, но валиден в реалиях жёсткой схемы базы и структуры загружаемого документа.