Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Библиотека для внедрения зависимостей (PSR-11)

Реализация стандарта PSR-11 для работы с контейнером зависимостей в PHP.

🛠 Как это работает

  1. Конфигурация
    Большая часть зависимостей конфигурируется автоматически с помощью autowiring в constructor.
    Вы конфигурируете по минимуму лишь в исключительных случаях и переиспользуете эту конфигурацию дальше.

  2. Build - генерация На основе вашей конфигурации библиотека создаёт PHP-класс, реализующий Psr\Conainer\ContainerInterface.

  3. Использование
    Вы используете сгенерированный класс у себя в проекте для разрешения зависимостей.

Если в конфигурации есть ошибки или зависимости не могут быть разрешены - библиотека сообщит об этом на этапе build.

✅ Преимущества

  • Простота
    Работаете с готовым классом через стандартный интерфейс.

  • Оптимизация
    Не требует дополнительного кэширования - всё работает с opcache.

  • Скорость Нет runtime-накладных расходов: контейнер готов к использованию.
    Все накладные расходы только на этапе build.

  • Гибкость
    Можно генерировать код при деплое, во время сборки или при первом запросе.
    Вы сами определяете как вам удобней.

⚠️ Что стоит учесть

  • Требуется сборка
    Нужно организовать этап генерации кода, что необычно для типичного PHP-проекта.

Начало работы

Пример проекта

Давайте рассмотрим простейший проект у которого все исходники в src и namespace App с классами Example и A (зависимости, чтобы продемонстрировать autowiring в конструктор).

src/Example.php

<?php

declare(strict_types=1);

namespace App;

class Example {
    public function __construct(
        private A $a,
    ) {
    }
}

src/A.php

<?php

declare(strict_types=1);

namespace App;

class A {
}

index.php - Usage (Использование)

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$example = new \App\Example(new \App\A());
var_dump($example);

Естественно будет настроенна автозагрузка классов с помощью composer и psr4.

composer.json

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Установка

composer require cekta/di

Минимальная настройка проекта.

1. Создаем скрипт

bin/build.php

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';

file_put_contents(
    $filename,
    (new \Cekta\DI\ContainerBuilder(
        fqcn: $fqcn,
        entries: [\App\Example::class],
        // you configuration here, like entries, params, alias, etc.
    ))->build()
);

В этом скрипте вы можете осуществлять основную конфигурацию.

2. Генерируем Container (build)

php bin/build.php

Эту команду мы будем запускать каждый раз когда изменится наши зависимости и мы захотим актуализировать наш Container.

3. Используем Container

В вашей основной точке входа теперь все зависимости создаем через наш созданный контейнер

index.php

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$params = []; // you current params
$container = new \App\Container($params);
var_dump($container->get(\App\Example::class));

⚠️ Если какие-то параметры ИСПОЛЬЗОВАЛИСЬ во время build, то эти параметры необходимо передать при создании Container.

Использовались это не значит что были объявлены, а значит что реально были использованы для разрешения entries, такие параметры запоминаются.

Container гарантирует все что было указано в entries на этапе build будет доступно при использовании.

Полезные рекомендации.

Получение параметров из одного места.

Так как параметры нужны как на этапе build так и во время использования, лучше чтобы они генерировались в одном месте и их можно было получать в разных местах.

src/Config.php

<?php

declare(strict_types=1);

namespace App;

class Config
{
    public function __construct(private readonly array $env = []) 
    {
    }
    
    public function load(): array 
    {
        $json = [];
        $config = __DIR__ '/../config.json';
        if (file_exists($config)) {
            $json = json_decode(file_get_contents($config), true);
        }
        return [
            'db_username' => $this->env['DB_USERNAME'] ?? $json['db']['username'] ?? 'default username',
            // etc
        ];
    }
}

Наличие такого конфига решает 2 основные проблемы:

  1. Позволяет получать параметры на этапе build и usage.
  2. Позволяет управлять конфигурацией проекта.

