Aplikacja fullstack: Frontend + Backend w kontenerach

Aplikacja fullstack: Frontend + Backend w kontenerach

Wprowadzenie do aplikacji fullstack

Aplikacja fullstack (full-stack) składa się z kilku warstw: - Frontend - interfejs użytkownika, z którym bezpośrednio wchodzi w interakcję użytkownik (React, Angular, Vue.js) - Backend - warstwa logiki biznesowej i API (Node.js/Express, Python/Django, Java/Spring) - Baza danych - warstwa przechowująca dane (MongoDB, PostgreSQL, MySQL)

Docker pozwala na konteneryzację każdej z tych warstw, co umożliwia: - Jednolitą konfigurację środowiska - Łatwą komunikację między komponentami - Niezależne skalowanie poszczególnych części - Przenośność całej aplikacji między środowiskami

Tworzenie backendu w Node.js/Express

Struktura projektu

Typowa struktura projektu backendowego w Node.js/Express wygląda następująco:

backend/
├── src/
│   ├── index.js        # Główny plik aplikacji
│   ├── routes/         # Definicje tras API
│   ├── controllers/    # Kontrolery obsługujące logikę biznesową
│   ├── models/         # Modele danych
│   └── middleware/     # Middleware (np. dla autoryzacji)
├── package.json        # Zależności projektu
├── .env.example        # Przykładowy plik zmiennych środowiskowych
├── .dockerignore       # Pliki ignorowane przez Dockera
└── Dockerfile          # Instrukcje budowania obrazu Docker

Podstawowy kod backendu

Oto przykład prostego API w Express.js:

// backend/src/index.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3000;

// Konfiguracja CORS - pozwala na komunikację z frontendem
app.use(cors({
  origin: process.env.CORS_ORIGIN || '*'
}));

// Parsowanie JSON w zapytaniach
app.use(express.json());

// Przykładowa trasa API
app.get('/api/data', (req, res) => {
  res.json({
    message: 'Hello from Docker container!',
    environment: process.env.NODE_ENV || 'development',
    timestamp: new Date().toISOString()
  });
});

// Uruchomienie serwera
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Dockerfile dla backendu

FROM node:18-alpine

WORKDIR /app

# Kopiowanie tylko plików zależności i instalacja
COPY package*.json ./
RUN npm ci

# Kopiowanie reszty kodu
COPY . .

# Port, który będzie nasłuchiwać kontener
EXPOSE 3000

# Komenda uruchamiająca aplikację
CMD ["node", "src/index.js"]

Plik .dockerignore

node_modules
npm-debug.log
.git
.env
.env.local
.DS_Store

Komunikacja między kontenerami

Metody komunikacji

W aplikacji konteneryzowanej, frontend i backend muszą komunikować się ze sobą. Oto różne metody:

  1. Komunikacja przez sieć Docker:
    • Kontenery mogą komunikować się przez wewnętrzną sieć Docker
    • W Docker Compose można używać nazw usług jako nazw hostów
  2. Komunikacja przez opublikowane porty:
    • Kontenery mogą publikować porty na hosta
    • Inne kontenery lub aplikacje mogą łączyć się przez adres hosta i port

Konfiguracja frontendu do komunikacji z backendem

// frontend/src/App.js
import React, { useState, useEffect } from 'react';
import config from './config';

function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(`${config.API_URL}/api/data`);
        const result = await response.json();
        setData(result);
        setLoading(false);
      } catch (error) {
        console.error('Error fetching data:', error);
        setLoading(false);
      }
    };
    
    fetchData();
  }, []);
  
  return (
    <div className="App">
      <header className="App-header">
        <h1>{config.APP_TITLE}</h1>
        <p>Środowisko: {config.ENVIRONMENT}</p>
        {loading ? (
          <p>Ładowanie danych...</p>
        ) : data ? (
          <div>
            <p>Wiadomość z backendu: {data.message}</p>
            <p>Środowisko backendu: {data.environment}</p>
            <p>Czas odpowiedzi: {data.timestamp}</p>
          </div>
        ) : (
          <p>Brak danych</p>
        )}
      </header>
    </div>
  );
}

export default App;

Konfiguracja CORS w backendzie

CORS (Cross-Origin Resource Sharing) to mechanizm bezpieczeństwa, który kontroluje dostęp do zasobów poza domeną. Należy go odpowiednio skonfigurować, aby frontend mógł komunikować się z backendem:

// Instalacja pakietu cors
// npm install cors

// Konfiguracja w backend/src/index.js
const cors = require('cors');

// Dla środowiska deweloperskiego
app.use(cors({ origin: 'http://localhost:3000' }));

// LUB dla wielu domen
app.use(cors({ 
  origin: ['http://localhost:3000', 'https://myapp.example.com'] 
}));

// LUB konfiguracja przez zmienne środowiskowe
app.use(cors({ 
  origin: process.env.CORS_ORIGIN || '*' 
}));

Sieci w Dockerze

Rodzaje sieci

Docker oferuje kilka typów sieci:

  1. bridge (domyślna) - izolowana sieć na hoście
  2. host - używa sieci hosta bezpośrednio (bez izolacji sieciowej)
  3. none - brak sieci
  4. overlay - komunikacja między wieloma hostami Docker (dla trybu swarm)
  5. macvlan - przypisanie adresu MAC do kontenera

Tworzenie sieci i uruchamianie kontenerów

# Tworzenie sieci
docker network create app-network

# Uruchamianie backendu w sieci
docker run -d --name backend --network app-network -p 3001:3000 my-backend-image

# Uruchamianie frontendu w tej samej sieci
docker run -d --name frontend --network app-network -p 3000:80 -e API_URL=http://backend:3000 my-frontend-image

Adresowanie kontenerów w sieci

W sieci Docker, kontenery mogą odwoływać się do siebie nawzajem po nazwach:

# Z kontenera frontend
curl http://backend:3000/api/data

# Z kontenera backend (jeśli mamy bazę danych)
curl http://db:5432

Praktyczny przykład: Pełna aplikacja full-stack

Spójrzmy na przykład aplikacji full-stack z frontenden, backendem i bazą danych:

1. Backend (Express.js)

// backend/src/index.js
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://db:27017/myapp';

// Połączenie z bazą danych
mongoose.connect(MONGO_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch(err => console.error('MongoDB connection error:', err));

// Model danych
const Task = mongoose.model('Task', {
  text: String,
  completed: Boolean,
  createdAt: { type: Date, default: Date.now }
});

// Middleware
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json());

// Trasy API
app.get('/api/tasks', async (req, res) => {
  try {
    const tasks = await Task.find().sort({ createdAt: -1 });
    res.json(tasks);
  } catch (error) {
    res.status(500).json({ error: 'Nie udało się pobrać zadań' });
  }
});

app.post('/api/tasks', async (req, res) => {
  try {
    const { text } = req.body;
    const task = new Task({ text, completed: false });
    await task.save();
    res.status(201).json(task);
  } catch (error) {
    res.status(500).json({ error: 'Nie udało się dodać zadania' });
  }
});

app.put('/api/tasks/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { completed } = req.body;
    const task = await Task.findByIdAndUpdate(
      id, 
      { completed }, 
      { new: true }
    );
    res.json(task);
  } catch (error) {
    res.status(500).json({ error: 'Nie udało się zaktualizować zadania' });
  }
});

app.delete('/api/tasks/:id', async (req, res) => {
  try {
    const { id } = req.params;
    await Task.findByIdAndDelete(id);
    res.status(204).send();
  } catch (error) {
    res.status(500).json({ error: 'Nie udało się usunąć zadania' });
  }
});

// Uruchomienie serwera
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2. Dockerfile dla backendu

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

EXPOSE 3000

CMD ["node", "src/index.js"]

3. Frontend (React z prostą listą zadań)

// frontend/src/App.js
import React, { useState, useEffect } from 'react';
import config from './config';
import './App.css';

function App() {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const apiUrl = config.API_URL;

  // Pobieranie zadań
  const fetchTasks = async () => {
    try {
      const response = await fetch(`${apiUrl}/api/tasks`);
      if (!response.ok) throw new Error('Nie udało się pobrać zadań');
      const data = await response.json();
      setTasks(data);
      setLoading(false);
    } catch (error) {
      setError(error.message);
      setLoading(false);
    }
  };

  // Pobierz zadania przy montowaniu komponentu
  useEffect(() => {
    fetchTasks();
  }, []);

  // Dodawanie nowego zadania
  const addTask = async (e) => {
    e.preventDefault();
    if (!newTask.trim()) return;

    try {
      const response = await fetch(`${apiUrl}/api/tasks`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: newTask })
      });

      if (!response.ok) throw new Error('Nie udało się dodać zadania');
      const task = await response.json();
      setTasks([task, ...tasks]);
      setNewTask('');
    } catch (error) {
      setError(error.message);
    }
  };

  // Przełączanie statusu zadania
  const toggleTask = async (id, completed) => {
    try {
      const response = await fetch(`${apiUrl}/api/tasks/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: !completed })
      });

      if (!response.ok) throw new Error('Nie udało się zaktualizować zadania');
      const updatedTask = await response.json();
      
      setTasks(tasks.map(task => 
        task._id === id ? updatedTask : task
      ));
    } catch (error) {
      setError(error.message);
    }
  };

  // Usuwanie zadania
  const deleteTask = async (id) => {
    try {
      const response = await fetch(`${apiUrl}/api/tasks/${id}`, {
        method: 'DELETE'
      });

      if (!response.ok) throw new Error('Nie udało się usunąć zadania');
      setTasks(tasks.filter(task => task._id !== id));
    } catch (error) {
      setError(error.message);
    }
  };

  if (loading) return <div className="App">Ładowanie zadań...</div>;
  if (error) return <div className="App">Błąd: {error}</div>;

  return (
    <div className="App">
      <h1>{config.APP_TITLE || 'Lista zadań'}</h1>
      <p>Środowisko: {config.ENVIRONMENT}</p>
      
      <form onSubmit={addTask} className="task-form">
        <input
          type="text"
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Dodaj nowe zadanie..."
        />
        <button type="submit">Dodaj</button>
      </form>

      <ul className="task-list">
        {tasks.length === 0 ? (
          <li className="empty">Brak zadań</li>
        ) : (
          tasks.map(task => (
            <li key={task._id} className={task.completed ? 'completed' : ''}>
              <span onClick={() => toggleTask(task._id, task.completed)}>
                {task.text}
              </span>
              <button onClick={() => deleteTask(task._id)}>Usuń</button>
            </li>
          ))
        )}
      </ul>
    </div>
  );
}

export default App;

4. Uruchamianie z Docker Compose

Docker Compose pozwala na uruchomienie wszystkich komponentów aplikacji za pomocą jednej komendy:

# docker-compose.yml
version: '3.8'

services:
  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    environment:
      - API_URL=http://backend:3000
      - ENVIRONMENT=development
      - APP_TITLE=Lista Zadań
    depends_on:
      - backend

  backend:
    build: ./backend
    ports:
      - "3001:3000"
    environment:
      - PORT=3000
      - MONGO_URI=mongodb://db:27017/myapp
      - CORS_ORIGIN=http://localhost:3000
      - NODE_ENV=development
    depends_on:
      - db

  db:
    image: mongo:5
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

Uruchamianie:

docker-compose up -d

Debugowanie aplikacji full-stack

Sprawdzanie logów

# Logi wszystkich kontenerów
docker-compose logs

# Logi konkretnego kontenera
docker-compose logs backend

# Logi w czasie rzeczywistym
docker-compose logs -f

Wykonywanie poleceń w kontenerze

# Uruchomienie powłoki w kontenerze
docker exec -it backend sh

# Sprawdzenie dostępności innego kontenera
docker exec -it frontend curl http://backend:3000/api/data

Inspekcja sieci

# Lista sieci
docker network ls

# Szczegóły sieci
docker network inspect app-network

Problemy i rozwiązania

Problem 1: Nie można połączyć się z backendem

Symptomy: - Frontend pokazuje błędy połączenia - Zapytania AJAX/fetch nie wracają

Rozwiązania: 1. Sprawdź czy backend działa: docker ps i docker logs backend 2. Sprawdź czy backend nasłuchuje na poprawnym porcie: docker exec backend netstat -tulpn 3. Sprawdź adres API w konfiguracji frontendu 4. Sprawdź czy kontenery są w tej samej sieci: docker network inspect

Problem 2: Błędy CORS

Symptomy: - W konsoli przeglądarki błędy typu “Access-Control-Allow-Origin”

Rozwiązania: 1. Dodaj odpowiedni middleware CORS w backendzie 2. Sprawdź czy CORS_ORIGIN ma poprawną wartość 3. Upewnij się, że protokół (http/https) i port są uwzględnione w konfiguracji CORS

Problem 3: Problemy z bazą danych

Symptomy: - Backend nie może połączyć się z bazą danych - Błędy “Connection refused” lub timeouty

Rozwiązania: 1. Sprawdź czy kontener bazy danych działa 2. Upewnij się, że adres URL bazy danych jest poprawny 3. Sprawdź czy używasz nazwy hosta zgodnej z nazwą usługi w docker-compose 4. Sprawdź czy kontenery są w tej samej sieci

Ćwiczenia praktyczne

Ćwiczenie 1: Tworzenie backendu i Dockerfile

  1. Stwórz prosty serwer Express.js z kilkoma trasami API
  2. Utwórz Dockerfile dla aplikacji backend
  3. Zbuduj obraz i uruchom kontener
  4. Przetestuj API za pomocą narzędzia curl lub Postman

Ćwiczenie 2: Łączenie backendu z frontendem

  1. Zaktualizuj frontend, aby komunikował się z API z ćwiczenia 1
  2. Utwórz własną sieć Docker
  3. Uruchom oba kontenery w tej samej sieci
  4. Zweryfikuj, czy frontend może pobierać dane z backendu

Ćwiczenie 3: Dodanie bazy danych

  1. Dodaj MongoDB do aplikacji
  2. Zaktualizuj backend, aby wykorzystywał bazę danych
  3. Utwórz docker-compose.yml dla całego stosu
  4. Uruchom całą aplikację za pomocą docker-compose up

Materiały dodatkowe