LinkedCaseInsensitiveMap源码分析

在使用spring framework框架的时候,经常会使用JdbcTemplate来操作数据库。使用JdbcTemplate从数据库中查询数据的典型操作如下

Map<String, Object> map = jdbcTemplate
.queryForMap("SELECT name,id FROM user WHERE id=?", "1");

我们知道,在oracle数据库中,字段名称是大小写不敏感的,oracle会统一将字段名称转换为大写来进行处理,上个示例中的字段名称会转换为NAME和ID;而在postgresql数据库中,字段名称是大小写敏感的,pg会统一将字段名称转换为小写来进行处理,上个示例中的字段名称会转换为name和id。

对JdbcTemplate查询的具体实现代码我们今天先不进行分析,只需要明确的一点是在使用JdbcTemplate执行如上查询的时候,在查询获取到ResultSet之后,有个处理过程是完成ResultSet到RowMapper的转换。转换之后,则将ResultSet中的数据抽取到Map中返回给我们。

基于以上我们的分析,我们可能会想当然的认为,在oracle数据库的情况下,我们可以使用map.get("NAME")和map.get("ID")来进行数据的获取,在pg数据库的情况下,我们可以使用map.get("name")和map.get("id")来进行数据的获取。

当然,上面的想法是正确的。但是如果我们在oracle数据库的情况下,使用map.get("name")来进行数据获取的话,会发生什么情况呢?答案是完全可以。这就要引出我们今天将要介绍的主角——LinkedCaseInsensitiveMap了。

LinkedCaseInsensitiveMap,从名字上看,可以理解为大小写不敏感的map,那么它是如何实现的呢?

public class LinkedCaseInsensitiveMap<V>
implements Map<String, V>, Serializable, Cloneable

在它的源码中,定义了如下的几个变量。

private final LinkedHashMap<String, V> targetMap;
private final HashMap<String, String> caseInsensitiveKeys;
private final Locale locale;

从以上的几个变量可以猜测,targetMap可能是真实存储数据的map,而caseInsensitiveKeys这个map大概是用来处理key的大小写映射,locale用来处理一些本地化信息,比如用于获取某个key对应的小写形式。这种猜测到底正确不正确呢?

来看一下构造函数吧。

public LinkedCaseInsensitiveMap(int initialCapacity, @Nullable Locale locale) {
    this.targetMap = new LinkedHashMap<String, V>(initialCapacity) {
        @Override
        public boolean containsKey(Object key) {
            return LinkedCaseInsensitiveMap.this.containsKey(key);
        }
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, V> eldest) {
            boolean doRemove = LinkedCaseInsensitiveMap.this.removeEldestEntry(eldest);
            if (doRemove) {
                caseInsensitiveKeys.remove(convertKey(eldest.getKey()));
            }
            return doRemove;
        }
    }
    this.caseInsensitiveKeys = new HashMap<>(initialCapacity);
    this.locale = (locale != null ? locale : Locale.getDefault());
}

在构造函数中,重写了两个方法,分别是containsKey和removeEldestEntry。

对于removeEldestEntry,重写之后直接返回false,即不要求删除最老的Entry。

重写之后,containsKey方法的实现如下

public boolean containsKey(Object key) {
    return (key instanceof String && this.caseInsensitiveKeys.containsKey(convert((Strng) key)));
}

protected String convertKey(String key) {
    return key.toLowerCase(getLocale());
}

为了更好的理解重写这两个函数的意图,需要首先来分析一下其put操作和get操作。

public V put(String key, @Nullable V value) {
    String oldKey = this.caseInsensitiveKeys.put(convert(key), key);
    V oldKeyValue = null;
    if (oldKey != null && !oldKey.equals(key)) {
        oldKeyValue = this.targetMap.remove(oldKey);
    }
    V oldValue = this.targetMap.put(key, value);
    return (oldKeyValue != null ? oldKeyValue : oldValue);
}

public V get(Object key) {
    if (key instanceof String) {
        String caseInsensitiveKey = this.caseInsensitiveKeys.get(convertkey(key));
        if (caseInsensitiveKey != null) {
            return this.targetMap.get(caseInsensitiveKey);
        }
    }
    return null;
}

我们来分析一下put函数,从中可以看出caseInsensitiveKeys这个map的key即使小写的字段名称到字段名称的映射,第一行中的oldKey对应的是存储在targetMap中的key。如果oldKey为null并且oldKey和key不相等的情况下,说明存在插入重复的情况,应该首先在targetMap中执行删除操作。删除完成后,再在targetMap中进行插入。理解了put函数之后,get函数的实现就一目了然了。

看过了put函数和get函数,再回过头来看一下重写的containsKey函数。如何判断targetMap中是否存在某个字段名称(各种形式的拼写)呢?首先将字段名称转换为小写拼写形式,然后判断是否在caseInsensitiveKeys中是否存在即可。

现在来回想一下本文开头提出问题,针对本文的场景,LinkedCaseInsensitiveMap中数据是如何组织的呢?

其实非常简单,

在oracle数据库下,caseInsensitiveKeys保存了(name, NAME)和(id, ID)两个key-value对,而targetMap保存了(NAME, VALUE1)和(ID, VALUE2)两个key-value对。

在pg数据库下,caseInsensitiveKeys保存了(name, name)和(id,id)两个key-value对,而targetMap保存了(name, VALUE1)和(id, value2)两个key-value对。

这样一番处理,问题得以解决。

后记

意识到这个问题,纯属意外。以前从来没有关注过这个Map的key的问题。前段时间在处理一个平台,需要把以前的基于oracle的业务代码做pg兼容性改造。在处理兼容性改造的时候,后台业务代码没有发现什么问题,但是当把数据传递到前台,进行web化展示的时候,立刻暴露出了问题,前台页面频频报错,字段不存在,这才有了本文的思考。

至于最后问题是如何解决的,其实也很简单。上面有说到,查询获取结果的时候,有一段处理过程是从ResultSet中抽取数据到map。具体的处理逻辑如下。

public Map<String, Object> mapRow(ResultSet rs, int rowNum) throws SQLException {
    ResultSetMetaData rsmd = rs.getMetaData();
    int columnCount = rsmd.getColumnCount();
    Map<String, Object> mapOfColumnValues = createColumnMap(columnCount);
    for (int i = 1; i <= columnCount; i++) {
        String column = JdbcUtils.lookupColumnName(rsmd, i);
        mapOfColumnValues.putIfAbsent(getColumnKey(column), getColumnValue(rs, i));
    }
    return mapOfColumnValues;
}

从以上逻辑中可以看到,会从rs中取出所有的columnName,然后获取对应的值,插入到map中,那只要在这个逻辑中,向map中插入对应的大写形式的字段即可。

Comments
Write a Comment