Poniższy poradnik pomoże Wam skonfigurować i wdrożyć na serwer w pełni działającą aplikację opartą o PHP Symfony oraz zautomatyzować cały proces testowania i deploymentu przy pomocy Buddy CI/CD.

Symfony i Continuous Delivery

Poradnik opisuje następujące elementy:

  • Instalację Symfony
  • Tworzenie nowego projektu Symfony
  • Dodawanie nowej funkcjonalności
  • Tworzenie formularza, kontrolera oraz widoku
  • Konfigurację pipeline'a pod Continuous Integration
  • Konfigurację pipeline'a pod Continuous Delivery
  • Wdrażanie aplikacji Symfony na serwer (tu: AWS Elastic Beanstalk)

Kompletny i działający projekt, o którym mowa w tym tekście, znajduje się na GitHubie pod adresem https://github.com/buddy-works/symfony-first-steps.

Tworzenie projektu Symfony

Zaczynamy od instalacji Symfony:

$ wget https://get.symfony.com/cli/installer -O - | bash

Następnie tworzymy nowy projekt o nazwie symfony-first-steps:

$ symfony new symfony-first-steps

Otwieramy katalog i stawiamy serwer deweloperski, aby sprawdzić czy wszystko poszło zgodnie z planem (i czy można już otworzyć pierwsze piwko):

$ cd symfony-first-steps
$ symfony serve

Pod adresem 127.0.1:8000 powinieneś zobaczyć coś takiego:

Działająca aplikacja SymfonyDziałająca aplikacja Symfony

Wszystko bangla, można więc rzucić okiem na specyfikację aplikacji do napisania.

Businnes logic

W opisie czytamy: “Aplikacja ma przyjmować dowolny tekst i zwracać ilość wystąpień każdego słowa w zadanym tekście.” Wszystko jasne – do roboty.

Instalacja PHPUnit

Zaczynamy od instalacji PHPUnit, w końcu TDD to nasz chleb powszedni:

$ composer require --dev phpunit/phpunit

Jak na prawilnego deva przystało, pamiętaj aby sprawdzić czy wszystko działa:

$ bin/phpunit

Pisanie testów

Biorąc pod uwagę, że to pierwsza instalacja, musimy chwilę poczekać, aż wszystko przejdzie. Finalnie wyświetli się nam komunikat, że nie ma żadnych testów. W takim razie trzeba go napisać:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\WordCounter;
use PHPUnit\Framework\TestCase;

final class WordCounterTest extends TestCase
{
    /**
     * @dataProvider textDataProvider
     */
    public function testWordCounter(string $text, array $result): void
    {
        self::assertEquals($result, (new WordCounter())->count($text));
    }

    public function textDataProvider(): \Generator
    {
        yield 'basic text' => ['Lorem ipsum dolor sit amet, ipsum dolor sit.', [
            'Lorem' => 1,
            'ipsum' => 2,
            'dolor' => 2,
            'sit' => 2,
            'amet' => 1,
        ]];
        yield 'leetspeak text' => ['l0r3m 1p5um d0l0r 517 4m37, 1p5um d0l0r 517.', [
            'l0r3m' => 1,
            '1p5um' => 2,
            'd0l0r' => 2,
            '517' => 2,
            '4m37' => 1,
        ]];
        yield 'ignore spaces' => ['Some       text     with     tabs and    spaces', [
            'Some' => 1,
            'text' => 1,
            'with' => 1,
            'tabs' => 1,
            'and' => 1,
            'spaces' => 1,
        ]];
    }
}

Na tą chwilę testy będą padać (czerwono po odpaleniu bin/phpunit), dodajmy więc implementację wspomnianego serwisu do zliczania słów:

<?php

declare(strict_types=1);

namespace App\Service;

final class WordCounter
{
    /**
     * @return array<string,int>
     */
    public function count(string $text): array
    {
        $words = explode(' ', preg_replace('/[^A-Za-z0-9?![:space:]]/', '', $text));

        return array_reduce($words, function (array $counts, string $word): array {
            if (trim($word) !== '') {
                $counts[$word] = isset($counts[$word]) ? ++$counts[$word] : 1;
            }

            return $counts;
        }, []);
    }
}

Na tę chwilę uruchomienie bin/phpunit zwróci nam coś takiego:

Elegancko. Teraz wchodzimy na drogę tworzenia formularza, który przyjmie tekst, a następnie przekaże go (po wstępnej walidacji) do naszego serwisu. Na końcu całość zwrócimy na nasz template, żeby wyświetlić wszystko userowi. To wszystko będzie się działo w kontrolerze, który będzie działał na danym URL (w tym przypadku będzie to po prostu /). Kontroler będzie tzw. punktem wejścia do naszej aplikacji. Zaczynamy więc od utworzenia kontrolera.

Praca z kontrolerem

Domyślnie każdy URL jest konfigurowany w pliku config/routes.yaml. Na potrzeby tego guide'a skorzystamy jednak z anotacji, które są trochę wygodniejsze. Do tego będziemy potrzebowali dodatkowej paczki. Wpisujemy więc w konsoli:

$ composer require annotations

Następnie tworzymy nowy plik: src/Controller/HomeController.php z następującą zawartością:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class HomeController
{
    /**
     * @Route("/")
     */
    public function index(): Response
    {
        return new Response('Word Counter');
    }
}

Odświeżamy przeglądarkę (http://127.0.0.1:8000/) i widzimy napis:

Działający kontrolerDziałający kontroler

Więcej o tworzeniu kontrolerów możesz przeczytać w oficjalnej dokumentacji Symfony

Ogień, pierwsza wartość biznesowa jest już dowieziona. Jedziemy dalej!

Praca z widokami

Jak zauważyłeś, z kontrolera zwróciliśmy prosty tekst, ale zazwyczaj będziemy chcieli wyrenderować stronę w HTML. Żeby uniknąć łączenia kodu HTML z PHP użyjemy do tego silnika szablonów (template engine). Domyślnym takim silnikiem dla Symfony jest Twig

W konsoli wpisujemy:

$ composer require twig

Następnie tworzymy nowy widok w katalogu templates (został automatycznie utworzony po instalacji Twiga). Dodajemy nowy plik: templates/home.html.twig o następującej treści:

{% extends 'base.html.twig' %}

{% block body %}
    <div class="container">
        <div class="row">
            <div class="col">
                <h1>Word Counter</h1>
                <p>Count unique words in given text.</p>
            </div>
        </div>
    </div>
{% endblock %}

Wykorzystujemy tutaj od razu gotowe komponenty Bootstrapa, którego dodamy sobie później w trakcie tworzenia formularza.

Teraz musimy trochę przerobić nasz kontroler. Aby ułatwić cały proces, dziedziczymy klasę AbstractController i wykorzystujemy metodę render, aby wygenerować widok. Całość po zmianach wygląda następująco:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class HomeController extends AbstractController
{
    /**
     * @Route("/")
     */
    public function index(): Response
    {
        return $this->render('home.html.twig');
    }
}

W efekcie strona wygląda teraz tak:

Licznik słów z podpisemLicznik słów z podpisem

Praca z formularzem

Dodamy teraz formularz, który pozwoli nam przyjąć dane od użytkownika, zwalidować je, a następnie przekazać do naszego serwisu liczącego ilość słówek. W przypadku Symfony, rekomendowany flow pracy wygląda tak:

  1. Utworzenie formularza w kontrolerze Symfony albo wykorzystanie dedykowanej klasy formularza
  2. Wyrenderowanie formularza w templejcie, tak aby użytkownik był w stanie go edytować i submitować
  3. Procesowanie formularza: walidacja przesłanych danych, przeformatowanie na PHP i dalsze działana (np. zapisanie w bazie danych)

Zaczynamy od zaciągnięcia paczki z formularzami:

composer require symfony/form

Symfony zaleca, by ograniczać ilość logiki w kontrolerach do minimum. Zamiast definiować wszystko w akcjach kontrolera, najlepiej parować złożone formularze i elementy do dedykowanych klas, aby móc je wykorzystywać w wielu akcjach i usługach równocześnie.

Tak więc w pliku: src/Form/Type/CountWordType.php tworzymy następujący formularz:

<?php

declare(strict_types=1);

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;

final class CountWordType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('text', TextareaType::class, ['label' => 'Text to count'])
            ->add('submit', SubmitType::class, ['label' => 'Count words'])
        ;
    }
}

Dodatkowo dodamy sobie jeszcze Bootstrapa, żeby nasz formularz ładnie się renderował – a Ty byś mógł składać apkę niczym klocki Lego (jak za starych dobrych czasów). Dobra, można otworzyć drugie piwo, zrobiło się sentymentalnie.

W pliku templates/home.html.twig dodajemy style i JavaScript wymagane przez Bootstrapa:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}
            <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        {% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}
            <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
            <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
        {% endblock %}
    </body>
</html>

Teraz czas wstawić stworzony formularz CountWordType do naszego szablonu ze stroną główną. Najpierw stworzymy go w kontrolerze – akcja index będzie wyglądać następująco:

public function index(): Response
    {
        $form = $this->createForm(CountWordType::class);

        return $this->render('home.html.twig', [
            'form' => $form->createView(),
        ]);
    }

Następnie wstawiamy formularz w szablon. W tym celu wystarczy dodać {{ form(form) }} zaraz pod pierwszym paragrafem:

{% extends 'base.html.twig' %}

{% block body %}
    <div class="container">
        <div class="row">
            <div class="col">
                <h1>Word Counter</h1>
                <p>Count unique words in given text.</p>

                {{ form(form) }}
            </div>
        </div>
    </div>
{% endblock %}

Tak powinna wyglądać nasza strona:

Licznik słów z działającym formularzemLicznik słów z działającym formularzem

Następnym krokiem jest dodanie obsługi formularza w kontrolerze. Wypełniając formularz przykładowym tekstem i klikając w button Count words, wysyłamy do naszej aplikacji nowy request, który trzeba obsłużyć. Używając formularza z Symfony robimy to jedną prostą metodą handleRequest:

    public function index(Request $request): Response
    {
        $form = $this->createForm(CountWordType::class);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            return $this->render('home.html.twig', [
                'results' => $this->wordCounter->count($form->get('text')->getData()),
                'form' => $form->createView(),
            ]);
        }

        return $this->render('home.html.twig', [
            'form' => $form->createView(),
        ]);
    }

Jak widać, jeżeli formularz był submitowany i jest poprawny ($form->isSubmitted() && $form->isValid()), wtedy zwracamy na widok wynik działania naszego serwisu:

'results' => $this->wordCounter->count($form->get('text')->getData())

Pozostaje nam tylko wyświetlenie wyników przy użyciu stworzonego wcześniej szablonu:

{% block body %}
    <div class="container">
        <div class="row">
            <div class="col">
                <h1>Word Counter</h1>
                <p>Count unique words in given text.</p>

                {% if results is defined %}
                    <h2>Results:</h2>
                    <ul style="column-count: 4;">
                        {% for word, count in results|sort|reverse %}
                            <li>{{ word }}: {{ count }}</li>
                        {% endfor %}
                    </ul>
                {% endif %}

                {{ form(form) }}
            </div>
        </div>
    </div>
{% endblock %}

Continuous Integration

Przygotujemy teraz pipeline, który umożliwi Ci wdrożenie procesu Ciągłej Integracji.

Warto tutaj przypomnieć, że pipeline sam w sobie nie równa się ciągłej integracji. Ciągłą integracją nazywamy technikę, w którym każda zmiana w kodzie jest natychmiastowo testowana i scalana z główną linią kodu (branchem). Pipeline to narzędzie, które pomaga taką technikę wdrożyć.

Scalanie wyników pracy oznacza że:

  • Kod znajduje się w tym samym branchu
  • Aplikacja buduje się poprawnie
  • Aplikacja działa poprawnie (testy)
  • Aplikacja wdraża się poprawnie (deployment)

Przygotowanie skryptów Composera

Zaczniemy od przygotowania paru skryptów Composera. W pliku composer.json w sekcji “scripts” dodajemy:

        "phpunit": [
            "bin/phpunit --colors=always"
        ],
        "tests": [
            "@phpunit"
        ]

W ten sposób pozostawiamy sobie miejsce na dodanie dodatkowych narzędzi (np. PHPStan lub PHP CS Fixer) bez zmieniana samego skryptu do testowania:

composer tests

Tworzenie nowego pipeline'a

Na tym etapie Twój kod powinien być skomitowany i spushowany do repozytorium. Zacznijmy od stworzenia nowego projektu w Buddym i dodaniu pierwszego pipeline'a:

Dodawanie projektu Symfony do Buddy'egoDodawanie projektu Symfony do Buddy'ego

Klikamy Add a new pipeline i konfigurujemy pipeline:

  • podajemy nazwę (np. 'test')
  • ustawiamy Trigger mode na On push
  • jako branch triggerujący wybieramy 'Branch by wildcard' i ustawiamy go na refs/*

Konfiguracja pipeline'a testującegoKonfiguracja pipeline'a testującego

W ten sposób nasz pipeline będzie uruchamiał się dla wszystkich pushowanych zmian. Dzięki temu możesz mieć pewność, że spuszowany kod działa prawidłowo.

Pipeline postawiony, dodajemy akcję PHP:

Dodawanie akcji PHPDodawanie akcji PHP

Akcja PHP w Buddy to nic innego jak wyizolowany kontener z zainstalowanym PHP w wybranej wersji i dociągniętymi dependencjami, w którym możesz wykonywać komendy – tak jak w terminalu bashowym.

Kolejny krok to konfiguracja komend do wywołania:

composer validate
composer install
composer tests

Konfiguracja akcji PHPKonfiguracja akcji PHP

Tak przygotowany pipeline przetestuje nasz kodzik po każdym puszu do repozytorium. Czas na release naszej aplikacji na produkcję! 👹

Continuous Delivery

Do hostingu aplikacji użyjemy AWS Elastic Beanstalk. Możesz też użyć innego serwisu tego typu, np. Google App Engine, Heroku, albo Azure App Service.

Konfiguracja pipeline'a

Dodajemy nowy pipeline o nazwie 'deploy' ustawiony na branch master. W związku z tym, że to release na produkcję, tryb uruchamiania ustawiamy na manualny (chyba, że ktoś potrzebuje adrenaliny i lubi sobie puścić releasa po przypadkowym pushu, to może wybrać inny :)

Konfiguracja pipeline'a wdrożeniowegoKonfiguracja pipeline'a wdrożeniowego

Teraz dodajemy akcję Elastic Beanstalk z sekcji Amazona:

Sekcja akcji Amazon w BuddymSekcja akcji Amazon w Buddym

Pojawi się okno dodawania integracji AWS. Autoryzacja możę się odbywać po kluczu, przez dodanie Buddy'ego do listy zaufanych aplikacji, albo poprzez asumowanie roli:

Okno dodawania integracji z AWSOkno dodawania integracji z AWS

Dodanie integracji jest jednorazowe i pozwala na korzystanie z pozostałych akcji Amazonowych w Twoich pipeline'ach.

Po dodaniu integracji wyświetli się okno konfiguracji akcji EBS. Zacznijmy od utworzenia nowej aplikacji w samym Beanstalku:

Tworzenie nowej aplikacji z akcji Elastic BeanstalkTworzenie nowej aplikacji z akcji Elastic Beanstalk

W konsoli AWS tworzymy nową aplikację EBS o nazwie 'word-counter`, do której będziemy deployować nasze dzieło:

Dodawanie nowej aplikacji Elastic Beanstalk w konsoli AWSDodawanie nowej aplikacji Elastic Beanstalk w konsoli AWS

Po utworzeniu appki możemy wrócić do Buddy'ego, aby dokończyć konfigurację naszej akcji. Ustawiamy region i wybieramy 'word-counter' z listy dostępnych aplikacji (może byc potrzebne przeładowanie strony, żeby odświeżyć listę):

Wybór aplikacji docelowej w akcji Elastic BeanstalkWybór aplikacji docelowej w akcji Elastic Beanstalk

Buddy ma fajny ficzer który, podpowiada jakie akcje możemy dodać w następnej kolejności do pipeline'u. W tym przypadku jest to monitoring środowiska Elastic Beanstalk, który pozwoli nam sprawdzić czy aplikacja wstała po deployu:

Podgląd podpowiadania akcji w pipeliniePodgląd podpowiadania akcji w pipelinie

Ponownie wybieramy odpowiedni region, aplikację i environment.

W ustawieniach akcji warto zaznaczyć opcję Failed if yellow i ustawić Wait until ready na 5 minut. Dzięki temu akcja poczeka przed przeprowadzeniem testów zanim wdrożenie w pełni się zakończy a aplikacja się odpali.

Podsumowując, nasz pipeline wdrażający składa się teraz z dwóch akcji:

Pipeline wdrażającyPipeline wdrażający

Czas go odpalić i zobaczyć czy wszystko działa. Buddy zaciągnie ostatnią wersję z repozytorium, wyśle pliki do wskazanej aplikacji na EB, a na sam koniec odpali testy:

Widok podsumowujący po wdrożeniuWidok podsumowujący po wdrożeniu

Po wdrożeniu aplikacja będzie dostępna z adresu w panelu AWS:

Panel AWSPanel AWS

Konfiguracja środowiska Elastic Beanstalk

Po pierwszym wdrożeniu aplikacja nie będzie jeszcze działać poprawnie: trzeba jeszcze skonfigurować środowisko EBS, wskazując główną lokalizację dla serwera webowego. Robimy to w konsoli AWS wchodząc w ustawienia środowiska, a następnie w sekcji Software klikamy Modify. W polu Document root wpsiujemy wartość /public:

Konfiguracja środowiska EBSKonfiguracja środowiska EBS

Teraz trzeba chwilkę poczekać aż AWS uploaduje config na serwer i po chwili możemy cieszyć się w pełni działającą aplikacją! 🙌

Wdrożona aplikacja na serwerze Elastic BeanstalkWdrożona aplikacja na serwerze Elastic Beanstalk

Polecam wkleić sobie ten adres w ustawieniach pipeline'a, żeby mieć możliwość otwarcia witryny bezpośrednio z Buddy'ego:

Skrót do URL'a na pipelinieSkrót do URL'a na pipelinie

Kolejny krok: Continuous Deployment

Na sam koniec spróbujemy scalić oba pipeline'y w jedno środowisko CI/CD. Aby to zrobić, użyjemy akcji, która uruchomi pipeline wdrażający od razu po przeprowadzeniu testów. Akcja nazywa się Run next pipeline i należy ją dodać na koniec pipeline'u testującego, wybierając pipeline 'deploy' jako element docelowy:

Akcja Run Another PipelineAkcja Run Another Pipeline

Pipeline testujący działa na wildcardzie, co znaczy, że testy są uruchamiane na pushu do każdego brancha w repozytorium. W związku z tym, że końcowe wdrożenie odbywa się na produkcję, nas interesuje tylko branch master. Postawimy więc jeden warunek: deployment odpali się jedynie gdy przejdą testy na branchu master. Do tego celu wykorzystamy zmienne środowiskowe:

  • Na ekranie konfiguracji akcji Run next pipeline przejdź do zakładki Condition
  • Wybierz opcję ENV VAR z menu wyboru
  • Ustaw nazwę zmiennej na $BUDDY_EXECUTION_BRANCH, a wartość na master

Konfiguracja warunków triggerującychKonfiguracja warunków triggerujących

W ten sposób udało nam się w pełni zautomatyzować proces wdrażania: za każdym razem gdy zostanie spuszowany kod, Buddy uruchomi testy. Jeżeli testy przejdą pomyślnie, a branchem docelowym był master, uruchomi się kolejny pipeline, który wdroży naszą aplikację na produkcję. Taki typ workflowu nazywa się Continuous Deployment.

W przeciwieństwie do Continous Delivery, w Continuous Deployment cały proces wdrażania jest całkowicie automatyczny, wliczając w to release na live. Z tego powodu nie polecamy tego typu rozwiązania przy pracy z dużym projektami – chyba że każdy etap developmentu, od środowiska lokalnego, przez stage, po deploy na live jest dokładnie pokryty testami (jednostkowymi, end-to-end, przeglądarkowymi), a narzędzie do wdrażania jest w 100% stabilne i przetestowane w warunkach bojowych.

Opcjonalnie: Notyfikacje

Ostatnią z funkcjonalności Buddy'ego, o której chciałbym wspomnieć, są notyfikacje warunkowe. Tego typu notyfikacje są wysyłane tylko wtedy, gdy spełnione są konkretne warunki – na przykład akcja się wysypie. Dzięki nim możesz poinformować zespół DevOps, że coś poszło nie tak w czasie deploymentu, albo dać znać oddziałowi QA, żeby zerknął na wyniki testów, bo coś nie działa. Buddy obsługuje wszystkie najpopularniejsze komunikatory, wliczając w to Slack, Telegram, Discord, SMS i klasyczny e-mail (obecnie trwają prace nad wsparciem Microsoft Teams).

Aby wysłać taką wiadomość, dodaj akcję notyfikacyjną do sekcji Actions run on failure na dole pipeline'a:

Pipeline z notyfikacją warunkowąPipeline z notyfikacją warunkową

Podsumowanie

Dzięki za czas poświęcony na przeczytanie i dobrnięcie do konca tego poradnika. Mam nadzieję, że zawarte w nim wskazówki i informacje przydają Wam się w praktyce w pracy z PHP i Symfony, a być może również ułatwią wdrożenie CI/CD do Waszego workflowu. Ze swojej strony dodam, że Buddy to produkt całkowicie polski, developowany przez zespół 30 osób z Bielska-Białej. Naprawdę warto dać mu szansę, tym bardziej, że nie znajdziecie łatwiejszej w obsłudze i szybszej platformy DevOps – mogę się założyć o każde wspomniane w tekście piwko 😎.

Powodzenia! Arek Kondas

Powiązane artykuły