
使用NestJS和Prisma構建REST API:身份驗證
人們習慣于談論應用程序和 API 實現之間的功能約定,以便在調用 API 函數時得到正確的行為表現。調用方必須滿足某些初始要求,然后函數必須按照指定的要求執行。雖然如今的 API 規范并沒有以一種正確性證明的方式來明確正確性的標準,但是 API 函數/接口的類型聲明和文檔描述了其邏輯行為的確定性。
然而,API 函數/接口的意義不僅只有功能的正確性。它消耗了什么資源,速度有多快?人們常常根據自己對某個函數的實現做出假設,對于任何復雜的API函數或者接口,不同的人可能會給出不同的性能預期,而API 文檔很少提示執行成本高昂或者低廉。更復雜的是,當我們將應用程序針對API調整到 性能預期之后,新版本的 API 或者新的遠程服務很可能會導致整體性能的變化,甚至會導致系統的崩潰。
因此,軟件系統中API的性能約定值得更多的關注。
先看一段C語言的代碼:
fs = fopen("~abel/mydata.txt", "r");
for ( i=0; i<10000; i++) {
ch = fgetc(fs);
//處理 ch
}
函數fopen的執行預計需要一段時間,fgetc的執行預計成本較低。這在直觀上是有意義的,為了處理一個文件,一個流只需要打開一次,但是“獲取下一個字符”函數將經常被調用,也許會成千上萬次。這兩個流函數是由庫實現的,庫文檔清楚地說明了函數的功能,是函數的功能性約定。但沒有提到性能,也沒有向程序員暗示這兩個函數在性能上有著本質的不同。因此,我們基于經驗判斷性能,而不是規范。
并非所有函數都有明顯的性能屬性,例如,fseek(fs, ptr, SEEK_SET);
當目標文件的數據已經在緩沖區里時,這個函數可能性能很好。在一般情況下,它將涉及一個操作系統的調用,也許還包括 I/O操作。在冷存儲的極端條件下,這個API的執行可能需要卷動上千米的磁帶。即使在簡單的情況下,這個函數也可能成本不低,具體的實現可能只是存儲指針,并設置一個標記,這將在下一個讀取或寫入的流調用上比較困難,從而導致性能的不確定性。
鑒于此,我們可以簡單根據經驗對API的性能進行分類。
這類API函數的性能表現是恒定的,例如,isdigit 和toupper, 這兩個函數是性能恒定的。Java.util.HashMap.get在正常大小哈希表中的查找應該很快,但是哈希沖突可能會偶爾減慢的訪問速度,類似的函數還有很多。
許多API函數被設計成大多數時候都很快,但是偶爾需要調用復雜的代碼,例如,java.util.HashMap.put 在哈希表中存儲一個新條目可能會超出當前表的大小,以至于會整表放大并重新哈希所有條目。
java.util.HashMap 在公開API的性能約定方面是一個很好的例子: “這個實現為基本操作(get 和 put)提供了常量時間性能,假設哈希函數將元素正確地分散存儲桶中。對集合視圖的迭代需要與 HashMap 的‘容量’成比例的時間… ”
fgetc 的性能取決于底層流的屬性。如果是一個磁盤文件,那么該函數通常從用戶的內存緩沖區讀取,而不需要操作系統調用,但它必須偶爾調用操作系統來讀取新的緩沖區。如果是從鍵盤讀取輸入,那么實現可能會調用操作系統來讀取每個字符。
一些函數的性能隨其參數的屬性而變化,例如,要排序的數組的大小或要搜索的字符串長度。這些函數通常是數據結構或算法的實用程序,使用眾所周知的算法,不需要系統調用。我們通常可以根據對底層算法的期望來判斷性能,例如,qsort排序的平均計算復雜度是 nlog n。當使用復雜的數據結構例如 b 樹的變體等,在這些地方可能很難確定底層的具體實現,可能更難估計性能。重要的是,可預測性可能只是可能的,例如 regexec 通常是可預測的,但是有一些變態的表達會導致指數時間的爆發。
像open、 fseek、 pthread_create、許多“初始化”函數以及任何遍歷網絡的調用,大多是成本未知的。這些函數的執行成本較高,而且它們的性能常常有很大的差異。它們從池(線程、內存、磁盤、操作系統對象)中分配資源,通常需要對操作系統或I/O 資源的獨占訪問,常需要大量的初始化工作。通過網絡的調用相對于本地訪問總是昂貴的,但是成本的差異可能更大,這使得性能模型的形成變得更加困難。
線程庫是性能問題的明顯標志。Posix 標準花了很多年才穩定下來,并且在實現仍然被各種問題所困擾,基于線程的應用程序可移植性仍然存在風險。線程難以使用的一些原因有:
(1)與操作系統緊密集成,幾乎所有操作系統(包括 Unix 和 Linux)最初設計時都沒有考慮到線程;
(2)與其他庫的交互,特別是保證線程安全而導致的性能問題;
(3)線程的實現不同,表現為輕量級和重量級。
有些庫提供了執行一個函數的多種方法,通常是因為這些方法的性能差別很大。
對于API函數fgetc而言,大多數程序員被告知使用這個庫函數來獲取每個字符并不是最快的方法,注重性能的人會讀取一個大型的字符數組,并使用不同編程語言中的數組或指針操作提取每個字符。在極端情況下,應用程序可以將文件頁映射到內存頁,以避免將數據復制到數組中。例如fseek的調用,給調用方帶來了更大的負擔。
程序員總是被建議避免在程序中過早地進行優化,從而推遲了對性能的修訂。確定性能的唯一方法就是衡量性能,通常先編寫整個程序,然后再面對性能預期與實際交付之間的不匹配。
“可預測成本”的API函數性能可以根據其參數的屬性進行估計,”成本未知”的API函數也可能因為要求它們做什么而有很大的不同。在存儲設備上打開流所需的時間當然取決于底層設備的訪問時間,或許還取決于數據傳輸的速率。通過網絡協議訪問的存儲可能成本較高, 但也是可變的。
許多API函數只是在大多數時候成本較低,或者有一個低成本的預期。由于各種原因,具有“成本未知”的API函數可能表現出很大的性能差異,原因之一是函數蠕變 ,其中一般函數隨著時間的推移變得更加強大。I/O流就是一個很好的例子: 打開一個流會調用操作系統和庫中非常不同的代碼,這取決于流的類型(本地磁盤文件、網絡服務文件、管道、網絡流、內存中的字符串等)。隨著 I/O設備和文件類型范圍的擴展,性能的差異只會增加。大多數 API 有著相同的命運,隨著時間的推移逐步增加功能,不可避免地增加了性能變化。
另一個很大的變化來源是不同平臺間庫的移植差異。當然,平臺的底層硬件和操作系統會有所不同,但是庫的移植可能會導致 API 內的相對性能或 API 間性能的變化。對于一個初始的庫移植版本而言,存在許多性能問題并不罕見,這些問題都是逐步修復的。有些線程庫的移植性能差異非常大,線程異常可能以極端的形式出現,應用程序可能會極其緩慢甚至是死鎖。
這些差異可能是難以建立API性能約定的原因,通常不需要精確地了解性能,但是需要根據預期行為的極端變化考慮可能會導致的問題。
API 的說明一般包括了調用失敗時的行為細節。返回錯誤代碼和拋出異常是告訴調用方API未執行成功的常用方法。但是,與正常的API行為一樣,沒有指定故障發生時的性能。這里有三個典型的場景:
對于API調用失敗時的性能,在直覺上很少像對于正常調用時性能的直覺那樣好。原因之一是編寫、調試和調優程序提供的處理故障事件的經驗遠遠少于處理普通事件的經驗。另一個原因是,API調用可能在許多方面出現故障,其中一些是致命的,而且并非所有的調研失敗都會在 API 規范中描述。即使是精確地描述了錯誤處理的異常機制,也不能使所有可能的異常都可見。此外,隨著庫功能的增加和增強,失敗的機會也在增加。例如,封裝了網絡服務的API (ODBC/JDBC/UPnP …)訂閱了大量的網絡故障機制。一個勤奮的程序員會盡量處理可能的調用失敗用例。一種常見的技術是用 try… catch 塊包圍程序的大部分,這些塊可以重試失敗的整個部分。
處理暫停或死鎖的唯一方法是設置一個看門狗線程,該線程期望一個正常運行的應用程序定期向看門狗發送通知,說明“我仍在正常運行。”如果間隔的時間過長,看門狗就會采取行動,例如,保存狀態、中止主線程或者重新啟動整個應用程序等。如果一個交互式程序調用可能緩慢失敗的API函數來響應用戶的命令,可以使用看門狗終止整個命令,并返回到一個已知的狀態,允許用戶繼續執行其他命令。這就產生了一種防御式的編程風格。
為什么 API 必須遵守性能約定呢?因為應用程序的主要結構可能取決于 API 是否遵守了這樣的性能約定。程序員根據性能期望選擇 API、數據結構和整個程序結構。如果預期或性能嚴重錯誤,程序員不能僅僅通過調優 API 調用來恢復,而是必須重寫程序的主要部分。
實際上, 明確性能約定的程序較難與不遵守性能約定的APi相配合。當然,有許多程序的結構和性能很少受到庫性能的影響。然而,如今許多的“常規 業務程序”,特別是基于 web 服務的軟件,廣泛使用了對整體性能至關重要的庫。
即使性能上的微小變化也會導致用戶對程序的感知發生重大變化,在處理各種媒體的程序中尤其如此。偶事實上,比起允許幀速率滯后而言,而放棄視頻流的幀可能是可以接受的,但是人們可以檢測到音頻中的輕微中斷,因此音頻媒體性能的微小變化可能會產生重大影響。這種擔憂引起了人們對服務質量概念的極大興趣,在許多方面,服務質量是為了確保高性能。
盡管違反性能約定的情況較少,而且較少出現災難性的事故,但在使用軟件庫時注意性能可以幫助我么生成更健壯的軟件。以下是一些關注點和使用策略。
如果我們有幸從頭開始編寫一個程序,那么在開始編寫時,最好考慮一下性能約定的含義。如果這個程序一開始是一個原型,然后在服務中保持一段時間,那么毫無疑問它至少會被重寫一次; 重寫是一個重新思考 API 和結構選擇的機會。
一個新的實驗性 API 也會吸引某些用戶。此后,更改性能約定肯定會激怒開發人員,并可能導致他們重寫自己的程序。一旦 API 成熟,性能約定的不變性就很重要了。事實上,大多數通用 API (例如 libc)之所以變得如此,部分原因在于它們的性能約定在 其API 發展的過程中是穩定的。
人們可能希望 API 的開發者能夠定期測試新版本,以驗證它們沒有引入性能衰退。不幸的是,這樣的測試很少進行。但是,這并不意味著我們不能對依賴的 API 進行自己的測試。使用分析器,通常可以發現程序依賴的那些API。編寫一個性能測試套件,將一個庫的新版本與早期版本的性能記錄進行比較,這樣可以給程序員提供一個早期警告,隨著新庫的發布,他們自己代碼的性能將發生變化。
許多程序員希望計算機及其軟件能夠一致地隨著時間的推移而變得更快。也就是說,希望一個庫或一個計算機系統的每個新版本都能平等地提高所有 API 函數的性能,這實際上對于供應商來說是很難保證的。許多用戶希望圖形庫、驅動程序和硬件的新版本能夠提高所有圖形應用程序的性能,但他們同樣熱衷于多種功能的改進,這通常會降低舊功能的性能,可能只是輕微地降低。
人們也可以希望 API 規范將性能約定明確化,這樣在使用、修改或移植代碼的時候就能遵守約定。注意,函數對動態內存分配的使用,無論是隱式的還是自動的,都應該是API文檔的一部分。
在調用性能未知或高可變的 API 函數時,程序員可以使用特殊的注意事項,異常處理優先。我們可以將初始化移到性能關鍵區域之外,并嘗試預熱 API 可能使用的任何緩存數據(例如字體)。對于表現出大量性能差異或擁有大量內部緩存數據的 API 而言, 可以通過提供助手函數將關于如何分配或初始化這些結構的提示從應用程序傳遞給 API。健康檢測可以建立一個可能不可用的服務器列表,從而避免一些長時間的故障暫停。
有些庫提供了影響其API性能的明確方法,例如,分配給文件的緩沖區大小、表的初始大小或緩存的大小等。操作系統還提供了調優選項,調整這些參數可以在性能約定的范圍內提高性能。調優雖然不能解決總體問題,但可以減少嵌入在庫中的固定選項,那些選項可能會嚴重影響性能。
有些庫提供具有相同語義函數的替代實現,通過選擇最好的具體實現進行調優會比較容易。Java Collection就是這種結構的一個很好的例子。越來越多的 API被設計用于動態地適應使用,使程序員無需選擇最佳的參數設置。如果一個哈希表滿了,它會自動擴展并重新哈希。如果一個文件是按順序讀取的,那么就可以分配更多的緩沖區,以便在更大的塊中讀取。
定期進行概要分析,從可信賴的基礎上衡量性能偏差。
常見建議是檢測關鍵數據結構,以確定每個結構是否正確使用。例如,可以測量哈希表的完整程度或發生哈希沖突的頻率。或者,可以驗證一個以寫性能為代價而設計的快速讀取結構。添加工具來準確地度量許多 API 調用的性能是困難的,這需要大量的工作,而且可能不值得。然而,在那些對應用程序的性能至關重要的 API 調用上添加工具 ,可以在出現問題時會節省大量時間。
所有這些都不是為了阻止開發自動化儀表和測量的工具,或者開發詳細說明性能約定的方法。這些目標并不容易實現,回報可能也不會很大。通常可以在沒有事先檢測軟件的情況下進行性能度量,例如,使用 DTrace等工具,優點是在出現問題之前不需要任何工作。它們還可以幫助診斷當修改代碼或庫影響性能時出現的問題。
當分布式服務組成一個復雜的系統時,可能會出現越來越多的違反性能約定的行為。在許多配置中,度量過程偶爾會發出服務請求,以檢查 SLA 是否滿足由于這些服務對性能的要求,例如, XML-RPC、 SOAP 或 REST在網絡連接上的調用。應用程序會檢測這些服務的失敗,并且通常會適應得當。然而,響應緩慢,特別是當有許多這樣的服務互相依賴時,會很快破壞系統性能。
如果這些服務的客戶端能夠記錄他們所期望的性能,并生成有助于診斷問題的日志 ,那將會很有幫助。當你的文件備份看起來不合理的慢,那是不是比昨天慢呢?比操作系統更新之前還要慢?或者是否有一些合理的解釋,例如,備份系統發現一個損壞的數據結構并開始一個長的過程來重新構建它)?
診斷不透明軟件組合中的性能問題需要軟件在報告性能和發現問題方面發揮作用。雖然我們不能在軟件內部解決性能問題 ,但可以對操作系統和網絡進行調整或修復。如果備份設備由于磁盤幾乎已滿而速度較慢,那么我們會斷定可以添加更多的磁盤空間。好的日志和相關的工具會有所幫助,日志在計算機系統演進中是一個被低估和忽視的領域,可以參考《日志分析的那些挑戰》和《全棧必備 Log日志》。
軟件系統依賴于各種獨立組件的組合來工作,這意味著它們以可接受的速度執行所需的計算。靜態檢查是難保證系統的性能的,軟件工程實踐已經開發出了測試組件和組合的方法,這些方法可以工作得非常好。每次應用程序綁定到動態庫或在操作系統接口上時,都需要驗證組合的正確性和API的性能約定。
誠然,API的性能約定沒有功能正確性約定那么重要,但是軟件系統的核心體驗往往取決于它。