首頁 > 軟體

C++實現xml解析器範例詳解

2022-08-12 22:00:21

xml格式簡單介紹

<?xml version="1.0"?>
<!--這是註釋-->
<workflow>
    <work name="1" switch="on">
        <plugin name="echoplugin.so" switch="on" />
    </work>
</workflow>

我們來簡單觀察下上面的xml檔案,xml格式和html格式十分類似,一般用於儲存需要屬性的設定或者需要多個巢狀關係的設定。

xml一般使用於專案的組態檔,相比於其他的ini格式或者yaml格式,它的優勢在於可以將一個標籤擁有多個屬性,比如上述xml檔案格式是用於設定工作流的,其中有name屬性和switch屬性,且再work標籤中又巢狀了plugin標籤,相比較其他組態檔格式是要靈活很多的。

具體的應用場景有很多,比如使用過Java中Mybatis的同學應該清楚,Mybatis的組態檔就是xml格式,而且也可以通過xml格式進行sql語句的編寫,同樣Java的maven專案的組態檔也是採用的xml檔案進行設定。

而我為什麼要寫一個xml解析器呢?很明顯,我今後要寫的C++專案需要用到。

xml格式解析過程淺析

同樣回到之前的那段程式碼,實際上已經把xml檔案格式的不同情況都列出來了。

從整體上看,所有的xml標籤分為:

  • xml宣告(包含版本、編碼等資訊)
  • 註釋
  • xml元素:1.單標籤元素。 2.成對標籤元素。

其中xml宣告和註釋都是非必須的。 而xml元素,至少需要一個成對標籤元素,而且在最外層有且只能有一個,它作為根元素。

從xml元素來看,分為:

  • 名稱
  • 屬性
  • 內容
  • 子節點

根據之前的例子,很明顯,名稱是必須要有的而且是唯一的,其他內容則是可選。 根據元素的結束形式,我們把他們分為單標籤和雙標籤元素。

程式碼實現

完整程式碼倉庫:xml-parser

實現儲存解析資料的類——Element

程式碼如下:

namespace xml
{
    using std::vector;
    using std::map;
    using std::string_view;
    using std::string;
    class Element
    {
    public:
        using children_t = vector<Element>;
        using attrs_t = map<string, string>;
        using iterator = vector<Element>::iterator;
        using const_iterator = vector<Element>::const_iterator;
        string &Name()
        {
            return m_name;
        }
        string &Text()
        {
            return m_text;
        }
        //迭代器方便遍歷子節點
        iterator begin()
        {
            return m_children.begin();
        }
        [[nodiscard]] const_iterator begin() const
        {
            return m_children.begin();
        }
        iterator end()
        {
            return m_children.end();
        }
        [[nodiscard]] const_iterator end() const
        {
            return m_children.end();
        }
        void push_back(Element const &element)//方便子節點的存入
        {
            m_children.push_back(element);
        }
        string &operator[](string const &key) //方便key-value的存取
        {
            return m_attrs[key];
        }
        string to_string()
        {
            return _to_string();
        }
    private:
        string _to_string();
    private:
        string m_name;
        string m_text;
        children_t m_children;
        attrs_t m_attrs;
    };
}

上述程式碼,我們主要看成員變數。

  • 我們用string型別表示元素的name和text
  • 用vector巢狀表示孩子節點
  • 用map表示key-value對的屬性

其餘的方法要麼是Getter/Setter,要麼是方便操作孩子節點和屬性。 當然還有一個to_string()方法這個待會講。

關鍵程式碼1——實現整體的解析

關於整體結構我們分解為下面的情形:

程式碼如下:

Element xml::Parser::Parse()
{
    while (true)
    {
        char t = _get_next_token();
        if (t != '<')
        {
            THROW_ERROR("invalid format", m_str.substr(m_idx, detail_len));
        }
        //解析版本號
        if (m_idx + 4 < m_str.size() && m_str.compare(m_idx, 5, "<?xml") == 0)
        {
            if (!_parse_version())
            {
                THROW_ERROR("version parse error", m_str.substr(m_idx, detail_len));
            }
            continue;
        }
        //解析註釋
        if (m_idx + 3 < m_str.size() && m_str.compare(m_idx, 4, "<!--") == 0)
        {
            if (!_parse_comment())
            {
                THROW_ERROR("comment parse error", m_str.substr(m_idx, detail_len));
            }
            continue;
        }
        //解析element
        if (m_idx + 1 < m_str.size() && (isalpha(m_str[m_idx + 1]) || m_str[m_idx + 1] == '_'))
        {
            return _parse_element();
        }
        //出現未定義情況直接丟擲異常
        THROW_ERROR("error format", m_str.substr(m_idx, detail_len));
    }
}

上述程式碼我們用while迴圈進行巢狀的原因在於註釋可能有多個。

關鍵程式碼2——解析所有元素

對應程式碼:

Element xml::Parser::_parse_element()
{
    Element element;
    auto pre_pos = ++m_idx; //過掉<
    //判斷name首字元合法性
    if (!(m_idx < m_str.size() && (std::isalpha(m_str[m_idx]) || m_str[m_idx] == '_')))
    {
        THROW_ERROR("error occur in parse name", m_str.substr(m_idx, detail_len));
    }
    //解析name
    while (m_idx < m_str.size() && (isalpha(m_str[m_idx]) || m_str[m_idx] == ':' ||
                                    m_str[m_idx] == '-' || m_str[m_idx] == '_' || m_str[m_idx] == '.'))
    {
        m_idx++;
    }
    if (m_idx >= m_str.size())
        THROW_ERROR("error occur in parse name", m_str.substr(m_idx, detail_len));
    element.Name() = m_str.substr(pre_pos, m_idx - pre_pos);
    //正式解析內部
    while (m_idx < m_str.size())
    {
        char token = _get_next_token();
        if (token == '/') //1.單元素,直接解析後結束
        {
            if (m_str[m_idx + 1] == '>')
            {
                m_idx += 2;
                return element;
            } else
            {
                THROW_ERROR("parse single_element failed", m_str.substr(m_idx, detail_len));
            }
        }
        if (token == '<')//2.對應三種情況:結束符、註釋、下個子節點
        {
            //結束符
            if (m_str[m_idx + 1] == '/')
            {
                if (m_str.compare(m_idx + 2, element.Name().size(), element.Name()) != 0)
                {
                    THROW_ERROR("parse end tag error", m_str.substr(m_idx, detail_len));
                }
                m_idx += 2 + element.Name().size();
                char x = _get_next_token();
                if (x != '>')
                {
                    THROW_ERROR("parse end tag error", m_str.substr(m_idx, detail_len));
                }
                m_idx++; //千萬注意把 '>' 過掉,防止下次解析被識別為初始的tag結束,實際上這個element已經解析完成
                return element;
            }
            //是註釋的情況
            if (m_idx + 3 < m_str.size() && m_str.compare(m_idx, 4, "<!--") == 0)
            {
                if (!_parse_comment())
                {
                    THROW_ERROR("parse comment error", m_str.substr(m_idx, detail_len));
                }
                continue;
            }
            //其餘情況可能是註釋或子元素,直接呼叫parse進行解析得到即可
            element.push_back(Parse());
            continue;
        }
        if (token == '>') //3.對應兩種情況:該標籤的text內容,下個標籤的開始或者註釋(直接continue跳到到下次迴圈即可
        {
            m_idx++;
            //判斷下個token是否為text,如果不是則continue
            char x = _get_next_token();
            if (x == '<')//不可能是結束符,因為xml元素不能為空body,如果直接出現這種情況也有可能是中間夾雜了註釋
            {
                continue;
            }
            //解析text再解析child
            auto pos = m_str.find('<', m_idx);
            if (pos == string::npos)
                THROW_ERROR("parse text error", m_str.substr(m_idx, detail_len));
            element.Text() = m_str.substr(m_idx, pos - m_idx);
            m_idx = pos;
            //注意:有可能直接碰上結束符,所以需要continue,讓element裡的邏輯來進行判斷
            continue;
        }
        //4.其餘情況都為屬性的解析
        auto key = _parse_attr_key();
        auto x = _get_next_token();
        if (x != '=')
        {
            THROW_ERROR("parse attrs error", m_str.substr(m_idx, detail_len));
        }
        m_idx++;
        auto value = _parse_attr_value();
        element[key] = value;
    }
    THROW_ERROR("parse element error", m_str.substr(m_idx, detail_len));
}

開發技巧

無論是C++開發,還是各種其他語言的造輪子,在這個造輪子的過程中,不可能是一帆風順的,需要不斷的debug,然後再測試,然後再debug。。。實際上這類格式的解析,單純的進行程式的偵錯效率是非常低下的!

特別是你用的語言還是C++,那麼如果出現意外宕機行為,debug幾乎是不可能簡單的找出原因的,所以為了方便偵錯,或者是意外宕機行為,我們還是多做一些錯誤、例外處理的工作比較好。

比如上述的程式碼中,我們大量的用到了 THROW_ERROR 這個宏,實際上這個宏輸出的內容是有便於偵錯和快速定位的。 具體程式碼如下:

//用於返回較為詳細的錯誤資訊,方便錯誤追蹤
#define THROW_ERROR(error_info, error_detail) 
    do{                                    
    string info = "parse error in ";              
    string file_pos = __FILE__;                          
    file_pos.append(":");                                
    file_pos.append(std::to_string(__LINE__));
    info += file_pos;                                  
    info += ", ";                          
    info += (error_info);                    
    info += "ndetail:";                          
    info += (error_detail);
    throw std::logic_error(info); 
}while(false)

如果發生錯誤,這個異常攜帶的資訊如下:

列印出了兩個非常關鍵的資訊:

內部的C++程式碼解析丟擲異常的位置

解析發生錯誤的字串

按理來說這些資訊應該是用紀錄檔來進行記錄的,但是由於這個專案比較小型,直接用日常資訊當紀錄檔來方便偵錯也未嘗不可


IT145.com E-mail:sddin#qq.com