Opis projektu

Jest to projekt, który pisałem ucząc się używania bazy danych w PHP. Kilka dni temu odświeżyłem go, dopisałem testy i postanowiłem udostępnić.

Z artykułu dowiesz się jak centralizować konfigurację projektu, logować zdarzenia na stronie do bazy danych, testować stronę wykorzystując selenium.

Skład kodu źródłowego to:

PHP 43.2% Perl 19.8% HTML 19.6% Cucumber 7.4% JavaScript 6.5% CSS 3.5%

Po napisaniu projekt będzie wyglądał tak:

Instalacja

Uwaga! Zanim włączysz install.pl upewnij się, że nie masz bazy o nazwie calc, zanim włączysz install.sh upewnij się, że nie masz chrome w sources.list. Skrypy instalacyjne w perlu i bashu nie są długie, zapoznaj się z nimi przed uruchomieniem.

Instalację projektu zalecam przeprowadzić na maszynie wirtualnej, np.: Lubuntu.

Aby zainstalować projekt należy pobrać repozytorium (w lokacji, w której nie ma katalogu calc)

git clone https://github.com/gustawdaniel/calc

Przejść do katalogu calc i zainstalować potrzebne oprogramowanie. Przed instalacją przejrzyj plik install.sh i wykomentuj dodawanie repozytorium chrome jeśli masz je już zainstalowane.

cd calc && bash install.sh

Sprawdź swoje parametry połączenia z bazą danych mysql. Jeśli podczas instalacji klikałeś tylko enter i nie miałeś wcześniej zainstalowanego pakietu mysql-server możesz zostawić domyślne. W przeciwnym wypadku wpisz poprawne wartości do pliku config/parameters.yml i usuń go z repozytorium.

git rm --cached config/parameters.yml

Aby zainstalować bazę danych i włączyć serwer php wpisz komendę

perl install.pl

W nowym terminalu (ctrl+n) włącz serwer selenium

selenium-standalone start

W kolejnym możesz włączyć testy:

vendor/bin/behat

Możesz również normalnie korzystać ze strony, która wystawiona jest na porcie 9000

firefox localhost:9000

Jeśli masz domyślne parametry łączenia z bazą, to, żeby zobaczyć zawartość bazy danych wpisz

sudo mysql -u root
use calc;
select * from log;

Struktura bazy

Zwykle zaczynam projekt od bazy danych. Jej instalację umieściłem w pliku sql/main.sql.

sql/main.sql

DROP   DATABASE IF     EXISTS database_name;
CREATE DATABASE IF NOT EXISTS database_name
    DEFAULT CHARACTER SET = 'utf8'
    DEFAULT COLLATE 'utf8_unicode_ci';

USE database_name;

CREATE TABLE log
(
    id      	  BIGINT UNSIGNED    		NOT NULL AUTO_INCREMENT PRIMARY KEY,
    time   		  DATETIME           		NOT NULL,
    a      		  DOUBLE					,
    b      		  DOUBLE					,
    button  	  ENUM('sum', 'diff')       ,
    useragent	  VARCHAR(255)
);

Istotne jest, że nazwa bazy, jaką stworzymy to nie database_name lecz nazwa podana później w pliku konfiguracyjnym. Zastąpi ona tą nazwę dzięki zastosowaniu języka perl, który “skompiluje” ten skrypt do wykonywalnej postaci. O tym będzie kolejny rozdział.

Konfiguracja

Bardzo dobrym nawykiem, który wyniosłem z pracy z Symfony jest trzymanie parametrów dotyczących połączenia z bazą danych poza kodem projektu. Jeszcze lepszym jest rozdzielenie parametrów prywatnych (które mogą zawierać loginy i hasła ze środowiska produkcyjnego - nie trzymanych w repozytorium), od domyślnych.

W tym przykładzie stosujemy jedynie domyślne parametry. Umieścimy je w pliku parameters.yml w katalogu config.

config/parameters.yml

config:
    host: 'localhost'
    user: 'root'
    pass: ''
    base: 'calc'
    port: '3306'

Będziemy się do nich odnosić w instalatorze napisanym w perlu oraz w klasie odpowiadającej za zapis do bazy danych w PHP.

Konfiguracja w Perlu

Napiszemy dwa skrypty - do tworzenia, oraz do resetowania bazy. Do odczytywania pliku parameters.yml wykorzystamy bibliotekę YAML::Tiny. Poniższy skrypt kolejno:

Odczytuje plik z parametrami do zmiennej $yaml. Zapisuje wszystkie parametry do odpowiednich zmiennych.

install.pl

#!/bin/perl

use YAML::Tiny;

use strict;
use warnings;

#
#       Config:
#

    my $yaml = YAML::Tiny->read( 'config/parameters.yml' );
    my $baseName  = $yaml->[0]->{config}->{base};
    my $user  = $yaml->[0]->{config}->{user};
    my $pass  = $yaml->[0]->{config}->{pass};
    my $host  = $yaml->[0]->{config}->{host};
    my $port  = $yaml->[0]->{config}->{port};

Tworzy zmienne z ustawieniami katalogów. (Instrukcje tworzące bazę znajdują się w pliku main.sql.)

#
#       Catalogs structure:
#

    my $build = "build/";
    my $sql = "sql/";
    my $mainSQL = "main.sql";

Otwiera plik z kodem sql i zapisuje treść do zmiennej $content.

#
#       Script:
#


#-----------------------------------------    Database   -------------#

#       Prepare catalog
    system('mkdir -p '.$build);

#       Read file with mysql
    my $content;
    open(my $fh, '<', $sql.$mainSQL) or die "cannot open file";
    {
        local $/;
        $content = <$fh>;
    }
    close($fh);

Zamienia każde wystąpienie ciągu database_name na nazwę z pliku parameters.yml i zapisuje.

#       Replace database name by name from config
    $content =~ s/database_name/$baseName/g;

#       Save file with correct db name
    open($fh, '>', $build.$mainSQL) or die "Could not open file' $!";
    {
        print $fh $content;
    }
    close $fh;

Nadaje domyślnemu użytkownikowi prawo otwierania bazy jako root, tworzy bazę i włącza serwer php.

#       Execute file
    my $passSting = ($pass eq "") ? "" : " -p ".$pass;
    system('sudo mysql -h '.$host.' -P '.$port.' -u '.$user.$passSting.' < '.$build.$mainSQL);

#       Start server
    system('cd web && php -S localhost:9000');

Konfiguracja w PHP

Do obsługi pliku konfiguracyjnego w php zastosujemy bibliotekę "mustangostang/spyc": "^0.6.1". Będzie ona wykorzystana jedynie przy łączeniu się z bazą - w pliku php/DataBase.php.

php/DataBase.php

<?php

require_once __DIR__."/../vendor/mustangostang/spyc/Spyc.php";

class DataBase
{

	...

		// config from yml
		$config = Spyc::YAMLLoad(__DIR__."/../config/parameters.yml")["config"];

		// connecting
		$mysqli = @new mysqli($config["host"], $config["user"], $config["pass"], $config["base"], $config["port"]);

	...

W do zmiennej $config zapisywana jest tablica z parametrami do połączenia z bazą. Zasada działania jest taka sama, jak w poprzednim skrypcie.

Logowanie danych w bazie

W paragrafie dotyczącym struktury bazy pokazaliśmy jakie rekordy zawiera jedyna tabela jaką mamy - log. Są to id, time, a, b, button i useragent. a i b odpowiadają liczbom wpisanym przez użytkownika. button jest akcją którą wybrał sum dla sumy lub diff dla różnicy. useragent to dane dotyczące przeglądarki.

Odwzorujemy teraz rekord bazy danych w php jako obiekt. W tym celu tworzymy klasę Log w pliku php/Log.php

php/Log.php

<?php

class Log
{
    private $a;
    private $b;
    private $action;
    private $agent;

    /**
     * @return mixed
     */
    public function getC()
    {
        if($this->action=="sum"){
            return $this->a + $this->b;
        } elseif ($this->action=="diff") {
            return $this->a - $this->b;
        } else {
            return null;
        }
    }

   ...
}

Zawiera ona wszystkie pola z tabeli poza identyfikatorem i czasem, które nadawane są podczas zapisu do bazy. Przez trzy kropki oznaczyłem wszystkie gettery i settery dla własności klasy. W większości IDE można je wygenerować automatycznie, np.: w PhpStorm wybierając code->Generate.... Metoda getC pozwala wyliczyć wartość sumy lub różnicy po stronie serwera, co wykorzystane jest później w interfejsie API.

Teraz możemy przedstawić w całości wspomnianą wcześniej klasę DataBase, która służyła do zapisu danych otrzymanych ze strony do bazy.

php/DataBase.php

<?php

require_once __DIR__."/Log.php";
require_once __DIR__."/../vendor/mustangostang/spyc/Spyc.php";

class DataBase
{
	function save(Log $log){

		$a = $log->getA();
		$b = $log->getB();
		$s = $log->getAction();
		$u = $log->getAgent();

		// config from yml
		$config = Spyc::YAMLLoad(__DIR__."/../config/parameters.yml")["config"];

		// connecting
		$mysqli = @new mysqli($config["host"], $config["user"], $config["pass"], $config["base"], $config["port"]);

		// test of connecting
		if ($mysqli -> connect_errno)
		{
			$code = $mysqli -> connect_errno;
			$mess = $mysqli -> connect_error;
			die("Failed to connect to MySQL: ($code) $mess\n");
		}

		// definition of query
		$query  = 'INSERT INTO log VALUES(NULL,NOW(),?,?,?,?);';

		// preparing
		$stmt = @$mysqli -> prepare($query);

		// test of preparing
		if(!$stmt)
		{
			$code = $mysqli -> errno;
			$mess = $mysqli -> error;
			$mysqli -> close();
			die("Failed to prepare statement: ($code) $mess\n");
		}

		// binding
		$bind = @$stmt -> bind_param("ddss", $a, $b, $s, $u);

		// test of binding
		if(!$bind)
		{
			$stmt   -> close();
			$mysqli -> close();
			die("Failed to bind param.\n");
		}

		// executing query
		$exec = @$stmt -> execute();

		// checking fails
		if(!$exec)
		{
			$stmt   -> close();
			$mysqli -> close();
			die("Failed to execute prepare statement.\n");
		}

		// clearing and disconnecting
		$stmt   -> close();
		$mysqli -> close();
	}
}

Klasa ta nie ma własności, ma za to jedną metodę - save. Ta metoda pobiera obiekt Log i wykonuje logowanie do bazy danych wszystkich własności tego obiektu, przy czym dodaje jeszcze czas. Najciekawsza część tej klasy - pobieranie konfiguracji była omówiona wcześniej. Reszta jest po prostu w zwykłym zapisem do bazy.

To były klasy, teraz czas na skrypt wejściowy back-endu naszej aplikacji. Znajduje się w pliku web/api.php i odpowiada za poprawne przechwycenie żądania, pobranie parametrów, przekazanie ich bazie i oddanie odpowiedzi zawierającej wynik działania.

<?php

// error display
//ini_set('display_errors', 1);
//ini_set('display_startup_errors', 1);
//error_reporting(E_ALL);

require_once __DIR__."/../php/Log.php";
require_once __DIR__."/../php/DataBase.php";

// routing
if($_SERVER['REQUEST_METHOD']=="POST"
    && parse_url($_SERVER["REQUEST_URI"])["path"]=="/api.php/action"){

    // get data from request
    $log = new Log();
    $log->setA($_POST["a"]);
    $log->setB($_POST["b"]);
    $log->setAction($_POST["action"]);
    $log->setAgent($_SERVER['HTTP_USER_AGENT']);

    // connect to db and save data
    $db = new DataBase();
    $db->save($log);

    // send response
    header('Content-type: application/json');
    echo json_encode([
        "a"=>$log->getA(),
        "b"=>$log->getB(),
        "c"=>$log->getC(),
        "action"=>$log->getAction()
    ]);
}

Testowanie Api przez httpie

Możemy przetestować nasze api wykorzystując httpie. Komenda

http -fv 127.0.0.1:9000/api.php/action a=1 b=2 action="sum"

powinna wyprodukować następujący output:

POST /api.php/action HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 18
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: 127.0.0.1:9000
User-Agent: HTTPie/0.9.2

a=1&b=2&action=sum

HTTP/1.1 200 OK
Connection: close
Content-type: application/json
Host: 127.0.0.1:9000
X-Powered-By: PHP/7.0.8-0ubuntu0.16.04.3

{
    "a": "1",
    "action": "sum",
    "b": "2",
    "c": 3
}

AJAX

Kiedy mamy gotową bazę oraz skrypty do jej obsługiwania, nic nie stoi na przeszkodzie dokończenia projektu przez napisanie frontu. Zakładamy, że instalacja przebiegła pomyślnie i bower zainstalował potrzebne paczki - to znaczy "bootstrap": "v4.0.0-alpha.5" w katalogu web. Ponieważ jQuery jest zależnością dla Bootstrapa możemy z niej skorzystać przy tworzeniu skryptów.

Nasz front składa się z trzech plików: web/index.html, web/css/style.css i web/js/site.js. Oto one:

web/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Php calculator logging requests into database.</title>

    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css">
    <link href="https://fonts.googleapis.com/css?family=Lato:300" rel="stylesheet">
    <link rel="stylesheet" href="css/style.css">
  </head>
  <body>

