Jak napisać dobry kontroler? Jeden z najczęstszych błędów na przykładzie Laravela
Każdy uczy się na błędach. W tym artykule wskażę na jeden, ale za to kardynalny, który prowadzi do sytuacji, kiedy kontroler zawiera kilkaset linii kodu i staje się nieczytelny oraz bardzo trudny w utrzymaniu. Przedstawię jednak kilka sposobów jego rozwiązania.
Realizując w Droptica usługi PHP developmentu wielokrotnie spotykałem się z tym błędem, a nawet sam na początku go popełniałem. O czym mówię? O umieszczaniu całej logiki w metodach kontrolera bez separacji poszczególnych działań na odrębne komponenty w aplikacji.
Aby tego uniknąć należy kierować się dwiema głównymi zasadami:
- Możemy dysponować nieograniczoną liczbą kontrolerów. Lepiej mieć ich wiele, które zawierają niewiele metod, niż jeden kontroler zawierający wiele metod.
- Kontroler odpowiada tylko za ogólną logikę. Kierując się zasadą od ogółu do szczegółu, w kontrolerze powinna znajdować się część ogólna. Szczegóły natomiast powinny zostać umieszczone w innych komponentach aplikacji. Im mniej kodu w kontrolerze tym lepiej.
Spójrzmy zatem na poniższy przykład:
class CustomersController extends Controller
{
public function getActiveCustomers()
{
return Customer::where('active', 1)->get();
}
public function store()
{
$data = $this->validateRequest();
$customer = Customer::create($data);
// Send notification email to admin.
Mail::to('[email protected]')->send(new NewCustomerMail($customer));
// Send welcome mail to customer
Mail::to($customer->email)->send(new WelcomeMail($customer));
}
public function getQueryResults()
{
$customers = Customer::query();
if (request()->has('name')) {
$customers->where('name', request('name'));
}
if (request()->has('active')) {
$customers->where('active', request('active'));
}
if (request()->has('sort')) {
$customers->orderBy('name', request('sort'));
}
$customers->get();
return $customers;
}
private function validateRequest()
{
// Request validation code goes here…
}
}
Na pierwszy rzut oka może wydawać się, że wszystko jest jak trzeba. Kontroler zawiera trzy metody. Pierwsza z nich: zwraca aktywnych klientów, druga: tworzy nowego klienta oraz wysyła wiadomości email z powiadomieniami, trzecia natomiast odpowiada za filtrowanie wyników wyszukiwania. Dlaczego zatem ten przykład został przedstawiony jako nieprawidłowy?
CRUD
Zacznijmy od pierwszego przykładu. Zgodnie z dokumentacją Laravela kontroler powinien zawierać metody index, create, store, show, edit, update i/lub oraz delete. Należy zatem zmienić nazwę metody na którąś z powyższych. Jednak aby to miało sens, musimy zastosować się do pierwszej reguły. Stwórzmy zatem zupełnie nowy kontroler ActiveCustomersController i tam przenieśmy metodę getActiveCustomers, jednocześnie zmieniając jej nazwę na index.
class ActiveCustomersController extends Controller
{
public function index()
{
return Customer::where('active', 1)->get();
}
}
Pamiętaj, że jeśli Twój kontroler zawiera tylko jedną metodę, w Laravelu możesz zmienić nazwę tej metody na __invoke().
class ActiveCustomersController extends Controller
{
public function __invoke()
{
return Customer::where('active', 1)->get();
}
}
To nie jest zadanie kontrolera
Zajmijmy się teraz następną metodą: store(). Jej nazwa jest prawidłowa i kontrolera również. Gdzie zatem ukrywa się błąd?
W tym przykładzie po utworzeniu nowego klienta wysyłane są dwie wiadomości email (do klienta oraz administratora). Nie jest tego dużo, ale wyobraźmy sobie, co będzie, jeśli w przyszłości będziemy chcieli dodać do tego jeszcze powiadomienie na Slacku oraz wysłanie smsa (np. z kodem weryfikacyjnym). Ilość kodu znacznie się zwiększy, co spowoduje złamanie drugiej zasady. Powinniśmy zatem poszukać innego miejsca w naszej aplikacji, które będzie odpowiedzialne za wysyłanie powiadomień. Znakomicie do tego celu sprawdzą się eventy.
Zacznijmy więc od uruchomienia eventu, który zastąpi nam kod odpowiedzialny za wysyłanie powiadomień:
public function store()
{
$data = $this->validateRequest();
$customer = Customer::create($data);
event(new NewCustomerHasRegisteredEvent($customer));
}
Następnie stwórzmy ten event wpisując w konsoli:
php artisan make:event NewCustomerHasRegisteredEvent
Dzięki tej komendzie zostanie utworzona nowa klasa NewCustomerHasRegisteredEvent w katalogu app/Events. Ponieważ w kontrolerze przekazujemy za pomocą parametru utworzonego klienta., powinniśmy w klasie z eventem przyjąć te dane w konstruktorze.
Całość powinna wyglądać w ten sposób:
class NewCustomerHasRegisteredEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $customer;
public function __construct($customer)
{
$this->customer = $customer;
}
}
Następnie musimy utworzyć listenery, które będą wykonywać odpowiednią akcję (w tym przypadku wysyłka wiadomości email), jeśli event zostanie uruchomiony. Ponownie wpiszmy w konsoli:
php artisan make:listener WelcomeNewCustomerListener
Tym razem powinna zostać utworzona nowa klasa WelcomeNewCustomerListener w katalogu app/Listeners. Jedyne co musimy zrobić w tym przypadku, to umieścić kod odpowiedzialny za wysłanie maila w metodzie handle(). Metoda ta przyjmuje parametr $event, który będzie zawierał dane klienta przekazane w konstruktorze klasy NewCustomerHasRegisteredEvent.
class WelcomeNewCustomerListener
{
public function handle($event)
{
Mail::to($event->customer->email)->send(new WelcomeMail($event->customer));
}
}
Spróbujcie teraz sami stworzyć jeszcze jeden listener odpowiedzialny za wysłanie wiadomości email do administratora, nazywając go np.: NewCustomerAdminNotificationListener.
Mamy stworzony event oraz listenery. Na koniec musimy spiąć wszystko razem tak, aby odpowiednie listenery uruchamiały się podczas wywołania odpowiedniego eventu. W tym celu należy dodać do tablicy $listen w klasie App\Providers\EventServiceProvider nasz event oraz listenery:
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
NewCustomerHasRegisteredEvent::class => [
WelcomeNewCustomerListener::class,
NewCustomerAdminNotificationListener::class,
],
];
}
Pipelines
Ostatni, trzeci przykład na dziś, będzie nieco inny. Wiemy już na podstawie pierwszego przykładu, że w tym przypadku powinniśmy zacząć od stworzenia nowego kontrolera np. CustomersQueryController, jednak na tym nie poprzestaniemy. Kod zawarty w metodzie nie jest zły, jednak można to zrobić lepiej. W dodatku wykorzystamy funkcję, która w Laravelu jest dość często stosowana, a jednak w oficjalnej dokumentacji nie ma na ten temat zbyt wiele informacji.
Pipelines to nic innego jak seria kroków/zadań, które chcemy wykonać jeden po drugim. Znakomicie sprawdzi się to w naszym przypadku, gdzie w zależności od tego jakie dane chce otrzymać użytkownik, musimy przejść przez serię kroków, aby zbudować za pomocą Eloquent odpowiednie zapytanie.
Zacznijmy zatem od przeniesienia metody getQUeryResults do osobnego kontrolera oraz zmiany nazwy:
class CustomersQueryController extends Controller
{
public function index()
{
$customers = Customer::query();
if (request()->has('name')) {
$customers->where('name', request('name'));
}
if (request()->has('active')) {
$customers->where('active', request('active'));
}
if (request()->has('sort')) {
$customers->orderBy('name', request('sort'));
}
$customers->get();
return $customers;
}
}
Następnie w katalogu app stwórzmy nowy katalog QueryFitlers, a w nim trzy klasy: name, active, sort. Każda dla jednego parametru wyszukiwania. Klasa ta będzie zawierała jedną metodę handle(), która będzie przyjmować dwa parametry: $request oraz $next. W dalszej części będę przedstawiał jako przykład klasę active. Dla nabrania wprawy spróbujcie pozostałe dwie klasy przygotować sami.
class Active
{
public function handle($request, \Closure $next)
{
}
}
W metodzie handle powinniśmy umieścić warunek odwrotny, do tego który dotychczas znajdował się w kontrolerze. Jeśli request nie zawiera ‘active’ to przejdź do następnego kroku w pipeline. W przeciwnym przypadku dodaj do buildera odpowiedni warunek i przejdź dalej:
public function handle($request, \Closure $next)
{
if (!request()->has('active')) {
return $next($request);
}
$builder = $next($request);
return $builder->where('active', request('active'));
}
Kiedy już przygotujemy analogicznie pozostałe dwie klasy, pozostaje nam już tylko spiąć wszystko razem. Wróćmy zatem do kontrolera i przepuśćmy wszystkie w/w klasy przez pipeline:
public function index()
{
$customers = app(Pipeline::class)
->send(Customer::query())
->through([
\App\QueryFilters\Active::class,
\App\QueryFilters\Name::class,
\App\QueryFilters\Sort::class,
])
->thenReturn();
return $customers->get();
}
Podsumowanie
W tym artykule przedstawiłem trzy sposoby na wydzielenie logiki z kontrolera. Oczywiście nie musicie ograniczać się tylko do nich - sposobów jest dużo więcej. Możecie o tym poczytać w artykule na temat przydatnych funkcjach Laravela, o których nie każdy wie.
Pamiętajcie zatem, aby w kontrolerze trzymać możliwie jak najmniej kodu. Kod ten powinien zawierać tylko ogólną logikę. Wszelkie bardziej szczegółowe oraz zagmatwane elementy powinniście umieścić osobnym miejscu. I zawsze zadawajcie sobie pytanie: Czy aby napewno to jest zadanie dla kontrolera?