Obsługa sesji w PHP oraz żądań XHR na przykładzie koszyka produktów

Artykuł dodany: 16 listopada 2017.

Stopień trudności (1 - dla początkujących, 5 - dla ekspertów): 2

Dzisiaj mam zamiar połączyć kilka wcześniejszych artykułów i pokazać jak wykorzystać zdobytą wiedzę w praktyce. Potrzebna nam będzie:
- Propagacja i delegacja zdarzeń w JavaScript
- Konfiguracja PHP
- Pierwsze kroki z AJAX
- Jak przygotować PHP do obsługi żądań XHR JSON?

Naszym celem będzie stworzenie koszyka zakupów którego zawartość będzie przechowywana w sesji, natomiast wszystkie operacje – jak dodawanie produktów, kasowanie zawartości koszyka – odbywać się będą dynamicznie w tle, poprzez żądania AJAX. Naszym magazynem będzie dla uproszczenia prosta tablica w PHP, chociaż zapewne w rzeczywistości będziesz chciał Czytelniku wykorzystać do tego celu bazę danych. Trzeba pamiętać, że chociaż gotowy kod będzie w pełni przygotowany do pracy, to część rzeczy przedstawionych w artykule może mieć charakter podglądowy. Tak aby jak najwięcej rzeczy pokazać i nauczyć ich Ciebie. Dlatego w pewnych fragmentach kod może nie być do końca optymalny. Pliki możemy umieścić w dowolnym katalogu, a ich struktura jest następująca:

|- index.php
|- koszyk.js
|- koszyk.php
|- produkty.php

Zaczniemy od pliku `produkty.php`. Zawiera on prostą tablicę produktów w naszym pseudo-magazynie.

produkty.php

<?php

return [
    [
        'id' => 1,
        'nazwa' => 'Suszarka do włosów',
        'magazyn' => 10
    ],
    [
        'id' => 2,
        'nazwa' => 'Telewizor',
        'magazyn' => 4
    ],
    [
        'id' => 3,
        'nazwa' => 'Telefon',
        'magazyn' => 5
    ],
    [
        'id' => 4,
        'nazwa' => 'Laptop',
        'magazyn' => 2
    ],
];

Każdy element tablicy posiada 3 klucze. Unikalne `id` (w relacyjnej bazie danych będzie to klucz główny), nazwę produktu oraz ilość dostępnych produktów na magazynie. Dodanie do koszyka większej ilości niż dostępna poskutkuje wyświetleniem komunikatu błędu.

Kolejnym plikiem jest `index.php`. Zawiera on podstawową strukturę HTML oraz zajmuje się wyświetlaniem naszego stanu magazynowego.

index.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Przykładowy koszyk produktów</title>
    <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
    <script src="koszyk.js"></script>
    <style>
        dt:before {
            content: "";
            display: block;
        }
        dt, dd {
            display: inline;
        }

        .ukryj {
            display: none;
        }

        .zamknij {
            float: right;
            cursor: pointer;
        }

        #koszyk_podsumowanie {
            width: 50vw;
            position: fixed;
            top: 20%;
            left: calc(50% - 25vw);
            background-color: rgba(255, 255, 255, .9);
            border: 2px solid #ccc;
            padding: 10px;
        }
    </style>
</head>
<body>
    <main>
        <div id="wiadomosc"></div>
        <dl id="produkty">
        <?php
            $produkty = require_once 'produkty.php';

            foreach ($produkty as $produkt) {
                echo '<dt>', $produkt['nazwa'], '</dt>';
                echo '<dd><button data-id="' . $produkt['id'] . '">Dodaj do koszyka</button></dd>';
            }
        ?>
        </dl>
        <section id="koszyk">
            <a id="pokaz_koszyk" href="#">Pokaż zawartość koszyka</a>
            <div id="koszyk_podsumowanie" class="ukryj">
                <span class="zamknij">zamknij [x]</span>
                <div class="body"></div>
            </div>
        </section>

    </main>
</body>
</html>

Dołączyłem dwa pliki JavaScript – bibliotekę JQuery oraz plik z naszymi skryptami `koszyk.js`. Poniżej znajduje się podstawowe formatowanie z wykorzystaniem CSS. Podsumowanie koszyka zostanie wyświetlone jako niezależna, wyśrodkowana warstwa (okienko), którą przyciskiem `zamknij [x]` będzie można ukryć. #wiadomosc zawierać będzie wszystkie komunikaty z warstwy JS (typu “udało się dodać produkt do koszyka”). Linia:

$produkty = require_once 'produkty.php';

wczytuje zawartość naszego magazynu do zmiennej `$produkty` – staje się ona w tym momencie tablicą – po której następnie iterujemy aby je wyświetlić. Postawiłem tutaj na listę definicji `dl` ale oczywiście może to być dowolny inny element HTML. `button` zawiera atrybut `data-id` w którym umieszczamy id danego produktu. Posłuży ono nam do obsługi całego koszyka. Jest to bardzo często wykorzystywany sposób wiązania danych wygenerowanych przez PHP ze zdarzeniami w JS.

Dodawanie produktów do koszyka

Pierwszą rzeczą jaką będziemy chcieli zrobić jest dodawanie produktów do koszyka. Jeśli jeszcze tego nie zrobiłeś to utwórz plik

koszyk.js

$(function() {
    var adres = 'koszyk.php';
    var $produkty = $('#produkty');
    var $koszyk = $('#koszyk');
});

Zmienna `adres` zawiera adres pliku PHP do którego kierowane będą wszystkie żądania. Zmienne `$produkty` i `$koszyk` to po prostu elementy HTML wybrane w JQuery. Co chcemy osiągnąć? Po kliknięciu w przycisk `#produkty button` wyślemy żądanie do pliku `koszyk.php`, który sprawdzi:

- czy koszyk istnieje, jeśli nie to utworzy nowy
- zweryfikuje czy liczba produktów dodanych do koszyka nie przekracza stanu magazynowego
- ustawi odpowiednie nagłówki odpowiedzi. Wszystkie dane przesyłane będą w postaci formatu JSON
- zweryfikuje czy dane przesłane do PHP są prawidłowe

Jeszcze drobna uwaga. Widać już że zacząłem używać polskich nazw zmiennych. Stosuję je wyjątkowo tylko w ramach tego kursu i jego czytelności dla wszystkich odbiorców. Jednak dobrą praktyką jest stosowanie zawsze języka angielskiego. Ułatwia to odbiór przez różne grupy programistów, jest uniwersalne a dodatkowo nie powoduje dziwnych sytuacji mieszania nazw w kilku językach. Zawsze wszystkie komentarze i nazwy powinny być jak najbardziej “światowe”.

Pora dodać obsługę kliknięcia. Do naszego kodu JS dodaj (poniżej var $koszyk…):

$produkty.on('click', 'button', function(event) {
        event.preventDefault();
        $.ajax({
            url: adres,
            method: 'POST',
            dataType: 'json',
            data: {
                akcja: 'dodaj',
                id: $(this).data('id')
            }
        })
        .done(function(data) {
            $('#wiadomosc').html(data.wiadomosc);
        })
        .fail(function(jqXHR) {
            $('#wiadomosc').html(jqXHR.responseJSON.wiadomosc);
        });
    });

Po kliknięciu w `button` zostanie wysłane żądanie POST pod podany adres URL. Jako dodatkowe parametry przesyłamy

data: {
    akcja: 'dodaj',
    id: $(this).data('id')
}

akcję, w tym wypadku `dodaj`, po której rozróżnimy do robić dalej z żądaniem oraz identyfikator klikniętego przycisku. Wykorzystujemy tutaj zdefiniowany wcześniej atrybut `data-id`. Niezależnie od odpowiedzi (done/fail) wstawiamy jej treść do elementu `#wiadomosc`. Jeśli nie do końca jeszcze rozumiesz co dana zmienna zawiera, sprawdź ją przy pomocy narzędzi deweloperskich przeglądarki (uruchamianych zazwyczaj przez skrót CTRL+SHIFT+I lub F12). Można wykorzystać zarówno debugowanie jak i proste polecenie

console.log(zmienna);

które wyświetli zawartość zmiennej.

Pora na część najtrudniejszą czyli logikę naszego skryptu z pliku `koszyk.php`. Warto wcześniej zaznaczyć, iż wykorzystam w nim możliwości języka PHP w wersji 7. Jest ona na tyle stabilna i dostępna, że nie ma już sensu trzymać się jeszcze wersji 5 (która nie ma już aktywnego wsparcia).

koszyk.php

<?php
header("Content-Type: application/json;charset=utf-8");
session_start();

$akcja = $_POST['akcja'] ?? null;

if ($akcja === 'dodaj') {
    $id =  (int) ($_POST['id'] ?? 0);
    if ($id < 1) {
        echo generuj_blad('Nieprawidłowe id produktu');
        return;
    }

    $ilosc = (isset($_SESSION['koszyk']) && array_key_exists($id, $_SESSION['koszyk'])) 
        ? $_SESSION['koszyk'][$id] : 0;
    $ilosc++;
    if (!czy_poprawna_ilosc($id, $ilosc)) {
        echo generuj_blad('Nie można dodać kolejnego produktu. Ilość przekracza stan magazynowy');
        return;    
    }
    $_SESSION['koszyk'][$id] = $ilosc;

    echo json_encode([
        'wiadomosc' => 'Produkt został dodany do koszyka',
        'koszyk' => $_SESSION['koszyk']
    ]);
    return;
} else {
    echo generuj_blad('Nieprawidłowa akcja');
}

Na razie zawartość może być skomplikowana jednak zaraz postaram się wszystko wyjaśnić. Na początku wysyłamy nagłówek informujący że odpowiedź wysyłana jest w formacie JSON z kodowaniem UTF-8 (JSON rozumie tylko to kodowanie). W kolejnej linii startujemy sesję. Warto tutaj pamiętać że wszelkie nagłówki muszą być zawsze wysłane przed treścią, inaczej PHP wyświetli błąd `headers already sent`. Do zmiennej `akcja` pobieramy dane `$_POST[‘akcja’]` lub, jeżeli nie istnieje, `null`. Jest to operator null coalesce. Następnie robimy to samo z identyfikatorem produktu, rzutując go dodatkowo na wartość integer. Sprawdzamy czy nie jest mniejszy od 1 i wyświetlamy błąd. Bardziej spostrzegawczy Czytelnicy zauważyli zapewne że brakuje definicji funkcji `generuj_blad()`. Oto ona:

function generuj_blad (string $komunikat, int $status = 500):string {
    if (headers_sent()) {
        throw new Exception('Nagłówki zostały już wysłane!');
    }

    http_response_code($status);
    return json_encode([
        'wiadomosc' => $komunikat
    ]);
}

Zdefiniowane są w niej 2 argumenty: komunikat błędu (string) oraz kod błędu (int). Funkcja zwraca wartość w postaci stringa. Jej definicję możemy umieścić np. za `session_start();`. Funkcja sprawdza dodatkowo czy nagłówki nie zostały wcześniej wysłane, jeśli tak się stało to rzuca wyjątek. Ustawia odpowiedni kod statusu i koduje komunikat jako string JSON, który następnie prześlemy w odpowiedzi do kodu JQuery. Kolejne linie:

$ilosc = (isset($_SESSION['koszyk']) && array_key_exists($id, $_SESSION['koszyk'])) 
        ? $_SESSION['koszyk'][$id] : 0;
    $ilosc++;

sprawdzają czy koszyk został wcześniej ustawiony oraz, czy dany identyfikator produktu istnieje w koszyku. W przeciwnym razie zwracane jest 0 produktów. Jaką przyjąłem logikę aplikacji w tym punkcie? W zmiennej sesyjnej o nazwie `koszyk` zapisuję tablicę wartości w formie:

id produktu = ilość produktów w koszyku

Umożliwia to błyskawiczny dostęp po danym kluczu do ilości sztuk produktu dodanej do koszyka. Dlaczego operuję na zmiennej `$ilosc`? Jakiekolwiek manipulacje na danych sesyjnych typu zwiększenie ilości produktów zostały by zapamiętane, a nie chcemy tego robić przed sprawdzeniem stanu magazynowego. Odpowiada za to funkcja:

function czy_poprawna_ilosc(int $id, int $ilosc = 1):bool {
    $produkty = require_once 'produkty.php';
    $produkty = array_column($produkty, 'magazyn', 'id');

    if (array_key_exists($id, $produkty) && $produkty[$id] >= $ilosc) {
        return true;
    }
    return false;

}

Definiuje ona 2 argumenty. `$id` jest identyfikatorem produktu który chcemy sprawdzić, natomiast `$ilosc` musi być równa lub mniejsza ilości sztuk na magazynie. Dalej jeszcze raz wczytujemy nasz “magazyn” i robimy na nim drobną operację przekształcenia przy użyciu funkcji `array_column()`. Przekształci ona tablicę produktów na identyczną jak wcześniej omawiana, Tym razem id produktu będzie kluczem, a wartością ilość sztuk na magazynie. Z naszego magazynu będą to zatem pary:

1 = 10
2 = 4
3 = 5
4 = 2

Z tego już bardzo prosta droga do weryfikacji czy parametry podane w funkcji spełniają warunek ilości sztuk na magazynie. Funkcja zwraca `boolean` w zależności od dostępności. Gdy ilość podanych sztuk się zgadza wysyłamy odpowiednio sformatowany JSON, plus dodatkowo do podglądu wartość koszyka. Możesz go sprawdzić w narzędziach deweloperskich, odpowiedzi otrzymanej z serwera (zakładka sieć).

$_SESSION['koszyk'][$id] = $ilosc;

echo json_encode([
    'wiadomosc' => 'Produkt został dodany do koszyka',
    'koszyk' => $_SESSION['koszyk']
]);

Kliknięcie w `button` “Dodaj do koszyka” wyświetli teraz odpowiedni komunikat wewnątrz diva `#wiadomosc`. Możesz też zweryfikować zakładkę “Dane” narzędzi deweloperskich przeglądarki. Po wejściu na stronę nie było żadnych danych sesji, natomiast po wywołaniu żądania XHR została utworzona nowa sesja.

Podgląd zawartości koszyka

Za obsługę koszyka odpowiada sekcja `#koszyk` czyli nasze mini okno pop-up. W pierwszej kolejności chcemy mieć możliwość zamknięcia go (schowania) po kliknięciu przycisku `zamknij [x]`. Dodaj do pliku:

koszyk.js

$koszyk
    .on('click', '#pokaz_koszyk', function(event) {
        event.preventDefault();
        $(event.delegateTarget).find('#koszyk_podsumowanie').removeClass('ukryj')
            .children('.body').load(adres, {akcja: 'pokaz'});
    })
    .on('click', '.zamknij', function(event) {
        event.preventDefault();
        $(event.delegateTarget).find('#koszyk_podsumowanie').addClass('ukryj');
    });

Równocześnie do elementu a#pokaz_koszyk (Pokaż zawartość koszyka) dodaliśmy funkcję JQuery `load()`. Odkrywa ona okno poprzez usunięcie klasy `.usun`, oraz wysyła żądanie XHR zwracając w odpowiedzi HTML. Żądanie kierujemy tym razem do akcji `pokaz`. Normalnie funkcja `load()` przesyła dane metodą GET, jednak gdy pojawia się dodatkowy parametr z danymi do przesłania, przechodzi na metodę POST. Warto o tym pamiętać.

koszyk.php

