Saltar a contenido

Demo integrador - Tabla de personas

Se crea un proyecto integrador imitando la organización de un servicio monolítico práctico.

Se utilizan todo tipo de recursos disponibles:

  • Port mapping;
  • Networks;
  • Variables de entorno;
  • Secrets;
  • Argumentos de imagen.

Introducción

Se implementa una página web dinámica que muestra datos artificiales de personas ficticias y un botón flotante. Cada vez que se pulsa el botón se inventan los datos de una nueva persona y se agregan en una nueva fila a la tabla. Los datos se almacenan en una base de datos SQL.

Organización del proyecto

El proyecto se despliega en tres contenedores: uno para el servidor de la webapp (frontend), uno para el servidor backend y uno para la base de datos.

---
title: "Demo Integrador - Organización general"
config:
  markdownAutoWrap: false
---
flowchart LR

    subgraph proyecto [Entorno proyecto]


        subgraph services [Servicios]
        front["`Frontend 
            frontend_flet:8000`"]
        back["`Backend
            backend_fastapi:8000`"]
        db["`Base de datos PostgreSQL
            base_datos:5432`"]
        end

        subgraph redes [Redes]
            red-database["red-database"]
            red-frontend["red-frontend"]
        end

    end

    front --- red-frontend
    back --- red-frontend
    back --- red-database 
    db --- red-database 

    subgraph host [Host]
        subgraph ports [Puertos]
            portf["`Navegador
            localhost:PUERTO_FRONT`"]
            portb["`API
            localhost:PUERTO_BACK`"]
            portdb["`Cliente SQL
            localhost:PUERTO_DB`"]
        end

    end

    portf --> front
    portb --> back
    portdb --> db

La API del backend admite peticiones en dos URL paths: /leer_todos para pedir todos los datos existentes y /nuevo para ordenar la creación de un nuevo registro de usuario. El servicio frontend realiza estas mismas peticiones a la API cada vez que el usuario pulsa el botón flotante.

Se dejó abierto el acceso a la API del backend y a la base de datos con fines didácticos.

Configuración

La contraseña de la base de datos se asigna con ayuda de un elemento secret. El resto de las variables de configuración se asignan mediante variables de entorno.

Arbol del proyecto

Los servicios de frontend y backend están integramente escritos en Python. Se eligió como gestor de SQL una imagen PostgreSQL, aunque podría haberse elegido MaríaDB o MySQL.

Integrador - Árbol de archivos
.
├── frontend
   ├── app
      ├── main.py
      └── tabla_personas.py
   ├── Dockerfile
   └── requirements.txt
├── backend
   ├── app
      ├── main.py
      ├── persona.py
      └── sql.py
   ├── Dockerfile
   └── requirements.txt
├── .env
├── compose.yml
└── secreto.txt

Archivo de configuración

El despliegue se configuró mediante un único archivo compose.yml. En él se especificaron todos los servicios y elementos auxiliares necesarios.

Integrador - compose.yml
name: demo-integrador


services:

  frontend_flet:    
    build: 
      context: frontend/    # ruta al Dockerfile
      # args:
      #   VERSION_PYTHON: 3.13.7-slim-trixie   # Debian 13 (recortada)
    image: tabla_frontend_flet-web:v1
    ports:
      - ${PUERTO_FRONT:-8000}:8000
    restart: always
    networks:
      - red-frontend
    environment:
      SERVICIO_BACKEND:   backend_fastapi
      PUERTO_BACKEND:     8000
    depends_on: 
      - backend_fastapi


  backend_fastapi:    
    build:
      context: backend/    # ruta al Dockerfile
      # args:
      #   VERSION_PYTHON: 3.13.7-slim-trixie   # Debian 13 (recortada)
    image: tabla_backend_fastapi:v1
    ports:
      - ${PUERTO_BACK:-8001}:8000
    depends_on: 
      base_datos:
          restart: true
          condition: service_healthy
          required: true
    restart: always
    environment:
      TAG_LENGUAJE: "ES_AR"
      POSTGRES_DB:       ${NOMBRE_DB:-personas_tabla}
      POSTGRES_DOMINIO:  base_datos
      POSTGRES_USER:     ${USUARIO:-noname}
      POSTGRES_PASSWORD_FILE: /run/secrets/secreto
    secrets:
      - secreto
    networks:
      - red-frontend
      - red-database


  base_datos:
    restart: always
    image: postgres:17.2-bookworm     
    environment:
      POSTGRES_USER:     ${USUARIO:-noname}
      POSTGRES_PASSWORD_FILE: /run/secrets/secreto
      POSTGRES_DB:       ${NOMBRE_DB:-personas_tabla}
    ports:
      - ${PUERTO_DB:-5432}:5432
    volumes:
      - volumen_db:/var/lib/postgresql/data
    secrets:
      - secreto
    networks:
      - red-database
    healthcheck:  
      test: ["CMD-SHELL", "psql -U ${USUARIO:-noname} -d ${NOMBRE_DB:-personas_tabla} -c 'SELECT 1' || exit 1"]  
      interval: 10s
      timeout: 60s
      retries: 5


networks:
  red-frontend:
  red-database:


volumes:
  volumen_db:


secrets:
  secreto:
    file: ./secreto.txt    
    # file: $HOME/secreto.txt   

Las variables de entorno usadas para mapeo de puertos se cargaron en el archivo .env. Valores de ejemplo:

Integrador - archivo .env
PUERTO_FRONT=8181
PUERTO_BACK=8182
PUERTO_DB=9000

Frontend

El servicio de frontend se encarga de proporcionar una pagina web dinámica construida con el framework Flet. Este servicio interactúa con el servicio de backend haciendo peticiones HTTP con ayuda del paquete requests.

Archivos del frontend
Frontend - requirements.txt
flet[all]==0.28.3
requests==2.32.5
Frontend - Dockerfile
# imagen de referencia
ARG VERSION_PYTHON="3.13.5-alpine3.22"
FROM python:${VERSION_PYTHON}

# directorio de trabajo (se crea automáticamente)
WORKDIR /code

# instalación de dependencias
COPY requirements.txt ./
RUN pip install -r requirements.txt --no-cache-dir 

# copia de rutinas al directorio de trabajo
COPY app/ ./

# Puerto expuesto (meramente informativo)
EXPOSE 8000

# comando, opciones y argumentos fijos
ENTRYPOINT ["uvicorn", "main:app", "--port", "8000"]

# opciones y argumentos sobreescribibles
CMD ["--host", "0.0.0.0"]
Frontend - main.py
"""main.py - Rutina principal - incluye el maquetado de la pagina web"""

# biblioteca estandar
from logging import basicConfig
from logging import info
from logging import INFO
import os

# paquetes
import flet as ft
import requests

# modulos
from tabla_personas import TablaPersonas

# uso de la consola de logs
basicConfig(
    level=INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",  # info incorporada
)

# URLs al backend
servicio_backend = os.getenv("SERVICIO_BACKEND")
puerto_backend   = os.getenv("PUERTO_BACKEND")

URL_CREAR_PERSONA = f"http://{servicio_backend}:{puerto_backend}/nuevo/"
URL_LEER_TODOS    = f"http://{servicio_backend}:{puerto_backend}/leer_todos/"


# diseño de página
def main(page: ft.Page):
    """Esta función define el diseño de la página web."""

    def crear_persona(e):
        """Este handler ordena crear una nueva persona ficticia."""

        # orden al backend para crear nueva persona
        requests.post(URL_CREAR_PERSONA, timeout=5)
        # # relectura de tablas
        actualizacion_tabla()
        info("Nueva persona creada")


    def actualizacion_tabla():
        """Esta funcion lee la tabla desde la base de datos y la carga a la página."""

        # lectura de tabla (completa)
        respuesta = requests.get(URL_LEER_TODOS, timeout=5)
        personas = respuesta.json()

        # borrado de filas
        tabla.rows = []

        # # creacion de filas - una a una
        for persona in personas:
            info(persona)
            tabla.nueva_persona(persona)

        # actualizacion grafica
        page.update()

    # boton flotante
    page.floating_action_button = ft.FloatingActionButton(
        icon=ft.Icons.ADD,
        on_click=crear_persona,
    )

    # tabla web
    tabla = TablaPersonas()

    # maquetado y estilos
    page.add(
        ft.SafeArea(
            ft.Container(
                tabla,
                alignment=ft.alignment.center,
                expand=True,
            )
        )
    )

    # actualizacion grafica
    actualizacion_tabla()
    page.update()


# objeto renderizable por el server Uvicorn
app = ft.app(main, export_asgi_app=True)
Frontend - tabla_personas.py
"""tabla_personas.py - Módulo implementado para diseñar la tabla gráfica."""

# paquetes
from flet import DataTable, DataCell, DataRow, DataColumn
from flet import Text, FontWeight


class TablaPersonas(DataTable):
    """Tabla custom - con lectura de filas a medida"""

    def __init__(self):
        """Inicializacion de la tabla gráfica."""
        super().__init__(
            width=1200,
            height=700,
            heading_row_height=50,
            column_spacing=20,
            columns=[
                DataColumn(Text("ID", weight=FontWeight.BOLD, width=100)),
                DataColumn(Text("Nombre", weight=FontWeight.BOLD, width=300)),
                DataColumn(Text("Dirección", weight=FontWeight.BOLD, width=300)),
                DataColumn(
                    Text("Edad", weight=FontWeight.BOLD, width=100), numeric=True
                ),
            ],
        )

    # def nueva_persona(self, persona: Persona):
    def nueva_persona(self, persona: dict):
        """Agrega una fila a la tabla gráfica con los datos de entrada."""
        fila = DataRow(
            cells=[
                DataCell(Text(persona["id"], width=100)),
                DataCell(Text(persona["nombre"], width=300)),
                DataCell(Text(persona["direccion"], width=300)),
                DataCell(Text(persona["edad"], width=100)),
            ],
        )
        self.rows.append(fila)
        return fila

Backend

El backend implementa un servidor HTTP con ayuda del framework FastAPI. Para interactuar con la base de datos utiliza el paquete SQLModel, el cual permite diseñar las tablas SQL e implementar tanto el guardado como la lectura de datos mediante clases pedefinidas, sin necesidad de agregar código SQL. Por último, los datos de los registros se inventan con ayuda del paquete Faker, que inventa datos de personas ficticias con un simple llamado a un método predefinido.

Archivos del backend
Backend - requirements.txt
fastapi[all]==0.117.1
sqlmodel==0.0.24
psycopg2-binary==2.9.10
Faker==37.4.2

(psycopg2 es requerido para poder establecer la conexión con la base Postgres, sin embargo no requiere ser llamado explícitamente por la rutina de Python).

Backend - Dockerfile
# imagen de referencia
ARG VERSION_PYTHON="3.13.5-alpine3.22"
FROM python:${VERSION_PYTHON}

# directorio de trabajo (se crea automáticamente)
WORKDIR /code

# instalación de dependencias
COPY requirements.txt ./
RUN pip install -r requirements.txt --no-cache-dir 

# copia de rutinas al directorio de trabajo
COPY app/ ./

# Puerto expuesto (meramente informativo)
EXPOSE 8000

# comando, opciones y argumentos fijos
ENTRYPOINT ["uvicorn", "main:app", "--port", "8000"]

# opciones y argumentos sobreescribibles
CMD ["--host", "0.0.0.0"]
Backend - main.py
"""main.py - Rutina principal - incluye el maquetado de la pagina web"""

# paquetes
from fastapi import FastAPI

# modulos
from sql import leer_todas_personas_db, guardar_persona_db
from persona import nueva_persona

# objeto renderizable por el server Uvicorn
app = FastAPI()


# paths implementados
@app.get("/")
async def root():
    """Un mero mensaje informativo"""
    return {"message": "Backend hecho en FastAPI"}


@app.post("/nuevo")
async def nuevo_usuario():
    """Path elegido para ordenar la creación de un nuevo usuario."""
    persona = nueva_persona()
    datos_nuevos:dict = await guardar_persona_db(persona)
    return datos_nuevos


@app.get("/leer_todos")
async def leer_usuarios():
    """Path elegido para leer los datos de todos los usuarios en la base de datos.
    se devuelven como una lista de diccionarios."""
    lista_todos: list[dict]
    lista_todos = await leer_todas_personas_db()
    return lista_todos
Backend - persona.py
"""persona.py - Este módulo crea los datos de personas ficticias."""

# bibliotecas estandar
import os
from random import randint

# paquetes
from faker import Faker

# lenguaje y región del registro de personas
lenguaje_region = os.getenv("TAG_LENGUAJE",default="ES_ES")


# generador de datos ficticios
datos_fake = Faker(locale=lenguaje_region)

# crear nueva persona
def nueva_persona()->dict:
    """Esta función crea los datos de una nueva persona ficticia."""
    # generacion de nueva persona
    nombre = datos_fake.name()
    direccion = datos_fake.address()
    edad = randint(13, 65)

    # agrupacion como diccionario
    data_persona = {
        "nombre": nombre ,
        "direccion": direccion,
        "edad": edad
    }

    return data_persona
Backend - sql.py
"""sql.py - Módulo dedicado a las consultas a la base de datos"""

# bibliotecas estandar
import os
from pathlib import Path
from typing import Optional

# paquetes
from sqlmodel import Field, SQLModel, create_engine
from sqlmodel import Session, select


# Variables de entorno - necesarias para componer la URL de la base de datos
user = os.getenv("POSTGRES_USER")
database = os.getenv("POSTGRES_DB")
dominio = os.getenv("POSTGRES_DOMINIO")
ruta_password_secreto = os.getenv("POSTGRES_PASSWORD_FILE")


if Path(ruta_password_secreto).is_file():
    with open(ruta_password_secreto, "r",encoding="utf-8") as archivo:
        password = archivo.read()
else:
    password = os.getenv("POSTGRES_PASSWORD")

# composicion de la URL dela base de datos
ruta_db = f"postgresql://{user}:{password}@{dominio}:5432/{database}"


# creación del conector
engine = create_engine(
    ruta_db,
    # echo=True,
    pool_pre_ping=True,
)

# Diseño de tabla SQL
class PersonaSQL(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    nombre: str
    direccion: str
    edad: Optional[int] = None


# creacion de base de datos vacía (sólo si aun no existe)
SQLModel.metadata.create_all(engine)


# funciones para peticiones remotas

async def guardar_persona_db(persona: dict)->dict:
    """Esta función guarda los datos de la persona en una base de datos."""
    # carga en base datos
    with Session(engine) as session:
        # carga de datos - fila a fila
        persona = PersonaSQL(
            nombre=persona["nombre"],
            direccion=persona["direccion"],
            edad=persona["edad"],
        )
        session.add(persona)
        # confirmación de cambios
        session.commit()
        return persona

    return None


async def leer_todas_personas_db()->list[dict]:
    """Esta función lee los datos de todas las personas registradas
    y los devuelve como lista de diccionarios. """
    # lectura desde base de datos 
    with Session(engine) as session:
        statement = select(PersonaSQL)
        resultados = session.exec(statement)
        data = resultados.all()
        personas = data
        return personas

    return []

Despliegue y consulta

Simplemente ubicarse en la ruta del proyecto y ejecutar:

Integrador - Despliegue
podman compose up -d

La web dinámica debe aparecer visible en el puerto 8181 o en el puerto por default que es el 8000.

La API se puede consultar desde el navegador puerto 8182 o en el puerto preasignado que es el 8001.