mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
feat: 字体子集化并发队列控制(服务端+客户端)
后端: - subset_queue.ts: Semaphore 并发控制器,排队超时返回 503 - subset.ts: fontSubset 调用包裹在 withConcurrencyLimit 中 - config.ts: 新增 SUBSET_CONCURRENCY 环境变量(默认 2) - routes/config.ts: 暴露 subsetConcurrency 到配置 API 前端: - api.ts: ServerConfig 新增 subsetConcurrency 字段 - webfont-sdk.js: 客户端并发池(默认 4),setMaxConcurrent API 通过 document.fonts.load 精确追踪字体加载完成释放槽位
This commit is contained in:
parent
abd75985b7
commit
65ba984972
@ -125,6 +125,8 @@ services:
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||
# Temp font retention (seconds), auto-deleted if unused. Default: 10800 (3h)
|
||||
- TEMP_RETENTION_SECONDS=10800
|
||||
# Max concurrent font subsetting. Default: 2
|
||||
- SUBSET_CONCURRENCY=2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
@ -125,6 +125,8 @@ services:
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||
# 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时)
|
||||
- TEMP_RETENTION_SECONDS=10800
|
||||
# 字体裁剪最大并发数,默认 2(内存受限环境建议 1-3)
|
||||
- SUBSET_CONCURRENCY=2
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
@ -21,5 +21,14 @@ export const tempRetentionSeconds = parseInt(env.TEMP_RETENTION_SECONDS ?? "1080
|
||||
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
|
||||
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 字体子集化最大并发数
|
||||
*
|
||||
* 字体裁剪是 CPU/内存密集操作,并发过多会导致 LLRT OOM 崩溃。
|
||||
* 默认 2:在 900M 内存限制下安全运行。
|
||||
* 服务器内存充足时可适当调大(如 4-8)。
|
||||
*/
|
||||
export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "2", 10) || 2);
|
||||
|
||||
/** 字体搜索目录(按优先级排序:admin > 普通 > 临时) */
|
||||
export const fontDirs = ["font/admin", "font", "font/temp"] as const;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { jsonResponse } from "../shared";
|
||||
import { enableTempUpload, adminApiKey, tempRetentionSeconds } from "../config";
|
||||
import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency } from "../config";
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
export async function handleGetConfig(req: Request, _res: Response) {
|
||||
@ -11,6 +11,8 @@ export async function handleGetConfig(req: Request, _res: Response) {
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
/** 临时字体保留时限(秒) */
|
||||
tempRetentionSeconds,
|
||||
/** 字体子集化最大并发数 */
|
||||
subsetConcurrency,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import { fontSubset } from "../font_util/font";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
|
||||
import { markFontUsed } from "../temp_cleaner";
|
||||
import { withConcurrencyLimit } from "../subset_queue";
|
||||
import { subsetConcurrency } from "../config";
|
||||
|
||||
/**
|
||||
* 进程启动时戳(模块加载时取一次,进程重启即变化)
|
||||
@ -108,10 +110,34 @@ export async function handleFontSubset(req: Request, res: Response) {
|
||||
/** readFontBuffer 结束时间戳(磁盘 IO / buffer 缓存命中) */
|
||||
const t2 = Date.now();
|
||||
|
||||
const newFont = await fontSubset(oldFontBuffer, text, {
|
||||
outType: outType,
|
||||
sourceType: fontType,
|
||||
/**
|
||||
* 实际子集化(CPU/内存密集)—— 通过并发队列控制
|
||||
*
|
||||
* 缓存未命中的请求才进入队列;并发满时排队等待,超时返回 503。
|
||||
* 避免大字集 brotli 压缩同时执行导致 LLRT OOM 崩溃。
|
||||
*/
|
||||
const subsetResult = await withConcurrencyLimit(subsetConcurrency, async () => {
|
||||
return fontSubset(oldFontBuffer, text, {
|
||||
outType: outType,
|
||||
sourceType: fontType,
|
||||
});
|
||||
});
|
||||
|
||||
/** 排队超时,返回 503 让客户端重试 */
|
||||
if (subsetResult === null) {
|
||||
return {
|
||||
req,
|
||||
res: new Response("Server busy, please retry", {
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Retry-After": "1",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const newFont = subsetResult;
|
||||
/** fontSubset 结束时间戳(实际裁剪,亚毫秒级应在此体现) */
|
||||
const t3 = Date.now();
|
||||
|
||||
|
||||
114
backend/subset_queue.ts
Normal file
114
backend/subset_queue.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 字体子集化并发队列控制器
|
||||
*
|
||||
* 字体裁剪是 CPU/内存密集操作(尤其大字集 woff2 brotli 压缩),
|
||||
* LLRT 运行时内存受限(~900M),并发过多会导致 brotli decoder OOM 崩溃。
|
||||
*
|
||||
* 通过 Semaphore 限制同时执行的子集化数量,超出并发的请求排队等待,
|
||||
* 等待超时则返回 503,避免请求无限堆积。
|
||||
*/
|
||||
|
||||
/** 等待队列中的请求超时时间(毫秒),默认 10 秒 */
|
||||
const QUEUE_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** 当前正在执行的子集化数量 */
|
||||
let activeCount = 0;
|
||||
/** 当前排队等待的数量 */
|
||||
let waitingCount = 0;
|
||||
|
||||
/**
|
||||
* 通过 Semaphore 执行子集化任务
|
||||
*
|
||||
* - 并发未满时立即执行
|
||||
* - 并发已满时排队等待,超时返回 null(调用方应返回 503)
|
||||
* - 返回任务结果,或 null 表示排队超时
|
||||
*
|
||||
* @param maxConcurrency 最大并发数
|
||||
* @param task 实际的子集化异步任务
|
||||
* @returns 任务结果,或 null 表示排队超时
|
||||
*/
|
||||
export async function withConcurrencyLimit<T>(
|
||||
maxConcurrency: number,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
/** 并发未满,直接执行 */
|
||||
if (activeCount < maxConcurrency) {
|
||||
activeCount++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
activeCount--;
|
||||
/** 唤醒一个等待者(如果有)—— 通过 resolve 触发 */
|
||||
notifyNext();
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发已满,进入排队 */
|
||||
waitingCount++;
|
||||
try {
|
||||
/** 等待获取许可,或超时 */
|
||||
const acquired = await waitForSlot(QUEUE_TIMEOUT_MS);
|
||||
if (!acquired) {
|
||||
/** 排队超时,返回 null 让调用方返回 503 */
|
||||
return null;
|
||||
}
|
||||
activeCount++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
activeCount--;
|
||||
notifyNext();
|
||||
}
|
||||
} finally {
|
||||
waitingCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待队列(FIFO) */
|
||||
const waitQueue: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* 等待获取一个并发许可
|
||||
*
|
||||
* @param timeout 超时时间(毫秒)
|
||||
* @returns true=获得许可,false=超时
|
||||
*/
|
||||
function waitForSlot(timeout: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
/** 超时定时器 */
|
||||
const timer = setTimeout(() => {
|
||||
/** 从队列中移除自己 */
|
||||
const idx = waitQueue.indexOf(resolver);
|
||||
if (idx !== -1) waitQueue.splice(idx, 1);
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
/** resolve 包装:清除定时器再 resolve */
|
||||
const resolver = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
waitQueue.push(resolver);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 唤醒队列中下一个等待者
|
||||
*
|
||||
* 在 activeCount 减少后调用,让排队中的请求依次进入。
|
||||
*/
|
||||
function notifyNext() {
|
||||
/** 仍有空位且有人在等 */
|
||||
const resolver = waitQueue.shift();
|
||||
if (resolver) {
|
||||
resolver();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前队列状态(用于 stats / 日志) */
|
||||
export function getQueueStats() {
|
||||
return {
|
||||
active: activeCount,
|
||||
waiting: waitingCount,
|
||||
};
|
||||
}
|
||||
@ -30,6 +30,55 @@ var WebFont = (function () {
|
||||
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */
|
||||
var loaders = {};
|
||||
|
||||
/**
|
||||
* 全局并发请求池
|
||||
*
|
||||
* 字体 @font-face 注入 DOM 后浏览器立即发起请求。
|
||||
* 页面同时加载多种字体(如列表页预览)时,短时间内大量请求打满服务端子集化队列。
|
||||
* 通过排队控制同时挂载的 @font-face 数量,避免服务端过载。
|
||||
*
|
||||
* 默认 4:在浏览器同域 6 并发限制内留出余量给其他资源。
|
||||
* 用户可通过 WebFont.setMaxConcurrent(n) 调整。
|
||||
*/
|
||||
var maxConcurrent = 4;
|
||||
/** 当前正在执行的字体加载任务数 */
|
||||
var activeFontLoads = 0;
|
||||
/** 待执行的字体加载任务队列(FIFO) */
|
||||
var fontLoadQueue = [];
|
||||
|
||||
/**
|
||||
* 设置最大并发请求数
|
||||
*
|
||||
* @param {number} n - 并发数,最小 1
|
||||
*/
|
||||
function setMaxConcurrent(n) {
|
||||
maxConcurrent = Math.max(1, n | 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过并发池执行字体加载
|
||||
*
|
||||
* @param {function} fn - 实际执行 loadChars 的函数
|
||||
*/
|
||||
function enqueueFontLoad(fn) {
|
||||
if (activeFontLoads < maxConcurrent) {
|
||||
activeFontLoads++;
|
||||
fn(doneFontLoad);
|
||||
} else {
|
||||
fontLoadQueue.push(fn);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个加载完成,唤醒队列中下一个 */
|
||||
function doneFontLoad() {
|
||||
activeFontLoads--;
|
||||
if (fontLoadQueue.length > 0 && activeFontLoads < maxConcurrent) {
|
||||
var next = fontLoadQueue.shift();
|
||||
activeFontLoads++;
|
||||
next(doneFontLoad);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 fontKey,同一字体+family 归入同一组
|
||||
*/
|
||||
@ -94,34 +143,51 @@ var WebFont = (function () {
|
||||
|
||||
/**
|
||||
* 差量加载新字符,生成 unicode-range CSS 并注入
|
||||
*
|
||||
* 通过并发队列控制:同时挂载的 @font-face 不超过 maxConcurrent,
|
||||
* 避免页面同时加载大量字体时打满服务端子集化队列。
|
||||
* @param {Object} loader - getLoader 返回的加载器对象
|
||||
* @param {string[]} newChars - 待加载的新字符数组
|
||||
*/
|
||||
function loadChars(loader, newChars) {
|
||||
if (newChars.length === 0) return;
|
||||
|
||||
var fontName = loader.fontName;
|
||||
var family = loader.family;
|
||||
var baseUrl = loader.baseUrl;
|
||||
var loadedChars = loader.loadedChars;
|
||||
enqueueFontLoad(function (done) {
|
||||
var fontName = loader.fontName;
|
||||
var family = loader.family;
|
||||
var baseUrl = loader.baseUrl;
|
||||
var loadedChars = loader.loadedChars;
|
||||
|
||||
var text = newChars.join("");
|
||||
var outType = loader.outType || "woff2";
|
||||
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
|
||||
var formatStr = outType === "woff2" ? "woff2" : "truetype";
|
||||
var unicodeRanges = newChars
|
||||
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
|
||||
.join(", ");
|
||||
var text = newChars.join("");
|
||||
var outType = loader.outType || "woff2";
|
||||
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
|
||||
var formatStr = outType === "woff2" ? "woff2" : "truetype";
|
||||
var unicodeRanges = newChars
|
||||
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
|
||||
.join(", ");
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent =
|
||||
'@font-face {\n' +
|
||||
' font-family: "' + family + '";\n' +
|
||||
' src: url("' + url + '") format("' + formatStr + '");\n' +
|
||||
' unicode-range: ' + unicodeRanges + ';\n' +
|
||||
'}\n';
|
||||
document.head.appendChild(style);
|
||||
loader.injectedStyles.push(style);
|
||||
var style = document.createElement("style");
|
||||
style.textContent =
|
||||
'@font-face {\n' +
|
||||
' font-family: "' + family + '";\n' +
|
||||
' src: url("' + url + '") format("' + formatStr + '");\n' +
|
||||
' unicode-range: ' + unicodeRanges + ';\n' +
|
||||
'}\n';
|
||||
document.head.appendChild(style);
|
||||
loader.injectedStyles.push(style);
|
||||
|
||||
/**
|
||||
* 释放并发槽位的策略:
|
||||
*
|
||||
* 优先用 FontFaceSet API 精确追踪字体加载完成;
|
||||
* 不可用时退化为 setTimeout(3 秒兜底窗口,覆盖绝大多数裁剪+传输时间)。
|
||||
*/
|
||||
if (document.fonts && document.fonts.load) {
|
||||
document.fonts.load(outType === "woff2" ? "16px \"" + family + "\"" : "16px \"" + family + "\"").then(done, function () { done(); });
|
||||
} else {
|
||||
setTimeout(done, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -424,6 +490,8 @@ var WebFont = (function () {
|
||||
loadFont: loadFont,
|
||||
observeFont: observeFont,
|
||||
loadText: loadText,
|
||||
disposeAll: disposeAll
|
||||
disposeAll: disposeAll,
|
||||
/** 设置客户端最大并发字体请求数(默认 4) */
|
||||
setMaxConcurrent: setMaxConcurrent
|
||||
};
|
||||
})();
|
||||
|
||||
@ -11,6 +11,8 @@ export interface ServerConfig {
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
/** 临时字体保留时限(秒) */
|
||||
tempRetentionSeconds?: number;
|
||||
/** 字体子集化最大并发数 */
|
||||
subsetConcurrency?: number;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user