傳統 Web 專案將上傳文件直接存放在 webapps/ROOT/upload 目錄下,導致三大痛點:
本文介紹基於 VFS (Virtual File System) 虛擬路徑 的工程化方案,實現「應用無狀態、文件持久化、訪問可控」三大目標。
┌─────────────────────────────────────┐ │ Controller 層 │ 接收 MultipartFile / 下載請求 ├─────────────────────────────────────┤ │ Service 層 │ 業務邏輯、權限校驗、元數據入庫 ├─────────────────────────────────────┤ │ VFSUtil (核心工具類) │ 路徑解析、流操作、異常統一 ├─────────────────────────────────────┤ │ 物理存儲: 本地磁盤 / NFS / S3 / HDFS │ 統一 VFS_ROOT_PATH 抽象 └─────────────────────────────────────┘
package cn.gukeer.common.utils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.Properties;
public abstract class VFSUtil {
private static final Log log = LogFactory.getLog(VFSUtil.class);
/** VFS 根路徑(無結尾分隔符),支持 Windows/Linux 雙環境 */
private static String VFS_ROOT_PATH;
static {
try { readVFSRootPath(); }
catch (Exception e) { log.error("VFS 根路徑初始化失敗", e); }
}
/** 讀取 db.properties 中的 vfsroot.windows / vfsroot.linux */
private static void readVFSRootPath() throws IOException {
String key = System.getProperty("os.name").toUpperCase().contains("WINDOWS")
? "vfsroot.windows" : "vfsroot.linux";
Properties p = new Properties();
try (InputStream in = new ClassPathResource("/db.properties").getInputStream()) {
p.load(in);
}
VFS_ROOT_PATH = p.getProperty(key);
// 兜底默認值
if (VFS_ROOT_PATH == null || VFS_ROOT_PATH.isEmpty()) {
VFS_ROOT_PATH = System.getProperty("os.name").toUpperCase().contains("WINDOWS")
? "C:/gukeer/vfsroot/" : "/opt/gukeer/vfsroot/";
}
// 確保目錄存在
new File(VFS_ROOT_PATH).mkdirs();
}
public static String getVFSRootPath() { return VFS_ROOT_PATH; }
/**
* 獲取文件輸入流
* @param fileStream true=文件流(大文件/直接傳輸) false=內存流(小文件/多次讀取)
*/
public static InputStream getInputStream(File file, boolean fileStream) throws FileOperationException {
if (!file.exists() || !file.isFile() || !file.canRead()) {
throw new FileOperationException("文件不存在或不可讀: " + file.getAbsolutePath());
}
if (fileStream) {
return new FileInputStream(file); // 大文件流式傳輸,避免 OOM
} else {
// 小文件讀入內存,支持多次讀取/關閉後復用
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis)) {
byte[] buf = new byte[1024];
int len;
while ((len = bis.read(buf)) != -1) buffer.write(buf, 0, len);
}
return new ByteArrayInputStream(buffer.toByteArray());
}
}
/** 保存上傳文件,返回相對 VFS 根路徑的相對路徑(入庫存儲) */
public static String saveFile(MultipartFile multipartFile, String bizPath) throws IOException {
String originalName = multipartFile.getOriginalFilename();
String ext = originalName != null && originalName.contains(".")
? originalName.substring(originalName.lastIndexOf(".")) : "";
String fileName = UUID.randomUUID().toString().replace("-", "") + ext;
File targetDir = new File(VFS_ROOT_PATH, bizPath);
targetDir.mkdirs();
File targetFile = new File(targetDir, fileName);
multipartFile.transferTo(targetFile); // Spring 自動處理大文件臨時文件清理
return bizPath + "/" + fileName; // 返回相對路徑,如 /images/upload/20240118/abc123.jpg
}
/** 刪除文件 */
public static boolean deleteFile(String relativePath) {
File file = new File(VFS_ROOT_PATH, relativePath);
return file.exists() && file.delete();
}
// 自定義異常
public static class FileOperationException extends RuntimeException {
public FileOperationException(String msg) { super(msg); }
public FileOperationException(String msg, Throwable cause) { super(msg, cause); }
}
}
# 數據庫配置
jdbc.schema=gk_platform
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC
jdbc.username=root
jdbc.password=123456
# VFS 根路徑(Windows/Linux 分離)
vfsroot.windows=C:/platform/vfsroot/
vfsroot.linux=/opt/platform/vfsroot/
@RestController
@RequestMapping("/website")
public class WebsiteController {
private static final Logger logger = LoggerFactory.getLogger(WebsiteController.class);
/** 圖片/文件預覽與下載統一入口 */
@GetMapping("/showPicture")
public void showPicture(HttpServletResponse response, @RequestParam String picPath) {
File file = new File(VFSUtil.getVFSRootPath() + picPath);
if (!file.exists()) {
logger.error("文件不存在: {}", file.getAbsolutePath());
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 根據副本名推斷 Content-Type,或統一 application/octet-stream 強制下載
String contentType = Files.probeContentType(file.toPath());
if (contentType == null) contentType = "application/octet-stream";
response.setContentType(contentType);
// 內聯預覽:inline; 附件下載:attachment;filename="xxx.jpg"
response.setHeader("Content-Disposition", "inline;filename=\"" + file.getName() + "\"");
response.setContentLengthLong(file.length());
try (InputStream in = VFSUtil.getInputStream(file, true); // 文件流模式
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
out.flush();
} catch (IOException e) {
logger.error("文件輸出異常: {}", e.getMessage(), e);
}
}
}
<!-- 圖片預覽 -->
<img src="<%=contextPath%>/website/showPicture?picPath=/images/upload/image/20240118/abc123.jpg" />
<!-- 文件下載 -->
<a href="<%=contextPath%>/website/showPicture?picPath=/docs/contract/20240118/contract.pdf" download>下載合同</a>
數據庫僅存儲相對路徑:/website/showPicture?picPath=/images/upload/image/20240118/abc123.jpg,前端拼接上下文路徑即可。
只需替換 VFSUtil 的存儲實現,Controller/Service 層零修改:
// S3 實現示例
public class S3VFSUtil {
private static final AmazonS3 s3Client = AmazonS3ClientBuilder.standard().build();
private static final String BUCKET = "gukeer-vfs";
public static String saveFile(MultipartFile file, String bizPath) {
String key = bizPath + "/" + UUID.randomUUID() + getExt(file);
ObjectMetadata meta = new ObjectMetadata();
meta.setContentLength(file.getSize());
meta.setContentType(file.getContentType());
s3Client.putObject(BUCKET, key, file.getInputStream(), meta);
return key; // 返回 S3 Key
}
public static InputStream getInputStream(String key, boolean fileStream) {
S3Object obj = s3Client.getObject(BUCKET, key);
return fileStream ? obj.getObjectContent() : toByteArrayInputStream(obj);
}
}
picPath 需標準化 new File(base, picPath).getCanonicalPath() 並校驗以 base 開頭。spring.servlet.multipart.max-file-size + 代碼層二次校驗。通過 VFS 虛擬路徑層抽象,應用實現了完全無狀態化:文件存儲與應用生命週期解耦、支持水平擴展、便於遷移雲存儲。這是構建雲原生 Java Web 應用的基礎設施模式之一。