Alkalmazás könyvtárstruktúrája

Hogyan tervezzünk áttekinthető és skálázható könyvtárstruktúrát Nette Framework projektekhez? Megmutatjuk a bevált gyakorlatokat, amelyek segítenek a kód szervezésében. Megtudhatja:

  • hogyan logikusan tagoljuk az alkalmazást könyvtárakba
  • hogyan tervezzük meg a struktúrát úgy, hogy jól skálázódjon a projekt növekedésével
  • mik a lehetséges alternatívák és azok előnyei vagy hátrányai

Fontos megemlíteni, hogy maga a Nette Framework nem ragaszkodik semmilyen konkrét struktúrához. Úgy tervezték, hogy könnyen alkalmazkodjon bármilyen igényhez és preferenciához.

A projekt alapstruktúrája

Bár a Nette Framework nem diktál semmilyen merev könyvtárstruktúrát, létezik egy bevált alapértelmezett elrendezés a Web Project formájában:

web-project/
├── app/              ← alkalmazás könyvtára
├── assets/           ← SCSS, JS fájlok, képek..., alternatívaként resources/
├── bin/              ← parancssori szkriptek
├── config/           ← konfiguráció
├── log/              ← logolt hibák
├── temp/             ← ideiglenes fájlok, cache
├── tests/            ← tesztek
├── vendor/           ← Composer által telepített könyvtárak
└── www/              ← nyilvános könyvtár (document-root)

Ezt a struktúrát tetszés szerint módosíthatja igényei szerint – a mappákat átnevezheti vagy áthelyezheti. Ezután csak a relatív elérési utakat kell módosítani a Bootstrap.php fájlban és esetleg a composer.json-ban. Semmi másra nincs szükség, nincs bonyolult újrakonfigurálás, nincs konstansok módosítása. A Nette okos automatikus felismeréssel rendelkezik, és automatikusan felismeri az alkalmazás helyét, beleértve annak URL alapját is.

Kódszervezési elvek

Amikor először vizsgál meg egy új projektet, gyorsan eligazodnia kell benne. Képzelje el, hogy kibontja az app/Model/ könyvtárat, és ezt a struktúrát látja:

app/Model/
├── Services/
├── Repositories/
└── Entities/

Ebből csak azt olvashatja ki, hogy a projekt valamilyen szolgáltatásokat, repository-kat és entitásokat használ. Az alkalmazás valódi céljáról semmit sem tud meg.

Nézzünk meg egy másik megközelítést – szervezés domainek szerint:

app/Model/
├── Cart/
├── Payment/
├── Order/
└── Product/

Itt más a helyzet – első pillantásra világos, hogy egy webáruházról van szó. Már maguk a könyvtárnevek is elárulják, mit tud az alkalmazás – fizetésekkel, rendelésekkel és termékekkel dolgozik.

Az első megközelítés (szervezés osztálytípus szerint) a gyakorlatban számos problémát okoz: a logikailag összetartozó kód különböző mappákba van szétszórva, és ugrálnia kell közöttük. Ezért domainek szerint fogunk szervezni.

Névterek

Szokás, hogy a könyvtárstruktúra megfelel az alkalmazás névtereinek. Ez azt jelenti, hogy a fájlok fizikai elhelyezkedése megfelel a namespace-üknek. Például az app/Model/Product/ProductRepository.php-ban elhelyezett osztálynak App\Model\Product namespace-szel kellene rendelkeznie. Ez az elv segít a kódban való tájékozódásban és egyszerűsíti az autoloadingot.

Egyes vs többes szám a nevekben

Figyelje meg, hogy az alkalmazás fő könyvtárainál egyes számot használunk: app, config, log, temp, www. Ugyanígy az alkalmazáson belül is: Model, Core, Presentation. Ez azért van, mert mindegyik egy-egy összefüggő koncepciót képvisel.

Hasonlóképpen például az app/Model/Product mindent reprezentál a termékekkel kapcsolatban. Nem nevezzük Products-nak, mert nem egy termékekkel teli mappa (akkor nokia.php, samsung.php fájlok lennének benne). Ez egy namespace, amely osztályokat tartalmaz a termékekkel való munkához – ProductRepository.php, ProductService.php.

Az app/Tasks mappa többes számban van, mert önálló futtatható szkriptek készletét tartalmazza – CleanupTask.php, ImportTask.php. Mindegyik önálló egység.

A következetesség érdekében javasoljuk a következők használatát:

  • Egyes szám egy funkcionális egységet reprezentáló namespace-hez (még ha több entitással is dolgozik)
  • Többes szám önálló egységek gyűjteményeihez
  • Bizonytalanság esetén, vagy ha nem akar ezen gondolkodni, válassza az egyes számot

Nyilvános könyvtár www/

Ez a könyvtár az egyetlen, amely a webről elérhető (ún. document-root). Gyakran találkozhat a public/ névvel is a www/ helyett – ez csak konvenció kérdése, és nincs hatással a funkcionalitásra. A könyvtár tartalmazza:

  • Az alkalmazás belépési pontját index.php
  • A .htaccess fájlt mod_rewrite szabályokkal (Apache esetén)
  • Statikus fájlokat (CSS, JavaScript, képek)
  • Feltöltött fájlokat

Az alkalmazás megfelelő biztonsága érdekében elengedhetetlen a helyesen konfigurált document-root.

Soha ne helyezze ebbe a könyvtárba a node_modules/ mappát – ez több ezer fájlt tartalmaz, amelyek futtathatók lehetnek, és nem kellene nyilvánosan elérhetőnek lenniük.

Alkalmazás könyvtára app/

Ez az alkalmazás kódjának fő könyvtára. Alapstruktúra:

app/
├── Core/               ← infrastrukturális ügyek
├── Model/              ← üzleti logika
├── Presentation/       ← presenterek és sablonok
├── Tasks/              ← parancssori szkriptek
└── Bootstrap.php       ← az alkalmazás indító osztálya

A Bootstrap.php az alkalmazás indító osztálya, amely inicializálja a környezetet, betölti a konfigurációt és létrehozza a DI konténert.

Most nézzük meg részletesebben az egyes alkönyvtárakat.

Presenterek és sablonok

Az alkalmazás prezentációs része az app/Presentation könyvtárban található. Alternatíva a rövid app/UI. Ez a hely minden presenter, azok sablonjai és esetleges segédosztályai számára.

Ezt a réteget domainek szerint szervezzük. Egy komplex projektben, amely kombinálja a webáruházat, a blogot és az API-t, a struktúra így nézne ki:

app/Presentation/
├── Shop/              ← webáruház frontend
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← blog
│   ├── Home/
│   └── Post/
├── Admin/             ← adminisztráció
│   ├── Dashboard/
│   └── Products/
└── Api/               ← API végpontok
	└── V1/

Ezzel szemben egy egyszerű blog esetében a következő tagolást használnánk:

app/Presentation/
├── Front/             ← web frontend
│   ├── Home/
│   └── Post/
├── Admin/             ← adminisztráció
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, sitemap-ek stb.

A Home/ vagy Dashboard/ mappák presentereket és sablonokat tartalmaznak. A Front/, Admin/ vagy Api/ mappákat moduloknak nevezzük. Technikailag ezek átlagos könyvtárak, amelyek az alkalmazás logikai tagolására szolgálnak.

Minden presenter mappa tartalmaz egy azonos nevű presentert és annak sablonjait. Például a Dashboard/ mappa tartalmazza:

Dashboard/
├── DashboardPresenter.php     ← presenter
└── default.latte              ← sablon

Ez a könyvtárstruktúra tükröződik az osztályok névtereiben. Például a DashboardPresenter az App\Presentation\Admin\Dashboard névtérben található (lásd mapování presenterů):

namespace App\Presentation\Admin\Dashboard;

class DashboardPresenter extends Nette\Application\UI\Presenter
{
	// ...
}

Az Admin modulon belüli Dashboard presenterére az alkalmazásban kettőspontos jelöléssel hivatkozunk, mint Admin:Dashboard. Annak default akciójára pedig mint Admin:Dashboard:default. Beágyazott modulok esetén több kettőspontot használunk, például Shop:Order:Detail:default.

A struktúra rugalmas fejlesztése

Ennek a struktúrának az egyik nagy előnye, hogy milyen elegánsan alkalmazkodik a projekt növekvő igényeihez. Vegyük példaként az XML feedeket generáló részt. Kezdetben egyszerű formában van:

Export/
├── ExportPresenter.php   ← egy presenter minden exportáláshoz
├── sitemap.latte         ← sablon a sitemaphoz
└── feed.latte            ← sablon az RSS feedhez

Idővel újabb feed típusok jelennek meg, és több logikára van szükségünk hozzájuk… Semmi probléma! Az Export/ mappa egyszerűen modullá válik:

Export/
├── Sitemap/
│   ├── SitemapPresenter.php
│   └── sitemap.latte
└── Feed/
	├── FeedPresenter.php
	├── zbozi.latte         ← feed a Zboží.cz-hez
	└── heureka.latte       ← feed a Heureka.cz-hez

Ez az átalakulás teljesen zökkenőmentes – csak új almappákat kell létrehozni, szétosztani bennük a kódot és frissíteni a linkeket (pl. Export:feed-ről Export:Feed:zbozi-ra). Ennek köszönhetően a struktúrát fokozatosan bővíthetjük igény szerint, a beágyazási szint nincs korlátozva.

Ha például az adminisztrációban sok presenter van a rendelések kezelésével kapcsolatban, mint például OrderDetail, OrderEdit, OrderDispatch stb., akkor a jobb szervezettség érdekében ezen a ponton létrehozhat egy Order modult (mappát), amelyben a Detail, Edit, Dispatch és további presenterek (mappái) lesznek.

Sablonok elhelyezése

Az előző példákban láttuk, hogy a sablonok közvetlenül a presenter mappájában helyezkednek el:

Dashboard/
├── DashboardPresenter.php     ← presenter
├── DashboardTemplate.php      ← opcionális osztály a sablonhoz
└── default.latte              ← sablon

Ez az elhelyezés a gyakorlatban a legkényelmesebbnek bizonyul – minden kapcsolódó fájl kéznél van.

Alternatívaként a sablonokat elhelyezheti a templates/ almappába. A Nette mindkét változatot támogatja. Sőt, a sablonokat akár teljesen a Presentation/ mappán kívül is elhelyezheti. A sablonok elhelyezési lehetőségeiről mindent megtalál a Sablonok keresése fejezetben.

Segédosztályok és komponensek

A presenterekhez és sablonokhoz gyakran tartoznak további segédfájlok is. Ezeket logikusan a hatókörük szerint helyezzük el:

1. Közvetlenül a presenter mellett, ha az adott presenterhez specifikus komponensekről van szó:

Product/
├── ProductPresenter.php
├── ProductGrid.php        ← komponens a termékek listázásához
└── FilterForm.php         ← űrlap a szűréshez

2. A modulhoz – javasoljuk az Accessory mappa használatát, amely áttekinthetően az ábécé elején helyezkedik el:

Front/
├── Accessory/
│   ├── NavbarControl.php    ← komponensek a frontendhez
│   └── TemplateFilters.php
├── Product/
└── Cart/

3. Az egész alkalmazáshoz – a Presentation/Accessory/-ban:

app/Presentation/
├── Accessory/
│   ├── LatteExtension.php
│   └── TemplateFilters.php
├── Front/
└── Admin/

Vagy elhelyezheti a segédosztályokat, mint a LatteExtension.php vagy TemplateFilters.php, az infrastrukturális app/Core/Latte/ mappába. És a komponenseket az app/Components-be. A választás a csapat szokásaitól függ.

Model – az alkalmazás szíve

A modell tartalmazza az alkalmazás összes üzleti logikáját. Szervezésére ismét az a szabály érvényes – domainek szerint strukturálunk:

app/Model/
├── Payment/                   ← minden a fizetésekkel kapcsolatban
│   ├── PaymentFacade.php      ← fő belépési pont
│   ├── PaymentRepository.php
│   ├── Payment.php            ← entitás
├── Order/                     ← minden a rendelésekkel kapcsolatban
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← minden a szállítással kapcsolatban

A modellben tipikusan ezekkel az osztálytípusokkal találkozhat:

Fasádok (Facades): az alkalmazás egy adott domainjének fő belépési pontját képviselik. Orchestrátorként működnek, amely koordinálja a különböző szolgáltatások közötti együttműködést a teljes use-case-ek (mint a “rendelés létrehozása” vagy “fizetés feldolgozása”) implementálása érdekében. Az orchestrációs rétege alatt a fasád elrejti az implementációs részleteket az alkalmazás többi része elől, ezáltal tiszta interfészt biztosítva az adott domainnel való munkához.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// validáció
		// rendelés létrehozása
		// e-mail küldése
		// statisztikákba írás
	}
}

Szolgáltatások (Services): egy specifikus üzleti műveletre összpontosítanak a domainen belül. Ellentétben a fasáddal, amely teljes use-case-eket orchestrál, a szolgáltatás egy konkrét üzleti logikát implementál (mint az árkalkulációk vagy a fizetések feldolgozása). A szolgáltatások tipikusan állapotmentesek, és használhatók akár fasádok által építőelemekként komplexebb műveletekhez, akár közvetlenül az alkalmazás más részei által egyszerűbb feladatokhoz.

class PricingService
{
	public function calculateTotal(Order $order): Money
	{
		// árkalkuláció
	}
}

Repository-k: biztosítják az összes kommunikációt az adattárolóval, tipikusan adatbázissal. Feladata az entitások betöltése és mentése, valamint metódusok implementálása azok kereséséhez. A repository elszigeteli az alkalmazás többi részét az adatbázis implementációs részleteitől, és objektumorientált interfészt biztosít az adatokkal való munkához.

class OrderRepository
{
	public function find(int $id): ?Order
	{
	}

	public function findByCustomer(int $customerId): array
	{
	}
}

Entitások: objektumok, amelyek az alkalmazás fő üzleti koncepcióit reprezentálják, saját identitással rendelkeznek és idővel változnak. Tipikusan olyan osztályokról van szó, amelyeket adatbázis táblákra map-elnek ORM segítségével (mint a Nette Database Explorer vagy a Doctrine). Az entitások tartalmazhatnak üzleti szabályokat az adataikra vonatkozóan és validációs logikát.

// Az orders adatbázis táblára map-elt entitás
class Order extends Nette\Database\Table\ActiveRow
{
	public function addItem(Product $product, int $quantity): void
	{
		$this->related('order_items')->insert([
			'product_id' => $product->id,
			'quantity' => $quantity,
			'unit_price' => $product->price,
		]);
	}
}

Value objektumok: megváltoztathatatlan objektumok, amelyek értékeket reprezentálnak saját identitás nélkül – például pénzösszeg vagy e-mail cím. Két azonos értékű value objektum példány azonosnak tekintendő.

Infrastrukturális kód

A Core/ (vagy Infrastructure/) mappa az alkalmazás technikai alapjának otthona. Az infrastrukturális kód tipikusan tartalmazza:

app/Core/
├── Router/               ← routing és URL menedzsment
│   └── RouterFactory.php
├── Security/             ← authentikáció és autorizáció
│   ├── Authenticator.php
│   └── Authorizator.php
├── Logging/              ← logolás és monitoring
│   ├── SentryLogger.php
│   └── FileLogger.php
├── Cache/                ← cachovací réteg
│   └── FullPageCache.php
└── Integration/          ← integráció külső szolgáltatásokkal
	├── Slack/
	└── Stripe/

Kisebb projekteknél természetesen elegendő a lapos tagolás:

Core/
├── RouterFactory.php
├── Authenticator.php
└── QueueMailer.php

Olyan kódról van szó, amely:

  • Technikai infrastruktúrát old meg (routing, logolás, cacholás)
  • Külső szolgáltatásokat integrál (Sentry, Elasticsearch, Redis)
  • Alapszolgáltatásokat nyújt az egész alkalmazás számára (mail, adatbázis)
  • Többnyire független a konkrét domaintól – a cache vagy a logger ugyanúgy működik egy webáruház vagy egy blog esetében.

Bizonytalan, hogy egy adott osztály ide vagy a modellbe tartozik-e? A kulcsfontosságú különbség az, hogy a Core/-ban lévő kód:

  • Nem tud semmit a domainről (termékek, rendelések, cikkek)
  • Többnyire átvihető egy másik projektbe
  • Azt oldja meg, “hogyan működik” (hogyan küldjön e-mailt), nem pedig azt, “mit csinál” (milyen e-mailt küldjön)

Példa a jobb megértéshez:

  • App\Core\MailerFactory – létrehozza az e-mailek küldésére szolgáló osztály példányait, kezeli az SMTP beállításokat
  • App\Model\OrderMailer – használja a MailerFactory-t a rendelésekkel kapcsolatos e-mailek küldésére, ismeri azok sablonjait és tudja, mikor kell elküldeni őket

Parancssori szkriptek

Az alkalmazásoknak gyakran kell tevékenységeket végezniük a szokásos HTTP kéréseken kívül – legyen szó akár háttérbeli adatfeldolgozásról, karbantartásról, vagy időszakos feladatokról. Futtatásukra egyszerű szkriptek szolgálnak a bin/ könyvtárban, magát az implementációs logikát pedig az app/Tasks/ (esetleg app/Commands/) mappába helyezzük.

Példa:

app/Tasks/
├── Maintenance/               ← karbantartó szkriptek
│   ├── CleanupCommand.php     ← régi adatok törlése
│   └── DbOptimizeCommand.php  ← adatbázis optimalizálása
├── Integration/               ← integráció külső rendszerekkel
│   ├── ImportProducts.php     ← import a beszállítói rendszerből
│   └── SyncOrders.php         ← rendelések szinkronizálása
└── Scheduled/                 ← rendszeres feladatok
	├── NewsletterCommand.php  ← hírlevelek kiküldése
	└── ReminderCommand.php    ← értesítések az ügyfeleknek

Mi tartozik a modellbe és mi a parancssori szkriptekbe? Például egyetlen e-mail elküldésének logikája a modell része, több ezer e-mail tömeges kiküldése már a Tasks/-ba tartozik.

A feladatokat általában parancssorból vagy cron segítségével futtatjuk. HTTP kérésen keresztül is futtathatók, de gondolni kell a biztonságra. A feladatot elindító presentert védeni kell, például csak bejelentkezett felhasználók számára, vagy erős tokennel és hozzáféréssel engedélyezett IP-címekről. Hosszú feladatok esetén növelni kell a szkript időkorlátját és használni kell a session_write_close()-t, hogy ne záródjon le a session.

További lehetséges könyvtárak

Az említett alapkönyvtárakon kívül a projekt igényei szerint további specializált mappákat is hozzáadhat. Nézzük meg a leggyakoribbakat és azok használatát:

app/
├── Api/              ← API logika, amely független a prezentációs rétegtől
├── Database/         ← migrációs szkriptek és seederek tesztadatokhoz
├── Components/       ← megosztott vizuális komponensek az egész alkalmazásban
├── Event/            ← hasznos, ha event-driven architektúrát használ
├── Mail/             ← e-mail sablonok és kapcsolódó logika
└── Utils/            ← segédosztályok

Az alkalmazásban használt megosztott vizuális komponensekhez használható az app/Components vagy app/Controls mappa:

app/Components/
├── Form/                 ← megosztott űrlap komponensek
│   ├── SignInForm.php
│   └── UserForm.php
├── Grid/                 ← komponensek adatlistázáshoz
│   └── DataGrid.php
└── Navigation/           ← navigációs elemek
	├── Breadcrumbs.php
	└── Menu.php

Ide tartoznak azok a komponensek, amelyek komplexebb logikával rendelkeznek. Ha komponenseket szeretne megosztani több projekt között, célszerű őket külön composer csomagba kivonni.

Az app/Mail könyvtárba helyezheti az e-mail kommunikáció kezelését:

app/Mail/
├── templates/            ← e-mail sablonok
│   ├── order-confirmation.latte
│   └── welcome.latte
└── OrderMailer.php

Presenterek map-elése

A map-elés definiálja a szabályokat az osztály nevének levezetésére a presenter nevéből. Ezeket a konfigurációban adjuk meg az application › mapping kulcs alatt.

Ezen az oldalon megmutattuk, hogy a presentereket az app/Presentation (esetleg app/UI) mappába helyezzük. Ezt a konvenciót közölnünk kell a Nette-vel a konfigurációs fájlban. Egyetlen sor elegendő:

application:
	mapping: App\Presentation\*\**Presenter

Hogyan működik a map-elés? A jobb megértés érdekében először képzeljünk el egy alkalmazást modulok nélkül. Azt szeretnénk, hogy a presenter osztályok az App\Presentation névtérbe essenek, hogy a Home presenter az App\Presentation\HomePresenter osztályra map-eljen. Ezt ezzel a konfigurációval érjük el:

application:
	mapping: App\Presentation\*Presenter

A map-elés úgy működik, hogy a Home presenter neve helyettesíti a csillagot az App\Presentation\*Presenter maszkban, így kapjuk meg az App\Presentation\HomePresenter végső osztálynevet. Egyszerű!

Ahogy azonban a példákban ebben és más fejezetekben látható, a presenter osztályokat azonos nevű alkönyvtárakba helyezzük, például a Home presenter az App\Presentation\Home\HomePresenter osztályra map-el. Ezt a kettőspont megduplázásával érjük el (Nette Application 3.2-t igényel):

application:
	mapping: App\Presentation\**Presenter

Most térjünk át a presenterek modulokba való map-elésére. Minden modulhoz definiálhatunk specifikus map-elést:

application:
	mapping:
		Front: App\Presentation\Front\**Presenter
		Admin: App\Presentation\Admin\**Presenter
		Api: App\Api\*Presenter

Ezen konfiguráció szerint a Front:Home presenter az App\Presentation\Front\Home\HomePresenter osztályra map-el, míg az Api:OAuth presenter az App\Api\OAuthPresenter osztályra.

Mivel a Front és Admin modulok hasonló map-elési móddal rendelkeznek, és valószínűleg több ilyen modul lesz, létrehozható egy általános szabály, amely helyettesíti őket. Az osztály maszkjába így bekerül egy új csillag a modulhoz:

application:
	mapping:
		*: App\Presentation\*\**Presenter
		Api: App\Api\*Presenter

Ez mélyebben beágyazott könyvtárstruktúrák esetén is működik, mint például a Admin:User:Edit presenter, a csillaggal jelölt szegmens minden szinten megismétlődik, és az eredmény az App\Presentation\Admin\User\Edit\EditPresenter osztály.

Alternatív jelölésként string helyett használhatunk egy három szegmensből álló tömböt. Ez a jelölés egyenértékű az előzővel:

application:
	mapping:
		*: [App\Presentation, *, **Presenter]
		Api: [App\Api, '', *Presenter]
verzió: 4.0