
Гайд по простой разработке backend-приложения с помощью сервиса Platform V Functions и DataSpace SDK — инструмента для простого взаимодействия с DataSpace по протоколу JSON-RPC.
Привет, Хабр! Продолжаем рассказывать, как быстро и просто создавать микросервисные приложения. В прошлой статье мы написали 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 будет своя модель данных:
- vouchers_model.xml — модель для DataSpace Vouchers;
- gifts_model.xml — модель для DataSpace Gifts.
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 в нашем контроллере:
java@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:
java@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.
Алгоритм заказа подарка по промокоду будет выглядеть так:
- Запрос клиента поступает с фронта в сервис Vouchers, который выполняет валидацию промокода.
- Если валидация прошла успешно, сервис Vouchers вызывает сервис Gifts по REST.
- Сервис Gifts должен найти подходящий подарок и забронировать его либо вернуть ответ, содержащий информацию о том, что доступные подарки отсутствуют.
- Сервис Vouchers получает идентификатор подарка, привязывает его к промокоду и отправляет ответ с серийным номером подарка и наименованием компании клиенту.
- Если подарок не был найден, сервис отправляет соответствующий ответ клиенту:
javapublic 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:
javapublic 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:
javaprivate 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:
javapublic 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 в нашем контроллере:
java@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.
java@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:
javapublic 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, которая хранит количество поступивших запросов для каждого типа подарка.
Предполагается, что она будет использована в аналитических отчётах:
javaprivate 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 получения следующего отчёта:
Компания | тип подарка | кол-во подарков:
java@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:
javapublic 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.