DDD架构设计

今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步,因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。

  在做架构设计时,一个好的架构应该需要实现以下几个目标:

  • 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
  • 独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
  • 独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
  • 可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。

这就好像是建筑中的楼宇,一个好的楼宇,无论内部承载了什么人、有什么样的活动、还是外部有什么风雨,一栋楼都应该屹立不倒,而且可以确保它不会倒。但是今天我们在做业务研发时,更多的会去关注一些宏观的架构,比如SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现。

1 . 传统Springboot MVC架构设计弊端

样例:

以微信支付收单为例,按照业务拆分,其业务需求可能会被如下拆分:

1. 从数据库获取数据,比如查看订单数据是否存在

2.业务参数校验

3.获取服务费,计算金额

4.微信下单

5.数据保存

6.发送消息

伪代码如下所示:

public class TransferServiceImpl implements TransferService {

    private OrderMapper orderDao;
    private WxForexService WxForex;

    @Override
    public Result<Boolean> transfer(String orderId, BigDecimal amount, String storeId) {
        // 1. 从数据库读取数据
        OrderDo targetOrderDo = orderDao.selectByOrderId(orderId);

        // 2. 业务参数校验
        if (amount != null) {
            throw new InvalidAmountException();
        }

        // 3. 获取服务费,计算金额
        BigDecimal exchangeRate = WxForex.getServiceRate(amount, storeId);
        BigDecimal newTarget = serviceFeeAmount.add(amount);
		// 4. 微信下单
		wxPay.orderPlacement(param);

        // 5. 更新到数据库
        orderDao.update(targetOrderDo);
        
        // 6. 发送消息
        mq.push(message);

        return Result.success(true);
    }

}

可以看到,一段业务代码里经常包含了参数校验、数据读取、业务计算、调用外部服务等多种逻辑。在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。

问题1-可维护性能差

一个应用最大的成本一般不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。

**可维护性 = 当依赖变化时,有多少代码需要随之改变**

参考以上的案例代码,上述脚本类的代码很难维护因为以下几点:

数据结构的不稳定性:OrderDO类是一个纯数据结构,映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做Sharding,或者换一个表设计,或者改变字段名。

依赖库的升级:AccountMapper依赖MyBatis的实现,如果MyBatis未来升级版本,可能会造成用法的不同(可以参考iBatis升级到基于注解的MyBatis的迁移成本)。同样的,如果未来换一个ORM体系,迁移成本也是巨大的。

计算逻辑的的不确定性:比如微信的服务费率计算未来很有可能会有变化,而且下单的服务费率也不仅仅是微信的,后面可能还需要计算支付宝的,甚至整个服务费率计算逻辑会推倒重来。

第三方服务API的接口变化:wxPay下单接口能保证不会变化吗?参数如果不是param怎么办??谁能保证未来接口不会改变?如果改变了,后续逻辑是不是y。

中间件更换:今天我们用Kafka发消息,明天如果要上阿里云用RocketMQ该怎么办?后天如果消息的序列化方式从String改为Binary该怎么办?

我们发现案例里的代码对于任何外部依赖的改变都会有比较大的影响。如果你的应用里有大量的此类代码,你每一天的时间基本上会被各种库升级、依赖服务升级、中间件升级、jar包冲突占满,最终这个应用变成了一个不敢升级、不敢部署、不敢写新功能、并且随时会爆发的炸弹,终有一天会给你带来惊喜。

问题2-可拓展性差

脚本式代码的第二大缺陷是:虽然写单个用例的代码非常高效简单,但是当用例多起来时,其扩展性会变得越来越差。

参考以上的代码,如果今天需要增加一个支付宝支付下单的能力,会发现基本上需要重新开发,基本上没有任何的可复用性。

在脚本式的架构下,一般做第一个需求都非常的快,但是做第N个需求时需要的时间很有可能是呈指数级上升的,绝大部分时间花费在老功能的重构和兼容上,最终你的创新速度会跌为0,促使老应用被推翻重构。

