// Package taskq 提供基于 Redis 的异步任务队列功能 // inspect.go 文件包含任务队列的监控和检查功能 package taskq import ( _ "embed" "encoding/json" "fmt" "html/template" "net/http" "sort" "strconv" "strings" "time" "github.com/hibiken/asynq" ) //go:embed dashboard.html var dashboardHTML string // InspectOptions 配置监控服务的选项 type InspectOptions struct { // RootPath 监控服务的根路径 // 默认为 "/monitor" RootPath string // ReadOnly 是否只读模式,禁用所有修改操作 // 默认为 false ReadOnly bool } // HTTPHandler 监控服务的 HTTP 处理器 type HTTPHandler struct { router *http.ServeMux rootPath string readOnly bool inspector *asynq.Inspector } // NewInspectHandler 创建新的监控处理器 // 使用全局的 redisClient 创建 asynq.Inspector func NewInspectHandler(opts InspectOptions) (*HTTPHandler, error) { if redisClient == nil { return nil, fmt.Errorf("taskq: redis client not initialized, call SetRedis() first") } // 设置默认值 if opts.RootPath == "" { opts.RootPath = "/monitor" } // 确保路径以 / 开头且不以 / 结尾 if !strings.HasPrefix(opts.RootPath, "/") { opts.RootPath = "/" + opts.RootPath } opts.RootPath = strings.TrimSuffix(opts.RootPath, "/") // 创建 asynq inspector inspector := asynq.NewInspectorFromRedisClient(redisClient) handler := &HTTPHandler{ router: http.NewServeMux(), rootPath: opts.RootPath, readOnly: opts.ReadOnly, inspector: inspector, } handler.setupRoutes() return handler, nil } // ServeHTTP 实现 http.Handler 接口 func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) } // RootPath 返回监控服务的根路径 func (h *HTTPHandler) RootPath() string { return h.rootPath } // Close 关闭 inspector 连接 func (h *HTTPHandler) Close() error { return h.inspector.Close() } // setupRoutes 设置路由 func (h *HTTPHandler) setupRoutes() { // API 路由 apiPath := h.rootPath + "/api/" h.router.HandleFunc(apiPath+"queues", h.handleQueues) h.router.HandleFunc(apiPath+"queues/", h.handleQueueDetail) h.router.HandleFunc(apiPath+"tasks/", h.handleTasks) // 主页路由 h.router.HandleFunc(h.rootPath+"/", h.handleDashboard) h.router.HandleFunc(h.rootPath, h.handleDashboard) } // handleQueues 处理队列列表请求 func (h *HTTPHandler) handleQueues(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 获取所有队列信息 asynqQueues, err := h.inspector.Queues() if err != nil { http.Error(w, fmt.Sprintf("Failed to get queues: %v", err), http.StatusInternalServerError) return } fmt.Println("Redis中的队列:", asynqQueues) fmt.Println("注册的队列:", queues) // 获取每个队列的详细信息 type QueueInfo struct { Name string `json:"name"` Priority int `json:"priority"` Active int `json:"active"` Pending int `json:"pending"` Retry int `json:"retry"` Archived int `json:"archived"` } var queueInfos []QueueInfo // 首先显示所有注册的队列(即使Redis中还没有任务) for queueName, priority := range queues { stats, err := h.inspector.GetQueueInfo(queueName) if err != nil { // 如果队列不存在,创建一个空的状态 queueInfos = append(queueInfos, QueueInfo{ Name: queueName, Priority: priority, Active: 0, Pending: 0, Retry: 0, Archived: 0, }) } else { queueInfos = append(queueInfos, QueueInfo{ Name: queueName, Priority: priority, Active: stats.Active, Pending: stats.Pending, Retry: stats.Retry, Archived: stats.Archived, }) } } // 按优先级排序 sort.Slice(queueInfos, func(i, j int) bool { return queueInfos[i].Priority > queueInfos[j].Priority }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(queueInfos) } // handleQueueDetail 处理队列详情请求 func (h *HTTPHandler) handleQueueDetail(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 从 URL 中提取队列名称 path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/queues/") parts := strings.Split(path, "/") if len(parts) == 0 || parts[0] == "" { http.Error(w, "Queue name is required", http.StatusBadRequest) return } queueName := parts[0] // 检查队列是否已注册 if _, exists := queues[queueName]; !exists { http.Error(w, "Queue not found", http.StatusNotFound) return } // 获取队列详细信息 stats, err := h.inspector.GetQueueInfo(queueName) if err != nil { http.Error(w, fmt.Sprintf("Failed to get queue info: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } // 转换任务信息 type TaskInfo struct { ID string `json:"id"` Type string `json:"type"` Payload string `json:"payload"` Queue string `json:"queue"` Retried int `json:"retried"` LastFailed string `json:"last_failed,omitempty"` LastError string `json:"last_error,omitempty"` NextProcess string `json:"next_process,omitempty"` CompletedAt string `json:"completed_at,omitempty"` } // handleTasks 处理任务列表请求 func (h *HTTPHandler) handleTasks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 从 URL 中提取队列名称和任务状态 path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/tasks/") parts := strings.Split(path, "/") if len(parts) < 2 { http.Error(w, "Queue name and task state are required", http.StatusBadRequest) return } queueName := parts[0] taskState := parts[1] // 检查队列是否已注册 if _, exists := queues[queueName]; !exists { http.Error(w, "Queue not found", http.StatusNotFound) return } // 解析分页参数 page := 1 pageSize := 20 if p := r.URL.Query().Get("page"); p != "" { if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { page = parsed } } if ps := r.URL.Query().Get("page_size"); ps != "" { if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { pageSize = parsed } } // 根据任务状态获取任务列表 var tasks []*asynq.TaskInfo var err error switch taskState { case "active": tasks, err = h.inspector.ListActiveTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1)) case "pending": tasks, err = h.inspector.ListPendingTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1)) case "retry": tasks, err = h.inspector.ListRetryTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1)) case "archived": tasks, err = h.inspector.ListArchivedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1)) case "completed": tasks, err = h.inspector.ListCompletedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1)) default: http.Error(w, "Invalid task state. Valid states: active, pending, retry, archived, completed", http.StatusBadRequest) return } if err != nil { http.Error(w, fmt.Sprintf("Failed to get tasks: %v", err), http.StatusInternalServerError) return } var taskInfos []TaskInfo for _, task := range tasks { info := TaskInfo{ ID: task.ID, Type: task.Type, Payload: string(task.Payload), Queue: task.Queue, Retried: task.Retried, } if !task.LastFailedAt.IsZero() { info.LastFailed = task.LastFailedAt.Format(time.RFC3339) } if task.LastErr != "" { info.LastError = task.LastErr } if !task.NextProcessAt.IsZero() { info.NextProcess = task.NextProcessAt.Format(time.RFC3339) } if !task.CompletedAt.IsZero() { info.CompletedAt = task.CompletedAt.Format(time.RFC3339) } taskInfos = append(taskInfos, info) } response := map[string]any{ "tasks": taskInfos, "page": page, "page_size": pageSize, "total": len(taskInfos), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // handleDashboard 处理仪表板页面 func (h *HTTPHandler) handleDashboard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 使用嵌入的 HTML 模板 tmpl, err := template.New("dashboard").Parse(dashboardHTML) if err != nil { http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) return } data := struct { RootPath string }{ RootPath: h.rootPath, } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, data) }