From 4da1dc74954fafe4d21841892bafc955a37ac805 Mon Sep 17 00:00:00 2001 From: muqiuhan Date: Tue, 27 May 2025 06:06:45 +0000 Subject: deploy: 74a1d57ead41454de540829cd6de5ba1f5702bd6 --- .../index.html" | 10 +-- .../index.html" | 2 +- .../index.html" | 12 ++-- .../index.html" | 16 ++--- .../index.html" | 2 +- .../index.html" | 30 ++++----- .../index.html" | 40 ++++++------ 2025/03/25/Scala-3-Capture-Checking/index.html | 72 +++++++++++----------- .../index.html" | 26 ++++---- .../index.html" | 26 ++++---- 2025/04/13/uuidv7-rdbms/index.html | 2 +- 2025/05/07/nestjs-bullmq-mail-business/index.html | 16 ++--- .../index.html | 8 +-- 2025/05/27/vertical-slicing-practice/index.html | 44 ++++++------- 14 files changed, 153 insertions(+), 153 deletions(-) (limited to '2025') diff --git "a/2025/02/10/\345\256\236\346\227\266\346\220\234\347\264\242\344\270\255\347\232\204\351\230\262\346\212\226\345\207\275\346\225\260/index.html" "b/2025/02/10/\345\256\236\346\227\266\346\220\234\347\264\242\344\270\255\347\232\204\351\230\262\346\212\226\345\207\275\346\225\260/index.html" index 168f08af..9e826d10 100644 --- "a/2025/02/10/\345\256\236\346\227\266\346\220\234\347\264\242\344\270\255\347\232\204\351\230\262\346\212\226\345\207\275\346\225\260/index.html" +++ "b/2025/02/10/\345\256\236\346\227\266\346\220\234\347\264\242\344\270\255\347\232\204\351\230\262\346\212\226\345\207\275\346\225\260/index.html" @@ -194,9 +194,9 @@

在实现实时搜索功能时,通常会使用输入框的事件监听器来捕获用户的输入变化,并在输入变化时发送搜索请求。为了避免过多的请求导致服务器负担过重,通常会使用“防抖”(debounce)技术来控制请求的频率。

实现步骤

    -
  1. 监听输入框的变化:使用input事件监听器来捕获用户的输入变化。
  2. -
  3. 防抖处理:使用防抖函数来限制请求的频率。防抖函数会在用户停止输入一段时间后才发送请求。
  4. -
  5. 发送请求:在防抖函数中调用搜索请求。
  6. +
  7. 监听输入框的变化:使用input事件监听器来捕获用户的输入变化。
  8. +
  9. 防抖处理:使用防抖函数来限制请求的频率。防抖函数会在用户停止输入一段时间后才发送请求。
  10. +
  11. 发送请求:在防抖函数中调用搜索请求。

防抖函数示例

以下是一个简单的防抖函数示例:

1
2
3
4
5
6
7
8
function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
@@ -205,8 +205,8 @@
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
<script>
import { onMount } from 'svelte';
import { patientsStore } from '$lib/stores/patients.svelte';

let searchTerm = '';

// 防抖函数
function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}

// 搜索函数
const searchPatients = debounce(async (term) => {
if (term) {
// 发送搜索请求
const response = await fetch(`/api/search-patients?query=${term}`);
const data = await response.json();
patientsStore.mbglPatients = data;
} else {
// 清空搜索结果或恢复默认数据
patientsStore.mbglPatients = [];
}
}, 300); // 300ms 的防抖时间

// 监听输入框变化
function handleInput(event) {
searchTerm = event.target.value;
searchPatients(searchTerm);
}
</script>

<input type="text" placeholder="搜索患者..." on:input={handleInput} bind:value={searchTerm} />

请求发送间隔

通过这种方式,你可以实现一个高效的实时搜索功能,既能保证用户体验,又能减少服务器的负担。

diff --git "a/2025/02/18/Prisma-\345\205\263\347\263\273\345\236\213\346\225\260\346\215\256\345\272\223\347\232\204-Self-relations/index.html" "b/2025/02/18/Prisma-\345\205\263\347\263\273\345\236\213\346\225\260\346\215\256\345\272\223\347\232\204-Self-relations/index.html" index b69d4397..7a025e10 100644 --- "a/2025/02/18/Prisma-\345\205\263\347\263\273\345\236\213\346\225\260\346\215\256\345\272\223\347\232\204-Self-relations/index.html" +++ "b/2025/02/18/Prisma-\345\205\263\347\263\273\345\236\213\346\225\260\346\215\256\345\272\223\347\232\204-Self-relations/index.html" @@ -207,7 +207,7 @@

一对一的 self-relation 需要两个端点,即使这两个端点是同一条数据。

diff --git "a/2025/03/13/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\347\232\204\342\200\234\350\201\232\345\220\210\346\240\271\342\200\235/index.html" "b/2025/03/13/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\347\232\204\342\200\234\350\201\232\345\220\210\346\240\271\342\200\235/index.html" index 56df60c9..be156f43 100644 --- "a/2025/03/13/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\347\232\204\342\200\234\350\201\232\345\220\210\346\240\271\342\200\235/index.html" +++ "b/2025/03/13/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\347\232\204\342\200\234\350\201\232\345\220\210\346\240\271\342\200\235/index.html" @@ -195,15 +195,15 @@

在领域驱动设计(Domain-Driven Design,简称DDD)中,聚合根(Aggregate Root)是聚合(Aggregate)中的核心实体,是一个聚合的入口点和控制者,负责维护聚合内部的一致性和不变性条件。聚合是一组紧密相关的领域对象的集合,这些对象通过一定的业务规则绑定在一起,并被视为一个单元。

主要的作用如下:

    -
  • 维护不变性:聚合根确保聚合内所有对象的一致性和不变性条件不被破坏。它负责封装与聚合相关的业务逻辑,保证聚合内的对象符合业务规则。
  • -
  • 管理生命周期:聚合根负责管理其内部对象的创建、修改和删除。它控制着聚合内部成员的生命周期,包括它们的创建、更新和删除。
  • -
  • 处理业务逻辑:聚合根负责处理与聚合相关的业务逻辑和操作,外部系统通过调用聚合根的方法来执行这些操作。它不仅是数据的容器,还负责封装与聚合相关的业务逻辑。
  • +
  • 维护不变性:聚合根确保聚合内所有对象的一致性和不变性条件不被破坏。它负责封装与聚合相关的业务逻辑,保证聚合内的对象符合业务规则。
  • +
  • 管理生命周期:聚合根负责管理其内部对象的创建、修改和删除。它控制着聚合内部成员的生命周期,包括它们的创建、更新和删除。
  • +
  • 处理业务逻辑:聚合根负责处理与聚合相关的业务逻辑和操作,外部系统通过调用聚合根的方法来执行这些操作。它不仅是数据的容器,还负责封装与聚合相关的业务逻辑。

其具有以下特性:

    -
  • 唯一入口:聚合根是聚合内部对象的唯一入口,外部系统只能与聚合根交互,而无法直接访问聚合内部的其他对象。这样可以避免外部系统直接修改聚合内的实体,确保聚合的一致性和业务逻辑的完整性。
  • -
  • 标识唯一性:每个聚合根都有一个全局唯一的标识符(ID),用以区分不同的聚合实例。
  • -
  • 事务边界:聚合根常常作为事务的边界,确保事务内的所有操作要么全部成功,要么全部失败,以此来维护数据的完整性。
  • +
  • 唯一入口:聚合根是聚合内部对象的唯一入口,外部系统只能与聚合根交互,而无法直接访问聚合内部的其他对象。这样可以避免外部系统直接修改聚合内的实体,确保聚合的一致性和业务逻辑的完整性。
  • +
  • 标识唯一性:每个聚合根都有一个全局唯一的标识符(ID),用以区分不同的聚合实例。
  • +
  • 事务边界:聚合根常常作为事务的边界,确保事务内的所有操作要么全部成功,要么全部失败,以此来维护数据的完整性。

用 F# 来描述,以订单管理为例,大概写一下:

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
type OrderStatus = 
| New
| Shipped
| Delivered
| Cancelled

type OrderItem (productName: string, price: float, quantity: int) =
do
if quantity <= 0 then
failwith "Quantity must be positive"

member public this.ProductName = productName
member public this.Price = price
member public this.Quantity = quantity
member public this.TotalPrice () = price * quantity

type Order (id: int, customerName: string) =
let mutable status = OrderStatus.New
let mutable orderItems = []

member public this.Id = id
member public this.CustomerName = customerName
member public this.Status = status
member public this.OrderItems = orderItems

member public this.AddItem (item: OrderItem, price: float, quantity: int) =
if quantity <= 0 then
failwith "Quantity must be positive"

orderItems <- orderItems @ [OrderItem(item.ProductName, price, quantity)]

member public this.ChangeStatus (status: OrderStatus) =
this.Status <- status

member public this.TotalPrice () =
orderItems |> List.sumBy (fun item -> item.TotalPrice())

member public this.GetTotalPrice () =
orderItems |> List.sumBy (fun item -> item.TotalPrice())
diff --git "a/2025/03/14/DDD-\344\270\255\347\232\204-Ubiquitous-Languages/index.html" "b/2025/03/14/DDD-\344\270\255\347\232\204-Ubiquitous-Languages/index.html" index ff6bf73f..2119c761 100644 --- "a/2025/03/14/DDD-\344\270\255\347\232\204-Ubiquitous-Languages/index.html" +++ "b/2025/03/14/DDD-\344\270\255\347\232\204-Ubiquitous-Languages/index.html" @@ -195,17 +195,17 @@

Ubiquitous Language(通用语言)是 Domain-Driven Design (DDD) 中的一个核心概念。它是一种共享的、统一的语言,用于描述和讨论领域模型中的概念和规则。Ubiquitous Language 的目的是确保开发团队和业务专家之间的沟通更加高效和准确,从而减少误解和错误。

