Pull to refresh

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

Reading time13 min
Views26K

Это первая часть статьи о применении комбинации технологий 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.

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments4

Articles