    <section>
      <div class="container">
        <div class="row">
          <div class="offset-md-3 col-md-6">
            <div class="card text-xs-center">
              <div class="card-header">
                Set two numbers and chose calculation
              </div>
              <div class="card-block">
                <div class="form-group">
                  <input id="a" type="number" step="any" class="form-control">
                </div>
                <div class="form-group">
                  <input id="b" type="number" step="any" class="form-control">
                </div>

                <div class="form-group row submit-area">
                  <div class="col-xs-6">
                    <input class="btn btn-lg btn-block hidden-xs-down btn-primary" type="submit" value='Sum' name="sum">
                    <input class="btn btn-lg btn-block hidden-sm-up btn-primary" type="submit" value='+' name="sum">
                  </div>
                  <div class="col-xs-6">
                    <input class="btn btn-lg btn-block hidden-xs-down btn-danger" type="submit" value='Difference' name="diff">
                    <input class="btn btn-lg btn-block hidden-sm-up btn-danger" type="submit" value='-' name="diff">
                  </div>
                </div>
                <div class="form-group">
                  <input id="c" type="text" readonly step="any" class="form-control">
                </div>

              </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>


    <nav class="navbar navbar-fixed-bottom navbar-light bg-faded">
      <a class="navbar-brand" href="README.html">Documentation</a>
      <a class="navbar-brand float-xs-right" href="http://gustawdaniel.pl">Daniel Gustaw</a>
    </nav>

    <script src="bower_components/jquery/dist/jquery.min.js"></script>
    <script src="js/site.js"></script>
  </body>
</html>

Standardowy plik html. To co jest w nim ciekawego, to wykorzystanie klasy card z bootstrap 4 oraz zmiana napisów na przyciskach z pełnych nazw na znaki + i - przy małych szerokościach ekranu.

Jeszcze prostsze są style naszej strony.

web/css/style.css

body {
    font-family: 'Lato', 'SansSerif', serif;
}

section {
    margin-top: 20vh;
}

Jest to zasługa Bootstrapa który naprawdę dużo potrafi odwzorować tak, jak bym oczekiwał. Jedyne czego potrzebujemy to margines pionowy i czcionka.

Najciekawsza część to JavaScript:

web/js/site.js

(function () {

    var submitArea = document.getElementsByClassName("submit-area")[0];
    var card = document.getElementsByClassName("card")[0];
    var a = document.getElementById("a");
    var b = document.getElementById("b");
    var c = document.getElementById("c");

    function round(value,dec=5) {
        return 1*(Math.round(value+"e+"+dec)+"e-"+dec);
    }

    submitArea.addEventListener('click',function (e) {
        if(e.target.name=='sum') {
            c.value = round((a.value*1) + (b.value*1));
        } else if(e.target.name=='diff') {
            c.value = a.value - b.value;
        }

        $.post("api.php/action", {a: a.value, b: b.value, c: c.value, action: e.target.getAttribute('name')}, function (data) {
            console.log(data);
        })
    });

})();

Cały zawarty jest w funkcji anonimowej, co zapewnia enkapsulację - nie mieszamy naszych zmiennych z globalnymi. Struktura skryptu jest następująca. Najpierw definiujemy zmienne powiązane z elementami htmla, później umieszczamy funkcje pomocnicze - u nas round, na koniec definiujemy listener.

Funkcja round pozwala na zaokrąglanie obliczeń w JavaScript. Domyślna funkcja round z obiektu Math zawsze zaokrągla do liczb całkowitych. Wartość domyślna liczby miejsc po przecinku definiowana przez znak = jest stosunkowo nowym rozwiązaniem w JavaScript. Wnętrze funkcji pełnymi garściami czerpie z dynamicznego typowania i notacji naukowej do przedstawiania liczb w tym języku.

Zauważ, że ponieważ przyciski do liczenia sumy i różnycy występują podwójnie (ze względu na responsywność aplikacji), dopiero wewnątrz listenera musimy określić który z nich został wybrany. Jeśli jest to suma, mnożymy nasze wartości przez 1, aby znak + oznaczał dodawanie, a nie konkatenację.

Natychmiast po zidentyfikowaniu, który przycisk został wybrany, następuje aktualizacja wyniku. Dopiero wtedy wysyłane jest żadnie POST co dzięki jQuery jest wyjątkowo proste. Takie rozwiązanie ma zalety i wady. Zaletą jest szybkość, użytkownik nie musi czekać naodpowiedź z serwera. Wadą jest duplikacja logiki odpowiedzialnej za wykonywanie obliczeń. Nie trudno domyślić się, że z powodu innych zaokrągleń wyniki przekazywane w odpowiedzi API będą mogły różnić się od tych wyświetlanych na stronie.

Behat i Selenium

Behat jest narzędziem do pisania behawioralnych testów automatycznych. Jest to najbardziej naturalny dla człowieka sposób testowania oparty o historie, które mogą się wydażyć podczas korzystania z aplikacji. Selenium to serwer pozwalający symulować przeglądarkę, wyposażony w programistyczne API. Łącząc te dwa narzędzia otrzymujemy możliwość pisania czegoś w rodzaju bota odwiedzającego naszą stronę i wykonującego określone akcje. To właśnie użycie tego narzędzia widziałeś w video na początku wpisu.

Dzięki poleceniu vendor/bin/behat --init behat generuje domyślny plik features/bootstrap/FeatureContext.php. Rozszerzymy tą klasę dodając do niej MinkContext. Jest to zbiór tłumaczeń między naturalnym językiem Gherkin a akcjami wykonywanymi przed drivery przeglądarki takie jak selenium.

Napisałem o Gerkinie, że jest językiem naturalnym. W oficjalnej dokumentacji jest przedstawiany następująco:

Gherkin is the language that Cucumber understands. It is a Business Readable, Domain Specific Language that lets you describe software’s behaviour without detailing how that behaviour is implemented.

Poza tym rozszerzeniem dodamy kilka funkcji, których brakuje w MinkConext

features/bootstrap/FeatureContext.php

<?php

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Behat\MinkExtension\Context\MinkContext;

/**
 * Defines application features from the specific context.
 */
class FeatureContext extends MinkContext implements Context
{
    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can also pass arbitrary arguments to the
     * context constructor through behat.yml.
     */
    public function __construct()
    {
    }

    /**
     * @param String $field
     * @param String $value
     * @Given I set :field as :value
     */
    public function iSetAs($field, $value)
    {
        $javascript = 'document.getElementById("'.$field.'").value='.$value;
        $this->getSession()->executeScript($javascript);
    }

    /**
     * @Then Result should be :value
     */
    public function resultShouldBe($value)
    {
        $javascript = 'document.getElementById("c").value';
        $realResult = $this->getSession()->evaluateScript($javascript);

        if ( $value !== $realResult) {
            throw new Exception(
                "Actual result is:\n" . $realResult
            );
        }
    }

    /**
     * @param String $number
     * @When I wait :number ms
     */
    public function iWaitMs($number)
    {
        $this->getSession()->wait($number);
    }

    /**
     * @param String $number
     * @When I wait :number ms for jQuery
     */
    public function iWaitMsForJQuery($number)
    {
        $this->getSession()->wait($number, '(0 === jQuery.active)');
    }
}

Te funkcje to ustawianie wartości pola, kiedy nie znajduje się ono w formulażu, sprawdzanie poprawności wyniku i czekanie: zwykłe, oraz pozwalające nie czekać dłużej jeśli wszystkie requesty zostały wykonane.

Mając przygotowany kontekst możemy przyjrzeć się zawartości pliku opisującego testy

features/calculation.feature

Feature: Executing calculations on the website
  In order to calculate sum or difference
  As an web browser
  I want to see result after pressing button

  @javascript
  Scenario Outline: Action on two numbers
    Given I am on the homepage
    And I set "a" as <a>
    And I set "b" as <b>
    When I press "<action>"
    And I wait 1000 ms for jQuery
    Then Result should be <result>

    Examples:
      | a      | b       | action | result |
      | 1      | 2       | sum    | 3      |
      | 3      | 6       | sum    | 9      |
      | 100    | 2000    | sum    | 2100   |
      | -1.5   | -3.1    | sum    | -4.6   |
      | 1.9990 | -0.0090 | sum    | 1.99   |
      | 1      | 2       | diff   | -1     |
      | -1     | -2      | diff   | 1      |
      | 1.001  | 2.001   | diff   | -1     |
      | 0.993  | 9.33    | diff   | -8.337 |
      | 12     | -12     | diff   | 24     |

Zawiera on scenariusz składający się z 6 kroków powtórzyny w 10 konfiguracjach. Te kroki to typowe wykonywanie obliczeń na stronie - ustawienie, a, b wybranie przycisku, czekanie na rezultat i sprawdzenie jego poprawności.

Żeby wszystko zadziałało poprawnie brakuje jeszcze pliku konfiguracyjnego behata. Jest to behat.yml.

behat.yml

default:
  extensions:
    Behat\MinkExtension:
      browser_name: chrome
      base_url:  'http://localhost:9000'
      sessions:
        default:
          goutte: ~
        selenium:
          selenium2: ~

To już wszystko. Jeśli prześledziłeś kod aż do tego momentu, znasz ten projekt na wylot. Mam nadzieję, że czegoś się nauczyłeś, a jeśli widzisz miejsca, gdzie mógł bym coś poprawić, śmiało daj mi znać. Będę wdzięczny za wszystkie konstruktywne uwagi.