diff --git a/README.md b/README.md index 5c936ee..36e5e3d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,17 @@ # Практическое задание 2 # Redis Map -В рамках практической задачи нужно реализовать прослойку между Redis и интерфейсом Map -Достаточно будет реализации Map +## Краткое описание проделанной работы +- Реализован класс `org.redis.RedisMap` — обёртка над Redis Hash, соответствующая `Map`, с использованием Jedis. +- Обеспечена безопасная работа с ресурсами (`try-with-resources`), добавлен `AutoCloseable` для корректного закрытия пула при владении им. +- Возвращаются немодифицируемые снимки `keySet`/`values`/`entrySet`; null-ключи и null-значения не поддерживаются. +- Покрыто модульными тестами JUnit 5; каждый тест использует уникальный `hashKey` для изоляции; тесты проходят успешно. -**Описание:**
-✅ Написать реализацию методов Map, которая будет работать с Redis
-✅ Работать с redis можно через Jedis
-✅ Покрыть все методы тестами
-✅ Для поднятия redis, в корне проекта есть docker-compose файл
- -Redis поднимается командой +## Как запустить проверки +1) Поднять Redis: ``` docker-compose up -d ``` - -**Как выполнить:** -1. Сделать форк этого проекта -2. Сделать ветку dev в своем проекте. Выполнять работу в ней -3. После выполнения работы открыть Pull Request в master -4. Назначить ментора ревьювером -5. Отправить ссылку на PR в тг чат ментору \ No newline at end of file +2) Запустить тесты: +``` +mvn test +``` diff --git a/src/main/java/org/redis/RedisMap.java b/src/main/java/org/redis/RedisMap.java new file mode 100644 index 0000000..7cd2fc4 --- /dev/null +++ b/src/main/java/org/redis/RedisMap.java @@ -0,0 +1,344 @@ +package org.redis; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Реализация Map поверх одного Redis Hash с использованием Jedis. + * Ключи соответствуют полям хеша, значения — значениям полей. + *