Для примера реализованная простейшая конфигурация, параметр db_username либо берется из env DB_USERNAME если там задан, в противном случае он читается из конфигурационного файла в формате json, в остальных случаях используется значение по умолчанию.

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

Сгенерированные файлы в отдельной папке

Лучше не мешать файлы что пишутся людьми с файлами что были сгенерированными скриптами, например для сгенерированных файлов можно создать папку runtime в корне с проектом.

mkdir runtime

Внутри этой папки можно разместить readme.md что это для сгенерированных файлов, чтобы папка с этим файлом была в git.

echo "# For generated files" > runtime/readme.md
git add runtime/readme.md

Можно выделить отдельный namespace, например App\Runtime\ для сгенерированных файлов.

composer.json:

{
  "autoload": {
    "psr-4": {
      "App\\": "src/",
      "App\\Runtime\\": "runtime/"
    }
  }
}

Сгенерированные файлы в .gitignore

Нет смысла добавлять сгенерированные файлы в систему контроля версий (git), лучше их внести в .gitignore чтобы они случайно не добавились

.gitignore

runtime # в случае если сгенерированные файлы в отдельной папке (предварительно добавленный readme.md останется)
src/Container.php # в случае минимальной конфигурации

Skeleton для проектов.

Имеется проект cekta/skeleton в котором сложенны лучшие практик, в том числе по cekta/di.

Вы можете использовать данный проект для ваших новых проектов или посмотреть лучшие применения в нем.

Конфигурация ContainerBuilder

Основная конфигурация хранится, в скрипте по генерации контейнера.

bin/build.php

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';

file_put_contents(
    $filename,
    (new \Cekta\DI\ContainerBuilder(
        fqcn: $fqcn,
        entries: [\App\Example::class],
        // you configuration here, like entries, params, alias, etc.
    ))->build()
);

Здесь вы можете задать следующие параметры

FQCN

Полное имя класса (namespace + class_name). Из FQCN эти параметры будут извлечены.

FILENAME

Полный путь до файла где он будет сохранен Container.

Крайне желательно чтобы файл располагался вместе где он будет доступен с помощью автозагрузки классов composer, с учетом его fqcn и физического расположения.

entries

Массив строк, где каждая строка это основная точки входа которая должна быть разрешима с помощью Container.

На этапе build, Container использует эти зависимости и пытается их разрешить с помощью конфигурации (других параметров) или autowiring в конструктор.

Вы можете сгенерировать список этих точек входа, на основе содержимого вашего проекта, например все классы реализующие Psr\Http\Server\RequestHandlerInterface или имеющие php attribute.

params

Основные параметры которые можно внедрять в зависимости.

params: [
    'Имя зависимости в локальном или глобальном именование' => 'Значение',
    \PDO::class . '$dsn' => 'sqlite:./db.sqlite', // локальное имя, только для PDO аргумент с именем dsn будет иметь значение ...
    'username' => 'root', // глобальное имя, всем кому потребуется username, будет использовано это значение.
]

Параметры можно задавать глобально и локально.
Рекомендую задавать параметры для конкретной зависимости(локально).

Например можно задавать dsn, username, password и тд.

Подробней

alias

С помощью данного параметра можно заменить одну зависимость на другую.

alias: [
    I::class => R2::class, // Глобальное имя
    // Есть возможность задавать и локальные имена!
]

Вместо I::class будет использоваться R2::class, например так можно регистрировать реализацию интерфейса для всего проекта.

Alias можно задавать глобально и локально.
Чаще всего их задают глобально для всего проекта.

Подробней

singletons

Позволяет управлять жизненным циклом зависимостей.

В singletons указываются зависимости, которые должны иметь жизненный цикл singleton, а именно создаваться в единичном экземпляре за все время жизни скрипта.

factories

Позволяет управлять жизненным циклом зависимости.

В factories указываются зависимости, которые должны иметь жизненный цикл factories, а именно создается каждый раз новый экземпляр.

Params (Параметры)

Параметры позволяют задавать конкретные значения для аргументов зависимостей:

  • Встроенные типы (string, int, array и т.д.)
  • Переопределять значение по умолчанию
  • Конкретные экземпляры объектов (например если вы создали $logger вы можете использовать его и дальше)

Некоторые параметры могут разрешаться в момент использования, а не во время build Container, такие параметры называются Lazy (ленивые). С помощью таких параметров можно реализовать свою callback функцию, которая будет генерировать зависимость в особо сложных случаях. Подробней о Lazy (ленивых) параметрах.

Пример

src/Example.php

<?php

namespace App;

class Example
{
    public function __construct(public string $username, public string $password){}
}

bin/build.php

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';

file_put_contents(
    $filename,
    (new \Cekta\DI\ContainerBuilder(
        fqcn: $fqcn,
        entries: [\App\Example::class],
        params: [
            'username' => 'some username', // глобальное имя, всем кому потребуется username, будет использовано это значение.
            \App\Example::class . '$password' => 'some password', // локальное имя, только для Example, аргумент с именем password будет иметь значение ...
        ]
    ))->build()
);

Параметры можно задавать глобально и локально.
Рекомендую задавать параметры для конкретной зависимости(локально).

Генерируем Container - build

php bin/build.php

index.php - usage

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$params = []; // you current params
$container = new \App\Container([
    'username' => 'actual username',
    \App\Example::class . '$password' => 'actual password',
]);
$example = $container->get(\App\Example::class);

assert($example->username === 'actual username');
assert($example->password === 'actual password');

Обратите внимание что используется актуальное значение параметра которое передается при создании контейнера, а не то что было на этапе build.

Параметры, что использовались на этапе build считаются обязательными для создания Container, если вы их не передадите, получите соответствующее исключение.

Использовались != были объявлены. Использовались это значит они применялись для разрешения entries.

Alias (Псевдонимы)

Механизм псевдонимов позволяет заменять одну зависимость другой. Это полезно для:

  1. Выбора реализации интерфейса - указание, какой конкретный класс использовать для интерфейса.
  2. Выбора наследника абстрактного класса - определение конкретной реализации абстрактного класса.
  3. Замены зависимостей - использование подклассов вместо родительских классов.
  4. Сокращения имен - создание коротких псевдонимов для длинных имен классов

Пример

src/Example.php

<?php

namespace App;

class Example {
    public function __construct(
        public I $i,
        public Base $base,
    ) {}
}

src/I.php

<?php

namespace App;

interface I {}

src/R2.php

<?php

namespace App;

class R2 implements I {}

src/Base.php

<?php

namespace App;

class Base {}

src/E1.php

<?php

namespace App;

class E1 extends Base {}

bin/build.php - build

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';

file_put_contents(
    $filename,
    (new \Cekta\DI\ContainerBuilder(
        fqcn: $fqcn,
        entries: [\App\Example::class],
        alias: [
            I::class => R2::class,      // Для I используем R2
            Base::class => E1::class,   // Для Base используем E1
    ],
    ))->build()
);

Генерируем Container

php bin/build.php

index.php - использование (use)

<?php
declare(strict_types=1);

namespace App;

$container = new \App\Container([]);
$example = $container->get(Example::class);

$example->i instanceof R2;      // true
$example->base instanceof E1;   // true

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

🕐 Lazy-параметры (отложенные значения)

Некоторые параметры могут разрешаться в момент использования, а не во время build Container, такие параметры называются Lazy (ленивые). С помощью таких параметров можно реализовать свою callback функцию, которая будет генерировать зависимость в особо сложных случаях:

  1. Нужно внедрять зависимости не только в конструктор, но и например в методы после создания объекта.
  2. Нужно получить текущий Container, для различных service locator которым требуется текущая реализация ContainerInterface.
  3. Можно генерировать различные значения на основе других, например dsn строку подключения к бд, на основе db_type, db_host, db_name и тд.

Если параметр реализует Lazy interface, то он будет загружаться в runtime.

Пример внедрения зависимостей через метод.

Может быть ситуация когда требуется внедрить зависимость не через конструктор, а через метод или другими более сложными способами.

src/Example.php

