Сколько стоит фреймворк автоматизации #2
Вторая часть будет больше про работу с докером, инфрой, конфигами и подключение к Postgres.
Словарик
- POJO (Plain Old Java Object) — это простой Java-объект, не зависящий от специфических фреймворков и не расширяющий заранее заданные классы. Обычно он используется для упаковки и переноса данных.
- АТ - платформа автотестов, которая крутит тесты, что я разрабатываю
- ОТ - объект тестирования, или тестируемое приложение.
- JDBC - Java Data Base Connector - тулза для подключения к БД
Задача
Итак, когда была написана большая часть тестов, обнаружилась небольшая проблемка, рантайм в 34 минуты. Пу-пу-пу…
Немного диагностики — и понимаем, что 2/3 данного времени занимает очистка QA-стенда ОТ, а не прогон тестов. Сущность, которая под разными соусами создается 1.5к раз, удаляется с небольшой задержкой, ей нужно от 2 до 5 секунд. Это действие сильно увеличивает runtime.
читать первым в телеграм читать первым в макс
Как пофиксить?
- Снизить количество тестов, применить разные матрицы тестирования, хитрые сценарии и прочая теоретическая дребедень
- Сделать соседний стенд, который можно безбожно засирать и не чистить
- Лупить TRUNCATE напрямую в базу
Это все круто, но скучно. Вот бы делать много тестов, не чистить стенд и не думать.
Танцы с бубном
После долгих обсуждений и экспериментов было решено переносить все в Docker, а именно в Testcontainers.
Какие профиты:
- Можно развернуть тестируемое приложение, прогнать тесты и все выкинуть. То есть не надо чистить стенд.
- Тесты и стенд разворачиваются в CI/CD-раннере, QA-стенд свободен.
- Расширение бест-практис внутри компании. Одна платформа автотестов уже работает таким образом.
Что нужно сделать:
- Запихать ОТ в контейнер.
- Запихать вспомогательное приложение для ОТ в контейнер.
- Придумать, что делать с БД, ибо извне (через API) с ОТ можно сделать не все.
- Оптимизация под debian. ОТ просто не запустится, если не найдет нужные конфиги в
/etc, если у нужных директорий не будет правильного овнера и так далее по списку. - Лицензии… Лицензии часто привязаны к mac-адресу, hostname, ip, а хардкод сетевых параметров — это не то, что любит докер.
Альтернатива
В процессе работы над докером узнал об lxc. Думаю, в кейсе ОТ было бы правильнее развернуть инфру в lxc и управлять ей через SSH-клиент из АТ, но что сделано, то сделано…
Dockerfile
Для начала нужно просто запихнуть ОТ в контейнер. Желательно, чтобы образ не весил тонну. Если у вас full spring-boot-приложение или python-пет с лавандовым рафом, можно использовать spring-boot-maven-builder или другие тулзы, которые за пару строк кода соберут вам контейнер. Если вам надо имитировать что-то debian-подобное, добро пожаловать в мир боли.
Dockerfile, который у меня получился:
# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jre-jammy
ARG APP_SERVICE=app
ARG APP_USER=app-user
ARG APP_GROUP=app-group
ARG APP_INSTALL_DIR=/opt/${APP_SERVICE}
ENV APP_SERVICE=${APP_SERVICE} \
APP_USER=${APP_USER} \
APP_GROUP=${APP_GROUP} \
APP_INSTALL_DIR=${APP_INSTALL_DIR}
RUN addgroup --system ${APP_GROUP} && \
adduser --system \
--ingroup ${APP_GROUP} \
--no-create-home \
--disabled-login \
--shell /usr/sbin/nologin \
${APP_USER}
#юзер для sftp
RUN addgroup --system importer && \
adduser --system \
--ingroup importer \
--home /home/importer \
--disabled-password \
--shell /bin/bash \
importer && \
echo "importer:importer" | chpasswd && \
mkdir -p \
/home/importer/import/batch \
/home/importer/import/SUCCESS \
/home/importer/import/UNSUCCESS
RUN chown -R importer:importer /home/importer
#тулзы для sctp
RUN apt-get update && apt-get install -y \
lksctp-tools && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Копирование исполняемых скриптов
COPY --chown=${APP_USER}:${APP_GROUP} --chmod=755 app/src/main/resources/bin/* ${APP_INSTALL_DIR}/bin/
# Копирование библиотек и основного jar
COPY --chown=${APP_USER}:${APP_GROUP} app/target/lib/* ${APP_INSTALL_DIR}/lib/
COPY --chown=${APP_USER}:${APP_GROUP} app/target/app*.jar ${APP_INSTALL_DIR}/lib/
# Копирование миграций (сохраняем структуру oracle/postgres)
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/migrations/oracle/ ${APP_INSTALL_DIR}/config/migrations/oracle/
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/migrations/postgres/ ${APP_INSTALL_DIR}/config/migrations/postgres/
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/changelog.xml /etc/${APP_SERVICE}/
# Копирование конфигурационных файлов
COPY --chown=${APP_USER}:${APP_GROUP} \
app/src/main/resources/app.properties \
dao/src/main/resources/dao.properties \
app/src/main/resources/config/server.properties \
key-proxy-app/src/main/resources/keyproxy.properties \
app/src/main/resources/config/log4j2.xml \
app/src/main/resources/config/hazelcast.xml \
/etc/${APP_SERVICE}/
COPY --chown=${APP_USER}:${APP_GROUP} app/target/classes/etc/app/app.conf /etc/${APP_SERVICE}/
ENTRYPOINT ["/opt/app/bin/run.sh"]
Суть простая: после mvn install копируем в контейнер все jar, баш-скрипты, конфиги, заводим юзера, меняем владельца и прочее прочее. Как итог, получился образ на 253 мегабайта, 90 из них — образ убунты + jre, 163 — сам ОТ. Можно было заморочиться и сделать образ через alpine + jre и сэкономить еще 40 мб таким образом, но я не буду (может потом…).
Далее правим строчку в скрипте запуска с:
if [ "x$A1S_APP_START_LOG" = "x" ]; then
nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >/dev/null 2>&1 &
else
nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >>$A1S_APP_START_LOG 2>&1 &
fi
на:
#dockerchek
if [ -f "/.dockerenv" ]; then
IS_DOCKER=true
else
IS_DOCKER=false
fi
#If app in container we start app without nohup for 1 pid way
if $IS_DOCKER; then
chown -R $APP_USER:$APP_GROUP /etc/${APP_USER}
$JAVA $APP_OPTS -cp ".:$A1S_APP_HOME/lib/*$WARS" com.app.Application
elif [ "x$A1S_APP_START_LOG" = "x" ]; then
nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >/dev/null 2>&1 &
else
nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >>$A1S_APP_START_LOG 2>&1 &
fi
И готово, контейнер собран и готов стартовать. Тулзу для тестов пакуем в контейнер таким же образом (да-да, там те же приколы).
Testcontainers
Если кратко, Testcontainers на стадии test разворачивает контейнеры с помощью докер-демона, а как тесты закончатся — все чистит. Удобно, стильно, управление из кода, плюс реализован на многих ЯП, что добавляет актуальности.
Можно собрать инфру чисто в коде, можно развернуть из compose.yml. Думая сейчас, я бы скорее разворачивал из compose, т.к. выше вероятность, что добрый Дядя-Админ поймет его лучше, чем самодельную писанину + compose имеет больше фишек и функционала. Например, вы устанете искать информацию, как забиндить порт хоста и контейнера: в testcontainers это делается через перформанс с бубном, в compose-файле же легко и просто.
Не буду рассказывать пошагово, как и что включать, т.к. есть дока. Опишу наиболее частые или важные моменты.
Например, постгря создается так:
var postgres = new PostgreSQLContainer(DockerImageName.parse("postgres:16"));
Просто тянется из публичного Docker Hub. Можно и из локального registry:
var appContainer = new GenericContainer<>(DockerImageName.parse("you.registry/apps/you_app").withTag(BUILD_NUMBER));
Вот вы и скачали ранее собранный образ.
Важные детали при запуске контейнера в моем кейсе — сетевой хардкод, хоть и Малинин не одобрит. Надо прибить гвоздями хостнейм, ip, mac-адрес, чтобы лицензии не ломались при каждом запуске.
Выглядит примерно так:
private static Network getNetwork() {
var ipamConfig = new com.github.dockerjava.api.model.Network.Ipam.Config()
.withIpRange("172.19.0.0/24")
.withSubnet("172.19.0.0/16")
.withGateway("172.19.0.1");
var ipam = new com.github.dockerjava.api.model.Network.Ipam()
.withDriver("default")
.withConfig(ipamConfig);
return Network.builder()
.driver("bridge")
.createNetworkCmdModifier(createNetworkCmd ->
createNetworkCmd
.withIpam(ipam)
.withName("platform-network"))
.build();
}
//Прикручивается так
appContainer = new GenericContainer<>(...)
.withNetwork(getNetwork())
.withExposedPorts(port1, port2, port3)
.withCreateContainerCmdModifier(
createContainerCmd -> {
createContainerCmd
.withName("app")
.withHostName("host-name")
.withMacAddress("mac")
.withIpv4Address("ip")
;
}
)
Вот мы биндим кучу конфигов, заранее собранных в АТ (да-да, все своими лапками):
appContainer
.withClasspathResourceMapping("/etc/app/app.conf", "/etc/app/app.conf", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/hazelcast.xml", "/etc/app/hazelcast.xml", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/log4j2.xml", "/etc/app/log4j2.xml", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/app.properties", "/etc/app/app.properties", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/server.properties", "/etc/app/server.properties", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/dao.properties", "/etc/app/dao.properties", BindMode.READ_ONLY)
.withClasspathResourceMapping("/etc/app/public.key", "/etc/app/public.key", BindMode.READ_ONLY)
Указываем wait:
appContainer.waitingFor(Wait
.forLogMessage(".*Started AppApplication in.*", 1)
.withStartupTimeout(Duration.ofMinutes(1))
);
Ну и при запуске не забываем про depends и собираем путь до приложения в контейнере:
appContainer.dependsOn(postgres).
//и
var container_url = "http://" + appContainer.getHost() + ":" + appContainer.getMappedPort(port1) + "/path";
Testcontainers маппит все порты самостоятельно при каждом запуске, поэтому путь важно обновлять, имейте в виду.
БД
С подготовкой базы два варианта:
- Тянем дамп базы с QA-стенда, кладем его в контейнер с БД.
- Разворачиваем девственный контейнер с БД и заполняем его данными из кода.
По принципу “скучно” первый вариант отметается. Важно оговориться, что ОТ хранит в себе все миграции и прогоняет их при каждом запуске приложения. Это приятный момент, т.к. структура БД уже будет подготовлена.
С загрузкой данных напрямую в БД есть несколько вариантов:
- Написать ORM.
- В кейсе с Java — JDBI.
- В кейсе с Java — JDBC.
Первая попытка была с ORM, но в процессе работы с ним обнаружились недостатки:
- Долго.
- Дорого.
- Не совсем соответствует термину “тестирование”.
DAO, DTO, Batch, Criteria, коннект Hibernate к БД и довольно сильная зависимость от Spring Boot. Это все дорого, долго, громоздко, плюс главная суть всего этого — делать манипуляции с БД ПРАВИЛЬНО, а в тестировании нужно делать не всегда ПРАВИЛЬНО, иногда нужно шатать.
От JDBC меня сразу отговорил Андрюха. Слишком ванильно, слишком много мучений с raw-контекстами. Методом исключения был выбран JDBI. По опыту скажу, тулза великолепная.
- Быстрое развертывание.
- Отличная дока.
- Удобное использование.
- Интеграция с Testcontainers.
Приведу несколько примеров, как все собирал.
DTO
Собираем POJO по аналогии с таблицей, над которой хотим колдовать, для удобства использования:
@Data public class DtoExample {
@ColumnName("id")
private long id;
@ColumnName("param")
private Object param;
...
ЗЫ: Здесь начал экспериментировать с Lombok для генерации всяких геттеров/сеттеров.
Для удобства накидываем в аннотацию название колонки в таблице, чтобы не лазить в psql каждый раз.
DAO
В JDBI есть Declarative API, рекомендую ознакомиться. Во-первых, удобно. Во-вторых, очень красивый код.
public interface DaoExample {
@SqlUpdate(
"""
INSERT INTO example_table (
param1,
param2,
param3
) VALUES (
:param1,
:param2,
:param3
)
"""
)
@GetGeneratedKeys("id")
Long insert(@BindBean DtoExample example);
Что интересно:
- Мы получим id сгенерированной сущности в БД — @GetGeneratedKeys(“id”).
- Не обязательно указывать все параметры. Если мы укажем только 1 параметр, а остальные будут null, JDBI это срезолвит с помощью @BindBean. Это значит, можно написать один insert на все параметры, а не писать много разных.
- JDBI получает данные из бина через геттеры. Важно, чтобы они были.
Подключаем
Далее пилим DB Driver. В нашем кейсе крайне удобно, что данные можно взять из контейнера.
abstract public class PostgresDriver {
private static final Jdbi JDBI = Jdbi.create(
DB.getJdbcUrl(),
DB.getUsername(),
DB.getPassword()
)
.installPlugin(new PostgresPlugin())
.installPlugin(new SqlObjectPlugin());
public static Jdbi getDriver() {
return JDBI;
}
}
Наваливаем DAO в драйвер:
public static final DaoExample daoExample = JDBI.onDemand(DaoExample.class);
И делаем insert после инициализации нашей БД. Примерно так:
dtoExamplePojo.setId(daoExample.insert(dtoExamplePojo));
Ура, данные добавлены в БД, повторить нужное количество раз. Таким же образом можно делать select и прочие sql-команды, пользуйтесь.
Справедливости ради скажу, что большую часть времени при работе занимает не код, а работа с самими таблицами. Отслеживание взаимосвязей, добавление подсущностей, чтобы основная сущность работала корректно, и т.д. и т.п. Команда \d в psql использовалась сотню раз, если не больше.
Заключение
В принципе все, с данным набором наше приложение стартует -> живет -> умирает в докере. Какие бенефиты мы из этого получили:
- Рантайм теперь не 34 минуты, а 14. Можно дальше безбожно плодить тесты.
- Не занимается мануальный QA-стенд, а главное — не засоряется. Теперь он всегда свободен и чист.
- Помимо всего прочего, теперь каждый прогон — это тест на развертывание системы.
- Неограниченные возможности допила — сбор дампов, Kibana и прочие ребята.
- Бест-практис с другим проектом.
- Разработчики могут использовать контейнер в своих целях, хоть клиентам отдавать. Сам контейнер получился довольно гуд.
Затраты
Несмотря на то, что описанное могло показаться простым, этот этап занял тоже полгода, а если верить таск-трекеру — 340 часов :))) Если ваше приложение уже в контейнере или легко в него интегрируется(или хотя бы у него не 120 таблиц в postgres), естественно, этот этап займет куда меньше.
Профит
В этой части сложно выделить профит, т.к. доп. тесты не писались. Здесь, скорее, была подготовлена благодатная почва, чтобы расширить тесты, при этом сделать это максимально удобно. Я ожидаю, что такой сетап переварит около 7к тест-кейсов, что кратно расширит возможности регресса.
Роадмап
Планирую еще прикрутить:
- nicolaka/netshoot для дампов.
- Контейнер Kibana, чтобы удобно снифать ошибки из логов.
- Умный log consumer, который запилил Андрюха для контейнеров.
- Накатить многопоточку, когда рантайм опять вылезет за 30 минут.
Может, чуть позже обозрею.
Послесловие
Вот такой интересный кейс получился. Казалось бы, в сумме такой нехитрый фреймворк занял 520 часов разработки, экспериментов, слез, желаний уехать в дурку. Много это или мало — решайте сами.
Хочу выразить респект Андрюхе, которого не раз упоминал на протяжении статьи. Это мой Бадди, наставник, сенсей. @xepyk, большое спасибо за помощь!
