Иногда требуется скачивать файл порциями. Причины бывают разные, например слишком большой объем файла, ширина канала не достаточна или сервер ограничивает объем данных для скачивания.
В этой статье опишу каким образом реализовать скачивание файла небольшими порциями на языке Java по протоколу HTTP.
Об HTTP
Для таких целей HTTP предоставляет заголовок
Range
для запроса. В котором указывается
диапазон байтов для скачивания. Заголовок Range
относится только к телу запроса, заголовки сюда не входят.Спецификация определяет следующие форматы указания значений заголовка:
Range: bytes=first-byte-pos "-" [last-byte-pos]
first-byte-pos начальное смещение байта с которого необходимо начать (продолжить) скачивание, оно должно быть больше либо равно 0, и меньше либо равно last-byte-pos;
last-byte-pos конечное смещение байта до которого необходимо скачать файл, оно должно быть больше либо равно first-byte-pos и при этом меньше либо равно скачиваемому размеру файла минус один (потому что это смещение, то есть индекс в массиве байтов).
Примеры
Исключительно по указанному диапазону
bytes=0-255
bytes=256-512
Скачать начиная с позиции first-byte-pos до конца
Range: bytes=first-byte-pos "-"
bytes=512-
Скачать last-byte-pos с конца
Range: bytes="-"last-byte-pos
bytes=-32
На подобный запрос сервер в ответ пришлёт два возможных статуса
-
206 Partial Content
файл успешно скачан частично; -
416 Range Not Satisfiable
неудовлетворительный диапазон для скачивания.
Конечно же ответов может быть больше. В контексте статьи они нас не интересуют.
И заголовок
Content-Range
в котором указан
запрошенный диапазон и общий размер.
Content-Range: bytes 256-512/1024
Этот заголовок сообщает что пришёл ответ на запрос с 256-512 позиции в массиве байтов из 1024 байтов.
Реализация на Java 14
В качестве HTTP клиента возьмем стандартный из JDK, доступный с Java 11
java.net.http.HttpClient
.Для реализации логики выполнения запроса по порциям, напишем класс обёртку
art.aukhatov.http.WebClient
.Опишем интерфейс этого класса
-
byte[] download(String uri, int chunkSize)
скачивает файл по указанным порциям байтов; -
Response download(String uri, int firstBytePos, int lastBytePos)
скачивает файл по указанному диапазону.
В случае если переданный URI не валидный, то метод бросает исключение
java.net.URISyntaxException
. Исключение
java.io.IOException
бросается если какая-либо
неожиданная ошибка с вводом/выводом.Классы WebClient и Response
package art.aukhatov.http;import java.io.BufferedInputStream;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.time.Duration;public class WebClient { private final HttpClient httpClient; public WebClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } public static class Response { final BufferedInputStream inputStream; final int status; final HttpHeaders headers; public Response(BufferedInputStream inputStream, int status, HttpHeaders headers) { this.inputStream = inputStream; this.status = status; this.headers = headers; } }}
В качестве представления ответа опишем
nested class
WebClient.Response
с полями BufferedInputStream, HTTP
Status, HTTP Header. Эти данные необходимы для формирования
результирующего массива байтов и понимания продолжать скачивать или
нет.Метод Response download(final String uri, int firstBytePos, int lastBytePos)
import java.io.BufferedInputStream;import java.io.IOException;import java.io.InputStream;import java.net.URI;import java.net.URISyntaxException;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.net.http.HttpRequest;import java.net.http.HttpResponse;private static final String HEADER_RANGE = "Range";private static final String RANGE_FORMAT = "bytes=%d-%d";public Response download(final String uri, int firstBytePos, int lastBytePos) throws URISyntaxException, IOException, InterruptedException { HttpRequest request = HttpRequest .newBuilder(new URI(uri)) .header(HEADER_RANGE, format(RANGE_FORMAT, firstBytePos, lastBytePos)) .GET() .version(HttpClient.Version.HTTP_2) .build(); HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); return new Response(new BufferedInputStream(response.body()), response.statusCode(), response.headers());}
Этот метод скачивает указанный диапазон данных. Но прежде чем начать нам нужно знать сколько всего данных нам надо ожидать. Для этого необходимо сделать запрос без получения контента. Воспользуемся методом
HEAD
.Метод long contentLength(final String uri)
import java.util.OptionalLong;private static final String HTTP_HEAD = "HEAD";private static final String HEADER_CONTENT_LENGTH = "content-length";private long contentLength(final String uri)throws URISyntaxException, IOException, InterruptedException { HttpRequest headRequest = HttpRequest .newBuilder(new URI(uri)) .method(HTTP_HEAD, HttpRequest.BodyPublishers.noBody()) .version(HttpClient.Version.HTTP_2) .build(); HttpResponse<String> httpResponse = httpClient.send(headRequest, HttpResponse.BodyHandlers.ofString()); OptionalLong contentLength = httpResponse .headers().firstValueAsLong(HEADER_CONTENT_LENGTH); return contentLength.orElse(0L);}
Теперь у нас есть ожидаемая длина файла в байтах.
Метод byte[] download(final String uri, int chunkSize)
Можем приступить к написанию метода контролирующего скачивание файла по порциям. Для удобства, договоримся что размер порций будет передаваться вторым аргументом в этот метод. Хотя можно было бы придумать умный способ определения размера порций.
Определим размер файла
final int expectedLength = (int) contentLength(uri);
Начальное смещение
int firstBytePos = 0;
Конечное смещение
int lastBytePos = chunkSize - 1;
Данные
Скачанные данные за каждую итерацию необходимо накапливать, создадим для этого массив, размер массива нам уже известен.
byte[] downloadedBytes = new byte[expectedLength];
Размер скачанных данных
Дополнительно к самому массиву необходимо определять суммарно сколько данных скачано.
Поэтому эту длину будем считать отдельно.
int downloadedLength = 0;
Цикл скачивания
Условие цикла простое: продолжаем скачивать пока не достигнем ожидаемого размера. После того как успешно скачали очередную порцию данных, необходимо его прочитать и сохранить в результирующий массив, воспользуемся системным методом копирования массива
System.arraycopy()
. Затем нужно увеличить количество
прочитанных данных и следующий диапазон скачиваемых данных. При
увеличении диапазона нужно быть осторожнее, нельзя выходить за
пределы. Поэтому будем брать минимальное значение из
Math.min(lastBytePos + chunkSize, expectedLength -
1)
.
private static final int HTTP_PARTIAL_CONTENT = 206;while (downloadedLength < expectedLength) { Response response; try { response = download(uri, firstBytePos, lastBytePos); } try (response.inputStream) { byte[] chunkedBytes = response.inputStream.readAllBytes(); downloadedLength += chunkedBytes.length; if (isPartial(response)) { System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length); firstBytePos = lastBytePos + 1; lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1); } } } return downloadedBytes;}private boolean isPartial(Response response) { return response.status == HTTP_PARTIAL_CONTENT;}
На вид всё хорошо. Что не так?
Когда при скачивании или чтении что-то пойдет не так, броситься I/O исключение и скачивание прекратиться. Отсутствуют fallback. Давайте напишем простой fallback ввиде количества совершенных попыток.
Определим поле для веб-клиента содержащий максимальное количество допустимых попыток скачивания файла.
private int maxAttempts;public int maxAttempts() { return maxAttempts;}public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts;}
Будем ловить отдельно каждое исключение и инкрементировать локальный счетчик попыток. Цикл скачивания должен остановиться если количество совершенных попыток превышает допустимое. Поэтому дополним условие цикла.
private static final int DEFAULT_MAX_ATTEMPTS = 3;int attempts = 1;while (downloadedLength < expectedLength && attempts < maxAttempts) { Response response; try { response = download(uri, firstBytePos, lastBytePos); } catch (IOException e) { attempts++; continue; } try (response.inputStream) { byte[] chunkedBytes = response.inputStream.readAllBytes(); downloadedLength += chunkedBytes.length; if (isPartial(response)) { System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length); firstBytePos = lastBytePos + 1; lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1); } } catch (IOException e) { attempts++; continue; } attempts = 1;}
Дополним метод еще логами. Окончательный вариант выглядит так:
package art.aukhatov.http;import java.io.BufferedInputStream;import java.io.IOException;import java.io.InputStream;import java.net.URI;import java.net.URISyntaxException;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.time.Duration;import java.util.OptionalLong;import static java.lang.String.format;import static java.lang.System.err;import static java.lang.System.out;public class WebClient {private static final String HEADER_RANGE = "Range";private static final String RANGE_FORMAT = "bytes=%d-%d";private static final String HEADER_CONTENT_LENGTH = "content-length";private static final String HTTP_HEAD = "HEAD";private static final int DEFAULT_MAX_ATTEMPTS = 3;private static final int HTTP_PARTIAL_CONTENT = 206;private final HttpClient httpClient;private int maxAttempts;public WebClient() {this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();this.maxAttempts = DEFAULT_MAX_ATTEMPTS;}public WebClient(HttpClient httpClient) {this.httpClient = httpClient;}private long contentLength(final String uri)throws URISyntaxException, IOException, InterruptedException {HttpRequest headRequest = HttpRequest.newBuilder(new URI(uri)).method(HTTP_HEAD, HttpRequest.BodyPublishers.noBody()).version(HttpClient.Version.HTTP_2).build();HttpResponse<String> httpResponse = httpClient.send(headRequest, HttpResponse.BodyHandlers.ofString());OptionalLong contentLength = httpResponse.headers().firstValueAsLong(HEADER_CONTENT_LENGTH);return contentLength.orElse(0L);}public Response download(final String uri, int firstBytePos, int lastBytePos)throws URISyntaxException, IOException, InterruptedException {HttpRequest request = HttpRequest.newBuilder(new URI(uri)).header(HEADER_RANGE, format(RANGE_FORMAT, firstBytePos, lastBytePos)).GET().version(HttpClient.Version.HTTP_2).build();HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());return new Response(new BufferedInputStream(response.body()), response.statusCode(), response.headers());}public byte[] download(final String uri, int chunkSize)throws URISyntaxException, IOException, InterruptedException {final int expectedLength = (int) contentLength(uri);int firstBytePos = 0;int lastBytePos = chunkSize - 1;byte[] downloadedBytes = new byte[expectedLength];int downloadedLength = 0;int attempts = 1;while (downloadedLength < expectedLength && attempts < maxAttempts) {Response response;try {response = download(uri, firstBytePos, lastBytePos);} catch (IOException e) {attempts++;err.println(format("I/O error has occurred. %s", e));out.println(format("Going to do %d attempt", attempts));continue;}try (response.inputStream) {byte[] chunkedBytes = response.inputStream.readAllBytes();downloadedLength += chunkedBytes.length;if (isPartial(response)) {System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length);firstBytePos = lastBytePos + 1;lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1);}} catch (IOException e) {attempts++;err.println(format("I/O error has occurred. %s", e));out.println(format("Going to do %d attempt", attempts));continue;}attempts = 1; // reset attempts counter}if (attempts >= maxAttempts) {err.println("A file could not be downloaded. Number of attempts are exceeded.");}return downloadedBytes;}private boolean isPartial(Response response) {return response.status == HTTP_PARTIAL_CONTENT;}public int maxAttempts() {return maxAttempts;}public void setMaxAttempts(int maxAttempts) {this.maxAttempts = maxAttempts;}public static class Response {final BufferedInputStream inputStream;final int status;final HttpHeaders headers;public Response(BufferedInputStream inputStream, int status, HttpHeaders headers) {this.inputStream = inputStream;this.status = status;this.headers = headers;}}}
Тестирование
Теперь можем написать тест на Junit 5 для проверки скачивания файла. Для примера возьмем рандомный файл в Интернете из доступных без аутентификации: file-examples.com/wp-content/uploads/2017/10/file-example_PDF_1MB.pdf
Сохраним файл во временную директорию. И проверим размер файла.
class WebClientTest {@Testvoid downloadByChunk() throws IOException, URISyntaxException, InterruptedException {WebClient fd = new WebClient();byte[] data = fd.download("https://file-examples.com/wp-content/uploads/2017/10/file-example_PDF_1MB.pdf", 262_144);final String downloadedFilePath = System.getProperty("java.io.tmpdir") + "sample.pdf";System.out.println("File has downloaded to " + downloadedFilePath);Path path = Paths.get(downloadedFilePath);try (OutputStream outputStream = Files.newOutputStream(path)) {outputStream.write(data);outputStream.flush();assertEquals(1_042_157, Files.readAllBytes(Paths.get(downloadedFilePath)).length);Files.delete(path);}}}
Заключение
В этой статье было рассмотрено каким образом реализовать скачивание файла заранее заданными порциями. Для большей гибкости можно подумать о динамическом размере порций, который расширяется и сужается в зависимости от поведения сервера. Также до конца не покрыты возможные исключения, которые можно обработать иначе. Например ошибка
401 Unauthorized
или 500
Internal Server Error
.В качестве домашнего задания код можно доработать так, чтобы не выполнять первый запрос на получение размера файла. Также можно реализовать приостановку и возобновление.