数据备份功能

124 阅读23分钟

时间: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;

项目结构

数据备份目录结构.png

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

恢复的

数据

  1. sql文件数据
  2. 上传的文件数据
  3. 日志文件数据

流程实现:

大体流程

其实就4大步,数据备份,数据加密,数据解密,数据恢复。

大体是先备份数据库文件,然后给数据库文件设计一个MD5值用来判断文件的完整性,然后使用AES对这个sql文件进行加密。然后备份上传文件,备份之后将文件夹压缩成一个zip文件(因为源文件夹东西太多),然后对这个zip文件进行MD5和AES加密,日志文件同理。

恢复时先校验文件完整性,然后解密。然后对于数据库文件直接恢复,对于压缩文件,则是需要将文件恢复到指定目录,然后删除多余项。

  1. 在配置环境的时候,自己配置文件的相关属性,(数据库地址,用户名,密码,port端口,数据文件,数据库文件路径这些),并且使用@ConfigurationProperties注解将这些属性自动映射到BackupConfig配置类中。
  2. 在手动备份的时候,系统会有一个默认的推荐路径(自动备份则是默认使用这个路径)
  3. 路径选好之后,开始备份数据,数据包括数据库文件和资料文件(主要是pdf文件),首次备份的话是全量备份,之后的备份是增量备份,节省时间和空间。使用日志文件保存备份信息。
  4. 自动备份也是一样的道理,但基本都是增量备份,用户可以自己设置更新时间(每天,每周,或者指定天数。不过银行一般都是每天晚上10点更新数据)
  5. 因为文件太大,所以采用MD5方法,设置一个MD5值,用于文件完整性检验,在备份之后对文件进行加密操作。
  6. 恢复数据,校验文件完整性,然后对加密文件进行解密,使用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);
        }
    }
  1. 操作系统判断

    • 如果是Windows系统,创建一个File对象指向D:盘下的推荐路径。
    • 如果是Linux系统,首先获取当前用户的名称,然后构建媒体目录的路径。接着,列出媒体目录下的所有目录,并检查它们是否是USB设备。如果是,将它们的绝对路径添加到usbPaths列表中,并记录日志。
  2. 记录USB设备信息:如果Linux系统中找到了USB设备,记录找到的USB设备数量和路径。

识别外部存储:在许多应用程序中,特别是涉及到文件备份、数据传输或移动应用中,识别连接的USB设备(如U盘、移动硬盘等)是必要的步骤,以便用户可以选择存储数据的目标位置。

  1. 不支持的操作系统:如果操作系统既不是Windows也不是Linux,记录日志并返回,不执行后续操作。

  2. 检查推荐目录是否存在:如果推荐目录不存在,则尝试创建它。

    • 如果创建成功,记录日志。
    • 如果创建失败,记录日志并抛出运行时异常。
  3. 目录已存在:如果推荐目录已经存在,记录日志。

