第一版是為 Lua 5.0 編寫的。雖然在很大程度上仍然適用於後續版本,但有一些差異。
第四版針對 Lua 5.3,可在 Amazon 和其他書店購買。
購買本書,您也可以 支援 Lua 專案


29.2 – XML 解析器

現在我們將探討 lxp 的簡化實作,它是 Lua 和 Expat 之間的繫結。Expat 是一個用 C 編寫的開放原始碼 XML 1.0 解析器。它實作了 SAX,即 XML 的簡易 API。SAX 是一個基於事件的 API。這表示 SAX 解析器會讀取 XML 文件,並在讀取的過程中透過回呼函式向應用程式報告它找到的內容。例如,如果我們指示 Expat 解析類似

    <tag cap="5">hi</tag>
的字串,它會產生三個事件:一個 開始元素 事件,當它讀取子字串 "<tag cap="5">" 時;一個 文字 事件(也稱為 字元資料 事件),當它讀取 "hi" 時;一個 結束元素 事件,當它讀取 "</tag>" 時。這些事件中的每個事件都會呼叫應用程式中適當的 回呼函式處理常式

我們在此不會涵蓋整個 Expat 函式庫。我們只會專注於那些說明與 Lua 互動的新技術的部分。在實作這個核心功能後,稍後很容易加入其他功能。雖然 Expat 處理超過十幾個不同的事件,但我們只會考慮在先前範例中看到的三個事件(開始元素、結束元素和文字)。我們這個範例需要的 Expat API 部分很小。首先,我們需要建立和銷毀 Expat 解析器的函式

    #include <xmlparse.h>
    
    XML_Parser XML_ParserCreate (const char *encoding);
    void XML_ParserFree (XML_Parser p);
參數 encoding 是選用的;我們會在繫結中使用 NULL

在我們有了解析器後,我們必須註冊它的回呼函式處理常式

    XML_SetElementHandler(XML_Parser p,
                          XML_StartElementHandler start,
                          XML_EndElementHandler end);
    
    XML_SetCharacterDataHandler(XML_Parser p,
                                XML_CharacterDataHandler hndl);
第一個函式註冊開始和結束元素的處理常式。第二個函式註冊文字(在 XML 術語中稱為 字元資料)的處理常式。

所有回呼函式處理常式都會收到一些使用者資料作為它們的第一個參數。開始元素處理常式也會收到標籤名稱和它的屬性

    typedef void (*XML_StartElementHandler)(void *uData,
                                            const char *name,
                                            const char **atts);
屬性會以 NULL 終止的字串陣列形式出現,其中每兩個連續的字串包含一個屬性名稱和它的值。結束元素處理常式只有一個額外的參數,即標籤名稱
    typedef void (*XML_EndElementHandler)(void *uData,
                                          const char *name);
最後,文字處理常式只收到文字作為額外的參數。這個文字字串不是 NULL 終止的;相反地,它有一個明確的長度
    typedef void
    (*XML_CharacterDataHandler)(void *uData,
                                const char *s,
                                int len);

要將文字提供給 Expat,我們使用下列函數

    int XML_Parse (XML_Parser p,
                   const char *s, int len, int isFinal);
Expat 透過對 XML_Parse 的連續呼叫,接收要解析的文件片段。XML_Parse 的最後一個參數 isFinal,會告知 Expat 該片段是否為文件的最後一個片段。請注意,每一段文字不需要以零終止;相反地,我們提供明確的長度。如果 XML_Parse 函數偵測到解析錯誤,則會傳回零。(Expat 提供輔助函數來擷取錯誤資訊,但為了簡潔起見,我們在此會略過這些函數。)

我們從 Expat 所需要的最後一個函數,允許我們設定要傳遞給處理常式的使用者資料

    void XML_SetUserData (XML_Parser p, void *uData);

現在讓我們來看看如何使用這個函式庫於 Lua 中。第一種方法是直接方法:直接將所有這些函數匯出到 Lua 中。更好的方法是將功能調整為 Lua。例如,由於 Lua 是非型別的,我們不需要不同的函數來設定每種類型的回呼。更好的是,我們可以完全避免回呼註冊函數。相反地,當我們建立一個剖析器時,我們提供一個回呼表格,其中包含所有回呼處理常式,每個處理常式都有適當的鍵。例如,如果我們只想要列印文件的佈局,我們可以使用下列回呼表格

    local count = 0
    
    callbacks = {
      StartElement = function (parser, tagname)
        io.write("+ ", string.rep("  ", count), tagname, "\n")
        count = count + 1
      end,
    
      EndElement = function (parser, tagname)
        count = count - 1
        io.write("- ", string.rep("  ", count), tagname, "\n")
      end,
    }
提供輸入 "<to> <yes/> </to>",這些處理常式會列印
    + to
    +   yes
    -   yes
    - to
