获取更多镜像tag
This commit is contained in:
314
src/search.go
314
src/search.go
@@ -66,14 +66,21 @@ type Image struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// TagPageResult 分页标签结果
|
||||
type TagPageResult struct {
|
||||
Tags []TagInfo `json:"tags"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
timestamp time.Time
|
||||
expiresAt time.Time // 存储过期时间
|
||||
}
|
||||
|
||||
const (
|
||||
maxCacheSize = 1000 // 最大缓存条目数
|
||||
cacheTTL = 30 * time.Minute
|
||||
maxCacheSize = 1000 // 最大缓存条目数
|
||||
maxPaginationCache = 200 // 分页缓存最大条目数
|
||||
cacheTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
@@ -98,7 +105,8 @@ func (c *Cache) Get(key string) (interface{}, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Since(entry.timestamp) > cacheTTL {
|
||||
// 比较过期时间
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.mu.Lock()
|
||||
delete(c.data, key)
|
||||
c.mu.Unlock()
|
||||
@@ -109,40 +117,36 @@ func (c *Cache) Get(key string) (interface{}, bool) {
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, data interface{}) {
|
||||
c.SetWithTTL(key, data, cacheTTL)
|
||||
}
|
||||
|
||||
func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for k, v := range c.data {
|
||||
if now.Sub(v.timestamp) > cacheTTL {
|
||||
delete(c.data, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 惰性清理:仅在容量超限时清理过期项
|
||||
if len(c.data) >= c.maxSize {
|
||||
toDelete := len(c.data) / 4
|
||||
for k := range c.data {
|
||||
if toDelete <= 0 {
|
||||
break
|
||||
}
|
||||
delete(c.data, k)
|
||||
toDelete--
|
||||
}
|
||||
c.cleanupExpiredLocked()
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
c.data[key] = cacheEntry{
|
||||
data: data,
|
||||
timestamp: now,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Cleanup() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanupExpiredLocked()
|
||||
}
|
||||
|
||||
// cleanupExpiredLocked 清理过期缓存(需要已持有锁)
|
||||
func (c *Cache) cleanupExpiredLocked() {
|
||||
now := time.Now()
|
||||
for key, entry := range c.data {
|
||||
if now.Sub(entry.timestamp) > cacheTTL {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(c.data, key)
|
||||
}
|
||||
}
|
||||
@@ -152,6 +156,8 @@ func (c *Cache) Cleanup() {
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop() // 确保ticker资源释放
|
||||
|
||||
for range ticker.C {
|
||||
searchCache.Cleanup()
|
||||
}
|
||||
@@ -214,8 +220,43 @@ func filterSearchResults(results []Repository, query string) []Repository {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// normalizeRepository 统一规范化仓库信息(消除重复逻辑)
|
||||
func normalizeRepository(repo *Repository) {
|
||||
if repo.IsOfficial {
|
||||
repo.Namespace = "library"
|
||||
if !strings.Contains(repo.Name, "/") {
|
||||
repo.Name = "library/" + repo.Name
|
||||
}
|
||||
} else {
|
||||
// 处理用户仓库:设置命名空间但保持Name为纯仓库名
|
||||
if repo.Namespace == "" && repo.RepoOwner != "" {
|
||||
repo.Namespace = repo.RepoOwner
|
||||
}
|
||||
|
||||
// 如果Name包含斜杠,提取纯仓库名
|
||||
if strings.Contains(repo.Name, "/") {
|
||||
parts := strings.Split(repo.Name, "/")
|
||||
if len(parts) > 1 {
|
||||
if repo.Namespace == "" {
|
||||
repo.Namespace = parts[0]
|
||||
}
|
||||
repo.Name = parts[len(parts)-1] // 取最后部分作为仓库名
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchDockerHub 搜索镜像
|
||||
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
|
||||
return searchDockerHubWithDepth(ctx, query, page, pageSize, 0)
|
||||
}
|
||||
|
||||
// searchDockerHubWithDepth 搜索镜像(带递归深度控制)
|
||||
func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) {
|
||||
// 防止无限递归:最多允许1次递归调用
|
||||
if depth > 1 {
|
||||
return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词")
|
||||
}
|
||||
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
|
||||
|
||||
// 尝试从缓存获取
|
||||
@@ -264,11 +305,7 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
defer safeCloseResponseBody(resp.Body, "搜索响应体")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -281,8 +318,8 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
||||
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
|
||||
case http.StatusNotFound:
|
||||
if isUserRepo && namespace != "" {
|
||||
// 如果用户仓库搜索失败,尝试普通搜索
|
||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
||||
// 如果用户仓库搜索失败,尝试普通搜索(递归调用)
|
||||
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
|
||||
}
|
||||
return nil, fmt.Errorf("未找到相关镜像")
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||
@@ -318,18 +355,16 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
||||
for _, repo := range userRepos.Results {
|
||||
// 如果指定了仓库名,只保留匹配的结果
|
||||
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
|
||||
// 确保设置正确的命名空间和名称
|
||||
// 设置命名空间并使用统一的规范化函数
|
||||
repo.Namespace = namespace
|
||||
if !strings.Contains(repo.Name, "/") {
|
||||
repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name)
|
||||
}
|
||||
normalizeRepository(&repo)
|
||||
result.Results = append(result.Results, repo)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到结果,尝试普通搜索
|
||||
// 如果没有找到结果,尝试普通搜索(递归调用)
|
||||
if len(result.Results) == 0 {
|
||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
||||
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
|
||||
}
|
||||
|
||||
result.Count = len(result.Results)
|
||||
@@ -340,23 +375,9 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 处理搜索结果
|
||||
// 处理搜索结果:使用统一的规范化函数
|
||||
for i := range result.Results {
|
||||
if result.Results[i].IsOfficial {
|
||||
if !strings.Contains(result.Results[i].Name, "/") {
|
||||
result.Results[i].Name = "library/" + result.Results[i].Name
|
||||
}
|
||||
result.Results[i].Namespace = "library"
|
||||
} else {
|
||||
parts := strings.Split(result.Results[i].Name, "/")
|
||||
if len(parts) > 1 {
|
||||
result.Results[i].Namespace = parts[0]
|
||||
result.Results[i].Name = parts[1]
|
||||
} else if result.Results[i].RepoOwner != "" {
|
||||
result.Results[i].Namespace = result.Results[i].RepoOwner
|
||||
result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name)
|
||||
}
|
||||
}
|
||||
normalizeRepository(&result.Results[i])
|
||||
}
|
||||
|
||||
// 如果是用户/仓库搜索,过滤结果
|
||||
@@ -394,61 +415,150 @@ func isRetryableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getRepositoryTags 获取仓库标签信息
|
||||
func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) {
|
||||
// getRepositoryTags 获取仓库标签信息(支持分页)
|
||||
func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) {
|
||||
if namespace == "" || name == "" {
|
||||
return nil, fmt.Errorf("无效输入:命名空间和名称不能为空")
|
||||
return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name)
|
||||
// 默认参数
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 分页缓存key
|
||||
cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page)
|
||||
if cached, ok := searchCache.Get(cacheKey); ok {
|
||||
return cached.([]TagInfo), nil
|
||||
result := cached.(TagPageResult)
|
||||
return result.Tags, result.HasMore, nil
|
||||
}
|
||||
|
||||
// 构建API URL
|
||||
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
|
||||
params := url.Values{}
|
||||
params.Set("page_size", "100")
|
||||
params.Set("page", fmt.Sprintf("%d", page))
|
||||
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
||||
params.Set("ordering", "last_updated")
|
||||
|
||||
fullURL := baseURL + "?" + params.Encode()
|
||||
|
||||
// 使用统一的搜索HTTP客户端
|
||||
resp, err := GetSearchHTTPClient().Get(fullURL)
|
||||
// 获取当前页数据
|
||||
pageResult, err := fetchTagPage(ctx, fullURL, 3)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求失败: %v", err)
|
||||
return nil, false, fmt.Errorf("获取标签失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
||||
|
||||
hasMore := pageResult.Next != ""
|
||||
|
||||
// 缓存结果(分页缓存时间较短)
|
||||
result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore}
|
||||
searchCache.SetWithTTL(cacheKey, result, 30*time.Minute)
|
||||
|
||||
return pageResult.Results, hasMore, nil
|
||||
}
|
||||
|
||||
// fetchTagPage 获取单页标签数据,带重试机制
|
||||
func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []TagInfo `json:"results"`
|
||||
}, error) {
|
||||
var lastErr error
|
||||
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
if retry > 0 {
|
||||
// 重试前等待一段时间
|
||||
time.Sleep(time.Duration(retry) * 500 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// 读取响应体
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
resp, err := GetSearchHTTPClient().Get(url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if isRetryableError(err) && retry < maxRetries-1 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("发送请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
// 读取响应体(立即关闭,避免defer在循环中累积)
|
||||
body, err := func() ([]byte, error) {
|
||||
defer safeCloseResponseBody(resp.Body, "标签响应体")
|
||||
return io.ReadAll(resp.Body)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if retry < maxRetries-1 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []TagInfo `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
// 4xx错误通常不需要重试
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
|
||||
return nil, fmt.Errorf("请求失败: %v", lastErr)
|
||||
}
|
||||
if retry < maxRetries-1 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("请求失败: %v", lastErr)
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
searchCache.Set(cacheKey, result.Results)
|
||||
return result.Results, nil
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []TagInfo `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
lastErr = err
|
||||
if retry < maxRetries-1 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// parsePaginationParams 解析分页参数
|
||||
func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) {
|
||||
page = 1
|
||||
pageSize = defaultPageSize
|
||||
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// safeCloseResponseBody 安全关闭HTTP响应体(统一资源管理)
|
||||
func safeCloseResponseBody(body io.ReadCloser, context string) {
|
||||
if body != nil {
|
||||
if err := body.Close(); err != nil {
|
||||
fmt.Printf("关闭%s失败: %v\n", context, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendErrorResponse 统一错误响应处理
|
||||
func sendErrorResponse(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||
}
|
||||
|
||||
// RegisterSearchRoute 注册搜索相关路由
|
||||
@@ -457,22 +567,15 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
r.GET("/search", func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
|
||||
sendErrorResponse(c, "搜索关键词不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 25
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
page, pageSize := parsePaginationParams(c, 25)
|
||||
|
||||
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
sendErrorResponse(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,16 +588,27 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
name := c.Param("name")
|
||||
|
||||
if namespace == "" || name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"})
|
||||
sendErrorResponse(c, "命名空间和名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := getRepositoryTags(c.Request.Context(), namespace, name)
|
||||
page, pageSize := parsePaginationParams(c, 100)
|
||||
|
||||
tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
sendErrorResponse(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tags)
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tags": tags,
|
||||
"has_more": hasMore,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user