代码以及注释在最下面
一、业务场景
这是一个分布式文件监听与同步系统,核心功能为:
- 实时监控FTP服务器上指定目录的文件变化(如气象观测设备生成的数据文件)。
- 自动下载新增或更新的文件到本地存储,并记录元数据。
- 通过Kafka消息队列通知下游系统(如数据处理中心DPC)处理文件。
- 支持历史数据批量下载与异步处理,确保数据完整性。
- 适用于气象、物联网等领域,需实时采集多设备数据并触发后续分析的场景。
二、核心流程与代码解析
1. 定时扫描FTP目录(FileListener.scanFtp
)
- 触发机制:通过
@Scheduled(fixedDelay = 1000 * 30)
每30秒执行一次。 - 设备遍历:遍历配置中的设备列表(
ftpClientUtilProperties.getDeviceLogin()
),每个设备对应不同的FTP连接参数(如雷达、激光雷达等)。 - 文件获取:
- 调用
ftpClientUtil.getRemoteDirFtpFiles()
获取FTP目录下的文件列表。 - 重试机制:若首次获取失败,清理连接池(
ftpClientUtil.clear()
)并重试最多5次,确保网络波动时的稳定性。
- 调用
- 文件过滤:
- 正则匹配:通过
ReUtil.isMatch()
筛选符合命名规则的文件(如Z_URAD_CR_54399_20220501000000
)。 - 时间范围:仅处理24小时内生成的文件(
cTime - dataTime.getTime() < 24h
),避免历史数据干扰。
- 正则匹配:通过
2. 文件下载与本地存储
- 本地路径生成:
getLocalDestPath()
根据设备类型、站点号、日期等生成层级目录(如/data/54399/cloud_radar/2022/05/01
),便于分类存储。 - 防重复下载:
- 检查本地是否存在
.check
标记文件,若存在则跳过下载。 - 下载完成后创建
.check
文件,作为下载完成的凭证。
- 检查本地是否存在
- 异常处理:
- 文件大小过小(
<10B
)时视为无效文件,等待下次同步。 - 捕获异常并记录日志,避免单文件错误导致整体任务中断。
- 文件大小过小(
3. 元数据处理与Kafka通知
- 站点与设备映射:
- 解析文件名获取站点号(如
54399
),通过syncService.getDeviceId()
查询数据库或Redis缓存,转换为内部设备ID。 - Redis缓存:使用
KEY_LAST_TIME
记录各站点的最新文件时间,确保按时间顺序处理数据。
- 解析文件名获取站点号(如
- Kafka消息发送:
- 构建
FileDto
对象,包含设备标签、站点号、文件路径等元数据。 - 发送至动态主题
mco_filelog_{device}
,不同设备数据隔离,便于下游按需消费。 - 示例消息内容:
{"deviceTag":"cloud_radar", "stationNum":"54399", "absPath":"/data/.../Z_URAD_CR_54399_20220501000000"}
。
- 构建
4. 历史数据批量处理(SyncService.history
)
- 异步触发:通过
@Async
注解实现异步执行,避免阻塞实时任务。 - ZIP包处理:
- 下载指定日期的ZIP压缩包(如
20220501.zip
)到临时目录。 - 解压后筛选有效文件,移动至正式存储路径,并发送Kafka消息。
- 下载指定日期的ZIP压缩包(如
- 清理机制:删除临时解压文件和空目录,释放存储空间。
5. FTP连接管理(FTPClientUtil
接口)
- 连接池管理:通过
ProducerFactory
模式管理FTP客户端,支持多设备并行连接。 - 核心操作:
- 文件下载(
get()
)、上传(put()
)、移动(move()
)、删除(deleteFile()
)。 - 目录遍历(
getRemoteDirFtpFiles()
)和路径检查(isRemotePathExist()
)。
- 文件下载(
6. Kafka集成(KafkaTemplate
)
- 消息发送:封装Spring Kafka的
KafkaTemplate
,支持同步/异步发送、事务管理。 - 事务支持:通过
executeInTransaction()
确保消息发送与文件下载的原子性。 - 动态主题:根据设备类型动态生成主题名,实现数据路由。
三、关键参数与配置
-
FTP设备配置(
FTPClientUtilProperties.LoginInfo
):device
:设备类型标识(如微波辐射计、云雷达)。homePath
:FTP服务器上的监控目录。regexFilter
:文件名正则表达式(如.*\.DAT
),用于过滤目标文件。
-
本地存储路径:
Const.PATH_FILE
:基础存储目录(如/data
)。- 层级结构:
城市ID/站点号/设备类型/年/月/日
,便于按时空维度管理。
-
Redis键设计:
KEY_LAST_TIME
:记录各站点的最新文件时间戳,格式为last_time_{站号}_{设备类型}
。DEVICE_FIRST_DEVICEID_PIDCODE_KEY
:缓存站点与设备ID的映射关系,减少数据库查询。
-
Kafka配置:
- 主题命名规则:
mco_filelog_{device}
,按设备类型隔离消息。 - 序列化方式:
FileDto
对象转为JSON字符串发送。
- 主题命名规则:
四、异常处理与优化
- 连接故障:FTP连接失败时清理连接池并重试,避免僵尸连接。
- 数据一致性:
- 通过
.check
文件标记下载完成,防止重复处理。 - Redis记录最新时间戳,确保按时间顺序处理文件。
- 通过
- 性能优化:
- 并行流处理(
parallelStream()
)加速文件过滤与下载。 - 异步历史数据处理(
@Async
)与实时任务解耦。
- 并行流处理(
五、技术栈与架构
- 核心框架:Spring Boot(定时任务、依赖注入)、Spring Kafka。
- 存储组件:FTP服务器(原始数据)、本地文件系统(持久化)、Redis(元数据缓存)。
- 消息中间件:Kafka实现解耦与流量削峰。
- 监控与容错:重试机制、连接池管理、事务支持。
六、典型应用场景
- 气象数据采集:实时监控雷达、辐射计等设备生成的数据文件,触发风场反演、降水预测等分析。
- 工业物联网:采集传感器数据文件,实时推送至数据分析平台。
- 日志聚合:集中采集分布式系统的日志文件,供ELK分析。
/**
* 定时同步文件目录,已经下载的文件存入Redis,4天之后过期
*/
@Scheduled(fixedDelay = 1000*30)
public void scanFtp() {
//查询每种设备
for (FTPClientUtilProperties.LoginInfo loginInfo : ftpClientUtilProperties.getDeviceLogin()) {
String device = loginInfo.getDevice();
String homePath = loginInfo.getHomePath();
if(StrUtil.isBlank(homePath)){
homePath = "";
}
try {
int retryTimes = 5;
//获取根目录下的文件
List<FTPFile> ftpFiles = null;
try {
ftpFiles = ftpClientUtil.getRemoteDirFtpFiles(device, homePath);
}catch (Exception e){
log.error( device+":获取文件失败,清理连接池并重试!",e);
ftpClientUtil.clear(device);
ftpFiles = ftpClientUtil.getRemoteDirFtpFiles(device, homePath);
}
while (ftpFiles.size() == 0 && retryTimes > 0){
logger.info("{}获取文件数量为0,开始清理连接池并重试,剩余重试次数:{}。",device,retryTimes);
ftpClientUtil.clear(device);
ftpFiles = ftpClientUtil.getRemoteDirFtpFiles(device, homePath);
retryTimes--;
}
Set<String> rawNames = ftpFiles.stream().map(FTPFile::getName).filter(fileName-> ReUtil.isMatch(loginInfo.getRegexFilter(),fileName.toUpperCase())).collect(Collectors.toSet());
logger.info("{}总计{}个文件,匹配{}个文件。",device,ftpFiles.size(),rawNames.size());
//过滤一天内的文件
Set<String> names = Collections.synchronizedSet(new HashSet<>());
long cTime = System.currentTimeMillis();
int lastMil = 1 * 24 * 60 * 60 * 1000;
rawNames.parallelStream().forEach(e->{
Date dataTime = getDataTime(e,device);
if(dataTime == null){
return;
}
if(cTime-dataTime.getTime()< lastMil){
names.add(e);
}
});
logger.info("{},一天内共{}个文件。",device,names.size());
//下载不同的文件
String finalHomePath = homePath;
names.parallelStream().forEach(fileName->{
try {
String localDestPath = getLocalDestPath(device, fileName);
File checkDestFile = new File(localDestPath+File.separator+fileName+".check");
//判断文件是否在本地目录
if(FileUtil.exist(checkDestFile)){
log.debug("文件已存在本地目录:{}",fileName);
return;
}
//构建文件信息对象,用于向dpc发送
String stationNum = getStationNum(fileName);
Integer deviceId = syncService.getDeviceId(device, stationNum);
if(deviceId == null){
log.debug("设备ID不存在,不下载此文件:{}",fileName);
return;
}
//从ftp服务器下载文件,下载后的文件名与ftp服务器上的文件名一致
ftpClientUtil.get(device,localDestPath, finalHomePath +fileName);
File destFile = new File(localDestPath+File.separator+fileName);
//文件太小就不同步,等待下一次同步
if(destFile.length() < 10){
return;
}
//在本地生成一个检查文件是否存在的文件
checkDestFile.createNewFile();
//记录最后到达时间,last_time_{站号}_{设备类型:例如 Const.TAG_WBFS} 示例:last_time_54399_wbfs
Date dataTime = getDataTime(fileName, device);
//判断是否是衢州站,衢州站的云雷达,激光雷达,微波辐射计,是世界时要向后加8小时
dataTime = McoUtil.getFileDataTime(stationNum,dataTime);
ArrayList<String> stringStationList = McoUtil.getHzStationList();
//杭州地区改为世界时需要添加8小时 云雷达与微波辐射计
if(stringStationList.contains(stationNum) && (Const.TAG_CLOUDRADA.equals(device) || Const.TAG_WBFS.equals(device))){
dataTime = DateUtil.offsetHour(dataTime,8) ;
}
//获取记录的最新时间,避免读取历史数据导致时间错乱
String lastTimeKey = Const.KEY_LAST_TIME + stationNum.toUpperCase() + "_" + device;
Object lastTimeStr = RedisUtil.get(lastTimeKey);
if(lastTimeStr != null){
DateTime lastTime = DateUtil.parseDateTime(lastTimeStr.toString());
if(lastTime.getTime()<dataTime.getTime()){
RedisUtil.set(lastTimeKey,DateUtil.formatDateTime(dataTime));
}
}else{
RedisUtil.set(lastTimeKey,DateUtil.formatDateTime(dataTime));
}
FileDto fileDto = new FileDto();
fileDto.setDeviceTag(device);
fileDto.setDeviceId(deviceId);
fileDto.setCityId(Const.CITY_ID);
fileDto.setStationNum(stationNum.toUpperCase());
fileDto.setAbsPath(localDestPath+"/"+fileName);
kafkaTemplate.send("mco_filelog_"+device, JSONUtil.toJsonStr(fileDto));
log.info("文件下载成功:{},文件大小:{}",fileName, FileUtil.readableFileSize(destFile));
} catch (Exception exception) {
log.error("文件异常"+fileName,exception);
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static String getLocalDestPath(String device, String fileName) throws Exception{
String[] nameSplits = fileName.split("_");
String stationNum = nameSplits[3];
String year = nameSplits[4].substring(0, 4);
String month = nameSplits[4].substring(4, 6);
String day = nameSplits[4].substring(6, 8);
return Const.PATH_FILE+"/"+Const.CITY_ID+ "/"+stationNum+ "/"+device+"/"+year+"/"+month+"/"+day;
}
public static String getStationNum(String fileName){
String[] nameSplits = fileName.split("_");
return nameSplits[3];
}
/**
* 可获取文件名中包含yyyyMMddHHmmss格式的时间
* 仅能获取20世纪的时间
* @param fileName 文件名
* @return java.util.Date
*
**/
public static Date getDataTime(String fileName,String device) {
List<String> allGroup0 = ReUtil.findAllGroup0("(20\\d\\d\\d{0,10})", fileName);
DateTime parse;
if(allGroup0.size() == 0){
String[] nameArr = fileName.split("_");
parse = DateUtil.parse(nameArr[4]);
}else{
parse = DateUtil.parse(allGroup0.get(0));
}
if(Const.TAG_WPR_RADAR.equals(device) || Const.TAG_CDWL.equals(device) ){
parse = DateUtil.offsetHour(parse,8);
}
return parse;
}
}
package com.smart.service;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.ZipUtil;
import cn.hutool.json.JSONUtil;
import com.smart.common.Const;
import com.smart.common.dto.FileDto;
import com.smart.ftp.FTPClientUtil;
import com.smart.ftp.FTPClientUtilProperties;
import com.smart.listener.FileListener;
import com.smart.model.Device;
import com.smart.redis.RedisUtil;
import org.apache.commons.net.ftp.FTPFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipFile;
/**
* 文件同步类
*
*/
@Service
public class SyncService {
private final static Logger logger = LoggerFactory.getLogger(SyncService.class);
private final static String DEVICE_FIRST_DEVICEID_PIDCODE_KEY = "device_first_deviceId_pidCode_key_";
@Autowired
private KafkaTemplate kafkaTemplate;
@Autowired
FTPClientUtilProperties ftpClientUtilProperties;
@Autowired
private FTPClientUtil ftpClientUtil;
/**
* 异步下载历史数据文件,并触发解析
*
*/
@Async
public synchronized void history(List<Date> dates){
for (Date date : dates) {
try {
//服务端和本地文件的日期路径和文件前缀
String datePath = DateUtil.format(date, "yyyy/MM/dd/");
String filePrefix = DateUtil.format(date, "yyyyMMdd");
//遍历每一个设备
for (FTPClientUtilProperties.LoginInfo loginInfo : ftpClientUtilProperties.getDeviceLogin()) {
String homePath = loginInfo.getHomePath();
String device = loginInfo.getDevice();
String remotePath = homePath + "/"+ datePath + filePrefix + ".zip";
String zipDestPath = Const.PATH_TEMP_FILE+ "/"+device+ "/"+datePath+ filePrefix + ".zip";
try {
//获取压缩包
ftpClientUtil.get(device,zipDestPath, remotePath);
//解压并删除压缩包
File zipFile = new File(zipDestPath);
ZipUtil.unzip(zipFile);
FileUtil.del(zipFile);
//列出解压后的所有文件
List<File> dataFiles = FileUtil.loopFiles(zipFile.getParentFile());
//过滤所需的文件
Set<File> filterFiles = dataFiles.stream().filter(file-> ReUtil.isMatch(loginInfo.getRegexFilter(),file.getName())).collect(Collectors.toSet());
//每一个文件都要先分拣,再发送kafka通知dpc处理
for (File dataFile : filterFiles) {
//分拣的目标地址
String localDestPath = FileListener.getLocalDestPath(device, dataFile.getName());
File destPath = new File(localDestPath);
FileUtil.move(dataFile, destPath,true);
String fileName = destPath.getName();
//构建文件信息对象,用于向dpc发送
String stationNum = FileListener.getStationNum(fileName);
Integer deviceId = getDeviceId(device, stationNum);
FileDto fileDto = new FileDto();
fileDto.setDeviceTag(device);
fileDto.setDeviceId(deviceId);
fileDto.setCityId(Const.CITY_ID);
fileDto.setStationNum(stationNum);
fileDto.setAbsPath(destPath.getAbsolutePath());
kafkaTemplate.send("mco_filelog_"+device, JSONUtil.toJsonStr(fileDto));
}
//执行完毕之后删除解压后的文件,此时有用的文件已经挪走了
FileUtil.del(zipFile.getParentFile());
//记录当前日期已经下载过了
RedisUtil.set(Const.KEY_HISTORY_DATE+date,true);
} catch (Exception e) {
e.printStackTrace();
}
}
}catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 获取具体设备号
* @param pidCode
* @param stationNum
* @return
*/
public Integer getDeviceId(String pidCode,String stationNum){
Object deviceId = RedisUtil.get(DEVICE_FIRST_DEVICEID_PIDCODE_KEY + pidCode + stationNum);
if (null != deviceId) {
return Integer.parseInt(deviceId.toString());
} else {
Device res = new Device().findFirst("SELECT d.id FROM device d INNER JOIN device_category c ON c.id = d.dcId INNER JOIN device_category cc ON cc.id = c.pid "
+ "INNER JOIN station s ON s.id = d.stationId WHERE s.num = ? AND cc.`code` = ?", stationNum, pidCode);
if (res == null) {
return null;
}
RedisUtil.set(DEVICE_FIRST_DEVICEID_PIDCODE_KEY + pidCode + stationNum, res.getId());
return res.getId();
}
}
public Integer test(String device,String homePath) {
try {
logger.info("{}:{}",device,homePath);
List<FTPFile> ftpFiles = ftpClientUtil.getRemoteDirFtpFiles(device, homePath);
if(ftpFiles.size() == 0){
ftpClientUtil.clear(device);
}
return ftpFiles.size();
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
}
package com.smart.ftp;
import org.apache.commons.net.ftp.FTPFile;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
/**
*
*/
public interface FTPClientUtil {
/**
* 上传文件到ftp服务器
*
* @param localInputStream 本地输入流,即需要传输到服务器的数据
* @param remotePathUri ftp服务器目录
* @param remoteFilename 上传后,在ftp服务器上的文件名
* @param suffix 文件上传时添加的文件后缀,上传完成时,重名为name; suffix 为null 时,则不加后缀
* @throws Exception 连接不到ftp服务器,登录失败,文件上传失败等异常
*/
void put(
String device,
InputStream localInputStream,
String remotePathUri,
String remoteFilename,
String suffix) throws Exception;
/**
* 上传文件到ftp服务器 重载put()函数,将InputStream参数变为File
*
* @param localFile 本地文件,File类型参数
* @param remoteDir ftp服务器目录
* @param remoteFilename 上传后,在ftp服务器上的文件名
* @param suffix 文件上传时添加的文件后缀,上传完成时,重名为name; suffix 为null 时,则不加后缀
* @throws Exception 连接不到ftp服务器,登录失败,文件上传失败等异常
*/
void put(
String device,
File localFile,
String remoteDir,
String remoteFilename,
String suffix) throws Exception;
/**
* 上传文件到ftp服务器 重载put()函数,将InputStream参数变为 本地文件路径
*
* @param localFileAbsolutePathUri 本地文件路径
* @param remoteDir ftp 服务器目录
* @param remoteFilename 上传后,在ftp服务器上的文件名
* @param suffix 文件上传时添加的文件后缀,上传完成时,重名为name; suffix 为null 时,则不加后缀
* @throws Exception 连接不到ftp服务器,登录失败,文件上传失败等异常
*/
void put(
String device,
String localFileAbsolutePathUri,
String remoteDir,
String remoteFilename,
String suffix) throws Exception;
/**
* 从ftp服务器下载文件,并写入到本地outputStream中
*
* @param localOutputStream 本地保存数据的输出流, 即下载的数据将写入该输出流
* @param remoteDir ftp 服务器目录
* @param remoteFilename 要下载的文件名
* @throws Exception ftp连接,登录失败,传输失败等异常。
*/
void get(String device,OutputStream localOutputStream, String remoteDir, String remoteFilename) throws Exception;
/**
* 从ftp服务器下载文件
*
* @param localFile 本地文件, File类型参数
* @param remoteDir ftp 服务器目录
* @param remoteFilename 要下载的文件名
* @throws Exception
*/
void get(String device,File localFile, String remoteDir, String remoteFilename) throws Exception;
/**
* 从ftp服务器下载文件, 下载后的文件名与ftp服务器上的文件名一致
*
* @param localFileDir 本地路径
* @param remoteDir ftp 服务器目录
* @param remoteFilename 要下载的文件名
* @throws Exception
*/
void get(String device,String localFileDir, String remoteDir, String remoteFilename) throws Exception;
/**
* 从ftp服务器下载文件,下载后的文件名与ftp服务器上的文件名一致
*
* @param localDir 本地路径
* @param remoteFilePathUri ftp服务器上文件的完整路径名(包括文件名)
* @throws Exception
*/
void get(String device,String localDir, String remoteFilePathUri) throws Exception;
/**
* ftp 服务器文件move
*
* @param remoteSrcDir FTP远程源目录
* @param remoteDestDir FTP远程目的目录
* @param remoteFilename FTP远程文件名
* @throws Exception 连接不到ftp服务器,登录失败,文件上传失败等异常
*/
void move(String device,String remoteSrcDir, String remoteDestDir, String remoteFilename) throws Exception;
/**
* ftp 服务器文件copy
*
* @param remoteSrcDir FTP远程源目录
* @param remoteDestDir FTP远程目的目录
* @param remoteSrcFilename FTP远程文件名
* @param suffix 后缀
* @param remoteNewFilename 重命名后名字(为空则不重命名)
* @throws Exception 连接不到ftp服务器,登录失败,文件上传失败等异常
*/
void copy(String device,
String remoteSrcDir,
String remoteDestDir,
String remoteSrcFilename,
String suffix,
String remoteNewFilename) throws Exception;
/**
* 刪除ftp服務器上的文件
*
* @param remoteFileAbsolutePathUri 文件在ftp服務器上的全路徑(包括文件名)
* @return true 成功刪除, false 刪除失敗。
* @throws Exception
*/
boolean deleteFile(String device,String remoteFileAbsolutePathUri) throws Exception;
/**
* 刪除ftp服務器上的目录(必须确保删除执行该方法时,需删除的目录下不能存在文件否则删除失败)
*
* @param remoteDir 文件夹路径
* @return true 成功刪除, false 刪除失敗。
* @throws Exception
*/
boolean removeDirectory(String device,String remoteDir) throws Exception;
/**
* 获取ftp服务目录下文件或目录名,
*
* @param remoteDir ftp服务器路径
* @return 注意返回的路径是全路径名
* @throws Exception
*/
List<String> getRemoteDirFilenames(String device,String remoteDir) throws Exception;
/**
* 获取ftp服务器目录下的文件或目录
*
* @param remoteDir ftp服务器路径
* @return 返回 FTPFile对象, FTPFile对象可能是文件或目录,
* 通过FTPFile.isFile或FTPFile.isDirectory判断,获取单独的文件通过FTPFile.getName()函数获取
* @throws Exception
* @Description: 获取ftp服务器目录下的文件或目录
*/
List<FTPFile> getRemoteDirFtpFiles(String device,String remoteDir) throws Exception;
/**
* 获取远程ftp目录中的所有的文件
*
* @param remoteDir 用户访问ftp路径
* @param recursive 是否扫描子文件夹
* @return 文件列表
* @throws Exception
*/
List<RemoteFileInfo> getRemoteFileByDir(String device,String remoteDir, boolean recursive) throws Exception;
/**
* 获取远程ftp目录中的所有的文件
*
* @param remoteDir 用户访问ftp路径
* @return 文件列表
* @throws Exception
*/
List<RemoteFileInfo> getRemoteFileByDir(String device,String remoteDir) throws Exception;
/**
* 判断远程ftp目录或文件是否存在
* @param remotePath
* @return
*/
boolean isRemotePathExist(String device,String remotePath) throws Exception;
void clear(String device);
}