Struktura mape aplikacije

Kako zasnovati pregledno in razširljivo strukturo map za projekte v Nette Framework? Pokazali si bomo preverjene prakse, ki vam bodo pomagale pri organizaciji kode. Izvedeli boste:

  • kako logično razčleniti aplikacijo v mape
  • kako strukturo zasnovati tako, da dobro skalira z rastjo projekta
  • kakšne so možne alternative in njihove prednosti ali slabosti

Pomembno je omeniti, da Nette Framework sam po sebi ne vztraja pri nobeni konkretni strukturi. Zasnovan je tako, da se ga da enostavno prilagoditi kakršnimkoli potrebam in preferencam.

Osnovna struktura projekta

Čeprav Nette Framework ne narekuje nobene fiksne strukture map, obstaja preverjena privzeta ureditev v obliki Web Project:

web-project/
├── app/              ← mapa z aplikacijo
├── assets/           ← datoteke SCSS, JS, slike..., alternativno resources/
├── bin/              ← skripti za ukazno vrstico
├── config/           ← konfiguracija
├── log/              ← zabeležene napake
├── temp/             ← začasne datoteke, predpomnilnik
├── tests/            ← testi
├── vendor/           ← knjižnice, nameščene s Composerjem
└── www/              ← javna mapa (document-root)

To strukturo lahko poljubno urejate glede na svoje potrebe – mape preimenujete ali premaknete. Nato je dovolj le urediti relativne poti do map v datoteki Bootstrap.php in po potrebi composer.json. Nič več ni potrebno, nobene zapletene rekonfiguracije, nobenih sprememb konstant. Nette razpolaga s pametnim samodejnim zaznavanjem in samodejno prepozna lokacijo aplikacije, vključno z njeno osnovno URL.

Principi organizacije kode

Ko prvič raziskujete nov projekt, bi se morali v njem hitro znajti. Predstavljajte si, da odprete mapo app/Model/ in vidite to strukturo:

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

Iz nje razberete le to, da projekt uporablja neke storitve, repozitorije in entitete. O dejanskem namenu aplikacije ne izveste ničesar.

Poglejmo si drugačen pristop – organizacijo po domenah:

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

Tukaj je drugače – na prvi pogled je jasno, da gre za spletno trgovino. Že sama imena map razkrivajo, kaj aplikacija zna – dela s plačili, naročili in izdelki.

Prvi pristop (organizacija po tipu razredov) v praksi prinaša vrsto težav: koda, ki logično sodi skupaj, je razdrobljena v različne mape in morate med njimi preskakovati. Zato bomo organizirali po domenah.

Imenski prostori

Običajno je, da struktura map ustreza imenskim prostorom v aplikaciji. To pomeni, da fizična lokacija datotek ustreza njihovemu imenskemu prostoru. Na primer, razred, ki se nahaja v app/Model/Product/ProductRepository.php, bi moral imeti imenski prostor App\Model\Product. Ta princip pomaga pri orientaciji v kodi in poenostavlja samodejno nalaganje.

Ednina vs množina v imenih

Opazite, da pri glavnih mapah aplikacije uporabljamo ednino: app, config, log, temp, www. Enako tudi znotraj aplikacije: Model, Core, Presentation. To je zato, ker vsaka od njih predstavlja en celovit koncept.

Podobno na primer app/Model/Product predstavlja vse v zvezi z izdelki. Ne bomo ga poimenovali Products, ker ne gre za mapo, polno izdelkov (to bi bile tam datoteke nokia.php, samsung.php). To je imenski prostor, ki vsebuje razrede za delo z izdelki – ProductRepository.php, ProductService.php.

Mapa app/Tasks je v množini zato, ker vsebuje nabor samostojnih izvedljivih skriptov – CleanupTask.php, ImportTask.php. Vsak od njih je samostojna enota.

Za doslednost priporočamo uporabo:

  • Ednine za imenski prostor, ki predstavlja funkcionalno celoto (čeprav dela z več entitetami)
  • Množine za zbirke samostojnih enot
  • V primeru negotovosti ali če o tem ne želite razmišljati, izberite ednino

Javna mapa www/

Ta mapa je edina dostopna s spleta (t.i. document-root). Pogosto se lahko srečate tudi z imenom public/ namesto www/ – to je le vprašanje konvencije in na funkcionalnost aplikacije nima vpliva. Mapa vsebuje:

  • Vstopno točko aplikacije index.php
  • Datoteko .htaccess s pravili za mod_rewrite (pri Apache)
  • Statične datoteke (CSS, JavaScript, slike)
  • Naložene datoteke

Za pravilno varnost aplikacije je ključno imeti pravilno konfiguriran document-root.

Nikoli ne postavljajte v to mapo mape node_modules/ – vsebuje na tisoče datotek, ki so lahko izvedljive in ne bi smele biti javno dostopne.

Aplikacijska mapa app/

To je glavna mapa z aplikacijsko kodo. Osnovna struktura:

app/
├── Core/               ← infrastrukturne zadeve
├── Model/              ← poslovna logika
├── Presentation/       ← presenterji in predloge
├── Tasks/              ← ukazni skripti
└── Bootstrap.php       ← zagonski razred aplikacije

Bootstrap.php je zagonski razred aplikacije, ki inicializira okolje, nalaga konfiguracijo in ustvarja DI vsebnik.

Poglejmo si zdaj posamezne podmape podrobneje.

Presenterji in predloge

Predstavitveni del aplikacije imamo v mapi app/Presentation. Alternativa je kratko app/UI. To je mesto za vse presenterje, njihove predloge in morebitne pomožne razrede.

To plast organiziramo po domenah. V kompleksnem projektu, ki združuje spletno trgovino, blog in API, bi struktura izgledala takole:

app/Presentation/
├── Shop/              ← spletna trgovina frontend
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← blog
│   ├── Home/
│   └── Post/
├── Admin/             ← administracija
│   ├── Dashboard/
│   └── Products/
└── Api/               ← API končne točke
	└── V1/

Nasprotno pa bi pri preprostem blogu uporabili členitev:

app/Presentation/
├── Front/             ← frontend spletnega mesta
│   ├── Home/
│   └── Post/
├── Admin/             ← administracija
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, sitemaps itd.

Mape kot Home/ ali Dashboard/ vsebujejo presenterje in predloge. Mape kot Front/, Admin/ ali Api/ imenujemo moduli. Tehnično gre za običajne mape, ki služijo za logično členitev aplikacije.

Vsaka mapa s presenterjem vsebuje enako poimenovan presenter in njegove predloge. Na primer, mapa Dashboard/ vsebuje:

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

Ta struktura map se odraža v imenskih prostorih razredov. Na primer, DashboardPresenter se nahaja v imenskem prostoru App\Presentation\Admin\Dashboard (glej #mapiranje presenterjev):

namespace App\Presentation\Admin\Dashboard;

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

Na presenter Dashboard znotraj modula Admin se v aplikaciji sklicujemo s pomočjo dvopične notacije kot na Admin:Dashboard. Na njegovo akcijo default potem kot na Admin:Dashboard:default. V primeru ugnezdenih modulov uporabljamo več dvopičij, na primer Shop:Order:Detail:default.

Fleksibilen razvoj strukture

Ena od velikih prednosti te strukture je, kako elegantno se prilagaja rastočim potrebam projekta. Kot primer si vzemimo del, ki generira XML vire. Na začetku imamo preprosto obliko:

Export/
├── ExportPresenter.php   ← en presenter za vse izvoze
├── sitemap.latte         ← predloga za sitemap
└── feed.latte            ← predloga za RSS vir

Sčasoma se dodajo nove vrste virov in zanje potrebujemo več logike… Noben problem! Mapa Export/ preprosto postane modul:

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

Ta transformacija je popolnoma gladka – dovolj je ustvariti nove podmape, razdeliti kodo vanje in posodobiti povezave (npr. iz Export:feed na Export:Feed:zbozi). Zahvaljujoč temu lahko strukturo postopoma širimo glede na potrebe, raven gnezdenja ni nikakor omejena.

Če na primer v administraciji imate veliko presenterjev, ki se nanašajo na upravljanje naročil, kot so OrderDetail, OrderEdit, OrderDispatch itd., lahko za boljšo organiziranost na tem mestu ustvarite modul (mapo) Order, v katerem bodo (mape za) presenterje Detail, Edit, Dispatch in drugi.

Lokacija predlog

V prejšnjih primerih smo videli, da so predloge nameščene neposredno v mapi s presenterjem:

Dashboard/
├── DashboardPresenter.php     ← presenter
├── DashboardTemplate.php      ← izbirni razred za predlogo
└── default.latte              ← predloga

Ta lokacija se v praksi izkaže za najudobnejšo – vse povezane datoteke imate takoj pri roki.

Alternativno lahko predloge namestite v podmapo templates/. Nette podpira obe varianti. Celo predloge lahko namestite tudi popolnoma izven mape Presentation/. Vse o možnostih lokacije predlog najdete v poglavju Iskanje predlog.

Pomožni razredi in komponente

K presenterjem in predlogam pogosto spadajo tudi druge pomožne datoteke. Namestimo jih logično glede na njihovo področje delovanja:

1. Neposredno pri presenterju v primeru specifičnih komponent za dani presenter:

Product/
├── ProductPresenter.php
├── ProductGrid.php        ← komponenta za izpis izdelkov
└── FilterForm.php         ← obrazec za filtriranje

2. Za modul – priporočamo uporabo mape Accessory, ki se namesti pregledno takoj na začetku abecede:

Front/
├── Accessory/
│   ├── NavbarControl.php    ← komponente za frontend
│   └── TemplateFilters.php
├── Product/
└── Cart/

3. Za celotno aplikacijo – v Presentation/Accessory/:

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

Ali pa lahko pomožne razrede kot LatteExtension.php ali TemplateFilters.php namestite v infrastrukturno mapo app/Core/Latte/. In komponente v app/Components. Izbira je odvisna od navad ekipe.

Model – srce aplikacije

Model vsebuje vso poslovno logiko aplikacije. Za njegovo organizacijo velja spet pravilo – strukturiramo po domenah:

app/Model/
├── Payment/                   ← vse v zvezi s plačili
│   ├── PaymentFacade.php      ← glavna vstopna točka
│   ├── PaymentRepository.php
│   ├── Payment.php            ← entiteta
├── Order/                     ← vse v zvezi z naročili
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← vse v zvezi z dostavo

V modelu se tipično srečate s temi tipi razredov:

Fasade: predstavljajo glavno vstopno točko v konkretno domeno v aplikaciji. Delujejo kot orkestrator, ki koordinira sodelovanje med različnimi storitvami za namen implementacije celotnih primerov uporabe (kot “ustvari naročilo” ali “obdelaj plačilo”). Pod svojo orkestracijsko plastjo fasada skriva implementacijske podrobnosti pred preostankom aplikacije, s čimer zagotavlja čist vmesnik za delo z dano domeno.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// validacija
		// ustvarjanje naročila
		// pošiljanje e-pošte
		// zapisovanje v statistiko
	}
}

Storitve: osredotočajo se na specifično poslovno operacijo znotraj domene. Za razliko od fasade, ki orkestrira celotne primere uporabe, storitev implementira konkretno poslovno logiko (kot izračuni cen ali obdelava plačil). Storitve so tipično brez stanja in jih lahko uporabljajo bodisi fasade kot gradniki za kompleksnejše operacije ali neposredno drugi deli aplikacije za enostavnejše naloge.

class PricingService
{
	public function calculateTotal(Order $order): Money
	{
		// izračun cene
	}
}

Repozitoriji: zagotavljajo vso komunikacijo s podatkovnim skladiščem, tipično podatkovno bazo. Njegova naloga je nalaganje in shranjevanje entitet ter implementacija metod za njihovo iskanje. Repozitorij loči preostanek aplikacije od implementacijskih podrobnosti podatkovne baze in zagotavlja objektno usmerjen vmesnik za delo s podatki.

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

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

Entitete: objekti, ki predstavljajo glavne poslovne koncepte v aplikaciji, ki imajo svojo identiteto in se spreminjajo s časom. Tipično gre za razrede, preslikane na tabele podatkovne baze s pomočjo ORM (kot Nette Database Explorer ali Doctrine). Entitete lahko vsebujejo poslovna pravila, ki se nanašajo na njihove podatke, in validacijsko logiko.

// Entiteta, preslikana na tabelo podatkovne baze 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,
		]);
	}
}

Vrednostni objekti: nespremenljivi objekti, ki predstavljajo vrednosti brez lastne identitete – na primer denarni znesek ali e-poštni naslov. Dve instanci vrednostnega objekta z enakimi vrednostmi se štejeta za identični.

Infrastrukturna koda

Mapa Core/ (ali tudi Infrastructure/) je dom za tehnično osnovo aplikacije. Infrastrukturna koda tipično vključuje:

app/Core/
├── Router/               ← usmerjanje in upravljanje URL-jev
│   └── RouterFactory.php
├── Security/             ← avtentikacija in avtorizacija
│   ├── Authenticator.php
│   └── Authorizator.php
├── Logging/              ← dnevniško beleženje in nadzor
│   ├── SentryLogger.php
│   └── FileLogger.php
├── Cache/                ← plast predpomnjenja
│   └── FullPageCache.php
└── Integration/          ← integracija z zunanjimi storitvami
	├── Slack/
	└── Stripe/

Pri manjših projektih seveda zadostuje ravna členitev:

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

Gre za kodo, ki:

  • Rešuje tehnično infrastrukturo (usmerjanje, beleženje, predpomnjenje)
  • Integrira zunanje storitve (Sentry, Elasticsearch, Redis)
  • Zagotavlja osnovne storitve za celotno aplikacijo (pošta, podatkovna baza)
  • Je večinoma neodvisna od konkretne domene – predpomnilnik ali logger deluje enako za spletno trgovino ali blog.

Se sprašujete, ali določen razred spada sem ali v model? Ključna razlika je v tem, da koda v Core/:

  • Ne ve nič o domeni (izdelki, naročila, članki)
  • Je večinoma mogoče prenesti v drug projekt
  • Rešuje “kako deluje” (kako poslati pošto), ne pa “kaj dela” (kakšno pošto poslati)

Primer za boljše razumevanje:

  • App\Core\MailerFactory – ustvarja instance razreda za pošiljanje e-pošte, rešuje SMTP nastavitve
  • App\Model\OrderMailer – uporablja MailerFactory za pošiljanje e-pošte o naročilih, pozna njihove predloge in ve, kdaj se morajo poslati

Ukazni skripti

Aplikacije pogosto potrebujejo izvajanje dejavnosti izven običajnih HTTP zahtevkov – bodisi gre za obdelavo podatkov v ozadju, vzdrževanje ali periodične naloge. Za zagon služijo preprosti skripti v mapi bin/, samo implementacijsko logiko pa namestimo v app/Tasks/ (po potrebi app/Commands/).

Primer:

app/Tasks/
├── Maintenance/               ← vzdrževalni skripti
│   ├── CleanupCommand.php     ← brisanje starih podatkov
│   └── DbOptimizeCommand.php  ← optimizacija podatkovne baze
├── Integration/               ← integracija z zunanjimi sistemi
│   ├── ImportProducts.php     ← uvoz iz dobaviteljskega sistema
│   └── SyncOrders.php         ← sinhronizacija naročil
└── Scheduled/                 ← redne naloge
	├── NewsletterCommand.php  ← pošiljanje novičnikov
	└── ReminderCommand.php    ← obvestila strankam

Kaj spada v model in kaj v ukazne skripte? Na primer, logika za pošiljanje enega e-poštnega sporočila je del modela, množično pošiljanje tisočev e-poštnih sporočil pa že spada v Tasks/.

Naloge običajno zaženemo iz ukazne vrstice ali prek crona. Lahko jih zaženemo tudi prek HTTP zahtevka, vendar je treba misliti na varnost. Presenter, ki nalogo zažene, je treba zavarovati, na primer samo za prijavljene uporabnike ali z močnim žetonom in dostopom z dovoljenih IP naslovov. Pri dolgih nalogah je treba povečati časovno omejitev skripta in uporabiti session_write_close(), da se ne zaklene seja.

Druge možne mape

Poleg omenjenih osnovnih map lahko glede na potrebe projekta dodate druge specializirane mape. Poglejmo si najpogostejše izmed njih in njihovo uporabo:

app/
├── Api/              ← logika za API, neodvisna od predstavitvene plasti
├── Database/         ← migracijski skripti in sejalci za testne podatke
├── Components/       ← deljene vizualne komponente po celotni aplikaciji
├── Event/            ← uporabno, če uporabljate arhitekturo, vodeno z dogodki
├── Mail/             ← e-poštne predloge in povezana logika
└── Utils/            ← pomožni razredi

Za deljene vizualne komponente, uporabljene v presenterjih po celotni aplikaciji, lahko uporabite mapo app/Components ali app/Controls:

app/Components/
├── Form/                 ← deljene komponente obrazcev
│   ├── SignInForm.php
│   └── UserForm.php
├── Grid/                 ← komponente za izpise podatkov
│   └── DataGrid.php
└── Navigation/           ← navigacijski elementi
	├── Breadcrumbs.php
	└── Menu.php

Sem spadajo komponente, ki imajo kompleksnejšo logiko. Če želite komponente deliti med več projekti, je priporočljivo, da jih izločite v samostojen composer paket.

V mapo app/Mail lahko namestite upravljanje e-poštne komunikacije:

app/Mail/
├── templates/            ← e-poštne predloge
│   ├── order-confirmation.latte
│   └── welcome.latte
└── OrderMailer.php

Mapiranje presenterjev

Mapiranje definira pravila za izpeljavo imena razreda iz imena presenterja. Specificiramo jih v konfiguraciji pod ključem application › mapping.

Na tej strani smo si pokazali, da presenterje nameščamo v mapo app/Presentation (po potrebi app/UI). To konvencijo moramo Nette sporočiti v konfiguracijski datoteki. Dovolj je ena vrstica:

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

Kako mapiranje deluje? Za boljše razumevanje si najprej predstavljajmo aplikacijo brez modulov. Želimo, da razredi presenterjev spadajo v imenski prostor App\Presentation, da se presenter Home preslika na razred App\Presentation\HomePresenter. Kar dosežemo s to konfiguracijo:

application:
	mapping: App\Presentation\*Presenter

Mapiranje deluje tako, da ime presenterja Home nadomesti zvezdico v maski App\Presentation\*Presenter, s čimer dobimo končno ime razreda App\Presentation\HomePresenter. Preprosto!

Kot pa vidite v primerih v tem in drugih poglavjih, razrede presenterjev nameščamo v istoimenske podmape, na primer presenter Home se preslika na razred App\Presentation\Home\HomePresenter. To dosežemo z podvojitvijo dvopičja (zahteva Nette Application 3.2):

application:
	mapping: App\Presentation\**Presenter

Zdaj pristopimo k mapiranju presenterjev v module. Za vsak modul lahko definiramo specifično mapiranje:

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

Glede na to konfiguracijo se presenter Front:Home preslika na razred App\Presentation\Front\Home\HomePresenter, medtem ko se presenter Api:OAuth na razred App\Api\OAuthPresenter.

Ker imata modula Front in Admin podoben način mapiranja in takšnih modulov bo najverjetneje več, je mogoče ustvariti splošno pravilo, ki jih nadomesti. V masko razreda tako pride nova zvezdica za modul:

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

Deluje tudi za globlje ugnezdene strukture map, kot je na primer presenter Admin:User:Edit, se segment z zvezdico ponovi za vsako raven in rezultat je razred App\Presentation\Admin\User\Edit\EditPresenter.

Alternativni zapis je namesto niza uporabiti polje, sestavljeno iz treh segmentov. Ta zapis je ekvivalenten prejšnjemu:

application:
	mapping:
		*: [App\Presentation, *, **Presenter]
		Api: [App\Api, '', *Presenter]
različica: 4.0