原文地址 翻译:DeveloperLx
2016年9月25日更新: 本教程已更新至Xcode 8及Swift 3。
更新笔记: 本教程已由Hai Nguyen更新至Swift版。原教程由Vincent Ngo撰写。
在 大数据 时代,数据是以多种格式保存的,这就使得处理它成为了一项挑战。如果你幸运的话,数据会是有组织和层次的格式,就像 JSON , XML , CSV 这样。否则,你就会挣扎在无尽的if/case语句之间。另一方面,手动地摘取数据也是非常无聊的。
谢天谢地,苹果提供了一系列用来分析在任何形式下的字符串数据的工具,从自然的到电脑的语言,诸如
NSRegularExpression
,
NSDataDetector
或
Scanner
。它们各自都有各自的优势,但到目前为止,
Scanner
是最容易使用的,不仅功能强大,而且非常灵活。在本教程中,你将会学习如何使用它,来从电子邮件的信息中抽取信息,去构建一个macOS应用。它工作起来,就像如下所示的苹果的Mail界面这样。
尽管你是为Mac构建app,但
Scanner
同样也可以用在iOS上。在这个教程的结尾,你将可以在任一平台上解析文本。
在开始之前,让我们来看一看
Scanner
有哪些功能!
Scanner
的主要功能是检索和解释子字符串和数值。
例如,
Scanner
可以分析一个电话号码,并将其拆分成像下面这样的几个部分:
// 1.
let hyphen = CharacterSet(charactersIn: "-")
// 2.
let scanner = Scanner(string: "123-456-7890")
scanner.charactersToBeSkipped = hyphen
// 3.
var areaCode, firstThreeDigits, lastFourDigits: NSString?
scanner.scanUpToCharacters(from: hyphen, into: &areaCode) // A
scanner.scanUpToCharacters(from: hyphen, into: &firstThreeDigits) // B
scanner.scanUpToCharacters(from: hyphen, into: &lastFourDigits) // C
print(areaCode!, firstThreeDigits!, lastFourDigits!)// 123 - area code
// 456 - first three digits
// 7890 - last four digits
以上代码做的事有:
-
创建的一个名为
hyphen
的CharacterSet
的实例。这将会作为字符串成分之间的分隔符。 -
初始化一个
Scanner
对象,并将它的charactersToBeSkipped
默认值(空格和换行符)修改为hyphen
,因此返回的字符串将不包含任何连字符。 -
areaCode
,firstThreeDigits
和lastFourDigits
将会储存你从scanner返回的解析过的值。由于你无法将 Swift 本身的String
直接作为AutoreleasingUnsafeMutablePointer
,因此为了将他们传给scanner的方法,你不得不将这些变量声明为可选的NSString
对象。-
扫描至第一个
–
字符并将连字符前的值分配到
areaCode
中。 -
继续扫描到第二个
–
并抓取下面的三个数字到
firstThreeDigits
中。在调用scanUpToCharactersFromSet(from:into:)
之前,scanner的读取光标位于首次发现-
的位置。在忽略掉连字符之后,你就获得了电话号码的第二个成分。 -
寻找下一个
-
。scanner结束了字符串的剩余部分,并返回一个成功的状态。之后就没有连字符了,scanner会将剩余的子字符串装到lastFourDigits
中。
-
扫描至第一个
–
字符并将连字符前的值分配到
这就是
Scanner
所作的全部的事。容易吧!现在,是时候来搞个app了!
下载 起始项目 并提取出ZIP文件的内容。在Xcode中打开 EmailParser.xcodeproj 。
你将发现下列的内容:
- DataSource.swift 包含一个预制的结构,它可以配置 data source/delegate 来填充一个 table view 。
- PostCell.swift 包含所有你需要展示的每个独立数据项目的property。
- Support/Main.storyboard 包含一个带有定制的cell的 TableView ,位于左侧,以及在另一侧的 TextView 。
你将解析在 comp.sys.mac.hardware 中的49个样本文件中的数据。让我们来花一些时间来浏览它们的结构吧。你将会收集诸如 Name , Email 等项目到一个table中,以便一眼把它们看懂。
运行项目来在实际中查看它。
table view当前使用 [Field]Value 前缀展示占位的label。在这篇教程的最后,它们将会被解析过的数据来替换。
在深入解析之前,理解你要实现的,是一个什么样的效果是非常重要的。以下是样本文件中的一个,你将重点检索的数据项。
总的来说,这些数据项目就是:
- From 域 :它包含了sender的名称和email。解析它是非常tricky的,因为名称可能会在email之前出现,反之亦然;它可能甚至只包含一个却不包含另一个。
- Subject , Date , Organization 和 Lines fields :这些包含了由冒号分隔的值。
- Message 部分 :包含了成本信息以及一些下面的关键字: apple , macs , software , keyboard , printer , video , monitor , laser , scanner , disks , cost , price , floppy , card ,以及 phone 。
Scanner
是超赞的;然而,我们使用它的时候会感到有一些笨重,并且不那么的swift,因此你会转换內建的方法,就像上面那个电话号码的例子中一样,返回可选类型的值。
点击 File\New\File… (或只需按 Command+N 键)。选择 macOS > Source > Swift File 并单击 Next 。设置文件的名称为 Scanner+.swift ,然后单击 Create 。
打开 Scanner+.swift 并添加下列的extension:
extension Scanner {
func scanUpToCharactersFrom(_ set: CharacterSet) -> String? {
var result: NSString? // 1.
return scanUpToCharacters(from: set, into: &result) ? (result as? String) : nil // 2.
}
func scanUpTo(_ string: String) -> String? {
var result: NSString?
return self.scanUpTo(string, into: &result) ? (result as? String) : nil
}
func scanDouble() -> Double? {
var double: Double = 0
return scanDouble(&double) ? double : nil
}
}
这些助手方法封装了一些你在这篇教程中用到的
Scanner
的方法,它们会返回
String
的可选类型。这三个方法有着相同的结构:
-
定义一个
result
变量来持有scanner返回的结果。 -
使用一个三元操作符来检查扫描是否成功。如果成功的话,将
result
转化为String
并返回它;否则就返回nil
。
Scanner
方法,并将它们保存到你的武器库中:
- scanDecimal(_:)
- scanFloat(_:)
- scanHexDouble(_:)
- scanHexFloat(_:)
- scanHexInt32(_:)
- scanHexInt64(_:)
- scanInt(_:)
- scanInt32(_:)
- scanInt64(_:)
很简单,不是么?现在返回主项目并开始进行解析吧!
找到 File\New\File… (或只需按下 Command+N 键)。选择 macOS > Source > Swift File 并单击 Next 。设置文件的名称为 HardwarePost.swift ,然后单击 Create 。
打开 HardwarePost.swift 并添加下列的结构体:
struct HardwarePost {
// MARK: Properties
// the fields' values once extracted placed in the properties
let email: String
let sender: String
let subject: String
let date: String
let organization: String
let numberOfLines: Int
let message: String
let costs: [Double] // cost related information
let keywords: Set<String> // set of distinct keywords
}
以上代码定义了
HardwarePost
结构体,它可以用来储存解析到的数据。默认情况下,
Swift
基于结构体的property,向你提供了一个默认的构造方法,但你会在之后回到这里,来实现你自己的初始化方法。
准备好用
Scanner
解析数据了么?动手吧。
找到 File\New\File… (或只需按下 Command+N 键),选择 macOS > Source > Swift File 并点击 Next 。设置文件的名称为 ParserEngine.swift ,然后点击 Create 。
打开
ParserEngine.swift
并添加下列代码,创建
ParserEngine
类:
final class ParserEngine {
}
考虑下列示例的元数据段:
这里是
Scanner
进入并分割字段和它的值的地方。下面的图给出了你这个结构体的一般的视觉上的表示。
打开
ParserEngine.swift
并在
ParserEngine
类中添加下列的代码:
// 1.
typealias Fields = (sender: String, email: String, subject: String, date: String, organization: String, lines: Int)
/// Returns a collection of predefined fields' extracted values
func fieldsByExtractingFrom(_ string: String) -> Fields {
// 2.
var (sender, email, subject, date, organization, lines) = ("", "", "", "", "", 0)
// 3.
let scanner = Scanner(string: string)
scanner.charactersToBeSkipped = CharacterSet(charactersIn: " :\n")
// 4.
while !scanner.isAtEnd { // A
let field = scanner.scanUpTo(":") ?? "" // B
let info = scanner.scanUpTo("\n") ?? "" // C
<span class="hljs-comment">// D</span>
<span class="hljs-keyword">switch</span> field {
<span class="hljs-keyword">case</span> <span class="hljs-string">"From"</span>: (email, sender) = fromInfoByExtractingFrom(info) <span class="hljs-comment">// E</span>
<span class="hljs-keyword">case</span> <span class="hljs-string">"Subject"</span>: subject = info
<span class="hljs-keyword">case</span> <span class="hljs-string">"Date"</span>: date = info
<span class="hljs-keyword">case</span> <span class="hljs-string">"Organization"</span>: organization = info
<span class="hljs-keyword">case</span> <span class="hljs-string">"Lines"</span>: lines = <span class="hljs-type">Int</span>(info) ?? <span class="hljs-number">0</span>
<span class="hljs-keyword">default</span>: <span class="hljs-keyword">break</span>
}
}
return (sender, email, subject, date, organization, lines)
}
不要惊慌! Xcode 上的未解决的标识符的错误,将会在下一部分中消失。
这里是以上代码所做的事:
-
为解析到的字段的元组定义一个
Fields
类型的别名。 - 创建用来持有返回值的变量
-
初始化一个
Scanner
示例,并将它的charactersToBeSkipped
property改变为除了它的默认值(空格和换行符)外,还包含一个冒号。 -
通过重复下列的过程,获取全部想要的字段的值:
-
使用
while
来循环访问string
的内容,直到它的结尾处。 -
调用你之前创建的工具方法之一,来获取
field
位于:
之前的标题。 -
继续扫描至行尾
\n
处,并将结果赋值给info
。 -
使用
switch
来找到匹配的字段,并将它的info
property的值储存到合适的变量中。
-
调用
fromInfoByExtractingFrom(_:)
分析 From 字段。你将在这部分之后实现这个方法。
-
使用
还记得 From 字段这个tricky的部分么?挺住,你将在正则表达式的帮助下克服这个挑战。
在
ParserEngine.swift
的末尾,添加下列的
String
的extension:
private extension String {
func isMatched( pattern: String) -> Bool {
return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: self)
}
}
这个extension定义了一个私有的助手方法,来得出给到的字符串是否匹配给到的正则表达式。
它用
NSPredicate
操作符,使用正则表达式创建了一个
NSPredicate
对象。然后调用
evaluate(with:)
来检查字符串是否与之匹配。
NSPredicate
的内容。
现在添加下列的方法到
ParserEngine
实现的内部,就在
fieldsByExtractingFrom(_:)
方法之后:
fileprivate func fromInfoByExtractingFrom( string: String) -> (email: String, sender: String) {
let scanner = Scanner(string: string)
// 1.
/*
* ROGOSCHP@MAX.CC.Uregina.CA (Are we having Fun yet ???)
* oelt0002@student.tc.umn.edu (Bret Oeltjen)
* (iisi owner)
* mbuntan@staff.tc.umn.edu ()
* barry.davis@hal9k.ann-arbor.mi.us (Barry Davis)
*/
if string.isMatched(".[\s]\({1}(.*)") { // A
scanner.charactersToBeSkipped = CharacterSet(charactersIn: "() ") // B
let email = scanner.scanUpTo("(") // C
let sender = scanner.scanUpTo(")") // D
return (email ?? "", sender ?? "")
}
// 2.
/*
* "Jonathan L. Hutchison" <jh6r+@andrew.cmu.edu>
* <BR4416A@auvm.american.edu>
* Thomas Kephart <kephart@snowhite.eeap.cwru.edu>
* Alexander Samuel McDiarmid <am2o+@andrew.cmu.edu>
*/
if string.isMatched(".[\s]<{1}(.*)") {
scanner.charactersToBeSkipped = CharacterSet(charactersIn: "<> ")
let sender = scanner.scanUpTo("<")
let email = scanner.scanUpTo(">")
return (email ?? "", sender ?? "")
}
// 3.
return ("unknown", string)
}
在检查了49个数据集之后,最后考虑以下三种case:
- email (name)
- name <email>
- 没有名称的 email
以上代码完成了:
-
用第一个模式 -
email (name)
匹配
string
。如果不匹配的话,执行到下一个case。-
.*
可以用来查找零个或多个任意的字符,后跟零个或多个的空格 -[\s]*
,后跟一个开发的括号 -\({1}
,最后则是一个或多个的字符串 -(.*)
。 -
设置
Scanner
对象的charactersToBeSkipped
包含“(”,“)”和空格。 -
扫描到
(
来获取email
的值。 -
扫描到
)
,这会给到你sender
的名称。这就提取了在(
和)
之间的任何内容。
-
- 检查给到的字符创是否匹配模式 - name <email> 。 if 语句体和第一个场景中几乎是相同的,除了你对尖括号的处理。
- 最终,如果两种模式都不匹配,那么就只有一份电子邮件。你只需返回email的字符串,sender则返回“unknown”。
现在,你可以build你的项目了。之前的编译错误消失了。
你已经使用了
Scanner
来分析和检索来自于模式字符串的信息。在接下来的两节中,你将了解到如何去解析非结构化的数据。
解析非结构化数据的一个很好的例子,就是确定电子邮件的正文中是否包含成本相关的信息。为了实现这点,你将使用
Scanner
来搜索一个美元字符:
$
的出现。
仍然在
ParserEngine.swift
中,添加下列的实现到
ParserEngine
类中:
func costInfoByExtractingFrom(_ string: String) -> [Double] {
// 1.
var results = Double
// 2.
let dollar = CharacterSet(charactersIn: "$")
// 3.
let scanner = Scanner(string: string)
scanner.charactersToBeSkipped = dollar
// 4.
while !scanner.isAtEnd && scanner.scanUpToCharacters(from: dollar, into: nil) {
results += [scanner.scanDouble()].flatMap { $0 }
}
return results
}
这个代码相当地直接:
- 定义一个空数组来保存费用的值。
-
使用
$
字符创建一个CharacterSet
对象。 -
初始化一个
Scanner
实例,并配置它忽略 $ 字符。 -
遍历
string
的内容,当$
字符被找到时,使用你的助手方法抓取$
字符后的数字,并将它添加到results
数组中。
解析非结构化数据的另一个例子,是在给到的文本内容中找到关键字。你的搜索策略,是查找每一个单词,并检查一组关键字,看它是否匹配。你将使用 空格 和 换行符 来将消息中的单词进行扫描。
在
ParserEngine
类的尾部添加下列代码:
// 1.
let keywords: Set<String> = ["apple", "macs", "software", "keyboard",
"printers", "printer", "video", "monitor",
"laser", "scanner", "disks", "cost", "price",
"floppy", "card", "phone"]
/// Return a set of keywords extracted from
func keywordsByExtractingFrom(_ string: String) -> Set<String> {
// 2.
var results: Set<String> = []
// 3.
let scanner = Scanner(string: string)
// 4.
while !scanner.isAtEnd, let word = scanner.scanUpTo(" ")?.lowercased() {
if keywords.contains(word) {
results.insert(word)
}
}
return results
}
以上代码:
- 定义了你将匹配的关键字的集合。
-
创建一个
String
的Set
来保存找到的关键字。 -
初始化一个
Scanner
的实例。你将采用默认的charactersToBeSkipped
值,即空格和换行符。 -
对于每个发现的单词,检查其是否是预定义的
keywords
之一。如果是的话,添加它到results
中。
在这里,你已经有了获取所需信息的所有必须的方法。是时候投入实用,为49个数据文件创建
HardwarePost
实例。
打开
HardwarePost.swift
并添加下面的初始化器到
HardWarePost
结构体中:
init(fromData data: Data) {
// 1.
let parser = ParserEngine()
// 2.
let string = String(data: data, encoding: String.Encoding.utf8) ?? ""
// 3.
let scanner = Scanner(string: string)
// 4.
let metadata = scanner.scanUpTo("\n\n") ?? ""
let (sender, email, subject, date, organization, lines) = parser.fieldsByExtractingFrom(metadata)
// 5.
self.sender = sender
self.email = email
self.subject = subject
self.date = date
self.organization = organization
self.numberOfLines = lines
// 6.
let startIndex = string.characters.index(string.startIndex, offsetBy: scanner.scanLocation) // A
let message = string[startIndex..<string.endIndex] // B
self.message = message.trimmingCharacters(in: .whitespacesAndNewlines ) // C
// 7.
costs = parser.costInfoByExtractingFrom(message)
keywords = parser.keywordsByExtractingFrom(message)
}
HardwarePost
如何初始化它的property:
-
创建名为
parser
的ParserEngine
对象。 -
将
data
转化为String
。 -
初始化一个
Scanner
的实例,来解析由“\n\n”分隔的元数据和消息段。 -
扫描到第一个
\n\n
来抓取元数据的字符串,然后调用parser
的fieldsByExtractingFrom(_:)
方法,来获取全部的元数据字段。 -
将解析的结果赋值到
HardwarePost
的property中。 -
准备消息内容:
-
使用
scanLocation
获取scanner
当前的读取指针,并将它转化为String.CharacterView.Index
,这样你就可以通过range来替代string
。 -
将
scanner
剩余还未读取的字符串赋给新的message
变量。 -
由于
message
的值仍然包含\n\n
,也就是scanner
从之前的读取停止的地方,你需要trim它,并将新的值给回到HardwarePost
实例的message
property中。
-
使用
-
用
message
调用parser
的方法,来检索cost
和keywords
property的值。
现在,你就可以由文件的数据直接创建
HardwarePost
实例。你距离展现出最后的产品已只剩几步之遥了!
打开
PostCell.swift
并添加下列的方法到
PostCell
类的实现中:
func configure(_ post: HardwarePost) {
senderLabel.stringValue = post.sender
emailLabel.stringValue = post.email
dateLabel.stringValue = post.date
subjectLabel.stringValue = post.subject
organizationLabel.stringValue = post.organization
numberOfLinesLabel.stringValue = "(post.numberOfLines)"
// 1.
costLabel.stringValue = post.costs.isEmpty ? "NO" :
post.costs.map { "($0)" }.lazy.joined(separator: "; ")
// 2.
keywordsLabel.stringValue = post.keywords.isEmpty ? "No keywords found" :
post.keywords.joined(separator: "; ")
}
上面的代码将post的值分配给了cell label。
costLabel
和
keywordsLabel
需要特殊的处理,因为它们可以为空。这里是会发生的事:
-
如果
costs
数组为空,就设置costLabel
的string值为 NO ;否则,就用“;”这个操作符连接cost的值。 -
类似的,当
post.keywords
是一个空集合时,设置keywordsLabel
的string值为No words found
。
你几乎已要搞定了!打开
DataSource.swift
。删除
DataSource
初始化器
init()
并添加下列的代码到这个类中:
let hardwarePosts: [HardwarePost] // 1.
override init() {
self.hardwarePosts = Bundle.main // 2.
.urls(forResourcesWithExtension: nil, subdirectory: "comp.sys.mac.hardware")? // 3.
.flatMap( { try? Data(contentsOf: $0) }).lazy // 4.
.map(HardwarePost.init) ?? [] // 5.
super.init()
}
上述代码:
-
储存
HardwarePost
实例。 - 获取指向app主Bundle的引用。
- 检索在 comp.sys.mac.hardware 目录中的示例文件的url。
-
通过使用
Data
的可是白初始化器和flatMap(_:)
读取文件内容,惰性获取Data
实例的数组。使用flatMap(_:)
是要获取元素不为nil
的子数组。 -
最后,将
Data
的结果转换为HardwarePost
对象,并将它们赋给DataSource
的hardwarePosts
property。
现在你需要配置table view的data source和delegate,让app可以展示你的工作成果。
打开
DataSource.swift
。找到
numberOfRows(in:)
,并使用下列代码来替换它:
func numberOfRows(in tableView: NSTableView) -> Int {
return hardwarePosts.count
}
numberOfRows(in:)
是table view的data source协议的一部分;它设置了table view的行数。
接下来,找到
tableView(_:viewForTableColumn:row:)
,并使用下列代码替换注释
//TODO: Set up cell view
:
cell.configure(hardwarePosts[row])
table view会调用代理方法
tableView(_:viewForTableColumn:row:)
来设置每个cell。它为相应的行获取post的引用,并调用
PostCell
的
configure(_:)
方法来展示数据。
现在你需要当在table view中选择一个post时,在text view上展示它。使用下列代码替换
tableViewSelectionDidChange(_:)
的实现:
func tableViewSelectionDidChange(_ notification: Notification) {
guard let tableView = notification.object as? NSTableView else {
return
}
textView.string = hardwarePosts[tableView.selectedRow].message
}
tableViewSelectionDidChange(_:)
方法会在table view的选择发生变化时被调用。当调用发生时,这个代码就会获取选择的行的硬件post,并在text view中展示
message
。
运行你的项目。
所有被解析的字段现在都已经很好地展示在table上了。选择一个左侧的cell,你就会在右侧看到相应的信息。Good Job!
这里是完整项目的
源码
。
有很多你可以基于解析到的数据去做的事。你可以写一个格式转换器,将
HardwarePost
对象转换为JSON,XML,CSV或其它格式。你可以通过新发现的灵活性,来以不同的格式展示数据,你可以在不同的平台上分享数据。
如果你感兴趣于学习计算机语言,已经它们是如何实现的,可以参加 If you’re interested in the study of computer languages and how they are implemented, take a class in comparative 语言的课程。你的课程将可能覆盖正式的语言,和BNF(巴科斯-诺尔范式)语法的有关设计和实现解析器的重要的概念。
有关
Scanner
和其它解析理论的更多信息,请访问下列资源:
- Swift教程:使用JSON
- Apple:Scanner参考指南
- iOS的XML教程 - 如果为你的iPhone项目选择最好的XML解析器 (Objective-C)
- 使用NSScanner写一个解析器(一个CSV的解析例子) - Matt Gallagher(Objective-C)
- NSScanner的简短介绍 - Lorex Antiono(Objective-C)