存储过程

存储过程在数据库中比较常见,虽然大多数存储过程的调用比较复杂,但是使用 MyBatis 调用时,用法都一样,因此这一节将通过几个简单的存储过程带大家了解 MyBatis 中存储过程的使用方法。

先创建几个不同的存储过程,代码如下。

# 第一个存储过程
# 根据用户ID查询其他信息
# 方法看起来很奇怪,但是展示了多个输出参数
DROP PROCEDURE IF EXISTS 'select_user_by_id';

DELIMITER ;;
CREATE PROCEDURE select_user_by_id(
  IN  userId       BIGINT,
  OUT userName     VARCHAR(50),
  OUT userPassword VARCHAR(50),
  OUT userEmail    VARCHAR(50),
  OUT userInfo     TEXT,
  OUT headImg      BLOB,
  OUT createTime   DATETIME)
  BEGIN
    # 根据用户ID查询其他数据
    SELECT
      user_name,
      user_password,
      user_email,
      user_info,
      head_img,
      create_time
    INTO userName, userPassword, userEmail, userInfo, headImg, createTime
    FROM sys_user
    WHERE id = userId;
  END ;;
DELIMITER ;

# 第二个存储过程
# 简单根据用户名和分页参数进行查询,返回总数和分页数据
DROP PROCEDURE IF EXISTS 'select_user_page';
DELIMITER ;;
CREATE PROCEDURE select_user_page(
  IN  userName VARCHAR(50),
  IN  _offset  BIGINT,
  IN  _limit   BIGINT,
  OUT total    BIGINT)
  BEGIN
    # 查询数据总数
    SELECT count(*)
    INTO total
    FROM sys_user
    WHERE user_name LIKE concat('%', userName, '%');
    # 分页查询数据
    SELECT *
    FROM sys_user
    WHERE user_name LIKE concat('%', userName, '%')
    LIMIT _offset, _limit;
  END ;;
DELIMITER ;

# 第三个存储过程
# 保存用户信息和角色关联信息
DROP PROCEDURE IF EXISTS 'insert_user_and_roles';
DELIMITER ;;
CREATE PROCEDURE insert_user_and_roles(
  OUT userId       BIGINT,
  IN  userName     VARCHAR(50),
  IN  userPassword VARCHAR(50),
  IN  userEmail    VARCHAR(50),
  IN  userInfo     TEXT,
  IN  headImg      BLOB,
  OUT createTime   DATETIME,
  IN  roleIds      VARCHAR(200))
  BEGIN
    # 设置当前时间
    SET createTime = NOW();
    # 插入数据
    INSERT INTO sys_user (user_name, user_password, user_email, user_info, head_img, create_time)
    VALUES (userName, userPassword, userEmail, userInfo, headImg, createTime);
    # 获取自增主键
    SELECT last_insert_id()
    INTO userId;
    # 保存用户和角色关系数据
    SET roleIds = CONCAT(',', roleIds, ',');
    INSERT INTO sys_user_role (user_id, role_id)
      SELECT
        userId,
        id
      FROM sys_role
      WHERE instr(roleIds, CONCAT(',', id, ',')) > 0;
  END ;;
DELIMITER ;

# 第四个存储过程
# 删除用户信息和角色关联信息
DROP PROCEDURE IF EXISTS 'delete_user_by_id';
DELIMITER ;;
CREATE PROCEDURE delete_user_by_id(IN userId BIGINT)
  BEGIN
    DELETE FROM sys_user_role
    WHERE user_id = userId;
    DELETE FROM sys_user
    WHERE id = userId;
  END ;;
DELIMITER ;

下面针对上述代码中的 4 个简单存储过程分别来使用 MyBatis。

第一个存储过程

在 UserMapper.xml 映射文件中添加如下方法。

