Структура каталогов приложения

Как спроектировать наглядную и масштабируемую структуру каталогов для проектов на 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/

Здесь всё иначе: с первого взгляда ясно, что это интернет-магазин. Сами имена каталогов раскрывают, что умеет приложение: оно работает с платежами, заказами и товарами.

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

Пространства имён

Принято, чтобы структура каталогов соответствовала пространствам имён приложения. Это значит, что физическое расположение файлов совпадает с их пространством имён. Например, у класса, лежащего в app/Model/Product/ProductRepository.php, должно быть пространство имён App\Model\Product. Этот принцип помогает ориентироваться в коде и упрощает автозагрузку.

Единственное и множественное число в именах

Обратите внимание, что для главных каталогов приложения мы используем единственное число: app, config, log, temp, www. То же и внутри приложения: Model, Core, Presentation. Это потому, что каждый из них представляет одно цельное понятие.

Точно так же app/Model/Product представляет всё, что связано с товарами. Мы не называем его Products, потому что это не папка, полная товаров (в ней лежали бы файлы вроде nokia.php, samsung.php). Это пространство имён с классами для работы с товарами – ProductRepository.php, ProductService.php.

Папка app/Tasks во множественном числе, потому что содержит набор отдельных исполняемых скриптов – CleanupTask.php, ImportTask.php. Каждый из них – самостоятельная единица.

Ради единообразия мы рекомендуем использовать:

  • единственное число для пространств имён, представляющих функциональную единицу (даже если она работает с несколькими сущностями)
  • множественное число для наборов самостоятельных единиц
  • в случае сомнений или если не хочется над этим думать, выбирайте единственное число

Публичный каталог www/

Этот каталог – единственный, доступный из веба (document-root). Часто вместо www/ вам может встретиться имя public/ – это лишь вопрос соглашения, на работу приложения оно не влияет. Каталог содержит:

  • точку входа приложения 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/              ← витрина интернет-магазина
│   ├── Product/
│   ├── Cart/
│   └── Order/
├── Blog/              ← блог
│   ├── Home/
│   └── Post/
├── Admin/             ← администрирование
│   ├── Dashboard/
│   └── Products/
└── Api/               ← точки входа API
	└── V1/

И наоборот, для простого блога мы использовали бы такую структуру:

app/Presentation/
├── Front/             ← публичная часть сайта
│   ├── Home/
│   └── Post/
├── Admin/             ← администрирование
│   ├── Dashboard/
│   └── Posts/
├── Error/
└── Export/            ← RSS, карты сайта и прочее

Папки вроде 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         ← шаблон карты сайта
└── feed.latte            ← шаблон RSS-ленты

Со временем добавляются новые виды лент, и логики для них нужно больше… Не беда! Папка Export/ просто становится модулем:

Export/
├── Sitemap/
│   ├── SitemapPresenter.php
│   └── sitemap.latte
└── Feed/
	├── FeedPresenter.php
	├── amazon.latte         ← лента для Amazon
	└── ebay.latte           ← лента для eBay

Это преобразование проходит совершенно гладко: достаточно создать новые подпапки, разложить по ним код и обновить ссылки (например, с Export:feed на Export:Feed:amazon). Благодаря этому мы можем постепенно расширять структуру по мере надобности, уровень вложенности ничем не ограничен.

Например, если в администрировании у вас много презентеров, связанных с управлением заказами, таких как 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    ← компоненты публичной части
│   └── 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/                  ← всё о доставке

В модели вам обычно встречаются такие типы классов:

Фасады: представляют главную точку входа в определённый домен приложения. Они выступают дирижёрами, согласующими взаимодействие разных сервисов ради выполнения полных сценариев (вроде “создать заказ” или “обработать платёж”). Под своим дирижёрским слоем фасад скрывает от остального приложения подробности реализации и тем самым даёт чистый интерфейс для работы с этим доменом.

class OrderFacade
{
	public function createOrder(Cart $cart): Order
	{
		// проверка
		// создание заказа
		// отправка письма
		// запись в статистику
	}
}

Сервисы: сосредоточены на конкретных бизнес-операциях внутри домена. В отличие от фасадов, дирижирующих целыми сценариями, сервис реализует конкретную бизнес-логику (вроде расчёта цен или обработки платежей). Сервисы обычно не хранят состояния и могут использоваться либо фасадами как строительные блоки более сложных операций, либо напрямую другими частями приложения для более простых задач.

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: скрипт в bin/ создаёт DI-контейнер методом bootConsoleApplication() и достаёт из него нужный сервис. Их можно запускать и HTTP-запросом, но при этом нужно подумать о безопасности. Презентер, запускающий задачу, нужно защитить, например только для вошедших пользователей или сильным токеном и доступом с разрешённых IP-адресов. Для долгих задач нужно увеличить ограничение времени работы скрипта и использовать session_write_close(), чтобы не блокировать сессию.

Другие возможные каталоги

Помимо упомянутых базовых каталогов вы можете добавить и другие специализированные папки под нужды проекта. Посмотрим на самые частые и на их применение:

app/
├── Api/              ← логика API, независимая от презентационного слоя
├── Database/         ← миграционные скрипты и сидеры тестовых данных
├── Components/       ← общие визуальные компоненты для всего приложения
├── Event/            ← полезно при событийно-ориентированной архитектуре
├── 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 3.3 это соглашение по умолчанию, которое настраивать не нужно. Если вы используете другую структуру или хотите указать отображение явно, настройке по умолчанию соответствует такая строка:

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

Как работает отображение? Для лучшего понимания сначала представим приложение без модулей. Мы хотим, чтобы классы презентеров попадали в пространство имён App\Presentation, так чтобы презентер Home отображался в класс App\Presentation\HomePresenter. Этого мы добиваемся такой конфигурацией:

application:
	mapping: App\Presentation\*Presenter

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

Однако, как вы видите в примерах в этой и других главах, мы размещаем классы презентеров в одноимённых подкаталогах, например презентер Home отображается в класс App\Presentation\Home\HomePresenter. Этого мы добиваемся двойной звёздочкой ** (требуется Nette Application 3.2.3):

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.x