关于ddd:领域驱动设计入门与实践-下

43次阅读

共计 9871 个字符,预计需要花费 25 分钟才能阅读完成。

上期对 DDD 进行了简略的介绍,Phone Number 案例也使咱们对 DDD 有了进一步的理解。

通过该案例,咱们理解到 PhoneNumber 蕴含了初始化、校验、属性解决等多种逻辑,而传统的 POJO 类只蕴含其属性的 getter setter 办法。这是 DDD 和传统面向数据开发的重要差别点。

「PhoneNumber- 充血模型」与「POJO 类 - 贫血模型」不难理解,笔者在「畛域驱动设计入门与实际 - 上」中已对其做过介绍。难的是在理论我的项目中,若应用充血模型,如何把握好其强弱水平须要很丰盛的教训。

DP(Domain Primtive)

咱们将 PhoneNumber 这种类型称为 DP- Domain Primitive。类比 Integer、String 在 Java 编程语言中一样,DP 是 DDD 里所有模型、办法、架构的根底。

定义 DP:

在 DDD 里,DP 能够说是所有模型、办法、架构的根底,它是在特定畛域,领有精准定义、可自我验证、领有行为的对象,可认为是畛域的最小组成部分。

应用 DP 的三条准则:

  • 将隐性概念显性化 /Make Implicit Concepts Expecit
    在 Phone Number 这个案例中,若应用 String 类型来定义电话号码,则「归属地编号」、「运营商编号」这些属于电话号码的隐性属性就难以体现进去,咱们通过自定义类型 PhoneNumber,通过赋予它行为来显性化了这两个概念,让代码的业务语义更加明确。

这里咱们通过一个例子来阐明:

假如当初要实现一个性能: 使 A 用户能够领取 x 元给用户 B,可能的实现如下:

public void pay(BigDecimal money, Long recipientId) {bankService.transfer(money, "CNY", recipientId); 
}

如果这个是境内转账,并且境内的货币永远不变,该办法仿佛没啥问题。一旦货币变更或做跨境转账,该办法留有显著的 bug,因为 Money 对应的不肯定是 CNY

在这个 case 里,当咱们说“领取 x 元”时,除了 x 自身的数字外,还有一个隐含的概念「元」。

在原始的入参中,只用 BigDecimal 的起因是咱们默认 CNY 货币是不变的,是一个隐含的上下文条件。但当咱们写代码时,要 把所有隐性的条件显性化,而后整体组成以后的上下文:做「领取」时,咱们将「领取金额」&「货币品种」作为一个入参输出进来,两者整合成一个残缺而独立的概念:Money。

原有的代码变为:


public class Money {     
    private final BigDecimal amount;     
    private final Currency currency;       
    public Money(BigDecimal amount, Currency currency) {         
        this.amount = amount;         
        this.currency = currency;     
        } 
    }  

    public void pay(Money money, Long recipientId) {bankService.transfer(money, recipientId); 
    }
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money,咱们防止了很多以后看不出来将来可能会暴雷的 bug。

  • 封装多对象行为 /Encapsulate Multi-Object Behavior

将后面的案例降级一下:用户要做跨境转账,从 CNY 到 USD 且汇率随时在稳定。

代码块:



public void pay(Money money, Currency targetCurrency, Long recipientId) {if (money.getCurrency().equals(targetCurrency)) {bankService.transfer(money, recipientId);
    } else {BigDecimal rate = exchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        bankService.transfer(targetMoney, recipientId);
    }
}

这个 case 里

1、targetCurrency 不等于 money Curreny

2、调用一个服务去取汇率,而后做计算

3、用计算后的后果做转账

最大的问题在于 金额的计算被蕴含在领取的服务中,波及多个对象:2 个 Currency,2 个 Money,1 个 BigDecimal。这种波及到多个对象的业务逻辑,咱们要用 DP 包装。

利用 DP 的 封装多对象行为,将转换汇率的性能封装到一个叫做 ExchangeRate 的 DP 里:

ExchangeRate 被定义为汇率对象,通过 对金额计算逻辑 & 各种校验逻辑封装,使原始代码变得简略:

public class ExchangeRate {
    private final BigDecimal rate;
    private final Currency from;
    private final Currency to;
    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }
    public Money exchange(Money fromMoney) {notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}
