DEV Community

Cover image for Symfony in microservice architecture - Episode I : Symfony and Golang communication through gRPC
Achref Riahi
Achref Riahi

Posted on

Symfony in microservice architecture - Episode I : Symfony and Golang communication through gRPC

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next copy the dev config file.

$ cp vendor/baldinof/roadrunner-bundle/.rr.dev.yaml .
Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now we create our two entities, Product and Category using
make command.

$ php bin/console make:entity
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally to generate the data 🥱.

$ docker-compose -f docker-compose.dev.yml exec roadrunner php bin/console  doc:fix:load --no-interaction
Enter fullscreen mode Exit fullscreen mode

🤬 ? 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
Enter fullscreen mode Exit fullscreen mode

⚠️ Run this command to know wich version suite to you CPU architecture before installing protoc-gen-php-grpc plugin.

$ dpkg --print-architecture
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

We gonna expose 3 gRPC services from the inventory microservice.

  1. GetProductById
  2. GetCategoryById
  3. 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)

Directories structure of symfony project implementing gRPC service

⚠️ 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);
}
Enter fullscreen mode Exit fullscreen mode

To generate gRPC service interfaces and related protobuf classes:

$ protoc -I proto/servers --php_out=. --php-grpc_out=. proto/servers/inventory.proto
Enter fullscreen mode Exit fullscreen mode

⛔ 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Or without options to generate all proto files under proto directory.

$ php bin/console protobuf:gen
Enter fullscreen mode Exit fullscreen mode

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)
            ]
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) {};
}
Enter fullscreen mode Exit fullscreen mode

We generate the protobuf module files

$ protoc -I proto/ --go_out=protobuf --go-grpc_out=protobuf proto/*.proto
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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.")
    }
}
Enter fullscreen mode Exit fullscreen mode

To test Golang gRPC service :

$ grpcurl -d '{"from":"USD","to":"TND"}' -plaintext -import-path proto -proto finance.proto localhost:29000 app.Finance/getExchangeRate
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)