Допущения для реализации: + *
- null ключи и null значения не поддерживаются (бросается NullPointerException) + *
- Коллекционные представления (keySet/values/entrySet) возвращаются как немодифицируемые снимки + */ +public class RedisMap implements Map, AutoCloseable { + + public static final String KEY_MUST_NOT_BE_NULL = "Ключ не может быть null"; + public static final String VALUE_MUST_NOT_BE_NULL = "Значение не может быть null"; + public static final String COLLECTION_MUST_NOT_BE_NULL = "Коллекция не может быть null"; + + private final JedisPool pool; + private final String hashKey; + private final boolean ownPool; + + private static final String DEFAULT_HOST = "localhost"; + private static final int DEFAULT_PORT = 6379; + private static final String DEFAULT_HASH_KEY = "redis-map"; + + /** + * Создаёт карту, подключённую к Redis на localhost:6379 и использующую хеш "redis-map". + */ + public RedisMap() { + this(new JedisPool(DEFAULT_HOST, DEFAULT_PORT), DEFAULT_HASH_KEY, true); + } + + /** + * Создаёт карту с указанными параметрами подключения и именем Redis-хеша. + * + * @param host хост Redis + * @param port порт Redis + * @param hashKey имя Redis-хеша для хранения данных + */ + public RedisMap(String host, int port, String hashKey) { + this(new JedisPool(Objects.requireNonNull(host, "host"), port), hashKey, true); + } + + /** + * Создаёт карту поверх указанного пула соединений и Redis-хеша. + * + * @param pool пул соединений Jedis + * @param hashKey имя Redis-хеша для хранения данных + */ + public RedisMap(JedisPool pool, String hashKey) { + this(pool, hashKey, false); + } + + /** + * Создаёт карту поверх переданного пула, с указанием владения пулом. + * + * @param pool пул соединений Jedis + * @param hashKey имя Redis-хеша для хранения данных + * @param ownPool признак, что карта владеет пулом и должна его закрыть + */ + private RedisMap(JedisPool pool, String hashKey, boolean ownPool) { + this.pool = Objects.requireNonNull(pool, "pool"); + this.hashKey = Objects.requireNonNull(hashKey, "hashKey"); + this.ownPool = ownPool; + } + + /** + * Возвращает количество записей (HLEN). + * + * @return размер карты + */ + @Override + public int size() { + try (Jedis jedis = pool.getResource()) { + long length = jedis.hlen(hashKey); + return (int) Math.min(length, Integer.MAX_VALUE); + } + } + + /** + * Проверяет, пуста ли карта (HLEN == 0). + * + * @return true, если карта пуста + */ + @Override + public boolean isEmpty() { + return size() == 0; + } + + /** + * Проверяет наличие ключа (HEXISTS). + * null ключи не поддерживаются. + * Ключи не типа String считаются отсутствующими. + * + * @param key ключ + * @return true, если ключ присутствует + */ + @Override + public boolean containsKey(Object key) { + Objects.requireNonNull(key, KEY_MUST_NOT_BE_NULL); + if (!(key instanceof String)) { + return false; + } + try (Jedis jedis = pool.getResource()) { + return jedis.hexists(hashKey, (String) key); + } + } + + /** + * Проверяет наличие значения путём перебора всех значений (HVALS). + * null значения не поддерживаются. + * Значения не типа String считаются отсутствующими. + * + * @param value значение + * @return true, если значение присутствует + */ + @Override + public boolean containsValue(Object value) { + Objects.requireNonNull(value, VALUE_MUST_NOT_BE_NULL); + if (!(value instanceof String)) { + return false; + } + try (Jedis jedis = pool.getResource()) { + List valueList = jedis.hvals(hashKey); + for (String currentValue : valueList) { + if (Objects.equals(currentValue, value)) { + return true; + } + } + return false; + } + } + + /** + * Возвращает значение по ключу (HGET). + * null ключи не поддерживаются. + * Для ключей не типа String возвращает null. + * + * @param key ключ + * @return значение или null, если отсутствует + */ + @Override + public String get(Object key) { + Objects.requireNonNull(key, KEY_MUST_NOT_BE_NULL); + if (!(key instanceof String)) { + return null; + } + try (Jedis jedis = pool.getResource()) { + return jedis.hget(hashKey, (String) key); + } + } + + /** + * Записывает значение по ключу (HSET), возвращая предыдущее (HGET). + * null ключи и значения не поддерживаются. + * + * @param key ключ + * @param value значение + * @return предыдущее значение или null, если не было + */ + @Override + public String put(String key, String value) { + Objects.requireNonNull(key, KEY_MUST_NOT_BE_NULL); + Objects.requireNonNull(value, VALUE_MUST_NOT_BE_NULL); + try (Jedis jedis = pool.getResource()) { + String previousValue = jedis.hget(hashKey, key); + jedis.hset(hashKey, key, value); + return previousValue; + } + } + + /** + * Удаляет значение по ключу (HDEL), возвращая предыдущее (HGET). + * null ключи не поддерживаются. + * Ключи не типа String игнорируются (возвращается null). + * + * @param key ключ + * @return предыдущее значение или null + */ + @Override + public String remove(Object key) { + Objects.requireNonNull(key, KEY_MUST_NOT_BE_NULL); + if (!(key instanceof String)) { + return null; + } + try (Jedis jedis = pool.getResource()) { + String previousValue = jedis.hget(hashKey, (String) key); + if (previousValue != null) { + jedis.hdel(hashKey, (String) key); + } + return previousValue; + } + } + + /** + * Массовая запись полей (HSET с картой). + * null ключи/значения не поддерживаются. + * + * @param m карта для записи + */ + @Override + public void putAll(Map m) { + Objects.requireNonNull(m, COLLECTION_MUST_NOT_BE_NULL); + if (m.isEmpty()) return; + + Map copy = new HashMap<>(m.size()); + for (Entry entry : m.entrySet()) { + String key = Objects.requireNonNull(entry.getKey(), KEY_MUST_NOT_BE_NULL); + String valueToPut = Objects.requireNonNull(entry.getValue(), VALUE_MUST_NOT_BE_NULL); + copy.put(key, valueToPut); + } + + try (Jedis jedis = pool.getResource()) { + jedis.hset(hashKey, copy); + } + } + + /** + * Полная очистка карты (DEL хеша). + */ + @Override + public void clear() { + try (Jedis jedis = pool.getResource()) { + jedis.del(hashKey); + } + } + + /** + * Возвращает снимок множества ключей (HKEYS). + * + * @return снимок множества ключей + */ + @Override + public Set keySet() { + try (Jedis jedis = pool.getResource()) { + Set keys = jedis.hkeys(hashKey); + return Set.copyOf(keys); + } + } + + /** + * Возвращает снимок коллекции значений (HVALS). + * + * @return снимок коллекции значений + */ + @Override + public Collection values() { + try (Jedis jedis = pool.getResource()) { + List valueList = jedis.hvals(hashKey); + return List.copyOf(valueList); + } + } + + /** + * Возвращает снимок множества пар (HGETALL). + * + * @return снимок множества пар + */ + @Override + public Set> entrySet() { + try (Jedis jedis = pool.getResource()) { + Map allEntries = jedis.hgetAll(hashKey); + Set> entriesSnapshot = new HashSet<>(allEntries.size()); + for (Entry entry : allEntries.entrySet()) { + entriesSnapshot.add(new SimpleImmutableEntry<>(entry)); + } + return Collections.unmodifiableSet(entriesSnapshot); + } + } + + /** + * Сравнение по содержимому как у обычной Map (по снимку данных). + * + * @param o другой объект + * @return true, если содержимое эквивалентно + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Map other)) return false; + Map snapshotMap = snapshot(); + return snapshotMap.equals(other); + } + + /** + * Хэш-код по контракту Map на основе снимка данных. + * + * @return хэш-код + */ + @Override + public int hashCode() { + return snapshot().hashCode(); + } + + /** + * Возвращает снимок данных как обычную HashMap. + * + * @return снимок карты + */ + private Map snapshot() { + try (Jedis jedis = pool.getResource()) { + return jedis.hgetAll(hashKey); + } + } + + /** + * Закрывает пул соединений, если пул создан и управляется этой картой. + */ + @Override + public void close() { + if (ownPool) { + pool.close(); + } + } + + /** + * Возвращает имя Redis-хеша, используемого этой картой. + * + * @return имя хеша + */ + public String getHashKey() { + return hashKey; + } + + /** + * Возвращает пул соединений Jedis, используемый картой. + * + * @return пул Jedis + */ + public JedisPool getPool() { + return pool; + } +} diff --git a/src/test/java/org/redis/RedisMapTest.java b/src/test/java/org/redis/RedisMapTest.java new file mode 100644 index 0000000..2f8f741 --- /dev/null +++ b/src/test/java/org/redis/RedisMapTest.java @@ -0,0 +1,187 @@ +package org.redis; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.JedisPool; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Тесты для RedisMap с изоляцией через уникальный hashKey на тест. + */ +class RedisMapTest { + + private static JedisPool pool; + + private RedisMap map; + + /** + * Инициализация пула соединений к локальному Redis. + */ + @BeforeAll + static void initPool() { + pool = new JedisPool("localhost", 6379); + } + + /** + * Перед каждым тестом создаётся уникальный хеш для изоляции. + */ + @BeforeEach + void setUp() { + String hashKey = "redis-map-test:" + UUID.randomUUID(); + map = new RedisMap(pool, hashKey); + } + + /** + * Закрытие пула после всех тестов. + */ + @AfterAll + static void closePool() { + if (pool != null) { + pool.close(); + } + } + + /** + * Проверяет size и isEmpty. + */ + @Test + void testIsEmptyAndSize() { + assertTrue(map.isEmpty()); + assertEquals(0, map.size()); + + map.put("key1", "value1"); + map.put("key2", "value2"); + map.put("key3", "value3"); + + assertFalse(map.isEmpty()); + assertEquals(3, map.size()); + } + + /** + * Проверяет put/get и возврат предыдущего значения при перезаписи. + */ + @Test + void testPutGetOverwriteReturnsPrev() { + assertNull(map.put("key", "value")); + assertEquals("value", map.get("key")); + + assertEquals("value", map.put("key", "new")); + assertEquals("new", map.get("key")); + } + + /** + * Проверяет remove и возврат предыдущего значения. + */ + @Test + void testRemoveReturnsPrev() { + map.put("remove", "text"); + assertEquals("text", map.remove("remove")); + assertNull(map.remove("remove")); + } + + /** + * Проверяет putAll и clear. + */ + @Test + void testPutAllAndClear() { + Map initial = new HashMap<>(); + initial.put("a", "alpha"); + initial.put("b", "beta"); + + map.putAll(initial); + assertEquals(2, map.size()); + assertEquals("alpha", map.get("a")); + assertEquals("beta", map.get("b")); + + map.clear(); + assertTrue(map.isEmpty()); + } + + /** + * Проверяет containsKey и containsValue. + */ + @Test + void testContainsKeyAndContainsValue() { + map.put("город", "Астрахань"); + map.put("страна", "Россия"); + + assertTrue(map.containsKey("город")); + assertFalse(map.containsKey("улица")); + + assertTrue(map.containsValue("Россия")); + assertFalse(map.containsValue("Казахстан")); + } + + /** + * Проверяет снимки keySet/values/entrySet и их немодифицируемость. + */ + @Test + void testKeySetValuesEntrySetSnapshotsAndUnmodifiable() { + map.put("x", "икс"); + map.put("y", "игрек"); + + Set keys = map.keySet(); + assertEquals(new HashSet<>(Arrays.asList("x", "y")), keys); + + Collection values = map.values(); + assertTrue(values.contains("икс")); + assertTrue(values.contains("игрек")); + assertEquals(2, values.size()); + + Set> entries = map.entrySet(); + Map expected = new HashMap<>(); + expected.put("x", "икс"); + expected.put("y", "игрек"); + assertEquals(expected.entrySet(), entries); + + // Проверка немодифицируемости + assertThrows(UnsupportedOperationException.class, () -> keys.add("z")); + assertThrows(UnsupportedOperationException.class, () -> values.add("зет")); + assertThrows(UnsupportedOperationException.class, entries::clear); + } + + /** + * Проверяет equals и hashCode при сравнении с обычной картой. + */ + @Test + void testEqualsAndHashCodeWithHashMap() { + map.put("a", "1"); + map.put("b", "2"); + + Map regular = new HashMap<>(); + regular.put("a", "1"); + regular.put("b", "2"); + + assertEquals(map, regular); + assertEquals(regular, map); + assertEquals(regular.hashCode(), map.hashCode()); + } + + /** + * Проверяет, что null ключи/значения не поддерживаются. + */ + @Test + void testNullKeyValueNotAllowed() { + assertThrows(NullPointerException.class, () -> map.put(null, "value")); + assertThrows(NullPointerException.class, () -> map.put("key", null)); + assertThrows(NullPointerException.class, () -> map.containsKey(null)); + assertThrows(NullPointerException.class, () -> map.containsValue(null)); + assertThrows(NullPointerException.class, () -> map.get(null)); + assertThrows(NullPointerException.class, () -> map.remove(null)); + } +}