Что происходит в этом вызове API?
Одной из основных функций, над которой я работал, является «Экспорт здания». Это API, который отправляет zip-файл csv во внешний интерфейс, когда пользователь нажимает «Загрузить».
В этой статье описаны проблемы, которые я решил в этом API:
- Тип ответа
- Обработка ошибок
- Асинхронная обработка
- Почтовая логика
- Java-поток
- OpenCSV
- Подтвердить на Почтальоне
Тип ответа
Во-первых, наш возвращаемый тип — StreamingResponseBody
. Это поток, поэтому мы можем асинхронно записывать наш вывод. Его описание ниже:
Тип возвращаемого значения метода контроллера для асинхронной обработки запроса, когда приложение может писать непосредственно в ответ
OutputStream
, не задерживая поток контейнера сервлетов.
И наш контроллер очень простой
@PostMapping("/{scenarioId}/export") public ResponseEntity<StreamingResponseBody> exportScenario(@PathVariable String scenarioId, @RequestBody ScenarioResponse scenarioResponse){ return exportService.exportScenario(scenarioResponse); }
Затем в нашем сервисном слое мы создаем StreamingResponseBody
У него есть метод интерфейса writeTo()
, который нам нужно реализовать. Обратите внимание, что это дает нам outputStream
public ResponseEntity<StreamingResponseBody> exportBuildings(@RequestBody List<Long> buildingIds) throws Throwable { return exportBuildingService.exportBuilding(buildingIds); }
В фактическом сервисном методе мы заключаем этот StreamingResponseBody
в ResponseEntity
public ResponseEntity<StreamingResponseBody> exportScenarioBuilding( String zipName, List<Long> buildingIds) throws Throwable { CompletableFuture<List<File>> createFileResult = createBuildingReport(buildingIds); try{ createFileResult.get(); StreamingResponseBody responseBody = outputStream -> { CompletableFuture<Void> zipFileResult = getZipFileResultFuture(createFileResult, outputStream); try { zipFileResult.get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e.getCause()); } }; return ResponseEntity .ok() .header("Content-Disposition", "attachment; filename=" + zipName) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(responseBody); }catch (ExecutionException | InterruptedException e){ throw e.getCause(); } }
Убедитесь, что тип содержимого равен APPLICATION_OCTET_STREAM, чтобы соответствовать нашему возвращаемому типу.
Обработка ошибок
Заметили, что я оборачиваю всю логику StreamingResponseBody
в try-catch? Если вы вернете ResponseEntity.ok()
и в более поздней части вызова возникнет ошибка, интерфейс получит код состояния 200, но с пустым телом. Это очень запутанно.
Сложность возврата StreamingResponseBody
заключается в том, что нет простого способа изменить код HttpStatus в случае исключения.
Это связано с тем, что, возвращаяResponseEntity.ok()
, вы уже отправили заголовок ответа в выходной поток (и, возможно, вы уже отправили его по сети).
Перехватив любую ошибку из логики подготовки файла и повторно выдав ее, мы можем позволить Spring ServiceExceptionHandler
преобразовать код состояния 500 во внешний интерфейс.
Асинхронная обработка
Мой вариант использования - вернуть несколько файлов в одном zip-ответе, поэтому, естественно, мне нужно обрабатывать каждый файл параллельно.
И способ сделать это с помощью Future
, точнее CompletableFuture
для цепочки операций с этими фьючерсами
Это проходит через основной вариант использования будущего:
Затем у меня есть этот метод для обработки логики асинхронного создания файла.
1. Создайте List<CompletableFuture<File>>
, где у меня есть другие вспомогательные методы для возврата CompletableFuture<File>
для разных типов файлов.
2. Статический метод CompletableFuture.allOf
позволяет дождаться завершения всех Future в списке CompletableFuture.
3. Сопоставьте все эти Future‹File› в один CompletableFuture<List<File>>
и верните
private CompletableFuture<List<File>> createBuildingReport( List<Long> buildingIds){ List<CompletableFuture<File>> futureList = new ArrayList<>(); futureList.add(getUSBuildingFuture(buildingIds)); futureList.add(getUKBuildingFuture(buildingIds)); // wait for completion of all Futures CompletableFuture<Void> cfFiles = CompletableFuture.allOf(futureList.toArray(new CompletableFuture[futureList.size()])); // collect all completableFutures results return cfFiles.thenApply(ignoredVoid -> { List<File> result = futureList.stream() .map(CompletableFuture::join) .collect(toList()); return result; } ); }
Обработка ошибок для будущего
Также стоит упомянуть обработку ошибок на будущее. Runnable
и Callable
, которые мы отправили исполнителю, не обрабатываются, пока мы не вызовем Future.get()
Итак, как упоминалось ранее, просто деформируйте Future.get()
part в try-catch.
Почтовая логика
Что теперь делать с результатом CompletableFuture<List<File>>
?
У нас есть этот метод, который принимает результат, и outputStream из StreamingResponseBody
Этот метод будет читать байт из Future и записывать в outputStream
private CompletableFuture<Void> getZipFileResultFuture(CompletableFuture<List<File>> createFileResult, OutputStream outputStream){ return createFileResult.thenApply(files -> { try { FileUtils.zipFilesToResponse(outputStream, files); } catch (Exception e) { throw new RuntimeException(e.getMessage()); } return null; }); }
Ниже приведена реализация логики архивирования файлов.
import java.nio.file.Files; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; public static void zipFilesToResponse( OutputStream outputStream, List<File> files) throws IOException { ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream); for (File file : files) { try (FileInputStream fileInputStream = new FileInputStream(file)) { ZipEntry zipEntry = new ZipEntry(file.getName()); zipEntry.setSize(file.length()); zipEntry.setTime(System.currentTimeMillis()); zipOutputStream.putNextEntry(zipEntry); StreamUtils.copy(fileInputStream, zipOutputStream); zipOutputStream.closeEntry(); } finally { Files.delete(file.getAbsoluteFile().toPath()); } } zipOutputStream.close(); }
Java-поток
В какой-то момент логики API мне нужно сгруппировать здание по типам топлива.
У меня есть List<Building>
и List<FuelType>
, мне нужно превратить их в Map<Building, Map<FuelTypeEnum, FuelType>>
Реализация, которую я использую,
// group building to fuel types Map<Building, Map<FuelTypeEnum, FuelType>> buildingFuelTypeMap = fuelTypes.stream().collect(groupingBy(fuelType -> { Long fuelTypeBuildingId = fuelType.getBuildingId(); return buildingsMap.get(fuelTypeBuildingId); // must find the building, because FuelType is found from buildingId in the first place }, toMap(fuelType-> FuelTypeEnum.getEnum(fuelType.getType()), fuelType-> fuelType)));
Оператор потока groupingBy
строит карту. Возвращаемый тип лямбды станет ключом карты. Путем потоковой передачи через List<FuelType>
мы можем представить, что groupingBy
сгруппирует FuelType по BuildingId типа топлива в
key: BuildingA, value:[FuelType1, FuelType2] key: BuildingB, value:[FuelType3, FuelType4]
Если мы просто оставим все как есть, значения будут списком. Но я хочу далее превратить значения в Map<FuelTypeEnum, FuelType>
, где у каждогоFuelType
есть свое FuelTypeEnum
Для этого groupingBy
также принимает второй параметр, чтобы мы могли дальше управлять значением. Здесь я указал оператор toMap
для создания перечисления из FuelType в качестве ключа, и пусть fuelType будет значением.
OpenCSV
Последним шагом API является запись объектов Java в файл csv. OpenCSV — это библиотека с открытым исходным кодом, которую мы используем для этой работы.
Два распространенных способа использования OpenCSV — это запись из массива строк и запись из списка bean-компонентов.
Если ваш CSV-контент имеет фиксированные столбцы, то, вероятно, проще определить его как bean-компонент (класс). Но если у вас есть динамические столбцы, OpenCSV имеет ограничение на создание динамических заголовков.
У меня есть динамические заголовки столбцов, потому что в каждом здании используются разные типы топлива. Итак, я сгенерировал List<String[]>
, где каждый String[]
в списке представляет строку. Первая строка является заголовком столбца. Запись из массива строк не так красива, как список bean-компонентов, но работает.
import com.opencsv.CSVWriter; public static File createReportFileFromArray(String fileName, List<String[]> csvAllRows) throws IOException{ File file = new File(fileName); Writer fileWriter = new FileWriter(file); CSVWriter csvWriter = new CSVWriter(fileWriter); csvWriter.writeAll(csvAllRows); csvWriter.close(); fileWriter.close(); return file; }
Подтвердить на Почтальоне
- Сохранить ответ в файл
2. Загрузите zip-файл и извлеките CSV-файл внутри.
3. Данные есть