NEST-NEXT: Best Practices — Часть 1

Kate

Administrator
Команда форума
Это первая часть статьи о применении комбинации технологий nest.js и NEXT.js, где будет рассмотрено создание проекта, подключение фреймворков и выбор эффективного способа работы с SSR. Во второй части я расскажу о HMR, использовании данных при SSR и разворачивании "за слешом".

Вступление​

Когда речь заходит о выборе фреймворка для разработки вебсайта в 2021 году, обязательно всплывает NEXT.js - основанный на React фреймворк из коробки позволяет поддерживать SSR/SSG, TypeScript, code-splitting, раутинг и имеет прочие возможности. Но это в первую очередь клиентский фреймворк, который также имеет простенький сервер.

Возникает вопрос - а что использовать для бэкенда/фронтбэка? Отличным вариантом будет nest - прогрессивный node.js фреймворк, который позволяет писать достаточно производительные и легко поддерживаемые бэкенды. Сильным его преимуществом является знакомая по Angular/Spring MVC модель архитектуры, основанная на DI.

А как связать эти две сущности? Есть вариант запускать два сервера и настраивать хитрый proxy-сервер, который будет распределять запросы между ними, но такой способ очевидно имеет недостатки: необходимость поднимать несколько сервисов/контейнеров и сложная конфигурация прокси.

К счастью, существует специальный пакет nest-next, который позволяет запускать NEXT.js внутри nest, рендерить страницы на запросы и т.д. Но при такой работе возникает ряд нюансов и неприятностей, решения которых не всегда очевидны. Давайте вместе создадим простой проект на nest-next, по ходу чего я расскажу о главных особенностях данных технологий и поделюсь теми best practices, которые были обнаружены мною и моими коллегами за почти год разработки в таком режиме.

По ходу повествования я постараюсь как можно меньше вдаваться в подробности каждого из фреймворков, фокусируясь прежде всего на провязку между ними. Там, где будут требоваться определенные знания об одном из фреймворков, я оставлю ссылки на официальную документацию.

Прежде чем мы начнем​

Скорее всего, решение использовать NEXT.js будет принято первым, а потом уже команда или одиночный разработчик задумаются о выборе бэкенда или фронтбэка. Здесь следует остановиться и всерьез рассмотреть имеющиеся возможности NEXT.js. Если планируется создавать простой фронтбэк, который будет рендерить страницы клиенту и проксировать запросы в бэкенд, то сервер NEXT.js как раз это и умеет, не имеет смысла тянуть отдельный сервер и заниматься оптимизацией совместной работы двух сущностей.

Следует рассматривать симбиоз nest-next только в тех случаях, когда требуется либо полноценный бэкенд на node.js, который не хочется выносить в отдельный сервис, либо достаточно серьезный фронтбэк-сервер с существенными обязанностями и/или завязкой под имеющуюся инфраструктуру на Express/nest.
Статья была разделена на 2 части: в этой, первой части я расскажу про создание проекта на nest-next с нуля и его настройке, решению самых первых проблем SSR. Если вы считаете себя уже достаточно опытным разработчиком в этом стеке, то, возможно, стоит начать сразу со второй части, где я рассказываю о HMR, использовании данных при SSR и разворачивании "за слэшом".

И последнее: для желающих сразу же взглянуть на готовый код прикладываю ссылку на проект на GitHub - https://github.com/yakovlev-alexey/nest-next-example - последовательность коммитов в целом совпадает с ходом дилогии статей.

Создание приложения nest​

Первым делом создадим базовое приложение nest в терминале. Для этого воспользуемся утилитой @nestjs/cli, которая позволяет генерировать шаблонные проекты:

npx @nestjs/cli new nest-next-example
Проследуем указаниям генератора. Я выбрал yarn в качестве менеджера пакетов и далее в статье буду приводить сниппеты для yarn, для npm они будут аналогичными.

В результате выполнения мы получим стартовый проект, который сразу можно запустить. Предлагаю удалить файлы для тестирования (директория test в корне проекта и файл app.controller.spec.ts - в рамках данной статьи мы не будем писать тесты.

Для удобства будем использовать следующую архитектуру папки src:

└── src
├── client # клиентский код: хуки, компоненты и т.д.
├── pages # экраны вебсайта (NEXT.js)
├── server # сервер nest.js
└── shared # общие типы/хелперы и т.д.
Отразим эти изменения в конфигурации nest:

// ./nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "server/main"
}
Теперь мы можем запустить сервер командой yarn start:dev и увидеть наш "Hello world" по адресу localhost:3000 в браузере.

Из-за особенностей сборки nest, при запуске может появиться ошибка Error: Cannot find module '.../dist/server/main' - в таком случае временно можно сделать "entryFile": "main".

Установка NEXT.js​

Теперь добавим в проект NEXT.js:

# NEXT.js и его peer зависимости
yarn add next react react-dom
# необходимые типы и конфиг для eslint
yarn add -D @types/react @types/react-dom eslint-config-next
Запустим сервер разработки NEXT.js: yarn next dev. В наш проект будут внесены следующие необходимые изменения: добавлен файл next-env.d.ts и изменен tsconfig.json. Сервер будет успешно запущен, но когда мы попытаемся снова запустить nest, обнаружим, что изменения конфигурации TypeScript сломали сборку. Переиспользуем имеющийся tsconfig.build.json в качестве tsconfig.server.json и поместим туда следующее:

// ./tsconfig.server.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"include": [
"./src/server/**/*.ts",
"./src/shared/**/*.ts",
"./@types/**/*.d.ts"
]
}
Теперь сервер вновь заработает, когда мы подключим корректный конфиг Typescript. Для этого обновим скрипты в package.json, чтобы обеспечить поддержку обоих фреймворков:

// ./package.json
"scripts": {
"prebuild": "rimraf dist",
"build": "yarn build:next && yarn build:nest",
"build:next": "next build",
"build:nest": "nest build --path ./tsconfig.server.json",
"start": "node ./dist/server/main.js",
"start:next": "next dev",
"start:dev": "nest start --path ./tsconfig.server.json --watch",
"start:debug": "nest start --path ./tsconfig.server.json --debug --watch",
"start:prod": "node dist/main",
// ... прочие скрипты (lint/format etc)
},
Добавим в каталог src/pages экран index и компонент App, необходимый для работы NEXT.js:

// ./src/pages/app.tsx
import { FC } from 'react';
import { AppProps } from 'next/app';

const App: FC<AppProps> = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};

export default App;
// ./src/pages/index.tsx
import { FC } from 'react';

const Home: FC = () => {
return <h1>Home</h1>;
};

export default Home;
Запустив сервер NEXT.js командой yarn start:next, мы сможем увидеть эти страницы по адресу localhost:3000.

В .gitignore следует добавить .next - туда будут попадать результаты сборок NEXT.js

Подружим фреймворки​

Теперь у нас есть два отдельных сервера, но нашей целью было подключить NEXT.js в nest. Для этого установим пакет nest-next:

yarn add nest-next
Для корректной работы библиотеки нам следует подключить RenderModule в app.module.ts:

// ./src/server/app.module.ts
import { Module } from '@nestjs/common';
import { RenderModule } from 'nest-next';
import Next from 'next';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
/* в метод инициализации следует
передать инстанс сервера NEXT.js */
imports: [RenderModule.forRootAsync(Next({}))],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Теперь нам доступен декоратор @Render, который позволяет рендерить страницы подключаемым рендер-модулем nest. Обновим контроллер app.controller.ts:

// ./src/server/app.controller.ts
import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get()
@Render('index')
home() {
return {};
}
}
Запустим сервер nest: yarn start:dev. При попытке зайти на страницу, мы увидим ошибку при создании инстанса NEXT.js - собранный бандл не был обнаружен - сервер запустился в продакшен режиме. Для корректной работы нам нужно передать в инстанс NEXT.js опцию dev: true:

// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(Next({ dev: true }))
],
Теперь попробуем зайти на localhost:3000. Мы увидим стандартную страницу 404 NEXT.js - странно, ведь у нас есть файл index.tsx в src/pages и мы видели его при запуске отдельного сервера NEXT.js. На самом деле nest-next по умолчанию ищет страницы в директории views внутри pages, чтобы изменить этот параметр, передадим опцию viewsDir в метод инициализации RenderModule:

// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(
Next({ dev: true }),
/* значение null сообщает nest-next
искать экраны в корне pages */
{ viewsDir: null }
)
],
Рядом с viewsDir есть еще одна опция dev уже для nest-next, которая отвечает уже за сериализацию ошибок перед отправкой их в NEXT.js - в production окружениях мы можем не хотеть отправлять подробную информацию об ошибке.
Теперь по адресу localhost:3000 мы наконец обнаружим нашу страницу, описанную в index.tsx.

Запрос данных для SSR​

Одно из главных преимуществ NEXT.js - возможность удобно запрашивать данные для SSR. Для этого у нас есть сразу несколько методов, мы будем использовать getServerSideProps (далее - GSSP) - для рендеринга страницы во время обработки запроса - классический SSR. При этом nest-next корректно поддерживает другие виды генерации страниц, предоставляемые NEXT.js. Первым делом добавим еще одну страницу. Предположим, что главная страница - список всех записей в блоге, и добавим страницу блогпоста по id. На нее добавим ссылку на главную страницу.

// ./src/shared/types/blog-post.ts
export type BlogPost = {
title: string;
id: number;
};

// ./src/server/app.controller.ts
import { Controller, Get, Param, Render } from '@nestjs/common';

// ...

@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return {};
}
// ./src/pages/[id].tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';

type TBlogProps = {
post: BlogPost;
};

const Blog: FC<TBlogProps> = ({ post = {} }) => {
return (
<div>
<Link href={'/'}>Home</Link>
<h1>Blog {post.title}</h1>
</div>
);
};

export const getServerSideProps: GetServerSideProps<TBlogProps> = async (
ctx,
) => {
return { props: {} };
};

export default Blog;
// ./src/pages/index.tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';

type THomeProps = {
blogPosts: BlogPost[];
};

const Home: FC<THomeProps> = ({ blogPosts = [] }) => {
return (
<div>
<h1>Home</h1>
{blogPosts.map(({ title, id }) => (
<div key={id}>
<Link href={`/${id}`}>{title}</Link>
</div>
))}
</div>
);
};

export const getServerSideProps: GetServerSideProps<THomeProps> = async (
ctx,
) => {
return { props: {} };
};

export default Home;
Отлично, теперь у нас есть страницы, которым нужны данные. Осталось эти данные им отправить. Рассмотрим разные варианты, которые мы можем использовать.

Через контроллер nest​

Наш контроллер home в app.controller.ts возвращает пустой объект - все, что мы отправим отсюда, попадет в ctx.query в GSSP.

Замокаем блогпосты в app.service.ts:

// ./src/server/app.service.ts
import { Injectable } from '@nestjs/common';
import { from } from 'rxjs';

const BLOG_POSTS = [
{ title: 'Lorem Ipsum', id: 1 },
{ title: 'Dolore Sit', id: 2 },
{ title: 'Ame', id: 3 },
];

@Injectable()
export class AppService {
getBlogPosts() {
return from(BLOG_POSTS);
}
}
В контроллере обратимся к сервису и вернем блогпосты из контроллера.

// ./src/server/app.controller.ts
import { map, toArray } from 'rxjs';

// ...

@Get('/')
@Render('index')
home() {
return this.appService.getBlogPosts().pipe(
toArray(),
map((blogPosts) => ({ blogPosts })),
);
}
Теперь в GSSP мы сможем получить доступ к blogPosts через ctx.query. Очевидно, такой вариант крайне неудобен - тайпинги query подсказывают нам, что нет там никакого blogPosts, а есть только параметры запроса ParsedUrlQuery. Дейстивтельно, попробуем зайти на страницу localhost:3000/1, например, и осуществить переход по ссылке на Home - мы обнаружим, что blogPosts там не окажется.

При переходах NEXT.js обращается к внутреннему эндопинту, который на сервере исполняет GSSP и возвращает JSON - в таком случае наш контроллер вовсе не исполняется, а в ctx.query попадают только параметры запроса из адреса и location.search.

Через прямое обращение к сервисам​

Как известно, GSSP исполняется только на сервере, следовательно, мы могли бы обращаться к сервисам nest прямо из тела функции. В действительности этот вариант так же нас совершенно не устраивает - нам нужно самостоятельно создавать инстансы сервисов, про сервисы-синглтоны, DI и вообще все преимущества nest можно забыть. Если же мы попробуем обратиться к сервисам через инстанс приложения, мы натолкнемся на схожую проблему при переходах - сервисам будет не хватать контекста (например, объекта req) для корректной работы.

Через обращение сервера к самому себе​

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

// ./src/shared/constants/env.ts
export const isServer = typeof window === 'undefined';

export const isClient = !isServer;

export const NODE_ENV = process.env.NODE_ENV;

export const PORT = process.env.PORT || 3000;
Обновим подписку на порт в main.ts (await app.listen(PORT)), а также определим режим работы для NEXT в зависимости от среды исполнения:

// ./src/server/app.module.ts
RenderModule.forRootAsync(
Next({ dev: NODE_ENV === 'development' }),
{ viewsDir: null }
)

// ./package.json
"start:dev": "NODE_ENV=development nest start --path ./tsconfig.server.json --watch"
Теперь сервер импортирует модули из src/shared и структура скомпилированного сервера nest выглядит иначе: main.ts переехал в dist/server, как и следовало изначально. Если вы меняли entryFile в nest-cli.json, то нужно вернуть изначально запланированное значение и перезапустить сервер, а папку dist очистить, иначе сервер будет запускаться на старой версии сборки.
Обертка над fetch

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

// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';

const envAwareFetch = (url: string, options?: Record<string, unknown>) => {
const fetchUrl =
isServer && url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;

return fetch(fetchUrl, options).then((res) => res.json());
};

export { envAwareFetch as fetch };
Обновим сервис app.service.ts:

// ./src/server/app.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { from, of, toArray } from 'rxjs';

