简介面向Lotus Domino平台BS应用开发者内容聚焦使用LotusScript代理与Java代理完成Excel导入导出的常见需求适合在浏览器/服务器架构中处理数据报表、批量迁移或报表生成场景的工程师。这项工作在企业数据整合、批量上报和数据库迁移等日常运维中非常常见。导出以Java代理为主说明如何设置输出内容为Excel可识别的MIME类型借助PrintWriter逐行生成制表符分隔数据并遍历视图文档按指定字段读取且处理多值域导入部分介绍通过Apache POI解析Excel后写回NotesDocument的典型思路也提及LotusScript下的实现方式。包体为单个doc文档仅35KB内容集中精炼可直接用于开发参考。已有140人浏览学习适合正在Domino平台做BS开发、希望快速实现Excel数据交互的读者。1. Lotus Domino BS开发里的Excel导入导出代理是入口MIME是核心在 Lotus Domino 的 BS 应用里导出 Excel 的核心其实是一段以 application/vnd.ms-excel 开头、用 TAB 分隔的文本流浏览器看到 MIME 头就自动变成 Excel。难的是把视图文档、域值、多值域和回收时机处理对。这份资源里我整理了 Java 代理导出、Apache POI 导入以及等价的 LotusScript 代理三套实现。适合正在用 Domino BS 做报表导出、数据迁移的开发者也适合想从 LotusScript 迁到 Java 代理的团队。提前避雷这里说的 Java 代理是 Domino 里的 Java 代理程序不是 JDK 动态代理别被热词带偏。2. Java代理导出ExcelMIME类型、TAB分隔与对象回收的完整拆解2.1 入口与输出流为什么是NotesMain()而不是main()为什么用getAgentOutput()而不是System.outDomino 的 Java 代理不是普通 Java 程序它的入口固定是继承 AgentBase 后的 NotesMain()由 Domino 服务器在当前 HTTP 请求线程里调用。你写不了 public static void main(String[] args)写了也不会被调度。这就是很多第一次接触 Domino 代理的人翻车的地方拿起来先在本地写了个 main 方法测试结果部署到服务器完全没动静。看这段入口代码它完成了代理工作的全部前置条件public void NotesMain() { try { session getSession(); agentContext session.getAgentContext(); db agentContext.getCurrentDatabase(); view db.getView(Documents.vw); pw getAgentOutput(); pw.println(Content-Type:application/vnd.ms-excel); printHead(); printView(); pw.print(sb.toString()); } catch (Exception e) { e.printStackTrace(); } finally { recycle(); } }逻辑说明getSession() 从当前代理线程拿到 NotesSession它代表与 Domino 服务器的连接agentContext 是当前代理的上下文对象里面有当前数据库、当前文档、请求参数等信息db.getView(Documents.vw) 取的并不是视图文件名而是 Designer 里视图的别名建议用别名而不是视图名因为视图改名后别名通常不变。参数说明getAgentOutput() 返回 PrintWriter它是 Domino 为当前 HTTP 响应准备的输出通道。这里最容易踩的坑是用 System.out.println 输出那只会把内容打到服务器控制台和日志里浏览器端什么都收不到。Content-Type 必须写在最前面我用的是原代码的写法你也可以在它后面再 println 一个空行避免部分 Web 服务器把第一行内容当作响应头的一部分处理。2.2 表头与字段取值多值域不能只取第一个值printHead() 本身很简单但它和 FIELDS、FIELDSNAME 两个数组的对应关系是导出正确性的基础。FIELDS 是文档中字段的实际名称FIELDSNAME 是 Excel 里要显示的中文表头两个数组必须一一对应否则导出的列顺序和内容就对不上。private void printHead() { for (String fieldName : FIELDSNAME) { sb.append(fieldName); sb.append(\t); } sb.append(\n); }逻辑说明循环把表头字符串追加到 StringBuilder每列之间用 TAB 分隔最后追加一个换行。这里用 StringBuilder 而不是每行直接 PrintWriter 输出是为了减少 I/O 次数数据量大时差异很明显。printDocInfo() 是导出时最需要小心的部分。原代码里只取 Vector 的第一个值这在单选域和普通文本域没问题但碰上多值域就会丢数据。我一般会改成这样private void printDocInfo(Document doc) throws NotesException { for (String field : FIELDS) { Vector fldValue doc.getItemValue(field); if (fldValue.size() 0) { sb.append(\t); continue; } StringBuilder cell new StringBuilder(); for (int i 0; i fldValue.size(); i) { if (i 0) { cell.append(;); } cell.append(fldValue.get(i).toString()); } sb.append(cell.toString()); sb.append(\t); } sb.append(\n); }逻辑说明getItemValue(field) 返回 Vector即使字段只有一个值它也是长度为 1 的 Vector。size() 为 0 时补一个空白 TAB保证 Excel 列不错位。多值域遍历后用分号拼接这样 Excel 单元格里能看到完整内容而不是只保留第一个值。参数说明这里的 cell.append(;) 中的分号是我自己定的分隔符你可以改成逗号或竖线只要导入时能对应处理。日期字段用 toString() 拿到的通常是 Domino 默认格式文本如果你需要精确到时分秒的标准格式建议在导出前对日期字段单独做格式化。2.3 视图遍历与回收时机先取下一个文档再回收当前文档printView() 的循环写法是 Domino Java 代理的经典模式它处理了对象回收的安全边界private void printView() throws NotesException { Document doc view.getFirstDocument(); Document temp null; while (doc ! null) { printDocInfo(doc); temp view.getNextDocument(doc); doc.recycle(); doc temp; } }逻辑说明view.getFirstDocument() 拿到第一条文档循环里先输出当前文档数据然后用 view.getNextDocument(doc) 拿下一跳文档并赋给 temp接着立即回收当前 doc再把 temp 赋给 doc。这个顺序不能反如果先 recycle 再 getNextDocument这个文档对象的内存句柄已经释放再访问会抛 NotesException。参数说明recycle() 是必须的Domino 的 Notes 对象在 Java 里是对底层 C 接口的封装不回收会导致句柄泄漏。长时间运行的大批量导出句柄耗尽后代理表现为突然变慢、报Notes/Domino object handle limit exceeded或者干脆崩掉。recycle() 方法里要按依赖顺序回收先 pw.close()再 view、db、agentContext最后 session。这个顺序不能打乱session 必须最后一个回收因为它是最外层的连接持有者。这一段来自原工程里一个很完整的 OutputToExcel 类我在 Designer 里建 Java 代理时把代理类型选成 Java 代理不是 LotusScript 也不是 Web ServiceTarget 选当前数据库触发方式按需选“通过 URL 调用”或者“代理选择”。这样浏览器访问一个连接的 URL 就能触发导出。3. Apache POI导入Excel附件解析、单元格类型判断与文档落库3.1 为什么必须用Apache POI来导入而不是自己解析文件导出可以靠 TAB 分隔文本流糊弄过去导入不行。Excel 的 xls 是 OLE2 复合文档格式xlsx 是基于 ZIP 的 OpenXML 格式手动解析这两者工作量非常大而且边缘情况极多。Apache POI 是 Java 处理 Office 文件的通用选择HSSFWorkbook 吃 xlsXSSFWorkbook 吃 xlsxAPI 层面统一用 Workbook、Sheet、Row、Cell 操作。我一般不建议在 Domino 里用 LotusScript 直接解析 xlsxLotusScript 处理二进制和 ZIP 解压非常吃力。BS 场景下常见的做法是前端表单里加一个富文本域用户在页面选择 Excel 文件上传保存后文档附件里就有这个文件。代理在 WebQuerySave 事件或者一个按钮触发的独立代理里读当前文档的附件用 POI 把数据解析出来再逐行创建 NotesDocument。3.2 从当前文档取附件并解析工作簿public class ExcelImport extends AgentBase { public void NotesMain() { Session session null; AgentContext ctx null; Database db null; Document doc null; try { session getSession(); ctx session.getAgentContext(); db ctx.getCurrentDatabase(); doc ctx.getDocumentContext(); RichTextItem rt (RichTextItem) doc.getFirstItem(ExcelFile); if (rt null) { return; } for (int i 0; i rt.getEmbeddedObjectCount(); i) { EmbeddedObject eo rt.getEmbeddedObject(i); if (eo.getType() EmbeddedObject.EMBED_ATTACHMENT) { importWorkbook(db, eo.getName(), eo.getInputStream()); } } } catch (Exception e) { e.printStackTrace(); } finally { // 按依赖顺序回收对象 if (doc ! null) { doc.recycle(); } if (db ! null) { db.recycle(); } if (ctx ! null) { ctx.recycle(); } if (session ! null) { session.recycle(); } } } }逻辑说明ctx.getDocumentContext() 拿到的是触发代理时上下文里的文档。如果是 WebQuerySave 触发就是用户当前提交的那张表单文档如果是按钮点击后调用代理则还是当前打开编辑的文档。getFirstItem(ExcelFile) 取富文本域循环里用 getEmbeddedObject(i) 逐个检查附件。参数说明eo.getInputStream() 是 POI 读取的入口用完之后不要主动关闭因为它内部绑定的是 Domino 的附件流随文档回收即可。埋一个坑如果你的导入代理是定时批量任务拿不到 documentContext此时要从数据库某个视图里查询待处理文档再通过 doc.getAttachment(ExcelFile) 取附件路径逻辑不一样。importWorkbook 方法做真正的解析和落库private void importWorkbook(Database db, String fileName, InputStream in) { Workbook wb null; if (fileName.endsWith(.xlsx) || fileName.endsWith(.xlsm)) { wb new XSSFWorkbook(in); } else if (fileName.endsWith(.xls)) { wb new HSSFWorkbook(in); } else { return; } Sheet sheet wb.getSheetAt(0); for (int r 1; r sheet.getLastRowNum(); r) { Row row sheet.getRow(r); if (row null) { continue; } Document newDoc db.createDocument(); newDoc.replaceItemValue(Form, ImportForm); String bh getCellString(row.getCell(0)); Double total getCellNumeric(row.getCell(5)); newDoc.replaceItemValue(fldBH, bh); if (total ! null) { newDoc.replaceItemValue(Total, total); } java.util.Date date getCellDate(row.getCell(3)); if (date ! null) { newDoc.replaceItemValue(AppDate, session.createDateTime(date)); } newDoc.save(true, false); } wb.close(); }逻辑说明先按扩展名选择 Workbook 实现只支持 xls 和 xlsx 两种文件名命中的情况之外一律不处理。sheet.getLastRowNum() 返回的是最后一行索引如果 Excel 有 100 行数据这个方法返回 99所以 for 循环条件写的是小于等于。循环从 r1 开始默认第一行是表头这个约定要在开发文档里写清楚业务人员给表格时最容易把表头行和标题行混在一起。参数说明newDoc.save(true, false) 第一个参数 true 表示允许生成新文档第二个 false 表示不做冲突处理。正式系统里我建议改成 doc.save(true, true) 并配合冲突检测否则多人同时导入同一批数据时可能出现覆盖问题。session.createDateTime(date) 是把 Java 的 java.util.Date 转换成 Domino 的 DateTime 类型再存入日期域直接用 replaceItemValue 传 java.util.Date 会变成字符串。3.3 单元格类型判断字符串、数字、日期和科学计数法的处理POI 读取单元格时不会自动判断类型你必须先看 cell.getCellType() 再决定取值方式。早期 POI 这里的类型是 int 常量POI 4.x 之后改成了 CellType 枚举老代码里的 Cell.CELL_TYPE_STRING 在新版本里编译直接报错。我按新版写但包了兼容层private String getCellString(Cell cell) { if (cell null) { return ; } if (cell.getCellType() CellType.STRING) { return cell.getStringCellValue(); } if (cell.getCellType() CellType.NUMERIC) { if (DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue().toString(); } double v cell.getNumericCellValue(); if (v Math.floor(v) !Double.isInfinite(v)) { return String.valueOf((long) v); } return String.valueOf(v); } return cell.toString(); } private Double getCellNumeric(Cell cell) { if (cell null) { return null; } if (cell.getCellType() CellType.NUMERIC) { return cell.getNumericCellValue(); } if (cell.getCellType() CellType.STRING) { String s cell.getStringCellValue().trim(); if (s.length() 0) { return Double.parseDouble(s); } } return null; }逻辑说明getCellString 处理了最常见的三种情况。STRING 直接取字符串NUMERIC 里先用 DateUtil.isCellDateFormatted 判断这个数字单元格是不是日期格式如果是应该走 getDateCellValue 而不是取数字否则你会拿到一组秒数纯数字再做整数判断避免 100 变成 100.0。科学计数法的问题也在这里如果 Excel 单元格显示 1000但存储的可能是 1E3直接 toString 会得到 1E3 而不是 1000所以我用 long 转换把整值还原。参数说明Double.parseDouble 在字符串不是合法数字时会抛 NumberFormatException真实业务里这一步要有 try-catch 并记录行号不能因为某一行脏数据导致整个导入代理中断。我一般会在循环里维护一个失败列表最后打印前 50 条错误信息到导入日志这样业务用户知道哪几行需要人工处理。大批量导入的性能问题也必须考虑。一万行数据逐行 createDocument save代理会跑得非常慢。常见做法是每 500 行做一次批量攒批Document 用完后及时 recycle同时用 db.setNoReplication() 在导入期间关闭复制导入完成后再打开。这招在每天定时跑批导数据时候很好用能省掉大量复制族日志的时间。4. LotusScript代理实现同样的导入导出示例代码与Java代理的选型对比4.1 LotusScript导出ExcelPrint输出与Chr(9)制表符有些老系统表单逻辑全写在 LotusScript 里加一个 Java 代理要重新编译部署这时用 LotusScript 写导出是更快的路子。原理和 Java 代理一样只是输出方式从 PrintWriter 换成了 LotusScript 的 Print 语句。Sub Initialize Dim session As New NotesSession Dim ctx As NotesAgentContext Dim db As NotesDatabase Dim view As NotesView Dim doc As NotesDocument Dim strOut As String Set ctx session.AgentContext Set db ctx.CurrentDatabase Set view db.GetView(Documents.vw) Print Content-Type:application/vnd.ms-excel Print 单号-1 Chr(9) 单号-2 Chr(9) 标题 Chr(9) 创建时间 Chr(9) 作者 Chr(9) 金额 Set doc view.GetFirstDocument While Not doc Is Nothing strOut doc.GetItemValue(fldYM)(0) Chr(9) strOut strOut doc.GetItemValue(fldBH)(0) Chr(9) strOut strOut doc.GetItemValue(Subject)(0) Chr(9) strOut strOut doc.GetItemValue(AppDate)(0) Chr(9) strOut strOut doc.GetItemValue(Creator)(0) Chr(9) strOut strOut doc.GetItemValue(Total)(0) Print strOut Set doc view.GetNextDocument(doc) Wend End Sub逻辑说明Print 语句在代理里默认输出到当前 HTTP 响应第一行同样要先写 Content-Type。LotusScript 的 GetItemValue(field)(0) 直接取数组第一个元素单个文档没问题。注意这里没有做多值域处理如果业务里存在多值域要在循环里遍历数组再拼接。参数说明Print 自带换行你不需要在字符串末尾手动加 Chr(13) Chr(10)。表头那行用 Chr(9) 分隔浏览器识别 TAB 后自动分列。这里有个细节LotusScript 代理的 Print 输出编码不受你控制是服务器 Notes.ini 里的默认字符集中文在 Linux 服务器上可能出现乱码我一般会把导出逻辑放到 Java 代理里LotusScript 只做简单场景。4.2 LotusScript导入ExcelNotesStream逐行读取与文档创建LotusScript 没有 POI 这种现成库直接解析 xlsx 不现实。我实测下来LotusScript 导入要走一条更原始的路线先把附件保存到服务器临时目录用 NotesStream 逐行读取按分隔符拆分再创建 NotesDocument。下面这个示例按制表符分隔的文本文件处理Sub Initialize Dim session As New NotesSession Dim ctx As NotesAgentContext Dim db As NotesDatabase Dim docCur As NotesDocument Dim docNew As NotesDocument Dim attachment As NotesEmbeddedObject Dim stream As NotesStream Dim strLine As String Dim arrFields() As String Dim i As Integer Set ctx session.AgentContext Set db ctx.CurrentDatabase Set docCur ctx.DocumentContext Set attachment docCur.GetAttachment(import.tsv) If attachment Is Nothing Then Exit Sub End If 释放到Domino数据目录再通过NotesStream打开 Call attachment.ExtractFile(tmp_import.tsv) Set stream session.CreateStream If Not stream.Open(tmp_import.tsv) Then Exit Sub End If 跳过第一行表头 strLine stream.ReadLine() strLine stream.ReadLine() Do While strLine arrFields Split(strLine, Chr(9)) Set docNew db.CreateDocument docNew.Form ImportForm docNew.fldBH arrFields(0) docNew.Subject arrFields(2) docNew.Total Cdb(Val(arrFields(5))) Call docNew.Save(True, False) strLine stream.ReadLine() Loop Call stream.Close() End Sub逻辑说明GetAttachment(import.tsv) 从当前文档取名为 import.tsv 的附件取不到就直接退出。ExtractFile 把附件释放到 Domino 数据目录下用的是相对路径这一步不做的话 NotesStream 找不到物理文件。ReadLine 第一行是表头第二次 ReadLine 才进入数据行。参数说明Split 出来的数组下标从 0 开始所以 arrFields(0) 对应第一列arrFields(5) 对应第六列也就是金额那列。Cdb(Val(arrFields(5))) 先把字符串转成数字再转成 Currency 类型避免直接把字符串赋给数值域导致类型不匹配。这套方案的边界很明显只能处理简单文本格式带格式的 xlsx 还是得用 Java 代理。4.3 两种代理的选型对比什么时候用Java什么时候用LotusScript对比项Java代理LotusScript代理启动速度第一次调用要加载 JVM较慢启动快适合短小频繁的代理输出编码控制可通过 PrintWriter 和响应头控制依赖服务器默认字符集Excel 解析能力POI 完整支持 xls/xlsx只适合解析文本格式多值域与类型处理灵活可控性高数组操作繁琐类型转换易错代码维护结构化适合团队协作在 Designer 里编辑调试困难典型场景大型报表导出、POI 导入、定时批处理简单导出、表单处理、快速脚本从血泪经验来看新写功能优先 Java 代理尤其涉及 Excel 导入导出。LotusScript 适合维护老逻辑在不动工程结构的前提下快速补一个导出。别为了追求技术统一强制把成熟的 LotusScript 代理改成 Java改完大概率一周内会被业务发现导出结果变了。还有一点要澄清Domino 的 Java 代理和 Java 动态代理完全是两码事。前者是 Domino 里用 Java 语言编写的代理程序跑在 Domino 服务器上后者是 JDK 的 Proxy 类运行时生成代理对象的机制跟 Domino 没有关系。搜索时这两个词经常混淆你如果是为了做 Domino 导入导出找资料认准“Domino Java 代理”这个方向就行。5. 导入导出排查手记中文乱码、代理超时与多值域的五个坑5.1 中文乱码同一个代理在Windows服务器上正常Linux服务器上WPS打开乱码现象开发环境是 Windows 本地 Domino导出正常。部署到 Linux 服务器后浏览器下载的 Excel 用 WPS 打开表头全是乱码用 Microsoft Excel 打开反而正常。原因Java 代理里 getAgentOutput() 返回的 PrintWriter 编码继承服务器启动时的 JVM 默认字符集。Linux 服务器多为 UTF-8WPS 默认按 GBK 解析没有 BOM 的文本文件而 Microsoft Excel 会做编码嗅探。解决输出时显式指定响应 charset。我习惯在 Content-Type 里直接带编码pw.println(Content-Type:application/vnd.ms-excel;charsetGBK);如果业务环境是 UTF-8 标准要求那就改成输出 UTF-8 BOM让 WPS 强制识别为 UTF-8pw.print(\uFEFF);注意 BOM 只能加在最前面加到表头中间会让第一列出现不可见字符。从那以后我每次做 Domino 导出都会先问一句对方的办公软件是 WPS 还是 Office这个问题的答案直接决定我要不要加 BOM。5.2 大数据量导出超时浏览器转圈十分钟最后报代理请求超时现象视图里两万条记录导出代理跑起来后浏览器一直转圈服务器控制台没有异常栈过一会儿前端收到超时或者“服务器无响应”。原因printView() 里是一个长循环所有数据攒在 StringBuilder 里全部处理完才一次性输出。Domino HTTP 任务对单个请求有超时阈值超过后宁可杀掉请求也不等代理慢慢跑完。解决分批输出并强制 flush在循环里按批向 PrintWriter 写数据int batchSize 500; Document doc view.getFirstDocument(); int count 0; while (doc ! null) { printDocInfo(doc); count; if (count % batchSize 0) { pw.print(sb.toString()); pw.flush(); sb.setLength(0); } Document temp view.getNextDocument(doc); doc.recycle(); doc temp; }逻辑说明count 每满 500 条把 StringBuilder 里已经拼好的内容推到 PrintWriter并立即清空缓冲区。这样浏览器能持续收到数据同时也降低服务器内存压力。另外可以把视图里已删除但未归档的文档排除通过 db.getView 时加选择条件或者在视图定义里过滤。5.3 多值域导出后只剩第一个值用户以为是漏数据其实是代码只取了下标0现象某个字段在 Domino 表单里是多值域比如“参与人员”存了三个名字导出到 Excel 后单元格里只有第一个名字。原因原代码 printDocInfo 里写的是 fldvalue.get(0).toString()多值域的 Vector 有多个元素但只取了第一个。解决遍历 Vector 全部值用分号拼接后写入单元格。修复代码在第 2 章已经给过。这类问题难发现是因为日志不报错没有异常也没有警告只有业务人员核对数据时才看到不对。我现在的习惯是遇到多值域字段在导出前先看一遍文档的这个域值数量再决定输出策略。5.4 导入xlsx报“NotOLE2FileException”或“头损坏”扩展名和实际格式不一致现象用户上传了一个文件名后缀是 .xls 的文件POI 解析时抛 org.apache.poi.poifs.filesystem.NotOLE2FileException或者暴力报“Invalid header signature”。原因很多业务系统导出的“Excel”其实是用 CSV 或其他文本格式保存但改了扩展名文件内容根本不是 OLE2 复合文档。按扩展名选 HSSFWorkbook 时它去读 POIFS 文件系统头读不到自然抛异常。解决不要只相信扩展名先探测文件头。POI 自带 FileMagic 工具按前几个字节识别真正格式InputStream bufferedIn new BufferedInputStream(in); byte[] header new byte[8]; int read bufferedIn.read(header); if (read 8) { return; } if (FileMagic.valueOf(header) FileMagic.OLE2) { wb new HSSFWorkbook(bufferedIn); } else if (FileMagic.valueOf(header) FileMagic.OOXML) { wb new XSSFWorkbook(bufferedIn); } else { return; }参数说明importWorkbook 方法现在只按文件名后缀分流遇到伪装文件就会翻车。改成先读前 8 字节再做判断能同时兜住扩展名写错和伪装格式两类问题。如果 FileMagic 都不认识说明这个文件不是 Excel直接记录日志返回不要在 POI 的异常里挣扎。5.5 LotusScript读附件报“文件无法打开”attachment.FilePath不是服务器物理路径现象LotusScript 代理里写 stream.Open(attachment.FilePath)运行后返回 False或者抛“文件不存在”。原因NotesEmbeddedObject 的 FilePath 属性返回的是文档内的逻辑路径不是服务器文件系统里的绝对路径。NotesStream.Open 要的是服务器本地文件路径两者对不上怎么 Open 都是失败的。解决先用 ExtractFile 把附件释放到 Domino 数据目录下的临时文件名再让 NotesStream 去 Open 那个临时文件。第 4 章示例代码里已经写明了顺序Call attachment.ExtractFile(tmp_import.tsv) Set stream session.CreateStream If Not stream.Open(tmp_import.tsv) Then Exit Sub End If注意 ExtractFile 写出的临时文件在服务器数据目录下跑完导入后记得清理否则每次导入都留一个文件时间长了会堆积垃圾。我习惯在代理末尾里加一条 Kill tmp_import.tsv用后即删。6. 动态列与真xlsx输出把TAB分隔导出升级成POI文件6.1 把列定义做成配置改字段不再重新编译代理导出代理里 FIELDS 和 FIELDSNAME 是硬编码数组表单增加一个字段就要改代码重新部署。我后期做的导出代理会把列定义挪到数据库里存放一个配置文档每行记录“显示名|字段名|数据类型”代理启动时读取并转换成 List 这样业务加字段时只需要让管理员在配置文档里改一行不用碰代码。ColumnDef 很简单class ColumnDef { String header; String fieldName; String dataType; // string, number, date }加载配置后printDocInfo 里根据 dataType 选择取值和格式化分支date 类型走 SimpleDateFormatnumber 类型做精度处理。这套逻辑的优点在于把经常变的部分从代码里分离出来缺点是配置错误时问题定位要查数据而不是看代码。6.2 真正输出xlsx列宽、多Sheet和下载链接TAB 分隔的导出虽然快但它不是真正的 Excel 文件无法设置列宽、无法分 Sheet、无法做数据格式。如果前端要求这些能力就得改用 POI 生成 xlsx。做法是代理里创建 XSSFWorkbook写完后通过 FileOutputStream 保存到服务器临时目录String tmpPath System.getProperty(java.io.tmpdir) File.separator export_ System.currentTimeMillis() .xlsx; try (FileOutputStream fos new FileOutputStream(tmpPath)) { workbook.write(fos); }逻辑说明Domino Java 代理直接输出二进制流有限制getAgentOutput() 的 PrintWriter 按字符流处理写二进制文件很容易被编码转换破坏。所以先落盘到临时文件再给浏览器返回一个下载地址或者把文件作为附件挂到当前文档里让用户另存。这个方案牺牲了一点响应速度换来了 xlsx 的完整能力。设置列宽的姿势值得记一下sheet.setColumnWidth(index, width) 的单位是 1/256 像素比较反人类宽度 20 要写成 20 * 256也就是 5120。每次写这个都容易算错建议封装成一个方法private void setColumnWidths(Sheet sheet, int[] widths) { for (int i 0; i widths.length; i) { sheet.setColumnWidth(i, widths[i] * 256); } }从那以后我每次给 Domino 做 Excel 导入导出都先确认三件事输出编码能不能被对方办公软件识别数据量会不会把代理拖到超时Notes 对象有没有按顺序回收。这三条都过基本就不会在线上返工。希望帮到你。本文还有配套的精品资源点击获取