<select id="selectUserById" statementType="CALLABLE" useCache="false">
    {call select_user_by_id(
            #{id,mode=IN},
            #{userName,mode=OUT,jdbcType=VARCHAR},
            #{userPassword,mode=OUT,jdbcType=VARCHAR},
            #{userEmail,mode=OUT,jdbcType=VARCHAR},
            #{userInfo,mode=OUT,jdbcType=VARCHAR},
            #{headImg,mode=OUT,jdbcType=BLOB,javaType=_byte[]},
            #{createTime,mode=OUT,jdbcType=TIMESTAMP}
    )}
</select>

在调用存储过程的方法中,需要把 statementType 设置为 CALLABLE,在使用 select 标签调用存储过程时,由于存储过程方式不支持 MyBatis 的二级缓存(后面章节会介绍),因此为了避免缓存配置出错,直接将 select 标签的 useCache 属性设置为 false。

在存储过程中使用参数时,除了写上必要的属性名,还必须指定参数的 mode(模式),可选值为 IN、OUT、INOUT 三种。入参使用 IN,出参使用 OUT,输入输出参数使用 INOUT。从上面的代码可以很容易看出,IN 和 OUT 两种模式的区别是,OUT 模式的参数必须指定 jdbcType。这是因为在 IN 模式下,MyBatis 提供了默认的 jdbcType,在 OUT 模式下没有提供。另外在使用 Oracle 数据库时,如果入参存在 null 的情况,那么入参也必须指定 jdbcType。

除了上面提到的这几点,headImg 还特别设置了 javaType。在 MyBatis 映射的 Java 类中,不推荐使用基本类型,数据库 BLOB 类型对应的 Java 类型通常都是写成 byte[] 字节数组的形式的,因为 byte[] 数组不存在默认值的问题,所以不影响一般的使用。但是在不指定 javaType 的情况下,MyBatis 默认使用 Byte 类型。由于 byte 是基本类型,所以设置 javaType 时要使用带下画线的方式,在这里就是 _byte[]。_byte 对应的是基本类型,byte 对应的是 Byte 类型,在使用 javaType 时一定要注意。

在 UserMapper 接口中添加相应的方法,代码如下。

/**
 * 使用存储过程查询用户信息
 * @param user
 */
void selectUserById(SysUser user);

因为这个存储过程没有返回值(不要和出参混淆),所以返回值类型使用 void。把返回值设置为 SysUser 或 List 也不会报错,但是任何时候返回值都是 null。

针对该方法,在 UserMapperTest 中编写如下测试。

@Test
public void testSelectUserById(){
    //获取 sqlSession
    SqlSession sqlSession = getSqlSession();
    try {
        //获取 UserMapper 接口
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        SysUser user = new SysUser();
        user.setId(1L);
        userMapper.selectUserById(user);
        Assert.assertNotNull(user.getUserName());
        System.out.println("用户名:" + user.getUserName());
    } finally {
        //不要忘记关闭 sqlSession
        sqlSession.close();
    }
}

该测试执行后,输出日志如下。

从日志可以看到,这个存储过程没有返回值,我们使用出参的方式得到了该用户的信息。使用出参方式时,通常情况下会使用对象中的属性接收出参的值,或者使用 Map 类型接收返回值。这两种情况有很大的区别。当使用 JavaBean 对象接收出参时,必须保证所有出参在 JavaBean 中都有对应的属性存在,否则就会抛出类似 “Could not set property’xxx'” 这样的错误。这是由于 JavaBean 对象中不存在出参对应的 setter 方法,使用 Map 类型时就不需要保证所有出参都有对应的属性,当 Map 接收了存储过程的出参时,可以通过 Map 对象的 get("属性名") 方法获取出参的值。

除了上面提到的错误,在执行存储过程时还可能会遇到下以下错误。

Parameter number x is not an OUT parameter

产生这个错误可能是因为调用的存储过程不存在,或者 MyBatis 中写的出参和数据库存储过程的出参无法对应。

