📁 Vostok.File
File 文件存储模块
统一文件访问抽象层,默认实现为本地文件系统,支持文本/字节读写、流式传输、目录管理、压缩解压、图片缩略图、文件加密、目录监听、目录迁移,可扩展为对象存储(S3/OSS)等后端。
初始化(可选)
零配置启动(默认行为)
// 无需任何初始化——直接使用即可。
// 首次操作时自动以本地文件系统启动,根目录为 JAR 所在目录下的 vkfiles/。
Vostok.File.write("config/app.conf", "key=value");
String text = Vostok.File.read("config/app.conf");
显式初始化
Vostok.init(cfg -> cfg
.fileConfig(new VKFileConfig()
.baseDir("./data/files")
.charset(StandardCharsets.UTF_8)
.watchRecursiveDefault(false)
.datePartitionPattern("yyyy/MM/dd")
.datePartitionZoneId("Asia/Shanghai")
)
);
零配置启动
File 模块无需显式初始化。首次调用任意文件操作时,若尚未初始化,将自动以本地文件系统后端启动:
根目录为 JAR 所在目录下的 vkfiles/(IDE/解压部署时为 class 目录下的 vkfiles/),
编码 UTF-8,其余参数取默认值。
如需自定义根目录、编码或其他参数,在首次调用前调用 init(VKFileConfig) 即可。
初始化是线程安全且幂等的,重复调用 init() 会关闭并重建默认后端。
文本读写
// 创建文件(文件已存在时行为由后端决定,通常抛异常)
Vostok.File.create("config/app.conf", "key=value");
// 覆盖写入(文件不存在时自动创建)
Vostok.File.write("data/report.txt", content);
// 更新已有文件内容(文件不存在时抛 FK-404)
Vostok.File.update("config/app.conf", newContent);
// 追加文本
Vostok.File.append("logs/access.log", "2024-01-01 GET /\n");
// 读取全部文本
String text = Vostok.File.read("config/app.conf");
// 按行读取
List<String> lines = Vostok.File.readLines("config/app.conf");
// 按行写入(覆盖)
Vostok.File.writeLines("config/app.conf", List.of("k1=v1", "k2=v2"));
字节读写
// 写入字节(覆盖)
Vostok.File.writeBytes("assets/logo.png", pngBytes);
// 追加字节
Vostok.File.appendBytes("data/chunk.bin", chunk);
// 读取全部字节
byte[] raw = Vostok.File.readBytes("assets/logo.png");
// 部分读取:从 offset 开始读取 length 字节
byte[] slice = Vostok.File.readRange("data/large.bin", 1024, 512);
流式读写
// 从 InputStream 写入文件,返回写入字节数
long written = Vostok.File.writeFrom("uploads/avatar.jpg", inputStream);
// 写入时控制是否覆盖已有文件
long written = Vostok.File.writeFrom("uploads/avatar.jpg", inputStream, false);
// 从 InputStream 追加到文件,返回追加字节数
long appended = Vostok.File.appendFrom("data/stream.log", inputStream);
// 将文件全部内容写出到 OutputStream,返回字节数
long bytes = Vostok.File.readTo("assets/logo.png", outputStream);
// 部分读取到 OutputStream:从 offset 开始读取 length 字节
long bytes = Vostok.File.readRangeTo("data/large.bin", 0L, 4096L, outputStream);
目录操作
// 创建单层目录(父目录必须已存在)
Vostok.File.mkdir("uploads/2024");
// 递归创建目录(含所有父目录,等同 mkdirs)
Vostok.File.mkdirs("uploads/2024/01/photos");
// 列出目录直接子项(返回 VKFileInfo 列表)
List<VKFileInfo> items = Vostok.File.list("uploads");
// 递归列出目录所有子项
List<VKFileInfo> all = Vostok.File.list("uploads", true);
// 递归遍历并过滤(只要普通文件)
List<VKFileInfo> files = Vostok.File.walk("uploads", true,
info -> !info.directory());
// 存在性与类型检查
boolean exists = Vostok.File.exists("config/app.conf");
boolean isDir = Vostok.File.isDirectory("uploads");
boolean isFile = Vostok.File.isFile("config/app.conf");
// 获取文件大小(字节)
long bytes = Vostok.File.size("data/report.txt");
// 递归统计目录总大小(字节)
long total = Vostok.File.totalSize("uploads");
// 获取最后修改时间
Instant ts = Vostok.File.lastModified("data/report.txt");
// touch:不存在则创建空文件,已存在则更新修改时间
Vostok.File.touch("logs/app.log");
VKFileInfo 包含以下字段:
| 方法 | 类型 | 说明 |
|---|---|---|
path() | String | 相对于存储根目录的路径 |
directory() | boolean | 是否为目录 |
size() | long | 文件大小(字节),目录为 0 |
lastModified() | Instant | 最后修改时间 |
文件与目录的复制、移动、重命名、删除
// 重命名(在同目录下改名)
Vostok.File.rename("temp/draft.txt", "draft_v2.txt");
// 复制文件(默认覆盖目标)
Vostok.File.copy("templates/base.html", "pages/index.html");
// 复制文件,指定是否覆盖
Vostok.File.copy("templates/base.html", "pages/about.html", false);
// 移动/重命名文件(默认覆盖目标)
Vostok.File.move("temp/draft.txt", "published/article.txt");
// 移动文件,指定是否覆盖
Vostok.File.move("temp/draft.txt", "archive/article.txt", false);
// 复制目录(指定冲突策略)
Vostok.File.copyDir("src/assets", "dist/assets", VKFileConflictStrategy.OVERWRITE);
// 移动目录(指定冲突策略)
Vostok.File.moveDir("staging/release", "production/release", VKFileConflictStrategy.SKIP);
// 删除文件(文件不存在时抛 FK-404)
Vostok.File.delete("temp/draft.txt");
// 删除文件(文件不存在时静默返回 false,存在且删除成功返回 true)
boolean deleted = Vostok.File.deleteIfExists("temp/maybe.txt");
// 递归删除目录及其所有内容
boolean ok = Vostok.File.deleteRecursively("temp");
VKFileConflictStrategy 枚举值:
| 值 | 行为 |
|---|---|
FAIL | 目标已存在时抛出异常(默认) |
SKIP | 目标已存在时跳过,不覆盖 |
OVERWRITE | 目标已存在时覆盖 |
日期分区路径
按当前时间自动插入日期目录,路径格式由 datePartitionPattern 配置(默认 yyyy/MM/dd)。
// 生成日期分区路径(不写入文件)
// 例如今天 2024-01-15,relativePath="report.csv"
// 返回 "2024/01/15/report.csv"
String path = Vostok.File.suggestDatePath("report.csv");
// 指定时刻生成路径
String path = Vostok.File.suggestDatePath("report.csv", Instant.parse("2024-06-01T00:00:00Z"));
// 按日期分区写入文本,返回实际写入路径
String writtenPath = Vostok.File.writeByDatePath("report.csv", csvContent);
// 按日期分区写入字节,返回实际写入路径
String writtenPath = Vostok.File.writeBytesByDatePath("photo.jpg", jpegBytes);
// 按日期分区从流写入,返回实际写入路径
String writtenPath = Vostok.File.writeFromByDatePath("upload.bin", inputStream);
临时文件
// 在 tmp/ 目录下创建临时文件,返回相对路径
String tmpPath = Vostok.File.createTemp("upload_", ".tmp");
// 在指定子目录下创建临时文件
String tmpPath = Vostok.File.createTemp("processing", "chunk_", ".bin");
压缩与解压
// ZIP 压缩(文件或目录)
Vostok.File.zip("data/reports", "archives/reports.zip");
// ZIP 解压(使用配置中的安全限制)
Vostok.File.unzip("archives/data.zip", "output/");
// ZIP 解压,指定是否覆盖已有文件
Vostok.File.unzip("archives/data.zip", "output/", false);
// ZIP 解压,自定义安全选项
VKUnzipOptions opts = VKUnzipOptions.builder()
.replaceExisting(true)
.maxEntries(500)
.maxTotalUncompressedBytes(100L * 1024 * 1024) // 100 MB
.maxEntryUncompressedBytes(50L * 1024 * 1024) // 50 MB
.build();
Vostok.File.unzip("archives/data.zip", "output/", opts);
// GZip 压缩
Vostok.File.gzip("data/large.log", "data/large.log.gz");
// GZip 解压(方法名为 gunzip,非 ungzip)
Vostok.File.gunzip("data/large.log.gz", "data/large.log");
安全
ZIP 解压内置 Zip Slip 路径穿越防护和 Zip Bomb 检测。安全上限通过 VKUnzipOptions 或 VKFileConfig 中的 unzipMaxEntries、unzipMaxTotalUncompressedBytes、unzipMaxEntryUncompressedBytes 控制;默认值 -1 表示不限制,生产环境建议显式配置。
图片缩略图
// 生成缩略图字节,返回 byte[]
VKThumbnailOptions opts = VKThumbnailOptions.builder(200, 200)
.mode(VKThumbnailMode.FIT) // FIT:等比缩放;FILL:裁剪填充
.format("jpg")
.quality(0.85f)
.keepAspectRatio(true)
.upscale(false)
.stripMetadata(true)
.build();
byte[] thumb = Vostok.File.thumbnail("uploads/photo.jpg", opts);
// 生成缩略图并写入指定路径
Vostok.File.thumbnailTo("uploads/photo.jpg", "thumbs/photo_200x200.jpg", opts);
文件哈希
// 计算文件哈希,算法名同 MessageDigest(MD5 / SHA-1 / SHA-256 等)
String md5 = Vostok.File.hash("data/package.zip", "MD5");
String sha256 = Vostok.File.hash("data/package.zip", "SHA-256");
文件加密与解密
委托 Vostok.Security 的 AES-256-GCM 实现,采用 vkf2 分块流式格式(每 1 MB 独立一个 GCM 分块),内存消耗恒定,支持任意大小的二进制文件。加密密钥通过 DEK/KEK 双层 Key Wrapping 保护,keyId 和 KEK 版本号写入文件头,解密时自动读取,无需调用方保存额外参数。使用前须先初始化 Vostok.Security 的 KeyStore。
// 前提:初始化 Security 模块的 KeyStore(与 encryptWithKeyId 共享同一 KeyStore)
Vostok.Security.initKeyStore(new VKKeyStoreConfig()
.baseDir("./keys")
.masterKey("my-master-secret"));
// 加密文件:sourcePath 明文 → targetPath vkf2 密文,keyId 对应 KeyStore 中的 KEK
Vostok.File.encryptFile("data/secret.bin", "data/secret.enc", "file-key-v1");
// 解密文件:sourcePath 密文 → targetPath 明文(keyId 和 KEK 版本自动从文件头读取)
// 解密失败(认证错误/密钥错误)时目标文件不写入任何字节
Vostok.File.decryptFile("data/secret.enc", "data/secret.bin");
文件监听
watch() 返回 VKFileWatchHandle(实现 AutoCloseable),调用 close() 停止监听。
// 监听目录变化(是否递归由 watchRecursiveDefault 配置决定,默认 false)
VKFileWatchHandle handle = Vostok.File.watch("config/", event -> {
System.out.println(event.type() + ": " + event.path() + " at " + event.time());
});
// 监听目录变化,显式指定是否递归
VKFileWatchHandle handle = Vostok.File.watch("uploads/", true, event -> {
if (event.type() == VKFileWatchEventType.CREATE) {
processUpload(event.path());
}
});
// 停止监听
handle.close();
// 使用 try-with-resources 自动停止
try (VKFileWatchHandle h = Vostok.File.watch("hot-reload/", false, this::onFileChanged)) {
// 监听期间执行业务逻辑…
}
VKFileWatchEvent 字段:
| 方法 | 类型 | 说明 |
|---|---|---|
type() | VKFileWatchEventType | 事件类型:CREATE / MODIFY / DELETE / OVERFLOW |
path() | String | 发生变化的文件相对路径 |
time() | Instant | 事件发生时刻 |
目录迁移
将当前存储根目录下的所有文件迁移到新的目录,支持复制或移动模式、并行迁移、断点续传、进度回调。
// 简单迁移(COPY_ONLY,冲突时抛异常)
VKFileMigrateResult result = Vostok.File.migrateBaseDir("/new/storage");
// 高级迁移选项
VKFileMigrateOptions opts = new VKFileMigrateOptions()
.mode(VKFileMigrateMode.MOVE) // COPY_ONLY 或 MOVE
.conflictStrategy(VKFileConflictStrategy.OVERWRITE)
.verifyHash(true) // 迁移后校验哈希
.dryRun(false) // true 时只模拟不实际操作
.parallelism(4) // 并行线程数
.maxRetries(3) // 单文件失败最大重试次数
.retryIntervalMs(500L) // 重试间隔毫秒
.checkpointFile("migrate.checkpoint") // 断点续传文件
.progressListener(p -> {
System.out.printf("[%s] %s (%d/%d)%n",
p.status(), p.path(), p.migratedFiles(), p.totalFiles());
});
VKFileMigrateResult result = Vostok.File.migrateBaseDir("/new/storage", opts);
// 检查迁移结果
if (!result.success()) {
result.failures().forEach(f ->
System.out.println("FAILED: " + f.path() + " - " + f.message()));
}
System.out.printf("迁移完成:%d 个文件,%d 字节,耗时 %d ms%n",
result.migratedFiles(), result.migratedBytes(), result.durationMs());
多存储后端
// 注册自定义存储实现(实现 VKFileStore 接口)
Vostok.File.registerStore("s3", new S3FileStore(s3Client, bucket));
// 切换全局默认后端
Vostok.File.setDefaultMode("s3");
// 查询当前默认后端名称
String mode = Vostok.File.defaultMode();
// 查询所有已注册后端名称
Set<String> modes = Vostok.File.modes();
// 在当前线程临时切换后端(Runnable 形式)
Vostok.File.withMode("s3", () -> {
Vostok.File.write("remote/file.txt", data);
});
// 在当前线程临时切换后端(Supplier 形式,带返回值)
String content = Vostok.File.withMode("s3", () -> Vostok.File.read("remote/config.txt"));
// 查询当前线程实际使用的后端(含 withMode 覆盖)
String current = Vostok.File.currentMode();
只读模式
Vostok.File.setReadOnly(true); // 开启只读,所有写操作抛 FK-560
Vostok.File.setReadOnly(false); // 恢复可写
boolean ro = Vostok.File.isReadOnly();
提示
Vostok.File.close() 会自动将只读状态重置为 false,防止重新初始化后残留只读状态。
模块状态
// 检查模块是否已初始化
boolean ready = Vostok.File.started();
// 获取当前配置副本
VKFileConfig cfg = Vostok.File.config();
// 关闭模块,释放所有后端资源
Vostok.File.close();
配置参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| mode | String | "local" | 默认存储后端名称 |
| baseDir | String | user.dir | 本地存储根目录(懒加载默认为 JAR 所在目录/vkfiles) |
| charset | Charset | StandardCharsets.UTF_8 | 文本读写编码,需传入 Charset 对象 |
| unzipMaxEntries | long | -1(不限) | ZIP 解压最大条目数,-1 表示不限制 |
| unzipMaxTotalUncompressedBytes | long | -1(不限) | ZIP 解压总字节上限,-1 表示不限制 |
| unzipMaxEntryUncompressedBytes | long | -1(不限) | 单条目解压字节上限,-1 表示不限制 |
| watchRecursiveDefault | boolean | false | watch(path, listener) 是否默认递归监听 |
| datePartitionPattern | String | "yyyy/MM/dd" | 日期分区目录格式,需符合 DateTimeFormatter 语法 |
| datePartitionZoneId | String | 系统默认时区 | 日期分区所用时区 ID,如 "Asia/Shanghai" |
API 速查
| 方法 | 说明 |
|---|---|
| 文本读写 | |
create(path, content) | 创建新文件并写入内容 |
write(path, content) | 覆盖写入文本 |
update(path, content) | 更新已有文件内容(不存在抛 FK-404) |
append(path, content) | 追加文本 |
read(path) | 读取全部文本 |
readLines(path) | 按行读取,返回 List<String> |
writeLines(path, lines) | 按行覆盖写入 |
| 字节读写 | |
writeBytes(path, bytes) | 覆盖写入字节 |
appendBytes(path, bytes) | 追加字节 |
readBytes(path) | 读取全部字节 |
readRange(path, offset, length) | 部分读取字节 |
| 流式读写 | |
writeFrom(path, input) | 从 InputStream 写入,返回字节数 |
writeFrom(path, input, replace) | 从流写入,指定是否覆盖 |
appendFrom(path, input) | 从流追加,返回字节数 |
readTo(path, output) | 读取全部内容到 OutputStream |
readRangeTo(path, offset, len, output) | 部分读取到 OutputStream |
| 目录与文件管理 | |
mkdir(path) | 创建单层目录 |
mkdirs(path) | 递归创建目录(含父目录) |
list(path) | 列出直接子项,返回 List<VKFileInfo> |
list(path, recursive) | 列出子项,可递归 |
walk(path, recursive, filter) | 遍历并过滤 |
exists(path) | 是否存在 |
isFile(path) | 是否为普通文件 |
isDirectory(path) | 是否为目录 |
size(path) | 文件大小(字节) |
totalSize(dirPath) | 目录总大小(字节) |
lastModified(path) | 最后修改时间(Instant) |
touch(path) | 创建空文件或更新修改时间 |
rename(path, newName) | 重命名(同目录) |
copy(src, dest) | 复制文件(默认覆盖) |
copy(src, dest, replace) | 复制文件,指定是否覆盖 |
move(src, dest) | 移动文件(默认覆盖) |
move(src, dest, replace) | 移动文件,指定是否覆盖 |
copyDir(src, dest, strategy) | 复制目录 |
moveDir(src, dest, strategy) | 移动目录 |
delete(path) | 删除文件(不存在抛 FK-404) |
deleteIfExists(path) | 删除文件(不存在返回 false) |
deleteRecursively(path) | 递归删除目录 |
| 日期分区 | |
suggestDatePath(relativePath) | 生成当前时间的分区路径 |
suggestDatePath(relativePath, atTime) | 生成指定时刻的分区路径 |
writeByDatePath(relativePath, content) | 按日期分区写入文本,返回实际路径 |
writeBytesByDatePath(relativePath, bytes) | 按日期分区写入字节,返回实际路径 |
writeFromByDatePath(relativePath, input) | 按日期分区从流写入,返回实际路径 |
| 临时文件 | |
createTemp(prefix, suffix) | 在 tmp/ 下创建临时文件,返回相对路径 |
createTemp(subDir, prefix, suffix) | 在指定子目录下创建临时文件 |
| 压缩与解压 | |
zip(src, zipPath) | ZIP 压缩文件或目录 |
zip(src, zipPath, includeBaseDir) | ZIP 压缩并指定是否保留源目录名 |
unzip(zipPath, destDir) | ZIP 解压(使用配置安全限制) |
unzip(zipPath, destDir, replace) | ZIP 解压,指定是否覆盖 |
unzip(zipPath, destDir, options) | ZIP 解压,自定义安全选项 |
gzip(src, gzPath) | GZip 压缩 |
gunzip(gzPath, dest) | GZip 解压 |
| 图片与哈希 | |
thumbnail(imagePath, options) | 生成缩略图字节 |
thumbnailTo(imagePath, targetPath, options) | 生成缩略图并写入文件 |
hash(path, algorithm) | 计算文件哈希(MD5/SHA-256 等) |
| 加密与解密 | |
encryptFile(src, dest, keyId) | vkf2 分块流式 AES-256-GCM 加密文件(需先初始化 Security KeyStore) |
decryptFile(src, dest) | 解密 vkf2/vkf1 格式文件(keyId 自动从文件头读取,失败时目标文件不写入任何字节) |
| 文件监听 | |
watch(path, listener) | 监听文件/目录变化,返回 VKFileWatchHandle |
watch(path, recursive, listener) | 监听,显式指定是否递归 |
| 目录迁移 | |
migrateBaseDir(targetBaseDir) | 迁移存储根目录(默认选项) |
migrateBaseDir(targetBaseDir, options) | 迁移存储根目录(自定义选项) |
| 多后端与状态 | |
registerStore(mode, store) | 注册自定义存储后端 |
setDefaultMode(mode) | 切换全局默认后端 |
defaultMode() | 获取当前默认后端名称 |
modes() | 获取所有已注册后端名称 |
withMode(mode, action) | 当前线程临时切换后端(Runnable) |
withMode(mode, supplier) | 当前线程临时切换后端(Supplier<T>) |
currentMode() | 获取当前线程实际使用的后端名称 |
setReadOnly(flag) | 设置只读模式 |
isReadOnly() | 查询只读模式 |
started() | 查询模块是否已初始化 |
config() | 获取当前配置副本 |
close() | 关闭模块,释放所有后端资源 |
错误码
| 错误码 | 常量 | 触发场景 |
|---|---|---|
FK-400 | INVALID_ARGUMENT | 参数为 null 或空白 |
FK-401 | NOT_INITIALIZED | 模块未初始化(极少见,通常会触发懒加载) |
FK-402 | CONFIG_ERROR | 配置错误(如无效的日期格式、未注册的后端) |
FK-403 | STATE_ERROR | 后端未找到或状态异常 |
FK-404 | NOT_FOUND | 目标路径不存在 |
FK-410 | PATH_ERROR | 路径非法或路径穿越(Zip Slip) |
FK-500 | IO_ERROR | 底层 IO 操作失败 |
FK-520 | SECURITY_ERROR | 安全校验失败 |
FK-530 | UNSUPPORTED | 当前后端不支持该操作 |
FK-540 | ZIP_BOMB_RISK | 解压超过安全限制(Zip Bomb 检测) |
FK-550 | IMAGE_DECODE_ERROR | 图片解码失败 |
FK-551 | IMAGE_ENCODE_ERROR | 图片编码失败 |
FK-552 | IMAGE_LIMIT_EXCEEDED | 图片像素数超过安全上限 |
FK-553 | UNSUPPORTED_IMAGE_FORMAT | 不支持的图片格式 |
FK-560 | READ_ONLY_ERROR | 只读模式下执行写操作 |
FK-561 | ENCRYPT_ERROR | 文件加密或解密失败(含密钥错误) |
FK-562 | GZIP_ERROR | GZip 压缩或解压失败 |
异常处理
import yueyang.vostok.file.exception.VKFileException;
try {
Vostok.File.read("missing.txt");
} catch (VKFileException e) {
System.out.println(e.getCode() + ": " + e.getMessage());
// e.g. FK-404: Path not found
switch (e.getErrorCode()) {
case NOT_FOUND -> handleNotFound();
case READ_ONLY_ERROR -> handleReadOnly();
case IO_ERROR -> handleIoError(e);
default -> throw e;
}
}
完整错误码见 错误码参考。