近期專案需求要求開發一個小功能:將手機的 VCF 通訊錄檔案匯出後導入資料庫,並能從資料庫讀取記錄生成 VCF 檔案,匯入手機後可直接作為通訊錄使用。VCF(vCard)格式規格可參考相關文件,本文將分享完整的 Java 實作細節。
專案參考了 mime-dir-j 開源專案的原始碼,經過分析後自行實作核心邏輯。
在 Servlet 中透過輸出流動態生成 VCF 內容,關鍵在於正確設定 HTTP 標頭與編碼處理:
response.setHeader("Content-Disposition", "attachment; filename="
+ new String(bean.getTrueName().getBytes("GBK"), "ISO8859-1") + ".vcf");
try {
java.io.PrintWriter bufout = response.getWriter();
bufout.write("BEGIN:VCARD\r\n");
bufout.write("VERSION:2.1\r\n");
// 姓名需經 Quoted-Printable 編碼
bufout.write("N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:"
+ AddressServlet.qpEncodeing(bean.getTrueName()) + ";\r\n");
if (bean.getMobile() != null && !bean.getMobile().isEmpty()) {
bufout.write("TEL;CELL:" + bean.getMobile() + "\r\n");
}
if (bean.getWorkMobile() != null && !bean.getWorkMobile().isEmpty()) {
bufout.write("TEL;WORK:" + bean.getWorkMobile() + "\r\n");
}
if (bean.getTelephone() != null && !bean.getTelephone().isEmpty()) {
bufout.write("TEL;HOME:" + bean.getTelephone() + "\r\n");
}
if (bean.getEmail() != null && !bean.getEmail().isEmpty()) {
bufout.write("EMAIL:" + bean.getEmail() + "\r\n");
}
bufout.write("END:VCARD\r\n");
response.getOutputStream().flush();
response.getOutputStream().close();
} catch (IOException e) {
System.out.println("VCF 匯出發生 I/O 錯誤!");
}
關鍵點: qpEncodeing() 方法用於將中文姓名轉為 Quoted-Printable 編碼,確保手機正確識別。實作可參考 此篇文章。
使用 Apache Commons FileUpload 處理檔案上傳,限制 2MB 大小,驗證副檔名為 .vcf:
FileItem fi = null;
try {
FileUpload fu = new FileUpload();
fu.setSizeMax(2097152); // 2MB
DiskFileItemFactory factory = new DiskFileItemFactory();
fu.setFileItemFactory(factory);
List fileItems = fu.parseRequest(requestContext);
Iterator i = fileItems.iterator();
while (i.hasNext()) {
fi = (FileItem) i.next();
String fileName = fi.getName();
if (fi.getSize() > 2097152) return "檔案過大";
if (fileName == null) return "檔案錯誤";
String fileExt = StringUtils.getFileExt(fileName).toLowerCase();
if ("vcf".equals(fileExt)) {
Properties p = this.createProperties();
try {
ArrayList li = this.importVCFFileContact(fi.getInputStream(), p);
if (li == null || li.isEmpty()) {
return "導入 0 筆,檔案格式可能不正確";
}
this.addAddresses(li);
return "1";
} catch (IOException e) {
return "檔案格式發生錯誤";
}
} else {
return "非 VCF 檔案";
}
}
} catch (Exception e) {
if (e.getMessage().indexOf("maximum") > 0) {
return "導入檔案錯誤,檔案大於 2MB";
}
log.debug(e.getMessage());
return "導入檔案發生錯誤: " + e.getMessage();
} finally {
try { if (fi != null) fi.delete(); } catch (Exception ignored) {}
}
return "發生錯誤";
核心解析邏輯使用正則表達式提取每張 vCard 的欄位,支援多筆聯繫人:
public ArrayList importVCFFileContact(InputStream in, Properties pp) throws SystemException {
ArrayList abs = new ArrayList();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
StringBuffer bu = new StringBuffer();
String line;
while ((line = DecorGroup.nextLine(reader)) != null) {
bu.append(line).append("\r\n");
}
// 以 BEGIN:VCARD ... END:VCARD 分組
Pattern p = Pattern.compile("BEGIN:VCARD(\\r\\n)([\\s\\S\\r\\n\\.]*?)END:VCARD");
Matcher m = p.matcher(bu.toString());
while (m.find()) {
AddressBean be = new AddressBean();
be.setCateId(gb.getId());
be.setUserId(gb.getUserId());
String vcard = m.group(0);
// 解析姓名(支援 QUOTED-PRINTABLE 與 CHARSET)
Pattern pn = Pattern.compile("N;([\\s\\S\\r\\n\\.]*?)([\\r\\n])");
Matcher mn = pn.matcher(vcard);
while (mn.find()) {
String name = "";
if (mn.group(1).indexOf("ENCODING=QUOTED-PRINTABLE") > -1) {
name = mn.group(1).substring(
mn.group(1).indexOf("ENCODING=QUOTED-PRINTABLE:") + "ENCODING=QUOTED-PRINTABLE:".length());
name = name.substring(name.indexOf(":") + 1);
if (name.indexOf(";") > -1) name = name.substring(0, name.indexOf(";"));
be.setTrueName(AddressServlet.qpDecoding(name));
} else {
Pattern pnn = Pattern.compile("CHARSET=([A-Za-z0-9-]*?):");
Matcher mnn = pnn.matcher(mn.group(1));
while (mnn.find()) {
name = mn.group(1).substring(
mn.group(1).indexOf(mnn.group(0)) + mnn.group(0).length());
be.setTrueName(name);
}
}
}
if (be.getTrueName().length() > 20) return null; // 防禦性檢查
// 解析手機、公司電話、住宅電話、Email
be.setMobile(extractField(vcard, "TEL;CELL:"));
be.setWorkMobile(extractField(vcard, "TEL;WORK:"));
be.setTelephone(extractField(vcard, "TEL;HOME:"));
be.setEmail(extractEmail(vcard));
abs.add(be);
}
reader.close();
} catch (Exception e) {
log.debug(e.getMessage());
throw new SystemException("系統忙碌,請稍後再試!");
}
return abs;
}
// 輔助方法:提取單一欄位
private String extractField(String vcard, String prefix) {
Pattern p = Pattern.compile(prefix + "\\d*");
Matcher m = p.matcher(vcard);
String val = "";
while (m.find()) {
val = m.group(0).substring(prefix.length());
}
return val;
}
// 輔助方法:提取 Email
private String extractEmail(String vcard) {
Pattern p = Pattern.compile("\\w+(\\.\\w+)*@\\w+(\\.\\w+)+");
Matcher m = p.matcher(vcard);
String val = "";
while (m.find()) val = m.group(0);
return val;
}
VCF 規範允許長行以空白或 Tab 續行,需正確還原:
public static String nextLine(BufferedReader reader) throws IOException {
String line, nextLine;
do {
line = reader.readLine();
if (line == null) return null;
} while (line.length() == 0);
// Evolution 風格:以 = 結尾續行
while (line.endsWith("=")) {
line = line.substring(0, line.length() - 1) + reader.readLine();
}
// RFC 2425 風格:下一行以空白或 Tab 開頭
reader.mark(1000);
nextLine = reader.readLine();
if (nextLine != null && nextLine.length() > 0
&& (nextLine.charAt(0) == 0x20 || nextLine.charAt(0) == 0x09)) {
line += nextLine.substring(1);
} else {
reader.reset();
}
return line.trim();
}
程式已在 Nokia、Samsung、Motorola、波導等品牌手機實測通過。因各廠牌 VCF 欄位可能略有差異,本實作僅提取共通欄位:姓名、行動電話、住宅電話、公司電話、Email。如需擴充欄位,可依樣畫葫蘆增加正則解析。
完整程式碼已整理於上,若有疑歡迎留言討論。掌握 VCF 解析與生成技巧,即可輕鬆實現手機通訊錄與系統資料庫的雙向同步。