• Czas czytania ~14 min
  • 06.06.2022

Jednym z najważniejszych pytań Laravela, które słyszę, jest „Jak uporządkować projekt”. Jeśli zawęzimy to, największa część brzmi jak „Jeśli logika nie powinna znajdować się w kontrolerach, to gdzie powinniśmy ją umieścić?”

Problem polega na tym, że nie ma jednej poprawnej odpowiedzi na takie pytania. Laravel daje ci swobodę samodzielnego wyboru struktury, co jest zarówno błogosławieństwem, jak i przekleństwem.Nie znajdziesz żadnych zaleceń w oficjalnych dokumentach Laravela, więc spróbujmy omówić różne opcje na podstawie jednego konkretnego przykładu.

Uwaga: ponieważ nie ma jednego sposobu na uporządkowanie projektu, ten artykuł będzie pełen uwag pobocznych, „co jeśli” i podobnych akapitów.Radzę ich nie pomijać i przeczytać cały artykuł, aby mieć świadomość wszystkich wyjątków od najlepszych praktyk.

Wyobraź sobie, że masz metodę kontrolera do rejestrowania użytkowników, która wykonuje wiele rzeczy:

public function store(Request $request)
{
    // 1. Validation
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);
 
    // 2. Create user
    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);
 
    // 3. Upload the avatar file and update the user
    if ($request->hasFile('avatar')) {
        $avatar = $request->file('avatar')->store('avatars');
        $user->update(['avatar' => $avatar]);
    }
 
    // 4. Login
    Auth::login($user);
 
    // 5. Generate a personal voucher
    $voucher = Voucher::create([
        'code' => Str::random(8),
        'discount_percent' => 10,
        'user_id' => $user->id
    ]);
 
    // 6. Send that voucher with a welcome email
    $user->notify(new NewUserWelcomeNotification($voucher->code));
 
    // 7. Notify administrators about the new user
    foreach (config('app.admin_emails') as $adminEmail) {
        Notification::route('mail', $adminEmail)
            ->notify(new NewUserAdminNotification($user));
    }
 
    return redirect()->route('dashboard');
}

Dokładnie siedem rzeczy. Pewnie wszyscy się zgodzicie, że to za dużo jak na jedną metodę kontrolera, musimy rozdzielić logikę i gdzieś przenieść części. Ale gdzie dokładnie?

  • Services?
  • Jobs?
  • Events/listeners?
  • Action classes?
  • Something else?

Najtrudniejsze jest to, że wszystkie powyższe odpowiedzi byłyby poprawnymi odpowiedziami. To prawdopodobnie główna wiadomość, którą powinieneś zabrać do domu z tego artykułu. Podkreślę to dla Ciebie pogrubieniem i dużymi literami.

Możesz dowolnie ustrukturyzować swój projekt, JAK CHCESZ.

Oto powiedziałem.Innymi słowy, jeśli widzisz gdzieś polecaną strukturę, nie oznacza to, że musisz skakać i stosować ją wszędzie. Wybór zawsze należy do Ciebie. Musisz wybrać strukturę, która byłaby wygodna dla Ciebie i Twojego przyszłego zespołu, aby później utrzymywać kod.

Dzięki temu prawdopodobnie mógłbym już teraz zakończyć artykuł. Ale prawdopodobnie chcesz trochę „mięsa”, prawda?Dobrze, pobawmy się powyższym kodem.


Ogólna strategia refaktoryzacji

Po pierwsze „zastrzeżenie”, aby było jasne, co tu robimy i dlaczego. Naszym ogólnym celem jest skrócenie metody Controller, aby nie zawierała żadnej logiki.

Metody kontrolera muszą wykonywać trzy rzeczy:

  • Accept the parameters from routes or other inputs
  • Call some logic classes/methods, passing those parameters
  • Return the result: view, redirect, JSON return, etc.

Tak więc kontrolery wywołują metody, a nie implementują logikę wewnątrz samego kontrolera.

Pamiętaj też, że sugerowane przeze mnie zmiany to tylko JEDEN sposób na zrobienie tego, istnieją dziesiątki innych sposobów, które również by działały. Po prostu przedstawię Ci moje sugestie z własnego doświadczenia.


1.Walidacja: Klasy Request Form

To osobista preferencja, ale lubię trzymać zasady walidacji osobno, a Laravel ma na to świetne rozwiązanie: Żądania formularzy

Więc generujemy:

php artisan make:request StoreUserRequest

Przenosimy nasze reguły walidacji z kontrolera do tej klasy.

use Illuminate\Validation\Rules\Password;
 
class StoreUserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }
 
    public function rules()
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ];
    }
}

2. Utwórz użytkownika: klasa usługi

use App\Http\Requests\StoreUserRequest;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        // No $request->validate needed here
 
        // Create user
        $user = User::create([...]) // ...
    }
}

Następnie musimy utworzyć użytkownika i przesłać dla niego awatar:


Jeśli postępujemy zgodnie z zaleceniami, ta logika nie powinna znajdować się w kontrolerze. Kontrolery nie powinny wiedzieć nic o strukturze bazy danych użytkownika ani o tym, gdzie przechowywać awatary. Wystarczy wywołać metodę klasy, która zajmie się wszystkim.

Dość powszechnym miejscem na umieszczenie takiej logiki jest utworzenie oddzielnej klasy PHP wokół operacji jednego modelu. Nazywa się to klasą Service, ale jest to tylko "wymyślna" oficjalna nazwa klasy PHP, która "dostarcza usługę" dla kontrolera.

// Create user
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    'password' => Hash::make($request->password),
]);
 
// Avatar upload and update user
if ($request->hasFile('avatar')) {
    $avatar = $request->file('avatar')->store('avatars');
    $user->update(['avatar' => $avatar]);
}

Dlatego nie ma polecenia takiego jak php artisan make:service, ponieważ jest to po prostu klasa PHP, o dowolnej strukturze, więc możesz ją utworzyć ręcznie w swoim IDE, w dowolnym folderze.

Zazwyczaj Usługi są tworzone, gdy istnieje więcej niż jedna metoda wokół tej samej jednostki lub modelu.Tak więc, tworząc tutaj usługę UserService, zakładamy, że w przyszłości będzie więcej metod, nie tylko do tworzenia użytkownika.

Ponadto usługi zazwyczaj mają metody, które zwracają coś (czyli „dostarcza usługę”). Dla porównania, akcje lub zadania są zwykle wywoływane bez oczekiwania niczego w zamian.

W moim przypadku utworzę app/Services/UserService.php, na razie z jedną metodą.

Następnie w kontrolerze możemy po prostu wskazać tę klasę Service jako parametr metody i wywołać metodę wewnątrz.

Tak, nie musimy nigdzie wywoływać new UserService(). Laravel pozwala na wpisywanie podpowiedzi dowolnej klasy w Kontrolerach, możesz przeczytać więcej o Method Injection tutaj w dokumentacji.

namespace App\Services;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class UserService
{
    public function createUser(Request $request): User
    {
        // Create user
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);
 
        // Avatar upload and update user
        if ($request->hasFile('avatar')) {
            $avatar = $request->file('avatar')->store('avatars');
            $user->update(['avatar' => $avatar]);
        }
 
        return $user;
    }
}

2.1. Klasa usług z zasadą pojedynczej odpowiedzialności

use App\Services\UserService;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
        $user = $userService->createUser($request);
 
        // Login and other operations...

Teraz kontroler jest znacznie krótszy, ale ta prosta separacja kodu kopiuj-wklej jest nieco problematyczna.

Pierwszy problem polega na tym, że metoda Service powinna działać jak „czarna skrzynka”, która po prostu akceptuje parametry i nie wie, skąd one pochodzą. Tak więc ta metoda będzie możliwa do wywołania w przyszłości z kontrolera, z polecenia rzemieślnika lub z zadania.

Kolejnym problemem jest to, że metoda Service narusza zasadę pojedynczej odpowiedzialności: tworzy użytkownika i przesyła plik.

Więc potrzebujemy jeszcze dwóch „warstw”: jednej do przesyłania plików, a drugiej do przekształcenia $request na parametry funkcji. I jak zawsze istnieją różne sposoby jego realizacji.

Możesz pomyśleć, że metody Service są zbyt małe, aby je rozdzielić, ale jest to bardzo uproszczony przykład: w rzeczywistych projektach metoda przesyłania plików może być znacznie bardziej złożona, podobnie jak logika tworzenia użytkownika.< /p>

W tym przypadku odeszliśmy nieco od świętej zasady „skróć kontroler” i dodaliśmy drugą linię kodu, ale moim zdaniem z właściwych powodów.

3. Może działanie zamiast usługi?

W ostatnich latach koncepcja klas Action zyskała popularność w społeczności Laravel. Logika jest następująca: masz osobną klasę tylko dla JEDNEGO działania. W naszym przypadku klasami akcji mogą być:

class UserService
{
    public function uploadAvatar(Request $request): ?string
    {
        return ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
    }
 
    public function createUser(array $userData): User
    {
        return User::create([
            'name' => $userData['name'],
            'email' => $userData['email'],
            'password' => Hash::make($userData['password']),
            'avatar' => $userData['avatar']
        ]);
    }
}

Tak więc, jak widać, te same wielokrotne operacje wokół użytkowników, tylko nie w jednej klasie UserService, ale podzielone na klasy Action.Może to mieć sens, patrząc z punktu widzenia pojedynczej zasady odpowiedzialności, ale lubię grupować metody w klasy, zamiast mieć wiele oddzielnych klas. Ponownie, to osobiste preferencje.

public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
 
    // ...

Teraz przyjrzyjmy się, jak wyglądałby nasz kod w przypadku klasy Action.

Ponownie, nie ma php artisan make:action, po prostu tworzysz klasę PHP.Na przykład utworzę app/Actions/CreateNewUser.php:

  1. The method createUser() now doesn't know anything about the Request, and we may call it from any Artisan command or elsewhere
  2. The avatar upload is separated from the user creation operation

Możesz wybrać nazwę metody dla klasy Action, lubię handle().

Zarejestrowany kontroler użytkownika:


Innymi słowy, przenosimy CAŁĄ logikę do klasy akcji, która następnie zajmuje się wszystkim, zarówno w zakresie przesyłania plików, jak i tworzenia użytkowników.Szczerze mówiąc, nie jestem nawet pewien, czy jest to najlepszy przykład do zilustrowania klas Action, ponieważ osobiście nie jestem ich wielkim fanem i nie korzystałem z nich zbyt często. Jako kolejne źródło przykładów możesz wziąć spójrz na kod Laravel Fortify.

Jaka jest zaleta tego podejścia w przypadku wydarzeń i słuchaczy?Są one używane w kodzie jak „haki” i każdy inny w przyszłości będzie mógł z niego skorzystać. Innymi słowy, mówisz przyszłym programistom: „Hej, użytkownik jest zarejestrowany, wydarzenie się wydarzyło, a teraz, jeśli chcesz dodać jakąś inną operację, która ma miejsce tutaj, po prostu stwórz dla niej swój odbiornik”.

  • CreateNewUser
  • UpdateUserPassword
  • UpdateUserProfile
  • etc.

Ale moim zdaniem jest to nieco niebezpieczny wzorzec.Nie tylko logika implementacji jest ukryta przed kontrolerem, ale samo istnienie tych operacji nie jest jasne. Wyobraź sobie, że nowy programista dołącza do zespołu za rok, czy sprawdziłby wszystkie możliwe metody obserwacji podczas prowadzenia rejestracji użytkownika?

Krótszy, podzielony na różne pliki i nadal czytelny, prawda?Powtórzę jeszcze raz, że to tylko jeden sposób na wykonanie tej misji, możesz zdecydować się na inną strukturę.

Jaka jest zaleta tego podejścia w przypadku wydarzeń i słuchaczy? Są one używane jako „haki” w kodzie i każdy inny w przyszłości będzie mógł używać tego haka.Innymi słowy, mówisz przyszłym programistom: „Hej, użytkownik jest zarejestrowany, zdarzenie miało miejsce, a teraz, jeśli chcesz dodać jakąś inną operację, która ma miejsce tutaj, po prostu stwórz dla niej swój listener”.

namespace App\Actions;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class CreateNewUser
{
    public function handle(Request $request)
    {
        $avatar = ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
 
        return User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'avatar' => $avatar
        ]);
    }
}

Ale moim zdaniem jest to nieco niebezpieczny wzorzec.Nie tylko logika implementacji jest ukryta przed kontrolerem, ale samo istnienie tych operacji nie jest jasne. Wyobraź sobie, że nowy programista dołącza do zespołu za rok, czy sprawdziłby wszystkie możliwe metody obserwacji podczas prowadzenia rejestracji użytkownika?

Oczywiście można to rozgryźć, ale nadal nie jest to oczywiste.Naszym celem jest sprawienie, by kod był łatwiejszy w utrzymaniu, więc im mniej „niespodzianek”, tym lepiej. Więc nie jestem wielkim fanem Obserwatorów.

public function store(StoreUserRequest $request, CreateNewUser $createNewUser)
{
    $user = $createNewUser->handle($request);
 
    // ...

Jaka jest zaleta tego podejścia w przypadku wydarzeń i słuchaczy? Są one używane jako „haki” w kodzie i każdy inny w przyszłości będzie mógł używać tego haka.Innymi słowy, mówisz przyszłym programistom: „Hej, użytkownik jest zarejestrowany, zdarzenie miało miejsce, a teraz, jeśli chcesz dodać jakąś inną operację, która ma miejsce tutaj, po prostu stwórz dla niej swój listener”.


4. Voucher Creation: Same or Different Service?
Auth::login($user);
 
$voucher = Voucher::create([
    'code' => Str::random(8),
    'discount_percent' => 10,
    'user_id' => $user->id
]);
 
$user->notify(new NewUserWelcomeNotification($voucher->code));

Ale moim zdaniem jest to nieco niebezpieczny wzorzec.

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

use App\Models\Voucher;
use Illuminate\Support\Str;
 
class UserService
{
    // public function uploadAvatar() ...
    // public function createUser() ...
 
    public function createVoucherForUser(int $userId): string
    {
        $voucher = Voucher::create([
            'code' => Str::random(8),
            'discount_percent' => 10,
            'user_id' => $userId
        ]);
 
        return $voucher->code;
    }
}
public function store(StoreUserRequest $request, UserService $userService)
{
	// ...
 
    Auth::login($user);
 
    $voucherCode = $userService->createVoucherForUser($user->id);
    $user->notify(new NewUserWelcomeNotification($voucherCode));
class UserService
{
    public function sendWelcomeEmail(User $user)
    {
        $voucherCode = $this->createVoucherForUser($user->id);
        $user->notify(new NewUserWelcomeNotification($voucherCode));
    }
$userService->sendWelcomeEmail($user);

Ale moim zdaniem jest to nieco niebezpieczny wzorzec.

foreach (config('app.admin_emails') as $adminEmail) {
    Notification::route('mail', $adminEmail)
        ->notify(new NewUserAdminNotification($user));
}

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:job NewUserNotifyAdminsJob
class NewUserNotifyAdminsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    private User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
 
    public function handle()
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($this->user));
        }
    }
}
use App\Jobs\NewUserNotifyAdminsJob;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
    	// ...
 
        NewUserNotifyAdminsJob::dispatch($user);
public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
    $userService->sendWelcomeEmail($user);
    NewUserNotifyAdminsJob::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}

Ale moim zdaniem jest to nieco niebezpieczny wzorzec.

  1. We're actively creating the user and logging them in
  2. And then something with that user may (or may not) happen in the background. So we're passively waiting for those other operations: sending a welcome email and notifying the admins.

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:event NewUserRegistered
php artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegistered
php artisan make:listener NewUserNotifyAdminsListener --event=NewUserRegistered
use App\Models\User;
 
class NewUserRegistered
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
 
    public User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
}
public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
 
    NewUserRegistered::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}
use App\Events\NewUserRegistered;
use App\Services\UserService;
 
class NewUserWelcomeEmailListener
{
    public function handle(NewUserRegistered $event, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
    }
}
use App\Events\NewUserRegistered;
use App\Notifications\NewUserAdminNotification;
use Illuminate\Support\Facades\Notification;
 
class NewUserNotifyAdminsListener
{
    public function handle(NewUserRegistered $event)
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:observer UserObserver --model=User
use App\Models\User;
use App\Notifications\NewUserAdminNotification;
use App\Services\UserService;
use Illuminate\Support\Facades\Notification;
 
class UserObserver
{
    public function created(User $user, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
 
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297