===============================================

  ccb_warlock

 

===============================================

今年1月临时接手了一个spring boot项目的开发,其中包含了文件上传和获取的功能。但是发现原功能通过直接压缩文件成字符串然后存入数据库来实现,于是我准备改写存入FTP来优化。但是发现查了很多文章,几乎找不到一篇代码结构清晰且能跑的起来的代码片段,于是我整理了这篇记录供需要的人参考。


这里我只实现文件的上传功能,文件的下载因为是内网项目,所以我还是采取了nginx代理FTP的的方式直接通过url来访问。

 


一、部署ftp


 


二、pom引用

这里我使用的是java中普遍操作ftp的轮子org.apache.commons.net。

<dependency>
    <groupId>commons-net</groupId>
    <artifactId>commons-net</artifactId>
    <version>3.8.0</version>
</dependency>

 


三、application.yaml增加配置信息

将FTP的配置记录到配置文件中。

ftp:
  # ftp服务的地址
  host: 127.0.0.1
  # 连接端口
  port: 38021
  # 用户名
  username: myftp
  # 密码
  password: 123456
  # 模式(PORT.主动模式,PASV.被动模式)
  mode: PASV
  # http访问的路径前缀
  url: http://127.0.0.1:8001/ftp

 


四、工具类封装

为了方便后续调用,我抽象了ftp操作的方法集成到了一个独立的工具类(FTPUtil)。

1 package com.example.demo.utils;
  2 
  3 import lombok.extern.slf4j.Slf4j;
  4 import org.apache.commons.lang3.StringUtils;
  5 import org.apache.commons.net.ftp.FTP;
  6 import org.apache.commons.net.ftp.FTPClient;
  7 import org.apache.commons.net.ftp.FTPReply;
  8 import org.springframework.beans.factory.annotation.Value;
  9 import org.springframework.stereotype.Component;
 10 import org.springframework.web.multipart.MultipartFile;
 11 
 12 import java.io.IOException;
 13 import java.io.InputStream;
 14 
 15 @Slf4j
 16 @Component
 17 public class FTPUtil {
 18     private static String host;
 19     private static int port;
 20     private static String userName;
 21     private static String password;
 22     private static String mode;
 23 
 24     @Value("${ftp.host:127.0.0.1}")
 25     private void setHost(String host) {
 26         FTPUtil.host = host;
 27     }
 28 
 29     @Value("${ftp.port:21}")
 30     private void setPort(int port){
 31         FTPUtil.port = port;
 32     }
 33 
 34     @Value("${ftp.username:''}")
 35     private void setUserName(String userName){
 36         FTPUtil.userName = userName;
 37     }
 38 
 39     @Value("${ftp.password:''}")
 40     private void setPassword(String password){
 41         FTPUtil.password = password;
 42     }
 43 
 44     @Value("${ftp.mode:PASV}")
 45     private void setMode(String mode){
 46         FTPUtil.mode = mode;
 47     }
 48 
 49     private static FTPClient getInstance(String workingDirectory) {
 50         FTPClient ftpClient = new FTPClient();
 51         ftpClient.setControlEncoding("UTF-8");
 52 
 53         try{
 54             ftpClient.connect(host, port);
 55             ftpClient.login(userName, password);
 56 
 57             int replyCode = ftpClient.getReplyCode();
 58 
 59             if(!FTPReply.isPositiveCompletion(replyCode)){
 60                 log.error("FTP服务({}:{})连接失败。", host, port);
 61                 throw new Exception("FTP服务连接失败");
 62             }
 63             log.info("FTP服务({}:{})连接成功。", host, port);
 64 
 65             if("PORT".equals(mode)){
 66                 ftpClient.enterLocalActiveMode();
 67             }
 68             else{
 69                 ftpClient.enterLocalPassiveMode();
 70             }
 71 
 72             ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
 73             changeWorkingDirectory(ftpClient, workingDirectory);
 74         }
 75         catch(Exception e){
 76             e.printStackTrace();
 77         }
 78 
 79         return ftpClient;
 80     }
 81 
 82     private static void changeWorkingDirectory(FTPClient ftpClient, String workingDirectory) throws IOException {
 83         String[] directories = workingDirectory.split("/");
 84 
 85         for(String directory : directories){
 86             if(StringUtils.isBlank(directory)){
 87                 continue;
 88             }
 89 
 90             if(ftpClient.changeWorkingDirectory(directory)){
 91                 continue;
 92             }
 93 
 94             ftpClient.makeDirectory(directory);
 95             ftpClient.changeWorkingDirectory(directory);
 96         }
 97     }
 98 
 99     private static void close(FTPClient client){
100         if(null == client){
101             return;
102         }
103 
104         try{
105             client.logout();
106         }
107         catch(Exception e){
108             log.error("FTP退出登录失败。异常信息:{}", e.getMessage());
109         }
110         finally {
111             if(client.isConnected()){
112                 try{
113                     client.disconnect();
114                     log.info("FTP断开连接成功。");
115                 }
116                 catch(Exception e){
117                     log.error("FTP断开连接失败。异常信息:{}", e.getMessage());
118                 }
119             }
120         }
121     }
122 
123     public static void upload(String workingDirectory, String fileName, MultipartFile file) throws Exception {
124         InputStream inputStream = file.getInputStream();
125         FTPClient client = getInstance(workingDirectory);
126 
127         if (client.storeFile(fileName, inputStream)) {
128             log.info("上传文件{}成功。", fileName);
129         }
130         else{
131             log.error("上传文件{}失败({})。", fileName, client.getReplyString());
132         }
133 
134         close(client);
135         inputStream.close();
136     }
137 
138 }

 


五、调用

为了方便呈现,这里设计了一个post接口方便测试

1)服务(IFileService、FileServiceImpl)

IFileService

1 package com.example.demo.api.interfaces;
2 
3 import org.springframework.web.multipart.MultipartFile;
4 
5 public interface IFileService {
6 
7     void uploadFile(long companyId, MultipartFile file) throws Exception;
8 
9 }

 

FileServiceImpl

package com.example.demo.domain.service;

import org.apache.commons.lang3.StringUtils;
import com.example.demo.api.interfaces.IFileService;
import com.example.demo.utils.FTPUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.Calendar;
import java.util.List;
import java.util.UUID;

@Service
public class FileServiceImpl implements IFileService {

    @Value("${ftp:url:''}")
    private String ftpUrl;

    @Override
    public String uploadFile(long companyId, MultipartFile file) throws Exception {
        String fileName = getUuidFileName(file.getOriginalFilename());

        //无多级目录
        FTPUtil.upload("", fileName, file);
        return ftpUrl + "/" + fileName;

        //存到company/{companyId}/images路径下
        //FTPUtil.upload("company/" + companyId + "/images", fileName, file);
        //return ftpUrl + "/company/" + companyId + "/images/" + fileName;
    }

    private String getUuidFileName(String originalFileName){
        String uuid = getUuid();
        if(StringUtils.isBlank(originalFileName)){
            return uuid;
        }

        int i = originalFileName.lastIndexOf('.');
        return -1 == i ? uuid : uuid + originalFileName.substring(i);
    }

    private String getUuid(){
        String uuid = UUID.randomUUID().toString();
        return uuid.replace("-", "");
    }

}

 

2)控制器(FileController)

PS. 这里的ApiResult是demo中封装的接口输出格式类

1 package com.example.demo.api.controller;
 2 
 3 import com.example.demo.api.interfaces.IFileService;
 4 import com.example.demo.common.base.BaseController;
 5 import com.example.demo.entity.vo.ApiResult;
 6 import io.swagger.annotations.Api;
 7 import io.swagger.v3.oas.annotations.Operation;
 8 import org.springframework.web.bind.annotation.*;
 9 import org.springframework.web.multipart.MultipartFile;
10 
11 import javax.annotation.Resource;
12 
13 @Api(tags = "文件")
14 @RestController
15 @RequestMapping("file")
16 public class FileController {
17 
18     @Resource
19     private IFileService fileService;
20 
21     @Operation(summary = "上传文件")
22     @PostMapping(path = "/images/{companyId}")
23     public ApiResult uploadFile(@PathVariable long companyId, @RequestParam("file") MultipartFile file)
24             throws Exception {
25         String url = fileService.uploadFile(companyId, file);
26         return ApiResult.success(url);
27     }
28 
29 }

 


六、测试

接着我们用postman测试post接口,其中companyId随便赋值一个数。

 

当FileServiceImpl使用“无多级目录”的代码时,文件将会保存在“FTP物理路径/用户名”的目录下(如果ftp完全根据我提供的资料部署,则文件保存到/Users/mbp/docker/vol/vsftpd/data/myftp)。

 

当FileServiceImpl使用“存到company/{companyId}/images路径下”,文件将会保存在“FTP物理路径/用户名/company/{companyId}/images”的目录下如果ftp完全根据我提供的资料部署,则文件保存到/Users/mbp/docker/vol/vsftpd/data/myftp/company/{companyId}/images)。

 


七、我遇到的问题

1)500 Illegal PORT command.

答:

因为我部署的ftp是被动模式,所以ftp获取客户端实例时需要设置模式(详见“四、工具类封装”的65 - 70行代码)。

 

2)Connection closed without indication.

答:

这是我在mac上通过docker部署时,如果容器的端口20映射笔记本的端口20、容器端口21映射笔记本的端口21,则会引起该报错(如果有大佬愿意指点,请在评论中留言)。我采取的解决方案是换笔记本的端口绑(38021、38022)。

 

3)上传的文件损坏

答:

在初始化客户端实例时需要设置其文件类型为二进制(详见“四、工具类封装”的第72行代码)

PS. 很多文章的代码都没注意这个问题,文件看着是上传到ftp目录了,但实际该文件损坏。

 

4)ftp多级目录没有自动生成

答:

客户端实例的changeWorkingDirectory方法无法处理多级目录,所以设计循环遍历路径,有需要则创建(详见“四、工具类封装”的82 - 97行代码)