Падручнік па Java і API фільмаў

Створыце кансольны даведнік па эпізодах на Java, які друкуе кожны эпізод сезона серыяла з нумарам, датай эфіру і працягласцю. Ён выкарыстоўвае JDK HttpClient і Jackson, адлюстроўвае JSON у records, паўтарае запыт пасля 429 праз затрымку Retry-After і кэшуе адказы ў файлах. Патрэбныя Java 17 або новей і Maven 3.9.

.java Java Абноўлена Адкрыць у Markdown

Вы створыце EpisodeGuide, кансольную праграму. Запусціце яе з id серыяла і нумарам сезона, і яна надрукуе кожны эпізод з нумарам, датай эфіру, працягласцю і назвай. Яна выкарыстоўвае JDK HttpClient, Jackson 2.x, Java 17 або новей і Maven 3.9. Закладзіце 20 хвілін.

Крок 1: Атрымайце ключ API

Для ключа патрэбны тарыфны план з аплатай наперад, таму пачніце са старонкі цэны. Усе планы аддаюць аднолькавыя даныя, адрозніваецца толькі частата запытаў у хвіліну. Стварыце ключ у асабістым кабінеце і экспартуйце яго:

Праграма чытае System.getenv("AMODB_KEY") і не запускаецца без яго.

Shell
export AMODB_KEY="YOUR_API_KEY"

Крок 2: Стварыце праект

Адзіная залежнасць, Jackson. Стварыце тэчку, мінімальны pom.xml і каталог з зыходным кодам:

Падыдзе і навейшы выпуск 2.x jackson-databind.

Shell
mkdir episode-guide && cd episode-guide
mkdir -p src/main/java
cat > pom.xml <<'EOF'
<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>episode-guide</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.18.2</version>
    </dependency>
  </dependencies>
</project>
EOF

Крок 3: Адпраўце першы запыт

Эндпоінт сезона: GET /3/tv/{tv_id}/season/{season_number}, а Breaking Bad мае id серыяла 1396. Гэты клас друкуе код статусу. Захавайце яго як src/main/java/First.java, каб паспрабаваць асобна. Каманда запуску з кроку 7 працуе і для яго, калі задаць -Dexec.mainClass=First.

Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class First {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.amodb.dev/3/tv/1396/season/1"))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + System.getenv("AMODB_KEY"))
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode());
    }
}

Крок 4: Адлюструйце JSON у records

Адказ па сезоне мае name і масіў episodes. Кожны эпізод мае episode_number, name, air_date і runtime, і два апошнія могуць быць null для эпізода, які яшчэ не выйшаў. Для ліку, які можа адсутнічаць, выкарыстоўвайце Integer. Поўны спіс палёў ёсць на старонцы сезоны і эпізоды.

Java
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
        @JsonProperty("episode_number") int number,
        String name,
        @JsonProperty("air_date") String airDate,
        Integer runtime) {}

Крок 5: Апрацоўка памылак і лімітаў

Чытайце статус, перш чым звяртацца да цела адказу.

СтатусШто робіць праграма
200Вяртае цела для Jackson.
401Выкідае IOException з назвай AMODB_KEY. Дрэнны ключ ламае кожны выклік, таму паўтораў няма.
404Выкідае выключэнне з status_message, напрыклад калі сезона не існуе.
429Чытае Retry-After, спіць ад 1 да 30 секунд і адпраўляе запыт яшчэ раз, не болей за 3 спробы.
Тайм-аўт або збой злучэнняПаўторна выкідаецца як network error з прычынай.

Цела памылкі мае форму TMDB, таму status_message і ёсць тэкст для паказу. На старонцы памылкі пералічаны ўсе коды, а ліміты запытаў тлумачаць 60-секунднае акно.

Крок 6: Кэшуйце адказы ў файлах

Edge-кэш трымае дэталі 6 гадзін, таму спіс эпізодаў рэдка змяняецца паміж запускамі. Праграма запісвае кожнае цела ў файл у часовай тэчцы, называе яго па шляху запыту і чытае назад, пакуль файл маладзейшы за TTL. Трапленне ў кэш нічога не адпраўляе і не расходуе ваш ліміт запытаў у хвіліну.

Крок 7: Поўная праграма

Захавайце гэта як src/main/java/EpisodeGuide.java.

Java
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;

