From 4da1dc74954fafe4d21841892bafc955a37ac805 Mon Sep 17 00:00:00 2001 From: muqiuhan Date: Tue, 27 May 2025 06:06:45 +0000 Subject: deploy: 74a1d57ead41454de540829cd6de5ba1f5702bd6 --- search.xml | 394 ++++++++++++++++++++++++++++++------------------------------- 1 file changed, 197 insertions(+), 197 deletions(-) (limited to 'search.xml') diff --git a/search.xml b/search.xml index 0ed243fe..b6ec488e 100644 --- a/search.xml +++ b/search.xml @@ -16,7 +16,7 @@

You can also execute a single-line with Alt + ‘. I rarely use this option, but this can save you time because you don’t need to select the entire line of code.

-

In case the keyboard shortcuts to send code to FSI do not work anymore (ReSharper used to over-write them in the past), you can reset them in Visual Studio, by going to Tools / Options / Environment / Keyboard. The 2 commands you need to map are EditorContextMenus.CodeWindow.ExecuteInInteractive and EditorContextMenus.CodeWindow.ExecuteLineInInteractive.

+

In case the keyboard shortcuts to send code to FSI do not work anymore (ReSharper used to over-write them in the past), you can reset them in Visual Studio, by going to Tools / Options / Environment / Keyboard. The 2 commands you need to map are EditorContextMenus.CodeWindow.ExecuteInInteractive and EditorContextMenus.CodeWindow.ExecuteLineInInteractive.

You can also use these shortcuts from a regular .fs file, which can be handy if you want to validate that a piece of code is behaving the way you want.

@@ -102,7 +102,7 @@

Tip 6: Use Paket

The Nuget package manager is useful to consume existing packages. However, by default, Nuget stores assemblies in a folder that includes the package version number. This is very impractical for a script. In our example above, if fsharp.data gets an update, our script reference will be broken once we update the Nuget package:

#r @"../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"

-

Fixing the script requires manually editing the version number in the path, which quickly becomes a pain. Paket provides a better experience, because it stores packages without the version number, in this case, under:

+

Fixing the script requires manually editing the version number in the path, which quickly becomes a pain. Paket provides a better experience, because it stores packages without the version number, in this case, under:

#r @"../packages/FSharp.Data/lib/net40/FSharp.Data.dll"

Your scripts will now gracefully handle version number changes.

If you end up consuming numerous packages, you can make your life even easier, by referencing paths where assemblies might be searched for, using #I:

@@ -140,7 +140,7 @@

Tip 10: Bonus Material

Did you know that you could…

Now we could as well stop here - IMHO this approach is already good and useful for most cases. We can also try to push it further. As you’ve seen, our code now requires quite a lot of env passing around. Could we do something about this? It turns out that yes, we could.

-

Reader monad

Before we continue: what we’re going to cover now is less useful in terms of current state of F# ecosystem for the reasons I’ll mention later.

-

The pattern we’ll use here is known as a Reader Monad. While it’s useful in certain situations, it’s not widely used - IMO it’s fault lies in the name itself, which somehow managed to sound both borderline meaningless and scary in ears of many developers.

+

Reader monad

Before we continue: what we’re going to cover now is less useful in terms of current state of F# ecosystem for the reasons I’ll mention later.

+

The pattern we’ll use here is known as a Reader Monad. While it’s useful in certain situations, it’s not widely used - IMO it’s fault lies in the name itself, which somehow managed to sound both borderline meaningless and scary in ears of many developers.

The rest of this blog post will be introduction to this style in F#, however focused solely around problem of dependency management - we’ll ignore other aspects of monads.

We’ll going to reuse our environment type from above, but now encode it directly into another type we’ll call Effect. Since I’ve mentioned that our pattern has M-word in it, you can safely assume that our handler’s logic will be defined as a lazy sequence of steps to be executed (sounds almost like async/await). In F# we’ll sugar them by using custom computation expression (I’m going to call it effect { ... }) returning our effect type, which we’ll define as:

[<Struct>] type Effect<'env, 'out> = Effect of ('env -> 'out)
@@ -828,7 +828,7 @@ /2025/05/08/Multiplayer-Collaborative-Systems-tips/ 最近碰到一块业务:在系统中可以存在多个用户同时对某个项目信息进行编辑,这种多人协作的场景挺有意思的,不过在我们的业务中,并不需要实时协作,只需要保证不会出错就行,话虽如此,但也可以探索一下实时协作的实现方案,防止老年痴呆。

先来看看第一个方案 —— CRDT(Conflict-free Replicated Data Type,无冲突可复制数据类型)是一类数据结构,它保证了在分布式节点(或多客户端)上进行离线/并发更新后,无需中心协调、也无需人工干预,通过“合并策略”就能得到一致的最终状态。

-

核心思想是:所有并发操作都是幂等(idempotent)、可交换(commutative)的。

+

核心思想是:所有并发操作都是幂等(idempotent)、可交换(commutative)的。

常见类型有:
一、G-Counter(只能增计数器)
二、PN-Counter(可增可减计数器)
三、LWW-Register(最后写入胜出)
四、结合 JSON 的树型 CRDT(如 Automerge / Yjs)

更多原理可参考 Decipad 博客“Collaborative and Offline Editing Using CRDTs”[^1]。

@@ -871,7 +871,7 @@ await prisma.$transaction(async tx => {

前端的话,大概就是:

在进入编辑前请求一下 /api/project/:id/lock 之类的 API,失败则提示“被占用”;
在 onbeforeunload 时执行 /unlock;
超时后后端自动允许新锁。


-

第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。

+

第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。

具体实现中,可以尝试在 project 表加上 version INT NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ ,然后更新项目时:

async updateProject(parent, { id, version, input }, ctx) {
     const result = await prisma.$executeRaw`
@@ -891,7 +891,7 @@ await prisma.$transaction(async tx => {
 

前端捕获到冲突错误可以用一个弹窗提示“另有用户已更新此项目,是否合并/重新加载?” 之类的玩意儿。


第三个方案是:操作转化(Operational Transformation,OT)

-

也就是记录用户每次的“操作”(insert/delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。

+

也就是记录用户每次的“操作”(insert/delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。

有一些实现案例:
一、ShareDB(Node.js)
二、Google Docs 中的同步算法

具体实现的话,可能要现在前端逐字符/块地包装成操作并 WebSocket 推送,服务器再维护一个“操作历史队列”,每来一个 op 就 transform 并 broadcast,而客户端收到广播后,按顺序 replay 保证视图一致。


@@ -901,7 +901,7 @@ await prisma.$transaction(async tx => {

总结来说,

    -
  • CRDT 最擅长 去中心化、离线编辑、自动合并;
  • +
  • CRDT 最擅长 去中心化、离线编辑、自动合并;
  • 若不引入 CRDT,可根据业务侧重点选用:
    1. 悲观锁 → 强制串行编辑,简单粗暴;
    2. 乐观并发 → 适合大多数业务场景,成本低;
    3. @@ -921,31 +921,31 @@ await prisma.$transaction(async tx => { N+1 selects problem 与 Prisma ORM /2025/04/02/N-1-selects-problem-%E4%B8%8E-Prisma-ORM/ - 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 个用户。
        SELECT * FROM User LIMIT 10;
      2. -
      3. 接下来的 N (=10) 次查询 (The “N”): 对于上一步获取到的每一个用户,单独执行一次查询来获取该用户的帖子。
        -- 用户 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 个用户。
        SELECT * FROM User LIMIT 10;
      6. +
      7. 接下来的 N (=10) 次查询 (The “N”): 对于上一步获取到的每一个用户,单独执行一次查询来获取该用户的帖子。
        -- 用户 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,可以这样写:

      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 记录。
        SELECT "public"."User"."id", "public"."User"."name", /* ... other user fields */ FROM "public"."User" WHERE 1=1
      2. -
      3. 查询关联的子模型: 使用上一步获取到的所有用户 id,通过 WHERE IN (...) 子句一次性查询所有相关的 Post 记录。
        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 记录。
        SELECT "public"."User"."id", "public"."User"."name", /* ... other user fields */ FROM "public"."User" WHERE 1=1
      6. +
      7. 查询关联的子模型: 使用上一步获取到的所有用户 id,通过 WHERE IN (...) 子句一次性查询所有相关的 Post 记录。
        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.

        @@ -1707,7 +1707,7 @@ await prisma.$transaction(async tx => {
        • 关系的两端都必须定义一个共享相同名称的 @relation 属性(BlogOwnerHistory)
        • 关系字段必须是完全注释的。例如 successor 字段需要定义 field 和 references 参数。
        • -
        • 关系字段必须由外键支持。successor 字段由 successorId 外键提供支持,该外键引用 id 字段中的值。successorId 还需要 @unique 属性来保证一对一的关系。****
        • +
        • 关系字段必须由外键支持。successor 字段由 successorId 外键提供支持,该外键引用 id 字段中的值。successorId 还需要 @unique 属性来保证一对一的关系。

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

        @@ -1881,7 +1881,7 @@ await prisma.$transaction(async tx => { Rust NewType 模式 /2023/05/03/Rust-NewType-%E6%A8%A1%E5%BC%8F/ - New Type模式是一种软件设计模式,用于在已有类型的基础上创建一个新的类型。在Rust中,这通常是通过定义一个结构体,其中只包含一个单一成员。这个结构体(New Type)对外提供了一个新的、独立的类型,用于对原始类型增加额外的语义或限制。

        + New Type模式是一种软件设计模式,用于在已有类型的基础上创建一个新的类型。在Rust中,这通常是通过定义一个结构体,其中只包含一个单一成员。这个结构体(New Type)对外提供了一个新的、独立的类型,用于对原始类型增加额外的语义或限制。

        加强类型安全

        struct Meters(f64);
        struct Feet(f64);

        let length_in_meters = Meters(100.0);
        let length_in_feet = Feet(328.084);

        // 编译器会防止以下代码执行,因为类型不匹配
        // let wrong_length = Meters(length_in_feet); // 编译错误

        // 正确的构造
        fn add_lengths(length1: Meters, length2: Meters) -> Meters {
        Meters(length1.0 + length2.0)
        }

        这个例子使用 newtype 模式避免将原始类型f64用于不同的量度,从而增强了类型的安全性。

        @@ -1915,10 +1915,10 @@ await prisma.$transaction(async tx => {

        在全部比较可能的场景,我们会使用Ord trait,它要求实现cmp方法,总是返回一个Ordering,表示两个值之间的确切比较关系。Ord是在所有值都能够比较时使用的,例如整数和字符串。

        设计用意和解决的问题

        Rust 设计 PartialEq 和 PartialOrd trait 主要出于以下几个理由:

          -
        • 非总序理念:并不是所有类型都有一个全局的排序方法。例如,复数之间就没有一个自然的大小顺序。为了避免为这些类型人为地赋予一个排序方法,Rust 提供了一个只需部分实现序列操作的选择。
        • -
        • IEEE 浮点数标准:由于浮点数标准定义了特殊值(NaN, 正负无穷),以及NaN不等于自身的规则,浮点数在一些情况下不能进行相等性或大小比较。
        • -
        • 提升错误处理能力和安全性:通过返回 Option<Ordering>,partial_cmp 方法明确指出了失败的可能性,从而迫使程序员在使用时考虑并处理这种情况,增加了代码的正确性和稳健性。
        • -
        • 表达性和灵活性:这些 trait 允许开发者为自定义类型定义适当的相等性和排序行为,从而加强了 Rust 类型系统的表达性和灵活性。
        • +
        • 非总序理念:并不是所有类型都有一个全局的排序方法。例如,复数之间就没有一个自然的大小顺序。为了避免为这些类型人为地赋予一个排序方法,Rust 提供了一个只需部分实现序列操作的选择。
        • +
        • IEEE 浮点数标准:由于浮点数标准定义了特殊值(NaN, 正负无穷),以及NaN不等于自身的规则,浮点数在一些情况下不能进行相等性或大小比较。
        • +
        • 提升错误处理能力和安全性:通过返回 Option<Ordering>,partial_cmp 方法明确指出了失败的可能性,从而迫使程序员在使用时考虑并处理这种情况,增加了代码的正确性和稳健性。
        • +
        • 表达性和灵活性:这些 trait 允许开发者为自定义类型定义适当的相等性和排序行为,从而加强了 Rust 类型系统的表达性和灵活性。

        PartialEq 和 PartialOrd trait 的设计允许程序员选择精准的相等性和排序语义,同时明确了对于某些类型相等性比较和大小排序并不总是可能的事实。通过引入适度的复杂性,让 Rust 的类型系统更加安全。

        ]]>
        @@ -1999,19 +1999,19 @@ await prisma.$transaction(async tx => {

        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可以捕获这种不安全的操作:
        def usingLogFile[T](op: FileOutputStream => T): T =
        val logFile = FileOutputStream("log")
        val result = op(logFile)
        logFile.close()
        result
        @@ -2020,47 +2020,47 @@ await prisma.$transaction(async tx => {
        |  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 的纯函数。

        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 到不提及该变量本身的最小超类型,这个过程通常会涉及到变量的捕获集。这种加宽有助于改善类型推断和代码清晰度,避免局部变量的类型变得过于复杂。

        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。这种自动推断机制在很多情况下减少了手动指定类捕获集的需要。

        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()
        ...
        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: 代码示例与演示概念

        @@ -2070,9 +2070,9 @@ await prisma.$transaction(async tx => { - - - + + + @@ -2141,11 +2141,11 @@ await prisma.$transaction(async tx => {
        代码片段演示概念捕获行为解释代码片段演示概念捕获行为解释
        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器在编译过程中的状态的深入信息。

        ]]>
        Technique @@ -2156,27 +2156,27 @@ await prisma.$transaction(async tx => { /2025/03/20/TDD-%E5%92%8C-DDD-%E7%9A%84%E4%B8%80%E4%BA%9B%E5%B0%8F%E6%83%B3%E6%B3%95/ 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 来驱动实现,确保每个领域模型的实现。

        @@ -2218,8 +2218,8 @@ await prisma.$transaction(async tx => {

        那么 apps 或 packages 目录中有 package.json 的每个目录都将被视为一个包。

        -

        注意:Turborepo 不支持嵌套包,例如 apps/** 或 packages/** 这种,将一个包放在apps/a 并将另一个包放在 apps/a/b 的结构将导致错误。

        -

        如果想按目录对包进行分组,可以使用 packages/* 和 packages/group/* 等 glob 来完成此操作,而不是创建 packages/group/package.json 文件。

        +

        注意:Turborepo 不支持嵌套包,例如 apps/ 或 packages/ 这种,将一个包放在apps/a 并将另一个包放在 apps/a/b 的结构将导致错误。

        +

        如果想按目录对包进行分组,可以使用 packages/* 和 packages/group/* 等 glob 来完成此操作,而不是创建 packages/group/package.json 文件。

        根目录的 package.json 是 workspace 的基础,常见的配置:

        {
        "private": true,
        "scripts": {
        "build": "turbo run build",
        "dev": "turbo run dev",
        "lint": "turbo run lint"
        },
        "devDependencies": {
        "turbo": "latest"
        },
        "packageManager": "npm@10.0.0"
        }
        @@ -2240,9 +2240,9 @@ await prisma.$transaction(async tx => {

        以这种方式使用导出有三个主要好处:

          -
        • 避免 barrel 文件:barrel 文件是重新导出同一包中其他文件的文件,从而为整个包创建一个入口点。虽然它们可能看起来很方便,但编译器和捆绑程序很难处理它们,并且可能很快导致性能问题。
        • -
        • 更强大的功能:与主字段(如 Conditional Exports)相比,exports 还具有其他强大的功能。一般来说,尽可能使用 exports 而不是 main 就行了。
        • -
        • IDE 友好:通过使用 export 指定包的入口点,代码编辑器可以为包的导出提供自动完成。
        • +
        • 避免 barrel 文件:barrel 文件是重新导出同一包中其他文件的文件,从而为整个包创建一个入口点。虽然它们可能看起来很方便,但编译器和捆绑程序很难处理它们,并且可能很快导致性能问题。
        • +
        • 更强大的功能:与主字段(如 Conditional Exports)相比,exports 还具有其他强大的功能。一般来说,尽可能使用 exports 而不是 main 就行了。
        • +
        • IDE 友好:通过使用 export 指定包的入口点,代码编辑器可以为包的导出提供自动完成。

        除此之外还有 import 字段,也就是一种创建包中其他模块的子路径的方法。可以简单地视为 “快捷方式” ,用于编写更简单的导入路径,这些路径对日后文件被移动后的重构更具弹性。

        @@ -2257,13 +2257,13 @@ await prisma.$transaction(async tx => {

        这种做法有几个好处:

          -
        • 更清晰:当软件包的依赖项列在其 package.json 中时,更容易理解软件包所依赖的内容。在存储库中工作的开发人员可以一目了然地看到包中使用了哪些依赖项。
        • -
        • 更具灵活性:在大规模的 monorepo 中,想让每个包都使用相同版本的外部依赖项可能是不现实的。当有许多团队在同一个代码库中工作时,优先级、时间表和需求会有所不同。通过在 “使用它们的包” 中安装依赖项,可以让 UI 团队能够升级到最新版本的 TypeScript,而 Web 团队可以优先发布新功能并在以后使用 TypeScript。
        • -
        • 更好的缓存能力:如果在存储库的根目录中安装了太多依赖项,则每当添加、更新或删除依赖项时,都会更改工作区根目录,从而导致不必要的缓存未命中。
        • -
        • 修剪未使用的依赖项:对于 Docker 用户,Turborepo 的修剪功能可以从 Docker 镜像中删除未使用的依赖项,以创建更轻量级的镜像。当依赖项安装在它们所适用的包中时,Turborepo 可以读取锁文件并删除所需的包中未使用的依赖项。
        • +
        • 更清晰:当软件包的依赖项列在其 package.json 中时,更容易理解软件包所依赖的内容。在存储库中工作的开发人员可以一目了然地看到包中使用了哪些依赖项。
        • +
        • 更具灵活性:在大规模的 monorepo 中,想让每个包都使用相同版本的外部依赖项可能是不现实的。当有许多团队在同一个代码库中工作时,优先级、时间表和需求会有所不同。通过在 “使用它们的包” 中安装依赖项,可以让 UI 团队能够升级到最新版本的 TypeScript,而 Web 团队可以优先发布新功能并在以后使用 TypeScript。
        • +
        • 更好的缓存能力:如果在存储库的根目录中安装了太多依赖项,则每当添加、更新或删除依赖项时,都会更改工作区根目录,从而导致不必要的缓存未命中。
        • +
        • 修剪未使用的依赖项:对于 Docker 用户,Turborepo 的修剪功能可以从 Docker 镜像中删除未使用的依赖项,以创建更轻量级的镜像。当依赖项安装在它们所适用的包中时,Turborepo 可以读取锁文件并删除所需的包中未使用的依赖项。
        -

        属于工作区根目录的唯一依赖项是用于管理存储库的工具,而用于构建应用程序和库的依赖项安装在各自的包中。一些适合安装在根中的依赖项示例包括 turbo、husky 或 lint-staged。

        +

        属于工作区根目录的唯一依赖项是用于管理存储库的工具,而用于构建应用程序和库的依赖项安装在各自的包中。一些适合安装在根中的依赖项示例包括 turbo、husky 或 lint-staged。

        保持同一版本的依赖

        一些 monorepo 维护者更喜欢按照规则在所有软件包中保持对相同版本的依赖关系。有几种方法可以实现此目的:

          @@ -2277,18 +2277,18 @@ await prisma.$transaction(async tx => {

          内部包

          内部包是工作区的构建块(building blocks),是一种在存储库中共享代码的强大方式。Turborepo 读取 package.json 中的依赖项来分析内部包之间的关系,并在后台创建 Package Graph 以优化存储库的工作流程。

          在创建内部包时,建议创建具有单一 “用途” 的包。这是最佳实践,具体取决于存储库的规模、组织、团队需求等。此策略具有以下几个优点:

            -
          • 更易于理解:随着存储库的扩展,在存储库中工作的开发人员将能够更轻松地找到他们需要的代码。
          • -
          • 减少每个包的依赖项:每个包使用更少的依赖项,以便 Turborepo 可以更有效地修剪包图的依赖项。
          • +
          • 更易于理解:随着存储库的扩展,在存储库中工作的开发人员将能够更轻松地找到他们需要的代码。
          • +
          • 减少每个包的依赖项:每个包使用更少的依赖项,以便 Turborepo 可以更有效地修剪包图的依赖项。

          在创建应用程序包时,最好避免将共享代码放在这些包中。相反,应该为共享代码创建一个单独的包,并让应用程序包依赖于该包。
          此外,应用程序包不应安装到其他包中。相反,应将它们视为 Package Graph 的入口点。

          配置任务

          Turborepo 将始终按照 turbo.json 配置和 Package Graph 中描述的顺序运行任务,并尽可能并行化工作以确保一切尽可能快地运行。

          根目录的 turbo.json 文件是注册 Turborepo 将运行的任务的位置。定义任务后,将能够使用 turbo run 运行一个或多个任务。

          -

          tasks 对象中的每个 key 都是一个可以通过 turbo run 执行的任务。Turborepo 将在 package.json 中搜索与任务同名的软件包:

          +

          tasks 对象中的每个 key 都是一个可以通过 turbo run 执行的任务。Turborepo 将在 package.json 中搜索与任务同名的软件包:

          dependsOn 键用于指定在其他任务开始运行之前必须完成的任务。在大多数情况下,库的build脚本在应用程序的build脚本运行之前完成,所以可以这么写:

          {
          "tasks": {
          "build": {
          "dependsOn": ["^build"]
          }
          }
          }
          -

          ^ 这个语法告诉 Turborepo 从依赖关系图的底部开始运行任务。如果应用程序依赖于名为 ui 的库,并且该库具有build任务,则 ui 中的build脚本将首先运行。成功完成后,才会运行应用程序中的build任务。
          这是一个重要的形式,因为它可以确保应用程序的build任务具有编译所需的所有必要依赖项。当依赖关系图发展到具有多个级别的任务依赖关系的更复杂的结构时,此概念也适用。

          +

          ^ 这个语法告诉 Turborepo 从依赖关系图的底部开始运行任务。如果应用程序依赖于名为 ui 的库,并且该库具有build任务,则 ui 中的build脚本将首先运行。成功完成后,才会运行应用程序中的build任务。
          这是一个重要的形式,因为它可以确保应用程序的build任务具有编译所需的所有必要依赖项。当依赖关系图发展到具有多个级别的任务依赖关系的更复杂的结构时,此概念也适用。

          有时可能需要确保同一包中的两个任务按特定顺序运行。例如需要先在库中运行build任务,然后再在同一库中运行test任务。这种情况删掉 ^ 就行了:

          {
          "tasks": {
          "test": {
          "dependsOn": ["build"]
          }
          }
          }
          @@ -2305,10 +2305,10 @@ await prisma.$transaction(async tx => {

          指定输入输出

          outputs 键告诉 Turborepo 文件和目录在任务成功完成时应该缓存在哪。如果未定义此 key,Turborepo 将不会缓存任何文件。

          例如缓存 vite 的输出一般可以这么写:

          -
          {
          "tasks": {
          "build": {
          "outputs": ["dist/**"]
          }
          }
          }
          +
          {
          "tasks": {
          "build": {
          "outputs": ["dist/"]
          }
          }
          }

          inputs 键用于指定要包含在任务哈希中以进行缓存的文件。默认情况下,Turborepo 将包含包中由 Git 跟踪的所有文件。但是也可以使用 inputs 键更具体地说明哈希中包含哪些文件, 例如,在 Markdown 文件中查找拼写错误的任务可以定义如下::

          -
          {
          "tasks": {
          "spell-check": {
          "inputs": ["**/*.md", "**/*.mdx"]
          }
          }
          }
          +
          {
          "tasks": {
          "spell-check": {
          "inputs": ["/*.md", "/*.mdx"]
          }
          }
          }

          可以通过微调 input 以忽略对已知不会影响任务输出的文件的更改来提高某些任务的缓存命中率, 可以使用 $TURBO_DEFAULT$ 微语法来微调默认 input 行为:

          {
          "tasks": {
          "build": {
          "inputs": ["$TURBO_DEFAULT$", "!README.md"]
          }
          }
          }
          @@ -2321,7 +2321,7 @@ await prisma.$transaction(async tx => {
        • 包配置是直接放入包中的turbo.json文件。这允许软件包为其自己的任务定义特定行为,而不会影响存储库的其余部分。

        • 有一些始终需要运行的任务,例如缓存生成后的部署脚本。对于这些任务,用 “cache”: false :

          -
          {
          "tasks": {
          "deploy": {
          "dependsOn": ["^build"],
          "cache": false
          },
          "build": {
          "outputs": ["dist/**"]
          }
          }
          }
          +
          {
          "tasks": {
          "deploy": {
          "dependsOn": ["^build"],
          "cache": false
          },
          "build": {
          "outputs": ["dist/"]
          }
          }
          }
        • 某些任务可以并行运行,例如 Linter 不需要等待依赖项中的输出成功才能运行:

          {
          "tasks": {
          "transit": {
          "dependsOn": ["^transit"]
          },
          "check-types": {
          "dependsOn": ["transit"]
          },
          },
          }
          @@ -2348,7 +2348,7 @@ await prisma.$transaction(async tx => {

          其他

          • --dry 标志,可用于查看如果在没有实际运行任务的情况下运行任务会发生什么。当不确定正在运行的任务时,这对于调试缓存问题非常有用。
          • --summarize 标志,可用于获取任务的所有输入、输出等的概览。比较两个摘要将揭示两个任务的哈希值不同的原因。
          • -
          • 强制 turbo 重新执行已缓存的任务,请使用 --force 标志。请注意,这将禁用读取缓存,而不是写入。
          • +
          • 强制 turbo 重新执行已缓存的任务,请使用 --force 标志。请注意,这将禁用读取缓存,而不是写入。

          开发工作流

          在 turbo.json 中定义开发任务 (development task) 会告诉 Turborepo 将运行一个长期任务。这对于运行开发服务器、运行测试或构建应用程序等操作非常有用:

          {
          "tasks": {
          "dev": {
          "cache": false,
          "persistent": true
          }
          }
          }
          @@ -2359,7 +2359,7 @@ await prisma.$transaction(async tx => {

        一些脚本允许使用 stdin 在其中键入以进行交互式输入。使用终端 UI,可以选择一个任务,输入它,然后像往常一样使用 stdin。

        需要运行用于设置开发环境或预构建包的脚本。可以使用 dependsOn 确保这些任务在 dev 任务之前运行:

        -
        {
        "tasks": {
        "dev": {
        "cache": false,
        "persistent": true,
        "dependsOn": ["//#dev:setup"]
        },
        "//#dev:setup": {
        "outputs": [".codegen/**"]
        }
        }
        }
        +
        {
        "tasks": {
        "dev": {
        "cache": false,
        "persistent": true,
        "dependsOn": ["//#dev:setup"]
        },
        "//#dev:setup": {
        "outputs": [".codegen/"]
        }
        }
        }

        这里用的是 Root Task,但可以对 packages 中的任意任务使用相同的思路。

        Watch mode

        许多工具都有一个内置的 watcher,比如 tsc --watch,它会响应源代码中的更改。有些则没有,Turbo Watch 为任何工具添加了依赖项感知的 Watcher。对源代码的更改将遵循在 turbo.json 中描述的 Task Graph (任务图),例如:

        @@ -2377,11 +2377,11 @@ await prisma.$transaction(async tx => {

        Environment mode

        Turborepo 的 Environment Mode 允许控制哪些环境变量在运行时可用于任务:

          -
        • 严格模式(默认):将环境变量过滤为仅在 turbo.json 的 env 和 globalEnv 键中指定的环境变量。
        • +
        • 严格模式(默认):将环境变量过滤为仅在 turbo.json 的 env 和 globalEnv 键中指定的环境变量。
        • 宽松模式:允许进程的所有环境变量可用。

        其他

          -
        • .env 文件非常适合在本地处理应用程序。Turborepo 不会将 .env 文件加载到任务的运行时中,而是让它们由框架或 dotenv 等工具处理。但是,turbo 必须知道 .env 文件中值的更改,以便它可以将它们用于哈希。如果在两次构建之间更改 .env 文件中的变量,则 build 任务应该不会用上缓存。所以可以将其添加到 input 键中:

          +
        • .env 文件非常适合在本地处理应用程序。Turborepo 不会将 .env 文件加载到任务的运行时中,而是让它们由框架或 dotenv 等工具处理。但是,turbo 必须知道 .env 文件中值的更改,以便它可以将它们用于哈希。如果在两次构建之间更改 .env 文件中的变量,则 build 任务应该不会用上缓存。所以可以将其添加到 input 键中:

          {
          "globalDependencies": [".env"], // All task hashes
          "tasks": {
          "build": {
          "inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"] // Only the `build` task hash
          }
          }
          }
        • 不建议在存储库的根目录中使用 .env 文件。相反,建议将 .env 文件放入使用它们的包中。

          @@ -2529,14 +2529,14 @@ await prisma.$transaction(async tx => {
          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**:是真正“干活”的地方,收到 job 之后立刻被调用,任何主业务逻辑(发邮件/写数据库/第三方请求等)都应该放这里。
          • -
          • onCompleted:只是一个事件监听器,在 job 已经成功完成之后 才会被触发,不会影响 job 的重试逻辑(也就是说,在这里抛错,job 已经算完成了,也不会重试)。
          • +
          • process:是真正“干活”的地方,收到 job 之后立刻被调用,任何主业务逻辑(发邮件/写数据库/第三方请求等)都应该放这里。
          • +
          • onCompleted:只是一个事件监听器,在 job 已经成功完成之后 才会被触发,不会影响 job 的重试逻辑(也就是说,在这里抛错,job 已经算完成了,也不会重试)。
          -

          而我在此处的业务目的是 “用队列来做可靠的、可重试的邮件发送”,那么一定要把发送邮件的逻辑写到 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:

          // ... 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) { … }
          }
          @@ -2546,9 +2546,9 @@ await prisma.$transaction(async tx => {
        • “Events → OnJobCompleted”:completed 事件只是一个监听钩子,不会参与重试。

        -

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

        +

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

          -
        • 默认情况下,如果不传 attempts(或不在 defaultJobOptions 里配置),Job 不会自动重试(相当于 attempts = 0)。
        • +
        • 默认情况下,如果不传 attempts(或不在 defaultJobOptions 里配置),Job 不会自动重试(相当于 attempts = 0)。
        • 如果在 queue.add()(或全局 defaultJobOptions)里设置了 attempts: N,那么 BullMQ 最多会让该 Job 运行 N 次(也就是初始执行 + N−1 次重试,或者根据文档含义最多触发 N 次失败) ,失败后才算真正移入失败集合。
        • attempts 可以是任意的正整数(受 JavaScript Number 范围限制),BullMQ 本身不会再做额外的上限检查。
        @@ -2557,13 +2557,13 @@ await prisma.$transaction(async tx => {

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

        -

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

        +

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

        我想到了两种解决方案:

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

        // 调度时
        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() 只做一次“发送”即可:

        // 调度时:先生成报告
        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; // 触发重试
        }
        }

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

        -

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

        +

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

        参考文档:

        • “Retrying failing jobs” · BullMQ Guide
          https://docs.bullmq.io/guide/retrying-failing-jobs
        • BullMQ Guide & Patterns · Process Step Jobs (completed event only fires after process resolves)
          https://docs.bullmq.io/patterns/process-step-jobs
        • @@ -2631,7 +2631,7 @@ await prisma.$transaction(async tx => {

          Rimon Tawadrous 在其 GitHub repo 中的测试,对比 100 万条逐条插入实验,UUID v7 相较 UUID v4 在单线程插入上速度快约 3.24%,多线程下更可观【1】。


          -

          参考链接
          [1] “为什么 UUID 7 比 UUID 4 更适合作为 RDBMS 的聚集索引?” dbaplus.cn
          https://dbaplus.cn/news-160-6313-1.html
          [2] “PostgreSQL and UUID as primary key” maciejwalkowiak
          https://maciejwalkowiak.com/blog/postgres-uuid-primary-key/
          [3] “Optimised UUIDs in mysql” stitcher
          https://stitcher.io/blog/optimised-uuids-in-mysql
          [3] “Storing UUID Values in MySQL” percona
          https://www.percona.com/blog/store-uuid-optimized-way/

          +

          参考链接
          [1] “为什么 UUID 7 比 UUID 4 更适合作为 RDBMS 的聚集索引?” dbaplus.cn
          https://dbaplus.cn/news-160-6313-1.html
          [2] “PostgreSQL and UUID as primary key” maciejwalkowiak
          https://maciejwalkowiak.com/blog/postgres-uuid-primary-key/
          [3] “Optimised UUIDs in mysql” stitcher
          https://stitcher.io/blog/optimised-uuids-in-mysql
          [3] “Storing UUID Values in MySQL” percona
          https://www.percona.com/blog/store-uuid-optimized-way/

          ]]> Technique @@ -2674,43 +2674,43 @@ await prisma.$transaction(async tx => { Vertical slicing 在开发中的实践探索 /2025/05/27/vertical-slicing-practice/ - 垂直切片 (Vertical Slicing) 是一种在敏捷软件开发中将产品需求(通常是用户故事)拆分为可独立交付的、具有端到端功能的小块的方法。这意味着每个“切片”都包含了从用户界面 (UI) 到底层数据库,以及中间所有业务逻辑层所需的工作。

          + 垂直切片 (Vertical Slicing) 是一种在敏捷软件开发中将产品需求(通常是用户故事)拆分为可独立交付的、具有端到端功能的小块的方法。这意味着每个“切片”都包含了从用户界面 (UI) 到底层数据库,以及中间所有业务逻辑层所需的工作。

          与水平切片(即按技术分层,如先完成所有 UI,再完成所有后端逻辑)不同,垂直切片的目标是尽快交付一个虽小但完整可用的功能。


          想象一个蛋糕,垂直切片就像切下一块完整的蛋糕,包含从顶部到底部的每一层。在软件开发中,这意味着一个任务或用户故事的完成会涉及到:

            -
          • **用户界面 (UI)**:用户能看到并与之交互的部分。
          • -
          • **业务逻辑层 (Business Logic Layer)**:处理数据和执行核心功能的部分。
          • -
          • **数据访问层 (Data Access Layer)**:与数据库或其他数据存储交互的部分。
          • -
          • **数据库 (Database)**:存储数据的部分。
          • +
          • 用户界面 (UI):用户能看到并与之交互的部分。
          • +
          • 业务逻辑层 (Business Logic Layer):处理数据和执行核心功能的部分。
          • +
          • 数据访问层 (Data Access Layer):与数据库或其他数据存储交互的部分。
          • +
          • 数据库 (Database):存储数据的部分。

          所以一个垂直切片代表了一个可以独立运行、测试和向用户展示的小功能。


          在实践中,用垂直切片进行任务拆分或许可以按照如下步骤来实现:

          -

          一、**从用户故事开始 (Start with User Stories)**:明确用户需要什么功能以及这个功能为用户带来的价值。例如:“作为一个注册用户,我希望能用我的邮箱和密码登录系统,以便访问我的个人资料。”

          -

          二、识别涉及的技术层面 (Identify Affected Layers)

          +

          一、从用户故事开始 (Start with User Stories):明确用户需要什么功能以及这个功能为用户带来的价值。例如:“作为一个注册用户,我希望能用我的邮箱和密码登录系统,以便访问我的个人资料。”

          +

          二、识别涉及的技术层面 (Identify Affected Layers)

          对于登录功能,需要考虑:

            -
          • UI 层:登录表单(输入邮箱、密码的地方)、提交按钮、错误提示信息。
          • -
          • API/服务层:接收登录请求、验证用户凭证的接口。
          • -
          • 业务逻辑层:校验输入格式、查询用户信息、验证密码、生成会话(Session)或令牌(Token)。
          • -
          • 数据访问层:从数据库中读取用户信息。
          • +
          • UI 层:登录表单(输入邮箱、密码的地方)、提交按钮、错误提示信息。
          • +
          • API/服务层:接收登录请求、验证用户凭证的接口。
          • +
          • 业务逻辑层:校验输入格式、查询用户信息、验证密码、生成会话(Session)或令牌(Token)。
          • +
          • 数据访问层:从数据库中读取用户信息。
          -

          三、创建可交付的小功能块 (Create Small, Deliverable Chunks)

          +

          三、创建可交付的小功能块 (Create Small, Deliverable Chunks)

          就是将一个大的用户故事拆分成更小的、但仍然是垂直的、可独立交付的故事。例如,可以将“用户登录”进一步细化:

          -

          **切片1 (基础登录)**:用户可以使用正确的邮箱和密码成功登录。这包含了 UI 输入、后端验证和数据库查询。

          -

          **切片2 (错误处理)**:用户输入错误的邮箱或密码时,系统给出明确的错误提示。这可能只涉及 UI 和业务逻辑层的少量修改。

          -

          **切片3 (“记住我” 功能)**:用户可以选择“记住我”,下次访问时自动登录。这可能涉及 UI、业务逻辑和客户端存储。

          -

          四、**确保每个切片都有价值 (Ensure Each Slice Has Value)**:每完成一个切片,都应该为用户或产品带来可感知的价值,并且理想情况下是可以演示给利益相关者看的。

          -

          五、**保持切片足够小 (Keep Slices Small Enough)**:每个切片的工作量应该小到可以在一个迭代周期(例如 Sprint)内完成。这有助于团队保持专注,并快速获得反馈。

          +

          切片1 (基础登录):用户可以使用正确的邮箱和密码成功登录。这包含了 UI 输入、后端验证和数据库查询。

          +

          切片2 (错误处理):用户输入错误的邮箱或密码时,系统给出明确的错误提示。这可能只涉及 UI 和业务逻辑层的少量修改。

          +

          切片3 (“记住我” 功能):用户可以选择“记住我”,下次访问时自动登录。这可能涉及 UI、业务逻辑和客户端存储。

          +

          四、确保每个切片都有价值 (Ensure Each Slice Has Value):每完成一个切片,都应该为用户或产品带来可感知的价值,并且理想情况下是可以演示给利益相关者看的。

          +

          五、保持切片足够小 (Keep Slices Small Enough):每个切片的工作量应该小到可以在一个迭代周期(例如 Sprint)内完成。这有助于团队保持专注,并快速获得反馈。


          这里有一些拆分技巧:

            -
          • 按操作流程拆分:例如,一个复杂的表单提交可以先实现基本信息的提交,后续再添加高级选项的提交。
          • -
          • 按业务规则拆分:先实现核心的业务规则,再逐步添加次要的或复杂的规则。
          • -
          • 按数据类型或参数拆分:先支持一种数据类型或最常用的参数,再扩展到其他类型。
          • -
          • 按用户角色或权限拆分:先实现某个核心角色的功能,再实现其他角色的特定功能。
          • -
          • 简化错误处理或用户体验:先实现基本功能,再完善错误处理和用户体验细节。
          • +
          • 按操作流程拆分:例如,一个复杂的表单提交可以先实现基本信息的提交,后续再添加高级选项的提交。
          • +
          • 按业务规则拆分:先实现核心的业务规则,再逐步添加次要的或复杂的规则。
          • +
          • 按数据类型或参数拆分:先支持一种数据类型或最常用的参数,再扩展到其他类型。
          • +
          • 按用户角色或权限拆分:先实现某个核心角色的功能,再实现其他角色的特定功能。
          • +
          • 简化错误处理或用户体验:先实现基本功能,再完善错误处理和用户体验细节。
          ]]>
          @@ -3702,12 +3702,12 @@ await prisma.$transaction(async tx => { 一、传统手艺:Partial Application

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

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

          优点:

          +

          优点:

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

          缺点:

          +

          缺点:

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

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

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

            [<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实现单元测试,无需依赖具体实现。
            -

            应用场景:

            +

            应用场景:

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

            @@ -3733,15 +3733,15 @@ await prisma.$transaction(async tx => {

            然后:

            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的构造器注入
            • @@ -3789,7 +3789,7 @@ await prisma.$transaction(async tx => {

              界面是用户与系统通信的中介层,界面中的交互通常需要用户执行某些操作。不同的操作可能会导致不同的结果,其中可能有一些对于双方来说都非常重要甚至危险的操作。

              所以经常需要提供额外的保护措施来“保护”用户执行一些危险甚至无法恢复的操作。
              这里的“保护”并不是完全的阻止,否则这个操作也没有存在的必要。

              -

              “良好的错误消息很重要,但最好的设计首先会小心地防止问题发生。要么消除容易出错的情况,要么检查它们并在用户承诺操作之前向他们提供确认选项。”

              +

              “良好的错误消息很重要,但最好的设计首先会小心地防止问题发生。要么消除容易出错的情况,要么检查它们并在用户承诺操作之前向他们提供确认选项。”

              什么是危险行为?

              危险行为并不意味着要删除某些内容,具体的危险行为应该由系统的“领域”来定义,例如:

                @@ -3856,9 +3856,9 @@ await prisma.$transaction(async tx => { /2025/02/10/%E5%AE%9E%E6%97%B6%E6%90%9C%E7%B4%A2%E4%B8%AD%E7%9A%84%E9%98%B2%E6%8A%96%E5%87%BD%E6%95%B0/ 在实现实时搜索功能时,通常会使用输入框的事件监听器来捕获用户的输入变化,并在输入变化时发送搜索请求。为了避免过多的请求导致服务器负担过重,通常会使用“防抖”(debounce)技术来控制请求的频率。

                实现步骤

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

                防抖函数示例

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

                function debounce(func, wait) {
                let timeout;
                return function(...args) {
                const context = this;
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(context, args), wait);
                };
                }
                @@ -3867,8 +3867,8 @@ await prisma.$transaction(async tx => {
                <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} />

                请求发送间隔

                  -
                • 防抖时间:通常设置为 300ms 到 500ms 之间。这个时间足够让用户完成输入并减少不必要的请求。
                • -
                • 考虑用户体验:防抖时间过短可能导致过多请求,过长则可能让用户感到延迟。300ms 是一个常用的折中值。
                • +
                • 防抖时间:通常设置为 300ms 到 500ms 之间。这个时间足够让用户完成输入并减少不必要的请求。
                • +
                • 考虑用户体验:防抖时间过短可能导致过多请求,过长则可能让用户感到延迟。300ms 是一个常用的折中值。

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

                ]]>
                @@ -4528,42 +4528,42 @@ await prisma.$transaction(async tx => { /2025/03/20/%E8%82%BA%E5%8A%9F%E8%83%BD%E6%A3%80%E6%9F%A5%E6%95%B0%E5%80%BC/ 肺功能测试是评估患者呼吸系统健康的重要工具。各个指标的及其异常数值可能指示的潜在生理疾病:

                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测量在最大呼气后,肺内仍然残留的气体量。
                      • +
                      • 异常:
                          +
                        • 增加:常见于阻塞性肺病,因气道阻塞导致气体无法完全排出。
                        • +
                        • 降低:可能与限制性肺病相关。
                      @@ -6163,15 +6163,15 @@ await prisma.$transaction(async tx => { 在领域驱动设计(Domain-Driven Design,简称DDD)中,聚合根(Aggregate Root)是聚合(Aggregate)中的核心实体,是一个聚合的入口点和控制者,负责维护聚合内部的一致性和不变性条件。聚合是一组紧密相关的领域对象的集合,这些对象通过一定的业务规则绑定在一起,并被视为一个单元。

                      主要的作用如下:

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

                      其具有以下特性:

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

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

                      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())
                      @@ -6185,7 +6185,7 @@ await prisma.$transaction(async tx => { 领域驱动设计中聚合根持久化和事件发布可能导致数据不一致问题 /2025/03/19/%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1%E4%B8%AD%E8%81%9A%E5%90%88%E6%A0%B9%E6%8C%81%E4%B9%85%E5%8C%96%E5%92%8C%E4%BA%8B%E4%BB%B6%E5%8F%91%E5%B8%83%E5%8F%AF%E8%83%BD%E5%AF%BC%E8%87%B4%E6%95%B0%E6%8D%AE%E4%B8%8D%E4%B8%80%E8%87%B4%E9%97%AE%E9%A2%98/ - 使用领域事件的一种直接做法是:在 应用服务 (Application Service) 中产生事件并发布出去。例如,对于“用户昵称更新”的场景来讲,对应的应用服务 UserCommandService 实现如下:

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

                      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 将事件发送到消息队列中。虽然这种方式比较流行,但它至少存在两个问题:

                      -- cgit v1.2.3