十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Fine语言自定义类实战:解决数据清洗、财年计算与报表性能优化

Fine语言自定义类实战:解决数据清洗、财年计算与报表性能优化 我们接着上一篇继续聊Fine语言自定义类。上一篇里我们把自定义类的基本写法、加载方式、以及在FineReport里怎么调用讲透了这一篇直接上生产案例专门解决那些“用Fine语言硬写也能写、但写出来又丑又慢又难维护”的问题。我自己在项目里踩过的几个典型场景今天全部分享出来代码可以直接抄逻辑可以直接套。1. 自定义类到底解决的是Fine语言的什么痛点先说个判断标准什么时候该用自定义类而不是闷头写Fine语言脚本我自己的经验是满足下面任意一条就应该考虑上自定义类同一段逻辑在多个报表里重复出现比如日期区间格式化、编码转名称、金额单位转换。Fine语言脚本循环次数大、性能肉眼可见地慢比如对几千行数据做逐行字符串拼接。逻辑本身很复杂用Fine语言嵌套三层if else之后基本没人能维护。需要调用Java生态里现成的库比如正则、JSON解析、加密、日期计算Fine语言本身不直接支持。Fine语言在报表表达式、数据列计算这种“单行、短路径”的场景里确实方便但它本质上是解释执行的脚本语言跑大数据量循环的时候性能衰减很厉害。而且语法上对复杂业务逻辑的支持比较弱写出来的代码可读性差调试也费劲。自定义类则可以把这些又重又复杂的逻辑封装成黑盒Fine语言这边只负责传参数、拿结果。实际开发中我的习惯是能用表达式一行写完的不用自定义类超过三行或者有循环的直接写Java类。这个标准帮我省了很多无谓的折腾。2. 案例一写一个数据清洗与格式化工具类先看一个最常见的需求报表源数据往往很“脏”比如从ERP导出的发货计划单号格式混乱有的大写、有的小写有的带了前后空格有的中间混入了全角字符还有的日期字段是字符串、格式五花八门直接用Fine语言的replace和trim去处理链式调用写出来长到怀疑人生而且每张报表都要复制粘贴一遍。2.1 需求拆解与类设计业务方给的需求通常是这样的单据号统一转为大写去除所有空白字符包括全角空格。如果单据号以“SO”开头截取后8位作为短编码否则原样返回。将字符串类型的日期“2024-1-5”、“2024/01/05”、“2024.01.05”统一转为“2024-01-05”格式。金额字符串如“12,345.67”去掉千分位逗号并保留两位小数。如果把这段逻辑放在Fine语言里写要用到replace、trim、left、right、dateFormat再配合条件判断和类型转换表达式会非常长。而且Fine语言里的字符串处理对正则支持有限全角空格这种字符处理起来尤其恶心。2.2 Java代码实现我建了一个名为CleanDataUtils的类代码如下import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.regex.Pattern; public class CleanDataUtils { private static final Pattern WHITESPACE_PATTERN Pattern.compile([\\s\\u3000]); private static final Pattern DATE_PATTERN_1 Pattern.compile((\\d{4})[-/年.](\\d{1,2})[-/月.](\\d{1,2})日?); private static final Pattern COMMA_PATTERN Pattern.compile(,); /** * 清洗单据号去空白、转大写、可选截取短编码 */ public static String cleanBillNo(String rawBillNo, int shortCodeLength) { if (rawBillNo null) { return ; } String cleaned WHITESPACE_PATTERN.matcher(rawBillNo).replaceAll().toUpperCase(Locale.ROOT); if (shortCodeLength 0 cleaned.startsWith(SO)) { int endIndex Math.min(cleaned.length(), shortCodeLength); return cleaned.substring(cleaned.length() - endIndex); } return cleaned; } /** * 标准化日期字符串为 yyyy-MM-dd */ public static String normalizeDate(String rawDate) { if (rawDate null || rawDate.trim().isEmpty()) { return ; } java.util.regex.Matcher matcher DATE_PATTERN_1.matcher(rawDate.trim()); if (matcher.find()) { int year Integer.parseInt(matcher.group(1)); int month Integer.parseInt(matcher.group(2)); int day Integer.parseInt(matcher.group(3)); return String.format(Locale.ROOT, %04d-%02d-%02d, year, month, day); } return rawDate.trim(); } /** * 金额字符串格式化去逗号保留两位小数返回字符串 */ public static String formatAmount(String rawAmount) { if (rawAmount null || rawAmount.trim().isEmpty()) { return 0.00; } String withoutComma COMMA_PATTERN.matcher(rawAmount.trim()).replaceAll(); try { double value Double.parseDouble(withoutComma); return String.format(Locale.ROOT, %.2f, value); } catch (NumberFormatException e) { return 0.00; } } /** * 重载支持直接传入double类型 */ public static String formatAmount(double rawAmount) { return String.format(Locale.ROOT, %.2f, rawAmount); } /** * 兼容传入Date类型 */ public static String normalizeDate(Date rawDate) { if (rawDate null) { return ; } SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd, Locale.ROOT); return sdf.format(rawDate); } }这段代码里有两个细节值得说明。第一个是\u3000这个是全角空格的Unicode编码。从业务系统导出的数据经常混入全角空格普通trim()根本去不掉必须用正则把它和半角空格、制表符统一清洗掉。一开始我踩过这个坑直接用replaceAll( , )结果全角空格纹丝不动报表里看起来没空格但就是匹配不上。第二个是Locale.ROOT。toUpperCase()如果不带Locale参数在某些语言环境下会出问题比如土耳其语环境下i.toUpperCase()会变成İ而不是I。虽然国内报表环境大概率碰不到这个问题但写上Locale.ROOT是防御性编程的好习惯成本就是多敲几个字母。2.3 在FineReport模板中调用这个类编译后放到FineReport的classes目录下我通常放在FineReport_11.0\WEB-INF\classes或者在FineReport设计器里通过“服务器 报表工具集 类管理器”注册。然后在FineReport的表达式编辑器中写CleanDataUtils.cleanBillNo(B4, 8) CleanDataUtils.normalizeDate(C4) CleanDataUtils.formatAmount(D4)这里有个使用习惯想提醒一下自定义类方法在FineReport里调用时默认要求方法是静态的。如果非要用实例方法表达式里得先new出来比如new CleanDataUtils().formatAmount(...)但这个写法在数据列批量计算时性能会差一些能避免就避免。3. 案例二跨报表复用的季度、财年计算器第二个案例是我在做一个销售分析驾驶舱时遇到的。业务方定了很奇怪的“财年”规则每年从4月1日开始到次年3月31日结束而且月份还要映射成自己定义的“财月”比如4月是财年第一个月。这个规则不是只在一张报表里用而是所有跟销售趋势、同比环比相关的报表都要用。用Fine语言硬写的话一个IF套MONTH函数的长表达式能写几十行而且每张报表都要改一遍月份判断逻辑。如果某天业务方把财年起始月从4月改成7月那就得满项目去搜索替换。3.1 类的封装思路我封装了一个FiscalPeriodUtils类专门做财年、财季、财月计算import java.util.Calendar; import java.util.Date; import java.util.Locale; public class FiscalPeriodUtils { private static final int FISCAL_START_MONTH 4; /** * 根据日期返回财年例如 2024-04-05 - 2025财年如果4月是起始月 */ public static int getFiscalYear(Date date) { Calendar cal Calendar.getInstance(); cal.setTime(date); int year cal.get(Calendar.YEAR); int month cal.get(Calendar.MONTH) 1; if (month FISCAL_START_MONTH) { return year 1; } return year; } /** * 返回财季1-4 */ public static int getFiscalQuarter(Date date) { Calendar cal Calendar.getInstance(); cal.setTime(date); int month cal.get(Calendar.MONTH) 1; int adjustedMonth month - FISCAL_START_MONTH; if (adjustedMonth 0) { adjustedMonth 12; } return adjustedMonth / 3 1; } /** * 返回财月1-12 */ public static int getFiscalMonth(Date date) { Calendar cal Calendar.getInstance(); cal.setTime(date); int month cal.get(Calendar.MONTH) 1; int adjustedMonth month - FISCAL_START_MONTH; if (adjustedMonth 0) { adjustedMonth 12; } return adjustedMonth; } /** * 最常用的组合返回 2024-Q1 这种字符串用于分组报表 */ public static String getFiscalPeriod(Date date) { return getFiscalYear(date) -Q getFiscalQuarter(date); } }3.2 为什么不用FineReport自带的日期函数我知道肯定会有人说FineReport本身就有YEAR()、MONTH()函数用IF判断下不就行了确实一个财季的判断用Fine语言写也就是这样IF(MONTH(日期) 4 MONTH(日期) 6, Q1, IF(MONTH(日期) 7 MONTH(日期) 9, Q2, ...))但这个写法有几个问题表达式冗长在单元格里一长串调试的时候根本看不清。每个用到财季的单元格或者数据集都要复制这段逻辑一旦口径调整改起来想死。阅读代码的人还得自己数月份没注释根本看不懂。而用自定义类的方式数据集SQL里可以直接做一列计算比如FineReport的“数据集查询”里用SELECT *, ${FiscalPeriodUtils.getFiscalPeriod(日期)} AS fiscal_period FROM 订单这样注入也可以直接在设计器里新建一个计算列调用。关键是所有报表复用同一个类口径天然统一。3.3 实战应用按财季分组的销售报表在销售报表里我通常会在数据集层面先把原始日期字段传给这个类新建数据集SQL里查出订单日期、金额、客户等字段。在FineReport右侧数据集面板中添加一个“公式”列表达式写成FiscalPeriodUtils.getFiscalPeriod(订单日期)。报表分组字段直接选这个新列。这个方案跑起来非常稳定后来业务方提了一嘴“财年会不会改成跟自然年一致”我只改了一行FISCAL_START_MONTH常量重新编译替换class文件所有报表一次性生效不需要打开任何一张报表模板去改表达式。4. 案例三用缓存类把报表性能提升一个数量级第三个案例是性能问题也是最让我印象深刻的一个。有一张库存台账报表数据量不算特别大两万行左右但每次打开报表都要五六秒客户天天抱怨。后来我定位到问题根源报表里有一个“供应商名称”字段源表里只有供应商编码名称要从另一张表查而FineReport里的做法是在单元格里写SQL(SELECT 名称 FROM 供应商 WHERE 编码 A5)。问题就出在这里——两万行数据每行执行一次独立SQL查询数据库连接来回开销巨大加起来就是五六秒。4.1 思路用自定义类做内存缓存优化的思路很直接把供应商编码到名称的映射加载到内存里报表计算时只查内存不再查数据库。import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement; import java.util.HashMap; import java.util.Map; public class SupplierCacheUtils { private static MapString, String cache null; private static void loadCache() { cache new HashMap(); String url jdbc:mysql://localhost:3306/reportdb?useUnicodetruecharacterEncodingutf8; String user report_user; String password your_password; String sql SELECT code, name FROM supplier; try (Connection conn DriverManager.getConnection(url, user, password); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql)) { while (rs.next()) { cache.put(rs.getString(code), rs.getString(name)); } } catch (Exception e) { e.printStackTrace(); } } public static String getSupplierName(String code) { if (cache null) { synchronized (SupplierCacheUtils.class) { if (cache null) { loadCache(); } } } return cache.getOrDefault(code, code); } }4.2 调用方式与性能对比在报表单元格里直接写SupplierCacheUtils.getSupplierName(A5)即可。第一次调用时触发loadCache()从数据库一次性加载全部供应商数据到HashMap后续两万行数据全部走内存查询。HashMap的查询时间复杂度是O(1)两万次查询毫秒级完成。我实际测过优化前后的对比指标SQL函数方式逐行查库自定义类缓存方式首次加载报表耗时5.8秒6.1秒含0.3秒缓存加载第二次及以后打开耗时5.8秒1.2秒数据库连接创建次数2万1报表服务器CPU峰值高低这里要补充说一下实测第二次打开只有1.2秒是因为FineReport的模板计算和渲染本身还需要一点时间而缓存命中的部分几乎不占时间。效果非常明显。4.3 缓存类使用中的几个隐患这个方案虽然效果好但踩过的坑也不少写几个要点缓存刷新问题只要类不被卸载缓存就一直驻留内存。如果供应商数据变更了报表里看到的还是旧数据。我的做法是提供一个refresh()方法在FineReport的定时调度里每天凌晨调用一次或者管理员手动通过平台调度刷新。数据量控制如果映射表有几十万行且字段又多全量加载到内存可能占用几十MB此时要考虑LRU缓存方案而不是简单的HashMap。并发与线程安全loadCache()里用了双重检查锁就是为了防止多个用户同时打开报表导致重复加载。首次加载时其他线程会阻塞等待持续零点几秒可接受。如果缓存加载时间很长建议系统启动时就预热加载。5. 常见问题与排查技巧实录自定义类用多了之后报错也见得多。这里根据我用FineReport几年的经验整理一份高频问题速查表现象可能原因解决办法表达式里找不到类class文件没放到正确目录检查classes目录或报表应用classpath确认类名和文件名一致报错“Class not found”类所在的jar包没引入把class打成jar放WEB-INF\lib或者确认在classpath中方法调用报“No such method”方法名拼错或参数类型不匹配看清重载方法的签名FineReport里传入的可能是Object类型需要转换成实际类型再匹配返回值为null导致单元格显示空白方法内部异常被吞掉在Java方法里try-catch后返回默认值或空字符串检查服务器日志stdout.log改了Java代码重新编译后不生效类加载器缓存了旧的class重启FineReport的web应用或报表服务器在类管理器里重新注册在数据集SQL里调用静态方法失败调用方式写错或类未注册先在单元格里测试类必须能被Class.forName加载性能反而更差了每次调用都在new对象或加载数据改成静态方法静态缓存避免在表达式里频繁实例化类5.1 日志定位技巧自定义类出问题时最怕的是FineReport表达式里只显示一个“#ERROR”没有任何细节。我的排查流程是这样的先在单元格里精简表达式只保留一个方法调用比如CleanDataUtils.cleanBillNo(AB 12, 8)缩小范围。看FineReport安装目录下的日志文件Windows一般在C:\FineReport_11.0\logsLinux在/opt/FineReport/logs打开stdout.log搜Exception关键字。如果日志里没捕获到异常就在Java类的catch块里加e.printStackTrace()重新编译部署再看日志。在方法入口打印参数值确认传进来的数据格式是不是自己预期的。这一步特别有用因为有时不是代码逻辑问题而是数据本身有不可见字符。5.2 部署细节自定义类通常有两种部署方式一种是直接把.class文件扔进WEB-INF/classes对应包目录下另一种是打成.jar包放到WEB-INF/lib下。我的建议是正式环境打成jar包因为class文件散落容易误删而jar包管理起来清晰版本也方便替换。如果类引用了第三方依赖比如阿里的fastjson、Apache Commons记得把依赖jar包也一起放到lib目录否则会报NoClassDefFoundError。这个错误特别坑因为有时候它藏在日志深处不是一眼能看到。5.3 热部署的一个小技巧严格来说Java类在Web应用里没办法完全热替换。但我有一个相对轻量的操作流程改完代码、编译、把新class或jar包传上去然后不重启整个服务器而是用FineReport的“平台 服务器 类管理器”把旧的类移除再重新添加。有些情况下这种方式能生效但最保险的还是重启web应用。生产环境建议在低峰期操作避免报表用户受影响。6. 从自定义类到“报表工具链”的一点体会最后想聊点自己的真实感受。刚接触FineReport自定义类时我以为这只是个“Java高手才用得上的高级功能”。做了几个项目之后才意识到它其实是给报表开发人员的逃生舱凡是Fine语言表达力不够的场景、凡是数据计算性能扛不住的场景、凡是业务逻辑复杂到没办法在单元格里维护的场景都可以把问题下沉到Java层解决报表模板保持清爽业务逻辑集中在代码里。目前我手上维护的报表平台自定义类大概有二十几个有的是工具类日期、字符串、金额有的是业务类客户等级、区域映射、产品线归属还有几个是数据获取类对接第三方API、读NoSQL数据库。基本上形成了稳定的小工具库在FineReport的类管理器里统一注册新的报表模板开发速度提升了很多。如果你刚开始用自定义类我建议你从这周就开始做一件事回顾一下当前项目里有没有重复写了三次以上的Fine语言表达式如果有把它抽成自定义类试试。第一次可能有点别扭但用顺了之后你会回来感谢这个功能的。
返回列表