数据备份

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);
                // 处理关闭流产生的异常
            }
        }
  1. 方法参数

    • baseBackupPath:基础备份路径,可能用于日志记录或错误处理。
    • savePath:保存备份文件的路径。
    • mysqldumpmysqldump工具的路径或命令。
    • ip:MySQL服务器的IP地址。
    • port:MySQL服务器的端口号。
    • user:连接MySQL服务器的用户名。
    • password:连接MySQL服务器的密码。
    • db:要备份的数据库名称。
  2. 初始化

    • sqlFilePath:用于存储生成的SQL文件的完整路径。
  3. 检查和修正savePath

    • 如果savePath不以文件分隔符结尾,就添加文件分隔符。
  4. 资源初始化

    • PrintWriterBufferedReader分别用于写入文件和读取mysqldump命令的输出。
  5. 执行mysqldump命令

    • 使用Runtime.getRuntime().exec()执行mysqldump命令,包括数据库连接参数和要备份的数据库名称。
    • 使用--set-charset=UTF8确保导出的数据使用UTF-8编码。
  6. 读取和写入数据

    • mysqldump命令的标准输出读取数据,并将每一行写入到savePath指定的文件中。
  7. 刷新和等待

    • 调用printWriter.flush()确保所有数据都被写入到文件。
    • 调用process.waitFor()等待mysqldump命令执行完成。
  8. 异常处理

    • 如果在执行过程中发生IOExceptionInterruptedException,将打印异常堆栈跟踪,并调用checkAndDeleteDirectories方法(未在代码中定义)进行错误处理。
  9. 关闭资源

    • finally块中,确保BufferedReaderPrintWriter都被关闭,以释放资源。
    • 如果关闭资源时发生异常,同样调用checkAndDeleteDirectories方法。
  10. 返回结果

    • 方法返回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;
        }
    }
  1. 获取源目录中的文件列表:调用sourceDir.listFiles()获取源目录中的所有文件和目录列表。
  2. 检查文件列表:如果files不为空,进入循环处理每个文件。
  3. 检查备份是否被取消:在备份每个文件之前,检查DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。
  4. 递归处理目录:如果当前文件是一个目录,则创建目标目录,并递归调用backupFiles方法。
  5. 备份修改过的文件:如果当前文件是一个常规文件,并且它的最后修改时间大于或等于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();
        }
    }

  1. 方法声明addFilesToBackup是一个静态方法,接收四个参数:上次备份日志的路径lastBackupLog,当前备份日志的路径currentBackupLog,源文件路径sourcePath,和备份路径backupPath
  2. 初始化文件集合:创建两个HashSet<String>集合,用于存储上次和当前备份日志中的文件路径。
  3. 读取上次备份日志:使用BufferedReader读取lastBackupLog文件,并将每一行(即一个文件路径)添加到lastBackupFiles集合中。
  4. 关闭上次备份日志读取器:完成读取后关闭lastReader
  5. 读取当前备份日志:同样使用BufferedReader读取currentBackupLog文件,并将文件路径添加到currentBackupFiles集合中。
  6. 关闭当前备份日志读取器:完成读取后关闭currentReader
  7. 找出新增或修改的文件:使用removeAll方法从currentBackupFiles集合中移除那些在lastBackupFiles集合中的文件,剩下的就是新增或修改的文件。
  8. 遍历新增或修改的文件:对currentBackupFiles集合中的每个文件路径执行操作。
  9. 检查备份是否被取消:在备份每个文件之前,检查DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。
  10. 复制文件:对于每个新增或修改的文件,使用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();
    }
  1. 方法声明compressDirectory是一个静态方法,接收两个参数:源目录路径sourceDirectory和目标ZIP文件路径destinationFile

  2. 初始化文件计数器:使用AtomicLong创建一个原子长整型变量fileCount,用于计数压缩的文件数量。

  3. 初始化ZIP输出流:使用ZipArchiveOutputStream创建一个ZIP归档输出流,并将其与目标ZIP文件路径destinationFile关联。这里使用了BufferedOutputStreamFileOutputStream来提高写入性能。使用try-with-resources语句确保在代码块执行完毕后自动关闭资源。

  4. 获取源路径:使用Paths.get方法获取源目录的Path对象。

  5. 遍历源目录:使用Files.walk方法递归遍历源目录下的所有文件。

  6. 过滤非文件项:使用filter(Files::isRegularFile)过滤掉非文件项(例如目录)。

  7. 压缩文件:在forEach循环中,对每个文件执行压缩操作。

    • 使用sourcePath.relativize(source).toString()生成相对于源路径的文件名。
    • 创建一个ZipArchiveEntry对象,表示ZIP归档中的一个条目。
    • 将ZIP条目写入ZIP输出流。
    • 使用BufferedInputStream读取文件内容,并写入ZIP输出流。
    • 在读取文件内容的过程中,检查DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断循环。
  8. 关闭ZIP条目:在文件内容写入完成后,调用zipOut.closeArchiveEntry()关闭ZIP条目。

  9. 更新文件计数:每次成功压缩一个文件,调用fileCount.getAndIncrement()方法增加文件计数。

  10. 返回文件计数:在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;
        }
    }
  1. 获取MD5摘要实例:调用MessageDigest.getInstance("MD5")获取MD5摘要算法的实例。如果Java安全提供者没有实现MD5算法,将会抛出NoSuchAlgorithmException
  2. 打开文件输入流:使用Files.newInputStream(Paths.get(filePath))打开指定文件的输入流。这里使用了try-with-resources语句,确保输入流在操作完成后自动关闭。
  3. 读取文件内容:创建一个字节缓冲区buffer,大小为1024字节。使用while循环读取文件内容到缓冲区中,每次读取buffer字节或更少,直到文件结束。
  4. 更新MD5摘要:对每次读取的数据调用md.update(buffer, 0, bytesRead),更新MD5摘要实例的状态。
  5. 完成MD5摘要计算:调用md.digest()完成摘要计算,返回包含摘要的字节数组。
  6. 格式化MD5散列值:创建一个Formatter实例,遍历MD5摘要的字节数组,使用formatter.format("%02x", b)将每个字节格式化为两位十六进制数。
  7. 返回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;
        }
    }
  1. 定义种子密钥seedKey是一个硬编码的字符串,用作生成密钥的种子。

  2. 创建密码数组:将种子密钥和文件名拼接后转换为字符数组password

  3. 转换文件名为字节数组:将文件名字符串转换为字节数组saltBytes,用作PBKDF2算法的盐值。

  4. 设置迭代次数iterations是PBKDF2算法的迭代次数,这里设置为10000。

  5. 设置密钥长度keyLength是生成密钥的长度,单位是比特,这里设置为256。

  6. 使用PBKDF2派生密钥

    • 使用SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")获取一个SecretKeyFactory实例,用于基于PBKDF2算法生成密钥。
    • 创建一个PBEKeySpec实例,包含密码、盐值、迭代次数和密钥长度。
    • 使用SecretKeyFactory实例的generateSecret方法和KeySpec实例生成SecretKey对象。
  7. 获取密钥字节序列:调用SecretKey对象的getEncoded方法获取密钥的字节序列。

  8. 编码密钥为Base64字符串:使用Base64.getEncoder().encodeToString(keyBytes)将密钥的字节序列编码为Base64格式的字符串。

  9. 返回密钥字符串:返回编码后的密钥字符串。

