Мы живем в эпоху микросервисов. Одним из преимуществ микросервисной архитектуры является ускорение выхода на рынок. Разработка может быть разделена на более мелкие части, следовательно, может развиваться параллельно. В большинстве случаев связь между микросервисами осуществляется через REST API. При этом возникает вопрос, как эффективно разработать часть системы, которая требует связи с другой частью, которая не готова. И ответ заключается в использовании mock. В этом руководстве показано, как легко настроить локальный фиктивный сервер.

Создание сервера

Создайте проект Java с помощью Maven или Gradel. Я сделаю тебя Знатоком. Добавьте необходимую зависимость. Я буду использовать последнюю на данный момент версию 2.27.2. Создайте класс с основным методом и добавьте следующую конфигурацию.

package com.polovyi.ivan.tutorials;

import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

import com.github.tomakehurst.wiremock.WireMockServer;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class WiremockLocalMockServer {

    public static void main(String[] args) {
        log.info("Starting local mock server on port 8000");
        new WireMockServer(options()
                .usingFilesUnderClasspath("src/main/resources/wiremock")
                .port(8000)
        ).start();
    }
}

Этот фрагмент кода создает сервер WireMock, настраивает его для прослушивания порта 8000 и устанавливает исходный каталог файла. Это в основном все, что нам нужно настроить для запуска фиктивного сервера.

Моки из статических файлов

Самый простой способ создать макет — создать папку с именем __files и добавить в нее файлы.

Теперь мы можем выполнить запрос GET, используя URL:

http://<mock-server-host>:<port>/file-name.file-extension

Мы можем использовать браузер, чтобы сделать то же самое:

Сопоставления

Большую часть времени нам понадобятся моки, которые более продвинуты, чем мы можем получить из статических файлов. Для создания более продвинутых макетов мы можем использовать сопоставления. Сопоставление — это файл в формате JSON, расположенный в специальной папке с именем mappings, который описывает запрос и ответ. Это описание содержит URL-адрес запроса, метод запроса, текст запроса и ответа, заголовки и статус ответа. Например, если мы хотим имитировать конечную точку проверки работоспособности привода Spring Boot, мы можем использовать следующее сопоставление:

{
  "request": {
    "method": "GET",
    "url": "/actuator/health"
  },
  "response": {
    "status": 200,
    "body": "{\"status\":\"UP\"}",
    "headers": {
      "Content-Type": "application/json"
    }
  }
}

Мы даже можем создать сопоставление, просто выполнив запрос POST по следующему URL-адресу:

http://<mock-server-host>:<port>/__admin/mappings

с телом, содержащим такое отображение:

И после того, как мы можем вызвать этот макет:

Шаблон ответа

Бывают случаи, когда мы хотим вернуть внутри ответа какие-то данные, полученные в теле запроса, пути или заголовках. Например, выполняем запрос на получение клиента по id:

http://<mock-server-host>:<port>/customers/{id}

И мы хотим получить тело ответа, содержащее идентификатор клиента, переданный в качестве параметра пути. Для этого мы можем использовать Шаблоны рулей.

Мы создаем отображение со следующим содержанием:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/customers/([0-9]*)"
  },
  "response": {
    "transformers": ["response-template"],
    "status": 200,
    "jsonBody": {
      "id": "{{request.path.[1]}}",
      "customerName": "Customer Name",
      "paymentMethods": [
        "VISA",
        "MASTER"
      ]
    },
    "headers": {
      "Content-Type": "application/json"
    }
  }
}

Как вы уже можете видеть, это сопоставление немного отличается от предыдущего, которое мы использовали. Здесь у нас есть специальный параметр в объекте ответа, называемый трансформаторами. Внутри этого параметра мы должны указать имя класса преобразователя. Это особый тип класса, который расширяет ResponseDefinitionTransformer. Этот класс будет обрабатывать запрос для нас за сценой. Просто добавить имя этого класса в сопоставление недостаточно, мы должны зарегистрировать его на mock-сервере как расширение:

package com.polovyi.ivan.tutorials;

import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class WiremockLocalMockServer {

    public static void main(String[] args) {
        log.info("Starting local mock server on port 8000");
        new WireMockServer(options()
                .usingFilesUnderClasspath("src/main/resources/wiremock")
                .extensions(new ResponseTemplateTransformer(false))
                .port(8000)
        ).start();
    }
}

Подробнее о трансформаторах я расскажу в следующей главе.

Мы можем использовать urlPathPattern и применить регулярное выражение. Теперь с помощью регулярного выражения мы можем сопоставить все запросы, поступающие по пути customers, плюс числовое значение.

/customers/{id}

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

{{request.path.[1]}}

Мы можем думать о пути, который исходит от запроса, как показано ниже:

/customers/{id}
/request.path.[0]/request.path.[1]

Итак, теперь наш запрос стал более динамичным:

Подробнее о шаблонах ответов можно прочитать здесь.

Трансформеры

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

Перед созданием класса преобразователя мы должны создать файл сопоставления, который будет указывать на этот преобразователь.

{
  "request": {
    "method": "POST",
    "urlPath": "/customers"
  },
  "response": {
    "status": 201,
    "transformers": [
      "create-customer-transformer"
    ]
  }
}

Класс преобразования — это класс, который расширяет ResponseDefinitionTransformer и регистрируется на сервере как расширение. Этот класс должен реализовать 3 метода из родительского класса. Это следующие методы: transform, getName,и applyGlobally. Метод getName вернет имя преобразователя, и это имя должно совпадать с именем, которое мы поместили в файл сопоставления. Если метод applyGlobally возвращает true, то преобразователь применяется глобально, в противном случае он применяется только для этого API. В большинстве сценариев это ложно, и обычно, когда все ваши запросы возвращают один и тот же ответ независимо от URI, скорее всего, вы забываете переопределить этот метод, поэтому будьте осторожны. И последнее, но не менее важное — это метод transform, при котором вы фактически получаете запрос и возвращаете ответ после манипуляций.

Преобразователь, который мы создадим, имитирует поведение простого POST API, который будет получать запрос с двумя полями, одно из которых содержит имя клиента, а другое — список способов оплаты, таких как Visa, Master и т. д. Оба поля являются обязательными, следовательно, не может быть пустым.

Нам нужно выполнить еще одну проверку в поле способов оплаты. Это поле может содержать только определенные способы оплаты. Действительные методы хранятся внутри файла в каталоге __files. Метод преобразования получает в качестве параметра объект класса FileSource, который можно использовать для получения требуемого файла из __file каталог проекта и прочитать его. Файл с действительным способом оплаты имеет формат CSV и содержит следующие значения:

MASTER
VISA
DISCOVER
AMEX

Когда запрос проходит все проверки, метод преобразования объединяет путь запроса с UUID и возвращает его в заголовке ответа. Название заголовка — «местоположение». Это стандартная практика REST после того, как создание объекта возвращает адрес (местоположение) созданного объекта. Таким образом, его можно использовать для получения созданного объекта, вызвав метод GET для этого URL-адреса. Полный класс трансформатора приведен ниже:

package com.polovyi.ivan.tutorials.transformer;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
import com.github.tomakehurst.wiremock.common.FileSource;
import com.github.tomakehurst.wiremock.common.TextFile;
import com.github.tomakehurst.wiremock.extension.Parameters;
import com.github.tomakehurst.wiremock.extension.ResponseDefinitionTransformer;
import com.github.tomakehurst.wiremock.http.Request;
import com.github.tomakehurst.wiremock.http.ResponseDefinition;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;

public class CreateCustomerTransformer extends ResponseDefinitionTransformer {

    private final ObjectMapper mapper = new ObjectMapper();

    @SneakyThrows
    @Override
    public ResponseDefinition transform(Request request, ResponseDefinition responseDefinition, FileSource fileSource,
            Parameters parameters) {
        String bodyAsString = request.getBodyAsString();
        CreateCustomerRequest createCustomerRequest = mapper.readValue(bodyAsString, CreateCustomerRequest.class);

        Set<ErrorResponse> errorResponses = validateRequest(createCustomerRequest, fileSource);
        if (errorResponses.isEmpty()) {
            return ResponseDefinitionBuilder.responseDefinition()
                    .withHeader("location", request.getAbsoluteUrl() + "/" + UUID.randomUUID())
                    .build();
        } else {
            return ResponseDefinitionBuilder.jsonResponse(errorResponses);
        }
    }

    @Override
    public String getName() {
        return "create-customer-transformer";
    }

    @Override
    public boolean applyGlobally() {
        return false;
    }

