Struktura katalogów aplikacji

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

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

Ważne jest, aby wspomnieć, że sam Nette Framework nie narzuca żadnej konkretnej struktury. Jest zaprojektowany tak, aby można go było łatwo dostosować do wszelkich potrzeb i preferencji.

Podstawowa struktura projektu

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

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

Tę strukturę można dowolnie modyfikować zgodnie z własnymi potrzebami – zmieniać nazwy lub przenosić foldery. Następnie wystarczy tylko zmodyfikować ścieżki względne do katalogów w pliku Bootstrap.php i ewentualnie composer.json. Nic więcej nie jest potrzebne, żadna skomplikowana rekonfiguracja, żadne zmiany stałych. Nette dysponuje inteligentną autodetekcją i automatycznie rozpozna lokalizację aplikacji, w tym jej bazę URL.

Zasady organizacji kodu

Kiedy po raz pierwszy eksplorujesz nowy projekt, powinieneś szybko się w nim zorientować. Wyobraź sobie, że rozwijasz katalog app/Model/ i widzisz taką strukturę:

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

Z niej wyczytasz tylko to, że projekt używa jakichś usług, repozytoriów i encji. O rzeczywistym celu aplikacji nie dowiesz się absolutnie nic.

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 e-sklep. Już 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 szereg problemów: kod, który jest ze sobą logicznie powiązany, jest rozproszony w różnych folderach i trzeba między nimi przeskakiwać. Dlatego będziemy organizować według domen.

Przestrzenie nazw

Jest zwyczajem, że struktura katalogów odpowiada przestrzeniom nazw w aplikacji. Oznacza to, że fizyczna lokalizacja plików odpowiada ich namespace. Na przykład klasa umieszczona w app/Model/Product/ProductRepository.php powinna mieć namespace App\Model\Product. Ta zasada pomaga w orientacji w kodzie i upraszcza autoloading.

Liczba pojedyncza vs mnoga w nazwach

Zauważ, że w głównych katalogach aplikacji używamy liczby pojedynczej: app, config, log, temp, www. Podobnie wewnątrz aplikacji: Model, Core, Presentation. Dzieje się tak dlatego, że każdy z nich reprezentuje jeden spójny koncept.

Podobnie np. app/Model/Product reprezentuje wszystko związane z produktami. Nie nazwiemy tego Products, ponieważ nie jest to folder pełen produktów (byłyby tam pliki nokia.php, samsung.php). Jest to namespace zawierający klasy do pracy z produktami – ProductRepository.php, ProductService.php.

Folder app/Tasks jest w liczbie mnogiej, ponieważ zawiera zestaw samodzielnych skryptów wykonywalnych – CleanupTask.php, ImportTask.php. Każdy z nich jest samodzielną jednostką.

Dla spójności zalecamy używanie:

  • Liczby pojedynczej dla namespace reprezentującego funkcjonalną całość (nawet jeśli pracuje z wieloma encjami)
  • Liczby mnogiej dla kolekcji samodzielnych jednostek
  • W przypadku niepewności lub jeśli nie chcesz się nad tym zastanawiać, wybierz liczbę pojedynczą

Katalog publiczny www/

Ten katalog jest jedynym dostępnym z sieci (tzw. document-root). Często można spotkać się również z nazwą public/ zamiast www/ – jest to tylko kwestia konwencji i nie ma wpływu na funkcjonalność. Katalog zawiera:

  • Punkt wejściowy aplikacji index.php
  • Plik .htaccess z regułami dla mod_rewrite (w Apache)
  • Pliki statyczne (CSS, JavaScript, obrazy)
  • Przesłane pliki

Dla prawidłowego zabezpieczenia aplikacji kluczowe jest posiadanie poprawnie skonfigurowanego 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 jest główny katalog z kodem aplikacji. Podstawowa struktura:

app/
├── Core/               ← kwestie 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, ładuje konfigurację i tworzy kontener DI.

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

Presentery i szablony

Część prezentacyjną aplikacji mamy w katalogu app/Presentation. Alternatywą jest krótkie app/UI. Jest to miejsce dla wszystkich presenterów, ich szablonów i ewentualnych klas pomocniczych.

Tę warstwę organizujemy według domen. W złożonym projekcie, który łączy e-sklep, blog i API, struktura wyglądałaby tak:

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

Natomiast w prostym blogu użylibyśmy podziału:

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

Foldery takie jak Home/ czy Dashboard/ zawierają presentery i szablony. Foldery takie jak Front/, Admin/ czy Api/ nazywamy modułami. Technicznie są to zwykłe katalogi, które służą do logicznego podziału aplikacji.

Każdy folder z presenterem zawiera tak samo nazwany presenter 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 znajduje się w przestrzeni nazw App\Presentation\Admin\Dashboard (zobacz mapování presenterů):

namespace App\Presentation\Admin\Dashboard;

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

Do presentera Dashboard wewnątrz modułu Admin odwołujemy się w aplikacji za pomocą notacji dwukropkowej jako Admin:Dashboard. Do jego akcji default następnie jako Admin:Dashboard:default. W przypadku zagnieżdżonych modułów używamy więcej 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ą kanały XML. Na początku mamy prostą postać:

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

Z czasem pojawią się kolejne typy kanałów i będziemy potrzebować dla nich więcej logiki… Żaden problem! Folder Export/ po prostu stanie się modułem:

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

Ta transformacja jest całkowicie płynna – wystarczy utworzyć nowe podfoldery, podzielić do nich kod i zaktualizować linki (np. z Export:feed na Export:Feed:zbozi). Dzięki temu możemy strukturę stopniowo rozszerzać w miarę potrzeb, poziom zagnieżdżenia nie jest w żaden sposób ograniczony.

Jeśli na przykład w administracji masz wiele presenterów dotyczących zarządzania zamówieniami, takich jak OrderDetail, OrderEdit, OrderDispatch itp., możesz dla lepszej organizacji w tym miejscu utworzyć moduł (folder) Order, w którym będą (foldery dla) presenterów Detail, Edit, Dispatch i inne.

Lokalizacja szablonów

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

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

Ta lokalizacja w praktyce okazuje się najwygodniejsza – wszystkie powiązane pliki masz od razu pod ręką.

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

Klasy pomocnicze i komponenty

Do presenterów i szablonów często należą również inne pliki pomocnicze. Umieszczamy je logicznie według ich zakresu:

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

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

2. Dla modułu – zalecamy wykorzystanie folderu Accessory, który umieści się przejrzyście 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/

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

Model – serce aplikacji

Model zawiera całą logikę biznesową aplikacji. Dla jego organizacji obowiązuje ponownie zasada – strukturyzujemy według domen:

app/Model/
├── Payment/                   ← wszystko związane z płatnościami
│   ├── PaymentFacade.php      ← główny punkt wejściowy
│   ├── PaymentRepository.php
│   ├── Payment.php            ← encja
├── Order/                     ← wszystko związane z zamówieniami
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← wszystko związane z wysyłką

W modelu typowo spotkasz się z tymi typami klas:

Fasady: reprezentują główny punkt wejściowy do konkretnej domeny w aplikacji. Działają jako orkiestrator, który koordynuje współpracę między różnymi usługami w celu implementacji kompletnych przypadków użycia (jak “utwórz zamówienie” lub “przetwórz płatność”). Pod swoją warstwą orkiestracji fasada ukrywa szczegóły implementacyjne przed resztą aplikacji, dostarczając czysty interfejs do pracy z daną domeną.

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

Usługi: koncentrują się na specyficznej operacji biznesowej w ramach domeny. W przeciwieństwie do fasady, która orkiestruje całe przypadki użycia, usługa implementuje konkretną logikę biznesową (jak kalkulacje cen lub przetwarzanie płatności). Usługi są typowo bezstanowe i mogą być używane albo przez fasady jako bloki budulcowe dla bardziej złożonych operacji, albo bezpośrednio przez inne części aplikacji dla prostszych zadań.

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

Repozytoria: zapewniają całą komunikację z magazynem danych, typowo bazą danych. Jego zadaniem jest wczytywanie i zapisywanie encji oraz implementacja metod do ich wyszukiwania. Repozytorium odizolowuje resztę aplikacji od szczegółów implementacyjnych bazy danych i dostarcza interfejs zorientowany obiektowo do pracy z danymi.

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

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

Encje: obiekty reprezentujące główne koncepty biznesowe w aplikacji, które mają swoją tożsamość i zmieniają się w czasie. Typowo są to klasy mapowane na tabele bazy danych za pomocą ORM (jak Nette Database Explorer lub Doctrine). Encje mogą zawierać reguły biznesowe dotyczące ich danych oraz 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,
		]);
	}
}

Obiekty wartości: niemutowalne obiekty reprezentujące wartości bez własnej tożsamości – na przykład kwota pieniężna lub adres e-mail. Dwie instancje obiektu wartości z tymi samymi wartościami są uważane za identyczne.

Kod infrastruktury

Folder Core/ (lub także Infrastructure/) jest domem dla technicznego fundamentu aplikacji. Kod infrastruktury typowo obejmuje:

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/

W mniejszych projektach oczywiście wystarczy płaska struktura:

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

