В современных приложениях часто требуется выбирать данные, созданные за определённый промежуток времени – например, за последние 30 дней․ Ниже представлен подробный обзор способов реализации такого запроса на Java: от чистого JDBC до популярных фреймворков (Spring Data JPA, MyBatis)․ В статье учтены нюансы работы с датой и временем, часовыми поясами и параметризованными запросами․
Содержание
- Подготовка окружения
- Работа с датой и временем в Java
- Формирование SQL‑запроса
- Пример с чистым JDBC
- Пример с Spring Data JPA
- Пример с MyBatis
- Учёт часовых поясов
- Тестирование и отладка
- Лучшие практики
Получение записей за последние 30 дней – типичная задача для аналитики, дашбордов и систем мониторинга․ Основные требования:
- Запрос должен быть параметризованным, чтобы избежать SQL‑инъекций․
- Необходимо корректно работать с типами
DATE,TIMESTAMPиDATETIMEв базе данных․ - Желательно использовать текущее время сервера БД или приложения, учитывая возможные различия часовых поясов․
Подготовка окружения
Для примеров понадобится:
- Java 17+ (можно и более ранние версии, но
java․timeдоступен с Java 8)․ - Драйвер JDBC для вашей СУБД (например,
postgresql-42․7․3․jarилиmysql-connector-j-8․0․33․jar)․ - Опционально: Spring Boot 3․x, Spring Data JPA, MyBatis‑Spring․
- Тестовая таблица, например:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
event_type VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW
);
Работа с датой и временем в Java
С Java 8 рекомендуется использовать пакет java;time:
Instant– момент времени в UTC․OffsetDateTimeилиZonedDateTime– момент с учётом смещения/часового пояса․LocalDateTime– без пояса (используется, если колонка БДTIMESTAMP WITHOUT TIME ZONE)․
Получить текущий момент и вычесть 30 дней можно так:
// Текущий момент в UTC
Instant now = Instant․now;
// Минус 30 дней
Instant thirtyDaysAgo = now․minus(java․time․Duration․ofDays(30));
Если нужен конкретный часовой пояс (например, сервер находится в Европе/Москве):
ZoneId zone = ZoneId․of("Europe/Moscow");
ZonedDateTime nowZoned = ZonedDateTime․now(zone);
ZonedDateTime thirtyDaysAgoZoned = nowZoned․minusDays(30);
Формирование SQL‑запроса
Базовый запрос выглядит так:
SELECT *
FROM events
WHERE created_at >= ? -- нижняя граница (30 дней назад)
AND created_at < ? -- верхняя граница (сейчас, исключительно)
ORDER BY created_at DESC;
Мы используем два параметра, чтобы избежать проблем с включением/исключением границы и обеспечить возможность использования индекса по колонке created_at․
Пример с чистым JDBC
Ниже показан метод, возвращающий список объектов Event за последние 30 дней․
import java․sql․;
import java․time․;
import java․util․*;
public class EventDaoJdbc {
private final String url;
private final String user;
private final String password;
public EventDaoJdbc(String url, String user, String password) {
this․url = url;
this․user = user;
this․password = password;
}
public List<Event> findLast30Days throws SQLException {
String sql = """
SELECT id, user_id, event_type, created_at
FROM events
WHERE created_at >= ?
AND created_at < ?
ORDER BY created_at DESC
""";
try (Connection conn = DriverManager․getConnection(url, user, password);
PreparedStatement ps = conn․prepareStatement(sql)) {
// Вычисляем границы в UTC (можно изменить на нужный часовой пояс)
Instant now = Instant․now;
Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
// Устанавливаем параметры как TIMESTAMP WITH TIME ZONE
ps․setTimestamp(1, Timestamp․from(thirtyDaysAgo));
ps․setTimestamp(2, Timestamp․from(now));
try (ResultSet rs = ps․executeQuery) {
List<Event> list = new ArrayList<>;
while (rs․next) {
Event e = new Event;
e․setId(rs․getLong("id"));
e․setUserId(rs․getLong("user_id"));
e․setEventType(rs․getString("event_type"));
e․setCreatedAt(rs․getTimestamp("created_at")․toInstant);
list․add(e);
}
return list;
}
}
}
}
Пояснения:
- Мы используем
PreparedStatement– защита от SQL‑инъекций․ - Параметры передаются как
java․sql․Timestamp, построенный изInstant(UTC)․ Если ваша колонка хранит время без пояса, используйтеLocalDateTimeиTimestamp․valueOf․ - Запрос использует диапазон
[ thirtyDaysAgo , now )– это гарантирует, что записи с точно текущим моментом не будут дублироваться при повторном вызове в течение одной миллисекунды․
Пример с Spring Data JPA
Если проект уже использует Spring Boot и Spring Data JPA, достаточно определить репозиторий с методом, использующим @Query или derivation․
Сущность
import jakarta․persistence․*;
import java․time․Instant;
@Entity
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType․IDENTITY)
private Long id;
private Long userId;
@Column(name = "event_type")
private String eventType;
@Column(name = "created_at")
private Instant createdAt;
// геттеры и сеттеры
} Репозиторий
import org․springframework․data․jpa․repository․*;
import org․springframework․data․repository․query․Param;
import java․time․Instant;
import java․util․List;
public interface EventRepository extends JpaRepository<Event, Long> {
@Query("SELECT e FROM Event e WHERE e․createdAt >= :start AND e․createdAt < :end")
List<Event> findByCreatedAtBetween(
@Param("start") Instant start,
@Param("end") Instant end);
}
Сервис
import org․springframework;stereotype․Service;
import java․time․*;
import java․util․List;
@Service
public class EventService {
private final EventRepository repo;
public EventService(EventRepository repo) {
this․repo = repo;
}
public List<Event> getLast30Days {
Instant now = Instant․now;
Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
return repo․findByCreatedAtBetween(thirtyDaysAgo, now);
}
}
Spring Data JPA автоматически преобразует Instant в appropriate SQL‑тип (обычно TIMESTAMP WITH TIME ZONE для PostgreSQL или DATETIME для MySQL)․
Пример с MyBatis
MyBatis позволяет писать XML‑мапперы или использовать аннотации․
Mapper (XML)
<mapper namespace="com․example․mapper․EventMapper">
<select id="selectLast30Days" resultType="Event">
SELECT id, user_id, event_type, created_at
FROM events
WHERE created_at >= #{startDate}
AND created_at < #{endDate}
ORDER BY created_at DESC
</select>
</mapper>
Java‑интерфейс
package com․example․mapper;
import org․apache․ibatis․annotations․*;
import java․time․Instant;
import java․util․List;
public interface EventMapper {
@Select("""
SELECT id, user_id, event_type, created_at
FROM events
WHERE created_at >= #{startDate}
AND created_at < #{endDate}
ORDER BY created_at DESC
""")
List<Event> selectLast30Days(@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
}
Вызов из сервиса
@Service
public class EventServiceMyBatis {
private final EventMapper mapper;
public EventServiceMyBatis(EventMapper mapper) {
this․mapper = mapper;
}
public List<Event> getLast30Days {
Instant now = Instant․now;
Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
return mapper․selectLast30Days(thirtyDaysAgo, now);
}
}
Учёт часовых поясов
Неправильная работа с часовыми поясами – частая причина ошибок при выборке за период․
- Если колонка
created_atимеет типTIMESTAMP WITH TIME ZONE(PostgreSQL, Oracle сTIMESTAMP WITH TIME ZONE), передавайте параметры в видеInstantилиOffsetDateTime; JDBC‑драйвер сам выполнит конверсию в нужный часовой пояс БД․ - Для колонки
TIMESTAMP WITHOUT TIME ZONE(MySQLDATETIME, PostgreSQL безWITH TIME ZONE) лучше хранить время в UTC и всегда сравнивать с UTC․ В этом случае используйтеInstantи приведите его кTimestamp(как в примере JDBC)․ - Если требуется работать с локальным временем пользователя (например, «за последние 30 дней по времени пользователя»), сначала переведите момент пользователя в UTC, используя его
ZoneId, а затем выполните запрос․
Тестирование и отладка
Для unit‑тестов удобно использовать in‑memory БД (H2) с режимом совместимости с вашей СУБД․
@Test
void testFindLast30Days_returnsRecentEvents throws Exception {
// given
Instant now = Instant․now;
Instant thirtyDaysAgo = now․minus(Duration․ofDays(30));
Instant old = now․minus(Duration․ofDays(31));
// вставляем тестовые данные через JdbcTemplate или EntityManager
jdbc․update("INSERT INTO events (user_id, event_type, created_at) VALUES (?, ?, ?)",
1L, "TYPE_A", Timestamp․from(old));
jdbc․update("INSERT INTO events (user_id, event_type, created_at) VALUES (?, ?, ?)",
2L, "TYPE_B", Timestamp․from(now․minus(Duration․ofDays(15))));
// when
List<Event> result = dao․findLast30Days;
// then
assertEquals(1, result․size);
assertEquals("TYPE_B", result․get(0)․getEventType);
}
Для проверки корректности работы с часовыми поясами можно установить временную зону JVM:
TimeZone․setDefault(TimeZone․getTimeZone("America/New_York"));
и убедиться, что результаты не меняются (если используется UTC в базе)․
Лучшие практики
- Параметризуйте запрос – никогда не concatenate строки с датами․
- Индексируйте колонку даты – без индекса по
created_atзапрос будет сканировать всю таблицу․ - Выбирайте только нужные колонки – вместо
SELECT *явно перечислите поля, чтобы уменьшить объём передаваемых данных․ - Учитывайте смещение времени – если ваша БД хранит время в UTC, всегда сравнивайте с UTC․
- Логируйте параметры запроса (в режиме DEBUG) – это упрощает отладку проблем с часовыми поясами․
- Используйте connection pool (HikariCP, Tomcat JDBC) – избегайте создания нового соединения на каждый запрос․
- Тестируйте граничные случаи – записи, созданные ровно 30 дней назад и ровно сейчас․
Получение записей за последние 30 дней в Java – задача, которую можно решить несколькими способами в зависимости от стека технологий:
- Чистый JDBC даёт полный контроль над запросом и параметрами, подходит для высоконагруженных систем или когда нужен минимальный набор зависимостей․
- Spring Data JPA упрощает код благодаря автоматическому маппингу и возможности использовать derivation или
@Query․ - MyBatis гибок в написании SQL и удобен, когда требуется сложная динамическая конструкция запроса․
Ключевой момент – правильно работать с датой и временем, использовать параметризованные запросы и учитывать часовые пояса․ Следуя рекомендациям из этой статьи, вы сможете построить надёжный и производительный механизм выборки данных за последние 30 дней․
Автор: ваш помощник по программированию․ Дата публикации: ․