~/home of geeks

Plugable Module mit Springboot

· 1027 Wörter · 5 Minute(n) Lesedauer

patterns in nature

Dank der Auto-Configuration-Module von Spring-Boot lassen sich Komponenten anhand der Konfiguration ein- und ausschalten. Darüber lassen sich schöne Plugin-Module umsetzen.

Angenommen wir bauen ein Modul, das Daten in ein Storage schreiben soll. Im besonderen Fall soll das Storage ein S3-Bucket sein. Gleichzeitig möchten wir zum “Debuggen” die Möglichkeit haben, die Daten in ein lokales Verzeichnis zu schreiben, denn Schreiben in ein Bucket kostet Geld und für einen Testlauf mit mehreren tausend Datensätzen ist das nicht ideal.

Wir definieren also eine Schnittstelle, die das Schreiben in ein Storage ermöglicht. Analoge Methoden zum Löschen einer Datei und anderen Operationen sind denkbar.

public interface Storage {
    /**
     * Schreibt die angegebenen Daten als Datei in den Storage.
     *
     * @param fileName Dateiname auf dem Storage
     * @param content  Inhalt der Datei
     * @throws IOException Bei IO-Fehlern
     */
    void write(String fileName, byte[] content) throws IOException;
}

Anschließend implementieren wir eine Version, welche lokal Dateien speichert:

@Component
@Log4j2
public class LocalStorage implements Storage {
    private final File storageDir;

    @Autowired
    public LocalStorage(final LocalStorageProperties localStorageProperties) {
        storageDir = localStorageProperties.getStorageDir();
        log.info("LocalStorage initialisiert mit Speicherordner '{}'.", storageDir.getAbsolutePath());
        if (!storageDir.exists()) {
            storageDir.mkdirs();
        }
    }
    
    @Override
    public void write(final String fileName, final byte[] content) throws IOException {
        final File file = new File(storageDir, fileName);
        log.info("Schreibe Datei '{}'.", file.getAbsolutePath());
        FileUtils.writeByteArrayToFile(file, content);
    }
}

Die Konfiguration für den LocalStorage erfolgt über eine Properties-Klasse, welche nur für den LocalStorage verwendet wird:

@Configuration
@ConfigurationProperties(prefix = LocalStorageProperties.CONFIG_PREFIX, ignoreUnknownFields = false)
@Getter
@Setter
@Validated
public class LocalStorageProperties {
    public static final String CONFIG_PREFIX = "myapp.local-storage";

    @NotNull(message = "Bitte geben Sie einen Ordner (" + CONFIG_PREFIX + ".storage-dir) für das lokale Speichern an.")
    private File storageDir;

}

Hierbei definieren wir auch einen Prefix für die Properties. In der YAML-Konfiguration sieht das dann in etwa so aus:

myapp:
    local-storage:
        storage-dir: tmp/storage

Nun möchten wir aber, dass diese Komponente nur dann instantiiert wird, wenn wir das explizit in der Konfiguration angeben. Dazu definieren wir eine ConditionalOn-Annotation, welche prüft, ob der Konfigurationsschlüssel angegeben wurde:

/**
 * {@link org.springframework.context.annotation.Condition}, welche prüft, ob
 * ein lokaler Storage-Schlüssel konfiguriert ist (via {@link LocalStorageProperties#CONFIG_PREFIX} + ".storage-dir").
 */
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ConditionalOnProperty(name = LocalStorageProperties.CONFIG_PREFIX + ".storage-dir")
public @interface ConditionalOnLocalStorage {}

Diese Annotation können wir nun an der Konfiguration des LocalStorage und am LocalStorage selber anbringen:

@Configuration
@ConfigurationProperties(prefix = LocalStorageProperties.CONFIG_PREFIX, ignoreUnknownFields = false)
@ConditionalOnLocalStorage
@Getter
@Setter
@Validated
public class LocalStorageProperties {
    // ...
}
@Component
@Log4j2
@ConditionalOnLocalStorage
public class LocalStorage implements Storage {
    // ...
}  

Nun wird der LocalStorage nur dann initialisiert, wenn der Konfigurationsschlüssel myapp.local-storage.storage-dir belegt ist.

Das Gleiche machen wir nun für den S3Storage:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ConditionalOnProperty(name = GoogleCloudStorageProperties.CONFIG_PREFIX + ".credentials-file")
public @interface ConditionalOnGoogleCloudStorage {}

Die Konfiguration erfolgt einfachheitshalber über eine JSON-Datei, welche die Credentials für den Zugriff auf den Bucket enthält:

my-gcs-credentials.json

{
  "type": "service_account",
  "project_id": "my-project",
  "private_key_id": "1234567890"
  // ...
}
@Configuration
@ConfigurationProperties(prefix = GoogleCloudStorageProperties.CONFIG_PREFIX, ignoreUnknownFields = false)
@ConditionalOnGoogleCloudStorage
@Getter
@Setter
@Validated
public class GoogleCloudStorageProperties {
    public static final String CONFIG_PREFIX = "myapp.google-cloud-storage";

    @NotNull(message = "Bitte geben Sie eine Credentials-Datei (" + CONFIG_PREFIX + ".credentials-file) für die Verbindung zum Google Cloud Storage an.")
    private File credentialsFile;

}

Einige weitere Konfigurationen sind für den GCS notwendig.

@Configuration
@ConditionalOnGoogleCloudStorage
public class GoogleCloudStorageConfiguration {

    @Bean
    @Autowired
    public ServiceAccountCredentials credentials(GoogleCloudStorageProperties googleCloudStorageProperties) 
            throws IOException {
        return ServiceAccountCredentials.fromStream(
                new FileInputStream(googleCloudStorageProperties.getCredentialsFile()));
    }

    @Bean
    @Autowired
    public Storage storage(ServiceAccountCredentials credentials) throws IOException {
        final Storage storage = StorageOptions.newBuilder()
                .setCredentials(credentials)
                .setProjectId(credentials.getProjectId())
                .build()
                .getService();
        return storage;
    }

    @Bean("gcsBucketName")
    @Autowired
    public String bucketName(ServiceAccountCredentials credentials){
        return credentials.getProjectId() + "-mytestbucket";
    }
}

Und natürlich die Implementierung des Storage-Interfaces:

@Component
@ConditionalOnGoogleCloudStorage
@Log4j2
public class GoogleCloudStorage implements Storage {
    /**
     * Byte-Limit für kleine Dateien.<br>
     * Dateien, die größer sind, werden nicht über den Speicher geladen und 
     * auf Google kopiert, sondern mit einem speziellen asynchronen Verfahren.
     */
    public static final long SMALL_FILE_SIZE_LIMIT = 1_000_000;
    public static final int LARGE_FILE_BUFFER_SIZE = 8192;
    
    private final Storage storage;
    private final String bucketName;

    @Autowired
    public GoogleCloudStorage(final Storage storage,
                              @Qualifier("gcsBucketName") final String bucketName) {
        this.storage = storage;
        this.bucketName = bucketName;
    }

    public BlobId blobId(String remotePath) {
        return BlobId.of(bucketName, remotePath);
    }

    @Override
    public void write(String fileName, byte[] content) throws IOException {
        log.info("Schreibe Datei '{}'.", fileName);
        final long start = System.currentTimeMillis();
        final BlobId blobId = blobId(fileName);
        final BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("application/json").build();
        if (content.length > SMALL_FILE_SIZE_LIMIT) {
            copyLargeFile(content, blobInfo);
        } else {
            // create the blob in one request.
            storage.create(blobInfo, content);
        }
        log.info("Datei '{}' in {} ms geschrieben.", fileName, System.currentTimeMillis() - start);
    }

    private void copyLargeFile(byte[] content, BlobInfo blobInfo) throws IOException {
        // When content is not available or large (1MB or more) it is recommended
        // to write it in chunks via the blob's channel writer.
        try (WriteChannel writer = storage.writer(blobInfo)) {
            final byte[] buffer = new byte[LARGE_FILE_BUFFER_SIZE];
            try (InputStream input = new BufferedInputStream(new ByteArrayInputStream(content))) {
                int limit;
                while ((limit = input.read(buffer)) >= 0) {
                    writer.write(ByteBuffer.wrap(buffer, 0, limit));
                }
            }
        }
    }
}

Die Konfiguration hierzu sieht dann wie folgt aus:

myapp:
    google-cloud-storage:
        credentials-file: ./my-gcs-credentials.json

Wie man sehen kann, erhalten alle Teilkomponenten, Konfigurationen und Properties die Annotation @ConditionalOnGoogleCloudStorage, damit sie nur dann aktiv werden, wenn die entsprechende Konfiguration vorhanden ist. Würde man das z. B. an den Properties auslassen, dann würden diese immer initialisiert werden und zu Fehlern führen.

Nun fehlt aber noch eine komfortable Möglichkeit, auf die Storages aus anderen Applikationsteilen zuzugreifen. Hierzu definieren wir eine Klasse, welche den Zugriff auf Storage-Implementierungen kapselt, insbesondere, wenn beide Storages aktiv sind:

@Service
@Log4j2
public class StorageManager {
    private final List<Storage> storages;

    @Autowired
    public StorageManager(final List<Storage> storages) {
        if (storages == null || storages.isEmpty()) {
            throw new IllegalStateException("Kein Storage gefunden. Es wurden wohl keine Storages konfiguriert.");
        }
        this.storages = new ArrayList<>(storages);
        log.info("Aktive Storages: {}", storages.stream().map(storage -> storage.getClass().getSimpleName()).toList());
    }

    /**
     * Schreibt die angegebenen Daten als Datei in die Storages.
     *
     * @param fileName Dateiname auf der Storage
     * @param content  Inhalt der Datei
     * @throws IOException Bei IO-Fehlern
     */
    public void write(final String fileName, final byte[] content) throws IOException {
        for (final Storage storage : storages) {
            storage.write(fileName, content);
        }
    }
}

Der StorageManager erhält bei der Initialisierung alle im Applicationkontext vorhandenen und aktiven Storages. Und welche aktiv sind, oder nicht, bestimmen wir über die Konfiguration. Dass der StorageManager eine Exception wirft, wenn keine Storages konfiguriert sind, ist optional und kann je nach Anwendungsfall angepasst werden.