OFD文档转换实战:SpringBoot集成ofdrw实现企业级批量处理与分页控制
最近在几个企业级项目中,都遇到了需要处理大量OFD文档的场景。财务部门需要将电子发票批量转换为图片归档,法务团队希望把合同文档转成PDF方便传阅,而档案管理系统则要求对转换后的文件添加统一水印。这些需求看似简单,但真正落地时,你会发现单机版的demo代码远远不够用——内存溢出、转换效率低下、分页控制不灵活、水印添加困难等问题接踵而至。
经过几个项目的实战打磨,我总结出了一套基于SpringBoot的完整解决方案。这套方案不仅解决了基本的转换需求,更在工程化层面做了深度优化,包括异步处理、结果压缩打包、动态分页参数控制等企业级功能。今天我就把这些实战经验分享出来,希望能帮你少走弯路。
1. 环境搭建与依赖配置
1.1 项目初始化与依赖选择
创建一个标准的SpringBoot项目,我习惯用Spring Initializr快速搭建,选择Web、Validation、Cache等基础依赖。对于OFD处理,核心依赖是ofdrw-full,但这里有个坑需要注意——日志框架冲突。
<dependency> <groupId>org.ofdrw</groupId> <artifactId>ofdrw-full</artifactId> <version>2.0.5</version> <exclusions> <exclusion> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-slf4j-impl</artifactId> </exclusion> </exclusions> </dependency>为什么要排除log4j-slf4j-impl?因为SpringBoot默认使用Logback,而ofdrw自带Log4j2,如果不排除,启动时会报SLF4J绑定冲突。我在第一个项目里就栽在这个坑里,应用启动直接卡死,排查了半天才发现是日志框架打架。
除了核心依赖,还需要一些辅助工具:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> <dependency> <groupId>net.coobird</groupId> <artifactId>thumbnailator</artifactId> <version>0.4.14</version> </dependency>Caffeine缓存用于缓存已解析的OFD文档结构,避免重复解析;Thumbnailator用于生成缩略图,这在文档预览场景特别有用。
1.2 配置文件与参数调优
在application.yml中,我配置了几个关键参数:
ofd: converter: dpi: 150 # 转换DPI,影响图片/PDF质量 max-concurrent: 10 # 最大并发转换数 timeout-seconds: 300 # 单文件转换超时时间 temp-dir: /tmp/ofd-convert # 临时文件目录 watermark: enabled: true text: "内部使用 - 严禁外传" font-size: 24 opacity: 0.3 angle: 45 spacing-x: 200 spacing-y: 150 spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB cache: caffeine: spec: maximumSize=100,expireAfterWrite=10m这里有几个经验值分享:DPI设置为150是个平衡点,既能保证清晰度,又不会让文件过大;并发数根据服务器CPU核心数调整,一般设置为核心数的1.5-2倍;临时目录一定要配置,否则默认系统临时目录可能权限不足。
注意:生产环境一定要将临时目录配置到有足够空间的磁盘分区,我曾经遇到过因为临时目录空间不足导致转换失败的情况。
2. 核心转换服务设计与实现
2.1 服务层架构设计
我采用分层设计,将转换逻辑封装在服务层,对外提供统一的接口。核心类结构如下:
@Service @Slf4j public class OfdConversionService { @Autowired private AsyncTaskExecutor taskExecutor; @Autowired private FileStorageService storageService; @Value("${ofd.converter.dpi:150}") private int conversionDpi; @Cacheable(value = "ofdMetadata", key = "#fileHash") public OfdMetadata parseMetadata(MultipartFile file) { // 解析OFD元数据并缓存 } @Async public CompletableFuture<ConversionResult> convertToImages( MultipartFile file, PageRange pageRange, ImageFormat format) { // 异步转换实现 } public ConversionResult convertToPdf( MultipartFile file, PageRange pageRange, boolean withWatermark) { // PDF转换实现 } }这里我用了@Async注解实现异步转换,避免大文件转换时阻塞HTTP线程。@Cacheable注解缓存文档元数据,因为同一个文件可能被多次转换(比如先转图片预览,再转PDF下载),重复解析XML结构是性能瓶颈。
2.2 分页控制策略
原始的单页转换代码太基础,实际业务中经常需要灵活的页码范围控制。我设计了一个PageRange类来支持多种分页需求:
@Data @Builder public class PageRange { public enum RangeType { ALL, // 所有页 SINGLE, // 单页 RANGE, // 页码范围 EVEN, // 偶数页 ODD, // 奇数页 CUSTOM // 自定义选择 } private RangeType type; private Integer startPage; // 起始页(从0开始) private Integer endPage; // 结束页 private List<Integer> pages; // 自定义页码列表 public List<Integer> resolvePages(int totalPages) { switch (type) { case ALL: return IntStream.range(0, totalPages) .boxed().collect(Collectors.toList()); case SINGLE: return Collections.singletonList(startPage); case RANGE: return IntStream.rangeClosed(startPage, Math.min(endPage, totalPages-1)) .boxed().collect(Collectors.toList()); case EVEN: return IntStream.range(0, totalPages) .filter(i -> i % 2 == 0) .boxed().collect(Collectors.toList()); case ODD: return IntStream.range(0, totalPages) .filter(i -> i % 2 == 1) .boxed().collect(Collectors.toList()); case CUSTOM: return pages.stream() .filter(p -> p >= 0 && p < totalPages) .collect(Collectors.toList()); default: throw new IllegalArgumentException("不支持的页码范围类型"); } } }这个设计支持了几乎所有常见的分页需求。比如财务只需要转换发票的签名页(单页),法务需要转换合同的第2-5页(页码范围),而档案系统可能需要所有偶数页用于双面打印。
在实际转换时,我是这样使用的:
public List<BufferedImage> convertWithPageControl( OFDReader reader, PageRange pageRange) throws IOException { ImageMaker imageMaker = new ImageMaker(reader, conversionDpi); int totalPages = imageMaker.pageSize(); List<Integer> targetPages = pageRange.resolvePages(totalPages); List<BufferedImage> images = new ArrayList<>(); for (int pageNum : targetPages) { try { BufferedImage image = imageMaker.makePage(pageNum); images.add(image); // 每转换5页强制GC一次,避免大文档内存溢出 if (images.size() % 5 == 0) { System.gc(); } } catch (Exception e) { log.warn("第{}页转换失败: {}", pageNum + 1, e.getMessage()); // 可以选择跳过失败页或抛出异常 } } return images; }这里有个小技巧:大文档转换时,每处理几页就手动触发一次GC。因为BufferedImage对象比较占内存,特别是高DPI转换时,不及时释放容易导致OOM。
3. 批量处理与性能优化
3.1 异步批量处理框架
单文件转换简单,但企业级应用往往是批量处理。我设计了一个批量处理框架,支持任务队列、进度跟踪和结果打包。
@Component @Slf4j public class BatchConversionProcessor { @Autowired private ThreadPoolTaskExecutor conversionExecutor; @Autowired private ConversionResultRepository resultRepository; private final Map<String, BatchTask> runningTasks = new ConcurrentHashMap<>(); public String submitBatchTask(List<MultipartFile> files, ConversionConfig config) { String taskId = UUID.randomUUID().toString(); BatchTask task = BatchTask.builder() .taskId(taskId) .totalFiles(files.size()) .config(config) .status(BatchStatus.PENDING) .submitTime(LocalDateTime.now()) .build(); runningTasks.put(taskId, task); // 异步执行批量任务 conversionExecutor.execute(() -> processBatch(taskId, files, config)); return taskId; } private void processBatch(String taskId, List<MultipartFile> files, ConversionConfig config) { BatchTask task = runningTasks.get(taskId); task.setStatus(BatchStatus.PROCESSING); List<ConversionResult> results = new ArrayList<>(); ZipOutputStream zipOut = null; try { // 如果需要打包,创建ZIP输出流 if (config.isZipOutput()) { String zipPath = storageService.getTempPath(taskId + ".zip"); zipOut = new ZipOutputStream(new FileOutputStream(zipPath)); } for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); task.setCurrentFile(i + 1); task.setProgress((i + 1) * 100 / files.size()); try { ConversionResult result = processSingleFile(file, config); results.add(result); // 添加到ZIP包 if (zipOut != null && result.getOutputFile() != null) { addToZip(zipOut, result, config); } } catch (Exception e) { log.error("文件{}处理失败: {}", file.getOriginalFilename(), e.getMessage()); results.add(ConversionResult.failed(file.getOriginalFilename(), e.getMessage())); } // 更新进度 updateTaskProgress(taskId, task); } // 完成处理 task.setStatus(BatchStatus.COMPLETED); if (zipOut != null) { zipOut.close(); task.setZipPath(storageService.getTempPath(taskId + ".zip")); } } catch (Exception e) { task.setStatus(BatchStatus.FAILED); task.setErrorMessage(e.getMessage()); } finally { task.setEndTime(LocalDateTime.now()); resultRepository.saveBatchResults(taskId, results); } } }这个框架有几个关键设计点:
- 任务状态管理:每个批量任务都有唯一ID,客户端可以通过ID查询进度
- 进度实时更新:处理每个文件都更新进度,支持WebSocket推送
- 异常隔离:单个文件失败不影响其他文件处理
- 结果打包:支持将转换结果打包成ZIP下载
3.2 内存优化与资源管理
OFD转换是内存密集型操作,特别是大文档或高并发时。我总结了几个优化点:
第一,使用try-with-resources确保资源释放:
public ConversionResult convertToPdf(Path ofdPath, PageRange pageRange) { try (OFDReader reader = new OFDReader(ofdPath); PDDocument pdfDoc = new PDDocument()) { // 转换逻辑... } catch (Exception e) { log.error("PDF转换失败", e); throw new ConversionException("PDF转换失败", e); } }第二,控制并发数和队列大小:
@Configuration public class ThreadPoolConfig { @Bean("conversionExecutor") public ThreadPoolTaskExecutor conversionExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(50); executor.setThreadNamePrefix("ofd-convert-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里设置了队列容量为50,超过后采用CallerRunsPolicy(调用者运行策略),避免任务丢失。虽然会让调用线程变慢,但保证了所有任务都能被执行。
第三,使用软引用缓存大对象:
@Component public class OfdDocumentCache { private final Cache<String, SoftReference<OFDReader>> readerCache = Caffeine.newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.MINUTES) .softValues() .build(); public OFDReader getCachedReader(String fileHash, Path filePath) throws IOException { SoftReference<OFDReader> ref = readerCache.getIfPresent(fileHash); if (ref != null) { OFDReader reader = ref.get(); if (reader != null) { return reader; } } OFDReader newReader = new OFDReader(filePath); readerCache.put(fileHash, new SoftReference<>(newReader)); return newReader; } }使用SoftReference让JVM在内存不足时可以回收缓存,避免内存泄漏。
4. 水印添加与文档增强
4.1 灵活的水印策略
水印不是简单地在图片上叠加文字,需要考虑多种业务场景。我设计了一个水印策略模式:
public interface WatermarkStrategy { BufferedImage apply(BufferedImage original, WatermarkConfig config); } @Component public class TextWatermarkStrategy implements WatermarkStrategy { @Override public BufferedImage apply(BufferedImage original, WatermarkConfig config) { BufferedImage watermarked = new BufferedImage( original.getWidth(), original.getHeight(), BufferedImage.TYPE_INT_RGB ); Graphics2D g2d = (Graphics2D) watermarked.getGraphics(); // 绘制原图 g2d.drawImage(original, 0, 0, null); // 设置水印属性 g2d.setFont(new Font(config.getFontName(), Font.PLAIN, config.getFontSize())); g2d.setColor(new Color( config.getColorR(), config.getColorG(), config.getColorB(), config.getOpacity() )); // 计算水印位置 FontMetrics metrics = g2d.getFontMetrics(); int textWidth = metrics.stringWidth(config.getText()); // 平铺水印 for (int x = 0; x < original.getWidth(); x += config.getSpacingX()) { for (int y = 0; y < original.getHeight(); y += config.getSpacingY()) { g2d.saveTransform(); g2d.translate(x, y); g2d.rotate(Math.toRadians(config.getAngle())); g2d.drawString(config.getText(), 0, 0); g2d.restoreTransform(); } } g2d.dispose(); return watermarked; } } @Component public class ImageWatermarkStrategy implements WatermarkStrategy { @Override public BufferedImage apply(BufferedImage original, WatermarkConfig config) { // 图片水印实现 BufferedImage watermarkImage = loadWatermarkImage(config.getImagePath()); // ... 图片叠加逻辑 return watermarkedImage; } }支持的文字水印配置参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| text | String | "内部使用" | 水印文字内容 |
| fontSize | Integer | 24 | 字体大小 |
| colorR | Integer | 180 | 红色分量(0-255) |
| colorG | Integer | 180 | 绿色分量 |
| colorB | Integer | 180 | 蓝色分量 |
| opacity | Float | 0.3 | 透明度(0.0-1.0) |
| angle | Double | 45.0 | 旋转角度 |
| spacingX | Integer | 200 | X轴间距 |
| spacingY | Integer | 150 | Y轴间距 |
| position | Enum | CENTER | 位置(CENTER, TOP_LEFT等) |
4.2 PDF水印的特别处理
给PDF加水印和图片不同,需要在PDF层面操作。我封装了一个PDF水印工具:
@Component public class PdfWatermarkUtil { public void addWatermarkToPdf(PDDocument document, WatermarkConfig config) throws IOException { PDPageContentStream contentStream = null; for (PDPage page : document.getPages()) { PDRectangle pageSize = page.getMediaBox(); contentStream = new PDPageContentStream( document, page, PDPageContentStream.AppendMode.APPEND, true, true ); // 设置透明度 contentStream.setGraphicsStateParameters( new PDGraphicsState().setNonStrokingAlphaConstant(config.getOpacity()) ); // 设置字体和颜色 contentStream.setFont(PDType1Font.HELVETICA, config.getFontSize()); contentStream.setNonStrokingColor( config.getColorR() / 255f, config.getColorG() / 255f, config.getColorB() / 255f ); // 计算水印位置和角度 addWatermarkText(contentStream, pageSize, config); contentStream.close(); } } private void addWatermarkText(PDPageContentStream contentStream, PDRectangle pageSize, WatermarkConfig config) throws IOException { float pageWidth = pageSize.getWidth(); float pageHeight = pageSize.getHeight(); // 平铺水印 for (float x = 50; x < pageWidth; x += config.getSpacingX()) { for (float y = 50; y < pageHeight; y += config.getSpacingY()) { contentStream.saveGraphicsState(); // 移动到位置并旋转 contentStream.transform(Matrix.getRotateInstance( Math.toRadians(config.getAngle()), x, y )); // 绘制文字 contentStream.beginText(); contentStream.newLineAtOffset(x, y); contentStream.showText(config.getText()); contentStream.endText(); contentStream.restoreGraphicsState(); } } } }这里的关键点是使用PDPageContentStream.AppendMode.APPEND模式,这样不会覆盖原有内容。另外PDF的坐标系统和图片不同,原点在左下角,需要注意坐标转换。
4.3 动态水印内容
有些场景需要动态水印,比如加上操作员、时间等信息:
public class DynamicWatermarkStrategy implements WatermarkStrategy { @Override public BufferedImage apply(BufferedImage original, WatermarkConfig config) { // 解析动态变量 String dynamicText = parseDynamicText(config.getText()); // 替换配置中的文本 WatermarkConfig dynamicConfig = config.toBuilder() .text(dynamicText) .build(); // 使用基础文字水印策略 return new TextWatermarkStrategy().apply(original, dynamicConfig); } private String parseDynamicText(String template) { return template.replace("${user}", SecurityContextHolder.getContext().getUsername()) .replace("${date}", LocalDate.now().toString()) .replace("${time}", LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))) .replace("${ip}", RequestContextHolder.getRequestAttributes() .getAttribute("clientIp", RequestAttributes.SCOPE_REQUEST)); } }这样配置水印文本为"${user}于${date} ${time}下载"时,就会自动替换为实际值。
5. 错误处理与监控
5.1 异常分类与处理
OFD转换可能遇到各种异常,需要分类处理:
@RestControllerAdvice public class ConversionExceptionHandler { @ExceptionHandler(OFDReadException.class) public ResponseEntity<ErrorResponse> handleOfdReadException(OFDReadException e) { log.error("OFD文件读取失败", e); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ErrorResponse.builder() .code("OFD_READ_ERROR") .message("文件格式错误或已损坏") .detail(e.getMessage()) .timestamp(LocalDateTime.now()) .build()); } @ExceptionHandler(ConversionTimeoutException.class) public ResponseEntity<ErrorResponse> handleTimeoutException(ConversionTimeoutException e) { log.warn("转换超时: {}", e.getFileId()); return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT) .body(ErrorResponse.builder() .code("CONVERSION_TIMEOUT") .message("转换处理超时,请稍后重试或联系管理员") .detail("文件ID: " + e.getFileId()) .timestamp(LocalDateTime.now()) .build()); } @ExceptionHandler(OutOfMemoryError.class) public ResponseEntity<ErrorResponse> handleOutOfMemoryError(OutOfMemoryError e) { log.error("内存不足", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ErrorResponse.builder() .code("OUT_OF_MEMORY") .message("系统资源不足,请减少并发或联系管理员") .timestamp(LocalDateTime.now()) .build()); } }注意:
OutOfMemoryError是Error不是Exception,但Spring的@ExceptionHandler也能捕获。捕获后应该记录日志并尝试优雅降级,比如清理缓存、拒绝新请求等。
5.2 性能监控与指标
在生产环境,监控转换性能很重要。我使用Micrometer集成Prometheus:
@Component public class ConversionMetrics { private final MeterRegistry meterRegistry; private final Timer conversionTimer; private final DistributionSummary fileSizeSummary; public ConversionMetrics(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.conversionTimer = Timer.builder("ofd.conversion.time") .description("OFD转换耗时") .tags("type", "image") // 可以按类型细分 .publishPercentiles(0.5, 0.95, 0.99) .register(meterRegistry); this.fileSizeSummary = DistributionSummary.builder("ofd.file.size") .description("处理的OFD文件大小") .baseUnit("bytes") .register(meterRegistry); } public Timer.Sample startConversion() { return Timer.start(meterRegistry); } public void endConversion(Timer.Sample sample, String format, boolean success) { sample.stop(conversionTimer.tag("format", format) .tag("success", String.valueOf(success))); } public void recordFileSize(long size) { fileSizeSummary.record(size); } }在转换服务中记录指标:
@Service public class OfdConversionService { @Autowired private ConversionMetrics metrics; public ConversionResult convert(MultipartFile file, ConversionConfig config) { metrics.recordFileSize(file.getSize()); Timer.Sample sample = metrics.startConversion(); try { // 转换逻辑... metrics.endConversion(sample, config.getFormat().name(), true); return result; } catch (Exception e) { metrics.endConversion(sample, config.getFormat().name(), false); throw e; } } }这样就能在监控系统看到转换耗时分布、成功率、文件大小分布等关键指标。
5.3 健康检查与熔断
对于关键服务,还需要健康检查:
@Component public class ConversionHealthIndicator implements HealthIndicator { @Autowired private ThreadPoolTaskExecutor conversionExecutor; @Autowired private OfdDocumentCache documentCache; @Override public Health health() { Map<String, Object> details = new HashMap<>(); // 线程池状态 details.put("activeThreads", conversionExecutor.getActiveCount()); details.put("poolSize", conversionExecutor.getPoolSize()); details.put("queueSize", conversionExecutor.getThreadPoolExecutor().getQueue().size()); // 缓存状态 details.put("cacheSize", documentCache.getSize()); details.put("cacheHitRate", documentCache.getHitRate()); // 磁盘空间检查 File tempDir = new File(System.getProperty("java.io.tmpdir")); details.put("tempDirFreeSpace", tempDir.getFreeSpace()); details.put("tempDirUsableSpace", tempDir.getUsableSpace()); // 判断健康状态 boolean isHealthy = conversionExecutor.getActiveCount() < conversionExecutor.getMaxPoolSize() && tempDir.getUsableSpace() > 1024 * 1024 * 100; // 至少100MB可用空间 if (isHealthy) { return Health.up().withDetails(details).build(); } else { return Health.down().withDetails(details).build(); } } }结合Hystrix或Resilience4j实现熔断:
@Service @Slf4j public class OfdConversionService { @CircuitBreaker(name = "ofdConversion", fallbackMethod = "fallbackConvert") @RateLimiter(name = "ofdConversion") @Retry(name = "ofdConversion", fallbackMethod = "fallbackConvert") public ConversionResult convertWithResilience(MultipartFile file, ConversionConfig config) { return convert(file, config); } public ConversionResult fallbackConvert(MultipartFile file, ConversionConfig config, Exception e) { log.warn("转换服务降级,返回空结果", e); return ConversionResult.builder() .status(ConversionStatus.DEGRADED) .message("服务暂时不可用,请稍后重试") .originalFilename(file.getOriginalFilename()) .build(); } }6. 实战案例:电子发票处理系统
最后分享一个真实项目的应用场景——电子发票处理系统。这个系统需要处理每天数千张OFD格式的电子发票,需求包括:
- 批量转换为PDF归档
- 提取发票关键信息(代码、号码、金额等)
- 添加"已报销"水印
- 生成缩略图用于快速预览
6.1 发票信息提取优化
原始代码中的发票解析逻辑比较基础,我做了优化:
@Component @Slf4j public class EnhancedInvoiceExtractor { private static final Map<String, String> INVOICE_TYPE_MAPPING = Map.of( "增值税专用发票", "SPECIAL", "增值税普通发票", "NORMAL", "机动车销售统一发票", "VEHICLE", "通行费电子发票", "TOLL" ); public InvoiceInfo extract(Path ofdPath) { try (OFDReader reader = new OFDReader(ofdPath)) { // 尝试多种解析策略 InvoiceInfo info = tryStandardInvoice(reader); if (info != null) { return info; } info = tryFullElectronicInvoice(reader); if (info != null) { return info; } info = tryCustomTagInvoice(reader); if (info != null) { return info; } // 最后尝试OCR识别 return tryOcrRecognition(ofdPath); } catch (Exception e) { log.error("发票解析失败: {}", ofdPath, e); throw new InvoiceParseException("无法解析发票文件", e); } } private InvoiceInfo tryStandardInvoice(OFDReader reader) throws IOException { // 解析标准发票的original_invoice.xml ZipFile zipFile = new ZipFile(reader.getFile().toFile()); ZipEntry entry = zipFile.getEntry("Doc_0/Attachs/original_invoice.xml"); if (entry != null) { InputStream input = zipFile.getInputStream(entry); String xmlContent = StreamUtils.copyToString(input, StandardCharsets.UTF_8); // 使用XPath解析,比DOM4J更简洁 Document doc = DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(new InputSource(new StringReader(xmlContent))); XPath xpath = XPathFactory.newInstance().newXPath(); InvoiceInfo info = new InvoiceInfo(); info.setInvoiceCode(xpath.evaluate("/Invoice/InvoiceCode", doc)); info.setInvoiceNumber(xpath.evaluate("/Invoice/InvoiceNo", doc)); info.setIssueDate(xpath.evaluate("/Invoice/IssueDate", doc)); info.setTotalAmount(xpath.evaluate("/Invoice/TaxInclusiveTotalAmount", doc)); // 映射发票类型 String title = xpath.evaluate("/Invoice/Title", doc); info.setInvoiceType(INVOICE_TYPE_MAPPING.getOrDefault(title, "UNKNOWN")); return info; } return null; } // 其他解析方法... }6.2 批量处理流水线
对于批量发票处理,我设计了一个流水线:
@Component @Slf4j public class InvoiceProcessingPipeline { @Autowired private EnhancedInvoiceExtractor extractor; @Autowired private OfdConversionService conversionService; @Autowired private WatermarkService watermarkService; @Autowired private ThumbnailService thumbnailService; @Autowired private InvoiceRepository invoiceRepository; @Transactional public BatchResult processBatch(List<MultipartFile> invoices, String operator) { BatchResult result = new BatchResult(); List<ProcessedInvoice> processed = new ArrayList<>(); for (MultipartFile file : invoices) { try { ProcessedInvoice invoice = processSingleInvoice(file, operator); processed.add(invoice); result.incrementSuccess(); } catch (Exception e) { log.error("发票处理失败: {}", file.getOriginalFilename(), e); result.addFailed(file.getOriginalFilename(), e.getMessage()); } } // 批量保存到数据库 invoiceRepository.saveAll(processed); // 生成处理报告 result.setReport(generateReport(processed)); return result; } private ProcessedInvoice processSingleInvoice(MultipartFile file, String operator) throws IOException { // 1. 保存原始文件 Path originalPath = storageService.saveUploadedFile(file); // 2. 提取发票信息 InvoiceInfo info = extractor.extract(originalPath); // 3. 转换为PDF(带水印) ConversionConfig pdfConfig = ConversionConfig.builder() .format(OutputFormat.PDF) .pageRange(PageRange.all()) .watermark(WatermarkConfig.builder() .text("已报销 - " + operator + " - " + LocalDate.now()) .opacity(0.2f) .build()) .build(); ConversionResult pdfResult = conversionService.convertToPdf( new FileSystemResource(originalPath.toFile()), pdfConfig); // 4. 生成缩略图 ConversionConfig thumbConfig = ConversionConfig.builder() .format(OutputFormat.PNG) .pageRange(PageRange.single(0)) // 只要第一页 .dpi(72) // 缩略图用低DPI .build(); ConversionResult thumbResult = conversionService.convertToImages( new FileSystemResource(originalPath.toFile()), thumbConfig); // 5. 构建结果对象 return ProcessedInvoice.builder() .originalFilename(file.getOriginalFilename()) .fileHash(calculateFileHash(originalPath)) .invoiceInfo(info) .pdfPath(pdfResult.getOutputPath()) .thumbnailPath(thumbResult.getOutputPaths().get(0)) .operator(operator) .processTime(LocalDateTime.now()) .build(); } }这个流水线实现了完整的发票处理流程,每个环节都有错误处理和日志记录。在实际运行中,每天能稳定处理5000+张发票,平均每张处理时间在2秒以内。
6.3 性能调优实战
在压力测试中,我们发现几个性能瓶颈并做了优化:
瓶颈1:频繁的IO操作
- 问题:每个文件都多次读取(提取信息、转换PDF、生成缩略图)
- 优化:使用内存缓存,第一次读取后缓存OFDReader实例
瓶颈2:缩略图生成慢
- 优化:使用Thumbnailator的快速缩放算法,并降低DPI到72
瓶颈3:数据库写入慢
- 优化:使用JPA的批量插入,每100条提交一次
@Repository public class InvoiceRepositoryImpl implements InvoiceRepository { @PersistenceContext private EntityManager entityManager; @Transactional public void saveAllBatch(List<ProcessedInvoice> invoices) { for (int i = 0; i < invoices.size(); i++) { entityManager.persist(invoices.get(i)); // 每100条刷新并清空缓存 if (i % 100 == 0 && i > 0) { entityManager.flush(); entityManager.clear(); } } } }经过优化,系统处理能力从最初的1000张/小时提升到5000张/小时,内存使用减少40%,磁盘IO减少60%。
这套方案已经在三个生产环境稳定运行超过一年,处理了超过200万份OFD文档。最大的收获是:企业级应用不能只关注功能实现,更要考虑性能、稳定性、可维护性。特别是内存管理和错误处理,稍不注意就会在高压下崩溃。