ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FineReport脚本实战:Fine语言将BLOB数据导出为文件的三种方案与踩坑记录

FineReport脚本实战:Fine语言将BLOB数据导出为文件的三种方案与踩坑记录 最近在做一个报表附件归档项目数据库里存了一批签字扫描件和合同PDF按业务要求每月月底要把BLOB字段里的数据导成二进制文件落到指定归档目录。一开始我想着用Java单独写个小工具跑定时任务后来发现报表服务器上本来就有FineReport它内置的Fine语言就能直接干这件事连额外部署都省了。Fine语言这玩意儿大部分人在报表初始化、数据集参数里拿来算算数、做做校验很少会往文件操作上想。但它的脚本引擎跑在JVM上可以直接new Java对象、调Java标准库所以把二进制数据块写入文件这种操作完全可以用一段脚本在报表里完成。这篇文章就把我的完整做法、三种可抄作业的写入方案以及实测踩过的坑一起梳理出来给后面做同类需求的朋友当个参考。1. 项目里到底什么时候需要把二进制块写进文件1.1 报表项目中二进制数据的三种典型来源先说清楚二进制数据块从哪里来这个问题不搞明白后面所有代码都没法写。第一种也是最常见的来自数据库BLOB字段。Oracle里的BLOB、MySQL里的LONGBLOB、PostgreSQL里的BYTEA这些字段存储的就是二进制大对象。我们项目里存的扫描件、合同附件、签字图片业务上不要求存在文件系统里为了统一管理都进了库。到了归档环节就得把BLOB取出来重新写成文件。第二种来源是接口返回的Base64字符串。很多外部系统对接时不会直接给你文件流而是给一串Base64编码的文本比如企业微信的临时素材下载接口、某些政企系统的附件交换接口都是这种玩法。Base64本质上就是把二进制数据编码成ASCII可见字符所以拿到字符串之后得先解码还原成字节数组再决定要不要落盘。第三种稍微隐蔽点是报表运行过程中自己产生的二进制流。比如你用FineReport的导出功能把报表导出成PDF或者Excel程序内部拿到的是一个临时文件流或者内存流。如果你要在导出之后做后续处理比如按日期归档、批量加水印、转存到另一个目录就得先把这份流里的数据块写进文件。不管来源是哪一种落到Fine语言里最终面对的都是两类东西要么是byte[]字节数组要么是InputStream输入流。后面所有方案基本都围绕这两类数据形态去展开。1.2 Fine语言能操作文件的底层条件很多人以为Fine语言只能算报表公式这是误解。它运行在Java环境中脚本引擎支持直接使用Java类。官方文档里写得很清楚你可以在脚本里用importClass或者全限定名来引用Java标准库的类。也就是说java.io.FileOutputStream、java.nio.file.Files这些玩意儿在Fine语言里跟你在Java代码里用没什么两样。不过要注意一个版本差异。早期版本的FineReport脚本引擎对importClass这类语法支持得不错有些实现里写var fos new Packages.java.io.FileOutputStream(...)才能拿到类。为了少踩坑我建议你在写的时候直接用全限定名比如var fos new java.io.FileOutputStream(D:/data/out.bin);这种写法在绝大多数内置脚本引擎里都兼容也避免了不同版本对导入语法支持不一致的问题。另外要说清楚Fine语言的操作权限边界取决于脚本运行的身份。在报表服务器里跑脚本通常就是应用服务器的启动账号能不能往某个目录写文件取决于操作系统对这个账号的授权。这就是后面要说的权限坑的来源。2. 动手前先搞定两件事数据块提取与目标路径设计2.1 通过数据集或JDBC连接把BLOB字段取出来在FineReport里取数据库BLOB有两条路。一条路是直接用内置数据集。把BLOB字段查出来之后它可能会以字节数组或者java.sql.Blob对象的形式出现在结果集里。这种情况在报表模板的数据预览里能看到但具体怎么在Fine语言里拿到这个值不同版本差异较大我更推荐下面这条路。另一条路是在Fine语言脚本里拿连接、执行SQL自己把BLOB字段读出来。我们项目里写归档脚本用的就是这种。在报表服务器环境里可以通过this.getConnection()拿到数据源连接然后走标准JDBCvar conn this.getConnection(); var stmt conn.createStatement(); var rs stmt.executeQuery(SELECT id, file_name, file_data FROM t_archive WHERE id 1001); if (rs.next()) { var blob rs.getBlob(file_data); // blob.length() 返回的是long类型在Fine语言里可能需要转成int再使用 var byteLen parseInt(blob.length()); var bytes blob.getBytes(1, byteLen); // 下面就可以拿bytes去写文件了 } rs.close(); stmt.close();这里要特别提醒一个JDBC细节Blob.getBytes(long pos, int length)的第一个参数是从1开始不是从0开始写错一位在大多数数据量下可能没感觉但边界数据会出问题。还有如果BLOB字段特别大直接getBytes会把整块数据一次性加载进JVM堆内存后面专门说这个问题。2.2 把Base64字符串还原成字节数组如果你拿到的是Base64字符串处理起来更简单因为不需要碰数据库。Java标准库java.util.Base64可以直接调用var base64Str UEsDBBQABgAIAAAAIQC...; // 假设是从接口拿到的数据 var decoder java.util.Base64.getDecoder(); var bytes decoder.decode(base64Str);解码之后拿到的字节数组跟原始文件内容一字不差直接走写入逻辑就行。这个方案里我碰到过两个坑一个是Base64字符串里带了换行符getDecoder()默认不认换行会直接抛异常解决办法是用Base64.getMimeDecoder()它是按MIME规则处理的允许换行出现在字符串里。另一个是字符串尾部多了一个填充符有些接口返回的时候没做处理解码也会出问题建议解码前先trim()一下。2.3 输出路径与文件名的落地规则写文件之前先想清楚路径问题不然写出来的文件不知道去哪了或者干脆没权限写不了。第一个规则是目录不存在要先建。Java的FileOutputStream不会自动帮你建目录目录不存在直接抛FileNotFoundException。所以脚本里要先判断、先创建var dir new java.io.File(D:/archive/20240630); if (!dir.exists()) { dir.mkdirs(); }第二个规则是路径分隔符尽量用File.separator。写死在Windows上的D:\路径一上Linux全废。要么用java.io.File来组装路径要么统一用反斜杠/正斜杠的兼容写法。我们项目服务器是Linux我统一用的正斜杠Java在Linux上对正斜杠支持没问题。第三个规则是文件名里别带特殊字符。BLOB导出的文件名通常来自业务表里的file_name字段这个字段可能带空格、中文冒号、问号之类的东西。在Windows上这些字符会直接报错在Linux上虽然允许但后续下载、FTP传输时容易出幺蛾子。我建议写文件前做一次文件名净化只保留[A-Za-z0-9._-]剩下的替换成下划线。这点做得好后面省很多事。3. 可抄作业的三种写文件方案3.1 方案一FileOutputStream直接写字节数组这是最直接、最适合中小文件的方案。数据量在几十MB以内直接把字节数组拿过来塞给FileOutputStreamvar bytes ...; // 上一节通过getBytes或Base64解码得到的byte[] var fos new java.io.FileOutputStream(D:/archive/20240630/合同_1001.pdf); try { fos.write(bytes); fos.flush(); } finally { fos.close(); }这段代码写出来没什么可解释的write(byte[])就是核心。但我建议你养成两个习惯第一flush()一下确保缓冲区内容真正落盘第二用finally保证流一定关闭脚本一旦抛异常又不关流文件句柄泄漏会让后续所有操作都变得古怪。为什么说适合中小文件因为bytes数组本身就在内存里。一个100MB的BLOB取出字节数组后JVM堆内存至少要额外挂100MB报表服务器本身还要跑报表引擎、缓存数据内存很容易吃不消。你要是只归档几个文件这个方案完全够用代码也短。3.2 方案二BufferedOutputStream处理中等大小的数据块有些时候你手里已经是一个大的byte[]但你又不想让write被调用的次数太多那就可以用BufferedOutputStream包一层。这个类的意义在于内部维护了一个缓冲区减少底层系统调用的次数通俗点说就是攒一批再写一次。var bytes ...; var bos new java.io.BufferedOutputStream( new java.io.FileOutputStream(D:/archive/20240630/附件.dat), 65536 // 缓冲区设成64KB默认是8KB ); try { bos.write(bytes); bos.flush(); } finally { bos.close(); }这里的缓冲区大小不是随便拍的。64KB是性价比比较好的一个值写大文件时既不会因为缓冲区太小导致调用频繁也不会因为太大浪费内存。当然如果你手里的字节数组本身只有几百KB那用不用缓冲区别不大它真正的价值是用在循环写入多批次数据的场景。3.3 方案三InputStream流式复制专治大文件内存压力如果说前两种方案都有一个绕不开的问题——字节数组得整个存在内存里那流式复制就是大文件的解法。它的核心思路是不管数据有多大只开一个小缓冲区从输入流读一小块、往输出流写一小块滚动前进全程内存占用就是缓冲区那点大小。在直接从BLOB读文件的场景里这个方案尤其好用不需要把BLOB整体转成byte[]直接从Blob.getBinaryStream()取流然后再写var conn this.getConnection(); var stmt conn.createStatement(); var rs stmt.executeQuery(SELECT file_data FROM t_archive WHERE id 2001); if (rs.next()) { var blob rs.getBlob(file_data); var input blob.getBinaryStream(); var output new java.io.FileOutputStream(D:/archive/20240630/大文件.bin); // 手工创建一个8KB的字节数组作为缓冲区 var buffer java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8192); var readLen input.read(buffer); while (readLen ! -1) { output.write(buffer, 0, readLen); readLen input.read(buffer); } output.flush(); output.close(); input.close(); } rs.close(); stmt.close();这段代码里面有两点需要展开说。第一为什么缓冲区是8KB而不是64KB因为这里的瓶颈不在写文件而在读数据库流。数据库驱动从网络或磁盘拉数据速度远低于本地写文件8KB已经能让读取过程足够平滑。你调大缓冲区并不会线性变快反而白白占内存。也有些人习惯用byte[8192]这是Java老传统内存开销小性能又不差我用习惯了一直没改。第二为什么只用一个缓冲区而不是两个如果你追求极致性能可以双缓冲交替读写让数据库读取和文件写入并行。但在Fine语言脚本里做这种优化意义不大脚本本身要等整个流程跑完才结束报表任务省那几百毫秒没价值代码复杂度却上去了。3.4 完整案例定时任务导出全套归档文件把我上面说的内容串起来就是一个实际可用的归档脚本。这个脚本我放在FineReport的定时任务里每月月末自动跑把指定批次的所有BLOB字段导出成文件。为了避免粘贴太多业务代码我把结构简化如下var conn this.getConnection(); var stmt conn.createStatement(); var rs stmt.executeQuery(SELECT id, file_name, file_data FROM t_archive WHERE archive_date 20240630); var dir new java.io.File(/opt/archive/20240630); if (!dir.exists()) { dir.mkdirs(); } while (rs.next()) { var id rs.getInt(id); var fileName rs.getString(file_name); // 滤掉文件名里的特殊字符避免落盘路径异常 fileName fileName.replaceAll([\\\\/:*?\|], _); var blob rs.getBlob(file_data); var input blob.getBinaryStream(); // 单个文件单独命名用记录主键保证不重名 var output new java.io.FileOutputStream( new java.io.File(dir, id _ fileName) ); var buffer java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8192); var readLen input.read(buffer); while (readLen ! -1) { output.write(buffer, 0, readLen); readLen input.read(buffer); } output.flush(); output.close(); input.close(); } rs.close(); stmt.close();这套逻辑跑下来目录里出现的就是一堆规规矩矩的文件文件名带ID前缀不会重名也不会带非法字符。整个过程中内存占用非常平稳哪怕单个BLOB上GB也不会OOM。4. 实测踩坑记录从没反应到文件全对的排查链路4.1 文件写出来了但大小是0这个坑是我第一次跑脚本时遇到的当时检查输出目录文件是建出来了大小却是0字节没有任何报错。排查了半天最后定位到问题出在数据库驱动的getBlob返回了一个空对象SQL里查出来的记录确实存在但BLOB字段是NULL。要注意的是JDBC里从NULL字段取getBlob返回的是null而不是长度为0的Blob。你在脚本里直接调blob.getBinaryStream()就会抛NullPointerException但因为FineReport的脚本引擎对未捕获异常的处理方式比较温柔有时候只在后台日志里打了条记录前台报表该生成还是生成你就误以为没反应。解决很简单取流之前先判空var blob rs.getBlob(file_data); if (blob ! null blob.length() 0) { // 再执行读取逻辑 }另外还有个隐蔽情况记录存在、Blob不空但里面确实是0字节的空内容。这种查一下blob.length()是不是等于0就行等于0就直接跳过别强行建文件。4.2 中文路径在Linux服务器上报错我们在开发环境Windows上跑得好好的脚本部署到Linux服务器就报FileNotFoundException一开始以为是没权限后来ls -la看了目录权限一切正常最后才发现是中文文件名编码的问题。Tomcat默认的URIEncoding和文件系统编码不一致的时候脚本字符串里的中文路径在底层转成字节时可能变成乱码导致文件系统根本找不到对应路径。这个问题在不同版本的FineReport上表现也不一样。我的处理方式是双管齐下第一文件名里的中文按业务要求必须保留的就在部署脚本时统一配置-Dfile.encodingUTF-8让整个JVM的文件编码一致第二代码里做文件名净化时把中文替换成拼音缩写或者直接用ID当文件名最大程度避开编码争议。归档场景本来就更看重文件能不能被程序化处理而不是文件名漂不漂亮。4.3 大文件写入时JVM内存溢出前面方案三里我一直强调用流式复制就是因为我在这上面翻过车。第一次接一个670MB的扫描件我图省事直接getBytes(1, blob.length())结果OutOfMemoryError差点把报表服务器干趴下。事后分析那次内存溢出不只是BLOB数据本身占了600多MB还有FineReport引擎处理报表时的其他内存占用加在一起直接顶到了JVM堆上限。给还在用getBytes方案的朋友一个判断标准文件大小预期超过100MB就别犹豫直接用getBinaryStream流式方案。如果项目里可能频繁导出大文件我还会建议在JVM启动参数里单独给脚本执行留出-Xmx配额或者直接在专门的定时任务服务器上跑别跟日常报表共用同一个实例。这个经验说多了都是泪一台服务器上既出日报又跑大文件归档高峰期两个一起卡死。4.4 数据流没关导致文件被占用这个问题排查起来比前几个都难受因为它不报错、不崩溃唯一的表现是脚本第一次跑成功第二次跑的时候输出目录里文件还在但文件内容没有更新或者生成了一堆临时文件。原因就是循环里的input.close()和output.close()没有被执行。可能是指定条件提前break了也可能是脚本引擎抛异常后跳过了关闭逻辑。文件被上一个没关闭的流占着新的写入就会失败而Java在Windows上对这个表现得最明显——文件被锁定谁写都报另一个程序正在使用此文件。我的习惯是不管逻辑怎么跳都用try-catch-finally把流关闭这件事兜底var input null; var output null; try { input blob.getBinaryStream(); output new java.io.FileOutputStream(path); // 循环读写 } catch (e) { // 打印异常日志 } finally { if (input ! null) input.close(); if (output ! null) output.close(); }如果Fine语言的版本支持try-with-resources那就更省事了不过我在项目中为了兼容旧版一直用的手动关闭。4.5 脚本到底放哪个事件里执行最合适最后分享一个结构性的问题这段写文件脚本在FineReport里放哪儿我在刚开始做的时候一股脑全塞到报表初始化后事件里结果每次打开报表就写一次文件时间一长目录里全是重复文件。后来我把归档脚本挪到了定时任务里利用FineReport的调度功能让脚本在指定的时间点执行一次。再配合数据集参数或者程序数据源把需要归档的数据范围动态传进去。这么做的好处是文件写入逻辑和数据查询逻辑彻底分离日常不触发月底到点自动跑。如果确实需要在报表打开时写文件我建议加一个幂等控制检查目标文件是否已经存在存在就不重复写。简单的一行判断能避免一堆副本产生var target new java.io.File(/opt/archive/20240630/合同_1001.pdf); if (!target.exists()) { // 执行写入 }这种做法在处理重复触发场景时非常管用我现在所有写文件的地方都默认带上。从数据提取、目标路径规划到三种写入方案、完整案例再到踩过的这些坑一条链路走下来Fine语言处理二进制数据块的核心思路其实就一句话你把它当成能用Java类库的脚本来用不要只当成算报表公式的工具。流式处理解决大文件、判空解决异常数据、finally关流解决资源泄漏这三个习惯培养好同类需求基本不会再出大问题。
返回列表