Janoszen

Tiszta kód, 9. rész – Helyezzük üzembe a blogot!

Tiszta kód, 9. rész – Helyezzük üzembe a blogot!

Az előző részben megírtuk egy blog üzleti logikáját, teljesen figyelmen kívül hagyva a tényleges adatbázist vagy böngészőt. Most itt az ideje, hogy üzembe is helyezzük.

Ez a cikk erősen épít a sorozat korábbi elemeire. Érdemes az olvasást az elején kezdeni.

A külső függőségek problémája

Egészen eddig egyáltalán nem foglalkoztunk azzal, hogy milyen frameworköt vagy külső csomagokat fogunk használni. Bob bácsi azt mondta, hogy a rendszerünk magja legyen független a frameworköktől, de azt nem igazán magyarázta el, hogy miért?

Teljesen mindegy, hogy nyílt forrású csomagokat használunk GitHubról, vagy külső partner által szállított szoftverkomponenst, érdemes néhány dologra ügyelni. Nézzük, hogy melyek ezek.

Biztonság

Mennyire biztonságos az a komponens, amit használsz? Itt két fontos eszköz van a kezedben. Az egyik természetesen az, hogy auditálod a kódod. Azaz elolvasod és leteszteled biztonsági hibákra. Legyünk őszinték, ez a lehető legkevesebb esetben történik meg, különösen ha nem csak néhány kis komponensről van szó, hanem egész frameworkökről.

A másik fontos mutató a közösség az adott projekt körül. Ha egy projektet több tízezer helyen használnak, a több szem többet lát elv alapján sokkal előbb derülnek ki a problémák, mint a néhány tucat felhasználós kis projekteknél. Ez persze nem abszolut szabály, egy jól képzett, biztonsági háttérrel rendelkező fejlesztő által karbantartott kis modul sokkal biztonságosabb lesz, mint az átlagos, több ezer felhasználóval rendelkező 0.0.1-es NodeJS modul.

Bármelyiket is választjuk, két fontos feladatot mindenképpen szem előtt kell tartanunk. Az egyik, hogy figyeljük a biztonsági értesítéseket. Ha a projekt rendelkezik security levelezési listával, iratkozzunk fel rá. Ha nincs ilyen, nézzük meg a csomagkezelőnket, hátha támogatja azt, hogy jelezze a biztonsági frissítéseket.

Stabilitás

Persze, a biztonság fontos, de ami talán még fontosabb, az a stabilitás. Ha megváltozik a bekötött modul egyik napról a másikra, előre nem tervezhető fejlesztések elé nézünk, és jó eséllyel nem fogjuk végrehajtani a biztonsági frissítéseket.

Röviden és tömören, ezekre érdemes figyelni:

  • Van-e előre tervezett karbantartási ciklus?
  • Van-e szemantikus verziózás? (Azaz egy visszafele nem kompatibilis változásnál változik-e a főverzió száma?)
  • Van-e elérhető levelezési lista (vagy hasonló) a kiadásokról?

Ha ezek mindegyikére nem a válasz, komoly kockázattal jár a csomag használata, hiszen bármikor szembe jöhet az a probléma, hogy egy composer update megöli az egész rendszerünket.

Természetesen néha kénytelenek vagyunk ezzel a kockázattal élni, például amikor nincs alternatív csomag és nincs keret megírni a kívánt funkcionalitást. Ha ilyen kompromisszumot kötünk, különösen ügyelni kell arra, hogy a külső csomag kellően le legyen választva a rendszerünkről.

Minőség

Némileg kapcsolódik a stabilitáshoz a külső csomag minősége is. Ha egyszer külső csomag vagy keretrendszer mellett döntünk, szeretnénk ha az jó minőségű lenne. Mi alapján dönthetünk erről:

  • Ránézünk a kódra. Ha hihetetlenül hosszú függvényekkel, spagettikóddal találkozunk, esetleg HTML és debug kód keveredik a tényleges programlogikával, legyünk nagyon nagyon óvatosak. (Ugyanakkor megjegyezném, hogy a bonyolult nem azonos a rossz kódminőséggel.)
  • Vannak-e tesztek? A tesztek jelenléte, és könnyű futtathatósága azt jelzi számunkra, hogy a csomag szerzője foglalkozott a projektje minőségbiztosításával. Amíg a saját projektek teszteletlensége, ha nem is bocsánatos bűn, de legalább valamilyen szinten korlátozott következményekkel jár, a külső felhasználásra készített függvénykönyvtárak nem tesztelése komoly intő jel lehet.
  • Futnak-e automatikusan a tesztek? A legjobb teszt mit sem ér, ha nem kerülnek futtatásra. Éppen ezért a modernebb open source projektek a tesztjeiket automatikusan futtatják, például Travis-CI segítségével, és ezt büszkén jelzik is a GitHub oldalukon.

