Привет, Хабр! Продолжаем рассказывать, как быстро и просто создавать микросервисные приложения. В прошлой статье мы написали frontend с помощью Platform V DataSpace. В примере был использован TypeScript, но, как мы и говорили, это необязательное требование.

Теперь рассмотрим, как разрабатывать backend-приложения на языке Java с помощью сервиса Platform V Functions и инструмента DataSpace SDK.

Platform V Functions — это FaaS-решение, позволяющее загружать исходный код сервиса в виде функции в OpenShift/k8s без создания docker-образов и настройки окружения.

Но основное внимание в статье уделим даже не Functions, а DataSpace SDK. Это инструмент для удобного взаимодействия с DataSpace по протоколу JSON-RPC. По ходу статьи мы рассмотрим основные фичи, которые DataSpace SDK предоставляет Java-разработчику.

Приложение «Промоакция»

В качестве примера снова возьмём приложение «Промоакция» из предыдущей статьи.

Изменим архитектуру нашего приложения, разбив его на микросервисы. Теперь промокоды и подарки будут вестись раздельно разными сервисами, у каждого из которых будет свой DataSpace со своей моделью данных.

Архитектура приложения на этот раз будет выглядеть вот так:

Function 1 Vouchers — backend-сервис, отвечающий за ведение промокодов.

Function 2 Gifts — backend-сервис, отвечающий за ведение подарков.

Function 3 Report — backend-сервис, предоставляющий различные аналитические отчёты о подарках.

Разработка

Представим, что разработкой данного приложения занимаются два разработчика:

  • разработчик Vouchers реализует часть приложения, которая связана с управлением промокодами;

  • разработчик Gifts реализует часть приложения, которая связана с управлением подарками.

Для начала работы каждому разработчику нужно развернуть сервис DataSpace в своём пространстве в SmartMarket Studio. Подробнее о том, как это сделать, мы рассказывали здесь, в разделе «Работа» в SmartMarket Studio.

У каждого DataSpace будет своя модель данных:

Voucher и Gift теперь имеют связь OneToOne. Но тип этой связи «из внешней системы», так как они находятся в разных моделях данных.

Итак, сервисы DataSpace развёрнуты. Теперь создадим заготовки для наших сервисов.

Разработчик Vouchers создаёт в своём пространстве соответствующую функцию:

Разработчик Gifts создаёт в своём пространстве функции Gifts Function, Reports Function:

Теперь разработаем «начинки» для функций — это будут хорошо известные всем Spring Boot приложения.

Сервис Voucher

Переходим на вкладку «Детали» и скачиваем инструмент DataSpace SDK — он был сгенерирован после развёртывания сервиса DataSpace Vouchers.

Создадим проект со стандартной структурой. Для удобства можно взять за основу шаблонный проект в одной из наших функций-заготовок. Для этого в действиях выбираем пункт «Экспортировать»:

При этом добавим в src/libs jar, полученный из скачанного ранее архива.

Также нам потребуется java-sdk-core для подписи REST-запросов при помощи ak/sk. Скачиваем его по ссылке, достаём из архива и добавляем в src/libs нашего проекта.

В pom.xml проекта необходимо добавить следующие зависимости:

Данная зависимость содержит служебные классы, сгенерированные под нашу модель данных.
Это позволяет достичь строгой типизации при написании прикладного кода.
        <dependency>
            <groupId>sbp.com.sbt.dataspace</groupId>
            <artifactId>m7063364230573391874-model-sdk</artifactId>
            <version>0.0.1</version>
            <scope>system</scope>
            <systemPath>${project.basedir}/src/libs/m7063364230573391874-model-sdk-0.0.3.jar</systemPath>
        </dependency>
         
Зависимости необходимые для работы DataSpace SDK
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>
        <dependency>
            <groupId>io.projectreactor.netty</groupId>
            <artifactId>reactor-netty</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>26.0-jre</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-stub</artifactId>
            <version>1.39.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java</artifactId>
            <version>3.15.8</version>
        </dependency>
 
Зависимость нужна для осуществления подписи REST-запросов при помощи ak/sk
        <dependency>
            <groupId>sbp.ts.faas</groupId>
            <artifactId>java-sdk-core</artifactId>
            <version>3.1.2</version>
            <scope>system</scope>
            <systemPath>${project.basedir}/src/libs/java-sdk-core-3.1.2.jar</systemPath>
        </dependency>
 
Зависимость необходимая для работы java-sdk-core
        <dependency>
            <groupId>joda-time</groupId>
            <artifactId>joda-time</artifactId>
            <version>2.10.3</version>
        </dependency>

Сервис Vouchers будет предоставлять REST API, который принимает на вход промокод и тип подарка. В ответ он отдаёт сообщение с информацией о результате бронирования подарка.

Определим API в нашем контроллере:

@RestController
public class VouchersController {
    @Autowired
    private VouchersService vouchersService;
 
    @RequestMapping(value = "/getGiftByPromoCode")
    public ResponseEntity<String> getGiftByPromoCode(@RequestParam String voucherCode, @RequestParam String giftKind) {
        return ResponseEntity.ok()
                .contentType(MediaType.TEXT_PLAIN)
                .body(vouchersService.getGift(voucherCode, giftKind));
    }
}

Перейдём к конфигурации. Определим инстансы DataspaceCorePacketClient и DataspaceCoreSearchClient. Нам понадобится адрес сервиса DataSpace и ak/sk для авторизации на API gateway. Все эти значения мы получаем из соответствующих инфраструктурных переменных DATASPACE_URL, APP_KEY, APP_SECRET.

Также нам потребуется RestTemplate для осуществления вызовов к сервису Gifts:

@Configuration
public class Config {
 
    @Value("${DATASPACE_URL}")
    private String dataSpaceUrl;
    @Value("${APP_KEY}")
    private String appKey;
    @Value("${APP_SECRET}")
    private String appSecret;
 
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
 
    @Bean
    public DataspaceCoreSearchClient searchClient() {
        return new DataspaceCoreSearchClient(dataSpaceUrl,
                DataspaceSdkApiClientConfiguration.of(builder ->
                        builder
                                .setApiGatewayConfiguration(AKSKApiGatewayConfiguration.of(appKey, appSecret))
                )
        );
    }
 
    @Bean
    public DataspaceCorePacketClient packetClient() {
        return new DataspaceCorePacketClient(dataSpaceUrl,
                DataspaceSdkApiClientConfiguration.of(builder ->
                        builder
                                .setApiGatewayConfiguration(AKSKApiGatewayConfiguration.of(appKey, appSecret))
                )
        );
    }

Также нам понадобятся:

  • адрес проекта;

  • appKey;

  • appSecret.

Найти эти значения можно в настройках проекта:

В конфигурационном файле config.yaml определим необходимые настройки:

gifts.url: https://gw-ift-sm.pv-api-test.sbc.space/fn_fa969687_4694_4b3e_a871_5g42q56he710
gifts.appKey: d9ad1de7d38f493793c407061dc1111e
gifts.appSecret: a418e8315cf0222fbf4784811fe3dc8a

Перейдём к реализации VoucherService.

Алгоритм заказа подарка по промокоду будет выглядеть так:

  1. Запрос клиента поступает с фронта в сервис Vouchers, который выполняет валидацию промокода.

  2. Если валидация прошла успешно, сервис Vouchers вызывает сервис Gifts по REST.

  3. Сервис Gifts должен найти подходящий подарок и забронировать его либо вернуть ответ, содержащий информацию о том, что доступные подарки отсутствуют.

  4. Сервис Vouchers получает идентификатор подарка, привязывает его к промокоду и отправляет ответ с серийным номером подарка и наименованием компании клиенту.

  5. Если подарок не был найден, сервис отправляет соответствующий ответ клиенту:

