2024年3月


实体

值对象
在一致性边界之内组成聚合乍看起来是一件轻松的任务,但在DDD众多的战术性指导中,该模式却是最不容易理解的。

让我们首先来看看一些常见的问题。聚合只是将一些共享父类、密切相关联的对象聚集成一个对象树吗?如果是这样,对于存在于这个树中的对象有没有一个实用的数目限制?既然一个聚合可以引用另一个聚合,我们是否可以深度地递归遍历下去,并且在此过程中修改对象呢?聚合的不变条件和一致性边界究竟是什么意思?最后一个问题的答案将在很大程度上影响我们对前面所有问题的解答。

有很多途径都将导致我们建立不正确的聚合模型。一方面,我们可能为了对象组合上的方便而将聚合设计得很大。另一方面,我们设计的聚合又可能过于贫瘠而丧失了保护真正不变条件的目的。我们应该同时避免这两个极端,转而将注意力集中在业务规则上。

原则:在一致性边界之内建模真正的不变条件

要从
限界上下文
中发现聚合,我们需要了解模型中真正的不变条件。只有这样,我们才能决定什么样的对象可以放在一个聚合中。

这里的不变条件表示一个业务规则,该规则应该总是保持一致的。存在多种类型的一致性,其中之一便是事务一致性,事务一致性要求立即性和原子性。同时,还存在最终一致性。

对于一个典型的持久化机制来说,我们通常使用单事务来管理一致性。在提交事务时,边界之内的所有内容都必须保持一致。对于一个设计良好的聚合来说,无论由于何种业务需求而发生改变,在单个事务中,聚合中的所有不变条件都是一致的。而对于一个设计良好的限界上下文来说,无论在哪种情况下,它都能保证在一个事务中只修改一个聚合实例。此外,在设计聚合时,我们必须将事务分析也考虑在内。

在一个事务中只修改一个聚合实例,这听起来可能过于严格。但是,这却是设计聚合的重要经验原则,也是我们为什么使用聚合的原因。

前面我们提到,在设计聚合时,我们需要慎重地考虑一致性,这意味着每次客户请求应该只在一个聚合实例上执行一个方法。如果客户所请求的业务过多,那么有可能出现一次请求修改多个聚合实例的情况。

因此,在设计聚合时,我们主要关注的是聚合的一致性边界,而不是创建一个对象树。现实世界中的有些不变条件可能比这更加复杂。但是即便如此,通常情况下的不变条件所需要的建模代价并不大,所以,要设计出小的聚合是可能的。

原则:设计小聚合

现在,我们可以全面地回答前面的问题了:要维护一个庞大的聚合还存在哪些额外的成本?对于大聚合,即便我们可以保证事务的成功执行,它依然有可能限制系统的性能和可伸缩性。

考虑一下系统性能和可伸缩性,假定一个存在了一年多的敏捷项目,其中已经包含了数以千计的待定项,如果一个租户的某个用户只是需要将一个待定项添加到产品中,会发生什么情况?假设我们使用了延迟加载的持久化机制,我们几乎不用同时加载待定项、发布和冲刺。但是,为了添加一个待定项,我们依然需要先将所有的待定项集合元素加载到内存里,而这个数目是巨大的。对于那些不支持延迟加载的持久化机制来说,问题就更糟了。即便我们将内存考虑在内,有时我们仍然需要加载多个集合。

如果我们要设计小的聚合,那么,这里的“小”是什么意思呢?最极端的情况是,一个聚合只拥有全局标识和单个属性,当然,这并不是我们所推荐的做法(除非这正是需求所在)。好的做法是,使用根实体来表示聚合,其中只包含最小数量的属性或值类型属性。这里的“最小数量”表示所需的最小属性集合,不多也不少。

哪些属性是所需的?简单的答案是:那些必须与其他属性保持一致的属性——虽然这不是领域专家所指定的原则。

小聚合不仅有性能和可伸缩性上的好处,它还有助于事务的成功执行,即它可以减少事务提交冲突。这样一来,系统的可用性也得到了增强。在你的领域中,迫使你设计大聚合的不变条件约束并不多。当你遇到这样的情况时,可以考虑添加实体或是集合,但无论如何,我们都应该将聚合设计得尽量减少。

不要相信每一个用例

在交付用例规范时,业务分析人员扮演者非常重要的角色。他们将大量的精力放在那些大而细的规范上,而这将在很大程度上影响我们的设计。此时,我们应该知道,以这种方式产生的用例并没有表达出领域专家的意图。对于每一个用例,我们依然需要用当前模型来进行验证,其中便包含聚合。此时容易出现的一个问题是,某个用例需要修改多个聚合实例。在这种情况下,我们需要搞清楚的是,对用户需求的实现是否分散在多个事务中,还是单个事务?无论写得多好,这样的用例都不能准确地反映出模型中真正的聚合。

假设你的聚合边界与真实的业务约束是一致的,如果业务分析人员给了你如下图的用例需求,问题也将随之而来。考虑不同的提交顺序,你会发现在有些情况下,三次请求中的两次都会失败。对于你的设计来说,这能说明什么呢?这个问题的答案将引导你更深层次地去理解自己的领域。试图保持多个聚合实例间的一致性通常意味着我们缺少了某些聚合不变条件。为了满足新的业务规则,你可能会将多个聚合组合在一起而创建一个新的概念(当然,有可能只是将原有聚合中的某些部分提取出来,然后创建一个新的聚合)。

因此,新的用例可能引导我们重新对聚合进行建模,但是此时你依然需要谨慎行事。从多个聚合中创建一个新的聚合可能会引出一个全新的概念,该概念拥有全新的名字。但是,如果对这个新的概念建模导致了一个大的聚合,这样显然是不好的。那么,此时我们还可以采取什么方法呢?

一个用例可能要求在单个事务中维持聚合的一致性,但是,这并不意味着我们就必须这么做。通常来说,在这种情况下,业务目标都可以通过聚合间的最终一致性来实现的。因此,我们需要带着批判性的态度来审查用例,并在必要的时候敢于挑战自己的假设。

原则:通过唯一标识引用其他聚合

在设计聚合时,我们可能希望使用对象组合,因为这样我们可以对聚合中的对象树进行深度遍历。但是,这并不是使用聚合模式的动机。[Evans]写道,一个聚合可以引用另一个聚合的根聚合。然而,我们需要注意的是,此时被引用的聚合不应该放在引用聚合的一致性边界之内。同时,这种引用方式也并非创建了一个整体性的聚合。 让我们看看下图中的例子:

public classBacklogItem extends ConcurrencySafeEntity{
...
privateProduct product;
...
}

在上例中,一个BacklogItem直接关联了一个Product。

结合前文已经讨论的和接下来即将讨论的,以上实现方式隐含着以下几点:

  1. 引用聚合(BacklogItem)和被引用聚合(Product)不可以放在同一个事务中进行修改。
  2. 如果你试图在单个事务中修改多个聚合,这往往意味着此时的一致性边界是错误的。发生这样的情况通常是因为我们遗漏了某些建模点,或者尚未发现通用语言中的某个概念。
  3. 如果你试图采用第2点,但却遇到了先前所讲的有关大聚合的种种麻烦,那么此时你可能需要使用最终一致性,而不是原子一致性。

在不持有对象引用的情况下,我们是不能修改其他聚合的,因此我们可以避免在同一个事务中修改多个聚合。但是,这种方式的缺点在于限制性太强,因为在领域模型中我们总需要对象之间的关联关系来完成一些任务。那么,此时我们应该怎么办呢?

通过标识引用使多个聚合协同工作

我们应该优先考虑通过全局唯一标识来引用外部聚合,而不是通过直接的对象引用,如图所示。

public classBacklogItem extends ConcurrencySafeEntity{
...
privateProductId productId;
...
}

自然地,通过这种方式创建的聚合也会变得更小,因为此时所关联的聚合是不会即时加载的。模型的性能也将随之变好,因为它需要更少的加载时间和更小的内存。更小的内存使用量不止在内存分配上有好处,对于垃圾回收也是有好处的。

建模对象导航性

通过标识引用并不意味着我们完全丧失了对象导航性。有些人习惯在聚合中使用
资源库
来定位其他聚合。这种技术称为
失联领域模型
,而事实上这只是延迟加载的一种形式。此外,我们还推荐另一种方法:在调用聚合行为方法之前,使用资源库或
领域服务
来获取所需要的对象。在客户端中,应用服务可以对此做出控制,然后分发给聚合:

通过应用服务来处理依赖关系可以避免在聚合中使用资源库或领域服务。然而,如果要处理特定于领域的复杂依赖关系,在聚合的命令方法中使用领域服务却是最好的方法。这里再重申一次,不管使用哪种方式在一个聚合中引用另外的聚合,我们都不能在同一个事务中修改多个聚合实例。

在模型中只使用唯一标识来引用对象的缺点在于:在客户端的用户界面层,要组装多个聚合并予以显示将变得非常困难,我们不得不使用多个资源库。此时,如果对聚合的查询导致了性能问题,那么我们可以考虑theta联合查询或者CQRS。如果heta联合查询和CQRS都不能满足我们的需求,那么就需要在标识引用和直接引用之间折中考虑了。

如果以上所有的建议有损模型的使用方便性,那么我们可以转而考虑它们的其他好处——一个小聚合可以增强模型的性能和伸缩性,另外它还有助于创建分布式系统。

可伸缩性和分布式

当一个核心域中,通常存在多个限界上下文,使用标识引用使得我们可以将分布式的领域模型关联起来。在使用事件驱动架构时,基于消息的领域事件包含了聚合标识,这样的领域事件将在整个企业范围内传播。外部限界上下文中的消息订阅方将使用聚合标识在他们自己的领域模型中展开操作。标识引用形成了一种远程关联或者合作者关系。分布式操作通过双方活动进行管理,但是在
发布-订阅
或者
观察者模式
中,却是多方的。分布式系统中的事务并不是原子性的,各个系统中的聚合通过事件达成一致性。

原则:在边界之外使用最终一致性


在[Evans]对聚合模式的定义中,有一条经常被忽略。如果单次用户请求需要修改多个聚合实例,而此时我们

由于同一台电脑可以安装多个版本的.NET Core SDK。

当安装了许多不同版本的.NET Core SDK 之后,要如何才能使用旧版dotnet 命令,执行dotnet new 或dotnet build 之类的命令?

这部分其实并不困难,只要设定global.json 即可。

首先要查询目前电脑所有安装过的SDK 版本,首先通过 dotnet --list-sdks 查看已安装的sdk版本。

C:\Users\CC>dotnet --list-sdks3.1.426[C:\Program Files\dotnet\sdk]6.0.419[C:\Program Files\dotnet\sdk]8.0.100[C:\Program Files\dotnet\sdk]8.0.102 [C:\Program Files\dotnet\sdk]

然后通过在 global.json 中设置想用的版本。

首先,我们先来示范如何快速建立 global.json 文档:

1. 先利用 dotnet new globaljson 快速建立这个文档,这个文档默认会使用当前默认的 .NET Core SDK 版本

D:\demo\test\globaljson>dotnet newglobaljson

{
"sdk": {"version": "8.0.102"}
}

2. 然后将 global.json 文件内的 "version" 修改为特定 SDK 版本即可

{"sdk": {"version": "3.1.426"}
}

或者是你也可以利用 --sdk-version 参数,直接给定 SDK 版本,如下所示:

D:\demo\test\globaljson>dotnet new globaljson --sdk-version=3.1.426The template"global.json file" was created successfully

之后使用 dotnet new console 这类命令就会使用3.1.426 版本SDK 而不是默认的版本SDK。

写在开头

在写完上一篇文章
《Java面试必考题之线程的生命周期,结合源码,透彻讲解!》
后,本以为这个小知识点就总结完了。

但刚刚吃晚饭时,突然想到了多年前自己面试时的亲身经历,决定再回来补充一个小知识点!

记得是一个周末去面试
Java后端开发工程师岗位
,面试官针对Java多线程进行了狂轰乱炸般的考问,什么线程创建的方式、线程的状态、各状态间的切换、如果保证线程安全、各种锁的区别,如何使用等等,因为有好好背八股文,所以七七八八的也答上来了,但最后面试官问了一个现在看来很简单,但当时根本不知道的问题,他先是问了我,看过Thread的源码没,我毫不犹豫的回答看过,紧接着他问:

线程在调用了一次start启动后,再调用一次可以不?如果线程执行完,同样再调用一次start又会怎么样?

这个问题抛给你们,请问该如何作答呢?

线程的启动

我们知道虽然很多八股文面试题中说Java创建线程的方式有3种、4种,或者更多种,但实际上真正可以创建一个线程的只有new Thread().start();

【代码示例1】

public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {});
        System.out.println(thread.getName()+":"+thread.getState());
        thread.start();
        System.out.println(thread.getName()+":"+thread.getState());
    }
}

输出:

Thread-0:NEW
Thread-0:RUNNABLE

创建一个Thread,这时线程处于NEW状态,这时调用start()方法,会让线程进入到RUNNABLE状态。

RUNNABLE的线程调用start

在上面测试代码的基础上,我们再次调用start()方法。

【代码示例2】

public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {});
        System.out.println(thread.getName()+":"+thread.getState());
        //第一次调用start
        thread.start();
        System.out.println(thread.getName()+":"+thread.getState());
        //第二次调用start
        thread.start();
        System.out.println(thread.getName()+":"+thread.getState());
    }
}

输出:

Thread-0:NEW
Thread-0:RUNNABLE
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.start(Thread.java:708)
	at com.javabuild.server.pojo.Test.main(Test.java:17)

第二次调用时,代码抛出IllegalThreadStateException异常。

这是为什么呢?我们跟进start源码中一探究竟!

【源码解析1】

// 使用synchronized关键字保证这个方法是线程安全的
public synchronized void start() {
    // threadStatus != 0 表示这个线程已经被启动过或已经结束了
    // 如果试图再次启动这个线程,就会抛出IllegalThreadStateException异常
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    // 将这个线程添加到当前线程的线程组中
    group.add(this);

    // 声明一个变量,用于记录线程是否启动成功
    boolean started = false;
    try {
        // 使用native方法启动这个线程
        start0();
        // 如果没有抛出异常,那么started被设为true,表示线程启动成功
        started = true;
    } finally {
        // 在finally语句块中,无论try语句块中的代码是否抛出异常,都会执行
        try {
            // 如果线程没有启动成功,就从线程组中移除这个线程
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            // 如果在移除线程的过程中发生了异常,我们选择忽略这个异常
        }
    }
}

这里有个threadStatus,若它不等于0表示线程已经启动或结束,直接抛IllegalThreadStateException异常,我们在start源码中打上断点,从第一次start中跟入进去,发现此时没有报异常。
image

此时的threadStatus=0,线程状态为NEW,断点继续向下走时,走到native方法start0()时,threadStatus=5,线程状态为RUNNABLE。此时,我们从第二个start中进入断点。
image

这时threadStatus=5,满足不等于0条件,抛出IllegalThreadStateException异常!

TERMINATED的线程调用start

终止状态下的线程,情况和RUNNABLE类似!

【代码示例3】

public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {});
        thread.start();
        Thread.sleep(1000);
        System.out.println(thread.getName()+":"+thread.getState());
        thread.start();
        System.out.println(thread.getName()+":"+thread.getState());
    }
}

输出:

Thread-0:TERMINATED
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.start(Thread.java:708)
	at com.javabuild.server.pojo.Test.main(Test.java:17)

这时同样也满足不等于0条件,抛出IllegalThreadStateException异常!

我们其实可以跟入到state的源码中,看一看线程几种状态设定的逻辑。

【源码解析2】

// Thread.getState方法源码:
public State getState() {
    // get current thread state
    return sun.misc.VM.toThreadState(threadStatus);
}

// sun.misc.VM 源码:
// 如果线程的状态值和4做位与操作结果不为0,线程处于RUNNABLE状态。
// 如果线程的状态值和1024做位与操作结果不为0,线程处于BLOCKED状态。
// 如果线程的状态值和16做位与操作结果不为0,线程处于WAITING状态。
// 如果线程的状态值和32做位与操作结果不为0,线程处于TIMED_WAITING状态。
// 如果线程的状态值和2做位与操作结果不为0,线程处于TERMINATED状态。
// 最后,如果线程的状态值和1做位与操作结果为0,线程处于NEW状态,否则线程处于RUNNABLE状态。
public static State toThreadState(int var0) {
    if ((var0 & 4) != 0) {
        return State.RUNNABLE;
    } else if ((var0 & 1024) != 0) {
        return State.BLOCKED;
    } else if ((var0 & 16) != 0) {
        return State.WAITING;
    } else if ((var0 & 32) != 0) {
        return State.TIMED_WAITING;
    } else if ((var0 & 2) != 0) {
        return State.TERMINATED;
    } else {
        return (var0 & 1) == 0 ? State.NEW : State.RUNNABLE;
    }
}

总结

OK,今天就讲这么多啦,其实现在回头看看,这仅是一个简单且微小的细节而已,但对于刚准备步入职场的我来说,却是一个难题,今天写出来,除了和大家分享一下Java线程中的小细节外,更多的是希望正在准备面试的小伙伴们,能够心细,多看源码,多问自己为什么?并去追寻答案,Java开发不可浅尝辄止。

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得
留言+点赞+收藏
呀。原创不易,转载请联系Build哥!

image

如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

image

随着上周知名 Switch 开源模拟器 Yuzu(柚子)被任天堂起诉,该项目作者就删库了,但还是要赔偿任天堂数百万美元。此事还在 GitHub 上掀起了一波 Yuzu fork 项目的小浪潮,正所谓野火烧不尽,春风吹又生。

很多读者都表示事情发生得太突然,没来得及 fork 一下,所以本周的「热点速递」上来就是另一款用 C# 写的 Switch 开源模拟器,要 fork 的抓紧时间啦。此事告一段落,让我们来看看 Rust 和 JavaScript 的结合,会擦出什么样的火花呢?近期 Vue 团队开源了 Rust 写的打包工具:Rolldown,短短几天时间收获了 3000+ Star。另外,运行 Windows 的 Docker 项目最近也在飙升,值得关注一下。

最后,试试本地运行大模型的 Ollama,搭配上微软出的生成式 AI 教程,借着 AIGC 的“春风”,唤醒职业的第二春吧!

  • 本文目录
    • 1. 开源热搜项目
      • 1.1 另一款 Switch 开源模拟器:Ryujinx
      • 1.2 原生级性能的 JavaScript 打包工具:Rolldown
      • 1.3 Docker 容器中的 Windows:windows
      • 1.4 本地运行 LLM 的工具:Ollama
      • 1.5 面向初学者的 GAI 教程:generative-ai-for-beginners
    • 2. HelloGitHub 热评
      • 2.1 Windows 右键扩展工具:Shell
      • 2.2 无忧应对多编程语言不同版本的工具:vfox
    • 3. 往期回顾

