Статьи

JNDI и JPA без контейнера J2EE

Мы хотели протестировать код JPA с максимально простой настройкой. План состоял в том, чтобы использовать только Java и Maven без сервера приложений или другого контейнера J2EE.

Для успешной работы нашей конфигурации JPA необходимы две вещи:

  • база данных для хранения данных,
  • JNDI для доступа к базе данных.

Этот пост состоит из двух частей. Первая часть показывает, как использовать автономный JNDI и встроенную базу данных в памяти в тесте. Остальные главы объясняют, как работает решение.

Весь используемый код доступен на Github . Если вы заинтересованы в решении, но не хотите читать объяснения, загрузите проект с Github и прочитайте только первую главу.

JPA Test

В этой главе показано, как использовать наш код для включения в тесты автономной JNDI и встроенной базы данных в памяти. Как и почему работает решение, объясняется в оставшейся части этого поста.

Решение имеет три класса API:

  • JNDIUtil — инициализация JNDI, очистка и некоторые удобные методы,
  • InMemoryDBUtil — создание / удаление базы данных и источника данных,
  • AbstractTestCase — очистка базы данных перед первым тестом и JNDI перед каждым тестом.

Мы используем Liquibase для поддержки структуры базы данных. Если вы не хотите использовать Liquibase, вам придется настроить класс InMemoryDBUtil . createDatabaseStructure метод createDatabaseStructure чтобы сделать то, что вам нужно.

Liquibase хранит список всех необходимых изменений базы данных в файле с именем changelog. Если не указано иное, каждое изменение выполняется только один раз. Даже если файл журнала изменений применяется несколько раз к одной и той же базе данных.

использование

Любой тестовый пример, расширенный из AbstractTestCase будет:

  • сбросить базу данных перед первым тестом,
  • установить автономный JNDI или удалить все данные, хранящиеся в нем, перед каждым тестом,
  • запускать Liquibase changelog для базы данных перед каждым тестом.

Тестовый пример JPA должен расширять AbstractTestCase и переопределять метод getInitialChangeLog . Метод должен возвращать местоположение файла изменений.

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
public class DemoJPATest extends AbstractTestCase {
 
  private static final String CHANGELOG_LOCATION = "src/test/java/org/meri/jpa/simplest/db.changelog.xml";
  private static EntityManagerFactory factory;
 
  public DemoJPATest() {
  }
 
  @Override
  protected String getInitialChangeLog() {
    return CHANGELOG_LOCATION;
  }
 
  @Test
  @SuppressWarnings("unchecked")
  public void testJPA() {
    EntityManager em = factory.createEntityManager();
 
    Query query = em.createQuery("SELECT x FROM Person x");
    List<Person> allUsers = query.getResultList();
    em.close();
 
    assertFalse(allUsers.isEmpty());
  }
 
  @BeforeClass
  public static void createFactory() {
    factory = Persistence.createEntityManagerFactory("Simplest");
  }
 
  @AfterClass
  public static void closeFactory() {
    factory.close();
  }
 
}

Примечание: было бы чище сбросить базу данных перед каждым тестом. Однако удаление и воссоздание структуры БД является дорогостоящей операцией. Это слишком сильно замедлило бы тест. Делать это только перед классом кажется разумным компромиссом.

Хотя база данных удаляется только один раз, журнал изменений запускается перед каждым тестом. Это может показаться пустой тратой, но у этого решения есть некоторые преимущества. Во-первых, метод getInitialChangeLog не обязательно должен быть статическим и может быть переопределен в каждом тесте. Во-вторых, изменения, сконфигурированные для ‘runAlways’, будут выполняться перед каждым тестом и, таким образом, могут содержать некоторые дешевые очистки или другие инициализации.

JNDI

В этой главе объясняется, что такое JNDI, как он используется и как его настроить. Если вы не заинтересованы в теории, перейдите к следующей главе. Там создается автономный JNDI.

Основное использование

JNDI позволяет клиентам хранить и искать данные и объекты по имени. Доступ к хранилищу данных осуществляется через реализацию интерфейса Context .

Следующий код показывает, как хранить данные в JNDI:

