使用java和spring来实现六边形架构

“六角形架构”已经存在很长时间了,的确相当长的时间了,这个玩意儿从主流架构中消失了很久,直到最近才慢慢的才回到大众视野里。

但是,我发现关于如何用这种架构在实际应用程序的项目很少。本文的目的是提供一种用Java和Spring来实现六边形架构的Web应用程序。

如果您想更深入地研究该主题,请看一下我的书

例子代码

本文的代码示例在github

什么是六边形架构?

与常见的分层体系架构相反,“六角形架构”的主要特征是组件之间的依赖关系“指向内部”,指向我们的领域对象:


图片.png

六边形只是一种描述应用程序核心的方法,该应用程序由领域对象,用例(Use Case),以及为外界提供接口的输入和输出端口组成。

我们先来对这种架构的每一层进行学习吧。

领域对象

在拥有业务规则的域中,域对象是应用程序的命脉。域对象了包含状态和行为。行为越接近状态,代码将越容易理解,维护。

域对象没有任何外部依赖性。它们是纯Java code,并用例提供了API。

由于域对象不依赖于应用程序的其他层,因此其他层的更改不会对其产生影响。也就是他们的改变可以不用依赖其他层的代码。这是“单一责任原则”(“ SOLID”中的“ S”)的一个主要例子,该原则指出组件应该只有一个更改的理由。对于我们的域对象,须要改变的原因是业务需求的变化。

只需承担一项责任,我们就可以改变域对象,而不必考虑外部依赖关系。这种可扩展性使六角形架构风格非常适合您练习域驱动设计。在开发过程中,我们只是遵循自然的依赖关系流程:我们开始在域对象中进行编码,然后从那里开始。

用例

我们知道用例是用户使用我们的软件所做的抽象描述。在六角形体系架构中,将用例提升为我们代码库的一等公民是有意义的。

用例是一个处理特定场景所有内容的类。作为示例,我们考虑银行应用程序中的一个用例:“将钱从一个帐户发送到另一个帐户”。我们将创建一个API类SendMoneyUseCase,该API允许用户进行汇款。该代码包含特定于用例的所有业务规则验证和逻辑,因此无法在域对象中实现。其他所有内容都委托给域对象(例如,可能有一个域对象Account)。

与域对象类似,用例类不依赖于外部组件。当它需要六角形之外的东西时,我们创建一个输出端口。

输入输出端口

域对象和用例在六边形内,即在应用程序的核心内。他们每次与外部的通信都是通过专用的“端口”进行的。

输入端口是一个简单的接口,可由外部组件调用,并由用例实现。调用该类输入端口的组件称为输入适配器或“驱动”适配器。

输出端口还是一个简单的接口,如果我们的用例需要外部的东西(例如,数据库访问),则可以通过它们来调用。该接口目的是满足用例的需求,也称为输出或“驱动”适配器的外部组件实现。如果您熟悉SOLID原理,则这是Dependency Inversion Principle(SOLID中的应用),因为我们通过接口将依赖关系从用例转换为输出适配器。

有了适当的输入和输出端口,我们就有了不同的数据进入和离开我们的系统的地方,这使得对架构的推理变得容易。

适配器

适配器在六角形架构的外层。它们不是核心的一部分,但可以与之交互。

输入适配器或“驱动”适配器调用输入端口以完成操作。例如,输入适配器可以是Web界面。当用户单击浏览器中的按钮时,Web适配器将调用某个输入端口以调用相应的用例。

输出适配器或“驱动”适配器由我们的用例调用,例如,可能提供来自数据库的数据。输出适配器实现一组输出端口接口。请注意,接口由用例决定。

适配器使外部和应用程序的特定层交互变得很简单。如果该应用程序想在新的Web端使用,则可以添加新客户端输入适配器。如果应用程序需要其他数据库,则添加一个新的持久性适配器,该适配器保持与旧的持久性适配器实现相同的输出端口接口。

Show Code!

在简要介绍了上面的六边形架构之后,我们最后来看一些代码。将该体系结构样式的概念转换为代码时始终要遵循解释和风格,因此,请不要按照给定的以下代码示例进行操作,而应创建你自己的风格。

这些代码示例全部来自我在GitHub上的“ BuckPal”示例应用程序,并围绕着将资金从一个帐户转移到另一个帐户的用例进行讨论。出于此博客文章的目的,对某些代码段进行了稍微的修改。

构建领域对象

我们首先构建一个可以满足用例需求的领域对象。我们将创建一个Account类对一个帐户的取款和存款的管理:

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

  @Getter private final AccountId id;

  @Getter private final Money baselineBalance;

  @Getter private final ActivityWindow activityWindow;

  public static Account account(
          AccountId accountId,
          Money baselineBalance,
          ActivityWindow activityWindow) {
    return new Account(accountId, baselineBalance, activityWindow);
  }

  public Optional<AccountId> getId(){
    return Optional.ofNullable(this.id);
  }

  public Money calculateBalance() {
    return Money.add(
        this.baselineBalance,
        this.activityWindow.calculateBalance(this.id));
  }

  public boolean withdraw(Money money, AccountId targetAccountId) {

    if (!mayWithdraw(money)) {
      return false;
    }

    Activity withdrawal = new Activity(
        this.id,
        this.id,
        targetAccountId,
        LocalDateTime.now(),
        money);
    this.activityWindow.addActivity(withdrawal);
    return true;
  }

  private boolean mayWithdraw(Money money) {
    return Money.add(
        this.calculateBalance(),
        money.negate())
        .isPositiveOrZero();
  }

  public boolean deposit(Money money, AccountId sourceAccountId) {
    Activity deposit = new Activity(
        this.id,
        sourceAccountId,
        this.id,
        LocalDateTime.now(),
        money);
    this.activityWindow.addActivity(deposit);
    return true;
  }

  @Value
  public static class AccountId {
    private Long value;
  }

}

一个账户可以有许多相关的操作,每个操作代表该账户的取款或存款。由于我们并不总是希望加载给定帐户的所有操作,因此我们将其限制为特定的ActivityWindow。为了能够计算帐户的总余额,Account类拥有baselineBalance属性,该属性包含了操作窗口开始时帐户的余额。

如您在上面的代码中看到的,我们完全不依赖于其他层就构建了领域对象。我们可以按照自己认为合适的方式对代码进行建模,在这种情况下,可以创建一种非常接近模型状态的“丰富”行为,以便于理解。

如果你愿意,也可以在领域模型中使用外部库,但是这些依赖关系应该相对稳定,以防止强制更改我们的代码。例如,在上面的代码中,我们包含了Lombok库。

现在,Account 类允许我们将资金在一个帐户中进行取款和存入操作,但是我们希望在两个帐户之间转移资金。因此,我们创建了一个用例类来为我们完成这件事。

构建输入端口

但是,在实现用例之前,我们先为该用例创建外部API,它将成为六边形架构中的输入端口:

public interface SendMoneyUseCase {

  boolean sendMoney(SendMoneyCommand command);

  @Value
  @EqualsAndHashCode(callSuper = false)
  class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {

    @NotNull
    private final AccountId sourceAccountId;

    @NotNull
    private final AccountId targetAccountId;

    @NotNull
    private final Money money;

    public SendMoneyCommand(
        AccountId sourceAccountId,
        AccountId targetAccountId,
        Money money) {
      this.sourceAccountId = sourceAccountId;
      this.targetAccountId = targetAccountId;
      this.money = money;
      this.validateSelf();
    }
  }
}

通过调用sendMoney()方法,我们应用程序核心外部的适配器现在可以调用该用例。

我们将所需的所有参数汇总到SendMoneyCommand这个值对象中。这使我们可以在值对象的构造函数中进行输入验证。在上面的示例中,我们甚至使用Bean Validation的注解@NotNull,该方法已通过validateSelf()方法进行验证。这样,实际的用例代码就不会被嘈杂的验证代码所污染。

我们后面需要实现该接口就可以了.

构建Use Case和输出端口

在用例实现中,我们使用领域模型从源帐户中提取资金,并向目标帐户中存款:

@RequiredArgsConstructor
@Component
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

  private final LoadAccountPort loadAccountPort;
  private final AccountLock accountLock;
  private final UpdateAccountStatePort updateAccountStatePort;

  @Override
  public boolean sendMoney(SendMoneyCommand command) {

    LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);

    Account sourceAccount = loadAccountPort.loadAccount(
        command.getSourceAccountId(),
        baselineDate);

    Account targetAccount = loadAccountPort.loadAccount(
        command.getTargetAccountId(),
        baselineDate);

    accountLock.lockAccount(sourceAccountId);
    if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) {
      accountLock.releaseAccount(sourceAccountId);
      return false;
    }

    accountLock.lockAccount(targetAccountId);
    if (!targetAccount.deposit(command.getMoney(), sourceAccountId)) {
      accountLock.releaseAccount(sourceAccountId);
      accountLock.releaseAccount(targetAccountId);
      return false;
    }

    updateAccountStatePort.updateActivities(sourceAccount);
    updateAccountStatePort.updateActivities(targetAccount);

    accountLock.releaseAccount(sourceAccountId);
    accountLock.releaseAccount(targetAccountId);
    return true;
  }

}

该用例实现从数据库中加载源帐户和目标帐户,锁定帐户,以使其他事务无法同时进行,进行取款和存款,最后将帐户的新状态写回到数据库。

另外,通过使用@Component,我们使其成为Spring Bean,可以注入到需要访问SendMoneyUseCase输入端口的任何组件中,而不必依赖于实际的实现。

为了从数据库中加载和存储帐户,实现取决于输出端口LoadAccountPort和UpdateAccountStatePort,它们是我们稍后将在持久性适配器中实现的接口。

输出端口接口由用例决定。在编写用例时,我们可能会发现我们需要从数据库中加载某些数据,因此我们为其创建了输出端口接口。这些端口当然可以在其他用例中重复使用。在我们的例子中,输出端口如下所示:

public interface LoadAccountPort {

  Account loadAccount(AccountId accountId, LocalDateTime baselineDate);

}
public interface UpdateAccountStatePort {

  void updateActivities(Account account);

}

构建一个Web适配器

有了领域模型,用例以及输入和输出端口,我们现在已经完成了应用程序的核心(即六边形内的所有内容)。但是,如果我们不将其与外界联系起来,那么这个核心将无济于事。因此,我们构建了一个适配器,通过REST API公开了我们的应用程序核心:

@RestController
@RequiredArgsConstructor
public class SendMoneyController {

  private final SendMoneyUseCase sendMoneyUseCase;

  @PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
  void sendMoney(
      @PathVariable("sourceAccountId") Long sourceAccountId,
      @PathVariable("targetAccountId") Long targetAccountId,
      @PathVariable("amount") Long amount) {

    SendMoneyCommand command = new SendMoneyCommand(
        new AccountId(sourceAccountId),
        new AccountId(targetAccountId),
        Money.of(amount));

    sendMoneyUseCase.sendMoney(command);
  }

}

如果您熟悉Spring MVC,就会发现这是一个非常无聊的Controller。它只是从请求路径中读取所需的参数,将它们放入SendMoneyCommand中并调用用例。例如,在更复杂的场景中,Web控制器还可以检查身份验证和授权,并对JSON输入进行更复杂的映射。

上面的控制器通过将HTTP请求映射到用例的输入端口来向外界展示我们的用例。现在,让我们看看如何通过连接输出端口将应用程序连接到数据库。

构建持久化适配器

输入端口由用例服务实现,而输出端口由持久化适配器实现。假设我们使用Spring Data JPA作为管理代码库中持久化的首选工具。实现输出端口LoadAccountPort和UpdateAccountStatePort的持久化适配器可能如下所示:

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
    LoadAccountPort,
    UpdateAccountStatePort {

  private final AccountRepository accountRepository;
  private final ActivityRepository activityRepository;
  private final AccountMapper accountMapper;

  @Override
  public Account loadAccount(
          AccountId accountId,
          LocalDateTime baselineDate) {

    AccountJpaEntity account =
        accountRepository.findById(accountId.getValue())
            .orElseThrow(EntityNotFoundException::new);

    List<ActivityJpaEntity> activities =
        activityRepository.findByOwnerSince(
            accountId.getValue(),
            baselineDate);

    Long withdrawalBalance = orZero(activityRepository
        .getWithdrawalBalanceUntil(
            accountId.getValue(),
            baselineDate));

    Long depositBalance = orZero(activityRepository
        .getDepositBalanceUntil(
            accountId.getValue(),
            baselineDate));

    return accountMapper.mapToDomainEntity(
        account,
        activities,
        withdrawalBalance,
        depositBalance);

  }

  private Long orZero(Long value){
    return value == null ? 0L : value;
  }

  @Override
  public void updateActivities(Account account) {
    for (Activity activity : account.getActivityWindow().getActivities()) {
      if (activity.getId() == null) {
        activityRepository.save(accountMapper.mapToJpaEntity(activity));
      }
    }
  }

}

该适配器实现已实现的输出端口所需的loadAccount()和updateActivities()方法。它使用Spring Data存储库从数据库中加载数据并将数据保存到数据库中,并使用AccountMapper将Account领域对象映射到AccountJpaEntity对象中,这些对象代表数据库中的一个帐户。

而且我们使用@Component使其成为Spring Bean,可以将其注入上述用例服务中。

值得吗?

人们经常问自己,这样的架构是否有价值(我在​​这里包括我自己)。毕竟,我们必须创建如此多的端口接口,并且每个还有那么多的不同的实现。

所以,他真的值得吗?

作为专业顾问,我的答案当然是“看情况”。

如果我们要构建一个仅保存数据的CRUD应用程序,那么这样的体系结构可能就是巨大的开销。如果我们要构建一个具有丰富业务规则的应用程序,并且可以在将状态与行为结合在一起的丰富域模型中的应用程序,那么该体系结构确实会发光,因为它将域模型置于全局的中心。

原文:https://reflectoring.io/spring-hexagonal/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容