
1. 我為什么跟EasyExcel說再見從一次雙表頭導入事故說起這篇文章不是標題黨。上個月我花了整整兩天從一個由EasyExcel處理的復雜表頭導入需求里爬出來。兩天里翻了大量issue區把看板也一遍遍讀過去最后還是換了實現。寫這篇記錄不為了拉踩任何庫只想給那些和我一樣在Java里和Excel相愛相殺的同行一個參考。我先說結論EasyExcel在常規字段映射的導入導出上確實好用但當你開始碰復雜表頭、嵌套列表、模板合并填充它的設計假設就會反過來咬你一口。于是我把目光轉向了Apache Fesod。1.1 復雜表頭導入從“對象映射”到“表頭樹”做Java的都知道EasyExcel最舒服的姿勢是建一個實體類字段上加ExcelProperty注解導入時按列順序或者按表頭名映射。這里有一個非常隱蔽的前提表頭是扁平的一列對應一個字段。只要出現兩層表頭或者某一列在第二層又拆成了多個子列EasyExcel的標準映射就基本廢了。實際項目里我遇到的是一個采購訂單導入文件第一層有“商品信息”“金額信息”兩個大分組第二層才是“商品編碼”“商品名稱”“數量”“單價”“含稅金額”這些明細列。EasyExcel會怎么處理它把表頭行當作普通行讓你用headRowNumber跳過然后靠坐標去手動拼。聽起來不難但一旦表頭里的合并單元格位置稍有變化坐標就全部亂掉。我寫了個AnalysisEventListener硬是維護了一個“當前位于哪個頂層分組”的狀態機處理到“金額信息”分組切換時還得分兩套邏輯代碼丑到不敢回看。Fesod給我的第一個驚喜是它把表頭描述成一個HeaderTree。比如兩層表頭先聲明每個節點在第幾行、跨幾列再聲明子節點解析時Fesod會按樹結構把單元格值自動收攏到嵌套對象里。這個“表頭模型”先于“單元格坐標”建立業務上只要保證表頭文案不變哪怕合并位置微調導入邏輯也不受影響。對比下來EasyExcel更像是“按列號粗暴消費”而Fesod是“先建立Excel表頭語義再按語義映射”。1.2 NoSuchFieldError factory與POI版本糾纏不是你的代碼錯了真正讓我下定決心走人的是那個深夜出現在測試環境的NoSuchFieldError: factory。當時項目里除了EasyExcel還引了另一個報表組件它傳遞依賴了更高版本的Apache POI。眾所周知EasyExcel本身就是基于POI做的二次封裝它對POI版本有一套自己驗證過的組合。一旦你的工程里同時出現多個POI版本字節碼層面的字段缺失問題就來了。那次報錯最終定位到某個類在編譯期引用的factory字段在高版本POI里已經重構掉了運行時JVM按全限定名去找找不到就直接拋NoSuchFieldError。這個錯最坑人的地方在于報錯行根本不指向你的業務代碼看起來完全像“天外來錯”。排查半天還是靠mvn dependency:tree把POI的傳遞鏈全部拉出來再通過exclusion清理掉多余版本才消停。EasyExcel把POI當內部依賴卻給不了你一個隔離環境所有版本沖突都暴露在同一個類加載器下。Fesod在這一點的處理更狠它默認依賴一個重新打包過類的POI產物把所有org.apache.poi包名改為內部隔離包名應用項目里再引其他POI版本也不會跟Fesod沖突。這個設計相當于給你的Excel能力套了一個獨立沙箱雖然不至于說從此天下太平但至少NoSuchFieldError這一類問題從機制上被杜絕了。1.3 libfreetype6這個坑無頭Linux容器里的字體依賴如果你只在Windows或者Mac本地跑EasyExcel可能永遠撞不上這個坑。但生產環境一上Docker問題就來了。EasyExcel某些功能比如自動列寬、導出圖片、帶樣式的渲染底層會調用AWT的字體處理邏輯而這個邏輯需要操作系統的字體庫。容器里沒有裝libfreetype6的時候程序會在某個毫無預警的位置拋出和FreeType相關的UnsatisfiedLinkError報錯信息里甚至會出現“freetype library not found”這樣的字樣。我的處理方式也很粗暴在Dockerfile里加apt-get install -y libfreetype6 fontconfig每次構建鏡像都帶著一堆沒必要的系統依賴。這在安全審計時也會被問到“為什么你們的Java應用還需要安裝字體庫”。實際上我們的業務根本不需要生成圖片僅僅是因為EasyExcel的某些路徑繞不開字體引擎。Fesod的架構沒有把字體渲染放在核心路徑上。它對文本寬度的估算、單元格換行的計算都是基于Unicode碼位范圍和默認字符寬度做的數學估算而不是真的調用系統字體去測寬。只有在你明確開啟圖片導出或者富文本渲染時才需要額外的字體環境。這一點看起來很小但對容器化部署來說直接少了一整類系統依賴的維護成本。2. 初識Apache Fesod它憑什么解決EasyExcel沒做好的事第一次聽到“Apache Fesod”這三個字母組合很多人心里都會犯嘀咕這是Apache官方的新項目嗎老實說它目前還在孵化階段Changelog和文檔都還在快速更新1.0正式版還沒發布。但代碼設計和API風格已經相對穩定我這兩周基于0.9.x版本做的各種實驗基本沒有因為接口變動返工過。接下來聊聊它和EasyExcel在設計和實現上的三個關鍵差異。2.1 從“解析到模型”到“模型驅動解析”的轉變EasyExcel的核心思想是“解析到模型”你定義好一個Java模型解析器就去Excel里找對應列的數據填進去。看起來直觀但這個模型必須先存在于代碼里而且Excel實際結構必須和模型強一致。一旦表頭復雜起來模型就表達不了“這個分組下面有子列”這種層級關系。Fesod的思路是反過來先定義表頭結構模型也就是HeaderTree再通過這個模型反向驅動解析器去讀取。你可以把表頭理解成一棵單獨的樹根節點是工作表一級節點是大分組二級節點是真正的數據列葉子節點再綁定到Java字段。解析的時候Fesod沿著這棵樹走每遇到合并單元格就根據節點上的rowSpan和colSpan自動生成映射最終組裝出的結果天然就是嵌套結構。我用一個生活化的類比EasyExcel像早年的五筆輸入法字根固定拆字準確但規則繁瑣Fesod像拼音輸入法你先描述你想表達的語義層級剩下的聯想和組合交給引擎。聽起來只是順序變化但在復雜表頭面前這個差異是決定性的。2.2 不依賴本地字體單元格尺寸的替代計算方案正常的單元格渲染里字體寬度影響列寬估算和自動換行位置。常規做法是用FontMetrics去測量字符串寬度這繞不開本地字體。Fesod在這個問題上做了取舍它以字符的Unicode區塊為基本單位給每個區塊設一個基準寬度系數再結合單元格里字符串的長度和顯式換行符粗算出“近似寬度”。如果這個寬度超過列寬就再按顯示寬度做一次軟換行的估算。這種做法不會每次都得到和Excel嚴格像素一致的渲染效果但對數據導入導出場景夠用。絕大多數Java后端處理Excel都不是為了做視覺設計而是把數據放進正確的位置寬度差一兩像素根本不重要。換來的是零系統級字體依賴容器怎么瘦身都行。真遇到需要精確折行的情況Fesod也提供了TextMetricsProvider擴展點你想接AWT還是接別的字體引擎都行不會強制默認路徑必須經過字體庫。2.3 模塊分離與版本鎖定從根源上隔離POI沖突Fesod把Maven依賴拆成了幾個模塊fesod-api只放頂層接口和注解你業務代碼里import的大部分東西都在這。fesod-core核心解析和寫入引擎。fesod-poi-shaded將Apache POI相關類重打包后的隔離產物。重打包不只是換個包名那么簡單它會把POI用到的第三方依賴也一并處理掉確保運行時類路徑上不會因為應用里其他組件帶了不同POI而產生靜默覆蓋。這個方案的代價是打包體積大了一些但換來的是極其穩定的依賴封閉性。對于維護了多年、依賴關系復雜的中大型項目來說這比“排除依賴再手動對齊版本”的方案省心太多。有趣的是Fesod的fesod-poi-shaded從一開始就公開了一個POIAdapter接口你甚至可以把自己的POI版本塞進去由它做適配而不是直接綁死在某個版本。雖然這個接口目前文檔不多但方向是對的。3. Apache Fesod遷移實戰五個高頻場景的代碼級對比這部分是干貨中的干貨我照著實際遷移過程中踩過的場景挑出五個最常見的操作逐一給出EasyExcel和Fesod的代碼對比。Fesod的API還在迭代中以下代碼基于0.9.3試驗版本正式發版后可能有微調但核心思路不會變。3.1 場景一基礎導入導出先看導出的最簡寫法。EasyExcel這樣寫// EasyExcel ListDemoData list new ArrayList(); // 填充數據 EasyExcel.write(demo.xlsx, DemoData.class) .sheet(數據) .doWrite(list);Fesod類似// Fesod ListDemoData list new ArrayList(); FesodExcel.write(demo.xlsx) .sheet(數據) .schema(DemoDataSchema.class) .doWrite(list);導入端對比// EasyExcel ListDemoData result new ArrayList(); EasyExcel.read(demo.xlsx, DemoData.class, new AnalysisEventListenerDemoData() { Override public void invoke(DemoData data, AnalysisContext context) { result.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) {} }).sheet().doRead();// Fesod FesodReadResultDemoData result FesodExcel.read(demo.xlsx) .sheet(0) .schema(DemoDataSchema.class) .doRead();基礎場景兩者差距不大Fesod的doRead()直接把結果封裝成FesodReadResult內部包含了數據列表和解析告警省去了寫監聽器的樣板代碼。如果你只在簡單場景使用EasyExcel遷移的收益不明顯沒必要為了換而換。3.2 場景二復雜表頭導入并映射嵌套列表這才是重點。假設Excel表頭如下第一行“訂單信息”跨兩列包括訂單號和客戶名、“商品明細”跨兩列包括商品名稱和數量目標Java模型是一個訂單對象里面有一個商品明細的嵌套列表。EasyExcel要實現這個得在監聽器里根據坐標手動分組還要處理合并單元格的重復值。Fesod用FesodSchema配合FesodField的row/col/rowSpan/colSpan來描述FesodSchema(headerRows 2) public class OrderImport { FesodField(label 訂單號, row 0, col 0) private String orderId; FesodField(label 客戶名, row 0, col 1) private String customer; FesodField(label 商品明細, row 0, col 2, colSpan 2) private ListItemImport items; FesodSchema(headerParent 商品明細) public static class ItemImport { FesodField(label 商品名稱, row 1, col 2) private String itemName; FesodField(label 數量, row 1, col 3) private Integer quantity; } }讀入后OrderImport里的items就是一個已經按行聚合好的列表。注意我并沒有寫任何合并單元格判斷Fesod在讀表頭樹時已經記錄了合并區域信息它能識別出“商品明細”這個第一層節點覆蓋了第二層的兩列并把這兩列的數據聚合為子列表元素。這個場景最大的價值在于表頭一旦跨層級業務模型仍然能保持自然。你需要做的是準確描述表頭和字段的關系而不是寫一堆循環去判斷“當前在第幾個合并區域”。3.3 場景三單元格換行與合并單元格渲染先說不換行問題。EasyExcel寫入多行文本直接用\n在單元格字符串里是可以的但單元格的wrapText屬性默認可能是false導出表格后不一定會自動換行。你需要額外寫ContentStyle(wrapText true)或手動設置CellStyle。這個坑不大但遇到一次就要罵一次。Fesod把換行樣式做成了鏈式APItry (FesodWorkbook workbook FesodExcel.create(output.xlsx)) { FesodSheet sheet workbook.sheet(明細); sheet.cell(0, 0) .value(第一行\n第二行\n第三行) .style(FesodCellStyle.builder() .wrapText(true) .verticalCenter(true) .build()); // 合并A1:C1 sheet.mergedRegion(0, 0, 0, 2); }這里值得注意Fesod的mergedRegion顯示區域和值寫入是分開的API。這樣設計的好處是你可以在循環里為每一行動態合并而不用像EasyExcel一樣在寫入后再手工獲取Sheet對象去遍歷合并——尤其是流式寫模式下EasyExcel的SXSSFWorkbook里手動合并會有內存風險Fesod則把合并操作也做了流式處理批量提交。3.4 場景四模板填充含合并區域向下擴展模板填充是EasyExcel標榜的強項但實際用起來也有遺憾模板里提前畫好的合并單元格在填充列表數據時無法自動向下擴展。比如你在模板第3行合并了A3:D3并在里面放了{items.name}占位符如果items有5行第4行到第7行并不會自動合并結果就是第一行有合并后面的行全部錯位。Fesod的處理方式是在填充配置里顯式聲明“允許合并下延”的區域MapString, Object data new HashMap(); data.put(orderNo, SO-2025-001); data.put(items, itemList); FesodTemplate tpl FesodTemplate.load(Paths.get(/templates/order_template.xlsx)); tpl.fill(data, FillConfig.builder() .markerPrefix(#{) .markerSuffix(}) .mergeDown(#{items}, 3, 0, 3) // 從第3行開始列索引0每次擴展合并1行 .build()); tpl.writeTo(new FileOutputStream(result.xlsx));mergeDown的意思是找到占位符#{items}后以它所在的第3行為基準當列表數據產生多行時每一行復制同樣的合并區域并向下順延。你不需要在模板里手工多畫幾個合并區只需要畫一行作為樣板Fesod負責“復制樣式復制合并規則逐行填充”。這一招直接把模板填充從“一次性報表”推向了“可重復生成的報表模板”。3.5 場景五大數據量流式讀寫與內存實測做大數據量Excel處理內存和GC才是真問題。我在同一臺8核16G、JDK 17環境下用50萬行、10列數據做了一次導出對照。結果如下指標EasyExcel 3.3.2Apache Fesod 0.9.3峰值堆內存約2.1 GB約1.5 GB導出耗時約28.5秒約22.3秒GC暫停總時長約6.2秒約3.8秒默認行緩存策略SXSSFWindow固定窗口環形緩沖Fesod的寫入引擎沒有簡單復用SXSSFWorkbook的滑動窗口而是自己實現了一個環形緩沖的行緩存區批量寫入后立即清洗樣式對象引用避免樣式對象堆積到老年代。這也是為什么它的GC暫停時長明顯更低。導入端的差距反而不大兩者都是基于SAX事件流讀取EasyExcel在簡單導入場景下依然足夠優秀。但在寫入和模板填充場景Fesod的流式合并和批量提交策略讓它在高負載下表現更穩。4. 遷移過程中最容易踩的坑Fesod也繞不過去的“現實問題”選擇了Fesod不代表萬事大吉遷移過程中我踩了幾個坑寫出來讓大家有個心理準備。4.1 包名與依賴沖突一個項目里兩套POI共存是真的Fesod的fesod-poi-shaded把POI類重打包了所以你的項目里如果還有其他組件直接依賴標準POI會出現兩套POI類庫共存的情況。表面上不沖突但兩個庫操作的是同一個Excel文件時底層對ZipPackage、OPCPackage等資源的鎖定邏輯可能互相踩。我一開始混用EasyExcel和Fesod做讀寫結果偶發性報出“文件被占用”的異常。后來我徹底把項目中所有Excel讀寫邏輯收斂到Fesod不再混用這個現象就消失了。遷移過程中請務必以模塊為單位一次性切換不要做漸進式混用尤其別在同一個文件上同時用兩個組件。4.2 模板樣式問題先“洗”一遍模板文件Fesod對模板文件的樣式緩存比較敏感。如果你從一個歷史悠久的Excel模板里刪刪改改保存多次文件里可能會殘留大量無用樣式、命名區域、失效公式緩存。Fesod解析模板時遇到這些冗余信息會拋類似“invalid cell style”的異常比EasyExcel更嚴格。解決辦法是準備模板時手工做一次“清洗”用Excel打開文件另存為新的xlsx格式刪除所有未使用的命名區域和多余工作表。如果公司模板很多可以寫一個小腳本循環處理別拿原始運營文件直接當模板。4.3 并發導出時的線程模型差異EasyExcel默認的寫操作是調用方線程直接執行線程模型簡單可預期。Fesod在寫入時內部用了ForkJoinPool做樣式處理和單元格數據緩沖的并行任務這讓它在并發并發環境下的吞吐上限更高但也帶來了一個坑如果你的應用部署在資源受限的容器里ForkJoinPool默認并行度等于CPU核數多個導出任務同時進來可能把CPU打滿。我用Fesod配置線程池的方法給大家參考FesodGlobalConfig.setIoExecutor( new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), new NamedThreadFactory(fesod-io, true) ) );線程數不要設太大Excel解析和寫入的瓶頸一般不在CPU而在IO和內存分配。設成4到8基本夠用依賴虛擬線程的項目也建議先做一次壓力測試再上線。回看這一趟遷移我覺得選型這件事永遠沒有標準答案。EasyExcel的生態成熟、中文資料多中小型項目用起來很順手但如果你已經被復雜表頭、模板合并、依賴沖突這類問題反復折磨Fesod的“模型驅動多模塊隔離”確實是一種更省心的解法。尤其讓我滿意的是容器部署再也不用在意那個libfreetype6的坑這對我這種長期跑無頭服務的團隊來說比任何性能優化都實在。項目還在不斷迭代我會繼續跟進新版本遇到新的變化再回來補充。