最近遇到一個詭異的中文郵件讀取, 顯示亂碼的問題, 解決過程比較曲折, 我覺得很有必要記錄下來.
故事是這樣的, 最近做了一個讀取郵件在系統(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是怎么獲取正文的了:
- getContent首先得到一個DataHandler
- DataHandler然后得到DataContentHandler
- DataContentHandler是通過當前文本類型, 在CommandMap工廠里獲得的
- 這里應該要獲取text_plain這個handler來處理
- 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, 它的加載方式來源三個地方:
- User Home目錄下的.mailcap文件
- JavaHome lib下的mailcap文件
- 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ā)的樂趣所在了.