Как загрузить файл в контроллер через Ajax?
Изучаю Java. Дошла учеба до Web.
Решил сделать добавление объекта через модальное окно, а не через новую страницу.
Там нужно использовать Ajax. Как я понял, всю форму нагуглил, как загружать, а как передать файл в контроллер, так и не понял.
Контроллер:
@PostMapping("/createCandidate")
public String createCandidate(
@RequestBody Candidate candidate,
@RequestParam("file") MultipartFile file
) throws IOException {
candidate.setCreated(LocalDateTime.now());
this.store.create(candidate);
return PATS;
}
Форма:
<form id="data" method="post" enctype="multipart/form-data">
<input type="hidden" name="id" id="id"/>
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" id="name">
</div>
<div class="form-group">
<label for="name">Photo</label>
<input type="file" class="form-control" name="file" id="file">
</div>
<div class="form-group">
<label for="desc">Description</label>
<input type="text" name="desc" id="desc">
</div>
<div class="form-check">
<input type="checkbox" name="visible" id="visible">
<label for="visible">Publish</label>
</div>
</form>
Скрипт:
<script>
function send_candidate() {
let moreinfo = '';
$('input[type=checkbox]').each(function () {
moreinfo = this.checked;
});
$.ajax({
url: "/createCandidate",
dataType: "json",
type: "POST",
cache: false,
contentType: "application/json",
data: JSON.stringify({
id: $("#id").val(),
name: $("#name").val(),
desc: $("#desc").val(),
visible: $("#visible").val().Boolean = Boolean(moreinfo)
})
})
}
</script>
Модель:
public class Candidate {
private int id;
private String name;
private byte[] photo;
private boolean visible;
private String desc;
private LocalDateTime created;
}
Ответы (2 шт):
У тебя AJAX-запрос находится в функции, а в какой момент функция вызывается? При вызове функции у тебя и отправится AJAX на заданный урл: "url: "/createCandidate",". Только убери большую букву из ссылки на бэк - '/createcandidate'.
В этом была проблема?
Backend
Начнем с контроллера:
@PostMapping("/createCandidate")
public String createCandidate(
@RequestBody Candidate candidate,
@RequestParam("file") MultipartFile file
) throws IOException {
candidate.setCreated(LocalDateTime.now());
this.store.create(candidate);
return PATS;
}
Здесь вы последовательно пытаетесь получить:
@RequestBody Candidate candidate
Который ожидает, что в теле POST-запроса будет сериализованный объект.
В вашем случае подразумевалось, что вы пришлете POST запрос c заголовкомContent-type: application/jsonи c cформированным c помощью JavaScript'а JSON'ом, который будет занимать все тело запроса@RequestParam("file") MultipartFile file
Который подразумевает, что Вы пришлете POST-запрос c заголовкомContent-type: multipart/form-data, в котором будет часть, посвященная файлу, которая будет выглядеть примерно так:
------WebKitFormBoundary5oVe5Ocv731AlUHT
Content-Disposition: form-data; name="file"; filename="2022-08-29_06-32.png"
Content-Type: image/png
Два данных объявления конфликтуют и не могут сосуществовать одновременно.
Один ожидает запрос одного типа, другой - второго.
Нужно выбрать что-то одно: и в вашем случаеь легче выбрать multipart/form-data
Соответственно, нам нужно всего лишь понять, как нам можно передать форму типа multipart/form-data.
И тут на самом деле все просто: для этого достаточно заменить аннотацию @RequestBody на @ModelAttribute
@PostMapping("/createCandidate")
public String createCandidate(
@ModelAttribute Candidate candidate,
@RequestParam("file") MultipartFile file
) throws IOException {
// ...
}
Также для корректной последующей отдачи изображений во время их запросов, было бы неплохо еще сохранять:
Content-typeили тип изображения (image/jpeg,image/png` и пр)- Размер изображения Для того, чтобы позже можно было указать их в заголовках ответа, отдающих эти изображения.
Поэтому добавим в модель поля:
private Long size;
private String contentType;
ну и раз уж мы залезли в модель, то сразу вам скажу, что desc очень плохое имя для объекта, который будет маппиться на таблицу БД, потому что DESC - это зарезервированное ключевое слово в SQL - оно отвечает за указание направления сортировки
select * from users where 1=1 ORDER BY ID DESC; /* <- и это точно не ваше поле*/
Поэтому я дожидаться беды не стал и сразу переименовал его в
private String description;
Оно так и понятнее.
Кстати, раз уж мы договорились о БД, то сразу скажу, что тип колонки BLOB, не такой уж и большой (у меня во всяком случае изображения не помещаются) и я его создавал как LONGBLOB
@Lob
@Column(name = "photo", columnDefinition="LONGBLOB")
private byte[] photo;
Для справки:
TINYBLOB: < 255 байт
BLOB: < 65 535 байт
MEDIUMBLOB: < 16 777 215 байт
LONGBLOB: < 4 294 967 295 байт
Итого модель:
public class Candidate {
private int id;
private String name;
private byte[] photo;
private boolean visible;
private String description; // <- его мы переименовали
private Long size; // <- а вот этих двух товарищей
private String contentType; // <- мы добавили, чтобы корректно
// отдавать http ответ с изображением
private LocalDateTime created;
}
После таких приготовлений мы можем в контроллере прокинуть данные из MultipartFile file в Candidate candidate следующим образом:
candidate.setPhoto(file.getBytes());
candidate.setContentType(file.getContentType());
candidate.setSize(file.getSize());
В итоге метод будет выглядеть так
@PostMapping("/createCandidate")
public String createCandidate(
@ModelAttribute Candidate candidate,
@RequestParam("file") MultipartFile file
) throws IOException {
candidate.setPhoto(file.getBytes());
candidate.setContentType(file.getContentType());
candidate.setSize(file.getSize());
candidate.setCreated(LocalDateTime.now());
this.store.create(candidate); // за данный метод я не ручаюсь, он под вашей ответственностью
return PATS;
}
Frontend
Для начала:
оставьте, пожалуйста, jQuery... дайте ему уже умереть спокойно.
Ванильный JavaScript уже давно умеет делать все что нужно:
- запросы легко и просто можно делать с помощью
fetch() - искать элементы по CSS-селектору можно с помощью
querySelector()/querySelectorAll()
шаблон:
Не забудем, что мы переименовали поле, поэтому на нужно поправить соответствующее поле в форме
<div class="form-group">
<label for="description">Description</label>
<input type="text" name="description" id="description">
</div>
Возвращаемся к JavaScript:
Если у нас есть нормально оформленная форма, с корректными именами полей, которые совпадают с именами полей модели - нам необязательно ручками брать и по одному полю извлекать все данные вот так вот:
name: $("#name").val(),
Можно просто найти элемент формы и передать его в FormData
let formElement = document.getElementById("data");
let data = new FormData(formElement);
а потом его можно будет передать прямиком в fetch();
Но перед этим я покажу Вам как, как найти список элементов по CSS-селектору и проитерировать их без jQuery:
document.querySelectorAll('input[type=checkbox]').forEach(function (element) {
moreinfo = element.checked;
});
document.querySelectorAll() выглядит чуть более громоздко чем $(), но он есть везде, не требует никаких подключений и работает отлично... и это достаточная цена за то, чтобы не тоскать с одной страницы на другую труп jQuery.
Кстати, в форму можно и ручками что-то добавить если нужно.
Вот так это делается:
data.append('visible', moreinfo);
Запрос
Чтобы сделать нужный нам запрос достаточно сделать:
fetch("/createCandidate", {
method : "POST",
body: data
})
Это асинхронная функция и возвращает она Promise.
Соответственно обработать результаты можно в then()
Итого JS получился таким:
<script>
function send_candidate() {
let moreinfo = '';
document.querySelectorAll('input[type=checkbox]').forEach(function (element) {
moreinfo = element.checked;
});
let formElement = document.getElementById("data");
let data = new FormData(formElement);
data.append('visible', moreinfo);
fetch("/createCandidate", {
method : "POST",
body: data
}).then(response => response.text()) // Если вы возвращаете application/json, то .then(response => response.json())
.then(html => console.log(html));
}
</script>
Что в итоге?
Все поля на месте и содержимое фото тоже сохранилось.
проверял корректность я с помощью такого метода, который находит в таблице нужную запись и возвращает картинку
@GetMapping("/candidate/{id}/photo")
public ResponseEntity<?> photo(@PathVariable("id") Long id) throws IOException {
Optional<Candidate> candidateSearchResult = candidateRepository.findById(id);
if(!candidateSearchResult.isPresent()) {
// если не найдено, то выбрасываем 404
return ResponseEntity.notFound().build();
}
Candidate candidate = candidateSearchResult.get();
return ResponseEntity
.ok()
.contentType(MediaType.valueOf(candidate.getContentType())) // вот тут нам понадобились Content-Type
.contentLength(candidate.getSize()) // и size
.body(candidate.getPhoto()); // и в body передаем byte[] photo
}
На выходе получаем:
Все работает как надо!
Безопасность
Если у Вас подключен Spring Security(и только в этом случае) - не забудьте пробросить CSRF токен в форму
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
Иначе Spring Security не авторизует ваш запрос

