Документация

- крохотная Scala-библиотека, которую я сделал, чтобы кешировать в памяти результаты Squeryl-запросов в моих веб-приложениях. Она не зависит ни от Squeryl, ни от чего ещё, и может использоваться для кеширования чего угодно.

Характеристики:

  • Написана на чистой Scala; без аннотаций, без аспектов.
  • Простой, компактный и строготипизированный синтаксис при использовании.
  • Не требует явных вызовов для startup/shutdown.
  • Не требует сериализуемости кешируемых данных; соответственно, нет поддержки кластеризации, и кешируемые объекты должны быть immutable.
  • Нет вытеснения устаревших данных; соответственно, нет опций типа maxLifetime.
  • Поддерживает реконфигурирование на лету, инвалидацию и сбор статистики для всех кешей сразу с помощью объекта CacheRegistry.
  • Потокобезопасная: все public-методы - synchronized.

Содержание этой страницы:

Как кешировать одиночные значения

В моих проектах обычно есть однострочная таблица config, хранящая всякие настройки. С помощью Squeryl, я маплю её следующим образом:

					package myapp.model
					import org.squeryl._

					// Первичный ключ здесь - просто ради удобства, пусть даже его значение всегда равно 1.
					case class Config (id: Int, ...) extends KeyedEntity[Int] {
						def this() = this(1, ...)
					}

					object T extends Schema {
						val config = table[Config]
					}
				

И затем обращаюсь к ней так:

					package myapp.dal
					import myapp.model._
					import org.squeryl.PrimitiveTypeMode._
					import ru.dimgel.lib.cache._

					object ConfigDAL {
						private val cache = new ValueCache[Config]

						def data = cache {
							// Здесь нужен inTransaction{}, т.к. настройки могут запрашиваться методом init()
							// веб-приложения вне контекста транзакции запроса.
							inTransaction { from(T.config)(t => select(t)).head }
						}

						def data_=(x: Config) {
							// Кроме вышеуказанного случая, я не использую inTransaction{},
							// потому что у меня метод service() веб-приложения всегда обёрнут в transaction{}.
							require(x.id == 1)
							T.config.update(x)

							// Мелкая оптимизация, во избежание лишнего SQL-запроса при следующем вызове getter-а:
							//cache.clear()
							cache.set(x)
						}
					}
				

Таким образом, вы создаёте экземпляр ValueCache[V] и оборачиваете логику запроса данных в вызов cache.apply(dataProvider: => V): V.

ЗАМЕЧАНИЕ: Если ваш код запроса данных выбрасывает исключение, оно пробрасывается в вызывающий код, состояние кеша при этом не меняется.

При изменении данных, вы должны очистить (invalidate) кеш вручную, вызвав cache.clear(); чтобы следующий вызов к getter-у выполнил вашу логику запроса данных. Или, в целях оптимизации, вы можете сразу записать в кешновые данные с помощью вызова cache.set(v: V); в этом случае следующий вызов getter-а вернёт эти данные без лишнего выполнения вашей логики запроса.

Я также часто использую ValueCache для кеширования списков объектов, когда я уверен, что списки небольшие, например:

					object NewsDAL {
						private val cache = new ValueCache[List[News]]

						def list = cache {
							from(T.news)(n => where(1 === 1) select(n) orderBy(n.whenCreated desc)).page(0, 10).toList
						}
					}
				

См. ниже как кешировать несколько объектов по ключу с помощью MapCache, и как ValueCache и MapCache могут использоваться совместно.

Как оно работает

Самая первая версия ValueCache вылгядела следующим образом:

					package ru.dimgel.lib.cache

					class ValueCache[V] {
						private var data_? : Option[V] = None

						def apply(valueProvider: => V): V = synchronized {
							if (data_?.isEmpty)
								data_? = Some(valueProvider)
							data_?.get
						}

						def set(v: V) { synchronized {
							data_? = Some(v)
						}}

						def clear() { synchronized {
							data_? = None
						}}
					}
				

