制作一个苦力
创造一个工具,为自己,也为他人。
作者:@nixzhu
现今,几乎所有的 API 都返回 JSON,但 JSON 是一种文本数据,为了使访问更加安全和自然,传递更加方便,我们通常会将它转换为客户端模型,而不仅仅将其当作一个字典来使用。
通常,我们会有服务器端所提供的 API 的文档,里面会描述每个 API 可能返回的数据。据此,作为客户端开发者,根据这些信息,我们就能设计出适合客户端使用的模型,或者接口、协议等。
可是,如果 API 很多,那可能模型也会很多。假如我们用结构体来做模型,光是每个模型的属性(以及从字典到模型的转换代码)都够我们写上一段时间,而且这个过程并不有趣。
有一些框架可以帮助我们做“从字典到模型的转换”这一步,但我们仍然要先定义好结构体(或者类)。
如果一件事情对人类而言枯燥无趣,通常计算机就会很喜欢。如果我们能让计算机帮我们从 JSON 直接生成模型,然后我们再来对模型做一些修改和调整,那我们应该就像一个人了。
开发者当然是人,而且是刚好能够用计算机制造工具的人。
JSON 里有些什么信息呢?足够帮助我们生成模型吗?下面来看一个简单的例子。假如有如下 JSON:
{
"name": "NIX",
"age": 18,
"skills": [
"Swift on iOS",
"C on Linux"
],
"motto": "Love you love."
}
而我们期望得到如下模型:
struct User {
let name: String
let age: Int
let skills: [String]
let motto: String
}
通过观察可知,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 里包含的基本单位有这么几种:
- 对象开始符
{
- 对象结束符
}
- 数组开始符
[
- 数组结束符
]
- 键值分隔符
:
- 键值对分隔符
,
- 布尔值,真
true
- 布尔值,假
false
- 数字
42
或-0.99
… - 字符串
"name"
或"NIX"
- 空
null
不要觉得复杂,因为并没有多少种。注意其中的“字符串”既可以用来表示 key,也可以作为 value 的一种。
很明显,有的基本单位就是一个字符而已,但有的不是,比如布尔值、数字、字符串等。这是一种重要的洞见,这说明我们不该将 JSON 当做一个字符串来看待,而应该将其当做一种“基本单位串”。
这里的“基本单位”,在计算机科学里,被称为“Token”,也就是说,JSON 是由一个个 Token 串联起来的。当我们能用 Token 串来看待 JSON 时,我们思考解析的过程会更清晰,不用再纠结于字符。
再看一个更简单的 JSON:
{
"name": "NIX",
"age": 18
}
在计算机“看来”是这样:{\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,于是有:
enum Token {
case BeginObject(Swift.String) // {
case EndObject(Swift.String) // }
case BeginArray(Swift.String) // [
case EndArray(Swift.String) // ]
case Colon(Swift.String) // :
case Comma(Swift.String) // ,
case Bool(Swift.Bool) // true or false
enum NumberType {
case Int(Swift.Int)
case Double(Swift.Double)
}
case Number(NumberType) // 42, -0.99
case String(Swift.String) // "name", "NIX", ...
case Null
}
作为一种合理的简化,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。
其用法如下:
let scanner = NSScanner(string: "{\n\t\"name\": \"NIX\",\n\t\"age\": 18\n}")
func scanBeginObject() -> Token? {
if scanner.scanString("{", intoString: nil) {
return .BeginObject("{")
}
return nil
}
其中scanBeginObject
利用scanner
扫描{
,若能找到,就返回一个 BeginObject Token。类似这样,我们能写出
scanEndObject
、scanBeginArray
、scanEndArray
、scanColon
、scanComma
、scanBool
、scanNumber
、scanString
以及scanNull
。
然后,我们可以利用一个 while 循环,把 JSON 字符串转换为 Token 串:
func generateTokens() -> [Token] {
// ...
var tokens = [Token]()
while !scanner.atEnd {
let previousScanLocation = scanner.scanLocation
if let token = scanBeginObject() {
tokens.append(token)
}
if let token = scanEndObject() {
tokens.append(token)
}
if let token = scanBeginArray() {
tokens.append(token)
}
if let token = scanEndArray() {
tokens.append(token)
}
if let token = scanColon() {
tokens.append(token)
}
if let token = scanComma() {
tokens.append(token)
}
if let token = scanBool() {
tokens.append(token)
}
if let token = scanNumber() {
tokens.append(token)
}
if let token = scanString() {
tokens.append(token)
}
if let token = scanNull() {
tokens.append(token)
}
let currentScanLocation = scanner.scanLocation
guard currentScanLocation > previousScanLocation else {
print("Not found valid token")
break
}
}
return tokens
}
上面的函数依然只是看着比较长而已,实质非常简单。注意我们在一次循环里尽可能寻找合法的 Token,若最后 currentScanLocation
没有大于 previousScanLocation
,那说明当前扫描没有找到合法的 Token,也就是说 JSON 字符串有语法问题。
经过上面的步骤,我们应该已得到了一个 Token 数组,接下来就是解析了。不过我们首先要明确解析的目的,我们要生成一个中间结构来表示 JSON 的结构,根据前面提及的 JSON 定义,我们也不难写出如下 enum:
enum Value {
case Bool(Swift.Bool)
enum NumberType {
case Int(Swift.Int)
case Double(Swift.Double)
}
case Number(NumberType)
case String(Swift.String)
case Null
indirect case Dictionary([Swift.String: Value])
indirect case Array(name: Swift.String?, values: [Value])
}
我们将一个 JSON 看作一个 Value,而 Value 本身可以是布尔值、数字、字符串、null 或者递归结构(String: Value 字典,或者 Value 数组),这其实是一种上下文无关文法的表示。我不打算在这里解释上下文无关文法的定义,但简单来说,当我们说一个 Value 是什么的时候,我们知道它可能表示一个布尔值、数字、……、或者与 Value 有关的结构(字典或数组),Value 本身可以作为构建 Value 的基石。
有了 Value 的定义,那我们的解析函数可如下定义:
func parse() -> Value? {
let tokens = generateTokens()
guard !tokens.isEmpty else {
print("No tokens")
return nil
}
// ...
}
哈哈,真实的parse()
当然不会这么短,不过我们知道它应该返回一个 Value(或 nil,表示解析失败)。
有了 tokens,我们再定义一个 var next = 0
,表示我们当前“查看”到哪一个 Token 了,然后我们在parse()
内部定义一个parseValue()
,并在最后调用它,如下:
func parse() -> Value? {
let tokens = generateTokens()
guard !tokens.isEmpty else {
print("No tokens")
return nil
}
var next = 0
func parseValue() -> Value? {
guard let token = tokens[coolie_safe: next] else {
print("No token for parseValue")
return nil
}
switch token {
case .BeginArray:
var arrayName: String?
let nameIndex = next - 2
if nameIndex >= 0 {
if let nameToken = tokens[coolie_safe: nameIndex] {
if case .String(let name) = nameToken {
arrayName = name.capitalizedString
}
}
}
next += 1
return parseArray(name: arrayName)
case .BeginObject:
next += 1
return parseObject()
case .Bool:
return parseBool()
case .Number:
return parseNumber()
case .String:
return parseString()
case .Null:
return parseNull()
default:
return nil
}
}
// ...
return parseValue()
}
首先,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 的理解)。
很明显,我们还会在上面的注释处继续添加函数,其中最复杂的就是parseArray
和parseObject
,我再稍微描述一下它们:
func parseArray(name name: String? = nil) -> Value? {
guard let token = tokens[coolie_safe: next] else {
print("No token for parseArray")
return nil
}
var array = [Value]()
if case .EndArray = token {
next += 1
return .Array(name: name, values: array)
} else {
while true {
guard let value = parseValue() else {
break
}
array.append(value)
if let token = tokens[coolie_safe: next] {
if case .EndArray = token {
next += 1
return .Array(name: name, values: array)
} else {
guard let _ = parseComma() else {
print("Expect comma")
break
}
guard let nextToken = tokens[coolie_safe: next] where nextToken.isNotEndArray else {
print("Invalid JSON, comma at end of array")
break
}
}
}
}
return nil
}
}
我们先准备了一个var array = [Value]()
用来装解析出的 values,然后判断 next 表示的 Token,如果是.EndArray
(右中括号),表示这是一个空的数组,因此立即返回,不然呢,就进入一个 while 循环。在 while 循环中,我们实际上身处第一个 Value,请回忆 Value 里 Array 的定义,Array 就是 Value 的数组(一种递归定义),因此,我们直接调用parseValue
即可,如果 JSON 没有语法问题,那么我们就能得到表示数组中第一个元素的 value,我们把这个 value 添加到 array 里。然后,我们取下一个 Token,经过前面parseValue
的解析,这一个 token 有这几种可能:右中括号(表示数组结束)、逗号(表示数组里还有更多元素),终究,我们的循环可以处理这些情况,并在合适的时候用 return 跳出循环。
func parseObject() -> Value? {
guard let token = tokens[coolie_safe: next] else {
print("No token for parseObject")
return nil
}
var dictionary = [String: Value]()
if case .EndObject = token {
next += 1
return .Dictionary(dictionary)
} else {
while true {
guard let key = parseString(), _ = parseColon(), value = parseValue() else {
print("Expect key : value")
break
}
if case .String(let key) = key {
dictionary[key] = value
}
if let token = tokens[coolie_safe: next] {
if case .EndObject = token {
next += 1
return .Dictionary(dictionary)
} else {
guard let _ = parseComma() else {
print("Expect comma")
break
}
guard let nextToken = tokens[coolie_safe: next] where nextToken.isNotEndObject else {
print("Invalid JSON, comma at end of object")
break
}
}
}
}
}
return nil
}
看完上面对parseArray
的分析,我想,parseObject
看起来也不会太难。只不过这次我们先定义一个var dictionary = [String: Value]()
来装结果,然后判断下一个 Token 是否表示对象结束(也即是右大括号),不然又进入 while 循环来继续解析,只需注意guard let key = parseString(), _ = parseColon(), value = parseValue()
,我们在其中取到了 key 和 value(中间的逗号被跳过了),确保 key 是一个 String,然后就可以将 value 装入我们早就准备好的 dictionary 里了。然后当然是继续判断,下一个 Token 要么是对象结束,要么是一个逗号。同样,不符合我们预期的 Token 当然表示 JSON 不合法。
其它诸如parseColon
、parseComma
等都比较简单,我就不贴代码分析了,感兴趣的读者可直接去阅读代码。
不出意外,我们得到了一个 Value,然后我们只需要根据我们对模型的需求写出一个生成函数,利用它生成模型和模型的构造方法,我们就得到一个苦力了。
目前我写了两个生成函数:generateStruct
和generateClass
,分别用于生成 Swift struct 或 class(比较琐碎,也不贴代码分析了)。而且因为 Value 是递归的,因此我们生成的模型也是递归的。如果你所用的编程语言不支持递归定义,那可能要稍微麻烦一点。另外,为了方便开发者使用,我还写了一个Arguments
模块,用于解析命令行参数,感兴趣的读者可直接到 Coolie 的 GitHub Repo 处研究。
我想读者大概能够看出,其实 Coolie 是一个迷你的编译器,它有词法分析、语法分析、中间表示、代码生成,因此它能将一个 JSON 文件“编译”为一个 Swift 文件,而且因为其内部有一个中间表示(可看成 AST),所以根据不同的用途,它也可以生成其它语言的模型代码。
苦力是我在写 Yep 的过程中被写模型代码的繁琐逼出来的(我也看了不少编译原理相关的资料),可惜做得太晚,自己倒没怎么用上,不过我希望其他开发者不用再这样受苦。
欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog