领域驱动设计DDD详解与战术建模落地

news2024/9/27 5:49:39

一、什么是DDD?

1.1、DDD的概念

Domain-Driven Design(领域驱动设计)它由Eric Evans在他的2003年出版的书籍《Domain-Driven Design: Tackling Complexity in the Heart of Software》中首次提出。DDD 核心思想是通过领域驱动设计方法定义领域模型,从而确定业务和应用边界,保证业务模型与代码模型的一致性 。领域驱动设计,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。
因此DDD不是某一种设计模式、架构模式,而是一种软件开发方法论。 它从具体的较复杂和繁琐的业务问题出发,通过定义一致的领域语言,使用具体的设计工具,最终将问题转化为领域模型,从而给产品设计和技术设计提供重要支撑。

1.2、没有银弹

DDD不是万能的银弹,也存在着适用性和局限性
适用性:

  • 有一定的“领域”特征,并且在该领域内,核心域部分是存在的或者比较容易达成广泛一致认知的。
  • 具备一定的复杂性。
  • 业务是长期迭代的(非临时性的业务)。

局限性:

  • 临时的、一次性的或很简单的业务不适合,因为DDD的方法是有一定成本的。
  • 业务模式不成熟、变化很快时不适合DDD。
  • DDD并不擅长解决技术本身的难题,如性能、稳定性、大数据量等,遇到技术难题时还应结合其他的设计方式综合考虑,必要的时候要为技术难点做设计上的妥协。
  • 因此类似于很多成熟的业务模式的系统如:电商系统、CRM系统、采购系统等都是业务形态已经存在或很容易达成一致的场景,适合用DDD来梳理。
  • 反之如果想要搞一个临时的线上抽奖活动,就不适合了。也比如很多toC的创新业务在开始阶段,本身模式就不清晰,用DDD也是无法开展。

二、基本概念

2.1、领域对象-实体(entity)

传统的系统架构设计阶段,通常我们会将关注点放在数据上面,而不是领域上面。 这种设计风格在软件开发中,使数据库占据了主导地位,我们总是有限考虑数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。这样做的结果是直接将数据模型反映在对象模型上,导致这些表示领域模型的实体中含有大量的getter、setter方法,也就是贫血领域模型,这不符合DDD的做法。
与传统数据模型设计优先不同,DDD 是先构建领域模型,针对实际业务场景构建实体对象和行为,再将实体对象映射到数据持久化对象。传统数据模型不具备行为能力,而DDD的领域模型实体含有丰富的行为能力。

因此在DDD中理解领域对象的概念则变得尤为重要。
领域对象:指系统中的具有强业务属性的事物 ,例如业务中常出现的采购订单、员工、招聘候选人这类名词,需要我们在DDD建模过程中识别出来,并建立起对这些领域对象的属性定义、行为定义,同时它包括了实体、值对象。
在这里插入图片描述

实体: 在Eric中的书里描述Entity时,选用了指纹的图。而这其实就能完美说明了Entity的特点:唯一标识性

  • 两个实体即使其他的属性或行为完全一样,只要能代表唯一标识的ID不一样就不是一个实体。
  • 例如: 在一份采购订单中,即使其中的供应商相同、采购的品类,上传的附件、创建的时间等都相同。但是其采购订单的PO单号不同,那么它就不是同一个实体。
  • 实体是具有生命周期的,实体创建完后会被系统持久化,然后在某个时间,重新在系统中找到它,执行完某些业务属性变更后再次持久化。

生命周期最好能用有限状态机表示,如下是对考试、答卷实体的状态分析例子:
在这里插入图片描述
常见的实体: 订单、物流单、商品、用户。


2.2、领域对象-值对象(value object)

很多对象没有概念上的标识,它们描述了一个事务的某种特征,则这个对象则为值对象。因此两个值对象只要属性一样,那么就认为这两个值对象是指一样的东西。

  • 值对象和实体最本质的区别是值对象没有标识性的ID。
  • 常见的值对象如:订单的创建时间、工区的具体地址、商品的描述信息等。

区分出值对象的目的:

  • 避免细碎的实体属性,让实体模型更加清晰,属性更具有语义。
  • 减少实体的数量,引导团队思考某个对象建模为实体是否合适。
  • 为性能优化提供了更多的选择,因为值对象往往为数众多。eg: 如果每个电源插座都是一个实体,那么一做房屋设计中就可能含有上百个的不同的电源插座实体,但是假如设计成可互换的值对象,那么其实就只需要共享/复制同一个电源插座实例。在大型系统中这个效果可能会被放大数千倍。

2.2、聚合(Aggregate)

在 DDD 中,领域对象有实体和值对象。实体一般对应业务对象,它具有业务属性和业务行为;而值对象主要是属性集合,对实体的状态和特征进行描述。但实体和值对象都只是个体化的对象,它们的行为表现出来的是个体的能力。而能让这些个体协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。

聚合有一个聚合根(root)和上下文边界(boundary),这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,根则是聚合所包含的一个特定Entity。

  • 聚合根
    如果把聚合比作组织,那聚合根就是这个组织的负责人。 聚合根也称为根实体,它不仅是实体,还是聚合的管理者。 首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。最后在聚合之间,它还是聚合对外的接口人,以聚合根ID关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根ID关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。

如何在上下文边界内在众多对象中确定一个root?
假如现在有一个在采购系统中订单有一个验收的场景。验收单的组成由验收单头实体、以及n个验收单明细实体组成。它们之间的关系是1:n。
在这里插入图片描述

那么此时验收单头则是聚合根,验收的明细则是被聚合的实体。因为验收单头能唯一标识这个聚合根(含有验收单号)。这个验收单明细少了验收单头信息则变得没有意义(无法标识是哪个验收单)。

如何设计出好的聚合:

  • 设计小聚合: 如果聚合设计得过大,聚合会因为包含过多的实体,导致实体之间的管理过于复杂,高频操作时会出现并发冲突或者数据库锁,最终导致系统可用性变差。而小聚合设计则可以降低由于业务过大导致聚合重构的可能性 ,让领域模型更能适应业务的变化。
  • 边界内的内容具有强一致性,边界之外最终一致: 在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用**最终一致的(聚合间的一致性)**方式。这也是聚合能实现业务高内聚的原因
  • 通过唯一标识引用其他聚合 :聚合之间是通过关联外部聚合根ID的方式引用,而不是直接对象引用的方式。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度

2.3、工厂(factory)

创建一个对象或创建整个聚合时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用工厂进行封装创建。隐藏其创建的细节。因为假如让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或AGGREGATE的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合在这里插入图片描述

如何设计出好的工厂:

  • 工厂的创建方法应该都是原子的:如果FACTORY通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者采用其他机制,以确保不会返回错误的值。
  • FACTORY最好被抽象为所需的类型,而不是所要创建的具体类。

2.4、仓库(repository)

factory用来创建领域对象,而repository就是在生命周期的中间和末尾使用,来提供查找和持久化、检索对象并封装庞大基础设施的手段。

  • REPO vs DAO/DAL
  • 在使用repo的结构前我们一般取而代替的是直接使用DAO,DAL。DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型。
  • 但是在领域驱动设计中,entity、value开始联结成聚合。管理聚合的生命周期,往往才是在DDD中经常会做的事。而Repository在DDD中作为领域模型和数据存储之间的桥梁它封装了对聚合根的检索和持久化逻辑,确保数据的一致性和完整性。 Repository通常与聚合根紧密相关,支持复杂的查询和持久化操作,这些操作与业务逻辑相关。

所以Eric提到在这个过程中,要注重的的是领域驱动设计的目标是通过关注领域模型(而不是技术)创建更好的软件。这与Robert在《整洁架构》里面的观点一致,领域模型是核心,数据模型是技术细节。假如研发构造一连串的sql,然后传给infra基础设施层的某个dal查询。再根据这些信息传给构造函数或者factory。这个过程很容易把我们的对象当做放置数据的容器(data/db model),整个设计就会回到数据处理风格。 处理的是技术,而不是模型。 因此引入Repository是尤为重要的,因为Repository的设计和实现与业务逻辑紧密相关,在repo处理domain model关注于模型本身,而在infra层的dal/dao层上处理data/model本身。
在这里插入图片描述

2.5、领域事件(domain event)

最早在eric书中其实未给出领域事件的正式定义,而是在该书出版之后提出来的。

在这里插入图片描述
因此当在做用户旅程或者场景分析时,我们要捕捉业务、需求人员或领域专家口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。即当X发生时执行Y

因此领域事件基本位于多个聚合间,领域事件实现的是最终一致性。它可以切断领域模型之间的强依赖关系,事件发布完成后,发布方不必关心后续订阅方事件处理是否成功,这样可以实现领域模型的解耦,维护领域模型的独立性和数据的一致性。

  • 在实现上可以有一个整体的接口,这一部分主要是为了未来实现扩展可以用消息总线event bus实现通知到别的聚合的广播。
type DomainEvent interface {
	EventId() string
	OccurredOn() int64
}


其中Id则为表示这个事件的唯一Id以及OccurredOn()这个事件所发生的事件。
简易的eventbus实现:

type EventBus struct {
	listeners map[string][]chan DomainEvent
}

func NewEventBus() *EventBus {
	return &EventBus{
		listeners: make(map[string][]chan DomainEvent),
	}
}

func (eb *EventBus) Subscribe(eventKey string, listener chan DomainEvent) {
	eb.listeners[eventKey] = append(eb.listeners[eventKey], listener)
}

func (eb *EventBus) Publish(eventKey string, event DomainEvent) {
	for _, listener := range eb.listeners[eventKey] {
		listener <- event
	}
}

接着可以按照定义的描述在domain层中定义具体的对应聚合的事件:

type acceptanceEvent struct {
	eventId        int
	occurrenceTime int64
	msg            string
}

func (a *acceptanceEvent) EventId() int {
	return a.eventId
}

func (a *acceptanceEvent) OccurredOn() int64 {
	return a.occurrenceTime
}

在repo层进行具体的发布:

func (a AcceptanceRepoImpl) Persist(agg *AcceptanceAgg) error {
	err := conn.TX(&a.ctx, func(tx *gorm.DB) error {
		// ... 持久化逻辑
		return nil
	})
	if err != nil {
		return err
	}

	// 发布验收单持久化事件
	a.publishPersistEvent(&acceptanceEvent{
		eventId:        strconv.FormatInt(agg.Acceptance.ID, 10) + "_" + strconv.FormatInt(time.Now().Unix(), 10),
		occurrenceTime: time.Now().Unix(),
		msg:            "验收单持久化",
	},
	)
	return nil
}

2.6、战略设计与战术设计

  • 战术设计:

    • 战术设计犹如使用一把精小的画笔在领域模型上描绘着每个细枝末节。用聚合将领域模型中的值对象、对象,在同一个领域边界内组织关联起来。
    • 而在这一过程中则会从技术视角出发,侧重于领域模型的技术实现完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和仓库、工厂、领域模型的充血行为等代码逻辑的设计和实现。
  • 战略设计:

    • 战略设计强调的是业务战略上的重点,如何按重要性分配工作,以及如何进行最佳整合。战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,限界上下文可以作为微服务设计的参考边界。
    • 战略设计会建立领域模型,领域模型可以用于指导微服务的设计和拆分。事件风暴是建立领域模型的主要方法,它是一个从发散到收敛的过程。它通常采用用例分析、场景分析和用户旅程分析,尽可能全面不遗漏地分解业务领域,并梳理领域对象之间的关系,这是一个发散的过程。 事件风暴过程会产生很多的实体、命令、事件等领域对象, 我们将这些领域对象从不同的维度进行聚类,形成如聚合、限界上下文等边界,建立领域模型,这就是一个收敛的过程, 而这个过程在eric中则被认为是产生统一通用语言的过程。
      在这里插入图片描述
  • 因此可以看出战略设计的难度是比战术的设计的难度更大一些的,因为它需要各方配合(产品、研发、业务专家等)不断地进行输出统一语言,划定领域的边界。而这在很多产品未达到一定规模是无法做到的。因为无法判断什么是这个产品最核心收益的部分(核心域),又或者哪些是通用子域等。

  • 但是战术设计虽然难度小一点,但是它在ddd也是重要的组成部分。没有战术设计的领域内基础,领域间的战略设计最多只能是纸上谈兵。而平常研发在ddd中发挥作用最大的则也是战术设计。因为它包括了聚合根、实体、值对象、领域服务、应用服务和仓库、工厂、领域模型的充血行为等代码逻辑的设计和实现。

三、分层设计

如今见到的大多的领域设计分层中大多是eric在书中提到的四层:

3.1、DDD四层架构

在这里插入图片描述

  • 用户界面层(或表示层): 负责向用户显示信息和解释用户指令。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人。
  • 应用层: 定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其他系统的应用层进行交互的必要渠道应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调
    任务,分配工作,使它们互相协作
    。它没有反映业务情况的状态,但是却可以具有另外一种状态,为用户或程序显示某个任务的进度。
  • 领域层(或模型层): 负责表达业务概念,业务状态信息以及业务规则。 尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心。
  • 基础设施层: 为上面各层提供通用的技术能力:为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件,等等。基础设施层还能够通过架构框架来支持4个层次间的交互模式。
  • 因此可以看出DDD分层与普通MVC分层的核心差别:隔离业务逻辑至领域层。 将业务逻辑隔离之后,我们可以专注于业务逻辑的开发和变动。 由于领域层不依赖其它层,所以改动其它层(如更换调用方式、存储加密)不会,也不应该影响到业务逻辑。
  • 并且每层只能与位于其下方的层发生耦合 ,否则会产生循环依赖
  • 层级之间职责更加分明,数据的调用流动更加单向。数据的混合调用往往是屎山代码的开端

