Директорийна структура на приложението

Как да проектираме ясна и мащабируема директорийна структура за проекти в Nette Framework? Ще покажем доказани практики, които ще ви помогнат с организацията на кода. Ще научите:

  • как логически да разделим приложението на директории
  • как да проектираме структурата така, че добре да се мащабира с растежа на проекта
  • какви са възможните алтернативи и техните предимства или недостатъци

Важно е да се спомене, че самият Nette Framework не налага никаква конкретна структура. Той е проектиран така, че да може лесно да се адаптира към всякакви нужди и предпочитания.

Основна структура на проекта

Въпреки че Nette Framework не диктува никаква твърда директорийна структура, съществува доказано подразбиращо се подреждане под формата на Web Project:

web-project/
├── app/              ← директория с приложението
├── assets/           ← файлове SCSS, JS, изображения..., алтернативно resources/
├── bin/              ← скриптове за командния ред
├── config/           ← конфигурация
├── log/              ← логвани грешки
├── temp/             ← временни файлове, кеш
├── tests/            ← тестове
├── vendor/           ← библиотеки, инсталирани от Composer
└── www/              ← публична директория (document-root)

Тази структура можете свободно да променяте според вашите нужди – да преименувате или премествате папки. След това е достатъчно само да промените относителните пътища до директориите във файла Bootstrap.php и евентуално composer.json. Нищо повече не е необходимо, никаква сложна реконфигурация, никакви промени на константи. Nette разполага с умна автодетекция и автоматично разпознава местоположението на приложението, включително неговата URL основа.

Принципи на организация на кода

Когато за първи път разглеждате нов проект, трябва бързо да се ориентирате в него. Представете си, че разгръщате директорията app/Model/ и виждате тази структура:

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

От нея разбирате само, че проектът използва някакви сървиси, репозиторита и ентитита. За истинската цел на приложението не научавате абсолютно нищо.

Да разгледаме друг подход – организация по домейни:

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

Тук е различно – на пръв поглед е ясно, че става въпрос за електронен магазин. Самите имена на директориите разкриват какво може приложението – работи с плащания, поръчки и продукти.

Първият подход (организация по тип класове) носи на практика редица проблеми: код, който логически е свързан, е разпръснат в различни папки и трябва да прескачате между тях. Затова ще организираме по домейни.

Именни пространства

Прието е директорийната структура да съответства на именните пространства в приложението. Това означава, че физическото местоположение на файловете отговаря на техния namespace. Например клас, разположен в app/Model/Product/ProductRepository.php, трябва да има namespace App\Model\Product. Този принцип помага за ориентацията в кода и опростява autoloading-а.

Единствено срещу множествено число в имената

Забележете, че при основните директории на приложението използваме единствено число: app, config, log, temp, www. Също така и вътре в приложението: Model, Core, Presentation. Това е така, защото всяка от тях представлява една цялостна концепция.

Подобно, например app/Model/Product представлява всичко около продуктите. Няма да го наречем Products, защото не става въпрос за папка, пълна с продукти (тогава там биха били файлове nokia.php, samsung.php). Това е namespace, съдържащ класове за работа с продукти – ProductRepository.php, ProductService.php.

Папката app/Tasks е в множествено число, защото съдържа набор от самостоятелни изпълними скриптове – CleanupTask.php, ImportTask.php. Всеки от тях е самостоятелна единица.

За консистентност препоръчваме да използвате:

  • Единствено число за namespace, представляващ функционална цялост (макар и работещ с множество ентитита)
  • Множествено число за колекции от самостоятелни единици
  • В случай на несигурност или ако не искате да мислите за това, изберете единствено число

Публична директория www/

Тази директория е единствената достъпна от уеб (т.нар. document-root). Често можете да срещнете и името public/ вместо www/ – това е само въпрос на конвенция и няма влияние върху функционалността на приложението. Директорията съдържа:

  • Входна точка на приложението index.php
  • Файл .htaccess с правила за mod_rewrite (при Apache)
  • Статични файлове (CSS, JavaScript, изображения)
  • Качени файлове

За правилното осигуряване на сигурността на приложението е от съществено значение да имате правилно конфигуриран document-root.

Никога не поставяйте в тази директория папката node_modules/ – тя съдържа хиляди файлове, които могат да бъдат изпълними и не трябва да бъдат публично достъпни.

Апликационна директория app/

Това е основната директория с кода на приложението. Основна структура:

app/
├── Core/               ← инфраструктурни въпроси
├── Model/              ← бизнес логика
├── Presentation/       ← презентери и шаблони
├── Tasks/              ← командни скриптове
└── Bootstrap.php       ← зареждащ клас на приложението

Bootstrap.php е стартовият клас на приложението, който инициализира средата, зарежда конфигурацията и създава DI контейнер.

Нека сега разгледаме отделните поддиректории по-подробно.

Презентери и шаблони

Презентационната част на приложението имаме в директорията app/Presentation. Алтернатива е краткото app/UI. Това е мястото за всички презентери, техните шаблони и евентуални помощни класове.

Този слой организираме по домейни. В сложен проект, който комбинира електронен магазин, блог и API, структурата би изглеждала така:

app/Presentation/
├── Shop/              ← електронен магазин frontend
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← блог
│   ├── Home/
│   └── Post/
├── Admin/             ← администрация
│   ├── Dashboard/
│   └── Products/
└── Api/               ← API endpoints
	└── V1/

Напротив, при прост блог бихме използвали разделяне:

app/Presentation/
├── Front/             ← frontend на уебсайта
│   ├── Home/
│   └── Post/
├── Admin/             ← администрация
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, sitemaps и т.н.

Папки като Home/ или Dashboard/ съдържат презентери и шаблони. Папки като Front/, Admin/ или Api/ наричаме модули. Технически това са обикновени директории, които служат за логическо разделяне на приложението.

Всяка папка с презентер съдържа едноименен презентер и неговите шаблони. Например папка Dashboard/ съдържа:

Dashboard/
├── DashboardPresenter.php     ← презентер
└── default.latte              ← шаблон

Тази директорийна структура се отразява в именните пространства на класовете. Например DashboardPresenter се намира в именното пространство App\Presentation\Admin\Dashboard (виж Мапиране на презентери):

namespace App\Presentation\Admin\Dashboard;

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

Към презентера Dashboard вътре в модула Admin се обръщаме в приложението с помощта на нотация с двоеточие като към Admin:Dashboard. Към неговото действие default след това като към Admin:Dashboard:default. В случай на вложени модули използваме повече двоеточия, например Shop:Order:Detail:default.

Гъвкаво развитие на структурата

Едно от големите предимства на тази структура е колко елегантно се адаптира към растящите нужди на проекта. Като пример да вземем частта, генерираща XML фийдове. В началото имаме проста форма:

Export/
├── ExportPresenter.php   ← един презентер за всички експорти
├── sitemap.latte         ← шаблон за sitemap
└── feed.latte            ← шаблон за RSS feed

С времето се добавят други типове фийдове и се нуждаем от повече логика за тях… Няма проблем! Папката Export/ просто става модул:

Export/
├── Sitemap/
│   ├── SitemapPresenter.php
│   └── sitemap.latte
└── Feed/
	├── FeedPresenter.php
	├── zbozi.latte         ← фийд за Zboží.cz
	└── heureka.latte       ← фийд за Heureka.cz

Тази трансформация е напълно плавна – достатъчно е да се създадат нови подпапки, да се раздели кодът в тях и да се актуализират връзките (напр. от Export:feed на Export:Feed:zbozi). Благодарение на това можем постепенно да разширяваме структурата според нуждите, нивото на влагане не е никак ограничено.

Ако например в администрацията имате много презентери, свързани с управлението на поръчки, като OrderDetail, OrderEdit, OrderDispatch и т.н., можете за по-добра организираност на това място да създадете модул (папка) Order, в който ще бъдат (папки за) презентерите Detail, Edit, Dispatch и други.

Местоположение на шаблоните

В предишните примери видяхме, че шаблоните са разположени директно в папката с презентера:

Dashboard/
├── DashboardPresenter.php     ← презентер
├── DashboardTemplate.php      ← незадължителен клас за шаблона
└── default.latte              ← шаблон

Това местоположение на практика се оказва най-удобно – всички свързани файлове са ви веднага под ръка.

Алтернативно можете да поставите шаблоните в подпапка templates/. Nette поддържа и двата варианта. Дори можете да поставите шаблоните изцяло извън папката Presentation/. Всичко за възможностите за разполагане на шаблони ще намерите в главата Търсене на шаблони.

Помощни класове и компоненти

Към презентерите и шаблоните често принадлежат и други помощни файлове. Разполагаме ги логично според тяхната област на действие:

1. Директно при презентера в случай на специфични компоненти за дадения презентер:

Product/
├── ProductPresenter.php
├── ProductGrid.php        ← компонент за извеждане на продукти
└── FilterForm.php         ← формуляр за филтриране

2. За модула – препоръчваме да използвате папка Accessory, която се поставя прегледно веднага в началото на азбуката:

Front/
├── Accessory/
│   ├── NavbarControl.php    ← компоненти за frontend
│   └── TemplateFilters.php
├── Product/
└── Cart/

3. За цялото приложение – в Presentation/Accessory/:

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

Или можете да поставите помощни класове като LatteExtension.php или TemplateFilters.php в инфраструктурната папка app/Core/Latte/. А компонентите в app/Components. Изборът зависи от навиците на екипа.

Модел – сърцето на приложението

Моделът съдържа цялата бизнес логика на приложението. За неговата организация важи отново правилото – структурираме по домейни:

app/Model/
├── Payment/                   ← всичко около плащанията
│   ├── PaymentFacade.php      ← основна входна точка
│   ├── PaymentRepository.php
│   ├── Payment.php            ← ентитит
├── Order/                     ← всичко около поръчките
│   ├── OrderFacade.php
│   ├── OrderRepository.php
│   ├── Order.php
└── Shipping/                  ← всичко около доставката

В модела типично ще срещнете тези типове класове:

Фасади: представляват основната входна точка към конкретен домейн в приложението. Действат като оркестратор, който координира сътрудничеството между различни сървиси с цел имплементиране на пълни use-cases (като “създай поръчка” или “обработи плащане”). Под своя оркестрационен слой фасадата скрива имплементационните детайли от останалата част на приложението, като по този начин предоставя чист интерфейс за работа с дадения домейн.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// валидация
		// създаване на поръчка
		// изпращане на имейл
		// записване в статистики
	}
}

Сървиси: фокусират се върху специфична бизнес операция в рамките на домейна. За разлика от фасадата, която оркестрира цели use-cases, сървисът имплементира конкретна бизнес логика (като изчисления на цени или обработка на плащания). Сървисите са типично безсъстоянийни и могат да бъдат използвани или от фасади като строителни блокове за по-сложни операции, или директно от други части на приложението за по-прости задачи.

class PricingService
{
	public function calculateTotal(Order $order): Money
	{
		// изчисление на цена
	}
}

Репозиторита: осигуряват цялата комуникация с хранилището на данни, типично база данни. Неговата задача е зареждане и съхраняване на ентитита и имплементиране на методи за тяхното търсене. Репозиторият изолира останалата част от приложението от имплементационните детайли на базата данни и предоставя обектно-ориентиран интерфейс за работа с данни.

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

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

Ентитита: обекти, представляващи основните бизнес концепции в приложението, които имат своя идентичност и се променят във времето. Типично става въпрос за класове, мапнати към таблици в базата данни с помощта на ORM (като Nette Database Explorer или Doctrine). Ентититата могат да съдържат бизнес правила, свързани с техните данни и валидационна логика.

// Ентитит, мапнат към таблицата 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 обекти: неизменни обекти, представляващи стойности без собствена идентичност – например парична сума или имейл адрес. Две инстанции на value обект със същите стойности се считат за идентични.

Инфраструктурен код

Папката Core/ (или също Infrastructure/) е домът на техническата основа на приложението. Инфраструктурният код типично включва:

app/Core/
├── Router/               ← маршрутизация и управление на URL
│   └── RouterFactory.php
├── Security/             ← автентикация и авторизация
│   ├── Authenticator.php
│   └── Authorizator.php
├── Logging/              ← логване и мониторинг
│   ├── SentryLogger.php
│   └── FileLogger.php
├── Cache/                ← кеширащ слой
│   └── FullPageCache.php
└── Integration/          ← интеграция с външни сървиси
	├── Slack/
	└── Stripe/

При по-малки проекти, разбира се, е достатъчно плоско разделяне:

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

Става въпрос за код, който:

  • Решава техническата инфраструктура (маршрутизация, логване, кеширане)
  • Интегрира външни сървиси (Sentry, Elasticsearch, Redis)
  • Предоставя основни сървиси за цялото приложение (поща, база данни)
  • Е предимно независим от конкретния домейн – кешът или логерът работи еднакво за електронен магазин или блог.

Чудите се дали определен клас принадлежи тук, или към модела? Ключовата разлика е в това, че кодът в Core/:

  • Не знае нищо за домейна (продукти, поръчки, статии)
  • Е предимно възможно да се пренесе в друг проект
  • Решава “как работи” (как да се изпрати имейл), а не “какво прави” (какъв имейл да се изпрати)

Пример за по-добро разбиране:

  • App\Core\MailerFactory – създава инстанции на клас за изпращане на имейли, решава SMTP настройките
  • App\Model\OrderMailer – използва MailerFactory за изпращане на имейли за поръчки, знае техните шаблони и кога трябва да се изпратят

Командни скриптове

Приложенията често трябва да извършват дейности извън обичайните HTTP заявки – било то обработка на данни във фонов режим, поддръжка или периодични задачи. За стартиране служат прости скриптове в директорията bin/, самата имплементационна логика след това поставяме в app/Tasks/ (евентуално app/Commands/).

Пример:

app/Tasks/
├── Maintenance/               ← скриптове за поддръжка
│   ├── CleanupCommand.php     ← изтриване на стари данни
│   └── DbOptimizeCommand.php  ← оптимизация на базата данни
├── Integration/               ← интеграция с външни системи
│   ├── ImportProducts.php     ← импорт от доставчикова система
│   └── SyncOrders.php         ← синхронизация на поръчки
└── Scheduled/                 ← редовни задачи
	├── NewsletterCommand.php  ← разпращане на бюлетини
	└── ReminderCommand.php    ← нотификации към клиенти

Какво принадлежи към модела и какво към командните скриптове? Например логиката за изпращане на един имейл е част от модела, масовото разпращане на хиляди имейли вече принадлежи към Tasks/.

Задачите обикновено стартираме от командния ред или чрез cron. Могат да се стартират и чрез HTTP заявка, но е необходимо да се мисли за сигурността. Презентерът, който стартира задачата, трябва да бъде защитен, например само за влезли потребители или със силен токен и достъп от разрешени IP адреси. При дълги задачи е необходимо да се увеличи времевият лимит на скрипта и да се използва session_write_close(), за да не се заключва сесията.

Други възможни директории

Освен споменатите основни директории, можете според нуждите на проекта да добавите други специализирани папки. Да разгледаме най-често срещаните от тях и тяхното използване:

app/
├── Api/              ← логика за API, независима от презентационния слой
├── Database/         ← миграционни скриптове и seeders за тестови данни
├── Components/       ← споделени визуални компоненти в цялото приложение
├── Event/            ← полезно, ако използвате event-driven архитектура
├── Mail/             ← имейл шаблони и свързана логика
└── Utils/            ← помощни класове

За споделени визуални компоненти, използвани в презентерите в цялото приложение, може да се използва папка app/Components или app/Controls:

app/Components/
├── Form/                 ← споделени формулярни компоненти
│   ├── SignInForm.php
│   └── UserForm.php
├── Grid/                 ← компоненти за извеждане на данни
│   └── DataGrid.php
└── Navigation/           ← навигационни елементи
	├── Breadcrumbs.php
	└── Menu.php

Тук принадлежат компоненти, които имат по-сложна логика. Ако искате да споделяте компоненти между няколко проекта, е препоръчително да ги изнесете в отделен composer пакет.

В директорията app/Mail можете да поставите управлението на имейл комуникацията:

app/Mail/
├── templates/            ← имейл шаблони
│   ├── order-confirmation.latte
│   └── welcome.latte
└── OrderMailer.php

Мапиране на презентери

Мапирането дефинира правила за извеждане на името на класа от името на презентера. Специфицираме ги в конфигурацията под ключа application › mapping.

На тази страница показахме, че поставяме презентерите в папка app/Presentation (евентуално app/UI). Тази конвенция трябва да съобщим на Nette в конфигурационния файл. Достатъчен е един ред:

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

Как работи мапирането? За по-добро разбиране първо си представете приложение без модули. Искаме класовете на презентерите да попадат в именното пространство App\Presentation, така че презентерът Home да се мапира към класа App\Presentation\HomePresenter. Което постигаме с тази конфигурация:

application:
	mapping: App\Presentation\*Presenter

Мапирането работи така, че името на презентера Home замества звездичката в маската App\Presentation\*Presenter, с което получаваме крайния име на класа App\Presentation\HomePresenter. Просто!

Както обаче виждате в примерите в тази и други глави, класовете на презентерите поставяме в едноименни поддиректории, например презентерът Home се мапира към класа App\Presentation\Home\HomePresenter. Това постигаме с удвояване на двоеточието (изисква Nette Application 3.2):

application:
	mapping: App\Presentation\**Presenter

Сега ще пристъпим към мапиране на презентери в модули. За всеки модул можем да дефинираме специфично мапиране:

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

Според тази конфигурация презентерът Front:Home се мапира към класа App\Presentation\Front\Home\HomePresenter, докато презентерът Api:OAuth към класа App\Api\OAuthPresenter.

Тъй като модулите Front и Admin имат подобен начин на мапиране и такива модули най-вероятно ще бъдат повече, е възможно да се създаде общо правило, което да ги замени. В маската на класа така ще се добави нова звездичка за модула:

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

Това работи и за по-дълбоко вложени директорийни структури, като например презентер Admin:User:Edit, сегментът със звездичка се повтаря за всяко ниво и резултатът е клас App\Presentation\Admin\User\Edit\EditPresenter.

Алтернативен запис е вместо низ да се използва масив, състоящ се от три сегмента. Този запис е еквивалентен на предходния:

application:
	mapping:
		*: [App\Presentation, *, **Presenter]
		Api: [App\Api, '', *Presenter]
версия: 4.0