Datei-Upload und -Download in Hilla Web-Apps

René Wilby | 19.04.2024 Min. Lesezeit

Im früheren Discord-Channel von Vaadin und im aktuellen Vaadin Forum kam schon häufiger die Frage auf, wie man einen Datei-Upload und -Download in Web-Apps auf Basis des Hilla-Frameworks realisiert. Anhand eines kleinen Beispiels möchte ich einen möglichen Lösungsansatz zeigen. Wer neugierig ist, kann sich das Ergebnis als Video am Ende des Beitrages anschauen.

Separation of Concerns

Das Hilla-Framework macht einen tollen Job, um die Kommunikation zwischen Frontend und Backend typsicher und einfach zu gestalten. Die Kommunikation zwischen Frontend und Backend erfolgt über einen eigenen HTTP-Client und entsprechende Backend-Services. Als Datenaustauschformat kommt JSON zum Einsatz. Der Upload und Download von Dateien ließe sich auch mit dieser Art der Kommunikation realisieren, würde aber einige Nachteile mit sich bringen, u.a. die Serialisierung und Deserialisierung der zu übertragenden Dateien.

Erfreulicherweise gibt es aber Alternativen. Eine Hilla-Anwendung ist im Kern ein klassisches Spring Boot-Projekt. Somit stehen im Backend unzählige Funktionen aus dem mächtigen Spring-Ökosystem zur Verfügung. Im Kontext von Spring ist das Handling von Datei-Uploads und -Downloads sehr einfach zu realisieren, zum Beispiel in Form eines RestController.

Vorbereitungen im Backend

Eine Datei wird in diesem Beispiel über die folgende Entität File repräsentiert:

@Entity
@Table(name = "files")
public class File {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    Long id;

    @NotBlank
    private String filename;

    @NotBlank
    private String originalFilename;

    @NotBlank
    private String contentType;

    @NotNull
    private Long size;

    @NotNull
    private LocalDate created;

    // Getter and Setter omitted
}

Diese Entität enthält jedoch lediglich die Metadaten zu einer Datei, aber nicht die Datei selbst. Die Metadaten werden in einem passenden Spring Data JPA-Repository namens FileRepository persistiert:

public interface FileRepository extends JpaRepository<File, Long>, JpaSpecificationExecutor<File> {

    Optional<File> findOneByFilename(String filename);
}

Das Schreiben und Lesen einer Datei in das bzw. aus dem Dateisystem und das Löschen einer Datei aus dem Dateisystem erfolgt über einen FileStorageService und einen FileHandler:

public class FileHandler {

    private static final Logger logger = LoggerFactory.getLogger(FileHandler.class);

    public static byte[] readFileFromFilesystem(String filename) {
        try (FileInputStream fis = new FileInputStream(filename)) {
            return fis.readAllBytes();
        } catch (IOException e) {
            logger.error("Could not read file from filesystem.", e);
            throw new RuntimeException(e);
        }
    }

    public static void writeFileToFilesystem(InputStream in, String filename) {
        FileOutputStream fos;
        try {
            fos = new FileOutputStream(filename);
            fos.write(in.readAllBytes());
            fos.close();
        } catch (IOException e) {
            logger.error("Could not write file to filesystem.", e);
            throw new RuntimeException(e);
        }
    }

    public static void deleteFileFromFilesystem(String filename) {
        try {
            Files.deleteIfExists(Paths.get(filename));
        } catch (IOException e) {
            logger.error("Could not delete file from filesystem.", e);
            throw new RuntimeException(e);
        }
    }
}
@Component
public class FileStorageService {

    @Value("${storage.file.path}")
    private String path;

    private final Logger logger = LoggerFactory.getLogger(FileStorageService.class);

    public void save(InputStream is, String filename) {
        logger.debug("Saving file '" + filename + "' to path '" + path + "'");
        FileHandler.writeFileToFilesystem(is, path + "/" + filename);
    }

    public byte[] read(String filename) {
        logger.debug("Reading file '" + filename + "' from path '" + path + "'");
        return FileHandler.readFileFromFilesystem(path + "/" + filename);
    }

    public void delete(String filename) {
        logger.debug("Deleting file '" + filename + "' from path '" + path + "'");
        FileHandler.deleteFileFromFilesystem(path + "/" + filename);
    }
}

Der konkrete Speicherplatz der Dateien im Dateisystem wird über die Konfiguration storage.file.path bestimmt.

Ein eigener FileService vereinfacht den Umgang mit Dateien und kümmert sich bspw. darum, dass beim Upload einer Datei die Metadaten über das FileRepository persistiert werden und die eigentliche Datei über den FileStorageService im Dateisystem abgelegt wird.

@Component
public class FileService {

    @Autowired
    FileRepository fileRepository;

    @Autowired
    FileStorageService fileStorageService;

    private final Logger logger = LoggerFactory.getLogger(FileService.class);

    public File save(MultipartFile multipartFile) {
        File file = new File(multipartFile);
        logger.debug("Saving " + file + " to database.");
        fileRepository.save(file);
        try {
            fileStorageService.save(multipartFile.getInputStream(), file.getFilename());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return file;
    }

    public Resource readFile(String filename) {
        return new ByteArrayResource(fileStorageService.read(filename));
    }

    public Optional<File> read(String filename) {
        logger.debug("Reading file with file '" + filename + "' from database.");
        return fileRepository.findOneByFilename(filename);
    }
}

Spring RestController

Für den Upload und den Download einer Datei stellen wir einen klassischen Spring RestController namens FileController zur Verfügung:

@RestController
@RequestMapping("/api")
public class FileController {

    private final Logger logger = LoggerFactory.getLogger(FileController.class);

    @Autowired
    FileService fileService;

    @PostMapping("/files")
    public ResponseEntity<File> upload(@RequestParam("file") MultipartFile multipartFile) {
        logger.debug("Uploading file '" + multipartFile.getOriginalFilename() + "'");
        try {
            File file = fileService.save(multipartFile);
            return ResponseEntity.ok().body(file);
        } catch (Exception e) {
            logger.error("Error uploading file.", e);
            return ResponseEntity.internalServerError().build();
        }
    }

    @GetMapping("/files/{filename:.+}")
    public ResponseEntity<Resource> download(@PathVariable String filename) {
        logger.debug("Downloading file '" + filename + "'");
        Optional<File> file = fileService.read(filename);
        if (file.isPresent()) {
            Resource resource = fileService.readFile(filename);
            if (resource != null) {
                return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"" + file.get().getOriginalFilename() + "\"").body(resource);
            }
        }
        return ResponseEntity.notFound().build();
    }
}

Der Upload einer Datei erfolgt per HTTP-POST über den Pfad /api/files und der Download erfolgt per HTTP-Get über den Pfad /api/files/{filename:.+}.

Browser-Callable

Für die Metadaten einer Datei erstellen wir einen eigenen Hilla-spezifischen Endpunkt namens FileBrowserCallable:

@BrowserCallable
@AnonymousAllowed
public class FileBrowserCallable extends CrudRepositoryService<File, Long, FileRepository> { }

Die Annotation @BrowserCallable sorgt dafür, dass Hilla automatisch einen typsicheren TypeScript-Client und zugehörige Dateien generiert, die wir im weiteren Verlauf im Frontend verwenden können.

Frontend

Das Frontend unserer Beispiel-Anwendung verwendet den neuen File-Router von Hilla. Die Datei src/main/frontend/views/files/@index.tsx enthält eine Upload-Komponente, über die der eigentliche Upload erfolgt:

  <Upload
    accept='application/pdf,.pdf'
    maxFiles={1}
    maxFileSize={10485760}
    target='/api/files'
    className='w-full'
    onUploadSuccess={() => {
      autoGridRef.current?.refresh();
    }}
  />

Die Upload-Komponente erlaubt in diesem Fall nur den Upload von PDF-Dateien. Der Upload erfolgt über den Pfad /api/files und somit direkt zur Methode upload des oben gezeigten FileController.

Des Weiteren enthält die View eine AutoGrid-Komponente zur Darstellung der Metadaten aller hochgeladenen Dateien. Dabei kommen die zuvor generierten Dateien zum Einsatz und übernehmen sämtliche Kommunikation mit dem Backend:

  <AutoGrid ref={autoGridRef} model={FileModel} service={FileBrowserCallable} />

Darüber hinaus erhält die AutoGrid-Komponente ein ContextMenu, über das Dateien heruntergeladen und gelöscht werden können:

  const autoGridRef = useRef<AutoGridRef>(null);

  const renderMenu = ({ context }: MenuProps) => {
    const grid = autoGridRef.current?.grid;
    if (!grid) {
      return null;
    }
    const { sourceEvent } = context.detail as { sourceEvent: Event };
    const eventContext = grid.getEventContext(sourceEvent);
    const file = eventContext.item;
    if (!file) {
      return null;
    }

    return (
      <ListBox>
        <Item
          onClick={async () => {
            if (file?.id) {
              try {
                await FileBrowserCallable.delete(file.id);
                if (autoGridRef.current) {
                  autoGridRef.current.refresh();
                }
              } catch (e) {
                alert(e);
              }
            }
          }}
        >
          Delete
        </Item>
        <Item
          onClick={() => {
            window.open(`/api/files/${file.filename}`, '_blank');
          }}
        >
          Download
        </Item>
      </ListBox>
    );
  };

  // ...

  <ContextMenu renderer={renderMenu} className='w-full'>
    <AutoGrid ref={autoGridRef} model={FileModel} service={FileBrowserCallable} />
  </ContextMenu>

Wie zu erkennen ist, erfolgt der Download anhand des Dateinamens über den Pfad /api/files/${file.filename} und mit einem einfachen HTML-Link, der die Datei in einem neuen Fenster bzw. Tab zum Download öffnet. Der Download erfolgt somit über Methode download des oben gezeigten FileController.

Mit Hilfe der Upload- und AutoGrid-Komponente können nun Dateien beliebig hochgeladen, heruntergeladen und gelöscht werden.

EntityListener

Damit beim Löschen einer Datei über das ContextMenu zusätzlich zu den Metadaten auch die zugehörige Datei im Dateisystem gelöscht wird, bietet sich der Einsatz eines EntityListeners an:

@Component
public class FileListener {

    @Autowired
    FileStorageService fileStorageService;

    @PostRemove
    private void afterDelete(File file) {
        fileStorageService.delete(file.getFilename());
    }
}

Der FileListener wird mit Hilfe der Annotation @EntityListeners an der Entität File registriert:

@Entity
@EntityListeners(FileListener.class)
@Table(name = "files")
public class File {
    // ...
}

Fazit

Der Upload und Download von Dateien ist in Web-Apps auf Basis des Hilla-Frameworks sehr leicht umsetzbar. Wichtig ist es dabei, die Stärken der beteiligten Frameworks und Tools richtig zu nutzen. Die Darstellung und der Umgang mit den Metadaten einer Datei kann Hilla super übernehmen. Das Handling der eigentlichen Datei übernimmt hingegen Spring. Im Zusammenspiel ergeben sich flexible Möglichkeiten, um Datei-Uploads und -Downloads an die jeweiligen Bedürfnisse der eigenen Anwendung anzupassen.

Der Quellcode für das Beispiel ist bei GitHub verfügbar.