Structura directoarelor aplicației

Cum să proiectăm o structură de directoare clară și scalabilă pentru proiectele în Nette Framework? Vom arăta practici dovedite care vă vor ajuta cu organizarea codului. Veți afla:

  • cum să împărțiți logic aplicația în directoare
  • cum să proiectați structura astfel încât să scaleze bine odată cu creșterea proiectului
  • care sunt alternativele posibile și avantajele sau dezavantajele lor

Este important de menționat că Nette Framework însuși nu impune nicio structură specifică. Este proiectat astfel încât să poată fi ușor adaptat la orice nevoi și preferințe.

Structura de bază a proiectului

Deși Nette Framework nu dictează nicio structură de directoare fixă, există o aranjare implicită dovedită sub forma Web Project:

web-project/
├── app/              ← director cu aplicația
├── assets/           ← fișiere SCSS, JS, imagini..., alternativ resources/
├── bin/              ← scripturi pentru linia de comandă
├── config/           ← configurație
├── log/              ← erori înregistrate
├── temp/             ← fișiere temporare, cache
├── tests/            ← teste
├── vendor/           ← biblioteci instalate de Composer
└── www/              ← director public (document-root)

Această structură poate fi modificată liber în funcție de nevoile dvs. – folderele pot fi redenumite sau mutate. Apoi este suficient doar să modificați căile relative către directoare în fișierul Bootstrap.php și eventual composer.json. Nimic mai mult nu este necesar, nicio reconfigurare complicată, nicio modificare a constantelor. Nette dispune de autodetecție inteligentă și recunoaște automat locația aplicației, inclusiv baza sa URL.

Principii de organizare a codului

Când explorați pentru prima dată un proiect nou, ar trebui să vă orientați rapid în el. Imaginați-vă că deschideți directorul app/Model/ și vedeți această structură:

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

Din aceasta deduceți doar că proiectul folosește niște servicii, depozite și entități. Despre scopul real al aplicației nu aflați absolut nimic.

Să ne uităm la o altă abordare – organizarea pe domenii:

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

Aici este altfel – la prima vedere este clar că este vorba despre un magazin online. Chiar și numele directoarelor dezvăluie ce poate face aplicația – lucrează cu plăți, comenzi și produse.

Prima abordare (organizarea după tipul claselor) aduce în practică o serie de probleme: codul care este logic legat este fragmentat în diferite foldere și trebuie să săriți între ele. De aceea, vom organiza pe domenii.

Spații de nume

Este obișnuit ca structura directoarelor să corespundă spațiilor de nume din aplicație. Aceasta înseamnă că locația fizică a fișierelor corespunde namespace-ului lor. De exemplu, o clasă situată în app/Model/Product/ProductRepository.php ar trebui să aibă namespace-ul App\Model\Product. Acest principiu ajută la orientarea în cod și simplifică autoloading-ul.

Singular vs plural în nume

Observați că pentru directoarele principale ale aplicației folosim singularul: app, config, log, temp, www. La fel și în interiorul aplicației: Model, Core, Presentation. Acest lucru se datorează faptului că fiecare dintre ele reprezintă un concept unitar.

Similar, de exemplu, app/Model/Product reprezintă totul legat de produse. Nu îl vom numi Products, deoarece nu este un folder plin de produse (acolo ar fi fișiere nokia.php, samsung.php). Este un namespace care conține clase pentru lucrul cu produse – ProductRepository.php, ProductService.php.

Folderul app/Tasks este la plural deoarece conține un set de scripturi executabile separate – CleanupTask.php, ImportTask.php. Fiecare dintre ele este o unitate separată.

Pentru consistență, recomandăm utilizarea:

  • Singularului pentru namespace-ul care reprezintă un ansamblu funcțional (chiar dacă lucrează cu mai multe entități)
  • Pluralului pentru colecții de unități separate
  • În caz de incertitudine sau dacă nu doriți să vă gândiți la asta, alegeți singularul

Director public www/

Acest director este singurul accesibil de pe web (așa-numitul document-root). Adesea puteți întâlni și numele public/ în loc de www/ – este doar o chestiune de convenție și nu are nicio influență asupra funcționalității aplicației. Directorul conține:

  • Punctul de intrare al aplicației index.php
  • Fișierul .htaccess cu reguli pentru mod_rewrite (pentru Apache)
  • Fișiere statice (CSS, JavaScript, imagini)
  • Fișiere încărcate

Pentru securitatea corectă a aplicației, este esențial să aveți configurat corect document-root.

Nu plasați niciodată folderul node_modules/ în acest director – conține mii de fișiere care pot fi executabile și nu ar trebui să fie accesibile public.

Director aplicație app/

Acesta este directorul principal cu codul aplicației. Structura de bază:

app/
├── Core/               ← aspecte de infrastructură
├── Model/              ← logica de business
├── Presentation/       ← presentere și șabloane
├── Tasks/              ← scripturi de comandă
└── Bootstrap.php       ← clasa de inițializare a aplicației

Bootstrap.php este clasa de pornire a aplicației, care inițializează mediul, încarcă configurația și creează containerul DI.

Să ne uităm acum mai detaliat la subdirectoarele individuale.

Presentere și șabloane

Partea de prezentare a aplicației o avem în directorul app/Presentation. O alternativă este scurtul app/UI. Este locul pentru toți presenterele, șabloanele lor și eventualele clase ajutătoare.

Acest strat îl organizăm pe domenii. Într-un proiect complex, care combină un magazin online, un blog și un API, structura ar arăta astfel:

app/Presentation/
├── Shop/              ← frontend magazin online
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← blog
│   ├── Home/
│   └── Post/
├── Admin/             ← administrare
│   ├── Dashboard/
│   └── Products/
└── Api/               ← endpoint-uri API
	└── V1/

Pe de altă parte, pentru un blog simplu, am folosi o împărțire:

app/Presentation/
├── Front/             ← frontend web
│   ├── Home/
│   └── Post/
├── Admin/             ← administrare
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, sitemap-uri etc.

Foldere precum Home/ sau Dashboard/ conțin presentere și șabloane. Foldere precum Front/, Admin/ sau Api/ le numim module. Tehnic, sunt directoare obișnuite care servesc la împărțirea logică a aplicației.

Fiecare folder cu un presenter conține un presenter cu același nume și șabloanele sale. De exemplu, folderul Dashboard/ conține:

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

Această structură de directoare se reflectă în spațiile de nume ale claselor. De exemplu, DashboardPresenter se află în spațiul de nume App\Presentation\Admin\Dashboard (vezi mapování presenterů):

namespace App\Presentation\Admin\Dashboard;

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

La presenterul Dashboard din interiorul modulului Admin facem referire în aplicație folosind notația cu două puncte ca Admin:Dashboard. La acțiunea sa default apoi ca Admin:Dashboard:default. În cazul modulelor imbricate, folosim mai multe două puncte, de exemplu Shop:Order:Detail:default.

Dezvoltare flexibilă a structurii

Unul dintre marile avantaje ale acestei structuri este cât de elegant se adaptează la nevoile în creștere ale proiectului. Ca exemplu, să luăm partea care generează feed-uri XML. La început avem o formă simplă:

Export/
├── ExportPresenter.php   ← un singur presenter pentru toate exporturile
├── sitemap.latte         ← șablon pentru sitemap
└── feed.latte            ← șablon pentru feed RSS

Cu timpul, apar noi tipuri de feed-uri și avem nevoie de mai multă logică pentru ele… Nicio problemă! Folderul Export/ devine pur și simplu un modul:

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

Această transformare este complet fluidă – este suficient să creați noi subfoldere, să împărțiți codul în ele și să actualizați linkurile (de ex. de la Export:feed la Export:Feed:zbozi). Datorită acestui fapt, putem extinde treptat structura după necesități, nivelul de imbricare nu este limitat în niciun fel.

Dacă, de exemplu, în administrare aveți mulți presenteri referitori la gestionarea comenzilor, cum ar fi OrderDetail, OrderEdit, OrderDispatch etc., puteți crea pentru o mai bună organizare în acest loc un modul (folder) Order, în care vor fi (foldere pentru) presenterele Detail, Edit, Dispatch și altele.

Amplasarea șabloanelor

În exemplele anterioare am văzut că șabloanele sunt plasate direct în folderul cu presenterul:

Dashboard/
├── DashboardPresenter.php     ← presenter
├── DashboardTemplate.php      ← clasă opțională pentru șablon
└── default.latte              ← șablon

Această amplasare se dovedește în practică a fi cea mai convenabilă – aveți toate fișierele aferente la îndemână.

Alternativ, puteți plasa șabloanele într-un subfolder templates/. Nette suportă ambele variante. Puteți chiar plasa șabloanele complet în afara folderului Presentation/. Totul despre posibilitățile de amplasare a șabloanelor găsiți în capitolul Căutarea șabloanelor.

Clase ajutătoare și componente

Presenterelor și șabloanelor le aparțin adesea și alte fișiere ajutătoare. Le plasăm logic în funcție de domeniul lor de aplicare:

1. Direct lângă presenter în cazul componentelor specifice pentru presenterul respectiv:

Product/
├── ProductPresenter.php
├── ProductGrid.php        ← componentă pentru listarea produselor
└── FilterForm.php         ← formular pentru filtrare

2. Pentru modul – recomandăm utilizarea folderului Accessory, care se plasează convenabil chiar la începutul alfabetului:

Front/
├── Accessory/
│   ├── NavbarControl.php    ← componente pentru frontend
│   └── TemplateFilters.php
├── Product/
└── Cart/

3. Pentru întreaga aplicație – în Presentation/Accessory/:

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

Sau puteți plasa clase ajutătoare precum LatteExtension.php sau TemplateFilters.php în folderul de infrastructură app/Core/Latte/. Și componentele în app/Components. Alegerea depinde de obiceiurile echipei.

Model – inima aplicației

Modelul conține întreaga logică de business a aplicației. Pentru organizarea sa se aplică din nou regula – structurăm pe domenii:

app/Model/
├── Payment/                   ← totul despre plăți
│   ├── PaymentFacade.php      ← principalul punct de intrare
│   ├── PaymentRepository.php
│   ├── Payment.php            ← entitate
├── Order/                     ← totul despre comenzi
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← totul despre transport

În model veți întâlni de obicei aceste tipuri de clase:

Facade: reprezintă principalul punct de intrare într-un domeniu specific al aplicației. Acționează ca un orchestrator care coordonează colaborarea între diferite servicii în scopul implementării cazurilor de utilizare complete (cum ar fi “creează comandă” sau “procesează plată”). Sub stratul său de orchestrator, fațada ascunde detaliile de implementare de restul aplicației, oferind astfel o interfață curată pentru lucrul cu domeniul respectiv.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// validare
		// creare comandă
		// trimitere e-mail
		// înregistrare în statistici
	}
}

Servicii: se concentrează pe o operațiune specifică de business în cadrul domeniului. Spre deosebire de fațadă, care orchestrează cazuri de utilizare întregi, serviciul implementează o logică de business specifică (cum ar fi calcule de prețuri sau procesarea plăților). Serviciile sunt de obicei fără stare și pot fi utilizate fie de fațade ca blocuri de construcție pentru operațiuni mai complexe, fie direct de alte părți ale aplicației pentru sarcini mai simple.

class PricingService
{
	public function calculateTotal(Order $order): Money
	{
		// calcul preț
	}
}

Depozite: asigură întreaga comunicare cu stocarea de date, de obicei o bază de date. Sarcina sa este de a încărca și salva entități și de a implementa metode pentru căutarea lor. Depozitul izolează restul aplicației de detaliile de implementare ale bazei de date și oferă o interfață orientată pe obiecte pentru lucrul cu datele.

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

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

Entități: obiecte care reprezintă principalele concepte de business în aplicație, care au identitatea lor și se schimbă în timp. De obicei, sunt clase mapate pe tabele de baze de date folosind ORM (cum ar fi Nette Database Explorer sau Doctrine). Entitățile pot conține reguli de business referitoare la datele lor și logică de validare.

// Entitate mapată pe tabela de bază de date orders
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,
		]);
	}
}

Obiecte valoare: obiecte imuabile care reprezintă valori fără identitate proprie – de exemplu, o sumă de bani sau o adresă de e-mail. Două instanțe ale unui obiect valoare cu aceleași valori sunt considerate identice.

Cod de infrastructură

Folderul Core/ (sau și Infrastructure/) este casa pentru baza tehnică a aplicației. Codul de infrastructură include de obicei:

app/Core/
├── Router/               ← rutare și management URL
│   └── RouterFactory.php
├── Security/             ← autentificare și autorizare
│   ├── Authenticator.php
│   └── Authorizator.php
├── Logging/              ← logare și monitorizare
│   ├── SentryLogger.php
│   └── FileLogger.php
├── Cache/                ← strat de cache
│   └── FullPageCache.php
└── Integration/          ← integrare cu servicii ext.
	├── Slack/
	└── Stripe/

Pentru proiecte mai mici, este suficientă, desigur, o structură plată:

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

Este vorba despre cod care:

  • Rezolvă infrastructura tehnică (rutare, logare, cache)
  • Integrează servicii externe (Sentry, Elasticsearch, Redis)
  • Oferă servicii de bază pentru întreaga aplicație (mail, bază de date)
  • Este în mare parte independent de domeniul specific – cache-ul sau loggerul funcționează la fel pentru magazinul online sau blog.

Ezitați dacă o anumită clasă aparține aici sau în model? Diferența cheie este că codul din Core/:

  • Nu știe nimic despre domeniu (produse, comenzi, articole)
  • Este în mare parte posibil să fie transferat într-un alt proiect
  • Rezolvă “cum funcționează” (cum se trimite un mail), nu “ce face” (ce mail să trimită)

Exemplu pentru o mai bună înțelegere:

  • App\Core\MailerFactory – creează instanțe ale clasei pentru trimiterea e-mailurilor, rezolvă setările SMTP
  • App\Model\OrderMailer – folosește MailerFactory pentru a trimite e-mailuri despre comenzi, cunoaște șabloanele lor și știe când trebuie trimise

Scripturi de comandă

Aplicațiile au adesea nevoie să execute activități în afara cererilor HTTP obișnuite – fie că este vorba de procesarea datelor în fundal, întreținere sau sarcini periodice. Pentru rulare se folosesc scripturi simple în directorul bin/, logica de implementare propriu-zisă o plasăm apoi în app/Tasks/ (eventual app/Commands/).

Exemplu:

app/Tasks/
├── Maintenance/               ← scripturi de întreținere
│   ├── CleanupCommand.php     ← ștergerea datelor vechi
│   └── DbOptimizeCommand.php  ← optimizarea bazei de date
├── Integration/               ← integrare cu sisteme externe
│   ├── ImportProducts.php     ← import din sistemul furnizorului
│   └── SyncOrders.php         ← sincronizarea comenzilor
└── Scheduled/                 ← sarcini regulate
	├── NewsletterCommand.php  ← trimiterea newsletterelor
	└── ReminderCommand.php    ← notificări clienți

Ce aparține modelului și ce scripturilor de comandă? De exemplu, logica pentru trimiterea unui singur e-mail face parte din model, trimiterea în masă a mii de e-mailuri aparține deja Tasks/.

Sarcinile le rulăm de obicei din linia de comandă sau prin cron. Pot fi rulate și prin cerere HTTP, dar trebuie să ne gândim la securitate. Presenterul care rulează sarcina trebuie securizat, de exemplu, doar pentru utilizatorii conectați sau cu un token puternic și acces de la adrese IP permise. Pentru sarcinile lungi, este necesar să se mărească limita de timp a scriptului și să se folosească session_write_close(), pentru a nu bloca sesiunea.

Alte directoare posibile

Pe lângă directoarele de bază menționate, puteți adăuga, în funcție de nevoile proiectului, alte foldere specializate. Să ne uităm la cele mai frecvente dintre ele și la utilizarea lor:

app/
├── Api/              ← logica pentru API independentă de stratul de prezentare
├── Database/         ← scripturi de migrare și seedere pentru date de test
├── Components/       ← componente vizuale partajate în întreaga aplicație
├── Event/            ← util dacă utilizați arhitectura bazată pe evenimente
├── Mail/             ← șabloane de e-mail și logica aferentă
└── Utils/            ← clase ajutătoare

Pentru componentele vizuale partajate utilizate în presentere în întreaga aplicație, se poate folosi folderul app/Components sau app/Controls:

app/Components/
├── Form/                 ← componente de formular partajate
│   ├── SignInForm.php
│   └── UserForm.php
├── Grid/                 ← componente pentru listări de date
│   └── DataGrid.php
└── Navigation/           ← elemente de navigație
	├── Breadcrumbs.php
	└── Menu.php

Aici aparțin componentele care au o logică mai complexă. Dacă doriți să partajați componente între mai multe proiecte, este recomandabil să le extrageți într-un pachet composer separat.

În directorul app/Mail puteți plasa gestionarea comunicării prin e-mail:

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

Maparea presenterelor

Maparea definește reguli pentru derivarea numelui clasei din numele presenterului. Le specificăm în configurație sub cheia application › mapping.

Pe această pagină am arătat că plasăm presenterele în folderul app/Presentation (eventual app/UI). Această convenție trebuie să o comunicăm lui Nette în fișierul de configurare. Este suficientă o singură linie:

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

Cum funcționează maparea? Pentru o mai bună înțelegere, să ne imaginăm mai întâi o aplicație fără module. Dorim ca clasele presenterelor să se încadreze în spațiul de nume App\Presentation, astfel încât presenterul Home să fie mapat pe clasa App\Presentation\HomePresenter. Ceea ce realizăm cu această configurație:

application:
	mapping: App\Presentation\*Presenter

Maparea funcționează astfel încât numele presenterului Home înlocuiește asteriscul din masca App\Presentation\*Presenter, obținând astfel numele final al clasei App\Presentation\HomePresenter. Simplu!

Dar, după cum vedeți în exemplele din acest capitol și din altele, plasăm clasele presenterelor în subdirectoare eponime, de exemplu, presenterul Home se mapează pe clasa App\Presentation\Home\HomePresenter. Acest lucru se realizează prin dublarea celor două puncte (necesită Nette Application 3.2):

application:
	mapping: App\Presentation\**Presenter

Acum trecem la maparea presenterelor în module. Pentru fiecare modul putem defini o mapare specifică:

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

Conform acestei configurații, presenterul Front:Home se mapează pe clasa App\Presentation\Front\Home\HomePresenter, în timp ce presenterul Api:OAuth pe clasa App\Api\OAuthPresenter.

Deoarece modulele Front și Admin au un mod similar de mapare și probabil vor exista mai multe astfel de module, este posibil să se creeze o regulă generală care să le înlocuiască. Astfel, în masca clasei va apărea un nou asterisc pentru modul:

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

Funcționează și pentru structuri de directoare mai adânc imbricate, cum ar fi, de exemplu, presenterul Admin:User:Edit, segmentul cu asterisc se repetă pentru fiecare nivel și rezultatul este clasa App\Presentation\Admin\User\Edit\EditPresenter.

O notație alternativă este să folosim un array format din trei segmente în loc de un șir de caractere. Această notație este echivalentă cu cea anterioară:

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