Docker: заметки веб-разработчика. Итерация третья

от автора

Привет, друзья! Продолжаю делиться с вами заметками о Docker.

Заметки состоят из 4 частей: 2 теоретических и 2 практических. Если быть более конкретным:

В этой части мы разработаем простое приложение, состоящее из трех сервисов и базы данных, а в заключительной — «контейнеризуем» его.

Репозиторий с кодом приложения.

Если вам это интересно, прошу под кат.

Подготовка и настройка проекта

Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвященные работе с 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

  • expressNode.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!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/650969/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *