Skip to content

Commit 0b1ded9

Browse files
committed
♻️ 优化资源加载代码:提取魔法数字、清理疑问注释、添加并发控制单元测试
- 提取魔法数字为命名常量(MAX_CONCURRENT_FETCHES, FETCH_DELAY, FETCH_SEMAPHORE_TIMEOUT, RESOURCE_CACHE_TTL) - 清理疑问注释,改为明确的设计决策说明 - 为 Semaphore 和 withTimeoutNotify 添加单元测试
1 parent e49f730 commit 0b1ded9

4 files changed

Lines changed: 213 additions & 16 deletions

File tree

src/app/service/service_worker/resource.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,17 @@ import { blobToUint8Array } from "@App/pkg/utils/datatype";
1616
import { readBlobContent } from "@App/pkg/utils/encoding";
1717
import { Semaphore, withTimeoutNotify } from "@App/pkg/utils/concurrency-control";
1818

19-
const fetchSemaphore = new Semaphore(5);
19+
/** 同时发起的最大 fetch 数量,避免大量请求冲击同一服务器 */
20+
const MAX_CONCURRENT_FETCHES = 5;
21+
/** fetch 前的随机延迟范围(ms),分散请求时间 */
22+
const FETCH_DELAY_MIN_MS = 100;
23+
const FETCH_DELAY_MAX_MS = 150;
24+
/** fetch 超时后释放信号量的时间(ms),不会中止 fetch 本身 */
25+
const FETCH_SEMAPHORE_TIMEOUT_MS = 800;
26+
/** 资源缓存过期时间(ms),24小时 */
27+
const RESOURCE_CACHE_TTL_MS = 86400_000;
28+
29+
const fetchSemaphore = new Semaphore(MAX_CONCURRENT_FETCHES);
2030

2131
export class ResourceService {
2232
logger: Logger;
@@ -135,7 +145,7 @@ export class ResourceService {
135145
const updateTime = oldResources.updatetime;
136146
// 资源最后更新是24小时内则不更新
137147
// 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源
138-
if (updateTime && updateTime > Date.now() - 86400_000) return;
148+
if (updateTime && updateTime > Date.now() - RESOURCE_CACHE_TTL_MS) return;
139149
}
140150
// 旧资源或没有资源记录或本地档案,尝试更新
141151
await this.updateResource(uuid, u, type, oldResources);
@@ -267,19 +277,22 @@ export class ResourceService {
267277
let released = false;
268278
await fetchSemaphore.acquire();
269279
// Semaphore 锁 - 同期只有五个 fetch 一起执行
270-
const delay = randNum(100, 150); // 100~150ms delay before starting fetch
280+
const delay = randNum(FETCH_DELAY_MIN_MS, FETCH_DELAY_MAX_MS);
271281
await sleep(delay);
272-
// 执行 fetch, 若超过 800ms, 不会中止 fetch 但会启动下一个网络连接任务
273-
// 这只为了避免等候时间过长,同时又不会有过多网络任务同时发生,使Web伺服器返回错误
274-
const { result, err } = await withTimeoutNotify(fetch(url), 800, ({ done, timeouted, err }) => {
275-
if (timeouted || done || err) {
276-
// fetch 成功 或 发生错误 或 timeout 时解锁
277-
if (!released) {
278-
released = true;
279-
fetchSemaphore.release();
282+
// 执行 fetch, 若超时则不中止 fetch 但释放信号量,让下一个任务启动
283+
const { result, err } = await withTimeoutNotify(
284+
fetch(url),
285+
FETCH_SEMAPHORE_TIMEOUT_MS,
286+
({ done, timeouted, err }) => {
287+
if (timeouted || done || err) {
288+
// fetch 成功 或 发生错误 或 timeout 时解锁
289+
if (!released) {
290+
released = true;
291+
fetchSemaphore.release();
292+
}
280293
}
281294
}
282-
});
295+
);
283296
// Semaphore 锁已解锁。继续处理 fetch Response 的结果
284297

285298
if (err) {

src/app/service/service_worker/runtime.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,7 +1211,7 @@ export class RuntimeService {
12111211
// 如果有 校验hash 的话,根本不用更新本地资源呀!
12121212
continue;
12131213
}
1214-
// const oldResources = await this.resource.getResourceModel(u);
1214+
// 这里不用 getResourceModel 是因为上面已经跳过了有 hash 的 URL,无需 SRI 校验
12151215
const oldResources = await this.resource.resourceDAO.get(u.url);
12161216
const updatedResource = await this.resource.updateResource(scriptRes.uuid, u, type, oldResources);
12171217
if (!updatedResource || !updatedResource.contentType || updatedResource === oldResources) {
@@ -1220,7 +1220,7 @@ export class RuntimeService {
12201220
continue;
12211221
}
12221222
if (updatedResource.hash?.sha512 !== sha512) {
1223-
// ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 -----
1223+
// updateResource 更新的是数据库,这里更新的是内存中的 scriptRes.resource 对象
12241224
for (const uri of resourceList) {
12251225
/** 资源键名 */
12261226
let resourceKey = uri;
@@ -1250,7 +1250,6 @@ export class RuntimeService {
12501250
}
12511251
}
12521252
}
1253-
// ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 -----
12541253
}
12551254
}
12561255
if (resourceUpdated) {

src/app/service/service_worker/script.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ export class ScriptService {
430430
compiledResourceUpdatePromise,
431431
this.resourceService.updateResourceByTypes(script, ["require", "require-css", "resource"]),
432432
]);
433-
// 如果资源不完整,还是要接受安装吗???
433+
// 资源下载失败不阻止安装,失败不影响安装
434434

435435
// 广播一下
436436
// Runtime 会负责更新 CompiledResource
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { Semaphore, withTimeoutNotify } from "./concurrency-control";
3+
4+
describe("Semaphore", () => {
5+
it.concurrent("limit 小于 1 时抛出错误", () => {
6+
expect(() => new Semaphore(0)).toThrow("limit must be >= 1");
7+
expect(() => new Semaphore(-1)).toThrow("limit must be >= 1");
8+
});
9+
10+
it.concurrent("未达到限制时 acquire 立即返回", async () => {
11+
const sem = new Semaphore(2);
12+
await sem.acquire();
13+
await sem.acquire();
14+
// 两次 acquire 都不应阻塞
15+
sem.release();
16+
sem.release();
17+
});
18+
19+
it.concurrent("达到限制时 acquire 阻塞,release 后恢复", async () => {
20+
const sem = new Semaphore(1);
21+
const order: number[] = [];
22+
23+
await sem.acquire();
24+
order.push(1);
25+
26+
const blocked = sem.acquire().then(() => {
27+
order.push(3);
28+
});
29+
30+
// 等一个 microtask,确认 blocked 还没执行
31+
await Promise.resolve();
32+
order.push(2);
33+
34+
sem.release();
35+
await blocked;
36+
37+
expect(order).toEqual([1, 2, 3]);
38+
sem.release();
39+
});
40+
41+
it.concurrent("并发数不超过限制", async () => {
42+
const limit = 3;
43+
const sem = new Semaphore(limit);
44+
let concurrent = 0;
45+
let maxConcurrent = 0;
46+
47+
const task = async () => {
48+
await sem.acquire();
49+
concurrent++;
50+
maxConcurrent = Math.max(maxConcurrent, concurrent);
51+
// 模拟异步操作
52+
await new Promise((r) => setTimeout(r, 10));
53+
concurrent--;
54+
sem.release();
55+
};
56+
57+
await Promise.all(Array.from({ length: 10 }, () => task()));
58+
59+
expect(maxConcurrent).toBe(limit);
60+
});
61+
62+
it.concurrent("按 FIFO 顺序唤醒等待者", async () => {
63+
const sem = new Semaphore(1);
64+
const order: number[] = [];
65+
66+
await sem.acquire();
67+
68+
const p1 = sem.acquire().then(() => {
69+
order.push(1);
70+
sem.release();
71+
});
72+
const p2 = sem.acquire().then(() => {
73+
order.push(2);
74+
sem.release();
75+
});
76+
const p3 = sem.acquire().then(() => {
77+
order.push(3);
78+
sem.release();
79+
});
80+
81+
sem.release();
82+
await Promise.all([p1, p2, p3]);
83+
84+
expect(order).toEqual([1, 2, 3]);
85+
});
86+
87+
it.concurrent("double release 输出警告", () => {
88+
const sem = new Semaphore(1);
89+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
90+
91+
sem.release();
92+
expect(warnSpy).toHaveBeenCalledWith("Semaphore double release detected");
93+
94+
warnSpy.mockRestore();
95+
});
96+
});
97+
98+
describe("withTimeoutNotify", () => {
99+
it.concurrent("promise 在超时前完成时,回调收到 done=true", async () => {
100+
const promise = Promise.resolve("ok");
101+
const calls: Array<{ done: boolean; timeouted: boolean }> = [];
102+
103+
const res = await withTimeoutNotify(promise, 1000, (r) => {
104+
calls.push({ done: r.done, timeouted: r.timeouted });
105+
});
106+
107+
expect(res.result).toBe("ok");
108+
expect(res.done).toBe(true);
109+
expect(res.timeouted).toBe(false);
110+
expect(res.err).toBeUndefined();
111+
// 只调用一次(done),不触发 timeout
112+
expect(calls).toEqual([{ done: true, timeouted: false }]);
113+
});
114+
115+
it.concurrent("promise 在超时前失败时,回调收到 err", async () => {
116+
const error = new Error("fail");
117+
const promise = Promise.reject(error);
118+
const calls: Array<{ done: boolean; err: Error | undefined }> = [];
119+
120+
const res = await withTimeoutNotify(promise, 1000, (r) => {
121+
calls.push({ done: r.done, err: r.err });
122+
});
123+
124+
expect(res.err).toBe(error);
125+
expect(res.done).toBe(true);
126+
expect(res.result).toBeUndefined();
127+
expect(calls).toEqual([{ done: true, err: error }]);
128+
});
129+
130+
it.concurrent("超时后回调被调用,promise 完成后再次调用", async () => {
131+
vi.useFakeTimers();
132+
let resolvePromise: (v: string) => void;
133+
const promise = new Promise<string>((r) => {
134+
resolvePromise = r;
135+
});
136+
const calls: Array<{ done: boolean; timeouted: boolean }> = [];
137+
138+
const resultPromise = withTimeoutNotify(promise, 100, (r) => {
139+
calls.push({ done: r.done, timeouted: r.timeouted });
140+
});
141+
142+
// 触发超时
143+
vi.advanceTimersByTime(100);
144+
expect(calls).toEqual([{ done: false, timeouted: true }]);
145+
146+
// promise 完成
147+
resolvePromise!("late");
148+
const res = await resultPromise;
149+
150+
expect(res.result).toBe("late");
151+
expect(res.done).toBe(true);
152+
expect(res.timeouted).toBe(true);
153+
// 回调被调用两次:timeout + done
154+
expect(calls).toHaveLength(2);
155+
expect(calls[1]).toEqual({ done: true, timeouted: true });
156+
157+
vi.useRealTimers();
158+
});
159+
160+
it.concurrent("超时后 promise 失败,回调也被调用两次", async () => {
161+
vi.useFakeTimers();
162+
let rejectPromise: (e: Error) => void;
163+
const promise = new Promise<string>((_, reject) => {
164+
rejectPromise = reject;
165+
});
166+
const calls: Array<{ timeouted: boolean; err: Error | undefined }> = [];
167+
168+
const resultPromise = withTimeoutNotify(promise, 50, (r) => {
169+
calls.push({ timeouted: r.timeouted, err: r.err });
170+
});
171+
172+
vi.advanceTimersByTime(50);
173+
expect(calls).toHaveLength(1);
174+
175+
const error = new Error("network error");
176+
rejectPromise!(error);
177+
const res = await resultPromise;
178+
179+
expect(res.err).toBe(error);
180+
expect(calls).toHaveLength(2);
181+
expect(calls[1]).toEqual({ timeouted: true, err: error });
182+
183+
vi.useRealTimers();
184+
});
185+
});

0 commit comments

Comments
 (0)