Здравствуйте.
Недавно довелось делать тестовое задание на 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
Велкам в комментарии или пул-реквесты :-)
des1roer
код слабо читаем
если уж есть модификатор readonly то ничто не мешает в dto свойства сделать public. и выглядит чище
bombe
@des1roer именно. И добавить статический фабричный метод, который будет приводить типы перед вызовом конструктора. Никаких геттеров не нужно, у нас публичные ридонли поля.
@mvs если хочется еще по заморачиваться и челленджей для такой задачи, то подумайте о том, что будет, когда обновлять курсы надо не через HTTP. Допустим, из файла. Тогда RatesUpdater станет HttpRatesUpdater унаследованным от RatesUpdaterInterface. По факту же главное передать валидный ДТО в сервис. А откуда это ДТО пришло - всё равно.