使用 Swift Package 插件生成代碼的示例詳解

前言

不久前,我正在工作中開發一項新服務,該服務由 Swift Package 組成,該 Package 公開瞭一個類似於Decodable​協議,供我們應用程序的其餘部分使用。事實上,該協議是從Decodable本身繼承下來的,看起來像這樣:

Fetchable.swit

protocol Fetchable: Decodable, Equatable {}

新的 package 將采用符合Fetchable的類型來嘗試從遠程或緩存的JSON數據塊中解碼它們。

由於這項服務對應用程序的正確運行至關重要,作為這項工作的一部分,我們希望確保始終存在故障安全( fail-safe)。因此,我們讓該應用程序附帶瞭一個備用的JSON文件,如果遠程和緩存的數據解碼失敗,將使用該文件,來保證程序的正常運行。

無論如何,我們需要符合Fetchable的新類型從備用數據中正確解碼。然而,有一個問題,有時很難發現備用JSON文件或模型本身是否有任何錯誤,因為解碼錯誤會在運行時發生,並且隻有在訪問某些屏幕/功能時才會發生。

為瞭讓我們對我們要發送的代碼更有信心,我們添加瞭一些單元測試,試圖根據我們附帶的備用JSON解碼符合Fetchable協議的每個模型。這些將使我們在CI上有一個早期指示,表明備用數據或模型中存在錯誤,如果所有測試都通過,我們將確定,一旦我們發佈新服務,它始終具有故障安全功能。

我們手動編寫瞭這些測試,但我們很快就意識到這個解決方案是不可擴展的,因為隨著越來越多的符合Fetchable協議的類型被添加,我們引入瞭大量的代碼復制,並可能有人最終忘記為特定功能編寫這些測試。

我們考慮過自動化該過程,但由於我們的代碼庫的性質,我們遇到瞭一些問題,代碼庫高度模塊化,混合瞭Xcode項目和Swift Package。一些架構決策還意味著我們必須收集大量符號信息,才能獲得生成測試的正確類型。

是什麼讓我再次關註到它?

在我忘記瞭這件事一段時間後,Xcode 14的公告允許在Xcode項目中使用 Swift Package 插件,以及一些架構更改使提取類型信息變得容易得多,這讓我有動力再次開始研究這個問題。

請註意,Xcode項目的構建工具插件尚未按照發佈說明在Xcode 14 Beta 2中提供,但將在Xcode 14的未來版本中提供。

圖片取自 Xcode Beta 2 版的發佈說明

在過去的幾周裡,我一直在研究如何使用軟件包插件生成單元測試,在這篇文章中,我將解釋我在向哪個方向嘗試以及它涉及瞭什麼。

實施細節

我開始瞭一項任務,即創建一個構建工具插件,與 Xcode 14 引入的命令插件不同,該插件可以任意運行並依賴用戶輸入,作為Swift軟件包構建過程的一部分運行。

我知道我需要創建一個可執行文件,因為 Build Tool 插件依賴這些來執行操作。這個腳本將完全用 Swift 編寫,因為這是我最熟悉的語言,並承擔以下職責:

  • 掃描目標目錄並提取所有.swift文件。目標將被遞歸掃描,以確保不會錯過子目錄。
  • 使用sourcekit,或者更具體地說,SourceKitten,掃描這些.swift​文件並收集類型信息。這將允許提取符合Fetchable協議的所有類型,以便可以針對它們編寫測試。
  • 獲得這些類型後,生成一個帶有XCTestCase的.swift文件,其中包含每種類型的單元測試。

讓我們寫一些代碼吧

與所有 Swift Package 一樣,最簡單的入門方法是在命令行上運行swift package init。

這創建瞭兩個目標,一個是包含Fetchable協議定義和符合該定義的類型的實現代碼,另一個是應用插件為此類類型生成單元測試的測試目標。

Package.swit

// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
    name: "CodeGenSample",
    platforms: [.macOS(.v10_11)],
    products: [
        .library(
            name: "CodeGenSample",
            targets: ["CodeGenSample"]),
    ],
    dependencies: [
    ],
    targets: [
        .target(
            name: "CodeGenSample",
            dependencies: []
        ),
        .testTarget(
            name: "CodeGenSampleTests",
            dependencies: ["CodeGenSample"]
        )
     ]
)

編寫可執行文件

如前所述,所有構建工具插件都需要可執行文件來執行所有必要的操作。

為瞭幫助開發此命令行,將使用幾個依賴項。第一個是SourceKitten——特別是其SourceKitten框架庫,這是一個Swift包裝器,用於幫助使用Swift代碼編寫sourcekit請求,第二個是快速參數解析器,這是蘋果提供的軟件包,可以輕松創建命令行工具,並以更快、更安全的方式解析在執行過程中傳遞的命令行參數。

