作为程序员,我们经常致力于由多个部分组成的应用程序和系统,这些部分需要以某种方式连接起来 -而且以优雅、稳健和未来证明的方式来做到这一点往往说起来容易做起来难。
特别是在使用高度静态的语言(如Swift)时,有时,要弄清楚如何以一种既满足编译器又能生成易于处理的代码的方式对某些条件或数据片段建模是很棘手的。
本周,让我们来看看其中一种情况,它涉及到为同一数据模型的多个变体建模,并探索一些不同的技术和方法,使我们能够以仍然依赖Swift强调的类型安全的方式处理动态数据。
Mixed structures
举个例子,假设我们正在开发一个烹饪应用程序,它包括视频和书面食谱, 我们的内容是从一个返回JSON格式的web服务加载的,如下所示:
{
"items": [
{
"type": "video",
"title": "Making perfect toast",
"imageURL": "https://image-cdn.com/toast.png",
"url": "https://videoservice.com/toast.mp4",
"duration": "00:12:09",
"resolution": "720p"
},
{
"type": "recipe",
"title": "Tasty burritos",
"imageURL": "https://image-cdn.com/burritos.png",
"text": "Here's how to make the best burritos...",
"ingredients": [
"Tortillas",
"Salsa",
...
]
}
]
}
虽然上面构造JSON响应的方法非常普遍,但是创建与之匹配的Swift表示却非常具有挑战性。因为我们接收到一个包含食谱和视频的条目数组,所以我们需要以一种允许我们同时解码这两个变量的方式来编写我们的模型代码。
一种方法是创建一个ItemType enum,其中包含我们两个变量的大小写,以及一个统一的Item数据模型,该模型包含我们希望遇到的所有属性,以及一个我们可以将JSON解码为ItemCollection的包装器,:
enum ItemType: String, Decodable {
case video
case recipe
}
struct Item: Decodable {
let type: ItemType
var title: String
var imageURL: URL
var text: String?
var url: URL?
var duration: String?
var resolution: String?
var ingredients: [String]?
}
struct ItemCollection: Decodable {
var items: [Item]
}
之所以上面的type属性是常量,而其他所有Item属性仍然是变量,是因为这是我们在任何情况下都不希望修改的唯一数据-因为一个食谱不能转换成视频,反之亦然。对于其他属性,我们通过使它们成为变量来利用Swift的值语义。
虽然上面的方法让我们成功地解码我们的JSON,但它离理想还很远——因为我们被迫实现我们的大多数属性作为可选的,考虑到它们只适用于我们两种变体中的一种。这样做将反过来要求我们不断地unwrap那些可选的,即使在只处理单个变量的代码中,如VideoPlayer:
class VideoPlayer {
...
func playVideoItem(_ item: Item) {
// We can't establish a compile-time guarantee that the
// item passed to this method will, in fact, be a video.
guard let url = item.url else {
assertionFailure("Video item doesn't have a URL: \(item)")
return
}
startPlayback(from: url)
}
}
因此,让我们探索解决上述问题的几种方法,并看看每种方法可能给我们带来什么样的权衡。
Complete polymorphism
因为我们试图在一天结束时为一组多态数据建模(因为我们的模型可以采取多种形式),一种方法是使Swift表示的数据也具有多态性。
为此,我们可以创建一个项目协议,其中包含我们两个变体之间共享的所有属性, 还有两种不同的类型——一种用于视频,一种用于食谱——都符合新协议:
protocol Item: Decodable {
var type: ItemType { get }
var title: String { get }
var imageURL: URL { get }
}
struct Video: Item {
var type: ItemType { .video }
var title: String
var imageURL: URL
var url: URL
var duration: String
var resolution: String
}
struct Recipe: Item {
var type: ItemType { .recipe }
var title: String
var imageURL: URL
var text: String
var ingredients: [String]
}
由于我们的项现在由两种不同的类型表示,我们可能还希望修改ItemCollection包装器,为这两种类型中的每一种包含单独的数组-否则的话,我们必须不断地为视频或食谱输入道具值:
struct ItemCollection: Decodable {
var videos: [Video]
var recipes: [Recipe]
}
然而,虽然上面的模型结构在理论上看起来很棒,但在实践中它需要一些额外的工作,因为Swift代码不再匹配JSON响应的格式。然而,这并不是一个大问题,因为我们总是可以像在“Swift中定制可编码类型”中所做的那样——创建专门用于解码的类型,以及定制的可解码实现。
在这个例子中,让我们重用以前的Item和ItemCollection实现,同时重新命名它们以适应它们的新用途——像这样:
private extension ItemCollection {
struct Encoded: Decodable {
var items: [EncodedItem]
}
struct EncodedItem: Decodable {
let type: ItemType
var title: String
var imageURL: URL
var text: String?
var url: URL?
var duration: String?
var resolution: String?
var ingredients: [String]?
}
}
现在,我们几乎已经准备好编写自定义可解码实现了——但是,因为在这样做时,我们将需要展开大量的可选项 ,让我们先创建一个小的实用方法,让这个过程更简单:
extension ItemCollection {
struct MissingEncodedValue: Error {
var name: String
...
}
private func unwrap<T>(_ value: T?, name: String) throws -> T {
guard let value = value else {
throw MissingEncodedValue(name: name)
}
return value
}
}
如果上面的unwrap方法看起来很熟悉,可能是因为它与可选类型本身的扩展非常相似,这在以前的几篇文章中已经出现过。
有了上述部分,现在让我们编写实际的解码代码。我们首先解码一个新编码的包装器实例,然后将其条目转换为Video和Recipe值,如下所示:
extension ItemCollection {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let collection = try container.decode(Encoded.self)
for item in collection.items {
switch item.type {
case .video:
try videos.append(Video(
type: item.type,
title: item.title,
imageURL: item.imageURL,
url: unwrap(item.url, name: "url"),
duration: unwrap(item.duration, name: "duration"),
resolution: unwrap(item.resolution, name: "resolution")
))
case .recipe:
try recipes.append(Recipe(
type: item.type,
title: item.title,
imageURL: item.imageURL,
text: unwrap(item.text, name: "text"),
ingredients: unwrap(item.ingredients, name: "ingredients")
))
}
}
}
}
最后一个部分就绪后,我们现在就有了JSON数据的完全类型安全表示,所有这些都没有任何非可选的可选项。然而,上述方法不仅需要大量特定于解码的代码,我们现在也失去了对项目的总体顺序的跟踪(因为我们在解码时将它们分解为两个数组)。
当然,我们有不同的方法来解决这个问题,包括维护一个单独的[Item]数组,我们可以用它来排序,我们还将探索第三种方法,它可能成为前两种实现之间的一个巧妙的中间地带。
与其将我们的变量视为共享公共接口的两个独立实现,不如将它们实际视为同一模型的变量。 这看起来可能是一个微妙的变化,但它最终会对我们的最终模型结构产生相当大的影响。首先,让我们将前面的Item protocol重命名为ItemVariant,同时删除它的type属性:
protocol ItemVariant: Decodable {
var title: String { get }
var imageURL: URL { get }
}
然后,让我们将实际的项目类型建模为enum,两个变量各用一个case -每个包含该变体专用模型的实例作为关联值:
enum Item {
case video(Video)
case recipe(Recipe)
}
有了上述更改,我们现在可以极大地简化自定义可解码实现 - 这现在可以完全发生在我们的新项目类型本身,只需要检查每个JSON项目的类型值,以决定解码哪个底层类型:
extension Item: Decodable {
struct InvalidTypeError: Error {
var type: String
...
}
private enum CodingKeys: CodingKey {
case type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "video":
self = .video(try Video(from: decoder))
case "recipe":
self = .recipe(try Recipe(from: decoder))
default:
throw InvalidTypeError(type: type)
}
}
}
使用原始字符串表示每种类型的另一种方法是继续使用前面的ItemType enum。然而,考虑到我们现在已经引入了ItemVariant协议,保留enum可能会增加一些混乱, 与在上面的初始化式中内联定义字符串相比,并没有给我们带来任何好处。
由于我们的Item实现再次负责解码它自己的实例,现在我们可以将ItemCollection还原为一个简单的Item值数组的容器 -这也让它依赖解码的默认实现,就像之前一样:
struct ItemCollection: Decodable {
var items: [Item]
}
虽然这最后一次迭代的好处是让我们继续使用我们的专用模型,同时也保持我们的解码代码简单, 而且我们的全部商品订单都是完整的,但它的缺点是需要我们在使用前将商品拆开包装—例如使用switch语句,如下:
extension ItemCollection {
func allTitles() -> [String] {
items.map { item in
switch item {
case .video(let video):
return video.title
case .recipe(let recipe):
return recipe.title
}
}
}
}
而当我们需要访问特定于菜谱或视频的数据时,我们将不得不继续编写如上所述的代码(这可以说是一件好事,因为这“迫使”我们同时处理这两种可能的情况),有一种方法可以让我们直接访问在ItemVariant协议中定义的任何属性,那就是使用Swift的动态成员查找功能。
首先要做的是将@dynamicMemberLookup属性添加到我们的主项声明中:
@dynamicMemberLookup
enum Item {
case video(Video)
case recipe(Recipe)
}
难题的第二部分是实现一个下标来解析给定动态成员的值。虽然这样做最初需要这样一个下标来接受任何任意字符串作为输入,但由于Swift 5.1可以使用键路径来代替——这让我们可以以完全类型安全的方式添加对动态成员的支持,如下所示:
extension Item {
subscript<T>(dynamicMember keyPath: KeyPath<ItemVariant, T>) -> T {
switch self {
case .video(let video):
return video[keyPath: keyPath]
case .recipe(let recipe):
return recipe[keyPath: keyPath]
}
}
}
有了上面的内容,我们现在可以访问视频和食谱之间共享的任何属性(通过我们的ItemVariant协议),就像该属性是在我们的Item类型本身中定义的一样。 再加上键路径现在可以转换成函数(是的,我喜欢键路径),我们可以将allTitles方法从以前转换成现在这样:
extension ItemCollection {
func allTitles() -> [String] {
items.map(\.title)
}
}
很酷的!使用上面的设置,我们可以达到两全其美的效果,因为我们现在可以直接访问这两种变体所支持的所有属性-同时也能够使用我们专门的视频和配方模型,当我们想写代码,具体到任何这两个变种。
Conclusion
虽然在Swift中干净地表示动态或多态数据有时会很困难,但通常有一些方法可以做到这一点——尽管在每个给定的情况下找到正确的结构可能需要我们尝试几种不同的方法,就像我们在本文中所做的那样。
尽管这种“试错”可能需要一些额外的时间,但当涉及到模型代码时,经历这一过程通常是一项很好的投资,因为应用的数据模型往往是其整体代码基础的基础。
当然,最好的方法是让我们的序列化数据总是符合我们在Swift代码中所期望的格式,但这并不总是可能的——尤其是在开发一个兼容多个平台的产品时。