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 和其它解析理论的更多信息,请访问下列资源: