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 ...
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 r