简介:一份面向 Java 开发者的轻量示例代码,聚焦用 Apache POI 将 XLS 电子表格转换为 TXT 或 CSV 的常见需求,适合需要快速了解表格解析与文本导出流程的初中级开发者参考。压缩包共 2 个文件,包含 1 个 Java 源码文件与 1 个 Markdown 说明文件,整体约 2KB;Java 文件给出核心转换示例,Markdown 文件用于补充使用说明与背景信息,体量小巧便于直接查阅。示例代码针对某些特定格式的 XLS 编写,因此存在少量硬编码数字,描述中也提示可通过不指定特定列号来规避限制,这一细节对改造适配不同表格结构有参考意义。已有 191 人学习,读者可从中了解基于 POI API 读取工作簿、处理单元格并输出 TXT/CSV 的基本思路,并借助源码与说明文档快速定位可复用的实现片段。
1. 从 300 个旧 XLS 说起:XLS2TXT 到底替谁省时间
某公司数据迁移前夜,业务同事丢来 300 多个 .xls,结构横七竖八,目标系统只认 TXT 和 CSV。手点 Excel 另存为,既慢又容易把日期、前导零和中文编码弄丢。XLS2TXT 就是冲这个场景来的:一个基于 Java 的 XLS 转 TXT/CSV 工具,能把单文件或整目录的旧表格批量落成文本,适合做数据清洗前的第一道粗加工。它不是 ETL 平台,别指望它建表、洗脏数据;它的价值是把 Excel 这层壳剥掉,让后面的脚本、数据库导入和比对工具能接手。
2. 拆开 XLS2TXT 的 Java 底座:BIFF8 记录、POI 与单元格类型
2.1 .xls 不是改了后缀的表格:OLE2、BIFF8 与 HSSF 的边界
很多人拿到 .xls 第一反应是改后缀为 .xlsx,再用新库读,结果直接报 Invalid header signature。原因不复杂:.xls 是 OLE2 复合文档,内部按 BIFF8 记录流存工作表、格式、公式;.xlsx 则是 ZIP 包加 XML。文件头就能区分:OLE2 开头通常是D0 CF 11 E0 A1 B1 1A E1,ZIP 开头是50 4B 03 04。XLS2TXT 这类 Java 工具,底层常见做法是 Apache POI:HSSF 读 .xls,XSSF 读 .xlsx,SXSSF 只解决写大文件。HSSF 的边界要提前知道:一个 sheet 最多 65536 行、256 列,BIFF5 的老文件支持不完整,加密文件不额外处理就会在打开阶段失败。
标题里写“任何 XLS”,实际落地时要分几种情况:标准 BIFF8 的 .xls 最稳;从旧系统导出的 BIFF5 可能丢格式;把 HTML 表格或 XML 内容直接改成 .xls 后缀的“假 Excel”,POI 会在文件头校验时拒绝;带打开密码的文件,要先让业务方另存为无密码版本,别在转换脚本里硬啃解密。选型上,如果只处理 .xls,HSSF 足够;如果同一批目录里混着 .xls 和 .xlsx,直接用WorkbookFactory按文件头自动分派,比按后缀判断可靠。XLS2TXT 如果提供的是源码包,先看它依赖的是 POI 还是 jxl;jxl 对 BIFF8 够用,但对新格式和复杂公式的支持面窄,后期二次开发容易卡住。
还有一个容易忽略的点:Excel 的“空行”不一定是空行。行对象存在、单元格对象不存在、单元格存在但类型是 BLANK,这三种状态在转换时行为不同。XLS2TXT 输出 TXT 时,如果直接按row.getLastCellNum()截断,可能把中间的空列吃掉;如果按固定列数补齐,又可能给下游多塞空字段。我的做法是先用最小读取器把 sheet 名、有效行数、每行列数和单元格类型打印出来,确认源文件没有“假空行”和“合并单元格残影”,再决定批量转换的列策略。
2.2 先写一个最小读取器:把单元格类型、日期和公式看清楚
在把整批文件交给 XLS2TXT 之前,我一般会先用二十行 Java 写一个读取器,确认这个文件到底能不能被 POI 正常打开、日期列是不是真日期、公式列有没有缓存值。下面这段代码不依赖 XLS2TXT 的具体 API,只做“体检”:
import org.apache.poi.ss.usermodel.*; import java.io.File; public class XlsInspector { public static void main(String[] args) throws Exception { File file = new File(args[0]); // WorkbookFactory 按文件头选 HSSF 或 XSSF,避免手动判断后缀 try (Workbook wb = WorkbookFactory.create(file)) { DataFormatter formatter = new DataFormatter(); FormulaEvaluator evaluator = wb.getCreationHelper().createFormulaEvaluator(); for (Sheet sheet : wb) { System.out.println("SHEET=" + sheet.getSheetName()); for (Row row : sheet) { StringBuilder line = new StringBuilder(); for (Cell cell : row) { // 公式单元格先看缓存结果类型,避免直接读成公式字符串 if (cell.getCellType() == CellType.FORMULA) { CellType cached = cell.getCachedFormulaResultType(); if (cached == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { line.append(formatter.formatCellValue(cell, evaluator)).append('\t'); continue; } } // DataFormatter 尽量按单元格显示格式输出,日期和数字都受益 String text = formatter.formatCellValue(cell, evaluator); line.append(text).append('\t'); } System.out.println(line); } } } } }逻辑说明:WorkbookFactory.create(File)是入口,它根据文件头而不是后缀选择 HSSF 或 XSSF;DataFormatter负责把单元格按 Excel 里看到的格式转成字符串,日期、百分比、千分位都靠它;FormulaEvaluator用来计算公式,但公式单元格本身可能带缓存值,所以先看getCachedFormulaResultType(),避免把公式文本直接写进 TXT。参数上,args[0]传单个 .xls 或 .xlsx 路径;输出用制表符分隔,方便先肉眼比对;如果源文件有公式且缓存值为空,evaluator可能抛异常,这时要在外层 catch 里记录文件名和 sheet 名,不要静默跳过。
这段代码跑通,至少说明三件事:文件头合法、POI 能打开、单元格类型可枚举。后面用 XLS2TXT 批量转换时,如果某个文件输出异常,我会先拿它跑一遍这个读取器。读取器也报错,问题在源文件;读取器正常而 XLS2TXT 输出错,问题在转换参数或输出编码。这个顺序能省掉大量“到底是文件坏还是工具坏”的拉扯。
3. 把单元格落成文本:UTF-8/GBK 编码、分隔符与转义三件事
3.1 编码选型:UTF-8、GBK、GB18030 与 BOM 的取舍
XLS2TXT 把内容从 Workbook 里读出来之后,最终要落到字节流。Java 里常见写法是OutputStreamWriter包FileOutputStream,如果不显式传 charset,就会吃平台默认编码。Windows 上默认可能是 GBK,Linux 容器里可能是 UTF-8,同一份转换脚本在两台机器上跑出两种结果,这种翻车现场我见过不止一次。稳妥做法是:在配置里把输出编码写成显式参数,TXT 和 CSV 分别可配,日志里打印实际使用的 charset 名称。
| 落地场景 | 建议编码 | 关键动作 |
|---|---|---|
| Windows Excel 双击打开 CSV | GBK 或 GB18030 | 不加 BOM,避免首列带隐藏字符 |
| Linux/容器里的导入脚本 | UTF-8 | 显式指定 charset,不依赖file.encoding |
| 跨系统交换、含生僻字 | UTF-8 或 GB18030 | 先确认接收端字符集,别只看文件能打开 |
| 老数据库客户端 | GBK | 用Charset.forName("GBK"),别用平台默认 |
BOM 要单独说。给 Excel 双击用的 UTF-8 CSV,加 BOM 能减少中文乱码;但很多解析器、数据库导入工具、Spark 读 CSV 时会把 BOM 当成第一列列名的一部分,导致“字段名对不上”。我的习惯是:面向人看的 CSV 可以加 BOM,面向程序导入的 CSV 不加;TXT 一般不加。XLS2TXT 如果提供“编码”和“BOM”两个选项,不要只改编码不检查 BOM,最好在输出后用十六进制看一眼文件头是不是EF BB BF。
注意:
-Dfile.encoding=UTF-8只影响 JVM 默认字符集,不能替代代码里的显式 charset。转换工具内部如果用了new String(bytes)或FileReader,仍然可能按平台默认解码,排查时要看源码里有没有硬编码。
3.2 分隔符与转义:逗号、制表符、换行和引号怎么处理
CSV 不是“用逗号把字段拼起来”这么简单。单元格里出现逗号、双引号、换行时,必须按 RFC 4180 的思路处理:含分隔符、引号或换行的字段用双引号包起来,字段内部的双引号写成两个双引号。TXT 如果用制表符分隔,规则类似,只是分隔符换成\t。XLS2TXT 的输出配置里通常会有分隔符、引号字符、是否全部加引号这几个参数,别嫌麻烦,先把转义逻辑跑通再批量。
import java.io.*; import java.nio.charset.*; import java.util.*; public class TextExporter { public static void writeRow(BufferedWriter writer, List<String> cells, char delimiter, char quote, boolean quoteAll) throws IOException { StringBuilder line = new StringBuilder(); for (int i = 0; i < cells.size(); i++) { if (i > 0) line.append(delimiter); String value = cells.get(i) == null ? "" : cells.get(i); boolean needQuote = quoteAll || value.indexOf(delimiter) >= 0 || value.indexOf(quote) >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0; if (needQuote) { line.append(quote); line.append(value.replace(String.valueOf(quote), String.valueOf(quote) + quote)); line.append(quote); } else { line.append(value); } } writer.write(line.toString()); writer.write("\n"); // 固定 LF,避免 Windows/Linux 混跑时行尾不一致 } public static void main(String[] args) throws Exception { Charset charset = StandardCharsets.UTF_8; // 给 Windows Excel 双击时可换成 Charset.forName("GBK") try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream("out.csv"), charset))) { writeRow(writer, Arrays.asList("订单号", "金额", "备注"), ',', '"', false); writeRow(writer, Arrays.asList("A001", "12.50", "含逗号,和\"引号\""), ',', '"', false); } } }逻辑说明:writeRow负责一行的拼接和转义,delimiter是分隔符,CSV 用,,TXT 用\t;quote是引号字符,通常用双引号;quoteAll为 true 时所有字段都加引号,适合字段内容不可控、下游解析器又比较挑剔的场景。value.indexOf(delimiter)检查字段里有没有分隔符,value.indexOf(quote)检查有没有引号,\n和\r检查换行。替换时把单个引号变成两个引号,这是 CSV 的标准转义。writer.write("\n")固定 LF,不让平台默认行尾混进来;如果下游明确要求 CRLF,再改成\r\n。
参数之外还有两个细节。第一,数值格式:如果单元格是数值型,DataFormatter可能输出12.5而不是12.50,金额列要在转换前确认单元格格式,或者在导出后统一补小数位。第二,前导零:订单号000123如果被 Excel 存成数值,转出来就是123,这不是 XLS2TXT 的锅,是源文件列格式的问题;要么让业务方把列设成文本,要么在转换配置里按列强制字符串读取。批量转换前先抽 20 行看这两类字段,比整批跑完再后悔便宜得多。
4. 批量转换流水线:目录扫描、命名隔离与失败重试
4.1 先规划输出目录:相对路径、同名文件和覆盖策略
单文件转换用 GUI 点两下就行,几百个文件再手点就是体力活。XLS2TXT 如果提供命令行入口,可以用 shell 循环;如果只提供 Java 调用入口,就写一个小批量调度器。无论哪种,第一步都是把输入目录和输出目录分开,输出路径按输入文件的相对路径映射,避免所有 CSV 平铺在一个目录里互相覆盖。下面这段代码只做文件发现和输出路径计算:
import java.nio.file.*; import java.util.*; import java.util.stream.*; public class BatchPlanner { public static List<Path> findXls(Path root) throws IOException { try (Stream<Path> stream = Files.walk(root)) { return stream.filter(Files::isRegularFile) .filter(p -> { String name = p.getFileName().toString().toLowerCase(Locale.ROOT); return name.endsWith(".xls") || name.endsWith(".xlsx"); }) .sorted() .collect(Collectors.toList()); } } public static Path toOutput(Path root, Path outputRoot, Path xls, String ext) { Path relative = root.relativize(xls); String name = relative.toString().replaceAll("(?i)\\.(xls|xlsx)$", "." + ext); return outputRoot.resolve(name); } }逻辑说明:Files.walk(root)递归扫描,try-with-resources保证流关闭;过滤条件同时接受.xls和.xlsx,大小写用Locale.ROOT避免土耳其语环境下的I问题;sorted()让输出顺序稳定,方便两次转换做校验和比对。toOutput用root.relativize(xls)保留子目录结构,replaceAll把扩展名换成csv或txt。参数上,root是输入根目录,outputRoot是输出根目录,ext不带点,传csv或txt。
这里有个坑:同一目录下如果同时有a.xls和a.xlsx,按上面的规则都会映射到a.csv,后跑的覆盖先跑的。解决方式有三种:输出名加源扩展名(a.xls.csv)、加短哈希、或者按输入子目录再分一层。我一般选第一种,名字丑一点,但排查时一眼能看出源文件是谁。覆盖策略也要显式化:批量任务默认REPLACE_EXISTING方便重跑,但第一次跑之前先确认输出目录是空的,别把上一次的结果和这一次混在一起。
4.2 失败重试与日志:让坏文件停在原地,不拖垮整批
批量转换最怕一个坏文件抛异常,整个循环中断,前面跑完的不知道跑到哪,后面没跑的也没人知道。正确做法是每个文件独立 try-catch,失败记录到日志,必要时重试一次。重试只对临时性错误有意义,比如文件被占用、磁盘瞬时满、网络盘抖动;文件头损坏、密码保护、格式不支持这类错误,重试一百次也没用,日志里要能区分。
import java.nio.file.*; import java.util.concurrent.TimeUnit; public class RetryRunner { public static boolean convertWithRetry(Path input, Path output, int maxAttempts) { for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { convertOnce(input, output); // 替换成 XLS2TXT 的转换入口 return true; } catch (Exception e) { System.err.println("FAIL input=" + input + " attempt=" + attempt + " msg=" + e.getMessage()); if (attempt < maxAttempts) { try { TimeUnit.MILLISECONDS.sleep(300L * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } } } } return false; } private static void convertOnce(Path input, Path output) throws Exception { // 这里调用 XLS2TXT 的单文件转换方法;若是命令行包,可用 ProcessBuilder 调 java -jar throw new UnsupportedOperationException("替换为实际转换入口"); } }逻辑说明:maxAttempts是最大尝试次数,建议设为 2 或 3,不要无限重试;300L * attempt是线性退避,第一次失败等 300ms,第二次等 600ms,给文件锁和网络盘一点恢复时间;InterruptedException要恢复中断位,不要吞掉。convertOnce是占位方法,实际使用时替换成 XLS2TXT 的 service 方法,或者用ProcessBuilder调它的命令行入口。日志至少记四个字段:输入路径、输出路径、尝试次数、异常摘要;批量跑完再按输入路径排序,和源目录做差集,就知道哪些文件没转成功。
如果 XLS2TXT 只提供 GUI,没有命令行,也可以用 Java 的ProcessBuilder调java -jar,但参数要提前在单文件上验证,别在循环里猜。批量任务最好串行跑,不要一上来就开 8 个线程:POI 读 .xls 是内存密集型,并发太高容易 OOM,而且日志交错后很难定位。我的习惯是先用 5 个文件试跑,确认输出目录结构、编码、分隔符都正确,再放开整批;每批控制在 50 到 100 个文件,跑完做一次行数抽查。
5. 转换翻车现场:5 个 XLS2TXT 高频问题排查
5.1 输出全是乱码:编码没对齐,还是 BOM 在捣乱
现象:XLS2TXT 转出来的 CSV 用 Excel 打开正常,用 Linux 的less或数据库导入工具打开全是问号;或者反过来,Linux 下正常,Windows Excel 打开乱码。原因通常是输出编码和接收端编码不一致:工具按 UTF-8 写,Excel 按 GBK 解;或者工具按平台默认 GBK 写,下游按 UTF-8 解。解决:先确认 XLS2TXT 的输出编码参数,显式设成 UTF-8 或 GBK,不要留空;再用file -i out.csv或十六进制看文件头,UTF-8 BOM 是EF BB BF,有 BOM 而下游不认就去掉。给 Excel 双击的 CSV 用 GBK 最省事,给程序导入的 CSV 用 UTF-8 且不加 BOM,两边都别靠猜。
5.2 日期变成 45000:单元格格式没被读取
现象:源表里是2024-01-01,转出来变成45292或45292.0。原因:Excel 的日期在文件里存的是序列号,显示成日期靠的是单元格格式;XLS2TXT 如果只读数值、不读格式,就会把序列号直接落成文本。解决:转换前用DateUtil.isCellDateFormatted(cell)判断,配合DataFormatter.formatCellValue按格式输出;如果工具本身不处理日期,就在后处理脚本里按列做序列号转日期,注意 Excel 的 1900 闰年 bug 和 1904 日期系统。更稳的做法是让业务方在源文件里把日期列统一成文本yyyy-MM-dd,再跑转换,别把格式问题留到下游。
5.3 长数字变科学计数法:double 精度和显示格式的锅
现象:身份证号、订单号、银行卡号转出来变成1.23457E+17,或者后三位变成000。原因:Excel 数值型单元格底层是 double,超过 15 位有效数字就会丢精度;XLS2TXT 如果按数值读取再转字符串,科学计数法和精度丢失都会出现。解决:转换前检查源列格式,能改文本就改文本;不能改的话,用DataFormatter按显示格式读,或者按列配置“强制字符串”模式。批量转换后抽一批长数字做长度校验,18 位变 15 位、末位变 0 的,直接打回源文件,不要在输出端做补救。
5.4 公式单元格为空或结果旧:缓存值没刷新
现象:源表里公式列明明有值,转出来是空字符串,或者还是上一次计算的旧值。原因:POI 默认不重新计算公式,只读文件里缓存的公式结果;如果文件由程序生成、缓存值没写,或者公式引用了外部工作簿,缓存值就可能为空或过期。解决:用FormulaEvaluator在读取时计算公式,或者让业务方在 Excel 里打开、全量重算、另存为值;外部引用和循环引用别硬算,先在源文件里拆掉。XLS2TXT 如果提供“计算公式”开关,先拿一个包含SUM、VLOOKUP、跨 sheet 引用的样本试,确认结果和 Excel 里一致再批量。
5.5 大文件 OOM:HSSF 全量加载与 65536 行上限
现象:几十兆的 .xls 转到一半报OutOfMemoryError,或者超过 65536 行的部分直接消失。原因:HSSF 把整个工作簿加载进内存,.xls 本身又限制单 sheet 65536 行、256 列;文件里如果有很多样式、合并单元格、图片,内存占用还会更高。解决:先调 JVM 参数-Xmx2g或更高,确认不是内存太小;再拆文件,按 sheet 或按行段拆成多个 .xls;如果源文件其实是 .xlsx,换 XSSF 或流式读取,别用 HSSF 硬扛。转换日志里记录每个文件的 sheet 数和行数,超过 6 万行的提前预警,别等跑完才发现少了后半截。
6. 交付前最后一公里:抽样比对、行数校验与校验和
批量转换跑完不等于能交付。我一般会做三层校验:第一层看行数和列数,第二层看关键字段的汇总值,第三层抽 20 行做逐列比对。行数用wc -l和源表有效行对比,少一行可能是空行被吞,多一行可能是表头重复;列数看首行分隔符数量,多一列少一列通常是转义或空列处理出了问题。金额列做sum,日期列取min/max,长数字列检查长度分布,这三项能抓住大部分格式性错误。
| 校验项 | 做法 | 失败信号 |
|---|---|---|
| 行数 | wc -l对比源表有效行 | 少一行可能是空行/合并单元格 |
| 列数 | 抽查首行分隔符数量 | 多一列少一列通常是转义 |
| 金额合计 | 对源表与输出做 sum | 差异说明数值格式或精度 |
| 日期范围 | 取 min/max | 变成序列号或空值 |
| 抽样 20 行 | diff 关键列 | 中文、引号、换行 |
| 校验和 | 重复转换后 sha256 | 不稳定说明排序/时间戳 |
# 输出文件行数与校验和,用于两次转换结果比对 wc -l out.csv sha256sum out.csv # 抽样前 21 行,检查中文、逗号和引号 sed -n '1,21p' out.csvwc -l给的是物理行数,如果单元格里有换行,CSV 的物理行数会大于逻辑行数,所以它只能做粗筛,不能当唯一标准;sha256sum适合验证“同一批输入、同一套参数”下输出是否稳定,如果两次转换校验和不同,先查文件排序、时间戳、随机 ID 这些不稳定因素;sed -n '1,21p'是抽样,重点看中文有没有乱码、含逗号的字段有没有被引号包住、引号有没有双写。校验脚本不要写得太复杂,能重复跑、能输出差异文件路径就行。
从那以后我每次批量转换前都强制走一遍“单文件体检、20 行抽样、行数与金额汇总”这三步,哪怕业务方催得再急也不跳过。XLS2TXT 能把重复劳动压下去,但源文件的格式债不会自动消失,早发现比晚返工便宜。希望帮到你。
本文还有配套的精品资源,点击获取