Есть такая известная парадигма в ООП — сервис-контейнеры. Суть её в том, чтобы получать реализацию того или иного сервиса, который тебе зачем-то нужен, из некоего контейнера. То есть не задумываясь о деталях, о том, что это за класс и т.д. Обращаешься к контейнеру — а контейнер сам определит, что надо, и вернет нужный сервис. Очевидно, если заранее не знаешь, о чем речь, из такого пояснения ни фига толком не ясно. Обычно эти пояснения иллюстрируются примерами. В доках Ларавела, скажем, используется пример EventPusher-а и его реализации через Redis.
Слабое место подобных примеров, на мой взгляд, в том, что для начинающего разработчика, не очень знакомого с концепцией контейнера и dependency injection, идея замены одной реализации сервиса на другую не очень близка. В голову сразу приходит, что у меня-то сейчас один класс используется — вот этот. Когда надо будет другой, тогда и контейнер сделаю, а пока буду явно обращаться к данному классу. И это, как мне кажется, вполне нормальный ход мыслей.
Поэтому попробую привести чуть более живой (на мой взгляд, опять же) пример, связанный с тестами, показывающий, в каком случае нам понадобится замена реализации сервиса прямо сейчас, а не когда-нибудь потом.
Допустим, вы в своих проектах практикуете TDD. И у вас, помимо всех остальных тестов, проверяющих всё на свете, есть тест создания какой-нибудь модели через API Post, или просто через форму. И вот внезапно вам понадобилось подцепить на эту форму проверку reCAPTCHA против спаммеров. Реализовать саму логику работы во фронте (на Vue.js) и проверку полученного токена в бэке — нетрудно (вот пример).
А вот тест создания модели (и все связанные с этим роутом/формой тесты) — немедленно поломаются. Потому что где вы возьмете валидный ключ капчи для проверки, если он в тестовом режиме не прилетает из фронта?
Можно было бы попытаться организовать прохождение капчи для бэк-энд тестов (хотя я не совсем понял, как это именно в случае reCAPTCHA сделать). Но:
- от этого нет пользы помимо тестирования;
- у нас нет задачи тестировать гугл-капчу в каждом тесте бэкенда, который проверяет работу методов post/put у каждой защищенной капчей формы;
- для тестирования внешнего сервиса достаточно одного теста интеграции — что мы верно всё подключили — а остальное есть забота разработчика внешнего сервиса;
- если каждый тест формы будет проверять ключ капчи через сервис гугла, тесты внезапно станут значительно дольше работать.
В общем, представляется что не нужно проходить гугл-капчу на бэк-энде в тестовых целях. Вот тут-то и возникает необходимость в подмене живой капчи — фейковой, чтобы её не проходить. И это как раз неплохо иллюстрирует концепцию сервис-контейнеров и внедрения зависимостей.
Пусть у нас для проверки рекапчи есть вот такое правило валидации:
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class Recaptcha implements Rule
{
public function passes($attribute, $value)
{
$recaptcha = new RecaptchaCheck();
// в $value полученный от фронт-энда токен
return $recaptcha->check($value);
}
public function message()
{
return 'The reCaptcha token is invalid.';
}
}
Вызов метода RecaptchaCheck::check()
здесь проверяет полученный токен капчи и говорит, правильный он, или нет. Вот этот вызов мы и хотим подменить фейковым, чтобы не долбиться в гуглосервис при каждом запуске тестов, контроллеры которых используют наше новое правило.
Для подмены сначала создадим интерфейс:
namespace App\Rules;
interface RecaptchaCheck
{
/**
*
* Returns true if $token (and captcha) is valid
*
* @param string $token
*
* @return bool
*/
public function check($token);
}
Теперь сделаем, чтобы настоящий класс проверки капчи реализовывал этот интерфейс:
namespace App\Rules;
use GuzzleHttp\Client;
class LiveRecaptchaCheck implements RecaptchaCheck
{
/**
*
* Returns true if $token (and captcha) is valid
*
* @param string $token
*
* @return bool
*/
public function check($token)
{
// с деталями этой реализации можете ознакомиться в статье, приведенной выше
$client = new Client();
$response = $client->post('https://www.google.com/recaptcha/api/siteverify', [
'form_params' => [
'secret' => config('services.recaptcha.secret'),
'response' => $token,
],
]);
$data = json_decode($response->getBody(), true);
return $data['success'];
}
}
Теперь создадим фейковый класс для подмены:
namespace App\Rules;
class FakeRecaptchaCheck implements RecaptchaCheck
{
/**
*
* Returns true if $token (and captcha) is valid
*
* @param string $token
*
* @return bool
*/
public function check($token)
{
return ($token == 'Valid reCaptcha token');
}
}
Как мы видим, метод check()
фейкового класса будет возвращать TRUE
для строки 'Valid reCaptcha token'
и FALSE
для любых других строк.
Теперь сделаем так, чтобы наше правило валидации получало сервис проверки из контейнера:
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class Recaptcha implements Rule
{
/** @var RecaptchaCheck */
protected $recaptcha;
public function __construct()
{
// getting reCaptcha check service
$this->recaptcha = resolve(RecaptchaCheck::class);
}
public function passes($attribute, $value)
{
return $this->recaptcha->check($value);
}
public function message()
{
return 'The reCaptcha token is invalid.';
}
}
Обратите внимание, что для получения сервиса явно используется хелпер resolve()
. Он нужен, потому что при использовании в валидаторе (как будет показано ниже) мы добавляем правило вручную через new
. Соответственно автоматический резолвер зависимостей в конструкторе здесь не сработает и ничего не подставит. Придётся вручную вставлять объект нужного класса, а этого мы как раз не хотим.
Чтобы контейнер мог зарезолвить наш интерфейс в объект нужного класса, не забудем добавить запись в сервис-провайдер:
class AppServiceProvider extends ServiceProvider
{
// ...
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->bind(RecaptchaCheck::class, LiveRecaptchaCheck::class);
}
}
А использовать созданное правило в валидаторе можно так:
$request->validate([
// ...
'recaptcha_token' => ['required', new Recaptcha],
]);
где recaptcha_token
— токен, переданный в запросе из фронт-энда (полученный, очевидно, от API рекапчи).
Вот так мы можем подменить реальный класс проверки тестовым:
protected function setUp() {
parent::setUp();
$this->app->bind(RecaptchaCheck::class, FakeRecaptchaCheck::class);
}
Теперь все тесты, реализующие (или унаследовавшие) такой метод setUp()
будут вместо живой проверки капчи использовать фейковую.
Например, вот так фейковая проверка в тесте не пройдет валидацию:
/**
* @test
*/
public function invalid_recaptcha_token_results_in_validation_error() {
$response = $this->json('POST', '/api/my-form', [
'recaptcha_token' => 'Invalid reCaptcha token',
]);
$response->assertStatus(422); // validation error
$this->assertArrayHasKey('recaptcha_token', $response->decodeResponseJson()['errors']);
}
А вот такая — пройдет:
/**
* @test
*/
public function valid_recaptcha_token_passes_validation() {
$response = $this->json('POST', '/api/my-form', [
// other required fields...
'recaptcha_token' => 'Valid reCaptcha token',
]);
$response->assertStatus(200);
}