Подготовка шаблона приложения на Typescript с Nest, Nuxt 3 и Docker

Kate

Administrator
Команда форума
Решил описать свой подход построения окружения на Typescript с Nest на бекенде, Nuxt (SPA) на фронтенде. Все заворачивается в один docker‑образ и запускается как standalone приложение c nginx, healthcheck»ами, тестами и ш…широкой сферой применения.

8d3e6688a6dfb96db7ff054a3a1cea45.jpg

Делал это в качестве фундамента для будущих проектов или с целью изучения Nest, Nuxt 3 с composable функциями. Можно использовать это как инструкцию к настройке подобной архитектуры, можно взять за основу код с github.

Архитектура проекта​

Шаблон приложения поставляется в виде одного docker‑образа, в котором установлен nest+nginx и собраны backend и frontend.

Схема архитектуры
Схема архитектуры

Файловая структура​

Для начала опишу из как выглядит архитектура проекта.

└── application/
├── backend/
│ └── NEST приложение
├── frontend/
│ └── NUXT приложение
├── docker/
│ └── nginx/
│ └── conf.conf
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
└── readme.md
  • backend — стандартное nest приложение с добавленным serve-static модулем;
  • frontend — стандартное nuxt приложение с добавленным и настроенной связью с backend;
  • docker — папка с конфигами, которые пойдут в docker образ (в текущей версии только nginx);
  • Dockerfile — указания по сборке докер-образа;
  • docker-compose.yml — файл для запуска проекта.
Весь проект доступен на github, его можно склонировать, запустить командой docker-compose up -d (подробнее про запуск написал в конце статьи) и запустить готовый к расширению шаблон приложения. Ниже я описал что именно изменено в стартовых приложениях и каким образом настроена связь между ними

В этом шаблоне нет базы данных и каких‑либо других сторонних зависимостей, чтобы не ограничивать набор компонентов для дальнейшей разработки.

Процесс обработки запросов​

В качестве сервера, принимающего запросы используется Nginx. Он раздает статику собранного frontend приложения и перенаправляет запрос на бекенд, если URL запроса начинается на /api

Таким образом может быть 2 типа запроса.

Статический:

8985fc4abefa4722ca22b8e0a67a1779.png

И запрос к API:

e3e5ee4fcc7dcf00dd9aa5f5e022d902.png

Подготовка Backend сервиса​

За основу взят стартовый набор nest:

$ npm i -g @nestjs/cli
$ nest new backend
Дальше необходимо сделать некоторые доработки. Первым делом в main.ts прописываем порт по умолчанию на 3001, добавляем префикс /api. Таким образом main.ts обретает следующий вид:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.APP_PORT || 3001;
app.setGlobalPrefix('api');
app.enableCors();
await app.listen(port);
}
bootstrap();

Настройка static директории​

В папку static будет переноситься статичный html/js/css бандл с nuxt приложением и потом раздаваться как статичный сайт при запуске проекта без nginx.

Да, при прочих равных, стоит запускать проект с nginx и для этого не нужно переносить в папку static ничего.
Но на то это и бойлер, что я заранее не знаю как он будет и где запускаться. Может быть, в каких то ситуациях, при малых нагрузках, будет достаточно запуска чистого Nest.
Для того, чтобы nest раздавал статику достаточно подключить модуль serve‑static внутрь AppModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from '@nestjs/serve-static';
import * as path from 'path';

@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: path.join(__dirname, '..', 'static'),
serveRoot: '/',
exclude: ['/api*'],
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Обратите внимание на блок exclude: ['/api*']. Это нужно для того, чтобы статика раздавалась на всех ссылках, кроме /api — при запуске проекта по пути /api будет размещаться само nest приложение.

В саму папку static размещаем .gitignore с двумя строчками:

*
!index.html
И index.html, который будет использоваться только при разработке и при сборке конечного docker-образа в эту папку будет складываться html/js/css интерфейса.

Небольшое отступление по поводу префикса к api​

В nest можно реализовать префикс /api двумя способами:

  • в каждом контроллере приписывать /api в @Controller('/api/controller-route')
  • прописать на уровне nest приложения глобальный префикс
Я в своем шаблоне использую второй способ. Для его реализации нужно сделать следующее:

  1. Прописать в main.ts строчку app.setGlobalPrefix('api');
  2. Поправить e2e тест, чтобы в нем тоже создавалось приложение с префиксом и поправить сами тесты.
Поскольку я стараюсь разрабатывать через e2e тесты и тестов в проектах может быть очень много, я сразу выношу в отдельную функцию создание тестового приложения:

export async function createTestingApp() {
return (
await Test.createTestingModule({
imports: [AppModule],
}).compile()
)
.createNestApplication()
.setGlobalPrefix('api');
}
Дальше я уже, в тестах, использую эту функцию вместо штатной инициализации приложения:

import { createTestingApp } from './utils/create-testing-app';

// .....

beforeEach(async () => {
app = await createTestingApp();
await app.init();
});

Настройка тестового окружения​

Я уже немного затронул тестовое окружение в предыдущем блоке, но по тестам я сделал еще небольшие изменения.

  • удалил стандартный spec файл у контроллера, т.к. сам предпочитаю e2e тесты и узкие тесты пишу в редких случаях;
  • поменял формат jest.e2e.config.json на js, тк, зачастую, в проектах приходится добавлять динамические конфигурации и IDE js формат считывает сразу;
  • поправил базовый тест с указанием /api в самих тестах.

Подготовка Frontend​

В качестве фронта берется Nuxt 3 и ставится через официальную команду

yarn create nuxt-app frontend
Важный момент: я не буду использовать Nuxt с SSR, т.к. у меня планируется чисто SPA подход (когда браузер загружает целиком весь код к себе и дальше уже рендерит интерфейс).

Да, SSR классно и здорово, но считаю его уместным в проектах с необходимостью поддерживать SEO или если необходимо часть логики отображения скрыть от пользователя (чтобы не показывать какие‑то переменные окружения).

ba03ae3799c53203984f05802f881dfa.gif

В любом случае, при необходимости, данный стартовый набор можно «переобуть» на работу с SSR. Что бы выключить SSR режим надо в nuxt.config.ts указать ssr: false

Пара слов про Options и Composition​

Если вы давно знакомы с Vue, то вы должны знать, что раньше все компоненты можно было делать vue компоненты только через Options подход (создавать объект с полями data, computed и тд). Сейчас появился подход через setup функцию и мне до конца не ясны прелести этого подхода.

Я же остановился, пока что, на подходе через options и постепенно внедряю compose функции в небольших проектах. В текущем наборе я выбрал Composition подход, т.к. тут функционала почти нет и заодно можно попробовать.

Подключение NuxtPage​

Изначально в App.vue не проставлен NuxtPage компонент и, следовательно, маршрутизация через файлы в pages работать не будет. Поэтому необходимо App.Vue привести к следующему виду:

<template>
<div>
<NuxtPage />
</div>
</template>
После чего каждый файл в папке pages/ будет открываться по одноименной ссылке в браузере. Подробнее можно прочитать здесь.

Коннектор к API​

Для реализации бизнес‑логики во Vue 3 разработчиками можно использовать Composable функции. Раньше я всегда делал подобные вещи в виде отдельного плагина с подстановкой хедера авторизации + указания baseUrl из env переменной.

Сейчас я сделаю по‑современному через создание своей composable функции, расширяющей useFetch. В Nuxt composables создаются автоматически, создав файл в папке composables.

// frontend/composables/api.ts
import { UseFetchOptions } from '#app';
import { NitroFetchRequest } from 'nitropack';
import { KeyOfRes } from 'nuxt/dist/app/composables/asyncData';

export function useApiRequest<T>(
request: NitroFetchRequest,
opts?:
| UseFetchOptions<T extends void ? unknown : T,
(res: T extends void ? unknown : T) => T extends void ? unknown : T,
KeyOfRes<(res: T extends void ? unknown : T) => T extends void ? unknown : T>>
| undefined
) {
const config = useRuntimeConfig();

return useFetch(request, {baseURL: config.public.baseURL, ...opts});
}
Чтобы конструкция config.public.baseURL работала, необходимо расширить nuxt.config.ts следующим образом:

export default defineNuxtConfig({
ssr: false,
runtimeConfig: {
public: {
baseURL: process.env.API_URL || 'http://localhost:3001/',
},
},
})

И теперь, по умолчанию, baseURL будет равен http://localhost:3001/, чтобы, при разработке, стучаться в отдельно запущенный Nest. При сборке буду менять его на /api.

Пример использования API вызова​

В качестве примера я оставил в компонент, который делает вызов в /api/test и проставляет в разметку все состояния запроса:

<template>
<div>
<template v-if="pending">
Loading
</template>
<template v-else>
<template v-if="data">
Api result: {{ data }}
</template>
<template v-else-if="error">
Api ERROR: {{ error }}
</template>
<button @click="refresh()">refresh</button>
</template>
</div>
</template>

<script setup>
import { useApiRequest } from '../composables/api'

const { data, pending, error, refresh } = useApiRequest('/api/test')
</script>