Külső projekteket értelemszerűen nem fogunk unittesztelni. Éppen ezért halmozottan fontos, hogy ne csak egységteszteket, hanem integrációs teszteket is alkalmazzunk, amik az egész szoftvercsomag együttes működését tesztelik. Erre vannak különböző megoldások, amiket majd megvizsgálunk a cikk további részében.

Mit szeretnénk egy frameworktől?

Az világos, hogy ma már senki nem fog nulláról, csak saját kódot felhasználva megírni egy teljes blogot. Gazdasági szempontból ez egyáltalán nem hatékony. Éppen ezért vizsgáljuk meg, hogy mit is várunk egy frameworktől, milyen szempontokat kell figyelembe vennünk?

1. dependency injection

Ez majdnem triviális, szeretnénk egy dependency injection megoldást, hogy ne kelljen kézzel összedrótozni az alkatrészeket.

2. adatbázis kezelés

Ahhoz, hogy a blogunk ténylegesen használható is legyen, kell mögé valamilyen adattároló. SQL, vagy fájlok, majdnem mindegy, de jó lenne, ha nem nekünk kellene megírni.

3. routing és HTTP kezelés

Ha bejön egy kérés egy böngészőtől, szeretnénk a megfelelő kódrészhez irányítani. Mindezt a lekért cím alapján szeretnénk megtenni, tehát a http://www.example.com/ oldalra a blogpostok listáját szeretnénk kiszolgálni, a http://www.example.com/elso-blogpost/ címre pedig a konkrét blogpostot. Ezen felül persze a választ is szeretnénk visszaküldeni a böngészőnek.

4. sablon kezelés

Ha HTML-t kell gyártanunk, semmi sem kényelmetlenebb, mint a működési logikát összevegyíteni a kimenettel. Éppen ezért szeretnénk egy sablon kezelő rendszert, pl. Smarty, Twig, Jinja2, stb.

5. konfiguráció

Ahhoz, hogy a későbbiekben kellemesen lehessen használni, jó lenne, ha lenne egy INI, YAML vagy XML fájl amiben konfigurálni lehet a blogunkat.

6. caching (gyorsítótárazás)

Idővel szükségünk lehet arra, hogy bizonyos dolgokat cacheljünk, hogy gyorsabb legyen a blogunk. Ez első pillanatban nem feltétlenül szempont, de a későbbiekben érdekes lehet.

Választási lehetőségek

Ha ezeket a szempontokat figyelembe vesszük, három nagy kategória van, amiből választhatunk:

Teljes framework

Ezek a fajta frameworkök (Symfony, Zend, stb) rengeteg funkcionalitást hoznak magukkal. A jogosultság-kezeléstől, az adatbázis kezelésen át, a mindenféle konfigurációig mindent kapunk. Ennek vannak előnyei, hiszen szinte semmit nem kell leprogramoznunk a core logikán kívül, viszont egy szép nagy halom függőséget kapunk ajándékba. Az alap Symfony telepítés például 31 csomagot ránt magával 11 különböző gyártótól, a Laravel 57 csomagot húz be 31 különböző gyártótól. Ugyan a frameworkök gyártói hihetetlen munkát végeznek a csomagok összehangolásánál, de azért említésre méltó, hogy némely ezek közül nem rendelkezik tesztekkel, és elég sok a rendszermagtól teljesen eltérő kódolási konvencióval vagy felépítéssel rendelkezik.

Mikroframeworkök

Ezek olyan frameworkök, amelyek igyekeznek a funkcionalitásukat minimálisra szorítani. Az egyik ilyen framework a Silex, amely 9 csomagot húz be 4 gyártótól. Sokkal kevesebb függőség, sokkal egyszerűbb felépítés, viszont számos olyan funkció hiányzik, amire mindenképpen szükségünk lesz, például az adatbázis kapcsolatok kezelése. Magyarán a core csomagok nem lesznek elegendőek a rendszer felépítésére.

Framework nélkül

Némely körökben eretnekségnek számíthat, de koránt sem elvetemült ötlet. Ebben az esetben saját magunk válogatjuk össze a modulokat, amiket behúzunk. Szinte egyértelmű, hogy ezt nem ússzuk meg integrációs tesztelés nélkül, és kénytelenek leszünk komoly erőfeszítést fordítani arra, hogy ezek a modulok szépen szeparáltak legyenek a rendszerünktől. Az sem elhanyagolható tényező, hogy senki másra nem számíthatunk, kénytelenek leszünk a használt csomagok kódját bizonyos mélységig átnézni, auditálni. Ez a fajta felépítés különösen akkor érdekes, ha olyan feladatunk van, amibe a framework kevés hozzáadott értéket vinne be.

Mit választunk?

Ennyi bevezető után bizonyára már kíváncsian várod: mégis mit választunk? Itt egy olyan döntést hozunk, ami a fentiek alapján némileg furának tűnhet, és ennek meg is van az oka.

A válasz az, hogy framework nélkül fogunk fejleszteni. De nem azért, mert így hatékony, koránt sem. Csomó munkát megspórolhatnánk azzal, hogy behúzunk egy mikroframeworköt.

Kizárólag azért dolgozunk framework nélkül, hogy bemutassuk az alkalmazás teljes felépítését. Kérlek, ne így építsd fel a következő éles projektedet!

Na de, ha nincs framework, milyen csomagokat fogunk használni?

  Auryn   Dependency Injection Container   <a href="https://packagist.org/packages/rdlowrey/auryn">rdlowrey/auryn</a>  
  Twig   Sablon kezelő rendszer   <a href="https://packagist.org/packages/twig/twig">twig/twig</a>  
  FastRoute   Routing könyvtár   <a href="https://packagist.org/packages/nikic/fast-route">nikic/fast-route</a>  
  PSR-7   Szabványosított interfacek HTTP kérdésekre/válaszokra PHP-ban   <a href="https://packagist.org/packages/psr/http-message">psr/http-message</a>  
  Guzzle PSR-7   PSR-7 megvalósítás   <a href="https://packagist.org/packages/guzzlehttp/psr7">guzzlehttp/psr7</a>  

A facade pattern, vagyis a külső függőségek leválasztása

Mint már számtalanszor említettük, a célunk az, hogy a külső függőségeket leválasszuk. Ennek vajmi egyszerű az oka: ha a külső függőség gyártójának elgurul a gyógyszere és elkezd hülyeséget csinálni, szeretnénk biztositani a lehetőséget, hogy kidobjuk a csomagot és lecseréljük egy másikra. Viszonylag fájdalommentesen.

Külső függőség alatt itt a frameworköt is értjük! Azt is érdemes leválasztani, így a frissítés a következő verzióra sokkal fájdalommentesebb lesz!

Ezt az un. facade patternnel fogjuk megvalósítani. Először is, végig gondoljuk a feladatot és létrehozunk egy jól definiált interface-t a feladatra. Ez a dependency injection containerünkre nézhetne ki például így:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
interface DependencyInjectionContainer {
    /**
     * Mark a class, interface or object as shared.
     *
     * @param string|object $classNameOrInstance
     */
    public function share($classNameOrInstance);

    /**
     * Mark a certain implementation as an alias for an interface.
     * This can be used to specify the concrete implementation
     * of an interface.
     *
     * @param string $interfaceName
     * @param string $implementationClassName
     */
    public function alias($interfaceName, $implementationClassName);

    /**
     * Set the values for a certain class' constructor explicitly.
     * This is useful when a certain parameter has no type hinting,
     * e.g. a configuration option.
     *
     * @param string $className
     * @param array  $arguments key-value array of arguments and
     *                          their values.
     */
    public function setClassParameters($className, $arguments);

    /**
     * Create an instance of $class or its alias, using dependency
     * injection.
     *
     * @param string $class
     *
     * @return object
     */
    public function make($class);

    /**
     * Call a class method with the parameter autodiscovery.
     *
     * @param callable $method
     * @param array    $arguments Optional arguments set explicitly.
     *
     * @return mixed
     */
    public function execute($method, $arguments = []);
}

Természetesen itt nagyon figyelni kell arra, hogy a facade ne csak azt az egy konkrét megvalósítást támogassa. Éppen ezért érdemes megnézni legalább 2-3 hasonló külső könyvtárat, hogy egy általános csatolófelületet hozzunk létre.

Ezek után neki esünk a megvalósításnak, szem előtt tartva, hogy a magvalósító osztálynak el kell fednie a külső könyvtár összes rondaságát. Az Auryn esetén ez relatíve egyszerű, de egy SwiftMailer (levelező könyvtár), vagy a Twig (templatező rendszer) felkonfigurálása már trükkösebb lehet. Az eddigi aranyszabály az volt, hogy a függőségeket bekérjük a konstruktorban, de ezt itt tudatosan megsértjük:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AurynDependencyInjectionContainer
    implements DependencyInjectionContainer {

    /**
     * @var Injector
     */
    private $auryn;
	
    public function __construct() {
        $this->auryn = new Injector();
    }
    
    //...
}

Mielőtt kezdenéd kibontani a vákuum csomagolt instant vasvillát, erre jó okunk van. Az Aurynnál viszonylag egyszerű dolgunk van, de más külső könyvtáraknál, például a Twignél, már bonyolultabb lesz felhúzni:

1
2
3
$loader = new Twig_Loader_Filesystem($templateDirectory);
$twig = new Twig_Environment($loader, array('debug' => true));
$twig->addExtension(new Twig_Extension_Debug());

Természetesen ezt még bonyolíthatjuk különböző extesionökkel, cacheléssel, stb.

Na most, a DIC-t konfigurálnunk kell, vagyis meg kell adnunk, hogy melyik interface helyett mit töltsön be, és melyek a fix paraméterek (pl. $templateDirectory). Ha facade-ok belső életét is dependency injectionnel toljuk be (tehát a Twig_Loader_Filesystem osztályt bekérjük a konstruktorban), akkor egy részt olvashatatlanul hosszú lesz a konfig. Másrészt, és ez a lényegesebb, ismernünk kell a facade belső életét ahhoz, hogy használni tudjuk. Na de pont ez az, amit szerettünk volna elkerülni!

Magyarán maradunk annál, hogy a facade megvalósításában elrejtjük a külső könyvtár összes komplexitását, beleértve az osztályok példányosítását is. Ennek persze az a következménye, hogy már nem tudunk egységtesztelni, hiszen az AurynDependencyInjectionContainer az osztályok létrehozása miatt nem tesztelhető önállóan.

De semmi vész, helyette integrációs tesztelést végzünk. Vagyis nem egy osztályt, hanem a teljes modul teljes funkcionalitását teszteljük egyszerre. Ha például a Twig (template engine) integrációt teszteljük, lerakunk egy templatet és megkérjük, hogy olvassa fel. Ha azt kapjuk vissza, amit várunk, minden rendben.

Fontos látni, hogy sok esetben meglehetősen macerás az integrációs tesztelés. Ha például egy adatbázis kezelő réteget húzunk be, szükségünk van egy adatbázis szerverre a megfelelő teszteléshez. Ilyen esetben azt szokás csinálni, hogy ezeket az online teszteket egy külön csoportba vagy test suite-be rakjuk, és csak akkor futtatjuk, ha rendelkezésre áll a teszteléshez használt, dedikált adatbázis szerver. Jobbára az integrációs réteg nem változik túl gyakran, tehát nem kell fejlesztés közben tesztelnünk, elég release előtt. Ideális esetben ezt egy Continuous Integration szerver végzi egy erre a célra felhúzott környezetben.

Architektúra

Na most aztán jól kiveséztük a külső függőségek kezelését, térjünk rá az architektúra kérdésre egy kicsit. Az előző részben felépítettük a meglehetősen egyszerű, de igen csak bővíthető üzleti logikánkat. Ezt most két új résszel kell felszerelnünk: a webes kiszolgálást végző delivery mechanizmussal és valamiféle perzisztens adattárolóval, például adatbázissal.

Kezdjük a delivery mechanizmussal. Itt teljesen szabályosan követni fogjuk a modern MVC keretrendszereket: lesz egy routing, ami eldönti, hogy melyik címre melyik controllert kell használni, lesz néhány controller és ezekhez view-k. Valahogy így:

blogapplication

Majdnem MVC, igaz? A különböző interfaceket nem jelöltem az ábrán, de remélhetőleg ez nem okoz túl sok bonyodalmat. A kódot ehhez szokás szerint megtalálod a GitHub oldalunkon.

Mi a helyzet az adatbázissal? Na az a helyzet, hogy az adatbázissal semmi nincs. Az előző cikkben tárgyaltak szerint az adatbázis felé az entity gateway nyújt felületet, vagyis elegendő írnunk egy PDOMySQLBlogEntityGateway osztályt, ami közvetlenül használja a PDO-t, és készen vagyunk.

Megtehetnénk, hogy behúzunk egy csilivili adatbázis absztrakciós réteget? Meg. Esetleg egy ORM-et? Hogyne. De vegyük észre, hogy blogot írunk, nem egy vezérlőrendszert a SpaceX Dragon űrhajóhoz. (A NASA emlegetése olyan régimódi.) Minek bonyolítani?

A tesztelés

A sorozat elején megbeszéltük, hogy a unit tesztek mindig az egységeket tesztelik, az összeépítést viszont nem. Erre valóak a különböző szintű integrációs tesztek. Ha valami bonyolultabbat írnánk, akkor minden bizonnyal bevezetnénk különböző közép szintű tesztet, ami néhány modul összeépítését teszteli, esetleg csinálnánk olyan teszteket, amik csak a delivery mechanizmust tesztelik a működési logika leválasztásával. De ismétlem, egy blogot írunk, nem egy űrhajó oprendszerét.

Éppen ezért a tesztelést Seleniumon keresztül fogjuk végezni Behat Mink segítségével. Ez azt eredményezi, hogy PHPUnitban írhatunk teszteket, amik a szemünk láttára a böngészőben nyomkodják végig az alkalmazást. Ezek a tesztek nézhetnek ki például így:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$session = new Session(
    new Selenium2Driver());

$session->start();

$session->visit(
    'http://localhost:8000/');

$page = $session->getPage();

$this->assertEquals(
    'This is my second post',
    $page->find('css', 'h2')->getText());

$page->find('css', 'h2 a')
     ->click();

$this->assertEquals(
    'http://localhost:8000/second-post',
    $session->getCurrentUrl());

Vissza az ügyfélhez

Ezen a ponton eltöltöttünk a feladattal 2 munkanapot (16 munkaórát), és van egy működőképes, ámbár elég fapados blogmotorunk. Az ügyfél igényeinek megfelelően ezután jön az admin felület kialakítása, a design felhúzása, stb stb. Magyarán ez volt a munka bevezető része. Viszont ezzel megteremtettük a lehetőséget arra, hogy szinte bármilyen elképesztő kérést fedél alá hozzunk.

Ha szeretnéd kipróbálni az általunk írt blogot, azt viszonylag egyszerűen megteheted:

  1. Töltsd le a forráskódot GitHubról
  2. Készítsd el a config/local.php fájlt a minta alapján
  3. Futtasd ke a composer install parancsot
  4. Futtasd le a bin/migrate.php fájlt
  5. Indítsd el a fejlesztői PHP szervert a htdocs mappában: php -S 127.0.0.1:8000
  6. Nyisd meg a http://localhost:8000 oldalt a böngésződben.

A PHPLoc kimenetét megnézve az src könyvtárral nem is melóztunk olyan sokat ezen két nap alatt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
Directories                                         12
Files                                               40

Size
  Lines of Code (LOC)                             1679
  Comment Lines of Code (CLOC)                     580 (34.54%)
  Non-Comment Lines of Code (NCLOC)               1099 (65.46%)
  Logical Lines of Code (LLOC)                     394 (23.47%)
    Classes                                        305 (77.41%)
      Average Class Length                           7
        Minimum Class Length                         0
        Maximum Class Length                        32
      Average Method Length                          2
        Minimum Method Length                        1
        Maximum Method Length                       20
    Functions                                        2 (0.51%)
      Average Function Length                        0
    Not in classes or functions                     87 (22.08%)

Cyclomatic Complexity
  Average Complexity per LLOC                     0.12
  Average Complexity per Class                    2.23
    Minimum Class Complexity                      1.00
    Maximum Class Complexity                     11.00
  Average Complexity per Method                   1.56
    Minimum Method Complexity                     1.00
    Maximum Method Complexity                    11.00

Dependencies
  Global Accesses                                    0
    Global Constants                                 0 (0.00%)
    Global Variables                                 0 (0.00%)
    Super-Global Variables                           0 (0.00%)
  Attribute Accesses                                80
    Non-Static                                      80 (100.00%)
    Static                                           0 (0.00%)
  Method Calls                                     140
    Non-Static                                     140 (100.00%)
    Static                                           0 (0.00%)

Structure
  Namespaces                                        13
  Interfaces                                        11
  Traits                                             0
  Classes                                           29
    Abstract Classes                                 4 (13.79%)
    Concrete Classes                                25 (86.21%)
  Methods                                          104
    Scope
      Non-Static Methods                           104 (100.00%)
      Static Methods                                 0 (0.00%)
    Visibility
      Public Methods                                92 (88.46%)
      Non-Public Methods                            12 (11.54%)
  Functions                                          3
    Named Functions                                  1 (33.33%)
    Anonymous Functions                              2 (66.67%)
  Constants                                          1
    Global Constants                                 1 (100.00%)
    Class Constants                                  0 (0.00%)

Összegzés

Nincs olyan szabály, amire nincs kivétel. Nincs olyan framework, ami minden feladatra egyformán alkalmas lenne. Éppen ezért azt javaslom, hogy válaszd ki azokat az eszközöket, amik a Te projektedhez legjobban illenek, amikkel tudsz dolgozni és amihez találsz hozzáértő munkatársat. Ha olyan alkalmazást gyártasz, ami 2 hónapig fog élni, teljesen fölösleges ennyire leválasztani a frameworkről. Ha előre láthatólag akár tíz évnél is hosszabb ideig karban kell tartani, akkor nem csak a frameworkről, de a programnyelv beépített eszközeiről is le kell valasztanod.

Sajnos a szakmánkban dúl a silver bullet szindróma, mindig újabb és újabb eszközök jönnek ki, és sokan ész nélkül ráugranak ezekre. Blogpostokat írnak, hogy ez a jövő, erre kell váltani, és hülye vagy, ha nem ezt csinálod. Egy évvel később pedig ugyanezekről az emberekről olvasod, hogy migrálnak lefele a korábban hype-olt technológiáról. Csak az a kár, hogy egyik esetben sincs igazuk. Nem feltétlenül kellene rögtön felugrani a legújabb hype trainre, főleg ha a karbantarthatóság a fontos, de azt is látni kellene, hogy minden technológiának megvan a helye, nem kell rögtön leugrálni sem, amint alább hagyott a lelkesedés. Ha hosszú távon karbantartható projekteket akarsz fejleszteni, akkor nem árt egy kis konzervatív gondolkodásmód, egy kis óvatosság. Technológiák, frameworkök, függvénykönyvtárak jönnek-mennek, az alkalmazásaink viszont egyre hosszabb ideig élnek.

Végső soron talán egyetlen tanáccsal szolgálhatok a mindennapi fejlesztéshez: olyan eszközöket, módszertant válassz, ami a projekt teljes időtartama alatt a lehető legkevesebb fájdalommal jár. Vagyis olyat válassz, ami Neked jó. Ezt pedig – jó eséllyel – csak Te tudod eldönteni.

Ezzel a cikkel véget ért a tiszta kód sorozatunk. De ne csüggedj, a jövőben is fogunk a tiszta kódról írni, egyelőre azonban más irányba állítjuk a napszélvitorlát: megnézzük, hogy hogyan is kell egy ilyen tiszta kód projektet üzemeltetni.

Kérdőív

Ha szeretnél nekünk visszajelzést adni a sorozatról, itt a lehetőséged:

[formidable id=2]

comments powered by Disqus