Вступление
Всем привет. Меня зовут Ирек, и я в профессиональном IT с 2012 года. Прошел путь от специалиста службы поддержки до разработчика. На данный момент занимаюсь автоматизацией тестирования в компании РТК ИТ.
В статье хочу рассказать о своём опыте автоматизации тестирования websocket. О том какие грабли собрал и какой в итоге велосипед изобрёл.
На один из проектов разрабы завезли websoket и нужно было автоматизировать процесс тестирования. Бэк написан на Spring, фронт на React и оба они успешно используют библиотеку SockJS на которой и построена вся функциональность связанная с ws.
Автотесты мы пишем на нативной Java без использования Spring в отдельном от основного проекта репозитории.
Первое знакомство
Так получилось, что раньше эта тема меня обходила стороной. Поэтому для таких же как я постараюсь дать краткую вводную.
WebSocket — протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером, используя постоянное соединение.
Чуть подробнее вот тут.
STOMP (Simple Text Oriented Messaging Protocol) — текстово-ориентированный протокол, который может работать поверх websocket. Дело в том, что сам ws не определяет содержимое сообщений обмена и для согласованности принято использовать суб-протоколы.
Про STOMP понятно написано вот тут.
SockJS — это JavaScript библиотека, которая обеспечивает двусторонний междоменный канал связи между клиентом и сервером.
Про SockJS и как он работает в связке Spring, есть хорошая статья на хабре.
Как это выглядит в браузере
Открываем страничку, где используются ws.
Идем в DevTools или нажимаем F12, переходим во вкладку Network.
После ищем запрос со статусом 101.
Далее в самом запросе можно посмотреть на сообщения.
Ответы от сервера имеют определенные префиксы. На скрине они хорошо видны.
Из статьи приведенной выше мы узнаем, что для поддержания совместимости с Websocket Api SockJS использует кастомный протокол обмена сообщениями:
o — (open frame) отправляется каждый раз при открытии новой сессии.
c — (close frame) отправляется когда клиент запрашивает закрытие соединения.
h — (heartbeat frame) проверка доступности соединения.
a — (data frame) Массив json сообщений. К примеру: a[«message»].
SockJS для тестирования
Если у нас на бэке и на фронте используется SockJS, то логично поискать и для тестирования подобную библиотеку.
На страничке в Github SockJS мы узнаем, что для Java существует клиент внутри Spring Framework, Atmosphere Framework и некая библиотека vert.x.
Потратил кучу времени на vert.x, но так и не смог заставить ее работать.
Решение №1. Нативный
Мы построим свой SockJS с асинхронкой и тестировщицами.
Немного покопался и нашел замечательное видео с которого и начал погружаться в ws. Сначала повторил все вслед за спикером и тестовым примером, а потом пробовал адаптировать под себя. Получилось не сразу, но всё же заработало.
Описание класса клиента будет выглядеть следующим образом
import org.java_websocket.client.WebSocketClient; import org.java_websocket.drafts.Draft; import org.java_websocket.handshake.ServerHandshake; import java.net.URI; import java.nio.ByteBuffer; public class Client extends WebSocketClient { public Client(URI serverUri, Draft draft) { super(serverUri, draft); } public Client(URI serverURI) { super(serverURI); } @Override public void onOpen(ServerHandshake handshakedata) { System.out.println("new connection is opened"); } @Override public void onClose(int code, String reason, boolean remote) { System.out.println("closed with exit code " + code + " additional info: " + reason); } @Override public void onMessage(String message) { System.out.println("received <- " + message); } @Override public void onMessage(ByteBuffer message) { System.out.println("received ByteBuffer"); } @Override public void onError(Exception ex) { System.err.println("an error occurred:" + ex); } @Override public void send(String text) { System.out.println("send -> " + text); super.send(text); } }
Вариант использования будет таким
// Создаем экземпляр клиента WebSocketClient ws = new Client(new URI("wss://myhost.rt.ru/websocket/tracker/666/autotest/websocket")); // Подключаемся к хосту ws.connectBlocking(); // Отправляем сообщение о подключении ws.send("[\"CONNECT\\naccept-version:1.2,1.1,1.0\\nheart-beat:10000,10000\\n\\n\\u0000\"]"); sleep(2000); // Подписываемся на редактирование заголовка ws.send("[\"SUBSCRIBE\\nid:23051/title/edit\\ndestination:/topic/articles/23051/title/edit\\n\\n\\u0000\"]"); sleep(2000); // Отправляем сообщение с новым заголовком статьи ws.send("[\"SEND\\ndestination:/kernel/articles/23051/title/edit\\ncontent-length:22\\n\\n{\\\"title\\\":\\\"Hello Habr\\\"}\\u0000\"]"); sleep(2000);
Соответственно в выводе увидим следующее
new connection is opened send -> ["CONNECT\naccept-version:1.2,1.1,1.0\nheart-beat:10000,10000\n\n\u0000"] received <- o received <- a["CONNECTED\nversion:1.2\nheart-beat:0,0\n\n\u0000"] send -> ["SUBSCRIBE\nid:23051/title/edit\ndestination:/topic/articles/23051/title/edit\n\n\u0000"] send -> ["SEND\ndestination:/kernel/articles/23051/title/edit\ncontent-length:22\n\n{\"title\":\"Hello Habr\"}\u0000"] received <- a["MESSAGE\ndestination:/topic/articles/23051/title/edit\ncontent-type:application/json\nsubscription:23051/title/edit\nmessage-id:autotest-79\ncontent-length:22\n\n{\"title\":\"Hello Habr\"}\u0000"] received <- h
Немного про то откуда берется странная ссылка wss://myhost.rt.ru/websocket/tracker/666/autotest/websocket
Дело в том, что SockJs для формирования ссылки использует следующий шаблон wss://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
где
{server-id} — случайный параметр от 000 до 999, единственное назначение которого упростить балансировку на серверной стороне.
{session-id} — сопоставляет HTTP-запросы, принадлежащие сессии SockJs.
{transport} — указывает на транспортный протокол «websocket», «xhr-streaming», и т.д.
Поэтому в качестве server-id решили выбрать счастливое число 666, а в качестве session-id указали autotest вместо рандомного id, чтобы легче следить по логам.
Решение №2. Тащим Spring к себе в тесты
Сначала я очень сопротивлялся, но теперь думаю что зря.
Решение будет немного лаконичнее за счет еще одного уровня абстракции.
Для начала нужно определить класс управления сессией
import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; public class StompSessionHandler extends StompSessionHandlerAdapter { // Описываем действия с подключением к вебсокету @Override public void afterConnected(StompSession session, StompHeaders connectedHeaders) { // Выводим в консоль, что подключение есть System.out.println("new connection is opened"); } }
Далее описываем хендлер для STOMP фреймов
import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import java.util.Map; import java.util.Queue; public class CustomStompFrameHandler implements StompFrameHandler { Queue<Map<String, Object>> queue; public CustomStompFrameHandler(Queue<Map<String, Object>> queue) { this.queue = queue; } @Override public Type getPayloadType(StompHeaders headers) { // WS запрашивает у нас тип для Payload return Map.class; } @Override @SuppressWarnings("unchecked") public void handleFrame(StompHeaders headers, Object payload) { // Получен ответ от WS if (payload != null) { // Выведем ответ в консоль для отладки System.out.println("received <- " + payload); System.out.println(" headers:"); for (String key : headers.keySet()) { System.out.println(" " + key + ":" + headers.get(key)); } } if (payload instanceof Map) { queue.add((Map<String, Object>) payload); } } }
Теперь у нас есть всё, чтобы написать клиент
import org.json.JSONObject; import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; import org.springframework.web.socket.sockjs.client.SockJsClient; import org.springframework.web.socket.sockjs.client.WebSocketTransport; import java.util.Collections; import java.util.Map; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; public class Client { private static final String TOPIC = "/topic"; private static final String KERNEL = "/kernel"; private WebSocketStompClient stompClient; private StompSession session = null; private Queue<Map<String, Object>> queue = new ConcurrentLinkedQueue<>(); private String websocketURI; public Client(String websocketURI) { this.websocketURI = websocketURI; stompClient = new WebSocketStompClient(new SockJsClient( Collections.singletonList(new WebSocketTransport(new StandardWebSocketClient())))); stompClient.setMessageConverter(new MappingJackson2MessageConverter()); } public void connect() { try { session = stompClient.connectAsync( websocketURI, new StompSessionHandler()) .get(1, SECONDS); } catch (InterruptedException | ExecutionException | TimeoutException e) { throw new RuntimeException(e); } } public void disconnect() { if (session.isConnected()) { session.disconnect(); } } public void subscribe(String destination, String id) { if (!session.isConnected()) { connect(); } session.subscribe(destination.formatted(TOPIC, id), new CustomStompFrameHandler(queue)); } public void send(String destination, JSONObject json, String id) { if (!session.isConnected()) { connect(); } Map<String, Object> payload = json.toMap(); System.out.println("send -> " + payload); session.send(destination.formatted(KERNEL, id), payload); await().atMost(1, SECONDS) .untilAsserted(() -> assertThat(queue).contains(payload)); } }
Вариант использования будет таким
// Создаем экземпляр клиента Client ws = new Client("wss://myhost.rt.ru/websocket/tracker"); // Подключаемся к хосту ws.connect(); // Подписываемся на редактирование заголовка статьи ws.subscribe("%s/articles/%s/title/edit", "23051"); // Отправляем сообщение с новым заголовком статьи ws.send("%s/articles/%s/title/edit", new JSONObject().put("title", "Hello Habr"), "23051");
Вывод будет следующим
new connection is opened send -> {title=Hello Habr} received <- {title=Hello Habr} headers: destination:[/topic/articles/23051/title/edit] content-type:[application/json] subscription:[0] message-id:[6fe34d3531c14e3d8289168fcf0f6488-111] content-length:[22]
А если нужна нагрузка?
Для нагрузочных тестов использовал Gatling. Это было первое знакомство, поэтому мог нагородить лишнего. Если что поправьте в комментариях.
import io.gatling.http.Predef._ import io.gatling.core.Predef._ import io.gatling.core.structure.ChainBuilder object ArticleCase { val subscribeTitle = "[\"SUBSCRIBE\\nid:173920/title/edit\\ndestination:/topic/articles/173920/title/edit\\n\\n\\u0000\"]" val sendTextTitle = "[\"SEND\\ndestination:/kernel/articles/173920/block/edit\\ncontent-length:170\\n\\n{\\\"blockId\\\":\\\"a5fb646f-8386-42bc-8070-1d40d135fc02\\\",\\\"currentUser\\\":\\\"80bc7774-e00a-4455-85dd-527499c5012a\\\",\\\"payload\\\":{\\\"type\\\":\\\"ROOT\\\",\\\"title\\\":\\\"WebSocket Load Testing is WORK\\\"}}\\u0000\"]" val updateArticleTitle: ChainBuilder = exec( ws("Подключение к Websocket").connect("/websocket/tracker/666/autotest/websocket"), pause(2), ws("Подписка на события заголовка").sendText(subscribeTitle), pause(1), ws("Отправка сообщения с новым заголовком").sendText(sendTextTitle), pause(2), ws("Закрытие канала Websocket").close ) }
Подведем итоги
На этом всё. Надеюсь кто-то найдет себе что-то новое, а кто-то сэкономит немножко времени, когда столкнётся с подобным случаем на практике.
В целом работа с ws довольно приятная и интересная, особенно в череде однотипных задач по автоматизации rest api.
Еще немного полезных ссылок, если нужно чуть глубже погрузиться:
ссылка на оригинал статьи https://habr.com/ru/articles/842406/
Добавить комментарий