Symfony in microservice architecture - Episode I : Symfony and Golang communication through gRPC
Achref Riahi
Posted on August 21, 2022
Today, application modernization often means migrating to cloud-native approach, with microservices-based architecture.
In addition to Amazon, Uber and Airbnb, many other companies have adopted this approach because the microservices architecture improves the scalability, development speeds, iteration of new functionality, reduced integration efforts... and the biggest advantage in my opinion
is the ability to collaborate with several teams with different technologies.
On the other hand, microservices-based architecture brings many challenges as multiple services are being built and deployed simultaneously in this design. Similarly, a software developer faces many questions such as how services will communicate and share data.
Microservices Communication
Basically they are two approche of communication between microservices. Communication is going to be synchronous request/response with HTTP/RPC or asynchronous with Message Passing.
RPC
There are various notable implementations of RPC like Apache Thrift and gRPC.
In this post we i will explore how to expose and consume a gRPC service using Symfony 5.4 .
Why gRPC ?
gRPC is a modern open source high performance RPC framework developed by Google and that can run in multiple environment.
- gRPC use HTTP/2 as communication protocol which divided messages into small frame binary format. Unlike text-based HTTP/1.1, it makes sending and receiving messages compact and efficient.
- Message exchange happens faster, even in devices with a slower CPU like IoT or mobile devices because data is represented in a binary format which minimizes the size of encoded messages.
- By forcing developers to use schema (proto3), we can ensure that the message doesn't get lost between microservices and its structural stay the same on another microservice as well.
Init Symfony project
Suppose we need to create an inventory microservice that checks which products are in stock and handle their categories and prices... So if you receive an order from a POS or from your e-commerce website, there will be a centralized service to handle that.
Let's create the microservice project and add some required packages.
$ symfony new inventory --version=5.4 && cd inventory
$ composer req orm api symfony/filesystem symfony/process google/protobuf grpc/grpc spiral/roadrunner-grpc
$ composer req --dev symfony/maker-bundle orm-fixtures
PHP is not built to run as a standalone daemon, therefore there is no support for PHP gRPC servers š. Don't panic RoadRunner solved the problem š¤.
RoadRunner is a high-performance PHP application server, load-balancer, and process manager written in Golang. To learn more about RoadRunner i invite you to read @khepin post Building a gRPC server in PHP.
Now, to integrate RoadRunner in our microservice we add Roadrunner Bundle
$ composer req baldinof/roadrunner-bundle symfony/runtime
Next copy the dev config file.
$ cp vendor/baldinof/roadrunner-bundle/.rr.dev.yaml .
We need to add the gRPC config manualy, you can find the final result of .rr.dev.yaml
in my github repository.
grpc:
# Port to expose as gRPC service.
listen: "tcp://:9000"
proto:
- "proto/servers/protofileA.proto"
- "proto/servers/protofileB.proto"
- "proto/servers/protofileC.proto"
To keep Roadrunner in watch mode also for gRPC server as for web server we add this config.
reload:
enabled: true
interval: 1s
patterns: [".php", ".yaml"]
services:
http:
dirs: ["."]
recursive: true
grpc:
dirs: [ "." ]
recursive: true
Create a dockerfile dev image for RoadRunner container under this path ./dockerfiles/roadrunner/dev.Dockerfile
with all PHP needed dependencies .
# Roadrunner Dev Dockerfile
FROM php:8.1-alpine
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
linux-headers \
&& apk add --update --no-cache \
openssl-dev \
pcre-dev \
icu-dev \
icu-data-full \
libzip-dev \
postgresql-dev \
protobuf \
grpc \
&& docker-php-ext-install \
bcmath \
intl \
opcache \
zip \
sockets \
pdo_pgsql \
&& pecl install protobuf \
&& pecl install grpc \
&& docker-php-ext-enable \
grpc \
protobuf \
&& pecl clear-cache \
&& apk del --purge .build-deps
WORKDIR /usr/src/app
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-scripts --no-progress --no-interaction
RUN ./vendor/bin/rr get-binary --location /usr/local/bin
ENV APP_ENV=dev
EXPOSE 8080 9000
USER root
COPY ./dockerfiles/roadrunner/dev-entrypoint.sh /root/entrypoint.sh
RUN chmod 544 /root/entrypoint.sh
CMD ["/root/entrypoint.sh"]
And create the entypoint bash file under ./dockerfiles/roadrunner/dev-entrypoint.sh
.
#!/bin/bash
composer dump-autoload --optimize && \
composer check-platform-reqs && \
composer run-script post-install-cmd && \
php bin/console cache:warmup && \
rr serve -c .rr.dev.yaml
Now we create our two entities, Product and Category using
make command.
$ php bin/console make:entity
Product entity result:
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ProductRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ApiResource(
collectionOperations: [],
itemOperations: [
'get' => [
'openapi_context' => [
'parameters' => [
[
'in' => 'query',
'name' => 'currency',
'type' => 'string',
'enum' => ['USD', 'EUR'],
'description' => 'The currency in which you wish to get the product price (Only USD and EUR are accepted) .',
],
]
]
]
],
normalizationContext: ['groups' => ['product:read']],
denormalizationContext: ['groups' => ['product:write']],
)]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['product:read', 'product:write'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['product:read', 'product:write'])]
#[Assert\NotBlank]
private ?string $name = null;
#[ORM\Column(nullable: true)]
#[Groups(['product:read', 'product:write'])]
#[Assert\NotBlank]
private ?int $quantity = null;
#[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 3)]
#[Groups(['product:read', 'product:write'])]
#[Assert\NotBlank]
private ?string $price = null;
#[ORM\ManyToOne(inversedBy: 'products', cascade: ['persist'])]
#[Groups(['product:read', 'product:write'])]
private ?Category $category = null;
/**
* Get product id.
*
* @return integer|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Get product name.
*
* @return string|null
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Set product name.
*
* @param string $name
* @return self
*/
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* Get product quantity.
*
* @return integer|null
*/
public function getQuantity(): ?int
{
return $this->quantity;
}
/**
* Set product quantity.
*
* @param integer|null $quantity
* @return self
*/
public function setQuantity(?int $quantity): self
{
$this->quantity = $quantity;
return $this;
}
/**
* Get product price.
*
* @return string|null
*/
public function getPrice(): ?string
{
return $this->price;
}
/**
* Set product price.
*
* @param string $price
* @return self
*/
public function setPrice(string $price): self
{
$this->price = $price;
return $this;
}
/**
* Get product category.
*
* @return Category|null
*/
public function getCategory(): ?Category
{
return $this->category;
}
/**
* Set product quantity.
*
* @param Category|null $category
* @return self
*/
public function setCategory(?Category $category): self
{
$this->category = $category;
return $this;
}
/**
* Convert price using currency exchange rate.
*
* @param float $exchangeRate
* @return void
*/
public function setPriceWithRate(float $exchangeRate): void
{
$convertedPriceValue = (float)$this->getPrice() * $exchangeRate;
$integer = (int)($convertedPriceValue);
$fraction = ceil((($convertedPriceValue - $integer) * 1000) / 250) * 250 ;
$this->setPrice($integer . '.' . $fraction);
}
}
Category entity result:
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\CategoryRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
#[ApiResource(
collectionOperations: [],
itemOperations: ['get'],
)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['product:read'])]
private ?string $name = null;
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
/**
* Get category id.
*
* @return integer|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Get category name.
*
* @return string|null
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Set category name.
*
* @param string $name
* @return self
*/
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* Get category products.
*
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
/**
* Add product to category.
*
* @param Product $product
* @return self
*/
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products->add($product);
$product->setCategory($this);
}
return $this;
}
/**
* Remove product from category.
*
* @param Product $product
* @return self
*/
public function removeProduct(Product $product): self
{
if ($this->products->removeElement($product)) {
// set the owning side to null (unless already changed)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
Generate the migration file and migrate the database schema.
$ docker-compose -f docker-compose.dev.yml exec roadrunner php bin/console make:mig --no-interaction
$ docker-compose -f docker-compose.dev.yml exec roadrunner php bin/console doc:mig:mig --no-interaction
Add fixtures for test.
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use App\Entity\Product;
use App\Entity\Category;
class AppFixtures extends Fixture
{
/**
* Generate fake categories and products.
*
* @param ObjectManager $manager
* @return void
*/
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 3; $i++) {
$category = new Category();
$category->setName('Category '.$i);
$manager->persist($category);
for ($j = 1; $j < mt_rand(2, 10); $j++) {
$price = number_format(mt_rand(10 * 2, 100 * 2) / 4, 3, '.', '');
$product = new Product();
$product->setName('Product '. $i . $j)
->setPrice($price)
->setQuantity(mt_rand(0, 10))
->setCategory($category);
$manager->persist($product);
}
}
$manager->flush();
}
}
Finally to generate the data š„±.
$ docker-compose -f docker-compose.dev.yml exec roadrunner php bin/console doc:fix:load --no-interaction
š¤¬ ? Yes, i know There is nothing new to invent here.
Expose a gRPC service using Symfony
Firstly we need to install PHP extensions protobuf & gRPC in our host machine (already installed in container by the dev.Dockerfile
) and protobuf compiler with the PHP plugin.
$ sudo apt install -y protobuf-compiler protobuf-compiler-grpc php-protobuf php-grpc
ā ļø Run this command to know wich version suite to you CPU architecture before installing protoc-gen-php-grpc plugin.
$ dpkg --print-architecture
In my case is amd64 so my command will be:
$ sudo wget -c https://github.com/roadrunner-server/roadrunner/releases/download/v2.11.0/protoc-gen-php-grpc-2.11.0-linux-amd64.tar.gz -O - | sudo tar -zxvf protoc-gen-php-grpc-2.11.0-linux-amd64/protoc-gen-php-grpc -C /usr/local/bin/
We gonna expose 3 gRPC services from the inventory microservice.
- GetProductById
- GetCategoryById
- GetCategories
So we define them in the inventory.proto
and we add Product
and Category
definition as Protobuf messages.
I placed the proto file under a dedicated directory proto\servers
at the root project directory (the same level as src
)
ā ļø gRPC service definition require always an argument, for service that does need argument we can use google/protobuf/empty.proto
to solve this problem.
ā ļø repeated
represent an array.
ā ļø For more information about scalar types supported by proto you can visit Google proto3 Language Guide.
syntax = "proto3";
option php_namespace = "App\\Protobuf\\Generated";
option php_metadata_namespace = "App\\Protobuf\\Generated\\GPBMetadata";
import "google/protobuf/empty.proto";
package app;
message Product {
int32 id = 1;
string name = 2;
float price = 3;
int32 quantity = 4;
Category category = 5;
}
message Category {
int32 id = 1;
string name = 2;
repeated Product products = 3;
}
message GetProductByIdRequest {
int32 id = 1;
}
message GetProductByIdResponse {
Product product = 1;
}
message GetCategoryByIdRequest {
int32 id = 1;
}
message GetCategoryByIdResponse {
Category category = 1;
}
message GetCategoriesResponse {
repeated Category categories = 1;
}
service Inventory {
rpc GetProductById (GetProductByIdRequest) returns (GetProductByIdResponse);
rpc GetCategoryById (GetCategoryByIdRequest) returns (GetCategoryByIdResponse);
rpc GetCategories (google.protobuf.Empty) returns (GetCategoriesResponse);
}
To generate gRPC service interfaces and related protobuf classes:
$ protoc -I proto/servers --php_out=. --php-grpc_out=. proto/servers/inventory.proto
ā The default protoc compiler does not respect the location of the application namespaces. The code will be generated in the App/Protobuf/Generated
and App/Protobuf/Generated/GPBMetadata
directories. Move the generated code to make it loadable.
Not practical ? No problem, you can create a symfony command to solve that.
<?php
namespace App\Command;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Process\Process;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
#[AsCommand(
name: 'protobuf:generate',
description: 'Generate gRPC service interfaces and related protobuf classes, \n
depending on proto files located in proto directory.',
)]
class ProtobufGeneratorCommand extends Command
{
private const TMP_PROTOBUF_DIR = 'var/tmp_protobuf';
/** @var ContainerBagInterface */
private $params;
/** @var Filesystem */
private $filesystem;
/** @var SymfonyStyle */
private $io;
/**
* @param Filesystem $filesystem
* @param ContainerBagInterface $params
*/
public function __construct(Filesystem $filesystem, ContainerBagInterface $params)
{
$this->params = $params;
$this->filesystem = $filesystem;
parent::__construct();
}
/**
* @inheritDoc
*/
protected function configure(): void
{
$this->addOption(
'server_proto_file',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'The server(s) proto files names. If not specified all proto files will be used.',
['*']
);
$this->addOption(
'client_proto_file',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'The client(s) proto files names. If not specified all proto files will be used.',
['*']
);
}
/**
* Set SymfonyStyle to command input and output.
*
* @param SymfonyStyle $io
* @return void
*/
protected function setIO(SymfonyStyle $io): void
{
$this->io = $io;
}
/**
* Get protobuf directory path.
*
* @return string
*/
protected function getProtobufDirectoryPath(): string
{
return $this->getProjectDir() . '/src/Protobuf';
}
/**
* Get project root directory.
*
* @return string
*/
protected function getProjectDir(): string
{
return $this->params->get('kernel.project_dir');
}
/**
* Create protobuf directory.
*
* @return void
*/
protected function createProtobufDirectory(): void
{
try {
$this->filesystem->mkdir(
Path::normalize($this->getProtobufDirectoryPath()),
);
} catch (IOExceptionInterface $exception) {
$this->io->error("An error occurred while creating your directory at ".$exception->getPath());
}
}
private function createTempDir(string $projectDir): void
{
$tmpProtobufDir = Path::normalize($projectDir . '/' . self::TMP_PROTOBUF_DIR);
if ($this->filesystem->exists($tmpProtobufDir)) {
$this->filesystem->remove($tmpProtobufDir);
}
$this->filesystem->mkdir($tmpProtobufDir);
}
/**
* Generate protobuf PHP classes.
*
* @param array<string> $serversProtoFiles
* @param array<string> $clientsProtoFiles
* @return void
*/
protected function generateProtobufFiles(array $serversProtoFiles, array $clientsProtoFiles): void
{
array_walk($serversProtoFiles, function (&$protoFile) {
$protoFile = 'proto/servers/' . $protoFile . '.proto';
});
array_walk($clientsProtoFiles, function (&$protoFile) {
$protoFile = 'proto/clients/' . $protoFile . '.proto';
});
$projectDir = $this->getProjectDir();
$this->createTempDir($projectDir);
$serversProcess = Process::fromShellCommandline(
'protoc -I proto/servers '.
' --php_out=' . self::TMP_PROTOBUF_DIR.
' --php-grpc_out=' . self::TMP_PROTOBUF_DIR.
' '. implode(' ', $serversProtoFiles),
$projectDir
);
$serversProcess->run();
if (!$serversProcess->isSuccessful()) {
throw new ProcessFailedException($serversProcess);
}
$clientsProcess = Process::fromShellCommandline(
'protoc -I proto/clients '.
' --php_out=' . self::TMP_PROTOBUF_DIR.
' --grpc_out=' . self::TMP_PROTOBUF_DIR.
' --plugin=protoc-gen-grpc=/usr/bin/grpc_php_plugin '.
implode(' ', $clientsProtoFiles),
$projectDir
);
$clientsProcess->run();
if (!$clientsProcess->isSuccessful()) {
throw new ProcessFailedException($clientsProcess);
}
$this->filesystem->rename($projectDir . '/var/tmp_protobuf/App/Protobuf/Generated', $projectDir . '/src/Protobuf/Generated', true);
$this->filesystem->remove(self::TMP_PROTOBUF_DIR);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->setIO(new SymfonyStyle($input, $output));
$this->createProtobufDirectory();
$this->generateProtobufFiles($input->getOption('server_proto_file'), $input->getOption('client_proto_file'));
return Command::SUCCESS;
}
}
Cool no ? so to generate a specific proto files you use :
$ php bin/console protobuf:generate -server_proto_file=x -server_proto_file=y -server_proto_file=z -client_proto_file=a
-client_proto_file=b -client_proto_file=c
Or without options to generate all proto files under proto
directory.
$ php bin/console protobuf:gen
Last step š, now we need to create a class that implement InventoryInterface
and which represents the behavior of our 3 services.
<?php
namespace App\Protobuf;
use App\Entity\Product;
use App\Entity\Category;
use Spiral\RoadRunner\GRPC;
use App\Protobuf\GRPCHelper;
use Doctrine\Persistence\ManagerRegistry;
use App\Protobuf\Generated\InventoryInterface;
use App\Protobuf\Generated\GetCategoriesResponse;
use App\Protobuf\Generated\GetProductByIdRequest;
use App\Protobuf\Generated\GetCategoryByIdRequest;
use App\Protobuf\Generated\GetProductByIdResponse;
use App\Protobuf\Generated\GetCategoryByIdResponse;
use App\Protobuf\Generated\Product as ProductMessage;
use App\Protobuf\Generated\Category as CategoryMessage;
class Inventory implements InventoryInterface
{
/** @var ManagerRegistry */
private $doctrine;
public function __construct(ManagerRegistry $doctrine)
{
$this->doctrine = $doctrine;
}
/**
* @inheritDoc
*/
public function GetProductById(GRPC\ContextInterface $ctx, GetProductByIdRequest $in): GetProductByIdResponse
{
/** @var Product */
$product = $this->doctrine->getRepository(Product::class)->find($in->getId());
if ($product == null) {
throw new GRPC\Exception\GRPCException(
"Invalid product id.",
GRPC\StatusCode::INVALID_ARGUMENT
);
}
$categoryMessageArray = new CategoryMessage(GRPCHelper::messageParser([
'id' => $product->getCategory()?->getId(),
'name' => $product->getCategory()?->getName(),
]));
$productMessageArray = GRPCHelper::messageParser([
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice(),
'quantity' => $product->getQuantity(),
'category' => $categoryMessageArray
]);
return new GetProductByIdResponse([
'product' => new ProductMessage($productMessageArray)
]);
}
/**
* @inheritDoc
*/
public function GetCategoryById(GRPC\ContextInterface $ctx, GetCategoryByIdRequest $in): GetCategoryByIdResponse
{
/** @var Category */
$category = $this->doctrine->getRepository(Category::class)->find($in->getId());
if ($category == null) {
throw new GRPC\Exception\GRPCException(
"Invalid category id.",
GRPC\StatusCode::INVALID_ARGUMENT
);
}
$productsMessageArray = array_map(
fn ($product) => new ProductMessage(
GRPCHelper::messageParser([
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice(),
'quantity' => $product->getQuantity()
])
),
$category->getProducts()->toArray()
);
return new GetCategoryByIdResponse([
'category' => new CategoryMessage(
GRPCHelper::messageParser([
'id' => $category->getId(),
'name' => $category->getName(),
'products' => $productsMessageArray
])
)
]);
}
/**
* @inheritDoc
*/
public function GetCategories(GRPC\ContextInterface $ctx, \Google\Protobuf\GPBEmpty $in): GetCategoriesResponse
{
/** @var array<Category> */
$categories = $this->doctrine->getRepository(Category::class)->findAll();
return new GetCategoriesResponse(
[
'categories' => array_map(fn ($category) => new CategoryMessage([
'id' => $category->getId(),
'name' => $category->getName(),
]), $categories)
]
);
}
}
ā ļø gRPC server crash if a protobuf message item containes null value, so we need to parse array of data.
<?php
namespace App\Protobuf;
class GRPCHelper
{
/**
* Remove null value from data array of Protobuf messages to avoid errors.
*
* @param array<mixed> $message
* @return array<mixed>
*/
public static function messageParser(array $message): array
{
return array_filter($message, fn ($item) => !is_null($item));
}
}
To test you can use a GUI like Insomnia or a CLI like grpcurl.
$ grpcurl -d '{"id": 1}' -plaintext -import-path proto/servers -proto inventory.proto localhost:19000 app.Inventory/GetProductById
$ grpcurl -d '{"id": 1}' -plaintext -import-path proto/servers -proto inventory.proto localhost:19000 app.Inventory/GetCategoryById
$ grpcurl -plaintext -import-path proto/servers -proto inventory.proto localhost:19000 app.Inventory/GetCategories
Consume a gRPC service using Symfony
Now suppose we need to expose our product in a REST API from inventory
with price depending in request currency.
To response to this request and maybe another future requirements we create a Golang microservice with responsibility to handle financial requirements.
The Golang microservice will provide exchange rate service, that consume api.exchangerate.host REST API and give result to all other microservices that need this feature.
We start with init Golang project.
$ go mod init Companyname/finance
We define our service in proto file.
syntax = "proto3";
option go_package = "./finance";
option php_namespace = "App\\Protobuf\\Generated";
option php_metadata_namespace = "App\\Protobuf\\Generated\\GPBMetadata";
package app;
enum Currency {
UNKNOWN = 0;
TND = 1;
USD = 2;
EUR = 3;
}
message GetExchangeRateRequest {
Currency from = 1;
Currency to = 2;
}
message GetExchangeRateResponse {
double rate = 1;
}
service Finance {
rpc getExchangeRate (GetExchangeRateRequest) returns (GetExchangeRateResponse) {};
}
We generate the protobuf module files
$ protoc -I proto/ --go_out=protobuf --go-grpc_out=protobuf proto/*.proto
We define the service behavior
package finance
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Server struct {
UnimplementedFinanceServer
}
type ExchangeRateConvertResponse struct {
Motd struct {
Msg string `json:"msg"`
URL string `json:"url"`
} `json:"motd"`
Success bool `json:"success"`
Query struct {
From string `json:"from"`
To string `json:"to"`
Amount float64 `json:"amount"`
} `json:"query"`
Info struct {
Rate float64 `json:"rate"`
} `json:"info"`
Historical bool `json:"historical"`
Date string `json:"date"`
Result float64 `json:"result"`
}
func (s *Server) GetExchangeRate(ctx context.Context, in *GetExchangeRateRequest) (*GetExchangeRateResponse, error) {
log.WithFields(log.Fields{"from": in.From.String(), "to": in.To.String()}).Info("Call GetExchangeRate gRPC service")
if !isCurrencyArgumentValid(in.From) || !isCurrencyArgumentValid(in.To) {
errMessage := "Bad or missing argument."
log.Error(errMessage)
err := status.Error(codes.InvalidArgument, errMessage)
return nil, err
}
exchangeRateConvert, err := getExchangeRateConvert(in.From.String(), in.To.String())
if err != nil {
errMessage := "Error occurred when calling exchangerate.host API."
log.Error(errMessage)
err := status.Error(codes.FailedPrecondition, errMessage)
return nil, err
}
return &GetExchangeRateResponse{Rate: exchangeRateConvert.Info.Rate}, nil
}
func isCurrencyArgumentValid(val Currency) bool {
return val != Currency_UNKNOWN
}
func getExchangeRateConvert(from, to string) (*ExchangeRateConvertResponse, error) {
client := http.Client{
Timeout: 2 * time.Second,
}
request, err := http.NewRequest("GET", "https://api.exchangerate.host/convert?from="+from+"&to="+to, nil)
if err != nil {
log.WithError(err).Error("Failed to generate request.")
return nil, err
}
response, err := client.Do(request)
if err != nil {
log.WithError(err).Error("Failed to request.")
return nil, err
}
body, err := ioutil.ReadAll(response.Body) // response body is []byte
if err != nil {
log.WithError(err).Error("Failed to serialize response.")
return nil, err
}
var result ExchangeRateConvertResponse
if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to the go struct pointer
log.Error("Can not unmarshal JSON")
return nil, err
}
return &result, nil
}
And finally we run the gRPC server in main.go
on port 9000
package main
import (
"achrefriahi/finance/protobuf/finance"
"net"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
)
func main() {
listen, err := net.Listen("tcp", ":9000")
if err != nil {
log.WithError(err).Error("Failed to listen.")
}
log.Info("Start listing gRPC service on port 9000.")
grpcServer := grpc.NewServer()
s := finance.Server{}
finance.RegisterFinanceServer(grpcServer, &s)
if err := grpcServer.Serve(listen); err != nil {
log.WithError(err).Error("Failed to serve.")
}
}
To test Golang gRPC service :
$ grpcurl -d '{"from":"USD","to":"TND"}' -plaintext -import-path proto -proto finance.proto localhost:29000 app.Finance/getExchangeRate
On Symfony side (inventory microservice), we copy the proto into proto/clients
and we generate the protobuf client class with our php bin/console protobuf:gen
command.
Rationally, we need to make gRPC client as service, to inject them whenever we need, so we add a Factory to create this services.
<?php
namespace App\Protobuf;
use Grpc\BaseStub;
use Grpc\ChannelCredentials;
class GRPCClientFactory
{
/**
* Create gRPC client.
*
* @param string $className
* @param string $hostname
* @param string $port
* @param string $credentials
* @return BaseStub
*/
public static function createGRPCClient(string $className, string $hostname, string $port, string $credentials): BaseStub
{
// @todo Handle all type of connection by switch, createInsecure, createSsl(file_get_contents("app.crt"))...
$credentialsConfig = ['credentials' => ChannelCredentials::createInsecure()];
return new $className(gethostbyname($hostname).':'.$port, $credentialsConfig);
}
}
And we add this lines to config/services.yaml
App\Protobuf\Generated\FinanceClient:
factory: ['@App\Protobuf\GRPCClientFactory', 'createGRPCClient']
arguments:
$className: 'App\Protobuf\Generated\FinanceClient'
$hostname: '%app.finance_gRPC_host%'
$port: 9000
$credentials: 'Insecure'
And to finish, we create an event listener on Product
GET
operation to handle currency
request.
<?php
namespace App\EventListener;
use App\Entity\Product;
use App\Protobuf\Generated\Currency;
use App\Protobuf\Generated\FinanceClient;
use Symfony\Component\Validator\Validation;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use App\Protobuf\Generated\GetExchangeRateRequest;
use ApiPlatform\Core\EventListener\EventPriorities;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
#[AsEventListener(event: KernelEvents::VIEW, method: 'convertPriceCurrency', priority: EventPriorities::PRE_SERIALIZE)]
class ApiPlatformGetProductListener
{
/** @var FinanceClient */
private $financeGRPCClient;
public function __construct(FinanceClient $financeGRPCClient)
{
$this->financeGRPCClient = $financeGRPCClient;
}
/**
* Convert price using currency code.
*
* @param ViewEvent $event
* @return void
*/
public function convertPriceCurrency(ViewEvent $event): void
{
$product = $event->getControllerResult();
$request = $event->getRequest();
$method = $request->getMethod();
$currency = (string)$request->query->get('currency');
if (!$product instanceof Product || Request::METHOD_GET !== $method || !$currency) {
return;
}
$currency = strtoupper($currency);
if (!$this->isCurrencyValid($currency)) {
throw new BadRequestException('Bad currency code, only USD and EUR are accepted.');
}
$exchangeRate = $this->getExchangeRate($currency);
$product->setPriceWithRate($exchangeRate);
}
/**
* Check if requested currency is valid.
*
* @param string $currency
* @return boolean
*/
private function isCurrencyValid(string $currency): bool
{
$validation = Validation::createIsValidCallable(new Assert\Choice([
'choices' => [
'USD',
'EUR'
],
'message' => 'Bad currency code, only USD and EUR are accepted.',
]));
return $validation($currency);
}
private function getExchangeRate(string $currency): float
{
[$getExchangeRateResponse, $mt] = $this->financeGRPCClient->getExchangeRate(
new GetExchangeRateRequest([
'from' => Currency::TND,
'to' => constant('\App\Protobuf\Generated\Currency::' . $currency)
])
)->wait();
if ($mt->code !== \Grpc\STATUS_OK) {
throw new ServiceUnavailableHttpException(5, 'Currency exchange service cannot be reach.');
}
return $getExchangeRateResponse->getRate();
}
}
So, as you can see in our event listener we throw two exceptions in two cases. If we cannot achieve our gRPC service we throw ServiceUnavailableHttpException
converted by Symfony to 500
HTTP Error Code in response and we throw BadRequestException
converted by Symfony to 400
HTTP Error Code if the currency value is different that USD and EUR.
Github
You can find the entirely code of this project on github š.
Posted on August 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 21, 2022