• Czas czytania ~12 min
  • 20.02.2023

Budowanie interfejsów API w Laravel jest formą sztuki. Musisz myśleć nie tylko o dostępie do danych i pakowaniu modeli elokwentnych w punkty końcowe interfejsu API.

Pierwszą rzeczą, którą musisz zrobić, to zaprojektować interfejs API; najlepszym sposobem na to jest zastanowienie się nad przeznaczeniem interfejsu API. Dlaczego tworzysz ten interfejs API i jaki jest docelowy przypadek użycia? Po ustaleniu tego możesz skutecznie zaprojektować interfejs API w oparciu o to, jak powinien być zintegrowany.

Koncentrując swoją perspektywę na tym, jak interfejs API powinien być zintegrowany, możesz wyeliminować wszelkie potencjalne problemy w interfejsie API, zanim jeszcze zostanie wydany. Dlatego zawsze testuję integrację wszelkich interfejsów API, które tworzę, aby zapewnić płynną integrację, która obejmuje wszystkie przypadki użycia, które zamierzam mieć.

Porozmawiajmy o przykładzie, aby namalować obraz. Buduję nowy bank, Laracoin. Potrzebuję, aby moi użytkownicy mogli tworzyć konta i transakcje dla tych kont. Mam model konta, model transakcji i model dostawcy, do których będzie należeć każda transakcja. Przykładem tego

Account -> Has Many -> Transaction -> Belongs To -> Vendor
Spending Account -> Lunch 11.50 -> Some Restaurant

jest:Mamy więc trzy główne modele, na których musimy się skupić dla naszego interfejsu API. Gdybyśmy podeszli do tego bez myślenia opartego na projektowaniu, stworzylibyśmy następujące trasy:

GET /accounts
POST /accounts
GET /accounts/{account}
PUT|PATCH /accounts/{account}
DELETE /accounts/{account}
GET /transactions
POST /transactions
GET /transactions/{transaction}
PUT|PATCH /transactions/{transaction}
DELETE /transactions/{transaction}
GET /vendors
POST /vendors
GET /vendors/{vendor}
PUT|PATCH /vendors/{vendor}
DELETE /vendors/{vendor}

Jakie są jednak zalety tych tras? Po prostu tworzymy dostęp JSON dla naszych elokwentnych modeli, co działa - ale dodaje zerową wartość, a z perspektywy integracji sprawia, że wszystko wydaje się bardzo zrobotyzowane.

Zamiast tego pomyślmy o projekcie i celu naszego interfejsu API. Nasz interfejs API będzie prawdopodobnie dostępny głównie za pośrednictwem wewnętrznych aplikacji mobilnych i internetowych. Na początek skupimy się na tych przypadkach użycia. Wiedząc o tym, możemy dostroić nasz interfejs API, aby dopasować go do podróży użytkownika w naszych aplikacjach. Zazwyczaj w tych aplikacjach zobaczymy listę kont, ponieważ możemy zarządzać naszymi kontami. Będziemy musieli również kliknąć na konto, aby zobaczyć listę transakcji. Następnie będziemy musieli kliknąć transakcję, aby zobaczyć więcej szczegółów. Nigdy tak naprawdę nie musielibyśmy widzieć dostawców bezpośrednio, ponieważ są tam bardziej do kategoryzacji niż czegokolwiek innego. Mając to na uwadze, możemy zaprojektować nasz interfejs API wokół tych przypadków użycia i zasad:

GET /accounts
POST /accounts
GET /accounts/{account}
PUT|PATCH /accounts/{account}
DELETE /accounts/{account}
GET /accounts/{account}/transactions
GET /accounts/{account}/transactions/{transaction}
POST /transactions

Pozwoli nam to skutecznie zarządzać naszymi kontami i będzie mogło pobierać transakcje tylko bezpośrednio przez konto, do którego należy. Nie chcemy, aby transakcje były teraz edytowane lub zarządzane. Powinny one być tworzone tylko - a stamtąd wewnętrzny proces powinien je aktualizować, jeśli będą wymagane. Teraz, gdy wiemy, jak nasz interfejs API

