通过一个具体的例子告诉你我们如何在 Java 中实现一个只允许根据业务规则定义良好的状态转换的域模型,然后使用 Spring 在一个REST-API 中发布它。看看我们如何构建一个完全由该 API 驱动的简单 Web 应用程序。该实现使用来自领域驱动设计(DDD) 的概念,这是一种软件工件试图与业务模型一致的方法,例如通过使用领域语言的术语。
CRUD 应用程序的问题
create-read-update-delete 或简短的 CRUD 方法非常容易实现,它是有状态 Web 应用程序中将图形用户界面 (GUI) 与后端集成的主要方法。因此,后端的实体仅对需要持久化的领域逻辑的状态进行建模,而不是对适用于产生有效状态更改的业务操作和业务规则进行建模。
在最好的情况下,服务操作会为这些操作命名并包含实现所需业务规则的逻辑。这些规则很容易从整个代码库中泄漏出来,并且也经常出现在图形用户界面中,现在通常实现为在浏览器中运行的单页 Web 应用程序。
结果,我们有一个应用程序,它带有 Martin Fowler 所说的贫血域模型,以及一个难以理解和维护的相互纠缠的泥球,因为职责没有明确分离。基于启发式,GUI 对实体可以做什么做出假设,并实现与后端分离的导航逻辑。
HATEOAS 和 Richardson 成熟度模型
查阅文献以寻找解决此问题的解决方案,您可能会遇到 HTTP 应用程序 API 的Richardson 成熟度模型。它从普通的旧 XML 开始,这意味着 XML 内容被发布到 Web 服务端点。
- 在 1级API中 引入了资源的概念,允许单独操作后端实体,从而将大型服务端点分解为多个资源。
- 2级API进一步使用特定的 HTTP 动词,如PUT、DELETE或PATCH来细化操作的含义。Martin Fowler 称它提供了一组标准的动词,以便我们以相同的方式处理类似的情况,消除不必要的变化。
- 在第 3级,通过向每个响应添加特定于上下文的超链接,将可发现性融入 API,让客户了解接下来可以使用给定资源执行哪些操作,或链接相关资源。在这个级别,与 API 交换的“超文本”充当应用程序状态的引擎,植根于后端并通过超链接向客户端(在我们的例子中为 Web 前端)显示。
REST-ful API 和 HAL 标准如何帮助我们解决将业务逻辑封装在后端的初始问题?让我们看一个具体的例子。
示例领域:制造资源计划
假设我们正在构建一个制造资源计划系统( MRP ),产品经理可以在其中准备和提交生产订单。然后制造商可以接受订单,指明预计的交货日期,并在产品生产后完成订单。
此外,以下业务规则适用:
- 产品经理在提交后无法更改生产订单。
- 当制造商接受生产订单时,他必须指明未来可以完成订单的日期。
领域故事:
使用 Spring 和 Angular 设置项目
正如 Josh Long 一直告诉我们的,每个项目都应该从https://start.spring.io开始。事实上,该页面非常方便,让我们可以轻松地引导一个包含所需技术的新应用程序。对于我们的案例,我们选择以下依赖项:
- Spring Data JDBC:基于 java 数据库连接 (JDBC) 的直接 OR 映射器,使我们免于 JPA 的开销,非常适合持久化 DDD 风格的聚合
- H2 数据库:用 Java 编写的关系数据库,可以开箱即用地在内存中运行
- Rest Repositories:一个 Spring 库,允许将我们的聚合发布为 REST 资源
- Lombok:一个字节码生成器,它极大地减少了样板代码的数量,并为 Java 提供了一些现代语言,如 Kotlin 或 TypeScript
- Spring Boot DevTools:一个开发依赖,它会在代码库的每次更改时自动重新启动应用程序
聚合:状态符合业务规则的地方
域驱动应用程序的核心是域模型。它不受技术和集成方面的影响,并尽可能地遵循商业模式和术语。因此,应用程序的状态在所谓的实体中被捕获,其中相关的实体可以被分组以形成一个聚合体。每个聚合都定义了应用程序内部的一致性边界,这意味着只有明确定义的状态更改才会在聚合内以事务方式发生。
为了保持专注,让我们从一个非常简单的生产订单模型开始,没有子实体,只有四个字段:
- id:区分不同生产订单的标识符。为简单起见,我们将其建模为 aLong并让数据库对其进行初始化。注释告诉 Spring Data JDBC这@Id是主键。
- name:生产订单的名称。提交后,名称不得再更改。
- expectedCompletionDate:制造商在接受生产订单时提供的日期,表明制造过程的计划完成
- state:根据领域模型的生产订单状态。它可以假定值DRAFT, SUBMITTED, ACCEPTED, COMPLETED, 建模为枚举。
我们使用 Lombok 的注解对类进行@Getter注解,它生成字节码以使用 getter 来检测我们的(普通)聚合,以便从外部访问这些字段的值。
我们现在如何确保域模型只允许定义良好的状态转换,而不是通过 setter 展示所有字段?答案是:通过执行各自的业务操作。
当然,我们可以使用构造函数来创建我们的实体。然而,我更喜欢提供一个静态工厂方法,它允许我们使用适当的业务术语作为名称,而不是相当技术性的new语句。creat- 方法将产品订单的名称作为单个参数并将状态初始化为DRAFT。当聚合被持久化到数据库时,该id字段稍后将由框架初始化。
初始的create方法:package com.example.demo.productionorders;
import java.time.LocalDate;
import org.springframework.data.annotation.Id;
import lombok.Getter; import lombok.val;
@Getter public class ProductionOrder {
@Id private Long id; private String name; private LocalDate expectedCompletionDate; private ProductionOrderState state;
public static ProductionOrder create(String name) { val result = new ProductionOrder(); result.name = name; result.state = ProductionOrderState.DRAFT; return result; }
public enum ProductionOrderState { DRAFT, SUBMITTED, ACCEPTED; }
}
|
Repository库和基本 REST API
为了将我们的聚合持久化到数据库并从那里检索它,我们定义了一个接口,为简单起见扩展了CrudRepositorySpring Data 的接口。我们没有在其名称中使用“存储库”,而是将其命名ProductionOrders为持久保存我们领域无处不在的语言。
为了将我们的聚合持久化到数据库并从那里检索它,我们定义了一个接口,为简单起见扩展了CrudRepositorySpring Data 的接口。我们没有在其名称中使用“存储库”,而是将其命名ProductionOrders为坚持我们领域无处不在的语言。
package com.example.demo.productionorders;
import org.springframework.data.repository.CrudRepository; import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource public interface ProductionOrders extends CrudRepository<ProductionOrder, Long> {
}
|
启动应用程序并通过 curl 命令行工具查询其 API,我们得到以下响应:
$ curl http://localhost:8080/api { "_links" : { "productionOrders" : { "href" : "http://localhost:8080/api/productionOrders" }, "profile" : { "href" : “http://localhost:8080/api/profile” } } }
|
你注意到字段_links了吗?是的,Spring Data REST默认产生HAL格式的响应。它向我们表明,API提供了一个集合资源 "productionOrders",包括一个如何导航的链接。如果每个资源都提供了客户需要的所有链接,以导航到相关的资源和调用动作,那么我们就可以得出以下结论。
客户端只需要知道一个URL,那就是"/api"。在一个真正的REST-ful API中,所有其他的URL都可以从API的响应中检索出来。
为了进一步说明这个概念,我们在DemoApplication类中创建并持久化一些生产订单,然后关注productionOrders资源的href-Property。
$ curl http://localhost:8080/api/productionOrders { "_embedded" : { "productionOrders" : [ { "name" : "Order 1", "expectedCompletionDate" : null, "state" : "DRAFT", "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders/1" }, "productionOrder" : { "href" : "http://localhost:8080/api/productionOrders/1" } } }, { "name" : "Order 2", "expectedCompletionDate" : null, "state" : "DRAFT", "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders/2" }, "productionOrder" : { "href" : "http://localhost:8080/api/productionOrders/2" } } } ] }, "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders" }, "profile" : { "href" : "http://localhost:8080/api/profile/productionOrders" } } }
|
我们看到,两个生产订单被返回,包含在HAL规范定义的特殊字段_embedded的一个属性中。如果与一个资源的交互需要额外的信息,例如下拉列表的值,以过滤特定状态下的生产订单,这些数据可以被添加到响应的_embedded属性下的另一个字段。
每个生产订单资源都提供一组链接,默认情况下是相当琐碎的:一个自我链接和一个生产订单链接,都指向资源本身。作为下一步,我们现在将把业务操作添加到生产订单类中,并把调用它们的端点作为附加链接公开。
增加业务行为
如果我们回顾一下业务需求的大纲,我们的聚合体需要支持以下操作。
- 当处于DRAFT状态时允许重命名
- 一个向制造商提交订单的操作
- 一个接受订单的操作,提供预期交货日期
该实现遵循我们在创建方法中已经使用的方法。我们没有提供getters和setters,而是用符合我们领域语言的名字来实现方法:renameTo、submit、accept。
如上所述,聚合被看作是一致性的边界。由于额外的方法不再是静态的,我们可以直接利用类的字段来执行所需的业务规则,并允许只执行定义明确的状态转换。例如,我们可以完全确定永远不会遇到没有完成日期的已接受的生产订单,这是我们第二个业务规则的要求。与基于setter的方法相比,这是一个多么大的区别啊!
在生产订单总量中实施三种业务操作,只允许有明确的状态转换。
package com.example.demo.productionorders;
import java.time.LocalDate; import java.util.Objects;
import org.springframework.data.annotation.Id;
import lombok.Getter; import lombok.val;
@Getter public class ProductionOrder {
@Id private Long id; private String name; private LocalDate expectedCompletionDate; private ProductionOrderState state;
public static ProductionOrder create(String name) { val result = new ProductionOrder(); result.name = name; result.state = ProductionOrderState.DRAFT; return result; } public ProductionOrder renameTo(String newName) { if (state != ProductionOrderState.DRAFT) { throw new IllegalStateException("Cannot rename production order in state " + state); } name = newName; return this; } public ProductionOrder submit() { if (state != ProductionOrderState.DRAFT) { throw new IllegalStateException("Cannot submit production order in state " + state); } state = ProductionOrderState.SUBMITTED; return this; }
public ProductionOrder accept(LocalDate expectedCompletionDate) { if (state != ProductionOrderState.SUBMITTED) { throw new IllegalStateException("Cannot accept production order in state " + state); } Objects.requireNonNull(expectedCompletionDate, "expectedCompletionDate is required to submit a production order"); if (expectedCompletionDate.isBefore(LocalDate.now())) { throw new IllegalArgumentException("Expected completion date must be in the future, but was " + expectedCompletionDate); } state = ProductionOrderState.ACCEPTED; this.expectedCompletionDate = expectedCompletionDate; return this; }
public enum ProductionOrderState { DRAFT, SUBMITTED, ACCEPTED; }
}
|
在REST API中公开业务能力
作为下一步,我们现在要在REST API中公开业务操作。我们需要这样做的成分是。
- 为每个行动提供新的端点,其形式为/api/productionOrders/{id}/{action}。
- ProductionOrder资源的HAL表示中的链接
仔细想想,如果我们只暴露一个指向端点的链接,如果相应的动作实际上是允许的,这不是很好吗,这取决于特定生产订单的状态?这可以通过以下方式轻松实现。
我们首先实现一个ProductionOrderController类,并在类的层面上将其映射到/api/productionOrders端点(如果你在映射中遇到麻烦,请参见bug https://github.com/spring-projects/spring-data-rest/issues/1342)。
这使得我们可以通过额外的方法来扩展Spring Data REST提供的标准API:rename, submit, accept。这些方法从路径中获取生产订单的ID,并从请求体中获取任何额外的必要参数。由于与网络技术的集成是应用层的问题,我们把它放在子包web中,以便将其与领域逻辑明确分开。
在聚合上应用动作的模式总是相同的:从持久性存储中加载聚合,调用业务操作,并将其保存到存储中。这同样适用于我们案例中的关系型持久化,但也适用于事件源模型。为了简单起见,我们在控制器中做了所有的事情,而在一个更大的或更纯粹的应用中,控制器将委托给一个域服务。
关于这篇博文的主题,更有趣的部分是通过实现Spring HATEOAS的RepresentationModelProcessor接口。在这个方法过程中,它把生产订单包装成一个实体模型。这个实体模型允许向生产订单资源添加额外的链接。因为该模型也提供了生产订单本身,我们可以很容易地检查它的状态,然后决定是否生成一个特定的链接。
Spring提供了静态的辅助方法linkTo和methodOn来动态地导出引用的控制器方法的URL。package com.example.demo.productionorders.web;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
import java.time.LocalDate;
import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.server.RepresentationModelProcessor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException;
import com.example.demo.productionorders.ProductionOrder; import com.example.demo.productionorders.ProductionOrder.ProductionOrderState; import com.example.demo.productionorders.ProductionOrders;
import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.Value; import lombok.val;
@RestController @RequestMapping("/api/productionOrders") @RequiredArgsConstructor public class ProductionOrderController implements RepresentationModelProcessor<EntityModel<ProductionOrder>> {
public static final String REL_RENAME = "rename"; public static final String REL_SUBMIT = "submit"; public static final String REL_ACCEPT = "accept"; private final ProductionOrders productionOrders; @PostMapping("/{id}/rename") public ResponseEntity<?> rename(@PathVariable Long id, @RequestBody RenameRequest request) { return productionOrders.findById(id) .map(po -> productionOrders.save(po.renameTo(request.newName))) .map(po -> ResponseEntity.ok().body(EntityModel.of(po))) .orElse(ResponseEntity.notFound().build()); }
@PostMapping("/{id}/submit") public ResponseEntity<?> submit(@PathVariable Long id) { return productionOrders.findById(id) .map(po -> productionOrders.save(po.submit())) .map(po -> ResponseEntity.ok().body(EntityModel.of(po))) .orElse(ResponseEntity.notFound().build()); } @PostMapping("/{id}/accept") public ResponseEntity<?> accept(@PathVariable Long id, @RequestBody CompleteRequest request) { return productionOrders.findById(id) .map(po -> productionOrders.save(po.accept(request.expectedCompletionDate))) .map(po -> ResponseEntity.ok().body(EntityModel.of(po))) .orElse(ResponseEntity.notFound().build()); } @Override public EntityModel<ProductionOrder> process(EntityModel<ProductionOrder> model) { val order = model.getContent(); if (order.getState() == ProductionOrderState.DRAFT) { model.add(linkTo(methodOn(getClass()).rename(order.getId(), null)).withRel(REL_RENAME)); model.add(linkTo(methodOn(getClass()).submit(order.getId())).withRel(REL_SUBMIT)); } if (order.getState() == ProductionOrderState.SUBMITTED) { model.add(linkTo(methodOn(getClass()).accept(order.getId(), null)).withRel(REL_ACCEPT)); } return model; } @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) void handleValidationException(Exception exception) { throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, exception.getMessage()); } @Value static class RenameRequest { @NonNull String newName; }
@Value static class CompleteRequest { @NonNull LocalDate expectedCompletionDate; } }
|
再次查询productionOrders资源为我们提供了每个生产订单资源上的新链接。
$ curl http://localhost:8080/api/productionOrders { "_embedded" : { "productionOrders" : [ { "name" : "Order 1", "expectedCompletionDate" : null, "state" : "DRAFT", "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders/1" }, "productionOrder" : { "href" : "http://localhost:8080/api/productionOrders/1" }, "rename" : { "href" : "http://localhost:8080/api/productionOrder/1/rename" }, "submit" : { "href" : "http://localhost:8080/api/productionOrder/1/submit" } } }, { "name" : "Order 2", "expectedCompletionDate" : null, "state" : "DRAFT", "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders/2" }, "productionOrder" : { "href" : "http://localhost:8080/api/productionOrders/2" }, "rename" : { "href" : "http://localhost:8080/api/productionOrder/2/rename" }, "submit" : { "href" : "http://localhost:8080/api/productionOrder/2/submit" } } } ] }, "_links" : { "self" : { "href" : "http://localhost:8080/api/productionOrders" }, "profile" : { "href" : "http://localhost:8080/api/profile/productionOrders" } } }
|
你看到Spring并没有公开聚合的ID属性。我们稍后会看到,我们不需要在客户端知道它,因为它包含在链接中。
还请注意,每个链接都有一个关系属性,简称 "rel"。这个属性非常重要,因为它定义了与API客户端的契约,即一个特定资源存在哪些链接。我们很快就会看到;我们的后端现在已经完成了,我们可以继续在前端利用它了。
在前端消费HAL模型
正如我之前所说,一个真正的REST-ful API的客户端应该只知道一个URL。/api。客户端调用的任何其他URL都应该从响应中的链接中获得。
从我们Angular应用程序的顶级组件(即AppComponent)中发出对基本URL的请求,并将其作为输入传递给子组件,这将是一个自然的选择。为了保持简单,我们在ProductionOrderListComponent中做了所有事情,作为onInit方法的一部分获取API,并将响应存储在字段根中。见下面代码
为了显示生产订单,我们在类顶部的@Component-decorator中添加一个HTML模板,并通过跟踪根资源中与 "productionOrders "有关的链接,从后台加载productionOrders。正如我们在上面看到的,这个链接的url属性是http://localhost:8080/api/productionOrders,但前端对此是不知道的。事实上,后端可以在一个完全不同的URL下提供生产订单,而我们的前端仍然可以工作。只有 "productionOrders "这个关系,也就是后端和前端之间的契约,必须保持稳定。
最初的生产订单列表组件,首先加载API资源,然后通过各自关系下提供的URL加载生产订单。
import { HttpClient } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; import { ProductionOrderResource } from '../model';
const API = "/api"; const REL = "productionOrders";
@Component({ selector: 'app-production-order-list', template: ` <ul> <li *ngFor="let order of productionOrders">{{order.name}} <span *ngIf="order.expectedCompletionDate"> Expected for {{order.expectedCompletionDate|date}} </span> ({{order.state}}) </li> </ul> `, styleUrls: ['./production-order-list.component.css'] }) export class ProductionOrderListComponent implements OnInit {
root: any; productionOrders?: ProductionOrderResource[];
constructor(private http: HttpClient) { }
ngOnInit(): void { this.http.get(API).subscribe( response => { this.root = response; this.reload(); }, error => alert(error) ); }
private reload(): void { if (this.root) { this.http.get<any>(this.root._links[REL].href).subscribe( response => this.productionOrders = response._embedded[REL], error => alert(error) ) } } }
|
接下来,我们需要添加一种方式,让用户可以在模型上执行相应的业务操作。最简单的方法是为当前允许的每个动作在生产订单旁边添加一个按钮。
为每个生产订单添加按钮,根据生产订单资源中相关链接关系的存在与否,显示或隐藏这些按钮。
<ul> <li *ngFor="let order of productionOrders">{{order.name}} <span *ngIf="order.expectedCompletionDate"> Expected for {{order.expectedCompletionDate|date}} </span> ({{order.state}}) <button *ngIf="can('rename', order)" (click)="do('rename', order)">rename</button> <button *ngIf="can('submit', order)" (click)="do('submit', order)">submit</button> <button *ngIf="can('accept', order)" (click)="do('accept', order)">accept</button> </li> </ul>
|
为了决定一个给定的动作是否被允许,以及相应的按钮是否应该被显示,我们查询底层资源,以获取与给定关系的链接。事实上,我们只是实现了一个功能切换:如果该关系被提供为一个链接,则该动作被启用,否则它在GUI中被隐藏。
请注意,示例代码(你可以在这篇文章的末尾找到链接)添加了一些接口,以允许对生产订单资源的_links-property进行类型安全的访问。
最后,我们只需要为那些需要提交额外数据的动作添加特殊处理。在这里,我们再次使用最简单的解决方案,使用本地的提示控制,在重命名动作的情况下接受新的名称,在接受动作的情况下接受预期完成日期。
一个非常简单的 "能 "和 "做 "方法的实现,利用关系和联系或生产秩序资源。
can(action: string, order: any): boolean { return !!order._links[action]; }
do(action: string, order: ProductionOrderResource): void { var body = {}; if (action === 'rename') { const newName = prompt("Please enter the new name:"); if (!newName) { return; } body = { newName: newName }; } else if (action === 'accept') { const expectedCompletionDate = prompt("Expected completion date (yyyy-MM-dd):"); if (!expectedCompletionDate) { return; } body = { expectedCompletionDate: expectedCompletionDate } } const url = order._links[action].href; this.http.post(url, body).subscribe( _ => this.reload(), response => alert([response.error.error, response.error.message].join("\n"))); }
|
因为我们在can-method中检查了一个链接的存在,所以我们可以通过提取该链接的href-property轻松地确定要发布正文的URL。这样一来,我们的小演示程序的前端也就完成了。
总结
尽管许多开发者知道REST-ful APIs的Richardson成熟度模型,但只有少数人使用嵌入式链接来驱动应用状态。基于Open API(以前称为Swagger)的API文档将重点放在绝对URL上,而不是关系上。因此,前端代码经常被固定的URL所束缚,并复制了许多最好在后端进行的逻辑。
Spring HATEOAS为利用REST的全部潜力提供了所有必要的工具。因此,HAL是一个简单而强大的链接关系标准,可以很容易地被Angular应用程序消费,很适合选择性地发布遵循领域驱动设计原则的领域模型的业务操作。本文展示的方法可以帮助大大降低Web应用的复杂性,使业务逻辑远离前端,并使前端的行为基于后台的状态而 "恰到好处"。
你可以在这里找到源代码:https://github.com/sth77/spring-angular-ddd-hateoas。