在 Spring Boot ToDolist 中实现图片上传和展示

使用 MultipartFile 实现了基础的图片上传,运用 SHA-256 优化了图片的存储路径。

思考过程

刚开始思考要怎么实现这个功能的时候,最困扰我的是要设计哪几个 API,请求流程是什么样的?

首先,我选择的是使用 MultipartFile,这是 Spring 提供的一个接口,用于处理 HTTP 文件上传(在我的项目中先默认用户只会上传图片)。当前端通过 form-data 方式提交文件时, Spring Boot 会将上传的文件封装成 MultipartFile 对象。

这样就有了第一个要写的接口 uploadFile,上传路径为 /v1/files,发送 POST 请求,上传 file。接口要实现的功能就是把文件存储到服务端。这里就迎来了我当时的第一个问题,上传的图片怎么和对应的 todo 关联起来呢?需要明确的是,一开始想的是直接把图片存到数据库里,显然这个方案不太合理,会占用很大的存储空间。后来发现可以存 URL,也就是前端上传图片,后端存文件,数据库存 URL。如此一来,uploadFile 中除了存文件,还需要返回文件的 URL。有了文件的 URL,就可以把它存到对应的 todo 中去。

所以对于第二个接口 updateFile,上传路径为 /v1/users/todos/${id}/files,发送 PATCH 请求,前端上传用户选择的 todo 的 id 和第一个接口返回的 URL,后端实现把 URL 存到对应的 todo 下面。

最后还有一个问题,网页上怎么显示这些图片呢?查询相关资料后发现,前端在 innerHTML 里插入 <img src="${todo.filePath}"> 之后,浏览器会自动向 src 指定的 URL 发送一个 GET 请求来获取图片资源。所以我还要写 getImage 这个接口,返回图片资源。

下面就是前端和各个接口的实现。

前端

在前端中增添的代码如下。

1
2
3
4
5
<form id="form" enctype="multipart/form-data">
    <input type="number" id="upload-todo-id" placeholder="Enter the id of the todo you want to upload" />
    <input type="file" id="fileInput" /><br />
    <button onclick="event.preventDefault(); uploadFile()" type="submit">Upload</button>
</form>

displayTodos 函数中增添的内容。

1
2
3
4
5
6
7
const imageList = document.getElementById('image-list');
        for (let i = 0; i < todos.length; i++) {
            const todo = todos[i];
            const filePath = todo.filePath;
            if (filePath) {
                imageList.innerHTML += `<img src="${filePath}">`;
            }

上传图片部分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
async function uploadFile() {
        const id = document.getElementById('upload-todo-id').value;
        const inputFile = document.getElementById('fileInput');
        if (id.trim() === '') {
            alert('ToDo id cannot be empty.');
            return;
        }
        const filePath = await upload(`/v1/files`, inputFile);
        await getUrl(`/v1/users/todos/${id}/files`, filePath);
        document.getElementById('upload-todo-id').value = '';
        fetchTodos(0,3);
    }

FileController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@RestController
@AllArgsConstructor
@RequestMapping("/v1/files")
public class FileController {
    private final FileUpdateService fileUpdateService;

    @PostMapping("")
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
        return ResponseEntity.status(HttpStatus.OK).body(fileUpdateService.uploadFile(file));
    }

    @GetMapping("/{id}")
    public ResponseEntity<byte[]> getImage(@PathVariable String id) {
        return ResponseEntity.status(HttpStatus.OK).contentType(MediaType.IMAGE_JPEG).body(fileUpdateService.getImage(id));
    }
}

FileUpdateService

1
2
3
4
public interface FileUpdateService {
    String uploadFile(MultipartFile file);
    byte[] getImage(String id);
}

FileUpdateServiceImpl

在存储 URL 时,还有一个需要关注的点,就是文件的命名。用户上传的文件名可能会有重复,或存在非法字符,所以需要将文件名进行转换。

这里我采用了 SHA-256 加密算法,将文件名转换成 64 位的字符串,然后使用 Base64 编码,得到一个 URL 安全的字符串作为文件名。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@RequiredArgsConstructor
@Service

public class FileUpdateServiceImpl implements  FileUpdateService{
    @Value("${file.upload-dir}")
    private String uploadDir;

    @Override
    public String uploadFile(MultipartFile file) {
        try {
            // 保存上传的图片
            Path uploadPath = Paths.get(uploadDir);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }

            String fileName = file.getOriginalFilename();
            Path filePath = uploadPath.resolve(fileName);
            MessageDigest algorithm = MessageDigest.getInstance("SHA-256");

            InputStream is = file.getInputStream();
            DigestInputStream hashingStream = new DigestInputStream(is, algorithm);
            Files.copy(hashingStream, filePath, StandardCopyOption.REPLACE_EXISTING);
            byte [] digest = algorithm.digest();
            // String newFileName = new BigInteger(1, digest).toString(16);
            String newFileName = Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
            Path newFilePath = uploadPath.resolve(newFileName);
            Files.move(filePath, newFilePath);

            // 返回文件路径
            return "/v1/files/"+newFileName;
        } catch (IOException e) {
            throw new FileInteralServerErrorException();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public byte[] getImage(String id) {
        try {
            Path uploadPath = Paths.get(uploadDir);
            String fileName =id;
            Path filePath = uploadPath.resolve(fileName);
            FileInputStream inputStream = new FileInputStream(filePath.toString());
            return inputStream.readAllBytes();

        } catch (IOException e) {
            throw new FileInteralServerErrorException();
        }
    }
}
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy