Ohhnews

分类导航

$ cd ..
foojay原文

Java中的领域驱动设计:实践指南

#领域驱动设计#java#spring boot#软件架构#企业应用

目录 理解“机场”领域 在 Java 中建模核心机场领域 识别聚合和实体 实现实体和值对象 限界上下文和模块化 仓储、领域服务和工厂

在机场等待航班时,您是否曾想过要让机场顺利运行,幕后需要多少规划?每天,数千架航班起降,乘客穿梭于航站楼,工作人员 tirelessly 工作,以确保一切顺利进行。表面上的混乱实际上是一个高度协调系统的结果——一个始终在后台工作的“大脑”,管理物流、适应突发情况并保持机场高效运转。

在软件世界中,领域驱动设计(DDD)扮演着类似的角色。DDD 是一种构建健壮业务应用程序的方法,它通过关注核心领域——业务核心的基本知识和操作——来构建应用程序。就像机场的中央系统协调从航班时刻表到安检的一切事务一样,DDD 将您的代码围绕真实世界的流程和概念进行组织。通过使用 DDD,开发人员不仅仅是编写代码,他们还在建模业务的复杂现实,使他们的应用程序像一个主要的国际机场一样可靠、适应性强且协调良好。他们创建了一个共享语言,定义了清晰的界限并灵活地响应不断变化的业务需求。

DDD 的一些关键思想包括领域本身(将其视为应用程序的“空中交通管制”,协调所有主要操作)、统一语言(一种共享的、标准化的词汇——就像机场中每个团队使用的精确航空术语一样)和限界上下文(独立的职能区域,就像机场有航站楼、安检和行李提取,每个区域都有自己的规则和工作流程)。

您会遇到实体(独特的、可追踪的物品,如飞机或乘客)、值对象(描述或细节,如登机牌)、聚合(相关操作的集群)、仓储(存储、查找或更新重要信息的地方)和服务(协调系统活动但不属于任何单一实体的基本操作)。

现代 DDD 的一个重要部分是使用领域事件——表示重大变化的时刻,例如 FlightDelayedEventBoardingStartedEvent,它们有助于整个系统做出反应和适应,就像实际机场中的实时公告和操作一样。

通过将这些构建块中的每一个与机场运营联系起来,DDD 将抽象的软件概念转化为实用的、真实的解决方案——使您的业务逻辑像世界上最繁忙的交通枢纽一样有条理、可预测和高效。

在本文中,我们将通过实际构建一个机场运营系统来学习领域驱动设计——一步一步,使用 Java 和 Spring Boot。您将看到当我们将真实世界的机场活动建模、创建领域类、组织限界上下文并将所有内容与实际代码连接起来时,DDD 概念是如何实现的。

理解“机场”领域

作为第一步,我们集思广益,以理解“机场”领域:机场运营的独特之处是什么?列出核心业务活动:航班调度、登机口分配、乘客登机、行李追踪、安全执行。

结果:我们将为您的领域创建一个词汇表(统一语言),包含 FlightGateRunwayBoardingPass 等术语。然后,我们将在 Markdown 或 Google Doc 中记录这个共享词汇表。我们将在代码注释和项目中创建的对象类名中引用它们。

在 Java 中建模核心机场领域

在您的 IDE(如 IntelliJ IDEA)中打开一个新的 Spring Boot 项目(使用 Spring Initializr),并将其命名为 airport-domain-demo 作为 Maven 项目。

[LOADING...]

为了本演示的目的,我们将领域对象限制在最少,但在实际场景中,您将拥有比演示中展示的更广泛的领域对象词汇表、它们的字段和实现。

识别聚合和实体

在 DDD 中,聚合是定义在执行业务操作时哪些实体和值对象属于一起的一致性边界。对于我们简化的机场领域:

  • 实体:Flight 代表一个预定航班。字段包括 flightNumber、origin、destination 和 schedule。每个航班都由其航班号唯一标识。
  • 实体:Passenger 代表一个旅客。它包含 id、name 和对其座位分配的引用(显示他们在航班中的位置)。
  • 值对象:SeatAssignment 是一个值对象,表示没有身份的描述性属性。SeatAssignment 通过 seatNumber 和 class(例如,经济舱、商务舱)描述乘客的座位,并且不能独立存在而没有乘客。
  • 聚合根:Flight 在这个简化模型中充当聚合根。它管理其乘客和他们的座位分配,确保所有对乘客的操作都通过航班进行以保持一致性。

实现实体和值对象

在创建的 Spring Boot 项目中,导航到 src/main/java/com/example/airport/domain。在这里,我们将定义反映机场领域重要概念的关键类。这些类成为应用程序业务逻辑的构建块。另一个核心 DDD 实践是允许实体作为其业务逻辑的一部分发布领域事件。在机场环境中,当乘客被添加到航班时,您可能希望引发一个事件(如 PassengerAddedEvent),以便系统的其余部分可以响应(更新清单、发送通知等)。一种典型的方法是使用 Spring 的领域事件支持。

  1. Flight
$ java
public class Flight {

    private String flightNumber;

    private String origin;

    private String destination;

    private LocalDateTime scheduledDeparture;

    private LocalDateTime scheduledArrival;

    private List<Passenger> passengers = new ArrayList<>();

    // Constructors, getters, setters

// Standard names for Domain Event publishing via Spring

@DomainEvents

Collection<Object> domainEvents() {

    return events;

}

@AfterDomainEventPublication

void clearDomainEvents() {

    events.clear();

}

    // Business method to add passenger

    public void addPassenger(Passenger passenger) {

        // e.g. validate seat availability before adding

        this.passengers.add(passenger);

    }

}
  1. Passenger
$ java
public class Passenger {

    private Long id;

    private String name;

    private SeatAssignment seatAssignment;

    // Constructors, getters, setters

}
  1. SeatAssignment
$ java
public class SeatAssignment {

    private String seatNumber;

    private String seatClass; // e.g. Economy, Business

    // Constructors, equals and hashCode for value semantics

}

实体和值对象的分离有助于保持清晰度。实体具有身份(Flight、Passenger),而值对象描述或详细说明实体,没有唯一的身份(SeatAssignment)。

限界上下文和模块化

接下来,我们将应用程序划分为反映机场部门的限界上下文,这有助于通过隔离领域的各个部分来管理复杂性。

  • 航班运营:处理航班调度、乘客管理和通信
  • 乘客服务:管理值机、登机和座位分配
  • 地面服务:可能涉及行李处理和登机口分配,但为简化起见在此省略

在我们创建的 Java 项目中,我们将这些上下文映射到主基本包中的单独包/模块。例如:

com.example.airport.flightops

com.example.airport.passengerservices

每个上下文都将拥有自己的模型和业务逻辑,以避免重叠和冲突。

仓储、领域服务和工厂

仓储

仓储抽象数据持久化和检索,将您的领域模型与数据库或外部系统连接起来。

示例接口:

$ java
public interface FlightRepository {

    Flight findByFlightNumber(String flightNumber);

    void save(Flight flight);

}

public interface PassengerRepository {

    Passenger findById(Long id);

    void save(Passenger passenger);

}

仓储暴露聚合根和实体以进行检索和持久化,而无需向领域逻辑暴露数据库细节。

领域服务

领域服务封装涉及多个领域对象或不自然地适合实体或值对象的业务逻辑。

示例:FlightService 管理乘客到航班的分配,确保没有重复的座位预订

$ java
@Service

public class FlightService {

    private final FlightRepository flightRepository;

    public FlightService(FlightRepository flightRepository) {

        this.flightRepository = flightRepository;

    }

    public void addPassengerToFlight(String flightNumber, Passenger passenger) {

        Flight flight = flightRepository.findByFlightNumber(flightNumber);

        // Validate seat isn't already taken

        boolean seatTaken = flight.getPassengers().stream()

            .anyMatch(p -> p.getSeatAssignment().equals(passenger.getSeatAssignment()));

        if (seatTaken) {

            throw new IllegalArgumentException("Seat already assigned");

        }

        flight.addPassenger(passenger);

        flightRepository.save(flight);

    }

}

工厂

工厂创建复杂的聚合实例,同时封装创建逻辑。示例:

$ java
@Component

public class FlightFactory {

    public Flight createFlight(String flightNumber, String origin, String destination, LocalDateTime departure, LocalDateTime arrival) {

        Flight flight = new Flight();

        flight.setFlightNumber(flightNumber);

        flight.setOrigin(origin);

        flight.setDestination(destination);

        flight.setScheduledDeparture(departure);

        flight.setScheduledArrival(arrival);

        return flight;

    }

}

应用层和集成

设置一个简单的 REST 控制器,与您的服务集成,向客户端暴露核心功能。

$ java
@RestController

@RequestMapping("/api")

public class FlightController {

    private final FlightService flightService;

    private final FlightFactory flightFactory;

    private final FlightRepository flightRepository;

    public FlightController(FlightService flightService, FlightFactory flightFactory, FlightRepository flightRepository) {

        this.flightService = flightService;

        this.flightFactory = flightFactory;

        this.flightRepository = flightRepository;

    }

    @PostMapping("/flights")

    public ResponseEntity<Flight> createFlight(@RequestBody FlightRequest flightRequest) {

        Flight flight = flightFactory.createFlight(

            flightRequest.getFlightNumber(),

            flightRequest.getOrigin(),

            flightRequest.getDestination(),

            flightRequest.getScheduledDeparture(),

            flightRequest.getScheduledArrival()

        );

        flightRepository.save(flight);

        return ResponseEntity.ok(flight);

    }

    @PostMapping("/flights/{flightNumber}/passengers")

    public ResponseEntity<String> addPassenger(@PathVariable String flightNumber, @RequestBody Passenger passenger) {

        try {

            flightService.addPassengerToFlight(flightNumber, passenger);

            return ResponseEntity.ok("Passenger added");

        } catch (Exception e) {

            return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());

        }

    }

}

FlightRequestDTO

$ java
public class FlightRequest {

    private String flightNumber;

    private String origin;

    private String destination;

    private LocalDateTime scheduledDeparture;

    private LocalDateTime scheduledArrival;

    // getters and setters

}

API 暴露与业务流程对齐的聚合根操作和领域行为。

测试和演进模型

下一步是为核心场景编写 Junit 测试,例如航班创建、添加乘客等。

$ java
@SpringBootTest

public class AirportApplicationTests {

    @Autowired

    private FlightRepository flightRepository;

    @Autowired

    private PassengerRepository passengerRepository;

    @Test

    public void testCreateFlight() {

        Flight flight = new Flight();

        flight.setFlightNumber("AB123");

        flight.setOrigin("JFK");

        flight.setDestination("LAX");

        flightRepository.save(flight);

        assertNotNull(flightRepository.findByFlightNumber("AB123"));

    }

}

完整的 DDD 实现源代码

您可以在我的 GitHub 仓库 中找到 airport-domain-demo 的详细代码库。

该项目在设计时考虑了 DDD 方法,并使用 MongoDB 作为其底层数据库,因为 MongoDB 的面向文档模型自然支持 DDD 概念,例如聚合、限界上下文和仓储抽象。

该项目为演示创建了以下端点:

基础 URL: http://localhost:8080/api/flights

航班管理端点

  1. 创建航班

    • 方法:POST
    • URL:/api/flight
    • 目的:在系统中创建一个新航班
    • 请求体(JSON):
      $ cat
      {
        "flightNumber": "UA101",
        "origin": "JFK",
        "destination": "LAX",
        "scheduledDeparture": "2024-12-25T10:00:00",
        "scheduledArrival": "2024-12-25T13:00:00"
      }
      
  2. 获取所有航班

    • 方法:GET
    • URL:/api/flights
    • 目的:检索系统中的所有航班
    • 响应:航班对象数组,包括其乘客
    • DDD 概念:实现仓储模式,从核心领域抽象数据检索。
  3. 按航班号获取航班

    • 方法:GET
    • URL:/api/flights/{flightNumber}
    • 示例:/api/flights/UA101
    • 目的:获取特定航班的详细信息,包括乘客列表
    • 响应:带乘客列表的航班对象
    • DDD 概念:检索聚合根 (Flight) 及其相关实体 (Passenger)
  4. 按航线获取航班

    • 方法:GET
    • URL:/api/flights/route?origin={origin}&destination={destination}
    • 示例:/api/flights/route?origin=JFK&destination=LAX
    • 目的:查找指定机场之间的航班
    • 响应:符合条件的航班数组
    • DDD 概念:实现领域特定查询的领域服务方法
  5. 按出发时间范围获取航班

    • 方法:GET
    • URL:/api/flights/departures?start={startTime}&end={endTime}
    • 示例:/api/flights/departures?start=2024-12-25T00:00:00&end=2024-12-25T23:59:59
    • 目的:列出给定时间窗口内出发的航班
    • 响应:在该范围内的航班数组
    • DDD 概念:带有时基查询领域逻辑的仓储
  6. 删除航班

    • 方法:DELETE
    • URL:/api/flights/{flightNumber}
    • 示例:/api/flights/UA101
    • 目的:从系统中删除航班
    • 响应:200 OK - "Flight deleted successfully"
    • DDD 概念:聚合根删除以维护系统一致性

乘客管理端点

  1. 将乘客添加到航班

    • 方法:POST
    • URL:/api/flights/{flightNumber}/passengers
    • 示例:/api/flights/UA101/passengers
    • 目的:将乘客及其座位分配添加到特定航班
    • 请求体(JSON):
      $ cat
      {
        "name": "John Doe",
        "seatNumber": "12A",
        "seatClass": "Economy"
      }
      
    • 响应:200 OK - "Passenger added successfully"
    • 错误:409 Conflict - "Seat 12A is already assigned"
    • DDD 概念:业务规则强制执行,以防止聚合内重复的座位分配
  2. 添加没有座位的乘客

    • 方法:POST
    • URL:/api/flights/{flightNumber}/passengers
    • 目的:添加没有预分配座位的乘客
    • 请求体(JSON):
      $ cat
      {
        "name": "Jane Smith"
      }
      
    • 响应:200 OK - "Passenger added successfully"
    • DDD 概念:演示可选值对象 (SeatAssignment)
  3. 从航班中移除乘客

    • 方法:DELETE
    • URL:/api/flights/{flightNumber}/passengers/{passengerId}
    • 示例:/api/flights/UA101/passengers/1
    • 目的:从航班中移除特定乘客
    • 响应:200 OK - "Passenger removed successfully"
    • DDD 概念:聚合管理其实体,确保封装和一致的删除## Postman 测试用例

场景 1:完整的航班预订流程

  • 创建航班。
  • 添加多名不同座位等级的乘客。
  • 尝试添加座位号重复的乘客(预期失败)。
  • 检索航班详情以验证乘客信息。
  • 移除一名乘客。
  • 检索更新后的航班详情。

场景 2:业务规则验证

  • 尝试创建起飞时间晚于抵达时间的航班(应失败)。
  • 尝试创建计划在过去的航班(应失败)。
  • 添加座位号重复的乘客(应失败)。

场景 3:查询操作

  • 创建多条不同航线的航班。
  • 按航线搜索航班。
  • 按时间范围搜索航班。
  • 检索所有航班以进行总览。

示例数据

应用程序初始化时包含以下三个航班:

  • UA101:JFK → LAX(两名乘客)
  • UA102:LAX → JFK(一名乘客)
  • AA203:ORD → MIA(三名乘客)

实际应用建议与常见陷阱

在每个阶段之后暂停并反思我们在领域驱动方法(DDD)的每个步骤中取得了什么成就,这一点很重要。例如,在对这些聚合进行建模后,我们是否发现了之前遗漏的新业务术语或规则?我们随后会根据这些新发现更新我们的通用语言文档。

此外,在 DDD 中,识别陷阱并采取预防措施也很重要,例如将业务逻辑与控制器混淆,这就像拥有一个“意大利面条式”的跑道。我们经常需要重构令人困惑的服务或仓库,使其符合 DDD 方法。

结论

通过遵循这些演示驱动的步骤,您将体验到 DDD 作为 Spring Boot 项目的实用“控制塔”:它能组织代码、明确职责,并使您的团队能够发展和适应系统——就像真实的机场运营一样。

我们可以通过添加新功能、新部门(上下文)或将更多现实生活中的事件关联融入设计来增强此演示应用程序的功能。DDD 的主要动机是始终努力使代码回归业务理解。

这篇博文 Java 中的领域驱动设计:实用指南 最早发布于 foojay