ORACLE驱动thin方式动态切换客户端字符集实现

2017-01-03 10:11:08来源:oschina作者:tivenwang人点击

第七城市
表里明明有这条数据,SQL语句却查不到?
A客户端写入,B查询出来的内容却是乱码?
在未知数据编码的情况下如何做到动态的切换客户端字符集去适应变化?

这两个问题大家应该都遇到过,通常此类问题的根本原因是读写或数据转换中的字符集不一致,导致不一致的原因却有多种。数据库字符集、客户端字符集、session字符集,结合Java应用之上的输入字符集,任何一个转换操作有误都可能导致上面的问题。下面我们通过一些概念和知识储备来完成1和2,从而进一步在解决3.


Java方式下MySQL可以通过驱动地址上的参数以及set names来解决。那么ORACLE是如何处理的,先来了解一下它的NLS。ORACLE作为非常成熟的数据库系统,它对全球化(Globalization Support)有着全面的支持,NLS包含了字符集、时区、货币、日期数字格式显示,NLS运行库提供了一套与语言无关的函数,能够正确的处理文本和字符以及语言约定操作。 这些函数在特定于语言环境下对语言和区域的行为进行标识和加载。下图所示服务器根据客户端NLS设置(french和Japanese)进行加载对应的运行库。



这意味着客户端的设置在此过程中非常重要。在Java下,oracle驱动提供了两种方式去访问oracle数据库:OCI和Thin。OCI需要客户端安装oracle客户端,而thin的方式比较简单,只需要依赖驱动即可使用,在此我们仅关注thin下它的行为。


其他信息如编码和字符集、java web和应用编码转换这类知识网上已经有很多透彻的信息可供查阅。


下面做一个测试来复现下上面的问题:


SELECT * FROM NLS_DATABASE_PARAMETERS;//查看数据库端NLS相关设置


SELECT USERENV('language') FROM DUAL;//查看用户session级别的设置


SELECT * FROM V$NLS_PARAMETERS;


服务器端的为US7ASCII,ascii编码。客户端也为ascii编码,即ISO8859-1。


这里创建一张表,以GBK的编码插入一条数据“白痴3号”,查询整张表可以看到这条数据的确存在。(正常情况下此处查出的结果应该为乱码,这里的客户端是已经解决过编码问题的,在确保表中数据正确的情况下以原文本展示)



但是当我们指定该值的时候竟然什么也没查到。



这种情况基本可以确定一件事情,就是客户端编码有问题!oracle的NLS不是能够自适应的么,且上面客户端和服务端的NLS字符集是一致的,查阅ORACLE文档JDBC and NLS得知,thin客户端的NLS设置非常有限,仅参考系统参数user.language来设置NLS的NLS_LANGUAGE和NLS_TERRITORY,其他能想到的一点就是应用编码,通过系统参数file.encoding看到我的应用编码是UTF8的,那么差异就出来了,原来还和客户端应用的字符集有关?先验证下是否正确,使用CONVERT函数转换字符编码查询,的确如此!可看结果:



那么这个问题有解了,只需要在JVM参数上设置-Dfile.encoding即可。上图乱码字符的问题对应该文章第二个,如何将这个乱码转换为正常字符?在thin下,oracle有单独的处理方式,当你的数据库编码为US7ASCII或者WE8ISO8859P1时,ORACLE不做任何转换直接返回UCS-2数据给客户端,如果不是这两种,那么在服务器端将转换为UTF8,然后在客户端由thin驱动转换为UCS-2给应用使用,UCS-2即为UTF-16。所以这个乱码还是客户端捣鬼,原GBK的bit数据转换为了UTF8于是乎就乱了。这个可以再使用CONVERT将txt转换为UTF8数据即可正常显示,其实jvm参数设置好以后,这两个问题是一块解决掉的。如图:oracle客户端字符集转换流程:


Text description of nls81009.gif follows.

那么接下来我们如何动态设置这个字符集?有这样一种场景,我们的应用需要连接的数据源编码千奇百怪,应用设置了UTF8,GBK的数据却不能正常查询和使用了,难道要动态设置file.encoding。。。系统参数都是一次性初始化无法设置。


回过头来想,为什么在NLS设置都已经初始化了的情况下,数据库依然收到错误的数据?为什么会和file.encoding有关?带着这两个问题一块来看ORACLE的驱动到底是怎么做的。ORACLE的真实CONNECTION是T4CConnection,它的sql都放在一个叫做OracleSql的类中,在Statement#execute的时候,它的一个方法getSqlBytes直接推送给了数据库,那么这里是如何实现的?接着往下看发现,T4CConnection的初始化中还包含了另外一个很重要的数据结构:DBConversion,它包含了客户端和服务器端的NLS配置



OracleSql#getSqlBytes正是由该类中的参数组合而来,当客户端为ASCII时,getSqlBytes的逻辑为


public static final byte[] stringToASCII(String var0) {
byte[] var1 = new byte[var0.length()];
var1 = var0.getBytes();
return var1;
}

var0.getBytes()! 默认实现使用的就是file.encoding,所以这里返回的就是file.encoding对应的bytes,这样就可以解释通了,其实上面转换流程这张图中“JDBC-Thin(Calling Java socket in Java)”之前还和平台本身的编码有关。


了解到了这里我们就很清楚的知道如何动态设置了,目标就是oracle.jdbc.driver.DBConversion#clientCharSet,


/**
* oracle.jdbc.driver.DBConversion 转换bytes的入口
**/
public byte[] StringToCharBytes(String var1) throws SQLException {
if(var1.length() == 0) {
return null;
} else {
switch(this.clientCharSetId) {
case -1:
return this.serverCharSet.convertWithReplacement(var1);
case 2:
case 31:
case 178:
return this.clientCharSet.convertWithReplacement(var1);
default:
return stringToDriverCharBytes(var1, this.clientCharSetId);
}
}
}

在ACSII下走的是default路径,即由应用本身的编码控制,这样有很大的不确定性。那么我们要动态设置解就要执行case 2&31&178,同时设置clientCharSet。还有一些要准备的环境数据:


客户端全球化支持需要依赖orai18n.jar,pom依赖如下



oracle.i18n
orai18n
12.1.0

准备好了就开搞了。这时另外一个问题来了,oracle的驱动非开源额,怎么办,对外也没有这些数据的设置入口,只能使用“黑科技”:Java proxy了。


梳理下流程:


应用层提供设置目标数据库编码入口。


开启客户端编码模式。


设置客户端编码。


执行。


调用代码:


//设置prepareStatement代理
OraclePreparedStatementProxy proxy=new OraclePreparedStatementProxy(statement,targetCharacterType);
PreparedStatement proxyStatement = (PreparedStatement) Proxy.newProxyInstance(statement.getClass().getClassLoader(), new Class[]{PreparedStatement.class}, proxy);
//使用代理获取结果
ResultSet resultSet = proxyStatement.executeQuery();

代理实现:

import com.xxx.CharacterType;
import oracle.jdbc.OraclePreparedStatement;
import oracle.sql.CharacterSet;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.sql.PreparedStatement;
/**
* Created by.
* 类说明:代理
*/
public class OraclePreparedStatementProxy implements InvocationHandler {
private static final Log logger = LogFactory.getLog(OraclePreparedStatementProxy.class);
private PreparedStatement target;
private CharacterType characterType;
private Object clientCharSetIdTmp;
private Object clientCharSetTmp;
private Object conversionField;public OraclePreparedStatementProxy(PreparedStatement target, CharacterType characterType) {
this.target = target;
this.characterType = characterType;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
if (method.getName().equals("executeQuery")||method.getName().equals("executeUpdate")||method.getName().equals("close")) {
if (target instanceof OraclePreparedStatement){
if (method.getName().equals("close")){//上下文依赖 清除设置
filterStatementCharacterSet(target,characterType,Boolean.FALSE);
}else {
filterStatementCharacterSet(target,characterType,Boolean.TRUE);
}
return method.invoke(target, args);
}
}
return method.invoke(target, args);
} catch (Exception e) {
if (e.getCause() == null) {
throw e;
} else {
throw e.getCause();
}
}
}/**
* 由于oracle驱动无法直接设置编码 且底层使用getBytes()将数据流传给db,严重依赖运行环境的编码
* 在这里指定客户端编码来解决
* @param oraclePreparedStatement
*/
private void filterStatementCharacterSet(Object oraclePreparedStatement, CharacterType characterType,boolean isGet){
try {
if (null==characterType||characterType==CharacterType.UNKNOWN){
return;
}
CharacterSet characterSet;
switch (characterType){
case UTF8:
case UTF_8:
characterSet=CharacterSet.make(CharacterSet.UTF8_CHARSET);
break;
case GB2312:
case ISO8859_1:
case ISO_8859_1:
case GBK:
characterSet=CharacterSet.make(CharacterSet.ZHS16GBK_CHARSET);
break;
default:
return;
}
Class<?> c;
if (isGet){
c = Class.forName("oracle.jdbc.driver.OraclePreparedStatementWrapper");
Field preparedStatement = c.getDeclaredField("preparedStatement");
preparedStatement.setAccessible(true);
Object t4CPreparedStatement = preparedStatement.get(oraclePreparedStatement);
c = Class.forName("oracle.jdbc.driver.OracleStatement");
Field sqlObject = c.getDeclaredField("sqlObject");
sqlObject.setAccessible(true);
Object sqlObjectField = sqlObject.get(t4CPreparedStatement);
c = Class.forName("oracle.jdbc.driver.OracleSql");
Field conversion = c.getDeclaredField("conversion");
conversion.setAccessible(true);
conversionField = conversion.get(sqlObjectField);
}
c = Class.forName("oracle.jdbc.driver.DBConversion");
Field clientCharSetId = c.getDeclaredField("clientCharSetId");
clientCharSetId.setAccessible(true);
if (isGet){
//设置开启客户端编码
clientCharSetIdTmp=clientCharSetId.get(conversionField);
clientCharSetId.setShort(conversionField, (short) 2);
}else {
clientCharSetId.set(conversionField, clientCharSetIdTmp);
}
Field clientCharSet = c.getDeclaredField("clientCharSet");
clientCharSet.setAccessible(true);
if (isGet){
//设置客户端编码
clientCharSetTmp=clientCharSet.get(conversionField);
clientCharSet.set(conversionField, characterSet);
}else {
clientCharSet.set(conversionField, clientCharSetTmp);
}
} catch (Exception e) {
logger.error("filterStatementCharacterSet error!",e);
}
}
}

大功告成,解决了第三个问题。


ORACLE的NLS是一套覆盖面非常广、内容庞杂的知识体系,想完整的了解它不太容易,所以它的解决方案异常强大。字符集的问题也是老生常谈,自由切换字符集是个比较新颖的操作,似乎没有看到有类似的解决方案,希望这篇需要类似操作的同学。

第七城市

最新文章

123

最新摄影

微信扫一扫

第七城市微信公众平台