閱讀602 返回首頁    go 阿裏雲 go 技術社區[雲棲]


java處理字符集-第二部分-文件字符集

前麵有一篇文章提及到亂碼的產生:https://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的東西,下載這個包可以到https://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沒有頭部。

最後更新:2017-04-04 07:03:14

  上一篇:go iOS/Android係統多任務淺析
  下一篇:go iOS 和 Android 的後台推送工作原理各是如何?有什麼區別?