Symfony in microservice architecture - Episode I : Symfony and Golang communication through gRPC
Tags: symfony, grpc, go, microservices
Published: 2022-08-21 06:01:14

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.

<img width="100%" style="width:100%" src="https://media.giphy.com/media/3o7bucZfOGGG4gWlPy/giphy.gif">

## 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](https://github.com/apache/thrift) and [gRPC](https://github.com/grpc/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](https://opensource.google/projects/grpc) 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](https://developers.google.com/protocol-buffers/docs/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.
```console
$ 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](https://roadrunner.dev/) 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](https://dev.to/khepin/building-a-grpc-server-in-php-3bgc).

Now, to integrate RoadRunner in our microservice we add [Roadrunner Bundle](https://github.com/Baldinof/roadrunner-bundle)

```console
$ composer req baldinof/roadrunner-bundle symfony/runtime
```

Next copy the dev config file.

```console
$ 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](https://github.com/achref-riahi/symfony-in-microservice-architecture/tree/EpisodeI).

```yaml
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.

```yaml
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 .

```Dockerfile
# 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`.

```bash
#!/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.
```console
$ php bin/console make:entity
```

Product entity result:

```PHP
<?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
<?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.

```console
$ 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
<?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 🥱.

```console
$ 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.

```console
$ sudo apt install -y protobuf-compiler protobuf-compiler-grpc php-protobuf php-grpc
```
:warning: Run this command to know wich version suite to you CPU architecture before installing [protoc-gen-php-grpc](https://github.com/roadrunner-server/roadrunner/releases/) plugin.

```console
$ dpkg --print-architecture
```
In my case is amd64 so my command will be:

```console
$ 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.

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bsxk4ybmv9ykdz6gynsu.png)

:warning: gRPC service definition require always an argument, for service that does need argument we can use `google/protobuf/empty.proto` to solve this problem.
:warning: `repeated` represent an array.
:warning: For more information about scalar types supported by proto you can visit [Google proto3 Language Guide](https://developers.google.com/protocol-buffers/docs/proto3#scalar).

```proto
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
<?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 :

```console
$ 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.

```console
$ 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
<?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)
            ]
        );
    }
}
```

:warning: gRPC server crash if a protobuf message item containes null value, so we need to parse array of data.

```PHP
<?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]
(https://insomnia.rest/download) or a CLI like [grpcurl](https://github.com/fullstorydev/grpcurl).

```console
$ 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](https://api.exchangerate.host/) REST API and give result to all other microservices that need this feature.

We start with init Golang project.

```console
$ go mod init Companyname/finance
```
We define our service in proto file.

```proto
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 

```golang
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`

```golang
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 :

```console
$ 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](https://symfony.com/doc/current/service_container/factories.html) to create this services. 

```PHP
<?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`

```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
<?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](https://github.com/achref-riahi/symfony-in-microservice-architecture/tree/EpisodeI) 😀.