Skip to content

Latest commit

 

History

History
1849 lines (1809 loc) · 64.2 KB

Scanner Tutorial for macOS.md

File metadata and controls

1849 lines (1809 loc) · 64.2 KB

macOS的Scanner教程

2016年9月25日更新: 本教程已更新至Xcode 8及Swift 3。

更新笔记: 本教程已由Hai Nguyen更新至Swift版。原教程由Vincent Ngo撰写。

NSScannerFeatureImage

大数据 时代,数据是以多种格式保存的,这就使得处理它成为了一项挑战。如果你幸运的话,数据会是有组织和层次的格式,就像 JSON , XML CSV 这样。否则,你就会挣扎在无尽的if/case语句之间。另一方面,手动地摘取数据也是非常无聊的。

谢天谢地,苹果提供了一系列用来分析在任何形式下的字符串数据的工具,从自然的到电脑的语言,诸如 NSRegularExpression NSDataDetector Scanner 。它们各自都有各自的优势,但到目前为止, Scanner 是最容易使用的,不仅功能强大,而且非常灵活。在本教程中,你将会学习如何使用它,来从电子邮件的信息中抽取信息,去构建一个macOS应用。它工作起来,就像如下所示的苹果的Mail界面这样。

Completed-Final-Screen

尽管你是为Mac构建app,但 Scanner 同样也可以用在iOS上。在这个教程的结尾,你将可以在任一平台上解析文本。

在开始之前,让我们来看一看 Scanner 有哪些功能!

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

以上代码做的事有:

  1. 创建的一个名为 hyphen CharacterSet 的实例。这将会作为字符串成分之间的分隔符。
  2. 初始化一个 Scanner 对象,并将它的 charactersToBeSkipped 默认值(空格和换行符)修改为 hyphen ,因此返回的字符串将不包含任何连字符。
  3. areaCode firstThreeDigits lastFourDigits 将会储存你从scanner返回的解析过的值。由于你无法将 Swift 本身的 String 直接作为 AutoreleasingUnsafeMutablePointer ,因此为了将他们传给scanner的方法,你不得不将这些变量声明为可选的 NSString 对象。
    1. 扫描至第一个 字符并将连字符前的值分配到 areaCode 中。
    2. 继续扫描到第二个 并抓取下面的三个数字到 firstThreeDigits 中。在调用 scanUpToCharactersFromSet(from:into:) 之前,scanner的读取光标位于首次发现 - 的位置。在忽略掉连字符之后,你就获得了电话号码的第二个成分。
    3. 寻找下一个 - 。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来展示数据,因此如果你不熟悉table view,请访问我们的 macOS NSTableView教程

运行项目来在实际中查看它。

Starter-Initial-Screen

table view当前使用 [Field]Value 前缀展示占位的label。在这篇教程的最后,它们将会被解析过的数据来替换。

理解原始样本的结构

在深入解析之前,理解你要实现的,是一个什么样的效果是非常重要的。以下是样本文件中的一个,你将重点检索的数据项。

Data-Structure-Illustration

总的来说,这些数据项目就是:

  • 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 的可选类型。这三个方法有着相同的结构:

  1. 定义一个 result 变量来持有scanner返回的结果。
  2. 使用一个三元操作符来检查扫描是否成功。如果成功的话,将 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 {
}

提取元数据字段

考虑下列示例的元数据段:

Metadata-Segment

这里是 Scanner 进入并分割字段和它的值的地方。下面的图给出了你这个结构体的一般的视觉上的表示。

Field-Structure-Illustraion

打开 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 上的未解决的标识符的错误,将会在下一部分中消失。

这里是以上代码所做的事:

  1. 为解析到的字段的元组定义一个 Fields 类型的别名。
  2. 创建用来持有返回值的变量
  3. 初始化一个 Scanner 示例,并将它的 charactersToBeSkipped property改变为除了它的默认值(空格和换行符)外,还包含一个冒号。
  4. 通过重复下列的过程,获取全部想要的字段的值:
    1. 使用 while 来循环访问 string 的内容,直到它的结尾处。
    2. 调用你之前创建的工具方法之一,来获取 field 位于 : 之前的标题。
    3. 继续扫描至行尾 \n 处,并将结果赋值给 info
    4. 使用 switch 来找到匹配的字段,并将它的 info property的值储存到合适的变量中。
    5. 调用 fromInfoByExtractingFrom(_:) 分析 From 字段。你将在这部分之后实现这个方法。

还记得 From 字段这个tricky的部分么?挺住,你将在正则表达式的帮助下克服这个挑战。

注意: 正则表达式是处理模式字符串的好工具,这篇 NSRegularExpression教程 会给你一个很好的关于如何使用它的概览。

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:) 来检查字符串是否与之匹配。

注意: 你可以在 the official Apple documentation 中读到更多的关于 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

以上代码完成了:

  1. 用第一个模式 - email (name) 匹配 string 。如果不匹配的话,执行到下一个case。
    1. .* 可以用来查找零个或多个任意的字符,后跟零个或多个的空格 - [\s]* ,后跟一个开发的括号 - \({1} ,最后则是一个或多个的字符串 - (.*)
    2. 设置 Scanner 对象的 charactersToBeSkipped 包含“(”,“)”和空格。
    3. 扫描到 ( 来获取 email 的值。
    4. 扫描到 ) ,这会给到你 sender 的名称。这就提取了在 ( ) 之间的任何内容。
  2. Field-Value-Illustration

  3. 检查给到的字符创是否匹配模式 - name <email> if 语句体和第一个场景中几乎是相同的,除了你对尖括号的处理。
  4. 最终,如果两种模式都不匹配,那么就只有一份电子邮件。你只需返回email的字符串,sender则返回“unknown”。

现在,你可以build你的项目了。之前的编译错误消失了。

Starter-Initial-Screen

注意: NSDataDetector 对类似于电话号码,地址,邮箱这样的已知的数据类型来讲,是一个更好的解决方案。你可以访问 这篇 关于使用 NSDataDetector 验证电子邮箱的博客。

你已经使用了 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
}

这个代码相当地直接:

  1. 定义一个空数组来保存费用的值。
  2. 使用 $ 字符创建一个 CharacterSet 对象。
  3. 初始化一个 Scanner 实例,并配置它忽略 $ 字符。
  4. 遍历 string 的内容,当 $ 字符被找到时,使用你的助手方法抓取 $ 字符后的数字,并将它添加到 results 数组中。

解析消息

解析非结构化数据的另一个例子,是在给到的文本内容中找到关键字。你的搜索策略,是查找每一个单词,并检查一组关键字,看它是否匹配。你将使用 空格 换行符 来将消息中的单词进行扫描。

Keywords-Parser-Illustration

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
}

以上代码:

  1. 定义了你将匹配的关键字的集合。
  2. 创建一个 String Set 来保存找到的关键字。
  3. 初始化一个 Scanner 的实例。你将采用默认的 charactersToBeSkipped 值,即空格和换行符。
  4. 对于每个发现的单词,检查其是否是预定义的 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:

  1. 创建名为 parser ParserEngine 对象。
  2. data 转化为 String
  3. 初始化一个 Scanner 的实例,来解析由“\n\n”分隔的元数据和消息段。
  4. 扫描到第一个 \n\n 来抓取元数据的字符串,然后调用 parser fieldsByExtractingFrom(_:) 方法,来获取全部的元数据字段。
  5. 将解析的结果赋值到 HardwarePost 的property中。
  6. 准备消息内容:
    1. 使用 scanLocation 获取 scanner 当前的读取指针,并将它转化为 String.CharacterView.Index ,这样你就可以通过range来替代 string
    2. scanner 剩余还未读取的字符串赋给新的 message 变量。
    3. 由于 message 的值仍然包含 \n\n ,也就是 scanner 从之前的读取停止的地方,你需要trim它,并将新的值给回到 HardwarePost 实例的 message property中。
  7. 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 需要特殊的处理,因为它们可以为空。这里是会发生的事:

  1. 如果 costs 数组为空,就设置 costLabel 的string值为 NO ;否则,就用“;”这个操作符连接cost的值。
  2. 类似的,当 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()
}

上述代码:

  1. 储存 HardwarePost 实例。
  2. 获取指向app主Bundle的引用。
  3. 检索在 comp.sys.mac.hardware 目录中的示例文件的url。
  4. 通过使用 Data 的可是白初始化器和 flatMap(_:) 读取文件内容,惰性获取 Data 实例的数组。使用 flatMap(_:) 是要获取元素不为 nil 的子数组。
  5. 最后,将 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

运行你的项目。

starter-final

所有被解析的字段现在都已经很好地展示在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 和其它解析理论的更多信息,请访问下列资源: