• 制作一个苦力

    制作一个苦力

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

    作者:@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