Здравствуйте.
Недавно довелось делать тестовое задание на Symfony - конвертер валют с прямой и кросс-конвертацией. Получилось весьма неплохо, поэтому хочу поделиться с сообществом примером простого консольного приложения по всем канонам Symfony: DI, autowiring, тегирование сервисов, гибкая конфигурация, вот это вот всё. Надеюсь, это будет полезно начинающим "симфонистам".

Код приложения https://github.com/vladimirmartsul/symfony-exchange-demo

Приложение считает "обмен валюты" по прямым курсам (например, USD -> EUR), а также через "промежуточные" валюты (например, BTC -> EUR). Также есть фейковые курсы для тестов.

Курсы берутся с сайтов ecb.europa.eu (основные мировые валюты по отношению к EUR) и coindesk.com (BTC к USD). Триангуляция основана на принципах отсюда http://www.dpxo.net/articles/fx_rate_triangulation_sql.html. Для хранения данных используется БД SQLite.

Использовать приложение можно через локальный PHP или в Docker.
Требования к PHP: версия 8.1, модули bcmath, ctype, iconv, intl, pdo_sqlite, simplexml, sqlite3.

На момент выполнения задания у меня было мало опыта с Symfony, в основном, я работал с Laravel, поэтому могут быть некоторые недоработки. Кроме того, использование SQLite наложило свои ограничения (из-за отсутствия настоящих decimal и numeric форматов и INSERT IGNORE пришлось "зашить" точность вычисления 16,8). Ещё был сбой с датами курсов ЕЦБ, из-за чего пришлось пожертвовать проверкой совпадения дат курсов, в приложении используется последний доступый день из каждого источника.

Основные моменты реализации

Команды

В приложении две консольные команды: "currency:update" - обновление курсов валют из указанных источников (\App\Command\CurrencyExchangeCommand) и "currency:exchange" - непосредственно обмен (\App\Command\CurrencyUpdateCommand).

Команды принимают параметры, валидируют данные, передают их в сервисы, ловят исключения и красиво выводят результат в консоль с соответствующими exit status.

Все сервисы и провайдеры передаются своим потребителям через внедрение в конструкторы. Провайдеры курсов помечены тегом "app.rates_provider" в config/services.yaml и по этому тегу передаюся через итератор в \App\Services\RatesUpdater. Очень удобно, на мой взгляд.

App\Providers\CoinDeskRatesProvider:
    tags: [ 'app.rates_provider' ]

App\Providers\EcbRatesProvider:
    tags: [ 'app.rates_provider' ]

App\Services\RatesUpdater:
    arguments:
        - !tagged_iterator app.rates_provider
class RatesUpdater
{
    public function __construct(private readonly iterable $ratesProviders, ...)
    {
    }
...
}

Обмен данными и валидация

Данные для обмена валют и сохранения курсов передаются через DTO: \App\Dto\Exchange и \App\Dto\Rate соотетственно. На DTO для обмена валют наложена валидация "AmountRequirements" - требования к количеству и "ExchangeCurrencyRequirements" - требования к валюте.
Кроме того, валидация наложена на сущности \App\Entity\Pair и \App\Entity\Rate.

Все валидаторы - кастомные, чтобы не засорять потребителей лишними деталями, а также для переиспользованияю. Валидаторы описаны в классах src/Validator/. Большинство из них - составные (Compound) из простейших правил. Например, требования к количеству - "Не пустая строка", "Тип Numeric" и "Положительное значение".

class AmountRequirements extends Compound
{
    protected function getConstraints(array $options): array
    {
        return [
            new Assert\NotBlank(),
            new Assert\Type(type: 'numeric', message: 'The value {{ value }} is not a valid {{ type }}'),
            new Assert\Positive(),
        ];
    }
}

Есть и более сложный валидатор существования валюты \App\Validator\PairCurrencyExistValidator. Он обращается к репозитарию валютных пар и проверяет в БД SELECT COUNT(1) FROM pair WHERE base = <переданный тикер валюты>. Реализовано через Doctrine Query Builder.

Обновление курсов валют

Тут всё достаточно просто: \App\Services\RatesUpdater получает в конструкторе итератор провайдеров курсов валют и по-очереди вызывает их (через __invoke, чтобы не придумывать название метода). Провайдеры, в свою очередь, наследуют абстрактный класс \App\Providers\RatesProvider и реализуют собственные методы трансформации данных в DTO \App\Dto\Rate.

Абстрактный провайдер "ходит" за курсами по указанному в конфигурации и .env адресу, который внедрён в конструктор вместе с названием базовой валюты. После получения курсов, провайдер парсит их из Json или XML в простой массив и передаёт их в трансформер конкретного провайдера. Парсеры реализованы в src/Parsers/.

Для тестов используется \App\Providers\FakeRatesProvider с переопределённым методом fetch и парой зашитых в него курсов.

Полученные в виде DTO курсы сохраняются в БД в прямом и обратном виде, после чего в работу включается триангулятор \App\Services\RatesTriangulator. Он создаёт все возможные сочетания курсов через промежуточные валюты (т.н. кросс-курсы) и записывает их в сущности \App\Entity\Pair.
Триангуляция основана на принципах http://www.dpxo.net/articles/fx_rate_triangulation_sql.html и в дальнейшем из такой отдельной таблицы с валютными парами гораздо проще получить интересующую пару для конвертации, нежели считать курсы при каждой конвертации.

Если что-то пошло не так, то провайдеры или триангулятор кидают исключения.

Использование приложения

При наличии локально установленного PHP необходимо клонировать репозитарий, установить пакеты, создать БД, выполнить миграции и обновить курсы валют

git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
composer install --no-dev --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console currency:update

Рассчитать конвертацию
php bin/console currency:exchange <amount> <from> <to>

Например
php bin/console currency:exchange 2 EUR BTC
должно вывести примерно
[OK] 2 EUR is 0.00005254 BTC

Также можно собрать и запустить приложение в Docker

git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
docker compose up --build

При сборке загрузятся курсы валют.

Рассчитать конвертацию
docker compose run symfony-exchange-demo currency:exchange <amount> <from> <to>

Например
docker compose run symfony-exchange-demo currency:exchange 2 EUR BTC

Результат должен быть таким же как при запуске с локальным PHP.

Тестирование

Для приложения написана пара тестов, позволяющих убедиться в правильной работе основного функционала. В тестах используется замоканый провайдер курсов валют.

\App\Tests\Command\CurrencyUpdateCommandTest - простая проверка наличия сообщений об успешной загрузке, триангуляции и обновлении курсов.

\App\Tests\Command\CurrencyExchangeCommandTest - чуть сложнее: проверка реальной конвертации при помощи dataProvider'а с несколькими парами валют и ожидаемым результатом. При каждом запуске теста производится обновление курсов валют.

Запустить тесты можно локально, доустановив dev-пакеты

cd symfony-exchange-demo
echo APP_ENV=test > .env.local
composer install --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
vendor/bin/phpunit

или аналогично в Docker

cd symfony-exchange-demo
echo APP_ENV=test > .env.local
docker compose run symfony-exchange-demo composer install --no-interaction
docker compose run symfony-exchange-demo doctrine:database:create
docker compose run symfony-exchange-demo doctrine:migrations:migrate --no-interaction
docker compose run symfony-exchange-demo vendor/bin/phpunit

Велкам в комментарии или пул-реквесты :-)

Комментарии (2)


  1. des1roer
    17.06.2022 15:45
    +2

    код слабо читаем

    class Rate
    {
        private readonly DateTimeImmutable $date;
        private readonly string $rate;
    
        public function __construct(
            public readonly string $currency,
            public readonly string $base,
            string $rate,
            string $date, 
        ) {
            $this->date = new DateTimeImmutable($date);
            $this->rate = (string)Decimal::create($rate, 8);
        }
    
        public function getDate(): DateTimeImmutable
        {
            return $this->date;
        }
    
        public function getRate(): string
        {
            return $this->rate;
        }
    }

    если уж есть модификатор readonly то ничто не мешает в dto свойства сделать public. и выглядит чище


    1. bombe
      18.06.2022 13:09

      @des1roer именно. И добавить статический фабричный метод, который будет приводить типы перед вызовом конструктора. Никаких геттеров не нужно, у нас публичные ридонли поля.

      @mvs если хочется еще по заморачиваться и челленджей для такой задачи, то подумайте о том, что будет, когда обновлять курсы надо не через HTTP. Допустим, из файла. Тогда RatesUpdater станет HttpRatesUpdater унаследованным от RatesUpdaterInterface. По факту же главное передать валидный ДТО в сервис. А откуда это ДТО пришло - всё равно.