Статьи

Расширение кэша Guava для переполнения на диск

Кэширование позволяет значительно ускорить приложения без особых усилий. Две отличные реализации кеша для платформы Java – это кеш Guava и Ehcache . Хотя Ehcache гораздо богаче по своим возможностям (например, его API с возможностью поиска , возможность сохранения кэшей на диске или переполнения на большую память ), он также имеет значительные издержки по сравнению с Guava. В недавнем проекте я обнаружил необходимость переполнения кеша на диск, но в то же время мне регулярно приходилось делать недействительными определенные значения этого кеша. Так как API поиска Ehcache доступен только для кэшей в памяти, это поставило меня перед дилеммой. Однако было довольно легко расширить кеш Guava, чтобы разрешить переполнение на диск структурированным образом. Это позволило мне как переполниться на диск и требуемую функцию аннулирования. В этой статье я хочу показать, как этого можно достичь.

Я реализую этот файл, сохраняющий кэш FilePersistingCache в форме оболочки для реального экземпляра Guava Cache . Это, конечно, не самое элегантное решение (более элегантно было бы реализовать реальный Guava Cache с таким поведением), но я сделаю это для большинства случаев.

Для начала я определю защищенный метод, который создает резервный кеш, о котором я упоминал ранее:

1
2
3
4
5
6
7
8
9
private LoadingCache<K, V> makeCache() {
  return customCacheBuild()
    .removalListener(new PersistingRemovalListener())
    .build(new PersistedStateCacheLoader());
}
  
protected CacheBuilder<K, V> customCacheBuild(CacheBuilder<K, V> cacheBuilder) {
  return CacheBuilder.newBuilder();
}

Первый метод будет использоваться внутри для создания необходимого кэша. Предполагается, что второй метод будет переопределен для реализации любого пользовательского требования к кешу, например, стратегии истечения срока действия. Это может быть, например, максимальное значение записей или мягких ссылок. Этот кеш будет использоваться так же, как и любой другой кеш Guava. Ключом к функциональности кеша являются RemovalListener и CacheLoader , которые используются для этого кеша. Мы определим эти две реализации как внутренние классы FilePersistingCache :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private class PersistingRemovalListener implements RemovalListener<K, V> {
  @Override
  public void onRemoval(RemovalNotification<K, V> notification) {
    if (notification.getCause() != RemovalCause.COLLECTED) {
      try {
        persistValue(notification.getKey(), notification.getValue());
      } catch (IOException e) {
        LOGGER.error(String.format("Could not persist key-value: %s, %s",
          notification.getKey(), notification.getValue()), e);
      }
    }
  }
}
  
public class PersistedStateCacheLoader extends CacheLoader<K, V> {
  @Override
  public V load(K key) {
    V value = null;
    try {
      value = findValueOnDisk(key);
    } catch (Exception e) {
      LOGGER.error(String.format("Error on finding disk value to key: %s",
        key), e);
    }
    if (value != null) {
      return value;
    } else {
      return makeValue(key);
    }
  }
}

Как видно из кода, эти внутренние классы вызывают методы FilePersistingCache мы еще не определили. Это позволяет нам определять пользовательское поведение сериализации, переопределяя этот класс. Слушатель удаления проверит причины удаления записи из кэша. Если в качестве RemovalCause , запись в кэше не была удалена пользователем вручную, но была удалена как следствие стратегии удаления кэша. Поэтому мы будем пытаться сохранить запись в кэше только в том случае, если пользователь не пожелал удалить записи. CacheLoader сначала попытается восстановить существующее значение с диска и создаст новое значение, только если такое значение не может быть восстановлено.

Недостающие методы определены следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private V findValueOnDisk(K key) throws IOException {
  if (!isPersist(key)) return null;
  File persistenceFile = makePathToFile(persistenceDirectory, directoryFor(key));
  (!persistenceFile.exists()) return null;
  FileInputStream fileInputStream = new FileInputStream(persistenceFile);
  try {
    FileLock fileLock = fileInputStream.getChannel().lock();
    try {
      return readPersisted(key, fileInputStream);
    } finally {
      fileLock.release();
    }
  } finally {
    fileInputStream.close();
  }
}
  
private void persistValue(K key, V value) throws IOException {
  if (!isPersist(key)) return;
  File persistenceFile = makePathToFile(persistenceDirectory, directoryFor(key));
  persistenceFile.createNewFile();
  FileOutputStream fileOutputStream = new FileOutputStream(persistenceFile);
  try {
    FileLock fileLock = fileOutputStream.getChannel().lock();
    try {
      persist(key, value, fileOutputStream);
    } finally {
      fileLock.release();
    }
  } finally {
    fileOutputStream.close();
  }
}
  
  
private File makePathToFile(@Nonnull File rootDir, List<String> pathSegments) {
  File persistenceFile = rootDir;
  for (String pathSegment : pathSegments) {
    persistenceFile = new File(persistenceFile, pathSegment);
  }
  if (rootDir.equals(persistenceFile) || persistenceFile.isDirectory()) {
    throw new IllegalArgumentException();
  }
  return persistenceFile;
}
  
protected abstract List<String> directoryFor(K key);
  
protected abstract void persist(K key, V value, OutputStream outputStream)
  throws IOException;
  
protected abstract V readPersisted(K key, InputStream inputStream)
  throws IOException;
  
protected abstract boolean isPersist(K key);

Внедренные методы заботятся о сериализации и десериализации значений, одновременно синхронизируя доступ к файлам и гарантируя, что потоки закрыты соответствующим образом. Последние четыре метода остаются абстрактными и могут быть реализованы пользователем кеша. Метод directoryFor(K) должен идентифицировать уникальное имя файла для каждого ключа. В простейшем случае метод toString класса K ключа реализован таким образом. Кроме того, я сделал абстрактными методы readPersisted , readPersisted и isPersist , чтобы учесть собственную стратегию сериализации, такую ​​как использование Kryo . В самом простом сценарии вы использовали бы встроенную функциональность Java, которая использует ObjectInputStream и ObjectOutputStream . Для isPersist вы бы вернули true , предполагая, что вы будете использовать эту реализацию, только если вам нужна сериализация. Я добавил эту функцию для поддержки смешанных кэшей, где вы можете сериализовать значения только для некоторых ключей. Не закрывайте потоки внутри методов readPersisted и readPersisted поскольку блокировки файловой системы зависят от того, readPersisted потоки открыты. Вышеуказанная реализация позаботится о том, чтобы закрыть поток для вас.

Наконец, я добавил несколько служебных методов для доступа к кешу. Реализация интерфейса Cache Guava, конечно, будет более элегантным решением:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public V get(K key) {
  return underlyingCache.getUnchecked(key);
}
  
public void put(K key, V value) {
  underlyingCache.put(key, value);
}
  
public void remove(K key) {
  underlyingCache.invalidate(key);
}
  
protected Cache<K, V> getUnderlyingCache() {
  return underlyingCache;
}

Конечно, это решение может быть улучшено. Если вы используете кэш в параллельном сценарии, имейте в RemovalListener , что RemovalListener отличается от того, что большинство методов кэширования в RemovalListener выполняется асинхронно. Как видно из кода, я добавил блокировки файлов, чтобы избежать конфликтов чтения / записи в файловой системе. Однако эта асинхронность подразумевает, что существует небольшая вероятность того, что запись значения будет воссоздана, даже если в памяти все еще есть значение. Если вам нужно избежать этого, обязательно cleanUp метод cleanUp базового кэша в методе get оболочки. Наконец, не забудьте очистить файловую систему, когда у вас истечет срок действия кэша. Оптимально, вы будете использовать временную папку вашей системы для хранения записей в кэше, чтобы вообще избежать этой проблемы. В примере кода каталог представлен полем экземпляра с именем persistenceDirectory который, например, может быть инициализирован в конструкторе.

Обновление : я написал чистую реализацию описанного выше, которую вы можете найти на моей странице Git Hub и в Maven Central . Не стесняйтесь использовать его, если вам нужно хранить объекты кэша на диске.

Ссылка: Расширение кэшей Guava для переполнения на диск от нашего партнера JCG Рафаэля Винтерхальтера в блоге My daily Java .