问题描述
具体场景
在文档分割处理中,当同时开启OCR识别和资源提取时出现图片无法正常访问的问题。
问题现象
- 上传状态:显示成功,返回文件地址 ✅
- OCR功能:文字识别正常 ✅
- 图片访问:文件链接无法打开,图片损坏 ❌
- 单独功能:仅开启OCR或仅开启上传都正常 ✅
根本原因
文件流被重复读取,导致上传的是空文件或不完整文件
技术原理深入分析
InputStream的单向消费特性
InputStream stream = fileInfo.getInputStream();
// 内部维护一个position指针:position = 0
// 第一次读取(OCR)
byte[] data1 = stream.readAllBytes(); // position = 文件大小(EOF)
// 流已被完全消费
// 第二次读取(上传)
byte[] data2 = stream.readAllBytes(); // 返回空数组 [],因为position已在EOF
问题代码分析
event.onFile((e, fileInfo) -> {
String res = "";
// 🔥 问题所在:两次使用同一个流
if (needOCR && fileInfo.isImage()) {
// 第一次:OCR完全消费了流
res += JBoltOCR.read(fileInfo.getInputStream());
}
if (needUpload) {
// 第二次:上传时流已经空了,上传空文件
res += uploadQiniu(fileInfo, path); // ❌ 上传损坏文件
}
return res;
});
为什么上传显示"成功"?
- 上传工具的容错性:大部分上传SDK对空文件也会返回成功状态
- 文件名有效:虽然内容为空,但文件名和路径都是有效的
- 服务器接受:云存储服务会接受0字节文件的上传
- 缺少校验:上传工具通常不会检查文件内容完整性
// 上传空文件的流程
InputStream emptyStream = ...; // 已被消费的流
uploadTool.upload(emptyStream, "image.jpg");
// 返回:{ success: true, url: "https://cdn.example.com/image.jpg" }
// 但实际文件大小为0KB,无法正常显示
完整解决方案
核心策略:数据缓存 + 流复用
event.onFile((e, fileInfo) -> {
logger.info("开始处理文件: {}", fileInfo.getFileName());
String res = "";
try {
// 🔑 核心:一次性读取完整文件数据
byte[] fileData = null;
try (InputStream inputStream = fileInfo.getInputStream()) {
fileData = inputStream.readAllBytes(); // Java 9+
// Java 8: fileData = IOUtils.toByteArray(inputStream);
logger.info("文件数据读取完成: {} bytes", fileData.length);
}
// ✅ OCR处理:使用完整数据
if (Objects.equals(file.getOcrImg(), true) && fileInfo.isImage()) {
logger.info("开始OCR识别: {}", fileInfo.getFileName());
try (ByteArrayInputStream ocrStream = new ByteArrayInputStream(fileData)) {
res += JBoltOCR.read(ocrStream) + "\n";
logger.info("OCR识别完成,识别内容长度: {}", res.length());
} catch (Exception ocrException) {
logger.error("OCR识别失败[{}]: {}", fileInfo.getFileName(), ocrException.getMessage());
}
}
// ✅ 资源上传:使用相同的完整数据
if (Objects.equals(file.getExtractResources(), true)) {
logger.info("开始资源上传: {}", fileInfo.getFileName());
try {
if (file.getResourcesPosition().equals(AiFile.RESOURCES_QINIU)) {
res += uploadQiniuWithBytes(fileData, fileInfo.getFileName(), file.getResourceSavePath());
} else {
res += uploadLocalWithBytes(fileData, fileInfo.getFileName(), file.getResourceSavePath());
}
logger.info("资源上传完成: {}", fileInfo.getFileName());
} catch (Exception uploadException) {
logger.error("资源上传失败[{}]: {}", fileInfo.getFileName(), uploadException.getMessage());
}
}
} catch (Exception e) {
logger.error("文件处理失败[{}]: {}", fileInfo.getFileName(), e.getMessage(), e);
}
return res;
});
优化的上传方法
/**
* 七牛云上传 - 字节数组版本
* 确保上传完整的文件数据
*/
private String uploadQiniuWithBytes(byte[] fileData, String fileName, String filePath) {
logger.info("准备上传到七牛云: {},文件大小: {} bytes", fileName, fileData.length);
// 数据完整性检查
if (fileData == null || fileData.length == 0) {
logger.warn("文件数据为空,跳过上传: {}", fileName);
return "";
}
try {
// 生成文件路径
if (StrUtil.isNotBlank(filePath)) {
filePath = filePathGeneratorUtil.generateFilePath(filePath, fileName);
}
// 使用完整数据创建流
try (ByteArrayInputStream uploadStream = new ByteArrayInputStream(fileData)) {
Result<String> fileRes = qiniuUtil.uploadFile(uploadStream, fileName, filePath);
if (fileRes.isSuccess()) {
String url = fileRes.getData();
logger.info("七牛云上传成功: {} -> {}", fileName, url);
// 可选:验证上传文件大小(如果七牛云SDK支持)
// verifyUploadedFileSize(url, fileData.length);
return "";
} else {
logger.warn("七牛云上传失败: {} - {}", fileName, fileRes.getMsg());
return "";
}
}
} catch (Exception e) {
logger.error("七牛云上传异常[{}]: {}", fileName, e.getMessage(), e);
return "";
}
}
/**
* 本地上传 - 字节数组版本
*/
private String uploadLocalWithBytes(byte[] fileData, String fileName, String filePath) {
logger.info("准备本地存储: {},文件大小: {} bytes", fileName, fileData.length);
if (fileData == null || fileData.length == 0) {
logger.warn("文件数据为空,跳过存储: {}", fileName);
return "";
}
try {
// 生成文件名
if (StrUtil.isNotBlank(filePath)) {
fileName = filePathGeneratorUtil.generateFilePath(filePath, fileName);
}
try (ByteArrayInputStream uploadStream = new ByteArrayInputStream(fileData)) {
Result<String> fileRes = uploadLocalUtil.uploadInputStreamToLocal(uploadStream, fileName);
if (fileRes.isSuccess()) {
String localPath = fileRes.getData();
logger.info("本地存储成功: {} -> {}", fileName, localPath);
// 可选:验证本地文件大小
// verifyLocalFileSize(localPath, fileData.length);
return "";
} else {
logger.warn("本地存储失败: {} - {}", fileName, fileRes.getMsg());
return "";
}
}
} catch (Exception e) {
logger.error("本地存储异常[{}]: {}", fileName, e.getMessage(), e);
return "";
}
}
Java 8 兼容处理
/**
* Java 8 兼容的字节读取方法
*/
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
int nRead;
byte[] data = new byte[8192]; // 8KB缓冲区
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
return buffer.toByteArray();
}
}
// 使用方式
try (InputStream inputStream = fileInfo.getInputStream()) {
fileData = readAllBytes(inputStream); // Java 8兼容
// fileData = inputStream.readAllBytes(); // Java 9+
// fileData = IOUtils.toByteArray(inputStream); // Apache Commons IO
}
问题排查与验证
数据完整性验证
/**
* 验证上传文件的完整性
*/
private void verifyUploadIntegrity(byte[] originalData, String uploadedUrl) {
try {
// 计算原始文件的MD5
String originalMd5 = DigestUtils.md5Hex(originalData);
logger.info("原始文件MD5: {}, 大小: {} bytes", originalMd5, originalData.length);
// 如果可以下载上传后的文件,验证MD5
// byte[] uploadedData = downloadFile(uploadedUrl);
// String uploadedMd5 = DigestUtils.md5Hex(uploadedData);
// if (!originalMd5.equals(uploadedMd5)) {
// logger.error("文件完整性校验失败!原始MD5: {}, 上传后MD5: {}", originalMd5, uploadedMd5);
// }
} catch (Exception e) {
logger.warn("文件完整性验证失败: {}", e.getMessage());
}
}
调试技巧
// 1. 检查流状态
InputStream stream = fileInfo.getInputStream();
logger.debug("流类型: {}", stream.getClass().getName());
logger.debug("流支持标记: {}", stream.markSupported());
logger.debug("流可用字节: {}", stream.available());
// 2. 监控数据流转
byte[] data = readFileData(fileInfo);
logger.debug("读取数据: {} bytes", data.length);
logger.debug("数据前10字节: {}", Arrays.toString(Arrays.copyOf(data, Math.min(10, data.length))));
// 3. 上传前后对比
logger.debug("上传前文件大小: {} bytes", data.length);
// 上传完成后,如果可能的话检查远程文件大小
生活化理解
错误场景:一份报纸多人看
📰 一份报纸(InputStream)
👤 张三拿去看完了,报纸变成废纸
👤 李四想看,但报纸已经变成废纸了
📋 结果:李四只能提交空白的"读后感"
正确方案:复印后分发
📰 原始报纸(InputStream)
📠 复印机(byte[] 数组)
↓ 一次性复印多份
📰 张三的副本 → 正常阅读
📰 李四的副本 → 正常阅读
📋 结果:两人都能提交完整的读后感
预防措施与最佳实践
1. 代码设计原则
// ✅ 好的设计:一次读取,多次使用
byte[] data = inputStream.readAllBytes();
processA(new ByteArrayInputStream(data));
processB(new ByteArrayInputStream(data));
// ❌ 坏的设计:尝试重复使用流
InputStream stream = getStream();
processA(stream); // 第一次使用
processB(stream); // ❌ 第二次使用失败
2. 异常处理策略
try {
byte[] fileData = readFileData(fileInfo);
// OCR处理(允许失败)
try {
processOCR(fileData);
} catch (Exception e) {
logger.warn("OCR处理失败,继续上传: {}", e.getMessage());
}
// 上传处理(核心功能)
try {
uploadFile(fileData);
} catch (Exception e) {
logger.error("上传失败: {}", e.getMessage());
throw e; // 上传失败需要抛出异常
}
} catch (IOException e) {
logger.error("文件读取失败: {}", e.getMessage());
throw new ProcessException("文件处理失败", e);
}
3. 性能优化考虑
// 内存使用评估
long maxFileSize = 50 * 1024 * 1024; // 50MB限制
if (fileSize > maxFileSize) {
// 大文件采用临时文件方案
return processLargeFile(fileInfo);
} else {
// 小文件采用内存缓存方案
return processSmallFile(fileInfo);
}
4. 单元测试建议
@Test
public void testFileProcessingWithOCRAndUpload() {
// 准备测试数据
byte[] testImageData = loadTestImage();
FileInfo mockFileInfo = createMockFileInfo(testImageData);
// 执行处理
String result = processFile(mockFileInfo);
// 验证结果
assertThat(result).contains(";
verify(ocrService).process(any(InputStream.class));
verify(uploadService).upload(any(InputStream.class), eq("test.jpg"));
}
扩展应用场景
场景1:文件多重处理
- 图片:缩略图生成 + 原图上传 + 内容识别
- 视频:截图提取 + 文件上传 + 格式转换
- 文档:内容提取 + 文件归档 + 索引建立
场景2:网络流处理
// HTTP响应流的多次处理
byte[] responseData = httpResponse.getInputStream().readAllBytes();
saveToCache(new ByteArrayInputStream(responseData));
parseContent(new ByteArrayInputStream(responseData));
场景3:分布式系统
// 消息队列中的文件处理
byte[] fileData = message.getFileData();
sendToOCRService(new ByteArrayInputStream(fileData));
sendToStorageService(new ByteArrayInputStream(fileData));
总结要点
🎯 问题核心
- 表面现象:上传成功但文件损坏
- 根本原因:InputStream重复读取导致数据不完整
- 影响范围:所有需要多次处理同一文件的场景
🛠️ 解决要点
- 一次读取:
byte[] data = stream.readAllBytes()
- 多次使用:
new ByteArrayInputStream(data)
- 数据完整性:确保每次处理都使用完整数据
🏆 方案优势
- 彻底解决:消除流重复读取问题
- 数据安全:保证文件完整性
- 跨平台:所有环境表现一致
- 易维护:代码逻辑清晰,便于调试
📝 记忆口诀
"先存桶里,再分别倒" 🪣→🥤🧴
- 🪣 byte[]数组存储完整数据
- 🥤 OCR使用数据副本
- 🧴 上传使用数据副本