Создание REST API на Java без использования Spring Framework

Чуть не забыл, что собирался написать эту статью. Не так давно мне пришлось делать тестовое задание, в котором нужно было создать REST API, но одним из условий было не использовать никакие фреймворки (то есть Spring в любой форме оказался под запретом). Я подумал, что стоит этот необычный опыт перемещения в программистское средневековье описать в статье, чтобы и самому не забыть и, может, кому-нибудь помочь с выполнением схожей задачи.

Если вам нужно всё же создать такой API со спрингом, то такие статьи у меня тоже уже есть: про контроллеры и сервисы | про HATEOAS.

Итак, предположим, что база данных уже существует, она уже заполнена данными, и в нашем проекте даже есть класс, который отвечает за соединение с базой. Используется пул подключений. О том, как организовать подключение к базе с помощью JDBC, я уже писал в двух прошлых статьях этого «цикла»:

Подключение к базе данных в Java: используем JDBC. На примере H2 database

Используем пул соединений к Базе Данных: Hikari Connection Pool + JDBC

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

Конфигурация проекта: файл pom.xml

Можно создать уже настроенный проект с помощью IDE. В Intellij Idea всё довольно просто: при создании проекта нужно выбрать Java Enterprise или Jakarta EE. И в окне выбора библиотек и фреймворков отметить CDI (Context and Dependency Enjection), JAX-RS (RESTful Web Service) и Servlet.

Но так как вам всё равно придётся лезть в pom.xml, чтобы добавить туда пул и H2 базу данных из прошлых статей, то быстро пробегусь по необходимым тегам в <dependencies>.


      <dependency>
            <groupId>javax.enterprise</groupId>
            <artifactId>cdi-api</artifactId>
            <version>2.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.ws.rs</groupId>
            <artifactId>javax.ws.rs-api</artifactId>
            <version>2.1.1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>

Ваши версии могут отличаться от моих – это не страшно. Не обязательно использовать мои данные, если вышли более новые версии.

Обмен с базой данных

Теперь можно заняться взаимодействием с базой данных. Работать в этой статье я буду с «кланами» из воображаемой компьютерной игры (тестовое задание было именно об этом). Структура объекта соответствует таблице в базе данных и получается следующей:

public class Clan {

    private long id;     
    private String name; 
    private int gold;    // текущее количество золота в казне клана

}

Теперь перейду к созданию класса ClanDAO. Не могу сказать, что я полноценно реализовал этот шаблон, но немного на оригинал он всё же похож. Интерфейс DAO выглядит следующим образом:


public interface DAO<T, K> {

    Optional<T> get(K id);

    List<T> getAll();

    void save(T t);

    void update(T t);

    void delete(K id);
}

А класс ClanDAO должен интерфейс реализовывать, так что заголовок будет соответствующий:

public class ClanDAO implements DAO<Clan, Long>

Кроме этого обязательно нужно «ввести инъекцию» с тем самым классом, отвечающим за подключение к базе данных:

@Inject
private DBConnector connector;

И далее уже пошла реализация методов. Здесь всё довольно стандартно. Начинаю с методов чтения из базы: get и getAll. Первый, естественно. возвращает лишь один клан (или ноль, если в базе ничего не нашлось, поэтому и нужно использовать Optional и на месте уже разбираться, что с ним делать) и поэтому требует id в списке параметров. Второй ничего не требует и возвращает список – он тоже может быть пустым.

    @Override
    public Optional<Clan> get(Long id) {
        Optional<Clan> clan = Optional.empty();

        try (Connection connection = connector.getConnection()) {
        	String sql = "SELECT * FROM clan WHERE clan_id=?";

            PreparedStatement ps = connection.prepareStatement(sql);
            ps.setLong(1, id);
            ResultSet rs = ps.executeQuery();
        	while (rs.next()) {
            	clan = new Clan();
            	clan.setId(rs.getLong("clan_id"));
            	clan.setName(rs.getString("name"));
            	clan.setGold(rs.getInt("gold"));
        	}
            rs.close();
        	ps.close();
        } catch (SQLException ex) {
            ex.printStackTrace();
        }

        return Optional.ofNullable(clan);;
    }

    @Override
    public List<Clan> getAll() {
        String sql = "SELECT * FROM clan";
        List<Clan> clans = new ArrayList<>();
        try (Connection connection = connector.getConnection();
             Statement statement = connection.createStatement();
             ResultSet rs = statement.executeQuery(sql)) {
            while (rs.next()) {
                Clan clan = new Clan();
                clan.setId(rs.getLong("clan_id"));
                clan.setName(rs.getString("name"));
                clan.setGold(rs.getInt("gold"));
                clans.add(clan);
            }
        } catch (SQLException ex) {
            ex.printStackTrace();
        }

        return clans;
    }