public class EpisodeGuide {
    private static final String BASE = "https://api.amodb.dev/3";
    private static final String KEY = System.getenv("AMODB_KEY");
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private static final Path CACHE_DIR = Path.of(System.getProperty("java.io.tmpdir"), "amodb-cache");

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Episode(
            @JsonProperty("episode_number") int number,
            String name,
            @JsonProperty("air_date") String airDate,
            Integer runtime) {}

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Season(String name, List<Episode> episodes) {}

    static String get(String path) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + KEY)
                .header("Accept", "application/json")
                .GET()
                .build();
        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response;
            try {
                response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (IOException e) {
                throw new IOException("network error: " + e, e);
            }
            int status = response.statusCode();
            if (status == 200) {
                return response.body();
            }
            if (status == 401) {
                throw new IOException("401: the key is invalid or suspended, check AMODB_KEY");
            }
            if (status == 429 && attempt < 3) {
                long seconds = response.headers().firstValueAsLong("Retry-After").orElse(1);
                Thread.sleep(Math.min(Math.max(seconds, 1), 30) * 1000);
                continue;
            }
            throw new IOException("amodb returned " + status + ": " + message(response.body()));
        }
    }

    static String message(String body) {
        try {
            return MAPPER.readTree(body).path("status_message").asText("no message");
        } catch (IOException e) {
            return "no message";
        }
    }

    static String cachedGet(String path, Duration ttl) throws IOException, InterruptedException {
        Files.createDirectories(CACHE_DIR);
        Path file = CACHE_DIR.resolve(path.replaceAll("[^A-Za-z0-9]", "_") + ".json");
        if (Files.exists(file)) {
            Instant modified = Files.getLastModifiedTime(file).toInstant();
            if (modified.plus(ttl).isAfter(Instant.now())) {
                return Files.readString(file);
            }
        }
        String body = get(path);
        Files.writeString(file, body);
        return body;
    }

    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("Usage: EpisodeGuide <tv_id> <season_number>");
            System.exit(2);
        }
        if (KEY == null || KEY.isBlank()) {
            System.err.println("Set AMODB_KEY first.");
            System.exit(2);
        }
        String path;
        try {
            path = "/tv/" + Integer.parseInt(args[0]) + "/season/" + Integer.parseInt(args[1]) + "?language=en-US";
        } catch (NumberFormatException e) {
            System.err.println("The series id and season number must be integers.");
            System.exit(2);
            return;
        }
        try {
            Season season = MAPPER.readValue(cachedGet(path, Duration.ofHours(6)), Season.class);
            System.out.println(season.name() + ", " + season.episodes().size() + " episodes");
            for (Episode ep : season.episodes()) {
                System.out.printf("%2d  %-10s  %-7s  %s%n",
                        ep.number(),
                        Objects.requireNonNullElse(ep.airDate(), "TBA"),
                        ep.runtime() == null ? "?" : ep.runtime() + " min",
                        ep.name());
            }
        } catch (IOException e) {
            System.err.println("error: " + e.getMessage());
            System.exit(1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.exit(1);
        }
    }
}

Скампілюйце і запусціце для сезона 1 серыяла 1396:

Вывад змяшчае адзін радок загалоўка з назвай сезона і колькасцю эпізодаў, потым па радку на эпізод. Эпізод без даты эфіру паказвае TBA, а без працягласці ?. Запусціце каманду двойчы: другі запуск чытае файл з часовай тэчкі і нічога не адпраўляе, пакуль не пройдзе 6 гадзін. Выдаліце тэчку amodb-cache, каб атрымаць свежыя даныя.

Трымайце HttpClient і ObjectMapper у статычных палях. Абодва патокабяспечныя і дарагія ў стварэнні, таму адзін экземпляр на працэс з’яўляецца звычайным падыходам, у тым ліку ў вэб-сэрвісе.

Shell
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"

Наступныя крокі

  • Праходзьце цыклам ад сезона 1 да number_of_seasons з дэталяў серыяла і друкуйце ўвесь серыял.
  • Паказвайце наступны эпізод серыяла, які выходзіць, праз next_episode_to_air.
  • Дадайце трэці аргумент для параметра language, а даступныя тэгі глядзіце на старонцы мовы.
  • Параўнайце з тым жа падыходам на Go або пачніце з агляду API даведніка па эпізодах.