2024年1月

开发者趋向于将关注点放在数据上,而不是领域上。这对于DDD新手来说也是如此,因为在软件开发中,数据库依然占据着主导地位。我们首先考虑的是数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。这样做的结果是将数据模型直接反映在对象模型上,导致那些表示领域模型的实体包含了大量的getter和setter方法。另外,还存在大量的工具可以帮助我们生成这样的实体模型。虽然在实体模型中加入getter和setter并不是什么大错,但这却不是DDD的做法。

为什么使用实体

当我们需要考虑一个对象的个性特征,或者需要区分不同的对象时,我们引入实体这个领域概念。一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。我们可以对实体做多次修改,故一个实体对象可能和它先前的状态大不相同。但是,由于它们拥有相同的身份标识,它们依然是同一个实体。

随着对象的改变,我们可能会跟踪这样的改变过程,比如什么时候发生了改变,发生了什么改变,是谁做出的改变等。也或者,当前对象已经包含了足够的先前状态的改变信息,此时我们便没必要显式地对对象状态进行跟踪。即便我们并不打算跟踪对象的每一个变化细节,我们也应该慎重对待在对象整个生命周期中所发生的合法改变。
唯一的身份标识和可变性(mutability)特征将实体对象和值对象区分开来。
实体的具体定义可以参考
《领域驱动设计》

唯一标识

在实体设计早期,我们将刻意地把关注点放在能体现实体身份唯一性的主要属性和行为上,同时还将关注如何对实体进行查询。另外,我们还会刻意地忽略掉那些次要的属性和行为。

在设计实体时,我们首先需要考虑实体的本质特征,特别是实体的唯一标识和对实体的查找,而不是一开始便关注实体的属性和行为。只有在对实体的本质特征有用的情况下,才加入相应的属性和行为。

那么,首先我们应该怎么做呢?找到多种能够
实现唯一标识性
的方法是非常重要的,同时我们还应该考虑如何
在实体的生命周期内维持它的唯一性

值对象可以用于存放实体的唯一标识。值对象是不变的,这可以保证实体身份的稳定性,并且与身份相关的行为也可以得到集中处理。这样,我们便可以避免将身份标识相关的行为泄漏到模型的其他部分或者客户端中。

实体的唯一标识并不见得一定有助于对实体的查找和匹配。将唯一标识用于实体匹配通常取决于标识的可读性。比如,如果系统提供根据人名查找功能,但此时一个Person实体的唯一标识极有可能不是人名,因为存在大量重名的情况。另一方面,如果一个系统提供根据公司税号的查找功能,此时税号便可以作为Company实体的唯一标识,因为政府为每个公司分配了唯一的税号。

以下是一些常用的创建实体身份标识的策略,从简单到复杂依次为:

  • 用户提供一个或多个初始唯一值作为程序输入,程序应该保证这些初始值是唯一的。
  • 程序内部通过某种算法自动生成身份标识,此时可以使用一些类库或框架,当然程序自身也可以完成这样的功能。
  • 程序依赖于持久化存储,比如数据库,来生成唯一标识。
  • 另一个限界上下文已经决定出唯一标识,这作为程序的输入,用户可以在一组标识中进行选择。

委派标识

有些ORM工具,比如Hibernate,通过自己的方式来处理对象的身份标识。Hibernate更倾向于使用数据库提供的机制,比如使用一个数值序列来生成实体标识。如果我们自己的领域需要另外一种实体标识,此时这两者将产生冲突。为了解决这个问题,我们需要使用两种标识,一种为领域所使用,一种为ORM所使用,在Hibernate中,这被称为委托标识(Surrogate Identity)。

通常来说,委派标识采用long和int类型。同时,我们还需要相应地在数据库中创建一个列来保存该委派标识,并加上逐渐约束。

对外界来说,我们最好将委派标识隐藏起来,因为委派标识并不是领域模型的一部分,而将委派标识暴露给外界可能造成持久化漏洞。

此时,我们可以使用
层超类型(Layer Supertype)

public abstract classIdentifiedDomainObjectimplementsSerializable
{
  
private long id = -1;
  
publicIdentifiedDomainObject() {
    
super();
  }
  
protected longid() {
    
return this.id;
  }
  
protected void setId(longanId) {
    
this.id =anId;
  }
}

这里的 IdentifiedDomainObject 便是层超类型,这是一个抽象基类,通过 protected 关键字,它向客户端隐藏了委派主键。所有实体都扩展自该抽象基类。在实体所处的模块之外,客户端根本不关心 id 这个委派标识。我们甚至可以将 protected 换为 private 。Hibernate 既可以通过 getter 和 setter 方法来访问属性,也可以通过反射机制直接访问属性,故无论是使用 protected 还是 private 都是无关紧要的。另外,层超类型还有其他好处,比如支持乐观锁,在聚合部分将会讲到。

标识稳定性

在多数情况下,我们都不应该修改实体的唯一标识,这样可以在实体的整个生命周期中保持标识的稳定性。

我们可以通过一些简单的措施来确保实体标识不被修改。此时,我们可以将标识的setter方法向客户端隐藏起来。我们也可以在setter方法中添加逻辑以确保标识在已经存在的情况下不会再被更新,比如可以使用一些断言语句:

public class User extendsEntity
{
...
  
protected voidsetUsername (String aUsername){
    
if(this.username != null) {
    
throw new IllegalStateException("The username may not be changed.");
  }

  
if (aUsername == null) {
    
throw newIllegalArgumentException(
    
"The username may not be set to null.");
  }
  
this.username =aUsername;
  }
  ...
}

以上这个setter方法并不会阻碍Hibernate对对象的重建,因为对象在创建时,它的属性都是使用默认值,并且采用无参数构造函数,因此username属性的初始值为null。

发现实体及其本质特征

在通用语言的术语中,名词用于给概念命名,形容词用于描述这些概念,而动词则表示可以完成的操作。但是,如果我们认为对象就是一组命名的类和在类上定义的操作,除此之外并不包含其他内容,那么,我们就错了。在领域模型中还可以包含很多其他内容。团队讨论和规范文档可以帮助我们创建更有意义的通用语言。到最后,团队可以直接使用通用语言来进行对话,而此时的模型也能够非常准确地反映通用语言。

如果一些特定的领域场景会在今后继续使用,这时可以用一个轻量的文档将它们记录下来。简单形式的通用语言可以是一组术语和一些简单的用例场景。但是,如果我们就此认为通用语言只包含术语和用例场景,那么我们又错了。在最后,通用语言应该直接反映在代码中,而要保持设计文档的实时更新是非常困难的。

揭开实体及其本质特征的面纱

挖掘实体的关键行为

角色和职责

建模的一个方面便是发现对象的角色和职责。通常来说,对角色和职责分析是可以应用在领域对象上的。这里我们特别关注的是实体的角色和职责。

领域对象扮演多种角色

在面向对象编程中,通常由接口来定义实现类的角色。在正确设计的情况下,一个类对于每一个它所实现的接口来说,都存在一种角色。如果一个类没有显式的角色—即该类没有实现任何显式的接口,那么在默认情况下它扮演的即是本类的角色。也即,该类的公有方法表示该类的隐式接口。比如,上面的User类并没有实现任何接口,但是它依然扮演了一种角色,即User角色。由于User实体中有一些“人”的属性,所以定义一个Person值对象。

我们可以使用一个对象同时扮演User和Person的角色,虽然这并不是我所建议的,但就目前而言,让我们假设这是一个好主意。这样一来,我们便没有必要在User中引用一个Person了,而是只需要创建一个对象来同时扮演这两种角色即可。

那我们为什么要这么做呢?通常是因为两个或对象既有相似之处,又有不同之处。此时,这些对象上重叠的属性可以通过一个实现了多个接口的对象来表示。比如,我们可以创建一个HumanUser对象,该对象既是一个User,又是一个Person:

public interfaceUser {
...
}
public interfacePerson {
...
}
public class HumanUser implementsUser,Person{
...
}

以上代码看似合乎情理的,但是它也可能使事情变得复杂。如果两个接口都是复杂的,那么HumanUser对象实现起来将是困难的。另外,如果User不是一个人,而是一个系统又该怎么办呢?此时我们可能需要3个接口,而要设计一个实现了这3个接口的对象将变得更加困难。我们可能需要创建一个通用的Principal(委托)来简化这个问题:

public interfaceUser{
...
}
public interfacePrincipal {
...
}
public class UserPrincipal implementsUser, Priincipal{
...
}

有了以上代码,我们可以直到运行时才决定一个Principal的类型。一个人对应的Principal和一个系统对应的Principal在实现上是不同的。一个系统不需要拥有像人一样的联系信息。另外,我们还可以通过委派的方式来实现以上两个接口,此时我们需要在运行时检查存在哪种类型的Principal,再将逻辑委派给这个实际的Principal对象:

public interfaceUser {
  ...
}
public interfacePrincipal{
  
publicName principalName();
  ...
}
public class PersonPrincipal implementsPrincipal (
  ...
}
public class SystemPrincipal implements Principal{
  ...
}
public class UserPrincipal implementsUser, Principal{
  
privatePrincipal personPrincipal;
  
privatePrincipal systemPrincipal;
  
publicName principalName(){
    
if (personPrincipal != null) {
      
returnpersonPrincipal.principalName();
    }
    
else if(systemPrincipal!=null){
      returnsystemPrincipal.principalName();

    }else{
    throw new IllegalStateException("The principal is unknown.");
    }
}

以上代码设计存在多个问题,其中之一便是对象分裂症(Object Schizophrenia)(即表示一个具有多重身份的对象)。对象的行为通过技术上的转向和分发来进行委派。无论是  personPrincipal 还是 systemPrincipal 它们都不具有 UserPrincipal 实体的身份标识,而 UserPrincipal 才是行为的最初执行对象。

对象分裂症的描述是:委派对象根本不知道原来被委派对象的身份标识,因此我们无法知道委派对象的真正身份。虽然并不是所有的委派对象都需要知道被委派对象的身份标识,但是在有些情况下的确是有必要的。我们可以向 principalName() 传入一个 UserPrincipal 对象的引用,但这使设计变得更加复杂,并且需要改变 Principal 接口,因此显然是不好的。“
委派只有在使问题简化而不是复杂时,才是好的。

以下两项需求有助于我们设计出好的接口:

  • 向一个客户添加订单。
  • 使客户成为优先(Preferred)客户。

Customer类实现了两个细粒度的角色接口:IAddOrdersToCustomer 和 IMakeCustomerPreferred。每一个接口都制定一个了单个操作,如图。我们甚至还可以使Customer实现另外的接口,比如IValidator。


聚合
中会提到,我们并不希望创建一个拥有大量对象的集合,比如向Customer中添加大量的订单。但是,这并不是我们当前的重点,这里的重点是演示对象角色的使用。

这种风格能给我们带来哪些好处?实体的角色可以在不同的用例之间发生转变。将一个新的Order实例添加到Customer,或者使用Customer变成优先客户,在这两种情况下一个Customer所扮演的角色是不同的。同时,这种风格还有技术上的好处,不同的用例所使用的Customer获取策略可能是不同的:

IMakeCustomer Preferred customer =session. Get<IMakeCustomer Preferred>(customerid);
customer.MakePreferred();
...
IAddOrdersToCustomer customer
=session.Get<IAddOrdersToCustomer>(customerId)
customer.AddOrder(order);

通过使用泛型,持久化机制从基础设施中查找不同的获取策略。如果某个接口没有特殊的获取策略,那么将使用默认的获取策略。在使用特定的获取策略时,所获取的Customer能够满足特定的用例。

当然,还存在其他的特定于某个用例的行为可以与角色联系起来,比如验证功能,在实体被持久化时,它可以充当验证器的角色对自身进行数据验证。

好的接口设计也有助于实现类,比如Customer,将功能实现在其自身上,而没有必要将实现委派给其他类,对象分裂症也由此得到避免。

将Customer的行为通过角色进行划分是否能给领域建模带来好处?我们可以将前面的Customer和下图中的Customer做个对比,哪个更好?当需要调用MakePreferred() 方法时,下图中的Customer是否更容易引导客户端错误地调用成AddOrder() 方法?但这并不是唯一的评判标准。

角色接口最实用之处可能也是其最简单之处。通过接口,我们可以将实现细节隐藏起来,从而不至于将实现细节泄漏到客户端中。我们所设计的接口应该刚好能够满足客户端的需求,不多也不少。实现类可以比接口复杂得多,它可以拥有大量支撑属性,外加这些属性的getter和setter方法。但是,客户端是看不到这些实现细节的。

不管采用哪种设计方式,我们都应该确保领域语言优先于技术实现。在DDD中,业务领域的模型才是最重要的。

创建实体

当我们新建一个实体时,我们希望通过构造函数来初始化足够多的实体状态,这一方面有助于表明该实体的身份,另一方面可以帮助客户端更容易地查找该实体。在使用及早生成唯一标识的策略时,构造函数至少需要接受一个唯一标识作为参数。如果我们还有可能通过其他方式对实体进行查找,比如名字或描述信息,那么应该将这些参数也一并传给构造函数。

有时一个实体维护了一个或多个不变条件。不变条件即是整个生命周期中都必须保持事务一致性的一种状态。
不变条件主要是聚合所关注的
,但是由于聚合根通常也是实体,故这里我们也稍作提及。如果实体的不变条件要求该实体所包含的对象都不能为null状态,或者由其他状态计算所得,那么这些状态需要作为参数传递给构造函数。

每一个User对象都必须包含tenantId、username、password和person属性。换句话说,在User对象得到正确实例化之后,这些属性绝对不能为null。User对象的构造函数和实例变量对象的setter方法保证了这一点:

public class User extendsEntity {protectedUser(TenantId aTenantId, String aaUsername
String aPassword, Person aPerson){
this();this.setPassword(aPassword);this.setPerson(aPerson);this.setTenantId (aTenantId);this.setUsername (aUsername);this.initialize();
}
protected voidsetPassword(String aPasswordd){if (aPassword == null) {throw newIllegalArgumentException ("The passwordmay not be set to null.");
}
this.password =aPassword;
}
protected voidsetPerson (Person aPerson) {if (aPerson == null) {throw newIllegalArgumentException("The person may not be set to null.");
}
this.person =aPerson;
}
protected voidsetTenantid(TenantId aTenantid){if(aTenantId== null) {throw newIllegalArgumentException("The tenantId may not be set to null.");
}
this.tenantId =aTenantId;
}
protected voidsetUsername(String aUsername) {if(this.username != null){throw newIllegalStateException ("The username may not be changed.");
}
if(aUsername == null){throw newIllegalArgumentException("The username may not be set to null.");
}
this.username =aUsername;
}
}

User对象展示了一种
自封装性
。在构造函数对实例变量赋值时,它把操作委派给了实例变量所对应的setter方法,这样便保证了实例变量的自封装性。实例变量的自封装性使用setter方法来决定何时给实例变量赋值。每一个setter方法都“代表着实体”对所传进参数做非null检查,这里的断言称为
守卫
。在“标识稳定性”一节中我们讲到,setter方法的自封装性技术可能会变的非常复杂。

对于那些非常复杂的创建实体的情况,我们可以使用工厂,后面“工厂”部分会讲到。

验证

验证的主要目的在于检查模型的正确性,检查的对象可以是某个属性,也可以是整个对象,甚至是多个对象的组合。我们将对模型进行三个级别的验证。虽然有很多种验证方式,包括专门用于验证的框架和类库等,但这里主要讨论的是一些通用的验证方法。

验证可以达到不同的目的。即便领域对象的各个属性都是合法的,这也并不表示该对象作为一个整体是合法的。两个合法属性组合起来有可能使整个对象不合法。同样的道理,单个对象的合法性并不能保证对象组合的合法性。两个合法实体对象的组合有可能是不合法的。因此我们需要采用不同级别的验证来处理这些情况。

验证属性

我们如何确保属性处于合法状态呢?建议使用自封装性来验证属性。

Martin Fowler曾说:“自封装性要求无论以哪种方式访问数据,即使从对象内部访问数据,都必须通过getter和setter方法”。这种方式有诸多优点。首先它为对象的实例变量和类变量提供了一层抽象。其次,我们可以方便地在对象中访问其所引用对象的属性。重要的是,自封装性使验证变得非常简单。

在上面的代码中,setAddress() 方法中存在4个前置条件,所有的前置条件都对 anAddress 参数进行断言。

EmailAddress 类并不是一个实体,而是值对象。这里我们使用值对象是有原因的。首先它向我们展示了一个很好的前置条件验证的例子,从null检查到格式检查。其次,EmailAddress 是Person实体的属性,因此 EmailAddress 和其他直接声明在Person中的简单属性一样。在为其他简单属性创建setter方法时,我们可以采用完全相同的方式对它们进行验证。在将一个整体值对象赋值给实体时,只有当值对象中的所有较小属性得到验证时,我们才能保证整体值对象的验证。

验证整体对象

虽然有时实体中的所有单个属性都是合法的,但是这并不意味着整个实体就是合法的。要验证整个实体,我们需要访问整个对象的状态——所有对象属性。此时我们可能还需要使用规范(Specification)或者策略(Strategy)来进行验证。

由于验证逻辑需要访问实体的所有状态,有人可能会直接将验证逻辑嵌入到实体对象中。这里我们需要注意,更多的时候验证逻辑比领域对象本身变化还快,而将验证逻辑嵌入在领域对象中也使领域对象承担了太多的职责。

此时我们可以创建一个单独的验证类来完成模型验证。

验证类可以实现规范模式或策略模式。当发现非法状态时,验证类将通知客户方或者记录下验证结果以便后用(比如,在批量处理完成之后)。验证过程应该收集到所有的验证结果,而不是一开始遇到非法状态时就抛出一场。考虑以下的例子:

/// <summary>
    ///验证器抽象类/// </summary>
    public abstract classValidator
{
//验证通知处理器程序 privateValidationNotificationHandler notificationHandler;publicValidator(ValidationNotificationHandler aHandler)
{
this.SetNotificationHandler(aHandler);

}
/// <summary> ///验证/// </summary> public abstract voidValidate();/// <summary> ///当前验证类的通知处理程序/// </summary> /// <returns></returns> protectedValidationNotificationHandler NotificationHandler()
{
returnnotificationHandler;

}
private voidSetNotificationHandler(ValidationNotificationHandler aHandler)
{
notificationHandler
=aHandler;

}

}
/// <summary>
    ///通用验证通知处理器/// </summary>
    public classValidationNotificationHandler
{
private List<string> errorList = new List<string>();//private publicValidationNotificationHandler()
{
}
public void HandleError(stringmsg)
{
errorList.Add(msg);

}
public IReadOnlyCollection<string>GetErrors()
{
returnerrorList;

}
}
public classWarbleValidator: Validator
{
privateWarble warble;publicWarbleValidator(Warble aWarble, ValidationNotificationHandler aHandler)
:
base(aHandler)
{
SetWarble(aWarble);

}
public override voidValidate()
{
if (this.HasWarpedWarbleCondition(this.warble)) {this.NotificationHandler().HandleError("the warble is warped.");
}
if (this.HasWackyWarbleState(this.warble))
{
this.NotificationHandler().HandleError("the warble has a wacky state.");
}
}
private voidSetWarble(Warble aWarble)
{
warble
=aWarble;

}
}

在上面例子中,WarbleValidator 在初始化时传入了一个 ValidationNotificationHandler 。任何时候,当发现非法状态时,WarbleValidator 都会调用 ValidationNotificationHandler 来处理。ValidationNotificationHandler 是一个通用实现,它拥有一个 HandleError 方法,该方法接收一个 String 类型的验证通知消息。我们可以将验证通知封装在方法中,这样一来,我们便将错误消息、消息通知与验证过程进行了解耦:

public classWarbleValidator: Validator
{
privateWarble warble;publicWarbleValidator(Warble aWarble, ValidationNotificationHandler aHandler)
:
base(aHandler)
{
SetWarble(aWarble);

}
private voidSetWarble(Warble aWarble)
{
warble
=aWarble;

}
public override voidValidate()
{
CheckForWarpedWarbleCondition();
CHeckForWackyWarbleState();
}
private voidCheckForWarpedWarbleCondition()
{
if (this.HasWarpedWarbleCondition(this.warble))
{
this.NotificationHandler().HandleError("the warble is warped.");
}
}
private voidCHeckForWackyWarbleState()
{
if (this.HasWackyWarbleState(this.warble))
{
this.NotificationHandler().HandleError("the warble has a wacky state.");
}
}
}

在这个例子中,我们使用了一个特定的 ValidationNotificationHandler 。我们可以定义一个子类 WarbleValidationNotificationHandler 继承自 ValidationNotificationHandler,然后在 WarbleValidator 使用子类。同时可以将各个消息通知方法封装在 WarbleValidationNotificationHandler :

public classWarbleValidationNotificationHandler: ValidationNotificationHandler
{
publicWarbleValidationNotificationHandler()
{
}
public voidHandleWarpedWarble()
{
this.HandleError("the warble is warped.");
}
public voidHandleWackydWarbleState()
{
this.HandleError("the warble has a wacky state.");
}
}
public classWarbleValidator: Validator
{
privateWarble warble;publicWarbleValidator(Warble aWarble, WarbleValidationNotificationHandler aHandler)
:
base(aHandler)
{
SetWarble(aWarble);

}
private voidSetWarble(Warble aWarble)
{
warble
=aWarble;

}
public override voidValidate()
{
var handler = (WarbleValidationNotificationHandler)this.NotificationHandler();
CheckForWarpedWarbleCondition(handler);
CHeckForWackyWarbleState(handler);
}
private voidCheckForWarpedWarbleCondition(WarbleValidationNotificationHandler handler)
{
if (this.HasWarpedWarbleCondition(this.warble))
{
handler.HandleWarpedWarble();
}
}
private voidCHeckForWackyWarbleState(WarbleValidationNotificationHandler handler)
{
if (this.HasWackyWarbleState(this.warble))
{
handler.HandleWackydWarbleState();
}
}
}

对于使用什么样的 ValidationNotificationHandler 类型,验证类和客户端应该达成一致。

客户端如何保证对实体的验证确实发生了?验证过程又从何处开始?

要将 Validate() 方法应用在所有需要验证的实体上,我们可以使用层超类型:

public abstract classEntity
{
publicEntity()
{
Validate();

}
public virtual voidValidate()
{

}

}

任何继承自 Entity 的类都可以安全地调用 Validate 方法。如果具体的实体类拥有自身的验证逻辑,该验证逻辑将被执行,否则 Validate 方法不做任何事情。同时,我们应该只在需要进行验证的实体中才重写 Validate 方法。

然而,实体应该进行自我验证吗?拥有 Validate 方法并不表示需要实体自行执行验证过程。此时实体可以将验证过程交给单独的验证类:

public classWarble: Entity
{
publicWarble()
{

}
public override voidValidate()
{
var notificationHandler = newWarbleValidationNotificationHandler();new WarbleValidator(this, notificationHandler)
.Validate();
if(notificationHandler.HasError())
{
throw new Exception("这里可以发送报错信息");
}
}
}

每一个专有的 Validator 都会执行特定的验证过程。实体类不用知道验证过程是如何发生的。单独的 Validator 也将验证逻辑的变化与实体对象本身的变化分离开来,并且有助于对复杂验证过程的测试。

验证对象组合

在需要对复杂对象进行验证时,我们可以使用延迟验证。这里我们关注的并不只是某个单独的实体是否合法,而是多个实体的组合是否全部合法,包括一个或多个聚合实例。要达到这样的目的,我们需要创建继承自 Validator 的不同验证类的实例。但是,最好的方式是把验证过程创建成一个领域服务。该领域服务可以通过资源库读取那些需要验证的聚合实例,然后对每个实例进行验证,可以是单独验证,也可以和其他聚合实例组合起来验证。

在任何时候,我们都需要决定是否可以展开验证。有时某个聚合或一组聚合可能处于临时的、中间的状态。此时我们可以在聚合上创建一个状态标识来避免这些状态的验证。当验证条件成熟时,模型通过发送领域事件的方式通知客户方。

在领域驱动设计(DDD)中,延迟验证可以通过以下步骤实现:

1. 定义聚合根和聚合实体:首先,你需要明确聚合根和聚合实体的概念。聚合根是聚合的起始点,负责维护聚合的状态。聚合实体是与聚合根相关联的其他对象。

2. 确定验证时机:确定何时进行验证。在DDD中,验证通常在聚合根上执行,而不是在实体上执行。这意味着验证逻辑应该集中在聚合根上,而不是分散在整个聚合中。

3. 实现验证逻辑:在聚合根中实现验证逻辑。你可以使用领域服务或工厂来获取对其他聚合根的引用,并在聚合根上调用适当的方法来执行验证。

4. 延迟验证:将验证逻辑推迟到真正需要验证的时间点上执行。这可以通过在聚合根上定义方法来实现,该方法将在适当的时机调用验证逻辑。例如,你可以在保存聚合根或执行某些关键操作时调用该方法。

5. 处理无效状态:如果验证失败,即验证逻辑发现聚合处于无效状态,你需要处理这种情况。你可以抛出异常或返回错误信息,以便调用者能够采取适当的措施。

通过以上步骤,你可以在DDD中实现延迟验证,将验证逻辑集中在聚合根上,并在适当的时间点上推迟验证操作。这样可以提高代码的可维护性和可读性,同时减少不必要的验证和资源消耗。

跟踪变化

根据实体的定义,我们没必要在整个生命周期中对状态的变化进行跟踪,而是只需要跟踪那些持续改变的状态。然而,有时领域专家可能会关心发生在模型中的一些重要事件,此时我们便应该对实体的一些特殊变化进行跟踪了。

跟踪变化最实用的方法是领域事件和事件存储。
我们为领域专家所关心的所有状态改变都创建单独的事件类型,事件的名字和属性表明发生了什么样的事件。
当命令操作执行完后,系统发出这些领域事件。事件的订阅方可以接收发生在模型上的所有事件。在接收到事件后,订阅方将事件保存在事件存储中。

领域专家并不会关心发生在模型中的所有变化,但这却是技术团队所应该关心的。可以参考
事件源
模式。

Kernel Memory 入门系列:异步管道

前面所介绍的处理流程都是基于同步管道的,即文档导入的时候,会等到文档处理完成之后才会返回。

但是在实际的应用中,文档很多,而且文档的处理时间也不确定,如果采用同步的方式,那么就会导致整个流程的处理时间过长,也会导致整个流程的阻塞。因此,我们需要一种异步的方式来处理这种情况。

注册消息队列

当我们使用异步管道的时候,需要先注册消息队列,Kernel Memory中默认提供了几种消息队列的实现,包括:

  • RabbitMQ
  • Azure Queue
  • Simple Queue (file based, for testing)

这里以Simple Queue为例,在构建Kernel Memory的时候,可以通过
WithSimpleQueuesPipeline
方法来注册Simple Queue。

var _ = new KernelMemoryBuilder(appBuilder.Services)
    //...
    .WithSimpleQueuesPipeline() // <- register simple queue
    .Build();

默认注册消息队列之后,处理流程就会以后台异步的方式进行处理。

后台任务

使用了异步队列之后,自定义的处理流程就注册方法就需要发生一些改变。

Kernel Memory提供的方式是将所有的异步处理流程都注册为HostedService, 也就是后台任务。

当注册自定义的处理流程的时候,就需要调用
AddHandlerAsHostedService
方法。

builder.Services.AddHandlerAsHostedService<MyHandler>("my_step");

注册好的后台任务会监听消息队列,当有消息到达的时候,就会触发对应的处理流程。

其余的导入、自定义处理流程和同步管道的方式一样。

构建异步处理服务

通过分布式的文件存储、向量存储和消息队列服务,就可以将文件的导入和处理流程进行分离,从而实现异步的处理流程。

sequenceDiagram

participant Client as client
participant Memory as Kernel Memory
participant ContentStorage as content Storage
participant Queue as message queue
participant HostedService as hosted service

Client->>Memory: ImportDocumentAsync
Memory->>ContentStorage: Save document
Memory->>Queue: Send message
Memory->>Client: Return documentId
Queue->>HostedService: Receive message
ContentStorage->>HostedService: Get document
HostedService->>HostedService: Run pipeline

Client->>Memory: GetDocumentStatusAsync
ContentStorage->>Memory: Get document Status
Memory->>Client: Return document status

整体的处理流程如下:

  1. 文件导入后,会保存到分布式的文件存储中,同时会发送消息到消息队列中。
  2. 后台任务会监听消息队列,当有消息到达的时候,就会触发对应的处理流程。
  3. 客户端可以通过
    GetDocumentStatusAsync
    方法来获取文档的处理状态。

由此就可以实现异步的处理流程。

wmproxy

wmproxy
已用
Rust
实现
http/https
代理,
socks5
代理, 反向代理, 静态文件服务器,四层TCP/UDP转发,七层负载均衡,内网穿透,后续将实现
websocket
代理等,会将实现过程分享出来,感兴趣的可以一起造个轮子

项目地址

国内: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

设计目标

需要能对针对性的IP地址进行放行或者禁止,从而达到网络限制或者安全等目的,保护系统的整体稳定性。

IP的作用

IP地址的作用是标示一台在互联网上的主机,就像每个人的住宅地址一样,邮寄东西需要住宅地址,而互联网上一台电脑对另一台电脑发送数据也需要一个可以识别的地址。

早期的IP地址由32位(即IPv4)的数据表示,也就是大概有42亿的地址,以现如今的网络拥有量已经将公有IP即将耗尽的情形。即使有大量的地址并不会占用公网地址,如公司内部的电脑,家庭内部的电脑,手机等并未占用公网地址,可能很多的设备占用了同一个公网出口。所以我们做了IPv4的限定的时候,也有可能将正常的用户做了相关限定。

现在正在推行的IP地址是128位(即IPv6)的数据将可以表示很大的IP数,将可以每个物联网的设备都拥有独立的IP,但是由于IP的升级涉及到大量的基础设备,大量的旧软件,所以当下基本上两种IP都必须支持的阶段。但是国内相对IPv4占了大部分。

IP中子网掩码

子网掩码就是为了划分同网段的主机数量。每类网段默认是254个。

我们在现实的生活中经常看到路由上
255.255.255.0/24
也经常在云的白名单上看到了
0.0.0.0/0
,那么他们表示的含义又是什么呢?

通常我们可以把IPv4看成一个无符号的32位整型,那么
255.255.255.0/24
后面的24就表示
0xffffff00
,那么我们将某个IP与该值进行
按位与
运算,得到相同的值将表示归属于同一个IP段。
举例:内网常用的ip如
192.168.0.100
按位与后将变成
192.168.0.0
,及
192.168.0.1-254
这254个ip按位与得到的地址均为
192.168.0.0
,所以他们归属于同一个IP段,也就是表示在他们在同一个路由器下面。


0.0.0.0/0
中最后的
0
就是表示
0x00000000
,那么我们将任意的IP与该值做按位与得到的值均为
0.0.0.0
,则表示所有的IP都归属于同一个类,也就是通常配置的白名单对所有的都生效。

IPv6与IPv4类似,只是IPv6的长度更长,子网的长度可以由0-127,而IPv6不叫子网掩码,通常称其为前缀,但其原理都是用位表示,前n位为网络位,则说明IP只要前n位一样,则子网一样,IP的机制通了,涉及IP的问题也就好解决了。

源码的实现


Rust
中并不支持子网掩码等,那么我们将在其基础上增加一个8位的无符号型:

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpGate {
    pub ip: IpAddr,
    pub gate: u8,
}

序列化我们通过
serde_with
中的
DisplayForStr
实现,如果存在
/
则将其切割,如果不存在那么子网掩码位数为0,兼容两种模式:


impl FromStr for IpGate {
    type Err = io::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let vals = s.split("/").collect::<Vec<&str>>();
        let ip = vals[0].parse::<IpAddr>().map_err(|_| io::Error::new(io::ErrorKind::Other, "parse ip error"))?;
        let mut gate = 0;
        if vals.len() > 1 {
            gate = vals[1].parse::<u8>().map_err(|_| io::Error::new(io::ErrorKind::Other, "parse ip error"))?;
            if ip.is_ipv4() && gate > 32 {
                return Err(io::Error::new(io::ErrorKind::Other, "too big gate"));
            } else if ip.is_ipv6() && gate > 128 {
                return Err(io::Error::new(io::ErrorKind::Other, "too big gate"));
            }
        }
        Ok(IpGate {
            ip, gate
        })
    }
}

impl Display for IpGate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.gate > 0 {
            f.write_fmt(format_args!("{}/{}", self.ip, self.gate))
        } else {
            f.write_fmt(format_args!("{}", self.ip))
        }
    }
}

查看是否包含:

pub fn contains(&self, ip: &IpAddr) -> bool {
    if self.gate == 0 {
        ip == &self.ip
    } else {
        match (&ip, &self.ip) {
            (IpAddr::V4(other), IpAddr::V4(my)) => {
                let other = u32::from_be_bytes(other.octets()) >> (32u8 - self.gate);
                let my = u32::from_be_bytes(my.octets()) >> (32u8 - self.gate);
                other == my
            }
            _ => {
                ip == &self.ip
            }
        }
    }
}

在负载均衡中的通行及禁止

我们将配置信息转化成可通行的IP段数组或者禁止的IP段数组

  • 如果存在可通行的配置那么必须在配置中才可通行
  • 如果存在禁止的IP,那么在配置中的将会被禁止

我们在配置的时候,就可以进行如下的配置:

[[http.server.location]]
rule = "/try"
# 只允许本地网络通行
allow_ip = "127.0.0.1 192.168.0.0/24"

[[http.server.location]]
rule = "/"
reverse_proxy = "http://server"
# 全面禁止10开头的IP段
deny_ip = "10.0.0.0/8"

源码示例:


if l.comm.deny_ip.is_some() || l.comm.allow_ip.is_some() {
    if let Some(ip) = req.headers().system_get("{client_ip}") {
        let ip = ip
            .parse::<IpAddr>()
            .map_err(|_| ProtError::Extension("client ip error"))?;
        if let Some(allow) = &l.comm.allow_ip {
            if !allow.contains(&ip) {
                return Ok(Response::status503()
                    .body("now allow ip")
                    .unwrap()
                    .into_type());
            }
        }
        if let Some(deny) = &l.comm.deny_ip {
            if deny.contains(&ip) {
                return Ok(Response::status503().body("deny ip").unwrap().into_type());
            }
        }
    }
}

小结

后续可能需要在接受连接的时候就直接禁止掉IP,那么我们可以防止客户端持续的发送流量,即可能造成流量被耗尽。

IP的通行及禁止帮我们更好的保护系统的健壮性及私域的隐私性做保证。自动禁止IP的话,将是WAF等进阶能力的,更好的保护源站。

点击
[关注]

[在看]

[点赞]
是对作者最大的支持

基于
Kaldi
实现语音识别时,需要引入一款名为
OpenFST
的开源软件,本文中提到的内存问题,即和这款软件相关。
考虑到过程比较曲折,内容相对比较长,因此先说结论。

在做长时间的语音识别时,集成了Kaldi和OpenFST的进程将会占用远超出预期的内存,这个现象可能和OpenFST、glibc的实现相关,未必是内存泄漏。

进程占用超出大量内存的原因,简单说一下:

  • OpenFST在工作过程中,申请了很多内存,同时产生了很多内存碎片。
  • 语音识别进程默认使用的glibc无法合并相关的碎片,因而即便相关的内存已经被释放,但glibc仍然无法向操作系统释放内存。
  • 因此,在使用
    top
    观察进程的虚拟内存时,发现进程占用的内存会时间增长而一直增长,进而会被判定为疑似内存泄漏。

当然了,经过分析后,现在可以确认前述现象为非问题,只需要调整机器规格即可解决问题,但如前所述,整个过程比较曲折,这里记录下来,以备后察。

测试同事反馈,在性能环境上,执行压测过程中,算法服务出现了重启的现象。这是一个大问题,于是在第一时间联系我进行定位。

观察测试同事的压测环境,发现确实如测试同事所说,压测开始后,算法服务占用的虚拟内存以肉眼可见的速度缓慢增长。通过操作系统的硬件资源监控平台,观察进程一段时间内虚拟内存的占用趋势,发现没有进入平稳状态的迹象。最终观察的结果是算法服务占用的虚拟内存一直在增长,最终随着进程异常退出而结束。

我们的算法服务由业务代码、算法推断代码和机器学习模型组成。

  • 业务代码使用Java开发,编译、构建成jar文件,运行时由JVM加载并运行。
  • 算法推断代码使用C++开发,基于JNA规范,Java代码暴露接口,编译、构建成动态库,运行时由JVM加载。
  • 机器学习模型,其实是几个数据文件,运行时由算法推断代码读取并使用。

考虑到当前版本中,算法推断代码和数据模型并没有引入新的变动点,因此重启现象的定位工作从算法服务的业务代码入手。

检查业务流程

首先分析业务流程。
本版本引入了长语音文件转写的特性,因此业务代码有比较大的变动。前期在实现时,为了简化实现方案,在文件转写的过程中,内存里缓存了很多数据。通过分析这部分实现,没有发现对象生命周期超长的现象,但仍然做了改进,将内存中缓存的数据交给数据库来缓存。
这时在开发环境中复现操作,观察内存增长的曲线,发现增长趋势有所减缓,但算法服务占用的虚拟内存,仍然在涨,没有收敛的迹象,因此仍然需要继续分析。

检查JVM配置

算法服务使用的Java堆的参数中,堆的最大值,比较大。本质上讲,经过上一环节的优化后,算法服务的业务代码中不涉及大量Java对象的生成,因此运行时,Java堆可以使用较少的内存。
修改算法服务Java堆的参数后,在开发环境中复现操作,基本功能正常。此外,使用
jstat -gcutil <pid> 1000 1000
观察,确认JVM的GC操作运行正常,未发现异常现象。
长时间观察内存增长的曲线,发现没有明显改进,算法服务占用的虚拟内存,仍然在涨,没有收敛的迹象,因此仍然需要继续分析。

分析Java堆内存

内存问题分析到现在, 光靠看代码已经不解决问题,是时候召唤专业工具上场了。
对于Java应用的内存,
jmap

MAT
是一对完美的组合。
执行如下命令,导出Java应用进程的堆。

jmap -dump:live,format=b,file=dump001.bin <pid>

为了方便对比分析,一般至少需要导出四次堆。

  • Java应用进程启动完毕。导出的堆文件命名为
    dump001.bin
  • 压力测试持续一段时间之后。假如可以准确的控制执行的压力测试的用例数量,则可以使用用例数量来衡量。导出的堆文件命名为
    dump002.bin
  • 在上次导出操作后,压力测试的TPS保持稳定,继续持续一段时间或者执行完毕一部分用例之后,再提取一次堆。导出的堆文件命名为
    dump003.bin
  • 停止压力测试,等待一段时间,此时再提取一次堆。导出的堆文件命名为
    dump004.bin

将上述导出的三个文件,
dump001.bin

dump002.bin

dump003.bin
一起导入至MAT。MAT基于
eclipse
开发,在配置文件中指定了Java堆的最小值和最大值,可以视堆文件的大小,酌情修改MAT的JVM参数。
使用MAT的histogram功能,对这三个文件进行对比。

  • 对比
    dump001.bin

    dump002.bin
    ,可以确认业务启动后,堆中出现的Java对象的类型和数量。结合业务用例和代码,可以确认对象的类型和数量,是否符合预期。
  • 对比
    dump002.bin

    dump003.bin
    ,可以确认业务运行平稳后,堆中出现的Java对象的类型和数量,是否稳定。假如压力测试的TPS保持稳定,则从理论上讲,Java堆中出现、湮灭的对象的数量应当是稳定的,对象的数量不会有太大的变化。
  • 对比
    dump003.bin

    dump004.bin
    ,确认Java堆中业务相关的对象的类型和数量,是否有较大的下降。一般而言,运行过程中的Java对象,应当在压力测试结束后,在JVM的垃圾回收操作中被回收掉,不应存在大量的残留。
  • 对比
    dump001.bin

    dump004.bin
    ,由于压力测试已经结束,Java堆中对象的类型和数量,二者之间的差异应当比较小。

使用jmap命令,导出堆,使用MAT分析。
反复拨测业务,使用jstat命令观察GC情况。
修改代码的实现,降低内存占用。

问题仍然存在。
算法同事参与分析,使用valgrind分析,memcheck和massif,未发现内存泄漏点。
使用pmap观察,Java进程的内存空间,发现很多64MB的块。在网上找到很多文章。

缩小变量的值
关闭线程分配器,均无效

使用tcmalloc分配器,内存仍然会涨,并且偶发性的进程异常退出,因此本方案不能在生产环境使用。

最终,定期调用malloc_trim,定期向操作系统释放内存。

总结

无法更新GPU驱动的版本,流程操作比较复杂,时间和技术上均不允许。

参考资料

Kaldi

glibc

JVM

valgrind

malloc

pmap

Ef Core花里胡哨系列(7) 使用Ef Core也能维护表架构?

我们这里指的并不是查询,而是利用Ef的迁移原理,生成可用的其它表架构操作的
Sql

例如你想在
Ef Core
里建表,并且可能程序里有多个
provider
,那么写Sql将是一件痛苦的事情,我们就是利用
Ef Core
迁移时的操作,来为我们所用。

如果看过此系列中屏蔽外键的那一篇博客,我们的主角就暗藏在里面,它就是各种
Operation

Operation

Ef Core
中所有的迁移的工作单元均由
Operation
组成,例如
CreateTableOperation

AlterColumnOperation
等等,我们要做的就是将我们的操作组装为对应的
Operation
来模拟迁移的操作,让
Ef Core
去生成
Sql
,那我们在一定程度上就避免了
Sql
的强耦合,生成
Sql
将有
Ef Core
的提供程序来提供支持。

当然我们之前提到过,不同的提供程序可能有些实现是没有的,例如微软官方就不提供
AlterColumnOperation
,直接采用的暴力的
Drop

Add
,我们只需重写
IMigrationsSqlGenerator
中对应的实现即可。

使用
Operation
有两种方法实现:

  • 直接拼接Operation
  • 类似于迁移文件的写法

直接拼接Operation

直接拼接
Operation
需要我们创建对应的
Operation
并且填充里面的主要信息,生成
Sql
时,需要拿到
DbContext
内部的
IMigrationsSqlGenerator
作为服务,用于生成
Sql

var service = CreateDbContext<DataDbContext>().GetService<IMigrationsSqlGenerator>();

var creator = new CreateTableOperation()
{
    Name = "test",
};
creator.Columns.Add(new AddColumnOperation()
{
    Name = "Id",
    ClrType = typeof(int),
    IsNullable = false
});

var operations = new List<MigrationOperation> {
    creator
};

var cmd = service.Generate(operations);

foreach (var item in cmd.Select(x => x.CommandText))
{
    TestOutputHelper.WriteLine(item);
}

类似于迁移文件的写法

类似于迁移文件的写法实现时,和迁移文件中展现的部分基本一样,生成
Sql
时,需要构建一个
MigrationBuilder
并且提供你要使用的
Ef Core
提供程序,
项目里需要引用该提供程序
。随后即可生成对应的
Sql

MigrationBuilder t = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");

t.CreateTable(
        name: "flow_draft",
        columns: table => new
        {
            ID = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: false),
            FlowId = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, comment: "流程Id"),
            UserId = table.Column<string>(type: "varchar(255)", nullable: false, comment: "草稿提交人"),
            FormDataId = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: false, comment: "数据Id"),
            Content = table.Column<string>(type: "longtext", nullable: false, comment: "表单冗余数据")
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_flow_draft", x => x.ID);
        },
        comment: "流程草稿记录")
    .Annotation("MySQL:Charset", "utf8mb4");

cmd = service.Generate(t.Operations);

foreach (var item in cmd.Select(x => x.CommandText))
{
    TestOutputHelper.WriteLine(item);
}