chore: 添加任务队列管理系统

This commit is contained in:
2025-12-09 14:31:02 +08:00
commit c88bde7b11
12 changed files with 1452 additions and 0 deletions

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.go]
indent_style = tab
indent_size = 4
[*.{md,yml,yaml,html}]
indent_style = space
indent_size = 2

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Log files
*.log
# Environment files
.env
.env.local
.env.*.local
# Build output
dist/
build/
bin/
# Temporary files
tmp/
temp/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 TaskQ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

39
Makefile Normal file
View File

@@ -0,0 +1,39 @@
# Makefile for TaskQ
.PHONY: test clean fmt lint install
# Default target
all: fmt lint test
# Run tests
test:
@echo "Running tests..."
go test -v ./...
# Clean
clean:
@echo "Cleaning..."
go clean
# Format code
fmt:
@echo "Formatting code..."
go fmt ./...
goimports -w .
# Run linter
lint:
@echo "Running linter..."
golint ./...
# Install dependencies
install:
@echo "Installing dependencies..."
go mod download
go mod tidy
# Development setup
dev-setup: install
@echo "Setting up development environment..."
go install golang.org/x/tools/cmd/goimports@latest
go install golang.org/x/lint/golint@latest

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# TaskQ - Task Queue Management System
A Go-based task queue management system for efficient task processing and management.
## Features
- Task queue management
- Dashboard interface
- Task inspection and monitoring
- Concurrent task processing
## Installation
```bash
go mod download
```
## Usage
```bash
go run .
```
## Project Structure
- `taskq.go` - Main application entry point
- `task.go` - Task definition and management
- `inspect.go` - Task inspection utilities
- `dashboard.html` - Web dashboard interface
- `example/` - Example implementations
## Development
```bash
# Run the application
go run .
# Run tests
go test ./...
# Build
go build -o taskq
```
## License
MIT License

420
dashboard.html Normal file
View File

