Контролери: основи маршрутизації
Контролери: основи маршрутизації
🎯 Мета лекції
- Зрозуміти роль контролерів як шару обробки HTTP-запитів у NestJS
- Опанувати використання декоратора @Controller для визначення базових маршрутів
- Навчитися створювати методи контролера для обробки різних запитів
- Засвоїти принципи розділення відповідальностей між контролерами та сервісами
- Вивчити автоматичну серіалізацію відповідей та роботу з асинхронним кодом
- Практикувати генерацію контролерів через NestJS CLI
🔑 Ключові терміни
- Controller (контролер): клас, відповідальний за обробку вхідних HTTP-запитів та формування відповідей
- Route Handler (обробник маршруту): метод контролера, що обробляє конкретний HTTP-запит
- Request-Response Cycle (цикл запит-відповідь): послідовність обробки від отримання запиту до відправлення відповіді
- Serialization (серіалізація): процес перетворення об'єктів JavaScript у JSON для передачі по мережі
- Thin Controller Pattern (патерн тонкого контролера): принцип, за яким контролер містить мінімум логіки
Що таке контролер: перший контактний шар застосунку
Контролери (controllers) є фундаментальним компонентом архітектури NestJS, що відповідає за обробку вхідних HTTP-запитів від клієнтів та формування відповідних HTTP-відповідей. Контролер можна розглядати як «вхідні двері» застосунку — саме через нього зовнішній світ взаємодіє з бізнес-логікою системи.
У класичній MVC-архітектурі (Model-View-Controller) контролер займає проміжну позицію між представленням (view, у випадку API — це клієнтський застосунок) та моделлю (model, бізнес-логіка та дані). Проте в контексті RESTful API, який будується на NestJS, роль контролера дещо специфічніша — він не генерує HTML-шаблони, а повертає структуровані дані в форматі JSON або XML.
Ключова філософія контролерів у NestJS полягає в принципі єдиної відповідальності (Single Responsibility Principle): контролер має відповідати виключно за координацію HTTP-комунікації. Він не містить бізнес-логіки, не виконує складних обчислень, не взаємодіє безпосередньо з базою даних. Замість цього контролер делегує всі змістовні операції сервісам (services), залишаючи собі лише технічні аспекти HTTP-протоколу.
Контролер виконує кілька чітко визначених функцій у циклі обробки запиту:
- Маршрутизація: Визначення, який саме метод має обробити конкретний запит на основі URL та HTTP-методу
- Екстракція даних: Витягування необхідної інформації з різних частин HTTP-запиту (параметри URL, query string, тіло запиту, заголовки)
- Валідація вхідних даних: Координація процесу перевірки коректності отриманих даних (делегується pipes)
- Делегування бізнес-логіки: Виклик відповідних методів сервісів для виконання змістовних операцій
- Формування відповіді: Серіалізація результатів у JSON та встановлення відповідного HTTP-статусу
Декоратор @Controller: визначення базового маршруту
Щоб позначити клас як контролер у NestJS, використовується декоратор @Controller(). Цей декоратор виконує дві критичні функції: по-перше, він повідомляє фреймворку, що клас є контролером і має бути зареєстрований у системі маршрутизації; по-друге, він дозволяє визначити базовий префікс шляху (path prefix) для всіх маршрутів цього контролера.
Базовий синтаксис декоратора
Декоратор @Controller() може використовуватися як з аргументом (префіксом шляху), так і без нього:
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {
// Маршрути цього контролера будуть доступні від кореня: /
}
import { Controller } from '@nestjs/common';
@Controller('users')
export class UsersController {
// Маршрути цього контролера будуть мати префікс: /users
}
Коли декоратор викликається з аргументом-рядком (наприклад, @Controller('users')), цей рядок стає базовим шляхом для всіх маршрутів, визначених у методах контролера. Якщо метод контролера визначає власний шлях, він буде додано до базового префікса, формуючи повний URL маршруту.
Формування повних шляхів маршрутів
Повний шлях до ендпоінту формується з кількох компонентів:
Повний URL = [Глобальний префікс] + [Префікс контролера] + [Шлях методу]
Наприклад:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // Глобальний префікс
await app.listen(3000);
}
bootstrap();
import { Controller, Get } from '@nestjs/common';
@Controller('users') // Префікс контролера
export class UsersController {
@Get('profile') // Шлях методу
getProfile() {
return { message: 'User profile' };
}
}
// Повний URL: http://localhost:3000/api/users/profile
У цьому прикладі:
- Глобальний префікс:
api(налаштовано вmain.ts) - Префікс контролера:
users(визначено в@Controller('users')) - Шлях методу:
profile(визначено в@Get('profile')) - Результуючий URL:
/api/users/profile
/users, всі операції з продуктами — під /products, що створює інтуїтивну та передбачувану структуру API.Версіювання через префікси
Префікси контролера часто використовуються для версіювання API:
@Controller('v1/users')
export class UsersV1Controller {
// Ендпоінти версії 1: /v1/users/*
}
@Controller('v2/users')
export class UsersV2Controller {
// Ендпоінти версії 2: /v2/users/*
}
Це дозволяє підтримувати кілька версій API одночасно, забезпечуючи зворотну сумісність для існуючих клієнтів під час впровадження нових функцій.
Структура класу контролера: анатомія компонента
Контролер у NestJS — це TypeScript-клас, позначений декоратором @Controller(). Структура типового контролера включає кілька стандартних елементів, кожен з яких має своє призначення.
Мінімальний контролер
Найпростіший функціональний контролер складається з класу з декоратором та хоча б одного методу-обробника:
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
Цей контролер обробляє GET-запити до /health та повертає статус здоров'я системи. Незважаючи на свою простоту, він демонструє всі базові концепції: декоратор класу, декоратор методу та повернення даних.
Контролер з впровадженням залежностей
У реальних застосунках контролери рідко працюють ізольовано — вони потребують сервісів для виконання бізнес-логіки. Залежності впроваджуються через конструктор класу:
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
// Впровадження залежностей через конструктор
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}
Модифікатори private readonly у параметрі конструктора виконують подвійну функцію в TypeScript:
private: Автоматично створює приватну властивість класу з такою самою назвоюreadonly: Забороняє зміну значення властивості після ініціалізації
Це скорочений синтаксис, еквівалентний наступному розгорнутому коду:
export class UsersController {
private readonly usersService: UsersService;
constructor(usersService: UsersService) {
this.usersService = usersService;
}
}
Множинні залежності
Контролер може впроваджувати стільки залежностей, скільки потрібно для його роботи:
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
import { LoggerService } from '../common/logger.service';
import { ConfigService } from '@nestjs/config';
@Controller('users')
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly logger: LoggerService,
private readonly config: ConfigService,
) {}
@Get()
findAll() {
this.logger.log('Fetching all users');
const pageSize = this.config.get<number>('PAGE_SIZE');
return this.usersService.findAll(pageSize);
}
}
Важливо зберігати кількість залежностей контролера помірною. Якщо контролер потребує п'яти або більше залежностей, це може сигналізувати про порушення принципу єдиної відповідальності — можливо, контролер виконує занадто багато функцій і його варто розділити.
Методи контролера: обробники маршрутів
Методи класу контролера, позначені декораторами маршрутів (такими як @Get(), @Post() тощо), називаються обробниками маршрутів (route handlers). Кожен обробник відповідає за обробку конкретного типу HTTP-запиту до певного URL.
Базова структура обробника
Обробник маршруту — це звичайний метод класу, який:
- Позначений декоратором HTTP-методу (
@Get(),@Post(),@Put(),@Delete()тощо) - Може приймати параметри (дані з запиту)
- Повертає дані, які автоматично серіалізуються у HTTP-відповідь
import { Controller, Get } from '@nestjs/common';
@Controller('products')
export class ProductsController {
@Get()
getAllProducts() {
// Логіка отримання всіх продуктів
return [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
];
}
}
У цьому прикладі метод getAllProducts() обробляє GET-запити до /products. Повернутий масив об'єктів автоматично серіалізується у JSON та відправляється клієнту з HTTP-статусом 200 (OK).
Іменування методів контролера
NestJS не нав'язує жодних правил іменування методів контролера — можна називати їх як завгодно. Проте спільнота виробила певні конвенції, які підвищують читабельність коду:
RESTful конвенції (найпоширеніші):
findAll()абоgetAll(): отримання колекції ресурсівfindOne()абоgetOne(): отримання одного ресурсу за ідентифікаторомcreate(): створення нового ресурсуupdate(): оновлення існуючого ресурсуremove()абоdelete(): видалення ресурсу
CRUD-операції (явне іменування):
createUser(),getUser(),updateUser(),deleteUser()
Бізнес-орієнтовані назви (для складніших операцій):
activateAccount(),sendPasswordReset(),processPayment()
Повернення значень: автоматична серіалізація
Однією з найзручніших можливостей NestJS є автоматична серіалізація значень, що повертаються з обробників маршрутів. Розробнику не потрібно вручну викликати JSON.stringify() або налаштовувати заголовки Content-Type — фреймворк обробляє це автоматично.
Типи повертаємих значень
NestJS підтримує кілька типів значень, що повертаються:
Примітивні типи (string, number, boolean):
@Get('version')
getVersion(): string {
return '1.0.0'; // Повертає рядок як plain text
}
@Get('count')
getCount(): number {
return 42; // Повертає число у тілі відповіді
}
Об'єкти (найпоширеніший випадок):
@Get('user')
getUser(): object {
return {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
}
// HTTP Response: {"id":1,"name":"John Doe","email":"john@example.com"}
// Content-Type: application/json
Масиви об'єктів:
@Get('users')
getUsers(): object[] {
return [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
}
// HTTP Response: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
Екземпляри класів:
class User {
constructor(
public id: number,
public name: string,
public email: string,
) {}
}
@Get('user')
getUser(): User {
return new User(1, 'John Doe', 'john@example.com');
}
// Серіалізується так само як об'єкт
Undefined та null:
@Get('nothing')
getNothing(): undefined {
return undefined; // HTTP Response: 200 OK з порожнім тілом
}
undefined з обробника створює відповідь 200 OK з порожнім тілом, що може бути некоректним для деяких випадків. Якщо ресурс не знайдено, краще викинути виняток NotFoundException замість повернення undefined.HTTP-статуси за замовчуванням
NestJS автоматично встановлює HTTP-статус залежно від типу запиту:
- GET, PUT, PATCH, DELETE: статус 200 (OK)
- POST: статус 201 (Created)
Це поведінка за замовчуванням, яку можна перевизначити через декоратор @HttpCode() (детальніше у наступних лекціях).
@Get('products')
getProducts() {
return [{ id: 1, name: 'Product' }];
}
// HTTP/1.1 200 OK
@Post('products')
createProduct() {
return { id: 1, name: 'New Product' };
}
// HTTP/1.1 201 Created
Асинхронні обробники: Promise та async/await
Більшість реальних операцій у веб-застосунках є асинхронними: запити до бази даних, виклики зовнішніх API, читання файлів тощо. NestJS має вбудовану підтримку асинхронних обробників через механізм Promise та синтаксис async/await.
Повернення Promise
Обробник може повертати Promise, який розв'язується у значення. NestJS автоматично очікує розв'язання Promise перед відправленням відповіді:
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
findAll(): Promise<object[]> {
// Імітація асинхронного запиту до бази даних
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}, 100);
});
}
}
NestJS очікує, поки Promise розв'яжеться, і відправляє результат клієнту. Якщо Promise відхиляється (reject), фреймворк автоматично перетворює помилку на відповідну HTTP-відповідь з помилкою.
Синтаксис async/await
Більш елегантним та читабельним підходом є використання async/await:
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll() {
// await автоматично очікує розв'язання Promise
const users = await this.usersService.findAll();
return users;
}
}
Ключове слово async перед методом перетворює його на асинхронну функцію, що завжди повертає Promise. Ключове слово await призупиняє виконання до розв'язання Promise, але не блокує інші запити — Node.js продовжує обробляти інші операції в цей час.
Обробка помилок в асинхронному коді
При роботі з асинхронними операціями важливо правильно обробляти помилки. NestJS автоматично перехоплює необроблені помилки та перетворює їх на HTTP-відповіді:
@Get(':id')
async findOne(id: string) {
const user = await this.usersService.findById(id);
if (!user) {
// Викидання винятку автоматично перетворюється на HTTP 404
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async/await замість .then()/.catch() у контролерах. Це робить код лінійним та читабельним, а помилки автоматично спливають догори та обробляються фреймворком.Паралельне виконання асинхронних операцій
Якщо потрібно виконати кілька незалежних асинхронних операцій, використовуйте Promise.all() для паралельного виконання:
@Get('dashboard')
async getDashboard() {
// Паралельне виконання трьох незалежних запитів
const [users, products, orders] = await Promise.all([
this.usersService.count(),
this.productsService.count(),
this.ordersService.count(),
]);
return {
totalUsers: users,
totalProducts: products,
totalOrders: orders,
};
}
Це значно швидше, ніж послідовне виконання трьох await, оскільки запити виконуються одночасно.
Реєстрація контролера у модулі
Створення класу контролера — це лише половина справи. Щоб NestJS «знав» про існування контролера та зареєстрував його маршрути, контролер має бути оголошений у відповідному модулі через масив controllers у декораторі @Module().
Базова реєстрація
// users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController], // Реєстрація контролера
providers: [UsersService], // Реєстрація сервісу
})
export class UsersModule {}
Коли модуль імпортується в кореневий AppModule, NestJS автоматично:
- Створює екземпляр контролера
- Розв'язує його залежності (впроваджує сервіси)
- Сканує декоратори маршрутів та реєструє їх у системі маршрутизації
- Готує контролер до обробки запитів
Множинні контролери в модулі
Один модуль може містити кілька контролерів, якщо вони логічно пов'язані:
@Module({
controllers: [
UsersController,
UserProfileController,
UserSettingsController,
],
providers: [UsersService, ProfileService, SettingsService],
})
export class UsersModule {}
Проте якщо модуль стає занадто великим (більше 3-4 контролерів), це може бути сигналом, що його варто розділити на кілька менших модулів за принципом єдиної відповідальності.
Якщо контролер створено, але не додано до масиву controllers жодного модуля, NestJS просто не знатиме про його існування. Маршрути, визначені в контролері, не будуть зареєстровані, і запити до цих URL повертатимуть 404 (Not Found).
При цьому жодної помилки на етапі компіляції чи запуску не виникне — контролер просто залишиться «мертвим кодом». Це підкреслює важливість явної реєстрації компонентів у модулях.
Технічно контролер можна зареєструвати в кількох модулях одночасно, але це антипатерн, який може призвести до дублювання маршрутів та непередбачуваної поведінки. Кожен контролер має належати рівно одному модулю.
Якщо потрібно використовувати функціональність контролера в різних частинах застосунку, правильніше винести спільну логіку в сервіс, який може бути експортований модулем та імпортований іншими модулями.
Генерація контролера через NestJS CLI
NestJS CLI надає зручну команду для автоматичної генерації контролера з правильною структурою файлів та базовим кодом. Це значно прискорює розробку та забезпечує дотримання конвенцій фреймворку.
Базова команда генерації
nest generate controller <name>
# або скорочено:
nest g controller <name>
Наприклад, для створення контролера управління задачами:
CLI автоматично:
- Створює директорію
tasks/(якщо вона не існує) - Генерує файл контролера
tasks.controller.tsз базовою структурою - Генерує файл тесту
tasks.controller.spec.ts - Оновлює найближчий модуль, додаючи контролер до масиву
controllers
Згенерований код виглядає наступним чином:
// tasks.controller.ts
import { Controller } from '@nestjs/common';
@Controller('tasks')
export class TasksController {}
CLI вже встановив префікс маршруту (tasks) на основі назви контролера. Тепер розробник може додавати методи-обробники для конкретних операцій.
Опції генерації контролера
CLI надає кілька корисних опцій для налаштування процесу генерації:
--no-spec — пропускає створення тестового файлу:
nest g controller tasks --no-spec
Це корисно, якщо команда використовує іншу стратегію тестування або тести будуть додані пізніше.
--flat — створює файл у поточній директорії замість створення нової піддиректорії:
nest g controller tasks --flat
# Створює src/tasks.controller.ts замість src/tasks/tasks.controller.ts
--dry-run або -d — виконує команду в режимі симуляції без фактичного створення файлів:
nest g controller tasks --dry-run
# Показує, які файли будуть створені, але не створює їх
Це дозволяє перевірити результат перед фактичною генерацією.
Вказування модуля — можна явно вказати, до якого модуля має належати контролер:
nest g controller tasks --module=app
# або для вкладеного модуля:
nest g controller admin/users --module=admin
AppModule.Тонкий контролер: принцип мінімальної логіки
Один з найважливіших принципів проєктування контролерів у NestJS — це патерн тонкого контролера (Thin Controller Pattern). Контролер має бути максимально простим, виконуючи лише координаційні функції та делегуючи всю змістовну роботу сервісам.
Антипатерн: товстий контролер
Розглянемо приклад поганого проєктування, де контролер містить бізнес-логіку:
// ❌ Погана практика: бізнес-логіка в контролері
@Controller('orders')
export class OrdersController {
constructor(
private readonly database: DatabaseService,
private readonly emailService: EmailService,
) {}
@Post()
async createOrder(orderData: any) {
// Валідація даних в контролері
if (!orderData.items || orderData.items.length === 0) {
throw new BadRequestException('Order must contain items');
}
// Обчислення в контролері
const total = orderData.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
// Бізнес-правила в контролері
const discount = total > 100 ? total * 0.1 : 0;
const finalTotal = total - discount;
// Прямий доступ до бази даних з контролера
const order = await this.database.query(
'INSERT INTO orders (total, discount) VALUES (?, ?)',
[finalTotal, discount],
);
// Відправлення email з контролера
await this.emailService.send({
to: orderData.customerEmail,
subject: 'Order confirmation',
body: `Your order #${order.id} has been placed`,
});
return order;
}
}
Проблеми цього підходу:
- Неможливість повторного використання: Логіку створення замовлення неможливо викликати з інших контекстів (консольні команди, обробники черг)
- Складність тестування: Тестування контролера вимагає моків для бази даних та email-сервісу
- Порушення єдиної відповідальності: Контролер «знає» про структуру бази даних, бізнес-правила та зовнішні сервіси
- Низька підтримуваність: Зміна бізнес-правил вимагає модифікації контролера
Правильний підхід: тонкий контролер
Той самий функціонал, реалізований правильно:
// ✅ Добра практика: тонкий контролер
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
async createOrder(createOrderDto: CreateOrderDto) {
// Контролер лише координує виклик сервісу
return this.ordersService.create(createOrderDto);
}
}
// orders.service.ts - вся логіка в сервісі
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly emailService: EmailService,
) {}
async create(createOrderDto: CreateOrderDto): Promise<Order> {
// Валідація бізнес-правил
this.validateOrderItems(createOrderDto.items);
// Обчислення
const total = this.calculateTotal(createOrderDto.items);
const discount = this.calculateDiscount(total);
// Збереження через репозиторій
const order = await this.ordersRepository.create({
...createOrderDto,
total: total - discount,
discount,
});
// Відправлення повідомлення
await this.emailService.sendOrderConfirmation(order);
return order;
}
private validateOrderItems(items: OrderItem[]): void {
if (!items || items.length === 0) {
throw new BadRequestException('Order must contain items');
}
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
private calculateDiscount(total: number): number {
return total > 100 ? total * 0.1 : 0;
}
}
Переваги тонкого контролера:
- Простота: Контролер зрозумілий з першого погляду
- Повторне використання: Логіку можна викликати з будь-якого місця
- Тестованість: Сервіс легко тестувати ізольовано
- Підтримуваність: Зміни бізнес-логіки не торкаються контролера
return), це ознака того, що частину логіки варто перенести в сервіс. Контролер має бути настільки простим, щоб його зрозуміти за кілька секунд.Практичний приклад: контролер управління статтями
Для закріплення матеріалу розглянемо повноцінний приклад контролера для управління статтями блогу:
Цей контролер демонструє кілька ключових концепцій:
- Чітке розділення: Контролер координує, сервіс виконує
- Асинхронність: Всі методи асинхронні для узгодженості
- Множинні маршрути: Різні ендпоінти для різних варіантів фільтрації
- Повна реєстрація: Контролер та сервіс зареєстровані в модулі
Результуючі URL:
GET /articles— всі статтіGET /articles/published— опубліковані статтіGET /articles/featured— рекомендовані статті
Підсумок: контролер як координатор
Контролери є критичним компонентом архітектури NestJS, що формує HTTP API застосунку. Їхня роль полягає не у виконанні бізнес-логіки, а в координації взаємодії між HTTP-шаром та доменною логікою застосунку.
Ключові принципи роботи з контролерами:
- Декоратор @Controller() визначає базовий префікс шляху та позначає клас як контролер
- Методи-обробники позначені декораторами HTTP-методів координують обробку запитів
- Автоматична серіалізація перетворює повернуті значення у JSON без додаткового коду
- Асинхронність підтримується нативно через Promise та async/await
- Реєстрація в модулі через масив
controllersробить контролер видимим для фреймворку - Тонкий контролер делегує всю бізнес-логіку сервісам, залишаючись простим координатором
У наступних лекціях ми детально розглянемо декоратори HTTP-методів, роботу з параметрами маршрутів, query-параметрами, тілом запиту та іншими аспектами розробки REST API на NestJS.
✅ Що ми опанували
- Роль контролера як координатора HTTP-комунікації
- Використання декоратора @Controller для визначення префіксів
- Структуру класу контролера та впровадження залежностей
- Створення методів-обробників маршрутів
- Автоматичну серіалізацію повертаємих значень
- Роботу з асинхронним кодом через async/await
- Реєстрацію контролера у модулі
- Генерацію через CLI
- Принцип тонкого контролера
🎯 Наступні кроки