位元詩人 [Groovy] 程式設計教學:處理 XML 檔案

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

處理 XML 資料是自動化腳本常見的任務之一,身為 Java 平台的命令稿語言,自然不能在這項任務中缺席。本文介紹使用 Groovy 處理 XML 的方式,並說明使用 Groovy 進行這項任務的益處。

groovy.xml.XmlSlurpergroovy.xml.XmlParser

在 Groovy 中,處理 XML 資料是內建的功能。根據處理 XML 資料的方式,可細分為 XmlSlurperXmlParser 兩種類別。兩者的差別在於前者適用於只讀不寫的情境,後者則適用於邊讀邊寫的情境。

本文的範例情境不需修改資料,所以會使用 XmlSlurper 類別。

使用 GPath 拜訪 XML 節點

Groovy 在處理 XML 的特色是用 GPath 取代 XML 內建的 XPath 來走訪 XML 節點。GPath 利用基於 Groovy 的領域專用語言來簡化走訪 XML 節點的任務。例如, a.b.c 會自動對應到以下 XML 資料:

<a>
    <b>
        <c>Some text</c>
    </b>
</a>

比起寫 XPath,直接在 Groovy 腳本中寫 GPath 會更簡單。

Groovy 是一個易學易用的腳本語言

除了內建 XML 相關功能外,Groovy 本身是一個易學易用的腳本語言。其語法大抵上相容於 Java,但更加簡化,所以寫起來很快。套件也都是直接用 Java 的,不需要刻意找給 Groovy 用的套件。

但直接寫給 Groovy 的學習資料比較少,所以有些程式設計者不願意使用 Groovy。變通的方式是在找不到 Groovy 範例時改找 Java 的範例,再自行用 Groovy 的語法來簡化程式碼,通常都可以使用。

範例情境:將 JMdict 寫入 SQLite 資料庫

JMdict 是開放源碼的日英詞典。該字典的原始檔案是 XML 文件。但在使用此字典時,不會直接使用 XML 文件,因為每次查詢單字時都要重新解析文件的話效率不佳。此外,有些詞彙會一字多義,以 XML 型態的資料不利於跨越多筆資料查詢。

實務上,會將字典轉為資料庫後再讀取。因資料庫本身就是以有效率的方式儲存資料,還內建查詢資料的語法,不需再以程式語言重新實作。由於字典資料庫在建立後基本上是只讀不寫,使用 SQLite 是個不錯的方案。

對於字典來說,近 19 萬筆條目 (entry) 算蠻豐富的。學到日檢 N1,大概學 10,000 個左右的單字量。但對 SQLite 來說,這樣的資料量相當少,不會造成資料庫的負擔。

接下來的文章會繼續說明這個範例情境,如果不想看文字說明,可以直接參考範例程式碼

JMdict 的資料形態

在處理資料前,要先了解資料。我們從 JMdict 中擷取一個條目來看:

<entry>
<ent_seq>1464530</ent_seq>
<k_ele>
<keb>日本語</keb>
<ke_pri>news1</ke_pri>
<ke_pri>nf02</ke_pri>
</k_ele>
<r_ele>
<reb>にほんご</reb>
<re_pri>news1</re_pri>
<re_pri>nf02</re_pri>
</r_ele>
<r_ele>
<reb>にっぽんご</reb>
</r_ele>
<sense>
<pos>&n;</pos>
<xref>国語・こくご・2</xref>
<gloss>Japanese (language)</gloss>
</sense>
</entry>

JMdict 的條目以 <entry> 標籤包住。每個條目會有假名 (kana)、漢字 (kanji)、品詞 (part of speech)、(英文的) 解釋、其他說明事項等子條目。本範例程式碼會直接捨去其他事項的部分。

大部分條目都只會有一個假名,但偶爾會有一條目多假名的情形。品詞也可能是一至多個,像是有些詞彙可兼做名詞和 する (suru) 動詞。

漢字則可能是零到多個。有些詞彙只會以假名書寫,像是以片假名 (katakana) 書寫的詞彙是外來語,這時候不會有漢字。助詞、助動詞等文法詞彙也不會有漢字。

解釋則可能是一至多個。要注意解釋有可能散布在不同 <sense> 標籤中。在 JMdict 中,同一個 <sense> 標籤內的解釋是同義或相關意義,但不同 <sense> 標籤的解釋則是不同的意義。

在了解資料的形態後,就可以開始設計資料庫綱要。詳見下一節。

JMdict 的資料庫綱要 (Database Schema)

以下是品詞的表格綱要:

field type
id integer
pos text

品詞本身是獨立在字彙之外的概念,所以用一個獨立的表格來存。

以下是假名的表格綱要:

field type
id integer
kana text

