时间:2024.3.11--2024.3.24
概况:在年前就有初步构想,年后3月多开始进行开发,于清明假前完成大体功能的开发。后续还有一些bug修改,那段时间天天加班,还没有加班费。
在做完之后,就开始交付,清明假期前成功在一个小银行部署,并开始使用。
以下的代码都是当时调研时自己做的demo
需求:
设计一个功能,实现数据备份和恢复功能。备份包括自动备份和手动备份,可以在windows,linux或者u盘进行数据备份,并且备份要求是全量或者增量的。数据包括文件数据和数据库数据,并且因为是银行的业务,所以对于数据的安全性上的要求很高。
表结构
backup_info表
字段:
/**
* 编号backup_info
*/
@TableId(type = IdType.AUTO)
private Integer id;
/**
* 备份时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date backupDate;
/**
* 操作方式
*/
private String operativeWay;
/**
* 备份方式
*/
private String backupWay;
/**
* 存储介质
*/
private String storageMedium;
/**
* 文件位置
*/
private String fileLocation;
/**
* 文件名称
*/
private String fileName;
/**
* 状态
*/
private String status;
/**
* 系统版本
*/
private String version;
项目结构
config
AutomaticConfig
存储自动备份的相关信息
@Value("${database.selectMonth}")
private Integer selectMonth;
@Value("${database.selectWeek}")
private Integer selectWeek;
@Value("${database.backupIsOpen}")
private Boolean backupIsOpen;
@Value("${database.selectBackupFrequency}")
private Integer selectBackupFrequency;
@Value("${database.selectTime}")
private String selectTime;
@Value("${database.automaticBackupSavePath}")
private String automaticBackupSavePath;
BackupConfig
备份的源文件,数据库等
private String username;
private String password;
private String host;
private String port;
private String mysqlDumpPath;
private String databaseName;
private String sourceDirectory;
private String version;
private String suggestFilePath;
RecoverConfig
恢复的
数据
- sql文件数据
- 上传的文件数据
- 日志文件数据
流程实现:
大体流程
其实就4大步,数据备份,数据加密,数据解密,数据恢复。
大体是先备份数据库文件,然后给数据库文件设计一个MD5值用来判断文件的完整性,然后使用AES对这个sql文件进行加密。然后备份上传文件,备份之后将文件夹压缩成一个zip文件(因为源文件夹东西太多),然后对这个zip文件进行MD5和AES加密,日志文件同理。
恢复时先校验文件完整性,然后解密。然后对于数据库文件直接恢复,对于压缩文件,则是需要将文件恢复到指定目录,然后删除多余项。
- 在配置环境的时候,自己配置文件的相关属性,(数据库地址,用户名,密码,port端口,数据文件,数据库文件路径这些),并且使用@ConfigurationProperties注解将这些属性自动映射到BackupConfig配置类中。
- 在手动备份的时候,系统会有一个默认的推荐路径(自动备份则是默认使用这个路径)
- 路径选好之后,开始备份数据,数据包括数据库文件和资料文件(主要是pdf文件),首次备份的话是全量备份,之后的备份是增量备份,节省时间和空间。使用日志文件保存备份信息。
- 自动备份也是一样的道理,但基本都是增量备份,用户可以自己设置更新时间(每天,每周,或者指定天数。不过银行一般都是每天晚上10点更新数据)
- 因为文件太大,所以采用MD5方法,设置一个MD5值,用于文件完整性检验,在备份之后对文件进行加密操作。
- 恢复数据,校验文件完整性,然后对加密文件进行解密,使用mysqldump恢复sql文件,解压缩zip文件到指定的目录,并删除多余目录。
1.获取磁盘空间
无论是备份还是恢复数据之前,都需要先获取磁盘空间
代码:
public static Map<String, String> getDiskSpaceInfo(String path) throws Exception {
File file = new File(path);
if (!file.exists() || !file.isDirectory()) {
throw new IllegalArgumentException("路径不存在或不是有效的目录");
}
long totalSpace = file.getTotalSpace();
long freeSpace = file.getFreeSpace();
float percentageUsed = ((float) (totalSpace - freeSpace) / totalSpace) * 100;
DecimalFormat df = new DecimalFormat("#0.00");
Map<String, String> diskSpaceInfo = new HashMap<>();
diskSpaceInfo.put("path", file.getCanonicalPath());
diskSpaceInfo.put("totalSpace", transformation(totalSpace));
diskSpaceInfo.put("freeSpace", transformation(freeSpace));
diskSpaceInfo.put("percentageUsed", Double.parseDouble(df.format(percentageUsed)) + "%");
System.out.println(diskSpaceInfo);
return diskSpaceInfo;
}
传入路径之后,先判断这条路径是否存在,并且是否是一个有效的目录
然后通过File(文档:File (Java 2 Platform SE 6) (oracle.com))里的方法,获取磁盘总空间和剩余空间,并计算已使用空间的百分比。
然后将数据存入diskSpaceInfo列表中返回
2.推荐目录
适配不同的设备(windows系统,linux系统,U盘),创建指定目录,创建文件夹
代码:
public void suggestDirectory() {
usbPaths.clear();
String suggestFilePath = backupConfig.getSuggestFilePath();
String osName = System.getProperty("os.name").toLowerCase();
File suggestFile ;
if (osName.contains("windows")) {
suggestFile = new File("D:" + suggestFilePath);
fullSuggestFilePath = ("D:" + suggestFilePath);
} else if (osName.contains("linux")) {
String username = System.getProperty("user.name");
String mediaDir = "/media/" + username + "/";
File mediaDirectory = new File(mediaDir);
File[] directories = mediaDirectory.listFiles();
suggestFile = new File(suggestFilePath);
fullSuggestFilePath = suggestFilePath;
if (directories != null) {
for (File directory : directories) {
if (directory.isDirectory()) {
usbPaths.add(directory.getAbsolutePath());
log.info("找到U盘: " + directory.getAbsolutePath());
}
}
log.info("找到U盘数量: " + usbPaths.size() + "找到U盘路径: "+ usbPaths);
}
}
else {
log.info("不支持的操作系统");
return;
}
//如果目录不存在则创建
if (!suggestFile.exists()) {
if (suggestFile.mkdirs()) {
log.info("创建推荐目录成功");
} else {
log.info("创建推荐目录失败");
throw new RuntimeException("创建推荐目录失败" + suggestFilePath);
}
} else {
log.info("推荐目录已存在" + suggestFilePath);
}
}
-
操作系统判断:
- 如果是Windows系统,创建一个
File对象指向D:盘下的推荐路径。 - 如果是Linux系统,首先获取当前用户的名称,然后构建媒体目录的路径。接着,列出媒体目录下的所有目录,并检查它们是否是USB设备。如果是,将它们的绝对路径添加到
usbPaths列表中,并记录日志。
- 如果是Windows系统,创建一个
-
记录USB设备信息:如果Linux系统中找到了USB设备,记录找到的USB设备数量和路径。
识别外部存储:在许多应用程序中,特别是涉及到文件备份、数据传输或移动应用中,识别连接的USB设备(如U盘、移动硬盘等)是必要的步骤,以便用户可以选择存储数据的目标位置。
-
不支持的操作系统:如果操作系统既不是Windows也不是Linux,记录日志并返回,不执行后续操作。
-
检查推荐目录是否存在:如果推荐目录不存在,则尝试创建它。
- 如果创建成功,记录日志。
- 如果创建失败,记录日志并抛出运行时异常。
-
目录已存在:如果推荐目录已经存在,记录日志。
数据备份
1.Mysql数据备份
备份MySQL数据库里的数据,使用的是mysqldump,会生成一个sql文件
String sqlFilePath = "";
if (!savePath.endsWith(File.separator)) {
savePath = savePath + File.separator;
}
PrintWriter printWriter = null;
BufferedReader bufferedReader = null;
try {
printWriter = new PrintWriter(new OutputStreamWriter(new FileOutputStream(savePath + "temp" + fileName), StandardCharsets.UTF_8));
sqlFilePath = savePath + "temp" + fileName;
//导出指定数据库指定表的结构和数据
Process process = Runtime.getRuntime().exec(mysqldump + " -h "+ip + " -u" + user + " -p" + password +" -P"+port+ " --set-charset=UTF8 " + db);
InputStreamReader inputStreamReader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8);
bufferedReader = new BufferedReader(inputStreamReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
printWriter.println(line);
}
printWriter.flush();
//0 表示线程正常终止。
process.waitFor();
} catch (IOException | InterruptedException e) {
e.printStackTrace();
checkAndDeleteDirectories(baseBackupPath);
// 处理文件未找到、IO、线程中断等异常
} finally {
try {
if (bufferedReader != null) {
bufferedReader.close();
}
if (printWriter != null) {
printWriter.close();
}
} catch (IOException e) {
e.printStackTrace();
checkAndDeleteDirectories(baseBackupPath);
// 处理关闭流产生的异常
}
}
-
方法参数:
baseBackupPath:基础备份路径,可能用于日志记录或错误处理。savePath:保存备份文件的路径。mysqldump:mysqldump工具的路径或命令。ip:MySQL服务器的IP地址。port:MySQL服务器的端口号。user:连接MySQL服务器的用户名。password:连接MySQL服务器的密码。db:要备份的数据库名称。
-
初始化:
sqlFilePath:用于存储生成的SQL文件的完整路径。
-
检查和修正
savePath:- 如果
savePath不以文件分隔符结尾,就添加文件分隔符。
- 如果
-
资源初始化:
PrintWriter和BufferedReader分别用于写入文件和读取mysqldump命令的输出。
-
执行
mysqldump命令:- 使用
Runtime.getRuntime().exec()执行mysqldump命令,包括数据库连接参数和要备份的数据库名称。 - 使用
--set-charset=UTF8确保导出的数据使用UTF-8编码。
- 使用
-
读取和写入数据:
- 从
mysqldump命令的标准输出读取数据,并将每一行写入到savePath指定的文件中。
- 从
-
刷新和等待:
- 调用
printWriter.flush()确保所有数据都被写入到文件。 - 调用
process.waitFor()等待mysqldump命令执行完成。
- 调用
-
异常处理:
- 如果在执行过程中发生
IOException或InterruptedException,将打印异常堆栈跟踪,并调用checkAndDeleteDirectories方法(未在代码中定义)进行错误处理。
- 如果在执行过程中发生
-
关闭资源:
- 在
finally块中,确保BufferedReader和PrintWriter都被关闭,以释放资源。 - 如果关闭资源时发生异常,同样调用
checkAndDeleteDirectories方法。
- 在
-
返回结果:
- 方法返回
sqlFilePath,即包含数据库备份的SQL文件路径。
- 方法返回
2.全量备份源文件
递归地备份指定源目录下的所有文件到目标目录,并且只备份在指定时间戳lastBackupTimestamp之后被修改过的文件。
public static void backupFiles(String sourceDirectory, String destinationDirectory, long lastBackupTimestamp) throws IOException {
try {
File sourceDir = new File(sourceDirectory);
File[] files = sourceDir.listFiles();
if (files != null) {
for (File file : files) {
boolean cancelBackup = DatabaseServiceImpl.cancelBackup;
if (cancelBackup) {
System.out.println("备份取消-文件备份-首次");
break;
}
if (file.isDirectory()) {
File destinationDir = new File(destinationDirectory, file.getName());
backupFiles(file.getAbsolutePath(), destinationDir.getAbsolutePath(), lastBackupTimestamp);
} else if (file.isFile() && file.lastModified() >= lastBackupTimestamp) {
// 执行文件备份操作
FileUtils.copyFileToDirectory(file, new File(destinationDirectory));
}
}
if (!new File(destinationDirectory).exists()) {
FileUtils.forceMkdir(new File(destinationDirectory));
}
} else {
throw new IOException("Unable to list files in source directory: " + sourceDirectory);
}
} catch (IOException e) {
System.err.println("An IO exception occurred during file backup: " + e.getMessage());
throw e;
}
}
- 获取源目录中的文件列表:调用
sourceDir.listFiles()获取源目录中的所有文件和目录列表。 - 检查文件列表:如果
files不为空,进入循环处理每个文件。 - 检查备份是否被取消:在备份每个文件之前,检查
DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。 - 递归处理目录:如果当前文件是一个目录,则创建目标目录,并递归调用
backupFiles方法。 - 备份修改过的文件:如果当前文件是一个常规文件,并且它的最后修改时间大于或等于
lastBackupTimestamp,则使用FileUtils.copyFileToDirectory方法将文件复制到目标目录。
将备份数据写入日志文件
日志记录:记录备份过程中的所有关键步骤和任何异常,这有助于在出现问题时进行调试和恢复。保证安全性
Files.walk(sourcePath)
.filter(path -> !Objects.equals(path, sourcePath)) // 排除源路径本身
.forEach(path -> {
String relativePath = sourcePath.relativize(path) + System.lineSeparator();
try {
writer.write(relativePath);
} catch (IOException e) {
System.err.println("An IO exception occurred while writing to log file: " + e.getMessage());
}
});
使用File里的Files.walk递归遍历文件树,将文件信息存入日志文件。
利用时间戳增量备份
获取时间戳
从文件中获取时间戳,并且有很多时间戳,代表多次备份数据
public static long[] getLastBackupTimestamp(String TIMESTAMP_FILE) throws IOException {
long[] result = new long[2]; // 数组存放时间戳和计数值
File timestampFile = new File(TIMESTAMP_FILE);
if (!timestampFile.exists()) {
return result;
} else {
try {
String[] lines = FileUtils.readLines(timestampFile, "UTF-8").toArray(new String[0]);
if (lines.length > 0) {
String lastLine = lines[lines.length - 1]; // 获取最后一行数据
String[] parts = lastLine.split(DELIMITER);
result[0] = Long.parseLong(parts[0]); // 时间戳
result[1] = Long.parseLong(parts[1]); // 计数值
} else {
throw new IOException("时间戳文件为空,无法获得最后备份的时间戳和计数值");
}
} catch (IOException | NumberFormatException e) {
e.printStackTrace();
throw new IOException();
}
}
return result;
}
更新写入时间戳
将时间戳写入一个新的文件之中。并且FileUtils.writeStringToFile(new File(TIMESTAMP_FILE), timestampWithCount, "UTF-8", false);中设置false参数,标识没有这个文件,不创建。
public static void updateLastBackupTimestamp(String TIMESTAMP_FILE, int count) {
try {
String currentTimestampStr = String.valueOf(System.currentTimeMillis());
String countStr = String.valueOf(count + 1); // 将 count 加一
String timestampWithCount = currentTimestampStr + DELIMITER + countStr;
FileUtils.writeStringToFile(new File(TIMESTAMP_FILE), timestampWithCount, "UTF-8", false);
} catch (IOException e) {
e.printStackTrace();
}
}
增量备份
在两次备份之间只备份那些新修改或新增的文件。
public static void addFilesToBackup(String lastBackupLog, String currentBackupLog, String sourcePath, String backupPath) {
try {
Set<String> lastBackupFiles = new HashSet<>();
Set<String> currentBackupFiles = new HashSet<>();
// 读取上次备份日志的文件路径列表
BufferedReader lastReader = new BufferedReader(new FileReader(lastBackupLog));
String line;
while ((line = lastReader.readLine()) != null) {
lastBackupFiles.add(line);
}
lastReader.close();
// 读取此次备份日志的文件路径列表
BufferedReader currentReader = new BufferedReader(new FileReader(currentBackupLog));
while ((line = currentReader.readLine()) != null) {
currentBackupFiles.add(line);
}
currentReader.close();
// 获取此次备份日志中特有的文件路径
currentBackupFiles.removeAll(lastBackupFiles);
for (String file : currentBackupFiles) {
Path sourceFilePath = Paths.get(sourcePath, file);
Path backupFilePath = Paths.get(backupPath, file);
boolean cancelBackup = DatabaseServiceImpl.cancelBackup;
if (cancelBackup) {
System.out.println("备份取消-文件备份-多");
break;
}
// 确认目标文件是否已经存在,如果存在则跳过复制操作
if (!Files.exists(backupFilePath)) {
Files.copy(sourceFilePath, backupFilePath, StandardCopyOption.REPLACE_EXISTING);
System.out.println("文件 " + file + " 备份完成!");
} else {
System.out.println("文件 " + file + " 已经存在,跳过备份。");
}
}
System.out.println("备份完成!");
} catch (IOException e) {
e.printStackTrace();
}
}
- 方法声明:
addFilesToBackup是一个静态方法,接收四个参数:上次备份日志的路径lastBackupLog,当前备份日志的路径currentBackupLog,源文件路径sourcePath,和备份路径backupPath。 - 初始化文件集合:创建两个
HashSet<String>集合,用于存储上次和当前备份日志中的文件路径。 - 读取上次备份日志:使用
BufferedReader读取lastBackupLog文件,并将每一行(即一个文件路径)添加到lastBackupFiles集合中。 - 关闭上次备份日志读取器:完成读取后关闭
lastReader。 - 读取当前备份日志:同样使用
BufferedReader读取currentBackupLog文件,并将文件路径添加到currentBackupFiles集合中。 - 关闭当前备份日志读取器:完成读取后关闭
currentReader。 - 找出新增或修改的文件:使用
removeAll方法从currentBackupFiles集合中移除那些在lastBackupFiles集合中的文件,剩下的就是新增或修改的文件。 - 遍历新增或修改的文件:对
currentBackupFiles集合中的每个文件路径执行操作。 - 检查备份是否被取消:在备份每个文件之前,检查
DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。 - 复制文件:对于每个新增或修改的文件,使用
Files.copy方法从sourceFilePath复制到backupFilePath。如果备份文件已存在,则使用StandardCopyOption.REPLACE_EXISTING选项替换它。
文件夹压缩
将指定源目录下的所有文件压缩到一个ZIP文件中,因为上传文件太多
public static long compressDirectory(String sourceDirectory, String destinationFile) throws IOException {
AtomicLong fileCount = new AtomicLong(0);
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new BufferedOutputStream(new FileOutputStream(destinationFile)))) {
Path sourcePath = Paths.get(sourceDirectory);
Files.walk(sourcePath)
.filter(Files::isRegularFile)
.forEach(source -> {
try {
String fileName = sourcePath.relativize(source).toString();
ZipArchiveEntry entry = new ZipArchiveEntry(fileName);
zipOut.putArchiveEntry(entry);
try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(source.toFile()))) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
zipOut.write(buffer, 0, bytesRead);
boolean cancelBackup = DatabaseServiceImpl.cancelBackup;
if (cancelBackup) {
System.out.println("备份取消-文件压缩");
break;
}
}
}
zipOut.closeArchiveEntry();
fileCount.getAndIncrement(); // 压缩文件数量加一
} catch (IOException e) {
e.printStackTrace();
}
});
}
return fileCount.get();
}
-
方法声明:
compressDirectory是一个静态方法,接收两个参数:源目录路径sourceDirectory和目标ZIP文件路径destinationFile。 -
初始化文件计数器:使用
AtomicLong创建一个原子长整型变量fileCount,用于计数压缩的文件数量。 -
初始化ZIP输出流:使用
ZipArchiveOutputStream创建一个ZIP归档输出流,并将其与目标ZIP文件路径destinationFile关联。这里使用了BufferedOutputStream和FileOutputStream来提高写入性能。使用try-with-resources语句确保在代码块执行完毕后自动关闭资源。 -
获取源路径:使用
Paths.get方法获取源目录的Path对象。 -
遍历源目录:使用
Files.walk方法递归遍历源目录下的所有文件。 -
过滤非文件项:使用
filter(Files::isRegularFile)过滤掉非文件项(例如目录)。 -
压缩文件:在
forEach循环中,对每个文件执行压缩操作。- 使用
sourcePath.relativize(source).toString()生成相对于源路径的文件名。 - 创建一个
ZipArchiveEntry对象,表示ZIP归档中的一个条目。 - 将ZIP条目写入ZIP输出流。
- 使用
BufferedInputStream读取文件内容,并写入ZIP输出流。 - 在读取文件内容的过程中,检查
DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。
- 使用
-
关闭ZIP条目:在文件内容写入完成后,调用
zipOut.closeArchiveEntry()关闭ZIP条目。 -
更新文件计数:每次成功压缩一个文件,调用
fileCount.getAndIncrement()方法增加文件计数。 -
返回文件计数:在ZIP输出流关闭后,返回
fileCount.get(),即压缩的文件数量。
2.数据加密
1.使用MD5值,对Mysql文件进行完整性检验。
1.计算MD5值
计算指定文件的MD5散列值。
public static String calculateMD5(String filePath) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
try (InputStream is = Files.newInputStream(Paths.get(filePath))) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
md.update(buffer, 0, bytesRead);
}
byte[] md5sum = md.digest();
Formatter formatter = new Formatter();
for (byte b : md5sum) {
formatter.format("%02x", b);
}
return formatter.toString();
}
} catch (NoSuchAlgorithmException | IOException e) {
e.printStackTrace();
return null;
}
}
- 获取MD5摘要实例:调用
MessageDigest.getInstance("MD5")获取MD5摘要算法的实例。如果Java安全提供者没有实现MD5算法,将会抛出NoSuchAlgorithmException。 - 打开文件输入流:使用
Files.newInputStream(Paths.get(filePath))打开指定文件的输入流。这里使用了try-with-resources语句,确保输入流在操作完成后自动关闭。 - 读取文件内容:创建一个字节缓冲区
buffer,大小为1024字节。使用while循环读取文件内容到缓冲区中,每次读取buffer字节或更少,直到文件结束。 - 更新MD5摘要:对每次读取的数据调用
md.update(buffer, 0, bytesRead),更新MD5摘要实例的状态。 - 完成MD5摘要计算:调用
md.digest()完成摘要计算,返回包含摘要的字节数组。 - 格式化MD5散列值:创建一个
Formatter实例,遍历MD5摘要的字节数组,使用formatter.format("%02x", b)将每个字节格式化为两位十六进制数。 - 返回MD5散列值:调用
formatter.toString()获取格式化后的MD5散列值字符串,并作为方法的结果返回。
2.将MD5值写入文件
public static void writeMD5ToFile(String filePath, String md5) {
String md5FilePath = filePath + ".md5";
try (PrintWriter writer = new PrintWriter(md5FilePath)) {
writer.print(md5);
System.out.println("MD5值已保存至文件: " + md5FilePath);
} catch (FileNotFoundException e) {
e.printStackTrace();
System.err.println("保存MD5值至文件失败");
}
}
将生成的MD5值写入文件之中
2.使用AES加密对文件进行加密
1.生成密钥
public static String generateKey(String fileName) {
try {
// 生成密钥材料
String seedKey = "ceshi"; // 种子密钥
char[] password = (seedKey + fileName).toCharArray();
byte[] saltBytes = fileName.getBytes();
int iterations = 10000; // 迭代次数
int keyLength = 256; // 密钥长度(单位:比特)
// 使用PBKDF2派生密钥
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(password, saltBytes, iterations, keyLength);
SecretKey secretKey = factory.generateSecret(spec);
byte[] keyBytes = secretKey.getEncoded();
// 将生成的密钥使用合适的编码方式转换成字符串
return Base64.getEncoder().encodeToString(keyBytes);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
// 异常处理
System.err.println("Exception occurred while generating key: " + e.getMessage());
return null;
}
}
-
定义种子密钥:
seedKey是一个硬编码的字符串,用作生成密钥的种子。 -
创建密码数组:将种子密钥和文件名拼接后转换为字符数组
password。 -
转换文件名为字节数组:将文件名字符串转换为字节数组
saltBytes,用作PBKDF2算法的盐值。 -
设置迭代次数:
iterations是PBKDF2算法的迭代次数,这里设置为10000。 -
设置密钥长度:
keyLength是生成密钥的长度,单位是比特,这里设置为256。 -
使用PBKDF2派生密钥:
- 使用
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")获取一个SecretKeyFactory实例,用于基于PBKDF2算法生成密钥。 - 创建一个
PBEKeySpec实例,包含密码、盐值、迭代次数和密钥长度。 - 使用
SecretKeyFactory实例的generateSecret方法和KeySpec实例生成SecretKey对象。
- 使用
-
获取密钥字节序列:调用
SecretKey对象的getEncoded方法获取密钥的字节序列。 -
编码密钥为Base64字符串:使用
Base64.getEncoder().encodeToString(keyBytes)将密钥的字节序列编码为Base64格式的字符串。 -
返回密钥字符串:返回编码后的密钥字符串。
代码的关键点:
- 使用PBKDF2(Password-Based Key Derivation Function 2)算法派生密钥,这是一种基于密码的密钥派生函数,常用于安全地从密码中派生出密钥。
- 使用HmacSHA256作为PBKDF2的伪随机函数(PRF)。
- 密钥派生过程中使用了盐值(salt),盐值是随机数据,用于确保即使两个密码相同,生成的密钥也不同。
- 使用Base64编码将密钥的字节序列转换为字符串,以便于存储和传输。
2.使用密钥进行加密
public static void encryption(String keyFile, String inputFile, String outputFile) {
try {
// 解码密钥字符串为字节数组
byte[] keyBytes = Base64.getDecoder().decode(keyFile);
// 使用字节数组创建密钥对象
SecretKey key = new SecretKeySpec(keyBytes, "AES");
// 生成随机的初始向量
SecureRandom secureRandom = new SecureRandom();
byte[] ivBytes = new byte[16];
secureRandom.nextBytes(ivBytes);
IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes);
// 加密过程
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec);
// 保存加密结果
try (FileInputStream fileInputStream = new FileInputStream(inputFile);
FileOutputStream fileOutputStream = new FileOutputStream(outputFile)) {
fileOutputStream.write(ivBytes); // 写入初始化向量
byte[] buffer = new byte[1024];
int len;
while ((len = fileInputStream.read(buffer)) != -1) {
byte[] encryptedData = cipher.update(buffer, 0, len);
fileOutputStream.write(encryptedData); // 写入加密后的数据
boolean cancelBackup = DatabaseServiceImpl.cancelBackup;
if (cancelBackup) {
System.out.println("备份取消-文件加密");
break;
}
}
byte[] encryptedData = cipher.doFinal();
fileOutputStream.write(encryptedData); // 写入加密后的最后部分数据
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | IOException e) {
// 异常处理
System.err.println("Exception occurred during encryption: " + e.getMessage());
}
}
-
解码密钥:使用
Base64.getDecoder().decode(keyFile)解码密钥文件中的字符串,得到密钥的字节数组keyBytes。 -
创建密钥对象:使用解码后的字节数组和"AES"算法名称创建一个
SecretKeySpec对象key。 -
生成初始向量:使用
SecureRandom生成一个随机的初始向量ivBytes,它将用于AES算法的CBC模式。 -
创建初始向量参数规范:使用生成的初始向量创建一个
IvParameterSpec对象ivParameterSpec。 -
初始化加密过程:
- 使用
Cipher.getInstance("AES/CBC/PKCS5Padding")获取一个Cipher实例,指定使用AES加密算法、CBC模式和PKCS5Padding填充机制。 - 使用
cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec)初始化Cipher实例为加密模式。
- 使用
-
保存加密结果:使用
FileInputStream读取输入文件,并使用FileOutputStream写入输出文件。- 首先,将初始向量
ivBytes写入到输出文件的开头。 - 然后,读取输入文件内容并分块加密,将加密后的数据块写入输出文件。
- 在读取和加密文件内容的过程中,检查
DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断加密过程。
- 首先,将初始向量
-
完成加密:在输入文件全部读取完毕后,调用
cipher.doFinal()完成加密过程,并将任何剩余的加密数据写入输出文件。 -
异常处理:捕获并处理可能发生的异常,包括:
NoSuchAlgorithmException:加密算法不存在。NoSuchPaddingException:填充机制不存在。InvalidKeyException:密钥无效。InvalidAlgorithmParameterException:算法参数无效。IllegalBlockSizeException:数据块大小不符合算法要求。BadPaddingException:填充数据不正确。IOException:I/O异常。
-
打印异常信息:如果发生异常,打印异常信息到错误输出。
代码的关键点:
- 使用AES算法进行文件加密,这是一种广泛使用的对称加密算法。
- 使用CBC模式,它需要一个初始向量来确保加密过程的随机性。
- 使用PKCS5Padding填充机制,以处理任意长度的数据。
- 初始向量写入输出文件的开头,以供解密时使用。
3.数据解密
1. 比较md5的值
// 从文件中读取MD5值
public static String readMD5FromFile(String filePath) {
String md5FilePath = filePath + ".md5";
try {
return new String(Files.readAllBytes(Paths.get(md5FilePath)));
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
// 比较MD5值
public static void compareMD5(String filePath, String inputMD5) {
String storedMD5 = readMD5FromFile(filePath);
if (storedMD5 != null && storedMD5.trim().equalsIgnoreCase(inputMD5.trim())) {
System.out.println("MD5值匹配,文件完整");
} else {
throw new RuntimeException("MD5值不匹配,文件可能已被篡改");
}
}
2.对AES进行解密
try {
decryptFileInputStream = new FileInputStream(decryptFilePath);
byte[] initVector = new byte[16];
decryptFileInputStream.read(initVector);
IvParameterSpec ivParameterSpec = new IvParameterSpec(initVector);
byte[] keyBytes = Base64.getDecoder().decode(keyFile);
SecretKey secretKey = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);
fos = new FileOutputStream(outFilePath);
cis = new CipherInputStream(decryptFileInputStream, cipher);
byte[] buffer = new byte[1024];
int readLen;
while ((readLen = cis.read(buffer)) != -1) {
boolean cancelRecover = DatabaseServiceImpl.cancelRecover;
if (cancelRecover) {
System.out.println("恢复取消-文件解密");
break;
}
fos.write(buffer, 0, readLen);
}
} finally {
if (cis != null) {
try {
cis.close();
} catch (IOException e) {
System.err.println("关闭CipherInputStream时发生IOException: " + e.getMessage());
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
System.err.println("关闭FileOutputStream时发生IOException: " + e.getMessage());
}
}
if (decryptFileInputStream != null) {
try {
decryptFileInputStream.close();
} catch (IOException e) {
System.err.println("关闭FileInputStream时发生IOException: " + e.getMessage());
}
}
}
- 初始化流变量:声明了三个流对象
decryptFileInputStream、fos和cis,它们将在方法中用于读取加密文件、写入解密后的数据和进行加密解密操作。 - 读取初始化向量:创建
FileInputStream对象读取加密文件的初始化向量(IV),这里假设IV正好是16字节。 - 创建IV参数规范:使用读取的初始化向量创建
IvParameterSpec对象。 - 解码密钥:使用Base64解码器解码
keyFile中的密钥字符串,得到密钥的字节数组。 - 创建密钥对象:使用解码的密钥字节数组和"AES"算法名称创建
SecretKeySpec对象。 - 初始化Cipher对象:创建并配置
Cipher对象为解密模式(Cipher.DECRYPT_MODE),使用前面创建的密钥和IV参数规范。 - 创建输出文件流和CipherInputStream:创建
FileOutputStream用于写入解密后的数据,CipherInputStream包装了加密文件输入流和Cipher对象,以便从流中读取解密的数据。 - 解密循环:使用循环从
CipherInputStream中读取数据,并写入到输出文件流中,直到所有数据被读取完毕。
4.数据恢复
1.恢复mysql数据库
也是使用mysqldump
try {
Runtime rt = Runtime.getRuntime();
//还原数据库数据
String command = "mysql -h" + ip + " -u" + user + " -p" + password + " --default-character-set=utf8 " + db;
log.info("数据库还原开始");
Process child = rt.exec(command);
try (OutputStream outputStream = child.getOutputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8));
OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
String str;
while ((str = bufferedReader.readLine()) != null) {
outputStreamWriter.write(str + "\r\n");
}
outputStreamWriter.flush();
}
log.info("数据库还原完成");
int exitCode = child.waitFor(); // 获取子进程的退出码
if (exitCode != 0) {
log.error("数据库还原失败,子进程退出码: {}", exitCode);
}
} catch (IOException e) {
log.error("IO异常: {}", e.getMessage(), e);
} catch (InterruptedException e) {
log.error("等待子进程结束时被中断: {}", e.getMessage(), e);
Thread.currentThread().interrupt(); // 重新标记中断状态
}
使用Runtime.exec开一个新的线程进行文件恢复操作
目的:
- 处理大文件:如果SQL文件很大,使用
Runtime.exec将文件内容逐行发送到MySQL服务器,可以避免一次性将整个文件加载到内存中,从而节省内存资源。 - 直接执行SQL命令:通过
Runtime.exec可以执行MySQL命令行工具,直接将SQL文件中的内容发送到MySQL服务器,从而实现数据库的还原。
2.解压缩
try (ZipFile zipFile = new ZipFile(backupFile)) {
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String fileName = entry.getName();
File restoreFile = new File(restoreDirectory, fileName);
try {
if (!restoreFile.getParentFile().exists()) {
if (!restoreFile.getParentFile().mkdirs()) {
throw new IOException("无法创建父目录: " + restoreFile.getParent());
}
}
// 使用流来进行文件复制,避免一次性加载整个文件到内存
try (InputStream input = zipFile.getInputStream(entry);
OutputStream output = new FileOutputStream(restoreFile)) {
byte[] buffer = new byte[8192]; // 使用缓冲区
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
boolean cancelRecover = DatabaseServiceImpl.cancelRecover;
if (cancelRecover) {
System.out.println("恢复取消-文件解密");
break;
}
output.write(buffer, 0, bytesRead);
}
}
} catch (IOException e) {
System.err.println("恢复文件时发生IOException: " + e.getMessage());
// 可以选择抛出异常或者记录日志
}
}
-
打开ZIP文件:使用
new ZipFile(backupFile)打开备份文件,并创建一个ZipFile实例。 -
获取ZIP条目枚举:通过调用
zipFile.entries()获取一个包含ZIP文件中所有条目的枚举。 -
遍历ZIP条目:使用
while循环遍历ZIP文件中的每个条目。- 获取当前条目
ZipEntry。 - 获取条目的名称
fileName。
- 获取当前条目
-
创建恢复文件路径:使用
restoreDirectory和fileName创建一个File对象restoreFile,表示恢复文件的目标路径。 -
检查并创建父目录:如果
restoreFile的父目录不存在,则尝试创建它。如果创建失败,则抛出IOException。 -
复制文件:使用
zipFile.getInputStream(entry)获取输入流,读取ZIP条目中的数据,并使用FileOutputStream写入到restoreFile。- 使用
byte[] buffer作为缓冲区,大小为8192字节。 - 循环读取输入流数据到缓冲区,并写入到输出流,直到输入流结束。
- 使用
3.恢复上传文件到指定目录
// 获取恢复目录下的所有文件和文件夹路径
List<String> restoredItems = listAllItems(restoreDirectory);
List<String> filePaths = new ArrayList<>();
for (String item : restoredItems) {
String filePath = restoreDirectory + File.separator + item;
File file = new File(filePath);
if (file.isDirectory()) {
List<String> subFiles = listAllFilesInDirectory(file);
filePaths.addAll(subFiles);
} else {
filePaths.add(filePath);
}
}
// 读取删除记录日志文件
File deletedItemsLog = new File(deletedItemsLogDirectory, deletedItemsLogFileName);
List<String> deletedItems = new ArrayList<>();
if (deletedItemsLog.exists()) {
try (BufferedReader reader = new BufferedReader(new FileReader(deletedItemsLog))) {
String line;
while ((line = reader.readLine()) != null) {
String deletedItemPath = restoreDirectory + File.separator + line;
deletedItems.add(deletedItemPath);
}
}
}
// 删除恢复目录中多余的文件和文件夹
for (String item : filePaths) {
if (!deletedItems.contains(item)) {
File file = new File(item);
if (file.exists()) {
if (file.isDirectory()) {
FileUtils.deleteDirectory(file);
} else {
file.delete();
}
System.out.println("处理多余文件" + file + "成功");
}
}
}
5.自动备份
使用Quartz框架,基于Corn表达式,开启一个定时任务。
先写这么多吧