第二个存储过程

继续在 UserMapper.xml 映射文件中添加如下方法。

<select id="selectUserPage" statementType="CALLABLE" useCache="false" resultMap="baseResultMap">
    {call select_user_page(
            #{userName,mode=IN},
            #{offset,mode=IN},
            #{limit,mode=IN},
            #{total,mode=OUT,jdbcType=BIGINT}
    )}
</select>

这个方法和第一个方法的区别在于,select 标签还设置了 resultMap,因为该方法通过 total 出参得到了查询的总数,通过存储过程返回了最后的结果集,所以需要设置返回值信息。

在 UserMapper 接口中添加如下方法。

/**
 * 使用存储过程分页查询
 * @param params userName/pageNum/pageSize/total
 * @return 用户集合
 */
List<SysUser> selectUserPage(Map<String,Object> params);

由于需要多个入参和一个出参,而入参中除了 userName 属性在 SysUser 中,其他 3 个参数都和 SysUser 无关,因此为了使用 SysUser 而增加 3 个属性也是可以的。这里为了实现方法多样化,也为了印证上一个方法中使用 Map 接收返回值的用法是正确的,因此使用 Map 类型作为参数。

在 UserMapperTest 中添加如下的测试代码。

@Test
public void testSelectUserPage(){
    //获取 sqlSession
    SqlSession sqlSession = getSqlSession();
    try {
        //获取 UserMapper 接口
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        Map<String, Object> params = new HashMap<String, Object>();
        params.put("userName", "ad");
        params.put("offset", 0);
        params.put("limit", 10);
        List<SysUser> userList = userMapper.selectUserPage(params);
        Long total = (Long) params.get("total");
        System.out.println("总数:" + total);
        for(SysUser user : userList){
            System.out.println("用户名:" + user.getUserName());
        }
    } finally {
        //不要忘记关闭 sqlSession
        sqlSession.close();
    }
}

测试代码的 Map 参数中不存在 total,执行了存储过程方法后,通过 get 方法得到了 total 的值。上面测试代码的输出日志如下。

为了更有效地测试这个分页查询的存储过程,可以向数据库中增加大量数据进行测试。

第三个和第四个存储过程

由于后面两个存储过程一个是插入用户和用户角色关联数据的,一个是删除用户和用户角色关联数据的,因此可以将这两部分放到一起来介绍。

在 UserMapper.xml 中添加如下两个方法。

<insert id="insertUserAndRoles" statementType="CALLABLE">
    {call insert_user_and_roles(
            #{user.id,mode=OUT,jdbcType=BIGINT},
            #{user.userName,mode=IN},
            #{user.userPassword,mode=IN},
            #{user.userEmail,mode=IN},
            #{user.userInfo,mode=IN},
            #{user.headImg,mode=IN,jdbcType=BLOB},
            #{user.createTime,mode=OUT,jdbcType=TIMESTAMP},
            #{roleIds,mode=IN}
    )}
</insert>

<delete id="deleteUserById" statementType="CALLABLE">
    {call delete_user_by_id(#{id,mode=IN})}
</delete>

这里要注意的是,我们分别使用了 insert 和 delete,同样设置 statementType 属性为 CALLABLE。

在 UserMapper 接口中添加如下两个方法。

/**
 * 使用存储过程保存用户信息和角色关联信息
 * @param user 用户信息
 * @param roleIds 角色ID集合(多个ID之间用英文逗号分隔)
 */
void insertUserAndRoles(@Param("user")SysUser user,@Param("roleIds")String roleIds);

/**
 * 根据用户ID删除用户和用户的角色信息
 * @param id 用户ID
 */
void deleteUserById(Long id);

在 insertUserAndRoles 方法中,SysUser 参数存储了用户基本信息,roleIds 参数存储了该用户的角色 id 字符串,如 "1,2,3",注意要使用逗号隔开多个 id。

在 UserMapperTest 中添加如下测试。

@Test
public void testInsertAndDelete(){
    //获取 sqlSession
    SqlSession sqlSession = getSqlSession();
    try {
        //获取 UserMapper 接口
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

        SysUser user = new SysUser();
        user.setUserName("test1");
        user.setUserPassword("123456");
        user.setUserEmail("test@mybatis.tk");
        user.setUserInfo("test info");
        //正常情况下应该读入一张图片存到 byte 数组中
        user.setHeadImg(new byte[]{1,2,3});
        //插入数据
        userMapper.insertUserAndRoles(user, "1,2");
        Assert.assertNotNull(user.getId());
        Assert.assertNotNull(user.getCreateTime());
        //可以执行下面的 commit 后查看数据库中的数据
        //sqlSession.commit();
        //测试删除刚刚插入的数据
        userMapper.deleteUserById(user.getId());
    } finally {
        //不要忘记关闭 sqlSession
        sqlSession.close();
    }
}

执行该方法,输出的日志如下。

在 insertUserAndRoles 存储过程中,我们通过出参实现了主键和日期的回写,之后通过返回的主键删除了对应的全部数据。

在Oracle中使用游标参数的存储过程

由于 MySQL 不支持游标参数,因此除了上面4种简单的存储过程,本节将针对 Oracle 数据库介绍一种简单的游标参数。首先在 Oracle 中创建第 1 章中提到的 Country 表,然后添加如下存储过程。

# 在Oracle中使用游标参数的存储过程
create or replace procedure SELECT_COUNTRIES(
  ref_cur1 out sys_refcursor,
  ref_cur2 out sys_refcursor) is
begin
  open ref_cur1 for select * from country where id < 3;
  open ref_cur2 for select * from country where id >= 3;
end SELECT_COUNTRIES;

为了能说明更多的问题,以及让代码尽可能简单,这个存储过程只有两个游标类型的出参,游标对应的值就是两个简单的 SQL。这个存储过程可以返回两个 List 结果,这里都是 country 对应的类型,实际上可以是任何能映射的类型。

在 CountryMapper.xml 中添加如下方法。

<select id="selectCountries" statementType="CALLABLE" useCache="false">
    {call SELECT_COUNTRIES(
            #{list1,mode=OUT,jdbcType=CURSOR,javaType=ResultSet,resultMap=BaseResultMap},
            #{list2,mode=OUT,jdbcType=CURSOR,javaType=ResultSet,resultMap=BaseResultMap}
    )}
</select>

使用游标类型时,需要注意将 jdbcType 设置为 CURSOR,将 javaType 设置为 ResultSet。除此之外,由于返回的游标是一个多列的复杂结果,因此要使用 resultMap 配置游标结果列的映射。

在 CountryMapper 接口中添加如下方法。

/**
 * 执行Oracle中的存储过程
 * @param params 入数
 * @return 出参
 */
Object selectCountries(Map<String,Object> params);

可以增加 Oracle 的 JDBC 驱动,然后修改 mybatis-config.xml 中的数据库配置,在可以连接到 Oracle 数据库的前提下,执行下面的测试代码。

@Test
public void testMapperWithStartPage3() {
    SqlSession sqlSession = getSqlSession();
    CountryMapper countryMapper = sqlSession.getMapper(CountryMapper.class);
    try {
        //获取第1页,10条内容,默认查询总数count
        Map<String, Object> params = new HashMap<String, Object>();
        countryMapper.selectCountries(params);
        List<Country> list1 = (List<Country>) params.get("list1");
        List<Country> list2 = (List<Country>) params.get("list2");
        Assert.assertNotNull(list1);
        Assert.assertNotNull(list2);
    } finally {
        sqlSession.close();
    }
}

执行该测试,输出的日志如下。

常见的存储过程大概就是以上几种,只要掌握了这几种用法,几乎就可以应对所有的情况了。