使用?Swift?Package?插件生成代碼的示例詳解
前言
不久前,我正在工作中開(kāi)發(fā)一項(xiàng)新服務(wù),該服務(wù)由 Swift Package 組成,該 Package 公開(kāi)了一個(gè)類(lèi)似于Decodable?協(xié)議,供我們應(yīng)用程序的其余部分使用。事實(shí)上,該協(xié)議是從Decodable本身繼承下來(lái)的,看起來(lái)像這樣:
Fetchable.swit
protocol Fetchable: Decodable, Equatable {}
新的 package 將采用符合Fetchable的類(lèi)型來(lái)嘗試從遠(yuǎn)程或緩存的JSON數(shù)據(jù)塊中解碼它們。
由于這項(xiàng)服務(wù)對(duì)應(yīng)用程序的正確運(yùn)行至關(guān)重要,作為這項(xiàng)工作的一部分,我們希望確保始終存在故障安全( fail-safe)。因此,我們讓該應(yīng)用程序附帶了一個(gè)備用的JSON文件,如果遠(yuǎn)程和緩存的數(shù)據(jù)解碼失敗,將使用該文件,來(lái)保證程序的正常運(yùn)行。
無(wú)論如何,我們需要符合Fetchable的新類(lèi)型從備用數(shù)據(jù)中正確解碼。然而,有一個(gè)問(wèn)題,有時(shí)很難發(fā)現(xiàn)備用JSON文件或模型本身是否有任何錯(cuò)誤,因?yàn)榻獯a錯(cuò)誤會(huì)在運(yùn)行時(shí)發(fā)生,并且只有在訪(fǎng)問(wèn)某些屏幕/功能時(shí)才會(huì)發(fā)生。
為了讓我們對(duì)我們要發(fā)送的代碼更有信心,我們添加了一些單元測(cè)試,試圖根據(jù)我們附帶的備用JSON解碼符合Fetchable協(xié)議的每個(gè)模型。這些將使我們?cè)贑I上有一個(gè)早期指示,表明備用數(shù)據(jù)或模型中存在錯(cuò)誤,如果所有測(cè)試都通過(guò),我們將確定,一旦我們發(fā)布新服務(wù),它始終具有故障安全功能。
我們手動(dòng)編寫(xiě)了這些測(cè)試,但我們很快就意識(shí)到這個(gè)解決方案是不可擴(kuò)展的,因?yàn)殡S著越來(lái)越多的符合Fetchable協(xié)議的類(lèi)型被添加,我們引入了大量的代碼復(fù)制,并可能有人最終忘記為特定功能編寫(xiě)這些測(cè)試。
我們考慮過(guò)自動(dòng)化該過(guò)程,但由于我們的代碼庫(kù)的性質(zhì),我們遇到了一些問(wèn)題,代碼庫(kù)高度模塊化,混合了Xcode項(xiàng)目和Swift Package。一些架構(gòu)決策還意味著我們必須收集大量符號(hào)信息,才能獲得生成測(cè)試的正確類(lèi)型。
是什么讓我再次關(guān)注到它?
在我忘記了這件事一段時(shí)間后,Xcode 14的公告允許在Xcode項(xiàng)目中使用 Swift Package 插件,以及一些架構(gòu)更改使提取類(lèi)型信息變得容易得多,這讓我有動(dòng)力再次開(kāi)始研究這個(gè)問(wèn)題。
請(qǐng)注意,Xcode項(xiàng)目的構(gòu)建工具插件尚未按照發(fā)布說(shuō)明在Xcode 14 Beta 2中提供,但將在Xcode 14的未來(lái)版本中提供。
圖片取自 Xcode Beta 2 版的發(fā)布說(shuō)明
在過(guò)去的幾周里,我一直在研究如何使用軟件包插件生成單元測(cè)試,在這篇文章中,我將解釋我在向哪個(gè)方向嘗試以及它涉及了什么。
實(shí)施細(xì)節(jié)
我開(kāi)始了一項(xiàng)任務(wù),即創(chuàng)建一個(gè)構(gòu)建工具插件,與 Xcode 14 引入的命令插件不同,該插件可以任意運(yùn)行并依賴(lài)用戶(hù)輸入,作為Swift軟件包構(gòu)建過(guò)程的一部分運(yùn)行。
我知道我需要?jiǎng)?chuàng)建一個(gè)可執(zhí)行文件,因?yàn)?Build Tool 插件依賴(lài)這些來(lái)執(zhí)行操作。這個(gè)腳本將完全用 Swift 編寫(xiě),因?yàn)檫@是我最熟悉的語(yǔ)言,并承擔(dān)以下職責(zé):
- 掃描目標(biāo)目錄并提取所有.swift文件。目標(biāo)將被遞歸掃描,以確保不會(huì)錯(cuò)過(guò)子目錄。
- 使用sourcekit,或者更具體地說(shuō),SourceKitten,掃描這些.swift?文件并收集類(lèi)型信息。這將允許提取符合Fetchable協(xié)議的所有類(lèi)型,以便可以針對(duì)它們編寫(xiě)測(cè)試。
- 獲得這些類(lèi)型后,生成一個(gè)帶有XCTestCase的.swift文件,其中包含每種類(lèi)型的單元測(cè)試。
讓我們寫(xiě)一些代碼吧
與所有 Swift Package 一樣,最簡(jiǎn)單的入門(mén)方法是在命令行上運(yùn)行swift package init。
這創(chuàng)建了兩個(gè)目標(biāo),一個(gè)是包含F(xiàn)etchable協(xié)議定義和符合該定義的類(lèi)型的實(shí)現(xiàn)代碼,另一個(gè)是應(yīng)用插件為此類(lèi)類(lèi)型生成單元測(cè)試的測(cè)試目標(biāo)。
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"] ) ] )
編寫(xiě)可執(zhí)行文件
如前所述,所有構(gòu)建工具插件都需要可執(zhí)行文件來(lái)執(zhí)行所有必要的操作。
為了幫助開(kāi)發(fā)此命令行,將使用幾個(gè)依賴(lài)項(xiàng)。第一個(gè)是SourceKitten——特別是其SourceKitten框架庫(kù),這是一個(gè)Swift包裝器,用于幫助使用Swift代碼編寫(xiě)sourcekit請(qǐng)求,第二個(gè)是快速參數(shù)解析器,這是蘋(píng)果提供的軟件包,可以輕松創(chuàng)建命令行工具,并以更快、更安全的方式解析在執(zhí)行過(guò)程中傳遞的命令行參數(shù)。
在創(chuàng)建executableTarget?并賦予它兩個(gè)依賴(lài)項(xiàng)后,Package.swift就是這個(gè)樣子:
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") ] ) ] )
可執(zhí)行目標(biāo)需要一個(gè)入口點(diǎn),因此,在PluginExecutable?目標(biāo)的源目錄下,必須創(chuàng)建一個(gè)名為PluginExecutable.swift的文件,其中所有可執(zhí)行邏輯都需要?jiǎng)?chuàng)建。
請(qǐng)注意,這個(gè)文件可以隨心所欲地命名,我傾向于以與我在Package.swift中創(chuàng)建的目標(biāo)相同的方式命名它。
如下所示的腳本導(dǎo)入必要的依賴(lài)項(xiàng),并創(chuàng)建可執(zhí)行文件的入口點(diǎn)(必須用@main裝飾),并聲明在執(zhí)行時(shí)傳遞的4個(gè)輸入。
所有邏輯和方法調(diào)用都存在于run?函數(shù)中,該函數(shù)是調(diào)用可執(zhí)行文件時(shí)運(yùn)行的方法。這是ArgumentParser語(yǔ)法的一部分,如果您想了解更多信息,Andy Ibañez有一篇關(guān)于該主題的精彩文章,可能非常有幫助。
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) } // ... }
現(xiàn)在讓我們專(zhuān)注于上面的run方法,以了解當(dāng)插件運(yùn)行可執(zhí)行文件時(shí)會(huì)發(fā)生什么:
- 首先,掃描目標(biāo)目錄以找到其中的所有.swift文件。這是遞歸完成的,這樣子目錄就不會(huì)錯(cuò)過(guò)。此目錄的路徑作為參數(shù)傳遞給可執(zhí)行文件。
- 對(duì)于上次調(diào)用中找到的每個(gè)文件,通過(guò)SourceKitten發(fā)出Structure?請(qǐng)求,以查找文件中Swift代碼的類(lèi)型信息。請(qǐng)注意,環(huán)境變量(IN_PROCESS_SOURCEKIT)也被設(shè)置為true。這需要確保選擇源套件的進(jìn)程中版本,以便它能夠遵守插件的沙盒規(guī)則。
Xcode附帶兩個(gè)版本的sourcekit可執(zhí)行文件,一個(gè)版本解析進(jìn)程中的文件,另一個(gè)使用XPC向解析進(jìn)程外文件的守護(hù)進(jìn)程發(fā)送請(qǐng)求。后者是mac上的默認(rèn)版本,為了能夠?qū)ourcekit用作插件進(jìn)程的一部分,必須選擇進(jìn)程中版本。這最近在SourceKitten上作為環(huán)境變量實(shí)現(xiàn),是運(yùn)行引擎蓋下使用sourcekit的其他可執(zhí)行文件的關(guān)鍵,例如SwiftLint。
- 瀏覽上次調(diào)用的所有響應(yīng),并掃描類(lèi)型信息以提取符合Fetchable協(xié)議的任何類(lèi)型。
- 在傳遞給可執(zhí)行文件的output參數(shù)指定的位置創(chuàng)建一個(gè)輸出文件,其中包含每種類(lèi)型的單元測(cè)試。
請(qǐng)注意,上面沒(méi)有重點(diǎn)介紹每個(gè)調(diào)用的具體細(xì)節(jié),但如果你對(duì)實(shí)現(xiàn)感興趣,包含所有代碼的repo現(xiàn)在已經(jīng)在Github上公開(kāi)了!
創(chuàng)建該插件
與可執(zhí)行文件一樣,必須向Package.swift?添加.plugin?目標(biāo),并且必須創(chuàng)建包含插件實(shí)現(xiàn)的.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")] ) ] )
以下代碼顯示了插件的初始實(shí)現(xiàn),其struct?符合BuildToolPlugin?的協(xié)議。這需要實(shí)現(xiàn)一個(gè)返回具有單個(gè)構(gòu)建命令的數(shù)組的createBuildCommands方法。
此插件使用buildCommand?而不是preBuildCommand?,因?yàn)樗枰鳛闃?gòu)建過(guò)程的一部分運(yùn)行,而不是在它之前運(yùn)行,因此它有機(jī)會(huì)構(gòu)建和使用它所依賴(lài)的可執(zhí)行文件。在這種情況下,支持使用buildCommand的另一點(diǎn)是,它只會(huì)在輸入文件更改時(shí)運(yùn)行,而不是每次構(gòu)建目標(biāo)時(shí)運(yùn)行。
此命令必須為要運(yùn)行的可執(zhí)行文件提供名稱(chēng)和路徑,這可以在插件的上下文中找到:
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?,用于生成單元測(cè)試文件。此文件可以在pluginWorkDirectory中生成,也可以在插件的上下文中找到。該目錄提供讀寫(xiě)權(quán)限且其中創(chuàng)建的任何文件都將是軟件包構(gòu)建過(guò)程的一部分。
- 提供輸入路徑和模塊名稱(chēng)。這是最棘手的部分,這些需要指向正在測(cè)試的目標(biāo)的來(lái)源,而不是插件正在應(yīng)用于的目標(biāo)——單元測(cè)試。謝天謝地,插件的目標(biāo)依賴(lài)項(xiàng)是可訪(fǎng)問(wèn)的,我們可以從該數(shù)組中獲取我們感興趣的依賴(lài)項(xiàng)。此依賴(lài)項(xiàng)將是內(nèi)部的(target?而不是product),它將為可執(zhí)行文件提供其名稱(chēng)和目錄。
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] ) ] } }
注意上述可選性處理方式。如果在測(cè)試目標(biāo)的依賴(lài)項(xiàng)中找不到 合適的 目標(biāo),則使用Diagnostics API將錯(cuò)誤轉(zhuǎn)發(fā)回Xcode,并告訴它完成構(gòu)建過(guò)程。
讓我們看下結(jié)果
插件這就完成了!現(xiàn)在讓我們?cè)?Xcode 中運(yùn)行它!為了測(cè)試這種方法,將包含以下內(nèi)容的文件添加到CodeGenSample目標(biāo)中:
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 } }
請(qǐng)注意,腳本將在結(jié)構(gòu)中首次出現(xiàn)Fetchable?協(xié)議時(shí)停止。這意味著任何嵌套的符合Fetchable協(xié)議的類(lèi)型都將被測(cè)試,只是外部模型。
給定此輸入并在主目標(biāo)上運(yùn)行測(cè)試,生成并運(yùn)行XCTestCase?,其中包含符合Fetchable協(xié)議的兩種類(lèi)型的測(cè)試。
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... } }
所有測(cè)試都通過(guò)了:sweat_smile::white_check_mark:而且,盡管他們目前沒(méi)有做很多事情,但可以擴(kuò)展實(shí)現(xiàn),以提供一些示例數(shù)據(jù)和一個(gè)JSONDecoder實(shí)例來(lái)對(duì)每個(gè)單元測(cè)試進(jìn)行解析。
到此這篇關(guān)于使用 Swift Package 插件生成代碼的文章就介紹到這了,更多相關(guān)Swift Package 插件生成代碼內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Swift版使用ThPullRefresh實(shí)現(xiàn)下拉上拉刷新數(shù)據(jù)
這篇文章主要介紹了Swift版使用ThPullRefresh實(shí)現(xiàn)下拉上拉刷新數(shù)據(jù),需要的朋友可以參考下2016-01-01Swift 5.1 之類(lèi)型轉(zhuǎn)換與模式匹配的教程詳解
這篇文章主要介紹了Swift 5.1 之類(lèi)型轉(zhuǎn)換與模式匹配的相關(guān)知識(shí),本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-05-05Swift算法實(shí)現(xiàn)字符串轉(zhuǎn)數(shù)字的方法示例
最近學(xué)完了swift想著實(shí)踐下,就通過(guò)一些簡(jiǎn)單的算法進(jìn)行學(xué)習(xí)研究,下面這篇文章主要介紹了Swift算法實(shí)現(xiàn)字符串轉(zhuǎn)數(shù)字的方法,需要的朋友可以參考借鑒,下面來(lái)一起看看吧。2017-03-03利用Swift實(shí)現(xiàn)一個(gè)響應(yīng)式編程庫(kù)
最近在學(xué)習(xí)swift,最近有空所以總結(jié)一下最近學(xué)習(xí)的內(nèi)容,下面這篇文章主要給大家介紹了關(guān)于利用Swift實(shí)現(xiàn)一個(gè)響應(yīng)式編程庫(kù)的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面來(lái)一起看看吧。2017-12-12SwiftUI智能家居開(kāi)關(guān)燈頁(yè)面搭建示例
這篇文章主要為大家介紹了SwiftUI智能家居開(kāi)關(guān)燈頁(yè)面搭建示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08