Мы решили создать курс для Java Backend и хотим обсудить некоторые аспекты и выбранные альтернативы, которые мы заметили при его создании.

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

Приложение представляет собой клон IMDB, основанный на наборе данных рекомендации MovieLens, дополненном данными о фильмах и ролях с themoviedb.org.

Внешний интерфейс написан на vue.js и выглядит довольно приятно.

Он вызывает несколько конечных точек REST API для вызова различных представлений и функций.

Основные функции:

  • регистрация и аутентификация пользователя и хранение его информации

  • список жанров, фильмов, людей, отсортированных и отфильтрованных, и сопутствующая информация

  • добавление фильмов в избранное и рейтинг, а также возврат этих списков и рекомендаций

Репозиторий содержит полный код приложения и может использоваться для сборки и запуска приложения.

У него есть ветки для каждого модуля курса, поэтому вы можете отслеживать прогресс/различия и переходить к завершенному решению, если что-то пойдет не так.

Инфраструктура курса использует Asciidoctor.js, поэтому мы можем использовать includes (используя тег region) непосредственно из нашего репозитория кода.

Таким образом, мы также получаем подсветку синтаксиса, предупреждающие маркеры и многие другие полезные вещи из коробки.

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

Курс также автоматически интегрируется с песочницей Neo4j, поэтому вы можете запускать тестовые запросы, а также приложение для своего персонального экземпляра, на котором размещен набор данных фильмов.

Настройка

Мы использовали традиционную настройку Java, установив Java 17 и Apache Maven через sdkman.

Поскольку в Java 17 появились строковые блоки и записи, мы захотели использовать эти возможности.

sdk install java 17-open
sdk use java 17-open
sdk install maven

Веб фреймворк — SparkJava

Возможно, вы не слышали о SparkJava, который существует уже довольно давно и представляет собой минималистичный веб фреймворк, эквивалентный Express/Sinatra для Java.

Поскольку появятся другие курсы со Spring (Data Neo4j), для этого курса мы хотели ограничиться чем-то простым. Quarkus также был вариантом, о котором мы думали, но затем выбрали SparkJava из-за его минимализма.

Также приложение курса JavaScript использовало Express.

Таким образом, перенос кода с JavaScript на Java был довольно простым, с помощью всего лишь нескольких замен у нас уже через несколько минут что-то заработало.

Минимальный пример hello world выглядит так:

import static spark.Spark.*;


public class HelloWorld {
    public static void main(String[] args) {
        get("/hello", (req, res) -> "Hello World");
    }
}

Все наше основное приложение, которое регистрирует маршруты, добавляет обработку ошибок, проверяет авторизацию, обслуживает форматирование публичных файлов в JSON (используя GSON) и запускает сервер, занимает не более 20 строк.

Документация для SparkJava очень короткая и в то же время исчерпывающая, все, что вам нужно, можно найти быстро.

package neoflix;

import static spark.Spark.*;

import java.util.*;
import com.google.gson.Gson;
import neoflix.routes.*;
import org.neo4j.driver.*;

public class NeoflixApp {

    public static void main(String[] args) throws Exception {
        AppUtils.loadProperties();
        int port = AppUtils.getServerPort();
        port(port);

        Driver driver = AppUtils.initDriver();
        Gson gson = GsonUtils.gson();

        staticFiles.location("/public");
        String jwtSecret = AppUtils.getJwtSecret();
        before((req, res) -> AppUtils.handleAuthAndSetUser(req, jwtSecret));
        path("/api", () -> {
            path("/movies", new MovieRoutes(driver, gson));
            path("/genres", new GenreRoutes(driver, gson));
            path("/auth", new AuthRoutes(driver, gson, jwtSecret));
            path("/account", new AccountRoutes(driver, gson));
            path("/people", new PeopleRoutes(driver, gson));
        });
        exception(ValidationException.class, (exception, request, response) -> {
            response.status(422);
            var body = Map.of("message",exception.getMessage(), "details", exception.getDetails());
            response.body(gson.toJson(body));
            response.type("application/json");
        });
        System.out.printf("Server listening on http://localhost:%d/%n", port);
    }
}

Маршруты — AccountRoutes

Маршруты могут быть сгруппированы по корневому пути, а затем обработаны в простом DSL. Вот как получить список избранного в AccountRoutes

get("/favorites", (req, res) -> {
    var params = Params.parse(req, Params.MOVIE_SORT);
    String userId = AppUtils.getUserId(req);
    return favoriteService.all(userId, params);
}, gson::toJson);

Сначала мы анализируем некоторые параметры из URL-адреса запроса, затем извлекаем userId из атрибутов запроса и вызываем FavoriteService для запроса базы данных.

Fixtures

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

Исходное Javascript приложение использовало файлы JS для хранения фикстур как JS объектов, но мы хотели более переносимый вариант.

Итак, мы преобразовали фикстуры в файлы JSON, а затем прочитали их в List<Map> структуры в сервисах, которые использовали фикстуры.

public static List<Map> loadFixtureList(final String name) {
    var fixture = new InputStreamReader(AppUtils.class.getResourceAsStream("/fixtures/" + name + ".json"));
    return GsonUtils.gson().fromJson(fixture,List.class);
}

Который затем можно использовать в сервисе с this.popular = AppUtils.loadFixtureList("popular");.

[{
    "actors": [
      {"name": "Tim Robbins","tmdbId": "0000209"},
      {"name": "William Sadler","tmdbId": "0006669"},
      {"name": "Bob Gunton","tmdbId": "0348409"},
      {"name": "Morgan Freeman","tmdbId": "0000151"}
    ],
    "languages": ["English"],
    "plot": "Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.",
    "year": 1994,
    "genres": [{"name": "Drama"},{"name": "Crime"}],
    "directors": [{"name": "Frank Darabont","tmdbId": "0001104"}],
    "imdbRating": 9.3,
    "tmdbId": "0111161",
    "favorite": false,
    "title": "Shawshank Redemption, The",
    "poster": "https://image.tmdb.org/t/p/w440_and_h660_face/5KCVkau1HEl7ZzfPsKAPM0sMiKc.jpg"
}]

Чтобы сделать его не полностью статичным, мы использовали обработку Java Streams для реализации фильтрации, сортировки и разбиения на страницы данных фикстуры.

public static List<Map> process(
                List<Map> result, Params params) {
    return params == null ? result : result.stream()
        .sorted((m1, m2) ->
            (params.order() == Params.Order.ASC ? 1 : -1) *
                ((Comparable)m1.getOrDefault(params.sort().name(),"")).compareTo(
                        m2.getOrDefault(params.sort().name(),"")
                ))
        .skip(params.skip()).limit(params.limit())
        .toList();
}

Который затем используется в сервисах, например:

public List<Map> all(Params params, String userId) {
    // TODO: Open an Session
    // TODO: Execute a query in a new Read Transaction
    // TODO: Get a list of Movies from the Result
    // TODO: Close the session


    return AppUtils.process(popular, params);
}

Драйвер Neo4j

Реализации реальной службы используют официальный драйвер Neo4j Java для запросов к базе данных.
Мы можем отправлять параметризованные запросы Cypher на сервер, использовать параметры и обрабатывать результаты в рамках функции повторяемой транзакции (транзакция чтения или записи).

Добавьте зависимость драйвера org.neo4j.driver:neo4j-java-driver в файл pom.xml.

Далее вы можете создать один экземпляр драйвера на весь срок службы вашего приложения и использовать сеансы драйвера по мере необходимости.

Сеансы не удерживают TCP-подключения, а используют их из пула по мере необходимости.
В рамках сеанса вы можете использовать транзакции чтения и записи для выполнения вашей единицы работы.

Мы прочитали учетные данные подключения из application.properties и для удобства установили в качестве системного свойства, и затем инициализировали драйвер.

static Driver initDriver() {
    AuthToken auth = AuthTokens.basic(getNeo4jUsername(), getNeo4jPassword());
    Driver driver = GraphDatabase.driver(getNeo4jUri(), auth);
    driver.verifyConnectivity();
    return driver;
}

Сервис FavoriteService

Затем драйвер передается каждой службе при построении и может использоваться оттуда для создания сеансов и взаимодействия с базой данных.

Здесь, в примере FavoriteService для перечисления избранного пользователя, вы можете увидеть, как мы используем блоки String для оператора Cypher и лямбда-выражения для обратного вызова readTransaction

public List<Map> all(String userId, Params params) {
    // Open a new session
    try (var session = this.driver.session()) {

        // Retrieve a list of movies favorited by the user
        var favorites = session.readTransaction(tx -> {
            String query = """
                        MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie)
                        RETURN m {
                            .*,
                            favorite: true
                        } AS movie
                        ORDER BY m.title ASC
                        SKIP $skip
                        LIMIT $limit
                    """;
            var res = tx.run(query, Values.parameters("userId", userId, "skip",
                                        params.skip(), "limit", params.limit()));
            return res.list(row -> row.get("movie").asMap());
        });
        return favorites;
    }
}

При добавлении любимого фильма мы используем транзакцию записи FavoriteService.add, чтобы создать связь FAVORITE между пользователем и фильмом.

public Map add(String userId, String movieId) {
    // Open a new Session
    try (var session = this.driver.session()) {
        // Create HAS_FAVORITE relationship within a Write Transaction
        var favorite = session.writeTransaction(tx -> {
            String statement = """
                        MATCH (u:User {userId: $userId})
                        MATCH (m:Movie {tmdbId: $movieId})

                        MERGE (u)-[r:HAS_FAVORITE]->(m)
                                ON CREATE SET r.createdAt = datetime()

                        RETURN m {
                            .*,
                            favorite: true
                        } AS movie
                    """;
            var res = tx.run(statement,
                            Values.parameters("userId", userId, "movieId", movieId));
            return res.single().get("movie").asMap();
        });
        return favorite;
    // Throw an error if the user or movie could not be found
    } catch (NoSuchRecordException e) {
        throw new ValidationException("Could not create favorite movie for user",
            Map.of("movie",movieId, "user",userId));
    }
}

Метод result.single() завершится ошибкой, если нет ровно одного результата с NoSuchRecordException, поэтому нам не нужно проверять это в запросе.

Аутентификация

Наше приложение также обеспечивает управление пользователями для персонализации.
Вот почему мы должны:

  • зарегистрировать пользователя

  • аутентифицировать пользователя

  • проверить токен авторизации и добавить информацию о пользователе в запрос

Для регистрации и аутентификации мы сохраняем пользователя непосредственно как узел в Neo4j и используем библиотеку bcrypt для хеширования и сравнения хешированных паролей с входными данными.

Вот пример из метода authenticate класса AuthService.

public Map authenticate(String email, String plainPassword) {
    // Open a new Session
    try (var session = this.driver.session()) {
        // Find the User node within a Read Transaction
        var user = session.readTransaction(tx -> {
            String statement = "MATCH (u:User {email: $email}) RETURN u";
            var res = tx.run(statement, Values.parameters("email", email));
            return res.single().get("u").asMap();

        });
        // Check password
        if (!AuthUtils.verifyPassword(plainPassword, (String)user.get("password"))) {
            throw new ValidationException("Incorrect password", Map.of("password","Incorrect password"));
        }
        String sub = (String)user.get("userId");
        // compute JWT token signature
        String token = AuthUtils.sign(sub, userToClaims(user), jwtSecret);
        return userWithToken(user, token);
    } catch(NoSuchRecordException e) {
        throw new ValidationException("Incorrect email", Map.of("email","Incorrect email"));
    }
}

Для передачи информации об аутентификации в виде токенов JWT в браузер и обратно мы используем библиотеку Java Auth0 для создания токена, а затем также проверяем его.

Это делается с помощью обработчика before в SparkJava, который при успешной проверке сохраняет атрибут sub, содержащий атрибут запроса userId. К которому затем могут получить доступ маршруты, например, для персонализации или рейтингов.

static void handleAuthAndSetUser(Request req, String jwtSecret) {
    String token = req.headers("Authorization");
    String bearer = "Bearer ";
    if (token != null &amp;&amp; !token.isBlank() &amp;&amp; token.startsWith(bearer)) {
        token = token.substring(bearer.length());
        String userId = AuthUtils.verify(token, jwtSecret);
        req.attribute("user", userId);
    }
}
// usage in NeoflixApp
before((req, res) -> AppUtils.handleAuthAndSetUser(req, jwtSecret));

Записи Java 17

Изначально мы планировали использовать записи Java 17 на протяжении всего курса, но потом столкнулись с двумя проблемами.

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

И результаты драйвера Java Neo4j не могли быть были просто конвертированы в экземпляр записи, как нам хотелось.

Поскольку мы не хотели добавлять много ненужных сложностей в образовательный код, особенно. если вы хотите сохранить однострочное лямбда замыкание для обратного вызова. Поэтому мы сохранили API toMap(), предлагаемый для результатов работы драйвера.

var movies = tx.run(query, params)
    .list(row -> row.get("movie")
                    .computeOrDefault(v ->
                        new Movie(v.get("title").asString(),v.get("tmbdId").asString()),
                            v.get("published").asLocalDate())));
var movies = tx.run(query, params).list(row -> row.get("movie").toMap());

Тестирование

Мы использовали JUnit 5 для тестирования, что не составляло труда.

Поскольку мы хотели, чтобы один и тот же тест работал во всех ветвях репозитория, независимо от того, доступно соединение с базой данных или нет, мы использовали операторы Assume, чтобы пропустить несколько тестов и условие существования экземпляра драйвера для некоторого кода очистки в методы настройки @BeforeClass/Before.

В курсе для запуска тестов используется команда mvn test -Dtest=neoflix.TestName#testMethod, с помощью которой участник курса мог проверить свой прогресс и правильность выполнения.

Некоторые тесты также выводят результаты, которые пользователь должен заполнить в викторинах во время курса.

Вывод

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

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

Но, в образовательных целях, мы оставили его в каждом сервисе.

Не стесняйтесь проверить курс Neo4j Java и приложение Neoflix

Если вам интересно переписать этот пример с любой другой веб фреймворк: Spring Boot, Quarkus, Micronaut, vert.x, Play и т. д., сообщите нам и поделитесь репозиторием, чтобы мы могли добавить его как ветку.

Комментарии (2)


  1. pewpew
    13.06.2022 21:52
    +3

    У вас ошибка с "-&gt;" в коде. Очевидно это "->"


    1. val6852 Автор
      14.06.2022 14:12

      Спасибо большое!

      Исправил все тексты кода. Видимо сломалось при копировании. :(