思考过程
刚开始思考要怎么实现这个功能的时候,最困扰我的是要设计哪几个 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();
}
}
}
|