三层架构和spring
术语“六角形建筑”已经存在很长时间了。 足够长的时间,这个主题的主要资源已经脱机了一段时间,并且直到最近才从档案中解救出来。
但是,我发现关于如何以这种体系结构样式实际实现应用程序的资源很少。 本文的目的是提供一种用Java和Spring以六边形样式实现Web应用程序的自以为是的方式。
如果您想更深入地研究该主题,请看一看我的书 。
代码示例
本文随附Github上的工作代码示例。
什么是“六角形建筑”?
与常见的分层体系结构样式相反,“六角体系结构”的主要特征是组件之间的依赖关系“指向内部”指向我们的领域对象:
六边形只是一种描述应用程序核心的好方法,该应用程序由领域对象,对其进行操作的用例以及为外界提供接口的输入和输出端口组成。
让我们看看这种架构样式中的每个构造型。
域对象
在具有业务规则的域中,域对象是应用程序的命脉。 域对象可以包含状态和行为。 行为越接近状态,代码就越容易理解,推理和维护。
域对象没有任何外部依赖。 它们是纯Java,并提供了用于用例的API。
由于域对象不依赖于应用程序的其他层,因此其他层的更改不会影响它们。 它们可以不受依赖地演变。 这是“单一责任原则”(“ SOLID”中的“ S”)的主要示例,该原则指出组件应该只有一个更改理由。 对于我们的域对象,这是业务需求的变化。
只需承担一项责任,我们就可以演化域对象,而不必考虑外部依赖关系。 这种可扩展性使六角形体系结构样式非常适合您在实践域驱动设计时。 在开发过程中,我们只是遵循自然的依赖关系流程:我们开始在域对象中进行编码,然后从那里开始。 如果不是域驱动的,那么我不知道是什么。
用例
我们知道用例是用户使用我们的软件所做的抽象描述。 在六角形体系结构样式中,将用例提升为我们代码库的一等公民是有意义的。
从这个意义上说,用例是一个处理特定用例周围所有内容的类。 作为示例,让我们考虑银行应用程序中的用例“将钱从一个帐户发送到另一个帐户”。 我们将创建一个具有不同API的类SendMoneyUseCase
,该API允许用户转移资金。 该代码包含特定于用例的所有业务规则验证和逻辑,因此无法在域对象中实现。 其他所有内容都委托给域对象(例如,可能有一个域对象Account
)。
与域对象类似,用例类不依赖于外部组件。 当它需要六角形之外的东西时,我们创建一个输出端口。
输入和输出端口
域对象和用例在六边形内,即在应用程序的核心内。 每次与外部的通信都是通过专用的“端口”进行的。
输入端口是一个简单的接口,可由外部组件调用,并由用例实现。 调用此类输入端口的组件称为输入适配器或“驱动”适配器。
输出端口还是一个简单的接口,如果我们的用例需要外部的东西(例如,数据库访问),我们的用例可以调用它。 该接口旨在满足用例的需求,但由称为输出或“驱动”适配器的外部组件实现。 如果您熟悉SOLID原理,则这是依赖反转原理(SOLID中的“ D”)的应用,因为我们正在使用接口将依赖关系从用例转换为输出适配器。
有了适当的输入和输出端口,我们就有了非常不同的数据进入和离开我们的系统的地方,这使对架构的推理变得容易。
转接器
适配器形成六角形结构的外层。 它们不是核心的一部分,但可以与之交互。
输入适配器或“驱动”适配器调用输入端口以完成某些操作。 例如,输入适配器可以是Web界面。 当用户单击浏览器中的按钮时,Web适配器将调用某个输入端口以调用相应的用例。
输出适配器或“驱动”适配器由我们的用例调用,例如,可能提供来自数据库的数据。 输出适配器实现一组输出端口接口。 请注意,接口由用例决定,而不是相反。
适配器使交换应用程序的特定层变得容易。 如果该应用程序还可以从胖客户端使用到Web上,则可以添加胖客户端输入适配器。 如果应用程序需要其他数据库,则添加一个新的持久性适配器,该适配器实现与旧的持久性适配器相同的输出端口接口。
给我看一些代码!
在简要介绍了上面的六角形体系结构样式之后,让我们最后看一些代码。 将体系结构样式的概念转换为代码始终受解释和影响,因此,请不要采用给定的以下代码示例,而应作为创建自己的样式的灵感。
这些代码示例全部来自我在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 ;
}
}
一个Account
可以有许多相关的Activity
,每个Activity
代表该账户的取款或存款。 由于我们并不总是希望加载给定帐户的所有活动,因此我们将其限制为特定的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
值对象中。 这使我们可以在value对象的构造函数中进行输入验证。 在上面的示例中,我们甚至使用Bean Validation批注@NotNull
,该批注已在validateSelf()
方法中进行了validateSelf()
。 这样,实际的用例代码就不会被嘈杂的验证代码所污染。
现在我们需要该接口的实现。
建立用例和输出端口
在用例实现中,我们使用域模型从源帐户中提取资金,并向目标帐户中存款:
@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,您会发现这是一个非常无聊的Web控制器。 它只是从请求路径中读取所需的参数,将它们放入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,可以将其注入到上述用例服务中。
值得努力吗?
人们经常问自己,这样的架构是否值得努力(我在这里包括自己)。 毕竟,我们必须创建端口接口,并且必须使用x来映射域模型的多种表示形式。 Web适配器中可能存在域模型表示,而持久性适配器中可能存在另一个域模型表示。
那么, 值得付出努力吗?
作为专业顾问,我的答案当然是“取决于”。
如果我们要构建一个仅存储和保存数据的CRUD应用程序,则这种架构可能会产生开销。 如果我们正在构建具有可以在结合了状态和行为的丰富域模型中表达的丰富业务规则的应用程序,那么该体系结构确实会发光,因为它将域模型置于事物的中心。
深潜
上面仅给出了六边形体系结构在实际代码中的外观的想法。 还有其他方式可以做到,因此请随时尝试并找到最适合您需求的方式。 而且,Web和持久性适配器只是外部适配器的示例。 可能有到其他第三方系统或其他面向用户的前端的适配器。
如果您想更深入地研究这个主题,请看一看我的书 ,它会更详细,并讨论诸如测试,映射策略和快捷方式之类的内容。
翻译自: https://dev.to/thombergs/hexagonal-architecture-with-java-and-spring-abl
三层架构和spring