Структура каталогів застосунку

Як спроектувати зрозумілу та масштабовану структуру каталогів для проектів на 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 – створює екземпляри класу для надсилання електронних листів, вирішує налаштування 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-запит, але потрібно пам'ятати про безпеку. 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]
версія: 4.0