制作一个苦力

创造一个工具,为自己,也为他人。

作者:@nixzhu


现今,几乎所有的 API 都返回 JSON,但 JSON 是一种文本数据,为了使访问更加安全和自然,传递更加方便,我们通常会将它转换为客户端模型,而不仅仅将其当作一个字典来使用。

通常,我们会有服务器端所提供的 API 的文档,里面会描述每个 API 可能返回的数据。据此,作为客户端开发者,根据这些信息,我们就能设计出适合客户端使用的模型,或者接口、协议等。

可是,如果 API 很多,那可能模型也会很多。假如我们用结构体来做模型,光是每个模型的属性(以及从字典到模型的转换代码)都够我们写上一段时间,而且这个过程并不有趣。

有一些框架可以帮助我们做“从字典到模型的转换”这一步,但我们仍然要先定义好结构体(或者类)。

如果一件事情对人类而言枯燥无趣,通常计算机就会很喜欢。如果我们能让计算机帮我们从 JSON 直接生成模型,然后我们再来对模型做一些修改和调整,那我们应该就像一个人了。

开发者当然是人,而且是刚好能够用计算机制造工具的人。

JSON 里有些什么信息呢?足够帮助我们生成模型吗?下面来看一个简单的例子。假如有如下 JSON:

  1. {
  2. "name": "NIX",
  3. "age": 18,
  4. "skills": [
  5. "Swift on iOS",
  6. "C on Linux"
  7. ],
  8. "motto": "Love you love."
  9. }

而我们期望得到如下模型:

  1. struct User {
  2. let name: String
  3. let age: Int
  4. let skills: [String]
  5. let motto: String
  6. }

通过观察可知,JSON 就像一个字典,有 key 和 value,如 name 为 key,其值 "NIX" 是一个字符串。对应到模型里即属性 name,类型为 String。其它依次类推即可。其中 skills 比较特殊,是一个数组,而且其元素是字符串,所以对应到模型属性 skills 的类型为 [String]。这个 JSON 比较简单,在更复杂的 JSON 里,有可能 key 对应的 value 也是一个字典,数组里也很可能不是基本类型,也是一个个字典。还有 key 可能没有 value,而对应 null

除了模型结构体的名字 User 外,其它信息都应该能从 JSON 中推断出来。也就是说,我们要写一个解析器,它能将 JSON 里的信息提取出来,用于生成我们需要的结构体。

那解析器怎么写?不要慌,我们先看看 JSON 的定义:http://www.json.org/json-zh.html,这份说明很短,应该不难看懂。

我再节录一点如下:

JSON建构于两种结构:

  • “名称/值”对的集合(A collection of name/value pairs)。不同的语言中,它被理解为对象(object),纪录(record),结构(struct),字典(dictionary),哈希表(hash table),有键列表(keyed list),或者关联数组 (associative array)。
  • 值的有序列表(An ordered list of values)。在大部分语言中,它被理解为数组(array)。

其中:

对象是一个无序的“‘名称/值’对”集合。一个对象以“{”(左括号)开始,“}”(右括号)结束。每个“名称”后跟一个“:”(冒号);“‘名称/值’ 对”之间使用“,”(逗号)分隔。
数组是值(value)的有序集合。一个数组以“[”(左中括号)开始,“]”(右中括号)结束。值之间使用“,”(逗号)分隔。

后面还定义了值(value)的具体类型,如字符串、数组、布尔值、空等。而且要注意,value 还可以是对象或数组,也就是说,JSON 是一种可递归的数据结构,因此它可以表征很复杂的数据。

总结一下,JSON 里包含的基本单位有这么几种:

  1. 对象开始符 {
  2. 对象结束符 }
  3. 数组开始符 [
  4. 数组结束符 ]
  5. 键值分隔符 :
  6. 键值对分隔符 ,
  7. 布尔值,真 true
  8. 布尔值,假 false
  9. 数字 42-0.99
  10. 字符串 "name""NIX"
  11. null

不要觉得复杂,因为并没有多少种。注意其中的“字符串”既可以用来表示 key,也可以作为 value 的一种。

很明显,有的基本单位就是一个字符而已,但有的不是,比如布尔值、数字、字符串等。这是一种重要的洞见,这说明我们不该将 JSON 当做一个字符串来看待,而应该将其当做一种“基本单位串”。

这里的“基本单位”,在计算机科学里,被称为“Token”,也就是说,JSON 是由一个个 Token 串联起来的。当我们能用 Token 串来看待 JSON 时,我们思考解析的过程会更清晰,不用再纠结于字符。

再看一个更简单的 JSON:

  1. {
  2. "name": "NIX",
  3. "age": 18
  4. }

