Структура каталогів застосунку
Як спроектувати зрозумілу та масштабовану структуру каталогів для проектів на 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
. Цей
принцип допомагає орієнтуватися в коді та спрощує автозавантаження.
Однина проти множини в назвах
Зверніть увагу, що для основних каталогів застосунку ми
використовуємо однину: 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/ ← presenter'и та шаблони ├── Tasks/ ← скрипти командного рядка └── Bootstrap.php ← завантажувальний клас застосунку
Bootstrap.php
— це стартовий клас
застосунку, який ініціалізує середовище, завантажує конфігурацію та
створює DI-контейнер.
Тепер розглянемо окремі підкаталоги детальніше.
Presenter'и та шаблони
Презентаційна частина застосунку знаходиться в каталозі
app/Presentation
. Альтернативою є коротке app/UI
. Це місце для всіх
presenter'ів, їхніх шаблонів та можливих допоміжних класів.
Цей шар ми організовуємо за доменами. У складному проекті, який поєднує інтернет-магазин, блог та API, структура виглядала б так:
app/Presentation/ ├── Shop/ ← фронтенд інтернет-магазину │ ├── Product/ │ ├── Cart/ │ └── Order/ ├── Blog/ ← блог │ ├── Home/ │ └── Post/ ├── Admin/ ← адміністрація │ ├── Dashboard/ │ └── Products/ └── Api/ ← кінцеві точки API └── V1/
Навпаки, для простого блогу ми б використали такий поділ:
app/Presentation/ ├── Front/ ← фронтенд сайту │ ├── Home/ │ └── Post/ ├── Admin/ ← адміністрація │ ├── Dashboard/ │ └── Posts/ ├── Error/ └── Export/ ← RSS, sitemaps тощо.
Папки, такі як Home/
або Dashboard/
, містять presenter'и та шаблони.
Папки, такі як Front/
, Admin/
або Api/
, називаємо
модулями. Технічно це звичайні каталоги, які служать для логічного
поділу застосунку.
Кожна папка з presenter'ом містить однойменний presenter та його шаблони.
Наприклад, папка Dashboard/
містить:
Dashboard/ ├── DashboardPresenter.php ← presenter └── default.latte ← шаблон
Ця структура каталогів відображається в просторах імен класів.
Наприклад, DashboardPresenter
знаходиться в просторі імен
App\Presentation\Admin\Dashboard
(див. Мапінг presenter ів):
namespace App\Presentation\Admin\Dashboard;
class DashboardPresenter extends Nette\Application\UI\Presenter
{
// ...
}
На presenter Dashboard
всередині модуля Admin
ми посилаємося в
застосунку за допомогою двокрапкової нотації як на Admin:Dashboard
. На
його дію default
— як на Admin:Dashboard:default
. У випадку вкладених
модулів використовуємо більше двокрапок, наприклад
Shop:Order:Detail:default
.
Гнучкий розвиток структури
Однією з великих переваг цієї структури є те, як елегантно вона адаптується до зростаючих потреб проекту. Як приклад візьмемо частину, що генерує XML-фіди. На початку маємо просту форму:
Export/ ├── ExportPresenter.php ← один presenter для всіх експортів ├── sitemap.latte ← шаблон для sitemap └── feed.latte ← шаблон для RSS-фіду
З часом з'являться інші типи фідів, і нам знадобиться для них більше
логіки… Жодних проблем! Папка Export/
просто стає модулем:
Export/ ├── Sitemap/ │ ├── SitemapPresenter.php │ └── sitemap.latte └── Feed/ ├── FeedPresenter.php ├── zbozi.latte ← фід для Zboží.cz └── heureka.latte ← фід для Heureka.cz
Ця трансформація абсолютно плавна – достатньо створити нові
підпапки, розділити в них код і оновити посилання (наприклад, з
Export:feed
на Export:Feed:zbozi
). Завдяки цьому ми можемо структуру
поступово розширювати за потребою, рівень вкладеності ніяк не
обмежений.
Якщо, наприклад, в адміністрації у вас багато presenter'ів, що стосуються
управління замовленнями, таких як OrderDetail
, OrderEdit
,
OrderDispatch
тощо, ви можете для кращої організації в цьому місці
створити модуль (папку) Order
, в якому будуть (папки для) presenter'ів
Detail
, Edit
, Dispatch
та інші.
Розташування шаблонів
У попередніх прикладах ми бачили, що шаблони розташовані безпосередньо в папці з presenter'ом:
Dashboard/ ├── DashboardPresenter.php ← presenter ├── DashboardTemplate.php ← необов'язковий клас для шаблону └── default.latte ← шаблон
Це розташування на практиці виявляється найзручнішим – усі пов'язані файли у вас одразу під рукою.
Альтернативно, ви можете розмістити шаблони в підпапці templates/
.
Nette підтримує обидва варіанти. Ви навіть можете розмістити шаблони
повністю поза папкою Presentation/
. Все про можливості розташування
шаблонів ви знайдете в розділі Пошук шаблонів.
Допоміжні класи та компоненти
До presenter'ів та шаблонів часто належать й інші допоміжні файли. Розмістимо їх логічно відповідно до їхньої сфери дії:
1. Безпосередньо біля presenter'а у випадку специфічних компонентів для даного presenter'а:
Product/ ├── ProductPresenter.php ├── ProductGrid.php ← компонент для виведення продуктів └── FilterForm.php ← форма для фільтрації
2. Для модуля – рекомендуємо використовувати папку Accessory
,
яка розміщується зручно на початку алфавіту:
Front/ ├── Accessory/ │ ├── NavbarControl.php ← компоненти для фронтенду │ └── 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,
]);
}
}
Об'єкти значень: незмінні об'єкти, що представляють значення без власної ідентичності – наприклад, грошова сума або адреса електронної пошти. Два екземпляри об'єкта значення з однаковими значеннями вважаються ідентичними.
Інфраструктурний код
Папка 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
– створює екземпляри класу для надсилання електронних листів, вирішує налаштування SMTPApp\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-запит,
але потрібно пам'ятати про безпеку. Presenter, який запускає завдання,
потрібно захистити, наприклад, лише для зареєстрованих користувачів
або сильним токеном та доступом з дозволених IP-адрес. Для тривалих
завдань потрібно збільшити часовий ліміт скрипта та використовувати
session_write_close()
, щоб не блокувалася сесія.
Інші можливі каталоги
Крім згаданих базових каталогів, ви можете за потребою проекту додати інші спеціалізовані папки. Розглянемо найпоширеніші з них та їхнє використання:
app/ ├── Api/ ← логіка для API, незалежна від презентаційного шару ├── Database/ ← міграційні скрипти та сідери для тестових даних ├── Components/ ← спільні візуальні компоненти для всього застосунку ├── Event/ ← корисно, якщо використовуєте подієво-орієнтовану архітектуру ├── Mail/ ← шаблони електронних листів та пов'язана логіка └── Utils/ ← допоміжні класи
Для спільних візуальних компонентів, що використовуються в presenter'ах
по всьому застосунку, можна використовувати папку 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
Мапінг presenter'ів
Мапінг визначає правила для виведення назви класу з назви presenter'а. Ми
вказуємо їх у конфігурації під
ключем application › mapping
.
На цій сторінці ми показали, що presenter'и розміщуємо в папці
app/Presentation
(або app/UI
). Цю конвенцію ми повинні повідомити Nette
в конфігураційному файлі. Достатньо одного рядка:
application:
mapping: App\Presentation\*\**Presenter
Як працює мапінг? Для кращого розуміння спочатку уявимо застосунок
без модулів. Ми хочемо, щоб класи presenter'ів належали до простору імен
App\Presentation
, щоб presenter Home
мапувався на клас
App\Presentation\HomePresenter
. Цього досягнемо такою конфігурацією:
application:
mapping: App\Presentation\*Presenter
Мапінг працює так, що назва presenter'а Home
замінює зірочку в масці
App\Presentation\*Presenter
, чим отримуємо кінцеву назву класу
App\Presentation\HomePresenter
. Просто!
Але, як ви бачите в прикладах у цьому та інших розділах, класи presenter'ів
ми розміщуємо в однойменних підкаталогах, наприклад, presenter Home
мапується на клас App\Presentation\Home\HomePresenter
. Цього досягнемо
подвоєнням двокрапки (вимагає Nette Application 3.2):
application:
mapping: App\Presentation\**Presenter
Тепер перейдемо до мапінгу presenter'ів у модулі. Для кожного модуля ми можемо визначити специфічний мапінг:
application:
mapping:
Front: App\Presentation\Front\**Presenter
Admin: App\Presentation\Admin\**Presenter
Api: App\Api\*Presenter
Згідно з цією конфігурацією, presenter Front:Home
мапується на клас
App\Presentation\Front\Home\HomePresenter
, тоді як presenter Api:OAuth
на клас
App\Api\OAuthPresenter
.
Оскільки модулі Front
та Admin
мають схожий спосіб мапінгу,
і таких модулів, ймовірно, буде більше, можна створити загальне
правило, яке їх замінить. До маски класу так додасться нова зірочка для
модуля:
application:
mapping:
*: App\Presentation\*\**Presenter
Api: App\Api\*Presenter
Це працює і для глибше вкладених структур каталогів, як, наприклад,
presenter Admin:User:Edit
, сегмент із зірочкою повторюється для кожного
рівня, і результатом є клас App\Presentation\Admin\User\Edit\EditPresenter
.
Альтернативним записом є використання замість рядка масиву, що складається з трьох сегментів. Цей запис еквівалентний попередньому:
application:
mapping:
*: [App\Presentation, *, **Presenter]
Api: [App\Api, '', *Presenter]