阅读视图

发现新文章,点击刷新页面。

手冲壶练习

当年今日

周一晚上吃过晚饭,回宿舍拖了个地,然后我又回到了办公室。我把之前已经拿回了宿舍的手冲壶又拿回了办公室。我的想法是我要练一下用手冲壶注水。虽然周一下午我用普通的茶壶跟勺子的确也能做出那个很别扭的小水流的效果,但显然还是用手冲壶会方便一点。手冲壶已经买了好几年,买那个东西的时候,我就只想着那个壶的尖嘴一定要够细,那么我的水流就不会那么大。当时我完全没有考虑手冲壶到底要买多大,还有就是要不要盖子、要不要保温。这些东西在我买那个玩意的时候,我根本没有考虑过。因为买那些东西的时候,我是一个一窍不通的状态。有多少人可以一次就到位呢?如果是有高人指点,或许可以,但如果有高人指点,一开始就到位,那么你那套东西估计不会便宜,有多少人可以在一开始的时候就下定决心、狠下血本进行那样的投入呢?穷人肯定不会这么考虑,能用就行,如果有现有物资的替代方案更好。有些人可能茶壶都没有,直接拿着个电热烧水壶就开干了,也不考虑什么大水流小水流,因为电热烧水壶本来就很重,顾不上那些东西。但是当你了解越多,你会发现之前那些贪图便宜的都是歪路。比如在磨豆机上面我就走了很多歪路。唯一没有走歪路的估计就是家里那个不锈钢的滤杯,但经过长期使用以后,滤网已经被细粉堵住了,很难过滤,所以我妈突发奇想,把里面的滤网全部撕掉,然后直接加滤纸使用,这样的话清洗很方便。

要做练手,首先我就得有粉、有滤杯、滤纸以及水。第一次我是直接用平时手冲的方式,冲了一杯出来。感觉那是我近期冲得最好喝的一杯。老豆子的辛鹿的蓝冬,几乎没有很明显的酸味,后面整个咖啡的味道都比较均衡,不会感觉很淡,但问题是喝完那一杯,我又开始担心了,因为已经晚上19点多,我从来没有试过在那个时候喝咖啡,会不会睡不着呢?不知道。虽然我知道正常情况下,我对辛鹿咖啡有免疫力,他们的咖啡因含量不会太高。但也不知道是咖啡起了作用,还是运动起了作用,又或者是我睡觉之前还在看手冲咖啡的视频起了作用,相对而言,睡眠质量没平时那么好。

周一晚上那杯咖啡,我调整了一个因素,我把巫师2.0从38格调到了36格。肉眼可见,咖啡粉的粒度小了一些。可以这么说,已经一般的挂咖啡粒度要小了,之所以做这个调整,因为我第一次用V60那个陶瓷滤杯的时候,发现过滤速度非常快。我想稍微控制一下那个速度,因为速度太快感觉冲出来的咖啡没什么味道,但也有可能没什么味道跟我加水太多有关。晚上喝完第一杯咖啡以后,第二杯我基本按照平时的思路整,但是出来的那个水基本上没有任何咖啡味道了,但起码第二次的时候我还是用开水的,虽然只能说是温水,但起码是烧开过的。接下来的练手,我用的完全是自来水。同一张滤纸同一批咖啡粉,被我一次又一次地拿来练手,过滤速度越来越慢,但虽然说很慢,但相比于家里那个已经被我妈用到堵住滤网的不锈钢滤杯比起来还是挺快的。我说的慢主要是对比我冲第一次那个粉的时候的速度。在没有咖啡秤的前提下,我怎么控制加液量呢?所以我拿出了之前本来我买来进行尿液pH值测试的50毫升小烧杯。买回来以后我根本没用过,所以一个已经被我用来专门洗钢笔了,另外一个还放在那里,因为我发现直接拿着试纸测量更方便,根本不需要用烧杯接。首先我把120毫升的水用烧杯量着倒进手冲壶里,感觉到那个位置以后,就反过来操作。有些手术壶里面是有刻度的,但我买的时候根本没在乎这些。所以到底用多少量,我只能自己感觉,我没有在里面做记号,如果我拿个小刀之类的东西,估计能在那里做记号,但我没有。然后我又想出了拿个牙签,在装到120毫升的时候,用牙签做记号,拿马克笔在牙签上标记,接下来每次从水龙头接我感觉120毫升的时候就拿牙签比。这样多次反复以后,我已经八九不离十能把握那个量。看一眼那个手冲壶,就知道那里大概有没有120毫升,但关键是周一晚上睡觉之前看的那些视频,又让我否定掉了自己这个训练结果。我那个手冲壶是250毫升的。要接120毫升的水,就只有小半杯。这样的话倒的时候实际上不好控制,而且只有那么点水,水的温度也会过快降低,所以实际上,周二下午我做手冲的时候,我直接把手冲装满大半壶,然后开始操作。控制加水量完全是看着那个液面变化情况以及接滤液杯子的液面做判断的。

周二下午的那杯咖啡酸味又很突出了,这意味着萃取的水温又过低了,而之所以会过低,是因为周二跟周一相比,气温出现了跳水,而我在烫滤纸的时候没有用烧开的水,而是用保温壶里的水,但除了酸味以外,咖啡后味还行,所以这还不算完全失败。

新手和老手最大的区别,我感觉就在于新手很难复刻出一杯自己觉得好喝的咖啡。

Promise 与异步编程

Promise 是 JavaScript 中的一个重要概念,与前端的工作更是息息相关。因此本文将整理一下 Promise 在日常工作中的应用。

目录

概念

MDN | 使用 Promise 中我们能学习到 Promise 的基础使用与错误处理、组合等概念,可以将 Promise 的特点概括为:

  • Promise 对象有三种状态,且状态一旦改变就不会再变。其值记录在内部属性 [[PromiseState]] 中:
    • pending: 进行中
    • fulfilled: 已成功
    • rejected: 已失败
  • 主要用于异步计算,并且可以将异步操作队列化 (链式调用),按照期望的顺序执行,返回符合预期的结果。
  • 可以在对象之间传递和操作 Promise,帮助我们处理队列
  • 链式调用的写法更简洁,可以避免回调地狱

在现实工作中,当我们使用 Promise 时更多是对请求的管理,由于不同请求或任务的异步性。因此我们会根据不同的使用场景处理 Promise 的调度。

async/await

async/await 是基于 Promise 的一种语法糖,使得异步代码的编写和理解更加像同步代码。

当一个函数前通过 async 关键字声明,那这个函数的返回值一定会返回一个 Promise,即便函数返回的值与 Promise 无关,也会隐式包装为 Promise 对象:

1
2
3
4
5
6
async function getAge() {
return 18;
}

getAge().then(age => console.log(`age: ${age}`))
// age: 18

await

await 操作符通常和 async 是配套使用的,它会等待 Promise 并拆开 Promise 包装直接取到里面的值。当它处于 await 状态时,Promise 还处于 ``,后续的代码将不会被执行,因此看起来像是 “同步” 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function delayResolve(x, timeout = 2000) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, timeout);
});
}

async function main() {
const x = await delayResolve(2, 2000);
console.log(x);

const y = await delayResolve(1, 1000);
console.log(y);
}

main();
// 2
// 1

错误处理

async/await 的错误处理通过是通过 try..catch 来捕获错误。当然,我们也会根据实际业务的需求来 catch 我们真正需要处理的问题。

1
2
3
4
5
6
7
8
9
try {
const response = await axios.get('https://example.com/user');

// 处理响应数据
console.log('User data fetched:', response.data);
} catch (error) {
console.error('Error response:', error.response);
// 做其他错误处理
}

学习了前文的基础概念后,我们可以更近一步的探讨 Promise 的使用。

Promise 串联

Promise 串联一般是指多个 Promise 操作按顺序执行,其中每个操作的开始通常依赖于前一个操作的完成。这种串行执行的一个典型场景是在第一个异步操作完成后,其结果被用作第二个异步操作的输入,依此类推。

考虑以下场景:加载系统时,需要优先读取用户数据,同时需要用户的数据去读取用户的订单的信息,再需要两者信息生成用户报告。因此这是一个存在前后依赖的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fetchUserInfo(userId) {
return axios.get(`/api/users/${userId}`);
}

function fetchUserOrders(userId) {
return axios.get(`/api/orders/${userId}`);
}

function generateReport(userInfo, orders) {
// 根据用户信息和订单生成报告
return {
userName: userInfo.name,
totalOrders: orders.length,
// ...其他报告数据
};
}

常规处理方法

处理串联请求无非有两种方法:

方法 1: 链式调用 .then()

在这种方法中,我们利用 .then() 的链式调用来处理每个异步任务。这种方式的优点是每个步骤都明确且连贯,但可能导致所谓的“回调地狱”,尤其是在处理多个串联的异步操作时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const userId = '12345'; // 假设已知的用户ID

fetchUserInfo(userId)
.then(response => {
const userInfo = response.data;
return fetchUserOrders(userInfo.id); // 使用用户ID获取订单
})
.then(response => {
const orders = response.data;
return generateReport(userInfo, orders); // 生成报告
})
.then(report => {
console.log('用户报告:', report);
})
.catch(error => {
console.error('在处理请求时发生错误:', error);
});

方法 2: 使用 async/await

async/await 提供了一种更加直观、类似同步的方式来处理异步操作。它使代码更易于阅读和维护,特别是在处理复杂的异步逻辑时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function getUserReport(userId) {
try {
const userInfoResponse = await fetchUserInfo(userId);
const userInfo = userInfoResponse.data;

const userOrdersResponse = await fetchUserOrders(userInfo.id);
const orders = userOrdersResponse.data;

const report = generateReport(userInfo, orders);
console.log('用户报告:', report);
} catch (error) {
console.error('在处理请求时发生错误:', error);
}
}

const userId = '12345'; // 假设已知的用户ID
getUserReport(userId);

在这个示例中,使用 async/await 使得代码的逻辑更加清晰和直观,减少了代码的嵌套深度,使错误处理变得简单。

串联自动化

以上是日常工作中最常见的需求.但这里我们还可以发散一下思维,考虑更复杂的情况:

现在有一个数组,数组内有 10 个或更多的异步函数,每个函数都依赖前一个异步函数的返回值需要做处理。在这种请求多了的特殊情况下我们手动维护会显得很冗余,因此可以通过循环来简化逻辑:

方法 1: 通过数组方法 reduce 组合

1
2
3
4
5
6
7
8
9
10
11
const processFunctions = [processStep1, processStep2, processStep3, ...];

processFunctions.reduce((previousPromise, currentFunction) => {
return previousPromise.then(result => currentFunction(result));
}, Promise.resolve(initialValue))
.then(finalResult => {
console.log('最终结果:', finalResult);
})
.catch(error => {
console.error('处理过程中发生错误:', error);
});

方法 2: 循环体和 async/await 的结合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function handleSequentialTasks(tasks, initialResult) {
let result = initialResult;

try {
for (const task of tasks) {
result = await task(result);
}
console.log('最终结果:', result);
} catch (error) {
console.error('处理过程中发生错误:', error);
}
}

const tasks = [task1, task2, task3, ...];
handleSequentialTasks(tasks, initialValue);

Promise 并发

并发(Concurrency)在编程中是指多个任务在同一时间段内启动、执行,但不一定同时完成。在 JavaScript 的 Promise 中,并发通常涉及同时开始多个异步操作,并根据它们何时解决(fulfilled)或被拒绝(rejected)来进行相应的处理。

Promise 的并发会比串联的场景更复杂。Promise 对象提供了几个静态方法来处理并发情况,让开发者可以根据不同的使用场景选择合适的方法:

Promise.all(iterable)

Promise.all 静态方法接受一个 Promise 可迭代对象作为输入,当传入的数组中每个都被 resolve 后返回一个 Promise。若任意一个 Promise 被 reject 后就 reject。

1
2
3
4
5
6
7
8
9
10
11
const promise1 = fetch('https://example.com/api/data1');
const promise2 = fetch('https://example.com/api/data2');

Promise.all([promise1, promise2])
.then(([data1, data2]) => {
console.log('所有数据已加载:', data1, data2);
})
.catch(error => {
console.error('加载数据时发生错误:', error);
});

Promise.allSettled(iterable)

Promise.allSettled 方法同样接受一个 Promise 的可迭代对象。不同于 Promise.all,这个方法等待所有传入的 Promise 都被解决(无论是 fulfilled 或 rejected),然后返回一个 Promise,它解决为一个数组,每个数组元素代表对应的 Promise 的结果。这使得无论成功还是失败,你都可以得到每个 Promise 的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.allSettled([
Promise.resolve(33),
new Promise((resolve) => setTimeout(() => resolve(66), 0)),
99,
Promise.reject(new Error("an error")),
]).then((values) => console.log(values));

// [
// { status: 'fulfilled', value: 33 },
// { status: 'fulfilled', value: 66 },
// { status: 'fulfilled', value: 99 },
// { status: 'rejected', reason: Error: an error }
// ]

Promise.race(iterable)

Promise.race 方法接受一个 Promise 的可迭代对象,但与 Promise.allPromise.allSettled 不同,它不等待所有的 Promise 都被解决。相反,Promise.race 返回一个 Promise,它解决或被拒绝取决于传入的迭代对象中哪个 Promise 最先解决或被拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function sleep(time, value, state) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (state === "fulfill") {
return resolve(value);
} else {
return reject(new Error(value));
}
}, time);
});
}

const p1 = sleep(500, "one", "fulfill");
const p2 = sleep(100, "two", "fulfill");

Promise.race([p1, p2]).then((value) => {
console.log(value); // "two"
// Both fulfill, but p2 is faster
});

const p3 = sleep(100, "three", "fulfill");
const p4 = sleep(500, "four", "reject");

Promise.race([p3, p4]).then(
(value) => {
console.log(value); // "three"
// p3 is faster, so it fulfills
},
(error) => {
// Not called
},
);

const p5 = sleep(500, "five", "fulfill");
const p6 = sleep(100, "six", "reject");

Promise.race([p5, p6]).then(
(value) => {
// Not called
},
(error) => {
console.error(error.message); // "six"
// p6 is faster, so it rejects
},
);

Promise.any(iterable)

Promise.any 接受一个 Promise 的可迭代对象,并返回一个 Promise。它解决为迭代对象中第一个被解决的 Promise 的结果。如果所有的 Promise 都被拒绝,Promise.any 会返回一个 AggregateError 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "one");
});

const promise2 = new Promise((resolve, reject) => {
setTimeout(reject, 100, "two");
});

Promise.race([promise1, promise2])
.then((value) => {
console.log("succeeded with value:", value);
})
.catch((reason) => {
// Only promise1 is fulfilled, but promise2 is faster
console.error("failed with reason:", reason);
});
// failed with reason: two

控制批次

JavaScript 默认提供的并发处理函数很方便我们根据业务场景的不同来处理请求,但显然我们工作中所遇到的需求得考虑更复杂的情况,还需要进一步的封装和扩展我们的 API。

在服务器端编程,我们经常遇到需要批量处理数据的场景。例如,批量修改数据库中的用户数据。在这种情况下,由于数据库操作的性能限制或者 API 调用限制,我们不能直接一口气修改全部,因为短时间内发出太多的请求数据库也会处理不来导致应用性能下降。因此,我们需要一种方法来限制同时进行的操作任务数,以保证程序的效率和稳定性。

我们代入实际业务场景:假设有一个社区组织了一次大型户外活动,活动吸引了大量参与者进行在线报名和付费。由于突发情况(比如恶劣天气或其他不可抗力因素),活动不得不取消。这时,组织者需要对所有已付费的参与者进行退款。

活动组织者发起「解散活动」后,服务端接收到请求后当然也不能一次性全部执行退款的操作啦,毕竟一场活动说不定有上千人。因此我们需要分批次去处理。

在上述社区活动退款的例子中,服务器端处理退款请求的一个有效方法是实施分批次并发控制。这种方法不仅保护了后端服务免受过载,还确保了整个退款过程的可管理性和可靠性。

分批次处理时有以下关键问题需要考虑:

  1. 批次大小:确定每个批次中处理的退款请求数量。这个数字应基于服务器的处理能力和支付网关的限制来确定。
  2. 批次间隔:设置每个批次之间的时间间隔。这有助于避免短时间内发出过多请求,从而减轻对数据库和支付网关的压力。
  3. 错误处理:在处理退款请求时,应妥善处理可能发生的错误,并确保能够重新尝试失败的退款操作。

简易版并发控制

将所有待处理的异步任务(如退款请求)存放在一个 tasks 数组中,在调用并发请求前将 tasks 数组分割成多个小批次,每个批次包含固定数量的请求。每当前一个批次处理完后,才处理下一个批次的请求,直到所有批次的请求都被处理完毕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 假设这些是返回 Promise 的函数
const tasks = [task1, task2, task3, ...];

// 分割任务数组为批次
function splitIntoBatches(tasks, batchSize) {
let batches = [];
for (let i = 0; i < tasks.length; i += batchSize) {
batches.push(tasks.slice(i, i + batchSize));
}
return batches;
}

// 处理单个批次的函数
function processBatch(batch) {
return Promise.all(batch.map(task => task()));
}

// 控制并发的主函数
async function processTasksInBatches(tasks, batchSize) {
const batches = splitIntoBatches(tasks, batchSize);

for (const batch of batches) {
await processBatch(batch);
// 可以在这里加入日志记录或其他处理
console.log('一个批次处理完毕');
}

console.log('所有批次处理完毕');
}

// 调用主函数,假设每批次处理 10 个任务
processTasksInBatches(tasks, 10);

这种写法实现的并发简单易懂,也易于维护,在一些并发压力不大,比较简单的业务场景来看是足够了。

但如果我们将这种处理方式放在时序图上进行分析,就能发现服务器可能有能力处理更多的并发任务,而这种方法可能没有充分利用可用资源。每个批次开始前会依赖于上一个批次中请求响应时间最慢的那一个,因此我们还可以进一步考虑优化并发实现方案。

动态任务队列

在之前的 “控制批次” 方法中,我们发现固定处理批次的局限性,尤其是在并发任务数量较大时可能导致的资源利用不足。为了解决这个问题,我们可以考虑采用一种更灵活的方法:维护一个动态的任务队列来处理异步请求:

  • 任务队列:创建一个任务队列,其中包含所有待处理的异步任务。
  • 动态出队和入队:当队列中的任务完成时,它会被移出队列,同时根据当前的系统负载和任务处理能力,从待处理任务列表中拉取新的任务进入队列。
  • 并发数控制:设置一个最大并发数,确保任何时候处理中的任务数量不会超过这个限制。

我们封装一个函数,提供 concurrency 参数作为并发限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function parallelLimit(tasks, {concurrency = 10}) {
const results = [];
const executing = new Set();

let currentlyRunning = 0;
let currentIndex = 0;

return new Promise((resolve) => {
const next = () => {
if (currentIndex < tasks.length) {
// 取出记录数,准备执行
const index = currentIndex;
const task = tasks[index];

currentIndex += 1
currentlyRunning += 1;

const resultPromise = task().then((result) => {
// 任务执行完毕,更新运行数、保存结果
currentlyRunning -= 1;
results[index] = result;
executing.delete(resultPromise);

// 开启下一个任务
next();
});

executing.add(resultPromise);

// 当前运行的任务数小于限制并且还有任务未开始时,继续添加任务
if (currentlyRunning < concurrency && currentIndex < tasks.length) {
next();
}
} else if (currentlyRunning === 0) {
// 所有任务都已完成
resolve(results);
}
};

// 初始化
for (let i = 0; i < Math.min(concurrency, tasks.length); i += 1) {
next();
}
});
}

该函数会在初始阶段会按照并发数先同步执行指定任务数,若某个任务执行完毕后,在执行完毕的回调中会唤醒下一个任务,直至任务队列执行完毕。

以下添加一些测试数据用于测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
function asyncTask(id) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`任务 ${id} 完成`);
resolve(`结果 ${id}`);
}, Math.random() * 2000);
});
}
const taskArray = Array.from({ length: 10 }, (_, i) => () => asyncTask(i + 1));

parallelLimit(taskArray, {concurrency: 3}).then((results) => {
console.log('所有任务完成:', results);
});

第三方库

在实际的项目开发中,特别是面临复杂的并发处理需求时,我们更多会考虑使用成熟的第三库来处理业务问题,它们具备更完善的测试用例来检验边界情况。

处理并发的热门库有 RxJSp-mapasync.js

  • RxJS 是一个以响应式编程为核心的库,竟然搭配 Angular 在网页端搭配使用,提供了丰富的操作符和方法来处理异步事件和数据流。
  • p-mapasync.js 包的体积更小,更适合在服务端中使用。p-map 专注于提供并发控制功能,而 async.js 提供包括并发控制、队列管理等广泛的异步处理模式,功能会更全。

笔者在 Node.js 环境下只需要处理并发问题,故用的 p-map 会更多一些。下面简要介绍 p-map 的使用:

1
2
3
4
5
6
7
8
9
import pMap from 'p-map';

const list = Array.from({ length: 10 }, (_, i) => i)

pMap(list, asyncTask, { concurrency: 3 })
.then((results) => {
console.log('所有任务完成:', results);
});

p-map源码实现很精简,建议想深入复习并发的同学去阅读其底层代码的实现作为参考思路。

总结

在本文中,我们首先回顾了 Promise 的基本概念及其在 JavaScript 异步编程中的常用方法。通过这个基础,我们能够更好地理解如何有效地处理和组织异步代码。

随后,我们深入到并发处理的实际应用场景,探讨了如何根据具体需求选择合适的并发实现策略。我们讨论了从简单的批次控制到更复杂的动态任务队列的不同方法,展示了在不同场景下优化异步任务处理的多种可能性。

但值得注意的是,我们自行实现的并发控制工具在没有做足测试用例测试时,可能不适合直接应用于生产环境。在实际的项目开发中,选择成熟且持续维护的第三方库往往是更安全和高效的选择。比如笔者选择的 p-map 稳定性和可靠性相比上文简单实现的版本将会更好。


参考资料

❌