Я думаю, объяснять тут нечего. Текущая версия поддерживает конфигурирование (см. ниже; для ValueCache это просто enabled/disabled), сбор статистики, глобальный реестр CacheRegistry, но суть осталась та же.

Как кешировать множества значений по ключу

Приведу пример. Предположим, у нас есть список стран, на который ссылается множество других таблиц. Будет гораздо более эффективно закешировать страны отдельно, вместо того чтобы join-ить их в огромное количество SQL-запросов.

Маппинги:

					package myapp.model
					import org.squeryl._

					case class Country(id: Int, name: String, ...) extends KeyedEntity[Int] {
						def this() = this(1, null, ...)
					}

					object T extends Schema {
						val country = table[Country]
					}
				

DAL:

					package myapp.dal
					import myapp.model._
					import org.squeryl.PrimitiveTypeMode._
					import ru.dimgel.lib.cache._

					object CountryDAL {
						// По умолчанию, нет ограничений на максимальное количество элементо в кеше.
						private val cache = new MapCache[Int, Country]

						def find(id: Int) =
							// Не кешируем отрицательные результаты, чтобы кеш не рос бесконечно.
							// Для этого, если страна с заданным кодом не существует,
							// мы выбрасываем исключение (None.get) и ловим его вовне вызова кеша.
							try {
								Some(cache(id, id => T.country.lookup(id).get))
							} catch {
								case e: NoSuchElementException => None
							}

						def get(id: Int) =
							find(id).get

						def updateCountry(x: Country) {
							require(x.id != 0)
							T.country.update(x)

							//cache.clear()
							//cache.remove(x.id)
							cache.set(x.id, x)
						}

						def insertCountry(x: Country) = {
							require(x.id == 0)

							// Терпеть не могу, что Squeryl инъектит id в _неизменяемый_ entity.
							val x2 = x.copy()
							T.country.insert(x2)
							assert(x2.id != 0)

							cache.set(x2.id, x2)

							x2
						}
					}
				

Идея та же, что и для ValueCache[V], но у MapCache[K,V] - два типопараметра (ключ и значение; внутри кеша данные хранятся в HashMap[K,V]), и метод apply() имеет более сложную сигнатуру: apply(k: K, dataProvider: K => V): V.

Однако имеется несколько тонкостей касательно использования. Посмотрите на метод find() в примере выше. Во-первых, отрицательные результаты не кешируются. Если вам нужно их кешировать, вы должны инстанциировать MapCache[Int, Option[Country]]. Во-вторых, если ваш код запроса данных выбрасывает исключение, оно прорбасывается в вызывающий код, и состояние кеша не меняется. Эти два поведения использованы таким образом, что метод find() возвращает тип Option[Country]: None если запрошенная страна не найдена, но это None не сохраняется в кеше.

См. ниже как конфигурировать максимальную ёмкость MapCache и как работает вытеснение.

При изменении Country, вы можете сбросить кеш полностью (что в данном случае глупо), инвалидировать только одну запись в кеше, или записать в кеш новый объект, тем самым избежав лишнего SQL-запроса когда этот объект будет запрошен. При добавлении новой записи также можно вызывать cache.set(K,V) too.

Соображения о независимых кешах вместо join таблиц

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

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

					package myapp.model
					case class Country(id: Int, ...) ...
					case class City(id: Int, countryId: Int, ...) ...
				
					package myapp.modelx
					import myapp.model
					case class CityX(city: City, country: Country)
				

Уродливо, но прямолинейно и просто. Кроме того, это позволяет объявлять различные варианты ModelX-классов для одного и того же entity-класса в зависимости от контекста использования. (Мне не нравится идея "частично заполненных объектов", содержащих данные только в тех полях, которые нужны для текущего сценария, т.к. в этом случае ни IDE, ни компилятор мне не подскажут, какие поля заполнены, а какие нет.)

Короче говоря, если в большинстве ваших сценариев требуется передавать Country вместе с City, кажется естественным закешировать CityX вместо City:

					package myapp.dal
					import ...

					object CityDAL {
						private val cache = new MapCache[Int, CityX]

						def find(id: Int) =
							try {
								Some(cache(id, id => {
									from(T.city, T.country)((ci,co) =>
										where(ci.id === id and co.id === ci.countryId)
										select(CityX(ci, co))
									).head
								}))
							} catch {
								case e: NoSuchElementException => None
							}
					}
				

Но здесь появляется вторая проблема: если вы измените какую-нибудь Country, вы будете должны сбросить/обновить не только соответствующий объект в of CountryDAL.cache, но также все объекты в CityDAL.cache (и в других кешах) которые на него ссылаются, в противном случае у вас очевидно будет рассогласование кешей.

В размышлениях на эту тему я пробовал добавлять методы ValueCache.clearIf(cond: V => Boolean) и MapCache.removeWhere(cond: (K,V) => Boolean) как возможное решение для тех, кто захочет вручную поддерживать согласованность кешей. Я имею в виду следующий сценарий:

					object CountryDAL {
						def updateCountry(x: Country) {
							...
							cache.set(x.id, id)
							CityDAL.countryChanged(x)
						}
					}
					object CityDAL {
						def countryChanged(x: Country) {
							cache.removeWhere((id,cityX) => cityX.city.countryId == x.id)
						}
					}
				

Но эта идея выглядит уродливой и опасной:

  • Связность и сложность. С какой стати CountryDAL должен знать о CityDAL? Ну, это можно решить с помощью паттерна Observer, но результат уже не назовёшь "простым и прозрачным" в любом случае. И ещё возможно проблемы с порядком инстанциирования объектов в Scala и циклическими зависимостями.
  • Поскольку все public-методы всех кешей - synchronized, я всегда боюсь deadlock-ов.
  • Масса дублирующихся данных в разных кешах.

Поэтому на данный момент я предпочитаю вместо обращения к cityX.country всюду вызывать CountryDAL.get(city.countryId). Я полагаю, в данном случае "больше кода" означает "меньше сложности". Если вы не согласны, или если у вас есть другие идеи на эту (или любую другую =)) тему, я буду благодарен, если вы поделитесь ими в GoogleGroups.

Настройки, политика вытеснения в MapCache

Опции настроек передаются как by-name параметры классов ValueCache и MapCache:

					class ValueCache[V] (enabled: => Boolean = true)
					class MapCache[K,V] (enabled: => Boolean = true, maxElements_? : => Option[Int] = None)
				

По умолчанию кеши включены, но могут быть отключены. При отключении их внутренние хранилища очищаются, методы apply() будут всегда делегировать к своим dataProvider-ам, и все методы записи в кеш (clear(), set(), remove() и т.п.) будут ничего не делать.

MapCache также имеет параметр maxElements_?. Значение по умолчанию None означает, что кеш может расти неограниченно. Если вы зададите Some(N), тогда N должно быть положительно, и количество элементов во внутреннем HashMap кеша никогда не привысит заданный лимит. Политика вытеснения простая: первыми выбрасываются элементы, к которым дольше всего не было обращений. Это делается эффективно, O(1), с помощью вспомогательного двусвязного списка (без дубликатов кешируемых данных).

Почему параметры кешей - by-name? Они применяются в момент инстанциирования объекта и повторно применяются когда вы вызываете метод кеша reloadConfig(). Вы можете даже хранить параметры кешей в базе (в полях объекта Config, см. пример использования ValueCache в начале статьи), предоставить админу HTML-форму для их редактирования и применять параметры кешей при сабмите формы. Просто объявите кеш как делаю я сам:

					object NotificationsDAL {
						private val byUserIdCache = new MapCache[Int, List[NotificationX]] (
							enabled = ConfigDAL.data.cache_notifications_isEnabled,
							maxElements_? = ConfigDAL.data.cache_notifications_maxElements
						)
					}
				

Повторюсь: доступ к параметрам выполняется только в двух случаях: при инстанциировании и когда вы вызываете метод кеша reloadConfig(). Но не при каждом обращении к кешу. Параметры вычисляются, их значения сохраняются во внутренние переменные (текущая эффективная конфигурация) и состояние кеша корректируется соответственно. Например, если вы отключите кеш, его внутреннее хранилище будет очищено; если вы уменьшите значение параметра maxElements_? для MapCache, лишние наиболее давно использовавшиеся элементы будут вытеснены в соответствии с новым ограничением.

CacheRegistry

И ValueCache, и MapCache наследуются из абстрактного класса Cache, объявляющего общие для них части API и регистрирущего свои экземпляры в в глобальном объекте CacheRegistry, который предоставляет вспомогательные методы для работы со всеми зарегистрированными кешами сразу:

  • reloadAllConfigs() вызывает reloadConfig() у всех зарегистрированных кешей (это то, что я вызываю при сабмите формы настроек, как я рассказал в предыдущем разделе);
  • clearAll() вызывает clear() у всех зарегистрированных кешей;
  • clearAllStatistics() вызывает clearStatistics() у всех зарегистрированных кешей;
  • getAllStatistics() вызывает getStatistics() у всех зарегистрированных кешей и возвращает их в неотсортированном списке (про статистику см. ниже).

CacheRegistry сохраняет ссылки на экземпляры кешей в WeakHashMap, таким образом не мешая их удалению сборщиком мусора.

ЗАМЕЧАНИЕ: У меня DAL - это Scala-объекты (синглтоны), они инстанциируются лениво (по запросу). Вы не можете работать с теми кешами, которые ещё не созданы (в моём случае, потому что они создаются внутри DAL которые ещё не инстанциированы).

ЗАМЕЧАНИЕ: Многие говорили мне, что глобальный реестр - плохая идея, и что мне следуюет вместо него сделать service provider во избежание смешивания кешей, объявленных в приложении, с кешами, пришедшими из используемых этим приложением библиотек. Но я сделал это намеренно: администратор сайта будет видеть и управлять всеми кешами в одном месте, независимо от того, откуда они пришли. Также, я не смог родить решения с service provider, не приводящего к значительному усложнению и утяжелению синтаксиса использования, а мне хотелось сохранить всё максимально простым.

CacheStatistics

Метод кешей getStatistics() возвращает экземпляр класса CacheStatistics, содержащего снимок текущей конфигурации кеша и его внутренних счётчиков статистики (детали см. в scaladoc). Я показываю эту статистику на HTML-странице, доступной админу сайта, вместе с кнопками, вызывающими разные методы CacheRegistry API.

CacheStatistics не содержит ссылки на экземпляр кеша, его создавшего. Вместо это он содержит описание кеша - description. По умлочанию, это просто имя класса - "MapCache" or "ValueCache". Рекомендуется переопределять описание кешей следующим образом:

					object NotificationDAL {

						private val byUserIdCache = new MapCache[Int, List[NotificationX]](...) {
							override protected val description = "NotificationDAL.byUserIdCache"
						}
					}
				

Обратите внимание, что description - это val, а не def.

Одновременное кеширование списка и его элементов по ключу

Такие вещи как страны, валюты и т.п. могут и запрашиваться по id, и выводиться в списках. Поэтому будет полезно согласованно кешировать и список, и by-id map. Класс CachedListAndMap[K,V] решает эту задачу. Он содержится в библиотеке, но я скопировал сюда его исходный код просто в качестве ещё одного примера использования:

					package ru.dimgel.lib.cache

					abstract class CachedListAndMap[K, V] {

						protected final class Data(val list: List[V], val map: Map[K,V])

						// Абстрактный, потому что пользователям может потребоваться конфигурировать экземпляры.
						protected val cache: ValueCache[Data]

						protected def queryList: Iterable[V]
						protected def getKey(v: V): Option[K]


						private def data = cache {
							val list = queryList.toList
							val map = Map() ++ list.map(v => (getKey(v) -> v)).filter(!_._1.isEmpty).map(t2 => (t2._1.get -> t2._2))
							new Data(list, map)
						}

						final def list = data.list

						final def find(k: K) = data.map.get(k)

						final def get(k: K) = data.map(k)

						final def clear() {
							cache.clear()
						}
					}