Русский
Русский
English
Статистика
Реклама

Java 11

Как скачать файл порциями?

08.07.2020 00:21:16 | Автор: admin

Иногда требуется скачивать файл порциями. Причины бывают разные, например слишком большой объем файла, ширина канала не достаточна или сервер ограничивает объем данных для скачивания.

В этой статье опишу каким образом реализовать скачивание файла небольшими порциями на языке 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.

В качестве домашнего задания код можно доработать так, чтобы не выполнять первый запрос на получение размера файла. Также можно реализовать приостановку и возобновление.
Подробнее..

Из песочницы Пишем свой аналог WolframAlpha

05.11.2020 20:23:57 | Автор: admin

Допустим, в какой-то момент времени, тебе, мой дорогой друг, захотелось узнать, а что же находится под капотом у математического движка и вдруг загорелся написать свой? Тогда милости прошу под кат.



Ну-с, начнем!


Постановка задачи


Возьмем простой случай: сложение и вычитание (без скобок). Надо же с чего-то начать? Затем будем постепенно дорабатывать и наращивать функционал.


Хотим, чтобы наш движок мог обрабатывать такие математические выражения:


  • 125 + 375
  • 15.25 + 7.90 + 3.12
  • 1200 450
  • 10 9 + 8 7 + 6 5 + 4 3 + 2 1

Формализация


Первым делом, нам надо понять, как разобрать математическое выражение на составляющие: без этого мы не продвинемся дальше на числа и знаки математических операций. Напишем грамматику (громко сказано, скорее адскую смесь регулярных выражений):


expression := expression '+' expression    | expression '-' expression    | NUMBERNUMBER := [0-9]+

Написание лексера


Взглянем на класс java.util.Scanner, в частности на методы:


  • boolean hasNextDouble()
  • double nextDouble()
  • boolean hasNext(Pattern pattern)
  • String next(Pattern pattern)

Да это же то, что нам нужно! Создадим класс ArsenicTau со следующим содержимым (куда же без элемента периодической системы и греческой буквы мы же создаем аналог W|A):


import java.util.ArrayList;import java.util.Scanner;public class ArsenicTau {    public static void main(String[] args) {        var scanner = new Scanner(System.in);        var tokens = new ArrayList<String>();        for (; ; ) {            if (scanner.hasNextDouble()) {                var number = scanner.nextDouble();                tokens.add(String.valueOf(number));            } else if (scanner.hasNext("[+-]")) {                var operator = scanner.next("[+-]");                tokens.add(operator);            } else {                break;            }        }        System.out.println(tokens);    }}

Запускаем, смотрим:


125 + 375^D[125.0, +, 375.0]

15.25 + 7.90 + 3.12^D[15.25, +, 7.9, +, 3.12]

1200 - 450^D[1200.0, -, 450.0]

10 - 9 + 8 - 7 + 6 - 5 + 4 - 3 + 2 - 1^D[10.0, -, 9.0, +, 8.0, -, 7.0, +, 6.0, -, 5.0, +, 4.0, -, 3.0, +, 2.0, -, 1.0]

Гуд. Теперь выделим класс Token:


import java.util.regex.Pattern;public class Token {    private final TokenType type;    private final String value;    public Token(TokenType type, String value) {        this.type = type;        this.value = value;    }    @Override    public String toString() {        return "Token{" +                "type=" + type +                ", value='" + value + '\'' +                '}';    }    public enum TokenType {        NUMBER(""),        PLUS("\\+"),        MINUS("-"),        ;        private final Pattern pattern;        TokenType(String pattern) {            this.pattern = Pattern.compile(pattern);        }        public Pattern getPattern() {            return pattern;        }    }}

Правим функцию ArsenicTau.main(String[]):


...var tokens = new ArrayList<Token>();...for (; ; ) {    Token.TokenType type;    String value;    if (scanner.hasNextDouble()) {        var number = scanner.nextDouble();        type = Token.TokenType.NUMBER;        value = String.valueOf(number);    } else if (scanner.hasNext(Token.TokenType.MINUS.getPattern())) {        type = Token.TokenType.MINUS;        value = scanner.next(type.getPattern());    } else if (scanner.hasNext(Token.TokenType.PLUS.getPattern())) {        type = Token.TokenType.PLUS;        value = scanner.next(type.getPattern());    } else {        break;    }    var token = new Token(type, value);    tokens.add(token);}

Смотрим, что получилось:


125 + 375^D[Token{type=NUMBER, value='125.0'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='375.0'}]

15.25 + 7.90 + 3.12^D[Token{type=NUMBER, value='15.25'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='7.9'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='3.12'}]

1200 - 450^D[Token{type=NUMBER, value='1200.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='450.0'}]

10 - 9 + 8 - 7 + 6 - 5 + 4 - 3 + 2 - 1^D[Token{type=NUMBER, value='10.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='9.0'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='8.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='7.0'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='6.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='5.0'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='4.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='3.0'}, Token{type=PLUS, value='+'}, Token{type=NUMBER, value='2.0'}, Token{type=MINUS, value='-'}, Token{type=NUMBER, value='1.0'}]

Написание парсера


С лексером справились. Осталось дело за малым: пишем парсер. Шучу. Рано еще. Выделим интерфейс Expression:


public interface Expression {    double evaluate();}

Затем интерфейс BinaryOperator:


public interface BinaryOperator extends Expression {    double apply(double x, double y);}

Имплементируем класс Constant:


public class Constant implements Expression {    private double value;    public Constant(double value) {        this.value = value;    }    @Override    public double evaluate() {        return value;    }    @Override    public String toString() {        return "Constant{" +                "value=" + value +                '}';    }}

Реализуем общие методы операторов в абстрактном классе AbstractBinaryOperator:


public abstract class AbstractBinaryOperator implements BinaryOperator {    private final Expression x;    private final Expression y;    protected AbstractBinaryOperator(Expression x, Expression y) {        this.x = x;        this.y = y;    }    @Override    public double evaluate() {        return apply(x.evaluate(), y.evaluate());    }    @Override    public String toString() {        return getClass().getSimpleName() + "{" +                "x=" + x +                ", y=" + y +                '}';    }}

Затем, собственно, сами операторы Add, Subtract:


public class Add extends AbstractBinaryOperator {    protected Add(Expression x, Expression y) {        super(x, y);    }    @Override    public double apply(double x, double y) {        return x + y;    }}

public class Subtract extends AbstractBinaryOperator {    protected Subtract(Expression x, Expression y) {        super(x, y);    }    @Override    public double apply(double x, double y) {        return x - y;    }}

Фух! Теперь мы наконец-то готовы к самому интересному: появлению виновника торжества парсер собственной персоной. Определим интерфейс Parser:


import java.util.List;public interface Parser {    Expression parse(List<Token> tokens);}

Реализуем метод рекурсивного спуска в классе ParserImpl:


import java.util.List;import java.util.ListIterator;import java.util.Objects;public class ParserImpl implements Parser {    private List<Token> tokens;    private ListIterator<Token> iterator;    @Override    public Expression parse(List<Token> tokens) {        Objects.requireNonNull(tokens, "tokens can't be null");        this.tokens = tokens;        this.iterator = tokens.listIterator();        return expression();    }    private Expression expression() {        var x = primary();        while (iterator.hasNext()) {            var operator = iterator.next();            var y = primary();            var type = operator.getType();            if (Token.TokenType.PLUS.equals(type)) {                x = new Add(x, y);            } else if (Token.TokenType.MINUS.equals(type)) {                x = new Subtract(x, y);            } else {                return x;            }        }        return x;    }    private Expression primary() {        if (!iterator.hasNext()) {            throw new IllegalStateException("expected primary but not found");        }        var token = iterator.next();        if (Token.TokenType.NUMBER.equals(token.getType())) {            var value = Double.parseDouble(token.getValue());            return new Constant(value);        } else {            throw new IllegalStateException("expected token but found [" + token + "]");        }    }}

Допишем наш основной метод ArsenicTau.main(String[]):


...var parser = new ParserImpl();var expression = parser.parse(tokens);System.out.println(expression);System.out.println(expression.evaluate());

Проверяем:


125 + 375^DAdd{x=Constant{value=125.0}, y=Constant{value=375.0}}500.0

15.25 + 7.90 + 3.12^DAdd{x=Add{x=Constant{value=15.25}, y=Constant{value=7.9}}, y=Constant{value=3.12}}26.27

1200 - 450^DSubtract{x=Constant{value=1200.0}, y=Constant{value=450.0}}750.0

10 - 9 + 8 - 7 + 6 - 5 + 4 - 3 + 2 - 1^DSubtract{x=Add{x=Subtract{x=Add{x=Subtract{x=Add{x=Subtract{x=Add{x=Subtract{x=Constant{value=10.0}, y=Constant{value=9.0}}, y=Constant{value=8.0}}, y=Constant{value=7.0}}, y=Constant{value=6.0}}, y=Constant{value=5.0}}, y=Constant{value=4.0}}, y=Constant{value=3.0}}, y=Constant{value=2.0}}, y=Constant{value=1.0}}5.0

Подведем итоги


  • написали лексер
  • написали парсер
  • реализовали бинарные операторы сложения и вычитания
Подробнее..

Telegram-бот на Java для самых маленьких от старта до бесплатного размещения на heroku

25.11.2020 10:14:05 | Автор: admin


Для кого написано


Если вы ни разу не писали Telegram-ботов на Java и только начинаете разбираться эта статья для вас. В ней подробно и с пояснениями описано создание реального бота, автоматизирующего одну конкретную функцию. Можно использовать статью как мануал для создания скелета своего бота, а потом подключить его к своей бизнес-логике.

Предыстория


Когда моя дочь начала изучать арифметику, я между делом накидал алгоритм генерации простых примеров на сложение и вычитание вида 5 + 7 =, чтобы не придумывать и не гуглить для неё задания.

И тут на глаза попалась новость, что Telegram выпустил новую версию Bot API 5.0. Ботов я раньше не писал, и потому решил попробовать поднять бота как интерфейс для своей поделки. Все примеры, которые мне удалось найти, показались либо совсем простыми (нужные мне функции не были представлены), либо очень сложными для новичка. Также мне не хватало объяснений, почему выбран тот или иной путь. В общем, написано было сразу для умных, а не для меня. Потому я решил описать свой опыт создания простого бота надеюсь, кому-нибудь это поможет быстрее въехать в тему.

Что в статье есть, чего нет


В статье есть про:

  • создание бекенда не-инлайн бота на Java 11 с использованием Telegram Bot Api 5.0;
  • обработка команд вида /dosomething;
  • обработка текстовых сообщений, не являющихся командами (т.е. не начинающихся с "/");
  • отправку пользователю текстовых сообщений и файлов;
  • деплой и запуск бота на heroku.

В статье нет про:

  • использование функций ботов, не перечисленных выше (например, создание клавиатур я их сначала добавил, но в итоге они мне просто не пригодились);
  • создание списка заданий;
  • работу с Word-документом;
  • обеспечивающие функции логирование, тесты и т.п.;
  • общение с BotFather (создание бота, получение его токена и формирование списка команд подробно и понятно описано во многих источниках, вот первый попавшийся мануал).

Из примеров кода в статье эти функции исключены, чтобы упростить восприятие. Полностью исходный код лежит на GitHub. Если у вас вдруг есть вопросы, пишите в личку, с удовольствием проконсультирую.

Бизнес-функции бота


Очень кратко, чтобы проще было воспринимать код. Бот позволяет:

  • выдавать пользователю справочную текстовую информацию в ответ на команды /start, /help и /settings;
  • обрабатывать и запоминать пользовательские настройки, направленные текстовым сообщением заданного формата. Настроек три минимальное + максимальное число, используемые в заданиях, и количество страниц выгружаемого файла;
  • оповещать пользователя о несоблюдении им формата сообщения;
  • формировать Word-файл с заданиями на сложение, вычитание или вперемешку в ответ на команды /plus, /minus и /plusminus с использованием дефолтных или установленных пользователем настроек.

Можно потыкать MentalCalculationBot (должен работать). Выглядит так:



Общий порядок действий


  • разобраться с зависимостями;
  • создать класс бота и реализовать обработку текстовых сообщений пользователя, не являющихся командами;
  • создать классы команд;
  • прописать запуск приложения;
  • задеплоить на heroku.

Ниже подробно расписан каждый пункт.

Зависимости


Для управления зависимостями использовался Apache Maven. Нужные зависимости собственно Telegram Bots и Lombok, использовавшийся для упрощения кода (заменяет стандартные java-методы аннотациями).

Вот что вышло в
pom.xml
    <groupId>***</groupId>    <artifactId>***</artifactId>    <version>1.0-SNAPSHOT</version>    <name>***</name>    <description>***</description>    <packaging>jar</packaging>    <properties>        <java.version>11</java.version>        <maven.compiler.source>${java.version}</maven.compiler.source>        <maven.compiler.target>${java.version}</maven.compiler.target>        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>        <org.projectlombok.version>1.18.16</org.projectlombok.version>        <apache.poi.version>4.1.2</apache.poi.version>        <telegram.version>5.0.1</telegram.version>    </properties>    <dependencies>        <!-- Telegram API -->        <dependency>            <groupId>org.telegram</groupId>            <artifactId>telegrambots</artifactId>            <version>${telegram.version}</version>        </dependency>        <dependency>            <groupId>org.telegram</groupId>            <artifactId>telegrambotsextensions</artifactId>            <version>${telegram.version}</version>        </dependency>        <!-- Lombok -->        <dependency>            <groupId>org.projectlombok</groupId>            <artifactId>lombok</artifactId>            <version>${org.projectlombok.version}</version>            <scope>compile</scope>        </dependency>    </dependencies>    <build>        <finalName>${project.artifactId}</finalName>        <plugins>            <plugin>                <groupId>org.apache.maven.plugins</groupId>                <artifactId>maven-compiler-plugin</artifactId>                <version>3.8.1</version>                <configuration>                    <release>${java.version}</release>                    <annotationProcessorPaths>                        <path>                            <groupId>org.projectlombok</groupId>                            <artifactId>lombok</artifactId>                            <version>${org.projectlombok.version}</version>                        </path>                    </annotationProcessorPaths>                </configuration>            </plugin>            <plugin>                <groupId>org.apache.maven.plugins</groupId>                <artifactId>maven-dependency-plugin</artifactId>                <version>3.1.2</version>                <executions>                    <execution>                        <id>copy-dependencies</id>                        <phase>package</phase>                        <goals>                            <goal>copy-dependencies</goal>                        </goals>                    </execution>                </executions>            </plugin>            <plugin>                <groupId>org.apache.maven.plugins</groupId>                <artifactId>maven-surefire-plugin</artifactId>                <version>3.0.0-M5</version>            </plugin>        </plugins>    </build>


Класс бота и обработка текстовых сообщений


Мой класс Bot унаследован от TelegramLongPollingCommandBot, который, в свою очередь, наследуется от более распространённого в примерах TelegramLongPollingBot.

CommandBot хорош тем, что в нём уже реализованы приём и обработка команд то есть сообщений, начинающихся с "/". Можно создавать отдельные классы команд (подробнее о них ниже), инициализировать их в конструкторе бота и уже в них писать логику их обработки.

В классе Bot таким образом остаётся только логика обработки текстовых сообщений, не являющихся командами. В моём случае это пользовательские настройки или мусорные сообщения, не соответствующие формату. Для лаконичности логику их обработки тоже стоит вынести в отдельный вспомогательный класс, вызывая его метод из переопределенного метода processNonCommandUpdate(Update update) класса Bot.

В качестве параметров для инициализации бота используются его имя и токен, полученные от BotFather. В ходе разработки удобно создать тестового бота и прописать его параметры прямо в коде, чтобы проще было запускать бекенд локально и отлаживать прямо в Telegram, однако перед релизом стоит пересоздать бота и вынести эти настройки из кода например, в переменные окружения (об этом ниже).

Получился вот такой
Bot.java
import lombok.Getter;import org.telegram.telegrambots.extensions.bots.commandbot.TelegramLongPollingCommandBot;import org.telegram.telegrambots.meta.api.methods.send.SendMessage;import org.telegram.telegrambots.meta.api.objects.Message;import org.telegram.telegrambots.meta.api.objects.Update;import org.telegram.telegrambots.meta.api.objects.User;import org.telegram.telegrambots.meta.exceptions.TelegramApiException;import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.MinusCommand;import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.PlusCommand;import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.PlusMinusCommand;import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.HelpCommand;import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.SettingsCommand;import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.StartCommand;import ru.taksebe.telegram.mentalCalculation.telegram.nonCommand.NonCommand;import ru.taksebe.telegram.mentalCalculation.telegram.nonCommand.Settings;import java.util.HashMap;import java.util.Map;public final class Bot extends TelegramLongPollingCommandBot {    private final String BOT_NAME;    private final String BOT_TOKEN;    //Класс для обработки сообщений, не являющихся командой    private final NonCommand nonCommand;    /**     * Настройки файла для разных пользователей. Ключ - уникальный id чата     */    @Getter    private static Map<Long, Settings> userSettings;    public Bot(String botName, String botToken) {        super();        this.BOT_NAME = botName;        this.BOT_TOKEN = botToken;        //создаём вспомогательный класс для работы с сообщениями, не являющимися командами        this.nonCommand = new NonCommand();        //регистрируем команды        register(new StartCommand("start", "Старт"));        register(new PlusCommand("plus", "Сложение"));        register(new MinusCommand("minus", "Вычитание"));        register(new PlusMinusCommand("plusminus", "Сложение и вычитание"));        register(new HelpCommand("help","Помощь"));        register(new SettingsCommand("settings", "Мои настройки"));        userSettings = new HashMap<>();    }    @Override    public String getBotToken() {        return BOT_TOKEN;    }    @Override    public String getBotUsername() {        return BOT_NAME;    }    /**     * Ответ на запрос, не являющийся командой     */    @Override    public void processNonCommandUpdate(Update update) {        Message msg = update.getMessage();        Long chatId = msg.getChatId();        String userName = getUserName(msg);        String answer = nonCommand.nonCommandExecute(chatId, userName, msg.getText());        setAnswer(chatId, userName, answer);    }    /**     * Формирование имени пользователя     * @param msg сообщение     */    private String getUserName(Message msg) {        User user = msg.getFrom();        String userName = user.getUserName();        return (userName != null) ? userName : String.format("%s %s", user.getLastName(), user.getFirstName());    }    /**     * Отправка ответа     * @param chatId id чата     * @param userName имя пользователя     * @param text текст ответа     */    private void setAnswer(Long chatId, String userName, String text) {        SendMessage answer = new SendMessage();        answer.setText(text);        answer.setChatId(chatId.toString());        try {            execute(answer);        } catch (TelegramApiException e) {            //логируем сбой Telegram Bot API, используя userName        }    }}


Класс обработки текстовых сообщений
NonCommand.java
import ru.taksebe.telegram.mentalCalculation.exceptions.IllegalSettingsException;import ru.taksebe.telegram.mentalCalculation.telegram.Bot;/** * Обработка сообщения, не являющегося командой (т.е. обычного текста не начинающегося с "/") */public class NonCommand {    public String nonCommandExecute(Long chatId, String userName, String text) {        Settings settings;        String answer;        try {            //создаём настройки из сообщения пользователя            settings = createSettings(text);            //добавляем настройки в мапу, чтобы потом их использовать для этого пользователя при генерации файла            saveUserSettings(chatId, settings);            answer = "Настройки обновлены. Вы всегда можете их посмотреть с помощью /settings";            //логируем событие, используя userName        } catch (IllegalSettingsException e) {            answer = e.getMessage() +                    "\n\n Настройки не были изменены. Вы всегда можете их посмотреть с помощью /settings";            //логируем событие, используя userName        } catch (Exception e) {            answer = "Простите, я не понимаю Вас. Возможно, Вам поможет /help";            //логируем событие, используя userName        }        return answer;    }    /**     * Создание настроек из полученного пользователем сообщения     * @param text текст сообщения     * @throws IllegalArgumentException пробрасывается, если сообщение пользователя не соответствует формату     */    private Settings createSettings(String text) throws IllegalArgumentException {        //отсекаем файлы, стикеры, гифки и прочий мусор        if (text == null) {            throw new IllegalArgumentException("Сообщение не является текстом");        }        //создаём из сообщения пользователя 3 числа-настройки (min, max, listCount) либо пробрасываем исключение о несоответствии сообщения требуемому формату        return new Settings(min, max, listCount);    }    /**     * Добавление настроек пользователя в мапу, чтобы потом их использовать для этого пользователя при генерации файла     * Если настройки совпадают с дефолтными, они не сохраняются, чтобы впустую не раздувать мапу     * @param chatId id чата     * @param settings настройки     */    private void saveUserSettings(Long chatId, Settings settings) {        if (!settings.equals(Settings.getDefaultSettings())) {            Bot.getUserSettings().put(chatId, settings);        }    }}


Классы команд


Все классы команд наследуются от BotCommand.

Команды в моём боте делятся на 2 группы:

  • Сервисные возвращают справочную информацию;
  • Основные формируют файл с заданиями.

Структура классов команд для этих групп идентична в абстрактном суперклассе реализуются общие методы, в наследуемые от него классах отдельных команд вынесена их кастомная логика. Разница лишь в том, что Основные команды обращаются к внешним классам, где реализована бизнес-логика, в то время как Сервисные просто формируют текстовый ответ.

Начнём с более простых Сервисных команд. В абстрактный суперкласс вынесен метод отправки пользователю ответа, а в классах команд формируется текст ответа.

Абстрактный суперкласс Сервисных команд
ServiceCommand.java
import org.telegram.telegrambots.extensions.bots.commandbot.commands.BotCommand;import org.telegram.telegrambots.meta.api.methods.send.SendMessage;import org.telegram.telegrambots.meta.bots.AbsSender;import org.telegram.telegrambots.meta.exceptions.TelegramApiException;/** * Суперкласс для сервисных команд */abstract class ServiceCommand extends BotCommand {    ServiceCommand(String identifier, String description) {        super(identifier, description);    }    /**     * Отправка ответа пользователю     */    void sendAnswer(AbsSender absSender, Long chatId, String commandName, String userName, String text) {        SendMessage message = new SendMessage();        //включаем поддержку режима разметки, чтобы управлять отображением текста и добавлять эмодзи        message.enableMarkdown(true);        message.setChatId(chatId.toString());        message.setText(text);        try {            absSender.execute(message);        } catch (TelegramApiException e) {            //логируем сбой Telegram Bot API, используя commandName и userName        }    }}


Класс Сервисной команды на примере
StartCommand.java
import org.telegram.telegrambots.meta.api.objects.Chat;import org.telegram.telegrambots.meta.api.objects.User;import org.telegram.telegrambots.meta.bots.AbsSender;/** * Команда "Старт" */public class StartCommand extends ServiceCommand {    public StartCommand(String identifier, String description) {        super(identifier, description);    }    @Override    public void execute(AbsSender absSender, User user, Chat chat, String[] strings) {        //формируем имя пользователя - поскольку userName может быть не заполнено, для этого случая используем имя и фамилию пользователя        String userName = (user.getUserName() != null) ? user.getUserName() :                String.format("%s %s", user.getLastName(), user.getFirstName());        //обращаемся к методу суперкласса для отправки пользователю ответа        sendAnswer(absSender, chat.getId(), this.getCommandIdentifier(), userName,                "Давайте начнём! Если Вам нужна помощь, нажмите /help");    }}


В суперклассе Основных команд, помимо аналогичного метода отправки ответов, содержится формирование Word-документа.
OperationCommand.java
import org.telegram.telegrambots.extensions.bots.commandbot.commands.BotCommand;import org.telegram.telegrambots.meta.api.methods.send.SendDocument;import org.telegram.telegrambots.meta.api.methods.send.SendMessage;import org.telegram.telegrambots.meta.api.objects.InputFile;import org.telegram.telegrambots.meta.bots.AbsSender;import org.telegram.telegrambots.meta.exceptions.TelegramApiException;import ru.taksebe.telegram.mentalCalculation.calculation.Calculator;import ru.taksebe.telegram.mentalCalculation.calculation.PlusMinusService;import ru.taksebe.telegram.mentalCalculation.enums.OperationEnum;import ru.taksebe.telegram.mentalCalculation.fileProcessor.WordFileProcessorImpl;import ru.taksebe.telegram.mentalCalculation.telegram.Settings;import java.io.FileInputStream;import java.io.IOException;import java.util.List;/** * Суперкласс для команд создания заданий с различными операциями */abstract class OperationCommand extends BotCommand {    private PlusMinusService service;    OperationCommand(String identifier, String description) {        super(identifier, description);        this.service = new PlusMinusService(new WordFileProcessorImpl(), new Calculator());    }    /**     * Отправка ответа пользователю     */    void sendAnswer(AbsSender absSender, Long chatId, List<OperationEnum> operations, String description, String commandName, String userName) {        try {            absSender.execute(createDocument(chatId, operations, description));        } catch (IOException | IllegalArgumentException e) {            sendError(absSender, chatId, commandName, userName);            e.printStackTrace();        } catch (TelegramApiException e) {            //логируем сбой Telegram Bot API, используя commandName и userName        }    }    /**     * Создание документа для отправки пользователю     * @param chatId id чата     * @param operations список типов операций (сложение и/или вычитание)     * @param fileName имя, которое нужно присвоить файлу     */    private SendDocument createDocument(Long chatId, List<OperationEnum> operations, String fileName) throws IOException {        FileInputStream stream = service.getPlusMinusFile(operations, Bot.getUserSettings(chatId));        SendDocument document = new SendDocument();        document.setChatId(chatId.toString());        document.setDocument(new InputFile(stream, String.format("%s.docx", fileName)));        return document;    }    /**     * Отправка пользователю сообщения об ошибке     */    private void sendError(AbsSender absSender, Long chatId, String commandName, String userName) {        try {            absSender.execute(new SendMessage(chatId.toString(), "Похоже, я сломался. Попробуйте позже"));        } catch (TelegramApiException e) {            //логируем сбой Telegram Bot API, используя commandName и userName        }    }}


Класс Основной команды на примере
PlusMinusCommand.java
import org.telegram.telegrambots.meta.api.objects.Chat;import org.telegram.telegrambots.meta.api.objects.User;import org.telegram.telegrambots.meta.bots.AbsSender;import ru.taksebe.telegram.mentalCalculation.enums.OperationEnum;/** * Команда получение файла с заданиями на сложение и вычитание */public class PlusMinusCommand extends OperationCommand {    public PlusMinusCommand(String identifier, String description) {        super(identifier, description);    }    @Override    public void execute(AbsSender absSender, User user, Chat chat, String[] strings) {        //формируем имя пользователя - поскольку userName может быть не заполнено, для этого случая используем имя и фамилию пользователя        String userName = (user.getUserName() != null) ? user.getUserName() :                String.format("%s %s", user.getLastName(), user.getFirstName());        //обращаемся к методу суперкласса для формирования файла на сложение и вычитание (за это отвечает метод getPlusMinus() перечисления OperationEnum) и отправки его пользователю        sendAnswer(absSender, chat.getId(), OperationEnum.getPlusMinus(), this.getDescription(), this.getCommandIdentifier(), userName);    }}


Приложение


В методе main инициализируется TelegramBotsApi, в котором и регистрируется Bot.

TelegramBotsApi в качестве параметра принимает Class<? extends BotSession>. Если нет никаких заморочек с прокси, можно использовать DefaultBotSession.class.

Чтобы получать имя и токен бота как переменные окружения, необходимо использовать System.getenv().

Получаем вот такой
MentalCalculationApplication.java
import org.telegram.telegrambots.meta.TelegramBotsApi;import org.telegram.telegrambots.meta.exceptions.TelegramApiException;import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;import ru.taksebe.telegram.mentalCalculation.telegram.Bot;import java.util.Map;public class MentalCalculationApplication {    private static final Map<String, String> getenv = System.getenv();    public static void main(String[] args) {        try {            TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class);            botsApi.registerBot(new Bot(getenv.get("BOT_NAME"), getenv.get("BOT_TOKEN")));        } catch (TelegramApiException e) {            e.printStackTrace();        }    }}


Деплой на heroku


Для начала нужно создать в корне проекта файл Procfile и написать в него одну строку:
worker: java -Xmx300m -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8 -cp ./target/classes:./target/dependency/* <путь до приложения, в моём случае ru.taksebe.telegram.mentalCalculation.MentalCalculationApplication>
, где worker это тип процесса.

Если в проекте используется версия Java, отличная от 8, также необходимо создать в корне проекта файл system.properties и прописать в нём одну строку:
java.runtime.version=<версия Java>

Далее порядок такой:

  1. Регистрируемся на heroku и идём в консоль;
  2. mvn clean install;
  3. heroku login после выполнения потребуется нажать любую клавишу и залогиниться в открывшемся окне браузера;
  4. heroku create <имя приложения> создаём приложение на heroku;
  5. git push heroku master пушим в репозиторий heroku;
  6. heroku config:set BOT_NAME=<имя бота> добавляем имя бота в переменные окружения;
  7. heroku config:set BOT_TOKEN=<токен бота> добавляем токен бота в переменные окружения;
  8. heroku config:get BOT_NAME (аналогично BOT_TOKEN) убеждаемся, что переменные окружения установлены верно;
  9. heroku ps:scale worker=1 устанавливаем количество контейнеров (dynos) для типа процесса worker (ранее мы выбрали этот тип в Procfile), при этом происходит рестарт приложения;
  10. В интерфейсе управления приложением в личном кабинете на heroku переходим к логам (прячутся под кнопкой More в правом верхнем углу) и убеждаемся, что приложение запущено;
  11. Тестируем бота через Telegram.

Если вы храните код на GitHub, то в интерфейсе управления приложением в личном кабинете на heroku на вкладке Deploy вы можете в дальнейшем переключить деплой на GitHub-репозиторий (по запросу или автоматически), чтобы не пушить параллельно в два репозитория.

Вместо заключения


Как выяснилось, не только лишь все видели чудесный советский мультик про козлёнка, который учился считать до 10.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru