草庐IT

戏说领域驱动设计(十八)——内验

SKevin 2023-03-28 原文

  验证在我们现实的生活中非常常见,比如您找工作得先整个面试验证你的能力是否靠谱;找对象得先验证下对方的颜值和升值空间。有些工程师写代码从不验证,我觉得是有三个原因,一是意识不够,过于相信前端或外部服务;二是个人缺少主动思考的能力;三是团队负责人的问题,您都当了领导了为什么不制定一些基本开发规则给团队树规矩。实际上,验证这个事情说简单也的确不难,不就是个值判断吗?可如果想把这个事情做好还真是一个需要值得思考的工作,就和异常的处理一样,我告诉你就算干了10年的开发都未必知道怎么有效的使用异常。代码里中充满了土味,一看就特Low。所以我们把验证这个事情单独的提出来,越是越是简单的东西想写好才越难。  

  您应该不知道“对象不变性”这个名字吧?领域模型包括实体与值对象都需要遵循这个规则,就是说不论你对一个领域对像做什么操作,不论怎么盘它,其本质应该保持不变。不都说“江没易改,本性难变”吗?上述的操作不仅是调用对象上的方法,还包括构造对象的过程。有一个例子说“一个没有角的独角兽还能称得上是独角兽吗?”,简单来说就是你需要始终保持领域对象处于合法的状态或者说是属性的值不能超出业务规则限制。比如订单对象:客户信息不能为空、价格信息不能为负数等、订单项数量要大于0小于100等,不论你在订单对象上做什么操作,这些属性值都不可以超出约束。

  想要保证对象的“不变性”,不能依赖于前端的输入和数据库本身的约束,那些基本都不靠谱,最好的方式还是首推“验证”。针对对象本身是否合法的验证我称之为“内验”。相对的,验证某个业务先决条件的验证称之为“外验”,因为此时的验证已经超出了对象本身的规则范围。既然需要对所有的对象都进行验证,就应该将其做为一种通用的能力放到OOP编程框架中。其实我个人特别不喜欢称之为“框架”,感觉概念太大了,所以我们就称呼为基础类库吧。这个类库可以提供一些用于检验领域对象是否合法的工具随用随取,不用重复的造轮子。

  领域对象内验的实现思想很简单:为每个领域对象中加入用于验证的方法和验证规则,在对象创建后或持久化前通过调用每个对象的验证方法实现验证逻辑。您一定要注意前面这句话中所说的触发验证方法的时机,别回头不管什么场景就调用验证,这叫过度设计,那代码会让人吐的。另外,既然是通用的能力而且用于验证领域对象,就最好将其放到领域模型的基类中按需在具体类中进行重写,所以就让我们从这些基类作为起点开搞。

一、验证服务基类

  看过前面的文章您应该已经知道了我们在实现验证的时候使用了一种类“规约模式”,也就是将验证规则嵌入到领域对象中,并在合适的时机进行验证方法的调用。为此,在设计领域模型基类的时候我们让其继承的了一个用于验证的父类“ValidatableBase”,这个类里面包含了两个方法,具体代码如下所示。“ValidatableBase”是一个抽象类,实现了接口“Validatable”,这个接口很重要,但凡需要验证的对象都会实现这个接口。 您可以看一下下面的类图,说得挺绕其实就三个组件。

 

public interface Validatable {

    /**
     * 验证
     * @return 验证结果
     */
    ParameterValidationResult validate();
}
public abstract class ValidatableBase implements Validatable {

    /**
     * 验证当前领域模型
     * @return 验证的结果
     */
    final public ParameterValidationResult validate() {
        RuleManager ruleManager = new RuleManager(this);
        this.addRule(ruleManager);
        return ruleManager.validate();
    }

    /**
     * 增加验证规则
     * @param ruleManager 验证规则管理器
     */
    protected void addRule(RuleManager ruleManager) {

    }
}
public abstract class DomainModel extends ValidatableBase {
   protected void addRule(RuleManager ruleManager) { }
}

  “ValidatableBase”类中的方法“validate”用于触发模型的验证。方法“addRule”用于将验证规则加入到一个包含了验证规则列表的对象“RuleManager”中,所以你可根据需要决定是否在具体类中进行方法的重写,比如上面的“DomainModel”中我就对它进行了覆盖。当触发验证的时候,只需要遍历这个“RuleManager”对象中的每个规约并将验证结果合并即可实现统一验证的目的,RuleManager代码可参看如下片段。

public class RuleManager implements Validatable {

    //规则拥有者
    private DomainModel owner;

    //规则列表
    private List<Rule> rules = new ArrayList<Rule>();


    /**
     * 增加规则
     * @param rule 规则对象
     */
    public void addRule(Rule rule){
        if(rule != null){
            rules.add(rule);
        }
    }

    public RuleManager(DomainModel owner){
        this.owner = owner;
    }

    /**
     * 执行验证,调用规则的验证方法来执行具体的验证。
     * @return 验证结果
     */
    public ParameterValidationResult validate(){
        CompositeParameterValidateResult result = new CompositeParameterValidateResult();
        for(Rule rule : this.rules){
            //针对嵌入式对象的验证
            if (rule instanceof EmbeddedObjectRule){
                EmbeddedObjectRule embeddedObjectRule = (EmbeddedObjectRule) rule;
                ParameterValidationResult validationResult = embeddedObjectRule.getTarget().validate();
                if(!validationResult.isSuccess()){
                    result.addValidationResult(new ParameterValidationResult(false, validateHandlingResult.getMessage()));
                }
                continue;
            }
            ParameterValidationResult ruleVerifyResult = rule.validate();
            if(!ruleVerifyResult.isSuccess()){
                result.fail();
                result.addValidationResult(new ParameterValidationResult(false, errorMessage));
            }
        }
        return result;
    }
}

  这里面其实最有意思也最值得一说的是“EmbeddedObjectRule”这一段,其用于对内嵌对象进行验证。所谓的内嵌对象是指包含于其它对象内部的领域对象,比如下面代码片段中的“contact”就是一个嵌套对象。我们验证领域对象的时候不仅要验证每个简单类型的属性,还需要验证其中嵌入的其它对象。通过这种方式,就可以实现一层层的验证,使得每个属性都能被检验到。在上面代码中另外一个有意思的地方是这段“ParameterValidationResult ruleVerifyResult = rule.validate();”,您会发现真正执行验证操作的其实是“Rule”对象,这些是我们预定好的一组规则,当然您也可以通过实现“Rule”接口自行加入新的规则。使用预定义规则的方式能加速开发的速度,让我们拎包即可入住。由“Rule”做验证其实是OOP中使用较为频繁的方式,把责任分配的非常明确,十分有利用扩展。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    protected Order(Long id, String name, Contact contact) throws OrderCreationException {
        super(id);
        this.name = name;
        this.contact = contact;
    }

    @Override
    protected void addRule(RuleManager ruleManager) {
        super.addRule(ruleManager);
        ruleManager.addRule(new EmbeddedObjectRule("contact", this.contact));        
    }

    public String getName() {
        return name;
    }

    public Contact getContact() {
        return contact;
    }
}

  验证规则定义了待验证目标需要满足什么样的规范,由于规则间有一些通用的属性,所以我们在设计的时候首先会引入一个“RuleBase”基类,所有的规则都会从他继承。“RuleBase”实现了“Rule”接口,而“Rule”也对前面我们说过的“Validatable”进行了扩展。类图与代码如下所示,其实也是三个组件。

 

 

   

public interface Rule extends Validatable {
    /**
     * 与操作
     * @param rule 目标规则
     * @return 与后的规则
     */
    Rule and(Rule rule);

    /**
     * 或操作
     * @param rule 目标规则
     * @return 或后的规则
     */
    Rule or(Rule rule);
}
public abstract class RuleBase<TTarget extends DomainModel> implements Rule {

    //验证的目标
    private TTarget target;
    //验证目标的名称
    private String nameOfTarget;
    //当规验证失败时的错误提示信息
    private String customErrorMessage = GlobalConstants.EMPTY_STRING;

    /**
     * 规则基类
     * @param nameOfTarget 验证目标的名称
     * @param target 验证的目标
     */
    protected RuleBase(String nameOfTarget, TTarget target){
        this(nameOfTarget, target, new String());
    }

    /**
     * 与操作
     * @param rule 目标规则
     * @return 与后的规则
     */
    @Override
    public Rule and(Rule rule) {
        return new AndRule(this, (RuleBase)rule);
    }

    /**
     * 或操作
     *
     * @param rule 目标规则
     * @return 或后的规则
     */
    @Override
    public Rule or(Rule rule) {
        return new OrRule(this, (RuleBase)rule);
    }
}

  “RuleBase”类里除了包含了共用属性外,还实现了两个逻辑操作“与”和“或”,也就是说您可以实现规则的组合,比如我们要求:用户名称不能为空且长度小于等于30,就可以使用下面代码表示,这样写比较优雅。

new ObjectNotNullRule("name", this.name).and(new LE("name", this.name.length(), 30))

  通过上面提到的验证规则框架,我们就可以开始着手建立一些具体的规则 ,下面展示了“对象不为空”规则的代码片段,这里面需要特别关注的是方法“validate”,用于执行实际的验证逻辑。类似“大于”规则,可以通过使用“compareTo”方法实现。

public class ObjectNotNullRule extends RuleBase<DomainModel> {

    /**
     * 获取验证失败时缺省的错误提示信息
     */
    @Override
    protected String getDefaultErrorMessage() {
        return String.format("%s为空对象", this.getNameOfTarget());
    }

    /**
     * 对象非空规则
     * @param nameOfTarget 验证目标的名称
     * @param target       验证的目标
     */
    public ObjectNotNullRule(String nameOfTarget, DomainModel target) {
        this(nameOfTarget, target, GlobalConstants.EMPTY_STRING);
    }

    /**
     * 执行验证
     * @return 验证是否成功
     */
    @Override
    public ParameterValidationResult validate() {
        if(this.getTarget() == null){
            return ParameterValidationResult.failed(null);
        }
        return ParameterValidationResult.success();
    }
}

  到目前为止我们已经展示了内验所具备的一切条件,现在我们就可以在领域模型中加入各类验证规则了。下面的代码片段以上面的“ObjectNotNullRule”规则为例展示了如何在业务代码中设置验证规则。这样的代码是不是看起来非常的漂亮?至少不用写一堆的“if……else”。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    @Override
    protected void addRule(RuleManager ruleManager) {
        super.addRule(ruleManager);
        ruleManager.addRule(new EmbeddedObjectRule("contact", this.contact));
        ruleManager.addRule(new ObjectNotNullRule("name", this.name));
    }
}

二、验证触发的时机

  验证触发的时机是需要重点说明和解释的内容。通过上面的代码您应该可以看出来每个领域模型无论是实体还是值对象都会包含一个叫作“validate”的公有方法,既然是公有就代表您可以随意的使用,所以如果不加以限制代码就会变得特别脏……像我这种有代码洁癖的人是无论如何不能忍受的,所以我们需要确定触发验证的时机,这里给的答案很简单:对象构造完成时。对象构造包括使用构造函数和对象工厂两种方式,一旦不合法就直接抛出异常,因为不合法的对象是一个畸形儿不能该被创造出来,一般情况下也不允许创造出来后做二次加工使其合法。直白一点就是说你只能使用一行代码构造对象比如“new BusinessEntity()”或“BusinessEntityFactory.create(),比较建议使用工厂的方式创建对象以避免在构造函数中抛异常”,如果成功就返回目标对象失败则直接报错,第十七章中我展示过一个“OrderFactory”的案例,您可以翻看一下。

  领域对象的创建其实也只会出现在两个时机中:新建及反序列化时。针对新建做验证是因为参数来源于用户或其它服务的输入,这些是不可信任的;而反序列化时进行验证的原因也很简单,我们在将对象序列化时它其实是合法的,不过一旦存储到比如数据库中就不可控了,您知道谁手贱把数据给改了或由于错误执行了某些脚本造成数据变质了。您不能或也不应该只依赖于数据库本身的验证规则来保障数据的正确性,使用关系型数据库还好一点,使用如MongoDB这种的,那只能看运气了。再说了,业务对象的验证属于业务代码要处理的,您把这个责任推给数据库就不合适了。

  被成功创建后的对象,您就可以为所欲为的进行操作了,包括最后的持久化阶段也不需要进行二次验证(如果我在前面的文章中提及到对象在持久化时进行验证的话,请务必注意这种后验的方式很不友好。比如订单中的客户信息由于意外被置成了“null”,如果不进行构造时的检测,您在使用这个信息的时候就可能抛NPE)。这种说法应该没让您惊呆了吧?也许您可能认为这种说法非常的荒唐,我给您解释一下为什么。

  首先,我们的前提是对象创建后是合法的,这个在前面已经说过,使用构造函数或工厂进行保障;第二,由于有了聚合及聚合根的概念,您不可能绕过聚合根而直接修改其聚合内部的对象。比如用户实体包含了一个值对象“实名信息”,我们在修改这个信息的时候不应该绕过用户对象而直接对其引用或修改。假如此时的用户是被冻结的状态,修改实名信息是没有意义的,违反了“客户冻结”时的业务操作限制;而通过让客户对象提供修改的方法,就可以在修改前加一些验证对操作进行限制,也就是说“只能通过聚合根修改聚合”的原则进一步保障了对象的合法性。当然了,您也可以在修改前先把客户信息查询出来判断一下状态再做变更逻辑,但这种方式会造成业务规则不够内聚,而且这也是典型的面向过程的编程思维。第三点,我假设您在调用领域对象的公有方法时已经进行了参数的验证,如果出现违反业务规则的情况则可直接抛出一个业务异常,比如“冻结的用户不能修改实名信息”这个规则,您的代码可能会按如下方式写。其实第三条的假设就不应该存在,谁写公有方法的时候不验证啊?

public class Account extends EntityModel<Long> {
    public void changeRealName(string name, string idCard) throws RealNameModificationException {
        if (this.status == AccountStatus.FREEZEN) {
            throw new RealNameModificationException();
        }
        ……
    }
}

  综上三条所述,已经覆盖了您使用领域对象时涉及修改的所有场景,每一步都对对象的不变性进行了保障,那创建好的领域对象不就是您手中的小白羊吗?盘它的时候根本而不用担心它不服。

总结

  对象的内验是一种验证对象合法性的手段,条条大路通罗马,在实践中其实有多种验证的方式可采用,您所关注的其实应该是它的思想。还是要多提醒一句,你应该知道在DDD中要以聚合为存储单元、事务单元,其实应该还需要多加一条:验证单元,上述所说的验证是以聚合为单位的而非某一个实体或值对象。在实践中您需要多去思考对象的合法性,虽然说不太可能一下子都想全了,但要有一个验证意识。这样的代码安全性才高。其实不论是做什么样的系统,应该对安全抱有敬畏的态度,今天多想一点,明天您就少吃点亏。

有关戏说领域驱动设计(十八)——内验的更多相关文章

  1. ruby-on-rails - Rails - 子类化模型的设计模式是什么? - 2

    我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co

  2. ruby-on-rails - 使用 rails 4 设计而不更新用户 - 2

    我将应用程序升级到Rails4,一切正常。我可以登录并转到我的编辑页面。也更新了观点。使用标准View时,用户会更新。但是当我添加例如字段:name时,它​​不会在表单中更新。使用devise3.1.1和gem'protected_attributes'我需要在设备或数据库上运行某种更新命令吗?我也搜索过这个地方,找到了许多不同的解决方案,但没有一个会更新我的用户字段。我没有添加任何自定义字段。 最佳答案 如果您想允许额外的参数,您可以在ApplicationController中使用beforefilter,因为Rails4将参数

  3. ruby-on-rails - 如何在 Ruby on Rails 中实现由 JSF 2.0 (Primefaces) 驱动的 UI 魔法 - 2

    按照目前的情况,这个问题不适合我们的问答形式。我们希望答案得到事实、引用或专业知识的支持,但这个问题可能会引发辩论、争论、投票或扩展讨论。如果您觉得这个问题可以改进并可能重新打开,visitthehelpcenter指导。关闭10年前。问题1)我想知道ruby​​onrails是否有功能类似于primefaces的gem。我问的原因是如果您使用primefaces(http://www.primefaces.org/showcase-labs/ui/home.jsf),开发人员无需担心javascript或jquery的东西。据我所知,JSF是一个规范,基于规范的各种可用实现,prim

  4. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

  5. LC滤波器设计学习笔记(一)滤波电路入门 - 2

    目录前言滤波电路科普主要分类实际情况单位的概念常用评价参数函数型滤波器简单分析滤波电路构成低通滤波器RC低通滤波器RL低通滤波器高通滤波器RC高通滤波器RL高通滤波器部分摘自《LC滤波器设计与制作》,侵权删。前言最近需要学习放大电路和滤波电路,但是由于只在之前做音乐频谱分析仪的时候简单了解过一点点运放,所以也是相当从零开始学习了。滤波电路科普主要分类滤波器:主要是从不同频率的成分中提取出特定频率的信号。有源滤波器:由RC元件与运算放大器组成的滤波器。可滤除某一次或多次谐波,最普通易于采用的无源滤波器结构是将电感与电容串联,可对主要次谐波(3、5、7)构成低阻抗旁路。无源滤波器:无源滤波器,又称

  6. 计算机毕业设计ssm+vue基本微信小程序的小学生兴趣延时班预约小程序 - 2

    项目介绍随着我国经济迅速发展,人们对手机的需求越来越大,各种手机软件也都在被广泛应用,但是对于手机进行数据信息管理,对于手机的各种软件也是备受用户的喜爱小学生兴趣延时班预约小程序的设计与开发被用户普遍使用,为方便用户能够可以随时进行小学生兴趣延时班预约小程序的设计与开发的数据信息管理,特开发了小程序的设计与开发的管理系统。小学生兴趣延时班预约小程序的设计与开发的开发利用现有的成熟技术参考,以源代码为模板,分析功能调整与小学生兴趣延时班预约小程序的设计与开发的实际需求相结合,讨论了小学生兴趣延时班预约小程序的设计与开发的使用。开发环境开发说明:前端使用微信微信小程序开发工具:后端使用ssm:VU

  7. ruby-on-rails - 设计注册确认 - 2

    我在我的项目中有一个用户和一个管理员角色。我使用Devise创建了身份验证。在我的管理员角色中,我没有任何确认。在我的用户模型中,我有以下内容:devise:database_authenticatable,:confirmable,:recoverable,:rememberable,:trackable,:validatable,:timeoutable,:registerable#Setupaccessible(orprotected)attributesforyourmodelattr_accessible:email,:username,:prename,:surname,:

  8. ruby-on-rails - 设计通过 reset_password_token 获取用户 - 2

    我正在尝试创建密码规则来设计可恢复的密码更改。我通过passwords_controller.rb做了一个父类(superclass),但我需要在应用规则之前检查用户角色,但我所拥有的只是reset_password_token。 最佳答案 假设您的模型是用户:User.with_reset_password_token(your_token_here)Source 关于ruby-on-rails-设计通过reset_password_token获取用户,我们在StackOverflow

  9. ruby-on-rails - Rails 5,公寓和设计 : sign in with subdomains are not working - 2

    我已经使用Apartment设置了一个Rails5应用程序(1.2.0)和Devise(4.2.0)。由于某些DDNS问题,应用只能在app.myapp.com下访问(请注意子域app)。myapp.com重定向到app.myapp.com。我的用例是每个注册该应用的用户(租户)都应该通过他们的子域(例如tenant.myapp.com)访问他们的特定数据。用户不应限定在其子域内。基本上应该可以从任何子域登录。重定向到租户的正确子域由ApplicationController处理。根据Devise标准,登录页面位于app.myapp.com/users/sign_in。这就是问题开始的

  10. ruby-on-rails - 设计中的 ArgumentError::RegistrationsController#new 错误的参数数量(2 代表 0..1) - 2

    我在关注RyanbatesRailsCast的devise和omniauth(第235集-devise-and-omniauth-revised)。当我尝试使用Twitter登录时,标题中不断出现错误。defself.new_with_session(params,session)ifsession["devise.user_attributes"]new(session["devise.user_attributes"],without_protection:true)do|user|user.attributes=paramsuser.valid?end完整跟踪:C:/Ruby20

随机推荐