那些年,被“大文件”支配的恐惧

在日常开发和运维中,你是否遇到过以下场景?

  1. 邮件附件限制:公司邮件系统限制附件最大 50MB,但你的日志包有 2GB。
  2. 微信/钉钉传输壁垒:想给客户发一个安装包,结果卡在“文件过大无法发送”。
  3. 弱网环境下上传:好不容易上传了 90% 的云盘,断网了,只能从头再来。
  4. U盘格式限制:老旧的 FAT32 格式 U盘不支持超过 4GB 的单文件。

痛点: 传统的 Linux split 命令拆出的 xaa, xab 碎片,发给 Windows 用户时,对方往往一脸茫然,不知道怎么合并。

目标: 我们需要一种方案,在 Linux/Java 后端生成,在 Windows 上让对方双击就能解压。


实现方案:标准的“分卷压缩”逻辑

关键点在于使用 Zip Spanning (分卷标准)。它会生成 .z01, .z02 ... 以及一个引导文件 .zip

方案 A:Shell 脚本工具(运维利器)

我们利用 Linux 原生的 zip 工具。

  • 拆分原理zip -s [大小] -r [目标名] [源]
  • Windows 兼容性:100%。Windows 用户只需右键点击 .zip 文件,使用 7-Zip 或 WinRAR 即可自动识别全部分卷。

Shell 脚本:ez-split.sh

你可以将以下代码保存为 ez-split.sh,并赋予执行权限 chmod +x ez-split.sh。

#!/bin/bash

# 显示用法
usage() {
    echo "用法:"
    echo "  拆分: $0 split [文件或文件夹] [每个分卷大小(如 100M, 1G)]"
    echo "  恢复: $0 merge [主zip文件(.zip)]"
    echo ""
    echo "示例:"
    echo "  $0 split my_data/ 500M  -> 将生成 my_data.zip, my_data.z01, ..."
    echo "  $0 merge my_data.zip    -> 将合并并解压到当前目录"
    exit 1
}

# 检查是否安装了 zip 和 unzip
check_tools() {
    if ! command -v zip &> /dev/null || ! command -v unzip &> /dev/null; then
        echo "错误: 未找到 zip 或 unzip 工具。请先安装: sudo apt install zip unzip"
        exit 1
    fi
}

# 拆分压缩逻辑
split_zip() {
    local TARGET=$1
    local SIZE=$2
    local OUTPUT="${TARGET%/}.zip"

    echo "正在压缩并拆分 [$TARGET] ..."
    # -r: 递归压缩 (针对文件夹)
    # -s: 设置分卷大小
    # --out: 指定输出主文件名
    zip -r -s "$SIZE" "$OUTPUT" "$TARGET"

    if [ $? -eq 0 ]; then
        echo "------------------------------------------------"
        echo "成功!已生成分卷文件。"
        echo "Windows 用户说明: 请确保所有 .z01, .z02... 和 .zip 文件在同一目录下,"
        echo "右键点击 [.zip] 后缀的文件,使用 7-Zip 或 WinRAR 选择 '提取到当前文件夹' 即可。"
        echo "------------------------------------------------"
    else
        echo "拆分过程中出现错误。"
    fi
}

# 恢复逻辑 (修正了 unzip 不支持分卷的问题)
merge_zip() {
    local MAIN_ZIP=$1
    local TEMP_SINGLE="temp_merged_archive.zip"

    if [[ ! "$MAIN_ZIP" == *.zip ]]; then
        echo "错误: 请选择以 .zip 结尾的主文件。"
        exit 1
    fi

    # 优先检查是否安装了 7z (7-zip 在 Linux 上对分卷支持最好)
    if command -v 7z &> /dev/null; then
        echo "检测到 7z,正在使用 7z 直接解压..."
        7z x "$MAIN_ZIP"
        return
    fi

    # 如果没有 7z,使用 zip 自身的合并功能
    echo "正在将分卷合并为单文件 [$TEMP_SINGLE] ..."
    # zip -s 0 是合并分卷的核心命令
    zip -s 0 "$MAIN_ZIP" --out "$TEMP_SINGLE"

    if [ $? -eq 0 ]; then
        echo "合并成功,正在解压..."
        unzip "$TEMP_SINGLE"
        rm "$TEMP_SINGLE" # 解压后删除临时单文件
        echo "解压完成!"
    else
        echo "合并失败。请确保 .z01, .z02... 等分卷文件在当前目录。"
        [ -f "$TEMP_SINGLE" ] && rm "$TEMP_SINGLE"
    fi
}
# 脚本逻辑入口
check_tools

case "$1" in
    split)
        if [ -z "$2" ] || [ -z "$3" ]; then usage; fi
        split_zip "$2" "$3"
        ;;
    merge)
        if [ -z "$2" ]; then usage; fi
        merge_zip "$2"
        ;;
    *)
        usage
        ;;
esac

执行示例:

# 拆包
./ez.split.sh split utools_5.1.1_amd64.deb 20M
# 合并压缩包
./ez.split.sh merge utools_5.1.1_amd64.deb.zip


方案 B:Java 通用方法(后端集成)

在企业级应用(如自动备份系统)中,我们需要用代码实现。推荐使用 Zip4j 库,因为它完美支持了 PKWare 的分卷协议。

在 Java 生态中,处理分卷压缩(Split/Spanned Zip)最成熟、最简单的库是 Zip4j。原生的 java.util.zip 不支持分卷,手动写流处理逻辑非常复杂且容易出错(涉及到 Zip64 头的处理)。

下面使用 Zip4j 编写一个通用的工具类,能够实现:

  1. 拆分压缩:将文件/文件夹压缩并拆分为 .z01, .z02, ... .zip。
  2. 合并解压:识别主 .zip 文件并自动关联分卷进行解压。

1. 添加依赖 (Maven)

如果你使用 Maven,请在 pom.xml 中添加:

<dependency>
    <groupId>net.lingala.zip4j</groupId>
    <artifactId>zip4j</artifactId>
    <version>2.11.5</version>
</dependency>

2. Java 工具类实现

import net.lingala.zip4j.ZipFile;
import net.lingala.zip4j.model.ZipParameters;
import net.lingala.zip4j.model.enums.CompressionLevel;
import net.lingala.zip4j.model.enums.CompressionMethod;

import java.io.File;
import java.io.IOException;

public class ZipSplitUtil {

    /**
     * 拆分压缩方法
     * @param sourcePath 要压缩的文件或文件夹路径
     * @param destinationZipPath 输出的主 zip 文件路径 (例如: D:/test/myfiles.zip)
     * @param splitSize 分卷大小(单位:字节),例如 10 * 1024 * 1024 (10MB)
     */
    public static void splitZip(String sourcePath, String destinationZipPath, long splitSize) throws IOException {
        File sourceFile = new File(sourcePath);
        ZipFile zipFile = new ZipFile(destinationZipPath);

        ZipParameters parameters = new ZipParameters();
        parameters.setCompressionMethod(CompressionMethod.DEFLATE);
        parameters.setCompressionLevel(CompressionLevel.NORMAL);

        System.out.println("开始分卷压缩...");
        
        // 如果是文件夹
        if (sourceFile.isDirectory()) {
            zipFile.createSplitZipFileFromFolder(sourceFile, parameters, true, splitSize);
        } else {
            // 如果是单文件
            zipFile.createSplitZipFile(java.util.Collections.singletonList(sourceFile), parameters, true, splitSize);
        }

        System.out.println("压缩完成!生成文件: " + destinationZipPath + " 等");
    }

    /**
     * 合并并解压方法
     * @param mainZipPath 分卷压缩包的主 zip 文件路径 (必须是 .zip 后缀那个)
     * @param extractPath 解压到的目标目录
     */
    public static void mergeAndExtract(String mainZipPath, String extractPath) throws IOException {
        ZipFile zipFile = new ZipFile(mainZipPath);

        // 检查是否是分卷压缩包
        if (zipFile.isSplitArchive()) {
            System.out.println("检测到分卷压缩包,正在合并解压...");
        } else {
            System.out.println("检测到普通压缩包,正在解压...");
        }

        // 自动识别同目录下的 .z01, .z02...
        zipFile.extractAll(extractPath);
        System.out.println("解压完成!目标路径: " + extractPath);
    }

    public static void main(String[] args) {
        try {
            // 示例 1: 拆分压缩一个文件夹,每个包 5MB
            String src = "C:/Temp/BigData"; 
            String dest = "C:/Temp/Output/Archive.zip";
            long size = 5 * 1024 * 1024; // 5MB
            splitZip(src, dest, size);

            // 示例 2: 解压分卷包
            // mergeAndExtract("C:/Temp/Output/Archive.zip", "C:/Temp/Restored");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

关键点详解

  1. Windows 兼容性
    Zip4j 生成的格式是标准的 PKWare 分卷格式。它会生成 Archive.z01, Archive.z02 ... Archive.zip。这种格式与 WinRAR7-Zip 完全兼容。用户在 Windows 上只需右键点击 .zip 文件即可解压。
  2. createSplitZipFile 逻辑
    • 第一个参数是文件列表。
    • 第二个参数是压缩配置。
    • 第三个参数 true 表示是否在完成后生成单体文件的逻辑(通常保持为 true)。
    • 第四个参数是分卷大小(Byte)。注意:ZIP 标准规定分卷大小最小不能低于 65536 字节 (64KB)
  3. 自动识别分卷
    在解压时,你只需要指定 .zip 文件。ZipFile 对象会自动在同一目录下寻找同名的 .z01, .z02 等文件。如果缺少任何一个分卷,它会抛出异常。
  4. 不需要手动合并
    有些做法是先用 Java 的 FileInputStream 把所有 .z 文件拼成一个大文件再解压,那种做法非常低效且容易导致内存溢出。Zip4j 采用流式读取,直接从各个分卷中跳转读取数据,性能最优。

注意事项

  • 文件名一致性:确保分卷文件(.z01, .z02)与主文件(.zip)的前缀完全一致且在同一文件夹下。
  • 权限:确保 Java 进程对目标文件夹有写权限。
  • Zip64:如果单个文件超过 4GB,Zip4j 会自动启用 Zip64 扩展,这在现代解压软件中都是支持的。

进阶:如何优雅地通过邮件发送这些拆分包?

当你拆分了 10 个 20MB 的包后,手动发 10 封邮件会让人崩溃。我们需要一个自动化的“邮件拆包发送”最佳实践:

  1. 主题命名规范:必须包含 (Part 1/5) 标识。
  2. 间隔发送策略:防止触发反垃圾邮件系统。
  3. MD5 校验:随邮件发送正文,告知每个分卷的校验码。

邮件发送分卷文件的最佳实践代码 (Java)

这里使用 Jakarta Mail (原 JavaMail) 结合前面的拆分逻辑。

import jakarta.mail.*;
import jakarta.mail.internet.*;
import java.util.Properties;
import java.io.File;

public class SplitFileEmailSender {

    public static void sendSplitFiles(String recipient, String folderPath, String zipName) throws Exception {
        // 1. 配置邮件服务器
        Properties prop = new Properties();
        prop.put("mail.smtp.host", "smtp.example.com");
        prop.put("mail.smtp.port", "587");
        prop.put("mail.smtp.auth", "true");
        prop.put("mail.smtp.starttls.enable", "true");

        Session session = Session.getInstance(prop, new Authenticator() {
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication("your-email@example.com", "password");
            }
        });

        // 2. 获取目录下所有的分卷文件 (.z01, .z02, ..., .zip)
        File dir = new File(folderPath);
        File[] files = dir.listFiles((d, name) -> name.startsWith(zipName));

        if (files == null) return;

        int totalParts = files.length;
        System.out.println("准备发送 " + totalParts + " 个分卷文件...");

        // 3. 循环发送每一部分
        for (int i = 0; i < files.length; i++) {
            File currentFile = files[i];
            Message message = new MimeMessage(session);
            message.setFrom(new InternetAddress("sender@example.com"));
            message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipient));
            
            // 规范的主题命名
            message.setSubject(String.format("大文件传输: %s (第 %d 部分, 共 %d 部分)", 
                               zipName, (i + 1), totalParts));

            // 邮件正文
            MimeBodyPart textPart = new MimeBodyPart();
            textPart.setText("这是分卷压缩包的一部分。请下载所有附件到同一文件夹后,解压后缀为 .zip 的文件。");

            // 附件部分
            MimeBodyPart attachmentPart = new MimeBodyPart();
            attachmentPart.attachFile(currentFile);

            Multipart multipart = new MimeMultipart();
            multipart.addBodyPart(textPart);
            multipart.addBodyPart(attachmentPart);

            message.setContent(multipart);

            System.out.println("正在发送: " + currentFile.getName());
            Transport.send(message);

            // 频率限制:每发一封邮件休息 2 秒,防止被封
            Thread.sleep(2000);
        }
        System.out.println("全部邮件已安全发出。");
    }
}

Q.E.D.


寻门而入,破门而出