Подготовка Docker-образа и docker-compose.yml​

В своем личном блоге я писал и снимал про это видео, что небольшие проекты я разворачиваю достаточно топорным способом:

  • подготовить docker образ;
  • подготовить docker-compose;
  • развернуть на сервере nginx-proxy c acme-companion;
  • запускать проект обычным docker-compose up -d и наслаждаться рабочим продуктом.
Да, это конечно не Kubernetes и не супер отказоустойчивая архитектура. Но такой подход позволяет на VPS на 400р в месяц запустить десяток подобных проектов для личного использования.

Основная идея сборки состоит из следущих этапов:

  1. Собрать frontend (html, js, css).
  2. Собрать backend.
  3. Подсунуть в backend файлы из frontend в папку static.
  4. Собрать nginx образ, который будет разбирать траффик на статику и логику.

Dockerfile с multi-stage build​

В проекте я использую node 16 на базе образа alpine. Поэтому начинаем Dockerfile со строчек

FROM node:16-alpine as base-builder
WORKDIR /app
Для начала нужно собрать frontend — подтянуть зависимости, собрать html, js, css.

FROM base-builder as build_fe
WORKDIR /app
COPY ./frontend/package.json ./frontend/yarn.lock* ./
RUN yarn install
ADD ./frontend ./
RUN yarn generate
По итогу в этом промежуточном образе у нас будет собранный frontend в папке /app/dist

Далее собираем backend

FROM base-builder as build_be
WORKDIR /app
COPY ./backend/package.json ./backend/yarn.lock* ./
RUN yarn install
ADD ./backend ./
RUN yarn build
И получаем промежуточный образ только с backend. Теперь осталось собрать воедино в следующий промежуточный образ, который будет на 3001 порту слушать все запросы:

FROM node:16-alpine as finalNode
WORKDIR /app
COPY --from=build_be /app /app
COPY --from=build_fe /app/dist /app/static
CMD yarn start
Я до конца не определился в необходимости этого этапа и, честно говоря, его можно и не делать. У нас, в итоге, получается backend, который умеет также отдавать статику приложения — то есть полностью самостоятельно рабочий docker‑образ с приложением, который может работать без nginx. Но именно в рамках текущей статьи эта возможность не используется

Теперь осталось собрать ту часть, которая будет с nginx:

FROM nginx:alpine as finalNginx
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=finalNode /app/static .
COPY ./docker/nginx/conf.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]
Также надо не забыть положить файл конфигурации nginx по указанном пути:

# docker/nginx/conf.conf
server {
listen 80 default_server;
root /usr/share/nginx/html;

client_max_body_size 20M;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

location /api {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://node:3001;
}
}
Теперь мы, в рамках одного Dockerfile получили полную сборку всего, что нужно для работы приложения.

Итоговый Dockerfile можно посмотреть в github репозитории.

Подготовка docker-compose.yml​

Как я писал выше, я запускаю подобные проекты на сервере, используя nginx-proxy. Так что, первым делом, в конце файла надо объявить сеть reverse-proxy, через которую будет идти подключение из внешнего мира к моему контейнеру с nginx.

version: "3.8"
services:
# тут будут сервисы
networks:
reverse-proxy:
external:
name: reverse-proxy
back:
driver: bridge
Также я добавил сеть back — это изолированная сеть, через которую между собой будут общаться nginx и backend.

Теперь опишем как мы будем запускать наш образ с той частью, которая отвечает за backend:

node:
build:
context: .
target: finalNode
networks:
- back
expose:
- 3001
restart: always
environment:
- APP_PORT=3001
healthcheck:
test: wget --no-verbose --tries=1 --spider <http://localhost:3001> || exit 1
timeout: 3s
interval: 3s
retries: 10

По порядку о каждом параметре:

  • build
    • context — что будет являться текущей директорией при сборке Dockerfile;
    • target — какую часть multi‑stage build нужно запускать в этом месте. В данном случае мы указываем, что собирать нужно все до finalNode.
  • networks
    • тут мы указываем только back, т.к. во внешний мир контейнер ходить не будет и нужен только доступ от nginx к этому контейнеру.
  • expose
    • этот пункт открывает доступ другим контейнерам в сети по перечисленным портам. В данном случае мы сообщаем, что в сети back контейнеры могут подключаться на 3001 порт.
  • restart: always
    • сообщаем, что этот контейнер надо перезапускать всегда. Даже после перезапуска сервера проект будет запущен;
    • будет работать до тех пор пока не выключим его командой docker-compose down.
  • environment
    • передача переменных окружения в сам процесс node;
    • в нашем случае только указываем порт, на котором мы хотим, чтобы backend был запущен.
  • healthcheck
    • прекрасный инструмент для контроля работоспособности контейнера;
    • test — команда, от которой мы ожидаем exit-code = 0 (какие есть еще можно прочитать здесь);
    • timeout — время, которое может выполняться команда. Если команда зависла на больший срок, то проверка считается не пройденой;
    • interval — с какой частотой стоит выполнять команду, чтобы быть уверенным, что контейнер работает;
    • retries — после скольких неудачных ответов сервер помечается «нерабочим».
Теперь добавим блок с запуском nginx:

nginx:
build:
context: .
target: finalNginx
networks:
- reverse-proxy
- back
expose:
- 80
restart: always
depends_on:
node:
condition: service_healthy
environment:
- VIRTUAL_HOST=${DOMAIN}
- VIRTUAL_PORT=80
- LETSENCRYPT_HOST=${DOMAIN}
- LETSENCRYPT_EMAIL=test@test.ru
Подробнее:

  • build тоже самое, что в node сервисе, только указан другой target, т.к. нам нужно получить ту часть, которая связана с nginx;
  • networks тут теперь 2 сети:
    • reverse-proxy сеть, через которую будет доступ от контейнера nginx-proxy;
    • back та сеть, в которой есть контейнер node чтобы можно было пересылать запросы ему.
  • expose сообщаем всем в сетях, что в этот контейнер можно стучаться на 80 порт. Это нужно nginx-proxy для обработки запросов;
  • restart аналогично сервису node;
  • depends_on тут мы указываем от каких сервисов мы зависим:
    • если это не указать, то nginx будет запускаться вместе с остальными и может получиться ситуация, в которой node еще не запущен, а nginx уже готов принимать запросы, что нехорошо;
    • поэтому мы указываем, что зависит от node сервиса;
    • зависимость можно считать удовлетворенной только когда сервис прошел свой healthcheck (как раз блок condition).
  • environment
    • тут мы указываем переменные окружения, которые нужны для работы nginx-proxy:
      • VIRTUAL_HOST название домена доступа к приложению;
      • VIRTUAL_PORT порт, на котором запущено приложение в контейнере;
      • LETSENCRYPT_HOST тот же самый домен но уже для создания https сертификата;
      • LETSENCRYPT_EMAIL электронная почта, куда писать о том, что скоро сертификат будет просрочен.
    • тут используется внешняя переменная окружения ${DOMAIN} и она будет записаться из файла .env который будет лежать рядом с docker-compose.yml файлом (подробнее тут).
Конечный вариант файла также находится в github репозитории.

Дополнительные моменты в подготовке окружения​

В корне проекта я создал файл .dockerignore чтобы, во время сборки, не перекачивать в контекст лишнего:

#.dockerignore
.idea
.git
**/.nuxt
**/dist
**/.output
**/node_modules
**/.env
Также создал .env.example в качестве файла-примера:

DOMAIN=domain.ru

Запуск приложения​

Подготовка сервера​

Разумеется на сервере должен уже стоять Docker. Если нет, то установите его по официальной инструкции.

Далее необходимо на сервере запустить nginx-proxy и лучше это делать в отдельном месте на том‑же сервере (инструкция здесь, но если нужно, то напишите в комментариях и дополню эту инструкцию здесь).

Запуск самого приложения​

Запускается все это приложение очень простым образом:

  1. Клонируем исходники.
  2. Прописываем DOMAIN в .env файл в корне проекта.
  3. Запускаем командой docker-compose up -d.
Одной командой этот запуск можно сделать следующей командой:

DOMAIN=domain.ru && echo DOMAIN=$DOMAIN > .env && docker-compose up -d --build
Важно: заменить domain.ru на свой домен, который уже направлен на сервер, где мы запускаем сервис.

Обновление версии приложения​

Если нужно обновить исходники до последней версии, то можно выполнить следующую команду:

git fetch && git reset --hard origin/master && docker-compose up -d --build
И проект обновится и запустит обновленную версию на домене.

Небольшое заключение​

В конечном итоге получился вариант шаблона приложения на Nuxt + Nest который дальше можно расширять. Он крайне пуст — нет БД, авторизации и прочих базовых вещей. Разумеется в наших проектах есть разные шаблоны приложений, но я решил начать с описания самого базового варианта, который дальше можно развивать куда угодно.

Если подобный формат полезен и интересен для дальнейшего описания, то в следующих статьях опишу подобный стартовый набор с базой данных (Postgres) и авторизацией (JWT). Также есть мысль описать процесс подготовки и настройки ansible для подобных проектов.

 
Сверху