Статьи

Разработка сервисов с использованием Apache Camel. Часть II. Создание и тестирование маршрутов

Эта статья является второй в серии статей об Apache Camel и о том, как я использовал ее вместо IBM Message Broker для клиента. В первой статье «  Разработка сервисов с использованием Apache Camel. Часть I: Вдохновение» описывается, почему я выбрал Camel для этого проекта.

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

  1. Напишите интеграционный тест, указывающий на старый сервис.
  2. Напишите реализацию и модульный тест, чтобы доказать, что это работает.
  3. Напишите интеграционный тест, указывающий на новый сервис.

Я решил начать с замены самого простого сервиса. Это была служба SOAP, которая обращалась к базе данных для получения значения на основе входного параметра. Чтобы узнать больше о Camel и о том, как он работает, я начал с  примера CXF Tomcat . Я узнал, что Camel используется для обеспечения  маршрутизации  запросов. Используя его  компонент CXF , он может легко создавать конечные точки веб-службы SOAP. Конечная точка — это просто интерфейс, и Camel позаботится о создании реализации.

Legacy Integration Test

Я начал с написания  LegacyDrugServiceTests интеграционного теста для старой наркологической службы. Я попробовал два разных способа тестирования, используя сгенерированные WSDL классы Java, а также с помощью SOAP API JAX-WS. Найти WSDL для устаревшей службы было сложно, поскольку IBM Message Broker не раскрывает его при добавлении «? Wsdl» в URL-адрес службы. Вместо этого мне пришлось копаться в файлах проекта, пока я не нашел его. Затем я использовал  cxf-codegen-plugin  для генерации клиента веб-сервиса. Ниже показано, как выглядит один из тестов, использующий API-интерфейс JAX-WS.

@Test
public void sendGPIRequestUsingSoapApi() throws Exception {
    SOAPElement bodyChildOne = getBody(message).addChildElement("gpiRequest", "m");
    SOAPElement bodyChildTwo = bodyChildOne.addChildElement("args0", "m");
    bodyChildTwo.addChildElement("NDC", "ax22").addTextNode("54561237201");
    SOAPMessage reply = connection.call(message, getUrlWithTimeout(SERVICE_NAME));
    if (reply != null) {
        Iterator itr = reply.getSOAPBody().getChildElements();
        Map resultMap = TestUtils.getResults(itr);
        assertEquals("66100525123130", resultMap.get("GPI"));
    }
}

Внедрение наркологической службы

В прошлой статье я упоминал, что не хотел XML в проекте. Для этого я использовал Java DSL Camel   для определения маршрутов и JavaConfig  от Spring для настройки зависимостей.

Первым маршрутом, который я написал, был поиск GPI (общего идентификатора продукта) по NDC (Национальный кодекс по лекарственным средствам).

@WebService
public interface DrugService {
    @WebMethod(operationName = "gpiRequest")
    GpiResponse findGpiByNdc(GpiRequest request);
}

Чтобы представить это как конечную точку веб-службы с CXF, мне нужно было сделать две вещи:

  1. Скажите Spring, как настроить CXF, импортировав «classpath: META-INF / cxf / cxf.xml» в класс @Configuration.
  2. Настройте сервлет CXF, чтобы конечные точки могли обслуживаться по определенному URL-адресу.

Чтобы удовлетворить пункт № 1, я создал  CamelConfig класс, который расширяет  CamelConfiguration . Этот класс позволяет настраивать Camel с помощью SpringConfig. В нем я импортировал конфигурацию CXF, позволил динамически настраивать трассировку и выставил свой application.properties верблюд. Я также настроил (с  @ComponentScan), чтобы искать маршруты Camel, отмеченные  @Component.

@Configuration
@ImportResource("classpath:META-INF/cxf/cxf.xml")
@ComponentScan("com.raibledesigns.camel")
public class CamelConfig extends CamelConfiguration {
    @Value("${logging.trace.enabled}")
    private Boolean tracingEnabled;
 
    @Override
    protected void setupCamelContext(CamelContext camelContext) throws Exception {
        PropertiesComponent pc = new PropertiesComponent();
        pc.setLocation("classpath:application.properties");
        camelContext.addComponent("properties", pc);
        // see if trace logging is turned on
        if (tracingEnabled) {
            camelContext.setTracing(true);
        }
        super.setupCamelContext(camelContext);
    }
 
    @Bean
    public Tracer camelTracer() {
        Tracer tracer = new Tracer();
        tracer.setTraceExceptions(false);
        tracer.setTraceInterceptors(true);
        tracer.setLogName("com.raibledesigns.camel.trace");
        return tracer;
    }
}

CXF имеет сервлет, который отвечает за обслуживание своих служб по общему пути. Чтобы отобразить сервлет CXF, я использовал SpringApplicationInitializer  в  AppInitializer классе. Я решил обслужить все с  /api/* базового URL.

package com.raibledesigns.camel.config;
 
import org.apache.cxf.transport.servlet.CXFServlet;
import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.ContextLoaderListener;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
 
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRegistration;
 
public class AppInitializer implements WebApplicationInitializer {
 
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        servletContext.addListener(new ContextLoaderListener(getContext()));
        ServletRegistration.Dynamic servlet = servletContext.addServlet("CXFServlet", new CXFServlet());
        servlet.setLoadOnStartup(1);
        servlet.setAsyncSupported(true);
        servlet.addMapping("/api/*");
    }
 
    private AnnotationConfigWebApplicationContext getContext() {
        AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
        context.setConfigLocation("com.raibledesigns.camel.config");
        return context;
    }
}

Чтобы реализовать этот веб-сервис с Camel, я создал  DrugRoute класс, который расширяет Camel  RouteBuilder.

@Component
public class DrugRoute extends RouteBuilder {
    private String uri = "cxf:/drugs?serviceClass=" + DrugService.class.getName();
 
    @Override
    public void configure() throws Exception {
        from(uri)
            .recipientList(simple("direct:${header.operationName}"));
        from("direct:gpiRequest").routeId("gpiRequest")
            .process(new Processor() {
                public void process(Exchange exchange) throws Exception {
                    // get the ndc from the input
                    String ndc = exchange.getIn().getBody(GpiRequest.class).getNDC();
                    exchange.getOut().setBody(ndc);
                }
            })
            .to("sql:{{sql.selectGpi}}")
            .to("log:output")
            .process(new Processor() {
                public void process(Exchange exchange) throws Exception {
                    // get the gpi from the input
                    List<HashMap> data = (ArrayList<HashMap>) exchange.getIn().getBody();
                    DrugInfo drug = new DrugInfo();
                    if (data.size() > 0) {
                        drug = new DrugInfo(String.valueOf(data.get(0).get("GPI")));
                    }
                    GpiResponse response = new GpiResponse(drug);
                    exchange.getOut().setBody(response);
                }
            });
    }
}

sql.selectGpi Свойство читается  src/main/resources/application.properties и выглядит следующим образом :

sql.selectGpi=select GPI from drugs where ndc = #?dataSource=ds.drugs

Ссылка «ds.drugs» относится к источнику данных, который создан Spring. Из моего  AppConfig класса:

@Configuration
@PropertySource("classpath:application.properties")
public class AppConfig {
 
    @Value("${ds.driver.db2}")
    private String jdbcDriverDb2;
 
    @Value("${ds.password}")
    private String jdbcPassword;
 
    @Value("${ds.url}")
    private String jdbcUrl;
 
    @Value("${ds.username}")
    private String jdbcUsername;
 
    @Bean(name = "ds.drugs")
    public DataSource drugsDataSource() {
        return createDataSource(jdbcDriverDb2, jdbcUsername, jdbcPassword, jdbcUrl);
    }
 
    private BasicDataSource createDataSource(String driver, String username, String password, String url) {
        BasicDataSource ds = new BasicDataSource();
        ds.setDriverClassName(driver);
        ds.setUsername(username);
        ds.setPassword(password);
        ds.setUrl(url);
        ds.setMaxActive(100);
        ds.setMaxWait(1000);
        ds.setPoolPreparedStatements(true);
        return ds;
    }
}

Модульное тестирование

Самым сложным в модульном тестировании этого маршрута было выяснить, как использовать поддержку тестирования Camel  . Я отправил  вопрос  в список рассылки пользователей Camel в начале июня. Основываясь на полученных советах, я купил  Camel в действии , прочитал главу 6 о тестировании и пошел на работу. Я хотел устранить зависимость от источника данных, поэтому я использовал  функцию AdviceWith компании Camel,  чтобы изменить мой маршрут и перехватить вызов SQL. Это позволило мне вернуть заранее определенные результаты и убедиться, что все работает.

@RunWith(CamelSpringJUnit4ClassRunner.class)
@ContextConfiguration(loader = CamelSpringDelegatingTestContextLoader.class, classes = CamelConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@UseAdviceWith
public class DrugRouteTests {
 
    @Autowired
    CamelContext camelContext;
 
    @Produce
    ProducerTemplate template;
 
    @EndpointInject(uri = "mock:result")
    MockEndpoint result;
 
    static List<Map> results = new ArrayList<Map>() {{
        add(new HashMap<String, String>() {{
            put("GPI", "123456789");
        }});
    }};
 
    @Before
    public void before() throws Exception {
        camelContext.setTracing(true);
 
        ModelCamelContext context = (ModelCamelContext) camelContext;
        RouteDefinition route = context.getRouteDefinition("gpiRequest");
        route.adviceWith(context, new RouteBuilder() {
            @Override
            public void configure() throws Exception {
                interceptSendToEndpoint("sql:*").skipSendToOriginalEndpoint().process(new Processor() {
                    @Override
                    public void process(Exchange exchange) throws Exception {
                        exchange.getOut().setBody(results);
                    }
                });
            }
        });
        route.to(result);
        camelContext.start();
    }
 
    @Test
    public void testMockSQLEndpoint() throws Exception {
        result.expectedMessageCount(1);
        GpiResponse expectedResult = new GpiResponse(new DrugInfo("123456789"));
        result.allMessages().body().contains(expectedResult);
 
        GpiRequest request = new GpiRequest();
        request.setNDC("123");
        template.sendBody("direct:gpiRequest", request);
 
        MockEndpoint.assertIsSatisfied(camelContext);
    }
}

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

  1. Вызовите хранимую процедуру в удаленной базе данных, которая затем вставит запись во временную таблицу.
  2. Найдите эти данные, используя значение, возвращенное хранимой процедурой.
  3. Удалить запись из временной таблицы.
  4. Разбор данных (как CSV), поскольку возвращаемое значение разделено ~.
  5. Преобразуйте проанализированные данные в объекты, затем выполните вставку базы данных в локальную базу данных (если данные не существуют).

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

  • interceptSendToEndpoint("bean:*") перехватить вызов моего бина хранимой процедуры.
  • weaveById("myJdbcProcessor").before() заменить поиск временной таблицы на файл CSV.
  • Mockito,  чтобы издеваться над JdbcTemplate, который делает вставки.

Чтобы выяснить, как настроить и выполнить хранимые процедуры в маршруте, я использовал проект  camel-store-процедур на GitHub . Mockito в ArgumentCaptor  также стал очень полезным при разработке маршрута , который называется веб — службы третьей стороной в рамках маршрута. Джеймс Карр имеет  больше информации  о том, как вы можете использовать это для проверки значений аргумента.

Чтобы проверить, затрагивают ли мои тесты все аспекты кода, я интегрировал  модуль cobertura-maven-plugin  для отчетов о покрытии кода (генерируемых при запуске  mvn site).

<build>
    <plugins>
        <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>cobertura-maven-plugin</artifactId>
            <configuration>
                <instrumentation>
                    <excludes>
                        <exclude>**/model/*.class</exclude>
                        <exclude>**/AppInitializer.class</exclude>
                        <exclude>**/StoredProcedureBean.class</exclude>
                        <exclude>**/SoapActionInterceptor.class</exclude>
                    </excludes>
                </instrumentation>
                <check/>
            </configuration>
            <version>2.6</version>
        </plugin>
...
<reporting>
    <plugins>
        <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>cobertura-maven-plugin</artifactId>
            <version>2.6</version>
        </plugin>

Интеграционное тестирование

Написание интеграционного теста было довольно простым. Я создал  DrugRouteITest класс, клиент, использующий JaxWsProxyFactoryBean в CXF,   и вызвал метод службы.

public class DrugRouteITest {
 
    private static final String URL = "http://localhost:8080/api/drugs";
 
    protected static DrugService createCXFClient() {
        JaxWsProxyFactoryBean factory = new JaxWsProxyFactoryBean();
        factory.setBindingId("http://schemas.xmlsoap.org/wsdl/soap12/");
        factory.setServiceClass(DrugService.class);
        factory.setAddress(getTestUrl(URL));
        return (DrugService) factory.create();
    }
 
    @Test
    public void findGpiByNdc() throws Exception {
        // create input parameter
        GpiRequest input = new GpiRequest();
        input.setNDC("54561237201");
 
        // create the webservice client and send the request
        DrugService client = createCXFClient();
        GpiResponse response = client.findGpiByNdc(input);
 
        assertEquals("66100525123130", response.getDrugInfo().getGPI());
    }
}

Этот интеграционный тест запускается только после запуска Tomcat и развертывания приложения. Модульные тесты запускаются с помощью плагина Maven  surefire , а интеграционные тесты — с помощью  плагина failsafe . Доступный порт Tomcat определяется  модулем build-helper-maven-plugin . Этот порт устанавливается как системное свойство и читается с помощью  getTestUrl() вызова метода выше.

public static String getTestUrl(String url) {
    if (System.getProperty("tomcat.http.port") != null) {
        url = url.replace("8080", System.getProperty("tomcat.http.port"));
    }
    return url;
}

Ниже приведены соответствующие биты,  pom.xml которые определяют, когда запускать / останавливать Tomcat, а также какие тесты запускать.

<plugin>
    <groupId>org.apache.tomcat.maven</groupId>
    <artifactId>tomcat7-maven-plugin</artifactId>
    <version>2.2</version>
    <configuration>
        <path>/</path>
    </configuration>
    <executions>
        <execution>
            <id>start-tomcat</id>
            <phase>pre-integration-test</phase>
            <goals>
                <goal>run</goal>
            </goals>
            <configuration>
                <fork>true</fork>
                <port>${tomcat.http.port}</port>
            </configuration>
        </execution>
        <execution>
            <id>stop-tomcat</id>
            <phase>post-integration-test</phase>
            <goals>
                <goal>shutdown</goal>
            </goals>
        </execution>
    </executions>
</plugin>
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.17</version>
    <configuration>
        <excludes>
            <exclude>**/*IT*.java</exclude>
            <exclude>**/Legacy**.java</exclude>
        </excludes>
        <includes>
            <include>**/*Tests.java</include>
            <include>**/*Test.java</include>
        </includes>
    </configuration>
</plugin>
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <version>2.17</version>
    <configuration>
        <includes>
            <include>**/*IT*.java</include>
        </includes>
        <systemProperties>
            <tomcat.http.port>${tomcat.http.port}</tomcat.http.port>
        </systemProperties>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

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

  • @BindingType(SOAPBinding.SOAP12HTTP_BINDING)
  • @WebResult(name = "return", targetNamespace = "...")
  • @ResponseWrapper(localName = "gpiResponse")
  • @WebParam(name = "args0", targetNamespace = "...")
  • @XmlElement(name = "...")

Непрерывная интеграция и развертывание

Следующим моим бизнесом была настройка работы в  Дженкинсе  для постоянного тестирования и развертывания. Пройти все тесты было легко, а развертывание в Tomcat было достаточно простым благодаря  плагину Deploy  и  этой статье . Однако после нескольких развертываний Tomcat выдаст исключения OutOfMemory. Поэтому я закончил тем, что создал второе задание «развернуть», которое останавливает Tomcat, копирует успешно построенную WAR в $ CATALINA_HOME / webapps, удаляет $ CATALINA_HOME / webapps / ROOT и перезапускает Tomcat. Я использовал Jenkins «Execute shell» для настройки этих трех шагов. Я был рад обнаружить, что мой  /etc/init.d/tomcat скрипт все еще работает для запуска Tomcat во время загрузки и предоставляет удобные команды запуска / остановки.

Резюме

В этой статье показано, как я реализовал и протестировал простой маршрут Apache Camel. Описанный маршрут выполняет только простой поиск в базе данных, но вы можете увидеть, как поддержка тестирования Camel позволяет имитировать результаты и сосредоточиться на разработке логики маршрута. Я нашел его среду тестирования очень полезной и плохо документированной, так что, надеюсь, эта статья поможет это исправить. В следующей статье я расскажу об обновлении до Spring 4, интеграции Spring Boot и обсуждении развертывания микросервисов в нашей команде.