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

от автора

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

Наступил Октябрь 2022 и вышла новая 13 версия NEXT.js. В ней очень много важных изменений, которые сильно меняют то, как можно использовать nest.js и NEXT.js вместе. Пока я изучаю работу с новой директорией app и готовлю обновление для этого туториала, предлагаю вам тоже оставлять ваш фидбек по использованию NEXT.js 13 с библиотекой nest-next (про которую речь пойдет дальше) в этом GitHub issue.

Оглавление

Вступление

Когда речь заходит о выборе фреймворка для разработки вебсайта в 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.


ссылка на оригинал статьи https://habr.com/ru/articles/570886/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *