在不想引入 Apache Commons Configuration、SnakeYAML 或 Jackson 等重型庫的項目中,手寫一個支援「區段/鍵/值」結構、帶註解保留、線程安全的 INI 解析器是極佳的工程練習,也能滿足中小型專案的配置管理需求。本文將重構原始代碼,修復其 **NPE 捕獲控制流、資源洩漏、併發不安全、註解丟失、編碼假設** 等嚴重缺陷,打造一個生產可用的 IniFile 工具類。
// 節點類型區分
enum NodeType { SECTION, KEY_VALUE, COMMENT, BLANK_LINE }
// 統一節點結構,保留原始行文本與解析後數據
class IniNode {
NodeType type;
String rawLine; // 原始行 (含縮進、註解)
String section; // 區段名 (若為 SECTION 或 KEY_VALUE)
String key; // 鍵名
String value; // 鍵值
String comment; // 內聯/整行註解內容
int lineNumber; // 原始行號
}
// 主存儲:有序列表保證寫入順序 = 讀取順序
private final List nodes = new ArrayList<>();
// 輔助索引:快速定位 (區段+鍵) -> 節點列表索引
private final Map> index = new LinkedHashMap<>();
public void load() throws IOException {
nodes.clear(); index.clear();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String currentSection = ""; // 預設無區段
String line;
int lineNum = 0;
while ((line = reader.readLine()) != null) {
lineNum++;
String trimmed = line.stripLeading(); // 保留尾部空格? 通常strip()更安全
IniNode node = new IniNode();
node.lineNumber = lineNum;
node.rawLine = line; // 完整保留
if (trimmed.isEmpty()) {
node.type = NodeType.BLANK_LINE;
} else if (trimmed.startsWith(";") || trimmed.startsWith("#")) {
node.type = NodeType.COMMENT;
node.comment = trimmed.substring(1).stripLeading();
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
node.type = NodeType.SECTION;
currentSection = node.section = trimmed.substring(1, trimmed.length()-1).strip();
} else {
// 尋找第一個 '=' 分割 key/value,支援內聯註解
int eqIdx = findFirstUnquotedEquals(trimmed); // 需處理值中含 '=' 情況
if (eqIdx > 0) {
node.type = NodeType.KEY_VALUE;
node.section = currentSection;
node.key = trimmed.substring(0, eqIdx).stripTrailing();
String valPart = trimmed.substring(eqIdx+1);
// 分離值與內聯註解
int commentIdx = findFirstUnquotedComment(valPart);
if (commentIdx >= 0) {
node.value = valPart.substring(0, commentIdx).stripTrailing();
node.comment = valPart.substring(commentIdx+1).stripLeading();
} else {
node.value = valPart.stripTrailing();
}
} else {
// 格式錯誤行視為註解保留
node.type = NodeType.COMMENT;
node.comment = "[格式錯誤] " + trimmed;
}
}
nodes.add(node);
if (node.type == NodeType.KEY_VALUE) {
index.computeIfAbsent(node.section + "/" + node.key, k -> new ArrayList<>()).add(nodes.size()-1);
}
}
}
}
// 輔助:尋找第一個未在引號內的 '=' (簡易實現)
private int findFirstUnquotedEquals(String s) {
boolean inSingle = false, inDouble = false;
for (int i=0; i
線程安全的讀寫操作
public synchronized String get(String section, String key) {
List idxList = index.get(normalizeSection(section) + "/" + key);
if (idxList == null || idxList.isEmpty()) return null;
// 取最後一次出現的值 (標準 INI 行為:後蓋前)
return nodes.get(idxList.get(idxList.size()-1)).value;
}
public synchronized void set(String section, String key, String value) {
String normSec = normalizeSection(section);
String idxKey = normSec + "/" + key;
List idxList = index.get(idxKey);
if (idxList != null && !idxList.isEmpty()) {
// 更新現有最後一個
IniNode node = nodes.get(idxList.get(idxList.size()-1));
node.value = value;
// 重建該行 rawLine (保留原縮進風格)
node.rawLine = rebuildKeyValueLine(node);
} else {
// 新增:插入到對應區段末尾,或檔案末尾
int insertPos = findInsertPosition(normSec);
IniNode node = new IniNode();
node.type = NodeType.KEY_VALUE;
node.section = normSec;
node.key = key;
node.value = value;
node.rawLine = key + " = " + value; // 預設風格
nodes.add(insertPos, node);
index.computeIfAbsent(idxKey, k -> new ArrayList<>()).add(insertPos);
updateIndicesAfter(insertPos); // 維護索引行號
}
dirty = true; // 標記需保存
}
public synchronized void remove(String section, String key) {
String idxKey = normalizeSection(section) + "/" + key;
List idxList = index.remove(idxKey);
if (idxList != null) {
// 從後往前刪除節點,避免索引偏移
for (int i = idxList.size()-1; i >=0; i--) {
nodes.remove(idxList.get(i));
}
updateIndicesAfter(0); // 簡單重建索引
dirty = true;
}
}
public synchronized void save() throws IOException {
if (!dirty) return;
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
for (IniNode node : nodes) {
writer.write(node.rawLine);
writer.newLine();
}
}
dirty = false;
}
使用範例
public static void main(String[] args) throws IOException {
File configFile = new File("app.ini");
IniFile ini = new IniFile(configFile);
// 寫入
ini.set("database", "host", "127.0.0.1");
ini.set("database", "port", "3306");
ini.set("ui", "theme", "dark");
ini.save(); // 產生標準 INI 檔案
// 讀取
String host = ini.get("database", "host"); // "127.0.0.1"
String theme = ini.get("ui", "theme"); // "dark"
// 修改並保存 (註解、空行、順序完美保留)
ini.set("database", "port", "5432");
ini.remove("ui", "theme");
ini.save();
}
生產環境建議:何時自造輪子,何時用成熟庫
場景
建議方案
簡單鍵值對、單檔案、無複雜結構、團隊輕量級偏好
✅ **本文重構版 IniFile** (約 200 行,零依賴,可控)
需支援巢狀結構、列表、類型轉換、多檔案合併、熱重載
📦 Typesafe Config (HOCON) / Apache Commons Configuration 2
Spring Boot / MicroProfile 專案
🌱 **框架原生支援** (`application.yml` + `@ConfigurationProperties` / MicroProfile Config)
高併發寫入、分布式配置中心
☁️ **Nacos / Apollo / Consul / etcd** (勿用本地檔案)
總結
手寫 INI 解析器是理解「文本解析、狀態機、有序數據結構、線程安全、資源管理」的極佳練習。本文重構版修復了原始代碼的所有致命缺陷,實現了**格式完美保留、線程安全、資源無洩漏、標準語法支援**。但在實際專案中,請根據複雜度果斷選擇成熟方案:**別在生產環境為了省幾個 JAR 包大小,而承擔維護自研配置庫的長期成本**,除非您的專案極其簡單且長期穩定。