Роутер, контролери та сервіси
Що робить REST-роутер
Роутер має мапінг між URL та відповідним обробником запиту. Наприклад, коли користувач вашого вебсайту з браузера запитує адресу /some-path чи /other-path, чи /path-with/:parameter і т.д. - таким чином він повідомляє Ditsmod-застосунок, що хоче отримати певний ресурс, або хоче здійснити певну зміну на сайті. Щоб Ditsmod-застосунок знав, що саме треба робити в даних випадках, у його коді потрібно прописувати відповідні обробники запитів. Тобто, якщо запитують /some-path, значить треба виконати певну функцію; якщо запитують /other-path, значить треба виконати іншу функцію і т.д. Коли прописують подібну відповідність між адресою та її обробником - це і є процес створення мапінгу між URL та відповідним обробником запиту.
Хоча вам не прийдеться вручну писати цей мапінг, але для загального уявлення роботи роутера, у дуже спрощеному вигляді його можна уявити так:
const routes = new Map<string, Function>();
routes.set('/some-path', function() { /** обробка запиту... **/ });
routes.set('/other-path', function() { /** обробка запиту... **/ });
routes.set('/path-with/:parameter', function() { /** обробка запиту... **/ });
// ...
Зразу після того, як Node.js отримує HTTP-запит і передає його в Ditsmod, URL запиту розбивається на дві частини, які розділяються знаком питання (якщо він є). Перша частина завжди містить так званий path, а друга частина - query-параметри, якщо URL містить знак питання.
Задача роутера полягає в тому, щоб знайти обробник HTTP-запиту по path. У дуже спрощеному вигляді цей процес можна уявити так:
const path = '/some-path';
const handle = routes.get(path);
// ...
// І потім цей обробник викликаєть ся у функції, що прослуховує HTTP-запити.
if (handle) {
handle();
}
У більшості випадків, обробник запиту викликає метод контролера.
Що являє собою REST-контролер
Мапінг між URL та обробником запиту формується на основі метаданих, які закріпляються за методами контролерів. TypeScript клас стає контролером Ditsmod завд яки декоратору controller:
import { controller, route } from '@ditsmod/rest';
@controller()
export class SomeController {
@route('GET', 'hello')
method1() {
// ...
}
}
Файли контролерів ре комендується називати із закінченням *.controller.ts, а імена їхніх класів - із закінченням *Controller.
Як видно з попереднього прикладу, будь-який REST-контролер повинен мати:
- метод класу, який буде викликатись під час HTTP-запиту;
- назву HTTP-методу (
GET,POST,PATCHі т.д.); - URL до якого буде прив'язуватись виклик метода класу (опціонально).
Комбінація другого та третього пункту повинна бути унікальною на весь застосунок. Тобто, якщо ви один раз визначили що GET + /hello будуть прив'язані до певного методу контролера, то другий раз ця сама комбінація не повинна повторюватись. В противному разі, модуль @ditsmod/rest кине помилку з відповідним повідомленням.
Ditsmod забезпечує роботу контролерів у двох альтернативних режимах, які відрізняються механізмом передачі аргументів у метод контролера:
- Request-scoped контролер (по-дефолту). Метод контролера може отримувати довільну кількість аргументів від DI-інжектора. Серед цих аргументів може бути HTTP-запит.
- Route-scoped контролер. Метод контролера отримує єдиний аргумент - контекст запиту, який зокрема містить HTTP-запит.
Перший режим більш зручний і більш безпечний, коли потрібно працювати в контексті поточного HTTP-запиту (клієнт надає певний ідентифікатор, який необхідно враховувати для формування відповіді). Другий режим роботи помітно швидший (приблизно на 15-20%) і споживає менше пам'яті, але контекст запиту не можна зберігати у властивостях інстансу контролера, бо цей інстанс може одночасно використовуватись для інших клієнтів.
Request-scoped контролер
По-дефолту, Ditsmod працює з контролером у request-scoped режимі. Це означає, по-перше, що для кожного HTTP-запиту буде створюватись окремий інстанс контролеру. По-друге, будь-який метод контролера, який має декоратор route, буде отримувати довільну кількість аргументів від DI-інжектора. В наступному прикладі створено єдиний маршрут, що приймає GET запит за адресою /hello:
import { controller, route, RequestContext } from '@ditsmod/rest';
import { Service1 } from './service-1';
import { Service2 } from './service-2';
@controller()
export class HelloWorldController {
@route('GET', 'hello')
method1(service1: Service1, service2: Service2, ctx: RequestContext) {
// Working with service1 and service2
// ...
ctx.send('Hello, World!');
}
}
Що ми тут бачимо:
- Маршрут створюється за допомогою декоратора
route, що ставиться перед методом класу, причому не важливо як саме називається цей метод. - В даному режимі контролера, у методі класу можна оголосити довільну кількість параметрів. У даному разі ми оголосили три залежності:
service1з типом данихService1,service2з типом данихService2, таresз типом данихRequestContext. До речі,res- це скорочення від слова response. - Текстові відповіді на HTTP-запити відправляються через
ctx.send().
Хоча в попередньому прикладі залежності декларувались у method1, але аналогічним чином ми можемо зробити це і в конструкторі:
import { controller, RequestContext, route } from '@ditsmod/rest';
import { Service1 } from './service-1';
import { Service2 } from './service-2';
@controller()
export class HelloWorldController {
constructor(private service1: Service1, private service2: Service2, private ctx: RequestContext) {}
@route('GET', 'hello')
method1() {
// Working with this.service1 and this.service2
// ...
this.ctx.send('Hello, World!');
}
}
Звичайно ж, у параметрах методів можна декларувати й інші залежності, причому послідовність параметрів є неважливою.
Модифікатор доступу в конструкторі може бути будь-яким (private, protected або public), але взагалі без модифікатора - параметри матимуть видимість лише в конструкторі (вони не будуть доступними в методах).
Параметри в роутінгу
Щоб передати path-параметри для роутера, необхідно використовувати двокрапку перед іменем параметра. Наприклад, в URL some-url/:param1/:param2 передано два path-параметри. Якщо для роутінгу ви використовуєте модуль @ditsmod/rest, лише path-параметри визначають роути, а query-параметри не беруться до уваги.
Щоб отримати path-параметри чи query-параметри, доведеться скористатись декоратором ctx та токенами PATH_PARAMS і QUERY_PARAMS:
import { ctx, AnyObj } from '@ditsmod/core';
import { controller, route, PATH_PARAMS, QUERY_PARAMS } from '@ditsmod/rest';
@controller()
export class SomeController {
@route('GET', 'some-url/:param1/:param2')
method1(
@ctx(PATH_PARAMS) pathParams: AnyObj,
@ctx(QUERY_PARAMS) queryParams: AnyObj
) {
return ({ pathParams, queryParams });
}
}
Більше інформації про те, що таке токен та що саме робить декоратор ctx ви можете отримати з розділу Dependency Injection.
Як бачите з попереднього прикладу, відповіді на HTTP-запити також можна відправляти завдяки звичайному return.
Рідні Node.js об'єкти запиту та відповіді можна отримати за токенами відповідно - RAW_REQ та RAW_RES:
import { ctx } from '@ditsmod/core';
import { controller, route, RAW_REQ, RAW_RES, RawRequest, RawResponse } from '@ditsmod/rest';
@controller()
export class HelloWorldController {
constructor(
@ctx(RAW_REQ) private rawReq: RawRequest,
@ctx(RAW_RES) private rawRes: RawResponse
) {}
@route('GET', 'hello')
method1() {
this.rawRes.end('Hello, World!');
}
}
Вас також може зацікавити як можна отримати тіло HTTP-запиту.
Route-scoped контролер
Щоб контролер працював в режимі route-scoped, в його метаданих потрібно вказати { scope: 'route' }. Через те, що інстанс контролера у цьому режимі створюється єдиний раз, ви не зможете запитувати у його конструкторі інстанси класів, які створюються за кожним запитом. Наприклад, якщо в конструкторі ви запросите інстанс класу RequestContext, Ditsmod кине помилку:
import { RequestContext, controller, route } from '@ditsmod/rest';
@controller({ scope: 'route' })
export class HelloWorldController {
constructor(private ctx: RequestContext) {}
@route('GET', 'hello')
method1() {
this.ctx.send('Hello, World!');
}
}
Робочий варіант буде таким:
import { controller, RequestContext, route } from '@ditsmod/rest';
@controller({ scope: 'route' })
export class HelloWorldController {
@route('GET', 'hello')
method1(ctx: RequestContext) {
ctx.send('Hello, World!');
}
}
В режимі "route-scoped", методи контролерів, що прив'язані до певних роутів, отримують єдиний аргумент - контекст запиту. Тобто в цьому режимі ви вже не зможете оголосити інші параметри методів. Разом з тим, в конструкторі ви все ще можете оголошувати довільну кількість параметрів, які створюються єдиний раз.
Ієрархія інжекторів контролера
Контролер в режимі request-scoped, окрім свого власного інжектора на рівні запиту, має ще й три батьківські інжектори: на рівні роута, модуля та застосунка. Ці інжектори також формуються на основі провайдерів, які ви передаєте в наступні масиви:
providersPerApp;providersPerMod;providersPerRou;providersPerReq(це масив, з якого формується інжектор для контролера в режимі request-scoped).
Тобто контролер в режимі request-scoped може залежати від сервісів на будь-якому рівні.
Якщо ж контролер є в режимі route-scoped, його власний інжектор знаходиться на рівні модуля, і він має один батьківський інжектор на рівні застосунку:
providersPerApp;providersPerMod(це масив, з якого формується інжектор для контролера в режимі route-scoped).
Прив'язка контролера до хост-модуля
Будь-який контролер повинен прив'язуватись лише до поточного модуля, де він був оголошений, тобто до хост-модуля. Така прив'язка робиться через масив controllers:
import { restModule } from '@ditsmod/rest';
import { SomeController } from './some.controller.js';
@restModule({ controllers: [SomeController] })
export class SomeModule {}
Після прив'язки контролерів до хост-модуля, щоб Ditsmod брав їх до уваги у зовнішньому модулі, хост-модуль потрібно або прикріпити, або імпортувати у формі об'єкта, що має інтерфейс ModuleWithParams. В наступному прикладі показано і прикріплення, і повний імпорт хост-модуля (це зроблено лише щоб продемонструвати можливість, на практиці немає сенсу робити одночасне прикріплення з імпортом):
import { restModule } from '@ditsmod/rest';
import { SomeModule } from './some.module.js';
@restModule({
appends: [SomeModule],
// OR
imports: [{ module: SomeModule, path: '' }]
})
export class OtherModule {}
Якщо модуль імпортується без властивості path, Ditsmod буде імпортувати лише його провайдери та розширення:
import { restModule } from '@ditsmod/rest';
import { SomeModule } from './some.module.js';
@restModule({
imports: [SomeModule]
})
export class OtherModule {}
Більш докладну інформацію ви можете прочитати у розділі Експорт, імпорт та прикріплення модулів.
Сервіси
Хоча з технічної точки зору, для обробки HTTP-запиту можна обійтись одним лише контролером, але об'ємний код з бізнес логікою краще виносити в окремі класи, щоб при потребі можна було повторно використовувати цей код, і щоб його можна було простіше тестувати. Ці окремі класи з бізнес логікою, як правило, називають сервісами.
Що можуть робити сервіси:
- перевіряти права доступу;
- робити валідацію запиту;
- надавати конфігурацію;
- робити парсинг тіла запиту;
- парцювати з базами даних, з поштою;
- і т.п.
Будь-який TypeScript клас може бути сервісом Ditsmod, але якщо ви хочете щоб DI вирішував залежність, яку ви вказуєте в конструкторах даних класів, перед ними необхідно прописувати декоратор injectable:
import { injectable } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private firstService: FirstService) {}
methodOne() {
this.firstService.doSomeThing();
}
}
Файли сервісів рекомендується називати із закінченням *.service.ts, а їхні класи - із закінченням *Service.
Як бачите, правила отримання інстансу класу в конструкторі такі ж, як і в контролерах: за допом огою модифікатора доступу private оголошуємо властивість класу firstService з типом даних FirstService.
Щоб можна було користуватись новоствореними класами сервісів, їх потрібно передати у метадані поточного модуля чи контролера. Передати сервіси у метадані модуля можна наступним чином:
import { restModule } from '@ditsmod/rest';
import { FirstService } from './first.service.js';
import { SecondService } from './second.service.js';
@restModule({
providersPerReq: [
FirstService,
SecondService
],
})
export class SomeModule {}
Аналогічно сервіси передаються у метадані контролера:
import { controller, RequestContext, route } from '@ditsmod/rest';
import { FirstService } from './first.service.js';
import { SecondService } from './second.service.js';
@controller({
providersPerReq: [
FirstService,
SecondService
],
})
export class SomeController {
@route('GET', 'hello')
method1(ctx: RequestContext, secondService: SecondService) {
ctx.send(secondService.sayHello());
}
}
В останніх двох прикладах сервіси передаються у масив providersPerReq, але це не єдиний спосіб передачі сервісів. Більш докладну інформацію про правила роботи з DI можна отримати у розділі Dependency Injection.