Решил описать свой подход построения окружения на Typescript с Nest на бекенде, Nuxt (SPA) на фронтенде. Все заворачивается в один docker‑образ и запускается как standalone приложение c nginx, healthcheck»ами, тестами и ш…широкой сферой применения.
Делал это в качестве фундамента для будущих проектов или с целью изучения 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 типа запроса.
Статический:
И запрос к API:
Подготовка 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 приложения глобальный префикс
Я в своем шаблоне использую второй способ. Для его реализации нужно сделать следующее:
Прописать в
main.ts
строчкуapp.setGlobalPrefix('api');
Поправить 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 или если необходимо часть логики отображения скрыть от пользователя (чтобы не показывать какие‑то переменные окружения).
В любом случае, при необходимости, данный стартовый набор можно «переобуть» на работу с 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р в месяц запустить десяток подобных проектов для личного использования.
Основная идея сборки состоит из следущих этапов:
Собрать
frontend
(html, js, css).Собрать
backend
.Подсунуть в
backend
файлы из frontend в папкуstatic
.Собрать 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
и лучше это делать в отдельном месте на том‑же сервере (инструкция здесь, но если нужно, то напишите в комментариях и дополню эту инструкцию здесь).
Запуск самого приложения
Запускается все это приложение очень простым образом:
Клонируем исходники.
Прописываем
DOMAIN
в.env
файл в корне проекта.Запускаем командой
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 для подобных проектов.
Также в своем личном блоге в рубрике разработка пишу разные обучающие статьи и делюсь опытом на своем Youtube канале и Telegram.
Благодарю за внимание.
Black_Yuzia
Я бы сказал что вместо express стоит использовать fastify. По умолчанию. Все таки по бенчмаркам он продуктивнее.
amorev Автор
не работал еще с
fastify
.express
достаточно пока было.