Implementación de Carga Dividida y Reanudación de Transferencia con SpringBoot, MinIO y Vue

Configuración del Entorno

Para esta implementación, MinIO se despliega mediente Docker (versión 2024.7.4), SpringBoot maneja la lógica del servidor y Vue.js gesitona la interfaz de usuario.

Funcionalidad de Carga Fragmentada

Implementación en Vue.js

<template>
  <div class="contenedor">
    <el-upload
        class="upload-demo"
        drag
        multiple
        :on-change="manejarCambio"
        :auto-upload="false">
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">Arrastra archivos aquí, o <em>haz clic para seleccionar</em></div>
    </el-upload>
    <el-button style="margin-left: 10px;" size="small" type="success" @click="enviarFragmentos">Subir al servidor</el-button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      archivosSeleccionados: [],
      tamanoFragmento: 1024 * 1024 * 100 // 100MB
    }
  },
  methods: {
    async enviarFragmentos() {
      const archivo = this.archivosSeleccionados[0].raw;
      const totalFragmentos = Math.ceil(archivo.size / this.tamanoFragmento);
      for (let i = 0; i < totalFragmentos; i++) {
        const inicio = i * this.tamanoFragmento;
        const fin = Math.min(inicio + this.tamanoFragmento, archivo.size);
        const fragmento = archivo.slice(inicio, fin);
        const datosFormulario = new FormData();
        datosFormulario.append('archivo', fragmento);
        datosFormulario.append('nombreArchivo', archivo.name);
        datosFormulario.append('indice', i);
        await fetch('/api/sistema/subida/fragmento', {
          method: 'POST',
          body: datosFormulario
        });
      }
      await fetch('/api/sistema/subida/fusionar', {
        method: 'POST',
        body: JSON.stringify({nombreArchivo: archivo.name}),
        headers: {'Content-Type': 'application/json'}
      });
    },
    manejarCambio(archivo, listaArchivos) {
      this.archivosSeleccionados = listaArchivos;
    }
  }
}
</script>

Implementación en SpringBoot

Controlador

@RestController
@RequestMapping("/api/sistema/subida")
public class ControladorSubida {

    private final ServicioSubida servicioSubida;

    public ControladorSubida(ServicioSubida servicioSubida) {
        this.servicioSubida = servicioSubida;
    }

    @PostMapping("/fragmento")
    public ResponseEntity<?> cargarFragmento(@RequestPart("archivo") MultipartFile archivo,
                                             @RequestParam("nombreArchivo") String nombreArchivo,
                                             @RequestParam("indice") Integer indice) {
        if (archivo.isEmpty()) {
            return ResponseEntity.badRequest().body("Archivo vacío");
        }
        Map<String, Object> resultado = servicioSubida.subirFragmento(archivo, nombreArchivo + ".part." + indice);
        return ResponseEntity.ok(resultado);
    }

    @PostMapping("/fusionar")
    public ResponseEntity<?> fusionarFragmentos(@RequestBody Map<String, String> solicitud) {
        String nombreArchivo = solicitud.get("nombreArchivo");
        Map<String, Object> resultado = servicioSubida.fusionarFragmentos(nombreArchivo);
        return ResponseEntity.ok(resultado);
    }
}

Servicio

@Service
public class ServicioSubida {

    private final ServicioAlmacenamiento servicioAlmacenamiento;

    public ServicioSubida(ServicioAlmacenamiento servicioAlmacenamiento) {
        this.servicioAlmacenamiento = servicioAlmacenamiento;
    }

    public Map<String, Object> subirFragmento(MultipartFile archivo, String nombreFragmento) {
        Map<String, Object> mapa = new HashMap<>();
        try {
            String rutaCompleta = "/temp/" + nombreFragmento;
            servicioAlmacenamiento.subirArchivo(rutaCompleta, archivo.getInputStream());
            mapa.put("ruta", rutaCompleta);
            mapa.put("nombre", nombreFragmento);
        } catch (Exception e) {
            throw new RuntimeException("Error al subir fragmento", e);
        }
        return mapa;
    }

    public Map<String, Object> fusionarFragmentos(String nombreOriginal) {
        Map<String, Object> mapa = new HashMap<>();
        String directorio = "/temp/" + nombreOriginal.replace(".", "_") + "/";
        servicioAlmacenamiento.fusionarArchivos(directorio, nombreOriginal);
        String url = servicioAlmacenamiento.obtenerUrl(nombreOriginal);
        mapa.put("url", url);
        return mapa;
    }
}

