System reakcji à la Facebook z wykorzystaniem jQuery, MySQL i PHP

Artykuł dodany: 03 września 2017.

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

Facebookowe “Lubię to” zna chyba większość internautów. Samo wykonanie takiego systemu nie jest szczególnie skomplikowane. W naszym artykule posłużymy się bazą danych MySQL jako magazynem danych. Potrzebna też będzie najnowsza wersja biblioteki jQuery. Dlatego aby kontynuować należy, chociaż w podstawowym zakresie, znać JavaScript oraz język SQL. Samą aplikację można wykorzystać w wielu zastosowaniach, choćby jako system głosowania.

Nasz system będzie musiał spełniać następujące warunki:

- wczytywanie / aktualizacja danych będą odbywać się w tle (żądania XHR)
- połączenie z bazą wykonamy poprzez sterownik PDO
- możliwe będzie dodanie kolejnych reakcji. Zmiana taka nie będzie wymagała ingerencji w kod JS

Dla ułatwienia przyjmę, że nasz użytkownik będzie rozróżniany tylko po adresie IP (bez wymaganego logowania). Ma to oczywiście swoje wady ale na potrzeby artykułu powinno wystarczyć. W rzeczywistej aplikacji, IP użytkownika powinno się zastąpić np. jego identyfikatorem pobranym z sesji. Polubienia powinny być powiązane z jakąś stroną, artykułem czy komentarzem.

Przygotowanie bazy danych MySQL

Nasza baza danych może mieć postać:

CREATE TABLE `likes` (
    `page_id` INT(11) NOT NULL,
    `user` INT(10) UNSIGNED NOT NULL,
    `like_status` TINYINT(1) NOT NULL DEFAULT '0',
    PRIMARY KEY (`page_id`, `user`)
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB;

Kolumna `page_id` identyfikuje stronę na której system jest osadzony. Przyjmę, że dla całego kodu, będzie to na stałe wartość `1`. Na swojej stronie powinieneś to zamienić na konkretny np. artykuł i powiązać kluczem obcym z odpowiednią tabelą. IP użytkownika będziemy przechowywać w kolumnie `user` jako UNSIGNED INT. Ponieważ chcemy aby użytkownik mógł dodawać unikalne polubienia w ramach danej strony, kolumny `page_id` oraz `user` są kluczem głównym. Kolumna `like_status` jest typu TINYINT (albo inaczej BOOLEAN) i będzie przechowywać wartość `0` oraz `1`. Wartością domyślną dla wszystkich nowych reakcji powinno być `0`. Jeśli chciałbyś dodać kolejną reakcję, może to być kolumna `angry_status` BOOLEAN NOT NULL DEFAULT ‘0’.

Struktura HTML dokumentu

Dokument (HTML5) powinien być zapisany z kodowaniem UTF-8. Wygląd to oczywiście kwestia indywidualna, dlatego dołączę tylko podstawowe formatowanie.

<!doctype html>
<html lang="pl">
    <head>
        <meta charset="UTF-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
        <title>System reakcji - samouczek ProPHP</title>
        <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
        <style>
            #rating a {
                display: block;
                width: 64px;
                height: 64px;
            }

            #rating #like {
                background: transparent url('img/likeit.png') no-repeat 0 -64px;
            }

            #rating #like.on {
                background-position-y: 0;
            }

            .message {
                padding: 5px;
                background-color: red;
                color: #fff;
                display: none;
                position: fixed;
                top: 0;
                right: 0;
                padding: 10px;

            }
        </style>
        <script>
            $(function() {
            // Tutaj nasz kod JS
            // Można go też dołączyć jako zewnętrzny plik
            });
        </script>
    </head>
    <body>
        <main>
            <div class="message"></div>
            <div id="rating" data-pageid="1">
                <a href="#" id="like"></a>
            </div>
        </main>
    </body>
</html>

Element `div.message` to okienko, w którym wyświetlane będą komunikaty błędów w odpowiedziach XHR. Właściwą częścią systemu jest element `#rating`. W atrybucie `data-pageid` będziemy przechowywać informacje o konkretnej stronie, do której reakcji powinna być przypisana. Link `#like` w naszym przypadku będzie grafiką (link do niej dostępny na końcu artykułu) zawierającą obrazek dla stanu aktywnego / nieaktywnego. Plik graficzny ma rozmiar 64px x 128px. W zależności od tego czy polubienie posiada klasę `.on` czy `.off` będziemy przesuwać tło tego elementu o 64px w pionie. Jest to znana od dawna technika podmiany obrazków dla różnych stanów. Zamiast tego można też wykorzystać własne fonty z tłem graficznym i np. zmieniać tylko kolor. Cały kod JS zostanie wykonany w chwili, gdy dostępne będzie drzewo DOM w przeglądarce ( $(function() {…}); ). Domyślnie ustawiony jest nieaktywny stan dla like’a i dopiero żądanie XHR wczyta odpowiednie dane.

Pobieranie danych o reakcjach

Pora zająć się pobieraniem danych. Postanowiłem wczytywać je dynamicznie, co da nam większe pole do nauki. Ma to taki minus, iż na stronie może być widoczne mignięcie podczas podmiany obrazka (jeżeli ktoś kliknął wcześniej polubienie). Żądanie XHR oczywiście trwa chwilę, reakcja będzie tym wolniejsza, im wolniejszy nasz serwer. Kod po stronie PHP omawiany był już w jednym z wcześniejszych artykułów. Należy pamiętać o ustawieniu odpowiednich nagłówków tak, aby jQuery nie miało problemu z przetwarzaniem danych. W celu wysłania żądania będzie nam potrzebnych kilka przygotowań:

$(function() {
    // URL naszego skryptu
    var url = 'likes_ajax.php';
    // Numer strony - na sztywno podana testowo wartość 1
    var pageId = $('#rating').data('pageid');
    // Nazwa klasy aktywnej / nieaktywnej
    var classStates = {
        0: 'off',
        1: 'on'
    };
});

Status aktywny / nieaktywny `classStates` postanowiłem podać jako obiekt JS. Dzięki temu, nie trzeba będzie nigdzie w kodzie podawać na sztywno nazw klas CSS.

Serwer będzie zwracał odpowiedź w formacie JSON. Zarówno dla pobranych jak i zaktualizowanych danych odpowiedź będzie jednakowa, dlatego przetwarzanie jej umieścimy wewnątrz funkcji `parseResponse`. Dane pobierzemy metodą jQuery.ajax.

var classStates... 
// Parsowanie odpowiedzi XHR
var parseResponse = function (result) {};

// Pobranie danych początkowych
$.ajax({
    url: url,
    method: 'POST',
    dataType: 'json',
    data: {
        action: 'select',
        pageId: pageId
    }
})
.done(function(result) {
    parseResponse(result);
})
.fail(function(jqXHR) {
    $('.message').html(jqXHR.responseJSON.message).show();
});

Konfigurując żądanie AJAX należy pamiętać o przesłaniu odpowiednich danych. Kod PHP będzie rozróżniał dwie akcje: `select` oraz `save`. Metoda żądania powinna być ustawiona na `POST`. W przypadku niepoprawnej odpowiedzi (fail) dodamy odpowiedni komunikat do elementu `div.message`. Z kolei poprawna odpowiedź będzie w formacie:

{"data":{"like":"0"}}

Dla kolejnych reakcji było by to przykładowo:

{"data":{"like":"0","angry":"1"}}

`like` odpowiada wartości kolumny w bazie danych `like_status` (w kodzie PHP jest to alias – do tego jeszcze przejdziemy). Jest zarazem powiązany z identyfikatorem `#like`. Chcemy aby nasz system był dynamiczny i kolumny z bazy były bezpośrednio wiązane z odpowiednimi linkami `a#nazwa_kolumny`. Aby to zrobić, musimy zdefiniować ciało funkcji `parseResponse`:

// Parsowanie odpowiedzi XHR
var parseResponse = function (result) {
    // jeśli odpowiedź nie jest pusta
    if (!$.isEmptyObject(result)) {
        // potrzebny nam będzie identyfikator a#id
        // zmapujemy go z danych zawartych w obiekcie result.data
        // zmienna `key` będzie tym kluczem - w przykładzie wartość `like`
        for (var key in result.data) {
            // wartość klucza 0 lub 1
            var value = result.data[key];
            // wyczyścimy wszystkie klasy on oraz off aby nie kolidowały
            $.each(classStates, function(i, v){
                $('#' + key).removeClass(v);
            });
            // do odpowiedniego identyfikatora (a#like) dodamy klasę on lub off
            // zależną od wartości pobranej z bazy - 0 lub 1
            $('#' + key).addClass(classStates[value]);
        }                        
    }
};

Kod ma zadanie usunąć wszystkie klasy `.on.` oraz `.off` i do właściwego elementu a#reakcja dodać ponownie poprawną klasę – zależną od stanu `1` lub `0` pobranego z odpowiedzi PHP.

Po stronie JS to wszystko jeżeli chodzi o pobieranie danych. Pora zająć się plikiem `likes_ajax.php` do którego trafiają wszystkie żądania.

likes_ajax.php

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

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode([
        'message' => 'Nieprawidłowa metoda. Dozwolone tylko POST.'
    ]);
    return;
}

try {
    $dbh = new PDO('mysql:dbname=test;host=127.0.0.1', 'user', 'hasło',
        [
            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"
        ]
    );
    $dbh->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
} catch(PDOException $e) {
    http_response_code(500);
    echo json_encode([
        'message' => 'Klasa PDO zwróciła wyjątek: ' . $e->getMessage()
    ]);
    return;
}

Na początku ustawiamy nagłówek informujący skrypt iż odpowiedź będzie w formacie `application/json` (wszelkie nagłówki muszą być wysłane przed jakąkolwiek treścią). Jeżeli metodą żądania nie jest `POST`, zabraniamy dalszego wykonywania – z kodem błędu 405 (Method not allowed). Następnie nawiązujemy połączenie z bazą. Jeżeli połączenie się nie powiodło przechwytujemy wyjątek `PDOException` i zwracamy do klienta komunikat wyjątku w kluczu `message`. Komunikat w środowisku produkcyjnym warto zamienić na inny.

Pora pobrać odpowiednie dane. Podobnie jak w funkcji `parseResponse`, kod SELECT będzie wykonywany dwukrotnie (dla akcji `select` i `save`), dlatego warto umieścić go w funkcji:

/**
 * Pobieranie danych o reakcjach z bazy
 * 
 * @param \PDO $dbh Obiekt PDO połączenia z bazą
 * @param int $pageId numer strony
 * @param string $user IP użytkownika
 * @return array
 */
function fetch(\PDO $dbh, $pageId, $user) {
    $sql = 'SELECT `like_status` AS `like` FROM `likes` WHERE `page_id` = ? AND INET_NTOA(`user`) = ? LIMIT 1';
    $sth = $dbh->prepare($sql);
    $sth->execute([
        $pageId,
        $user
    ]);

    $result = $sth->fetch(PDO::FETCH_ASSOC);
    return (!empty($result)) ? $result : [];
}

Funkcja posiada trzy argumenty, wszystkie wymagane: $dbh (obiekt połączenia z bazą), $pageId (numer strony) oraz $user (IP użytkownika). W zapytaniu wybieramy wartość kolumny `like_status` i robimy na nim alias, będący równocześnie identyfikatorem w kodzie HTML. Gdybyś chciał dodać kolejne reakcje, zapytanie mogłoby wyglądać:

SELECT `like_status` AS `like`, `angry_status` AS `angry`...

Pamiętaj też o `` wokół nazw inaczej dostaniesz błąd. Słowo `like` jest słowem kluczowym w MySQL. Ciekawa jest funkcja INET_NTOA(`user`). Przekształca ona liczbową reprezentację adresu IP na adres właściwy (`127.0.0.1` to `2130706433`). Dalej wykonujemy zapytanie i w zależności od wyniku, zwracamy jego albo pustą tablicę.

Pora teraz wykonać logikę dla akcji `select` ustalonej w JS:

$pageId = (int) $_POST['pageId'];
$action = isset($_POST['action']) ? $_POST['action'] : null;

if ($action === 'select') {
    try {
        $result = fetch($dbh, $pageId, $_SERVER['REMOTE_ADDR']);
        echo json_encode([
            'data' => $result
        ]);
    } catch(PDOException $e) {
        http_response_code(500);
        echo json_encode([
            'message' => 'Klasa PDO zwróciła wyjątek: ' . $e->getMessage()
        ]);
        return;
    }
} else {
    http_response_code(500);
    echo json_encode([
        'message' => 'Nieprawidłowa akcja'
    ]);
    return;
}

Dla akcji `select` pobieramy dane, dla innych wyświetlamy komunikat błędu “Nieprawidłowa akcja”. Cały `if` akcji jeszcze raz obejmujemy blokiem try/catch – warto to robić aby wyłapać błędy. Gdyby np. znalazła się literówka w nazwie tabeli, PHP wyświetliło by błąd “Uncaught Exception…” psując tym samym odpowiedź JSON. Wywołujemy teraz naszą funkcję `fetch` podając jako użytkownika adres IP. Ponieważ zadbaliśmy już wcześniej o wygenerowanie poprawnej tablicy (dane właściwe albo pusty `array`), jedyne co nam pozostaje to zakodować odpowiedź funkcją json_encode.

