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.