Лучшие практики написания кода Celesta

Следующие советы позволят избежать некоторых распространённых ошибок при написании кода, работающего с СУБД, с использованием Celesta, а также описывают ряд зарекомендовавших себя паттернов.

1. Явно закрывайте курсоры, создаваемые в цикле

Курсор — объект, заключающий в себе JDBC PreparedStatements и ResultSets, необходимые для выполнения методов курсора. Эти ресурсы необходимо закрывать после использования. Кроме того, ни сам курсор, ни объекты, составляющие его внутреннее состояние, не являются потокобезопасными, поэтому нельзя хранить курсор в виде разделяемого ресурса. Закрытие курсора, утеря ссылки на него и завершение транзакции — события, которые в норме должны происходить одновременно.

Время жизни курсора ограничено временем жизни CallContext, с помощью которого он был создан. Вызов метода close() на CallContext приводит к закрытию всех курсоров, созданных в этом контексте, поэтому обычно явно закрывать курсоры в коде не нужно.

Тем не менее, в ситуации, когда создание курсора может происходить в цикле, его следует явно закрывать.

CallContext не даст создать более 1023 курсоров (иначе это может привести к ошибке на стороне базы данных). Если курсор может создаваться в цикле, его следует закрывать (в следующем пункте код будет ещё улучшен):

ПЛОХО НОРМАЛЬНО
@CelestaTransaction
void doSomething(CallContext context) {
  for (int i = 0; i < 2000; i++)
    doSomethingElse(context);
}

void doSomethingElse(CallContext context) {
  //Exception: too many data accessors
  FooCursor foo = new FooCursor (context);
}
@CelestaTransaction
void doSomething(CallContext context) {
  for (int i = 0; i < 2000; i++)
    doSomethingElse(context);
}

void doSomethingElse(CallContext context) {
  try(FooCursor foo = new FooCursor (context)){
  /*no exception thrown, but
  we can do better!*/
  }
}

2. Избегайте создания курсора в цикле

Создание и закрытие курсора в цикле приводит к многократному созданию и закрытию JDBC PreparedStatements, что работает не эффективно. Лучшим решением является дизайн кода, при котором курсоры создаются в самом начале сервисного метода и затем переиспользуются:

НОРМАЛЬНО ПРАВИЛЬНО
@CelestaTransaction
void doSomething(CallContext context) {
  for (int i = 0; i < 2000; i++)
    doSomethingElse(context);
}

void doSomethingElse(CallContext context) {
  try(FooCursor foo = new FooCursor (context)){
  /*no exception thrown, but
  we can do better!*/
  }
}
@CelestaTransaction
void doSomething(CallContext context) {
  FooCursor foo = new FooCursor (context);
  for (int i = 0; i < 2000; i++)
   doSomethingElse(foo);
}
void doSomethingElse(FooCursor foo) {
  /*now we do not create-and-close
  the cursor in each iteration*/
}

3. Используйте ограничение количества полей в курсоре

Если в читаемой таблице много полей, но для работы метода нужны лишь некоторые из них — для ускорения работы следует воспользоваться ограничением выборки полей. Это способно ощутимо сократить объём данных, передаваемых из базы.

4. Методы, не обращающиеся к базе данных, работают быстро

Методы курсоров можно условно разделить на три категории:

Не обращающиеся к базе данных Производящие чтение Производящие обновление
  • установка/чтение полей

  • setRange

  • setFilter

  • setComplexFilter

  • setIn

  • limit

  • orderBy

  • reset

  • clear

  • [try]Get

  • [try]First

  • [try]Last

  • [try]FindSet

  • next

  • navigate

  • count

  • [try]Insert

  • [try]Update

  • delete

  • deleteAll

Вызов методов из первой категории не приводит к отправке никаких запросов к базе данных. Например, orderBy не сортирует записи в момент вызова, setRange не фильтрует записи в момент вызова — они лишь формируют состояние курсора, которое будет использовано при формировании SQL-запросов при вызове методов из второй и третьей категории.

Таким образом, правильным паттерном является максимальная «подготовка курсора» через выставление всех необходимых ограничений методами из первой категории перед тем, как запускать методы чтения.

5. Понимайте семантику метода get

Метод get(…​) извлекает из базы данных одну запись по её известному первичному ключу. Это гарантированно быстрая (за счёт обязательного наличия индекса на первичном ключе) и очень часто требующаяся на практике операция. Метод get(…​) не учитывает наличие каких-либо фильтров на курсоре, поэтому в редком случае, когда надо проверить, попадает ли запись с известным ключом в набор, определённый фильтрами на текущем курсоре, следует воспользоваться методом navigate("=").

6. Не производите лишних чтений перед удалением

Если нам известен первичный ключ записи, которую мы хотим удалить, не нужно перед этим вычитывать её из базы данных с помощью get или find. Методу delete() достаточно только того, чтобы поля первичного ключа были заполнены нужными значениями.

ПЛОХО ПРАВИЛЬНО
if (foo.tryGet(idForDeletion)) {
	foo.delete();
}
foo.setId(idForDeletion);
foo.delete();

7. Не используйте count() только для того, чтобы определить, что набор данных не пуст

Часто встречающаяся ошибка — проверять набор записей «на пустоту» через метод count(). Пересчитывать на стороне базы данных все записи для того, чтобы понять, что есть хотя бы одна — плохая идея.

ПЛОХО ПРАВИЛЬНО
if (fooCursor.count() > 0) {
	...
}
if (fooCursor.tryFirst()) {
	...
}

8. Пользуйтесь сортировкой на уровне базы данных для того, чтобы найти минимальные/максимальные значения

Если поиск одного значения можно выполнить в самой базе данных — это гораздо предпочтительнее, чем передавать весь набор записей в приложение, чтобы выполнять поиск средствами приложения:

ПЛОХО ПРАВИЛЬНО

Цикл по записям с целью поиска максимального значения поля bar.

foo.orderBy(foo.COLUMNS.bar().desc());
foo.first();

9. Используйте правильный тип фильтрации

В Celesta имеется четыре метода фильтрации данных, по мере роста сложности:

  1. setRange

  2. setFilter

  3. setComplexFilter

  4. setIn

Не используйте более сложный фильтр, если можно обойтись более простым!

Метод setRange закрывает большинство практических задач, когда поле необходимо фильтровать по единственному значению или диапазону значений:

  • cursor.setRange(cursor.COLUMNS.foo(), value) порождает запрос вида WHERE foo = value.

  • cursor.setRange(cursor.COLUMNS.foo(), from, to) порождает запрос вида WHERE foo BETWEEN from AND to

cursor.setRange(cursor.COLUMNS.foo()) (без параметров) убирает фильтр на поле “foo”, ранее заданный как с помощью setRange, так и с помощью setFilter.

Метод setFilter нужен для более редко встречающихся случаев, когда множество значений поля, по которым осуществляется фильтрация, необходимо задать сложным выражением. На одном поле может быть либо фильтр, заданный setRange, либо фильтр, заданный setFilter, поэтому вызов этих методов для одного и того же поля «вытесняет» заданный ранее фильтр.

Следует применять setRange там, где это возможно, т. к. в этом случае Celesta имеет возможность переиспользовать JDBC PreparedStatement-ы, что существенно улучшает быстродействие при работе в цикле:

ПЛОХО ПРАВИЛЬНО
FooCursor foo = new FooCursor(context);
BarCursor bar = new BarCursor(context);
for (FooCursor c: foo){
  bar.setFilter("baz", "’"+c.getBaz()+"’");
  /* PreparedStatements are
  re-created in each iteration :-(( */
  ...
}
FooCursor foo = new FooCursor(context);
BarCursor bar = new BarCursor(context);
for (FooCursor c: foo){
  bar.setRange(“baz”, c.getBaz());
  /* PreparedStatement is created
  only once and is being reused :-)*/
}

setRange предпочтительнее ещё и потому, что его API позволяет осуществлять контроль типов, передаваемых в качестве аргументов этого метода.

Метод setComplexFilter позволяет добавить «кусок SQL» в WHERE-выражение, задающее набор записей курсора. После любого вызова setComplexFilter все JDBC PreparedStatement-ы должны пересоздаваться заново, поэтому вызывать его в цикле, как и setFilter, неэффективно. Основное применение этого метода — для того, чтобы задавать условия между полями, например: a >= b. В остальных случаях подходят setRange/setFilter.

Метод setIn требуется в ситуациях, когда набор фильтруемых значений определяется динамически из других данных в базе.

10. Применяйте индексы

Общий момент, касающийся любой работы с реляционными СУБД. В случае с Celesta, простым первым приближением является следующее: если на поле курсора вызывается метод setRange — на данное поле следует создать индекс.

11. Эффективно кэшируйте значения при работе в циклах

Запрос данных в цикле — довольно часто встречающийся паттерн. В этом случае количество запросов к базе данных можно существенно сократить, а скорость увеличить, если применить самое простое кэширование: не надо вызывать get для курсора, если запрашиваемое значение первичного ключа уже соответствует тому, что было вызвано ранее:

//mind possible nulls!
if (!Objects.equals(bar.getId(), c.getBarId()))
		bar.get(c.getBarId());
//use newly fetched or cached bar here...

Эту идею можно развить в очень эффективный паттерн: за счёт сортировки по курсора c по полю barId можно свести количество обращений в базу данных за записями bar к минимуму:

ПЛОХО ПРАВИЛЬНО
FooCursor foo = new FooCursor(context);
BarCursor bar = new BarCursor(context);
for (FooCursor c: foo){
  bar.get(c.getBarId());
/*fetching data
  in each iteration :-(*/
}
FooCursor foo = new FooCursor(context);
/*note the orderBy!!!*/
foo.orderBy(foo.COLUMNS.barId());
BarCursor bar = new BarCursor(context);
for (FooCursor c: foo){
  if (!Objects.equals(bar.getId(), c.getBarId()))
	/*minimum number of fetches*/
	bar.get(c.getBarId());
}

12. Не используйте try-методы без необходимости

Многие методы курсора имеют два варианта: без приставки try и c приставкой try. С точки зрения быстродействия они работают одинаково, с точки зрения дизайна кода (fail-fast подход) предпочтительнее использовать методы без try, если вы не собираетесь использовать возвращаемое булевское значение. Этот момент разъяснён в документации.

13. В циклах используйте итерацию вместо навигации

Для перемещения по записям в курсоре существуют две группы методов: навигационные и итерационные.

К навигационным относятся tryFirst(), next(), previous() и navigate(…​), позволяющие переместиться на другую запись относительно текущей по определённым правилам.

К итерационным относятся пара методов tryFindSet() — nextInSet(), а также метод iterator(), реализующий соответствующий метод интерфейса java.lang.Iterable.

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

Итерационные методы отправляют запрос и открывают JDBC ResultSet, используемый в дальнейшем для перехода по записям.

ПЛОХО ПРАВИЛЬНО
if (foo.tryFirst()){
  do {
     ...
  } while (foo.next());
  /*new query in each iteration :-(*/
}
/*only one query! :-)*/
for (FooCursor c: foo) {
  ...
}