java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Mybatis操作Clickhouse数组

Mybatis操作Clickhouse数组的最佳实践分享

作者:Jaising666

ClickHouse 的 Array(T) 数据类型支持任意有效数据类型作为元素,包括基本类型、嵌套数组和可空类型,本文给大家分享了Mybatis操作Clickhouse数组的最佳实践,需要的朋友可以参考下

ClickHouse Array 类型概述

ClickHouse 的 Array(T) 数据类型支持任意有效数据类型作为元素,包括基本类型、嵌套数组和可空类型,关键特性包括:

-- 有效用法
SELECT array(1, 2, 3);           -- Array(UInt8)
SELECT array('a', 'b', 'c');     -- Array(String)
SELECT array([1, 2], [3, 4]);    -- Array(Array(UInt8))

-- 无效用法:类型不兼容
SELECT array(1, 'a');
-- Error: There is no supertype for types UInt8, String

MyBatis 写入 ClickHouse 数组的两种方法比较

JPA 与 Mybatis 是常见的两种 ORM 框架。JPA 主要为 OLTP 设计,ClickHouse 是 OLAP 数据库,JPQL 难以表达 ClickHouse 的复杂分析查询,而这正好可以发挥 Mybaits 灵活控制 SQL 的特性。对于 Clickhouse 的数组类型写入一般有两种方法:

方法一:使用 ClickHouse array() 函数 + ${} 参数替换

Mybatis XML 代码:

insert into xxx_base
    (array1)
values (array(${array1Value}))

Java 代码:

// 手动格式化数组
List<String> userIds = Arrays.asList("user1", "user2", "user3");
String userIdsStr = userIds.stream()
    .map(s -> "'" + s.replace("'", "\\'") + "'")  // 转义单引号
    .collect(Collectors.joining(","));
// userIdsStr = "'user1','user2','user3'"

// 处理空值
String deviceIdsStr = deviceIds.isEmpty() ? "" : 
    deviceIds.stream()
        .map(s -> "'" + s.replace("'", "\\'") + "'")
        .collect(Collectors.joining(","));

方法二:使用自定义 TypeHandler + #{} 参数绑定

Mybatis XML 代码:

insert into xxx_base
    (array1)
values (#{array1Value,typeHandler=com.test.clickhousemybatisdemo.typehandler.ClickHouseArrayTypeHandler})

Java 代码:

// 直接使用List对象
List<String> userIds = Arrays.asList("user1", "user2", "user3");
List<String> deviceIds = new ArrayList<>();  // 空列表也可以直接使用
// TypeHandler会自动处理转换和空值情况

方案对比分析

维度array() + ${}TypeHandler + #{}
安全性❌ SQL注入风险✅ 预编译安全,类型安全
可读性❌ 需要格式化处理✅ 直接使用List
可维护性❌ 逻辑分散✅ 逻辑集中
性能⚠️ 字符串拼接开销✅ 预编译缓存

得到的结论是推荐方法二:

自定义 TypeHandler 的优势

MyBatis 内置的 TypeHandler 主要针对关系型数据库的标准 SQL 类型设计,对于 ClickHouse 这样的分析型数据库的特殊数据类型支持有限,Java 的List<T>与 ClickHouse 的Array(T)之间缺少直接的类型转换机制。而如果使用 Java String 来处理又会带来数据类型频繁转换的工程问题,代码可读性与可维护性都会受到影响。

ClickHouse JDBC 驱动对数组类型的处理与传统关系型数据库存在差异:

// 传统数据库的数组处理(如PostgreSQL)
Array sqlArray = connection.createArrayOf("varchar", stringArray);

// ClickHouse需要特殊的类型名称映射
Array sqlArray = connection.createArrayOf("String", stringArray);  // 注意:"String"而非"varchar"

于是,扩展 TypeHandler 实现处理数组问题就变得有必要:

  1. 双向转换:实现 Java List<T> ↔ ClickHouse Array(T) 的无缝转换
  2. 类型安全:确保编译期和运行期的类型一致性
  3. 空值处理:正确处理 null 值和空数组的边界情况
  4. 性能优化:避免不必要的字符串拼接和解析开销

扩展 TypeHandler 支持 Clickhouse Array

TypeHandler 接口设计

Mybatis TypeHandler 的设计就是为了缓解 JDBC 与 Java 数据类型不匹配的问题,通过扩展 TypeHandler 可以对各种数据库的各种数据类型予以支持。

TypeHandler 接口很简洁,一个是 setParameter 方法通过 PreparedStatement 为 SQL 语句绑定参数,实现 JDBC 到 Java 的数据类型转换;另外三个 getResult 重载方法通过 ResultSet 获取数据时,将 Java 转换成 JDBC 数据类型。

public interface TypeHandler<T> {

  void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

  T getResult(ResultSet rs, String columnName) throws SQLException;

  T getResult(ResultSet rs, int columnIndex) throws SQLException;

  T getResult(CallableStatement cs, int columnIndex) throws SQLException;
}

BaseTypeHandler 抽象类设计

BaseTypeHandler 实现 TypeHandler 接口抽象成基类,setParameter 提取出参数是否为空的条件判断,如果为空就会调用 PreparedStatement#setNull,如果不为空就会调用 setNonNullParameter。前者会委托给具体的数据库驱动,这里引入的 clickhouse-jdbc 就会实现 setNull 方法;后者则会交给具体的 TypeHandler 实现类。

类似的,BaseTypeHandler 实现了 TypeHandler#getResult 后抽象出了 getNullableResult 方法,委托给具体的 TypeHandler 实现。

public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> {

  @Override
  public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
    if (parameter == null) {
      if (jdbcType == null) {
        ...
        ps.setNull(i, jdbcType.TYPE_CODE);
        ...
    } else {
      ...
      setNonNullParameter(ps, i, parameter, jdbcType);
      ...
    }
  }

  @Override
  public T getResult(ResultSet rs, String columnName) throws SQLException {
	...
    return getNullableResult(rs, columnIndex);
    ...
  }

  @Override
  public T getResult(ResultSet rs, int columnIndex) throws SQLException {
	...
    return getNullableResult(rs, columnIndex);
    ...
  }

  @Override
  public T getResult(CallableStatement cs, int columnIndex) throws SQLException {
	...
    return getNullableResult(rs, columnIndex);
    ...
  }

  public abstract void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType)
      throws SQLException;

  public abstract T getNullableResult(ResultSet rs, String columnName) throws SQLException;

  public abstract T getNullableResult(ResultSet rs, int columnIndex) throws SQLException;

  public abstract T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException;

}

要想扩展简单的 TypeHandler 就可以继承 BaseTypeHandler,实现设置非空 Java 数据类型和获取非空 JDBC 数据类型的 4 个抽象方法。

Mybatis 内置了一些常用的 BaseTypeHandler 实现类,比如 ArrayTypeHandler(当然,这个指的是 Java 中的基础类型 Array 而不是 List)、ClobTypeHandler、LocalDateTimeTypeHandler 等。

实现 BaseTypeHandler<List<String>>

参考这些内置的 BaseTypeHandler,容易实现支持 Java 的 List 与 ClickHouse 的 Array 的类型绑定,首先支持 List<String> 与 Array(String) 的类型绑定。

/**
 * ClickHouse Array(String) 类型处理器
 * 处理 Java List<String> 和 ClickHouse Array(String) 之间的转换
 */
@MappedTypes(List.class)
@MappedJdbcTypes(JdbcType.ARRAY)
public class ClickHouseArrayTypeHandler extends BaseTypeHandler<List<String>> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
        if (parameter == null || parameter.isEmpty()) {
            ps.setArray(i, null);
        } else {
            // 将 List<String> 转换为数组
            String[] array = parameter.toArray(new String[0]);
            Array sqlArray = ps.getConnection().createArrayOf("String", array);
            ps.setArray(i, sqlArray);
        }
    }

    @Override
    public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
        Array array = rs.getArray(columnName);
        return convertArrayToList(array);
    }

    @Override
    public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        Array array = rs.getArray(columnIndex);
        return convertArrayToList(array);
    }

    @Override
    public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        Array array = cs.getArray(columnIndex);
        return convertArrayToList(array);
    }

    /**
     * 将 SQL Array 转换为 List<String>
     */
    private List<String> convertArrayToList(Array sqlArray) throws SQLException {
        if (sqlArray == null) {
            return new ArrayList<>();
        }
        
        Object[] array = (Object[]) sqlArray.getArray();
        if (array == null || array.length == 0) {
            return new ArrayList<>();
        }
        
        List<String> result = new ArrayList<>();
        for (Object item : array) {
            if (item != null) {
                result.add(item.toString());
            }
        }
        return result;
    }
}

实现 BaseTypeHandler<List<T>>

进一步的,可以将 Array(String) 扩展为支持 Array(T),这样可以处理 Clickhouse 通用数组类型。

通过动态类型检测实现多数据类型支持,getSqlTypeName 方法提供 Java 类型到 ClickHouse 类型的映射机制:

/**
 * ClickHouse Array(T) 类型处理器
 * 处理 Java List<T> 和 ClickHouse Array(T) 之间的转换
 * 支持 String, Integer, Long, Double, Float, Boolean 等基本类型
 */
@MappedTypes(List.class)
@MappedJdbcTypes(JdbcType.ARRAY)
public class ClickHouseArrayTypeHandler<T> extends BaseTypeHandler<List<T>> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, List<T> parameter, JdbcType jdbcType) throws SQLException {
        if (parameter == null || parameter.isEmpty()) {
            ps.setArray(i, null);
        } else {
            // 检测元素类型并创建相应的数组
            Object firstElement = parameter.get(0);
            String sqlTypeName = getSqlTypeName(firstElement);
            Object[] array = parameter.toArray();
            Array sqlArray = ps.getConnection().createArrayOf(sqlTypeName, array);
            ps.setArray(i, sqlArray);
        }
    }

    @Override
    public List<T> getNullableResult(ResultSet rs, String columnName) throws SQLException {
        Array array = rs.getArray(columnName);
        return convertArrayToList(array);
    }

    @Override
    public List<T> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        Array array = rs.getArray(columnIndex);
        return convertArrayToList(array);
    }

    @Override
    public List<T> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        Array array = cs.getArray(columnIndex);
        return convertArrayToList(array);
    }

    /**
     * 将 SQL Array 转换为 List<T>
     */
    @SuppressWarnings("unchecked")
    private List<T> convertArrayToList(Array sqlArray) throws SQLException {
        if (sqlArray == null) {
            return new ArrayList<>();
        }
        
        Object[] array = (Object[]) sqlArray.getArray();
        if (array == null || array.length == 0) {
            return new ArrayList<>();
        }
        
        List<T> result = new ArrayList<>();
        for (Object item : array) {
            if (item != null) {
                result.add((T) convertToTargetType(item));
            }
        }
        return result;
    }

    /**
     * 根据 Java 对象类型获取对应的 SQL 类型名称
     */
    private String getSqlTypeName(Object obj) {
        if (obj instanceof String) {
            return "String";
        } else if (obj instanceof Integer) {
            return "Int32";
        } else if (obj instanceof Long) {
            return "Int64";
        } else if (obj instanceof Double) {
            return "Float64";
        } else if (obj instanceof Float) {
            return "Float32";
        } else if (obj instanceof Boolean) {
            return "UInt8";
        } else {
            // 默认转换为字符串
            return "String";
        }
    }

    /**
     * 将对象转换为目标类型
     */
    private Object convertToTargetType(Object obj) {
        // 对于基本类型,直接返回
        if (obj instanceof String || obj instanceof Integer || obj instanceof Long ||
            obj instanceof Double || obj instanceof Float || obj instanceof Boolean) {
            return obj;
        }
        // 对于其他类型,转换为字符串
        return obj.toString();
    }
}

Docker 容器化测试

这里引入 TestContainers 实现自动化测试,避免安装 Clickhouse 的繁琐、避免使用替身数据库无法还原真实依赖,确保 MyBatis 与ClickHouse 数组操作的可靠性。

环境配置

Docker Compose 配置(docker-compose.yml):

version: '3.8'
services:
  clickhouse:
    image: clickhouse/clickhouse-server:latest
    ports:
      - "8123:8123"
      - "9000:9000"
    environment:
      CLICKHOUSE_DB: test_db
      CLICKHOUSE_USER: test_user
      CLICKHOUSE_PASSWORD: test_password
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:8123/ping"]
      interval: 30s
      timeout: 10s

Maven 依赖:

<dependencies>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

集成测试实现

测试类核心实现:

@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
class ClickHouseIntegrationTest {

    @Container
    static GenericContainer<?> clickhouseContainer = new GenericContainer<>(
            DockerImageName.parse("clickhouse/clickhouse-server:latest"))
            .withExposedPorts(8123, 9000)
            .withEnv("CLICKHOUSE_DB", "test_db")
            .withEnv("CLICKHOUSE_USER", "test_user")
            .withEnv("CLICKHOUSE_PASSWORD", "test_password")
            .waitingFor(Wait.forHttp("/ping").forPort(8123));

    @Autowired
    private xxxMapper xxxMapper;
    
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", () -> 
            "jdbc:clickhouse://localhost:" + clickhouseContainer.getMappedPort(8123) + "/test_db");
        registry.add("spring.datasource.username", () -> "test_user");
        registry.add("spring.datasource.password", () -> "test_password");
    }

    @BeforeEach
    void initializeDatabase() {
        jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS test_db");
        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS xxx_base (" +
            "uuid String, name String, xxx_type Array(String), userIds Array(String), " +
            "deviceIds Array(String)) ENGINE = MergeTree() ORDER BY uuid");
    }
}

核心测试用例

容器状态验证:

@Test
void testContainerIsRunning() {
    assertTrue(clickhouseContainer.isRunning());
}

数组 CRUD 操作测试:

@Test
void testArrayInsertAndQuery() {
    int result = xxxMapper.insert(testxxx);
    assertEquals(1, result);
    
    List<xxxBase> list = xxxMapper.selectByPage(0, 10);
    assertNotNull(list);
    
    xxxBase found = list.stream()
            .filter(a -> test.getUuid().equals(a.getUuid()))
            .findFirst().orElse(null);
    
    assertNotNull(found.getUserIds());
}

执行测试

命令行执行:

# 使用TestContainers自动化测试
mvn test -Dtest=ClickHouseIntegrationTest

# 或使用Docker Compose手动环境
docker-compose up -d && mvn test

测试配置(application-test.properties):

mybatis.type-handlers-package=com.test.clickhousemybatisdemo.typehandler
logging.level.com.test.clickhousemybatisdemo=DEBUG

验证结果

容器化读写测试验证 TypeHandler 实现了 Java List<T> 与 Clickhouse Array(T) 的映射关系。

以上就是Mybatis操作Clickhouse数组的最佳实践分享的详细内容,更多关于Mybatis操作Clickhouse数组的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文