Pipelines (chain of responsibility) laravel

Przypadek. Potrzebujemy znaleźć informacje na tematu hotelu. Mamy własną bazę hoteli, ale jeżeli nie znajdziemy go w naszej bazie. Następnym krokiem jest użycie zewnętrznego API (np. booking). Z pomocą przychodzi nam pipeline.

Na początek określmy klasę bazową

use Illuminate\Contracts\Pipeline;

readonly class SearchHotels {

    /**
     * @param HotelChain[] $chains
     */
    public function __construct(private Pipeline $pipeline, private array $chains)
    {
    }

    public function search(string $hotelValue): HotelValueObject
    {
        $hotel = $this->pipeline
            ->send($hotelValue) // co przesyłamy
            ->via('search') // przez jaką funkcje w chain (default handle)
            ->through($this->chains)
            ->thenReturn();

        if (!$hotel instanceof HotelValueObject) {
            throw new Exception('Hotel not found');
        }

        return $hotel;
    }
}

Wstrzykujemy klasę Pipeline i nasze chains. Pierwszym będzie lokalne repozytorium do bazy danych, drugie to będzie zewnętrzne API. Można już przygotować gotową implementację klasy Pipeline w ServiceProvider’erze. Na potrzeby bloga zrobiłem to w klasie bazowej.

Zdefiniujmy interfejs dla chains. W klasie bazowej w funkcji ->via('search') użyliśmy słowa search, więc i tak musi się nazywać nasza funkcja w interfejsie. Funkcja będzie zwracać ValueObject lub string. ValueObject będzie określana przez nas. Jeżeli nie uda się znaleźć hotelu w żadnym chain Pipeline zwróci początkową wartość w naszym przypadku jest to string.

interface HotelChain {
    public function search(string $hotelValue, Closure $next): HotelValueObject|string;
}

Potrzebujemy przekazać wartość od użytkownika i Closure $next jest to kolejna klasa w kolejce chains.

Zbudujmy pierwszy chain:

readonly class LocalSearch implements HotelChain {
    public function __construct(private HotelRepository $hotelRepository)
    {
    }

    public function search(string $hotelValue, Closure $next): HotelValueObject|string
    {
        $hotel = $this->hotelRepository->byName($hotelValue);

        if (null === $hotel) {
            return $next($hotelValue);
        }

        return HotelValueObject::fromArray($hotel);
    }
}

Zbudujmy drugi chain

readonly class BookingSearch implements HotelChain {
    public function __construct(private object $serviceAPI)
    {
    }

    public function search(string $hotelValue, Closure $next): HotelValueObject|string
    {
        $hotel = $this->serviceAPI->search($hotelValue);

        if (null === $hotel) {
            return $next($hotelValue);
        }

        return HotelValueObject::fromArray($hotel);
    }
}

Krótkie wyjaśnienie, klasa LocalSearch stara się znaleźć w lokalnej bazie danych jakiś hotel. Jeżeli jej się nie uda wykonuje Closure $next. Kryje się pod tym po prostu klasa BookingSearch. Jeżeli chain się kończy jak na klasie BookingSearch Laravel zwraca wartość którą przekazaliśmy w klasie bazowej w funkcji ->send($hotelValue).

Dlatego w klasie bazowej zrobiliśmy sprawdzanie czy if (!$hotel instanceof HotelValueObject).

Stwórzmy serwis który połączy nam to wszystko

$this->app->bind(
    SearchHotels::class,
    fn(Application $application) => new SearchHotels(
        $application->get(Pipeline::class),
        [
            $application->get(LocalSearch::class),
            $application->get(BookingSearch::class),
        ]
    ));

I od teraz możemy wstrzyknąć klasę do kontrolera i jej użyć.

class HotelController extends Controller
{
    public function __construct(readonly private SearchHotels $hotels)
    {
    }

    public function __invoke(HotelRequest $request): JsonResponse
    {
        $hotel = $this->searchHotels->search($request->validated('name'));

        return new JsonResponse(['hotel' => $hotel]);
    }
}

Kolejność chains przy określaniu serwisu ma znaczenie

[
  $application->get(LocalSearch::class),
  $application->get(BookingSearch::class),
]


Najpierw szukamy w lokalnej bazie, następnie w API booking.

Plus tego podejścia jest taki że mamy zachowaną zasadę Open/Close. Chcemy dodać kolejne miejsce do szukania. Żaden problem, tworzymy nowa klasa z API do airbnb, dodajemy tylko chain do serwisu i działa. Ja dodałem również HotelValueObject żeby normalizować odpowiedź dla każdego chaina. Dla mnie używanie array jest nie czytelne, gdyż nie wiem co ten array zawiera.

Minusem tego podejścia jest to że do naszego kodu przenika framework. Na szczęście tylko do klasy bazowej, więc w razie breaking change zmiana będzie w jednym miejscu.

Oczywiście można tego używać w wielu przypadkach, np. do budowania bardzo złożonej struktury. Gdzie część danych pochodzi ze źródła A a cześć ze źródła B.

Scroll to Top