在 URL 傳遞中文參數時,瀏覽器與服務端需遵循 Percent-Encoding(百分號編碼) 標準(RFC 3986)。本文提供一個零依賴、手寫實現的工具類,涵蓋 UTF-8 編碼為 `%E4%BD%A0` 格式、解碼還原中文,並解析核心算法原理,適合無法引入外部庫、或需理解底層機制的場景。
package com.example.util;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
/**
* URL 編碼解碼工具類
*
* 提供標準庫版(推薦生產)與手寫版(教學/無依賴環境)雙實現。
*
*/
public final class UrlCodecUtil {
private UrlCodecUtil() {}
// ==================== 推薦:標準庫實現(JDK 內建、經過充分測試) ====================
/**
* 標準 URL 編碼(application/x-www-form-urlencoded 規則)
* 空格編碼為 +,保留字符 - _ . * 不編碼,其餘非 ASCII 按 UTF-8 %XX
*/
public static String encode(String input) {
if (input == null) return "";
try {
return URLEncoder.encode(input, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException impossible) {
throw new IllegalStateException("UTF-8 not supported", impossible);
}
}
/**
* 標準 URL 解碼
*/
public static String decode(String input) {
if (input == null) return "";
try {
return URLDecoder.decode(input, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException impossible) {
throw new IllegalStateException("UTF-8 not supported", impossible);
}
}
/**
* RFC 3986 兼容編碼(路徑/查詢參數通用)
* 空格編碼為 %20,保留字符 - _ . ~ 不編碼
*/
public static String encodeRfc3986(String input) {
if (input == null) return "";
String encoded = encode(input);
return encoded.replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
}
// ==================== 教學:手寫 UTF-8 Percent-Encoding 實現 ====================
/**
* 手寫編碼:字符串 → UTF-8 字節 → %XX 大寫十六進制
* ASCII 可見字符(33-126)保留字符外直接輸出,其餘全編碼
*/
public static String manualEncode(String input) {
if (input == null) return "";
StringBuilder sb = new StringBuilder(input.length() * 3);
for (char c : input.toCharArray()) {
// ASCII 可打印且非保留字符:直接輸出
if (c >= 33 && c <= 126 && !isReservedChar(c)) {
sb.append(c);
} else {
// 非 ASCII 或保留字符:UTF-8 編碼後逐字節 %XX
byte[] bytes;
try {
bytes = String.valueOf(c).getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
bytes = new byte[0];
}
for (byte b : bytes) {
int unsigned = b & 0xFF; // 無符號字節值 0-255
sb.append('%');
sb.append(Character.forDigit((unsigned >> 4) & 0xF, 16));
sb.append(Character.forDigit(unsigned & 0xF, 16));
}
}
}
return sb.toString();
}
/**
* 判斷 RFC 3986 保留字符:! * ' ( ) ; : @ & = + $ , / ? # [ ]
*/
private static boolean isReservedChar(char c) {
return "!*'();:@&=+$,/?#[]".indexOf(c) >= 0;
}
/**
* 手寫解碼:%XX 序列 → 字節數組 → UTF-8 解碼
* 支援多字節字符(中文 3 字節)、ASCII 透傳、+ 轉空格
*/
public static String manualDecode(String input) {
if (input == null) return "";
byte[] bytes = new byte[input.length()]; // 最大可能長度
int byteCount = 0;
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c == '%' && i + 2 < input.length()) {
// 解析兩位十六進制
int high = hexToInt(input.charAt(i + 1));
int low = hexToInt(input.charAt(i + 2));
if (high != -1 && low != -1) {
bytes[byteCount++] = (byte) ((high << 4) | low);
i += 2;
continue;
}
} else if (c == '+') {
bytes[byteCount++] = (byte) ' '; // + 轉空格
continue;
}
// 普通 ASCII 字符直接存入
if (c <= 0x7F) {
bytes[byteCount++] = (byte) c;
} else {
// 非 ASCII 直接字符(極少見),按 UTF-8 編碼存入
try {
byte[] extra = String.valueOf(c).getBytes("UTF-8");
System.arraycopy(extra, 0, bytes, byteCount, extra.length);
byteCount += extra.length;
} catch (UnsupportedEncodingException ignored) {}
}
}
// 截取有效長度並 UTF-8 解碼
byte[] validBytes = new byte[byteCount];
System.arraycopy(bytes, 0, validBytes, 0, byteCount);
try {
return new String(validBytes, "UTF-8");
} catch (UnsupportedEncodingException e) {
return new String(validBytes, StandardCharsets.ISO_8859_1); // 降級
}
}
private static int hexToInt(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
// ==================== 測試入口 ====================
public static void main(String[] args) {
String original = "你好 世界!Hello World";
System.out.println("原文: " + original);
// 標準庫
String encoded = encode(original);
System.out.println("標準編碼: " + encoded);
System.out.println("標準解碼: " + decode(encoded));
// 手寫版
String manualEncoded = manualEncode(original);
System.out.println("手寫編碼: " + manualEncoded);
System.out.println("手寫解碼: " + manualDecode(manualEncoded));
// 驗證雙向一致性
assert original.equals(decode(encoded));
assert original.equals(manualDecode(manualEncoded));
System.out.println("✅ 雙向驗證通過");
}
}
| 場景 | 推薦方案 | 理由 |
|---|---|---|
| 一般 Web 開發 | URLEncoder/URLDecoder 或 框架封裝(Spring `UriUtils`、Netty `QueryStringDecoder`) | 標準庫經過海量驗證、處理邊界情況完善 |
| Android / 受限環境 | 手寫版或 android.net.Uri.encode/decode | 標準庫行為一致、體積可控 |
| 高性能/高併發 | Netty `UrlEncoder/Decoder`、或預編碼常用字符串 | 零拷貝、對象池、原生優化 |
| 教學/面試/無依賴 | 本文手寫版 | 理解 Percent-Encoding 本質、UTF-8 多字節組合邏輯 |
理解 Percent-Encoding 本質,配合標準庫正確使用,是處理 URL 中文參數的基石。手寫實現適合學習原理,生產請信賴成熟庫。