首頁 > 軟體

使用 Swift Package 外掛生成程式碼的範例詳解

2022-08-10 14:04:14

前言

不久前,我正在工作中開發一項新服務,該服務由 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 外掛生成程式碼內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com