一般来说,Ubiquitous Language 有以下特点:

    -
  1. 共享:Ubiquitous Language 是由开发团队和业务专家共同创建和使用的。它不仅用于代码和文档,还用于日常的沟通和讨论。
  2. -
  3. 统一:在整个项目中,Ubiquitous Language 应该是一致的。无论是在代码、文档、会议还是白板上,都应该使用相同的术语和概念。
  4. -
  5. 精确:Ubiquitous Language 应该尽可能精确,避免模糊和歧义。每个术语都应该有明确的定义和含义。
  6. -
  7. 演进:Ubiquitous Language 是动态的,会随着项目的进展和业务需求的变化而演进。团队应该定期审查和更新它。
  8. +
  9. 共享:Ubiquitous Language 是由开发团队和业务专家共同创建和使用的。它不仅用于代码和文档,还用于日常的沟通和讨论。
  10. +
  11. 统一:在整个项目中,Ubiquitous Language 应该是一致的。无论是在代码、文档、会议还是白板上,都应该使用相同的术语和概念。
  12. +
  13. 精确:Ubiquitous Language 应该尽可能精确,避免模糊和歧义。每个术语都应该有明确的定义和含义。
  14. +
  15. 演进:Ubiquitous Language 是动态的,会随着项目的进展和业务需求的变化而演进。团队应该定期审查和更新它。

基于此,它可以用在这些地方:

    -
  1. 命名:在代码中使用 Ubiquitous Language 来命名类、方法、变量等。例如,如果业务专家使用“订单”来描述一个概念,那么在代码中也应该使用“Order”而不是“Purchase”或“Transaction”。
  2. -
  3. 文档:在文档中使用 Ubiquitous Language 来描述系统的设计、架构和功能。这有助于业务专家更容易理解技术文档。
  4. -
  5. 沟通:在团队会议、讨论和白板会议中使用 Ubiquitous Language。这有助于确保所有参与者都在讨论同一个概念。
  6. -
  7. 测试:在编写测试用例时,使用 Ubiquitous Language 来描述测试的前提条件、步骤和预期结果。这有助于确保测试覆盖了业务需求。
  8. +
  9. 命名:在代码中使用 Ubiquitous Language 来命名类、方法、变量等。例如,如果业务专家使用“订单”来描述一个概念,那么在代码中也应该使用“Order”而不是“Purchase”或“Transaction”。
  10. +
  11. 文档:在文档中使用 Ubiquitous Language 来描述系统的设计、架构和功能。这有助于业务专家更容易理解技术文档。
  12. +
  13. 沟通:在团队会议、讨论和白板会议中使用 Ubiquitous Language。这有助于确保所有参与者都在讨论同一个概念。
  14. +
  15. 测试:在编写测试用例时,使用 Ubiquitous Language 来描述测试的前提条件、步骤和预期结果。这有助于确保测试覆盖了业务需求。

假设正在开发一个电子商务系统,业务专家使用“订单”来描述用户购买的商品集合。团队可以在代码中使用“Order”来命名相关的类和方法:

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
type Order = {
items: OrderItem list
customer: Customer
orderDate: DateTime
}

type OrderItem = {
product: Product
quantity: int
}

type Customer = {
name: string
}

type Product = {
name: string
price: float
}

let addItem (order: Order) (item: OrderItem) =
{ order with items = order.items @ [item] }

let getTotalAmount (order: Order) =
let total = 0.0
for item in order.items do
total <- total + item.price
total

let getPrice (item: OrderItem) =
item.product.price * item.quantity

let getTotalPrice (order: Order) =
let total = 0.0
for item in order.items do
total <- total + getPrice(item)
total
diff --git "a/2025/03/19/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\350\201\232\345\220\210\346\240\271\346\214\201\344\271\205\345\214\226\345\222\214\344\272\213\344\273\266\345\217\221\345\270\203\345\217\257\350\203\275\345\257\274\350\207\264\346\225\260\346\215\256\344\270\215\344\270\200\350\207\264\351\227\256\351\242\230/index.html" "b/2025/03/19/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\350\201\232\345\220\210\346\240\271\346\214\201\344\271\205\345\214\226\345\222\214\344\272\213\344\273\266\345\217\221\345\270\203\345\217\257\350\203\275\345\257\274\350\207\264\346\225\260\346\215\256\344\270\215\344\270\200\350\207\264\351\227\256\351\242\230/index.html" index f899eeff..de021da0 100644 --- "a/2025/03/19/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\350\201\232\345\220\210\346\240\271\346\214\201\344\271\205\345\214\226\345\222\214\344\272\213\344\273\266\345\217\221\345\270\203\345\217\257\350\203\275\345\257\274\350\207\264\346\225\260\346\215\256\344\270\215\344\270\200\350\207\264\351\227\256\351\242\230/index.html" +++ "b/2025/03/19/\351\242\206\345\237\237\351\251\261\345\212\250\350\256\276\350\256\241\344\270\255\350\201\232\345\220\210\346\240\271\346\214\201\344\271\205\345\214\226\345\222\214\344\272\213\344\273\266\345\217\221\345\270\203\345\217\257\350\203\275\345\257\274\350\207\264\346\225\260\346\215\256\344\270\215\344\270\200\350\207\264\351\227\256\351\242\230/index.html" @@ -192,7 +192,7 @@
-

使用领域事件的一种直接做法是:在 应用服务 (Application Service) 中产生事件并发布出去。例如,对于“用户昵称更新”的场景来讲,对应的应用服务 UserCommandService 实现如下:

+

使用领域事件的一种直接做法是:在 应用服务 (Application Service) 中产生事件并发布出去。例如,对于“用户昵称更新”的场景来讲,对应的应用服务 UserCommandService 实现如下:

1
2
3
4
5
6
7
8
9
10
member public this.UpdateMyName (command: UpdateUsernameCommand) (user: User) =
let user = userRepository.GetById user.Id
let oldName = user.Username
let newName = command.Username

user.UpdateUsername newName
|> userRepository.Save

UsernameChangeEvent (user.Id, newName, oldName)
|> eventPublisher.Publish

这里,在更新了用户姓名之后,即刻调用事件发布器 eventPublisher.Publish 将事件发送到消息队列中。虽然这种方式比较流行,但它至少存在两个问题:

diff --git "a/2025/03/20/TDD-\345\222\214-DDD-\347\232\204\344\270\200\344\272\233\345\260\217\346\203\263\346\263\225/index.html" "b/2025/03/20/TDD-\345\222\214-DDD-\347\232\204\344\270\200\344\272\233\345\260\217\346\203\263\346\263\225/index.html" index e1b89568..a05a3f92 100644 --- "a/2025/03/20/TDD-\345\222\214-DDD-\347\232\204\344\270\200\344\272\233\345\260\217\346\203\263\346\263\225/index.html" +++ "b/2025/03/20/TDD-\345\222\214-DDD-\347\232\204\344\270\200\344\272\233\345\260\217\346\203\263\346\263\225/index.html" @@ -194,27 +194,27 @@

Test-Driven Development (TDD) 和 Domain-Driven Design (DDD) 是两种不同的软件开发方法论,各自有其独特的优缺点和应用场景。

Test-Driven Development (TDD)

优点:

    -
  1. 提高代码质量:通过编写测试来驱动开发,可以确保代码的正确性和可靠性。
  2. -
  3. 减少缺陷:早期发现和修复缺陷,减少后期的调试和维护成本。
  4. -
  5. 促进可维护性:代码更加模块化和可测试,便于后续的维护和扩展。
  6. -
  7. 文档化:测试代码本身就是一种文档,描述了系统的行为和期望。
  8. -
  9. 提高开发速度:虽然初期可能会慢一些,但长期来看,由于减少了重构和调试的时间,开发速度会提高。
  10. +
  11. 提高代码质量:通过编写测试来驱动开发,可以确保代码的正确性和可靠性。
  12. +
  13. 减少缺陷:早期发现和修复缺陷,减少后期的调试和维护成本。
  14. +
  15. 促进可维护性:代码更加模块化和可测试,便于后续的维护和扩展。
  16. +
  17. 文档化:测试代码本身就是一种文档,描述了系统的行为和期望。
  18. +
  19. 提高开发速度:虽然初期可能会慢一些,但长期来看,由于减少了重构和调试的时间,开发速度会提高。

缺点:

    -
  1. 初期投入大:需要在开发前编写测试,初期投入的时间和精力较大。
  2. -
  3. 学习曲线陡峭:对新手开发者来说,学习和掌握TDD需要一定的时间。
  4. -
  5. 过度依赖测试:可能导致过度依赖单元测试,忽略了系统的整体性能和用户体验。
  6. +
  7. 初期投入大:需要在开发前编写测试,初期投入的时间和精力较大。
  8. +
  9. 学习曲线陡峭:对新手开发者来说,学习和掌握TDD需要一定的时间。
  10. +
  11. 过度依赖测试:可能导致过度依赖单元测试,忽略了系统的整体性能和用户体验。

Domain-Driven Design (DDD)

优点:

    -
  1. 聚焦领域:通过深入理解业务领域,确保软件系统与业务需求紧密结合。
  2. -
  3. 模型驱动:建立一个清晰的领域模型,帮助开发者和业务专家共同理解系统。
  4. -
  5. 可维护性:通过明确的领域模型和边界,系统结构更加清晰,便于维护和扩展。
  6. -
  7. 沟通桥梁:提供了一种通用的语言(Ubiquitous Language),促进开发团队和业务团队之间的沟通。
  8. +
  9. 聚焦领域:通过深入理解业务领域,确保软件系统与业务需求紧密结合。
  10. +
  11. 模型驱动:建立一个清晰的领域模型,帮助开发者和业务专家共同理解系统。
  12. +
  13. 可维护性:通过明确的领域模型和边界,系统结构更加清晰,便于维护和扩展。
  14. +
  15. 沟通桥梁:提供了一种通用的语言(Ubiquitous Language),促进开发团队和业务团队之间的沟通。

缺点:

    -
  1. 复杂性高:DDD方法论较为复杂,需要深入理解领域模型和设计模式。
  2. -
  3. 初期投入大:需要大量的时间和精力进行领域分析和建模。
  4. -
  5. 适用范围有限:对于简单的系统或小型项目,DDD可能显得过于复杂和冗余。
  6. +
  7. 复杂性高:DDD方法论较为复杂,需要深入理解领域模型和设计模式。
  8. +
  9. 初期投入大:需要大量的时间和精力进行领域分析和建模。
  10. +
  11. 适用范围有限:对于简单的系统或小型项目,DDD可能显得过于复杂和冗余。

TDD 和 DDD 并不冲突,实际上它们可以互补。TDD 可以帮助确保代码的正确性和可靠性,而 DDD 可以确保系统与业务需求紧密结合。在进行 TDD 时,可以使用 DDD 的领域模型和 Ubiquitous Language 来编写测试用例,确保测试覆盖了业务需求。在进行 DDD 时,可以使用 TDD 来驱动实现,确保每个领域模型的实现。

diff --git "a/2025/03/20/\350\202\272\345\212\237\350\203\275\346\243\200\346\237\245\346\225\260\345\200\274/index.html" "b/2025/03/20/\350\202\272\345\212\237\350\203\275\346\243\200\346\237\245\346\225\260\345\200\274/index.html" index fc5c0117..ed5ed1d8 100644 --- "a/2025/03/20/\350\202\272\345\212\237\350\203\275\346\243\200\346\237\245\346\225\260\345\200\274/index.html" +++ "b/2025/03/20/\350\202\272\345\212\237\350\203\275\346\243\200\346\237\245\346\225\260\345\200\274/index.html" @@ -194,42 +194,42 @@

肺功能测试是评估患者呼吸系统健康的重要工具。各个指标的及其异常数值可能指示的潜在生理疾病:

1. FEV1(第一秒用力呼气量)

    -
  • 评估:FEV1用于评估气道的通畅程度。它反映了在用力呼气的第一秒内,患者能够排出的气体量。
  • -
  • 异常:FEV1降低通常提示气道阻塞,常见于慢性阻塞性肺病(COPD)、哮喘、支气管炎等疾病。
  • +
  • 评估:FEV1用于评估气道的通畅程度。它反映了在用力呼气的第一秒内,患者能够排出的气体量。
  • +
  • 异常:FEV1降低通常提示气道阻塞,常见于慢性阻塞性肺病(COPD)、哮喘、支气管炎等疾病。

2. FVC(用力肺活量)

    -
  • 评估:FVC测量的是患者在一次用力呼气中能够排出的最大气体量,反映了肺的容量和扩张能力。
  • -
  • 异常:FVC降低可能指示限制性肺病,如肺纤维化、胸廓畸形或神经肌肉疾病等。
  • +
  • 评估:FVC测量的是患者在一次用力呼气中能够排出的最大气体量,反映了肺的容量和扩张能力。
  • +
  • 异常:FVC降低可能指示限制性肺病,如肺纤维化、胸廓畸形或神经肌肉疾病等。

3. FEV1/FVC比值

    -
  • 评估:FEV1/FVC比值用于区分阻塞性和限制性肺病。正常情况下,该比值应大于70%。
  • -
  • 异常:
      -
    • **低于70%**:提示气道阻塞,常见于COPD和哮喘。
    • -
    • **正常或高于70%**但FVC降低:可能提示限制性肺病。
    • +
    • 评估:FEV1/FVC比值用于区分阻塞性和限制性肺病。正常情况下,该比值应大于70%。
    • +
    • 异常:
        +
      • 低于70%:提示气道阻塞,常见于COPD和哮喘。
      • +
      • 正常或高于70%但FVC降低:可能提示限制性肺病。

    4. PEF(峰值呼气流量)

      -
    • 评估:PEF测量患者在用力呼气时达到的最大流速,常用于监测哮喘患者的病情变化。
    • -
    • 异常:PEF降低可能提示气道狭窄或阻塞,常见于哮喘急性发作或COPD加重。
    • +
    • 评估:PEF测量患者在用力呼气时达到的最大流速,常用于监测哮喘患者的病情变化。
    • +
    • 异常:PEF降低可能提示气道狭窄或阻塞,常见于哮喘急性发作或COPD加重。

    5. MVV(最大通气量)

      -
    • 评估:MVV测量在一定时间内(通常是12秒)能够进行的最大通气量,反映了肺部的通气能力和呼吸肌的力量。
    • -
    • 异常:MVV降低可能与呼吸肌无力、气道阻塞或肺部疾病(如COPD)相关。
    • +
    • 评估:MVV测量在一定时间内(通常是12秒)能够进行的最大通气量,反映了肺部的通气能力和呼吸肌的力量。
    • +
    • 异常:MVV降低可能与呼吸肌无力、气道阻塞或肺部疾病(如COPD)相关。

    6. TLC(总肺容量)

      -
    • 评估:TLC测量肺部在最大吸气后所能容纳的气体总量,反映了肺的整体容量。
    • -
    • 异常:
        -
      • 增加:可能与阻塞性肺病(如COPD)相关,因肺部过度膨胀。
      • -
      • 降低:可能与限制性肺病(如肺纤维化、胸廓畸形)相关。
      • +
      • 评估:TLC测量肺部在最大吸气后所能容纳的气体总量,反映了肺的整体容量。
      • +
      • 异常:
          +
        • 增加:可能与阻塞性肺病(如COPD)相关,因肺部过度膨胀。
        • +
        • 降低:可能与限制性肺病(如肺纤维化、胸廓畸形)相关。

      7. RV(残气量)

        -
      • 评估:RV测量在最大呼气后,肺内仍然残留的气体量。
      • -
      • 异常:
          -
        • 增加:常见于阻塞性肺病,因气道阻塞导致气体无法完全排出。
        • -
        • 降低:可能与限制性肺病相关。
        • +
        • 评估:RV测量在最大呼气后,肺内仍然残留的气体量。
        • +
        • 异常:
            +
          • 增加:常见于阻塞性肺病,因气道阻塞导致气体无法完全排出。
          • +
          • 降低:可能与限制性肺病相关。
        diff --git a/2025/03/25/Scala-3-Capture-Checking/index.html b/2025/03/25/Scala-3-Capture-Checking/index.html index c9d6a9a0..08dbf98c 100644 --- a/2025/03/25/Scala-3-Capture-Checking/index.html +++ b/2025/03/25/Scala-3-Capture-Checking/index.html @@ -198,19 +198,19 @@

        Scala 3 在其类型系统的演进过程中,引入了诸多实验性特性,旨在提升语言的表达能力和代码的可靠性。Capture Checking 是其中一项引人注目的创新。目前还在试验阶段,其通过增强静态分析的能力,在编译时捕获潜在的错误,从而减少运行时问题的发生。

        Capture Checking引入了一系列核心概念,这些概念共同构成了其类型系统的基础:

          -
        • 捕获类型 (Capturing Types): 捕获类型采用 T^{c₁, ..., cᵢ} 的形式,其中 T 是一个普通的 Scala 类型,而 {c₁, ..., cᵢ} 则是一个捕获的 Capabilities 集合。这个捕获集合明确地列出了类型 T 的值所依赖或能够访问的 Capabilities。这种类型表示方式为类型信息增加了一个新的维度,它不仅描述了数据的结构,还包含了数据交互的环境或资源的相关信息。
        • -
        • Capabilities: 在 Capture Checking 的语境下,Capabilities 指的是方法或类的参数、局部变量,或者其类型本身就是一个具有非空捕获集合的捕获类型的封闭类的 this 引用。这些实体之所以需要被跟踪,是因为它们通常代表了执行某些操作或访问某些资源的“权限”或“授权”。跟踪这些 Capabilities 意味着可以控制这种影响在程序中的传播方式和范围。
        • -
        • 通用 Capability (cap): 通用 Capability cap 是一个最基本的 Capability,所有的其他的 Capabilities 都派生自它。类型 T^ 是 T^{cap} 的简写形式,表示类型 T 的值可以捕获任意的 Capability。cap 的存在为 Capture Checking 系统提供了一种 处理精确跟踪并非必需或不可行 的场景的方式。它充当了一种通配符,表明一个值可能依赖于任何 Capability。
        • -
        • 纯函数 vs. 非纯函数 (Pure vs. Impure Functions): Capture Checking 显式地区分了纯函数和非纯函数。类型为 A => B 的函数被认为是非纯函数,它可以捕获任意 Capability,等价于 A ->{cap} B 1。而类型为 A -> B 的函数则是纯函数,它不能捕获任何 Capability。此外,还可以使用 A ->{c, d} B 的形式来显式指定函数只能捕获 Capability c 和 d。这种区分使得类型系统能够强制执行函数式编程的原则,其中纯函数因其可预测性和可测试性而备受推崇。
        • -
        • 子捕获 (Subcapturing): 子捕获描述了捕获集合之间的关系。如果一个捕获集合 C₁ 中的每个元素都包含在另一个捕获集合 C₂ 中,那么我们说 C₁ 是 C₂ 的子捕获,记作 C₁ <: C₂ 1。子捕获关系在类型系统中扮演着重要的角色,例如在判断类型兼容性时。它允许具有较小捕获集合的值在需要具有较大捕获集合的地方使用,这基于一种替代原则,即更受限制的 Capability 集合是较少受限制的集合的子类型。
        • -
        • Capability Classes: 扩展了 caps.Capability trait 的类被称为 Capability Class。它们的类型捕获集合始终为 {cap}。Capability Class 似乎提供了一种在类型系统中显式定义和管理基本 Capability 的方法。它们与通用 Capability cap 的关联表明,它们具有与环境进行广泛交互的潜力。
        • +
        • 捕获类型 (Capturing Types): 捕获类型采用 T^{c₁, ..., cᵢ} 的形式,其中 T 是一个普通的 Scala 类型,而 {c₁, ..., cᵢ} 则是一个捕获的 Capabilities 集合。这个捕获集合明确地列出了类型 T 的值所依赖或能够访问的 Capabilities。这种类型表示方式为类型信息增加了一个新的维度,它不仅描述了数据的结构,还包含了数据交互的环境或资源的相关信息。
        • +
        • Capabilities: 在 Capture Checking 的语境下,Capabilities 指的是方法或类的参数、局部变量,或者其类型本身就是一个具有非空捕获集合的捕获类型的封闭类的 this 引用。这些实体之所以需要被跟踪,是因为它们通常代表了执行某些操作或访问某些资源的“权限”或“授权”。跟踪这些 Capabilities 意味着可以控制这种影响在程序中的传播方式和范围。
        • +
        • 通用 Capability (cap): 通用 Capability cap 是一个最基本的 Capability,所有的其他的 Capabilities 都派生自它。类型 T^ 是 T^{cap} 的简写形式,表示类型 T 的值可以捕获任意的 Capability。cap 的存在为 Capture Checking 系统提供了一种 处理精确跟踪并非必需或不可行 的场景的方式。它充当了一种通配符,表明一个值可能依赖于任何 Capability。
        • +
        • 纯函数 vs. 非纯函数 (Pure vs. Impure Functions): Capture Checking 显式地区分了纯函数和非纯函数。类型为 A => B 的函数被认为是非纯函数,它可以捕获任意 Capability,等价于 A ->{cap} B 1。而类型为 A -> B 的函数则是纯函数,它不能捕获任何 Capability。此外,还可以使用 A ->{c, d} B 的形式来显式指定函数只能捕获 Capability c 和 d。这种区分使得类型系统能够强制执行函数式编程的原则,其中纯函数因其可预测性和可测试性而备受推崇。
        • +
        • 子捕获 (Subcapturing): 子捕获描述了捕获集合之间的关系。如果一个捕获集合 C₁ 中的每个元素都包含在另一个捕获集合 C₂ 中,那么我们说 C₁ 是 C₂ 的子捕获,记作 C₁ <: C₂ 1。子捕获关系在类型系统中扮演着重要的角色,例如在判断类型兼容性时。它允许具有较小捕获集合的值在需要具有较大捕获集合的地方使用,这基于一种替代原则,即更受限制的 Capability 集合是较少受限制的集合的子类型。
        • +
        • Capability Classes: 扩展了 caps.Capability trait 的类被称为 Capability Class。它们的类型捕获集合始终为 {cap}。Capability Class 似乎提供了一种在类型系统中显式定义和管理基本 Capability 的方法。它们与通用 Capability cap 的关联表明,它们具有与环境进行广泛交互的潜力。

        Capture Checking 的主要目标是通过静态地跟踪资源或能力的使用情况,从而防止与资源生命周期和可访问性相关的错误。这尤其在涉及资源管理和并发编程的场景中显得至关重要。通过这种静态跟踪,Capture Checking 旨在提高代码的整体安全性,并增强程序的鲁棒性。

        Capture Checking 试图解决编程语言中长期存在的一些问题:

          -
        • 不安全的资源管理: 例如,传统的 try-with-resources 模式旨在确保资源在使用后被正确关闭。Capture Checking 通过跟踪与资源相关的 Capabilities,可以防止在资源关闭或失效后继续使用它的情况。文档中提到的 usingLogFile 示例就展示了这一点,其中一个闭包尝试写入一个已经关闭的文件,而Capture Checking可以捕获这种不安全的操作:
        • +
        • 不安全的资源管理: 例如,传统的 try-with-resources 模式旨在确保资源在使用后被正确关闭。Capture Checking 通过跟踪与资源相关的 Capabilities,可以防止在资源关闭或失效后继续使用它的情况。文档中提到的 usingLogFile 示例就展示了这一点,其中一个闭包尝试写入一个已经关闭的文件,而Capture Checking可以捕获这种不安全的操作:
        1
        2
        3
        4
        5
        def usingLogFile[T](op: FileOutputStream => T): T =
        val logFile = FileOutputStream("log")
        val result = op(logFile)
        logFile.close()
        result
        @@ -219,47 +219,47 @@
        1
        2
        3
        4
        |  val later = usingLogFile { f => () => f.write(0) }
        | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        |The expression's type () => Unit is not allowed to capture the root capability `cap`.
        |This usually means that a capability persists longer than its allowed lifetime.
          -
        • Effect Polymorphism: Capture Checking 提供了一种更灵活和精确地推理和控制副作用的机制。它可以被视为一种 Effect system,允许类型系统跟踪和控制代码可能产生的副作用。
        • -
        • “函数的颜色”问题: 在异步编程中,区分同步和异步操作一直是一个挑战。Capture Checking 有可能帮助区分和管理同步与异步计算,这通过跟踪与异步操作相关的 Capabilities 来实现。
        • -
        • 基于区域的内存分配: Capture Checking 可以通过推理与内存位置相关的Capability,来促进更安全的内存管理。这暗示了 Capture Checking 与内存管理之间可能存在更深层次的集成,从而可能实现更高效和更安全的内存使用。
        • -
        • CE: Scala 3 通过使用 CanThrow 跟踪抛出特定异常的Capability,实现了一个干净且完全安全的 CE 系统。这提供了一种类型安全的替代方案,相较于传统的 CE,这种方式可能更加灵活和有原则。
        • -
        • 逃逸分析: Capture Checking 可以防止局部Capability逃逸其预期的作用域。例如,当一个闭包捕获了一个局部Capability,并且这个闭包被赋值给一个全局变量或者以不安全的方式从函数返回时,Capture Checking 可以检测到这种潜在的风险。
        • +
        • Effect Polymorphism: Capture Checking 提供了一种更灵活和精确地推理和控制副作用的机制。它可以被视为一种 Effect system,允许类型系统跟踪和控制代码可能产生的副作用。
        • +
        • “函数的颜色”问题: 在异步编程中,区分同步和异步操作一直是一个挑战。Capture Checking 有可能帮助区分和管理同步与异步计算,这通过跟踪与异步操作相关的 Capabilities 来实现。
        • +
        • 基于区域的内存分配: Capture Checking 可以通过推理与内存位置相关的Capability,来促进更安全的内存管理。这暗示了 Capture Checking 与内存管理之间可能存在更深层次的集成,从而可能实现更高效和更安全的内存使用。
        • +
        • CE: Scala 3 通过使用 CanThrow 跟踪抛出特定异常的Capability,实现了一个干净且完全安全的 CE 系统。这提供了一种类型安全的替代方案,相较于传统的 CE,这种方式可能更加灵活和有原则。
        • +
        • 逃逸分析: Capture Checking 可以防止局部Capability逃逸其预期的作用域。例如,当一个闭包捕获了一个局部Capability,并且这个闭包被赋值给一个全局变量或者以不安全的方式从函数返回时,Capture Checking 可以检测到这种潜在的风险。

        -

        在 Scala 3 中,函数类型 A => B 被认为是不纯的,它可以捕获任意 Capability。实际上,A => B 是 A ->{cap} B 的别名,明确地表明了它可能捕获 “通用 Capability”。这种默认行为反映了 Scala 过去函数可以拥有任意副作用的特点。然而,随着 Capture Checking 的引入,开发者被鼓励更明确地表达函数的纯度。

        -

        与不纯函数相对的是纯函数,其类型为 A -> B,表示该函数不能捕获任何 Capability。纯函数是函数式编程中的核心概念,Capture Checking 提供了一种在类型层面强制执行纯性的方法。确保函数的纯性可以使代码更具可预测性和可测试性,因为纯函数的输出完全取决于其输入,并且没有副作用。

        -

        开发者还可以指定函数可以捕获的特定 Capability,语法为 A ->{c, d} B,表示该函数可以捕获 Capability c 和 d。这种语法允许对函数可以使用的Capability 进行精确控制,从而提高了资源管理的细粒度。通过显式列出捕获的 Capability,编译器可以验证函数是否遵守这些约束,并防止其意外访问其他资源。

        -

        捕获注解 ^ 的优先级高于 -> 。理解运算符的优先级对于正确解释和编写带有捕获注解的函数类型至关重要。不正确的解析可能导致意想不到的行为或类型错误。例如,A ^ C -> B 表示一个从捕获的 A 到 B 的纯函数。

        +

        在 Scala 3 中,函数类型 A => B 被认为是不纯的,它可以捕获任意 Capability。实际上,A => B 是 A ->{cap} B 的别名,明确地表明了它可能捕获 “通用 Capability”。这种默认行为反映了 Scala 过去函数可以拥有任意副作用的特点。然而,随着 Capture Checking 的引入,开发者被鼓励更明确地表达函数的纯度。

        +

        与不纯函数相对的是纯函数,其类型为 A -> B,表示该函数不能捕获任何 Capability。纯函数是函数式编程中的核心概念,Capture Checking 提供了一种在类型层面强制执行纯性的方法。确保函数的纯性可以使代码更具可预测性和可测试性,因为纯函数的输出完全取决于其输入,并且没有副作用。

        +

        开发者还可以指定函数可以捕获的特定 Capability,语法为 A ->{c, d} B,表示该函数可以捕获 Capability c 和 d。这种语法允许对函数可以使用的Capability 进行精确控制,从而提高了资源管理的细粒度。通过显式列出捕获的 Capability,编译器可以验证函数是否遵守这些约束,并防止其意外访问其他资源。

        +

        捕获注解 ^ 的优先级高于 -> 。理解运算符的优先级对于正确解释和编写带有捕获注解的函数类型至关重要。不正确的解析可能导致意想不到的行为或类型错误。例如,A ^ C -> B 表示一个从捕获的 A 到 B 的纯函数。

        1
        def f(x: ->{c} Int): Int
        -

        Capture Checking 也适用于上下文函数。不纯的上下文函数使用 ?=>,行为类似于 =>,可以捕获任意Capability。纯的上下文函数使用 ?->,行为类似于 ->,不能捕获任何Capability。这表明,Capture Checking扩展到了上下文函数,允许控制在它们的隐式参数作用域内捕获的 Capability。

        -

        值得注意的是,方法本身并不是值,因此它们不直接捕获Capability。相反,它们对 Capability 的引用会被计入封闭对象的捕获集中。这种区分很重要,因为方法的捕获行为与它们所属对象的状态和Capability相关联,这反映了 Scala 的面向对象特性。

        -

        与函数类型类似,Capture Checking的概念也延伸到了命名参数类型。=> Int 允许任意Capability引用,类似于不纯函数类型。-> Int 禁止任何Capability引用,类似于纯函数类型。而 ->{c} Int 则只允许引用Capability c。这种一致性确保了即使是延迟求值的表达式也遵循Capability约束。

        -

        子捕获(C₁ <: C₂)定义了捕获集之间的关系。捕获集 C₁ 是 C₂ 的子类型,如果 C₂ 包含了 C₁ 中的每一个元素,并且满足以下条件之一:c ∈ C₂(直接包含);c 是一个类参数,且 C₂ 包含 Cls.this(Capability 来源于封闭的类实例);c 的类型具有捕获集 C,且 C <: C₂(基于 Capability 类型的递归子捕获)。子捕获定义了 Capability 依赖的层级结构,这对于类型系统判断一个需要特定 Capability 集合的值是否可以在提供不同 Capability 集合的上下文中使用至关重要。

        -

        对于捕获类型的子类型,存在以下规则:纯类型是捕获类型的子类型(T <: C T);较小的捕获集会产生子类型(如果 C₁ <: C₂ 且 T₁ <: T₂,则 C₁ T₁ <: C₂ T₂)。这意味着一个依赖较少 Capability 的值通常更通用,可以在更广泛的场景中使用。根 Capability {cap} 覆盖了所有其他捕获集,因此任何特定的捕获集都是 {cap} 的子类型。这允许具有特定捕获要求的类型在允许任何 Capability 的上下文中使用。

        -

        Capability widening(也称为 _avoidance_)是一种简化局部变量类型的机制。局部变量的类型会被 widening 到不提及该变量本身的最小超类型,这个过程通常会涉及到变量的捕获集。这种加宽有助于改善类型推断和代码清晰度,避免局部变量的类型变得过于复杂。

        +

        Capture Checking 也适用于上下文函数。不纯的上下文函数使用 ?=>,行为类似于 =>,可以捕获任意Capability。纯的上下文函数使用 ?->,行为类似于 ->,不能捕获任何Capability。这表明,Capture Checking扩展到了上下文函数,允许控制在它们的隐式参数作用域内捕获的 Capability。

        +

        值得注意的是,方法本身并不是值,因此它们不直接捕获Capability。相反,它们对 Capability 的引用会被计入封闭对象的捕获集中。这种区分很重要,因为方法的捕获行为与它们所属对象的状态和Capability相关联,这反映了 Scala 的面向对象特性。

        +

        与函数类型类似,Capture Checking的概念也延伸到了命名参数类型。=> Int 允许任意Capability引用,类似于不纯函数类型。-> Int 禁止任何Capability引用,类似于纯函数类型。而 ->{c} Int 则只允许引用Capability c。这种一致性确保了即使是延迟求值的表达式也遵循Capability约束。

        +

        子捕获(C₁ <: C₂)定义了捕获集之间的关系。捕获集 C₁ 是 C₂ 的子类型,如果 C₂ 包含了 C₁ 中的每一个元素,并且满足以下条件之一:c ∈ C₂(直接包含);c 是一个类参数,且 C₂ 包含 Cls.this(Capability 来源于封闭的类实例);c 的类型具有捕获集 C,且 C <: C₂(基于 Capability 类型的递归子捕获)。子捕获定义了 Capability 依赖的层级结构,这对于类型系统判断一个需要特定 Capability 集合的值是否可以在提供不同 Capability 集合的上下文中使用至关重要。

        +

        对于捕获类型的子类型,存在以下规则:纯类型是捕获类型的子类型(T <: C T);较小的捕获集会产生子类型(如果 C₁ <: C₂ 且 T₁ <: T₂,则 C₁ T₁ <: C₂ T₂)。这意味着一个依赖较少 Capability 的值通常更通用,可以在更广泛的场景中使用。根 Capability {cap} 覆盖了所有其他捕获集,因此任何特定的捕获集都是 {cap} 的子类型。这允许具有特定捕获要求的类型在允许任何 Capability 的上下文中使用。

        +

        Capability widening(也称为 _avoidance_)是一种简化局部变量类型的机制。局部变量的类型会被 widening 到不提及该变量本身的最小超类型,这个过程通常会涉及到变量的捕获集。这种加宽有助于改善类型推断和代码清晰度,避免局部变量的类型变得过于复杂。

        1
        2
        3
        4
        5
        6
        7
        fs: FileSystem^
        ct: CanThrow[Exception]^
        l : Logger^{fs}

        {l} <: {fs} <: {cap}
        {fs} <: {fs, ct} <: {cap}
        {ct} <: {fs, ct} <: {cap}

        继承自 caps.Capability 的类具有隐式的 {cap} 捕获集。这表明这些类的实例本质上代表了一种 Capability。Capability 类提供了一种将 Capability 显式定义和管理为类型系统中的一等公民的方式。开发者可以创建具有特定语义和使用模式的自定义 Capability。

        -

        在使用 Capability 类的场景中,通常会结合 using clauses 和隐式参数来减少在代码中显式传递 Capability 的需要。这两种方法提供了一种自动将必要的 Capability 提供给函数和方法的方式,从而减少了样板代码并提高了代码的可读性。

        -

        闭包会捕获在其主体中引用的来自其周围环境的 Capability。这导致闭包的函数类型中包含捕获集。例如,如果一个闭包引用了一个局部变量 fs,而 fs 是一个 Capability,那么该闭包的类型可能就是 String ->{fs} Unit。这意味着闭包继承了其封闭代码的 Capability 要求,确保它们只能在这些 Capability 可用的上下文中被使用。

        +

        在使用 Capability 类的场景中,通常会结合 using clauses 和隐式参数来减少在代码中显式传递 Capability 的需要。这两种方法提供了一种自动将必要的 Capability 提供给函数和方法的方式,从而减少了样板代码并提高了代码的可读性。

        +

        闭包会捕获在其主体中引用的来自其周围环境的 Capability。这导致闭包的函数类型中包含捕获集。例如,如果一个闭包引用了一个局部变量 fs,而 fs 是一个 Capability,那么该闭包的类型可能就是 String ->{fs} Unit。这意味着闭包继承了其封闭代码的 Capability 要求,确保它们只能在这些 Capability 可用的上下文中被使用。

        此外,闭包还会捕获它们调用的函数的 Capability。如果一个闭包调用了一个需要特定 Capability 的函数,那么该闭包的捕获集也会包含该 Capability。这种传递性的捕获机制确保了所有 Capability 依赖,无论是直接的还是间接的,都会被闭包所跟踪。

        类会将其方法中使用的 Capability 保留为(私有)字段。这意味着如果一个方法使用了作为构造函数参数传递的Capability,该类很可能会存储对它的引用。这会导致类的类型中包含捕获集。例如,一个使用文件系统Capability xfs 的 Logger 类可能具有类型 Logger^{xfs}。这表明类封装了它们所依赖的Capability,并将这些依赖作为其类型签名的一部分。

        @constructorOnly 注解可以用于标记一个仅在构造函数中使用而不会作为字段保留的类参数。这有助于减少类的捕获集。如果一个参数仅用于初始化,后续不再访问,那么类就没有必要将其保留为一种 Capability。

        -

        类的捕获引用包括从类外部使用的局部 Capability 以及具有捕获类型的构造函数参数(参数Capability)。局部Capability会被内部类继承。

        +

        类的捕获引用包括从类外部使用的局部 Capability 以及具有捕获类型的构造函数参数(参数Capability)。局部Capability会被内部类继承。

        类实例的 this 的捕获集是根据捕获的引用、父类以及类内部的使用约束来推断的 1。这种自动推断机制在很多情况下减少了手动指定类捕获集的需要。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        import caps.Capability

        class FileSystem extends Capability

        class Logger(using FileSystem):
        def log(s: String): Unit = ???

        def test(using fs: FileSystem) =
        val l: Logger^{fs} = Logger()
        ...
        1
        2
        3
        class Logger(using FileSystem^{cap}):
        ^^^^^^^^^^^^^^
        redundant capture: FileSystem already accounts for cap
        -

        捕获隧道 (Capture Tunnelling) 是指当一个类型变量被一个捕获类型实例化时,捕获信息不会立即传播到外层的泛型类型。相反,捕获会“穿过隧道”,并在类型变量被访问或其成员被使用时重新出现。这种机制有助于以更简洁和可管理的方式处理泛型代码中的捕获集,避免类型签名过于复杂。

        -

        逃逸检查施加了一些限制。作为类型变量实例的捕获类型不能携带通用Capability cap 。可变变量也不能拥有通用捕获集 。逃逸检查阻止了在参数化类型的参数中返回或分配带有局部 Capability 的闭包,因为这可能导致 Capability 逃逸其预期的作用域。单调性规则指出,在一个带有字段 f 的类中,{this} 覆盖了 {this.f} 以及 this.f 对纯参数的应用。这意味着如果类实例本身被视为一种 Capability,那么其字段所持有的任何 Capability 也会被隐式地覆盖。逃逸检查对于维护 Capability 跟踪的完整性至关重要,它可以防止 Capability 在其预期生命周期或作用域之外被使用,尤其是在泛型和可变状态的上下文中。

        -

        受检异常可以通过导入 language.experimental.saferExceptions 来启用。方法上的 throws 子句会扩展为一个隐式的 CanThrow Capability参数,表明该方法可能抛出指定类型的异常。throw 表达式需要 CanThrow Capability,而 try 表达式会创建这种Capability。在 language.experimental.captureChecking 下,由于逃逸的 Capability 而导致未处理异常的代码会被拒绝。为了实现这种集成,CanThrow 需要继承 Capability,并且需要将逃逸检查扩展到 try 表达式,以防止捕获 cap。Capture Checking 与受检异常的集成确保了异常的可能性也被作为一种 Capability 需求来跟踪,从而加强了语言的整体资源管理和错误处理 Capability。

        +

        捕获隧道 (Capture Tunnelling) 是指当一个类型变量被一个捕获类型实例化时,捕获信息不会立即传播到外层的泛型类型。相反,捕获会“穿过隧道”,并在类型变量被访问或其成员被使用时重新出现。这种机制有助于以更简洁和可管理的方式处理泛型代码中的捕获集,避免类型签名过于复杂。

        +

        逃逸检查施加了一些限制。作为类型变量实例的捕获类型不能携带通用Capability cap 。可变变量也不能拥有通用捕获集 。逃逸检查阻止了在参数化类型的参数中返回或分配带有局部 Capability 的闭包,因为这可能导致 Capability 逃逸其预期的作用域。单调性规则指出,在一个带有字段 f 的类中,{this} 覆盖了 {this.f} 以及 this.f 对纯参数的应用。这意味着如果类实例本身被视为一种 Capability,那么其字段所持有的任何 Capability 也会被隐式地覆盖。逃逸检查对于维护 Capability 跟踪的完整性至关重要,它可以防止 Capability 在其预期生命周期或作用域之外被使用,尤其是在泛型和可变状态的上下文中。

        +

        受检异常可以通过导入 language.experimental.saferExceptions 来启用。方法上的 throws 子句会扩展为一个隐式的 CanThrow Capability参数,表明该方法可能抛出指定类型的异常。throw 表达式需要 CanThrow Capability,而 try 表达式会创建这种Capability。在 language.experimental.captureChecking 下,由于逃逸的 Capability 而导致未处理异常的代码会被拒绝。为了实现这种集成,CanThrow 需要继承 Capability,并且需要将逃逸检查扩展到 try 表达式,以防止捕获 cap。Capture Checking 与受检异常的集成确保了异常的可能性也被作为一种 Capability 需求来跟踪,从而加强了语言的整体资源管理和错误处理 Capability。


        -

        文档中提供了一个关于惰性列表(Lazy Lists)的较大示例,很好地展示了 Capture Checking 如何在更复杂的数据结构中工作。LzyList 的 tail 具有一个捕获注解,表明它可以捕获与列表相同的引用。诸如 map、filter 和 concat 等操作在惰性列表上能够正确地推断出捕获集,这取决于原始列表和所使用的函数。值得注意的是 effect polymorphism的概念,传递给这些操作的纯函数不会出现在结果的捕获集中。这与严格列表形成对比,严格列表通常不需要捕获注解,因为它们的副作用不会被延迟。惰性列表的例子具体说明了Capture Checking 如何应用于非平凡的数据结构,展示了其在涉及延迟求值的复杂场景中跟踪依赖关系的Capability。effect polymorphism 是一个显著的优点,它允许在不影响捕获集的情况下使用纯计算。

        +

        文档中提供了一个关于惰性列表(Lazy Lists)的较大示例,很好地展示了 Capture Checking 如何在更复杂的数据结构中工作。LzyList 的 tail 具有一个捕获注解,表明它可以捕获与列表相同的引用。诸如 map、filter 和 concat 等操作在惰性列表上能够正确地推断出捕获集,这取决于原始列表和所使用的函数。值得注意的是 effect polymorphism的概念,传递给这些操作的纯函数不会出现在结果的捕获集中。这与严格列表形成对比,严格列表通常不需要捕获注解,因为它们的副作用不会被延迟。惰性列表的例子具体说明了Capture Checking 如何应用于非平凡的数据结构,展示了其在涉及延迟求值的复杂场景中跟踪依赖关系的Capability。effect polymorphism 是一个显著的优点,它允许在不影响捕获集的情况下使用纯计算。


        -

        表 1: 代码示例与演示概念

        +

        表 1: 代码示例与演示概念

        @@ -269,9 +269,9 @@ - - - + + + @@ -340,11 +340,11 @@
        代码片段演示概念捕获行为解释代码片段演示概念捕获行为解释
        import language.experimental.captureChecking

        当 cap 出现在函数的结果类型中时,通常表示一个由存在性量词绑定的未知类型(例如,() -> Iterator^ 意味着 () -> Exists x. Iterator^x)。这表明返回的迭代器可能捕获了某种 Capability,但具体的哪种 Capability 在静态类型检查时是未知的。在内部,这种存在性 Capability 使用带有 sealed trait Exists 的依赖函数类型来表示 。结果类型中协变的 cap 会被替换为一个新的 existential variable。当应用一个具有 existential result 类型 Exists ex.T 的函数时,结果是 T,其中 ex 被 cap 替换。Existential Capability 允许类型系统表达在编译时具体捕获的 Capability 未知的情况,从而提供了灵活性,同时仍然保持了一定程度的跟踪。

        -

        **Reach Capability **用于表达一个变量引用了通过另一个Capability“Reach”的任何操作。例如,如果 ops 是一个表示一组操作的Capability,那么 ops* 就表示出现在 ops 类型中且通过 ops 访问的任何协变 Capability。Reach Capability 提供了一种间接推理和跟踪 Capability 的方式,这对于建模具有相互连接资源的复杂系统非常有用。

        -

        Capability 多态允许使用带有上界 CapSet 的类型变量来参数化操作的捕获集。这使得定义诸如 Source[X^] 这样的类型成为可能,其中 X^ 表示监听器可以持有的一组 Capability。Capability 多态增强了代码的表达性和可重用性,因为它允许函数和数据结构在它们可能依赖的 Capability 集上进行参数化。

        +

        Reach Capability 用于表达一个变量引用了通过另一个Capability“Reach”的任何操作。例如,如果 ops 是一个表示一组操作的Capability,那么 ops* 就表示出现在 ops 类型中且通过 ops 访问的任何协变 Capability。Reach Capability 提供了一种间接推理和跟踪 Capability 的方式,这对于建模具有相互连接资源的复杂系统非常有用。

        +

        Capability 多态允许使用带有上界 CapSet 的类型变量来参数化操作的捕获集。这使得定义诸如 Source[X^] 这样的类型成为可能,其中 X^ 表示监听器可以持有的一组 Capability。Capability 多态增强了代码的表达性和可重用性,因为它允许函数和数据结构在它们可能依赖的 Capability 集上进行参数化。


        Scala 3 提供了几个与 Capture Checking 相关的编译选项,。-Xprint:cc 选项会打印出带有推断捕获类型的程序代码 1。这对于理解编译器是如何推断捕获集的以及调试与类型相关的错误非常有用。另一个选项是 -Ycc-debug,它提供了关于 Capture Checking 过程的详细的、面向实现的的信息。这个选项对于需要深入了解 Capture Checking 器工作原理或者遇到复杂问题的开发者来说很有帮助。这些编译选项是使用 Capture Checking 的开发者的重要工具,它们允许开发者检查编译器的推理并诊断问题。

        -

        Capture Checking被实现为一个传播约束求解器,它在标准的类型检查阶段之后运行,未知的捕获集由约束变量表示。在类型中显式编写的捕获集被求解器视为常量。类型之间的子类型要求会转化为它们各自捕获集上的子捕获测试。求解器根据程序的结构和类型约束,将 Capability 传播给约束变量及其超集。捕获集的映射受到类型参数的变性(协变、逆变、不变)的影响。装箱(boxing)和拆箱(unboxing)是用于隐藏和恢复捕获集的虚拟操作,特别是在类型参数的上下文中用于管理捕获隧道。-Ycc-debug 的输出提供了关于变量依赖关系和Capture Checking器在编译过程中的状态的深入信息。

        +

        Capture Checking被实现为一个传播约束求解器,它在标准的类型检查阶段之后运行,未知的捕获集由约束变量表示。在类型中显式编写的捕获集被求解器视为常量。类型之间的子类型要求会转化为它们各自捕获集上的子捕获测试。求解器根据程序的结构和类型约束,将 Capability 传播给约束变量及其超集。捕获集的映射受到类型参数的变性(协变、逆变、不变)的影响。装箱(boxing)和拆箱(unboxing)是用于隐藏和恢复捕获集的虚拟操作,特别是在类型参数的上下文中用于管理捕获隧道。-Ycc-debug 的输出提供了关于变量依赖关系和Capture Checking器在编译过程中的状态的深入信息。

diff --git "a/2025/03/31/\345\234\250F-\344\270\255\345\244\204\347\220\206\345\244\215\346\235\202\344\276\235\350\265\226\346\263\250\345\205\245\347\232\204\345\256\236\350\267\265\346\214\207\345\215\227/index.html" "b/2025/03/31/\345\234\250F-\344\270\255\345\244\204\347\220\206\345\244\215\346\235\202\344\276\235\350\265\226\346\263\250\345\205\245\347\232\204\345\256\236\350\267\265\346\214\207\345\215\227/index.html" index 49034c9f..090d8078 100644 --- "a/2025/03/31/\345\234\250F-\344\270\255\345\244\204\347\220\206\345\244\215\346\235\202\344\276\235\350\265\226\346\263\250\345\205\245\347\232\204\345\256\236\350\267\265\346\214\207\345\215\227/index.html" +++ "b/2025/03/31/\345\234\250F-\344\270\255\345\244\204\347\220\206\345\244\215\346\235\202\344\276\235\350\265\226\346\263\250\345\205\245\347\232\204\345\256\236\350\267\265\346\214\207\345\215\227/index.html" @@ -195,12 +195,12 @@

一、传统手艺:Partial Application

在函数式编程中,Partial Application 是传递依赖的常用方式。例如:

1
2
3
let foo bar baz request = ...
let wired = foo dependency1 dependency2
let response = wired request
-

优点:

+

优点:

  • 无需框架或反射,直接通过函数参数传递依赖。
  • 符合函数式编程的纯函数理念。
-

缺点:

+

缺点:

  • 参数爆炸:当功能扩展时,参数数量激增(如日志、数据库、加密等)。
  • 维护困难:新增依赖需修改所有调用点的参数传递。
  • @@ -210,13 +210,13 @@

    二、结构化方法:单一环境参数(env)

    为解决参数爆炸问题,可将依赖封装为单一环境对象env,并通过接口约束访问权限:

    1
    2
    3
    4
    5
    6
    7
    8
    [<Interface>] type ILog = abstract Logger: ILogger
    [<Interface>] type IDb = abstract Database: IDatabase

    module Log =
    let info (env: #ILog) = env.Logger.Info("Message")

    module Db =
    let fetchUser (env: #IDb) = env.Database.Query(...)
    -

    优点:

    +

    优点:

      -
    • 显式依赖声明:函数签名仅需env参数,编译器验证接口实现。
    • -
    • 模块化隔离:各模块仅声明所需接口(如ILog、IDb),避免全局依赖。
    • -
    • 易于测试:通过模拟env实现单元测试,无需依赖具体实现。
    • +
    • 显式依赖声明:函数签名仅需env参数,编译器验证接口实现。
    • +
    • 模块化隔离:各模块仅声明所需接口(如ILog、IDb),避免全局依赖。
    • +
    • 易于测试:通过模拟env实现单元测试,无需依赖具体实现。
    -

    应用场景:

    +

    应用场景:

    1
    2
    3
    4
    5
    let changePass env req = task {
    let! user = Db.fetchUser env req.UserId
    Log.info env "Processing user: %i" user.Id
    ...
    }

    @@ -226,15 +226,15 @@

    然后:

    1
    2
    3
    4
    5
    6
    let changePass req = effect {
    let! user = Db.fetchUser req.UserId
    let! salt = Random.bytes 32
    do! Log.info "Password updated for user %i" user.Id
    return Ok()
    }
    -

    优点:

    +

    优点:

      -
    • 隐式依赖管理:通过effect计算表达式自动传递env,减少样板代码。
    • -
    • 组合性:支持与其他计算表达式(如async/task)结合,处理异步操作。
    • +
    • 隐式依赖管理:通过effect计算表达式自动传递env,减少样板代码。
    • +
    • 组合性:支持与其他计算表达式(如async/task)结合,处理异步操作。
    -

    缺点:

    +

    缺点:

      -
    • 性能开销:频繁的闭包创建和间接调用可能导致性能下降。
    • -
    • 生态兼容性:需自定义计算表达式,与现有异步框架集成复杂。
    • +
    • 性能开销:频繁的闭包创建和间接调用可能导致性能下降。
    • +
    • 生态兼容性:需自定义计算表达式,与现有异步框架集成复杂。

    Refs.

    • Spring的构造器注入
    • diff --git "a/2025/04/02/N-1-selects-problem-\344\270\216-Prisma-ORM/index.html" "b/2025/04/02/N-1-selects-problem-\344\270\216-Prisma-ORM/index.html" index c12c8c1f..6a5cd15f 100644 --- "a/2025/04/02/N-1-selects-problem-\344\270\216-Prisma-ORM/index.html" +++ "b/2025/04/02/N-1-selects-problem-\344\270\216-Prisma-ORM/index.html" @@ -192,31 +192,31 @@
-

N+1 查询问题是指在通过 ORM 查询数据时,执行了一次初始查询来获取父对象列表(这 1 次查询),然后为列表中的每一个父对象都单独执行了一次额外的查询来获取其关联的子对象(这 N 次查询)。最终导致总共执行了 1 + N 次数据库查询,其中 N 是初始查询返回的父对象的数量。

-

举个例子:

+

N+1 查询问题是指在通过 ORM 查询数据时,执行了一次初始查询来获取父对象列表(这 1 次查询),然后为列表中的每一个父对象都单独执行了一次额外的查询来获取其关联的子对象(这 N 次查询)。最终导致总共执行了 1 + N 次数据库查询,其中 N 是初始查询返回的父对象的数量。

+

举个例子:

假设有两个数据库模型:User(用户)和 Post(帖子),一个用户可以有多篇帖子(一对多关系)。

现在,需要获取前 10 个用户以及他们各自的所有帖子。

-

一种有问题的 ORM 实现(或不当的使用方式)可能会这样执行:

+

一种有问题的 ORM 实现(或不当的使用方式)可能会这样执行:

    -
  1. 第一次查询 (The “1”): 获取前 10 个用户。
    1
    SELECT * FROM User LIMIT 10;
  2. -
  3. 接下来的 N (=10) 次查询 (The “N”): 对于上一步获取到的每一个用户,单独执行一次查询来获取该用户的帖子。
    1
    2
    3
    4
    5
    6
    7
    8
    -- 用户 1
    SELECT * FROM Post WHERE authorId = 1;
    -- 用户 2
    SELECT * FROM Post WHERE authorId = 2;
    -- 用户 3
    SELECT * FROM Post WHERE authorId = 3;
    -- ... 直到 用户 10
    SELECT * FROM Post WHERE authorId = 10;
  4. +
  5. 第一次查询 (The “1”): 获取前 10 个用户。
    1
    SELECT * FROM User LIMIT 10;
  6. +
  7. 接下来的 N (=10) 次查询 (The “N”): 对于上一步获取到的每一个用户,单独执行一次查询来获取该用户的帖子。
    1
    2
    3
    4
    5
    6
    7
    8
    -- 用户 1
    SELECT * FROM Post WHERE authorId = 1;
    -- 用户 2
    SELECT * FROM Post WHERE authorId = 2;
    -- 用户 3
    SELECT * FROM Post WHERE authorId = 3;
    -- ... 直到 用户 10
    SELECT * FROM Post WHERE authorId = 10;
-

在这个场景下,总共执行了 1 + 10 = 11 次数据库查询。如果 N 的值很大(比如获取 1000 个用户),就会产生 1001 次查询,这对数据库造成巨大的、不必要的压力,并显著增加应用程序的响应时间。每一次数据库交互都有网络延迟和数据库处理的开销,N+1 次查询会将这些开销放大 N 倍。

+

在这个场景下,总共执行了 1 + 10 = 11 次数据库查询。如果 N 的值很大(比如获取 1000 个用户),就会产生 1001 次查询,这对数据库造成巨大的、不必要的压力,并显著增加应用程序的响应时间。每一次数据库交互都有网络延迟和数据库处理的开销,N+1 次查询会将这些开销放大 N 倍。

N+1 问题通常源于 ORM 处理关联数据的方式,特别是与“懒加载”(Lazy Loading)相关的策略。懒加载是指只有在显式访问关联属性时,ORM 才会去数据库加载这些数据。虽然这在某些情况下可以避免加载不需要的数据,但如果在循环中访问关联属性,就很容易触发 N+1 问题。

-

然而,问题的根源在于没有有效地预先加载(或批量加载)所需的关联数据。即使不使用严格意义上的懒加载,如果 ORM 在处理关联查询时不够智能,采用了逐个获取关联对象的策略,同样会产生 N+1 查询。

+

然而,问题的根源在于没有有效地预先加载(或批量加载)所需的关联数据。即使不使用严格意义上的懒加载,如果 ORM 在处理关联查询时不够智能,采用了逐个获取关联对象的策略,同样会产生 N+1 查询。

在 Prisma 出现之前或在其他 ORM 中,解决 N+1 问题常见的方法包括:

    -
  1. 预先加载(Eager Loading): 在执行初始查询时,就明确指示 ORM 同时将关联数据也查询出来。这通常通过 SQL 的 JOIN 操作实现。例如,一次性查询出用户和他们的帖子。虽然这减少了查询次数,但复杂的 JOIN 可能会导致查询本身变得庞大和低效,并可能返回冗余数据。
  2. -
  3. 批量加载(Batch Loading): 先执行初始查询获取父对象列表,然后收集所有父对象的 ID,在第二次查询中使用 WHERE IN (...) 子句一次性加载所有相关的子对象。这种方式通常需要两次查询,但避免了 N 次单独的查询。
  4. +
  5. 预先加载(Eager Loading): 在执行初始查询时,就明确指示 ORM 同时将关联数据也查询出来。这通常通过 SQL 的 JOIN 操作实现。例如,一次性查询出用户和他们的帖子。虽然这减少了查询次数,但复杂的 JOIN 可能会导致查询本身变得庞大和低效,并可能返回冗余数据。
  6. +
  7. 批量加载(Batch Loading): 先执行初始查询获取父对象列表,然后收集所有父对象的 ID,在第二次查询中使用 WHERE IN (...) 子句一次性加载所有相关的子对象。这种方式通常需要两次查询,但避免了 N 次单独的查询。
-

Prisma ORM 在设计上就考虑了 N+1 问题,并提供了一种既方便开发者又高效的解决方案。当使用 Prisma Client 查询数据并需要包含关联模型时,Prisma 会自动优化查询,避免产生 N+1 查询,主要通过关系查询(Relation Queries)中的 include 选项或嵌套读取(nested reads)来实现这一点:

+

Prisma ORM 在设计上就考虑了 N+1 问题,并提供了一种既方便开发者又高效的解决方案。当使用 Prisma Client 查询数据并需要包含关联模型时,Prisma 会自动优化查询,避免产生 N+1 查询,主要通过关系查询(Relation Queries)中的 include 选项或嵌套读取(nested reads)来实现这一点:

假设想获取所有用户及其发布的帖子,使用 Prisma Client,可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function getUsersWithPosts() {
const usersWithPosts = await prisma.user.findMany({
include: {
posts: true, // 指示 Prisma 加载关联的 posts
},
})
// usersWithPosts 包含了用户列表,每个用户对象中都有一个 posts 数组
console.log(usersWithPosts)
}

getUsersWithPosts()
.catch((e) => {
throw e
})
.finally(async () => {
await prisma.$disconnect()
})
-

当执行上述查询时,Prisma 不会 生成 N+1 个 SQL 查询。而是首先会分析请求,并将其转化为数量非常有限的高效 SQL 查询。对于上面这个一对多关系的 include 查询,Prisma 通常会执行以下两步(类似于批量加载策略):

+

当执行上述查询时,Prisma 不会 生成 N+1 个 SQL 查询。而是首先会分析请求,并将其转化为数量非常有限的高效 SQL 查询。对于上面这个一对多关系的 include 查询,Prisma 通常会执行以下两步(类似于批量加载策略):

    -
  1. 查询父模型: 获取所有 User 记录。
    1
    SELECT "public"."User"."id", "public"."User"."name", /* ... other user fields */ FROM "public"."User" WHERE 1=1
  2. -
  3. 查询关联的子模型: 使用上一步获取到的所有用户 id,通过 WHERE IN (...) 子句一次性查询所有相关的 Post 记录。
    1
    SELECT "public"."Post"."id", "public"."Post"."title", "public"."Post"."authorId", /* ... other post fields */ FROM "public"."Post" WHERE "public"."Post"."authorId" IN ($1, $2, $3, ...) /* 这里的 $1, $2, ... 是第一步查到的用户 ID 列表 */
  4. +
  5. 查询父模型: 获取所有 User 记录。
    1
    SELECT "public"."User"."id", "public"."User"."name", /* ... other user fields */ FROM "public"."User" WHERE 1=1
  6. +
  7. 查询关联的子模型: 使用上一步获取到的所有用户 id,通过 WHERE IN (...) 子句一次性查询所有相关的 Post 记录。
    1
    SELECT "public"."Post"."id", "public"."Post"."title", "public"."Post"."authorId", /* ... other post fields */ FROM "public"."Post" WHERE "public"."Post"."authorId" IN ($1, $2, $3, ...) /* 这里的 $1, $2, ... 是第一步查到的用户 ID 列表 */

Prisma Client 在内存中将这两次查询的结果高效地组合起来,最终返回嵌套的、符合 TypeScript 类型的数据。

Refs.

diff --git a/2025/05/07/nestjs-bullmq-mail-business/index.html b/2025/05/07/nestjs-bullmq-mail-business/index.html index 33c566c5..0315b2b9 100644 --- a/2025/05/07/nestjs-bullmq-mail-business/index.html +++ b/2025/05/07/nestjs-bullmq-mail-business/index.html @@ -196,14 +196,14 @@
  1. 队列(在 NestJS 里由 @Processor 装饰的类)会被一个底层的 Worker 订阅。
  2. 有新任务(job)进来时,Worker 会调用写在该类里的 async process(job: Job) 方法。
  3. -
  4. 如果 process() 正常返回(即没有抛异常),Job 就被标记为 completed,然后才会去触发所有注册了 @OnWorkerEvent('completed') 的回调。
  5. +
  6. 如果 process() 正常返回(即没有抛异常),Job 就被标记为 completed,然后才会去触发所有注册了 @OnWorkerEvent('completed') 的回调。

也就是说:

-

而我在此处的业务目的是 “用队列来做可靠的、可重试的邮件发送”,那么一定要把发送邮件的逻辑写到 process() 里,这样在 commandBus.execute(new SendMailCommand(...)) 抛错时,BullMQ 会根据创建 JOB 时的重试策略(retry、backoff 等)自动重新入队。而把它放到 onCompleted(),只相当于 job 成功完成后的“事后通知”,一旦失败不会再重试,也无法利用 BullMQ 的锁、超时、重试机制。

+

而我在此处的业务目的是 “用队列来做可靠的、可重试的邮件发送”,那么一定要把发送邮件的逻辑写到 process() 里,这样在 commandBus.execute(new SendMailCommand(...)) 抛错时,BullMQ 会根据创建 JOB 时的重试策略(retry、backoff 等)自动重新入队。而把它放到 onCompleted(),只相当于 job 成功完成后的“事后通知”,一旦失败不会再重试,也无法利用 BullMQ 的锁、超时、重试机制。

举个最简化的调整示例,删掉 onCompleted,把真正的发信放到 process:

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
// ... existing imports ...

@Processor(process.env.MAILER_QUEUE_NAME || "gcpm-mailer")
export class BullMQMailerProcesser extends WorkerHost {
constructor(
private readonly commandBus: CommandBus,
private readonly logger: LoggingService,
) {
super();
}

// ① 当有新 job 拉取到时,这个方法会被调用
public async process(job: Job): Promise<void> {
const mailAggregate = new Mail(job.data.mail);
try {
await this.commandBus.execute(new SendMailCommand(mailAggregate));
} catch (err) {
this.logger.error(`邮件发送失败,jobId=${job.id}`, err);
// 抛出错误,触发重试或失败
throw err;
}
}

// ② onCompleted 仅在 process() 正常返回后触发,
// 不建议在这里执行核心业务(也无法触发重试)。
// @OnWorkerEvent("completed")
// async onCompleted(job: Job) { … }
}
@@ -213,9 +213,9 @@
  • “Events → OnJobCompleted”:completed 事件只是一个监听钩子,不会参与重试。

  • -

    而 重试次数本身并没有一个硬性上限,完全由添加 Job 时通过 attempts 这个选项来控制:

    +

    而 重试次数本身并没有一个硬性上限,完全由添加 Job 时通过 attempts 这个选项来控制:

    @@ -224,13 +224,13 @@

    还有一个需要注意的地方,在我的业务中,邮件发送的是一种时间区间报告,这个报告包含了过去二十四小时的一些系统中的事件,但如果重试有延迟策略或重试本身就有计算成本的话,这封邮件就不是 “过去二十四小时” 的了,因为重试带来了一个真空期。

    -

    换言之,这个问题本质上是——重试导致「发送时刻」与「原始 24 小时窗口」错开,从而让邮件里报出来的数据不再精确。常见的解决思路就是:把「窗口定义」或者「报表内容」在调度时就固化下来,真正的队列任务只负责发送,而不再实时去重新计算时间区间。

    +

    换言之,这个问题本质上是——重试导致「发送时刻」与「原始 24 小时窗口」错开,从而让邮件里报出来的数据不再精确。常见的解决思路就是:把「窗口定义」或者「报表内容」在调度时就固化下来,真正的队列任务只负责发送,而不再实时去重新计算时间区间。

    我想到了两种解决方案:

    一、任务参数里带上「时间区间」
    在 enqueue 的时候,就算出 windowStart/windowEnd,然后把它放到 job.data 里。无论后面 process 什么时候真正跑,都是基于同一个时间区间去查询:

    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
    // 调度时
    const now = new Date();
    const windowStart = new Date(now.getTime() - 24 * 60 * 60 * 1000);
    await this.mailerQueue.add(
    id,
    {
    mail: new Mail({
    ...options,
    id,
    sentAt: now,
    status: MailStatus.PENDING,
    windowStart,
    windowEnd: now,
    }),
    },
    {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
    },
    );

    // process 里
    public async process(job: Job) {
    const { windowStart, windowEnd } = job.data.mail;
    // ① 只查询 [windowStart, windowEnd] 的事件
    const events = await this.reportService.findEvents(windowStart, windowEnd);
    const reportHtml = await this.reportService.renderReport(events);
    await this.commandBus.execute(new SendMailCommand(job.data.mail, reportHtml));
    }

    ➜ 这样无是马上执行还是几次重试后才执行,数据规则都不会变。

    二、预先生成「静态报表内容」,挂到队列里
    如果计算成本很高,或者怕重复查询数据开销大,也可以在调度时就把最终的 HTML/Text/附件 都先打好,然后作为 job.data 传进去,真正的 process() 只做一次“发送”即可:

    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
    // 调度时:先生成报告
    const now = new Date();
    const windowStart = new Date(now.getTime() - 24*3600*1000);
    const events = await this.reportService.findEvents(windowStart, now);
    const reportHtml = await this.reportService.renderReport(events);

    // 把静态内容塞到队列
    await this.mailerQueue.add(
    id,
    {
    mail: new Mail({ /*…*/, windowStart, windowEnd: now }),
    reportHtml, // <- 预渲染好的文本/HTML
    attachments: […], // <- 如果有附件也一并塞
    },
    { attempts: 3, backoff: { type: 'fixed', delay: 5_000 } },
    );

    // process 里只关注发送
    public async process(job: Job) {
    try {
    await this.mailService.send({
    to: job.data.mail.to,
    subject: `系统 24h 报表`,
    html: job.data.reportHtml,
    attachments: job.data.attachments,
    });
    } catch (e) {
    throw e; // 触发重试
    }
    }

    ➜ 重试带来的任何延迟,都不影响邮件正文,始终是一份「事先约定好、并且静态化」的报告。

    -

    这两种模式都能保证最终发送时的数据窗口或内容,与当初调度时的预期完全一致,不会因为重试延迟而出现“数据真空”或“多算/少算”问题。

    +

    这两种模式都能保证最终发送时的数据窗口或内容,与当初调度时的预期完全一致,不会因为重试延迟而出现“数据真空”或“多算/少算”问题。

    参考文档: