2025-08-17 22:54:34
摘要:.Net生态有一个大名鼎鼎中介者模式实现库MediatR,今天介绍一个优雅的装饰器模式实现库DecoratR。
作为软件工程师,我们不断面临在应用程序中实现横切关注点的挑战,例如日志记录、缓存、验证、重试逻辑和安全性。传统方法通常会导致:
重复的样板代码散布在你的服务中。
业务逻辑与基础设施关注点之间紧密耦合。
由于职责混合而导致测试困难。
当需求变更时,可维护性差。
考虑这个典型的服务方法:
public async TaskOrder GetOrderAsync(int orderId)
{
_logger.LogInformation(Getting order {OrderId}, orderId); // 正在获取订单 {OrderId}
// 先检查缓存
var cacheKey = $order_{orderId};
if (_cache.TryGetValue(cacheKey, out Order cachedOrder))
{
_logger.LogInformation(Order {OrderId} found in cache, orderId); // 订单 {OrderId} 在缓存中找到
return cachedOrder;
}
try
{
// 验证输入
if (orderId = 0)
thrownew ArgumentException(Invalid order ID); // 无效的订单 ID
// 业务逻辑埋没在基础设施代码中
var order = await _repository.GetOrderAsync(orderId);
// 缓存结果
_cache.Set(cacheKey, order, TimeSpan.FromMinutes(5));
_logger.LogInformation(Order {OrderId} retrieved successfully, orderId); // 订单 {OrderId} 成功获取
return order;……
阅读全文
2020-12-09 11:08:58
摘要:Request-scoped context
在 Go 服务中,每个传入的请求都在其自己的goroutine 中处理。请求处理程序通常启动额外的 goroutine 来访问其他后端,如数据库和 RPC服务。处理请求的 goroutine 通常需要访问特定于请求(request-specific context)的值,例如最终用户的身份、授权令牌和请求的截止日期(deadline)。当一个请求被取消或超时时,处理该请求的所有 goroutine 都应该快速退出(fail fast),这样系统就可以回收它们正在使用的任何资源。
Go 1.7 引入一个 context 包,它使得跨 API 边界的请求范围元数据、取消信号和截止日期很容易传递给处理请求所涉及的所有 goroutine(显示传递)。
核心接口:
如何将 context 集成到 API 中
在将 context 集成到 API 中时,要记住的最重要的一点是,它的作用域是请求级别 的。例如,沿单个数据库查询存在是有意义的,但沿数据库对象存在则没有意义。
目前有两种方法可以将 context 对象集成到 API 中:
The first parameter of a function call:首参数传递 context 对象,比如,参考 net 包 Dialer.DialContext。此函数执行正常的 Dial 操作,但可以通过 context 对象取消函数调用。
Optional config on a request structure:在第一个 request 对象中携带一个可选的 context 对象。例如 net/http 库的 Request.WithContext,通过携带给定的 context 对象,返回一个新的 Request 对象。
Do not store Contexts inside a struct type
使用 context 的一个很好的心智模型是它应该在程序中流动,应该贯穿你的代码。这通常意味着您不希望将其存储在结构体之中。它从一个函数传递到另一个函数,并根据需要进行扩展。理想情况下,每个请求都会创建一个 context 对象,并在请求结束时过期。
不存储上下文的一个例外是,当您需要将它放入一个结构中时,该结构纯粹用作通过通道传递的消息。如下例所示。
type ……
阅读全文
2020-12-05 16:43:06
摘要:channels 是一种类型安全的消息队列,充当两个 goroutine 之间的管道,将通过它同步的进行任意资源的交换。chan 控制 goroutines 交互的能力从而创建了 Go 同步机制。当创建的 chan 没有容量时,称为无缓冲通道。反过来,使用容量创建的 chan 称为缓冲通道。
要了解通过 chan 交互的 goroutine 的同步行为是什么,我们需要知道通道的类型和状态。根据我们使用的是无缓冲通道还是缓冲通道,场景会有所不同,所以让我们单独讨论每个场景。
Unbuffered Channels
ch := make(chan struct)
无缓冲 chan 没有容量,因此进行任何交换前需要两个 goroutine 同时准备好。当 goroutine 试图将一个资源发送到一个无缓冲的通道并且没有goroutine 等待接收该资源时,该通道将锁住发送 goroutine 并使其等待。当 goroutine 尝试从无缓冲通道接收,并且没有 goroutine 等待发送资源时,该通道将锁住接收 goroutine 并使其等待。
无缓冲信道的本质是保证同步。
func main() {
c:= make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c - `foo`
}
go func() {
defer wg.Done()
time.Sleep(time.Second * 1)
printIn(`Message` + -c)
}()
wg.Wait()
}
第一个 goroutine 在发送消息 foo 之后被阻塞,因为还没有接收者准备好。规范中对这种行为进行了很好的解释:
Receive 先于 Send 发生。
好处: 100% 保证能收到。
代价: 延迟时间未知。
Buffered Channels
buffered channel 具有容量,因此其行为可能有点不同。当 goroutine 试图将资源发送到缓冲通道,而该通道已满时,该通道将锁住 goroutine并使其等待缓冲区可用。如果通道中有空间,发送可以立即进行,goroutine 可以继续。当goroutine 试图从缓冲通道接收数据,……
阅读全文
2020-12-04 17:47:56
摘要:Share Memory By Communicating
传统的线程模型(通常在编写 Java、C++ 和Python 程序时使用)程序员在线程之间通信需要使用共享内存。通常,共享数据结构由锁保护,线程将争用这些锁来访问数据。在某些情况下,通过使用线程安全的数据结构(如Python的Queue),这会变得更容易。
Go 的并发原语 goroutines 和 channels 为构造并发软件提供了一种优雅而独特的方法。Go 没有显式地使用锁来协调对共享数据的访问,而是鼓励使用 chan 在 goroutine 之间传递对数据的引用。这种方法确保在给定的时间只有一个goroutine 可以访问数据。
Do not communicate by sharing memory; instead, share memory by communicating.
Detecting Race Conditions With Go
data race 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码可以创建您见过的最疯狂和最随机的 bug。通常需要大量的日志记录和运气才能找到这些类型的bug。
早在6月份的Go 1.1中,Go 工具引入了一个 race detector。竞争检测器是在构建过程中内置到程序中的代码。然后,一旦你的程序运行,它就能够检测并报告它发现的任何竞争条件。它非常酷,并且在识别罪魁祸首的代码方面做了令人难以置信的工作。
var Wait sync.WaitGroup
var Counter int = 0
func main() {
for routine := 1; routine = 2; routine++ {
Wait.Add(1)
go Rountine(rountine)
}
Wait.Wait()
fmt.Print(Final Counter: %d\n, Counter)
}
func Rountine(id int) {
for count := 0; count 2; count++ {
value := Counter
value++
Counter = value
}
Wait.Done()
}……
阅读全文
2020-12-03 21:15:36
摘要:需要阅读https://golang.org/ref/mem
如何保证在一个 goroutine 中看到在另一个 goroutine 修改的变量的值,如果程序中修改数据时有其他 goroutine 同时读取,那么必须将读取串行化。为了串行化访问,请使用 channel 或其他同步原语,例如 sync 和 sync/atomic 来保护数据。
在一个 goroutine 中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个 goroutine 的行为时才可能修改读和写的执行顺序。由于重排,不同的goroutine 可能会看到不同的执行顺序。例如,一个goroutine 执行 a = 1;b = 2;,另一个 goroutine 可能看到 b 在 a 之前更新。
Memory Reordering
用户写下的代码,先要编译成汇编代码,也就是各种指令,包括读写内存的指令。CPU 的设计者们,为了榨干 CPU 的性能,无所不用其极,各种手段都用上了,你可能听过不少,像流水线、分支预测等等。其中,为了提高读写内存的效率,会对读写指令进行重新排列,这就是所谓的 内存重排,英文为 MemoryReordering。
这一部分说的是 CPU 重排,其实还有编译器重排。比如:
但是,如果这时有另外一个线程同时干了这么一件事:x=0
在多核心场景下,没有办法轻易地判断两段程序是“等价”的。
现代 CPU 为了“抚平” 内核、内存、硬盘之间的速度差异,搞出了各种策略,例如三级缓存等。为了让 (2) 不必等待 (1) 的执行“效果”可见之后才能执行,我们可以把 (1) 的效果保存到 store buffer:
先执行 (1) 和 (3),将他们直接写入 store buffer,接着执行 (2) 和 (4)。“奇迹”要发生了:(2) 看了下 store buffer,并没有发现有 B 的值,于是从 Memory 读出了 0,(4) 同样从 Memory 读出了 0。最后,打印出了 00。
因此,对于多线程的程序,所有的 CPU 都会提供“锁”支持,称之为 barrier,或者 fence。它要求:barrier 指令要求所有对内存的操作都必须要“扩散”到 memory 之后才能继续执行其他对 memory 的操作。因此,我们可以用高级点的 atomic com……
阅读全文
2020-11-30 20:05:56
摘要:Processes and Threads
操作系统会为该应用程序创建一个进程。作为一个应用程序,它像一个为所有资源而运行的容器。这些资源包括内存地址空间、文件句柄、设备和线程。
线程是操作系统调度的一种执行路径,用于在处理器执行我们在函数中编写的代码。一个进程从一个线程开始,即主线程,当该线程终止时,进程终止。这是因为主线程是应用程序的原点。然后,主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。
无论线程属于哪个进程,操作系统都会安排线程在可用处理器上运行。每个操作系统都有自己的算法来做出这些决定。
Goroutines and Parallelism
Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutines 在绑定到单个操作系统线程的逻辑处理器中运行(P)。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。
并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutines 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。
Keep yourself busy or do the work yourself
比如我们想监听一个端口,但并不知道它什么时候返回,我们可能会使用一个select来永远阻塞,如下代码:
func main() {
http.HandleFunc(/, func(w http.ResponseWriter, r *http.Request)) {
fmt.FprintIn(w, Hello, GrpherCon SG)
}
go func() {
if(err := http.listenAndServe(:8080, nil); err != nil) {
log.Fatal(err)
}
}()
sele……
阅读全文
2020-11-28 20:24:53
摘要:Error
error类型是go语言的一种内置类型,使用的时候不用特定去import,他本质上是一个接口
//http://golang.org/pkg/builtin/#error
type error interface{
Error() string //Error()是每一个订制的error对象需要填充的错误消息,可以理解成是一个字段Error
}
我们经常使用 errors.New() 来返回一个 error 对象。
//http://golang.org/src/pkg/errors/errors.go
type errorString struct {
s String
}
func (e *errorString) Error() string {
return e.s
}
基础库中有大量自定义的error,如bufio
//http://golang.org/src/pkg/bufio/bufio.go
var {
ErrInvalidUnreadByte = errors.New(bufio: invalid use of UnreadByte)
ErrInvalidUnreadRune = errors.New(bufio: invalid use of UnreadRune)
ErrBufferFull = errors.New(bufio: Buffer full)
ErrNegativeCount = errors.New(bufio: negative count)
}
errors.New()返回的是内部errorString对象的指针
//http://golang.org/src/pkg/errors/errors.go
//New returns an error that formats as the given text
func New(text string) error {
return errorString{text}
}
Error vs Exception
各个语言的演进历史:
C:单返回值,一般通过传递指针作为入参,返回值为 int 表示成功还是失败。
C++:引入了 exception,但是无法知道被调用方会抛出什么异常
Java:引入了 checked exception……
阅读全文
2016-01-06 21:45:35
摘要:前面介绍了 DDD 分层架构,同时也提到了微服务架构模型其实还有好多种,不知道你注意到了没?这些架构模型在我们的实际应用中都具有很高的借鉴价值。
那么今天我们就把 DDD 分层架构、整洁架构、六边形架构这三种架构模型放到一起,对比分析,看看如何利用好它们,帮助我们设计出高内聚低耦合的中台以及微服务架构。
整洁架构
整洁架构又名“洋葱架构”。为什么叫它洋葱架构?看看下面这张图你就明白了。整洁架构的层就像洋葱片一样,它体现了分层的设计思想。
在整洁架构里,同心圆代表应用软件的不同部分,从里到外依次是领域模型、领域服务、应用服务和最外围的容易变化的内容,比如用户界面和基础设施。
整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,越往里依赖越低,代码级别越高,越是核心能力。外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。
在洋葱架构中,各层的职能是这样划分的:
领域模型实现领域内核心业务逻辑,它封装了企业级的业务规则。领域模型的主体是实体,一个实体可以是一个带方法的对象,也可以是一个数据结构和方法集合。
领域服务实现涉及多个实体的复杂业务逻辑。
应用服务实现与用户操作相关的服务组合与编排,它包含了应用特有的业务流程规则,封装和实现了系统所有用例。
最外层主要提供适配的能力,适配能力分为主动适配和被动适配。主动适配主要实现外部用户、网页、批处理和自动化测试等对内层业务逻辑访问适配。被动适配主要是实现核心业务逻辑对基础资源访问的适配,比如数据库、缓存、文件系统和消息中间件等。
红圈内的领域模型、领域服务和应用服务一起组成软件核心业务能力。
六边形架构
六边形架构又名“端口适配器架构”。追溯微服务架构的渊源,一般都会涉及到六边形架构。
六边形架构的核心理念是:应用是通过端口与外部进行交互的。我想这也是微服务架构下API 网关盛行的主要原因吧。
也就是说,在下图的六边形架构中,红圈内的核心业务逻辑(应用程序和领域模型)与外部资源(包括 APP、Web应用以及数据库资源等)完全隔离,仅通过适配器进行交互。它解决了业务逻辑与用户界面的代码交错问题,很好地实现了前后端分离。六边形架构各层的依赖关系与整洁架构一样,都是由外向内依赖。
六边形架构将系统分为内六边形和外六边形两层,这两层的职能划分如下:
红圈内的六边形实现应用的核心业务逻辑;
外六边形完成外部应用、……
阅读全文
2015-12-30 11:51:54
摘要:前面我们讲了 DDD 的一些重要概念以及领域模型的设计理念。
今天我们来聊聊“DDD 分层架构”。微服务架构模型有好多种,例如整洁架构、CQRS 和六边形架构等等。每种架构模式虽然提出的时代和背景不同,但其核心理念都是为了设计出“高内聚低耦合”的架构,轻松实现架构演进。而 DDD 分层架构的出现,使架构边界变得越来越清晰,它在微服务架构模型中,占有非常重要的位置。
那 DDD 分层架构到底长什么样?DDD 分层架构如何推动架构演进?我们该怎么转向 DDD 分层架构?这就是我们这一讲重点要解决的问题。
什么是 DDD 分层架构?
DDD 的分层架构在不断发展。最早是传统的四层架构;后来四层架构有了进一步的优化,实现了各层对基础层的解耦;再后来领域层和应用层之间增加了上下文环境(Context)层,五层架构(DCI)就此形成了。
我们看一下上面这张图,在最早的传统四层架构中,基础层是被其它层依赖的,它位于最核心的位置,那按照分层架构的思想,它应该就是核心,但实际上领域层才是软件的核心,所以这种依赖是有问题的。后来我们采用了依赖倒置(Dependencyinversion principle,DIP)的设计,优化了传统的四层架构,实现了各层对基础层的解耦。
我们今天讲的 DDD分层架构就是优化后的四层架构。在下面这张图中,从上到下依次是:用户接口层、应用层、领域层和基础层。那 DDD各层的主要职责是什么呢?下面我来逐一介绍一下。
1.用户接口层
用户接口层负责向用户显示信息和解释用户指令。这里的用户可能是:用户、程序、自动化测试和批处理脚本等等。
2.应用层
应用层是很薄的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。但应用层又位于领域层之上,因为领域层包含多个聚合,所以它可以协调多个聚合的服务和领域对象完成服务编排和组合,协作完成业务操作。
此外,应用层也是微服务之间交互的通道,它可以调用其它微服务的应用服务,完成微服务之间的服务组合和编排。
这里我要提醒你一下:在设计和开发时,不要将本该放在领域层的业务逻辑放到应用层中实现。因为庞大的应用层会使领域模型失焦,时间一长你的微服务就会演化为传统的三层架构,业务逻辑会变得混乱。
另外,应用服务是在应用层的,它负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装,以粗粒度的服务通过A……
阅读全文
2015-12-18 21:02:38
摘要:在事件风暴(EventStorming)时,我们发现除了命令和操作等业务行为以外,还有一种非常重要的事件,这种事件发生后通常会导致进一步的业务操作,在 DDD中这种事件被称为领域事件。这只是最简单的定义,并不能让我们真正理解它。那到底什么是领域事件?领域事件的技术实现机制是怎样的?今天,我们就重点解决这两个大的问题。
领域事件
领域事件是领域模型中非常重要的一部分,用来表示领域中发生的事件。一个领域事件将导致进一步的业务操作,在实现业务解耦的同时,还有助于形成完整的业务闭环。
举例来说的话,领域事件可以是业务流程的一个步骤,比如投保业务缴费完成后,触发投保单转保单的动作;也可能是定时批处理过程中发生的事件,比如批处理生成季缴保费通知单,触发发送缴费邮件通知操作;或者一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。
那如何识别领域事件呢?
很简单,和刚才讲的定义是强关联的。在做用户旅程或者场景分析时,我们要捕捉业务、需求人员或领域专家口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。
那领域事件为什么要用最终一致性,而不是传统SOA 的直接调用的方式呢?
我们一起回顾一下之前讲到的聚合的一个设计原则:在边界之外使用最终一致性。一次事务最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的最终一致性。
领域事件驱动设计可以切断领域模型之间的强依赖关系,事件发布完成后,发布方不必关心后续订阅方事件处理是否成功,这样可以实现领域模型的解耦,维护领域模型的独立性和数据的一致性。在领域模型映射到微服务系统架构时,领域事件可以解耦微服务,微服务之间的数据不必要求强一致性,而是基于事件的最终一致性。
回到具体的业务场景,我们发现有的领域事件发生在微服务内的聚合之间,有的则发生在微服务之间,还有两者皆有的场景,一般来说跨微服务的领域事件处理居多。在微服务设计时不同领域事件的处理方式会不一样。
1.微服务内的领域事件
当领域事件发生在微服务内的聚合之间,领域事件发生后完成事件实体构建和事件数据持久化,发布方聚合将事件发布到事件总线,订阅方接收事件数据完成后续业务操作。
微服务内大部分事件的集成,都发……
阅读全文