Итак, в обоих методах (и дальше я буду делать то же самое) я сначала инициализирую пременную с sql-запросом. Можно их вынести в отдельные методы или константы, но я не стал с этим заморачиваться, ведь число строк кода это никак не уменьшит и читаемость вряд ли улучшит. В моём случае преимуществ нет.

Потом получаю соединение и, собственно, выполняю запрос. Так как в первом методе у запроса есть параметры, то использую PreparedStatement, чтобы защититься от инъекций. Во втором случае такой опасности нет, поэтому здесь уже обычный Statement.

После выполнения всех действия важно не забыть ничего закрыть, чтобы соединение могло вернуться в пул, а не осталось в подвешенном состоянии. Для этого я закрываю и ResultSet и Statement, а в случае с соединением в обоих случаях полагаюсь на try-with-resources.

 
    @Override
    public void save(Clan clan) {
        String insertQuery = "INSERT INTO clan " + "(name, gold) VALUES " + "(?,?)";
        try (Connection connection = connector.getConnection();
             PreparedStatement insertPreparedStatement = connection.prepareStatement(insertQuery);) {
            insertPreparedStatement.setString(1, clan.getName());
            insertPreparedStatement.setInt(2, clan.getGold());
            insertPreparedStatement.executeUpdate();
        } catch (SQLException ex) {
            ex.printStackTrace();
        }
    }

    @Override
    public void update(Clan clan) {
        String updateQuery = "UPDATE clan SET name=?, gold=? "
                + "WHERE clan_id=?";

        try (Connection connection = connector.getConnection()) {
            PreparedStatement statement = connection.prepareStatement(updateQuery);
        	statement.setString(1, clan.getName());
        	statement.setInt(2, clan.getGold());
        	statement.setLong(3, clan.getId());
        	statement.executeUpdate();
       	statement.close();
        } catch (SQLException ex) {
            ex.printStackTrace();
        }
    }

    @Override
    public void delete(Long id) {
        String sql = "DELETE FROM clan WHERE clan_id=?";
        try (Connection connection = connector.getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)){
            ps.setLong(1, id);
            ps.executeUpdate();

        } catch (SQLException ex) {
            ex.printStackTrace();
        }
    }
}

В этих методах нет ничего необычного и удивительного, поэтому тут даже добавить нечего. Объявляю переменную с запросом к базе, получаю подключение, готовлю запрос (подставляю значения параметров), выполняю запрос, закрываю всё и заметаю все следы. Так как все эти методы void, то возвращать ничего не надо – никаких лишних телодвижений.

Следующий уровень: сервис

Этот уровень необходим, чтобы скрыть здесь бизнес-логику. Если перед тем, как начать чтение или запись – в общем, любое взаимодействие с базой, нужно что-то сделать (посчитать какой-то дополнительный параметр или проверить условие), то всё это делается именно здесь.

В этом классе у меня никакой особой логики нет, поэтому всё довольно просто. Но зато наглядно (именно поэтому из всех классов в своей программе я выбрал именно этот). Итак, интерфейс выглядит следующим образом:

public interface ClanService {
    Clan get(long clanId);
    long save(Clan clan);
    void update(Clan clan);
    void delete(long clanId);
    List<Clan> getAll(); //я всё же добавил один метод, хоть он и не нужен для примера
}

Ну а его реализация уже немного длиннее, но вполне предсказуемая: мы всего лишь вызываем уже знакомые методы с предыдущего слоя. В такой реализации этот класс лишь усложняет программу и его можно было бы опустить, но я всё равно показываю его, чтобы обозначить место под бизнес-логику и показать, как нужно делать «по уму».

@ApplicationScoped
public class ClanServiceImpl implements ClanService {

    @Inject
    private ClanDAO clanDAO;

    @Override
    public Clan get(long clanId) {
        return clanDAO.get(clanId).orElse(null);
    }

    @Override
    public List<Clan> getAll() {
        return clanDAO.getAll();
    }

    @Override
    public long save(Clan clan) {
        clanDAO.save(clan);
        long id = clanDAO.getAll().size();
        return id;
    }

    public void update(Clan clan) {
        clanDAO.update(clan);
    }

    public void delete(long clanId) {
        clanDAO.delete(clanId);
    }
}

Стоит обратить внимание лишь на метод save. Так как задавать поле id нужно в коде (пользователь его не вводит при создании клана), и в моём случае оно само собой заполняется в базе (clan_id BIGINT AUTO_INCREMENT PRIMARY KEY), то нужно его как-то узнать, чтобы вернуть этот назначенный номер. Чтобы не рыться в базе в поисках последней записи, я просто использую метод getAll() и возвращаю количество всех кланов – оно будет совпадать с номером только что созданного клана.

REST-controller (он же REST-ресурс)

И пришло время самого важного момента во всей статье: создание слоя, который и будет принимать запросы пользователя и отправлять ему данные в формате JSON. Класс ClanController.

@Path("/clan")
public class ClanController {
    @Inject
    ClanService clanService;

@Path говорит о том, по какому адресу нужно искать данный ресурс. Ну а @Inject позволяет внедрить зависимость – написанный ранее сервис будет тут очень кстати.

Перед каждым из методов нужно объявить, какой же HTTP-метод будет с ним ассоциироваться, ну и не забыть про тип данных. В принципе, можно не ограничиваться JSON, но я тут не стал особо экспериментировать.

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getById(@PathParam("id") long id) {
        Clan clan = clanService.get(id);
        if (clan != null)
            return Response.ok(clan).build();
        return Response.status(Response.Status.NOT_FOUND).build();
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAll() {
        List<Clan> clans = clanService.getAll();
        return Response.ok(clans).build();
    }

В случае, если наш сервис возвращает Optional без клана внутри, то будет уместно вернуть код 404. А если всё в порядке, то возвращаю 200 с кланом в теле ответа.

Ещё в коде фигурирует @PathParam. Так обозначаются параметры, которые берутся из адреса ресурса. Как положено в REST-сервисах, обращение к каждому объекту происходит по его уникальному адресу. Уникальный адрес складывается из адреса контроллера (@Path обозначенного над классом) и адреса, заданного над методом (если он есть). Так как нам интеерсен параметр — переменная id, то нужно поставить её название в фигурные скобки: @Path(«/{id}»).

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(Clan clan, @Context UriInfo uriInfo) {
        long id = clanService.save(clan);

        UriBuilder uriBuilder = uriInfo.getAbsolutePathBuilder();
        uriBuilder.path(Long.toString(id));
        return Response.created(uriBuilder.build()).build();
    }

В методе POST (с созданием нового клана) я возвращаю ссылку на новый объект. Так как для доступа к нему нужен лишь его id, то я составляю адрес с помощью того самого id, который вычислял выше в сервисе.

С методами PUT и DELETE всё ещё проще:

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    public Response update(Clan updatedClan, @PathParam("id") long id) {
        clanService.update(updatedClan);
        return Response.ok().build();
    }

    @DELETE
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    public Response delete(@PathParam("id") long id) {
        clanService.delete(id);
        return Response.noContent().build();
    }

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


Что ж, на этом всё. На примере одной сущности (клана из игры) я разобрал создание REST API в Java без помощи Spring. По итогу у нас есть встроенная база данных, описанная в отдельном пакете модель, слой, отвечающий за трансфер объектов из базы и в базу, сервис и сам контроллер, который обрабатывает запросы пользователя.

Конечно, этот проект можно дорабатывать и расширять. На всякий случай прилагаю ссылку на свой репозиторий, где я выложил целую версию проекта. DAO там выглядят сложнее из-за перегрузки методов (я решил сделать так, чтобы не запрашивать новое соединение с базой ради каждого небольшого изменения), а также местами есть намёки на многопоточность. Изменять записи в базе там может лишь один поток в один момент времени. С многопоточностью я не самый большой эксперт (судя по тому, что меня с таким тестовым отфутболили), поэтому на эти моменты я бы не полагался, но, по-моему, всё работает очень даже мило. И для учебного проекта вполне хватит.

Ссылка на проект.

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s