在 Martin Fowler 的企业应用架构模式中介绍了四种关系数据库处理的模式。对于比较复杂的应用,比较常见的就是 active record 模式和 data mapper 模式。active record 正如 rails
的 activerecord
将面向业务的领域模型与数据实现绑定起来,Hibernate 和 JPA 就是采用的这种模式,通过标注可以将一个领域对象映射到数据库表中。而 data mapper 则强调领域模型和关系型数据库(当然,实际上也可以处理 noSQL 的)的数据结构是有差异的,需要一个 mapping 处理两者的差异,不能将两个东西融合成一个,这就是 MyBatis 所提供的能力。虽然如今的 Spring Data 已经非常的强大了,通过简单的接口声明就能够创建一个可以完成 CRUD
的 Repository
,通过在对象之间建立关联关系就能处理更复杂的联表查询。但是这样子依然不能解决一系列的问题:
convertor
,那么使用 JPA 的优势就不再明显了。@Query
注解和 sql
语句随处可见。activerecord
相比,它还是不够好用...说的挺让人伤心的,但是的确如此,努力了这么多年,就是做了一个 activerecord
的弱化版。那些快速的、用于忽悠的 CRUD
样例到目前为止,能和 rails 的脚手架比么...而且之前也提过,这种玩具代码毫无意义,我们需要的是可以处理复杂应用的情况,不然为啥不用 rails?另外,不论是 DDD 的书籍,还是 Applying UML and Patterns 或者是 Spring 的开山鼻祖 Rod Johnson 的 expert one-on-one J2EE Development without EJB 都在强调能够很好的实施面向对象的体系才是好的体系。MyBatis 做为一个 Data Mapper 的实现模式,完全的独立与业务对象,加上它 type handler
discriminator
的这些机制,可以很好的支持灵活的数据转换方式以及对象的多态机制。绝对是复杂业务系统的不二之选。
MyBatis 提供了一个 starter
用于和 Spring Boot 的集成。build.gradle
如下:
buildscript {
ext {
springBootVersion = '1.5.3.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile('org.flywaydb:flyway-core')
compile('org.mybatis.spring.boot:mybatis-spring-boot-starter:1.3.0')
compile('org.springframework.boot:spring-boot-starter-web')
runtime('com.h2database:h2')
compileOnly('org.projectlombok:lombok')
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.mybatis.spring.boot:mybatis-spring-boot-starter-test:1.3.0')
}
可以看到,首先我引用了 flyway
做数据 migration。然后我只用了一个 h2 内存数据库,然后除了 mybatis-spring-boot-starter
之外还有一个 mybatis-spring-boo-starter-test
后面会讲到它。
这里我们举一个简单的例子,展示用 MyBatis 创建一个 Repository
的方式。有关 Repository 概念的内容可以在这里看到。
// User.java
@Data // [1]
public class User {
private final String id;
private final String username;
public User(String id, String username) {
this.id = id;
this.username = username;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id) &&
Objects.equals(username, user.username);
}
@Override
public int hashCode() {
return Objects.hash(id, username);
}
}
// UserRepository.java
@Repository
public interface UserRepository {
void save(User user);
Optional<User> findById(String userId); // [2]
}
// MyBatisUserRepository.java
@Repository
public class MyBatisUserRepository implements UserRepository {
@Autowired
private UserMapper mapper; // [3]
@Override
public void save(User user) {
mapper.insert(user);
}
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(mapper.findById(id));
}
}
// UserMapper.java
@Component
@Mapper
public interface UserMapper {
void insert(@Param("user") User user);
User findById(@Param("id") String id);
}
在业务领域,只有 User
UserRepository
而在具体的实现上,是采用了 MyBatisUserRepository
以及其所依赖的 UserMapper
具体的实现隐藏的很深,好处就是支持未来对其进行替换。
当然,很多时候、很多人都说尼玛这种替换怎么可能,很明显是想多了。但实际上我觉得未必如此,很多时候数据库的切换不一定是说你已经积攒了 2TB 数据了才去这么做,比如在开发的末期出现了一些严重影响架构的因素导致需要对数据库进行调整,你说这时候算早还是算晚呢?而且,通过技术手段尽量延迟决定本来就是一个很好的思路。再者,测试环境和生产环境采用不同的 Repository 也是很常见的情况呀,硬绑定了不就都变成集成测试了吗。
其中在代码中 [1]
的那个注解 @Data
源自 lombok 大大减少了 java 的模板代码。
前面提到的 mybatis-spring-boot-starter-test
这里要排上用场了。它提供了一个超超超好用了注解 MyBatisTest
,官方对其解释如下:
By default it will configure MyBatis(MyBatis-Spring) components(SqlSessionFactory and SqlSessionTemplate), configure MyBatis mapper interfaces and configure an in-memory embedded database. MyBatis tests are transactional and rollback at the end of each test by default.
也就是说,它会自动的帮助创建 embedded database 并且自动的采用 transactional 以及 rollback。有了它我们真是只需要关注业务逻辑就行了。下面是对 MyBatisUserRepository
的测试。
@RunWith(SpringRunner.class)
@MybatisTest
@Import(MyBatisUserRepository.class)
public class MyBatisRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
public void should_save_user_success() throws Exception {
User user = new User(UUID.randomUUID().toString(), "abc");
userRepository.save(user);
Optional<User> userOptional = userRepository.findById(user.getId());
assertThat(userOptional.get(), is(user));
}
}
详细内容见 mybatis-spring-boot-test-autoconfigure
最后还是要讲一些集成的额外内容。
src/main/resources
下有 db/migration
的目录,目录中的 migration 脚本以 V1__name
V2__name
V3__name
格式命名。更多内容见 flyway 官网。src/main/resources/application.properties
做一些配置。application.properties
或者 mybatis-config.xml
中指定 Mapper 的位置完整的项目见 Github
上一篇文章 介绍了我从 Jersey 切换到 Spring Boot 的一些原因,虽然伴随着一些无奈,但是还是对 Spring Boot 充满了信心。但是在学习的过程中我也发现了一些问题。
首先,我发现 Spring Boot 的版本更迭非常的快,而不同的版本的很多语法和支持都有一定的区别,当遇到一个问题去 stackoverflow 搜索的时候经常会发现不同版本的解决方案,弄得我很是苦恼。(真是找到了用 npm 的感觉,每次升级包都会出问题。每到这个时候就念到了 rails 的好,一个成熟的、稳定、合理的生态体系是多么的重要!)。在这里我明确的在标题里提到了我所使用的版本 1.5.3
也希望 Spring Boot 在之后能够尽量的保持各个版本的一致性。
其次,Spring 官网提供了太多的 Getting Started 比如这个或者是 Hello World 的示例。这些示例真的是太太太简单了,完全没办法作为学习的材料(再次强调,能不能看看人家 Rails 官方的 Guide 呀),而去其他地方搜索的内容又大多是过时(因为版本更迭快呀)的内容。所以我这里也希望尽量覆盖更全的场景,使得这里的内容可以作为实际开发中的参考。
注意 这里所展示的测试的例子是对 RESTful API 的测试,在前后端分离,构建微服务的今天,我们在 Spring MVC 中做模板渲染的情况越来越少了了,我们主要处理的是 JSON 数据:我们的输入不是传统的表单数据而是 JSON,我们的输出不再是 HTML 而是 JSON。
测试的重要性在 ThoughtWorks 是老生常谈了,但实际上并不是所有的团队都会在写代码的同时写测试,在看到大量的 Spring Boot 的文章和代码的时候居然很难找到一个完整的、包含着测试的项目,真是恐怖。不过做了一些 search 之后我发现 Spring Boot 目前的测试真的是非常的简单,和 Jersey 比的话那真是好的太多了。一个基本的、纯粹的 Spring MVC 的测试长如下的样子,这里涉及多个例子,我会一点点做介绍。
@RunWith(SpringRunner.class) // [1]
public class UsersApiTest {
private UserRepository userRepository;
@Before
public void setUp() throws Exception {
userRepository = mock(UserRepository.class);
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new UsersApi(userRepository))
.setControllerAdvice(new CustomizeExceptionHandler())
.build(); // [2]
RestAssuredMockMvc.mockMvc(mockMvc); // [3]
}
@Test
public void should_get_empty_user_lists_success() throws Exception {
// [4]
given().
when().
get("/users").
then().
statusCode(200);
}
@Test
public void should_create_user_success() throws Exception {
Map<String, Object> createUserParameter = new HashMap<String, Object>() {{
put("username", "aisensiy");
}};
given()
.contentType("application/json")
.body(createUserParameter)
.when().post("/users")
.then().statusCode(201);
verify(userRepository).save(any());
}
@Test
public void should_get_400_error_message_with_wrong_parameter_when_create_user() throws Exception {
Map<String, Object> wrongParameter = new HashMap<String, Object>() {{
put("name", "aisensiy");
}};
given()
.contentType("application/json")
.body(wrongParameter)
.when().post("/users")
.then().statusCode(400)
.body("fieldErrors[0].field", equalTo("username")) // [5]
.body("fieldErrors.size()", equalTo(1));
}
@Test
public void should_get_one_user_success() throws Exception {
User user = new User(UUID.randomUUID().toString(), "aisensiy");
when(userRepository.findById(eq(user.getId())))
.thenReturn(Optional.of(user));
given()
.standaloneSetup(new UserApi(userRepository))
.when().get("/users/{userId}", user.getId()) // [6]
.then().statusCode(200)
.body("id", equalTo(user.getId()))
.body("username", equalTo(user.getUsername()))
.body("links.self", endsWith("/users/" + user.getId()));
}
}
以上的代码包含了四个测试用例,测试内容如下:
GET /users
获取用户列表POST /users
用合法的参数创建一个用户,返回创建成功POST /users
用非法的参数创建一个用户,返回参数错误信息GET /users/{userId}
获取单个用户的信息下面我按照对代码中标注的点一个个做解释:
SpringJUnit4ClassRunner
被替换为更容易阅读的 SpringRunner
,在 stackoverflow 中会找到大量的 SpringJUnit4ClassRunner
对我这种刚接触的人来说真是带来了很多的困惑。另外,我们在这里并没有使用一个 SpringBootTest
的注解,SpringBootTest 是只有需要一个比较完整的 Spring Boot 环境的时候(比如需要做集成测试,启动 EmbeddedWebApplicationContext
的时候)需要。而我们这里仅仅通过单元测试就可以完成任务了,这样的好处是可以大大提升测试的速度。MockMvcBuilders
是 Spring MVC 提供的一个 mock 环境,使我们可以不启动 HTTP server 就能进行测试。这里我们通过 standaloneSetup
的方法创建我们要测试的 UsersApi
并且通过 setControllerAdvice
添加错误处理的机制。有关 ControllerAdvice
做异常处理的内容我们会在后面的文章中介绍。build.gradle
引入了 rest assured 的两个包用于 json 的测试,我们通过这个语句将所创建的 mock mvc 提供给 rest assured。status code
,实际的项目中可能需要做更详细的 json 内容的测试body("fieldErrors[0].field", equalTo("username"))
这种直接读取 json path 的测试方式相对将 json 转化成 map 再一点点的读取字段来说真是方便的太多,有关这种测试的其他内容详见 rest assured 官方文档PathVariable
类似大多数情况下,通过 standaloneSetup
的方式就可以对 Controller
进行有效的单元测试了,当然 MockMvcBuilders
也可以引入外部的 ControllerAdvice
对错误处理进行测试。加上 rest assured 测试 json api 真是简单了太多了。不过这里并没有覆盖 filter 的测试,后面的有关安全的文章会补上。
最后附上项目所使用的 build.gradle
,完整的项目内容可以在 Github 找到。
// build.gradle
buildscript {
ext {
springBootVersion = '1.5.3.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile('org.flywaydb:flyway-core')
compile('org.mybatis.spring.boot:mybatis-spring-boot-starter:1.3.0')
compile("org.springframework.boot:spring-boot-starter-hateoas")
compile('org.springframework.boot:spring-boot-starter-web')
runtime('com.h2database:h2')
compileOnly('org.projectlombok:lombok')
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.mybatis.spring.boot:mybatis-spring-boot-starter-test:1.3.0')
testCompile 'io.rest-assured:rest-assured:3.0.2'
testCompile 'io.rest-assured:spring-mock-mvc:3.0.2'
}
前一阵子去了联想的项目去做性能调优,顺便也正儿八经的接触了一下 spring boot 的体系(当然也使用了很多 spring cloud 的内容,这个以后再讲)。这里简单的对比一下它和我之前主要使用的 jersey 体系,讲一下我看到的它们两者之间的差异以及 spring boot 相比 jersey 的一些优势和个别的不足。
再次回到 spring
的主题也是感慨万千,这让我想起来本科时候刚开始接触 web 开发的情况。那时候 spring + hibernate + structs
是 web 开发的主流框架。不过鉴于当时我自己水平有限,spring
的水平基本上提留在了 Spring in Action
前三章的水平。在经历了 PHP Python(Django),ruby(Rails),Jersey 之后又能回到 Spring 不得不说 Pivotal 旗下的 Spring 团队功不可没。Spring boot 自己的 Reference 所说的,Spring boot 给了开发者一个很好的 getting started 的体验并且并且了大量 xml 配置的实现方式,本来我以为之前我所看到的如此简洁的 main
只是因为是 demo 但是当我看到联想这边的生产代码也依然优雅的时候敬畏之心油然而生。
目前来看 Spring 的体系非常的完备,一方面其核心 DI 和 AOP 组件本来就是 java 语言的标配,再加上与各种持久化框架、模板引擎的完美整合已经称得上包罗万象。另一方面,微服务架构逐渐成为主流的今天,spring cloud 体系的构建也非常的及时,大量的组件解决了云环境、微服务的诸多问题。与 spring 强大的生态相比,jersey 作为一个纯粹的 web framework 来说实在是太无力了,并且其与其他模块的组合也显得捉襟见肘。jersey 自己采用一个叫做 hk2
的依赖注入框架,它用起来并不那么方便,在之前的多个项目中,我们甚至需要把 hk2 和 guava 的容器建立一个 bridge 才能让它们一起工作,需要大量的模板代码,我曾经试图把之前遗留的模板代码进行重构但由于担心影响到生产环境的稳定性最终还是放弃了。
当时和 Jersey 相比,Spring MVC 绝对是 spring 体系中的一个弱势。Jersey 实现了 JAX-RS
的标准,很明显这套标注的实现比 Spring MVC 的要好用,并且 jersey 中有一个非常重要的概念:sub resource,它允许一个 url
进行链式解析。比如下面的 url:
/users/1234/posts/123
可以理解为用户 1234
的 id 为 123
的文章。在 jersey 中,我们可以用一下的方式实现:
//UsersApi.java
@Path("users") // [1]
public class UsersApi {
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<User> getUsers(@Context UserRepository users) {
return users.getAll();
}
@GET
@Path("{userId}")
public String getOneUserById(@PathParam("userId") String userId,
@Context UserRepository userRepository) {
return userRepository.getUserById(userId)
.map(UserApi::new) // [2]
.orElseThrow(() -> new UserNotFoundException()); // [3]
}
}
//UserApi.java
public class UserApi {
private User user;
public UserApi(User user) {
this.user = user;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public User getUser() {
return user;
}
@Path("posts")
public UserEvaluationsApi userEvaluationsApi() {
return new UserPostsApi(user);
}
}
//UserPostsApi.java
@Path("projects")
public class UserPostsApi.java {
@Path("{postId}")
public ProjectApi getPostApi(@PathParam("postId") String postId,
@Context PostRepository postRepository) {
return postRepository
.findById(postId)
.map(UserPostApi::new)
.orElseThrow(() -> new PostNotFoundException();
}
}
//UserPostApi.java
public class UserPostApi.java {
private Post post;
public UserPostApi(Post post) {
this.post = post;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public Post getPost() {
return post;
}
}
如上所示,四个类 UsersApi
UserApi
UserPostsApi
UserPostApi
将整个流程切分成了四块,每个流程按照 url 逐步解析,其中:
UsersApi
为入口(EntryPoint),只有它拥有类级别的 @Path
而 Spring MVC 则完全不支持这样的方式,和大多数 mvc 框架一样,它只能老老实实的按照 pattern 对整个 url 解析,不论是在处理 /users/123
还是处理 /users/123/posts/1234
都需要捕捉 UserNotFoundException
的异常。
// UsersApi.java
@RestController
@RequestMapping("/users")
public class UsersApi {
private UserRepository userRepository;
@Autowired
public UsersApi(UserRepository userRepository) {
this.userRepository = userRepository;
}
@RequestMapping(method = GET)
public List<User> getUsers() {
return userRepository.findAll();
}
}
// UserApi.java
@RestController
@RequestMapping("/users/{userId}")
public class UserApi {
@Autowired
private UserRepository userRepository;
@RequestMapping(method = GET)
return User getUser(@PathVariable("userId") String userId) {
return userRepository.getUserById(userId)
.map(user -> user)
.orElseThrow(() -> new UserNotFoundException());
}
}
// UserPostsApi.java
@RestController
@RequestMapping("/users/{userId}/posts")
public class UserPostsApi {
@Autowired
private UserRepository userRepository;
@Autowired
private PostRepository postRepository;
@RequestMapping("/{postId}", method = GET)
return Post getPost(@PathVariable("userId") String userId,
@PathVariable("postId") String postId) {
if (!userRepository.getUserById(userId).isPresent()) {
throw new UserNotFoundException();
}
return postRepository
.findById(postId)
.map(post -> post)
.orElseThrow(() -> new PostNotFoundException();
}
}
前面已经提到了 spring 可以和很多其他的框架完美的结合,那么能不能让 jersey 和 spring boot 完美的结合在一起呢?这样的话既拥有了 spring boot 又能拥有 jersey 的 sub resource 的构建方式,一举两得呀。但现实给我泼了桶冷水。
首先,Jersey 自成体系,想要和其他框架结合会产生一定的工程摩擦。Spring mvc 和 spring core 自然是很好的集成了的,但是 jersey 中自己的那个 hk2 依赖注入框架和 spring 就不能那么好的相处了。使用的时候只能将其全部换成 spring 的依赖注入方式。同时 spring mvc 有一个 mock mvc 测试体系,它大大加速的测试的速度,然而它仅仅支持 spring mvc。并且到目前为止,我都没有找到任何一个很好的测试 jersey 的方式,其自身的测试框架在 spring 体系下的结合实例我就没见到过,而其他 mock 的支持也没走通过。
另一方面,spring 体系中 spring mvc 虽然在我看起来还是有很多的缺点,但是它遵循的是大量 web 框架的模式,比如 django 的 url dispatcher 比如 rails 的 routes 都是类似的 url 映射模式。Spring MVC 同样是沿着 web 的发展趋势一路走来,作为一个历史悠久的框架自然也继承了大多数 web MVC 的特点,也应该会被更多的人所接受,实在是无可厚非。所以,我不知道我自己坚持使用 jersey 是不是会给项目中其他成员带来伤害。
如下所示,jersey 的测试需要将整个 server 启动,采用 RANDOM_PORT
的方式进行测试。
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UsersApiTest {
@Value("${local.server.port}")
int port;
@MockBean
UserRepository userRepository;
@Before
public void setUp() throws Exception {
RestAssured.port = port;
}
@Test
public void should_get_empty_user_lists() throws Exception {
when(userRepository.getAll()).thenReturn(Arrays.asList(new User("123", "aisensiy")));
io.restassured.RestAssured.when()
.get("/users")
.then()
.statusCode(200);
}
}
而 spring mvc 的测试则可以使用 mock mvc 测试速度快,并且支持 standaloneSetup
模式,对单个 controller 进行测试。
@RunWith(SpringRunner.class)
public class UsersApiTest {
private UserRepository userRepository;
@Before
public void setUp() throws Exception {
userRepository = mock(UserRepository.class);
// 只对 UsersApi 进行测试
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new UsersApi(userRepository))
.setControllerAdvice(new CustomizeExceptionHandler()).build();
RestAssuredMockMvc.mockMvc(mockMvc);
}
@Test
public void should_get_empty_user_lists_success() throws Exception {
given().
when().
get("/users").
then().
statusCode(200);
}
}
当然,优雅的测试是重头戏,后面的文章中会介绍一些我自己发觉的测试的模式。