Compare commits
8
Commits
main
..
5388ecaa7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5388ecaa7e | ||
|
|
963584e361 | ||
|
|
e464dfc35a | ||
|
|
6c843c4030 | ||
|
|
28eaccf35f | ||
|
|
c0b4dc6612 | ||
|
|
da31bedc88 | ||
|
|
781e77ada0 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
1.9-SNAPSHOT
|
1.9-SNAPSHOT-Java21
|
||||||
|
|
||||||
此版本包含:
|
此版本包含:
|
||||||
1. 压缩/解压 (`compress`)
|
1. 压缩/解压 (`compress`)
|
||||||
@@ -29,5 +29,27 @@
|
|||||||
- 支持单线程下载和多线程并行下载(基于 HTTP Range 请求)
|
- 支持单线程下载和多线程并行下载(基于 HTTP Range 请求)
|
||||||
- 多线程下载自动创建父目录,预分配文件空间
|
- 多线程下载自动创建父目录,预分配文件空间
|
||||||
- 使用 Java HttpClient 标准库,支持自动重定向
|
- 使用 Java HttpClient 标准库,支持自动重定向
|
||||||
|
- 多线程下载块失败时向调用方抛出异常(不再静默吞掉)
|
||||||
|
- 测试改为本地 HTTP 服务器驱动(不依赖外网),确保可重复执行
|
||||||
|
|
||||||
在 1.8-SNAPSHOT 的基础上进行版本升级。
|
1.8 更新内容(历史):
|
||||||
|
- 版本号升级为 1.8-SNAPSHOT-Java21(pom / version 常量 / README 引用同步)
|
||||||
|
- 质量插件适配 Java 21:checkstyle 升级至 3.6.0(新增 checkstyle.xml 并绑定 verify 阶段),
|
||||||
|
SpotBugs 4.8.6.6 替换已停止维护的 FindBugs(新增排除规则),javadoc 升级至 3.12.0(禁用 doclint)
|
||||||
|
- 修复 SpotBugs 发现的空指针风险:压缩/解压方法 getFileName()/getParent() 空值防御,
|
||||||
|
加密模块共享 SecureRandom 实例
|
||||||
|
|
||||||
|
1.7 更新内容(历史):
|
||||||
|
- 安全:修复 Zip-Slip 路径穿越漏洞,解压时校验条目路径并拒绝符号/硬链接条目
|
||||||
|
- 安全:AES 升级为 AES-256/GCM(随机 IV + 认证标签),RSA 实现真实混合加密
|
||||||
|
(RSA-2048/OAEP 包裹随机 AES-256 会话密钥),密钥文件限制为仅属主可读写
|
||||||
|
- 健壮性:加解密改为流式处理(支持大文件),临时文件 + 原子替换,中途失败不损坏原数据
|
||||||
|
- 修复:随机数上界 Integer.MAX_VALUE 溢出;ColorPrintlnPlus 格式码叠加(颜色+加粗等)
|
||||||
|
- 修复:getHWID 命令执行增加超时并并发排空输出流,Windows 改用 PowerShell CIM(wmic 回退),
|
||||||
|
修正 Linux 磁盘序列号命令,MAC 优先物理网卡
|
||||||
|
- 修复:getJarPath 经 URI 解析(正确处理空格与 URL 编码),临时目录改用唯一命名
|
||||||
|
- 工程:测试全部迁移至 JUnit 5(53 个用例),pom 补充元数据与插件版本,
|
||||||
|
版本号统一由 pom / MANIFEST Implementation-Version 提供
|
||||||
|
- 兼容性:向下降级为 Java 21
|
||||||
|
|
||||||
|
在 1.8-SNAPSHOT-Java21 的基础上新增文件下载模块并同步 1.9 版本号。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **Java 25**
|
- **Java 21**
|
||||||
- **Maven** 构建
|
- **Maven** 构建
|
||||||
- **Apache Commons Compress 1.28.0**(压缩功能依赖)
|
- **Apache Commons Compress 1.28.0**(压缩功能依赖)
|
||||||
- **JUnit Jupiter 5.13.0**(测试框架)
|
- **JUnit Jupiter 5.13.0**(测试框架)
|
||||||
@@ -441,17 +441,17 @@ mvn package
|
|||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
项目使用 JUnit 5 编写测试(共 53 个用例),运行:
|
项目使用 JUnit 5 编写测试(共 58 个用例),运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn test
|
mvn test
|
||||||
```
|
```
|
||||||
|
|
||||||
测试覆盖压缩/解压往返、Zip-Slip 恶意压缩包防护、AES/DES/RSA 加解密往返(含 8MB 大文件流式测试与错误密钥不损坏密文)、随机数边界(含 `Integer.MAX_VALUE` 上界)、彩色输出格式叠加与注入防护、临时目录创建与 HWID 稳定性。
|
测试覆盖压缩/解压往返、Zip-Slip 恶意压缩包防护、AES/DES/RSA 加解密往返(含 8MB 大文件流式测试与错误密钥不损坏密文)、随机数边界(含 `Integer.MAX_VALUE` 上界)、彩色输出格式叠加与注入防护、临时目录创建与 HWID 稳定性,以及文件下载(本地 HTTP 服务器驱动:单线程、多线程、小文件边界、服务器不支持断点续传时抛错)。
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
编译成功后,将 `target/gnu_java_utils_library-1.9-SNAPSHOT.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。
|
编译成功后,将 `target/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。
|
||||||
|
|
||||||
**pom.xml 引入本地依赖:**
|
**pom.xml 引入本地依赖:**
|
||||||
|
|
||||||
@@ -460,9 +460,9 @@ mvn test
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>top.gtb520.java.gnu</groupId>
|
<groupId>top.gtb520.java.gnu</groupId>
|
||||||
<artifactId>gnu_java_utils_library</artifactId>
|
<artifactId>gnu_java_utils_library</artifactId>
|
||||||
<version>1.9-SNAPSHOT</version>
|
<version>1.9-SNAPSHOT-Java21</version>
|
||||||
<scope>system</scope>
|
<scope>system</scope>
|
||||||
<systemPath>${project.basedir}/lib/gnu_java_utils_library-1.9-SNAPSHOT.jar</systemPath>
|
<systemPath>${project.basedir}/lib/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar</systemPath>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!DOCTYPE module PUBLIC
|
||||||
|
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||||
|
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
最小化 Checkstyle 配置:
|
||||||
|
当前仅启用不涉及命名/布局风格的基础检查,保证 Java 21 项目在 verify 阶段通过。
|
||||||
|
后续可按需逐步追加规则(如 LineLength、Naming 等)。
|
||||||
|
-->
|
||||||
|
<module name="Checker">
|
||||||
|
<property name="charset" value="UTF-8"/>
|
||||||
|
|
||||||
|
<!-- 禁止使用制表符缩进 -->
|
||||||
|
<module name="FileTabCharacter"/>
|
||||||
|
|
||||||
|
<!-- 文件末尾需要换行 -->
|
||||||
|
<module name="NewlineAtEndOfFile"/>
|
||||||
|
|
||||||
|
<module name="TreeWalker">
|
||||||
|
<!-- 未使用的 import -->
|
||||||
|
<module name="UnusedImports"/>
|
||||||
|
<!-- 冗余的 import -->
|
||||||
|
<module name="RedundantImport"/>
|
||||||
|
<!-- 禁止星号通配 import -->
|
||||||
|
<module name="AvoidStarImport"/>
|
||||||
|
</module>
|
||||||
|
</module>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>top.gtb520.java.gnu</groupId>
|
<groupId>top.gtb520.java.gnu</groupId>
|
||||||
<artifactId>gnu_java_utils_library</artifactId>
|
<artifactId>gnu_java_utils_library</artifactId>
|
||||||
<version>1.9-SNAPSHOT</version>
|
<version>1.9-SNAPSHOT-Java21</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>GNU Java Utils Library</name>
|
<name>GNU Java Utils Library</name>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</licenses>
|
</licenses>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.release>25</maven.compiler.release>
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<junit.version>5.13.0</junit.version>
|
<junit.version>5.13.0</junit.version>
|
||||||
<surefire.version>3.6.0</surefire.version>
|
<surefire.version>3.6.0</surefire.version>
|
||||||
@@ -64,6 +64,60 @@
|
|||||||
</archive>
|
</archive>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<configuration>
|
||||||
|
<configLocation>checkstyle.xml</configLocation>
|
||||||
|
<!-- <encoding>UTF-8</encoding>-->
|
||||||
|
<failOnViolation>true</failOnViolation>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>checkstyle-check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.spotbugs</groupId>
|
||||||
|
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||||
|
<version>4.8.6.6</version>
|
||||||
|
<configuration>
|
||||||
|
<effort>Max</effort>
|
||||||
|
<threshold>Medium</threshold>
|
||||||
|
<failOnError>true</failOnError>
|
||||||
|
<xmlOutput>true</xmlOutput>
|
||||||
|
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>spotbugs-check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-javadoc-plugin</artifactId>
|
||||||
|
<version>3.12.0</version>
|
||||||
|
<configuration>
|
||||||
|
<show>private</show>
|
||||||
|
<nohelp>true</nohelp>
|
||||||
|
<doclint>none</doclint>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<FindBugsFilter xmlns="https://github.com/spotbugs/filter/4.8.6"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="https://github.com/spotbugs/filter/4.8.6 https://raw.githubusercontent.com/spotbugs/spotbugs/4.8.6/spotbugs/etc/findbugsfilter.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
遗留命名规范:以下类/方法沿用了历史 API 命名(小写类名、PascalCase 方法名),
|
||||||
|
重命名属于破坏性变更,暂不处理,待后续大版本统一规范后移除本排除。
|
||||||
|
-->
|
||||||
|
<Match>
|
||||||
|
<Class name="~.*\.(compress|echo|version|defaults|getHWID|file)$"/>
|
||||||
|
<Bug pattern="NM_CLASS_NAMING_CONVENTION"/>
|
||||||
|
</Match>
|
||||||
|
<Match>
|
||||||
|
<Class name="~.*\.(CreateFile|EncryptionAndDecryption|echo|file)$"/>
|
||||||
|
<Bug pattern="NM_METHOD_NAMING_CONVENTION"/>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
REC_CATCH_EXCEPTION:CompressPNG.PNG 的兜底 catch(Exception) 为有意设计
|
||||||
|
(压缩失败统一返回 null),该检查噪音较高,全局排除。
|
||||||
|
-->
|
||||||
|
<Match>
|
||||||
|
<Bug pattern="REC_CATCH_EXCEPTION"/>
|
||||||
|
</Match>
|
||||||
|
</FindBugsFilter>
|
||||||
@@ -52,7 +52,11 @@ public class compress {
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
Files.createDirectories(targetPath);
|
Files.createDirectories(targetPath);
|
||||||
} else {
|
} else {
|
||||||
Files.createDirectories(targetPath.getParent());
|
Path parent = targetPath.getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
throw new IllegalArgumentException("无法确定条目父目录: " + entry.getName());
|
||||||
|
}
|
||||||
|
Files.createDirectories(parent);
|
||||||
Files.copy(tarIn, targetPath);
|
Files.copy(tarIn, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +119,11 @@ public class compress {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
TarArchiveEntry entry = new TarArchiveEntry(sourcePath, sourcePath.getFileName().toString());
|
Path fileName = sourcePath.getFileName();
|
||||||
|
if (fileName == null) {
|
||||||
|
throw new IllegalArgumentException("无法从源路径获取文件名: " + sourcePath);
|
||||||
|
}
|
||||||
|
TarArchiveEntry entry = new TarArchiveEntry(sourcePath, fileName.toString());
|
||||||
tarOut.putArchiveEntry(entry);
|
tarOut.putArchiveEntry(entry);
|
||||||
Files.copy(sourcePath, tarOut);
|
Files.copy(sourcePath, tarOut);
|
||||||
tarOut.closeArchiveEntry();
|
tarOut.closeArchiveEntry();
|
||||||
@@ -164,7 +172,11 @@ public class compress {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ZipArchiveEntry entry = new ZipArchiveEntry(sourcePath.getFileName().toString());
|
Path fileName = sourcePath.getFileName();
|
||||||
|
if (fileName == null) {
|
||||||
|
throw new IllegalArgumentException("无法从源路径获取文件名: " + sourcePath);
|
||||||
|
}
|
||||||
|
ZipArchiveEntry entry = new ZipArchiveEntry(fileName.toString());
|
||||||
zipOut.putArchiveEntry(entry);
|
zipOut.putArchiveEntry(entry);
|
||||||
Files.copy(sourcePath, zipOut);
|
Files.copy(sourcePath, zipOut);
|
||||||
zipOut.closeArchiveEntry();
|
zipOut.closeArchiveEntry();
|
||||||
@@ -200,7 +212,11 @@ public class compress {
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
Files.createDirectories(targetPath);
|
Files.createDirectories(targetPath);
|
||||||
} else {
|
} else {
|
||||||
Files.createDirectories(targetPath.getParent());
|
Path parent = targetPath.getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
throw new IllegalArgumentException("无法确定条目父目录: " + entry.getName());
|
||||||
|
}
|
||||||
|
Files.createDirectories(parent);
|
||||||
Files.copy(zipIn, targetPath);
|
Files.copy(zipIn, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -53,6 +53,9 @@ public class EncryptionAndDecryption {
|
|||||||
private static final String DES_ECB = "DES/ECB/PKCS5Padding";
|
private static final String DES_ECB = "DES/ECB/PKCS5Padding";
|
||||||
private static final String RSA_WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
private static final String RSA_WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||||
|
|
||||||
|
/** 共享的安全随机数发生器,避免每次加密都新建 SecureRandom 实例 */
|
||||||
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件加密。加密后覆盖原文件(原子替换),密钥保存到 keyPath 目录下,密钥名为 原文件名.key。
|
* 文件加密。加密后覆盖原文件(原子替换),密钥保存到 keyPath 目录下,密钥名为 原文件名.key。
|
||||||
*
|
*
|
||||||
@@ -135,7 +138,7 @@ public class EncryptionAndDecryption {
|
|||||||
|
|
||||||
private static void encryptAesGcm(File file, SecretKey key) throws Exception {
|
private static void encryptAesGcm(File file, SecretKey key) throws Exception {
|
||||||
byte[] iv = new byte[GCM_IV_BYTES];
|
byte[] iv = new byte[GCM_IV_BYTES];
|
||||||
new SecureRandom().nextBytes(iv);
|
SECURE_RANDOM.nextBytes(iv);
|
||||||
Cipher cipher = buildCipher(AES_GCM, Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
Cipher cipher = buildCipher(AES_GCM, Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||||
|
|
||||||
Path tmp = tempSibling(file);
|
Path tmp = tempSibling(file);
|
||||||
@@ -172,7 +175,7 @@ public class EncryptionAndDecryption {
|
|||||||
// 随机 AES-256 会话密钥,RSA-OAEP 包裹后存入文件头
|
// 随机 AES-256 会话密钥,RSA-OAEP 包裹后存入文件头
|
||||||
SecretKey sessionKey = generateKey("AES", AES_KEY_BITS);
|
SecretKey sessionKey = generateKey("AES", AES_KEY_BITS);
|
||||||
byte[] iv = new byte[GCM_IV_BYTES];
|
byte[] iv = new byte[GCM_IV_BYTES];
|
||||||
new SecureRandom().nextBytes(iv);
|
SECURE_RANDOM.nextBytes(iv);
|
||||||
|
|
||||||
Cipher wrapCipher = Cipher.getInstance(RSA_WRAP);
|
Cipher wrapCipher = Cipher.getInstance(RSA_WRAP);
|
||||||
wrapCipher.init(Cipher.WRAP_MODE, publicKey);
|
wrapCipher.init(Cipher.WRAP_MODE, publicKey);
|
||||||
@@ -279,6 +282,9 @@ public class EncryptionAndDecryption {
|
|||||||
*/
|
*/
|
||||||
private static Path tempSibling(File file) throws IOException {
|
private static Path tempSibling(File file) throws IOException {
|
||||||
Path dir = file.toPath().toAbsolutePath().getParent();
|
Path dir = file.toPath().toAbsolutePath().getParent();
|
||||||
|
if (dir == null) {
|
||||||
|
throw new IOException("无法确定文件所在目录: " + file);
|
||||||
|
}
|
||||||
return Files.createTempFile(dir, file.getName() + ".", ".tmp");
|
return Files.createTempFile(dir, file.getName() + ".", ".tmp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-13
@@ -10,9 +10,12 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
public class file {
|
public class file {
|
||||||
|
|
||||||
@@ -29,8 +32,9 @@ public class file {
|
|||||||
|
|
||||||
Path destPath = Paths.get(destFilePanth);
|
Path destPath = Paths.get(destFilePanth);
|
||||||
// 确保父目录存在
|
// 确保父目录存在
|
||||||
if (destPath.getParent() != null) {
|
Path parent = destPath.getParent();
|
||||||
Files.createDirectories(destPath.getParent());
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpClient client = HttpClient.newBuilder()
|
HttpClient client = HttpClient.newBuilder()
|
||||||
@@ -68,8 +72,9 @@ public class file {
|
|||||||
|
|
||||||
Path destPath = Paths.get(destFilePanth);
|
Path destPath = Paths.get(destFilePanth);
|
||||||
// 确保父目录存在
|
// 确保父目录存在
|
||||||
if (destPath.getParent() != null) {
|
Path parent = destPath.getParent();
|
||||||
Files.createDirectories(destPath.getParent());
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpClient client = HttpClient.newBuilder()
|
HttpClient client = HttpClient.newBuilder()
|
||||||
@@ -93,34 +98,62 @@ public class file {
|
|||||||
.orElseThrow(() -> new RuntimeException("无法获取文件大小"));
|
.orElseThrow(() -> new RuntimeException("无法获取文件大小"));
|
||||||
|
|
||||||
int numThreads = threadNum.getNum();
|
int numThreads = threadNum.getNum();
|
||||||
long blockSize = fileSize / numThreads;
|
// 文件小于线程数时收敛有效线程数,避免出现空块或负数 Range
|
||||||
|
int effectiveThreads = (int) Math.max(1, Math.min(numThreads, fileSize));
|
||||||
|
long blockSize = fileSize / effectiveThreads;
|
||||||
|
|
||||||
// 创建临时文件占位
|
// 创建临时文件占位
|
||||||
try (RandomAccessFile raf = new RandomAccessFile(destPath.toFile(), "rw")) {
|
try (RandomAccessFile raf = new RandomAccessFile(destPath.toFile(), "rw")) {
|
||||||
raf.setLength(fileSize);
|
raf.setLength(fileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
CountDownLatch latch = new CountDownLatch(numThreads);
|
ExecutorService executor = Executors.newFixedThreadPool(effectiveThreads);
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
|
try {
|
||||||
|
CountDownLatch latch = new CountDownLatch(effectiveThreads);
|
||||||
|
List<Future<?>> futures = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < numThreads; i++) {
|
for (int i = 0; i < effectiveThreads; i++) {
|
||||||
long start = i * blockSize;
|
long start = i * blockSize;
|
||||||
long end = (i == numThreads - 1) ? fileSize - 1 : (start + blockSize - 1);
|
if (start >= fileSize) {
|
||||||
|
// 空文件时跳过下载块
|
||||||
|
latch.countDown();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long end = (i == effectiveThreads - 1) ? fileSize - 1 : start + blockSize - 1;
|
||||||
|
|
||||||
executor.submit(() -> {
|
// 末尾 return null 使 lambda 明确解析为 Callable(而非 Runnable),
|
||||||
|
// 从而允许 downloadBlock 抛出受检异常
|
||||||
|
futures.add(executor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
downloadBlock(client, URL, destPath.toFile(), start, end);
|
downloadBlock(client, URL, destPath.toFile(), start, end);
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("下载块失败 [" + start + "-" + end + "]: " + e.getMessage(), e);
|
|
||||||
} finally {
|
} finally {
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
});
|
return null;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
latch.await();
|
latch.await();
|
||||||
|
// 重新抛出块下载任务中的异常,避免下载失败被静默吞掉;
|
||||||
|
// 解包 ExecutionException,保持对外异常类型与单线程下载一致(RuntimeException)
|
||||||
|
for (Future<?> future : futures) {
|
||||||
|
try {
|
||||||
|
future.get();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw e;
|
||||||
|
} catch (java.util.concurrent.ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
if (cause instanceof RuntimeException runtimeException) {
|
||||||
|
throw runtimeException;
|
||||||
|
}
|
||||||
|
throw new RuntimeException("下载块执行失败", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
executor.shutdown();
|
executor.shutdown();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载指定字节范围的数据块
|
* 下载指定字节范围的数据块
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ public class version {
|
|||||||
// 项目的版本信息,不代表实时运行环境
|
// 项目的版本信息,不代表实时运行环境
|
||||||
// 与 pom.xml 保持一致;构建 JAR 时优先读取 MANIFEST 的 Implementation-Version
|
// 与 pom.xml 保持一致;构建 JAR 时优先读取 MANIFEST 的 Implementation-Version
|
||||||
public static final String VERSION = "1.9";
|
public static final String VERSION = "1.9";
|
||||||
public static final String JAVA_VERSION = "25";
|
public static final String JAVA_VERSION = "21";
|
||||||
public static final String OpenJDK_VERSION = "25.0.0.0.1";
|
public static final String OpenJDK_VERSION = "21";
|
||||||
public static final String OpenJDK_Runtime_Environment = "25.0.0.0.1+36-GA";
|
public static final String OpenJDK_Runtime_Environment = "21+35-2513";
|
||||||
public static final String OpenJDK_64Bit_Server_VM = "25.0.0.0.1+36-GA";
|
public static final String OpenJDK_64Bit_Server_VM = "21+35-2513";
|
||||||
|
|
||||||
public String GetVersion() {
|
public String GetVersion() {
|
||||||
// 打包为 JAR 时读取 MANIFEST 中的 Implementation-Version,类路径运行(如测试)时回退到常量
|
// 打包为 JAR 时读取 MANIFEST 中的 Implementation-Version,类路径运行(如测试)时回退到常量
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package top.gtb520.java.gnu.java.utils.library.utils.file;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file;
|
||||||
|
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件下载模块测试。
|
||||||
|
* 使用 JDK 内置 HttpServer 在本地提供文件下载,不依赖外网,可重复执行。
|
||||||
|
*/
|
||||||
|
class DownloadFileTest {
|
||||||
|
|
||||||
|
private static HttpServer server;
|
||||||
|
private static ExecutorService serverExecutor;
|
||||||
|
private static byte[] data;
|
||||||
|
private static byte[] tinyData;
|
||||||
|
private static String baseUrl; // 支持 Range 的地址(256KB)
|
||||||
|
private static String tinyUrl; // 支持 Range 的地址(3 字节)
|
||||||
|
private static String noRangeUrl; // 不支持 Range 的地址
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startServer() throws IOException {
|
||||||
|
// 256KB 测试数据
|
||||||
|
data = new byte[256 * 1024];
|
||||||
|
for (int i = 0; i < data.length; i++) {
|
||||||
|
data[i] = (byte) (i * 31 + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
serverExecutor = Executors.newFixedThreadPool(8);
|
||||||
|
server.setExecutor(serverExecutor);
|
||||||
|
|
||||||
|
// 支持 Range 的下载地址
|
||||||
|
server.createContext("/file.bin", exchange -> {
|
||||||
|
long start = 0;
|
||||||
|
long end = data.length - 1;
|
||||||
|
int status = 200;
|
||||||
|
String range = exchange.getRequestHeaders().getFirst("Range");
|
||||||
|
if (range != null && range.startsWith("bytes=")) {
|
||||||
|
String spec = range.substring("bytes=".length());
|
||||||
|
String[] parts = spec.split("-");
|
||||||
|
start = Long.parseLong(parts[0]);
|
||||||
|
if (parts.length > 1 && !parts[1].isEmpty()) {
|
||||||
|
end = Math.min(Long.parseLong(parts[1]), data.length - 1);
|
||||||
|
} else {
|
||||||
|
end = data.length - 1;
|
||||||
|
}
|
||||||
|
if (start > end || start >= data.length) {
|
||||||
|
exchange.sendResponseHeaders(416, -1);
|
||||||
|
exchange.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = 206;
|
||||||
|
exchange.getResponseHeaders().set("Content-Range",
|
||||||
|
"bytes " + start + "-" + end + "/" + data.length);
|
||||||
|
}
|
||||||
|
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
|
||||||
|
byte[] body = Arrays.copyOfRange(data, (int) start, (int) end + 1);
|
||||||
|
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
|
// HEAD 请求:显式返回 Content-Length(HttpServer 不会自动附加)
|
||||||
|
exchange.getResponseHeaders().set("Content-Length", String.valueOf(data.length));
|
||||||
|
exchange.sendResponseHeaders(status, -1);
|
||||||
|
} else {
|
||||||
|
exchange.sendResponseHeaders(status, body.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 字节小文件(用于验证文件小于线程数的边界)
|
||||||
|
tinyData = new byte[]{1, 2, 3};
|
||||||
|
server.createContext("/tiny.bin", exchange -> {
|
||||||
|
long start = 0;
|
||||||
|
long end = tinyData.length - 1;
|
||||||
|
int status = 200;
|
||||||
|
String range = exchange.getRequestHeaders().getFirst("Range");
|
||||||
|
if (range != null && range.startsWith("bytes=")) {
|
||||||
|
String spec = range.substring("bytes=".length());
|
||||||
|
String[] parts = spec.split("-");
|
||||||
|
start = Long.parseLong(parts[0]);
|
||||||
|
if (parts.length > 1 && !parts[1].isEmpty()) {
|
||||||
|
end = Math.min(Long.parseLong(parts[1]), tinyData.length - 1);
|
||||||
|
} else {
|
||||||
|
end = tinyData.length - 1;
|
||||||
|
}
|
||||||
|
if (start > end || start >= tinyData.length) {
|
||||||
|
exchange.sendResponseHeaders(416, -1);
|
||||||
|
exchange.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = 206;
|
||||||
|
exchange.getResponseHeaders().set("Content-Range",
|
||||||
|
"bytes " + start + "-" + end + "/" + tinyData.length);
|
||||||
|
}
|
||||||
|
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
|
||||||
|
byte[] body = Arrays.copyOfRange(tinyData, (int) start, (int) end + 1);
|
||||||
|
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
|
exchange.getResponseHeaders().set("Content-Length", String.valueOf(tinyData.length));
|
||||||
|
exchange.sendResponseHeaders(status, -1);
|
||||||
|
} else {
|
||||||
|
exchange.sendResponseHeaders(status, body.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 不支持 Range 的下载地址(总是返回 200 全量)
|
||||||
|
server.createContext("/no-range.bin", exchange -> {
|
||||||
|
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
|
exchange.getResponseHeaders().set("Content-Length", String.valueOf(data.length));
|
||||||
|
exchange.sendResponseHeaders(200, -1);
|
||||||
|
} else {
|
||||||
|
exchange.sendResponseHeaders(200, data.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.start();
|
||||||
|
int port = server.getAddress().getPort();
|
||||||
|
baseUrl = "http://127.0.0.1:" + port + "/file.bin";
|
||||||
|
tinyUrl = "http://127.0.0.1:" + port + "/tiny.bin";
|
||||||
|
noRangeUrl = "http://127.0.0.1:" + port + "/no-range.bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopServer() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
if (serverExecutor != null) {
|
||||||
|
serverExecutor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleThreadDownload(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path dest = tempDir.resolve("single.bin");
|
||||||
|
file.DownloadFile(baseUrl, dest.toString());
|
||||||
|
|
||||||
|
assertTrue(Files.exists(dest));
|
||||||
|
assertArrayEquals(data, Files.readAllBytes(dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiThreadDownload(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path dest = tempDir.resolve("multi.bin");
|
||||||
|
file.DownloadFile(baseUrl, dest.toString(), ThreadNum.THREAD_NUM_IV);
|
||||||
|
|
||||||
|
assertTrue(Files.exists(dest));
|
||||||
|
assertArrayEquals(data, Files.readAllBytes(dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiThreadDownloadTinyFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
// 文件小于线程数(3 字节 / 4 线程)时应收敛线程数并正确下载
|
||||||
|
Path dest = tempDir.resolve("tiny.bin");
|
||||||
|
file.DownloadFile(tinyUrl, dest.toString(), ThreadNum.THREAD_NUM_IV);
|
||||||
|
assertArrayEquals(tinyData, Files.readAllBytes(dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiThreadDownloadWithoutRangeSupportFails(@TempDir Path tempDir) {
|
||||||
|
Path dest = tempDir.resolve("no-range.bin");
|
||||||
|
// 服务器不支持 Range 时多线程下载应抛出异常(不再静默失败)
|
||||||
|
assertThrows(RuntimeException.class,
|
||||||
|
() -> file.DownloadFile(noRangeUrl, dest.toString(), ThreadNum.THREAD_NUM_II));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullArgsThrow() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> file.DownloadFile(null, "dest"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> file.DownloadFile(baseUrl, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package top.gtb520.java.gnu.java.utils.library.utils.file;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file;
|
|
||||||
|
|
||||||
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
public class downloadfile {
|
|
||||||
@Test
|
|
||||||
void download_single() {
|
|
||||||
// 获取当前运行目录
|
|
||||||
String currentDirectory = System.getProperty("user.dir");
|
|
||||||
|
|
||||||
// 文件URL
|
|
||||||
String fileURL = "http://server-1.database-mysql.com:5525/resource_repository/Java/Linux/openjdk-25.0.2_linux-x64_bin.tar.gz";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建目录
|
|
||||||
File directory = new File(currentDirectory + "/test_download");
|
|
||||||
if (!directory.exists()) {
|
|
||||||
directory.mkdirs();
|
|
||||||
}
|
|
||||||
System.out.println("目录: " + currentDirectory);
|
|
||||||
System.out.println("下载目录: " + directory);
|
|
||||||
|
|
||||||
file.DownloadFile(fileURL, directory + "/openjdk-25.0.2_linux-x64_bin.tar.gz");
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println("抛出异常: \n" + e.getMessage());
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void download_thread() {
|
|
||||||
// 获取当前运行目录
|
|
||||||
String currentDirectory = System.getProperty("user.dir");
|
|
||||||
|
|
||||||
// 文件URL
|
|
||||||
String fileURL = "http://server-1.database-mysql.com:5525/resource_repository/Java/Linux/java_composer.tar.gz";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建目录
|
|
||||||
File directory = new File(currentDirectory + "/test_download");
|
|
||||||
if (!directory.exists()) {
|
|
||||||
directory.mkdirs();
|
|
||||||
}
|
|
||||||
System.out.println("目录: " + currentDirectory);
|
|
||||||
System.out.println("下载目录: " + directory);
|
|
||||||
|
|
||||||
file.DownloadFile(fileURL, directory + "/java_composer.tar.gz", ThreadNum.THREAD_NUM_X);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println("抛出异常: \n" + e.getMessage());
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user