SpringBoot2.x入門:使用MyBatis

throwable發表於2020-07-19

這是公眾號《Throwable文摘》釋出的第25篇原創文章,收錄於專輯《SpringBoot2.x入門》。

前提

這篇文章是《SpringBoot2.x入門》專輯的第8篇文章,使用的SpringBoot版本為2.3.1.RELEASEJDK版本為1.8

SpringBoot專案引入MyBatis一般的套路是直接引入mybatis-spring-boot-starter或者使用基於MyBatis進行二次封裝的框架例如MyBatis-Plus或者tk.mapper等,但是本文會使用一種更加原始的方式,單純依賴org.mybatis:mybatisorg.mybatis:mybatis-springMyBatis的功能整合到SpringBoot中,Spring(Boot)使用的是微核心架構,任何第三方框架或者外掛都可以按照本文的思路融合到該微核心中。

引入MyBatis依賴

編寫本文的時候(2020-07-18org.mybatis:mybatis的最新版本是3.5.5,而org.mybatis:mybatis-spring的最新版本是2.0.5,在使用BOM管理SpringBoot版本的前提下,引入下面的依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.5</version>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>2.0.5</version>
</dependency>

注意的是低版本的MyBatis如果需要使用JDK8的日期時間API,需要額外引入mybatis-typehandlers-jsr310依賴,但是某個版本之後mybatis-typehandlers-jsr310中的類已經移植到org.mybatis:mybatis中作為內建類,可以放心使用JDK8的日期時間API。

新增MyBatis配置

MyBatis的核心模組是SqlSessionFactoryMapperScannerConfigurer。前者可以使用SqlSessionFactoryBean,功能是為每個SQL的執行提供SqlSession和載入全域性配置或者SQL實現的XML檔案,後者是一個BeanDefinitionRegistryPostProcessor實現,主要功能是主動通過配置的基礎包(Base Package)中遞迴搜尋Mapper介面(這個算是MyBatis獨有的掃描階段,務必指定明確的掃描包,否則會因為效率太低導致啟動階段耗時增加),並且把它們註冊成MapperFactoryBean(簡單理解為介面動態代理實現新增到方法快取中,並且委託到IOC容器,此後可以直接注入Mapper介面),注意這個BeanFactoryPostProcessor的回撥優先順序極高,在自動裝配@Autowired族註解或者@ConfigurationProperties屬性繫結處理之前已經回撥,因此在處理MapperScannerConfigurer的屬性配置時候絕對不能使用@Value或者自定義字首屬性Bean進行自動裝配,但是可以從Environment中直接獲取。

這裡新增一個自定義屬性字首mybatis,用於繫結配置檔案中的屬性到MyBatisProperties類中:

@ConfigurationProperties(prefix = "mybatis")
@Data
public class MyBatisProperties {

    private String configLocation;
    private String mapperLocations;
    private String mapperPackages;

    private static final ResourcePatternResolver RESOLVER = new PathMatchingResourcePatternResolver();

    /**
     * 轉化Mapper對映檔案為Resource
     */
    public Resource[] getMapperResourceArray() {
        if (!StringUtils.hasLength(mapperLocations)) {
            return new Resource[0];
        }
        List<Resource> resources = new ArrayList<>();
        String[] locations = StringUtils.commaDelimitedListToStringArray(mapperLocations);
        for (String location : locations) {
            try {
                resources.addAll(Arrays.asList(RESOLVER.getResources(location)));
            } catch (IOException e) {
                throw new IllegalArgumentException(e);
            }
        }
        return resources.toArray(new Resource[0]);
    }
}

接著新增一個MybatisAutoConfiguration用於配置SqlSessionFactory

@Configuration
@EnableConfigurationProperties(value = {MyBatisProperties.class})
@ConditionalOnClass({SqlSessionFactory.class, SqlSessionFactoryBean.class})
@RequiredArgsConstructor
public class MybatisAutoConfiguration {

    private final MyBatisProperties myBatisProperties;

    @Bean
    public SqlSessionFactoryBean sqlSessionFactoryBean(DataSource dataSource) {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        // 其實核心配置就是這兩項,其他TypeHandlersPackage、TypeAliasesPackage等等自行斟酌是否需要新增
        bean.setConfigLocation(new ClassPathResource(myBatisProperties.getConfigLocation()));
        bean.setMapperLocations(myBatisProperties.getMapperResourceArray());
        return bean;
    }

    /**
     * 事務模板,用於程式設計式事務 - 可選配置
     */
    @Bean
    @ConditionalOnMissingBean
    public TransactionTemplate transactionTemplate(PlatformTransactionManager platformTransactionManager) {
        return new TransactionTemplate(platformTransactionManager);
    }

    /**
     * 資料來源事務管理器 - 可選配置
     */
    @Bean
    @ConditionalOnMissingBean
    public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

一般情況下,啟用事務需要定義PlatformTransactionManager的實現,而TransactionTemplate適用於程式設計式事務(和宣告式事務@Transactional區別,程式設計式更加靈活)。上面的配置類中只使用了兩個屬性,而mybatis.mapperPackages將用於MapperScannerConfigurer的載入上。新增MapperScannerRegistrarConfiguration如下:

@Configuration
public class MapperScannerRegistrarConfiguration {

    public static class AutoConfiguredMapperScannerRegistrar implements
            BeanFactoryAware, EnvironmentAware, ImportBeanDefinitionRegistrar {

        private Environment environment;
        private BeanFactory beanFactory;


        @Override
        public void setBeanFactory(@NonNull BeanFactory beanFactory) throws BeansException {
            this.beanFactory = beanFactory;
        }

        @Override
        public void setEnvironment(@NonNull Environment environment) {
            this.environment = environment;
        }

        @Override
        public void registerBeanDefinitions(@NonNull AnnotationMetadata importingClassMetadata,
                                            @NonNull BeanDefinitionRegistry registry) {
            BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(MapperScannerConfigurer.class);
            builder.addPropertyValue("processPropertyPlaceHolders", true);
            StringJoiner joiner = new StringJoiner(ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
            // 這裡使用了${mybatis.mapperPackages},否則會使用AutoConfigurationPackages.get(this.beanFactory)獲取專案中自定義配置的包
            String mapperPackages = environment.getProperty("mybatis.mapperPackages");
            if (null != mapperPackages) {
                String[] stringArray = StringUtils.commaDelimitedListToStringArray(mapperPackages);
                for (String pkg : stringArray) {
                    joiner.add(pkg);
                }
            } else {
                List<String> packages = AutoConfigurationPackages.get(this.beanFactory);
                for (String pkg : packages) {
                    joiner.add(pkg);
                }
            }
            builder.addPropertyValue("basePackage", joiner.toString());
            BeanWrapper beanWrapper = new BeanWrapperImpl(MapperScannerConfigurer.class);
            Stream.of(beanWrapper.getPropertyDescriptors())
                    .filter(x -> "lazyInitialization".equals(x.getName())).findAny()
                    .ifPresent(x -> builder.addPropertyValue("lazyInitialization",
                            "${mybatis.lazyInitialization:false}"));
            registry.registerBeanDefinition(MapperScannerConfigurer.class.getName(), builder.getBeanDefinition());
        }
    }

    @Configuration
    @Import(AutoConfiguredMapperScannerRegistrar.class)
    @ConditionalOnMissingBean({MapperFactoryBean.class, MapperScannerConfigurer.class})
    public static class MapperScannerRegistrarNotFoundConfiguration {

    }
}

到此基本的配置Bean已經定義完畢,接著需要新增配置項。一般一個專案的MyBatis配置是相對固定的,可以直接新增在主配置檔案application.properties中:

server.port=9098
spring.application.name=ch8-mybatis
mybatis.configLocation=mybatis-config.xml
mybatis.mapperLocations=classpath:mappings/base,classpath:mappings/ext
mybatis.mapperPackages=club.throwable.ch8.repository.mapper,club.throwable.ch8.repository

個人喜歡在resource/mappings目錄下定義baseext兩個目錄,base目錄用於存在MyBatis生成器生成的XML檔案,這樣就能在後續新增了表欄位之後直接重新生成和覆蓋base目錄下對應的XML檔案即可。同理,在專案的原始碼包下建repository/mapper,然後Mapper類直接存放在repository/mapper目錄,DAO類存放在repository目錄,MyBatis生成器生成的Mapper類可以直接覆蓋repository/mapper目錄中對應的類。

resources目錄下新增一個MyBatis的全域性配置檔案mybatis-config.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <!--下劃線轉駝峰-->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <!--未知列對映忽略-->
        <setting name="autoMappingUnknownColumnBehavior" value="NONE"/>
    </settings>
</configuration>

專案目前的基本結構如下:

使用Mybatis

為了簡單起見,這裡使用h2記憶體資料庫進行演示。新增h2的依賴:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.200</version>
</dependency>

resources目錄下新增一個schema.sqldata.sql

// resources/schema.sql
drop table if exists customer;

create table customer
(
    id            bigint generated by default as identity,
    customer_name varchar(32),
    age           int,
    create_time   timestamp default current_timestamp,
    edit_time     timestamp default current_timestamp,
    primary key (id)
);

// resources/data.sql
INSERT INTO customer(customer_name,age) VALUES ('doge', 22);
INSERT INTO customer(customer_name,age) VALUES ('throwable', 23);

新增對應的實體類club.throwable.ch8.entity.Customer

@Data
public class Customer {

    private Long id;
    private String customerName;
    private Integer age;
    private LocalDateTime createTime;
    private LocalDateTime editTime;
}

新增MapperDAO類:

// club.throwable.ch8.repository.mapper.CustomerMapper
public interface CustomerMapper {

}

// club.throwable.ch8.repository.CustomerDao
public interface CustomerDao extends CustomerMapper {

    Customer queryByName(@Param("customerName") String customerName);
}

新增XML檔案resource/mappings/base/BaseCustomerMapper.xmlresource/mappings/base/ExtCustomerMapper.xml

// BaseCustomerMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="club.throwable.ch8.repository.mapper.CustomerMapper">

    <resultMap id="BaseResultMap" type="club.throwable.ch8.entity.Customer">
        <id column="id" jdbcType="BIGINT" property="id"/>
        <result column="customer_name" jdbcType="VARCHAR" property="customerName"/>
        <result column="age" jdbcType="INTEGER" property="age"/>
        <result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
        <result column="edit_time" jdbcType="TIMESTAMP" property="editTime"/>
    </resultMap>

</mapper>

// ExtCustomerMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="club.throwable.ch8.repository.CustomerDao">

    <resultMap id="BaseResultMap" type="club.throwable.ch8.entity.Customer"
               extends="club.throwable.ch8.repository.mapper.CustomerMapper.BaseResultMap">
    </resultMap>

    <select id="queryByName" resultMap="BaseResultMap">
        SELECT *
        FROM customer
        WHERE customer_name = #{customerName}
    </select>

</mapper>

細心的夥伴會發現,DAO和Mapper類是繼承關係,而ext和base下對應的Mapper檔案中的BaseResultMap也是繼承關係

配置檔案中增加h2資料來源的配置:

// application.properties
spring.datasource.url=jdbc:h2:mem:db_customer;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=org.h2.Driver
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.h2.console.settings.web-allow-others=true
spring.datasource.schema=classpath:schema.sql
spring.datasource.data=classpath:data.sql

新增一個啟動類進行驗證:

public class Ch8Application implements CommandLineRunner {

    @Autowired
    private CustomerDao customerDao;

    @Autowired
    private ObjectMapper objectMapper;

    public static void main(String[] args) {
        SpringApplication.run(Ch8Application.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        Customer customer = customerDao.queryByName("doge");
        log.info("Query [name=doge],result:{}", objectMapper.writeValueAsString(customer));
        customer = customerDao.queryByName("throwable");
        log.info("Query [name=throwable],result:{}", objectMapper.writeValueAsString(customer));
    }
}

執行結果如下:

使用Mybatis生成器生成Mapper檔案

有些時候為了提高開發效率,更傾向於使用生成器去預生成一些已經具備簡單CRUD方法的Mapper檔案,這個時候可以使用mybatis-generator-core。編寫本文的時候(2020-07-18mybatis-generator-core的最新版本為1.4.0mybatis-generator-core可以通過程式設計式使用或者Maven外掛形式使用。

這裡僅僅簡單演示一下Maven外掛形式下使用mybatis-generator-core的方式,關於mybatis-generator-core後面會有一篇數萬字的文章詳細介紹此生成器的使用方式和配置項的細節。在專案的resources目錄下新增一個generatorConfig.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
    <context id="H2Tables" targetRuntime="MyBatis3">
        <property name="autoDelimitKeywords" value="true"/>
        <property name="javaFileEncoding" value="UTF-8"/>
        <property name="beginningDelimiter" value="`"/>
        <property name="endingDelimiter" value="`"/>

        <commentGenerator>
            <property name="suppressDate" value="true"/>
            <!-- 是否去除自動生成的註釋 true:是 : false:否 -->
            <property name="suppressAllComments" value="true"/>
            <property name="suppressDate" value="true"/>
        </commentGenerator>

        <jdbcConnection driverClass="org.h2.Driver"
                        connectionURL="jdbc:h2:mem:db_customer;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE"
                        userId="root"
                        password="123456"/>

        <javaTypeResolver>
            <property name="forceBigDecimals" value="false"/>
        </javaTypeResolver>

        <!-- 生成模型的包名和位置(實體類)-->
        <javaModelGenerator targetPackage="club.throwable.ch8.entity"
                            targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
            <property name="trimStrings" value="false"/>

        </javaModelGenerator>

        <!-- 生成對映XML檔案的包名和位置-->
        <sqlMapGenerator targetPackage="mappings.base"
                         targetProject="src/main/resources">
            <property name="enableSubPackages" value="true"/>
        </sqlMapGenerator>

        <!-- 生成DAO的包名和位置-->
        <javaClientGenerator type="XMLMAPPER"
                             targetPackage="club.throwable.ch8.repository.mapper"
                             targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
        </javaClientGenerator>

        <table tableName="customer"
               enableCountByExample="false"
               enableUpdateByExample="false"
               enableDeleteByExample="false"
               enableSelectByExample="false">
        </table>

    </context>
</generatorConfiguration>

然後再專案的POM檔案新增一個Maven外掛:

<plugins>
    <plugin>
        <groupId>org.mybatis.generator</groupId>
        <artifactId>mybatis-generator-maven-plugin</artifactId>
        <version>1.4.0</version>
        <executions>
            <execution>
                <id>Generate MyBatis Artifacts</id>
                <goals>
                    <goal>generate</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <jdbcURL>jdbc:h2:mem:db_customer;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE</jdbcURL>
            <jdbcDriver>org.h2.Driver</jdbcDriver>
            <jdbcUserId>root</jdbcUserId>
            <jdbcPassword>123456</jdbcPassword>
            <sqlScript>${basedir}/src/main/resources/schema.sql</sqlScript>
        </configuration>
        <dependencies>
            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
                <version>1.4.200</version>
            </dependency>
        </dependencies>
    </plugin>
</plugins>

筆者發現這裡必須要在外掛的配置中重新定義資料庫連線屬性和schema.sql,因為外掛跑的時候無法使用專案中已經啟動的h2例項,具體原因未知。

配置完畢之後,執行Maven命令:

mvn -Dmybatis.generator.overwrite=true mybatis-generator:generate -X

然後resource/mappings/base目錄下新增了一個帶有基本CRUD方法實現的CustomerMapper.xml,同時CustoemrMapper介面和Customer實體也被重新覆蓋生成了。

這裡把前面手動編寫的BaseCustomerMapper.xml註釋掉,預防衝突。另外,CustomerMapper.xml的insertSelective標籤需要加上keyColumn="id" keyProperty="id" useGeneratedKeys="true"屬性,用於實體insert後的主鍵回寫。

最後,修改並重啟啟動一下Ch8Application驗證結果:

小結

這篇文章相對詳細地介紹了SpringBoot專案如何使用MyBatis,如果需要連線MySQL或者其他資料庫,只需要修改資料來源配置和MyBatis生成器的配置檔案即可,其他配置類和專案骨架可以直接複用。

本文demo倉庫:

(本文完 c-2-d e-a-20200719 封面來自《秒速五釐米》)

公眾號《Throwable文摘》(id:throwable-doge),不定期推送架構設計、併發、原始碼探究相關的原創文章:

相關文章