const BLOG_POSTS = [
{ title: 'Lorem Ipsum', id: 1 },
{ title: 'Dolore Sit', id: 2 },
{ title: 'Ame', id: 3 },
];

@Injectable()
export class AppService {
getBlogPosts() {
return from(BLOG_POSTS).pipe(toArray());
}

getBlogPost(postId: number) {
const blogPost = BLOG_POSTS.find(({ id }) => id === postId);

if (!blogPost) {
throw new NotFoundException();
}

return of(blogPost);
}
}
Добавим эндпоинты API для запроса блогпостов в наш контроллер:

// ./src/server/app.controller.ts
import { Controller, Get, Param, ParseIntPipe, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get('/')
@Render('index')
home() {
return {};
}

@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return {};
}

@Get('/api/blog-posts')
public listBlogPosts() {
return this.appService.getBlogPosts();
}

@Get('/api/blog-posts/:id')
public getBlogPostById(@Param('id', new ParseIntPipe()) id: number) {
return this.appService.getBlogPost(id);
}
}
Отлично, теперь мы можем обновить методы GSSP в наших экранах:

// ./src/pages/index.tsx
import { fetch } from 'src/shared/utils/fetch';

export const getServerSideProps: GetServerSideProps<THomeProps> = async () => {
const blogPosts = await fetch('/api/blog-posts');
return { props: { blogPosts } };
};

// ./src/pages/[id].tsx
import { fetch } from 'src/shared/utils/fetch';

export const getServerSideProps: GetServerSideProps<TBlogProps> = async () => {
const id = ctx.query.id;
const post = await fetch(`/api/blog-posts/${id}`);

return { props: { post } };
};
Зайдем на localhost:3000 и увидим, что список блогпостов действительно подгрузился. Перейдем по ссылке на один из постов - здесь тоже все работает, как нужно, мы видим корректное название. Но попробуем обновить страницу: теперь мы видим ошибку - такой блогпост не был найден. Странно, ведь при переходе все работало.

На самом деле, как мы уже выяснили, при SSR nest-next подкладывает в ctx.query возвращаемое значение контроллера, соответственно, параметров запроса там не оказывается. Решим это недоразумение - вернем нужный параметр из контроллера.

// ./src/server/app.controller.ts
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return { id };
}
Для эндпоинта API мы парсили передаваемый параметр в Int - здесь нет в этом необходимости, ведь это внесет больше путаницы: NEXT.js не преобразует параметры, когда помещает их ctx.query, не будем и мы.
Теперь обновим страницу в браузере - как и ожидалось, это решило нашу проблему.

Прокидывание параметров запроса

Прописывать все параметры вручную в каждом контроллере неудобно - можно забыть, особенно если это касается location.search. Для избежания повторения таких конструкций обратимся к AOP (Aspect-oriented programming) и механизму его реализации при обработке запросов в nest: концепту Interceptor.

// ./src/server/params.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ParamsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest() as Request;

/* после выполнения обработчика запроса подложим
возвращаемому значению параметры запроса */
return next.handle().pipe(
map((data) => {
return {
...request.query,
...request.params,
...data,
};
}),
);
}
}

В соответствии с документацией NEXT.js, параметры запроса имеют приоритет по сравнению с параметрами query. Так же не будем перезаписывать поля из возвращаемого значения контроллера, оставляя возможность обработать параметры в контроллере вручную.
Подключим наш интерсептор на обработчики запросов страниц:

// ./src/server/app.controller.ts
import { UseInterceptors } from '@nestjs/common';
import { ParamsInterceptor } from './params.interceptor';

// ...

@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor)
public home() {
return {};
}

@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor)
public blogPost() {
return {};
}
Важно помнить, что названия переменных в адресе должны совпадать в nest и NEXT.js. Иными словами, названия переменных в декораторах @Get() и @Render() должны совпадать. На обработчики API, разумеется, вешать этот интерсептор не требуется - мы не хотим, чтобы в возвращаемый JSON попадали параметры запроса.

Хорошим паттерном при разработке nest-next будет разделять контроллеры страниц и API - тогда можно будет повесить декоратор @UseInterceptors() прямо на весь класс контроллера. В рамках данной статьи для простоты примера контроллеры не были разделены.
Проверим работу интерсептора в браузере: вновь обновим страницу - интерсептор успешно работает.

Заключение​

Уже сейчас приложение позволяет удовлетворить большинство потребностей при разработки простого вебсайта. Но есть еще несколько способов выжать пользу из nest-next, а также ряд нюансов, которые могут встретиться, особенно в энтерпрайзе. Про них читайте во второй части.

Надеюсь, что данная статья помогла желающим попробовать эти фреймворки вместе войти в разработку с максимальной эффективностью, даже несмотря на почти полное отсутствие официальной документации по nest-next.

 
Сверху