Работа с датой в Spring Boot

Вернуться назад

12 мая 2018

В предыдущих статьях мы уже создавали rest-приложение (Spring Boot Restful Service, Работа с БД в Spring Boot на примере postgresql). А теперь давайте рассмотрим, как работать с датой и временем в Spring Boot на уровне rest-запросов и на уровне БД.

Предположим, перед нами стоит задача фиксировать в специальной таблице все действия пользователя (регистрация, вход, выход и т.п.) Таблица для СУБД Postgres в самом простом случае будет выглядеть так:

CREATE TABLE user_action
(
   id serial NOT NULL
   action_date timestamp without time zone NOT NULL
   user_id integer NOT NULL
   action_type integer NOT NULL
   CONSTRAINT user_action_pk PRIMARY KEY (id)


Тип serial представляет собой поле, которое автоматически увеличивается на единицу для каждой новой записи, поэтому его удобно использовать в качестве первичного ключа для записи.

Тип timestamp without time zone позволяет хранить метку времени без привязки к часовому поясу.

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

Для типов действий удобно создать enum, чтобы не запоминать конкретные id.

public enum ActionType {

    LOGIN(1),
    LOGOUT(2);

    private int id;

    ActionType(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }

    public static ActionType getById(int id) {
        for (ActionType action : values()) {
            if (action.getId() == id) {
                return action;
            }
        }
        throw new RuntimeException("Unknown action type: " + id);
    }
}

В этом enum имеется приватное поле id. Возможные значения (LOGIN, LOGOUT) инициализируются тут же через приватный конструктор. Статический метод getById() позволяет по числовому значению получить одно из значений enum, что очень удобно при чтении записи из БД. Все доступные значения мы получаем через метод values() и последовательно проверяем их. Как только нашли соответствие - сразу же возвращаем его, выходя из метода (а значит, и из цикла). Если же вдруг мы не смогли найти подходящего значения для указанного id - это является ошибкой времени выполнения и мы кидаем исключение после цикла. Однако если вы всегда пишете в БД тип действия при помощи этого enum, то такой ошибки произойти не может.

Добавление записи

Теперь мы готовы реализовать добавление записи о действии пользователя на уровне БД.

@Repository
public class UserActionDao {

    private static final String SQL_ADD_ACTION =
        "insert into user_action (action_date, user_id, action_type) values (:actionDate, :userId, :actionId)";

    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    public void addAction(LocalDateTime actionDate, int userId, ActionType action) {
        MapSqlParameterSource params = new MapSqlParameterSource();
        params.addValue("actionDate", Timestamp.valueOf(actionDate));
        params.addValue("userId", userId);
        params.addValue("actionId", action.getId());
        jdbcTemplate.update(SQL_ADD_ACTION, params);
    }
}

Здесь в общем-то всё прозрачно. Формируем параметры запроса через класс MapSqlParameterSource, а затем выполняем sql-запрос через метод update() класса NamedParameterJdbcTemplate. В тексте запроса значения параметров начинаются с двоеточия. В параметры дата должна передаваться в виде Timestamp. Но у этого класса есть статический метод valueOf(), который на вход принимает именно наш LocalDateTime.

Класс, в который будет мапиться json-запрос на добавление записи выглядит так:

public class UserAction {

    @NotNull
    private LocalDateTime actionDate;

    @NotNull
    @Min(1)
    private Integer userId;

    @NotNull
    private ActionType action;

    // далее идут геттеры и сеттеры...
}

Здесь мы используем валидацию параметров. Аннотация @NotNull означает, что параметр обязательный. Чтобы корректно работала эта проверка с числовыми типами, в запросе используем не примитивный int, который по умолчанию примет значение 0, а объектный Integer, который по умолчанию null. Аннотация @Min указывает, какое минимальное значение допустимо для числового поля. В данном примере исходим из того, что все id положительные и начинаются с 1.

Пример json-запроса, который соответствует этому классу:

{
    "actionDate""2018-05-10T11:22:33",
    "userId"1,
    "action""LOGIN"
}

Обратите внимание, что параметр «action» имеет здесь не числовой тип, а строковый, причём допустимые значения - это имена значений из перечисления ActionType.

Не менее интересен формат даты и времени. Сначала идёт год, затем месяц, затем день, затем литерал «T», затем время. Это стандартный формат даты, но чтобы он корректно работал в Spring Boot, нужно добавить новую dependency в наш pom-файл:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Также нужно добавить новый параметр в конфигурационный файл приложения, путь до которого указывается в командной строке через параметр --spring.config.location:

spring.jackson.serialization.write_dates_as_timestamps=false

Теперь мы готовы написать наш rest-контроллер:

@RestController
@RequestMapping(value = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
public class UserController {

    @Autowired
    private UserActionDao userActionDao;

    @PostMapping("/actions")
    @ResponseStatus(HttpStatus.CREATED)
    public void writeAction(@Valid @RequestBody UserAction request) {
        userActionDao.addAction(request.getActionDate(), request.getUserId(), request.getAction());
    }
}

В rest-сервисах добавление новых записей происходит черeз POST-запрос (аннотация @PostMapping). Через аннотацию @ResponseStatus мы указываем, что http-статус ответа будет 201 - «Created». @RequestBody указывает, какой именно параметр метода должен быть связан с телом json-запроса, а @Valid нужна для того, чтобы работала валидация параметров, которую мы рассматривали выше.

Теперь мы можем запустить наше приложение и отправить указанный выше POST-запрос. Не забудьте добавить http-заголовок «Content-Type: application/json». Из командной строки запрос можно выполнить так:

curl -X POST -H 'Content-Type: application/json' -i 'http://127.0.0.1:8080/users/actions' --data '{
    "actionDate": "2018-05-10T11:22:33",
    "userId": 1,
    "action": "LOGIN"
}'

Если всё сделано правильно, в нашей таблице появится новая запись.

Просмотр истории записей

А теперь давайте создадим запрос на чтение ранее добавленных записей, причём будем фильтровать по дате добавления, передавая на вход нужный нам диапазон дат. На выходе будем получать список из тех же объектов UserAction, который использовали ранее. Поэтому нам понадобится RowMapper, который будет построчно преобразовывать полученный из БД набор данных в нужные нам объекты:

@Component
public class UserActionMapper implements RowMapper<UserAction> {

    @Override
    public UserAction mapRow(ResultSet rs, int i) throws SQLException {
        UserAction action = new UserAction();
        action.setUserId(rs.getInt("user_id"));
        action.setAction(ActionType.getById(rs.getInt("action_type")));
        action.setActionDate(rs.getTimestamp("action_date").toLocalDateTime());
        return action;
    }
}

При чтении данных из БД для даты действия получаем объект Timestamp, который имеет встроенный метод для преобразования в привычный нам LocalDateTime.

Теперь добавим в UserActionDao метод для чтения истории. Также нам потребуется подгрузить наш маппер:

private static final String SQL_GET_HISTORY =
    "select * from user_action where action_date >= :dateFrom and action_date < :dateTo" +
    " and user_id = :userId order by action_date desc";

@Autowired
private UserActionMapper userActionMapper;

public List<UserAction> getUserActionHistory(int userId, LocalDate dateFrom, LocalDate dateTo) {
    MapSqlParameterSource params = new MapSqlParameterSource();
    params.addValue("userId", userId);
    params.addValue("dateFrom", Timestamp.valueOf(dateFrom.atTime(LocalTime.MIN)));
    params.addValue("dateTo", Timestamp.valueOf(dateTo.atTime(LocalTime.MAX)));
    return jdbcTemplate.query(SQL_GET_HISTORY, params, userActionMapper);
}

Обратите внимание, что метод получает на вход LocalDate, т.е. дату без времени. Указывая диапазон дат, мы подразумеваем промежуток с первой секунды начальной даты до последней секунды конечной даты. Поэтому преобразуем дату в дату со временем при помощи метода atTime() и констант, доступных в классе LocalTime.

Теперь добавим новый метод в наш контроллер:

@GetMapping("/{userId}/action/history")
public List<UserAction> getUserActionHistory(
    @PathVariable("userId"int userId,
    @RequestParam("dateFrom"@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
    @RequestParam("dateTo"@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo
) {
    return userActionDao.getUserActionHistory(userId, dateFrom, dateTo);
}

Аннотация @GetMapping определяет, какой тип запроса и какой адрес связывается с данным методом, причём здесь мы указываем переменную userId. Её значение подставляем в соответствующий параметр при помощи аннотации @PathVariable. Поскольку у GET-запроса не может быть body, в отличие от POST и PUT запросов, диапазон передаём в виде параметров запроса (аннотация @RequestParam). Формат даты задаём через @DateTimeFormat («ГГГГ-ММ-ДД»). В теле обработчика вызываем наш dao-слой.

Если всё сделано правильно, нам достаточно запустить наше приложение и выполнить следующий GET-запрос:

http://127.0.0.1:8080/users/1/action/history?dateFrom=2018-05-10&dateTo=2018-05-11

В этом примере мы запрашиваем историю действий для пользователя с userId=1 с 10 по 11 мая 2018 года. Формат ответа будет таким:

[{
  "actionDate""2018-05-10T11:22:33",
  "userId"1,
  "action""LOGIN"
}, {
  "actionDate""2018-05-10T11:22:33",
  "userId"1,
  "action""LOGOUT"
}]

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

Тэги: Java, PostgreSQL, Spring Boot.


Исходники