使用OpenAPI在Swift中构建健壮的网络层

本文详细介绍了如何利用OpenAPI规范在Swift中自动生成网络层代码,减少手动编写重复性代码的工作量,提升开发效率和代码质量,包括创建规范文件、集成生成器、编写包装器及实际应用示例。

如何用OpenAPI在Swift中构建健壮的网络层

我们要解决的问题是什么?

对于许多应用开发者(包括我在内)来说,编写应用程序的网络层是一个熟悉且繁琐的过程。你编写并测试第一个调用,之后便陷入重复的任务循环中。

在Swift中,这个过程通常如下:

  1. 创建一个URLSession实例。
  2. 创建一个URLRequest对象。
  3. 创建与服务器预期输入输出匹配的@Codable模型。

对于后端提供的每个API端点,你都需要重复上述步骤。这个过程不仅耗时且对开发者缺乏挑战性,还容易出错。

如果后端API发生微小变更(例如字段重命名或新增属性),就可能导致应用崩溃。但你可能直到将应用交付QA测试,或在更糟的情况下交付给用户后才会发现这个问题。这正是OpenAPI规范作为现代、健壮解决方案的用武之地。

在本教程中,你将学习什么是OpenAPI以及它如何改善开发流程。之后,我们将通过创建一个小型SwiftUI应用并使用OpenAPI方法与JSONPlaceholder API交互来实现OpenAPI。让我们开始吧。

本指南适合谁?

本指南既适合寻找最佳实践的新手开发者,也适合希望实施或了解更多OpenAPI规范的经验丰富的开发者。让我们深入探讨。

目录

  • OpenAPI是什么以及为什么你应该关注它?
  • 对Swift(iOS)开发者的好处
  • 实施此解决方案的实用指南
    • 步骤1:创建良好的openapi.yaml文件(规范)
    • 步骤2:设置你的项目
    • 步骤3:编写包装器
    • 步骤4:调用包装器并显示数据
  • 潜在陷阱
    • 冗长或丑陋的生成代码
    • 大型规范和性能问题
    • 不支持的规范特性
  • 结论:拥抱规范驱动开发

OpenAPI是什么以及为什么你应该关注它?

OpenAPI规范的核心是提供了一个标准的、与语言无关的接口,用于描述RESTful API。这个规范一旦填充完成,允许人类和计算机无需访问源代码或网络请求就能发现和理解服务的功能。

OpenAPI的强大之处在于它充当了系统不同部分之间的正式契约。这个契约通过在设计过程中消除歧义来帮助前端和后端程序员。这还有一个额外的好处,就是可以使用代码生成器在后端和客户端生成样板代码(我们今天也会讨论这一点)。

传统上,当你想在团队中创建新API时,要么是产品经理、前端工程师,要么是后端工程师负责提出请求。然后后端团队构建并记录它。这随后被前端团队用来使用API。

某些请求者 → 后端团队 → 文档 → 前端团队

如果你使用OpenAPI,当有人请求新API时,经过与前端和后端团队的商议后,它会被形式化为一个规范。然后这作为单一事实来源,用于生成后端和前端代码,而无需太多相互依赖。

某些请求者 → 所有团队 → 规范 → 所有团队

这不仅简化了添加新API的过程,还为每个端点提供了明确的单一事实来源。这也使得前端工程师和后端工程师不会对结果中提供的参数是Int还是String等问题产生分歧。一切都写在规范中。

对Swift(iOS)开发者的好处

采用OpenAPI和swift-openapi-generator为Swift/应用开发过程带来了许多实际好处。它以几种关键方式改变了应用程序与Web服务的交互方式。

减少开发时间和成本

你将看到的最直接的改进是显著减少了需要编写的样板代码。生成器自动化了所谓的样板代码或仪式性代码的创建。这是网络请求、响应处理和数据模型定义的重复逻辑。

通过委托这项工作,开发者可以专注于应用程序的核心功能,从而实现更快、更有趣的开发周期。

编译时类型安全

这对我来说是一个重大改进。我们现在使用强类型模型,而不是依赖JSON解析的"强"类型键。生成器直接从OpenAPI文档中定义的模式创建原生Swift结构和枚举类型。这为网络和解析层带来了强类型系统的力量。

例如,如果API的返回值变为可选,我们将在构建时编译失败,而不是在运行时崩溃。这迫使我们立即解决这个问题。

改进的协作和互操作性

这确保所有开发者对给定端点都有相同的理解。由于这个规范是与语言无关的它将作为项目中所有团队(移动端、Web和后端)的通用语言。

其他工具

一旦你有了规范,你可以用它来驱动各种工具。你可以生成交互式文档,为前端开发创建模拟服务器,并运行自动化测试。

好了,希望你已经信服了——那么现在如何将其实施到你的项目中呢?

实施此解决方案的实用指南

我们现在来看一个实际例子,以便你理解如何在项目中实施这一点。这包括:

  • 创建一个openapi.yaml文件来描述API规范
  • 配置并将swift-openapi-generator集成到SwiftUI应用程序中
  • 原型设计一个从https://jsonplaceholder.typicode.com/获取并显示帖子列表的应用

要跟随操作,你需要安装Xcode,并对Swift编程和SwiftUI应用开发有基本了解。

步骤1:创建良好的openapi.yaml文件(规范)

规范的质量非常重要,因为它直接决定了swift-openapi-generator生成的代码质量。如果你没有好的规范,可能会遇到开发者经常抱怨的几个问题,比如混乱和冗长的方法名。

例如,它可能会生成像get_all_my_meal_recipes_hyphen_detailed这样的东西。如果规范中没有提供标识符,生成器被迫基于API路径创建新名称时可能会发生这种情况。因此,与其逐个处理这些问题,我们将从一开始就创建一个良好清晰的规范。

由于我们使用jsonplaceholder作为后端服务器,我们能做的调整有限——但它是一个很棒的项目,让我们可以模拟后端服务器。

一般来说,OpenAPI.yaml文件包含:

  • OpenAPI信息和服务器——这将提供API的元数据,如OpenAPI版本、调用指向的服务器等
  • 路径——这将提供可用的端点。在我们的例子中,它可以包含/posts作为其中之一。我们还需要提及端点的类型(get、post、put等)
  • OperationID——这个字段指示生成器用这个名字创建一个清晰的方法
  • 响应——这定义了API调用的可能结果。我们将在这里指定成功的200 OK响应或任何其他错误的结构
  • 组件/模式——这定义了所有可重用组件和数据模型。如果我们在这里定义了Post模式,生成器将使用它来创建匹配的Swift Post结构

考虑到所有这些元素,我为本教程编译了一个yaml文件:

 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
# openapi.yaml
openapi: "3.0.3"
info:
  title: "JSONPlaceholder API"
  version: "1.0.0"
servers:
  - url: "https://jsonplaceholder.typicode.com"
paths:
  /posts:
    get:
      summary: "Get all posts"
      operationId: "getPosts"
      responses:
        "200":
          description: "A list of posts"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Post"
components:
  schemas:
    Post:
      type: object
      required:
        - userId
        - id
        - title
        - body
      properties:
        userId:
          type: integer
        id:
          type: integer
        title:
          type: string
        body:
          type: string

第一行openapi: “3.0.3"只是告诉生成器和解析器我们使用的是3.0.3版本。

接下来,我们有更多的元数据——API的名称和版本。我们还有调用API的服务器。

定义完这些元数据后,我们现在定义我们的端点。为了这个例子,假设我们只有一个端点来获取帖子。我们通过在路径下说/posts来表示这一点。然后我们通过指定get:来指定它是哪种类型。

我们在summary中给出了它做什么的简短描述,然后指定了一个operationId,这就是我们在生成代码中调用这个函数的方式。我们还确切指定了响应将具有什么结构,即一个Post数组的JSON。

然后我们列出我们在API中拥有的任何组件,如Post。注意,我们在进一步定义它之前,在返回响应结构中使用了Post模式。组件中的模式将决定我们将使用这个yaml文件生成的模型结构。

步骤2:设置你的项目

创建一个新的SwiftUI项目。为了本教程的目的,我们将使用一个iOS应用——但你可以在任何应用中使用这个。选择Swift作为语言,SwiftUI作为界面。

将我们刚刚创建的openapi.yaml文件添加到这个项目中。(你也可以在Xcode中创建这个文件并从上面的脚本复制粘贴。)

现在,将以下Swift包添加到项目中。(注意:在继续之前请阅读关于添加包的整个部分。)

添加这些包时要注意的一个主要警告是,Swift OpenAPI Generator不应该添加到你的项目目标中。这是因为我们只使用它来生成代码,但我们不在应用中使用它。

