根据业务,我们开始编写用例,接下来我们开始编程。
编写用例
网站登录,考虑以下用例
- 用户正常登录
- 用户无账号,注册账号
- 用户忘记密码,重置密码
- 用户注销登录
示例代码(JAVA):
@RestController
public class UserController {
/**
* 登录
*/
public void login(){
}
/**
* 注册
*/
public void register(){
}
/**
* 重置密码
*/
public void reset(){
}
/**
* 退出登录
*/
public void logout(){
}
}
这样,先将业务的方法写好,占住位置,待以后实现这些功能。
这么做的优点是,提醒自己需要完成的功能,避免因为实现某一功能太过深入,而忘记其他需要实现的功能,也避免了频繁切换查看需求文档。
本来这应该是测试驱动开发中的做法,但我们现在是开头,还没开始编写Service服务层,所以把这些业务都罗列到Controller中。
在这一阶段,我们需要考虑我们是否完全穷尽了该场景下的需求?
比如,是否支持第三方登录,限制尝试密码的次数等等需求。当然,如果用例是由别人给出的,那这就是别人应该考虑的了。
陈列所有需求之后,控制器层的编码远没有完成。
确定接收的参数
Controller层是处理与UI或通信协议有关的整个软件系统的门面,它的任务包括:
- 匹配路由
- 接收参数
- 调用服务
- 返回消息
那么接下来,我们优先进行哪个任务呢?
按道理,我们实现某个功能方法,需要考虑输入和输出,而输入的参数又依赖匹配的路由,所以应该先设计访问路由。然而,路由的设计需要通盘考虑所有功能,甚至可能需要restful风格的API。
所以,建议优先思考接收的参数。即,先与客户方确定好请求数据的格式
对于用户登录这一场景,首先想到接收账号密码,作为登录。
示例代码如下:
@RestController
public class UserController {
@Autowired
UserApplicationService service;
/**
* 登录
*/
@PostMapping("/user/login")
public String login(@RequestBody LoginCommand command){
service.login(command);
return "登录成功!";
}
}
由于登录功能实际上是交给服务层完成的,需要创建Service应用服务层文件。
应用服务层的代码暂时可以像Controller这样,依次编写方法占位。
示例代码:
@Service
public class UserApplicationService{
@Transactional
public void login(LoginCommand command){
}
}
而返回的参数,当然不能仅仅返回个字符串,我们最后肯定要返回JSON的。
但是目前为止,我们还不清楚最后应该返回什么内容,所以先用“登录成功!”标记下来。
而路由的话,目前想不到什么比较好的路由设计,也先用“/user/login”标记下来,之后全盘考虑时再做修改。
这样,第一次迭代结束,可以先进行测试,确保访问正常。
其中,对于接收的参数,因为是通过JSON提交的,所以需要为它设计一个Command对象,用于接收消息体。
public class LoginCommand{
private String username;
private String password;
//省略Getter和Setter、构造器等方法...
//省略的方法可由Lombok或IDEAJ快速生成
}
同时,对于应用服务的接收参数的建议如下:
//应用服务推荐接收id或命令对象,命令操作是外部向领域模型发起的一次写操作,它封装原始数据类型
//用命令对象的好处是可以概览软件向外部提供的功能,领域模型与外部隔离
//技巧:若命令对象只有1-2个字段,甚至可以把命令对象解开,只传递原始数据
public OrderId createOrder(CreateOrderCommand command) ;
public void changeProductCount(String id, ChangeProductCountCommand command) ;
public void pay(String id, PayOrderCommand command) ;
public void changeAddressDetail(String id, String detail) ;
应用服务
应用服务,是领域模型的门面。在DDD中业务被提到第一优先级,所以我们希望对业务的处理能提取出来。
在DDD中,实现业务功能应该采用自顶向下的实现方式。
ApplicationService采用了门面模式,作为领域模型向外提供业务功能的总出入口,就像酒店的前台处理客户的不同需求一样。
//示例代码OrderApplicationService:
//该方法目的为改变订单中产品的数量,必须是事务操作
@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
//接收参数为实体ID和命令对象
//获得Order对象
Order order = orderRepository.byId(orderId(id));
//order自身业务逻辑:获得产品ID,改变数量
order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
//order仓库保存对象
orderRepository.save(order);
}
通过Controller层调用ApplicationService。
一个业务用例对应应用服务上的一个业务方法,即Controller控制器的方法与ApplicationService应用服务的方法对应。
//获得订单ID和命令对象
@PostMapping("/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
//通过Controller层调用ApplicationService
orderApplicationService.changeProductCount(id, command);
}
对业务需求的处理流程体现了DDD处理业务需求的最常见最典型的形式:
应用服务作为总体协调者
- 先通过资源库获取到聚合根
- 然后调用聚合根中的业务方法
- 最后再次调用资源库保存聚合根。
获取聚合根
软件中的写操作要么是修改既有数据,要么是新建数据。
对于前者,DDD要从资源库获取聚合根,而新建数据要创建聚合根。
聚合根的创建过程可简单可复杂
简单时,直接调用构造函数即可
复杂时,比如需要调用其他系统获取数据等
其中,使用工厂方法的主要动机是创建复杂的对象和聚合。
工厂应该提供一个创建对象的接口,接口封装所有创建对象的复杂操作过程。
//聚合根中的工厂方法
//工厂方法create(),业务上的创建
public static Product create(String name, String description, BigDecimal price) {
return new Product(name, description, price);
}
//构造函数,技术上的创建
private Product(String name, String description, BigDecimal price) {
this.id = ProductId.newProductId();
this.name = name;
this.description = description;
this.price = price;
this.createdAt = Instant.now();
}
工厂方法的优势:
1.分离技术和业务上的职责,表达通用语言
2.减少输入参数,减轻创建对象的负担
3.封装对象的创建,确保对象的创建处于正确状态
劣势:
由于要从持久化存储中获得聚合根,对性能上有影响
资源库
聚合根创建之后需要持久化到硬盘,否则在内存中会被垃圾回收机制回收。
而资源库作为聚合根的家,提供聚合根的查询和保存,类似于HashMap的get和set方法。
只有聚合根才配拥有资源库Repository,若是查询其他的功能则在展现层提供。
比如,实现Order的资源库OrderRepository如下:
//只提供两个方法
public void save(Order order) {
String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) " +
"ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;";
Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order));
jdbcTemplate.update(sql, paramMap);
}
public Order byId(OrderId id) {
try {
String sql = "SELECT JSON_CONTENT FROM ORDERS WHERE ID=:id;";
return jdbcTemplate.queryForObject(sql, of("id", id.toString()), mapper());
} catch (EmptyResultDataAccessException e) {
throw new OrderNotFoundException(id);
}
}
通常仓库接口会放在领域目录里,供领域模型调用,然后实现Repository会放基础设施目录里。比如MybatisRepository和JpaRepostiory提供不同的实现。
总结
综合下来,开发的第一次迭代过程如下:
1.接到需求后,考虑业务流程,划分出主题域,找出聚合根
2.根据聚合根,创建Controller文件,编写方法覆盖聚合根的该业务场景的所有功能。
3.开始准备完成第一个功能
4.处理输入的参数,根据需求创建命令对象,标记路由等。
5.创建应用服务文件,编写方法供Contoller层调用
6.暂时用字符串作为返回消息。
7.测试路由访问是否正常
在之前的第一次迭代完成后,可以测试是否能正常响应。接下来该完成实际业务逻辑。