ООО «ЦИТ» corporate blog
IT systems testing
Scala
API
Web services testing
August 2016 31

Создаем заглушки сервисов для интеграционного тестирования на Apache Camel (с использованием Scala DSL)

Tutorial
image


Это третья статья об использовании Scala в тестировании. Сегодня будут рассмотрены примеры использования Apache Camel для создания тестовых заглушек, а также компонентов информационной системы.


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


Для разовой проверки интеграции мы бы использовали простое Java или Scala приложение, сценарий Apache JMeter или SoapUI. Но нам нужна система, которая постоянно работает, отвечает на запросы и не требует действий со стороны тестировщика — запустил и забыл. Для решения такой задачи мы можем создать приложение, основанное на фреймворке Apache Сamel.
Рассмотрим 5 примеров:


  1. Чтение файлов в одной кодировке, запись в другой;
  2. Запрос к веб сервису по расписанию и сохранение сообщения в хранилище данных;
  3. Реализация веб-сервиса, который возвращает сообщение в зависимости от параметра GET запроса;
  4. Чтение сообщения из очереди и отправка сообщения в БД;
  5. Пример маршрутизации по содержимому файла.

Кратко опишем инструменты, которые используются для решения задачи. Apache camel (http://camel.apache.org/) — Java фреймворк, предназначенный для реализации обмена сообщениями между отдельными приложениями, подсистемами информационной системы. Реализует подход к разработке связующего программного обеспечения Enterprise Integration Patterns (EIP). Позволяет работать с файлами, БД, менеджерами очередей, веб-сервисами, и другими компонентами — их более 240 видов на странице проекта component. В приложениие Camel описываются так называемые endpoints — конечные точки, и правила преобразования и маршрутизации сообщений между ними.


Компонент Camel реализует конечную точку. Это либо производитель сообщения (Producer), либо потребитель (Consumer). Некоторые компоненты могут реализовывать оба вида точек, к примеру, из файла можно получить сообщение и записать. Некоторые компоненты реализуют только производителя сообщения, например таймер, или потребителя, например вывод в лог.


В ходе работы приложения происходит манипулирование телом сообщения и его заголовками. Порядок работы с Camel следующий:


  1. Описываем источник сообщения (файл, очередь, БД, сервис, таймер и т.п.);
  2. Описываем правила преобразования данных и форматов;
  3. Описываем получателя (получателей) сообщения(файл, очередь, БД, сервис, вывод в консоль и т.п.) и логику маршрутизации;
  4. Запускаем приложение, которое слушает источник, и при появлении сообщения преобразует его и маршрутизирует до получателей.

Для описания правил маршрутизации и преобразования сообщений используются различные языки 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, в котором настроен уровень логгирования и формат сообщения.


<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

Иначе по умолчанию будет уровень 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 представлен на рисунке.


image


Пример 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(<uuid>{responseText}</uuid>)
      // Вариант отправки параметра как строки s"<uuid>$responseText</uuid>" тоже рабочий
    })
  }
}

Примеры запросов для проверки:
» 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.


Примеры ответов сервиса представлены на рисунке


image


Пример 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-сообщение, обработка которого зависит от значения элемента "То".


<To>ActiveMQ</To> — нужно отправить в очередь, а <To>H2</To> — обработать каким-то образом и отправить в БД, <To>someAdress</To> — обработать еще каким-то образом.


В сообщение будет добавлен заголовок "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 в нашей компании показал его эффективность для создания заглушек, компонентов интеграции а иногда и нагрузочных тестов.

+11
8.1k 43
Comments 3
Top of the day