crossSvg

Platform V DataSpace: пишем код на Java при помощи удобного SDK

24.04.2022
photo1654068207-3.jpeg

Гайд по простой разработке backend-приложения с помощью сервиса Platform V Functions и DataSpace SDK — инструмента для простого взаимодействия с DataSpace по протоколу JSON-RPC. 

Источник: Habr "Platform V DataSpace: пишем код на Java при помощи удобного SDK"

Привет, Хабр! Продолжаем рассказывать, как быстро и просто создавать микросервисные приложения. В прошлой статье мы написали 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 со своей моделью данных.

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

f29d940eeb8b9ccbccdab3f5159532b5.png

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 создаёт в своём пространстве соответствующую функцию:

9d3360c67803f8876649b9d7fee5faad.png

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

cce89c958ea040bfc56d664a3e419739.png
f43338efcd05c182617d87e1135be9f6.png

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

Сервис Voucher

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

c0cf0c07ffba9e3b18916f5c62d1ab7a.png

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

0d9b23669144d5d7b5fc66ef00da9ea3.png

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

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

8f883dfa9e2efa5fd21c284e28e9d331.png

В 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.

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

52d2a3febc7ab93837786e8b7b89a324.png
9e0a7ed37fac99eae0de1c28cebdbdc8.png

В конфигурационном файле 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:

ae89e1410b0ca28c62c790f95f7837fb.png

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

f34511588c457eaac11d463233c99d38.png

Сервис 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.

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

68c3baa51ba6e9432364ba8043f062a5.png

Реализуем 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:

4995bb4cbc23551a4bd956df4c5429ce.png

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

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

099a77f11f1f1c2baa7db03222d56c5b.png

Итог

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

  • Подарки:

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

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

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

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

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

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