    public String getGiftByPromoCode(String code,
                                     String giftKind) {
        try {
            String voucherId = verifyPromoCode(code);
 
            JsonNode giftResponse = getGift(giftKind, voucherId);
 
            JsonNode error = giftResponse.get("error");
            if (error != null) {
                return error.textValue();
            }
 
            updateVoucher(voucherId, giftResponse.get("giftId").textValue());
            return "You have been given a gift from " + giftResponse.get("vendor") + ". Serial number: " + giftResponse.get("serialNumber");
        } catch (Exception e) {
            LOG.error(e.getMessage());
            return e.getMessage();
        }
    }

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

Рассмотрим метод verifyPromoCode:

    public String verifyPromoCode(String code) throws SdkJsonRpcClientException {
        try {
            VoucherGet voucher = searchClient.getVoucher(voucherWith ->
                    voucherWith
                            .withCode()
                            .withStatusForVoucherMain(StatusWithLinkable::withCode)
                            .withGift()
 
                            .setWhere(where -> where.codeEq(code)));
 
            if (voucher.getGift().getEntityId() != null ||
                    !voucher.getStatusForVoucherMain().getCode().equals(VoucherVoucherMainStatus.OPEN.getValue())) {
                throw new GiftAlreadyIssuedException(code);
            }
 
            return voucher.getObjectId();
        } catch (ObjectNotFoundException objectNotFoundException) {
            throw new VoucherNotFoundException(code);
        }
    }

Метод DataspaceCoreSearchClient#getVoucher из состава DataSpace SDK позволяет построить в типизированном формате запрос к сервису DataSpace Vouchers.

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

Get-метод предполагает возникновение ObjectNotFoundException в случае, если по запросу ничего не нашлось.

Далее нужно убедиться, что у запрашиваемого промокода нет ссылки на уже полученный подарок, а статус — «ОТКРЫТ». В противном случае отправляем сообщение о том, что данный промокод уже был использован.

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

В методе getGift вызовем сервис Gifts по REST. При этом подпишем наш запрос при помощи ключей ak/sk для корректной авторизации на ApiGateway:

    private JsonNode getGift(String giftKind, String voucherId) throws Exception {
        final String GET_GIFT_URL = giftsFunctionUrl + GET_GIFT_ENDPOINT;
 
        Request request = new Request();
        request.setMethod("GET");
        request.setBody("");
        request.setKey(appKey);
        request.setSecret(appSecret);
        request.setUrl(GET_GIFT_URL);
        request.addQueryStringParam("voucherId", voucherId);
        request.addQueryStringParam("giftKind", giftKind);
        new Signer().sign(request);
 
        HttpHeaders requestHeaders = new HttpHeaders();
        request.getHeaders().forEach((k, v) -> requestHeaders.put(k, Collections.singletonList(v)));
 
        String urlTemplate = UriComponentsBuilder.fromHttpUrl(GET_GIFT_URL)
                .queryParam("voucherId", "{voucherId}")
                .queryParam("giftKind", "{giftKind}")
                .encode()
                .toUriString();
 
        Map<String, String> params = new HashMap<>();
        params.put("voucherId", voucherId);
        params.put("giftKind", giftKind);
 
        ResponseEntity<JsonNode> response = restTemplate.exchange(
                urlTemplate, HttpMethod.GET, new HttpEntity<>(requestHeaders), JsonNode.class, params);
 
        return response.getBody();
    }

В ответ получаем ошибку, которую пробрасываем на фронт, или атрибуты забронированного подарка.

Если мы получили положительный ответ от Gifts, нужно отметить, что обрабатываемый промокод использован и за ним закреплён подарок.

Рассмотрим метод updateVoucher:

