Привет, друзья! Продолжаю делиться с вами заметками о Docker
.
Заметки состоят из 4 частей: 2 теоретических и 2 практических. Если быть более конкретным:
- первая часть посвящена
Docker
,Docker CLI
иDockerfile
; - во второй части рассказывается о
Docker Compose
.
В этой части мы разработаем простое приложение, состоящее из трех сервисов и базы данных, а в заключительной — "контейнеризуем" его.
Репозиторий с кодом приложения.
Если вам это интересно, прошу под кат.
Подготовка и настройка проекта
Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвященные работе с Docker
. Впрочем, в этой части Docker
будет совсем чуть-чуть.
Также предполагается, что на вашей машине установлен Docker
и Node.js
.
Хорошо, если на вашей машине установлен Yarn
и вы имеете опыт работы с React
, Vue
, Node.js
, PostgreSQL
и sh или bash
(все это опционально).
Как я сказал, наше приложение будет состоять из трех сервисов:
- клиента на
React.js
; - админки на
Vue.js
; - сервера (API) на
Node.js
.
В качестве базы данных мы будем использовать PostgreSQL
, а для взаимодействия с ней — Prisma.
Функционал нашего приложения будет следующим:
- в админке задаются настройки для приветствия, темы и базового размера шрифта;
- эти настройки записываются в БД и применяются на клиенте;
- на клиенте реализована "тудушка";
- задачи записываются в БД;
- все это обслуживается сервером.
Создаем директорию для проекта, переходим в нее и создаем еще парочку директорий:
mkdir docker-test
cd !$ # docker-test
mkdir services sh uploads
В директории services
будут находиться наши сервисы, в директории sh
— скрипты для терминала, директорию uploads
мы использовать не будем, но обычно в ней хранятся различные файлы, загружаемые админом или пользователями.
Переходим в директорию services
, создаем директорию для API, генерируем шаблон клиента с помощью Create React App
и шаблон админки с помощью Vue CLI
:
cd services
mkdir api
yarn create react-app client
# or
npx create-react-app client
yarn create vue-app admin
# or
npx vue create admin
Начнем с API.
API
Переходим в директорию api
, инициализируем Node.js-проект
и устанавливаем зависимости:
cd api
yarn init -yp
# or
npm init -y
# производственные зависимости
yarn add express cors
# зависимости для разработки
yarn add -D nodemon prisma
- express —
Node.js-фреймворк
для разработки веб-серверов; - cors — утилита для работы с CORS;
- nodemon — утилита для запуска сервера для разработки;
- prisma — ядро (core) ORM, которое мы будем использовать для взаимодействия с
postgres
.
Инициализируем prisma
:
npx prisma init
Это приводит к генерации директории prisma
, а также файлов prisma/schema.prisma
и .env
.
Определяем генератор, источник данных и модели в файле prisma/schema.prisma
:
// https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
// это нужно для контейнера
binaryTargets = ["native"]
}
datasource db {
provider = "postgresql"
// путь к БД извлекается из переменной среды окружения `DATABASE_URL`
url = env("DATABASE_URL")
}
// модель для настроек
model Settings {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
greetings String
theme String
base_font_size String
}
// модель для задачи
model Todo {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
text String
done Boolean
}
Определяем путь к БД в файле .env
:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb?schema=public
Здесь:
postgres
— имя пользователя и пароль;localhost
— хост, на котором запущен серверpostgres
;5432
— порт, на котором запущен серверpostgres
;mydb
— название БД.
Определяем команду для запуска контейнера postgres
в файле sh/db
(без расширения):
docker run --rm --name postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=mydb -dp 5432:5432 -v $HOME/docker/volumes/postgres:/var/lib/postgresql/data postgres
Обратите внимание: если вы работаете на Mac
, вам потребуется предоставить самому себе разрешение на выполнение кода из файла sh/db
. Это можно сделать так:
# мы находимся в директории `sh`
chmod +x db
# or
sudo chmod +x db
Находясь в корневой директории проекта, открываем терминал и выполняем команду:
sh/db
Происходит загрузка образа postgres
из Docker Hub
и запуск контейнера под названием postgres
.
Обратите внимание: иногда может возникнуть ошибка, связанная с тем, что порт 5432 занят другим процессом. В этом случае необходимо найти PID
данного процесса и "убить" его. На Mac
это делается так:
# получаем `PID` процесса, запущенного на порту `5432`
sudo lsof -i :5432
# предположим, что `PID` имеет значение `103`
# "убиваем" процесс
sudo kill 103
Убедиться в запуске контейнера можно, выполнив команду docker ps
:
Или запустив Docker Desktop
:
Или в разделе Individual Containers
расширения Docker
для VSCode
:
Выполняем миграцию:
# мы находимся в директории `api`
# migrate dev - миграция для разработки
# --name init - название миграции
npx prisma migrate dev --name init
Это приводит к генерации файла prisma/migrations/[Date]-init/migration.sql
, подключению к БД, созданию в ней таблиц, установке и настройке @prisma/client
.
Создаем файл prisma/seed.js
с кодом для заполнения БД начальными данными:
import Prisma from '@prisma/client'
const { PrismaClient } = Prisma
// инициализируем клиента
const prisma = new PrismaClient()
// начальные настройки
const initialSettings = {
greetings: 'Welcome to Docker Test App',
theme: 'light',
base_font_size: '16px'
}
// начальные задачи
const initialTodos = [
{
text: 'Eat',
done: true
},
{
text: 'Code',
done: true
},
{
text: 'Sleep',
done: false
},
{
text: 'Repeat',
done: false
}
]
async function main() {
try {
// если таблица настроек является пустой
if (!(await prisma.settings.findFirst())) {
await prisma.settings.create({ data: initialSettings })
}
// если таблица задач является пустой
if (!(await (await prisma.todo.findMany()).length)) {
await prisma.todo.createMany({ data: initialTodos })
}
console.log('Database has been successfully seeded ? ')
} catch (e) {
console.log(e)
} finally {
await prisma.$disconnect()
}
}
main()
В package.json
определяем тип кода сервера (модуль), команды для запуска сервера в режиме для разработки и производственном режиме, а также команду для заполнения БД начальными данными:
"type": "module",
"scripts": {
"dev": "nodemon",
"start": "prisma generate && prisma migrate deploy && node index.js"
},
"prisma": {
"seed": "node prisma/seed.js"
}
Заполняем БД начальными данными:
# мы находимся в директории `api`
npx prisma db seed
Открываем нашу БД в интерактивном режиме:
npx prisma studio
Это приводит к открытию вкладки браузера по адресу http://localhost:5555
:
Приступаем к разработке сервера.
Структура сервера будет следующей:
- routes
- index.js
- settings.routes.js - маршруты (роуты) для настроек
- todo.routes.js - роуты для задач
- index.js
Содержание файла index.js
:
// импортируем библиотеки и утилиты
import express from 'express'
import cors from 'cors'
import Prisma from '@prisma/client'
import apiRoutes from './routes/index.js'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const { PrismaClient } = Prisma
// создаем и экспортируем экземпляр `prisma`
export const prisma = new PrismaClient()
// путь к текущей директории
const __dirname = dirname(fileURLToPath(import.meta.url))
// создаем экземпляр приложения `express`
const app = express()
// отключаем `cors`
app.use(cors())
// включаем парсинг `json` в объекты
app.use(express.json())
// это пригодится нам при запуске приложения в производственном режиме
if (process.env.ENV === 'prod') {
// обратите внимание на пути
// путь к текущей директории + `client/build`
const clientBuildPath = join(__dirname, 'client', 'build')
// путь к текущей директории + `admin/dist`
const adminDistPath = join(__dirname, 'admin', 'dist')
// обслуживание статических файлов
// клиент будет доступен по пути сервера
app.use(express.static(clientBuildPath))
app.use(express.static(adminDistPath))
// админка будет доступна по пути сервера + `/admin`
app.use('/admin', (req, res) => {
res.sendFile(join(adminDistPath, decodeURIComponent(req.url)))
})
}
// роутинг
app.use('/api', apiRoutes)
// обработчик ошибок
app.use((err, req, res, next) => {
console.log(err)
const status = err.status || 500
const message = err.message || 'Something went wrong. Try again later'
res.status(status).json({ message })
})
// запускаем сервер на порту 5000
app.listen(5000, () => {
console.log(`Server ready ? `)
})
Рассмотрим роуты.
Начнем с роутера приложения (routes/index.js
):
import { Router } from 'express'
import todoRoutes from './todo.routes.js'
import settingsRoutes from './settings.routes.js'
const router = Router()
router.use('/todo', todoRoutes)
router.use('/settings', settingsRoutes)
export default router
Роутер для настроек (routes/settings.routes.js
):
import { Router } from 'express'
import { prisma } from '../index.js'
const router = Router()
// получение настроек
router.get('/', async (req, res, next) => {
try {
const settings = await prisma.settings.findFirst()
res.status(200).json(settings)
} catch (e) {
next(e)
}
})
// обновление настроек
router.put('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
const settings = await prisma.settings.update({
data: req.body,
where: { id }
})
res.status(201).json(settings)
} catch (e) {
next(e)
}
})
export default router
Роутер для задач (routes/todo.routes.js
):
import { Router } from 'express'
import { prisma } from '../index.js'
const router = Router()
// получение задач
router.get('/', async (req, res, next) => {
try {
const todos = (await prisma.todo.findMany()).sort(
(a, b) => a.created_at - b.created_at
)
res.status(200).json(todos)
} catch (e) {
next(e)
}
})
// создание задачи
router.post('/', async (req, res, next) => {
try {
const newTodo = await prisma.todo.create({
data: req.body
})
res.status(201).json(newTodo)
} catch (e) {
next(e)
}
})
// обновление задачи
router.put('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
const updatedTodo = await prisma.todo.update({
data: req.body,
where: { id }
})
res.status(201).json(updatedTodo)
} catch (e) {
next(e)
}
})
// удаление задачи
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
await prisma.todo.delete({
where: { id }
})
res.sendStatus(201)
} catch (e) {
next(e)
}
})
export default router
Это все, что требуется от нашего сервера.
Админка
Структура админки будет следующей (admin/src
):
- components
- App.vue - основной компонент приложения
- Settings.vue - компонент для обновления настроек
- index.js
Начнем с основного компонента (components/App.vue
).
Разметка:
<template>
<div id="app">
<h1>Admin</h1>
<!-- Загрузка -->
<h2 v-if="loading">Loading...</h2>
<!-- Ошибка -->
<h3 v-else-if="error" class="error">
{{ error.message || 'Something went wrong. Try again later' }}
</h3>
<!-- Компонент для обновления настроек -->
<div v-else>
<h2>Settings</h2>
<!-- Пропы: настройки, полученные от сервера (из БД), метод для их получения и адрес API -->
<Settings :settings="settings" :getSettings="getSettings" :apiUri="apiUri" />
</div>
</div>
</template>
Стили:
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');
:root {
--primary: #0275d8;
--success: #5cb85c;
--warning: #f0ad4e;
--danger: #d9534f;
--light: #f7f7f7;
--dark: #292b2c;
}
* {
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
}
body.light {
background-color: var(--light);
color: var(--dark);
}
body.dark {
background-color: var(--dark);
color: var(--light);
}
#app {
display: flex;
flex-direction: column;
text-align: center;
}
h2 {
font-size: 1.4rem;
}
form div {
display: flex;
flex-direction: column;
align-items: center;
}
label {
margin: 0.5rem 0;
}
input {
padding: 0.5rem;
max-width: 220px;
width: max-content;
outline: none;
border: 1px solid var(--dark);
border-radius: 4px;
text-align: center;
}
input:focus {
border-color: var(--primary);
}
button {
margin: 1rem 0;
padding: 0.5rem 1rem;
background: none;
border: none;
border-radius: 4px;
outline: none;
background-color: var(--success);
color: var(--light);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
cursor: pointer;
user-select: none;
transition: 0.2s;
}
button:active {
box-shadow: none;
}
.error {
color: var(--danger);
}
Скрипт:
// импортируем компонент для обновления настроек
import Settings from './Settings'
export default {
// название компонента
name: 'App',
// дочерние компоненты
components: {
Settings
},
// начальное состояние
data() {
return {
loading: true,
error: null,
settings: {},
apiUri: 'http://localhost:5000/api/settings'
}
},
// монтирование компонента
created() {
// получаем настройки
this.getSettings()
},
// методы
methods: {
// для получения настроек
async getSettings() {
this.loading = true
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
this.settings = await response.json()
} catch (e) {
this.error = e
} finally {
this.loading = false
}
}
}
}
Теперь рассмотрим компонент для обновления настроек (components/Settings.vue
).
Разметка:
<template>
<!-- Загрузка -->
<div v-if="loading">Loading...</div>
<!-- Ошибка -->
<div v-else-if="error">
{{ error.message || JSON.stringify(error, null, 2) }}
</div>
<!-- Настройки -->
<form v-else @submit.prevent="saveSettings">
<!-- Приветствие -->
<div>
<label for="greetings">Greetings</label>
<input
type="text"
id="greetings"
name="greetings"
:value="settings.greetings"
required
/>
</div>
<!-- Тема -->
<div>
<label for="theme">Theme</label>
<input
type="text"
id="theme"
name="theme"
:value="settings.theme"
required
/>
</div>
<!-- Базовый размер шрифта -->
<div>
<label for="base_font_size">Base font size</label>
<input
type="text"
id="base_font_size"
name="base_font_size"
:value="settings.base_font_size"
required
/>
</div>
<button>Save</button>
</form>
</template>
Скрипт:
export default {
// название компонента
name: 'Settings',
// пропы
props: {
settings: {
type: Object,
required: true
},
getSettings: {
type: Function,
required: true
},
apiUri: {
type: String,
required: true
}
},
// начальное состояние
data() {
return {
loading: false,
error: null
}
},
// методы
methods: {
// для обновления настроек в БД
async saveSettings(e) {
this.loading = true
const formDataObj = [...new FormData(e.target)].reduce(
(obj, [key, val]) => ({
...obj,
[key]: val
}),
{}
)
try {
const response = await fetch(`${this.apiUri}/${this.settings.id}`, {
method: 'PUT',
body: JSON.stringify(formDataObj),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
// получаем обновленные настройки
await this.getSettings()
} catch (e) {
this.error = e
} finally {
this.loading = false
}
}
}
}
На этом с админкой мы закончили.
Клиент
Структура клиента будет следующей (client/src
):
- api
- settings.api.js - API для настроек
- todo.api.js - API для задач
- components
- TodoForm.js - компонент для создания задачи
- TodoList.js - компонент для формирования списка задач
- hooks
- useStore.js - хранилище состояние в виде пользовательского хука
- App.js - основной компонент приложения
- App.css
- index.js
Для управления состоянием приложения мы будем использовать Zustand
.
Устанавливаем его:
yarn add zustand
Начнем с API для настроек (api/settings.api.js
):
// конечная точка
const API_URI = 'http://localhost:5000/api/settings'
// метод для получения настроек
const fetchSettings = async () => {
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
const settingsApi = { fetchSettings }
export default settingsApi
API для задач (api/todo.api.js
):
// конечная точка
const API_URI = 'http://localhost:5000/api/todo'
// метод для получения задач
const fetchTodos = async () => {
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для создания новой задачи
const addTodo = async (newTodo) => {
try {
const response = await fetch(API_URI, {
method: 'POST',
body: JSON.stringify(newTodo),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для обновления задачи
const updateTodo = async (id, changes) => {
try {
const response = await fetch(`${API_URI}/${id}`, {
method: 'PUT',
body: JSON.stringify(changes),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для удаления задачи
const removeTodo = async (id) => {
try {
const response = await fetch(`${API_URI}/${id}`, {
method: 'DELETE'
})
if (!response.ok) throw response
} catch (e) {
throw e
}
}
const todoApi = { fetchTodos, addTodo, updateTodo, removeTodo }
export default todoApi
Хранилище состояния в виде пользовательского хука (hooks/useStore.js
):
import create from 'zustand'
// API для настроек
import settingsApi from '../api/settings.api'
// API для задач
import todoApi from '../api/todo.api'
const useStore = create((set, get) => ({
// начальное состояние
settings: {},
todos: [],
loading: false,
error: null,
// методы для
// получения настроек
fetchSettings() {
set({ loading: true })
settingsApi
.fetchSettings()
.then((settings) => {
set({ settings })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// получения задач
fetchTodos() {
set({ loading: true })
todoApi
.fetchTodos()
.then((todos) => {
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// создания задачи
addTodo(newTodo) {
set({ loading: true })
todoApi
.addTodo(newTodo)
.then((newTodo) => {
const todos = [...get().todos, newTodo]
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// обновления задачи
updateTodo(id, changes) {
set({ loading: true })
todoApi
.updateTodo(id, changes)
.then((updatedTodo) => {
const todos = get().todos.map((todo) =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// удаления задачи
removeTodo(id) {
set({ loading: true })
todoApi
.removeTodo(id)
.then(() => {
const todos = get().todos.filter((todo) => todo.id !== id)
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
}
}))
export default useStore
Компонент для создания новой задачи (components/TodoForm.js
):
import { useState, useEffect } from 'react'
import useStore from '../hooks/useStore'
export default function TodoForm() {
// метод для создания задачи из хранилища
const addTodo = useStore(({ addTodo }) => addTodo)
// состояние для текста новой задачи
const [text, setText] = useState('')
const [disable, setDisable] = useState(true)
useEffect(() => {
setDisable(!text.trim())
}, [text])
// метод для обновления текста задачи
const onChange = ({ target: { value } }) => {
setText(value)
}
// метод для отправки формы
const onSubmit = (e) => {
e.preventDefault()
if (disable) return
const newTodo = {
text,
done: false
}
addTodo(newTodo)
}
return (
<form onSubmit={onSubmit}>
<label htmlFor='text'>New todo text</label>
<input type='text' id='text' value={text} onChange={onChange} />
<button className='add'>Add</button>
</form>
)
}
Компонент для формирования списка задач (components/TodoList.js
):
import useStore from '../hooks/useStore'
export default function TodoList() {
// задачи и методы для обновления и удаления задачи из хранилища
const { todos, updateTodo, removeTodo } = useStore(
({ todos, updateTodo, removeTodo }) => ({ todos, updateTodo, removeTodo })
)
return (
<ul>
{todos.map(({ id, text, done }) => (
<li key={id}>
<input
type='checkbox'
checked={done}
onChange={() => {
updateTodo(id, { done: !done })
}}
/>
<span>{text}</span>
<button
onClick={() => {
removeTodo(id)
}}
>
Remove
</button>
</li>
))}
</ul>
)
}
Основной компонент приложения (App.js
):
import { useEffect } from 'react'
import './App.css'
import useStore from './hooks/useStore'
import TodoForm from './components/TodoForm'
import TodoList from './components/TodoList'
// получаем настройки
useStore.getState().fetchSettings()
// получаем задачи
useStore.getState().fetchTodos()
function App() {
// настройки, индикатор загрузки и ошибка из хранилища
const { settings, loading, error } = useStore(
({ settings, loading, error }) => ({ settings, loading, error })
)
useEffect(() => {
if (Object.keys(settings).length) {
// применяем базовый размер шрифта к элементу `html`
document.documentElement.style.fontSize = settings.base_font_size
// применяем тему
document.body.className = settings.theme
}
}, [settings])
// загрузка
if (loading) return <h2>Loading...</h2>
// ошибка
if (error)
return (
<h3 className='error'>
{error.message || 'Something went wrong. Try again later'}
</h3>
)
return (
<div className='App'>
<h1>Client</h1>
<h2>{settings.greetings}</h2>
<TodoForm />
<TodoList />
</div>
)
}
export default App
Стили (App.css
):
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');
:root {
--primary: #0275d8;
--success: #5cb85c;
--warning: #f0ad4e;
--danger: #d9534f;
--light: #f7f7f7;
--dark: #292b2c;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
}
/* Тема */
body.light {
background-color: var(--light);
color: var(--dark);
}
body.dark {
background-color: var(--dark);
color: var(--light);
}
/* --- */
#root {
padding: 1rem;
display: flex;
justify-content: center;
}
.App {
display: flex;
flex-direction: column;
align-items: center;
}
h1,
h2 {
margin: 1rem 0;
}
h1 {
font-size: 1.6rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.2rem;
}
label {
margin-bottom: 0.5rem;
display: block;
}
form {
margin: 1rem 0;
}
form input {
padding: 0.5rem;
max-width: 220px;
width: max-content;
outline: none;
border: 1px solid var(--dark);
border-radius: 4px;
text-align: center;
}
form input:focus {
border-color: var(--primary);
}
ul {
list-style: none;
}
li {
margin: 0.75rem 0;
display: flex;
align-items: center;
justify-content: space-between;
}
li input {
width: 18px;
height: 18px;
}
li span {
display: block;
width: 120px;
word-break: break-all;
}
button {
padding: 0.5rem 1rem;
background: none;
border: none;
border-radius: 4px;
outline: none;
background-color: var(--danger);
color: var(--light);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
cursor: pointer;
user-select: none;
transition: 0.2s;
}
button:active {
box-shadow: none;
}
button.add {
background-color: var(--success);
}
.error {
color: var(--danger);
}
.App {
text-align: center;
}
Поскольку отступы и размеры заданы с помощью rem, мы легко можем манипулировать этими значениями, меняя размер шрифта элемента html
.
На этом с клиентом мы также закончили.
Проверка работоспособности приложения
Поднимаемся в корневую директорию (docker-test
), инициализируем Node.js-проект
и устанавливаем concurrently
— утилиту для одновременного выполнения команд, определенных в файле package.json
:
# мы находимся в директории `docker-test`
yarn init -yp
yarn add concurrently
Определяем команды для запуска серверов для разработки в package.json
:
"scripts": {
"dev:client": "yarn --cwd services/client start",
"dev:admin": "yarn --cwd services/admin dev",
"dev:api": "yarn --cwd services/api dev",
"dev": "concurrently \"yarn dev:client\" \"yarn dev:admin\" \"yarn dev:api\""
}
Выполняем команду yarn dev
или npm run dev
.
Это приводит к запуску 3 серверов для разработки:
- для клиента по адресу
http://localhost:3000
:
- для админки по адресу
http://localhost:4000
":
- для сервера по адресу
http://localhost:5000
.
Меняем настройки в админке:
Перезагружаем клиента:
Видим, что настройки успешно применились.
Работаем с задачами:
Задачи успешно создаются/обновляются/удаляются и сохраняются в БД.
Отлично. Приложение работает, как ожидается.
Пожалуй, это все, о чем я хотел вам рассказать в этой части.
Благодарю за внимание и happy coding!