todo-api
本文将 ToDo 应用 扩展为一个 REST 服务,将数据保存在数据库中,也就是CRUD。
扩展前准备
在写任何代码之前,我们要搞清楚要把应用搞成什么样子。
把ToDo 扩展为 REST 服务就是说,原来本地的操作要以 REST 的方式提供。此外,Repository 要提供一个基于数据库的实现。
我们暂时不考虑多用户的情况,专注与测试本身,增加更多的功能时需求实现的事情。
确定好了需求目标,我们进入到具体的实现过程。我们可以建立一个新的模块来放置这些代码,叫做 todo-api。具体的技术栈,使用当前社区常用的 Spring Boot。
同之前一样,我们先来实现 Repository 的部分,然后再来做接口。
为什么我们不是实现业务核心部分?
因为我们之前将业务核心部分隔离了出来,让它不依赖具体的实现。虽然我们是将命令行改写成一个 RESTful API,但业务核心没有任何改变,所以也不用重写编写一份。这就是软件设计的价值所在。
数据访问
我们选择 MySQL 这给数据库,访问数据库的程序库选择 Spring Data JPA,它可以让我尽可能少写代码。
技术选型
数据库常用的程序库有 MyBatis 和 JPA。MyBatis 倾向于手工编写 SQL 语句, JPA 采用更加面向对象的角度,JPA 访问数据库的 SQL 通常由框架生成。
两者的主要差异是 Mybatis 更倾向于具体实现 , JPA 提供了更好的抽象能力。
数据库迁移
编码工作之前,我们要先确定好 Todo 项存储的数据结构。也就是数据库要创建那些表
CREATE TABLE todo_items
(
`id` int auto_increment,
`content` varchar(255) not null,
`done` tinyint not null default 0,
primary key (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然后我们为实体类加上一些 JPA 的注解
@Getter
@Entity
@Table(name = "todo_items")
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public class TodoItem {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter
private long index;
@Column
private String content;
@Column
@Setter
private boolean done;
...
}
本项目采用了 Flyway 迁移数据库,所以我们把sql文件放到
$rootDir/gradle/config/migration,执行我们的 gradle 任务就可以创建好表(flywayMigrate 是我们编写的gradle task)。
./gradlew flywayMigrate
数据库表已经生成好了,我们要准备测试了。
编写测试
测试数据库相关的内容属于兼具继承测试和单元测试两种属性的测试,
一方面它要对数据库做集成,另一方面,它要测的内容本身属于验证一个单元代码是否编写正确的范畴。
Spring 对数据库相关测试提供了很好的支持。
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:test.properties")
class TodoItemJpaRepositoryTest {
@Autowired
private TodoItemJpaRepository repository;
@Test
public void should_find_nothing_for_empty_repository() {
final Iterable<TodoItem> items = repository.findAll();
assertThat(items).hasSize(0);
}
}
@DataJpaTest ,表示这个测试采用 Spring Data JPA. 有了这个注解,Spring 会替我们把 Repository 实例生成出来。
我们来看看 TodoItemJpaRepository 接口
@Repository
public interface TodoItemJpaRepository extends TodoItemRepository, JpaRepository<TodoItem, Long> {
@Override
TodoItem save(TodoItem todoItem);
}
同之前相比,方法没有变化,只是扩展了 JpaRepository, 这是一个标记接口,没有方法。
实现这个接口是 Spring Data JPA 的要求,它会在运行时为接口生成实例,所以我们不许编写具体实现。
按照 Spring Data JPA 的要求,我们要配置一下 实体类 和 Repository 的扫描路径。
@SpringBootApplication
@EnableJpaRepositories(basePackages = {"io.github.himcs.todo.api"})
@EntityScan("io.github.himcs.todo")
public class Bootstrap {
...
}
如果一切顺利,测试会一次性通过。
测试并不会在数据库留下痕迹,测试在运行后回滚了数据, 这是 SpringJpaTest的缺省行为。
RESTful API
有了 Repository ,接下来我们来设计实现 API 接口。
设计 RESTful API
回顾一下 ToDo 应用的能力,包括
- 添加一个 Todo 项;
- 完成一个 Todo 项;
- Todo 项列表。
所有的能力都是围绕这 Todo 项进行的,所以我们把他们设计在一个资源下,可以把 URI 设计为 /todo-items。
- 首先是添加一个 Todo 项
一般创建会用 POST , 格式我们采用最常用的 JSON 。
POST /todo-items
{
"content": "foo"
}
- 完成一个 Todo 项
修改一般会使用 PUT
PUT /todo-items/{index}
{
done: true
}
- todo 项列表
查询一般使用 GET。我们的需求里的查询会有一个是否查询所有的参数,所以我们添加一个参数 all,默认为false。
GET /todo-items?all=true
测试 RESTful API
同 Repository 部分一样,我们这部分测试从之前的测试借鉴过来,所以本篇不重点分析测试场景,而来看如何编写测试。
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class TodoItemResourceTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private TodoItemRepository repository;
...
.
@Test
public void should_add_item() throws Exception {
String todoItem = "{ " +
"\"content\": \"foo\"" +
"}";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().isCreated());
assertThat(repository.findAll()).anyMatch(item -> item.getContent().equals("foo"));
}
...
}
这个类 是测试 添加 Todo项的,开头有几个注解
- @SpringBootTest, 表示接下来的测试是集成测试,因为最外面的接口很薄,所以把集成测试和单元测试放到了一起。
- @AutoConfigureMockMvc,表示我们要使用的是模拟的网络环境,也就不是真实的网络环境,这样做可以让访问速度快一些。
- @Transactional,说明这个测试是事务性的,在缺省的测试事务中,执行完测试之后,数据是要回滚,也就是不对数据库造成实际的影响。这要单独标记,否则就会有数据写入到数据库里面。而之前的 @DataJpaTest 自身就包含了这个 Annotation,所以不用特别声明。
有了上面这些基础,我们可以测试了。我们可以认为,当我们执行测试时服务已经起好了,我们就像一个普通客户端一样取访问服务。
核心部分是下面这段。
todoItem = "{ " +
"\"content\": \"foo\"" +
"}";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().isCreated());
我们创建了一个请求,设置了请求的信息, 使用 POST ,JSON格式,设置内容等等。然后预期返回上面。
这里我们使用的是 MockMVC ,因为我们配置了 @AutoConfigureMockMvc,它创建了一个模拟的网络环境。这就是 Spring 在测试做的很好的地方,正常情况下这些接口都是标准的网络环境,但 Spring 为我们提供了测试专用的实现,也就是不同的运行时,这就是做好了软件设计的结果。
我们还可以测试一些输入外部接口行为,例如传入空串会怎么办。
@Test
public void should_fail_to_add_unknown_request() throws Exception {
String todoItem = "";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().is4xxClientError());
}
编写 RESTful API
@RestController
@RequestMapping("/todo-items")
public class TodoItemResource {
private TodoItemService service;
@Autowired
public TodoItemResource(final TodoItemService service) {
this.service = service;
}
@PostMapping
public ResponseEntity addTodoItem(@RequestBody final AddTodoItemRequest request) {
if (Strings.isNullOrEmpty(request.getContent())) {
return ResponseEntity.badRequest().build();
}
final TodoParameter parameter = TodoParameter.of(request.getContent());
final TodoItem todoItem = this.service.addTodoItem(parameter);
final URI uri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(todoItem.getIndex())
.toUri();
return ResponseEntity.created(uri).build();
}
...
}`
一段普通的 MVC 代码。
这里我们要注意,使用 AddTodoItemRequest
实体来接收请求体.HTTP 请求传输的是文本 , Spring 框架会帮我们把文本转换为 Java 对象。我们要把转换规则声明出来,Spring Boot 采用的 JSON 框架是 Jackson, 所以我们要在类上加上 Jackson 的规则。
public class AddTodoItemRequest {
@Getter
private String content;
@JsonCreator
public AddTodoItemRequest(@JsonProperty("content") final String content) {
this.content = content;
}
}
总结
扩展 ToDo 应用为 REST 服务
框架
- Sping Data JPA 数据库交互
- Flyway 数据库迁移
集成测试
- @SpringBootTest
- @DataJpaTest 默认回滚
- @Transactional 测试完成,数据库回滚
设计
- 防腐层,接口层,请求参数与业务参数隔离
如果今天的内容你只能记住一句话,那么请记住,集成测试回滚数据,保证测试的可重复性。