Веб-приложение Fullstack JavaScript: Nextjs и Docker
Существует множество технологий, но мы постараемся сделать пример как можно более простым, чтобы сделать его понятным.
Мы будем использовать подход "снизу вверх", начиная с базы данных и заканчивая интерфейсом.
Архитектура
Прежде чем мы начнем, вот простая схема, объясняющая архитектуру приложения.
Frontend - это Next.js приложение с TypeScript и Tailwind CSS. Backend - это Node.js приложение с Express и Prisma в качестве ORM. Database - PostgreSQL. Мы будем использовать Docker для запуска базы данных, серверной части, а также внешнего интерфейса (вы также можете использовать Vercel). Мы будем использовать Docker Compose для совместной работы интерфейса, серверной части и базы данных.
Необходимые требования
- Базовые знания о том, что такое интерфейс, серверная часть, API и база данных
- Docker установлен на вашем компьютере
- Node.js установлено на вашем компьютере
- Postman или любой другой инструмент для выполнения HTTP-запросов (необязательно)
1. Подготовка
Создайте любую папку, которую вы хотите, а затем откройте ее с помощью вашего любимого редактора кода.
mkdir <YOUR_FOLDER>
cd <YOUR_FOLDER>
code .
Инициализируйте репозиторий git.
git init
touch .gitignore
Заполните .gitignore
файл следующим содержимым:
*node_modules
Создайте файл с именем compose.yaml
в корневом каталоге проекта.
touch compose.yaml
Ваши проекты должны выглядеть следующим образом:
Мы готовы создать приложение fullstack и создавать его снизу вверх, начиная с базы данных.
После каждого шага мы будем тестировать текущее состояние приложения, чтобы убедиться, что все работает должным образом.
2. База данных
Мы будем использовать Postgres, но не устанавливать его на нашем компьютере. Вместо этого мы будем использовать Docker для запуска в контейнере. Таким образом, мы можем легко запускать и останавливать базу данных, не устанавливая ее на нашем компьютере.
Откройте файл compose.yaml
и добавьте следующее содержимое:
version: '3.9'
services:
db:
container_name: db
image: postgres:12
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
Затем введите в своем терминале:
docker compose up -d
Это приведет к извлечению образа Postgres из Docker Hub и запуску контейнера. Флаг -d
означает, что контейнер будет работать в отключенном режиме, поэтому мы можем продолжать использовать терминал.
Проверьте, запущен ли контейнер:
docker ps -a
Шаг в контейнер db:
docker exec -it db psql -U postgres
Теперь, находясь в контейнере Postgres, вы можете ввести:
\l
\dt
И вы не должны увидеть никаких связей.
Теперь вы можете выйти из контейнера с помощью exit
команды.
3. Серверная часть
Первый шаг сделан. Теперь мы создадим серверную часть. Мы будем использовать Node.js и Express. Мы также будем использовать Prisma для взаимодействия с базой данных.
Создайте папку с именем backend
в корневом каталоге проекта.
mkdir backend
Затем откройте папку в терминале и инициализация проекта Node.js:
cd backend
npm init -y
Установите зависимости:
- express: для создания сервера
- prisma: для взаимодействия с базой данных
- @prisma/client: для генерации кода для взаимодействия с базой данных
npm i express prisma @prisma/client
Инициализируем проект prisma:
npx prisma init
Это инициализирует проект prism. Мы будем использовать Prisma для взаимодействия с базой данных. Prisma сгенерирует код для взаимодействия с базой данных, поэтому нам не придется писать его самим.
Откройте файл с именем .env
и замените содержимое следующим:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"
В качестве модели для нашего проекта мы будем использовать "Пользователя" с идентификатором, именем и электронной почтой. Идентификатор будет автоматически вводимым целым числом, имя будет строкой, и адрес электронной почты также будет строкой.
Откройте файл /prisma/schema.prisma
и замените содержимое следующим:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
//User with id as int autoincrement, name as string, email as string
model User {
id Int @id @default(autoincrement())
name String
email String
}
Файл schema.prisma
file должен выглядеть следующим образом:
Теперь создайте файл с именем index.js
внутри /backend
папки и добавьте следующее содержимое:
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const app = express();
//use json
app.use(express.json());
//cors
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
//test api with error handling
app.get('/test', (req, res, next) => {
try {
res.status(200).json({ message: 'Success!' });
} catch (err) {
next(err);
}
});
//get all users
app.get('/users', async (req, res, next) => {
try {
const users = await prisma.user.findMany();
res.status(200).json(users);
} catch (err) {
next(err);
}
});
//get user by id
app.get('/users/:id', async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: Number(req.params.id) },
});
res.status(200).json(user);
} catch (err) {
next(err);
}
});
//create user
app.post('/users', async (req, res, next) => {
try {
const user = await prisma.user.create({
data: { ...req.body },
});
res.status(201).json(user);
} catch (err) {
next(err);
}
});
//update user
app.put('/users/:id', async (req, res, next) => {
try {
const user = await prisma.user.update({
where: { id: Number(req.params.id) },
data: { ...req.body },
});
res.status(200).json(user);
} catch (err) {
next(err);
}
});
//delete user
app.delete('/users/:id', async (req, res, next) => {
try {
const user = await prisma.user.delete({
where: { id: Number(req.params.id) },
});
res.status(200).json(user);
} catch (err) {
next(err);
}
});
//Start server
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Теперь вы можете сгенерировать схему Prisma:
npx prisma generate
Прежде чем мы настроим серверную часть, давайте протестируем ее. Введите в вашем терминале:
node index.js
Откройте ваш браузер и перейдите на http://localhost:4000/test
. Вы должны увидеть сообщение Success!
.
Но если мы продолжим localhost:4000/users
, мы действительно увидим ошибку! Это потому, что у нас еще нет схемы в нашей базе данных:
Мы можем приступить к части докеризации, и мы решим эту проблему позже.
Доработайте серверную часть
Давайте создадим 2 файла с именами .dockerignore
и backend.dockerfile
в backend
папке.
Откройте файл .dockerignore
и добавьте следующее содержимое:
**/node_modules
Откройте файл backend.dockerfile
и добавьте следующее содержимое:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
EXPOSE 4000
CMD ["node", "index.js"]
Давайте обновим compose.yaml
файл в корневом каталоге проекта, добавив backend
сервис.
Ниже представлена обновленная версия:
version: '3.9'
services:
backend:
container_name: backend
image: backend
build:
context: ./backend
dockerfile: backend.dockerfile
ports:
- "4000:4000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres?schema=public
depends_on:
- db
db:
container_name: db
image: postgres:12
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
Создание образа серверной части:
docker compose build
Запустите серверный контейнер:
docker compose up -d backend
Проверьте, запущен ли контейнер:
docker ps -a
Кое-что интересное, прежде чем мы продолжим или сделаем какие-либо http-запросы.
docker exec -it db psql -U postgres
\dt
И мы не должны видеть никаких связей.
Теперь введите:
docker exec -it backend npx prisma migrate dev --name init
docker exec -it db psql -U postgres
\dt
И мы должны увидеть таблицу User
в базе данных. Конечно, она все еще пустая, но Prisma создала ее для нас. Круто!
Добавлять пользователей можно тремя различными способами
Теперь давайте создадим 3 разных пользователя, используя 3 разных способа.
- 1 пользователь, использующий Prisma Studio
- 1 пользователь, использующий Postman
- 1 пользователь, использующий psql
Создайте пользователя с помощью Prism Studio
Откройте новый терминал и введите:
npx prisma studio
Это откроет Prisma Studio в вашем браузере по адресу http://localhost:5555
(Примечание: мы не используем Docker для запуска Prisma Studio, мы запускаем его непосредственно на нашем компьютере).
Добавьте запись: userfromprisma
и userfromprismamail
Нажмите Save 1 change
. Вы можете оставить Prisma Studio открытой во вкладке, мы воспользуемся ею позже.
Теперь давайте добавим еще одного пользователя с помощью Postman (или любого другого инструмента, который вам нравится).
Если мы отправим http-запрос к http://localhost:4000/users
, мы должны увидеть 1 пользователя (того, которого мы только что создали с помощью Prisma Studio).
{
"name": "userfrompostman",
"email": "userfrompostmanmail"
}
Если мы проверим localhost:4000/users
, то сейчас должны увидеть 2 пользователя:
Мы также можем еще раз проверить согласованность наших операций в Prisma Studio.
Вставить пользователя из psql
Давайте вставим другого пользователя, используя psql.
docker exec -it db psql -U postgres
\dt
Давайте вставим нового пользователя вручную, используя psql (не делайте этого в процессе производства, это просто для тестирования!)
insert into "User" (name, email) values ('frompsql', 'userfrompsqlmail');
select * from "User";
Давайте еще раз проверим Prisma Studio, и теперь мы должны увидеть 3 пользователя:
4. Интерфейс
Теперь, когда у нас запущена серверная часть, мы можем приступить к интерфейсу. Мы будем использовать Next.js 14 с TypeScript и Tailwind.
Из корневой папки проекта,
cd ..
И из корневой папки проекта запустите эту команду:
npx create-next-app@latest --no-git
Мы используем флаг --no-git
, потому что мы уже инициализировали репозиторий git в корневом каталоге проекта.
В качестве опций:
- Как называется ваш проект?
frontend
- TypeScript?
Yes
- ESLint?
Yes
- Tailwind CSS?
Yes
- Используете структуру каталогов по умолчанию?
Yes
- Маршрутизатор приложений?
No
(не требуется для этого проекта) - Настроить псевдоним импорта по умолчанию?
No
Это должно создать новый проект Next.js примерно за одну минуту.
Перейдите в папку frontend:
cd frontend
Установите Axios, мы будем использовать его для выполнения HTTP-запросов (обязательно в frontend
папке):
npm i axios
Прежде чем мы продолжим, попробуйте запустить проект:
npm run dev
И откройте свой браузер по адресу http://localhost:3000
. Вы должны увидеть страницу по умолчанию Next.js.
Измените файл styles/global.css
В src/frontend/src/styles/globals.css
файле замените содержимое на это (чтобы избежать некоторых проблем с Tailwind):
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
Создайте новый компонент
В /frontend/src
папке создайте новую папку с именем components
, а внутри нее создайте новый файл с именем CardComponent.tsx
и добавьте следующее содержимое:
import React from 'react';
interface Card {
id: number;
name: string;
email: string;
}
const CardComponent: React.FC<{ card: Card }> = ({ card }) => {
return (
<div className="bg-white shadow-lg rounded-lg p-2 mb-2 hover:bg-gray-100">
<div className="text-sm text-gray-600">ID: {card.id}</div>
<div className="text-lg font-semibold text-gray-800">{card.name}</div>
<div className="text-md text-gray-700">{card.email}</div>
</div>
);
};
export default CardComponent;
Файл должен выглядеть следующим образом:
Теперь откройте src/frontend/src/pages/index.tsx
файл и замените содержимое следующим:
Заполните файл index.tsx
Откройте файл с именем index.tsx
в frontend/src/pages
папке и замените содержимое следующим:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import CardComponent from '../components/CardComponent';
interface User {
id: number;
name: string;
email: string;
}
export default function Home() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const [users, setUsers] = useState<User[]>([]);
const [newUser, setNewUser] = useState({ name: '', email: '' });
const [updateUser, setUpdateUser] = useState({ id: '', name: '', email: '' });
// Fetch users
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${apiUrl}/users`);
setUsers(response.data.reverse());
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, []);
// Create a user
const createUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const response = await axios.post(`${apiUrl}/users`, newUser);
setUsers([response.data, ...users]);
setNewUser({ name: '', email: '' });
} catch (error) {
console.error('Error creating user:', error);
}
};
// Update a user
const handleUpdateUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await axios.put(`${apiUrl}/users/${updateUser.id}`, { name: updateUser.name, email: updateUser.email });
setUpdateUser({ id: '', name: '', email: '' });
setUsers(
users.map((user) => {
if (user.id === parseInt(updateUser.id)) {
return { ...user, name: updateUser.name, email: updateUser.email };
}
return user;
})
);
} catch (error) {
console.error('Error updating user:', error);
}
};
// Delete a user
const deleteUser = async (userId: number) => {
try {
await axios.delete(`${apiUrl}/users/${userId}`);
setUsers(users.filter((user) => user.id !== userId));
} catch (error) {
console.error('Error deleting user:', error);
}
};
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4 bg-gray-100">
<div className="space-y-4 w-full max-w-2xl">
<h1 className="text-2xl font-bold text-gray-800 text-center">User Management App</h1>
{/* Form to add new user */}
<form onSubmit={createUser} className="p-4 bg-blue-100 rounded shadow">
<input
placeholder="Name"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
className="mb-2 w-full p-2 border border-gray-300 rounded"
/>
<input
placeholder="Email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="mb-2 w-full p-2 border border-gray-300 rounded"
/>
<button type="submit" className="w-full p-2 text-white bg-blue-500 rounded hover:bg-blue-600">
Add User
</button>
</form>
{/* Form to update user */}
<form onSubmit={handleUpdateUser} className="p-4 bg-green-100 rounded shadow">
<input
placeholder="User ID"
value={updateUser.id}
onChange={(e) => setUpdateUser({ ...updateUser, id: e.target.value })}
className="mb-2 w-full p-2 border border-gray-300 rounded"
/>
<input
placeholder="New Name"
value={updateUser.name}
onChange={(e) => setUpdateUser({ ...updateUser, name: e.target.value })}
className="mb-2 w-full p-2 border border-gray-300 rounded"
/>
<input
placeholder="New Email"
value={updateUser.email}
onChange={(e) => setUpdateUser({ ...updateUser, email: e.target.value })}
className="mb-2 w-full p-2 border border-gray-300 rounded"
/>
<button type="submit" className="w-full p-2 text-white bg-green-500 rounded hover:bg-green-600">
Update User
</button>
</form>
{/* Display users */}
<div className="space-y-2">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between bg-white p-4 rounded-lg shadow">
<CardComponent card={user} />
<button onClick={() => deleteUser(user.id)} className="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded">
Delete User
</button>
</div>
))}
</div>
</div>
</main>
);
}
Мы можем создать другого пользователя прямо из пользовательского интерфейса, добавив имя и адрес электронной почты.
Мы также можем обновить пользователя непосредственно из пользовательского интерфейса, добавив идентификатор пользователя, новое имя и новый адрес электронной почты.
Если мы проверим localhost:4000/users
, мы сможем увидеть изменения, внесенные нами с помощью пользовательского интерфейса:
Вы также можете удалить пользователя прямо из пользовательского интерфейса:
Теперь остановите Next.js приложение, нажав Ctrl-C или Cmd-c, чтобы мы оставили порт 3000 доступным для докеризованного Next.js приложения.
Доработайте интерфейс
Развертывание Next.js приложения с помощью Docker.
Измените next.config.js
файл в frontend
папке, заменив его следующим содержимым:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone'
}
module.exports = nextConfig
Создайте файл с именем .dockerignore
в frontend
папке и добавьте следующее содержимое:
**/node_modules
Создайте файл с именем frontend.dockerfile
в frontend
папке и добавьте следующее содержимое (оно взято непосредственно из официального примера docker от vercel).
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build && ls -l /app/.next
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
Теперь давайте обновим docker-compose.yml
файл в корневом каталоге проекта, добавив frontend
сервис.
Ниже представлена обновленная версия:
version: '3.9'
services:
frontend:
container_name: frontend
image: frontend
build:
context: ./frontend
dockerfile: frontend.dockerfile
ports:
- '3000:3000'
environment:
- NEXT_PUBLIC_API_URL=http://localhost:4000
restart: always
depends_on:
- backend
backend:
container_name: backend
image: backend
build:
context: ./backend
dockerfile: backend.dockerfile
ports:
- '4000:4000'
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres?schema=public
depends_on:
- db
db:
container_name: db
image: postgres:12
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgresD
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
Создание образа интерфейса:
docker compose build
Запустите интерфейсный контейнер:
docker compose up -d frontend
5. Финальный тест
Сначала давайте проверим, запущены ли все 3 контейнера:
docker ps -a
Теперь откройте свой браузер по адресу http://localhost:3000
. Вы должны увидеть запущенное приложение, но на этот раз мы запускаем его с помощью Docker.
Вы можете, например, создать нового пользователя прямо из пользовательского интерфейса:
Заключение
Мы создали приложение с полным стеком, используя Docker для запуска базы данных, серверной части и интерфейса. Мы также использовали Docker Compose для совместной работы базы данных, серверной части и интерфейса.
Мы использовали множество технологий, но на нашем компьютере ничего не устанавливали, кроме Docker и Node.js.
- Next.js 14 (TypeScript)
- Tailwind CSS
- Node.js
- Express (JavaScript)
- Prisma
- PostgreSQL
- Docker
- Docker Compose
Весь код доступен бесплатно на GitHub.