ma być zaprojektowany, możemy skupić się na tym, jak zbudować ten interfejs API, aby zapewnić, że szybko reaguje i może skalować się pod względem złożoności.

Po pierwsze, założymy, że budujemy aplikację Laravel tylko dla API - więc nie będziemy potrzebować żadnego prefiksu api. Zastanówmy się, w jaki sposób możemy zarejestrować te trasy, ponieważ często jest to pierwsza część aplikacji, w której pojawiają się problemy. Plik zajętych tras jest trudny do przeanalizowania mentalnie, a obciążenie poznawcze jest pierwszą bitwą w każdej aplikacji.

Jeśli ten interfejs API miałby być dostępny publicznie, rozważyłbym obsługę wersjonowanego interfejsu API, w którym to przypadku utworzyłbym katalog wersji i przechowywał każdą główną grupę w dedykowanym pliku. Jednak w tym przypadku nie używamy wersjonowania, więc zorganizujemy je inaczej.

Pierwszym plikiem tras, który chcemy utworzyć, jest routes/api/accounts.php, który możemy dodać do naszego routes/api.php.

Route::prefix('accounts')->as('accounts:')->middleware(['auth:sanctum', 'verified'])->group(
    base_path('routes/api/accounts.php),
);

Każda grupa będzie ładować swoje trasy, konfigurując domyślny prefiks oprogramowania pośredniczącego i wzorzec nazewnictwa tras. Nasz plik tras dla kont będzie płaski z minimalnym grupowaniem innym niż wtedy, gdy chcemy spojrzeć na podzasoby. Dzięki temu możemy mieć tylko jeden obszar, na który możemy spojrzeć, próbując zrozumieć same trasy, ale oznacza to, że wszystko, co ma związek z kontami, będzie należało do tego pliku.

Route::get(
    '/',
    App\Http\Controllers\Accounts\IndexController::class,
)->name('index');

Naszą pierwszą trasą jest trasa indeksu kont, która pokaże wszystkie konta uwierzytelnionego użytkownika. Jest to prawdopodobnie pierwsza rzecz wywoływana przez API oprócz tras uwierzytelniania, więc zazwyczaj skupiam się na tym w pierwszej kolejności. Ważne jest, aby najpierw spojrzeć na najbardziej krytyczne trasy, aby odblokować inne zespoły, ale także pozwala to rozwinąć standardy, których chcesz przestrzegać w swojej aplikacji. Teraz, gdy rozumiemy, w jaki sposób

kierujemy nasze żądania, możemy zastanowić się, w jaki sposób chcemy je przetwarzać. Gdzie żyje logika i jak możemy zapewnić, że ograniczymy duplikację kodu do minimum?

Niedawno napisałem samouczek o tym, jak używać < href = "https://laravel-news.com/effective-eloquent" > Eloquent Effective, który zagłębia się w klasy zapytań. Jest to moje preferowane podejście, ponieważ zapewnia minimalną ilość duplikacji kodu. Nie będę wchodził w szczegóły, dlaczego użyję tego podejścia, ponieważ szczegółowo opisałem w poprzednim samouczku. Jednak przejdę przez to, jak go używać w aplikacji. Możesz zastosować to podejście, jeśli odpowiada Twoim potrzebom.

Najważniejszą rzeczą do zapamiętania jest to, że najlepszym sposobem na maksymalne wykorzystanie interfejsu API jest zbudowanie go w sposób, który działa dla Ciebie i Twojego zespołu. Spędzanie godzin na próbach dostosowania się do metody, która nie wydaje się naturalna, spowolni cię w sposób, który nie da ci korzyści, które próbujesz osiągnąć.

Podczas tworzenia klasy zapytania należy powiązać odpowiedni interfejs z kontrolerem. Nie jest to wymagany krok. Jednak to ja piszę samouczek - więc czego tak naprawdę się spodziewałeś?

interface FilterForUserContract
{
    public function handle(Builder $query, string $user): Builder;
}

Następnie implementacja, której chcemy użyć:

final class FilterAccountsForUser implements FilterForUserContract
{
    public function handle(Builder $query, string $user): Builder
    {
        return QueryBuilder::for(
            subject: $query,
        )->allowedIncludes(
            include: ['transactions'],
        )->where('user_id', $user)->getEloquentBuilder();
    }
}

Ta klasa zapytań pobierze wszystkie konta dla przekazanego użytkownika, umożliwiając opcjonalne uwzględnienie transakcji dla każdego konta - a następnie przekaż z powrotem elokwentnego konstruktora, aby dodać dodatkowe zakresy w razie potrzeby.

Następnie możemy użyć tego w naszym kontrolerze, aby wysłać zapytanie do kont uwierzytelnionego użytkownika, a następnie zwrócić je w naszej odpowiedzi. Przyjrzyjmy się, jak możemy użyć tego zapytania, aby zrozumieć dostępne opcje.

final class IndexController
{
    public function __construct(
        private readonly Authenticatable $user,
        private readonly FilterForUserContract $query,
    ) {}
    public function __invoke(Request $request): Responsable
    {
        $accounts = $this->query->handle(
            query: Account::query()->latest(),
            user: $this->user->getAuthIdentifier(),
        );
        // return response here.
    }
}

W tym momencie nasz kontroler ma wymowny kreator, który przejdzie do odpowiedzi, więc podczas przekazywania danych upewnij się, że wywołasz get lub paginate, aby poprawnie przekazać dane. To prowadzi nas do następnego punktu w mojej opiniotwórczej podróży.

Reagowanie jest głównym obowiązkiem naszego API. Powinniśmy reagować szybko i skutecznie, aby mieć szybki i responsywny interfejs API dla naszych użytkowników. Sposób, w jaki reagujemy jako interfejs API, można podzielić na dwa obszary: klasę odpowiedzi i sposób przekształcania danych na potrzeby odpowiedzi.

Te dwa obszary to Odpowiedzi i Zasoby interfejsu API. Zacznę od zasobów API, ponieważ bardzo mi na nich zależy. Zasoby interfejsu API służą do zaciemniania struktury bazy danych i umożliwiają przekształcanie informacji przechowywanych w interfejsie API w sposób, który najlepiej będzie używany po stronie klienta.

Używam standardów JSON:API w moich Laravel API, ponieważ jest to doskonały standard, który jest dobrze udokumentowany i używany w społeczności API. Na szczęście Tim MacDonald stworzył fantastyczny pakiet do tworzenia zasobów JSON:API w Laravel, na który przysięgam we wszystkich moich aplikacjach Laravel. Niedawno < href = "https://laravel-news.com/json-api-resources-in-laravel" > napisałem samouczek na temat korzystania z tego pakietu, więc omówię tutaj tylko szczegóły.

Zacznijmy od zasobu konta, który zostanie skonfigurowany tak, aby miał odpowiednie relacje i atrybuty. Od czasu mojego ostatniego samouczka pakiet został niedawno zaktualizowany, co ułatwia konfigurowanie relacji.

final class AccountResource extends JsonApiResource
{
    public $relationships = [
        'transactions' => TransactionResource::class,
    ];
    public function toAttributes(Request $request): array
    {
        return [
            'name' => $this->name,
            'balance' => $this->balance->getAmount(),
        ];
    }
}

Na razie utrzymujemy to super proste. Chcemy zwrócić nazwę konta i saldo, z opcją załadowania relacji transakcji.

Korzystanie z tych zasobów oznacza, że aby uzyskać dostęp do nazwy, a my musielibyśmy użyć: data.attributes.name, co może zająć trochę czasu, aby przyzwyczaić się do aplikacji internetowych lub mobilnych, ale wkrótce to zrozumiesz. Podoba mi się takie podejście, ponieważ możemy oddzielić relacje i atrybuty i rozszerzyć je w razie potrzeby.

Po wypełnieniu naszych zasobów możemy skupić się na innych obszarach, takich jak autoryzacja. Jest to istotna część naszego API i nie należy jej pomijać. Większość z nas korzystała już wcześniej z Laravels Gate, korzystając z Gate Facade. Lubię jednak wstrzykiwać kontrakt Gate z samych ram. Dzieje się tak głównie dlatego, że wolę Dependency Injection od fasad, kiedy mam szansę. Przyjrzyjmy się, jak to może wyglądać w StoreController dla kont.

final class StoreController
{
    public function __construct(
        private readonly Gate $access,
    ) {}
    public function __invoke(StoreRequest $request): Responsable
    {
        if (! $this->access->allows('store')) {
            // respond with an error.
        }
        // the rest of the controller goes here.
    }
}

Tutaj po prostu używamy funkcji Gate tak, jakby to była fasada, ponieważ są one tym samym. Używam tutaj pozwoleń, ale możesz użyć can lub innych metod. Powinieneś skupić się na autoryzacji, a nie na sposobie jej implementacji, ponieważ jest to drobny szczegół dla Twojej aplikacji na koniec dnia.

Wiemy więc, w jaki sposób chcemy, aby dane były reprezentowane w API i jak chcemy autoryzować użytkowników w aplikacji. Następnie możemy przyjrzeć się, jak możemy poradzić sobie z operacjami zapisu.

Jeśli chodzi o nasz interfejs API, operacje zapisu mają kluczowe znaczenie. Musimy upewnić się, że są one szybkie, jak to tylko możliwe, aby nasz interfejs API był szybki.

Możesz zapisywać dane w interfejsie API na wiele różnych sposobów, ale moim preferowanym podejściem jest używanie zadań w tle i szybki zwrot. Oznacza to, że możesz martwić się o logikę wokół tego, jak rzeczy są tworzone w twoim czasie, a nie w twoich klientach. Zaletą jest to, że zadania w tle mogą nadal publikować aktualizacje za pośrednictwem gniazd internetowych, aby uzyskać efekt w czasie rzeczywistym.

Spójrzmy na zaktualizowany StoreController dla kont, gdy używamy tego podejścia:

final class StoreController
{
    public function __construct(
        private readonly Gate $access,
        private readonly Authenticatable $user,
    ) {}
    public function __invoke(StoreRequest $request): Responsable
    {
        if (! $this->access->allows('store')) {
            // respond with an error.
        }
        dispatch(new CreateAccount(
            payload: NewAccount::from($request->validated()),
            user: $this->user->getAuthIdentifier(),
        ));
        // the rest of the controller goes here.
    }
}

Wysyłamy naszemu zadaniu w tle ładunek obiektu transferu danych, który zostanie zserializowany w kolejce. Utworzyliśmy to DTO przy użyciu zweryfikowanych danych i chcemy wysłać je za pośrednictwem identyfikatora użytkownika, ponieważ musimy wiedzieć, dla kogo to zrobić.

Zgodnie z tym podejściem mamy prawidłowe dane i bezpieczne dane typu przekazywane w celu utworzenia modelu. W naszych testach wszystko, co musimy zrobić, to upewnić się, że zadanie zostało wysłane.

it('dispatches a background job for creation', function (string $string): void {
    Bus::fake();

    actingAs(User::factory()->create())->postJson(
        uri: action(StoreController::class),
        data: [
            'name' => $string,
        ],
    )->assertStatus(
        status: Http::ACCEPTED->value,
    );
    Bus::assertDispatched(CreateAccount::class);
})->with('strings');

Testujemy tutaj, aby upewnić się, że przejdziemy weryfikację, odzyskamy poprawny kod stanu z naszego interfejsu API, a następnie potwierdzimy, że właściwe zadanie w tle zostało wysłane.

Następnie możemy przetestować zadanie w izolacji, ponieważ nie musi ono być uwzględniane w naszym teście punktu końcowego. Teraz, jak zostanie to zapisane w bazie danych? Używamy klasy Command do zapisywania naszych danych. Stosuję to podejście, ponieważ używanie tylko klas Action jest niechlujne. Otrzymujemy 100 klas akcji, które trudno przeanalizować, szukając konkretnej w naszym katalogu.

Jak zawsze, ponieważ uwielbiam używać Dependency Injection, musimy stworzyć interfejs, którego użyjemy do rozwiązania naszej implementacji.

interface CreateNewAccountContract
{
    public function handle(NewAccount $payload, string $user): Model;
}

Używamy DTO nowego konta jako ładunku i przekazujemy identyfikator użytkownika jako ciąg znaków. Zazwyczaj podaję to jako ciąg; Użyłbym identyfikatora UUID lub ULID jako pola ID w moich aplikacjach.

final class CreateNewAccount implements CreateNewAccountContract
{
    public function handle(NewAccount $payload, string $user): Model
    {
        return DB::transaction(
            callback: fn (): Model => Account::query()->create(
                    attributes: [
                        ...$payload->toArray(),
                        'user_id' => $user,
                ],
            ),
        );
    }
}

Akcję zapisu umieszczamy w transakcji bazy danych, dzięki czemu zatwierdzamy bazę danych tylko wtedy, gdy zapis się powiedzie. Pozwala nam wycofać się i zgłosić wyjątek, jeśli zapis się nie powiedzie.

Omówiliśmy, jak przekształcać dane modelu dla naszej odpowiedzi, jak wysyłać zapytania i zapisywać dane, a także jak chcemy autoryzować użytkowników w aplikacji. Ostatnim etapem budowania solidnego API w Laravel jest przyjrzenie się, jak reagujemy jako API.

Większość interfejsów API jest do bani, jeśli chodzi o reagowanie. Jest to ironiczne, ponieważ jest to prawdopodobnie najważniejsza część API. W Laravel istnieje wiele sposobów odpowiadania, od użycia funkcji pomocniczych do zwracania nowych wystąpień JsonResponse. Lubię jednak budować dedykowane klasy Response. Są one podobne do klas Query i Command, które mają na celu ograniczenie duplikacji kodu, ale są również najbardziej przewidywalnym sposobem zwracania odpowiedzi.

Pierwszą odpowiedzią, którą tworzę, jest odpowiedź kolekcji, której użyłbym przy zwracaniu listy kont należących do uwierzytelnionego użytkownika. Stworzyłbym również zbiór innych odpowiedzi, od odpowiedzi pojedynczego modelu do pustych odpowiedzi i odpowiedzi na błędy.

class Response implements Responsable
{
    public function toResponse(): JsonResponse
    {
        return new JsonResponse(
            data: $this->data,
            status: $this->status->value,
        );
    }
}

Najpierw musimy stworzyć wstępną odpowiedź, którą rozszerzą nasze klasy odpowiedzi. Dzieje się tak dlatego, że wszystkie zareagują w ten sam sposób. Wszystkie muszą zwrócić dane i kod statusu - w ten sam sposób. Spójrzmy teraz na samą klasę odpowiedzi na kolekcję.

final class CollectionResponse extends Response
{
    public function __construct(
        private readonly JsonApiResourceCollection $data,
        private readonly Http $status = Http::OK,
    ) {}
}

Jest to bardzo czyste i łatwe do wdrożenia, a właściwość danych można przekształcić w typ unii, aby była bardziej elastyczna.

final class CollectionResponse extends Response
{
    public function __construct(
        private readonly Collection|JsonResource|JsonApiResourceCollection $data,
        private readonly Http $status = Http::OK,
    ) {}
}

Są one czyste i łatwe do zrozumienia, więc spójrzmy na ostateczną implementację IndexController dla kont.

final class IndexController
{
    public function __construct(
        private readonly Authenticatable $user,
        private readonly FilterForUserContract $query,
    ) {}
    public function __invoke(Request $request): Responsable
    {
        $accounts = $this->query->handle(
            query: Account::query()->latest(),
            user: $this->user->getAuthIdentifier(),
        );
        return new CollectionResponse(
            data: $accounts->paginate(),
        );
    }
}

Skoncentrowanie się na tych krytycznych obszarach umożliwia skalowanie interfejsu API pod względem złożoności bez martwienia się o duplikację kodu. Są to kluczowe obszary, na których zawsze będę się skupiał, próbując dowiedzieć się, co powoduje, że API Laravel jest powolne.

Nie jest to w żadnym wypadku wyczerpujący samouczek ani lista tego, na czym musisz się skupić, ale postępując zgodnie z tym nieco krótkim przewodnikiem, możesz przygotować się na sukces.

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