WebAPI 与 Codable

在编程中,类型准确是一件必要的事情,一方面保证了大家的理解,另一方面能方便地使用 API。


前言

很多人习惯用 String 代替 URL,但是 URL 并不等同于 String。使用 String 作为类型,不能快速获取 scheme / host / path 等属性,因为这些属性并不是 String 的必要定义,而是 URL 的。

又如,使用数字作为分类,第一反应就是「这是什么东西」,写代码时还得去翻文档注释,远不如枚举(常量)来得直观。

后端由于历史遗留问题,使用了数字作为分类,或者新接口已经使用字符串作为分类,客户端都需要定义强类型的 model,例如 Enum with RawValue,既表意准确,也便于比较。

Codable

Swift 4.0 中增加的 Codable 协议,大大减少了 Model 层的代码量。依赖于编译器的自动合成,如果 model 的成员变量全部实现了 Codable 协议,那么 model 本身声明即实现 Codable 协议,不需要重写 Codable 所需要的方法。

尽管如此方便,WebAPI 的不规范仍然会导致客户端出现这样那样的问题:

  1. Optional,变量声明非 Optional, WebAPI 返回 null 即 decode 失败。
  2. URL,WebAPI 返回空字符串即 decode 失败。
  3. Enum with RawValue,WebAPI 返回新值(客户端版本未定义值)即 decode 失败。

还有 JSONDecoder 本身设计导致的问题:

  1. 不重写 init(from:) 的情况下,遇到 error 直接抛出到顶层,无法在声明为 Optional 的变量中处理成 nil。

以上种种,不得已对 Model 层作出限制:

  1. 所有成员变量使用 Optional。
  2. 重写 URL 的 decodeIfPresent 实现。
  3. 重写 RawRepresentable 的 decodeIfPresent 实现。

URL

1
2
3
4
5
6
7
8
9
/// model

struct Model: Codable {
var out: Out?
}

struct Out: Codable {
var url: URL?
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/// json

let url_json_1 =
"""
{
"out": {
"url": null
}
}
"""

let url_json_2 =
"""
{
"out": {
"url": ""
}
}
"""

let url_json_3 =
"""
{
"out": {
"url": 1
}
}
"""

let url_json_4 =
"""
{
"out": {
"url": "https://www.apple.com/"
}
}
"""

normal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// normal

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(url: nil))))
let model_1 = try? decoder.decode(Model.self, from: url_json_1.data(using: .utf8)!)

// nil
// Invalid URL string.
let model_2 = try? decoder.decode(Model.self, from: url_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: url_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: Optional(https://www.apple.com/)))))
let model_4 = try? decoder.decode(Model.self, from: url_json_4.data(using: .utf8)!)

JSONDecoder 在遇到抛出错误的子元素时,解析中断,整个 model 被解析为 nil,而不理会该元素是否被声明为 optional。

try? decode

1
2
3
4
5
6
7
8
9
10
11
12
extension KeyedDecodingContainer {
func decodeIfPresent(_ type: URL.Type, forKey key: Key) throws -> URL? {
guard
let string = try? decode(String.self, forKey: key),
let url = URL(string: string)
else {
return nil
}

return url
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// try? decode

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(url: nil))))
let model_1 = try? decoder.decode(Model.self, from: url_json_1.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: nil))))
let model_2 = try? decoder.decode(Model.self, from: url_json_2.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: nil))))
let model_3 = try? decoder.decode(Model.self, from: url_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: Optional(https://www.apple.com/)))))
let model_4 = try? decoder.decode(Model.self, from: url_json_4.data(using: .utf8)!)

自定义 URL 的 decodeIfPresent 方法后,值为空字符串时解析为 nil,但值为数字时应该抛出错误。

try decodeIfPresent

1
2
3
4
5
6
7
8
9
10
11
12
extension KeyedDecodingContainer {
func decodeIfPresent(_ type: URL.Type, forKey key: Key) throws -> URL? {
guard
let string = try decodeIfPresent(String.self, forKey: key),
let url = URL(string: string)
else {
return nil
}

return url
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// try decodeIfPresent

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(url: nil))))
let model_1 = try? decoder.decode(Model.self, from: url_json_1.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: nil))))
let model_2 = try? decoder.decode(Model.self, from: url_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: url_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(url: Optional(https://www.apple.com/)))))
let model_4 = try? decoder.decode(Model.self, from: url_json_4.data(using: .utf8)!)

Enum

1
2
3
4
5
6
7
8
9
10
11
12
13
/// model

struct Model: Codable {
var out: Out?
}

struct Out: Codable {
var `in`: In?
}

enum In: String, Codable {
case apple
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// json

let enum_json_1 =
"""
{
"out": {
"in": null
}
}
"""

let enum_json_2 =
"""
{
"out": {
"in": ""
}
}
"""

let enum_json_3 =
"""
{
"out": {
"in": 1
}
}
"""

let enum_json_4 =
"""
{
"out": {
"in": "apple"
}
}
"""

let enum_json_5 =
"""
{
"out": {
"in": "boy"
}
}
"""

normal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// normal

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(in: nil))))
let model_1 = try? decoder.decode(Model.self, from: enum_json_1.data(using: .utf8)!)

// nil
// Cannot initialize In from invalid String value .
let model_2 = try? decoder.decode(Model.self, from: enum_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: enum_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.apple)))))
let model_4 = try? decoder.decode(Model.self, from: enum_json_4.data(using: .utf8)!)