分层之外的区别:

  • 实体拥有业务行为,贫血模型变成充血模型
  • 抽象数据模型,定义出每一个行为的聚合根和富集的Repo

带来的收益

  • 提升可读性: 充血模型的实体类由于包含业务逻辑,符合人类的思维模式,因此易于阅读与理解;将业务操作与数据库操作解耦,易测试
  • 提高一致性: 建立了一个比对象粒度更大的边界,聚集那些紧密关联的对象,形成了一个业务上的对象整体

3.2、应用服务 && 流程服务

DDD将原有的Service拆成了Application Service与Domain Service。从分层的概念上来看应用层主要做的是领域对象的协调任务,使它们互相协作,负责的是流程调度。而Domain层主要做的是表达业务概念,业务状态信息以及业务规则,负责业务规则处理。因此Application Service一般是领域间的调度流程集合,而Domain Service是业务规则逻辑处理的集合,Application Service是流程引擎,Domain Service是规则引擎。

假如这样还是觉得抽象可以这样思考:

  • 可以先按原有的思路先不区分Application、Domain Service。按照原有的MVC的思路合在一起,然后先抽离出这个service,在同一个领域内需要进行内存计算的逻辑。而这个就是就是Domain service的业务规则,而剩下的就为Application Service,负责调度domain service的方法进行流程的编排。
  • 假如原有的业务逻辑涉及到跨领域间的聚合,那么这相关的逻辑一定是写在Application Service层的,Domain Service的逻辑一定是只涉及在同一领域内的聚合。如:在这里插入图片描述

四、战术实践

4.1、系统分层

基于提到的目前系统分层架构为:在这里插入图片描述

4.2、代码分层

. 
├── cmd
├── conf
├── internal                                        // 整体业务逻辑入口
│   ├── userinterface                               // 用户接口层
│   │   ├── facade
│   │   │   └── lark_event_facade.go
│   │   └── handler
│   ├── application                                 // 应用层
│   │   ├── convert                                 // 转化层
│   │   │   ├── acceptance.go
│   │   │   └── attachment.go
│   │   └── service                                 // 应用服务
│   │       ├── acceptance_service.go
│   │       ├── acceptance_service_test.go
│   │       ├── lark_event_service.go
│   │       └── lark_event_service_test.go
│   ├── constants                                   // 常量
│   ├── domain                                      // 领域层
│   │   ├── acceptance                              // 具体的领域服务
│   │   │   ├── acceptance_service.go
│   │   │   ├── aggregate.go
│   │   │   ├── factory.go
│   │   │   └── repo.go
│   ├── infra                                       // 基础设施层
│   │   ├── conn                                    // 连接信息(redis、mysql...)
│   │   ├── dal                                     // dal/dao
│   │   ├── fsm                                     // 状态机
│   │   ├── gateway                                 // 网关(对外防腐层)
│   │   │   ├── lark_client
│   │   │   └── mdata_client
│   │   ├── model                                   // model集合
│   │   │   ├── bizmodel                            
│   │   │   └── dbmodel
│   │   └── mq                                      // 封装消息队列分发和消费
│   │       ├── consumer
│   │       ├── msg.go
│   │       └── productor
│   └── utils
├── handler.go                                      // rpc入口
├── idl.sh                                          // idl
├──  main.go
├── build.sh
└── README.md
  • domain层应该怎么划分?
    按照领域进行划分,领域内再按职责划分(聚合根、工厂、数据仓库、服务等…)。
  • convert 层的作用?
    用于进行外部数据与内部数据的转化兼容。如:
func AssembleAcceptance(ctx context.Context, req *procurement.ProcurementOrderAcceptanceReq) (acceptance *dbmodel.ProcurementOrderAcceptance,
	detailList []*dbmodel.ProcurementOrderAcceptanceDetail, err error) {
	// ...
	acceptance = &dbmodel.ProcurementOrderAcceptance{}
	if err = copier.Copy(acceptance, req); err != nil {
		logs.CtxError(ctx, "[CreateProcurementAcceptanceOrder] copy err:%v", err)
		return nil, nil, err
	}
	// ...
	return acceptance, detailList, nil
}

// ConvertToOrderAcceptance 转换为前端需要的数据
func ConvertToOrderAcceptance(ctx context.Context, acceptance *dbmodel.ProcurementOrderAcceptance) (order *procurement.ProcurementOrderAcceptance, err error) {
	// ...
	order = &procurement.ProcurementOrderAcceptance{}
	if err = copier.Copy(order, &acceptance); err != nil {
		logs.CtxError(ctx, "[getProcurementOrderAcceptanceById] copy err:%v", err)
		return nil, utils.NewDataErr("转换验收单详情失败", err)
	}
	order.Id = acceptance.ID
	// ...
	return
}

这里用Assemble前缀标识代表数据是像内组装,向下传递。用ConvertTo前缀标识代表数据是向外转换,向上传递

4.3、从聚合出发

还是按照前面的例子:在采购系统中采购验收单由两部分组成:一个验收单主信息(验收单明细)与多个验收单明细。而验收明细又与一个订单明细一一对应。然后在创建一个验收单时,除了创建验收单头、验收单明细、还会联动的去计算更改订单明细的状态与订单的状态。
在这里插入图片描述
所以在这个过程中可以找出聚合内的实体对象应该有:验收头、验收明细、订单明细、订单头。那么如何去找聚合根呢?

  • 判断这个事件的主体是谁,客体是谁。谁是影响的主导来源,谁是被影响者

很明显在图中箭头来看,只有验收头是影响的来源,能通过引用链延伸到其他对象。 因此可以写出如下的聚合代码:

type AcceptanceAgg struct {
	// agg root
	Acceptance           *dbmodel.ProcurementOrderAcceptance
	AcceptanceDetailList []*dbmodel.ProcurementOrderAcceptanceDetail
	AttachmentList       []*dbmodel.ProcurementAttachment
	// 验收单所对应的订单明细、订单
	OrderDetailList []*dbmodel.ProcurementOrderDetail
	OrderList       []*dbmodel.ProcurementOrder
}

单单这样其实还不够,因为目前还是跟MVC的数据模型一致。实现行为的类与存储状态的类是分开的。假如在验收的过程中需要对属性进行检验计算,如验收金额不能小于等于0之类,这时根据mvc的数据模型的写法会面向过程过程的风格插入在service中。

  • 这种设计风格是高度面向过程的,Martin Fowler在《企业应用架构模式》将其称为事务脚本(transaction script pattern)模式。你只需要提供一个事务(需要执行的一段原子业务),然后去进行运行脚本(一组原子业务的编排方式-service)。
  • 所以这种方式与ddd方式有着强烈的差别:领域模型强调将业务逻辑封装在领域对象中,形成充血模型,以更好地反映业务概念和规则。而事务脚本则更侧重于流程和操作,可能没有充分利用面向对象的优势来组织业务逻辑
type AcceptanceAgg struct {
	// agg root
	Acceptance           *dbmodel.ProcurementOrderAcceptance
	AcceptanceDetailList []*dbmodel.ProcurementOrderAcceptanceDetail
	AttachmentList       []*dbmodel.ProcurementAttachment
	// 验收单所对应的订单明细、订单
	OrderDetailList []*dbmodel.ProcurementOrderDetail
	OrderList       []*dbmodel.ProcurementOrder
}

// PersistValid 分读写校验(充血行为)
func (agg AcceptanceAgg) PersistValid() error {
	if agg.Acceptance.TotalAcceptanceAmount == 0 && agg.Acceptance.TotalAcceptanceQuantity == 0 {
		return utils.NewDataErr("验收单的数据不能为0", nil)
	}
	// ...其他数据校验
	return nil
}

扩展思考点:

  • 上图说了验收单头、验收单明细、订单头、订单明细等是实体,也就是领域对象。这里使用的是dbmodel,那么还是属于领域对象吗

    • 属于领域对象,但是此时的领域对象与数据模型进行了共用,最好还是拆分开来。可以创建一个一摸一样的实体,并且假如完全一摸一样,那么可以直接使用内嵌dbmodel的方式进行简化
    • 共用的缺点是dbmodel基本上是gen自动生成出来的,而在自动生成的文件上你无法做到充血。 并且一个是infra层一个是domain层,在分层上也不相同。在这里共用的原因是暂时可以在这聚合上去进行充血。因为可以说是聚合是领域对象的一种特殊形式,它通过将相关的实体和值对象组合在一起,形成了具有 明确边界和一致性规则的业务单元
  • 假如分页的基础上需要查询验收的信息光联到了申请单/商品的信息。并且这几个信息也能通过验收单去进行溯源关联进来此时需要把他们也加进来吗?

    • 可以,但是最好不要。因为一个好的聚合一定是高度精炼的,过多的实体一个复杂的聚合会变得不再内聚,并且难以维护。如果要做也应该在聚合能进行分布加载的时候做到。
    • 假如是分页操作设计到很多个表,但是其实每个表只用到几个字段,那么这个时候也可以不一定要使用领域模型。直接使用view封装成一个数据模型,然后在infra/dal层直接封装查询对view的方法。

4.4、Repo与Factory

Factory的内容则主要是New,这里使用OPTIONS设计模式来构造(也可以使用建造者模式等)。
aggregate.go:

type OptionFunc func(agg *AcceptanceAgg) error

func (agg *AcceptanceAgg) With(opts ...OptionFunc) (*AcceptanceAgg, error) {
	for _, opt := range opts {
		err := opt(agg)
		if err != nil {
			return agg, err
		}
	}
	return agg, nil
}

  • factory.go:
func NewAcceptanceAgg(opts ...OptionFunc) *AcceptanceAgg {
	agg := &AcceptanceAgg{}
	agg, _ = agg.With(opts...)
	return agg
}

func WithOptionAcceptance(acceptance *dbmodel.ProcurementOrderAcceptance) OptionFunc {
	return func(agg *AcceptanceAgg) error {
		agg.Acceptance = acceptance
		return nil
	}
}

func WithOptionAcceptanceDetailList(detail []*dbmodel.ProcurementOrderAcceptanceDetail) OptionFunc {
	return func(agg *AcceptanceAgg) error {
		agg.AcceptanceDetailList = detail
		return nil
	}
}
//...其他options方法
  • Repo则是用来加载聚合对象:
type AcceptanceRepo interface {
	// Load 加载验收agg(支持可选参数构造)
	Load(inputAgg *AcceptanceAgg, opts ...OptionFunc) (agg *AcceptanceAgg, err error)
	// Persist Persist 持久化存储验收信息
	Persist(agg *AcceptanceAgg) error
	// ...其他接口如LazyLoad、Store等
}

type AcceptanceRepoImpl struct {
	ctx context.Context
}

func NewAcceptanceRepo(ctx context.Context) AcceptanceRepo {
	return &AcceptanceRepoImpl{
		ctx: ctx,
	}
}

func (a AcceptanceRepoImpl) Persist(agg *AcceptanceAgg) error {
	err := conn.TX(&a.ctx, func(tx *gorm.DB) error {
		err := agg.PersistValid()
		if err != nil {
			logs.CtxError(a.ctx, "[AcceptanceRepo-Persist] PersistValid has err:%v", err)
			return err
		}
		// 保存验收单表头
		if err := dal.GetDbFromCtx(a.ctx).Create(&agg.Acceptance).Error; err != nil {
			logs.CtxError(a.ctx, "[AcceptanceRepo-Persist] InsertAcceptance has err:%v", err)
			return err
		}
		// ..其他持久化逻辑
		return nil
	})
	return err
}


// Load 加载验收agg,必须加载聚合根,其他作为options
func (a AcceptanceRepoImpl) Load(inputAgg *AcceptanceAgg, opts ...OptionFunc) (outputAgg *AcceptanceAgg, err error) {
	if inputAgg == nil {
		inputAgg = NewAcceptanceAgg()
	}
	// 由agg options控制加载
	outputAgg, err = inputAgg.With(opts...)
	// ... 在这里可以加入缓存逻辑,做cqrs直接读写分离
	return outputAgg, err

}

func WithLoadAcceptance(ctx context.Context, acceptanceId int64, acceptanceNO string) OptionFunc {
	return func(agg *AcceptanceAgg) error {
		params := &dal.QueryAcceptanceInfoParam{}
		if acceptanceId != 0 {
			params.AcceptanceIds = []int64{acceptanceId}
		}
		if len(acceptanceNO) != 0 {
			params.AcceptanceBusinessNos = []string{acceptanceNO}
		}
		// 获取验收单
		acceptanceList, err := dal.NewAcceptanceDB(ctx).GetAcceptanceListByQuery(&dal.QueryAcceptanceInfoParam{
			AcceptanceIds:         []int64{acceptanceId},
			AcceptanceBusinessNos: []string{acceptanceNO}})
		if err != nil {
			logs.CtxError(ctx, "[AggregateRepo-Load] GetAcceptanceListByQuery:%v", err)
			return err
		}
		agg.Acceptance = acceptanceList[0]
		return nil
	}
}
// ...其他options加载逻辑

4.5、在服务结束

接着编写application service、domain service验证下四层架构下的链路。请求从RPC层通过handler层转发。

func GetProcurementOrderAcceptance(ctx context.Context, req *procurement.GetProcurementOrderAcceptanceReq) (resp *procurement.GetProcurementOrderAcceptanceResp, err error) {

	resp = procurement.NewGetProcurementOrderAcceptanceResp()
	resp.BaseResp = base.NewBaseResp()
	// 填充订单详情
	resp, err = service.NewAcceptanceServiceImpl(ctx).GetProcurementOrderAcceptanceInfo(req.Id)
	if err != nil {
		utils.HandlerErr(ctx, resp.BaseResp, &err)
		return
	}
	return
}

应用服务层进行创建验收的流程调度。

type AcceptanceService interface {
	// GetProcurementOrderAcceptanceInfo 获取验收单详情
	GetProcurementOrderAcceptanceInfo(id int64) (resp *procurement.GetProcurementOrderAcceptanceResp, err error)
}

func NewAcceptanceServiceImpl(ctx context.Context) AcceptanceService {
	return &AcceptanceServiceImpl{
		ctx: ctx,
	}
}

func (a AcceptanceServiceImpl) CreateProcurementAcceptanceOrder(req *procurement.ProcurementOrderAcceptanceReq) (err error) {
	acceptance, detailList, err := convert.AssembleAcceptance(a.ctx, req)
	flag, err := preAcceptanceCheck(a.ctx, detailList)
	// ...进行前置校验,并且持久化验收信息
	txErr := conn.TX(&a.ctx, func(tx *gorm.DB) error {
		// 持久化验收单
		attachmentDbModels := convert.AssembleAttachmentList(req.AttachmentList, acceptance.ID, constants.AttachmentResourceTypeAcceptance)
		agg := acceptanceDomain.NewAcceptanceAgg(acceptanceDomain.WithOptionAcceptance(acceptance),
			acceptanceDomain.WithOptionAcceptanceDetailList(detailList), acceptanceDomain.WithOptionAttachmentList(attachmentDbModels))
		err = acceptanceDomain.NewAcceptanceRepo(a.ctx).Persist(agg)
		

		// 联动计算验收相关订单明细
		agg, err = acceptanceDomain.NewAcceptanceRepo(a.ctx).Load(agg, acceptanceDomain.WithLoadOrderDetailList(a.ctx))
		err = acceptanceDomain.NewAcceptanceService(a.ctx).CalOrderDetailsUpdate(agg)
		if err != nil {
			logs.CtxError(a.ctx, "[CreateProcurementAcceptanceOrder] CalOrderDetailsUpdate has err:%v", err)
			return err
		}

		err := dal.GetDbFromCtx(a.ctx).Save(agg.OrderDetailList).Error
		if err != nil {
			logs.CtxError(a.ctx, "[CreateProcurementAcceptanceOrder] BatchSaveOrderDetail has err:%v", err)
			return err
		}
		// ...其他流程逻辑
		return nil
	})
	if txErr != nil {
		logs.CtxError(a.ctx, "[CreateProcurementAcceptanceOrder] Persist has err:%v", err)
		return txErr
	}

	return nil
}

  • Domain Service:
type AcceptanceService interface {
	// ...
	// CalOrderDetailsUpdate 计算验收单的更新时联动的订单明细变化
	CalOrderDetailsUpdate(agg *AcceptanceAgg) (err error)
	// ...
}

type AcceptanceServiceImpl struct {
	ctx context.Context
}
func (s *AcceptanceServiceImpl) CalCancelOrderDetailsUpdate(agg *AcceptanceAgg) (err error) {
	// 进行恢复可验收数量/金额,已验收数量/金额
	orderDetailsMap := lo.GroupBy(agg.OrderDetailList, func(item *dbmodel.ProcurementOrderDetail) int64 {
		return item.ID
	})
	// 获取计算后的订单明细
	orderDetails := make([]*dbmodel.ProcurementOrderDetail, 0)
	for _, item := range agg.AcceptanceDetailList {
		// 从验收单流向订单,订单明细只会有有一条验收明细。
		orderDetail := orderDetailsMap[item.OrderDetailID][0]
		orderDetailRes, err := calCancelOrderDetailUpdate(s.ctx, orderDetail, item)
		if err != nil {
			logs.CtxError(s.ctx, "[CreateProcurementAcceptanceOrder] AcceptanceOrderEt has err:%v", err)
			return err
		}
		orderDetails = append(orderDetails, orderDetailRes)
	}
	agg.OrderDetailList = orderDetails
	return nil

}

func calCancelOrderDetailUpdate(ctx context.Context, orderDetail *dbmodel.ProcurementOrderDetail,
	orderAcceptanceDetail *dbmodel.ProcurementOrderAcceptanceDetail) (res *dbmodel.ProcurementOrderDetail, err error) {
	// 进行恢复可验收数量/金额,已验收数量/金额
	orderDetail.AcceptableAmount = orderDetail.AcceptableAmount + orderAcceptanceDetail.AcceptanceAmount
	orderDetail.AcceptableQuantity = orderDetail.AcceptableQuantity + orderAcceptanceDetail.AcceptanceQuantity
	orderDetail.AcceptedAmount = orderDetail.AcceptedAmount - orderAcceptanceDetail.AcceptanceAmount
	orderDetail.AcceptedQuantity = orderDetail.AcceptedQuantity - orderAcceptanceDetail.AcceptanceQuantity
	// ..其他字段的计算
	res = orderDetail
	return
}

可以主要看到这一层application主要是把数据通过convert层进行转换成domain层需要的原始数据然后用factory进行创建,用repo进行持久化 ,然后调用domain层的服务进行业务规则计算,最后更新计算后的订单。

五、CQRS与性能

5.1、架构模式-CQRS

在DDD下,聚合联结了不同的的值对象与对象,成为了一个特殊形式下的领域对象。这个领域对象粒度会变的更大,此时加载这个聚合性能就会变得更大。并且在大多数场景下读负载与写负载并不对等,读的时候我可能只想读验收聚合中验收主信息而不想获取其附件信息、明细信息。而写的时候通常又是全量写入,达到聚合内数据的强一致。那么这个时候就可以从CQRS架构模式上寻找灵感。

CQRS(Command-Query Responsibility Segregation) 是一种读写分离的模式,从字面意思上理解Command是命令的意思,其代表写入操作;Query是查询的意思,代表的查询操作,这种模式的主要思想是将数据的写入操作和查询操作分开。
在这里插入图片描述

通过分离Query Service、Command Service。分离数据模型,在读模型下使用cache,按需加载方式等,更高性能的获取领域对象。
在这里插入图片描述

5.2、数据仓库性能优化 - 懒加载

  • 懒加载也叫延迟加载/按需加载,指的是在长网页中延迟加载图像,是一种很好优化网页性能的方式。用户滚动到它们之前,可视区域外的图像不会加载。这与图像预加载相反,在长网页上使用延迟加载将使网页加载更快。

通过懒加载的方式的方式可以加载只需要自己所需的一部分,而不需要加载无用的领域对象,到实际需要时再加载剩余部分,从量上进行性能的提高
而前面实现的则是通过options的方式,在构造函数的阶段就进行按需加载。或者用建造者模式进行创建:

// LoadAcceptanceAgg 是一个结构,它将用于加载AcceptanceAgg
type LoadAcceptanceAgg struct {
    acceptanceAgg *AcceptanceAgg
}

// NewLoadAcceptanceAgg 创建一个新的LoadAcceptanceAgg实例
func NewLoadAcceptanceAgg() *LoadAcceptanceAgg {
    return &LoadAcceptanceAgg{
        acceptanceAgg: &AcceptanceAgg{},
    }
}

// WithAcceptance 加载验收单信息
func (l *LoadAcceptanceAgg) WithAcceptance(ctx context.Context, id int64, no string) *LoadAcceptanceAgg {
    if err := WithLoadAcceptance(ctx, id, no)(l.acceptanceAgg); err != nil {
        //...
    }
    return l
}

// WithAcceptanceDetailList 加载验收单明细列表
func (l *LoadAcceptanceAgg) WithAcceptanceDetailList(ctx context.Context) *LoadAcceptanceAgg {
    if err := WithLoadAcceptanceDetailList(ctx)(l.acceptanceAgg); err != nil {
         //...
    }
    return l
}

// 其他With...方法类似...

// Build 最终构建并返回AcceptanceAgg
func (l *LoadAcceptanceAgg) Build(ctx context.Context) (*AcceptanceAgg, error) {
    // 确保所有必要的步骤都已完成
    // 这里可以添加额外的构建逻辑
    return l.acceptanceAgg, nil
}

然后在加载的时候使用:

agg, err := NewLoadAcceptanceAgg().
        WithAcceptance(ctx), 1, "NOXXXXXX").
        WithAcceptanceDetailList(ctx).
        // 链式调用其他With...方法...
        Build(ctx)

这个方式也是JAVA @Builder注解的实现方式。

5.3、数据仓库性能优化 - 预先加载

  • 资源预加载是另一个性能优化技术,我们可以使用该技术来预先告知浏览器某些资源可能在将来会被使用到。预加载简单来说就是将所有所需的资源提前请求加载到本地,这样后面在需要用到时就直接从缓存取资源。

因此我们可以预先缓存未来可能需要用到的数据,缓存到读取性能更快的位置,如NOSQL/本地内存中,等到未来需要加载的时候使用。这也算是前面CQRS架构中提到的读负载的优化实践,走preload而不是直接走eager的方式。

  • 那么这个时候首先要考虑的是:需要缓存什么?以及缓存的设计的粒度是什么?

  • 在DDD的缓存设计的初期,应该缓存的粒度设计应该为领域实体对象。而不是整个聚合。 因为设计缓存整个聚合,维护/可用性上都会降低,那么因为聚合中的某一个对象更新。那么这一整个聚合的缓存都是脏的,需要进行回源等操作。而假如这个缓存粒度设计为对象级别的,那么只要更新单个缓存对象即可。然后在单个缓存对象的基础上可以再考虑做多级缓存,此时缓存整个聚合。
    在这里插入图片描述

因此可以通过lazyLoad + preLoad,两个维度上提高性能的缓存。

func WithPreLoadAcceptance(ctx context.Context, acceptanceId int64, acceptanceNO string) OptionFunc {
	return func(agg *AcceptanceAgg) error {
		cacheKey := fmt.Sprintf("%d-%s", acceptanceId, acceptanceNO)

		acceptanceCache := dal.GetCache(ctx, cacheKey)
		if acceptanceCache != nil {
			// 假设缓存中存储的是 JSON 字符串
			var acceptance *dbmodel.ProcurementOrderAcceptance
			if err := json.Unmarshal(acceptanceCache, &acceptance); err != nil {
				return fmt.Errorf("unmarshal acceptance from cache failed: %v", err)
			}
			agg.Acceptance = acceptance
		} else {
			// 缓存未命中,从数据库加载
			err := WithLoadAcceptance(ctx, acceptanceId, acceptanceNO)(agg)
			if err != nil {
				return fmt.Errorf("load acceptance from database failed: %v", err)
			}

			// 将加载的数据存储到缓存
			acceptanceJSON, err := json.Marshal(agg.Acceptance)
			if err := dal.Cache(cacheKey, acceptanceJSON); err != nil {
				return fmt.Errorf("cache acceptance failed: %v", err)
			}
			// ...
		}
		return nil
	}
}

5.4、数据仓库性能优化-隐式读时复制

由于我们在存储的粒度往往面向的是聚合。那么这个时候为了在写的时候能够进一步的精细写的粒度,我们会希望在这背后的持久化机制上提供一些特殊的功能支持,来达到隐式的跟踪发生在每个持久化对象上的改变,从而在真正写的时候能从量上减少写的负载。在《实现领域驱动设计》第十二章则提出了,有两种方式可以达到这个目的,分别是隐式读时复制与隐式写时复制。

隐式读时复制(Implicit Copy-on-Read)[Keith & Stafford]:在从数据存储中读取一个对象时,持久化机制隐式地对该对象进行复制,在提交时,再将该复制对象与客户端中的对象进行比较。详细过程如下:当客户端请求持久化机制从数据存储中读取一个对象时,该持久化机制一方面将获取到的对象返回给客户端,一方面立即创建一份该对象的备份(除去延迟加载部分,这些部分可以在之后实际加载时再进行复制)。当客户端提交事务时,持久化机制把该复制对象与客户端中的对象进行比较。所有的对象修改都将更新到数据存储中

基于这个则是Snapshot的方案:当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。参考的实现有Hibernate/Entity Framework。当数据从数据库加载到内存中时,Hibernate会创建数据的一个快照,并在事务提交时比较这个快照和当前内存中的对象,以确定哪些数据发生了变化需要更新回数据库。
实现一个简单的demo,aggregate.go:

type AcceptanceAgg struct {
	// agg root
	Snapshot             *AcceptanceAgg
	Acceptance           *dbmodel.ProcurementOrderAcceptance
	AcceptanceDetailList []*dbmodel.ProcurementOrderAcceptanceDetail
	AttachmentList       []*dbmodel.ProcurementAttachment
	// 验收单所对应的订单明细、订单
	OrderDetailList []*dbmodel.ProcurementOrderDetail
	OrderList       []*dbmodel.ProcurementOrder
}

type AcceptanceDiff struct {
	AcceptanceDiffEnable     bool
	AcceptanceDiff           *dbmodel.ProcurementOrderAcceptance
	AcceptanceDetailListDiff []*dbmodel.ProcurementOrderAcceptanceDetail
	AttachmentListDiff       []*dbmodel.ProcurementAttachment
	// 验收单所对应的订单明细、订单
	OrderDetailListDiff []*dbmodel.ProcurementOrderDetail
	OrderListDiff       []*dbmodel.ProcurementOrder
}
func (a *AcceptanceAgg) DetectDiff(new *AcceptanceAgg) *AcceptanceDiff {
	diff := &AcceptanceDiff{}
	if a.Snapshot == nil {
		return &AcceptanceDiff{
			AcceptanceDiffEnable: false,
		}
	}
	// ..diff操作给出结果
	return diff
}

func (a *AcceptanceAgg) Attach() {
	// 强刷模式调用一次刷新一次当前的snapshot
	a.Snapshot = deepCopy(a)
}

func deepCopy(a *AcceptanceAgg) *AcceptanceAgg {
	return &AcceptanceAgg{
		// ... 对各属性进行深拷贝
	}
}

在repo层加载数据时加载snapshot,并在持久化时判断diff:

func (a AcceptanceRepoImpl) Load(inputAgg *AcceptanceAgg, opts ...OptionFunc) (outputAgg *AcceptanceAgg, err error) {
	if inputAgg == nil {
		inputAgg = NewAcceptanceAgg()
	}
	// 由agg options控制加载
	outputAgg, err = inputAgg.With(opts...)
	outputAgg.Attach()
	return outputAgg, err

}
func (a AcceptanceRepoImpl) Persist(agg *AcceptanceAgg) error {
	diff := agg.DetectDiff(agg)
	if !diff.AcceptanceDiffEnable {
		// ...snapshot为空不启用diff直接全量load
	} else {
		// ...持久化diff的部分
	}
	return nil
}
  • 优点: 提供了一种相对简单的方式来跟踪和同步数据的变化,实现上较为简单。客户端直接操作数据的Snapshot,不需要通过委派对象,简化了数据模型。
  • 缺点: 在事务提交时,需要比较Snapshot和当前数据,如果字段较多,这个过程可能会比较耗时。并且在高并发的场景下,每个请求通常都会维护一个snapshot使用,内存占用容易过多。

5.5、数据仓库性能优化-隐式写时复制

隐式写时复制:持久化久化机制通过委派来管理所有被加载的持久化对象。在加载每个对象时,持久化机制都会为其创建一个微小的委派并将其交给客户端。客户端并不知道自己调用的是委派对象中的行为方法,委派对象会调用真实对象中的行为方法。当委派对象首次接收到方法调用时,它将创建一份对真实对象的备份。委派对象将跟踪发生在真实对象上的改变,并将其标记为“肮脏的”(dirty)。当事务提交时,该事务检查所有的“肮脏”对象并将对它们的修改更新到数据存储中。

基于此的实现有:当数据从DB里取出来后,通过weaving的方式将所有setter都增加一个切面来判断setter是否被调用以及值是否变更,如果变更则标记为Dirty。在保存时根据Dirty判断是否需要更新。
可参考Entity Framework中的使用,在Entity Framework中使用代理。这里可以写出一个以订单为例子的代理demo

// Entity 定义了领域实体的接口,所有领域对象都应该实现这个接口
type Entity interface {
    Save() error
}

// Order 领域对象,实现了Entity接口
type Order struct {
    ID int
    // 其他字段...
}

// Save 实现Entity接口的Save方法,持久化订单
func (o *Order) Save() error {
    // 持久化逻辑,例如写入数据库
    fmt.Printf("Order %d saved\n", o.ID)
    return nil
}

// OrderProxy 订单的代理,实现了Entity接口
type OrderProxy struct {
    realOrder *Order
    isDirty   bool
}

// NewOrderProxy 创建一个新的订单代理
func NewOrderProxy(order *Order) *OrderProxy {
    return &OrderProxy{realOrder: order}
}

// Save 实现Entity接口的Save方法,代理保存逻辑
func (p *OrderProxy) Save() error {
    if p.isDirty {
        return p.realOrder.Save()
    }
    // 如果没有更改,则不需要保存
    return nil
}

// Modify 模拟对订单的修改,标记为脏
func (p *OrderProxy) Modify() {
    // 这里可以添加修改逻辑
    p.isDirty = true
}

每次领域实体都需要做数据更新时都通过OrderProxy去进行操作更新p.isDirty = true,这样就能知道哪些字段是做了更改。然后在实际更新时就能通过这个进行状态的diff。
优点: 性能更高,不需要维护snapshot,基本没有额外的成本开销。并且通过委派对象来管理数据,CoW可以简化并发控制机制,因为每个委派对象可以独立地跟踪数据的变更。
缺点: 实现成本会来的更高,当内嵌的对象比较复杂时,对于嵌套对象的字段更改也需要进行监听。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2077454.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

COMSOL工业碱性电解槽(3D、双欧拉模型)

本案例通过改写COMSOL官方案例获得&#xff0c;使用了碱性电解槽和欧拉-欧拉&#xff08;湍流&#xff09;模型&#xff0c;阳极室和阴极室带有乳突状的结构&#xff0c;模型进行了人为缩小&#xff0c;仅供参考。其中一些参数可参考如下链接文章的说明。COMSOL碱性电解槽参数解…

在NextChat中接入SiliconCloud API 体验不同的开源先进大语言模型

NextChat介绍 One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support. 一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。 主要功能 在 1 分钟内使用 Vercel 免费一键部署提供体积…

Cyberchef实用功能之-json解析美化和转换

本文将介绍如何使用cyberchef的json操作功能&#xff0c;根据自己的需求处理常见的json问题。 在网络安全日常的运营&#xff0c;护网行动&#xff0c;重保活动的过程中&#xff0c;经常需要查看多种平台的json格式日志&#xff0c;如下&#xff1a; 以json格式存储的防火墙日…

基于Python Django的保险数据分析系统优化要点

&#x1f393; 作者&#xff1a;计算机毕设小月哥 | 软件开发专家 &#x1f5a5;️ 简介&#xff1a;8年计算机软件程序开发经验。精通Java、Python、微信小程序、安卓、大数据、PHP、.NET|C#、Golang等技术栈。 &#x1f6e0;️ 专业服务 &#x1f6e0;️ 需求定制化开发源码提…

畅捷通CRM newleadset.php SQL注入漏洞复现

0x01 产品简介 用友畅捷通CRM是面向小企业全力打造的简单、实用的客户关系管理应用。帮助企业用好自己的客户资源、管好商机跟进过程、引导好业务员跟单行为,促进团队销售能力的提升;通过查询和分析,识别企业的价值客户,融合电话、短信、邮件等工具,实现精准营销;帮助企…

STM32(F103ZET6)第一课:按键控制LED灯和蜂鸣器

目录 功能要求一、开发思路二、详细过程1.查看原理图2.各模块编写LED灯按键使用蜂鸣器 功能要求 设备上电&#xff0c;4个灯灭 按键1按下&#xff0c;4个灯灭 按键2按下&#xff0c;4个灯亮 按键3按下&#xff0c;蜂鸣器响 按键4按下&#xff0c;蜂鸣器关闭 一、开发思路 1.…

ARM32开发——(八)中断

1. 中断概念 中断是计算机系统中的一种机制&#xff0c;用于响应外部事件或内部事件,它可以使单片机暂时中断正在执行的程序&#xff0c;转而去执行一个中断处理函数&#xff0c;待中断处理函数执行完毕后&#xff0c;再回到原来的程序继续执行。中断机制使得计算机可以响应各…

【已解决】Win11任务栏怎么使用小图标_Win11任务栏调整图标大小方法

使用注册表编辑器 1、打开运行&#xff0c;并输入 regedit 命令&#xff0c;确定或回车&#xff0c;可以打开注册表编辑器。 2、注册表编辑器窗口&#xff0c;依次展开到以下路径&#xff1a; HKEY_CURRENT_USER-Software-Microsoft -Windows-CurrentVersion-Explorer-Advanc…

『功能项目』技能释放【08】

我们打开上一篇07摄像机跟随角色的项目&#xff0c; 本章要做的事情是摄像机跟随主角移动&#xff0c; 首先创建一个空文件夹Resources 创建一个球体Shpere 修改球体缩放尺寸 创建一个材质Material 将材质重命名为Yellow&#xff0c;色板调至为黄色 将Yellow材质球拖拽至球体S…

0基础学习spark

零、pyspark模板 import os from pyspark import SparkContext, SparkConf os.environ[SPARK_HOME] /export/server/spark os.environ[PYSPARK_PYTHON] /root/anaconda3/bin/python3 os.environ[PYSPARK_DIRVER_PYTHON] /root/anaconda3/bin/python3 if __name__ __main__:…

物联网---ESP32

物联网---ESP32 一、TCP/IP协议(互联网协议)二、MQTT协议(通信协议)2.1 MQTT基本原理2.2 连接MQTT服务端 三、ESP323.1 ESP介绍3.2 ESP32连接云端3.2.1 ESP32连接WIFI/MQTT3.2.2 OneNET云端 一、TCP/IP协议(互联网协议) TCP/IP是一组用于互联网及其他网络中数据传输的通信协议…

MySQL集群 主从复制 和 高可用 配置详解

安装数据库 1、环境依赖 [rootmysql-node10 ~]# yum install cmake gcc-c openssl-devel ncurses-devel.x86_64 libtirpc-devel-1.3.3-8.el9_4.x86_64.rpm rpcgen.x86_64 -y2、下载软件包 3、进行解压 [rootmysql ~]# tar zxf mysql-boost-5.7.44.tar.gz4、生成源码 #进入…

zotero同步之infiniteCLOUD网盘 WebDAV

zotero zotero打开 编辑->首选项->同步 一、数据同步 数据同步&#xff0c;申请并登录zotero账号&#xff0c;免费额度300M&#xff0c;用于存储条目信息。 二、文件同步 有zotero和WebDAV两种形式&#xff0c;zotero是直接把pdf存储在zotero账号中&#xff0c;免费…

【面试问题汇总】

面试问题汇总: Math.round函数:基础加0.5,向下取整 MySQL查询执行流程: 当我们执行一条SQL查询语句时,MySQL数据库会按照以下步骤进行处理: 语法解析器会对SQL语句进行解析,检查SQL语法是否正确。元数据查询器会检查查询的表和字段是否存在,以及当前用户是否具有相应…

前端通过draggable结合fabricjs实现拖拽至画布生成元素自定义编排功能

前端通过draggable结合fabricjs实现拖拽自定义编排功能 太久没有更新了&#xff0c;主要最近行情不太好失业了一段时间&#xff0c;一度到怀疑人生&#xff0c;然后就是做的东西大多没有什么含金量&#xff0c;没什么好分享的就很尴尬。 刚好最近遇到一个奇葩的需求&#xff0…

SparkSQL日期时间模式详解

datatime使用场景 CSV/JSON数据源使用模式字符串来解析和格式化日期时间内容。日期时间函数用于转换StringType类型到DateType或TimestampType类型&#xff0c;反之亦然。例如&#xff0c;unix_timestamp, date_format, to_unix_timestamp, from_unixtime, to_date, to_timest…

SQL AI 工具:颠覆数据库管理与分析的创新力量

一、SQL AI 工具的兴起与发展 在当今数字化的时代&#xff0c;数据量呈现爆炸式增长&#xff0c;企业和个人对于高效管理和分析数据的需求日益迫切。传统的数据库管理和查询方式&#xff0c;对于非技术人员来说存在较高的门槛&#xff0c;操作复杂且耗时。这一背景为 SQL AI 工…

Redis Cluster(无中心化设计)

目录 1 Redis Cluster 的介绍 1.1 Redis Cluster 工作原理 1.2 Redis Cluster特点如下 1.3 Redis cluster 架构 1.4 Redis cluster 主从架构 1.5 Redis Cluster 部署架构说明 1.6 创建redis cluster的前提 2 部署redis cluster 2.1 修改Redis主配置文件参数 2.2 查看端口是否启…

vue 实现简单AI聊天程序(二): python 对接通义千问API

申请账号流程&#xff0c;参考&#xff1a;https://blog.csdn.net/u012917925/article/details/140794192 登录阿里云&#xff0c;然后找到阿里云百炼&#xff0c;创建一个API KEY 配置环境变量&#xff1a;https://help.aliyun.com/zh/model-studio/developer-reference/con…

java直接内存

Java中的内存从广义上可以划分为两个部分&#xff0c;一部分是受JVM管理的堆内存&#xff0c;另一部分则是不受JVM管理的堆外内存&#xff0c;也称为直接内存。直接内存由操作系统来管理&#xff0c;这部分内存的应用可以减少垃圾收集对应用程序的影响。 直接内存概述 直接内…