问题3-可测试性能差

除了部分工具类、框架类和中间件类的代码有比较高的测试覆盖之外,我们在日常工作中很难看到业务代码有比较好的测试覆盖,而绝大部分的上线前的测试属于人肉的“集成测试”。低测试率导致我们对代码质量很难有把控,容易错过边界条件,异常case只有线上爆发了才被动发现。而低测试覆盖率的主要原因是业务代码的可测试性比较差。

参考以上的一段代码,这种代码有极低的可测试性,代码的修改往往是牵一发而动全身。

2. 软件设计原则:

分析一下为什么以上的问题会出现?因为以上的代码违背了至少以下几个软件设计的原则:

  • 单一性原则(Single Responsibility Principle):单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里,代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。
  • 依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现.
  • 开放封闭原则(Open Closed Principle):当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现。以上设计原则,几乎都需要通过接口实现,所以面向接口编程在软件设计中显得尤为重要,什么时候需要设计为接口,需依照以上设计原则。

我们需要对代码重构才能解决这些问题。

3. DDD概述

DDD (Domain Driven Design) 称为领域驱动设计,是一种架构设计思想,不同于设计模式,它是针对软件整体设计,用于实现“高内聚、低耦合”,而设计模式是针对某一特定场景实现的专属设计。DDD是服务于业务的,最好能达到不同开发或者业务经理 查看主流程代码,能够一眼看懂主要做了什么。

领域驱动设计(DDD)中采用的是松散分层架构,层间关系不那么严格。每层都可能使用它下面所有层的服务,而不仅仅是下一层的服务。每层都可能是半透明的,这意味着有些服务只对上一层可见,而有些服务对上面的所有层都可见。

分层的作用,从上往下:

  • 用户交互层:web 请求,rpc 请求,mq 消息等外部输入均被视为外部输入的请求,可能修改到内部的业务数据。
  • 业务应用层:与 MVC 中的 service 不同的不是,service 中存储着大量业务逻辑。但在应用服务的实现中,它负责编排、转发、校验等。
  • 领域层:或称为模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手。
  • 基础设施层:主要有 2 方面内容,一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;一是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现。

DDD常见概念:

实体: 包含属性以及行为操作的个体,比如Account实体,它包含了转账金额、币种、余额等属性以及转入、转出等操作。通过封装实体,可以将数据的任何操作都统一到实体类中实现内聚。同时实体和数据库字段无关,降低了数据库和实体的耦合

领域服务: DDD中代表特定服务,比如订单等。

防腐层:防止接口功能被污染,具体见下面解释

仓库: 和数据库关联,用于处理数据库数据。

工厂: 通过工厂模式设计加工具体的实现类

4. 使用DDD重构

 抽象数据存储层

第一步常见的操作是将Data Access层做抽象,降低系统对数据库的直接依赖。具体的方法如下:

新建OrderDo表示订单的实体,和数据库订单表映射,当数据库表字段改变时,同步修改OrderDo。同时创建

具体的简单代码实现如下:

public interface OrderInfoRepository {
    int createOrder(Order order);

    boolean existOrder(String orderId,String storeId);
}


@Repository("eastpayOrderInfoRepository")
public class EastpayOrderInfoRepository implements OrderInfoRepository{
    private static final Logger LOG = LoggerFactory.getLogger(EastpayOrderInfoRepository.class);

    @Resource
    private OrderMapper orderMapper;

    @Override
    public int createOrder(Order order) {
        int count =  orderMapper.insert(convertBean2OrderDo(order));
        LOG.info("Succeed to insert order info");
        return count;
    }

    @Override
    public boolean existOrder(String orderId,String storeId) {
        LambdaQueryWrapper<OrderDo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(OrderDo::getOrderId, orderId);
        queryWrapper.eq(OrderDo::getStoreId, storeId);
        long count = orderMapper.selectCount(queryWrapper);
        // 如果查询到的记录数大于 0,表示订单存在
        return count > 0;
    }

  
    private OrderDo convertBean2OrderDo(Order order) {
        Date now = new Date();
        String dateTime = DateUtils.dateTime(now);
        ZoneId japanZoneId = ZoneId.of("Asia/Tokyo");
        ZonedDateTime japanTime = ZonedDateTime.now(japanZoneId);
//        BigDecimal exchangeRate = new BigDecimal(WechatpayqueryExchangeRateHandler.map.get("exchangeRate")).
//                divide(BigDecimal.TEN.pow(8));
        OrderDo orderDo = OrderDo.builder()
                .orgOrderId(order.getOrderId())
                .operationType(ApiOperation.THIRD_ORDER_PLACEMENT.code)
                .id(order.getId())
                .storeId(order.getStoreId())
                .orderId(order.getOrderId())

                .createDate(DateUtils.date(now))
                .trxnTime(dateTime)
                .oTrxnTime(japanTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")))
                .channelId(order.getChannelId())
                .subChannelId(order.getSubChannelId())
                .trxnType(PaymentType.DB.name())
                .bgRetUrl(order.getBgRetUrl())
                .trxnCurrCode(order.getTrxnCurrCode())
                .trxnCurrAmt(order.getTrxnCurrAmt())
                .status(order.getOrderStatus())
                .errorCode(order.getGatewayErrCode())
                .errorMsg(order.getGatewayErrMsg())
                .payCurrCode(order.getPayCurrCode())
                .userId(order.getUserId())
                .serviceFee(order.getServiceFee())
                .productPrice(order.getProductPrice())
                .language("cn")
                .payType(order.getPayType())
//                .moMsgVersion(exchangeRate.toString())
                .orderDesc(order.getOrderDesc())
                .isSplitPay(String.valueOf(order.getIsSplitPay()))
                .serviceFeeRate(order.getServiceFeeRate())
                .emailStatus(ConstantsUtils.EMAIL_SENT)
                .origin(order.getOrigin())
                .build();
        return orderDo;
    }
}

Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。

通过Repository,改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。

Repository作为一个接口类,可以比较容易的实现Mock或Stub,可以很容易测试。
EastpayOrderInfoRepository实现类,由于其职责被单一出来,只需要关注其到AccountDO的映射关系和Repository方法到DAO方法之间的映射关系,相对于来说更容易测试。

抽象外部服务

类似对于数据库的抽象,所有外部服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。在这个例子里我们抽象出IServiceFeeInfoService的服务,和一个ServiceFeeInfoService实现类(自己读取数据库实现的,实际通过调用第三方实现):

public interface IServiceFeeInfoService {
    Map<String, BigDecimal> obtainServiceFeeInfo(Order order);
}



@Service
public class ServiceFeeInfoService implements IServiceFeeInfoService{
    @Resource(name = "datasourceServiceFeeRateRepository")
    private ServiceFeeRateRepository serviceFeeRateRepository;

