SpringBoot集成SFTP客戶端實現(xiàn)文件上傳下載實例
背景
在項目開發(fā)中,一般文件存儲很少再使用SFTP服務(wù),但是也不排除合作伙伴使用SFTP來存儲項目中的文件或者通過SFTP來實現(xiàn)文件數(shù)據(jù)的交互。
我遇到的項目中,就有銀行和保險公司等合作伙伴通過SFTP服務(wù)來實現(xiàn)與我們項目的文件數(shù)據(jù)的交互。
為了能夠順利地完成與友商的SFTP服務(wù)的連通,我們需要在自己的項目中實現(xiàn)一套SFTP客戶端工具。一般我們會采用Jsch來實現(xiàn)SFTP客戶端。
依賴
<!--執(zhí)行遠(yuǎn)程操作-->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<!--鏈接池-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
首先我們一定要引入jsch依賴,這個是我們實現(xiàn)SFTP客戶端的基石;其次我們引入了鏈接池工具,為了避免每次執(zhí)行SFTP命令都要重新創(chuàng)建鏈接,我們使用池化的方式優(yōu)化了比較消耗資源的創(chuàng)建操作。
創(chuàng)建工具類
為了更好的使用SFTP工具,我們把jsch中關(guān)于SFTP的相關(guān)功能提煉出來,做了一次簡單的封裝,做成了我們可以直接使用的工具類。
里面只有兩類方法:
1.創(chuàng)建Session與開啟Session;
session創(chuàng)建好后,還不能創(chuàng)建channel,需要開啟session后才能創(chuàng)建channel;
2.創(chuàng)建channel與開啟channel;
channel也是一樣,創(chuàng)建好的channel需要開啟后才能真正地執(zhí)行命令;
public class JschUtil {
/**
* 創(chuàng)建session
*
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param privateKeyFile 密鑰文件
* @param passphrase 口令
* @return
* @throws AwesomeException
*/
public static Session createSession(String userName, String password, String host, int port, String privateKeyFile, String passphrase) throws AwesomeException {
return createSession(new JSch(), userName, password, host, port, privateKeyFile, passphrase);
}
/**
* 創(chuàng)建session
*
* @param jSch
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param privateKeyFile 密鑰
* @param passphrase 口令
* @return
* @throws AwesomeException
*/
public static Session createSession(JSch jSch, String userName, String password, String host, int port, String privateKeyFile, String passphrase) throws AwesomeException {
try {
if (!StringUtils.isEmpty(privateKeyFile)) {
// 使用密鑰驗證方式,密鑰可以是有口令的密鑰,也可以是沒有口令的密鑰
if (!StringUtils.isEmpty(passphrase)) {
jSch.addIdentity(privateKeyFile, passphrase);
} else {
jSch.addIdentity(privateKeyFile);
}
}
// 獲取session
Session session = jSch.getSession(userName, host, port);
if (!StringUtils.isEmpty(password)) {
session.setPassword(password);
}
// 不校驗域名
session.setConfig("StrictHostKeyChecking", "no");
return session;
} catch (Exception e) {
throw new AwesomeException(500, "create session fail");
}
}
/**
* 創(chuàng)建session
*
* @param jSch
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @return
* @throws AwesomeException
*/
public static Session createSession(JSch jSch, String userName, String password, String host, int port) throws AwesomeException {
return createSession(jSch, userName, password, host, port, StringUtils.EMPTY, StringUtils.EMPTY);
}
/**
* 創(chuàng)建session
*
* @param jSch
* @param userName 用戶名
* @param host 域名
* @param port 端口
* @return
* @throws AwesomeException
*/
private Session createSession(JSch jSch, String userName, String host, int port) throws AwesomeException {
return createSession(jSch, userName, StringUtils.EMPTY, host, port, StringUtils.EMPTY, StringUtils.EMPTY);
}
/**
* 開啟session鏈接
*
* @param jSch
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param privateKeyFile 密鑰
* @param passphrase 口令
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(JSch jSch, String userName, String password, String host, int port, String privateKeyFile, String passphrase, int timeout) throws AwesomeException {
Session session = createSession(jSch, userName, password, host, port, privateKeyFile, passphrase);
try {
if (timeout >= 0) {
session.connect(timeout);
} else {
session.connect();
}
return session;
} catch (Exception e) {
throw new AwesomeException(500, "session connect fail");
}
}
/**
* 開啟session鏈接
*
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param privateKeyFile 密鑰
* @param passphrase 口令
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(String userName, String password, String host, int port, String privateKeyFile, String passphrase, int timeout) throws AwesomeException {
Session session = createSession(userName, password, host, port, privateKeyFile, passphrase);
try {
if (timeout >= 0) {
session.connect(timeout);
} else {
session.connect();
}
return session;
} catch (Exception e) {
throw new AwesomeException(500, "session connect fail");
}
}
/**
* 開啟session鏈接
*
* @param jSch
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(JSch jSch, String userName, String password, String host, int port, int timeout) throws AwesomeException {
return openSession(jSch, userName, password, host, port, StringUtils.EMPTY, StringUtils.EMPTY, timeout);
}
/**
* 開啟session鏈接
*
* @param userName 用戶名
* @param password 密碼
* @param host 域名
* @param port 端口
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(String userName, String password, String host, int port, int timeout) throws AwesomeException {
return openSession(userName, password, host, port, StringUtils.EMPTY, StringUtils.EMPTY, timeout);
}
/**
* 開啟session鏈接
*
* @param jSch
* @param userName 用戶名
* @param host 域名
* @param port 端口
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(JSch jSch, String userName, String host, int port, int timeout) throws AwesomeException {
return openSession(jSch, userName, StringUtils.EMPTY, host, port, StringUtils.EMPTY, StringUtils.EMPTY, timeout);
}
/**
* 開啟session鏈接
*
* @param userName 用戶名
* @param host 域名
* @param port 端口
* @param timeout 鏈接超時時間
* @return
* @throws AwesomeException
*/
public static Session openSession(String userName, String host, int port, int timeout) throws AwesomeException {
return openSession(userName, StringUtils.EMPTY, host, port, StringUtils.EMPTY, StringUtils.EMPTY, timeout);
}
/**
* 創(chuàng)建指定通道
*
* @param session
* @param channelType
* @return
* @throws AwesomeException
*/
public static Channel createChannel(Session session, ChannelType channelType) throws AwesomeException {
try {
if (!session.isConnected()) {
session.connect();
}
return session.openChannel(channelType.getValue());
} catch (Exception e) {
throw new AwesomeException(500, "open channel fail");
}
}
/**
* 創(chuàng)建sftp通道
*
* @param session
* @return
* @throws AwesomeException
*/
public static ChannelSftp createSftp(Session session) throws AwesomeException {
return (ChannelSftp) createChannel(session, ChannelType.SFTP);
}
/**
* 創(chuàng)建shell通道
*
* @param session
* @return
* @throws AwesomeException
*/
public static ChannelShell createShell(Session session) throws AwesomeException {
return (ChannelShell) createChannel(session, ChannelType.SHELL);
}
/**
* 開啟通道
*
* @param session
* @param channelType
* @param timeout
* @return
* @throws AwesomeException
*/
public static Channel openChannel(Session session, ChannelType channelType, int timeout) throws AwesomeException {
Channel channel = createChannel(session, channelType);
try {
if (timeout >= 0) {
channel.connect(timeout);
} else {
channel.connect();
}
return channel;
} catch (Exception e) {
throw new AwesomeException(500, "connect channel fail");
}
}
/**
* 開啟sftp通道
*
* @param session
* @param timeout
* @return
* @throws AwesomeException
*/
public static ChannelSftp openSftpChannel(Session session, int timeout) throws AwesomeException {
return (ChannelSftp) openChannel(session, ChannelType.SFTP, timeout);
}
/**
* 開啟shell通道
*
* @param session
* @param timeout
* @return
* @throws AwesomeException
*/
public static ChannelShell openShellChannel(Session session, int timeout) throws AwesomeException {
return (ChannelShell) openChannel(session, ChannelType.SHELL, timeout);
}
enum ChannelType {
SESSION("session"),
SHELL("shell"),
EXEC("exec"),
X11("x11"),
AGENT_FORWARDING("auth-agent@openssh.com"),
DIRECT_TCPIP("direct-tcpip"),
FORWARDED_TCPIP("forwarded-tcpip"),
SFTP("sftp"),
SUBSYSTEM("subsystem");
private final String value;
ChannelType(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
}
SFTP鏈接池化
我們通過實現(xiàn)BasePooledObjectFactory類來池化通道ChannelSftp。這并不是真正池化的代碼,下面的代碼只是告知池化管理器如何創(chuàng)建對象和銷毀對象。
static class SftpFactory extends BasePooledObjectFactory<ChannelSftp> implements AutoCloseable {
private Session session;
private SftpProperties properties;
// 初始化SftpFactory
// 里面主要是創(chuàng)建目標(biāo)session,后續(xù)可用通過這個session不斷地創(chuàng)建ChannelSftp。
SftpFactory(SftpProperties properties) throws AwesomeException {
this.properties = properties;
String username = properties.getUsername();
String password = properties.getPassword();
String host = properties.getHost();
int port = properties.getPort();
String privateKeyFile = properties.getPrivateKeyFile();
String passphrase = properties.getPassphrase();
session = JschUtil.createSession(username, password, host, port, privateKeyFile, passphrase);
}
// 銷毀對象,主要是銷毀ChannelSftp
@Override
public void destroyObject(PooledObject<ChannelSftp> p) throws Exception {
p.getObject().disconnect();
}
// 創(chuàng)建對象ChannelSftp
@Override
public ChannelSftp create() throws Exception {
int timeout = properties.getTimeout();
return JschUtil.openSftpChannel(this.session, timeout);
}
// 包裝創(chuàng)建出來的對象
@Override
public PooledObject<ChannelSftp> wrap(ChannelSftp channelSftp) {
return new DefaultPooledObject<>(channelSftp);
}
// 驗證對象是否可用
@Override
public boolean validateObject(PooledObject<ChannelSftp> p) {
return p.getObject().isConnected();
}
// 銷毀資源,關(guān)閉session
@Override
public void close() throws Exception {
if (Objects.nonNull(session)) {
if (session.isConnected()) {
session.disconnect();
}
session = null;
}
}
}
為了實現(xiàn)真正的池化操作,我們還需要以下代碼:
1.我們需要在SftpClient對象中創(chuàng)建一個GenericObjectPool對象池,這個才是真正的池子,它負(fù)責(zé)創(chuàng)建和存儲所有的對象。
2.我們還需要提供資源銷毀的功能,也就是實現(xiàn)AutoCloseable,在服務(wù)停止時,需要把相關(guān)的資源銷毀。
public class SftpClient implements AutoCloseable {
private SftpFactory sftpFactory;
GenericObjectPool<ChannelSftp> objectPool;
// 構(gòu)造方法1
public SftpClient(SftpProperties properties, GenericObjectPoolConfig<ChannelSftp> poolConfig) throws AwesomeException {
this.sftpFactory = new SftpFactory(properties);
objectPool = new GenericObjectPool<>(this.sftpFactory, poolConfig);
}
// 構(gòu)造方法2
public SftpClient(SftpProperties properties) throws AwesomeException {
this.sftpFactory = new SftpFactory(properties);
SftpProperties.PoolConfig config = properties.getPool();
// 默認(rèn)池化配置
if (Objects.isNull(config)) {
objectPool = new GenericObjectPool<>(this.sftpFactory);
} else {
// 自定義池化配置
GenericObjectPoolConfig<ChannelSftp> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxIdle(config.getMaxIdle());
poolConfig.setMaxTotal(config.getMaxTotal());
poolConfig.setMinIdle(config.getMinIdle());
poolConfig.setTestOnBorrow(config.isTestOnBorrow());
poolConfig.setTestOnCreate(config.isTestOnCreate());
poolConfig.setTestOnReturn(config.isTestOnReturn());
poolConfig.setTestWhileIdle(config.isTestWhileIdle());
poolConfig.setBlockWhenExhausted(config.isBlockWhenExhausted());
poolConfig.setMaxWait(Duration.ofMillis(config.getMaxWaitMillis()));
poolConfig.setTimeBetweenEvictionRuns(Duration.ofMillis(config.getTimeBetweenEvictionRunsMillis()));
objectPool = new GenericObjectPool<>(this.sftpFactory, poolConfig);
}
}
// 銷毀資源
@Override
public void close() throws Exception {
// 銷毀鏈接池
if (Objects.nonNull(this.objectPool)) {
if (!this.objectPool.isClosed()) {
this.objectPool.close();
}
}
this.objectPool = null;
// 銷毀sftpFactory
if (Objects.nonNull(this.sftpFactory)) {
this.sftpFactory.close();
}
}
}
SFTP鏈接池的使用
我們已經(jīng)對鏈接池進(jìn)行了初始化,下面我們就可以從鏈接池中獲取我們需要的ChannelSftp來實現(xiàn)文件的上傳下載了。
下面實現(xiàn)了多種文件上傳和下載的方式:
1.直接把本地文件上傳到SFTP服務(wù)器的指定路徑;
2.把InputStream輸入流提交到SFTP服務(wù)器指定路徑中;
3.可以針對以上兩種上傳方式進(jìn)行進(jìn)度的監(jiān)測;
4.把SFTP服務(wù)器中的指定文件下載到本地機器上;
5.把SFTP服務(wù)器˙中的文件寫入指定的輸出流;
6.針對以上兩種下載方式,監(jiān)測下載進(jìn)度;
/**
* 上傳文件
*
* @param srcFilePath
* @param targetDir
* @param targetFileName
* @return
* @throws AwesomeException
*/
public boolean uploadFile(String srcFilePath, String targetDir, String targetFileName) throws AwesomeException {
return uploadFile(srcFilePath, targetDir, targetFileName, null);
}
/**
* 上傳文件
*
* @param srcFilePath
* @param targetDir
* @param targetFileName
* @param monitor
* @return
* @throws AwesomeException
*/
public boolean uploadFile(String srcFilePath, String targetDir, String targetFileName, SftpProgressMonitor monitor) throws AwesomeException {
ChannelSftp channelSftp = null;
try {
// 從鏈接池獲取對象
channelSftp = this.objectPool.borrowObject();
// 如果不存在目標(biāo)文件夾
if (!exist(channelSftp, targetDir)) {
mkdirs(channelSftp, targetDir);
}
channelSftp.cd(targetDir);
// 上傳文件
if (Objects.nonNull(monitor)) {
channelSftp.put(srcFilePath, targetFileName, monitor);
} else {
channelSftp.put(srcFilePath, targetFileName);
}
return true;
} catch (Exception e) {
throw new AwesomeException(500, "upload file fail");
} finally {
if (Objects.nonNull(channelSftp)) {
// 返還對象給鏈接池
this.objectPool.returnObject(channelSftp);
}
}
}
/**
* 上傳文件到目標(biāo)文件夾
*
* @param in
* @param targetDir
* @param targetFileName
* @return
* @throws AwesomeException
*/
public boolean uploadFile(InputStream in, String targetDir, String targetFileName) throws AwesomeException {
return uploadFile(in, targetDir, targetFileName, null);
}
/**
* 上傳文件,添加進(jìn)度監(jiān)視器
*
* @param in
* @param targetDir
* @param targetFileName
* @param monitor
* @return
* @throws AwesomeException
*/
public boolean uploadFile(InputStream in, String targetDir, String targetFileName, SftpProgressMonitor monitor) throws AwesomeException {
ChannelSftp channelSftp = null;
try {
channelSftp = this.objectPool.borrowObject();
// 如果不存在目標(biāo)文件夾
if (!exist(channelSftp, targetDir)) {
mkdirs(channelSftp, targetDir);
}
channelSftp.cd(targetDir);
if (Objects.nonNull(monitor)) {
channelSftp.put(in, targetFileName, monitor);
} else {
channelSftp.put(in, targetFileName);
}
return true;
} catch (Exception e) {
throw new AwesomeException(500, "upload file fail");
} finally {
if (Objects.nonNull(channelSftp)) {
this.objectPool.returnObject(channelSftp);
}
}
}
/**
* 下載文件
*
* @param remoteFile
* @param targetFilePath
* @return
* @throws AwesomeException
*/
public boolean downloadFile(String remoteFile, String targetFilePath) throws AwesomeException {
return downloadFile(remoteFile, targetFilePath, null);
}
/**
* 下載目標(biāo)文件到本地
*
* @param remoteFile
* @param targetFilePath
* @return
* @throws AwesomeException
*/
public boolean downloadFile(String remoteFile, String targetFilePath, SftpProgressMonitor monitor) throws AwesomeException {
ChannelSftp channelSftp = null;
try {
channelSftp = this.objectPool.borrowObject();
// 如果不存在目標(biāo)文件夾
if (!exist(channelSftp, remoteFile)) {
// 不用下載了
return false;
}
File targetFile = new File(targetFilePath);
try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
if (Objects.nonNull(monitor)) {
channelSftp.get(remoteFile, outputStream, monitor);
} else {
channelSftp.get(remoteFile, outputStream);
}
}
return true;
} catch (Exception e) {
throw new AwesomeException(500, "upload file fail");
} finally {
if (Objects.nonNull(channelSftp)) {
this.objectPool.returnObject(channelSftp);
}
}
}
/**
* 下載文件
*
* @param remoteFile
* @param outputStream
* @return
* @throws AwesomeException
*/
public boolean downloadFile(String remoteFile, OutputStream outputStream) throws AwesomeException {
return downloadFile(remoteFile, outputStream, null);
}
/**
* 下載文件
*
* @param remoteFile
* @param outputStream
* @param monitor
* @return
* @throws AwesomeException
*/
public boolean downloadFile(String remoteFile, OutputStream outputStream, SftpProgressMonitor monitor) throws AwesomeException {
ChannelSftp channelSftp = null;
try {
channelSftp = this.objectPool.borrowObject();
// 如果不存在目標(biāo)文件夾
if (!exist(channelSftp, remoteFile)) {
// 不用下載了
return false;
}
if (Objects.nonNull(monitor)) {
channelSftp.get(remoteFile, outputStream, monitor);
} else {
channelSftp.get(remoteFile, outputStream);
}
return true;
} catch (Exception e) {
throw new AwesomeException(500, "upload file fail");
} finally {
if (Objects.nonNull(channelSftp)) {
this.objectPool.returnObject(channelSftp);
}
}
}
/**
* 創(chuàng)建文件夾
*
* @param channelSftp
* @param dir
* @return
*/
protected boolean mkdirs(ChannelSftp channelSftp, String dir) {
try {
String pwd = channelSftp.pwd();
if (StringUtils.contains(pwd, dir)) {
return true;
}
String relativePath = StringUtils.substringAfter(dir, pwd);
String[] dirs = StringUtils.splitByWholeSeparatorPreserveAllTokens(relativePath, "/");
for (String path : dirs) {
if (StringUtils.isBlank(path)) {
continue;
}
try {
channelSftp.cd(path);
} catch (SftpException e) {
channelSftp.mkdir(path);
channelSftp.cd(path);
}
}
return true;
} catch (Exception e) {
return false;
}
}
/**
* 判斷文件夾是否存在
*
* @param channelSftp
* @param dir
* @return
*/
protected boolean exist(ChannelSftp channelSftp, String dir) {
try {
channelSftp.lstat(dir);
return true;
} catch (Exception e) {
return false;
}
}
集成到SpringBoot中
我們可以通過java config的方式,把我們已經(jīng)實現(xiàn)好的SftpClient類實例化到Spring IOC容器中來管理,以便讓開發(fā)人員在整個項目中通過@Autowired的方式就可以直接使用。
配置
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author zouwei
* @className SftpProperties
* @date: 2022/8/19 下午12:12
* @description:
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "sftp.config")
public class SftpProperties {
// 用戶名
private String username;
// 密碼
private String password;
// 主機名
private String host;
// 端口
private int port;
// 密鑰
private String privateKeyFile;
// 口令
private String passphrase;
// 通道鏈接超時時間
private int timeout;
// 鏈接池配置
private PoolConfig pool;
@Data
public static class PoolConfig {
//最大空閑實例數(shù),空閑超過此值將會被銷毀淘汰
private int maxIdle;
// 最小空閑實例數(shù),對象池將至少保留2個空閑對象
private int minIdle;
//最大對象數(shù)量,包含借出去的和空閑的
private int maxTotal;
//對象池滿了,是否阻塞獲?。╢alse則借不到直接拋異常)
private boolean blockWhenExhausted;
// BlockWhenExhausted為true時生效,對象池滿了阻塞獲取超時,不設(shè)置則阻塞獲取不超時,也可在borrowObject方法傳遞第二個參數(shù)指定本次的超時時間
private long maxWaitMillis;
// 創(chuàng)建對象后是否驗證對象,調(diào)用objectFactory#validateObject
private boolean testOnCreate;
// 借用對象后是否驗證對象 validateObject
private boolean testOnBorrow;
// 歸還對象后是否驗證對象 validateObject
private boolean testOnReturn;
// 定時檢查期間是否驗證對象 validateObject
private boolean testWhileIdle;
//定時檢查淘汰多余的對象, 啟用單獨的線程處理
private long timeBetweenEvictionRunsMillis;
//jmx監(jiān)控,和springboot自帶的jmx沖突,可以選擇關(guān)閉此配置或關(guān)閉springboot的jmx配置
private boolean jmxEnabled;
}
}
java Bean注入
import com.example.awesomespring.exception.AwesomeException;
import com.example.awesomespring.sftp.SftpClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author zouwei
* @className SftpConfig
* @date: 2022/8/19 下午12:12
* @description:
*/
@Configuration
public class SftpConfig {
@Autowired
private SftpProperties properties;
// 創(chuàng)建SftpClient對象
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "sftp.config")
public SftpClient sftpClient() throws AwesomeException {
return new SftpClient(properties);
}
}
通過以上代碼,我們就可以在項目的任何地方直接使用SFTP客戶端來上傳和下載文件了。
更多關(guān)于SpringBoot SFTP文件上傳下載的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Java?String類和StringBuffer類的區(qū)別介紹
這篇文章主要介紹了Java?String類和StringBuffer類的區(qū)別,?關(guān)于java的字符串處理我們一般使用String類和StringBuffer類有什么不同呢,下面我們一起來看看詳細(xì)介紹吧2022-03-03
Java+Selenium調(diào)用JavaScript的方法詳解
這篇文章主要為大家講解了java在利用Selenium操作瀏覽器網(wǎng)站時候,有時會需要用的JavaScript的地方,代碼該如何實現(xiàn)呢?快跟隨小編一起學(xué)習(xí)一下吧2023-01-01
Springboot項目異常處理及返回結(jié)果統(tǒng)一
這篇文章主要介紹了Springboot項目異常處理及返回結(jié)果統(tǒng)一,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價值,需要的朋友可以參考一下2022-08-08

