Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Решил описать свой подход построения окружения на 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.
Благодарю за внимание.