Spring FileUpload

基本概念

2017/6/21 updated

2019/01/20 (增加連結)

Spring Framework提供一個MutipartFile,讓我們可以接受form上傳的檔案,然後我們就可以將MutipartFile檔案複製到web server,這樣就完成檔案的上傳了。

首先,Controller (如:FileUploadController.java)需要列出所有檔案 (如:listUploadedFiles)、取得檔案 (如:serveFile)、上傳檔案 (如:handleFileUpload)。這個Controller會使用到StorageService,StorageService是個interface,真正的實作是FileSystemStorageService,FileSystemStorageService用到了StorageProperties來儲存上傳的路徑,另外,還用到了StorageException及StorageFileNotFoundException來處理例外事件。相對應的view是: uploadForm.html。

這個範例用了java 7的新類別,如:Path、Files。

FileUploadController.java

package com.example.demo.controller;

import com.example.demo.storage.StorageFileNotFoundException;
import com.example.demo.storage.StorageService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.io.IOException;
import java.util.stream.Collectors;

@Controller
public class FileUploadController {
  
    private final StorageService storageService;

    @Autowired
    public FileUploadController(StorageService storageService) {
        this.storageService = storageService;
    }

    //After Spring 4.3, we can use @GetMapping instead of @RequestMapping(value = "/", method = RequestMethod.GET)
    //@RequestMapping(value = "/", method = RequestMethod.GET)
    @GetMapping("/")
    public String listUploadedFiles(Model model) throws IOException {

        model.addAttribute("files", storageService
                .loadAll()
                .map(path ->
                        MvcUriComponentsBuilder
                                .fromMethodName(FileUploadController.class, "serveFile", path.getFileName().toString())
                                .build().toString())
                .collect(Collectors.toList()));
        
        return "uploadForm";
    }

    @GetMapping("/files/{filename:.+}")
    @ResponseBody
    public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

        Resource file = storageService.loadAsResource(filename);
        return ResponseEntity
                .ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\""+file.getFilename()+"\"")
                .body(file);
    }

    //After Spring 4.3, we can use @PostMapping instead of @RequestMapping(value = "/", method = RequestMethod.POST)
    @PostMapping("/")
    public String handleFileUpload(@RequestParam("file") MultipartFile file,
                                   RedirectAttributes redirectAttributes) {

        storageService.store(file);
        redirectAttributes.addFlashAttribute("message",
                "You successfully uploaded " + file.getOriginalFilename() + "!");

        return "redirect:/";
    }

    @ExceptionHandler(StorageFileNotFoundException.class)
    @ResponseBody
    public ResponseEntity<Void> handleStorageFileNotFound(StorageFileNotFoundException exc) {
     System.out.println("Error");
        return ResponseEntity.notFound().build();
    }

}

這個部份是接受路徑參數 (path variable),透過storageService讀取檔案,然後將檔案內容回傳回去,ResponseEntity將 HTTP headers內容及狀態碼(status code)包起來。ok()就是產生ok的狀態碼,header就是設定header,body就是設定內容。

    //After Spring 4.3, we can use @GetMapping instead of @RequestMapping(value = "/files/{filename:.+}", method = RequestMethod.GET)    
    @GetMapping("/files/{filename:.+}")
    @ResponseBody
    public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

        Resource file = storageService.loadAsResource(filename);
        return ResponseEntity
                .ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\""+file.getFilename()+"\"")
                .body(file);
    }

這個部份是接受參數,透過storageService儲存檔案。因為使用了redirect,所以,利用redirectAttribute.addFlashAttribute設定attribute(類似ViewAndModel的addObject)。

    //After Spring 4.3, we can use @PostMapping instead of @RequestMapping(value = "/", method = RequestMethod.POST)
    @PostMapping("/")
    public String handleFileUpload(@RequestParam("file") MultipartFile file,
                                   RedirectAttributes redirectAttributes) {

        storageService.store(file);
        redirectAttributes.addFlashAttribute("message",
                "You successfully uploaded " + file.getOriginalFilename() + "!");

        return "redirect:/";
    }

uploadForm.html,因為要上傳檔案,所以要加上「enctype="multipart/form-data"」及 「type="file"」。如果沒有「enctype="multipart/form-data"」,controller會接收到檔名而不是檔案內容。

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">
<body>

  <div th:if="${message}">
  <h2 th:text="${message}"/>
 </div>

 <div>
  <form method="POST" enctype="multipart/form-data" action="/">
   <table>
    <tr><td>File to upload:</td><td><input type="file" name="file" /></td></tr>
    <tr><td></td><td><input type="submit" value="Upload" /></td></tr>
   </table>
  </form>
 </div>

 <div>
  <ul>
   <li th:each="file : ${files}">
    <a th:href="${file}" th:text="${file}" />
   </li>
  </ul>
 </div>

</body>
</html>

StorageService.java

package com.example.demo.storage;


import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {

    void init();

    void store(MultipartFile file);

    Stream<Path> loadAll();

    Path load(String filename);

    Resource loadAsResource(String filename);

    void deleteAll();

}

FileSystemStorageService.java

package com.example.demo.storage;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

@Service
public class FileSystemStorageService implements StorageService {

    private final Path rootLocation;

    @Autowired
    public FileSystemStorageService(StorageProperties properties) {
        this.rootLocation = Paths.get(properties.getLocation());
    }

    @Override
    public void store(MultipartFile file) {
        try {
            if (file.isEmpty()) {
                throw new StorageException("Failed to store empty file " + file.getOriginalFilename());
            }
            Files.copy(file.getInputStream(), this.rootLocation.resolve(file.getOriginalFilename()));
        } catch (IOException e) {
            throw new StorageException("Failed to store file " + file.getOriginalFilename(), e);
        }
    }

    @Override
    public Stream<Path> loadAll() {
        try {
            return Files.walk(this.rootLocation, 1)
                    .filter(path -> !path.equals(this.rootLocation))
                    .map(path -> this.rootLocation.relativize(path));
            
        } catch (IOException e) {
            throw new StorageException("Failed to read stored files", e);
        }

    }

    @Override
    public Path load(String filename) {
        return rootLocation.resolve(filename);
    }

    @Override
    public Resource loadAsResource(String filename) {
        try {
            Path file = load(filename);
            Resource resource = new UrlResource(file.toUri());
            if(resource.exists() || resource.isReadable()) {
                return resource;
            }
            else {
                throw new StorageFileNotFoundException("Could not read file: " + filename);

            }
        } catch (MalformedURLException e) {
            throw new StorageFileNotFoundException("Could not read file: " + filename, e);
        }
    }

    @Override
    public void deleteAll() {
        FileSystemUtils.deleteRecursively(rootLocation.toFile());
    }

    @Override
    public void init() {
        try {
            Files.createDirectory(rootLocation);
        } catch (IOException e) {
            throw new StorageException("Could not initialize storage", e);
        }
    }
}

store是在接受file之後,將收到的file複製一份到目錄中,以原檔名儲存。Java 7以後可以直接用Files.copy複製檔案。這個範例也使用到Path,Path(this.rootLocation)的使用,請參閱http://javapapers.com/java/java-nio-path/

    @Override
    public void store(MultipartFile file) {
        try {
            if (file.isEmpty()) {
                throw new StorageException("Failed to store empty file " + file.getOriginalFilename());
            }
            Files.copy(file.getInputStream(), this.rootLocation.resolve(file.getOriginalFilename()));
        } catch (IOException e) {
            throw new StorageException("Failed to store file " + file.getOriginalFilename(), e);
        }
    }

loadAsResource是將檔案轉為urlResource並回傳。這個範例也使用到Path,Path(file)的使用,請參閱http://javapapers.com/java/java-nio-path/

    @Override
    public Resource loadAsResource(String filename) {
        try {
            Path file = load(filename);
            Resource resource = new UrlResource(file.toUri());
            if(resource.exists() || resource.isReadable()) {
                return resource;
            }
            else {
                throw new StorageFileNotFoundException("Could not read file: " + filename);

            }
        } catch (MalformedURLException e) {
            throw new StorageFileNotFoundException("Could not read file: " + filename, e);
        }
    }