    private Set<ErrorResponse> validateRequest(CreateCustomerRequest createCustomerRequest, FileSource fileSource) {

        Set<ErrorResponse> errors = new HashSet<>();
        if (StringUtils.isBlank(createCustomerRequest.getCustomerName())) {
            errors.add(new ErrorResponse("400.01", "Field customerName is empty"));
        }
        Set<String> paymentMethods = createCustomerRequest.getPaymentMethods();
        if (paymentMethods == null || paymentMethods.isEmpty()) {
            errors.add(new ErrorResponse("400.02", "Field paymentMethods is empty"));
        } else {
            TextFile textFile = fileSource.getTextFileNamed("payment-methods.csv");
            Set<String> validPaymentTypes = textFile.readContentsAsString().lines().collect(Collectors.toSet());
            if (!validPaymentTypes.containsAll(paymentMethods)) {
                errors.add(new ErrorResponse("400.03", "Field paymentMethods contains invalid values"));
            }
        }

        return errors;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    private static class CreateCustomerRequest {

        private String customerName;
        private Set<String> paymentMethods;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    private static class ErrorResponse {

        private String errorCode;
        private String errorMessage;
    }
}

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

Перезвонить

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

{
    "customerId": "12345",
    "amount": "100",
    "items": [
        "item1",
        "item2"
    ]
}

Приложению заказа требуется время для обработки этого запроса. Он принимает запрос и через некоторое время выполняет вызов API клиентского приложения и сообщает идентификатор заказа и идентификатор клиента.

У Wiremock есть способ имитировать асинхронную связь. Добавьте следующую зависимость в файл POM:

<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-webhooks-extension</artifactId>
    <version>2.35.0</version>
    <scope>test</scope>
</dependency>

Зарегистрируйте новое расширение из этой зависимости:

package com.polovyi.ivan.tutorials;

import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer;
import com.polovyi.ivan.tutorials.transformer.CreateCustomerTransformer;
import lombok.extern.slf4j.Slf4j;
import org.wiremock.webhooks.Webhooks;

@Slf4j
public class WiremockLocalMockServer {

    public static void main(String[] args) {
        log.info("Starting local mock server on port 8000");
        new WireMockServer(options()
                .usingFilesUnderClasspath("src/main/resources/wiremock")
                .extensions(new ResponseTemplateTransformer(false),
                        new CreateCustomerTransformer(),
                        new Webhooks()
                        )
                .port(8000)
        ).start();
    }
}

Определите макет, который получит обратный вызов, и сохраните файл в папке сопоставлений:

{
  "request": {
    "method": "POST",
    "url": "/notifications"
  },
  "response": {
    "status": 201,
    "body": "{\"ACCEPTED\"}",
    "headers": {
      "Content-Type": "application/json"
    }
  }
}

В реальной жизни вы будете использовать некоторые приложения, но для демонстрации я буду использовать макет. Создайте сопоставление для макета, который будет выполнять обратный вызов после получения запроса:

{
  "request": {
    "urlPath": "/order-callback",
    "method": "POST"
  },
  "response": {
    "status": 201
  },
  "postServeActions": [
    {
      "name": "webhook",
      "parameters": {
        "method": "POST",
        "url": "http://localhost:8000/notifications",
        "delay": {
          "type": "fixed",
          "milliseconds": 3000
        },
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "{ \"orderId\": \"1\", \"customerId\": \"{{jsonPath originalRequest.body '$.customerId'}}\" }"
      }
    }
  ]
}

Определение очень простое. Здесь появился новый раздел postServiceActions, где вы определяете имя расширения, которое мы зарегистрировали в начале главы. Здесь также определяются метод, URL и тело. Приятной особенностью является задержка, которую можно определить, с помощью этой функции вы можете контролировать время между запросом и обратным вызовом. Здесь мы можем использовать путь JSON, чтобы получить значения из запроса и вернуть их через обратный вызов.

Сделайте запрос и в логах увидите оба запроса:

Полный код вы можете найти ниже:



Я рекомендую вам запустить код самостоятельно. В проекте есть коллекция Postman, которую можно использовать для тестов.

Короткое демонстрационное видео:

Заключение

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

Спасибо за чтение! Пожалуйста, поставьте лайк и подпишитесь. Если у вас есть какие-либо вопросы или предложения, пожалуйста, не стесняйтесь писать в разделе комментариев или в моем аккаунте LinkedIn.

Станьте членом для полного доступа к контенту Medium.

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Хлопайте за историю и подписывайтесь на автора 👉
  • 📰 Смотрите больше контента в публикации Level Up Coding
  • 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
  • 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу