1. 程式人生 > >使用由 Python 編寫的 lxml 實現高性能 XML 解析

使用由 Python 編寫的 lxml 實現高性能 XML 解析

預編譯 例子 parsing 信息 each using 創建 multi 元素

lxml 簡介

Python 從來不出現 XML 庫短缺的情況。從 2.0 版本開始,它就附帶了 xml.dom.minidom 和相關的 pulldom 以及 Simple API for XML (SAX) 模塊。從 2.4 開始,它附帶了流行的 ElementTree API。此外,很多第三方庫可以提供更高級別的或更具有 python 風格的接口。

盡管任何 XML 庫都足夠處理簡單的 Document Object Model (DOM) 或小型文件的 SAX 解析,但開發人員越來越多碰到更加大型的數據集,以及在 Web 服務上下文中實時解析 XML 的需求。同時,經驗豐富的 XML 開發人員可能傾向於使用原本就支持 XML 的語言,例如 XPath 或 XSLT,這樣可以保持緊湊和表達力。最理想的情況是使用 XPath 的聲明式語法,同時保留 Python 的通用的功能。

本文使用的軟件版本和示例數據
  • lxml 2.1.2
  • libxml2 2.7.1
  • libxslt 1.1.24
  • cElementTree 1.0.5
  • United States 版權更新(copyright renewal)數據,由 Google 提供
  • Open Directory Resource Description Framework (RDF) 內容

有關軟件和數據的更多信息,請參閱 參考資料。

我執行的基準測試使用了 Pentium M 1.86GHz ThinkPad T43、2GB RAM,運行 Ubuntu,使用 IPython 的 timeit 命令。計時的目的主要是為了比較方法,因此不應該作為所述軟件的代表性基準。

lxml 是第一款表現出高性能特征的 Python XML 庫,它天生支持 XPath 1.0、XSLT 1.0、定制元素類,甚至 python 風格的數據綁定接口。它構建在兩個 C 庫之上:libxml2libxslt。它們為執行解析、序列化和轉換等核心任務提供了主要動力。

您要在代碼中使用 lxml 的哪一部分取決於您的需求:您是否熟悉 XPath?是否希望使用類似 Python 的對象?系統中有多少內存可用來維持大型樹?

本文並沒有介紹 lxml 的所有部分,但是演示了一些可以有效處理大型 XML 文件、進行優化以提高處理速度並減少內存使用的技術。這裏使用了兩種可免費使用的示例文檔:Google 將其轉換為 XML 的 U.S. 版權更新數據和 Open Directory RDF 內容。

這裏只將 lxml 與 cElementTree 比較,而沒有與其他 Python 庫進行比較。選擇 cElementTree 是因為它和 lxml 一樣是 Python 2.5 的一部分,並且構建在 C 庫之上。

超大型的數據會引起什麽問題?

XML 庫通常針對非常小的示例文件進行設計和測試。事實上,很多實際項目最初並沒有完整的可用數據。編程人員一連數周或數月都使用示例內容,並編寫如 清單 1 所示的代碼。

cElementTree

如果程序的任務就是解析和執行簡單的分析,可以考慮使用 cElementTree 模塊,它是 Python 2.5 的一部分。它是 ElementTree 的 C 實現,使用 expat 進行解析,並且在解析完整的文檔樹方面比其他庫強大。然而,它的 API 限制比 ElementTree 還多,並且在執行大多數其他任務時(特別是執行序列化時)速度比 lxml 慢。

清單 1. 一個簡單的解析操作
1 2 from lxml import etree doc = etree.parse(‘content-sample.xml‘)

lxml parse 方法讀取整個文檔並在內存中構建一個樹。相對於 cElementTree,lxml 樹的開銷要高一些,因為它保持了更多有關節點上下文的信息,包括對其父節點的引用。使用這種方法解析一個 2G 的文檔時,會使一個具有 2G RAM 的機器進入交換,這會大大影響性能。假設在編寫應用程序時這些數據在內存中可用,那麽將要執行較大的重構。

叠代解析