<?php

namespace App;

class Example
{
    private \PDO $connection;

    public function setPdo(\PDO $connection)
    {
        // inject via method
        $this->connection = $connection;
    }
}

src/Config.php

<?php

namespace App;

class Config
{
    public function __construct(private array $env)
    {
    }

    public function __invoke(): array
    {
        return [
            \App\Example::class => new \Cekta\DI\Lazy\Closure(
                function (\Psr\Container\ContainerInterface $c) {
                    $example = new \App\Example();
                    $example->setPdo($c->get(\PDO::class));
                    return $example;
                }
            ),
            \PDO::class . '$dsn' => $this->env['DB_DSN'] ?? 'sqlite:./db.sqlite',
        ];
    }
}

bin/build.php

<?php

require_once __DIR__ . '/../vendor/autoload.php';

(new \Cekta\DI\ContainerBuilder(
    entries: [\App\Example::class, \PDO::class],
    fqcn: '\App\Container',
    params: (new \App\Config(getenv() + $_ENV))(),
))->build();

index.php

<?php

reqire_once __DIR__ . '/vendor/autoload.php';

$container = new \App\Container((new Config(getenv() + $_ENV))());
var_dump($container->get(\App\Example::class));

\Cekta\DI\Lazy\Closure использую callback функцию создает любое значение. Вы можете задать любую callback функцию и внедрять любым способом.

Вы можете управлять жизненном циклом данных зависимостей.

Рекомендация:

  1. Если параметры используются внутри LazyClosure, добавьте их в entries для гарантии доступности.
  2. Передавайте зависимости через конструктор.

Пример передачи ContainerInterface в Service Locator.

Service Locator для своей работы может требовать ContainerInterface, чтобы разрешать зависимости.

src/Example.php

<?php

namespace App;

class Example
{
    public function __construct(private \Psr\Container\ContainerInterface $container) {
    }
}

src/Config.php

<?php

namespace App;

class Config
{
    public function __construct(private array $env)
    {
    }

    public function __invoke(): array
    {
        return [
            \Psr\Container\ContainerInterface::class => new \Cekta\DI\Lazy\Container(),
        ];
    }
}

bin/build.php

<?php

require_once __DIR__ . '/../vendor/autoload.php';

(new \Cekta\DI\ContainerBuilder(
    entries: [\App\Example::class],
    fqcn: '\App\Container',
    params: (new \App\Config(getenv() + $_ENV))(),
))->build();

index.php

<?php

reqire_once __DIR__ . '/vendor/autoload.php';

$container = new \App\Container((new Config(getenv() + $_ENV))());
var_dump($container->get(\App\Example::class));

Внедрить текущую реализацию Container можно с помощью Lazy параметра \Cekta\DI\Lazy\Container.

Управление жизненным циклом зависимостей

Библиотека предоставляет три типа жизненного цикла для зависимостей, что особенно полезно в долгоживущих процессах:

  • Приложениях на RoadRunner или FrankenPHP
  • Фоновых workers
  • В консольных командах, обрабатывающих множество задач

Вы можете управлять жизненным циклом любых зависимостей: entries, params, alias и autowiring.

📋 Демонстрация разницы.

<?php
declare(strict_types=1);

namespace App;

class Scoped {}
class Singleton {}
class Factory {}

new \Cekta\DI\ContainerBuilder(
    entries: [
        \App\Scoped::class,
        \App\Singleton::class,
        \App\Factory::class,
    ],
    fqcn: 'App\\Container',
    singletons: [\App\Singleton::class],   // Singleton-зависимости
    factories: [\App\Factory::class],      // Factory-зависимости
    // Scoped-зависимости не указываются явно (используются по умолчанию)
)->build();

index.php - Usage (Использование)

<?php
declare(strict_types=1);

namespace App;