// nil
// Cannot initialize In from invalid String value boy.
let model_5 = try? decoder.decode(Model.self, from: enum_json_5.data(using: .utf8)!)

当服务端返回客户端未定义值时,解析中断,整个 model 被解析为 nil。

case unknown

1
2
3
4
5
6
7
8
9
10
enum In: String, Codable {
case apple
case unknown

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = Self(rawValue: rawValue) ?? .unknown
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// case unknown

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(in: nil))))
let model_1 = try? decoder.decode(Model.self, from: enum_json_1.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.unknown)))))
let model_2 = try? decoder.decode(Model.self, from: enum_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: enum_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.apple)))))
let model_4 = try? decoder.decode(Model.self, from: enum_json_4.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.unknown)))))
let model_5 = try? decoder.decode(Model.self, from: enum_json_5.data(using: .utf8)!)

给枚举类型增加 unknown 分支,并自定义 init(from:) 初始化方法,可以把未定义值回退到 unknown。但每一个枚举类型都需要增加 unknown 分支和自定义初始化方法,过于繁琐。

try decode

1
2
3
4
5
extension KeyedDecodingContainer {
func decodeIfPresent<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? where T: RawRepresentable {
return try decode(T.self, forKey: key)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// try decode

let decoder = JSONDecoder()

// nil
// Expected String but found null value instead.
let model_1 = try? decoder.decode(Model.self, from: enum_json_1.data(using: .utf8)!)

// nil
// Cannot initialize In from invalid String value .
let model_2 = try? decoder.decode(Model.self, from: enum_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: enum_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.apple)))))
let model_4 = try? decoder.decode(Model.self, from: enum_json_4.data(using: .utf8)!)

// nil
// Cannot initialize In from invalid String value boy.
let model_5 = try? decoder.decode(Model.self, from: enum_json_5.data(using: .utf8)!)

带原始值的枚举类型实现了 RawRepresentable 协议,因此可以自定义 RawRepresentable 的 decodeIfPresent 方法。当使用 try decode 时,null 为值时直接抛出错误。空字符串和未定义值也会抛出错误。

try? decode

1
2
3
4
5
extension KeyedDecodingContainer {
func decodeIfPresent<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? where T: RawRepresentable {
return try? decode(T.self, forKey: key)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// try? decode

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(in: nil))))
let model_1 = try? decoder.decode(Model.self, from: enum_json_1.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: nil))))
let model_2 = try? decoder.decode(Model.self, from: enum_json_2.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: nil))))
let model_3 = try? decoder.decode(Model.self, from: enum_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.apple)))))
let model_4 = try? decoder.decode(Model.self, from: enum_json_4.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: nil))))
let model_5 = try? decoder.decode(Model.self, from: enum_json_5.data(using: .utf8)!)

当使用 try? decode 时,会吞掉所有抛出的错误,而非法类型会被解析为 nil。

try decodeIfPresent

1
2
3
4
5
6
7
8
9
10
11
12
extension KeyedDecodingContainer {
func decodeIfPresent<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? where T: RawRepresentable, T.RawValue: Decodable {
guard
let rawValue = try decodeIfPresent(type.RawValue.self, forKey: key),
let value = T(rawValue: rawValue)
else {
return nil
}

return value
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// try decodeIfPresent

let decoder = JSONDecoder()

// Optional(Model(out: Optional(Out(in: nil))))
let model_1 = try? decoder.decode(Model.self, from: enum_json_1.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: nil))))
let model_2 = try? decoder.decode(Model.self, from: enum_json_2.data(using: .utf8)!)

// nil
// Expected to decode String but found a number instead.
let model_3 = try? decoder.decode(Model.self, from: enum_json_3.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: Optional(In.apple)))))
let model_4 = try? decoder.decode(Model.self, from: enum_json_4.data(using: .utf8)!)

// Optional(Model(out: Optional(Out(in: nil))))
let model_5 = try? decoder.decode(Model.self, from: enum_json_5.data(using: .utf8)!)

总结

  1. 在 decode 中,RawValue 类型正确但值错误,个人倾向于回退到 nil,而 RawValue 类型错误,直接 throw。
  2. 以上方法没有解决类型为 [Element]? 的问题,同时 WebAPI 也可能返回 [null]

小尾巴

本以为 propertyWrapper 可以结合 Codable 玩些新花样,例如只改 CodingKey 或者 RawValue,不重写 init(from:),然而并不可以。

参考

  1. 或许你并不需要重写 init(from:) 方法 - kemchenj