記一次詭異的Java Mail郵件亂碼問題

最近遇到一個詭異的中文郵件讀取, 顯示亂碼的問題, 解決過程比較曲折, 我覺得很有必要記錄下來.

故事是這樣的, 最近做了一個讀取郵件在系統(tǒng)顯示的功能, 標準的Java Mail的處理方式, 很快發(fā)現(xiàn)有一些中文的郵件, 顯示為亂碼, 一堆問號:

?????????????????????
????Э??????????????ɡ?

開始很納悶, 因為本地并不能重現(xiàn), 本地測試同一封郵件, 讀取回來就是正常的, 同時郵件標題中的中文字符是沒有問題的, 還發(fā)現(xiàn)其他的一封中文郵件, 也是沒有問題的.

所以判斷一定是這封郵件有什么獨特的地方, 通過給MailStore的property設(shè)置"mail.debug"為true, 打開調(diào)試模式后, 調(diào)試信息是這樣的:

A5 FETCH 1661 (ENVELOPE INTERNALDATE RFC822.SIZE)

  • 1661 FETCH (ENVELOPE ("Wed, 30 May 2018 10:37:37 +0800" "=?GBK?B?T1IyMDE4...0Irzcu79WxhYmVs?=" ... INTERNALDATE "30-May-2018 10:37:35 +0800" RFC822.SIZE 4028)
    A6 FETCH 1661 (BODYSTRUCTURE)
  • 1661 FETCH (BODYSTRUCTURE ((("TEXT" "PLAIN" ("charset" "GB2312") NIL NIL "BASE64" 110 3 NIL NIL NIL)("TEXT" "HTML" ("charset" "GB2312") NIL NIL "QUOTED-PRINTABLE" 795 11 NIL NIL NIL) "ALTERNATIVE" ("BOUNDARY" "----=002_NextPart882583734822=----") NIL NIL)("IMAGE" "PNG" ("name" "=?GB2312?B?OTM4X9...vC5wbmc=?=") "_Foxmail.1@51f50e29-48f0-ad3d-2e81-703262901068" NIL "BASE64" 1288 NIL NIL NIL) "RELATED" ("BOUNDARY" "----=001_NextPart260442321737=----") NIL NIL))
    A6 OK FETCH Completed
    A7 FETCH 1661 (BODY[1.1])
  • 1661 FETCH (BODY[1.1] {110} v827p8LytO2jrLWryseyu7P...hsYWJlbLDJoaMNCg0KDQoNCg==

可以發(fā)現(xiàn)郵件標題用了MIME Encoded-Words, 編碼是GBK, 郵件本體是multipart, 有3塊, 分別是text/plain, text/html, image/png, 其中文本部分有標明編碼是GB2312, 正文部分用了Base64編碼

根據(jù)官方解釋, getContent方法應當負責讀取相應的編碼信息來解析文本:

https://javaee.github.io/javamail/FAQ#unsupen
Typically, such bodyparts internally hold their textual data in some non Unicode charset. JavaMail (through the corresponding DataContentHandler) attempts to convert that data into a Unicode string

前面說過發(fā)現(xiàn)另一封中文郵件讀取正常, 于是也調(diào)試了一下, 發(fā)現(xiàn)那封郵件頭寫的編碼是UTF-8, 沒有問題.

然后查了數(shù)據(jù)庫連接, 數(shù)據(jù)庫引擎, 數(shù)據(jù)庫表還是操作系統(tǒng)上聲明的編碼, 都統(tǒng)一是UTF-8, 沒有問題.

因為郵件標題能正常讀取, 并存儲顯示都正常, 所以其實是迅速排除了數(shù)據(jù)庫/操作系統(tǒng)底層設(shè)置的問題, 看起來問題還是出在程序沒有正確按郵件頭的編碼來解碼.

于是寫了一個程序測試, 發(fā)現(xiàn)確實當同樣的GB2312編碼的字符串強行按UTF-8解碼, 就會出現(xiàn)前面的一堆問號的亂碼.

確定了出問題的方式, 下面就是想辦法復現(xiàn), 因為本地是好的, 所以只能想辦法在線上的環(huán)境動手腳, 因為有一個備用的環(huán)境, 經(jīng)測試也能重現(xiàn)問題, 于是單獨寫一段測試代碼部署過去觸發(fā), 然后查看日志, 這樣調(diào)試很沒有效率, 但是也只能這樣了.

結(jié)果剛開始就發(fā)現(xiàn)一個令人震驚的事, 測試代碼顯示中文被正確解析了, 然后當我過一段時間再運行的時候, 又變成了亂碼, 完全搞不懂為什么, 于是反復加了很多的調(diào)試輸出, 來回部署了十幾遍, 真的很折騰.

當陷入僵局的時候我想到為什么我本地沒有問題呢, 很有可能是因為服務器環(huán)境是jdk6, 而本地是jdk8, 不過當我在本地安裝了jdk6之后, 還是不能重現(xiàn)問題

反正接下來就是想盡辦法剝開getContent方法背后所有執(zhí)行的代碼, 查看經(jīng)過了那些類那些方法, 雖然沒有找到問題, 但是知道了getContent是怎么獲取正文的了:

  1. getContent首先得到一個DataHandler
  2. DataHandler然后得到DataContentHandler
  3. DataContentHandler是通過當前文本類型, 在CommandMap工廠里獲得的
  4. 這里應該要獲取text_plain這個handler來處理
  5. handler會讀取charset的編碼設(shè)定, 解析文本

上面說了, 正常情況下這個DataContentHandler應該是text_plain, 但是隨即發(fā)現(xiàn)出問題的時候, 這里用的居然是StringDataContentHandler, 同時通過查源碼得知, StringDataContentHandler在jdk6和jdk8的時候確實是不一樣的, 8的時候加入了編碼的判斷, 而6的時候沒有, 因為沒有指定, 所以用了defaultCharset, 也就是UTF-8, 砰!出問題!, 而我本地是8的環(huán)境,這也是為什么我總是復現(xiàn)不成功的原因了.

// StringDataContentHandler在jdk8下的源碼
enc = this.getCharset(ds.getContentType()); // 這里去讀了ContentType下的編碼信息
is = new InputStreamReader(ds.getInputStream(), enc); // 使用正確的編碼解碼

// 這是text_plain的源碼, 可以看到同樣讀取了編碼信息, 和上面是一樣的, 所以這兩個是對的
enc = getCharset(ds.getContentType());
is = new InputStreamReader(ds.getInputStream(), enc);

// StringDataContentHandler在jdk6下的源碼, 是有bug的
is = new InputStreamReader(ds.getInputStream()); 
// 1. 里面的getInputStream是解析Base64編碼的正文的
// 2. 外面的Reader才是負者文本解碼的
// InputStreamReader使用一個參數(shù)創(chuàng)建時, 用的是系統(tǒng)默認編碼: Charset.defaultCharset()
// 根據(jù)配置不同可能是US-ASCII或者UTF-8等等

然后發(fā)現(xiàn)一般在我剛部署完成的時候, 測試是通過的, 用的處理類也是text_plain, 但是過一段時間就會變成StringDataContentHandler, 于是猜測有什么東西會在運行的時候改變這個設(shè)置.

隨后仔細研究了CommandMap這個類, 它是抽象類, 用到的實現(xiàn)類是MailcapCommandMap, 它的加載方式來源三個地方:

  1. User Home目錄下的.mailcap文件
  2. JavaHome lib下的mailcap文件
  3. javax.mail的包里面的mailcap文件

前兩個地方通常是空的, 第三個地方內(nèi)容是:

text/plain;; x-java-content-handler=com.sun.mail.handlers.text_plain
text/html;; x-java-content-handler=com.sun.mail.handlers.text_html
...

所以在這里text_plain的配置是正常的.

那StringDataContentHandler是怎么配置進去的呢, 通過google這個類發(fā)現(xiàn), 還真有可以注入的地方, 那就是rt.jar/com.sun.xml.internal.ws.binding下的BindingImpl這個類:

public static void initializeJavaActivationHandlers() {
    try {
        CommandMap map = CommandMap.getDefaultCommandMap();
        if (map instanceof MailcapCommandMap) {
            MailcapCommandMap mailMap = (MailcapCommandMap)map;
            if (!cmdMapInitialized(mailMap)) {
                mailMap.addMailcap("text/xml;;x-java-content-handler=com.sun.xml.internal.ws.encoding.XmlDataContentHandler");
                mailMap.addMailcap("application/xml;;x-java-content-handler=com.sun.xml.internal.ws.encoding.XmlDataContentHandler");
                mailMap.addMailcap("image/*;;x-java-content-handler=com.sun.xml.internal.ws.encoding.ImageDataContentHandler");
                // 這里用指定的類覆蓋了默認設(shè)置
                mailMap.addMailcap("text/plain;;x-java-content-handler=com.sun.xml.internal.ws.encoding.StringDataContentHandler");
            }
        }
    } catch (Throwable var2) {
        ;
    }
}

不過這個類是jdk8的, 對應jdk6是com.sun.xml.internal.ws.encoding下的MimeCodec, 效果是一樣的:

static {
    try {
        CommandMap var0 = CommandMap.getDefaultCommandMap();
        if (var0 instanceof MailcapCommandMap) {
            MailcapCommandMap var1 = (MailcapCommandMap)var0;
            String var2 = ";;x-java-content-handler=";
            var1.addMailcap("text/xml" + var2 + XmlDataContentHandler.class.getName());
            var1.addMailcap("application/xml" + var2 + XmlDataContentHandler.class.getName());
            var1.addMailcap("image/*" + var2 + ImageDataContentHandler.class.getName());
            var1.addMailcap("text/plain" + var2 + StringDataContentHandler.class.getName());
        }
    } catch (Throwable var3) {
        ;
    }
}

看到這個的時候, 真的有一種讓人"呵呵"的感覺, 要知道addMailCap甚至特意留了一個空位給這個用:

public MailcapCommandMap() {
    List dbv = new ArrayList(5);
    MailcapFile mf = null;
    dbv.add((Object)null); // 這里特意存了一個null到第一個的位置
    LogSupport.log("MailcapCommandMap: load HOME");

正常情況下這個CommandMap是這樣的:

[
  null, // 這個是預留的空位
  {"mimeTypes": ["message/rfc822", "multipart/*", "text/plain", "text/xml", "text/html"]}, // 這個是從javax.mail-1.5.2.jar/META-INF/mailcap讀來的
  {"mimeTypes": ["image/jpeg", "image/gif", "text/*"]} // 這個是從classes.jar/META-INF/mailcap.default讀來的
]

當addMailCap后, CommandMap變成這樣的:

[
  // 被BindingImpl注入
  {"mimeTypes": ["application/xml","text/plain","text/xml","image/*"],   
  {"mimeTypes": ["message/rfc822", "multipart/*", "text/plain", "text/xml", "text/html"]}, 
  {"mimeTypes": ["image/jpeg", "image/gif", "text/*"]} 
]

MailcapCommandMap實例化的時候就特意加了一個null到數(shù)組開頭, 就是為了addMailcap的時候占用這個位置, 從而達到覆蓋配置的目的, 非常"精巧", 除了注入的這個類有bug!

所以真相大白, 剛部署的時候一切正常, 讀取郵件也正常, 但是當應用執(zhí)行了一些webservice相關(guān)的代碼后, 因為其中的初始化設(shè)定, 用有bug的StringDataContentHandler覆蓋了正常的text_plain, 于是出現(xiàn)亂碼.

解決方法1: 升級jdk8, 顯然這個影響有點大, 不考慮

解決方法2: 想辦法讓text_plain能夠排在前面, 擁有最高優(yōu)先級, 但是前面說了通過程序注入的配置已經(jīng)是最高優(yōu)先級了, 甚至高過了java home下的配置文件, 那只能先下手為強, 把text_plain搶先配置進去

還好BindingImpl的初始化是lazy的, 只有觸發(fā)相關(guān)代碼才會執(zhí)行, 所以解決方案就是在應用啟動時就搶先配置, 占好位置, 這樣后面再加的配置優(yōu)先級都低過它.

通過注冊Spring的web listener可以做到應用啟動時執(zhí)行:

public class StartupListener extends ContextLoaderListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        try {
            CommandMap map = CommandMap.getDefaultCommandMap();
            if (map instanceof MailcapCommandMap) {
                MailcapCommandMap commandMap = (MailcapCommandMap) map;
                commandMap.addMailcap("text/plain;;x-java-content-handler=com.sun.mail.handlers.text_plain");
            }
        } catch (Exception ignore) {
        }
    }
}

這么一改, 問題解決!

其實我是非常喜歡這樣刨根問底, 最終解決問題的過程的, 因為不僅可以解決問題, 還能學到不少之前不知道的東西, 比如:

亂碼還原問題

不是所有亂碼都是可以還原的, 比如這次的GB2312被錯誤解碼為UTF-8, 因為UTF-8的特殊編碼方式, 不被識別的字符全都變成問號了, 是不可能還原的.

但是反過來, UTF-8的字符串被錯誤的用GB2312解碼的話是有可能還原的哦.

郵件編碼方案

為了郵件在互聯(lián)網(wǎng)傳輸過程中達到最大的通用性, 標準規(guī)定只能使用可打印的ascii字符, 那中文或者其他語言的字符怎么辦呢, 方法就是這類字符先按該語言的特定編碼存儲, 比如GB2312編碼, 然后將編碼信息以二進制的形式二次編碼為基礎(chǔ)的ascii碼, 這里的方案通常有Base64, 或者Quoted-printable, 然后只要在合適的地方標明使用的兩次編碼的方案, 還原的時候倒過來解碼就可以了.

比如郵件標題是中文的情況下, 獲取來是這樣的:

=?GBK?B?T1IyMDE4...0Irzcu79WxhYmVs?=

這個格式是MIME的標準, 表示是用GBK+Base64的編碼方案

比如郵件正文有中文的話, 頭信息是這樣的:

"TEXT" "PLAIN" ("charset" "GB2312") NIL NIL "BASE64" 110 3 NIL NIL NIL

這里表示是用GB2312+Base64的編碼方案

或者還可能這樣:

"TEXT" "HTML" ("charset" "GB2312") NIL NIL "QUOTED-PRINTABLE" 795 11 NIL NIL NIL

這里表示是用GB2312+QUOTED-PRINTABLE的編碼方案

Quoted-Printable編碼看起來長這樣:

=CD=F8=C9=CF=B9=BA=CE=EF

總的來說Base64的信息含量比較高, 因為Base64用了3個可打印的字節(jié)替換4個原始的二進制字節(jié), 所以理論上講, 編碼后的字符串比原來長了1/3.

但是Quoted-Printable是把一個8位的字符用兩個十六進制數(shù)值來表示更振,然后在前面加"=", 3個字節(jié)換一個, 長了2倍, 雖然勝在簡單, 但是比較消耗流量, 所以現(xiàn)在常用的都是Base64編碼了

總結(jié)

雖然表面上看起來只是一個普通的亂碼問題, 但是背后卻隱藏了這么多彎彎繞繞, 然而最后解決又只需要三四行代碼, 所以想起來以前說工程師的一個老笑話, "擰一顆螺絲值1塊錢, 但是知道擰哪一顆值5000塊!". 這也正是開發(fā)的樂趣所在了.

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市喂走,隨后出現(xiàn)的幾起案子殃饿,更是在濱河造成了極大的恐慌,老刑警劉巖芋肠,帶你破解...
    沈念sama閱讀 211,348評論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件乎芳,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機奈惑,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,122評論 2 385
  • 文/潘曉璐 我一進店門吭净,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人肴甸,你說我怎么就攤上這事寂殉。” “怎么了原在?”我有些...
    開封第一講書人閱讀 156,936評論 0 347
  • 文/不壞的土叔 我叫張陵友扰,是天一觀的道長。 經(jīng)常有香客問我庶柿,道長村怪,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,427評論 1 283
  • 正文 為了忘掉前任浮庐,我火速辦了婚禮甚负,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘审残。我一直安慰自己梭域,他們只是感情好,可當我...
    茶點故事閱讀 65,467評論 6 385
  • 文/花漫 我一把揭開白布搅轿。 她就那樣靜靜地躺著病涨,像睡著了一般。 火紅的嫁衣襯著肌膚如雪介时。 梳的紋絲不亂的頭發(fā)上没宾,一...
    開封第一講書人閱讀 49,785評論 1 290
  • 那天,我揣著相機與錄音沸柔,去河邊找鬼循衰。 笑死,一個胖子當著我的面吹牛褐澎,可吹牛的內(nèi)容都是我干的会钝。 我是一名探鬼主播,決...
    沈念sama閱讀 38,931評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼工三,長吁一口氣:“原來是場噩夢啊……” “哼迁酸!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起俭正,我...
    開封第一講書人閱讀 37,696評論 0 266
  • 序言:老撾萬榮一對情侶失蹤奸鬓,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后掸读,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體串远,經(jīng)...
    沈念sama閱讀 44,141評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡宏多,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,483評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了澡罚。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片伸但。...
    茶點故事閱讀 38,625評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖留搔,靈堂內(nèi)的尸體忽然破棺而出更胖,到底是詐尸還是另有隱情,我是刑警寧澤隔显,帶...
    沈念sama閱讀 34,291評論 4 329
  • 正文 年R本政府宣布却妨,位于F島的核電站,受9級特大地震影響括眠,放射性物質(zhì)發(fā)生泄漏管呵。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,892評論 3 312
  • 文/蒙蒙 一哺窄、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧账锹,春花似錦萌业、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至廓奕,卻和暖如春抱婉,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背桌粉。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評論 1 265
  • 我被黑心中介騙來泰國打工蒸绩, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人铃肯。 一個月前我還...
    沈念sama閱讀 46,324評論 2 360
  • 正文 我出身青樓患亿,卻偏偏與公主長得像,于是被迫代替她去往敵國和親押逼。 傳聞我的和親對象是個殘疾皇子步藕,可洞房花燭夜當晚...
    茶點故事閱讀 43,492評論 2 348

推薦閱讀更多精彩內(nèi)容