1
2
3
Context ctx = new InitialContext();
ctx.bind("jndiName", "value");
ctx.close();

Второй фрагмент кода показывает, как искать вещи в JNDI:

1
2
3
Context ctx = new InitialContext();
Object result = ctx.lookup("jndiName");
ctx.close();

Попробуйте запустить вышеупомянутое без контейнера J2EE, и вы получите ошибку:

1
2
3
4
5
6
7
javax.naming.NoInitialContextException: Need to specify class name in environment or system property, or as an applet parameter, or in an application resource file:  java.naming.factory.initial
 at javax.naming.spi.NamingManager.getInitialContext(Unknown Source)
 at javax.naming.InitialContext.getDefaultInitCtx(Unknown Source)
 at javax.naming.InitialContext.getURLOrDefaultInitCtx(Unknown Source)
 at javax.naming.InitialContext.bind(Unknown Source)
 at org.meri.jpa.JNDITestCase.test(JNDITestCase.java:16)
 at ...

Код не работает, потому InitialContext класс InitialContext не является реальным хранилищем данных. Класс InitialContext может только найти другой экземпляр интерфейса Context и делегировать всю работу ему. Он не может ни хранить данные, ни находить их.

Фабрики контекста

Реальный контекст, который выполняет всю работу и может хранить / находить данные, должен быть создан фабрикой контекста. В этом разделе показано, как создать фабрику контекста и как настроить InitialContext для ее использования.

Каждая фабрика контекста должна реализовывать интерфейс InitialContextFactory и иметь конструктор без аргументов:

01
02
03
04
05
06
07
08
09
10
package org.meri.jpa.jndi;
 
public class MyContextFactory implements InitialContextFactory {
 
 @Override
 public Context getInitialContext(Hashtable environment) throws NamingException {
  return new MyContext();
 }
  
}

Наша фабрика возвращает простой контекст MyContext . Его метод lookup всегда возвращает строку «сохраненное значение»:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class MyContext implements Context {
 
 @Override
 public Object lookup(Name name) throws NamingException {
  return "stored value";
 }
 
 @Override
 public Object lookup(String name) throws NamingException {
  return "stored value";
 }
 
 .. the rest ...
}

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

Используйте свойство "java.naming.factory.initial" чтобы указать имя класса фабрики контекста. Свойство определено в константе Context.INITIAL_CONTEXT_FACTORY .

1
2
3
4
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "className");
 
Context ctx = new InitialContext(environnement);

Следующий тест настраивает MyContextFactory и проверяет, возвращает ли созданный начальный контекст «сохраненное значение» независимо от того, что:

01
02
03
04
05
06
07
08
09
10
11
12
@Test
@SuppressWarnings({ "unchecked", "rawtypes" })
public void testDummyContext() throws NamingException {
 Hashtable environnement = new Hashtable();
 environnement.put(Context.INITIAL_CONTEXT_FACTORY, "org.meri.jpa.jndi.MyContextFactory");
 
 Context ctx = new InitialContext(environnement);
 Object value = ctx.lookup("jndiName");
 ctx.close();
 
 assertEquals("stored value", value);
}

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

Менеджер по именам

Начальный контекст использует NamingManager для создания реального контекста. Менеджер именования имеет статический метод getInitialContext(Hashtable env) который возвращает экземпляр контекста. Параметр env содержит свойства конфигурации, используемые для построения контекста.

По умолчанию менеджер именования считывает Context.INITIAL_CONTEXT_FACTORY из хеш-таблицы env и создает экземпляр указанной исходной фабрики контекста. Затем фабричный метод создает новый экземпляр контекста. Если это свойство не задано, диспетчер имен создает исключение.

Можно настроить поведение менеджеров имен. Класс NamingManager имеет метод setInitialContextFactoryBuilder . Если установлен начальный конструктор фабрики контекста, менеджер имен будет использовать его для создания фабрики контекста.

Вы можете использовать этот метод только один раз. Установленный контекст фабричного компоновщика изменить нельзя.

1
2
3
4
5
6
try {
  MyContextFactoryBuilder builder = new MyContextFactoryBuilder();
  NamingManager.setInitialContextFactoryBuilder(builder);
} catch (NamingException e) {
  // handle exception
}

Начальный фабричный контекстный конструктор должен реализовывать интерфейс InitialContextFactoryBuilder . Интерфейс прост. У него есть только один метод InitialContextFactory createInitialContextFactory(Hashtable env) .

Резюме

Короче говоря, начальный контекст делегирует реальную инициализацию контекста менеджеру имен, который делегирует его фабрике контекста. Фабрика контекста создается экземпляром начального компоновщика фабрики контекста.


Автономный JNDI

Мы создадим и установим отдельную реализацию JNDI. Точкой входа в нашу автономную реализацию JNDI является класс JNDIUtil .

Для включения JNDI без сервера приложений необходимы три вещи:

  • реализация интерфейсов Context и InitialContextFactory ,
  • реализация интерфейса InitialContextFactoryBuilder ,
  • первоначальная установка фабрики контекста и возможность очистки всех хранимых данных.

Контекст и Фабрика

Мы взяли реализацию SimpleJNDI из проекта osjava и изменили ее, чтобы она лучше соответствовала нашим потребностям. В проекте используется новая лицензия BSD .

Добавьте SimpleJNDI maven-зависимость в pom.xml:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
simple-jndi
 
 
 
simple-jndi
 
 
 
0.11.4.1

SimpleJNDI поставляется с контекстом MemoryContext который живет исключительно в памяти. Он почти не требует настройки и его состояние никогда не сохраняется в загруженном состоянии. Это делает почти то, что нам нужно, кроме двух вещей:

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

Большинство библиотек предполагают, что метод close оптимизирует ресурсы. Они обычно называют это каждый раз, когда загружают или хранят данные. Если метод close удаляет все данные сразу после их сохранения, контекст становится бесполезным. Мы должны расширить класс MemoryContext и переопределить метод close :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
@SuppressWarnings({"rawtypes"})
public class CloseSafeMemoryContext extends MemoryContext {
 
 public CloseSafeMemoryContext(Hashtable env) {
  super(env);
 }
 
 @Override
 public void close() throws NamingException {
  // Original context lost all data on close();
  // That made it unusable for my tests.
 }
 
}

По соглашению система сборки / фабрики создает новый экземпляр контекста для каждого использования. Если они не обмениваются данными, JNDI нельзя использовать для передачи данных между разными библиотеками.

К счастью, эта проблема также имеет простое решение. Если хеш-таблица среды содержит свойство "org.osjava.sj.jndi.shared" со значением "true" , созданный контекст памяти будет использовать общее статическое хранилище. Поэтому наша первоначальная фабрика контекста создает экземпляры CloseSafeMemoryContext и настраивает их для использования общего хранилища:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public class CloseSafeMemoryContextFactory implements InitialContextFactory {
 
 private static final String SHARE_DATA_PROPERTY = "org.osjava.sj.jndi.shared";
 
 public Context getInitialContext(Hashtable environment) throws NamingException {
 
  // clone the environnement
  Hashtable sharingEnv = (Hashtable) environment.clone();
 
  // all instances will share stored data
  if (!sharingEnv.containsKey(SHARE_DATA_PROPERTY)) {
   sharingEnv.put(SHARE_DATA_PROPERTY, "true");
  }
   
  return new CloseSafeMemoryContext(sharingEnv);;
 }
 
}

Начальный контекст Factory Builder

Наш конструктор действует почти так же, как и оригинальная реализация менеджера имен. Если во входящей среде присутствует свойство Context.INITIAL_CONTEXT_FACTORY указанная фабрика.

Однако если это свойство отсутствует, построитель создает экземпляр CloseSafeMemoryContextFactory . Оригинальный менеджер именования выдает исключение.

Наша реализация интерфейса InitialContextFactoryBuilder :

01
02
03
04
05
06
07
08
09
10
11
12
public InitialContextFactory createInitialContextFactory(Hashtable env) throws NamingException {
 String requestedFactory = null;
 if (env!=null) {
  requestedFactory = (String) env.get(Context.INITIAL_CONTEXT_FACTORY);
 }
 
 if (requestedFactory != null) {
  return simulateBuilderlessNamingManager(requestedFactory);
 }
  
 return new CloseSafeMemoryContextFactory();
}

