Актуальность XML в 2022 году была бы под вопросом, но все еще остается много legacy систем, которые могут предоставлять данные в этом формате, поэтому нам приходится с ним работать. XML популярен в travel индустрии. Например, GDS (международные системы бронирования, более подробно можно почитать о них в википедии) или информационная система Darwin ассоциации железнодорожных транспортных компаний Великобритании активно используют его. Поэтому, я надеюсь, что эта статья будет кому-то полезна. В ней рассмотрена пара подходов к парсингу xml в Golang: обычный и потоковый, пользовательский парсинг поля и работа с различными кодировками. Мы будем использовать пакет encoding/xml из стандартной библиотеки. Если вы уже работали c encoding/json, то будет много похожего, но некоторые различия все же есть.
Генерация Go структур по XML
Прежде всего нам понадобится описать go структуры, соответствующие xml файлам. Можно сделать это вручную c помощью структурных тегов (детальную информацию о том, как описывать теги для XML можно посмотреть в документации по методу Marshal). Но для скорости и простоты мы воспользуемся одним из многочисленных генераторов go структуры по xml, например https://github.com/miku/zek/ (обратите внимание, что есть онлайн версия этого генератора: https://www.onlinetool.io/xmltogo/). Если вы впервые сталкиваетесь с таким подходом, то рекомендую сначала ознакомиться, например, со статьей "Использование тегов структур в Go"
Парсинг XML в Go структуру
Начнем с простого xml файла и обычного Unmarshal в golang структуру. Пример файла я взял с сайта w3schools. Напомню, что сначала нам понадобится описать go struct, соответствующую структуре xml. Чтобы сделать это мы будем использовать тэги структур (аналогично json, если ранее не работали с json в golang, то можете почитать об этом здесь и здесь, на русском здесь и здесь).
Давайте посмотрим на пример XML файла:
<?xml version="1.0" encoding="UTF-8"?>
<breakfast_menu>
<food>
<name>Belgian Waffles</name>
<price>$5.95</price>
<description>Two of our famous Belgian Waffles with plenty of real maple syrup</description>
<calories>650</calories>
</food>
<food>
<name>Strawberry Belgian Waffles</name>
<price>$7.95</price>
<description>Light Belgian waffles covered with strawberries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>Berry-Berry Belgian Waffles</name>
<price>$8.95</price>
<description>Light Belgian waffles covered with an assortment of fresh berries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>French Toast</name>
<price>$4.50</price>
<description>Thick slices made from our homemade sourdough bread</description>
<calories>600</calories>
</food>
<food>
<name>Homestyle Breakfast</name>
<price>$6.95</price>
<description>Two eggs, bacon or sausage, toast, and our ever-popular hash browns</description>
<calories>950</calories>
</food>
</breakfast_menu>
И вот go структура, которая его описывает:
type BreakfastMenu struct {
XMLName xml.Name `xml:"breakfast_menu"`
Food []struct {
Name string `xml:"name"`
Price string `xml:"price"`
Description string `xml:"description"`
Calories string `xml:"calories"`
} `xml:"food"`
}
Дальше все достаточно тривиально. Код для Unmarshal (парсинг из xml в golang структуру) будет выглядеть следующим образом:
menu := new(BreakfastMenu)
err := xml.Unmarshal([]byte(data), menu)
if err != nil {
fmt.Printf("error: %v", err)
return
}
Соответственно Marshal (операция, обратная Unmarshal, из go структуры в xml):
xmlText, err := xml.MarshalIndent(menu, " ", " ")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
Полный текст программы вы можете посмотреть под катом и попробовать запустить по ссылке (обратите внимание, что мы используем MarshalIndent вместо Marshal – это функция позволяет вывести xml в более читаемом виде: добавить отступы и переносы строк.
Пример Golang XML Marshal/Unmarshal
package main
import (
"encoding/xml"
"fmt"
)
type BreakfastMenu struct {
XMLName xml.Name `xml:"breakfast_menu"`
//Text string `xml:",chardata"`
Food []struct {
//Text string `xml:",chardata"`
Name string `xml:"name"`
Price string `xml:"price"`
Description string `xml:"description"`
Calories string `xml:"calories"`
} `xml:"food"`
}
func main() {
menu := new(BreakfastMenu)
err := xml.Unmarshal([]byte(data), menu)
if err != nil {
fmt.Printf("error: %v", err)
return
}
fmt.Printf("--- Unmarshal ---\n\n")
for _, foodNode := range menu.Food {
fmt.Printf("Name: %s\n", foodNode.Name)
fmt.Printf("Price: %s\n", foodNode.Price)
fmt.Printf("Description: %s\n", foodNode.Description)
fmt.Printf("Calories: %s\n", foodNode.Calories)
fmt.Printf("---\n")
}
xmlText, err := xml.MarshalIndent(menu, " ", " ")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
fmt.Printf("\n--- Marshal ---\n\n")
fmt.Printf("xml: %s\n", string(xmlText))
}
var data = `
<?xml version="1.0" encoding="UTF-8"?>
<breakfast_menu>
<food>
<name>Belgian Waffles</name>
<price>$5.95</price>
<description>Two of our famous Belgian Waffles with plenty of real maple syrup</description>
<calories>650</calories>
</food>
<food>
<name>Strawberry Belgian Waffles</name>
<price>$7.95</price>
<description>Light Belgian waffles covered with strawberries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>Berry-Berry Belgian Waffles</name>
<price>$8.95</price>
<description>Light Belgian waffles covered with an assortment of fresh berries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>French Toast</name>
<price>$4.50</price>
<description>Thick slices made from our homemade sourdough bread</description>
<calories>600</calories>
</food>
<food>
<name>Homestyle Breakfast</name>
<price>$6.95</price>
<description>Two eggs, bacon or sausage, toast, and our ever-popular hash browns</description>
<calories>950</calories>
</food>
</breakfast_menu>
`
Таким образом, здесь все просто, но есть пара проблем: в том случае, если xml файл большого размера, то нам понадобится большое количество оперативной памяти, и в том случае, если мы получаем файл по сети нам понадобится дождаться получения полного содержимого, прежде чем начать парсинг. Давайте рассмотрим второй подход, который нам позволит решить эти проблемы.
Потоковый парсинг XML
Для потокового парсинга XML мы можем использовать тип Decoder, который позволяет парсить xml файл потоково и ожидает, что поток будет в кодировке UTF-8 (дословная цитата из документации: “A Decoder represents an XML parser reading a particular input stream. The parser assumes that its input is encoded in UTF-8.”)
С полным текстом программы можно под катом и запустить по ссылке.
Пример потокового парсинга XML в Golang
package main
import (
"bytes"
"encoding/xml"
"fmt"
)
const foodElementName = "food"
type BreakfastMenu struct {
Food []Food `xml:"food"`
}
type Food struct {
Name string `xml:"name"`
Price string `xml:"price"`
Description string `xml:"description"`
Calories string `xml:"calories"`
}
func main() {
var (
menu BreakfastMenu
food Food
)
xmlData := bytes.NewBufferString(data)
d := xml.NewDecoder(xmlData)
for t, _ := d.Token(); t != nil; t, _ = d.Token() {
switch se := t.(type) {
case xml.StartElement:
if se.Name.Local == foodElementName {
d.DecodeElement(&food, &se)
menu.Food = append(menu.Food, food)
}
}
}
fmt.Printf("--- Unmarshal ---\n\n")
for _, foodNode := range menu.Food {
fmt.Printf("Name: %s\n", foodNode.Name)
fmt.Printf("Price: %s\n", foodNode.Price)
fmt.Printf("Description: %s\n", foodNode.Description)
fmt.Printf("Calories: %s\n", foodNode.Calories)
fmt.Printf("---\n")
}
}
var (
data = `
<?xml version="1.0" encoding="UTF-8"?>
<breakfast_menu>
<food>
<name>Belgian Waffles</name>
<price>$5.95</price>
<description>Two of our famous Belgian Waffles with plenty of real maple syrup</description>
<calories>650</calories>
</food>
<food>
<name>Strawberry Belgian Waffles</name>
<price>$7.95</price>
<description>Light Belgian waffles covered with strawberries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>Berry-Berry Belgian Waffles</name>
<price>$8.95</price>
<description>Light Belgian waffles covered with an assortment of fresh berries and whipped cream</description>
<calories>900</calories>
</food>
<food>
<name>French Toast</name>
<price>$4.50</price>
<description>Thick slices made from our homemade sourdough bread</description>
<calories>600</calories>
</food>
<food>
<name>Homestyle Breakfast</name>
<price>$6.95</price>
<description>Two eggs, bacon or sausage, toast, and our ever-popular hash browns</description>
<calories>950</calories>
</food>
</breakfast_menu>
`
)
Давайте посмотрим на основные изменения в коде и разберем их подробнее:
d := xml.NewDecoder(xmlData)
for t, _ := d.Token(); t != nil; t, _ = d.Token() {
switch se := t.(type) {
case xml.StartElement:
if se.Name.Local == foodElementName {
d.DecodeElement(&food, &se)
menu.Food = append(menu.Food, food)
}
}
}
Сначала инстанцируется xml.Decoder с помощью функции xml.NewDecoder. Далее происходит итерация по токенам xml с помощью метода Token. Он возвращает тип Token или nil, если достигнут конец файла. Строго говоря, метод возвращает два значения: Token и ошибку, если она произошла. В случае достижения конца файла возвращается nil для Token и io.EOF в качестве ошибки.
Тип xml.Token представляет собой интерфейс и объявлен как 'type Token any' (в свою очередь any объявлен как пустой интерфейс: 'type any = interface{}'). До введения дженериков в Golang xml.Token был объявлен как пустой интерфейс: 'type Token interface{}'. Таким образом, может содержать любой тип данных и, согласно документации, может быть одним из следующих типов: StartElement, EndElement, CharData, Comment, ProcInst или Directive. Нас будет интересовать только начало элемента, т.е. тип StartElement. Как только он нам встречается мы проверяем, что он является нодой “food”. Если это так, то декодируем в Go структуру с помощью метода Decode.
Пользовательский парсинг (custom unmarshal)
Иногда требуется описать свой декодер для определенного поля. Часто такое бывает в случае парсинга даты, времени, либо enum-ов. Сделать это можно с помощью пользовательского типа данных, который должен реализовать интерфейс Unmarshaler пакета encoding/xml. Интерфейс содержит только один метод: UnmarshalXML, давайте посмотрим на пример его реализации:
type userDate time.Time
const userDateFormat = "2006-01-02"
func (ud *userDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
dateString := ""
err := d.DecodeElement(&dateString, &start)
if err != nil {
return err
}
dat, err := time.Parse(userDateFormat, dateString)
if err != nil {
return err
}
*ud = userDate(dat)
return nil
}
Коротко говоря, метод принимает на вход инстанс текущего xml.Decoder и xml элемент (мы уже использовали тип данных xml.StartElement в потоковом парсинге), которые используются для декодирования элемента в строку. После чего мы парсим строку в тип time.Time (используем свой шаблон формата даты: userDateFormat) и присваиваем значения ресиверу ud, предварительно преобразовав тип к userDate. Полный текст программы вы можете посмотреть под катом и попробовать запустить по ссылке.
Custom unmarshal XML Golang
package main
import (
"encoding/xml"
"fmt"
"time"
)
type userDate time.Time
const userDateFormat = "2006-01-02"
type FilmsDB struct {
XMLName xml.Name `xml:"films"`
Film []Film `xml:"film"`
}
type Film struct {
Title string `xml:"title"`
ReleaseDate userDate `xml:"releaseDate"`
}
func (ud *userDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
dateString := ""
err := d.DecodeElement(&dateString, &start)
if err != nil {
return err
}
dat, err := time.Parse(userDateFormat, dateString)
if err != nil {
return err
}
*ud = userDate(dat)
return nil
}
func (ud userDate) String() string {
return time.Time(ud).Format(time.RFC822)
}
func main() {
filmsDB := new(FilmsDB)
err := xml.Unmarshal([]byte(data), filmsDB)
if err != nil {
fmt.Printf("error: %v", err)
return
}
fmt.Printf("--- Unmarshal ---\n\n")
for _, film := range filmsDB.Film {
fmt.Printf("Title: %s\n", film.Title)
fmt.Printf("Release Date: %s\n", film.ReleaseDate)
fmt.Printf("---\n")
}
}
var (
data = `
<?xml version="1.0" encoding="UTF-8"?>
<films>
<film>
<title>Johnny Mnemonic</title>
<releaseDate>1995-05-26</releaseDate>
</film>
</films>
`
)
Текстовые кодировки
Хотелось бы еще сказать несколько слов про кодировки в xml. Редко, но все же иногда вы можете столкнуться с кодировкой, отличной от UTF-8. На этот случай вы можете задать нужную кодировку с помощью поля CharsetReader у декодера, которое является функцией и ожидается, что будет конвертировать из кодировки xml файла в utf-8 (сигнатура: 'CharsetReader func(charset string, input io.Reader) (io.Reader, error)' )
Самый простой вариант задать CharsetEncoder – это использование NewReaderLabel из пакета x/net/html/charset. По переданному charset (он же label в сигнатуре NewReaderLabel) с помощью метода Lookup он находит соответствие кодировке из этой таблицы. Передаваемый параметр charset берется из encoding параметра xml файла. Код будет примерно такой:
filmsDB := new(FilmsDB)
r := bytes.NewReader([]byte(data))
d := xml.NewDecoder(r)
d.CharsetReader = charset.NewReaderLabel
err := d.Decode(&filmsDB)
if err != nil {
fmt.Printf("error: %v", err)
return
}
Полный код под катом, а запустить можно по ссылке. Обратите внимание, что задан 'encoding="windows-1251"' у XML и title в кодировке windows-1251.
Работа с кодировками XML в Golang
package main
import (
"bytes"
"encoding/xml"
"fmt"
"time"
"golang.org/x/net/html/charset"
)
type userDate time.Time
const userDateFormat = "2006-01-02"
type FilmsDB struct {
XMLName xml.Name `xml:"films"`
Film []Film `xml:"film"`
}
type Film struct {
Title string `xml:"title"`
ReleaseDate userDate `xml:"releaseDate"`
}
func (ud *userDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
dateString := ""
err := d.DecodeElement(&dateString, &start)
if err != nil {
return err
}
dat, err := time.Parse(userDateFormat, dateString)
if err != nil {
return err
}
*ud = userDate(dat)
return nil
}
func (ud userDate) String() string {
return time.Time(ud).Format(time.RFC822)
}
func main() {
filmsDB := new(FilmsDB)
r := bytes.NewReader([]byte(data))
d := xml.NewDecoder(r)
d.CharsetReader = charset.NewReaderLabel
err := d.Decode(&filmsDB)
if err != nil {
fmt.Printf("error: %v", err)
return
}
fmt.Printf("--- Unmarshal ---\n\n")
for _, film := range filmsDB.Film {
fmt.Printf("Title: %s\n", film.Title)
fmt.Printf("Release Date: %s\n", film.ReleaseDate)
fmt.Printf("---\n")
}
}
var (
jhonnyMnemonicASCII = []byte{0xc4, 0xe6, 0xee, 0xed, 0xed, 0xe8, 0x2d, 0xcc, 0xed, 0xe5, 0xec, 0xee, 0xed, 0xe8, 0xea}
)
var (
data = `
<?xml version="1.0" encoding="windows-1251"?>
<films>
<film>
<title>` + string(jhonnyMnemonicASCII) + `</title>
<releaseDate>1995-05-26</releaseDate>
</film>
</films>
`
)
Заключение
В статье я постарался рассмотреть основные способы парсинга xml и некоторые сопутствующие вопросы. Она не претендует на полноту и не является всеобъемлющей, но этого, обычно, оказывается вполне достаточно, чтобы решить большую часть типовых задач. Надеюсь, что данные примеры немного упростят кому-нибудь жизнь и ускорят вашу разработку. В следующем разделе оставлю некоторые полезные ссылки.
Полезные ссылки
https://pkg.go.dev/encoding/xml#Marshal - документация по методу Marshal, здесь можно почитать как описывать go структуры для xml
https://www.digitalocean.com/community/tutorials/how-to-use-struct-tags-in-go-ru - про теги структур в Golang
https://www.onlinetool.io/xmltogo/ - онлайн генератор go структур по xml файлу
https://habr.com/ru/company/vk/blog/463063/ - хорошая статья про интерфейсы в общем и про пустой интерфейс в частности