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集群,提供企業級應用的全生命周期管理,功能包括應用開發環境、應用市場、微服務架構、應用持續交付、應用運維、應用級多雲管理等。