MySQL 的jdbc为何不能正确的编码汉字

作者:emu(黄希彤)从mysql4.1的connector/J(3.1.?版)就有了汉字编码问题。http://www.csip.cn/new/st/db/2004/0804/428.htm 里面介绍了一种解决方法。但是我现在使用的是mysql5.0beta和Connector/J(mysql-connector-java-3.2.0-alpha版),原来的方法不适用了,趁这个机会对Connector/J的源码做一点分析吧。
mysql-connector-java-3.2.0-alpha的下载地址:http://dev.mysql.com/get/Downloads/Connector-J/mysql-connector-java-3.2.0-alpha.zip/from/pick

3.2版的connectotJ已经不象 http://www.csip.cn/new/st/db/2004/0804/428.htm 上面描述的样子了。原来的“com.mysql.jdbc.Connecter.java” 已经不复存在了,“this.doUnicode = true; ”在com.mysql.jdbc.Connection.java 中变成了setDoUnicode(true),而这个调用在Connection类中的两次调用都是在checkServerEncoding 方法中(2687,2716),而checkServerEncoding 方法只由 initializePropsFromServer 方法调用            //
            // We only do this for servers older than 4.1.0, because
            // 4.1.0 and newer actually send the server charset
            // during the handshake, and that's handled at the
            // top of this method...
            //
            if (!clientCharsetIsConfigured) {
                checkServerEncoding();
            }
它说只在4.1.0版本以前才需要调用这个方法,对于mysql5.0,根本就不会进入这个方法

从initialize里面找不到问题,直接到ResultSet.getString里面跟一下看看。一番努力之后终于定位到了出错的地方:com.mysql.jdbc.SingleByteCharsetConverter

193 /**
194  * Convert the byte buffer from startPos to a length of length
195  * to a string using this instance's character encoding.
196  *
197  * @param buffer the bytes to convert
198  * @param startPos the index to start at
199  * @param length the number of bytes to convert
200  * @return the String representation of the given bytes
201  */
202 public final String toString(byte[] buffer, int startPos, int length) {
203     char[] charArray = new char[length];
204     int readpoint = startPos;
205
206     for (int i = 0; i < length; i++) {
207         charArray[i] = this.byteToChars[buffer[readpoint] - Byte.MIN_VALUE];
208         readpoint++;
209     }
210
211     return new String(charArray);
212 }

在进入这个方法的时候一切都还很美好,buffer里面放着从数据库拿来的正确的Unicode数据(一个汉字对应着两个byte)
刚进入方法,就定义了一个char数组,其实相当于就是String的原始形式。看看定义了多少个字符:
char[] charArray = new char[length];
嘿嘿,字符数和byte数组长度一样,也就是说每个汉字将转换成两个字符。
后面的循环是把byte数组里面的字符一个一个转换成char。一样的没有对unicode数据进行任何处理,简单的就把一个汉字转成两个字符了。最后用这个字符数组来构造字符串,能不错吗?把toString方法改造一下:

    public final String toString(byte[] buffer, int startPos, int length) {
        return new String(buffer,startPos,length);
    }

这是解决问题最简单的办法了吧。但是我们还可以追究一下原因,看看有没有更好的解决方法。

这个toString方法其实是写来转换所谓的SingleByteCharset,也就是单字节字符用的。用这个方法而不直接new String,目的是提高转换效率,可是现在为什么在转换unicode字符的时候被调用了呢?一路跟踪出来,问题出在com.mysql.jdbc.ResultSet.java的extractStringFromNativeColumn里面:

    /**
  * @param columnIndex
  * @param stringVal
  * @param mysqlType
  * @return
  * @throws SQLException
  */
 private String extractStringFromNativeColumn(int columnIndex, int mysqlType) throws SQLException {
  if (this.thisRow[columnIndex - 1] instanceof String) {
      return (String) this.thisRow[columnIndex - 1];
  }

  String stringVal = null;
  
  if ((this.connection != null) && this.connection.getUseUnicode()) {
      try {
          String encoding = this.fields[columnIndex - 1].getCharacterSet();

          if (encoding == null) {
              stringVal = new String((byte[]) this.thisRow[columnIndex -
                      1]);
          } else {
              SingleByteCharsetConverter converter = this.connection.getCharsetConverter(encoding);

              if (converter != null) {
                  stringVal = converter.toString((byte[]) this.thisRow[columnIndex -
                          1]);
              } else {
                  stringVal = new String((byte[]) this.thisRow[columnIndex -
                          1], encoding);
              }
          }
      } catch (java.io.UnsupportedEncodingException E) {
          throw new SQLException(Messages.getString(
                  "ResultSet.Unsupported_character_encoding____138") //$NON-NLS-1$
               + this.connection.getEncoding() + "'.", "0S100");
      }
  } else {
      stringVal = StringUtils.toAsciiString((byte[]) this.thisRow[columnIndex -
              1]);
  }

  // Cache this conversion if the type is a MySQL string type
  if ((mysqlType == MysqlDefs.FIELD_TYPE_STRING) ||
          (mysqlType == MysqlDefs.FIELD_TYPE_VAR_STRING)) {
      this.thisRow[columnIndex - 1] = stringVal;
  }

  return stringVal;
 }

这个方法从fields里面取得编码方式。而fields是在MysqlIO类里面根据数据库返回的数据解析处理字符集代号,这里取回的是数据库的默认字符集。所以如果你在创建数据库或者表的时候指定了字符集为gbk(CREATE DATABASE dbname DEFAULT CHARSET=GBK;)那么恭喜恭喜,你取回的数据不需要再行编码了。

但是当时我在建数据库表的时候没有这么做(也不能怪我,是bugzilla的checksetup.pl自己创建的库啊),所以现在fields返回的不是我们期望的GBK而是mysql默认的设置ISO8859-1。于是ResultSet就拿ISO8859-1来编码我们GBK编码的数据,这就是为什么我们从getString取得数据以后先getBytes("ISO8859-1")再new String就可以把汉字变回来了。

其实我们指定了jdbc的编码方式的情况下,jdbc应该明白我们已经不打算使用数据库默认的编码方式了,因此ResultSet应该忽略原来数据库的编码方式的,否则我们设置的编码方式还有什么用呢?可是mysql偏偏就选择了忽略我们的选择而用了数据库的编码方式。解决方法很简单,把mysql那段自作聪明的判断编码方式的代码全部干掉:

    /**
  * @param columnIndex
  * @param stringVal
  * @param mysqlType
  * @return
  * @throws SQLException
  */
 private String extractStringFromNativeColumn(int columnIndex, int mysqlType) throws SQLException {
  if (this.thisRow[columnIndex - 1] instanceof String) {
      return (String) this.thisRow[columnIndex - 1];
  }

  String stringVal = null;
  
  if ((this.connection != null) && this.connection.getUseUnicode()) {
      try {
//          String encoding = this.fields[columnIndex - 1].getCharacterSet();
          String encoding = null;
          if (encoding == null) {
              stringVal = new String((byte[]) this.thisRow[columnIndex -
                      1]);
          } else {
              SingleByteCharsetConverter converter = this.connection.getCharsetConverter(encoding);

              if (converter != null) {
                  stringVal = converter.toString((byte[]) this.thisRow[columnIndex -
                          1]);
              } else {
                  stringVal = new String((byte[]) this.thisRow[columnIndex -
                          1], encoding);
              }
          }
      } catch (java.io.UnsupportedEncodingException E) {
          throw new SQLException(Messages.getString(
                  "ResultSet.Unsupported_character_encoding____138") //$NON-NLS-1$
               + this.connection.getEncoding() + "'.", "0S100");
      }
  } else {
      stringVal = StringUtils.toAsciiString((byte[]) this.thisRow[columnIndex -
              1]);
  }

  // Cache this conversion if the type is a MySQL string type
  if ((mysqlType == MysqlDefs.FIELD_TYPE_STRING) ||
          (mysqlType == MysqlDefs.FIELD_TYPE_VAR_STRING)) {
      this.thisRow[columnIndex - 1] = stringVal;
  }

  return stringVal;
 }


好了,整个世界都清静了,现在不管原来的表是什么编码都按默认方式处理,绕过了爱出问题的针对ISO8859-1的加速代码。上面的toString也可以改回去了,不过改不改都无所谓,它没有机会被执行了。

可是我的疑惑没有完全消除。数据库表定义的是ISO8859-1编码,为何返回回来的数据却又是GBK编码呢?而且这个编码并不随我在jdbc的url中的设定而改变,那么mysql是根据什么来决定返回回来的数据的编码方式呢?作者:emu(黄希彤)



作者:emu(黄希彤)
上面研究的只是Result.getString的编码问题。提交数据的时候有类似的编码问题,但是其原因就更复杂一些了。我发现这样做的结果是对的:

pstmt.setBytes(1,"我们都是祖国的花朵".getBytes());

而这样居然是错的:

pstmt.setString(1,"我们都是祖国的花朵");


一番努力之后把断点打到了MysqlIO的send(Buffer packet, int packetLen)方法里面:

                if (!this.useNewIo) {
                    this.mysqlOutput.write(packetToSend.getByteBuffer(), 0,
                        packetLen);
                    this.mysqlOutput.flush();
                } else {...

字符串的编码在packetToSend.getByteBuffer()里面还是对的,但是送到数据库里面的时候就全部变成“???????”了。也就是说,数据库接收这组byte的时候重新进行了编码,而且是错误的编码。比较两种方式发送的byte数组,数据差异很小,基本上就是第0、4和16这三个byte的值会有些变化,看起来似乎第15、16个byte里面保存的是一个代表数据类型的int,估计就是这个标记,让mysql服务器对接收到的数据进行了再加工。但是源码里面对这些逻辑也没有写充分的注释(还是看jdk自己的源码比较舒服),看起来一头雾水,算了。作者:emu(黄希彤)

posted @ 2005-06-03 11:49  emu  阅读(186)  评论(0编辑  收藏  举报