如果你遇到这个错误:swift-openapi-generator/Sources/_OpenAPIGeneratorCore/PlatformChecks.swift:21:5 _OpenAPIGeneratorCore仅由swift-openapi-generator本身使用——你的目标不应该链接这个库或命令行工具直接。——那么你犯了这个错误。

修复这个问题的最简单方法是删除包并重新添加。或者你可以转到项目 → 目标 → 构建阶段 → 链接二进制与库 → 删除Swift OpenAPI Generator。

现在我们已经添加了这些生成器和运行时插件,我们需要给生成器一些关于生成什么的指令。你可以使用openapi-generator-config.yaml文件来做到这一点。对于我们的项目,使用以下文件。它非常简单:

1
2
3
generate:
  - types
  - client

这告诉我们的生成器生成类型——来自文件模式部分的Swift结构、枚举等,以及客户端——与网络逻辑交互的主类。

将其保存到openapi-generator-config.yaml文件中,如图所示。

最后,我们希望生成器在我们想要构建这个应用程序/目标时运行。我们可以在目标的构建阶段选项卡中指定这一点。在"目标 → 构建阶段 → 运行构建工具插件"下,添加OpenAPIGenerator插件。

设置后第一次构建项目时,Xcode将显示一个安全对话框。这将让我们"信任并启用"这个插件。这是一个一次性的确认,给予这个插件在构建过程中运行所需的权限。

在给予这些权限后第二次构建时,你将生成文件。你可能在Xcode窗口本身看不到任何变化。但如果你好奇想看结果,请转到这个文件夹。

DerivedData → <项目名称>*标识符 → Build → intermediates.noindex → BuildToolPluginIntermediates → <目标名称>.output → <目标名称> → OpenAPIGenerator → GeneratedSources

如果你好奇,可以在这里了解更多关于派生数据文件夹的信息:https://gayeugur.medium.com/derived-data-2e9468c6da9b

请记住,这个位置可能因Xcode版本、OpenAPI版本和你的项目设置而异。但你不需要担心文件位置。

你将看到三个文件:Client.swift、Types.swift和Server.swift。

这些是我们的生成器创建并填充了我们需要的类型和函数的文件。

在下一节中,我们将讨论如何使用这些文件来调用服务器。

步骤3:编写包装器

虽然在整个应用程序中仅使用生成的代码(客户端)类型来调用服务器肯定是可能的,但更可维护的方法是在这些类型周围使用包装器。这将为我们的应用程序的其余部分提供一个稳定、干净的接口,并将功能代码与生成的代码解耦。

我能听到你在想:“等等。生成这个代码的整个目的不就是为了避免这种样板抽象吗?”

虽然它在生成的代码之上添加了一些抽象,但拥有这个有很多原因。以下是其中几个:

  • 更好的命名。现在生成的Post结构将被称为Components.Schemas.Post
  • 如果你曾经想远离生成器,抽象真的很有帮助
  • 如果你想模拟这个服务器调用,你可以通过抽象来做到这一点
  • UI优化。你可能想要扁平化模型的结构以减少其中的计算变量数量,等等

所以,我们想把它包装在一个名为WebService.swift的文件中:

 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
// WebService.swift
import Foundation
import OpenAPIURLSession

// 一个干净、应用特定的Post模型
// 这将视图与生成的类型解耦
struct AppPost: Identifiable, Codable {
    let id: Int
    let title: String
    let body: String
}

class WebService {
    private let client: Client

    init() {
        // 服务器URL和传输来自生成的代码
        // `Servers.Server1.url()`对应于规范中`servers`数组中的第一个URL
        self.client = Client(
            serverURL: try! Servers.Server1.url(),
            transport: URLSessionTransport()
        )
    }

    func getPosts() async throws -> [AppPost] {
        // 调用生成的方法,该方法使用`operationId`命名
        let response = try await client.getPosts(.init())

        // 生成的响应是一个类型安全的枚举,涵盖所有文档化的状态码
        switch response {
        case.ok(let okResponse):
            // 主体也是不同内容类型的类型安全枚举
            switch okResponse.body {
            case.json(let posts):
                // 将生成的`Components.Schemas.Post`映射到我们干净的`AppPost`模型
                return posts.map { post in
                    AppPost(id: post.id, title: post.title, body: post.body)
                }
            }
        // 生成器强制处理其他文档化的响应
        // 我们的简单规范只有200,所以任何其他响应都是未文档化的
        case.undocumented(statusCode: let statusCode, _):
            throw URLError(.badServerResponse, userInfo: ["statusCode": statusCode])
        }
    }
}

让我们浏览这个文件来理解我们在做什么。

首先,我们导入OpenAPIUrlSession和Foundation。这允许我们调用服务器,获取响应并解析该响应。

接下来,我们定义新的AppPost结构。这旨在成为应用中Post的表示。在生成的Types.Swift文件中,我们有生成的Post结构。它被定义为:

 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
/// - Remark: Generated from `#/components/schemas/Post`.
internal struct Post: Codable, Hashable, Sendable {
    /// - Remark: Generated from `#/components/schemas/Post/userId`.
    internal var userId: Swift.Int
    /// - Remark: Generated from `#/components/schemas/Post/id`.
    internal var id: Swift.Int
    /// - Remark: Generated from `#/components/schemas/Post/title`.
    internal var title: Swift.String
    /// - Remark: Generated from `#/components/schemas/Post/body`.
    internal var body: Swift.String
    /// Creates a new `Post`.
    ///
    /// - Parameters:
    ///   - userId:
    ///   - id:
    ///   - title:
    ///   - body:
    internal init(
        userId: Swift.Int,
        id: Swift.Int,
        title: Swift.String,
        body: Swift.String
    ) {
        self.userId = userId
        self.id = id
        self.title = title
        self.body = body
    }
    internal enum CodingKeys: String, CodingKey {
        case userId
        case id
        case title
        case body
    }
}

如你所见,我们的AppPost结构与此生成类型不同。我们省略了userId,因为我们不关心它(至少现在不关心)。

回到WebService类,我们看到一个client属性。这是一个生成的类型变量,将让我们与服务器交互。在WebService类的初始化器中,我们使用模式中指定的第一个服务器URL创建一个新的Client,并使用URLSessionTransport对象进行这些调用。

然后我们定义我们的方法。在这种情况下,我们的getPosts()函数返回[AppPost]数组。

let response = try await client.getPosts(.init())将调用Client对象上的getPosts()函数。这里的Client.getPosts()函数接受一个名为Operations.getPosts.Input的输入结构,该结构由这里传递的.init()初始化。

这个生成的响应是一个类型安全的枚举,涵盖所有文档化的代码。(目前我们的yaml文件中只有200)。因此,我们使用一个简单的switch来查看这两种情况,并进一步使用更多的switch语句来获得适当的响应。你可以看到这比手动解析响应要容易多少。

一旦我们得到Components.Schemas.Post响应,我们映射并将其转换为[AppPost]数组并返回它。

现在,让我们使用这个包装器在我们的应用中显示数据。

步骤4:调用包装器并显示数据

我们现在到了最后一步。我们将使用我们创建的包装器来显示获取的帖子。我们还将使用一个状态变量来在我们的ContentView视图中存储我们的AppPost数组。然后我们将在视图首次显示给用户时调用getPosts()。

 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
47
48
// ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var posts: [AppPost] = []
    @State private var errorMessage: String?

    private let webService = WebService()

    var body: some View {
        NavigationStack {
            List(posts) { post in
                VStack(alignment:.leading, spacing: 8) {
                    Text(post.title)
                       .font(.headline)
                    Text(post.body)
                       .font(.subheadline)
                       .foregroundColor(.secondary)
                }
               .padding(.vertical, 4)
            }
           .navigationTitle("Posts")
           .task {
                await loadPosts()
            }
           .overlay {
                if let errorMessage {
                    ContentUnavailableView("Error", systemImage: "xmark.octagon", description: Text(errorMessage))
                } else if posts.isEmpty {
                    ProgressView()
                }
            }
        }
    }

    func loadPosts() async {
        self.errorMessage = nil
        do {
            self.posts = try await webService.getPosts()
        } catch {
            self.errorMessage = error.localizedDescription
        }
    }
}

#Preview {
    ContentView()
}

你可以在预览中看到虚拟帖子。如你所见,我们所要做的就是调用webService.getPosts()来填充变量。

你可能会想,对于像Post这样简单的结构,我们需要创建一个名为AppPost的包装器,这需要很多设置。但如果你有十个这样的类型和二十个要调用的端点呢?你不必处理大量重复的、容易出错的代码。

潜在陷阱

不幸的是,没有过程是完美的。你可能仍然会遇到很多生成代码和这种方法的问题。我在这里列出了一些以及如何处理它们。

冗长或丑陋的生成代码

如果你有非常冗长或丑陋的生成代码,问题几乎总是API路径缺少operationId。如果你不指定一个,生成器

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计