Servicio de Almacenamiento

@Service
public class ServicioAlmacenamiento {

    private final MinioClient clienteMinio;
    private final String nombreCubo;

    public ServicioAlmacenamiento(MinioClient clienteMinio, @Value("${minio.bucket}") String nombreCubo) {
        this.clienteMinio = clienteMinio;
        this.nombreCubo = nombreCubo;
    }

    public void subirArchivo(String nombreObjeto, InputStream flujo) {
        try {
            clienteMinio.putObject(PutObjectArgs.builder()
                    .bucket(nombreCubo)
                    .object(nombreObjeto)
                    .stream(flujo, flujo.available(), -1)
                    .build());
        } catch (Exception e) {
            throw new RuntimeException("Error al subir a MinIO", e);
        }
    }

    public void fusionarArchivos(String prefijo, String nombreFinal) {
        try {
            List<ComposeSource> fuentes = new ArrayList<>();
            Iterable<Result<Item>> objetos = clienteMinio.listObjects(ListObjectsArgs.builder()
                    .bucket(nombreCubo)
                    .prefix(prefijo)
                    .recursive(false)
                    .build());
            for (Result<Item> resultado : objetos) {
                Item item = resultado.get();
                if (!item.isDir()) {
                    fuentes.add(ComposeSource.builder()
                            .bucket(nombreCubo)
                            .object(item.objectName())
                            .build());
                }
            }
            clienteMinio.composeObject(ComposeObjectArgs.builder()
                    .bucket(nombreCubo)
                    .object(nombreFinal)
                    .sources(fuentes)
                    .build());
            // Limpiar fragmentos temporales
            for (ComposeSource fuente : fuentes) {
                clienteMinio.removeObject(RemoveObjectArgs.builder()
                        .bucket(nombreCubo)
                        .object(fuente.object())
                        .build());
            }
        } catch (Exception e) {
            throw new RuntimeException("Error al fusionar fragmentos", e);
        }
    }

    public String obtenerUrl(String nombreObjeto) {
        try {
            return clienteMinio.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .bucket(nombreCubo)
                    .object(nombreObjeto)
                    .method(Method.GET)
                    .build());
        } catch (Exception e) {
            throw new RuntimeException("Error al obtener URL", e);
        }
    }
}

Funcionalidad de Raenudación de Descarga

Implementación en Vue.js

<template>
  <div class="contenedor-principal">
    <div class="lista-archivos">
      <h3>Archivos Disponibles</h3>
      <el-table :data="archivos" border style="width: 100%">
        <el-table-column prop="nombre" label="Nombre" width="200"></el-table-column>
        <el-table-column prop="tamano" label="Tamaño" width="150">
          <template #default="alcance">
            {{ formatearTamano(alcance.row.tamano) }}
          </template>
        </el-table-column>
        <el-table-column label="Acciones" width="100">
          <template #default="alcance">
            <el-button size="small" type="primary" @click="iniciarDescarga(alcance.row)">Descargar</el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
    <div class="descargas-activas">
      <h3>Descargas en Progreso</h3>
      <div v-for="descarga in descargas" :key="descarga.id">
        <div class="item-descarga">
          <span>{{ descarga.nombre }}</span>
          <span>{{ descarga.velocidad }}</span>
          <el-progress :percentage="descarga.porcentaje"></el-progress>
          <el-button @click="pausarReanudar(descarga)">
            {{ descarga.pausado ? 'Reanudar' : 'Pausar' }}
          </el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue';
import axios from 'axios';

const archivos = ref([]);
const descargas = ref([]);

function formatearTamano(bytes) {
  const unidades = [' B', ' KB', ' MB', ' GB'];
  let tamano = bytes;
  let unidad = 0;
  while (tamano >= 1024 && unidad < unidades.length - 1) {
    tamano /= 1024;
    unidad++;
  }
  return tamano.toFixed(2) + unidades[unidad];
}

async function cargarArchivos() {
  const respuesta = await axios.get('/api/sistema/archivos');
  archivos.value = respuesta.data;
}
cargarArchivos();

function iniciarDescarga(archivo) {
  const nuevaDescarga = reactive({
    id: Date.now(),
    nombre: archivo.nombre,
    tamano: archivo.tamano,
    porcentaje: 0,
    velocidad: '0 MB/s',
    pausado: false,
    fragmentoActual: 1,
    fragmentosCompletados: []
  });
  descargas.value.push(nuevaDescarga);
  descargarFragmento(nuevaDescarga);
}

async function descargarFragmento(descarga) {
  const tamanoFragmento = 5 * 1024 * 1024; // 5MB
  const totalFragmentos = Math.ceil(descarga.tamano / tamanoFragmento);

  if (descarga.fragmentoActual > totalFragmentos || descarga.pausado) return;

  if (!descarga.fragmentosCompletados.includes(descarga.fragmentoActual)) {
    const inicio = (descarga.fragmentoActual - 1) * tamanoFragmento;
    const longitud = descarga.fragmentoActual === totalFragmentos 
      ? descarga.tamano - inicio 
      : tamanoFragmento;

    try {
      const respuesta = await axios.post('/api/sistema/descarga/fragmento', {
        nombreArchivo: descarga.nombre,
        inicio,
        longitud
      }, { responseType: 'blob' });

      descarga.fragmentosCompletados.push(descarga.fragmentoActual);
      descarga.porcentaje = Math.round((descarga.fragmentoActual / totalFragmentos) * 100);
      descarga.velocidad = `${(longitud / 1024 / 1024).toFixed(1)} MB/s`;

      if (descarga.fragmentoActual === totalFragmentos) {
        // Fusionar fragmentos y descargar
        const blob = new Blob(descarga.fragmentosCompletados.map(f => respuesta.data));
        const url = URL.createObjectURL(blob);
        const enlace = document.createElement('a');
        enlace.href = url;
        enlace.download = descarga.nombre;
        enlace.click();
        URL.revokeObjectURL(url);
        descargas.value = descargas.value.filter(d => d.id !== descarga.id);
      } else {
        descarga.fragmentoActual++;
        descargarFragmento(descarga);
      }
    } catch (error) {
      console.error('Error en descarga:', error);
    }
  } else {
    descarga.fragmentoActual++;
    descargarFragmento(descarga);
  }
}

function pausarReanudar(descarga) {
  descarga.pausado = !descarga.pausado;
  if (!descarga.pausado) {
    descargarFragmento(descarga);
  }
}
</script>

Implementación en SpringBoot

Controlador

@RestController
@RequestMapping("/api/sistema/descarga")
public class ControladorDescarga {

    private final ServicioDescarga servicioDescarga;

    public ControladorDescarga(ServicioDescarga servicioDescarga) {
        this.servicioDescarga = servicioDescarga;
    }

    @PostMapping("/fragmento")
    public void descargarFragmento(@RequestBody Map<String, Object> solicitud,
                                   HttpServletResponse respuesta) {
        String nombreArchivo = (String) solicitud.get("nombreArchivo");
        long inicio = Long.parseLong(solicitud.get("inicio").toString());
        long longitud = Long.parseLong(solicitud.get("longitud").toString());
        servicioDescarga.descargarFragmento(respuesta, nombreArchivo, inicio, longitud);
    }
}

Servicio

@Service
public class ServicioDescarga {

    private final ServicioAlmacenamiento servicioAlmacenamiento;

    public ServicioDescarga(ServicioAlmacenamiento servicioAlmacenamiento) {
        this.servicioAlmacenamiento = servicioAlmacenamiento;
    }

    public void descargarFragmento(HttpServletResponse respuesta, String nombreArchivo, 
                                   long inicio, long longitud) {
        try {
            InputStream flujo = servicioAlmacenamiento.obtenerFragmento(nombreArchivo, inicio, longitud);
            respuesta.setContentType("application/octet-stream");
            respuesta.setHeader("Content-Disposition", "attachment; filename=" + nombreArchivo);
            try (OutputStream salida = respuesta.getOutputStream()) {
                byte[] buffer = new byte[8192];
                int bytesLeidos;
                while ((bytesLeidos = flujo.read(buffer)) != -1) {
                    salida.write(buffer, 0, bytesLeidos);
                }
            }
            flujo.close();
        } catch (Exception e) {
            throw new RuntimeException("Error en descarga fragmentada", e);
        }
    }
}

Métodos adicionales en ServicioAlmacenamiento

    public InputStream obtenerFragmento(String nombreObjeto, long inicio, long longitud) {
        try {
            return clienteMinio.getObject(GetObjectArgs.builder()
                    .bucket(nombreCubo)
                    .object(nombreObjeto)
                    .offset(inicio)
                    .length(longitud)
                    .build());
        } catch (Exception e) {
            throw new RuntimeException("Error al obtener fragmento de MinIO", e);
        }
    }

Etiquetas: SpringBoot minio Vue java JavaScript

Publicado el 7-2 03:12