Parse dữ liệu XML dung lượng lớn với XMLParser trong Swift 3.0

Bài toán cụ thể: Đọc dữ liệu XML với số lượng bản ghi tương đối lớn (~5000): tracklog leo núi Pu Si Lung

So sánh các mô hình XML Parser nổi bật

Để đọc dữ liệu XML, ta có thể lựa chọn những parser dựa trên 2 mô hình chủ yếu: DOM Parser và SAX Parser.

  • DOM Parser: Parser theo mô hình cây (tree model) - dữ liệu từ file XML được load toàn bộ vào memory và chuyển thành một DOM tree, ta không can thiệp được gì trong quá trình này. Khi parse xong, ta có thể duyệt theo cây này để truy xuất dữ liệu. Lợi ích của parser kiểu này là dễ sử dụng, dễ truy xuất dữ liệu nhưng có bất lợi là tốn bộ nhớ và thời gian chờ load dữ liệu lâu với những file XML có lượng dữ liệu lớn -> Phù hợp với việc đọc file XML dung lượng nhỏ.
  • SAX Parser: Parser theo event trigger - dữ liệu từ file XML được đọc dần dần vào memory và event sẽ phát sinh khi gặp các thẻ tag <tag>, </tag> và attribute của thẻ tag <tag attribute="">. Lập trình viên phải tự xử lý các event để bóc các dữ liệu cần thiết từ file XML. Parser kiểu này khó dùng hơn so với DOM parser nhưng bù lại có nhiều lợi ích: không chiếm dụng nhiều memory do quá trình là đọc đến đâu ghi đến đấy, việc ghi dữ liệu là do lập trình viên lựa chọn nên có thể customize để bỏ qua những trường không cần thiết (DOM Parser lưu toàn bộ các trường dữ liệu vào cây).

Những delegate chính của XMLParser (SAX Parser)

Apple cung cấp class XMLParser làm việc theo mô hình SAX Parser để thực thi quá trình đọc file XML. Những delegate chính cần quan tâm của class gồm:

// bắt đầu parse data XML
func parserDidStartDocument(XMLParser)
// bắt đầu tag, ví dụ <trkpt>
func parser(XMLParser, didStartElement: String, namespaceURI: String?,
qualifiedName: String?, attributes: [String : String] = [:])
// kết thúc tag, ví dụ </trkpt>
func parser(XMLParser, didEndElement: String, namespaceURI: String?,
qualifiedName: String?)
// lấy value nằm giữa thẻ tag <key>value</key>
func parser(XMLParser, foundCharacters: String)
// kết thúc quá trình parse data XML
func parserDidEndDocument(XMLParser)

Với 1 bản ghi data mẫu dưới đây (thông tin tracklog leo núi gồm toạ độ, độ cao và thời gian ghi tracklog):

<trkpt lat="22.54297276" lon="102.854709076">
    <ele>1318.59</ele>
    <time>2014-05-01T02:01:15Z</time>
</trkpt>

thì quá trình đọc sẽ như sau:

  • Trigger event (TE) didStartElement được gọi khi bắt đầu gặp thẻ <trkpt>, trong này ta sẽ thu được một dictionary attributes, truy cập dictionary này để bóc thông tin lat, lon.
  • TE didStartElement được gọi tiếp khi gặp thẻ <ele>
  • TE foundCharacters được gọi, giá trị ele lấy được qua biến foundCharacters của hàm này.
  • TE didEndElement được gọi khi gặp thẻ </ele>
  • TE didStartElement được gọi khi gặp thẻ <time>, chuyển tiếp qua trigger foundCharacters để bắt giá trị của time tương tự như thẻ <ele>.
  • TE didEndElement được gọi khi gặp thẻ </time>
  • TE didEndElement được gọi khi gặp thẻ </trkpt>

Quá trình được lặp lại cho đến khi đọc hết các thẻ <trkpt> khác.

Những delegate này chỉ duyệt tuần tự qua các thẻ của file XML và trả về những trigger events mà không làm gì cả, lập trình viên phải tự lựa chọn data để trích xuất và tự quyết định cấu trúc dữ liệu mapping đầu ra.

Những quy tắc khi parse dữ liệu XML bằng XMLParser

  • Dùng cờ (flag var - Bool) để tracking quá trình đọc dữ liệu: với data mẫu bên trên mình sử dụng cờ isReadingTrackingPoint cho thẻ <trkpt>, isReadingTrackingElevation cho thẻ <ele>... Bật cờ (=true) khi bắt đầu thẻ và hạ cờ (=false) khi đóng thẻ. Dùng cờ để validate tránh việc mapping nhầm dữ liệu (sẽ giải thích cụ thể ở phần sau).

  • Khi mapping dữ liệu vào Swift object: khởi tạo object 1 lần duy nhất, reset sau mỗi lần ghi xong dữ liệu, tránh khởi tạo nhiều lần gây lãng phí bộ nhớ.

  • Chỉ reset Swift object ở didEndElement sau khi đã ghi dữ liệu kết hợp với hạ cờ.

Parse dữ liệu XML với XMLParser

Với đầu vào là file xml như trên đầu bài viết, đầu ra mong muốn sau khi parse dữ liệu là 1 dictionary như sau :

["name":"Phu si Lung 05/01/14",
"trackPoints":[Point], // Point là 1 Swift object lưu thông tin tracklog
"totalTrackPoints":[Point].count]

Ta bắt đầu với việc tạo object để hứng dữ liệu được mapping từ file xml về:

import UIKit

class Point: NSObject {

    // Hold the elevation of tracking point
    var elevation: Double

    // Hold the latitude of tracking point
    var latitude: Double

    // Hold the longitude of tracking point
    var longitude: Double

    override init() {
        elevation = 0.0
        latitude  = 0.0
        longitude = 0.0
    }

    init(lat: Double, long: Double, ele: Double) {
        latitude  = lat
        longitude = long
        elevation = ele
    }
}

Tiếp theo tạo 1 class Parser conform với XMLParserDelegate:

import UIKit

let kTrackNameKey:String                        = "name"
let kTrackPointsKey:String                      = "trackPoints"
let kTotalTrackPointsKey:String                 = "totalTrackPoints"

let kTrackSegmentKey:String                     = "trkseg"
let kTrackPointKey:String                       = "trkpt"
let kTrackPointLatitudeAttributeKey:String      = "lat"
let kTrackPointLongitudeAttributeKey:String     = "lon"
let kTrackPointElevationKey:String              = "ele"

protocol HNXMLParserDelegate {
    func HNXMLParserDidFinishParsing(withResult: [String:Any])
}

class HNXMLParser: NSObject, XMLParserDelegate {

    static let sharedIntance = HNXMLParser() // singleton
    var parseDelegate: HNXMLParserDelegate?
    var xmlParser: XMLParser!

    // output parsing result
    var parsingResult = [String:Any]()

    // reading flags
    var isReadingName:Bool          = false
    var isReadingTrackSegment:Bool  = false  // trkseg
    var isReadingTrackPoint:Bool    = false  // trkpt
    var isReadingElevation:Bool     = false  // ele

    // tracking values
    var trackPoints = [Point]()
    var trackPoint:Point?

    func startParsingFileFromURL(urlString: String) {
        guard let url = URL(string: urlString) else {
            print("Can't load URL: \(urlString)")
            return
        }
        self.xmlParser = XMLParser(contentsOf: url)
        self.xmlParser.delegate = self
        let result = self.xmlParser.parse()
        print("Parsed from URL result: \(result)")
        if result == false {
            print(xmlParser.parserError?.localizedDescription)
        }
    }

    func startParsingFile(fileName: String, fileType: String) {
        guard Bundle.main.url(forResource: fileName, withExtension: fileType) != nil else {
            print("Can't load file \(fileName).\(fileType)")
            return
        }
        let url = Bundle.main.url(forResource: fileName, withExtension: fileType)
        self.xmlParser = XMLParser(contentsOf: url!)
        self.xmlParser.delegate = self
        let result = self.xmlParser.parse()
        print("Parsed from file result: \(result)")
        if result == false {
            print(xmlParser.parserError?.localizedDescription)
        }
    }

    //MARK: XMLParserDelegate

    // start document
    func parserDidStartDocument(_ parser: XMLParser) {
        print("parserDidStartDocument")
    }

    // start element <key>
    func parser(_ parser: XMLParser, didStartElement elementName: String,
    namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {

    }

    // found value of element <key>value</key>
    func parser(_ parser: XMLParser, foundCharacters string: String) {

    }

    // end element </key>
    func parser(_ parser: XMLParser, didEndElement elementName: String,
    namespaceURI: String?, qualifiedName qName: String?) {

    }

    // end document
    func parserDidEndDocument(_ parser: XMLParser) {
        parsingResult[kTotalTrackPointsKey] = trackPoints.count
        parser.abortParsing()
        xmlParser = nil
        print("parserDidEndDocument")
        self.parseDelegate?.HNXMLParserDidFinishParsing(withResult: parsingResult)
    }
}

Để bắt đầu quá trình parse dữ liệu, chỉ định delegate sau đó gọi hàm parse():

self.xmlParser.delegate = self
let result = self.xmlParser.parse()

Class này có một protocol nhằm mục đích gửi dữ liệu sau khi parse cho view controller để xử lí tiếp. Protocol được gọi khi kết thúc quá trình đọc XML.

protocol HNXMLParserDelegate {
    func HNXMLParserDidFinishParsing(withResult: [String:Any])
}

// end document
func parserDidEndDocument(_ parser: XMLParser) {
    print("parserDidEndDocument")
    self.parseDelegate?.HNXMLParserDidFinishParsing(withResult: parsingResult)
}

Tạo một view controller trống gọi đến parser trên:

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        HNXMLParser.sharedIntance.parseDelegate = self
        HNXMLParser.sharedIntance.startParsingFile(fileName: "Phu_si_Lung_05_01_14", fileType: "gpx")
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

extension ViewController: HNXMLParserDelegate {
    func HNXMLParserDidFinishParsing(withResult: [String : Any]) {
        print("PARSE RESULT: \(withResult.description)")
    }
}

Build app để chạy thử, kết quả thu được sẽ như sau:

parserDidStartDocument
parserDidEndDocument
PARSE RESULT: ["totalTrackPoints": 0]
Parsed from file result: true

Ta bắt đầu với trường "name" của file xml:

func parser(_ parser: XMLParser, didStartElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
        if elementName == kTrackNameKey {
            isReadingName = true
        }
}

func parser(_ parser: XMLParser, foundCharacters string: String) {
        if isReadingName {
            parsingResult[kTrackNameKey] = string
        }
}

func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
        if elementName == kTrackNameKey {
            isReadingName = false
        }
}

Bắt đầu bằng việc bật cờ khi gặp thẻ <name> trong hàm didStartElement, chuyển tiếp sang hàm foundCharacters để lấy dữ liệu nằm trong thẻ, kết thúc bằng việc hạ cờ trong hàm didEndElement. Build app chạy thử ta sẽ thu được kết quả như sau:

parserDidStartDocument
parserDidEndDocument
PARSE RESULT: ["name": "Phu si Lung 05/01/14", "totalTrackPoints": 0]
Parsed from file result: true

Tiếp theo đọc đến thẻ <trkseg>, theo cấu trúc file xml thì thẻ bao toàn bộ các đối tượng cần parse, đúng logic là khi gặp thẻ này mới khởi tạo mảng chứa object, nhưng do Swift bắt khai báo kèm khởi tạo nên ta có thể bỏ qua bước này.

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
        if elementName == kTrackNameKey {
            isReadingName = true
        } else if elementName == kTrackSegmentKey {
            // do nothing here
        }
    }

Phần quan trọng nhất, đọc thông tin track point: Bật cờ khi gặp thẻ <trkpt>, thẻ này có chứa attribute nên ta duyệt dictionary attributeDict để lấy thông tin:

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
        if elementName == kTrackNameKey {
            isReadingName = true
        } else if elementName == kTrackSegmentKey {
            // do nothing here
        } else if elementName == kTrackPointKey {
            isReadingTrackPoint = true
            // get lat value
            guard attributeDict[kTrackPointLatitudeAttributeKey] != nil else {
                return
            }
            let lat:Double = NSString(string: attributeDict[kTrackPointLatitudeAttributeKey]!).doubleValue
            // get long value
            guard attributeDict[kTrackPointLongitudeAttributeKey] != nil else {
                return
            }
            let lon:Double = NSString(string: attributeDict[kTrackPointLongitudeAttributeKey]!).doubleValue
            trackPoint = Point()
            trackPoint?.latitude = lat
            trackPoint?.longitude = lon
        }
    }

    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
        if elementName == kTrackNameKey {
            isReadingName = false
        }
        else if elementName == kTrackPointKey {
            // add point to track points array
            guard trackPoint != nil else {
                return
            }
            trackPoints.append(trackPoint!)
            // reset reading flag and current track point
            trackPoint = nil
            isReadingTrackPoint = false
        } else if elementName == kTrackSegmentKey {
            // save track points data
            parsingResult[kTrackPointsKey] = trackPoints
            // reset reading flag
            isReadingTrackSegment = false
        }
    }

Sau khi đã có đủ thông tin lat, long cho object Point, khi gặp thẻ đóng ta làm những việc sau:

  • Thêm đối tượng Point vào mảng object đã khởi tạo trước
  • Reset giá trị của object Point để dùng lại ở lượt đọc mới, ko cần thiết phải khởi tạo một object mới ở mỗi lần đọc
  • Hạ cờ isReadingTrackPoint. Sau khi thẻ </trkpt> cuối cùng được duyệt, công việc mapping object coi như đã hoàn tất, chuyển sang thẻ bao </trkseg>. Ở thẻ này ta hoàn thiện những bước cuối của quá trình đọc file xml:
  • Hạ cờ isReadingTrackSegment
  • Gán giá trị của mảng object Point đã thu được ở trên đẩy ra delegate Sửa lại phần log in ra một chút, ta thu được kết quả như sau:
parserDidStartDocument
parserDidEndDocument
== PARSING RESULT ==:
Track name: Phu si Lung 05/01/14
Track points start:
point 1 - lat:22.54297276, long: 102.854709076, ele: 0.0
point 2 - lat:22.542975945, long: 102.854734389, ele: 0.0
point 3 - lat:22.542992374, long: 102.854716033, ele: 0.0
....
point 4625 - lat:22.606668351, long: 102.809284106, ele: 0.0
point 4626 - lat:22.60661236, long: 102.809297852, ele: 0.0
Track points end. Total track points: 4626
Parsed from file result: true

Có thể nhận thấy giá trị của trường elevation trống vì ta chưa xử lý trường dữ liệu này. Để tách trường dữ liệu này, bắt đầu bằng việc bật cờ khi gặp thẻ <ele>:

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
        if elementName == kTrackNameKey {
            isReadingName = true
        } else if elementName == kTrackSegmentKey {
            // do nothing here
        } else if elementName == kTrackPointKey {
            isReadingTrackPoint = true
            // get lat value
            guard attributeDict[kTrackPointLatitudeAttributeKey] != nil else {
                return
            }
            let lat:Double = NSString(string: attributeDict[kTrackPointLatitudeAttributeKey]!).doubleValue
            // get long value
            guard attributeDict[kTrackPointLongitudeAttributeKey] != nil else {
                return
            }
            let lon:Double = NSString(string: attributeDict[kTrackPointLongitudeAttributeKey]!).doubleValue
            trackPoint = Point()
            trackPoint?.latitude = lat
            trackPoint?.longitude = lon
        } else if elementName == kTrackPointElevationKey {
            isReadingElevation = true
        }
    }

Tiếp theo ta lấy giá trị của thẻ này qua hàm foundCharacters sau đó gán vào đối tượng Point hiện tại (vì thẻ <ele> nằm trong thẻ <trkpt> nên khi đọc thẻ <ele> đối tượng Point vẫn tồn tại, chỉ cần kiểm tra điều kiện rồi gán giá trị bình thường)

func parser(_ parser: XMLParser, foundCharacters string: String) {
        if isReadingName {
            parsingResult[kTrackNameKey] = string
        }
        else if isReadingTrackPoint {
            // parsing elevation
            if isReadingElevation {
                guard trackPoint != nil else {
                    return
                }
                trackPoint!.elevation = NSString(string: string).doubleValue
            }

        }
    }

Quá trình đọc dữ liệu hoàn tất khi gặp thẻ </ele>, hạ cờ isReadingElevation.

func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
        if elementName == kTrackNameKey {
            isReadingName = false
        }
        else if elementName == kTrackPointKey {
            // add point to track points array
            guard trackPoint != nil else {
                return
            }
            trackPoints.append(trackPoint!)
            // reset reading flag and current track point
            trackPoint = nil
            isReadingTrackPoint = false
        } else if elementName == kTrackPointElevationKey {
            // do nothing here
            isReadingElevation = false
        } else if elementName == kTrackSegmentKey {
            // save track points data
            parsingResult[kTrackPointsKey] = trackPoints
            // reset reading flag
            isReadingTrackSegment = false
        }
    }

Build app chạy thử, kết quả thu được sẽ như sau:

parserDidStartDocument
parserDidEndDocument
== PARSING RESULT ==:
Track name: Phu si Lung 05/01/14
Track points start:
point 1 - lat:22.54297276, long: 102.854709076, ele: 1318.59
point 2 - lat:22.542975945, long: 102.854734389, ele: 1318.1
point 3 - lat:22.542992374, long: 102.854716033, ele: 1318.1
...
point 4625 - lat:22.606668351, long: 102.809284106, ele: 2143.88
point 4626 - lat:22.60661236, long: 102.809297852, ele: 2141.47
Track points end. Total track points: 4626
Parsed from file result: true

Kết

Như vậy là ta đã hoàn thành quá trình đọc 1 file xml, mapping vào object trong Swift. Các bạn có thể tải về project hoàn chỉnh tại đây (Yêu cầu xcode 8.x trở lên), có thể thử mapping nốt trường date ra nếu thích. Qua bài viết này hi vọng mọi người có thể hiểu được cơ chế parse xml của XMLParser để có thể áp dụng vào tình huống cụ thể.


All Rights Reserved