Struktura katalogów aplikacji

Jak zaprojektować przejrzystą i skalowalną strukturę katalogów dla projektów w Nette Framework? Pokażemy Ci sprawdzone praktyki, które pomogą uporządkować kod. Dowiesz się:

  • jak logicznie podzielić aplikację na katalogi
  • jak zaprojektować strukturę, aby dobrze się skalowała wraz z rozwojem projektu
  • jakie są możliwe alternatywy oraz ich zalety i wady

Warto wspomnieć, że sam Nette Framework nie wymusza żadnej konkretnej struktury. Zaprojektowano go tak, aby dawał się łatwo dostosować do dowolnych potrzeb i preferencji.

Podstawowa struktura projektu

Choć Nette Framework nie narzuca żadnej sztywnej struktury katalogów, istnieje sprawdzony układ domyślny w postaci Web Project:

web-project/
├── app/              ← katalog aplikacji
├── assets/           ← pliki SCSS, JS, obrazy..., alternatywnie resources/
├── bin/              ← skrypty do wiersza poleceń
├── config/           ← konfiguracja
├── log/              ← zalogowane błędy
├── temp/             ← pliki tymczasowe, cache
├── tests/            ← testy
├── vendor/           ← biblioteki zainstalowane przez Composera
└── www/              ← katalog publiczny (document-root)

Strukturę tę możesz swobodnie modyfikować według swoich potrzeb: zmieniać nazwy folderów albo je przenosić. Wystarczy wtedy dostosować ścieżki względne do katalogów w Bootstrap.php i ewentualnie composer.json. Nic więcej nie jest potrzebne, żadnej skomplikowanej rekonfiguracji, żadnych zmian stałych. Nette ma sprytną autodetekcję i samo rozpoznaje położenie aplikacji wraz z jej bazowym URL.

Zasady organizacji kodu

Gdy po raz pierwszy poznajesz nowy projekt, powinieneś móc szybko się w nim odnaleźć. Wyobraź sobie, że klikasz w katalog app/Model/ i widzisz taką strukturę:

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

Dowiadujesz się z tego tylko tyle, że projekt używa jakichś usług, repozytoriów i encji. O rzeczywistym przeznaczeniu aplikacji nie dowiadujesz się niczego.

Spójrzmy na inne podejście – organizację według domen:

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

Tutaj jest inaczej – na pierwszy rzut oka widać, że chodzi o sklep internetowy. Same nazwy katalogów zdradzają, co aplikacja potrafi: pracuje z płatnościami, zamówieniami i produktami.

Pierwsze podejście (organizacja według typu klas) przynosi w praktyce kilka problemów: kod logicznie powiązany jest rozproszony po różnych folderach i trzeba między nimi skakać. Dlatego będziemy organizować według domen.

Przestrzenie nazw

Zwyczajowo struktura katalogów odpowiada przestrzeniom nazw w aplikacji. Oznacza to, że fizyczne położenie plików zgadza się z ich przestrzenią nazw. Na przykład klasa leżąca w app/Model/Product/ProductRepository.php powinna mieć przestrzeń nazw App\Model\Product. Zasada ta pomaga w poruszaniu się po kodzie i upraszcza autoloading.

Liczba pojedyncza kontra mnoga w nazwach

Zauważ, że dla głównych katalogów aplikacji używamy liczby pojedynczej: app, config, log, temp, www. To samo dotyczy wnętrza aplikacji: Model, Core, Presentation. Dzieje się tak dlatego, że każdy z nich reprezentuje jedno spójne pojęcie.

Podobnie app/Model/Product reprezentuje wszystko, co dotyczy produktów. Nie nazywamy go Products, bo to nie jest folder pełen produktów (zawierałby pliki w rodzaju nokia.php, samsung.php). To przestrzeń nazw zawierająca klasy do pracy z produktami: ProductRepository.php, ProductService.php.

Folder app/Tasks jest w liczbie mnogiej, bo zawiera zestaw osobnych wykonywalnych skryptów: CleanupTask.php, ImportTask.php. Każdy z nich jest niezależną jednostką.

Dla spójności zalecamy używać:

  • liczby pojedynczej dla przestrzeni nazw reprezentujących jednostkę funkcjonalną (nawet jeśli pracuje z wieloma encjami)
  • liczby mnogiej dla zbiorów niezależnych jednostek
  • w razie wątpliwości albo gdy nie chcesz się nad tym zastanawiać, wybierz liczbę pojedynczą

Katalog publiczny www/

Ten katalog jako jedyny jest dostępny z sieci (document-root). Często możesz spotkać się z nazwą public/ zamiast www/ – to tylko kwestia konwencji i nie wpływa na działanie aplikacji. Katalog zawiera:

  • punkt wejścia aplikacji index.php
  • plik .htaccess z regułami mod_rewrite (dla Apache)
  • pliki statyczne (CSS, JavaScript, obrazy)
  • przesłane pliki

Dla właściwego bezpieczeństwa aplikacji kluczowe jest poprawne skonfigurowanie document-root.

Nigdy nie umieszczaj w tym katalogu folderu node_modules/ – zawiera tysiące plików, które mogą być wykonywalne i nie powinny być publicznie dostępne.

Katalog aplikacji app/

To główny katalog zawierający kod aplikacji. Podstawowa struktura:

app/
├── Core/               ← sprawy infrastrukturalne
├── Model/              ← logika biznesowa
├── Presentation/       ← presentery i szablony
├── Tasks/              ← skrypty poleceń
└── Bootstrap.php       ← klasa startowa aplikacji

Bootstrap.php to klasa startowa aplikacji, która inicjalizuje środowisko, wczytuje konfigurację i tworzy kontener DI.

Przyjrzyjmy się teraz poszczególnym podkatalogom szczegółowo.

Presentery i szablony

Warstwa prezentacji aplikacji leży w katalogu app/Presentation. Alternatywą jest krótsze app/UI. To miejsce na wszystkie presentery, ich szablony i ewentualne powiązane klasy pomocnicze.

Warstwę tę organizujemy według domen. W złożonym projekcie łączącym sklep internetowy, blog i API struktura wyglądałaby tak:

app/Presentation/
├── Shop/              ← frontend sklepu
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← blog
│   ├── Home/
│   └── Post/
├── Admin/             ← administracja
│   ├── Dashboard/
│   └── Products/
└── Api/               ← endpointy API
	└── V1/

Odwrotnie, dla prostego bloga użylibyśmy takiej struktury:

app/Presentation/
├── Front/             ← frontend witryny
│   ├── Home/
│   └── Post/
├── Admin/             ← administracja
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, mapy strony itd.

Foldery w rodzaju Home/ czy Dashboard/ zawierają presentery i szablony. Foldery w rodzaju Front/, Admin/ czy Api/ nazywamy modułami. Technicznie to zwykłe katalogi służące do logicznego podziału aplikacji.

Każdy folder zawierający presenter obejmuje sam plik presentera i jego szablony. Na przykład folder Dashboard/ zawiera:

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

Ta struktura katalogów odzwierciedla się w przestrzeniach nazw klas. Na przykład DashboardPresenter leży w przestrzeni nazw App\Presentation\Admin\Dashboard (zobacz Mapowanie presenterów):

namespace App\Presentation\Admin\Dashboard;

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

Do presentera Dashboard w module Admin odwołujemy się w aplikacji zapisem z dwukropkiem jako Admin:Dashboard. Do jego akcji default odwołujemy się potem jako Admin:Dashboard:default. Przy zagnieżdżonych modułach używamy większej liczby dwukropków, na przykład Shop:Order:Detail:default.

Elastyczny rozwój struktury

Jedną z wielkich zalet tej struktury jest to, jak elegancko dostosowuje się do rosnących potrzeb projektu. Jako przykład weźmy część generującą feedy XML. Na początku mamy prostą postać:

Export/
├── ExportPresenter.php   ← jeden presenter dla wszystkich eksportów
├── sitemap.latte         ← szablon mapy strony
└── feed.latte            ← szablon feedu RSS

Z czasem dochodzą kolejne typy feedów i potrzebujemy dla nich więcej logiki… Żaden problem! Folder Export/ po prostu staje się modułem:

Export/
├── Sitemap/
│   ├── SitemapPresenter.php
│   └── sitemap.latte
└── Feed/
	├── FeedPresenter.php
	├── amazon.latte         ← feed dla Amazona
	└── ebay.latte           ← feed dla eBaya

Ta przemiana jest całkowicie płynna – wystarczy utworzyć nowe podfoldery, podzielić między nie kod i zaktualizować odnośniki (np. z Export:feed na Export:Feed:amazon). Dzięki temu możemy stopniowo rozbudowywać strukturę według potrzeb, a poziom zagnieżdżenia nie jest w żaden sposób ograniczony.

Jeśli na przykład w administracji masz wiele presenterów związanych z zarządzaniem zamówieniami, takich jak OrderDetail, OrderEdit, OrderDispatch itd., możesz dla lepszej organizacji utworzyć moduł (folder) o nazwie Order, który będzie zawierać (foldery dla) presentery Detail, Edit, Dispatch i inne.

Położenie szablonów

W poprzednich przykładach widzieliśmy, że szablony leżą bezpośrednio w folderze z presenterem:

Dashboard/
├── DashboardPresenter.php     ← presenter
├── DashboardTemplate.php      ← opcjonalna klasa szablonu
└── default.latte              ← szablon

To położenie okazuje się w praktyce najwygodniejsze – masz wszystkie powiązane pliki pod ręką.

Alternatywnie możesz umieścić szablony w podfolderze templates/. Nette obsługuje oba warianty. Możesz nawet umieścić szablony całkowicie poza folderem Presentation/. Wszystko o możliwościach położenia szablonów znajdziesz w rozdziale Wyszukiwanie szablonów.

Klasy pomocnicze i komponenty

Presenterom i szablonom często towarzyszą inne pliki pomocnicze. Umieszczamy je logicznie według ich zasięgu:

1. Bezpośrednio przy presenterze w przypadku komponentów specyficznych dla tego presentera:

Product/
├── ProductPresenter.php
├── ProductGrid.php        ← komponent do listowania produktów
└── FilterForm.php         ← formularz do filtrowania

2. Dla modułu – zalecamy folder Accessory, który wygodnie ląduje na początku alfabetu:

Front/
├── Accessory/
│   ├── NavbarControl.php    ← komponenty dla frontendu
│   └── TemplateFilters.php
├── Product/
└── Cart/

3. Dla całej aplikacji – w Presentation/Accessory/:

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

Alternatywnie klasy pomocnicze, takie jak LatteExtension.php czy TemplateFilters.php, możesz umieścić w folderze infrastrukturalnym app/Core/Latte/. A komponenty w app/Components. Wybór zależy od konwencji zespołu.

Model – serce aplikacji

Model zawiera całą logikę biznesową aplikacji. Zasada jego organizacji jest znów taka sama: strukturyzuj według domen:

app/Model/
├── Payment/                   ← wszystko o płatnościach
│   ├── PaymentFacade.php      ← główny punkt wejścia
│   ├── PaymentRepository.php
│   ├── Payment.php            ← encja
├── Order/                     ← wszystko o zamówieniach
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← wszystko o wysyłce

W modelu typowo spotkasz te rodzaje klas:

Fasady: reprezentują główny punkt wejścia do konkretnej domeny w aplikacji. Działają jak orkiestrator, koordynując współpracę różnych usług w celu zrealizowania kompletnych przypadków użycia (jak “utwórz zamówienie” czy “przetwórz płatność”). Pod swoją warstwą orkiestracji fasada ukrywa przed resztą aplikacji szczegóły implementacyjne, dostarczając tym samym czysty interfejs do pracy z daną domeną.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// walidacja
		// utworzenie zamówienia
		// wysłanie e-maila
		// zapis do statystyk
	}
}

Usługi: skupiają się na konkretnych operacjach biznesowych w obrębie domeny. W odróżnieniu od fasad, które orkiestrują całe przypadki użycia, usługa implementuje konkretną logikę biznesową (jak obliczanie cen czy przetwarzanie płatności). Usługi są zwykle bezstanowe i mogą być używane albo przez fasady jako klocki do bardziej złożonych operacji, albo bezpośrednio przez inne części aplikacji do prostszych zadań.

class PricingService
{
	public function calculateTotal(Order $order): Money
	{
		// obliczenie ceny
	}
}

Repozytoria: zajmują się całą komunikacją z magazynem danych, zwykle z bazą danych. Ich zadaniem jest wczytywanie i zapisywanie encji oraz implementowanie metod do ich wyszukiwania. Repozytorium osłania resztę aplikacji przed szczegółami implementacyjnymi bazy danych i dostarcza obiektowy interfejs do pracy z danymi.

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

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

Encje: obiekty reprezentujące główne pojęcia biznesowe w aplikacji, które mają własną tożsamość i zmieniają się w czasie. Zwykle są to klasy mapowane na tabele bazy danych za pomocą ORM (jak Nette Database Explorer albo Doctrine). Encje mogą zawierać reguły biznesowe związane ze swoimi danymi i logikę walidacji.

// encja mapowana na tabelę bazy danych '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,
		]);
	}
}

Value Objects: niezmienne obiekty reprezentujące wartości bez własnej tożsamości, na przykład kwotę pieniężną albo adres e-mail. Dwie instancje value objectu o tych samych wartościach uznawane są za identyczne.

Kod infrastrukturalny

Folder Core/ (albo alternatywnie Infrastructure/) jest domem technicznego fundamentu aplikacji. Kod infrastrukturalny obejmuje zwykle:

app/Core/
├── Router/               ← routing i zarządzanie URL
│   └── RouterFactory.php
├── Security/             ← uwierzytelnianie i autoryzacja
│   ├── Authenticator.php
│   └── Authorizator.php
├── Logging/              ← logowanie i monitorowanie
│   ├── SentryLogger.php
│   └── FileLogger.php
├── Cache/                ← warstwa cache
│   └── FullPageCache.php
└── Integration/          ← integracja z usługami zewnętrznymi
	├── Slack/
	└── Stripe/

Przy mniejszych projektach naturalnie wystarczy struktura płaska:

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

To kod, który:

  • zajmuje się infrastrukturą techniczną (routing, logowanie, cache)
  • integruje usługi zewnętrzne (Sentry, Elasticsearch, Redis)
  • dostarcza podstawowe usługi dla całej aplikacji (poczta, baza danych)
  • jest w większości niezależny od konkretnej domeny – cache albo logger działa tak samo dla sklepu internetowego i dla bloga

Zastanawiasz się, czy dana klasa należy tutaj, czy do modelu? Kluczowa różnica jest taka, że kod w Core/:

  • nie wie nic o domenie (produkty, zamówienia, artykuły)
  • zwykle da się przenieść do innego projektu
  • rozwiązuje “jak to działa” (jak wysłać e-mail), a nie “co robi” (jaki e-mail wysłać)

Przykład dla lepszego zrozumienia:

  • App\Core\MailerFactory – tworzy instancje klasy do wysyłania e-maili, zajmuje się ustawieniami SMTP
  • App\Model\OrderMailer – używa MailerFactory do wysyłania e-maili o zamówieniach, zna ich szablony i wie, kiedy mają być wysyłane

Skrypty poleceń

Aplikacje często potrzebują wykonywać czynności poza zwykłymi żądaniami HTTP, czy to przetwarzanie danych w tle, czy konserwację, czy zadania okresowe. Do uruchamiania służą proste skrypty w katalogu bin/, a właściwa logika implementacji umieszczana jest w app/Tasks/ (albo app/Commands/).

Przykład:

app/Tasks/
├── Maintenance/               ← skrypty konserwacyjne
│   ├── CleanupCommand.php     ← usuwanie starych danych
│   └── DbOptimizeCommand.php  ← optymalizacja bazy danych
├── Integration/               ← integracja z systemami zewnętrznymi
│   ├── ImportProducts.php     ← import z systemu dostawcy
│   └── SyncOrders.php         ← synchronizacja zamówień
└── Scheduled/                 ← zadania regularne
	├── NewsletterCommand.php  ← wysyłanie newsletterów
	└── ReminderCommand.php    ← powiadomienia dla klientów

Co należy do modelu, a co do skryptów poleceń? Na przykład logika wysyłania jednego e-maila jest częścią modelu, natomiast masowe wysyłanie tysięcy e-maili należy do Tasks/.

Zadania uruchamiane są zwykle z wiersza poleceń albo przez cron: skrypt w bin/ tworzy kontener DI metodą bootConsoleApplication() i pobiera z niego potrzebną usługę. Można je uruchamiać również przez żądanie HTTP, ale trzeba pomyśleć o bezpieczeństwie. Presenter uruchamiający zadanie trzeba zabezpieczyć, na przykład tylko dla zalogowanych użytkowników albo silnym tokenem i dostępem z dozwolonych adresów IP. Przy długo działających zadaniach trzeba zwiększyć limit czasu skryptu i użyć session_write_close(), aby nie blokować sesji.

Inne możliwe katalogi

Poza wymienionymi podstawowymi katalogami możesz dodać kolejne, wyspecjalizowane foldery według potrzeb projektu. Spójrzmy na najczęstsze i ich zastosowanie:

app/
├── Api/              ← logika API niezależna od warstwy prezentacji
├── Database/         ← skrypty migracyjne i seedery danych testowych
├── Components/       ← współdzielone komponenty wizualne w całej aplikacji
├── Event/            ← przydatne przy architekturze zdarzeniowej
├── Mail/             ← szablony e-maili i powiązana logika
└── Utils/            ← klasy pomocnicze

Na współdzielone komponenty wizualne używane w presenterach w całej aplikacji możesz przeznaczyć folder app/Components albo app/Controls:

app/Components/
├── Form/                 ← współdzielone komponenty formularzy
│   ├── SignInForm.php
│   └── UserForm.php
├── Grid/                 ← komponenty do listowania danych
│   └── DataGrid.php
└── Navigation/           ← elementy nawigacji
	├── Breadcrumbs.php
	└── Menu.php

To miejsce dla komponentów o bardziej złożonej logice. Jeśli chcesz współdzielić komponenty między wieloma projektami, warto wydzielić je do osobnego pakietu Composera.

W katalogu app/Mail możesz umieścić zarządzanie komunikacją e-mailową:

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

Mapowanie presenterów

Mapowanie definiuje reguły wyprowadzania nazwy klasy z nazwy presentera. Podajemy je w konfiguracji pod kluczem application › mapping.

Na tej stronie pokazaliśmy, że presentery umieszczamy w folderze app/Presentation (albo app/UI). Od Nette Application 3.3 jest to konwencja domyślna, której nie trzeba konfigurować. Jeśli używasz innej struktury albo chcesz podać mapowanie jawnie, ustawienie domyślne odpowiada temu wierszowi:

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

Jak działa mapowanie? Dla lepszego zrozumienia wyobraźmy sobie najpierw aplikację bez modułów. Chcemy, aby klasy presenterów należały do przestrzeni nazw App\Presentation, tak aby presenter Home mapował się na klasę App\Presentation\HomePresenter. Osiągniemy to taką konfiguracją:

application:
	mapping: App\Presentation\*Presenter

Mapowanie działa tak, że gwiazdka w masce App\Presentation\*Presenter zastępowana jest nazwą presentera Home, co daje ostateczną nazwę klasy App\Presentation\HomePresenter. Proste!

Jak jednak widzisz w przykładach w tym i innych rozdziałach, klasy presenterów umieszczamy w jednoimiennych podkatalogach, na przykład presenter Home mapuje się na klasę App\Presentation\Home\HomePresenter. Osiągniemy to, używając podwójnej gwiazdki ** (wymaga Nette Application 3.2.3):

application:
	mapping: App\Presentation\**Presenter

Przejdźmy teraz do mapowania presenterów w modułach. Dla każdego modułu możemy zdefiniować własne mapowanie:

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

Zgodnie z tą konfiguracją presenter Front:Home mapuje się na klasę App\Presentation\Front\Home\HomePresenter, a presenter Api:OAuth na klasę App\Api\OAuthPresenter.

Ponieważ moduły Front i Admin mają podobny wzorzec mapowania, a takich modułów będzie zapewne więcej, można utworzyć ogólną regułę, która je zastąpi. Do maski klasy dochodzi nowa gwiazdka dla modułu:

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

Działa to również dla głębiej zagnieżdżonych struktur katalogów, jak presenter Admin:User:Edit, gdzie segment z gwiazdką powtarza się dla każdego poziomu modułu, co daje klasę App\Presentation\Admin\User\Edit\EditPresenter.

Alternatywnym zapisem jest użycie zamiast stringa tablicy złożonej z trzech segmentów. Dla pokazanych wyżej przykładów zapis ten jest równoważny poprzedniemu:

application:
	mapping:
		*: [App\Presentation, *, **Presenter]
		Api: [App\Api, '', *Presenter]
wersja: 4.x