代码的关键点:

  • 使用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());
        }
    }
  1. 解码密钥:使用Base64.getDecoder().decode(keyFile)解码密钥文件中的字符串,得到密钥的字节数组keyBytes

  2. 创建密钥对象:使用解码后的字节数组和"AES"算法名称创建一个SecretKeySpec对象key

  3. 生成初始向量:使用SecureRandom生成一个随机的初始向量ivBytes,它将用于AES算法的CBC模式。

  4. 创建初始向量参数规范:使用生成的初始向量创建一个IvParameterSpec对象ivParameterSpec

  5. 初始化加密过程

    • 使用Cipher.getInstance("AES/CBC/PKCS5Padding")获取一个Cipher实例,指定使用AES加密算法、CBC模式和PKCS5Padding填充机制。
    • 使用cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec)初始化Cipher实例为加密模式。
  6. 保存加密结果:使用FileInputStream读取输入文件,并使用FileOutputStream写入输出文件。

    • 首先,将初始向量ivBytes写入到输出文件的开头。
    • 然后,读取输入文件内容并分块加密,将加密后的数据块写入输出文件。
    • 在读取和加密文件内容的过程中,检查DatabaseServiceImpl.cancelBackup的值,如果为true,则打印取消备份的消息并中断加密过程。
  7. 完成加密:在输入文件全部读取完毕后,调用cipher.doFinal()完成加密过程,并将任何剩余的加密数据写入输出文件。

  8. 异常处理:捕获并处理可能发生的异常,包括:

    • NoSuchAlgorithmException:加密算法不存在。
    • NoSuchPaddingException:填充机制不存在。
    • InvalidKeyException:密钥无效。
    • InvalidAlgorithmParameterException:算法参数无效。
    • IllegalBlockSizeException:数据块大小不符合算法要求。
    • BadPaddingException:填充数据不正确。
    • IOException:I/O异常。
  9. 打印异常信息:如果发生异常,打印异常信息到错误输出。

代码的关键点:

  • 使用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());
                }
            }
        }
    
  1. 初始化流变量:声明了三个流对象decryptFileInputStreamfoscis,它们将在方法中用于读取加密文件、写入解密后的数据和进行加密解密操作。
  2. 读取初始化向量:创建FileInputStream对象读取加密文件的初始化向量(IV),这里假设IV正好是16字节。
  3. 创建IV参数规范:使用读取的初始化向量创建IvParameterSpec对象。
  4. 解码密钥:使用Base64解码器解码keyFile中的密钥字符串,得到密钥的字节数组。
  5. 创建密钥对象:使用解码的密钥字节数组和"AES"算法名称创建SecretKeySpec对象。
  6. 初始化Cipher对象:创建并配置Cipher对象为解密模式(Cipher.DECRYPT_MODE),使用前面创建的密钥和IV参数规范。
  7. 创建输出文件流和CipherInputStream:创建FileOutputStream用于写入解密后的数据,CipherInputStream包装了加密文件输入流和Cipher对象,以便从流中读取解密的数据。
  8. 解密循环:使用循环从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开一个新的线程进行文件恢复操作

目的:

  1. 处理大文件:如果SQL文件很大,使用Runtime.exec将文件内容逐行发送到MySQL服务器,可以避免一次性将整个文件加载到内存中,从而节省内存资源。
  2. 直接执行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());
                    // 可以选择抛出异常或者记录日志
                }
            }

  1. 打开ZIP文件:使用new ZipFile(backupFile)打开备份文件,并创建一个ZipFile实例。

  2. 获取ZIP条目枚举:通过调用zipFile.entries()获取一个包含ZIP文件中所有条目的枚举。

  3. 遍历ZIP条目:使用while循环遍历ZIP文件中的每个条目。

    • 获取当前条目ZipEntry
    • 获取条目的名称fileName
  4. 创建恢复文件路径:使用restoreDirectoryfileName创建一个File对象restoreFile,表示恢复文件的目标路径。

  5. 检查并创建父目录:如果restoreFile的父目录不存在,则尝试创建它。如果创建失败,则抛出IOException

  6. 复制文件:使用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表达式,开启一个定时任务。

先写这么多吧