接口撥測 Plus 版本,你知道多少?
之前寫了一個《開發一個接口監控的Prometheus Exporter》,當時只是單純的實現了一個簡單的Exporter,但是基本能滿足要求,最近對接口監控的需求做了升級,主要有:
- 接口的管理通過前端頁面實現,將數據存入數據庫
- 接口的校驗除了可以校驗狀態碼,還增加了返回值校驗
- 前端頁面可以顯示當前接口的可用性百分比
- 撥測項可以靈活配置
- 撥測頻率可以靈活調整
- 撥測結果校驗可以靈活配置
- 可以靈活開啟或關閉撥測
功能的實現方式比較簡單,梳理如下:
- 用戶創建撥測任務,將任務存入數據庫
- 后端為新的撥測起一個定時任務
- 后端協程實時監聽更新或者刪除操作,更新定時任務
- 撥測任務生成Prometheus指標,供Prometheus收集做監控告警使用
下面簡單總結后端的實現和前端的效果。
Tips: 整個項目是使用gin-vue-admin搭建,撥測只是其中一個小功能。
后端實現
// DialApi 撥測 結構體
type DialApi struct {
global.GVA_MODEL
Name string `json:"name" form:"name" gorm:"column:name;default:'';comment:接口名稱;size:32;"` //接口名稱
Type string `json:"type" form:"type" gorm:"column:type;default:'';comment:撥測類型 HTTP TCP PING DNS;size:8;"` // 撥測類型
HttpMethod string `json:"httpMethod" form:"httpMethod" gorm:"column:http_method;default:GET;comment:HTTP請求方法;size:8;"` //HTTP請求方法
Url string `json:"url" form:"url" gorm:"column:url;comment:撥測地址;size:255;"binding:"required"` //撥測地址
RequestBody string `json:"requestBody" form:"requestBody" gorm:"column:request_body;comment:請求BODY;size:255;"` //撥測地址
Enabled *bool `json:"enabled" form:"enabled" gorm:"column:enabled;default:false;comment:是否啟用;"binding:"required"` //是否啟用
Application string `json:"application" form:"application" gorm:"column:application;comment:所屬應用;size:32;"` //所屬應用
ExceptResponse string `json:"exceptResponse" form:"exceptResponse" gorm:"column:except_response;comment:預期返回值;size:32;"` //預期返回值
HttpStatus int `json:"httpStatus" form:"httpStatus" gorm:"column:http_status;type:smallint(5);default:200;comment:預期狀態碼;size:16;"` //預期狀態碼
Cron string `json:"cron" form:"cron" gorm:"column:cron;comment:cron表達式;size:20;"` //cron表達式
SuccessRate string `json:"successRate" form:"successRate" gorm:"column:success_rate;comment:撥測成功率"`
CreatedBy uint `gorm:"column:created_by;comment:創建者"`
UpdatedBy uint `gorm:"column:updated_by;comment:更新者"`
DeletedBy uint `gorm:"column:deleted_by;comment:刪除者"`
}
在結構體中,主要定義撥測相關字段,比如撥測地址,返回值,狀態碼,撥測頻率等,這些字段都通過前端頁面填寫。
然后就是對撥測任務的增刪改查,這類接口比較通用,可以直接復制gin-vue-admin中的實例進行修改。
(2)對于新創建的撥測任務,需要將其加入到定時任務中。在這里做了偷懶,直接使用gin-vue-admin的定時任務功能。因此,需要實現一個Run方法,如下:
type StartDialApi struct{}
type StartSingleDialApiTask struct{}
func (j *StartDialApi) Run() {
var dialService = service.ServiceGroupApp.DialApiServiceGroup.DialApiService
// 獲取狀態為打開的定時任務
pageInfo := dialApiReq.DialApiSearch{}
dialApiInfoList, _, err := dialService.GetDialApiInfoList(pageInfo)
if err == nil {
var option []cron.Option
option = append(option, cron.WithSeconds())
for _, dialApi := range dialApiInfoList {
// 將cron的值變成表達式
c := utils.ConvertToCronExpression(dialApi.Cron)
dialApi.Cron = c
dialService.AddSingleDialApiTimerTask(dialApi)
}
} else {
global.GVA_LOG.Error("獲取撥測任務列表失敗")
}
}
然后會調用dialService.AddSingleDialApiTimerTask實現定時任務的真正操作。
func (dialService *DialApiService) AddSingleDialApiTimerTask(dialApiEntity dialApi.DialApi) {
var option []cron.Option
option = append(option, cron.WithSeconds())
idStr := strconv.Itoa(int(dialApiEntity.ID))
cronName := global.DIAL_API + idStr
taskName := global.DIAL_API + idStr
task, found := global.GVA_Timer.FindTask(cronName, taskName)
if !found {
if *dialApiEntity.Enabled {
_, err := global.GVA_Timer.AddTaskByFunc(cronName, dialApiEntity.Cron, func() {
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "success").Add(0)
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "failed").Add(0)
switch dialApiEntity.Type {
case "HTTP":
ok := checkHTTP(dialApiEntity)
if ok {
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "success").Add(1)
} else {
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "failed").Add(1)
}
// 記錄日志
logHealthCheckResult(ok, nil, dialApiEntity, "HTTP")
// 獲取Prometheus指標并存入數據庫
getSuccessRateFromPrometheus(dialApiEntity)
case "TCP", "DNS", "ICMP":
var ok bool
var err error
switch dialApiEntity.Type {
case "TCP":
ok, err = checkTCP(dialApiEntity)
case "DNS":
ok, err = checkDNS(dialApiEntity)
case "ICMP":
ok, err = checkICMP(dialApiEntity)
}
if ok {
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "success").Add(1)
} else {
global.HealthCheckResults.WithLabelValues(dialApiEntity.Name, dialApiEntity.Type, "failed").Add(1)
}
// 記錄日志
logHealthCheckResult(ok, err, dialApiEntity, dialApiEntity.Type)
// 獲取Prometheus指標并存入數據庫
getSuccessRateFromPrometheus(dialApiEntity)
default:
global.GVA_LOG.Error("未知的檢測類型",
zap.String("DetectType", dialApiEntity.Type),
)
}
}, global.DIAL_API+idStr, option...)
if err != nil {
global.GVA_LOG.Error(fmt.Sprintf("添加撥測定時任務失敗: %s : %s , 原因是: %s", idStr, dialApiEntity.Name, err.Error()))
}
}
} else {
if task.Spec != dialApiEntity.Cron {
global.GVA_LOG.Info(fmt.Sprintf("修改定時任務時間: %s", dialApiEntity.Name))
global.GVA_Timer.Clear(global.DIAL_API + idStr)
dialService.AddSingleDialApiTimerTask(dialApiEntity)
} else if !*dialApiEntity.Enabled || dialApiEntity.DeletedAt.Valid {
global.GVA_LOG.Info(fmt.Sprintf("停止撥測任務: %s", dialApiEntity.Name))
global.GVA_Timer.RemoveTaskByName(cronName, taskName)
}
}
}
在該方法中,先判斷定時任務是否已經存在,只有不存在且開啟撥測的任務才會加入定時任務。否則,就會執行修改或者刪除邏輯。
另外,為了方便前端顯示撥測成功率,每次執行任務的時候會計算一次成功率,這里采用的是直接計算Prometheus指標,使用getSuccessRateFromPrometheus方法實現,如下:
func getSuccessRateFromPrometheus(dialApiEntity dialApi.DialApi) {
// 查詢prometheus獲取過去1小時的成功率
successQuery := fmt.Sprintf(`sum(rate(health_check_results{name="%s", type="%s", status="success"}[1h]))`, dialApiEntity.Name, dialApiEntity.Type)
totalQuery := fmt.Sprintf(`sum(rate(health_check_results{name="%s", type="%s"}[1h]))`, dialApiEntity.Name, dialApiEntity.Type)
successResponse, err := utils.QueryPrometheus(successQuery, global.GVA_CONFIG.Prometheus.Address)
if err != nil {
global.GVA_LOG.Error("Failed to query success rate from Prometheus", zap.Error(err))
return
}
totalResponse, err := utils.QueryPrometheus(totalQuery, global.GVA_CONFIG.Prometheus.Address)
if err != nil {
global.GVA_LOG.Error("Failed to query total rate from Prometheus", zap.Error(err))
return
}
// 解析 Prometheus 響應并計算成功率
var successValue float64
var totalValue float64
if len(successResponse.Data.Result) > 0 {
for _, result := range successResponse.Data.Result {
if value, ok := result.Value[1].(string); ok {
if value, err := strconv.ParseFloat(value, 64); err == nil {
successValue = value
}
}
}
}
if len(totalResponse.Data.Result) > 0 {
for _, result := range totalResponse.Data.Result {
if value, ok := result.Value[1].(string); ok {
if value, err := strconv.ParseFloat(value, 64); err == nil {
totalValue = value
}
}
}
}
if totalValue > 0 {
successRate := CalculateSuccessRate(successValue, totalValue)
// 獲取數據庫中最新的值
var dialService = DialApiService{}
dial, err := dialService.GetDialApi(strconv.Itoa(int(dialApiEntity.ID)))
if err != nil {
global.GVA_LOG.Error("獲取任務失敗", zap.String("err", err.Error()))
}
successRateStr := fmt.Sprintf("%.2f", successRate)
if dial.SuccessRate != successRateStr {
dial.SuccessRate = successRateStr
err := dialService.UpdateDialApi(dial)
if err != nil {
global.GVA_LOG.Error("更新任務成功率失敗", zap.String("err", err.Error()))
return
}
}
}
}
// CalculateSuccessRate 計算成功率
func CalculateSuccessRate(success, total float64) float64 {
if total == 0 {
return 0
}
return (success / total) * 100 // 返回百分比形式的成功率
}
另外,撥測任務支持HTTP、TCP、DNS以及ICMP(ICMP功能未完善),代碼如下:
func checkHTTP(dialApiEntity dialApi.DialApi) bool {
idStr := strconv.Itoa(int(dialApiEntity.ID))
var response *http.Response = nil
var httpErr error = nil
switch dialApiEntity.HttpMethod {
case "GET":
response, httpErr = http.Get(dialApiEntity.Url)
break
case "POST":
response, httpErr = http.Post(dialApiEntity.Url, "application/json", strings.NewReader(dialApiEntity.RequestBody))
break
default:
}
if response != nil {
dialApiRecrod := new(dialApi.DialApiRecrod)
dialApiRecrod.DialApiId = dialApiEntity.ID
dialApiRecrod.CreatedAt = time.Now()
dialApiRecrod.UpdatedAt = time.Now()
if httpErr == nil {
if response.StatusCode == dialApiEntity.HttpStatus {
// 如果定義了返回值判斷
if dialApiEntity.ExceptResponse != "" {
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return false
}
if strings.Contains(string(bodyBytes), dialApiEntity.ExceptResponse) {
return true
} else {
return false
}
} else {
return true
}
} else {
global.GVA_LOG.Info(idStr + ":" + dialApiEntity.Name + "撥測結果與預期不一致")
return false
}
} else {
global.GVA_LOG.Error("撥測失敗: " + dialApiEntity.Url)
dialApiRecrod.FailReason = httpErr.Error()
return false
}
}
return false
}
func checkTCP(dialApiEntity dialApi.DialApi) (bool, error) {
conn, err := net.DialTimeout("tcp", dialApiEntity.Url, 5*time.Second)
if err != nil {
return false, err
}
defer conn.Close()
return true, nil
}
func checkDNS(dialApiEntity dialApi.DialApi) (bool, error) {
_, err := net.LookupHost(dialApiEntity.Url)
if err != nil {
return false, err
}
return true, nil
}
func checkICMP(dialApiEntity dialApi.DialApi) (bool, error) {
pinger, err := ping.NewPinger(dialApiEntity.Url)
if err != nil {
return false, err
}
pinger.Count = 2
err = pinger.Run() // Blocks until finished.
if err != nil {
return false, err
}
return true, nil
}
其中HTTP撥測是比較常用的,相比之前的Prometheus Exporter,這里豐富了對結果的校驗,使撥測的結果值更準確。
(3)如果遇到撥測任務的更新或者刪除,有一個定時的協程去處理。如下:
func startUpdateDialCron() {
var dialService = service.ServiceGroupApp.DialApiServiceGroup.DialApiService
for {
select {
case updateId := <-global.UpdateDialAPIChannel:
// 獲取數據
if updateId != "" {
dial, err := dialService.GetDialApi(updateId)
if err != nil {
global.GVA_LOG.Error("獲取任務失敗", zap.String("err", err.Error()))
continue
} else {
// 先刪除舊的定時任務
global.GVA_LOG.Info("更新定時任務", zap.String("updateId", updateId))
cronName := global.DIAL_API + updateId
taskName := global.DIAL_API + updateId
if _, found := global.GVA_Timer.FindTask(cronName, taskName); found {
global.GVA_Timer.Clear(cronName)
// 啟動新的定時任務
// 將cron的值變成表達式
c := utils.ConvertToCronExpression(dial.Cron)
dial.Cron = c
dialService.AddSingleDialApiTimerTask(dial)
}
}
}
case deleteId := <-global.DeleteDialAPIChannel:
if deleteId != "" {
cronName := global.DIAL_API + deleteId
taskName := global.DIAL_API + deleteId
if _, found := global.GVA_Timer.FindTask(cronName, taskName); found {
global.GVA_LOG.Info("刪除定時任務", zap.String("updateId", deleteId))
global.GVA_Timer.RemoveTaskByName(cronName, taskName)
}
}
}
}
}
該協程監聽global.UpdateDialAPIChannel和global.DeleteDialAPIChannel這兩個channel,然后再調用dialService.AddSingleDialApiTimerTask對定時任務進行操作。
上面就是簡單的接口撥測的功能實現,因能力有限,所以代碼比較混亂。
前端展示
為了便于日常的維護,所以開發一個前端界面,主要支持撥測任務的增刪改查。
新增撥測任務,可以靈活選擇撥測類型以及定義返回值和狀態碼。
然后可以查看撥測任務的具體情況,也可以靈活開啟或者關閉或者任務。
監控告警
在前端頁面只是展示了成功率,實際告警還是通過Prometheus實現,該平臺暫未實現直接配置告警。
所以,只需要創建一個Prometheus收集的Job,就可以查看對應的指標,指標名是health_check_results,如下:
然后再配置一個告警規則,在使用率低于100%的時候發送告警通知,如下:
至此,整個功能就實現了,足夠滿足日常使用。在公有云上,是有成熟的撥測產品,不過有的收費比較貴,好處是可以實現不同地區的撥測,覆蓋面比較廣。另外,也可以使用Black Exporter實現撥測,其也支持上面的所有功能,只是沒有前端的維護界面,不過功能強大很多。