Namespace 問題
在上一篇文章『HTML 資訊汲取(中篇) - Default namespace 問題』中提到:在 XPath 中,沒有所謂 default namespace (預設命名空間)。若 XPath 路徑未使用 prefix (前置字符) 指明 namespace,則其對應的 namespace 為 empty namespace (空命名空間)。因此,若在 XML 文件中定義了 default namespace,則所有的標籤必定都歸屬於某個不為空的 namespace。此時,未指明 namespace 的 XPath 路徑,將對應不到任何元素。
另一方面,TagSoup 處理過的 HTML 文件,有三個與 namespace 有關的問題:
- TagSoup 處理過的 HTML 文件,一律輸出為 XHTML 格式,並且定義了
xmlns="http://www.w3.org/1999/xhtml"
這個 default namespace,以及xmlns:html="http://www.w3.org/1999/xhtml"
這個以html
為 prefix 的 namespace。而其餘的 namespace 的定義,都將被移除。 - 由於 TagSoup 處理過的 HTML 文件,含有 default namespace 的定義,使用 XPath 選取元素時,一定要在路徑的標籤或屬性前,加上
html
這個 prefix,才能對應到元素。 - TagSoup 處理過的 HTML 文件,其元素標籤若含有 prefix 定義,即使 prefix 是
html
,都會被修改並對應到urn:x-prefix:html
這樣的 URI(參考 TagSoup 原始碼中 Parser 類別的 foreign() 函數、ElementType 類別的 namespace() 函數以及 change log),因而使該標籤對應不到原本正確的 namespace 的 URI。導致使用了該 prefix 的 XPath 路徑,也對應不到正確的標籤。(原本應該能正確對應的,這一點可以經由使用 JDOM 內建的 XML 解析器的實驗證明。)
思考可能的解決方案
內建的 XML 解析器不會有 namespace 的問題,但是無法處理 non-well-formed HTML。顯然此路不通……。
TagSoup 可以處理 non-well-formed HTML,但是會有 namseapce 問題。該是時候放棄 TagSoup,另找一個 HTML 解析器了嗎?還是如果我們可以配合 TagSoup 輸出的 namespace 定義,在 XPath 中一律使用 html
prefix;或是,相反地,讓 TagSoup 不輸出 namespace 。是否就可以解決問題呢?(雖然邏輯上還存在著一個完美的可能性,就是讓 TagSoup 不輸出 default namespace,也同時能保留外部 namespace 的定義,不過這並不容易達到。這已牽涉到 TagSoup 的設計架構了,除非大幅修改 TagSoup 的架構,讓它正確處理外部 namespace 的定義。但是這已經超出本文的目的了。)
試著分析一下這兩種方法的優劣:
在 XPath 路徑中一律使用 prefix | 關掉 TagSoup 對 namespace 的處理 | |
---|---|---|
致命缺點 |
|
|
缺點 |
|
|
優點 |
|
|
接下來,讓我詳細說明這兩種作法。
方法一: 在 XPath 路徑中,一律使用 prefix
首先,若 HTML 中不含其他 namespace 的定義,除了前面提到的,可能會比較麻煩或不友善的問題,這其實是比較好,比較正規的解決方式。但若是 HTML 中另含有其他 namespace 定義(譬如 Google News 中,另外定義了 data
namespace),則會被 TagSoup 移除,造成錯誤。真可惜,如果 TagSoup 不會移除其他的 namespace 就好了,畢竟這是比較完善的解決方案。
TagSoup 的作者 John Cowan 也推薦這個作法,在 Google Groups 的 tagsoup-friends 討論群組中,John Cowan 這樣回答:
Re: Thank you for great product! Workaround for namespace issue: John Cowan May 13 2010, 2:03 am misha680 scripsit: > I am using tagsoup with XOM. It is working great, however I have run > into the following namespace issues as described here: > http://www.supermind.org/blog/613/dom4j-xpath-tagsoup-namespaces-sweet > Per the post, it looks like there is not an easy solution, but I was > hoping perhaps that there might be something (new) I am missing? Well, you could suck it up and use html: prefixes in your XPath expressions, which is what I would recommend. There is a long-standing bug in getting TagSoup not to send namespace information. When I get a chance I'll work on that.
作者也提到 namespace 問題是長久以來的 bug,如果有機會他會設法解決。
如果在你的專案中,可以在 XPath 路徑中使用 prefix,而且要解析的 HTML 文件,也確定都沒有使用 prefix 引用 namespace 的話,這就是標準答案了。如果不是,或是純粹好奇有沒有其他的解法,請繼續往下看。
方法二:設法略過 namespace 的處理
設法關掉 TagSoup 對於 namespace 的處理,這個方法是希望讓原本屬於 default namespace 的元素或屬性,歸到 empty namespace 下,讓未指定 prefix 的 XPath 可以對應。不過,一旦關閉 namespaces 的處理,所有的 namespaces 都將失效,都成為 empty namespace 了,這將造成 namespace 衝突,所以一定要確定要處理的 HTML 文件未使用外部 namespace 才行。
事實上,SAX API 提供有標準的作法,可以將 namespace 的處理關掉,透過 setFeature() 函數 就可以做到:
1 | builder.setFeature( "http://xml.org/sax/features/namespaces", false ) |
其中,參數 http://xml.org/sax/features/namespaces
是 SAX API 定義的所謂 features (特性或模式),用來設定 XML 解析器或是文件處理器的行為。更多的 features 可參考 SAX API 的 features 列表。
可惜的是,這個方法在 JDOM/TagSoup 這個組合行不通。網路上有一堆討論,讀者若有興趣,可以使用 tagsoup namespace 關鍵字自行搜尋。值得注意的是,在這一堆討論中,其中一篇討論文中提到:
How to parse XML document with default namespace with JDOM XPath TagSoup lets you disable namespaces by setting the standard SAX feature `http://xml.org/sax/features/namespaces` to false. Unfortunately for you, JDOM will turn it back on before parsing. You might need to use a standard DOM instead; Java 5 and Java 6 have built-in XPath support.
意思大致上是說,TagSoup 是支援 http://xml.org/sax/features/namespaces
這個 features 的,但是 JDOM 解析文件前,會再把 namespaces 的處理功能重新打開。
基本上,這個說法是對的:JDOM 會再把 namespace 的處理功能重新打開。不過情況還要再複雜一些,稍後我會詳細說明。
上面的回答中也提到,應該找標準的 DOM 的處理方法。不過,Java 5/6 內建的 JAXP 的 DocumentBuilderFactory,是透過 javax.xml.parsers.SAXParserFactory
這個系統屬性來建立解析器的。依照 TagSoup 文件 的說明,若要透過 JAXP 使用 TagSoup 作為解析器,則必須透過 System 類別的靜態方法 setProperty() 函數 設定此屬性:
1 | System.setProperty( "javax.xml.parsers.SAXParserFactory", "org.ccil.cowan.tagsoup.jaxp.SAXFactoryImpl" ) |
這等於是使用了 global variable!太邪惡了!在大型專案中,這可是會讓其他需要處理 XML 的模組,都受到影響的!絕對要避免使用。
其實,前面提到的 TagSoup 的作者回覆的問題中,提問者指出的參考文章:
就是在這樣絕望的情況下,自行針對解析完畢的 DOM 物件,尋訪其全部的元素,再一一移除每個元素的 namespace 定義。不過,文章中介紹的是針對 dom4j 的作法,在 JDOM 下,要怎麼做呢?其實也不難,我的作法如下:
1 | def removeNamespace( element ) { |
上面這個函數負責移除指定的元素以及其所有子元素的所有的 namespace 定義。如果只要移除特定的 namespace,則需要判斷 URI,不過由於 TagSoup 會修改使用 prefix 的標籤的 URI 的定義,這樣做並沒有意義。另外,上面的函數並未處理屬性的 namespace,需要的話,請讀者自行嘗試加上屬性的處理。
有了上面的函數,要移除整個文件的 namespace 定義,只要將 JDOM 的 Document 物件傳入即可:
1 | def builder = new SAXBuilder( "org.ccil.cowan.tagsoup.Parser" ) |
從源頭下手
透過事後的處理,將 JDOM/TagSoup 產生的 namespace 移除,確實可以達到效果,不過這樣做並不是很有效率。
為甚麼不直接從源頭修正呢?
如果前面提到的『JDOM 會再把 namespace 的處理重新打開』是正確的,我們是不是可以在實際解析之前,再把 namespace 的處理關掉呢?
查看 JDOM 的原始碼,可以在 SAXBuilder
類別中看到,XML 解析器是在 createParser()
函數中建立的:
1 | protected XMLReader createParser() throws JDOMException { |
建立 XML 解析器之後,會呼叫 setFeaturesAndProperties()
函數,設定解析器:
1 | setFeaturesAndProperties(parser, true); |
注意到這裡的第二個參數是 true
。再來查看 setFeaturesAndProperties()
函數:
1 | private void setFeaturesAndProperties(XMLReader parser, boolean coreFeatures) |
因為 coreFeatures
參數值是 true
,所以進入 if
區塊,呼叫 internalSetFeature()
函數。這裡可以看到,設定的是 namespaces
及 namespace-prefixes
這兩個 features,而傳入的參數值是 true
。
其實看到這邊心裡就有數了。不過,我們還是看看 internalSetFeature()
函數,確認一下:
1 | private void internalSetFeature(XMLReader parser, String feature, boolean value, String displayName) |
顯然,JDOM 的確是把 namespaces 這個 feature 又打開了。
在動手修改 JDOM 之前,先做個簡單的實驗確認一下:
1 | static class ParserHack extends org.ccil.cowan.tagsoup.Parser { |
我建立了一個假的 XML 解析器 ParserHack
,讓它繼承 TagSoup 的 org.ccil.cowan.tagsoup.Parser
,然後覆寫(override) setFeature()
函數,在這個函數中,比對 feature 名稱,若為 namespaces
,則強迫設定為 false
。然後,將這個 class 的名稱傳入 SAXBuilder
的 constructor,用來取代 TagSoup 的解析器:
1 | def builder = new SAXBuilder( ParserHack.class.canonicalName ) |
將上面的程式碼套入上一篇的實驗程式中,執行結果如下:
1 | Caught: org.jdom.IllegalNameException: The name "" is not legal for JDOM/XML element: XML names cannot be null or empty. |
發生錯誤了。這表示,即使 JDOM 並未強制將 namespaces
這個 feature 打開,TagSoup 還是會出錯。看來,問題並不光是修改 JDOM 的原始碼這麼簡單。
讓我們看看 TagSoup 的原始碼,看看問題到底在哪邊?
既然問題是在呼叫 setFeature()
函數,設定 http://xml.org/sax/features/namespaces
這個 feature 之後才發生的,我們就找找看在程式中有哪些地方用到它:
首先,http://xml.org/sax/features/namespaces
這個 feature ,在 src/java/org/ccol/cowan/tagsoup/Parser.java 中,被定義為 namespacesFeature
這個常數:
1 | public final static String namespacesFeature = "http://xml.org/sax/features/namespaces"; |
然後,在 setFeature()
函數中,判定若參數 name
值等於 namespacesFeature
,則將 namespace 這個變數設定為指定的值,在上面的實驗裡,這個值是 false
:
1 | public void setFeature (String name, boolean value) |
最後,真正使用 namespaces
這個變數的地方,在 push()
和 pop()
函數中,我們看 pop()
函數就好:
1 | private void pop() throws SAXException { |
注意到上面對 ContentHandler
的 endElement()
函數的呼叫,傳入的三個參數分別是 namespace
, localName
, name
。但是,在前一行中:
1 | if (!namespaces) namespace = localName = ""; |
若 namespaces
變數值為 false
,則會將 namespace
及 localName
兩個要傳給 endElement() 函數的參數都設定為空字串!所以,JDOM 就抱怨 localName
是空字串。這就是我們看到的錯誤訊息的元兇!...是嗎?
本來我也以為,當 http://xml.org/sax/features/namespaces
這個 feature 設定為 false
時,TagSoup 呼叫 startElement()
及 endElement()
函數時將 localName
設定為空字串,這樣的處理是錯誤的。但是 SAX 的 startElement() 函數的說明文件 卻是這麼寫的:
localName - the local name (without prefix), or the empty string if Namespace processing is not being performed
意思大致是說,當不處理 namespace 時(也就是 http://xml.org/sax/features/namespaces
設定為 false
時),localName
這個參數是空字串。
另外,這一篇 SAX 的 namespace 的說明文章,也詳細說明了http://xml.org/sax/features/namespaces
和 http://xml.org/sax/features/namespace-prefixes
這個兩個 feature 開啟與關閉時,startElement()
及 endElement()
函數的相關行為。
所以,是 JDOM 的處理方式不正確,才發生 IllegalNameException
這個錯誤的。不過,也不難想像這個結果:JDOM 就是不允許關閉 namespace 的處理,否則它何必非要將 http://xml.org/sax/features/namespaces
這個 feature 定義為 coreFeatures
,還強制將它打開呢?
既然問題都在 JDOM,就動手來改吧(還稱不算是修正,頂多算是 hack 吧):
首先,要先解除封印︰允許 namespaces
及 namespace-prefixes
兩個 features 的設定(其實 TagSoup 不支援 namespace-prefixes
。可以參考 Parser.java 中 namespacePrefixesFeature
變數的註解)。打開 SAXBuilder.java,找到 setFeaturesAndProperties()
函數,將下列兩個 internalSetFeature()
函數的呼叫註解掉:
1 | // Setup some namespace features. |
再來,要處理 localName
為空字串的狀況。同時,除了元素名稱之外,屬性名稱也有類似的問題:打開 SAXHandler.java,找到 startElement()
函數,加上下面 hack start
到 hack end
部份的程式碼,為避免說明太過繁瑣造成誤解,完整函數修改如下:
1 | public void startElement(String namespaceURI, String localName, String qName, Attributes atts) |
使用 ant 建置專案,然後將 build/jdom.jar 放到 CLASSPATH 中(記得先備份原有的 jdom.jar)。
最後,修正實驗用的程式:
執行結果如下:
1 | input: |
雖然 html
標籤中,仍留有 xmlns:html
的定義,但是已經沒有 default namespace 的定義了。同時,輸出的 span
標籤元素,已不再含有任何 namespace 宣告,也不含 prefix。而 XPath 選擇的結果,只有未指定 prefix 的 XPath 路徑://span
,可以篩選出 span
元素。
雖然大部分情況下,這樣的修改就夠了,不過細心的讀者可能已經發現,我的實驗程式中的 HTML 文件內容,元素都未包含有屬性。所以,可能會有這樣的疑問:修改後的 JDOM 可以正確處理屬性標籤的 prefix 嗎?答案是:JDOM 已經可以了,但是 TagSoup 還要一點小修改。因為 TagSoup 在 namespaces
這個 features 設定為 false
時,仍然會呼叫 ContentHandler
的 startPrefixMapping()
及 endPrefixMapping()
函數,這會導致帶有 prefix 的屬性的元素,輸出時會含有類似 xmlns:xxx="urn:x-prefix:xxx"
的定義。
要編譯 TagSoup 之前,需要先做一些準備工作。我的環境使用的是 JDK 1.6.x,根據 TagSoup 網站的說明,若要在 Java 5.x+ 以上編譯,必須使用 Saxon 6.5.5:
**Warning: TagSoup will not build on stock Java 5.x or 6.x!** Due to a bug in the versions of Xalan shipped with Java 5.x and 6.x, TagSoup will not build out of the box. You need to retrieve [Saxon 6.5.5][19], which does not have the bug. Unpack the zipfile in an empty directory and copy the saxon.jar and saxon-xml-apis.jar files to $ANT_HOME/lib. The Ant build process for TagSoup will then notice that Saxon is available and use it instead.
所以請先下載 Saxon 6.5.5,解開後,將 saxon.jar 及 saxon-xml-apis.jar 放到 ant 安裝目錄的 lib 目錄下。
然後請打開 Parser.java,我們要將所有使用到 startPrefixMapping()
及 endPrefixMapping()
函數的地方,都加上是否啟用 namespaces
的判斷。同樣地,要修改的地方都放在 hack start
到 hack end
註解中間,為了避免說明不清造成誤解,修改後的函數的完整列表如下:
1 | public void parse (InputSource input) throws IOException, SAXException { |
修改後,同樣使用 ant 建置專案,完成後,將 build/lib/tagsoup-1.2.jar 放到 CLASSPATH 下即可(記得先備份原有的 tagsoup-1.2.jar)。
為了確認能否正確處理屬性,還要將實驗程式加上屬性:
執行結果如下︰
1 | input: |
不但 id
屬性可以正確處理,連 html
標籤上的 xmlns:html
定義都移除了。帥啊!
最後,套用到 gnews.groovy 看看:
輸出結果,就請讀者自行動手試試看吧。
結論
若讀者的專案可以於 XPath 中使用 prefix,則推薦於專案中使用完整路徑來選取元素。相反地,若不適合在專案中使用含 prefix 的 XPath 路徑,則可依照本文提供的兩種方法,將 default namespace 移除。
另外,由於 TagSoup 完全不處理 http://www.w3.org/1999/xhtml
以外的 namespace,並直接將外部 namespace 都對應為 xmlns:xxx="urn:x-prefix:xxx"
的形式。所以,當處理含有外部 namespace 定義的文件時,應考慮使用標準 XML 解析器進行解析。若使用 TagSoup 解析,將會遇到許多困難。
歡迎大家的回饋與心得分享。