dockerHubService.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /**
  2. * Docker Hub 服务模块
  3. */
  4. const axios = require('axios');
  5. const logger = require('../logger');
  6. const pLimit = require('p-limit');
  7. const axiosRetry = require('axios-retry');
  8. // 配置并发限制,最多5个并发请求
  9. const limit = pLimit(5);
  10. // 优化HTTP请求配置
  11. const httpOptions = {
  12. timeout: 15000, // 15秒超时
  13. headers: {
  14. 'User-Agent': 'DockerHubSearchClient/1.0',
  15. 'Accept': 'application/json'
  16. }
  17. };
  18. // 配置Axios重试
  19. axiosRetry(axios, {
  20. retries: 3, // 最多重试3次
  21. retryDelay: (retryCount) => {
  22. console.log(`[INFO] 重试 Docker Hub 请求 (${retryCount}/3)`);
  23. return retryCount * 1000; // 重试延迟,每次递增1秒
  24. },
  25. retryCondition: (error) => {
  26. // 只在网络错误或5xx响应时重试
  27. return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
  28. (error.response && error.response.status >= 500);
  29. }
  30. });
  31. // 搜索仓库
  32. async function searchRepositories(term, page = 1, requestCache = null) {
  33. const cacheKey = `search_${term}_${page}`;
  34. let cachedResult = null;
  35. // 安全地检查缓存
  36. if (requestCache && typeof requestCache.get === 'function') {
  37. cachedResult = requestCache.get(cacheKey);
  38. }
  39. if (cachedResult) {
  40. console.log(`[INFO] 返回缓存的搜索结果: ${term} (页码: ${page})`);
  41. return cachedResult;
  42. }
  43. console.log(`[INFO] 搜索Docker Hub: ${term} (页码: ${page})`);
  44. try {
  45. // 使用更安全的直接请求方式,避免pLimit可能的问题
  46. const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=25`;
  47. const response = await axios.get(url, httpOptions);
  48. const result = response.data;
  49. // 将结果缓存(如果缓存对象可用)
  50. if (requestCache && typeof requestCache.set === 'function') {
  51. requestCache.set(cacheKey, result);
  52. }
  53. return result;
  54. } catch (error) {
  55. logger.error('搜索Docker Hub失败:', error.message);
  56. // 重新抛出错误以便上层处理
  57. throw new Error(error.message || '搜索Docker Hub失败');
  58. }
  59. }
  60. // 获取所有标签
  61. async function getAllTags(imageName, isOfficial) {
  62. const fullImageName = isOfficial ? `library/${imageName}` : imageName;
  63. logger.info(`获取所有镜像标签: ${fullImageName}`);
  64. // 为所有标签请求设置超时限制
  65. const allTagsPromise = fetchAllTags(fullImageName);
  66. const timeoutPromise = new Promise((_, reject) =>
  67. setTimeout(() => reject(new Error('获取所有标签超时')), 30000)
  68. );
  69. try {
  70. // 使用Promise.race确保请求不会无限等待
  71. const allTags = await Promise.race([allTagsPromise, timeoutPromise]);
  72. // 过滤掉无效平台信息
  73. const cleanedTags = allTags.map(tag => {
  74. if (tag.images && Array.isArray(tag.images)) {
  75. tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown'));
  76. }
  77. return tag;
  78. });
  79. return {
  80. count: cleanedTags.length,
  81. results: cleanedTags,
  82. all_pages_loaded: true
  83. };
  84. } catch (error) {
  85. logger.error(`获取所有标签失败: ${error.message}`);
  86. throw error;
  87. }
  88. }
  89. // 获取特定页的标签
  90. async function getTagsByPage(imageName, isOfficial, page, pageSize) {
  91. const fullImageName = isOfficial ? `library/${imageName}` : imageName;
  92. logger.info(`获取镜像标签: ${fullImageName}, 页码: ${page}, 页面大小: ${pageSize}`);
  93. const tagsUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`;
  94. try {
  95. const tagsResponse = await axios.get(tagsUrl, {
  96. timeout: 15000,
  97. headers: {
  98. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
  99. }
  100. });
  101. // 检查响应数据有效性
  102. if (!tagsResponse.data || typeof tagsResponse.data !== 'object') {
  103. logger.warn(`镜像 ${fullImageName} 返回的数据格式不正确`);
  104. return { count: 0, results: [] };
  105. }
  106. if (!tagsResponse.data.results || !Array.isArray(tagsResponse.data.results)) {
  107. logger.warn(`镜像 ${fullImageName} 没有返回有效的标签数据`);
  108. return { count: 0, results: [] };
  109. }
  110. // 过滤掉无效平台信息
  111. const cleanedResults = tagsResponse.data.results.map(tag => {
  112. if (tag.images && Array.isArray(tag.images)) {
  113. tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown'));
  114. }
  115. return tag;
  116. });
  117. return {
  118. ...tagsResponse.data,
  119. results: cleanedResults
  120. };
  121. } catch (error) {
  122. logger.error(`获取标签列表失败: ${error.message}`, {
  123. url: tagsUrl,
  124. status: error.response?.status,
  125. statusText: error.response?.statusText
  126. });
  127. throw error;
  128. }
  129. }
  130. // 获取标签数量
  131. async function getTagCount(name, isOfficial, requestCache) {
  132. const cacheKey = `tag_count_${name}_${isOfficial}`;
  133. const cachedResult = requestCache?.get(cacheKey);
  134. if (cachedResult) {
  135. console.log(`[INFO] 返回缓存的标签计数: ${name}`);
  136. return cachedResult;
  137. }
  138. const fullImageName = isOfficial ? `library/${name}` : name;
  139. const apiUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags/?page_size=1`;
  140. try {
  141. const result = await limit(async () => {
  142. const response = await axios.get(apiUrl, httpOptions);
  143. return {
  144. count: response.data.count,
  145. recommended_mode: response.data.count > 500 ? 'paginated' : 'full'
  146. };
  147. });
  148. if (requestCache) {
  149. requestCache.set(cacheKey, result);
  150. }
  151. return result;
  152. } catch (error) {
  153. throw error;
  154. }
  155. }
  156. // 递归获取所有标签
  157. async function fetchAllTags(fullImageName, page = 1, allTags = [], maxPages = 10) {
  158. if (page > maxPages) {
  159. logger.warn(`达到最大页数限制 (${maxPages}),停止获取更多标签`);
  160. return allTags;
  161. }
  162. const pageSize = 100; // 使用最大页面大小
  163. const url = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`;
  164. try {
  165. logger.info(`获取标签页 ${page}/${maxPages}...`);
  166. const response = await axios.get(url, {
  167. timeout: 10000,
  168. headers: {
  169. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
  170. }
  171. });
  172. if (!response.data.results || !Array.isArray(response.data.results)) {
  173. logger.warn(`页 ${page} 没有有效的标签数据`);
  174. return allTags;
  175. }
  176. allTags.push(...response.data.results);
  177. logger.info(`已获取 ${allTags.length}/${response.data.count || 'unknown'} 个标签`);
  178. // 检查是否有下一页
  179. if (response.data.next && allTags.length < response.data.count) {
  180. // 添加一些延迟以避免请求过快
  181. await new Promise(resolve => setTimeout(resolve, 500));
  182. return fetchAllTags(fullImageName, page + 1, allTags, maxPages);
  183. }
  184. logger.success(`成功获取所有 ${allTags.length} 个标签`);
  185. return allTags;
  186. } catch (error) {
  187. logger.error(`递归获取标签失败 (页码 ${page}): ${error.message}`);
  188. // 如果已经获取了一些标签,返回这些标签而不是抛出错误
  189. if (allTags.length > 0) {
  190. return allTags;
  191. }
  192. // 如果没有获取到任何标签,则抛出错误
  193. throw error;
  194. }
  195. }
  196. // 统一的错误处理函数
  197. function handleAxiosError(error, res, message) {
  198. let errorDetails = '';
  199. if (error.response) {
  200. // 服务器响应错误的错误处理函数
  201. const status = error.response.status;
  202. errorDetails = `状态码: ${status}`;
  203. if (error.response.data && error.response.data.message) {
  204. errorDetails += `, 信息: ${error.response.data.message}`;
  205. }
  206. console.error(`[ERROR] ${message}: ${errorDetails}`);
  207. res.status(status).json({
  208. error: `${message} (${errorDetails})`,
  209. details: error.response.data
  210. });
  211. } else if (error.request) {
  212. // 请求已发送但没有收到响应
  213. if (error.code === 'ECONNRESET') {
  214. errorDetails = '连接被重置,这可能是由于网络不稳定或服务端断开连接';
  215. } else if (error.code === 'ECONNABORTED') {
  216. errorDetails = '请求超时,服务器响应时间过长';
  217. } else {
  218. errorDetails = `${error.code || '未知错误代码'}: ${error.message}`;
  219. }
  220. console.error(`[ERROR] ${message}: ${errorDetails}`);
  221. res.status(503).json({
  222. error: `${message} (${errorDetails})`,
  223. retryable: true
  224. });
  225. } else {
  226. // 其他错误
  227. errorDetails = error.message;
  228. console.error(`[ERROR] ${message}: ${errorDetails}`);
  229. console.error(`[ERROR] 错误堆栈: ${error.stack}`);
  230. res.status(500).json({
  231. error: `${message} (${errorDetails})`,
  232. retryable: true
  233. });
  234. }
  235. }
  236. module.exports = {
  237. searchRepositories,
  238. getAllTags,
  239. getTagsByPage,
  240. getTagCount,
  241. fetchAllTags,
  242. handleAxiosError
  243. };