Chodzi o kod, który:

  • Rozwiązuje problemy techniczne infrastruktury (routing, logowanie, cachowanie)
  • Integruje usługi zewnętrzne (Sentry, Elasticsearch, Redis)
  • Dostarcza podstawowe usługi dla całej aplikacji (mail, baza danych)
  • Jest zazwyczaj niezależny od konkretnej domeny – cache lub logger działa tak samo dla e-sklepu czy bloga.

Wahasz się, czy dana klasa należy tutaj, czy do modelu? Kluczowa różnica polega na tym, że kod w Core/:

  • Nie wie nic o domenie (produkty, zamówienia, artykuły)
  • Jest zazwyczaj możliwe przeniesienie go do innego projektu
  • Rozwiązuje “jak to działa” (jak wysłać maila), a nie “co to robi” (jakiego maila wysłać)

Przykład dla lepszego zrozumienia:

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

Skrypty poleceń

Aplikacje często potrzebują wykonywać czynności poza zwykłymi żądaniami HTTP – czy to chodzi o przetwarzanie danych w tle, konserwację, czy zadania okresowe. Do uruchamiania służą proste skrypty w katalogu bin/, samą logikę implementacyjną umieszczamy w app/Tasks/ (ewentualnie 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 do wysłania jednego e-maila jest częścią modelu, masowa wysyłka tysięcy e-maili już należy do Tasks/.

Zadania zazwyczaj uruchamiamy z wiersza poleceń lub przez cron. Można je uruchamiać również przez żądanie HTTP, ale trzeba pamiętać o bezpieczeństwie. Presenter, który uruchomi zadanie, trzeba zabezpieczyć, na przykład tylko dla zalogowanych użytkowników lub silnym tokenem i dostępem z dozwolonych adresów IP. W przypadku długich zadań trzeba zwiększyć limit czasu skryptu i użyć session_write_close(), aby nie blokować sesji.

Inne możliwe katalogi

Oprócz wspomnianych podstawowych katalogów można w zależności od potrzeb projektu dodać inne specjalistyczne foldery. Spójrzmy na najczęstsze z nich i ich zastosowanie:

app/
├── Api/              ← logika dla API niezależna od warstwy prezentacji
├── Database/         ← skrypty migracyjne i seedery dla danych testowych
├── Components/       ← współdzielone komponenty wizualne w całej aplikacji
├── Event/            ← przydatne jeśli używasz architektury sterowanej zdarzeniami
├── Mail/             ← szablony e-mail i powiązana logika
└── Utils/            ← klasy pomocnicze

Dla współdzielonych komponentów wizualnych używanych w presenterach w całej aplikacji można użyć folderu app/Components lub app/Controls:

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

Tutaj należą komponenty, które mają bardziej złożoną logikę. Jeśli chcesz współdzielić komponenty między wieloma projektami, wskazane jest wydzielenie ich do osobnego pakietu composera.

Do katalogu app/Mail można umieścić zarządzanie komunikacją e-mail:

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

Mapowanie presenterów

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

Na tej stronie pokazaliśmy, że presentery umieszczamy w folderze app/Presentation (ewentualnie app/UI). Tę konwencję musimy przekazać Nette w pliku konfiguracyjnym. Wystarczy jedna linia:

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

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

application:
	mapping: App\Presentation\*Presenter

Mapowanie działa tak, że nazwa presentera Home zastępuje gwiazdkę w masce App\Presentation\*Presenter, przez co uzyskujemy wynikową nazwę klasy App\Presentation\HomePresenter. Proste!

Jak jednak widać w przykładach w tym i innych rozdziałach, klasy presenterów umieszczamy w eponimicznych podkatalogach, na przykład presenter Home mapuje się na klasę App\Presentation\Home\HomePresenter. Osiągniemy to przez podwojenie dwukropka (wymaga Nette Application 3.2):

application:
	mapping: App\Presentation\**Presenter

Teraz przystąpimy do mapowania presenterów do modułów. Dla każdego modułu możemy zdefiniować specyficzne 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, podczas gdy presenter Api:OAuth na klasę App\Api\OAuthPresenter.

Ponieważ moduły Front i Admin mają podobny sposób mapowania i takich modułów będzie prawdopodobnie więcej, możliwe jest utworzenie ogólnej reguły, która je zastąpi. Do maski klasy dojdzie więc 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 na przykład presenter Admin:User:Edit, segment z gwiazdką powtarza się dla każdego poziomu, a wynikiem jest klasa App\Presentation\Admin\User\Edit\EditPresenter.

Alternatywnym zapisem jest użycie zamiast stringa tablicy składającej się z trzech segmentów. Ten zapis jest ekwiwalentny z poprzednim:

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