    @Override
    public Map<String, BigDecimal> obtainServiceFeeInfo(Order order) {
        BigDecimal amount = order.getProductPrice();
        String merchantId = order.getStoreId();
        String payType = order.getPayType();
        String channel = order.getChannelId();
        MdiscxDo mdiscxDo = serviceFeeRateRepository.obtainServiceFeeRate(amount,merchantId,payType,channel);
        BigDecimal serviceFeeRate = BigDecimal.ZERO;

        BigDecimal amountRegion1 =  Optional.ofNullable(mdiscxDo.getMdTierAmt1()).map(s -> new BigDecimal(s.toString())).orElse(null);
        BigDecimal amountRegion2 =  Optional.ofNullable(mdiscxDo.getMdTierAmt2()).map(s -> new BigDecimal(s.toString())).orElse(null);
        BigDecimal amountRegion3 =  Optional.ofNullable(mdiscxDo.getMdTierAmt3()).map(s -> new BigDecimal(s.toString())).orElse(null);

        BigDecimal serviceFeeRate1 = Optional.ofNullable(mdiscxDo.getMdPercentage1()).map(s -> new BigDecimal(s.toString())).orElse(BigDecimal.ZERO);
        BigDecimal serviceFeeRate2 = Optional.ofNullable(mdiscxDo.getMdPercentage2()).map(s -> new BigDecimal(s.toString())).orElse(BigDecimal.ZERO);
        BigDecimal serviceFeeRate3 = Optional.ofNullable(mdiscxDo.getMdPercentage3()).map(s -> new BigDecimal(s.toString())).orElse(BigDecimal.ZERO);
        BigDecimal serviceFeeLimitAmt = Optional.ofNullable(mdiscxDo.getMdMaxAmt()).map(s -> new BigDecimal(s.toString())).orElse(null);
        if (amountRegion3 != null && amount.compareTo(amountRegion3) >= 0) {
            serviceFeeRate = serviceFeeRate3;
        } else if (amountRegion2 != null && amount.compareTo(amountRegion2) >= 0) {
            serviceFeeRate = serviceFeeRate2;
        } else if (amountRegion1 != null && amount.compareTo(amountRegion1) >= 0) {
            serviceFeeRate = serviceFeeRate1;
        }

        String formatFee = ApiUtils.formatAmount(amount.multiply(serviceFeeRate, MathContext.DECIMAL32), order.getTrxnCurrCode());
        BigDecimal serviceFee = serviceFeeLimitAmt.compareTo(new BigDecimal(formatFee)) <= 0 ? serviceFeeLimitAmt : new BigDecimal(formatFee);
        BigDecimal totalAmount = amount.add(serviceFee);

        Map<String,BigDecimal> res = new HashMap<>();
        res.put(ApiFields.SERVICE_FEE_RATE,serviceFeeRate);
        res.put(ApiFields.SERVICE_FEE_LIMIT_AMT,serviceFeeLimitAmt);
        res.put(ApiFields.SERVICE_FEE,serviceFee);
        res.put(ApiFields.TRANSACTION_AMOUNT,totalAmount);
        return res;
    }
}

 防腐层(ACL)

这种常见的设计模式叫做Anti-Corruption Layer(防腐层或ACL)。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。

ACL 不仅仅只是多了一层调用,在实际开发中ACL能够提供更多强大的功能:

适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。

兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。

易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。

抽象中间件

类似于抽象外部服务,抽象中间件的做法也大致相同,在此不做赘述。

接口参数隔离

在很多时候,程序员开发的下单接口并不由自己决定,尤其是参数规则,需要根据合作方的需求来调整,往往是A合作方要求接口需要使用AParam接受,B合作方要求接口使用BParam接受,这时如果没有对下单接口的参数进行隔离,就会发现接口难以维护了。

比如,使用下单接口,我们使用的是OrderPlacementDto接受外部的请求参数,并且将此param传递到后续所有业务逻辑,包括参数校验、服务费率获取、金额计算、数据保存等等。这时当需要对OrderPlacementDto进行修改,你会发现项目处处都是修改点,不利于维护和测试。

针对此场景,我们可以新建系统自己的业务实体类Order,当接收到下单请求时,将参数先转换为Order,这样不管合作方要求我们如何修改接口入参,我们只需要修改由OrderPlacementDto --> Order的转换逻辑就行。

5. 总结

  DDD 强调技术主动理解业务,业务如何实现,程序就如何构建。 DDD 强调在对职责进行清晰地划分 后,让技术只要“ 刚刚好 的解决业务问题即可。至于以后的需求变更,可以从领域划分的角度,拆分落 实到不同的领域,然后交由各个领域分工合作。此时各种各样的设计模式、工具、框架等,就不再是解决业务的核心,而是随手拈来的工具。
  这里由于问题域是未知的,所以肯定也没有办法说一下就能提供一个完美的解决方案。但是,有六个字你一定很熟悉。高内聚,低耦合 。让整个系统内的各个业务模块功能尽量内聚,业务模块与业务模块 之间减少相互依赖。这样即使面对更复杂的问题,也可以通过将问题拆解成为多个业务模块之间的协作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值