perf(server): 将同步文件I/O替换为异步操作提升并发性能
feat(server): 为浏览器访问添加加载动画HTML页面
This commit is contained in:
@@ -54,3 +54,5 @@
|
|||||||
- 优化协议检测逻辑:当目标主机 443 端口无法连接或超时时,自动降级并打印日志 `falling back to HTTP`,确保非 SSL 站点也能正常访问。
|
- 优化协议检测逻辑:当目标主机 443 端口无法连接或超时时,自动降级并打印日志 `falling back to HTTP`,确保非 SSL 站点也能正常访问。
|
||||||
- 优化缓存逻辑:不再缓存小尺寸(< 15KB)的 GIF 图片(通常是 mShots 的 "Generating" 占位图),以便下次请求时能重新尝试获取真实截图。
|
- 优化缓存逻辑:不再缓存小尺寸(< 15KB)的 GIF 图片(通常是 mShots 的 "Generating" 占位图),以便下次请求时能重新尝试获取真实截图。
|
||||||
- 新增备用接口机制:当 mShots 返回无效图片(如生成中 GIF)或失败时,自动降级尝试使用 `thum.io` 获取截图,确保高可用性。
|
- 新增备用接口机制:当 mShots 返回无效图片(如生成中 GIF)或失败时,自动降级尝试使用 `thum.io` 获取截图,确保高可用性。
|
||||||
|
- 性能优化:将关键路径上的同步文件 I/O (readFileSync/writeFileSync) 替换为异步操作 (fs.promises),防止高并发下 Event Loop 阻塞导致服务无响应。
|
||||||
|
- 用户体验优化:当在浏览器中直接访问 API (Accept: text/html) 时,返回一个带有加载动画的 HTML 页面,解决等待过程中的白屏问题。
|
||||||
|
|||||||
297
server.js
297
server.js
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const fsPromises = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
@@ -180,26 +181,50 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) {
|
|||||||
const key = sha1(upstreamUrl);
|
const key = sha1(upstreamUrl);
|
||||||
const { data: dataPath, meta: metaPath } = getCachePaths(key);
|
const { data: dataPath, meta: metaPath } = getCachePaths(key);
|
||||||
|
|
||||||
|
// 1. 尝试读取缓存 (使用 async 版本)
|
||||||
try {
|
try {
|
||||||
// 1. 尝试读取缓存
|
// 检查文件是否存在
|
||||||
if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) {
|
await fsPromises.access(dataPath);
|
||||||
try {
|
await fsPromises.access(metaPath);
|
||||||
const metaRaw = fs.readFileSync(metaPath, 'utf8');
|
|
||||||
const meta = JSON.parse(metaRaw);
|
const metaRaw = await fsPromises.readFile(metaPath, 'utf8');
|
||||||
if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) {
|
let meta;
|
||||||
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
try {
|
||||||
res.type(meta.contentType);
|
meta = JSON.parse(metaRaw);
|
||||||
const stream = fs.createReadStream(dataPath);
|
} catch (e) {
|
||||||
stream.on('error', () => {
|
console.warn(`[cache-warn] meta corrupted for ${upstreamUrl}`);
|
||||||
if (!res.headersSent) res.status(500).send('Cache read error');
|
// meta 损坏,视为无缓存,继续回源
|
||||||
});
|
|
||||||
return stream.pipe(res);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 缓存元数据损坏,忽略,继续回源
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
// 检查缓存有效期 (例如 30 天)
|
||||||
|
const cachedTime = new Date(meta.createdAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 30 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
if (now - cachedTime < maxAge) {
|
||||||
|
console.log(`[cache-hit] ${upstreamUrl}`);
|
||||||
|
res.type(meta.contentType);
|
||||||
|
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
||||||
|
if (meta.source === 'thum.io') {
|
||||||
|
res.set('X-Source', 'fallback-thum.io-cache');
|
||||||
|
} else {
|
||||||
|
res.set('X-Source', 'mshots-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式返回
|
||||||
|
const stream = fs.createReadStream(dataPath);
|
||||||
|
stream.pipe(res);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log(`[cache-expired] ${upstreamUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 文件不存在或其他错误,忽略,继续回源
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// 2. 回源请求
|
// 2. 回源请求
|
||||||
const resp = await fetchUpstreamWithRetry(upstreamUrl);
|
const resp = await fetchUpstreamWithRetry(upstreamUrl);
|
||||||
|
|
||||||
@@ -231,8 +256,8 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) {
|
|||||||
source: isFallback ? 'thum.io' : 'mshots'
|
source: isFallback ? 'thum.io' : 'mshots'
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(dataPath, finalResp.data);
|
await fsPromises.writeFile(dataPath, finalResp.data);
|
||||||
fs.writeFileSync(metaPath, JSON.stringify(meta));
|
await fsPromises.writeFile(metaPath, JSON.stringify(meta));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 写盘失败也继续返回
|
// 写盘失败也继续返回
|
||||||
}
|
}
|
||||||
@@ -246,21 +271,22 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) {
|
|||||||
|
|
||||||
// 5. 非图片或非200:兜底策略
|
// 5. 非图片或非200:兜底策略
|
||||||
// 若本地已有有效缓存(可能是旧的但有效),优先返回缓存
|
// 若本地已有有效缓存(可能是旧的但有效),优先返回缓存
|
||||||
if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) {
|
try {
|
||||||
try {
|
await fsPromises.access(dataPath);
|
||||||
const metaRaw = fs.readFileSync(metaPath, 'utf8');
|
await fsPromises.access(metaPath);
|
||||||
const meta = JSON.parse(metaRaw);
|
|
||||||
if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) {
|
const metaRaw = await fsPromises.readFile(metaPath, 'utf8');
|
||||||
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
const meta = JSON.parse(metaRaw);
|
||||||
res.type(meta.contentType);
|
if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) {
|
||||||
const stream = fs.createReadStream(dataPath);
|
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
||||||
stream.on('error', () => {
|
res.type(meta.contentType);
|
||||||
if (!res.headersSent) res.status(500).send('Cache read error');
|
const stream = fs.createReadStream(dataPath);
|
||||||
});
|
stream.on('error', () => {
|
||||||
return stream.pipe(res);
|
if (!res.headersSent) res.status(500).send('Cache read error');
|
||||||
}
|
});
|
||||||
} catch (_) {}
|
return stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// 否则透传上游响应
|
// 否则透传上游响应
|
||||||
res.status(resp.status);
|
res.status(resp.status);
|
||||||
@@ -270,21 +296,22 @@ async function handleProxyRequest(res, upstreamUrl, targetUrl) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 5. 回源彻底失败
|
// 5. 回源彻底失败
|
||||||
// 若本地有缓存可兜底
|
// 若本地有缓存可兜底
|
||||||
if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) {
|
try {
|
||||||
try {
|
await fsPromises.access(dataPath);
|
||||||
const metaRaw = fs.readFileSync(metaPath, 'utf8');
|
await fsPromises.access(metaPath);
|
||||||
const meta = JSON.parse(metaRaw);
|
|
||||||
if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) {
|
const metaRaw = await fsPromises.readFile(metaPath, 'utf8');
|
||||||
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
const meta = JSON.parse(metaRaw);
|
||||||
res.type(meta.contentType);
|
if (meta.contentType && meta.contentType.toLowerCase().startsWith('image/')) {
|
||||||
const stream = fs.createReadStream(dataPath);
|
res.set('Cache-Control', 'public, max-age=315360000, immutable');
|
||||||
stream.on('error', () => {
|
res.type(meta.contentType);
|
||||||
if (!res.headersSent) res.status(500).send('Cache read error');
|
const stream = fs.createReadStream(dataPath);
|
||||||
});
|
stream.on('error', () => {
|
||||||
return stream.pipe(res);
|
if (!res.headersSent) res.status(500).send('Cache read error');
|
||||||
}
|
});
|
||||||
} catch (_) {}
|
return stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
console.error(`[upstream-failed] url=${upstreamUrl} err=${err.message}`);
|
console.error(`[upstream-failed] url=${upstreamUrl} err=${err.message}`);
|
||||||
return res.status(502).type('text/plain').send('Upstream error');
|
return res.status(502).type('text/plain').send('Upstream error');
|
||||||
}
|
}
|
||||||
@@ -311,6 +338,92 @@ app.use('/mshots/v1', async (req, res) => {
|
|||||||
// 拼接完整上游 URL
|
// 拼接完整上游 URL
|
||||||
const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl;
|
const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl;
|
||||||
|
|
||||||
|
// 浏览器访问优化:如果 Accept 包含 text/html 且没有 ?raw=true 参数
|
||||||
|
// 返回一个带有加载动画的 HTML 页面,前端再请求真实图片
|
||||||
|
if (req.headers.accept && req.headers.accept.includes('text/html') && !req.query.raw) {
|
||||||
|
const rawUrl = req.originalUrl.includes('?')
|
||||||
|
? `${req.originalUrl}&raw=true`
|
||||||
|
: `${req.originalUrl}?raw=true`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Snapshot Loading...</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
.loader {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: none; /* 初始隐藏 */
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div id="loading-state">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<div class="status">Generating snapshot for<br><strong>${targetUrl}</strong>...</div>
|
||||||
|
<div style="font-size: 12px; color: #999;">This may take up to 30 seconds if not cached.</div>
|
||||||
|
</div>
|
||||||
|
<img id="result-img" src="${rawUrl}" alt="Snapshot" onload="showImage()" onerror="showError()">
|
||||||
|
<div id="error-state" style="display:none; color: #e74c3c;">
|
||||||
|
Failed to load snapshot.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function showImage() {
|
||||||
|
document.getElementById('loading-state').style.display = 'none';
|
||||||
|
document.getElementById('result-img').style.display = 'block';
|
||||||
|
}
|
||||||
|
function showError() {
|
||||||
|
document.getElementById('loading-state').style.display = 'none';
|
||||||
|
document.getElementById('error-state').style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
return res.type('text/html').send(html);
|
||||||
|
}
|
||||||
|
|
||||||
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,6 +442,92 @@ app.use(async (req, res) => {
|
|||||||
// 拼接完整上游 URL
|
// 拼接完整上游 URL
|
||||||
const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl;
|
const upstreamUrl = UPSTREAM_HOST + '/mshots/v1/' + targetUrl;
|
||||||
|
|
||||||
|
// 浏览器访问优化:如果 Accept 包含 text/html 且没有 ?raw=true 参数
|
||||||
|
// 返回一个带有加载动画的 HTML 页面,前端再请求真实图片
|
||||||
|
if (req.headers.accept && req.headers.accept.includes('text/html') && !req.query.raw) {
|
||||||
|
const rawUrl = req.originalUrl.includes('?')
|
||||||
|
? `${req.originalUrl}&raw=true`
|
||||||
|
: `${req.originalUrl}?raw=true`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Snapshot Loading...</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
.loader {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: none; /* 初始隐藏 */
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div id="loading-state">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<div class="status">Generating snapshot for<br><strong>${targetUrl}</strong>...</div>
|
||||||
|
<div style="font-size: 12px; color: #999;">This may take up to 30 seconds if not cached.</div>
|
||||||
|
</div>
|
||||||
|
<img id="result-img" src="${rawUrl}" alt="Snapshot" onload="showImage()" onerror="showError()">
|
||||||
|
<div id="error-state" style="display:none; color: #e74c3c;">
|
||||||
|
Failed to load snapshot.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function showImage() {
|
||||||
|
document.getElementById('loading-state').style.display = 'none';
|
||||||
|
document.getElementById('result-img').style.display = 'block';
|
||||||
|
}
|
||||||
|
function showError() {
|
||||||
|
document.getElementById('loading-state').style.display = 'none';
|
||||||
|
document.getElementById('error-state').style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
return res.type('text/html').send(html);
|
||||||
|
}
|
||||||
|
|
||||||
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
return handleProxyRequest(res, upstreamUrl, targetUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user