应用领域驱动设计与Java企业级开发:行为驱动方法
在软件开发中,最大的错误之一就是精确交付客户所要求的内容。虽然这听起来有些老生常谈,但即使在行业经历数十年发展后,这个问题依然存在。更有效的方法是从业务需求出发开始测试。
行为驱动开发(BDD)是一种软件开发方法,强调行为和领域术语,也称为统一语言。它使用共享的自然语言从用户角度定义和测试软件行为。BDD在测试驱动开发(TDD)的基础上构建,专注于与业务相关的场景。这些场景被编写成纯语言规范,可以自动化为测试,同时也作为活文档。
这种方法促进了技术和非技术利益相关者之间的共同理解,确保软件满足用户需求,并有助于减少返工和开发时间。在本文中,我们将进一步探讨这种方法论,并讨论如何使用Oracle NoSQL和Java实现它。
BDD与DDD如何协同工作
乍看之下,行为驱动开发(BDD)和领域驱动设计(DDD)似乎解决了不同的问题——一个专注于测试,另一个专注于建模。然而,两者共享相同的哲学基础:确保软件真正反映其所服务的业务领域。
DDD由Eric Evans在其2003年的开创性著作《领域驱动设计:处理软件核心的复杂性》中提出,教导我们围绕业务概念——实体、值对象、聚合和限界上下文——来建模软件。其力量在于使用统一语言,这是一种连接开发人员和领域专家的共享词汇表。
BDD由Dan North在几年后提出,作为这一思想的自然延伸出现。它将统一语言带入测试过程,将业务规则转化为可执行的规范。DDD定义了系统应该表示什么,而BDD则验证系统如何按照该模型行为。
当一起使用时,DDD和BDD形成一个连续的反馈循环:
- DDD塑造捕获业务逻辑的领域模型
- BDD确保系统行为随时间推移与该模型保持一致
在实践中,这种协同意味着您可以编写功能场景——例如"当我预订VIP房间时,系统应将其标记为不可用"——直接关联到如Room和Reservation等聚合。这些测试成为开发人员和利益相关者的活文档,确保您的领域与真实业务需求保持一致。
代码示例
在这个示例中,我们将使用Java企业版和Oracle NoSQL数据库生成一个简单的酒店管理应用程序。
第一步是创建项目。由于我们使用Java SE,可以使用以下Maven命令生成:
1
2
3
4
5
6
7
8
9
|
mvn archetype:generate \
"-DarchetypeGroupId=io.cucumber" \
"-DarchetypeArtifactId=cucumber-archetype" \
"-DarchetypeVersion=7.30.0" \
"-DgroupId=org.soujava.demos.hotel" \
"-DartifactId=behavior-driven-development" \
"-Dpackage=org.soujava.demos" \
"-Dversion=1.0.0-SNAPSHOT" \
"-DinteractiveMode=false"
|
下一步是包含Eclipse JNoSQL与Oracle NoSQL,以及Jakarta EE组件实现:CDI、JSON和Eclipse MicroProfile实现。
初始项目准备就绪后,我们将开始创建测试。请记住,BDD是TDD的扩展,包括统一语言——领域和业务之间的共享词汇表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
Feature: Manage hotel rooms
Scenario: Register a new room
Given the hotel management system is operational
When I register a room with number 203
Then the room with number 203 should appear in the room list
Scenario: Register multiple rooms
Given the hotel management system is operational
When I register the following rooms:
| number | type | status | cleanStatus |
| 101 | STANDARD | AVAILABLE | CLEAN |
| 102 | SUITE | RESERVED | DIRTY |
| 103 | VIP_SUITE | UNDER_MAINTENANCE | CLEAN |
Then there should be 3 rooms available in the system
Scenario: Change room status
Given the hotel management system is operational
And a room with number 101 is registered as AVAILABLE
When I mark the room 101 as OUT_OF_SERVICE
Then the room 101 should be marked as OUT_OF_SERVICE
|
Maven项目完成后,让我们进入下一步,即创建建模和存储库。如前所述,我们将专注于房间管理。因此,我们的下一个目标是确保之前定义的BDD测试通过。让我们开始实现领域模型和存储库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
public enum CleanStatus {
CLEAN,
DIRTY,
INSPECTION_NEEDED
}
public enum RoomStatus {
AVAILABLE,
RESERVED,
UNDER_MAINTENANCE,
OUT_OF_SERVICE
}
public enum RoomType {
STANDARD,
DELUXE,
SUITE,
VIP_SUITE
}
@Entity
public class Room {
@Id
private String id;
@Column
private int number;
@Column
private RoomType type;
@Column
private RoomStatus status;
@Column
private CleanStatus cleanStatus;
@Column
private boolean smokingAllowed;
@Column
private boolean underMaintenance;
}
|
有了模型后,下一步是创建Java企业版和Oracle NoSQL作为非关系数据库之间的桥梁。我们可以使用Jakarta Data轻松完成,它有一个单一的存储库,因此我们不需要担心实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Repository
public interface RoomRepository {
@Query("FROM Room")
List<Room> findAll();
@Save
Room save(Room room);
void deleteBy();
Optional<Room> findByNumber(Integer number);
}
|
项目完成后,下一步是准备测试环境,首先为测试提供数据库实例。多亏了Testcontainers,我们可以轻松启动一个隔离的Oracle NoSQL实例来运行测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
public enum DatabaseContainer {
INSTANCE;
private final GenericContainer<?> container = new GenericContainer<>(
DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
.withExposedPorts(8080);
{
container.start();
}
public DatabaseManager get(String database) {
DatabaseManagerFactory factory = managerFactory();
return factory.apply(database);
}
public DatabaseManagerFactory managerFactory() {
var configuration = DatabaseConfiguration.getConfiguration();
Settings settings = Settings.builder()
.put(OracleNoSQLConfigurations.HOST, host())
.build();
return configuration.apply(settings);
}
public String host() {
return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
}
}
|
之后,我们将创建一个与@Alternative CDI注解集成的生产者。此配置教CDI如何提供数据库实例——在这种情况下,由Testcontainers管理的实例:
1
2
3
4
5
6
7
8
9
10
11
12
|
@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {
@Produces
@Database(DatabaseType.DOCUMENT)
@Default
public DatabaseManager get() {
return DatabaseContainer.INSTANCE.get("hotel");
}
}
|
使用Cucumber,我们可以定义一个ObjectFactory,将类注入Cucumber测试上下文。由于我们使用CDI和Weld作为实现,我们将创建一个自定义的WeldCucumberObjectFactory来无缝集成这两种技术。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public class WeldCucumberObjectFactory implements ObjectFactory {
private Weld weld;
private WeldContainer container;
@Override
public void start() {
weld = new Weld();
container = weld.initialize();
}
@Override
public void stop() {
if (weld != null) {
weld.shutdown();
}
}
@Override
public boolean addClass(Class<?> stepClass) {
return true;
}
@Override
public <T> T getInstance(Class<T> type) {
return (T) container.select(type).get();
}
}
|
一个重要说明:此设置作为SPI(服务提供者接口)工作。因此,您必须创建以下文件:
src/test/resources/META-INF/services/io.cucumber.core.backend.ObjectFactory
内容如下:
1
|
org.soujava.demos.hotels.config.WeldCucumberObjectFactory
|
我们将让Mapper将我们的表转换为所有模型中的Room。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@ApplicationScoped
public class RoomDataTableMapper {
@DataTableType
public Room roomEntry(Map<String, String> entry) {
return Room.builder()
.number(Integer.parseInt(entry.get("number")))
.type(RoomType.valueOf(entry.get("type")))
.status(RoomStatus.valueOf(entry.get("status")))
.cleanStatus(CleanStatus.valueOf(entry.get("cleanStatus")))
.build();
}
}
|
整个测试基础设施完成后,下一步是设计包含我们测试的Step测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
@ApplicationScoped
public class HotelRoomSteps {
@Inject
private RoomRepository repository;
@Before
public void cleanDatabase() {
repository.deleteBy();
}
@Given("the hotel management system is operational")
public void theHotelManagementSystemIsOperational() {
Assertions.assertThat(repository).as("RoomRepository should be initialized").isNotNull();
}
@When("I register a room with number {int}")
public void iRegisterARoomWithNumber(Integer number) {
Room room = Room.builder()
.number(number)
.type(RoomType.STANDARD)
.status(RoomStatus.AVAILABLE)
.cleanStatus(CleanStatus.CLEAN)
.build();
repository.save(room);
}
@Then("the room with number {int} should appear in the room list")
public void theRoomWithNumberShouldAppearInTheRoomList(Integer number) {
List<Room> rooms = repository.findAll();
Assertions.assertThat(rooms)
.extracting(Room::getNumber)
.contains(number);
}
@When("I register the following rooms:")
public void iRegisterTheFollowingRooms(List<Room> rooms) {
rooms.forEach(repository::save);
}
@Then("there should be {int} rooms available in the system")
public void thereShouldBeRoomsAvailableInTheSystem(int expectedCount) {
List<Room> rooms = repository.findAll();
Assertions.assertThat(rooms).hasSize(expectedCount);
}
@Given("a room with number {int} is registered as {word}")
public void aRoomWithNumberIsRegisteredAs(Integer number, String statusName) {
RoomStatus status = RoomStatus.valueOf(statusName);
Room room = Room.builder()
.number(number)
.type(RoomType.STANDARD)
.status(status)
.cleanStatus(CleanStatus.CLEAN)
.build();
repository.save(room);
}
@When("I mark the room {int} as {word}")
public void iMarkTheRoomAs(Integer number, String newStatusName) {
RoomStatus newStatus = RoomStatus.valueOf(newStatusName);
Optional<Room> roomOpt = repository.findByNumber(number);
Assertions.assertThat(roomOpt)
.as("Room %s should exist", number)
.isPresent();
Room updatedRoom = roomOpt.orElseThrow();
updatedRoom.update(newStatus);
repository.save(updatedRoom);
}
@Then("the room {int} should be marked as {word}")
public void theRoomShouldBeMarkedAs(Integer number, String expectedStatusName) {
RoomStatus expectedStatus = RoomStatus.valueOf(expectedStatusName);
Optional<Room> roomOpt = repository.findByNumber(number);
Assertions.assertThat(roomOpt)
.as("Room %s should exist", number)
.isPresent()
.get()
.extracting(Room::getStatus)
.isEqualTo(expectedStatus);
}
}
|
是时候执行测试了:
您可以看到结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
INFO: Connecting to Oracle NoSQL database at http://localhost:61325 using ON_PREMISES deployment type
✔ Given the hotel management system is operational # org.soujava.demos.hotels.HotelRoomSteps.theHotelManagementSystemIsOperational()
✔ And a room with number 101 is registered as AVAILABLE # org.soujava.demos.hotels.HotelRoomSteps.aRoomWithNumberIsRegisteredAs(java.lang.Integer,java.lang.String)
✔ When I mark the room 101 as OUT_OF_SERVICE # org.soujava.demos.hotels.HotelRoomSteps.iMarkTheRoomAs(java.lang.Integer,java.lang.String)
✔ Then the room 101 should be marked as OUT_OF_SERVICE # org.soujava.demos.hotels.HotelRoomSteps.theRoomShouldBeMarkedAs(java.lang.Integer,java.lang.String)
Oct 21, 2025 6:18:43 PM org.jboss.weld.environment.se.WeldContainer shutdown
INFO: WELD-ENV-002001: Weld SE container fc4b3b51-fba8-4ea6-9cef-42bcee97d220 shut down
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.231 s -- in org.soujava.demos.hotels.RunCucumberTest
[INFO] Running org.soujava.demos.hotels.MongoDBTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in org.soujava.demos.hotels.MongoDBTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
|
结论
通过结合领域驱动设计(DDD)和行为驱动开发(BDD),开发人员可以超越技术正确性,构建真正反映业务意图的软件。DDD为领域提供结构,确保模型精确捕捉现实世界概念,而BDD通过用业务本身的语言编写的清晰、可测试的场景,确保这些模型按预期行为。
在本文中,您学习了如何使用Oracle NoSQL、Eclipse JNoSQL和Jakarta EE连接这两个世界——从定义您的领域到运行由Cucumber和CDI驱动的真实行为测试。这种协同作用将测试转化为活文档,弥合工程师和利益相关者之间的差距,并确保您的系统在演进过程中与业务目标保持一致。
您可以深入探索并将DDD与BDD结合。在《使用Java进行领域驱动设计》一书中,您可以找到理解为什么DDD对我们仍然重要的良好起点。它扩展了这里分享的想法,展示了DDD和BDD如何共同导致更简单、更可维护和更注重业务的软件。这种软件提供了超越需求的实际价值。