同樣的假名在表格中只會出現一次。假名和品詞的連動會放在另一個表格來處理。

以下是漢字的表格綱要:

field type
id integer
kanji text

我們也把漢字的部分正規化。

以下是 (英文的) 解釋的表格綱要:

field type
id integer
definition text
kana_id foreign id from kana
kanji_id foreign id from kanji
pos_id foreign id from pos

由於解釋會重覆的機率很低,這裡也未完全正規化。

分段展示程式碼

在本節中,我們會分段展示及說明範例 Groovy 命令稿。在這個過程中學習使用 Groovy 處理 XML 資料。我們會以概念的先後次序來展示程式碼,且為了節省篇幅,不會展示所有的程式碼。讀者可以搭配完整的命令稿來看,更容易理解此範例程式碼。

資料庫的位置會和命令稿相同,故先取得命令稿所在的位置,再算出資料庫所在的位置:

/* Groovy way to get the directory of the script itself. */
def path = new File(getClass().protectionDomain.codeSource.location.path).parent
def database = Paths.get(path, "jmdict.sqlite")

其實在這裡開了個新的 File 物件,這算是相對浪費資源的做法。但這個動作在整個命令稿只執行一次,就不要太計較了。

在資料庫中建立各個表格,待寫入資料:

/* Trick to use SQLite JDBC. */
Class.forName("org.sqlite.JDBC")

groovy.sql.Sql conn = null

