Eisen's Blog

© 2024. All rights reserved.

把 Spring Boot 1.5.3 与 MyBatis 集成

2017 May-04

为什么选择 MyBatis

在 Martin Fowler 的企业应用架构模式中介绍了四种关系数据库处理的模式。对于比较复杂的应用,比较常见的就是 active record 模式和 data mapper 模式。active record 正如 railsactiverecord 将面向业务的领域模型与数据实现绑定起来,Hibernate 和 JPA 就是采用的这种模式,通过标注可以将一个领域对象映射到数据库表中。而 data mapper 则强调领域模型和关系型数据库(当然,实际上也可以处理 noSQL 的)的数据结构是有差异的,需要一个 mapping 处理两者的差异,不能将两个东西融合成一个,这就是 MyBatis 所提供的能力。虽然如今的 Spring Data 已经非常的强大了,通过简单的接口声明就能够创建一个可以完成 CRUDRepository,通过在对象之间建立关联关系就能处理更复杂的联表查询。但是这样子依然不能解决一系列的问题:

  1. 数据模型与领域模型的绑定:我还是需要把一个领域对象通过注解直接映射到数据对象,但是有的时候我的领域对象是一个聚合根(Aggregate Root),它包含一系列实体(Entity)和值对象(Value Object),这简单的注解做不到呀,我还是需要耗费很多的力气去做 convertor,那么使用 JPA 的优势就不再明显了。
  2. 实现读写分离难度大,正如我在 some tips for ddd 中所说的,DDD 关注的是一个写模型,关注领域的构建以及模型内数据的一致性。然而 JPA 实际上并没有考虑到这一点,它默认的实现是希望有一个统一的模型,不考虑读写模型的区别,而在这个基础上对其做读写的分离其难度是大于灵活性更强的 MyBatis 的。
  3. 通常在采用 rest api 进行数据展示的 GET 方法中所提供的数据是读模型中的数据,会使用大量的多表 join 以及参数的直接或间接映射,其实采用 jpa 的注解进行包裹反而显得不方便了。我不认为 spring data 提供的那种查询可以很好的处理,至少在我参与的稍微复杂的项目中,内嵌在 JpaRepository 中的 @Query 注解和 sql 语句随处可见。
  4. 和 rails 的 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 的这些机制,可以很好的支持灵活的数据转换方式以及对象的多态机制。绝对是复杂业务系统的不二之选。

集成 Spring Boot 与 MyBatis

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

前面提到的 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

其他

最后还是要讲一些集成的额外内容。

  1. flyway 要求在项目的 src/main/resources 下有 db/migration 的目录,目录中的 migration 脚本以 V1__name V2__name V3__name 格式命名。更多内容见 flyway 官网
  2. Mybatis 需要配置一个 mybatis-config.xml 文件,并在 src/main/resources/application.properties 做一些配置。
  3. 如果使用 XML 定义 Mapper 还需要在 application.properties 或者 mybatis-config.xml 中指定 Mapper 的位置

完整的项目见 Github


在 Spring Boot 1.5.3 中进行 Spring MVC 测试

2017 May-04

上一篇文章 介绍了我从 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()));
    }
}

以上的代码包含了四个测试用例,测试内容如下:

  1. GET /users 获取用户列表
  2. POST /users 用合法的参数创建一个用户,返回创建成功
  3. POST /users 用非法的参数创建一个用户,返回参数错误信息
  4. GET /users/{userId} 获取单个用户的信息

下面我按照对代码中标注的点一个个做解释:

  1. 老版本的 SpringJUnit4ClassRunner 被替换为更容易阅读的 SpringRunner,在 stackoverflow 中会找到大量的 SpringJUnit4ClassRunner 对我这种刚接触的人来说真是带来了很多的困惑。另外,我们在这里并没有使用一个 SpringBootTest 的注解,SpringBootTest 是只有需要一个比较完整的 Spring Boot 环境的时候(比如需要做集成测试,启动 EmbeddedWebApplicationContext 的时候)需要。而我们这里仅仅通过单元测试就可以完成任务了,这样的好处是可以大大提升测试的速度。
  2. MockMvcBuilders 是 Spring MVC 提供的一个 mock 环境,使我们可以不启动 HTTP server 就能进行测试。这里我们通过 standaloneSetup 的方法创建我们要测试的 UsersApi 并且通过 setControllerAdvice 添加错误处理的机制。有关 ControllerAdvice 做异常处理的内容我们会在后面的文章中介绍。
  3. 我们在 build.gradle 引入了 rest assured 的两个包用于 json 的测试,我们通过这个语句将所创建的 mock mvc 提供给 rest assured。
  4. 使用了 rest assured 的测试可读性大大的增强了,这里就是检查了请求所获取的 status code,实际的项目中可能需要做更详细的 json 内容的测试
  5. body("fieldErrors[0].field", equalTo("username")) 这种直接读取 json path 的测试方式相对将 json 转化成 map 再一点点的读取字段来说真是方便的太多,有关这种测试的其他内容详见 rest assured 官方文档
  6. 这里是一个包含动态 url 的例子,其使用方式和在 Spring MVC 中使用 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 Getting Started

2017 May-03

前一阵子去了联想的项目去做性能调优,顺便也正儿八经的接触了一下 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 拥有完备的生态体系

目前来看 Spring 的体系非常的完备,一方面其核心 DI 和 AOP 组件本来就是 java 语言的标配,再加上与各种持久化框架、模板引擎的完美整合已经称得上包罗万象。另一方面,微服务架构逐渐成为主流的今天,spring cloud 体系的构建也非常的及时,大量的组件解决了云环境、微服务的诸多问题。与 spring 强大的生态相比,jersey 作为一个纯粹的 web framework 来说实在是太无力了,并且其与其他模块的组合也显得捉襟见肘。jersey 自己采用一个叫做 hk2 的依赖注入框架,它用起来并不那么方便,在之前的多个项目中,我们甚至需要把 hk2 和 guava 的容器建立一个 bridge 才能让它们一起工作,需要大量的模板代码,我曾经试图把之前遗留的模板代码进行重构但由于担心影响到生产环境的稳定性最终还是放弃了。

Jersey 的 sub resource

当时和 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 逐步解析,其中:

  1. UsersApi 为入口(EntryPoint),只有它拥有类级别的 @Path
  2. 当需要进行下一步的 url 处理时,可以主动创建 sub resource
  3. 如果当前层次报错,则可以终止 url 的处理

而 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();
    }
}

将 jersey 和 spring boot 整合的尝试

前面已经提到了 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);
    }
}

当然,优雅的测试是重头戏,后面的文章中会介绍一些我自己发觉的测试的模式。

参考