在StorageProperties.java設定檔案的位置 (如: 「upload-dir」)。也也要在專案裡新增上傳路徑 (如:「upload-dir」就是「/upload-dir」)

package com.example.demo.storage;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("storage")
public class StorageProperties {

  // we can setup location here
    private String location = "upload-dir";
    //private String location = "c:\users\upload-dir";
    public String getLocation() {
        return location;
    }

    public void setLocation(String location) {
        this.location = location;
    }

}

StorageException.java 設定StorageException是RuntimeException的子類別。

package com.example.demo.storage;


public class StorageException extends RuntimeException {

    public StorageException(String message) {
        super(message);
    }

    public StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

StorageFileNotFoundException.java設定StorageFileNotFoundException是StorageException的子類別。

package com.example.demo.storage;

public class StorageFileNotFoundException extends StorageException {

    public StorageFileNotFoundException(String message) {
        super(message);
    }

    public StorageFileNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

因為StorageProperties設了@ConfigurationProperties("storage"),所以,在Application中(如:DemoFileUploadApplication.java)就要@EnableConfigurationProperties(StorageProperties.class),如果沒有,就會得到「Parameter 0 of constructor in com.example.storage.FileSystemStorageService required a bean of type 'com.example.storage.StorageProperties' that could not be found.」

的錯誤訊息。

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

import com.example.demo.storage.StorageProperties;

@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class DemoFileUploadApplication {

  public static void main(String[] args) {
  SpringApplication.run(DemoFileUploadApplication.class, args);
 }
}

*另外,Exception的部份請參考Error handling

JPA+File upload

如果顧客資料或產品資料要附上照片,要:

  • 複製com.example.storage裡所有的檔案(FileSystemStorageService.java、StorageException.java、StorageFileNotFoundException.java、StorageProperties.java、StorageService.java)到現有的JPA專案。
  • 在Application裡加「@EnableConfigurationProperties(StorageProperties.class)」(參考DemoFileUploadApplication.java)
  • 在資料表中要加上照片檔案名稱的欄位 (如: photo varchar(45)),不建議將照片放到資料表中,因為不能被快取(cache),存取速度會很慢。
  • 修改對應的entity (加兩個變數:photoFile、photo及對應的setter及getter),photoFile是為了儲存使用者上傳的檔案,所以,資料型態是MultipartFile,將photoFile設為transient,也就是不將這變數儲存到資料庫。photo則是一般的String,我們會把photoFile的檔案名稱存在這個變數,並將內容存到資料庫。
package com.example.demo.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

//import org.springframework.data.annotation.Transient;
import org.springframework.web.multipart.MultipartFile;

@Entity
@Table(name = "customer")
public class Customer {
  @Id
 @GeneratedValue(strategy = GenerationType.AUTO)
 private Long id;
 private String name;

 @Column(name = "address")
 private String address;
 private int weight;
 
 //@Transient
 private transient MultipartFile photoFile;
 
 private String photo;

 public Long getId() {
  return id;
 }

 public void setId(Long id) {
  this.id = id;
 }

 public String getName() {
  return name;
 }

 public void setName(String name) {
  this.name = name;
 }

 public String getAddress() {
  return address;
 }

 public void setAddress(String address) {
  this.address = address;
 }

 public int getWeight() {
  return weight;
 }

 public void setWeight(int weight) {
  this.weight = weight;
 }

 public MultipartFile getPhotoFile() {
  return photoFile;
 }
 
 public void setPhotoFile(MultipartFile photoFile) {
  this.photoFile = photoFile;
 }

  public void setPhoto() {
  this.photo = photoFile.getOriginalFilename();
 }

 public String getPhoto() {
  return photo;
 }

 public void setPhoto(String photo) {
  this.photo = photo;
 }



}

  • 修改view (如:customerCreate.html),記得要有「enctype="multipart/form-data"」,否則,controller會接收到檔名而不是檔案內容。

customerCreate.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Create New Customer</title>
</head>
<body>

<form action="customerCreate" enctype="multipart/form-data" method ="post">

   姓名:<input type="text" name="name"/><br/>
   地址:<input type="text" name = "address"/><br/>
   重量:<input type="text" name ="weight"/><br/>
   照片:<input type="file" name="photoFile" /><br/>
 <input type="submit" value="Submit"/>

</form>

</body>
</html>
  • 修改對應的controller,先autowire StorageService。
@Controller
public class CustomerController {
  @Autowired
 CustomerDAO dao;
 
    private final StorageService storageService;

    @Autowired
    public CustomerController(StorageService storageService) {
        this.storageService = storageService;
    }
  • 從view取得PhotoFile,儲存PhotoFile,將PhotoFile的檔名複製到photo,將資料儲存到資料庫。
    @RequestMapping(value = "/customerCreate", method = RequestMethod.POST)
    public ModelAndView processFormCreate(@ModelAttribute Customer cus) throws SQLException {

       ModelAndView model = new ModelAndView("redirect:/customerRetrieveAll");
       storageService.store(cus.getPhotoFile());
       cus.setPhoto();//copy file name to the field photo
       //cus.setPhoto(cus.getPhotoFile().getOriginalFilename());//copy file name to the field photo
       
       dao.save(cus);
       model.addObject(cus);
       return model;
    }
  • 要在專案裡新增上傳路徑 (如:「upload-dir」就是「/upload-dir」)
  • 記得也要修改update的controller及相關的view
    @RequestMapping(value = "/customerUpdate", method = RequestMethod.POST)
    public ModelAndView processFormUpdate(@ModelAttribute Customer cus, BindingResult bindingResult) throws SQLException {
       ModelAndView model = new ModelAndView("redirect:/customerRetrieveAll");
       if (bindingResult.hasErrors()){
         System.out.println(bindingResult);
       }
       //if photo is updated
       if (!("").equals(cus.getPhotoFile().getOriginalFilename())){
           storageService.store(cus.getPhotoFile());
           cus.setPhoto();//copy file name to the field photo
       }
       dao.save(cus);             

       return model;
    }

updateCustomer.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Update Customer</title>
</head>
<body>

<form action="customerUpdate" th:object="${customer}" enctype="multipart/form-data" method ="post">

   姓名:<input type="text" name="name" th:field="*{name}"/><br/>
   地址:<input type="text" name = "address" th:field="*{address}"/><br/>
   重量:<input type="text" name ="weight" th:field="*{weight}"/><br/>
   照片:<label th:text="*{photo}">photo</label><br/>
 <input type="hidden" name="photo" th:field="*{photo}"/><br/>
 <input type="file" name="photoFile"/><br/>
 <input type="hidden" name ="id" th:field="*{id}"/>
 <input type="submit" value="Submit"/>

</form>

</body>
</html>
  • 要列出顧客或產品對應的照片,先在controller加入:
    @GetMapping("/files/{filename:.+}")
    @ResponseBody
    public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

        Resource file = storageService.loadAsResource(filename);
        return ResponseEntity
                .ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\""+file.getFilename()+"\"")
                .body(file);
    }
  • 修改html就可以利用剛剛加進去的方法取得照片
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>List All Customers</title>
</head>
<body>
<a href="customerCreate">新增顧客</a>
<table>
  <tr>
  <th>編號</th>
  <th>姓名</th>
  <th>地址</th>
  <th>重量</th>
  <th>照片</th>
  <th></th>
 </tr>
    <tr th:each="customer : ${allCustomers} " th:object="${customer}">
        <td th:text="*{id}">1</td>
        <td th:text="*{name}">Ben</td>
        <td th:text="*{address}">Fu Jen</td>
        <td th:text="*{weight}">56</td>
        <td><img th:src="'files/'+*{photo}" width="100px"/></td>
        <td><a th:href="@{customerUpdate(id=*{id})}">修改</a> <a th:href="@{customerDelete(id=*{id})}">刪除</a></td>
    </tr>
</table>


</body>
</html>

參考資料