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ą Ci zorganizować kod. Dowiesz się:

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

Ważne jest, aby wspomnieć, że sam Nette Framework nie nalega na żadną konkretną strukturę. Został zaprojektowany tak, aby można go było łatwo dostosować do wszelkich potrzeb i preferencji.

Podstawowa struktura projektu

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

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

Możesz dowolnie modyfikować tę strukturę zgodnie ze swoimi potrzebami – zmieniać nazwy lub przenosić foldery. Następnie wystarczy dostosować względne ścieżki do katalogów w Bootstrap.php i ewentualnie composer.json. Nic więcej nie jest potrzebne, żadnej skomplikowanej rekonfiguracji, żadnych ciągłych zmian. Nette posiada inteligentne automatyczne wykrywanie i automatycznie rozpoznaje lokalizację aplikacji, w tym jej bazę URL.

Zasady organizacji kodu

Kiedy po raz pierwszy odkrywasz nowy projekt, powinieneś być w stanie szybko się zorientować. Wyobraź sobie, że klikasz na katalog app/Model/ i widzisz następującą strukturę:

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

Z tego dowiesz się tylko, że projekt korzysta z niektórych usług, repozytoriów i encji. Nie dowiesz się niczego o rzeczywistym celu aplikacji.

Przyjrzyjmy się innemu podejściu – organizacji według domen:

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

Jest inaczej – na pierwszy rzut oka widać, że jest to witryna e-commerce. Same nazwy katalogów zdradzają, co potrafi aplikacja – działa z płatnościami, zamówieniami i produktami.

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

Przestrzenie nazw

Konwencjonalne jest, że struktura katalogów odpowiada przestrzeniom nazw w aplikacji. Oznacza to, że fizyczna lokalizacja plików odpowiada ich przestrzeni nazw. Na przykład, klasa znajdująca się w app/Model/Product/ProductRepository.php powinna mieć przestrzeń nazw App\Model\Product. Zasada ta pomaga w orientacji kodu i upraszcza automatyczne ładowanie.

Liczba pojedyncza a mnoga w nazwach

Zauważ, że używamy liczby pojedynczej dla głównych katalogów aplikacji: app, config, log, temp, www. To samo dotyczy wewnątrz aplikacji: Model, Core, Presentation. Dzieje się tak, ponieważ każdy z nich reprezentuje jedną ujednoliconą koncepcję.

Podobnie, app/Model/Product reprezentuje wszystko o produktach. Nie nazywamy go Products, ponieważ nie jest to folder pełen produktów (który zawierałby pliki takie jak iphone.php, samsung.php). Jest to przestrzeń nazw zawierająca klasy do pracy z produktami – ProductRepository.php, ProductService.php.

Folder app/Tasks ma liczbę mnogą, ponieważ zawiera zestaw samodzielnych skryptów wykonywalnych – CleanupTask.php, ImportTask.php. Każdy z nich jest niezależną jednostką.

Dla zachowania spójności zalecamy używanie:

  • Singular dla przestrzeni nazw reprezentujących jednostkę funkcjonalną (nawet w przypadku pracy z wieloma jednostkami)
  • Liczba mnoga dla zbiorów niezależnych jednostek
  • W przypadku niepewności lub jeśli nie chcesz o tym myśleć, wybierz liczbę pojedynczą

Katalog publiczny www/

Ten katalog jest jedynym dostępnym z sieci (tzw. document-root). Często można spotkać się z nazwą public/ zamiast www/ – jest to tylko kwestia konwencji i nie ma wpływu na funkcjonalność. 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 prawidłowego bezpieczeństwa aplikacji kluczowe znaczenie ma poprawnie skonfigurowany document-root.

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

Katalog aplikacji app/

Jest to główny katalog z kodem aplikacji. Podstawowa struktura:

app/
├── Core/               ← Infrastruktura ma znaczenie
├── Model/              ← logika biznesowa
├── Presentation/       ← prezentery i szablony
├── Tasks/              ← skrypty poleceń
└── Bootstrap.php       ← klasa bootstrap aplikacji

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

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

Prezentery i szablony

Część prezentacyjna aplikacji znajduje się w katalogu app/Presentation. Alternatywą jest krótki app/UI. Jest to miejsce dla wszystkich prezenterów, ich szablonów i wszelkich klas pomocniczych.

Organizujemy tę warstwę według domen. W złożonym projekcie, który łączy e-commerce, blog i API, struktura wyglądałaby następująco:

app/Presentation/
├── Shop/              ← frontend e-commerce
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← blog
│   ├── Home/
│   └── Post/
├── Admin/             ← administracja
│   ├── Dashboard/
│   └── Products/
└── Api/               ← Punkty końcowe API
	└── V1/

I odwrotnie, dla prostego bloga użylibyśmy tej struktury:

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

Foldery takie jak Home/ lub Dashboard/ zawierają prezentery i szablony. Foldery takie jak Front/, Admin/ lub Api/ nazywane są modułami. Z technicznego punktu widzenia są to zwykłe katalogi, które służą do logicznej organizacji aplikacji.

Każdy folder z prezenterem zawiera podobnie nazwany prezenter i jego szablony. Na przykład folder Dashboard/ zawiera:

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

Ta struktura katalogów jest odzwierciedlona w przestrzeniach nazw klas. Na przykład DashboardPresenter znajduje się w przestrzeni nazw App\Presentation\Admin\Dashboard (zobacz mapowanie prezentera):

namespace App\Presentation\Admin\Dashboard;

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

Odnosimy się do prezentera Dashboard wewnątrz modułu Admin w aplikacji używając notacji dwukropka jako Admin:Dashboard. Do jego akcji default następnie jako Admin:Dashboard:default. W przypadku modułów zagnieżdżonych używamy więcej dwukropków, na przykład Shop:Order:Detail:default.

Elastyczny rozwój struktury

Jedną z największych zalet tej struktury jest to, jak elegancko dostosowuje się ona do rosnących potrzeb projektu. Jako przykład weźmy część generującą kanały XML. Początkowo mamy prosty formularz:

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

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

Export/
├── Sitemap/
│   ├── SitemapPresenter.php
│   └── sitemap.latte
└── Feed/
	├── FeedPresenter.php
	├── amazon.latte         ← kanał dla Amazon
	└── ebay.latte           ← kanał dla eBay

Transformacja ta jest całkowicie bezproblemowa – wystarczy utworzyć nowe podfoldery, podzielić na nie kod i zaktualizować odnośniki (np. z Export:feed na Export:Feed:amazon). Dzięki temu możemy stopniowo rozbudowywać strukturę w miarę potrzeb, poziom zagnieżdżenia nie jest w żaden sposób ograniczony.

Przykładowo, jeśli w administracji mamy wiele prezenterów związanych z zarządzaniem zamówieniami, takich jak OrderDetail, OrderEdit, OrderDispatch itp. to dla lepszej organizacji możemy utworzyć moduł (folder) Order, który będzie zawierał (foldery dla) prezenterów Detail, Edit, Dispatch i innych.

Lokalizacja szablonu

W poprzednich przykładach widzieliśmy, że szablony znajdują się bezpośrednio w folderze z prezenterem:

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

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

Alternatywnie można umieścić szablony w podfolderze templates/. Nette obsługuje oba warianty. Można nawet umieścić szablony całkowicie poza folderem Presentation/. Wszystko na temat opcji lokalizacji szablonów można znaleźć w rozdziale Template Lookup.

Klasy pomocnicze i komponenty

Prezentery i szablony często są dostarczane z innymi plikami pomocniczymi. Umieszczamy je logicznie zgodnie z ich zakresem:

1. Bezpośrednio z prezenterem w przypadku specyficznych komponentów dla danego prezentera:

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

2. Dla modułu – zalecamy korzystanie z folderu Accessory, który jest umieszczony na początku alfabetu:

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

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

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

Można też umieścić klasy pomocnicze, takie jak LatteExtension.php lub TemplateFilters.php w folderze infrastruktury 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. Dla jego organizacji obowiązuje ta sama zasada – strukturyzujemy według domen:

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

W modelu zazwyczaj spotyka się następujące typy klas:

Facades: reprezentują główny punkt wejścia do określonej domeny w aplikacji. Działają jako orkiestrator, który koordynuje współpracę między różnymi usługami w celu wdrożenia kompletnych przypadków użycia (takich jak “utwórz zamówienie” lub “przetwarzaj płatność”). Pod warstwą orkiestracji fasada ukrywa szczegóły implementacji przed resztą aplikacji, zapewniając w ten sposób czysty interfejs do pracy z daną domeną.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// walidacja
		// tworzenie zamówień wysyłanie wiadomości
		// wysyłanie wiadomości e-mail
		// zapis do statystyk
	}
}

Usługi: koncentrują się na konkretnych operacjach biznesowych w domenie. W przeciwieństwie do fasad, które orkiestrują całe przypadki użycia, usługa implementuje określoną logikę biznesową (np. obliczenia cen lub przetwarzanie płatności). Usługi są zazwyczaj bezstanowe i mogą być używane przez fasady jako bloki konstrukcyjne dla bardziej złożonych operacji lub bezpośrednio przez inne części aplikacji do prostszych zadań.

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

Repozytoria: obsługują całą komunikację z magazynem danych, zazwyczaj bazą danych. Ich zadaniem jest ładowanie i zapisywanie encji oraz implementacja metod ich wyszukiwania. Repozytorium chroni resztę aplikacji przed szczegółami implementacji bazy danych i zapewnia obiektowy interfejs do pracy z danymi.

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

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

Entities: obiekty reprezentujące główne koncepcje biznesowe w aplikacji, które mają swoją tożsamość i zmieniają się w czasie. Zazwyczaj są to klasy mapowane do tabel bazy danych przy użyciu ORM (takich jak Nette Database Explorer lub Doctrine). Podmioty mogą zawierać reguły biznesowe dotyczące ich danych i logiki walidacji.

// Podmiot zmapowany do tabeli zamówień bazy danych
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: niezmienne obiekty reprezentujące wartości bez własnej tożsamości – na przykład kwota pieniędzy lub adres e-mail. Dwie instancje obiektu wartości z tymi samymi wartościami są uważane za identyczne.

Kod infrastruktury

Folder Core/ (lub również Infrastructure/) jest domem dla technicznych podstaw aplikacji. Kod infrastruktury zazwyczaj zawiera:

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

W przypadku mniejszych projektów płaska struktura jest naturalnie wystarczająca:

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

To jest kod, który:

  • obsługuje infrastrukturę techniczną (routing, logowanie, buforowanie)
  • Integruje usługi zewnętrzne (Sentry, Elasticsearch, Redis)
  • Zapewnia podstawowe usługi dla całej aplikacji (poczta, baza danych)
  • Jest w większości niezależny od konkretnej domeny – cache lub logger działa tak samo dla e-commerce lub bloga.

Zastanawiasz się, czy dana klasa należy do tego miejsca, czy do modelu? Kluczową różnicą jest to, że kod w Core/:

  • Nie wie nic o domenie (produkty, zamówienia, artykuły)
  • Zazwyczaj może być przeniesiony do innego projektu
  • Rozwiązuje “jak to działa” (jak wysłać pocztę), a nie “co to robi” (jaką pocztę wysłać)

Przykład dla lepszego zrozumienia:

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

Skrypty poleceń

Aplikacje często muszą wykonywać zadania poza zwykłymi żądaniami HTTP – niezależnie od tego, czy jest to przetwarzanie danych w tle, konserwacja czy zadania okresowe. Proste skrypty w katalogu bin/ są używane do wykonania, podczas gdy rzeczywista logika implementacji jest umieszczona w app/Tasks/ (lub 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/                 ← regularne zadania
	├── 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 jednej wiadomości e-mail jest częścią modelu, masowe wysyłanie tysięcy wiadomości e-mail należy do Tasks/.

Zadania są zwykle uruchamiane z wiersza poleceń lub przez cron. Mogą być również uruchamiane za pośrednictwem żądania HTTP, ale należy wziąć pod uwagę bezpieczeństwo. Prezenter, który uruchamia zadanie, musi być zabezpieczony, na przykład tylko dla zalogowanych użytkowników lub z silnym tokenem i dostępem z dozwolonych adresów IP. W przypadku długich zadań konieczne jest zwiększenie limitu czasu skryptu i użycie session_write_close(), aby uniknąć blokowania sesji.

Inne możliwe katalogi

Oprócz wspomnianych podstawowych katalogów, można dodać inne wyspecjalizowane foldery zgodnie z potrzebami projektu. Przyjrzyjmy się najpopularniejszym z nich i ich zastosowaniu:

app/
├── Api/              ← Logika API niezależna od warstwy prezentacji
├── Database/         ← skrypty migracyjne i siewniki danych testowych
├── Components/       ← współdzielone komponenty wizualne w całej aplikacji
├── Event/            ← przydatne w przypadku korzystania z architektury sterowanej zdarzeniami
├── Mail/             ← szablony wiadomości e-mail i powiązana logika
└── Utils/            ← klasy pomocnicze

W przypadku współdzielonych komponentów wizualnych używanych w prezenterach 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 dla list danych
│   └── DataGrid.php
└── Navigation/           ← elementy nawigacyjne
	├── Breadcrumbs.php
	└── Menu.php

To tutaj znajdują się komponenty z bardziej złożoną logiką. Jeśli chcesz współdzielić komponenty między wieloma projektami, dobrze jest oddzielić je w samodzielnym pakiecie kompozytora.

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

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

Mapowanie prezentera

Mapowanie definiuje zasady wyprowadzania nazw klas z nazw prezenterów. Określamy je w konfiguracji pod kluczem application › mapping.

Na tej stronie pokazaliśmy, że umieszczamy prezenterów w folderze app/Presentation (lub app/UI). Musimy poinformować Nette o tej konwencji w pliku konfiguracyjnym. Wystarczy jedna linijka:

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

Jak działa mapowanie? Aby lepiej to zrozumieć, wyobraźmy sobie najpierw aplikację bez modułów. Chcemy, aby klasy prezenterów należały do przestrzeni nazw App\Presentation, tak aby prezenter Home był mapowany na klasę App\Presentation\HomePresenter. Można to osiągnąć za pomocą tej konfiguracji:

application:
	mapping: App\Presentation\*Presenter

Mapowanie działa poprzez zastąpienie gwiazdki w masce App\Presentation\*Presenter nazwą prezentera Home, w wyniku czego otrzymujemy ostateczną nazwę klasy App\Presentation\HomePresenter. Proste!

Jednakże, jak widać w przykładach w tym i innych rozdziałach, umieszczamy klasy prezenterów w podkatalogach o tej samej nazwie, na przykład prezenter Home mapuje się na klasę App\Presentation\Home\HomePresenter. Osiągamy to poprzez podwojenie dwukropka (wymaga Nette Application 3.2):

application:
	mapping: App\Presentation\**Presenter

Teraz przejdziemy do mapowania prezenterów na moduły. Możemy zdefiniować specyficzne mapowanie dla każdego modułu:

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

Zgodnie z tą konfiguracją, prezenter Front:Home mapuje do klasy App\Presentation\Front\Home\HomePresenter, podczas gdy prezenter Api:OAuth mapuje do klasy App\Api\OAuthPresenter.

Ponieważ moduły Front i Admin mają podobną metodę mapowania i prawdopodobnie będzie więcej takich modułów, można utworzyć ogólną regułę, która je zastąpi. Nowa gwiazdka dla modułu zostanie dodana do maski klasy:

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

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

Alternatywnym zapisem jest użycie tablicy składającej się z trzech segmentów zamiast ciągu znaków. Ten zapis jest równoważny poprzedniemu:

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