在计算机“看来”是这样:{\n\t"name": "NIX",\n\t"age": 18\n},一个字符串,包含换行符\n、制表符\t和空格`(注意这里为了表示方便,并未转义)。 如果我们去除这些空白符,就有:{“name”:”NIX”,”age”:18}`,看起来好多了。

以我们对 JSON 的理解,我们再对其作分割,就有:{"name":"NIX","age":18},共9个独立的部分。
很明显我们的大脑知道如何“正确”分割,这里的正确指的是符合 JSON 的定义。比如,当我们看到{时就知道这个 JSON 是一个字典,看到"name"及其后的:时,我们就知道 name 是一个 key,再后面的 "NIX" 就是 value 了。看到,时就知道这个键值对结束(也预示下一个键值对要开始)。当我们看到18时,我们除了知道它时一个 value 外,还知道它是一个数字,而不是字符串,因为字符串都有双引号包围。

这些独立的部分不应该再被分割,不然其意义就不明确了,这种不能被分割的部分就是 Token。

Swift 的 enum 特别适合用来表示不同的 Token,于是有:

  1. enum Token {
  2. case BeginObject(Swift.String) // {
  3. case EndObject(Swift.String) // }
  4. case BeginArray(Swift.String) // [
  5. case EndArray(Swift.String) // ]
  6. case Colon(Swift.String) // :
  7. case Comma(Swift.String) // ,
  8. case Bool(Swift.Bool) // true or false
  9. enum NumberType {
  10. case Int(Swift.Int)
  11. case Double(Swift.Double)
  12. }
  13. case Number(NumberType) // 42, -0.99
  14. case String(Swift.String) // "name", "NIX", ...
  15. case Null
  16. }

作为一种合理的简化,Number 只考虑整型和简单的浮点型。

那么上面的9个独立部分就可以表示为:.BeginObject("{").String("name").Colon(":").String("NIX").Comma(",").String("age").Colon(":").Number(.Int(18)).EndObject("}"),也就是一个 Token 串了。

那么,我们的第一步就将 JSON 字符串转换为 Token 串,为后面的解析(所谓解析,是将 Token 串转化为一个中间数据结构,这个结构里有我们最后所要生成的模型所需要的所有信息)做准备。

通常,在各种介绍“编译原理”的书籍中,会把这个步骤成为“词法分析”。又通常,会进一步介绍“正则表达式”和“状态机”,以便用它们写出做词法分析的工具。

不过我们还不需要去学它们。对于 JSON 这种比较简单的数据表示,我们可以利用 NSScanner 来帮我们生成 Token 串。NSScanner 的文档在此,简单来说,它是一个根据一些预定义的模式,从一个字符串中寻找匹配模式的字符串,并在匹配后移动其内部的指针,以便继续扫描,直至结束。在任意一个模式匹配后,我们就可以利用匹配到的信息来生成 Token。

其用法如下:

  1. let scanner = NSScanner(string: "{\n\t\"name\": \"NIX\",\n\t\"age\": 18\n}")
  2. func scanBeginObject() -> Token? {
  3. if scanner.scanString("{", intoString: nil) {
  4. return .BeginObject("{")
  5. }
  6. return nil
  7. }

其中scanBeginObject利用scanner扫描{,若能找到,就返回一个 BeginObject Token。类似这样,我们能写出
scanEndObjectscanBeginArrayscanEndArrayscanColonscanCommascanBoolscanNumberscanString以及scanNull

然后,我们可以利用一个 while 循环,把 JSON 字符串转换为 Token 串:

  1. func generateTokens() -> [Token] {
  2. // ...
  3. var tokens = [Token]()
  4. while !scanner.atEnd {
  5. let previousScanLocation = scanner.scanLocation
  6. if let token = scanBeginObject() {
  7. tokens.append(token)
  8. }
  9. if let token = scanEndObject() {
  10. tokens.append(token)
  11. }
  12. if let token = scanBeginArray() {
  13. tokens.append(token)
  14. }
  15. if let token = scanEndArray() {
  16. tokens.append(token)
  17. }
  18. if let token = scanColon() {
  19. tokens.append(token)
  20. }
  21. if let token = scanComma() {
  22. tokens.append(token)
  23. }
  24. if let token = scanBool() {
  25. tokens.append(token)
  26. }
  27. if let token = scanNumber() {
  28. tokens.append(token)
  29. }
  30. if let token = scanString() {
  31. tokens.append(token)
  32. }
  33. if let token = scanNull() {
  34. tokens.append(token)
  35. }
  36. let currentScanLocation = scanner.scanLocation
  37. guard currentScanLocation > previousScanLocation else {
  38. print("Not found valid token")
  39. break
  40. }
  41. }
  42. return tokens
  43. }

上面的函数依然只是看着比较长而已,实质非常简单。注意我们在一次循环里尽可能寻找合法的 Token,若最后 currentScanLocation 没有大于 previousScanLocation,那说明当前扫描没有找到合法的 Token,也就是说 JSON 字符串有语法问题。

经过上面的步骤,我们应该已得到了一个 Token 数组,接下来就是解析了。不过我们首先要明确解析的目的,我们要生成一个中间结构来表示 JSON 的结构,根据前面提及的 JSON 定义,我们也不难写出如下 enum:

  1. enum Value {
  2. case Bool(Swift.Bool)
  3. enum NumberType {
  4. case Int(Swift.Int)
  5. case Double(Swift.Double)
  6. }
  7. case Number(NumberType)
  8. case String(Swift.String)
  9. case Null
  10. indirect case Dictionary([Swift.String: Value])
  11. indirect case Array(name: Swift.String?, values: [Value])
  12. }

我们将一个 JSON 看作一个 Value,而 Value 本身可以是布尔值、数字、字符串、null 或者递归结构(String: Value 字典,或者 Value 数组),这其实是一种上下文无关文法的表示。我不打算在这里解释上下文无关文法的定义,但简单来说,当我们说一个 Value 是什么的时候,我们知道它可能表示一个布尔值、数字、……、或者与 Value 有关的结构(字典或数组),Value 本身可以作为构建 Value 的基石。

有了 Value 的定义,那我们的解析函数可如下定义:

  1. func parse() -> Value? {
  2. let tokens = generateTokens()
  3. guard !tokens.isEmpty else {
  4. print("No tokens")
  5. return nil
  6. }
  7. // ...
  8. }

哈哈,真实的parse()当然不会这么短,不过我们知道它应该返回一个 Value(或 nil,表示解析失败)。

有了 tokens,我们再定义一个 var next = 0,表示我们当前“查看”到哪一个 Token 了,然后我们在parse()内部定义一个parseValue(),并在最后调用它,如下:

  1. func parse() -> Value? {
  2. let tokens = generateTokens()
  3. guard !tokens.isEmpty else {
  4. print("No tokens")
  5. return nil
  6. }
  7. var next = 0
  8. func parseValue() -> Value? {
  9. guard let token = tokens[coolie_safe: next] else {
  10. print("No token for parseValue")
  11. return nil
  12. }
  13. switch token {
  14. case .BeginArray:
  15. var arrayName: String?
  16. let nameIndex = next - 2
  17. if nameIndex >= 0 {
  18. if let nameToken = tokens[coolie_safe: nameIndex] {
  19. if case .String(let name) = nameToken {
  20. arrayName = name.capitalizedString
  21. }
  22. }
  23. }
  24. next += 1
  25. return parseArray(name: arrayName)
  26. case .BeginObject:
  27. next += 1
  28. return parseObject()
  29. case .Bool:
  30. return parseBool()
  31. case .Number:
  32. return parseNumber()
  33. case .String:
  34. return parseString()
  35. case .Null:
  36. return parseNull()
  37. default:
  38. return nil
  39. }
  40. }
  41. // ...
  42. return parseValue()
  43. }

首先,Don’t Panic! 其实上面的parseValue()也并不复杂,不过是 case 较多(这由 Token 的种类决定)而已。它先用 next 取到当前的 Token,之后就 switch token 来具体处理。例如对于最复杂的.BeginArray,它利用 next 回退了两个 Token,以拿到这个数组的名字(在这里,我们其实做了一种假设,即 JSON 的“基底”是一个字典,而数组只会出现在字典内部,因此数组一定有一个名字,这个名字对于我们后面的代码生成来说是必要的,而且这种假设也很合理,因为我们通常都会用一个 JSON 字典来表示一个模型),之后增加 next 跳过这个表示中括号的 Token,再调用了parseArray(我们先不管它是怎么实现的,实际上,在编写解析器的过程中,这种“大局观”很重要,有时候必须从全局看问题)。对于.BeginObject,它增加 next 以跳过这个表示大括号的 Token,然后调用parseObject,其它类似(注意我们并没有 switch 所有的 case,这也是基于对 JSON 的理解)。

很明显,我们还会在上面的注释处继续添加函数,其中最复杂的就是parseArrayparseObject,我再稍微描述一下它们:

  1. func parseArray(name name: String? = nil) -> Value? {
  2. guard let token = tokens[coolie_safe: next] else {
  3. print("No token for parseArray")
  4. return nil
  5. }
  6. var array = [Value]()
  7. if case .EndArray = token {
  8. next += 1
  9. return .Array(name: name, values: array)
  10. } else {
  11. while true {
  12. guard let value = parseValue() else {
  13. break
  14. }
  15. array.append(value)
  16. if let token = tokens[coolie_safe: next] {
  17. if case .EndArray = token {
  18. next += 1
  19. return .Array(name: name, values: array)
  20. } else {
  21. guard let _ = parseComma() else {
  22. print("Expect comma")
  23. break
  24. }
  25. guard let nextToken = tokens[coolie_safe: next] where nextToken.isNotEndArray else {
  26. print("Invalid JSON, comma at end of array")
  27. break
  28. }
  29. }
  30. }
  31. }
  32. return nil
  33. }
  34. }

我们先准备了一个var array = [Value]()用来装解析出的 values,然后判断 next 表示的 Token,如果是.EndArray(右中括号),表示这是一个空的数组,因此立即返回,不然呢,就进入一个 while 循环。在 while 循环中,我们实际上身处第一个 Value,请回忆 Value 里 Array 的定义,Array 就是 Value 的数组(一种递归定义),因此,我们直接调用parseValue即可,如果 JSON 没有语法问题,那么我们就能得到表示数组中第一个元素的 value,我们把这个 value 添加到 array 里。然后,我们取下一个 Token,经过前面parseValue的解析,这一个 token 有这几种可能:右中括号(表示数组结束)、逗号(表示数组里还有更多元素),终究,我们的循环可以处理这些情况,并在合适的时候用 return 跳出循环。

  1. func parseObject() -> Value? {
  2. guard let token = tokens[coolie_safe: next] else {
  3. print("No token for parseObject")
  4. return nil
  5. }
  6. var dictionary = [String: Value]()
  7. if case .EndObject = token {
  8. next += 1
  9. return .Dictionary(dictionary)
  10. } else {
  11. while true {
  12. guard let key = parseString(), _ = parseColon(), value = parseValue() else {
  13. print("Expect key : value")
  14. break
  15. }
  16. if case .String(let key) = key {
  17. dictionary[key] = value
  18. }
  19. if let token = tokens[coolie_safe: next] {
  20. if case .EndObject = token {
  21. next += 1
  22. return .Dictionary(dictionary)
  23. } else {
  24. guard let _ = parseComma() else {
  25. print("Expect comma")
  26. break
  27. }
  28. guard let nextToken = tokens[coolie_safe: next] where nextToken.isNotEndObject else {
  29. print("Invalid JSON, comma at end of object")
  30. break
  31. }
  32. }
  33. }
  34. }
  35. }
  36. return nil
  37. }

看完上面对parseArray的分析,我想,parseObject看起来也不会太难。只不过这次我们先定义一个var dictionary = [String: Value]()来装结果,然后判断下一个 Token 是否表示对象结束(也即是右大括号),不然又进入 while 循环来继续解析,只需注意guard let key = parseString(), _ = parseColon(), value = parseValue(),我们在其中取到了 key 和 value(中间的逗号被跳过了),确保 key 是一个 String,然后就可以将 value 装入我们早就准备好的 dictionary 里了。然后当然是继续判断,下一个 Token 要么是对象结束,要么是一个逗号。同样,不符合我们预期的 Token 当然表示 JSON 不合法。

其它诸如parseColonparseComma等都比较简单,我就不贴代码分析了,感兴趣的读者可直接去阅读代码

不出意外,我们得到了一个 Value,然后我们只需要根据我们对模型的需求写出一个生成函数,利用它生成模型和模型的构造方法,我们就得到一个苦力了。
目前我写了两个生成函数:generateStructgenerateClass,分别用于生成 Swift struct 或 class(比较琐碎,也不贴代码分析了)。而且因为 Value 是递归的,因此我们生成的模型也是递归的。如果你所用的编程语言不支持递归定义,那可能要稍微麻烦一点。另外,为了方便开发者使用,我还写了一个Arguments模块,用于解析命令行参数,感兴趣的读者可直接到 Coolie 的 GitHub Repo 处研究。

我想读者大概能够看出,其实 Coolie 是一个迷你的编译器,它有词法分析、语法分析、中间表示、代码生成,因此它能将一个 JSON 文件“编译”为一个 Swift 文件,而且因为其内部有一个中间表示(可看成 AST),所以根据不同的用途,它也可以生成其它语言的模型代码。

苦力是我在写 Yep 的过程中被写模型代码的繁琐逼出来的(我也看了不少编译原理相关的资料),可惜做得太晚,自己倒没怎么用上,不过我希望其他开发者不用再这样受苦。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog