本文與測驗無關。這是關於採用一種工作流程,讓您在開發功能時保持控制。測試只是這個過程的引擎和美好的結果。
這個工作流程徹底改變了我的編碼方式,它總是讓我臉上露出笑容。我希望它對你也有同樣的作用。最後,您將擁有一個完全開發的功能,滿足所有業務規則,以及一個測試套件,可以在不到一秒鐘的時間內對其進行驗證。
我使用 PHP 進行演示,但此工作流程完全適用於任何語言。
工作流程和測試挫折
我從哪裡開始我的功能?
當需要開發新功能時,通常很難知道從哪裡開始。您應該從業務邏輯、控制器還是前端開始?
知道何時停止也同樣棘手。如果沒有明確的流程,衡量進度的唯一方法就是手動測試。一種乏味且容易出錯的方法。
別害怕,這只是一個 2000 行的文件,沒有任何測試?
你認識這個人。該文件發生了無法解釋的事情。您需要更改一行,但每次嘗試似乎都會破壞整個專案。如果沒有測試的安全網,重構就像在峽谷上走鋼索。
測試速度慢得令人痛苦
為了避免這種混亂,您決定透過端到端測試來測試每個功能。好主意!直到您意識到在等待測試套件完成時您有足夠的時間喝五杯咖啡。生產力?窗外。
每次重構後測試都會中斷
為了建立即時回饋,您決定為所有課程編寫細粒度的測驗。然而,現在您所做的每項更改都會導致一系列損壞的測試,並且您最終會花費更多的時間來修復它們,而不是進行實際的更改。這是令人沮喪的、低效的,並且讓你害怕每一次重構。
讓我們一起夢想吧!什麼是完美測試?
即時回饋
回饋是軟體開發每個階段的關鍵。在開發專案核心時,我需要即時回饋以立即了解我的任何業務規則是否被破壞。在重構過程中,有一個助手告訴我程式碼是否被破壞或我是否可以安全地繼續,這是一個無價的優勢。
注意行為
專案能夠做什麼,專案行為,是最重要的面向。這些行為應該以使用者為中心。即使實現(為使功能正常運作而創建的程式碼)發生變化,使用者的意圖也將保持不變。
例如,當使用者下訂單時,他們希望收到通知。通知使用者的方式有很多種:電子郵件、簡訊、郵件等。雖然使用者的意圖不會改變,但實施往往會改變。
如果測試代表了使用者的意圖並且程式碼不再滿足該意圖,則測試應該失敗。但是,在重構期間,如果程式碼仍然符合使用者的意圖,則測試不應該中斷。
副駕駛
想像一下在創建新功能的過程中一步步被引導。透過先編寫測試,它將成為您編寫正確功能的指南。可能還不完全清楚,但我希望我的測試在編碼時能充當我的 GPS。透過這種方法,我不需要考慮下一步,我只需按照 GPS 指示操作即可。如果這個概念仍然不清楚,別擔心!我將在工作流程部分詳細解釋。 ?
開始之前的一些要求
功能和驗收測試
正如我在簡介中提到的,本文不是關於測試,而是關於創建功能。為此,我需要一個功能。更具體地說,我需要一個帶有範例定義的驗收測試的功能。對於每項功能,團隊應建立驗收標準或規則,並為每項規則建立一個或多個範例。
⚠️ 這些範例必須以使用者/網域為中心,不描述任何技術方面。檢查範例是否定義良好的一個簡單方法是問自己:「我的範例是否適用於任何實作(Web 前端、HTTP API、終端 CLI、現實生活等)?」
例如,如果我想開發「將產品加入購物籃」功能,我的範例可能如下所示:
這些範例將作為測試的基礎。對於更複雜的場景,我使用給定/何時/然後模式來獲取更多細節,但對於更簡單的場景則沒有必要。
以領域為中心的架構
即時回饋和對行為的關注,這不是一個很大的挑戰嗎?使用 Symfony MVC 服務架構,要實現這一點可能很困難。這就是為什麼我將使用以領域為中心的架構,我在本文中詳細介紹了這一點:建構 Symfony 專案的另一種方法。
以行為為重點的測試
為了專注於行為,我需要以正確的方式建立測驗。首先,我描述使用者操作之前系統的狀態。接下來,我執行使用者操作,這會更改系統的狀態。最後,我斷言系統的狀態符合我的期望。
以這種方式測試,測試並不關心系統如何處理使用者操作;它只檢查操作是否成功。這意味著如果我們更改使用者操作的實現,測試不會中斷。但是,如果實作無法如預期更新系統狀態,則測試將中斷-而這正是我們想要的!
偽造 I/O
為了實現即時回饋,需要模擬系統的某些部分。以域為中心的架構使這成為可能,因為域僅依賴與外部程式庫互動的介面。這使得偽造這些依賴關係變得非常容易,從而使功能測試運行得非常快。當然,真正的實作也將使用整合測試進行測試(儘管不在本文中)。
GPS 工作流程
在這個工作流程中,測試的是我的 GPS!我設定一個目的地,讓它引導我,並在我到達時通知我。測試將根據團隊提供的範例進行。
輸入您的目的地
為了測試業務邏輯和使用者意圖,我使用功能測試:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
此文件將包含此功能的所有測試,但讓我們從第一個開始。
安排
在第一部分中,我描述了應用程式的狀態。這裡,有一個空籃子供 ID 為「1」的顧客使用。
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
行為
handler和command代表了使用者的意圖,這樣就很明確了,大家都明白。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
? :我決定將這個專案中的命令和查詢分開,但我們可以有一個 AddProductToBasketUseCase。
斷言
最後,是時候描述一下最終結果應該是什麼樣子了。我希望我的購物籃裡有合適的產品。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
目的地
這是將第一個範例轉換為程式碼的測試。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
不要害怕錯誤
此時,我的 IDE 到處都顯示錯誤,因為我在此測試中使用的任何內容都不存在。但這些真的是錯誤嗎,還是只是我們邁向目的地的下一步?我將這些錯誤視為指令,每次執行測試時,它都會告訴我們下一步該做什麼。這樣我就不用想太多;我只是跟著GPS走。
? 提示:為了改善開發人員體驗,您可以啟用檔案觀察器,該觀察器會在您進行變更時自動執行測試套件。由於測試套件速度非常快,因此您將在每次更新時獲得即時回饋。
在這個例子中,我將一一解決每個錯誤。當然,如果你有信心,你可以走捷徑。 ?
那麼,讓我們來進行測試吧!對於每個錯誤,我都會執行下一步所需的最低限度的操作。
使測試編譯
❌? :「錯誤:找不到類別「AppTestsFunctionalBasket」」
由於這是此功能的第一次測試,因此大多數類別尚不存在。所以第一步,我建立一個籃子物件:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? :「錯誤:找不到類別「AppTestsFunctionalInMemoryBasketRepository」」
我建立了 InMemoryBasketRepository :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? :「錯誤:找不到類別「AppTestsFunctionalAddProductToBasketCommandHandler」」
我在 Catalog/Application 資料夾中建立 AddProductToBasketCommandHandler,該處理程序將成為「將產品新增至購物籃」功能的入口點。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
為了尊重以領域為中心的架構,我為儲存庫創建了一個接口,這樣我們就反轉了依賴關係。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
❌? : 「TypeError : AppCatalogApplicationCommandAddProductToBasketAddProductToBasketCommandHandler::__construct(): 參數 #1 ($basketRepository) 必須是 AppCatalogDomainBasketRepository、AppCatalogetRepository類型給定“
現在,InMemory 實作必須實作該介面才能注入到命令處理程序中。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
❌? :_「錯誤:找不到類別「AppTestsFunctionalAddProductToBasketCommand」」
_
我建立了一個命令,因為命令始終是 DTO,所以我將其標記為唯讀並將所有屬性公開。
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
❌? : 「斷言兩個物件相等失敗。」
測試正在編譯中!它還是紅色的,所以我們還沒有完成,讓我們繼續吧! ?
讓我們來看看業務邏輯
是時候編寫業務邏輯了。就像我編寫測試時一樣,即使什麼都不存在,我也會編寫我期望處理程序處理命令的方式。它無法編譯,但測試將再次引導我進行下一步。
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
❌? : 「錯誤:呼叫未定義的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::get()」
我在儲存庫介面中建立 get 方法:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? : 「PHP 致命錯誤:類別 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository 包含 1 個抽象方法,因此必須宣告為抽象方法或實作其餘方法 (AppCatalogDomainBasketRepository::getget)」
現在我在 InMemory 儲存庫中實作它。由於它只是為了測試,我創建了一個非常簡單的實作:
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? :
add「錯誤:呼叫未定義的方法 AppCatalogDomainBasket::add()」
我創建
方法並在籃子物件上實現它
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
❌? :
錯誤:呼叫未定義的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::save()
同樣,我在介面中創建方法並在記憶體存儲庫中實現它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
✅? :
目的地到達! ? ? ?
透過這個工作流程,我已經將開發者體驗遊戲化了。在完成紅色測驗的挑戰後,看到它變成綠色,就會注射多巴胺??
重構時間
為了讓測驗通過,我故意走得快一點。現在,我可以花時間完善測試並以更好的方式實現程式碼。
測試
測試翻譯了範例,但在翻譯過程中失去了使用者意圖。透過很少的函數,我可以使其更接近原始範例。
範例:
檢定:
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
程式碼
這不是本文的重點,所以我不會詳細介紹,但我可以使用豐富的領域模型更精確地表達領域模型。
由於測驗是綠色的,現在我可以隨心所欲地重構,測驗有我的支持。 ?
其他例子
正如您在文章開頭看到的,一個功能有很多範例來描述它。所以是時候全部實現它們了:
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
我已經通過不同的測試多次遵循此工作流程,並且我的程式碼的各個部分已經發展到與業務規則保持一致。正如您在第一個測試中看到的那樣,我的物件非常簡單,有時甚至是基本的。然而,隨著新業務規則的引入,它們促使我開發一個更聰明的領域模型。這是我的資料夾結構:
為了簡化測試,同時保留域模型的封裝,我引入了建構器和快照模式。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
我要如何使用它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
速度⚡
工作流程
由於我完全專注於業務邏輯,因此我的測試會在每一步中指導我,告訴我每次添加新程式碼時要做什麼。此外,我不再需要執行手動測試來確保一切正常,這顯著提高了我的效率。
在本文中,我示範了一個非常簡單的範例,其中我主要建立了類別。然而,隨著業務邏輯更加複雜,每條新規則都會增加領域模型的複雜性。在重構過程中,我的目標是簡化領域模型,同時保持測試綠色。這是一個真正的挑戰,但當它最終發揮作用時,會令人難以置信的滿足。
我在這裡展示的只是完整工作流程的第一部分。正如我所提到的,為了擁有完整的功能,我仍然需要實現真正的適配器。在本例中,這將包括使用測試優先方法和真實資料庫建立儲存庫(購物籃、客戶和產品)。完成後,我只需在依賴項注入配置中配置真正的實作即可。
測試
我使用純PHP,不依賴任何框架,只專注於封裝所有業務邏輯。有了這個策略,我就不用再擔心測試效能了。無論有數百甚至數千個測試,它們仍然會在短短幾秒鐘內運行,在編碼時提供即時回饋。
為了讓您了解效能,這裡是重複 10,000 次的測試套件:
4秒...⚡
概念
此工作流程位於多個概念的交叉點。讓我介紹其中一些,以便您可以在需要時進一步探索它們。
映射範例
為了描述一個功能,最好的方法是提供盡可能多的範例。團隊會議可以透過確定該功能如何運作的具體範例來幫助您深入了解該功能。
Given-When-Then 框架是用來形式化這些範例的一個很好的工具。
要將這些範例轉換為程式碼,Gherkin 語言(在 PHP 中使用 Behat)可能會有所幫助。然而,我個人更喜歡直接在 PHPUnit 測試中工作並使用這些關鍵字命名我的函數。
更進一步:
- Matt Wynne - 介紹範例映射
- Aslak Hellesøy - 介紹範例映射
- Kenny Baas-Schwegler - 使用 DDD 和事件風暴處理「現實生活故事」
- 馬丁·福勒 - 給定、何時、然後
第一的
「我們想要什麼?」部分可以用縮寫 F.I.R.S.T 來概括:
- 快
- 孤立
- 可重複
- 自我驗證
- 徹底
現在您有一個清單可以知道您的測驗是否做得很好。
走得更遠
- Robert C. Martin - 簡潔程式碼,第 9 章:單元測試,F.I.R.S.T
連接埠和適配器、六邊形、洋蔥形和簡潔架構
所有這些架構的目的是將業務邏輯與實作細節隔離。無論您使用連接埠和適配器、六角形還是乾淨架構,核心思想都是使業務邏輯框架與框架無關且易於測試。
一旦您記住了這一點,就會有各種各樣的實現,而最好的實現取決於您的上下文和偏好。這種架構的一個主要優點是,透過隔離業務邏輯,它可以實現更有效率的測試。
走得更遠
- Alistair Cockburn - 六角形建築
- 叔叔鮑伯 - 乾淨的建築
- Herberto Graca - DDD、六邊形、洋蔥、清潔、CQRS,…我如何將它們放在一起
由外而內的鑽石測試策略
我猜你已經熟悉了測試金字塔,相反我更喜歡使用菱形表示。 Thomas Pierrain 描述的策略是用例驅動的,重點關注我們應用程式的行為。
此策略還鼓勵對應用程式進行黑盒測試,重點關注其產生的結果,而不是其產生結果的方式。這種方法使重構變得更加容易。
走得更遠
- 托馬斯皮爾蘭 - 由外而內的鑽石? TDD #1 - 一種由(並為)普通人打造的風格
- Thomas Pierrain - 由外而內的鑽石? TDD #2(風格剖析)
- Thomas Pierrain - 使用「由外而內的鑽石」編寫反脆弱和領域驅動測試 ◆ TDD
一廂情願的編程
正如您在整個工作流程中看到的那樣,在測試和命令處理程序中,我總是在它存在之前就寫下我想要的內容。我許下了一些「願望」。這種方法稱為一廂情願的程式設計。它允許您在決定如何實現之前想像系統的結構以及與之互動的理想方式。當與測試相結合時,它成為指導您編程的強大方法。
安排、行動、斷言
這種模式有助於有效地建立測試。首先,將系統設定為正確的初始狀態。接下來,執行狀態修改器,例如命令或使用者操作。最後,進行斷言以確保系統在操作後處於預期狀態。
走得更遠
- 比爾·韋克 - 3A – 安排、行動、斷言
最後的想法
我們已經介紹了以領域為中心的架構與驗收測試驅動開發相結合如何改變您的開發體驗。即時回饋、強大的測試以及對用戶意圖的關注使編碼不僅更有效率,而且更有趣。
嘗試這個工作流程,你可能會發現每次測試變綠時你自己都會微笑✅ ➡️ ?
請在評論中告訴我你的完美工作流程是什麼,或者你會在這個工作流程中改進什麼!
以上是在不到一秒的時間內測試您的所有業務邏輯的詳細內容。更多資訊請關注PHP中文網其他相關文章!

要保護應用免受與會話相關的XSS攻擊,需採取以下措施:1.設置HttpOnly和Secure標誌保護會話cookie。 2.對所有用戶輸入進行輸出編碼。 3.實施內容安全策略(CSP)限制腳本來源。通過這些策略,可以有效防護會話相關的XSS攻擊,確保用戶數據安全。

优化PHP会话性能的方法包括:1.延迟会话启动,2.使用数据库存储会话,3.压缩会话数据,4.管理会话生命周期,5.实现会话共享。这些策略能显著提升应用在高并发环境下的效率。

theSession.gc_maxlifetimesettinginphpdeterminesthelifespanofsessiondata,setInSeconds.1)它'sconfiguredinphp.iniorviaini_set().2)abalanceisesneededeededeedeedeededto toavoidperformance andunununununexpectedLogOgouts.3)

在PHP中,可以使用session_name()函數配置會話名稱。具體步驟如下:1.使用session_name()函數設置會話名稱,例如session_name("my_session")。 2.在設置會話名稱後,調用session_start()啟動會話。配置會話名稱可以避免多應用間的會話數據衝突,並增強安全性,但需注意會話名稱的唯一性、安全性、長度和設置時機。

會話ID應在登錄時、敏感操作前和每30分鐘定期重新生成。 1.登錄時重新生成會話ID可防會話固定攻擊。 2.敏感操作前重新生成提高安全性。 3.定期重新生成降低長期利用風險,但需權衡用戶體驗。