@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskQ 监控面板</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2em;
}
.content {
padding: 20px;
}
.queues-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.queue-card {
border: 1px solid #e1e5e9;
border-radius: 6px;
padding: 15px;
background: #fafafa;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.queue-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.queue-name {
font-weight: bold;
color: #333;
margin-bottom: 10px;
font-size: 1.1em;
}
.queue-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat {
text-align: center;
padding: 8px;
background: white;
border-radius: 4px;
border: 1px solid #e1e5e9;
}
.stat-value {
font-size: 1.2em;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.8em;
color: #666;
margin-top: 2px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
}
.refresh-btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 20px;
}
.refresh-btn:hover {
background: #5a6fd8;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: #000;
}
.task-tabs {
display: flex;
border-bottom: 1px solid #e1e5e9;
margin-bottom: 20px;
}
.task-tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
}
.task-tab.active {
border-bottom-color: #667eea;
color: #667eea;
font-weight: bold;
}
.task-list {
max-height: 400px;
overflow-y: auto;
}
.task-item {
border: 1px solid #e1e5e9;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
background: #fafafa;
}
.task-id {
font-family: monospace;
font-size: 0.9em;
color: #666;
}
.task-type {
font-weight: bold;
color: #333;
}
.task-payload {
font-family: monospace;
font-size: 0.8em;
color: #666;
background: white;
padding: 5px;
border-radius: 3px;
margin-top: 5px;
word-break: break-all;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 5px 10px;
border: 1px solid #e1e5e9;
background: white;
cursor: pointer;
border-radius: 3px;
}
.pagination button:hover {
background: #f0f0f0;
}
.pagination button.active {
background: #667eea;
color: white;
border-color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 TaskQ 监控面板</h1>
<p>实时监控异步任务队列状态</p>
</div>
<div class="content">
<button class="refresh-btn" onclick="loadQueues()">🔄 刷新</button>
<div id="loading" class="loading">加载中...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="queues" class="queues-grid" style="display: none;"></div>
</div>
</div>
<!-- 任务详情模态框 -->
<div id="taskModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeTaskModal()">&times;</span>
<h2 id="modalTitle">队列任务详情</h2>
<div class="task-tabs">
<button class="task-tab active" onclick="switchTab('active')">活跃</button>
<button class="task-tab" onclick="switchTab('pending')">等待</button>
<button class="task-tab" onclick="switchTab('retry')">重试</button>
<button class="task-tab" onclick="switchTab('archived')">归档</button>
<button class="task-tab" onclick="switchTab('completed')">完成</button>
</div>
<div id="taskContent" class="task-list"></div>
<div id="pagination" class="pagination"></div>
</div>
</div>
<script>
let currentQueue = '';
let currentTab = 'active';
let currentPage = 1;
const pageSize = 20;
async function loadQueues() {
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const queues = document.getElementById('queues');
loading.style.display = 'block';
error.style.display = 'none';
queues.style.display = 'none';
try {
const response = await fetch('{{.RootPath}}/api/queues');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const queueData = await response.json();
displayQueues(queueData);
} catch (err) {
error.textContent = '加载队列数据失败: ' + err.message;
error.style.display = 'block';
} finally {
loading.style.display = 'none';
}
}
function displayQueues(queueData) {
const queues = document.getElementById('queues');
if (queueData.length === 0) {
queues.innerHTML = '<div style="text-align: center; padding: 40px; color: #666;">暂无队列数据</div>';
queues.style.display = 'block';
return;
}
queues.innerHTML = queueData.map(queue => `
<div class="queue-card" onclick="openTaskModal('${queue.name}')">
<div class="queue-name">${queue.name} (优先级: ${queue.priority})</div>
<div class="queue-stats">
<div class="stat">
<div class="stat-value">${queue.active}</div>
<div class="stat-label">活跃</div>
</div>
<div class="stat">
<div class="stat-value">${queue.pending}</div>
<div class="stat-label">等待</div>
</div>
<div class="stat">
<div class="stat-value">${queue.retry}</div>
<div class="stat-label">重试</div>
</div>
<div class="stat">
<div class="stat-value">${queue.archived}</div>
<div class="stat-label">归档</div>
</div>
</div>
</div>
`).join('');
queues.style.display = 'grid';
}
function openTaskModal(queueName) {
currentQueue = queueName;
currentPage = 1;
document.getElementById('modalTitle').textContent = `队列 "${queueName}" 任务详情`;
document.getElementById('taskModal').style.display = 'block';
loadTasks();
}
function closeTaskModal() {
document.getElementById('taskModal').style.display = 'none';
}
function switchTab(tab) {
currentTab = tab;
currentPage = 1;
// 更新标签样式
document.querySelectorAll('.task-tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
loadTasks();
}
async function loadTasks() {
const taskContent = document.getElementById('taskContent');
const pagination = document.getElementById('pagination');
taskContent.innerHTML = '<div class="loading">加载任务中...</div>';
pagination.innerHTML = '';
try {
const response = await fetch(`{{.RootPath}}/api/tasks/${currentQueue}/${currentTab}?page=${currentPage}&page_size=${pageSize}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
displayTasks(data.tasks);
displayPagination(data.page, data.page_size, data.total);
} catch (err) {
taskContent.innerHTML = `<div class="error">加载任务失败: ${err.message}</div>`;
}
}
function displayTasks(tasks) {
const taskContent = document.getElementById('taskContent');
if (tasks.length === 0) {
taskContent.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">暂无任务</div>';
return;
}
taskContent.innerHTML = tasks.map(task => `
<div class="task-item">
<div class="task-id">ID: ${task.id}</div>
<div class="task-type">类型: ${task.type}</div>
${task.payload ? `<div class="task-payload">载荷: ${task.payload}</div>` : ''}
${task.retried > 0 ? `<div>重试次数: ${task.retried}</div>` : ''}
${task.last_error ? `<div style="color: #c33; font-size: 0.9em;">错误: ${task.last_error}</div>` : ''}
${task.next_process ? `<div style="color: #666; font-size: 0.9em;">下次处理: ${new Date(task.next_process).toLocaleString()}</div>` : ''}
${task.completed_at ? `<div style="color: #4caf50; font-size: 0.9em;">完成时间: ${new Date(task.completed_at).toLocaleString()}</div>` : ''}
</div>
`).join('');
}
function displayPagination(page, pageSize, total) {
const pagination = document.getElementById('pagination');
if (total === 0) return;
const totalPages = Math.ceil(total / pageSize);
let paginationHTML = '';
// 上一页
if (page > 1) {
paginationHTML += `<button onclick="changePage(${page - 1})">上一页</button>`;
}
// 页码
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === page ? 'active' : '';
paginationHTML += `<button class="${activeClass}" onclick="changePage(${i})">${i}</button>`;
}
// 下一页
if (page < totalPages) {
paginationHTML += `<button onclick="changePage(${page + 1})">下一页</button>`;
}
pagination.innerHTML = paginationHTML;
}
function changePage(page) {
currentPage = page;
loadTasks();
}
// 点击模态框外部关闭
window.onclick = function(event) {
const modal = document.getElementById('taskModal');
if (event.target === modal) {
closeTaskModal();
}
}
// 页面加载时自动加载数据
document.addEventListener('DOMContentLoaded', loadQueues);
// 每30秒自动刷新
setInterval(loadQueues, 30000);
</script>
</body>
</html>

137
example/main.go Normal file
View File

@@ -0,0 +1,137 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"code.tczkiot.com/wlw/taskq"
"github.com/redis/go-redis/v9"
)
// 定义任务数据结构
type EmailTask struct {
UserID int `json:"user_id"`
TemplateID string `json:"template_id"`
}
type ImageResizeTask struct {
SourceURL string `json:"source_url"`
}
// 定义任务处理器
func handleEmailTask(ctx context.Context, t EmailTask) error {
log.Printf("处理邮件任务: 用户ID=%d, 模板ID=%s", t.UserID, t.TemplateID)
// 模拟邮件发送逻辑
return nil
}
func handleImageResizeTask(ctx context.Context, t ImageResizeTask) error {
log.Printf("处理图片调整任务: 源URL=%s", t.SourceURL)
// 模拟图片调整逻辑
return nil
}
func main() {
// 创建 Redis 客户端
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
DB: 1,
})
defer rdb.Close()
// 初始化 taskq
taskq.SetRedis(rdb)
taskq.Init()
// 创建邮件任务
emailTask := &taskq.Task[EmailTask]{
Queue: "email",
Name: "email:deliver",
MaxRetries: 3,
Priority: 5,
TTR: 0,
Handler: handleEmailTask,
}
// 创建图片调整任务
imageTask := &taskq.Task[ImageResizeTask]{
Queue: "image",
Name: "image:resize",
MaxRetries: 3,
Priority: 3,
TTR: 0,
Handler: handleImageResizeTask,
}
// 注册任务
if err := taskq.Register(emailTask); err != nil {
log.Fatal("注册邮件任务失败:", err)
}
if err := taskq.Register(imageTask); err != nil {
log.Fatal("注册图片任务失败:", err)
}
// 创建监控处理器
handler, err := taskq.NewInspectHandler(taskq.InspectOptions{
RootPath: "/monitor",
ReadOnly: false,
})
if err != nil {
log.Fatal("创建监控处理器失败:", err)
}
// 启动 taskq 服务器
ctx := context.Background()
go func() {
err := taskq.Start(ctx)
if err != nil {
log.Fatal("启动 taskq 服务器失败:", err)
}
}()
// 定时发布任务
go func() {
ticker := time.NewTicker(5 * time.Second) // 每5秒发布一次任务
defer ticker.Stop()
taskCounter := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
taskCounter++
// 发布邮件任务
err := emailTask.Publish(ctx, EmailTask{
UserID: taskCounter,
TemplateID: "welcome",
})
if err != nil {
log.Printf("发布邮件任务失败: %v", err)
} else {
log.Printf("发布邮件任务成功: 用户ID=%d", taskCounter)
}
// 发布图片调整任务
err = imageTask.Publish(ctx, ImageResizeTask{
SourceURL: fmt.Sprintf("https://example.com/image%d.jpg", taskCounter),
})
if err != nil {
log.Printf("发布图片任务失败: %v", err)
} else {
log.Printf("发布图片任务成功: 任务ID=%d", taskCounter)
}
}
}
}()
// 启动 HTTP 服务器提供监控界面
log.Printf("启动监控服务器在 http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}

20
go.mod Normal file
View File

@@ -0,0 +1,20 @@
module code.tczkiot.com/wlw/taskq
go 1.25.4
require (
github.com/hibiken/asynq v0.25.1
github.com/redis/go-redis/v9 v9.7.0
github.com/rs/xid v1.6.0
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.7.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
)

38
go.sum Normal file
View File

@@ -0,0 +1,38 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=

332
inspect.go Normal file
View File

@@ -0,0 +1,332 @@
// 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)
}

148
task.go Normal file
View File

@@ -0,0 +1,148 @@
package taskq
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"reflect"
"time"
"github.com/hibiken/asynq"
"github.com/rs/xid"
)
// Task 定义泛型任务结构
// T 表示任务数据的类型,必须是结构体
type Task[T any] struct {
// 公开字段:用户配置
Queue string // 任务队列名称
Group string // 任务分组
Name string // 任务名称,唯一标识
MaxRetries int // 最大重试次数
Priority int // 任务优先级(数值越大优先级越高)
TTR time.Duration // 任务超时时间Time-To-Run
Handler any // 处理器函数
// 私有字段:运行时反射信息
funcValue reflect.Value // 处理器函数的反射值
dataType reflect.Type // 数据类型的反射信息
inputContext bool // 是否需要 context.Context 参数
inputData bool // 是否需要数据参数
returnError bool // 是否返回 error
}
// PublishOption 任务发布选项函数类型
// 用于配置任务发布时的各种选项
type PublishOption func() asynq.Option
// Delay 设置任务延迟执行时间
// 参数 d 表示延迟多长时间后执行
func Delay(d time.Duration) PublishOption {
return func() asynq.Option {
return asynq.ProcessIn(d)
}
}
// DelayUntil 设置任务在指定时间执行
// 参数 t 表示任务执行的具体时间点
func DelayUntil(t time.Time) PublishOption {
return func() asynq.Option {
return asynq.ProcessAt(t)
}
}
// TTR 设置任务超时时间
// 覆盖任务默认的超时时间配置
func TTR(d time.Duration) PublishOption {
return func() asynq.Option {
return asynq.Timeout(d)
}
}
// Retention 设置任务结果保留时间
// 任务执行完成后,结果在 Redis 中保留的时间
func Retention(d time.Duration) PublishOption {
return func() asynq.Option {
return asynq.Retention(d)
}
}
// Publish 发布任务到队列
// 将任务数据序列化后发送到 Redis 队列中等待处理
func (t *Task[T]) Publish(ctx context.Context, data T, options ...PublishOption) error {
// 获取 asynq 客户端
c := client.Load()
if c == nil {
return errors.New("taskq: client not initialized, call SetRedis() first")
}
// 序列化任务数据为 JSON
payload, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("taskq: failed to marshal task data: %w", err)
}
// 构建任务选项
opts := []asynq.Option{
asynq.Queue(t.Queue), // 设置队列名称
asynq.Group(t.Group), // 设置任务组
asynq.MaxRetry(t.MaxRetries), // 设置最大重试次数
asynq.TaskID(xid.New().String()), // 生成唯一任务ID
asynq.Timeout(t.TTR), // 设置超时时间
asynq.Retention(time.Hour * 24), // 设置结果保留24小时
}
// 应用用户自定义选项
for _, option := range options {
if opt := option(); opt != nil {
opts = append(opts, opt)
}
}
// 发布任务到队列
info, err := c.EnqueueContext(
ctx,
asynq.NewTask(t.Name, payload),
opts...,
)
// 记录任务发布信息(用于调试)
log.Println(info)
return err
}
// ProcessTask 处理任务的核心方法
// 由 asynq 服务器调用,根据任务配置动态调用处理器函数
func (t *Task[T]) ProcessTask(ctx context.Context, tsk *asynq.Task) error {
var in []reflect.Value
// 根据配置添加 context.Context 参数
if t.inputContext {
in = append(in, reflect.ValueOf(ctx))
}
// 根据配置添加数据参数
if t.inputData {
// 创建数据类型的指针实例
dataValue := reflect.New(t.dataType)
// 反序列化任务载荷
err := json.Unmarshal(tsk.Payload(), dataValue.Interface())
if err != nil {
return err
}
in = append(in, dataValue)
}
// 通过反射调用处理器函数
out := t.funcValue.Call(in)
// 处理返回值
if t.returnError {
// Register 已确保返回类型为 error无需类型断言
return out[0].Interface().(error)
}
return nil
}

184
taskq.go Normal file
View File

@@ -0,0 +1,184 @@
// Package taskq 提供基于 Redis 的异步任务队列功能
// 使用 asynq 库作为底层实现,支持任务注册、发布、消费和重试机制
package taskq
import (
"context"
"errors"
"log"
"maps"
"reflect"
"sync/atomic"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
// 全局状态变量
var (
started atomic.Bool // 服务器启动状态
exit chan chan struct{} // 优雅退出信号通道
handlers map[string]asynq.Handler // 任务处理器映射表
queues map[string]int // 队列优先级配置
client atomic.Pointer[asynq.Client] // asynq 客户端实例
redisClient redis.UniversalClient // Redis 客户端实例
errorType = reflect.TypeOf((*error)(nil)).Elem() // error 类型反射
contextType = reflect.TypeOf((*context.Context)(nil)).Elem() // context.Context 类型反射
)
// Init 初始化 taskq 系统
// 创建必要的全局变量和映射表,必须在调用其他函数之前调用
func Init() {
exit = make(chan chan struct{}) // 创建优雅退出通道
handlers = make(map[string]asynq.Handler) // 创建任务处理器映射
queues = make(map[string]int) // 创建队列优先级映射
}
// Register 注册任务处理器
// 使用泛型确保类型安全,通过反射验证处理器函数签名
// 处理器函数签名必须是func(context.Context, T) error 或 func(context.Context) 或 func(T) error 或 func()
func Register[T any](t *Task[T]) error {
rv := reflect.ValueOf(t.Handler)
if rv.Kind() != reflect.Func {
return errors.New("taskq: handler must be a function")
}
rt := rv.Type()
// 验证返回值:只能是 error 或无返回值
var returnError bool
for i := range rt.NumOut() {
if i == 0 && rt.Out(0).Implements(errorType) {
returnError = true
} else {
return errors.New("taskq: handler function must return either error or nothing")
}
}
// 验证参数最多2个参数第一个必须是 context.Context第二个必须是结构体
var inContext bool
var inData bool
var dataType reflect.Type
for i := range rt.NumIn() {
if i == 0 {
fi := rt.In(i)
if !fi.Implements(contextType) {
return errors.New("taskq: handler function first parameter must be context.Context")
}
inContext = true
continue
}
if i != 1 {
return errors.New("taskq: handler function can have at most 2 parameters")
}
fi := rt.In(i)
if fi.Kind() != reflect.Struct {
return errors.New("taskq: handler function second parameter must be a struct")
}
inData = true
dataType = fi
}
// 检查服务器是否已启动
if started.Load() {
return errors.New("taskq: cannot register handler after server has started")
}
// 设置任务的反射信息
t.funcValue = rv
t.dataType = dataType
t.inputContext = inContext
t.inputData = inData
t.returnError = returnError
// 注册到全局映射表
handlers[t.Name] = t
queues[t.Queue] = t.Priority
return nil
}
// SetRedis 设置 Redis 客户端
// 必须在启动服务器之前调用,用于配置任务队列的存储后端
func SetRedis(rdb redis.UniversalClient) error {
if started.Load() {
return errors.New("taskq: server is already running")
}
redisClient = rdb
client.Store(asynq.NewClientFromRedisClient(rdb))
return nil
}
// Start 启动 taskq 服务器
// 开始监听任务队列并处理任务,包含健康检查和优雅退出机制
func Start(ctx context.Context) error {
// 原子操作确保只启动一次
if !started.CompareAndSwap(false, true) {
return errors.New("taskq: server is already running")
}
// 检查 Redis 客户端是否已初始化
if redisClient == nil {
return errors.New("taskq: redis client not initialized, call SetRedis() first")
}
// 创建任务路由器
mux := asynq.NewServeMux()
for name, handler := range handlers {
mux.Handle(name, handler)
}
// 创建 asynq 服务器
srv := asynq.NewServerFromRedisClient(redisClient, asynq.Config{
Concurrency: 30, // 并发处理数
Queues: maps.Clone(queues), // 队列配置
BaseContext: func() context.Context { return ctx }, // 基础上下文
LogLevel: asynq.DebugLevel, // 日志级别
})
// 启动监控协程:处理优雅退出和健康检查
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
ticker := time.NewTicker(time.Minute) // 每分钟健康检查
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case exit := <-exit: // 收到退出信号
srv.Stop()
exit <- struct{}{}
return
case <-ticker.C: // 定期健康检查
err := srv.Ping()
if err != nil {
log.Println(err)
Stop()
}
}
}
}()
// 启动任务处理服务器
go func() {
if err := srv.Run(mux); err != nil {
log.Fatal(err)
}
}()
return nil
}
// Stop 优雅停止 taskq 服务器
// 发送停止信号并等待服务器完全关闭
func Stop() {
quit := make(chan struct{})
exit <- quit // 发送退出信号
<-quit // 等待确认退出
}