/* Create the tables in the database. */
try {
    conn = groovy.sql.Sql.newInstance("jdbc:sqlite:${database}", "org.sqlite.JDBC")

    conn.execute(createPOSTableQuery)
    conn.execute(createKanaTableQuery)
    conn.execute(createKanjiTableQuery)
    conn.execute(createTranslationTableQuery)
}
catch (SQLException e)
{
    println(e.getMessage())
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

在撰寫資料庫相關的程式時,關閉資料庫的部分會寫在 try 敘述的 finally 子區塊,以確保資料庫連接有釋放掉。

以下節錄其中一個 SQL 敘述:

final createPOSTableQuery = """CREATE TABLE IF NOT EXISTS pos
(id INTEGER PRIMARY KEY AUTOINCREMENT,
 pos TEXT NOT NULL)"""

基本上就是 SQLite 的 CREATE 語法。由於該 Groovy 命令稿可能呼叫多次,要考慮資料表已存在的情形。其餘三個 SQL 敘述寫法雷同,就不展示出來了。

將品詞相關的資料寫入資料表:

def pos = ["noun", "pronoun", "verb", "adjective", "adjectival noun", "adverb",
    "auxiliary verb", "particle", "conjunction", "interjection", "attributive",
    "auxiliary", "unclassified"]

/* Write the PoS data into the database. */
try {
    conn = groovy.sql.Sql.newInstance("jdbc:sqlite:${database}", "org.sqlite.JDBC")

    for (String p : pos) {
        final insertPOSQuery =
"""INSERT INTO pos (pos) SELECT ?
WHERE NOT EXISTS (SELECT id FROM pos WHERE pos=?)"""

        conn.execute insertPOSQuery, p, p
    }
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

品詞的資料是固定的,而且數量有限,直接寫死在命令稿中也無妨,不需要真的逐一從字典原始碼中讀。

XmlSlurper 物件讀入字典檔:

/* Read the dictionary file as a XML object. */
def dict = new XmlSlurper(false, false, true).parse(new File("JMdict_e"))

讀入後,將條目 (entry) 逐一寫入資料庫中。由於這段程式碼比較長,我們先以註解代替,用來觀察程式碼整體的架構:

try {
    conn = groovy.sql.Sql.newInstance("jdbc:sqlite:${database}", "org.sqlite.JDBC")

    /* Iterate over the entries of the dictionary. */
    dict.entry.each { entry ->
        /* Extract kana(s) from an entry. */

        /* Extract kanji(s) from an entry. */

        entry.sense.each { sense ->
            /* Extract translation(s) from an entry. */

            /* Join gloss(es) into a definition string. */

            /* Iterate over the kana, kanji, pos combo
                and write a definition to the database. */
        }
    }
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

dict 代表整個 XML 檔案物件。dict.entry 代表該 XML 文件的 <entry> 標籤。由於 <entry> 標籤有多個,故用 each 來走訪。

同理,在每個 <entry> 中可能有一至多個 <sense> 標籤,所以用 entry.sense.each 來走訪 <sense> 標籤。

接下來,我們逐一看每段註解實際的行為。

entry 節點中取出假名 (kana):

/* Extract kana(s) from an entry. */
def kana = []
entry.r_ele.each { r_ele ->
    kana << r_ele.reb.toString()
}

在大部分情形下,每個 entry 只會有一個相對應的假名。但偶爾會出現多個,所以 kana 要用串列來儲存資料。

同理,漢字 (kanji) 可能有零到多個,所以要用串列來儲存資料:

/* Extract kanji(s) from an entry. */
def kanji = []
entry.k_ele.each { k_ele ->
    kanji << k_ele.keb.toString()
}

處理 sense 的部分較複雜,所以我們分段來看:

def posList = []

entry.sense.each { sense ->
    def glossList = []

    if (sense.pos.size() > 0) {
        posList = []
        sense.pos.each { p ->
            posList << p.toString()
        }
    }

    sense.gloss.each { gloss ->
        glossList << gloss.toString()
    }

    def definition = String.join("; ", glossList)

    if (kanji.size() <= 0) {
        kana.each { na ->
            posList.each { p ->
               /* Insert the kana into the database
                   if the kana is not in the database. */

               /* Get current kana id. */

               /* Get current pos id. */

               /* Write the definition of the kana of the pos
                   to the database. */
            }
        }
    }
    else {
        kana.each { na ->
            kanji.each { ji ->
                posList.each { p ->
                    /* Write the kana to the database.
                        if the kana is not in the database. */

                    /* Get current kana id. */

                    /* Get current pos id. */

                    /* Get current kanji id. */

                    /* Write the definition of the kanji of the kana of the pos
                        to the database. */
                }
            }
        }
    }
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

由於 JMdict 把品詞的資料埋在 sense.pos 中,而且不一定會出現,我們要在程式中偵測品詞是否出現,並將資料存起來。

英文解譯的部分埋在 sense.gloss 中。由於同一個 <sense> 中的 <gloss> 基本上是相近語義,所以我們把 <gloss>; 合併成單一字串 definition

每個條目可能有相對應的漢字,也有可能完全沒有漢字。所以要用 if 敘述區分兩種情境。

當該條目沒有漢字時,只要以假名和品詞去排列組合即可:

kana.each { na ->
    posList.each { p ->               
        conn.execute insertKanaQuery, na, na

        def kana_id
        conn.query(selectKanaQuery, [na]) { result ->
            while (result.next()) {
                kana_id = result.getInt('id')
                break
            }
        }

        def pos_id = -1
        conn.query(selectPOSQuery, [posTrans(p)]) { result ->
            while (result.next()) {
                pos_id = result.getInt('id')
                break
            }
        }

        if (pos_id <= 0) {
            return
        }

        conn.execute insertTransQuery, definition, kana_id, pos_id, definition, kana_id, pos_id
    }
}

posTrans 是一個自製的函式,該函式將 JMdict 的品詞轉換到資料庫所用的品詞。因為 JMdict 對品詞的分類較細,而我們的資料庫不需要分那麼細。posTrans 是一個大的 switch 敘述,為了節省篇幅,這裡就不放上來了。

而該條目有漢字時,則要以假名、漢字、品詞去排列組合:

kana.each { na ->
    kanji.each { ji ->
        posList.each { p ->
            System.out.println "Record: ${na} ${ji} ${p}"

            conn.execute insertKanaQuery, na, na

            def kana_id
            conn.query(selectKanaQuery, [na]) { result ->
                while (result.next()) {
                    kana_id = result.getInt('id')
                    break
                }
            }

            def pos_id = -1
            conn.query(selectPOSQuery, [posTrans(p)]) { result ->
                while (result.next()) {
                    pos_id = result.getInt('id')
                    break
                }
            }

            if (pos_id <= 0) {
                return
            }

            conn.execute insertKanjiQuery, ji, ji

            def kanji_id = -1
            conn.query(selectKanjiQuery, [ji]) { result ->
                while (result.next()) {
                    kanji_id = result.getInt('id')
                    break
                }
            }

            if (kanji_id <= 0) {
                return
            }

            conn.execute insertTransWithKanjiQuery, definition, kana_id, kanji_id, pos_id, definition, kana_id, kanji_id, pos_id
        }
    }
}

為了節省篇幅,我們略去 SQL 敘述的部分。請讀者自行追蹤原命令稿的程式碼。

附註

JMdict 的條目蠻多的,筆者在 AWS Cloud 9 跑該命令稿時,約花了三個多小時才跑完。此外,由於 JMdict 的資料量較大,筆者使用 8GB 的 AWS 虛擬機器來跑。跑完該命令稿後,SQLite 資料庫可重覆使用,而且實際查詢單字時效率還蠻快的,所以花點時間轉換資料是值得的。

筆者先前用自己的 Windows 桌機來跑,跑了很久還跑不完。由於完全沒有錯誤訊息,很難推測真實原因。故筆者改用 Cloud 9 重跑整個命令稿。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。