public void pay(Money money, Currency targetCurrency, Long recipientId) {ExchangeRate rate = exchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    bankService.transfer(targetMoney, recipientId);

案例解说

public class RegistrationServiceImpl implements RegistrationService {
    private final SalesRepMapper salesRepDAO;
    private final UserMapper userDAO;
    private final RewardMapper rewardDAO;
    private final TelecomRealnameService telecomService;
    private final RiskControlService riskControlService;

    public User register(String name, PhoneNumber phone) throws ValidationException {TelecomInfoDTO rnInfoDTO = telecomService.getRealnameInfo(phone.getNumber());
        if (!Objects.equals(name, rnInfoDTO.getName())) {throw new InvalidRelnameException();
        }
        // 计算用户标签
        String label = getLabel(rnInfoDTO);
        // 计算销售组
        String salesRepId = getSalesRepId(phone);
        // 结构 User 对象和 Reward 对象
        String idCard = rnInfoDTO.getIdCard();
        UserDO userDO = new UserDO(idCard, name, phone.getNumber(), label, salesRepId);
        RewardDO rewardDO = new RewardDO(idCard, label);

        // 风控逻辑
        if (!riskControlService.check(idCard, label)) {userDO.setNew(true);
            rewardDO.setAvailable(false);
        } else {userDO.setNew(false);
            rewardDO.setAvailable(true);
        }
        // 长久化数据
        rewardDAO.insert(rewardDO);
        return userDAO.insert(userDO);
    }

    private String getLabel(TelecomInfoDTO telecomInfoDTO) {// TODO}

    private String getSalesRepId(PhoneNumber phone) {SalesRepDO repDO = salesRepDAO.select(phone.getAreaCode(), phone.getOperatorCode());
        if (repDO != null) {return repDO.getRepId();
        }
        return null;
    }
}

惯例逻辑和实现代码。咱们先来给它挑挑刺,再利用畛域驱动设计的思维来重构。发现

问题 1>> 对外部依赖的耦合十分重大

所有不属于以后域内的设施和服务,都可认为是内部依赖。比方:数据库,数据库 Schema,RPC 服务,ORM 框架,中间件….. 并且都是可替换的。咱们要做的是 把由内部依赖变动导致的本人零碎内产生的变动管制在最小范畴。

“由内部依赖变动导致的外部零碎的革新水平,咱们能够了解为一个零碎的可维护性。”

在查看这段代码的可维护性前,咱们先来看看它有哪些内部依赖:

1-1、数据库 Schema

这里的业务代码强依赖数据库 schema,也就是 DO 类。一旦数据表的字段产生变动,DO 类就会随之变动。

但 DO 在这段代码里到处都是,并且将 UserDO 这个对象传递到了办法内部。一旦业务逻辑简单起来、DO 发生变化,这段代码就会面目全非,甚至很可能会毁坏掉原有失常的性能。

1-2、ORM 框架

此代码应用了大家相熟的 MyBatis 框架:应用 Mapper 这种 DAO 对象来构建和执行 SQL。

如果框架自身没有向下兼容、API 产生了变动,零碎要降级框架;或出于对平安问题的思考,零碎要替换整个 ORM 框架,业务代码要进行大量的变动。这是不合理且存在危险的。

1-3、RPC 服务

应用中国电信提供的手机号实名信息查问服务,强依赖在业务逻辑中。一旦中国电信提供的接口入参和返回都产生变动,或者变更服务商,那么业务逻辑代码也要进行相应的批改。说起来简略做起来难,倡议抱有此想法的童鞋趁早放弃,不要给本人找锅背。

剖析了以上三种依赖,大家曾经理解了代码耦合度高的起因。如何革新?

思路:将其革新成面向形象接口的编程。这样,DDD 将会作为一种指导思想辅助咱们设计。

>> 针对数据拜访形象

有两个关键点:

1、DO 是具体实现,不应间接裸露给业务逻辑

2、DAO 作为拜访数据库的具体实现

引入畛域驱动设计中的 Entity 和 Repository。

下层的业务逻辑不须要关怀上层的具体实现。

这里定义了一个 User 类—Entity,一种畛域实体类。User 类中的属性用于形容这个零碎内客户应该含有的信息,应尽量多的应用 DP 来将更多的自检和隐性属性内聚起来。

参照这句话「下层的业务逻辑不须要关怀上层的具体实现」,在定义 User 类的时候,咱们不关怀上层数据库的具体实现、User 对象的存储在哪里,咱们只须要关注如何去形容这个畛域实体。

“有人可能会对 Entity 和 DP 的差异产生纳闷,它们最实质的差别就是主语义上是否领有数据状态。”

Repository 就是数据拜访的形象层。在形象层中,咱们只定义动作。

比方这里的 UserRepository,只定义了 find 和 save 这两个动作,这样在实现类中,咱们就能够依赖数据库拜访相干的具体实现,而后,间接依赖 MyBatis 框架,比拟 Redis Client 等各种数据库操作。

通过对数据拜访层的革新,咱们再来看业务代码,革新前:

// User Entity
public class User extends Entity {
    private UserId userId;
    private PhoneNumber phone;
    private Label label;
    private SalesRepId salesRepId;
    private SalesRepRepository salesRepRepository;

    public User(RealnameInfo info, String name, PhoneNumber phone) {
        // 参数一致性校验,若校验失败,则 check 内抛出异样(DP 的长处)info.check(name);
        initId(info);
        labelledAs(info);
        this.salesRepId = salesRepRepository.ofPhone(phone).getRepId();}

    // 对 this.userId 赋值
    private void initId(RealnameInfo info) {

}

    // 对 this.label 赋值
    private void labelledAs(RealnameInfo info) {// 本地解决逻辑}
}

革新后:


public interface UserRepository {User ofId(UserId id);
    User ofPhone(PhoneNumber phone);
    User save(User user);
}
public class UserRepositoryImpl implements UserRepository {
    private final UserMapper userMapper;
    private final UserConverter userConverter;@Override public User ofId(UserId id) {UserDO userDO = userMapper.selectById(id.value());
        return userConverter.fromDO(userDO);
    }
    @Override 
    public User ofPhone(PhoneNumber phone) {UserDO userDO = userMapper.selectByPhone(phone.getNumber());
        return userConverter.fromDO(userDO);
    }
    @Override 
    public User save(User user) {UserDO userDO = userConverter.toDO(user);
        if (userDO.getId() == null) {userMapper.insert(userDO);
        } else {userMapper.update(userDO);
        }
        return userConverter.fromDO(userDO);
    }
}

>> RPC 调用形象

这里次要有两块改变:

1、应用 RealnameService 接口类代替 TelecomRealnameService 具体实现类— 依赖倒置

2、DP 具体逻辑内聚:应用 RealnameInfo 这个 DP 来代替 TelecomInfoDTO 这个具体实现。这样无论内部服务是参数变动还是替换实现,咱们都将变动范畴管制在了具体实现类和配置文件外部,保障了外围业务逻辑的稳固。

这里还要引入一个概念:

“防腐层(Anticruption Layer):避免内部零碎的腐烂影响到咱们本人的零碎。这里的 RealnameService 就是咱们的防腐层。”

代码如下:


public class RegistrationServiceImpl implements RegistrationService {
    private final SalesRepMapper salesRepDAO;
    private final UserMapper userDAO;
    private final RewardMapper rewardDAO;
    private final RealnameService realnameService;
    private final RiskControlService riskControlService;

    public User register(String name, PhoneNumber phone) throws ValidationException {RealnameInfo realnameInfo = realnameService.get(phone);
        realnameInfo.check(name);

        // 计算用户标签
        String label = getLabel(rnInfoDTO);
        // 计算销售组
        String salesRepId = getSalesRepId(phone);
        // 结构 User 对象和 Reward 对象
        String idCard = realnameInfo.getIdCard();
        UserDO userDO = new UserDO(idCard, name, phone.getNumber(), label, salesRepId);
        RewardDO rewardDO = new RewardDO(idCard, label);

        // 风控逻辑
        if (!riskControlService.check(idCard, label)) {userDO.setNew(true);
            rewardDO.setAvailable(false);
        } else {userDO.setNew(false);
            rewardDO.setAvailable(true);
        }
        // 长久化数据
        rewardDAO.insert(rewardDO);
        return userDAO.insert(userDO);
    }
}

问题 2 >> 逻辑凌乱

public class RegistrationServiceImpl implements RegistrationService {
    private final UserRepository userRepository;
    private final RewardRepository rewardRepository;
    private final RealnameService realnameService;
    private final RiskControlService riskControlService;

    public User register(String name, PhoneNumber phone) {
        // 查问实名信息
        RealnameInfo realnameInfo = realnameService.get(phone);

        // 构建对象
        User user = new User(realnameInfo, phone);
        Reward reward = new Reward(user);

        // 查看风控
        if (!riskControlService.check(user)) {user.fresh();
            reward.inavailable();}
        // 长久化
        rewardRepository.save(reward);
        return userRepository.save(user);
    }
}

Register 办法的语义,只是注册。在最后的代码中,Register 办法中曾经耦合了许多不属于「注册」这个业务域须要关怀的逻辑,这为后边的业务批改无形中削减了不少工作量。

“由外部业务逻辑变动所导致的外部零碎的革新水平,咱们能够广义的了解为零碎的可扩展性”

回到这个例子,注册的内部逻辑曾经进行了肯定水平的解耦,但它仍然不纯正:「奖品对象」和「风控查看」为什么会耦合在注册逻辑中呢?如果,之后的发奖逻辑产生变动,注册办法还要批改吗?

咱们持续思考:

  • 发奖是为了给予新用户一些福利,实质是判断以后用户是否为新用户
  • 在注册这个业务域中,咱们将它的行为形象为「获取用户信息」,「查看并更新用户」,「存储用户信息」,这三个步骤不无不合理之处
  • 在「查看并更新用户」这个逻辑中,存在发奖这种衍生行为与其余可能的行为

理清逻辑后,咱们来看下最终代码:

public interface CheckUserService {void check(User user);
}

public class CheckUserServiceImpl implements CheckUserService {
    private final RiskControlService riskControlService;
    private final RewardRepository rewardRepository;

    @Override public void check(User user) {rewardCheck(user);
        // ...
        // 可能存在的其余逻辑
    }

    private void rewardCheck(User user) {Reward reward = new Reward(user);
        // 查看风控
        if (!riskControlService.check(user)) {user.fresh();
            reward.inavailable();}
        rewardRepository.save(reward);
    }
}

这里须要留神一点:CheckUserService 中进行查看时,可能会扭转 User 对象和 Reward 对象的状态,波及到了多个 Entity 状态扭转的服务,被称为 Domain Service。Domain Service 次要用于封装多 Entity 或跨业务域的逻辑。

依据最终的代码块显示,外围逻辑已清晰,可维护性和可扩展性也大大加强 √

来说下单元测试

革新前的代码,多个业务逻辑耦合在一起;

革新后的代码,通过对业务逻辑的解耦,测试用例变得更容易保护。

随时间推移,迭代增多,单元测试会破费更少的精力,取得更高的单元测试覆盖率。这就是逻辑内聚 & 解耦给单元测试所带来的益处。

代码革新前:

代码革新后:

演绎概念

>> DP

:形象并封装自检和一些隐性属性的计算逻辑,这些属性是无状态的

>> Entity

:形象并封装单对象有状态的逻辑

>> Domain Service

:形象并封装多对象的有状态逻辑

>> Repository

:形象并封装内部数据拜访逻辑

>> 畛域驱动设计的指导思想

  • 首先对业务问题进行总览
  • 而后对畛域对象 -Entity 进行划分,明确每个畛域对象蕴含的信息和职
  • 责边界,进行跨对象,多对象的逻辑组织 -Domain Service
  • 接着在下层利用中依据业务形容去编排 -Entity & Domain Service
  • 最初做一些基础设施工作:对上层的数据拜访,RPC 调用去做一些具体实现

值得阐明的一点是:在实际工作过程中,DDD 只是对咱们进行一种领导,咱们不用循序渐进,全盘照抄上述这种设计规范,但要遵循 「高内聚低耦合」 的思维, 对边界的划分与管制是畛域驱动设计强调的核心思想。

架构

DDD 的一大益处是它不须要应用某些特定的架构。

因为外围域位于限界上下文中,咱们能够在整个零碎中应用多种格调的架构。有些架构突围着畛域模型,可能全局性地影响零碎; 有些架构满足了某些特定的需要。咱们的指标是 抉择适宜本人的架构和架构模式。

这里咱们简略的介绍两种在 DDD 落地过程中比拟实用的架构格调:

>> Clean Architecture

指标:框架无关、可测试、UI 无关、数据库无关、内部代理无关

COLA Architecture

COLA 代表了简洁的业务思维:

1、COLA 是一种架构思维,是整合了洋葱圈架构、适配器架构、DDD、整洁架构、TMF 等架构思维的一种利用架构;2、COLA 也是框架组件。

利用架构的实质,就是要从繁冗的业务零碎中提炼出共性,找到解决业务问题的最佳模式,为开发人员提供对立的认知,治理凌乱。“从凌乱到有序”= 定义良好的利用构造,提供最佳实际。

畛域驱动设计入门与实际「上 & 下」至此完结。全文作者:nerd4me,一枚优良的程序员小编~

正文完
 0