在PHP中設置會話cookie參數可以通過session_set_cookie_params()函數實現。 1)使用該函數設置參數,如過期時間、路徑、域名、安全標誌等;2)調用session_start()使參數生效;3)根據需求動態調整參數,如用戶登錄狀態;4)注意設置secure和httponly標誌以提升安全性。

在PHP中使用會話的主要目的是維護用戶在不同頁面之間的狀態。 1)會話通過session_start()函數啟動,創建唯一會話ID並存儲在用戶cookie中。 2)會話數據保存在服務器上,允許在不同請求間傳遞數據,如登錄狀態和購物車內容。

如何在子域名間共享會話?通過設置通用域名的會話cookie實現。 1.在服務器端設置會話cookie的域為.example.com。 2.選擇合適的會話存儲方式,如內存、數據庫或分佈式緩存。 3.通過cookie傳遞會話ID,服務器根據ID檢索和更新會話數據。


熱AI工具

Undresser.AI Undress
人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover
用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool
免費脫衣圖片

Clothoff.io
AI脫衣器

Video Face Swap
使用我們完全免費的人工智慧換臉工具,輕鬆在任何影片中換臉!

熱門文章

熱工具

禪工作室 13.0.1
強大的PHP整合開發環境

DVWA
Damn Vulnerable Web App (DVWA) 是一個PHP/MySQL的Web應用程序,非常容易受到攻擊。它的主要目標是成為安全專業人員在合法環境中測試自己的技能和工具的輔助工具,幫助Web開發人員更好地理解保護網路應用程式的過程,並幫助教師/學生在課堂環境中教授/學習Web應用程式安全性。 DVWA的目標是透過簡單直接的介面練習一些最常見的Web漏洞,難度各不相同。請注意,該軟體中

mPDF
mPDF是一個PHP庫,可以從UTF-8編碼的HTML產生PDF檔案。原作者Ian Back編寫mPDF以從他的網站上「即時」輸出PDF文件,並處理不同的語言。與原始腳本如HTML2FPDF相比,它的速度較慢,並且在使用Unicode字體時產生的檔案較大,但支援CSS樣式等,並進行了大量增強。支援幾乎所有語言,包括RTL(阿拉伯語和希伯來語)和CJK(中日韓)。支援嵌套的區塊級元素(如P、DIV),

SublimeText3 英文版
推薦:為Win版本,支援程式碼提示!

SublimeText3漢化版
中文版,非常好用