Метод simulateBuilderlessNamingManager использует загрузчик классов для загрузки запрошенной фабрики контекста:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
private InitialContextFactory simulateBuilderlessNamingManager(String requestedFactory) throws NoInitialContextException {
  try {
    ClassLoader cl = getContextClassLoader();
    Class requestedClass = Class.forName(className, true, cl);
    return (InitialContextFactory) requestedClass.newInstance();
  } catch (Exception e) {
    NoInitialContextException ne = new NoInitialContextException(...);
    ne.setRootCause(e);
    throw ne;
  }
}
 
private ClassLoader getContextClassLoader() {
 return (ClassLoader) AccessController.doPrivileged(new PrivilegedAction() {
  public Object run() {
   return Thread.currentThread().getContextClassLoader();
  }
 });
}

Установка Builder и очистка контекста

Наконец, мы должны установить конструктор контекста. Поскольку мы хотели использовать автономный JNDI в тестах, нам также требовался метод очистки всех хранимых данных между тестами. И то, и другое делается внутри метода initializeJNDI который будет запускаться перед каждым тестом:

01
02
03
04
05
06
07
08
09
10
11
public class JNDIUtil {
 
 public void initializeJNDI() {
  if (jndiInitialized()) {
   cleanAllInMemoryData();
  } else {
   installDefaultContextFactoryBuilder();
  }
 }
 
}

JNDI инициализируется, если уже был установлен конструктор фабрики контекста по умолчанию:

1
2
3
private boolean jndiInitialized() {
  return NamingManager.hasInitialContextFactoryBuilder();
 }

Установка стандартного контекстного фабричного компоновщика:

1
2
3
4
5
6
7
8
9
private void installDefaultContextFactoryBuilder() {
  try {
    NamingManager.setInitialContextFactoryBuilder(new ImMemoryDefaultContextFactoryBuilder());
  } catch (NamingException e) {
    //We can not solve the problem. We will let it go up without
    //having to declare the exception every time.
    throw new ConfigurationException(e);
  }
}

Используйте исходную реализацию метода close в классе MemoryContext для очистки хранимых данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
private void cleanAllInMemoryData() {
  CleanerContext cleaner = new CleanerContext();
  try {
      cleaner.close();
  } catch (NamingException e) {
    throw new RuntimeException("Memory context cleaning failed:", e);
  }
}
 
class CleanerContext extends MemoryContext {
  
  private static Hashtable environnement = new Hashtable();
  static {
    environnement.put("org.osjava.sj.jndi.shared", "true");
  }
 
  public CleanerContext() {
    super(environnement);
  }
 
}


База данных в памяти

Apache Derby — это реляционная база данных с открытым исходным кодом, реализованная на Java. Он доступен под лицензией Apache, версия 2.0. Дерби может работать во встроенном режиме. Данные встроенной базы данных хранятся либо в файловой системе, либо в памяти.

Maven зависимость для дерби:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
org.apache.derby
 
 
 
derby
 
 
 
10.8.2.2

Создать источник данных

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

Следующий код создает источник данных, указывающий на экземпляр базы данных в памяти. Если база данных еще не существует, она будет создана:

1
2
3
4
5
6
7
8
private EmbeddedDataSource createDataSource() {
 EmbeddedDataSource dataSource = new EmbeddedDataSource();
 dataSource.setDataSourceName(dataSourceJndiName);
 dataSource.setDatabaseName("memory:" + databaseName);
 dataSource.setCreateDatabase("create");
 
 return dataSource;
}

Удалить базу данных