if ($akcja === 'dodaj') {
    // wcześniejszy kod
} elseif ($akcja === 'pokaz') {
    header("Content-Type: text/html; charset=UTF-8");
    echo generuj_podsumowanie_html();
    return;
}

Tym razem odpowiedzią będzie czysty HTML, zatem zmieniamy nagłówek na bardziej odpowiedni. Funkcja `generuj_podsumowanie_html()` przyjmuje postać:

function generuj_podsumowanie_html():string {
    if (!isset($_SESSION['koszyk']) || empty($_SESSION['koszyk'])) {
        return '<p>Brak produktów w koszyku</p>';
    }

    $produkty = require_once 'produkty.php';
    $produkty = array_filter($produkty, function($produkt) {
        return array_key_exists($produkt['id'], $_SESSION['koszyk']);
    });

    ob_start();
    echo '<dl id="produkty_podsumowanie">';
    foreach ($produkty as $produkt) {
        echo '<dt>' . $produkt['nazwa'] . '(' . $_SESSION['koszyk'][$produkt['id']] . ')</dt>';
        echo '<dd><button data-id="' . $produkt['id'] . '">Usuń z koszyka</button></dd>';
    }
    echo '</dl>';
    echo '<button id="wyczysc_koszyk">Wyczyść zawartość koszyka</button>';

    $out = ob_get_contents();
    ob_end_clean();
    return $out;
}

Funkcja zwraca `string` (gotowy do wyświetlenia kod HTML). W pierwszej kolejności musimy sprawdzić czy są jakieś produkty w koszyku do wyświetlenia. Następnie ponownie wczytujemy magazyn i za pomocą funkcji `array_filter()` zwracamy tylko te pozycje z magazynu, które występują w zmiennej sesyjnej `koszyk`. Gdy spojrzysz w definicję funkcji array_filter

array array_filter ( array $array [, callable $callback [, int $flag = 0 ]] )

zobaczysz, że drugim argumentem jest `callable $callback`. W PHP, podobnie jak w JS, możliwe jest tworzenie funkcji anonimowych (function() {}) co wykorzystaliśmy. Jest to nasz `callback`. Tak odfiltrowaną tablicę dodajemy po kolei do nowej listy `dl#produkty_podsumowanie`. Lista zawiera nowy przycisk “Usuń z koszyka” który dodatkowo trzeba będzie oprogramować po stronie JQuery. Użyte w kodzie funkcje buforowania – ob_* – służą do przechwytywania wejścia. Mimo że pomiędzy `ob_start()` i `ob_get_contents()` znajduje się konstrukcja `echo`, nic z tego kodu nie zostanie wypisane na ekranie. W zamian całość została przypisana do zmiennej `$out`. Innymi słowy, cały HTML został przypisany do zmiennej i odesłany w odpowiedzi do przeglądarki.

Powinno teraz działać wyświetlanie / zamykanie okienka oraz wczytywanie zawartości koszyka.

Kasowanie produktów z koszyka

Przy kasowaniu wykorzystamy bardzo podobny schemat jak przy dodawaniu produktów.

koszyk.js

$koszyk
.on('click', '#produkty_podsumowanie button', function(event) {
    var $self = $(this);
    event.preventDefault();
    $.ajax({
        url: adres,
        method: 'POST',
        dataType: 'json',
        data: {
            akcja: 'usun',
            id: $(this).data('id')
        }
    })
    .done(function(data) {
        $('#wiadomosc').html(data.wiadomosc);
        $self.parent('dd').prev('dt').remove();
        $self.parent('dd').remove();
    })
    .fail(function(jqXHR) {
        $('#wiadomosc').html(jqXHR.responseJSON.wiadomosc);
    });
})

Różnica jest taka, iż po otrzymaniu poprawnej odpowiedzi kasujemy dynamicznie odpowiedni element `dt+dd`.

koszyk.php

