Java微服务-领域驱动模型设计DDD学习总结-充血模型

createh52个月前 (02-21)技术教程23

Java微服务-领域驱动模型设计DDD学习总结-充血模型

What

面向对象编程(Object Oriented Programming - OOP):一种编程范式或编程风格,以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。

贫血模型:数据和业务逻辑被分隔到不同的类中。数据与操作分离,破坏了面向对象的封装特性,是典型的面向过程的编程风格。

充血模型:数据和对应的业务逻辑被封装到同一个类(领域模型)中。满足面向对象的封装特性,是典型的面向对象编程风格。

领域驱动设计(Domain Driven Design - DDD):一种设计思想,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。微服务就是一种典型的实践。

Why

基于充血模型的 DDD 开发模式相较于贫血模式的开发模式,优势在哪儿?

我们平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。service中的业务逻辑或sql基本都是针对特定的嗯业务功能编写的,复用性差。对于简单业务系统来说,这种开发方式问题不大。但对于复杂业务系统的开发来说,这样的开发方式会让代码越来越混乱,最终导致无法维护。

基于充血模型的 DDD 开发模式用类来描述业务模型(数据和功能),把原来又重又凌乱的service层逻辑拆分并转移至各领域(Domain)类内,对不同业务功能的数据和方法进行封装,提升了代码内聚性和复用性,也提高了代码可读性。由于类等同于业务模型,在功能不断迭代后就不会像贫血模型的service一样变得杂乱、模糊、难以维护,整个系统的代码看起来层次清晰,阅读逻辑简单易懂,也方便新人快速上手了解系统逻辑。无论是开发新功能还是老功能的迭代,我们优先考虑的是领域类如何定义、修改,因此称之为领域驱动设计。

简而言之:提升了代码的复用性、扩展性、可维护性、可读性。这对于复杂系统十分重要。

为什么几乎所有Web项目都基于贫血模型开发?

原因1:大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于 SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便我们使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义。

原因2:充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。

原因3:思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。你随便问一个旁边的大龄同事,基本上他过往参与的所有 Web 项目应该都是基于这个开发模式的,而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计,那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的。

什么项目应该考虑使用基于充血模型的 DDD 开发模式?

复杂业务系统、非业务系统、框架、工具等。

越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。

对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。

引用:
“工作中遇到非crud的需求我就会想尽一切办法让他通用,基本需求分析和需求设计的时间占用百分之五十,开发和重构到自认为最优占用百分之五十。。。(略)总之我认为如果有机会遇到非crud的需求,一定要好好珍惜,好好把握,把他打造成属于自己的产品,这样会让自己下意识的去想尽一切办法把他做到最优,亲儿子一样的待遇,再也不会无脑cv,连变量名可能都要认真的重构一两遍”

HOW DDD

思考模式:面向过程 → 面向对象

面向过程:一个功能,由上至下,先做什么 、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。

面向对象:一个功能,由下至上,先将任务拆解成一个个小模块(就是类,领域模型),设计类之间 的交互,最后按照流程将 类组装起来,完成整个任务。

需要涉及经验和技巧:如何封装合适的数据和方法到一个类中,如何涉及类之间的关系和交互等等。

面向对象分析(Object Oriented Analysis - OOA):需求分析

面向对象设计(Object Oriented Design - OOD):产出系统和类设计,包含属性、方法、类之间如何交互

  • 划分职责进而识别出有哪些类;方式一:基于需求中出现的名词筛选方式二:基于需求中的功能点归类
  • 定义类及其属性和方法;名词作属性,动词作方法
  • 定义类与类之间的交互关系;继承实现组合依赖
  • 将类组装起来并提供执行入口;

每个人的设计结果都可能不太一样,需要反复迭代、重构、打破重写,这个过程也是软件开发的本质。上面只是指导思想不用照搬,熟练了基本就凭感觉。

Service类的职责是什么?

  • 与Repository 交流。使domain与数据层、开发框架(spring、mybatis)解耦,保证domain的复用性。
  • 跨领域模型的业务聚合功能。保证domain之间的独立性,也是解耦。如果业务的聚合变得复杂,我们还可以考虑复杂的聚合功能是否可以独立成一个领域模型。
  • 一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。

Controller 层和 Repository 层是否有必要也进行充血领域建模?
没有必要。
Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。
就拿 Repository 的 Entity 来说,即便它被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但 Entity 的生命周期是有限的。一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改。
我们再来说说 Controller 层的 VO。实际上 VO 是一种 DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也是比较合理的。

示例

示例1 - 虚拟钱包(业务)

/**
  充血
**/

public class VirtualWallet {
  private Long id;
  private Long createTime = System.currentTimeMillis();;
  private BigDecimal balance = BigDecimal.ZERO;
  private boolean isAllowedOverdraft = true;
  private BigDecimal overdraftAmount = BigDecimal.ZERO;
  private BigDecimal frozenAmount = BigDecimal.ZERO;
  
  public VirtualWallet(Long preAllocatedId) {
    this.id = preAllocatedId;
  }
  
  public void freeze(BigDecimal amount) { ... }
  public void unfreeze(BigDecimal amount) { ...}
  public void increaseOverdraftAmount(BigDecimal amount) { ... }
  public void decreaseOverdraftAmount(BigDecimal amount) { ... }
  public void closeOverdraft() { ... }
  public void openOverdraft() { ... }
  
  public BigDecimal balance() {
    return this.balance;
  }
  
  public BigDecimal getAvaliableBalance() {
    BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
    if (isAllowedOverdraft) {
      totalAvaliableBalance += this.overdraftAmount;
    }
    return totalAvaliableBalance;
  }
  
  public void debit(BigDecimal amount) {
    BigDecimal totalAvaliableBalance = getAvaliableBalance();
    if (totoalAvaliableBalance.compareTo(amount) < 0) {
      throw new InsufficientBalanceException(...);
    }
    this.balance.subtract(amount);
  }
  
  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException(...);
    }
    this.balance.add(amount);
  }
}



/* 贫血*/

public class VirtualWalletController {
  // 通过构造函数或者IOC框架注入
  private VirtualWalletService virtualWalletService;
  
  public BigDecimal getBalance(Long walletId) { ... } //查询余额
  public void debit(Long walletId, BigDecimal amount) { ... } //出账
  public void credit(Long walletId, BigDecimal amount) { ... } //入账
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //转账
}


public class VirtualWalletBo {//省略getter/setter/constructor方法
  private Long id;
  private Long createTime;
  private BigDecimal balance;
}

public class VirtualWalletService {
  // 通过构造函数或者IOC框架注入
  private VirtualWalletRepository walletRepo;
  private VirtualWalletTransactionRepository transactionRepo;
  
  public VirtualWalletBo getVirtualWallet(Long walletId) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWalletBo walletBo = convert(walletEntity);
    return walletBo;
  }
  
  public BigDecimal getBalance(Long walletId) {
    return walletRepo.getBalance(walletId);
  }
  
  public void debit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    BigDecimal balance = walletEntity.getBalance();
    if (balance.compareTo(amount) < 0) {
      throw new NoSufficientBalanceException(...);
    }
    walletRepo.updateBalance(walletId, balance.subtract(amount));
  }
  
  public void credit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    BigDecimal balance = walletEntity.getBalance();
    walletRepo.updateBalance(walletId, balance.add(amount));
  }
  
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setFromWalletId(fromWalletId);
    transactionEntity.setToWalletId(toWalletId);
    transactionEntity.setStatus(Status.TO_BE_EXECUTED);
    Long transactionId = transactionRepo.saveTransaction(transactionEntity);
    try {
      debit(fromWalletId, amount);
      credit(toWalletId, amount);
    } catch (InsufficientBalanceException e) {
      transactionRepo.updateStatus(transactionId, Status.CLOSED);
      ...rethrow exception e...
    } catch (Exception e) {
      transactionRepo.updateStatus(transactionId, Status.FAILED);
      ...rethrow exception e...
    }
    transactionRepo.updateStatus(transactionId, Status.EXECUTED);
  }
}


