上期对 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,一枚优良的程序员小编~