Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Привет, Хабр! Продолжаем рассказывать, как быстро и просто создавать микросервисные приложения. В прошлой статье мы написали 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>
Зависимость нужна для осуществления подписи 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.
Алгоритм заказа подарка по промокоду будет выглядеть так:
Запрос клиента поступает с фронта в сервис Vouchers, который выполняет валидацию промокода.
Если валидация прошла успешно, сервис Vouchers вызывает сервис Gifts по REST.
Сервис Gifts должен найти подходящий подарок и забронировать его либо вернуть ответ, содержащий информацию о том, что доступные подарки отсутствуют.
Сервис Vouchers получает идентификатор подарка, привязывает его к промокоду и отправляет ответ с серийным номером подарка и наименованием компании клиенту.
Если подарок не был найден, сервис отправляет соответствующий ответ клиенту:
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:
vouchers.zip
gifts.zip
report.zip
Затем жмём кнопку «Опубликовать» и ждём, пока функции задеплоятся.
После успешного деплоя на вкладке «Тестирование» мы можем проверить работоспособность наших API:
Итог
С помощью Platform V Functions и DataSpace SDK мы создали и развернули два полноценных микросервиса:
Подарки:
a) Ведение компаний-спонсоров и их подарков.
b) Аналитический учёт пользовательских запросов.
·Промоакции:
a) Ведение промоакций и ваучеров в рамках сервиса.
b) Резервирование подарков в рамках промоакций (интеграция с сервисом «Подарки»).
В следующих статьях подробнее раскроем фичи и возможности Platform V Functions и расскажем, как ещё можно сократить время на разработку и реализовать микросервисный подход, используя инструменты Platform V.