使用這個 API,我們不需要函數來操作回呼。我們直接在回呼表格中操作它們。因此,整個 API 只需要三個函數:一個用於建立剖析器,一個用於剖析一段文字,一個用於關閉剖析器。(實際上,我們會將最後兩個函數實作為剖析器物件的方法。)API 的典型用法如下所示
    p = lxp.new(callbacks)     -- create new parser
    for l in io.lines() do     -- iterate over input lines
      assert(p:parse(l))               -- parse the line
      assert(p:parse("\n"))            -- add a newline
    end
    assert(p:parse())        -- finish document
    p:close()


現在讓我們將注意力轉移到實作上。第一個決定是如何在 Lua 中表示剖析器。使用使用者資料是很自然的方式,但我們需要在其中放入什麼?至少,我們必須保留實際的 Expat 剖析器和回呼表格。我們無法將 Lua 表格儲存在使用者資料中(或任何 C 結構中);但是,我們可以建立對表格的參考,並將參考儲存在使用者資料中。(請從 第 27.3.2 節 記得,參考是註冊表中 Lua 所產生的整數鍵。)最後,我們必須能夠將 Lua 狀態儲存在剖析器物件中,因為這些剖析器物件是 Expat 回呼從我們的程式所接收到的所有內容,而回呼需要呼叫 Lua。因此,剖析器物件的定義如下所示

    #include <xmlparse.h>
    
    typedef struct lxp_userdata {
      lua_State *L;
      XML_Parser *parser;          /* associated expat parser */
      int tableref;   /* table with callbacks for this parser */
    } lxp_userdata;

下一步是建立剖析器物件的函數。函數如下所示

    static int lxp_make_parser (lua_State *L) {
      XML_Parser p;
      lxp_userdata *xpu;
    
      /* (1) create a parser object */
      xpu = (lxp_userdata *)lua_newuserdata(L,
                                       sizeof(lxp_userdata));
    
      /* pre-initialize it, in case of errors */
      xpu->tableref = LUA_REFNIL;
      xpu->parser = NULL;
    
      /* set its metatable */
      luaL_getmetatable(L, "Expat");
      lua_setmetatable(L, -2);
    
      /* (2) create the Expat parser */
      p = xpu->parser = XML_ParserCreate(NULL);
      if (!p)
        luaL_error(L, "XML_ParserCreate failed");
    
      /* (3) create and store reference to callback table */
      luaL_checktype(L, 1, LUA_TTABLE);
      lua_pushvalue(L, 1);  /* put table on the stack top */
      xpu->tableref = luaL_ref(L, LUA_REGISTRYINDEX);
    
      /* (4) configure Expat parser */
      XML_SetUserData(p, xpu);
      XML_SetElementHandler(p, f_StartElement, f_EndElement);
      XML_SetCharacterDataHandler(p, f_CharData);
      return 1;
    }
lxp_make_parser 函數有四個主要步驟

下一步是 parse 方法,它會解析一段 XML 資料。它取得兩個參數:解析器物件(方法的 self)和一段選用的 XML 資料。在沒有任何資料呼叫時,它會通知 Expat 文件沒有更多部分

    static int lxp_parse (lua_State *L) {
      int status;
      size_t len;
      const char *s;
      lxp_userdata *xpu;
    
      /* get and check first argument (should be a parser) */
      xpu = (lxp_userdata *)luaL_checkudata(L, 1, "Expat");
      luaL_argcheck(L, xpu, 1, "expat parser expected");
    
      /* get second argument (a string) */
      s = luaL_optlstring(L, 2, NULL, &len);
    
      /* prepare environment for handlers: */
      /* put callback table at stack index 3 */
      lua_settop(L, 2);
      lua_getref(L, xpu->tableref);
      xpu->L = L;  /* set Lua state */
    
      /* call Expat to parse string */
      status = XML_Parse(xpu->parser, s, (int)len, s == NULL);
    
      /* return error code */
      lua_pushboolean(L, status);
      return 1;
    }
lxp_parse 呼叫 XML_Parse 時,後者函數會呼叫處理常式中每個相關元素的處理常式。因此,lxp_parse 首先為這些處理常式準備一個環境。在呼叫 XML_Parse 時還有另一個細節:請記住,此函數的最後一個參數會告訴 Expat 給定的文字是否為最後一個。當我們在沒有參數的情況下呼叫 parse 時,s 會是 NULL,因此這個最後一個參數會為真。

現在讓我們將注意力轉移到回呼函數 f_StartElementf_EndElementf_CharData。這三個函數都有類似的結構:每個函數都會檢查回呼表格是否為其特定事件定義一個 Lua 處理常式,如果有的話,就會準備參數,然後呼叫那個 Lua 處理常式。

讓我們先看看 f_CharData 處理常式。它的程式碼非常簡單。它使用只有兩個參數呼叫 Lua 中對應的處理常式(如果存在):解析器和字元資料(字串)

    static void f_CharData (void *ud, const char *s, int len) {
      lxp_userdata *xpu = (lxp_userdata *)ud;
      lua_State *L = xpu->L;
    
      /* get handler */
      lua_pushstring(L, "CharacterData");
      lua_gettable(L, 3);
      if (lua_isnil(L, -1)) {  /* no handler? */
        lua_pop(L, 1);
        return;
      }
    
      lua_pushvalue(L, 1);  /* push the parser (`self') */
      lua_pushlstring(L, s, len);  /* push Char data */
      lua_call(L, 2, 0);  /* call the handler */
    }