Самый простой способ очистить базу данных — удалить и воссоздать ее. Создайте экземпляр встроенного источника данных, установите для атрибута соединения «drop» значение «true» и вызовите его метод getConnection . Это отбросит базу данных и выдаст исключение.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
private static final String DATABASE_NOT_FOUND = "XJ004";
 
 private void dropDatabase() {
  EmbeddedDataSource dataSource = createDataSource();
  dataSource.setCreateDatabase(null);
  dataSource.setConnectionAttributes("drop=true");
 
  try {
   //drop the database; not the nicest solution, but works
   dataSource.getConnection();
  } catch (SQLNonTransientConnectionException e) {
   //this is OK, database was dropped
  } catch (SQLException e) {
   if (DATABASE_NOT_FOUND.equals(e.getSQLState())) {
    //attempt to drop non-existend database
    //we will ignore this error
    return ;
   }
   throw new ConfigurationException("Could not drop database.", e);
  }
 }


Структура базы данных

Мы использовали Liquibase для создания структуры базы данных и тестирования данных. Структура базы данных хранится в так называемом файле журнала изменений. Это XML-файл, но вы можете включить DDL или SQL-код, если вам не хочется изучать еще один XML-язык.

Liquibase и его преимущества выходят за рамки этой статьи. Наиболее значимым преимуществом этой демонстрации является возможность многократного запуска одного и того же журнала изменений для одной и той же базы данных. Каждый прогон применяет только новые изменения в базе данных. Если файл не изменился, ничего не происходит.

Вы можете добавить журнал изменений в jar или war и запускать его при каждом запуске приложения. Это обеспечит постоянное обновление базы данных до последней версии. Нет необходимости в настройке или установке скриптов.

Добавьте зависимость Liquibase в pom.xml:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
org.liquibase
 
 
 
liquibase-core
 
 
 
2.0.3

Следующий журнал изменений создает одну таблицу с именем Person и помещает в нее одну запись ‘slash — Simon Worth’:

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
 
 <changeSet id="1" author="meri">
  <comment>Create table structure for users and shared items.</comment>
 
  <createTable tableName="person">
   <column name="user_id" type="integer">
    <constraints primaryKey="true" nullable="false" />
   </column>
   <column name="username" type="varchar(1500)">
    <constraints unique="true" nullable="false" />
   </column>
   <column name="firstname" type="varchar(1500)"/>
   <column name="lastname" type="varchar(1500)"/>
   <column name="homepage" type="varchar(1500)"/>
   <column name="about" type="varchar(1500)"/>
  </createTable>
 </changeSet>
 
 <changeSet id="2" author="meri" context="test">
  <comment>Add some test data.</comment>
  <insert tableName="person">
   <column name="user_id" valueNumeric="1" />
   <column name="userName" value="slash" />
   <column name="firstName" value="Simon" />
   <column name="lastName" value="Worth" />
   <column name="homePage" value="http://www.slash.blogs.net" />
   <column name="about" value="I like nature and writing my blog. The blog contains my opinions about everything." />
  </insert>
 </changeSet>
 
</databaseChangeLog>

Использование Liquibase довольно просто. Используйте источник данных для создания нового экземпляра Liquibase , запустите его метод update и обработайте все объявленные исключения:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
private void initializeDatabase(String changelogPath, DataSource dataSource) {
  try {
   //create new liquibase instance
   Connection sqlConnection = dataSource.getConnection();
   DatabaseConnection db = new DerbyConnection(sqlConnection);
   Liquibase liquibase = new Liquibase(changelogPath, new FileSystemResourceAccessor(), db);
 
   //update the database
   liquibase.update("test");
 
  } catch (SQLException e) {
   // We can not solve the problem. We will let it go up without
   // having to declare the exception every time.
   throw new ConfigurationException(DB_INITIALIZATION_ERROR, e);
  } catch (LiquibaseException e) {
   // We can not solve the problem. We will let it go up without
   // having to declare the exception every time.
   throw new ConfigurationException(DB_INITIALIZATION_ERROR, e);
  }
 }


Конец

Как автономный JNDI, так и встроенная база данных в памяти работают и работают каждый раз, когда мы запускаем наши тесты. Хотя настройка JNDI, вероятно, универсальна, для построения базы данных, вероятно, потребуются конкретные изменения проекта.

Не стесняйтесь загружать пример проекта с Github и использовать / изменять то, что вы считаете полезным.

Ссылка: Запуск JNDI и JPA без контейнера J2EE от нашего партнера по JCG Марии Юрковичовой в блоге This is Stuff .