elseif ($akcja === 'usun') {
    if (!isset($_SESSION['koszyk']) || empty($_SESSION['koszyk'])) {
        echo generuj_blad('Koszyk nie został odnaleziony');
        return;
    }
    $id =  (int) ($_POST['id'] ?? 0);
    if ($id < 1) {
        echo generuj_blad('Nieprawidłowe id produktu');
        return;
    }
    unset($_SESSION['koszyk'][$id]);
    echo json_encode([
        'wiadomosc' => 'Produkt został usunięty',
        'koszyk' => $_SESSION['koszyk']
    ]);
    return;
}

Pamiętaj aby zawsze sprawdzać poprawność danych. Każde żądanie XHR liczone jest osobno zatem poprzednie (w tym poprzednio przeprowadzona walidacja) nie ma wpływu na kod. Dlatego ponownie wykonujemy sprawdzenie wartości, następnie usuwamy z koszyka klucz będący danym `id`.

Kasowanie całego koszyka

W końcu została ostatnia rzecz do zrobienia czyli oprogramowanie przycisku usuwającego cały koszyk.

koszyk.js

$koszyk
.on('click', '#wyczysc_koszyk', function(event) {
    event.preventDefault();
    $('<div/>').load(adres, {akcja: 'wyzeruj'}, function(data) {
        $('#wiadomosc').html(JSON.parse(data).wiadomosc);
        $('#koszyk_podsumowanie').addClass('ukryj');
    });
});

Mogliśmy oczywiście wykorzystać funkcję `$.ajax` jednak dodatkowo pokazałem jak można parsować JSON pochodzący z odpowiedzi – funkcja `JSON.parse`.

koszyk.php

elseif ($akcja === 'wyzeruj') {
    $_SESSION['koszyk'] = [];
    echo json_encode([
        'wiadomosc' => 'Koszyk został wyczyszczony',
        'koszyk' => $_SESSION['koszyk']
    ]);
    return;
}

Po stronie PHP nie ma większej filozofii. Zerujemy cały koszyk poprzez przypisanie mu nowej tablicy.

Na koniec można dodać jeszcze jedną rzecz:

$produkty.on('click', 'button', function(event) {
        event.preventDefault();
        $.ajax({
        // wcześniej używany kod
        .done(function(data) {
            $('#wiadomosc').html(data.wiadomosc);
            // dodajemy linię
            $('#koszyk_podsumowanie').addClass('ukryj');
        })
    });

Po kliknięciu w przycisk “Dodaj do koszyka” chowamy okienko pop-up, aby użytkownik był zmuszony wyświetlić prawidłową ilość produktów. Można oczywiście też zrobić to dynamicznie i zapraszam do takich prób we własnym zakresie.

Podsumowanie

Nauczyliśmy się dziś jak przesyłać dane w żądaniach XHR, oraz jak zarządzać nimi zarówno po stronie serwera jak i klienta. Zaletą takiej formy pracy jest minimalizacja przesyłanych danych – klient dostaje tylko niezbędną do pracy porcję danych. Nie trzeba przeładowywać całej strony po każdej odsłonie zatem i efektywność pracy wzrasta. Sam mechanizm sesji po stronie PHP nie jest bardzo skomplikowany. Polega tylko na wystartowaniu sesji i zapisie / odczycie danych z kontenera. Pamiętaj aby wcześniej skonfigurować środowisko pracy poprzez odpowiednie ustawienia albo w pliku `php.ini`, albo przez funkcję `ini_set()`. Ułatwi to rozwiązywanie problemów i wyeliminuje nieścisłości pomiędzy środowiskami.

Całe repozytorium można zobaczyć na Github.

Komentarze

Nie ma jeszcze żadnych komentarzy do wyświetlenia. Może chcesz zostać pierwszą osobą która podzieli się swoją opinią?

Dodaj komentarz

*
Nazwa zostanie wyświetlona wraz z komentarzem. Możesz też utworzyć nowe konto w serwisie, dzięki czemu uzyskasz dodatkową funkcjonalność.
*
Akceptowana jest ograniczona składnia Textile. Wszystkie tagi HTML zostaną usunięte.