Po zapisaniu i odświeżeniu strony możemy sprawdzić narzędzia deweloperskie przeglądarki. W zakładce “sieć” powinno znaleźć się nasze żądanie. Klikając na nie, dowiesz się jakie były nagłówki oraz odpowiedź.

Pobieranie i przetwarzanie odpowiedzi działa – pora zająć się zapisywaniem danych.

Zapisywanie danych w bazie

Większość kodu jest już gotowa. Musimy teraz dodać akcje na kliknięcie w poszczególne elementy `a#id`. Wykorzystamy tu delegację zdarzeń o której pisałem wcześniej.

// Pobranie danych początkowych $.ajax({...});
$('#rating').on('click', 'a', function(e) {
    e.preventDefault();

    // Aktualizacja danych
    $.ajax({
        url: url,
        method: 'POST',
        dataType: 'json',
        data: {
            action: 'save',
            pageId: pageId,
            element: $(this).attr('id')
        }
    })
    .done(function(result) {
        parseResponse(result);
    })
    .fail(function(jqXHR) {
        $('.message').html(jqXHR.responseJSON.message).show();
    });
});

Tym razem zmieniamy w danych `action` na `save` oraz dodajemy id elementu (element: $(this).attr(‘id’) zwróci nam np. `like`). `this` odnosi się klikniętego elementu `a`. W odpowiedzi oczekujemy te same dane co dla zwykłego pobierania danych.

Po stronie PHP kod prezentuje się następująco:

if ($action === 'select') {...}
elseif ($action === 'save') {
    // Lista dozwolonych kolumn w parametrze `element`
    $allowedColumns = [
        'like' => 'like_status'
    ];

    $columnName = isset($_POST['element']) ? $_POST['element'] : null;
    if (!array_key_exists($columnName, $allowedColumns)) {
        http_response_code(500);
        echo json_encode([
            'message' => 'Nieprawidłowy wartość dla pola `element`.'
        ]);
        return;
    }
    $columnName = '`' . $allowedColumns[$columnName] . '`';

    try {
        $sql = "INSERT INTO `likes` (`page_id`, `user`, $columnName) VALUES (?, INET_ATON(?), 1)";
        $sql.= "ON DUPLICATE KEY UPDATE $columnName = NOT $columnName;";

        $sth = $dbh->prepare($sql);
        $sth->execute([
            $pageId,
            $_SERVER['REMOTE_ADDR']
        ]);

        $result = fetch($dbh, $pageId, $_SERVER['REMOTE_ADDR']);
        echo json_encode([
            'data' => $result
        ]);
    } catch(PDOException $e) {
        http_response_code(500);
        echo json_encode([
            'message' => 'Klasa PDO zwróciła wyjątek: ' . $e->getMessage()
        ]);
        return;
    }
}

Ponieważ lista kolumn jest dynamiczna a my nie chcemy otworzyć luki w naszym skrypcie (PDO nie ma bezpośrednio możliwości zabezpieczenia nazw kolumn), definiujemy listę możliwych kolumn i ich mapowań w zmiennej $allowedColumns. Jeśli wszystko się zgadza dodajemy ciapki `` wokół jej nazwy. Do zapisu danych wykorzystamy zapytanie INSERT … ON DUPLICATE KEY UPDATE … W naszym przypadku, jeżeli nie istnieje powtórzenie klucza głównego, wstawią się nowe dane. Jeżeli użytkownik wcześniej klikał reakcję, nastąpi jej odwrócenie – `1` stanie się `0` a `0` przekształcone zostanie na `1`. Wykorzystujemy też odwrotność poprzednio użytej funkcji czyli INET_ATON(). Przekształci ona adres IP na wartość liczbową. Ponieważ potrzebne są nam właściwe dane do wypełnienia odpowiednich pól przez JS, na koniec jeszcze raz wykonujemy funkcję `fetch`.

I to wszystko. Mamy teraz przygotowany cały kod pobierający i zapisujący dane użytkownika. Warto tylko pamiętać o dodaniu kolejnych mapowań jeśli w systemie ma znaleźć się więcej reakcji.

Podsumowanie

Stworzenie działającego systemu reakcji, przy dostępnych gotowych bibliotekach, nie jest przesadnie skomplikowane. Moim zdaniem cała trudność polega na zrozumieniu, w jaki sposób przerzucać dane JSON pomiędzy serwerem i klientem. Gdy to mamy już opanowane pozostaje sama przyjemność z programowania. A Ty jak sądzisz? Podziel się uwagami w komentarzach.

Pliki projektu w serwisie 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.