一、背景
使用 Spring Boot 访问 Windows 共享目录,对文件进行增删改查。
1. Windows 共享目录介绍
Windows 共享目录使用的是 CIFS 协议:
CIFS 的全称是 Common Internet File System ,即通用互联网文件系统,是一种基于 SMB(Server Message Block,服务器消息块)协议 扩展的网络文件共享协议,主要用于在局域网或广域网中实现不同设备间的文件、打印机等资源共享。
二、解决方案
Maven引入 jcifs-ng 包
1. jcifs(Java CIFS/SMB 客户端库)
jcifs 全称 Java CIFS Client Library ,是纯 Java 实现的 SMB/CIFS 协议客户端库,专门用于在 Java 程序中访问 SMB/CIFS 共享资源(如 Windows 共享文件夹、Samba 服务器)。它解决了 Java 原生 API 不支持 SMB 协议的问题,是 Java 开发中操作网络共享文件的核心工具。
2. 引入 jcifs-ng
xml
<!-- 添加jcifs-ng库依赖 -->
<dependency>
<groupId>eu.agno3.jcifs</groupId>
<artifactId>jcifs-ng</artifactId>
<version>2.1.3</version> <!-- 推荐使用最新稳定版 -->
</dependency>
3. 编写工具类进行访问
java
import jcifs.CIFSContext;
import jcifs.CIFSException;
import jcifs.config.PropertyConfiguration;
import jcifs.context.BaseContext;
import jcifs.smb.NtlmPasswordAuthenticator;
import jcifs.smb.SmbFile;
import jcifs.smb.SmbFileInputStream;
import jcifs.smb.SmbFileOutputStream;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.*;
/**
* SMB/CIFS文件操作工具类
* 支持Windows共享目录的文件读写、目录管理等操作
*/
public final class SmbFileUtils {
// 私有构造器(工具类禁止实例化)
private SmbFileUtils() {
throw new UnsupportedOperationException("工具类禁止实例化");
}
/**
* 构建CIFS上下文(核心:封装认证和配置)
*/
synchronized public static CIFSContext buildCifsContext(DgDeviceSpecificationSmbConfig config) {
String cacheKey = buildCacheKey(config);
try {
Properties props = new Properties();
// ========== 协议版本优化(禁用SMB1,强制SMB2/SMB3)==========
props.setProperty("jcifs.smb.client.minVersion", "SMB210"); // 最低SMB2.1
props.setProperty("jcifs.smb.client.maxVersion", "SMB311"); // 最高SMB3.1.1
props.setProperty("jcifs.smb.client.disableSMB1", "true"); // 禁用低效的SMB1
// ========== 连接池优化(避免重复创建连接)==========
props.setProperty("jcifs.pool.maxActive", "20"); // 最大活跃连接数
props.setProperty("jcifs.pool.maxIdle", "10"); // 最大空闲连接数
props.setProperty("jcifs.pool.minIdle", "5"); // 最小空闲连接数
props.setProperty("jcifs.pool.idleTimeout", "300000"); // 空闲连接超时(5分钟)
props.setProperty("jcifs.pool.maxWait", "30000"); // 获取连接超时(30秒)
// ========== 解析优化(解决NetBIOS/DNS耗时)==========
// 1. 优先用IP代替主机名(彻底避免解析耗时)
// 例如:smb://192.168.1.100/share 代替 smb://fileserver/share
// 2. 禁用NetBIOS(传统解析方式极慢)
props.setProperty("jcifs.netbios.disabled", "true");
// 3. 仅用DNS解析,且启用缓存
props.setProperty("jcifs.resolveOrder", "DNS"); // 解析顺序:仅DNS
props.setProperty("jcifs.dns.cache.ttl", "3600"); // DNS缓存1小时
props.setProperty("jcifs.netbios.wins", ""); // 清空WINS服务器(禁用WINS)
// ========== 超时配置(避免默认超长等待)==========
props.setProperty("jcifs.smb.client.connectTimeout", "5000"); // 连接超时5秒
props.setProperty("jcifs.smb.client.soTimeout", "10000"); // 读取超时10秒
props.setProperty("jcifs.smb.client.negotiateTimeout", "5000"); // 协议协商超时5秒
// ========== 鉴权缓存(避免重复NTLM/Kerberos鉴权)==========
props.setProperty("jcifs.ntlm.cache.ttl", "3600"); // NTLM鉴权上下文缓存1小时
props.setProperty("jcifs.ntlm.cache.size", "100"); // 缓存最大条目数
// ========== 禁用不必要功能(减少协商开销)==========
props.setProperty("jcifs.smb.client.dfs.disabled", "true"); // 禁用Dfs(不用则关)
props.setProperty("jcifs.smb.client.signingRequired", "false"); // 禁用SMB签名(服务器允许则关)
props.setProperty("jcifs.smb.client.tcpNoDelay", "true"); // 启用TCP_NODELAY(减少延迟)
// 构建配置上下文
PropertyConfiguration propertyConfig = new PropertyConfiguration(props);
CIFSContext baseContext = new BaseContext(propertyConfig);
// 添加认证信息
NtlmPasswordAuthenticator authenticator = new NtlmPasswordAuthenticator(
config.getDomain(), config.getUsername(), config.getPassword()
);
CIFSContext context = baseContext.withCredentials(authenticator);
// 放入缓存
return context;
} catch (Exception e) {
throw new IpsException("构建SMB上下文失败"+e.getMessage());
}
}
/**
* 构建缓存Key(需包含核心配置,避免不同配置复用)
* @param config
* @return
*/
private static String buildCacheKey(DgDeviceSpecificationSmbConfig config) {
return String.format("%s_%s_%s_%s_%s",
Optional.ofNullable(config.getDomain()).orElse(""),
config.getUsername(),
config.getMinSmbVersion(),
config.getMaxSmbVersion(),
config.getEncoding()
);
}
/**
* 验证SMB目录是否存在且为有效目录
* @param smbUrl SMB路径(格式:smb://IP/共享名/子路径)
* @param config 连接配置
*/
public static boolean isDirectoryExist(String smbUrl, DgDeviceSpecificationSmbConfig config) {
try {
CIFSContext context = buildCifsContext(config);
SmbFile smbFile = new SmbFile(smbUrl, context);
return smbFile.exists() && smbFile.isDirectory();
} catch (Exception e) {
throw new IpsException("验证目录失败:" + smbUrl+e);
}
}
/**
* 验证SMB文件是否存在
* @param smbUrl SMB路径(格式:smb://IP/共享名/子路径)
* @param context SMB连接
*/
public static boolean isFileExist(String smbUrl, CIFSContext context) {
try {
SmbFile smbFile = new SmbFile(smbUrl, context);
return smbFile.exists() && smbFile.isFile();
} catch (Exception e) {
throw new IpsException("验证文件失败:" + smbUrl+e);
}
}
/**
* 列出目录下的文件/子目录
* @param smbUrl 目录路径
* @param config 连接配置
* @param includeDir 是否包含子目录
*/
public static List<String> listFiles(String smbUrl, DgDeviceSpecificationSmbConfig config, boolean includeDir) {
CIFSContext context=null;
try {
context = buildCifsContext(config);
SmbFile smbDir = new SmbFile(smbUrl, context);
if (!smbDir.exists() || !smbDir.isDirectory()) {
throw new IpsException("目录不存在或不是目录:" + smbUrl);
}
SmbFile[] smbFiles = smbDir.listFiles();
if (smbFiles == null || smbFiles.length == 0) {
return Collections.emptyList();
}
List<String> fileList = new ArrayList<>();
for (SmbFile file : smbFiles) {
if (includeDir || file.isFile()) {
fileList.add(file.getPath());
}
}
return fileList;
} catch (Exception e) {
throw new IpsException("列出目录文件失败:" + smbUrl+e.getMessage());
} finally {
// 5. 释放上下文资源(避免连接泄漏)
if (context != null) {
try {
context.close();
} catch (CIFSException e) {
// 关闭上下文的异常不抛出(避免覆盖核心异常),仅打印日志
System.err.println("关闭SMB上下文失败(删除文件后):" + smbUrl + ",异常信息:" + e.getMessage());
}
}
}
}
/**
* 获取SMB文件的输入流(数据流)
* 【重要】调用方必须关闭此流(建议用try-with-resources),否则会导致连接泄漏
* @param smbUrl 文件路径(smb://IP/共享名/文件路径)
* @param config 连接配置
* @return 封装后的InputStream(关闭时自动释放SMB上下文)
*/
public static InputStream getSmbFileInputStream(String smbUrl, DgDeviceSpecificationSmbConfig config) {
CIFSContext context = null;
try {
// 构建上下文(不使用try-with-resources,交给包装流关闭)
context = buildCifsContext(config);
SmbFile smbFile = new SmbFile(smbUrl, context);
// 校验文件有效性
if (!smbFile.exists()) {
throw new IpsException("文件不存在:" + smbUrl);
}
if (smbFile.isDirectory()) {
throw new IpsException("路径是目录,不是文件:" + smbUrl);
}
// 创建SMB输入流,并包装(关闭流时自动关闭上下文)
SmbFileInputStream smbIn = new SmbFileInputStream(smbFile);
return smbIn;
} catch (Exception e) {
String cacheKey = buildCacheKey(config);
throw new IpsException("获取SMB文件流失败:" + smbUrl+e.getMessage());
} finally {
// 5. 释放上下文资源(避免连接泄漏)
if (context != null) {
try {
context.close();
} catch (CIFSException e) {
// 关闭上下文的异常不抛出(避免覆盖核心异常),仅打印日志
System.err.println("关闭SMB上下文失败(删除文件后):" + smbUrl + ",异常信息:" + e.getMessage());
}
}
}
}
/**
* 将 MultipartFile 写入 SMB 服务器(文件不存在则新增,存在则覆盖)
* @param smbUrl 目标SMB文件路径(格式:smb://IP/共享名/子路径/文件名.后缀)
* @param config SMB连接配置
* @param multipartFile 待写入的MultipartFile(如Web上传的文件)
*/
public static void writeMultipartFileToSmb(String smbUrl, DgDeviceSpecificationSmbConfig config, MultipartFile multipartFile) {
// 1. 校验MultipartFile有效性
if (multipartFile.isEmpty()) {
throw new IpsException("待写入的MultipartFile为空,文件名称:" + multipartFile.getOriginalFilename());
}
if (multipartFile.getSize() <= 0) {
throw new IpsException("待写入的文件大小为0,文件名称:" + multipartFile.getOriginalFilename());
}
CIFSContext context = null;
InputStream fileIn = null;
// 2. 构建SMB上下文并写入文件
try{ // 获取MultipartFile输入流
context = buildCifsContext(config);
fileIn = multipartFile.getInputStream();
SmbFile smbFile = new SmbFile(smbUrl, context);
// 判断文件是否存在
if(isFileExist(smbUrl, context)){
throw new IpsException("新增失败,已存在同名文件" + smbUrl);
}
// 判断父目录是否存在自动创建父目录(关键:解决父目录不存在导致的写入失败)
SmbFile parentDir = new SmbFile(smbFile.getParent(), context);
if (!parentDir.exists()) {
throw new IpsException("父目录:" + smbFile.getParent()+"不存在,请先创建父目录");
}
// 3. 写入文件(覆盖模式,若需追加可传true)
try (SmbFileOutputStream smbOut = new SmbFileOutputStream(smbFile)) {
byte[] buffer = new byte[4096];
int len;
while ((len = fileIn.read(buffer)) != -1) {
smbOut.write(buffer, 0, len);
}
smbOut.flush(); // 强制刷出缓冲区数据
}
System.out.println("MultipartFile写入SMB成功:" + smbUrl + ",文件大小:" + multipartFile.getSize() + "字节");
} catch (Exception e) {
throw new IpsException(
"写入MultipartFile到SMB失败,文件名称:" + multipartFile.getOriginalFilename() + ",目标路径:" + smbUrl+e.getMessage()
);
} finally {
// 5. 释放上下文资源(避免连接泄漏)
if (context != null) {
try {
context.close();
} catch (CIFSException e) {
// 关闭上下文的异常不抛出(避免覆盖核心异常),仅打印日志
System.err.println("关闭SMB上下文失败(删除文件后):" + smbUrl + ",异常信息:" + e.getMessage());
}
}
}
}
/**
* 将 MultipartFile 写入 SMB 服务器(文件不存在则新增,存在则覆盖)
* @param smbUrl 目标SMB文件路径(格式:smb://IP/共享名/子路径/文件名.后缀)
* @param config SMB连接配置
* @param inputStream 待写入的MultipartFile(如Web上传的文件)
*/
public static void writeOutputStreamToSmb(String smbUrl, DgDeviceSpecificationSmbConfig config, InputStream inputStream) {
CIFSContext context = null;
// 2. 构建SMB上下文并写入文件
try{ // 获取MultipartFile输入流
context = buildCifsContext(config);
SmbFile smbFile = new SmbFile(smbUrl, context);
// 3. 写入文件(覆盖模式,若需追加可传true)
try (SmbFileOutputStream smbOut = new SmbFileOutputStream(smbFile)) {
byte[] buffer = new byte[4096];
int len;
while ((len = inputStream.read(buffer)) != -1) {
smbOut.write(buffer, 0, len);
}
smbOut.flush(); // 强制刷出缓冲区数据
}
} catch (Exception e) {
throw new IpsException(
"写入MultipartFile到SMB失败," + ",目标路径:" + smbUrl+e.getMessage()
);
} finally {
try {
inputStream.close();
} catch (IOException e) {
throw new IpsException(
e.getMessage()
);
}
// 5. 释放上下文资源(避免连接泄漏)
if (context != null) {
try {
context.close();
} catch (CIFSException e) {
// 关闭上下文的异常不抛出(避免覆盖核心异常),仅打印日志
System.err.println("关闭SMB上下文失败(删除文件后):" + smbUrl + ",异常信息:" + e.getMessage());
}
}
}
}
/**
* 删除SMB服务器上的指定文件(仅删除文件,不支持删除目录)
* @param smbUrl 要删除的文件SMB路径(格式:smb://IP/共享名/子路径/文件名.后缀)
* @param config SMB连接配置
* @throws IpsException 路径为空/文件不存在/路径是目录/删除操作失败时抛出
*/
public static void deleteSmbFile(String smbUrl, DgDeviceSpecificationSmbConfig config) {
// 1. 基础参数校验
if (smbUrl == null || smbUrl.trim().isEmpty()) {
throw new IpsException("要删除的SMB文件路径不能为空");
}
CIFSContext context = null;
try {
// 2. 构建SMB上下文
context = buildCifsContext(config);
SmbFile smbFile = new SmbFile(smbUrl, context);
// 3. 校验文件状态:存在且是文件(防止误删目录)
if (!smbFile.exists()) {
throw new IpsException("要删除的文件不存在:" + smbUrl);
}
if (smbFile.isDirectory()) {
throw new IpsException("指定路径是目录,不支持删除目录(仅支持文件删除):" + smbUrl);
}
// 4. 执行删除操作
smbFile.delete();
System.out.println("SMB文件删除成功:" + smbUrl);
} catch (CIFSException e) {
// 捕获SMB协议相关异常,精准提示
throw new IpsException("删除SMB文件失败(CIFS协议异常):" + smbUrl + ",异常信息:" + e.getMessage());
} catch (Exception e) {
// 捕获其他通用异常
throw new IpsException("删除SMB文件失败:" + smbUrl + ",异常信息:" + e.getMessage());
} finally {
// 5. 释放上下文资源(避免连接泄漏)
if (context != null) {
try {
context.close();
} catch (CIFSException e) {
// 关闭上下文的异常不抛出(避免覆盖核心异常),仅打印日志
System.err.println("关闭SMB上下文失败(删除文件后):" + smbUrl + ",异常信息:" + e.getMessage());
}
}
}
}
}
4. 细节注意
4.1 将Windows路径转换为SMB路径
arduino
\\domain\home\code\testcode.java
smb://127.0.0.1/home/code/testcode.java
- 将\替换为/
- 域名domain替换为ip地址
三、问题
1. 文件占用问题,
刚开始我构建CIFS上下文的配置没写好,导致连接时间很长,平均 20s 左右。于是我使用单例模式构建CIFS上下文,将构建CIFS上下文报错下来不释放。虽然访问时间大大缩短 (20ms) ,但出现了文件占用问题,CIFS上下文不释放,文件就不能被修改和删除。
解决方法:采用连接池配置,耗时平均1.5s。并且每次使用完后释放上下文,解决文件占用问题