如果構建內存樹並不是必須的或並不實際,則可以使用一種叠代解析技術,這種技術不需要讀取整個源樹。lxml 提供了兩種方法:

  • 提供一個目標解析器類
  • 使用 iterparse 方法

使用目標解析器方法

目標解析器方法對於熟悉 SAX 事件驅動代碼的開發人員來說應該不陌生。目標解析器是可以實現以下方法的類:

  1. start 在元素打開時觸發。數據和元素的子元素仍不可用。
  2. end 在元素關閉時觸發。所有元素的子節點,包括文本節點,現在都是可用的。
  3. data 觸發文本子節點並訪問該文本。
  4. close 在解析完成後觸發。

清單 2 演示了如何創建實現所需方法的目標解析器類(這裏稱為 TitleTarget)。這個解析器在一個內部列表(self.text)中收集 Title 元素的文本節點,並在到達 close() 方法後返回列表。

清單 2. 一個目標解析器,它返回 Title 標記的所有文本子節點的列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class TitleTarget(object): def __init__(self): self.text = [] def start(self, tag, attrib): self.is_title = True if tag == ‘Title‘ else False def end(self, tag): pass def data(self, data): if self.is_title: self.text.append(data.encode(‘utf-8‘)) def close(self): return self.text parser = etree.XMLParser(target = TitleTarget()) # This and most other samples read in the Google copyright data infile = ‘copyright.xml‘ results = etree.parse(infile, parser) # When iterated over, ‘results‘ will contain the output from # target parser‘s close() method out = open(‘titles.txt‘, ‘w‘) out.write(‘\n‘.join(results)) out.close()

在運行版權數據時,代碼運行時間為 54 秒。目標解析可以實現合理的速度並且不會生成消耗內存的解析樹,但是在數據中為所有元素觸發事件。對於特別大型的文檔,如果只對其中一些元素感興趣,那麽這種方法並不理想,就像在這個例子中一樣。能否將處理限制到選擇的標記並獲得較好的性能呢?

使用 iterparse 方法

lxml 的 iterparse 方法是 ElementTree API 的擴展。iterparse 為所選的元素上下文返回一個 Python 叠代器。它接受兩個有用的參數:要監視的事件元組和標記名。在本例中,我只對 <Title> 的文本內容感興趣(達到 end 事件即可獲得)。清單 3的輸出與 清單 2 的目標解析器方法的輸出相同,但是速度應該會提高很多,因為 lxml 可以在內部優化事件處理。同時也會減少代碼量。

清單 3. 對指定的標記和事件進行簡單叠代
1 2 3 4 context = etree.iterparse(infile, events=(‘end,‘), tag=‘Title‘) for event, elem in context: out.write(‘%s\n‘ % elem.text.encode(‘utf-8‘))

如果運行這段代碼並監視它的輸出,可以看到它首先會非常快速地追加標題,然後又馬上減緩下來。快速檢查 top 會發現計算機已經進入交換:

1 2 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 170 root 15 -5 0 0 0 D 3.9 0.0 0:01.32 kswapd0

這裏發生了什麽?盡管 iterparse 起初並沒有消耗整個文件,但它也沒有釋放對每一次叠代的節點的引用。當對整個文檔進行重復訪問時,這點必須註意。不過,在本例中,我選擇在每次循環結束後回收內存。這包括對已經處理的子節點和文本節點的引用,以及對當前節點前面的兄弟節點的引用。這些引用中來自根節點的引用也被隱式地保留,如 清單 4 所示:

清單 4. 修改後的叠代去掉了不需要的節點引用
1 2 3 4 5 6 7 8 9 for event, elem in context: out.write(‘%s\n‘ % elem.text.encode(‘utf-8‘)) # It‘s safe to call clear() here because no descendants will be accessed elem.clear() # Also eliminate now-empty references from the root node to <Title> while elem.getprevious() is not None: del elem.getparent()[0]

為簡單起見,我將 清單 4 重構為一個函數,它接受一個可調用的 func 對當前節點執行操作,如 清單 5 所示。我將在後面的示例中使用這個方法。

清單 5. 函數循環遍歷上下文並在每次循環時調用 func,然後清除不必要的引用
1 2 3 4 5 6 7 def fast_iter(context, func): for event, elem in context: func(elem) elem.clear() while elem.getprevious() is not None: del elem.getparent()[0] del context

性能特點

清單 4 中的 iterparse 方法經過優化後生成的輸出與 清單 2 中目標解析器生成的輸出相同,但只用了一半的時間。當處理特定事件或標記名(比如本例)時,處理速度甚至比 cElementTree 還快。(但是,大多數情況下,如果解析是主要活動的話,cElementTree 的表現要比 lxml 優秀)。

表 1 展示了各種解析器技術在 基準測試側邊欄 中描述的計算機上測試版權數據使用的時間。

表 1. 比較叠代解析方法:從 <Title> 提取 text()

它的伸縮性如何?

對 Open Directory 數據使用 清單 4 中的 iterparse 方法,每次運行耗時 122 秒,約是解析版權數據所用時間的 5 倍。由於 Open Directory 數據的數量也約是版權數據的 5 倍(1.9 GB),這種方法應該表現出非常好性能,對特別大的文件尤其如此。

序列化

如果對 XML 文件所做的全部操作只是從單個節點獲取一些文本,可以使用一個簡單的正則表達式,其處理速度可能會比任何 XML 解析器都快。但是在實踐中,如果數據非常復雜,則幾乎不可能完成任務,因此不推薦使用這種方法。在需要真正的數據操作時,XML 庫的價值是不可估量的。

將 XML 序列化為一個字符串或文件是 lxml 的長項,因為它依賴於 libxml2 C 代碼庫。如果要執行要求序列化的任務,lxml 無疑是最佳選擇,但是需要使用一些技巧來獲得最佳性能。

在序列化子樹時使用 deepcopy

lxml 保持子節點及其父節點之間的引用。該特性的一個特點就是 lxml 中的節點有且僅有一個父節點(cElementTree 沒有父節點)。

清單 6 包含版權文件中的所有 <Record>,並寫入了一條只包含標題和版權信息的簡化記錄。

清單 6. 序列化元素的子節點
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from lxml import etree import deepcopy def serialize(elem): # Output a new tree like: # <SimplerRecord> # <Title>This title</Title> # <Copyright><Date>date</Date><Id>id</Id></Copyright> # </SimplerRecord> # Create a new root node r = etree.Element(‘SimplerRecord‘) # Create a new child t = etree.SubElement(r, ‘Title‘) # Set this child‘s text attribute to the original text contents of <Title> t.text = elem.iterchildren(tag=‘Title‘).next().text # Deep copy a descendant tree for c in elem.iterchildren(tag=‘Copyright‘): r.append( deepcopy(c) ) return r out = open(‘titles.xml‘, ‘w‘) context = etree.iterparse(‘copyright.xml‘, events=(‘end‘,), tag=‘Record‘) # Iterate through each of the <Record> nodes using our fast iteration method fast_iter(context, # For each <Record>, serialize a simplified version and write it # to the output file lambda elem: out.write( etree.tostring(serialize(elem), encoding=‘utf-8‘)))

不要使用 deepcopy 復制單個節點的文本。手動創建新節點、填充文本屬性並進行序列化,這樣做的速度更快。在我的測試中,對 <Title><Copyright> 調用 deepcopy 要比 清單 6 中的代碼慢 15%。在序列化大型後代樹(descendant trees)時,會看到 deepcopy 將使性能得到巨大的提升。

在使用 清單 7 中的代碼對 cElementTree 進行基準測試時,lxml 的序列化程序的速度幾乎提高了兩倍(50% 和 95%):

清單 7. 使用 cElementTree 序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def serialize_cet(elem): r = cet.Element(‘Record‘) # Create a new element with the same text child t = cet.SubElement(r, ‘Title‘) t.text = elem.find(‘Title‘).text # ElementTree does not store parent references -- an element can # exist in multiple trees. It‘s not necessary to use deepcopy here. for c in elem.findall(‘Copyright‘): r.append(h) return r context = cet.iterparse(‘copyright.xml‘, events=(‘end‘,‘start‘)) context = iter(context) event, root = context.next() for event, elem in context: if elem.tag == ‘Record‘ and event ==‘end‘: result = serialize_cet(elem) out.write(cet.tostring(result, encoding=‘utf-8‘)) root.clear()

有關叠代模式的更多信息,請參閱 ElementTree 文檔 “Incremental Parsing”(參見 參考資料 獲得鏈接)。

快速查找元素

完成解析後,最常見的 XML 任務是在解析後的樹中查找特定的數據。lxml 提供了簡化的搜索語法和完整的 XPath 1.0 等各種方法。作為用戶,您應當了解每種方法的性能特征和優化技巧。

避免使用 findfindall

findfindall 方法繼承自 ElementTree API,可使用簡化的類似 XPath 的表達式語言(稱為 ElementPath)查找一個或多個後代節點。從 ElementTree 遷移過來的用戶可以繼續使用 find/ElementPath 語法。

lxml 提供了另外兩種查找子節點的選項:iterchildren/iterdescendants 方法和真正的 XPath。如果表達式需要匹配一個節點名,那麽使用 iterchildreniterdescendants 方法以及其可選的標記參數,這要比使用 ElementPath 表達式快很多(有時速度會快上兩倍)。

對於更復雜的模式,可以使用 XPath 類預編譯搜索模式。使用標記參數(例如 etree.XPath("child::Title"))模擬 iterchildren 行為的簡單模式的執行時間與 iterchildren 是相同的。但是,預編譯仍然非常重要。在每次執行循環時編譯模式或對元素使用 xpath() 方法(參見 參考資料 中 lxml 文檔的描述),幾乎比與只編譯一次然後反復使用模式慢 2 倍。

lxml 中的 XPath 計算非常。如果只需要對一部分節點進行序列化,那麽在檢查所有節點之前使用精確的 XPath 表達式限制條件,這樣效果會好很多。例如,限制示例序列化使其只包括含有 night 單詞的標題,如 清單 8 所示,這只需序列化完整數據所用的時間的 60%。

清單 8. 使用 XPath 類進行有條件的序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def write_if_node(out, node): if node is not None: out.write(etree.tostring(node, encoding=‘utf-8‘)) def serialize_with_xpath(elem, xp1, xp2): ‘‘‘Take our source <Record> element and apply two pre-compiled XPath classes. Return a node only if the first expression matches. ‘‘‘ r = etree.Element(‘Record‘) t = etree.SubElement(r, ‘Title‘) x = xp1(elem) if x: t.text = x[0].text for c in xp2(elem): r.append(deepcopy(c)) return r xp1 = etree.XPath("child::Title[contains(text(), ‘night‘)]") xp2 = etree.XPath("child::Copyright") out = open(‘out.xml‘, ‘w‘) context = etree.iterparse(‘copyright.xml‘, events=(‘end‘,), tag=‘Record‘) fast_iter(context, lambda elem: write_if_node(out, serialize_with_xpath(elem, xp1, xp2)))

在文檔的其他部分查找節點

註意,即使使用了 iterparse,仍然可以根據當前的節點 使用 XPath 謂詞。要查找後面緊跟一個記錄(記錄的標題包含單詞 night)的所有 <Record> 節點,則執行以下操作:

1 etree.XPath("Title[contains(../Record/following::Record[1]/Title/text(), ‘night‘)]")

然而,如果使用 清單 4 描述的節省內存的叠代策略,該命令將不會返回任何內容,因為解析完整個文檔時將刪除前面的節點:

1 etree.XPath("Title[contains(../Record/preceding::Record[1]/Title/text(), ‘night‘)]")

雖然可以編寫有效的算法來解決這一問題,但是對於那些需要跨節點進行分析的任務(特別是那些隨機分布在文檔中的節點),使用使用 XQuery(比如 eXist)的 XML 數據庫更加適合。

提高性能的其他方法

除了使用 lxml 內部 的特定方法外,還可以通過庫以外的方法提高執行速度。其中一些方法只需要修改一下代碼;而另一些方法則需要重新考慮如何處理大型數據。

Psyco

Psyco 模塊常常被忽略,但是它可以通過較少的工作提高 Python 應用程序的速度。一個純 Python 程序的典型性能收益是普通程序的 2 至 4 倍,但是 lxml 使用 C 語言完成了大部分工作,因此它們之間的差別非常小。當我在啟用 Psyco 的情況下運行 清單 4 時,運行時間僅僅減少了 3 秒(43.9 秒對 47.3 秒)。Psyco 需要很大的內存開銷,如果機器進入交換,它甚至會抵銷 Python 獲得的任何性能。

如果由 lxml 驅動的應用程序包含頻繁執行的核心純 Python 代碼(可能是對文本節點執行的大量字符串操作),那麽僅對這些方法啟用 Psyco 可能會有好處。有關 Psyco 的更多信息,參見 參考資料。

線程化

相反,如果應用程序主要依賴內部的、C 驅動的 lxml 特性,那麽可能適合將它作為多處理環境下的線程化應用程序運行。關於如何啟動線程有很多限制 — 對 XSLT 而言尤其如此。要了解更多內容,可參考 lxml 文檔中有關線程的 FAQ 部分。

拆分解決

如果可以將特別大的文檔分解為單個的、可分析的子樹,那麽就可以在子樹級別上分解文檔(使用 lxml 的快速序列化),並將工作分布到位於多臺計算機中的這些文件。對於執行 CPU 密集型的脫機任務,使用隨需應變的虛擬服務器正成為一種日益流行的解決方案。可以獲得 Python 程序員用於設置和管理 Amazon 虛擬 Elastic Compute Cloud (EC2) 集群的詳細指南。參見 參考資料 了解更多信息。

適合大型 XML 任務的一般策略

本文給出的具體代碼示例可能並不適合您的項目,但是對於 GB 級或以上的 XML 數據,請考慮以下的原則(已通過測試和 lxml 文檔的驗證):

  • 使用叠代解析策略,漸進式地處理大型文檔。
  • 如果需要隨機地搜索整個文檔,那麽使用索引式 XML 數據庫。
  • 只選擇需要的數據。如果只對特定的節點感興趣,使用按名字選擇的方法。如果需要謂詞語法,那麽嘗試可用的 XPath 類和方法。
  • 考慮手頭的任務和開發人員的舒適程度。如果不需要考慮速度的話,lxml 的對象化或 Amara 等對象模型對於 Python 開發人員來說可能更自然。cElementTree 在只需要進行解析時才會體現出速度優勢。
  • 花些時間做些非常簡單的基準測試。在處理數百萬條記錄時,細微的差別就會累積起來,但是並不能總是很明顯地看出哪種方法最有效。

結束語

很多軟件產品都附帶了 pick-two 警告,表示在速度、靈活性或可讀性之間只能選擇其中兩種。然而,如果得到合理使用,lxml 可以滿足全部三個要求。那些希望提高 DOM 性能或使用 SAX 事件驅動模型的 XML 開發人員現在有機會獲得更高級的 Python 庫。擁有 Python 背景的開發人員在剛開始接觸 XML 時也可以輕松地利用 XPath 和 XSLT 的表達能力。這兩種編程風格可以在一個基於 lxml 的應用程序中和諧共存。

本文只介紹了 lxml 的一小部分功能。請查看 lxml.objectify 模塊,它主要針對那些較小的數據集或對 XML 的依賴不是強的應用程序。對於不具備良好格式的 HTML 內容,lxml 提供了兩個有用的包:lxml.html 模塊和 BeautifulSoup 解析器。如果要編寫能夠從 XSLT 調用的 Python 模塊,或創建定制的 Python 或 C 擴展,還可以擴展 lxml。可以從 參考資料 中的 lxml 文檔中找到有關所有這些內容的信息。

使用由 Python 編寫的 lxml 實現高性能 XML 解析