Создаем заглушки сервисов для интеграционного тестирования на Apache Camel (с использованием Scala DSL)
Это третья статья об использовании Scala в тестировании. Сегодня будут рассмотрены примеры использования Apache Camel для создания тестовых заглушек, а также компонентов информационной системы.
Часто возникает необходимость эмулировать работу какой-либо части системы для интеграционного тестирования, сделать заглушку или написать простой компонент интеграции. Это может быть веб-сервис, возвращающий нужные ответы, тест, наполняющий базу данных, приложение, которое считывает сообщение из очереди и возвращает результат обработки, генератор файлов и другие компоненты.
Для разовой проверки интеграции мы бы использовали простое Java или Scala приложение, сценарий Apache JMeter или SoapUI. Но нам нужна система, которая постоянно работает, отвечает на запросы и не требует действий со стороны тестировщика — запустил и забыл. Для решения такой задачи мы можем создать приложение, основанное на фреймворке Apache Сamel.
Рассмотрим 5 примеров:
- Чтение файлов в одной кодировке, запись в другой;
- Запрос к веб сервису по расписанию и сохранение сообщения в хранилище данных;
- Реализация веб-сервиса, который возвращает сообщение в зависимости от параметра GET запроса;
- Чтение сообщения из очереди и отправка сообщения в БД;
- Пример маршрутизации по содержимому файла.
Кратко опишем инструменты, которые используются для решения задачи. Apache camel (http://camel.apache.org/) — Java фреймворк, предназначенный для реализации обмена сообщениями между отдельными приложениями, подсистемами информационной системы. Реализует подход к разработке связующего программного обеспечения Enterprise Integration Patterns (EIP). Позволяет работать с файлами, БД, менеджерами очередей, веб-сервисами, и другими компонентами — их более 240 видов на странице проекта component. В приложениие Camel описываются так называемые endpoints — конечные точки, и правила преобразования и маршрутизации сообщений между ними.
Компонент Camel реализует конечную точку. Это либо производитель сообщения (Producer), либо потребитель (Consumer). Некоторые компоненты могут реализовывать оба вида точек, к примеру, из файла можно получить сообщение и записать. Некоторые компоненты реализуют только производителя сообщения, например таймер, или потребителя, например вывод в лог.
В ходе работы приложения происходит манипулирование телом сообщения и его заголовками. Порядок работы с Camel следующий:
- Описываем источник сообщения (файл, очередь, БД, сервис, таймер и т.п.);
- Описываем правила преобразования данных и форматов;
- Описываем получателя (получателей) сообщения (файл, очередь, БД, сервис, вывод в консоль и т.п.) и логику маршрутизации;
- Запускаем приложение, которое слушает источник, и при появлении сообщения преобразует его и маршрутизирует до получателей.
Для описания правил маршрутизации и преобразования сообщений используются различные языки languages. Для себя мы выбрали Scala DSL scala-dsl-eip, потому что этот язык хорошо подходит для простого и быстрого создания компонентного программного обеспечения. Для Scala используем систему сборки проекта SBT.
Существует отличный пример с чтением сообщения из файла и отправкой его http post запросом. Он немного устаревший, но может быть полезен.
» http://www.lightbend.com/activator/template/camel-http
» https://github.com/hilton/activator-camel-http#master
Подготовительные работы
Создадим проект в idea на основе SBT. Пример создания проекта можно подсмотреть — Реализация мониторинга и интеграционного тестирования информационной системы с использованием Scalatest. Часть1
В файле build.sbt пропишем настройки
name := "camel-scaladsl"
version := "1.0"
scalaVersion := "2.11.8"
val camelVersion = "2.17.1"
libraryDependencies ++= Seq(
// Компоненты для Camel
"org.apache.camel" % "camel-core" % camelVersion,
"org.apache.camel" % "camel-scala" % camelVersion,
// Для каждого компонента Camel своя зависимость
"org.apache.camel" % "camel-quartz" % camelVersion,
"org.apache.camel" % "camel-spring-redis" % camelVersion,
"org.apache.camel" % "camel-http" % camelVersion,
"org.apache.camel" % "camel-jetty" % camelVersion,
"org.apache.camel" % "camel-jms" % camelVersion,
"org.apache.camel" % "camel-jdbc" % camelVersion,
// Компоненты для логгирования
"ch.qos.logback" % "logback-classic" % "1.1.2",
"org.slf4j" % "slf4j-api" % "1.7.7",
// Компонент для работы xml в скала
"org.scala-lang.modules" % "scala-xml_2.11" % "1.0.5",
// Драйвер БД H2
"com.h2database" % "h2" % "1.4.192",
"org.apache.commons" % "commons-dbcp2" % "2.1.1",
// Драйвер для брокера activemq
"org.apache.activemq" % "activemq-client" % "5.13.3"
)
Добавим файл src/main/resources файл logback.xml, в котором настроен уровень логгирования и формат сообщения.
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
Иначе по умолчанию будет уровень DEBUG — выводится слишком много информации.
Пример 1
Чтение файлов в одной кодировке, запись в другой. Это простое приложение, которое использует компонент http://camel.apache.org/file2.html из пакета camel-core. Оно состоит из объекта, запускающего приложение FromFileToFileApp и класса FromFileToFileRoute, в котором описаны маршруты. Класс с маршрутами можно вынести в отдельный файл.
Содержимое файла src/main/scala/FromFileToFileApp.scala
import org.apache.camel.CamelContext
import org.apache.camel.main.Main
import org.apache.camel.scala.dsl.builder.{ScalaRouteBuilder, RouteBuilderSupport}
object FromFileToFileApp extends App with RouteBuilderSupport {
//Создаем Camel Main класс и контекст в нем
val mainApp = new Main
val context = mainApp.getOrCreateCamelContext
// Привязываем классы с маршрутами
mainApp.addRouteBuilder(new FromFileToFileRoute(context))
// Запускаем
mainApp.run
}
class FromFileToFileRoute(context: CamelContext) extends ScalaRouteBuilder(context) {
// Читаем содержимое файла в одной кодировке из папки "inbox"
"""file:inbox?charset=utf-8""" ==> {
// Пишем в другой кодировке в директорию "outbox"
to ("file:outbox?charset=Windows-1251")
}
}
В классе FromFileToFileRoute не происходит никаких преобразований с содержимым сообщения, отсутствует маршрутизация. После запуска приложения в папке проекта будут автоматически созданы папки «inbox», «outbox». При попадании в директорию «inbox», файл автоматически считывается — исчезает из папки. Затем он появляется в директории «outbox» в другой кодировке. При этом в папке «inbox» в отдельной подпапке будут храниться сообщения, прочитанные Сamel.
Пример 2
Запрос к веб-сервису по расписанию и сохранение сообщения в хранилище данных. В этом примере по таймеру будем собирать даные о курсе валют и отправлять в Redis. Для того, чтобы выпонить действия над сообщением (записать тело и заголовки), существует метод «process». Для Redis отправка значений производится с помощью пары заголовков «CamelRedis.Key»/«CamelRedis.Value». Нам необходимо извлечь тело сообщения, которое возвращает HTTP GET запрос и сделать его заголовком «CamelRedis.Value».
Ключ будем генерировать уникальный, подходящий для сортировки — текущее время в миллисекундах.
import org.apache.camel.{Exchange, CamelContext}
import org.apache.camel.main.Main
import org.apache.camel.scala.dsl.builder.{ScalaRouteBuilder, RouteBuilderSupport}
import org.springframework.data.redis.serializer.StringRedisSerializer
object FromHTTPToRedisApp extends App with RouteBuilderSupport{
val mainApp = new Main
// Прописываем вместо стандартного кастомный stringSerializer для Redis
mainApp.bind("stringSerializer",new StringRedisSerializer)
val context = mainApp.getOrCreateCamelContext
mainApp.addRouteBuilder(new FromHTTPToRedisRoute(context))
mainApp.run
}
class FromHTTPToRedisRoute (context: CamelContext) extends ScalaRouteBuilder(context) {
// По таймеру, раз в минуту обращаемся к HTTP сервису
"""quartz:timerName?cron=0+0/1+*+*+*+?""" ==> {
// Вывод информации в консоль
log("Запрос к сервису")
// Запрос к сервису
to("http://www.google.com/finance/info?q=CURRENCY%3aUSDRUB")
// Создание пары ключ-значение для Кedis, запись в заголовок
process((exchange: Exchange) => {
exchange.getOut.setHeader("CamelRedis.Key",System.currentTimeMillis())
exchange.getOut.setHeader("CamelRedis.Value",exchange.getIn.getBody(classOf[String]))
})
// Логгирование через отправку в конечную точку позволяет просмотреть сообщение и его атрибуты
// В данном случае тело сообщения будет пусто (Body: [Body is null]])
to("log:FromHTTPToRedisApp")
// Отправляем данные в Redis
// #stringSerializer - объявленный нами ранее кастомный сериалайзер
to("""spring-redis://172.16.7.58:6379?serializer=#stringSerializer""")
}
}
Чтобы писать в Redis с удаленного хоста, может понадобиться разрешение. К примеру, в консоли Redis на хосте, где он запущен, выполнить команду
CONFIG SET protected-mode no
Пример отображения записей в Redis представлен на рисунке.
Пример 3
Реализация веб-сервиса, который возвращает сообщение в зависимости от параметра GET запроса. В данном примере с помощью компонента Jetty реализуем простой HTTP сервер, который получает GET запрос с параметром и возвращает xml со значением параметра, либо с ошибкой.
object JettyApp extends App with RouteBuilderSupport{
val mainApp = new Main
val context = mainApp.getOrCreateCamelContext
mainApp.addRouteBuilder(new JettyRoute(context))
mainApp.run
}
class JettyRoute(context: CamelContext) extends ScalaRouteBuilder(context) {
// Определяем порт и адрес сервиса
"""jetty:http://0.0.0.0:1234/myapp/myservice""" ==> {
delay(2 seconds)
process((exchange: Exchange) => {
// Извлекаем значение параметра uuid из get запроса к сервису
val uuidParam = exchange.getIn.getHeader("uuid")
// Определяем паттерн для параметра
val pattern = """[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}""".r
// Возвращаем ответ в зависимости от извлеченного значения
// Возможен случай отсутсвия параметра, а также несоответствия значения шаблону uuid
def responseText = uuidParam match {
case null => "Uuid parameter not found"
case pattern() => s"$uuidParam"
case _ => s"Uuid parameter format is not valid"
}
// Определяем тип возвращаемого контента как xml
exchange.getOut().setHeader(Exchange.CONTENT_TYPE,"text/xml; charset=utf-8")
// Возвращаем xml с ответом.
exchange.getOut().setBody({responseText} )
// Вариант отправки параметра как строки s"$responseText " тоже рабочий
})
}
}
Примеры запросов для проверки:
» http://localhost:1234/myapp/myservice? uuid=2a577d52-e5a1–4da5–96e5-bdba1f68e6f1;
» http://localhost:1234/myapp/myservice? uuid=123;
» http://localhost:1234/myapp/myservice;
» http://localhost:1234/myapp/myservice? guid=2a577d52-e5a1–4da5–96e5-bdba1f68e6f.
Примеры ответов сервиса представлены на рисунке
Пример 4
Чтение сообщения из очереди и запись в БД. Работа с очередями и БД была выделена в отдельный пример. Настройка этих компонентов требует иного подхода. Если в предыдущих примерах настройка проводилась с помощью параметров в строке endpoint, то здесь нужно заранее создать объект, сделать на его основе компонент и использовать далее.
Для БД создаем экземпляр класса org.apache.commons.dbcp2.BasicDataSource и передаем ему параметры подключения. Для очереди создаем экземпляр класса javax.jms.ConnectionFactory, в котором также сохраняем параметры подключения. Далее для этих компонентов создается имя для конечной точки, и используется в URI. Разница в том, что для БД используется компонент «camel-jdbc», а для очередей создается новый компонент на основе «camel-jms».
Таблица, в которую происходит вставка записи в примерах, создается следующим запросом:
CREATE TABLE MESSAGETABLE(
ID UUID NOT NULL PRIMARY KEY,
DATETIME TIMESTAMP,
BODY VARCHAR(65536)
Следующий код будет забирать сообщения из очереди, выполнять в БД запрос на добавление уникального идентификатора, времени и тела сообщения.
import java.text.SimpleDateFormat
import java.util.{UUID, Date}
import org.apache.camel.component.jms.JmsComponent
import org.apache.camel.main.Main
import org.apache.camel.scala.dsl.builder.{RouteBuilderSupport, ScalaRouteBuilder}
import org.apache.camel.{CamelContext, Exchange}
// Для создания подключения к БД импортируем BasicDataSource
import org.apache.commons.dbcp2.BasicDataSource
// Для работы с месседж-брокером импортируем соответствующий ConnectionFactory класс
import org.apache.activemq.ActiveMQConnectionFactory
object FromMQToDBApp extends App with RouteBuilderSupport {
val mainApp = new Main
// Для работы с БД создаем объект и передаем ему свойства соединения
val ds = new BasicDataSource
ds.setDriverClassName("org.h2.Driver")
ds.setUrl("jdbc:h2:./h2db")
// Добавляем endpoint в приложение, далее в названии получателя будем использовать "h2db"
mainApp.bind("h2db",ds)
// Для работы с очередью создаем MQConnectionFactory
val cf = new ActiveMQConnectionFactory("tcp://192.168.3.38:61616")
// Создаем компонент для работы с очередью
mainApp.bind("amq-jms", JmsComponent.jmsComponentAutoAcknowledge(cf))
val context = mainApp.getOrCreateCamelContext
mainApp.addRouteBuilder(new FromMQToDBAppRoute(context))
mainApp.run
}
// Класс реализует чтение сообщения из очереди и запись его в БД
class FromMQToDBAppRoute(context: CamelContext) extends ScalaRouteBuilder(context) {
// Читаем сообщение из очереди. Компонент называется также, как мы его назвали ранее - "amq-jms", имя очереди передается как параметр
// Для каждого менеджера очередей необходимо создавать свой компонент
"""amq-jms:queue:TESTQ""" ==> {
process((exchange: Exchange) => {
// Генериуем uuid, дату/время
val uuid = UUID.randomUUID
val time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())
// Извлекаем тело сообщения
val messageBody = exchange.getIn.getBody(classOf[String])
// Формируем запрос с параметрами
exchange.getOut.setBody(s"INSERT INTO PUBLIC.MESSAGETABLE (ID, DATETIME, BODY) VALUES('$uuid', '$time', '$messageBody')")
})
// Отправляем подготовленный запрос в БД
// Компонент называется jdbc, далее указывается конкретный DataSource
to("jdbc:h2db")
}
}
Следует помнить о том, что при попытке записи в БД сообщения больше длины поля (у таблицы, созданной запросом ранее, длина поля — 65536 символов) — возникнет ошибка. Ее можно решить, обрезая тело до нужного размера, либо добавив errorHandler (deadLetterChannel («file: error»)), который будет отправлять сообщения, приводящие к ошибкам, в папку «error».
В примере рассмотрено взаимодействие с базой данных H2. Для других БД нужно добавить соответствующую библиотеку в build.sbt, определить имя класса драйвера, URL. Могут понадобиться другие свойства подключения, к примеру, имя пользователя и пароль.
Пример описания реквизитов подключения для работы с Postgresql:
Добавление библиотеки в build.sbt
libraryDependencies += "org.postgresql" % "postgresql" % "9.4.1207"
Реализация в классе:
val ds = new BasicDataSource {
setDriverClassName("org.postgresql.Driver")
setUrl(conf.getString("jdbc:postgresql://myhost:5432/mydb"))
setUsername(conf.getString("myusername"))
setPassword(conf.getString("mypassword"))
}
С очередями несколько сложнее. Для некоторых из менеджеров очередей, библиотеки не открыты для доступа в репозиториях. В этом случае, используются *.jar файлы, которые хранятся в папке lib проекта.
Для любого менеджера очередей нужно создать соответствующий объект типа connection factory.
К примеру, код, обеспечивающий взаимодействие с IBM Websphere MQ, будет таким:
val cf = new MQQueueConnectionFactory {
setHostName("myhost")
setPort(1414)
setTransportType(1)
setQueueManager("myqmname")
setChannel("mychannel")
}
Для Oracle Weblogic Jms еще интереснее. Если создать очереди по иструкции How to Create a Simple JMS Queue in Weblogic Server 11g, то объявление компонента будет таким:
val env = new util.Hashtable[String, String]
env.put(javax.naming.Context.INITIAL_CONTEXT_FACTORY, "weblogic.jndi.WLInitialContextFactory")
env.put(javax.naming.Context.PROVIDER_URL, "t3://myhost:7001")
val ic: InitialContext = new InitialContext(env)
val connectionFactory = ic.lookup("jms/TestConnectionFactory").asInstanceOf[QueueConnectionFactory]
// где jms/TestConnectionFactory - jndi для ConnectionFactory"
mainApp.bind("ora-jms", JmsComponent.jmsComponentAutoAcknowledge(connectionFactory))
а endpoint URI будет такого формата: «ora-jms: queue:./TestJMSModule! TestJMSQueue», где ./ обозначает текущий сервер, «TestJMSModule» JNDI имя модуля «TestJMSQueue» — JNDI имя очереди
Пример 5
Маршрутизация по содержимому файла. В данном примерерассмотрим маршрутизацию сообщения в зависимости от его содержимого.
Предположим, что на входе имеется xml-сообщение, обработка которого зависит от значения элемента «То».
— нужно отправить в очередь, а
— обработать каким-то образом и отправить в БД,
— обработать еще каким-то образом.
В сообщение будет добавлен заголовок «Destination» с именем конечной точки, в которую надо будет отправить сообщение.
Если возникнет ошибка при обработке сообщения или в таблице маршрутизации не будет соответствующего значения, то отправляем сообщение в «direct: trash».
В примере используется конструкция скала »???», которая позволяет заменить несуществующий блок кода для успешной компиляции. Вместо этого блока нужно написать логику обработки.
import org.apache.camel.{Exchange, CamelContext}
import org.apache.camel.scala.dsl.builder.ScalaRouteBuilder
import scala.xml.XML
class ContentOrientedRouting(context: CamelContext) extends ScalaRouteBuilder(context) {
// При ошибках обработки сообщения, отправляем его в "direct:trash"
errorHandler(deadLetterChannel("direct:trash"))
// Опишем таблицу маршрутизации в виде Map
val destMap = Map(
"ActiveMQ" -> "jms-amq:queue:inbox",
"H2" -> "direct:h2db",
"someAdress" -> "direct:outbox")
// Вынесем обработку в отдельную функцию
val addRoutingAction = (exchange: Exchange) => {
// Получим значение тега "To" из XML-файла, который пришел на вход
val body = exchange.getIn.getBody(classOf[String])
val xmlBody = XML.loadString(body)
val toValue = (xmlBody \\ "To").text
// Получим имя endpoint, если такого значения нет - отправляем в "direct:trash"
val dest = destMap.getOrElse(toValue,"direct:trash")
// Устанавливаем значение заголовка
exchange.getOut.setHeader("Destination", dest)
}
"""direct:inbox1""" ==> {
process(addRoutingAction)
// извлекаем из заголовка "Destination" endpoint и отправляем туда сообщение
recipients(_.in("Destination"))
}
// Описываем логику для разных endpoint
"""jms-amq:queue:inbox""" ==> {???}
"""direct:h2db""" ==> {
process((exchange: Exchange) => {???})
to ("jdbc:h2db")
}
"""direct:outbox""" ==> {
// Параллельная отправка сообщения в файл и в лог
to("file:someFile", "log:Somelog")
}
"""direct:trash""" ==> {???}
}
Примеры показывают, как можно реализовать небольшое приложение для наших целей. Рассмотрим дополнительные аспекты. позволяющие сделать разработку и обслуживание приложения удобнее.
Для конфигурации приложения используем бибилитеку от Typesafe, чтобы не зашивать параметры подключения в коде, а хранить в конфигурационном файле.
В build.sbt добавляем:
libraryDependencies += "com.typesafe" % "config" % "1.3.0"
в папке src/main/resources создаем файл application.conf, в котором прописываем настройки и вызываем их из кода.
Запуск приложения выполняется командой sbt run. В некоторых случаях это может быть неудобно.
Возможно создание jar-файла с помощью плагина sbt-assembly https://github.com/sbt/sbt-assembly для запуска командой java –jar camelapp.jar. В .jar-файле будут содержаться все зависимости, поэтому размер будет большой, но запуск происходит сразу, без скачивания компонентов.
Для запуска в фоне удобно использовать приложение nohup.
Создаем скрипт для запуска в папке, которая входит в переменную среды $PATH, чтобы вызывать по имени из любой директории. К примеру в /usr/local/bin/. Скрипт для запуска:
/usr/local/bin/camelstart
#!/bin/bash
/usr/bin/nohup java -jar /opt/camelapp.jar&
Для остановки:
/usr/local/bin/camelstop
#!/bin/bash
pkill -f camelapp
Запуск приложения делается командой camelstart, остановка — camelstop.
Можем выделить некоторые плюсы и минусы использования Apache Camel.
Плюсы:
- Быстрая реализация приложений;
- Большое количество готовых компонентов;
- Многопоточность, параллельная обработка сообщений из коробки;
- Возможность выбора способа описания в виде XML или одного из DSL;
Минусы:
- У каждого компонента своя логика работы, требуется время на понимание;
- Существует порог входа;
Кроме того, поскольку Apache Camel работает на JVM, приложениям, созданным на его основе, присущи плюсы и минусы этой платформы.
Опыт использования Apache Camel в связке со ScalaDSL в нашей компании показал его эффективность для создания заглушек, компонентов интеграции, а иногда и нагрузочных тестов.