位元詩人 [Groovy] 程式設計教學:自給自足的命令稿

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

當我們撰寫程式的目的是任務自動化時,會使用相對簡單易用的程式語言來寫。這類型程式沒有什麼高深的演算法,保護程式碼並不是重要的考量,也不需要很好的程式效能。這時候,使用高階直譯語言撰寫命令稿 (script) 就是常見的選項。

雖然 Groovy 並不是最常見的自動化命令稿語言,我們在本文中會介紹使用 Groovy 撰寫命令稿的好處,並且展示實際的應用範例。

使用命令稿得重建運行環境

當我們在撰寫命令稿時,不會只用程式語言內建的功能,而會使用社群函式庫來補足所需的特性。但社群函式庫會造成額外的相依性問題。

當命令稿的使用者是程式設計者本人時,這個問題就不是問題,因為撰寫命令稿的開發者已經建好該命令稿的運行環境了。然而,當命令稿的使用者是第三者時,該名使用者得重建相同或相容的運行環境,才能順利地使用該命令稿。目前的解決方式,是另附一個建置環境的腳本或設定檔,以簡化相依性議題。

不同語言對這個議題的處理方式相異。以 Python 來說,只要附上 requirements.txt 設定檔,命令稿使用者就可以用 Pip 重建相同版本的相依函式庫。我們會在下一節介紹 Groovy 如何處理這項議題。

使用 Grape 建立自給自足的命令稿

在 Groovy 中,自動處理相依性的功能是 Grape。Groovy 程式設計師只要在程式碼中加上 Grab 指令,Groovy 就會自動抓取指定的函式庫。例如,以下指令用來抓取解析 YAML 檔案的函式庫:

@Grab(group='org.yaml', module='snakeyaml', version='1.26')

通常 Grab 指令會寫在整個 Groovy 命令稿的最上方,因為要先確認相依性滿足後,才繼續寫程式。

比起其他語言來說,使用 Grape 的好處在於 Grab 指令本身也是 Groovy 程式碼,不需要寫在另一個設定檔中,可以直接把 Groovy 命令稿分享給他人。該 Groovy 命令稿的使用者只要建置 Java 平台就好,其他的相依性由 Grape 自動解決。

直接使用 Java 生態圈的函式庫

真正寫給 Groovy 的函式庫很少,但這本來就不是重點。使用 Groovy 真正的好處是無縫接軌 Java 社群龐大的社群資源。例如,在 Maven Central 搜尋得到的 Java 函式庫,都有機會放在 Groovy 命令稿中,只要用途相配即可。

由於 Grape 是常見的功能,Maven Central 很貼心地為每個函式庫寫上相對應的 Grab 指令,不用擔心因寫錯指令無法順利抓取相依函式庫。

比直接寫 Java 程式更簡單

Groovy 的程式碼是直接簡化 Java 語法而來。所以,程式設計者不用重學新的語法,沿用原本 Java 的知識即可。

此外,Groovy 在使用上比 Java 更簡單。例如,不用先編譯再直譯,不需撰寫主函式,(在大部分情形下) 不需標註變數的形態,可直接寫一般函式而非靜態函式。Groovy 直譯器會自動把 Groovy 程式還原成相對應的 Java 程式碼。所以,Groovy 就是加了層語法糖的 Java。

範例情境:將韓語字典轉為 SQLite 資料庫

在本文中,我們會以真實情境來介紹 Groovy 範例命令稿。這個例子是將韓語字典的字典檔轉為相對應的 SQLite 資料庫,供日後快速查詢韓文單字。

kedict 是一個開放源碼 (open-source) 的韓英字典。原本字典檔是以 YAML 格式發佈。但每查一個單字就要從頭解析字典檔的效率過差。而且韓文字一字多義的情形並不少見,使用資料庫比較能夠有效率地撈出所需的資料。

如果讀者不想看文字解說,可以直接到這裡觀看寫好的 Groovy 程式碼。或是和本文交互地看,更了解程式的運作方式。

韓文字典的格式

我們從該字典檔中節錄一小段資料,用來觀察資料的格式:

- word: 건강
  romaja: geon-gang
  hanja: 健康
  pos: n
  defs:
    - def: "health"
      examples: 
        - example: "건강을 조심하다"
          transliteration: "geon-gang-eul josimhada"
          translation: "to take care of one's health"
        - example: "공부하느라 건강을 해치다"
          transliteration: "gongbuhaneura geon-gang-eul haechida"
          translation: "to damage one's health by studying [too much]"
  ants: [병]
  rels: [건강하다, 건강히]
  tags: [topik1]

word 是韓文單字,每個單字在該條目中都是唯一的 (unique)。

romaja 是將 word 翻成羅馬字的寫法。由於韓文字本來就是記錄讀音的符號,可以直接翻成對應的羅馬字。故不建議在字典中儲存羅馬字,用函式庫即時轉換即可,比較省空間。

hanja 則是 word 相對應的漢字。韓文漢字和正體中文相當類似,但並不是每個韓文單字都有相對應的漢字,實際上該字典中的 hanja 也不多。韓文平常都是以韓文字來書寫,和日文大量使用漢字 (kanji) 有所不同。

pos (part of speech) 則是 word 所屬的品詞。品詞相對於中文的詞性。有些 word 會有多種品詞,這時會放在不同條目中。

def 則是 defs 的子項目,這是該 word 在該 pos 下的英文解釋。同一個 worddef 可能會有多個。如果把同一 word 中所有 pos 中的所有 def 都列進來,則會有更多條解釋。

所以,對同一個韓語詞語來說:

  • word 是唯一的
  • pos 是一至多個
  • hanja 是零到多個
  • def 是一到多個

除了這些項目外,還有其他選擇性內容。我們用不到這些內容,所以都捨去了。

範例資料庫的綱要 (Schema)

當我們了解資料後,就可以建立相對應的資料庫綱要。

pos 的表格如下:

field type
id integer
pos text

pos 表格代表該字典中的韓文詞語的品詞,約略等同於韓文的所有品詞。

word 的表格如下:

field type
id integer
word text

hanja 的表格如下:

field type
id integer
hanja text
word_id foreign id from word
pos_id foreign id from pos

hanja 會對應到 wordpos,所以這裡用 foreign key 來處理相依性。

definition 的表格如下:

field type
id integer
definition text
word_id foreign id from word
pos_id foreign id from pos

同樣地,definition 會對應到 wordpos,所以用 foreign key 來處理。

如果要比較嚴格的對應,還要考慮相對應的 hanja。若讀者有這方面的需求,建議再開一個新的表格,不要直接寫進來,會比較省空間。而且,韓文平常都是以韓文字來書寫,使用漢字 (hanja) 的機會不多,不做這樣的對照表無妨。

分段說明範例程式碼

在本節中,我們會分段展示範例 Groovy 命令稿。在展示 Groovy 程式碼片段時,我們不會完全按照程式碼的順序來展示,而會以理解的順序來展示。若讀者想要追蹤範例程式碼,建議搭配此命令稿來理解此範例程式。

Groovy 命令稿的開頭通常都是用來處理相依性的 Grab 敘述:

@Grab(group='org.yaml', module='snakeyaml', version='1.26')
/* Due to a SQLite-JDBC issue, we have to load the JAR manually. */
/* @Grab(group='org.xerial', module='sqlite-jdbc', version='3.31.1') */

讀者可注意此範例程式沒有用 Grab 敘述抓取 SQLite 的 JDBC 函式庫。這是 classpath 的議題所造成,似乎是一個比較細微的 bug。但這個 bug 可藉由手動下載 JAR 輕易繞過,所以我們沒有在這裡停留太久。

在程式中動態取得命令稿所在的路徑。透過這種方式,只要把字典檔和命令稿放在一起即可,不需寫死路徑:

/* 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, "kedict.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(createWordTableQuery)
    conn.execute(createPOSTableQuery)
    conn.execute(createHanjaTableQuery)
    conn.execute(createTranslationTableQuery)
}
catch (SQLException e) {
    println(e.getMessage())
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

這裡利用 finally 區塊以確保資料庫會關閉。

我們節錄其中一個 SQL 敘述:

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

實際在撰寫較長的命令稿時,通常不會一步到位,而會邊寫邊改。所以,要考慮重新執行命令稿時,表格已經建好的情境,並撰寫相對應的 SQL 敘述。

建立 pos 表格並填入內容:

/* Here are the PoS of Korean. */
def pos = ["noun", "proper noun", "pronoun", "number",
    "verb", "adjective", "adverb",
    "interjection", "determiner", "particle", 
    "abbreviation", "suffix", "prefix"]

/* 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())
    }
}

品詞的條目就是固定那幾個,所以,直接在程式中寫死資料即可,不用真的從字典檔讀入 pos 項目。

在插入資料時,同樣要考慮反覆執行命令稿的情境。在撰寫 SQL 敘述時,應考慮資料重覆的問題,避免重覆寫入表格。

讀入字典檔,該字典檔使用 YAML 格式:

/* Load kedict.yml into a list of YAML objects. */
final yaml = new Yaml()
final input = new FileInputStream(new File("kedict.yml"))
List<Object> data = yaml.load(input)

匯進來的 data 的資料形態是 List<Object>。因為該字典檔有多個條目,而且我們邊寫邊改,一開始不確定條目的資料形態,所以暫時用 Object。對於動態的資料,先以萬用形態 Object 讀入後再轉型態是常見的做法。

寫入 SQLite 資料庫的程式碼比較大,這裡先寫成虛擬碼,比較容易觀看程式碼的架構:

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

    /* Load one lexicon per iteration. */
    for (Object d : data) {
        /* Load the word here. */

        /* Write the non-duplicated hanja (漢字)
            into the database only when it is available. */
        if (w.hanja) {
            /* Load the hanja of the word here. */
        }

        /* Write the definitions of a word into the database.
            One word usually owns multiple definitions. */
        for (Object df : w.defs) {
            /* Load the definition of the word here. */
        }
    }
}
finally {
    try {
        if (null != conn)
            conn.close()
    }
    catch (SQLException e) {
        println(e.getMessage())
    }
}

我們先前有列出單一條目的資料形式。每跑一次迴圈就會寫入一筆條目。

解析傳入的資料,將其轉為相對應的 Java 物件:

/* Java way to parse a YAML object into a Java object. */
Word w = (Word) d

這裡的資料型態 Word 要根據傳入的資料來標註:

/* Type of a parsed YAML object. */
class Word {
    String word
    String romaja
    String pos
    String hanja  /* nullable */
    List<Object> defs
    List<String> syns  /* nullable */
    List<String> ants  /* nullable */
    List<String> rels  /* nullable */
    List<String> ders  /* nullable */
    List<String> cf    /* nullable */
    List<String> tags  /* nullable */
    List<String> conj  /* nullable */

    /* Either List<Object> or String.
       nullable */
    Object notes
}

實際上在寫 Word 類別時,並不是一次到位,而是反覆執行命令稿,慢慢抓出字典中的欄位。人工閱讀字典檔的說明文件反而會比較慢,所以我們直接讓程式來檢查檔案中的欄位。

將詞語存入資料庫的程式碼:

/* Insert non-duplicated word into the database. */
final insertWordQuery =
"""INSERT INTO word (word) SELECT ?
WHERE NOT EXISTS (SELECT id FROM word WHERE word=?)"""

conn.execute insertWordQuery, w.word, w.word

從資料庫中取出詞語和品詞的 id 的程式碼:

final selectWordQuery = "SELECT id FROM word WHERE word=?"
final selectPOSQuery = "SELECT id FROM pos WHERE pos=?"

/* Get the id of the word. */
def wid
conn.query(selectWordQuery, [w.word]) { result ->
    while (result.next()) {
        wid = result.getInt('id')
        break
    }
}

/* Get the id of the PoS. */
def pid
conn.query(selectPOSQuery, [posTrans(w.pos)]) { result ->
    while (result.next()) {
        pid = result.getInt('id')
        break
    }
}

我們在儲存資料時,都沒有存入重覆的資料,所以只要取出一筆即可。

存入漢字 (hanja) 的程式碼:

/* Write the non-duplicated hanja (漢字)
    into the database only when it is available. */
if (w.hanja) {
    final insertHanjaQuery = 
"""INSERT INTO hanja (hanja, word_id, pos_id) SELECT ?,?,?
WHERE NOT EXISTS (SELECT id FROM hanja WHERE hanja=?)"""

    conn.execute insertHanjaQuery, w.hanja, wid, pid, w.hanja
}

不一定每個詞語都有相對應的漢字,所以要先偵測漢字存在才寫入資料庫。

寫入詞語解釋的程式碼:

/* Write the definitions of a word into the database.
    One word usually owns multiple definitions. */
for (Object df : w.defs) {
    String trans = (String) df.def

    final insertTransQuery = 
"""INSERT INTO definition (definition, word_id, pos_id) SELECT ?,?,?
WHERE NOT EXISTS (SELECT id FROM definition WHERE definition=? AND word_id=? AND pos_id=?)"""

    conn.execute insertTransQuery, trans, wid, pid, trans, wid, pid
}

本範例程式並沒有使用 ORM,因為我們只會使用單種資料庫。其實,直接寫 SQL 敘述是最簡單的,使用 ORM 反而會受限於 ORM 所提供的 API。有些 ORM 函式庫寫得不好,限制住程式設計者能做的事。

(選讀) 在網頁程式中使用 SQLite

建完資料庫後,還要另外寫使用者介面的部分。這部分就要看專案的需求,沒有一定的答案。

一般來說,SQLite 資料庫會用在桌面程式或行動程式。但其實 SQLite 也可以用在中低流量的網頁程式,該資料庫的官方說明就有提到這一點。

這是因為 SQLite 允許多人同時讀取資料,只有在寫入資料時會鎖住資料庫,無法多人同時寫入。所以,如果建好的資料庫只讀不寫,而且網站流量不要太高的話,使用 SQLite 是沒問題的。SQLite 的官方 FAQ 有更詳細的說明。

由於 SQLite 資料庫只是檔案 (flat file),不需要額外的資料庫管理程式,和網頁後端共用主機資源即可,無形中省下資料庫端的成本。所以,若符合使用情境的話,可以考慮用 SQLite 取代 MySQL 或 PostgreSQL。

使用 Groovy 撰寫命令稿的議題

雖然用 Groovy 寫命令稿很方便,但 Groovy 並非熱門語言,直接查詢 Groovy 相關資料的話,能得到的資訊會比較少。建議直接查 Java 相關的資料,然後再自行用 Groovy 的語法簡化程式碼。

使用便利的語法糖的代價是會影響程式效能。如果要寫函式庫的話,最好還是用 Java 來寫,然後再給 Groovy 或其他 Java 平台語言來呼叫。Java 仍然是高階語言,寫起來並不會太難,只是程式碼會稍長一點。

關於作者

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

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