雅處理字符串拼接與格式化)
1. 從一次“丑陋”的字符串拼接說起如果你寫過Java肯定干過這事兒把一堆字符串用某個(gè)分隔符比如逗號(hào)、橫杠拼接起來。最原始的做法可能就是用一個(gè)StringBuilder然后手動(dòng)判斷是不是最后一個(gè)元素來決定要不要加分隔符。代碼寫出來大概是這個(gè)味兒ListString cities Arrays.asList(北京, 上海, 廣州, 深圳); StringBuilder sb new StringBuilder(); for (int i 0; i cities.size(); i) { sb.append(cities.get(i)); if (i cities.size() - 1) { sb.append(, ); } } String result sb.toString(); // 結(jié)果是“北京, 上海, 廣州, 深圳”這段代碼功能上沒問題但說實(shí)話有點(diǎn)啰嗦而且容易出錯(cuò)。每次循環(huán)都要判斷索引一不小心就可能多一個(gè)或少一個(gè)分隔符。后來String.join()方法出現(xiàn)了對(duì)于集合或數(shù)組的拼接確實(shí)清爽了不少String result String.join(, , cities);String.join()很好但它有個(gè)局限它只接受一個(gè)CharSequence類型的可變參數(shù)或者一個(gè)Iterable。如果你想在拼接的結(jié)果前后加上前綴和后綴比如生成一個(gè)JSON數(shù)組字符串[北京, 上海, 廣州, 深圳]String.join()就無能為力了你最終還是得回到StringBuilder的老路上去。正是為了解決這種“格式化拼接”的需求并且提供一個(gè)比手動(dòng)操作StringBuilder更優(yōu)雅、更安全的APIJava 8 引入了StringJoiner這個(gè)類。它的名字直白地告訴了你它的工作專門用來“連接字符串”。但它的能力遠(yuǎn)不止簡單的連接。很多人包括一些工作了幾年的開發(fā)者可能只是知道有這么個(gè)類或者僅限于在String.join()的內(nèi)部實(shí)現(xiàn)里見過它并沒有真正去深入了解過它。今天我們就來徹底拆解一下這個(gè)看似簡單實(shí)則精巧的工具類。2. StringJoiner的核心三板斧構(gòu)造、添加與合并StringJoiner的設(shè)計(jì)遵循了“單一職責(zé)”和“流暢接口”的原則它的API非常簡潔核心方法就幾個(gè)。我們先從最基礎(chǔ)的用法看起。2.1 構(gòu)造函數(shù)與基礎(chǔ)狀態(tài)StringJoiner有兩個(gè)構(gòu)造函數(shù)// 構(gòu)造函數(shù)1只指定分隔符 StringJoiner sj new StringJoiner(, ); // 構(gòu)造函數(shù)2指定分隔符、前綴和后綴 StringJoiner sj new StringJoiner(, , [, ]);第一個(gè)構(gòu)造函數(shù)創(chuàng)建了一個(gè)只有分隔符的拼接器。此時(shí)它的“前綴”和“后綴”都是空字符串。第二個(gè)構(gòu)造函數(shù)則完整地定義了拼接的格式每個(gè)元素之間用分隔符隔開整個(gè)結(jié)果字符串以指定的前綴開頭以后綴結(jié)尾。這直接解決了我們前面提到的生成JSON數(shù)組字符串的需求。這里有一個(gè)極易被忽略但非常重要的細(xì)節(jié)StringJoiner內(nèi)部維護(hù)了一個(gè)StringBuilder實(shí)例名為value來真正構(gòu)建字符串同時(shí)還維護(hù)了兩個(gè)String類型的變量prefix前綴和suffix后綴。關(guān)鍵在于前綴和后綴只在最終調(diào)用toString()方法生成結(jié)果時(shí)才會(huì)被添加到value的前后。在中間添加元素的過程中value里存儲(chǔ)的只是“已添加的元素分隔符”。2.2 add() 方法智能添加元素add()方法是往StringJoiner里添加新元素的主要方式StringJoiner sj new StringJoiner(, , [, ]); sj.add(北京); sj.add(上海);它的內(nèi)部邏輯非常聰明第一次調(diào)用add(“北京”)時(shí)因?yàn)関alue是空的它不會(huì)先加分隔符而是直接追加“北京”。此時(shí)value北京。第二次調(diào)用add(“上海”)時(shí)它發(fā)現(xiàn)value不為空了于是先追加分隔符, 再追加“上海”。此時(shí)value北京, 上海。這種“非首次添加時(shí)自動(dòng)補(bǔ)分隔符”的邏輯徹底把我們從手動(dòng)判斷索引的繁瑣中解放了出來。這也是StringJoiner比手動(dòng)使用StringBuilder優(yōu)雅的核心所在。實(shí)操心得add()方法接受的是CharSequence這意味著你不僅可以傳String還可以傳StringBuilder、StringBuffer等。但要注意如果傳入null它會(huì)被轉(zhuǎn)換成字符串null追加進(jìn)去。如果你不希望這樣需要在調(diào)用add()之前自己做空值判斷。2.3 merge() 方法拼接器的“合并”這是StringJoiner一個(gè)非常強(qiáng)大但常被低估的功能。merge()方法允許你將另一個(gè)StringJoiner的內(nèi)容合并到當(dāng)前這個(gè)里面。StringJoiner sj1 new StringJoiner(, , [, ]); sj1.add(北京).add(上海); StringJoiner sj2 new StringJoiner(-, (, )); sj2.add(廣州).add(深圳); sj1.merge(sj2); System.out.println(sj1.toString()); // 輸出[北京, 上海, 廣州, 深圳]注意看輸出結(jié)果。sj1合并了sj2的所有元素內(nèi)容“廣州”和“深圳”但是忽略了sj2的前綴(和后綴)。合并時(shí)如果sj1的value不為空且sj2的value也不為空sj1會(huì)先追加自己的分隔符然后把sj2.value的整個(gè)字符串內(nèi)容追加過來。merge()的設(shè)計(jì)意圖是用于組合多個(gè)相同格式的字符串片段。例如你可能有多個(gè)方法各自返回一個(gè)拼接了部分結(jié)果的StringJoiner最后你可以用一個(gè)主StringJoiner把它們?nèi)縨erge()起來得到完整結(jié)果。踩坑提示merge()方法不會(huì)改變當(dāng)前StringJoiner的分隔符、前綴和后綴。它只合并“內(nèi)容”。這意味著如果你試圖合并一個(gè)分隔符不同的StringJoiner最終結(jié)果里可能會(huì)出現(xiàn)多種分隔符混用的情況這通常不是你想要的效果。所以merge()最好用在多個(gè)相同配置的StringJoiner之間。2.4 toString() 與 setEmptyValue()toString()方法的行為是理解StringJoiner的另一個(gè)關(guān)鍵點(diǎn)如果StringJoiner內(nèi)部從未添加過任何元素即value為空那么toString()會(huì)返回一個(gè)“空值字符串”默認(rèn)是prefix suffix。對(duì)于new StringJoiner(, , “[, “]”)空值時(shí)返回[]。如果添加過元素則返回prefix value suffix。有時(shí)候這個(gè)默認(rèn)的空值結(jié)果可能不是你想要的。比如你希望當(dāng)沒有元素時(shí)返回一個(gè)空字符串或者返回一個(gè)特定的提示如“無”。這時(shí)就可以用setEmptyValue()方法StringJoiner sj new StringJoiner(, ); sj.setEmptyValue(暫無數(shù)據(jù)); System.out.println(sj.toString()); // 輸出暫無數(shù)據(jù) sj.add(數(shù)據(jù)1); System.out.println(sj.toString()); // 輸出數(shù)據(jù)1這里有一個(gè)非常重要的注意事項(xiàng)setEmptyValue()設(shè)置的字符串僅在StringJoiner為空value長度為0時(shí)toString()方法才會(huì)返回它。一旦你添加了任何元素toString()就會(huì)忽略這個(gè)設(shè)置按照prefixvaluesuffix的規(guī)則返回。這個(gè)方法常用于生成對(duì)用戶更友好的空狀態(tài)提示。3. 深入源碼看StringJoiner如何優(yōu)雅工作理解一個(gè)類最好的方式就是看它的源碼。StringJoiner的源碼非常短小精悍不到200行我們挑核心部分看看。核心字段private final String prefix; // 前綴 private final String delimiter; // 分隔符 private final String suffix; // 后綴 private StringBuilder value; // 真正構(gòu)建字符串的地方 private String emptyValue; // 空值時(shí)的默認(rèn)字符串構(gòu)造函數(shù)public StringJoiner(CharSequence delimiter) { this(delimiter, , ); // 調(diào)用另一個(gè)構(gòu)造前綴后綴設(shè)為空字符串 } public StringJoiner(CharSequence delimiter, CharSequence prefix, CharSequence suffix) { // 關(guān)鍵對(duì)參數(shù)進(jìn)行非空檢查并轉(zhuǎn)換為String存儲(chǔ) Objects.requireNonNull(prefix, The prefix must not be null); Objects.requireNonNull(delimiter, The delimiter must not be null); Objects.requireNonNull(suffix, The suffix must not be null); this.prefix prefix.toString(); this.delimiter delimiter.toString(); this.suffix suffix.toString(); // 初始化emptyValue為 prefix suffix this.emptyValue this.prefix this.suffix; }可以看到前綴、分隔符、后綴在構(gòu)造時(shí)就被確定并轉(zhuǎn)換為String存儲(chǔ)下來且不可變final。這保證了StringJoiner實(shí)例行為的確定性。add() 方法public StringJoiner add(CharSequence newElement) { prepareBuilder().append(newElement); return this; } private StringBuilder prepareBuilder() { if (value ! null) { value.append(delimiter); // 非首次添加先加分隔符 } else { value new StringBuilder().append(prefix); // 首次添加先加前綴 } return value; }prepareBuilder()是精髓所在。它處理了是否首次添加的邏輯并保證了前綴只在第一次添加元素時(shí)才被放入value。注意這里放入value的前綴在最終toString()時(shí)還會(huì)不會(huì)再加一次呢我們看toString()就明白了。toString() 方法public String toString() { if (value null) { return emptyValue; // 從未添加元素返回空值字符串 } else { // 如果后綴不為空才需要追加后綴 if (suffix.equals()) { return value.toString(); } else { // 追加后綴并轉(zhuǎn)換為String返回 int initialLength value.length(); String result value.append(suffix).toString(); // 將value回退到追加后綴之前的狀態(tài)以便后續(xù)繼續(xù)add value.setLength(initialLength); return result; } } }這里揭示了幾個(gè)關(guān)鍵點(diǎn)空值判斷以value是否為null為依據(jù)。后綴處理如果后綴不是空字符串toString()會(huì)在返回結(jié)果前臨時(shí)將后綴追加到value的末尾生成結(jié)果字符串然后再把value的長度設(shè)回之前的狀態(tài)setLength。這是一個(gè)非常巧妙的設(shè)計(jì)它保證了多次調(diào)用toString()不會(huì)重復(fù)追加后綴同時(shí)value本身并不永久包含后綴后續(xù)還可以繼續(xù)add()元素。前綴去哪了回顧prepareBuilder()前綴在第一次add()時(shí)就已經(jīng)被加到value里了。所以對(duì)于非空的StringJoinervalue里已經(jīng)包含了前綴和所有元素及分隔符。toString()只需要處理后綴即可。通過閱讀源碼我們就能徹底明白為什么StringJoiner能如此優(yōu)雅地處理前綴、后綴和分隔符其內(nèi)部的StringBuilder(value) 的狀態(tài)管理是核心。4. 實(shí)戰(zhàn)場景不止于集合拼接很多人以為StringJoiner只能用來拼接集合那就太小看它了。它的應(yīng)用場景非常靈活。4.1 場景一構(gòu)建動(dòng)態(tài)SQL或查詢條件這是后端開發(fā)中非常常見的場景。我們需要根據(jù)前端傳入的多個(gè)條件動(dòng)態(tài)拼接SQL的WHERE子句。public String buildQueryCondition(ListString tags, String author, Date startDate) { StringJoiner whereClause new StringJoiner( AND , WHERE , ); if (tags ! null !tags.isEmpty()) { // 假設(shè)tags需要構(gòu)建成 IN (?, ?, ?) 的形式 StringJoiner placeholders new StringJoiner(, , (, )); tags.forEach(tag - placeholders.add(?)); whereClause.add(tags IN placeholders.toString()); } if (author ! null !author.trim().isEmpty()) { whereClause.add(author ?); } if (startDate ! null) { whereClause.add(create_time ?); } // 如果沒有條件whereClause.toString() 會(huì)返回空字符串因?yàn)閟etEmptyValue默認(rèn)是 return whereClause.toString(); }使用StringJoiner我們可以清晰地以“AND”為分隔符構(gòu)建條件子句并且自動(dòng)處理了是否添加“WHERE”關(guān)鍵字的問題如果沒有條件toString()返回空字符串整個(gè)WHERE子句就不會(huì)出現(xiàn)。4.2 場景二生成特定格式的日志或消息當(dāng)需要生成結(jié)構(gòu)化的日志行或者組裝一個(gè)給用戶看的復(fù)合消息時(shí)StringJoiner很好用。public String formatLogEntry(String level, String clazz, String method, String message) { // 格式[LEVEL] ClassName#MethodName - Message return new StringJoiner( ) .add([ level ]) .add(clazz # method) .add(-) .add(message) .toString(); } // 使用 String log formatLogEntry(ERROR, UserService, login, Authentication failed for user: admin); // 輸出[ERROR] UserService#login - Authentication failed for user: admin4.3 場景三替代復(fù)雜的StringBuilder手動(dòng)拼接任何你覺得用StringBuilder寫起來需要頻繁判斷分隔符是否該加、前綴后綴該怎么處理的地方都可以考慮用StringJoiner來簡化。// 傳統(tǒng)StringBuilder方式容易出錯(cuò) StringBuilder pathBuilder new StringBuilder(); pathBuilder.append(/api); if (version ! null) { pathBuilder.append(/v).append(version); } pathBuilder.append(/users); if (userId ! null) { pathBuilder.append(/).append(userId); } // 需要小心處理多余的斜杠 // 使用StringJoiner清晰且安全 StringJoiner pathJoiner new StringJoiner(/, /, ); pathJoiner.add(api); if (version ! null) { pathJoiner.add(v version); } pathJoiner.add(users); if (userId ! null) { pathJoiner.add(userId); } String path pathJoiner.toString(); // 例如/api/v1/users/123用StringJoiner來構(gòu)建URL路徑或文件路徑邏輯一目了然完全不用擔(dān)心多余或缺少分隔符的問題。5. 性能考量與最佳實(shí)踐雖然StringJoiner的代碼很簡潔但作為一個(gè)工具類我們還是會(huì)關(guān)心它的性能。本質(zhì)上它是對(duì)StringBuilder的一層封裝所以其性能特征與StringBuilder基本一致。5.1 與StringBuilder和String.join()的對(duì)比vs 手動(dòng)StringBuilder在功能等價(jià)的前提下StringJoiner的性能開銷微乎其微多了一層方法調(diào)用和空值判斷。它帶來的代碼簡潔性和可維護(hù)性提升遠(yuǎn)遠(yuǎn)超過這點(diǎn)性能損耗。在絕大多數(shù)業(yè)務(wù)場景下應(yīng)優(yōu)先使用StringJoiner。vs String.join()String.join()內(nèi)部就是使用StringJoiner實(shí)現(xiàn)的。對(duì)于簡單的、無前綴后綴的集合拼接直接使用String.join()是最簡潔的。String.join()是一個(gè)靜態(tài)工具方法而StringJoiner是一個(gè)對(duì)象后者在需要多次add或復(fù)雜格式控制時(shí)更靈活。5.2 預(yù)分配StringBuilder大小StringJoiner內(nèi)部的StringBuilder在初始化時(shí)如果沒有內(nèi)容其value是null。第一次添加元素時(shí)會(huì)創(chuàng)建一個(gè)默認(rèn)容量16字符的StringBuilder。如果你能預(yù)估最終字符串的大致長度可以通過一個(gè)“小技巧”來優(yōu)化先添加一個(gè)空字符串然后獲取內(nèi)部的StringBuilder來預(yù)分配容量。ListString largeList // ... 一個(gè)包含很多字符串的列表 StringJoiner sj new StringJoiner(, ); if (!largeList.isEmpty()) { sj.add(); // 先添加一個(gè)空元素觸發(fā)內(nèi)部StringBuilder的創(chuàng)建 // 預(yù)估容量 (平均字符串長度 分隔符長度) * 數(shù)量 int estimatedCapacity (10 2) * largeList.size(); // 注意這里是一個(gè)hack直接訪問了非公開的字段。實(shí)際中不推薦僅作原理演示。 // sj.value.ensureCapacity(estimatedCapacity); // 更實(shí)際的做法是如果你知道大概大小直接用StringBuilder可能更直接。 } // 然后正常添加largeList里的元素重要提示上面的代碼中直接訪問sj.value是不可行的因?yàn)関alue是私有字段。這里只是為了說明原理。實(shí)際上StringJoiner并沒有提供直接設(shè)置初始容量的公共API。對(duì)于極高性能敏感的場景如果你確需控制初始容量可能需要回歸到直接使用StringBuilder并手動(dòng)管理分隔符邏輯。但這屬于非常極端的優(yōu)化99%的情況下不需要考慮。5.3 線程安全和StringBuilder一樣StringJoiner不是線程安全的。如果多個(gè)線程同時(shí)操作同一個(gè)StringJoiner實(shí)例會(huì)導(dǎo)致不確定的結(jié)果。在并發(fā)環(huán)境下每個(gè)線程應(yīng)該使用自己獨(dú)立的StringJoiner實(shí)例。6. 常見“坑”與最佳實(shí)踐總結(jié)即使是一個(gè)簡單的工具類用不好也會(huì)踩坑。下面是一些實(shí)踐中總結(jié)出來的要點(diǎn)。6.1 空值null處理策略StringJoiner.add(null)會(huì)把null這個(gè)字符串加進(jìn)去。這有時(shí)是符合預(yù)期的比如日志記錄但有時(shí)可能不是。你需要根據(jù)業(yè)務(wù)邏輯決定是否在調(diào)用add前進(jìn)行空值過濾。// 如果你不希望null被拼接進(jìn)去 items.stream() .filter(Objects::nonNull) // 過濾null .forEach(sj::add);6.2 小心分隔符中的空格分隔符是一個(gè)CharSequence它可以是任意字符序列。常用的分隔符如, 逗號(hào)加空格、 - 橫杠加空格。定義分隔符時(shí)一定要想清楚你是否需要這些空格。例如在拼接URL路徑時(shí)分隔符/就不能有空格。6.3 merge() 的語義陷阱再次強(qiáng)調(diào)merge()只合并另一個(gè)StringJoiner的內(nèi)容即其內(nèi)部value字符串而忽略其前綴、后綴和分隔符。不要誤以為合并后格式也會(huì)統(tǒng)一。合并具有不同分隔符的StringJoiner通常會(huì)產(chǎn)生混亂的結(jié)果。6.4 與Stream API的完美結(jié)合StringJoiner和Java 8的Stream API是天作之合。Collectors.joining()收集器的底層實(shí)現(xiàn)就是StringJoiner。ListString list Arrays.asList(a, b, c); // 等價(jià)于使用StringJoiner String result1 list.stream().collect(Collectors.joining(, , [, ])); // 手動(dòng)使用StringJoiner StringJoiner sj new StringJoiner(, , [, ]); list.forEach(sj::add); String result2 sj.toString(); // result1 和 result2 都是 [a, b, c]在流式處理中直接使用Collectors.joining()是更函數(shù)式、更簡潔的選擇。6.5 清晰勝于巧妙雖然StringJoiner的鏈?zhǔn)秸{(diào)用sj.add(“a”).add(“b”).add(“c”)很流暢但為了代碼可讀性如果添加邏輯很復(fù)雜夾雜很多條件判斷適當(dāng)換行或使用臨時(shí)變量會(huì)讓代碼更清晰。// 清晰的寫法 StringJoiner sj new StringJoiner(\n); if (conditionA) { sj.add(generatePartA()); } if (conditionB) { sj.add(generatePartB()); } // ... 更多條件 return sj.toString();StringJoiner是一個(gè)典型的“小工具大用處”的JDK類。它沒有驚天動(dòng)地的功能但通過一個(gè)精巧的設(shè)計(jì)解決了字符串拼接中的一個(gè)特定痛點(diǎn)并極大地提升了代碼的簡潔性和表達(dá)力。下次當(dāng)你下意識(shí)地敲出StringBuilder來拼接帶分隔符的字符串時(shí)不妨先停下來想一想用StringJoiner是不是更合適理解和善用這些標(biāo)準(zhǔn)庫提供的精良工具正是我們寫出更優(yōu)雅、更健壯代碼的關(guān)鍵一步。