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);
}
}