請注意,由於我們在建立解析器時呼叫 XML_SetUserData,所有這些 C 處理常式都會收到一個 lxp_userdata 結構作為它們的第一個參數。另請注意它如何使用 lxp_parse 設定的環境。首先,它假設回呼表格在堆疊索引 3。其次,它假設解析器本身在堆疊索引 1(它必須在那裡,因為它應該是 lxp_parse 的第一個參數)。

f_EndElement 處理常式也簡單,且與 f_CharData 非常類似。它也會呼叫對應的 Lua 處理常式,並提供兩個引數:剖析器和標籤名稱(同樣是字串,但現在以 null 結束)

    static void f_EndElement (void *ud, const char *name) {
      lxp_userdata *xpu = (lxp_userdata *)ud;
      lua_State *L = xpu->L;
    
      lua_pushstring(L, "EndElement");
      lua_gettable(L, 3);
      if (lua_isnil(L, -1)) {  /* no handler? */
        lua_pop(L, 1);
        return;
      }
    
      lua_pushvalue(L, 1);  /* push the parser (`self') */
      lua_pushstring(L, name);  /* push tag name */
      lua_call(L, 2, 0);  /* call the handler */
    }

最後一個處理常式 f_StartElement,會呼叫 Lua 並提供三個引數:剖析器、標籤名稱和屬性清單。此處理常式比其他處理常式複雜一些,因為它需要將標籤的屬性清單轉換為 Lua。我們將使用非常自然的轉換方式。例如,類似下列的起始標籤

    <to method="post" priority="high">
會產生下列的屬性表格
    { method = "post", priority = "high" }
f_StartElement 的實作如下
    static void f_StartElement (void *ud,
                                const char *name,
                                const char **atts) {
      lxp_userdata *xpu = (lxp_userdata *)ud;
      lua_State *L = xpu->L;
    
      lua_pushstring(L, "StartElement");
      lua_gettable(L, 3);
      if (lua_isnil(L, -1)) {  /* no handler? */
        lua_pop(L, 1);
        return;
      }
    
      lua_pushvalue(L, 1);  /* push the parser (`self') */
      lua_pushstring(L, name);  /* push tag name */
    
      /* create and fill the attribute table */
      lua_newtable(L);
      while (*atts) {
        lua_pushstring(L, *atts++);
        lua_pushstring(L, *atts++);
        lua_settable(L, -3);
      }
    
      lua_call(L, 3, 0);  /* call the handler */
    }

剖析器的最後一個方法是 close。當我們關閉剖析器時,我們必須釋放其所有資源,也就是 Expat 結構和回呼表格。請記得,由於在建立過程中偶爾會發生錯誤,剖析器可能沒有這些資源

    static int lxp_close (lua_State *L) {
      lxp_userdata *xpu;
    
      xpu = (lxp_userdata *)luaL_checkudata(L, 1, "Expat");
      luaL_argcheck(L, xpu, 1, "expat parser expected");
    
      /* free (unref) callback table */
      luaL_unref(L, LUA_REGISTRYINDEX, xpu->tableref);
      xpu->tableref = LUA_REFNIL;
    
      /* free Expat parser (if there is one) */
      if (xpu->parser)
        XML_ParserFree(xpu->parser);
      xpu->parser = NULL;
      return 0;
    }
請注意,當我們關閉剖析器時,我們會讓剖析器維持一致的狀態,因此,如果我們嘗試再次關閉剖析器或當垃圾回收器完成剖析器時,不會有任何問題。實際上,我們會將此函數當作完成器使用。這樣可以確保每個剖析器最終會釋放其資源,即使程式設計師沒有關閉剖析器。

最後一個步驟是開啟函式庫,將所有這些部分組合在一起。我們將在此使用與我們在物件導向陣列範例中所使用的相同配置(第 28.3 節):我們將建立一個元表格,將所有方法放入其中,並讓其 __index 欄位指向自己。為此,我們需要一個包含剖析器方法的清單

    static const struct luaL_reg lxp_meths[] = {
      {"parse", lxp_parse},
      {"close", lxp_close},
      {"__gc", lxp_close},
      {NULL, NULL}
    };
我們也需要一個包含此函式庫函數的清單。與 OO 函式庫常見的情況一樣,此函式庫只有一個函數,用於建立新的剖析器
    static const struct luaL_reg lxp_funcs[] = {
      {"new", lxp_make_parser},
      {NULL, NULL}
    };
最後,開啟函數必須建立元表格,讓它指向自己(透過 __index),並註冊方法和函數
    int luaopen_lxp (lua_State *L) {
      /* create metatable */
      luaL_newmetatable(L, "Expat");
    
      /* metatable.__index = metatable */
      lua_pushliteral(L, "__index");
      lua_pushvalue(L, -2);
      lua_rawset(L, -3);
    
      /* register methods */
      luaL_openlib (L, NULL, lxp_meths, 0);
    
      /* register functions (only lxp.new) */
      luaL_openlib (L, "lxp", lxp_funcs, 0);
      return 1;
    }