1. 概述
該方案寫作目的在於描述一個基於Locust實現的壓力測試,文中詳細地描述瞭如何利用locustfile.py檔案定義期望達成的測試用例,並利用Locust對目標站點進行併發壓力測試。
特別說明:
本文件所使用的 Locust 環境一鍵安裝自 Rainbond 開源應用商店中的 Locust 應用。版本為0.14.4
,更高版本的特性和語法,煩請參見 Locust 官方文件。
關於Locust這個壓力測試工具,其官網與文件,請關注如下連結:
如果不想閱讀英文文件,那麼強烈建議先讀如下連結中的內容:
接下來,我將重點講解不同場景下的 locustfile.py 的寫作方法。
首先,我們聊一聊什麼是 locustfile.py。
2. 什麼是locustfile.py
Locust通過識別 /locustfile.py
來獲悉壓力測試任務的細節,這個檔案的路徑當前是預設值。
如果你利用Rainbond應用市場一鍵安裝部署了 Locust 叢集,那麼你可以在 locust_master 元件的 環境配置 > 配置檔案設定 中找到已掛載的該檔案,如有必要,只需要修改裡面的內容,然後更新整個應用(包括 locust_master 和 locust_slave叢集)。
locustfile.py 是一個標準的 python 指令碼檔案,通過官方指定的方式,你可以在這個檔案裡定義特定的類 ,通過類例項化之後,就會“孵化”出符合定義的例項,模仿使用者對被測試的目標站點“群起而攻之”,就像蝗蟲(Locust)一樣,這就是 Locust 得名的由來。
3. 寫作的要領
locustfile.py檔案的寫作,核心是規定了兩個類(class),它們分別繼承由locust匯入的 HttpLocust 、TaskSet 兩個超類。
-
繼承自 HttpLocust 的類,是 Locust 呼叫的入口。
-
繼承自 TaskSet 的類,用於定義虛擬使用者要模擬的任務。
簡要的寫作方式如下面的程式碼所示:
from locust import HttpLocust, TaskSet, between, task
class MyTaskSet(TaskSet):
@task(1)
def task1(self):
do task1
@task(2)
def task2(self):
do task2
@task(3)
def task3(self):
do task3
@task(4)
def task4(self):
do task4
class Mytest(HttpLocust):
task_set = MyTaskSet
wait_time = between(5.0, 10.0)
在這個 locustfile.py 中,我依次做了這些事:
- 由模組 locust 匯入了類:HttpLocust, TaskSet, between, task。
- 定義了一個類,名為 MyTaskSet,該類繼承自 TaskSet,具備 TaskSet 所有的方法和屬性。這個類中,後續定義的所有新的方法,都可以視作壓力測試要執行的任務。
- 依次定義了task1 - task4 四個新的方法,如何讓 Locust 知道這些方法是要執行的任務呢?關鍵在於裝飾器
@task
,裝飾器包裝了新定義的方法,告知 Locust 這個方法是一個要執行的任務。圓括號中的數字,用來表示任務執行的權重,即一個虛擬的使用者,會執行被包裝的方法(即任務)指定的次數,需要指出的是,執行的順序是隨機的,如何定義執行順序將在後文講解。 - 定義了一個類,名為 Mytest,該類繼承自 HttpLocust,具備 HttpLocust 所有的方法和屬性。這個類通過屬性
task_set = MyTaskSet
定義Locust要執行指定的任務類(就是上一個自定義的類),通過wait_time = between(5.0, 10.0)
來定義虛擬使用者執行每個任務間隔的時間,當前的寫法,是指定間隔時間為5s至10s中的隨機值。
一個 locustfile.py 的基本框架即是如此。當然還可以有其它的方式來定義這個檔案,但是我認為這種寫作方式已經足夠,而且明瞭。
關於 wait_time
,這是一個很必要的屬性。Locust 的開發者認為,真正的使用者行為,並不會像機器人一樣迅速而接連不斷地執行所有任務。大家都會有這樣的體驗,訪問到一個頁面後,會東瞧瞧,西看看,或者乾脆發會兒呆,心滿意足之後,再點選下個頁面進行下一個流程。所以在這裡會定義 wait_time
來明確兩個任務間隔時間。
至此,我們基本可以明確,真正的難點在於如何定義任務類。至於 Mytest 這個類,大多數情況下複製上面的程式碼,就已經足夠了。
4. 典型的場景
下面我們來聊一聊,在以下幾個典型的場景下,該如何定義任務類:
- get請求
- post請求
- 獲取響應
- 猴子測試
- 流程測試
4.1 get請求
這是一種最簡單的場景,一般情況下,我們會通過 get 方法,來請求站點的靜態頁面資源,比如 /index.html 這樣的路徑。
簡要程式碼如下:
class MyGet(TaskSet):
@task
def index(self):
self.client.get("/index.html")
這就定義了一個簡單的壓測目標站點 "/index.html" 的get請求。
4.2 post請求
post請求一般用於帶著引數訪問站點的指定介面,來實現一些特定的功能,比如登陸行為。
簡要程式碼如下:
class MyPost(TaskSet):
@task
def login(self):
self.client.post("/login", {"username":"admin", "password":"mypassword"})
這就定義了一個post請求,用來攜帶著使用者名稱密碼進行一次登陸。
關於 client ,官方的介紹稱之為 Locust 例項化過程中,類 HttpSession 生成的一個例項。這個例項支援儲存 Cookies,以實現保持 Http 請求之間的 session。個人認為,沒必要細究,首先了解如何建立請求,其次需要了解在發起請求之後,如何獲取響應(response)的詳情。做法在下個小節中講述。
4.3 獲取響應
在一些請求完成後,其響應(response)的內容往往非常關鍵。在這裡最重要的有兩點:
- 返回的狀態碼。這直接標明這次請求大致的結果,預設 2xx、3xx這樣的狀態碼錶示請求成功;4xx、5xx反之,但是憑藉狀態碼不一定能夠完全判定請求的介面是否真的按照預想的情況工作,詳細的內容請見後文斷言一節。在這裡,我們需要知道狀態碼是 Locust 判定請求是否成功的預設條件。
- 返回的內容。請求介面返回的內容(一般情況下是個Json),有的時候攜帶了非常重要的資訊,比如後文要描述的對某個 CRM 系統的壓力測試例項中,我們需要通過登陸請求後返回內容中的
authKey
來定義後續請求,以實現登陸態。
Locust中的所有請求,都可以通過下面的方法獲得狀態碼和返回的內容:
獲取返回狀態碼:
response = self.client.get("/about")
print("Response status code:", response.status_code)
即例項 response.status_code 返回了這次請求的狀態碼。
獲取返回的內容:
response = self.client.get("/about")
print("Response content:", response.text) #返回字串
print("Response content:", response.json()) #返回Json,可以作為字典處理
4.4 猴子測試
假設一隻“猴子”作為使用者,它不會按照正常的業務流程去使用業務系統,而是瞎搞一氣,還沒註冊就登陸,沒有選擇商品就要付錢。這可以幫助我們發現更多在正常業務流程之外的BUG。
我們已經瞭解到如何定義一些普通的請求方式,來請求我們所有已知的介面,那麼我們只需要把這些任務統統放進我們的 TaskSet
類中,隨機定義任務被呼叫的權重即可形成一個猴子測試的任務設定。最終,我們會發現這個猴子測試使用的 locustfile.py
和我在 [寫作的要領](#3. 寫作的要領) 一節中展示的模版檔案大同小異。故此這裡就不再贅述了。
4.5 流程測試
在這裡我想表達如何進行一次對某一業務流程的順序測試。和之前的猴子測試不同,我會在這個測試中規劃一個正確的流程。如果這個流程可以走得通,並承受住大併發壓力的考驗,那麼就可以認為業務系統通過了測試。
這份 locustfile.py
和之前的模版會有一些不同,主要在於我們想要定義順序執行,而非隨機呼叫的任務。所以我們生成的任務類不再繼承自 TaskSet
,而是繼承自 TaskSequence
。後者是前者的子類,但是新增了順序呼叫的方法,搭配新的裝飾器 @seq_task
,這樣我們就可以定義所有任務的執行順序了。
流程測試是這個方案的重點,所以我專門找了一個CRM系統,來作為測試的受體,然後對 登陸 —— 獲取使用者列表 —— 新增使用者 —— 刪除使用者 —— 登出 這一流程做壓力測試。
特意指出,在這裡我不僅使用了類似 self.client.get()、self.client.post()
等相對簡單明確的方法,還使用了更基礎的 self.client.request()
方法,這個方法可以通過傳遞引數來實現和 post
以及 get
一樣的效果。瞭解更多請看看這裡。
來看看程式碼:
from locust import HttpLocust, TaskSequence, between, task, seq_task
import random, string
class MyTaskSet(TaskSequence):
login_header = {}
del_params = {}
@seq_task(1)
def login(self):
to_login = self.client.post("/index.php/admin/base/login", {"username":"184xxxxxx66", "password":"mypassword"})
self.login_header['authKey'] = to_login.json()["data"]["authKey"]
self.login_header['sessionId'] = to_login.json()["data"]["sessionId"]
self.login_header['Cookie'] = 'PHPSESSID=' + to_login.json()["data"]["sessionId"]
print(to_login.json())
@seq_task(2)
def get_customer(self):
to_get = self.client.request(method="post", url="/index.php/crm/customer/index", params={"page":1, "limit":15}, headers=self.login_header)
print(to_get.json())
@seq_task(3)
def add_customer(self):
self.add_params={"level":"A(重點客戶)", "industry":"金融業", "source":"促銷活動", "deal_status":"未成交", "telephone":"13555555555"}
self.add_params['name'] = ''.join(random.sample(string.ascii_letters + string.digits, 8)) #隨機生成客戶名
to_add = self.client.request(method="post", url="/index.php/crm/customer/save", params=self.add_params, headers=self.login_header)
self.del_params["id[0]"] = to_add.json()['data']['customer_id']
print(to_add.json())
@seq_task(4)
def del_customer(self):
to_del = self.client.request(method="post", url="/index.php/crm/customer/delete", params=self.del_params, headers=self.login_header)
print(to_del.json())
@seq_task(5)
@task(3)
def index(self):
self.client.get("/index.php/admin/system/index")
@seq_task(6)
def logout(self):
self.client.post("/index.php/admin/base/logout")
class Mytest(HttpLocust):
task_set = MyTaskSet
wait_time = between(5.0, 10.0)
接下來講解下和最開頭的模版不一樣的地方:
- 新匯入了
TaskSequence
、seq_task
,前者是TaskSet
的替代者,用於實現順序呼叫任務,後者是新的裝飾器,來定義任務的呼叫順序。 - 新匯入
random, string
模組,來為新增的使用者隨機生成使用者名稱,CRM系統規定使用者名稱不可以相同。 - 任務類
MyTaskSet
繼承自TaskSequence
。 - 通過
@seq_task()
來裝飾任務,圓括號中的數字代表執行順序。 - 為所有任務例項定義新的屬性
login_header
、del_params
,初始值均為空的字典,在任務執行過程中,會將保持登陸態所需要的header
資訊儲存進去以供其他行為呼叫、儲存新增使用者的特殊ID,以供刪除操作使用。 - 登陸過程完成後,收集請求的響應內容(Json格式),經過操作,更新例項的
login_header
屬性,將保持登陸態所使用的authKey
Cookie
sessionId
儲存起來。 - 列印每個任務的響應內容,這樣可以在後臺日志中清楚地看到每個任務的執行情況,這對於發現BUG非常有用。
- 有關如何知悉新增使用者或者刪除使用者流程中所傳遞的引數,將在下一節講解。
整個流程測試任務的重點,在於明確地規劃好整個測試的任務流程。在執行流程測試的時候,一定要保證業務是按照我們預先設計好的流程進行,這樣才可以發現在大併發壓力下,我們的業務系統是否正常表現。
對於這個例子而言,登陸後的操作如何保持登陸態?新建使用者要傳遞哪些引數?刪除使用者的憑據是什麼?這三個問題是保障整個流程順利進行的重點,而這三個問題的答案都在於請求時傳遞哪些引數。
根據官方文件介紹,client例項具備了儲存Cookie與Session的功能,但是並非所有的業務系統保持登陸態都依賴於這兩個引數。比如例子中的 CRM 系統,保持登陸態還需要獲取引數 authKey。所以,無論如何,都需要自行定義保持登陸態的操作。
5. 請求的引數
這裡所說的引數,是一個廣義的概念,實際上包括了 params
、header
,甚至在某些情況下還需要 data
、auth
等等資料。
有很多的時候,被測試的業務系統,並不是我們自己設計的,我們並不知道這些 “引數” 究竟包含什麼,在這一節,我想要闡述一個方法,可以通過瀏覽器來幫助我們獲取這些“引數”。
對於 B/S 架構程式而言,我們通過瀏覽器進行的所有操作,都是有跡可循的。開啟瀏覽器的 檢查 (一般情況下,預設快捷鍵 F12),選擇 Network 並進行操作,就可以獲悉我們通過瀏覽器到底做了什麼。
下面是一個例子,模擬了CRM系統在登陸時的行為:
-
通過
Request URL
獲取所有的路由資訊,這個URL由業務系統的域名和介面路徑組成。域名在 Locust的WEB-UI中寫入,介面路徑作為引數 url 傳遞給任務中的請求。 -
通過
Request Method
我們可以知道這是一個 POST 請求。 -
最下面,是通過
params
傳遞登陸引數,這裡定義的是使用者名稱和密碼。
結合下實際的請求,這會更利於理解:
self.client.post(url="/index.php/admin/base/login", params={"username":"184xxxxxx66", "password":"xxxxxxxx"})
實際上引數名可以省略,用以上引數,就可以獲取執行請求的全部引數了。
那麼請求成功後,會得到什麼返回呢?
切換到 Preview 或者 Response 頁面,可以得到介面的返回,建議看 Preview 。
- 在這裡,我們最想要得到的是
authKey
和sessionId
這兩個值,結合與sessionId
相關的Cookie
就可以獲得保持登陸態的所有引數。
接下來,我們來執行列示使用者的操作,訪問使用者管理頁面,選擇使用者,可以得到 getField
這個介面的請求資訊,這裡要重點關注 Request Header
部分,因為我們需要從這裡獲悉如何保持登陸態。
看到這裡,我們就明確了,為什麼在登陸時會返回 authKey
和 sessionId
,其實是在請求頭 (Request Header)加入這些資訊來保持登陸態。
通過分析瀏覽器中的請求資訊,我們就可以知道特定的流程中瀏覽器為我們做了哪些事,接著就可以去定義測試任務了。
6. 斷言
前文已經說過,Locust 預設通過請求返回的狀態碼來判斷這次請求是否成功,但是這還不夠。我在測試CRM的時候,發現了一個問題。
我沒有執行登陸操作,直接去列示了所有使用者,這個操作返回給我如下回復:
>>> to_get = l.client.request(method="post", url="/index.php/crm/customer/index", params={"page":1, "limit":15})
>>> to_get.status_code
200
>>> to_get.json()
{'code': 101, 'error': '請先登入'}
狀態碼返回200,這將導致Locust 認為該請求執行成功。
CRM的處理也是正確的,我沒有登陸,卻請求了這個埠,CRM非常明確地處理了這個問題,告訴我“請先登入”。
但是從我自定義的業務流程上講,這是個錯誤,我並沒有獲取我預期的所有使用者的列表。我希望在Locust的結果分析中,將這種情況視作失敗的請求,所以,我需要一個斷言。
斷言就是分析請求的返回,即使狀態碼返回2xx、3xx,也可以根據返回內容將任務判定為失敗狀態,反之亦然。
Locust 中斷言的實現,是在請求中設定 catch_response=True
引數來抓獲返回,再進行判斷。
我們來優化CRM測試任務中有關get_customer
方法的程式碼:
@task
def get_customer(self):
with self.client.request(method="post", url="/index.php/crm/customer/index", params={"page":1, "limit":15}, catch_response=True) as to_get:
if to_get.json() == {'code': 101, 'error': '請先登入'}:
to_get.failure("它告訴我要先登陸")
執行壓力測試後,我的目的達到了:
結果分析也丟擲了我已經設定好的訊息:
7. 壓力
使用Locust的一大好處就是它可以模擬很大的壓力,這裡我不去描述它在實現原理上有何優勢,而是想向大家介紹它的分散式特點。Locust支援主從叢集模式,主節點(locust_master)負責壓力測試任務的排程,從節點(locust_slave)負責具體的使用者模擬和測試的執行。其中,從節點支援分散式部署。也就是說,如果需要,就可以使用Rainbond的橫向伸縮功能擴充套件出很多 locust_slave 的例項,能夠模擬的壓力也就隨之增大。
這一節,我們來描述在WEB-UI介面中如何定義壓力。
訪問 locust_master 服務元件的 8089 埠,會進入到WEB-UI介面,並開始規劃一個新的壓力併發。
-
第一個值規定了本次測試的最大模擬使用者數量,即併發。
-
第二個值規定每秒“孵化”的使用者數量,如圖中的配置,開始測試後,Locust將在十秒鐘內啟動100個使用者。
-
測試站點的域名,這裡有一點要注意,就是一定要帶協議頭,即 "http://"。
那麼,Locust能提供多大壓力,是否有個衡量呢?我執行的一個測試顯示,3個slave的 Locust可以輕鬆提供5000併發。
而被壓測的CRM已經超出了能接受的壓力極限,開始出現大量的錯誤,使用的記憶體急劇飆升到了90%以上,Locust 得出的 Failures 頁面報告了錯誤產生的原因。
8. 結果分析
藉助Locust提供的WEB-UI介面,我們可以非常方便地分析壓力測試結果。
Statistics頁面將向我們展示所有被壓測介面的彙總報告,結果包括:
請求總數、失敗次數、中位數響應時間、90%請求響應時間、平均響應時間、最小響應時間、最大響應時間、請求的平均大小、當前吞吐率、當前錯誤率。
Charts頁面將主要結果繪製成為隨時間變化的圖表,能夠在趨勢上給予使用者指引。
除了這些之外,還有幾項值得關注的值會在最上面一排全域性展示,包括當前請求的主機域名、當前產生的併發使用者數量、slave節點數量、當前所有請求介面的總吞吐率、錯誤率,以及停止測試的按鈕。
其它的幾個頁面會提供請求失敗的介面及失敗原因(Failures)、測試中意外的錯誤以及錯誤原因(Expections)、csv格式的測試資料下載地址(Download Data)、 所有slave例項的資訊(Slaves)。
所有的資料都基於圖形展示,十分方便。
9. 寫在最後
Locust 是一個相當不錯的壓力測試工具,可自由定製的東西很多,我寫在這個方案中的種種用法還不足以囊括它的所有特性,但是 Good Enough is Best ,這個方案已經可以應付絕大多數場景了。
Rainbond 是一個開源的雲原生應用管理平臺,使用簡單,不需要懂容器和Kubernetes,支援管理多個Kubernetes叢集,提供企業級應用的全生命週期管理,功能包括應用開發環境、應用市場、微服務架構、應用持續交付、應用運維、應用級多雲管理等。