/**  Domain领域模型(充血模型) **/
public class VirtualWallet { // Domain领域模型(充血模型)
  private Long id;
  private Long createTime = System.currentTimeMillis();;
  private BigDecimal balance = BigDecimal.ZERO;
  
  public VirtualWallet(Long preAllocatedId) {
    this.id = preAllocatedId;
  }
  
  public BigDecimal balance() {
    return this.balance;
  }
  
  public void debit(BigDecimal amount) {
    if (this.balance.compareTo(amount) < 0) {
      throw new InsufficientBalanceException(...);
    }
    this.balance.subtract(amount);
  }
  
  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException(...);
    }
    this.balance.add(amount);
  }
}

public class VirtualWalletService {
  // 通过构造函数或者IOC框架注入
  private VirtualWalletRepository walletRepo;
  private VirtualWalletTransactionRepository transactionRepo;
  
  public VirtualWallet getVirtualWallet(Long walletId) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    return wallet;
  }
  
  public BigDecimal getBalance(Long walletId) {
    return walletRepo.getBalance(walletId);
  }
  
  public void debit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.debit(amount);
    walletRepo.updateBalance(walletId, wallet.balance());
  }
  
  public void credit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.credit(amount);
    walletRepo.updateBalance(walletId, wallet.balance());
  }
  
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
    //...跟基于贫血模型的传统开发模式的代码一样...
  }
}

示例2 - 接口鉴权(非业务)

需求:“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”

定义类(领域模型)

需求分析

第一轮:appid+password,明文传输。问题:明文容易被截获,不安全。

第二轮:url+appid+password生成token(sign),传url+appid+token。问题:每个url的token固定,容易被截获后伪装请求,即重放攻击。

第三轮:url+appid+password+timestamp生成token(sign),传url+appid+token+timestamp,超过限定时间的请求被视为无效请求。问题:appid、用户的密码存哪儿?

第四轮:针对 AppID 和密码的存取,使用接口进行解耦,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

确定需求:

  • 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
  • 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
  • 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
  • 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。

功能点列表:

  1. 把 URL、AppID、密码、时间戳拼接为一个字符串;
  2. 对字符串通过加密算法加密生成 token;
  3. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
  4. 解析 URL,得到 token、AppID、时间戳等信息;
  5. 从存储中取出 AppID 和对应的密码;
  6. 根据时间戳判断 token 是否过期失效;
  7. 验证两个 token 是否匹配;

从上面的功能列表中,我们发现,1、2、6、7 都是跟 token 有关,负责 token 的生成、验证;3、4 都是在处理 URL,负责 URL 的拼接、解析;5 是操作 AppID 和密码,负责从存储中读取 AppID 和密码。

所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。

  • AuthToken 负责实现 1、2、6、7 这四个操作;
  • Url 负责 3、4 两个操作;
  • CredentialStorage 负责 5 这个操作。

定义类的属性和方法

AuthToken

  • 把 URL、AppID、密码、时间戳拼接为一个字符串;
  • 对字符串通过加密算法加密生成 token;
  • 根据时间戳判断 token 是否过期失效;
  • 验证两个 token 是否匹配。

Url

  • 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
  • 解析 URL,得到 token、AppID、时间戳等信息。

CredentialStorage

  • 从存储中取出 AppID 和对应的密码。

定义类与类之间的交互关系

MysqlCredentialStorage 实现 CredentialStorage接口

将类组装起来并提供执行入口

定义ApiAuthenticator 接口,提供给网关过滤器调用

/*
	(鉴权 - 充血模型)ApiAuthenticator.java
**/
package com.heytea.manager.payment.utils;

import sun.plugin2.main.server.AppletID;

import java.util.Map;

public interface ApiAuthenticator {
    void auth(String url);

    void auth(ApiRequest apiRequest);
}

class AuthToken {
    private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1 * 60 * 1000;
    private String token;
    private long createTime;
    private long expriredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;

    public AuthToken(String token, long createTime) {
        this.token = token;
        this.createTime = createTime;
    }

    public AuthToken(String token, long createTime, long expriredTimeInterval) {
        this.token = token;
        this.createTime = createTime;
        this.expriredTimeInterval = expriredTimeInterval;
    }

    public static AuthToken generate(String baseUtl, long createTime, Map params) {
        return null;
    }

    public String getToken() {
        return null;
    }

    public boolean isExpired() {
        return false;
    }

    public boolean match(AuthToken authToken) {
        return false;
    }
}

@Getter
class ApiRequest {
    private String baseUrl;
    private String token;
    private String appId;
    private long timestamp;

    public ApiRequest(String baseUrl, String token, String appId, long timestamp) {
        this.baseUrl = baseUrl;
        this.token = token;
        this.appId = appId;
        this.timestamp = timestamp;
    }

    public static ApiRequest buildFromUrl(String url) {
        return null;
    }

}

public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
    private CredentialStorage credentialStorage;

    public DefaultApiAuthenticator() {
        this.credentialStorage = new MysqlCredentialStorage();
    }

    public DefaultApiAuthenticator(CredentialStorage credentialStorage) {
        this.credentialStorage = credentialStorage;
    }

    @Override
    public void auth(String url) {
        ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
        auth(apiRequest);
    }

    @Override
    public void auth(ApiRequest apiRequest) {
        String appId = apiRequest.getAppId();
        String token = apiRequest.getToken();
        long timestamp = apiRequest.getTimestamp();
        String originalUrl = apiRequest.getBaselUrl();

        AuthToken clientAuthToken = new AuthToken(token, timestamp);
        if (clientAuthToken.isExpired()) {
            throw new RuntimeException("Token is expired.");
        }

        String password = credentialStorage.getPasswordByAppId(appId);
        AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
        if (!serverAuthToken.match(clientAuthToken)) {
            throw new RuntimeException("Token verfication failed.");
        }
    }
}

相关文章

重磅发布!AI 驱动的 Java 开发框架:Spring AI Alibaba

本文作者系阿里云云原生微服务技术负责人,Spring AI Alibaba 发起人彦林,望陶和隆基对可观测和 RocketMQ 部分内容亦有贡献。摘要随着生成式 AI 的快速发展,基于 AI 开发框架...

Java,事件驱动,Reactor设计模式,反应器设计模式

前言服务器端处理WEB请求,通常有两种体系结构:1、基于线程————thread-based architecture2、事件驱动————event-driven architecture基于线程(t...

Linux系统移植之—Nand flash驱动编写与移植,学Linux的先收藏

Linux系统要跑起来,除了上几章节讲到的uboot、kernel、文件系统的移植,还有一个不可缺少的——Nand flash驱动的移植,搞linux的或者android底层驱动的先收藏,以后工作中会...

国能智深控制技术申请 Matlab 驱动加载专利,提高了兼容性

金融界 2024 年 9 月 18 日消息,天眼查知识产权信息显示,国能智深控制技术有限公司申请一项名为“Matlab 驱动加载方法、装置、存储介质及电子设备“,公开号 CN202410689145....

经典书单 ,最受程序员推荐的Java书籍,值得阅读

点击上方?,轻松关注!及时获取有趣有料的技术文章书籍绝对是一种很好的学习方式,将它们和文章、教程和视频结合使用,你一定会有一个事半功倍的效果Java是最重要的编程语言之一,关于Java编程的书籍并不少...

JAVA开发,使用Cursor,真的香啊!

JAVA开发,使用Cursor,真的香啊!Cursor IDE:人工智能驱动的Java编程新纪元在当今快速发展的技术环境中,编程工具的革新对于提升开发者效率和代码质量至关重要。Cursor IDE,作...