在創建executableTarget​並賦予它兩個依賴項後,Package.swift就是這個樣子:

Package.swift

// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
    name: "CodeGenSample",
    platforms: [.macOS(.v10_11)],
    products: [
        .library(
            name: "CodeGenSample",
            targets: ["CodeGenSample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"),
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "CodeGenSample",
            dependencies: []
        ),
        .testTarget(
            name: "CodeGenSampleTests",
            dependencies: ["CodeGenSample"]
        ),
        .executableTarget(
            name: "PluginExecutable",
            dependencies: [
                .product(name: "SourceKittenFramework", package: "SourceKitten"),
                .product(name: "ArgumentParser", package: "swift-argument-parser")
            ]
        )
     ]
)

可執行目標需要一個入口點,因此,在PluginExecutable​目標的源目錄下,必須創建一個名為PluginExecutable.swift的文件,其中所有可執行邏輯都需要創建。

請註意,這個文件可以隨心所欲地命名,我傾向於以與我在Package.swift中創建的目標相同的方式命名它。

如下所示的腳本導入必要的依賴項,並創建可執行文件的入口點(必須用@main裝飾),並聲明在執行時傳遞的4個輸入。

所有邏輯和方法調用都存在於run​函數中,該函數是調用可執行文件時運行的方法。這是ArgumentParser語法的一部分,如果您想瞭解更多信息,Andy Ibañez有一篇關於該主題的精彩文章,可能非常有幫助。

PluginExecutable.swift

import SourceKittenFramework
import ArgumentParser
import Foundation
@main
struct PluginExecutable: ParsableCommand {
    @Argument(help: "The protocol name to match")
    var protocolName: String
    @Argument(help: "The module's name")
    var moduleName: String
    @Option(help: "Directory containing the swift files")
    var input: String
    @Option(help: "The path where the generated files will be created")
    var output: String
    func run() throws {
  // 1
        let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true))
        // 2
        setenv("IN_PROCESS_SOURCEKIT", "YES", 1)
        let structures = try files.map { try Structure(file: File(path: $0.path)!) }
        // 3
        var matchedTypes = [String]()
        structures.forEach { walkTree(dictionary: $0.dictionary, acc: &matchedTypes) }
        // 4
        try createOutputFile(withContent: matchedTypes)
    }

    // ...
}

現在讓我們專註於上面的run方法,以瞭解當插件運行可執行文件時會發生什麼:

  • 首先,掃描目標目錄以找到其中的所有.swift文件。這是遞歸完成的,這樣子目錄就不會錯過。此目錄的路徑作為參數傳遞給可執行文件。
  • 對於上次調用中找到的每個文件,通過SourceKitten發出Structure​請求,以查找文件中Swift代碼的類型信息。請註意,環境變量(IN_PROCESS_SOURCEKIT)也被設置為true。這需要確保選擇源套件的進程中版本,以便它能夠遵守插件的沙盒規則。

Xcode附帶兩個版本的sourcekit可執行文件,一個版本解析進程中的文件,另一個使用XPC向解析進程外文件的守護進程發送請求。後者是mac上的默認版本,為瞭能夠將sourcekit用作插件進程的一部分,必須選擇進程中版本。這最近在SourceKitten上作為環境變量實現,是運行引擎蓋下使用sourcekit的其他可執行文件的關鍵,例如SwiftLint。

  • 瀏覽上次調用的所有響應,並掃描類型信息以提取符合Fetchable協議的任何類型。
  • 在傳遞給可執行文件的output參數指定的位置創建一個輸出文件,其中包含每種類型的單元測試。

請註意,上面沒有重點介紹每個調用的具體細節,但如果你對實現感興趣,包含所有代碼的repo現在已經在Github上公開瞭!

創建該插件

與可執行文件一樣,必須向Package.swift​添加.plugin​目標,並且必須創建包含插件實現的.swift​文件(Plugins/SourceKitPlugin/SourceKitPlugin.swift)。

Package.swift

// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
    name: "CodeGenSample",
    platforms: [.macOS(.v10_11)],
    products: [
        .library(
            name: "CodeGenSample",
            targets: ["CodeGenSample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"),
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "CodeGenSample",
            dependencies: []
        ),
        .testTarget(
            name: "CodeGenSampleTests",
            dependencies: [“CodeGenSample"],
plugins: [“SourceKitPlugin”],
        ),
        .executableTarget(
            name: "PluginExecutable",
            dependencies: [
                .product(name: "SourceKittenFramework", package: "SourceKitten"),
                .product(name: "ArgumentParser", package: "swift-argument-parser")
            ]
        ),
        .plugin(
            name: "SourceKitPlugin",
            capability: .buildTool(),
            dependencies: [.target(name: "PluginExecutable")]
        )
     ]
)

以下代碼顯示瞭插件的初始實現,其struct​符合BuildToolPlugin​的協議。這需要實現一個返回具有單個構建命令的數組的createBuildCommands方法。

此插件使用buildCommand​而不是preBuildCommand​,因為它需要作為構建過程的一部分運行,而不是在它之前運行,因此它有機會構建和使用它所依賴的可執行文件。在這種情況下,支持使用buildCommand的另一點是,它隻會在輸入文件更改時運行,而不是每次構建目標時運行。

此命令必須為要運行的可執行文件提供名稱和路徑,這可以在插件的上下文中找到:

SourceKitPlugin.swift

import PackagePlugin
@main
struct SourceKitPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        return [
            .buildCommand(
                displayName: "Protocol Extraction!",
                executable: try context.tool(named: "PluginExecutable").path,
                arguments: [
                    "FindThis",
                      ,
                    "--input",
                      ,
                    "--output",
                      
                ],
                environment: ["IN_PROCESS_SOURCEKIT": "YES"],
                outputFiles: [  ]
            )
        ]
    }
}

如上面的代碼所示,還有一些空白需要填充( ):

  • 提供outputPath​,用於生成單元測試文件。此文件可以在pluginWorkDirectory中生成,也可以在插件的上下文中找到。該目錄提供讀寫權限且其中創建的任何文件都將是軟件包構建過程的一部分。
  • 提供輸入路徑和模塊名稱。這是最棘手的部分,這些需要指向正在測試的目標的來源,而不是插件正在應用於的目標——單元測試。謝天謝地,插件的目標依賴項是可訪問的,我們可以從該數組中獲取我們感興趣的依賴項。此依賴項將是內部的(target​而不是product),它將為可執行文件提供其名稱和目錄。

SourceKitPlugin.swift

import PackagePlugin
@main
struct SourceKitPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let outputPath = context.pluginWorkDirectory.appending(“GeneratedTests.swift”)

        guard let dependencyTarget = target
            .dependencies
            .compactMap { dependency -> Target? in
                switch dependency {
                case .target(let target): return target
                default: return nil
                }
            }
            .filter { "\($0.name)Tests" == target.name  }
            .first else {
                Diagnostics.error("Could not get a dependency to scan!”)

                return []
        }
        return [
            .buildCommand(
                displayName: "Protocol Extraction!",
                executable: try context.tool(named: "PluginExecutable").path,
                arguments: [
                    "Fetchable",
                  dependencyTarget.name,
                    "--input",
                    dependencyTarget.directory,
                    "--output",
                    outputPath
                ],
                environment: ["IN_PROCESS_SOURCEKIT": "YES"],
                outputFiles: [outputPath]
            )
        ]
    }
}

註意上述可選性處理方式。如果在測試目標的依賴項中找不到 合適的 目標,則使用Diagnostics API將錯誤轉發回Xcode,並告訴它完成構建過程。

讓我們看下結果

插件這就完成瞭!現在讓我們在 Xcode 中運行它!為瞭測試這種方法,將包含以下內容的文件添加到CodeGenSample目標中:

CodeGenSample.swift

import Foundation
protocol Fetchable: Decodable, Equatable {}
struct FeatureABlock: Fetchable {
    let featureA: FeatureA

    struct FeatureA: Fetchable {
        let url: URL
    }
}
enum Root {
    struct RootBlock: Fetchable {
        let url: URL
        let areAllFeaturesEnabled: Bool
    }
}

請註意,腳本將在結構中首次出現Fetchable​協議時停止。這意味著任何嵌套的符合Fetchable協議的類型都將被測試,隻是外部模型。

給定此輸入並在主目標上運行測試,生成並運行XCTestCase​,其中包含符合Fetchable協議的兩種類型的測試。

GeneratedTests.swift

import XCTest
@testable import CodeGenSample
class GeneratedTests: XCTestCase {
 func testFeatureABlock() {
  assertCanParseFromDefaults(FeatureABlock.self)
 }
 func testRoot_RootBlock() {
  assertCanParseFromDefaults(Root.RootBlock.self)
 }
    private func assertCanParseFromDefaults<T: Fetchable>(_ type: T.Type) {
        // Logic goes here...
    }
}

所有測試都通過瞭:sweat_smile::white_check_mark:而且,盡管他們目前沒有做很多事情,但可以擴展實現,以提供一些示例數據和一個JSONDecoder實例來對每個單元測試進行解析。

到此這篇關於使用 Swift Package 插件生成代碼的文章就介紹到這瞭,更多相關Swift Package 插件生成代碼內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: