前面有一篇文章提及到乱码的产生:http://blog.csdn.net/xieyuooo/article/details/6919007
那么知道主要原因是编码和解码方式不一样,那么有些时候如果我们知道编码方式,那么解码自然很好搞,例如输出的contentType会告诉浏览器我输出的内容是什么编码格式的,否则浏览器会才用一个当前默认的字符集编码来处理;本文要将一些java如何处理没有带正常协议头部的字符集应当如何来处理。
这里就说的是文件字符集,在了解字符集之前,回到上一篇文章说到默认字符集,自定义字符集,系统字符集,那么当前环境到底用的什么字符集呢?
System.out.println(Charset.defaultCharset());
当前java应用可以支持的所有字符集编码列表:
Set<String> charsetNames = Charset.availableCharsets().keySet(); for(String charsetName : charsetNames) { System.out.println(charsetName); }
因为java的流当中并没有默认说明如何得知文件的字符集,很神奇的是,一些编辑器,类似window的记事本、editplus、UltraEdit他们可以识别各种各样的字符集的字符串,是如何做到的呢,如果面对上传的文件,需要对文件内容进行解析,此时需要如何来处理呢?
首先,文本文件也有两种,一种是带BOM的,一种是不带BOM的,GBK这系列的字符集是不带BOM的,UTF-8、UTF-16LE、16UTF-16BE、UTF-32等等不一定;所谓带BOM就是指文件【头部有几个字节】,是用来标示这个文件的字符集是什么的,例如:
UTF-8 头部有三个字节,分别是:0xEF、0xBB、0xBF
UTF-16BE 头部有两个字节,分别是:0xFE、0xFF
UTF-16LE 头部有两个字节,分别是:0xFF、0xFE
UTF-32BE 头部有4个字节,分别是:0x00、0x00、0xFE、0xFF
貌似常用的字符集我们都可以再这得到解答,因为常用的对我们的程序来讲大多是UTF-8或GBK,其余的字符集相对比较兼容(例如GB2312,而GB18030是特别特殊的字符才会用到)。
我们先来考虑文件有头部的情况,因为这样子,我们不用将整个文件读取出来,就可以得到文件的字符集方便,我们继续写代码:
通过上面的描述,我们不难写出一个类来处理,通过inputStream来处理,自己写一个类:
import java.io.IOException; import java.io.InputStream; import java.io.PushbackInputStream; public class UnicodeInputStream extends InputStream { PushbackInputStream internalIn; boolean isInited = false; String defaultEnc; String encoding; private byte[]inputStreamBomBytes; private static final int BOM_SIZE = 4; public UnicodeInputStream(InputStream in) { internalIn = new PushbackInputStream(in, BOM_SIZE); this.defaultEnc = "GBK";//这里假如默认字符集是GBK try { init(); } catch (IOException ex) { IllegalStateException ise = new IllegalStateException( "Init method failed."); ise.initCause(ise); throw ise; } } public UnicodeInputStream(InputStream in, String defaultEnc) { internalIn = new PushbackInputStream(in, BOM_SIZE); this.defaultEnc = defaultEnc; } public String getDefaultEncoding() { return defaultEnc; } public String getEncoding() { return encoding; } /** * Read-ahead four bytes and check for BOM marks. Extra bytes are unread * back to the stream, only BOM bytes are skipped. */ protected void init() throws IOException { if (isInited) return; byte bom[] = new byte[BOM_SIZE]; int n, unread; n = internalIn.read(bom, 0, bom.length); inputStreamBomBytes = bom; if ((bom[0] == (byte) 0x00) && (bom[1] == (byte) 0x00) && (bom[2] == (byte) 0xFE) && (bom[3] == (byte) 0xFF)) { encoding = "UTF-32BE"; unread = n - 4; } else if ((bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE) && (bom[2] == (byte) 0x00) && (bom[3] == (byte) 0x00)) { encoding = "UTF-32LE"; unread = n - 4; } else if ((bom[0] == (byte) 0xEF) && (bom[1] == (byte) 0xBB) && (bom[2] == (byte) 0xBF)) { encoding = "UTF-8"; unread = n - 3; } else if ((bom[0] == (byte) 0xFE) && (bom[1] == (byte) 0xFF)) { encoding = "UTF-16BE"; unread = n - 2; } else if ((bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE)) { encoding = "UTF-16LE"; unread = n - 2; } else {//没有捕获到的字符集 //encoding = defaultEnc; //这里暂时不用默认字符集 unread = n; //inputStreamBomBytes = new byte[0]; } // System.out.println("read=" + n + ", unread=" + unread); if (unread > 0) internalIn.unread(bom, (n - unread), unread); isInited = true; } public byte[] getInputStreamBomBytes() { return inputStreamBomBytes; } public void close() throws IOException { isInited = true; internalIn.close(); } public int read() throws IOException { isInited = true; return internalIn.read(); } }
好了,下面来看看是否OK,我们测试一个文件,用【记事本】打开一个文件,编写一些中文,将文件分别另存为几种字符集,如下图所示:
通过这种方式保存的文件是有头部的,windows里面也保存了这个标准,但是并不代表,所有的编辑器都必须要写这个头部,因为文件上并没有定义如果不写头部,就不能保存文件,其实所谓的字符集,是我们逻辑上抽象出来的,和文件本身无关,包括这些后缀的.txt|.sql等等,都是人为定义的;
好,不带头部的,我们后面来讲,若带有头部,我们用下面的代码来看看是否正确(用windows自带的记事本、UE工具另存为是OK的,用EditPlus是不带头部的,这里为了测试,可以用前两种工具来保存):
我们这里写个组件类,方便其他地方都来调用,假如我们自己定义个叫FileUtils的组件类,里面定义一个方法:getFileStringByInputStream,传入输入流,和是否关闭输入流两个参数(因为有些时候就是希望暂时不关闭,由外部的框架来关闭),再定义一个重载方法,第二个参数不传递,调用第一个方法是,传入的是true(也就是默认情况下我们认为是需要关闭的)。
代码如下(其中closeStream是一个自己编写的关闭Closeable实现类方法,这里就不多说了):
public static String getFileStringByInputStream2(InputStream inputStream , boolean isCloseInputStream) throws IOException { if(inputStream.available() < 2) return ""; try { UnicodeInputStream in = newUnicodeInputStream(inputStream); String encoding = in.getEncoding(); int available = inputStream.available(); byte []bomBytes = in.getInputStreamBomBytes(); int bomLength = bomBytes.length; byte []last = new byte[available + bomLength]; System.arraycopy(bomBytes , 0 , last , 0 , bomLength);//将头部拷贝进去 inputStream.read(last , bomBytes.length , available);//抛开头部位置开始读取 String result = new String(last , encoding); if(encoding != null && encoding.startsWith("GB")) { return result; }else { return result.substring(1); } }finally { if(isCloseInputStream) closeStream(inputStream); } }
此时找了几个文件果然OK,不论改成什么字符集都是OK的,此时欣喜了一把,另一个人给了我一个Editplus的文件悲剧了,然后发现没有头部,用java默认的OuputStream输出文件也不会有头部,除非自己写进去才会有,或者说,如果你将头部乱写成另一种字符集的头部,通过上述方面就直接悲剧了;
但是如果是不带BOM的,这个方法是不行的,因为没有头部,就没法判定,可以这样说,目前没有任何一种编辑器可以再任何情况下保证没有乱码(一会我们来证明下),类似Editplus保存没有头部的文件,为什么记事本、UE、Editplus都可以认识出来呢(注意,这里指绝大部分情况,并非所有情况);
首先来说下,如果没有头部,只有咋判定字符集,没办法哈,只有一个办法,那就是读取文件字符流,根据字符流和各类字符集的编码进行匹配,来完成字符集的匹配,貌似是OK的,不过字符集之间是存在一个冲突的,若出现冲突,那么这就完蛋了。
做个实验:
写一个记事本或EditPlus,打开文件,在文件开始部分,输入两个字“联通”,然后另存为GBK格式,注意,windows下ASNI就是GBK格式的,或者一些默认,就是,此时,你用任何一种编辑器打开都是乱码,如下所示:
重新打开这个文件,用记事本:
用Editplus打开:
用UE打开:
很悲剧吧,这里仅仅是个例子,不仅仅这个字符,有些其他的字符也有可能,只是正好导致了,如果多写一些汉字(不是从新打开后写),此时会被认出来,因为多一些汉字绝大部分汉字还是没有多少冲突的,例如:联通公司现在表示OK,这是没问题的。
回到我们的问题,java如何处理,既然没有任何一种东西可以完全将字符集解析清楚,那么,java能处理多少,我们能否像记事本一样,可以解析编码,可以的,有一个框架是基于:mozilla的一个叫:chardet的东西,下载这个包可以到http://sourceforge.net/projects/jchardet/files/ 里面去下载,下载后面有相应的jar包和源码,内部有大量的字符集的处理。
那么如何使用呢,他需要扫描整个文件(注意,我们这里没考虑超过2G以上的文件)。
简单例子,在他的包中有个文件叫:HtmlCharsetDetector.java的测试类,有main方法可以运行,这个我大概测试过,大部分文本文件的字符集解析都是OK的,在使用上稍微做了调整而已;它的代码我这就不贴了,这里说下基于这个类和原先基于头部判定的两种方法结合起来的样子;
首先再写一个基于第三包的处理方法:
/** * 通过CharDet来解析文本内容 * @param inputStream 输入流 * @param bomBytes 头部字节,因为取出来后,需要将数据补充回去 因为先判定了头部,所以头部4个字节是传递进来,也需要判定,而inputStream的指针已经指在第四个位置了 * @param bomLength 头部长度,即使定义为4位,可能由于程序运行,不一定是4位长度 这里没有使用bomBytes.length直接获取,而是直接从外部传入,主要为了外部通用 * @param last 后面补充的数据 * @return 返回解析后的字符串 * @throws IOException 当输入输出发生异常时,抛出,例如文件未找到等 */ private static String processEncodingByCharDet(InputStream inputStream, byte[] bomBytes, int bomLength, byte[] last) throws IOException { byte []buf = new byte[1024]; nsDetector det = new nsDetector(nsPSMDetector.ALL); final String []findCharset = new String[1];//这里耍了点小聪明,让找到字符集的时候,写到外部变量里面来下,继承下也可以 det.Init(new nsICharsetDetectionObserver() { public void Notify(String charset) { if(CHARSET_CONVERT.containsKey(charset)) { findCharset[0] = CHARSET_CONVERT.get(charset); } } }); int len , allLength = bomLength; System.arraycopy(bomBytes, 0, last, 0, bomLength); boolean isAscii = det.isAscii(bomBytes , bomLength); boolean done = det.DoIt(bomBytes , bomLength , false); BufferedInputStream buff = new BufferedInputStream(inputStream); while((len = buff.read(buf , 0 , buf.length)) > 0) { System.arraycopy(buf , 0 , last , allLength , len); allLength += len; if (isAscii) { isAscii = det.isAscii(buf , len); } if (!isAscii && !done) { done = det.DoIt(buf , len , false); } } det.Done(); if (isAscii) {//这里采用默认字符集 return new String(last , Charset.defaultCharset()); } if(findCharset[0] != null) { return new String(last , findCharset[0]); } String encoding = null; for(String charset : det.getProbableCharsets()) {//遍历下可能的字符集列表,取到可用的,跳出 encoding = CHARSET_CONVERT.get(charset); if(encoding != null) { break; } } if(encoding == null) encoding = Charset.defaultCharset();//设置为默认值 return new String(last , encoding); }
CHARSET_CONVERT的定义如下,也就是返回的字符集仅仅是可以被解析的字符集,其余的字符集不考虑,因为有些时候,chardet也不好用:
private final static Map<String , String> CHARSET_CONVERT = new HashMap<String , String>() { { put("GB2312" , "GBK"); put("GBK" , "GBK"); put("GB18030" , "GB18030"); put("UTF-16LE" , "UTF-16LE"); put("UTF-16BE" , "UTF-16BE"); put("UTF-8" , "UTF-8"); put("UTF-32BE" , "UTF-32BE"); put("UTF-32LE" , "UTF-32LE"); } };
这个方法写好了,我们将原来的那个方法和这个方法进行合并:
/** * 获取文件的内容,包括字符集的过滤 * @param inputStream 输入流 * @param isCloseInputStream 是否关闭输入流 * @throws IOException IO异常 * @return String 文件中的字符串,获取完的结果 */ public static String getFileStringByInputStream(InputStream inputStream , boolean isCloseInputStream) throws IOException { if(inputStream.available() < 2) return ""; UnicodeInputStream in = new UnicodeInputStream(inputStream); try { String encoding = in.getEncoding();//先获取字符集 int available = inputStream.available();//看下inputStream一次性还能读取多少(不超过2G文件,就可以认为是剩余多少) byte []bomBytes = in.getInputStreamBomBytes();//取出已经读取头部的字节码 int bomLength = bomBytes.length;//提取头部的长度 byte []last = new byte[available + bomLength];//定义下总长度 if(encoding == null) {//如果没有取到字符集,则调用chardet来处理 return processEncodingByCharDet(inputStream, bomBytes, bomLength, last); }else {//如果获取到字符集,则按照常规处理 System.arraycopy(bomBytes , 0 , last , 0 , bomLength);//将头部拷贝进去 inputStream.read(last , bomBytes.length , available);//抛开头部位置开始读取 String result = new String(last , encoding); if(encoding.startsWith("GB")) { return result; }else { return result.substring(1); } } }finally { if(isCloseInputStream) closeStream(in); } }
外部再重载下方法,可以传入是否关闭输入流;
这样,通过测试,绝大部分文件都是可以被解析的;
注意,上面有个substring(1)的操作,是因为如果带BOM头部的文件,第一个字符(可能包含2-4个字节),但是转换为字符后就1个,此时需要将他去掉,GBK没有头部。