    public void updateVoucher(String voucherId,
                              String giftId) throws SdkJsonRpcClientException {
        UpdateVoucherParam updateVoucherParam =
                UpdateVoucherParam.create()
                        .setStatusForVoucherMain(VoucherVoucherMainStatus.ISSUED)
                        .setGift(GiftReference.of(giftId));
 
        Packet updatePacket = new Packet(voucherId);
 
        updatePacket.voucher.update(VoucherRef.of(voucherId), updateVoucherParam);
 
        packetClient.execute(updatePacket);
    }

Метод DataspaceCorePacketClient#execute оперирует объектами типа Packet. Packet является реализацией паттерна UnitOfWork. Все команды, содержащиеся в рамках одного Packet, выполняются в одной транзакции на стороне сервиса DataSpace.

Создаём объект Packet. При этом задаём параметр idempotencePacketId — таким образом мы наделяем Packet свойством идемпотентности.

IdempotencePacketId выступает ключом идемпотентности. Это означает, что на все последующие вызовы Packet c таким же ключом DataSpace вернёт результат, который был получен при первом успешном вызове. При этом сами операции изменения состояния БД выполнены не будут. В качестве ключа идемпотентности используем идентификатор сущности Voucher.

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

Вызываем метод DataspaceCorePacketClient#execute, чтобы отправить запрос в DataSpace.

В методе getGiftByPromoCode отправляем на фронт сообщение о полученном подарке или ошибку.

Сервис Gifts

Скачиваем jar с DataSpace SDK, но на этот раз из сервиса DataSpace Gifts:

Создаём проект, подключаем зависимости точно так же, как и в случае с сервисом Vouchers:

Сервис Gift будет предоставлять REST API, который принимает на вход идентификатор промокода и тип подарка.

В ответ он отдаёт JSON, в котором содержится информация о забронированном подарке или ошибка.

Определим API в нашем контроллере:

@RestController
public class GiftsController {
 
    @Autowired
    private GiftsService giftsService;
 
    @RequestMapping(value = "/getGift")
    public ResponseEntity<JsonNode> getGift(@RequestParam String voucherId, @RequestParam String giftKind) {
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(giftsService.getGift(voucherId, giftKind));
    }
}

Определим инстансы DataspaceCorePacketClient и DataspaceCoreSearchClient. Получаем необходимые параметры из соответствующих инфраструктурных переменных DATASPACE_URL, APP_KEY, APP_SECRET.

@Configuration
public class Config {
    @Value("${DATASPACE_URL}")
    private String dataSpaceUrl;
    @Value("${APP_KEY}")
    private String appKey;
    @Value("${APP_SECRET}")
    private String appSecret;
 
    @Bean
    public DataspaceCoreSearchClient searchClient() {
        return new DataspaceCoreSearchClient(dataSpaceUrl,
                DataspaceSdkApiClientConfiguration.of(builder ->
                        builder
                                .setApiGatewayConfiguration(AKSKApiGatewayConfiguration.of(appKey, appSecret))
                )
        );
    }
 
    @Bean
    public DataspaceCorePacketClient packetClient() {
        return new DataspaceCorePacketClient(dataSpaceUrl,
                DataspaceSdkApiClientConfiguration.of(builder ->
                        builder
                                .setApiGatewayConfiguration(AKSKApiGatewayConfiguration.of(appKey, appSecret))
                )
        );
    }
}

Перейдём к реализации GiftsService.

Рассмотрим основной метод GiftsService#getGift:

    public JsonNode getGift(String voucherId, String kind) {
        ObjectNode response = objectMapper.createObjectNode();
        try {
            updateRequestCount(voucherId, kind);
            GraphCollection<GiftGet> gifts = searchClient.searchGift(giftWith ->
                    giftWith
                            .withKind()
                            .withVendor(GiftVendorWithLinkable::withName)
                            .withSerialNumber()
                            .setWhere(where ->
                                    where
                                            .kindEq(GiftKind.valueOf(kind))
                                            .and(where.voucherIsNull().or(where.voucherEq(voucherId)))
                            )
            );
 
            if (gifts.isEmpty()) {
                LOG.error("Available gift not found");
                response.put("error", "Available gift not found");
                return response;
            }
 
            GiftGet gift = gifts.get(0);
            String giftId = gift.getObjectId();
 
            Packet packet = new Packet(giftId);
            packet.gift.update(GiftRef.of(giftId),
                    update -> update
                            .setVoucher(VoucherReference.of(voucherId)));
 
            packetClient.execute(packet);
 
            response.put("giftId", giftId);
            response.put("vendor", gift.getVendor().getName());
            response.put("serialNumber", gift.getSerialNumber());
 
        } catch (IdempotencyException idempotencyException) {
            LOG.error(idempotencyException.getMessage());
            return getGift(voucherId, kind);
 
        } catch (Exception exception) {
            LOG.error(exception.getMessage());
            response.put("error", exception.getMessage());
        }
 
        return response;
    }

Разберём его детально. 

В сервисе Gifts помимо самих подарков и компаний ведётся сущность GiftRequestCounter, которая хранит количество поступивших запросов для каждого типа подарка.

Предполагается, что она будет использована в аналитических отчётах:

    private void updateRequestCount(String voucherId, String kind) {
        String idempotencePacketId = voucherId + kind;
        Packet packet = new Packet(idempotencePacketId);
 
        CreateGiftRequestCounterParam createGiftRequestCounterParam =
                CreateGiftRequestCounterParam.create()
                        .setKind(GiftKind.valueOf(kind))
                        .setLastRequest(LocalDateTime.now());
 
        GiftRequestCounterRef giftRequestCounter = packet.giftRequestCounter.updateOrCreate(
                createGiftRequestCounterParam, KeyGiftRequestCounter.KIND);
 
        UpdateGiftRequestCounterReq updateGiftRequestCounterReq =
                UpdateGiftRequestCounterReq.create()
                        .setInc(IncGiftRequestCounterParam.create().setCounter(1));
 
        packet.giftRequestCounter.update(giftRequestCounter, updateGiftRequestCounterReq);
 
        packetClient.executeAsync(packet).subscribe();
    }

В методе updateRequestCount мы отправляем асинхронно запрос на увеличение счётчика GiftRequestCounter в сервис DataSpace Gifts.

Создаём Packet с ключом идемпотентности, состоящим из идентификатора промокода и типа подарка, чтобы избежать лишнего накручивания счётчика при ретраях.

В Packet добавляем команду UpdateOrCreate. Эта команда позволяет за один вызов проверить наличие сущности в БД и обновить её, а если сущности нет, то создать. Также мы добавляем команду update с установленным параметром на увеличение счётчика. Затем отправляем запрос асинхронно при помощи метода DataspaceCorePacketClient#executeAsync.

Далее в основном методе сервиса getGift производим поиск доступного подарка, используя метод DataspaceCoreSearchClient#searchGift:

            GraphCollection<GiftGet> gifts = searchClient.searchGift(giftWith ->
                    giftWith
                            .withKind()
                            .withVendor(GiftVendorWithLinkable::withName)
                            .withSerialNumber()
                            .setWhere(where ->
                                    where
                                            .kindEq(GiftKind.valueOf(kind))
                                            .and(where.voucherIsNull().or(where.voucherEq(voucherId)))
                            )
            );

Если доступные подарки не были найдены, формируем ответ с ошибкой:

            if (gifts.isEmpty()) {
                LOG.error("Available gift not found");
                response.put("error", "Available gift not found");
                return response;
            }

Если подходящие подарки нашлись, нам необходимо забронировать один из них, установив ссылку на соответствующий ваучер.

Снова воспользуемся функционалом DataspaceCorePacketClient. Создадим Packet и добавим в него команду на обновление сущности Gift.

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

Данный подход позволяет нам не допустить ситуацию, в которой один и тот же подарок будет забронирован для нескольких разных ваучеров, а также избежать выполнения лишних операций при ретраях:

GiftGet gift = gifts.get(0);
            String giftId = gift.getObjectId();            
            
            Packet packet = new Packet(giftId);
            packet.gift.update(GiftRef.of(giftId),
                    update -> update
                            .setVoucher(VoucherReference.of(voucherId)));
 
            packetClient.execute(packet);

Сервис Reports

Перейдём к реализации сервиса, который предоставляет API для получения отчётов.

Данный сервис будет предоставлять отчёты о подарках, поэтому нам потребуется jar DataSpace SDK из сервиса DataSpace Gifts.

Создадим проект, добавим необходимую зависимость:

Реализуем API получения следующего отчёта:

Компания | тип подарка | кол-во подарков:

@RestController
public class ReportController {
    @Autowired
    private ReportService reportService;
 
    @RequestMapping(value = "/getGiftsReport")
    public ResponseEntity<JsonNode> getGiftsReport() {
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(reportService.getGiftsReport());
    }
}

Рассмотрим реализацию основного метода ReportService#getGiftsReport с применением DataSpace SDK:

    public JsonNode getGiftsReport() {
        ObjectNode response = objectMapper.createObjectNode();
        try {
 
            SelectionWith<? extends GiftGrasp> selectionWith = GiftGraph.createSelection()
                    .$withGroup("vendor", groupSelector -> groupSelector.none(giftGrasp -> giftGrasp.vendor().name()))
                    .$withGroup("kind", groupSelector -> groupSelector.none(GiftGrasp::kind))
                    .$withGroup("giftsCount", groupSelector -> groupSelector.count(GiftGrasp::kind))
 
                    .$addGroupBy(groupBy -> groupBy.vendor().name())
 
                    .$addGroupBy(GiftGrasp::kind);
 
            GraphCollection<Selection> selections = searchClient.selectionSearch(selectionWith);
 
            ArrayNode reportRows = objectMapper.createArrayNode();
            selections.forEach(selection -> {
                ObjectNode objectNode = objectMapper.createObjectNode();
                objectNode.put("vendor", selection.$getCalculated("vendor", String.class));
                objectNode.put("kind", selection.$getCalculated("kind", String.class));
                objectNode.put("giftsCount", selection.$getCalculated("giftsCount", Integer.class));
                reportRows.add(objectNode);
            });
            response.set("report", reportRows);
 
        } catch (SdkJsonRpcClientException e) {
            LOG.error(e.getMessage());
            response.put("error", e.getMessage());
        }
 
        return response;
    }

Конструкция SelectionWith позволяет построить запрос с группировками.

Метод $withGroup первым параметром принимает алиас поля, который будет отображён в результирующей выборке. Вторым параметром $withGroup принимает groupSelector, который позволяет указать выражение, на основе которого будут получены данные, будь то значение поля как оно есть или агрегирующая функция.

При помощи метода $addGroupBy мы добавляем поля, по которым будет выполнена группировка.

После формирования объекта SelectionWith выполняем вызов DataspaceCoreSearchClient#selectionSearch. Формируем JSON-ответ. Метод Selection#$getCalculated позволяет получить данные из объекта Selection, а также привести их к требуемому типу данных.

Публикация функций и тестирование

Приложения готовы, теперь необходимо упаковать каждое в zip-архив и загрузить в соответствующую функцию в SmartMarket Studio:

Затем жмём кнопку «Опубликовать» и ждём, пока функции задеплоятся.

После успешного деплоя на вкладке «Тестирование» мы можем проверить работоспособность наших API:

Итог

С помощью Platform V Functions и DataSpace SDK мы создали и развернули два полноценных микросервиса:

  • Подарки:

a) Ведение компаний-спонсоров и их подарков.

b)  Аналитический учёт пользовательских запросов.

  • ·Промоакции:

a)  Ведение промоакций и ваучеров в рамках сервиса.

b) Резервирование подарков в рамках промоакций (интеграция с сервисом «Подарки»).

В следующих статьях подробнее раскроем фичи и возможности Platform V Functions и расскажем, как ещё можно сократить время на разработку и реализовать микросервисный подход, используя инструменты Platform V.

Комментарии (2)


  1. LivEEvil
    22.04.2022 13:12
    -1

    Сейчас бы из flux сontroller'а ResponseEntity вернуть


  1. DAN_SEA
    22.04.2022 13:22
    -1

    Спасибо за подробную информацию!