Apache Fesod 工作簿密码保护实战:读写加密 Excel 的完整指南
Apache Fesod 工作簿密码保护实战:读写加密 Excel 的完整指南
本文基于 Apache Fesod(Incubating)的官方文档 website/docs/sheet/advanced/password.md,讲解如何通过 FesodSheet 的 builder 链为 .xlsx / .xls 文件配置读写密码,并结合仓库源码剖析 .xlsx(AES 加密)与 .xls(BIFF8 写保护/加密)两条底层实现路径的差异。读完本文,你将能写出"一行配置即可加密"的读写代码,理解加密过程中内存占用偏高的原因,并掌握密码错误的异常处理与生产环境安全实践。
概述:Fesod 对 Excel 密码保护的支持方式
Fesod 完整支持 Excel 内置的密码保护能力,读写两个方向都覆盖:
- 写入方向:通过
FesodSheet.write(...)返回的ExcelWriterBuilder调用.password("xxx"),写出加密文件; - 读取方向:通过
FesodSheet.read(...)返回的ExcelReaderBuilder调用.password("xxx"),解密并读取加密文件。
API 层面两者对称且极简,但底层实现针对不同文件格式有本质区别,这是使用前必须建立的认知:
- 对
.xlsx(OOXML)输出,Fesod 基于 Apache POI 的标准加密方案(EncryptionMode.standard,AES 系列算法)对整份文件做内容级加密。由于该过程会把整个工作簿读入内存再重写,大工作簿加密时内存占用会显著升高(详见 ExcelWriterBuilder.java 中password(...)的 Javadoc 警告:"Encryption is when the entire file is read into memory, so it is very memory intensive")。 - 对传统
.xls(BIFF8)文件,密码保护走的是 POI 的Biff8EncryptionKey机制,其行为更接近"写保护/BIFF8 记录级加密",与.xlsx的整文件内容加密在行为与安全保证上并不等价。仓库中的 EncryptDataTest.java 专门验证了:即使.xls写入密码后,未提供正确密码时读取会抛EncryptedDocumentException,说明其内容同样受到 BIFF8 记录级加密保护,而非仅打上"只读"标记。
写入带密码的工作簿
完整代码示例
@Test
public void passwordWrite() {
String fileName = "passwordWrite" + System.currentTimeMillis() + ".xlsx";
FesodSheet.write(fileName)
.password("your_password")
.head(DemoData.class)
.sheet("PasswordSheet")
.doWrite(data());
}
要点说明:
.password("your_password")在ExcelWriterBuilder上设置WriteWorkbook的密码字段,见 ExcelWriterBuilder.java,它调用writeWorkbook.setPassword(password)后返回当前 builder,因此可以继续链式调用.head(...)、.sheet(...);.head(DemoData.class)声明表头模型,.sheet("PasswordSheet")指定工作表名称,.doWrite(data())执行写出;- 文件扩展名
.xlsx决定走 AES 加密路径;若把文件名改为.xls,则走 BIFF8 路径(见下文格式差异)。
源码层面的加密时机与实现
写出流程最终由 WriteContextImpl.java 统一收口,加密发生在工作簿内容写完之后的收尾阶段,分两种通道:
1. XLSX 文件通道(doFileEncrypt07,第 699-714 行)
该方法先做两个前置判断:密码为空或文件类型不是 XLSX 时直接返回;文件对象为空也直接返回。随后调用 openFileSystemAndEncrypt(...)(第 727-736 行)完成加密:
POIFSFileSystem fileSystem = new POIFSFileSystem();
Encryptor encryptor = new EncryptionInfo(EncryptionMode.standard).getEncryptor();
encryptor.confirmPassword(writeWorkbookHolder.getPassword());
try (OPCPackage opcPackage = OPCPackage.open(file, PackageAccess.READ_WRITE);
OutputStream outputStream = encryptor.getDataStream(fileSystem)) {
opcPackage.save(outputStream);
}
return fileSystem;
这段代码揭示了 .xlsx 加密的核心原理:先以 EncryptionMode.standard(AES)创建 Encryptor 并确认密码,再以读写模式打开已写好的 OOXML 包,把包内容整体写入加密数据流。注意 OPCPackage.open(...) 与 opcPackage.save(...) 意味着整个工作簿内容都要驻留内存——这就是官方 Javadoc 明确警告"加密非常吃内存"的根源。
2. XLSX 输出流通道(doOutputStreamEncrypt07,第 663-694 行)
当目标是 OutputStream 而非文件时,加密同样先落地一个临时 .xlsx 文件(FileUtils.createTmpFile(...)),再复用同一个 openFileSystemAndEncrypt 流程加密,最后 fileSystem.writeFilesystem(outputStream) 写入目标流。因此无论文件通道还是流通道,xlsx 加密的内存成本是一致的。
3. XLS 通道(clearEncrypt03,第 649-655 行)
对 .xls 类型,Fesod 走 POI 的 Biff8EncryptionKey 线程本地密钥机制,写出后会把 Biff8EncryptionKey.setCurrentUserPassword(null) 清空,防止密钥残留污染后续写出。配套的 WorkBookUtilTest.java 验证了:createWorkBook() 之后 BIFF8 加密密码必须继续保持设置,直到文件最终写出完毕,这确保了 HSSFWorkbook.write() 阶段能拿到正确的密钥。
读取带密码的工作簿
完整代码示例
@Test
public void passwordRead() {
String fileName = "path/to/encrypted.xlsx";
FesodSheet.read(fileName, DemoData.class, new DemoDataListener())
.password("your_password")
.sheet()
.doRead();
}
要点说明:
.password("your_password")在ExcelReaderBuilder上设置ReadWorkbook的密码字段,见 ExcelReaderBuilder.java,它调用readWorkbook.setPassword(password);DemoData.class是行数据模型,DemoDataListener实现ReadListener逐行回调处理数据;.sheet()进入 sheet 配置阶段,.doRead()触发流式(SAX)解析。
读取端的格式嗅探与解密分流
读取加密文件时,Fesod 的入口分析器 ExcelAnalyserImpl.java 在 chooseExcelExecutor 中会先做一次"嗅探":
- 先用
POIFSFileSystem打开文件,判断其根目录是否含有Decryptor.DEFAULT_POIFS_ENTRY(这是加密 OOXML 包的标准入口标记); - 若命中,说明"外表像
.xls、内里其实是加密的.xlsx",此时调用DocumentFactoryHelper.getDecryptedStream(poifsFileSystem.getRoot().getFileSystem(), readWorkbook.getPassword())解密出明文流,并改走XlsxSaxAnalyser按 XLSX 处理(第 137-153 行); - 若未命中,才真正按
.xls处理,交给XlsSaxAnalyser。
XlsxSaxAnalyser 的构造函数(XlsxSaxAnalyser.java)接收这个 decryptedStream,通过 readOpcPackage 打开 OPCPackage 后继续常规的 SAX 流式解析。
而 .xls 的读取则由 XlsSaxAnalyser.java 处理:setCurrentUserPassword() 从 readWorkbookHolder 取出密码并调用 Biff8EncryptionKey.setCurrentUserPassword(password) 注入 POI 的 HSSF 解密栈,解析结束后在 finally 中通过 clearCurrentUserPassword() 将密钥清空,避免线程残留。值得注意的是,加密 XLSX 走 XlsxSaxAnalyser 时不会注入 Biff8EncryptionKey,两者机制互相独立。
密码错误的典型表现
根据官方文档与 EncryptDataTest.java 的断言,不提供或提供错误密码时,读取会抛出 org.apache.poi.EncryptedDocumentException:
Assertions.assertThrows(EncryptedDocumentException.class, () ->
FesodSheet.read(file, SimpleData.class, listener)
.excelType(ExcelTypeEnum.XLS)
.sheet()
.doReadSync());
该测试同时证明了 .xls 场景下"无密码必失败、正确密码必成功"的完整闭环(第 116-140 行),是验证加密行为最直接的仓库证据。
加密读写的全组合回归测试
仓库在 EncryptDataTest.java 中用参数化测试覆盖了加密读写的全部组合:
| 维度 | 取值 |
|---|---|
| 输入形式 | 文件(File)/ 输出流(Stream) |
| 是否显式指定类型 | 不指定 / 显式指定 excelType |
| 是否带密码 | 无密码 / 带密码(PASSWORD = "123456") |
测试逻辑(readAndWrite)体现了两个实战要点:
- 流式 API 必须显式声明类型:当基于
InputStream/OutputStream读写且未显式指定excelType时,需要调用.excelType(ExcelTypeEnum.XLSX)等手动指定,否则无法从流中可靠嗅探格式(源码注释 "Stream API needs type hint when not explicitly set"); - 读写密码必须一致:
excelWriterBuilder.password(PASSWORD)与readerBuilder.password(PASSWORD)配对使用,写 10 行数据后doReadSync()必须完整读回 10 行且字段非空。
安全注意事项与生产实践
官方文档对密码保护的安全语义与工程实践给出三条明确建议:
- 加密的是文件内容本身:Excel 密码保护会加密文件内容,没有密码的人无法读取文件内部数据,而不是简单的"禁止修改"标记;
- 密码错误必然抛异常:缺少正确密码时读取会抛出
EncryptedDocumentException,因此业务代码应在调用.doRead()的地方捕获该异常,给出"密码错误或文件已加密"的友好提示,避免堆栈直接暴露给终端用户; - 禁止硬编码密码:生产环境中不要把密码写死在代码或配置仓库里,应使用配置文件(如环境变量、
application.yml外部化配置)、密钥管理服务(KMS / Vault)等机制动态注入,同时结合仓库 EncryptDataTest.java 的做法,用固定测试密码仅用于自动化验证。
此外,结合源码还可以补充两条官方文档未展开、但直接影响落地选型的结论:
.xlsx加密的内存代价是刚性的:因为OPCPackage.open(...)+save(...)需要整包驻留内存,加密大文件前应评估 JVM 堆(-Xmx),必要时搭配 large-file.md 中介绍的流式读写策略,把"加密 + 超大文件"这类组合场景单独压测;.xls与.xlsx的安全语义不同:.xlsx是标准的 AES 内容加密,.xls是 BIFF8 记录级加密(POIBiff8EncryptionKey机制)。虽然 EncryptDataTest.java 证明.xls也是"真加密"而非仅写保护,但两者加密强度与行为仍有差异,对安全敏感场景应优先使用.xlsx。
结语
Apache Fesod 的密码保护 API 只有一行 .password("xxx"),但底层为 .xlsx(EncryptionMode.standard AES 加密、整包内存重写)与 .xls(BIFF8 密钥注入)设计了完全不同的两条加密链路,并在读取端通过 Decryptor.DEFAULT_POIFS_ENTRY 嗅探实现"加密 XLSX 伪装 XLS"的透明解密。本文的代码示例可直接复制运行,源码路径(ExcelWriterBuilder.java、ExcelReaderBuilder.java、WriteContextImpl.java、ExcelAnalyserImpl.java、EncryptDataTest.java)可供继续深入阅读。