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

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++代碼解析拋出異常的位置

解析發生錯誤的字符串

按理來說這些信息應該是用日志來進行記錄的,但是由於這個項目比較小型,直接用日常信息當日志來方便調試也未嘗不可🥳

有關C++的優化

眾所周知在C++中,一個類有八個默認函數:

  • 默認構造函數
  • 默認拷貝構造函數
  • 默認析構函數
  • 默認重載賦值運算符函數
  • 默認重載取址運算符函數
  • 默認重載取址運算符const函數
  • 默認移動構造函數(C++11)
  • 默認重載移動賦值操作符函數(C++11)

我們一般情況需要註意的構造函數和賦值函數函數需要的是以下三類:

  • 拷貝構造。
  • 移動構造。
  • 析構函數。

以下面的代碼為例來說明默認的行為:

class Data{
    ...
}
class test{
pvivate:
    Data m_data;
}

額外註意

默認情況的模擬

class Data{
    ...
}
class test{
public:
    //拷貝構造
    test(test const&src) = default;//等價於下面的代碼
    //test(test const& src):m_data(src.m_data){}
    //移動構造
    test(test &&src) = default;//等價於下面代碼
    //tset(test&& src):m_data(std::move(src.m_data)){}
pvivate:
    Data m_data;
}

從上述情況可以看出,如果一個類的數據成員中含有原始指針數據,那麼拷貝構造和移動構造都需要自定義,如果成員中全都用的標準庫裡的東西,那麼我們就用默認的就行,因為標準庫的所有成員都自己實現瞭拷貝和移動構造!比如我們目前的Element就全都用默認的就好。

需要特別註意的點

  • 顯式定義瞭某個構造函數或者賦值函數,那麼相應的另一個構造或者賦值就會被刪除默認,需要再次顯式定義瞭。 舉個例子:比如我顯式定義瞭移動構造(關於顯式定義,手動創建算顯式,手動寫default也算顯示),那麼就會造成所有的默認拷貝(拷貝構造和拷貝賦值)被刪除。相反顯式定義瞭移動賦值也是類似的,默認的拷貝行為被刪除。拷貝的對於顯式的默認行為處理也是一模一樣。
  • 如果想要使用默認的構造/賦值函數,那麼對應的成員也都必須支持。 例如以下代碼:
class Data{
    ...
}
class test{
pvivate:
    Data m_data;
}

由於test類沒有寫任何構造函數,那麼這8個默認構造函數按理來說都是有的,但如果Data類中的拷貝構造由於某些顯式定義情況而被刪除瞭,那麼test類就不再支持拷貝構造(對我們造成的影響就是:沒法再直接通過等號初始化)。

最後,通過上述規則我們發現,如果想要通過默認的構造函數偷懶,那麼首先你的成員得支持對應的構造函數,還有就是不要畫蛇添足:比如本來我什麼都不用寫,它自動生成8大默認函數,然後你手寫瞭一個拷貝構造=default,好瞭,你的默認函數從此少瞭兩個,又得你一個個手動去補瞭!

故如果成員變量對移動和拷貝行為都是支持的,那麼你就千萬不要再畫蛇添足瞭,除非你需要自定義某些特殊行為(比如打日志什麼的)。如果你的成員變量中含有原始指針,那麼一定需要手動寫好移動和拷貝行為。如果成員變量對拷貝和移動行為部分支持,那麼根據你的使用情況來進行選擇是否需要手動補充這些行為!

以上就是C++實現xml解析器示例詳解的詳細內容,更多關於C++ xml解析器的資料請關註WalkonNet其它相關文章!

推薦閱讀: