Что происходит в этом вызове API?

Одной из основных функций, над которой я работал, является «Экспорт здания». Это API, который отправляет zip-файл csv во внешний интерфейс, когда пользователь нажимает «Загрузить».

В этой статье описаны проблемы, которые я решил в этом API:

  1. Тип ответа
  2. Обработка ошибок
  3. Асинхронная обработка
  4. Почтовая логика
  5. Java-поток
  6. OpenCSV
  7. Подтвердить на Почтальоне

Тип ответа

Во-первых, наш возвращаемый тип — 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;
}

Подтвердить на Почтальоне

  1. Сохранить ответ в файл

2. Загрузите zip-файл и извлеките CSV-файл внутри.

3. Данные есть