1. 开源热搜项目

1.1 另一款 Switch 开源模拟器:Ryujinx

主语言:C#

Star:30k

周增长:3k

该项目是采用 C# 编写的开源 Switch 模拟器,它创建于 2017 年 9 月,目前可流畅运行 3500 多款游戏。配置要求 8G 以上的内存,提供 Windows、macOS 和 Linux 操作系统的可执行文件,开箱即用。

GitHub 地址→
https://github.com/Ryujinx/Ryujinx

1.2 原生级性能的 JavaScript 打包工具:Rolldown

主语言:Rust

Star:4.2k

周增长:3k

这是由 Vue 团队开源的用 Rust 编写的高性能 JavaScript 打包工具,它的灵感来自 Rollup 和 esbuild(打包工具),所以提供了与 Rollup 兼容的 API 和插件接口,功能上和 esbuild 支持的差不多,相当于合二为一。但是该项目还处于开发阶段,生产环境中慎用。

GitHub 地址→
https://github.com/rolldown/rolldown

1.3 Docker 容器中的 Windows:windows

主语言:Shell

Star:6.9k

周增长:2k

通过该项目可以在 Docker 中运行一个干净的 Windows 系统,支持浏览器访问、远程桌面控制 Windows 系统,适用于运行 Windows 脚本和测试等场景。

GitHub 地址→
https://github.com/dockur/windows

1.4 本地运行 LLM 的工具:Ollama

主语言:Go

Star:46k

周增长:1k

这是一个用 Go 语言写的工具,用于在本地一条命令安装、启动和管理大型语言模型:
ollama run 模型名称
,支持 Llama 2、Gemma、Mistral 等大模型,适用于 Windows、macOS、Linux 操作系统。

GitHub 地址→
https://github.com/ollama/ollama

1.5 面向初学者的 GAI 教程:generative-ai-for-beginners

主语言:Jupyter Notebook

Star:28k

这是由微软开源的面向初学者的生成式 AI 免费课程,课程共 18 节,教程涵盖了创建生成式 AI 应用所需要了解的一切,包括生成式 AI 和 LLMs 的简介、提示词、构建文本生成应用、聊天应用、图像生成应用、向量数据库等方面。

GitHub 地址→
https://github.com/microsoft/generative-ai-for-beginners

2. HelloGitHub 热评

在这个章节,将会分享下本周 HelloGitHub 网站上的热门开源项目,欢迎与我们分享你上手这些开源项目后的使用体验。

2.1 Windows 右键扩展工具:Shell

主语言:C++

这项目是一个用于管理 Windows 文件资源管理器上下文菜单的程序。简单来说,就是扩展了 Windows 右键菜单的功能。该工具免费、开源、无广告、轻巧,支持所有文件系统对象,如文件、文件夹、桌面和任务栏。它提供了一系列提升效率的功能,包括拷贝文件地址、快速打开目录、终端打开、自定义外观以及复杂的嵌套菜单等。

项目详情→
https://hellogithub.com/repository/33109915aedc4ad5a8ed7cd15a7ecdd9

2.2 无忧应对多编程语言不同版本的工具:vfox

主语言:Go

这是一款跨平台的通用版本管理工具,通过命令行快速安装、切换编程语言的不同版本,并支持自定义源地址。相比于针对每种语言的独立版本管理工具(如 nvm、fvm、gvm 等),这个项目让开发者摆脱繁琐的学习和记忆过程,只需一个工具、一条命令,轻松搞定多编程语言版本管理。

项目详情→
https://hellogithub.com/repository/a32a1f2ad04a4b8aa4dd3e1b76c880b2

3. 往期回顾

往期回顾:

以上为本周的「GitHub 热点速递」如果你发现其他好玩、实用的 GitHub 项目,就来
HelloGitHub
和大家一起分享下吧。