function testLifecycle(string $className) {
    $container1 = new \App\Container();
    $container2 = new \App\Container();
    
    $a = $container1->get($className);
    $b = $container1->get($className);  // Второй запрос к тому же контейнеру
    $c = $container2->get($className);  // Запрос к другому контейнеру
    
    echo "Внутри одного контейнера: " . ($a === $b ? "одинаковый" : "разный") . "\n";
    echo "Между разными контейнерами: " . ($a === $c ? "одинаковый" : "разный") . "\n";
    echo "---\n";
}

echo "Scoped (по умолчанию):\n";
testLifecycle(Scoped::class);

echo "Singleton:\n";
testLifecycle(Singleton::class);

echo "Factory:\n";
testLifecycle(Factory::class);

Результат:

Scoped (по умолчанию):
Внутри одного контейнера: одинаковый
Между разными контейнерами: разный
---
Singleton:
Внутри одного контейнера: одинаковый
Между разными контейнерами: одинаковый
---
Factory:
Внутри одного контейнера: разный
Между разными контейнерами: разный

🎯 Сравнение жизненных циклов

ТипВнутри одного контейнераМежду разными контейнерамиКогда использовать
Scoped ⭐Один объектРазные объектыОбработка запросов, пользовательские сессии
SingletonОдин объектОдин объектКонфигурация, подключения к БД, кеши
FactoryРазные объектыРазные объектыStateless-сервисы, DTO, временные данные

⚠️ Важные замечания

  1. Scoped по умолчанию - если не указан другой тип, используется Scoped
  2. Конфликты приоритетов - нельзя указать один класс одновременно как Singleton и Factory
  3. Производительность - Factory создаёт наибольшую нагрузку, Singleton - наименьшую
  4. Потокобезопасность - Singleton должен быть потоко-безопасным в многопоточных средах

🚀 Пример для долгоживущего приложения

<?php
new \Cekta\DI\Compiler(
    entries: [
        HttpController::class,
        UserRepository::class,
        EmailService::class,
    ],
    singletons: [
        Database::class,           // Одно подключение
        RedisCache::class,         // Общий кеш
        Config::class,             // Конфигурация
    ],
    factories: [
        HttpRequest::class,        // Новый для каждого запроса
        UserSession::class,        // Новый для каждого пользователя
    ],
);

Правильное управление жизненным циклом позволяет:

  • Экономить ресурсы (Singleton)
  • Изолировать данные (Scoped)
  • Предотвращать утечки памяти (Factory)
  • Легко масштабировать приложение

Как разрешается зависимости

Во время build, берется каждая зависимость указанная в entries и пытаются построить полную карту зависимостей любой вложенности.

Построение карты зависимостей осуществляется только на этапе build, в дальнейшем используется уже созданная структура зависимостей.

Разрешение зависимости

Если зависимость указана в params или alias, то она разрешается согласно данной инструкции. В остальных случаях пытаемся сделать autowiring в конструктор.

Autowiring в конструктор.

Механизм с помощью которого анализируется аргументы конструктора класса.

В случае если вы делаете autowiring в несуществующий класс или экземпляр класса нельзя создать (абстрактный класс), то будет выброшено исключение с деталями проблемы.

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

Если для конкретного аргумента конкретной зависимости указаны конфигурация как создавать эти его создавать с помощью params или alias, то используются эта конфигурация.

Если аргумент имеет значение по умолчанию, то используется значение указанное по умолчанию.

В остальных случаях каждая найденная зависимость разрешается сначала.

Приоритет загрузки зависимостей.

Библиотека предоставляет несколько способов определения зависимостей. Важно понимать, в каком порядке они применяются - это помогает избежать неожиданного поведения и правильно настроить контейнер.

<?php

namespace App;

class Example
{
    public function __construct(
        public \stdClass $std_class,
    ) {
    
}
}

$class1 = new \stdClass();
$class1->id = 1;

$class2 = new \stdClass();
$class2->id = 2;

$class3 = new \stdClass();
$class3->id = 3;

$class4 = new \stdClass();
$class4->id = 4;

(new Cekta\DI\ContainerBuilder(
    entries: [Example::class],
    params: [
        Example::class . '$' . \stdClass::class => $class1, 
        \stdClass::class => $class3,
        'class2' => $class2,
        'class4' => $class4,
    ],
    alias: [
        Example::class . '$' . \stdClass::class => 'class2',
        \stdClass::class => 'class4',
    ],
))->build();

При запросе Example::class контейнер ищет зависимость в следующем порядке:

ПриоритетСпособВ нашем примере
1Локальное имя из paramsExample::class . '$' . \stdClass::class резульатат $class1
2Локальное имя из aliasExample::class . '$' . \stdClass::class резульатат $class2
3Глобальное имя из params\stdClass::class результат $class3
4Глобальное имя из alias\stdClass::class результат $class4
5Значение по умолчаниюзначение по умолчанию не заданно в конструкторе и будет пропущенно
6Autowiring в конструкторСоздает \stdClass через Autowiring

Как определяется имя зависимости

Как формируются имена зависимостей

Лучше рассмотреть конкретный пример.

<?php
namespace App;

class Example
{
    public function __construct(
        int $arg_1,
        Foo $arg_2,
        string|int $arg_3,
        int|string $arg_4,
        Foo|string $arg_5,
        string|Foo $arg_6,
        ?int $arg_7,
        ?Foo $arg_8,
        $arg_9,
        I $arg_10
        Foo|int ...$arg_11,
    ) {
    }
}

class Foo {}

interface I {}
Номер аргументаИмя аргументаГлобавльное имяЛокальное имя
1arg_1“arg_1”“App\Example$arg_1
2arg_2“App\Foo”“App\Example$arg_2”
3arg_3“string|int”“App\Example$arg_3”
4arg_4“string|int”“App\Example$arg_4”
5arg_5“App\Foo|string”“App\Example$arg_5”
6arg_6“App\Foo|string”“App\Example$arg_6”
7arg_7“arg_7”“App\Example$arg_7
8arg_8“?App\Foo”“App\Example$arg_8”
9arg_9“arg_9”“App\Example$arg_9”
10arg_10“App\I”“App\Example$arg_10”
11arg_11“…App\Foo|int”“…App\Example$arg_11”

Имя аргумента != имени зависимости

Необходимо разделять имя аргумента и имя зависимости, это разные вещи.

Имя зависимости это более сложная вещь, которая может в себя включать имя аргумента.

Глобальное vs Локальное имена зависимостей.

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

Локальное имя это возможность переопределить зависимость в конкретном ОДНОМ месте, точечно.
С помощью таких имен удобно задавать примитивные параметры вроде логинов или паролей и тд.

Правила формирования локальных имен.

В общем виде именование выглядит следующим образом:

$prefix . $fqcn . '$' . $argument_name

$prefix = '...' если аргумент является variadic, в остальных случаях $prefix = ''.

$fqcn - это полное имя куда внедряется зависимость.

$argument_name это имя аргумента.

Правила формирования глобальных имен.

Если у аргумента не указан тип или он является primitive (string, int, bool, array и тд), то:

$prefix . $argument_name

В остальных случаях:

$prefix . (string)$type

$prefix = '...' если аргумент является variadic, в остальных случаях $prefix = ''.

$argument_name - имя аргумента.

Самое интересное тут как PHP приводит тип к строке.

Если тип является nullable то в начале ему указывается ?.

Если тип является составным (Union, или Intersection, или DNF) тогда тип сортируется в “определенной” последовательности, мало кому известной и нигде не зафиксированной.

В приведенном выше примере посмотрите глобальные имена для аргумента 3 и 4, в конструкторе порядок у них разный, а php приводит их к одинаковому имени, а также посмотрите на аргумент 5 и 6 из таблицы, ситуация аналогичная.

Поддержка

Если у вас остались вопросы по текущей документации, или есть проблема с настройкой в вашем проекте вы можете:

  1. Оставить issue https://github.com/cekta/di/issues с вашим вопросом на EN или RU языке.
  2. Русскоязычный чат https://t.me/dev_ru

Если есть и предложения по улучшению, можно создавать issue или озвучивать их чате.

Проект открыт для приема MR по улучшению!!!